dsh-router-core 0.1.0 → 0.3.0
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 +58 -13
- package/lib/client-registry.js +824 -153
- package/lib/client.js +824 -153
- package/lib/index.js +1606 -227
- package/lib/types/llm/adapter.d.ts +2 -0
- package/lib/types/router/account-pool.d.ts +41 -0
- package/lib/types/router/index.d.ts +72 -4
- package/lib/types/router/types.d.ts +12 -10
- package/lib/types/router/usage-store.d.ts +93 -0
- package/lib/types/router/usage-tokens.d.ts +118 -0
- package/lib/types/shared.d.ts +69 -0
- package/lib/types/supplier-config.d.ts +15 -4
- package/lib/types/suppliers/contract.d.ts +70 -21
- package/lib/types/suppliers/registry.d.ts +4 -3
- package/package.json +2 -1
- package/src/client/CombosTab.tsx +28 -16
- package/src/client/RouterView.tsx +15 -2
- package/src/client/StatsTab.tsx +521 -0
- package/src/client/SupplierDetail.tsx +58 -34
- package/src/client/router.css +406 -0
- package/src/client/workspace-mount.tsx +2 -9
- package/src/index.ts +52 -4
- package/src/llm/adapter.test.ts +108 -0
- package/src/llm/adapter.ts +18 -6
- package/src/router/account-pool.test.ts +120 -0
- package/src/router/account-pool.ts +161 -0
- package/src/router/index.ts +652 -26
- package/src/router/models-cache.test.ts +455 -0
- package/src/router/precise-dispatch.test.ts +198 -0
- package/src/router/types.ts +12 -7
- package/src/router/usage-store.ts +499 -0
- package/src/router/usage-tokens.ts +327 -0
- package/src/router/usage.test.ts +558 -0
- package/src/shared.ts +77 -0
- package/src/supplier-config.ts +31 -14
- package/src/suppliers/contract.ts +71 -8
- package/src/suppliers/loader.ts +21 -13
- package/src/suppliers/nvidia/plugin.ts +161 -119
- package/src/suppliers/opencode/plugin.ts +47 -95
- package/src/suppliers/openrouter/plugin.ts +67 -122
- package/src/suppliers/registry.test.ts +194 -0
- package/src/suppliers/registry.ts +79 -59
package/src/router/index.ts
CHANGED
|
@@ -7,6 +7,24 @@ import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs'
|
|
|
7
7
|
import { dirname, join } from 'node:path'
|
|
8
8
|
import type { ServerResponse } from 'node:http'
|
|
9
9
|
import type { Supplier, ChatRequest, ModelInfo, ModelWithEnabled, SupplierStatus, Combo } from './types.ts'
|
|
10
|
+
import type { ChatOnceResult } from '../suppliers/contract.ts'
|
|
11
|
+
import { AccountPool } from './account-pool.ts'
|
|
12
|
+
import { SupplierConfigStore } from '../supplier-config.ts'
|
|
13
|
+
import { UsageStore } from './usage-store.ts'
|
|
14
|
+
import { tapStreamUsage, usageFromJsonBody, withEstimates, type UsageTokens } from './usage-tokens.ts'
|
|
15
|
+
|
|
16
|
+
/** 一次 chat 的追踪信息(记日志用,回答「到底用的哪个模型哪个号」)。 */
|
|
17
|
+
interface ChatTrace {
|
|
18
|
+
/** 实际服务的账号 uid;无账号供应商为 '(no-account)'。 */
|
|
19
|
+
uid?: string
|
|
20
|
+
/** 失败重试次数。 */
|
|
21
|
+
attempts: number
|
|
22
|
+
/** 最后一次失败原因。 */
|
|
23
|
+
lastError?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 模型列表缓存有效期。 */
|
|
27
|
+
const MODELS_TTL_MS = 60 * 1000
|
|
10
28
|
|
|
11
29
|
function openAIError(code: string, msg: string): Record<string, unknown> {
|
|
12
30
|
return { error: { message: msg, type: 'api_error', code } }
|
|
@@ -20,6 +38,325 @@ function writeJson(res: ServerResponse, status: number, body: unknown): void {
|
|
|
20
38
|
res.end(JSON.stringify(body))
|
|
21
39
|
}
|
|
22
40
|
|
|
41
|
+
/** 丢弃响应的假 ServerResponse(测试模型用:记录状态+内容,用于判定成败)。 */
|
|
42
|
+
function sinkRes(): ServerResponse & { status(): number; body(): string } {
|
|
43
|
+
let status = 200
|
|
44
|
+
let text = ''
|
|
45
|
+
const self = {
|
|
46
|
+
headersSent: false,
|
|
47
|
+
writableEnded: false,
|
|
48
|
+
writeHead: (code: number): unknown => {
|
|
49
|
+
status = code
|
|
50
|
+
return self
|
|
51
|
+
},
|
|
52
|
+
write: (chunk?: unknown): boolean => {
|
|
53
|
+
if (chunk !== undefined) text += String(chunk)
|
|
54
|
+
return true
|
|
55
|
+
},
|
|
56
|
+
end: (chunk?: unknown): unknown => {
|
|
57
|
+
if (chunk !== undefined) text += String(chunk)
|
|
58
|
+
return self
|
|
59
|
+
},
|
|
60
|
+
flushHeaders: (): void => {},
|
|
61
|
+
status: (): number => status,
|
|
62
|
+
body: (): string => text,
|
|
63
|
+
}
|
|
64
|
+
return self as unknown as ServerResponse & { status(): number; body(): string }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 把 OpenAI SSE 流聚合成一次非流式响应体。
|
|
69
|
+
*
|
|
70
|
+
* 为什么核心要做这个:有些供应商**只支持流式**(如 codebuddy 强制 stream:true),
|
|
71
|
+
* 于是客户端要 JSON 时也会拿到流。核心独占响应写入权,就得把这个错配吸收掉——
|
|
72
|
+
* 否则客户端按 JSON 解析 `choices[0].message.tool_calls[].function.name`,
|
|
73
|
+
* 而名字藏在 SSE 的 `delta` 里,解析结果就是 name 为空(下游报
|
|
74
|
+
* `unknown tool ""`)。这是协议错配,不是丢数据。
|
|
75
|
+
*/
|
|
76
|
+
async function aggregateSSE(stream: ReadableStream<Uint8Array>): Promise<string> {
|
|
77
|
+
const dec = new TextDecoder()
|
|
78
|
+
let id = ''
|
|
79
|
+
let model = ''
|
|
80
|
+
let created = Math.floor(Date.now() / 1000)
|
|
81
|
+
let content = ''
|
|
82
|
+
let reasoning = ''
|
|
83
|
+
let finish: string | null = null
|
|
84
|
+
let usage: unknown
|
|
85
|
+
// tool_calls 按 index 聚合:name 只在首个 delta 出现,arguments 逐段拼接
|
|
86
|
+
const calls = new Map<number, { id?: string; type?: string; name: string; args: string }>()
|
|
87
|
+
|
|
88
|
+
const reader = stream.getReader()
|
|
89
|
+
let buf = ''
|
|
90
|
+
for (;;) {
|
|
91
|
+
const { done, value } = await reader.read()
|
|
92
|
+
if (done) break
|
|
93
|
+
buf += dec.decode(value as Uint8Array, { stream: true })
|
|
94
|
+
// SSE 以空行分帧
|
|
95
|
+
let sep: number
|
|
96
|
+
while ((sep = buf.indexOf('\n\n')) >= 0) {
|
|
97
|
+
const frame = buf.slice(0, sep)
|
|
98
|
+
buf = buf.slice(sep + 2)
|
|
99
|
+
for (const line of frame.split('\n')) {
|
|
100
|
+
if (!line.startsWith('data:')) continue
|
|
101
|
+
const payload = line.slice(5).trim()
|
|
102
|
+
if (payload === '' || payload === '[DONE]') continue
|
|
103
|
+
let obj: Record<string, unknown>
|
|
104
|
+
try { obj = JSON.parse(payload) as Record<string, unknown> } catch { continue }
|
|
105
|
+
if (typeof obj.id === 'string' && obj.id !== '') id = obj.id
|
|
106
|
+
if (typeof obj.model === 'string' && obj.model !== '') model = obj.model
|
|
107
|
+
if (typeof obj.created === 'number') created = obj.created
|
|
108
|
+
if (obj.usage !== undefined) usage = obj.usage
|
|
109
|
+
const ch = (obj.choices as Array<Record<string, unknown>> | undefined)?.[0]
|
|
110
|
+
if (ch === undefined) continue
|
|
111
|
+
if (typeof ch.finish_reason === 'string') finish = ch.finish_reason
|
|
112
|
+
const delta = ch.delta as Record<string, unknown> | undefined
|
|
113
|
+
if (delta === undefined) {
|
|
114
|
+
// 少数实现把内容直接放在 message 里(非 delta)
|
|
115
|
+
const msg = ch.message as Record<string, unknown> | undefined
|
|
116
|
+
if (msg !== undefined) {
|
|
117
|
+
if (typeof msg.content === 'string') content += msg.content
|
|
118
|
+
if (typeof msg.reasoning_content === 'string') reasoning += msg.reasoning_content
|
|
119
|
+
}
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
if (typeof delta.content === 'string') content += delta.content
|
|
123
|
+
if (typeof delta.reasoning_content === 'string') reasoning += delta.reasoning_content
|
|
124
|
+
const tcs = delta.tool_calls as Array<Record<string, unknown>> | undefined
|
|
125
|
+
if (!Array.isArray(tcs)) continue
|
|
126
|
+
for (const tc of tcs) {
|
|
127
|
+
const idx = typeof tc.index === 'number' ? tc.index : 0
|
|
128
|
+
const fn = tc.function as Record<string, unknown> | undefined
|
|
129
|
+
const prev = calls.get(idx) ?? { name: '', args: '' }
|
|
130
|
+
if (typeof tc.id === 'string' && tc.id !== '') prev.id = tc.id
|
|
131
|
+
if (typeof tc.type === 'string') prev.type = tc.type
|
|
132
|
+
if (typeof fn?.name === 'string' && fn.name !== '') prev.name = fn.name
|
|
133
|
+
if (typeof fn?.arguments === 'string') prev.args += fn.arguments
|
|
134
|
+
calls.set(idx, prev)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const toolCalls = [...calls.entries()]
|
|
141
|
+
.sort((a, b) => a[0] - b[0])
|
|
142
|
+
// 没名字的工具调用下游只能失败(unknown tool ""),聚合时一并丢掉
|
|
143
|
+
.filter(([, c]) => c.name !== '')
|
|
144
|
+
.map(([, c]) => ({
|
|
145
|
+
id: c.id ?? `call_${c.name}`,
|
|
146
|
+
type: c.type ?? 'function',
|
|
147
|
+
function: { name: c.name, arguments: c.args },
|
|
148
|
+
}))
|
|
149
|
+
|
|
150
|
+
const message: Record<string, unknown> = { role: 'assistant', content }
|
|
151
|
+
if (reasoning !== '') message.reasoning_content = reasoning
|
|
152
|
+
if (toolCalls.length > 0) message.tool_calls = toolCalls
|
|
153
|
+
|
|
154
|
+
const body: Record<string, unknown> = {
|
|
155
|
+
id: id !== '' ? id : `chatcmpl-${Date.now()}`,
|
|
156
|
+
object: 'chat.completion',
|
|
157
|
+
created,
|
|
158
|
+
model,
|
|
159
|
+
choices: [{ index: 0, message, finish_reason: finish ?? 'stop', logprobs: null }],
|
|
160
|
+
}
|
|
161
|
+
if (usage !== undefined) body.usage = usage
|
|
162
|
+
return JSON.stringify(body)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 流式 SSE 逐帧归一化:剥掉上游多发一次的**空** tool_call 字段。
|
|
167
|
+
*
|
|
168
|
+
* 为什么必须做:CodeBuddy 上游的工具调用第二个 delta 会**显式发空串**
|
|
169
|
+
* `{"function":{"name":"","arguments":"{...}"},"index":0}`(以及 `"id":""`)。
|
|
170
|
+
* OpenAI 规范里后续 delta 应当**省略**这些字段,客户端沿用首帧的名字;
|
|
171
|
+
* 而客户端(dsh)判空用的是 `name !== void 0`,空串过不了这个判断,于是首帧
|
|
172
|
+
* 的 "bash" 被第二帧的 "" 冲掉 —— 工具名变空,下游报 `unknown tool ""`,
|
|
173
|
+
* 而 arguments 是独立累积的,所以现象是「参数对、名字空」。
|
|
174
|
+
*
|
|
175
|
+
* 核心是流式响应的唯一写入方,这个上游不合规得核心吸收:把空串字段**删掉**
|
|
176
|
+
* (而不是留着空串),客户端看到的就是 undefined,不会覆盖。
|
|
177
|
+
*
|
|
178
|
+
* 只动 tool_calls 的空 name/id,其余原样透传;逐帧流式处理,不缓冲整条流
|
|
179
|
+
* (首字节延迟不受影响)。解析失败一律原样透传——宁可不改,也不能改坏。
|
|
180
|
+
*/
|
|
181
|
+
function normalizeSSEStream(stream: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
|
|
182
|
+
const enc = new TextEncoder()
|
|
183
|
+
const dec = new TextDecoder()
|
|
184
|
+
let buf = ''
|
|
185
|
+
return new ReadableStream<Uint8Array>({
|
|
186
|
+
start: async (ctrl) => {
|
|
187
|
+
const reader = stream.getReader()
|
|
188
|
+
try {
|
|
189
|
+
for (;;) {
|
|
190
|
+
const { done, value } = await reader.read()
|
|
191
|
+
if (done) break
|
|
192
|
+
buf += dec.decode(value as Uint8Array, { stream: true })
|
|
193
|
+
let sep: number
|
|
194
|
+
// 按 SSE 空行分帧;跨 chunk 边界的不完整帧留在 buf 里等下一块
|
|
195
|
+
while ((sep = buf.indexOf('\n\n')) >= 0) {
|
|
196
|
+
const frame = buf.slice(0, sep + 2)
|
|
197
|
+
buf = buf.slice(sep + 2)
|
|
198
|
+
ctrl.enqueue(enc.encode(fixFrame(frame)))
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (buf !== '') ctrl.enqueue(enc.encode(fixFrame(buf)))
|
|
202
|
+
} finally {
|
|
203
|
+
reader.releaseLock()
|
|
204
|
+
}
|
|
205
|
+
ctrl.close()
|
|
206
|
+
},
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** 修一帧 SSE:只剥空的 name/id,出任何问题都原样返回。 */
|
|
211
|
+
function fixFrame(frame: string): string {
|
|
212
|
+
// 绝大多数帧没有 tool_calls,先廉价字符串筛查,避免无谓的 JSON 解析
|
|
213
|
+
if (!frame.includes('tool_calls')) return frame
|
|
214
|
+
return frame
|
|
215
|
+
.split('\n')
|
|
216
|
+
.map((line) => {
|
|
217
|
+
if (!line.startsWith('data: ')) return line
|
|
218
|
+
const payload = line.slice(6).trim()
|
|
219
|
+
if (payload === '' || payload === '[DONE]') return line
|
|
220
|
+
let obj: Record<string, unknown>
|
|
221
|
+
try { obj = JSON.parse(payload) as Record<string, unknown> } catch { return line }
|
|
222
|
+
let changed = false
|
|
223
|
+
const choices = obj.choices as Array<Record<string, unknown>> | undefined
|
|
224
|
+
const delta = choices?.[0]?.delta as Record<string, unknown> | undefined
|
|
225
|
+
const tcs = delta?.tool_calls as Array<Record<string, unknown>> | undefined
|
|
226
|
+
if (Array.isArray(tcs)) {
|
|
227
|
+
for (const tc of tcs) {
|
|
228
|
+
if (tc.id === '') { delete tc.id; changed = true }
|
|
229
|
+
const fn = tc.function as Record<string, unknown> | undefined
|
|
230
|
+
if (fn !== null && typeof fn === 'object' && fn.name === '') { delete fn.name; changed = true }
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return changed ? `data: ${JSON.stringify(obj)}` : line
|
|
234
|
+
})
|
|
235
|
+
.join('\n')
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* 把 chatOnce 的结果写进 res(核心独占响应写入权)。
|
|
240
|
+
* 流式:SSE 头 + pipe 上游已转好的 OpenAI SSE;非流式:原样写 JSON。
|
|
241
|
+
*
|
|
242
|
+
* @returns true = 响应已提交(客户端拿到东西了);false = **一个字节都没写**
|
|
243
|
+
* (响应头也还没发),调用方可以换账号/换模型重试。
|
|
244
|
+
*
|
|
245
|
+
* 流式**故意推迟到第一个字节才写响应头**:上游常见「连上了但立刻断」的情况,
|
|
246
|
+
* 先写头就等于把自己锁死在这一个账号上,组合就没法回退了——而客户端只拿到
|
|
247
|
+
* 一个空/截断的 SSE,表现为工具调用名是空串(`unknown tool ""`)。
|
|
248
|
+
*
|
|
249
|
+
* 天花板:一旦写出第一个字节就绑死(HTTP 语义),中途断流只能截断,
|
|
250
|
+
* 不能回退。要彻底解决得在写头前缓冲判定,代价是首字节延迟——未做。
|
|
251
|
+
*/
|
|
252
|
+
async function writeChatResult(res: ServerResponse, r: ChatOnceResult, wantsStream: boolean, probe?: UsageProbe, startedAt = 0): Promise<boolean> {
|
|
253
|
+
if (!r.ok) return false // 失败不该走到这里(核心先判 ok 才写)
|
|
254
|
+
if (!('stream' in r)) {
|
|
255
|
+
if (probe !== undefined) probe.tokens = usageFromJsonBody(r.body)
|
|
256
|
+
res.writeHead(r.status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
|
|
257
|
+
res.end(r.body)
|
|
258
|
+
return true
|
|
259
|
+
}
|
|
260
|
+
// 客户端要 JSON 但供应商只给流(如 codebuddy 强制 stream:true)→ 聚合成
|
|
261
|
+
// 一次非流式响应。核心独占响应写入权,这个协议错配必须由核心吸收,
|
|
262
|
+
// 否则客户端按 JSON 解析会在 SSE 的 delta 里找不到工具名。
|
|
263
|
+
if (!wantsStream) {
|
|
264
|
+
let body: string
|
|
265
|
+
try {
|
|
266
|
+
body = await aggregateSSE(r.stream)
|
|
267
|
+
} catch {
|
|
268
|
+
return false // 聚合失败 = 一个字节都没写,调用方可以换号重试
|
|
269
|
+
}
|
|
270
|
+
if (probe !== undefined) probe.tokens = usageFromJsonBody(body)
|
|
271
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
|
|
272
|
+
res.end(body)
|
|
273
|
+
return true
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// 流式:边透传边统计(统计在 tee 之外的旁路做,首字节延迟不受影响)
|
|
277
|
+
const tapped = probe === undefined ? undefined : tapStreamUsage(r.stream, startedAt)
|
|
278
|
+
let wroteAny = false
|
|
279
|
+
const writeChunk = (chunk: Uint8Array): Promise<void> =>
|
|
280
|
+
new Promise<void>((resolve) => {
|
|
281
|
+
if (!wroteAny) {
|
|
282
|
+
// 第一个字节到手,此刻才提交响应头
|
|
283
|
+
res.writeHead(200, {
|
|
284
|
+
'Content-Type': 'text/event-stream',
|
|
285
|
+
'Cache-Control': 'no-cache',
|
|
286
|
+
Connection: 'keep-alive',
|
|
287
|
+
'X-Accel-Buffering': 'no',
|
|
288
|
+
})
|
|
289
|
+
if (typeof (res as { flushHeaders?: () => void }).flushHeaders === 'function') {
|
|
290
|
+
;(res as { flushHeaders: () => void }).flushHeaders()
|
|
291
|
+
}
|
|
292
|
+
wroteAny = true
|
|
293
|
+
}
|
|
294
|
+
if (res.write(chunk)) resolve()
|
|
295
|
+
else res.once('drain', resolve)
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
// 流中途出错(客户端断开/上游断流)也要结束响应,否则连接悬挂
|
|
299
|
+
const onError = (): void => {
|
|
300
|
+
res.destroy()
|
|
301
|
+
}
|
|
302
|
+
res.once('error', onError)
|
|
303
|
+
try {
|
|
304
|
+
await normalizeSSEStream(tapped?.stream ?? r.stream).pipeTo(new WritableStream<Uint8Array>({ write: writeChunk }))
|
|
305
|
+
} catch {
|
|
306
|
+
// 流断了:一个字节都没写的话,调用方还能换号重试——交给上层决定
|
|
307
|
+
} finally {
|
|
308
|
+
res.removeListener('error', onError)
|
|
309
|
+
if (wroteAny) res.end()
|
|
310
|
+
// 流走完才出得了 usage(上游多在最后一帧才发 usage)
|
|
311
|
+
if (probe !== undefined && tapped !== undefined) {
|
|
312
|
+
const got = await tapped.done()
|
|
313
|
+
probe.tokens = got.usage
|
|
314
|
+
probe.outputChars = got.outputChars
|
|
315
|
+
probe.ttfbMs = got.ttfbMs
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return wroteAny
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** 从上游响应体提取错误信息(OpenAI {error:{message}} 或 {code,msg}/{message})。 */
|
|
322
|
+
function extractUpstreamError(body: string): string {
|
|
323
|
+
try {
|
|
324
|
+
const j = JSON.parse(body) as { error?: { message?: string }; message?: string; msg?: string; code?: number }
|
|
325
|
+
if (typeof j.error?.message === 'string') return j.error.message
|
|
326
|
+
if (typeof j.message === 'string') return j.message
|
|
327
|
+
if (typeof j.msg === 'string') return j.msg
|
|
328
|
+
} catch {
|
|
329
|
+
// 非 JSON(可能是 SSE 文本)→ 走下面截断
|
|
330
|
+
}
|
|
331
|
+
return body.slice(0, 200)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** 一次请求的用量观测。由 `chatCompletions` 建,一路传到 `writeChatResult`。
|
|
335
|
+
*
|
|
336
|
+
* 为什么是**每个客户端请求一条**而不是每次尝试一条:组合回退会依次试多个
|
|
337
|
+
* 供应商/账号,按尝试记账会把「1 个请求」记成「N 个请求」,请求数和成功率
|
|
338
|
+
* 全是假的。所以 probe 唯一,成功时由 `chatWithSupplier` 回填是哪个供应商
|
|
339
|
+
* 和账号成的,最后由 `chatCompletions` 落一次账。
|
|
340
|
+
*/
|
|
341
|
+
interface UsageProbe {
|
|
342
|
+
/** 客户端请求的 model(组合场景下就是组合名)。 */
|
|
343
|
+
requested: string
|
|
344
|
+
/** 实际服务的供应商(成功时回填)。 */
|
|
345
|
+
supplier: string
|
|
346
|
+
/** 实际调的模型(成功时回填)。 */
|
|
347
|
+
model: string
|
|
348
|
+
/** 实际服务的账号(成功时回填)。 */
|
|
349
|
+
uid: string
|
|
350
|
+
/** 上游给的 token(null = 上游没发,会走估算)。 */
|
|
351
|
+
tokens: UsageTokens | null
|
|
352
|
+
/** 输出内容字符数(上游没给输出 token 时用来估算)。 */
|
|
353
|
+
outputChars: number
|
|
354
|
+
/** 首字节延迟(ms);非流式/一个字节都没收到时为 0。 */
|
|
355
|
+
ttfbMs: number
|
|
356
|
+
/** 请求开始时间。 */
|
|
357
|
+
startedAt: number
|
|
358
|
+
}
|
|
359
|
+
|
|
23
360
|
/** 路由器。 */
|
|
24
361
|
export class Router {
|
|
25
362
|
private suppliers: Supplier[] = []
|
|
@@ -27,12 +364,32 @@ export class Router {
|
|
|
27
364
|
private customCombos: Combo[] = []
|
|
28
365
|
/** round-robin 轮转游标(按组合 id 记忆)。 */
|
|
29
366
|
private rrCursors = new Map<string, number>()
|
|
367
|
+
/** 通用供应商配置(连接池顺序/策略、模型启用、别名)。 */
|
|
368
|
+
private store: SupplierConfigStore
|
|
369
|
+
/** 模型列表缓存(supplierId → 模型 + 拉取时间)。插件只管拉,不缓存。 */
|
|
370
|
+
private modelsCache = new Map<string, { models: ModelWithEnabled[]; fetchedAt: number }>()
|
|
371
|
+
/** 请求日志出口(面板/宿主 logger)。 */
|
|
372
|
+
private log: (msg: string) => void
|
|
373
|
+
/** 用量统计(概览看板)。 */
|
|
374
|
+
usage: UsageStore
|
|
30
375
|
|
|
31
|
-
constructor(stateFile = '') {
|
|
376
|
+
constructor(stateFile = '', store?: SupplierConfigStore, log?: (msg: string) => void) {
|
|
32
377
|
this.combosFp = stateFile ? join(dirname(stateFile), 'combos.json') : ''
|
|
378
|
+
this.store = store ?? new SupplierConfigStore(stateFile)
|
|
379
|
+
this.log = log ?? ((): void => {})
|
|
380
|
+
this.usage = new UsageStore(stateFile)
|
|
33
381
|
this.loadCombos()
|
|
34
382
|
}
|
|
35
383
|
|
|
384
|
+
/**
|
|
385
|
+
* 请求追踪:每次 chat 记一行,回答「这个组合到底用的哪个模型和账号」。
|
|
386
|
+
* 排查「组合里某个模型不稳定」全靠它——没这个就只能猜。
|
|
387
|
+
* 格式刻意做成一行 grep 友好:`chat 请求model → supplier/model (uid) 结果 耗时`。
|
|
388
|
+
*/
|
|
389
|
+
private logChat(reqModel: string, detail: string, ms: number): void {
|
|
390
|
+
this.log(`chat ${JSON.stringify(reqModel)} → ${detail} ${ms}ms`)
|
|
391
|
+
}
|
|
392
|
+
|
|
36
393
|
private loadCombos(): void {
|
|
37
394
|
if (this.combosFp === '') return
|
|
38
395
|
try {
|
|
@@ -50,10 +407,20 @@ export class Router {
|
|
|
50
407
|
.map((s) => s.supplier)
|
|
51
408
|
.filter((m) => typeof m === 'string' && m !== '')
|
|
52
409
|
: []
|
|
53
|
-
//
|
|
410
|
+
// 存储格式 = supplierId,modelId。旧数据的两种形态都在这里归一:
|
|
411
|
+
// - 旧版全名 alias/id → 用别名反查供应商,存 supplierId,id
|
|
412
|
+
// - 裸 id(上一版)→ 查不到供应商,保持裸 id,路由时降级为遍历
|
|
413
|
+
// 用 indexOf 而非 lastIndexOf:模型 id 本身可含斜杠(如
|
|
414
|
+
// deepseek-ai/xxx),剥最后一段会把命名空间吃掉。
|
|
54
415
|
const models = raw.map((m) => {
|
|
55
|
-
const
|
|
56
|
-
|
|
416
|
+
const comma = m.indexOf(',')
|
|
417
|
+
if (comma > 0) return m // 已是 supplierId,modelId
|
|
418
|
+
const slash = m.indexOf('/')
|
|
419
|
+
if (slash <= 0) return m // 裸 id:保持,路由降级为遍历
|
|
420
|
+
const alias = m.slice(0, slash)
|
|
421
|
+
const modelId = m.slice(slash + 1)
|
|
422
|
+
const s = this.suppliers.find((x) => x.getAlias() === alias)
|
|
423
|
+
return s === undefined ? modelId : `${s.id},${modelId}`
|
|
57
424
|
})
|
|
58
425
|
return {
|
|
59
426
|
id: c.id,
|
|
@@ -86,6 +453,8 @@ export class Router {
|
|
|
86
453
|
add(supplier: Supplier): void {
|
|
87
454
|
this.suppliers.push(supplier)
|
|
88
455
|
this.suppliers.sort((a, b) => a.priority - b.priority)
|
|
456
|
+
// 让 store 知道全部供应商 id,别名唯一性校验才有得比较
|
|
457
|
+
this.store.sync(this.suppliers.map((s) => s.id))
|
|
89
458
|
}
|
|
90
459
|
|
|
91
460
|
/** 移除供应商(外部插件卸载时注销)。 */
|
|
@@ -93,6 +462,7 @@ export class Router {
|
|
|
93
462
|
const i = this.suppliers.findIndex((s) => s.id === id)
|
|
94
463
|
if (i < 0) return false
|
|
95
464
|
const [s] = this.suppliers.splice(i, 1)
|
|
465
|
+
this.modelsCache.delete(id)
|
|
96
466
|
s?.dispose()
|
|
97
467
|
return true
|
|
98
468
|
}
|
|
@@ -142,18 +512,61 @@ export class Router {
|
|
|
142
512
|
return [...this.customCombos]
|
|
143
513
|
}
|
|
144
514
|
|
|
145
|
-
/**
|
|
515
|
+
/**
|
|
516
|
+
* 取某供应商的模型(核心统一缓存)。
|
|
517
|
+
*
|
|
518
|
+
* 模型列表只在这里缓存一次:`/suppliers/:id/models`(供应商详情)和
|
|
519
|
+
* `/combos`(组合加模型)共用。组合面板**不主动打上游**——它只是读缓存,
|
|
520
|
+
* 冷启动(缓存还没建)时才拉一次;真正刷新由详情页打开或「获取模型」
|
|
521
|
+
* 按钮(force)触发。
|
|
522
|
+
*
|
|
523
|
+
* 天花板:TTL 60s 是拍的。若上游模型列表变更很频繁,可调小;要彻底实时
|
|
524
|
+
* 就得让供应商暴露 etag/版本号,目前没有这个需求。
|
|
525
|
+
*/
|
|
526
|
+
async modelsOf(supplierId: string, force = false): Promise<ModelWithEnabled[]> {
|
|
527
|
+
const s = this.suppliers.find((x) => x.id === supplierId)
|
|
528
|
+
if (s === undefined) return []
|
|
529
|
+
const hit = this.modelsCache.get(supplierId)
|
|
530
|
+
if (!force && hit !== undefined && Date.now() - hit.fetchedAt < MODELS_TTL_MS) return hit.models
|
|
531
|
+
const cfg = this.store.get(supplierId)
|
|
532
|
+
const custom = new Set(cfg.custom)
|
|
533
|
+
const disabled = new Set(cfg.disabled)
|
|
534
|
+
const list = await Promise.resolve(s.listModels())
|
|
535
|
+
const models: ModelWithEnabled[] = list.map((mm) => ({
|
|
536
|
+
...mm,
|
|
537
|
+
enabled: !disabled.has(mm.id),
|
|
538
|
+
custom: custom.has(mm.id) ? true : undefined,
|
|
539
|
+
}))
|
|
540
|
+
// 自定义模型(listModels 之外的)并入显示
|
|
541
|
+
const seen = new Set(models.map((mm) => mm.id))
|
|
542
|
+
for (const id of custom) {
|
|
543
|
+
if (!seen.has(id)) {
|
|
544
|
+
seen.add(id)
|
|
545
|
+
models.push({ id, enabled: !disabled.has(id), custom: true })
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
this.modelsCache.set(supplierId, { models, fetchedAt: Date.now() })
|
|
549
|
+
return models
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** 失效某供应商的模型缓存(增删改模型后调用)。 */
|
|
553
|
+
invalidateModels(supplierId: string): void {
|
|
554
|
+
this.modelsCache.delete(supplierId)
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** 可用模型(按供应商分组,仅启用),面板加模型用。
|
|
558
|
+
* 各供应商**并行**拉取——串行会把每个上游的延迟累加起来。 */
|
|
146
559
|
async supplierModels(): Promise<Array<{ supplier: { id: string; name: string; alias: string }; models: ModelWithEnabled[] }>> {
|
|
147
|
-
const
|
|
148
|
-
for (const s of this.suppliers) {
|
|
560
|
+
const groups = await Promise.all(this.suppliers.map(async (s) => {
|
|
149
561
|
try {
|
|
150
|
-
const models = (await s.
|
|
151
|
-
|
|
562
|
+
const models = (await this.modelsOf(s.id)).filter((m) => m.enabled)
|
|
563
|
+
return { supplier: { id: s.id, name: s.name, alias: s.getAlias() }, models }
|
|
152
564
|
} catch {
|
|
153
565
|
// 单供应商失败不影响其它
|
|
566
|
+
return undefined
|
|
154
567
|
}
|
|
155
|
-
}
|
|
156
|
-
return
|
|
568
|
+
}))
|
|
569
|
+
return groups.filter((g): g is NonNullable<typeof g> => g !== undefined)
|
|
157
570
|
}
|
|
158
571
|
|
|
159
572
|
private validModels(models: string[]): boolean {
|
|
@@ -165,12 +578,30 @@ export class Router {
|
|
|
165
578
|
return strategy === 'fallback' || strategy === 'round-robin'
|
|
166
579
|
}
|
|
167
580
|
|
|
168
|
-
/** 组合模型统一存裸 id:剥掉 alias/ 前缀(前缀随供应商动态变)。
|
|
581
|
+
/** 组合模型统一存裸 id:剥掉 alias/ 前缀(前缀随供应商动态变)。
|
|
582
|
+
* 只剥「已知 alias + /」开头的前缀——模型 id 本身可以含斜杠
|
|
583
|
+
* (如 nvidia 的 `deepseek-ai/deepseek-v4-flash-0731`),用 lastIndexOf 会把
|
|
584
|
+
* 命名空间一起吃掉,后面请求必然 404。 */
|
|
585
|
+
/**
|
|
586
|
+
* 归一组合模型名为存储格式 `supplierId,modelId`。
|
|
587
|
+
* 面板提交的是「供应商 id + 模型 id」两两组合(或旧数据的裸 id),
|
|
588
|
+
* 这里只做清理与校验,不再剥掉前缀——精准调用靠的就是供应商 id。
|
|
589
|
+
*/
|
|
169
590
|
private normalizeModelIds(models: string[]): string[] {
|
|
170
|
-
return models
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
591
|
+
return models
|
|
592
|
+
.map((m) => m.trim())
|
|
593
|
+
.filter((m) => m !== '')
|
|
594
|
+
.map((m) => {
|
|
595
|
+
const comma = m.indexOf(',')
|
|
596
|
+
if (comma > 0) return m
|
|
597
|
+
// 旧形态:alias/modelId 或裸 modelId → 尽量归到 supplierId,modelId
|
|
598
|
+
const slash = m.indexOf('/')
|
|
599
|
+
if (slash <= 0) return m
|
|
600
|
+
const alias = m.slice(0, slash)
|
|
601
|
+
const modelId = m.slice(slash + 1)
|
|
602
|
+
const s = this.suppliers.find((x) => x.getAlias() === alias)
|
|
603
|
+
return s === undefined ? modelId : `${s.id},${modelId}`
|
|
604
|
+
})
|
|
174
605
|
}
|
|
175
606
|
|
|
176
607
|
/** 创建组合(name 唯一,非 default)。 */
|
|
@@ -226,6 +657,35 @@ export class Router {
|
|
|
226
657
|
* - 否则 → 依次尝试供应商,直到某个返回 true(已写响应)或全部返回 false。
|
|
227
658
|
*/
|
|
228
659
|
async chatCompletions(req: ChatRequest, res: ServerResponse): Promise<void> {
|
|
660
|
+
// 每个客户端请求一条用量记录(组合回退会试多个供应商,但只落一次账)
|
|
661
|
+
const probe: UsageProbe = {
|
|
662
|
+
requested: req.model,
|
|
663
|
+
supplier: '',
|
|
664
|
+
model: '',
|
|
665
|
+
uid: '',
|
|
666
|
+
tokens: null,
|
|
667
|
+
outputChars: 0,
|
|
668
|
+
ttfbMs: 0,
|
|
669
|
+
startedAt: Date.now(),
|
|
670
|
+
}
|
|
671
|
+
const settle = (ok: boolean, error?: string): void => {
|
|
672
|
+
// 估算只在**请求真被服务**时才有意义:失败请求一个字节都没到上游
|
|
673
|
+
// (或上游直接拒了),拿请求体字符数给它编造输入 token 只会把
|
|
674
|
+
// token 总量灌水。成功但上游没发 usage 才走估算。
|
|
675
|
+
const usage = ok
|
|
676
|
+
? withEstimates(probe.tokens, req.rawBody.length, probe.outputChars)
|
|
677
|
+
: { promptTokens: 0, completionTokens: 0, cachedTokens: 0, inputEstimated: false, outputEstimated: false }
|
|
678
|
+
this.usage.record({
|
|
679
|
+
supplier: probe.supplier,
|
|
680
|
+
model: probe.model,
|
|
681
|
+
requested: probe.requested,
|
|
682
|
+
ok,
|
|
683
|
+
durationMs: Date.now() - probe.startedAt,
|
|
684
|
+
ttfbMs: probe.ttfbMs,
|
|
685
|
+
...(error === undefined ? {} : { error }),
|
|
686
|
+
}, usage)
|
|
687
|
+
}
|
|
688
|
+
|
|
229
689
|
const combo = this.comboByName(req.model)
|
|
230
690
|
if (combo) {
|
|
231
691
|
// 组合:按策略选起点,然后按组合模型顺序回退
|
|
@@ -236,38 +696,204 @@ export class Router {
|
|
|
236
696
|
for (let i = 0; i < combo.models.length; i++) {
|
|
237
697
|
const model = combo.models[(start + i) % combo.models.length]
|
|
238
698
|
if (model === undefined) continue
|
|
239
|
-
const served = await this.chatWithModel(req, res, model)
|
|
240
|
-
if (served)
|
|
699
|
+
const served = await this.chatWithModel(req, res, model, probe)
|
|
700
|
+
if (served) {
|
|
701
|
+
settle(true)
|
|
702
|
+
return
|
|
703
|
+
}
|
|
241
704
|
}
|
|
705
|
+
settle(false, 'combo: all models unavailable')
|
|
242
706
|
writeJson(res, 503, openAIError('no_healthy_supplier', `combo ${JSON.stringify(req.model)}: all models unavailable`))
|
|
243
707
|
return
|
|
244
708
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
709
|
+
// 直接调用:模型全名 = alias/model。用别名反查供应商,精准调用;
|
|
710
|
+
// 别名查不到(拼错 / 供应商没加载)才退化为遍历,保持旧行为可用。
|
|
711
|
+
const slash = req.model.indexOf('/')
|
|
712
|
+
const alias = slash > 0 ? req.model.slice(0, slash) : ''
|
|
713
|
+
const target = alias === '' ? undefined : this.supplierByAlias(alias)
|
|
714
|
+
if (target !== undefined) {
|
|
715
|
+
const served = await this.chatWithTarget(target, req, res, probe)
|
|
716
|
+
if (served) {
|
|
717
|
+
settle(true)
|
|
718
|
+
return
|
|
719
|
+
}
|
|
720
|
+
settle(false, `supplier ${target.id}: all accounts unavailable`)
|
|
721
|
+
writeJson(res, 503, openAIError('no_healthy_supplier', `supplier ${JSON.stringify(target.id)}: all accounts unavailable`))
|
|
722
|
+
return
|
|
723
|
+
}
|
|
724
|
+
const served = await this.chatWithModel(req, res, req.model, probe)
|
|
725
|
+
if (served) {
|
|
726
|
+
settle(true)
|
|
727
|
+
return
|
|
248
728
|
}
|
|
729
|
+
settle(false, 'all suppliers unavailable')
|
|
249
730
|
writeJson(res, 503, openAIError('no_healthy_supplier', 'all suppliers unavailable'))
|
|
250
731
|
}
|
|
251
732
|
|
|
252
|
-
/**
|
|
253
|
-
|
|
254
|
-
|
|
733
|
+
/** 调指定供应商。
|
|
734
|
+
* 模型名**原样**传给插件(不在这剥 alias/)——插件自己认得自己的别名
|
|
735
|
+
* (如 traework 会剥第一段)。核心在这剥就会剥两次,把模型 id 里的
|
|
736
|
+
* 命名空间(如 org/name)吃掉。 */
|
|
737
|
+
private async chatWithTarget(s: Supplier, req: ChatRequest, res: ServerResponse, probe?: UsageProbe): Promise<boolean> {
|
|
738
|
+
return await this.chatWithModel(req, res, `${s.id},${req.model}`, probe)
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/** 把 model 改写为组合选中的模型名后,找对应的供应商 + 账号(策略全在核心)。
|
|
742
|
+
*
|
|
743
|
+
* `model` 形如 `supplierId,modelId`(组合的存储格式):直接用 supplierId 定位,
|
|
744
|
+
* 不再挨个问供应商「这是不是你的模型」——精准调用,也避免同名模型串台。
|
|
745
|
+
* 兼容旧的裸 `modelId`(没有逗号):降级为遍历,行为同以前。
|
|
746
|
+
*/
|
|
747
|
+
private async chatWithModel(req: ChatRequest, res: ServerResponse, model: string, probe?: UsageProbe): Promise<boolean> {
|
|
748
|
+
const comma = model.indexOf(',')
|
|
749
|
+
const supplierId = comma > 0 ? model.slice(0, comma) : undefined
|
|
750
|
+
// 组合存的是供应商 id,但插件认的是自己的模型 id
|
|
751
|
+
const modelId = comma > 0 ? model.slice(comma + 1) : model
|
|
752
|
+
|
|
753
|
+
const clone: ChatRequest = { ...req, model: modelId }
|
|
255
754
|
try {
|
|
256
755
|
const obj = JSON.parse(req.rawBody) as Record<string, unknown>
|
|
257
|
-
obj.model =
|
|
756
|
+
obj.model = modelId
|
|
258
757
|
clone.rawBody = JSON.stringify(obj)
|
|
259
758
|
} catch {
|
|
260
759
|
clone.rawBody = req.rawBody
|
|
261
760
|
}
|
|
761
|
+
|
|
762
|
+
if (supplierId !== undefined) {
|
|
763
|
+
// 精准:只调这一个供应商。查不到就是配置错了,直接失败(不遍历兜底——
|
|
764
|
+
// 遍历会让拼错的 id 静默落到别的供应商上,更难查)
|
|
765
|
+
const s = this.suppliers.find((x) => x.id === supplierId)
|
|
766
|
+
if (s === undefined) {
|
|
767
|
+
this.logChat(req.model, `supplier ${JSON.stringify(supplierId)} 不存在`, 0)
|
|
768
|
+
return false
|
|
769
|
+
}
|
|
770
|
+
const t0 = Date.now()
|
|
771
|
+
const trace: ChatTrace = { attempts: 0 }
|
|
772
|
+
const served = await this.chatWithSupplier(s, clone, res, trace, probe)
|
|
773
|
+
this.logChat(req.model, served
|
|
774
|
+
? `${s.id}/${modelId} (${trace.uid ?? '?'}) ok${trace.attempts > 0 ? ` 重试${trace.attempts}次` : ''}`
|
|
775
|
+
: `${s.id}/${modelId} 失败 ${trace.lastError ?? 'no account'}`, Date.now() - t0)
|
|
776
|
+
return served
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// 旧格式裸模型 id:退化为遍历
|
|
262
780
|
for (const s of this.suppliers) {
|
|
263
|
-
const served = await
|
|
781
|
+
const served = await this.chatWithSupplier(s, clone, res, undefined, probe)
|
|
264
782
|
if (served) return true
|
|
265
783
|
}
|
|
266
784
|
return false
|
|
267
785
|
}
|
|
268
786
|
|
|
787
|
+
/** 按别名找供应商(对外模型全名 = alias/model)。别名唯一,故最多命中一个。 */
|
|
788
|
+
supplierByAlias(alias: string): Supplier | undefined {
|
|
789
|
+
return this.suppliers.find((s) => s.getAlias() === alias)
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* 单个供应商内遍历账号(核心策略):选号 → 调 chatOnce → 按返回的
|
|
794
|
+
* AccountState 处置 → 失败换号;写响应只在成功后发生。
|
|
795
|
+
*
|
|
796
|
+
* 流式**推迟到第一个字节才写响应头**,所以「上游刚连上就断」这种情况
|
|
797
|
+
* 还能换号重试;一旦写出第一个字节就绑死(HTTP 语义,9router 同样如此),
|
|
798
|
+
* 之后出错不再换号——要彻底能回退就得在写头前缓冲判定,代价是首字节
|
|
799
|
+
* 延迟,目前未做。
|
|
800
|
+
*/
|
|
801
|
+
private async chatWithSupplier(s: Supplier, req: ChatRequest, res: ServerResponse, trace?: ChatTrace, probe?: UsageProbe): Promise<boolean> {
|
|
802
|
+
const pool = s.pool
|
|
803
|
+
const cfg = this.store.get(s.id)
|
|
804
|
+
// 无账号供应商:直接调一次(uid 传空,插件忽略)
|
|
805
|
+
if (s.accounts !== undefined && s.accounts().length === 0) {
|
|
806
|
+
const r = await s.chatOnce('', req)
|
|
807
|
+
if (!r.ok) {
|
|
808
|
+
if (r.state === 'no_such_model') return false // 不是我的模型:换供应商,不记账
|
|
809
|
+
pool.noteFailure('', r.state, r.message)
|
|
810
|
+
return false
|
|
811
|
+
}
|
|
812
|
+
if (trace !== undefined) trace.uid = '(no-account)'
|
|
813
|
+
if (probe !== undefined) {
|
|
814
|
+
probe.supplier = s.id
|
|
815
|
+
// 记对外全名 alias/model:与直接调用路径一致。组合里存的是裸
|
|
816
|
+
// modelId,直接记会把同一个模型在 Top 榜上分裂成两行。
|
|
817
|
+
probe.model = `${s.getAlias()}/${req.model}`
|
|
818
|
+
probe.uid = ''
|
|
819
|
+
}
|
|
820
|
+
return await writeChatResult(res, r, req.stream, probe, probe?.startedAt ?? 0)
|
|
821
|
+
}
|
|
822
|
+
// 试过的号不再选:某些失败状态既不冷却也不计数(如模型不属于本供应商),
|
|
823
|
+
// 不排除试过的就会原地打转——死循环等于整个服务挂住。
|
|
824
|
+
const tried = new Set<string>()
|
|
825
|
+
for (;;) {
|
|
826
|
+
const uid = pool.pick(s.accounts().filter((a) => !tried.has(a.uid)), cfg.poolOrder, cfg.poolStrategy)
|
|
827
|
+
if (uid === undefined) return false
|
|
828
|
+
tried.add(uid)
|
|
829
|
+
const r = await s.chatOnce(uid, req)
|
|
830
|
+
if (!r.ok) {
|
|
831
|
+
// 模型不属于本供应商:整个供应商都跳过,换号重试没有意义,
|
|
832
|
+
// 也不能记在账号头上(否则无关账号会被攒够错误冷却掉)
|
|
833
|
+
if (r.state === 'no_such_model') {
|
|
834
|
+
if (trace !== undefined) trace.lastError = 'no_such_model'
|
|
835
|
+
return false
|
|
836
|
+
}
|
|
837
|
+
pool.noteFailure(uid, r.state, r.message)
|
|
838
|
+
if (trace !== undefined) {
|
|
839
|
+
trace.attempts += 1
|
|
840
|
+
trace.lastError = `${r.state}: ${r.message}`
|
|
841
|
+
}
|
|
842
|
+
continue
|
|
843
|
+
}
|
|
844
|
+
pool.noteSuccess(uid)
|
|
845
|
+
if (trace !== undefined) trace.uid = uid
|
|
846
|
+
if (probe !== undefined) {
|
|
847
|
+
probe.supplier = s.id
|
|
848
|
+
// 记对外全名 alias/model:与直接调用路径一致。组合里存的是裸
|
|
849
|
+
// modelId,直接记会把同一个模型在 Top 榜上分裂成两行。
|
|
850
|
+
probe.model = `${s.getAlias()}/${req.model}`
|
|
851
|
+
probe.uid = uid
|
|
852
|
+
}
|
|
853
|
+
const committed = await writeChatResult(res, r, req.stream, probe, probe?.startedAt ?? 0)
|
|
854
|
+
// 一个字节都没写(上游刚连上就断)→ 这个号不算数,换下一个重试
|
|
855
|
+
if (committed) return true
|
|
856
|
+
pool.noteFailure(uid, 'transport', 'stream failed before first byte')
|
|
857
|
+
if (trace !== undefined) {
|
|
858
|
+
trace.attempts += 1
|
|
859
|
+
trace.lastError = 'stream failed before first byte'
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/** 测试某供应商的某模型是否可用。
|
|
865
|
+
* 走真实的账号遍历 + chatOnce 路径:账号池回退/冷却由核心实现,自动生效;
|
|
866
|
+
* 响应丢弃到 sink,限定单一供应商(不跨供应商回退)。 */
|
|
867
|
+
async testModel(supplierId: string, model: string): Promise<{ ok: boolean; error?: string }> {
|
|
868
|
+
const s = this.suppliers.find((x) => x.id === supplierId)
|
|
869
|
+
if (s === undefined) return { ok: false, error: `unknown supplier ${JSON.stringify(supplierId)}` }
|
|
870
|
+
const sink = sinkRes()
|
|
871
|
+
const req: ChatRequest = {
|
|
872
|
+
model,
|
|
873
|
+
stream: false,
|
|
874
|
+
rawBody: JSON.stringify({ model, messages: [{ role: 'user', content: 'ping' }], stream: false, max_tokens: 1 }),
|
|
875
|
+
}
|
|
876
|
+
let served = false
|
|
877
|
+
try {
|
|
878
|
+
served = await this.chatWithSupplier(s, req, sink)
|
|
879
|
+
} catch (err) {
|
|
880
|
+
return { ok: false, error: (err as Error).message }
|
|
881
|
+
}
|
|
882
|
+
// 供应商可能「服务了但写出错误响应」(如 opencode:唯一能处理该模型的供应商,错误自己上报)
|
|
883
|
+
if (served && sink.status() < 400) return { ok: true }
|
|
884
|
+
const fromSink = served && sink.status() >= 400 ? extractUpstreamError(sink.body()) : ''
|
|
885
|
+
const detail = fromSink !== '' ? fromSink : s.lastError?.()
|
|
886
|
+
return {
|
|
887
|
+
ok: false,
|
|
888
|
+
error: detail !== undefined && detail !== ''
|
|
889
|
+
? `${detail}(账号/额度/限流问题,非模型问题)`
|
|
890
|
+
: '所有账号都失败或账号都在冷却中(稍后重试)',
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
269
894
|
dispose(): void {
|
|
270
895
|
for (const s of this.suppliers) s.dispose()
|
|
271
896
|
this.suppliers = []
|
|
897
|
+
this.usage.flush()
|
|
272
898
|
}
|
|
273
899
|
}
|