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,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 流的协议转码 —— 把「一种协议的 SSE 字节流」翻译成「另一种协议的 SSE 字节流」。
|
|
3
|
+
*
|
|
4
|
+
* ════════════════════════════════════════════════════════════════════
|
|
5
|
+
* 为什么拆成 transform + state 两层
|
|
6
|
+
* ════════════════════════════════════════════════════════════════════
|
|
7
|
+
* 逐帧翻译需要一个**有状态**的累加器(usage 分散在多帧、工具名要先于参数、
|
|
8
|
+
* 收尾帧要在流末补),直接写成一个 200 行的 async generator 会把「状态」和
|
|
9
|
+
* 「翻译」搅在一起。所以:
|
|
10
|
+
* - `StreamState`:一次流的有状态累加(纯内存,可单测,无 IO)。
|
|
11
|
+
* - `translateStream`:把它接到字节流上(只管分帧/拼帧/生命周期)。
|
|
12
|
+
*
|
|
13
|
+
* 同一条流里**同时只有一个转码器**(上游协议固定),但出站协议由客户端决定 ——
|
|
14
|
+
* 所以是「一个 decode + 一个 encode」,而不是各自独立的两趟。
|
|
15
|
+
*
|
|
16
|
+
* ⚠️ **不缓冲、不聚合**:每个事件到立刻吐对应帧。首字节延迟与上游一致,
|
|
17
|
+
* 不会因为转码多等一个 chunk(Gemini 的工具调用除外:它的参数是一次性整块,
|
|
18
|
+
* 天然没有增量可言)。
|
|
19
|
+
*/
|
|
20
|
+
import type { ChatEvent, CodecSession, ProtocolId } from './types.ts'
|
|
21
|
+
import { codecOf } from './index.ts'
|
|
22
|
+
import { SseSplitter, framesToBytes } from './sse.ts'
|
|
23
|
+
|
|
24
|
+
/** 一次流式转码的有状态累加。 */
|
|
25
|
+
export class StreamState {
|
|
26
|
+
/** 上游协议(解码用)。 */
|
|
27
|
+
readonly from: ProtocolId
|
|
28
|
+
/** 客户端协议(编码用)。 */
|
|
29
|
+
readonly to: ProtocolId
|
|
30
|
+
/** 上游 usage(跨帧合并,字段级 max —— 见 mergeUsage 的注释)。 */
|
|
31
|
+
private usage: { promptTokens: number; completionTokens: number; cachedTokens: number } = {
|
|
32
|
+
promptTokens: 0,
|
|
33
|
+
completionTokens: 0,
|
|
34
|
+
cachedTokens: 0,
|
|
35
|
+
}
|
|
36
|
+
/** 已经吐过起始帧没有(起始帧只能在流最前发一次)。 */
|
|
37
|
+
private started = false
|
|
38
|
+
/** 收尾原因(用于补 endFrames;缺省 stop)。 */
|
|
39
|
+
private finish: Extract<ChatEvent, { type: 'finish' }> | undefined
|
|
40
|
+
/** 模型名(回显进 startFrames/endFrames,如 Anthropic 的 message_start)。 */
|
|
41
|
+
private model = ''
|
|
42
|
+
/**
|
|
43
|
+
* 解码侧会话与编码侧会话,各一份。
|
|
44
|
+
*
|
|
45
|
+
* **必须每帧复用同一个**:Gemini 的工具名↔index 映射就存在这里;每帧新建
|
|
46
|
+
* 会让 `tool-args` 找不到 `tool-start` 给的名字(参数被丢),而放模块级会
|
|
47
|
+
* 跨请求串名。会话在 `StreamState` 构造时建、随流结束丢弃 —— 生命周期与
|
|
48
|
+
* 「一条流」严格对齐。
|
|
49
|
+
*/
|
|
50
|
+
private readonly decodeSession: CodecSession | undefined
|
|
51
|
+
private readonly encodeSession: CodecSession | undefined
|
|
52
|
+
|
|
53
|
+
constructor(from: ProtocolId, to: ProtocolId, model = '') {
|
|
54
|
+
this.from = from
|
|
55
|
+
this.to = to
|
|
56
|
+
this.model = model
|
|
57
|
+
this.decodeSession = codecOf(from).createSession?.()
|
|
58
|
+
this.encodeSession = codecOf(to).createSession?.()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 当前累计的用量。 */
|
|
62
|
+
currentUsage(): { promptTokens: number; completionTokens: number; cachedTokens: number } {
|
|
63
|
+
return { ...this.usage }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 把上游一帧原文(`data:` 载荷)翻成出站帧原文列表。 */
|
|
67
|
+
translateFrame(frame: string, event?: string): string[] {
|
|
68
|
+
const events = codecOf(this.from).decodeFrame(frame, event, this.decodeSession)
|
|
69
|
+
const out: string[] = []
|
|
70
|
+
if (!this.started) {
|
|
71
|
+
this.started = true
|
|
72
|
+
out.push(...codecOf(this.to).startFrames({ model: this.model, stream: true }))
|
|
73
|
+
}
|
|
74
|
+
for (const ev of events) {
|
|
75
|
+
if (ev.type === 'usage') this.mergeUsage(ev)
|
|
76
|
+
if (ev.type === 'finish') this.finish = ev
|
|
77
|
+
// finish / usage 在出站侧的帧要等流末一起发(见 finish()),
|
|
78
|
+
// 因为它们的位置在协议里是固定的(Anthropic 的 message_delta 在
|
|
79
|
+
// message_stop 前、Responses 的 completed 是最后一件)。
|
|
80
|
+
if (ev.type === 'finish' || ev.type === 'usage') continue
|
|
81
|
+
out.push(...codecOf(this.to).encodeFrame(ev, this.encodeSession))
|
|
82
|
+
}
|
|
83
|
+
return out
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 上游流结束:补发收尾帧。
|
|
88
|
+
*
|
|
89
|
+
* **顺序固定**:finish → usage → endFrames。各协议的收尾帧都要求这个次序
|
|
90
|
+
* (Anthropic 的 message_delta 必须在 message_stop 前,Responses 的
|
|
91
|
+
* `response.completed` 是最后一件且自带 usage)。
|
|
92
|
+
*
|
|
93
|
+
* `endFrames` 拿得到 finish 与 usage —— 有的协议(Responses)只有收尾帧
|
|
94
|
+
* 里才有地方放 usage 与「是否被截断」。
|
|
95
|
+
*/
|
|
96
|
+
finishStream(): string[] {
|
|
97
|
+
const enc = codecOf(this.to)
|
|
98
|
+
const ctx = { model: this.model, stream: true, finish: this.finish?.reason ?? 'stop', usage: { ...this.usage } }
|
|
99
|
+
if (!this.started) {
|
|
100
|
+
// 上游一个字节都没给:也要给客户端一个**完整形状**的流。只发
|
|
101
|
+
// startFrames 是不够的 —— Anthropic 客户端等 message_stop、Codex 等
|
|
102
|
+
// response.completed,缺收尾帧它们会报「流被截断」而不是「上游没答」。
|
|
103
|
+
this.started = true
|
|
104
|
+
return [...enc.startFrames(ctx), ...enc.endFrames(ctx, this.encodeSession)]
|
|
105
|
+
}
|
|
106
|
+
// finish 帧要拿到**已经累计好的**用量:有的协议(Anthropic)把 stop_reason
|
|
107
|
+
// 与 output_tokens 绑在同一帧里,而 finish 事件本身不带 usage。所以要派生
|
|
108
|
+
// 一个带 usage 的会话给它,不能复用构造时那份(那份的 usage 是空的)。
|
|
109
|
+
const finishSession = this.finishSessionWithUsage()
|
|
110
|
+
const out: string[] = []
|
|
111
|
+
out.push(...enc.encodeFrame(this.finish ?? { type: 'finish', reason: 'stop' }, finishSession))
|
|
112
|
+
out.push(...enc.encodeFrame({ type: 'usage', ...this.usage }, finishSession))
|
|
113
|
+
out.push(...enc.endFrames(ctx, finishSession))
|
|
114
|
+
return out
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 带当前用量派生的编码会话。
|
|
119
|
+
*
|
|
120
|
+
* 不能只在 `encodeSession` 存在时才派生:`createSession` 是**可选**的
|
|
121
|
+
* (只有 Gemini 这类有跨帧映射的协议实现它),Anthropic 没有 —— 按
|
|
122
|
+
* `encodeSession === undefined` 就返回 undefined 会让 usage 永远送不进去
|
|
123
|
+
* (2026-10-21 实测:output_tokens 恒为 0)。所以无论如何都造一个,
|
|
124
|
+
* 有 state 就带上。
|
|
125
|
+
*/
|
|
126
|
+
private finishSessionWithUsage(): CodecSession {
|
|
127
|
+
return { ...this.encodeSession, usage: { ...this.usage } }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* usage 合并:字段级 max。
|
|
132
|
+
*
|
|
133
|
+
* 与 `router/usage-tokens.ts` 的 `mergeUsage` 同规则(那里照 9router):
|
|
134
|
+
* Claude 把输入用量放 `message_start`、输出放 `message_delta`,两帧都给
|
|
135
|
+
* `usage` 字段,**覆盖**会让后一帧把前面的输入清零。
|
|
136
|
+
*/
|
|
137
|
+
private mergeUsage(ev: Extract<ChatEvent, { type: 'usage' }>): void {
|
|
138
|
+
this.usage.promptTokens = Math.max(this.usage.promptTokens, ev.promptTokens)
|
|
139
|
+
this.usage.completionTokens = Math.max(this.usage.completionTokens, ev.completionTokens)
|
|
140
|
+
this.usage.cachedTokens = Math.max(this.usage.cachedTokens, ev.cachedTokens)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 把上游 SSE 字节流转成客户端协议的 SSE 字节流。
|
|
146
|
+
*
|
|
147
|
+
* 生命周期:上游正常读完 / 上游 error / 消费方取消,三条路径都要**释放上游
|
|
148
|
+
* reader** 并把取消传下去 —— 否则上游 fetch body 永不 cancel,长跑会漏连接
|
|
149
|
+
* (`router/index.ts` 的 `normalizeSSEStream` 里同一个坑,判据在
|
|
150
|
+
* `router/disconnect-leak.test.ts`)。
|
|
151
|
+
*/
|
|
152
|
+
export function translateStream(
|
|
153
|
+
upstream: ReadableStream<Uint8Array>,
|
|
154
|
+
state: StreamState,
|
|
155
|
+
): ReadableStream<Uint8Array> {
|
|
156
|
+
const splitter = new SseSplitter()
|
|
157
|
+
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined
|
|
158
|
+
let released = false
|
|
159
|
+
const release = (): void => {
|
|
160
|
+
if (released) return
|
|
161
|
+
released = true
|
|
162
|
+
try { reader?.releaseLock() } catch { /* 有待定 read 时会抛,忽略 */ }
|
|
163
|
+
}
|
|
164
|
+
return new ReadableStream<Uint8Array>({
|
|
165
|
+
async start(ctrl) {
|
|
166
|
+
reader = upstream.getReader()
|
|
167
|
+
try {
|
|
168
|
+
for (;;) {
|
|
169
|
+
const { done, value } = await reader.read()
|
|
170
|
+
if (done) break
|
|
171
|
+
const frames = splitter.push(value)
|
|
172
|
+
for (const f of frames) {
|
|
173
|
+
if (f.data === '[DONE]' || f.data === '') continue
|
|
174
|
+
const out = state.translateFrame(f.data, f.event)
|
|
175
|
+
if (out.length > 0) ctrl.enqueue(framesToBytes(out))
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// 尾帧(上游不发结束空行时 SseSplitter.end() 才吐得出)
|
|
179
|
+
for (const f of splitter.end()) {
|
|
180
|
+
if (f.data === '[DONE]' || f.data === '') continue
|
|
181
|
+
const out = state.translateFrame(f.data, f.event)
|
|
182
|
+
if (out.length > 0) ctrl.enqueue(framesToBytes(out))
|
|
183
|
+
}
|
|
184
|
+
const tail = state.finishStream()
|
|
185
|
+
if (tail.length > 0) ctrl.enqueue(framesToBytes(tail))
|
|
186
|
+
} catch {
|
|
187
|
+
// 上游断流/消费方取消:已吐出的内容留着(HTTP 语义,写出去收不回)
|
|
188
|
+
} finally {
|
|
189
|
+
release()
|
|
190
|
+
}
|
|
191
|
+
try { ctrl.close() } catch { /* 已取消 */ }
|
|
192
|
+
},
|
|
193
|
+
cancel: (reason) => reader?.cancel(reason),
|
|
194
|
+
})
|
|
195
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 协议矩阵的**规范形态**(canonical form)与编解码契约。
|
|
3
|
+
*
|
|
4
|
+
* ════════════════════════════════════════════════════════════════════
|
|
5
|
+
* 为什么内部只留一种形态
|
|
6
|
+
* ════════════════════════════════════════════════════════════════════
|
|
7
|
+
* 四种协议两两互转是 4×3 = 12 条路径,逐条实现必然长出一打互相不一致的
|
|
8
|
+
* 实现。收敛成「全部先转成同一个规范形态、再从规范形态转出去」之后:
|
|
9
|
+
*
|
|
10
|
+
* 外来请求 ──decode──┐ ┌──encode── 外来响应
|
|
11
|
+
* ├─ 规范形态 ────┤
|
|
12
|
+
* 供应商请求 ◀─encode─┘ └─decode──▶ 供应商响应
|
|
13
|
+
*
|
|
14
|
+
* 只需要 N 个协议模块(N=4),而不是 N² 条翻译路径;新增第五种协议只是再加
|
|
15
|
+
* 一个模块,已有路径一行不改。
|
|
16
|
+
*
|
|
17
|
+
* 规范形态选 **OpenAI Chat Completions**(请求体)与**一组扁平事件**(响应流),
|
|
18
|
+
* 不是随便挑的:供应商契约(`suppliers/contract.ts` 的 `chatOnce`)今天就是
|
|
19
|
+
* 「吃 Chat Completions 请求体、吐 OpenAI SSE」,选别的形态等于把每个已装
|
|
20
|
+
* 供应商插件都改一遍。
|
|
21
|
+
*
|
|
22
|
+
* 天花板:规范形态只覆盖**能表达的部分**。各家私有的、没有对应概念的东西
|
|
23
|
+
* (Anthropic 的 `cache_control`、Responses 的 `previous_response_id`、
|
|
24
|
+
* Gemini 的 `safetySettings`)在跨协议时会被**丢掉**,不是被翻译。每条丢弃
|
|
25
|
+
* 在对应模块的注释里点名。
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** 四种协议。`chat` 是规范形态本身,因此它的编解码是恒等变换。 */
|
|
29
|
+
export type ProtocolId = 'chat' | 'anthropic' | 'responses' | 'gemini'
|
|
30
|
+
|
|
31
|
+
/** 所有协议 id(顺序 = 文档与面板展示顺序)。 */
|
|
32
|
+
export const PROTOCOL_IDS: readonly ProtocolId[] = ['chat', 'anthropic', 'responses', 'gemini']
|
|
33
|
+
|
|
34
|
+
/** 宽松 JSON 对象(外来请求体一律不可信,逐字段校验)。 */
|
|
35
|
+
export type JsonObject = Record<string, unknown>
|
|
36
|
+
|
|
37
|
+
/** 编解码时需要知道的上下文。 */
|
|
38
|
+
export interface CodecContext {
|
|
39
|
+
/**
|
|
40
|
+
* 请求里声明的模型名(已剥掉本插件的 `alias/` 前缀)。
|
|
41
|
+
*
|
|
42
|
+
* 有些协议把模型名放在 URL 路径里(Gemini 的
|
|
43
|
+
* `/v1beta/models/<model>:generateContent`),有些放在请求体里;模块统一从
|
|
44
|
+
* 这里取,不各自去猜。
|
|
45
|
+
*/
|
|
46
|
+
model: string
|
|
47
|
+
/** 客户端是否要流式。 */
|
|
48
|
+
stream: boolean
|
|
49
|
+
/**
|
|
50
|
+
* 收尾原因(仅 `endFrames` 会读)。
|
|
51
|
+
*
|
|
52
|
+
* 有些协议的收尾帧要**同时**带状态与用量(Responses 的 `response.completed`
|
|
53
|
+
* 里 `status: 'completed' | 'incomplete'` 决定 Codex 是否认为被截断),
|
|
54
|
+
* 而 `finish` 事件本身在这些协议里没有独立帧可发 —— 于是原因必须通过
|
|
55
|
+
* 上下文传进 `endFrames`,不能只靠「收到过 finish 事件」这个事实。
|
|
56
|
+
*/
|
|
57
|
+
finish?: FinishReason
|
|
58
|
+
/** 累计用量(仅 `endFrames` 会读,同上:Responses 只在收尾帧给 usage)。 */
|
|
59
|
+
usage?: { promptTokens: number; completionTokens: number; cachedTokens: number }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 规范形态的**流式事件**。
|
|
64
|
+
*
|
|
65
|
+
* 刻意做得很小:只要四家能无损落进来的都在这,落不进来的都不在。工具调用用
|
|
66
|
+
* `tool-start` + `tool-args` 两段表达(各家都是「先给名字、再给参数增量」,
|
|
67
|
+
* 只有 Gemini 把参数整块给一次 —— 它在 decode 时拆成一段即可)。
|
|
68
|
+
*/
|
|
69
|
+
export type ChatEvent =
|
|
70
|
+
/** 可见文本增量。 */
|
|
71
|
+
| { type: 'text'; text: string }
|
|
72
|
+
/** 推理过程增量(`reasoning_content` / `thinking` / `thought`)。 */
|
|
73
|
+
| { type: 'reasoning'; text: string }
|
|
74
|
+
/** 一个工具调用的开始(名字先到,参数随后)。同一 index 只发一次。 */
|
|
75
|
+
| { type: 'tool-start'; index: number; id?: string; name?: string }
|
|
76
|
+
/** 工具调用的参数**增量**(JSON 片段,逐段拼接后才是完整参数)。 */
|
|
77
|
+
| { type: 'tool-args'; index: number; delta: string }
|
|
78
|
+
/** 收尾原因。一条流最多一个,且必须是最后一个内容事件。 */
|
|
79
|
+
| { type: 'finish'; reason: FinishReason }
|
|
80
|
+
/** 用量。多数上游在最后一帧才给。 */
|
|
81
|
+
| { type: 'usage'; promptTokens: number; completionTokens: number; cachedTokens: number }
|
|
82
|
+
/** 上游在流内报的错(HTTP 200 + 流内 error 是真实存在的形态)。 */
|
|
83
|
+
| { type: 'error'; message: string }
|
|
84
|
+
|
|
85
|
+
/** 收尾原因(规范形态)。 */
|
|
86
|
+
export type FinishReason = 'stop' | 'length' | 'tool-calls' | 'content-filter' | 'error'
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 一条流里的编解码会话状态。
|
|
90
|
+
*
|
|
91
|
+
* 刻意做成**不透明的空接口 + 可选**:绝大多数协议(chat / Anthropic /
|
|
92
|
+
* Responses)逐帧无状态,不需要它;只有 Gemini 这类「用名字而非 index 指代
|
|
93
|
+
* 工具」的协议需要跨帧记住名字↔index。让它可选,是为了不给无状态协议强加
|
|
94
|
+
* 一个空对象参数。
|
|
95
|
+
*/
|
|
96
|
+
export interface CodecSession {
|
|
97
|
+
/** 会话内的临时槽位(各协议自己的形状,核心不解读)。 */
|
|
98
|
+
readonly state?: unknown
|
|
99
|
+
/**
|
|
100
|
+
* 本流已累计的用量。
|
|
101
|
+
*
|
|
102
|
+
* 为什么编码器需要它:有的协议把「收尾原因」与「输出用量」绑在**同一帧**
|
|
103
|
+
* (Anthropic 的 `message_delta`),而 finish 事件本身不带 usage。不给这个
|
|
104
|
+
* 槽位,收尾帧只能写 `output_tokens: 0` 或者再多发一帧 —— 后者会让客户端
|
|
105
|
+
* 取到第一个(占位)值,把用量读成 0(2026-10-21 在 3080 实测到)。
|
|
106
|
+
*/
|
|
107
|
+
readonly usage?: { promptTokens: number; completionTokens: number; cachedTokens: number }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 一个协议的编解码器。
|
|
112
|
+
*
|
|
113
|
+
* 四个方法分成两组,两组都必须实现 —— 少了任何一组就只能单向,而「双向互转」
|
|
114
|
+
* 正是这个模块存在的理由:
|
|
115
|
+
* - `toChat*`:**入站**用。客户端说这个协议,我们得听懂它、并用它回答。
|
|
116
|
+
* - `fromChat*`:**出站**用。供应商说这个协议,我们得用它的语言请求、并把
|
|
117
|
+
* 它的回答翻回规范形态。
|
|
118
|
+
*/
|
|
119
|
+
export interface ProtocolCodec {
|
|
120
|
+
readonly id: ProtocolId
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 一条流的编解码会话。
|
|
124
|
+
*
|
|
125
|
+
* 部分协议是**有状态**的:Gemini 的 `functionCall` 靠工具名认,而规范形态
|
|
126
|
+
* 靠 index,跨帧必须有映射;映射若放模块级就会串请求(并发两条流各自的
|
|
127
|
+
* `ls` 抢同一个 index,或上一条流的残留映射漏到下一条)。
|
|
128
|
+
*
|
|
129
|
+
* 所以状态由调用方在**请求边界**建一次、在整条流里传给每一帧:
|
|
130
|
+
* - `translateStream` / `StreamState` 在一次转码里建一个;
|
|
131
|
+
* - 单帧转换(测试、非流式一次性)可以不传,此时每帧各建一份 —— 结果
|
|
132
|
+
* 等价于无状态,代价只有一次 Map 分配。
|
|
133
|
+
*/
|
|
134
|
+
createSession?(): CodecSession
|
|
135
|
+
|
|
136
|
+
/** 外来请求体 → 规范请求体。`chat` 是恒等。 */
|
|
137
|
+
toChat(body: JsonObject, ctx: CodecContext): JsonObject
|
|
138
|
+
|
|
139
|
+
/** 规范请求体 → 外来请求体。`chat` 是恒等。 */
|
|
140
|
+
fromChat(body: JsonObject, ctx: CodecContext): JsonObject
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 外来响应流的**单帧**解码:一帧 SSE 载荷(已剥掉 `data:` 前缀)→ 0..n 个
|
|
144
|
+
* 规范事件。`[DONE]` 与无法识别的帧返回空数组,**不抛**:一条坏帧不该让
|
|
145
|
+
* 整轮响应作废(上游发半截 JSON 是常态,见 `llm/adapter.ts` 的同类处理)。
|
|
146
|
+
*
|
|
147
|
+
* @param frame SSE 的 `data:` 载荷原文;`[DONE]` 由调用方过滤,不会传进来。
|
|
148
|
+
* @param event 该帧的 `event:` 名(Anthropic 用它区分事件类型;其余协议为空)。
|
|
149
|
+
*/
|
|
150
|
+
decodeFrame(frame: string, event?: string, session?: CodecSession): ChatEvent[]
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 规范事件 → 0..n 个外来 SSE 帧。
|
|
154
|
+
*
|
|
155
|
+
* 返回的是**完整帧**(含 `event:`/`data:` 行与结尾空行),调用方只管拼字节。
|
|
156
|
+
* 做成整帧而不是载荷,是因为 Anthropic 这类协议每个事件都要带 `event:` 行,
|
|
157
|
+
* 让编码器自己拼比让调用方猜事件名干净。
|
|
158
|
+
*/
|
|
159
|
+
encodeFrame(event: ChatEvent, session?: CodecSession): string[]
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* 非流式响应体的解码:整个 JSON body → 规范事件列表。
|
|
163
|
+
*
|
|
164
|
+
* 与 `decodeFrame` 同构(同样返回事件列表),区别只在输入是**一个完整的
|
|
165
|
+
* 响应对象**而不是一帧增量。两种都必须实现:客户端可能发 `stream:false`,
|
|
166
|
+
* 供应商也可能只给 JSON(有些上游对短请求直接回非流式)。
|
|
167
|
+
*/
|
|
168
|
+
decodeJson(body: JsonObject): ChatEvent[]
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 规范事件 → 非流式响应体。
|
|
172
|
+
*
|
|
173
|
+
* 与 `encodeFrame` 同构:把攒起来的事件折成一个完整响应对象。
|
|
174
|
+
* `events` 里最多一个 `finish`、一个 `usage`,其余是内容。
|
|
175
|
+
*/
|
|
176
|
+
encodeJson(events: ChatEvent[], ctx: CodecContext): JsonObject
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 流开始时的一次性帧(Anthropic 的 `message_start`、Responses 的
|
|
180
|
+
* `response.created`)。恒等协议返回空数组。
|
|
181
|
+
*/
|
|
182
|
+
startFrames(ctx: CodecContext): string[]
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* 流结束时的一次性帧(`message_stop`、`response.completed`、`[DONE]`)。
|
|
186
|
+
* 与 `startFrames` 对称;`finish` 事件本身走 `encodeFrame`。
|
|
187
|
+
*
|
|
188
|
+
* 带上 `session` 是因为有的协议**必须**在流末才发得出东西:Gemini 的
|
|
189
|
+
* `functionCall` 是「名字+完整参数」一个 part,而上游逐帧发参数增量,
|
|
190
|
+
* 只能攒到流末(见 gemini.ts 的 endFrames)。
|
|
191
|
+
*/
|
|
192
|
+
endFrames(ctx: CodecContext, session?: CodecSession): string[]
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** 是否为已知协议 id。 */
|
|
196
|
+
export function isProtocolId(v: unknown): v is ProtocolId {
|
|
197
|
+
return typeof v === 'string' && (PROTOCOL_IDS as readonly string[]).includes(v)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/* ------------------------------------------------------------------ */
|
|
201
|
+
/* 小工具:外来 JSON 一律不可信,取字段全部走这里 */
|
|
202
|
+
/* ------------------------------------------------------------------ */
|
|
203
|
+
|
|
204
|
+
/** 取对象字段(不是对象则 undefined)。 */
|
|
205
|
+
export function obj(v: unknown): JsonObject | undefined {
|
|
206
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as JsonObject) : undefined
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** 取数组字段(不是数组则 undefined)。 */
|
|
210
|
+
export function arr(v: unknown): unknown[] | undefined {
|
|
211
|
+
return Array.isArray(v) ? v : undefined
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** 取非空字符串字段。 */
|
|
215
|
+
export function str(v: unknown): string | undefined {
|
|
216
|
+
return typeof v === 'string' && v !== '' ? v : undefined
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** 取有限数字字段。 */
|
|
220
|
+
export function num(v: unknown): number | undefined {
|
|
221
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : undefined
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** 取布尔字段。 */
|
|
225
|
+
export function bool(v: unknown): boolean | undefined {
|
|
226
|
+
return typeof v === 'boolean' ? v : undefined
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** 把 `finish` 原因在各协议间对齐(各家词汇不同,这里是唯一的对照表)。 */
|
|
230
|
+
export function finishOf(v: unknown): FinishReason {
|
|
231
|
+
switch (v) {
|
|
232
|
+
case 'length':
|
|
233
|
+
case 'max_tokens':
|
|
234
|
+
case 'MAX_TOKENS':
|
|
235
|
+
return 'length'
|
|
236
|
+
case 'tool_calls':
|
|
237
|
+
case 'tool_use':
|
|
238
|
+
case 'function_call':
|
|
239
|
+
return 'tool-calls'
|
|
240
|
+
case 'content_filter':
|
|
241
|
+
case 'SAFETY':
|
|
242
|
+
case 'safety':
|
|
243
|
+
return 'content-filter'
|
|
244
|
+
case 'error':
|
|
245
|
+
return 'error'
|
|
246
|
+
default:
|
|
247
|
+
return 'stop'
|
|
248
|
+
}
|
|
249
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 四协议出口的**端到端**测试 —— 从 `outboundSink` 到真实字节。
|
|
3
|
+
*
|
|
4
|
+
* `protocol.test.ts` 守的是编解码器两两往返(纯函数、无 IO);这里守的是接线:
|
|
5
|
+
* Router 只会写 OpenAI 形态,客户端说的是别的协议,中间那层中继必须把
|
|
6
|
+
* **状态行、content-type、内容、收尾**四件事同时搬对。
|
|
7
|
+
*
|
|
8
|
+
* 每一条都对应一个真实会坏的点:
|
|
9
|
+
* - 流式:客户端逐帧解析,缺 `message_start` / `message_stop` 就是「流格式错误」
|
|
10
|
+
* - 非流式:方向反了会返回 200 + 空内容(静默错,最难发现)
|
|
11
|
+
* - 工具调用:名字只在上游第一帧给,后续增量只带参数 —— 名字丢了等于工具没了
|
|
12
|
+
* - 空流:上游刚连上就断,要给协议合法的空流而不是裸断连接
|
|
13
|
+
*/
|
|
14
|
+
import { test } from 'node:test'
|
|
15
|
+
import assert from 'node:assert/strict'
|
|
16
|
+
import { outboundSink } from './entry.ts'
|
|
17
|
+
import type { ProtocolId } from './types.ts'
|
|
18
|
+
|
|
19
|
+
const enc = new TextEncoder()
|
|
20
|
+
|
|
21
|
+
/** 假的 ServerResponse:收集状态行/头/body,够 sink 用。 */
|
|
22
|
+
function captureRes(): {
|
|
23
|
+
res: Record<string, unknown>
|
|
24
|
+
status: () => number
|
|
25
|
+
headers: () => Record<string, string>
|
|
26
|
+
body: () => string
|
|
27
|
+
} {
|
|
28
|
+
const chunks: string[] = []
|
|
29
|
+
let status = 0
|
|
30
|
+
let headers: Record<string, string> = {}
|
|
31
|
+
const text = (b: unknown): string =>
|
|
32
|
+
typeof b === 'string' ? b : b instanceof Uint8Array ? new TextDecoder().decode(b) : String(b)
|
|
33
|
+
const self: Record<string, unknown> = {
|
|
34
|
+
writeHead(code: number, h?: Record<string, string>) {
|
|
35
|
+
status = code
|
|
36
|
+
headers = h ?? {}
|
|
37
|
+
return self
|
|
38
|
+
},
|
|
39
|
+
flushHeaders(): void {},
|
|
40
|
+
write(b: unknown): boolean {
|
|
41
|
+
chunks.push(text(b))
|
|
42
|
+
return true
|
|
43
|
+
},
|
|
44
|
+
end(b?: unknown): void {
|
|
45
|
+
if (b !== undefined) chunks.push(text(b))
|
|
46
|
+
},
|
|
47
|
+
on() { return self },
|
|
48
|
+
once() { return self },
|
|
49
|
+
removeListener(): void {},
|
|
50
|
+
destroy(): void {},
|
|
51
|
+
}
|
|
52
|
+
return { res: self, status: () => status, headers: () => headers, body: () => chunks.join('') }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 按 SSE 规则切帧(`event:` + `data:`)。 */
|
|
56
|
+
function sseFrames(body: string): Array<{ event: string; data: string }> {
|
|
57
|
+
return body
|
|
58
|
+
.split(/\r?\n\r?\n/)
|
|
59
|
+
.filter((raw) => raw.trim() !== '')
|
|
60
|
+
.map((raw) => {
|
|
61
|
+
let event = ''
|
|
62
|
+
const dataLines: string[] = []
|
|
63
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
64
|
+
if (line.startsWith('event:')) event = line.slice(6).trim()
|
|
65
|
+
else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim())
|
|
66
|
+
}
|
|
67
|
+
return { event, data: dataLines.join('\n') }
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 喂一段 OpenAI SSE 给 sink,返回客户端看到的 body。 */
|
|
72
|
+
function runStream(protocol: ProtocolId, upstream: string): string {
|
|
73
|
+
const cap = captureRes()
|
|
74
|
+
const sink = outboundSink(protocol, 'test-model', cap.res as never)
|
|
75
|
+
sink.head?.(200, { 'content-type': 'text/event-stream' })
|
|
76
|
+
// 故意切成两段:验证跨 chunk 的分帧缓冲
|
|
77
|
+
const bytes = enc.encode(upstream)
|
|
78
|
+
const cut = Math.floor(bytes.length / 2)
|
|
79
|
+
sink.write(bytes.slice(0, cut))
|
|
80
|
+
sink.write(bytes.slice(cut))
|
|
81
|
+
sink.end()
|
|
82
|
+
return cap.body()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const UPSTREAM_TEXT =
|
|
86
|
+
'data: {"choices":[{"delta":{"content":"你好"}}]}\n\n' +
|
|
87
|
+
'data: {"choices":[{"delta":{"content":"世界"}}]}\n\n' +
|
|
88
|
+
'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n' +
|
|
89
|
+
'data: [DONE]\n\n'
|
|
90
|
+
|
|
91
|
+
test('端到端 · Anthropic:流式帧序列完整(message_start -> text_delta -> stop_reason -> message_stop)', () => {
|
|
92
|
+
const frames = sseFrames(runStream('anthropic', UPSTREAM_TEXT))
|
|
93
|
+
const names = frames.map((f) => f.event)
|
|
94
|
+
|
|
95
|
+
assert.equal(names[0], 'message_start', '第一帧必须是 message_start')
|
|
96
|
+
assert.equal(names.at(-1), 'message_stop', '最后一帧必须是 message_stop')
|
|
97
|
+
assert.equal(names.filter((n) => n === 'message_stop').length, 1, 'message_stop 只能有一个')
|
|
98
|
+
|
|
99
|
+
const text = frames
|
|
100
|
+
.map((f) => { try { return JSON.parse(f.data) as Record<string, unknown> } catch { return undefined } })
|
|
101
|
+
.filter((o): o is Record<string, unknown> => o !== undefined)
|
|
102
|
+
.map((o) => {
|
|
103
|
+
const d = o.delta as { type?: string; text?: string } | undefined
|
|
104
|
+
return d?.type === 'text_delta' ? (d.text ?? '') : ''
|
|
105
|
+
})
|
|
106
|
+
.join('')
|
|
107
|
+
assert.equal(text, '你好世界', '文本增量被吞了或者顺序乱了')
|
|
108
|
+
|
|
109
|
+
const stop = frames
|
|
110
|
+
.map((f) => { try { return JSON.parse(f.data) as { delta?: { stop_reason?: string } } } catch { return {} } })
|
|
111
|
+
.find((o) => o.delta?.stop_reason !== undefined)
|
|
112
|
+
assert.equal(stop?.delta?.stop_reason, 'end_turn', 'stop_reason 没落到 text 块上')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('端到端 · Anthropic:工具调用跨帧保留名字(Claude Code 靠它调工具)', () => {
|
|
116
|
+
// 上游:名字在第一帧,参数分两段增量
|
|
117
|
+
const upstream =
|
|
118
|
+
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","type":"function","function":{"name":"Read","arguments":""}}]}}]}\n\n' +
|
|
119
|
+
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"file_path\\":"}}]}}]}\n\n' +
|
|
120
|
+
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\"/tmp/a\\"}"}}]}}]}\n\n' +
|
|
121
|
+
'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}\n\n' +
|
|
122
|
+
'data: [DONE]\n\n'
|
|
123
|
+
const frames = sseFrames(runStream('anthropic', upstream))
|
|
124
|
+
const objs = frames
|
|
125
|
+
.map((f) => { try { return JSON.parse(f.data) as Record<string, unknown> } catch { return undefined } })
|
|
126
|
+
.filter((o): o is Record<string, unknown> => o !== undefined)
|
|
127
|
+
|
|
128
|
+
const start = objs.find((o) => o.type === 'content_block_start')
|
|
129
|
+
assert.ok(start !== undefined, '缺 content_block_start(工具起点)')
|
|
130
|
+
const block = start.content_block as { type?: string; name?: string; id?: string }
|
|
131
|
+
assert.equal(block.type, 'tool_use')
|
|
132
|
+
assert.equal(block.name, 'Read', '工具名丢了 —— 客户端会收到一个没名字的工具')
|
|
133
|
+
assert.equal(block.id, 'c1')
|
|
134
|
+
|
|
135
|
+
const partial = objs
|
|
136
|
+
.map((o) => o.delta as { type?: string; partial_json?: string } | undefined)
|
|
137
|
+
.filter((d): d is { type: 'input_json_delta'; partial_json: string } => d?.type === 'input_json_delta')
|
|
138
|
+
.map((d) => d.partial_json)
|
|
139
|
+
.join('')
|
|
140
|
+
assert.deepEqual(JSON.parse(partial), { file_path: '/tmp/a' })
|
|
141
|
+
|
|
142
|
+
const stop = objs.find((o) => (o.delta as { stop_reason?: string } | undefined)?.stop_reason !== undefined)
|
|
143
|
+
assert.equal((stop?.delta as { stop_reason?: string }).stop_reason, 'tool_use', '工具轮的 stop_reason 必须是 tool_use')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('端到端 · Responses:流有 response.created 与带 status/usage 的 response.completed', () => {
|
|
147
|
+
const upstream =
|
|
148
|
+
'data: {"choices":[{"delta":{"content":"hi"}}],"usage":{"prompt_tokens":9,"completion_tokens":4}}\n\n' +
|
|
149
|
+
'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n' +
|
|
150
|
+
'data: [DONE]\n\n'
|
|
151
|
+
const frames = sseFrames(runStream('responses', upstream))
|
|
152
|
+
const names = frames.map((f) => f.event)
|
|
153
|
+
assert.equal(names[0], 'response.created', '缺 response.created')
|
|
154
|
+
assert.equal(names.at(-1), 'response.completed', '缺 response.completed(Codex 靠它判结束)')
|
|
155
|
+
|
|
156
|
+
const done = JSON.parse(frames.at(-1)!.data) as { response: { status?: string; usage?: Record<string, number> } }
|
|
157
|
+
assert.equal(done.response.status, 'completed')
|
|
158
|
+
assert.equal(done.response.usage?.input_tokens, 9, '输入用量丢了')
|
|
159
|
+
assert.equal(done.response.usage?.output_tokens, 4, '输出用量丢了')
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test('端到端 · Gemini:流是裸 data: 帧,finishReason 与 usageMetadata 都在', () => {
|
|
163
|
+
const upstream =
|
|
164
|
+
'data: {"choices":[{"delta":{"content":"hi"}}],"usage":{"prompt_tokens":6,"completion_tokens":2}}\n\n' +
|
|
165
|
+
'data: {"choices":[{"delta":{},"finish_reason":"length"}]}\n\n' +
|
|
166
|
+
'data: [DONE]\n\n'
|
|
167
|
+
const frames = sseFrames(runStream('gemini', upstream))
|
|
168
|
+
assert.ok(frames.every((f) => f.event === ''), 'Gemini 不该有 event: 行')
|
|
169
|
+
const body = frames.map((f) => f.data).join('\n')
|
|
170
|
+
assert.ok(body.includes('"text":"hi"'), '文本丢了')
|
|
171
|
+
assert.ok(body.includes('"finishReason":"MAX_TOKENS"'), 'length -> MAX_TOKENS 没翻对')
|
|
172
|
+
assert.ok(body.includes('"promptTokenCount":6'), '用量丢了')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test('端到端 · chat 客户端拿到原生 OpenAI SSE(含 [DONE])', () => {
|
|
176
|
+
const body = runStream('chat', UPSTREAM_TEXT)
|
|
177
|
+
assert.ok(body.includes('"content":"你好"'))
|
|
178
|
+
assert.ok(body.includes('data: [DONE]'), '缺 [DONE] 终帧')
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('端到端 · 空流:每种协议都给出形状合法的收尾,而不是裸断', () => {
|
|
182
|
+
for (const protocol of ['anthropic', 'responses', 'gemini', 'chat'] as ProtocolId[]) {
|
|
183
|
+
const cap = captureRes()
|
|
184
|
+
const sink = outboundSink(protocol, 'test-model', cap.res as never)
|
|
185
|
+
sink.head?.(200, { 'content-type': 'text/event-stream' })
|
|
186
|
+
sink.end()
|
|
187
|
+
const body = cap.body()
|
|
188
|
+
assert.equal(cap.status(), 200, `${protocol}: 状态行该是 200`)
|
|
189
|
+
assert.notEqual(body, '', `${protocol}: 空流不能一个字节都不给(客户端会当成网络故障重试)`)
|
|
190
|
+
if (protocol === 'anthropic') {
|
|
191
|
+
assert.ok(body.includes('message_start') && body.includes('message_stop'), 'anthropic 空流形状不完整')
|
|
192
|
+
}
|
|
193
|
+
if (protocol === 'responses') {
|
|
194
|
+
assert.ok(body.includes('response.completed'), 'responses 空流缺收尾')
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test('端到端 · 上游 503:错误形状按客户端协议给(不是 OpenAI 的 error)', () => {
|
|
200
|
+
const cap = captureRes()
|
|
201
|
+
const sink = outboundSink('anthropic', 'test-model', cap.res as never)
|
|
202
|
+
sink.head?.(503, { 'content-type': 'application/json' })
|
|
203
|
+
sink.write(enc.encode(JSON.stringify({ error: { message: 'all models unavailable', type: 'api_error' } })))
|
|
204
|
+
sink.end()
|
|
205
|
+
const body = cap.body()
|
|
206
|
+
assert.equal(cap.status(), 503, '状态码要透传')
|
|
207
|
+
assert.ok(body.includes('"type":"error"'), `Anthropic 客户端要的是 {type:'error'} 形状,实际:${body}`)
|
|
208
|
+
})
|