Files
claude-code/docs/plans/openai-compatibility.md
T
claude-code-best 00b044e8b2 支持 OpenAI Chat 兼容协议 (#99)
* feat: 完成 openai 接口兼容

* feat: 完成 openai 协议兼容

* fix: 修复测试用例
2026-04-03 23:33:17 +08:00

14 KiB
Raw Blame History

OpenAI 协议兼容层

概述

claude-code 支持通过 OpenAI Chat Completions API(/v1/chat/completions)兼容任意 OpenAI 协议端点,包括 Ollama、DeepSeek、vLLM、One API、LiteLLM 等。

核心策略为流适配器模式:在 queryModel() 中插入提前返回分支,将 Anthropic 格式请求转为 OpenAI 格式,调用 OpenAI SDK,再将 SSE 流转换回 BetaRawMessageStreamEvent 格式。下游代码(流处理循环、query.ts、QueryEngine.ts、REPL)完全不改。

环境变量

变量 必需 说明
CLAUDE_CODE_USE_OPENAI 是 设为 1 启用 OpenAI 后端
OPENAI_API_KEY 是 API key(Ollama 等可设为任意值)
OPENAI_BASE_URL 推荐 端点 URL(如 http://localhost:11434/v1)
OPENAI_MODEL 可选 覆盖所有请求的模型名(跳过映射)
OPENAI_MODEL_MAP 可选 JSON 映射,如 {"claude-sonnet-4-6":"gpt-4o"}
OPENAI_ORG_ID 可选 Organization ID
OPENAI_PROJECT_ID 可选 Project ID

使用示例

# Ollama
CLAUDE_CODE_USE_OPENAI=1 \
OPENAI_API_KEY=ollama \
OPENAI_BASE_URL=http://localhost:11434/v1 \
OPENAI_MODEL=qwen2.5-coder-32b \
bun run dev

# DeepSeek(自动支持 Thinking)
CLAUDE_CODE_USE_OPENAI=1 \
OPENAI_API_KEY=sk-xxx \
OPENAI_BASE_URL=https://api.deepseek.com/v1 \
OPENAI_MODEL=deepseek-chat \
bun run dev

# vLLM
CLAUDE_CODE_USE_OPENAI=1 \
OPENAI_API_KEY=token-abc123 \
OPENAI_BASE_URL=http://localhost:8000/v1 \
OPENAI_MODEL=Qwen/Qwen2.5-Coder-32B-Instruct \
bun run dev

# One API / LiteLLM
CLAUDE_CODE_USE_OPENAI=1 \
OPENAI_API_KEY=sk-your-key \
OPENAI_BASE_URL=https://your-one-api.example.com/v1 \
OPENAI_MODEL=gpt-4o \
bun run dev

# 自定义模型映射
CLAUDE_CODE_USE_OPENAI=1 \
OPENAI_API_KEY=sk-xxx \
OPENAI_BASE_URL=https://my-gateway.example.com/v1 \
OPENAI_MODEL_MAP='{"claude-sonnet-4-6":"gpt-4o-2024-11-20","claude-haiku-4-5":"gpt-4o-mini"}' \
bun run dev

架构

请求流程

queryModel() [claude.ts]
  ├── 共享预处理(消息归一化、工具过滤、媒体裁剪)
  └── if (getAPIProvider() === 'openai')
      └── queryModelOpenAI() [openai/index.ts]
          ├── resolveOpenAIModel()          → 解析模型名
          ├── normalizeMessagesForAPI()      → 共享消息预处理
          ├── toolToAPISchema()              → 构建工具 schema
          ├── anthropicMessagesToOpenAI()    → 消息格式转换
          ├── anthropicToolsToOpenAI()       → 工具格式转换
          ├── openai.chat.completions.create({ stream: true })
          └── adaptOpenAIStreamToAnthropic() → 流格式转换
              ├── delta.reasoning_content    → thinking 块
              ├── delta.content             → text 块
              ├── delta.tool_calls          → tool_use 块
              ├── usage.cached_tokens       → cache_read_input_tokens
              └── yield BetaRawMessageStreamEvent

模型名解析优先级

resolveOpenAIModel() 的解析顺序:

  1. OPENAI_MODEL 环境变量 → 直接使用,覆盖所有
  2. OPENAI_MODEL_MAP JSON 查表 → 自定义映射
  3. 内置默认映射(见下表)
  4. 以上都不匹配 → 原名透传

内置模型映射

Anthropic 模型 OpenAI 映射
claude-sonnet-4-6 gpt-4o
claude-sonnet-4-5-20250929 gpt-4o
claude-sonnet-4-20250514 gpt-4o
claude-3-7-sonnet-20250219 gpt-4o
claude-3-5-sonnet-20241022 gpt-4o
claude-opus-4-6 o3
claude-opus-4-5-20251101 o3
claude-opus-4-1-20250805 o3
claude-opus-4-20250514 o3
claude-haiku-4-5-20251001 gpt-4o-mini
claude-3-5-haiku-20241022 gpt-4o-mini

同时会自动剥离 [1m] 后缀(Claude 特有的 modifier)。

文件结构

新增文件

src/services/api/openai/
├── client.ts              # OpenAI SDK 客户端工厂(~50 行)
├── convertMessages.ts     # Anthropic → OpenAI 消息格式转换(~190 行)
├── convertTools.ts        # Anthropic → OpenAI 工具格式转换(~70 行)
├── streamAdapter.ts       # SSE 流转换核心,含 thinking + caching(~270 行)
├── modelMapping.ts        # 模型名解析(~60 行)
├── index.ts               # 公共入口 queryModelOpenAI()(~110 行)
└── __tests__/
    ├── convertMessages.test.ts   # 10 个测试
    ├── convertTools.test.ts      # 7 个测试
    ├── modelMapping.test.ts      # 6 个测试
    └── streamAdapter.test.ts     # 14 个测试(含 thinking + caching)

修改文件

文件 改动
src/utils/model/providers.ts 添加 'openai' provider 类型 + CLAUDE_CODE_USE_OPENAI 检查(最高优先级)
src/utils/model/configs.ts 每个 ModelConfig 添加 openai 键
src/services/api/claude.ts 在 stripExcessMediaItems() 后插入 OpenAI 提前返回分支(~8 行)
package.json 添加 "openai": "^4.73.0" 依赖

消息转换规则

Anthropic → OpenAI

Anthropic OpenAI
system prompt(string[]) role: "system" 消息(\n\n 拼接)
user + text 块 role: "user" 消息
assistant + text 块 role: "assistant" + content
assistant + tool_use 块 role: "assistant" + tool_calls[]
user + tool_result 块 role: "tool" + tool_call_id
thinking 块 静默丢弃(请求侧)

工具转换

Anthropic OpenAI
{ name, description, input_schema } { type: "function", function: { name, description, parameters } }
cache_control, defer_loading 等字段 剥离
tool_choice: { type: "auto" } "auto"
tool_choice: { type: "any" } "required"
tool_choice: { type: "tool", name } { type: "function", function: { name } }

消息转换示例

Anthropic:                              OpenAI:
[
  system: ["You are helpful."],         [
                                          { role: "system",
  { role: "user",                          content: "You are helpful." },
    content: [                            { role: "user",
      { type: "text", text: "Run ls" }      content: "Run ls"
    ]                                     },
  },                                      { role: "assistant",
  { role: "assistant",                     content: "I'll check.",
    content: [                            tool_calls: [{
      { type: "text", text: "I'll check."},  id: "tu_123",
      { type: "tool_use",                    type: "function",
        id: "tu_123", name: "bash",          function: {
        input: { command: "ls" } }             name: "bash",
    ]                                           arguments: '{"command":"ls"}'
  },                                      }] }
  { role: "user",                        { role: "tool",
    content: [                              tool_call_id: "tu_123",
      { type: "tool_result",                content: "file1\nfile2"
        tool_use_id: "tu_123",            }
        content: "file1\nfile2"          ]
    ]
  }
]

流转换规则

SSE Chunk → Anthropic Event 映射

OpenAI Chunk Anthropic Event
首个 chunk message_start(含 usage)
delta.reasoning_content content_block_start(thinking) + thinking_delta
delta.content content_block_start(text) + text_delta
delta.tool_calls content_block_start(tool_use) + input_json_delta
finish_reason: "stop" message_delta(stop_reason: "end_turn")
finish_reason: "tool_calls" message_delta(stop_reason: "tool_use")
finish_reason: "length" message_delta(stop_reason: "max_tokens")

块顺序

当模型返回 reasoning_content 时(如 DeepSeek),块顺序与 Anthropic 一致:

thinking block (index 0)  ← delta.reasoning_content
text block    (index 1)   ← delta.content

或:

thinking block (index 0)  ← delta.reasoning_content
tool_use block (index 1)  ← delta.tool_calls

无 reasoning_content 时:

text block    (index 0)   ← delta.content
tool_use block (index 1)  ← delta.tool_calls(如果有)

finish_reason 映射

OpenAI Anthropic
stop end_turn
tool_calls tool_use
length max_tokens
content_filter end_turn

事件序列示例

纯文本响应:

OpenAI chunks:
  delta.content = "Hello"
  delta.content = " world"
  finish_reason = "stop"

→ Anthropic events:
  message_start       { message: { id, role: 'assistant', usage: {...} } }
  content_block_start { index: 0, content_block: { type: 'text' } }
  content_block_delta { index: 0, delta: { type: 'text_delta', text: 'Hello' } }
  content_block_delta { index: 0, delta: { type: 'text_delta', text: ' world' } }
  content_block_stop  { index: 0 }
  message_delta       { delta: { stop_reason: 'end_turn' } }
  message_stop

Thinking + 文本(DeepSeek 风格):

OpenAI chunks:
  delta.reasoning_content = "Let me think..."
  delta.reasoning_content = " step by step."
  delta.content = "The answer is 42."
  finish_reason = "stop"

→ Anthropic events:
  message_start       { ... }
  content_block_start { index: 0, content_block: { type: 'thinking', signature: '' } }
  content_block_delta { index: 0, delta: { type: 'thinking_delta', thinking: 'Let me think...' } }
  content_block_delta { index: 0, delta: { type: 'thinking_delta', thinking: ' step by step.' } }
  content_block_stop  { index: 0 }
  content_block_start { index: 1, content_block: { type: 'text' } }
  content_block_delta { index: 1, delta: { type: 'text_delta', text: 'The answer is 42.' } }
  content_block_stop  { index: 1 }
  message_delta       { delta: { stop_reason: 'end_turn' } }
  message_stop

工具调用:

OpenAI chunks:
  delta.tool_calls[0] = { id: 'call_xxx', function: { name: 'bash', arguments: '' } }
  delta.tool_calls[0].function.arguments = '{"comm'
  delta.tool_calls[0].function.arguments = 'and":"ls"}'
  finish_reason = "tool_calls"

→ Anthropic events:
  message_start       { ... }
  content_block_start { index: 0, content_block: { type: 'tool_use', id: 'call_xxx', name: 'bash' } }
  content_block_delta { index: 0, delta: { type: 'input_json_delta', partial_json: '{"comm' } }
  content_block_delta { index: 0, delta: { type: 'input_json_delta', partial_json: 'and":"ls"}' } }
  content_block_stop  { index: 0 }
  message_delta       { delta: { stop_reason: 'tool_use' } }
  message_stop

功能支持

Thinking(思维链)

请求侧:不需要显式配置。支持思维链的模型(DeepSeek 等)会自动返回 delta.reasoning_content。

响应侧:delta.reasoning_content 被转换为 Anthropic thinking content block:

// content_block_start
{ type: 'content_block_start', index: 0,
  content_block: { type: 'thinking', thinking: '', signature: '' } }

// content_block_delta
{ type: 'content_block_delta', index: 0,
  delta: { type: 'thinking_delta', thinking: 'Let me analyze...' } }

thinking block 在 text/tool_use block 之前自动关闭,保持 Anthropic 的块顺序。

Prompt Caching

请求侧:OpenAI 端点使用自动缓存,无需显式设置 cache_control。

响应侧:OpenAI 的 usage.prompt_tokens_details.cached_tokens 被映射到 Anthropic 的 cache_read_input_tokens:

OpenAI:   usage.prompt_tokens_details.cached_tokens = 800
     ↓
Anthropic: message_start.message.usage.cache_read_input_tokens = 800

在 message_start 的 usage 中报告缓存命中量。

工具调用(Tool Use)

完整支持 OpenAI function calling 格式。所有本地工具(Bash、FileEdit、Grep、Glob、Agent 等)透明工作——它们通过 JSON 输入输出通信,格式无关。

工具参数以 input_json_delta 形式流式传输,由下游代码拼接解析。

不支持的功能

功能 策略
Beta Headers 不发送
Server Tools (advisor) 不发送
Structured Output 不发送
Fast Mode / Effort 不发送
Tool Search / defer_loading 不启用,所有工具直接发送
Anthropic Signature thinking block 的 signature 字段为空字符串
cache_creation_input_tokens 始终为 0(OpenAI 不区分创建/读取)

测试

# 运行所有 OpenAI 适配层测试
bun test src/services/api/openai/__tests__/

# 单独运行
bun test src/services/api/openai/__tests__/streamAdapter.test.ts     # 14 tests(含 thinking + caching)
bun test src/services/api/openai/__tests__/convertMessages.test.ts   # 10 tests
bun test src/services/api/openai/__tests__/convertTools.test.ts      # 7 tests
bun test src/services/api/openai/__tests__/modelMapping.test.ts      # 6 tests

当前测试覆盖:39 tests / 73 assertions / 0 fail。

测试覆盖矩阵

功能 convertMessages convertTools streamAdapter modelMapping
文本消息转换 ✅
tool_use 转换 ✅
tool_result 转换 ✅
thinking 剥离 ✅
完整对话流程 ✅
工具 schema 转换 ✅
tool_choice 映射 ✅
纯文本流 ✅
工具调用流 ✅
混合文本+工具 ✅
finish_reason 映射 ✅
thinking 流 ✅
thinking+text 切换 ✅
thinking+tool_use 切换 ✅
块索引正确性 ✅
cached_tokens 映射 ✅
OPENAI_MODEL 覆盖 ✅
默认模型映射 ✅
未知模型透传 ✅
[1m] 后缀剥离 ✅

端到端验证

# 1. 安装依赖
bun install

# 2. 运行单元测试
bun test src/services/api/openai/__tests__/

# 3. 连接实际端点(以 Ollama 为例)
CLAUDE_CODE_USE_OPENAI=1 \
OPENAI_API_KEY=ollama \
OPENAI_BASE_URL=http://localhost:11434/v1 \
OPENAI_MODEL=qwen2.5-coder-32b \
bun run dev

# 4. 连接 DeepSeek(测试 thinking 支持)
CLAUDE_CODE_USE_OPENAI=1 \
OPENAI_API_KEY=sk-xxx \
OPENAI_BASE_URL=https://api.deepseek.com/v1 \
OPENAI_MODEL=deepseek-reasoner \
bun run dev

# 5. 确认现有测试不受影响
bun test  # 无 CLAUDE_CODE_USE_OPENAI 时走原有路径

代码统计

类别 行数
新增源码 ~620 行
新增测试 ~450 行
改动现有代码 ~25 行
总计 ~1100 行