dsh-router-core 0.2.0 → 0.3.1

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 (38) hide show
  1. package/README.md +40 -11
  2. package/docs/screenshot.png +0 -0
  3. package/docs/suppliers.md +302 -0
  4. package/lib/client-registry.js +1119 -213
  5. package/lib/client.js +1119 -213
  6. package/lib/index.js +1066 -50
  7. package/lib/types/llm/adapter.d.ts +2 -0
  8. package/lib/types/router/index.d.ts +34 -5
  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 +86 -1
  12. package/lib/types/supplier-config.d.ts +15 -1
  13. package/package.json +21 -4
  14. package/src/client/CheckinCard.tsx +180 -0
  15. package/src/client/CombosTab.tsx +28 -18
  16. package/src/client/RouterView.tsx +44 -27
  17. package/src/client/StatsTab.tsx +531 -0
  18. package/src/client/index.tsx +61 -11
  19. package/src/client/router.css +688 -35
  20. package/src/client/settings-nav-icon.ts +55 -0
  21. package/src/client/settings-section.tsx +21 -0
  22. package/src/client/workspace-mount.tsx +2 -9
  23. package/src/index.ts +51 -3
  24. package/src/llm/adapter.test.ts +108 -0
  25. package/src/llm/adapter.ts +18 -6
  26. package/src/router/index.ts +461 -49
  27. package/src/router/models-cache.test.ts +269 -0
  28. package/src/router/precise-dispatch.test.ts +198 -0
  29. package/src/router/usage-store.ts +499 -0
  30. package/src/router/usage-tokens.ts +327 -0
  31. package/src/router/usage.test.ts +558 -0
  32. package/src/shared.ts +90 -1
  33. package/src/supplier-config.ts +30 -2
  34. package/src/suppliers/loader.ts +2 -1
  35. package/src/suppliers/nvidia/plugin.ts +2 -3
  36. package/src/suppliers/opencode/plugin.ts +2 -3
  37. package/src/suppliers/openrouter/plugin.ts +2 -3
  38. package/src/suppliers/registry.ts +7 -2
@@ -10,6 +10,18 @@ import type { Supplier, ChatRequest, ModelInfo, ModelWithEnabled, SupplierStatus
10
10
  import type { ChatOnceResult } from '../suppliers/contract.ts'
11
11
  import { AccountPool } from './account-pool.ts'
12
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
+ }
13
25
 
14
26
  /** 模型列表缓存有效期。 */
15
27
  const MODELS_TTL_MS = 60 * 1000
@@ -52,47 +64,258 @@ function sinkRes(): ServerResponse & { status(): number; body(): string } {
52
64
  return self as unknown as ServerResponse & { status(): number; body(): string }
53
65
  }
54
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
+
55
238
  /**
56
239
  * 把 chatOnce 的结果写进 res(核心独占响应写入权)。
57
240
  * 流式:SSE 头 + pipe 上游已转好的 OpenAI SSE;非流式:原样写 JSON。
241
+ *
242
+ * @returns true = 响应已提交(客户端拿到东西了);false = **一个字节都没写**
243
+ * (响应头也还没发),调用方可以换账号/换模型重试。
244
+ *
245
+ * 流式**故意推迟到第一个字节才写响应头**:上游常见「连上了但立刻断」的情况,
246
+ * 先写头就等于把自己锁死在这一个账号上,组合就没法回退了——而客户端只拿到
247
+ * 一个空/截断的 SSE,表现为工具调用名是空串(`unknown tool ""`)。
248
+ *
249
+ * 天花板:一旦写出第一个字节就绑死(HTTP 语义),中途断流只能截断,
250
+ * 不能回退。要彻底解决得在写头前缓冲判定,代价是首字节延迟——未做。
58
251
  */
59
- async function writeChatResult(res: ServerResponse, r: ChatOnceResult): Promise<void> {
60
- if (!r.ok) return // 失败不该走到这里(核心先判 ok 才写)
252
+ async function writeChatResult(res: ServerResponse, r: ChatOnceResult, wantsStream: boolean, probe?: UsageProbe, startedAt = 0): Promise<boolean> {
253
+ if (!r.ok) return false // 失败不该走到这里(核心先判 ok 才写)
61
254
  if (!('stream' in r)) {
255
+ if (probe !== undefined) probe.tokens = usageFromJsonBody(r.body)
62
256
  res.writeHead(r.status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
63
257
  res.end(r.body)
64
- return
258
+ return true
65
259
  }
66
- res.writeHead(200, {
67
- 'Content-Type': 'text/event-stream',
68
- 'Cache-Control': 'no-cache',
69
- Connection: 'keep-alive',
70
- 'X-Accel-Buffering': 'no',
71
- })
72
- if (typeof (res as { flushHeaders?: () => void }).flushHeaders === 'function') {
73
- ;(res as { flushHeaders: () => void }).flushHeaders()
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
74
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
+
75
298
  // 流中途出错(客户端断开/上游断流)也要结束响应,否则连接悬挂
76
299
  const onError = (): void => {
77
300
  res.destroy()
78
301
  }
79
302
  res.once('error', onError)
80
303
  try {
81
- await r.stream.pipeTo(
82
- new WritableStream<Uint8Array>({
83
- write: (chunk) =>
84
- new Promise<void>((resolve) => {
85
- if (res.write(chunk)) resolve()
86
- else res.once('drain', resolve)
87
- }),
88
- }),
89
- )
304
+ await normalizeSSEStream(tapped?.stream ?? r.stream).pipeTo(new WritableStream<Uint8Array>({ write: writeChunk }))
90
305
  } catch {
91
- // 尽力而为:流断了也要收尾
306
+ // 流断了:一个字节都没写的话,调用方还能换号重试——交给上层决定
92
307
  } finally {
93
308
  res.removeListener('error', onError)
94
- res.end()
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
+ }
95
317
  }
318
+ return wroteAny
96
319
  }
97
320
 
98
321
  /** 从上游响应体提取错误信息(OpenAI {error:{message}} 或 {code,msg}/{message})。 */
@@ -108,6 +331,32 @@ function extractUpstreamError(body: string): string {
108
331
  return body.slice(0, 200)
109
332
  }
110
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
+
111
360
  /** 路由器。 */
112
361
  export class Router {
113
362
  private suppliers: Supplier[] = []
@@ -119,13 +368,28 @@ export class Router {
119
368
  private store: SupplierConfigStore
120
369
  /** 模型列表缓存(supplierId → 模型 + 拉取时间)。插件只管拉,不缓存。 */
121
370
  private modelsCache = new Map<string, { models: ModelWithEnabled[]; fetchedAt: number }>()
371
+ /** 请求日志出口(面板/宿主 logger)。 */
372
+ private log: (msg: string) => void
373
+ /** 用量统计(概览看板)。 */
374
+ usage: UsageStore
122
375
 
123
- constructor(stateFile = '', store?: SupplierConfigStore) {
376
+ constructor(stateFile = '', store?: SupplierConfigStore, log?: (msg: string) => void) {
124
377
  this.combosFp = stateFile ? join(dirname(stateFile), 'combos.json') : ''
125
378
  this.store = store ?? new SupplierConfigStore(stateFile)
379
+ this.log = log ?? ((): void => {})
380
+ this.usage = new UsageStore(stateFile)
126
381
  this.loadCombos()
127
382
  }
128
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
+
129
393
  private loadCombos(): void {
130
394
  if (this.combosFp === '') return
131
395
  try {
@@ -143,12 +407,20 @@ export class Router {
143
407
  .map((s) => s.supplier)
144
408
  .filter((m) => typeof m === 'string' && m !== '')
145
409
  : []
146
- // 兼容旧版存的全名 alias/id:剥掉第一段前缀存裸 id(前缀随供应商动态变)。
147
- // 用 indexOf 而非 lastIndexOf:模型 id 本身可含斜杠(如 nvidia 的
410
+ // 存储格式 = supplierId,modelId。旧数据的两种形态都在这里归一:
411
+ // - 旧版全名 alias/id → 用别名反查供应商,存 supplierId,id
412
+ // - 裸 id(上一版)→ 查不到供应商,保持裸 id,路由时降级为遍历
413
+ // 用 indexOf 而非 lastIndexOf:模型 id 本身可含斜杠(如
148
414
  // deepseek-ai/xxx),剥最后一段会把命名空间吃掉。
149
415
  const models = raw.map((m) => {
416
+ const comma = m.indexOf(',')
417
+ if (comma > 0) return m // 已是 supplierId,modelId
150
418
  const slash = m.indexOf('/')
151
- return slash >= 0 ? m.slice(slash + 1) : m
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}`
152
424
  })
153
425
  return {
154
426
  id: c.id,
@@ -181,6 +453,8 @@ export class Router {
181
453
  add(supplier: Supplier): void {
182
454
  this.suppliers.push(supplier)
183
455
  this.suppliers.sort((a, b) => a.priority - b.priority)
456
+ // 让 store 知道全部供应商 id,别名唯一性校验才有得比较
457
+ this.store.sync(this.suppliers.map((s) => s.id))
184
458
  }
185
459
 
186
460
  /** 移除供应商(外部插件卸载时注销)。 */
@@ -308,12 +582,26 @@ export class Router {
308
582
  * 只剥「已知 alias + /」开头的前缀——模型 id 本身可以含斜杠
309
583
  * (如 nvidia 的 `deepseek-ai/deepseek-v4-flash-0731`),用 lastIndexOf 会把
310
584
  * 命名空间一起吃掉,后面请求必然 404。 */
585
+ /**
586
+ * 归一组合模型名为存储格式 `supplierId,modelId`。
587
+ * 面板提交的是「供应商 id + 模型 id」两两组合(或旧数据的裸 id),
588
+ * 这里只做清理与校验,不再剥掉前缀——精准调用靠的就是供应商 id。
589
+ */
311
590
  private normalizeModelIds(models: string[]): string[] {
312
- const aliases = this.suppliers.map((s) => s.getAlias()).filter((a) => a !== '')
313
- return models.map((m) => {
314
- const prefix = aliases.find((a) => m.startsWith(`${a}/`))
315
- return prefix === undefined ? m : m.slice(prefix.length + 1)
316
- })
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
+ })
317
605
  }
318
606
 
319
607
  /** 创建组合(name 唯一,非 default)。 */
@@ -369,6 +657,35 @@ export class Router {
369
657
  * - 否则 → 依次尝试供应商,直到某个返回 true(已写响应)或全部返回 false。
370
658
  */
371
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
+
372
689
  const combo = this.comboByName(req.model)
373
690
  if (combo) {
374
691
  // 组合:按策略选起点,然后按组合模型顺序回退
@@ -379,43 +696,109 @@ export class Router {
379
696
  for (let i = 0; i < combo.models.length; i++) {
380
697
  const model = combo.models[(start + i) % combo.models.length]
381
698
  if (model === undefined) continue
382
- const served = await this.chatWithModel(req, res, model)
383
- if (served) return
699
+ const served = await this.chatWithModel(req, res, model, probe)
700
+ if (served) {
701
+ settle(true)
702
+ return
703
+ }
384
704
  }
705
+ settle(false, 'combo: all models unavailable')
385
706
  writeJson(res, 503, openAIError('no_healthy_supplier', `combo ${JSON.stringify(req.model)}: all models unavailable`))
386
707
  return
387
708
  }
388
- const served = await this.chatWithModel(req, res, req.model)
389
- 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
728
+ }
729
+ settle(false, 'all suppliers unavailable')
390
730
  writeJson(res, 503, openAIError('no_healthy_supplier', 'all suppliers unavailable'))
391
731
  }
392
732
 
393
- /** 把 model 改写为组合选中的模型名后,遍历供应商 + 账号(策略全在核心)。 */
394
- private async chatWithModel(req: ChatRequest, res: ServerResponse, model: string): Promise<boolean> {
395
- 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 }
396
754
  try {
397
755
  const obj = JSON.parse(req.rawBody) as Record<string, unknown>
398
- obj.model = model
756
+ obj.model = modelId
399
757
  clone.rawBody = JSON.stringify(obj)
400
758
  } catch {
401
759
  clone.rawBody = req.rawBody
402
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:退化为遍历
403
780
  for (const s of this.suppliers) {
404
- const served = await this.chatWithSupplier(s, clone, res)
781
+ const served = await this.chatWithSupplier(s, clone, res, undefined, probe)
405
782
  if (served) return true
406
783
  }
407
784
  return false
408
785
  }
409
786
 
787
+ /** 按别名找供应商(对外模型全名 = alias/model)。别名唯一,故最多命中一个。 */
788
+ supplierByAlias(alias: string): Supplier | undefined {
789
+ return this.suppliers.find((s) => s.getAlias() === alias)
790
+ }
791
+
410
792
  /**
411
793
  * 单个供应商内遍历账号(核心策略):选号 → 调 chatOnce → 按返回的
412
794
  * AccountState 处置 → 失败换号;写响应只在成功后发生。
413
795
  *
414
- * 流式一旦写出响应头就绑死(上游协议如此,9router 同样如此),所以
415
- * 「拿到 ok:true 的流」即视为该账号成功,之后出错不再换号——要能回退
416
- * 就得在写头前缓冲判定,代价是首字节延迟,目前未做。
796
+ * 流式**推迟到第一个字节才写响应头**,所以「上游刚连上就断」这种情况
797
+ * 还能换号重试;一旦写出第一个字节就绑死(HTTP 语义,9router 同样如此),
798
+ * 之后出错不再换号——要彻底能回退就得在写头前缓冲判定,代价是首字节
799
+ * 延迟,目前未做。
417
800
  */
418
- private async chatWithSupplier(s: Supplier, req: ChatRequest, res: ServerResponse): Promise<boolean> {
801
+ private async chatWithSupplier(s: Supplier, req: ChatRequest, res: ServerResponse, trace?: ChatTrace, probe?: UsageProbe): Promise<boolean> {
419
802
  const pool = s.pool
420
803
  const cfg = this.store.get(s.id)
421
804
  // 无账号供应商:直接调一次(uid 传空,插件忽略)
@@ -426,8 +809,15 @@ export class Router {
426
809
  pool.noteFailure('', r.state, r.message)
427
810
  return false
428
811
  }
429
- await writeChatResult(res, r)
430
- return true
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)
431
821
  }
432
822
  // 试过的号不再选:某些失败状态既不冷却也不计数(如模型不属于本供应商),
433
823
  // 不排除试过的就会原地打转——死循环等于整个服务挂住。
@@ -440,13 +830,34 @@ export class Router {
440
830
  if (!r.ok) {
441
831
  // 模型不属于本供应商:整个供应商都跳过,换号重试没有意义,
442
832
  // 也不能记在账号头上(否则无关账号会被攒够错误冷却掉)
443
- if (r.state === 'no_such_model') return false
833
+ if (r.state === 'no_such_model') {
834
+ if (trace !== undefined) trace.lastError = 'no_such_model'
835
+ return false
836
+ }
444
837
  pool.noteFailure(uid, r.state, r.message)
838
+ if (trace !== undefined) {
839
+ trace.attempts += 1
840
+ trace.lastError = `${r.state}: ${r.message}`
841
+ }
445
842
  continue
446
843
  }
447
844
  pool.noteSuccess(uid)
448
- await writeChatResult(res, r)
449
- return true
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
+ }
450
861
  }
451
862
  }
452
863
 
@@ -483,5 +894,6 @@ export class Router {
483
894
  dispose(): void {
484
895
  for (const s of this.suppliers) s.dispose()
485
896
  this.suppliers = []
897
+ this.usage.flush()
486
898
  }
487
899
  }