feat: Anthropic Messages API compat (/v1/messages)

Add a wire-compatible Anthropic endpoint alongside the existing OpenAI one
so Claude Code / anthropic-sdk / Cursor Agent can hit Lingma directly.

- app/anthropic_schema.py (new): request model + content-block flattener
  + internal-messages adapter + affinity key helper. Handles text / image /
  tool_use / tool_result blocks; unknown types degrade gracefully.
- app/auth.py: add require_anthropic_key (x-api-key, Bearer fallback)
  and AnthropicAuthError so auth failures render in Anthropic's error
  envelope instead of FastAPI's {detail:...} wrapper.
- app/main.py: POST /v1/messages. Shares LingmaPool / SessionCache /
  InFlightGuard / StatsCollector with the OpenAI path — same api_key +
  same conversation prefix hits the same upstream sessionId across both
  protocols (KV cache carries over). Streaming emits the named Anthropic
  event sequence (message_start / content_block_start / content_block_delta
  / content_block_stop / message_delta / message_stop). No claude-*
  model mapping table: resolve_model's default fallback handles it.
- README.md / DESIGN.md: document the new endpoint, add decision 5.12,
  iteration history M5, and a 4.3b streaming flow diagram.
- Bump FastAPI app version to 0.4.0.

Made-with: Cursor
This commit is contained in:
GitHub Actions
2026-04-18 15:40:43 +08:00
parent d9dffbb8ba
commit 0b08dc6573
5 changed files with 716 additions and 3 deletions

View File

@@ -98,6 +98,58 @@ def require_metrics_access(
)
class AnthropicAuthError(Exception):
"""Raised when an Anthropic Messages request fails authentication.
Carries enough context for the endpoint to render the Anthropic-shaped
error body (`{"type":"error","error":{"type":..., "message":...}}`) — we
don't use `HTTPException` here because FastAPI would wrap the detail in
`{"detail": ...}`, which is not the Anthropic wire format.
"""
def __init__(self, status_code: int, error_type: str, message: str) -> None:
super().__init__(message)
self.status_code = status_code
self.error_type = error_type
self.message = message
def require_anthropic_key(request: Request, api_keys: list[str]) -> None:
"""Authenticate a `POST /v1/messages` request the Anthropic way.
Accept order:
1. `x-api-key` header (official Anthropic SDK / CLI / Claude Code)
2. `Authorization: Bearer <token>` (OpenAI-shaped clients / curl)
Empty `api_keys` means auth is disabled — the startup auth-posture warning
already covers that case loudly, same as `require_bearer`.
Note: we keep `anthropic-version` header permissive (don't parse/validate)
so clients on any official version work without gateway churn.
"""
if not api_keys:
return
token = request.headers.get("x-api-key", "").strip()
if not token:
auth = request.headers.get("authorization", "")
if auth.startswith("Bearer "):
token = auth[len("Bearer ") :].strip()
if not token:
raise AnthropicAuthError(
status.HTTP_401_UNAUTHORIZED,
"authentication_error",
"missing x-api-key header (or Authorization: Bearer ...)",
)
if not _match_any(token, api_keys):
raise AnthropicAuthError(
status.HTTP_401_UNAUTHORIZED,
"authentication_error",
"invalid x-api-key",
)
def require_admin_access(
request: Request,
api_keys: list[str],