记录一次claude code->litellm->openrouter->claude/gpt模型调用bug的修复

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
 lvzhongrenjie@MacBook-Pro  ~/python_project/litellm-roger   main  claude

▐▛███▜▌ Claude Code v2.1.69
▝▜█████▛▘ claude-opus-4.6 · API Usage Billing
▘▘ ▝▝ ~/python_project/litellm-roger

/model to try Opus 4.6

❯ list the files in the directory
⎿ API Error: 400 {"error":{"message":"litellm.BadRequestError: OpenAIException -
{\"error\":{\"message\":\"litellm.BadRequestError: OpenrouterException -
{\\\"error\\\":{\\\"message\\\":\\\"Provider returned error\\\",\\\"code\\\":400,\\\"metadata\\\":{\\\"ra
w\\\":\\\"{\\\\\\\"type\\\\\\\":\\\\\\\"error\\\\\\\",\\\\\\\"error\\\\\\\":{\\\\\\\"type\\\\\\\":\\\\\\\
"invalid_request_error\\\\\\\",\\\\\\\"message\\\\\\\":\\\\\\\"messages.1.content.1: `tool_use` ids must
be unique\\\\\\\"},\\\\\\\"request_id\\\\\\\":\\\\\\\"req_011CYzLfsNDGTxJ38CPcaZpg\\\\\\\"}\\\",\\\"provi
der_name\\\":\\\"Anthropic\\\",\\\"is_byok\\\":false}},\\\"user_id\\\":\\\"user_2y1ZByYtj77ms63zgitDw0v6Y
Pc\\\"}. Received Model Group=claude-opus-4.6\\nAvailable Model Group
Fallbacks=None\",\"type\":null,\"param\":null,\"code\":\"400\"}}. Received Model
Group=claude-opus-4.6\nAvailable Model Group Fallbacks=None","type":null,"param":null,"code":"400"}}

当用户通过 LiteLLM 使用 OpenRouter 调用 Claude 模型时,LiteLLM 以 OpenAI 格式发送消息。如果 assistant message 同时包含:

1. content 是一个 list,其中包含 Anthropic 风格的 tool_use 块
2. tool_calls 字段(OpenAI 风格)

OpenRouter 在转换时会处理这两个部分,可能导致 tool_use id 重复。

概述

OpenAI 和 Anthropic 使用不同的消息格式 API。LiteLLM 作为统一接口层,需要在两种格式之间进行转换。本报告详细对比两种格式的差异。


1. 基本消息结构

OpenAI 格式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"model": "gpt-4",
"messages": [
{
"role": "system",
"content": "You are a helpful assistant."
},
{
"role": "user",
"content": "Hello!"
},
{
"role": "assistant",
"content": "Hi there!"
}
]
}

Anthropic 格式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"model": "claude-3-sonnet",
"system": "You are a helpful assistant.",
"messages": [
{
"role": "user",
"content": "Hello!"
},
{
"role": "assistant",
"content": "Hi there!"
}
]
}

关键差异

特性 OpenAI Anthropic
System 消息 作为 messages 数组中的第一条消息 独立的 system 字段
Content 类型 stringarray 始终为 array
用户/助手交替 无强制要求 强制要求 user/assistant 交替

2. 多模态内容(图片)

OpenAI 格式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"role": "user",
"content": [
{
"type": "text",
"text": "What's in this image?"
},
{
"type": "image_url",
"image_url": {
"url": "https://example.com/image.png"
}
}
]
}

Anthropic 格式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"role": "user",
"content": [
{
"type": "text",
"text": "What's in this image?"
},
{
"type": "image",
"source": {
"type": "url",
"url": "https://example.com/image.png"
}
}
]
}

关键差异

特性 OpenAI Anthropic
图片类型 image_url image
图片字段 image_url.url source.urlsource.data
Base64 支持 data:image/png;base64,... URL source.type: "base64"

3. 工具调用(Tool Calling)- 核心差异

这是导致本次 bug 的核心区域。

OpenAI 格式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"role": "assistant",
"content": "Let me check the weather.",
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"location\": \"San Francisco\"}"
}
}
]
}

工具结果返回:

1
2
3
4
5
{
"role": "tool",
"tool_call_id": "call_abc123",
"content": "72°F, sunny"
}

Anthropic 格式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"role": "assistant",
"content": [
{
"type": "text",
"text": "Let me check the weather."
},
{
"type": "tool_use",
"id": "toolu_abc123",
"name": "get_weather",
"input": {
"location": "San Francisco"
}
}
]
}

工具结果返回:

1
2
3
4
5
6
7
8
9
10
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_abc123",
"content": "72°F, sunny"
}
]
}

关键差异对比表

特性 OpenAI Anthropic
工具调用位置 独立的 tool_calls 数组 嵌入 content 数组中
工具调用类型 function tool_use
ID 字段名 tool_calls[].id content[].id
函数名字段 function.name name
参数字段 function.arguments (JSON 字符串) input (JSON 对象)
参数格式 字符串(需要解析) 对象(直接使用)
结果角色 tool user
结果类型 无特殊类型 tool_result
结果 ID 字段 tool_call_id tool_use_id

4. 本次 Bug 的根本原因

问题场景

当消息历史中同时存在两种格式时:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{
"role": "assistant",
"content": [
{
"type": "text",
"text": "Let me check."
},
{
"type": "tool_use", // ← Anthropic 风格
"id": "toolu_abc123",
"name": "get_weather",
"input": {"location": "SF"}
}
],
"tool_calls": [ // ← OpenAI 风格(相同的 id)
{
"id": "toolu_abc123",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"location\": \"SF\"}"
}
}
]
}

问题链路

1
2
3
4
5
6
7
8
9
用户请求 (混合格式)

LiteLLM (可能未清理重复)

OpenRouter (格式转换)

Anthropic API

❌ "tool_use ids must be unique"

原因分析

  1. 消息历史混合格式:可能来自之前的 Anthropic 响应(content 中有 tool_use),被存储后又添加了 tool_calls 字段

  2. OpenRouter 转换逻辑:OpenRouter 将 OpenAI 的 tool_calls 转换为 Anthropic 的 tool_use 块,但没有检测 content 中已存在的 tool_use

  3. 结果:同一个 tool_use id 出现两次 → Anthropic 报错


5. LiteLLM 的转换逻辑

转换函数位置

  • Anthropic 消息转换: litellm/litellm_core_utils/prompt_templates/factory.py
    • anthropic_messages_pt() - 主转换函数
    • convert_to_anthropic_tool_invoke() - 工具调用转换
    • convert_to_anthropic_tool_result() - 工具结果转换

转换流程图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
OpenAI 消息格式


┌─────────────────────────────────────┐
│ anthropic_messages_pt() │
│ │
│ 1. 提取 system 消息到单独字段 │
│ 2. 转换图片格式 │
│ 3. 转换 tool_calls → tool_use │
│ 4. 转换 tool 消息 → tool_result │
│ 5. 去重 tool_use ids (本次修复) │
└─────────────────────────────────────┘


Anthropic 消息格式

关键转换代码片段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# OpenAI tool_calls 转换为 Anthropic tool_use
def convert_to_anthropic_tool_invoke(tool_calls):
anthropic_tool_invoke = []
for tool in tool_calls:
tool_id = tool["id"]
tool_name = tool["function"]["name"]
tool_input = json.loads(tool["function"]["arguments"]) # 字符串 → 对象

anthropic_tool_invoke.append({
"type": "tool_use",
"id": tool_id,
"name": tool_name,
"input": tool_input
})
return anthropic_tool_invoke

6. Tool Use ID 规则

Anthropic 的限制

1
2
3
4
5
6
7
# Anthropic 要求 tool_use_id 符合正则: ^[a-zA-Z0-9_-]+$
# LiteLLM 的清理函数
def _sanitize_anthropic_tool_use_id(tool_use_id: str) -> str:
sanitized = re.sub(r"[^a-zA-Z0-9_-]", "_", tool_use_id)
if not sanitized:
sanitized = "tool_use_id"
return sanitized

ID 前缀约定

前缀 类型 来源
toolu_ 普通工具调用 Anthropic 原生
srvtoolu_ 服务端工具 Anthropic 原生
call_ OpenAI 工具调用 OpenAI 原生
任意字符串 用户自定义 需要清洗

7. Thinking/Reasoning 块

OpenAI (o1/o3 等推理模型)

1
2
3
4
5
{
"role": "assistant",
"content": null,
"reasoning_content": "Let me think about this step by step..."
}

Anthropic (Claude Extended Thinking)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"role": "assistant",
"content": [
{
"type": "thinking",
"thinking": "Let me think about this step by step...",
"signature": "..."
},
{
"type": "redacted_thinking",
"data": "..."
}
]
}

8. Cache Control (Prompt Caching)

OpenAI 格式

1
2
3
4
5
{
"role": "system",
"content": "Long context...",
"cache_control": {"type": "ephemeral"} // LiteLLM 扩展
}

Anthropic 格式

1
2
3
4
5
6
7
8
9
10
{
"role": "user",
"content": [
{
"type": "text",
"text": "Long context...",
"cache_control": {"type": "ephemeral"}
}
]
}

OpenRouter 的特殊处理

OpenRouter 要求 cache_control 在 content 块内部,不在消息级别:

1
2
3
4
# LiteLLM 为 OpenRouter 的转换
def _move_cache_control_to_content(messages):
# 将消息级别的 cache_control 移动到最后一个 content block
...

9. 完整对照表

特性 OpenAI Anthropic
System 消息 messages 数组中 独立 system 字段
Content 类型 string | array array only
图片块类型 image_url image
图片数据结构 image_url.url source.{url|data}
工具调用位置 tool_calls[] 数组 content[] 数组内
工具调用类型 function tool_use
参数格式 JSON 字符串 JSON 对象
工具结果角色 tool user
工具结果类型 tool_result
思考块 reasoning_content 字段 thinking content 块
Cache Control 消息级别 content 块级别
ID 字符合法性 无限制 [a-zA-Z0-9_-]+
消息交替规则 宽松 严格 user/assistant 交替

10. 最佳实践建议

1. 避免混合格式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# ❌ 不要同时使用两种格式
{
"content": [{"type": "tool_use", ...}],
"tool_calls": [...]
}

# ✅ 只使用一种格式
{
"content": [{"type": "tool_use", ...}]
}
# 或
{
"tool_calls": [...]
}

2. 确保工具 ID 唯一

1
2
3
4
5
6
7
8
9
10
# 在处理消息历史时,追踪已使用的工具 ID
unique_tool_ids = set()

for item in tool_invoke_results:
item_id = item.get("id")
if item_id and item_id in unique_tool_ids:
continue # 跳过重复
if item_id:
unique_tool_ids.add(item_id)
assistant_content.append(item)

3. 使用 LiteLLM 的转换函数

1
2
3
4
5
6
7
8
from litellm.litellm_core_utils.prompt_templates.factory import anthropic_messages_pt

# 自动处理格式转换
converted = anthropic_messages_pt(
messages=openai_format_messages,
model="claude-3-sonnet",
llm_provider="anthropic"
)

11. 本次修复摘要

修复点 1: Anthropic 消息转换

文件: litellm/litellm_core_utils/prompt_templates/factory.py

1
2
3
4
5
6
7
8
# 在 SEQUENTIAL MODE 中添加 tool_use 块处理
elif m.get("type", "") == "tool_use":
_tool_use_id = m.get("id")
if _tool_use_id:
if _tool_use_id in unique_tool_ids:
continue # 跳过重复 ID
unique_tool_ids.add(_tool_use_id)
assistant_content.append(m)

修复点 2: OpenRouter 消息预处理

文件: litellm/llms/openrouter/chat/transformation.py

1
2
3
4
5
6
7
8
9
10
def _deduplicate_tool_use_in_messages(messages):
"""移除 content 中的 tool_use 块(当 tool_calls 存在时)"""
for message in messages:
if message.get("role") == "assistant" and message.get("tool_calls"):
content = message.get("content")
if isinstance(content, list):
# 过滤掉 tool_use 块
filtered = [b for b in content if b.get("type") != "tool_use"]
message["content"] = filtered
return messages

12. 相关代码文件

文件路径 功能
litellm/litellm_core_utils/prompt_templates/factory.py 消息格式转换主逻辑
litellm/llms/openrouter/chat/transformation.py OpenRouter 请求转换
litellm/llms/anthropic/chat/transformation.py Anthropic 直接调用转换
litellm/types/llms/anthropic.py Anthropic 类型定义
litellm/types/llms/openai.py OpenAI 类型定义

结论

OpenAI 和 Anthropic 的消息格式差异主要在于:

  1. 结构设计哲学不同:OpenAI 倾向于扁平结构,Anthropic 倾向于嵌套结构
  2. 工具调用的处理方式不同:这是最容易出问题的地方
  3. 类型严格程度不同:Anthropic 更严格

在 LiteLLM 这样的统一接口层中,必须仔细处理这些差异,特别是在消息历史可能混合两种格式的情况下。本次修复确保了即使存在混合格式,也能正确去重,避免 Anthropic API 报错。