fix(deps): upgrade OpenAI stack and resolve dependency vulnerabilities
This commit is contained in:
1 parent
bb873ec0e0
commit
6b2c0f199d
14 files changed
+2738
-2732
No files matched your search
@@ -0,0 +1,75 @@
|
||||
"""Regression coverage for the prototype's python-jose to PyJWT migration."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from types import ModuleType, SimpleNamespace
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
import jwt
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth(monkeypatch):
|
||||
# Load only the authentication module, without Docker, GCP or real settings.
|
||||
config = ModuleType("app.config")
|
||||
config.settings = SimpleNamespace(
|
||||
JWT_SECRET_KEY="test-only-signing-key-with-at-least-32-bytes",
|
||||
JWT_ALGORITHM="HS256",
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=60,
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "app.config", config)
|
||||
path = (
|
||||
Path(__file__).resolve().parents[2] / "playground/backend_manager/app/auth/jwt_handler.py"
|
||||
)
|
||||
spec = importlib.util.spec_from_file_location("backend_manager_jwt_test", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_signed_token_preserves_claims_and_authenticated_user(auth):
|
||||
token = auth.create_access_token("user@example.com", {"role": "tester"})
|
||||
claims = auth.decode_access_token(token)
|
||||
assert claims["sub"] == "user@example.com"
|
||||
assert claims["role"] == "tester"
|
||||
assert claims["iss"] == "artemis-backend-manager"
|
||||
assert claims["exp"] > claims["iat"]
|
||||
credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials=token)
|
||||
assert await auth.get_current_user(credentials) == "user@example.com"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invalid", ["expired", "signature", "algorithm", "unsigned", "malformed"])
|
||||
def test_invalid_tokens_remain_unauthorized(auth, invalid):
|
||||
claims = {"sub": "user", "exp": datetime.now(UTC) + timedelta(minutes=5)}
|
||||
key = auth.settings.JWT_SECRET_KEY
|
||||
algorithm = "HS256"
|
||||
if invalid == "expired":
|
||||
claims["exp"] = datetime.now(UTC) - timedelta(minutes=5)
|
||||
elif invalid == "signature":
|
||||
key = "different-test-only-signing-key-with-32-bytes"
|
||||
elif invalid == "algorithm":
|
||||
algorithm = "HS384"
|
||||
key = "another-test-only-key-long-enough-for-hs384-signatures"
|
||||
elif invalid == "unsigned":
|
||||
algorithm, key = "none", None
|
||||
token = jwt.encode(claims, key, algorithm=algorithm)
|
||||
if invalid == "malformed":
|
||||
token = "not-a-jwt"
|
||||
with pytest.raises(HTTPException) as error:
|
||||
auth.decode_access_token(token)
|
||||
assert error.value.status_code == 401
|
||||
assert error.value.headers == {"WWW-Authenticate": "Bearer"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_subject_is_rejected(auth):
|
||||
token = jwt.encode({}, auth.settings.JWT_SECRET_KEY, algorithm="HS256")
|
||||
credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials=token)
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await auth.get_current_user(credentials)
|
||||
assert error.value.status_code == 401
|
||||
@@ -5,7 +5,7 @@ import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import httpx
|
||||
import httpx2 as httpx
|
||||
from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, ToolMessage
|
||||
from langchain_core.runnables import RunnableLambda
|
||||
from pydantic import BaseModel, SecretStr
|
||||
@@ -59,26 +59,46 @@ def response_stream(status, arguments, name):
|
||||
]
|
||||
if status:
|
||||
events.append({"type": "response." + status, "response": body})
|
||||
return "".join("data: " + json.dumps(event) + "\n\n" for event in events) + "data: [DONE]\n\n"
|
||||
return (
|
||||
"".join("data: " + json.dumps(event, ensure_ascii=False) + "\n\n" for event in events)
|
||||
+ "data: [DONE]\n\n"
|
||||
)
|
||||
|
||||
|
||||
class FragmentedEventStream(httpx.AsyncByteStream):
|
||||
"""Split SSE frames and multibyte UTF-8 characters at transport boundaries."""
|
||||
|
||||
def __init__(self, text):
|
||||
self.data = text.encode("utf-8")
|
||||
|
||||
async def __aiter__(self):
|
||||
for offset in range(0, len(self.data), 4093):
|
||||
yield self.data[offset : offset + 4093]
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def transport_case(monkeypatch, tmp_path):
|
||||
async def transport_case(monkeypatch, tmp_path, request):
|
||||
from langchain_openai import ChatOpenAI
|
||||
import openai
|
||||
|
||||
requests, outputs, clients, events = [], [], [], []
|
||||
|
||||
def handle(request):
|
||||
payload = json.loads(request.content)
|
||||
def handle(http_request):
|
||||
payload = json.loads(http_request.content)
|
||||
requests.append(payload)
|
||||
status, arguments = outputs.pop(0)
|
||||
name = payload.get("tools", [{"name": "finish"}])[0]["name"]
|
||||
if payload.get("stream"):
|
||||
stream = response_stream(status, arguments, name)
|
||||
body = (
|
||||
{"stream": FragmentedEventStream(stream)}
|
||||
if getattr(request, "param", None) == "fragmented"
|
||||
else {"text": stream}
|
||||
)
|
||||
return httpx.Response(
|
||||
200,
|
||||
text=response_stream(status, arguments, name),
|
||||
headers={"content-type": "text/event-stream"},
|
||||
**body,
|
||||
)
|
||||
return httpx.Response(200, json=response_body(status, arguments, name))
|
||||
|
||||
@@ -175,15 +195,40 @@ async def test_completed_stream_returns_original_valid_arguments(transport_case)
|
||||
assert result.tool_calls[0]["args"] == {"passed": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("transport_case", ["fragmented"], indirect=True)
|
||||
@pytest.mark.parametrize("status", ["completed", None])
|
||||
async def test_large_unicode_tool_stream_requires_completion(transport_case, status):
|
||||
case = transport_case
|
||||
arguments = {"passed": True, "evidence": "截图已核验。\n" * 65536}
|
||||
encoded = json.dumps(arguments, ensure_ascii=False)
|
||||
assert len(encoded.encode("utf-8")) > 1024 * 1024
|
||||
case.outputs.append((status, encoded))
|
||||
|
||||
if status == "completed":
|
||||
result = await wrapped(case).complete([])
|
||||
assert result.response_metadata["status"] == "completed"
|
||||
assert result.tool_calls[0]["args"] == arguments
|
||||
else:
|
||||
with pytest.raises(LLMExhaustedError) as failure:
|
||||
await wrapped(case).complete([])
|
||||
assert failure.value.failure.category is FailureCategory.INVALID_OUTPUT
|
||||
assert len(case.requests) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("recovers", [False, True])
|
||||
@pytest.mark.parametrize("initial_status", [None, "failed"])
|
||||
async def test_integrity_retry_is_bounded_and_keeps_existing_tool_evidence(
|
||||
transport_case, recovers
|
||||
transport_case, recovers, initial_status
|
||||
):
|
||||
case = transport_case
|
||||
case.endpoint.max_retries = 5
|
||||
case.outputs.extend(
|
||||
[(None, '{"passed":false}'), ("completed" if recovers else None, '{"passed":true}')]
|
||||
[
|
||||
(initial_status, '{"passed":false}'),
|
||||
("completed" if recovers else initial_status, '{"passed":true}'),
|
||||
]
|
||||
)
|
||||
messages = [
|
||||
HumanMessage(content="Inspect then verify"),
|
||||
|
||||
Reference in new issue
Block a user