dsh-router-core 0.4.5 → 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 (75) hide show
  1. package/README.md +152 -12
  2. package/docs/ext.md +1 -1
  3. package/docs/protocols.md +198 -0
  4. package/docs/suppliers.md +2 -2
  5. package/lib/client-registry.js +146 -106
  6. package/lib/client.js +146 -106
  7. package/lib/ext-prompt/index.js +182 -17
  8. package/lib/index.js +2764 -63
  9. package/lib/suppliers/opencode/index.js +58 -30
  10. package/lib/types/ext/contract.d.ts +20 -1
  11. package/lib/types/ext-prompt/content.d.ts +7 -1
  12. package/lib/types/ext-prompt/mount.d.ts +12 -1
  13. package/lib/types/ext-prompt/plugin.d.ts +10 -0
  14. package/lib/types/ext-prompt/render.d.ts +14 -3
  15. package/lib/types/ext-prompt/sections.d.ts +95 -0
  16. package/lib/types/protocol/anthropic.d.ts +35 -0
  17. package/lib/types/protocol/chat.d.ts +25 -0
  18. package/lib/types/protocol/entry.d.ts +137 -0
  19. package/lib/types/protocol/gemini.d.ts +57 -0
  20. package/lib/types/protocol/index.d.ts +13 -0
  21. package/lib/types/protocol/responses.d.ts +41 -0
  22. package/lib/types/protocol/sse.d.ts +41 -0
  23. package/lib/types/protocol/stream.d.ts +92 -0
  24. package/lib/types/protocol/types.d.ts +224 -0
  25. package/lib/types/router/account-pool.d.ts +7 -2
  26. package/lib/types/router/index.d.ts +42 -1
  27. package/lib/types/router/types.d.ts +26 -3
  28. package/lib/types/router/usage-store.d.ts +4 -2
  29. package/lib/types/shared.d.ts +24 -0
  30. package/lib/types/suppliers/contract.d.ts +15 -1
  31. package/package.json +6 -6
  32. package/src/client/ExtDetail.tsx +25 -5
  33. package/src/client/group-controls.test.ts +53 -0
  34. package/src/client/group-controls.ts +43 -0
  35. package/src/client/router.css +20 -1
  36. package/src/ext/contract.ts +20 -1
  37. package/src/ext-prompt/assembly.test.ts +63 -0
  38. package/src/ext-prompt/content.ts +8 -10
  39. package/src/ext-prompt/ext-prompt.test.ts +38 -28
  40. package/src/ext-prompt/index.ts +50 -2
  41. package/src/ext-prompt/mount.test.ts +18 -1
  42. package/src/ext-prompt/mount.ts +22 -7
  43. package/src/ext-prompt/plugin.ts +64 -1
  44. package/src/ext-prompt/render.test.ts +1 -1
  45. package/src/ext-prompt/render.ts +17 -6
  46. package/src/ext-prompt/section-controls.test.ts +148 -0
  47. package/src/ext-prompt/section-filter.test.ts +140 -0
  48. package/src/ext-prompt/sections.test.ts +123 -0
  49. package/src/ext-prompt/sections.ts +193 -0
  50. package/src/ext-route.test.ts +16 -0
  51. package/src/index.ts +117 -6
  52. package/src/llm/adapter.test.ts +54 -0
  53. package/src/llm/adapter.ts +24 -2
  54. package/src/protocol/anthropic.ts +662 -0
  55. package/src/protocol/chat.ts +272 -0
  56. package/src/protocol/entry.ts +440 -0
  57. package/src/protocol/gemini.ts +548 -0
  58. package/src/protocol/index.ts +31 -0
  59. package/src/protocol/protocol.test.ts +728 -0
  60. package/src/protocol/responses.ts +551 -0
  61. package/src/protocol/sse.ts +94 -0
  62. package/src/protocol/stream.ts +195 -0
  63. package/src/protocol/types.ts +249 -0
  64. package/src/protocol/wire-e2e.test.ts +208 -0
  65. package/src/protocol/wiring.test.ts +225 -0
  66. package/src/router/account-pool.test.ts +35 -0
  67. package/src/router/account-pool.ts +15 -4
  68. package/src/router/index.ts +143 -24
  69. package/src/router/types.ts +26 -3
  70. package/src/router/usage-store.ts +4 -2
  71. package/src/shared.test.ts +35 -0
  72. package/src/shared.ts +33 -0
  73. package/src/suppliers/contract.ts +15 -2
  74. package/src/suppliers/opencode/plugin.test.ts +79 -25
  75. package/src/suppliers/opencode/plugin.ts +60 -29
@@ -0,0 +1,551 @@
1
+ /**
2
+ * OpenAI Responses 协议(Codex 用的是它)。
3
+ *
4
+ * ════════════════════════════════════════════════════════════════════
5
+ * 与 Chat Completions 的关键差异
6
+ * ════════════════════════════════════════════════════════════════════
7
+ * 请求 `input` 是**消息数组**(与 messages 同构)而不是 messages 本身
8
+ * `instructions` 替代 `system` 消息
9
+ * `max_output_tokens` 替代 `max_tokens`
10
+ * `tools` 项是扁平的 `{type:'function', name, parameters}`,
11
+ * 没有 `function` 这一层包装
12
+ * 响应 事件名带语义:`response.output_text.delta` / `response.function_call_arguments.delta`
13
+ * / `response.reasoning_summary_text.delta`,全部带 `sequence_number`
14
+ * 收尾是 `response.completed`(带完整 response 对象),不是 finish_reason
15
+ *
16
+ * 丢弃(明确不做):
17
+ * - `previous_response_id` / `store`:服务端会话态。我们的供应商契约是
18
+ * 无状态的(`chatOnce` 一次一请求),转发过去只会拿到错误。
19
+ * - `reasoning.effort` 之外的 `reasoning.summary`:摘要粒度由上游定,
20
+ * 我们只透传 reasoning 增量。
21
+ * - `include` / `text.format`(结构化输出):跨协议没有对应物。
22
+ */
23
+ import {
24
+ type ChatEvent,
25
+ type CodecContext,
26
+ type JsonObject,
27
+ type ProtocolCodec,
28
+ arr,
29
+ finishOf,
30
+ num,
31
+ obj,
32
+ str,
33
+ } from './types.ts'
34
+ import { dataFrame, eventFrame } from './sse.ts'
35
+
36
+ export const responsesCodec: ProtocolCodec = {
37
+ id: 'responses',
38
+
39
+ /** Responses 请求 → Chat Completions 请求。 */
40
+ toChat(body, ctx) {
41
+ const out: JsonObject = { ...body }
42
+ delete out.input
43
+ delete out.instructions
44
+ delete out.tools
45
+ delete out.max_output_tokens
46
+ delete out.previous_response_id
47
+ delete out.store
48
+ delete out.include
49
+ delete out.reasoning
50
+
51
+ const messages: unknown[] = []
52
+ const instructions = str(body.instructions)
53
+ if (instructions !== undefined) messages.push({ role: 'system', content: instructions })
54
+ for (const m of arr(body.input) ?? []) messages.push(inputItem(m))
55
+ out.messages = messages
56
+ if (body.max_output_tokens !== undefined) out.max_tokens = body.max_output_tokens
57
+ if (body.temperature !== undefined) out.temperature = body.temperature
58
+ if (body.top_p !== undefined) out.top_p = body.top_p
59
+ const tools = convertTools(body.tools)
60
+ if (tools.length > 0) out.tools = tools
61
+ if (ctx.stream) out.stream = true
62
+ return out
63
+ },
64
+
65
+ /** Chat Completions 请求 → Responses 请求。 */
66
+ fromChat(body, ctx) {
67
+ const out: JsonObject = { ...body }
68
+ delete out.messages
69
+ delete out.tools
70
+ delete out.stream
71
+ delete out.max_tokens
72
+
73
+ const input: unknown[] = []
74
+ const instructions: string[] = []
75
+ for (const m of arr(body.messages) ?? []) {
76
+ const msg = obj(m)
77
+ if (msg === undefined) continue
78
+ const role = str(msg.role)
79
+ if (role === 'system' || role === 'developer') {
80
+ const t = contentText(msg.content)
81
+ if (t !== '') instructions.push(t)
82
+ continue
83
+ }
84
+ // tool 角色 → `function_call_output`:Chat 把工具结果放在 role:'tool',
85
+ // Responses 放在一个独立的 input 项里。丢了它上游会以为「工具从没被调用过」,
86
+ // 于是重复发起同一个调用 —— 表现为无限工具循环。
87
+ if (role === 'tool') {
88
+ input.push({
89
+ type: 'function_call_output',
90
+ call_id: str(msg.tool_call_id) ?? '',
91
+ output: contentText(msg.content),
92
+ })
93
+ continue
94
+ }
95
+ const text = contentText(msg.content)
96
+ // 文本永远是 message 项(空文本就不造一个空 message)
97
+ if (text !== '') {
98
+ input.push({
99
+ type: 'message',
100
+ role: role === 'assistant' ? 'assistant' : 'user',
101
+ content: [{ type: role === 'assistant' ? 'output_text' : 'input_text', text }],
102
+ })
103
+ }
104
+ // assistant 的 tool_calls → 每个一个 `function_call` 项(Responses 没有
105
+ // 嵌套的 tool_calls,是平铺的独立项)。
106
+ if (role === 'assistant') {
107
+ for (const tc of arr(msg.tool_calls) ?? []) {
108
+ const call = obj(tc)
109
+ const fn = obj(call?.function)
110
+ if (call === undefined || fn === undefined) continue
111
+ const name = str(fn.name)
112
+ if (name === undefined) continue
113
+ input.push({
114
+ type: 'function_call',
115
+ call_id: str(call.id) ?? '',
116
+ name,
117
+ arguments: str(fn.arguments) ?? '{}',
118
+ })
119
+ }
120
+ }
121
+ }
122
+ if (instructions.length > 0) out.instructions = instructions.join('\n\n')
123
+ out.input = input
124
+ if (body.max_tokens !== undefined) out.max_output_tokens = body.max_tokens
125
+ const tools = (body.tools as unknown[] | undefined)?.map(chatToolToResponses).filter((t) => t !== undefined)
126
+ if (tools !== undefined && tools.length > 0) out.tools = tools
127
+ if (ctx.stream) out.stream = true
128
+ return out
129
+ },
130
+
131
+ /* ---------------- 响应侧:Responses SSE → ChatEvent ---------------- */
132
+
133
+ decodeFrame(frame, event) {
134
+ let payload: JsonObject
135
+ try {
136
+ payload = JSON.parse(frame) as JsonObject
137
+ } catch {
138
+ return []
139
+ }
140
+ switch (event) {
141
+ case 'response.output_text.delta': {
142
+ const delta = str(payload.delta)
143
+ return delta === undefined ? [] : [{ type: 'text', text: delta }]
144
+ }
145
+ case 'response.reasoning_summary_text.delta':
146
+ case 'response.reasoning_text.delta': {
147
+ const delta = str(payload.delta)
148
+ return delta === undefined ? [] : [{ type: 'reasoning', text: delta }]
149
+ }
150
+ case 'response.function_call_arguments.delta': {
151
+ const delta = str(payload.delta)
152
+ if (delta === undefined || delta === '') return []
153
+ // output_index 是「这一轮里第几个输出项」,与函数名无关;用 item_id 映射
154
+ // 成连续 index 的活儿在 decode 层做不了(无状态),故这里直接用
155
+ // output_index,由调方保证它与 tool-start 用的是同一套编号。
156
+ return [{ type: 'tool-args', index: num(payload.output_index) ?? 0, delta }]
157
+ }
158
+ case 'response.output_item.added':
159
+ return outputItemEvents(payload)
160
+ case 'response.completed':
161
+ return completedEvents(payload)
162
+ case 'response.failed':
163
+ case 'error': {
164
+ const err = obj(obj(payload.response)?.error) ?? obj(payload.error)
165
+ const message = str(err?.message) ?? str(payload.message) ?? 'upstream error'
166
+ return [{ type: 'error', message }]
167
+ }
168
+ default:
169
+ return []
170
+ }
171
+ },
172
+
173
+ /* ---------------- 响应侧:ChatEvent → Responses SSE ---------------- */
174
+
175
+ encodeFrame(event) {
176
+ switch (event.type) {
177
+ case 'text':
178
+ return [
179
+ eventFrame(
180
+ 'response.output_text.delta',
181
+ JSON.stringify({ type: 'response.output_text.delta', output_index: 0, delta: event.text }),
182
+ ),
183
+ ]
184
+ case 'reasoning':
185
+ return [
186
+ eventFrame(
187
+ 'response.reasoning_summary_text.delta',
188
+ JSON.stringify({ type: 'response.reasoning_summary_text.delta', output_index: 0, delta: event.text }),
189
+ ),
190
+ ]
191
+ case 'tool-start':
192
+ return [
193
+ eventFrame(
194
+ 'response.output_item.added',
195
+ JSON.stringify({
196
+ type: 'response.output_item.added',
197
+ output_index: event.index,
198
+ item: {
199
+ type: 'function_call',
200
+ id: event.id ?? `fc_${event.index}`,
201
+ call_id: event.id ?? `call_${event.index}`,
202
+ name: event.name ?? '',
203
+ arguments: '',
204
+ },
205
+ }),
206
+ ),
207
+ ]
208
+ case 'tool-args':
209
+ return [
210
+ eventFrame(
211
+ 'response.function_call_arguments.delta',
212
+ JSON.stringify({
213
+ type: 'response.function_call_arguments.delta',
214
+ item_id: `fc_${event.index}`,
215
+ output_index: event.index,
216
+ delta: event.delta,
217
+ }),
218
+ ),
219
+ ]
220
+ case 'usage':
221
+ return [] // usage 只在 response.completed 里给,见 endFrames
222
+ case 'error':
223
+ return [
224
+ eventFrame(
225
+ 'error',
226
+ JSON.stringify({ type: 'error', code: 'server_error', message: event.message }),
227
+ ),
228
+ ]
229
+ case 'finish':
230
+ return [] // 收尾在 response.completed,由 endFrames 负责
231
+ }
232
+ },
233
+
234
+ startFrames() {
235
+ return [
236
+ eventFrame(
237
+ 'response.created',
238
+ JSON.stringify({ type: 'response.created', response: { id: `resp_${Date.now().toString(36)}`, status: 'in_progress', output: [] } }),
239
+ ),
240
+ ]
241
+ },
242
+
243
+ /**
244
+ * 流结束帧:`response.completed` 带一个完整的 response 对象。
245
+ *
246
+ * 用量与收尾原因都从 `ctx` 来(`StreamState.finishStream` 填),因为 Codex
247
+ * 读的是 `response.completed` 里的 `status`/`usage`,而不是一个 finish_reason
248
+ * 字段 —— 少了这一帧客户端会一直等流结束。
249
+ */
250
+ endFrames(ctx) {
251
+ return [completedFrame(ctx.model, ctx.stream ? ctx.usage : undefined, ctx.finish)]
252
+ },
253
+
254
+ /**
255
+ * 非流式响应体 → 规范事件。
256
+ *
257
+ * 非流式 Responses body 形状:`{output:[{type:'message',content:[{type:'output_text',text}]}
258
+ * |{type:'function_call',name,arguments}], status, usage:{input_tokens,output_tokens}}`。
259
+ * 与流式的 `response.output_item.added` / `response.completed` 共用同一组
260
+ * item→事件 映射,见 `outputItemEvents`。
261
+ */
262
+ decodeJson(body) {
263
+ const out: ChatEvent[] = []
264
+ let index = 0
265
+ let text = ''
266
+ for (const item of arr(body.output) ?? []) {
267
+ const it = obj(item)
268
+ if (it === undefined) continue
269
+ if (it.type === 'message') {
270
+ for (const p of arr(it.content) ?? []) {
271
+ const t = str(obj(p)?.text)
272
+ if (t !== undefined) text += t
273
+ }
274
+ continue
275
+ }
276
+ if (it.type === 'function_call') {
277
+ out.push({ type: 'tool-start', index, name: str(it.name) ?? '' })
278
+ out.push({ type: 'tool-args', index, delta: str(it.arguments) ?? '' })
279
+ index += 1
280
+ continue
281
+ }
282
+ if (it.type === 'reasoning') {
283
+ for (const s of arr(it.summary) ?? []) {
284
+ const t = str(obj(s)?.text)
285
+ if (t !== undefined) out.push({ type: 'reasoning', text: t })
286
+ }
287
+ }
288
+ }
289
+ if (text !== '') out.unshift({ type: 'text', text })
290
+ const usage = obj(body.usage)
291
+ if (usage !== undefined) {
292
+ out.push({
293
+ type: 'usage',
294
+ promptTokens: num(usage.input_tokens) ?? 0,
295
+ completionTokens: num(usage.output_tokens) ?? 0,
296
+ cachedTokens: num(obj(usage.input_tokens_details)?.cached_tokens) ?? 0,
297
+ })
298
+ }
299
+ const status = str(body.status)
300
+ if (status === 'incomplete') out.push({ type: 'finish', reason: 'length' })
301
+ else if (status === 'failed') out.push({ type: 'finish', reason: 'error' })
302
+ else out.push({ type: 'finish', reason: 'stop' })
303
+ return out
304
+ },
305
+
306
+ /** 规范事件 → 非流式 body。 */
307
+ encodeJson(events, ctx) {
308
+ const output: JsonObject[] = []
309
+ let status = 'completed'
310
+ let usage = { input: 0, output: 0 }
311
+ for (const ev of events) {
312
+ switch (ev.type) {
313
+ case 'text':
314
+ if (output.length > 0 && output[output.length - 1]?.type === 'message') {
315
+ const c = (output[output.length - 1] as JsonObject).content as JsonObject[]
316
+ if (c.length > 0 && c[c.length - 1]?.type === 'output_text') {
317
+ ;(c[c.length - 1] as JsonObject).text += ev.text
318
+ break
319
+ }
320
+ c.push({ type: 'output_text', text: ev.text })
321
+ break
322
+ }
323
+ output.push({ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: ev.text }] })
324
+ break
325
+ case 'reasoning': {
326
+ const last = output[output.length - 1]
327
+ if (last?.type === 'reasoning') {
328
+ ;((last.summary as JsonObject[])[0] as JsonObject).text += ev.text
329
+ } else {
330
+ output.push({ type: 'reasoning', summary: [{ type: 'summary_text', text: ev.text }] })
331
+ }
332
+ break
333
+ }
334
+ case 'tool-start':
335
+ output.push({ type: 'function_call', id: ev.id ?? `fc_${ev.index}`, call_id: ev.id ?? `call_${ev.index}`, name: ev.name ?? '', arguments: '' })
336
+ break
337
+ case 'tool-args': {
338
+ const last = output[output.length - 1]
339
+ if (last?.type === 'function_call') last.arguments += ev.delta
340
+ break
341
+ }
342
+ case 'finish':
343
+ if (ev.reason === 'length') status = 'incomplete'
344
+ else if (ev.reason === 'error') status = 'failed'
345
+ break
346
+ case 'usage': usage = { input: ev.promptTokens, output: ev.completionTokens }; break
347
+ case 'error': break
348
+ }
349
+ }
350
+ return {
351
+ id: `resp_${Date.now().toString(36)}`,
352
+ object: 'response',
353
+ status,
354
+ model: ctx.model,
355
+ output,
356
+ usage: { input_tokens: usage.input, output_tokens: usage.output, total_tokens: usage.input + usage.output },
357
+ }
358
+ },
359
+ }
360
+
361
+ /* ------------------------------------------------------------------ */
362
+ /* 请求侧实现 */
363
+ /* ------------------------------------------------------------------ */
364
+
365
+ /** Responses 的 input 项 → Chat message。 */
366
+ function inputItem(item: unknown): JsonObject | undefined {
367
+ const it = obj(item)
368
+ if (it === undefined) return undefined
369
+ const type = str(it.type)
370
+ // 消息形态
371
+ if (type === 'message' || type === undefined) {
372
+ const role = str(it.role) ?? 'user'
373
+ const content = it.content
374
+ if (typeof content === 'string') return { role, content }
375
+ const text: string[] = []
376
+ for (const p of arr(content) ?? []) {
377
+ const part = obj(p)
378
+ const t = part === undefined ? undefined : (str(part.text) ?? str(part.output_text))
379
+ if (t !== undefined) text.push(t)
380
+ }
381
+ return { role, content: text.join('\n\n') }
382
+ }
383
+ // function_call 形态(模型上一轮发起的调用)
384
+ if (type === 'function_call') {
385
+ return {
386
+ role: 'assistant',
387
+ content: '',
388
+ tool_calls: [{
389
+ id: str(it.call_id) ?? str(it.id) ?? '',
390
+ type: 'function',
391
+ function: { name: str(it.name) ?? '', arguments: str(it.arguments) ?? '' },
392
+ }],
393
+ }
394
+ }
395
+ // function_call_output 形态(用户回传的工具结果)
396
+ if (type === 'function_call_output') {
397
+ return { role: 'tool', tool_call_id: str(it.call_id) ?? '', content: str(it.output) ?? '' }
398
+ }
399
+ return undefined
400
+ }
401
+
402
+ /** Chat 的 content(字符串或 parts)→ 纯文本。 */
403
+ function contentText(content: unknown): string {
404
+ if (typeof content === 'string') return content
405
+ const parts: string[] = []
406
+ for (const p of arr(content) ?? []) {
407
+ const t = str(obj(p)?.text)
408
+ if (t !== undefined) parts.push(t)
409
+ }
410
+ return parts.join('\n\n')
411
+ }
412
+
413
+ /** Chat tools → Responses tools(扁平形态)。 */
414
+ function convertTools(tools: unknown): JsonObject[] {
415
+ const out: JsonObject[] = []
416
+ for (const t of arr(tools) ?? []) {
417
+ // Responses 的 tools 是扁平的;兼容被中间层包过一层的输入
418
+ const tool = obj(t)
419
+ const fn = tool === undefined ? undefined : (obj(tool.function) ?? tool)
420
+ if (fn === undefined) continue
421
+ const name = str(fn.name)
422
+ if (name === undefined) continue
423
+ out.push({
424
+ type: 'function',
425
+ // Chat 的具名工具是 `{type:'function', function:{...}}`,规范形态就是
426
+ // Chat 形态 —— 所以这里必须**恢复包装**,否则跨协议往返时工具被丢掉
427
+ // (Responses 上游能接受扁平形态,但 Router 只认 Chat 形态)。
428
+ function: {
429
+ name,
430
+ description: str(fn.description) ?? '',
431
+ parameters: obj(fn.parameters) ?? { type: 'object', properties: {} },
432
+ },
433
+ })
434
+ }
435
+ return out
436
+ }
437
+
438
+ /**
439
+ * Chat tool → Responses tool(`fromChat` 方向)。Responses 的 tools 是**扁平**的
440
+ * `{ type:'function', name, description, parameters }`,而 Chat 的具名工具多包了
441
+ * 一层 `function`。不解包的话 `tool.name` 恒为 undefined,工具被静默全部丢掉。
442
+ */
443
+ function chatToolToResponses(t: unknown): JsonObject | undefined {
444
+ const tool = obj(t)
445
+ if (tool === undefined) return undefined
446
+ const fn = obj(tool.function) ?? tool
447
+ const name = str(fn.name)
448
+ if (name === undefined) return undefined
449
+ return {
450
+ type: 'function',
451
+ name,
452
+ description: str(fn.description) ?? '',
453
+ parameters: obj(fn.parameters) ?? { type: 'object', properties: {} },
454
+ }
455
+ }
456
+
457
+ /* ------------------------------------------------------------------ */
458
+ /* 响应侧实现 */
459
+ /* ------------------------------------------------------------------ */
460
+
461
+ /** `response.output_item.added`:函数调用项给出名字。 */
462
+ function outputItemEvents(payload: JsonObject): ChatEvent[] {
463
+ const item = obj(payload.item)
464
+ if (item === undefined || item.type !== 'function_call') return []
465
+ return [{
466
+ type: 'tool-start',
467
+ index: num(payload.output_index) ?? 0,
468
+ ...(str(item.id) === undefined ? {} : { id: str(item.id) }),
469
+ name: str(item.name) ?? '',
470
+ }]
471
+ }
472
+
473
+ /** `response.completed`:收尾 + 用量都在这帧。 */
474
+ function completedEvents(payload: JsonObject): ChatEvent[] {
475
+ const response = obj(payload.response)
476
+ const out: ChatEvent[] = []
477
+ const usage = obj(response?.usage)
478
+ if (usage !== undefined) {
479
+ out.push({
480
+ type: 'usage',
481
+ promptTokens: num(usage.input_tokens) ?? 0,
482
+ completionTokens: num(usage.output_tokens) ?? 0,
483
+ cachedTokens: num(obj(usage.input_tokens_details)?.cached_tokens) ?? 0,
484
+ })
485
+ }
486
+ const status = str(response?.status)
487
+ if (status === 'failed') {
488
+ out.push({ type: 'finish', reason: 'error' })
489
+ } else if (status === 'incomplete') {
490
+ // `incomplete_details.reason` 只可能是 `max_output_tokens` / `content_filter`。
491
+ // 别看 `status` 本身:`incomplete` 不等于截断,content_filter 也是 incomplete。
492
+ out.push({ type: 'finish', reason: finishOf(str(obj(response?.incomplete_details)?.reason)) })
493
+ } else {
494
+ // `status:'completed'` 本身**不带**收尾原因(Responses 没有 finish_reason
495
+ // 字段),真原因是 output 里有没有 function_call:有就是 tool-calls。
496
+ // 读不到 output 时按 stop —— 这是 Responses 语义上的「正常说完」。
497
+ out.push({ type: 'finish', reason: outputHasToolCall(response?.output) ? 'tool-calls' : 'stop' })
498
+ }
499
+ return out
500
+ }
501
+
502
+ /** `response.output` 里有没有 `function_call` 项。 */
503
+ function outputHasToolCall(output: unknown): boolean {
504
+ for (const item of Array.isArray(output) ? output : []) {
505
+ if (obj(item)?.type === 'function_call') return true
506
+ }
507
+ return false
508
+ }
509
+
510
+ /**
511
+ * 拼 `response.completed` 帧(收尾时由调用方带用量与收尾原因调)。
512
+ *
513
+ * `output` 里要写一条 `function_call` 占位项(当 finish 是 tool-calls 时)——
514
+ * Responses 没有 finish_reason 字段,客户端(以及我们自己的 `completedEvents`)
515
+ * 只能靠 `output` 里有没有 function_call 判断「这轮是调工具还是说完」。
516
+ * 少了这条,工具轮在往返后会被认成普通结束。
517
+ */
518
+ export function completedFrame(
519
+ model: string,
520
+ usage?: { promptTokens: number; completionTokens: number; cachedTokens?: number },
521
+ finish?: string,
522
+ ): string {
523
+ const response: JsonObject = {
524
+ id: `resp_${Date.now().toString(36)}`,
525
+ object: 'response',
526
+ // 收尾原因映射到 Responses 的终态:长度截断 → incomplete,其余 completed。
527
+ // 没有 `error` 分支:流内错误走 `error` 事件,不会走到收尾帧。
528
+ status: finish === 'length' ? 'incomplete' : 'completed',
529
+ model,
530
+ output: finish === 'tool-calls'
531
+ ? [{ type: 'function_call', id: `fc_${Date.now().toString(36)}`, call_id: `call_${Date.now().toString(36)}`, name: '', arguments: '' }]
532
+ : [],
533
+ }
534
+ if (finish === 'length') response.incomplete_details = { reason: 'max_output_tokens' }
535
+ if (usage !== undefined) {
536
+ response.usage = {
537
+ input_tokens: usage.promptTokens,
538
+ output_tokens: usage.completionTokens,
539
+ total_tokens: usage.promptTokens + usage.completionTokens,
540
+ ...(usage.cachedTokens !== undefined && usage.cachedTokens > 0
541
+ ? { input_tokens_details: { cached_tokens: usage.cachedTokens } }
542
+ : {}),
543
+ }
544
+ }
545
+ return eventFrame('response.completed', JSON.stringify({ type: 'response.completed', response }))
546
+ }
547
+
548
+ /** Responses 的 usage 帧(若调用方要单独发)。 */
549
+ export function usageFrame(event: Extract<ChatEvent, { type: 'usage' }>): string {
550
+ return dataFrame(JSON.stringify({ type: 'response.completed', response: { usage: event } }))
551
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * SSE 帧的编解码小工具 —— 四种协议共用。
3
+ *
4
+ * 只做三件事:把字节流按 SSE 规则切成「一帧」、把一帧拆成 `event:`/`data:`
5
+ * 两部分、把若干帧拼回字节流。**不**知道任何协议语义 —— 那在各协议的
6
+ * `ProtocolCodec` 里。
7
+ *
8
+ * 为什么不用现成库:宿主没有 SSE 依赖,而我们要的能力只有「分帧」这一件,
9
+ * 引入一个包换三十行代码不值;且分帧必须跨 chunk 边界缓冲(上游一帧被拆在
10
+ * 两个 TCP 段里是常态),现成库普遍假设整帧到达。
11
+ */
12
+
13
+ /** 一帧 SSE。 */
14
+ export interface SseFrame {
15
+ /** `event:` 行的值(没有该行时为空串)。 */
16
+ event: string
17
+ /** `data:` 各行的拼接(多行 `data:` 用 `\n` 连接,SSE 规范如此)。 */
18
+ data: string
19
+ }
20
+
21
+ /**
22
+ * 增量分帧器:喂字节进去,吐出完整帧。
23
+ *
24
+ * 用类而不是纯函数,是因为「半个帧」必须留在实例里 —— 每次调用都是无状态的
25
+ * 函数做不到这件事,而每个流都要一个独立缓冲。
26
+ */
27
+ export class SseSplitter {
28
+ private buf = ''
29
+
30
+ /** 喂入一块字节,返回其中包含的完整帧(可能 0..n 个)。 */
31
+ push(bytes: Uint8Array): SseFrame[] {
32
+ this.buf += new TextDecoder().decode(bytes, { stream: true })
33
+ return this.drain()
34
+ }
35
+
36
+ /** 流结束时冲刷:把残留的、不以空行结尾的尾帧也吐出来(上游不发尾空行是有的)。 */
37
+ end(): SseFrame[] {
38
+ // 收掉 decoder 里可能残留的替换序列
39
+ this.buf += new TextDecoder().decode()
40
+ const out = this.drain()
41
+ if (this.buf.trim() !== '') {
42
+ const frame = parseFrame(this.buf)
43
+ this.buf = ''
44
+ if (frame.data !== '') out.push(frame)
45
+ }
46
+ return out
47
+ }
48
+
49
+ /** 把已缓冲的完整帧全部切出来。 */
50
+ private drain(): SseFrame[] {
51
+ const out: SseFrame[] = []
52
+ // SSE 以空行分帧。容错 `\r\n\r\n` / `\n\r\n` 等变体:统一按行解析后
53
+ // 用「连续空行」判定边界,见 parseFrame 的调用方。
54
+ let sep: number
55
+ while ((sep = this.buf.search(/\r?\n\r?\n/)) >= 0) {
56
+ const match = /\r?\n\r?\n/.exec(this.buf)
57
+ const raw = this.buf.slice(0, sep)
58
+ this.buf = this.buf.slice(sep + (match?.[0].length ?? 2))
59
+ if (raw.trim() !== '') out.push(parseFrame(raw))
60
+ }
61
+ return out
62
+ }
63
+ }
64
+
65
+ /** 把一帧原文拆成 `event` + `data`。空 data 的帧(注释/keep-alive)返回空 data。 */
66
+ export function parseFrame(raw: string): SseFrame {
67
+ let event = ''
68
+ const dataLines: string[] = []
69
+ for (const line of raw.split(/\r?\n/)) {
70
+ if (line === '' || line.startsWith(':')) continue // 空行 / 注释行
71
+ const colon = line.indexOf(':')
72
+ const field = colon < 0 ? line : line.slice(0, colon)
73
+ let value = colon < 0 ? '' : line.slice(colon + 1)
74
+ if (value.startsWith(' ')) value = value.slice(1)
75
+ if (field === 'event') event = value
76
+ else if (field === 'data') dataLines.push(value)
77
+ }
78
+ return { event, data: dataLines.join('\n') }
79
+ }
80
+
81
+ /** 拼一帧字节(`data:` 帧;不带 `event:`)。 */
82
+ export function dataFrame(data: string): string {
83
+ return `data: ${data}\n\n`
84
+ }
85
+
86
+ /** 拼一帧字节(带 `event:`)。 */
87
+ export function eventFrame(event: string, data: string): string {
88
+ return `event: ${event}\ndata: ${data}\n\n`
89
+ }
90
+
91
+ /** 把若干帧原文拼成字节。 */
92
+ export function framesToBytes(frames: string[]): Uint8Array {
93
+ return new TextEncoder().encode(frames.join(''))
94
+ }