Claude API 经 LiteLLM 代理：修复 Anthropic 隐藏的宽容性

2026-02-26

背景

我有一个 Go 写的 JumpServer CLI 工具 (jms)，内嵌了 AI Panel 功能，通过 Anthropic Claude API 在终端内提供 AI 辅助诊断。原本直连 Anthropic API 一切正常，但当我把请求路由到 LiteLLM 代理时，接连遇到了三个兼容性问题。

这些问题的根因都指向同一个事实：Anthropic API 对自己产出的数据非常宽容，但 LiteLLM 的 Pydantic 校验严格遵循 API spec。

问题一：认证头不兼容

现象

直连 Anthropic 时，非 OAuth key 使用 x-api-key 头：

httpReq.Header.Set("x-api-key", ai.apiKey)

但 LiteLLM 代理的 virtual key（形如 sk-Pnl3...）期望的是 Authorization: Bearer 格式。

修复

Config 新增 ai_api_key 字段，不再必须依赖环境变量：

type Config struct {
// ...
AIApiKey string `json:"ai_api_key,omitempty"`
}

resolveAPIKey() 优先检查 config：

func resolveAPIKey() string {
if aiApiKey != "" {
return aiApiKey
}
// fallback: env vars, OAuth credentials...
}

自定义 ai_base_url 时自动切换为 Bearer auth：

isOAuth := strings.HasPrefix(apiKey, "sk-ant-oat") || aiBaseURL != ""

这样所有经过代理的请求都用 Authorization: Bearer，兼容 LiteLLM 的认证链。

问题二：omitempty 导致必需字段丢失

现象

{"error":{"message":"messages.3.content.0.text.text: Field required"}}

LiteLLM 的 Pydantic 校验报告 text content block 缺少 text 字段。

根因

原始结构体所有字段都用了 omitempty：

type aiContentBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input any `json:"input,omitempty"`
ToolUseID string `json:"tool_use_id,omitempty"`
Content string `json:"content,omitempty"`
}

这个结构体同时承载 text、tool_use、tool_result 三种 block 类型。当一个 text block 的文本为空时，omitempty 会把 text 字段整个省略，序列化结果变成 {"type":"text"} —— 缺少了 Anthropic API spec 要求的 text 字段。

更隐蔽的问题是：不同类型的 block 会泄漏不相关的字段。比如 tool_use block 可能带上空的 tool_use_id。

修复

添加自定义 MarshalJSON，按 type 精确控制序列化字段：

func (b aiContentBlock) MarshalJSON() ([]byte, error) {
m := map[string]any{"type": b.Type}
switch b.Type {
case "text":
m["text"] = b.Text
case "tool_use":
m["id"] = b.ID
m["name"] = b.Name
m["input"] = b.Input
case "tool_result":
m["tool_use_id"] = b.ToolUseID
m["content"] = b.Content
}
return json.Marshal(m)
}

每种 block 类型只输出它应该有的字段，且必需字段始终存在。

问题三：空 text content block

现象

{"error":{"message":"messages: text content blocks must be non-empty"}}

MarshalJSON 修复后 text 字段不再缺失，但值是空字符串，被 LiteLLM 拒绝。

根因

Claude 在 SSE 流式响应中，当模型直接调用 tool 而不输出文本时，仍然会发送：

event: content_block_start
data: {"type":"content_block_start","content_block":{"type":"text","text":""}}

event: content_block_stop
data: {"type":"content_block_stop"}

中间没有任何 text_delta 事件。结果就是一个 {"type":"text","text":""} 的空 block 被存入消息历史，下一轮请求时原样发回。

修复

在 SSE 解析阶段过滤空 text block：

case "content_block_stop":
if currentBlock != nil {
// ... tool_use input parsing ...
if currentBlock.Type == "text" && currentBlock.Text == "" {
currentBlock = nil
continue
}
contentBlocks = append(contentBlocks, *currentBlock)
currentBlock = nil
}

为什么直连没问题？

本质上是生产者和消费者的校验不对称：

Anthropic API

LiteLLM Proxy

产出空 text block

会（SSE 流中）

N/A

接受空 text block

宽容接受

严格拒绝

接受缺失 text 字段

宽容接受

严格拒绝

校验标准

自产自销，不介意

按 API spec 严格校验

Anthropic 对自己生成的”不规范”数据睁一只眼闭一只眼，LiteLLM 的 Pydantic 层把这种不一致暴露了出来。

总结

三个问题，三个修复：

认证头：自定义 base URL 时自动切换 Bearer auth

字段缺失：自定义 MarshalJSON 按 block type 精确序列化

空 block：SSE 解析阶段过滤空 text block

这些修复不仅解决了代理兼容性，本身也是更正确的实现。直连时 Anthropic 帮你兜底了，但不应该依赖这种隐式宽容。