mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
支持 OpenAI Chat 兼容协议 (#99)
* feat: 完成 openai 接口兼容 * feat: 完成 openai 协议兼容 * fix: 修复测试用例
This commit is contained in:
421
docs/plans/openai-compatibility.md
Normal file
421
docs/plans/openai-compatibility.md
Normal file
@@ -0,0 +1,421 @@
|
||||
# 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 |
|
||||
|
||||
### 使用示例
|
||||
|
||||
```bash
|
||||
# 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:
|
||||
|
||||
```ts
|
||||
// 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 不区分创建/读取) |
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
# 运行所有 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] 后缀剥离 | | | | ✅ |
|
||||
|
||||
## 端到端验证
|
||||
|
||||
```bash
|
||||
# 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 行** |
|
||||
Reference in New Issue
Block a user