dsh-router-core 0.1.0 → 0.2.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.
@@ -0,0 +1,120 @@
1
+ /**
2
+ * 账号池(核心策略)测试 —— 选号/冷却/禁用/错误累计/遍历回退。
3
+ * 用 node --test 跑(Node 原生 TS 剥离,零新依赖)。
4
+ */
5
+ import { test } from 'node:test'
6
+ import assert from 'node:assert/strict'
7
+ import { AccountPool } from './account-pool.ts'
8
+ import type { SupplierAccountNow } from '../suppliers/contract.ts'
9
+
10
+ const accs = (uids: string[]): SupplierAccountNow[] => uids.map((uid) => ({ uid, credits: 0, state: 'ok' }))
11
+
12
+ test('选号:无冷却时按 poolOrder 优先,未配置的追加在后', () => {
13
+ const p = new AccountPool()
14
+ const list = accs(['a', 'b', 'c'])
15
+ assert.equal(p.pick(list, ['c', 'a'], 'fallback'), 'c')
16
+ assert.equal(p.pick(list, ['b'], 'fallback'), 'b')
17
+ // 未配置顺序的按自然顺序补上
18
+ assert.equal(p.pick(list, [], 'fallback'), 'a')
19
+ })
20
+
21
+ test('选号:冷却中的号跳过,全冷却返回 undefined', () => {
22
+ const p = new AccountPool()
23
+ const list = accs(['a', 'b'])
24
+ p.noteFailure('a', 'rate_limit', '429')
25
+ assert.equal(p.pick(list, [], 'fallback'), 'b')
26
+ p.noteFailure('b', 'rate_limit', '429')
27
+ assert.equal(p.pick(list, [], 'fallback'), undefined)
28
+ })
29
+
30
+ test('选号:round-robin 在健康号间轮转', () => {
31
+ const p = new AccountPool()
32
+ const list = accs(['a', 'b'])
33
+ assert.equal(p.pick(list, [], 'round-robin'), 'a')
34
+ assert.equal(p.pick(list, [], 'round-robin'), 'b')
35
+ assert.equal(p.pick(list, [], 'round-robin'), 'a')
36
+ })
37
+
38
+ test('处置:session_dead 永久禁用(不再被选中)', () => {
39
+ const p = new AccountPool()
40
+ const list = accs(['a'])
41
+ p.noteFailure('a', 'session_dead', '凭证失效')
42
+ assert.equal(p.pick(list, [], 'fallback'), undefined)
43
+ assert.equal(p.decorate(list)[0]?.disabled, true)
44
+ })
45
+
46
+ test('处置:连续错误攒够阈值才冷却(前两次仍可服务)', () => {
47
+ const p = new AccountPool()
48
+ const list = accs(['a'])
49
+ p.noteFailure('a', 'unknown', 'e1')
50
+ assert.equal(p.pick(list, [], 'fallback'), 'a')
51
+ p.noteFailure('a', 'unknown', 'e2')
52
+ assert.equal(p.pick(list, [], 'fallback'), 'a')
53
+ // 第三次触发冷却
54
+ p.noteFailure('a', 'unknown', 'e3')
55
+ assert.equal(p.pick(list, [], 'fallback'), undefined)
56
+ })
57
+
58
+ test('处置:成功清零连续错误,避免之前累积的错误把好号冷却掉', () => {
59
+ const p = new AccountPool()
60
+ const list = accs(['a'])
61
+ p.noteFailure('a', 'unknown', 'e1')
62
+ p.noteFailure('a', 'unknown', 'e2')
63
+ p.noteSuccess('a')
64
+ p.noteFailure('a', 'unknown', 'e3')
65
+ assert.equal(p.pick(list, [], 'fallback'), 'a')
66
+ })
67
+
68
+ test('decorate:把冷却/禁用/错误累计叠加到插件报的「现在状态」上', () => {
69
+ const p = new AccountPool()
70
+ const list: SupplierAccountNow[] = [
71
+ { uid: 'ok', credits: 5, state: 'ok' },
72
+ { uid: 'cool', credits: 5, state: 'ok' },
73
+ { uid: 'dead', credits: 5, state: 'ok' },
74
+ ]
75
+ p.noteFailure('cool', 'rate_limit', '429')
76
+ p.noteFailure('dead', 'session_dead', '凭证失效')
77
+ const out = p.decorate(list)
78
+ const by = new Map(out.map((a) => [a.uid, a]))
79
+ assert.equal(by.get('ok')?.cooling, false)
80
+ assert.equal(by.get('ok')?.disabled, false)
81
+ assert.equal(by.get('cool')?.cooling, true)
82
+ assert.equal(by.get('cool')?.until !== undefined, true)
83
+ assert.equal(by.get('cool')?.reason, '429')
84
+ assert.equal(by.get('dead')?.disabled, true)
85
+ // 插件报的字段要原样透出(不能被 decorate 吃掉)
86
+ assert.equal(by.get('ok')?.credits, 5)
87
+ })
88
+
89
+ test('no_such_model 不惩罚账号:不冷却、不计数', () => {
90
+ const p = new AccountPool()
91
+ const list = accs(['a'])
92
+ // 攒够阈值次也不该冷却——模型不属于本供应商不是账号的错
93
+ for (let i = 0; i < 10; i++) p.noteFailure('a', 'no_such_model', 'unknown model "x"')
94
+ assert.equal(p.pick(list, [], 'fallback'), 'a')
95
+ const out = p.decorate(list)[0]
96
+ assert.equal(out?.cooling, false)
97
+ assert.equal(out?.disabled, false)
98
+ assert.equal(out?.err_count, 0)
99
+ })
100
+
101
+ test('状态表:各 AccountState 的处置符合预期', () => {
102
+ const p = new AccountPool()
103
+ const list = accs(['rate', 'quota', 'dead', 'unavail'])
104
+ p.noteFailure('rate', 'rate_limit', 'x')
105
+ p.noteFailure('quota', 'quota', 'x')
106
+ p.noteFailure('dead', 'session_dead', 'x')
107
+ p.noteFailure('unavail', 'unavailable', 'x')
108
+ // rate_limit/quota 立刻冷却;session_dead 禁用;unavailable 只计错误不冷却
109
+ assert.equal(p.pick(list, [], 'fallback'), 'unavail')
110
+ const by = new Map(p.decorate(list).map((a) => [a.uid, a]))
111
+ assert.equal(by.get('rate')?.cooling, true)
112
+ assert.equal(by.get('quota')?.cooling, true)
113
+ assert.equal(by.get('dead')?.disabled, true)
114
+ assert.equal(by.get('unavail')?.cooling, false)
115
+ assert.equal(by.get('unavail')?.err_count, 1)
116
+ // 攒够阈值后才冷却(与 unknown/transport 同一条路径)
117
+ p.noteFailure('unavail', 'unavailable', 'x')
118
+ p.noteFailure('unavail', 'unavailable', 'x')
119
+ assert.equal(p.pick(list, [], 'fallback'), undefined)
120
+ })
@@ -0,0 +1,161 @@
1
+ /**
2
+ * 账号池 —— dsh-router 核心策略:选号、冷却、禁用、错误累计、遍历回退。
3
+ *
4
+ * 供应商插件只管「对单个账号调通上游」,账号层面的策略全在这里:
5
+ * - 按 poolOrder 排序(未配置的按插件报告顺序追加)
6
+ * - 按 poolStrategy 选号:fallback = 取第一个健康;round-robin = 轮转健康号
7
+ * - 失败按插件报的 AccountState 处置(冷却/禁用/错误累计),攒够阈值自动冷却
8
+ *
9
+ * 天花板:冷却/禁用/错误计数都是**内存态**,重启归零(与旧插件行为一致,
10
+ * 插件的持久化只存凭证与积分,不存健康)。要跨重启保留得落盘,届时加一个
11
+ * stateFile 即可,接口不用变。
12
+ */
13
+ import type { AccountState, SupplierAccountNow } from '../suppliers/contract.ts'
14
+
15
+ /** 各 AccountState 的处置:冷却时长 / 是否禁用 / 是否计入连续错误。 */
16
+ interface Rule {
17
+ /** 冷却时长(ms);0 = 不冷却。 */
18
+ cooldownMs: number
19
+ /** 是否永久禁用(需重新登录才能恢复)。 */
20
+ disable: boolean
21
+ /** 是否计入连续错误(攒够阈值自动冷却)。 */
22
+ counts: boolean
23
+ }
24
+
25
+ const MINUTE = 60_000
26
+
27
+ /**
28
+ * 状态 → 处置表。**核心策略就在这一张表里**,插件只报状态不做决策。
29
+ * 时长参考 9router 的 errorConfig(限流类短冷却、额度类长冷却),
30
+ * 但 9router 按文本/状态匹配,这里按插件解读后的语义状态匹配,更准。
31
+ */
32
+ const RULES: Record<AccountState, Rule> = {
33
+ ok: { cooldownMs: 0, disable: false, counts: false },
34
+ rate_limit: { cooldownMs: 1 * MINUTE, disable: false, counts: false },
35
+ quota: { cooldownMs: 10 * MINUTE, disable: false, counts: false },
36
+ session_dead: { cooldownMs: 0, disable: true, counts: false },
37
+ // 404/服务下线:只计错误不立刻冷却——偶发的自己恢复,真挂了攒够阈值再冷却
38
+ unavailable: { cooldownMs: 0, disable: false, counts: true },
39
+ transport: { cooldownMs: 0, disable: false, counts: true },
40
+ unknown: { cooldownMs: 0, disable: false, counts: true },
41
+ // 模型不属于本供应商:不是账号的错,不冷却也不计数(核心据此换下一个供应商)
42
+ no_such_model: { cooldownMs: 0, disable: false, counts: false },
43
+ }
44
+
45
+ /** 连续错误攒够这么多次就自动冷却(防止坏号被反复重试)。 */
46
+ const ERR_THRESHOLD = 3
47
+ /** 攒够后的冷却时长。 */
48
+ const ERR_COOLDOWN_MS = 10 * MINUTE
49
+
50
+ interface Entry {
51
+ until: number
52
+ disabled: boolean
53
+ errCount: number
54
+ reason: string
55
+ }
56
+
57
+ export class AccountPool {
58
+ private entries = new Map<string, Entry>()
59
+ private rrCursor = 0
60
+
61
+ private entry(uid: string): Entry {
62
+ let e = this.entries.get(uid)
63
+ if (!e) {
64
+ e = { until: 0, disabled: false, errCount: 0, reason: '' }
65
+ this.entries.set(uid, e)
66
+ }
67
+ return e
68
+ }
69
+
70
+ /** 是否可服务:未禁用且不在冷却中。 */
71
+ private healthy(uid: string, now: number): boolean {
72
+ const e = this.entries.get(uid)
73
+ if (!e) return true
74
+ if (e.disabled) return false
75
+ return e.until <= now
76
+ }
77
+
78
+ /**
79
+ * 按策略选一个健康账号。
80
+ * @param accounts 插件报告的「现在状态」(顺序即插件的自然顺序)
81
+ * @param poolOrder 用户在面板拖出来的顺序(核心管)
82
+ * @returns 选中的 uid;无健康账号返回 undefined
83
+ */
84
+ pick(accounts: SupplierAccountNow[], poolOrder: string[], strategy: string): string | undefined {
85
+ const now = Date.now()
86
+ const byUid = new Map(accounts.map((a) => [a.uid, a]))
87
+ // 池顺序优先,未配置的按插件自然顺序追加
88
+ const ordered = [
89
+ ...poolOrder.filter((uid) => byUid.has(uid)),
90
+ ...accounts.map((a) => a.uid).filter((uid) => !poolOrder.includes(uid)),
91
+ ]
92
+ const healthy = ordered.filter((uid) => this.healthy(uid, now))
93
+ if (healthy.length === 0) return undefined
94
+ if (strategy !== 'round-robin') return healthy[0]
95
+ const uid = healthy[this.rrCursor % healthy.length]
96
+ this.rrCursor = (this.rrCursor + 1) % healthy.length
97
+ return uid
98
+ }
99
+
100
+ /** 记录一次失败:按状态处置(冷却/禁用/错误累计)。 */
101
+ noteFailure(uid: string, state: AccountState, message: string): void {
102
+ const rule = RULES[state]
103
+ const e = this.entry(uid)
104
+ if (rule.disable) {
105
+ e.disabled = true
106
+ e.reason = message
107
+ return
108
+ }
109
+ if (rule.cooldownMs > 0) {
110
+ e.until = Math.max(e.until, Date.now() + rule.cooldownMs)
111
+ e.reason = message
112
+ e.errCount = 0
113
+ return
114
+ }
115
+ if (rule.counts) {
116
+ e.errCount += 1
117
+ e.reason = message
118
+ if (e.errCount >= ERR_THRESHOLD) {
119
+ e.until = Date.now() + ERR_COOLDOWN_MS
120
+ e.errCount = 0
121
+ }
122
+ }
123
+ }
124
+
125
+ /** 记录一次成功:清零连续错误。 */
126
+ noteSuccess(uid: string): void {
127
+ const e = this.entries.get(uid)
128
+ if (e) e.errCount = 0
129
+ }
130
+
131
+ /** 手动冷却(面板/管理用途)。 */
132
+ cooldown(uid: string, ms: number, reason: string): void {
133
+ const e = this.entry(uid)
134
+ e.until = Date.now() + ms
135
+ e.reason = reason
136
+ e.errCount = 0
137
+ }
138
+
139
+ /** 把核心的冷却/禁用/错误累计叠加到插件报告的「现在状态」上,产出面板态。 */
140
+ decorate(accounts: SupplierAccountNow[]): Array<SupplierAccountNow & {
141
+ cooling: boolean
142
+ disabled: boolean
143
+ err_count: number
144
+ until?: string
145
+ reason?: string
146
+ }> {
147
+ const now = Date.now()
148
+ return accounts.map((a) => {
149
+ const e = this.entries.get(a.uid)
150
+ const cooling = e !== undefined && !e.disabled && e.until > now
151
+ return {
152
+ ...a,
153
+ cooling,
154
+ disabled: e?.disabled ?? false,
155
+ err_count: e?.errCount ?? 0,
156
+ until: cooling && e ? new Date(e.until).toISOString() : undefined,
157
+ reason: e?.reason !== '' ? e?.reason : undefined,
158
+ }
159
+ })
160
+ }
161
+ }
@@ -7,6 +7,12 @@ 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
+
14
+ /** 模型列表缓存有效期。 */
15
+ const MODELS_TTL_MS = 60 * 1000
10
16
 
11
17
  function openAIError(code: string, msg: string): Record<string, unknown> {
12
18
  return { error: { message: msg, type: 'api_error', code } }
@@ -20,6 +26,88 @@ function writeJson(res: ServerResponse, status: number, body: unknown): void {
20
26
  res.end(JSON.stringify(body))
21
27
  }
22
28
 
29
+ /** 丢弃响应的假 ServerResponse(测试模型用:记录状态+内容,用于判定成败)。 */
30
+ function sinkRes(): ServerResponse & { status(): number; body(): string } {
31
+ let status = 200
32
+ let text = ''
33
+ const self = {
34
+ headersSent: false,
35
+ writableEnded: false,
36
+ writeHead: (code: number): unknown => {
37
+ status = code
38
+ return self
39
+ },
40
+ write: (chunk?: unknown): boolean => {
41
+ if (chunk !== undefined) text += String(chunk)
42
+ return true
43
+ },
44
+ end: (chunk?: unknown): unknown => {
45
+ if (chunk !== undefined) text += String(chunk)
46
+ return self
47
+ },
48
+ flushHeaders: (): void => {},
49
+ status: (): number => status,
50
+ body: (): string => text,
51
+ }
52
+ return self as unknown as ServerResponse & { status(): number; body(): string }
53
+ }
54
+
55
+ /**
56
+ * 把 chatOnce 的结果写进 res(核心独占响应写入权)。
57
+ * 流式:SSE 头 + pipe 上游已转好的 OpenAI SSE;非流式:原样写 JSON。
58
+ */
59
+ async function writeChatResult(res: ServerResponse, r: ChatOnceResult): Promise<void> {
60
+ if (!r.ok) return // 失败不该走到这里(核心先判 ok 才写)
61
+ if (!('stream' in r)) {
62
+ res.writeHead(r.status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
63
+ res.end(r.body)
64
+ return
65
+ }
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()
74
+ }
75
+ // 流中途出错(客户端断开/上游断流)也要结束响应,否则连接悬挂
76
+ const onError = (): void => {
77
+ res.destroy()
78
+ }
79
+ res.once('error', onError)
80
+ 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
+ )
90
+ } catch {
91
+ // 尽力而为:流断了也要收尾
92
+ } finally {
93
+ res.removeListener('error', onError)
94
+ res.end()
95
+ }
96
+ }
97
+
98
+ /** 从上游响应体提取错误信息(OpenAI {error:{message}} 或 {code,msg}/{message})。 */
99
+ function extractUpstreamError(body: string): string {
100
+ try {
101
+ const j = JSON.parse(body) as { error?: { message?: string }; message?: string; msg?: string; code?: number }
102
+ if (typeof j.error?.message === 'string') return j.error.message
103
+ if (typeof j.message === 'string') return j.message
104
+ if (typeof j.msg === 'string') return j.msg
105
+ } catch {
106
+ // 非 JSON(可能是 SSE 文本)→ 走下面截断
107
+ }
108
+ return body.slice(0, 200)
109
+ }
110
+
23
111
  /** 路由器。 */
24
112
  export class Router {
25
113
  private suppliers: Supplier[] = []
@@ -27,9 +115,14 @@ export class Router {
27
115
  private customCombos: Combo[] = []
28
116
  /** round-robin 轮转游标(按组合 id 记忆)。 */
29
117
  private rrCursors = new Map<string, number>()
118
+ /** 通用供应商配置(连接池顺序/策略、模型启用、别名)。 */
119
+ private store: SupplierConfigStore
120
+ /** 模型列表缓存(supplierId → 模型 + 拉取时间)。插件只管拉,不缓存。 */
121
+ private modelsCache = new Map<string, { models: ModelWithEnabled[]; fetchedAt: number }>()
30
122
 
31
- constructor(stateFile = '') {
123
+ constructor(stateFile = '', store?: SupplierConfigStore) {
32
124
  this.combosFp = stateFile ? join(dirname(stateFile), 'combos.json') : ''
125
+ this.store = store ?? new SupplierConfigStore(stateFile)
33
126
  this.loadCombos()
34
127
  }
35
128
 
@@ -50,9 +143,11 @@ export class Router {
50
143
  .map((s) => s.supplier)
51
144
  .filter((m) => typeof m === 'string' && m !== '')
52
145
  : []
53
- // 兼容旧版存的全名 alias/id:剥掉前缀存裸 id(前缀随供应商动态变)
146
+ // 兼容旧版存的全名 alias/id:剥掉第一段前缀存裸 id(前缀随供应商动态变)。
147
+ // 用 indexOf 而非 lastIndexOf:模型 id 本身可含斜杠(如 nvidia 的
148
+ // deepseek-ai/xxx),剥最后一段会把命名空间吃掉。
54
149
  const models = raw.map((m) => {
55
- const slash = m.lastIndexOf('/')
150
+ const slash = m.indexOf('/')
56
151
  return slash >= 0 ? m.slice(slash + 1) : m
57
152
  })
58
153
  return {
@@ -93,6 +188,7 @@ export class Router {
93
188
  const i = this.suppliers.findIndex((s) => s.id === id)
94
189
  if (i < 0) return false
95
190
  const [s] = this.suppliers.splice(i, 1)
191
+ this.modelsCache.delete(id)
96
192
  s?.dispose()
97
193
  return true
98
194
  }
@@ -142,18 +238,61 @@ export class Router {
142
238
  return [...this.customCombos]
143
239
  }
144
240
 
145
- /** 可用模型(按供应商分组,仅启用),面板加模型用。 */
241
+ /**
242
+ * 取某供应商的模型(核心统一缓存)。
243
+ *
244
+ * 模型列表只在这里缓存一次:`/suppliers/:id/models`(供应商详情)和
245
+ * `/combos`(组合加模型)共用。组合面板**不主动打上游**——它只是读缓存,
246
+ * 冷启动(缓存还没建)时才拉一次;真正刷新由详情页打开或「获取模型」
247
+ * 按钮(force)触发。
248
+ *
249
+ * 天花板:TTL 60s 是拍的。若上游模型列表变更很频繁,可调小;要彻底实时
250
+ * 就得让供应商暴露 etag/版本号,目前没有这个需求。
251
+ */
252
+ async modelsOf(supplierId: string, force = false): Promise<ModelWithEnabled[]> {
253
+ const s = this.suppliers.find((x) => x.id === supplierId)
254
+ if (s === undefined) return []
255
+ const hit = this.modelsCache.get(supplierId)
256
+ if (!force && hit !== undefined && Date.now() - hit.fetchedAt < MODELS_TTL_MS) return hit.models
257
+ const cfg = this.store.get(supplierId)
258
+ const custom = new Set(cfg.custom)
259
+ const disabled = new Set(cfg.disabled)
260
+ const list = await Promise.resolve(s.listModels())
261
+ const models: ModelWithEnabled[] = list.map((mm) => ({
262
+ ...mm,
263
+ enabled: !disabled.has(mm.id),
264
+ custom: custom.has(mm.id) ? true : undefined,
265
+ }))
266
+ // 自定义模型(listModels 之外的)并入显示
267
+ const seen = new Set(models.map((mm) => mm.id))
268
+ for (const id of custom) {
269
+ if (!seen.has(id)) {
270
+ seen.add(id)
271
+ models.push({ id, enabled: !disabled.has(id), custom: true })
272
+ }
273
+ }
274
+ this.modelsCache.set(supplierId, { models, fetchedAt: Date.now() })
275
+ return models
276
+ }
277
+
278
+ /** 失效某供应商的模型缓存(增删改模型后调用)。 */
279
+ invalidateModels(supplierId: string): void {
280
+ this.modelsCache.delete(supplierId)
281
+ }
282
+
283
+ /** 可用模型(按供应商分组,仅启用),面板加模型用。
284
+ * 各供应商**并行**拉取——串行会把每个上游的延迟累加起来。 */
146
285
  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) {
286
+ const groups = await Promise.all(this.suppliers.map(async (s) => {
149
287
  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 })
288
+ const models = (await this.modelsOf(s.id)).filter((m) => m.enabled)
289
+ return { supplier: { id: s.id, name: s.name, alias: s.getAlias() }, models }
152
290
  } catch {
153
291
  // 单供应商失败不影响其它
292
+ return undefined
154
293
  }
155
- }
156
- return out
294
+ }))
295
+ return groups.filter((g): g is NonNullable<typeof g> => g !== undefined)
157
296
  }
158
297
 
159
298
  private validModels(models: string[]): boolean {
@@ -165,11 +304,15 @@ export class Router {
165
304
  return strategy === 'fallback' || strategy === 'round-robin'
166
305
  }
167
306
 
168
- /** 组合模型统一存裸 id:剥掉 alias/ 前缀(前缀随供应商动态变)。 */
307
+ /** 组合模型统一存裸 id:剥掉 alias/ 前缀(前缀随供应商动态变)。
308
+ * 只剥「已知 alias + /」开头的前缀——模型 id 本身可以含斜杠
309
+ * (如 nvidia 的 `deepseek-ai/deepseek-v4-flash-0731`),用 lastIndexOf 会把
310
+ * 命名空间一起吃掉,后面请求必然 404。 */
169
311
  private normalizeModelIds(models: string[]): string[] {
312
+ const aliases = this.suppliers.map((s) => s.getAlias()).filter((a) => a !== '')
170
313
  return models.map((m) => {
171
- const slash = m.lastIndexOf('/')
172
- return slash >= 0 ? m.slice(slash + 1) : m
314
+ const prefix = aliases.find((a) => m.startsWith(`${a}/`))
315
+ return prefix === undefined ? m : m.slice(prefix.length + 1)
173
316
  })
174
317
  }
175
318
 
@@ -242,14 +385,12 @@ export class Router {
242
385
  writeJson(res, 503, openAIError('no_healthy_supplier', `combo ${JSON.stringify(req.model)}: all models unavailable`))
243
386
  return
244
387
  }
245
- for (const s of this.suppliers) {
246
- const served = await s.chatCompletions(req, res)
247
- if (served) return
248
- }
388
+ const served = await this.chatWithModel(req, res, req.model)
389
+ if (served) return
249
390
  writeJson(res, 503, openAIError('no_healthy_supplier', 'all suppliers unavailable'))
250
391
  }
251
392
 
252
- /** 把 model 改写为组合选中的模型名后尝试供应商。 */
393
+ /** 把 model 改写为组合选中的模型名后,遍历供应商 + 账号(策略全在核心)。 */
253
394
  private async chatWithModel(req: ChatRequest, res: ServerResponse, model: string): Promise<boolean> {
254
395
  const clone: ChatRequest = { ...req, model }
255
396
  try {
@@ -260,12 +401,85 @@ export class Router {
260
401
  clone.rawBody = req.rawBody
261
402
  }
262
403
  for (const s of this.suppliers) {
263
- const served = await s.chatCompletions(clone, res)
404
+ const served = await this.chatWithSupplier(s, clone, res)
264
405
  if (served) return true
265
406
  }
266
407
  return false
267
408
  }
268
409
 
410
+ /**
411
+ * 单个供应商内遍历账号(核心策略):选号 → 调 chatOnce → 按返回的
412
+ * AccountState 处置 → 失败换号;写响应只在成功后发生。
413
+ *
414
+ * 流式一旦写出响应头就绑死(上游协议如此,9router 同样如此),所以
415
+ * 「拿到 ok:true 的流」即视为该账号成功,之后出错不再换号——要能回退
416
+ * 就得在写头前缓冲判定,代价是首字节延迟,目前未做。
417
+ */
418
+ private async chatWithSupplier(s: Supplier, req: ChatRequest, res: ServerResponse): Promise<boolean> {
419
+ const pool = s.pool
420
+ const cfg = this.store.get(s.id)
421
+ // 无账号供应商:直接调一次(uid 传空,插件忽略)
422
+ if (s.accounts !== undefined && s.accounts().length === 0) {
423
+ const r = await s.chatOnce('', req)
424
+ if (!r.ok) {
425
+ if (r.state === 'no_such_model') return false // 不是我的模型:换供应商,不记账
426
+ pool.noteFailure('', r.state, r.message)
427
+ return false
428
+ }
429
+ await writeChatResult(res, r)
430
+ return true
431
+ }
432
+ // 试过的号不再选:某些失败状态既不冷却也不计数(如模型不属于本供应商),
433
+ // 不排除试过的就会原地打转——死循环等于整个服务挂住。
434
+ const tried = new Set<string>()
435
+ for (;;) {
436
+ const uid = pool.pick(s.accounts().filter((a) => !tried.has(a.uid)), cfg.poolOrder, cfg.poolStrategy)
437
+ if (uid === undefined) return false
438
+ tried.add(uid)
439
+ const r = await s.chatOnce(uid, req)
440
+ if (!r.ok) {
441
+ // 模型不属于本供应商:整个供应商都跳过,换号重试没有意义,
442
+ // 也不能记在账号头上(否则无关账号会被攒够错误冷却掉)
443
+ if (r.state === 'no_such_model') return false
444
+ pool.noteFailure(uid, r.state, r.message)
445
+ continue
446
+ }
447
+ pool.noteSuccess(uid)
448
+ await writeChatResult(res, r)
449
+ return true
450
+ }
451
+ }
452
+
453
+ /** 测试某供应商的某模型是否可用。
454
+ * 走真实的账号遍历 + chatOnce 路径:账号池回退/冷却由核心实现,自动生效;
455
+ * 响应丢弃到 sink,限定单一供应商(不跨供应商回退)。 */
456
+ async testModel(supplierId: string, model: string): Promise<{ ok: boolean; error?: string }> {
457
+ const s = this.suppliers.find((x) => x.id === supplierId)
458
+ if (s === undefined) return { ok: false, error: `unknown supplier ${JSON.stringify(supplierId)}` }
459
+ const sink = sinkRes()
460
+ const req: ChatRequest = {
461
+ model,
462
+ stream: false,
463
+ rawBody: JSON.stringify({ model, messages: [{ role: 'user', content: 'ping' }], stream: false, max_tokens: 1 }),
464
+ }
465
+ let served = false
466
+ try {
467
+ served = await this.chatWithSupplier(s, req, sink)
468
+ } catch (err) {
469
+ return { ok: false, error: (err as Error).message }
470
+ }
471
+ // 供应商可能「服务了但写出错误响应」(如 opencode:唯一能处理该模型的供应商,错误自己上报)
472
+ if (served && sink.status() < 400) return { ok: true }
473
+ const fromSink = served && sink.status() >= 400 ? extractUpstreamError(sink.body()) : ''
474
+ const detail = fromSink !== '' ? fromSink : s.lastError?.()
475
+ return {
476
+ ok: false,
477
+ error: detail !== undefined && detail !== ''
478
+ ? `${detail}(账号/额度/限流问题,非模型问题)`
479
+ : '所有账号都失败或账号都在冷却中(稍后重试)',
480
+ }
481
+ }
482
+
269
483
  dispose(): void {
270
484
  for (const s of this.suppliers) s.dispose()
271
485
  this.suppliers = []