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.
- package/README.md +152 -12
- package/docs/ext.md +1 -1
- package/docs/protocols.md +198 -0
- package/docs/suppliers.md +2 -2
- 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 +2764 -63
- package/lib/suppliers/opencode/index.js +58 -30
- 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/account-pool.d.ts +7 -2
- package/lib/types/router/index.d.ts +42 -1
- package/lib/types/router/types.d.ts +26 -3
- package/lib/types/router/usage-store.d.ts +4 -2
- package/lib/types/shared.d.ts +24 -0
- package/lib/types/suppliers/contract.d.ts +15 -1
- 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 +117 -6
- package/src/llm/adapter.test.ts +54 -0
- package/src/llm/adapter.ts +24 -2
- 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/account-pool.test.ts +35 -0
- package/src/router/account-pool.ts +15 -4
- package/src/router/index.ts +143 -24
- package/src/router/types.ts +26 -3
- package/src/router/usage-store.ts +4 -2
- package/src/shared.test.ts +35 -0
- package/src/shared.ts +33 -0
- package/src/suppliers/contract.ts +15 -2
- package/src/suppliers/opencode/plugin.test.ts +79 -25
- 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
|
+
}
|