Update logging configuration to support daily log files and implement log pruning; add guidance in CLAUDE.md and enhance README.md with command usage

This commit is contained in:
2026-06-27 14:04:44 +08:00
parent 4685a0165d
commit 2fc815b788
6 changed files with 326 additions and 18 deletions

64
CLAUDE.md Normal file
View File

@@ -0,0 +1,64 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What This Is
A local Anthropic-compatible API proxy that lets Claude Code talk to AI Nexus Claude models. It receives Anthropic Messages API requests and translates them to AWS Bedrock Converse API calls targeting the corporate Nexus endpoint.
## Commands
```bash
# Install dependencies
uv sync --extra dev
# Start the proxy (prints Claude Code env helper)
uv run nexus-claude-api start --port 4141 --claude-code
# Start in dev mode (local config/logs instead of ~/.config)
uv run nexus-claude-api start --dev --port 4141 --claude-code
# Validate config without starting
uv run nexus-claude-api start --dry-run
# Run all tests
uv run pytest
# Run a single test file or focused test
uv run pytest tests/test_cli.py
uv run pytest tests/test_routes.py -k messages_stream
```
There is no lint, typecheck, or build command configured. Do not invent those steps.
## Architecture
```
Claude Code → FastAPI (routes/) → NexusClient (boto3) → AI Nexus Bedrock
```
- **cli.py** — CLI entrypoint (argparse). `__main__.py` just forwards to it.
- **server.py** — App factory (`create_app`), stores `settings` and `nexus_client` on `app.state`.
- **config.py** — `Settings` frozen dataclass, credential resolution, model ID mapping, defaults.
- **nexus_client.py** — Wraps boto3 Bedrock `converse` / `converse_stream`.
- **routes/messages.py** — `POST /v1/messages` and `/v1/messages/count_tokens`. Owns request validation, translation dispatch, SSE streaming, and error shaping.
- **translators/** — `anthropic_to_bedrock.py`, `bedrock_to_anthropic.py`, `stream.py`. Pure conversion logic.
- **errors.py** — `NexusClaudeError` exception mapped to Anthropic-shaped JSON errors with correlation IDs.
- **diagnostics.py** — Request summarization for logs; deliberately omits secrets, prompt text, base64.
- **shell.py** — Generates PowerShell env vars for Claude Code (sets `ANTHROPIC_AUTH_TOKEN='dummy'` as a placeholder).
## Key Design Decisions
- **Credential priority:** `--api-key``nexus-claude-api.local.json``NEXUS_API_KEY` env → `AWS_BEARER_TOKEN_BEDROCK` env (fixed in `Settings.from_values()`).
- **Model aliasing:** Sonnet/Haiku aliases resolve to Opus because users only have Opus access.
- **Localhost only:** Server binds to `127.0.0.1`.
- **No console logging:** App logs go to files only (`~/.config/nexus-claude-api/logs/` or `./logs/` in dev mode).
- **`NexusClient` side effect:** Writes `settings.api_key` into `AWS_BEARER_TOKEN_BEDROCK` env var before building the boto3 client.
## Testing Notes
- Tests use stub clients (`FakeNexusClient`, `AccessDeniedNexusClient`) — no network calls.
- `pythonpath = ["src"]` in pyproject.toml enables src-layout imports; keep this intact.
- `tests/test_cli.py` uses per-test temp workspaces under `.test-tmp/`.
- `nexus-claude-api.local.json` is gitignored and may exist locally; never overwrite it while testing.
- After changing request/error/logging behavior, run `uv run pytest tests/test_routes.py` — it covers SSE responses, correlation IDs, and verifies logs don't leak secrets.

View File

@@ -13,6 +13,12 @@ uv run nexus-claude-api config set --api-key "your-nexus-api-key"
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 start --port 4141 --claude-code
```
Use the printed Claude Code command in the same shell.
`ANTHROPIC_AUTH_TOKEN='dummy'` in the printed command is only a local Claude Code compatibility placeholder. Claude Code expects an Anthropic auth token variable to exist, but this local proxy does not validate it by default. It is not your Nexus key.
@@ -40,7 +46,7 @@ Credential lookup order:
To write user config:
```powershell
nexus-claude-api config set --api-key "your-nexus-api-key"
uv run nexus-claude-api config set --api-key "your-nexus-api-key"
```
This creates `~/.config/nexus-claude-api/config.json`:
@@ -51,7 +57,7 @@ This creates `~/.config/nexus-claude-api/config.json`:
}
```
Logs are written to `~/.config/nexus-claude-api/logs/nexus-claude-api.log` by default.
Logs are written to dated files under `~/.config/nexus-claude-api/logs/` by default, for example `nexus-claude-api-2026-06-27.log`. The service keeps the latest 7 daily log files and prunes older dated log files automatically.
For repository-local development only, pass `--dev` and create `nexus-claude-api.local.json` in the current directory:
@@ -59,7 +65,7 @@ For repository-local development only, pass `--dev` and create `nexus-claude-api
uv run nexus-claude-api start --dev --port 4141 --claude-code
```
In development mode, config is read from `nexus-claude-api.local.json` and logs are written to `logs/nexus-claude-api.log` in the current directory. The local config file is ignored by git.
In development mode, config is read from `nexus-claude-api.local.json` and daily logs are written to `logs/nexus-claude-api-YYYY-MM-DD.log` in the current directory. The local config file and `logs/` directory are ignored by git.
The service binds to `127.0.0.1` by default and does not persist API keys.

View File

@@ -1,6 +1,6 @@
[project]
name = "nexus-claude-api"
version = "0.1.0"
version = "0.1.1"
description = "Local Anthropic-compatible Claude Code proxy for AI Nexus Bedrock Converse."
readme = "README.md"
requires-python = ">=3.11"

View File

@@ -1,23 +1,128 @@
from __future__ import annotations
from datetime import datetime
import logging
from pathlib import Path
import time
from typing import Callable
import nexus_claude_api.config as config
LOCAL_LOG_DIR = Path("logs")
LOCAL_LOG_FILE = LOCAL_LOG_DIR / "nexus-claude-api.log"
LOG_FILE_PREFIX = "nexus-claude-api"
LOG_FILE_SUFFIX = ".log"
LOG_RETENTION_DAYS = 7
LOCAL_LOG_FILE = LOCAL_LOG_DIR / f"{LOG_FILE_PREFIX}.log"
USER_LOG_DIR = config.USER_CONFIG_DIR / "logs"
USER_LOG_FILE = USER_LOG_DIR / "nexus-claude-api.log"
USER_LOG_FILE = USER_LOG_DIR / f"{LOG_FILE_PREFIX}.log"
LOG_FILE = USER_LOG_FILE
LOG_FORMAT = "%(asctime)s %(levelname)s %(name)s: %(message)s"
def configure_logging(*, verbose: bool = False, log_file: Path = LOG_FILE) -> Path:
DateProvider = Callable[[float | None], datetime]
def _local_datetime(timestamp: float | None = None) -> datetime:
if timestamp is None:
return datetime.fromtimestamp(time.time())
return datetime.fromtimestamp(timestamp)
def _dated_log_file(log_dir: Path, current_date: datetime) -> Path:
return log_dir / f"{LOG_FILE_PREFIX}-{current_date:%Y-%m-%d}{LOG_FILE_SUFFIX}"
class DailyLogFileHandler(logging.Handler):
def __init__(
self,
log_file: Path,
*,
date_provider: DateProvider = _local_datetime,
retention_days: int = LOG_RETENTION_DAYS,
encoding: str = "utf-8",
) -> None:
super().__init__()
self.log_dir = log_file.parent
self.date_provider = date_provider
self.retention_days = retention_days
self.encoding = encoding
self.current_date = ""
self.stream = None
self.log_dir.mkdir(parents=True, exist_ok=True)
self._open_for_date(self.date_provider(None))
self._prune_old_logs()
def emit(self, record: logging.LogRecord) -> None:
try:
record_datetime = self.date_provider(record.created)
self._open_for_date(record_datetime)
message = self.format(record)
if self.stream is None:
return
self.stream.write(message + self.terminator)
self.flush()
except Exception:
self.handleError(record)
@property
def baseFilename(self) -> str:
return str(self.current_log_file)
@property
def terminator(self) -> str:
return "\n"
def flush(self) -> None:
if self.stream and not self.stream.closed:
self.stream.flush()
def close(self) -> None:
try:
if self.stream and not self.stream.closed:
self.stream.close()
finally:
self.stream = None
super().close()
def _open_for_date(self, current_datetime: datetime) -> None:
date_text = current_datetime.strftime("%Y-%m-%d")
if date_text == self.current_date and self.stream is not None:
return
if self.stream is not None and not self.stream.closed:
self.stream.close()
self.current_date = date_text
self.current_log_file = _dated_log_file(self.log_dir, current_datetime)
self.stream = self.current_log_file.open("a", encoding=self.encoding)
self._prune_old_logs()
def _prune_old_logs(self) -> None:
dated_logs = []
for path in self.log_dir.glob(f"{LOG_FILE_PREFIX}-*{LOG_FILE_SUFFIX}"):
date_text = path.stem.removeprefix(f"{LOG_FILE_PREFIX}-")
try:
log_date = datetime.strptime(date_text, "%Y-%m-%d").date()
except ValueError:
continue
dated_logs.append((log_date, path))
dated_logs.sort(reverse=True)
for _, path in dated_logs[self.retention_days :]:
path.unlink(missing_ok=True)
def configure_logging(
*,
verbose: bool = False,
log_file: Path = LOG_FILE,
date_provider: DateProvider = _local_datetime,
) -> Path:
log_file = _dated_log_file(log_file.parent, date_provider(None))
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 = DailyLogFileHandler(log_file, date_provider=date_provider)
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(formatter)
@@ -32,5 +137,5 @@ def configure_logging(*, verbose: bool = False, log_file: Path = LOG_FILE) -> Pa
def resolve_log_file(*, dev: bool = False) -> Path:
if dev:
return LOCAL_LOG_FILE
return config.USER_CONFIG_DIR / "logs" / "nexus-claude-api.log"
return _dated_log_file(LOCAL_LOG_DIR, _local_datetime(None))
return _dated_log_file(config.USER_CONFIG_DIR / "logs", _local_datetime(None))

View File

@@ -1,16 +1,27 @@
from __future__ import annotations
from datetime import datetime, timedelta
import logging
from pathlib import Path
from nexus_claude_api.logging_config import configure_logging, resolve_log_file
FIXED_NOW = datetime(2026, 6, 27, 10, 30, 0)
def fixed_date_provider(timestamp: float | None = None) -> datetime:
if timestamp is None:
return FIXED_NOW
return datetime.fromtimestamp(timestamp)
def test_configure_logging_writes_app_logs_to_file_without_console(
tmp_path,
capsys,
) -> None:
log_file = tmp_path / "nexus-claude-api.log"
dated_log_file = tmp_path / "nexus-claude-api-2026-06-27.log"
logger = logging.getLogger("nexus_claude_api.routes.messages")
app_logger = logging.getLogger("nexus_claude_api")
original_handlers = app_logger.handlers[:]
@@ -18,13 +29,18 @@ def test_configure_logging_writes_app_logs_to_file_without_console(
original_propagate = app_logger.propagate
try:
configure_logging(verbose=False, log_file=log_file)
returned_log_file = configure_logging(
verbose=False,
log_file=log_file,
date_provider=fixed_date_provider,
)
logger.info("detailed app request")
logger.debug("debug details hidden without verbose")
output = capsys.readouterr()
log_text = log_file.read_text(encoding="utf-8")
log_text = dated_log_file.read_text(encoding="utf-8")
assert returned_log_file == dated_log_file
assert output.err == ""
assert "detailed app request" in log_text
assert "debug details hidden without verbose" not in log_text
@@ -39,6 +55,7 @@ def test_configure_logging_verbose_writes_debug_app_logs_to_file(
tmp_path,
) -> None:
log_file = tmp_path / "nexus-claude-api.log"
dated_log_file = tmp_path / "nexus-claude-api-2026-06-27.log"
logger = logging.getLogger("nexus_claude_api.routes.messages")
app_logger = logging.getLogger("nexus_claude_api")
original_handlers = app_logger.handlers[:]
@@ -46,11 +63,18 @@ def test_configure_logging_verbose_writes_debug_app_logs_to_file(
original_propagate = app_logger.propagate
try:
configure_logging(verbose=True, log_file=log_file)
configure_logging(
verbose=True,
log_file=log_file,
date_provider=fixed_date_provider,
)
logger.debug("debug details stay in file")
assert "debug details stay in file" in log_file.read_text(encoding="utf-8")
assert (
"debug details stay in file"
in dated_log_file.read_text(encoding="utf-8")
)
finally:
app_logger.handlers.clear()
app_logger.handlers.extend(original_handlers)
@@ -64,9 +88,118 @@ def test_resolve_log_file_uses_user_config_dir_by_default(
) -> None:
user_config_dir = tmp_path / ".config" / "nexus-claude-api"
monkeypatch.setattr("nexus_claude_api.config.USER_CONFIG_DIR", user_config_dir)
monkeypatch.setattr(
"nexus_claude_api.logging_config._local_datetime",
lambda timestamp=None: FIXED_NOW,
)
assert resolve_log_file() == user_config_dir / "logs" / "nexus-claude-api.log"
assert (
resolve_log_file()
== user_config_dir / "logs" / "nexus-claude-api-2026-06-27.log"
)
def test_resolve_log_file_uses_local_logs_in_dev_mode() -> None:
assert resolve_log_file(dev=True) == Path("logs") / "nexus-claude-api.log"
def test_resolve_log_file_uses_local_logs_in_dev_mode(monkeypatch) -> None:
monkeypatch.setattr(
"nexus_claude_api.logging_config._local_datetime",
lambda timestamp=None: FIXED_NOW,
)
assert resolve_log_file(dev=True) == Path("logs") / "nexus-claude-api-2026-06-27.log"
def test_configure_logging_prunes_logs_older_than_recent_seven_days(tmp_path) -> None:
for days_ago in range(10):
log_date = FIXED_NOW - timedelta(days=days_ago)
(tmp_path / f"nexus-claude-api-{log_date:%Y-%m-%d}.log").write_text(
f"log {days_ago}",
encoding="utf-8",
)
ignored_log = tmp_path / "nexus-claude-api-debug.log"
ignored_log.write_text("keep me", encoding="utf-8")
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=tmp_path / "nexus-claude-api.log",
date_provider=fixed_date_provider,
)
remaining_logs = sorted(
path.name for path in tmp_path.glob("nexus-claude-api-*.log")
)
assert remaining_logs == [
"nexus-claude-api-2026-06-21.log",
"nexus-claude-api-2026-06-22.log",
"nexus-claude-api-2026-06-23.log",
"nexus-claude-api-2026-06-24.log",
"nexus-claude-api-2026-06-25.log",
"nexus-claude-api-2026-06-26.log",
"nexus-claude-api-2026-06-27.log",
"nexus-claude-api-debug.log",
]
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_switches_files_when_record_date_changes(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=False,
log_file=log_file,
date_provider=lambda timestamp=None: datetime.fromtimestamp(timestamp)
if timestamp is not None
else datetime(2026, 6, 27, 10, 30, 0),
)
before_record = logger.makeRecord(
logger.name,
logging.INFO,
__file__,
1,
"before midnight",
(),
None,
)
before_record.created = datetime(2026, 6, 27, 23, 59, 0).timestamp()
after_record = logger.makeRecord(
logger.name,
logging.INFO,
__file__,
1,
"after midnight",
(),
None,
)
after_record.created = datetime(2026, 6, 28, 0, 1, 0).timestamp()
for handler in app_logger.handlers:
handler.handle(before_record)
handler.handle(after_record)
assert "before midnight" in (
tmp_path / "nexus-claude-api-2026-06-27.log"
).read_text(encoding="utf-8")
assert "after midnight" in (
tmp_path / "nexus-claude-api-2026-06-28.log"
).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

2
uv.lock generated
View File

@@ -173,7 +173,7 @@ wheels = [
[[package]]
name = "nexus-claude-api"
version = "0.1.0"
version = "0.1.1"
source = { editable = "." }
dependencies = [
{ name = "boto3" },