dsh-router-core 0.3.11 → 0.3.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -41,6 +41,8 @@ import type { SupplierEnv, SupplierModule } from './suppliers/contract.ts'
41
41
  import { SupplierConfigStore } from './supplier-config.ts'
42
42
  import { CredentialStore } from './credential-store.ts'
43
43
  import { dataDirOf, profileDirOf } from './data-dir.ts'
44
+ import { ExtStore } from './ext/store.ts'
45
+ import type { ExtInfo, ExtStoreService, RouterExt, RouterExtService } from './ext/contract.ts'
44
46
 
45
47
  /**
46
48
  * Plugin identity for cordis.yml rows — 必须与 package.json 的 name 一致。
@@ -185,6 +187,17 @@ export function apply(rawContext: unknown): void {
185
187
  // 单装 dsh-router-codebuddy 时表永不出现,codebuddy 就一直不生效)。
186
188
  ctx.provide('router.suppliers', {})
187
189
 
190
+ // ---- 扩展 (Ext):router.ext 注册表 + router.extStore 存储 ----
191
+ // 核心只做**管理面**:发现(注册表)、展示(面板/API)、代存开关与插件数据。
192
+ // 拦截 tools/execute / 裁决 enabled+ready / 短路执行全部归扩展插件自己 —— 见
193
+ // ext/contract.ts 文件头。同 router.suppliers 模式:核心持有空表,扩展插件经
194
+ // ctx.inject 追加进同一个 live 对象。
195
+ const exts: Record<string, unknown> = {}
196
+ ctx.provide('router.ext', exts)
197
+ // 开关 + 插件数据归核心持久化(<dataDir>/ext.json);插件经此 service 读写,不自己 file IO。
198
+ const extStore = new ExtStore(stateFile)
199
+ ctx.provide('router.extStore', extStore as unknown as ExtStoreService)
200
+
188
201
  // 内置 + 用户 + 外部插件供应商(异步加载,完成后注册路由 + 加入路由器)
189
202
  const registerLoaded = (loaded: LoadedSupplier): void => {
190
203
  loadedSuppliers.push(loaded)
@@ -447,6 +460,56 @@ export function apply(rawContext: unknown): void {
447
460
  writeJson(res, 200, { ok: true, requireApiKey: keys.requireApiKey })
448
461
  })
449
462
 
463
+ // ---- 扩展 (Ext):扩展器列表 + 开关 ----
464
+ route(`${ROUTER_API_BASE}/ext`, async (req, res) => {
465
+ if (req.method === 'PATCH') {
466
+ let body: { id?: unknown; enabled?: unknown }
467
+ try {
468
+ body = JSON.parse(await readBody(req, 64 << 10)) as { id?: unknown; enabled?: unknown }
469
+ } catch {
470
+ writeJson(res, 400, { ok: false, error: 'invalid JSON body' })
471
+ return
472
+ }
473
+ const ext = typeof body.id === 'string' ? exts[body.id] as RouterExtService[string] : undefined
474
+ if (!ext) {
475
+ writeJson(res, 404, { ok: false, error: 'extension not found' })
476
+ return
477
+ }
478
+ if (typeof body.enabled !== 'boolean') {
479
+ writeJson(res, 400, { ok: false, error: 'enabled must be a boolean' })
480
+ return
481
+ }
482
+ const wantOn = body.enabled
483
+ // 开启自检:不可用(ready !== true)的扩展**不能开启成功**——即使绕过面板
484
+ // 直连 API 也会被拒。由扩展器报告就绪状态,核心据此裁决。
485
+ if (wantOn && typeof ext.getState === 'function') {
486
+ const st = ext.getState()
487
+ if (st?.ready !== true) {
488
+ writeJson(res, 409, { ok: false, error: st?.detail ?? 'extension not ready' })
489
+ return
490
+ }
491
+ }
492
+ // 开关归核心持久化。
493
+ extStore.setEnabled(ext.id, body.enabled)
494
+ }
495
+ const list: ExtInfo[] = Object.values(exts as RouterExtService)
496
+ .map((raw): ExtInfo | undefined => {
497
+ const e = raw as RouterExt
498
+ if (!e || typeof e.id !== 'string' || e.id === '') return undefined
499
+ const st = typeof e.getState === 'function' ? e.getState() : { ready: false }
500
+ return {
501
+ id: e.id,
502
+ name: e.name ?? e.id,
503
+ ...(e.description !== undefined ? { description: e.description } : {}),
504
+ enabled: extStore.isEnabled(e.id),
505
+ ready: st?.ready === true,
506
+ ...(st?.detail !== undefined ? { detail: st.detail } : {}),
507
+ }
508
+ })
509
+ .filter((e): e is ExtInfo => e !== undefined)
510
+ writeJson(res, 200, { ok: true, enhancers: list })
511
+ })
512
+
450
513
  // ---- 概览看板:用量统计 ----
451
514
 
452
515
  /** 解析并校验周期参数(不接受任意字符串)。 */
@@ -563,6 +626,14 @@ export function apply(rawContext: unknown): void {
563
626
 
564
627
  ctx.effect(
565
628
  () => () => {
629
+ // 注册表里的扩展器(插件自己 inject 的清理会摘键;这里兜底 dispose 仍在表里的)。
630
+ for (const raw of Object.values(exts as RouterExtService)) {
631
+ try {
632
+ ;(raw as RouterExt)?.dispose?.()
633
+ } catch {
634
+ // 卸载清理失败不影响其他清理
635
+ }
636
+ }
566
637
  for (const dispose of disposers.splice(0)) dispose()
567
638
  router.dispose() // 内部会 flush 用量统计(防抖中的计数不能丢)
568
639
  },
@@ -36,12 +36,129 @@ test('选号:某模型冷却中的号对该模型跳过,全冷却返回 unde
36
36
  assert.equal(p.pick(list, [], 'fallback', 'm1'), undefined)
37
37
  })
38
38
 
39
- test('round-robin 在健康号间轮转(带 model)', () => {
39
+ test('round-robin 是块轮询:块内粘住不换号(带 model)', () => {
40
40
  const p = pool()
41
41
  const list = accs(['a', 'b'])
42
+ // 块没满 → 一直粘在 a(这就是缓存命中的来源)
42
43
  assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
44
+ assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
45
+ assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
46
+ })
47
+
48
+ // ============ 块轮询(docs/pool-sticky-block.md) ============
49
+
50
+ /** 连打 n 次成功请求,全部命中缓存。 */
51
+ const serveHits = (p: AccountPool, uid: string, n: number, model = 'm1'): void => {
52
+ for (let i = 0; i < n; i++) p.noteCache(uid, model, 1000)
53
+ }
54
+ /** 连打 n 次成功请求,全部未命中(cold miss)。 */
55
+ const serveMiss = (p: AccountPool, uid: string, n: number, model = 'm1'): void => {
56
+ for (let i = 0; i < n; i++) p.noteCache(uid, model, 0)
57
+ }
58
+
59
+ test('【块】OR:计数<N(最短驻留)且没达标 → 留守,哪怕缓存已热', () => {
60
+ const p = pool()
61
+ const list = accs(['a', 'b'])
62
+ assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
63
+ // 计数 15 < N(16),命中率 100% >= M → 留守(最短驻留没走完,不因热度提前跳)
64
+ serveHits(p, 'a', 15)
65
+ assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
66
+ })
67
+
68
+ test('【块】OR:计数到 N 且命中率达标 → 换号(下一号也冷启动,让位均衡)', () => {
69
+ const p = pool()
70
+ const list = accs(['a', 'b'])
71
+ assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
72
+ // 计数 16 >= N 且命中率 100% >= 85% → 换号
73
+ serveHits(p, 'a', 16)
74
+ assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'b')
75
+ })
76
+
77
+ test('【块】OR:命中率没达标 → 计数到 N 仍留守(继续热,不急换)', () => {
78
+ const p = pool()
79
+ const list = accs(['a', 'b'])
80
+ assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
81
+ // 计数 20 全 miss → 命中率 0% < 85% → 留守(OR 语义:ratio<M 时留守)
82
+ serveMiss(p, 'a', 20)
83
+ assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
84
+ })
85
+
86
+ test('【块】N_max 兜底:命中率一直不达标 → 到 N_max(48) 必换', () => {
87
+ const p = pool()
88
+ const list = accs(['a', 'b'])
89
+ assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
90
+ // 一直 miss,命中率 0% 永不达标 → 不靠 N 换,靠 N_max 兜底
91
+ serveMiss(p, 'a', 48)
92
+ // 47 次后仍在 a;第 48 次触发 noteCache 的降权,随后 pick 跳过 a
93
+ assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'b')
94
+ })
95
+
96
+ test('【块】驻留满 N_max 仍不达标 → 该号降权,后续轮转跳过', () => {
97
+ const p = pool()
98
+ const list = accs(['a', 'b', 'c'])
99
+ assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
100
+ serveMiss(p, 'a', 48) // a 判定缓存无效(降权)
101
+ assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'b') // 跳过 a
102
+ // b 正常服务并达标 → 切走后轮转也应跳过 a
103
+ serveHits(p, 'b', 16)
104
+ assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'c')
105
+ })
106
+
107
+ test('【块】降权不是永久判决:全池都降权时退回健康池兜底', () => {
108
+ const p = pool()
109
+ const list = accs(['a', 'b'])
110
+ assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
111
+ serveMiss(p, 'a', 48)
112
+ assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'b')
113
+ serveMiss(p, 'b', 48)
114
+ // 两个都降权 → 不能返回 undefined(那等于服务挂了),退回健康池
115
+ const uid = p.pick(list, [], 'round-robin', 'm1')
116
+ assert.equal(uid !== undefined, true)
117
+ })
118
+
119
+ test('【块】故障切走作废块:dropBlock 后重新选号', () => {
120
+ const p = pool()
121
+ const list = accs(['a', 'b'])
122
+ assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
123
+ serveHits(p, 'a', 3) // 块才 3 次,本来会留守
124
+ p.dropBlock('m1')
125
+ // 块作废 → 从 a 之后前进,选到 b(而不是带着旧计数继续粘 a)
126
+ assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'b')
127
+ })
128
+
129
+ test('【块】失败请求不入块统计(失败没产生缓存,计入会污染命中率)', () => {
130
+ const p = pool()
131
+ const list = accs(['a', 'b'])
132
+ assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
133
+ // 16 次全命中 → 达标切走(失败不算,否则比例被稀释永不达标)
134
+ serveHits(p, 'a', 16)
43
135
  assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'b')
136
+ })
137
+
138
+ test('【块】块状态按模型隔离:m1 的驻留不影响 m2', () => {
139
+ const p = pool()
140
+ const list = accs(['a', 'b'])
44
141
  assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
142
+ serveHits(p, 'a', 16) // m1 上 a 已达标
143
+ assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'b')
144
+ // m2 是独立的块 → 从头开始,选 a
145
+ assert.equal(p.pick(list, [], 'round-robin', 'm2'), 'a')
146
+ })
147
+
148
+ test('【块】多号间长期均衡:块大小有界 → 请求数均分', () => {
149
+ const p = pool()
150
+ const list = accs(['a', 'b', 'c'])
151
+ const count: Record<string, number> = { a: 0, b: 0, c: 0 }
152
+ // 每个块 16 次(全命中达标),跑 12 个块
153
+ for (let block = 0; block < 12; block++) {
154
+ const uid = p.pick(list, [], 'round-robin', 'm1')!
155
+ count[uid]! += 1
156
+ serveHits(p, uid, 16)
157
+ }
158
+ // 12 块 / 3 号 → 每号 4 块
159
+ assert.equal(count['a'], 4)
160
+ assert.equal(count['b'], 4)
161
+ assert.equal(count['c'], 4)
45
162
  })
46
163
 
47
164
  // ============ 核心新语义:冷却按 (模型, 连接) 分号 ============
@@ -86,6 +86,15 @@ const ERR_THRESHOLD = 3
86
86
  /** 攒够后的冷却时长。 */
87
87
  const ERR_COOLDOWN_MS = 5 * MINUTE
88
88
 
89
+ /** 块轮询:命中率达标就放行(`留守 = 计数<N OR 命中率<M`)。见 stickyBlock 说明。 */
90
+ const BLOCK_HIT_RATIO = 0.85
91
+ /** 块轮询:最短驻留。新接到这个号的最少连续服务次数(防冷启动误切)。 */
92
+ const BLOCK_MIN = 16
93
+ /** 块轮询:绝对兜底。轮次过多(max)无论命中率如何都让位,防死守 + 保均衡下限。 */
94
+ const BLOCK_MAX = 48
95
+ /** 块轮询:判定「缓存无效」后的跳过时长。 */
96
+ const BLOCK_DEMOTE_MS = 10 * MINUTE
97
+
89
98
  /** 冷却记录(键 = (supplier, model, uid))。 */
90
99
  interface CooldownEntry {
91
100
  until: number
@@ -100,6 +109,26 @@ interface UidEntry {
100
109
  reason: string
101
110
  }
102
111
 
112
+ /**
113
+ * 块轮询的驻留状态(键 = (supplier, model),与冷却同粒度)。
114
+ *
115
+ * 为什么按 (供应商, 模型) 而不是按号:块是「当前这一轮由谁服务」的概念,
116
+ * 一个模型上只有一个号在驻留;换模型意味着换前缀,块自然作废。
117
+ */
118
+ interface BlockState {
119
+ /** 当前驻留的号。 */
120
+ uid: string
121
+ /** 本块已服务的成功请求数。 */
122
+ served: number
123
+ /** 本块命中缓存(cachedTokens > 0)的请求数。 */
124
+ hits: number
125
+ }
126
+
127
+ /** 「缓存无效」降权记录(键 = (supplier, model, uid))。 */
128
+ interface DemoteEntry {
129
+ until: number
130
+ }
131
+
103
132
  /** 指数退避:等级 1 = BACKOFF_BASE_MS,每级翻倍,封顶 BACKOFF_MAX_MS。 */
104
133
  function backoffMs(level: number): number {
105
134
  return Math.min(BACKOFF_BASE_MS * 2 ** Math.max(0, level - 1), BACKOFF_MAX_MS)
@@ -116,6 +145,10 @@ export class AccountPool {
116
145
  /** uid → 连接级禁用 / 手动暂停。 */
117
146
  private byUid = new Map<string, UidEntry>()
118
147
  private rrCursor = 0
148
+ /** (supplier, model) → 块轮询驻留状态。 */
149
+ private blocks = new Map<string, BlockState>()
150
+ /** (supplier, model, uid) → 缓存无效降权。 */
151
+ private demoted = new Map<string, DemoteEntry>()
119
152
 
120
153
  constructor(supplierId = '') {
121
154
  this.supplierId = supplierId
@@ -169,9 +202,19 @@ export class AccountPool {
169
202
 
170
203
  /**
171
204
  * 按策略为「某个模型」选一个健康账号。
205
+ *
206
+ * round-robin 走**块轮询**:一次轮转内连续服务若干请求,而不是每请求换号。
207
+ * 设计见 docs/pool-sticky-block.md。要点:
208
+ * - 留守 = `计数 < N OR 命中率 < M`(OR,用户拍板):
209
+ * 命中率达标(缓存热了)→ 换号保均衡;计数到 N → 换号止损。
210
+ * 单号最多连续服务 N 次,不会因命中率低而死守到 N_max(打架场景关键)。
211
+ * - 切号因子的思路:命中率是**留守**条件不是切号条件。反接会正反馈死锁
212
+ * (切→cold miss→命中率 0%→触发切号→…),见文档 §3。
213
+ * - `N_max` 绝对兜底:命中率永远不达标的号不能无限驻留(防死锁 + 防均衡归零)。
214
+ *
172
215
  * @param accounts 插件报告的「现在状态」(顺序即插件的自然顺序)
173
216
  * @param poolOrder 用户在面板拖出来的顺序(核心管)
174
- * @param strategy fallback 取第一个健康 / round-robin 轮转健康号
217
+ * @param strategy fallback 取第一个健康 / round-robin 块轮转健康号
175
218
  * @param modelId 当前要路由的模型(决定查哪个 (model, uid) 冷却单元)
176
219
  * @returns 选中的 uid;无健康账号返回 undefined
177
220
  */
@@ -186,11 +229,80 @@ export class AccountPool {
186
229
  const healthy = ordered.filter((uid) => this.healthy(uid, modelId, now))
187
230
  if (healthy.length === 0) return undefined
188
231
  if (strategy !== 'round-robin') return healthy[0]
189
- const uid = healthy[this.rrCursor % healthy.length]
190
- this.rrCursor = (this.rrCursor + 1) % healthy.length
232
+ // 缓存无效降权:驻留满 N_max 命中率仍不达标的号,跳过它(可到期恢复)
233
+ const usable = healthy.filter((uid) => !this.isDemoted(uid, modelId, now))
234
+ const pool = usable.length > 0 ? usable : healthy
235
+ const block = this.blocks.get(this.blockKey(modelId))
236
+ if (block !== undefined && pool.includes(block.uid) && this.blockKeeps(block)) {
237
+ return block.uid
238
+ }
239
+ // 块满/块达标/块不存在 → 前进游标。从当前号之后开始找,保证轮转顺序稳定。
240
+ const from = block === undefined ? this.rrCursor : Math.max(0, pool.indexOf(block.uid) + 1)
241
+ const uid = pool[from % pool.length]!
242
+ this.rrCursor = (pool.indexOf(uid) + 1) % pool.length
243
+ this.blocks.set(this.blockKey(modelId), { uid, served: 0, hits: 0 })
191
244
  return uid
192
245
  }
193
246
 
247
+ /** 该号在本模型上是否被判定「缓存无效」(降权中)。 */
248
+ private isDemoted(uid: string, modelId: string, now: number): boolean {
249
+ const e = this.demoted.get(this.key(modelId, uid))
250
+ return e !== undefined && e.until > now
251
+ }
252
+
253
+ /**
254
+ * 块是否还应驻留在当前号上。
255
+ *
256
+ * OR 语义(用户拍板):`留守 = 计数 < N OR 命中率 < M`。
257
+ * 单一账号内部:一个号先被轮到(冷启动/热),然后
258
+ * - 命中率**爬上去**(>= M,缓存热了)→ 让位,轮到下一个号(均衡)
259
+ * - 命中率**没上去**(冷启动/短块)→ 留守,继续热(不急着跳)
260
+ * - 轮次**实在太多**(计数 >= N_max)→ 也换(防死守 + 保均衡下限)
261
+ *
262
+ * 关键:命中率是**留守**条件不是切号执行器(反接会正反馈死锁,
263
+ * 见文档 §3)。换号由「命中达标 OR 份额用够」触发,都不是「命中率低」。
264
+ *
265
+ * 每个号各自守着「这个会话在自己账号上的那段前缀」,轮流热;
266
+ * 命中率 = 块的命中占比。单会话下每个号块都从冷到热各付一次,
267
+ * 收益来自块内连续驻留(缺了它就跟每请求轮询一样)。
268
+ */
269
+ private blockKeeps(block: BlockState): boolean {
270
+ if (block.served >= BLOCK_MAX) return false // 轮次过多 → 让位(兜底)
271
+ const ratio = block.served === 0 ? 0 : block.hits / block.served
272
+ return block.served < BLOCK_MIN || ratio < BLOCK_HIT_RATIO // 计数<N OR 命中率<M
273
+ }
274
+
275
+ /**
276
+ * 记录一次成功请求的缓存命中情况(块轮询的反馈信号)。
277
+ *
278
+ * 只有**成功**请求才计入:失败没产生缓存,计入会污染命中率并导致错切。
279
+ * @param uid 实际服务的号
280
+ * @param modelId 模型(块粒度)
281
+ * @param cachedTokens 上游报的命中 token 数;0 = 全量重算
282
+ */
283
+ noteCache(uid: string, modelId: string, cachedTokens: number): void {
284
+ const k = this.blockKey(modelId)
285
+ const block = this.blocks.get(k)
286
+ // 块已被别处重置/换号(如故障切走)→ 这次结果不属于当前块,忽略
287
+ if (block === undefined || block.uid !== uid) return
288
+ block.served += 1
289
+ if (cachedTokens > 0) block.hits += 1
290
+ // 驻留满 N_max 仍不达标 → 判定缓存无效,降权一段时间(可恢复)
291
+ if (block.served >= BLOCK_MAX && block.hits / block.served < BLOCK_HIT_RATIO) {
292
+ this.demoted.set(this.key(modelId, uid), { until: Date.now() + BLOCK_DEMOTE_MS })
293
+ }
294
+ }
295
+
296
+ /** 故障切走时作废当前块:换号后前缀归属变了,块计数不能沿用。 */
297
+ dropBlock(modelId: string): void {
298
+ this.blocks.delete(this.blockKey(modelId))
299
+ }
300
+
301
+ /** 块状态键:与冷却同粒度 (supplier, model),不含 uid。 */
302
+ private blockKey(modelId: string): string {
303
+ return `${this.supplierId}${SEP}${modelId}`
304
+ }
305
+
194
306
  /** 记录一次失败:按状态处置(冷却/禁用/退避),落在「(模型, 连接)」这个冷却单元上。 */
195
307
  noteFailure(uid: string, modelId: string, state: AccountState, message: string): void {
196
308
  const rule = RULES[state]
@@ -845,6 +845,8 @@ export class Router {
845
845
  ok,
846
846
  durationMs: Date.now() - probe.startedAt,
847
847
  ttfbMs: probe.ttfbMs,
848
+ // 只有真被服务过才记 uid:失败请求的 uid 可能是空或上一次的残留
849
+ ...(ok && probe.uid !== '' ? { uid: probe.uid } : {}),
848
850
  ...(error === undefined ? {} : { error }),
849
851
  }, usage)
850
852
  }
@@ -1018,6 +1020,8 @@ export class Router {
1018
1020
  return false
1019
1021
  }
1020
1022
  pool.noteFailure(uid, req.model, r.state, r.message)
1023
+ // 故障切走:下次选号会换号,旧块的驻留计数不再适用(前缀归属变了)
1024
+ pool.dropBlock(req.model)
1021
1025
  if (trace !== undefined) {
1022
1026
  trace.attempts += 1
1023
1027
  trace.lastState = r.state
@@ -1036,8 +1040,12 @@ export class Router {
1036
1040
  probe.uid = uid
1037
1041
  }
1038
1042
  const committed = await writeChatResult(res, r, req.stream, probe, probe?.startedAt ?? 0)
1043
+ if (committed) {
1044
+ // 块轮询反馈:只有成功请求才计入(失败没产生缓存,计入会污染命中率)
1045
+ pool.noteCache(uid, req.model, probe?.tokens?.cachedTokens ?? 0)
1046
+ return true
1047
+ }
1039
1048
  // 一个字节都没写(上游刚连上就断)→ 这个号不算数,换下一个重试
1040
- if (committed) return true
1041
1049
  pool.noteFailure(uid, req.model, 'transport', 'stream failed before first byte')
1042
1050
  if (trace !== undefined) {
1043
1051
  trace.attempts += 1
@@ -44,6 +44,14 @@ export interface UsageRecord {
44
44
  durationMs: number
45
45
  /** 首字节延迟(ms);非流式为 0。 */
46
46
  ttfbMs: number
47
+ /**
48
+ * 实际服务的账号 uid(成功时回填)。
49
+ *
50
+ * 有了它才能把命中率按**号**下钻——prompt cache 是 per-account 命名空间,
51
+ * 只看供应商/模型无法判断「是某个号的缓存没热,还是策略在乱切号」。
52
+ * 块轮询(docs/pool-sticky-block.md)的调参完全依赖这个归因。
53
+ */
54
+ uid?: string
47
55
  /** 失败原因(ok=false 时)。 */
48
56
  error?: string
49
57
  }
package/src/shared.ts CHANGED
@@ -139,6 +139,26 @@ export interface RouterSettingsResponse {
139
139
  requireApiKey?: boolean
140
140
  }
141
141
 
142
+ /** 一条扩展器(`/router/api/ext`)。 */
143
+ export interface RouterExtItem {
144
+ id: string
145
+ name: string
146
+ description?: string
147
+ /** 是否启用改写(面板开关)。 */
148
+ enabled?: boolean
149
+ /** 运行时是否就绪(如 RTK 二进制是否可用)。 */
150
+ ready?: boolean
151
+ /** 一句话状态说明(如「本机未装 rtk」)。 */
152
+ detail?: string
153
+ }
154
+
155
+ /** `/router/api/ext` response(扩展插件列表 + 开关)。 */
156
+ export interface RouterExtResponse {
157
+ ok: boolean
158
+ error?: string
159
+ enhancers?: RouterExtItem[]
160
+ }
161
+
142
162
  /** 供应商模型(含启用状态)。 */
143
163
  export interface RouterSupplierModel {
144
164
  id: string