背景
我有一个 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
}
为什么直连没问题?
@startuml
!theme plain
skinparam backgroundColor #FEFEFE
participant "JMS Client" as C
participant "LiteLLM Proxy" as P
participant "Anthropic API" as A
== 直连(无问题) ==
C -> A : Request (含空 text block)
A --> C : 200 OK(宽容接受)
== 经代理(报错) ==
C -> P : Request (含空 text block)
P -> P : Pydantic 校验
P --> C : 400 "text content blocks\nmust be non-empty"
@enduml
本质上是生产者和消费者的校验不对称:
| 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 帮你兜底了,但不应该依赖这种隐式宽容。