@wwkit/freetoken 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +96 -0
- package/cli/helpers/args.js +48 -0
- package/cli/index.js +374 -0
- package/cli/pid-manager.js +87 -0
- package/client/index.html +25 -0
- package/client/public/favicon.svg +1 -0
- package/client/public/icons.svg +24 -0
- package/client/src/App.vue +136 -0
- package/client/src/assets/vite.svg +1 -0
- package/client/src/assets/vue.svg +1 -0
- package/client/src/components/ChatBox.vue +320 -0
- package/client/src/components/CheckList.vue +52 -0
- package/client/src/components/CodeBlock.vue +99 -0
- package/client/src/components/DetailBlock.vue +47 -0
- package/client/src/components/HelloWorld.vue +95 -0
- package/client/src/components/LangSwitch.vue +32 -0
- package/client/src/components/LinkList.vue +32 -0
- package/client/src/components/OsSwitch.vue +42 -0
- package/client/src/components/OsToggle.vue +15 -0
- package/client/src/components/PageHeader.vue +31 -0
- package/client/src/components/StepList.vue +92 -0
- package/client/src/composables/useClipboard.js +26 -0
- package/client/src/composables/useOs.js +22 -0
- package/client/src/data/docs/agent.js +1002 -0
- package/client/src/data/docs/auth.js +631 -0
- package/client/src/data/docs/builtin-tools.js +322 -0
- package/client/src/data/docs/chat.js +181 -0
- package/client/src/data/docs/index.js +9 -0
- package/client/src/data/docs/skills.js +396 -0
- package/client/src/data/docs/spec-conversion.js +1042 -0
- package/client/src/data/freeModels/bluesliu.js +97 -0
- package/client/src/data/freeModels/index.js +11 -0
- package/client/src/data/freeModels/nvidia.js +126 -0
- package/client/src/data/freeModels/openrouter.js +116 -0
- package/client/src/data/harness/claude.js +89 -0
- package/client/src/data/harness/codex.js +66 -0
- package/client/src/data/harness/dsh.js +86 -0
- package/client/src/data/harness/hermes.js +56 -0
- package/client/src/data/harness/index.js +22 -0
- package/client/src/data/harness/opencode.js +77 -0
- package/client/src/data/proxy/api-relay.js +53 -0
- package/client/src/data/proxy/builtin-proxy.js +54 -0
- package/client/src/data/proxy/cf-workers.js +67 -0
- package/client/src/data/proxy/ecs-forward.js +75 -0
- package/client/src/data/proxy/ecs-reverse.js +89 -0
- package/client/src/data/proxy/index.js +15 -0
- package/client/src/docs/claudecode.md +44 -0
- package/client/src/docs/codex.md +90 -0
- package/client/src/docs/hermesagent.md +42 -0
- package/client/src/docs/opencode.md +103 -0
- package/client/src/locales/en.js +436 -0
- package/client/src/locales/index.js +31 -0
- package/client/src/locales/zh-CN.js +461 -0
- package/client/src/main.js +16 -0
- package/client/src/router/index.js +76 -0
- package/client/src/style.css +15 -0
- package/client/src/views/AdminModelsView.vue +214 -0
- package/client/src/views/DocsView.vue +1604 -0
- package/client/src/views/FreeModelsView.vue +518 -0
- package/client/src/views/HarnessView.vue +314 -0
- package/client/src/views/HomeView.vue +147 -0
- package/client/src/views/ProxyView.vue +112 -0
- package/client/src/views/TokenMarketView.vue +26 -0
- package/client/vite.config.js +41 -0
- package/package.json +85 -0
- package/scripts/build-zip.sh +61 -0
- package/scripts/postinstall.js +7 -0
- package/server/src/config/targets.json +1 -0
- package/server/src/index.js +52 -0
- package/server/src/lib/coding-test.js +215 -0
- package/server/src/lib/database.js +353 -0
- package/server/src/lib/run-test.js +15 -0
- package/server/src/lib/scheduler.js +21 -0
- package/server/src/lib/tester.js +310 -0
- package/server/src/routes/admin.js +48 -0
- package/server/src/routes/proxy.js +64 -0
- package/server/src/routes/speed.js +87 -0
- package/src/config.js +45 -0
- package/src/config.json5 +27 -0
- package/src/index.js +10 -0
|
@@ -0,0 +1,1042 @@
|
|
|
1
|
+
// 规格转换模块:openai_chat / claude_chat / codex_chat 三种 LLM 接口规格的差异与转换机制
|
|
2
|
+
// 基于 @wego/bluesllm 代理工程 src/translator.js 的真实转换逻辑
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
id: 'spec-conversion',
|
|
6
|
+
name: 'Spec Conversion',
|
|
7
|
+
badge: '规格转换',
|
|
8
|
+
tagline: '三种 LLM 接口规格的差异与转换机制',
|
|
9
|
+
description:
|
|
10
|
+
'不同 LLM 厂商使用不同的 API 格式——OpenAI Chat Completions、Anthropic Messages、OpenAI Responses(Codex)。代理工程 bluesllm 的 translator.js 在这三种格式之间双向转换请求体、响应体和流式事件。本节展示三种规格的核心差异、转换映射关系,以及 bluesllm 的转换实现。',
|
|
11
|
+
|
|
12
|
+
// ── 三种规格概览 ──
|
|
13
|
+
specs: [
|
|
14
|
+
{
|
|
15
|
+
id: 'openai_chat',
|
|
16
|
+
name: 'OpenAI Chat Completions',
|
|
17
|
+
endpoint: 'POST /v1/chat/completions',
|
|
18
|
+
description:
|
|
19
|
+
'最广泛使用的 LLM API 格式,由 OpenAI 定义,被绝大多数模型服务商兼容。消息以 messages 数组组织,角色含 system/user/assistant/tool。工具调用通过 message.tool_calls 返回,工具结果以 role: "tool" 消息回传。',
|
|
20
|
+
keyFields: [
|
|
21
|
+
{ field: 'messages', type: 'array', note: '对话消息数组,每条含 role + content' },
|
|
22
|
+
{ field: 'model', type: 'string', note: '模型 ID' },
|
|
23
|
+
{ field: 'max_tokens', type: 'integer', note: '最大生成 token 数' },
|
|
24
|
+
{ field: 'temperature', type: 'number', note: '采样温度' },
|
|
25
|
+
{ field: 'stream', type: 'boolean', note: '是否流式返回' },
|
|
26
|
+
{ field: 'tools', type: 'array', note: '工具定义,嵌套在 function 字段下' },
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: 'claude_chat',
|
|
31
|
+
name: 'Anthropic Messages',
|
|
32
|
+
endpoint: 'POST /v1/messages',
|
|
33
|
+
description:
|
|
34
|
+
'Anthropic Claude 的原生 API 格式。系统提示为顶层 system 字段(不在 messages 中),消息只含 user/assistant 角色。内容以 blocks 数组组织,工具调用是 content 中的 tool_use block,工具结果是 tool_result block。',
|
|
35
|
+
keyFields: [
|
|
36
|
+
{ field: 'system', type: 'string/array', note: '系统提示,顶层字段' },
|
|
37
|
+
{ field: 'messages', type: 'array', note: '仅 user/assistant 角色' },
|
|
38
|
+
{ field: 'max_tokens', type: 'integer', note: '必填,最大生成 token 数' },
|
|
39
|
+
{ field: 'temperature', type: 'number', note: '采样温度' },
|
|
40
|
+
{ field: 'stream', type: 'boolean', note: '是否流式返回' },
|
|
41
|
+
{ field: 'tools', type: 'array', note: '工具定义,用 input_schema 而非 parameters' },
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: 'codex_chat',
|
|
46
|
+
name: 'OpenAI Responses (Codex)',
|
|
47
|
+
endpoint: 'POST /v1/responses',
|
|
48
|
+
note: 'Codex CLI 使用的 Responses API 格式',
|
|
49
|
+
description:
|
|
50
|
+
'OpenAI 2024 年推出的新格式,被 Codex CLI 采用。系统提示为顶层 instructions 字段,对话以 input 数组组织,条目类型含 message/function_call/function_call_output。系统角色称为 developer 而非 system。',
|
|
51
|
+
keyFields: [
|
|
52
|
+
{ field: 'instructions', type: 'string', note: '系统提示,顶层字段' },
|
|
53
|
+
{ field: 'input', type: 'array', note: '对话条目数组,含多种 type' },
|
|
54
|
+
{ field: 'max_output_tokens', type: 'integer', note: '最大生成 token 数(非 max_tokens)' },
|
|
55
|
+
{ field: 'temperature', type: 'number', note: '采样温度' },
|
|
56
|
+
{ field: 'stream', type: 'boolean', note: '是否流式返回' },
|
|
57
|
+
{ field: 'tools', type: 'array', note: '工具定义,name/parameters 在顶层而非 function 嵌套' },
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
|
|
62
|
+
// ── 差异对比表 ──
|
|
63
|
+
comparisonTable: [
|
|
64
|
+
{
|
|
65
|
+
aspect: '端点',
|
|
66
|
+
openai: 'POST /v1/chat/completions',
|
|
67
|
+
claude: 'POST /v1/messages',
|
|
68
|
+
codex: 'POST /v1/responses',
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
aspect: '系统提示',
|
|
72
|
+
openai: 'messages 中 role: "system"',
|
|
73
|
+
claude: '顶层 system 字段(string 或 array)',
|
|
74
|
+
codex: '顶层 instructions 字段;角色称 "developer"',
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
aspect: '消息数组',
|
|
78
|
+
openai: 'messages[] — role + content',
|
|
79
|
+
claude: 'messages[] — 仅 user/assistant,content 为 blocks 数组',
|
|
80
|
+
codex: 'input[] — 含 message / function_call / function_call_output',
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
aspect: '内容格式',
|
|
84
|
+
openai: 'content 为 string 或 content_parts 数组',
|
|
85
|
+
claude: 'content 为 blocks 数组:text / image / tool_use / tool_result',
|
|
86
|
+
codex: 'content 为 parts 数组:input_text / output_text / input_image',
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
aspect: '工具定义',
|
|
90
|
+
openai: 'tools[].function.{ name, description, parameters }',
|
|
91
|
+
claude: 'tools[].{ name, description, input_schema }',
|
|
92
|
+
codex: 'tools[].{ type: "function", name, description, parameters }',
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
aspect: '工具调用(请求中)',
|
|
96
|
+
openai: 'message.tool_calls[].function.arguments(JSON 字符串)',
|
|
97
|
+
claude: 'content block: { type: "tool_use", id, name, input(对象) }',
|
|
98
|
+
codex: 'input item: { type: "function_call", call_id, name, arguments(字符串) }',
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
aspect: '工具结果',
|
|
102
|
+
openai: '{ role: "tool", tool_call_id, content }',
|
|
103
|
+
claude: 'content block: { type: "tool_result", tool_use_id, content }',
|
|
104
|
+
codex: 'input item: { type: "function_call_output", call_id, output }',
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
aspect: 'Token 限制',
|
|
108
|
+
openai: 'max_tokens',
|
|
109
|
+
claude: 'max_tokens(必填)',
|
|
110
|
+
codex: 'max_output_tokens',
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
aspect: '停止序列',
|
|
114
|
+
openai: 'stop(string 或 array)',
|
|
115
|
+
claude: 'stop_sequences(array)',
|
|
116
|
+
codex: '无直接对应',
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
aspect: 'finish_reason',
|
|
120
|
+
openai: '"stop" / "tool_calls" / "length"',
|
|
121
|
+
claude: 'stop_reason: "end_turn" / "tool_use" / "max_tokens"',
|
|
122
|
+
codex: 'finish_reason(同 OpenAI)',
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
aspect: 'usage 字段',
|
|
126
|
+
openai: 'prompt_tokens / completion_tokens / total_tokens',
|
|
127
|
+
claude: 'input_tokens / output_tokens',
|
|
128
|
+
codex: 'input_tokens / output_tokens / total_tokens',
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
aspect: '流式格式',
|
|
132
|
+
openai: 'data: { choices: [{ delta: { content } }] }',
|
|
133
|
+
claude: 'event: content_block_delta + data: { delta: { type: "text_delta", text } }',
|
|
134
|
+
codex: 'event: response.output_text.delta + data: { delta: "..." }',
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
aspect: '流式结束标记',
|
|
138
|
+
openai: 'data: [DONE]',
|
|
139
|
+
claude: 'event: message_stop',
|
|
140
|
+
codex: 'event: response.completed',
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
aspect: '推理/思考',
|
|
144
|
+
openai: 'message.reasoning_content(扩展字段)',
|
|
145
|
+
claude: 'content block: { type: "thinking", thinking: "..." }',
|
|
146
|
+
codex: 'output item: { type: "reasoning", summary: [...] }',
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
|
|
150
|
+
// ── 请求体转换示例 ──
|
|
151
|
+
requestConversion: {
|
|
152
|
+
title: '请求体转换',
|
|
153
|
+
description:
|
|
154
|
+
'同一段对话(系统提示 + 用户消息 + 工具调用 + 工具结果)在三种格式下的请求体对比。bluesllm 的 translator.js 通过 claudeToChatRequest / chatToClaudeRequest / chatToResponsesRequest 等函数实现双向转换。',
|
|
155
|
+
examples: [
|
|
156
|
+
{
|
|
157
|
+
spec: 'openai_chat',
|
|
158
|
+
label: 'OpenAI Chat',
|
|
159
|
+
code: `POST /v1/chat/completions
|
|
160
|
+
Content-Type: application/json
|
|
161
|
+
Authorization: Bearer sk-xxx
|
|
162
|
+
|
|
163
|
+
{
|
|
164
|
+
"model": "maas-glm-5.1-zhipu",
|
|
165
|
+
"messages": [
|
|
166
|
+
{ "role": "system", "content": "你是一个编程助手" },
|
|
167
|
+
{ "role": "user", "content": "查看当前目录" },
|
|
168
|
+
{
|
|
169
|
+
"role": "assistant",
|
|
170
|
+
"content": null,
|
|
171
|
+
"tool_calls": [{
|
|
172
|
+
"id": "call_001",
|
|
173
|
+
"type": "function",
|
|
174
|
+
"function": { "name": "bash", "arguments": "{\\"command\\":\\"ls\\"}" }
|
|
175
|
+
}]
|
|
176
|
+
},
|
|
177
|
+
{ "role": "tool", "tool_call_id": "call_001", "content": "file1.js\\nfile2.js" }
|
|
178
|
+
],
|
|
179
|
+
"max_tokens": 1024,
|
|
180
|
+
"temperature": 0.7,
|
|
181
|
+
"tools": [{
|
|
182
|
+
"type": "function",
|
|
183
|
+
"function": {
|
|
184
|
+
"name": "bash",
|
|
185
|
+
"description": "Execute bash command",
|
|
186
|
+
"parameters": { "type": "object", "properties": { "command": { "type": "string" } }, "required": ["command"] }
|
|
187
|
+
}
|
|
188
|
+
}]
|
|
189
|
+
}`,
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
spec: 'claude_chat',
|
|
193
|
+
label: 'Anthropic Claude',
|
|
194
|
+
code: `POST /v1/messages
|
|
195
|
+
Content-Type: application/json
|
|
196
|
+
x-api-key: sk-xxx
|
|
197
|
+
|
|
198
|
+
{
|
|
199
|
+
"model": "maas-glm-5.1-zhipu",
|
|
200
|
+
"system": "你是一个编程助手",
|
|
201
|
+
"messages": [
|
|
202
|
+
{ "role": "user", "content": "查看当前目录" },
|
|
203
|
+
{
|
|
204
|
+
"role": "assistant",
|
|
205
|
+
"content": [
|
|
206
|
+
{ "type": "tool_use", "id": "call_001", "name": "bash", "input": { "command": "ls" } }
|
|
207
|
+
]
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
"role": "user",
|
|
211
|
+
"content": [
|
|
212
|
+
{ "type": "tool_result", "tool_use_id": "call_001", "content": "file1.js\\nfile2.js" }
|
|
213
|
+
]
|
|
214
|
+
}
|
|
215
|
+
],
|
|
216
|
+
"max_tokens": 1024,
|
|
217
|
+
"temperature": 0.7,
|
|
218
|
+
"tools": [{
|
|
219
|
+
"name": "bash",
|
|
220
|
+
"description": "Execute bash command",
|
|
221
|
+
"input_schema": { "type": "object", "properties": { "command": { "type": "string" } }, "required": ["command"] }
|
|
222
|
+
}]
|
|
223
|
+
}`,
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
spec: 'codex_chat',
|
|
227
|
+
label: 'Codex Responses',
|
|
228
|
+
code: `POST /v1/responses
|
|
229
|
+
Content-Type: application/json
|
|
230
|
+
Authorization: Bearer sk-xxx
|
|
231
|
+
|
|
232
|
+
{
|
|
233
|
+
"model": "maas-glm-5.1-zhipu",
|
|
234
|
+
"instructions": "你是一个编程助手",
|
|
235
|
+
"input": [
|
|
236
|
+
{ "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "查看当前目录" }] },
|
|
237
|
+
{ "type": "function_call", "id": "call_001", "call_id": "call_001", "name": "bash", "arguments": "{\\"command\\":\\"ls\\"}" },
|
|
238
|
+
{ "type": "function_call_output", "call_id": "call_001", "output": "file1.js\\nfile2.js" }
|
|
239
|
+
],
|
|
240
|
+
"max_output_tokens": 1024,
|
|
241
|
+
"temperature": 0.7,
|
|
242
|
+
"tools": [{
|
|
243
|
+
"type": "function",
|
|
244
|
+
"name": "bash",
|
|
245
|
+
"description": "Execute bash command",
|
|
246
|
+
"parameters": { "type": "object", "properties": { "command": { "type": "string" } }, "required": ["command"] }
|
|
247
|
+
}],
|
|
248
|
+
"store": false,
|
|
249
|
+
"parallel_tool_calls": true
|
|
250
|
+
}`,
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
// 转换映射要点
|
|
254
|
+
mappings: [
|
|
255
|
+
{ from: 'messages[role=system]', to_claude: 'system(顶层)', to_codex: 'instructions(顶层)', note: '系统提示从消息数组提取为顶层字段' },
|
|
256
|
+
{ from: 'messages[role=tool]', to_claude: 'content block: tool_result', to_codex: 'input item: function_call_output', note: '工具结果从独立消息变为内容块/输入条目' },
|
|
257
|
+
{ from: 'message.tool_calls', to_claude: 'content block: tool_use', to_codex: 'input item: function_call', note: '工具调用从消息字段变为内容块/输入条目' },
|
|
258
|
+
{ from: 'tools[].function.parameters', to_claude: 'tools[].input_schema', to_codex: 'tools[].parameters(顶层)', note: '参数 Schema 字段名和嵌套层级不同' },
|
|
259
|
+
{ from: 'max_tokens', to_claude: 'max_tokens', to_codex: 'max_output_tokens', note: 'Codex 重命名为 max_output_tokens' },
|
|
260
|
+
{ from: 'stop', to_claude: 'stop_sequences', to_codex: '无', note: '停止序列仅 OpenAI 和 Claude 支持' },
|
|
261
|
+
],
|
|
262
|
+
},
|
|
263
|
+
|
|
264
|
+
// ── 响应体转换示例 ──
|
|
265
|
+
responseConversion: {
|
|
266
|
+
title: '响应体转换',
|
|
267
|
+
description:
|
|
268
|
+
'模型返回的非流式响应在三种格式下的结构对比。注意 content/tool_calls/usage 字段的位置和命名差异。',
|
|
269
|
+
examples: [
|
|
270
|
+
{
|
|
271
|
+
spec: 'openai_chat',
|
|
272
|
+
label: 'OpenAI Chat',
|
|
273
|
+
code: `{
|
|
274
|
+
"id": "chatcmpl-xxx",
|
|
275
|
+
"object": "chat.completion",
|
|
276
|
+
"model": "maas-glm-5.1-zhipu",
|
|
277
|
+
"choices": [{
|
|
278
|
+
"index": 0,
|
|
279
|
+
"message": {
|
|
280
|
+
"role": "assistant",
|
|
281
|
+
"content": "当前目录下有 file1.js 和 file2.js",
|
|
282
|
+
"reasoning_content": "用户想查看目录..."
|
|
283
|
+
},
|
|
284
|
+
"finish_reason": "stop"
|
|
285
|
+
}],
|
|
286
|
+
"usage": { "prompt_tokens": 50, "completion_tokens": 20, "total_tokens": 70 }
|
|
287
|
+
}`,
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
spec: 'claude_chat',
|
|
291
|
+
label: 'Anthropic Claude',
|
|
292
|
+
code: `{
|
|
293
|
+
"id": "msg_xxx",
|
|
294
|
+
"type": "message",
|
|
295
|
+
"role": "assistant",
|
|
296
|
+
"model": "maas-glm-5.1-zhipu",
|
|
297
|
+
"content": [
|
|
298
|
+
{ "type": "thinking", "thinking": "用户想查看目录..." },
|
|
299
|
+
{ "type": "text", "text": "当前目录下有 file1.js 和 file2.js" }
|
|
300
|
+
],
|
|
301
|
+
"stop_reason": "end_turn",
|
|
302
|
+
"stop_sequence": null,
|
|
303
|
+
"usage": { "input_tokens": 50, "output_tokens": 20 }
|
|
304
|
+
}`,
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
spec: 'codex_chat',
|
|
308
|
+
label: 'Codex Responses',
|
|
309
|
+
code: `{
|
|
310
|
+
"id": "resp_xxx",
|
|
311
|
+
"object": "response",
|
|
312
|
+
"status": "completed",
|
|
313
|
+
"model": "maas-glm-5.1-zhipu",
|
|
314
|
+
"output": [
|
|
315
|
+
{ "id": "rs_xxx_0", "type": "reasoning", "summary": [{ "type": "summary_text", "text": "用户想查看目录..." }] },
|
|
316
|
+
{ "id": "msg_xxx_0", "type": "message", "role": "assistant", "status": "completed",
|
|
317
|
+
"content": [{ "type": "output_text", "text": "当前目录下有 file1.js 和 file2.js" }] }
|
|
318
|
+
],
|
|
319
|
+
"usage": { "input_tokens": 50, "output_tokens": 20, "total_tokens": 70 }
|
|
320
|
+
}`,
|
|
321
|
+
},
|
|
322
|
+
],
|
|
323
|
+
mappings: [
|
|
324
|
+
{ from: 'choices[0].message.content', to_claude: 'content[].text block', to_codex: 'output[message].content[].text', note: '文本内容从 message 字段变为 content block' },
|
|
325
|
+
{ from: 'choices[0].message.tool_calls', to_claude: 'content[].tool_use block', to_codex: 'output[].function_call item', note: '工具调用从 message 字段变为 content block / output item' },
|
|
326
|
+
{ from: 'message.reasoning_content', to_claude: 'content[].thinking block', to_codex: 'output[].reasoning item', note: '推理内容(扩展字段)的映射' },
|
|
327
|
+
{ from: 'finish_reason: "stop"/"tool_calls"/"length"', to_claude: 'stop_reason: "end_turn"/"tool_use"/"max_tokens"', to_codex: 'finish_reason(同 OpenAI)', note: '停止原因的枚举值映射' },
|
|
328
|
+
{ from: 'usage.prompt_tokens', to_claude: 'usage.input_tokens', to_codex: 'usage.input_tokens', note: 'Token 统计字段名不同' },
|
|
329
|
+
],
|
|
330
|
+
},
|
|
331
|
+
|
|
332
|
+
// ── 流式事件转换 ──
|
|
333
|
+
streamConversion: {
|
|
334
|
+
title: '流式事件转换',
|
|
335
|
+
description:
|
|
336
|
+
'三种格式的流式 SSE 事件结构差异最大。bluesllm 的 translator.js 维护有状态转换器(Translator 类),在 chunk 之间累积状态,将一种格式的事件流逐条翻译为另一种格式的事件流。',
|
|
337
|
+
eventMapping: [
|
|
338
|
+
{
|
|
339
|
+
phase: '流开始',
|
|
340
|
+
openai: 'data: { choices: [{ delta: { role: "assistant" } }] }',
|
|
341
|
+
claude: 'event: message_start\\ndata: { type: "message_start", message: { role: "assistant" } }',
|
|
342
|
+
codex: 'event: response.created\\ndata: { type: "response.created", response: { status: "in_progress" } }',
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
phase: '文本增量',
|
|
346
|
+
openai: 'data: { choices: [{ delta: { content: "Hello" } }] }',
|
|
347
|
+
claude: 'event: content_block_delta\\ndata: { delta: { type: "text_delta", text: "Hello" } }',
|
|
348
|
+
codex: 'event: response.output_text.delta\\ndata: { type: "response.output_text.delta", delta: "Hello" }',
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
phase: '推理增量',
|
|
352
|
+
openai: 'data: { choices: [{ delta: { reasoning_content: "思考..." } }] }',
|
|
353
|
+
claude: 'event: content_block_delta\\ndata: { delta: { type: "thinking_delta", thinking: "思考..." } }',
|
|
354
|
+
codex: 'event: response.reasoning_summary_text.delta\\ndata: { delta: "思考..." }',
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
phase: '工具调用增量',
|
|
358
|
+
openai: 'data: { choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: "{\\"" } }] } }] }',
|
|
359
|
+
claude: 'event: content_block_delta\\ndata: { delta: { type: "input_json_delta", partial_json: "{\\"" } }',
|
|
360
|
+
codex: 'event: response.function_call_arguments.delta\\ndata: { delta: "{\\"" } }',
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
phase: '流结束',
|
|
364
|
+
openai: 'data: { choices: [{ delta: {}, finish_reason: "stop" }] }\\n\\ndata: [DONE]',
|
|
365
|
+
claude: 'event: message_delta\\ndata: { delta: { stop_reason: "end_turn" } }\\n\\nevent: message_stop',
|
|
366
|
+
codex: 'event: response.completed\\ndata: { response: { status: "completed" } }',
|
|
367
|
+
},
|
|
368
|
+
],
|
|
369
|
+
notes: [
|
|
370
|
+
'OpenAI 流式最简单:每个 chunk 只含 choices[0].delta,客户端拼接 content 即可',
|
|
371
|
+
'Claude 流式最复杂:需要处理 content_block_start/delta/stop 和 message_start/delta/stop 多种事件类型',
|
|
372
|
+
'Codex 流式介于两者之间:事件类型丰富(response.created/output_text.delta/function_call_arguments.delta/completed)但结构相对统一',
|
|
373
|
+
'bluesllm 的 Translator 类在 translateStreamEvent 中维护 state 对象,跨 chunk 累积工具调用参数和消息状态',
|
|
374
|
+
'当 source ≠ target 时,一个输入事件可能产生 0~N 个输出事件(如 Claude 的 content_block_stop 会触发 OpenAI 的完整 tool_call chunk)',
|
|
375
|
+
],
|
|
376
|
+
},
|
|
377
|
+
|
|
378
|
+
// ── 转换架构 ──
|
|
379
|
+
architecture: {
|
|
380
|
+
title: 'bluesllm 转换架构',
|
|
381
|
+
description:
|
|
382
|
+
'bluesllm 代理的核心设计:上游(upstream)使用固定的 source_format(通常为 openai_chat),客户端可以使用任意 target_format。代理在请求转发前将客户端格式转为 source_format,在响应返回前将 source_format 转回客户端格式。',
|
|
383
|
+
flow: [
|
|
384
|
+
{ step: '1. 客户端请求', detail: '客户端以 target_format(openai_chat / claude_chat / codex_chat)发送请求' },
|
|
385
|
+
{ step: '2. 请求翻译', detail: 'translator.translateRequest() 将 target_format → source_format' },
|
|
386
|
+
{ step: '3. 上游转发', detail: '代理以 source_format 向上游 LLM 发送请求' },
|
|
387
|
+
{ step: '4. 响应接收', detail: '上游返回 source_format 格式的响应(流式或非流式)' },
|
|
388
|
+
{ step: '5. 响应翻译', detail: 'translator.translateNonstreamResponse() 或 translateStreamEvent() 将 source_format → target_format' },
|
|
389
|
+
{ step: '6. 客户端响应', detail: '客户端收到 target_format 格式的响应,无需感知代理存在' },
|
|
390
|
+
],
|
|
391
|
+
configExample: `// configs/base.json
|
|
392
|
+
{
|
|
393
|
+
"upstream": {
|
|
394
|
+
"source_format": "openai_chat",
|
|
395
|
+
"target_formats": ["openai_chat", "claude_chat", "codex_chat"]
|
|
396
|
+
}
|
|
397
|
+
}`,
|
|
398
|
+
},
|
|
399
|
+
|
|
400
|
+
// ── 已知问题(代码审查发现) ──
|
|
401
|
+
knownIssues: {
|
|
402
|
+
title: '已知问题(代码审查发现)',
|
|
403
|
+
description: '在审查 bluesllm translator.js 时发现以下问题,使用时需注意:',
|
|
404
|
+
issues: [
|
|
405
|
+
{
|
|
406
|
+
severity: 'high',
|
|
407
|
+
title: 'chatToResponsesRequest 未处理 role: "tool" 消息',
|
|
408
|
+
detail: '当 OpenAI Chat 格式的请求中含 { role: "tool", tool_call_id, content } 消息时,chatToResponsesRequest 函数未将其转换为 { type: "function_call_output", call_id, output } 条目,而是错误地将其当作普通 user 消息处理(role 被映射为 "user"),丢失了 tool_call_id 关联。正确做法应在循环中增加 role === "tool" 的分支:input.push({ type: "function_call_output", call_id: message.tool_call_id, output: message.content })。',
|
|
409
|
+
location: 'translator.js 第 204-218 行 chatToResponsesRequest 函数',
|
|
410
|
+
fix: `// 在 for (const message of messages) 循环中添加:
|
|
411
|
+
if (role === 'tool') {
|
|
412
|
+
input.push({
|
|
413
|
+
type: 'function_call_output',
|
|
414
|
+
call_id: message.tool_call_id || '',
|
|
415
|
+
output: typeof message.content === 'string'
|
|
416
|
+
? message.content
|
|
417
|
+
: JSON.stringify(message.content || '')
|
|
418
|
+
})
|
|
419
|
+
continue
|
|
420
|
+
}`,
|
|
421
|
+
},
|
|
422
|
+
{
|
|
423
|
+
severity: 'medium',
|
|
424
|
+
title: 'claudeToChatRequest 中 tool_result 排序问题',
|
|
425
|
+
detail: '当 Claude 消息同时含 text block 和 tool_result block 时,tool_result 会在当前消息之前被 push 到 messages 数组(因为它在 block 循环内立即 push,而当前消息在循环后 push)。虽然实际场景中 tool_result 通常单独出现在 user 消息中,但混合 block 的情况可能导致消息顺序异常。',
|
|
426
|
+
location: 'translator.js 第 74-100 行 claudeToChatRequest 函数',
|
|
427
|
+
fix: null,
|
|
428
|
+
},
|
|
429
|
+
{
|
|
430
|
+
severity: 'low',
|
|
431
|
+
title: 'Codex 格式 function_call 的 id 与 call_id 使用相同值',
|
|
432
|
+
detail: 'chatToResponsesRequest 中 function_call 条目的 id 和 call_id 都设为 toolCall.id。在 Responses API 规范中,id 是条目 ID,call_id 是函数调用 ID,两者语义不同。使用相同值在多数场景下可行,但严格来说应区分。',
|
|
433
|
+
location: 'translator.js 第 212 行',
|
|
434
|
+
fix: null,
|
|
435
|
+
},
|
|
436
|
+
],
|
|
437
|
+
},
|
|
438
|
+
|
|
439
|
+
// ── 协议桥接(openai-tool-bridge) ──
|
|
440
|
+
toolBridge: {
|
|
441
|
+
title: 'openai-tool-bridge 协议桥接',
|
|
442
|
+
description:
|
|
443
|
+
'openai-tool-bridge 是一个 Go 实现的轻量代理,解决与 bluesllm translator.js 完全不同的问题:让不支持原生 tools/tool_calls 协议的模型也能用 tool calling。它不是格式转换(A 格式 → B 格式),而是协议降级桥接(tools 协议 → system prompt 文本 → 还原回 tools 协议)。客户端始终使用标准 OpenAI 格式,完全感知不到上游模型不支持 tools。',
|
|
444
|
+
comparison: {
|
|
445
|
+
title: '与 bluesllm 的区别',
|
|
446
|
+
rows: [
|
|
447
|
+
{ aspect: '解决的问题', bluesllm: '三种 API 格式互转(OpenAI ↔ Claude ↔ Codex)', toolBridge: '让不支持 tools 协议的模型也能用 tool calling' },
|
|
448
|
+
{ aspect: '转换类型', bluesllm: '格式映射(结构对结构)', toolBridge: '协议降级桥接(协议 → 文本 → 协议)' },
|
|
449
|
+
{ aspect: '客户端格式', bluesllm: '可以是 Claude / Codex 格式', toolBridge: '始终是 OpenAI 格式(不变)' },
|
|
450
|
+
{ aspect: '上游需要', bluesllm: '支持目标格式的原生协议', toolBridge: '不需要 tools 协议,只需能读 prompt 并输出文本' },
|
|
451
|
+
{ aspect: 'tools 字段', bluesllm: '保留并转换格式', toolBridge: '删除,改为注入 system prompt' },
|
|
452
|
+
{ aspect: '响应处理', bluesllm: '转换响应格式', toolBridge: '解析 XML 文本还原为 tool_calls' },
|
|
453
|
+
{ aspect: '语言', bluesllm: 'JavaScript (ESM)', toolBridge: 'Go' },
|
|
454
|
+
],
|
|
455
|
+
},
|
|
456
|
+
flow: [
|
|
457
|
+
{ phase: '1. 客户端请求', detail: '客户端发送标准 OpenAI 请求,含 tools 数组 + tool_calls 历史 + tool 结果消息' },
|
|
458
|
+
{ phase: '2. 请求桥接', detail: 'ApplyToolPromptBridge() 删除 tools/tool_choice 字段,将工具定义注入 system prompt,将 tool_calls 编码为 XML 文本,将 role:tool 改写为 role:user + <|tool_result|> 标签' },
|
|
459
|
+
{ phase: '3. 上游转发', detail: '上游模型收到纯文本对话,无任何 tools 协议字段,通过 prompt 理解可用工具' },
|
|
460
|
+
{ phase: '4. 上游响应', detail: '模型在文本中输出 XML 风格的伪工具调用(如 <function=bash>...)' },
|
|
461
|
+
{ phase: '5. 响应还原', detail: 'NormalizeNonStreamResponse() 解析 XML 文本,还原为标准 tool_calls 结构,设置 finish_reason: "tool_calls"' },
|
|
462
|
+
{ phase: '6. 思考清洗', detail: 'ExtractPromptBridgeThinkingContent() 将 Thinking...\\n> ... 格式的思考文本提取到 reasoning_content' },
|
|
463
|
+
{ phase: '7. 客户端响应', detail: '客户端收到标准 OpenAI tool_calls 响应,完全感知不到桥接过程' },
|
|
464
|
+
],
|
|
465
|
+
requestBridge: {
|
|
466
|
+
title: '请求桥接:tools → prompt 文本',
|
|
467
|
+
description: 'ApplyToolPromptBridge() 在请求转发前执行三步操作:删除 tools 字段、注入 system prompt、编码历史 tool_calls。',
|
|
468
|
+
steps: [
|
|
469
|
+
{
|
|
470
|
+
step: '删除协议字段',
|
|
471
|
+
detail: '删除 payload 中的 tools、tool_choice、parallel_tool_calls 字段,上游不会看到任何 tools 协议。',
|
|
472
|
+
code: `// request.go
|
|
473
|
+
delete(payload, "tools")
|
|
474
|
+
delete(payload, "tool_choice")
|
|
475
|
+
delete(payload, "parallel_tool_calls")`,
|
|
476
|
+
},
|
|
477
|
+
{
|
|
478
|
+
step: '注入 system prompt',
|
|
479
|
+
detail: '将工具定义拼成一段 prompt 文本,注入到第一条 system/user/developer 消息前。prompt 告诉模型用 XML 标签输出工具调用。',
|
|
480
|
+
code: `// prompt 核心内容(简化)
|
|
481
|
+
你当前运行在工具提示桥接模式。目标模型不支持原生 tool_calls。
|
|
482
|
+
|
|
483
|
+
如果需要调用工具,请直接输出 XML 标签块:
|
|
484
|
+
<function=工具名称>
|
|
485
|
+
<parameter=arguments_json>{"参数名":"参数值"}</parameter>
|
|
486
|
+
</function>
|
|
487
|
+
|
|
488
|
+
可用工具如下:
|
|
489
|
+
|
|
490
|
+
### bash
|
|
491
|
+
Execute bash command
|
|
492
|
+
参数 JSON Schema:
|
|
493
|
+
{"type":"object","properties":{"command":{"type":"string"}},"required":["command"]}`,
|
|
494
|
+
},
|
|
495
|
+
{
|
|
496
|
+
step: '编码历史 tool_calls',
|
|
497
|
+
detail: '将 assistant 消息中的 tool_calls 编码为 XML 文本追加到 content,将 role:tool 消息改写为 role:user + <|tool_result|> 标签。',
|
|
498
|
+
code: `// assistant 消息的 tool_calls → XML 文本
|
|
499
|
+
// 原始: { role: "assistant", tool_calls: [{ id: "call_001", function: { name: "bash", arguments: "{\\"command\\":\\"ls\\"}" } }] }
|
|
500
|
+
// 桥接后: { role: "assistant", content: "<function=bash>\\n<parameter=arguments_json>{\\"command\\":\\"ls\\"}</parameter>\\n</function>" }
|
|
501
|
+
|
|
502
|
+
// tool 结果消息 → user 消息 + 标签
|
|
503
|
+
// 原始: { role: "tool", tool_call_id: "call_001", content: "file1.js" }
|
|
504
|
+
// 桥接后: { role: "user", content: "<|tool_result|>\\n{\\"tool_call_id\\":\\"call_001\\",\\"name\\":\\"bash\\",\\"content\\":\\"file1.js\\"}\\n</|tool_result|>" }`,
|
|
505
|
+
},
|
|
506
|
+
],
|
|
507
|
+
},
|
|
508
|
+
responseBridge: {
|
|
509
|
+
title: '响应还原:文本 → tool_calls',
|
|
510
|
+
description: 'NormalizeNonStreamResponse() 在响应返回前执行:解析模型输出中的 XML 伪工具调用,还原为标准 tool_calls 结构。',
|
|
511
|
+
steps: [
|
|
512
|
+
{
|
|
513
|
+
step: '解析伪工具调用',
|
|
514
|
+
detail: 'parsePseudoToolCalls() 支持多种格式:XML 风格(<function=...>)、<|tool_use|> JSON 块、legacy <function_calls> 格式,以及容错处理 malformed 输出。',
|
|
515
|
+
code: `// 模型输出(文本):
|
|
516
|
+
<function=bash>
|
|
517
|
+
<parameter=arguments_json>{"command":"ls -la"}</parameter>
|
|
518
|
+
</function>
|
|
519
|
+
|
|
520
|
+
// 还原后(标准 OpenAI tool_calls):
|
|
521
|
+
{
|
|
522
|
+
"message": {
|
|
523
|
+
"role": "assistant",
|
|
524
|
+
"content": "",
|
|
525
|
+
"tool_calls": [{
|
|
526
|
+
"id": "tool_0",
|
|
527
|
+
"type": "function",
|
|
528
|
+
"function": { "name": "bash", "arguments": "{\\"command\\":\\"ls -la\\"}" }
|
|
529
|
+
}]
|
|
530
|
+
},
|
|
531
|
+
"finish_reason": "tool_calls"
|
|
532
|
+
}`,
|
|
533
|
+
},
|
|
534
|
+
{
|
|
535
|
+
step: '思考文本清洗',
|
|
536
|
+
detail: 'ExtractPromptBridgeThinkingContent() 将 Thinking...\\n> ... 格式的思考文本提取到 reasoning_content,从 content 中移除。',
|
|
537
|
+
code: `// 模型输出(原始 content):
|
|
538
|
+
Thinking...
|
|
539
|
+
> 用户想要查看目录,我需要调用 bash 工具...
|
|
540
|
+
> 先执行 ls 命令
|
|
541
|
+
|
|
542
|
+
<function=bash>...</function>
|
|
543
|
+
|
|
544
|
+
// 清洗后:
|
|
545
|
+
{
|
|
546
|
+
"content": "<function=bash>...</function>", // 只保留工具调用
|
|
547
|
+
"reasoning_content": "用户想要查看目录,我需要调用 bash 工具...\\n先执行 ls 命令" // 思考提取到此
|
|
548
|
+
}`,
|
|
549
|
+
},
|
|
550
|
+
],
|
|
551
|
+
},
|
|
552
|
+
pseudoFormats: {
|
|
553
|
+
title: '支持的伪工具调用格式',
|
|
554
|
+
description: 'openai-tool-bridge 的 pseudo_tools.go 能解析多种模型可能输出的伪工具调用格式,从容错错模型不完美的输出。',
|
|
555
|
+
formats: [
|
|
556
|
+
{
|
|
557
|
+
name: 'XML 风格(主要)',
|
|
558
|
+
pattern: '<function=name>\\n<parameter=key>value</parameter>\\n</function>',
|
|
559
|
+
note: '本代理注入的 prompt 指定的格式,最常用',
|
|
560
|
+
},
|
|
561
|
+
{
|
|
562
|
+
name: '<|tool_use|> JSON 块',
|
|
563
|
+
pattern: '<|tool_use|>{"name":"bash","arguments":{}}<|/tool_use|>',
|
|
564
|
+
note: '部分模型的原生格式',
|
|
565
|
+
},
|
|
566
|
+
{
|
|
567
|
+
name: 'Legacy <function_calls>',
|
|
568
|
+
pattern: '<function_calls><function_call><name>...</name>...</function_call></function_calls>',
|
|
569
|
+
note: '旧格式兼容',
|
|
570
|
+
},
|
|
571
|
+
{
|
|
572
|
+
name: 'Malformed 容错',
|
|
573
|
+
pattern: '缺少闭合标签、参数格式不规范等',
|
|
574
|
+
note: '模型输出不完美时的修复解析,含 partial JSON 修复',
|
|
575
|
+
},
|
|
576
|
+
],
|
|
577
|
+
},
|
|
578
|
+
streaming: {
|
|
579
|
+
title: '流式桥接',
|
|
580
|
+
description: '流式模式下,代理在 streamState 中累积文本,检测伪工具调用起始标记,在确认完整后一次性输出 tool_calls chunk。',
|
|
581
|
+
notes: [
|
|
582
|
+
'streamState 维护 pseudoToolContent 累积器,跨 chunk 收集可能的伪工具调用文本',
|
|
583
|
+
'LooksLikePseudoToolCallPrefix() 检测当前 chunk 是否是伪工具调用的前缀(如 "<func" 可能是 "<function=" 的开头)',
|
|
584
|
+
'FindPseudoToolCallStart() 在已累积的文本中查找伪工具调用起始位置',
|
|
585
|
+
'在流式模式下,代理需要缓冲文本直到确认不是伪工具调用才输出为 content delta,或确认是伪工具调用后还原为 tool_calls delta',
|
|
586
|
+
'思考文本的流式处理使用增量 diff:ConsumeStreamPromptBridgeThinkingIncremental() 只输出新增的 reasoning 部分',
|
|
587
|
+
],
|
|
588
|
+
},
|
|
589
|
+
},
|
|
590
|
+
|
|
591
|
+
// ── OpenCode 网关(opencode-llm-proxy) ──
|
|
592
|
+
openCodeProxy: {
|
|
593
|
+
title: 'opencode-llm-proxy 多格式网关',
|
|
594
|
+
description:
|
|
595
|
+
'opencode-llm-proxy 是一个 OpenCode 插件,启动本地 HTTP 服务器(默认 127.0.0.1:4010),以 OpenCode SDK 为后端,对外暴露 4 种 API 格式(OpenAI Chat / OpenAI Responses / Anthropic Messages / Google Gemini)。客户端无需关心后端模型由谁提供——OpenCode 已配置的所有 Provider(GitHub Copilot、Anthropic、Gemini、Ollama、Bedrock 等)均可通过任一 API 格式访问。Tool calling 通过 MCP 桥接技巧在所有 4 种格式上工作,包括并行调用。',
|
|
596
|
+
positioning:
|
|
597
|
+
'与前两个工具解决不同问题:bluesllm 是格式转换器(A 格式 ↔ B 格式),openai-tool-bridge 是协议降级桥接(tools → prompt → tools),opencode-llm-proxy 是多格式网关(一个后端 → 4 种 API 格式出口),且以 OpenCode SDK 而非直接 HTTP 作为后端。',
|
|
598
|
+
threeWayComparison: {
|
|
599
|
+
title: '三方对比',
|
|
600
|
+
rows: [
|
|
601
|
+
{ aspect: '定位', bluesllm: '格式转换器', toolBridge: '协议降级桥接', openCodeProxy: '多格式网关' },
|
|
602
|
+
{ aspect: '后端', bluesllm: '直接 HTTP 上游', toolBridge: '直接 HTTP 上游', openCodeProxy: 'OpenCode SDK(间接)' },
|
|
603
|
+
{ aspect: '支持格式', bluesllm: '3 种(OpenAI/Claude/Codex)', toolBridge: '1 种出 + 1 种入(OpenAI)', openCodeProxy: '4 种(OpenAI/Claude/Codex/Gemini)' },
|
|
604
|
+
{ aspect: '运行方式', bluesllm: '独立 Express 服务', toolBridge: '独立 Go 二进制', openCodeProxy: 'OpenCode 插件(随 opencode 启动)' },
|
|
605
|
+
{ aspect: 'Tool calling', bluesllm: '保留并转换格式', toolBridge: '降级为 prompt 再还原', openCodeProxy: 'MCP 桥接技巧(动态注册 MCP 服务器)' },
|
|
606
|
+
{ aspect: '并行工具', bluesllm: '支持(透传)', toolBridge: '支持(解析多个 XML 块)', openCodeProxy: '支持(所有 4 种格式)' },
|
|
607
|
+
{ aspect: '语言', bluesllm: 'JavaScript (ESM)', toolBridge: 'Go', openCodeProxy: 'JavaScript (ESM)' },
|
|
608
|
+
{ aspect: '状态管理', bluesllm: '有状态(Translator 跨 chunk)', toolBridge: '有状态(streamState 累积)', openCodeProxy: '无状态(每次请求新建临时 session)' },
|
|
609
|
+
],
|
|
610
|
+
},
|
|
611
|
+
architecture: {
|
|
612
|
+
title: '架构设计',
|
|
613
|
+
description:
|
|
614
|
+
'作为 OpenCode 插件运行,通过 V1 插件描述符注册。启动时在本地创建 HTTP 服务器,监听 4 种 API 格式的端点。每个请求经过认证、模型解析、消息规范化、OpenCode session 创建、prompt 渲染、响应渲染等步骤。',
|
|
615
|
+
endpoints: [
|
|
616
|
+
{ method: 'POST', path: '/v1/chat/completions', format: 'OpenAI Chat Completions' },
|
|
617
|
+
{ method: 'POST', path: '/v1/responses', format: 'OpenAI Responses API' },
|
|
618
|
+
{ method: 'POST', path: '/v1/messages', format: 'Anthropic Messages API' },
|
|
619
|
+
{ method: 'POST', path: '/v1beta/models/:model:generateContent', format: 'Google Gemini' },
|
|
620
|
+
{ method: 'GET', path: '/v1/models', format: '模型列表(OpenAI 格式)' },
|
|
621
|
+
{ method: 'GET', path: '/health', format: '健康检查' },
|
|
622
|
+
{ method: 'GET', path: '/metrics', format: 'Prometheus 指标(可选)' },
|
|
623
|
+
],
|
|
624
|
+
},
|
|
625
|
+
howItWorks: {
|
|
626
|
+
title: '工作原理',
|
|
627
|
+
description:
|
|
628
|
+
'每个请求经过 8 个阶段的处理管道。核心是 canonical-messages.js 的消息规范化——将 4 种格式的消息统一为 OpenCode 可接受的 JSON Lines 格式。',
|
|
629
|
+
steps: [
|
|
630
|
+
{ step: '1. 认证', detail: '如果配置了 token,验证 Bearer token。非 loopback 绑定(0.0.0.0)必须配置 token' },
|
|
631
|
+
{ step: '2. 模型解析', detail: '解析 model 字段(provider/model 格式或裸 model ID),支持 model alias 和 fallback 链' },
|
|
632
|
+
{ step: '3. 消息规范化', detail: 'adaptOpenAIChat / adaptAnthropic / adaptGemini / adaptOpenAIResponses 将客户端消息转为统一内部格式,保留 roles、文本、媒体、tool_calls、tool 结果的完整语义' },
|
|
633
|
+
{ step: '4. JSON Lines 渲染', detail: 'renderOpenCodePrompt() 将规范化后的消息渲染为 JSON Lines 格式——每条消息保持为结构化 JSON 对象而非扁平化文本。OpenCode 只接受一个 user prompt,所以多轮对话被编码为 JSON Lines 传入' },
|
|
634
|
+
{ step: '5. 媒体处理', detail: '图片/文档以 data URL 嵌入。远程 URL 默认拒绝(SSRF 防护),需显式开启 remote-media fetcher 才会下载并转为 data URL' },
|
|
635
|
+
{ step: '6. 创建临时 session', detail: '为每个请求创建临时 OpenCode session,通过 client.session.prompt / promptAsync 发送渲染后的 prompt。请求完成后删除 session(除非配置 KEEP_SESSIONS)' },
|
|
636
|
+
{ step: '7. 响应渲染', detail: '将 OpenCode 返回的响应转回客户端请求时的 API 格式。流式响应通过 client.event.subscribe() SSE 流转发' },
|
|
637
|
+
{ step: '8. 清理', detail: '删除临时 session,关闭上游异步迭代器(完成/错误/取消/工具调用提前终止时均会关闭)' },
|
|
638
|
+
],
|
|
639
|
+
},
|
|
640
|
+
mcpBridge: {
|
|
641
|
+
title: 'Tool Calling 的 MCP 桥接技巧',
|
|
642
|
+
description:
|
|
643
|
+
'OpenCode 的 agent loop 总是自己在服务端执行工具,没有"把 tool_calls 交给外部客户端执行"的原生概念。opencode-llm-proxy 用一个巧妙的 MCP 桥接技巧解决了这个问题。',
|
|
644
|
+
trick: [
|
|
645
|
+
{ step: '1. 动态注册 MCP 服务器', detail: '当请求含 tools 时,代理动态注册一个本地 MCP 服务器(mcp-tool-bridge.js),其工具列表恰好是客户端声明的 tool schemas。OpenCode 把这些当作可用的 MCP 工具' },
|
|
646
|
+
{ step: '2. 禁用内置工具', detail: '只为这一次 prompt 调用启用桥接工具——OpenCode 所有内置工具保持禁用,模型只能看到客户端声明的工具' },
|
|
647
|
+
{ step: '3. 监听事件流', detail: '代理监听 OpenCode 的实时事件流(client.event.subscribe),捕获模型在该轮中提出的每个 tool call——含完整参数' },
|
|
648
|
+
{ step: '4. 捕获并中止', detail: '在 tool-calling 步骤完成的瞬间中止 session——在 OpenCode 执行桥接的 no-op 结果之前。捕获的 tool calls 被翻译为客户端 API 格式的 tool_calls / tool_use / functionCall' },
|
|
649
|
+
{ step: '5. 桥接池复用', detail: '桥接服务器从固定大小的池中复用(px_tools_0, px_tools_1, ...),因为 OpenCode 的 server API 没有注销 MCP 服务器的接口。池大小默认 8,可通过环境变量配置' },
|
|
650
|
+
],
|
|
651
|
+
mcpBridgeCode: `// mcp-tool-bridge.js — 极简 MCP 服务器
|
|
652
|
+
// 被 OpenCode 作为 "local" MCP 服务器启动
|
|
653
|
+
// 永远不真正执行任何工具——代理在 OpenCode 执行前就中止了 session
|
|
654
|
+
|
|
655
|
+
switch (method) {
|
|
656
|
+
case "initialize":
|
|
657
|
+
return result(id, {
|
|
658
|
+
protocolVersion: "2024-11-05",
|
|
659
|
+
capabilities: { tools: {} },
|
|
660
|
+
serverInfo: { name: "opencode-llm-proxy-bridge", version: "1.0.0" },
|
|
661
|
+
})
|
|
662
|
+
case "tools/list":
|
|
663
|
+
// 返回客户端声明的 tool schemas
|
|
664
|
+
return result(id, {
|
|
665
|
+
tools: tools.map(tool => ({
|
|
666
|
+
name: tool.name,
|
|
667
|
+
description: tool.description ?? "",
|
|
668
|
+
inputSchema: tool.parameters ?? { type: "object", properties: {} },
|
|
669
|
+
})),
|
|
670
|
+
})
|
|
671
|
+
case "tools/call":
|
|
672
|
+
// 永远不会到达——代理在此时已中止 session
|
|
673
|
+
return result(id, {
|
|
674
|
+
content: [{ type: "text",
|
|
675
|
+
text: "(intercepted by opencode-llm-proxy; awaiting the external caller's tool result)" }],
|
|
676
|
+
})
|
|
677
|
+
}`,
|
|
678
|
+
notes: [
|
|
679
|
+
'MCP 桥接服务器是 no-op 的——它注册工具 schema 但从不真正执行工具',
|
|
680
|
+
'代理在 OpenCode 事件流中检测到 tool-call part 时立即中止 session',
|
|
681
|
+
'桥接池复用机制避免反复注册 MCP 服务器(OpenCode 无法注销已注册的 server)',
|
|
682
|
+
'并行 tool calls 完全支持:模型可以在一轮中提出多个工具调用,代理全部捕获后一次性返回',
|
|
683
|
+
'tool_choice: "none" 禁用工具调用;指定具体工具名也支持',
|
|
684
|
+
'桥接进程用 node 启动,所以运行 OpenCode 的环境必须有 node 在 PATH 中',
|
|
685
|
+
],
|
|
686
|
+
},
|
|
687
|
+
features: {
|
|
688
|
+
title: '关键特性',
|
|
689
|
+
items: [
|
|
690
|
+
{ name: 'Model Aliasing', description: '通过 OPENCODE_LLM_PROXY_MODEL_ALIASES 配置别名映射,支持 fallback 链——主模型不可用时自动降级' },
|
|
691
|
+
{ name: 'Prometheus Metrics', description: 'OPENCODE_LLM_PROXY_METRICS_ENABLED=true 时暴露 /metrics 端点,含请求计数、延迟、token 统计、上游结果等' },
|
|
692
|
+
{ name: 'CORS 安全', description: '浏览器跨域请求默认拒绝,需显式配置允许的 origin。支持 Private Network Access preflight' },
|
|
693
|
+
{ name: 'SSRF 防护', description: '远程媒体 URL 默认拒绝,需显式开启 remote-media fetcher。只允许 HTTPS(可配置),有大小和数量限制' },
|
|
694
|
+
{ name: '并发控制', description: 'maxConcurrentRequests(默认 8)+ maxQueuedRequests(默认 32),超限返回 503。tool-bridge 有独立池和队列' },
|
|
695
|
+
{ name: '多模态', description: '支持图片、文档、文件输入,通过 data URL 嵌入。媒体能力取决于所选模型' },
|
|
696
|
+
],
|
|
697
|
+
},
|
|
698
|
+
clients: {
|
|
699
|
+
title: '兼容的客户端',
|
|
700
|
+
description: '任何使用 OpenAI / Anthropic / Gemini SDK 的工具都可以直接指向代理,无需修改代码。',
|
|
701
|
+
items: [
|
|
702
|
+
{ name: 'OpenAI SDK (JS/Python)', format: 'Chat Completions / Responses', toolCalling: true },
|
|
703
|
+
{ name: 'Anthropic SDK (JS/Python)', format: 'Messages API', toolCalling: true },
|
|
704
|
+
{ name: 'Google Generative AI SDK', format: 'Gemini /v1beta', toolCalling: true },
|
|
705
|
+
{ name: 'LangChain', format: 'OpenAI / Anthropic wrappers', toolCalling: true },
|
|
706
|
+
{ name: 'n8n AI Agent', format: 'OpenAI / Anthropic', toolCalling: true },
|
|
707
|
+
{ name: 'Open WebUI', format: 'OpenAI-compatible', toolCalling: 'partial' },
|
|
708
|
+
{ name: 'Continue (VS Code)', format: 'OpenAI-compatible', toolCalling: 'not primary' },
|
|
709
|
+
{ name: 'Zed', format: 'OpenAI-compatible', toolCalling: 'not primary' },
|
|
710
|
+
],
|
|
711
|
+
},
|
|
712
|
+
},
|
|
713
|
+
|
|
714
|
+
// ── DeepSeek 逆向代理(ds-free-api) ──
|
|
715
|
+
dsFreeApi: {
|
|
716
|
+
title: 'ds-free-api DeepSeek 逆向代理',
|
|
717
|
+
description:
|
|
718
|
+
'ds-free-api 是一个 Rust 实现的代理,将 DeepSeek 免费网页端的内部私有协议逆向为标准 OpenAI 和 Anthropic API。与前面三个工具不同,它的"源"不是标准 API 而是逆向得到的私有协议——需要处理 PoW 挑战、WAF 绕过、Session 管理等网页端特有的认证和通信机制。',
|
|
719
|
+
positioning:
|
|
720
|
+
'在协议转换层面与其他工具不完全一致:bluesllm 转换的是标准 API 之间的格式差异,ds-free-api 转换的是私有协议 → 标准协议。它的 Anthropic 兼容层链式构建在 OpenAI 适配层之上(Anthropic → OpenAI → DeepSeek),与 bluesllm 的直接互转不同。在 tool calling 层面,它与 openai-tool-bridge 类似——都是将 tools 降级为 prompt 文本再解析还原,但使用了不同的 XML 标签和更复杂的三层修复管道。',
|
|
721
|
+
fourWayComparison: {
|
|
722
|
+
title: '四方对比',
|
|
723
|
+
rows: [
|
|
724
|
+
{ aspect: '定位', bluesllm: '格式转换器', toolBridge: '协议降级桥接', openCodeProxy: '多格式网关', dsFreeApi: '逆向代理 + 协议转换' },
|
|
725
|
+
{ aspect: '源协议', bluesllm: '标准 API(OpenAI/Claude/Codex)', toolBridge: '标准 API(OpenAI)', openCodeProxy: 'OpenCode SDK', dsFreeApi: '私有协议(DeepSeek 网页端逆向)' },
|
|
726
|
+
{ aspect: '目标协议', bluesllm: '标准 API 互转', toolBridge: '标准 API(OpenAI)', openCodeProxy: '4 种标准 API', dsFreeApi: '标准 API(OpenAI + Anthropic)' },
|
|
727
|
+
{ aspect: '认证需求', bluesllm: 'W3 账号认证', toolBridge: 'API Key 透传', openCodeProxy: 'OpenCode 管理', dsFreeApi: 'DeepSeek 账号 + PoW + WAF 绕过' },
|
|
728
|
+
{ aspect: 'Tool calling', bluesllm: '保留并转换', toolBridge: 'prompt 降级 + XML 还原', openCodeProxy: 'MCP 桥接', dsFreeApi: 'prompt 降级 + XML 还原 + 三层修复' },
|
|
729
|
+
{ aspect: '后端类型', bluesllm: 'HTTP API 服务', toolBridge: 'HTTP API 服务', openCodeProxy: 'OpenCode SDK', dsFreeApi: 'Web 网页端(非 API)' },
|
|
730
|
+
{ aspect: '语言', bluesllm: 'JavaScript', toolBridge: 'Go', openCodeProxy: 'JavaScript', dsFreeApi: 'Rust' },
|
|
731
|
+
{ aspect: '管理面板', bluesllm: 'CLI', toolBridge: '无', openCodeProxy: '无(插件)', dsFreeApi: 'Web 管理面板(JWT + 热重载)' },
|
|
732
|
+
],
|
|
733
|
+
},
|
|
734
|
+
auth: {
|
|
735
|
+
title: '登录认证机制',
|
|
736
|
+
description:
|
|
737
|
+
'ds-free-api 的认证分为两层:面向 DeepSeek 后端的账号认证(逆向网页端登录流程),和面向客户端的 API 认证(管理面板 + API Key)。',
|
|
738
|
+
layers: [
|
|
739
|
+
{
|
|
740
|
+
name: 'DeepSeek 账号认证',
|
|
741
|
+
type: '逆向网页端',
|
|
742
|
+
steps: [
|
|
743
|
+
{ step: '1. 账号池初始化', detail: 'AccountPool::init() 并发初始化所有账号(上限 13 个并发,Semaphore 控制)。每个账号独立完成登录 → 创建会话 → 健康检查 → 重命名会话四步' },
|
|
744
|
+
{ step: '2. 登录获取 Token', detail: '使用账号凭据调用 DeepSeek 登录接口,获取 Bearer Token。失败重试 3 次(2s 间隔),全部失败标记为 InitFailed' },
|
|
745
|
+
{ step: '3. 创建聊天会话', detail: '用 Token 调用 create_session 接口创建聊天会话' },
|
|
746
|
+
{ step: '4. 健康检查', detail: '执行一次带 PoW 的测试 completion,验证会话可写' },
|
|
747
|
+
{ step: '5. PoW 挑战求解', detail: 'DeepSeek 网页端要求 Proof of Work 挑战。ds-free-api 用 wasmtime 加载并执行 DeepSeek 的 WASM 求解器(DeepSeekHashV1),动态探测导出函数(不硬编码符号名)' },
|
|
748
|
+
{ step: '6. WAF 绕过', detail: '使用 wreq(BoringSSL)模拟 Chrome 136 TLS 指纹绕过 AWS WAF。非美国 IP 可能收到 WAF Challenge(202),需配置代理' },
|
|
749
|
+
{ step: '7. 账号轮转', detail: '空闲最久优先(LIFO)轮转,DashMap 无锁读。1 账号 = 1 session = 1 并发。AccountGuard 用 AtomicBool 标记忙碌,Drop 时释放' },
|
|
750
|
+
],
|
|
751
|
+
},
|
|
752
|
+
{
|
|
753
|
+
name: '客户端 API 认证',
|
|
754
|
+
type: '管理面板 + API Key',
|
|
755
|
+
steps: [
|
|
756
|
+
{ step: '1. 管理面板密码', detail: '首次访问 /admin 引导设置管理密码,bcrypt 哈希存储。登录后签发 JWT(24h 有效),密码重置时吊销旧 Token' },
|
|
757
|
+
{ step: '2. 登录限流', detail: '5 次登录失败锁定 5 分钟' },
|
|
758
|
+
{ step: '3. API Key 管理', detail: '通过管理面板创建/删除 API Key,HashSet O(1) 查找。config 为空时无认证' },
|
|
759
|
+
{ step: '4. 配置热重载', detail: '管理面板修改即时持久化到 config.toml(原子写入 tmp+rename,0600 权限),同时热重载到运行服务' },
|
|
760
|
+
],
|
|
761
|
+
},
|
|
762
|
+
],
|
|
763
|
+
powCode: `// PoW 求解流程(ds_core/src/accounts/pow.rs)
|
|
764
|
+
// 1. 从 DeepSeek 获取 challenge 参数
|
|
765
|
+
// 2. 加载 WASM 模块(wasmtime)
|
|
766
|
+
// 3. 动态探测导出函数(不硬编码符号名)
|
|
767
|
+
// 4. 执行 WASM 求解 DeepSeekHashV1
|
|
768
|
+
// 5. 返回 proof 供后续请求使用
|
|
769
|
+
|
|
770
|
+
// PoW 是 DeepSeek 网页端的反爬机制
|
|
771
|
+
// 每次聊天请求前必须求解一次`,
|
|
772
|
+
wafNote: 'wreq + BoringSSL 模拟 Chrome 136 TLS 指纹。如果 WAF 更新指纹策略,可能需要更新 wreq 版本或切换模拟配置文件。',
|
|
773
|
+
},
|
|
774
|
+
protocolConversion: {
|
|
775
|
+
title: '协议转换分析',
|
|
776
|
+
description:
|
|
777
|
+
'ds-free-api 的协议转换是"私有协议 → 标准协议",与其他三个工具的"标准协议 → 标准协议"有本质区别。',
|
|
778
|
+
analysis: [
|
|
779
|
+
{
|
|
780
|
+
aspect: '转换方向',
|
|
781
|
+
detail: 'DeepSeek 私有协议 → OpenAI 标准格式 → Anthropic 兼容格式。Anthropic 兼容层链式构建在 OpenAI 适配层之上,不直接访问 ds_core',
|
|
782
|
+
comparison: '与 bluesllm 不同:bluesllm 在标准格式间直接互转;ds-free-api 是单向链式(DeepSeek → OpenAI → Anthropic)',
|
|
783
|
+
},
|
|
784
|
+
{
|
|
785
|
+
aspect: '源协议特性',
|
|
786
|
+
detail: 'DeepSeek 网页端使用 p/o/v patch 协议的 SSE 流,有 PoW 挑战、WAF 防护、Session 管理、文件上传等网页端特有机制。ds_core 库将这些封装为 StreamEvent 类型事件(Meta/ThinkStart/ThinkDelta/ContentStart/ContentDelta/Done)',
|
|
787
|
+
comparison: '其他三个工具的源协议都是标准 HTTP API,无需处理 PoW/WAF/Session',
|
|
788
|
+
},
|
|
789
|
+
{
|
|
790
|
+
aspect: '请求管道',
|
|
791
|
+
detail: 'OpenAI 请求经过 5 步管道:normalize(校验默认值)→ tools(工具提取+注入提示词)→ files(data URL→文件上传)→ prompt(ChatML→DeepSeek 原生标签)→ resolver(模型映射+能力开关)',
|
|
792
|
+
comparison: 'bluesllm 的请求转换是字段映射(一步),ds-free-api 是多步管道(校验→工具→文件→提示词→模型解析)',
|
|
793
|
+
},
|
|
794
|
+
{
|
|
795
|
+
aspect: '响应管道',
|
|
796
|
+
detail: 'StreamEvent → ConverterStream(格式转换)→ ToolCallStream(XML 工具解析)→ RepairStream(修复)→ StopDetectStream(停止序列+混淆)→ SSE bytes。4 层 Stream 包装器链式处理',
|
|
797
|
+
comparison: 'bluesllm 的响应转换是单步映射,ds-free-api 是 4 层 Stream 链',
|
|
798
|
+
},
|
|
799
|
+
{
|
|
800
|
+
aspect: 'Anthropic 兼容',
|
|
801
|
+
detail: 'Anthropic 请求先转为 OpenAI 请求结构体,经过 OpenAI 适配层处理,响应再从 OpenAI 格式转回 Anthropic 格式。ID 映射:chatcmpl → msg,call → toolu',
|
|
802
|
+
comparison: '与 bluesllm 的 chatToClaudeRequest 直接映射不同,ds-free_api 是间接链式转换',
|
|
803
|
+
},
|
|
804
|
+
],
|
|
805
|
+
},
|
|
806
|
+
toolCalling: {
|
|
807
|
+
title: 'Tool Calling 机制',
|
|
808
|
+
description:
|
|
809
|
+
'与 openai-tool-bridge 类似,ds-free-api 也将 tools 降级为 prompt 文本,再从响应中解析 XML 还原。但使用了不同的标签格式和更复杂的三层修复管道。',
|
|
810
|
+
mechanism: [
|
|
811
|
+
{ step: '1. 工具注入', detail: '工具定义注入到 prompt 的 <tool_calls> 块中,以自然语言描述。DeepSeek 在搜索模式下会注入更强的系统提示词提升工具调用遵循度' },
|
|
812
|
+
{ step: '2. XML 解析', detail: 'ToolCallStream 滑动窗口检测器累积 content chunks,查找 <tool_calls> XML 标签。支持模糊字符归一化(全角|→|,▁→_)' },
|
|
813
|
+
{ step: '3. 三层修复管道', detail: '文本修复(backslash 转义、unquoted keys)→ JSON 修复(partial JSON 修复)→ 模型兜底(重新请求模型生成)' },
|
|
814
|
+
{ step: '4. 可配置标签', detail: '主标签 <tool_calls>(复数),可通过 config.toml 配置 fallback 标签(extra_starts/extra_ends)' },
|
|
815
|
+
],
|
|
816
|
+
comparison: '与 openai-tool-bridge 的 <function=name> 格式不同,ds-free-api 使用 <tool_calls> 格式。两者都是 prompt 降级 + XML 还原,但 ds-free-api 多了三层修复管道和模糊字符归一化。',
|
|
817
|
+
},
|
|
818
|
+
features: {
|
|
819
|
+
title: '关键特性',
|
|
820
|
+
items: [
|
|
821
|
+
{ name: '多账号池', description: '空闲最久优先轮转,DashMap 无锁读,AccountGuard 原子标记忙碌。支持水平扩展并发' },
|
|
822
|
+
{ name: '超长提示词回退', description: '提示词超限时自动使用分块补全 + 文件上传绕过。3 种请求路径:normal / oversized_file / oversized_chunk' },
|
|
823
|
+
{ name: '限流重试', description: '6 次指数退避(1s→2s→4s→8s→16s),触发条件:rate_limit_reached SSE hint 或所有账号忙碌' },
|
|
824
|
+
{ name: 'Web 管理面板', description: 'React + shadcn/ui SPA,账号池状态、API Key 管理、请求日志、i18n、主题切换、配置热重载' },
|
|
825
|
+
{ name: '文件上传', description: 'OpenAI file/image_url 和 Anthropic image/document 的 data URL 自动上传到 DeepSeek 会话。HTTP URL 自动触发搜索模式' },
|
|
826
|
+
{ name: '单二进制', description: 'Rust 编译为单一可执行文件,Web 面板编译时嵌入(rust_embed),无外部 .so/.dll 依赖' },
|
|
827
|
+
],
|
|
828
|
+
},
|
|
829
|
+
},
|
|
830
|
+
|
|
831
|
+
// ── llmproxy CodeArts 认证代理(非协议转换) ──
|
|
832
|
+
llmproxyCodearts: {
|
|
833
|
+
title: 'llmproxy CodeArts 认证代理',
|
|
834
|
+
description:
|
|
835
|
+
'@wwkit/llmproxy 的 codearts provider 是认证签名代理,不是协议转换工具。客户端发标准 OpenAI 请求,代理用 OAuth 凭证 + SDK 签名为请求添加认证头后透传给 CodeArts 上游(snap-access)。上游本身使用 OpenAI 格式,无需协议转换。唯一的"转换"是 SSE 异常检测——snap-access 偶尔在 SSE 流中嵌入非标准错误,代理检测后改写为标准 OpenAI 错误格式。',
|
|
836
|
+
positioning:
|
|
837
|
+
'在五个工具中定位独特:不做协议转换,不做协议降级,不做逆向——只做认证代理。解决的问题是"如何用 OAuth + SDK 签名访问需要华为云认证的 CodeArts 模型 API"。',
|
|
838
|
+
fiveWayComparison: {
|
|
839
|
+
title: '五方对比',
|
|
840
|
+
rows: [
|
|
841
|
+
{ aspect: '定位', bluesllm: '格式转换器', toolBridge: '协议降级桥接', openCodeProxy: '多格式网关', dsFreeApi: '逆向代理+协议转换', llmproxy: '认证签名代理' },
|
|
842
|
+
{ aspect: '协议转换', bluesllm: '✅ 三格式互转', toolBridge: '✅ tools→prompt', openCodeProxy: '✅ SDK→4格式', dsFreeApi: '✅ 私有→标准', llmproxy: '❌ 无(透传)' },
|
|
843
|
+
{ aspect: '认证', bluesllm: 'W3 账号', toolBridge: 'API Key 透传', openCodeProxy: 'OpenCode 管理', dsFreeApi: 'PoW+WAF+Session', llmproxy: 'OAuth PKCE+DPoP+SDK签名' },
|
|
844
|
+
{ aspect: '上游格式', bluesllm: '标准 API', toolBridge: '标准 API', openCodeProxy: 'OpenCode SDK', dsFreeApi: '私有协议', llmproxy: '标准 API(OpenAI)' },
|
|
845
|
+
{ aspect: '客户端格式', bluesllm: '3 种可选', toolBridge: 'OpenAI', openCodeProxy: '4 种可选', dsFreeApi: 'OpenAI+Anthropic', llmproxy: 'OpenAI(透传)' },
|
|
846
|
+
{ aspect: 'Tool calling', bluesllm: '保留转换', toolBridge: '降级+XML还原', openCodeProxy: 'MCP桥接', dsFreeApi: '降级+XML+修复', llmproxy: '透传(上游原生支持)' },
|
|
847
|
+
{ aspect: '语言', bluesllm: 'JavaScript', toolBridge: 'Go', openCodeProxy: 'JavaScript', dsFreeApi: 'Rust', llmproxy: 'JavaScript (ESM)' },
|
|
848
|
+
],
|
|
849
|
+
},
|
|
850
|
+
noConversionAnalysis: {
|
|
851
|
+
title: '为什么不做协议转换',
|
|
852
|
+
description:
|
|
853
|
+
'CodeArts 上游(snap-access.cn-north-4.myhuaweicloud.com)本身就使用标准 OpenAI Chat Completions 格式。客户端发送的请求体直接透传,响应体也直接透传。llmproxy 只在请求头层面添加认证信息(Authorization + X-Sdk-Date + X-Security-Token),不修改请求体或响应体的任何字段。',
|
|
854
|
+
flow: [
|
|
855
|
+
{ phase: '1. 客户端请求', detail: '标准 OpenAI Chat Completions 请求(messages + model + stream + tools 等)' },
|
|
856
|
+
{ phase: '2. 认证添加', detail: 'authProvider.getCredentials() → AK/SK/SecurityToken;signProvider.sign() → Authorization + X-Sdk-Date' },
|
|
857
|
+
{ phase: '3. 透传上游', detail: '请求体原封不动发送给 snap-access,只添加认证头和 maas_type(benefit 模型)' },
|
|
858
|
+
{ phase: '4. 响应透传', detail: '上游返回标准 OpenAI 格式响应(SSE 流或 JSON),直接透传给客户端' },
|
|
859
|
+
{ phase: '5. SSE 异常检测', detail: '唯一的"转换":wrapSSEAbnormalGuard 检测 SSE 流中的非标准错误(InferHub.ModelArts.xxx),改写为标准 OpenAI error 格式' },
|
|
860
|
+
],
|
|
861
|
+
sseGuard: `// providers/codearts/client.js — SSE 异常检测
|
|
862
|
+
function wrapSSEAbnormalGuard(upstream, providerId) {
|
|
863
|
+
const reader = upstream.body.getReader()
|
|
864
|
+
return new Response(new ReadableStream({
|
|
865
|
+
async pull(controller) {
|
|
866
|
+
const { done, value } = await reader.read()
|
|
867
|
+
if (done) { controller.close(); return }
|
|
868
|
+
const text = new TextDecoder().decode(value, { stream: true })
|
|
869
|
+
// 检测非标准错误 chunk
|
|
870
|
+
if (isAbnormalErrorChunk(text)) {
|
|
871
|
+
const { code, message } = parseAbnormalError(text)
|
|
872
|
+
// 改写为标准 OpenAI 错误
|
|
873
|
+
const errBody = JSON.stringify({
|
|
874
|
+
error: { message, type: "upstream_error", code }
|
|
875
|
+
})
|
|
876
|
+
controller.enqueue(encode("data: " + errBody + "\\n\\ndata: [DONE]\\n\\n"))
|
|
877
|
+
controller.close()
|
|
878
|
+
return
|
|
879
|
+
}
|
|
880
|
+
controller.enqueue(value) // 正常 chunk 直接透传
|
|
881
|
+
}
|
|
882
|
+
}), { status: upstream.status, headers: upstream.headers })
|
|
883
|
+
}`,
|
|
884
|
+
},
|
|
885
|
+
providerArchitecture: {
|
|
886
|
+
title: 'Provider 插件架构',
|
|
887
|
+
description:
|
|
888
|
+
'llmproxy 的 core/factory.js 按 providers/<type>/ 路径约定动态 import 模块。新增 provider 只需在 providers/<id>/ 下放对应文件,无需改代码。',
|
|
889
|
+
structure: `providers/
|
|
890
|
+
├── codearts/ # OAuth + SDK 签名(验证可用)
|
|
891
|
+
│ ├── auth.js # OAuth PKCE + DPoP → STS Token
|
|
892
|
+
│ ├── sign.js # SDK-HMAC-SHA256 签名
|
|
893
|
+
│ ├── client.js # auth + sign + fetch + SSE guard
|
|
894
|
+
│ ├── config.js # BENEFIT_MODELS
|
|
895
|
+
│ └── error.js # sessionLimit 错误模式
|
|
896
|
+
├── deepseek-web/ # 浏览器登录(CDP 捕获凭证)
|
|
897
|
+
├── qianwen-web/ # 浏览器登录(CDP 捕获凭证)`,
|
|
898
|
+
notes: [
|
|
899
|
+
'每个 provider 独立拥有 auth/sign/client/error 四个模块',
|
|
900
|
+
'core/factory.js 按 providers/<id>/auth.js 路径约定动态 import,模块缓存避免重复加载',
|
|
901
|
+
'provider id 支持 codearts/176 格式(type/account),取 / 前的部分作为代码目录',
|
|
902
|
+
'sign 模块导出 SIGN_CONFIG(host + basePath),client 从 sign 模块读取上游地址',
|
|
903
|
+
'error 模块可选——不存在时 fallback 到 BodyStringErrorPatterns',
|
|
904
|
+
],
|
|
905
|
+
},
|
|
906
|
+
},
|
|
907
|
+
|
|
908
|
+
// ── CodeArts 接口规格转换 ──
|
|
909
|
+
codeartsConversion: {
|
|
910
|
+
title: 'CodeArts 接口规格转换',
|
|
911
|
+
description:
|
|
912
|
+
'bluesllm 代理 CodeArts 上游时,通过 translator.js 在客户端格式(openai_chat / claude_chat / codex_chat)和 CodeArts 上游格式(openai_chat)之间双向转换。CodeArts 上游本身使用标准 OpenAI Chat Completions 格式,但客户端可以使用 Claude 或 Codex 格式访问——bluesllm 在请求转发前翻译为 openai_chat,在响应返回前翻译回客户端格式。',
|
|
913
|
+
upstreamSpec: {
|
|
914
|
+
title: 'CodeArts 上游接口规格',
|
|
915
|
+
description:
|
|
916
|
+
'CodeArts 有两个上游端点,均使用标准 OpenAI Chat Completions 格式,但认证方式不同。',
|
|
917
|
+
endpoints: [
|
|
918
|
+
{ name: 'cida(CodeAgentCli)', url: 'https://snapengine.cida.cce.prod-szv-g.dragon.tools.huawei.com/api/v2/chat/completions', auth: 'W3 Token(Bearer {token})', used: 'bluesllm configs/cida.json' },
|
|
919
|
+
{ name: 'snap-access', url: 'https://snap-access.cn-north-4.myhuaweicloud.com/api/v2/chat/completions', auth: 'OAuth STS + SDK-HMAC-SHA256', used: 'llmproxy providers/codearts/' },
|
|
920
|
+
],
|
|
921
|
+
format: '标准 OpenAI Chat Completions(POST /api/v2/chat/completions)',
|
|
922
|
+
models: [
|
|
923
|
+
{ id: 'maas-glm-5.1-zhipu', display: 'GLM-5.1 Zhipu', context: '131K' },
|
|
924
|
+
{ id: 'maas-glm-5.2-zhipu', display: 'GLM-5.2 Zhipu', context: '1M' },
|
|
925
|
+
{ id: 'MiniMax-M2.7', display: 'MiniMax M2.7', context: '192K' },
|
|
926
|
+
{ id: 'maas-DeepSeek-V4-Flash', display: 'DeepSeek V4 Flash', context: '1M' },
|
|
927
|
+
{ id: 'glm-5.3-flash', display: 'GLM-5.3 Flash(benefit)', context: '—' },
|
|
928
|
+
],
|
|
929
|
+
requestHeaders: `// bluesllm cida 配置注入的请求头
|
|
930
|
+
{
|
|
931
|
+
"authorization": "Bearer {token}", // W3 token 模板替换
|
|
932
|
+
"x-auth-token": "{token}", // W3 token 模板替换(base.json)
|
|
933
|
+
"is_confidential": "false",
|
|
934
|
+
"ide-name": "VSCode-huawei",
|
|
935
|
+
"kernel-version": "1.2602.14.01-win32-x64", // cida 专用
|
|
936
|
+
"plugin-version": "1.2602.14.01",
|
|
937
|
+
"x-ot-session-id": "bluesllm",
|
|
938
|
+
"area": "green",
|
|
939
|
+
"timestamp": "<dynamic: timestamp_ms>", // 动态值
|
|
940
|
+
"x-snap-span-id": "<dynamic: uuid16>", // 动态值
|
|
941
|
+
"X-snap-traceid": "<dynamic: uuid>" // 动态值
|
|
942
|
+
}`,
|
|
943
|
+
requestBody: `// bluesllm cida 配置注入的请求体参数(body.inject,仅在缺失时填充)
|
|
944
|
+
{
|
|
945
|
+
"tool_stream": true,
|
|
946
|
+
"top_p": 0.95,
|
|
947
|
+
"temperature": 0.7,
|
|
948
|
+
"top_k": 20,
|
|
949
|
+
"enable_thinking": true
|
|
950
|
+
}
|
|
951
|
+
// body.force_stream: true → 上游始终返回 SSE 流
|
|
952
|
+
// response.merge_stream_to_non_stream: true → 客户端请求非流式时,代理将 SSE 合并为 JSON`,
|
|
953
|
+
},
|
|
954
|
+
conversionFlow: {
|
|
955
|
+
title: '转换流程',
|
|
956
|
+
description:
|
|
957
|
+
'bluesllm 的 proxy-service.js 在请求/响应管道中调用 translator.js。转换方向由 source_format 和 target_format 决定——CodeArts 的 source_format 固定为 openai_chat。',
|
|
958
|
+
steps: [
|
|
959
|
+
{ phase: '1. 客户端请求', detail: '客户端以 target_format 发送请求(如 claude_chat 的 /v1/messages 或 codex_chat 的 /v1/responses)' },
|
|
960
|
+
{ phase: '2. 请求翻译', detail: 'translator.translateRequest(model, body, stream) 将 target_format → openai_chat。如 claude_chat → openai_chat 调用 claudeToChatRequest()' },
|
|
961
|
+
{ phase: '3. 认证注入', detail: 'headerHook 用 W3 token 模板替换注入认证头;requestHook 注入 body 参数(tool_stream/top_p/temperature 等)+ force_stream' },
|
|
962
|
+
{ phase: '4. 上游转发', detail: '以 openai_chat 格式发送给 CodeArts 上游(snapengine.cida... 或 snap-access...),上游返回 openai_chat 格式的 SSE 流或 JSON' },
|
|
963
|
+
{ phase: '5. 响应翻译', detail: 'translator.translateNonstreamResponse() 或 translateStreamEvent() 将 openai_chat → target_format。如 openai_chat → claude_chat 调用 chatResponseToClaude()' },
|
|
964
|
+
{ phase: '6. 客户端响应', detail: '客户端收到 target_format 格式的响应,完全感知不到上游是 openai_chat' },
|
|
965
|
+
],
|
|
966
|
+
configExample: `// configs/base.json — CodeArts 转换配置
|
|
967
|
+
{
|
|
968
|
+
"upstream": {
|
|
969
|
+
"source_format": "openai_chat", // 上游格式
|
|
970
|
+
"target_formats": ["openai_chat", "claude_chat", "codex_chat"] // 客户端可用格式
|
|
971
|
+
},
|
|
972
|
+
"body": {
|
|
973
|
+
"force_stream": true // 强制上游流式
|
|
974
|
+
},
|
|
975
|
+
"response": {
|
|
976
|
+
"merge_stream_to_non_stream": true, // 非流式请求时合并 SSE → JSON
|
|
977
|
+
"coalesce_reasoning_size": 500 // 推理内容合并阈值
|
|
978
|
+
}
|
|
979
|
+
}`,
|
|
980
|
+
},
|
|
981
|
+
conversionPairs: {
|
|
982
|
+
title: 'CodeArts 的转换对',
|
|
983
|
+
description:
|
|
984
|
+
'translator.js 支持 6 种转换对,CodeArts 使用其中 3 种(以 openai_chat 为中心)。Claude ↔ Codex 通过 openai_chat 中转(链式转换)。',
|
|
985
|
+
pairs: [
|
|
986
|
+
{ from: 'openai_chat', to: 'openai_chat', function: '直接透传(source === target)', note: '客户端和上游都是 OpenAI 格式,不转换' },
|
|
987
|
+
{ from: 'claude_chat', to: 'openai_chat', function: 'claudeToChatRequest()', note: 'Claude 的 system/messages/tools/input_schema → OpenAI 的 messages/tools/parameters' },
|
|
988
|
+
{ from: 'codex_chat', to: 'openai_chat', function: 'codexToChatRequest()', note: 'Codex 的 instructions/input/function_call → OpenAI 的 messages/tool_calls' },
|
|
989
|
+
{ from: 'openai_chat', to: 'claude_chat', function: 'chatToClaudeRequest()', note: '响应方向:OpenAI → Claude(system 提取、tool_calls → tool_use)' },
|
|
990
|
+
{ from: 'openai_chat', to: 'codex_chat', function: 'chatToResponsesRequest()', note: '响应方向:OpenAI → Codex(messages → input、max_tokens → max_output_tokens)' },
|
|
991
|
+
{ from: 'claude_chat', to: 'codex_chat', function: 'chatToClaudeRequest(codexToChatRequest(...))', note: '链式:Claude → OpenAI → Codex(两步转换)' },
|
|
992
|
+
],
|
|
993
|
+
},
|
|
994
|
+
keyMappings: {
|
|
995
|
+
title: 'CodeArts 转换中的关键字段映射',
|
|
996
|
+
description:
|
|
997
|
+
'以下是 Claude 客户端通过 bluesllm 访问 CodeArts 上游时的关键字段转换。Codex 客户端的转换逻辑类似但字段名不同。',
|
|
998
|
+
requestMappings: [
|
|
999
|
+
{ claudeField: 'system(顶层)', openaiField: 'messages[0].role=system', note: '系统提示从顶层字段提取为 messages 数组首条' },
|
|
1000
|
+
{ claudeField: 'messages[].content[](blocks)', openaiField: 'messages[].content(string/parts)', note: 'Claude 的 content blocks 数组 → OpenAI 的 content string 或 parts 数组' },
|
|
1001
|
+
{ claudeField: 'tools[].input_schema', openaiField: 'tools[].function.parameters', note: '工具参数 Schema 字段名和嵌套层级不同' },
|
|
1002
|
+
{ claudeField: 'content[].tool_use(block)', openaiField: 'message.tool_calls[]', note: '工具调用从 content block 变为 message 字段' },
|
|
1003
|
+
{ claudeField: 'content[].tool_result(block)', openaiField: '{ role: "tool", tool_call_id, content }', note: '工具结果从 content block 变为独立 tool 消息' },
|
|
1004
|
+
{ claudeField: 'max_tokens(必填)', openaiField: 'max_tokens', note: '字段名相同,但 Claude 必填' },
|
|
1005
|
+
{ claudeField: 'stop_sequences', openaiField: 'stop', note: '停止序列字段名不同' },
|
|
1006
|
+
],
|
|
1007
|
+
responseMappings: [
|
|
1008
|
+
{ openaiField: 'choices[0].message.content', claudeField: 'content[].text(block)', note: '文本从 message 字段变为 content block' },
|
|
1009
|
+
{ openaiField: 'choices[0].message.tool_calls', claudeField: 'content[].tool_use(block)', note: '工具调用从 message 字段变为 content block' },
|
|
1010
|
+
{ openaiField: 'choices[0].finish_reason: "tool_calls"', claudeField: 'stop_reason: "tool_use"', note: '停止原因枚举值映射' },
|
|
1011
|
+
{ openaiField: 'usage.prompt_tokens', claudeField: 'usage.input_tokens', note: 'Token 统计字段名不同' },
|
|
1012
|
+
{ openaiField: 'message.reasoning_content(扩展)', claudeField: 'content[].thinking(block)', note: '推理内容映射' },
|
|
1013
|
+
],
|
|
1014
|
+
},
|
|
1015
|
+
streamConversion: {
|
|
1016
|
+
title: '流式转换',
|
|
1017
|
+
description:
|
|
1018
|
+
'CodeArts 上游强制返回 SSE 流(force_stream: true)。如果客户端请求非流式,bluesllm 将 SSE 合并为 JSON(merge_stream_to_non_stream: true)。如果客户端请求 Claude 格式流式,translator.js 将 OpenAI SSE chunks 转为 Claude SSE events。',
|
|
1019
|
+
flow: [
|
|
1020
|
+
{ phase: '上游 SSE', detail: 'CodeArts 返回 OpenAI 格式 SSE:data: { choices: [{ delta: { content: "..." } }] }' },
|
|
1021
|
+
{ phase: '客户端 Claude 流式', detail: 'translator.translateStreamEvent() 将 OpenAI chunk → Claude events:content_block_start → content_block_delta(text_delta) → content_block_stop → message_delta → message_stop' },
|
|
1022
|
+
{ phase: '客户端非流式', detail: 'merge_stream_to_non_stream:累积所有 SSE chunks → 拼接 content → 组装完整 JSON 响应 → translateNonstreamResponse() 转为目标格式' },
|
|
1023
|
+
{ phase: '推理内容合并', detail: 'coalesce_reasoning_size: 500 → 多个 reasoning_content delta 合并到阈值后 flush,减少 chunk 数量' },
|
|
1024
|
+
],
|
|
1025
|
+
},
|
|
1026
|
+
vsLlmproxy: {
|
|
1027
|
+
title: 'bluesllm vs llmproxy 对 CodeArts 的处理差异',
|
|
1028
|
+
description:
|
|
1029
|
+
'两个代理都能访问 CodeArts 模型,但处理方式完全不同:bluesllm 做格式转换 + W3 认证,llmproxy 做透传 + OAuth 认证。',
|
|
1030
|
+
table: [
|
|
1031
|
+
{ aspect: '认证', bluesllm: 'W3 工号密码 → cloudDragonTokens', llmproxy: 'OAuth PKCE + DPoP → STS + SDK-HMAC-SHA256' },
|
|
1032
|
+
{ aspect: '协议转换', bluesllm: '✅ translator.js 三格式互转', llmproxy: '❌ 直接透传(上游已是 OpenAI 格式)' },
|
|
1033
|
+
{ aspect: '上游端点', bluesllm: 'snapengine.cida.cce...(cida 配置)', llmproxy: 'snap-access.cn-north-4...(SIGN_CONFIG)' },
|
|
1034
|
+
{ aspect: '客户端格式', bluesllm: 'openai_chat / claude_chat / codex_chat', llmproxy: '仅 openai_chat' },
|
|
1035
|
+
{ aspect: 'force_stream', bluesllm: '✅ 强制上游流式 + 可选合并为 JSON', llmproxy: '❌ 透传客户端 stream 设置' },
|
|
1036
|
+
{ aspect: 'body 参数注入', bluesllm: '✅ tool_stream/top_p/temperature 等', llmproxy: '❌ 不注入' },
|
|
1037
|
+
{ aspect: '动态头注入', bluesllm: '✅ timestamp/uuid/uuid16', llmproxy: '❌ 不注入' },
|
|
1038
|
+
{ aspect: 'SSE 异常检测', bluesllm: '❌ 无', llmproxy: '✅ InferHub.ModelArts 错误检测' },
|
|
1039
|
+
],
|
|
1040
|
+
},
|
|
1041
|
+
},
|
|
1042
|
+
}
|