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,662 @@
1
+ /**
2
+ * Anthropic Messages 协议。
3
+ *
4
+ * ════════════════════════════════════════════════════════════════════
5
+ * 为什么第一个做它
6
+ * ════════════════════════════════════════════════════════════════════
7
+ * Claude Code 只会说这一种协议,而它对 `ANTHROPIC_BASE_URL` 的支持是现成的:
8
+ * 把它指到我们的端点,就**直接**能用上账号池/组合/用量看板。其余三种协议
9
+ * (Responses/Gemini)没有这样的「一个客户端在等」的理由。
10
+ *
11
+ * ════════════════════════════════════════════════════════════════════
12
+ * 翻译表(唯一的事实来源)
13
+ * ════════════════════════════════════════════════════════════════════
14
+ * 请求 system → messages[0] 的 system-role 消息(字符串或块数组都要吃)
15
+ * tools[].input_schema → tools[].function.parameters
16
+ * tool_use / tool_result 块 → assistant.tool_calls / role:"tool"
17
+ * max_tokens / temperature / stop_sequences → 同名(下划线差异两处)
18
+ * 响应 message_start → usage(input_tokens 在这帧)
19
+ * content_block_start(text) → text 增量开始
20
+ * content_block_delta(text_delta) → text 增量
21
+ * content_block_delta(input_json_delta) → tool-args 增量
22
+ * content_block_delta(thinking_delta) → reasoning 增量
23
+ * content_block_stop → 该 index 结束(无对应规范事件,吃掉)
24
+ * message_delta → usage + finish_reason
25
+ * message_stop → 无对应事件(finish 已由 message_delta 给)
26
+ *
27
+ * 丢弃(明确不做翻译,见下):
28
+ * - `cache_control`:Anthropic 的提示缓存标记。跨到 Chat 后失去意义
29
+ * (Chat 的缓存是上游自动的),硬造一个 `prompt_cache_key` 反而是猜。
30
+ * - `thinking.budget_tokens`:只在响应侧保留为 reasoning 增量。
31
+ * - `mcp_servers` / `container` / `context_management`:客户端侧特性,
32
+ * 转发到只懂 Chat 的上游只会得到 400。
33
+ */
34
+ import {
35
+ type ChatEvent,
36
+ type CodecContext,
37
+ type FinishReason,
38
+ type JsonObject,
39
+ type ProtocolCodec,
40
+ arr,
41
+ finishOf,
42
+ num,
43
+ obj,
44
+ str,
45
+ } from './types.ts'
46
+ import { dataFrame, eventFrame } from './sse.ts'
47
+
48
+ /* ------------------------------------------------------------------ */
49
+ /* 请求侧 */
50
+ /* ------------------------------------------------------------------ */
51
+
52
+ export const anthropicCodec: ProtocolCodec = {
53
+ id: 'anthropic',
54
+
55
+ /** Anthropic 请求 → Chat Completions 请求。 */
56
+ toChat(body, ctx) {
57
+ const out: JsonObject = { ...body }
58
+ delete out.system
59
+ delete out.tools
60
+ delete out.max_tokens
61
+ delete out.stop_sequences
62
+
63
+ const messages: unknown[] = []
64
+ const system = body.system
65
+ if (system !== undefined) {
66
+ const text = systemText(system)
67
+ if (text !== '') messages.push({ role: 'system', content: text })
68
+ }
69
+ for (const m of messagePairs(body.messages)) messages.push(m)
70
+
71
+ out.messages = messages
72
+ if (body.max_tokens !== undefined) out.max_tokens = body.max_tokens
73
+ if (body.stop_sequences !== undefined) out.stop = body.stop_sequences
74
+ if (body.temperature !== undefined) out.temperature = body.temperature
75
+ if (body.top_p !== undefined) out.top_p = body.top_p
76
+ const tools = convertTools(body.tools)
77
+ if (tools.length > 0) out.tools = tools
78
+ if (ctx.stream) out.stream = true
79
+ return out
80
+ },
81
+
82
+ /** Chat Completions 请求 → Anthropic 请求(供应商说 Anthropic 时用)。 */
83
+ fromChat(body, ctx) {
84
+ const out: JsonObject = { ...body }
85
+ delete out.messages
86
+ delete out.tools
87
+ delete out.stop
88
+ delete out.stream
89
+
90
+ const systemParts: string[] = []
91
+ const messages: unknown[] = []
92
+ for (const m of body.messages as unknown[]) {
93
+ const msg = obj(m)
94
+ if (msg === undefined) continue
95
+ const role = str(msg.role)
96
+ if (role === 'system') {
97
+ const t = contentText(msg.content)
98
+ if (t !== '') systemParts.push(t)
99
+ continue
100
+ }
101
+ if (role === 'assistant') {
102
+ messages.push(assistantMessage(msg))
103
+ continue
104
+ }
105
+ if (role === 'tool') {
106
+ messages.push({
107
+ role: 'user',
108
+ content: [{
109
+ type: 'tool_result',
110
+ tool_use_id: str(msg.tool_call_id) ?? '',
111
+ content: contentText(msg.content),
112
+ }],
113
+ })
114
+ continue
115
+ }
116
+ messages.push({ role: 'user', content: contentText(msg.content) })
117
+ }
118
+
119
+ if (systemParts.length > 0) out.system = systemParts.join('\n\n')
120
+ out.messages = messages
121
+ // max_tokens 在 Anthropic 侧必填,而 Chat 侧可有可无 —— 缺省给一个值,
122
+ // 否则 Anthropic 上游会直接 400「max_tokens is required」
123
+ out.max_tokens = num(body.max_tokens) ?? 4096
124
+ if (body.stop !== undefined) out.stop_sequences = body.stop
125
+ const tools = (body.tools as unknown[] | undefined)?.map(chatToolToAnthropic).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
+ /* ---------------- 响应侧:Anthropic 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 'message_start':
142
+ return messageStartEvents(payload)
143
+ case 'content_block_start':
144
+ return contentBlockStartEvents(payload)
145
+ case 'content_block_delta':
146
+ return contentDeltaEvents(payload)
147
+ case 'message_delta':
148
+ return messageDeltaEvents(payload)
149
+ case 'error':
150
+ return [{ type: 'error', message: str(obj(payload.error)?.message) ?? 'upstream error' }]
151
+ // content_block_start / content_block_stop / ping / message_stop /
152
+ // message_delta 之外的:没有对应规范事件
153
+ default:
154
+ return []
155
+ }
156
+ },
157
+
158
+ /* ---------------- 响应侧:ChatEvent → Anthropic SSE ---------------- */
159
+
160
+ encodeFrame(event, session) {
161
+ switch (event.type) {
162
+ case 'text':
163
+ return [
164
+ eventFrame(
165
+ 'content_block_delta',
166
+ JSON.stringify({
167
+ type: 'content_block_delta',
168
+ index: 0,
169
+ delta: { type: 'text_delta', text: event.text },
170
+ }),
171
+ ),
172
+ ]
173
+ case 'reasoning':
174
+ return [
175
+ eventFrame(
176
+ 'content_block_delta',
177
+ JSON.stringify({
178
+ type: 'content_block_delta',
179
+ index: 0,
180
+ delta: { type: 'thinking_delta', thinking: event.text },
181
+ }),
182
+ ),
183
+ ]
184
+ case 'tool-start':
185
+ return [
186
+ eventFrame(
187
+ 'content_block_start',
188
+ JSON.stringify({
189
+ type: 'content_block_start',
190
+ index: event.index,
191
+ content_block: {
192
+ type: 'tool_use',
193
+ id: event.id ?? `toolu_${event.index}`,
194
+ name: event.name ?? '',
195
+ input: {},
196
+ },
197
+ }),
198
+ ),
199
+ ]
200
+ case 'tool-args':
201
+ return [
202
+ eventFrame(
203
+ 'content_block_delta',
204
+ JSON.stringify({
205
+ type: 'content_block_delta',
206
+ index: event.index,
207
+ delta: { type: 'input_json_delta', partial_json: event.delta },
208
+ }),
209
+ ),
210
+ ]
211
+ case 'usage':
212
+ // 流式下**不发**:Anthropic 的 message_delta 是「收尾帧」,stop_reason
213
+ // 与 output_tokens 必须同帧给。usage 单独一帧会让流尾出现两个
214
+ // message_delta —— 第一个 stop_reason + `output_tokens: 0`(占位),
215
+ // 第二个才是真数。客户端取第一个就把用量读成 0(2026-10-21 在 3080
216
+ // 实测到)。用量由 `finish` 帧带出,见下一分支。
217
+ //
218
+ // 非流式不受影响:那条路走 `encodeJson`,与这里无关。
219
+ return []
220
+ case 'error':
221
+ return [
222
+ eventFrame(
223
+ 'error',
224
+ JSON.stringify({ type: 'error', error: { type: 'api_error', message: event.message } }),
225
+ ),
226
+ ]
227
+ case 'finish': {
228
+ // 只发 message_delta(stop_reason)。`message_stop` 由 `endFrames` 发 ——
229
+ // 两处都发会在流尾出现两个 message_stop,Anthropic 客户端会把后一个
230
+ // 当成新的一轮开始。
231
+ //
232
+ // **输出用量必须并进这一帧**:Anthropic 的 message_delta 是收尾帧,
233
+ // stop_reason 与 output_tokens 同帧。单独再发一帧 usage 会让流尾出现
234
+ // 两个 message_delta,客户端取第一个就把用量读成 0。
235
+ const tokens = session?.usage?.completionTokens ?? 0
236
+ return [
237
+ eventFrame(
238
+ 'message_delta',
239
+ JSON.stringify({
240
+ type: 'message_delta',
241
+ delta: { stop_reason: finishToAnthropic(event.reason), stop_sequence: null },
242
+ usage: { output_tokens: tokens },
243
+ }),
244
+ ),
245
+ ]
246
+ }
247
+ }
248
+ },
249
+
250
+ startFrames(ctx) {
251
+ return [
252
+ eventFrame(
253
+ 'message_start',
254
+ JSON.stringify({
255
+ type: 'message_start',
256
+ message: {
257
+ id: `msg_${Date.now().toString(36)}`,
258
+ type: 'message',
259
+ role: 'assistant',
260
+ content: [],
261
+ model: ctx.model,
262
+ stop_reason: null,
263
+ stop_sequence: null,
264
+ usage: { input_tokens: 0, output_tokens: 0 },
265
+ },
266
+ }),
267
+ ),
268
+ ]
269
+ },
270
+
271
+ endFrames() {
272
+ return [eventFrame('message_stop', JSON.stringify({ type: 'message_stop' }))]
273
+ },
274
+
275
+ /**
276
+ * 非流式响应体 → 规范事件。
277
+ *
278
+ * Anthropic 非流式 body 形状:`{content:[{type:'text',text}|{type:'tool_use',...}],
279
+ * stop_reason, usage:{input_tokens,output_tokens,cache_read_input_tokens}}`。
280
+ * 与流式共用同一组 block→事件 的映射,见 `blockEvents`。
281
+ */
282
+ decodeJson(body) {
283
+ const out: ChatEvent[] = []
284
+ let index = 0
285
+ let text = ''
286
+ for (const b of arr(body.content) ?? []) {
287
+ const block = obj(b)
288
+ if (block === undefined) continue
289
+ if (block.type === 'text') {
290
+ const t = str(block.text)
291
+ if (t !== undefined) { text += t; index += 1 }
292
+ continue
293
+ }
294
+ if (block.type === 'thinking') {
295
+ const t = str(block.thinking)
296
+ if (t !== undefined) { out.push({ type: 'reasoning', text: t }); index += 1 }
297
+ continue
298
+ }
299
+ if (block.type === 'tool_use') {
300
+ out.push({ type: 'tool-start', index, ...(str(block.id) === undefined ? {} : { id: str(block.id) }), name: str(block.name) ?? '' })
301
+ out.push({ type: 'tool-args', index, delta: JSON.stringify(block.input ?? {}) })
302
+ index += 1
303
+ }
304
+ }
305
+ if (text !== '') out.unshift({ type: 'text', text })
306
+ const usage = obj(body.usage)
307
+ if (usage !== undefined) {
308
+ out.push({
309
+ type: 'usage',
310
+ promptTokens: num(usage.input_tokens) ?? 0,
311
+ completionTokens: num(usage.output_tokens) ?? 0,
312
+ cachedTokens: num(usage.cache_read_input_tokens) ?? 0,
313
+ })
314
+ }
315
+ const stop = body.stop_reason
316
+ if (stop !== undefined && stop !== null) {
317
+ out.push({ type: 'finish', reason: finishOf(stop === 'max_tokens' ? 'length' : stop) })
318
+ }
319
+ return out
320
+ },
321
+
322
+ /** 规范事件 → 非流式 body。 */
323
+ encodeJson(events, ctx) {
324
+ const content: JsonObject[] = []
325
+ let stop = 'end_turn'
326
+ let usage = { input: 0, output: 0, cached: 0 }
327
+ for (const ev of events) {
328
+ switch (ev.type) {
329
+ case 'text':
330
+ // 连续 text 事件合成一个块(非流式没有「增量」概念,拆成多个块
331
+ // 会让客户端渲染出多个段落)
332
+ if (content.length > 0 && content[content.length - 1]?.type === 'text') {
333
+ ;(content[content.length - 1] as JsonObject).text += ev.text
334
+ } else {
335
+ content.push({ type: 'text', text: ev.text })
336
+ }
337
+ break
338
+ case 'reasoning':
339
+ if (content.length > 0 && content[content.length - 1]?.type === 'thinking') {
340
+ ;(content[content.length - 1] as JsonObject).thinking += ev.text
341
+ } else {
342
+ content.push({ type: 'thinking', thinking: ev.text })
343
+ }
344
+ break
345
+ case 'tool-start':
346
+ content.push({ type: 'tool_use', id: ev.id ?? `toolu_${ev.index}`, name: ev.name ?? '', input: {} })
347
+ break
348
+ case 'tool-args': {
349
+ const last = content[content.length - 1]
350
+ if (last?.type === 'tool_use') {
351
+ // 参数增量可能是多段:拼起来再解析(半截 JSON 用空对象兜底)
352
+ const prev = str(last.input) ?? ''
353
+ last.input = parseArgs(prev + ev.delta)
354
+ }
355
+ break
356
+ }
357
+ case 'finish': stop = finishToAnthropic(ev.reason); break
358
+ case 'usage': usage = { input: ev.promptTokens, output: ev.completionTokens, cached: ev.cachedTokens }; break
359
+ case 'error': break
360
+ }
361
+ }
362
+ return {
363
+ id: `msg_${Date.now().toString(36)}`,
364
+ type: 'message',
365
+ role: 'assistant',
366
+ content,
367
+ model: ctx.model,
368
+ stop_reason: stop,
369
+ stop_sequence: null,
370
+ usage: {
371
+ input_tokens: usage.input,
372
+ output_tokens: usage.output,
373
+ ...(usage.cached > 0 ? { cache_read_input_tokens: usage.cached } : {}),
374
+ },
375
+ }
376
+ },
377
+ }
378
+
379
+ /* ------------------------------------------------------------------ */
380
+ /* 请求侧实现 */
381
+ /* ------------------------------------------------------------------ */
382
+
383
+ /** Anthropic 的 system 字段:字符串或 [{type:'text',text}] 数组。 */
384
+ function systemText(system: unknown): string {
385
+ if (typeof system === 'string') return system
386
+ const parts: string[] = []
387
+ for (const b of arr(system) ?? []) {
388
+ const t = str(obj(b)?.text)
389
+ if (t !== undefined) parts.push(t)
390
+ }
391
+ return parts.join('\n\n')
392
+ }
393
+
394
+ /**
395
+ * Anthropic messages → Chat messages。
396
+ *
397
+ * 关键差异:Anthropic 把「模型发起的工具调用」与「用户回传的工具结果」都放在
398
+ * content 块里,而 Chat 把它们拆成 `assistant.tool_calls` 与 `role:'tool'` 消息。
399
+ * 且 Anthropic 允许 tool_result 单独成一条 user 消息,Chat 也允许 —— 但**连续
400
+ * 多条 tool_result 必须各自一条**,合并不了(每个 tool_use_id 要能对上)。
401
+ */
402
+ function messagePairs(messages: unknown): Array<{ role: string; content: unknown }> {
403
+ const out: Array<{ role: string; content: unknown }> = []
404
+ for (const m of arr(messages) ?? []) {
405
+ const msg = obj(m)
406
+ if (msg === undefined) continue
407
+ const role = str(msg.role)
408
+ if (role !== 'user' && role !== 'assistant') continue
409
+ const blocks = arr(msg.content)
410
+ // 纯文本消息:走快路径,别为它造块
411
+ if (blocks === undefined) {
412
+ out.push({ role, content: typeof msg.content === 'string' ? msg.content : '' })
413
+ continue
414
+ }
415
+ const texts: string[] = []
416
+ const toolCalls: JsonObject[] = []
417
+ const toolResults: JsonObject[] = []
418
+ for (const b of blocks) {
419
+ const block = obj(b)
420
+ if (block === undefined) continue
421
+ switch (block.type) {
422
+ case 'text': {
423
+ const t = str(block.text)
424
+ if (t !== undefined) texts.push(t)
425
+ break
426
+ }
427
+ case 'tool_use':
428
+ toolCalls.push({
429
+ id: str(block.id) ?? '',
430
+ type: 'function',
431
+ function: {
432
+ name: str(block.name) ?? '',
433
+ arguments: JSON.stringify(block.input ?? {}),
434
+ },
435
+ })
436
+ break
437
+ case 'tool_result':
438
+ toolResults.push({
439
+ role: 'tool',
440
+ tool_call_id: str(block.tool_use_id) ?? '',
441
+ content: blockContentText(block.content),
442
+ })
443
+ break
444
+ // image / document / thinking:跨协议没有对应物,丢弃
445
+ default:
446
+ break
447
+ }
448
+ }
449
+ // 一条 Anthropic 消息可能同时带文本与工具调用(Chat 也允许:content + tool_calls)
450
+ if (role === 'assistant') {
451
+ const msg2: JsonObject = { role: 'assistant', content: texts.join('\n\n') }
452
+ if (toolCalls.length > 0) msg2.tool_calls = toolCalls
453
+ out.push(msg2 as { role: string; content: unknown })
454
+ } else {
455
+ // user:文本与工具结果都是 user 侧内容。Chat 侧 tool 结果必须是独立消息,
456
+ // 所以拆成「一条带文本的 user」+「每条 tool」。
457
+ if (texts.length > 0) out.push({ role: 'user', content: texts.join('\n\n') })
458
+ for (const tr of toolResults) out.push(tr as { role: string; content: unknown })
459
+ }
460
+ }
461
+ return out
462
+ }
463
+
464
+ /** tool_result 的 content:字符串或 [{type:'text'}] 数组。 */
465
+ function blockContentText(content: unknown): string {
466
+ if (typeof content === 'string') return content
467
+ const parts: string[] = []
468
+ for (const b of arr(content) ?? []) {
469
+ const t = str(obj(b)?.text)
470
+ if (t !== undefined) parts.push(t)
471
+ }
472
+ return parts.join('\n\n')
473
+ }
474
+
475
+ /** Chat 的 content(字符串或 parts 数组)→ 纯文本。 */
476
+ function contentText(content: unknown): string {
477
+ if (typeof content === 'string') return content
478
+ const parts: string[] = []
479
+ for (const p of arr(content) ?? []) {
480
+ const part = obj(p)
481
+ if (part === undefined) continue
482
+ const t = str(part.text) ?? str(part.content)
483
+ if (t !== undefined) parts.push(t)
484
+ }
485
+ return parts.join('\n\n')
486
+ }
487
+
488
+ /** Chat assistant 消息 → Anthropic assistant 消息(tool_calls → tool_use 块)。 */
489
+ function assistantMessage(msg: JsonObject): JsonObject {
490
+ const content: JsonObject[] = []
491
+ const text = contentText(msg.content)
492
+ if (text !== '') content.push({ type: 'text', text })
493
+ for (const tc of arr(msg.tool_calls) ?? []) {
494
+ const call = obj(tc)
495
+ if (call === undefined) continue
496
+ const fn = obj(call.function)
497
+ content.push({
498
+ type: 'tool_use',
499
+ id: str(call.id) ?? '',
500
+ name: str(fn?.name) ?? '',
501
+ input: parseArgs(str(fn?.arguments)),
502
+ })
503
+ }
504
+ return { role: 'assistant', content }
505
+ }
506
+
507
+ /** 解析工具参数字符串;坏 JSON 返回空对象(上游偶发半截参数,别让整条消息失败)。 */
508
+ function parseArgs(s: string | undefined): JsonObject {
509
+ if (s === undefined || s === '') return {}
510
+ try {
511
+ const v: unknown = JSON.parse(s)
512
+ return obj(v) ?? {}
513
+ } catch {
514
+ return {}
515
+ }
516
+ }
517
+
518
+ /**
519
+ * Anthropic tool → Chat tool(`toChat` 方向)。
520
+ *
521
+ * Anthropic 的工具是**扁平**的 `{ name, description, input_schema }`,而 Chat
522
+ * 要多包一层 `function`、并把 `input_schema` 改名为 `parameters`。两件事都要做。
523
+ *
524
+ * ⚠️ 曾经这里读的是 `tool.function.*`(Chat 的形状)—— 方向搞反了,于是从
525
+ * Anthropic 客户端进来的工具**被静默丢光**,Claude Code 接上来后模型会说
526
+ * 「我没有这个工具」(2026-10-21 在 3080 实测)。现按 Anthropic 结构读,
527
+ * 并兼容已经带 `function` 包装的输入(中间层可能提前转过)。
528
+ */
529
+ function convertTools(tools: unknown): JsonObject[] {
530
+ const out: JsonObject[] = []
531
+ for (const t of arr(tools) ?? []) {
532
+ const tool = obj(t)
533
+ if (tool === undefined) continue
534
+ const fn = obj(tool.function) ?? tool
535
+ const name = str(fn.name)
536
+ if (name === undefined) continue
537
+ out.push({
538
+ type: 'function',
539
+ function: {
540
+ name,
541
+ description: str(fn.description) ?? '',
542
+ parameters: obj(fn.input_schema) ?? obj(fn.parameters) ?? { type: 'object', properties: {} },
543
+ },
544
+ })
545
+ }
546
+ return out
547
+ }
548
+
549
+ /**
550
+ * Chat tool → Anthropic tool(`fromChat` 方向,与上面的 `convertTools` 反着走)。
551
+ *
552
+ * Chat 的具名工具是 `{ type:'function', function:{ name, description, parameters } }`
553
+ * —— **多包了一层** `function`。直接读 `tool.name` 会永远 undefined,于是所有
554
+ * 工具被静默丢掉(Claude Code 接上来就看不到任何 tool)。所以这里先解包,
555
+ * 再兼容已经扁平化的输入(有些中间层会提前拍平)。
556
+ */
557
+ function chatToolToAnthropic(t: unknown): JsonObject | undefined {
558
+ const tool = obj(t)
559
+ if (tool === undefined) return undefined
560
+ const fn = obj(tool.function) ?? tool
561
+ const name = str(fn.name)
562
+ if (name === undefined) return undefined
563
+ return {
564
+ name,
565
+ description: str(fn.description) ?? '',
566
+ input_schema: obj(fn.parameters) ?? { type: 'object', properties: {} },
567
+ }
568
+ }
569
+
570
+ /* ------------------------------------------------------------------ */
571
+ /* 响应侧实现 */
572
+ /* ------------------------------------------------------------------ */
573
+
574
+ /** message_start:把 input_tokens 带出来(Anthropic 只在这帧给输入用量)。 */
575
+ function messageStartEvents(payload: JsonObject): ChatEvent[] {
576
+ const usage = obj(obj(payload.message)?.usage)
577
+ const input = num(usage?.input_tokens) ?? 0
578
+ const cached = num(usage?.cache_read_input_tokens) ?? 0
579
+ return [{ type: 'usage', promptTokens: input, completionTokens: 0, cachedTokens: cached }]
580
+ }
581
+
582
+ /**
583
+ * content_block_start:`tool_use` 块的**名字**只在这一帧给,后面的
584
+ * `input_json_delta` 只有参数增量。所以这里必须产出 `tool-start`,否则
585
+ * tool-start 永远丢失,客户端收到一堆没有名字的参数。
586
+ *
587
+ * `text` 块与 `thinking` 块不产出事件:它们的内容全在后续 delta 里,
588
+ * 这里发空文本只会造成一个多余的空增量。
589
+ */
590
+ function contentBlockStartEvents(payload: JsonObject): ChatEvent[] {
591
+ const block = obj(payload.content_block)
592
+ if (block === undefined || block.type !== 'tool_use') return []
593
+ const index = num(payload.index) ?? 0
594
+ const name = str(block.name)
595
+ const id = str(block.id)
596
+ return [{
597
+ type: 'tool-start',
598
+ index,
599
+ ...(id === undefined ? {} : { id }),
600
+ ...(name === undefined ? {} : { name }),
601
+ }]
602
+ }
603
+
604
+ /** content_block_delta:四种 delta 各对应一种规范事件。 */
605
+ function contentDeltaEvents(payload: JsonObject): ChatEvent[] {
606
+ const delta = obj(payload.delta)
607
+ if (delta === undefined) return []
608
+ const index = num(payload.index) ?? 0
609
+ switch (delta.type) {
610
+ case 'text_delta': {
611
+ const text = str(delta.text)
612
+ return text === undefined ? [] : [{ type: 'text', text }]
613
+ }
614
+ case 'thinking_delta':
615
+ case 'signature_delta': {
616
+ const text = str(delta.thinking)
617
+ return text === undefined ? [] : [{ type: 'reasoning', text }]
618
+ }
619
+ case 'input_json_delta': {
620
+ const partial = str(delta.partial_json)
621
+ return partial === undefined || partial === '' ? [] : [{ type: 'tool-args', index, delta: partial }]
622
+ }
623
+ default:
624
+ return []
625
+ }
626
+ }
627
+
628
+ /** message_delta:finish_reason + 输出用量。 */
629
+ function messageDeltaEvents(payload: JsonObject): ChatEvent[] {
630
+ const out: ChatEvent[] = []
631
+ const delta = obj(payload.delta)
632
+ const stop = delta?.stop_reason
633
+ if (stop !== undefined && stop !== null) {
634
+ out.push({ type: 'finish', reason: finishOf(stop === 'max_tokens' ? 'length' : stop) })
635
+ }
636
+ // usage 是可选的:`message_delta` 常只带 stop_reason,紧跟的
637
+ // `message_stop` 才带最终 usage(也可能两帧都没有)。只有真的给了数字才
638
+ // 产事件 —— 否则会在流尾多出一个全 0 的 usage,把前一个真 usage 盖掉。
639
+ const usage = obj(payload.usage)
640
+ if (usage !== undefined) {
641
+ const prompt = num(usage.input_tokens)
642
+ const completion = num(usage.output_tokens)
643
+ if (prompt !== undefined || completion !== undefined) {
644
+ out.push({
645
+ type: 'usage',
646
+ promptTokens: prompt ?? 0,
647
+ completionTokens: completion ?? 0,
648
+ cachedTokens: num(usage.cache_read_input_tokens) ?? 0,
649
+ })
650
+ }
651
+ }
652
+ return out
653
+ }
654
+
655
+ function finishToAnthropic(reason: FinishReason): string {
656
+ switch (reason) {
657
+ case 'length': return 'max_tokens'
658
+ case 'tool-calls': return 'tool_use'
659
+ case 'content-filter': return 'refusal'
660
+ default: return 'end_turn'
661
+ }
662
+ }