dsh-router-core 0.4.4 → 0.4.5

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 (143) hide show
  1. package/LICENSE +0 -0
  2. package/README.md +0 -0
  3. package/cordis.patch.yml +0 -0
  4. package/docs/ext.md +0 -0
  5. package/docs/pool-sticky-block.md +59 -1
  6. package/docs/screenshot.png +0 -0
  7. package/docs/suppliers.md +20 -1
  8. package/lib/client-registry.js +24 -14
  9. package/lib/client.js +24 -14
  10. package/lib/ext-prompt/locale/en.json +0 -0
  11. package/lib/ext-prompt/locale/zh.json +0 -0
  12. package/lib/ext-prompt/package.json +0 -0
  13. package/lib/ext-test/locale/en.json +0 -0
  14. package/lib/ext-test/locale/zh.json +0 -0
  15. package/lib/ext-test/package.json +0 -0
  16. package/lib/index.js +69 -7
  17. package/lib/suppliers/nvidia/locale/en.json +0 -0
  18. package/lib/suppliers/nvidia/locale/zh.json +0 -0
  19. package/lib/suppliers/nvidia/package.json +0 -0
  20. package/lib/suppliers/opencode/locale/en.json +0 -0
  21. package/lib/suppliers/opencode/locale/zh.json +0 -0
  22. package/lib/suppliers/opencode/package.json +0 -0
  23. package/lib/suppliers/openrouter/locale/en.json +0 -0
  24. package/lib/suppliers/openrouter/locale/zh.json +0 -0
  25. package/lib/suppliers/openrouter/package.json +0 -0
  26. package/lib/types/router/account-pool.d.ts +23 -1
  27. package/lib/types/router/index.d.ts +25 -0
  28. package/lib/types/router/types.d.ts +9 -1
  29. package/lib/types/shared.d.ts +6 -0
  30. package/package.json +1 -1
  31. package/src/client/CheckinCard.tsx +0 -0
  32. package/src/client/CombosTab.tsx +0 -0
  33. package/src/client/EndpointTab.tsx +0 -0
  34. package/src/client/ExtDetail.tsx +0 -0
  35. package/src/client/ExtTab.tsx +0 -0
  36. package/src/client/ExtTestPanel.tsx +0 -0
  37. package/src/client/LastHitDock.tsx +0 -0
  38. package/src/client/Modal.tsx +0 -0
  39. package/src/client/RouterComponentsSection.tsx +0 -0
  40. package/src/client/RouterView.tsx +0 -0
  41. package/src/client/StatsTab.tsx +0 -0
  42. package/src/client/SupplierDetail.tsx +1 -5
  43. package/src/client/account-badge.test.ts +37 -0
  44. package/src/client/account-badge.ts +24 -0
  45. package/src/client/comp-button-scope.test.ts +0 -0
  46. package/src/client/comp-panel-layout.test.ts +0 -0
  47. package/src/client/env.d.ts +0 -0
  48. package/src/client/ext-panels.test.ts +0 -0
  49. package/src/client/ext-panels.ts +0 -0
  50. package/src/client/index.tsx +0 -0
  51. package/src/client/last-hit-store.ts +0 -0
  52. package/src/client/model-choice.test.ts +0 -0
  53. package/src/client/model-choice.ts +0 -0
  54. package/src/client/model-hint-copy.test.ts +0 -0
  55. package/src/client/model-hint-copy.ts +0 -0
  56. package/src/client/router-components.test.ts +0 -0
  57. package/src/client/router-components.ts +0 -0
  58. package/src/client/router.css +0 -0
  59. package/src/client/settings-nav-icon.ts +0 -0
  60. package/src/client/settings-section.tsx +0 -0
  61. package/src/client/sidebar-entry.ts +0 -0
  62. package/src/client/supplier-visibility.test.ts +0 -0
  63. package/src/client/supplier-visibility.ts +0 -0
  64. package/src/client/workspace-controller.ts +0 -0
  65. package/src/client/workspace-mount.tsx +0 -0
  66. package/src/credential-store.ts +0 -0
  67. package/src/data-dir.test.ts +0 -0
  68. package/src/data-dir.ts +0 -0
  69. package/src/ext/contract.ts +0 -0
  70. package/src/ext/store.test.ts +0 -0
  71. package/src/ext/store.ts +0 -0
  72. package/src/ext-prompt/assembly.test.ts +0 -0
  73. package/src/ext-prompt/content.ts +0 -0
  74. package/src/ext-prompt/ext-prompt.test.ts +0 -0
  75. package/src/ext-prompt/index.ts +0 -0
  76. package/src/ext-prompt/locale/en.json +0 -0
  77. package/src/ext-prompt/locale/zh.json +0 -0
  78. package/src/ext-prompt/logo.svg +0 -0
  79. package/src/ext-prompt/logo.ts +0 -0
  80. package/src/ext-prompt/mount.test.ts +0 -0
  81. package/src/ext-prompt/mount.ts +0 -0
  82. package/src/ext-prompt/plugin.ts +0 -0
  83. package/src/ext-prompt/render.test.ts +0 -0
  84. package/src/ext-prompt/render.ts +0 -0
  85. package/src/ext-prompt/row.json +0 -0
  86. package/src/ext-route.test.ts +0 -0
  87. package/src/ext-test/ext-test.test.ts +0 -0
  88. package/src/ext-test/index.ts +0 -0
  89. package/src/ext-test/locale/en.json +0 -0
  90. package/src/ext-test/locale/zh.json +0 -0
  91. package/src/ext-test/plugin.ts +0 -0
  92. package/src/ext-test/row.json +0 -0
  93. package/src/host-base-url.test.ts +0 -0
  94. package/src/host-base-url.ts +0 -0
  95. package/src/host-version.test.ts +0 -0
  96. package/src/host-version.ts +0 -0
  97. package/src/index.ts +12 -1
  98. package/src/keys.ts +0 -0
  99. package/src/llm/adapter.test.ts +0 -0
  100. package/src/llm/adapter.ts +0 -0
  101. package/src/router/account-pool.test.ts +74 -0
  102. package/src/router/account-pool.ts +37 -5
  103. package/src/router/combo-change.test.ts +125 -0
  104. package/src/router/disconnect-leak.test.ts +0 -0
  105. package/src/router/first-byte-budget.test.ts +0 -0
  106. package/src/router/index.ts +43 -3
  107. package/src/router/models-cache.test.ts +0 -0
  108. package/src/router/precise-dispatch.test.ts +0 -0
  109. package/src/router/prefix-affinity.ts +0 -0
  110. package/src/router/supplier-switch.test.ts +91 -0
  111. package/src/router/types.ts +9 -1
  112. package/src/router/usage-store.ts +0 -0
  113. package/src/router/usage-tokens.ts +0 -0
  114. package/src/router/usage.test.ts +0 -0
  115. package/src/shared.ts +6 -0
  116. package/src/supplier-config.test.ts +0 -0
  117. package/src/supplier-config.ts +0 -0
  118. package/src/suppliers/contract.ts +0 -0
  119. package/src/suppliers/http-state.test.ts +0 -0
  120. package/src/suppliers/http-state.ts +0 -0
  121. package/src/suppliers/loader.test.ts +53 -0
  122. package/src/suppliers/loader.ts +0 -0
  123. package/src/suppliers/nvidia/index.ts +0 -0
  124. package/src/suppliers/nvidia/locale/en.json +0 -0
  125. package/src/suppliers/nvidia/locale/zh.json +0 -0
  126. package/src/suppliers/nvidia/plugin.ts +0 -0
  127. package/src/suppliers/nvidia/row.json +0 -0
  128. package/src/suppliers/opencode/index.ts +0 -0
  129. package/src/suppliers/opencode/locale/en.json +0 -0
  130. package/src/suppliers/opencode/locale/zh.json +0 -0
  131. package/src/suppliers/opencode/plugin.test.ts +0 -0
  132. package/src/suppliers/opencode/plugin.ts +0 -0
  133. package/src/suppliers/opencode/row.json +0 -0
  134. package/src/suppliers/openrouter/index.ts +0 -0
  135. package/src/suppliers/openrouter/locale/en.json +0 -0
  136. package/src/suppliers/openrouter/locale/zh.json +0 -0
  137. package/src/suppliers/openrouter/plugin.ts +0 -0
  138. package/src/suppliers/openrouter/row.json +0 -0
  139. package/src/suppliers/probe.test.ts +0 -0
  140. package/src/suppliers/probe.ts +0 -0
  141. package/src/suppliers/registry.test.ts +0 -0
  142. package/src/suppliers/registry.ts +0 -0
  143. package/src/types.d.ts +0 -0
@@ -218,3 +218,94 @@ test('指定不存在的连接:明确报「连接不存在」,而不是静
218
218
  assert.equal(a.calls, 0, '一个号都不该被试')
219
219
  assert.match(r.error ?? '', /不存在/)
220
220
  })
221
+
222
+ test('指定连接失败:报上游真实原因,不许说成「连接不存在」', async () => {
223
+ // 2026-10-14 实测:0 积分号(uid 在池里、面板上看得见)测出来是
224
+ // 「连接 "4135677434275728" 不存在(可能已被删除)」——真话是额度用完。
225
+ // 机制:失败后 `continue`,下一轮 onlyUid 被 `tried` 挡掉 → uid undefined,
226
+ // 旧代码在那里无条件覆盖 lastError,把上游原因冲掉。假诊断比没有诊断更坏:
227
+ // 用户会去删链接、重新登录,而正解是充值。
228
+ const { router } = rig()
229
+ const a = spy('supA', 'a-model')
230
+ a.s.accounts = () => [
231
+ { uid: 'broke', credits: 0, state: 'ok' },
232
+ { uid: 'rich', credits: 100, state: 'ok' },
233
+ ]
234
+ a.s.status = () => ({ id: 'supA', name: 'supA', accounts: a.s.accounts() })
235
+ const seen: string[] = []
236
+ a.s.chatOnce = async (uid) => {
237
+ seen.push(uid)
238
+ return { ok: false, state: 'quota', message: 'Your requests have exceeded the quota.' }
239
+ }
240
+ add(router, a.s)
241
+
242
+ const r = await router.testModel('supA', 'a-model', 'broke')
243
+ assert.equal(r.ok, false)
244
+ assert.deepEqual(seen, ['broke'], '钉死单连接,失败也不换号')
245
+ assert.match(r.error ?? '', /quota|exceeded/, `要报上游真因,实际: ${r.error}`)
246
+ assert.doesNotMatch(r.error ?? '', /不存在/, 'uid 在池里,绝不是「不存在」')
247
+ })
248
+
249
+ // ---- 积分用完的号必须退出路由(插件报 quota → 核心不选它) ----
250
+ // 端到端串起来:traework 的 pool.list() 在 credits===0 时报 quota,核心 pick()
251
+ // 据此跳过该号。这条走真实入口 chatCompletions,钉住「报出的事实真的改变了路由」,
252
+ // 而不只是「状态字符串好看」。
253
+
254
+ test('插件报 quota 的号:请求直接落到还有额度的号,不去撞死号', async () => {
255
+ const { router } = rig()
256
+ const a = spy('supA', 'a-model')
257
+ const tried: string[] = []
258
+ a.s.accounts = () => [
259
+ { uid: 'broke', credits: 0, state: 'quota' },
260
+ { uid: 'rich', credits: 500, state: 'ok' },
261
+ ]
262
+ a.s.status = () => ({ id: 'supA', name: 'supA', accounts: a.s.accounts() })
263
+ a.s.chatOnce = async (uid) => {
264
+ tried.push(uid)
265
+ return { ok: true, status: 200, body: '{"ok":1}' }
266
+ }
267
+ add(router, a.s)
268
+
269
+ await router.chatCompletions(reqWith('supA/a-model'), fakeRes())
270
+
271
+ assert.deepEqual(tried, ['rich'], '一次都不该撞到积分用完的号')
272
+ })
273
+
274
+ test('积分用完的号恢复(充值后报 ok):立刻重新参与路由,无需重启', async () => {
275
+ const { router } = rig()
276
+ const a = spy('supA', 'a-model')
277
+ let broke = true
278
+ const tried: string[] = []
279
+ a.s.accounts = () => (broke
280
+ ? [{ uid: 'was-broke', credits: 0, state: 'quota' }, { uid: 'other', credits: 10, state: 'ok' }]
281
+ : [{ uid: 'was-broke', credits: 900, state: 'ok' }])
282
+ a.s.status = () => ({ id: 'supA', name: 'supA', accounts: a.s.accounts() })
283
+ a.s.chatOnce = async (uid) => {
284
+ tried.push(uid)
285
+ return { ok: true, status: 200, body: '{"ok":1}' }
286
+ }
287
+ add(router, a.s)
288
+
289
+ await router.chatCompletions(reqWith('supA/a-model'), fakeRes())
290
+ assert.deepEqual(tried, ['other'], '没额度时不选它')
291
+
292
+ broke = false
293
+ await router.chatCompletions(reqWith('supA/a-model'), fakeRes())
294
+ assert.equal(tried[1], 'was-broke', '充值后立刻回到候选')
295
+ })
296
+
297
+ test('全池都没额度:如实失败,不假装成功也不无限转圈', async () => {
298
+ const { router } = rig()
299
+ const a = spy('supA', 'a-model')
300
+ a.s.accounts = () => [
301
+ { uid: 'b1', credits: 0, state: 'quota' },
302
+ { uid: 'b2', credits: 0, state: 'quota' },
303
+ ]
304
+ a.s.status = () => ({ id: 'supA', name: 'supA', accounts: a.s.accounts() })
305
+ a.s.chatOnce = async () => ({ ok: true, status: 200, body: '{"ok":1}' })
306
+ add(router, a.s)
307
+
308
+ const r = await router.testModel('supA', 'a-model')
309
+ assert.equal(r.ok, false, '没号可选就是失败')
310
+ assert.equal(a.calls, 0, '不该去撞任何一个已知没额度的号')
311
+ })
@@ -3,7 +3,7 @@
3
3
  * 每个供应商提供 OpenAI 兼容的 chat/models,并暴露面板状态。
4
4
  * 仿 9router:供应商 = 一种"免费/付费"通道;dsh-router 在其上做路由。
5
5
  */
6
- import type { ChatOnceResult, SupplierAccountNow } from '../suppliers/contract.ts'
6
+ import type { AccountState, ChatOnceResult, SupplierAccountNow } from '../suppliers/contract.ts'
7
7
  import type { AccountPool } from './account-pool.ts'
8
8
 
9
9
  /** 面板展示的账号状态(脱敏)。 */
@@ -11,6 +11,14 @@ export interface SupplierAccount {
11
11
  uid: string
12
12
  nickname?: string
13
13
  credits: number
14
+ /**
15
+ * 插件解读上游信号后的语义状态(`quota` = 额度/积分用完,`session_dead` = 凭证失效)。
16
+ *
17
+ * `decorate()` 一直原样带出插件报的状态,核心选号也读它(`quota`/`session_dead`
18
+ * 的号不进候选)——面板类型里缺了这一格就会自相矛盾:号明明已被排除在路由之外,
19
+ * 界面上却只看得到「0 积分」,看不出它为什么不参与。
20
+ */
21
+ state?: AccountState
14
22
  cooling: boolean
15
23
  until?: string
16
24
  reason?: string
File without changes
File without changes
File without changes
package/src/shared.ts CHANGED
@@ -25,6 +25,12 @@ export interface RouterAccount {
25
25
  uid: string
26
26
  nickname?: string
27
27
  credits: number
28
+ /**
29
+ * 插件解读上游信号后的语义状态(`quota` = 额度/积分用完,`session_dead` = 凭证失效)。
30
+ * 核心选号会跳过这些号,所以面板必须显示出来——否则界面写着「健康」,而路由
31
+ * 永远不选它,用户无从知道为什么。
32
+ */
33
+ state?: string
28
34
  cooling: boolean
29
35
  until?: string
30
36
  reason?: string
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -215,3 +215,56 @@ test('用户目录只加载 *.js,且同 id 后者覆盖前者(按文件名
215
215
  assert.equal(suppliers[0]?.supplier.name, 'Second')
216
216
  assert.equal(suppliers[0]?.source, 'user')
217
217
  })
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // 插件报的「额度用完」要真的进路由(end-to-end 的那一段线)
221
+ // ---------------------------------------------------------------------------
222
+
223
+ /**
224
+ * 造一个报 quota 的插件(模拟 traework 的 pool.list():credits===0 → state quota)。
225
+ * 这条钉住 loader 的 `accounts()` 是**原样透传**插件状态(不带 decorate/hydrate):
226
+ * 核心 pick() 读的就是它。哪天有人给它加一层加工,把 quota 抹成 ok,路由又会
227
+ * 去撞没额度的号,而面板还显示正常——所以这里必须红。
228
+ */
229
+ function quotaPlugin(id: string): SupplierModule {
230
+ const accounts: SupplierAccountNow[] = [
231
+ { uid: 'u-broke', credits: 0, state: 'quota' },
232
+ { uid: 'u-rich', credits: 500, state: 'ok' },
233
+ ]
234
+ return {
235
+ id,
236
+ name: id,
237
+ status: () => ({ id, name: id, accounts }),
238
+ listModels: () => [],
239
+ getAlias: () => id,
240
+ chatOnce: async (_uid: string, _req: ChatRequest): Promise<ChatOnceResult> => ({ ok: true, status: 200, body: '{}' }),
241
+ dispose: () => {},
242
+ } as unknown as SupplierModule
243
+ }
244
+
245
+ test('插件报的 quota 原样到达核心 → 没额度的号不进选号', () => {
246
+ const { env: e } = env()
247
+ const { supplier } = wrapModule(quotaPlugin('traework'), e, 'test')
248
+ assert.equal(
249
+ supplier.accounts().find((a) => a.uid === 'u-broke')?.state,
250
+ 'quota',
251
+ 'accounts() 必须原样带出插件状态(核心 pick 读的就是它)',
252
+ )
253
+ assert.equal(supplier.pool.pick(supplier.accounts(), [], 'm1'), 'u-rich', '额度用完的号被跳过')
254
+ })
255
+
256
+ test('额度用完的号在面板上也能看出来(decorate 不吞掉插件报的 state)', () => {
257
+ const { env: e } = env()
258
+ const { supplier } = wrapModule(quotaPlugin('traework'), e, 'test')
259
+ const shown = supplier.status().accounts.find((a) => a.uid === 'u-broke')
260
+ assert.equal(shown?.state, 'quota', '面板要能区分「没额度」与「正常」')
261
+ assert.equal(shown?.cooling, false, '还没发生过失败,就不该标成冷却')
262
+ })
263
+
264
+ test('额度用完 ≠ -1:water 后积分真值 0 照旧落进核心缓存(不冲值、不误判)', () => {
265
+ const { env: e, store } = env()
266
+ const { supplier } = wrapModule(quotaPlugin('traework'), e, 'test')
267
+ supplier.status() // 触发 hydrateCredits
268
+ assert.equal(store.get('traework').credits['u-broke'], 0, '0 是真值,要落盘')
269
+ assert.equal(store.get('traework').credits['u-rich'], 500)
270
+ })
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
package/src/types.d.ts CHANGED
File without changes