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.
Files changed (42) hide show
  1. package/README.md +58 -13
  2. package/lib/client-registry.js +824 -153
  3. package/lib/client.js +824 -153
  4. package/lib/index.js +1606 -227
  5. package/lib/types/llm/adapter.d.ts +2 -0
  6. package/lib/types/router/account-pool.d.ts +41 -0
  7. package/lib/types/router/index.d.ts +72 -4
  8. package/lib/types/router/types.d.ts +12 -10
  9. package/lib/types/router/usage-store.d.ts +93 -0
  10. package/lib/types/router/usage-tokens.d.ts +118 -0
  11. package/lib/types/shared.d.ts +69 -0
  12. package/lib/types/supplier-config.d.ts +15 -4
  13. package/lib/types/suppliers/contract.d.ts +70 -21
  14. package/lib/types/suppliers/registry.d.ts +4 -3
  15. package/package.json +2 -1
  16. package/src/client/CombosTab.tsx +28 -16
  17. package/src/client/RouterView.tsx +15 -2
  18. package/src/client/StatsTab.tsx +521 -0
  19. package/src/client/SupplierDetail.tsx +58 -34
  20. package/src/client/router.css +406 -0
  21. package/src/client/workspace-mount.tsx +2 -9
  22. package/src/index.ts +52 -4
  23. package/src/llm/adapter.test.ts +108 -0
  24. package/src/llm/adapter.ts +18 -6
  25. package/src/router/account-pool.test.ts +120 -0
  26. package/src/router/account-pool.ts +161 -0
  27. package/src/router/index.ts +652 -26
  28. package/src/router/models-cache.test.ts +455 -0
  29. package/src/router/precise-dispatch.test.ts +198 -0
  30. package/src/router/types.ts +12 -7
  31. package/src/router/usage-store.ts +499 -0
  32. package/src/router/usage-tokens.ts +327 -0
  33. package/src/router/usage.test.ts +558 -0
  34. package/src/shared.ts +77 -0
  35. package/src/supplier-config.ts +31 -14
  36. package/src/suppliers/contract.ts +71 -8
  37. package/src/suppliers/loader.ts +21 -13
  38. package/src/suppliers/nvidia/plugin.ts +161 -119
  39. package/src/suppliers/opencode/plugin.ts +47 -95
  40. package/src/suppliers/openrouter/plugin.ts +67 -122
  41. package/src/suppliers/registry.test.ts +194 -0
  42. package/src/suppliers/registry.ts +79 -59
@@ -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
- // 兼容旧版存的全名 alias/id:剥掉前缀存裸 id(前缀随供应商动态变)
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 slash = m.lastIndexOf('/')
56
- return slash >= 0 ? m.slice(slash + 1) : m
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 out: Array<{ supplier: { id: string; name: string; alias: string }; models: ModelWithEnabled[] }> = []
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.modelsWithEnabled()).filter((m) => m.enabled)
151
- out.push({ supplier: { id: s.id, name: s.name, alias: s.getAlias() }, models })
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 out
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.map((m) => {
171
- const slash = m.lastIndexOf('/')
172
- return slash >= 0 ? m.slice(slash + 1) : m
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) return
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
- for (const s of this.suppliers) {
246
- const served = await s.chatCompletions(req, res)
247
- if (served) return
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
- /** 把 model 改写为组合选中的模型名后尝试供应商。 */
253
- private async chatWithModel(req: ChatRequest, res: ServerResponse, model: string): Promise<boolean> {
254
- const clone: ChatRequest = { ...req, model }
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 = 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 s.chatCompletions(clone, res)
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
  }