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:
@@ -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
95
tests/test_diagnostics.py
Normal 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)
|
||||
57
tests/test_logging_config.py
Normal file
57
tests/test_logging_config.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user