Skip to content

Commit 0e6ad83

Browse files
committed
fix: support OpenAI tool calling
1 parent f432f39 commit 0e6ad83

6 files changed

Lines changed: 775 additions & 123 deletions

File tree

‎README.md‎

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Docker Hub 镜像:<https://hub.docker.com/repository/docker/zqbxdev/opencodeap
1313
- **1 小时内存缓存**:减少模型列表请求开销,提升接口响应速度。
1414
- **静态兜底模型列表**:当上游模型接口不可用时仍可返回可用模型列表。
1515
- **协议适配**:对部分非 OpenAI Chat Completions 协议模型进行请求/响应格式转换。
16+
- **工具调用支持 (Tool Calling)**:支持标准的 OpenAI `tools` 和 `tool_choice` 参数,在 Anthropic 等不同协议模型间实现请求和流式响应的双向转换,能够自动映射工具参数并转换流式 tool_calls 输出。
1617
- **Abort 级联中止**:客户端断开连接后主动中止上游 fetch,减少僵尸连接与带宽浪费。
1718
- **Docker 部署**:提供轻量 `Dockerfile` 与 Docker Hub 镜像发布工作流。
1819
- **Tag 触发发布**:推送 Git tag 后自动构建并推送 Docker 镜像到 Docker Hub。
@@ -55,7 +56,7 @@ docker run -d \
5556
--name opencodeapi \
5657
--restart unless-stopped \
5758
-p 4097:4097 \
58-
zqbxdev/opencodeapi:v1.0.0
59+
zqbxdev/opencodeapi:v1.0.1
5960
```
6061

6162
## API 使用示例
@@ -110,6 +111,75 @@ curl http://localhost:4097/v1/chat/completions \
110111
}'
111112
```
112113

114+
### 工具调用 (Tool Calling) 示例
115+
116+
```bash
117+
curl http://localhost:4097/v1/chat/completions \
118+
-H "Content-Type: application/json" \
119+
-d '{
120+
"model": "big-pickle",
121+
"messages": [
122+
{"role": "user", "content": "今天巴黎的天气怎么样?"}
123+
],
124+
"tools": [
125+
{
126+
"type": "function",
127+
"function": {
128+
"name": "get_weather",
129+
"description": "获取指定位置的当前天气情况",
130+
"parameters": {
131+
"type": "object",
132+
"properties": {
133+
"location": {
134+
"type": "string",
135+
"description": "城市名称,例如巴黎"
136+
}
137+
},
138+
"required": ["location"]
139+
}
140+
}
141+
}
142+
],
143+
"tool_choice": "auto"
144+
}'
145+
```
146+
147+
期望返回的响应中将包含 `tool_calls` 结构:
148+
149+
```json
150+
{
151+
"id": "chatcmpl-12345",
152+
"object": "chat.completion",
153+
"created": 1717689600,
154+
"model": "claude-3-opus-20240229",
155+
"choices": [
156+
{
157+
"index": 0,
158+
"message": {
159+
"role": "assistant",
160+
"content": "",
161+
"tool_calls": [
162+
{
163+
"id": "toolu_xyz",
164+
"type": "function",
165+
"function": {
166+
"name": "get_weather",
167+
"arguments": "{\"location\":\"巴黎\"}"
168+
}
169+
}
170+
]
171+
},
172+
"finish_reason": "tool_calls"
173+
}
174+
],
175+
"usage": {
176+
"prompt_tokens": 85,
177+
"completion_tokens": 40,
178+
"total_tokens": 125
179+
}
180+
}
181+
```
182+
113183
### 使用示例脚本
114184

115185
```bash

‎TECHNICAL.md‎

Lines changed: 56 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ OpenCode 后端可能对不同模型使用不同接口协议。`opencodeapi` 的
158158
POST https://opencode.ai/zen/v1/chat/completions
159159
```
160160

161-
该路径与 OpenAI Chat Completions 结构接近,因此主要做透传与错误规范化。
161+
该路径与 OpenAI Chat Completions 结构接近。在此路径下,系统执行透传处理,将包含 `tools`、`tool_choice`、`temperature`、`max_tokens` 等参数在内的完整请求体原样保留并透传至上游。
162162

163163
### 6.2 Anthropic Messages 路径
164164

@@ -170,21 +170,58 @@ POST https://opencode.ai/zen/v1/messages
170170

171171
这类模型需要额外转换。
172172

173-
请求转换重点:
174-
175-
- 将 OpenAI `messages` 转为 Anthropic `messages`。
176-
- 处理 `system` 消息位置差异。
177-
- 将复杂 content block 合并为文本。
178-
- 自动补充 `max_tokens`。
179-
180-
流式响应转换重点:
181-
182-
| Anthropic SSE 事件 | OpenAI SSE 转换 |
183-
|---|---|
184-
| `message_start` | 初始化 `chat.completion.chunk` |
185-
| `content_block_delta` | 转成 `choices[0].delta.content` |
186-
| `message_delta` | 转成带 `finish_reason` 的 chunk |
187-
| `message_stop` | 输出 `data: [DONE]` |
173+
#### 6.2.1 请求参数转换
174+
175+
在发起上游请求前,系统会调用 `buildRequestBody` 自动识别终端类型。如果是 Messages 终端,将调用 `buildAnthropicRequestBody` 对请求进行转换:
176+
177+
1. **请求体保留与透传**:继承原有请求体中无关转换的额外参数,确保透传无遗漏。
178+
2. **工具格式转换 (OpenAI Tools 到 Anthropic Tools)**:
179+
* 将 OpenAI 的 `tools` 数组(或旧版 `functions`)中的函数定义解构,映射为 Anthropic 所需的格式。
180+
* 将 OpenAI 的 `parameters` 属性重命名为 Anthropic 的 `input_schema`。
181+
* 当 `tool_choice` 或 `function_call` 设置为 `"none"` 时,禁用工具调用,上游请求将不携带任何 tools 参数。
182+
3. **工具选择逻辑转换 (tool_choice 转换)**:
183+
* OpenAI 的 `"auto"` 映射为 Anthropic 的 `{ "type": "auto" }`。
184+
* OpenAI 的 `"required"` 映射为 Anthropic 的 `{ "type": "any" }`。
185+
* 指定特定工具调用时(如 `{ type: "function", function: { name: "xxx" } }` 或 `{ name: "xxx" }`),映射为 Anthropic 的 `{ "type": "tool", "name": "xxx" }`。
186+
4. **消息历史与工具结果转换**:
187+
* 将 `system` 消息从 `messages` 数组中提取并合并,放入外层的 `system` 字段。
188+
* 合并相邻且角色相同的消息。
189+
* 将 `role: "tool"` 的 OpenAI 工具执行结果转换为 Anthropic 规范下的 `role: "user"`,并且其 content 包含 `type: "tool_result"` 节点,同时通过 `tool_use_id` 关联。
190+
* 将包含 `tool_calls` 的 `role: "assistant"` 消息转换为 content 包含 `type: "tool_use"` 节点的 Anthropic 格式,将 JSON 字符串参数解析为结构化 Object。
191+
* 为确保 ID 安全,对所有工具 ID 执行 `sanitizeToolId` 格式化,限制字符范围。
192+
193+
#### 6.2.2 响应结果转换 (非流式)
194+
195+
当上游非流式请求返回时,系统使用 `convertAnthropicResponse` 将其转换为标准的 OpenAI 响应格式:
196+
* 将 Anthropic 返回的 `tool_use` 节点转换为 OpenAI 的 `choices[0].message.tool_calls`。
197+
* 重新将结构化 Object 序列化为 OpenAI 规范的 JSON 字符串参数。
198+
* 将停止原因映射为 OpenAI 规范:如 `tool_use` 映射为 `tool_calls`,`end_turn`/`stop_sequence` 映射为 `stop`,`max_tokens` 映射为 `length`。
199+
200+
#### 6.2.3 流式响应转换 (SSE)
201+
202+
在流式响应中,上游发出的 Anthropic 细粒度事件需要被重组为 OpenAI 的 `chat.completion.chunk`。为此系统引入了 `createStreamState()` 创建请求级别的共享状态实例,包含:
203+
* `messageId`: 当前请求的唯一消息 ID,确保所有流式块使用同一 ID。
204+
* `model`: 当前响应模型名称。
205+
* `toolCalls`: 保存工具调用索引与标识的 Map 结构。
206+
* `nextToolCallIndex`: 从 0 开始的递增计数器。
207+
208+
具体事件映射与状态维护如下:
209+
* **`message_start`**: 提取消息 ID 和模型名称,初始化第一帧。
210+
* **`content_block_start`**:
211+
* 如果是 `type: "tool_use"`,利用 `nextToolCallIndex` 分配唯一的递增索引(从 0 开始),并将此事件在 Anthropic 中的块 `index` 关联绑定至该唯一索引。这确保了即使 Anthropic 块索引不连续,OpenAI 侧的 `tool_calls` 索引也从 0 开始。
212+
* 输出包含工具 `id` 和 `name` 的初始 chunk。
213+
* 如果是 `type: "text"`,输出文本初始帧。
214+
* **`content_block_delta`**:
215+
* 如果是 `type: "input_json_delta"`,通过 `streamState.toolCalls` 检索该块对应的唯一工具索引,并以 `delta.tool_calls[0].function.arguments` 的增量形式将参数 JSON 片段输出。
216+
* 如果是 `type: "text_delta"`,输出增量文本。
217+
* **`message_delta`**: 映射停止原因并将其填入 `finish_reason` 属性中输出。
218+
* **`message_stop`**: 返回流结束信号。
219+
220+
#### 6.2.4 重复 DONE 信号防护
221+
222+
在流处理循环(`routes/chat.js`)中,系统维护了 `doneSent` 状态:
223+
* 当检测到 `chunk.done` 时调用 `sendDone()` 方法,它会先校验并设置 `doneSent` 为 true,随后仅写入一次 `data: [DONE]\n\n`。
224+
* 之后的数据读取和任何兜底逻辑都会跳过,避免在异常退出或流尾部重复输出 `[DONE]` 信号。
188225

189226
---
190227

@@ -409,9 +446,10 @@ NODE_ENV=production
409446

410447
1. **保持 workflow secrets 最小权限**:Docker Hub token 建议只用于当前镜像仓库的 Read/Write。
411448
2. **发布版本使用语义化 tag**:例如 `v1.0.0`、`v1.0.1`。
412-
3. **修改上游协议适配时必须跑压测**:至少运行 `bun run test-stress.js`。
449+
3. **修改上游协议适配时必须跑压测与单元测试**:至少运行 `bun run test-stress.js` 并执行 `bun test` 确保所有单元测试(包含 `tests/tool_calling.test.js`)完全通过。
413450
4. **新增特殊模型时更新过滤与协议映射**:如果模型不是标准 Chat Completions 协议,需要在 `executor.js` 中加入转换逻辑。
414-
5. **避免无意义高并发滥用上游公共通道**:服务适合自用与测试,不应用于批量刷量或商业转售。
451+
5. **发布与 Tag 管理**:推送新版本 tag 触发自动构建发布时,请确保文档已被正确更新,并使用递增版本号发布(如 `v1.0.1` )。
452+
6. **避免无意义高并发滥用上游公共通道**:服务适合自用与测试,不应用于批量刷量或商业转售。
415453

416454
---
417455

‎package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
"main": "index.js",
77
"scripts": {
88
"start": "node index.js",
9-
"dev": "node --watch index.js"
9+
"dev": "node --watch index.js",
10+
"test": "bun test"
1011
},
1112
"dependencies": {
1213
"express": "^4.21.0"

0 commit comments

Comments
 (0)