支持 OpenAI Chat 兼容协议 (#99)

* feat: 完成 openai 接口兼容

* feat: 完成 openai 协议兼容

* fix: 修复测试用例
This commit is contained in:
claude-code-best
2026-04-03 23:33:17 +08:00
committed by GitHub
parent 465e9f01c6
commit 00b044e8b2
22 changed files with 2283 additions and 18 deletions

View 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 keyOllama 等可设为任意值) |
| `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 | 始终为 0OpenAI 不区分创建/读取) |
## 测试
```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 行** |