dsh-router-core 0.3.15 → 0.3.18

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
@@ -80,12 +80,22 @@ js 报告**此刻**观察到的账户状况,而不是把上游原始响应丢给
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
 
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
+
89
99
  **js 只说「现在怎么了」,不说「该怎么办」。** 冷却多久、要不要换号
90
100
  都是核心的策略(见下),js 不该自己记冷却表——那样每个插件都会长出一份
91
101
  互相不一致的实现。
@@ -127,6 +137,7 @@ type ChatOnceResult =
127
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`——空响应交给核心换号/换模型回退,
@@ -3201,6 +3201,7 @@ window.__ModuleLoader__.load({
3201
3201
  }, [period, active]);
3202
3202
  const s = stats;
3203
3203
  const successRate = s !== null && s.requests !== void 0 && s.requests > 0 ? Math.round((s.okCount ?? 0) / s.requests * 100) : null;
3204
+ const cacheRatio = s !== null && (s.promptTokens ?? 0) > 0 ? Math.round((s.cachedTokens ?? 0) / (s.promptTokens ?? 0) * 100) : null;
3204
3205
  const estimated = s !== null && ((s.estimatedInputs ?? 0) > 0 || (s.estimatedOutputs ?? 0) > 0);
3205
3206
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3206
3207
  className: "dshr-tabBody",
@@ -3276,10 +3277,11 @@ window.__ModuleLoader__.load({
3276
3277
  tone: "ok"
3277
3278
  }),
3278
3279
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(StatCard, {
3279
- label: "缓存 Tokens",
3280
- value: fmtShort(s?.cachedTokens),
3281
- hint: "命中缓存的输入部分",
3282
- tone: "default"
3280
+ label: "缓存命中率",
3281
+ value: cacheRatio === null ? "—" : `${cacheRatio}%`,
3282
+ hint: cacheRatio === null ? "命中缓存的输入占比" : `命中 ${fmtShort(s?.cachedTokens)} / 输入 ${fmtShort(s?.promptTokens)}`,
3283
+ tone: cacheRatio !== null && cacheRatio >= 80 ? "ok" : "default",
3284
+ title: "缓存命中率 = 命中缓存的输入 tokens ÷ 总输入 tokens"
3283
3285
  }),
3284
3286
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(StatCard, {
3285
3287
  label: "平均耗时",
package/lib/client.js CHANGED
@@ -3201,6 +3201,7 @@ window.__ModuleLoader__.load({
3201
3201
  }, [period, active]);
3202
3202
  const s = stats;
3203
3203
  const successRate = s !== null && s.requests !== void 0 && s.requests > 0 ? Math.round((s.okCount ?? 0) / s.requests * 100) : null;
3204
+ const cacheRatio = s !== null && (s.promptTokens ?? 0) > 0 ? Math.round((s.cachedTokens ?? 0) / (s.promptTokens ?? 0) * 100) : null;
3204
3205
  const estimated = s !== null && ((s.estimatedInputs ?? 0) > 0 || (s.estimatedOutputs ?? 0) > 0);
3205
3206
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3206
3207
  className: "dshr-tabBody",
@@ -3276,10 +3277,11 @@ window.__ModuleLoader__.load({
3276
3277
  tone: "ok"
3277
3278
  }),
3278
3279
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(StatCard, {
3279
- label: "缓存 Tokens",
3280
- value: fmtShort(s?.cachedTokens),
3281
- hint: "命中缓存的输入部分",
3282
- tone: "default"
3280
+ label: "缓存命中率",
3281
+ value: cacheRatio === null ? "—" : `${cacheRatio}%`,
3282
+ hint: cacheRatio === null ? "命中缓存的输入占比" : `命中 ${fmtShort(s?.cachedTokens)} / 输入 ${fmtShort(s?.promptTokens)}`,
3283
+ tone: cacheRatio !== null && cacheRatio >= 80 ? "ok" : "default",
3284
+ title: "缓存命中率 = 命中缓存的输入 tokens ÷ 总输入 tokens"
3283
3285
  }),
3284
3286
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(StatCard, {
3285
3287
  label: "平均耗时",
package/lib/index.js CHANGED
@@ -135,6 +135,10 @@ const RULES = {
135
135
  no_such_model: {
136
136
  cooldown: 0,
137
137
  counts: false
138
+ },
139
+ bad_request: {
140
+ cooldown: 0,
141
+ counts: false
138
142
  }
139
143
  };
140
144
  /**
@@ -1048,7 +1052,14 @@ function num(v) {
1048
1052
  * `cached_tokens` 口径照 9router 的 `canonicalizeUsage`:OpenAI 系报的
1049
1053
  * prompt_tokens **已含**缓存,直接透传;Claude 系报的是**不含**缓存的
1050
1054
  * prompt + 单独的 cache_read,要折进来,否则总输入偏小。
1051
- * 判别式就是「有没有 cache_read_input_tokens」。
1055
+ *
1056
+ * 判别式**不能只看有没有 `cache_read_input_tokens`**:TRAE(SOLO) 借用了这个
1057
+ * Claude 字段名,语义却是 OpenAI 的「prompt_tokens 已含缓存」——
1058
+ * 于是同时报了 `prompt_tokens=160019` 和 `cache_read_input_tokens=159744`,
1059
+ * 旧逻辑无条件相加 → 总量翻倍、命中率被腰斩成 50%。正确判别是**看 prompt
1060
+ * 车道**:有 `prompt_tokens` = OpenAI 总语义(已含缓存,**不**再折 cache_read);
1061
+ * 只有 `input_tokens`/`promptTokenCount` = Claude/Gemini 不含缓存,才折。
1062
+ * 这与供应商无关、自描述,比把 supplierId 灌进 usage 管线更干净。
1052
1063
  *
1053
1064
  * @returns 归一用量;一个非零字段都没有时返回 null(= 没拿到 usage)
1054
1065
  */
@@ -1057,7 +1068,7 @@ function normalizeUsage(raw) {
1057
1068
  const u = raw;
1058
1069
  const cached = num(u.cached_tokens) + num(u.cache_read_input_tokens) + num(u.cachedContentTokenCount) + num(u.prompt_tokens_details?.cached_tokens);
1059
1070
  let prompt = num(u.prompt_tokens) + num(u.input_tokens) + num(u.promptTokenCount);
1060
- if (u.cache_read_input_tokens !== void 0) prompt += num(u.cache_read_input_tokens);
1071
+ if (u.cache_read_input_tokens !== void 0 && u.prompt_tokens === void 0) prompt += num(u.cache_read_input_tokens);
1061
1072
  const completion = num(u.completion_tokens) + num(u.output_tokens) + num(u.candidatesTokenCount);
1062
1073
  if (prompt === 0 && completion === 0 && cached === 0) return null;
1063
1074
  return {
@@ -1325,6 +1336,7 @@ function stripAlias(model, alias) {
1325
1336
  *
1326
1337
  * 只对**瞬时**故障等:连接层失败 / 上游不可用(对应 9router 的 502/503/504)。
1327
1338
  * 模型不属于本供应商(no_such_model)绝不能等 —— 它不是故障,等纯属浪费。
1339
+ * 请求本身非法(bad_request)同理:换哪个模型都是同一个 400,等也是白等。
1328
1340
  */
1329
1341
  const TRANSIENT_SETTLE_MS = 2e3;
1330
1342
  function isTransient(state) {
@@ -2166,7 +2178,7 @@ var Router = class {
2166
2178
  if (s.accounts !== void 0 && s.accounts().length === 0) {
2167
2179
  const r = await s.chatOnce("", req.lv ?? "auto", req);
2168
2180
  if (!r.ok) {
2169
- if (r.state === "no_such_model") return false;
2181
+ if (r.state === "no_such_model" || r.state === "bad_request") return false;
2170
2182
  if (trace !== void 0) trace.lastState = r.state;
2171
2183
  pool.noteFailure("", req.model, r.state, r.message);
2172
2184
  return false;
@@ -2187,8 +2199,11 @@ var Router = class {
2187
2199
  tried.add(uid);
2188
2200
  const r = await s.chatOnce(uid, req.lv ?? "auto", req);
2189
2201
  if (!r.ok) {
2190
- if (r.state === "no_such_model") {
2191
- if (trace !== void 0) trace.lastError = "no_such_model";
2202
+ if (r.state === "no_such_model" || r.state === "bad_request") {
2203
+ if (trace !== void 0) {
2204
+ trace.lastError = r.state;
2205
+ trace.lastState = r.state;
2206
+ }
2192
2207
  return false;
2193
2208
  }
2194
2209
  pool.noteFailure(uid, req.model, r.state, r.message);
@@ -2298,14 +2313,40 @@ var Router = class {
2298
2313
  * base64)。图片字节经 `resolveAttachments` 读取;读不到时回退为稳定的占位文本,
2299
2314
  * **绝不静默丢图**(丢图 = 模型看到一条没有图的消息却毫无提示)。
2300
2315
  */
2316
+ /** tool-result 里图片挂到 user 消息时用的说明文字(与官方适配器一致)。 */
2317
+ const TOOL_RESULT_IMAGE_TEXT = "Tool result images";
2301
2318
  async function wireMessages(options, system, attachments) {
2302
2319
  const out = [];
2320
+ /**
2321
+ * 攒着「tool-result 里带出来的图片」,等这一串 tool 消息**发完**再合并成一条
2322
+ * user 消息补在后面。
2323
+ *
2324
+ * 为什么必须跨消息攒、而不是各消息各发一条:harness 把**每个 tool 结果放
2325
+ * 自己的 user-role 消息**里(读两张图 = 两条消息)。若每条消息各发一个
2326
+ * `user(图)`,wire 就成 `assistant → tool → user(图) → tool → user(图)`——
2327
+ * 中间那条 user 又把 tool_call/tool_result 配对接断了,上游照样 400/11148
2328
+ * (实测:tool,user,tool,user = 400;tool,tool,user(两图) = 200)。
2329
+ * 官方 dsh-llm-deepseek 的 serializeMessagesWithImages 同样这么做。
2330
+ */
2331
+ let pendingToolImages = [];
2332
+ const flushToolImages = () => {
2333
+ if (pendingToolImages.length === 0) return;
2334
+ out.push({
2335
+ role: "user",
2336
+ content: [{
2337
+ type: "text",
2338
+ text: TOOL_RESULT_IMAGE_TEXT
2339
+ }, ...pendingToolImages]
2340
+ });
2341
+ pendingToolImages = [];
2342
+ };
2303
2343
  if (typeof system === "string" && system !== "") out.push({
2304
2344
  role: "system",
2305
2345
  content: system
2306
2346
  });
2307
2347
  for (const message of options.messages) {
2308
2348
  if (message.role === "system") {
2349
+ flushToolImages();
2309
2350
  out.push({
2310
2351
  role: "system",
2311
2352
  content: flattenText(message.content)
@@ -2313,6 +2354,7 @@ async function wireMessages(options, system, attachments) {
2313
2354
  continue;
2314
2355
  }
2315
2356
  if (message.role === "assistant") {
2357
+ flushToolImages();
2316
2358
  const text = flattenText(message.content);
2317
2359
  const toolCalls = message.content.filter((b) => b.type === "tool-call").map((b) => ({
2318
2360
  id: b.id,
@@ -2332,32 +2374,42 @@ async function wireMessages(options, system, attachments) {
2332
2374
  continue;
2333
2375
  }
2334
2376
  const toolResults = message.content.filter((b) => b.type === "tool-result");
2335
- if (hasImageBlock(message.content)) {
2336
- const parts = await contentParts(message.content, attachments, options.signal);
2337
- if (parts.length > 0) out.push({
2338
- role: "user",
2339
- content: parts
2340
- });
2341
- else if (toolResults.length === 0) out.push({
2377
+ const ownParts = await contentParts(message.content.filter((b) => b.type !== "tool-result"), attachments, options.signal);
2378
+ if (ownParts.length > 0 || toolResults.length === 0) {
2379
+ flushToolImages();
2380
+ out.push({
2342
2381
  role: "user",
2343
- content: ""
2382
+ content: compactUserContent(ownParts)
2344
2383
  });
2345
- } else {
2346
- const text = flattenText(message.content);
2347
- if (text.length > 0 || toolResults.length === 0) out.push({
2348
- role: "user",
2384
+ }
2385
+ for (const result of toolResults) {
2386
+ const parts = await contentParts(result.content, attachments, options.signal);
2387
+ const imageParts = parts.filter((p) => p.type !== "text");
2388
+ const text = parts.filter((p) => p.type === "text").map((p) => p.text ?? "").join("");
2389
+ out.push({
2390
+ role: "tool",
2391
+ tool_call_id: result.toolCallId,
2349
2392
  content: text
2350
2393
  });
2394
+ pendingToolImages.push(...imageParts);
2351
2395
  }
2352
- for (const result of toolResults) out.push({
2353
- role: "tool",
2354
- tool_call_id: result.toolCallId,
2355
- content: flattenText(result.content)
2356
- });
2357
2396
  }
2397
+ flushToolImages();
2358
2398
  return out;
2359
2399
  }
2360
2400
  /**
2401
+ * 纯文本 parts 收成紧凑字符串 wire 形式(无图时不回归成数组)。
2402
+ * 与官方适配器的 userContent 同义:只要有非 text part 就保持数组,否则 join。
2403
+ */
2404
+ function compactUserContent(parts) {
2405
+ const texts = [];
2406
+ for (const p of parts) {
2407
+ if (p.type !== "text") return parts;
2408
+ texts.push(p.text ?? "");
2409
+ }
2410
+ return texts.join("");
2411
+ }
2412
+ /**
2361
2413
  * 一块 DSH user/tool-result 内容序列化成 OpenAI content parts(text + image_url)。
2362
2414
  * 纯文本时返回空数组(上层走紧凑字符串路径)。图片转 base64 data URI;
2363
2415
  * attachments 缺失或读取失败时回退稳定占位文本(绝不静默丢图)。
@@ -2470,14 +2522,6 @@ function applyReasoning(body, effort) {
2470
2522
  function flattenText(blocks) {
2471
2523
  return blocks.filter((b) => b.type === "text").map((b) => b.text ?? "").join("");
2472
2524
  }
2473
- /** 内容块里(含递归进 tool-result)是否存在 image 块。决定走数组 content 还是字符串。 */
2474
- function hasImageBlock(blocks) {
2475
- for (const b of blocks) {
2476
- if (b.type === "image") return true;
2477
- if (b.type === "tool-result" && hasImageBlock(b.content)) return true;
2478
- }
2479
- return false;
2480
- }
2481
2525
  /** openai SSE payload → DSH StreamChunk 流。导出以便单测锁死 usage 契约。 */
2482
2526
  /**
2483
2527
  * 把上游 SSE 的 `data:` 载荷流翻译成 DSH 的 StreamChunk 事件流。
@@ -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
  }
@@ -38,7 +38,14 @@ export interface Usage extends UsageTokens {
38
38
  * `cached_tokens` 口径照 9router 的 `canonicalizeUsage`:OpenAI 系报的
39
39
  * prompt_tokens **已含**缓存,直接透传;Claude 系报的是**不含**缓存的
40
40
  * prompt + 单独的 cache_read,要折进来,否则总输入偏小。
41
- * 判别式就是「有没有 cache_read_input_tokens」。
41
+ *
42
+ * 判别式**不能只看有没有 `cache_read_input_tokens`**:TRAE(SOLO) 借用了这个
43
+ * Claude 字段名,语义却是 OpenAI 的「prompt_tokens 已含缓存」——
44
+ * 于是同时报了 `prompt_tokens=160019` 和 `cache_read_input_tokens=159744`,
45
+ * 旧逻辑无条件相加 → 总量翻倍、命中率被腰斩成 50%。正确判别是**看 prompt
46
+ * 车道**:有 `prompt_tokens` = OpenAI 总语义(已含缓存,**不**再折 cache_read);
47
+ * 只有 `input_tokens`/`promptTokenCount` = Claude/Gemini 不含缓存,才折。
48
+ * 这与供应商无关、自描述,比把 supplierId 灌进 usage 管线更干净。
42
49
  *
43
50
  * @returns 归一用量;一个非零字段都没有时返回 null(= 没拿到 usage)
44
51
  */
@@ -56,7 +56,17 @@ export type AccountState = 'ok' | 'rate_limit' | 'quota' | 'session_dead' | 'una
56
56
  * 否则组合里每有一个别人家的模型,就会给无关账号攒一次错误,攒够阈值
57
57
  * 把它冷却掉(历史上正是这个坑)。
58
58
  */
59
- | 'no_such_model';
59
+ | 'no_such_model'
60
+ /**
61
+ * 请求本身不合法,被上游拒(参数错、图片格式不支持、tool_call 配对断裂…)。
62
+ *
63
+ * **不是账号的错**:同一个请求对池里每个号都会失败,冷号只会让好号白白
64
+ * 失效,把一次「这条请求有问题」放大成「这个模型 30 秒内谁都别用」——
65
+ * 组合里两条腿同时被冷就是这个机制(2026-09-15 读图 11148 事故)。
66
+ * 核心据此**不惩罚账号**,直接跳出本供应商换下一个模型/供应商。
67
+ * 与 `no_such_model` 的区别:那个是「模型不归我」,这个是「请求我不认」。
68
+ */
69
+ | 'bad_request';
60
70
  /** 面板展示的账号此刻状态(插件只报它观察到的部分,冷却由核心叠加)。 */
61
71
  export interface SupplierAccountNow {
62
72
  uid: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-router-core",
3
- "version": "0.3.15",
3
+ "version": "0.3.18",
4
4
  "description": "DSH plugin: a simplified 9router (AI routing gateway). Adds a 路由系统 (Routing System) sidebar entry beside 记忆系统; the panel shows suppliers, account pool, combos, and endpoint/API keys.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -421,6 +421,12 @@ export function StatsTab({ onRefresh, refreshing, active, health }: StatsTabProp
421
421
  const successRate = s !== null && s.requests !== undefined && s.requests > 0
422
422
  ? Math.round(((s.okCount ?? 0) / s.requests) * 100)
423
423
  : null
424
+ // 缓存命中率 = cachedTokens / promptTokens(cachedTokens 是 promptTokens
425
+ // 的子集:命中缓存的输入部分)。分母 0 = 本周期没有带缓存语义的请求,
426
+ // 显示 — 而不是硬造 0%(那会把「没数据」和「全未命中」混为一谈)。
427
+ const cacheRatio = s !== null && (s.promptTokens ?? 0) > 0
428
+ ? Math.round(((s.cachedTokens ?? 0) / (s.promptTokens ?? 0)) * 100)
429
+ : null
424
430
  // 估算提示:只有真有估算时才显示,别没事吓唬人
425
431
  const estimated = s !== null && ((s.estimatedInputs ?? 0) > 0 || (s.estimatedOutputs ?? 0) > 0)
426
432
 
@@ -490,8 +496,16 @@ export function StatsTab({ onRefresh, refreshing, active, health }: StatsTabProp
490
496
  hint={(s?.estimatedOutputs ?? 0) > 0 ? '~ 含估算' : undefined}
491
497
  tone="ok"
492
498
  />
493
- {/* 缓存排在输入/输出之后:它是输入的子集,不是并列的第三种 token */}
494
- <StatCard label="缓存 Tokens" value={fmtShort(s?.cachedTokens)} hint="命中缓存的输入部分" tone="default" />
499
+ {/* 缓存排在输入/输出之后:它是输入的子集,不是并列的第三种 token。
500
+ 显示命中率而不是 token 数:50.2% 这种数一眼能读出「缓存工作是否正常」,
501
+ 绝对值要看的话输入 Tokens 卡里已含(title 给出原始数字)。 */}
502
+ <StatCard
503
+ label="缓存命中率"
504
+ value={cacheRatio === null ? '—' : `${cacheRatio}%`}
505
+ hint={cacheRatio === null ? '命中缓存的输入占比' : `命中 ${fmtShort(s?.cachedTokens)} / 输入 ${fmtShort(s?.promptTokens)}`}
506
+ tone={cacheRatio !== null && cacheRatio >= 80 ? 'ok' : 'default'}
507
+ title="缓存命中率 = 命中缓存的输入 tokens ÷ 总输入 tokens"
508
+ />
495
509
  <StatCard
496
510
  label="平均耗时"
497
511
  value={fmtDuration(s?.avgDurationMs)}
@@ -620,8 +620,110 @@ test('wire:无 attachments 时图片回退占位文本,绝不静默丢图',
620
620
  messages: [{ role: 'user', content: [{ type: 'image', attachment: ref }] }],
621
621
  }, undefined)
622
622
  const user = (body.messages as Array<{ role: string; content: unknown }>).find((m) => m.role === 'user')
623
- const parts = (user?.content ?? []) as Array<{ type: string; text?: string }>
624
- assert.equal(parts.length, 1)
625
- assert.equal(parts[0]?.type, 'text')
626
- assert.ok((parts[0]?.text ?? '').includes('image omitted'), '读不到附件时必须给占位文本,图片不能无声消失')
623
+ // 读不到字节时 parts 只剩一个 text 占位 → 收成紧凑字符串(无图可发,别硬撑数组)
624
+ const text = typeof user?.content === 'string' ? user.content : JSON.stringify(user?.content)
625
+ assert.ok(text.includes('image omitted'), '读不到附件时必须给占位文本,图片不能无声消失')
626
+ assert.ok(!text.includes('image_url'), '读不到字节时不能伪造图片 part')
627
+ })
628
+
629
+ test('wire:一步两个 read_image(每条一个 user 消息,同现场)→ tool 消息紧跟 assistant,图片合并到末尾', async () => {
630
+ const ref = {
631
+ attachmentId: 'sha256:abcdef1234567890',
632
+ mediaType: 'image/png',
633
+ bytes: 11,
634
+ width: 4,
635
+ height: 2,
636
+ }
637
+ // **关键**:harness 把每个 tool 结果放**各自的** user-role 消息里(现场就是
638
+ // 两条独立消息)。这是第一版修复漏掉的形态——按消息各发一条 user(图) 会得到
639
+ // `tool,user,tool,user`,上游照样 400/11148。必须跨消息攒图、发完整串再补一条。
640
+ const body = await captureBodyWith({
641
+ messages: [
642
+ { role: 'user', content: [{ type: 'text', text: '读这两张图' }] },
643
+ {
644
+ role: 'assistant',
645
+ content: [
646
+ { type: 'tool-call', id: 'c1', name: 'read_image', arguments: '{}' },
647
+ { type: 'tool-call', id: 'c2', name: 'read_image', arguments: '{}' },
648
+ ],
649
+ },
650
+ { role: 'user', content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'tree_assembly.png' }, { type: 'image', attachment: ref }] }] },
651
+ { role: 'user', content: [{ type: 'tool-result', toolCallId: 'c2', content: [{ type: 'text', text: 'tree_part.png' }, { type: 'image', attachment: ref }] }] },
652
+ ],
653
+ }, fakeAttachments())
654
+ const messages = body.messages as Array<{ role: string; tool_call_id?: string; tool_calls?: Array<{ id: string }>; content: unknown }>
655
+ assertToolPairingIntact(messages)
656
+
657
+ // 两条 tool 消息必须**连续**紧跟 assistant,各自的文本标签留在自己的 tool 里
658
+ const ai = messages.findIndex((m) => m.role === 'assistant')
659
+ assert.deepEqual(messages.slice(ai + 1, ai + 3).map((m) => m.role), ['tool', 'tool'], '两条 tool 必须连续,中间不得插 user')
660
+ assert.equal(messages[ai + 1]?.content, 'tree_assembly.png', '第一条 tool 带自己的标签')
661
+ assert.equal(messages[ai + 2]?.content, 'tree_part.png', '第二条 tool 带自己的标签')
662
+
663
+ // 图片合并到全部 tool 之后的那**一条** user 消息里(两张都在,不静默丢图)
664
+ const userWithImage = messages.find((m) => m.role === 'user' && Array.isArray(m.content))
665
+ assert.ok(userWithImage !== undefined, '带图内容必须序列化成 user 消息的 content 数组')
666
+ const parts = userWithImage.content as Array<{ type: string; text?: string }>
667
+ assert.equal(parts.filter((p) => p.type === 'image_url').length, 2, '两张图都要发出去')
668
+ const lastTool = messages.map((m) => m.role).lastIndexOf('tool')
669
+ const imageAt = messages.indexOf(userWithImage)
670
+ assert.ok(imageAt > lastTool, `带图 user 消息必须排在全部 tool 消息之后(实际 tool 末位 ${lastTool},图在 ${imageAt})`)
671
+ // 整段 wire 里 user(带图) 只出现一次:插两次就会把 tool 串切断
672
+ const imageUsers = messages.filter((m) => m.role === 'user' && Array.isArray(m.content))
673
+ assert.equal(imageUsers.length, 1, '带图 user 消息只能有一条(多条 = 切断了 tool 配对)')
674
+ })
675
+
676
+ /**
677
+ * 为什么要有这一组:`read_image` 的结果是**带图的 tool-result**。适配器曾把
678
+ * 图片和文本一起提成一条 `user` 消息、排在 `tool` 消息**之前**,于是 wire 里
679
+ * 出现 `assistant(tool_calls) → user(image) → tool`。上游判定 tool_call 与
680
+ * tool_result 失配,回 400 网关码 11148「tool calls and tool results do not
681
+ * match」(实测直连复现,把 tool 提前即 200)。
682
+ *
683
+ * 后果被池策略放大:11148 当时归 `unknown` → 瞬时冷却 30s;组合 money 两条腿
684
+ * 都用同一份历史,一条带图请求就把它俩同时冷掉,之后**连纯文本请求**也全灭
685
+ * 503(用户看到的现象)。所以这条不变式必须在 wire 层锁死:
686
+ * **assistant 的每个 tool_call 后面必须紧跟配套 tool 消息,中间不得插任何
687
+ * 其它角色**。
688
+ */
689
+
690
+ /** 断言 wire 里 tool_call / tool_result 严格配对且不被插队。 */
691
+ function assertToolPairingIntact(messages: Array<{ role: string; tool_call_id?: string; tool_calls?: Array<{ id: string }> }>): void {
692
+ for (let i = 0; i < messages.length; i++) {
693
+ const m = messages[i]
694
+ if (m?.role !== 'assistant' || (m.tool_calls ?? []).length === 0) continue
695
+ const wanted = new Set((m.tool_calls ?? []).map((c) => c.id))
696
+ // 紧随其后的必须**连续**是配套 tool 消息,中途混入任何别的角色都算失配
697
+ // (现场踩的就是 user(图) 插在两条 tool 之间,wire 成 tool,user,tool,user)
698
+ let j = i + 1
699
+ const satisfied = new Set<string>()
700
+ while (j < messages.length && messages[j]?.role === 'tool') {
701
+ const id = messages[j]?.tool_call_id ?? ''
702
+ assert.ok(wanted.has(id), `第 ${j} 条 tool 消息的 tool_call_id ${JSON.stringify(id)} 不在上一条 assistant 的 tool_calls 里`)
703
+ satisfied.add(id)
704
+ j += 1
705
+ }
706
+ assert.equal(
707
+ satisfied.size,
708
+ wanted.size,
709
+ `assistant 的 tool_calls 未被紧邻的 tool 消息配齐`
710
+ + `(中间插了别的角色:${messages.slice(i + 1, j).map((x) => x.role).join(',') || '—'})`,
711
+ )
712
+ for (const id of wanted) {
713
+ assert.ok(satisfied.has(id), `tool_call ${id} 没有配套 tool 消息(上游会回 11148)`)
714
+ }
715
+ }
716
+ }
717
+
718
+ test('wire:纯文本 tool-result 不因图片逻辑改变顺序(tool 紧跟 assistant)', async () => {
719
+ const body = await captureBodyWith({
720
+ messages: [
721
+ { role: 'user', content: [{ type: 'text', text: '跑一下' }] },
722
+ { role: 'assistant', content: [{ type: 'tool-call', id: 'c1', name: 'bash', arguments: '{}' }] },
723
+ { role: 'user', content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'out' }] }] },
724
+ ],
725
+ }, fakeAttachments())
726
+ const messages = body.messages as Array<{ role: string; tool_call_id?: string; tool_calls?: Array<{ id: string }> }>
727
+ assertToolPairingIntact(messages)
728
+ assert.deepEqual(messages.map((m) => m.role), ['user', 'assistant', 'tool'], '纯文本 tool 结果仍紧跟 assistant')
627
729
  })
@@ -78,18 +78,40 @@ export interface RouterAdapterSource {
78
78
  * base64)。图片字节经 `resolveAttachments` 读取;读不到时回退为稳定的占位文本,
79
79
  * **绝不静默丢图**(丢图 = 模型看到一条没有图的消息却毫无提示)。
80
80
  */
81
+ /** tool-result 里图片挂到 user 消息时用的说明文字(与官方适配器一致)。 */
82
+ const TOOL_RESULT_IMAGE_TEXT = 'Tool result images'
83
+
81
84
  async function wireMessages(options: GenerateOptions, system: string | undefined, attachments: RouterAttachmentStore | undefined): Promise<Array<Record<string, unknown>>> {
82
85
  const out: Array<Record<string, unknown>> = []
86
+ /**
87
+ * 攒着「tool-result 里带出来的图片」,等这一串 tool 消息**发完**再合并成一条
88
+ * user 消息补在后面。
89
+ *
90
+ * 为什么必须跨消息攒、而不是各消息各发一条:harness 把**每个 tool 结果放
91
+ * 自己的 user-role 消息**里(读两张图 = 两条消息)。若每条消息各发一个
92
+ * `user(图)`,wire 就成 `assistant → tool → user(图) → tool → user(图)`——
93
+ * 中间那条 user 又把 tool_call/tool_result 配对接断了,上游照样 400/11148
94
+ * (实测:tool,user,tool,user = 400;tool,tool,user(两图) = 200)。
95
+ * 官方 dsh-llm-deepseek 的 serializeMessagesWithImages 同样这么做。
96
+ */
97
+ let pendingToolImages: Array<Record<string, unknown>> = []
98
+ const flushToolImages = (): void => {
99
+ if (pendingToolImages.length === 0) return
100
+ out.push({ role: 'user', content: [{ type: 'text', text: TOOL_RESULT_IMAGE_TEXT }, ...pendingToolImages] })
101
+ pendingToolImages = []
102
+ }
83
103
  if (typeof system === 'string' && system !== '') {
84
104
  out.push({ role: 'system', content: system })
85
105
  }
86
106
  for (const message of options.messages) {
87
107
  if (message.role === 'system') {
88
108
  // 手写调用也可能把 system 塞进 messages(one-shot 场景),照旧支持
109
+ flushToolImages()
89
110
  out.push({ role: 'system', content: flattenText(message.content) })
90
111
  continue
91
112
  }
92
113
  if (message.role === 'assistant') {
114
+ flushToolImages()
93
115
  const text = flattenText(message.content)
94
116
  const toolCalls = message.content
95
117
  .filter((b) => b.type === 'tool-call')
@@ -108,24 +130,50 @@ async function wireMessages(options: GenerateOptions, system: string | undefined
108
130
  }
109
131
  // user / tool-result
110
132
  const toolResults = message.content.filter((b) => b.type === 'tool-result')
111
- // 有图 → content 数组;纯文本 → 保持紧凑字符串 wire 形式不回归。
112
- if (hasImageBlock(message.content)) {
113
- const parts = await contentParts(message.content, attachments, options.signal)
114
- if (parts.length > 0) out.push({ role: 'user', content: parts })
115
- else if (toolResults.length === 0) out.push({ role: 'user', content: '' })
116
- } else {
117
- const text = flattenText(message.content)
118
- if (text.length > 0 || toolResults.length === 0) out.push({ role: 'user', content: text })
133
+ // 这条消息自己的(非 tool-result 的)user 部分:文本 + 图片。
134
+ const ownParts = await contentParts(
135
+ message.content.filter((b) => b.type !== 'tool-result'),
136
+ attachments,
137
+ options.signal,
138
+ )
139
+ // 有实质 user 内容(或压根没有 tool 结果)就先发这条 user 消息;
140
+ // 只有 tool 结果的「纯工具」消息不发空 user,图片留到 pending 里合并。
141
+ if (ownParts.length > 0 || toolResults.length === 0) {
142
+ flushToolImages()
143
+ out.push({ role: 'user', content: compactUserContent(ownParts) })
119
144
  }
120
145
  for (const result of toolResults) {
146
+ // **tool 消息必须紧跟 assistant(tool_calls)**:中间插进任何 user 消息,
147
+ // 上游都会判定 tool_call 与 tool_result 失配 —— codebuddy 直接 400 网关码
148
+ // 11148「tool calls and tool results do not match」。
149
+ // 图片不能放进 tool 消息(实测模型会把两张不同的图认成同一张),
150
+ // 也不能插在两条 tool 之间(同样 400),所以先攒起来、发完整串再补一条 user。
151
+ const parts = await contentParts(result.content, attachments, options.signal)
152
+ const imageParts = parts.filter((p) => p.type !== 'text')
153
+ const text = parts.filter((p) => p.type === 'text').map((p) => (p as { text?: string }).text ?? '').join('')
121
154
  // 空结果就发空串,不要替换成 '(no output)' 之类的字面量 —— 模型会
122
155
  // 以为工具真的打印了那句话(9router 也是补 content: "")。
123
- out.push({ role: 'tool', tool_call_id: result.toolCallId, content: flattenText(result.content) })
156
+ out.push({ role: 'tool', tool_call_id: result.toolCallId, content: text })
157
+ pendingToolImages.push(...imageParts)
124
158
  }
125
159
  }
160
+ flushToolImages()
126
161
  return out
127
162
  }
128
163
 
164
+ /**
165
+ * 纯文本 parts 收成紧凑字符串 wire 形式(无图时不回归成数组)。
166
+ * 与官方适配器的 userContent 同义:只要有非 text part 就保持数组,否则 join。
167
+ */
168
+ function compactUserContent(parts: Array<Record<string, unknown>>): string | Array<Record<string, unknown>> {
169
+ const texts: string[] = []
170
+ for (const p of parts) {
171
+ if (p.type !== 'text') return parts
172
+ texts.push((p as { text?: string }).text ?? '')
173
+ }
174
+ return texts.join('')
175
+ }
176
+
129
177
  /**
130
178
  * 一块 DSH user/tool-result 内容序列化成 OpenAI content parts(text + image_url)。
131
179
  * 纯文本时返回空数组(上层走紧凑字符串路径)。图片转 base64 data URI;
@@ -238,15 +286,6 @@ function flattenText(blocks: readonly { type: string; text?: string }[]): string
238
286
  return blocks.filter((b) => b.type === 'text').map((b) => b.text ?? '').join('')
239
287
  }
240
288
 
241
- /** 内容块里(含递归进 tool-result)是否存在 image 块。决定走数组 content 还是字符串。 */
242
- function hasImageBlock(blocks: readonly { type: string }[]): boolean {
243
- for (const b of blocks) {
244
- if (b.type === 'image') return true
245
- if (b.type === 'tool-result' && hasImageBlock((b as unknown as { content: ReadonlyArray<{ type: string }> }).content)) return true
246
- }
247
- return false
248
- }
249
-
250
289
  interface OpenBlock {
251
290
  index: number
252
291
  kind: 'text' | 'reasoning' | 'tool-call'
@@ -380,6 +380,22 @@ test('no_such_model 不惩罚账号:不冷却、不计数', () => {
380
380
  assert.equal(out?.err_count, 0)
381
381
  })
382
382
 
383
+ /**
384
+ * 为什么要有这一条:请求本身非法(如 11133 参数错、11148 tool_call 配对断裂)
385
+ * 对池里**每个号**结果都相同。曾把这类归 rate_limit → 瞬冷 30s,于是一次
386
+ * 带图请求把 money 组合两条腿同时冷掉,之后连纯文本请求也全灭 503。
387
+ * 不惩罚账号,好号才不会替一条坏请求陪葬。
388
+ */
389
+ test('bad_request 不惩罚账号:不冷却、不计数(一次坏请求不该冷掉整池)', () => {
390
+ const p = pool()
391
+ const list = accs(['a'])
392
+ for (let i = 0; i < 10; i++) p.noteFailure('a', 'm1', 'bad_request', 'codebuddy 11148: tool calls do not match')
393
+ assert.equal(p.pick(list, [], 'fallback', 'm1'), 'a', '号必须仍然可用')
394
+ const out = p.decorate(list, 'm1')[0]
395
+ assert.equal(out?.cooling, false, '请求非法不该把号标成冷却')
396
+ assert.equal(out?.err_count, 0)
397
+ })
398
+
383
399
  test('状态表:各 AccountState 的处置符合预期', () => {
384
400
  const p = pool()
385
401
  const list = accs(['rate', 'quota', 'dead', 'unavail'])
@@ -76,6 +76,9 @@ const RULES: Record<AccountState, Rule> = {
76
76
  unknown: { cooldown: 'transient', counts: false },
77
77
  // 模型不属于本供应商:不是账号的错,不冷却也不计数(核心据此换下一个供应商)
78
78
  no_such_model: { cooldown: 0, counts: false },
79
+ // 请求本身非法(上游拒收这个 payload):同一个请求对池里每个号都会失败,
80
+ // 冷号只会把「这条请求有问题」放大成「这个模型谁都别用」。不惩罚账号。
81
+ bad_request: { cooldown: 0, counts: false },
79
82
  }
80
83
 
81
84
  /**
@@ -71,6 +71,7 @@ function stripAlias(model: string, alias: string): string {
71
71
  *
72
72
  * 只对**瞬时**故障等:连接层失败 / 上游不可用(对应 9router 的 502/503/504)。
73
73
  * 模型不属于本供应商(no_such_model)绝不能等 —— 它不是故障,等纯属浪费。
74
+ * 请求本身非法(bad_request)同理:换哪个模型都是同一个 400,等也是白等。
74
75
  */
75
76
  const TRANSIENT_SETTLE_MS = 2_000
76
77
 
@@ -998,7 +999,8 @@ export class Router {
998
999
  if (s.accounts !== undefined && s.accounts().length === 0) {
999
1000
  const r = await s.chatOnce('', req.lv ?? 'auto', req)
1000
1001
  if (!r.ok) {
1001
- if (r.state === 'no_such_model') return false // 不是我的模型:换供应商,不记账
1002
+ // 模型不属于本供应商、或请求本身被上游拒收:换供应商,不记账
1003
+ if (r.state === 'no_such_model' || r.state === 'bad_request') return false
1002
1004
  if (trace !== undefined) trace.lastState = r.state
1003
1005
  pool.noteFailure('', req.model, r.state, r.message)
1004
1006
  return false
@@ -1028,10 +1030,15 @@ export class Router {
1028
1030
  tried.add(uid)
1029
1031
  const r = await s.chatOnce(uid, req.lv ?? 'auto', req)
1030
1032
  if (!r.ok) {
1031
- // 模型不属于本供应商:整个供应商都跳过,换号重试没有意义,
1032
- // 也不能记在账号头上(否则无关账号会被攒够错误冷却掉)
1033
- if (r.state === 'no_such_model') {
1034
- if (trace !== undefined) trace.lastError = 'no_such_model'
1033
+ // 模型不属于本供应商、或请求本身被上游拒收:整个供应商都跳过,
1034
+ // 换号重试没有意义,也不能记在账号头上(否则无关账号会被冷掉)。
1035
+ // bad_request(如 tool_call 配对断裂、图片格式不认)对每个号结果相同,
1036
+ // 冷号只会把一次「请求有问题」放大成「这个模型谁都别用」。
1037
+ if (r.state === 'no_such_model' || r.state === 'bad_request') {
1038
+ if (trace !== undefined) {
1039
+ trace.lastError = r.state
1040
+ trace.lastState = r.state
1041
+ }
1035
1042
  return false
1036
1043
  }
1037
1044
  pool.noteFailure(uid, req.model, r.state, r.message)
@@ -61,7 +61,14 @@ function num(v: unknown): number {
61
61
  * `cached_tokens` 口径照 9router 的 `canonicalizeUsage`:OpenAI 系报的
62
62
  * prompt_tokens **已含**缓存,直接透传;Claude 系报的是**不含**缓存的
63
63
  * prompt + 单独的 cache_read,要折进来,否则总输入偏小。
64
- * 判别式就是「有没有 cache_read_input_tokens」。
64
+ *
65
+ * 判别式**不能只看有没有 `cache_read_input_tokens`**:TRAE(SOLO) 借用了这个
66
+ * Claude 字段名,语义却是 OpenAI 的「prompt_tokens 已含缓存」——
67
+ * 于是同时报了 `prompt_tokens=160019` 和 `cache_read_input_tokens=159744`,
68
+ * 旧逻辑无条件相加 → 总量翻倍、命中率被腰斩成 50%。正确判别是**看 prompt
69
+ * 车道**:有 `prompt_tokens` = OpenAI 总语义(已含缓存,**不**再折 cache_read);
70
+ * 只有 `input_tokens`/`promptTokenCount` = Claude/Gemini 不含缓存,才折。
71
+ * 这与供应商无关、自描述,比把 supplierId 灌进 usage 管线更干净。
65
72
  *
66
73
  * @returns 归一用量;一个非零字段都没有时返回 null(= 没拿到 usage)
67
74
  */
@@ -77,7 +84,11 @@ export function normalizeUsage(raw: unknown): UsageTokens | null {
77
84
 
78
85
  // promptTokenCount 是 Gemini 形态;input_tokens 是 Claude 形态
79
86
  let prompt = num(u.prompt_tokens) + num(u.input_tokens) + num(u.promptTokenCount)
80
- if (u.cache_read_input_tokens !== undefined) prompt += num(u.cache_read_input_tokens)
87
+ // 有 prompt_tokens = OpenAI 总语义(含缓存);没有 = Claude/Gemini 不含缓存,
88
+ // 这时才该把 cache_read 折进来,否则像 TRAE 会加两遍
89
+ if (u.cache_read_input_tokens !== undefined && u.prompt_tokens === undefined) {
90
+ prompt += num(u.cache_read_input_tokens)
91
+ }
81
92
 
82
93
  const completion = num(u.completion_tokens) + num(u.output_tokens) + num(u.candidatesTokenCount)
83
94
 
@@ -12,6 +12,7 @@ import {
12
12
  tapStreamUsage,
13
13
  withEstimates,
14
14
  estimateTokens,
15
+ toTokenUsage,
15
16
  } from './usage-tokens.ts'
16
17
  import { UsageStore, localDateKey } from './usage-store.ts'
17
18
  import { mkdirSync, writeFileSync } from 'node:fs'
@@ -35,6 +36,22 @@ test('归一化:Claude 形态的 prompt 不含缓存,cache_read 要折进输
35
36
  assert.deepEqual(u, { promptTokens: 130, completionTokens: 10, cachedTokens: 80 })
36
37
  })
37
38
 
39
+ test('归一化:TRAE 借 Claude 字段名、语义却是 OpenAI —— 不得重复加 cache(命中率腰斩 bug)', () => {
40
+ // TRAE(SOLO) 同时报 prompt_tokens(OpenAI 总语义,**已含**缓存)和
41
+ // cache_read_input_tokens。语义与字段名分属两条车道,判别不能只看后者。
42
+ // 2026-09-16 实测同一次请求:160019 + 159744 被加两遍 → 319763,命中率腰斩
43
+ // 成 ~50%;真实命中率 159744/160019 = 99.8%。
44
+ const u = normalizeUsage({
45
+ prompt_tokens: 160019,
46
+ completion_tokens: 307,
47
+ cache_read_input_tokens: 159744,
48
+ })
49
+ assert.deepEqual(u, { promptTokens: 160019, completionTokens: 307, cachedTokens: 159744 }, 'prompt_tokens 已含缓存,不得再加 cache_read')
50
+ // DISJOINT 折算后必须还原上游真实值:160019 - 159744 = 275 未缓存输入
51
+ const t = toTokenUsage(u)
52
+ assert.deepEqual(t, { inputTokens: 275, outputTokens: 307, cacheReadTokens: 159744 })
53
+ })
54
+
38
55
  test('归一化:Gemini 形态(promptTokenCount / usageMetadata)', () => {
39
56
  const u = normalizeUsage({ promptTokenCount: 7, candidatesTokenCount: 3, cachedContentTokenCount: 2 })
40
57
  assert.deepEqual(u, { promptTokens: 7, completionTokens: 3, cachedTokens: 2 })
@@ -67,6 +67,16 @@ export type AccountState =
67
67
  * 把它冷却掉(历史上正是这个坑)。
68
68
  */
69
69
  | 'no_such_model'
70
+ /**
71
+ * 请求本身不合法,被上游拒(参数错、图片格式不支持、tool_call 配对断裂…)。
72
+ *
73
+ * **不是账号的错**:同一个请求对池里每个号都会失败,冷号只会让好号白白
74
+ * 失效,把一次「这条请求有问题」放大成「这个模型 30 秒内谁都别用」——
75
+ * 组合里两条腿同时被冷就是这个机制(2026-09-15 读图 11148 事故)。
76
+ * 核心据此**不惩罚账号**,直接跳出本供应商换下一个模型/供应商。
77
+ * 与 `no_such_model` 的区别:那个是「模型不归我」,这个是「请求我不认」。
78
+ */
79
+ | 'bad_request'
70
80
 
71
81
  /** 面板展示的账号此刻状态(插件只报它观察到的部分,冷却由核心叠加)。 */
72
82
  export interface SupplierAccountNow {
@@ -0,0 +1,36 @@
1
+ /**
2
+ * HTTP 状态码 → AccountState 公共映射测试。
3
+ *
4
+ * 存在的原因:这条映射曾在内置三家供应商里各写一份,于是「哪个错误算账号的错」
5
+ * 有三份实现——bad_request 加固差点只补上 codebuddy,三家内置仍是 4xx→unknown
6
+ * →瞬冷 30s(同型事故)。收敛成一处后,这一份测试同时守住三家。
7
+ */
8
+ import { test } from 'node:test'
9
+ import assert from 'node:assert/strict'
10
+ import { stateFromHttpStatus } from './http-state.ts'
11
+
12
+ test('映射:账号侧错误(换号会好的)归各自的冷却状态', () => {
13
+ assert.equal(stateFromHttpStatus(429), 'rate_limit', '限流 = 换号有用')
14
+ assert.equal(stateFromHttpStatus(401), 'session_dead', '凭证失效')
15
+ assert.equal(stateFromHttpStatus(403), 'session_dead', '403 分不清死活与风控,按连接级冷却')
16
+ assert.equal(stateFromHttpStatus(402), 'quota', '要钱/额度不足')
17
+ assert.equal(stateFromHttpStatus(404), 'unavailable', '上游没有这个端点/服务下线')
18
+ })
19
+
20
+ test('映射:请求侧 4xx 归 bad_request —— 同一个请求对每个号都一样失败,不该冷号', () => {
21
+ for (const status of [400, 405, 409, 413, 422, 499]) {
22
+ assert.equal(stateFromHttpStatus(status), 'bad_request', `${status} 是请求形态问题,不是账号的错`)
23
+ }
24
+ })
25
+
26
+ test('映射:5xx 与异常码仍归 unknown(上游故障侧,瞬冷换号是对的)', () => {
27
+ for (const status of [500, 502, 503, 504, 599]) {
28
+ assert.equal(stateFromHttpStatus(status), 'unknown', `${status} 是上游故障`)
29
+ }
30
+ })
31
+
32
+ test('映射:绝不返回 ok —— 这个函数只在非 2xx 分支被调用', () => {
33
+ for (const status of [400, 401, 402, 403, 404, 429, 500]) {
34
+ assert.notEqual(stateFromHttpStatus(status), 'ok')
35
+ }
36
+ })
@@ -0,0 +1,31 @@
1
+ /**
2
+ * HTTP 状态码 → AccountState 的公共映射(内置三家供应商共用)。
3
+ *
4
+ * 为什么收敛成一处:opencode / openrouter / nvidia 本来各写一份逐字相同的
5
+ * 三元表达式,于是「哪个状态算谁的错」这件事有三份实现,改一处就漏两处
6
+ * (bad_request 就是这么差点只补了 codebuddy)。
7
+ *
8
+ * 关键判据:**这个错误换一个账号会不会好?**
9
+ * - 会好(限流/额度/凭证)→ 是账号的错,交给核心冷却/换号
10
+ * - 不会好(请求参数、模型不存在)→ 不是账号的错,绝不能冷号
11
+ * 否则一次坏请求会把整个池冷掉(2026-09-15 codebuddy 读图事故的同型:
12
+ * 组合两条腿同时被冷,之后连正常文本请求也全灭 503)
13
+ */
14
+ import type { AccountState } from './contract.ts'
15
+
16
+ /**
17
+ * 非 2xx 上游响应 → 语义状态。
18
+ *
19
+ * 4xx 里除了「限流/凭证/不存在」这三种明确归属账号的,剩下的都是**请求本身
20
+ * 有问题**(400 参数错、413 太大、422 语义错…)——同一个请求对池里每个号结果
21
+ * 都相同,归 `bad_request`(核心不惩罚账号,直接换下一个模型)。
22
+ * 5xx / 说不清的仍归 `unknown`(上游故障侧,瞬冷换号是对的)。
23
+ */
24
+ export function stateFromHttpStatus(status: number): AccountState {
25
+ if (status === 429) return 'rate_limit'
26
+ if (status === 401 || status === 403) return 'session_dead'
27
+ if (status === 404) return 'unavailable'
28
+ if (status === 402) return 'quota'
29
+ if (status >= 400 && status < 500) return 'bad_request'
30
+ return 'unknown'
31
+ }
@@ -18,6 +18,7 @@ import { join } from 'node:path'
18
18
  import type { ChatRequest, ModelInfo } from '../../router/types.ts'
19
19
  import type { AccountState, ChatOnceResult, SupplierStatusNow } from '../contract.ts'
20
20
  import type { SupplierEnv, SupplierModule } from '../contract.ts'
21
+ import { stateFromHttpStatus } from '../http-state.ts'
21
22
 
22
23
  export const id = 'nvidia'
23
24
  export const name = 'NVIDIA NIM'
@@ -287,12 +288,7 @@ export default function factory(env: SupplierEnv): SupplierModule {
287
288
  if (upstream.status < 200 || upstream.status >= 300) {
288
289
  const text = await upstream.text().catch(() => '')
289
290
  const msg = `upstream ${upstream.status}: ${text.slice(0, 120)}`
290
- const state: AccountState =
291
- upstream.status === 429 ? 'rate_limit'
292
- : upstream.status === 401 || upstream.status === 403 ? 'session_dead'
293
- : upstream.status === 404 ? 'unavailable'
294
- : 'unknown'
295
- return { ok: false, state, message: msg }
291
+ return { ok: false, state: stateFromHttpStatus(upstream.status), message: msg }
296
292
  }
297
293
 
298
294
  // 流式:上游已是 OpenAI SSE,原样交回核心写
@@ -9,7 +9,8 @@
9
9
  * 面板只显示模型卡片(连接池、签到由能力检测自动隐藏)。
10
10
  */
11
11
  import type { ChatRequest, ModelInfo } from '../../router/types.ts'
12
- import type { AccountState, ChatOnceResult, SupplierEnv, SupplierModule, SupplierStatusNow } from '../contract.ts'
12
+ import type { ChatOnceResult, SupplierEnv, SupplierModule, SupplierStatusNow } from '../contract.ts'
13
+ import { stateFromHttpStatus } from '../http-state.ts'
13
14
 
14
15
 
15
16
  export const id = 'opencode'
@@ -125,9 +126,7 @@ export default function factory(env: SupplierEnv): SupplierModule {
125
126
  if (upstream.status < 200 || upstream.status >= 300) {
126
127
  const text = await upstream.text().catch(() => '')
127
128
  const msg = `upstream ${upstream.status}: ${text.slice(0, 200)}`
128
- const state: AccountState =
129
- upstream.status === 429 ? 'rate_limit' : upstream.status === 404 ? 'unavailable' : 'unknown'
130
- return { ok: false, state, message: msg }
129
+ return { ok: false, state: stateFromHttpStatus(upstream.status), message: msg }
131
130
  }
132
131
 
133
132
  // 流式:上游已是 OpenAI SSE,原样交回核心(核心负责写响应)
@@ -12,6 +12,7 @@
12
12
  import type { ChatRequest, ModelInfo } from '../../router/types.ts'
13
13
  import type { AccountState, ChatOnceResult, SupplierStatusNow } from '../contract.ts'
14
14
  import type { SupplierEnv, SupplierModule } from '../contract.ts'
15
+ import { stateFromHttpStatus } from '../http-state.ts'
15
16
 
16
17
  export const id = 'openrouter'
17
18
  export const name = 'OpenRouter'
@@ -202,13 +203,8 @@ export default function factory(env: SupplierEnv): SupplierModule {
202
203
  if (upstream.status < 200 || upstream.status >= 300) {
203
204
  const text = await upstream.text().catch(() => '')
204
205
  const msg = `upstream ${upstream.status}: ${text.slice(0, 120)}`
205
- // 401/403 = 这个 key 本身不可用;429 = 限流;其余归为说不清
206
- const state: AccountState =
207
- upstream.status === 429 ? 'rate_limit'
208
- : upstream.status === 401 || upstream.status === 403 ? 'session_dead'
209
- : upstream.status === 404 ? 'unavailable'
210
- : 'unknown'
211
- return { ok: false, state, message: msg }
206
+ // 401/403 = 这个 key 本身不可用;429 = 限流;其余见 stateFromHttpStatus
207
+ return { ok: false, state: stateFromHttpStatus(upstream.status), message: msg }
212
208
  }
213
209
 
214
210
  // 流式:上游已是 OpenAI SSE,原样交回核心写