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

Kiyor
2026年02月26日 09:31
Show TOC

背景

我有一个 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 格式。

修复

  1. Config 新增 ai_api_key 字段,不再必须依赖环境变量:
type Config struct {
    // ...
    AIApiKey string `json:"ai_api_key,omitempty"`
}
  1. resolveAPIKey() 优先检查 config:
func resolveAPIKey() string {
    if aiApiKey != "" {
        return aiApiKey
    }
    // fallback: env vars, OAuth credentials...
}
  1. 自定义 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"`
}

这个结构体同时承载 texttool_usetool_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 层把这种不一致暴露了出来。

总结

三个问题,三个修复:

  1. 认证头:自定义 base URL 时自动切换 Bearer auth
  2. 字段缺失:自定义 MarshalJSON 按 block type 精确序列化
  3. 空 block:SSE 解析阶段过滤空 text block

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

AI Smart Recommendations
Based on Semantic Similarity

AI is analyzing article content to find similar articles...

More Articles

View more exciting content

About Blog

Tech sharing and life insights