dsh-router-core 0.3.3 → 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.
package/README.md CHANGED
@@ -45,7 +45,7 @@ dsh plugin --profile web add dsh-router-core
45
45
  | 零额外进程 | 就是 DSH 插件,随 `dsh web` 启停,天然同源(`/router/api/*` 无 CORS、面板嵌在设置里)。 |
46
46
  | 供应商即插即拔 | 内置供应商随插件分发;更多供应商 = 装一个 DSH 插件(`dsh-router-*`)或放一个 js 文件到 `~/.dsh/profiles/web/suppliers/`。 |
47
47
  | 模型不内置 | 供应商只实现差异化能力,模型拉取与缓存由核心统一管,不写死、不过时。 |
48
- | 策略只写一次 | 组合回退、账号池(选号/冷却/禁用/连续错误累计)、响应写入、凭证存储、模型管理都由核心提供。供应商 js 只对**单个账号**调一次上游并报告成败,不自己遍历账号、不维护冷却表——否则每个插件都会长出一份互相不一致的实现,而核心也就无从判断「该不该换号」。 |
48
+ | 策略只写一次 | 组合回退、账号池(选号/冷却/禁用)、响应写入、凭证存储、积分持久化、模型管理都由核心提供。供应商 js 只对**单个账号**调一次上游并报告成败,不自己遍历账号、不维护冷却表、不落盘积分——否则每个插件都会长出一份互相不一致的实现,而核心也就无从判断「该不该换号」。 |
49
49
  | 凭证单库 | `auths/credentials.sqlite`,供应商凭证不透明 blob,核心统一生命周期,干净可备份。 |
50
50
  | 组合即模型 | 建好的组合自动带出为 DSH 模型目录里的 `router` provider 选项,设置 → 模型直接选组合名即可。 |
51
51
  | 用量可观测 | 面板概览看板:周期切换、汇总卡、趋势折线、Top 榜、最近请求。 |
package/docs/suppliers.md CHANGED
@@ -10,16 +10,18 @@
10
10
  | | 谁负责 |
11
11
  |---|---|
12
12
  | 组合回退、按策略选中模型 | 核心 |
13
- | **账号池**:选号、冷却、禁用、连续错误累计、遍历回退 | 核心 |
13
+ | **账号池**:选号、冷却、禁用、遍历回退 | 核心 |
14
14
  | 响应写入(状态行、SSE 头、JSON body) | 核心 |
15
15
  | 模型启用/禁用、自定义模型、别名 | 核心 |
16
16
  | 凭证存储(`env.credentials`) | 核心 |
17
+ | 积分**持久化**(`supplier-config.json` 的 `credits`) | 核心 |
17
18
  | **对单个账号调通上游**(协议、鉴权、SSE 解析/生成) | **js** |
18
- | 列模型、登录、加 key、删链接、签到、拉积分 | **js** |
19
+ | 列模型、登录、加 key、删链接、签到、拉积分(只报值,不落盘) | **js** |
19
20
 
20
- 关键推论:**js 不遍历账号、不维护冷却表、不写 `res`**。
21
+ 关键推论:**js 不遍历账号、不维护冷却表、不写 `res`、不落盘积分**。
21
22
  `chatOnce(uid, req)` 一次只服务一个账号,成败告诉核心,换不换号由核心决定。
22
23
  这样「哪个账号坏了」「为什么坏」是核心的一等信息,组合回退才做得了判断。
24
+ 积分同理:js 报值,核心存。
23
25
 
24
26
  ## 契约
25
27
 
@@ -58,6 +60,7 @@ export const priority = 10 // 数字越小越优先(免费优先)
58
60
  js 报告**此刻**观察到的账户状况,而不是把上游原始响应丢给核心解读:
59
61
 
60
62
  ```ts
63
+ // credits: 剩余额度。拿不到(含不支持积分)报 -1——0 是真值「用完了」,别拿它当未知
61
64
  { uid, nickname?, credits: number, state: AccountState, message? }
62
65
  ```
63
66
 
@@ -74,10 +77,10 @@ js 报告**此刻**观察到的账户状况,而不是把上游原始响应丢给
74
77
  | `unknown` | 说不清是什么错 |
75
78
  | `no_such_model` | **这个模型不属于本供应商**(不是账号的失败) |
76
79
 
77
- **`no_such_model` 是必须的,不能拿 `unavailable` 顶替。** 后者在核心策略里
78
- 会计一次连续错误——组合里每有一个别人家的模型,就会给无关账号攒一次错误,
79
- 攒够 3 次把它冷却 5 分钟,表现为「组合越用越没号可用」。所以「这个模型不属于我」
80
- 一定要报 `no_such_model`,核心才知道该换供应商而不是换账号。
80
+ **`no_such_model` 是必须的,不能拿 `unavailable` 顶替。** 后者在核心策略里会
81
+ 把**这个号 × 这个模型**冷掉——组合里每有一个别人家的模型,js 就会对每个账号
82
+ 都报一次,于是一个都选不出来(表现为「组合越用越没号可用」)。所以「这个模型
83
+ 不属于我」一定要报 `no_such_model`,核心才知道该换供应商而不是换账号。
81
84
 
82
85
  **js 只说「现在怎么了」,不说「该怎么办」。** 冷却多久、是否禁用、要不要换号
83
86
  都是核心的策略(见下),js 不该自己记冷却表——那样每个插件都会长出一份
@@ -85,6 +88,16 @@ js 报告**此刻**观察到的账户状况,而不是把上游原始响应丢给
85
88
 
86
89
  核心在 `status()` 之上叠加 `cooling` / `disabled` / `err_count` / `until` / `reason`
87
90
  再给面板,所以 js 不用(也不该)填这些字段。`accounts` 为空数组 = 无账号供应商。
91
+ 注意 `err_count` 现在是**限流退避等级**(反复被限流的号等级递增),不是
92
+ 「连续失败次数」——面板按等级展示,等级在成功后清零。
93
+
94
+ ### 冷却颗粒度 = (供应商, 模型, 连接)
95
+
96
+ js 只要知道这一条:**报 `rate_limit`/`transport`/`unknown` 只影响「这个号 × 这个
97
+ 模型」**,不会牵连该号上的其它模型。一个连接服务多个模型时,模型 A 被限流不该
98
+ 让模型 B 也不可用。
99
+
100
+ 例外是 `session_dead`:凭证失效是连接级问题,该号**所有模型**都停用。
88
101
 
89
102
  ## `chatOnce(uid, req)`:一次只服务一个账号
90
103
 
@@ -105,17 +118,23 @@ type ChatOnceResult =
105
118
 
106
119
  | state | 核心处置 |
107
120
  |---|---|
108
- | `rate_limit` | 冷却 1 分钟 |
121
+ | `rate_limit` | **指数退避**:首次 2s,每次翻倍,上限 5 分钟;成功后等级清零 |
109
122
  | `quota` | 冷却 10 分钟 |
110
- | `session_dead` | **永久禁用**(需重新登录才能恢复) |
111
- | `unavailable` / `transport` / `unknown` | 计一次连续错误,攒够 3 次冷却 5 分钟 |
123
+ | `session_dead` | **永久禁用该号**(所有模型都停用,需重新登录才能恢复) |
124
+ | `unavailable` / `transport` / `unknown` | 瞬时短冷却 30s(**每次都冷**,不攒次数) |
112
125
  | `no_such_model` | **不惩罚账号**,核心直接跳过整个供应商换下一个 |
113
126
 
114
127
  - 非流式若聚合后发现**空响应**(无 content / reasoning / tool_calls),请返回
115
128
  `{ ok:false, state:'unknown' }` 而不是 `ok:true`——空响应交给核心换号/换模型回退,
116
129
  比当作成功更有用
117
130
 
118
- 冷却/禁用/错误累计目前是**内存态**,重启归零(与凭证/积分的持久化不同)。
131
+ 所以 js 别报「保险」的状态:把瞬时抖动报成 `quota` 会让好号白躺 10 分钟,把
132
+ 限流报成 `unknown` 则只冷 30s、等下个请求再撞一次。网关自带业务码的(如
133
+ 「请求参数非法」「上游临时不可用」)建议归 `rate_limit`——瞬时/请求类的错,
134
+ 短冷换号比长冷划算。
135
+
136
+ 冷却/禁用/退避等级都是**内存态**,重启归零(与凭证、积分的持久化不同)。
137
+ 重启后所有号都会重新被选中一次,所以**上游确实挂了的号会各撞一次**,属预期。
119
138
  要跨重启保留,届时给账号池加一个落盘 state 文件即可,接口不用变。
120
139
 
121
140
  ### 流式一旦写出第一个字节才绑死
@@ -176,9 +195,21 @@ js 只需把失败原因记下来并通过 `lastError()` 暴露,核心就能给
176
195
  - `message` 直接透给用户看;某个 uid 抛错由核心兜住记成 `error`,不会带垮其它链接
177
196
  - 核心汇总后给面板:`{ ok, total, succeeded, already, results }`
178
197
 
179
- `credits`(`status().accounts[].credits`)用于面板显示积分/额度;上游接口不实时时
180
- 建议缓存 + 签到后刷新。**填「剩余额度」而不是「已消耗」**——额度类接口两者常常
181
- 都有,填反了面板数字会越用越多。
198
+ `credits`(`status().accounts[].credits`)用于面板显示积分/额度。**填「剩余额度」
199
+ 而不是「已消耗」**——额度类接口两者常常都有,填反了面板数字会越用越多。
200
+
201
+ ### 积分归核心持久化,js 只报值
202
+
203
+ 积分的持久化由核心统一管(存 `supplier-config.json` 的 `credits` 字段),js **不要
204
+ 自己落盘**——一份数据两处存,两边都可能过期。js 只管两件事:内存 TTL、报真值。
205
+
206
+ 报值的规矩(见上「`status()`」一节):**拿不到就报 `-1`,不是 `0`**。核心靠这个
207
+ 区分「没拿到」和「拿到了 0」,报 0 会把缓存冲成 0(`0` 是真值「用完了」)。
208
+ 不支持积分的供应商也报 `-1`,面板显示「积分未知」。
209
+
210
+ 天花板:核心存的是「最后一次拿到的值」,不带时间戳。所以插件重启后短暂显示
211
+ 上次的值,真值等异步拉完自动覆盖(面板下一次刷新即可见)。要消掉这个窗口,
212
+ 得让核心存时间戳并在过期时标旧,目前没做。
182
213
 
183
214
  上游幂等要处理好:有的上游「今日已签到」返回 **HTTP 400 + 业务 code**,
184
215
  所以要先解析 body 的 code 再判 HTTP 状态,否则幂等情况会被误报成失败。
@@ -222,8 +253,8 @@ RPC 失败,表现为「历史加载失败」。
222
253
 
223
254
  面板链接池的「刷新」按钮是核心的活,js 不用做任何事:
224
255
 
225
- 1. **积分**:核心连着调 `status()` 等它落地;`status().accounts[].credits`
226
- 该缓存就缓存,不该缓存就现拉,核心只认这个字段
256
+ 1. **积分**:核心连着调 `status()` 等它落地(最多 3s),只认
257
+ `status().accounts[].credits` 这一个字段,并统一持久化
227
258
  2. **健康**:核心拿一个启用模型跑一次最简会话(复用「测试模型」的 `chatOnce`
228
259
  路径),回答「这个供应商还有没有活着的链接」——**不按链接细分**
229
260
 
@@ -239,8 +270,9 @@ export const id = 'echo'
239
270
  export const name = 'Echo'
240
271
  export const priority = 10
241
272
 
242
- // 只报「现在状态」:冷却/禁用/错误累计由核心叠加
243
- const accounts = [{ uid: 'echo-1', nickname: 'Echo 1', credits: 100, state: 'ok' }]
273
+ // 只报「现在状态」:冷却/禁用/退避等级由核心叠加,积分由核心持久化
274
+ // credits: -1 = 不支持/没拉到(别写 0,0 会被当成「用完了」存下来)
275
+ const accounts = [{ uid: 'echo-1', nickname: 'Echo 1', credits: -1, state: 'ok' }]
244
276
 
245
277
  export function status() { return { id, name, accounts } }
246
278
  export function listModels() { return [{ id: 'echo-model' }] }
@@ -278,17 +310,25 @@ export function dispose() {}
278
310
 
279
311
  单个供应商加载失败(缺 `id`/`name`、构造异常)不阻断其他供应商,错误记入 DSH 日志。
280
312
 
281
- **模型统一策略**:供应商的模型列表**优先从上游拉取、不自行缓存**——
282
- `listModels` 每次都从上游拉取(失败回退上次成功结果避免面板空模型),**缓存由
283
- 核心统一管**(`Router.modelsOf`,TTL 60s)。
313
+ **模型统一策略**:模型列表**优先从上游拉取**,缓存由核心统一管
314
+ (`Router.modelsOf`,TTL 10 分钟),js **不要自己缓存**——自己缓存一份会和核心
315
+ 那份一起过期两次,「获取模型」按钮就成了摆设。
316
+
317
+ 核心缓存是 **stale-while-revalidate**:过期后**先返回旧值**让面板立刻有内容,
318
+ 后台再重新拉。所以 `listModels` 被调用的时机是:
319
+
320
+ - **冷启动**(该供应商第一次被问,没有旧值)——**会同步等**,全程只有这一次
321
+ - **TTL 过期后的第一次**——返回旧值,`listModels` 在**后台**被调用
322
+ - **手动点「获取模型」**(`force=true`)——**会同步等**,用户要看到结果
284
323
 
285
- 这意味着 **js 的 `listModels` 会被核心频繁调用**,所以:
324
+ 因此:
286
325
 
287
- - 只有这两种场景会真的打到上游:**首次打开供应商详情**(缓存还没建)和
288
- **手动点「获取模型」**(`force=true`)。组合面板只**读缓存**,不触发拉取
289
- - 因此 `listModels` 要**快**:别在里面做全量探测之类的重活。重活放到
290
- `force=true` 时做(如按模型探测可用性),别拖慢每次只读
291
- - 失败时回退上次成功的结果,避免面板空模型
326
+ - `listModels` 要**快**:别在里面做全量探测之类的重活。重活放到 `force=true`
327
+ 时做(如按模型探测可用性)
328
+ - 冷启动那次会拖慢面板首屏(实测 0.3~1.0s/供应商,组合页取最慢那家),
329
+ **别在这条路上加重活**——这是唯一一次用户真会等到的调用
330
+ - 拉到空列表时核心会保住旧值不当成新结果,所以别用「返回空」表达
331
+ 「上游出错」;出错就抛,核心会退回旧值(首次则整个供应商剔除)
292
332
 
293
333
  `/suppliers/:id/models` 与 `/combos` 共用这份缓存(模型增删改后失效),
294
334
  `/v1/models`(LLM 侧)保持实时。
@@ -310,9 +350,10 @@ export function dispose() {}
310
350
 
311
351
  ### 为什么组合要存供应商 id 而不是裸模型 id
312
352
 
313
- 存裸 id 时,核心只能遍历所有供应商逐个试。插件用 `unavailable` 回答
314
- 「不是我的」,而这个状态在池策略里**计一次连续错误**——组合里每有一个别人家的
315
- 模型就给无关账号攒一次,攒够 3 次冷却 5 分钟,表现为「组合越用越没号可用」。
353
+ 存裸 id 时,核心只能遍历所有供应商逐个试。插件用 `unavailable` 回答「不是我的」,
354
+ 而这个状态在池策略里会把**这个号 × 这个模型**冷掉——组合里每有一个别人家的
355
+ 模型,每个账号在那模型上就都被冷一次,表现为「组合越用越没号可用」。
356
+ (`no_such_model` 是为此专门加的状态:它不冷任何东西。)
316
357
 
317
358
  存了供应商 id 就不需要问了:直接调那一个。查不到供应商(拼错/供应商没加载)
318
359
  直接失败,**不兜底遍历**——兜底会让拼错的 id 静默落到别的供应商上,更难查。
@@ -838,16 +838,20 @@ window.__ModuleLoader__.load({
838
838
  className: "dshr-linkDot",
839
839
  children: "·"
840
840
  }),
841
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
841
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
842
842
  className: "dshr-mono dshr-linkCredits",
843
- children: [Math.ceil(account.credits), " 积分"]
843
+ children: account.credits < 0 ? "积分未知" : `${Math.ceil(account.credits)} 积分`
844
844
  }),
845
845
  account.err_count !== void 0 && account.err_count > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
846
846
  className: "dshr-linkDot",
847
847
  children: "·"
848
848
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
849
849
  className: "dshr-linkErr",
850
- children: ["连续错误 ", account.err_count]
850
+ children: [
851
+ "退避 ",
852
+ account.err_count,
853
+ " 级"
854
+ ]
851
855
  })] }),
852
856
  coolingText !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
853
857
  className: "dshr-reason dshr-linkCooling",
package/lib/client.js CHANGED
@@ -838,16 +838,20 @@ window.__ModuleLoader__.load({
838
838
  className: "dshr-linkDot",
839
839
  children: "·"
840
840
  }),
841
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
841
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
842
842
  className: "dshr-mono dshr-linkCredits",
843
- children: [Math.ceil(account.credits), " 积分"]
843
+ children: account.credits < 0 ? "积分未知" : `${Math.ceil(account.credits)} 积分`
844
844
  }),
845
845
  account.err_count !== void 0 && account.err_count > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
846
846
  className: "dshr-linkDot",
847
847
  children: "·"
848
848
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
849
849
  className: "dshr-linkErr",
850
- children: ["连续错误 ", account.err_count]
850
+ children: [
851
+ "退避 ",
852
+ account.err_count,
853
+ " 级"
854
+ ]
851
855
  })] }),
852
856
  coolingText !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
853
857
  className: "dshr-reason dshr-linkCooling",
package/lib/index.js CHANGED
@@ -251,18 +251,13 @@ var AccountPool = class {
251
251
  });
252
252
  }
253
253
  };
254
- //#endregion
255
- //#region src/supplier-config.ts
256
- /**
257
- * 通用供应商配置 —— dsh-router 核心能力,按供应商 id 存储。
258
- *
259
- * 通用能力(所有供应商共享,供应商 js 不需要实现):
260
- * - 别名 alias(前缀,模型全名 = alias/id)
261
- * - 模型管理:启用/禁用 disabled、自定义 custom
262
- * - 连接池:poolOrder(拖动排序)、poolStrategy(回退/轮询)
263
- *
264
- * 持久化到 data/supplier-config.json。
265
- */
254
+ /** 读盘时的积分字段校验:只留有限非负数(负数一律当没缓存过)。 */
255
+ function readCredits(raw) {
256
+ const out = {};
257
+ if (typeof raw !== "object" || raw === null) return out;
258
+ for (const [uid, v] of Object.entries(raw)) if (typeof v === "number" && Number.isFinite(v) && v >= 0) out[uid] = v;
259
+ return out;
260
+ }
266
261
  /** 通用供应商配置存储。 */
267
262
  var SupplierConfigStore = class {
268
263
  fp = "";
@@ -292,7 +287,8 @@ var SupplierConfigStore = class {
292
287
  disabled: [],
293
288
  custom: [],
294
289
  poolOrder: [],
295
- poolStrategy: "fallback"
290
+ poolStrategy: "fallback",
291
+ credits: {}
296
292
  };
297
293
  this.bySupplier.set(supplierId, cfg);
298
294
  }
@@ -344,6 +340,32 @@ var SupplierConfigStore = class {
344
340
  this.get(supplierId).poolStrategy = strategy;
345
341
  this.saveLocked();
346
342
  }
343
+ /**
344
+ * 读缓存的积分。没缓存过返回 `CREDITS_UNKNOWN`(-1),让调用方自己决定
345
+ * 显示什么——不要把 -1 当 0 展示。
346
+ */
347
+ getCredits(supplierId, uid) {
348
+ const v = this.get(supplierId).credits[uid];
349
+ return typeof v === "number" && Number.isFinite(v) ? v : -1;
350
+ }
351
+ /**
352
+ * 合并插件报的积分:非 -1 就写入并返回它;-1(未知)则回落到上次缓存值。
353
+ * @returns 面板该显示的积分(仍可能是 -1 = 从来没拿到过)
354
+ */
355
+ putCredits(supplierId, uid, reported) {
356
+ if (typeof reported !== "number" || !Number.isFinite(reported) || reported < 0) return this.getCredits(supplierId, uid);
357
+ if (this.getCredits(supplierId, uid) === reported) return reported;
358
+ this.get(supplierId).credits[uid] = reported;
359
+ this.saveLocked();
360
+ return reported;
361
+ }
362
+ /** 删除链接时清掉它的积分缓存(不写盘:下一行 removeLink 会带出保存)。 */
363
+ clearCredits(supplierId, uid) {
364
+ const credits = this.get(supplierId).credits;
365
+ if (credits[uid] === void 0) return;
366
+ delete credits[uid];
367
+ this.saveLocked();
368
+ }
347
369
  load() {
348
370
  if (this.fp === "") return;
349
371
  let f;
@@ -357,7 +379,8 @@ var SupplierConfigStore = class {
357
379
  disabled: Array.isArray(c.disabled) ? c.disabled.filter((m) => typeof m === "string") : [],
358
380
  custom: Array.isArray(c.custom) ? c.custom.filter((m) => typeof m === "string") : [],
359
381
  poolOrder: Array.isArray(c.poolOrder) ? c.poolOrder.filter((u) => typeof u === "string") : [],
360
- poolStrategy: c.poolStrategy === "round-robin" ? "round-robin" : "fallback"
382
+ poolStrategy: c.poolStrategy === "round-robin" ? "round-robin" : "fallback",
383
+ credits: readCredits(c.credits)
361
384
  });
362
385
  }
363
386
  saveLocked() {
@@ -368,7 +391,8 @@ var SupplierConfigStore = class {
368
391
  disabled: [...cfg.disabled],
369
392
  custom: [...cfg.custom],
370
393
  poolOrder: [...cfg.poolOrder],
371
- poolStrategy: cfg.poolStrategy
394
+ poolStrategy: cfg.poolStrategy,
395
+ credits: { ...cfg.credits }
372
396
  };
373
397
  try {
374
398
  const dir = dirname(this.fp);
@@ -1055,8 +1079,15 @@ function withEstimates(usage, requestChars, outputChars) {
1055
1079
  * 仿 9router:组合 = 一组模型,请求 model 命中组合名时,按策略
1056
1080
  * (fallback 顺序尝试 / round-robin 轮转)选中一个模型路由。
1057
1081
  */
1058
- /** 模型列表缓存有效期。 */
1059
- const MODELS_TTL_MS = 6e4;
1082
+ /**
1083
+ * 模型列表缓存有效期。
1084
+ *
1085
+ * 取 10 分钟而不是 60s:模型列表本来很少变(上游上新/下线才动),而每次
1086
+ * TTL 过期都会穿透到插件真打一次上游——实测冷路径 0.3~1.0s/供应商,组合页
1087
+ * 取最慢那家就是 1s+ 的白屏。拉长 TTL 直接把这个穿透频率降一个量级;真要
1088
+ * 立刻看新模型,点「获取模型」走 force 即可。
1089
+ */
1090
+ const MODELS_TTL_MS = 6e5;
1060
1091
  function openAIError(code, msg) {
1061
1092
  return { error: {
1062
1093
  message: msg,
@@ -1426,6 +1457,11 @@ var Router = class {
1426
1457
  store;
1427
1458
  /** 模型列表缓存(supplierId → 模型 + 拉取时间)。插件只管拉,不缓存。 */
1428
1459
  modelsCache = /* @__PURE__ */ new Map();
1460
+ /**
1461
+ * 正在拉模型的供应商 → in-flight promise。
1462
+ * 组合页会同时从多个入口触发同一个供应商的刷新,不去重上游就被按 N 倍打。
1463
+ */
1464
+ modelsInflight = /* @__PURE__ */ new Map();
1429
1465
  /** 请求日志出口(面板/宿主 logger)。 */
1430
1466
  log;
1431
1467
  /** 用量统计(概览看板)。 */
@@ -1535,40 +1571,74 @@ var Router = class {
1535
1571
  * 取某供应商的模型(核心统一缓存)。
1536
1572
  *
1537
1573
  * 模型列表只在这里缓存一次:`/suppliers/:id/models`(供应商详情)和
1538
- * `/combos`(组合加模型)共用。组合面板**不主动打上游**——它只是读缓存,
1539
- * 冷启动(缓存还没建)时才拉一次;真正刷新由详情页打开或「获取模型」
1540
- * 按钮(force)触发。
1574
+ * `/combos`(组合加模型)共用。
1541
1575
  *
1542
- * 天花板:TTL 60s 是拍的。若上游模型列表变更很频繁,可调小;要彻底实时
1543
- * 就得让供应商暴露 etag/版本号,目前没有这个需求。
1576
+ * **过期后走 stale-while-revalidate**:先返回旧值让面板立刻有内容,后台
1577
+ * 重新拉,拉完下次请求就是新的。为什么必须这样:插件的 `listModels` 大多
1578
+ * 无条件打上游(实测冷路径 0.3~1.0s/供应商),而组合页要拉全部供应商、
1579
+ * 取最慢那家——同步等就是 1s+ 的白屏。旧值只可能「少列了新模型」,
1580
+ * 比白屏划算;想立刻看新模型点「获取模型」(force)即可。
1581
+ *
1582
+ * 只有两种情况会真的等:`force`(用户主动刷新)和**从来没有过缓存值**
1583
+ * (冷启动,没有旧值可给)。后者只发生一次。
1544
1584
  */
1545
1585
  async modelsOf(supplierId, force = false) {
1546
1586
  const s = this.suppliers.find((x) => x.id === supplierId);
1547
1587
  if (s === void 0) return [];
1548
1588
  const hit = this.modelsCache.get(supplierId);
1549
- if (!force && hit !== void 0 && Date.now() - hit.fetchedAt < MODELS_TTL_MS) return hit.models;
1550
- const cfg = this.store.get(supplierId);
1551
- const custom = new Set(cfg.custom);
1552
- const disabled = new Set(cfg.disabled);
1553
- const models = (await Promise.resolve(s.listModels())).map((mm) => ({
1554
- ...mm,
1555
- enabled: !disabled.has(mm.id),
1556
- custom: custom.has(mm.id) ? true : void 0
1557
- }));
1558
- const seen = new Set(models.map((mm) => mm.id));
1559
- for (const id of custom) if (!seen.has(id)) {
1560
- seen.add(id);
1561
- models.push({
1562
- id,
1563
- enabled: !disabled.has(id),
1564
- custom: true
1565
- });
1589
+ const fresh = hit !== void 0 && Date.now() - hit.fetchedAt < MODELS_TTL_MS;
1590
+ if (!force && fresh) return hit.models;
1591
+ if (!force && hit !== void 0) {
1592
+ this.refreshModels(supplierId, s).catch(() => {});
1593
+ return hit.models;
1566
1594
  }
1567
- this.modelsCache.set(supplierId, {
1568
- models,
1569
- fetchedAt: Date.now()
1595
+ return await this.refreshModels(supplierId, s);
1596
+ }
1597
+ /**
1598
+ * 真拉一次上游并写缓存。并发调用共享同一个 in-flight promise——
1599
+ * 组合页会同时触发多个入口,不去重会把上游按 N 倍打(各家插件自己没做这层)。
1600
+ *
1601
+ * 失败时:**有旧值就退回旧值**(宁可显示几分钟前的列表,也别让面板空掉),
1602
+ * **没旧值就把错误抛回去**——调用方(`supplierModels`)据此把该供应商整个
1603
+ * 剔除,而不是显示一个「有 0 个模型」的空壳。
1604
+ */
1605
+ refreshModels(supplierId, s) {
1606
+ const inflight = this.modelsInflight.get(supplierId);
1607
+ if (inflight !== void 0) return inflight;
1608
+ const p = (async () => {
1609
+ const cfg = this.store.get(supplierId);
1610
+ const custom = new Set(cfg.custom);
1611
+ const disabled = new Set(cfg.disabled);
1612
+ const models = (await Promise.resolve(s.listModels())).map((mm) => ({
1613
+ ...mm,
1614
+ enabled: !disabled.has(mm.id),
1615
+ custom: custom.has(mm.id) ? true : void 0
1616
+ }));
1617
+ const seen = new Set(models.map((mm) => mm.id));
1618
+ for (const id of custom) if (!seen.has(id)) {
1619
+ seen.add(id);
1620
+ models.push({
1621
+ id,
1622
+ enabled: !disabled.has(id),
1623
+ custom: true
1624
+ });
1625
+ }
1626
+ const stale = this.modelsCache.get(supplierId);
1627
+ if (models.length === 0 && stale !== void 0 && stale.models.length > 0) return stale.models;
1628
+ this.modelsCache.set(supplierId, {
1629
+ models,
1630
+ fetchedAt: Date.now()
1631
+ });
1632
+ return models;
1633
+ })().catch((err) => {
1634
+ const stale = this.modelsCache.get(supplierId);
1635
+ if (stale !== void 0) return stale.models;
1636
+ throw err instanceof Error ? err : new Error(String(err));
1637
+ }).finally(() => {
1638
+ this.modelsInflight.delete(supplierId);
1570
1639
  });
1571
- return models;
1640
+ this.modelsInflight.set(supplierId, p);
1641
+ return p;
1572
1642
  }
1573
1643
  /** 失效某供应商的模型缓存(增删改模型后调用)。 */
1574
1644
  invalidateModels(supplierId) {
@@ -1925,8 +1995,9 @@ var Router = class {
1925
1995
  })
1926
1996
  };
1927
1997
  let served = false;
1998
+ const trace = { attempts: 0 };
1928
1999
  try {
1929
- served = await this.chatWithSupplier(s, req, sink);
2000
+ served = await this.chatWithSupplier(s, req, sink, trace);
1930
2001
  } catch (err) {
1931
2002
  return {
1932
2003
  ok: false,
@@ -1935,7 +2006,7 @@ var Router = class {
1935
2006
  }
1936
2007
  if (served && sink.status() < 400) return { ok: true };
1937
2008
  const fromSink = served && sink.status() >= 400 ? extractUpstreamError(sink.body()) : "";
1938
- const detail = fromSink !== "" ? fromSink : s.lastError?.();
2009
+ const detail = fromSink !== "" ? fromSink : trace.lastError;
1939
2010
  return {
1940
2011
  ok: false,
1941
2012
  error: detail !== void 0 && detail !== "" ? `${detail}(账号/额度/限流问题,非模型问题)` : "所有账号都失败或账号都在冷却中(稍后重试)"
@@ -2526,6 +2597,26 @@ var KeysStore = class {
2526
2597
  function isFn(v) {
2527
2598
  return typeof v === "function";
2528
2599
  }
2600
+ /**
2601
+ * 积分回灌(核心统一持久化,插件不落盘)。
2602
+ *
2603
+ * 插件的积分是异步拉的:重启后第一次 status() 拿不到真值,报 `CREDITS_UNKNOWN`
2604
+ * (-1)。这里做两件事:
2605
+ * - 报了真值 → 写入 supplier-config.json(下次重启还在)
2606
+ * - 报了 -1 → 用上次存的顶上,面板不会闪 0
2607
+ *
2608
+ * 插件不该自己把 0 当「未知」报上来 —— 0 是真值(用完了),核心分不清,
2609
+ * 会把缓存冲成 0。这条是本层存在的理由(2026-09-03)。
2610
+ */
2611
+ function hydrateCredits(supplierId, store, accounts) {
2612
+ return accounts.map((a) => {
2613
+ const merged = store.putCredits(supplierId, a.uid, a.credits);
2614
+ return merged === a.credits ? a : {
2615
+ ...a,
2616
+ credits: merged
2617
+ };
2618
+ });
2619
+ }
2529
2620
  /** 从模块导出解析出供应商模块对象(支持 export default / 命名导出 / factory)。 */
2530
2621
  function resolveModule(mod, file) {
2531
2622
  const cand = mod.default ?? mod;
@@ -2579,7 +2670,7 @@ function wrapModule(instance, env, source, kind = "external") {
2579
2670
  return {
2580
2671
  id: now.id,
2581
2672
  name: now.name,
2582
- accounts: pool.decorate(now.accounts)
2673
+ accounts: pool.decorate(hydrateCredits(instance.id, env.store, now.accounts))
2583
2674
  };
2584
2675
  },
2585
2676
  listModels: (force) => instance.listModels(force),
@@ -2597,7 +2688,6 @@ function wrapModule(instance, env, source, kind = "external") {
2597
2688
  customModelIds: () => [...env.store.get(instance.id).custom],
2598
2689
  accounts: () => instance.status().accounts,
2599
2690
  chatOnce: (uid, req) => instance.chatOnce(uid, req),
2600
- lastError: isFn(instance.lastError) ? () => instance.lastError() : void 0,
2601
2691
  removeLink: isFn(instance.removeLink) ? (uid) => instance.removeLink(uid) : void 0,
2602
2692
  dispose: () => instance.dispose(),
2603
2693
  pool
@@ -2970,8 +3060,9 @@ function supplierRoutes(base, loaded, store, router) {
2970
3060
  });
2971
3061
  return;
2972
3062
  }
2973
- const body = JSON.parse(await readBody$1(req));
2974
- const ok = await s.removeLink(body.uid ?? "");
3063
+ const uid = JSON.parse(await readBody$1(req)).uid ?? "";
3064
+ const ok = await s.removeLink(uid);
3065
+ if (ok) store.clearCredits(s.id, uid);
2975
3066
  writeJson$1(res, ok ? 200 : 404, ok ? { ok: true } : {
2976
3067
  ok: false,
2977
3068
  error: "link not found"
@@ -13,6 +13,11 @@ export declare class Router {
13
13
  private store;
14
14
  /** 模型列表缓存(supplierId → 模型 + 拉取时间)。插件只管拉,不缓存。 */
15
15
  private modelsCache;
16
+ /**
17
+ * 正在拉模型的供应商 → in-flight promise。
18
+ * 组合页会同时从多个入口触发同一个供应商的刷新,不去重上游就被按 N 倍打。
19
+ */
20
+ private modelsInflight;
16
21
  /** 请求日志出口(面板/宿主 logger)。 */
17
22
  private log;
18
23
  /** 用量统计(概览看板)。 */
@@ -50,14 +55,27 @@ export declare class Router {
50
55
  * 取某供应商的模型(核心统一缓存)。
51
56
  *
52
57
  * 模型列表只在这里缓存一次:`/suppliers/:id/models`(供应商详情)和
53
- * `/combos`(组合加模型)共用。组合面板**不主动打上游**——它只是读缓存,
54
- * 冷启动(缓存还没建)时才拉一次;真正刷新由详情页打开或「获取模型」
55
- * 按钮(force)触发。
58
+ * `/combos`(组合加模型)共用。
59
+ *
60
+ * **过期后走 stale-while-revalidate**:先返回旧值让面板立刻有内容,后台
61
+ * 重新拉,拉完下次请求就是新的。为什么必须这样:插件的 `listModels` 大多
62
+ * 无条件打上游(实测冷路径 0.3~1.0s/供应商),而组合页要拉全部供应商、
63
+ * 取最慢那家——同步等就是 1s+ 的白屏。旧值只可能「少列了新模型」,
64
+ * 比白屏划算;想立刻看新模型点「获取模型」(force)即可。
56
65
  *
57
- * 天花板:TTL 60s 是拍的。若上游模型列表变更很频繁,可调小;要彻底实时
58
- * 就得让供应商暴露 etag/版本号,目前没有这个需求。
66
+ * 只有两种情况会真的等:`force`(用户主动刷新)和**从来没有过缓存值**
67
+ * (冷启动,没有旧值可给)。后者只发生一次。
59
68
  */
60
69
  modelsOf(supplierId: string, force?: boolean): Promise<ModelWithEnabled[]>;
70
+ /**
71
+ * 真拉一次上游并写缓存。并发调用共享同一个 in-flight promise——
72
+ * 组合页会同时触发多个入口,不去重会把上游按 N 倍打(各家插件自己没做这层)。
73
+ *
74
+ * 失败时:**有旧值就退回旧值**(宁可显示几分钟前的列表,也别让面板空掉),
75
+ * **没旧值就把错误抛回去**——调用方(`supplierModels`)据此把该供应商整个
76
+ * 剔除,而不是显示一个「有 0 个模型」的空壳。
77
+ */
78
+ private refreshModels;
61
79
  /** 失效某供应商的模型缓存(增删改模型后调用)。 */
62
80
  invalidateModels(supplierId: string): void;
63
81
  /** 可用模型(按供应商分组,仅启用),面板加模型用。
@@ -72,8 +72,6 @@ export interface Supplier {
72
72
  accounts(): SupplierAccountNow[];
73
73
  /** 对单个账号调一次上游。不遍历账号、不管冷却、不写 res。 */
74
74
  chatOnce(uid: string, req: ChatRequest): Promise<ChatOnceResult>;
75
- /** 上次 chatOnce 失败原因(诊断用,可选)。 */
76
- lastError?(): string | undefined;
77
75
  /** 删除链接(清理凭证;内部能力,js 契约不要求实现)。 */
78
76
  removeLink?(uid: string): Promise<boolean>;
79
77
  dispose(): void;
@@ -1,10 +1,21 @@
1
1
  export type PoolStrategy = 'fallback' | 'round-robin';
2
+ /**
3
+ * 积分哨兵:插件还**没拿到过**真实额度(刚重启、积分拉取失败、或压根不支持
4
+ * 积分)时报 `-1`,核心据此保留上一次的缓存值,而不是把 0 写进去。
5
+ *
6
+ * 为什么不能用 0 表示未知:积分 0 是有意义的真值(用完了),核心无法区分
7
+ * 「拿到 0」和「没拿到」。用 0 当哨兵会把缓存冲成 0 —— 这正是 codebuddy
8
+ * 重启后面板永久显示 0 积分的原因(插件只在内存里缓存积分)。
9
+ */
10
+ export declare const CREDITS_UNKNOWN = -1;
2
11
  export interface SupplierConfig {
3
12
  alias: string;
4
13
  disabled: string[];
5
14
  custom: string[];
6
15
  poolOrder: string[];
7
16
  poolStrategy: PoolStrategy;
17
+ /** 积分缓存:uid → 剩余额度(最后一次从插件拿到的非 -1 值)。 */
18
+ credits: Record<string, number>;
8
19
  }
9
20
  /** 通用供应商配置存储。 */
10
21
  export declare class SupplierConfigStore {
@@ -34,6 +45,18 @@ export declare class SupplierConfigStore {
34
45
  removeCustomModel(supplierId: string, modelId: string): void;
35
46
  setPoolOrder(supplierId: string, uids: string[]): void;
36
47
  setPoolStrategy(supplierId: string, strategy: string): void;
48
+ /**
49
+ * 读缓存的积分。没缓存过返回 `CREDITS_UNKNOWN`(-1),让调用方自己决定
50
+ * 显示什么——不要把 -1 当 0 展示。
51
+ */
52
+ getCredits(supplierId: string, uid: string): number;
53
+ /**
54
+ * 合并插件报的积分:非 -1 就写入并返回它;-1(未知)则回落到上次缓存值。
55
+ * @returns 面板该显示的积分(仍可能是 -1 = 从来没拿到过)
56
+ */
57
+ putCredits(supplierId: string, uid: string, reported: number): number;
58
+ /** 删除链接时清掉它的积分缓存(不写盘:下一行 removeLink 会带出保存)。 */
59
+ clearCredits(supplierId: string, uid: string): void;
37
60
  private load;
38
61
  private saveLocked;
39
62
  }