mslxdff 0.1.159 → 0.1.161

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.
Files changed (59) hide show
  1. package/docs/cli_help_mini.md +3 -1
  2. package/package.json +1 -1
  3. package/src/autostart.js +3 -0
  4. package/src/chat-pipeline/auto-race.js +1 -1
  5. package/src/chat-pipeline/empty-turn.js +119 -0
  6. package/src/chat-pipeline/index.js +4 -1
  7. package/src/chat-pipeline/serial-trial.js +244 -210
  8. package/src/cli/commands/daemon.js +4 -4
  9. package/src/cli/commands/model/list-providers.js +1 -1
  10. package/src/cli/commands/provider/globalqwenwork-login.js +126 -0
  11. package/src/cli/commands/provider/index.js +2 -0
  12. package/src/cli/commands/provider/models.js +4 -1
  13. package/src/cli/commands/stats.js +20 -5
  14. package/src/cli/commands/system.js +2 -2
  15. package/src/cli/format.js +6 -0
  16. package/src/cli/policy.js +2 -2
  17. package/src/cli/provider-row.js +2 -2
  18. package/src/daemon.js +7 -2
  19. package/src/logs.js +14 -1
  20. package/src/model-trace.js +5 -2
  21. package/src/providers/AGENTS.md +46 -0
  22. package/src/providers/classify.js +1 -1
  23. package/src/providers/globalqwenwork/account-store.js +141 -0
  24. package/src/providers/globalqwenwork/constants.js +73 -0
  25. package/src/providers/globalqwenwork/cosy.js +123 -0
  26. package/src/providers/globalqwenwork/crypto.js +220 -0
  27. package/src/providers/globalqwenwork/http.js +22 -0
  28. package/src/providers/globalqwenwork/index.js +331 -0
  29. package/src/providers/globalqwenwork/payload.js +145 -0
  30. package/src/providers/globalqwenwork/rsa.js +56 -0
  31. package/src/providers/globalqwenwork/sse.js +270 -0
  32. package/src/providers/globalqwenwork/stream.js +132 -0
  33. package/src/providers/globalqwenwork/upstream.js +122 -0
  34. package/src/providers/globalqwenwork.js +1 -0
  35. package/src/providers/registry.js +8 -0
  36. package/src/providers/zcode/chat.js +11 -10
  37. package/src/providers/zcode/const.js +1 -1
  38. package/src/providers/zcode/context-shape.js +162 -0
  39. package/src/providers/zcode/headers.js +38 -0
  40. package/src/providers/zcode/sse.js +19 -2
  41. package/src/routes/AGENTS.md +37 -0
  42. package/src/routes/chat/broadband-handler.js +3 -2
  43. package/src/routes/chat/exhausted-handler.js +23 -8
  44. package/src/routes/chat/hedge-handler.js +3 -0
  45. package/src/routes/chat/local-handler.js +5 -1
  46. package/src/routes/chat/peer-handler.js +2 -0
  47. package/src/routes/chat/relay-pipeline.js +40 -14
  48. package/src/routes/chat/via-route-handler.js +2 -0
  49. package/src/routes/helpers.js +20 -4
  50. package/src/routes/stream-hold.js +138 -0
  51. package/src/routes/stream-scan.js +278 -0
  52. package/src/routes/stream.js +106 -158
  53. package/src/runtime/lifecycle-forensics.js +116 -0
  54. package/src/runtime/lifecycle-log.js +23 -0
  55. package/src/runtime/provider-gate.js +4 -3
  56. package/src/talk-log.js +226 -0
  57. package/src/timeline.js +5 -2
  58. package/src/usage/record.js +8 -1
  59. package/src/usage/report.js +39 -2
@@ -18,10 +18,26 @@ export function authorized(req, token) {
18
18
  }
19
19
 
20
20
  export function json(res, status, body) {
21
- res.statusCode = status;
22
- // 已发 headers(如 failover 时前一个候选写过 SSE 注释帧)不可再设,否则抛 ERR_HTTP_HEADERS_SENT
23
- try { if (!res.headersSent) res.setHeader("Content-Type", "application/json"); } catch { /* ignore */ }
24
- res.end(JSON.stringify(body));
21
+ // 幂等守卫:已收场的响应必须 no-op(空轮 hold 后多处共用收场出口,二次 end 会抛)
22
+ if (!res || res.writableEnded) return;
23
+ if (!res.headersSent) {
24
+ res.statusCode = status;
25
+ try { res.setHeader("Content-Type", "application/json"); } catch { /* ignore */ }
26
+ try { res.end(JSON.stringify(body)); } catch { /* ignore */ }
27
+ return;
28
+ }
29
+ // headers 已 flush(failover 前候选写过 SSE 注释帧、或空轮 hold 期间发过 keepalive):
30
+ // 设 statusCode 会抛 ERR_HTTP_HEADERS_SENT,写 JSON 体更会把 application/json 混进 text/event-stream
31
+ // (客户端只会当噪声丢掉,等于悄悄吞掉错误)。SSE 一律改用 in-band 错误帧 + [DONE] 收场。
32
+ const ct = typeof res.getHeader === "function" ? String(res.getHeader("content-type") || "") : "";
33
+ if (ct.includes("text/event-stream")) {
34
+ try {
35
+ const msg = body && typeof body === "object" ? String(body.error ?? body.message ?? JSON.stringify(body)) : String(body ?? "error");
36
+ res.write(`data: ${JSON.stringify({ error: { message: msg, type: "mslxdff_error" } })}\n\n`);
37
+ res.write("data: [DONE]\n\n");
38
+ } catch { /* 下游已断 */ }
39
+ }
40
+ try { res.end(); } catch { /* ignore */ }
25
41
  }
26
42
 
27
43
  export function notFound(res) {
@@ -0,0 +1,138 @@
1
+ // 空轮的可挽救性策略(relay 的三个问题在这里回答,stream.js 只负责转发循环):
2
+ // ① 哪些帧还能撤销?—— 一次尝试在「出现任何模型产出」之前写下去的只有空 delta 帧与 [DONE],
3
+ // 这些字节一旦入下游就撤不回(重拉的正文会接在一个已完成的流后面),所以一律暂扣;
4
+ // 攒爆上限(异常形状)才被迫提交,行为退回改前。
5
+ // ② 撤销窗口留不留?—— 流结束仍零产出且下游还活着 → 不 res.end(),把这次尝试交回同模型重拉 /
6
+ // 换候选,让后面那发的正文还能顺着同一条连接送出去。
7
+ // ③ 全部救不回来怎么收场?—— 恰好一次、形状与已发出的字节相容(见 endHeldFailure / endEmptyTurnStream)。
8
+ // 另有一条次序铁律:`[DONE]` 是流的终点,客户端解析器一见它就收尾 —— 所以**任何要补在它前面的错误帧
9
+ // 都必须先把 [DONE] 暂扣住**(tail 槽),否则错误永不可见、还会留下两个终止符。
10
+ // 拆出原因:这些策略与 relay 本体同处一文件会破仓库 20KB 硬门,且语义本就独立(不碰上游读取与闸门计时)。
11
+ import { json } from "./helpers.js";
12
+
13
+ // 暂扣上限:正常空轮的前缀就是几帧空 delta + 一个 [DONE];超过即说明不是这个形状,别为它囤内存。
14
+ export const HOLD_MAX_FRAMES = 64;
15
+ export const HOLD_MAX_BYTES = 32 * 1024;
16
+
17
+ // 空轮「延后封口 + 前缀暂扣」总开关(默认开)。0/off/false = 关:连缓冲都不做,流一结束立即 end(),
18
+ // 逐字节复现改前行为(代价:之后的空轮重拉/换候选写的是一次已终结的响应,只省额度、救不回正文)。
19
+ export function holdEndEnabled() {
20
+ const s = String(process.env.MSLXDFF_EMPTY_TURN_HOLD_END ?? "").trim().toLowerCase();
21
+ return !(s === "0" || s === "off" || s === "false");
22
+ }
23
+
24
+ /**
25
+ * 可撤销前缀缓冲。帧分三类:
26
+ * - 注释帧(非数据帧):直写,不影响可撤销性(客户端视作噪声);
27
+ * - 可撤销前缀(空 delta / finish-only 帧):暂扣,出现模型产出时按序补写;
28
+ * - `[DONE]`:即使已提交也暂扣在 tail 槽,直到确认没有错误帧要补在它前面。
29
+ * 记账(detail.wroteChunks/wroteBytes)由本模块负责,调用方不必自己数字节。
30
+ */
31
+ export function createRevocablePrefix(res, detail, { maxFrames = HOLD_MAX_FRAMES, maxBytes = HOLD_MAX_BYTES } = {}) {
32
+ let pending = [];
33
+ let pendingBytes = 0;
34
+ let tail = null; // [DONE] 槽:必须最后写,且要让位给错误帧
35
+ let committed = false;
36
+ let draining = false;
37
+ const writeNow = (buf, size) => {
38
+ if (!draining && tail) flushTail(); // 保持帧序:先补已暂扣的 [DONE],再写新到的
39
+ detail.wroteChunks += 1;
40
+ detail.wroteBytes += size;
41
+ try { res.write(buf); } catch { /* 下游已断开:onClose 已掐上游 */ }
42
+ };
43
+ const drainPending = () => {
44
+ draining = true;
45
+ try {
46
+ for (const p of pending) writeNow(p.buf, p.size);
47
+ } finally {
48
+ draining = false;
49
+ }
50
+ pending = [];
51
+ pendingBytes = 0;
52
+ };
53
+ const flushTail = () => {
54
+ if (!tail) return false;
55
+ const t = tail;
56
+ tail = null;
57
+ detail.wroteChunks += 1;
58
+ detail.wroteBytes += t.size;
59
+ try { res.write(t.buf); } catch { /* 下游已断开 */ }
60
+ return true; // 告诉收场方:[DONE] 已经由这一帧发出去了,别再补第二个
61
+ };
62
+ return {
63
+ get committed() { return committed; },
64
+ get heldFrames() { return pending.length + (tail ? 1 : 0); },
65
+ /**
66
+ * 暂扣一帧。isDone=true 的帧进 tail 槽(无论是否已提交);否则进 pending(未提交时可整段撤销)。
67
+ * 返回 false = pending 超上限放不下,调用方必须改为提交,别默默丢字节。
68
+ */
69
+ hold(buf, size, isDone = false) {
70
+ if (isDone) {
71
+ if (tail) flushTail();
72
+ tail = { buf, size };
73
+ return true;
74
+ }
75
+ if (committed) { writeNow(buf, size); return true; }
76
+ if (pending.length >= maxFrames || pendingBytes + size > maxBytes) return false;
77
+ pending.push({ buf, size });
78
+ pendingBytes += size;
79
+ return true;
80
+ },
81
+ /** 提交:按序补写 pending(不含 tail),再写本帧,此后一律直写。 */
82
+ submit(buf, size) {
83
+ committed = true;
84
+ drainPending();
85
+ writeNow(buf, size);
86
+ },
87
+ /** 收尾前补写 pending;tail 留给 flushTail 决定时机(错误帧要先站上去)。 */
88
+ commit() {
89
+ committed = true;
90
+ drainPending();
91
+ },
92
+ flushTail,
93
+ writeNow,
94
+ };
95
+ }
96
+
97
+ /**
98
+ * 终局收场:把响应「恰好一次」关掉,形状必须与已经发出去的东西相容。
99
+ * - headers 未 flush → 502 JSON(干净的 OpenAI 形错误);
100
+ * - headers 已 flush(hold 期间发过 keepalive 注释帧,状态码锁死 200 text/event-stream)
101
+ * → SSE 错误帧 + [DONE]:绝不把 JSON 体混进 event-stream,也绝不抛 ERR_HTTP_HEADERS_SENT。
102
+ * 错误文案带 EMPTY_MODEL_RESPONSE 前缀(与 errors.log / isEmptyTurnError 判据同源,可 grep 对账)。
103
+ */
104
+ export function endHeldFailure(res, message, flushTail) {
105
+ if (!res || res.writableEnded) return false;
106
+ const msg = String(message || "EMPTY_MODEL_RESPONSE: empty turn, no recovery");
107
+ if (!res.headersSent) {
108
+ // headers 都没发出去 ⇒ 这一发的暂扣帧全部作废(它们属于失败的那一发,客户端从没收到)
109
+ json(res, 502, { error: msg });
110
+ return true;
111
+ }
112
+ return closeSseWithError(res, msg, flushTail);
113
+ }
114
+
115
+ /**
116
+ * 已经提交过内容、但正文为零的流(现网主流:思考刷满 max_tokens):错误帧必须排在
117
+ * 上游的 `[DONE]` **之前**,否则客户端一见 [DONE] 就收尾,原因永不可见。
118
+ * flushTail = 把暂扣的 [DONE] 补写出去(由调用方在错误帧之后、end() 之前触发)。
119
+ */
120
+ export function endEmptyTurnStream(res, message, flushTail) {
121
+ if (!res || res.writableEnded) return false;
122
+ return closeSseWithError(res, String(message || "EMPTY_MODEL_RESPONSE: empty turn"), flushTail);
123
+ }
124
+
125
+ /**
126
+ * SSE 收场:错误帧 → 上游原 [DONE](若被暂扣过)→ end。
127
+ * 铁律:终止符只能有一个,且必须排在错误帧之后 —— 否则客户端在错误帧到达前就已收尾。
128
+ */
129
+ function closeSseWithError(res, msg, flushTail) {
130
+ try {
131
+ res.write(`data: ${JSON.stringify({ error: { message: msg, type: "mslxdff_empty_turn" } })}\n\n`);
132
+ } catch { /* 下游已断:收不到是它的权利 */ }
133
+ let emittedDone = false;
134
+ try { emittedDone = typeof flushTail === "function" ? !!flushTail() : false; } catch { /* ignore */ }
135
+ if (!emittedDone) { try { res.write("data: [DONE]\n\n"); } catch { /* ignore */ } }
136
+ try { res.end(); } catch { /* ignore */ }
137
+ return true;
138
+ }
@@ -0,0 +1,278 @@
1
+ // relay 的观测累加器:把流式 SSE chunk / 非流式 JSON 体读成 detail 上的计数事实。
2
+ // 抽自 src/routes/stream.js(单文件 20KB 硬门,见 AGENTS.md「先拆后写」),行为与抽出前逐字一致。
3
+ // 纯函数契约:只读写传入的 detail,不碰 res / 定时器 / 闸门 / 网络,因此可离线单测。
4
+ // 语义钉子:detail.chars 只算正文,思考内容另计(reasoningChars)——两者相加会重复计数。
5
+ import { extractUsageFromJson } from "../metrics.js";
6
+
7
+ // 环形对话日志(talk log)的正文捕获:只有 relay 建了 detail.talk 才累积,未开启时零开销。
8
+ // 口径与 chars/reasoningChars 同源(读 delta,不读聚合帧),区别是这里存原文而不是只计数。
9
+ // 同样的边界:SSE 帧被 chunk 切断时该帧内容丢失(JSON.parse 失败即跳过)—— 研究用途可接受。
10
+ const TALK_CAP_CHARS = 400_000;
11
+
12
+ function talkPush(talk, dst, v) {
13
+ if (typeof v !== "string" || !v) return;
14
+ if (talk.n >= TALK_CAP_CHARS) { talk.capped = true; return; }
15
+ talk.n += v.length;
16
+ talk[dst].push(v);
17
+ }
18
+
19
+ // 流式 tool_calls 是分片到达的(index 定位、arguments 逐段拼),按 index 合回完整调用才看得懂。
20
+ function talkMergeToolCalls(talk, tcs) {
21
+ for (const tc of tcs) {
22
+ const i = Number.isInteger(tc?.index) ? tc.index : talk.tools.length;
23
+ let slot = talk.tools[i];
24
+ if (!slot) { slot = { id: "", type: "function", function: { name: "", arguments: "" } }; talk.tools[i] = slot; }
25
+ if (tc.id) slot.id = tc.id;
26
+ const nm = tc.function?.name;
27
+ const ar = tc.function?.arguments;
28
+ if (typeof nm === "string" && nm && !slot.function.name.includes(nm)) slot.function.name += nm;
29
+ if (typeof ar === "string" && ar) slot.function.arguments += ar;
30
+ }
31
+ }
32
+
33
+ /** 新建累积桶(n/capped 防超长生成把内存吃穿)。 */
34
+ export function createTalkBucket() { return { reasoning: [], content: [], tools: [], n: 0, capped: false }; }
35
+
36
+ /** 逐 chunk 累积一轮对话的思考/正文/工具调用。talk 为空 = 未开启,直接返回。 */
37
+ export function captureTalkSse(talk, txt) {
38
+ if (!talk) return;
39
+ for (const raw of String(txt ?? "").split("\n")) {
40
+ const t = raw.trim();
41
+ if (!t.startsWith("data:")) continue;
42
+ const d = t.slice(5).trim();
43
+ if (!d || d === "[DONE]") continue;
44
+ let j = null;
45
+ try { j = JSON.parse(d); } catch { continue; }
46
+ const c0 = Array.isArray(j?.choices) ? j.choices[0] : null;
47
+ if (!c0) continue;
48
+ for (const src of [c0.delta, c0.message]) {
49
+ if (!src) continue;
50
+ talkPush(talk, "reasoning", src.reasoning_content ?? src.reasoning);
51
+ talkPush(talk, "content", src.content);
52
+ if (Array.isArray(src.tool_calls)) talkMergeToolCalls(talk, src.tool_calls);
53
+ }
54
+ }
55
+ }
56
+
57
+ /** 非流式:整块 message 直接入桶(tool_calls 参数本来就是完整的)。 */
58
+ export function captureTalkMessage(talk, msg) {
59
+ if (!talk || !msg) return;
60
+ talkPush(talk, "reasoning", msg.reasoning_content ?? msg.reasoning);
61
+ talkPush(talk, "content", msg.content);
62
+ if (Array.isArray(msg.tool_calls)) talkMergeToolCalls(talk, msg.tool_calls);
63
+ }
64
+
65
+ /** 透传兜底:无 chat 帧可解析(纯文本/未知形状)时把原文当正文收下,别让对话日志留空洞。 */
66
+ export function captureTalkFallback(talk, text) {
67
+ if (!talk || !text) return;
68
+ if (talk.reasoning.length || talk.content.length || talk.tools.length) return;
69
+ if (String(text).includes('"choices"')) return;
70
+ talkPush(talk, "content", String(text));
71
+ }
72
+
73
+ // 逐 SSE chunk 扫描:done/finish_reason/chat 形状/工具调用/usage/正文字符
74
+ export function scanSseChunk(detail, txt) {
75
+ if (detail.talk) captureTalkSse(detail.talk, txt);
76
+ try {
77
+ if (txt.includes("[DONE]")) detail.sawDone = true;
78
+ const m = txt.match(/"finish_reason"\s*:\s*"([^"]+)"/);
79
+ if (m) detail.sawFinishReason = m[1];
80
+ // chat 形状证据:只有看得出是 chat 轮才配判空(非 chat SSE/JSON 透传是正式契约,不得误伤)
81
+ if (!detail.chatShaped && (txt.includes('"choices"') || txt.includes('"delta"') || txt.includes('"finish_reason"') || txt.includes('"usage"') || txt.includes('"prompt_tokens"') || txt.includes("[DONE]"))) detail.chatShaped = true;
82
+ // 工具调用计数(空数组不算):tool_calls 无正文是合法 agent 轮,
83
+ // 空转闸门必须豁免它,否则所有工具轮都会被误判为空轮——见 relay-pipeline 4b。
84
+ const tc = txt.match(/"tool_calls"\s*:\s*\[\s*\{/g);
85
+ if (tc) detail.toolCalls = (detail.toolCalls || 0) + tc.length;
86
+ // 尝试提取 usage(流式末帧):口径收口到 metrics.js,与未流式分支共用
87
+ if (txt.includes("\"usage\"") || txt.includes("\"prompt_tokens\"")) {
88
+ try {
89
+ const lines = txt.split("\n");
90
+ for (const line of lines) {
91
+ const t = line.trim();
92
+ if (!t.startsWith("data:")) continue;
93
+ const d = t.slice(5).trim();
94
+ if (d === "[DONE]" || !d) continue;
95
+ // 行级隔离:单行坏 JSON 不拖累同 chunk 其余行
96
+ try {
97
+ const j = JSON.parse(d);
98
+ if (!j || typeof j !== "object") continue;
99
+ const u = extractUsageFromJson(j);
100
+ if (u) detail.usage = u;
101
+ // 兜底 chars:从 choices 文本长度累加
102
+ const delta = j.choices?.[0]?.delta?.content || j.choices?.[0]?.message?.content || "";
103
+ if (delta) detail.chars += String(delta).length;
104
+ const rc = j.choices?.[0]?.delta?.reasoning_content || j.choices?.[0]?.delta?.reasoning
105
+ || j.choices?.[0]?.message?.reasoning_content || j.choices?.[0]?.message?.reasoning || "";
106
+ if (rc) detail.reasoningChars = (detail.reasoningChars || 0) + String(rc).length;
107
+ } catch { /* 单行坏帧忽略 */ }
108
+ }
109
+ } catch {}
110
+ } else {
111
+ // 非 usage 的普通 delta 也累 chars
112
+ try {
113
+ const ms = txt.match(/"content"\s*:\s*"([^"]*)"/g);
114
+ if (ms) for (const mm of ms) {
115
+ const c = JSON.parse(`{${mm}}`);
116
+ if (c.content) detail.chars += String(c.content).length;
117
+ }
118
+ // 同一 chunk 里的思考增量:键名两种上游都见过(reasoning_content / reasoning)
119
+ const rs = txt.match(/"reasoning(?:_content)?"\s*:\s*"([^"]*)"/g);
120
+ if (rs) for (const mm of rs) {
121
+ const c = JSON.parse(`{${mm}}`);
122
+ const v = c.reasoning_content ?? c.reasoning;
123
+ if (v) detail.reasoningChars = (detail.reasoningChars || 0) + String(v).length;
124
+ }
125
+ } catch {}
126
+ }
127
+ } catch { /* ignore */ }
128
+ }
129
+
130
+ // 非流式聚合 JSON 体:同源口径提 usage/chars/chat 形状/工具调用
131
+ export function scanNonStreamBody(detail, parsed) {
132
+ const u = extractUsageFromJson(parsed);
133
+ if (u) detail.usage = u;
134
+ if (parsed.choices?.[0]?.message?.content) detail.chars = String(parsed.choices[0].message.content).length;
135
+ else if (parsed.choices?.[0]?.text) detail.chars = String(parsed.choices[0].text).length;
136
+ // 思考内容另记(qoder 的 stream:false 出口把思考放在 message.reasoning_content)
137
+ const rc = parsed.choices?.[0]?.message?.reasoning_content || parsed.choices?.[0]?.message?.reasoning || "";
138
+ if (rc) detail.reasoningChars = (detail.reasoningChars || 0) + String(rc).length;
139
+ // 非流式同样只判 chat 形状:无 choices 的任意 JSON 是透传契约(chat-route 单测锁死),不得判空
140
+ if (Array.isArray(parsed?.choices)) detail.chatShaped = true;
141
+ const _tcList = parsed.choices?.[0]?.message?.tool_calls;
142
+ if (Array.isArray(_tcList) && _tcList.length) detail.toolCalls = _tcList.length;
143
+ if (detail.talk) captureTalkMessage(detail.talk, parsed.choices?.[0]?.message || (parsed.choices?.[0]?.text ? { content: parsed.choices[0].text } : null));
144
+ }
145
+
146
+ // 上报锚点偏移:转发入口时刻 − 本次上游尝试起点(performance.now 同源)。
147
+ // 只服务上报时长(usage 行),四类保护闸门仍用 relay 自己的 t0,绝不引用它。
148
+ export function preflightMs(t0, attemptStartMs) {
149
+ // 锚点必须是与 t0 同源(performance.now)的有限 number;其余一律按「无锚点」降级为 0,
150
+ // 宁可退回修复前口径,也不能产出 NaN 或假高值
151
+ if (typeof attemptStartMs !== "number" || !Number.isFinite(attemptStartMs)) return 0;
152
+ const delta = Math.round(t0 - attemptStartMs);
153
+ return delta > 0 ? delta : 0;
154
+ }
155
+
156
+ /**
157
+ * 空轮判据(纯函数,唯一真相):看得 chat 形状 + 零正文 + 零工具调用 + 收尾不是工具轮。
158
+ * stream.js 用它决定「这次要不要封口」,relay-pipeline 用它决定「交不交回重试」——两处不得各写一份。
159
+ * chatShaped 是前置证据:非 chat SSE / 无 choices 的透传是正式契约,一律放行不判空。
160
+ */
161
+ export function isEmptyTurnDetail(d) {
162
+ if (!d || d.chatShaped !== true) return false;
163
+ if ((Number(d.chars) || 0) > 0 || (Number(d.toolCalls) || 0) > 0) return false;
164
+ if (["tool_calls", "function_call"].includes(d.sawFinishReason)) return false;
165
+ return true;
166
+ }
167
+
168
+ // ===== 从 stream.js 迁入(体积门:stream.js 承载 relay 本体,纯判定函数一律住这里)=====
169
+
170
+ // 错误包络 chunk 判定(纯函数):data 行 JSON 含顶层 .error 对象、或只有 finish_reason=error
171
+ // 的空帧,且整 chunk 无任何 content/tool_calls/reasoning 输出 → 返回错误摘要(建议暂扣),
172
+ // 否则返回 null(透传)。解析失败一律透传(默认保安全)。
173
+ // 背景:网关把上游失败包成 HTTP 200 SSE;暂扣后下游流式 UI 不再展示瞬时错误
174
+ //(如 Vertex 503 + google fallback 400),本轮走空转重试,客户端只看到最终结果。
175
+ // Note: 流内错误包络捕获与 retryAfterSeconds 错误直出 — 见 .agents/notes/implemented/bug-fix/2026-09-28-retry-after-surfacing-error.md
176
+ export function extractRetryAfterSec(obj) {
177
+ if (!obj) return null;
178
+ if (typeof obj === "number") return obj;
179
+ if (typeof obj === "string") {
180
+ const m = obj.match(/"retryAfterSeconds"\s*:\s*(\d+)/i) || obj.match(/\bretryAfterSeconds\s*[:=]\s*(\d+)/i);
181
+ if (m) return Number(m[1]);
182
+ try {
183
+ const parsed = JSON.parse(obj);
184
+ return extractRetryAfterSec(parsed);
185
+ } catch {}
186
+ }
187
+ if (typeof obj === "object") {
188
+ if (Number.isFinite(obj.retryAfterSeconds)) return Number(obj.retryAfterSeconds);
189
+ if (obj.message) {
190
+ const r = extractRetryAfterSec(obj.message);
191
+ if (r != null) return r;
192
+ }
193
+ }
194
+ return null;
195
+ }
196
+
197
+ export function holdableChunk(txt) {
198
+ if (typeof txt !== "string" || !txt.includes("data:")) return null;
199
+ let sawData = false;
200
+ let sawEventError = false;
201
+ let retrySec = null;
202
+ const errs = [];
203
+ for (const rawLine of txt.split("\n")) {
204
+ const t = rawLine.trim();
205
+ if (t.startsWith("event:") && t.slice(6).trim() === "error") {
206
+ sawEventError = true;
207
+ continue;
208
+ }
209
+ if (!t.startsWith("data:")) continue;
210
+ const d = t.slice(5).trim();
211
+ if (!d || d === "[DONE]") continue;
212
+ let j = null;
213
+ try { j = JSON.parse(d); } catch { return null; }
214
+ if (!j || typeof j !== "object") return null;
215
+ sawData = true;
216
+ const c0 = (Array.isArray(j.choices) && j.choices[0]) || {};
217
+ const delta = c0.delta || {};
218
+ const msg = c0.message || {};
219
+ const out = delta.content || msg.content || delta.tool_calls || msg.tool_calls || delta.reasoning || msg.reasoning;
220
+ if (out && !(Array.isArray(out) && out.length === 0)) return null;
221
+
222
+ const r = extractRetryAfterSec(j);
223
+ if (r != null) {
224
+ retrySec = r;
225
+ continue;
226
+ }
227
+ if (j.error && typeof j.error === "object") {
228
+ const m = j.error.message || j.error.code || "";
229
+ if (m) errs.push(String(m).slice(0, 300));
230
+ } else if (sawEventError && j.message) {
231
+ errs.push(String(j.message).slice(0, 300));
232
+ } else if (c0.finish_reason === "error") {
233
+ errs.push("finish_reason=error 空帧");
234
+ }
235
+ }
236
+ if (!sawData) return null;
237
+ if (retrySec != null) return `上游供应商触发 retryAfterSeconds: ${retrySec} ,请等候重试`;
238
+ if (errs.length) return errs.join(" | ");
239
+ if (sawEventError) return "event: error 空帧";
240
+ return null;
241
+ }
242
+
243
+ /**
244
+ * 「可撤销前缀」判据(纯函数):这个 chunk 里除空 delta 帧与 [DONE] 外不含任何信息吗?
245
+ * 只有答案为是才允许暂扣不写——透传是正式契约:解析失败的帧、看不懂的形状一律立即透传(与
246
+ * holdableChunk 同一口径:默认保安全,绝不因为想救空轮而扣掉本该原样转给客户端的字节)。
247
+ */
248
+ export function isTrivialFrame(txt) {
249
+ if (typeof txt !== "string" || !txt.includes("data:")) return false;
250
+ let sawData = false;
251
+ for (const rawLine of txt.split("\n")) {
252
+ const t = rawLine.trim();
253
+ if (!t.startsWith("data:")) continue;
254
+ const d = t.slice(5).trim();
255
+ if (!d) continue;
256
+ if (d === "[DONE]") { sawData = true; continue; } // 结束符不含模型输出,可撤销
257
+ let j = null;
258
+ try { j = JSON.parse(d); } catch { return false; } // 解析失败 → 透传,绝不撤销
259
+ if (!j || typeof j !== "object" || Array.isArray(j)) return false;
260
+ // 白名单式可撤销:只认「choices 数组、且其中没有任何文本载荷」的 chat 帧。
261
+ // 其余形状(choices[].text、delta.text、顶层 usage、无 choices 的自定义帧……)一律算有信息 → 透传。
262
+ if (!Array.isArray(j.choices)) return false;
263
+ sawData = true;
264
+ if (j.choices.some((c) => c && typeof c === "object" && (c.text || c.message?.text))) return false;
265
+ for (const c of j.choices) {
266
+ if (!c || typeof c !== "object") return false;
267
+ for (const src of [c.delta, c.message]) {
268
+ if (!src || typeof src !== "object") continue;
269
+ for (const [k, v] of Object.entries(src)) {
270
+ if (k === "role" || k === "index" || k === "function_call_name") continue; // 无信息骨架字段
271
+ if (v == null || v === "") continue;
272
+ if (Array.isArray(v) ? v.length > 0 : true) return false; // 任何非空载荷都算产出
273
+ }
274
+ }
275
+ }
276
+ }
277
+ return sawData;
278
+ }