{"code":0,"data":{"id":522,"title":"Claude API 经 LiteLLM 代理：修复 Anthropic 隐藏的宽容性","content":"## 背景\n\n我有一个 Go 写的 JumpServer CLI 工具 (jms)，内嵌了 AI Panel 功能，通过 Anthropic Claude API 在终端内提供 AI 辅助诊断。原本直连 Anthropic API 一切正常，但当我把请求路由到 LiteLLM 代理时，接连遇到了三个兼容性问题。\n\n这些问题的根因都指向同一个事实：**Anthropic API 对自己产出的数据非常宽容，但 LiteLLM 的 Pydantic 校验严格遵循 API spec**。\n\n## 问题一：认证头不兼容\n\n### 现象\n\n直连 Anthropic 时，非 OAuth key 使用 `x-api-key` 头：\n\n```go\nhttpReq.Header.Set(\"x-api-key\", ai.apiKey)\n```\n\n但 LiteLLM 代理的 virtual key（形如 `sk-Pnl3...`）期望的是 `Authorization: Bearer` 格式。\n\n### 修复\n\n1. Config 新增 `ai_api_key` 字段，不再必须依赖环境变量：\n\n```go\ntype Config struct {\n    // ...\n    AIApiKey string `json:\"ai_api_key,omitempty\"`\n}\n```\n\n2. `resolveAPIKey()` 优先检查 config：\n\n```go\nfunc resolveAPIKey() string {\n    if aiApiKey != \"\" {\n        return aiApiKey\n    }\n    // fallback: env vars, OAuth credentials...\n}\n```\n\n3. 自定义 `ai_base_url` 时自动切换为 Bearer auth：\n\n```go\nisOAuth := strings.HasPrefix(apiKey, \"sk-ant-oat\") || aiBaseURL != \"\"\n```\n\n这样所有经过代理的请求都用 `Authorization: Bearer`，兼容 LiteLLM 的认证链。\n\n## 问题二：omitempty 导致必需字段丢失\n\n### 现象\n\n```json\n{\"error\":{\"message\":\"messages.3.content.0.text.text: Field required\"}}\n```\n\nLiteLLM 的 Pydantic 校验报告 text content block 缺少 `text` 字段。\n\n### 根因\n\n原始结构体所有字段都用了 `omitempty`：\n\n```go\ntype aiContentBlock struct {\n    Type      string `json:\"type\"`\n    Text      string `json:\"text,omitempty\"`\n    ID        string `json:\"id,omitempty\"`\n    Name      string `json:\"name,omitempty\"`\n    Input     any    `json:\"input,omitempty\"`\n    ToolUseID string `json:\"tool_use_id,omitempty\"`\n    Content   string `json:\"content,omitempty\"`\n}\n```\n\n这个结构体同时承载 `text`、`tool_use`、`tool_result` 三种 block 类型。当一个 text block 的文本为空时，`omitempty` 会把 `text` 字段整个省略，序列化结果变成 `{\"type\":\"text\"}` —— 缺少了 Anthropic API spec 要求的 `text` 字段。\n\n更隐蔽的问题是：不同类型的 block 会泄漏不相关的字段。比如 `tool_use` block 可能带上空的 `tool_use_id`。\n\n### 修复\n\n添加自定义 `MarshalJSON`，按 type 精确控制序列化字段：\n\n```go\nfunc (b aiContentBlock) MarshalJSON() ([]byte, error) {\n    m := map[string]any{\"type\": b.Type}\n    switch b.Type {\n    case \"text\":\n        m[\"text\"] = b.Text\n    case \"tool_use\":\n        m[\"id\"] = b.ID\n        m[\"name\"] = b.Name\n        m[\"input\"] = b.Input\n    case \"tool_result\":\n        m[\"tool_use_id\"] = b.ToolUseID\n        m[\"content\"] = b.Content\n    }\n    return json.Marshal(m)\n}\n```\n\n每种 block 类型只输出它应该有的字段，且必需字段始终存在。\n\n## 问题三：空 text content block\n\n### 现象\n\n```json\n{\"error\":{\"message\":\"messages: text content blocks must be non-empty\"}}\n```\n\nMarshalJSON 修复后 `text` 字段不再缺失，但值是空字符串，被 LiteLLM 拒绝。\n\n### 根因\n\nClaude 在 SSE 流式响应中，当模型直接调用 tool 而不输出文本时，仍然会发送：\n\n```\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\"}\n```\n\n中间没有任何 `text_delta` 事件。结果就是一个 `{\"type\":\"text\",\"text\":\"\"}` 的空 block 被存入消息历史，下一轮请求时原样发回。\n\n### 修复\n\n在 SSE 解析阶段过滤空 text block：\n\n```go\ncase \"content_block_stop\":\n    if currentBlock != nil {\n        // ... tool_use input parsing ...\n        if currentBlock.Type == \"text\" && currentBlock.Text == \"\" {\n            currentBlock = nil\n            continue\n        }\n        contentBlocks = append(contentBlocks, *currentBlock)\n        currentBlock = nil\n    }\n```\n\n## 为什么直连没问题？\n\n```plantuml\n@startuml\n!theme plain\nskinparam backgroundColor #FEFEFE\n\nparticipant \"JMS Client\" as C\nparticipant \"LiteLLM Proxy\" as P\nparticipant \"Anthropic API\" as A\n\n== 直连（无问题） ==\nC -> A : Request (含空 text block)\nA --> C : 200 OK（宽容接受）\n\n== 经代理（报错） ==\nC -> P : Request (含空 text block)\nP -> P : Pydantic 校验\nP --> C : 400 \"text content blocks\\nmust be non-empty\"\n@enduml\n```\n\n本质上是**生产者和消费者的校验不对称**：\n\n| | Anthropic API | LiteLLM Proxy |\n|---|---|---|\n| **产出空 text block** | 会（SSE 流中） | N/A |\n| **接受空 text block** | 宽容接受 | 严格拒绝 |\n| **接受缺失 text 字段** | 宽容接受 | 严格拒绝 |\n| **校验标准** | 自产自销，不介意 | 按 API spec 严格校验 |\n\nAnthropic 对自己生成的\"不规范\"数据睁一只眼闭一只眼，LiteLLM 的 Pydantic 层把这种不一致暴露了出来。\n\n## 总结\n\n三个问题，三个修复：\n\n1. **认证头**：自定义 base URL 时自动切换 Bearer auth\n2. **字段缺失**：自定义 `MarshalJSON` 按 block type 精确序列化\n3. **空 block**：SSE 解析阶段过滤空 text block\n\n这些修复不仅解决了代理兼容性，本身也是更正确的实现。直连时 Anthropic 帮你兜底了，但不应该依赖这种隐式宽容。","acl":"public","tags":["golang","anthropic","claude","litellm","api-proxy"],"category_id":4,"category":{"id":4,"name":"系统可靠性工程","description":"关于SRE实践、工具和技术的文章。","color":"#2B1B43","created_at":"2025-06-19T04:29:04.749+08:00","updated_at":"2025-06-19T04:29:04.749+08:00"},"author":{"id":1,"username":"kiyor","email":"cai@kiyor.com"},"enable_variables":false,"created_at":"2026-02-26T09:31:54.854+08:00","updated_at":"2026-02-26T09:31:54.854+08:00"}}
