dsh-router-core 0.4.6 → 0.4.7

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.
Files changed (63) hide show
  1. package/README.md +152 -12
  2. package/docs/ext.md +1 -1
  3. package/docs/protocols.md +198 -0
  4. package/lib/client-registry.js +146 -106
  5. package/lib/client.js +146 -106
  6. package/lib/ext-prompt/index.js +182 -17
  7. package/lib/index.js +2732 -55
  8. package/lib/types/ext/contract.d.ts +20 -1
  9. package/lib/types/ext-prompt/content.d.ts +7 -1
  10. package/lib/types/ext-prompt/mount.d.ts +12 -1
  11. package/lib/types/ext-prompt/plugin.d.ts +10 -0
  12. package/lib/types/ext-prompt/render.d.ts +14 -3
  13. package/lib/types/ext-prompt/sections.d.ts +95 -0
  14. package/lib/types/protocol/anthropic.d.ts +35 -0
  15. package/lib/types/protocol/chat.d.ts +25 -0
  16. package/lib/types/protocol/entry.d.ts +137 -0
  17. package/lib/types/protocol/gemini.d.ts +57 -0
  18. package/lib/types/protocol/index.d.ts +13 -0
  19. package/lib/types/protocol/responses.d.ts +41 -0
  20. package/lib/types/protocol/sse.d.ts +41 -0
  21. package/lib/types/protocol/stream.d.ts +92 -0
  22. package/lib/types/protocol/types.d.ts +224 -0
  23. package/lib/types/router/index.d.ts +42 -1
  24. package/lib/types/router/types.d.ts +19 -0
  25. package/lib/types/shared.d.ts +11 -0
  26. package/lib/types/suppliers/contract.d.ts +8 -0
  27. package/package.json +6 -6
  28. package/src/client/ExtDetail.tsx +25 -5
  29. package/src/client/group-controls.test.ts +53 -0
  30. package/src/client/group-controls.ts +43 -0
  31. package/src/client/router.css +20 -1
  32. package/src/ext/contract.ts +20 -1
  33. package/src/ext-prompt/assembly.test.ts +63 -0
  34. package/src/ext-prompt/content.ts +8 -10
  35. package/src/ext-prompt/ext-prompt.test.ts +38 -28
  36. package/src/ext-prompt/index.ts +50 -2
  37. package/src/ext-prompt/mount.test.ts +18 -1
  38. package/src/ext-prompt/mount.ts +22 -7
  39. package/src/ext-prompt/plugin.ts +64 -1
  40. package/src/ext-prompt/render.test.ts +1 -1
  41. package/src/ext-prompt/render.ts +17 -6
  42. package/src/ext-prompt/section-controls.test.ts +148 -0
  43. package/src/ext-prompt/section-filter.test.ts +140 -0
  44. package/src/ext-prompt/sections.test.ts +123 -0
  45. package/src/ext-prompt/sections.ts +193 -0
  46. package/src/ext-route.test.ts +16 -0
  47. package/src/index.ts +109 -1
  48. package/src/protocol/anthropic.ts +662 -0
  49. package/src/protocol/chat.ts +272 -0
  50. package/src/protocol/entry.ts +440 -0
  51. package/src/protocol/gemini.ts +548 -0
  52. package/src/protocol/index.ts +31 -0
  53. package/src/protocol/protocol.test.ts +728 -0
  54. package/src/protocol/responses.ts +551 -0
  55. package/src/protocol/sse.ts +94 -0
  56. package/src/protocol/stream.ts +195 -0
  57. package/src/protocol/types.ts +249 -0
  58. package/src/protocol/wire-e2e.test.ts +208 -0
  59. package/src/protocol/wiring.test.ts +225 -0
  60. package/src/router/index.ts +142 -23
  61. package/src/router/types.ts +19 -0
  62. package/src/shared.ts +11 -0
  63. package/src/suppliers/contract.ts +8 -0
@@ -0,0 +1,548 @@
1
+ /**
2
+ * Google Gemini 协议(`generateContent` / `streamGenerateContent`)。
3
+ *
4
+ * ════════════════════════════════════════════════════════════════════
5
+ * 与其他三种的差异
6
+ * ════════════════════════════════════════════════════════════════════
7
+ * 1. **模型名在 URL 路径里**:`/v1beta/models/<model>:generateContent`,
8
+ * 请求体里没有 `model` 字段。
9
+ * 2. **角色名不同**:`user` / `model`(不是 assistant)。
10
+ * 3. **内容是 parts**:`{parts:[{text}]}`,不是字符串。
11
+ * 4. **工具调用是整块**:`functionCall` part 一次给全 `{name,args}`,
12
+ * 没有增量形态 —— 故 decode 时拆成 tool-start + tool-args 一段。
13
+ * 5. **鉴权走 query 参数 `?key=`** 或 `x-goog-api-key` 头(不是 Bearer)。
14
+ * 6. **流式响应是 JSON 数组分片**(`[{...},{...}]`),不是 SSE ——
15
+ * `streamGenerateContent?alt=sse` 才给 SSE。我们只支持 `alt=sse`,
16
+ * 非 SSE 形态明确不支持(见文件尾 `geminiStreamSse` 的说明)。
17
+ *
18
+ * 丢弃:`safetySettings` / `tools[].googleSearch` / `cachedContent` /
19
+ * `systemInstruction` 之外的 `generationConfig` 大部分字段(只留
20
+ * temperature / maxOutputTokens / stopSequences)。
21
+ */
22
+ import {
23
+ type ChatEvent,
24
+ type CodecContext,
25
+ type CodecSession,
26
+ type FinishReason,
27
+ type JsonObject,
28
+ type ProtocolCodec,
29
+ arr,
30
+ finishOf,
31
+ num,
32
+ obj,
33
+ str,
34
+ } from './types.ts'
35
+ import { dataFrame } from './sse.ts'
36
+
37
+ export const geminiCodec: ProtocolCodec = {
38
+ id: 'gemini',
39
+
40
+ /** Gemini 请求 → Chat Completions 请求。 */
41
+ toChat(body, ctx) {
42
+ const out: JsonObject = {}
43
+ const messages = geminiMessages(body)
44
+ const sys = systemText(body.systemInstruction)
45
+ if (sys !== '') messages.unshift({ role: 'system', content: sys })
46
+ out.messages = messages
47
+ const gen = obj(body.generationConfig)
48
+ if (gen !== undefined) {
49
+ if (num(gen.temperature) !== undefined) out.temperature = gen.temperature
50
+ if (num(gen.maxOutputTokens) !== undefined) out.max_tokens = gen.maxOutputTokens
51
+ if (arr(gen.stopSequences) !== undefined) out.stop = gen.stopSequences
52
+ }
53
+ const tools = convertTools(body.tools)
54
+ if (tools.length > 0) out.tools = tools
55
+ if (ctx.stream) out.stream = true
56
+ // model 由 URL 路径给出,调用方已填进 ctx;规范请求体里补上,供应商才知道调谁
57
+ out.model = ctx.model
58
+ return out
59
+ },
60
+
61
+ /** Chat Completions 请求 → Gemini 请求。 */
62
+ fromChat(body, ctx) {
63
+ const out: JsonObject = {}
64
+ const systemParts: string[] = []
65
+ const contents: JsonObject[] = []
66
+ for (const m of arr(body.messages) ?? []) {
67
+ const msg = obj(m)
68
+ if (msg === undefined) continue
69
+ const role = str(msg.role)
70
+ if (role === 'system' || role === 'developer') {
71
+ const t = contentText(msg.content)
72
+ if (t !== '') systemParts.push(t)
73
+ continue
74
+ }
75
+ if (role === 'assistant') {
76
+ const parts: JsonObject[] = []
77
+ const text = contentText(msg.content)
78
+ if (text !== '') parts.push({ text })
79
+ for (const tc of arr(msg.tool_calls) ?? []) {
80
+ const fn = obj(obj(tc)?.function)
81
+ parts.push({
82
+ functionCall: {
83
+ name: str(fn?.name) ?? '',
84
+ args: parseArgs(str(fn?.arguments)),
85
+ },
86
+ })
87
+ }
88
+ contents.push({ role: 'model', parts })
89
+ continue
90
+ }
91
+ if (role === 'tool') {
92
+ // 工具结果 → functionResponse part,role 回到 user
93
+ contents.push({
94
+ role: 'user',
95
+ parts: [{
96
+ functionResponse: {
97
+ name: str(msg.name) ?? '',
98
+ response: { result: contentText(msg.content) },
99
+ },
100
+ }],
101
+ })
102
+ continue
103
+ }
104
+ contents.push({ role: 'user', parts: [{ text: contentText(msg.content) }] })
105
+ }
106
+ out.contents = contents
107
+ if (systemParts.length > 0) {
108
+ out.systemInstruction = { parts: [{ text: systemParts.join('\n\n') }] }
109
+ }
110
+ if (num(body.temperature) !== undefined || num(body.max_tokens) !== undefined) {
111
+ out.generationConfig = {
112
+ ...(num(body.temperature) === undefined ? {} : { temperature: body.temperature }),
113
+ ...(num(body.max_tokens) === undefined ? {} : { maxOutputTokens: body.max_tokens }),
114
+ }
115
+ }
116
+ if (arr(body.stop) !== undefined) {
117
+ (out.generationConfig as JsonObject).stopSequences = body.stop
118
+ }
119
+ const tools = (body.tools as unknown[] | undefined)?.map(chatToolToGemini).filter((t) => t !== undefined)
120
+ if (tools !== undefined && tools.length > 0) out.tools = [{ functionDeclarations: tools }]
121
+ void ctx
122
+ return out
123
+ },
124
+
125
+ /* ---------------- 响应侧:Gemini SSE → ChatEvent ---------------- */
126
+
127
+ createSession() {
128
+ return { state: newGeminiToolMap() }
129
+ },
130
+
131
+ decodeFrame(frame, _event, session) {
132
+ let payload: JsonObject
133
+ try {
134
+ payload = JSON.parse(frame) as JsonObject
135
+ } catch {
136
+ return []
137
+ }
138
+ return geminiBodyEvents(payload, mapOf(session))
139
+ },
140
+
141
+ /* ---------------- 响应侧:ChatEvent → Gemini SSE ---------------- */
142
+
143
+ encodeFrame(event, session) {
144
+ const toolMap = mapOf(session)
145
+ const chunk = (parts: JsonObject[], finish?: string): string[] => [
146
+ dataFrame(JSON.stringify({ candidates: [{ content: { role: 'model', parts }, ...(finish === undefined ? {} : { finishReason: finish }) }] })),
147
+ ]
148
+ switch (event.type) {
149
+ case 'text':
150
+ return chunk([{ text: event.text }])
151
+ case 'reasoning':
152
+ return chunk([{ text: event.text, thought: true }])
153
+ case 'tool-start': {
154
+ // **只登记名字,不发帧**。Gemini 的 functionCall 是「名字+参数整块」
155
+ // 一个 part,没有「先声明、后补参数」的形态;在 tool-start 就发一帧
156
+ // 空 args 会让客户端多认出一个 args={} 的工具调用,紧接着 tool-args
157
+ // 再发一个同名带参数的 —— 客户端看到两次调用(2026-10-21 在 3080
158
+ // 实测:`get_weather args={}` + `get_weather args={city:Beijing}`)。
159
+ const name = event.name ?? ''
160
+ if (name !== '') toolMap.remember(event.index, name)
161
+ return []
162
+ }
163
+ case 'tool-args': {
164
+ // **只累积,不发帧**。Gemini 的 `functionCall.args` 是整块对象,而上游
165
+ // 会把一次调用拆成很多 `arguments` 增量帧。逐帧发会让客户端看到 N 次
166
+ // 调用、前 N-1 次 args 还是空对象/半截 JSON(2026-10-21 在 3080 实测:
167
+ // 一次调用发了 5 个 `args:{}`)。所以攒到 `endFrames` 里一次发出。
168
+ toolMap.appendArgs(event.index, event.delta)
169
+ return []
170
+ }
171
+ case 'usage':
172
+ return [
173
+ dataFrame(
174
+ JSON.stringify({
175
+ usageMetadata: {
176
+ promptTokenCount: event.promptTokens,
177
+ candidatesTokenCount: event.completionTokens,
178
+ totalTokenCount: event.promptTokens + event.completionTokens,
179
+ ...(event.cachedTokens > 0 ? { cachedContentTokenCount: event.cachedTokens } : {}),
180
+ },
181
+ }),
182
+ ),
183
+ ]
184
+ case 'error':
185
+ return [
186
+ dataFrame(
187
+ JSON.stringify({
188
+ error: { code: 500, message: event.message, status: 'INTERNAL' },
189
+ }),
190
+ ),
191
+ ]
192
+ case 'finish':
193
+ // **不发帧**,由 `endFrames` 在最后发。
194
+ //
195
+ // Gemini 的 `finishReason` 是「本轮说完了」的标记,客户端看到它就结束
196
+ // 这一轮。而 `functionCall` 攒到流末才发 —— 若 finish 在这里先出去,
197
+ // 客户端会在收到工具调用**之前**就判定结束,那次调用被丢掉
198
+ // (2026-10-22 在 3080 实测:functionCall 排在 finishReason 之后)。
199
+ return []
200
+ }
201
+ },
202
+
203
+ startFrames: () => [],
204
+
205
+ /**
206
+ * 流末一次性发出**完整的** functionCall。
207
+ *
208
+ * 为什么集中在这里:Gemini 没有参数增量形态,一个调用必须「名字+完整参数」
209
+ * 用一个 part 给出。上游(OpenAI 系)逐帧发参数增量,`tool-args` 只能攒;
210
+ * 攒到流末参数才完整,此时才发得出合法的 `functionCall`。
211
+ *
212
+ * 半截 JSON(上游断流)**不发**:发出去客户端会拿坏参数执行工具,
213
+ * 比少一次调用危险得多。
214
+ */
215
+ endFrames(ctx, session) {
216
+ const toolMap = mapOf(session)
217
+ const out: string[] = []
218
+ for (const index of toolMap.pendingIndexes()) {
219
+ const name = toolMap.nameOf(index)
220
+ if (name === '') continue
221
+ const args = toolMap.completeArgs(index)
222
+ if (args === undefined) continue
223
+ out.push(
224
+ dataFrame(
225
+ JSON.stringify({ candidates: [{ content: { role: 'model', parts: [{ functionCall: { name, args } }] } }] }),
226
+ ),
227
+ )
228
+ }
229
+ // `finishReason` **最后**发 —— 客户端看到它就收尾这一轮,必须排在所有
230
+ // 内容(含刚补发的 functionCall)之后。
231
+ out.push(...chunkOf([], finishToGemini(ctx.finish ?? 'stop')))
232
+ return out
233
+ },
234
+
235
+ /**
236
+ * 非流式响应体 → 规范事件。
237
+ *
238
+ * Gemini 非流式 body 与流式一帧同形(`{candidates:[{content:{parts},finishReason}],
239
+ * usageMetadata}`),所以直接复用 `decodeFrame` —— 这是四种协议里唯一
240
+ * 「流与非流形状一致」的,没有第二份映射可写。
241
+ */
242
+ decodeJson(body) {
243
+ return geminiBodyEvents(body, newGeminiToolMap())
244
+ },
245
+
246
+ /** 规范事件 → 非流式 body。 */
247
+ encodeJson(events, ctx) {
248
+ const toolMap = newGeminiToolMap()
249
+ const parts: JsonObject[] = []
250
+ let finish = 'STOP'
251
+ let usage = { prompt: 0, output: 0, cached: 0 }
252
+ for (const ev of events) {
253
+ switch (ev.type) {
254
+ case 'text': parts.push({ text: ev.text }); break
255
+ case 'reasoning': parts.push({ text: ev.text, thought: true }); break
256
+ case 'tool-start': {
257
+ // 只登记名字,不产 part —— 与流式同理(Gemini 没有「先声明后补参」
258
+ // 的形态,发占位 part 会让客户端看到两次调用)。
259
+ const name = ev.name ?? ''
260
+ if (name !== '') toolMap.remember(ev.index, name)
261
+ break
262
+ }
263
+ case 'tool-args': {
264
+ const name = toolMap.nameOf(ev.index)
265
+ if (name !== '') parts.push({ functionCall: { name, args: parseArgs(ev.delta) } })
266
+ break
267
+ }
268
+ case 'finish': finish = finishToGemini(ev.reason); break
269
+ case 'usage': usage = { prompt: ev.promptTokens, output: ev.completionTokens, cached: ev.cachedTokens }; break
270
+ case 'error': break
271
+ }
272
+ }
273
+ void ctx
274
+ return {
275
+ candidates: [{ content: { role: 'model', parts }, finishReason: finish, index: 0 }],
276
+ usageMetadata: {
277
+ promptTokenCount: usage.prompt,
278
+ candidatesTokenCount: usage.output,
279
+ totalTokenCount: usage.prompt + usage.output,
280
+ ...(usage.cached > 0 ? { cachedContentTokenCount: usage.cached } : {}),
281
+ },
282
+ }
283
+ },
284
+ }
285
+
286
+ /* ------------------------------------------------------------------ */
287
+ /* 实现 */
288
+ /* ------------------------------------------------------------------ */
289
+
290
+ /** 一帧 `candidates`。与 `encodeFrame` 里的闭包同形,供 `endFrames` 用。 */
291
+ function chunkOf(parts: JsonObject[], finish?: string): string[] {
292
+ return [
293
+ dataFrame(
294
+ JSON.stringify({
295
+ candidates: [{ content: { role: 'model', parts }, ...(finish === undefined ? {} : { finishReason: finish }) }],
296
+ }),
297
+ ),
298
+ ]
299
+ }
300
+
301
+ /**
302
+ * 工具名 ↔ index 的映射,**每个请求一份**。
303
+ *
304
+ * Gemini 没有 index 概念(工具调用靠 name 认),而规范事件用 index —— 所以
305
+ * 跨帧必须有映射,而跨**请求**必须没有。模块级 Map 会串名(并发两条流各自
306
+ * 的 `ls` 抢同一个 index,或者上一条流的映射残留到下一条),所以这里做成
307
+ * 每个编解码会话一个实例,由 `StreamState` / `entry.ts` 在请求边界创建。
308
+ *
309
+ * 谁在用:`decodeFrameInto` / `encodeFrameWith` 拿到同一个 `GeminiToolMap`;
310
+ * 单次调用(测试、非流式一次性转换)走 `geminiBodyEvents` 的默认实例。
311
+ */
312
+ export interface GeminiToolMap {
313
+ /** 工具名 → 规范 index(同名复用同一个 index)。 */
314
+ indexOf(name: string): number
315
+ /** 规范 index → 工具名(未知返回 '')。 */
316
+ nameOf(index: number): string
317
+ /** 记下「规范 index → 工具名」(`tool-start` 带上游 index 时用)。 */
318
+ remember(index: number, name: string): void
319
+ /**
320
+ * 把一段参数增量累到该 index 上,返回**截至目前**的完整 JSON。
321
+ *
322
+ * Gemini 的 `functionCall.args` 是整块对象,没有增量形态;而上游(OpenAI
323
+ * 系)会把一次调用拆成很多 `arguments` 增量帧。逐帧发 `functionCall` 会让
324
+ * 客户端看到 N 次调用、且前 N-1 次 args 是空对象或半截 JSON
325
+ * (2026-10-21 在 3080 实测:5 个 `args:{}`)。所以只能攒。
326
+ */
327
+ appendArgs(index: number, delta: string): string
328
+ /** 该 index 上是否有**完整可解析**的参数(流末判定用)。 */
329
+ completeArgs(index: number): JsonObject | undefined
330
+ /** 攒过参数的所有 index(流末按序发出)。 */
331
+ pendingIndexes(): number[]
332
+ }
333
+
334
+ /** 新建一份空的工具名映射。 */
335
+ export function newGeminiToolMap(): GeminiToolMap {
336
+ const byName = new Map<string, number>()
337
+ const byIndex = new Map<number, string>()
338
+ /** index → 已累积的参数片段(跨帧拼接后才是完整 JSON)。 */
339
+ const argsByIndex = new Map<number, string>()
340
+ let seq = 0
341
+ return {
342
+ indexOf(name) {
343
+ const existing = byName.get(name)
344
+ if (existing !== undefined) return existing
345
+ const index = seq++
346
+ byName.set(name, index)
347
+ byIndex.set(index, name)
348
+ return index
349
+ },
350
+ nameOf(index) {
351
+ return byIndex.get(index) ?? ''
352
+ },
353
+ remember(index, name) {
354
+ byIndex.set(index, name)
355
+ if (!byName.has(name)) byName.set(name, index)
356
+ },
357
+ appendArgs(index, delta) {
358
+ const next = (argsByIndex.get(index) ?? '') + delta
359
+ argsByIndex.set(index, next)
360
+ return next
361
+ },
362
+ completeArgs(index) {
363
+ const raw = argsByIndex.get(index)
364
+ if (raw === undefined || raw === '') return {}
365
+ try {
366
+ const v: unknown = JSON.parse(raw)
367
+ return obj(v) ?? undefined
368
+ } catch {
369
+ return undefined // 半截 JSON:还没攒够
370
+ }
371
+ },
372
+ pendingIndexes() {
373
+ return [...argsByIndex.keys()].sort((a, b) => a - b)
374
+ },
375
+ }
376
+ }
377
+
378
+ /**
379
+ * 取本次调用的工具名映射:有会话用会话的,没有就临时建一份。
380
+ *
381
+ * 「没有会话」= 单帧独立转换(测试、非流式一次性)。结果与无状态等价 ——
382
+ * 名字在自己的调用里总能拿到,只是不跨帧复用。
383
+ */
384
+ function mapOf(session: CodecSession | undefined): GeminiToolMap {
385
+ const st = session?.state
386
+ return st instanceof Object && 'indexOf' in st ? (st as GeminiToolMap) : newGeminiToolMap()
387
+ }
388
+
389
+ /**
390
+ * 一个 Gemini 响应体(流式一帧或非流式 body,两者同形)→ 规范事件。
391
+ *
392
+ * Gemini 是四种协议里唯一「流与非流形状一致」的,所以只有这一份映射。
393
+ */
394
+ function geminiBodyEvents(payload: JsonObject, tools: GeminiToolMap): ChatEvent[] {
395
+ const out: ChatEvent[] = []
396
+ const candidate = obj(arr(payload.candidates)?.[0])
397
+ if (candidate !== undefined) {
398
+ const finish = candidate.finishReason
399
+ if (finish !== undefined && finish !== null) {
400
+ out.push({ type: 'finish', reason: finishOf(finish) })
401
+ }
402
+ for (const p of arr(obj(candidate.content)?.parts) ?? []) {
403
+ const part = obj(p)
404
+ if (part === undefined) continue
405
+ const text = str(part.text)
406
+ if (text !== undefined) {
407
+ // thought: true 的 part 是推理内容
408
+ if (part.thought === true) out.push({ type: 'reasoning', text })
409
+ else out.push({ type: 'text', text })
410
+ continue
411
+ }
412
+ const fc = obj(part.functionCall)
413
+ if (fc !== undefined) {
414
+ const name = str(fc.name) ?? ''
415
+ // 没名字的 functionCall 不能变成 tool-start:那会凭空造一个叫 "" 的
416
+ // 工具,客户端会把它当成第二个工具调用。
417
+ if (name === '') continue
418
+ const index = tools.indexOf(name)
419
+ out.push({ type: 'tool-start', index, name })
420
+ out.push({ type: 'tool-args', index, delta: JSON.stringify(fc.args ?? {}) })
421
+ }
422
+ }
423
+ }
424
+ const usage = obj(payload.usageMetadata)
425
+ if (usage !== undefined) {
426
+ out.push({
427
+ type: 'usage',
428
+ promptTokens: num(usage.promptTokenCount) ?? 0,
429
+ completionTokens: num(usage.candidatesTokenCount) ?? 0,
430
+ cachedTokens: num(usage.cachedContentTokenCount) ?? 0,
431
+ })
432
+ }
433
+ return out
434
+ }
435
+
436
+ /** Gemini 的 systemInstruction → 纯文本。 */
437
+ function systemText(sys: unknown): string {
438
+ const parts: string[] = []
439
+ for (const p of arr(obj(sys)?.parts) ?? []) {
440
+ const t = str(obj(p)?.text)
441
+ if (t !== undefined) parts.push(t)
442
+ }
443
+ return parts.join('\n\n')
444
+ }
445
+
446
+ /** Gemini contents → Chat messages。 */
447
+ function geminiMessages(body: JsonObject): unknown[] {
448
+ const out: unknown[] = []
449
+ for (const c of arr(body.contents) ?? []) {
450
+ const item = obj(c)
451
+ if (item === undefined) continue
452
+ const role = str(item.role) === 'model' ? 'assistant' : 'user'
453
+ const text: string[] = []
454
+ const toolCalls: JsonObject[] = []
455
+ for (const p of arr(item.parts) ?? []) {
456
+ const part = obj(p)
457
+ if (part === undefined) continue
458
+ const t = str(part.text)
459
+ if (t !== undefined) { text.push(t); continue }
460
+ const fc = obj(part.functionCall)
461
+ if (fc !== undefined) {
462
+ toolCalls.push({
463
+ id: `call_${str(fc.name) ?? ''}`,
464
+ type: 'function',
465
+ function: { name: str(fc.name) ?? '', arguments: JSON.stringify(fc.args ?? {}) },
466
+ })
467
+ continue
468
+ }
469
+ const fr = obj(part.functionResponse)
470
+ if (fr !== undefined) {
471
+ out.push({ role: 'tool', tool_call_id: `call_${str(fr.name) ?? ''}`, content: JSON.stringify(obj(fr.response)?.result ?? {}) })
472
+ }
473
+ }
474
+ if (role === 'assistant') {
475
+ const msg: JsonObject = { role: 'assistant', content: text.join('\n\n') }
476
+ if (toolCalls.length > 0) msg.tool_calls = toolCalls
477
+ out.push(msg)
478
+ } else if (text.length > 0) {
479
+ out.push({ role: 'user', content: text.join('\n\n') })
480
+ }
481
+ }
482
+ return out
483
+ }
484
+
485
+ /** Chat 的 content(字符串或 parts)→ 纯文本。 */
486
+ function contentText(content: unknown): string {
487
+ if (typeof content === 'string') return content
488
+ const parts: string[] = []
489
+ for (const p of arr(content) ?? []) {
490
+ const t = str(obj(p)?.text)
491
+ if (t !== undefined) parts.push(t)
492
+ }
493
+ return parts.join('\n\n')
494
+ }
495
+
496
+ function parseArgs(s: string | undefined): JsonObject {
497
+ if (s === undefined || s === '') return {}
498
+ try {
499
+ const v: unknown = JSON.parse(s)
500
+ return obj(v) ?? {}
501
+ } catch {
502
+ return {}
503
+ }
504
+ }
505
+
506
+ /** Chat tools → Gemini functionDeclarations。 */
507
+ function chatToolToGemini(t: unknown): JsonObject | undefined {
508
+ const fn = obj(obj(t)?.function)
509
+ const name = fn === undefined ? undefined : str(fn.name)
510
+ if (fn === undefined || name === undefined) return undefined
511
+ return {
512
+ name,
513
+ description: str(fn.description) ?? '',
514
+ parameters: obj(fn.parameters) ?? { type: 'object', properties: {} },
515
+ }
516
+ }
517
+
518
+ /** Gemini tools → Chat tools。 */
519
+ function convertTools(tools: unknown): JsonObject[] {
520
+ const out: JsonObject[] = []
521
+ for (const t of arr(tools) ?? []) {
522
+ for (const d of arr(obj(t)?.functionDeclarations) ?? []) {
523
+ const decl = obj(d)
524
+ if (decl === undefined) continue
525
+ const name = str(decl.name)
526
+ if (name === undefined) continue
527
+ out.push({
528
+ type: 'function',
529
+ function: {
530
+ name,
531
+ description: str(decl.description) ?? '',
532
+ parameters: obj(decl.parameters) ?? { type: 'object', properties: {} },
533
+ },
534
+ })
535
+ }
536
+ }
537
+ return out
538
+ }
539
+
540
+ function finishToGemini(reason: FinishReason): string {
541
+ switch (reason) {
542
+ case 'length': return 'MAX_TOKENS'
543
+ case 'tool-calls': return 'STOP'
544
+ case 'content-filter': return 'SAFETY'
545
+ case 'error': return 'OTHER'
546
+ default: return 'STOP'
547
+ }
548
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * 协议注册表 —— 按 id 取编解码器。
3
+ *
4
+ * 刻意保持成一张**静态表**而不是注册机制:四种协议是编译期就知道的集合,
5
+ * 加第五种就是改这个文件 + 加一个模块,不需要运行时发现。做成可注册会让
6
+ * 「谁在什么时候注册了什么」变成运行时事实,而这里没有任何插件需要插进来
7
+ * (供应商 wire 是另一回事,见 `suppliers/contract.ts` 的 `wire`)。
8
+ */
9
+ import type { ProtocolCodec, ProtocolId } from './types.ts'
10
+ import { PROTOCOL_IDS } from './types.ts'
11
+ import { chatCodec } from './chat.ts'
12
+ import { anthropicCodec } from './anthropic.ts'
13
+ import { responsesCodec } from './responses.ts'
14
+ import { geminiCodec } from './gemini.ts'
15
+
16
+ const CODECS: Record<ProtocolId, ProtocolCodec> = {
17
+ chat: chatCodec,
18
+ anthropic: anthropicCodec,
19
+ responses: responsesCodec,
20
+ gemini: geminiCodec,
21
+ }
22
+
23
+ /** 取编解码器。id 已由 `isProtocolId` 校验过,这里不再兜底。 */
24
+ export function codecOf(id: ProtocolId): ProtocolCodec {
25
+ return CODECS[id]
26
+ }
27
+
28
+ /** 全部编解码器(文档与测试用)。 */
29
+ export function allCodecs(): ProtocolCodec[] {
30
+ return PROTOCOL_IDS.map((id) => CODECS[id])
31
+ }