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,440 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 入站协议的四个入口 —— 把四种协议的请求翻成规范请求、交给 Router,
|
|
3
|
+
* 再把 Router 的响应翻回客户端说的协议。
|
|
4
|
+
*
|
|
5
|
+
* ════════════════════════════════════════════════════════════════════
|
|
6
|
+
* 为什么 Router 不认识协议,翻译却全在这
|
|
7
|
+
* ════════════════════════════════════════════════════════════════════
|
|
8
|
+
* Router 的核心职责是「选号/冷却/回退/记账」,它不该知道世界上有几种协议。
|
|
9
|
+
* 所以这里只做三件与协议有关的事:认路径、翻请求体、翻响应体。剩下全部
|
|
10
|
+
* 委托给 `Router.chatCompletions` —— 于是四种协议**自动**获得账号池、
|
|
11
|
+
* 组合回退、首字节预算、断流补帧、用量统计,一个都不用重写。
|
|
12
|
+
*
|
|
13
|
+
* 挂载点见 `src/index.ts` 的 `route(...)` 调用处;新增第五种协议只改
|
|
14
|
+
* 这里 + `protocol/` 下加一个模块,`src/index.ts` 只多一行 route。
|
|
15
|
+
*/
|
|
16
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
17
|
+
import { codecOf } from './index.ts'
|
|
18
|
+
import { SseSplitter } from './sse.ts'
|
|
19
|
+
import { StreamState } from './stream.ts'
|
|
20
|
+
import type { ChatEvent, JsonObject, ProtocolId } from './types.ts'
|
|
21
|
+
import { str } from './types.ts'
|
|
22
|
+
|
|
23
|
+
/** 一次入站请求解析后的结果。 */
|
|
24
|
+
export interface InboundRequest {
|
|
25
|
+
/** 规范请求体(Chat Completions 形状)。 */
|
|
26
|
+
body: JsonObject
|
|
27
|
+
/** 客户端要的流式。 */
|
|
28
|
+
stream: boolean
|
|
29
|
+
/** 模型名(已从路径或请求体取出)。 */
|
|
30
|
+
model: string
|
|
31
|
+
/** 推理等级;缺省 'auto'。 */
|
|
32
|
+
lv: string
|
|
33
|
+
/** 客户端协议。 */
|
|
34
|
+
protocol: ProtocolId
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 读请求体 + 按协议翻成规范请求。
|
|
39
|
+
*
|
|
40
|
+
* 模型名从哪来因协议而异:Chat/Anthropic/Responses 在 body 的 `model`,
|
|
41
|
+
* Gemini 在 URL 路径(`:generateContent` 之前那段)—— 由 `modelFromPath`
|
|
42
|
+
* 调用方先解析好传进来。
|
|
43
|
+
*/
|
|
44
|
+
export async function parseInbound(
|
|
45
|
+
req: IncomingMessage,
|
|
46
|
+
protocol: ProtocolId,
|
|
47
|
+
opts: {
|
|
48
|
+
limitBytes: number
|
|
49
|
+
modelFromPath?: string
|
|
50
|
+
lvFromBody?: (body: JsonObject) => string
|
|
51
|
+
/**
|
|
52
|
+
* 路径里已声明的流式意图。
|
|
53
|
+
*
|
|
54
|
+
* Gemini 是唯一把「流不流式」放在 **URL 路径**(`:streamGenerateContent`
|
|
55
|
+
* vs `:generateContent`)而不是 body 里的协议。不认它,Gemini 的流式请求
|
|
56
|
+
* 会被当成非流式处理 —— 客户端拿到一整个 JSON,而它按 SSE 解析 ⇒ 空响应
|
|
57
|
+
* (2026-10-21 在 3080 实测)。
|
|
58
|
+
*
|
|
59
|
+
* `undefined` = 路径没说,看 body;`true/false` = 路径已明确,body 不算数。
|
|
60
|
+
*/
|
|
61
|
+
streamFromPath?: boolean
|
|
62
|
+
},
|
|
63
|
+
): Promise<InboundRequest | { error: string; status: number }> {
|
|
64
|
+
const raw = await readBody(req, opts.limitBytes)
|
|
65
|
+
if (raw === undefined) return { error: 'request body too large', status: 413 }
|
|
66
|
+
let body: JsonObject
|
|
67
|
+
try {
|
|
68
|
+
body = JSON.parse(raw) as JsonObject
|
|
69
|
+
} catch {
|
|
70
|
+
return { error: 'invalid JSON body', status: 400 }
|
|
71
|
+
}
|
|
72
|
+
const codec = codecOf(protocol)
|
|
73
|
+
// 路径里的模型名优先:Gemini 只在路径里给,body 里没有 model 字段
|
|
74
|
+
const model = opts.modelFromPath ?? str(body.model) ?? ''
|
|
75
|
+
const stream = opts.streamFromPath ?? (body.stream === true)
|
|
76
|
+
const canonical = codec.toChat(body, { model, stream })
|
|
77
|
+
// 流式标志由**入口**说了算(路径 > body),协议模块不擅自改
|
|
78
|
+
canonical.stream = stream
|
|
79
|
+
canonical.model = model
|
|
80
|
+
const lv = opts.lvFromBody?.(body) ?? 'auto'
|
|
81
|
+
return { body: canonical, stream, model, lv, protocol }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 一段上游字节像不像 SSE。
|
|
86
|
+
*
|
|
87
|
+
* 只在 content-type **没有**任何提示时才用(流式路径的 `head` 可能一次都不来)。
|
|
88
|
+
* 判据是「第一段非空白内容是否 SSE 行」,不是「包含 data:」—— 后者会让一个
|
|
89
|
+
* 错误 JSON(`{"error":"data: bad"}`)被误判成流。
|
|
90
|
+
*/
|
|
91
|
+
function looksLikeSse(text: string): boolean {
|
|
92
|
+
const head = text.replace(/^\s+/, '')
|
|
93
|
+
return head.startsWith('data:') || head.startsWith('event:') || head.startsWith(':')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 上游字节 → 文本(逐字节流式解码,跨 chunk 的多字节字符不会被打断)。 */
|
|
97
|
+
const decoder = new TextDecoder()
|
|
98
|
+
|
|
99
|
+
/** 文本 → 字节。 */
|
|
100
|
+
const encoder = new TextEncoder()
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 给 Router 的**非 chat 出口**:收集 OpenAI 形态的响应,翻译成客户端协议。
|
|
104
|
+
*
|
|
105
|
+
* ════════════════════════════════════════════════════════════════════
|
|
106
|
+
* 两条路径分开处理,因为时序完全不同
|
|
107
|
+
* ════════════════════════════════════════════════════════════════════
|
|
108
|
+
* 非流式:Router 一次 `head(200) + end(json)`。整份 JSON 已在手里,
|
|
109
|
+
* `decodeJson` → `encodeJson` 一个来回就完。
|
|
110
|
+
* 流式: Router 分很多次 `write(bytes)`,且**第一个字节前可能失败换号**。
|
|
111
|
+
* 不能等收齐再翻(那就没有流式了);每收到一段就翻一段。
|
|
112
|
+
*
|
|
113
|
+
* 流式那条为什么插一层 sink 而不是让 Router 直接写翻译后的字节:Router 的
|
|
114
|
+
* `writeChatResult` 里有一整套「客户端断开就取消上游流」「断流补 `[DONE]`」
|
|
115
|
+
* 的逻辑,那些必须继续对真实的 `res` 生效。sink 只是**转发的中继**:
|
|
116
|
+
* 它翻译字节、写给真正的 res;断开时调用方取消本层(见 index.ts 挂载处),
|
|
117
|
+
* Router 那侧的取消链路原样传到上游。
|
|
118
|
+
*/
|
|
119
|
+
export function outboundSink(protocol: ProtocolId, model: string, res: ServerResponse): ResponseSinkLike {
|
|
120
|
+
const codec = codecOf(protocol)
|
|
121
|
+
let headSent = false
|
|
122
|
+
/** 流式:转码器(收到第一段 SSE 时初始化,跨 write 保持分帧状态)。 */
|
|
123
|
+
let translator: ChunkTranslator | undefined
|
|
124
|
+
/** 非流式:攒起来的 JSON(`undefined` = 还没收到过 body)。 */
|
|
125
|
+
let jsonBuf: string | undefined
|
|
126
|
+
let status = 200
|
|
127
|
+
/**
|
|
128
|
+
* 上游给的 content-type —— **判断流还是 JSON 只看它**,不看内容。
|
|
129
|
+
*
|
|
130
|
+
* 早先靠 `text.startsWith('data:')` 猜:那是错的。非流式 JSON body 里如果
|
|
131
|
+
* 有以 `data:` 开头的字符串值(错误信息里带 "data: ...")、或者上游先发一段
|
|
132
|
+
* 空白,就会被误判成 SSE,然后 JSON 被当帧喂给翻译器 —— 客户端拿到一个
|
|
133
|
+
* 空流而不是错误信息。content-type 是 Router/上游**明确给出的**事实。
|
|
134
|
+
*/
|
|
135
|
+
let contentType = ''
|
|
136
|
+
|
|
137
|
+
const sendHead = (code: number, contentType: string): void => {
|
|
138
|
+
if (headSent) return
|
|
139
|
+
headSent = true
|
|
140
|
+
res.writeHead(code, { 'Content-Type': contentType, 'Cache-Control': 'no-store' })
|
|
141
|
+
if (typeof (res as { flushHeaders?: () => void }).flushHeaders === 'function') {
|
|
142
|
+
;(res as { flushHeaders: () => void }).flushHeaders()
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
head(code, headers) {
|
|
148
|
+
status = code
|
|
149
|
+
const ct = headers['content-type'] ?? headers['Content-Type'] ?? ''
|
|
150
|
+
if (ct !== '') contentType = ct.toLowerCase()
|
|
151
|
+
// 错误响应(503/400)的形状是协议无关的 OpenAI error,而每种协议的
|
|
152
|
+
// error 形状不同。只把 Content-Type 记住,真正的形状翻译在 end() 里做
|
|
153
|
+
// (那时才知道是流内 error 还是 HTTP 错误)。
|
|
154
|
+
if (code !== 200) {
|
|
155
|
+
// 立刻提交:错误没有 body 可等(Router 的错误路径是 head + end 一起)
|
|
156
|
+
sendHead(code, ct !== '' ? ct : 'application/json; charset=utf-8')
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
write(bytes) {
|
|
160
|
+
const text = decoder.decode(bytes, { stream: true })
|
|
161
|
+
if (text === '') return
|
|
162
|
+
// 判定顺序:
|
|
163
|
+
// 1. content-type 说是 SSE → 流
|
|
164
|
+
// 2. content-type 说是 JSON → 攒着
|
|
165
|
+
// 3. 两者都没说(流式路径 head 可能一次都不来)→ 看首字节
|
|
166
|
+
if (translator === undefined && (contentType.includes('text/event-stream') || (contentType === '' && looksLikeSse(text)))) {
|
|
167
|
+
translator = new ChunkTranslator(new StreamState('chat', protocol, model))
|
|
168
|
+
}
|
|
169
|
+
if (translator !== undefined) {
|
|
170
|
+
const frames = translator.push(text)
|
|
171
|
+
// **即使这一批没有可发的帧,也要先把响应头提交掉**(带一个零长度
|
|
172
|
+
// 的写入会让 Router 立刻认为「已写第一个字节」)。
|
|
173
|
+
//
|
|
174
|
+
// 为什么:Gemini 的 functionCall 攒到流末才发,纯工具调用轮次在收尾前
|
|
175
|
+
// **一个字节都没有**。Router 看到 wroteAny=false 就判这次失败、
|
|
176
|
+
// 换号重试 —— 于是整条请求在上游成功的情况下被反复重放,客户端侧表现
|
|
177
|
+
// 为挂死(2026-10-22 实测)。响应头一旦提交,Router 就知道这条腿成了。
|
|
178
|
+
if (frames.length > 0) {
|
|
179
|
+
sendHead(200, 'text/event-stream')
|
|
180
|
+
res.write(encoder.encode(frames.join('')))
|
|
181
|
+
} else if (!headSent) {
|
|
182
|
+
sendHead(200, 'text/event-stream')
|
|
183
|
+
}
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
jsonBuf = (jsonBuf ?? '') + text
|
|
187
|
+
},
|
|
188
|
+
end() {
|
|
189
|
+
const tailChunk = decoder.decode()
|
|
190
|
+
if (tailChunk !== '') {
|
|
191
|
+
if (translator !== undefined) {
|
|
192
|
+
const frames = translator.push(tailChunk)
|
|
193
|
+
if (frames.length > 0) res.write(encoder.encode(frames.join('')))
|
|
194
|
+
} else {
|
|
195
|
+
jsonBuf = (jsonBuf ?? '') + tailChunk
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (jsonBuf !== undefined && jsonBuf !== '') {
|
|
199
|
+
finishJson(codec, protocol, model, res, sendHead, status, jsonBuf)
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
if (translator !== undefined) {
|
|
203
|
+
// 流式收尾:finish + usage + endFrames
|
|
204
|
+
const tail = translator.end()
|
|
205
|
+
if (tail.length > 0) {
|
|
206
|
+
sendHead(200, 'text/event-stream')
|
|
207
|
+
res.write(encoder.encode(tail.join('')))
|
|
208
|
+
}
|
|
209
|
+
// 判据用「尾巴有没有内容」而不是 headSent:纯工具调用的流在收尾前
|
|
210
|
+
// **一个字节都没写过**(Gemini 的 functionCall 攒到流末才发),此时
|
|
211
|
+
// headSent 仍是 false —— 照旧条件会走 finishEmptyStream,把刚写出去的
|
|
212
|
+
// 真实工具调用又用一个「upstream closed」的错误流盖掉,而且那两个
|
|
213
|
+
// writeHead 会打架(2026-10-22 实测:Gemini 流式工具调用整体挂死)。
|
|
214
|
+
if (headSent) res.end()
|
|
215
|
+
else finishEmptyStream(codec, protocol, model, res, sendHead)
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
finishEmptyStream(codec, protocol, model, res, sendHead)
|
|
219
|
+
},
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Router 的 ResponseSink 形状(本地声明,避免 protocol → router 的循环依赖)。 */
|
|
224
|
+
export interface ResponseSinkLike {
|
|
225
|
+
head?: (status: number, headers: Record<string, string>) => void
|
|
226
|
+
write: (bytes: Uint8Array) => void
|
|
227
|
+
end: () => void
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/* ------------------------------------------------------------------ */
|
|
231
|
+
/* 收尾的三种情形 */
|
|
232
|
+
/* ------------------------------------------------------------------ */
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* 非流式:整份 JSON 翻译后写出。
|
|
236
|
+
*
|
|
237
|
+
* ⚠️ 方向在这里:Router 吐的是 **OpenAI Chat** 形态(`choices[0].message`),
|
|
238
|
+
* 而客户端要的是 `protocol` 说的形态。所以**解码用 chat codec、编码用客户端
|
|
239
|
+
* codec** —— 曾经两处都用客户端的 codec,于是 Chat JSON 被当成 Anthropic
|
|
240
|
+
* body 解(`choices` 字段根本不存在),客户端拿到一个 content 为空的回复。
|
|
241
|
+
*/
|
|
242
|
+
function finishJson(
|
|
243
|
+
clientCodec: ReturnType<typeof codecOf>,
|
|
244
|
+
protocol: ProtocolId,
|
|
245
|
+
model: string,
|
|
246
|
+
res: ServerResponse,
|
|
247
|
+
sendHead: (code: number, contentType: string) => void,
|
|
248
|
+
status: number,
|
|
249
|
+
jsonBuf: string,
|
|
250
|
+
): void {
|
|
251
|
+
let events: ChatEvent[]
|
|
252
|
+
try {
|
|
253
|
+
events = codecOf('chat').decodeJson(JSON.parse(jsonBuf) as JsonObject)
|
|
254
|
+
} catch {
|
|
255
|
+
// 上游给的不是 JSON(罕见的纯文本错误)→ 原样透传,别把它翻成 200
|
|
256
|
+
sendHead(status, 'application/json; charset=utf-8')
|
|
257
|
+
res.end(jsonBuf)
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
const err = events.find((e) => e.type === 'error')
|
|
261
|
+
if (err !== undefined && err.type === 'error') {
|
|
262
|
+
sendHead(502, 'application/json; charset=utf-8')
|
|
263
|
+
res.end(JSON.stringify(errBody(protocol, err.message)))
|
|
264
|
+
return
|
|
265
|
+
}
|
|
266
|
+
const body = clientCodec.encodeJson(events, { model, stream: false })
|
|
267
|
+
sendHead(status, 'application/json; charset=utf-8')
|
|
268
|
+
res.end(JSON.stringify(body))
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* 流式但一个内容帧都没翻译出来(上游刚连上就断 / 首字节超时前就失败)。
|
|
273
|
+
*
|
|
274
|
+
* 给客户端一个**协议合法的空流**,别让 HTTP 连接裸断:客户端会把「没有响应」
|
|
275
|
+
* 当成网络故障重试,而「上游没答」才是事实——用协议的 error 收尾说出来。
|
|
276
|
+
*/
|
|
277
|
+
function finishEmptyStream(
|
|
278
|
+
codec: ReturnType<typeof codecOf>,
|
|
279
|
+
protocol: ProtocolId,
|
|
280
|
+
model: string,
|
|
281
|
+
res: ServerResponse,
|
|
282
|
+
sendHead: (code: number, contentType: string) => void,
|
|
283
|
+
): void {
|
|
284
|
+
const ctx = { model, stream: true, finish: 'error' as const }
|
|
285
|
+
const frames = [
|
|
286
|
+
...codec.startFrames(ctx),
|
|
287
|
+
...codec.encodeFrame({ type: 'error', message: 'upstream closed before any content' }),
|
|
288
|
+
...codec.encodeFrame({ type: 'finish', reason: 'error' }),
|
|
289
|
+
...codec.endFrames(ctx),
|
|
290
|
+
]
|
|
291
|
+
sendHead(200, 'text/event-stream')
|
|
292
|
+
res.end(new TextEncoder().encode(frames.join('')))
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** 每种协议的 error body 形状。 */
|
|
296
|
+
function errBody(protocol: ProtocolId, message: string): unknown {
|
|
297
|
+
if (protocol === 'anthropic') {
|
|
298
|
+
return { type: 'error', error: { type: 'api_error', message } }
|
|
299
|
+
}
|
|
300
|
+
if (protocol === 'gemini') {
|
|
301
|
+
return { error: { code: 502, message, status: 'UNAVAILABLE' } }
|
|
302
|
+
}
|
|
303
|
+
return { error: { message, type: 'api_error', code: 'upstream_error' } }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/* ------------------------------------------------------------------ */
|
|
307
|
+
/* 分帧(字符串版) */
|
|
308
|
+
/* ------------------------------------------------------------------ */
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* 一段上游 OpenAI SSE 文本的转码器 —— **有状态**。
|
|
312
|
+
*
|
|
313
|
+
* 上游一帧被拆在两个 TCP 段里是常态(Router 按拿到的字节原样转交),所以
|
|
314
|
+
* 分帧缓冲必须跨 `write()` 调用存活。这里用 `sse.ts` 的 `SseSplitter`(它内部
|
|
315
|
+
* 留着半个帧)而不是每次现切 —— 早先每次现切会把不完整帧当完整帧解析,
|
|
316
|
+
* 表现是「文本丢一半、工具参数截断」,而 HTTP 200,客户端看不到任何错误。
|
|
317
|
+
*
|
|
318
|
+
* 生命周期与 sink 一致:一个请求一个实例。
|
|
319
|
+
*/
|
|
320
|
+
export class ChunkTranslator {
|
|
321
|
+
private readonly splitter = new SseSplitter()
|
|
322
|
+
private readonly state: StreamState
|
|
323
|
+
|
|
324
|
+
constructor(state: StreamState) {
|
|
325
|
+
this.state = state
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** 喂一段文本,返回可写给客户端的帧。 */
|
|
329
|
+
push(text: string): string[] {
|
|
330
|
+
return this.emit(this.splitter.push(new TextEncoder().encode(text)))
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** 流结束:冲刷残留帧(上游不发尾空行是有的),返回收尾帧。 */
|
|
334
|
+
end(): string[] {
|
|
335
|
+
return [...this.emit(this.splitter.end()), ...this.state.finishStream()]
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private emit(frames: Array<{ event: string; data: string }>): string[] {
|
|
339
|
+
const out: string[] = []
|
|
340
|
+
for (const f of frames) {
|
|
341
|
+
if (f.data === '' || f.data === '[DONE]') continue
|
|
342
|
+
out.push(...this.state.translateFrame(f.data, f.event))
|
|
343
|
+
}
|
|
344
|
+
return out
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* 单次调用版:把一段**完整**的 SSE 文本转成客户端帧。
|
|
350
|
+
*
|
|
351
|
+
* 只适合「手里已经有一整段完整数据」的场合(测试、非流式上游误发了 SSE)。
|
|
352
|
+
* 真正的流式路径必须用 `ChunkTranslator` 保持跨段状态。
|
|
353
|
+
*/
|
|
354
|
+
export function translateChunk(state: StreamState, text: string): string[] {
|
|
355
|
+
const t = new ChunkTranslator(state)
|
|
356
|
+
return [...t.push(text), ...t.end()]
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/* ------------------------------------------------------------------ */
|
|
360
|
+
/* 小工具 */
|
|
361
|
+
/* ------------------------------------------------------------------ */
|
|
362
|
+
|
|
363
|
+
/** 读请求体(带大小上限)。超限返回 undefined。 */
|
|
364
|
+
function readBody(req: IncomingMessage, limit: number): Promise<string | undefined> {
|
|
365
|
+
return new Promise((resolve) => {
|
|
366
|
+
const chunks: Buffer[] = []
|
|
367
|
+
let size = 0
|
|
368
|
+
req.on('data', (chunk: Buffer) => {
|
|
369
|
+
size += chunk.length
|
|
370
|
+
if (size > limit) {
|
|
371
|
+
req.destroy()
|
|
372
|
+
resolve(undefined)
|
|
373
|
+
return
|
|
374
|
+
}
|
|
375
|
+
chunks.push(chunk)
|
|
376
|
+
})
|
|
377
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
378
|
+
req.on('error', () => resolve(undefined))
|
|
379
|
+
})
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* 从 Gemini 的 URL 路径里取模型名。
|
|
384
|
+
*
|
|
385
|
+
* 形如 `/v1beta/models/<model>:generateContent`(或 `:streamGenerateContent`),
|
|
386
|
+
* `<model>` 本身可以含斜杠,所以取 `models/` 之后到最后一个 `:` 之前那段。
|
|
387
|
+
*
|
|
388
|
+
* **必须先切掉 query**:Gemini CLI 默认带 `?alt=sse`,不切的话 `$` 锚点匹配
|
|
389
|
+
* 不上,模型名变空 —— 表现为「404 model not found」而不是参数错误,很难查。
|
|
390
|
+
* 顺便支持 `/v1beta/models/<model>` 这种带版本号但无方法的形态(走 GET 列表时
|
|
391
|
+
* 会撞上,这里取不到 `/` 就是取不到,返回 undefined 让上层报 400)。
|
|
392
|
+
*/
|
|
393
|
+
export function geminiModelFromPath(pathname: string): string | undefined {
|
|
394
|
+
const path = pathname.split('?')[0] ?? ''
|
|
395
|
+
const m = /\/models\/(.+?):(?:streamGenerateContent|generateContent|countTokens)$/.exec(path)
|
|
396
|
+
return m?.[1]
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Gemini 的流式意图在**路径尾部**:`:streamGenerateContent` 是流式、
|
|
401
|
+
* `:generateContent` 是非流式。返回 `undefined` 表示路径没表态(不是 Gemini
|
|
402
|
+
* 形态,或路径不可识别),交给 body 的 `stream` 决定。
|
|
403
|
+
*
|
|
404
|
+
* 为什么不能只看 body:Gemini 的请求体里**没有** `stream` 字段 —— 它的官方
|
|
405
|
+
* 契约就是靠 URL 区分两种方法。不认这个,流式请求会被当成非流式,客户端按
|
|
406
|
+
* SSE 解析一整个 JSON ⇒ 空响应(2026-10-21 在 3080 实测到)。
|
|
407
|
+
*/
|
|
408
|
+
export function geminiStreamFromPath(pathname: string): boolean | undefined {
|
|
409
|
+
const path = pathname.split('?')[0] ?? ''
|
|
410
|
+
if (/:streamGenerateContent$/.test(path)) return true
|
|
411
|
+
if (/:generateContent$/.test(path)) return false
|
|
412
|
+
return undefined
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* 从请求体取推理等级(各协议字段名不同)。
|
|
417
|
+
*
|
|
418
|
+
* 只认「明确说了要多少思考」的形态,其余一律 'auto':把「有这个字段」当成
|
|
419
|
+
* 「要高档推理」会让所有带 thinking 块的请求都按 high 走,费用与延迟都变。
|
|
420
|
+
*/
|
|
421
|
+
export function lvOf(body: JsonObject, protocol: ProtocolId): string {
|
|
422
|
+
const direct = str(body.reasoning_effort)
|
|
423
|
+
if (direct !== undefined) return direct
|
|
424
|
+
const effort = str(body.reasoning) ?? str(body.effort)
|
|
425
|
+
if (effort !== undefined) return effort
|
|
426
|
+
// Gemini:generationConfig.thinkingConfig.thinkingBudget 是数字档位
|
|
427
|
+
const budget = body.generationConfig
|
|
428
|
+
if (budget !== undefined) {
|
|
429
|
+
const tb = (budget as { thinkingConfig?: { thinkingBudget?: unknown } }).thinkingConfig?.thinkingBudget
|
|
430
|
+
if (typeof tb === 'number' && tb > 0) return 'high'
|
|
431
|
+
}
|
|
432
|
+
// Anthropic:thinking.budget_tokens
|
|
433
|
+
const thinking = body.thinking
|
|
434
|
+
if (thinking !== undefined) {
|
|
435
|
+
const tb = (thinking as { budget_tokens?: unknown }).budget_tokens
|
|
436
|
+
if (typeof tb === 'number' && tb > 0) return 'high'
|
|
437
|
+
}
|
|
438
|
+
void protocol
|
|
439
|
+
return 'auto'
|
|
440
|
+
}
|