dsh-router-core 0.3.14 → 0.3.16
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/docs/suppliers.md +19 -8
- package/lib/client-registry.js +2 -6
- package/lib/client.js +2 -6
- package/lib/index.js +84 -72
- package/lib/suppliers/nvidia.js +2 -1
- package/lib/suppliers/opencode.js +2 -1
- package/lib/suppliers/openrouter.js +2 -1
- package/lib/types/router/account-pool.d.ts +11 -11
- package/lib/types/router/types.d.ts +0 -1
- package/lib/types/shared.d.ts +0 -1
- package/lib/types/suppliers/contract.d.ts +16 -6
- package/package.json +1 -1
- package/src/client/RouterView.tsx +1 -1
- package/src/client/SupplierDetail.tsx +1 -2
- package/src/llm/adapter.test.ts +106 -4
- package/src/llm/adapter.ts +57 -18
- package/src/router/account-pool.test.ts +27 -10
- package/src/router/account-pool.ts +49 -45
- package/src/router/index.ts +12 -5
- package/src/router/models-cache.test.ts +0 -1
- package/src/router/types.ts +0 -1
- package/src/shared.ts +0 -1
- package/src/suppliers/contract.ts +15 -5
- package/src/suppliers/http-state.test.ts +36 -0
- package/src/suppliers/http-state.ts +31 -0
- package/src/suppliers/loader.ts +1 -1
- package/src/suppliers/nvidia/plugin.ts +2 -6
- package/src/suppliers/opencode/plugin.ts +3 -4
- package/src/suppliers/openrouter/plugin.ts +3 -7
- package/src/suppliers/registry.test.ts +3 -4
- package/src/suppliers/registry.ts +1 -1
package/docs/suppliers.md
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
| | 谁负责 |
|
|
15
15
|
|---|---|
|
|
16
16
|
| 组合回退、按策略选中模型 | 核心 |
|
|
17
|
-
|
|
|
17
|
+
| **账号池**:选号、冷却、遍历回退 | 核心 |
|
|
18
18
|
| 响应写入(状态行、SSE 头、JSON body) | 核心 |
|
|
19
19
|
| 模型启用/禁用、自定义模型、别名 | 核心 |
|
|
20
20
|
| 凭证存储(`env.credentials`) | 核心 |
|
|
@@ -75,22 +75,32 @@ js 报告**此刻**观察到的账户状况,而不是把上游原始响应丢给
|
|
|
75
75
|
| `ok` | 正常 |
|
|
76
76
|
| `rate_limit` | 限流(429 类) |
|
|
77
77
|
| `quota` | 额度/权益不足 |
|
|
78
|
-
| `session_dead` |
|
|
78
|
+
| `session_dead` | 凭证失效/凭证被上游拒(403 类,含风控误伤) |
|
|
79
79
|
| `unavailable` | 上游不可用(404 / 服务下线) |
|
|
80
80
|
| `transport` | 网络/连接层失败(没拿到 HTTP 状态) |
|
|
81
81
|
| `unknown` | 说不清是什么错 |
|
|
82
82
|
| `no_such_model` | **这个模型不属于本供应商**(不是账号的失败) |
|
|
83
|
+
| `bad_request` | **请求本身不合法,被上游拒**(参数错/图片认不出/tool_call 配对断裂;不是账号的失败) |
|
|
83
84
|
|
|
84
85
|
**`no_such_model` 是必须的,不能拿 `unavailable` 顶替。** 后者在核心策略里会
|
|
85
86
|
把**这个号 × 这个模型**冷掉——组合里每有一个别人家的模型,js 就会对每个账号
|
|
86
87
|
都报一次,于是一个都选不出来(表现为「组合越用越没号可用」)。所以「这个模型
|
|
87
88
|
不属于我」一定要报 `no_such_model`,核心才知道该换供应商而不是换账号。
|
|
88
89
|
|
|
89
|
-
|
|
90
|
+
**`bad_request` 同理,而且更容易踩。** 同一个非法请求对池里**每个号**结果都相同
|
|
91
|
+
——冷号只会把「这条请求有问题」放大成「这个模型 30 秒内谁都别用」。2026-09-15
|
|
92
|
+
读图事故就是这么来的:适配器把带图的 tool-result 排错序导致 tool_call 配对断裂,
|
|
93
|
+
上游回 400/11148,插件当时归 `rate_limit` → money 组合两条腿同时被冷 30s →
|
|
94
|
+
**之后连纯文本请求**也全灭 503。判据:上游明确说「请求参数/形态不对」(如
|
|
95
|
+
CodeBuddy 的 `extError.type = invalid_request_error`,涵盖 11133/11135/11148/
|
|
96
|
+
11128)就报 `bad_request`;只是「上游忙/临时不可用」(11134)仍归 `rate_limit`,
|
|
97
|
+
那种情况换号确实管用。
|
|
98
|
+
|
|
99
|
+
**js 只说「现在怎么了」,不说「该怎么办」。** 冷却多久、要不要换号
|
|
90
100
|
都是核心的策略(见下),js 不该自己记冷却表——那样每个插件都会长出一份
|
|
91
101
|
互相不一致的实现。
|
|
92
102
|
|
|
93
|
-
核心在 `status()` 之上叠加 `cooling` / `
|
|
103
|
+
核心在 `status()` 之上叠加 `cooling` / `err_count` / `until` / `reason`
|
|
94
104
|
再给面板,所以 js 不用(也不该)填这些字段。`accounts` 为空数组 = 无账号供应商。
|
|
95
105
|
注意 `err_count` 现在是**限流退避等级**(反复被限流的号等级递增),不是
|
|
96
106
|
「连续失败次数」——面板按等级展示,等级在成功后清零。
|
|
@@ -101,7 +111,7 @@ js 只要知道这一条:**报 `rate_limit`/`transport`/`unknown` 只影响「
|
|
|
101
111
|
模型」**,不会牵连该号上的其它模型。一个连接服务多个模型时,模型 A 被限流不该
|
|
102
112
|
让模型 B 也不可用。
|
|
103
113
|
|
|
104
|
-
例外是 `session_dead
|
|
114
|
+
例外是 `session_dead`:凭证失效是连接级问题,该号**所有模型**都停用(连接级冷却,到期自愈)。
|
|
105
115
|
|
|
106
116
|
## `chatOnce(uid, req)`:一次只服务一个账号
|
|
107
117
|
|
|
@@ -124,9 +134,10 @@ type ChatOnceResult =
|
|
|
124
134
|
|---|---|
|
|
125
135
|
| `rate_limit` | **指数退避**:首次 2s,每次翻倍,上限 5 分钟;成功后等级清零 |
|
|
126
136
|
| `quota` | 冷却 10 分钟 |
|
|
127
|
-
| `session_dead` |
|
|
137
|
+
| `session_dead` | **连接级冷却 30 分钟**(该号所有模型都停用,到期自愈;没有永久禁用——403 分不清凭证死活与风控误伤) |
|
|
128
138
|
| `unavailable` / `transport` / `unknown` | 瞬时短冷却 30s(**每次都冷**,不攒次数) |
|
|
129
139
|
| `no_such_model` | **不惩罚账号**,核心直接跳过整个供应商换下一个 |
|
|
140
|
+
| `bad_request` | **不惩罚账号**(请求非法不是号的错),核心直接跳到下一个模型/供应商 |
|
|
130
141
|
|
|
131
142
|
- 非流式若聚合后发现**空响应**(无 content / reasoning / tool_calls),请返回
|
|
132
143
|
`{ ok:false, state:'unknown' }` 而不是 `ok:true`——空响应交给核心换号/换模型回退,
|
|
@@ -137,7 +148,7 @@ type ChatOnceResult =
|
|
|
137
148
|
「请求参数非法」「上游临时不可用」)建议归 `rate_limit`——瞬时/请求类的错,
|
|
138
149
|
短冷换号比长冷划算。
|
|
139
150
|
|
|
140
|
-
|
|
151
|
+
冷却/退避等级都是**内存态**,重启归零(与凭证、积分的持久化不同)。
|
|
141
152
|
重启后所有号都会重新被选中一次,所以**上游确实挂了的号会各撞一次**,属预期。
|
|
142
153
|
要跨重启保留,届时给账号池加一个落盘 state 文件即可,接口不用变。
|
|
143
154
|
|
|
@@ -274,7 +285,7 @@ export const id = 'echo'
|
|
|
274
285
|
export const name = 'Echo'
|
|
275
286
|
export const priority = 10
|
|
276
287
|
|
|
277
|
-
//
|
|
288
|
+
// 只报「现在状态」:冷却/退避等级由核心叠加,积分由核心持久化
|
|
278
289
|
// credits: -1 = 不支持/没拉到(别写 0,0 会被当成「用完了」存下来)
|
|
279
290
|
const accounts = [{ uid: 'echo-1', nickname: 'Echo 1', credits: -1, state: 'ok' }]
|
|
280
291
|
|
package/lib/client-registry.js
CHANGED
|
@@ -116,10 +116,6 @@ window.__ModuleLoader__.load({
|
|
|
116
116
|
return `剩余 ${Math.ceil(minutes / 60)} 小时`;
|
|
117
117
|
}
|
|
118
118
|
function statusBadge(account) {
|
|
119
|
-
if (account.disabled) return {
|
|
120
|
-
text: "已禁用",
|
|
121
|
-
cls: "dshr-badge-danger"
|
|
122
|
-
};
|
|
123
119
|
if (account.cooling) return {
|
|
124
120
|
text: "冷却中",
|
|
125
121
|
cls: "dshr-badge-warn"
|
|
@@ -768,7 +764,7 @@ window.__ModuleLoader__.load({
|
|
|
768
764
|
className: "dshr-linkList",
|
|
769
765
|
children: orderedAccounts.map((account, index) => {
|
|
770
766
|
const badge = statusBadge(account);
|
|
771
|
-
const coolingText = account.cooling
|
|
767
|
+
const coolingText = account.cooling ? `${account.reason ?? ""}${untilLabel(account) !== "" ? ` · ${untilLabel(account)}` : ""}` : "";
|
|
772
768
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
773
769
|
className: `dshr-linkRow${dragIndex === index ? " dshr-linkRowDragging" : ""}`,
|
|
774
770
|
draggable: renameUid !== account.uid,
|
|
@@ -3585,7 +3581,7 @@ window.__ModuleLoader__.load({
|
|
|
3585
3581
|
className: "dshr-supplierGrid",
|
|
3586
3582
|
children: group.map((supplier) => {
|
|
3587
3583
|
const supplierAccounts = accounts.filter((a) => a.supplier === supplier.id);
|
|
3588
|
-
const healthy = supplierAccounts.filter((a) => !a.cooling
|
|
3584
|
+
const healthy = supplierAccounts.filter((a) => !a.cooling).length;
|
|
3589
3585
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
|
|
3590
3586
|
className: "dshr-supplierCard",
|
|
3591
3587
|
onClick: () => setDetailSupplier(supplier),
|
package/lib/client.js
CHANGED
|
@@ -116,10 +116,6 @@ window.__ModuleLoader__.load({
|
|
|
116
116
|
return `剩余 ${Math.ceil(minutes / 60)} 小时`;
|
|
117
117
|
}
|
|
118
118
|
function statusBadge(account) {
|
|
119
|
-
if (account.disabled) return {
|
|
120
|
-
text: "已禁用",
|
|
121
|
-
cls: "dshr-badge-danger"
|
|
122
|
-
};
|
|
123
119
|
if (account.cooling) return {
|
|
124
120
|
text: "冷却中",
|
|
125
121
|
cls: "dshr-badge-warn"
|
|
@@ -768,7 +764,7 @@ window.__ModuleLoader__.load({
|
|
|
768
764
|
className: "dshr-linkList",
|
|
769
765
|
children: orderedAccounts.map((account, index) => {
|
|
770
766
|
const badge = statusBadge(account);
|
|
771
|
-
const coolingText = account.cooling
|
|
767
|
+
const coolingText = account.cooling ? `${account.reason ?? ""}${untilLabel(account) !== "" ? ` · ${untilLabel(account)}` : ""}` : "";
|
|
772
768
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
773
769
|
className: `dshr-linkRow${dragIndex === index ? " dshr-linkRowDragging" : ""}`,
|
|
774
770
|
draggable: renameUid !== account.uid,
|
|
@@ -3585,7 +3581,7 @@ window.__ModuleLoader__.load({
|
|
|
3585
3581
|
className: "dshr-supplierGrid",
|
|
3586
3582
|
children: group.map((supplier) => {
|
|
3587
3583
|
const supplierAccounts = accounts.filter((a) => a.supplier === supplier.id);
|
|
3588
|
-
const healthy = supplierAccounts.filter((a) => !a.cooling
|
|
3584
|
+
const healthy = supplierAccounts.filter((a) => !a.cooling).length;
|
|
3589
3585
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
|
|
3590
3586
|
className: "dshr-supplierCard",
|
|
3591
3587
|
onClick: () => setDetailSupplier(supplier),
|
package/lib/index.js
CHANGED
|
@@ -106,42 +106,38 @@ const MINUTE = 6e4;
|
|
|
106
106
|
const RULES = {
|
|
107
107
|
ok: {
|
|
108
108
|
cooldown: 0,
|
|
109
|
-
disable: false,
|
|
110
109
|
counts: false
|
|
111
110
|
},
|
|
112
111
|
rate_limit: {
|
|
113
112
|
cooldown: "backoff",
|
|
114
|
-
disable: false,
|
|
115
113
|
counts: false
|
|
116
114
|
},
|
|
117
115
|
quota: {
|
|
118
116
|
cooldown: 10 * MINUTE,
|
|
119
|
-
disable: false,
|
|
120
117
|
counts: false
|
|
121
118
|
},
|
|
122
119
|
session_dead: {
|
|
123
|
-
cooldown:
|
|
124
|
-
disable: true,
|
|
120
|
+
cooldown: 30 * MINUTE,
|
|
125
121
|
counts: false
|
|
126
122
|
},
|
|
127
123
|
unavailable: {
|
|
128
124
|
cooldown: "transient",
|
|
129
|
-
disable: false,
|
|
130
125
|
counts: false
|
|
131
126
|
},
|
|
132
127
|
transport: {
|
|
133
128
|
cooldown: "transient",
|
|
134
|
-
disable: false,
|
|
135
129
|
counts: false
|
|
136
130
|
},
|
|
137
131
|
unknown: {
|
|
138
132
|
cooldown: "transient",
|
|
139
|
-
disable: false,
|
|
140
133
|
counts: false
|
|
141
134
|
},
|
|
142
135
|
no_such_model: {
|
|
143
136
|
cooldown: 0,
|
|
144
|
-
|
|
137
|
+
counts: false
|
|
138
|
+
},
|
|
139
|
+
bad_request: {
|
|
140
|
+
cooldown: 0,
|
|
145
141
|
counts: false
|
|
146
142
|
}
|
|
147
143
|
};
|
|
@@ -183,7 +179,7 @@ var AccountPool = class {
|
|
|
183
179
|
supplierId;
|
|
184
180
|
/** (supplier, model, uid) → 冷却/退避。 */
|
|
185
181
|
cooldowns = /* @__PURE__ */ new Map();
|
|
186
|
-
/** uid →
|
|
182
|
+
/** uid → 连接级冷却 / 手动暂停。 */
|
|
187
183
|
byUid = /* @__PURE__ */ new Map();
|
|
188
184
|
rrCursor = 0;
|
|
189
185
|
/** (supplier, model) → 当前驻留的号(只有**无亲和**的回退路径用它)。 */
|
|
@@ -213,46 +209,30 @@ var AccountPool = class {
|
|
|
213
209
|
}
|
|
214
210
|
return e;
|
|
215
211
|
}
|
|
216
|
-
/**
|
|
212
|
+
/** 某连接整体状态(连接级冷却/手动暂停)。 */
|
|
217
213
|
uidState(uid) {
|
|
218
214
|
let e = this.byUid.get(uid);
|
|
219
215
|
if (!e) {
|
|
220
216
|
e = {
|
|
221
217
|
until: 0,
|
|
222
|
-
disabled: false,
|
|
223
218
|
reason: ""
|
|
224
219
|
};
|
|
225
220
|
this.byUid.set(uid, e);
|
|
226
221
|
}
|
|
227
222
|
return e;
|
|
228
223
|
}
|
|
229
|
-
/**
|
|
224
|
+
/** 该连接是否处于连接级冷却/手动暂停中(跨模型生效,到期自愈)。 */
|
|
230
225
|
uidBlocked(uid, now) {
|
|
231
226
|
const e = this.byUid.get(uid);
|
|
232
|
-
|
|
233
|
-
blocked: false,
|
|
234
|
-
disabled: false
|
|
235
|
-
};
|
|
236
|
-
if (e.disabled) return {
|
|
237
|
-
blocked: true,
|
|
238
|
-
disabled: true
|
|
239
|
-
};
|
|
240
|
-
if (e.until > now) return {
|
|
241
|
-
blocked: true,
|
|
242
|
-
disabled: false
|
|
243
|
-
};
|
|
244
|
-
return {
|
|
245
|
-
blocked: false,
|
|
246
|
-
disabled: false
|
|
247
|
-
};
|
|
227
|
+
return e !== void 0 && e.until > now;
|
|
248
228
|
}
|
|
249
229
|
/**
|
|
250
230
|
* 给定要试的模型,该连接是否可服务:
|
|
251
|
-
* -
|
|
231
|
+
* - 不处于连接级冷却/手动暂停(跨模型,到期自愈);
|
|
252
232
|
* - 且此 (supplier, model, uid) 冷却单元不在冷却中。
|
|
253
233
|
*/
|
|
254
234
|
healthy(uid, model, now) {
|
|
255
|
-
if (this.uidBlocked(uid, now)
|
|
235
|
+
if (this.uidBlocked(uid, now)) return false;
|
|
256
236
|
const c = this.cooldowns.get(this.key(model, uid));
|
|
257
237
|
if (!c) return true;
|
|
258
238
|
return c.until <= now;
|
|
@@ -371,16 +351,17 @@ var AccountPool = class {
|
|
|
371
351
|
blockKey(modelId) {
|
|
372
352
|
return `${this.supplierId}${SEP}${modelId}`;
|
|
373
353
|
}
|
|
374
|
-
/**
|
|
354
|
+
/** 记录一次失败:按状态处置(冷却/退避),落在「(模型, 连接)」这个冷却单元上。 */
|
|
375
355
|
noteFailure(uid, modelId, state, message) {
|
|
376
356
|
const rule = RULES[state];
|
|
377
|
-
if (rule.
|
|
357
|
+
if (rule.cooldown === 0) return;
|
|
358
|
+
if (state === "session_dead") {
|
|
378
359
|
const e = this.uidState(uid);
|
|
379
|
-
|
|
360
|
+
const cd = rule.cooldown;
|
|
361
|
+
e.until = Math.max(e.until, Date.now() + (typeof cd === "number" ? cd : 0));
|
|
380
362
|
e.reason = message;
|
|
381
363
|
return;
|
|
382
364
|
}
|
|
383
|
-
if (rule.cooldown === 0) return;
|
|
384
365
|
const e = this.entry(modelId, uid);
|
|
385
366
|
e.reason = message;
|
|
386
367
|
if (rule.cooldown === "backoff") {
|
|
@@ -394,7 +375,7 @@ var AccountPool = class {
|
|
|
394
375
|
}
|
|
395
376
|
e.until = Math.max(e.until, Date.now() + rule.cooldown);
|
|
396
377
|
}
|
|
397
|
-
/**
|
|
378
|
+
/** 记录一次成功:清零「该模型」的限流退避等级(好号不背历史惩罚)。 */
|
|
398
379
|
noteSuccess(uid, modelId) {
|
|
399
380
|
const e = this.cooldowns.get(this.key(modelId, uid));
|
|
400
381
|
if (e) e.backoffLevel = 0;
|
|
@@ -406,7 +387,7 @@ var AccountPool = class {
|
|
|
406
387
|
e.reason = reason;
|
|
407
388
|
for (const [k, ce] of this.cooldowns) if (k.endsWith(`${SEP}${uid}`)) ce.backoffLevel = 0;
|
|
408
389
|
}
|
|
409
|
-
/**
|
|
390
|
+
/** 把核心的冷却叠加到插件报的「现在状态」上,产出面板态。
|
|
410
391
|
*
|
|
411
392
|
* 冷却现在是 (模型, 连接) 粒度的,而面板状态没有「当前模型」上下文,
|
|
412
393
|
* 这里默认**聚合**:只要该连接在任一模型上有活跃冷却,就显示 cooling=true
|
|
@@ -417,11 +398,10 @@ var AccountPool = class {
|
|
|
417
398
|
const now = Date.now();
|
|
418
399
|
return accounts.map((a) => {
|
|
419
400
|
const u = this.byUid.get(a.uid);
|
|
420
|
-
const
|
|
421
|
-
|
|
422
|
-
let
|
|
423
|
-
let
|
|
424
|
-
let reason = manualCooling ? u.reason : void 0;
|
|
401
|
+
const uidCooling = u !== void 0 && u.until > now;
|
|
402
|
+
let cooling = uidCooling;
|
|
403
|
+
let maxUntil = uidCooling ? u.until : 0;
|
|
404
|
+
let reason = uidCooling ? u.reason : void 0;
|
|
425
405
|
let err = 0;
|
|
426
406
|
for (const [k, ce] of this.cooldowns) {
|
|
427
407
|
if (!k.endsWith(`${SEP}${a.uid}`)) continue;
|
|
@@ -441,7 +421,6 @@ var AccountPool = class {
|
|
|
441
421
|
return {
|
|
442
422
|
...a,
|
|
443
423
|
cooling,
|
|
444
|
-
disabled,
|
|
445
424
|
err_count: err,
|
|
446
425
|
until: cooling && maxUntil > 0 ? new Date(maxUntil).toISOString() : void 0,
|
|
447
426
|
reason: reason !== void 0 && reason !== "" ? reason : void 0
|
|
@@ -1350,6 +1329,7 @@ function stripAlias(model, alias) {
|
|
|
1350
1329
|
*
|
|
1351
1330
|
* 只对**瞬时**故障等:连接层失败 / 上游不可用(对应 9router 的 502/503/504)。
|
|
1352
1331
|
* 模型不属于本供应商(no_such_model)绝不能等 —— 它不是故障,等纯属浪费。
|
|
1332
|
+
* 请求本身非法(bad_request)同理:换哪个模型都是同一个 400,等也是白等。
|
|
1353
1333
|
*/
|
|
1354
1334
|
const TRANSIENT_SETTLE_MS = 2e3;
|
|
1355
1335
|
function isTransient(state) {
|
|
@@ -2191,7 +2171,7 @@ var Router = class {
|
|
|
2191
2171
|
if (s.accounts !== void 0 && s.accounts().length === 0) {
|
|
2192
2172
|
const r = await s.chatOnce("", req.lv ?? "auto", req);
|
|
2193
2173
|
if (!r.ok) {
|
|
2194
|
-
if (r.state === "no_such_model") return false;
|
|
2174
|
+
if (r.state === "no_such_model" || r.state === "bad_request") return false;
|
|
2195
2175
|
if (trace !== void 0) trace.lastState = r.state;
|
|
2196
2176
|
pool.noteFailure("", req.model, r.state, r.message);
|
|
2197
2177
|
return false;
|
|
@@ -2212,8 +2192,11 @@ var Router = class {
|
|
|
2212
2192
|
tried.add(uid);
|
|
2213
2193
|
const r = await s.chatOnce(uid, req.lv ?? "auto", req);
|
|
2214
2194
|
if (!r.ok) {
|
|
2215
|
-
if (r.state === "no_such_model") {
|
|
2216
|
-
if (trace !== void 0)
|
|
2195
|
+
if (r.state === "no_such_model" || r.state === "bad_request") {
|
|
2196
|
+
if (trace !== void 0) {
|
|
2197
|
+
trace.lastError = r.state;
|
|
2198
|
+
trace.lastState = r.state;
|
|
2199
|
+
}
|
|
2217
2200
|
return false;
|
|
2218
2201
|
}
|
|
2219
2202
|
pool.noteFailure(uid, req.model, r.state, r.message);
|
|
@@ -2323,14 +2306,40 @@ var Router = class {
|
|
|
2323
2306
|
* base64)。图片字节经 `resolveAttachments` 读取;读不到时回退为稳定的占位文本,
|
|
2324
2307
|
* **绝不静默丢图**(丢图 = 模型看到一条没有图的消息却毫无提示)。
|
|
2325
2308
|
*/
|
|
2309
|
+
/** tool-result 里图片挂到 user 消息时用的说明文字(与官方适配器一致)。 */
|
|
2310
|
+
const TOOL_RESULT_IMAGE_TEXT = "Tool result images";
|
|
2326
2311
|
async function wireMessages(options, system, attachments) {
|
|
2327
2312
|
const out = [];
|
|
2313
|
+
/**
|
|
2314
|
+
* 攒着「tool-result 里带出来的图片」,等这一串 tool 消息**发完**再合并成一条
|
|
2315
|
+
* user 消息补在后面。
|
|
2316
|
+
*
|
|
2317
|
+
* 为什么必须跨消息攒、而不是各消息各发一条:harness 把**每个 tool 结果放
|
|
2318
|
+
* 自己的 user-role 消息**里(读两张图 = 两条消息)。若每条消息各发一个
|
|
2319
|
+
* `user(图)`,wire 就成 `assistant → tool → user(图) → tool → user(图)`——
|
|
2320
|
+
* 中间那条 user 又把 tool_call/tool_result 配对接断了,上游照样 400/11148
|
|
2321
|
+
* (实测:tool,user,tool,user = 400;tool,tool,user(两图) = 200)。
|
|
2322
|
+
* 官方 dsh-llm-deepseek 的 serializeMessagesWithImages 同样这么做。
|
|
2323
|
+
*/
|
|
2324
|
+
let pendingToolImages = [];
|
|
2325
|
+
const flushToolImages = () => {
|
|
2326
|
+
if (pendingToolImages.length === 0) return;
|
|
2327
|
+
out.push({
|
|
2328
|
+
role: "user",
|
|
2329
|
+
content: [{
|
|
2330
|
+
type: "text",
|
|
2331
|
+
text: TOOL_RESULT_IMAGE_TEXT
|
|
2332
|
+
}, ...pendingToolImages]
|
|
2333
|
+
});
|
|
2334
|
+
pendingToolImages = [];
|
|
2335
|
+
};
|
|
2328
2336
|
if (typeof system === "string" && system !== "") out.push({
|
|
2329
2337
|
role: "system",
|
|
2330
2338
|
content: system
|
|
2331
2339
|
});
|
|
2332
2340
|
for (const message of options.messages) {
|
|
2333
2341
|
if (message.role === "system") {
|
|
2342
|
+
flushToolImages();
|
|
2334
2343
|
out.push({
|
|
2335
2344
|
role: "system",
|
|
2336
2345
|
content: flattenText(message.content)
|
|
@@ -2338,6 +2347,7 @@ async function wireMessages(options, system, attachments) {
|
|
|
2338
2347
|
continue;
|
|
2339
2348
|
}
|
|
2340
2349
|
if (message.role === "assistant") {
|
|
2350
|
+
flushToolImages();
|
|
2341
2351
|
const text = flattenText(message.content);
|
|
2342
2352
|
const toolCalls = message.content.filter((b) => b.type === "tool-call").map((b) => ({
|
|
2343
2353
|
id: b.id,
|
|
@@ -2357,32 +2367,42 @@ async function wireMessages(options, system, attachments) {
|
|
|
2357
2367
|
continue;
|
|
2358
2368
|
}
|
|
2359
2369
|
const toolResults = message.content.filter((b) => b.type === "tool-result");
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
content: parts
|
|
2365
|
-
});
|
|
2366
|
-
else if (toolResults.length === 0) out.push({
|
|
2370
|
+
const ownParts = await contentParts(message.content.filter((b) => b.type !== "tool-result"), attachments, options.signal);
|
|
2371
|
+
if (ownParts.length > 0 || toolResults.length === 0) {
|
|
2372
|
+
flushToolImages();
|
|
2373
|
+
out.push({
|
|
2367
2374
|
role: "user",
|
|
2368
|
-
content:
|
|
2375
|
+
content: compactUserContent(ownParts)
|
|
2369
2376
|
});
|
|
2370
|
-
}
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2377
|
+
}
|
|
2378
|
+
for (const result of toolResults) {
|
|
2379
|
+
const parts = await contentParts(result.content, attachments, options.signal);
|
|
2380
|
+
const imageParts = parts.filter((p) => p.type !== "text");
|
|
2381
|
+
const text = parts.filter((p) => p.type === "text").map((p) => p.text ?? "").join("");
|
|
2382
|
+
out.push({
|
|
2383
|
+
role: "tool",
|
|
2384
|
+
tool_call_id: result.toolCallId,
|
|
2374
2385
|
content: text
|
|
2375
2386
|
});
|
|
2387
|
+
pendingToolImages.push(...imageParts);
|
|
2376
2388
|
}
|
|
2377
|
-
for (const result of toolResults) out.push({
|
|
2378
|
-
role: "tool",
|
|
2379
|
-
tool_call_id: result.toolCallId,
|
|
2380
|
-
content: flattenText(result.content)
|
|
2381
|
-
});
|
|
2382
2389
|
}
|
|
2390
|
+
flushToolImages();
|
|
2383
2391
|
return out;
|
|
2384
2392
|
}
|
|
2385
2393
|
/**
|
|
2394
|
+
* 纯文本 parts 收成紧凑字符串 wire 形式(无图时不回归成数组)。
|
|
2395
|
+
* 与官方适配器的 userContent 同义:只要有非 text part 就保持数组,否则 join。
|
|
2396
|
+
*/
|
|
2397
|
+
function compactUserContent(parts) {
|
|
2398
|
+
const texts = [];
|
|
2399
|
+
for (const p of parts) {
|
|
2400
|
+
if (p.type !== "text") return parts;
|
|
2401
|
+
texts.push(p.text ?? "");
|
|
2402
|
+
}
|
|
2403
|
+
return texts.join("");
|
|
2404
|
+
}
|
|
2405
|
+
/**
|
|
2386
2406
|
* 一块 DSH user/tool-result 内容序列化成 OpenAI content parts(text + image_url)。
|
|
2387
2407
|
* 纯文本时返回空数组(上层走紧凑字符串路径)。图片转 base64 data URI;
|
|
2388
2408
|
* attachments 缺失或读取失败时回退稳定占位文本(绝不静默丢图)。
|
|
@@ -2495,14 +2515,6 @@ function applyReasoning(body, effort) {
|
|
|
2495
2515
|
function flattenText(blocks) {
|
|
2496
2516
|
return blocks.filter((b) => b.type === "text").map((b) => b.text ?? "").join("");
|
|
2497
2517
|
}
|
|
2498
|
-
/** 内容块里(含递归进 tool-result)是否存在 image 块。决定走数组 content 还是字符串。 */
|
|
2499
|
-
function hasImageBlock(blocks) {
|
|
2500
|
-
for (const b of blocks) {
|
|
2501
|
-
if (b.type === "image") return true;
|
|
2502
|
-
if (b.type === "tool-result" && hasImageBlock(b.content)) return true;
|
|
2503
|
-
}
|
|
2504
|
-
return false;
|
|
2505
|
-
}
|
|
2506
2518
|
/** openai SSE payload → DSH StreamChunk 流。导出以便单测锁死 usage 契约。 */
|
|
2507
2519
|
/**
|
|
2508
2520
|
* 把上游 SSE 的 `data:` 载荷流翻译成 DSH 的 StreamChunk 事件流。
|
|
@@ -3207,7 +3219,7 @@ function cap(set, key) {
|
|
|
3207
3219
|
}
|
|
3208
3220
|
/** 账号状态指纹(积分 + 健康),用于判断刷新是否落地。 */
|
|
3209
3221
|
function fingerprint(accounts) {
|
|
3210
|
-
return accounts.map((a) => `${a.uid}:${a.credits}:${a.cooling}
|
|
3222
|
+
return accounts.map((a) => `${a.uid}:${a.credits}:${a.cooling}`).join("|");
|
|
3211
3223
|
}
|
|
3212
3224
|
/**
|
|
3213
3225
|
* 反复调插件已有的 status(),直到快照稳定再返回(是否有变化 = changed)。
|
package/lib/suppliers/nvidia.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { t as stateFromHttpStatus } from "../http-state-CGDexmJf.js";
|
|
1
2
|
import { join } from "node:path";
|
|
2
3
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
4
|
//#region src/suppliers/nvidia/plugin.ts
|
|
@@ -308,7 +309,7 @@ function factory(env) {
|
|
|
308
309
|
const msg = `upstream ${upstream.status}: ${text.slice(0, 120)}`;
|
|
309
310
|
return {
|
|
310
311
|
ok: false,
|
|
311
|
-
state: upstream.status
|
|
312
|
+
state: stateFromHttpStatus(upstream.status),
|
|
312
313
|
message: msg
|
|
313
314
|
};
|
|
314
315
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { t as stateFromHttpStatus } from "../http-state-CGDexmJf.js";
|
|
1
2
|
//#region src/suppliers/opencode/plugin.ts
|
|
2
3
|
const id = "opencode";
|
|
3
4
|
const name = "OpenCode Free";
|
|
@@ -106,7 +107,7 @@ function factory(env) {
|
|
|
106
107
|
const msg = `upstream ${upstream.status}: ${text.slice(0, 200)}`;
|
|
107
108
|
return {
|
|
108
109
|
ok: false,
|
|
109
|
-
state: upstream.status
|
|
110
|
+
state: stateFromHttpStatus(upstream.status),
|
|
110
111
|
message: msg
|
|
111
112
|
};
|
|
112
113
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { t as stateFromHttpStatus } from "../http-state-CGDexmJf.js";
|
|
1
2
|
//#region src/suppliers/openrouter/plugin.ts
|
|
2
3
|
const id = "openrouter";
|
|
3
4
|
const name = "OpenRouter";
|
|
@@ -196,7 +197,7 @@ function factory(env) {
|
|
|
196
197
|
const msg = `upstream ${upstream.status}: ${text.slice(0, 120)}`;
|
|
197
198
|
return {
|
|
198
199
|
ok: false,
|
|
199
|
-
state: upstream.status
|
|
200
|
+
state: stateFromHttpStatus(upstream.status),
|
|
200
201
|
message: msg
|
|
201
202
|
};
|
|
202
203
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 账号池 —— dsh-router
|
|
2
|
+
* 账号池 —— dsh-router 核心策略:选号、冷却、遍历回退。
|
|
3
3
|
*
|
|
4
4
|
* 供应商插件只管「对单个账号调通上游」,账号层面的策略全在这里。
|
|
5
5
|
*
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* 失败而对其它模型也不可用,这是错的。
|
|
12
12
|
*
|
|
13
13
|
* - 冷却/限流退避/瞬时 → 键 = (supplierId, modelId, uid),只冷「这个号 × 这个模型」
|
|
14
|
-
* -
|
|
14
|
+
* - 凭证失效(session_dead) → 键 = uid 连接级冷却(登录态问题,该号所有模型都不可用);
|
|
15
|
+
* **没有永久禁用**——403 分不清凭证死活与风控误伤,冷却到期自愈
|
|
15
16
|
* - 手动冷却(面板) → 键 = uid(管理员整连接暂停)
|
|
16
17
|
*
|
|
17
18
|
* 为什么键里要带 supplierId:不同供应商**会有同名的模型 id**(如 traework 与
|
|
@@ -19,7 +20,7 @@
|
|
|
19
20
|
* 会把跨供应商的同名模型串到一起。尽管本池实例当前是单供应商(loader 每供应商
|
|
20
21
|
* 一个 AccountPool),键仍显式带 supplierId,语义自明且将来若池合并也安全。
|
|
21
22
|
*
|
|
22
|
-
*
|
|
23
|
+
* 天花板:冷却都是**内存态**,重启归零(与旧插件行为一致)。要跨重启保留
|
|
23
24
|
* 得落盘,届时加 stateFile 即可,接口不用变。
|
|
24
25
|
*/
|
|
25
26
|
import type { AccountState, SupplierAccountNow } from '../suppliers/contract.ts';
|
|
@@ -28,7 +29,7 @@ export declare class AccountPool {
|
|
|
28
29
|
private supplierId;
|
|
29
30
|
/** (supplier, model, uid) → 冷却/退避。 */
|
|
30
31
|
private cooldowns;
|
|
31
|
-
/** uid →
|
|
32
|
+
/** uid → 连接级冷却 / 手动暂停。 */
|
|
32
33
|
private byUid;
|
|
33
34
|
private rrCursor;
|
|
34
35
|
/** (supplier, model) → 当前驻留的号(只有**无亲和**的回退路径用它)。 */
|
|
@@ -42,13 +43,13 @@ export declare class AccountPool {
|
|
|
42
43
|
constructor(supplierId?: string);
|
|
43
44
|
private key;
|
|
44
45
|
private entry;
|
|
45
|
-
/**
|
|
46
|
+
/** 某连接整体状态(连接级冷却/手动暂停)。 */
|
|
46
47
|
private uidState;
|
|
47
|
-
/**
|
|
48
|
+
/** 该连接是否处于连接级冷却/手动暂停中(跨模型生效,到期自愈)。 */
|
|
48
49
|
private uidBlocked;
|
|
49
50
|
/**
|
|
50
51
|
* 给定要试的模型,该连接是否可服务:
|
|
51
|
-
* -
|
|
52
|
+
* - 不处于连接级冷却/手动暂停(跨模型,到期自愈);
|
|
52
53
|
* - 且此 (supplier, model, uid) 冷却单元不在冷却中。
|
|
53
54
|
*/
|
|
54
55
|
private healthy;
|
|
@@ -109,13 +110,13 @@ export declare class AccountPool {
|
|
|
109
110
|
dropBlock(modelId: string): void;
|
|
110
111
|
/** 块状态键:与冷却同粒度 (supplier, model),不含 uid。 */
|
|
111
112
|
private blockKey;
|
|
112
|
-
/**
|
|
113
|
+
/** 记录一次失败:按状态处置(冷却/退避),落在「(模型, 连接)」这个冷却单元上。 */
|
|
113
114
|
noteFailure(uid: string, modelId: string, state: AccountState, message: string): void;
|
|
114
|
-
/**
|
|
115
|
+
/** 记录一次成功:清零「该模型」的限流退避等级(好号不背历史惩罚)。 */
|
|
115
116
|
noteSuccess(uid: string, modelId: string): void;
|
|
116
117
|
/** 手动暂停整连接(面板/管理用途,跨模型)。ms<=0 表示解除。 */
|
|
117
118
|
cooldown(uid: string, ms: number, reason: string): void;
|
|
118
|
-
/**
|
|
119
|
+
/** 把核心的冷却叠加到插件报的「现在状态」上,产出面板态。
|
|
119
120
|
*
|
|
120
121
|
* 冷却现在是 (模型, 连接) 粒度的,而面板状态没有「当前模型」上下文,
|
|
121
122
|
* 这里默认**聚合**:只要该连接在任一模型上有活跃冷却,就显示 cooling=true
|
|
@@ -124,7 +125,6 @@ export declare class AccountPool {
|
|
|
124
125
|
*/
|
|
125
126
|
decorate(accounts: SupplierAccountNow[], modelId?: string): Array<SupplierAccountNow & {
|
|
126
127
|
cooling: boolean;
|
|
127
|
-
disabled: boolean;
|
|
128
128
|
err_count: number;
|
|
129
129
|
until?: string;
|
|
130
130
|
reason?: string;
|