mslxdff 0.1.132 → 0.1.134

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.132",
3
+ "version": "0.1.134",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,131 @@
1
+ // zen 免费层"agent 形状"门禁(2026-09-18 上线,bisect 实测):
2
+ // 403 FreeTierError ≤ 请求必须同时满足:① stream:true ② tools 含 bash/edit/glob/grep/read 五个核心名。
3
+ // 另 UA 版本需 ≥ opencode/1.18.0(低版本返回 426 UpgradeRequired,见 opencode-identity.js)。
4
+ // 官方客户端自带这五个工具天然通过;-chat 直连(非流式、工具名不同)与裸 API 客户端需补形状。
5
+ // 逃生阀:MSLXDFF_FREE_LANE=0 关闭(注入与强制流式都跳过),上游若撤门禁可回退。
6
+
7
+ export const CORE_AGENT_TOOL_NAMES = ["bash", "edit", "glob", "grep", "read"];
8
+
9
+ function laneDisabled(env = process.env) {
10
+ const raw = env.MSLXDFF_FREE_LANE;
11
+ if (raw === undefined || raw === null || raw === "") return false;
12
+ const s = String(raw).trim().toLowerCase();
13
+ return s === "0" || s === "false" || s === "off" || s === "no";
14
+ }
15
+
16
+ const chatTool = (name) => ({
17
+ type: "function",
18
+ function: { name, description: `The ${name} tool.`, parameters: { type: "object", properties: {} } },
19
+ });
20
+
21
+ const responsesTool = (name) => ({
22
+ type: "function",
23
+ name,
24
+ description: `The ${name} tool.`,
25
+ parameters: { type: "object", properties: {} },
26
+ });
27
+
28
+ function toolName(tool, responses) {
29
+ return responses ? tool?.name : tool?.function?.name;
30
+ }
31
+
32
+ /**
33
+ * 给免费层请求补 agent 形状(幂等,原地改 body)。
34
+ * @returns {{injected: string[], forcedStream: boolean, disabled: boolean}}
35
+ */
36
+ export function ensureFreeLaneShape(body, { responses = false, env = process.env } = {}) {
37
+ if (!body || typeof body !== "object") return { injected: [], forcedStream: false, disabled: true };
38
+ if (laneDisabled(env)) return { injected: [], forcedStream: false, disabled: true };
39
+ const forcedStream = body.stream !== true;
40
+ body.stream = true;
41
+ const mk = responses ? responsesTool : chatTool;
42
+ const list = Array.isArray(body.tools) ? body.tools : [];
43
+ const have = new Set(list.map((t) => toolName(t, responses)).filter(Boolean));
44
+ const injected = CORE_AGENT_TOOL_NAMES.filter((n) => !have.has(n));
45
+ if (injected.length) body.tools = [...list, ...injected.map(mk)];
46
+ return { injected, forcedStream, disabled: false };
47
+ }
48
+
49
+ function mergeToolCall(target, idx, delta) {
50
+ const cur = target[idx] || (target[idx] = { index: idx, id: undefined, type: "function", function: { name: undefined, arguments: "" } });
51
+ if (delta?.id) cur.id = delta.id;
52
+ if (delta?.type) cur.type = delta.type;
53
+ if (delta?.function?.name) cur.function.name = delta.function.name;
54
+ if (delta?.function?.arguments) cur.function.arguments += delta.function.arguments;
55
+ }
56
+
57
+ function finishSseJson(acc) {
58
+ const message = { role: "assistant", content: acc.content };
59
+ if (acc.reasoning) message.reasoning_content = acc.reasoning;
60
+ if (acc.toolCalls.length) {
61
+ message.tool_calls = acc.toolCalls
62
+ .filter(Boolean)
63
+ .map((c, i) => ({ ...c, index: undefined, id: c.id || `call_${i}`, function: { name: c.function.name || "", arguments: c.function.arguments || "{}" } }));
64
+ if (!message.content) message.content = "";
65
+ }
66
+ const json = {
67
+ id: acc.id || "chatcmpl-aggregated",
68
+ object: "chat.completion",
69
+ created: Math.floor(Date.now() / 1000),
70
+ model: acc.model,
71
+ choices: [{ index: 0, message, finish_reason: acc.finishReason || (acc.toolCalls.length ? "tool_calls" : "stop"), logprobs: null }],
72
+ };
73
+ if (acc.usage) json.usage = acc.usage;
74
+ return json;
75
+ }
76
+
77
+ /**
78
+ * 把上游 SSE 流聚合回非流式 chat completion JSON。
79
+ * 仅消费 body,不改状态;解析失败时抛错(调用方自行回退)。
80
+ */
81
+ export async function aggregateChatSse(res) {
82
+ const reader = res.body?.getReader?.();
83
+ if (!reader) return res;
84
+ const acc = { id: null, model: null, content: "", reasoning: "", toolCalls: [], finishReason: null, usage: null, error: null };
85
+ const decoder = new TextDecoder();
86
+ let buf = "";
87
+ const eat = (frame) => {
88
+ for (const line of frame.split("\n")) {
89
+ const s = line.trim();
90
+ if (!s.startsWith("data:")) continue;
91
+ const payload = s.slice(5).trim();
92
+ if (!payload || payload === "[DONE]") continue;
93
+ let j;
94
+ try { j = JSON.parse(payload); } catch { continue; }
95
+ if (j?.error) { acc.error = acc.error || j.error; continue; }
96
+ if (j?.id) acc.id = j.id;
97
+ if (j?.model) acc.model = j.model;
98
+ if (j?.usage) acc.usage = j.usage;
99
+ const ch = j?.choices?.[0];
100
+ if (!ch) continue;
101
+ const d = ch.delta || {};
102
+ if (typeof d.content === "string") acc.content += d.content;
103
+ if (typeof d.reasoning_content === "string") acc.reasoning += d.reasoning_content;
104
+ else if (typeof d.reasoning === "string") acc.reasoning += d.reasoning;
105
+ if (Array.isArray(d.tool_calls)) for (const tc of d.tool_calls) mergeToolCall(acc.toolCalls, tc.index ?? 0, tc);
106
+ if (ch.finish_reason) acc.finishReason = ch.finish_reason;
107
+ if (ch.message && typeof ch.message.content === "string" && !acc.content) {
108
+ acc.content = ch.message.content;
109
+ if (Array.isArray(ch.message.tool_calls)) for (const tc of ch.message.tool_calls) mergeToolCall(acc.toolCalls, tc.index ?? 0, tc);
110
+ }
111
+ }
112
+ };
113
+ try {
114
+ for (;;) {
115
+ const { done, value } = await reader.read();
116
+ if (done) break;
117
+ buf += decoder.decode(value, { stream: true });
118
+ const frames = buf.split("\n\n");
119
+ buf = frames.pop() || "";
120
+ for (const f of frames) eat(f);
121
+ }
122
+ if (buf.trim()) eat(buf);
123
+ } finally {
124
+ try { reader.releaseLock?.(); } catch {}
125
+ }
126
+ if (acc.error && !acc.content && !acc.toolCalls.length) {
127
+ const body = JSON.stringify({ error: acc.error });
128
+ return new Response(body, { status: 502, headers: { "content-type": "application/json" } });
129
+ }
130
+ return new Response(JSON.stringify(finishSseJson(acc)), { status: res.status, headers: { "content-type": "application/json" } });
131
+ }
@@ -3,9 +3,10 @@ import crypto from "node:crypto";
3
3
  // opencode 客户端身份规格单一来源(源码 packages/schema/src/identifier.ts +
4
4
  // packages/opencode/src/session/llm/request.ts):id = <prefix>_ + 12 位 hex
5
5
  // (timestamp*4096+同毫秒计数,截 48bit) + 14 位 base62,共 26 字符;UA 必须 "opencode/<semver>"。
6
- // zen 免费层 2026-09-17 起按该规格放行(缺版本 UA 或 id 形状不符 → 403 FreeTierError)。
6
+ // zen 免费层 2026-09-17 起按该规格放行(缺版本 UA 或 id 形状不符 → 403 FreeTierError);
7
+ // 2026-09-18 起还要求 UA 版本 ≥ 1.18.0(低版本 426 UpgradeRequired),默认跟 npm latest。
7
8
  const ID_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
8
- const DEFAULT_OPENCODE_UA = "opencode/1.17.20";
9
+ const DEFAULT_OPENCODE_UA = "opencode/1.18.31";
9
10
  let idLastTs = 0;
10
11
  let idCounter = 0;
11
12
 
@@ -25,13 +25,15 @@ export async function handleBroadbandRelay({
25
25
  res,
26
26
  startedAt,
27
27
  plugins,
28
+ deps = {},
28
29
  }) {
29
- const bb = await tryBroadbandRelay({ groups, token, model, body, hops, bus, logs, reqId: handlerCtx.reqId, evt, res, mark, perf0, stages });
30
+ const { createPipeline = createRelayPipeline, tryBroadbandRelay: tryRelay = tryBroadbandRelay } = deps;
31
+ const bb = await tryRelay({ groups, token, model, body, hops, bus, logs, reqId: handlerCtx.reqId, evt, res, mark, perf0, stages });
30
32
  if (!bb) {
31
33
  evt("relay-miss", { reqId: handlerCtx.reqId, model });
32
34
  return { handled: false };
33
35
  }
34
- const pipeline = createRelayPipeline({
36
+ const pipeline = createPipeline({
35
37
  relay,
36
38
  buildFallbackInfo,
37
39
  auto,
@@ -46,7 +48,8 @@ export async function handleBroadbandRelay({
46
48
  });
47
49
  const isResponse = bb.result && typeof bb.result.status === "number" && typeof bb.result.headers?.get === "function";
48
50
  if (isResponse) {
49
- await pipeline.execute({ res, upRes: bb.result, body, requested, actual: model, lastErr, via: "broadband", lockModel, useAuto, handlerCtx, mark, perf0, stages, startedAt });
51
+ const r = await pipeline.execute({ res, upRes: bb.result, body, requested, actual: model, lastErr, via: "broadband", lockModel, useAuto, handlerCtx, mark, perf0, stages, startedAt });
52
+ if (!r.handled) return { handled: false, upRes: null, lastErr: r.lastErr };
50
53
  return { handled: true };
51
54
  }
52
55
  if (bb.result && typeof bb.result.status === "number") {
@@ -59,7 +62,8 @@ export async function handleBroadbandRelay({
59
62
  text: async () => str,
60
63
  body: isSSE ? (async function* () { yield Buffer.from(str); })() : null,
61
64
  };
62
- await pipeline.execute({ res, upRes: fakeRes, body, requested, actual: model, lastErr, via: "broadband-local", lockModel, useAuto, handlerCtx, mark, perf0, stages, startedAt });
65
+ const r = await pipeline.execute({ res, upRes: fakeRes, body, requested, actual: model, lastErr, via: "broadband-local", lockModel, useAuto, handlerCtx, mark, perf0, stages, startedAt });
66
+ if (!r.handled) return { handled: false, upRes: null, lastErr: r.lastErr };
63
67
  return { handled: true };
64
68
  }
65
69
  evt("relay-miss", { reqId: handlerCtx.reqId, model });
@@ -2,18 +2,23 @@ import { relay } from "../stream.js";
2
2
  import { json } from "../helpers.js";
3
3
  import { performance } from "node:perf_hooks";
4
4
 
5
- export async function handleExhaustedLocal({ res, body, lastErr, order, handlerCtx, evt, logCall, mark, perf0, stages, done, requested, useAuto }) {
5
+ export async function handleExhaustedLocal({ res, body, lastErr, order, handlerCtx, evt, logCall, mark, perf0, stages, done, requested, useAuto, deps = {} }) {
6
+ const { relay: relayImpl = relay } = deps;
6
7
  const model = lastErr?.model ?? handlerCtx.model;
7
8
  evt("exhausted-local", { reqId: handlerCtx.reqId, lastModel: lastErr?.model ?? model, lastStatus: lastErr?.status ?? 502, order });
8
9
  logCall(lastErr?.model ?? model, lastErr?.status ?? 502);
9
10
  if (lastErr?.upstream) {
10
11
  evt("relay-start", { reqId: handlerCtx.reqId, model: lastErr.model, via: "local-exhausted", isStream: Boolean(body.stream) });
11
- const out = await relay(res, lastErr.upstream, body, {
12
+ const out = await relayImpl(res, lastErr.upstream, body, {
12
13
  onFirstChunk: (d) => mark(`ttf-${lastErr.model}`),
13
14
  onDownstreamAbort: () => evt("client-abort", { reqId: handlerCtx.reqId, model: lastErr.model, totalMs: Math.round(performance.now() - perf0), stages: [...stages] }),
14
15
  });
15
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 });
16
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
+ // 最后一站:relay 超时路径不写响应(设计留给上层 failover),这里没有上层,必须自己收尾
19
+ if (out.timedOut) {
20
+ json(res, 502, { error: out.detail?.upstreamError ? `upstream error: ${out.detail.upstreamError}` : `stream timed out after ${out.totalMs}ms` });
21
+ }
17
22
  return true;
18
23
  }
19
24
  evt("result", { reqId: handlerCtx.reqId, model, status: lastErr?.status ?? 502, via: "none", timing: null });
@@ -22,17 +27,22 @@ export async function handleExhaustedLocal({ res, body, lastErr, order, handlerC
22
27
  return true;
23
28
  }
24
29
 
25
- export async function handleExhaustedAll({ res, body, lastErr, order, requested, handlerCtx, evt, logCall, mark, perf0, stages }) {
30
+ export async function handleExhaustedAll({ res, body, lastErr, order, requested, handlerCtx, evt, logCall, mark, perf0, stages, deps = {} }) {
31
+ const { relay: relayImpl = relay } = deps;
26
32
  evt("exhausted-all", { reqId: handlerCtx.reqId, lastModel: lastErr?.model ?? requested, lastStatus: lastErr?.status ?? 502, order });
27
33
  logCall(lastErr?.model ?? requested, lastErr?.status ?? 502);
28
34
  if (lastErr?.upstream) {
29
35
  evt("relay-start", { reqId: handlerCtx.reqId, model: lastErr.model, via: "local-final", isStream: Boolean(body.stream) });
30
- const out = await relay(res, lastErr.upstream, body, {
36
+ const out = await relayImpl(res, lastErr.upstream, body, {
31
37
  onFirstChunk: (d) => mark(`ttf-${lastErr.model}`),
32
38
  onDownstreamAbort: () => evt("client-abort", { reqId: handlerCtx.reqId, model: lastErr.model, totalMs: Math.round(performance.now() - perf0), stages: [...stages] }),
33
39
  });
34
40
  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 });
35
41
  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 });
42
+ // 最后一站:relay 超时路径不写响应,这里没有上层 failover,必须自己收尾
43
+ if (out.timedOut) {
44
+ json(res, 502, { error: out.detail?.upstreamError ? `upstream error: ${out.detail.upstreamError}` : `stream timed out after ${out.totalMs}ms` });
45
+ }
36
46
  return true;
37
47
  }
38
48
  evt("result", { reqId: handlerCtx.reqId, model: lastErr?.model ?? requested, status: lastErr?.status ?? 502, via: "none", timing: null });
@@ -27,14 +27,16 @@ export async function handleHedge({
27
27
  plugins,
28
28
  res,
29
29
  hedgeDelayMs,
30
+ deps = {},
30
31
  }) {
32
+ const { createPipeline = createRelayPipeline, hedgedFirstChunkRace: race = hedgedFirstChunkRace } = deps;
31
33
  const d = hedgeDelayMs;
32
34
  try {
33
- const hedged = await hedgedFirstChunkRace({ localUpRes: upRes, peers, handlerCtx, hedgeDelayMs: d, evt });
35
+ const hedged = await race({ localUpRes: upRes, peers, handlerCtx, hedgeDelayMs: d, evt });
34
36
  if (hedged && hedged.winner) {
35
37
  if (hedged.winner === "local") {
36
38
  const bufferedUpRes = { ...upRes, body: hedged.bufferedBody, headers: upRes.headers, status: upRes.status, _t: upRes._t };
37
- const pipeline = createRelayPipeline({
39
+ const pipeline = createPipeline({
38
40
  relay,
39
41
  buildFallbackInfo,
40
42
  auto,
@@ -72,7 +74,7 @@ export async function handleHedge({
72
74
  evt("peer-race-win", { reqId: handlerCtx.reqId, model, winPeer: win.peer.url, winTarget: win.target, latencyMs: win.latencyMs, hedged: true, ttfMs: hedged.ttfMs });
73
75
  await peers.recordResult(win.peer.url, { ok: true, latencyMs: win.latencyMs, model: win.target });
74
76
  const bufferedPeerRes = { ...win.res, body: hedged.bufferedBody, headers: win.res.headers, status: win.res.status, _t: win.res._t };
75
- const pipeline = createRelayPipeline({
77
+ const pipeline = createPipeline({
76
78
  relay,
77
79
  buildFallbackInfo,
78
80
  auto,
@@ -85,7 +87,7 @@ export async function handleHedge({
85
87
  startedAt,
86
88
  stages,
87
89
  });
88
- await pipeline.execute({
90
+ const r = await pipeline.execute({
89
91
  res,
90
92
  upRes: bufferedPeerRes,
91
93
  body,
@@ -101,6 +103,7 @@ export async function handleHedge({
101
103
  stages,
102
104
  startedAt,
103
105
  });
106
+ if (!r.handled) return { handled: false, upRes: null, lastErr: r.lastErr };
104
107
  return { handled: true };
105
108
  }
106
109
  }
@@ -22,14 +22,16 @@ export async function handlePeerRelay({
22
22
  startedAt,
23
23
  plugins,
24
24
  res,
25
+ deps = {},
25
26
  }) {
27
+ const { createPipeline = createRelayPipeline, racePeerCandidates: race = racePeerCandidates } = deps;
26
28
  evt("peer-race-start", { reqId: handlerCtx.reqId, model, peers: peers.ordered().length });
27
29
  const peerErrors = [];
28
30
  const pctx = { ...handlerCtx, peerErrors };
29
31
  const win =
30
- (await racePeerCandidates(peers.ordered(), pctx)) ||
31
- (await racePeerCandidates(peers.orderedByLastError(), pctx)) ||
32
- (await racePeerCandidates(peers.coolingByLastError(), pctx));
32
+ (await race(peers.ordered(), pctx)) ||
33
+ (await race(peers.orderedByLastError(), pctx)) ||
34
+ (await race(peers.coolingByLastError(), pctx));
33
35
  if (!win) {
34
36
  evt("peer-race-lose", { reqId: handlerCtx.reqId, model });
35
37
  // 组员全失败:把每个组员的真实返回汇总给调用者(否则只剩一个无信息的 429/502)
@@ -42,7 +44,7 @@ export async function handlePeerRelay({
42
44
  evt("peer-race-win", { reqId: handlerCtx.reqId, model, winPeer: win.peer.url, winTarget: win.target, latencyMs: win.latencyMs });
43
45
  await peers.recordResult(win.peer.url, { ok: true, latencyMs: win.latencyMs, model: win.target });
44
46
 
45
- const pipeline = createRelayPipeline({
47
+ const pipeline = createPipeline({
46
48
  relay,
47
49
  buildFallbackInfo,
48
50
  auto,
@@ -55,7 +57,7 @@ export async function handlePeerRelay({
55
57
  startedAt,
56
58
  stages,
57
59
  });
58
- await pipeline.execute({
60
+ const r = await pipeline.execute({
59
61
  res,
60
62
  upRes: win.res,
61
63
  body,
@@ -71,5 +73,7 @@ export async function handlePeerRelay({
71
73
  stages,
72
74
  startedAt,
73
75
  });
76
+ // Note: 超时/失败必须把 handled:false 交回上层换候选(丢弃 → 响应永不 end)— 见 .agents/notes/implemented/bug-fix/2026-09-17-relay-first-chunk-gate-real-cancel.md
77
+ if (!r.handled) return { handled: false, upRes: null, lastErr: r.lastErr };
74
78
  return { handled: true };
75
- }
79
+ }
@@ -5,7 +5,7 @@ import { computeMetrics } from "../../metrics.js";
5
5
 
6
6
  // 唯一/最后候选没有 failover 去向:首块闸门退化为纯"防连接泄漏",放宽避免误杀慢模型
7
7
  // (参考 opencode:zen 通道不设超时;openai responses 硬编码 300s headerTimeout)
8
- const LAST_CANDIDATE_TIMEOUT_MS = (() => {
8
+ export const LAST_CANDIDATE_TIMEOUT_MS = (() => {
9
9
  const n = Number(process.env.MSLXDFF_LAST_CANDIDATE_TIMEOUT_MS);
10
10
  return Number.isInteger(n) && n >= 0 ? n : 120_000;
11
11
  })();
@@ -60,6 +60,7 @@ export function createRelayPipeline({
60
60
  perf0,
61
61
  stages: s2,
62
62
  startedAt: sa2,
63
+ streamTimeoutMs: ctxStreamTimeoutMs,
63
64
  } = {}) {
64
65
  const markFn = m2 || _mark;
65
66
  const curStartedAt = sa2 ?? defaultStartedAt ?? Date.now();
@@ -79,13 +80,15 @@ export function createRelayPipeline({
79
80
  }
80
81
  _evt("relay-start", { reqId, model: actual, via, isStream: Boolean(body?.stream), fallback });
81
82
 
82
- // 3. relay(唯一/最后候选:无 failover 去向 → 闸门放宽到防泄漏级别)
83
+ // 3. relay(唯一/最后候选:无 failover 去向 → 闸门放宽到防泄漏级别;显式 streamTimeoutMs 优先)
83
84
  const orderLen = handlerCtx?.orderLen;
84
85
  const curIdx = handlerCtx?.idx;
85
86
  const isLastCandidate =
86
87
  Number.isInteger(orderLen) && orderLen > 0 &&
87
88
  (orderLen === 1 || (Number.isInteger(curIdx) && curIdx >= orderLen - 1));
88
- const streamTimeoutMs = isLastCandidate ? LAST_CANDIDATE_TIMEOUT_MS : C.STREAM_TIMEOUT_MS;
89
+ const streamTimeoutMs = Number.isInteger(ctxStreamTimeoutMs) && ctxStreamTimeoutMs >= 0
90
+ ? ctxStreamTimeoutMs
91
+ : (isLastCandidate ? LAST_CANDIDATE_TIMEOUT_MS : C.STREAM_TIMEOUT_MS);
89
92
  const out = await _relay(res, upRes, body, {
90
93
  fallback,
91
94
  streamTimeoutMs,
@@ -115,11 +118,12 @@ export function createRelayPipeline({
115
118
 
116
119
  // 5a. 首块超时未写字节 → 回退(显式 timedOut 字段,status 只是 HTTP 语义展示)
117
120
  if (out.timedOut === true) {
121
+ const why = out.detail?.upstreamError ? ` (upstream read error: ${out.detail.upstreamError})` : "";
118
122
  if (auto) try { await auto.recordError(actual, { status: 502, slow: true, note: `stream timeout ${streamTimeoutMs}ms` }); } catch {}
119
- try { _logError(actual, 502, `stream timeout ${streamTimeoutMs}ms`); } catch {}
123
+ try { _logError(actual, 502, `stream timeout ${streamTimeoutMs}ms${why}`); } catch {}
120
124
  _evt("upstream-error", { reqId, model: actual, status: 502, message: "stream timeout", timing: null });
121
125
  _evt("fallback", { reqId, from: actual, to: null, reason: "stream timeout" });
122
- return { handled: false, upRes: null, lastErr: { model: actual, upstream: null, status: 502, message: `stream timed out after ${streamTimeoutMs}ms` } };
126
+ return { handled: false, upRes: null, lastErr: { model: actual, upstream: null, status: 502, message: `stream timed out after ${streamTimeoutMs}ms${why}` } };
123
127
  }
124
128
 
125
129
  // 5b. 中断(stall 超时 / max 流时长)
@@ -1,4 +1,4 @@
1
- import { createRelayPipeline } from "./relay-pipeline.js";
1
+ import { createRelayPipeline, LAST_CANDIDATE_TIMEOUT_MS } from "./relay-pipeline.js";
2
2
  import { relay, SLOW_TOTAL_MS, STREAM_TIMEOUT_MS, STALL_TIMEOUT_MS, SCORE_STALL_MS } from "../stream.js";
3
3
  import { buildFallbackInfo } from "../fallback.js";
4
4
  import { getViaRoute } from "../../bench/via-routes.js";
@@ -121,7 +121,7 @@ export async function handleViaRoute({
121
121
  startedAt,
122
122
  stages,
123
123
  });
124
- await pipeline.execute({
124
+ const r = await pipeline.execute({
125
125
  res,
126
126
  upRes,
127
127
  body,
@@ -136,6 +136,9 @@ export async function handleViaRoute({
136
136
  perf0,
137
137
  stages,
138
138
  startedAt,
139
+ // 借道单路径无第二候选可比:按末位耐心档(默认 120s,MSLXDFF_LAST_CANDIDATE_TIMEOUT_MS 可调/0=不限)
140
+ streamTimeoutMs: LAST_CANDIDATE_TIMEOUT_MS,
139
141
  });
142
+ if (!r.handled) return { handled: false, upRes: null, lastErr: r.lastErr };
140
143
  return { handled: true };
141
144
  }
@@ -19,7 +19,8 @@ export function authorized(req, token) {
19
19
 
20
20
  export function json(res, status, body) {
21
21
  res.statusCode = status;
22
- res.setHeader("Content-Type", "application/json");
22
+ // 已发 headers(如 failover 时前一个候选写过 SSE 注释帧)不可再设,否则抛 ERR_HTTP_HEADERS_SENT
23
+ try { if (!res.headersSent) res.setHeader("Content-Type", "application/json"); } catch { /* ignore */ }
23
24
  res.end(JSON.stringify(body));
24
25
  }
25
26
 
@@ -20,6 +20,29 @@ function cancelBody(body) {
20
20
  } catch { /* ignore */ }
21
21
  }
22
22
 
23
+ // 注释帧(": keepalive" 等)不是模型输出:不算首块、不解除闸门、不触发超时救回,但照常透传
24
+ function hasPayload(text) {
25
+ for (const line of String(text).split("\n")) {
26
+ const t = line.trim();
27
+ if (!t || t.startsWith(":")) continue;
28
+ return true;
29
+ }
30
+ return false;
31
+ }
32
+
33
+ // 自持 reader 优先:for-await 会锁定 ReadableStream,使 body.cancel() 必 reject(真流上等于空操作),
34
+ // 超时/断下游要真能掐上游必须走 reader.cancel()
35
+ async function* bodyChunks(body, reader) {
36
+ if (reader) {
37
+ for (;;) {
38
+ const { done, value } = await reader.read();
39
+ if (done) return;
40
+ yield value;
41
+ }
42
+ }
43
+ for await (const c of body) yield c;
44
+ }
45
+
23
46
  export const SLOW_TOTAL_MS = (() => {
24
47
  const n = Number(process.env.MSLXDFF_SLOW_TOTAL_MS);
25
48
  return Number.isInteger(n) && n > 0 ? n : 20_000;
@@ -94,16 +117,26 @@ export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort,
94
117
  recoveries: 0,
95
118
  };
96
119
  let prevChunkAt = t0;
120
+ // 断下游即掐上游:流式分支装配真实取消,非流式保持 no-op
121
+ let cancelUpstream = () => {};
97
122
  const onClose = () => {
98
123
  detail.downstreamClosed = true;
99
- if (!finishedNormally && onDownstreamAbort) onDownstreamAbort();
124
+ if (!finishedNormally) {
125
+ cancelUpstream();
126
+ if (onDownstreamAbort) onDownstreamAbort();
127
+ }
100
128
  };
101
129
  res.on("close", onClose);
102
130
 
103
131
  if (isStream) {
104
- res.setHeader("Content-Type", "text/event-stream");
105
- res.setHeader("Cache-Control", "no-cache");
106
- res.setHeader("Connection", "keep-alive");
132
+ // failover 重入时 headers 可能已发(前一个候选只发过 keepalive 注释帧就被掐)——已发则不可再设
133
+ if (!res.headersSent) {
134
+ try {
135
+ res.setHeader("Content-Type", "text/event-stream");
136
+ res.setHeader("Cache-Control", "no-cache");
137
+ res.setHeader("Connection", "keep-alive");
138
+ } catch { /* ignore */ }
139
+ }
107
140
  if (fallback?.fallback) {
108
141
  try {
109
142
  res.write(`: mslxdff fallback ${fallback.requested_model} -> ${fallback.actual_model} (${fallback.reason})\n`);
@@ -113,10 +146,21 @@ export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort,
113
146
  if (upRes.body) {
114
147
  let first = true;
115
148
  let wroteAny = false;
149
+ let wrotePayload = false; // 真实数据帧(注释帧不算):决定能否安全 failover
116
150
  let timedOut = false;
117
151
  let stalled = false;
118
152
  let tooLong = false;
119
153
  let stallTimer = null;
154
+ // 自持 reader:超时/断下游时 reader.cancel() 才真的掐得断上游
155
+ // Note: 闸门真取消 + 注释帧不算首块 + wrotePayload 判据 — 见 .agents/notes/implemented/bug-fix/2026-09-17-relay-first-chunk-gate-real-cancel.md
156
+ const reader = typeof upRes.body.getReader === "function" ? upRes.body.getReader() : null;
157
+ let cancelled = false;
158
+ cancelUpstream = () => {
159
+ if (cancelled) return;
160
+ cancelled = true;
161
+ if (reader) { try { reader.cancel().catch(() => {}); } catch { /* ignore */ } }
162
+ else cancelBody(upRes.body);
163
+ };
120
164
  let pingTimer = keepaliveMs > 0
121
165
  ? setInterval(() => {
122
166
  if (wroteAny) return;
@@ -129,7 +173,7 @@ export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort,
129
173
  ? setTimeout(() => {
130
174
  stalled = true;
131
175
  detail.exitReason = "stall";
132
- cancelBody(upRes.body);
176
+ cancelUpstream();
133
177
  }, STALL_TIMEOUT_MS)
134
178
  : null;
135
179
  };
@@ -137,18 +181,18 @@ export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort,
137
181
  ? setTimeout(() => {
138
182
  timedOut = true;
139
183
  detail.exitReason = "first-timeout";
140
- cancelBody(upRes.body);
184
+ cancelUpstream();
141
185
  }, streamTimeoutMs)
142
186
  : null;
143
187
  const maxTimer = MAX_STREAM_MS
144
188
  ? setTimeout(() => {
145
189
  tooLong = true;
146
190
  detail.exitReason = "max";
147
- cancelBody(upRes.body);
191
+ cancelUpstream();
148
192
  }, MAX_STREAM_MS)
149
193
  : null;
150
194
  try {
151
- for await (const chunk of upRes.body) {
195
+ for await (const chunk of bodyChunks(upRes.body, reader)) {
152
196
  const now = performance.now();
153
197
  detail.receivedChunks += 1;
154
198
  const len = chunk?.length ?? chunk?.byteLength ?? 0;
@@ -159,8 +203,9 @@ export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort,
159
203
  if (gap > detail.maxGapMs) detail.maxGapMs = gap;
160
204
  if (gap > SCORE_STALL_MS) detail.stallHits += 1;
161
205
  prevChunkAt = now;
206
+ const txt = chunkText(chunk);
207
+ const isPayload = hasPayload(txt);
162
208
  try {
163
- const txt = chunkText(chunk);
164
209
  if (txt.includes("[DONE]")) detail.sawDone = true;
165
210
  const m = txt.match(/"finish_reason"\s*:\s*"([^"]+)"/);
166
211
  if (m) detail.sawFinishReason = m[1];
@@ -196,9 +241,10 @@ export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort,
196
241
  } catch {}
197
242
  }
198
243
  } catch { /* ignore */ }
199
- // 首块/空闲超时后上游仍吐出了数据 → 只是慢,不是死:撤销超时判定,照常转发
200
- //(cancel 是协作式的,缓冲数据仍会到达;丢掉已到达的数据是纯损失)
201
- if (timedOut || stalled) {
244
+ // 首块/空闲超时后上游仍吐出了真实数据 → 只是慢,不是死:撤销超时判定,照常转发
245
+ //(cancel 是异步的,竞态窗口内已到达的数据是纯收益;丢掉是纯损失)
246
+ // 注释帧(keepalive)不算:否则对端只要在发心跳,闸门就永远解除
247
+ if (isPayload && (timedOut || stalled)) {
202
248
  timedOut = false;
203
249
  stalled = false;
204
250
  detail.recoveries = (detail.recoveries || 0) + 1;
@@ -206,14 +252,14 @@ export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort,
206
252
  if (firstTimer) { clearTimeout(firstTimer); firstTimer = null; }
207
253
  }
208
254
  if (timedOut || stalled || tooLong) break;
209
- if (first) {
255
+ if (isPayload && first) {
210
256
  first = false;
211
257
  ttf = Math.round(now - t0);
212
258
  onFirstChunk?.(ttf);
213
259
  if (firstTimer) { clearTimeout(firstTimer); firstTimer = null; }
214
260
  }
215
261
  let outChunk = chunk;
216
- if (first === false && fallback?.fallback && wroteAny === false) {
262
+ if (first === false && fallback?.fallback && wrotePayload === false) {
217
263
  try {
218
264
  let txt = "";
219
265
  if (Buffer.isBuffer(chunk)) txt = chunk.toString("utf8");
@@ -226,16 +272,17 @@ export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort,
226
272
  } catch {}
227
273
  }
228
274
  wroteAny = true;
275
+ if (isPayload) wrotePayload = true;
229
276
  detail.wroteChunks += 1;
230
277
  detail.wroteBytes += Buffer.isBuffer(outChunk) ? outChunk.length : (outChunk?.length ?? len);
231
- res.write(outChunk);
278
+ try { res.write(outChunk); } catch { /* 下游已断开:onClose 已掐上游 */ }
232
279
  armStall();
233
280
  }
234
- if (!detail.exitReason) detail.exitReason = "normal";
281
+ if (!detail.exitReason) detail.exitReason = detail.downstreamClosed ? "downstream-closed" : "normal";
235
282
  } catch (err) {
236
283
  detail.upstreamError = String(err?.message || err).slice(0, 300);
237
284
  detail.exitReason = "upstream-error";
238
- if (!wroteAny) timedOut = true;
285
+ if (!wrotePayload) timedOut = true;
239
286
  else stalled = true;
240
287
  } finally {
241
288
  if (firstTimer) clearTimeout(firstTimer);
@@ -243,12 +290,13 @@ export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort,
243
290
  if (stallTimer) clearTimeout(stallTimer);
244
291
  if (pingTimer) clearInterval(pingTimer);
245
292
  }
246
- if (timedOut && !wroteAny) {
293
+ // 任一闸门到点且未写出真实数据 → 可安全 failover(注释帧对客户端无意义,不算已响应)
294
+ if ((timedOut || stalled || tooLong) && !wrotePayload) {
247
295
  res.removeListener("close", onClose);
248
296
  // Note: 超时是显式字段(timedOut),别再用 status 数值当信号 — 见 .agents/notes/implemented/architecture/2026-09-17-relay-timedout-explicit-and-metrics-seam.md
249
297
  return { status: 504, timedOut: true, ttfMs: null, totalMs: Math.round(performance.now() - t0), aborted: true, interrupted: false, detail };
250
298
  }
251
- if ((stalled || tooLong) && wroteAny) {
299
+ if ((stalled || tooLong) && wrotePayload) {
252
300
  interrupted = true;
253
301
  detail.exitReason = detail.exitReason || (stalled ? "stall" : "max");
254
302
  res.removeListener("close", onClose);
@@ -5,6 +5,7 @@
5
5
  import { createUpstreamClient, createOpencodeHeaderBuilder } from "../upstream.js";
6
6
  import { isResponsesModel } from "../upstream-responses.js";
7
7
  import { isFreeModel } from "../models.js";
8
+ import { ensureFreeLaneShape } from "../free-lane.js";
8
9
  import { createSdkChat } from "./sdk/chat.js";
9
10
  import { createSdkResponses } from "./sdk/responses.js";
10
11
  import { dispatcherFetch } from "./sdk/attempt.js";
@@ -32,9 +33,11 @@ export function createUpstreamEngine(opts = {}) {
32
33
  let logged = false;
33
34
 
34
35
  async function chat(body) {
35
- if (sdkDown) return legacy.chat(body);
36
- // doGenerate 聚合未实现:非流式(含 responses 非流式)委派 legacy,避免把 JSON 客户端 SSE 化
37
- if (body?.stream === false) return legacy.chat(body);
36
+ // zen 免费层 agent 形状门禁(2026-09-18):SDK 流式通道不经 legacy.chat,必须在这里补形状。
37
+ // 非流式先委派 legacy(它在自己内部注入并把 SSE 聚合回 JSON,避免这里先改 stream 导致误判)。
38
+ const wantsStream = body?.stream !== false;
39
+ if (sdkDown || !wantsStream) return legacy.chat(body);
40
+ if (isFreeModel(body?.model)) ensureFreeLaneShape(body);
38
41
  const useResponses = isResponsesModel(body?.model);
39
42
  try {
40
43
  const res = useResponses ? await responses.chat(body) : await sdk.chat(body);
package/src/upstream.js CHANGED
@@ -7,6 +7,7 @@ import { isFreeModel } from "./models.js";
7
7
  import { fmtShanghaiYMDHMS } from "./time.js";
8
8
  import { createTransport } from "./transport/index.js";
9
9
  import { isResponsesModel, chatToResponsesBody, toChatResponse, reshapeResponsesSse } from "./upstream-responses.js";
10
+ import { ensureFreeLaneShape, aggregateChatSse } from "./free-lane.js";
10
11
  import { digestIdTail, genId, opencodeClientIdentity, opencodeUa } from "./opencode-identity.js";
11
12
 
12
13
  export { opencodeClientIdentity, opencodeUa };
@@ -136,6 +137,27 @@ export function createUpstreamClient({
136
137
  const sessionId = opts?.sessionId || sessionFromMessages(body?.messages) || null;
137
138
  const url = isResp ? `${baseUrl}/zen/v1/responses` : `${baseUrl}/zen/v1/chat/completions`;
138
139
  const reqBody = isResp ? chatToResponsesBody(body) : body;
140
+ const clientWantsStream = body?.stream !== false;
141
+ // zen 免费层 agent 形状门禁(2026-09-18):必须流式 + 含核心五工具名,否则 403 FreeTierError
142
+ const freeLane = authToken === "public" && isFreeModel(body?.model);
143
+ if (freeLane) ensureFreeLaneShape(reqBody, { responses: isResp });
144
+ const laneDebug = freeLane && envInt("MSLXDFF_FREE_LANE_DEBUG", 0) === 1;
145
+ if (laneDebug) {
146
+ try { console.error(`[free-lane] send model=${body?.model} url=${url} stream=${reqBody.stream} tools=${(reqBody.tools || []).length} ua=${opencodeUa()} want=${clientWantsStream}`); } catch {}
147
+ }
148
+ // 非流式调用者实际拿到的是被强制流式的上游 → 聚合回 chat completion JSON
149
+ async function agentJson(resp) {
150
+ if (!(freeLane && !clientWantsStream && resp.ok)) return resp;
151
+ const ct = resp.headers?.get?.("content-type") || "";
152
+ if (!ct.includes("text/event-stream")) return resp;
153
+ try {
154
+ const out = await aggregateChatSse(resp);
155
+ out._t = resp._t;
156
+ return out;
157
+ } catch {
158
+ return resp;
159
+ }
160
+ }
139
161
  const t0 = performance.now();
140
162
 
141
163
  // 首发请求(transport 已处理 network/429 等重试)
@@ -146,7 +168,7 @@ export function createUpstreamClient({
146
168
  method: "POST",
147
169
  headers: buildHeaders(reqBody, { sessionId }),
148
170
  body: reqBody,
149
- stream: body?.stream !== false,
171
+ stream: reqBody.stream !== false,
150
172
  timeoutMs: connectTimeoutMs,
151
173
  retry,
152
174
  });
@@ -154,6 +176,9 @@ export function createUpstreamClient({
154
176
  e._t = e._t || { attempts: [], waitMs: 0, totalMs: Math.round(performance.now() - t0) };
155
177
  throw e;
156
178
  }
179
+ if (laneDebug) {
180
+ try { console.error(`[free-lane] resp model=${body?.model} status=${res.status} ct=${res.headers?.get?.("content-type") || ""}`); } catch {}
181
+ }
157
182
 
158
183
  // 匿名兜底:仅非 anonFirst 时,public 429 + free 模型才走 hermes 空头重试
159
184
  if (!anonFirst && res.status === 429 && isFreeModel(body?.model) && shouldTryAnonFree()) {
@@ -168,7 +193,7 @@ export function createUpstreamClient({
168
193
  method: "POST",
169
194
  headers: buildHeaders(reqBody, { anonymous: true, sessionId }),
170
195
  body: reqBody,
171
- stream: body?.stream !== false,
196
+ stream: reqBody.stream !== false,
172
197
  timeoutMs: connectTimeoutMs,
173
198
  retry,
174
199
  });
@@ -180,7 +205,7 @@ export function createUpstreamClient({
180
205
  let outAnon = anonRes;
181
206
  if (isResp && anonRes.ok) {
182
207
  const ctAnon = anonRes.headers.get("content-type") || "";
183
- const isStreamAnon = body?.stream !== false && ctAnon.includes("text/event-stream");
208
+ const isStreamAnon = (clientWantsStream || freeLane) && ctAnon.includes("text/event-stream");
184
209
  if (isStreamAnon) {
185
210
  outAnon = reshapeResponsesSse(anonRes, body.model);
186
211
  } else {
@@ -201,7 +226,7 @@ export function createUpstreamClient({
201
226
  if (consecutiveHits >= 2) {
202
227
  try { appendFile(freeAnonLogFile(), ` -> 连续额外额度 ${consecutiveHits} 次\n`).catch(() => {}); } catch {}
203
228
  }
204
- return outAnon;
229
+ return await agentJson(outAnon);
205
230
  }
206
231
  }
207
232
  if (anonRes) {
@@ -215,11 +240,11 @@ export function createUpstreamClient({
215
240
  // responses 模型成功态转 chat(复用 upstream-responses)
216
241
  if (isResp && res.ok) {
217
242
  const ct = res.headers.get("content-type") || "";
218
- const isStream = body?.stream !== false && ct.includes("text/event-stream");
243
+ const isStream = (clientWantsStream || freeLane) && ct.includes("text/event-stream");
219
244
  if (isStream) {
220
245
  const transformed = reshapeResponsesSse(res, body.model);
221
246
  transformed._t = { ...(res._t || {}), totalMs: Math.round(performance.now() - t0) };
222
- return transformed;
247
+ return await agentJson(transformed);
223
248
  }
224
249
  try {
225
250
  const txt = await res.text();
@@ -238,7 +263,7 @@ export function createUpstreamClient({
238
263
  }
239
264
  }
240
265
  res._t = { ...(res._t || {}), totalMs: res._t?.totalMs ?? Math.round(performance.now() - t0) };
241
- return res;
266
+ return await agentJson(res);
242
267
  }
243
268
 
244
269
  async function preheat() {