diff --git a/README.md b/README.md index 4248abd..61cba2a 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ uv run nexus-claude-api start --port 4141 --claude-code If the `nexus-claude-api` command is not on your `PATH`, run the module entrypoint instead: ```powershell +py -m nexus_claude_api config set --api-key "your-nexus-api-key" py -m nexus_claude_api start --port 4141 --claude-code ``` diff --git a/docs/PRD.md b/docs/PRD.md index 44810b7..c9166e6 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -68,8 +68,8 @@ The proxy defaults to Opus because this deployment is intended for users whose N - The server binds to `127.0.0.1` by default. - Default startup reads credentials from user config before environment variables. - `--dev` startup reads current-directory `nexus-claude-api.local.json` instead of user config. -- Default logs are written to `~/.config/nexus-claude-api/logs/nexus-claude-api.log`. -- `--dev` logs are written to current-directory `logs/nexus-claude-api.log`. +- Default logs are written to `~/.config/nexus-claude-api/logs/nexus-claude-api-YYYY-MM-DD.log`. +- `--dev` logs are written to current-directory `logs/nexus-claude-api-YYYY-MM-DD.log`. - Missing Nexus credentials fail fast with a clear error. - `GET /health` returns healthy status. - `GET /v1/models` returns the supported Claude models. diff --git a/docs/REQUIREMENTS_DESIGN.md b/docs/REQUIREMENTS_DESIGN.md index 727940f..c60987b 100644 --- a/docs/REQUIREMENTS_DESIGN.md +++ b/docs/REQUIREMENTS_DESIGN.md @@ -87,8 +87,8 @@ Config file paths: Log file paths: -- Default mode: `~/.config/nexus-claude-api/logs/nexus-claude-api.log` -- Development mode with `--dev`: current-directory `logs/nexus-claude-api.log` +- Default mode: `~/.config/nexus-claude-api/logs/nexus-claude-api-YYYY-MM-DD.log` +- Development mode with `--dev`: current-directory `logs/nexus-claude-api-YYYY-MM-DD.log` When `--claude-code` is used, print a PowerShell command that sets: @@ -248,6 +248,6 @@ CLI tests: Logging tests: -- Default-mode log path resolves to `~/.config/nexus-claude-api/logs/nexus-claude-api.log`. -- Development-mode log path resolves to current-directory `logs/nexus-claude-api.log`. +- Default-mode log path resolves to `~/.config/nexus-claude-api/logs/nexus-claude-api-YYYY-MM-DD.log`. +- Development-mode log path resolves to current-directory `logs/nexus-claude-api-YYYY-MM-DD.log`. - Verbose logging enables debug details without logging secrets. diff --git a/pyproject.toml b/pyproject.toml index d0128ea..cb5ad8b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nexus-claude-api" -version = "0.1.1" +version = "0.1.2" description = "Local Anthropic-compatible Claude Code proxy for AI Nexus Bedrock Converse." readme = "README.md" requires-python = ">=3.11" @@ -30,4 +30,3 @@ packages = ["src/nexus_claude_api"] [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["src"] - diff --git a/src/nexus_claude_api/__init__.py b/src/nexus_claude_api/__init__.py index d9ae45e..8096691 100644 --- a/src/nexus_claude_api/__init__.py +++ b/src/nexus_claude_api/__init__.py @@ -1,4 +1,3 @@ """Local Anthropic-compatible proxy for AI Nexus Claude models.""" -__version__ = "0.1.0" - +__version__ = "0.1.2" diff --git a/src/nexus_claude_api/cli.py b/src/nexus_claude_api/cli.py index 58120cb..bec74fa 100644 --- a/src/nexus_claude_api/cli.py +++ b/src/nexus_claude_api/cli.py @@ -5,6 +5,7 @@ import sys import uvicorn +from nexus_claude_api import __version__ import nexus_claude_api.config as config from nexus_claude_api.config import ( DEFAULT_ENDPOINT_URL, @@ -21,6 +22,7 @@ from nexus_claude_api.shell import generate_claude_code_powershell def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="nexus-claude-api") + parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") subparsers = parser.add_subparsers(dest="command") start = subparsers.add_parser("start", help="Start the local API server") @@ -71,16 +73,20 @@ def run_config(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int def run_start(args: argparse.Namespace) -> int: - settings = Settings.from_values( - host=args.host, - port=args.port, - endpoint_url=args.endpoint_url, - api_key=args.api_key, - model=args.model, - small_model=args.small_model, - verbose=args.verbose, - dev=args.dev, - ) + try: + settings = Settings.from_values( + host=args.host, + port=args.port, + endpoint_url=args.endpoint_url, + api_key=args.api_key, + model=args.model, + small_model=args.small_model, + verbose=args.verbose, + dev=args.dev, + ) + except ValueError as exc: + print(f"Invalid configuration: {exc}", file=sys.stderr) + return 2 if not settings.api_key: print( diff --git a/src/nexus_claude_api/routes/messages.py b/src/nexus_claude_api/routes/messages.py index e09fd63..6e9ecc7 100644 --- a/src/nexus_claude_api/routes/messages.py +++ b/src/nexus_claude_api/routes/messages.py @@ -2,7 +2,8 @@ from __future__ import annotations import logging import uuid -from typing import Annotated +from collections.abc import Iterable, Iterator +from typing import Annotated, Any from fastapi import APIRouter, Depends, Request from fastapi.responses import JSONResponse, Response, StreamingResponse @@ -60,6 +61,46 @@ def normalize_legacy_system_messages(raw: object) -> object: return normalized +def anthropic_sse_stream( + stream: Iterable[dict[str, Any]], + *, + model: str, + correlation_id: str, +) -> Iterator[str]: + try: + for event in bedrock_stream_to_anthropic_events(stream, model=model): + yield sse_frame(event) + except NexusClaudeError as exc: + yield sse_frame( + { + "type": "error", + "error": { + "type": exc.error_type, + "message": f"{exc.message} [correlation_id={correlation_id}]", + "correlation_id": correlation_id, + }, + } + ) + except Exception: + logger.exception( + "anthropic_messages_stream_error correlation_id=%s", + correlation_id, + ) + yield sse_frame( + { + "type": "error", + "error": { + "type": "api_error", + "message": ( + "Unexpected error while streaming response " + f"[correlation_id={correlation_id}]" + ), + "correlation_id": correlation_id, + }, + } + ) + + @router.post("/v1/messages", response_model=None) async def create_message( request: Request, @@ -85,12 +126,10 @@ async def create_message( correlation_id=correlation_id, ) return StreamingResponse( - ( - sse_frame(event) - for event in bedrock_stream_to_anthropic_events( - stream, - model=payload.model, - ) + anthropic_sse_stream( + stream, + model=payload.model, + correlation_id=correlation_id, ), media_type="text/event-stream", ) diff --git a/src/nexus_claude_api/server.py b/src/nexus_claude_api/server.py index 796bf64..819e4e9 100644 --- a/src/nexus_claude_api/server.py +++ b/src/nexus_claude_api/server.py @@ -5,6 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.requests import Request from fastapi.responses import JSONResponse +from nexus_claude_api import __version__ from nexus_claude_api.config import Settings from nexus_claude_api.errors import NexusClaudeError, anthropic_error_response from nexus_claude_api.nexus_client import NexusClient @@ -18,7 +19,7 @@ def create_app( nexus_client: NexusClient | None = None, ) -> FastAPI: resolved_settings = settings or Settings.from_values(require_api_key=False) - app = FastAPI(title="nexus-claude-api", version="0.1.0") + app = FastAPI(title="nexus-claude-api", version=__version__) app.state.settings = resolved_settings app.state.nexus_client = nexus_client or NexusClient(resolved_settings) diff --git a/src/nexus_claude_api/translators/anthropic_to_bedrock.py b/src/nexus_claude_api/translators/anthropic_to_bedrock.py index 7f4cfd1..468a199 100644 --- a/src/nexus_claude_api/translators/anthropic_to_bedrock.py +++ b/src/nexus_claude_api/translators/anthropic_to_bedrock.py @@ -34,12 +34,11 @@ def anthropic_to_bedrock_request(payload: AnthropicMessagesRequest) -> dict[str, inference_config["temperature"] = payload.temperature if payload.top_p is not None: inference_config["topP"] = payload.top_p + if payload.stop_sequences: + inference_config["stopSequences"] = payload.stop_sequences if inference_config: request["inferenceConfig"] = inference_config - if payload.stop_sequences: - request["stopSequences"] = payload.stop_sequences - tool_config = _tools_to_bedrock(payload) if tool_config: request["toolConfig"] = tool_config @@ -161,4 +160,3 @@ def _tools_to_bedrock(payload: AnthropicMessagesRequest) -> dict[str, Any] | Non tool_config["toolChoice"] = {"tool": {"name": choice.name}} return tool_config - diff --git a/tests/test_cli.py b/tests/test_cli.py index 5dd980e..f5eee3c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -4,6 +4,7 @@ import json import shutil from pathlib import Path +from nexus_claude_api import __version__ from nexus_claude_api.cli import main from nexus_claude_api.config import ( Settings, @@ -62,6 +63,38 @@ def test_missing_api_key_fails(monkeypatch) -> None: assert exit_code == 2 +def test_version_prints_package_version(capsys) -> None: + try: + main(["--version"]) + except SystemExit as exc: + assert exc.code == 0 + + output = capsys.readouterr() + assert output.out.strip() == f"nexus-claude-api {__version__}" + + +def test_invalid_user_config_returns_clean_error(monkeypatch, capsys) -> None: + tmp_path = _workspace_tmp("invalid-user-config") + user_config = tmp_path / ".config" / "nexus-claude-api" / "config.json" + user_config.parent.mkdir(parents=True, exist_ok=True) + user_config.write_text("{not json", encoding="utf-8") + monkeypatch.setattr("nexus_claude_api.config.USER_CONFIG_FILE", user_config) + monkeypatch.delenv("NEXUS_API_KEY", raising=False) + monkeypatch.delenv("AWS_BEARER_TOKEN_BEDROCK", raising=False) + monkeypatch.chdir(tmp_path) + + try: + exit_code = main(["start", "--dry-run"]) + finally: + monkeypatch.chdir(Path(__file__).parents[1]) + shutil.rmtree(tmp_path, ignore_errors=True) + + output = capsys.readouterr() + assert exit_code == 2 + assert "Invalid configuration: Invalid JSON" in output.err + assert "Traceback" not in output.err + + def test_user_config_api_key(monkeypatch) -> None: tmp_path = _workspace_tmp("user-config") user_config = tmp_path / ".config" / "nexus-claude-api" / "config.json" diff --git a/tests/test_routes.py b/tests/test_routes.py index 2ea1d9b..0312921 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -5,6 +5,7 @@ import logging from botocore.exceptions import ClientError from fastapi.testclient import TestClient +from nexus_claude_api.errors import NexusClaudeError 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 @@ -52,6 +53,21 @@ class AccessDeniedNexusClient: ) +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() + + def client(nexus_client: object | None = None) -> TestClient: settings = Settings.from_values(api_key="test", require_api_key=False) return TestClient( @@ -183,6 +199,26 @@ def test_messages_stream() -> None: assert "event: message_stop" in body +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 + + def test_count_tokens() -> None: response = client().post( "/v1/messages/count_tokens", diff --git a/tests/test_translators.py b/tests/test_translators.py index dae8684..7fdc4ca 100644 --- a/tests/test_translators.py +++ b/tests/test_translators.py @@ -28,6 +28,22 @@ def test_text_request_translation() -> None: assert request["inferenceConfig"]["temperature"] == 0.2 +def test_stop_sequences_are_translated_into_inference_config() -> None: + payload = AnthropicMessagesRequest.model_validate( + { + "model": "claude-opus-4.6", + "messages": [{"role": "user", "content": "Hello"}], + "max_tokens": 100, + "stop_sequences": ["STOP"], + } + ) + + request = anthropic_to_bedrock_request(payload) + + assert request["inferenceConfig"]["stopSequences"] == ["STOP"] + assert "stopSequences" not in request + + def test_image_request_translation() -> None: image_data = base64.b64encode(b"image-bytes").decode("ascii") payload = AnthropicMessagesRequest.model_validate( diff --git a/uv.lock b/uv.lock index 894fa27..9ebe087 100644 --- a/uv.lock +++ b/uv.lock @@ -173,7 +173,7 @@ wheels = [ [[package]] name = "nexus-claude-api" -version = "0.1.1" +version = "0.1.2" source = { editable = "." } dependencies = [ { name = "boto3" },