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 +1 -1
- package/docs/suppliers.md +71 -30
- package/lib/client-registry.js +7 -3
- package/lib/client.js +7 -3
- package/lib/index.js +140 -49
- package/lib/types/router/index.d.ts +23 -5
- package/lib/types/router/types.d.ts +0 -2
- package/lib/types/supplier-config.d.ts +23 -0
- package/lib/types/suppliers/contract.d.ts +9 -6
- package/package.json +1 -1
- package/src/client/SupplierDetail.tsx +7 -2
- package/src/router/index.ts +88 -28
- package/src/router/models-cache.test.ts +137 -2
- package/src/router/types.ts +0 -2
- package/src/supplier-config.test.ts +98 -0
- package/src/supplier-config.ts +60 -1
- package/src/suppliers/contract.ts +9 -9
- package/src/suppliers/loader.test.ts +118 -0
- package/src/suppliers/loader.ts +19 -4
- package/src/suppliers/nvidia/plugin.ts +12 -15
- package/src/suppliers/opencode/plugin.ts +10 -13
- package/src/suppliers/openrouter/plugin.ts +12 -15
- package/src/suppliers/registry.ts +4 -1
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
|
-
| 策略只写一次 | 组合回退、账号池(
|
|
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
|
-
|
|
80
|
-
|
|
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` |
|
|
121
|
+
| `rate_limit` | **指数退避**:首次 2s,每次翻倍,上限 5 分钟;成功后等级清零 |
|
|
109
122
|
| `quota` | 冷却 10 分钟 |
|
|
110
|
-
| `session_dead` |
|
|
111
|
-
| `unavailable` / `transport` / `unknown` |
|
|
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()`
|
|
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
|
-
|
|
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
|
-
`
|
|
283
|
-
|
|
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
|
-
|
|
324
|
+
因此:
|
|
286
325
|
|
|
287
|
-
-
|
|
288
|
-
|
|
289
|
-
-
|
|
290
|
-
|
|
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
|
-
|
|
353
|
+
存裸 id 时,核心只能遍历所有供应商逐个试。插件用 `unavailable` 回答「不是我的」,
|
|
354
|
+
而这个状态在池策略里会把**这个号 × 这个模型**冷掉——组合里每有一个别人家的
|
|
355
|
+
模型,每个账号在那模型上就都被冷一次,表现为「组合越用越没号可用」。
|
|
356
|
+
(`no_such_model` 是为此专门加的状态:它不冷任何东西。)
|
|
316
357
|
|
|
317
358
|
存了供应商 id 就不需要问了:直接调那一个。查不到供应商(拼错/供应商没加载)
|
|
318
359
|
直接失败,**不兜底遍历**——兜底会让拼错的 id 静默落到别的供应商上,更难查。
|
package/lib/client-registry.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.
|
|
841
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
842
842
|
className: "dshr-mono dshr-linkCredits",
|
|
843
|
-
children:
|
|
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: [
|
|
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.
|
|
841
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
842
842
|
className: "dshr-mono dshr-linkCredits",
|
|
843
|
-
children:
|
|
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: [
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
1543
|
-
*
|
|
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
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
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.
|
|
1568
|
-
|
|
1569
|
-
|
|
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
|
-
|
|
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 :
|
|
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
|
|
2974
|
-
const ok = await s.removeLink(
|
|
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
|
-
*
|
|
58
|
+
* `/combos`(组合加模型)共用。
|
|
59
|
+
*
|
|
60
|
+
* **过期后走 stale-while-revalidate**:先返回旧值让面板立刻有内容,后台
|
|
61
|
+
* 重新拉,拉完下次请求就是新的。为什么必须这样:插件的 `listModels` 大多
|
|
62
|
+
* 无条件打上游(实测冷路径 0.3~1.0s/供应商),而组合页要拉全部供应商、
|
|
63
|
+
* 取最慢那家——同步等就是 1s+ 的白屏。旧值只可能「少列了新模型」,
|
|
64
|
+
* 比白屏划算;想立刻看新模型点「获取模型」(force)即可。
|
|
56
65
|
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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
|
}
|