dsh-router-core 0.4.6 → 0.4.8
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/README.md +156 -76
- package/cordis.patch.yml +2 -7
- package/docs/ext.md +4 -18
- package/docs/protocols.md +198 -0
- package/lib/client-registry.js +193 -668
- package/lib/client.js +193 -668
- package/lib/ext-prompt/index.js +202 -32
- package/lib/index.js +2805 -547
- package/lib/types/ext/contract.d.ts +20 -1
- package/lib/types/ext-prompt/content.d.ts +18 -1
- package/lib/types/ext-prompt/index.d.ts +7 -9
- package/lib/types/ext-prompt/mount.d.ts +14 -3
- package/lib/types/ext-prompt/plugin.d.ts +13 -5
- package/lib/types/ext-prompt/render.d.ts +14 -3
- package/lib/types/ext-prompt/sections.d.ts +95 -0
- package/lib/types/protocol/anthropic.d.ts +35 -0
- package/lib/types/protocol/chat.d.ts +25 -0
- package/lib/types/protocol/entry.d.ts +137 -0
- package/lib/types/protocol/gemini.d.ts +57 -0
- package/lib/types/protocol/index.d.ts +13 -0
- package/lib/types/protocol/responses.d.ts +41 -0
- package/lib/types/protocol/sse.d.ts +41 -0
- package/lib/types/protocol/stream.d.ts +92 -0
- package/lib/types/protocol/types.d.ts +224 -0
- package/lib/types/router/index.d.ts +42 -1
- package/lib/types/router/types.d.ts +19 -0
- package/lib/types/shared.d.ts +11 -0
- package/lib/types/suppliers/contract.d.ts +8 -0
- package/lib/types/suppliers/registry.d.ts +0 -1
- package/package.json +6 -15
- package/src/client/ExtDetail.tsx +26 -18
- package/src/client/group-controls.test.ts +53 -0
- package/src/client/group-controls.ts +43 -0
- package/src/client/index.tsx +0 -17
- package/src/client/router-components.test.ts +1 -1
- package/src/client/router.css +23 -355
- package/src/ext/contract.ts +20 -1
- package/src/ext-prompt/assembly.test.ts +63 -0
- package/src/ext-prompt/content.ts +25 -22
- package/src/ext-prompt/ext-prompt.test.ts +41 -31
- package/src/ext-prompt/index.ts +57 -11
- package/src/ext-prompt/mount.test.ts +18 -1
- package/src/ext-prompt/mount.ts +24 -9
- package/src/ext-prompt/plugin.ts +67 -6
- package/src/ext-prompt/render.test.ts +1 -1
- package/src/ext-prompt/render.ts +17 -6
- package/src/ext-prompt/section-controls.test.ts +148 -0
- package/src/ext-prompt/section-filter.test.ts +140 -0
- package/src/ext-prompt/sections.test.ts +123 -0
- package/src/ext-prompt/sections.ts +193 -0
- package/src/ext-route.test.ts +16 -0
- package/src/index.ts +109 -1
- package/src/protocol/anthropic.ts +662 -0
- package/src/protocol/chat.ts +272 -0
- package/src/protocol/entry.ts +440 -0
- package/src/protocol/gemini.ts +548 -0
- package/src/protocol/index.ts +31 -0
- package/src/protocol/protocol.test.ts +728 -0
- package/src/protocol/responses.ts +551 -0
- package/src/protocol/sse.ts +94 -0
- package/src/protocol/stream.ts +195 -0
- package/src/protocol/types.ts +249 -0
- package/src/protocol/wire-e2e.test.ts +208 -0
- package/src/protocol/wiring.test.ts +225 -0
- package/src/router/index.ts +144 -26
- package/src/router/models-cache.test.ts +5 -6
- package/src/router/supplier-switch.test.ts +1 -1
- package/src/router/types.ts +19 -0
- package/src/shared.ts +11 -0
- package/src/suppliers/contract.ts +8 -0
- package/src/suppliers/registry.ts +0 -71
- package/lib/contract-D4BWZ7Xe.js +0 -13
- package/lib/ext-test/index.js +0 -36
- package/lib/ext-test/locale/en.json +0 -6
- package/lib/ext-test/locale/zh.json +0 -6
- package/lib/ext-test/package.json +0 -5
- package/lib/types/ext-test/index.d.ts +0 -21
- package/lib/types/ext-test/plugin.d.ts +0 -23
- package/lib/types/suppliers/probe.d.ts +0 -160
- package/src/client/ExtTestPanel.tsx +0 -392
- package/src/client/comp-button-scope.test.ts +0 -58
- package/src/client/comp-panel-layout.test.ts +0 -58
- package/src/client/ext-panels.test.ts +0 -44
- package/src/client/ext-panels.ts +0 -49
- package/src/client/model-choice.test.ts +0 -41
- package/src/client/model-choice.ts +0 -37
- package/src/ext-test/ext-test.test.ts +0 -60
- package/src/ext-test/index.ts +0 -35
- package/src/ext-test/locale/en.json +0 -6
- package/src/ext-test/locale/zh.json +0 -6
- package/src/ext-test/plugin.ts +0 -34
- package/src/ext-test/row.json +0 -5
- package/src/suppliers/probe.test.ts +0 -266
- package/src/suppliers/probe.ts +0 -475
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 协议矩阵的往返测试 —— 「四种协议双向互转」这件事的判据。
|
|
3
|
+
*
|
|
4
|
+
* 只测**能判定**的东西:给定一段真实形状的输入,翻译出来的东西必须能被
|
|
5
|
+
* 反方向翻译回去、且关键语义不丢。不测「看起来对不对」。
|
|
6
|
+
*
|
|
7
|
+
* 每种协议两组:
|
|
8
|
+
* 1. 请求侧往返:协议 A 的请求 → 规范 → 协议 A,关键字段必须原样回来
|
|
9
|
+
* 2. 响应侧往返:协议 A 的响应帧 → 规范事件 → 协议 A 的帧,内容必须可读回
|
|
10
|
+
*
|
|
11
|
+
* 外加一条**跨协议**的:Anthropic 请求经规范转成 Gemini 请求,再转回
|
|
12
|
+
* Anthropic —— 这是 4×3 路径里最有价值的那条(Claude Code → Gemini 上游)。
|
|
13
|
+
*/
|
|
14
|
+
import test from 'node:test'
|
|
15
|
+
import assert from 'node:assert/strict'
|
|
16
|
+
import { codecOf } from './index.ts'
|
|
17
|
+
import { PROTOCOL_IDS, type ChatEvent, type JsonObject } from './types.ts'
|
|
18
|
+
import { StreamState, translateStream } from './stream.ts'
|
|
19
|
+
|
|
20
|
+
/* ------------------------------------------------------------------ */
|
|
21
|
+
/* 工具 */
|
|
22
|
+
/* ------------------------------------------------------------------ */
|
|
23
|
+
|
|
24
|
+
/** 把事件列表里的文本拼起来。 */
|
|
25
|
+
function textOf(events: ChatEvent[]): string {
|
|
26
|
+
return events.filter((e) => e.type === 'text').map((e) => (e as { text: string }).text).join('')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 把事件列表里的工具调用拼成 [{name, args}]。 */
|
|
30
|
+
function toolsOf(events: ChatEvent[]): Array<{ name: string; args: string }> {
|
|
31
|
+
const calls = new Map<number, { name: string; args: string }>()
|
|
32
|
+
for (const e of events) {
|
|
33
|
+
if (e.type === 'tool-start') calls.set(e.index, { name: e.name ?? '', args: '' })
|
|
34
|
+
if (e.type === 'tool-args') {
|
|
35
|
+
const c = calls.get(e.index) ?? { name: '', args: '' }
|
|
36
|
+
c.args += e.delta
|
|
37
|
+
calls.set(e.index, c)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return [...calls.entries()].sort((a, b) => a[0] - b[0]).map(([, c]) => c)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function usageOf(events: ChatEvent[]): { promptTokens: number; completionTokens: number } | undefined {
|
|
44
|
+
const u = events.find((e) => e.type === 'usage')
|
|
45
|
+
return u === undefined || u.type !== 'usage' ? undefined : { promptTokens: u.promptTokens, completionTokens: u.completionTokens }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function finishOf(events: ChatEvent[]): string | undefined {
|
|
49
|
+
const f = events.find((e) => e.type === 'finish')
|
|
50
|
+
return f === undefined || f.type !== 'finish' ? undefined : f.reason
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 造一个规范的 Chat 请求体。 */
|
|
54
|
+
function chatRequest(over: JsonObject = {}): JsonObject {
|
|
55
|
+
return {
|
|
56
|
+
model: 'test-model',
|
|
57
|
+
messages: [
|
|
58
|
+
{ role: 'system', content: 'be brief' },
|
|
59
|
+
{ role: 'user', content: 'hello' },
|
|
60
|
+
{ role: 'assistant', content: '', tool_calls: [{ id: 'c1', type: 'function', function: { name: 'ls', arguments: '{"p":"/tmp"}' } }] },
|
|
61
|
+
{ role: 'tool', tool_call_id: 'c1', content: 'a.txt' },
|
|
62
|
+
],
|
|
63
|
+
tools: [{ type: 'function', function: { name: 'ls', description: 'list', parameters: { type: 'object', properties: { p: { type: 'string' } } } } }],
|
|
64
|
+
max_tokens: 100,
|
|
65
|
+
...over,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 造一个 `stream:true` 的版本(协议模块据此决定输出形态)。 */
|
|
70
|
+
function streaming(body: JsonObject): JsonObject {
|
|
71
|
+
return { ...body, stream: true }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const CTX = { model: 'test-model', stream: true }
|
|
75
|
+
|
|
76
|
+
/* ------------------------------------------------------------------ */
|
|
77
|
+
/* 1. 请求侧:每个协议 → 规范 → 自己,关键语义不丢 */
|
|
78
|
+
/* ------------------------------------------------------------------ */
|
|
79
|
+
|
|
80
|
+
test('chat 请求:恒等(toChat 不改任何字段)', () => {
|
|
81
|
+
const body = chatRequest()
|
|
82
|
+
const codec = codecOf('chat')
|
|
83
|
+
assert.deepEqual(codec.toChat(body, CTX), body)
|
|
84
|
+
assert.deepEqual(codec.fromChat(body, CTX), body)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('anthropic 请求往返:system / tools / tool_use / tool_result 都不丢', () => {
|
|
88
|
+
const codec = codecOf('anthropic')
|
|
89
|
+
// 规范的 Chat 请求 → Anthropic → 规范
|
|
90
|
+
const anth = codec.fromChat(chatRequest(), CTX)
|
|
91
|
+
// system 被提到顶层,不再占 messages[0]
|
|
92
|
+
assert.equal(anth.system, 'be brief')
|
|
93
|
+
const msgs = anth.messages as JsonObject[]
|
|
94
|
+
assert.equal(msgs.length, 3, 'user / assistant(tool_use) / user(tool_result)')
|
|
95
|
+
// 工具声明:input_schema 而非 parameters
|
|
96
|
+
const tools = anth.tools as JsonObject[]
|
|
97
|
+
assert.equal(tools[0]?.name, 'ls')
|
|
98
|
+
assert.ok((tools[0]?.input_schema as JsonObject)?.properties !== undefined)
|
|
99
|
+
|
|
100
|
+
// 再翻回规范:内容与工具调用必须回来
|
|
101
|
+
const back = codec.toChat(anth, CTX)
|
|
102
|
+
const backMsgs = back.messages as JsonObject[]
|
|
103
|
+
assert.equal(backMsgs[0]?.role, 'system')
|
|
104
|
+
assert.equal(backMsgs[0]?.content, 'be brief')
|
|
105
|
+
assert.equal(backMsgs[1]?.content, 'hello')
|
|
106
|
+
const assistant = backMsgs.find((m) => m.role === 'assistant')
|
|
107
|
+
assert.equal((assistant?.tool_calls as JsonObject[])?.[0]?.function && ((assistant?.tool_calls as JsonObject[])[0]!.function as JsonObject).name, 'ls')
|
|
108
|
+
const toolMsg = backMsgs.find((m) => m.role === 'tool')
|
|
109
|
+
assert.equal(toolMsg?.content, 'a.txt')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('anthropic 请求往返:纯文本 user 消息保持字符串形态(不为它造块)', () => {
|
|
113
|
+
const codec = codecOf('anthropic')
|
|
114
|
+
const back = codec.toChat({ messages: [{ role: 'user', content: 'hi' }] }, CTX)
|
|
115
|
+
assert.deepEqual((back.messages as JsonObject[])[0], { role: 'user', content: 'hi' })
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('responses 请求往返:instructions / 扁平 tools / max_output_tokens', () => {
|
|
119
|
+
const codec = codecOf('responses')
|
|
120
|
+
const resp = codec.fromChat(chatRequest(), CTX)
|
|
121
|
+
assert.equal(resp.instructions, 'be brief')
|
|
122
|
+
assert.equal(resp.max_output_tokens, 100)
|
|
123
|
+
const tools = resp.tools as JsonObject[]
|
|
124
|
+
assert.equal(tools[0]?.type, 'function')
|
|
125
|
+
assert.equal(tools[0]?.name, 'ls', 'Responses 的 tools 是扁平形态,没有 function 包装')
|
|
126
|
+
|
|
127
|
+
const back = codec.toChat(resp, CTX)
|
|
128
|
+
const backTools = back.tools as JsonObject[]
|
|
129
|
+
assert.equal((backTools[0]?.function as JsonObject).name, 'ls', '翻回来必须还原 function 包装')
|
|
130
|
+
assert.equal(back.max_tokens, 100)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('gemini 请求往返:role=model / parts / functionDeclarations', () => {
|
|
134
|
+
const codec = codecOf('gemini')
|
|
135
|
+
const gem = codec.fromChat(chatRequest(), CTX)
|
|
136
|
+
assert.deepEqual((gem.systemInstruction as JsonObject).parts, [{ text: 'be brief' }])
|
|
137
|
+
const contents = gem.contents as JsonObject[]
|
|
138
|
+
// user / model(tool call) / user(functionResponse)
|
|
139
|
+
assert.equal(contents[0]?.role, 'user')
|
|
140
|
+
assert.equal(contents[1]?.role, 'model', 'Gemini 的 assistant 叫 model')
|
|
141
|
+
const decls = (gem.tools as JsonObject[])[0]?.functionDeclarations as JsonObject[]
|
|
142
|
+
assert.equal(decls[0]?.name, 'ls')
|
|
143
|
+
|
|
144
|
+
const back = codec.toChat(gem, CTX)
|
|
145
|
+
const backMsgs = back.messages as JsonObject[]
|
|
146
|
+
assert.equal(backMsgs[0]?.role, 'system')
|
|
147
|
+
const assistant = backMsgs.find((m) => m.role === 'assistant')
|
|
148
|
+
assert.equal(((assistant?.tool_calls as JsonObject[])?.[0]?.function as JsonObject).name, 'ls')
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
/* ------------------------------------------------------------------ */
|
|
152
|
+
/* 2. 跨协议:Anthropic 请求 → Gemini 上游(4×3 里最有价值的那条) */
|
|
153
|
+
/* ------------------------------------------------------------------ */
|
|
154
|
+
|
|
155
|
+
test('★ 跨协议:Anthropic 请求 → 规范 → Gemini 请求,语义可读', () => {
|
|
156
|
+
const anthReq = {
|
|
157
|
+
model: 'm',
|
|
158
|
+
system: 'be brief',
|
|
159
|
+
max_tokens: 100,
|
|
160
|
+
messages: [
|
|
161
|
+
{ role: 'user', content: 'hello' },
|
|
162
|
+
{ role: 'assistant', content: [{ type: 'tool_use', id: 't1', name: 'ls', input: { p: '/' } }] },
|
|
163
|
+
{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 't1', content: 'ok' }] },
|
|
164
|
+
],
|
|
165
|
+
}
|
|
166
|
+
const canonical = codecOf('anthropic').toChat(anthReq, CTX)
|
|
167
|
+
const gem = codecOf('gemini').fromChat(canonical, CTX)
|
|
168
|
+
const contents = gem.contents as JsonObject[]
|
|
169
|
+
assert.deepEqual((gem.systemInstruction as JsonObject).parts, [{ text: 'be brief' }])
|
|
170
|
+
assert.equal(contents[0]?.role, 'user')
|
|
171
|
+
assert.equal(contents[1]?.role, 'model')
|
|
172
|
+
const parts = contents[1]?.parts as JsonObject[]
|
|
173
|
+
assert.equal((parts[0]?.functionCall as JsonObject).name, 'ls')
|
|
174
|
+
// 工具结果回到 user 侧、形态是 functionResponse
|
|
175
|
+
assert.equal(contents[2]?.role, 'user')
|
|
176
|
+
assert.ok(((contents[2]?.parts as JsonObject[])[0]?.functionResponse as JsonObject) !== undefined)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
/* ------------------------------------------------------------------ */
|
|
180
|
+
/* 3. 响应侧:每个协议的事件 → 规范 → 自己的帧,内容可读回 */
|
|
181
|
+
/* ------------------------------------------------------------------ */
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* 把规范事件编成某协议的帧、再解回来。
|
|
185
|
+
*
|
|
186
|
+
* 必须带上 `startFrames` / `endFrames`:Responses 的收尾原因与用量**只**存在
|
|
187
|
+
* 于 `response.completed`(由 endFrames 发),漏了它往返就一定丢 finish。
|
|
188
|
+
*/
|
|
189
|
+
function roundTripEvents(protocol: (typeof PROTOCOL_IDS)[number], events: ChatEvent[]): ChatEvent[] {
|
|
190
|
+
const codec = codecOf(protocol)
|
|
191
|
+
const finishEv = events.find((e) => e.type === 'finish')
|
|
192
|
+
const usageEv = events.find((e) => e.type === 'usage')
|
|
193
|
+
const boundaryCtx = {
|
|
194
|
+
model: 'm',
|
|
195
|
+
stream: true,
|
|
196
|
+
finish: finishEv?.type === 'finish' ? finishEv.reason : 'stop',
|
|
197
|
+
usage: usageEv?.type === 'usage'
|
|
198
|
+
? { promptTokens: usageEv.promptTokens, completionTokens: usageEv.completionTokens, cachedTokens: usageEv.cachedTokens }
|
|
199
|
+
: undefined,
|
|
200
|
+
}
|
|
201
|
+
// 一条流 = 一个会话:Gemini 的工具名↔index 映射跨帧存在这里
|
|
202
|
+
const session = codec.createSession?.()
|
|
203
|
+
const frames = [
|
|
204
|
+
...codec.startFrames(boundaryCtx),
|
|
205
|
+
...events.flatMap((e) => codec.encodeFrame(e, session)),
|
|
206
|
+
...codec.endFrames(boundaryCtx, session),
|
|
207
|
+
]
|
|
208
|
+
const back: ChatEvent[] = []
|
|
209
|
+
for (const frame of frames) {
|
|
210
|
+
// 每帧自带 event: 行(Anthropic/Responses),要拆出来喂给 decodeFrame
|
|
211
|
+
const m = /^event: (.+)\ndata: ([\s\S]*)\n\n$/.exec(frame)
|
|
212
|
+
if (m === null) {
|
|
213
|
+
const d = /^data: ([\s\S]*)\n\n$/.exec(frame)
|
|
214
|
+
if (d !== null && d[1] !== '[DONE]') back.push(...codec.decodeFrame(d[1]!))
|
|
215
|
+
continue
|
|
216
|
+
}
|
|
217
|
+
if (m[2] === '[DONE]') continue
|
|
218
|
+
back.push(...codec.decodeFrame(m[2]!, m[1], session))
|
|
219
|
+
}
|
|
220
|
+
return back
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
test('chat 响应帧往返:text / tool / usage / finish', () => {
|
|
224
|
+
const events: ChatEvent[] = [
|
|
225
|
+
{ type: 'text', text: 'hi ' },
|
|
226
|
+
{ type: 'text', text: 'there' },
|
|
227
|
+
{ type: 'tool-start', index: 0, id: 'c1', name: 'ls' },
|
|
228
|
+
{ type: 'tool-args', index: 0, delta: '{"p":' },
|
|
229
|
+
{ type: 'tool-args', index: 0, delta: '"/"}' },
|
|
230
|
+
{ type: 'usage', promptTokens: 10, completionTokens: 5, cachedTokens: 0 },
|
|
231
|
+
{ type: 'finish', reason: 'tool-calls' },
|
|
232
|
+
]
|
|
233
|
+
const back = roundTripEvents('chat', events)
|
|
234
|
+
assert.equal(textOf(back), 'hi there')
|
|
235
|
+
assert.deepEqual(toolsOf(back), [{ name: 'ls', args: '{"p":"/"}' }])
|
|
236
|
+
assert.deepEqual(usageOf(back), { promptTokens: 10, completionTokens: 5 })
|
|
237
|
+
assert.equal(finishOf(back), 'tool-calls')
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
test('anthropic 响应帧往返:text / thinking / tool_use / finish', () => {
|
|
241
|
+
const events: ChatEvent[] = [
|
|
242
|
+
{ type: 'reasoning', text: 'think' },
|
|
243
|
+
{ type: 'text', text: 'answer' },
|
|
244
|
+
{ type: 'tool-start', index: 0, id: 'tu1', name: 'ls' },
|
|
245
|
+
{ type: 'tool-args', index: 0, delta: '{"p":"/"}' },
|
|
246
|
+
{ type: 'usage', promptTokens: 7, completionTokens: 3, cachedTokens: 0 },
|
|
247
|
+
{ type: 'finish', reason: 'tool-calls' },
|
|
248
|
+
]
|
|
249
|
+
const back = roundTripEvents('anthropic', events)
|
|
250
|
+
assert.equal(textOf(back), 'answer')
|
|
251
|
+
assert.equal(back.filter((e) => e.type === 'reasoning').length, 1)
|
|
252
|
+
assert.deepEqual(toolsOf(back), [{ name: 'ls', args: '{"p":"/"}' }])
|
|
253
|
+
assert.equal(finishOf(back), 'tool-calls', 'tool_use → tool-calls')
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
test('responses 响应帧往返:text / tool / finish', () => {
|
|
257
|
+
const events: ChatEvent[] = [
|
|
258
|
+
{ type: 'text', text: 'hi' },
|
|
259
|
+
{ type: 'tool-start', index: 0, id: 'fc1', name: 'ls' },
|
|
260
|
+
{ type: 'tool-args', index: 0, delta: '{"p":"/"}' },
|
|
261
|
+
{ type: 'usage', promptTokens: 4, completionTokens: 2, cachedTokens: 0 },
|
|
262
|
+
{ type: 'finish', reason: 'stop' },
|
|
263
|
+
]
|
|
264
|
+
const back = roundTripEvents('responses', events)
|
|
265
|
+
assert.equal(textOf(back), 'hi')
|
|
266
|
+
assert.deepEqual(toolsOf(back), [{ name: 'ls', args: '{"p":"/"}' }])
|
|
267
|
+
assert.equal(finishOf(back), 'stop')
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
test('gemini 响应帧往返:text / functionCall / usage / finish', () => {
|
|
271
|
+
const events: ChatEvent[] = [
|
|
272
|
+
{ type: 'text', text: 'hi' },
|
|
273
|
+
{ type: 'tool-start', index: 0, name: 'ls' },
|
|
274
|
+
{ type: 'tool-args', index: 0, delta: '{"p":"/"}' },
|
|
275
|
+
{ type: 'usage', promptTokens: 6, completionTokens: 2, cachedTokens: 0 },
|
|
276
|
+
{ type: 'finish', reason: 'length' },
|
|
277
|
+
]
|
|
278
|
+
const back = roundTripEvents('gemini', events)
|
|
279
|
+
assert.equal(textOf(back), 'hi')
|
|
280
|
+
assert.deepEqual(toolsOf(back), [{ name: 'ls', args: '{"p":"/"}' }])
|
|
281
|
+
assert.equal(finishOf(back), 'length', 'MAX_TOKENS → length')
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
/* ------------------------------------------------------------------ */
|
|
285
|
+
/* 4. 非流式往返 */
|
|
286
|
+
/* ------------------------------------------------------------------ */
|
|
287
|
+
|
|
288
|
+
test('★ 非流式往返:每种协议 encodeJson → decodeJson,文本与工具不丢', () => {
|
|
289
|
+
const events: ChatEvent[] = [
|
|
290
|
+
{ type: 'text', text: 'answer' },
|
|
291
|
+
{ type: 'tool-start', index: 0, id: 'c1', name: 'ls' },
|
|
292
|
+
{ type: 'tool-args', index: 0, delta: '{"p":"/"}' },
|
|
293
|
+
{ type: 'usage', promptTokens: 9, completionTokens: 4, cachedTokens: 0 },
|
|
294
|
+
{ type: 'finish', reason: 'tool-calls' },
|
|
295
|
+
]
|
|
296
|
+
for (const protocol of PROTOCOL_IDS) {
|
|
297
|
+
const codec = codecOf(protocol)
|
|
298
|
+
const body = codec.encodeJson(events, { model: 'm', stream: false })
|
|
299
|
+
const back = codec.decodeJson(body)
|
|
300
|
+
assert.equal(textOf(back), 'answer', `${protocol}: 文本丢失`)
|
|
301
|
+
assert.deepEqual(toolsOf(back), [{ name: 'ls', args: '{"p":"/"}' }], `${protocol}: 工具调用丢失`)
|
|
302
|
+
assert.deepEqual(usageOf(back), { promptTokens: 9, completionTokens: 4 }, `${protocol}: 用量丢失`)
|
|
303
|
+
}
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
/* ------------------------------------------------------------------ */
|
|
307
|
+
/* 5. 流式管道:字节进、字节出,且流能正常结束 */
|
|
308
|
+
/* ------------------------------------------------------------------ */
|
|
309
|
+
|
|
310
|
+
test('★ 流式转码:chat 帧 → anthropic 帧,逐帧透传且收尾正确', async () => {
|
|
311
|
+
// 上游给 OpenAI SSE(含分帧边界被切断的情形)
|
|
312
|
+
const upstreamText =
|
|
313
|
+
'data: {"choices":[{"delta":{"content":"hi"}}]}\n\n' +
|
|
314
|
+
'data: {"choices":[{"delta":{"content":" there"}}]}\n\n' +
|
|
315
|
+
'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n' +
|
|
316
|
+
'data: [DONE]\n\n'
|
|
317
|
+
// 故意在中间切断,验证跨 chunk 缓冲
|
|
318
|
+
const bytes = new TextEncoder().encode(upstreamText)
|
|
319
|
+
const cut = Math.floor(bytes.length / 2)
|
|
320
|
+
const upstream = new ReadableStream<Uint8Array>({
|
|
321
|
+
start(ctrl) {
|
|
322
|
+
ctrl.enqueue(bytes.slice(0, cut))
|
|
323
|
+
ctrl.enqueue(bytes.slice(cut))
|
|
324
|
+
ctrl.close()
|
|
325
|
+
},
|
|
326
|
+
})
|
|
327
|
+
const out = translateStream(upstream, new StreamState('chat', 'anthropic'))
|
|
328
|
+
const text = await new Response(out).text()
|
|
329
|
+
// Anthropic 的帧:message_start → content_block_delta(text_delta) → message_delta/message_stop
|
|
330
|
+
assert.ok(text.includes('event: message_start'), '缺 message_start')
|
|
331
|
+
assert.ok(text.includes('"text_delta"'), '缺 text_delta')
|
|
332
|
+
assert.ok(text.includes('hi'), '缺内容 hi')
|
|
333
|
+
assert.ok(text.includes(' there'), '缺内容 there')
|
|
334
|
+
assert.ok(text.includes('event: message_stop'), '缺 message_stop')
|
|
335
|
+
assert.ok(text.includes('end_turn'), '缺 stop_reason')
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
test('★ 流式转码:上游一个字节都不给,仍产出协议合法的空流', async () => {
|
|
339
|
+
const upstream = new ReadableStream<Uint8Array>({
|
|
340
|
+
start(ctrl) { ctrl.close() },
|
|
341
|
+
})
|
|
342
|
+
const out = translateStream(upstream, new StreamState('chat', 'anthropic'))
|
|
343
|
+
const text = await new Response(out).text()
|
|
344
|
+
// 至少要有 message_start,否则 Claude Code 会报「流格式错误」而不是「上游没答」
|
|
345
|
+
assert.ok(text.includes('event: message_start'), '空流也要给 message_start')
|
|
346
|
+
assert.ok(text.includes('event: message_stop'), '空流也要收尾')
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
/* ------------------------------------------------------------------ */
|
|
350
|
+
/* 6. 逆序:供应商说 Anthropic,客户端说 chat */
|
|
351
|
+
/* ------------------------------------------------------------------ */
|
|
352
|
+
|
|
353
|
+
test('★ 出站:Anthropic 上游的帧 → chat 客户端可读', async () => {
|
|
354
|
+
const upstreamText =
|
|
355
|
+
'event: message_start\ndata: {"type":"message_start","message":{"usage":{"input_tokens":11,"output_tokens":0}}}\n\n' +
|
|
356
|
+
'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello"}}\n\n' +
|
|
357
|
+
'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}\n\n' +
|
|
358
|
+
'event: message_stop\ndata: {"type":"message_stop"}\n\n'
|
|
359
|
+
const upstream = new ReadableStream<Uint8Array>({
|
|
360
|
+
start(ctrl) {
|
|
361
|
+
ctrl.enqueue(new TextEncoder().encode(upstreamText))
|
|
362
|
+
ctrl.close()
|
|
363
|
+
},
|
|
364
|
+
})
|
|
365
|
+
const out = translateStream(upstream, new StreamState('anthropic', 'chat'))
|
|
366
|
+
const text = await new Response(out).text()
|
|
367
|
+
// chat 客户端看到的是 OpenAI SSE
|
|
368
|
+
assert.ok(text.includes('"content":"hello"'), '文本没翻出来')
|
|
369
|
+
assert.ok(text.includes('[DONE]'), '缺终止帧')
|
|
370
|
+
// usage:input 11 来自 message_start、output 5 来自 message_delta —— 必须都保住
|
|
371
|
+
const frames = text.split('\n').filter((l) => l.startsWith('data: ') && l !== 'data: [DONE]')
|
|
372
|
+
const usageFrame = frames.map((l) => JSON.parse(l.slice(6)) as JsonObject).find((o) => o.usage !== undefined)
|
|
373
|
+
assert.ok(usageFrame !== undefined, '缺 usage 帧')
|
|
374
|
+
const usage = usageFrame.usage as JsonObject
|
|
375
|
+
assert.equal(usage.prompt_tokens, 11, '输入用量丢了(message_start 那帧)')
|
|
376
|
+
assert.equal(usage.completion_tokens, 5, '输出用量丢了(message_delta 那帧)')
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
/* ------------------------------------------------------------------ */
|
|
380
|
+
/* 7. 坏输入不抛异常(上游半截 JSON 是常态) */
|
|
381
|
+
/* ------------------------------------------------------------------ */
|
|
382
|
+
|
|
383
|
+
test('坏帧不抛:半截 JSON / 空帧 / 未知事件一律返回空数组', () => {
|
|
384
|
+
for (const protocol of PROTOCOL_IDS) {
|
|
385
|
+
const codec = codecOf(protocol)
|
|
386
|
+
assert.deepEqual(codec.decodeFrame('{"half":'), [], `${protocol}: 半截 JSON 不该抛`)
|
|
387
|
+
assert.deepEqual(codec.decodeFrame('{}'), [], `${protocol}: 空对象不该抛`)
|
|
388
|
+
assert.deepEqual(codec.decodeFrame('not json at all'), [], `${protocol}: 纯文本不该抛`)
|
|
389
|
+
}
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
test('每种协议都有 startFrames 与 endFrames(形状完整)', () => {
|
|
393
|
+
for (const protocol of PROTOCOL_IDS) {
|
|
394
|
+
const codec = codecOf(protocol)
|
|
395
|
+
const start = codec.startFrames({ model: 'm', stream: true })
|
|
396
|
+
const end = codec.endFrames({ model: 'm', stream: true })
|
|
397
|
+
assert.ok(Array.isArray(start), `${protocol}: startFrames 必须是数组`)
|
|
398
|
+
assert.ok(Array.isArray(end), `${protocol}: endFrames 必须是数组`)
|
|
399
|
+
// 开始帧:只有 Anthropic(message_start)与 Responses(response.created)
|
|
400
|
+
// 有信封事件,那两个客户端严格等它。chat 的 [DONE] 由 finish 带;
|
|
401
|
+
// Gemini 的帧是裸 `data:`,没有信封。
|
|
402
|
+
if (protocol === 'chat' || protocol === 'gemini') {
|
|
403
|
+
assert.equal(start.length, 0, `${protocol}: 不该有开始信封帧`)
|
|
404
|
+
} else {
|
|
405
|
+
assert.ok(start.length > 0, `${protocol}: 流式必须有开始帧(客户端严格等)`)
|
|
406
|
+
}
|
|
407
|
+
// 收尾帧:Gemini **必须**有(finishReason 只能在最后发,见 gemini.ts
|
|
408
|
+
// 的 endFrames —— functionCall 攒到流末,finishReason 排它前面会让客户端
|
|
409
|
+
// 在收到工具调用前就收尾)。chat 没有([DONE] 由 finish 带)。
|
|
410
|
+
if (protocol === 'gemini') {
|
|
411
|
+
assert.ok(end.length > 0, 'gemini: 收尾帧必须带 finishReason')
|
|
412
|
+
assert.ok(end.join('').includes('finishReason'), 'gemini: 收尾帧缺 finishReason')
|
|
413
|
+
}
|
|
414
|
+
if (protocol === 'chat') {
|
|
415
|
+
assert.equal(end.length, 0)
|
|
416
|
+
}
|
|
417
|
+
if (protocol === 'anthropic' || protocol === 'responses') {
|
|
418
|
+
assert.ok(end.length > 0, `${protocol}: 必须有收尾帧`)
|
|
419
|
+
assert.ok(end.length > 0, `${protocol}: 流式必须有收尾帧(否则客户端报流被截断)`)
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
/* ------------------------------------------------------------------ */
|
|
425
|
+
/* 8. 回归:本会话修掉的四个真 bug */
|
|
426
|
+
/* ------------------------------------------------------------------ */
|
|
427
|
+
|
|
428
|
+
test('★ 回归:Chat 的 tools 带 function 包装,跨到各协议后工具不能被丢掉', () => {
|
|
429
|
+
// 事故:chatToolToAnthropic / chatToolToResponses 直接读 tool.name,
|
|
430
|
+
// 而 Chat 具名工具是 {type:'function', function:{name,...}} —— 于是全丢。
|
|
431
|
+
for (const protocol of ['anthropic', 'responses', 'gemini'] as const) {
|
|
432
|
+
const out = codecOf(protocol).fromChat(chatRequest(), CTX)
|
|
433
|
+
const names = JSON.stringify(out)
|
|
434
|
+
assert.ok(names.includes('"ls"'), `${protocol}: 工具声明丢失`)
|
|
435
|
+
}
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
test('★ 回归:Gemini 的 functionCall 攒到流末一次发出(不逐帧、不占位)', () => {
|
|
439
|
+
// 三个事故叠在一起:
|
|
440
|
+
// 1. tool-args 只有 index 没有 name,映射丢了会发出 name:'' 的 functionCall
|
|
441
|
+
// —— 客户端多认出一个空名工具;
|
|
442
|
+
// 2. tool-start 也发帧时,客户端看到一次 args={} 占位调用;
|
|
443
|
+
// 3. 上游把一次调用拆成多个 arguments 增量帧(OpenAI 系常态),逐帧发
|
|
444
|
+
// functionCall 会让客户端看到 N 次调用(2026-10-21 在 3080 实测:5 个
|
|
445
|
+
// args={} / 半截 JSON)。
|
|
446
|
+
// 正确行为:tool-start 登记名字、tool-args 累积、**流末**由 endFrames 一次发出。
|
|
447
|
+
const codec = codecOf('gemini')
|
|
448
|
+
const session = codec.createSession?.()
|
|
449
|
+
const ctx = { model: 'm', stream: true }
|
|
450
|
+
|
|
451
|
+
assert.deepEqual(codec.encodeFrame({ type: 'tool-start', index: 0, name: 'ls' }, session), [], 'tool-start 不该发帧')
|
|
452
|
+
// 参数分三段到(真实上游形态:JSON 字符串被切片)
|
|
453
|
+
for (const d of ['{"p":', '"/tm', 'p"}']) {
|
|
454
|
+
assert.deepEqual(codec.encodeFrame({ type: 'tool-args', index: 0, delta: d }, session), [], '参数增量不该逐帧发')
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const tail = codec.endFrames(ctx, session)
|
|
458
|
+
const joined = tail.join('')
|
|
459
|
+
assert.equal((joined.match(/functionCall/g) ?? []).length, 1, '一次工具调用只该产生一个 functionCall part')
|
|
460
|
+
assert.ok(joined.includes('"ls"'), '流末发的那一帧丢了工具名')
|
|
461
|
+
assert.ok(joined.includes('/tmp'), '参数没被拼完整')
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
test('★ 回归:两条并发流的工具名映射不串(会话隔离)', () => {
|
|
465
|
+
const codec = codecOf('gemini')
|
|
466
|
+
const a = codec.createSession?.()
|
|
467
|
+
const b = codec.createSession?.()
|
|
468
|
+
codec.encodeFrame({ type: 'tool-start', index: 0, name: 'read' }, a)
|
|
469
|
+
codec.encodeFrame({ type: 'tool-start', index: 0, name: 'write' }, b)
|
|
470
|
+
// 同名 index、不同会话,参数各自累积;流末各发自己的名字
|
|
471
|
+
const ctx = { model: 'm', stream: true }
|
|
472
|
+
codec.encodeFrame({ type: 'tool-args', index: 0, delta: '{"x":1}' }, a)
|
|
473
|
+
codec.encodeFrame({ type: 'tool-args', index: 0, delta: '{"y":2}' }, b)
|
|
474
|
+
const ta = codec.endFrames(ctx, a).join('')
|
|
475
|
+
const tb = codec.endFrames(ctx, b).join('')
|
|
476
|
+
assert.ok(ta.includes('"read"'), 'A 流的名字串了')
|
|
477
|
+
assert.ok(tb.includes('"write"'), 'B 流的名字串了')
|
|
478
|
+
assert.ok(ta.includes('"x"') && !ta.includes('"y"'), 'A 流的参数串了')
|
|
479
|
+
assert.ok(tb.includes('"y"') && !tb.includes('"x"'), 'B 流的参数串了')
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
test('★ 回归:Responses 收尾帧必须带 status 与 usage(Codex 靠它判结束)', () => {
|
|
483
|
+
const codec = codecOf('responses')
|
|
484
|
+
const end = codec.endFrames({
|
|
485
|
+
model: 'm',
|
|
486
|
+
stream: true,
|
|
487
|
+
finish: 'stop',
|
|
488
|
+
usage: { promptTokens: 11, completionTokens: 5, cachedTokens: 0 },
|
|
489
|
+
}).join('')
|
|
490
|
+
assert.ok(end.includes('response.completed'), '缺 response.completed')
|
|
491
|
+
assert.ok(end.includes('"status":"completed"'))
|
|
492
|
+
assert.ok(end.includes('"input_tokens":11'), '输入用量丢了')
|
|
493
|
+
assert.ok(end.includes('"output_tokens":5'), '输出用量丢了')
|
|
494
|
+
|
|
495
|
+
// 截断 → incomplete
|
|
496
|
+
const cut = codec.endFrames({ model: 'm', stream: true, finish: 'length' }).join('')
|
|
497
|
+
assert.ok(cut.includes('"status":"incomplete"'), '截断必须是 incomplete')
|
|
498
|
+
|
|
499
|
+
// 工具轮 → output 里要有 function_call,否则往返后认不出 tool-calls
|
|
500
|
+
const tool = codec.endFrames({ model: 'm', stream: true, finish: 'tool-calls' }).join('')
|
|
501
|
+
assert.ok(tool.includes('function_call'), '工具轮的 output 缺 function_call')
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
test('★ 回归:Anthropic 流尾只有一个 message_stop(finish 与 endFrames 不重复)', () => {
|
|
505
|
+
const codec = codecOf('anthropic')
|
|
506
|
+
const frames = [
|
|
507
|
+
...codec.encodeFrame({ type: 'finish', reason: 'stop' }),
|
|
508
|
+
...codec.endFrames({ model: 'm', stream: true }),
|
|
509
|
+
].join('')
|
|
510
|
+
const stops = frames.split('event: message_stop').length - 1
|
|
511
|
+
assert.equal(stops, 1, `message_stop 出现 ${stops} 次`)
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
test('★ 回归:Anthropic content_block_start(tool_use) 要还原 tool-start 的名字', () => {
|
|
515
|
+
const codec = codecOf('anthropic')
|
|
516
|
+
const evs = codec.decodeFrame(
|
|
517
|
+
JSON.stringify({ type: 'content_block_start', index: 0, content_block: { type: 'tool_use', id: 'tu1', name: 'ls', input: {} } }),
|
|
518
|
+
'content_block_start',
|
|
519
|
+
)
|
|
520
|
+
assert.deepEqual(toolsOf(evs), [{ name: 'ls', args: '' }], 'tool_use 的名字丢了')
|
|
521
|
+
})
|
|
522
|
+
|
|
523
|
+
test('★ 回归:上游 message_delta 不带 usage 时不能补一个全 0 的 usage', () => {
|
|
524
|
+
const codec = codecOf('anthropic')
|
|
525
|
+
const evs = codec.decodeFrame(
|
|
526
|
+
JSON.stringify({ type: 'message_delta', delta: { stop_reason: 'end_turn' } }),
|
|
527
|
+
'message_delta',
|
|
528
|
+
)
|
|
529
|
+
assert.equal(evs.filter((e) => e.type === 'usage').length, 0, '不该凭空造全 0 usage')
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
test('★ 回归:出站非流式 —— 解码 Chat 形态、编码成客户端协议(方向不能反)', async () => {
|
|
533
|
+
// 事故:finishJson 用客户端 codec 解 Chat JSON,`choices` 字段解不出来,
|
|
534
|
+
// 客户端拿到一个 content 为空、却状态 200 的回复(最糟的一类 bug:静默)。
|
|
535
|
+
const { outboundSink } = await import('./entry.ts')
|
|
536
|
+
interface Fake { chunks: string[]; body: string }
|
|
537
|
+
const makeRes = (): Fake & Record<string, unknown> => {
|
|
538
|
+
const chunks: string[] = []
|
|
539
|
+
return {
|
|
540
|
+
chunks,
|
|
541
|
+
get body() { return chunks.join('') },
|
|
542
|
+
writeHead() { return this },
|
|
543
|
+
flushHeaders() {},
|
|
544
|
+
write(b: Uint8Array | string) { chunks.push(typeof b === 'string' ? b : new TextDecoder().decode(b)); return true },
|
|
545
|
+
end(b?: Uint8Array | string) {
|
|
546
|
+
if (b !== undefined) chunks.push(typeof b === 'string' ? b : new TextDecoder().decode(b))
|
|
547
|
+
},
|
|
548
|
+
on() { return this }, once() { return this }, removeListener() {}, destroy() {},
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
const upstream = JSON.stringify({
|
|
552
|
+
choices: [{ message: { content: 'answer' }, finish_reason: 'stop' }],
|
|
553
|
+
usage: { prompt_tokens: 3, completion_tokens: 2 },
|
|
554
|
+
})
|
|
555
|
+
for (const protocol of ['anthropic', 'responses', 'gemini', 'chat'] as const) {
|
|
556
|
+
const res = makeRes()
|
|
557
|
+
const sink = outboundSink(protocol, 'm', res as never)
|
|
558
|
+
sink.head?.(200, { 'content-type': 'application/json' })
|
|
559
|
+
sink.write(new TextEncoder().encode(upstream))
|
|
560
|
+
sink.end()
|
|
561
|
+
assert.ok(res.body.includes('answer'), `${protocol}: 非流式内容丢了(方向反了?)`)
|
|
562
|
+
}
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
test('★ 回归:出站按 content-type 判流/JSON,不靠内容猜', async () => {
|
|
566
|
+
const { outboundSink } = await import('./entry.ts')
|
|
567
|
+
const chunks: string[] = []
|
|
568
|
+
const res: Record<string, unknown> = {
|
|
569
|
+
writeHead() { return this }, flushHeaders() {},
|
|
570
|
+
write(b: Uint8Array | string) { chunks.push(typeof b === 'string' ? b : new TextDecoder().decode(b)); return true },
|
|
571
|
+
end(b?: Uint8Array | string) { if (b !== undefined) chunks.push(typeof b === 'string' ? b : new TextDecoder().decode(b)) },
|
|
572
|
+
on() { return this }, once() { return this }, removeListener() {}, destroy() {},
|
|
573
|
+
}
|
|
574
|
+
const sink = outboundSink('anthropic', 'm', res as never)
|
|
575
|
+
sink.head?.(200, { 'content-type': 'application/json' })
|
|
576
|
+
// 内容里带 "data:" —— 旧实现会把它当 SSE 喂给翻译器,然后吐空流
|
|
577
|
+
sink.write(new TextEncoder().encode(JSON.stringify({ choices: [{ message: { content: 'data: not sse' } }] })))
|
|
578
|
+
sink.end()
|
|
579
|
+
const body = chunks.join('')
|
|
580
|
+
assert.ok(!body.includes('event: message_start'), '被误判成 SSE 了')
|
|
581
|
+
assert.ok(body.includes('data: not sse'), '内容丢了')
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
test('★ 回归:Gemini 路径带 ?alt=sse(CLI 默认)时模型名解析得出来', async () => {
|
|
585
|
+
const { geminiModelFromPath } = await import('./entry.ts')
|
|
586
|
+
assert.equal(geminiModelFromPath('/v1beta/models/gemini-2.5-pro:streamGenerateContent?alt=sse'), 'gemini-2.5-pro')
|
|
587
|
+
assert.equal(geminiModelFromPath('/v1beta/models/gemini-2.5-pro:generateContent'), 'gemini-2.5-pro')
|
|
588
|
+
// 模型名可含斜杠(`models/` 前缀是 Gemini 的合法写法)
|
|
589
|
+
assert.equal(geminiModelFromPath('/v1beta/models/models/gemini-pro:generateContent'), 'models/gemini-pro')
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
/* ------------------------------------------------------------------ */
|
|
593
|
+
/* 9. 穷举 4×4:每一对协议(含双向)都必须真通 */
|
|
594
|
+
/* ------------------------------------------------------------------ */
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* 「四种协议双向互转」这句话的**判据**。
|
|
598
|
+
*
|
|
599
|
+
* 前面几组测试各测一个协议对自己的往返;这里穷举 A→B 的全部有向组合
|
|
600
|
+
* (4×4 − 4 条自己到自己 = 12 条请求路径 + 12 条响应路径),任何一条
|
|
601
|
+
* 断掉都说明「双向互转」名不副实。
|
|
602
|
+
*
|
|
603
|
+
* 值钱的不是数量,而是**跨协议语义**:Claude Code 说 Anthropic、上游只会
|
|
604
|
+
* 说 Gemini 这种组合,是这套设计的全部理由。
|
|
605
|
+
*/
|
|
606
|
+
const CROSS_CANONICAL: JsonObject = {
|
|
607
|
+
model: 'm',
|
|
608
|
+
messages: [
|
|
609
|
+
{ role: 'system', content: 'be brief' },
|
|
610
|
+
{ role: 'user', content: '列出 /tmp' },
|
|
611
|
+
{ role: 'assistant', content: '', tool_calls: [{ id: 'c1', type: 'function', function: { name: 'ls', arguments: '{"p":"/tmp"}' } }] },
|
|
612
|
+
{ role: 'tool', tool_call_id: 'c1', content: 'a.txt' },
|
|
613
|
+
],
|
|
614
|
+
tools: [{ type: 'function', function: { name: 'ls', description: 'list', parameters: { type: 'object', properties: { p: { type: 'string' } } } } }],
|
|
615
|
+
max_tokens: 100,
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const CROSS_EVENTS: ChatEvent[] = [
|
|
619
|
+
{ type: 'text', text: 'hi' },
|
|
620
|
+
{ type: 'tool-start', index: 0, id: 'c1', name: 'ls' },
|
|
621
|
+
{ type: 'tool-args', index: 0, delta: '{"p":"/tmp"}' },
|
|
622
|
+
{ type: 'usage', promptTokens: 7, completionTokens: 3, cachedTokens: 0 },
|
|
623
|
+
{ type: 'finish', reason: 'tool-calls' },
|
|
624
|
+
]
|
|
625
|
+
|
|
626
|
+
test('★ 穷举请求路径:A 形态 → 规范 → B 形态,工具与工具结果都不丢', () => {
|
|
627
|
+
for (const a of PROTOCOL_IDS) {
|
|
628
|
+
for (const b of PROTOCOL_IDS) {
|
|
629
|
+
if (a === b) continue
|
|
630
|
+
// 规范 → A(我们用自己的语言向说 A 的供应商请求)
|
|
631
|
+
const fa = codecOf(a).fromChat(CROSS_CANONICAL, CTX)
|
|
632
|
+
// A → 规范(客户端说 A,我们听懂它)
|
|
633
|
+
const canonical = codecOf(a).toChat(fa, CTX)
|
|
634
|
+
// 规范 → B
|
|
635
|
+
const fb = codecOf(b).fromChat(canonical, CTX)
|
|
636
|
+
const blob = JSON.stringify(fb)
|
|
637
|
+
assert.ok(blob.includes('ls'), `${a} → ${b}: 工具声明丢了`)
|
|
638
|
+
assert.ok(blob.includes('a.txt'), `${a} → ${b}: 工具结果丢了`)
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
})
|
|
642
|
+
|
|
643
|
+
test('★ 穷举响应路径:A 的帧 → 规范 → B 的帧,文本与工具名都能读回', () => {
|
|
644
|
+
for (const a of PROTOCOL_IDS) {
|
|
645
|
+
for (const b of PROTOCOL_IDS) {
|
|
646
|
+
if (a === b) continue
|
|
647
|
+
const ca = codecOf(a)
|
|
648
|
+
const cb = codecOf(b)
|
|
649
|
+
const sa = ca.createSession?.()
|
|
650
|
+
const sb = cb.createSession?.()
|
|
651
|
+
|
|
652
|
+
// 规范事件 → A 的帧(含 A 的收尾帧:Gemini 的工具调用只在那里发出)
|
|
653
|
+
const framesA = [
|
|
654
|
+
...CROSS_EVENTS.flatMap((e) => ca.encodeFrame(e, sa)),
|
|
655
|
+
...ca.endFrames({ model: 'm', stream: true, finish: 'tool-calls' }, sa),
|
|
656
|
+
]
|
|
657
|
+
// A 的帧 → 规范事件
|
|
658
|
+
const back: ChatEvent[] = []
|
|
659
|
+
for (const frame of framesA) {
|
|
660
|
+
const m = /^event: (.+)\ndata: ([\s\S]*)\n\n$/.exec(frame)
|
|
661
|
+
if (m === null) {
|
|
662
|
+
const d = /^data: ([\s\S]*)\n\n$/.exec(frame)
|
|
663
|
+
if (d !== null && d[1] !== '[DONE]') back.push(...ca.decodeFrame(d[1]!, undefined, sa))
|
|
664
|
+
continue
|
|
665
|
+
}
|
|
666
|
+
if (m[2] === '[DONE]') continue
|
|
667
|
+
back.push(...ca.decodeFrame(m[2]!, m[1], sa))
|
|
668
|
+
}
|
|
669
|
+
// 规范 → B 的帧(内容事件 + 收尾帧)
|
|
670
|
+
const out = [
|
|
671
|
+
...back.filter((e) => e.type !== 'finish' && e.type !== 'usage').flatMap((e) => cb.encodeFrame(e, sb)),
|
|
672
|
+
...cb.endFrames({ model: 'm', stream: true, finish: 'tool-calls' }, sb),
|
|
673
|
+
].join('')
|
|
674
|
+
assert.ok(out.includes('hi'), `${a} → ${b}: 文本丢了`)
|
|
675
|
+
assert.ok(out.includes('ls'), `${a} → ${b}: 工具名丢了`)
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
})
|
|
679
|
+
|
|
680
|
+
test('★ 回归:各协议**入站**的 tools 必须转成 Chat 的 function 包装(否则客户端看不到任何工具)', () => {
|
|
681
|
+
// 事故(2026-10-21 在 3080 实测):anthropic 的 `convertTools` 读的是
|
|
682
|
+
// `tool.function.*`(Chat 形状),而 Anthropic 客户端给的是扁平的
|
|
683
|
+
// `{name, description, input_schema}` —— 于是 Claude Code 的所有工具被
|
|
684
|
+
// 静默丢光,模型回答「我没有这个工具」。上一条判据只测了出站方向,
|
|
685
|
+
// 入站方向当时一条都没有。
|
|
686
|
+
const expectChatTool = (protocol: string, out: JsonObject): void => {
|
|
687
|
+
const tools = out.tools as JsonObject[] | undefined
|
|
688
|
+
assert.ok(tools !== undefined && tools.length > 0, `${protocol}: 入站 tools 被丢光`)
|
|
689
|
+
const fn = tools[0]?.function as JsonObject | undefined
|
|
690
|
+
assert.equal(fn?.name, 'w', `${protocol}: 入站 tools 缺 function.name`)
|
|
691
|
+
assert.ok(
|
|
692
|
+
(fn?.parameters as JsonObject | undefined)?.properties !== undefined,
|
|
693
|
+
`${protocol}: 入站 tools 缺 parameters(input_schema 没转名)`,
|
|
694
|
+
)
|
|
695
|
+
}
|
|
696
|
+
const schema = { type: 'object', properties: { city: { type: 'string' } } }
|
|
697
|
+
|
|
698
|
+
expectChatTool('anthropic', codecOf('anthropic').toChat({
|
|
699
|
+
messages: [{ role: 'user', content: 'hi' }],
|
|
700
|
+
tools: [{ name: 'w', description: 'd', input_schema: schema }],
|
|
701
|
+
}, CTX))
|
|
702
|
+
|
|
703
|
+
expectChatTool('responses', codecOf('responses').toChat({
|
|
704
|
+
input: [{ type: 'message', role: 'user', content: [{ type: 'input_text', text: 'hi' }] }],
|
|
705
|
+
tools: [{ type: 'function', name: 'w', description: 'd', parameters: schema }],
|
|
706
|
+
}, CTX))
|
|
707
|
+
|
|
708
|
+
expectChatTool('gemini', codecOf('gemini').toChat({
|
|
709
|
+
contents: [{ role: 'user', parts: [{ text: 'hi' }] }],
|
|
710
|
+
tools: [{ functionDeclarations: [{ name: 'w', description: 'd', parameters: schema }] }],
|
|
711
|
+
}, CTX))
|
|
712
|
+
})
|
|
713
|
+
|
|
714
|
+
test('★ 回归:Gemini 非流式也只产出一个 functionCall(不重复占位)', () => {
|
|
715
|
+
// 与流式同一个坑:tool-start 发 part + tool-args 再发 part = 客户端看到
|
|
716
|
+
// 两次调用,第一次 args 还是空的。
|
|
717
|
+
const body = codecOf('gemini').encodeJson([
|
|
718
|
+
{ type: 'text', text: 'checking' },
|
|
719
|
+
{ type: 'tool-start', index: 0, name: 'ls' },
|
|
720
|
+
{ type: 'tool-args', index: 0, delta: '{"p":"/tmp"}' },
|
|
721
|
+
{ type: 'finish', reason: 'tool-calls' },
|
|
722
|
+
], { model: 'm', stream: false })
|
|
723
|
+
const parts = (body.candidates as JsonObject[])[0]?.content as JsonObject
|
|
724
|
+
const list = parts.parts as JsonObject[]
|
|
725
|
+
const calls = list.filter((p) => p.functionCall !== undefined)
|
|
726
|
+
assert.equal(calls.length, 1, `一次工具调用只该有一个 functionCall part,实际 ${calls.length}`)
|
|
727
|
+
assert.deepEqual((calls[0]?.functionCall as JsonObject).args, { p: '/tmp' })
|
|
728
|
+
})
|