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.
- package/README.md +152 -12
- package/docs/ext.md +1 -1
- package/docs/protocols.md +198 -0
- package/lib/client-registry.js +146 -106
- package/lib/client.js +146 -106
- package/lib/ext-prompt/index.js +182 -17
- package/lib/index.js +2732 -55
- package/lib/types/ext/contract.d.ts +20 -1
- package/lib/types/ext-prompt/content.d.ts +7 -1
- package/lib/types/ext-prompt/mount.d.ts +12 -1
- package/lib/types/ext-prompt/plugin.d.ts +10 -0
- 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/package.json +6 -6
- package/src/client/ExtDetail.tsx +25 -5
- package/src/client/group-controls.test.ts +53 -0
- package/src/client/group-controls.ts +43 -0
- package/src/client/router.css +20 -1
- package/src/ext/contract.ts +20 -1
- package/src/ext-prompt/assembly.test.ts +63 -0
- package/src/ext-prompt/content.ts +8 -10
- package/src/ext-prompt/ext-prompt.test.ts +38 -28
- package/src/ext-prompt/index.ts +50 -2
- package/src/ext-prompt/mount.test.ts +18 -1
- package/src/ext-prompt/mount.ts +22 -7
- package/src/ext-prompt/plugin.ts +64 -1
- 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 +142 -23
- package/src/router/types.ts +19 -0
- package/src/shared.ts +11 -0
- 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
|
+
}
|