mslxdff 0.1.153 → 0.1.154

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.153",
3
+ "version": "0.1.154",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -4,6 +4,8 @@ import { createEngine } from "./engine.js";
4
4
  import { runHook } from "../plugins.js";
5
5
  import { isFreeModel } from "../models.js";
6
6
  import { clientIp, summarizePrompt } from "../routes/helpers.js";
7
+ import { formatTimeline } from "../timeline.js";
8
+ import { summarizeRequest, shouldTraceModel } from "../model-trace.js";
7
9
 
8
10
  /**
9
11
  * ChatPipeline 深模块门面 — 对外 execute(req) 单一 inlet
@@ -25,6 +27,7 @@ export function createChatPipeline({ upstream, auto, logs, peers, groups, bus, t
25
27
  mark("parsed");
26
28
  if (aliasInfo) { try { res?.setHeader?.("x-mslxdff-alias", aliasInfo); } catch {} }
27
29
  // mslxdff/ 前缀或 alias 命中时,把 body.model 改写为还原后的模型(与原 gateway 语义一致)
30
+ const timeline = { direct: [], peers: [], retries: 0, result: null };
28
31
  if (aliasInfo && req?.body && req.body.model !== requested) {
29
32
  req.body = { ...req.body, model: requested };
30
33
  }
@@ -35,6 +38,25 @@ export function createChatPipeline({ upstream, auto, logs, peers, groups, bus, t
35
38
  const logError = (model, status, message) => logs?.appendError({ reqId, model, auto: useAuto, status, message, stages });
36
39
  const evt = (type, data) => {
37
40
  const entry = { ts: Date.now(), reqId, type, ...data, model: data.model ?? requested, auto: useAuto, durationMs: Date.now() - startedAt, stages: [...stages] };
41
+ const traceModel = entry.model || requested;
42
+ if (shouldTraceModel(type)) {
43
+ // Note: 模型日志只投影安全字段,不能把含 prompt 的 entry 原样下传 — 见 .agents/notes/implemented/feature/2026-09-25-model-trace-log.md
44
+ const safeTraceData = { ...entry };
45
+ if (safeTraceData.prompt !== undefined) delete safeTraceData.prompt;
46
+ logs?.appendModelTrace?.(traceModel, { type, reqId, model: traceModel, data: safeTraceData, request: type === "request" ? summarizeRequest(req?.body) : null, totalMs: entry.durationMs });
47
+ }
48
+ if (type === "peer-forward") timeline.peers.push({ peer: entry.peer, ok: entry.ok === true, status: entry.status, latencyMs: entry.latencyMs, message: entry.message || entry.error || "" });
49
+ if (type === "upstream-done" || type === "upstream-error") timeline.direct.push({ status: entry.status, reason: entry.message || entry.error || "" });
50
+ if (type === "peer-error") {
51
+ const known = timeline.peers.find((p) => p.peer === entry.peer);
52
+ if (known) { known.ok = false; known.status = entry.status ?? known.status; known.message = entry.message || entry.error || known.message; }
53
+ else timeline.peers.push({ peer: entry.peer, ok: false, status: entry.status, message: entry.message || entry.error || "" });
54
+ }
55
+ if (type === "empty-turn-retry") timeline.retries += 1;
56
+ if (type === "result" || (type === "client-response" && !timeline.result)) {
57
+ timeline.result = { status: entry.status, detail: entry.detail || null };
58
+ try { logs?.appendTimeline?.(formatTimeline({ reqId, model: entry.model || requested, ...timeline, totalMs: Date.now() - startedAt })); } catch {}
59
+ }
38
60
  if (bus) bus.emit(entry);
39
61
  logs?.appendEvent?.(entry);
40
62
  };
@@ -10,6 +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
14
  import { isEmptyTurnError } from "../routes/chat/relay-pipeline.js";
14
15
 
15
16
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -97,7 +98,7 @@ export async function runSerialTrial(ctx, deps = {}) {
97
98
  let emptyRetried = 0;
98
99
  for (;;) {
99
100
  const tUp = performance.now();
100
- evt("upstream-try", { reqId, model, attempt: idx + 1, emptyRetry: emptyRetried });
101
+ evt("upstream-try", { reqId, model, attempt: idx + 1, emptyRetry: emptyRetried, payload: summarizeRequest(forwarded) });
101
102
  try {
102
103
  upRes = await upstream.chat(forwarded, chatOptsArg);
103
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 });
@@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url";
4
4
  import { defaultStateFile } from "../../state.js";
5
5
  import { loadToken, refreshToken } from "../../state.js";
6
6
  import { stopDaemon, pidFile, logFile } from "../../daemon.js";
7
- import { logDir, eventsFile, callsFile, errorsFile, recentEvents } from "../../logs.js";
7
+ import { logDir, eventsFile, callsFile, errorsFile, recentEvents, timelineFile, recentTimeline } from "../../logs.js";
8
8
  import { fmtEvent } from "../format.js";
9
9
  import { fmtShanghaiYMDHMS, fmtShanghaiHMS } from "../../time.js";
10
10
  import { printHelp } from "../help.js";
@@ -95,6 +95,7 @@ export async function handleUninstall(args) {
95
95
  join(dir, "calls.log"),
96
96
  join(dir, "errors.log"),
97
97
  join(dir, "events.log"),
98
+ join(dir, "timeline.log"),
98
99
  ]) {
99
100
  try {
100
101
  rmSync(f, { force: true });
@@ -128,6 +129,13 @@ export async function handleLog(args) {
128
129
  if (count <= 10) {
129
130
  console.log(`\nhint: mslxdff -log 100 | calls: ${callsFile()} errors: ${errorsFile()} daemon: ${logFile()}`);
130
131
  }
132
+ const timeline = recentTimeline(count);
133
+ if (timeline.length) {
134
+ console.log(`--- last ${timeline.length} timeline line(s) ---`);
135
+ for (const line of timeline) console.log(line);
136
+ }
137
+ console.log(`timeline: ${timelineFile()}`);
138
+ console.log(`model logs: ${dir}\\<provider>-<model>.log`);
131
139
  process.exit(0);
132
140
  }
133
141
 
package/src/logs.js CHANGED
@@ -25,6 +25,10 @@ export function eventsFile() {
25
25
  return join(logDir(), "events.log");
26
26
  }
27
27
 
28
+ export function timelineFile() {
29
+ return join(logDir(), "timeline.log");
30
+ }
31
+
28
32
  function ensureDir(dir) {
29
33
  mkdirSync(dir, { recursive: true });
30
34
  }
@@ -86,6 +90,13 @@ function appendLine(file, entry) {
86
90
  .then(() => trimIfOversizedAsync(file).catch(() => {}));
87
91
  }
88
92
 
93
+ export function recentTimeline(n = 10, { file = timelineFile() } = {}) {
94
+ try {
95
+ if (!existsSync(file)) return [];
96
+ return readFileSync(file, "utf8").split("\n").filter(Boolean).slice(-n);
97
+ } catch { return []; }
98
+ }
99
+
89
100
  export function appendCall(entry, { file = callsFile() } = {}) {
90
101
  appendLine(file, entry);
91
102
  }
@@ -132,3 +143,14 @@ export function lastError({ file = errorsFile() } = {}) {
132
143
  export function recentErrors(n = 5, { file = errorsFile() } = {}) {
133
144
  return readLines(file).slice(-n);
134
145
  }
146
+
147
+ export function appendTimeline(entry, { file = timelineFile() } = {}) {
148
+ const line = `${fmtShanghaiYMDHMS(new Date())} ${String(entry || "")}\n`;
149
+ ensureDir(dirname(file));
150
+ if (shouldSync(file)) {
151
+ appendFileSync(file, line);
152
+ trimIfOversized(file);
153
+ return;
154
+ }
155
+ appendFile(file, line).catch(() => {}).then(() => trimIfOversizedAsync(file).catch(() => {}));
156
+ }
@@ -0,0 +1,97 @@
1
+ // Per-model request trace formatting. Never include prompt, response body, headers or credentials.
2
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { logDir } from "./logs.js";
5
+ import { fmtShanghaiYMDHMS } from "./time.js";
6
+
7
+ const MAX_TRACE_BYTES = 1024 * 1024;
8
+
9
+ export function modelLogName(model) {
10
+ const s = String(model || "unknown").trim().toLowerCase();
11
+ const safe = s.replace(/[^a-z0-9._-]+/g, "-").replace(/^[-.]+|[-.]+$/g, "").slice(0, 180);
12
+ return `${safe || "unknown"}.log`;
13
+ }
14
+
15
+ export function modelLogFile(model) {
16
+ return join(logDir(), modelLogName(model));
17
+ }
18
+
19
+
20
+ function count(v) {
21
+ const n = Number(v);
22
+ return Number.isFinite(n) && n >= 0 ? n : null;
23
+ }
24
+
25
+ export function summarizeRequest(body = {}) {
26
+ const messages = Array.isArray(body.messages) ? body.messages : [];
27
+ const roles = {};
28
+ for (const m of messages) roles[String(m?.role || "unknown")] = (roles[String(m?.role || "unknown")] || 0) + 1;
29
+ return {
30
+ stream: body.stream === true,
31
+ messages: messages.length,
32
+ roles,
33
+ tools: Array.isArray(body.tools) ? body.tools.length : 0,
34
+ maxTokens: count(body.max_tokens ?? body.max_completion_tokens),
35
+ temperature: Number.isFinite(Number(body.temperature)) ? Number(body.temperature) : null,
36
+ topP: Number.isFinite(Number(body.top_p)) ? Number(body.top_p) : null,
37
+ };
38
+ }
39
+
40
+ function hostPort(value) {
41
+ try { const u = new URL(String(value)); return u.port ? `${u.hostname}:${u.port}` : u.hostname; } catch { return "-"; }
42
+ }
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",
47
+ ]);
48
+
49
+ export function shouldTraceModel(type) {
50
+ return TRACE_STAGES.has(String(type || ""));
51
+ }
52
+
53
+ function safeText(value, n = 140) {
54
+ return String(value ?? "")
55
+ .replace(/([?&](?:token|refreshToken|access[_-]?token|refresh[_-]?token|api[_-]?key|apikey|key|secret|password|cookie)=)[^&\s]+/gi, "$1[redacted]")
56
+ .replace(/(authorization|bearer|access[_-]?token|refresh[_-]?token|cookie|api[-_]?key|password|secret)\s*[:=]\s*[^,; ]+/gi, "$1=[redacted]")
57
+ .replace(/\s+/g, " ").trim().slice(0, n);
58
+ }
59
+
60
+ function kv(obj) {
61
+ return Object.entries(obj).filter(([, v]) => v != null && v !== "").map(([k, v]) => `${k}=${typeof v === "object" ? JSON.stringify(v) : v}`).join(" ");
62
+ }
63
+
64
+ export function formatModelTrace({ type, reqId, model, data = {}, request = null, totalMs = null } = {}) {
65
+ const base = { time: fmtShanghaiYMDHMS(new Date()), req: reqId || "-", model: model || "-", stage: type || "-" };
66
+ let detail = "";
67
+ if (type === "request") detail = `incoming ${kv({ ...(request || summarizeRequest(data.body)), hops: data.hops, useAuto: data.useAuto })}`;
68
+ else if (type === "ordered") detail = `route order=${(data.order || []).join(" | ")} hops=${data.hops ?? "-"} fallback=${data.canFallback ? 1 : 0}`;
69
+ else if (type === "model-try") detail = `target=${data.model || model || "-"} idx=${data.idx ?? "-"} remaining=${data.remaining ?? "-"}`;
70
+ else if (type === "alias") detail = `rawModel=${data.rawModel || "-"} requested=${data.requested || model || "-"}`;
71
+ else if (type === "exhausted-local" || type === "exhausted-all") detail = `last=${data.lastModel || model || "-"} status=${data.lastStatus ?? "-"} order=${(data.order || []).join(" | ")}`;
72
+ 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 || "")}`;
74
+ 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
+ else if (type === "relay-done") {
76
+ const d = data.detail || {};
77
+ 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`;
79
+ } 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 || "");
82
+ return `[${base.time}] [req=${base.req}] [model=${base.model}] [stage=${base.stage}] ${detail}`;
83
+ }
84
+
85
+ export function appendModelTrace(model, entry, { file = modelLogFile(model) } = {}) {
86
+ const line = `${formatModelTrace(entry)}\n`;
87
+ try {
88
+ mkdirSync(logDir(), { recursive: true });
89
+ if (existsSync(file) && statSync(file).size > MAX_TRACE_BYTES) {
90
+ const lines = readFileSync(file, "utf8").split("\n").filter(Boolean);
91
+ writeFileSync(file, lines.slice(-100).join("\n") + "\n");
92
+ }
93
+ appendFileSync(file, line);
94
+ } catch {
95
+ // Logging must never affect the chat request.
96
+ }
97
+ }
@@ -15,6 +15,7 @@ export async function handleExhaustedLocal({ res, body, lastErr, order, handlerC
15
15
  });
16
16
  evt("relay-done", { reqId: handlerCtx.reqId, model: lastErr.model, via: "local-exhausted", status: out.status, ttfMs: out.ttfMs, totalMs: out.totalMs, aborted: out.aborted, interrupted: out.interrupted ?? false, detail: out.detail ?? null });
17
17
  evt("result", { reqId: handlerCtx.reqId, model: lastErr.model, status: out.status, via: "local", timing: lastErr.upstream._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, detail: out.detail ?? null });
18
+ evt("client-response", { requested, actual: lastErr.model, via: "local", fallback: false, status: out.status, reqId: handlerCtx.reqId });
18
19
  // 最后一站:relay 超时路径不写响应(设计留给上层 failover),这里没有上层,必须自己收尾
19
20
  if (out.timedOut) {
20
21
  json(res, 502, { error: out.detail?.upstreamError ? `upstream error: ${out.detail.upstreamError}` : `stream timed out after ${out.totalMs}ms` });
@@ -22,6 +23,7 @@ export async function handleExhaustedLocal({ res, body, lastErr, order, handlerC
22
23
  return true;
23
24
  }
24
25
  evt("result", { reqId: handlerCtx.reqId, model, status: lastErr?.status ?? 502, via: "none", timing: null });
26
+ evt("client-response", { requested, actual: model, via: "none", fallback: false, status: lastErr?.status ?? 502, reqId: handlerCtx.reqId });
25
27
  done({ via: "none", status: lastErr?.status ?? 502, error: lastErr?.message || "all auto models failed" });
26
28
  json(res, 502, { error: lastErr?.message || "all auto models failed" });
27
29
  return true;
@@ -39,6 +41,7 @@ export async function handleExhaustedAll({ res, body, lastErr, order, requested,
39
41
  });
40
42
  evt("relay-done", { reqId: handlerCtx.reqId, model: lastErr.model, via: "local-final", status: out.status, ttfMs: out.ttfMs, totalMs: out.totalMs, aborted: out.aborted, interrupted: out.interrupted ?? false, detail: out.detail ?? null });
41
43
  evt("result", { reqId: handlerCtx.reqId, model: lastErr.model, status: out.status, via: "local", timing: lastErr.upstream._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, detail: out.detail ?? null });
44
+ evt("client-response", { requested, actual: lastErr.model, via: "local", fallback: false, status: out.status, reqId: handlerCtx.reqId });
42
45
  // 最后一站:relay 超时路径不写响应,这里没有上层 failover,必须自己收尾
43
46
  if (out.timedOut) {
44
47
  json(res, 502, { error: out.detail?.upstreamError ? `upstream error: ${out.detail.upstreamError}` : `stream timed out after ${out.totalMs}ms` });
@@ -46,6 +49,7 @@ export async function handleExhaustedAll({ res, body, lastErr, order, requested,
46
49
  return true;
47
50
  }
48
51
  evt("result", { reqId: handlerCtx.reqId, model: lastErr?.model ?? requested, status: lastErr?.status ?? 502, via: "none", timing: null });
52
+ evt("client-response", { requested, actual: lastErr?.model ?? requested, via: "none", fallback: false, status: lastErr?.status ?? 502, reqId: handlerCtx.reqId });
49
53
  json(res, 502, { error: lastErr?.message || "all auto models failed" });
50
54
  return true;
51
55
  }
@@ -12,6 +12,7 @@ const DEFAULT_PEER_CONNECT_TIMEOUT_MS = 3_000;
12
12
  // 沿用 30s 会把组内转发的中继活流掐成 "terminated"(实测首块后 ~31s 必断,本地直连
13
13
  // 无此限制——组内因此比直连"不流畅")。与应用层 MAX_STREAM_MS(120s) 对齐:
14
14
  // undici 层只做最后兜底,掐流交给应用层的首块/总时长策略。
15
+ import { summarizeRequest } from "../model-trace.js";
15
16
  const PEER_BODY_TIMEOUT_MS = 120_000;
16
17
  export { PEER_BODY_TIMEOUT_MS };
17
18
 
@@ -203,7 +204,7 @@ export async function racePeerCandidates(candidates, ctx) {
203
204
  tried.add(peer.url);
204
205
  const ctrl = new AbortController();
205
206
  ctrls.set(peer.url, ctrl);
206
- ctx.evt("peer-request", { peer: peer.url, model: target, hops: ctx.hops + 1 });
207
+ ctx.evt("peer-request", { peer: peer.url, model: target, hops: ctx.hops + 1, payload: summarizeRequest(ctx.body) });
207
208
  // 插件 hook:peer:beforeForward — 转发给组员前观察
208
209
  if (ctx.plugins?.length) {
209
210
  runHook(ctx.plugins, "peer:beforeForward", { reqId: ctx.reqId, peer: peer.url, model: target, hops: ctx.hops + 1 }).catch(() => {});
@@ -1,10 +1,11 @@
1
- import { appendCall, appendError, appendEvent } from "../logs.js";
1
+ import { appendCall, appendError, appendEvent, appendTimeline } from "../logs.js";
2
2
  import { setupProviders } from "./providers-setup.js";
3
3
  import { startServerLifecycle } from "./server-lifecycle.js";
4
+ import { appendModelTrace } from "../model-trace.js";
4
5
  import { startGroupSync } from "./group-sync.js";
5
6
  import { startBroadband } from "./broadband.js";
6
7
 
7
- const logs = { appendCall, appendError, appendEvent };
8
+ const logs = { appendCall, appendError, appendEvent, appendTimeline, appendModelTrace };
8
9
 
9
10
  /**
10
11
  * daemon 启动门面 — 仅编排:组装世界 → 服务生命周期 → 群组同步 → 宽带中继 → 自更新。
@@ -0,0 +1,22 @@
1
+ // 人读请求时间线:只输出状态/耗时/出口,不输出 prompt、响应正文或凭据。
2
+ function hostPort(value) {
3
+ 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
+ function short(value, n = 90) {
6
+ const s = String(value ?? "").replace(/\s+/g, " ").trim();
7
+ return !s ? "-" : s.length > n ? `${s.slice(0, n - 1)}…` : s;
8
+ }
9
+ function outcome(status, reason) {
10
+ if (status == null) return reason ? `- ${short(reason)}` : "-";
11
+ return `${status}${reason ? ` ${short(reason)}` : ""}`;
12
+ }
13
+
14
+ export function formatTimeline({ reqId, model, direct = [], peers = [], retries = 0, result = null, totalMs = 0 } = {}) {
15
+ const d = direct.length ? direct[direct.length - 1] : null;
16
+ const ps = peers.map((p) => `[peer=${hostPort(p.peer)} ${p.ok ? "win" : "fail"} ${p.latencyMs != null ? `${p.latencyMs}ms` : "timing-"} ${short(p.message || p.status || "", 70)}]`);
17
+ const r = result || {};
18
+ const detail = r.detail || {};
19
+ const res = r.status == null ? "-" : `${r.status}${detail.sawFinishReason ? ` ${detail.sawFinishReason}` : ""}${detail.toolCalls ? ` tools=${detail.toolCalls}` : ""}${detail.chars != null ? ` chars=${detail.chars}` : ""}${detail.interrupted ? " interrupted=1" : ""}${detail.timedOut ? " timedOut=1" : ""}`;
20
+ const directText = d ? outcome(d.status, d.reason) : "-";
21
+ return `[req=${reqId || "-"}] [model=${model || "-"}] [direct=${directText}]${ps.length ? ` ${ps.join(" ")}` : ""}${retries ? ` [retry=${retries}]` : ""} [result=${res}] [total=${Math.max(0, Math.round(Number(totalMs) || 0))}ms]`;
22
+ }
@@ -4,10 +4,39 @@
4
4
  // HTTP 错误就地映射为带状态码的 Response;装载失败抛 _sdkLoadFailed 由引擎回退 legacy。
5
5
  // responses 适配器(sdk/responses.js)复用本文件的 baseURL 解析、错误映射与流序列化。
6
6
  // 见 .scratch/ai-sdk-upstream/{SPEC.md,SPEC-p3-responses.md} 与 docs/adr/0017。
7
+ // Headers 超时(防 SDK 通道挂死):doStream 裸 await 曾因上游连接半死永不 resolve
8
+ //(cline muse-spark 2026-09-25 17:48 悬空 27min+,无 upstream-done/error/result)。
9
+ // 默认 120s(覆盖慢模型首块握手的合理上限),MSLXDFF_SDK_HEADERS_TIMEOUT_MS=0 关闭。
10
+ // 注意错误文案不得含 "timed out":cline runChat 靠该子串做网络重试,命中会把挂死放大 3 倍。
7
11
  import { toModelPrompt, toModelTools, toModelToolChoice, toModelParams } from "./convert.js";
8
12
  import { createSseSerializer } from "./sse.js";
9
13
  import { diagnoseToolSequence, compactSequence } from "./diagnose.js";
10
14
  import { getUndici } from "../../compat.js";
15
+ function headersTimeoutMs(env = process.env) {
16
+ const n = Number(env.MSLXDFF_SDK_HEADERS_TIMEOUT_MS);
17
+ return Number.isFinite(n) && n >= 0 ? n : 120_000;
18
+ }
19
+
20
+ // race 包裹:超时 reject mkErr();先赢路径清定时器并吞掉迟到的 rejection(防 unhandled)。
21
+ export function withHeadersTimeout(promise, ms, mkErr) {
22
+ if (!Number.isFinite(ms) || ms <= 0) return promise;
23
+ let timer = null;
24
+ const guard = new Promise((_, reject) => {
25
+ timer = setTimeout(() => reject(mkErr()), ms);
26
+ });
27
+ return Promise.race([
28
+ Promise.resolve(promise).then(
29
+ (v) => { clearTimeout(timer); return v; },
30
+ (e) => { clearTimeout(timer); throw e; },
31
+ ),
32
+ guard,
33
+ ]).catch((e) => {
34
+ // 超时赢后原 promise 仍可能 reject:静默兜住,不让其变成 unhandled rejection。
35
+ if (promise && typeof promise.catch === "function") promise.catch(() => {});
36
+ throw e;
37
+ });
38
+ }
39
+
11
40
 
12
41
  let sdkPromise = null;
13
42
 
@@ -155,15 +184,23 @@ export async function attemptOnceSdk({
155
184
  ...(capturedFetch ? { fetch: capturedFetch } : {}),
156
185
  });
157
186
  const model = provider.chatModel(String(body?.model || ""));
187
+ const htMs = headersTimeoutMs();
188
+ const aborter = htMs > 0 ? new AbortController() : null;
158
189
  let res;
159
190
  try {
160
- res = await model.doStream({
161
- prompt: toModelPrompt(body?.messages),
162
- ...toModelParams(body, providerName),
163
- tools: toModelTools(body?.tools),
164
- toolChoice: toModelToolChoice(body?.tool_choice),
165
- });
191
+ res = await withHeadersTimeout(
192
+ model.doStream({
193
+ prompt: toModelPrompt(body?.messages),
194
+ ...toModelParams(body, providerName),
195
+ tools: toModelTools(body?.tools),
196
+ toolChoice: toModelToolChoice(body?.tool_choice),
197
+ ...(aborter ? { abortSignal: aborter.signal } : {}),
198
+ }),
199
+ htMs,
200
+ () => new Error(`sdk-channel: headers timeout after ${htMs}ms(上游未返回响应头,防挂死;MSLXDFF_SDK_HEADERS_TIMEOUT_MS=0 关闭)`),
201
+ );
166
202
  } catch (e) {
203
+ try { aborter?.abort(); } catch {}
167
204
  const mapped = errorResponseFromSdkError(e, { marker });
168
205
  if (mapped) {
169
206
  try { logRequestDiagnosis(lastBodyText, mapped.status); } catch {}