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

@@ -37,6 +37,7 @@
### 目标
1. **OpenAI 协议兼容**:任何支持 OpenAI 的客户端curl、Cursor、Dify、LangChain、LiteLLM…不改代码就能接入 Lingma。
1b. **Anthropic Messages 协议兼容**Claude Code / anthropic-sdk-python / Cursor Agent 等只会说 Anthropic 的客户端也能直接接入,和 OpenAI 共享同一 session cache 与池。
2. **单节点生产可用**:自用场景下能长期跑 7×24包含合理的观测、鉴权、背压、错误恢复。
3. **最大化利用单账号 / 多账号的配额**:通过多实例池 + 会话复用把后端吞吐做到接近原始 VSCode 插件水平。
4. **降低运维成本**:首次登录成功后,可以导出一份 bundle 永久复用,彻底摆脱浏览器自动化的不稳定性。
@@ -101,6 +102,7 @@
| `config.py` | 178 | env → `Settings` dataclass`LINGMA_ACCOUNTS` 多格式解析bundle 字段归一化 | `main.py` | — |
| `model_map.py` | 84 | Lingma 模型 `key ↔ displayName` 双向映射;请求 `model` 解析(`id``name` 都认) | `main.py` | — |
| `openai_schema.py` | 91 | OpenAI 请求/响应 Pydantic多模态内容 `flatten_content` 降级 | `main.py`, `session_cache.py` | — |
| `anthropic_schema.py` | ~140 | Anthropic Messages 请求 Pydanticcontent blocks `flatten_anthropic_content``anthropic_to_internal_messages` 归一化到内部消息;`affinity_key_for_anthropic` 选池键 | `main.py` | — |
| `stats.py` | 85 | 请求次数 / token 估算 / Prometheus 文本 | `main.py` | — |
| `logging_config.py` | 56 | 结构化 JSON logger`request_id` 通过 `ContextVar` 注入每行 | 所有模块 | — |
| `bootstrap_lingma.py` | 199 | 启动时从 Marketplace / VSIX 提取 Lingma 二进制到 `data/bin/` | 容器启动脚本 | — |
@@ -274,6 +276,58 @@ async def event_stream():
1. `ticket_transferred=True` 一旦设成 true外层 `finally` 就不会 release ticket责任转交给 `event_stream()` 的 finally。否则会 release 两次(虽然幂等,但会把 in_flight 计成 -1
2. `chat_stream` 走的是 JSON-RPC **notify** 而非 request。早期版本用 request 会等 30s 才下第一个字节(见决策 5.1)。
### 4.3b 流式 Anthropic Messages/v1/messages
输入输出协议都不同于 OpenAI但中间层完全复用
```
client ──► POST /v1/messages (x-api-key / Bearer)
require_anthropic_key # x-api-key 优先;缺了 → AnthropicAuthError
anthropic_to_internal_messages(req) # system → role="system"content blocks flatten
│ # 结果与 OpenAI 路径完全同构 (role/content dict)
session_cache lookup / affinity pick # 与 OpenAI 共享同一 SessionCache 实例
│ # → 同一用户切协议不丢 KV cache
pool.pick(affinity) + ensure_logged_in
resolve_model("claude-3-5-sonnet-*") # 兜底到 default_model
chat_guard.try_acquire() # 与 OpenAI 路径同一 in-flight 池
▼ stream=true
StreamingResponse(event_stream())
├─ event: message_start ← 一次性id / model / usage.input_tokens
├─ event: content_block_start ← index=0, type=text
├─ event: content_block_delta ← 每片 chunk 包一次
│ ...
├─ event: content_block_stop
├─ event: message_delta ← stop_reason (+ output_tokens)
└─ event: message_stop ← 终止,无 [DONE]
▼ finally
session_cache.put(write_key, upstream_sessionId, inst.name) # 仅 success
ticket.release() + inst.in_flight--
```
与 OpenAI 路径的差异点:
| 环节 | OpenAI | Anthropic |
|---|---|---|
| 鉴权 | `Authorization: Bearer` | `x-api-key`fallback Bearer|
| 系统消息 | messages 数组里的 `role:"system"` | 顶层 `system` 字段 |
| 内容结构 | `str``[{type:"text"|"image_url"...}]` | `str``[{type:"text"|"image"|"tool_use"|"tool_result"...}]` |
| 流式帧 | `data: {delta:{content:"..."}}` + `[DONE]` | 命名事件序列 `message_start / content_block_* / message_delta / message_stop` |
| usage 语义 | `prompt_tokens / completion_tokens` | `input_tokens / output_tokens` |
| 错误 envelope | `{"error":{...}}` | `{"type":"error","error":{...}}` |
| finish 语义 | `finish_reason: "stop"\|"length"` | `stop_reason: "end_turn"\|"max_tokens"` |
### 4.4 Lingma 子进程与 LSP 通信
```
@@ -520,6 +574,16 @@ FastAPI `lifespan` 退出 → `pool.close()` → 每个 `client.close()` → 进
- **方案**`client._proc` + `client._terminate_proc()`。pool 只负责 `client.start()` / `client.close()` 的调度,进程操作封装在 client 内部。
- **权衡**client 文件变长但边界清晰——pool 只看状态和在途数,具体进程是 client 的事。
### 5.12 Anthropic Messages 端点独立编排而非内部转发
- **问题**:既要兼容 Anthropic API又不能把 `v1_chat_completions` 的编排路径搞成大杂烩。
- **方案**:单独写一个 `v1_messages` 端点前半段auth / 归一化到内部 messages / affinity / session cache lookup / instance pick / prompt 构造 / ticket 获取)与 OpenAI 端点结构对齐但各自实现后半段SSE 事件生成 / 响应包装)按 Anthropic 格式输出。
- **共享的下沉层**`LingmaPool` / `SessionCache` / `InFlightGuard` / `StatsCollector` / `LingmaGatewayClient.chat_stream|chat_complete` / `resolve_model`
- **为何不用一层统一抽象**:两端的输入/输出对象形状差异足够大system 位置、content 类型、SSE 事件名、错误 envelope抽象出来的中间类型反而掩盖差异、增加维护成本。当前重复代码约 150 行,但每条分支读起来直接对应 wire 协议,调试、改协议时都是线性阅读。
- **会话复用跨协议**`session_cache.build_key(api_key, messages)` 在两端都接收归一化后的 `{role, content}` 列表——同一用户从 OpenAI 切 Anthropic只要对话前缀一致可直接命中同一上游 `sessionId`,等于白送 KV cache。
- **错误路径**`AnthropicAuthError` 专用异常 + `@app.exception_handler` 渲染 Anthropic envelope端点内部其他错误HTTPException、backpressure`_anthropic_error()` helper 直接返 `JSONResponse`,绕过 FastAPI 默认 `{"detail":...}` 包装。
- **模型名**:不维护 `claude-* → dashscope_*` 映射表。`resolve_model` 的末位兜底default_model / first available会把所有陌生 id 退回到实际可用的 Lingma keyAnthropic 客户端继续传 `claude-3-5-sonnet-*` 即可工作。
---
## 6. 扩展指引(要做 X 改哪里)
@@ -527,6 +591,7 @@ FastAPI `lifespan` 退出 → `pool.close()` → 每个 `client.close()` → 进
| 需求 | 改哪些文件 | 关键入口 |
|---|---|---|
| 加一个新的 OpenAI 端点(如 embeddings | `main.py`, `openai_schema.py` | 仿照 `v1_models``@app.post("/v1/embeddings", dependencies=[Depends(auth_guard)])` |
| 扩展 Anthropic 端点(如 count_tokens / tool_use 贯通) | `main.py::v1_messages`, `anthropic_schema.py` | count_tokens 只读:复用 `estimate_tokens`tool_use 需要 Lingma 上游支持payload 转发点在 `chat_stream` / `chat_complete` |
| 加一种新的实例调度策略(如加权轮询) | `lingma_pool.py::pick()` | 当前是 affinity → least-in-flight → round-robin |
| 改认证为 JWT / OAuth | `auth.py` | 三个 `require_*` 函数是全部入口;`main.py` 里只有 `*_guard` 代理 |
| 增加限流(按 api_key 配额) | `concurrency.py``PerKeyGuard``main.py``chat_guard.try_acquire()` 后再来一层 | 注意 ticket 释放顺序(内层先释放) |
@@ -604,6 +669,17 @@ uvicorn app.main:app --reload --port 8317
收益:单轮没有显著改变(推理仍然花最多时间),但第 2 轮起 TTFB 降 40%~60%,视 prompt 长度。
### M5 — Anthropic Messages 兼容
- **场景**Claude Code / Cursor Agent / anthropic-sdk-python / 各种 agent 框架只会说 Anthropic 协议。
- **改动**
- 新增 `anthropic_schema.py``AnthropicMessagesRequest` + `anthropic_to_internal_messages` + `flatten_anthropic_content` + `affinity_key_for_anthropic`
- `auth.py` 新增 `require_anthropic_key``x-api-key` 优先Bearer 回退)+ `AnthropicAuthError`
- `main.py` 新增 `/v1/messages` 端点:复用 `LingmaPool` / `SessionCache` / `InFlightGuard`;流式按 `message_start / content_block_start|delta|stop / message_delta / message_stop` Anthropic SSE 协议输出;错误 envelope 改写成 `{"type":"error","error":{...}}`
- `@app.exception_handler(AnthropicAuthError)` 渲染 Anthropic 错误 wire 格式。
- **关键设计**:两端共享同一 `SessionCache`,同一 api_key 下的会话前缀哈希一致 → 跨协议命中同一上游 `sessionId`。详见 §5.12。
- **模型名**:不维护 `claude-* → dashscope_*` 映射表,靠 `resolve_model` 末位兜底。
### M4 — 生产硬化包commit `2febc37`
用户代号"选项 A"。