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,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
|
+
}
|