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:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
66
src/nexus_claude_api/diagnostics.py
Normal file
66
src/nexus_claude_api/diagnostics.py
Normal 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
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
26
src/nexus_claude_api/logging_config.py
Normal file
26
src/nexus_claude_api/logging_config.py
Normal 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
|
||||
@@ -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",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}"])
|
||||
|
||||
Reference in New Issue
Block a user