2026-06-26 17:02:21 +08:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-06-26 22:36:09 +08:00
|
|
|
import logging
|
|
|
|
|
|
|
|
|
|
from botocore.exceptions import ClientError
|
2026-06-26 17:02:21 +08:00
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
2026-06-27 14:32:52 +08:00
|
|
|
from nexus_claude_api.errors import NexusClaudeError
|
2026-06-26 17:02:21 +08:00
|
|
|
from nexus_claude_api.config import Settings
|
2026-06-26 22:36:09 +08:00
|
|
|
from nexus_claude_api.nexus_client import _map_client_error
|
2026-06-26 17:02:21 +08:00
|
|
|
from nexus_claude_api.server import create_app
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FakeNexusClient:
|
2026-06-26 22:36:09 +08:00
|
|
|
def converse(self, request: dict, *, correlation_id: str | None = None) -> dict:
|
|
|
|
|
assert correlation_id
|
2026-06-26 17:02:21 +08:00
|
|
|
assert request["messages"][0]["content"][0]["text"] == "Hello"
|
|
|
|
|
return {
|
|
|
|
|
"ResponseMetadata": {"RequestId": "req-route"},
|
|
|
|
|
"output": {"message": {"content": [{"text": "Hi"}]}},
|
|
|
|
|
"stopReason": "end_turn",
|
|
|
|
|
"usage": {"inputTokens": 3, "outputTokens": 1},
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 22:36:09 +08:00
|
|
|
def converse_stream(self, request: dict, *, correlation_id: str | None = None):
|
|
|
|
|
assert correlation_id
|
2026-06-26 17:02:21 +08:00
|
|
|
assert request["messages"][0]["content"][0]["text"] == "Hello"
|
|
|
|
|
return [
|
|
|
|
|
{"contentBlockDelta": {"contentBlockIndex": 0, "delta": {"text": "Hi"}}},
|
|
|
|
|
{"contentBlockStop": {"contentBlockIndex": 0}},
|
|
|
|
|
{"messageStop": {"stopReason": "end_turn"}},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
2026-06-26 22:36:09 +08:00
|
|
|
class AccessDeniedNexusClient:
|
|
|
|
|
def converse(self, request: dict, *, correlation_id: str | None = None) -> dict:
|
|
|
|
|
raise _map_client_error(
|
|
|
|
|
ClientError(
|
|
|
|
|
{
|
|
|
|
|
"Error": {
|
|
|
|
|
"Code": "AccessDeniedException",
|
|
|
|
|
"Message": "not allowed",
|
|
|
|
|
},
|
|
|
|
|
"ResponseMetadata": {
|
|
|
|
|
"HTTPStatusCode": 403,
|
|
|
|
|
"RequestId": "req-denied",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
"Converse",
|
|
|
|
|
),
|
|
|
|
|
operation="converse",
|
|
|
|
|
correlation_id=correlation_id,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-06-27 14:32:52 +08:00
|
|
|
class StreamErrorNexusClient:
|
|
|
|
|
def converse_stream(self, request: dict, *, correlation_id: str | None = None):
|
|
|
|
|
assert correlation_id
|
|
|
|
|
|
|
|
|
|
def events():
|
|
|
|
|
yield {"contentBlockDelta": {"contentBlockIndex": 0, "delta": {"text": "Hi"}}}
|
|
|
|
|
raise NexusClaudeError(
|
|
|
|
|
"stream failed",
|
|
|
|
|
status_code=502,
|
|
|
|
|
error_type="api_error",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return events()
|
|
|
|
|
|
|
|
|
|
|
2026-06-26 22:36:09 +08:00
|
|
|
def client(nexus_client: object | None = None) -> TestClient:
|
2026-06-26 17:02:21 +08:00
|
|
|
settings = Settings.from_values(api_key="test", require_api_key=False)
|
2026-06-26 22:36:09 +08:00
|
|
|
return TestClient(
|
|
|
|
|
create_app(settings=settings, nexus_client=nexus_client or FakeNexusClient())
|
|
|
|
|
)
|
2026-06-26 17:02:21 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_health() -> None:
|
|
|
|
|
response = client().get("/health")
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
assert response.json() == {"status": "ok"}
|
|
|
|
|
|
|
|
|
|
|
2026-06-26 22:36:09 +08:00
|
|
|
def test_root_head() -> None:
|
|
|
|
|
response = client().head("/")
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
assert response.content == b""
|
|
|
|
|
|
|
|
|
|
|
2026-06-26 17:02:21 +08:00
|
|
|
def test_models() -> None:
|
|
|
|
|
response = client().get("/v1/models")
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
data = response.json()["data"]
|
2026-06-26 22:36:09 +08:00
|
|
|
assert [model["id"] for model in data] == ["claude-opus-4.6"]
|
2026-06-26 17:02:21 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_messages_non_stream() -> None:
|
|
|
|
|
response = client().post(
|
|
|
|
|
"/v1/messages",
|
|
|
|
|
json={
|
2026-06-26 22:36:09 +08:00
|
|
|
"model": "claude-opus-4.6",
|
2026-06-26 17:02:21 +08:00
|
|
|
"messages": [{"role": "user", "content": "Hello"}],
|
|
|
|
|
"max_tokens": 32,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
body = response.json()
|
|
|
|
|
assert body["id"] == "req-route"
|
|
|
|
|
assert body["content"][0]["text"] == "Hi"
|
|
|
|
|
|
|
|
|
|
|
2026-06-26 22:36:09 +08:00
|
|
|
def test_messages_normalizes_legacy_system_role() -> None:
|
|
|
|
|
response = client().post(
|
|
|
|
|
"/v1/messages",
|
|
|
|
|
json={
|
|
|
|
|
"model": "claude-opus-4.6",
|
|
|
|
|
"messages": [
|
|
|
|
|
{"role": "system", "content": "You are helpful."},
|
|
|
|
|
{"role": "user", "content": "Hello"},
|
|
|
|
|
],
|
|
|
|
|
"max_tokens": 32,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
assert response.json()["content"][0]["text"] == "Hi"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_messages_upstream_error_returns_correlation_id() -> None:
|
|
|
|
|
response = client(AccessDeniedNexusClient()).post(
|
|
|
|
|
"/v1/messages",
|
|
|
|
|
json={
|
|
|
|
|
"model": "claude-opus-4.6",
|
|
|
|
|
"messages": [{"role": "user", "content": "Hello"}],
|
|
|
|
|
"max_tokens": 32,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 403
|
|
|
|
|
error = response.json()["error"]
|
|
|
|
|
assert error["type"] == "api_error"
|
|
|
|
|
assert error["correlation_id"]
|
|
|
|
|
assert f"correlation_id={error['correlation_id']}" in error["message"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_client_error_mapping_logs_sanitized_details(caplog) -> None:
|
|
|
|
|
caplog.set_level(logging.WARNING, logger="nexus_claude_api.nexus_client")
|
|
|
|
|
|
|
|
|
|
mapped = _map_client_error(
|
|
|
|
|
ClientError(
|
|
|
|
|
{
|
|
|
|
|
"Error": {
|
|
|
|
|
"Code": "AccessDeniedException",
|
|
|
|
|
"Message": "model access denied",
|
|
|
|
|
},
|
|
|
|
|
"ResponseMetadata": {
|
|
|
|
|
"HTTPStatusCode": 400,
|
|
|
|
|
"RequestId": "req-log",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
"Converse",
|
|
|
|
|
),
|
|
|
|
|
operation="converse",
|
|
|
|
|
correlation_id="corr-log",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert mapped.status_code == 403
|
|
|
|
|
assert mapped.error_type == "api_error"
|
|
|
|
|
record = caplog.records[-1]
|
|
|
|
|
assert record.levelno == logging.WARNING
|
|
|
|
|
assert record.correlation_id == "corr-log"
|
|
|
|
|
assert record.operation == "converse"
|
|
|
|
|
assert record.nexus_error_code == "AccessDeniedException"
|
|
|
|
|
assert record.http_status == 403
|
|
|
|
|
assert record.nexus_request_id == "req-log"
|
|
|
|
|
log_text = caplog.text
|
|
|
|
|
assert "api_key" not in log_text
|
|
|
|
|
assert "Authorization" not in log_text
|
|
|
|
|
assert "aW1hZ2UtYnl0ZXM=" not in log_text
|
|
|
|
|
|
|
|
|
|
|
2026-06-26 17:02:21 +08:00
|
|
|
def test_messages_stream() -> None:
|
|
|
|
|
with client().stream(
|
|
|
|
|
"POST",
|
|
|
|
|
"/v1/messages",
|
|
|
|
|
json={
|
2026-06-26 22:36:09 +08:00
|
|
|
"model": "claude-opus-4.6",
|
2026-06-26 17:02:21 +08:00
|
|
|
"messages": [{"role": "user", "content": "Hello"}],
|
|
|
|
|
"max_tokens": 32,
|
|
|
|
|
"stream": True,
|
|
|
|
|
},
|
|
|
|
|
) as response:
|
|
|
|
|
body = response.read().decode("utf-8")
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
assert "event: message_start" in body
|
|
|
|
|
assert "event: content_block_delta" in body
|
|
|
|
|
assert "event: message_stop" in body
|
|
|
|
|
|
|
|
|
|
|
2026-06-27 14:32:52 +08:00
|
|
|
def test_messages_stream_returns_sse_error_when_stream_iteration_fails() -> None:
|
|
|
|
|
with client(StreamErrorNexusClient()).stream(
|
|
|
|
|
"POST",
|
|
|
|
|
"/v1/messages",
|
|
|
|
|
json={
|
|
|
|
|
"model": "claude-opus-4.6",
|
|
|
|
|
"messages": [{"role": "user", "content": "Hello"}],
|
|
|
|
|
"max_tokens": 32,
|
|
|
|
|
"stream": True,
|
|
|
|
|
},
|
|
|
|
|
) as response:
|
|
|
|
|
body = response.read().decode("utf-8")
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
assert "event: content_block_delta" in body
|
|
|
|
|
assert "event: error" in body
|
|
|
|
|
assert "stream failed" in body
|
|
|
|
|
assert "correlation_id" in body
|
|
|
|
|
|
|
|
|
|
|
2026-06-26 17:02:21 +08:00
|
|
|
def test_count_tokens() -> None:
|
|
|
|
|
response = client().post(
|
|
|
|
|
"/v1/messages/count_tokens",
|
|
|
|
|
json={
|
2026-06-26 22:36:09 +08:00
|
|
|
"model": "claude-opus-4.6",
|
2026-06-26 17:02:21 +08:00
|
|
|
"messages": [{"role": "user", "content": "Hello"}],
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
assert response.json()["input_tokens"] > 0
|