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,186 @@
1
+ /**
2
+ * 模型缓存(核心统一)测试 —— 组合面板不该每次都打上游。
3
+ * 用 node --test 跑(Node 原生 TS 剥离,零新依赖)。
4
+ */
5
+ import { test } from 'node:test'
6
+ import assert from 'node:assert/strict'
7
+ import { Router } from './index.ts'
8
+ import type { ServerResponse } from 'node:http'
9
+ import type { ModelInfo, ModelWithEnabled } from './types.ts'
10
+ import type { AccountPool } from './account-pool.ts'
11
+ import type { ChatOnceResult, SupplierAccountNow, SupplierStatusNow } from '../suppliers/contract.ts'
12
+ import { AccountPool as Pool } from './account-pool.ts'
13
+
14
+ /**
15
+ * 记 listModels 调用次数的假供应商。
16
+ * 默认带一个账号(走真实账号循环),模型不属于自己时报 no_such_model
17
+ * (与真实插件一致)。
18
+ */
19
+ function supplier(id: string, models: ModelInfo[], delayMs = 0) {
20
+ const state = { calls: 0 }
21
+ const accts: SupplierAccountNow[] = [{ uid: 'u1', credits: 0, state: 'ok' }]
22
+ return {
23
+ state,
24
+ s: {
25
+ id,
26
+ name: id,
27
+ priority: 0,
28
+ status: (): SupplierStatusNow => ({ id, name: id, accounts: accts }),
29
+ listModels: async (): Promise<ModelInfo[]> => {
30
+ state.calls += 1
31
+ if (delayMs > 0) await new Promise((r) => setTimeout(r, delayMs))
32
+ return models
33
+ },
34
+ modelsWithEnabled: async (): Promise<ModelWithEnabled[]> =>
35
+ models.map((m) => ({ ...m, enabled: true })),
36
+ getAlias: (): string => id,
37
+ accounts: (): SupplierAccountNow[] => accts,
38
+ chatOnce: async (uid: string, req: { rawBody: string }): Promise<ChatOnceResult> => {
39
+ const m = (JSON.parse(req.rawBody) as { model?: string }).model ?? ''
40
+ if (!models.some((mm) => mm.id === m)) {
41
+ return { ok: false, state: 'no_such_model', message: `unknown model ${m}` }
42
+ }
43
+ void uid
44
+ return { ok: true, status: 200, body: '{"ok":1}' }
45
+ },
46
+ dispose: (): void => {},
47
+ pool: new Pool() as AccountPool,
48
+ },
49
+ }
50
+ }
51
+
52
+ /** 丢弃响应的假 ServerResponse。 */
53
+ function fakeRes(): ServerResponse {
54
+ return {
55
+ writeHead: (): unknown => undefined,
56
+ end: (): unknown => undefined,
57
+ write: (): boolean => true,
58
+ once: (): unknown => undefined,
59
+ removeListener: (): unknown => undefined,
60
+ destroy: (): unknown => undefined,
61
+ } as unknown as ServerResponse
62
+ }
63
+
64
+ function addSupplier(router: Router, s: unknown): void {
65
+ // supplierModels/modelsOf 走 this.suppliers,测试里直接塞进去
66
+ ;(router as unknown as { suppliers: unknown[] }).suppliers.push(s)
67
+ }
68
+
69
+ test('组合面板连开两次只拉一次上游(缓存生效)', async () => {
70
+ const router = new Router('')
71
+ const a = supplier('a', [{ id: 'a-1' }])
72
+ const b = supplier('b', [{ id: 'b-1' }])
73
+ addSupplier(router, a.s)
74
+ addSupplier(router, b.s)
75
+
76
+ await router.supplierModels()
77
+ await router.supplierModels()
78
+ await router.supplierModels()
79
+
80
+ assert.equal(a.state.calls, 1)
81
+ assert.equal(b.state.calls, 1)
82
+ })
83
+
84
+ test('detail 与 combos 共用同一份缓存', async () => {
85
+ const router = new Router('')
86
+ const a = supplier('a', [{ id: 'a-1' }])
87
+ addSupplier(router, a.s)
88
+
89
+ const viaDetail = await router.modelsOf('a')
90
+ const viaCombos = await router.supplierModels()
91
+
92
+ assert.equal(a.state.calls, 1)
93
+ assert.equal(viaDetail.length, 1)
94
+ assert.equal(viaCombos[0]?.models.length, 1)
95
+ })
96
+
97
+ test('各供应商并行拉取:总耗时取最慢的那个,不是累加', async () => {
98
+ const router = new Router('')
99
+ // 三个各慢 200ms:串行会是 600ms+,并行约 200ms
100
+ for (const id of ['s1', 's2', 's3']) addSupplier(router, supplier(id, [{ id: `${id}-m` }], 200).s)
101
+
102
+ const t0 = Date.now()
103
+ await router.supplierModels()
104
+ const dt = Date.now() - t0
105
+
106
+ assert.ok(dt < 500, `并行拉取应在 500ms 内,实测 ${dt}ms`)
107
+ })
108
+
109
+ test('失效缓存后重新拉取;force 强制刷新', async () => {
110
+ const router = new Router('')
111
+ const a = supplier('a', [{ id: 'a-1' }])
112
+ addSupplier(router, a.s)
113
+
114
+ await router.modelsOf('a')
115
+ assert.equal(a.state.calls, 1)
116
+
117
+ router.invalidateModels('a')
118
+ await router.modelsOf('a')
119
+ assert.equal(a.state.calls, 2)
120
+
121
+ await router.modelsOf('a', true)
122
+ assert.equal(a.state.calls, 3)
123
+ })
124
+
125
+ test('单个供应商拉模型失败不影响其它供应商', async () => {
126
+ const router = new Router('')
127
+ const bad = supplier('bad', [])
128
+ bad.s.listModels = async (): Promise<ModelInfo[]> => {
129
+ throw new Error('upstream down')
130
+ }
131
+ const good = supplier('good', [{ id: 'g-1' }])
132
+ addSupplier(router, bad.s)
133
+ addSupplier(router, good.s)
134
+
135
+ const groups = await router.supplierModels()
136
+ assert.equal(groups.length, 1)
137
+ assert.equal(groups[0]?.supplier.id, 'good')
138
+ })
139
+
140
+ test('未知供应商返回空,不抛错', async () => {
141
+ const router = new Router('')
142
+ assert.deepEqual(await router.modelsOf('nonexistent'), [])
143
+ })
144
+
145
+ /**
146
+ * 对照组:插件若用 unavailable 表示「不是我的模型」就会污染账号——
147
+ * 这正是当初把 no_such_model 加进契约的原因(unavailable 在 RULES 里计数)。
148
+ * 这条锁住「为什么必须区分这两个状态」,别退化回去。
149
+ */
150
+ test('对照:用 unavailable 表示「不是我的模型」会污染账号', async () => {
151
+ const router = new Router('')
152
+ const other = supplier('other', [{ id: 'other-m' }])
153
+ // 模拟旧行为:不是我的模型也报 unavailable(会计数)
154
+ other.s.chatOnce = async (): Promise<ChatOnceResult> =>
155
+ ({ ok: false, state: 'unavailable', message: 'not my model' })
156
+ addSupplier(router, other.s)
157
+
158
+ const req = { model: 'want-m', stream: false, rawBody: JSON.stringify({ model: 'want-m', messages: [] }) }
159
+ for (let i = 0; i < 5; i++) await router.chatCompletions(req, fakeRes())
160
+
161
+ const acc = other.s.pool.decorate([{ uid: 'u1', credits: 0, state: 'ok' }])[0]
162
+ assert.equal(acc?.cooling, true, 'unavailable 会计数,攒够阈值就该冷却——这正说明必须区分状态')
163
+ })
164
+
165
+ /**
166
+ * 回归:组合里指定别人家的模型,不能把无关供应商的账号攒错误冷却掉。
167
+ * 历史上插件用 unavailable 表示「不是我的模型」,核心计成连续错误,
168
+ * 攒够 3 次就把无辜账号冷却 10 分钟——组合越用越「没号可用」。
169
+ */
170
+ test('组合请求不污染无关供应商的账号(no_such_model 不记账)', async () => {
171
+ const router = new Router('')
172
+ const other = supplier('other', [{ id: 'other-m' }])
173
+ const target = supplier('target', [{ id: 'want-m' }])
174
+ addSupplier(router, other.s)
175
+ addSupplier(router, target.s)
176
+
177
+ const req = { model: 'want-m', stream: false, rawBody: JSON.stringify({ model: 'want-m', messages: [] }) }
178
+ const res = fakeRes()
179
+ // 连打 5 次(远超错误阈值 3)
180
+ for (let i = 0; i < 5; i++) await router.chatCompletions(req, res)
181
+
182
+ const acc = other.s.pool.decorate([{ uid: 'u1', credits: 0, state: 'ok' }])[0]
183
+ assert.equal(acc?.err_count, 0, '不该累计连续错误')
184
+ assert.equal(acc?.cooling, false, '不该被冷却')
185
+ assert.equal(acc?.disabled, false, '不该被禁用')
186
+ })
@@ -3,7 +3,8 @@
3
3
  * 每个供应商提供 OpenAI 兼容的 chat/models,并暴露面板状态。
4
4
  * 仿 9router:供应商 = 一种"免费/付费"通道;dsh-router 在其上做路由。
5
5
  */
6
- import type { ServerResponse } from 'node:http'
6
+ import type { ChatOnceResult, SupplierAccountNow } from '../suppliers/contract.ts'
7
+ import type { AccountPool } from './account-pool.ts'
7
8
 
8
9
  /** 面板展示的账号状态(脱敏)。 */
9
10
  export interface SupplierAccount {
@@ -57,9 +58,8 @@ export interface ChatRequest {
57
58
  }
58
59
 
59
60
  /**
60
- * 供应商接口。chatCompletions 直接向 res 写响应(流式 SSE 或 JSON),
61
- * 成功返回 true;返回 false 表示"该供应商无健康账号可服务"(触发路由器
62
- * 轮换到下一个供应商)。写响应后不得再改 res。
61
+ * 供应商接口。核心负责账号池(选号/冷却/禁用/错误累计/遍历回退),
62
+ * 供应商只对**单个账号**调一次上游并报告结果,不碰 res。
63
63
  */
64
64
  export interface Supplier {
65
65
  readonly id: string
@@ -76,10 +76,15 @@ export interface Supplier {
76
76
  getAlias(): string
77
77
  /** 用户手动添加的模型 id(可选,/v1/models 用它聚合自定义模型)。 */
78
78
  customModelIds?(): string[]
79
- chatCompletions(req: ChatRequest, res: ServerResponse): Promise<boolean>
80
- /** 测试模型(验证上游真实可用,必要差异化能力)。 */
81
- testModel(id: string): Promise<{ ok: boolean; error?: string }>
79
+ /** 账号「现在状态」(从 status() 派生,插件只报它观察到的)。空 = 无账号供应商。 */
80
+ accounts(): SupplierAccountNow[]
81
+ /** 对单个账号调一次上游。不遍历账号、不管冷却、不写 res。 */
82
+ chatOnce(uid: string, req: ChatRequest): Promise<ChatOnceResult>
83
+ /** 上次 chatOnce 失败原因(诊断用,可选)。 */
84
+ lastError?(): string | undefined
82
85
  /** 删除链接(清理凭证;内部能力,js 契约不要求实现)。 */
83
86
  removeLink?(uid: string): Promise<boolean>
84
87
  dispose(): void
88
+ /** 该供应商的账号池(核心持有:选号/冷却/禁用/错误累计)。 */
89
+ readonly pool: AccountPool
85
90
  }
@@ -5,7 +5,6 @@
5
5
  * - 别名 alias(前缀,模型全名 = alias/id)
6
6
  * - 模型管理:启用/禁用 disabled、自定义 custom
7
7
  * - 连接池:poolOrder(拖动排序)、poolStrategy(回退/轮询)
8
- * - 签到规则 checkinRule(所有链接 / 首个链接)
9
8
  *
10
9
  * 持久化到 data/supplier-config.json。
11
10
  */
@@ -13,7 +12,6 @@ import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs'
13
12
  import { dirname, join } from 'node:path'
14
13
 
15
14
  export type PoolStrategy = 'fallback' | 'round-robin'
16
- export type CheckinRule = 'all' | 'first'
17
15
 
18
16
  export interface SupplierConfig {
19
17
  alias: string
@@ -21,7 +19,6 @@ export interface SupplierConfig {
21
19
  custom: string[]
22
20
  poolOrder: string[]
23
21
  poolStrategy: PoolStrategy
24
- checkinRule: CheckinRule
25
22
  }
26
23
 
27
24
  interface ConfigFile {
@@ -42,7 +39,7 @@ export class SupplierConfigStore {
42
39
  get(supplierId: string): SupplierConfig {
43
40
  let cfg = this.bySupplier.get(supplierId)
44
41
  if (!cfg) {
45
- cfg = { alias: '', disabled: [], custom: [], poolOrder: [], poolStrategy: 'fallback', checkinRule: 'all' }
42
+ cfg = { alias: '', disabled: [], custom: [], poolOrder: [], poolStrategy: 'fallback' }
46
43
  this.bySupplier.set(supplierId, cfg)
47
44
  }
48
45
  return cfg
@@ -91,12 +88,6 @@ export class SupplierConfigStore {
91
88
  this.saveLocked()
92
89
  }
93
90
 
94
- setCheckinRule(supplierId: string, rule: string): void {
95
- if (rule !== 'all' && rule !== 'first') return
96
- this.get(supplierId).checkinRule = rule
97
- this.saveLocked()
98
- }
99
-
100
91
  private load(): void {
101
92
  if (this.fp === '') return
102
93
  let f: ConfigFile
@@ -112,7 +103,6 @@ export class SupplierConfigStore {
112
103
  custom: Array.isArray(c.custom) ? c.custom.filter((m) => typeof m === 'string') : [],
113
104
  poolOrder: Array.isArray(c.poolOrder) ? c.poolOrder.filter((u) => typeof u === 'string') : [],
114
105
  poolStrategy: c.poolStrategy === 'round-robin' ? 'round-robin' : 'fallback',
115
- checkinRule: c.checkinRule === 'first' ? 'first' : 'all',
116
106
  })
117
107
  }
118
108
  }
@@ -127,7 +117,6 @@ export class SupplierConfigStore {
127
117
  custom: [...cfg.custom],
128
118
  poolOrder: [...cfg.poolOrder],
129
119
  poolStrategy: cfg.poolStrategy,
130
- checkinRule: cfg.checkinRule,
131
120
  }
132
121
  }
133
122
  try {
@@ -9,8 +9,7 @@
9
9
  * 用户自定义供应商 js 放到 `~/.dsh/profiles/web/suppliers/*.js`,重启后自动加载。
10
10
  * 内置供应商与用户 js 同构(差异化能力驱动)。
11
11
  */
12
- import type { ServerResponse } from 'node:http'
13
- import type { ChatRequest, ModelInfo, SupplierAccount, SupplierStatus } from '../router/types.ts'
12
+ import type { ChatRequest, ModelInfo, SupplierAccount } from '../router/types.ts'
14
13
  import type { SupplierConfigStore } from '../supplier-config.ts'
15
14
  import type { CredentialStore } from '../credential-store.ts'
16
15
 
@@ -29,6 +28,58 @@ export interface SupplierEnv {
29
28
  /** 通用供应商工厂:一个 js 模块 export 它(或 default export 它),loader 调用得到实例。 */
30
29
  export type SupplierFactory = (env: SupplierEnv) => SupplierModule
31
30
 
31
+ /**
32
+ * 账户此刻的状态 —— 插件**解读**上游信号后的语义状态。
33
+ *
34
+ * 插件只报「现在怎么了」,不说「该怎么办」:冷却多久、是否禁用、要不要换号
35
+ * 都是核心的策略,见 `AccountState` 各值的处置表(docs/suppliers.md)。
36
+ *
37
+ * 天花板:目前是固定枚举。若将来某供应商需要更细的语义,再加值而不是放宽成
38
+ * 字符串——核心的策略表要能穷举。
39
+ */
40
+ export type AccountState =
41
+ | 'ok' // 正常
42
+ | 'rate_limit' // 限流(429 类)
43
+ | 'quota' // 额度/权益不足
44
+ | 'session_dead' // 凭证彻底失效,必须重新登录才能恢复
45
+ | 'unavailable' // 上游不可用(404 / 服务下线)
46
+ | 'transport' // 网络/连接层失败(没拿到 HTTP 状态)
47
+ | 'unknown' // 说不清是什么错
48
+ /**
49
+ * 这个模型不属于本供应商(不是账号的失败,也不是上游故障)。
50
+ * 核心据此**跳过整个供应商**换下一个,而不是记在账号头上——
51
+ * 否则组合里每有一个别人家的模型,就会给无关账号攒一次错误,攒够阈值
52
+ * 把它冷却掉(历史上正是这个坑)。
53
+ */
54
+ | 'no_such_model'
55
+
56
+ /** 面板展示的账号此刻状态(插件只报它观察到的部分,冷却/禁用由核心叠加)。 */
57
+ export interface SupplierAccountNow {
58
+ uid: string
59
+ nickname?: string
60
+ credits: number
61
+ /** 插件观察到的上游状态。 */
62
+ state: AccountState
63
+ /** 状态的人类可读补充(如上游错误串),可选。 */
64
+ message?: string
65
+ }
66
+
67
+ /** 插件报的供应商状态(accounts 是「现在状态」,非核心加工后的面板态)。 */
68
+ export interface SupplierStatusNow {
69
+ id: string
70
+ name: string
71
+ accounts: SupplierAccountNow[]
72
+ }
73
+
74
+ /** 一次上游调用的结果。 */
75
+ export type ChatOnceResult =
76
+ /** 流式:插件已把上游协议转成 OpenAI SSE,核心只负责 pipe 到 res。 */
77
+ | { ok: true; stream: ReadableStream<Uint8Array> }
78
+ /** 非流式:核心写 JSON。 */
79
+ | { ok: true; status: number; body: string }
80
+ /** 失败:核心据此做冷却/禁用/换号。 */
81
+ | { ok: false; state: AccountState; message: string }
82
+
32
83
  /** 供应商模块 —— 契约(核心必须,差异化可选)。 */
33
84
  export interface SupplierModule {
34
85
  /** 供应商唯一 id(小写字母数字 - _)。 */
@@ -41,18 +92,29 @@ export interface SupplierModule {
41
92
  readonly icon?: string
42
93
 
43
94
  // ---- 核心(必须) ----
44
- status(): SupplierStatus
95
+ /** 报账号「现在状态」。冷却/禁用/错误累计由核心叠加,插件不算这些。 */
96
+ status(): SupplierStatusNow
45
97
  /** 模型来源。`force=true` 时强制刷新来源(核心「获取模型」按钮调用)。 */
46
98
  listModels(force?: boolean): Promise<ModelInfo[]> | ModelInfo[]
47
99
  /** 默认前缀(可被通用层 alias 覆盖)。 */
48
100
  getAlias(): string
49
- chatCompletions(req: ChatRequest, res: ServerResponse): Promise<boolean>
50
- /** 测试模型(验证上游真实可用,必要差异化能力)。 */
51
- testModel(id: string): Promise<{ ok: boolean; error?: string }>
101
+ /**
102
+ * 对**单个账号**调一次上游。插件不遍历账号、不管冷却、不写 res——
103
+ * 选号/回退/健康判定全是核心的活。
104
+ * 无账号供应商(如 opencode)传空 uid,忽略即可。
105
+ */
106
+ chatOnce(uid: string, req: ChatRequest): Promise<ChatOnceResult>
52
107
  dispose(): void
53
108
 
54
109
  // ---- 差异化能力(可选,按存在性暴露端点/UI) ----
55
110
 
111
+ /** 上次 chatOnce 失败的原因(诊断用)。
112
+ * 测试模型由 dsh-router 核心统一走 chatOnce 路径(账号池回退/冷却自动生效),
113
+ * 插件只需把失败原因暴露出来供核心汇总;不实现则测试失败时只有通用提示。 */
114
+ lastError?(): string | undefined
115
+
116
+ // ---- 差异化能力(可选,按存在性暴露端点/UI) ----
117
+
56
118
  /** 生成登录链接(加账号)。有它才显示连接池(账号池)。 */
57
119
  generateLoginUrl?(): string | { ok: boolean; error?: string; loginUrl?: string } | Promise<string | { ok: boolean; error?: string; loginUrl?: string }>
58
120
  /** 回调完成登录。 */
@@ -65,8 +127,9 @@ export interface SupplierModule {
65
127
  /** 轮询式登录标记:有它 → 面板隐藏「粘贴回调链接」步骤,登录后直接点完成(后台轮询)。 */
66
128
  pollLogin?(): boolean
67
129
 
68
- /** 触发签到。签到规则(all/first)是 dsh-router 通用策略,不在此列。 */
69
- checkinNow?(): Promise<{ ok: boolean; total: number; succeeded: number; already?: number; error?: string; results?: Array<{ uid: string; ok: boolean; status?: string; message?: string }> }>
130
+ /** 单个链接签到。遍历所有链接 + 结果汇总是 dsh-router 核心的活,插件只管一个 uid。
131
+ * status: 'ok'(签到成功) / 'already'(今日已签到) / 'error'(失败)。 */
132
+ checkinNow?(uid: string): Promise<{ ok: boolean; status: string; message?: string }>
70
133
  }
71
134
 
72
135
  /** 供应商账号(复用 router 类型,供 loader 包装)。 */
@@ -13,7 +13,8 @@ import type { ServerResponse } from 'node:http'
13
13
  import type { ChatRequest, ModelInfo, ModelWithEnabled, Supplier, SupplierStatus } from '../router/types.ts'
14
14
  import type { SupplierConfigStore } from '../supplier-config.ts'
15
15
  import type { CredentialStore } from '../credential-store.ts'
16
- import type { SupplierEnv, SupplierFactory, SupplierModule } from './contract.ts'
16
+ import type { ChatOnceResult, SupplierAccountNow, SupplierEnv, SupplierFactory, SupplierModule } from './contract.ts'
17
+ import { AccountPool } from '../router/account-pool.ts'
17
18
 
18
19
  /** 供应商加载错误(不阻断其他供应商)。 */
19
20
  export interface SupplierLoadError {
@@ -43,7 +44,6 @@ function resolveModule(mod: Record<string, unknown>, file: string): SupplierModu
43
44
  if (typeof cand !== 'object' || cand === null) throw new Error(`供应商模块无效: ${file}`)
44
45
  if (typeof cand.id !== 'string' || cand.id === '') throw new Error(`供应商缺少 id: ${file}`)
45
46
  if (typeof cand.name !== 'string' || cand.name === '') throw new Error(`供应商缺少 name: ${file}`)
46
- if (typeof cand.testModel !== 'function') throw new Error(`供应商 ${cand.id} 缺少 testModel(必要差异化能力): ${file}`)
47
47
  return cand as unknown as SupplierModule
48
48
  }
49
49
 
@@ -56,7 +56,7 @@ async function importSupplierFile(file: string): Promise<SupplierModule> {
56
56
 
57
57
  /**
58
58
  * 差异化能力(供应商 js 实现;其余为通用能力由核心提供)。
59
- * testModel 是必要差异化能力(核心必须),不在此列。
59
+ * testModel / 连接池 / 模型管理 / 别名 / 凭证存储均为核心通用能力,不在此列。
60
60
  */
61
61
  const DIFF_CAPS = [
62
62
  'generateLoginUrl', 'completeLogin',
@@ -75,9 +75,7 @@ async function loadOne(file: string, env: SupplierEnv, source: 'builtin' | 'user
75
75
  if (typeof instance !== 'object' || instance === null || typeof instance.id !== 'string' || instance.id === '') {
76
76
  throw new Error(`供应商 factory 返回无效: ${file}`)
77
77
  }
78
- if (typeof instance.testModel !== 'function') {
79
- throw new Error(`供应商 ${instance.id} 缺少 testModel(必要差异化能力): ${file}`)
80
- }
78
+ // 测试模型由核心统一走 chatOnce 路径,插件无需实现 testModel
81
79
  } else {
82
80
  instance = mod
83
81
  }
@@ -92,20 +90,25 @@ export function wrapModule(instance: SupplierModule, env: SupplierEnv, source: s
92
90
  if (typeof instance !== 'object' || instance === null || typeof instance.id !== 'string' || instance.id === '') {
93
91
  throw new Error(`供应商模块无效: ${source}`)
94
92
  }
95
- if (typeof instance.testModel !== 'function') {
96
- throw new Error(`供应商 ${instance.id} 缺少 testModel(必要差异化能力): ${source}`)
97
- }
93
+ // 测试模型由核心统一走 chatOnce 路径(账号池回退/冷却自动生效),插件无需实现 testModel
98
94
  const capabilities = new Set<string>()
99
95
  for (const key of DIFF_CAPS) {
100
96
  if (isFn((instance as unknown as Record<string, unknown>)[key])) capabilities.add(key)
101
97
  }
102
98
 
99
+ // 账号池由核心持有(选号/冷却/禁用/错误累计),插件不参与。
100
+ // 挂在 supplier 上:status() 叠加状态与 Router 的 chat 循环共用同一实例。
101
+ const pool = new AccountPool()
102
+
103
103
  const supplier: Supplier = {
104
104
  id: instance.id,
105
105
  name: instance.name,
106
106
  priority: instance.priority ?? 0,
107
107
  icon: (instance as { icon?: string }).icon,
108
- status: (): SupplierStatus => instance.status(),
108
+ status: (): SupplierStatus => {
109
+ const now = instance.status()
110
+ return { id: now.id, name: now.name, accounts: pool.decorate(now.accounts) }
111
+ },
109
112
  listModels: (force?: boolean): Promise<ModelInfo[]> | ModelInfo[] => instance.listModels(force),
110
113
  // 通用能力:模型启用状态/自定义由核心 SupplierConfigStore 合并
111
114
  modelsWithEnabled: (force?: boolean): Promise<ModelWithEnabled[]> | ModelWithEnabled[] => {
@@ -122,13 +125,17 @@ export function wrapModule(instance: SupplierModule, env: SupplierEnv, source: s
122
125
  },
123
126
  getAlias: (): string => env.store.get(instance.id).alias || instance.getAlias(),
124
127
  customModelIds: (): string[] => [...env.store.get(instance.id).custom],
125
- chatCompletions: (req: ChatRequest, res: ServerResponse): Promise<boolean> => instance.chatCompletions(req, res),
126
- testModel: (id: string): Promise<{ ok: boolean; error?: string }> => instance.testModel(id),
128
+ accounts: (): SupplierAccountNow[] => instance.status().accounts,
129
+ chatOnce: (uid: string, req: ChatRequest): Promise<ChatOnceResult> => instance.chatOnce(uid, req),
130
+ lastError: isFn((instance as unknown as Record<string, unknown>).lastError)
131
+ ? (): string | undefined => (instance as unknown as { lastError(): string | undefined }).lastError()
132
+ : undefined,
127
133
  // 删除链接:数据删除是通用能力,凭证清理由供应商内部实现(js 契约不要求)
128
134
  removeLink: isFn((instance as unknown as Record<string, unknown>).removeLink)
129
135
  ? (uid: string): Promise<boolean> => (instance as unknown as { removeLink(uid: string): Promise<boolean> }).removeLink(uid)
130
136
  : undefined,
131
137
  dispose: (): void => instance.dispose(),
138
+ pool,
132
139
  }
133
140
  // 差异化路由通过 LoadedSupplier.capabilities + supplier 实例调用面板方法
134
141
  ;(supplier as Supplier & { __module?: SupplierModule }).__module = instance