mslxdff 0.1.154 → 0.1.156

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.154",
3
+ "version": "0.1.156",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,7 +10,7 @@ import { handleBroadbandRelay } from "../routes/chat/broadband-handler.js";
10
10
  import { handleViaRoute } from "../routes/chat/via-route-handler.js";
11
11
  import { handleExhaustedLocal, handleExhaustedAll } from "../routes/chat/exhausted-handler.js";
12
12
  import { shouldUseGroupForModel, isHardLocalOnly, isKeyProviderDirectOnly } from "../state/schemas/use-group.js";
13
- import { summarizeRequest } from "../model-trace.js";
13
+ import { summarizeRequest, upstreamEcho } from "../model-trace.js";
14
14
  import { isEmptyTurnError } from "../routes/chat/relay-pipeline.js";
15
15
 
16
16
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -91,6 +91,8 @@ export async function runSerialTrial(ctx, deps = {}) {
91
91
  if (Object.keys(shareKeys).length) chatOpts.shareKeys = shareKeys;
92
92
  if (workbuddyUid) chatOpts.workbuddyUid = workbuddyUid;
93
93
  if (handlerCtx?.sessionId) chatOpts.sessionId = handlerCtx.sessionId;
94
+ // reqId = 本次客户端请求的身份:供应商据此"同请求粘号"(重试不换号,只有 401/403/429/5xx 冷却才换)
95
+ if (reqId) chatOpts.reqId = reqId;
94
96
  const chatOptsArg = Object.keys(chatOpts).length ? chatOpts : undefined;
95
97
  // 空转 200(模型无输出)同模型暂停重试:默认 2 次、间隔 1s;仅 EMPTY_MODEL_RESPONSE,
96
98
  // 429/403/500 与 fetch 异常走原有切号/failover(防烧额度)。MSLXDFF_EMPTY_TURN_RETRIES=0 关闭。
@@ -101,7 +103,7 @@ export async function runSerialTrial(ctx, deps = {}) {
101
103
  evt("upstream-try", { reqId, model, attempt: idx + 1, emptyRetry: emptyRetried, payload: summarizeRequest(forwarded) });
102
104
  try {
103
105
  upRes = await upstream.chat(forwarded, chatOptsArg);
104
- evt("upstream-done", { reqId, model, ok: !(upRes instanceof Error) && upRes.status < 400, status: upRes instanceof Error ? null : upRes.status, timing: upRes._t ?? null, error: null });
106
+ evt("upstream-done", { reqId, model, ok: !(upRes instanceof Error) && upRes.status < 400, status: upRes instanceof Error ? null : upRes.status, timing: upRes._t ?? null, error: null, ...upstreamEcho(upRes) });
105
107
  } catch (err) {
106
108
  if (auto) await auto.recordError(model, { message: errMsg(err) });
107
109
  lastErr = { model, upstream: null, status: 502, message: errMsg(err) };
@@ -148,7 +150,7 @@ export async function runSerialTrial(ctx, deps = {}) {
148
150
  const upMsg = upBody || `upstream ${upRes.status}`;
149
151
  lastErr = { model, upstream: upRes, status: upRes.status, message: upMsg };
150
152
  logError(model, upRes.status, `upstream ${upRes.status}${upBody ? ` body=${upBody.slice(0, 300)}` : ""}`);
151
- evt("upstream-error", { reqId, model, status: upRes.status, message: upMsg.slice(0, 300), timing: upRes._t ?? null });
153
+ evt("upstream-error", { reqId, model, status: upRes.status, message: upMsg.slice(0, 300), timing: upRes._t ?? null, ...upstreamEcho(upRes) });
152
154
  upRes = null;
153
155
  break;
154
156
  }
@@ -170,7 +172,8 @@ export async function runSerialTrial(ctx, deps = {}) {
170
172
  if (lr.handled) return { done: true };
171
173
  if (lr.lastErr && isEmptyTurnError(lr.lastErr) && emptyRetried < emptyCfg.max) {
172
174
  emptyRetried++;
173
- evt("empty-turn-retry", { reqId, model, retry: emptyRetried, max: emptyCfg.max, delayMs: emptyCfg.delayMs });
175
+ // 带上"刚空转的是哪个号/哪个站":切号是重试驱动的,日志必须能自证
176
+ evt("empty-turn-retry", { reqId, model, retry: emptyRetried, max: emptyCfg.max, delayMs: emptyCfg.delayMs, ...upstreamEcho(upRes) });
174
177
  await sleep(emptyCfg.delayMs);
175
178
  continue;
176
179
  }
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * 其他供应商 allowlist 段 + 遗留别名映射展示。
3
3
  * 输入均为已算好的数据;state/model-id 走动态 import(与原内联一致,避免循环依赖)。
4
+ * allowlist 按 compareModelIds 排序展示(只改展示顺序,不改 allowlist 落盘内容)。
4
5
  */
6
+ import { compareModelIds } from "./list-sort.js";
5
7
  export async function renderOtherProviders({ pickedIds, ids, fullAliases }) {
6
8
  const { loadProviderConfigs, loadProviderAllowedModels, loadProviderAllowAnyModels, loadProviderBaseUrl } = await import("../../../state.js");
7
9
  const { loadModelAliases: _la2, getAliasForModel: _gaf } = await import("../../../providers/model-id.js");
@@ -38,8 +40,7 @@ export async function renderOtherProviders({ pickedIds, ids, fullAliases }) {
38
40
  console.log(` 或放行全部: mslxdff -provider ${pid} allowAny on`);
39
41
  }
40
42
  } else {
41
- for (const raw of allowed) {
42
- const canonical = `${pid}/${raw}`;
43
+ for (const canonical of allowed.map((raw) => `${pid}/${raw}`).sort(compareModelIds)) {
43
44
  let alias = null;
44
45
  try { alias = _gaf(canonical); } catch {}
45
46
  if (!alias && String(canonical).includes("/")) alias = String(canonical).replace(/\//g, "-");
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { homedir } from "node:os";
4
+ import { compareModelIds, providerOf, providerRank } from "./list-sort.js";
4
5
 
5
6
  /** 构建 id → 别名映射(原 list 两处重复逻辑收敛) */
6
7
  export function buildAliasMap(ids, getAliasForModel) {
@@ -57,22 +58,19 @@ export function renderFreeList({ ids, at, pickedIds, sortedProvs, groups, aliasM
57
58
  }
58
59
  }
59
60
 
60
- /** 按供应商分组 + 排序(opencode/workbuddy/cline/openrouter 优先) */
61
+ /** 按供应商分组 + 排序(opencode 段最前,已知供应商优先,组内字母序) */
61
62
  export function groupByProvider(ids) {
62
63
  const groups = {};
63
64
  for (const id of ids) {
64
- const prov = String(id).includes("/") ? String(id).split("/")[0] : "opencode";
65
+ const prov = providerOf(id);
65
66
  if (!groups[prov]) groups[prov] = [];
66
67
  groups[prov].push(id);
67
68
  }
68
- const order = ["opencode", "workbuddy", "cline", "openrouter"];
69
+ for (const key of Object.keys(groups)) groups[key].sort(compareModelIds);
69
70
  const sortedProvs = Object.keys(groups).sort((a, b) => {
70
- const ia = order.indexOf(a), ib = order.indexOf(b);
71
- if (ia !== -1 || ib !== -1) {
72
- if (ia === -1) return 1;
73
- if (ib === -1) return -1;
74
- return ia - ib;
75
- }
71
+ const ra = providerRank(a);
72
+ const rb = providerRank(b);
73
+ if (ra !== rb) return ra - rb;
76
74
  return a.localeCompare(b);
77
75
  });
78
76
  return { groups, sortedProvs };
@@ -0,0 +1,52 @@
1
+ // `-models` / `-model list` 展示排序:opencode free 优先 → 其余供应商分组 → 组内字母序。
2
+ // 单一来源:本文件被 list.js(交互候选)与 list-render.js(分组渲染)共用。
3
+ // Note: 排序只影响展示顺序,不改 picks/候选集合 — 见 .agents/notes/implemented/feature/2026-09-28-models-sort-opencode-free-first.md
4
+ import { isFreeModel } from "../../../models.js";
5
+
6
+ /** 供应商展示优先级(opencode 恒第一;未知供应商按字母序排其后) */
7
+ export const PROVIDER_ORDER = ["opencode", "workbuddy", "cline", "openrouter", "qoder", "codearts", "traework", "bai"];
8
+
9
+ /** 取模型 id 的供应商前缀;无斜杠的裸 id 归 opencode */
10
+ export function providerOf(id) {
11
+ const s = String(id || "");
12
+ const i = s.indexOf("/");
13
+ return i > 0 ? s.slice(0, i).toLowerCase() : "opencode";
14
+ }
15
+
16
+ /** 供应商排序权重:opencode=0,已知供应商=其序号+1,未知=大数(按字母序兜底) */
17
+ export function providerRank(prov) {
18
+ const i = PROVIDER_ORDER.indexOf(prov);
19
+ return i === -1 ? PROVIDER_ORDER.length : i;
20
+ }
21
+
22
+ /**
23
+ * 模型 id 排序比较器:
24
+ * 1) opencode free(isFreeModel)最前 —— 用户主诉求
25
+ * 2) opencode 非 free 次之
26
+ * 3) 其余供应商按 PROVIDER_ORDER,未知按字母序
27
+ * 4) 同段内 localeCompare 字母序(大小写不敏感兜底)
28
+ */
29
+ export function compareModelIds(a, b) {
30
+ const pa = providerOf(a);
31
+ const pb = providerOf(b);
32
+ const fa = pa === "opencode" && isFreeModel(a);
33
+ const fb = pb === "opencode" && isFreeModel(b);
34
+ // 段位:0=opencode free,1=opencode 其他,2=其他供应商
35
+ const ga = fa ? 0 : pa === "opencode" ? 1 : 2;
36
+ const gb = fb ? 0 : pb === "opencode" ? 1 : 2;
37
+ if (ga !== gb) return ga - gb;
38
+ if (ga === 2) {
39
+ const ra = providerRank(pa);
40
+ const rb = providerRank(pb);
41
+ if (ra !== rb) return ra - rb;
42
+ if (pa !== pb) return pa.localeCompare(pb);
43
+ }
44
+ const la = String(a).toLowerCase();
45
+ const lb = String(b).toLowerCase();
46
+ return la === lb ? String(a).localeCompare(String(b)) : la.localeCompare(lb);
47
+ }
48
+
49
+ /** 返回排序后的新数组(不改原数组) */
50
+ export function sortModelIds(ids) {
51
+ return [...(ids || [])].sort(compareModelIds);
52
+ }
@@ -0,0 +1,76 @@
1
+ // 交互式 `-models`(TTY 多选)主流程:从 list.js 拆出以守住 ≤10KB 体积门。
2
+ // 候选合并(allowlist + live + picks 孤儿)→ 展示排序 → 勾选落盘。
3
+ // Note: 排序规则单一来源在 list-sort.js;候选集合语义不变(只改展示顺序)。
4
+ import { loadModelErrors, loadModelPicks, saveModelPicks } from "../../../state.js";
5
+ import { getPreferredModel } from "../../../auto.js";
6
+ import { pickInteractiveMulti } from "../../interactive.js";
7
+ import { filterStalePicks } from "../../../providers/model-id.js";
8
+ import { mergeLiveIds } from "./list-live.js";
9
+ import { sortModelIds } from "./list-sort.js";
10
+
11
+ /**
12
+ * 拼交互候选:opencode live ids + 已启用 provider 的 allowlist + live 补齐 + 可见 picks。
13
+ * 返回 { items, pickedIds };items 已按 sortModelIds 排序。
14
+ */
15
+ export async function buildPickerItems(liveIds, pickedIds) {
16
+ const statuses = loadModelErrors();
17
+ const current = getPreferredModel();
18
+ const combinedIds = [...liveIds];
19
+ const seen = new Set(combinedIds);
20
+ let staleFilter = null;
21
+ try {
22
+ const { loadProviderConfigs, loadProviderAllowedModels } = await import("../../../state.js");
23
+ const { buildProviderRows } = await import("../../provider-row.js");
24
+ // 已知 = 已启用(与 -provider list 同一 enabled 口径:有 baseUrl 且有 key;
25
+ // openrouter 有 key 即算,opencode 内置恒启用)。未启用 provider 的 picks
26
+ // 不进列表,启用后自动回来(数据未动)。
27
+ const knownProviders = buildProviderRows({}).filter((r) => r.enabled).map((r) => r.id);
28
+ loadProviderConfigs();
29
+ const allowedIds = [];
30
+ for (const pid of knownProviders.filter((k) => String(k).toLowerCase() !== "opencode")) {
31
+ for (const raw of loadProviderAllowedModels(pid)) {
32
+ const canonical = `${pid}/${raw}`;
33
+ allowedIds.push(canonical);
34
+ if (!seen.has(canonical)) {
35
+ seen.add(canonical);
36
+ combinedIds.push(canonical);
37
+ }
38
+ }
39
+ }
40
+ await mergeLiveIds({ combinedIds, seen, knownProviders, liveIds });
41
+ staleFilter = { knownProviders, liveIds, allowedIds };
42
+ } catch {}
43
+ // provider 已不存在的 picks 孤儿不进交互列表(数据不动,status --all 仍可审计)。
44
+ // configs 读失败时 staleFilter 为空 → 不过滤(默认放行)。
45
+ const visiblePicks = staleFilter ? filterStalePicks(pickedIds, staleFilter) : pickedIds;
46
+ for (const pid of visiblePicks) {
47
+ if (!seen.has(pid)) {
48
+ seen.add(pid);
49
+ combinedIds.push(pid);
50
+ }
51
+ }
52
+ // 展示排序:opencode free 最前 → opencode 其他 → 各供应商分组,组内字母序。
53
+ const items = sortModelIds(combinedIds).map((id) => {
54
+ const e = statuses[id];
55
+ return {
56
+ id,
57
+ status: typeof e === "number" ? "error" : e?.status || "normal",
58
+ current: id === current,
59
+ picked: pickedIds.includes(id),
60
+ };
61
+ });
62
+ return { items, pickedIds };
63
+ }
64
+
65
+ /** 跑一轮 TTY 勾选并落盘 picks(取消则不写)。调用方负责 exit。 */
66
+ export async function runInteractivePick(liveIds) {
67
+ const pickedIds = loadModelPicks();
68
+ const { items } = await buildPickerItems(liveIds, pickedIds);
69
+ const result = await pickInteractiveMulti(items, new Set(pickedIds), Math.max(0, items.findIndex((x) => x.current)));
70
+ if (!result) {
71
+ console.log("cancelled — picks unchanged");
72
+ return;
73
+ }
74
+ saveModelPicks([...result]);
75
+ console.log(`saved ${result.size} picked model(s): ${[...result].join(", ") || "(none — auto uses full list)"}`);
76
+ }
@@ -2,16 +2,12 @@ import { join } from "node:path";
2
2
  import { createModelsService } from "../../../models.js";
3
3
  import { createUpstreamClient } from "../../../upstream.js";
4
4
  import { logDir } from "../../../logs.js";
5
- import { loadModelErrors, loadModelPicks, saveModelPicks } from "../../../state.js";
6
- import { getPreferredModel } from "../../../auto.js";
5
+ import { loadModelPicks } from "../../../state.js";
7
6
  import { fmtShanghaiYMDHM } from "../../../time.js";
8
7
  import { readModelsCache } from "../../util.js";
9
- import { pickInteractiveMulti } from "../../interactive.js";
10
8
  import { buildAliasMap, readFullAliases, renderProviderList, renderFreeList, groupByProvider } from "./list-render.js";
11
9
  import { renderOtherProviders } from "./list-providers.js";
12
- import { filterStalePicks } from "../../../providers/model-id.js";
13
- import { mergeLiveIds } from "./list-live.js";
14
-
10
+ import { compareModelIds } from "./list-sort.js";
15
11
  /**
16
12
  * `-model list` 全流程:参数解析 → 刷新/回退 → provider 分支 → TTY 交互 → 分组渲染。
17
13
  * 渲染下沉 list-render / list-providers,本文件只留流程编排。
@@ -104,8 +100,7 @@ export async function handleModelList(args, idx, sub) {
104
100
  const at2 = cachedAt ? ` (cached ${fmtShanghaiYMDHM(cachedAt)})` : "";
105
101
  console.log(`${allowed.length} model(s) for ${prov}${at2} (allowlist,原名 + 别名):`);
106
102
  const pickedIds2 = loadModelPicks();
107
- for (const raw of allowed) {
108
- const canonical = `${prov}/${raw}`;
103
+ for (const canonical of allowed.map((raw) => `${prov}/${raw}`).sort(compareModelIds)) {
109
104
  let alias = null;
110
105
  try { alias = getAliasForModel(canonical); } catch {}
111
106
  if (!alias && String(canonical).includes("/")) alias = String(canonical).replace(/\//g, "-");
@@ -134,60 +129,8 @@ export async function handleModelList(args, idx, sub) {
134
129
  process.exit(0);
135
130
  }
136
131
  if (sub === undefined && process.stdin.isTTY && process.stdout.isTTY) {
137
- const statuses = loadModelErrors();
138
- const current = getPreferredModel();
139
- const pickedIds = loadModelPicks();
140
- const combinedIds = [...ids];
141
- const seen = new Set(combinedIds);
142
- let staleFilter = null;
143
- try {
144
- const { loadProviderConfigs, loadProviderAllowedModels } = await import("../../../state.js");
145
- const { buildProviderRows } = await import("../../provider-row.js");
146
- // 已知 = 已启用(与 -provider list 同一 enabled 口径:有 baseUrl 且有 key;
147
- // openrouter 有 key 即算,opencode 内置恒启用)。未启用 provider 的 picks
148
- // 不进列表,启用后自动回来(数据未动)。
149
- const knownProviders = buildProviderRows({}).filter((r) => r.enabled).map((r) => r.id);
150
- const configs = loadProviderConfigs();
151
- const allowedIds = [];
152
- for (const pid of knownProviders.filter((k) => String(k).toLowerCase() !== "opencode")) {
153
- const allowed = loadProviderAllowedModels(pid);
154
- for (const raw of allowed) {
155
- const canonical = `${pid}/${raw}`;
156
- allowedIds.push(canonical);
157
- if (!seen.has(canonical)) {
158
- seen.add(canonical);
159
- combinedIds.push(canonical);
160
- }
161
- }
162
- }
163
- await mergeLiveIds({ combinedIds, seen, knownProviders, liveIds: ids });
164
- staleFilter = { knownProviders, liveIds: ids, allowedIds };
165
- } catch {}
166
- // provider 已不存在的 picks 孤儿不进交互列表(数据不动,status --all 仍可审计)。
167
- // configs 读失败时 staleFilter 为空 → 不过滤(默认放行)。
168
- const visiblePicks = staleFilter ? filterStalePicks(pickedIds, staleFilter) : pickedIds;
169
- for (const pid of visiblePicks) {
170
- if (!seen.has(pid)) {
171
- seen.add(pid);
172
- combinedIds.push(pid);
173
- }
174
- }
175
- const items = combinedIds.map((id) => {
176
- const e = statuses[id];
177
- return {
178
- id,
179
- status: typeof e === "number" ? "error" : e?.status || "normal",
180
- current: id === current,
181
- picked: pickedIds.includes(id),
182
- };
183
- });
184
- const result = await pickInteractiveMulti(items, new Set(pickedIds), Math.max(0, items.findIndex((x) => x.current)));
185
- if (!result) {
186
- console.log("cancelled — picks unchanged");
187
- process.exit(0);
188
- }
189
- saveModelPicks([...result]);
190
- console.log(`saved ${result.size} picked model(s): ${[...result].join(", ") || "(none — auto uses full list)"}`);
132
+ const { runInteractivePick } = await import("./list-tty.js");
133
+ await runInteractivePick(ids);
191
134
  process.exit(0);
192
135
  }
193
136
  const at = cachedAt ? ` (cached ${fmtShanghaiYMDHM(cachedAt)})` : "";
@@ -1,4 +1,6 @@
1
1
  // Per-model request trace formatting. Never include prompt, response body, headers or credentials.
2
+ // Note: 同步 appendFileSync(异步 append 会在并发下打乱同一请求的阶段行序)— 见 .agents/notes/implemented/feature/2026-09-25-model-trace-log.md
3
+ // Note: 事件面用黑名单(默认全可见,只排除噪声/敏感面)+ 决定类事件只渲染登记过的标量字段——起因是 empty-turn-retry 曾被阶段白名单静默吞掉,"关键决定不许再被漏登记" — 见 .agents/notes/implemented/feature/2026-09-27-qoder-per-request-sticky-account.md
2
4
  import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
5
  import { join } from "node:path";
4
6
  import { logDir } from "./logs.js";
@@ -40,14 +42,21 @@ export function summarizeRequest(body = {}) {
40
42
  function hostPort(value) {
41
43
  try { const u = new URL(String(value)); return u.port ? `${u.hostname}:${u.port}` : u.hostname; } catch { return "-"; }
42
44
  }
43
- const TRACE_STAGES = new Set([
44
- "request", "alias", "ordered", "model-try", "upstream-try", "upstream-done", "upstream-error",
45
- "peer-request", "peer-forward", "peer-error", "relay-start", "relay-first-chunk", "relay-done",
46
- "client-response", "result", "exhausted-local", "exhausted-all",
45
+ // 黑名单(而非白名单):模型日志默认收下**所有**事件,只排除噪声与凭据面。
46
+ // 起因:`empty-turn-retry`("切号换 URL"的真实触发者)曾被白名单静默吞掉,排障时只见现象不见原因。
47
+ // 关键决定不允许再被漏登记:新增事件默认可见,确属噪声/敏感才在此登记排除。
48
+ const TRACE_DENY = new Set([
49
+ "peer-health", // 组员心跳,秒级高频,无决策价值
50
+ "heartbeat", // 运行心跳
51
+ "client-session", // 客户端会话标识(路由用),非决定且属敏感面
47
52
  ]);
48
53
 
49
54
  export function shouldTraceModel(type) {
50
- return TRACE_STAGES.has(String(type || ""));
55
+ const t = String(type || "");
56
+ if (!t) return false;
57
+ if (TRACE_DENY.has(t)) return false;
58
+ if (t.startsWith("upstream-probe")) return false; // 探针类(probe/probe-error/probe-skip)
59
+ return true;
51
60
  }
52
61
 
53
62
  function safeText(value, n = 140) {
@@ -61,6 +70,41 @@ function kv(obj) {
61
70
  return Object.entries(obj).filter(([, v]) => v != null && v !== "").map(([k, v]) => `${k}=${typeof v === "object" ? JSON.stringify(v) : v}`).join(" ");
62
71
  }
63
72
 
73
+ // 决定类事件的可见字段白名单:只渲染这些标量;事件里的 payload/detail/stages/请求正文等一律不落模型日志。
74
+ // 「加日志」不等于「倒数据」——新增决定字段要显式登记在这里才会出现。
75
+ const DECISION_FIELDS = [
76
+ "status", "from", "to", "reason", "hook", "applied", "skipped", "plugin",
77
+ "provider", "before", "after", "models", "skippedFaulty", "limit", "tried",
78
+ "winPeer", "winTarget", "peer", "peers", "peerLabel", "routeBest", "at",
79
+ "latencyMs", "ttfMs", "hedged", "hops", "hasShare", "elapsedMs", "threshold",
80
+ "stallHits", "maxGapMs", "interrupted", "providers", "via",
81
+ "retry", "max", "delayMs", "upstream", "account", "pick", "cooled",
82
+ ];
83
+
84
+ function decisionKv(data = {}) {
85
+ const out = [];
86
+ for (const k of DECISION_FIELDS) {
87
+ const v = data[k];
88
+ if (v == null || v === "") continue;
89
+ out.push(`${k}=${Array.isArray(v) ? v.join("|") : v}`);
90
+ }
91
+ return out.join(" ");
92
+ }
93
+
94
+ // provider 回显头 → 日志字段(谁上的 / 打哪个站 / 为什么选它 / 是否被冷却):
95
+ // 单一来源,防各处重复写法漂移;取不到时返回空对象(其它供应商零变化)。
96
+ export function upstreamEcho(res) {
97
+ const h = res?.headers;
98
+ if (typeof h?.get !== "function") return {};
99
+ const pick = (name) => h.get(name) || null;
100
+ return {
101
+ upstream: pick("x-mslxdff-upstream"),
102
+ account: pick("x-mslxdff-workbuddy-uid") || pick("x-mslxdff-qoder-region"),
103
+ pick: pick("x-mslxdff-qoder-account"),
104
+ cooled: pick("x-mslxdff-qoder-cooldown"),
105
+ };
106
+ }
107
+
64
108
  export function formatModelTrace({ type, reqId, model, data = {}, request = null, totalMs = null } = {}) {
65
109
  const base = { time: fmtShanghaiYMDHMS(new Date()), req: reqId || "-", model: model || "-", stage: type || "-" };
66
110
  let detail = "";
@@ -70,15 +114,16 @@ export function formatModelTrace({ type, reqId, model, data = {}, request = null
70
114
  else if (type === "alias") detail = `rawModel=${data.rawModel || "-"} requested=${data.requested || model || "-"}`;
71
115
  else if (type === "exhausted-local" || type === "exhausted-all") detail = `last=${data.lastModel || model || "-"} status=${data.lastStatus ?? "-"} order=${(data.order || []).join(" | ")}`;
72
116
  else if (type === "upstream-try") detail = `target=${data.model || model || "-"} attempt=${data.attempt ?? "-"} payload=${kv(data.payload || {})}`;
73
- else if (type === "upstream-done" || type === "upstream-error") detail = `upstream status=${data.status ?? "-"} timing=${data.timing?.totalMs ?? "-"}ms ${safeText(data.message || data.error || "")}`;
117
+ else if (type === "upstream-done" || type === "upstream-error") detail = `upstream status=${data.status ?? "-"} timing=${data.timing?.totalMs ?? "-"}ms${data.upstream ? ` via=${data.upstream}` : ""}${data.account ? ` account=${data.account}` : ""}${data.pick ? ` pick=${data.pick}` : ""}${data.cooled ? ` cooled=${data.cooled}` : ""} ${safeText(data.message || data.error || "")}`;
118
+ else if (type === "empty-turn-retry") detail = `retry ${data.retry ?? "-"}/${data.max ?? "-"} delay=${data.delayMs ?? "-"}ms${data.upstream ? ` after=${data.upstream}` : ""}${data.account ? ` account=${data.account}` : ""}${data.pick ? ` pick=${data.pick}` : ""} reason=empty turn`;
74
119
  else if (type === "peer-request" || type === "peer-forward" || type === "peer-error") detail = `peer=${hostPort(data.peer)} status=${data.status ?? "-"} latency=${data.latencyMs ?? "-"}ms payload=${kv(data.payload || {})} ${safeText(data.message || data.error || "")}`;
75
120
  else if (type === "relay-done") {
76
121
  const d = data.detail || {};
77
122
  const u = d.usage || {};
78
- detail = `upstream_response status=${data.status ?? "-"} finish=${d.sawFinishReason || "-"} chunks=${d.receivedChunks ?? "-"} bytes=${d.receivedBytes ?? "-"} tools=${d.toolCalls ?? 0} chars=${d.chars ?? 0} usage(prompt=${u.prompt_tokens ?? "-"}/completion=${u.completion_tokens ?? "-"}) elapsed=${data.totalMs ?? "-"}ms`;
123
+ detail = `upstream_response status=${data.status ?? "-"} finish=${d.sawFinishReason || "-"} chunks=${d.receivedChunks ?? "-"} bytes=${d.receivedBytes ?? "-"} tools=${d.toolCalls ?? 0} chars=${d.chars ?? 0} usage(prompt=${u.prompt_tokens ?? "-"}/completion=${u.completion_tokens ?? "-"}) elapsed=${data.totalMs ?? "-"}ms${data.upstream ? ` upstream=${data.upstream}` : ""}${data.account ? ` account=${data.account}` : ""}${data.pick ? ` pick=${data.pick}` : ""}`;
79
124
  } else if (type === "result" || type === "client-response") {
80
- detail = `client status=${data.status ?? "-"} via=${data.via || "-"} actual=${data.actual || model || "-"} fallback=${data.fallback?.fallback ? 1 : 0} total=${totalMs ?? data.durationMs ?? "-"}ms`;
81
- } else detail = safeText(data.message || data.reason || data.error || "");
125
+ detail = `client status=${data.status ?? "-"} via=${data.via || "-"} actual=${data.actual || model || "-"} fallback=${data.fallback?.fallback ? 1 : 0} total=${totalMs ?? data.durationMs ?? "-"}ms${data.upstream ? ` upstream=${data.upstream}` : ""}${data.account ? ` account=${data.account}` : ""}${data.pick ? ` pick=${data.pick}` : ""}`;
126
+ } else detail = safeText(`${decisionKv(data)} ${data.message || data.error || ""}`, 200);
82
127
  return `[${base.time}] [req=${base.req}] [model=${base.model}] [stage=${base.stage}] ${detail}`;
83
128
  }
84
129
 
@@ -52,5 +52,6 @@ export function createKeyRing(keys = [], { cooldownMs = DEFAULT_COOLDOWN_MS, now
52
52
  return list.filter((k) => !isCooling(k)).length;
53
53
  }
54
54
 
55
- return { next, onError, replace, available, size: list.length, cooldownMs, keys: [...list] };
55
+ // isCooling 对外暴露:粘号选择器要判断"上次这个号还在冷却吗"(决定是否必须换号)
56
+ return { next, onError, replace, available, isCooling, size: list.length, cooldownMs, keys: [...list] };
56
57
  }
@@ -5,7 +5,11 @@ import { normalizeRegion } from "./constants.js";
5
5
  import { buildUpstreamRequest } from "./request.js";
6
6
  import { reshapeQoderStream } from "./stream.js";
7
7
  import { aggregateQoderStream, toCompletionJson } from "./aggregate.js";
8
- import { errorStatus } from "./sse.js";
8
+ import { errorStatus, extractDelta } from "./sse.js";
9
+
10
+ function hostOf(url) {
11
+ try { return new URL(String(url)).host; } catch { return "-"; }
12
+ }
9
13
 
10
14
  function errRes(status, msg, type = "upstream_error") {
11
15
  return new Response(JSON.stringify({ error: { message: msg, type } }), { status, headers: { "Content-Type": "application/json" } });
@@ -15,11 +19,88 @@ function mapUpstreamError(status, detail) {
15
19
  return { kind: status === 401 || status === 403 ? "auth" : "upstream", status, detail: String(detail || "").slice(0, 300) };
16
20
  }
17
21
 
22
+ // 预读上限:判决恒在首帧(实测 457 字节),8 帧足够覆盖"角色帧先到、判决帧随后",
23
+ // 又不会把首字节延迟拖到不可控。超窗未得判决即放弃(退化为无冷却的流内 error,见 SPEC 已知上限)。
24
+ const PEEK_MAX_FRAMES = 8;
25
+
26
+ // 预读上游首帧只为取「流内判决」:qoder 把限流/鉴权判决裹在 HTTP 200 的 SSE 信封里
27
+ //(statusCodeValue:403 + serviceAvailable:false),upRes.status 恒 200,判决只有读完帧才知道。
28
+ // 不预读则状态码永远出不来流式路径 → 门面不冷却该号(ADR-0036 的粘号会把重试继续粘在它上面)。
29
+ // 返回 { res, status, frames }:res 是把预读帧回灌后的等价上游响应,frames 交回灌用。
30
+ // 任何异常都降级为「无判决」(status=0):排障手段不得变成故障源。
31
+ async function peekEnvelopeVerdict(upRes, cdbg) {
32
+ if (!upRes?.body) return { res: upRes, status: 0, frames: [] };
33
+ let reader;
34
+ try { reader = upRes.body.getReader(); } catch { return { res: upRes, status: 0, frames: [] }; }
35
+ const dec = new TextDecoder();
36
+ const frames = [];
37
+ let text = "";
38
+ let status = 0;
39
+ let decided = false;
40
+ try {
41
+ for (let i = 0; i < PEEK_MAX_FRAMES && !decided; i++) {
42
+ const { done, value } = await reader.read();
43
+ if (done) break;
44
+ if (value) { frames.push(value); text += dec.decode(value, { stream: true }); }
45
+ // 逐行判定:先到判决(err)或先到有效载荷即停,避免无谓等待
46
+ for (const raw of text.split("\n")) {
47
+ const line = raw.trim();
48
+ if (!line.startsWith("data:")) continue;
49
+ const p = line.slice(5).trim();
50
+ if (!p || p === "[DONE]") continue;
51
+ const d = extractDelta(p);
52
+ if (d.err) { status = Number(d.err.status) || 0; decided = true; break; }
53
+ if (d.content || d.reasoning || d.toolCalls || d.usageIn || d.usageOut) { decided = true; break; }
54
+ }
55
+ }
56
+ } catch (e) {
57
+ cdbg(`[peek-throw] ${String(e?.message || e).slice(0, 160)}`);
58
+ }
59
+ cdbg(`[peek] frames=${frames.length} verdict=${status || "none(正常流)"}`);
60
+ // 回灌:已读帧先按原样吐出,再接续同一个 reader 的剩余部分
61
+ const body = new ReadableStream({
62
+ start(ctrl) {
63
+ for (const f of frames) { try { ctrl.enqueue(f); } catch {} }
64
+ (async () => {
65
+ try {
66
+ for (;;) {
67
+ const { done, value } = await reader.read();
68
+ if (done) break;
69
+ if (value) ctrl.enqueue(value);
70
+ }
71
+ } catch {}
72
+ try { ctrl.close(); } catch {}
73
+ })();
74
+ },
75
+ });
76
+ return { res: new Response(body, { status: upRes.status, headers: upRes.headers }), status, frames };
77
+ }
18
78
  export function createChatService({ id = "qoder", fetchImpl, timeoutMs } = {}) {
19
79
  if (!fetchImpl) fetchImpl = compatFetch;
20
80
 
21
81
  // body: OpenAI chat 请求;sess: 已建 COSY 会话;region: 该账号所属区(每号独立选端点)。返回 Response。
22
- async function runChat(body, sess, region = "global") {
82
+ // 回显(可观测,不落凭据):x-mslxdff-upstream=本次请求的 host、x-mslxdff-qoder-region=cn|global;
83
+ // 管线层据此把"这次谁上的、打哪个站"投进模型日志(此前 provider 内部选号/切 URL 完全静默,排障无据)。
84
+ const withEcho = (res, url, region, pick) => {
85
+ try {
86
+ res.headers.set("x-mslxdff-upstream", hostOf(url));
87
+ res.headers.set("x-mslxdff-qoder-region", normalizeRegion(region));
88
+ // 账号选择决定(new/sticky/switch/forced):管线把它写进模型日志,"为什么会切号"才有据可查
89
+ if (pick) res.headers.set("x-mslxdff-qoder-account", String(pick));
90
+ } catch {}
91
+ return res;
92
+ };
93
+
94
+ // 上游状态回显(内部交接用,不落日志):流式路径按对外契约把非 200 整形成 "200 + 流内 error",
95
+ // 真实状态码只能靠这个头带出——否则 401/403/429/5xx 到不了 provider 门面,坏号不被冷却,
96
+ // 而"同请求粘号"(ADR-0036)会把后续重试继续粘在这个坏号上。
97
+ // Note: 流内判决需预读首帧才能拿到(HTTP 200 里裹 statusCodeValue)— 见 .agents/notes/implemented/bug-fix/2026-09-27-qoder-envelope-verdict-cooldown.md
98
+ const withUpstreamStatus = (res, st) => {
99
+ try { res.headers.set("x-mslxdff-qoder-upstream-status", String(st)); } catch {}
100
+ return res;
101
+ };
102
+
103
+ async function runChat(body, sess, region = "global", pick = "") {
23
104
  const stream = body?.stream !== false;
24
105
  const model = body?.model || "qfmodel";
25
106
  const chatId = "chatcmpl-qoder-" + Math.random().toString(16).slice(2, 10);
@@ -27,22 +108,34 @@ export function createChatService({ id = "qoder", fetchImpl, timeoutMs } = {}) {
27
108
  const messages = Array.isArray(body?.messages) ? body.messages : [];
28
109
  const maxTokens = Number(body?.max_tokens) || 0;
29
110
 
30
- let upRes;
111
+ let upRes; let reqUrl = "";
112
+ const echo = (res) => withEcho(res, reqUrl, region, pick);
113
+ // 排障日志(QODER_DEBUG_STREAM=1 开):记录出口/状态/耗时/响应头,便于对比"200 但空轮"
114
+ const cdbg = (...a) => { if (process.env.QODER_DEBUG_STREAM === "1") console.log("[qoder-chat]", ...a); };
115
+ let upMs = 0;
31
116
  try {
32
117
  const req = buildUpstreamRequest({ sess, region: normalizeRegion(region), model, messages, tools, maxTokens });
118
+ reqUrl = req.url;
119
+ cdbg(`[req] url=${hostOf(req.url)} region=${normalizeRegion(region)} model=${model} msgs=${messages.length} tools=${tools?.length ?? 0} maxTokens=${maxTokens} stream=${stream} bodyBytes=${req.bodyStr?.length ?? 0}`);
120
+ const t0 = Date.now();
33
121
  upRes = await fetchImpl(req.url, {
34
122
  method: "POST",
35
123
  headers: req.headers,
36
124
  body: req.bodyStr,
37
125
  signal: timeoutSignal(timeoutMs || 120_000),
38
126
  });
127
+ upMs = Date.now() - t0;
128
+ cdbg(`[res] status=${upRes.status} ms=${upMs} ct=${upRes.headers.get("content-type")} host=${hostOf(reqUrl)}`);
39
129
  } catch (e) {
40
130
  const detail = String(e?.message || e).slice(0, 300);
41
- if (stream) return reshapeQoderStream(new Response("data: [DONE]\n\n"), { model, chatId });
42
- return errRes(502, detail);
131
+ cdbg(`[res-throw] ms=${upMs} err=${detail}`);
132
+ // 网络异常(超时/连接失败)折成 502:门面据此冷却该号(否则坏号恒在轮换池里)
133
+ if (stream) return withUpstreamStatus(echo(reshapeQoderStream(new Response("data: [DONE]\n\n"), { model, chatId })), 502);
134
+ return withUpstreamStatus(echo(errRes(502, detail)), 502);
43
135
  }
44
136
  if (upRes.status !== 200) {
45
137
  const detail = await upRes.text().catch(() => "");
138
+ cdbg(`[res-non200] status=${upRes.status} body=${detail.slice(0, 300)}`);
46
139
  const e = mapUpstreamError(upRes.status, detail);
47
140
  if (stream) {
48
141
  // 首包错误也走流内 error 事件(与旧 reshapeStream catch 语义一致:200 + event:error + DONE)
@@ -54,17 +147,26 @@ export function createChatService({ id = "qoder", fetchImpl, timeoutMs } = {}) {
54
147
  c.close();
55
148
  },
56
149
  });
57
- return new Response(s, { status: 200, headers: { "Content-Type": "text/event-stream" } });
150
+ // 对外仍是 200(契约不变),真实状态码经回显头交门面决定是否冷却
151
+ return withUpstreamStatus(echo(new Response(s, { status: 200, headers: { "Content-Type": "text/event-stream" } })), upRes.status);
58
152
  }
59
- return errRes(errorStatus(e), e.detail, e.kind);
153
+ return withUpstreamStatus(echo(errRes(errorStatus(e), e.detail, e.kind)), upRes.status);
154
+ }
155
+ if (stream) {
156
+ // 预读首帧取流内判决(qoder 把 403/429 裹在 200 的信封里),预读帧原样回灌给整形器。
157
+ // 判决状态码经 withUpstreamStatus 出门面 → index.js 据此 ring.onError 冷却该号 →
158
+ // 粘号选择器下次因 isCooling 为真而换号。对外仍恒 200(契约不变)。
159
+ const peek = await peekEnvelopeVerdict(upRes, cdbg);
160
+ const shaped = echo(reshapeQoderStream(peek.res, { model, chatId, prefetched: peek.frames }));
161
+ // 只在真有判决时挂状态码:正常流不写这个头(index.js 靠它区分"要不要冷却")
162
+ return peek.status ? withUpstreamStatus(shaped, peek.status) : shaped;
60
163
  }
61
- if (stream) return reshapeQoderStream(upRes, { model, chatId });
62
164
  try {
63
165
  const agg = await aggregateQoderStream(upRes);
64
166
  const out = toCompletionJson({ model, chatId, ...agg });
65
- return new Response(JSON.stringify(out), { status: 200, headers: { "Content-Type": "application/json" } });
167
+ return echo(new Response(JSON.stringify(out), { status: 200, headers: { "Content-Type": "application/json" } }));
66
168
  } catch (e) {
67
- return errRes(errorStatus(e), String(e?.detail || e?.message || e).slice(0, 300), e?.kind || "upstream_error");
169
+ return echo(errRes(errorStatus(e), String(e?.detail || e?.message || e).slice(0, 300), e?.kind || "upstream_error"));
68
170
  }
69
171
  }
70
172
 
@@ -1,6 +1,7 @@
1
1
  // qoder provider 门面(原生直连,零桥依赖):
2
2
  // 多号 = keys[] 里每条 device_token blob(auths/qoder-<uid>.json 落盘由 login 维护);
3
- // 每请求 round-robin 选号 → 建会话 → COSY 签名直调上游;恒 local-only 不借 key。
3
+ // 选号 → 建会话 → COSY 签名直调上游;恒 local-only 不借 key。
4
+ // Note: 同请求粘号(一次客户端请求内复用同一个号,仅 401/403/429/5xx 冷却才换)与流式坏号冷却(真实状态码经内部头带出)— 见 .agents/notes/implemented/feature/2026-09-27-qoder-per-request-sticky-account.md
4
5
  import { loadProviderKeys, loadProviderConfig } from "../../state.js";
5
6
  import { listAccountDocs } from "./account-store.js";
6
7
  import { defaultStateFile } from "../../state/store.js";
@@ -11,6 +12,7 @@ import { normalizeRegion } from "./constants.js";
11
12
  import { fingerprintSeed, deriveMachineId, deriveMachineToken, deriveMachineType } from "./fingerprint.js";
12
13
  import { newSession } from "./session.js";
13
14
  import { createChatService } from "./chat.js";
15
+ import { createStickyPicker } from "./sticky.js";
14
16
  import { createModelsService } from "./models.js";
15
17
  import { compatFetch } from "../../compat.js";
16
18
 
@@ -55,15 +57,27 @@ export function createQoderProvider({
55
57
 
56
58
 
57
59
 
60
+ // 选号:同一客户端请求内粘住(重试不换号),只有该号被冷却(401/403/429/5xx)才换下一个;
61
+ // 无 scope(拿不到 reqId,如独立的 models 探活)时退回原 round-robin。
62
+ const pickKey = createStickyPicker({
63
+ pick: () => ring.next() || keys[0] || "",
64
+ isCooling: (k) => ring.isCooling(k),
65
+ ttlMs: envInt("MSLXDFF_QODER_STICKY_MS", 600_000),
66
+ });
67
+
58
68
  // 凭据 → 会话(会话含 RSA/AES 临时密钥,进程内可复用;此处按请求轻建,成本低)
59
- function pickSession() {
60
- const key = ring.next() || keys[0] || "";
69
+ function pickSession(scope) {
70
+ let decision = "new";
71
+ const key = pickKey(scope, (d) => { decision = d; });
72
+ // 全部号都在冷却时 pick() 只能兜底取 keys[0]:这是"被迫选择",必须与正常轮转区分开
73
+ if (ring.available() === 0) decision = "forced";
61
74
  const blob = accountFromBlob(key);
62
75
  if (!blob?.deviceToken) return null;
63
76
  const auth = authList.find((a) => String(a?.refreshToken || "") === String(blob.refreshToken || "")) || authList[0] || {};
64
77
  const region = normalizeRegion(region0 || auth.region || "global");
65
78
  const sess = buildSessionFor({ ...blob, uid: auth.uid, name: auth.name, region });
66
- return { sess, region };
79
+ // key/pick 带出:onError(key) 要冷却"刚用过的这个号";pick 让日志能写"这次为什么是它"
80
+ return { sess, region, key, pick: decision };
67
81
  }
68
82
  const region0 = region; // 构造参数优先
69
83
 
@@ -71,14 +85,37 @@ export function createQoderProvider({
71
85
  // models 服务按号选区:构造时无固定 region,listModels(sess, region) 动态传
72
86
  const modelsSvc = createModelsService({ id, fetchImpl });
73
87
 
74
- async function chat(body) {
75
- const picked = pickSession();
88
+ // 失败冷却:401/403/429/5xx 冷却当前号(cooldownMs,默认 30s),坏号不再参与轮换。
89
+ // 对齐 workbuddy/chat.js 的 onError 语义——此前 qoder 只 next() 不 onError,坏号恒在轮换池里。
90
+ // 网络异常(fetch throw)同样冷却。业务错(4xx 内容类)不冷却。
91
+ async function chat(body, opts) {
92
+ // scope=reqId:同一次客户端请求的多次上游调用(空转重试)粘同一个号
93
+ const picked = pickSession(opts?.reqId ? `req:${opts.reqId}` : null);
76
94
  if (!picked) {
77
95
  return new Response(JSON.stringify({ error: { message: "qoder: 无可用账号 — 先跑 mslxdff -provider qoder login", type: "auth_error" } }), { status: 401, headers: { "Content-Type": "application/json" } });
78
96
  }
97
+ const pickedKey = picked.key || "";
79
98
  const stripped = { ...body };
80
99
  if (typeof stripped.model === "string" && stripped.model.startsWith("qoder/")) stripped.model = stripped.model.slice(6);
81
- return chatSvc.runChat(stripped, picked.sess, picked.region);
100
+ let res;
101
+ try {
102
+ res = await chatSvc.runChat(stripped, picked.sess, picked.region, picked.pick);
103
+ } catch (e) {
104
+ try { ring.onError(pickedKey); } catch {}
105
+ throw e;
106
+ }
107
+ const badAuth = (n) => n === 401 || n === 403 || n === 429 || n >= 500;
108
+ const st = res?.status ?? 0;
109
+ // 流式路径把上游非 200 整形成 200 + 流内 error(对外契约),真实状态码只能从回显头取;
110
+ // 否则坏号(401/403/429/5xx)永不冷却,粘号还会把重试继续粘在这个坏号上。
111
+ const ust = Number(res?.headers?.get?.("x-mslxdff-qoder-upstream-status")) || 0;
112
+ const bad = badAuth(st) ? st : (badAuth(ust) ? ust : 0);
113
+ if (bad) {
114
+ try { ring.onError(pickedKey); } catch {}
115
+ // 冷却是个决定:钉在响应上,管线写进模型日志(cooled=<status>)
116
+ try { res.headers?.set?.("x-mslxdff-qoder-cooldown", String(bad)); } catch {}
117
+ }
118
+ return res;
82
119
  }
83
120
 
84
121
  // 全号聚合:双号分属 cn/global 两区,模型表各不同(cn 14 个/global 15 个);
@@ -140,9 +177,9 @@ export function createQoderProvider({
140
177
  }
141
178
 
142
179
  async function close() {}
143
- async function chatWithKeys(body, keysOverride) {
180
+ async function chatWithKeys(body, keysOverride, opts) {
144
181
  const tmp = createQoderProvider({ id, apiKeys: keysOverride, file, fetchImpl, cooldownMs, connectTimeoutMs });
145
- return tmp.chat(body);
182
+ return tmp.chat(body, opts);
146
183
  }
147
184
 
148
185
  return { id, chat, chatWithKeys, listModels, preheat, close, keyRing: ring, baseUrl: "qoder://native", region: normalizeRegion(region || authList[0]?.region) };
@@ -1,17 +1,30 @@
1
1
  // qoder 上游 SSE 信封解析(转译 qoder2api bridge.go ExtractDelta)
2
2
  // 帧形态:data:{"headers":{},"body":"<内层JSON>","statusCodeValue":200}
3
3
  // 信封 statusCodeValue!=200 = 瞬时上游错;内层 code!=0 = 业务错;usage 同帧合并
4
+ // 排障日志(QODER_DEBUG_STREAM=1 开):静默吞帧的分支必须有痕,否则"内容凭空消失"无从追
5
+ const DBG_SSE = () => process.env.QODER_DEBUG_STREAM === "1";
6
+ const ddbg = (...a) => { if (DBG_SSE()) console.log("[qoder-sse]", ...a); };
7
+
4
8
  export function extractDelta(dataLine) {
5
9
  let wrapper;
6
- try { wrapper = JSON.parse(dataLine); } catch { return {}; }
10
+ try { wrapper = JSON.parse(dataLine); } catch (e) {
11
+ ddbg(`[envelope-parse-fail] ${e.message} line=${dataLine.slice(0, 300)}`);
12
+ return {};
13
+ }
7
14
  if (wrapper.statusCodeValue !== undefined && Number(wrapper.statusCodeValue) !== 200) {
8
15
  const detail = typeof wrapper.body === "string" && wrapper.body ? wrapper.body : JSON.stringify(wrapper).slice(0, 400);
9
16
  return { err: { kind: "upstream", status: Number(wrapper.statusCodeValue) || 502, detail } };
10
17
  }
11
18
  const inner = wrapper.body;
12
- if (!inner || typeof inner !== "string") return {};
19
+ if (!inner || typeof inner !== "string") {
20
+ ddbg(`[body-not-string] bodyType=${typeof inner} envelopeKeys=${Object.keys(wrapper).join(",")} envelope=${dataLine.slice(0, 300)}`);
21
+ return {};
22
+ }
13
23
  let j;
14
- try { j = JSON.parse(inner); } catch { return {}; }
24
+ try { j = JSON.parse(inner); } catch (e) {
25
+ ddbg(`[inner-parse-fail] ${e.message} body=${inner.slice(0, 300)}`);
26
+ return {};
27
+ }
15
28
  let usageIn = 0, usageOut = 0;
16
29
  if (j.usage && typeof j.usage === "object") {
17
30
  usageIn = Math.trunc(Number(j.usage.prompt_tokens) || 0);
@@ -33,6 +46,9 @@ export function extractDelta(dataLine) {
33
46
  return { err: { kind: /inappropriate|DataInspection|Sensitive|ContentFilter/i.test(String(j.message)) ? "content_policy" : "business", code: j.code, detail: String(j.message || "") } };
34
47
  }
35
48
  if (usageIn > 0 || usageOut > 0) return { usageIn, usageOut };
49
+ // 走到这里 = 帧既无 content/tool_calls、也无业务错、也无 usage。空轮排查的关键取证点:
50
+ // 把内层 JSON 的顶层键与 choices 原样打出来,一眼看出上游改了什么字段名。
51
+ ddbg(`[no-delta] innerKeys=${Object.keys(j).join(",")} choicesLen=${choices.length} inner=${JSON.stringify(j).slice(0, 400)}`);
36
52
  return {};
37
53
  }
38
54
 
@@ -0,0 +1,38 @@
1
+ // qoder 同请求粘号:一个客户端请求内的多次上游调用(空转重试/换路重试)复用同一个号,
2
+ // 只有该号进入冷却(401/403/429/5xx → ring.onError)才切到下一个号。
3
+ // 背景:keyring 每次 next() 都前进一格,而 qoder 两个号分属 cn/global 两区(region 决定 URL),
4
+ // 于是「重试」会把同一个请求甩到另一个区——排障时表现为"URL 莫名在切"。粘号把"切号"收回到
5
+ // 唯一正当理由:该号已被冷却。无 scope(拿不到请求标识)时退化为原 round-robin,与旧行为一致。
6
+ // Note: 切号的唯一正当理由是冷却(401/403/429/5xx)——空转等其它失败不换号,故选择器要能问 keyring "上次这个号还在冷却吗" — 见 .agents/notes/implemented/feature/2026-09-27-qoder-per-request-sticky-account.md
7
+ export function createStickyPicker({ pick, isCooling = () => false, now = Date.now, ttlMs = 600_000, maxEntries = 200 } = {}) {
8
+ const seen = new Map(); // scope → { key, at }
9
+
10
+ function evict() {
11
+ const t = now();
12
+ for (const [scope, hit] of seen) if (t - hit.at >= ttlMs) seen.delete(scope);
13
+ while (seen.size > maxEntries) seen.delete(seen.keys().next().value);
14
+ }
15
+
16
+ // onDecision(可选):把"这次为什么选它"回报给调用方 —— 决定必须可观测。
17
+ // sticky=同请求复用 / new=新请求轮转选中 / switch=上次的号在冷却(或 TTL 过期)故换 / rr=无 scope 退回轮转
18
+ return function pickSticky(scope, onDecision) {
19
+ if (!scope) {
20
+ onDecision?.("rr");
21
+ return pick();
22
+ }
23
+ const hit = seen.get(scope);
24
+ // 粘住:同一请求内复用上次的号,除非它已被冷却(那时必须换)
25
+ if (hit && now() - hit.at < ttlMs && !isCooling(hit.key)) {
26
+ hit.at = now();
27
+ onDecision?.("sticky");
28
+ return hit.key;
29
+ }
30
+ const key = pick();
31
+ onDecision?.(hit ? "switch" : "new");
32
+ if (key) {
33
+ seen.set(scope, { key, at: now() });
34
+ if (seen.size > maxEntries) evict();
35
+ }
36
+ return key;
37
+ };
38
+ }
@@ -3,6 +3,9 @@
3
3
  // usage 只在尾帧出现:收到即记,附在 finish chunk 上一次发出(与旧回放语义一致)。
4
4
  import { extractDelta } from "./sse.js";
5
5
 
6
+ // 空轮排障用的临时日志开关:QODER_DEBUG_STREAM=1 才输出,默认静默(问题定位后可摘)
7
+ const DBG_STREAM = () => process.env.QODER_DEBUG_STREAM === "1";
8
+ const sdbg = (...a) => { if (DBG_STREAM()) console.log("[qoder-stream]", ...a); };
6
9
  function openaiChunk({ model, chatId, delta, finish, usage }) {
7
10
  const c = {
8
11
  id: chatId,
@@ -19,11 +22,13 @@ function errorEvent(e) {
19
22
  return `event: error\ndata: ${JSON.stringify({ message: String(e?.detail || e?.message || e), type: e?.kind || "upstream_error" })}\n\n`;
20
23
  }
21
24
 
22
- export function reshapeQoderStream(upstreamRes, { model, chatId }) {
25
+ export function reshapeQoderStream(upstreamRes, { model, chatId, prefetched = [] }) {
23
26
  const src = upstreamRes.body;
24
27
  const enc = new TextEncoder();
25
28
  const dec = new TextDecoder();
26
- let buf = "";
29
+ // 预读帧(chat.js 为取流内判决而先读的字节)原样回灌:既不丢内容也不重复,
30
+ // 判决帧会在这里被正常解析并抛错,同时 chat.js 已用同一批帧定出状态码挂上门面。
31
+ let buf = prefetched.map((b) => dec.decode(b, { stream: true })).join("");
27
32
  let usage = null;
28
33
  let finish = "stop";
29
34
  let sawContent = false;
@@ -32,40 +37,58 @@ export function reshapeQoderStream(upstreamRes, { model, chatId }) {
32
37
  async start(ctrl) {
33
38
  const reader = src.getReader();
34
39
  const send = (t) => ctrl.enqueue(enc.encode(t));
40
+ let chunkIdx = 0;
41
+ let contentLen = 0;
42
+ let lineIdx = 0;
35
43
  try {
36
- send(openaiChunk({ model, chatId, delta: { role: "assistant" } }));
44
+ sdbg(`[enter] model=${model} chatId=${chatId} ct=${upstreamRes.headers.get("content-type")} status=${upstreamRes.status}`);
45
+ // role: assistant will be attached on first real delta to avoid premature payload commit
37
46
  for (;;) {
38
47
  const { done, value } = await reader.read();
39
- if (value) buf += dec.decode(value, { stream: !done });
48
+ if (value) {
49
+ const text = dec.decode(value, { stream: !done });
50
+ buf += text;
51
+ sdbg(`[chunk#${chunkIdx++}] bytes=${value.byteLength} done=${done} decodedLen=${text.length} raw=${JSON.stringify(text.slice(0, 500))}`);
52
+ }
40
53
  const lines = buf.split("\n");
41
54
  buf = lines.pop();
55
+ sdbg(`[buf] lines=${lines.length} leftover=${JSON.stringify(buf.slice(0, 200))}`);
42
56
  for (const raw of lines) {
43
57
  const line = raw.replace(/\r$/, "");
44
- if (!line.startsWith("data:")) continue;
58
+ lineIdx++;
59
+ if (!line.startsWith("data:")) { sdbg(`[line#${lineIdx}] non-data: ${JSON.stringify(line.slice(0, 200))}`); continue; }
45
60
  const payload = line.slice(5).trim();
46
- if (!payload || payload === "[DONE]") continue;
61
+ if (!payload || payload === "[DONE]") { sdbg(`[line#${lineIdx}] skip: ${JSON.stringify(payload)}`); continue; }
47
62
  const d = extractDelta(payload);
48
- if (d.err) throw d.err;
63
+ sdbg(`[line#${lineIdx}] payload=${JSON.stringify(payload.slice(0, 800))}`);
64
+ sdbg(`[line#${lineIdx}] parsed keys=${Object.keys(d).join(",")} content=${JSON.stringify((d.content || "").slice(0, 200))} reasoning=${JSON.stringify((d.reasoning || "").slice(0, 200))} toolCalls=${(d.toolCalls || []).length} usageIn=${d.usageIn} usageOut=${d.usageOut} err=${!!d.err}`);
65
+ if (d.err) { sdbg(`[line#${lineIdx}] ERROR from extractDelta: ${JSON.stringify(d.err)}`); throw d.err; }
49
66
  if (d.usageIn || d.usageOut) {
50
67
  usage = { prompt_tokens: d.usageIn, completion_tokens: d.usageOut, total_tokens: d.usageIn + d.usageOut };
68
+ sdbg(`[usage] ${JSON.stringify(usage)}`);
51
69
  }
52
70
  const delta = {};
53
- if (d.content) { delta.content = d.content; sawContent = true; }
71
+ if (!sawContent) delta.role = "assistant";
72
+ if (d.content) { delta.content = d.content; contentLen += d.content.length; sawContent = true; }
54
73
  if (d.reasoning) { delta.reasoning_content = d.reasoning; sawContent = true; }
55
74
  if (d.toolCalls) { delta.tool_calls = d.toolCalls; finish = "tool_calls"; sawContent = true; }
56
75
  if (Object.keys(delta).length) send(openaiChunk({ model, chatId, delta }));
76
+ sdbg(`[acc] line#=${lineIdx} sawContent=${sawContent} contentLen=${contentLen} finish=${finish} usage=${JSON.stringify(usage)}`);
57
77
  }
58
78
  if (done) break;
59
79
  }
80
+ sdbg(`[end] totalChunks=${chunkIdx} totalLines=${lineIdx} sawContent=${sawContent} contentLen=${contentLen} finish=${finish} usage=${JSON.stringify(usage)}`);
60
81
  if (!sawContent && !usage) {
82
+ sdbg(`[empty-stream] sawContent=false usage=null → throw 502 empty upstream stream`);
61
83
  throw { kind: "upstream", status: 502, detail: "empty upstream stream" };
62
84
  }
63
85
  send(openaiChunk({ model, chatId, delta: {}, finish, usage }));
64
86
  send("data: [DONE]\n\n");
65
87
  finishSent = true;
66
88
  } catch (e) {
89
+ sdbg(`[catch] finishSent=${finishSent} e=${JSON.stringify(e?.message || e)}`);
67
90
  if (!finishSent) {
68
- try { send(openaiChunk({ model, chatId, delta: {} })); } catch {}
91
+ if (sawContent) { try { send(openaiChunk({ model, chatId, delta: {} })); } catch {} }
69
92
  try { send(errorEvent(e)); } catch {}
70
93
  try { send("data: [DONE]\n\n"); } catch {}
71
94
  finishSent = true;
@@ -1,3 +1,4 @@
1
+ // Note: 失败收尾四分支在 result 后补 client-response,与成功路径对账口径一致(result 条数 == client-response 条数)— 见 .agents/notes/implemented/bug-fix/2026-09-25-sdk-headers-timeout-and-failure-client-response.md
1
2
  import { relay } from "../stream.js";
2
3
  import { json } from "../helpers.js";
3
4
  import { performance } from "node:perf_hooks";
@@ -4,6 +4,7 @@ import { normalizeFullId } from "../../providers/model-id.js";
4
4
  import { computeMetrics } from "../../metrics.js";
5
5
  import { recordChatUsage } from "../../usage/record.js"; // 窗口报表唯一写入点(canonical 名单记防双计)— 见 .agents/notes/implemented/feature/2026-09-19-usage-report-jsonl.md
6
6
  import { recordOutput, computeOutputRow } from "../../providers/cline/usage.js"; // cline 专属旁路统计(账号×模型,流式也覆盖)
7
+ import { upstreamEcho } from "../../model-trace.js"; // 回显头→日志字段(谁上的/哪个号/为什么/是否冷却)单一来源
7
8
 
8
9
  // 唯一/最后候选没有 failover 去向:首块闸门退化为纯"防连接泄漏",放宽避免误杀慢模型
9
10
  // (参考 opencode:zen 通道不设超时;openai responses 硬编码 300s headerTimeout)
@@ -120,6 +121,7 @@ export function createRelayPipeline({
120
121
  aborted: out.aborted,
121
122
  interrupted: out.interrupted ?? false,
122
123
  timedOut: out.timedOut ?? false,
124
+ ...upstreamEcho(upRes),
123
125
  detail: out.detail ?? null,
124
126
  });
125
127
 
@@ -140,9 +142,13 @@ export function createRelayPipeline({
140
142
  // 错误包络暂扣后下游不再直观看到上游原文:把摘要带进最终报错(截断 200 字),排障不断线。
141
143
  const _err = _d.upstreamErrorText ? ` upstream=${String(_d.upstreamErrorText).slice(0, 200)}` : "";
142
144
  try { _logError(actual, 502, `empty turn${_why}${_err}`); } catch {}
143
- _evt("upstream-error", { reqId, model: actual, status: 502, message: "empty turn", timing: null });
145
+ _evt("upstream-error", { reqId, model: actual, status: 502, message: "empty turn", timing: null, ...upstreamEcho(upRes) });
144
146
  _evt("fallback", { reqId, from: actual, to: null, reason: "empty turn" });
145
- return { handled: false, upRes: null, lastErr: { model: actual, upstream: null, status: 502, message: `EMPTY_MODEL_RESPONSE: upstream returned 200 with no content${_why}${_err} — retry or rephrase` } };
147
+ let _errMsg = `EMPTY_MODEL_RESPONSE: upstream returned 200 with no content${_why}${_err} — retry or rephrase`;
148
+ if (_d.upstreamErrorText && _d.upstreamErrorText.includes("retryAfterSeconds")) {
149
+ _errMsg = _d.upstreamErrorText;
150
+ }
151
+ return { handled: false, upRes: null, lastErr: { model: actual, upstream: null, status: 502, message: _errMsg } };
146
152
  }
147
153
 
148
154
  // 5a. 首块超时未写字节 → 回退(显式 timedOut 字段,status 只是 HTTP 语义展示)
@@ -150,7 +156,7 @@ export function createRelayPipeline({
150
156
  const why = out.detail?.upstreamError ? ` (upstream read error: ${out.detail.upstreamError})` : "";
151
157
  if (auto) try { await auto.recordError(actual, { status: 502, slow: true, note: `stream timeout ${streamTimeoutMs}ms` }); } catch {}
152
158
  try { _logError(actual, 502, `stream timeout ${streamTimeoutMs}ms${why}`); } catch {}
153
- _evt("upstream-error", { reqId, model: actual, status: 502, message: "stream timeout", timing: null });
159
+ _evt("upstream-error", { reqId, model: actual, status: 502, message: "stream timeout", timing: null, ...upstreamEcho(upRes) });
154
160
  _evt("fallback", { reqId, from: actual, to: null, reason: "stream timeout" });
155
161
  return { handled: false, upRes: null, lastErr: { model: actual, upstream: null, status: 502, message: `stream timed out after ${streamTimeoutMs}ms${why}` } };
156
162
  }
@@ -161,7 +167,7 @@ export function createRelayPipeline({
161
167
  try { await auto.recordError(actual, { status: 200, slow: true, note: `stall ${C.STALL_TIMEOUT_MS}ms` }); } catch {}
162
168
  try { await auto.recordLatency(actual, out.totalMs ?? (Date.now() - curStartedAt)); } catch {}
163
169
  }
164
- _evt("slow-model", { reqId, model: actual, elapsedMs: out.totalMs ?? (Date.now() - curStartedAt), threshold: C.STALL_TIMEOUT_MS, interrupted: true, detail: out.detail ?? null });
170
+ _evt("slow-model", { reqId, model: actual, elapsedMs: out.totalMs ?? (Date.now() - curStartedAt), threshold: C.STALL_TIMEOUT_MS, interrupted: true, detail: out.detail ?? null, ...upstreamEcho(upRes) });
165
171
  try { _logCall(actual, 200); } catch {}
166
172
  // interrupted 的 200 也是真实消耗(最贵的长生成)——照常落 usage 标 interrupted:1,口径与 5c 一致 — 见 .agents/notes/implemented/feature/2026-09-19-usage-report-jsonl.md
167
173
  if (out.status === 200) {
@@ -172,7 +178,7 @@ export function createRelayPipeline({
172
178
  recordChatUsage({ model: normalizeFullId(actual), via, usage: u, interrupted: 1, ttfbMs: t0, totalMs: t1, tps: null }).catch(() => {});
173
179
  } catch {}
174
180
  }
175
- _evt("result", { reqId, model: actual, status: out.status, via, timing: upRes?._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, interrupted: true, detail: out.detail ?? null, fallback, requested, actual });
181
+ _evt("result", { reqId, model: actual, status: out.status, via, timing: upRes?._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, interrupted: true, ...upstreamEcho(upRes), detail: out.detail ?? null, fallback, requested, actual });
176
182
  _evt("client-response", { requested, actual, via, fallback, status: out.status, reqId, interrupted: true });
177
183
  if (plugins?.length) runHook(plugins, "request:completed", { reqId, requested, useAuto, hops, stream: Boolean(body?.stream), durationMs: Date.now() - curStartedAt, via, status: out.status, actual, interrupted: true, fallback }).catch(() => {});
178
184
  return { handled: true };
@@ -186,13 +192,13 @@ export function createRelayPipeline({
186
192
  if (C.SLOW_TOTAL_MS && auto && elapsed > C.SLOW_TOTAL_MS && out.status === 200) {
187
193
  try { await auto.recordError(actual, { status: 200, slow: true, note: `slow ${elapsed}ms` }); } catch {}
188
194
  try { await auto.recordLatency(actual, latencyMs); } catch {}
189
- _evt("slow-model", { reqId, model: actual, elapsedMs: elapsed, threshold: C.SLOW_TOTAL_MS, reason: "total", detail: out.detail ?? null });
195
+ _evt("slow-model", { reqId, model: actual, elapsedMs: elapsed, threshold: C.SLOW_TOTAL_MS, reason: "total", detail: out.detail ?? null, ...upstreamEcho(upRes) });
190
196
  scoredSlow = true;
191
197
  }
192
198
  if (out.detail?.stallHits > 0 && auto && out.status === 200) {
193
199
  try { await auto.recordError(actual, { status: 200, slow: true, note: `stall ${out.detail.stallHits}x gap>${C.SCORE_STALL_MS}ms maxGap ${out.detail.maxGapMs}ms` }); } catch {}
194
200
  try { await auto.recordLatency(actual, latencyMs); } catch {}
195
- _evt("slow-model", { reqId, model: actual, elapsedMs: elapsed, threshold: C.SCORE_STALL_MS, reason: "stall", stallHits: out.detail.stallHits, maxGapMs: out.detail.maxGapMs, detail: out.detail ?? null });
201
+ _evt("slow-model", { reqId, model: actual, elapsedMs: elapsed, threshold: C.SCORE_STALL_MS, reason: "stall", stallHits: out.detail.stallHits, maxGapMs: out.detail.maxGapMs, detail: out.detail ?? null, ...upstreamEcho(upRes) });
196
202
  scoredSlow = true;
197
203
  }
198
204
  if (!scoredSlow && auto && out.status === 200) {
@@ -234,7 +240,7 @@ export function createRelayPipeline({
234
240
  } catch {}
235
241
  }
236
242
 
237
- _evt("result", { reqId, model: actual, status: out.status, via, timing: upRes?._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, detail: out.detail ?? null, fallback, requested, actual });
243
+ _evt("result", { reqId, model: actual, status: out.status, via, timing: upRes?._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, ...upstreamEcho(upRes), detail: out.detail ?? null, fallback, requested, actual });
238
244
  _evt("client-response", { requested, actual, via, fallback, status: out.status, reqId });
239
245
  if (plugins?.length) runHook(plugins, "request:completed", { reqId, requested, useAuto, hops, stream: Boolean(body?.stream), durationMs: Date.now() - curStartedAt, via, status: out.status, actual, fallback }).catch(() => {});
240
246
  return { handled: true };
@@ -35,12 +35,40 @@ function hasPayload(text) {
35
35
  // 否则返回 null(透传)。解析失败一律透传(默认保安全)。
36
36
  // 背景:网关把上游失败包成 HTTP 200 SSE;暂扣后下游流式 UI 不再展示瞬时错误
37
37
  //(如 Vertex 503 + google fallback 400),本轮走空转重试,客户端只看到最终结果。
38
+ // Note: 流内错误包络捕获与 retryAfterSeconds 错误直出 — 见 .agents/notes/implemented/bug-fix/2026-09-28-retry-after-surfacing-error.md
39
+ function extractRetryAfterSec(obj) {
40
+ if (!obj) return null;
41
+ if (typeof obj === "number") return obj;
42
+ if (typeof obj === "string") {
43
+ const m = obj.match(/"retryAfterSeconds"\s*:\s*(\d+)/i) || obj.match(/\bretryAfterSeconds\s*[:=]\s*(\d+)/i);
44
+ if (m) return Number(m[1]);
45
+ try {
46
+ const parsed = JSON.parse(obj);
47
+ return extractRetryAfterSec(parsed);
48
+ } catch {}
49
+ }
50
+ if (typeof obj === "object") {
51
+ if (Number.isFinite(obj.retryAfterSeconds)) return Number(obj.retryAfterSeconds);
52
+ if (obj.message) {
53
+ const r = extractRetryAfterSec(obj.message);
54
+ if (r != null) return r;
55
+ }
56
+ }
57
+ return null;
58
+ }
59
+
38
60
  function holdableChunk(txt) {
39
61
  if (typeof txt !== "string" || !txt.includes("data:")) return null;
40
62
  let sawData = false;
63
+ let sawEventError = false;
64
+ let retrySec = null;
41
65
  const errs = [];
42
- for (const line of txt.split("\n")) {
43
- const t = line.trim();
66
+ for (const rawLine of txt.split("\n")) {
67
+ const t = rawLine.trim();
68
+ if (t.startsWith("event:") && t.slice(6).trim() === "error") {
69
+ sawEventError = true;
70
+ continue;
71
+ }
44
72
  if (!t.startsWith("data:")) continue;
45
73
  const d = t.slice(5).trim();
46
74
  if (!d || d === "[DONE]") continue;
@@ -53,15 +81,26 @@ function holdableChunk(txt) {
53
81
  const msg = c0.message || {};
54
82
  const out = delta.content || msg.content || delta.tool_calls || msg.tool_calls || delta.reasoning || msg.reasoning;
55
83
  if (out && !(Array.isArray(out) && out.length === 0)) return null;
84
+
85
+ const r = extractRetryAfterSec(j);
86
+ if (r != null) {
87
+ retrySec = r;
88
+ continue;
89
+ }
56
90
  if (j.error && typeof j.error === "object") {
57
91
  const m = j.error.message || j.error.code || "";
58
92
  if (m) errs.push(String(m).slice(0, 300));
59
- } else if (c0.finish_reason !== "error") {
60
- return null;
93
+ } else if (sawEventError && j.message) {
94
+ errs.push(String(j.message).slice(0, 300));
95
+ } else if (c0.finish_reason === "error") {
96
+ errs.push("finish_reason=error 空帧");
61
97
  }
62
98
  }
63
99
  if (!sawData) return null;
64
- return errs.length ? errs.join(" | ") : "finish_reason=error 空帧";
100
+ if (retrySec != null) return `上游供应商触发 retryAfterSeconds: ${retrySec} ,请等候重试`;
101
+ if (errs.length) return errs.join(" | ");
102
+ if (sawEventError) return "event: error 空帧";
103
+ return null;
65
104
  }
66
105
 
67
106
  // 自持 reader 优先:for-await 会锁定 ReadableStream,使 body.cancel() 必 reject(真流上等于空操作),
package/src/timeline.js CHANGED
@@ -1,4 +1,5 @@
1
1
  // 人读请求时间线:只输出状态/耗时/出口,不输出 prompt、响应正文或凭据。
2
+ // Note: 渲染口径与失败路径 client-response 对账(result 与 client-response 条数须相等)— 见 .agents/notes/implemented/feature/2026-09-25-model-trace-log.md
2
3
  function hostPort(value) {
3
4
  try { const u = new URL(String(value)); return u.port ? `${u.hostname}:${u.port}` : u.hostname; } catch { return String(value || "-").slice(0, 80); }
4
5
  }
@@ -8,6 +8,7 @@
8
8
  //(cline muse-spark 2026-09-25 17:48 悬空 27min+,无 upstream-done/error/result)。
9
9
  // 默认 120s(覆盖慢模型首块握手的合理上限),MSLXDFF_SDK_HEADERS_TIMEOUT_MS=0 关闭。
10
10
  // 注意错误文案不得含 "timed out":cline runChat 靠该子串做网络重试,命中会把挂死放大 3 倍。
11
+ // Note: 闸门定时器不可 unref()(会让事件循环排空、node:test 判 cancelled)— 见 .agents/notes/implemented/bug-fix/2026-09-25-sdk-headers-timeout-and-failure-client-response.md
11
12
  import { toModelPrompt, toModelTools, toModelToolChoice, toModelParams } from "./convert.js";
12
13
  import { createSseSerializer } from "./sse.js";
13
14
  import { diagnoseToolSequence, compactSequence } from "./diagnose.js";