# 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 行** |
