dsh-router-core 0.3.2 → 0.3.4

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 (37) hide show
  1. package/README.md +6 -2
  2. package/docs/suppliers.md +87 -30
  3. package/lib/client-registry.js +20 -11
  4. package/lib/client.js +20 -11
  5. package/lib/index.js +751 -264
  6. package/lib/types/llm/adapter.d.ts +7 -1
  7. package/lib/types/router/account-pool.d.ts +54 -19
  8. package/lib/types/router/index.d.ts +28 -5
  9. package/lib/types/router/types.d.ts +0 -2
  10. package/lib/types/router/usage-store.d.ts +20 -3
  11. package/lib/types/shared.d.ts +2 -0
  12. package/lib/types/supplier-config.d.ts +23 -0
  13. package/lib/types/suppliers/contract.d.ts +9 -6
  14. package/package.json +1 -1
  15. package/src/client/RouterView.tsx +1 -1
  16. package/src/client/SupplierDetail.tsx +15 -10
  17. package/src/llm/adapter.test.ts +404 -1
  18. package/src/llm/adapter.ts +242 -34
  19. package/src/router/account-pool.test.ts +163 -61
  20. package/src/router/account-pool.ts +200 -68
  21. package/src/router/index.ts +225 -66
  22. package/src/router/models-cache.test.ts +224 -6
  23. package/src/router/precise-dispatch.test.ts +110 -7
  24. package/src/router/types.ts +0 -2
  25. package/src/router/usage-store.ts +174 -74
  26. package/src/router/usage.test.ts +115 -0
  27. package/src/shared.ts +2 -0
  28. package/src/supplier-config.test.ts +98 -0
  29. package/src/supplier-config.ts +60 -1
  30. package/src/suppliers/contract.ts +9 -9
  31. package/src/suppliers/loader.test.ts +118 -0
  32. package/src/suppliers/loader.ts +21 -5
  33. package/src/suppliers/nvidia/plugin.ts +12 -15
  34. package/src/suppliers/opencode/plugin.ts +10 -13
  35. package/src/suppliers/openrouter/plugin.ts +12 -15
  36. package/src/suppliers/registry.test.ts +23 -3
  37. package/src/suppliers/registry.ts +11 -3
@@ -5,6 +5,7 @@
5
5
  * - 别名 alias(前缀,模型全名 = alias/id)
6
6
  * - 模型管理:启用/禁用 disabled、自定义 custom
7
7
  * - 连接池:poolOrder(拖动排序)、poolStrategy(回退/轮询)
8
+ * - 积分缓存 credits:按 supplier/uid 缓存最后一次拿到的剩余额度
8
9
  *
9
10
  * 持久化到 data/supplier-config.json。
10
11
  */
@@ -13,18 +14,40 @@ import { dirname, join } from 'node:path'
13
14
 
14
15
  export type PoolStrategy = 'fallback' | 'round-robin'
15
16
 
17
+ /**
18
+ * 积分哨兵:插件还**没拿到过**真实额度(刚重启、积分拉取失败、或压根不支持
19
+ * 积分)时报 `-1`,核心据此保留上一次的缓存值,而不是把 0 写进去。
20
+ *
21
+ * 为什么不能用 0 表示未知:积分 0 是有意义的真值(用完了),核心无法区分
22
+ * 「拿到 0」和「没拿到」。用 0 当哨兵会把缓存冲成 0 —— 这正是 codebuddy
23
+ * 重启后面板永久显示 0 积分的原因(插件只在内存里缓存积分)。
24
+ */
25
+ export const CREDITS_UNKNOWN = -1
26
+
16
27
  export interface SupplierConfig {
17
28
  alias: string
18
29
  disabled: string[]
19
30
  custom: string[]
20
31
  poolOrder: string[]
21
32
  poolStrategy: PoolStrategy
33
+ /** 积分缓存:uid → 剩余额度(最后一次从插件拿到的非 -1 值)。 */
34
+ credits: Record<string, number>
22
35
  }
23
36
 
24
37
  interface ConfigFile {
25
38
  suppliers: Record<string, Partial<SupplierConfig>>
26
39
  }
27
40
 
41
+ /** 读盘时的积分字段校验:只留有限非负数(负数一律当没缓存过)。 */
42
+ function readCredits(raw: unknown): Record<string, number> {
43
+ const out: Record<string, number> = {}
44
+ if (typeof raw !== 'object' || raw === null) return out
45
+ for (const [uid, v] of Object.entries(raw as Record<string, unknown>)) {
46
+ if (typeof v === 'number' && Number.isFinite(v) && v >= 0) out[uid] = v
47
+ }
48
+ return out
49
+ }
50
+
28
51
  /** 通用供应商配置存储。 */
29
52
  export class SupplierConfigStore {
30
53
  private fp = ''
@@ -55,7 +78,7 @@ export class SupplierConfigStore {
55
78
  get(supplierId: string): SupplierConfig {
56
79
  let cfg = this.bySupplier.get(supplierId)
57
80
  if (!cfg) {
58
- cfg = { alias: '', disabled: [], custom: [], poolOrder: [], poolStrategy: 'fallback' }
81
+ cfg = { alias: '', disabled: [], custom: [], poolOrder: [], poolStrategy: 'fallback', credits: {} }
59
82
  this.bySupplier.set(supplierId, cfg)
60
83
  }
61
84
  return cfg
@@ -116,6 +139,40 @@ export class SupplierConfigStore {
116
139
  this.saveLocked()
117
140
  }
118
141
 
142
+ // ---- 积分缓存(核心统一持久化,插件只管报) ----
143
+
144
+ /**
145
+ * 读缓存的积分。没缓存过返回 `CREDITS_UNKNOWN`(-1),让调用方自己决定
146
+ * 显示什么——不要把 -1 当 0 展示。
147
+ */
148
+ getCredits(supplierId: string, uid: string): number {
149
+ const v = this.get(supplierId).credits[uid]
150
+ return typeof v === 'number' && Number.isFinite(v) ? v : CREDITS_UNKNOWN
151
+ }
152
+
153
+ /**
154
+ * 合并插件报的积分:非 -1 就写入并返回它;-1(未知)则回落到上次缓存值。
155
+ * @returns 面板该显示的积分(仍可能是 -1 = 从来没拿到过)
156
+ */
157
+ putCredits(supplierId: string, uid: string, reported: number): number {
158
+ if (typeof reported !== 'number' || !Number.isFinite(reported) || reported < 0) {
159
+ return this.getCredits(supplierId, uid)
160
+ }
161
+ const prev = this.getCredits(supplierId, uid)
162
+ if (prev === reported) return reported // 值没变,不写盘(status() 是高频调用)
163
+ this.get(supplierId).credits[uid] = reported
164
+ this.saveLocked()
165
+ return reported
166
+ }
167
+
168
+ /** 删除链接时清掉它的积分缓存(不写盘:下一行 removeLink 会带出保存)。 */
169
+ clearCredits(supplierId: string, uid: string): void {
170
+ const credits = this.get(supplierId).credits
171
+ if (credits[uid] === undefined) return
172
+ delete credits[uid]
173
+ this.saveLocked()
174
+ }
175
+
119
176
  private load(): void {
120
177
  if (this.fp === '') return
121
178
  let f: ConfigFile
@@ -131,6 +188,7 @@ export class SupplierConfigStore {
131
188
  custom: Array.isArray(c.custom) ? c.custom.filter((m) => typeof m === 'string') : [],
132
189
  poolOrder: Array.isArray(c.poolOrder) ? c.poolOrder.filter((u) => typeof u === 'string') : [],
133
190
  poolStrategy: c.poolStrategy === 'round-robin' ? 'round-robin' : 'fallback',
191
+ credits: readCredits(c.credits),
134
192
  })
135
193
  }
136
194
  }
@@ -145,6 +203,7 @@ export class SupplierConfigStore {
145
203
  custom: [...cfg.custom],
146
204
  poolOrder: [...cfg.poolOrder],
147
205
  poolStrategy: cfg.poolStrategy,
206
+ credits: { ...cfg.credits },
148
207
  }
149
208
  }
150
209
  try {
@@ -57,6 +57,12 @@ export type AccountState =
57
57
  export interface SupplierAccountNow {
58
58
  uid: string
59
59
  nickname?: string
60
+ /**
61
+ * 剩余额度。**拿不到就报 `-1`**,不是 0:
62
+ * 插件刚重启(内存缓存空)、积分拉取失败、或压根不支持积分时都是 `-1`。
63
+ * 核心据此保留上一次持久化下来的值 —— 报 0 会把缓存冲成 0(0 是真值
64
+ * 「用完了」,核心分不清「拿到 0」和「没拿到」)。
65
+ */
60
66
  credits: number
61
67
  /** 插件观察到的上游状态。 */
62
68
  state: AccountState
@@ -96,25 +102,19 @@ export interface SupplierModule {
96
102
  status(): SupplierStatusNow
97
103
  /** 模型来源。`force=true` 时强制刷新来源(核心「获取模型」按钮调用)。 */
98
104
  listModels(force?: boolean): Promise<ModelInfo[]> | ModelInfo[]
99
- /** 默认前缀(可被通用层 alias 覆盖)。 */
100
- getAlias(): string
101
105
  /**
102
106
  * 对**单个账号**调一次上游。插件不遍历账号、不管冷却、不写 res——
103
107
  * 选号/回退/健康判定全是核心的活。
104
108
  * 无账号供应商(如 opencode)传空 uid,忽略即可。
109
+ *
110
+ * 失败原因通过返回值的 `message` 报(核心在测试模型时直接用它做诊断提示),
111
+ * 插件不必自己存一份「上次失败」的状态。
105
112
  */
106
113
  chatOnce(uid: string, req: ChatRequest): Promise<ChatOnceResult>
107
114
  dispose(): void
108
115
 
109
116
  // ---- 差异化能力(可选,按存在性暴露端点/UI) ----
110
117
 
111
- /** 上次 chatOnce 失败的原因(诊断用)。
112
- * 测试模型由 dsh-router 核心统一走 chatOnce 路径(账号池回退/冷却自动生效),
113
- * 插件只需把失败原因暴露出来供核心汇总;不实现则测试失败时只有通用提示。 */
114
- lastError?(): string | undefined
115
-
116
- // ---- 差异化能力(可选,按存在性暴露端点/UI) ----
117
-
118
118
  /** 生成登录链接(加账号)。有它才显示连接池(账号池)。 */
119
119
  generateLoginUrl?(): string | { ok: boolean; error?: string; loginUrl?: string } | Promise<string | { ok: boolean; error?: string; loginUrl?: string }>
120
120
  /** 回调完成登录。 */
@@ -0,0 +1,118 @@
1
+ /**
2
+ * 积分回灌(loader 层)测试 —— 锁住 2026-09-03 那个 bug:
3
+ *
4
+ * codebuddy 插件只在内存里缓存积分,重启后 status() 报 0(当时还没有 -1 哨兵),
5
+ * 核心又没有持久化 → 面板每次重启都显示 0 积分,而 traework 因为自己落盘
6
+ * state.json 所以有值。现在由核心统一持久化:插件报 -1 就顶上次的缓存。
7
+ *
8
+ * 这里测的是 wrapModule 包装后的 status()(面板真正读的那个)。
9
+ */
10
+ import test from 'node:test'
11
+ import assert from 'node:assert/strict'
12
+ import { mkdtempSync } from 'node:fs'
13
+ import { tmpdir } from 'node:os'
14
+ import { join } from 'node:path'
15
+ import { wrapModule } from './loader.ts'
16
+ import { SupplierConfigStore } from '../supplier-config.ts'
17
+ import type { CredentialStore } from '../credential-store.ts'
18
+ import type { SupplierModule, SupplierAccountNow, ChatOnceResult } from './contract.ts'
19
+ import type { ChatRequest } from '../router/types.ts'
20
+
21
+ const UNKNOWN = -1
22
+
23
+ /** 造一个 env(真实 SupplierConfigStore 落临时目录,CredentialStore 用不着的空壳)。 */
24
+ function env(): { env: Parameters<typeof wrapModule>[1]; store: SupplierConfigStore; file: string } {
25
+ const dir = mkdtempSync(join(tmpdir(), 'dshr-hydrate-'))
26
+ const stateFile = join(dir, 'state.json')
27
+ const store = new SupplierConfigStore(stateFile)
28
+ return {
29
+ env: { dataDir: dir, log: () => {}, store, credentials: {} as CredentialStore },
30
+ store,
31
+ file: join(dir, 'supplier-config.json'),
32
+ }
33
+ }
34
+
35
+ /**
36
+ * 造一个插件:第 N 次 status() 报什么由脚本决定(模拟「重启后第一次拿不到,
37
+ * 异步拉完才有值」)。
38
+ */
39
+ function plugin(id: string, script: Array<SupplierAccountNow[]>): SupplierModule & { calls: () => number } {
40
+ let n = 0
41
+ return {
42
+ calls: () => n,
43
+ id,
44
+ name: id,
45
+ // 脚本放完就停在最后一帧(模拟「第一次还没拉到 → 异步拉完有值」)
46
+ status: () => ({ id, name: id, accounts: script[Math.min(n++, script.length - 1)]! }),
47
+ listModels: () => [],
48
+ getAlias: () => id,
49
+ chatOnce: async (_uid: string, _req: ChatRequest): Promise<ChatOnceResult> => ({ ok: true, status: 200, body: '{}' }),
50
+ dispose: () => {},
51
+ } as unknown as SupplierModule & { calls: () => number }
52
+ }
53
+
54
+ const acct = (uid: string, credits: number): SupplierAccountNow => ({ uid, credits, state: 'ok' })
55
+
56
+ test('插件报 -1(刚重启还没拉到)→ 顶上次缓存,面板不显示 0', () => {
57
+ const { env: e, store } = env()
58
+ // 先跑一轮拿到真值(模拟上次运行)
59
+ const first = wrapModule(plugin('codebuddy', [[acct('cb-1', 1500)]]), e, 'test')
60
+ assert.equal(first.supplier.status().accounts[0]?.credits, 1500)
61
+
62
+ // 再换个实例(模拟重启):插件报 -1,核心应该顶出 1500
63
+ const restarted = wrapModule(plugin('codebuddy', [[acct('cb-1', UNKNOWN)]]), e, 'test')
64
+ assert.equal(restarted.supplier.status().accounts[0]?.credits, 1500)
65
+ assert.equal(store.getCredits('codebuddy', 'cb-1'), 1500)
66
+ })
67
+
68
+ test('插件报真值 → 缓存被刷新(不是一直吃旧值)', async () => {
69
+ const { env: e, store } = env()
70
+ store.putCredits('codebuddy', 'cb-1', 1500)
71
+ const loaded = wrapModule(plugin('codebuddy', [[acct('cb-1', 900)]]), e, 'test')
72
+ assert.equal(loaded.supplier.status().accounts[0]?.credits, 900)
73
+ assert.equal(store.getCredits('codebuddy', 'cb-1'), 900)
74
+ })
75
+
76
+ test('插件报 0(真用完了)→ 缓存被冲成 0,不被当成未知', async () => {
77
+ const { env: e, store } = env()
78
+ store.putCredits('codebuddy', 'cb-1', 500)
79
+ const loaded = wrapModule(plugin('codebuddy', [[acct('cb-1', 0)]]), e, 'test')
80
+ assert.equal(loaded.supplier.status().accounts[0]?.credits, 0)
81
+ assert.equal(store.getCredits('codebuddy', 'cb-1'), 0)
82
+ })
83
+
84
+ test('从没缓存过 + 插件报 -1 → 就是 -1(面板显示「积分未知」,不编 0)', async () => {
85
+ const { env: e } = env()
86
+ const loaded = wrapModule(plugin('codebuddy', [[acct('cb-1', UNKNOWN)]]), e, 'test')
87
+ assert.equal(loaded.supplier.status().accounts[0]?.credits, UNKNOWN)
88
+ })
89
+
90
+ test('异步落地:第一帧 -1(顶旧值)→ 第二帧真值(覆盖)', async () => {
91
+ const { env: e, store } = env()
92
+ store.putCredits('codebuddy', 'cb-1', 800)
93
+ // 模拟插件 fire-and-forget:第一次 status() 报 -1,异步拉完第二次报真值
94
+ const loaded = wrapModule(plugin('codebuddy', [[acct('cb-1', UNKNOWN)], [acct('cb-1', 1234)]]), e, 'test')
95
+ assert.equal(loaded.supplier.status().accounts[0]?.credits, 800)
96
+ assert.equal(loaded.supplier.status().accounts[0]?.credits, 1234)
97
+ assert.equal(store.getCredits('codebuddy', 'cb-1'), 1234)
98
+ })
99
+
100
+ test('多账号各自独立回灌', async () => {
101
+ const { env: e, store } = env()
102
+ store.putCredits('codebuddy', 'cb-1', 100)
103
+ store.putCredits('codebuddy', 'cb-2', 200)
104
+ const loaded = wrapModule(
105
+ plugin('codebuddy', [[acct('cb-1', UNKNOWN), acct('cb-2', 250)]]),
106
+ e,
107
+ 'test',
108
+ )
109
+ const by = new Map(loaded.supplier.status().accounts.map((a) => [a.uid, a.credits]))
110
+ assert.equal(by.get('cb-1'), 100) // 顶缓存
111
+ assert.equal(by.get('cb-2'), 250) // 用真值
112
+ })
113
+
114
+ test('不持久化(store fp 为空)时也不炸', async () => {
115
+ const e = { dataDir: '', log: () => {}, store: new SupplierConfigStore(''), credentials: {} as CredentialStore }
116
+ const loaded = wrapModule(plugin('codebuddy', [[acct('cb-1', 50)]]), e, 'test')
117
+ assert.equal(loaded.supplier.status().accounts[0]?.credits, 50)
118
+ })
@@ -36,6 +36,24 @@ function isFn(v: unknown): v is (...args: unknown[]) => unknown {
36
36
  return typeof v === 'function'
37
37
  }
38
38
 
39
+ /**
40
+ * 积分回灌(核心统一持久化,插件不落盘)。
41
+ *
42
+ * 插件的积分是异步拉的:重启后第一次 status() 拿不到真值,报 `CREDITS_UNKNOWN`
43
+ * (-1)。这里做两件事:
44
+ * - 报了真值 → 写入 supplier-config.json(下次重启还在)
45
+ * - 报了 -1 → 用上次存的顶上,面板不会闪 0
46
+ *
47
+ * 插件不该自己把 0 当「未知」报上来 —— 0 是真值(用完了),核心分不清,
48
+ * 会把缓存冲成 0。这条是本层存在的理由(2026-09-03)。
49
+ */
50
+ function hydrateCredits(supplierId: string, store: SupplierConfigStore, accounts: SupplierAccountNow[]): SupplierAccountNow[] {
51
+ return accounts.map((a) => {
52
+ const merged = store.putCredits(supplierId, a.uid, a.credits)
53
+ return merged === a.credits ? a : { ...a, credits: merged }
54
+ })
55
+ }
56
+
39
57
  /** 从模块导出解析出供应商模块对象(支持 export default / 命名导出 / factory)。 */
40
58
  function resolveModule(mod: Record<string, unknown>, file: string): SupplierModule {
41
59
  const cand = (mod.default ?? mod) as Record<string, unknown>
@@ -98,7 +116,8 @@ export function wrapModule(instance: SupplierModule, env: SupplierEnv, source: s
98
116
 
99
117
  // 账号池由核心持有(选号/冷却/禁用/错误累计),插件不参与。
100
118
  // 挂在 supplier 上:status() 叠加状态与 Router 的 chat 循环共用同一实例。
101
- const pool = new AccountPool()
119
+ // 冷却键带 supplierId(跨供应商同名模型不串),故构造时注入本供应商 id。
120
+ const pool = new AccountPool(instance.id)
102
121
 
103
122
  const supplier: Supplier = {
104
123
  id: instance.id,
@@ -107,7 +126,7 @@ export function wrapModule(instance: SupplierModule, env: SupplierEnv, source: s
107
126
  icon: (instance as { icon?: string }).icon,
108
127
  status: (): SupplierStatus => {
109
128
  const now = instance.status()
110
- return { id: now.id, name: now.name, accounts: pool.decorate(now.accounts) }
129
+ return { id: now.id, name: now.name, accounts: pool.decorate(hydrateCredits(instance.id, env.store, now.accounts)) }
111
130
  },
112
131
  listModels: (force?: boolean): Promise<ModelInfo[]> | ModelInfo[] => instance.listModels(force),
113
132
  // 通用能力:模型启用状态/自定义由核心 SupplierConfigStore 合并
@@ -128,9 +147,6 @@ export function wrapModule(instance: SupplierModule, env: SupplierEnv, source: s
128
147
  customModelIds: (): string[] => [...env.store.get(instance.id).custom],
129
148
  accounts: (): SupplierAccountNow[] => instance.status().accounts,
130
149
  chatOnce: (uid: string, req: ChatRequest): Promise<ChatOnceResult> => instance.chatOnce(uid, req),
131
- lastError: isFn((instance as unknown as Record<string, unknown>).lastError)
132
- ? (): string | undefined => (instance as unknown as { lastError(): string | undefined }).lastError()
133
- : undefined,
134
150
  // 删除链接:数据删除是通用能力,凭证清理由供应商内部实现(js 契约不要求)
135
151
  removeLink: isFn((instance as unknown as Record<string, unknown>).removeLink)
136
152
  ? (uid: string): Promise<boolean> => (instance as unknown as { removeLink(uid: string): Promise<boolean> }).removeLink(uid)
@@ -54,7 +54,6 @@ export default function factory(env: SupplierEnv): SupplierModule {
54
54
  // 模型缓存由 dsh-router 核心统一管;插件每次拉取,失败回退上次成功结果
55
55
  let modelsCache: ModelInfo[] | undefined
56
56
  /** 上次 chatOnce 失败原因(供核心测试模型汇总诊断)。 */
57
- let lastErr: string | undefined
58
57
  function listKeys(): string[] {
59
58
  return env.credentials.list(id)
60
59
  }
@@ -223,7 +222,6 @@ export default function factory(env: SupplierEnv): SupplierModule {
223
222
  return { id, name, accounts }
224
223
  },
225
224
  listModels: (force?: boolean): Promise<ModelInfo[]> => allModels(!!force),
226
- getAlias: (): string => id,
227
225
  async addApiKey(input: { name: string; apiKey: string }): Promise<{ ok: boolean; error?: string; account?: { uid: string; nickname: string } }> {
228
226
  const key = input.apiKey.trim()
229
227
  if (key === '') return { ok: false, error: 'API key 不能为空' }
@@ -246,18 +244,17 @@ export default function factory(env: SupplierEnv): SupplierModule {
246
244
  env.credentials.remove(id, uid)
247
245
  return true
248
246
  },
249
- lastError: (): string | undefined => lastErr,
250
247
  /** 对单个 key 调一次上游。选号/冷却/换号是核心的活,这里只报结果。 */
251
248
  async chatOnce(uid: string, req: ChatRequest): Promise<ChatOnceResult> {
252
249
  const base = stripAlias(req.model, currentAlias())
253
250
  if (!(await allModels(false)).some((m) => m.id === base)) {
254
- lastErr = `unknown model ${JSON.stringify(req.model)}`
255
- return { ok: false, state: 'no_such_model', message: lastErr }
251
+ const msg = `unknown model ${JSON.stringify(req.model)}`
252
+ return { ok: false, state: 'no_such_model', message: msg }
256
253
  }
257
254
  const acct = getKey(uid)
258
255
  if (acct === undefined) {
259
- lastErr = `unknown account ${JSON.stringify(uid)}`
260
- return { ok: false, state: 'no_such_model', message: lastErr }
256
+ const msg = `unknown account ${JSON.stringify(uid)}`
257
+ return { ok: false, state: 'no_such_model', message: msg }
261
258
  }
262
259
 
263
260
  let body = req.rawBody
@@ -278,33 +275,33 @@ export default function factory(env: SupplierEnv): SupplierModule {
278
275
  signal: AbortSignal.timeout(120000),
279
276
  })
280
277
  } catch (err) {
281
- lastErr = (err as Error).message
282
- return { ok: false, state: 'transport', message: lastErr }
278
+ const msg = (err as Error).message
279
+ return { ok: false, state: 'transport', message: msg }
283
280
  }
284
281
 
285
282
  if (upstream.status < 200 || upstream.status >= 300) {
286
283
  const text = await upstream.text().catch(() => '')
287
- lastErr = `upstream ${upstream.status}: ${text.slice(0, 120)}`
284
+ const msg = `upstream ${upstream.status}: ${text.slice(0, 120)}`
288
285
  const state: AccountState =
289
286
  upstream.status === 429 ? 'rate_limit'
290
287
  : upstream.status === 401 || upstream.status === 403 ? 'session_dead'
291
288
  : upstream.status === 404 ? 'unavailable'
292
289
  : 'unknown'
293
- return { ok: false, state, message: lastErr }
290
+ return { ok: false, state, message: msg }
294
291
  }
295
292
 
296
293
  // 流式:上游已是 OpenAI SSE,原样交回核心写
297
294
  if (req.stream) {
298
295
  if (!upstream.body) {
299
- lastErr = 'nvidia upstream: empty stream body'
300
- return { ok: false, state: 'transport', message: lastErr }
296
+ const msg = 'nvidia upstream: empty stream body'
297
+ return { ok: false, state: 'transport', message: msg }
301
298
  }
302
299
  return { ok: true, stream: upstream.body }
303
300
  }
304
301
  const text = await upstream.text().catch(() => '')
305
302
  if (text === '') {
306
- lastErr = 'nvidia upstream: empty body'
307
- return { ok: false, state: 'transport', message: lastErr }
303
+ const msg = 'nvidia upstream: empty body'
304
+ return { ok: false, state: 'transport', message: msg }
308
305
  }
309
306
  return { ok: true, status: upstream.status, body: text }
310
307
  },
@@ -30,7 +30,6 @@ export default function factory(env: SupplierEnv): SupplierModule {
30
30
  // 模型缓存由 dsh-router 核心统一管;插件每次拉取,失败回退上次成功结果
31
31
  let modelsCache: ModelInfo[] | undefined
32
32
  /** 上次 chatOnce 失败原因(供核心测试模型汇总诊断)。 */
33
- let lastErr: string | undefined
34
33
 
35
34
  async function refreshModels(): Promise<ModelInfo[]> {
36
35
  try {
@@ -81,15 +80,13 @@ export default function factory(env: SupplierEnv): SupplierModule {
81
80
  icon,
82
81
  status: (): SupplierStatusNow => ({ id, name, accounts: [] }),
83
82
  listModels: (force?: boolean): Promise<ModelInfo[]> => allModels(!!force),
84
- getAlias: (): string => id,
85
83
  // testModel 由 dsh-router 核心统一走 chatOnce 路径(无账号,无需回退)
86
- lastError: (): string | undefined => lastErr,
87
84
  /** 无账号供应商:uid 恒为空,只认 free 模型。 */
88
85
  async chatOnce(_uid: string, req: ChatRequest): Promise<ChatOnceResult> {
89
86
  // 只认 free 模型,其他模型交给别的供应商
90
87
  if (!isFreeModel(req.model)) {
91
- lastErr = `unknown free model ${JSON.stringify(req.model)}`
92
- return { ok: false, state: 'no_such_model', message: lastErr }
88
+ const msg = `unknown free model ${JSON.stringify(req.model)}`
89
+ return { ok: false, state: 'no_such_model', message: msg }
93
90
  }
94
91
  // 模型名规范化:剥 alias 前缀,body.model 用裸 id
95
92
  let body = req.rawBody
@@ -115,24 +112,24 @@ export default function factory(env: SupplierEnv): SupplierModule {
115
112
  signal: AbortSignal.timeout(120000),
116
113
  })
117
114
  } catch (err) {
118
- lastErr = `opencode upstream: ${(err as Error).message}`
119
- return { ok: false, state: 'transport', message: lastErr }
115
+ const msg = `opencode upstream: ${(err as Error).message}`
116
+ return { ok: false, state: 'transport', message: msg }
120
117
  }
121
118
 
122
119
  // 非 2xx:按状态归类,核心据此冷却/换号(本供应商无号可换,直接放弃)
123
120
  if (upstream.status < 200 || upstream.status >= 300) {
124
121
  const text = await upstream.text().catch(() => '')
125
- lastErr = `upstream ${upstream.status}: ${text.slice(0, 200)}`
122
+ const msg = `upstream ${upstream.status}: ${text.slice(0, 200)}`
126
123
  const state: AccountState =
127
124
  upstream.status === 429 ? 'rate_limit' : upstream.status === 404 ? 'unavailable' : 'unknown'
128
- return { ok: false, state, message: lastErr }
125
+ return { ok: false, state, message: msg }
129
126
  }
130
127
 
131
128
  // 流式:上游已是 OpenAI SSE,原样交回核心(核心负责写响应)
132
129
  if (req.stream) {
133
130
  if (!upstream.body) {
134
- lastErr = 'opencode upstream: empty stream body'
135
- return { ok: false, state: 'transport', message: lastErr }
131
+ const msg = 'opencode upstream: empty stream body'
132
+ return { ok: false, state: 'transport', message: msg }
136
133
  }
137
134
  return { ok: true, stream: upstream.body }
138
135
  }
@@ -140,8 +137,8 @@ export default function factory(env: SupplierEnv): SupplierModule {
140
137
  // 非流式:透传 JSON(空 body 视为上游异常)
141
138
  const text = await upstream.text().catch(() => '')
142
139
  if (text === '') {
143
- lastErr = 'opencode upstream: empty body'
144
- return { ok: false, state: 'transport', message: lastErr }
140
+ const msg = 'opencode upstream: empty body'
141
+ return { ok: false, state: 'transport', message: msg }
145
142
  }
146
143
  return { ok: true, status: upstream.status, body: text }
147
144
  },
@@ -50,7 +50,6 @@ export default function factory(env: SupplierEnv): SupplierModule {
50
50
  let modelsCache: ModelInfo[] | undefined
51
51
  let freeIds = new Set<string>()
52
52
  /** 上次 chatOnce 失败原因(供核心测试模型汇总诊断)。 */
53
- let lastErr: string | undefined
54
53
  function listKeys(): string[] {
55
54
  return env.credentials.list(id)
56
55
  }
@@ -121,7 +120,6 @@ export default function factory(env: SupplierEnv): SupplierModule {
121
120
  return { id, name, accounts }
122
121
  },
123
122
  listModels: (force?: boolean): Promise<ModelInfo[]> => allModels(!!force),
124
- getAlias: (): string => id,
125
123
  async addApiKey(input: { name: string; apiKey: string }): Promise<{ ok: boolean; error?: string; account?: { uid: string; nickname: string } }> {
126
124
  const key = input.apiKey.trim()
127
125
  if (key === '') return { ok: false, error: 'API key 不能为空' }
@@ -151,18 +149,17 @@ export default function factory(env: SupplierEnv): SupplierModule {
151
149
  env.credentials.remove(id, uid)
152
150
  return true
153
151
  },
154
- lastError: (): string | undefined => lastErr,
155
152
  /** 对单个 key 调一次上游。选号/冷却/换号是核心的活,这里只报结果。 */
156
153
  async chatOnce(uid: string, req: ChatRequest): Promise<ChatOnceResult> {
157
154
  const base = stripAlias(req.model, currentAlias())
158
155
  if (!freeIds.has(base) && !(await allModels(false)).some((m) => m.id === base)) {
159
- lastErr = `unknown free model ${JSON.stringify(req.model)}`
160
- return { ok: false, state: 'no_such_model', message: lastErr }
156
+ const msg = `unknown free model ${JSON.stringify(req.model)}`
157
+ return { ok: false, state: 'no_such_model', message: msg }
161
158
  }
162
159
  const acct = getKey(uid)
163
160
  if (acct === undefined) {
164
- lastErr = `unknown account ${JSON.stringify(uid)}`
165
- return { ok: false, state: 'no_such_model', message: lastErr }
161
+ const msg = `unknown account ${JSON.stringify(uid)}`
162
+ return { ok: false, state: 'no_such_model', message: msg }
166
163
  }
167
164
 
168
165
  let body = req.rawBody
@@ -188,34 +185,34 @@ export default function factory(env: SupplierEnv): SupplierModule {
188
185
  signal: AbortSignal.timeout(120000),
189
186
  })
190
187
  } catch (err) {
191
- lastErr = (err as Error).message
192
- return { ok: false, state: 'transport', message: lastErr }
188
+ const msg = (err as Error).message
189
+ return { ok: false, state: 'transport', message: msg }
193
190
  }
194
191
 
195
192
  if (upstream.status < 200 || upstream.status >= 300) {
196
193
  const text = await upstream.text().catch(() => '')
197
- lastErr = `upstream ${upstream.status}: ${text.slice(0, 120)}`
194
+ const msg = `upstream ${upstream.status}: ${text.slice(0, 120)}`
198
195
  // 401/403 = 这个 key 本身不可用;429 = 限流;其余归为说不清
199
196
  const state: AccountState =
200
197
  upstream.status === 429 ? 'rate_limit'
201
198
  : upstream.status === 401 || upstream.status === 403 ? 'session_dead'
202
199
  : upstream.status === 404 ? 'unavailable'
203
200
  : 'unknown'
204
- return { ok: false, state, message: lastErr }
201
+ return { ok: false, state, message: msg }
205
202
  }
206
203
 
207
204
  // 流式:上游已是 OpenAI SSE,原样交回核心写
208
205
  if (req.stream) {
209
206
  if (!upstream.body) {
210
- lastErr = 'openrouter upstream: empty stream body'
211
- return { ok: false, state: 'transport', message: lastErr }
207
+ const msg = 'openrouter upstream: empty stream body'
208
+ return { ok: false, state: 'transport', message: msg }
212
209
  }
213
210
  return { ok: true, stream: upstream.body }
214
211
  }
215
212
  const text = await upstream.text().catch(() => '')
216
213
  if (text === '') {
217
- lastErr = 'openrouter upstream: empty body'
218
- return { ok: false, state: 'transport', message: lastErr }
214
+ const msg = 'openrouter upstream: empty body'
215
+ return { ok: false, state: 'transport', message: msg }
219
216
  }
220
217
  return { ok: true, status: upstream.status, body: text }
221
218
  },
@@ -4,7 +4,10 @@
4
4
  * 锁定的契约:checkinNow 是**单账号**能力,遍历所有链接 + 汇总是核心的活:
5
5
  * - 禁用链接不签(不动它)
6
6
  * - 某个链接抛错 → 记成 error,不带垮其它链接
7
- * - succeeded / already 按 status 统计,ok = 有任一成功或今日已签
7
+ * - succeeded / already / failed 按 status 统计
8
+ * - **ok = 全部链接都成功(含已签)**;有任一失败 → HTTP 400 + ok:false,
9
+ * 让面板把失败如实亮出来(2026-09-02 修正:旧口径「有任一成功就算 ok」
10
+ * 会把部分失败吞成整体成功,UI 谎报「签到完成」)
8
11
  */
9
12
  import test from 'node:test'
10
13
  import assert from 'node:assert/strict'
@@ -84,6 +87,7 @@ interface CheckinPayload {
84
87
  total: number
85
88
  succeeded: number
86
89
  already: number
90
+ failed: number
87
91
  results?: CheckinResult[]
88
92
  error?: string
89
93
  }
@@ -120,20 +124,36 @@ test('签到:禁用链接照样签(禁用由插件自己判定,核心不
120
124
  assert.equal(body.results?.[0]?.status, 'disabled')
121
125
  })
122
126
 
123
- test('签到:单链接抛错记成 error,其余照签', async () => {
127
+ test('签到:单链接抛错记成 error 不连坐;有失败 → 400 不算整体成功', async () => {
124
128
  const { status, body } = await postCheckin(
125
129
  fixture([{ uid: 'a' }, { uid: 'b' }], async (uid) => {
126
130
  if (uid === 'a') throw new Error('boom')
127
131
  return { ok: true, status: 'ok' }
128
132
  }),
129
133
  )
130
- assert.equal(status, 200)
134
+ // 有任一失败整体就不是 ok(否则 UI 会谎报「签到完成」)——a 的错照记,不连坐 b
135
+ assert.equal(status, 400)
136
+ assert.equal(body.ok, false)
131
137
  assert.equal(body.succeeded, 1)
138
+ assert.equal(body.failed, 1)
132
139
  assert.equal(body.results?.[0]?.status, 'error')
133
140
  assert.equal(body.results?.[0]?.message, 'boom')
134
141
  assert.equal(body.results?.[1]?.status, 'ok')
135
142
  })
136
143
 
144
+ test('签到:全部成功(含 already)才算整体 ok', async () => {
145
+ const { status, body } = await postCheckin(
146
+ fixture([{ uid: 'a' }, { uid: 'b' }], async (uid) =>
147
+ uid === 'a' ? { ok: true, status: 'ok', message: '+200' } : { ok: true, status: 'already', message: '今日已签到' },
148
+ ),
149
+ )
150
+ assert.equal(status, 200)
151
+ assert.equal(body.ok, true)
152
+ assert.equal(body.succeeded, 1)
153
+ assert.equal(body.already, 1)
154
+ assert.equal(body.failed, 0)
155
+ })
156
+
137
157
  test('签到:今日已签(already)算成功,不计入 succeeded', async () => {
138
158
  const { status, body } = await postCheckin(
139
159
  fixture([{ uid: 'a' }], async () => ({ ok: true, status: 'already', message: '今日已签到' })),
@@ -327,7 +327,10 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
327
327
  return
328
328
  }
329
329
  const body = JSON.parse(await readBody(req)) as { uid?: string }
330
- const ok = await s.removeLink(body.uid ?? '')
330
+ const uid = body.uid ?? ''
331
+ const ok = await s.removeLink(uid)
332
+ // 链接没了,它的积分缓存也得跟着走(不然删号再重登会顶着一个旧数字)
333
+ if (ok) store.clearCredits(s.id, uid)
331
334
  writeJson(res, ok ? 200 : 404, ok ? { ok: true } : { ok: false, error: 'link not found' })
332
335
  },
333
336
  })
@@ -375,8 +378,13 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
375
378
  }
376
379
  const succeeded = results.filter((r) => r.status === 'ok').length
377
380
  const already = results.filter((r) => r.status === 'already').length
378
- const payload = { ok: succeeded + already > 0, total: uids.length, succeeded, already, results }
379
- writeJson(res, payload.ok ? 200 : 400, payload)
381
+ // 全部链接都成功(含已签)才算整体成功;有任一失败 → ok:false + HTTP 400,
382
+ // 让面板把失败如实亮出来。别学旧逻辑「有成功就算 ok」——部分失败被
383
+ // 当成整体成功,UI 会谎报「签到完成」(2026-09-02 实测踩到)。
384
+ const failed = results.length - succeeded - already
385
+ const ok = failed === 0 && results.length > 0
386
+ const payload = { ok, total: uids.length, succeeded, already, failed, results }
387
+ writeJson(res, ok ? 200 : 400, payload)
380
388
  },
381
389
  })
382
390
  }