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 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
- **js 只说「现在怎么了」,不说「该怎么办」。** 冷却多久、是否禁用、要不要换号
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` / `disabled` / `err_count` / `until` / `reason`
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
 
@@ -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 || account.disabled ? `${account.reason ?? ""}${untilLabel(account) !== "" ? ` · ${untilLabel(account)}` : ""}` : "";
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 && !a.disabled).length;
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 || account.disabled ? `${account.reason ?? ""}${untilLabel(account) !== "" ? ` · ${untilLabel(account)}` : ""}` : "";
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 && !a.disabled).length;
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: 0,
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
- disable: false,
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
- if (!e) return {
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).blocked) return false;
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.disable) {
357
+ if (rule.cooldown === 0) return;
358
+ if (state === "session_dead") {
378
359
  const e = this.uidState(uid);
379
- e.disabled = true;
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 disabled = u?.disabled ?? false;
421
- const manualCooling = u !== void 0 && !u.disabled && u.until > now;
422
- let cooling = manualCooling;
423
- let maxUntil = manualCooling ? u.until : 0;
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) trace.lastError = "no_such_model";
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
- if (hasImageBlock(message.content)) {
2361
- const parts = await contentParts(message.content, attachments, options.signal);
2362
- if (parts.length > 0) out.push({
2363
- role: "user",
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
- } else {
2371
- const text = flattenText(message.content);
2372
- if (text.length > 0 || toolResults.length === 0) out.push({
2373
- role: "user",
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}:${a.disabled}`).join("|");
3222
+ return accounts.map((a) => `${a.uid}:${a.credits}:${a.cooling}`).join("|");
3211
3223
  }
3212
3224
  /**
3213
3225
  * 反复调插件已有的 status(),直到快照稳定再返回(是否有变化 = 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 === 429 ? "rate_limit" : upstream.status === 401 || upstream.status === 403 ? "session_dead" : upstream.status === 404 ? "unavailable" : "unknown",
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 === 429 ? "rate_limit" : upstream.status === 404 ? "unavailable" : "unknown",
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 === 429 ? "rate_limit" : upstream.status === 401 || upstream.status === 403 ? "session_dead" : upstream.status === 404 ? "unavailable" : "unknown",
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
- * - 禁用(session_dead) → 键 = uid(登录态问题,该号所有模型都不可用)
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;
@@ -13,7 +13,6 @@ export interface SupplierAccount {
13
13
  cooling: boolean;
14
14
  until?: string;
15
15
  reason?: string;
16
- disabled: boolean;
17
16
  err_count?: number;
18
17
  }
19
18
  /** 供应商面板状态。 */
@@ -22,7 +22,6 @@ export interface RouterAccount {
22
22
  cooling: boolean;
23
23
  until?: string;
24
24
  reason?: string;
25
- disabled: boolean;
26
25
  err_count?: number;
27
26
  /** 所属供应商 id。 */
28
27
  supplier?: string;