Refactor models and logging for nexus-claude-api

- Update default models in config to use `claude-opus-4.6`.
- Introduce logging configuration to write logs to a file.
- Add correlation ID to error responses for better traceability.
- Implement diagnostics for summarizing message requests.
- Normalize legacy system messages in the API.
- Enhance tests to cover new logging and error handling features.
- Update README and documentation to reflect changes in model defaults and logging behavior.
This commit is contained in:
2026-06-26 22:36:09 +08:00
parent 2851fa01cf
commit 0e98ce57d4
21 changed files with 573 additions and 78 deletions

View File

@@ -19,8 +19,24 @@ def test_claude_code_command() -> None:
command = generate_claude_code_powershell(settings)
assert "ANTHROPIC_BASE_URL" in command
assert "claude-sonnet-4.6" in command
assert command.endswith("claude")
assert "ANTHROPIC_AUTH_TOKEN='dummy'" in command
assert "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY='1'" in command
assert "claude-opus-4.6" in command
assert command.endswith("claude --model claude-opus-4.6")
def test_claude_code_command_uses_custom_model() -> None:
settings = Settings.from_values(
host="127.0.0.1",
port=4141,
api_key="test",
model="claude-opus-4.6",
require_api_key=False,
)
command = generate_claude_code_powershell(settings)
assert command.endswith("claude --model claude-opus-4.6")
def test_missing_api_key_fails(monkeypatch) -> None:

95
tests/test_diagnostics.py Normal file
View File

@@ -0,0 +1,95 @@
from __future__ import annotations
import base64
from nexus_claude_api.diagnostics import summarize_messages_request
from nexus_claude_api.models import AnthropicMessagesRequest
def test_text_request_summary_omits_prompt_text() -> None:
payload = AnthropicMessagesRequest.model_validate(
{
"model": "claude-opus-4.6",
"messages": [{"role": "user", "content": "secret prompt"}],
"max_tokens": 32,
}
)
summary = summarize_messages_request(payload, correlation_id="corr-1")
assert summary["correlation_id"] == "corr-1"
assert summary["model"] == "claude-opus-4.6"
assert summary["backend_model"] == "claude-opus-4.6"
assert summary["stream"] is False
assert summary["message_count"] == 1
assert summary["content_block_types"] == {"text": 1}
assert "secret prompt" not in repr(summary)
def test_image_request_summary_omits_base64_and_records_size() -> None:
image_data = base64.b64encode(b"image-bytes").decode("ascii")
payload = AnthropicMessagesRequest.model_validate(
{
"model": "claude-opus-4.6",
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "describe"},
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": image_data,
},
},
],
}
],
"max_tokens": 32,
}
)
summary = summarize_messages_request(payload, correlation_id="corr-2")
assert summary["content_block_types"] == {"image": 1, "text": 1}
assert summary["images"] == [{"media_type": "image/png", "byte_size": 11}]
assert image_data not in repr(summary)
assert "image-bytes" not in repr(summary)
def test_tool_request_summary_omits_tool_inputs() -> None:
payload = AnthropicMessagesRequest.model_validate(
{
"model": "claude-opus-4.6",
"messages": [
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "toolu_1",
"name": "lookup_secret",
"input": {"api_key": "secret-tool-input"},
}
],
}
],
"tools": [
{
"name": "lookup_secret",
"input_schema": {"type": "object"},
}
],
"tool_choice": {"type": "tool", "name": "lookup_secret"},
"max_tokens": 32,
}
)
summary = summarize_messages_request(payload, correlation_id="corr-3")
assert summary["tools"] == {"count": 1, "names": ["lookup_secret"]}
assert summary["tool_choice_type"] == "tool"
assert "secret-tool-input" not in repr(summary)
assert "api_key" not in repr(summary)

View File

@@ -0,0 +1,57 @@
from __future__ import annotations
import logging
from nexus_claude_api.logging_config import configure_logging
def test_configure_logging_writes_app_logs_to_file_without_console(
tmp_path,
capsys,
) -> None:
log_file = tmp_path / "nexus-claude-api.log"
logger = logging.getLogger("nexus_claude_api.routes.messages")
app_logger = logging.getLogger("nexus_claude_api")
original_handlers = app_logger.handlers[:]
original_level = app_logger.level
original_propagate = app_logger.propagate
try:
configure_logging(verbose=False, log_file=log_file)
logger.info("detailed app request")
logger.debug("debug details hidden without verbose")
output = capsys.readouterr()
log_text = log_file.read_text(encoding="utf-8")
assert output.err == ""
assert "detailed app request" in log_text
assert "debug details hidden without verbose" not in log_text
finally:
app_logger.handlers.clear()
app_logger.handlers.extend(original_handlers)
app_logger.setLevel(original_level)
app_logger.propagate = original_propagate
def test_configure_logging_verbose_writes_debug_app_logs_to_file(
tmp_path,
) -> None:
log_file = tmp_path / "nexus-claude-api.log"
logger = logging.getLogger("nexus_claude_api.routes.messages")
app_logger = logging.getLogger("nexus_claude_api")
original_handlers = app_logger.handlers[:]
original_level = app_logger.level
original_propagate = app_logger.propagate
try:
configure_logging(verbose=True, log_file=log_file)
logger.debug("debug details stay in file")
assert "debug details stay in file" in log_file.read_text(encoding="utf-8")
finally:
app_logger.handlers.clear()
app_logger.handlers.extend(original_handlers)
app_logger.setLevel(original_level)
app_logger.propagate = original_propagate

View File

@@ -1,13 +1,18 @@
from __future__ import annotations
import logging
from botocore.exceptions import ClientError
from fastapi.testclient import TestClient
from nexus_claude_api.config import Settings
from nexus_claude_api.nexus_client import _map_client_error
from nexus_claude_api.server import create_app
class FakeNexusClient:
def converse(self, request: dict) -> dict:
def converse(self, request: dict, *, correlation_id: str | None = None) -> dict:
assert correlation_id
assert request["messages"][0]["content"][0]["text"] == "Hello"
return {
"ResponseMetadata": {"RequestId": "req-route"},
@@ -16,7 +21,8 @@ class FakeNexusClient:
"usage": {"inputTokens": 3, "outputTokens": 1},
}
def converse_stream(self, request: dict):
def converse_stream(self, request: dict, *, correlation_id: str | None = None):
assert correlation_id
assert request["messages"][0]["content"][0]["text"] == "Hello"
return [
{"contentBlockDelta": {"contentBlockIndex": 0, "delta": {"text": "Hi"}}},
@@ -25,9 +31,32 @@ class FakeNexusClient:
]
def client() -> TestClient:
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,
)
def client(nexus_client: object | None = None) -> TestClient:
settings = Settings.from_values(api_key="test", require_api_key=False)
return TestClient(create_app(settings=settings, nexus_client=FakeNexusClient()))
return TestClient(
create_app(settings=settings, nexus_client=nexus_client or FakeNexusClient())
)
def test_health() -> None:
@@ -36,22 +65,24 @@ def test_health() -> None:
assert response.json() == {"status": "ok"}
def test_root_head() -> None:
response = client().head("/")
assert response.status_code == 200
assert response.content == b""
def test_models() -> None:
response = client().get("/v1/models")
assert response.status_code == 200
data = response.json()["data"]
assert {model["id"] for model in data} >= {
"claude-sonnet-4.6",
"claude-opus-4.6",
"claude-haiku-4.5",
}
assert [model["id"] for model in data] == ["claude-opus-4.6"]
def test_messages_non_stream() -> None:
response = client().post(
"/v1/messages",
json={
"model": "claude-sonnet-4.6",
"model": "claude-opus-4.6",
"messages": [{"role": "user", "content": "Hello"}],
"max_tokens": 32,
},
@@ -63,12 +94,82 @@ def test_messages_non_stream() -> None:
assert body["content"][0]["text"] == "Hi"
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
def test_messages_stream() -> None:
with client().stream(
"POST",
"/v1/messages",
json={
"model": "claude-sonnet-4.6",
"model": "claude-opus-4.6",
"messages": [{"role": "user", "content": "Hello"}],
"max_tokens": 32,
"stream": True,
@@ -86,11 +187,10 @@ def test_count_tokens() -> None:
response = client().post(
"/v1/messages/count_tokens",
json={
"model": "claude-sonnet-4.6",
"model": "claude-opus-4.6",
"messages": [{"role": "user", "content": "Hello"}],
},
)
assert response.status_code == 200
assert response.json()["input_tokens"] > 0

View File

@@ -11,7 +11,7 @@ from nexus_claude_api.translators.stream import bedrock_stream_to_anthropic_even
def test_text_request_translation() -> None:
payload = AnthropicMessagesRequest.model_validate(
{
"model": "claude-sonnet-4.6",
"model": "claude-opus-4.6",
"messages": [{"role": "user", "content": "Hello"}],
"system": "You are helpful.",
"max_tokens": 100,
@@ -21,7 +21,7 @@ def test_text_request_translation() -> None:
request = anthropic_to_bedrock_request(payload)
assert request["modelId"] == "claude-sonnet-4.6"
assert request["modelId"] == "claude-opus-4.6"
assert request["messages"] == [{"role": "user", "content": [{"text": "Hello"}]}]
assert request["system"] == [{"text": "You are helpful."}]
assert request["inferenceConfig"]["maxTokens"] == 100
@@ -32,7 +32,7 @@ def test_image_request_translation() -> None:
image_data = base64.b64encode(b"image-bytes").decode("ascii")
payload = AnthropicMessagesRequest.model_validate(
{
"model": "claude-sonnet-4.6",
"model": "claude-opus-4.6",
"messages": [
{
"role": "user",
@@ -63,7 +63,7 @@ def test_image_request_translation() -> None:
def test_tool_translation() -> None:
payload = AnthropicMessagesRequest.model_validate(
{
"model": "claude-sonnet-4.6",
"model": "claude-opus-4.6",
"messages": [
{"role": "user", "content": "Use a tool"},
{
@@ -119,7 +119,7 @@ def test_bedrock_response_translation() -> None:
"usage": {"inputTokens": 10, "outputTokens": 3},
}
translated = bedrock_to_anthropic_response(response, model="claude-sonnet-4.6")
translated = bedrock_to_anthropic_response(response, model="claude-opus-4.6")
assert translated.id == "req-1"
assert translated.content[0].type == "text"
@@ -137,7 +137,7 @@ def test_stream_translation() -> None:
]
translated = list(
bedrock_stream_to_anthropic_events(events, model="claude-sonnet-4.6")
bedrock_stream_to_anthropic_events(events, model="claude-opus-4.6")
)
assert translated[0]["type"] == "message_start"