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

@@ -13,6 +13,7 @@ from nexus_claude_api.config import (
DEFAULT_SMALL_MODEL,
Settings,
)
from nexus_claude_api.logging_config import configure_logging
from nexus_claude_api.server import create_app
from nexus_claude_api.shell import generate_claude_code_powershell
@@ -73,8 +74,16 @@ def run_start(args: argparse.Namespace) -> int:
if args.dry_run:
return 0
log_file = configure_logging(verbose=settings.verbose)
print(f"Writing detailed logs to {log_file}")
app = create_app(settings)
uvicorn.run(app, host=settings.host, port=settings.port, log_level="debug" if settings.verbose else "info")
uvicorn.run(
app,
host=settings.host,
port=settings.port,
log_level="debug" if settings.verbose else "info",
access_log=True,
)
return 0

View File

@@ -9,19 +9,19 @@ from pathlib import Path
DEFAULT_ENDPOINT_URL = "https://genai-nexus.api.corpinter.net"
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 4141
DEFAULT_MODEL = "claude-sonnet-4.6"
DEFAULT_SMALL_MODEL = "claude-haiku-4.5"
DEFAULT_OPUS_MODEL = "claude-opus-4.6"
DEFAULT_MODEL = DEFAULT_OPUS_MODEL
DEFAULT_SMALL_MODEL = DEFAULT_OPUS_MODEL
LOCAL_CONFIG_FILENAME = "nexus-claude-api.local.json"
MODEL_ID_MAP = {
"claude-sonnet-4.6": "claude-sonnet-4.6",
"claude-opus-4.6": "claude-opus-4.6",
"claude-haiku-4.5": "claude-haiku-4.5",
"claude-sonnet-4": "claude-sonnet-4.6",
"claude-sonnet-4.6": DEFAULT_OPUS_MODEL,
"claude-haiku-4.5": DEFAULT_OPUS_MODEL,
"claude-sonnet-4": DEFAULT_OPUS_MODEL,
"claude-opus-4": "claude-opus-4.6",
"claude-haiku-4": "claude-haiku-4.5",
"claude-haiku-4": DEFAULT_OPUS_MODEL,
}

View File

@@ -0,0 +1,66 @@
from __future__ import annotations
import base64
from collections import Counter
from typing import Any
from nexus_claude_api.config import resolve_backend_model
from nexus_claude_api.models import (
AnthropicImageBlock,
AnthropicMessage,
AnthropicMessagesRequest,
)
def summarize_messages_request(
payload: AnthropicMessagesRequest,
*,
correlation_id: str,
) -> dict[str, Any]:
content_types: Counter[str] = Counter()
images: list[dict[str, Any]] = []
for message in payload.messages:
_summarize_message(message, content_types=content_types, images=images)
return {
"correlation_id": correlation_id,
"model": payload.model,
"backend_model": resolve_backend_model(payload.model),
"stream": bool(payload.stream),
"message_count": len(payload.messages),
"content_block_types": dict(sorted(content_types.items())),
"images": images,
"tools": {
"count": len(payload.tools or []),
"names": [tool.name for tool in payload.tools or []],
},
"tool_choice_type": payload.tool_choice.type if payload.tool_choice else None,
}
def _summarize_message(
message: AnthropicMessage,
*,
content_types: Counter[str],
images: list[dict[str, Any]],
) -> None:
if isinstance(message.content, str):
content_types["text"] += 1
return
for block in message.content:
content_types[block.type] += 1
if isinstance(block, AnthropicImageBlock):
images.append(_summarize_image(block))
def _summarize_image(block: AnthropicImageBlock) -> dict[str, Any]:
summary: dict[str, Any] = {
"media_type": block.source.media_type,
}
try:
summary["byte_size"] = len(base64.b64decode(block.source.data, validate=True))
except Exception:
summary["byte_size"] = None
return summary

View File

@@ -23,15 +23,23 @@ def anthropic_error_response(
*,
status_code: int = 400,
error_type: str = "invalid_request_error",
correlation_id: str | None = None,
) -> JSONResponse:
error = {
"type": error_type,
"message": (
f"{message} [correlation_id={correlation_id}]"
if correlation_id
else message
),
}
if correlation_id:
error["correlation_id"] = correlation_id
return JSONResponse(
status_code=status_code,
content={
"type": "error",
"error": {
"type": error_type,
"message": message,
},
"error": error,
},
)
@@ -43,4 +51,3 @@ def map_http_exception(exc: HTTPException) -> JSONResponse:
status_code=exc.status_code,
error_type="invalid_request_error",
)

View File

@@ -0,0 +1,26 @@
from __future__ import annotations
import logging
from pathlib import Path
LOG_DIR = Path("logs")
LOG_FILE = LOG_DIR / "nexus-claude-api.log"
LOG_FORMAT = "%(asctime)s %(levelname)s %(name)s: %(message)s"
def configure_logging(*, verbose: bool = False, log_file: Path = LOG_FILE) -> Path:
log_file.parent.mkdir(parents=True, exist_ok=True)
formatter = logging.Formatter(LOG_FORMAT)
file_handler = logging.FileHandler(log_file, encoding="utf-8")
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(formatter)
app_logger = logging.getLogger("nexus_claude_api")
app_logger.handlers.clear()
app_logger.setLevel(logging.DEBUG if verbose else logging.INFO)
app_logger.addHandler(file_handler)
app_logger.propagate = False
return log_file

View File

@@ -118,20 +118,9 @@ class CountTokensResponse(BaseModel):
SUPPORTED_MODELS = [
{
"id": "claude-sonnet-4.6",
"display_name": "Claude Sonnet 4.6",
"owned_by": "anthropic",
},
{
"id": "claude-opus-4.6",
"display_name": "Claude Opus 4.6",
"owned_by": "anthropic",
},
{
"id": "claude-haiku-4.5",
"display_name": "Claude Haiku 4.5",
"owned_by": "anthropic",
},
]

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import logging
import os
from typing import Any
@@ -10,6 +11,9 @@ from nexus_claude_api.config import Settings
from nexus_claude_api.errors import NexusClaudeError
logger = logging.getLogger(__name__)
class NexusClient:
def __init__(self, settings: Settings) -> None:
if settings.api_key:
@@ -21,11 +25,20 @@ class NexusClient:
region_name="nexus",
)
def converse(self, request: dict[str, Any]) -> dict[str, Any]:
def converse(
self,
request: dict[str, Any],
*,
correlation_id: str | None = None,
) -> dict[str, Any]:
try:
return self._client.converse(**request)
except ClientError as exc:
raise _map_client_error(exc) from exc
raise _map_client_error(
exc,
operation="converse",
correlation_id=correlation_id,
) from exc
except BotoCoreError as exc:
raise NexusClaudeError(
"Failed to call Nexus Converse API",
@@ -33,12 +46,21 @@ class NexusClient:
error_type="api_error",
) from exc
def converse_stream(self, request: dict[str, Any]) -> Any:
def converse_stream(
self,
request: dict[str, Any],
*,
correlation_id: str | None = None,
) -> Any:
try:
response = self._client.converse_stream(**request)
return response.get("stream", [])
except ClientError as exc:
raise _map_client_error(exc) from exc
raise _map_client_error(
exc,
operation="converse_stream",
correlation_id=correlation_id,
) from exc
except BotoCoreError as exc:
raise NexusClaudeError(
"Failed to call Nexus Converse Stream API",
@@ -47,14 +69,33 @@ class NexusClient:
) from exc
def _map_client_error(exc: ClientError) -> NexusClaudeError:
def _map_client_error(
exc: ClientError,
*,
operation: str,
correlation_id: str | None = None,
) -> NexusClaudeError:
error = exc.response.get("Error", {})
code = error.get("Code", "")
message = error.get("Message", "Nexus API request failed")
status_code = int(exc.response.get("ResponseMetadata", {}).get("HTTPStatusCode", 502))
metadata = exc.response.get("ResponseMetadata", {})
status_code = int(metadata.get("HTTPStatusCode", 502))
request_id = metadata.get("RequestId")
if code in {"AccessDeniedException", "UnrecognizedClientException"}:
status_code = 403
elif code in {"ThrottlingException", "TooManyRequestsException"}:
status_code = 429
log_fields = {
"correlation_id": correlation_id,
"operation": operation,
"nexus_error_code": code,
"nexus_error_message": message,
"http_status": status_code,
"nexus_request_id": request_id,
}
logger.warning(
"nexus_client_error %s",
log_fields,
extra=log_fields,
)
return NexusClaudeError(message, status_code=status_code, error_type="api_error")

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
from fastapi import APIRouter
from fastapi.responses import Response
router = APIRouter()
@@ -11,7 +12,11 @@ def root() -> dict[str, str]:
return {"status": "ok", "service": "nexus-claude-api"}
@router.head("/")
def root_head() -> Response:
return Response(status_code=200)
@router.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}

View File

@@ -1,11 +1,14 @@
from __future__ import annotations
import logging
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse, Response, StreamingResponse
from pydantic import ValidationError
from nexus_claude_api.diagnostics import summarize_messages_request
from nexus_claude_api.errors import NexusClaudeError, anthropic_error_response
from nexus_claude_api.models import (
AnthropicMessagesRequest,
@@ -23,23 +26,64 @@ from nexus_claude_api.translators.stream import (
router = APIRouter()
logger = logging.getLogger(__name__)
def get_nexus_client(request: Request) -> NexusClient:
return request.app.state.nexus_client
def normalize_legacy_system_messages(raw: object) -> object:
if not isinstance(raw, dict):
return raw
messages = raw.get("messages")
if not isinstance(messages, list):
return raw
system_parts: list[object] = []
normalized_messages: list[object] = []
changed = False
for message in messages:
if isinstance(message, dict) and message.get("role") == "system":
system_parts.append(message.get("content", ""))
changed = True
else:
normalized_messages.append(message)
if not changed:
return raw
normalized = dict(raw)
normalized["messages"] = normalized_messages
if "system" not in normalized and system_parts:
normalized["system"] = system_parts[0] if len(system_parts) == 1 else system_parts
return normalized
@router.post("/v1/messages", response_model=None)
async def create_message(
request: Request,
client: Annotated[NexusClient, Depends(get_nexus_client)],
) -> Response:
correlation_id = uuid.uuid4().hex
try:
raw = await request.json()
raw = normalize_legacy_system_messages(await request.json())
payload = AnthropicMessagesRequest.model_validate(raw)
summary = summarize_messages_request(
payload,
correlation_id=correlation_id,
)
logger.info(
"anthropic_messages_request %s",
summary,
extra=summary,
)
bedrock_request = anthropic_to_bedrock_request(payload)
if payload.stream:
stream = client.converse_stream(bedrock_request)
stream = client.converse_stream(
bedrock_request,
correlation_id=correlation_id,
)
return StreamingResponse(
(
sse_frame(event)
@@ -51,26 +95,34 @@ async def create_message(
media_type="text/event-stream",
)
response = client.converse(bedrock_request)
response = client.converse(
bedrock_request,
correlation_id=correlation_id,
)
anthropic_response = bedrock_to_anthropic_response(
response,
model=payload.model,
)
return JSONResponse(content=anthropic_response.model_dump(exclude_none=True))
except ValidationError as exc:
return anthropic_error_response(str(exc), status_code=400)
return anthropic_error_response(
str(exc),
status_code=400,
correlation_id=correlation_id,
)
except NexusClaudeError as exc:
return anthropic_error_response(
exc.message,
status_code=exc.status_code,
error_type=exc.error_type,
correlation_id=correlation_id,
)
@router.post("/v1/messages/count_tokens")
async def count_tokens(request: Request) -> JSONResponse:
try:
raw = await request.json()
raw = normalize_legacy_system_messages(await request.json())
payload = CountTokensRequest.model_validate(raw)
response = CountTokensResponse(input_tokens=estimate_input_tokens(payload))
return JSONResponse(content=response.model_dump())

View File

@@ -1,7 +1,5 @@
from __future__ import annotations
import logging
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.requests import Request
@@ -44,9 +42,4 @@ def create_app(
error_type=exc.error_type,
)
logging.basicConfig(
level=logging.DEBUG if resolved_settings.verbose else logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
return app

View File

@@ -14,7 +14,7 @@ def generate_claude_code_powershell(settings: Settings) -> str:
"ANTHROPIC_DEFAULT_HAIKU_MODEL": settings.small_model,
"DISABLE_NON_ESSENTIAL_MODEL_CALLS": "1",
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
"CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY": "1",
}
assignments = [f"$env:{key}='{value}'" for key, value in env.items()]
return "\n".join([*assignments, "claude"])
return "\n".join([*assignments, f"claude --model {settings.model}"])