Bump version to 0.1.2; update logging paths and enhance CLI with version command
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
"""Local Anthropic-compatible proxy for AI Nexus Claude models."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
__version__ = "0.1.2"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user