mslxdff 0.1.188 → 0.1.194

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.188",
3
+ "version": "0.1.194",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,7 +13,9 @@ const CHAR_CAP = 32_000_000;
13
13
  // (每条消息 content 整段一次 + 每个 tool_call 一次)。逐块各套会把「非空至少 1」下限乘上块数
14
14
  // —— 几十个 1 字符 text 块能被高报几十倍(评审 P3:方向虽偏高安全,但与生成侧口径分叉就是第二个真相源)。
15
15
  function add(acc, text) {
16
- if (acc.chars > CHAR_CAP) return; // 超限后停止累计,只保留已计部分与图片面值
16
+ // 超限后停止累计(chars 与 tokens 一起停):只护 chars 会让 tokens 无界增长,
17
+ // 客户端拿这个读数决定何时压实上下文 ⇒ 超长请求触发一次毫无必要的激进压缩。
18
+ if (acc.chars > CHAR_CAP) return;
17
19
  const s = text == null ? "" : String(text);
18
20
  const c = createTokenCounter();
19
21
  c.add(s);
@@ -36,8 +38,13 @@ function countMessage(m, acc) {
36
38
  else if (b.type === "thinking") put(String(b.thinking || ""));
37
39
  else if (b.type === "image") acc.images += 1;
38
40
  }
39
- if (acc.chars <= CHAR_CAP) acc.chars += chars;
40
- acc.tokens += c.tokens();
41
+ // tokens 与 chars 必须**同受 CHAR_CAP 约束**:只护 chars 不护 tokens 时,超长请求(readBody 无大小上限,
42
+ // 实测 10 条 4000 万字符消息 → 1.11 亿 tokens)会把读数推到天文数字,而客户端正是拿它决定何时压实上下文
43
+ // ⇒ 一次毫无必要的激进压缩。超限后不再累加,保留已计部分。
44
+ if (acc.chars <= CHAR_CAP) {
45
+ acc.chars += chars;
46
+ acc.tokens += c.tokens();
47
+ }
41
48
  }
42
49
 
43
50
  /** 字符估算版 token 计数(Anthropic 官方:端点缺失时客户端本来就退回字符估算,精度非目标)。 */
@@ -1,11 +1,15 @@
1
1
  // mslxdff -provider raccoon login — 扫码登录(终端二维码 + 轮询)。
2
- // 实测该链路匿名可达且**不需要验证码**;成功后落盘 auths/raccoon-<uid>.json(0600)
2
+ // ⚠ 2026-10 上游改版:二维码必须**先由服务端签发**(POST /api/iam/v1/auth/mp/qrcode),
3
+ // 拿到 qrcode_code + scan_path 才能渲染。旧的「本地自造 code」在新通道下没有服务端会话,
4
+ // 微信扫码后查不到该码 → 手机端报「二维码已过期或已被使用」。详见 src/providers/raccoon/login.js。
5
+ // 该链路匿名可达且**不需要验证码**;成功后落盘 auths/raccoon-<uid>.json(0600)
3
6
  // 并把兜底目录只增不减补齐进 allowlist(避免「登录成功却处处 403」)。
4
7
  import { normalizeProviderId } from "../../../providers/model-id.js";
5
8
  import { compatFetch } from "../../../compat.js";
6
9
  import {
7
10
  buildQrUrl,
8
- createQrCode,
11
+ cancelQrSession,
12
+ createQrSession,
9
13
  fetchRaccoonUserInfo,
10
14
  pollQrLogin,
11
15
  } from "../../../providers/raccoon/login.js";
@@ -17,6 +21,8 @@ import { claimRaccoonLoginReward, raccoonClaimStatus } from "../../../providers/
17
21
 
18
22
  const LOGIN_SUBS = new Set(["login", "auth", "oauth"]);
19
23
 
24
+ const RELOGIN_HINT = " (可重试:mslxdff -provider raccoon login)";
25
+
20
26
  export async function handleRaccoonLogin(id, sub, rest = [], deps = {}) {
21
27
  const pid = normalizeProviderId(id) || String(id || "").toLowerCase();
22
28
  if (pid !== "raccoon") return false;
@@ -26,9 +32,6 @@ export async function handleRaccoonLogin(id, sub, rest = [], deps = {}) {
26
32
  const log = deps.log || ((m) => console.log(m));
27
33
  const exit = deps.exit || ((code) => process.exit(code));
28
34
 
29
- const code = deps.qrCode || createQrCode();
30
- const url = buildQrUrl(code);
31
-
32
35
  log("=".repeat(60));
33
36
  log(" Raccoon 登录(商汤小浣熊 · 积分制模型池)");
34
37
  log("=".repeat(60));
@@ -37,12 +40,26 @@ export async function handleRaccoonLogin(id, sub, rest = [], deps = {}) {
37
40
  log(" 2. 确认后本机自动继续(CLI 轮询,无需回调)");
38
41
  log("");
39
42
 
43
+ // 签发二维码:这一步失败就没有可扫的码,必须当场说清楚,不能拿一个假码糊过去
44
+ let session;
45
+ try {
46
+ session = deps.qrSession || await createQrSession({ fetchImpl });
47
+ } catch (error) {
48
+ log(`❌ 无法获取二维码:${error?.message ?? error}`);
49
+ log(" (多为网络不通或上游改版;可重试:mslxdff -provider raccoon login)");
50
+ exit(1);
51
+ return true;
52
+ }
53
+ const code = deps.qrCode || session.qrcodeCode;
54
+ const url = deps.qrUrl || buildQrUrl(code, session.scanPath);
55
+
40
56
  const art = renderTerminal(encodeQr(url));
41
57
  if (art) {
42
58
  log(art);
43
59
  log("");
44
60
  }
45
61
  log(` 登录链接:${url}`);
62
+ log(` 有效期:${Math.round(session.expiresInMs / 1000)} 秒(过期请重跑本命令)`);
46
63
  log("");
47
64
  log("🔄 等待扫码确认…");
48
65
 
@@ -51,28 +68,26 @@ export async function handleRaccoonLogin(id, sub, rest = [], deps = {}) {
51
68
  out = await pollQrLogin(code, {
52
69
  fetchImpl,
53
70
  sleepFn: deps.sleepFn,
54
- intervalMs: deps.intervalMs,
55
- timeoutMs: deps.timeoutMs,
71
+ intervalMs: deps.intervalMs ?? session.pollIntervalMs,
72
+ timeoutMs: deps.timeoutMs ?? session.expiresInMs,
56
73
  onTick: deps.onTick,
57
74
  });
58
75
  } catch (error) {
59
76
  log("");
60
77
  log(`❌ 登录失败:${error?.message ?? error}`);
61
- log(" (可重试:mslxdff -provider raccoon login)");
78
+ log(RELOGIN_HINT);
62
79
  exit(1);
63
80
  return true;
64
81
  }
65
82
 
66
- if (out.status === "canceled") {
67
- log("");
68
- log("❌ 已在手机端取消登录(未写入任何凭据)");
69
- exit(1);
70
- return true;
71
- }
72
83
  if (out.status !== "success") {
84
+ // 非终态失败:回收服务端会话(失败也不打断),并对每种结局给一句人话
85
+ await cancelQrSession(code, { fetchImpl });
73
86
  log("");
74
- log("❌ 登录已超时,请重新运行 login");
75
- log(" (可重试:mslxdff -provider raccoon login)");
87
+ if (out.status === "canceled") log("❌ 已在手机端取消登录(未写入任何凭据)");
88
+ else if (out.status === "expired") log("❌ 二维码已过期(上游作废了本次会话)");
89
+ else log("❌ 登录已超时");
90
+ log(RELOGIN_HINT);
76
91
  exit(1);
77
92
  return true;
78
93
  }
package/src/logs.js CHANGED
@@ -52,7 +52,17 @@ function keepTailByBytes(text, maxBytes) {
52
52
  if (bytes + b > limit) break;
53
53
  bytes += b;
54
54
  }
55
- return lines.slice(i + 1).join("\n");
55
+ // 至少保留最后一条:单行本身超过 limit 时(超大 detail/堆栈)上面的循环会一路退到第一条内容行之前,
56
+ // 返回空串 = 整个日志被清空,比截断更糟(一次丢掉全部历史,且看起来像"日志坏了")。
57
+ // 判据是「保留结果为空」而不是 `i === lines.length-1`:文件以换行结尾时 split 出来的最后一个元素是
58
+ // 空串,`lines.length-1` 指向它,与真正的内容行不同(这一处曾写错,实测保底从未生效)。
59
+ // 宁可让这一条略微超出预算,也要留住"最新发生了什么"。
60
+ const kept = lines.slice(i + 1).join("\n");
61
+ if (!kept && lines.length) {
62
+ const last = lines.filter((l) => l !== "");
63
+ if (last.length) return last[last.length - 1];
64
+ }
65
+ return kept;
56
66
  }
57
67
 
58
68
  function trimIfOversized(file, maxBytes = MAX_BYTES) {
@@ -160,7 +160,14 @@ function keepTailByBytes(text, maxBytes) {
160
160
  if (bytes + b > limit) break;
161
161
  bytes += b;
162
162
  }
163
- return lines.slice(i + 1).join("\n");
163
+ // 至少保留最后一条:单行超 limit 时否则返回空串 = 清空整个 trace。
164
+ // 判据是「结果为空」——文件尾有换行时 split 的最后一个元素是空串,`i === lines.length-1` 判不到内容行。
165
+ const kept = lines.slice(i + 1).join("\n");
166
+ if (!kept) {
167
+ const nonEmpty = lines.filter((l) => l !== "");
168
+ if (nonEmpty.length) return nonEmpty[nonEmpty.length - 1];
169
+ }
170
+ return kept;
164
171
  }
165
172
 
166
173
  export function appendModelTrace(model, entry, { file = modelLogFile(model) } = {}) {
@@ -10,10 +10,18 @@ export const RACCOON_LLM_PREFIX = "/api/web/llm/v2";
10
10
  export const RACCOON_AUTH_PREFIX = "/api/web/auth/v1";
11
11
  export const RACCOON_POINTS_PREFIX = "/api/web/points/v1";
12
12
  export const RACCOON_DESKTOP_PREFIX = "/api/web/desktop/v1";
13
+ /**
14
+ * 新一代身份通道前缀(2026-10 上游改版实测)。
15
+ * 扫码登录已从 `/api/web/auth/v1/login_with_qrcode_code` 迁到这里,契约完全变了:
16
+ * code 由**服务端签发**(`qr-<uuid>`),并下发 scan_path / expires_in / poll_interval。
17
+ * 旧的「客户端自造 code」在新通道里没有服务端会话 —— 微信扫码后查不到该码,
18
+ * 正好表现为「二维码已过期或已被使用」,故整条扫码链路必须走新端点。
19
+ * ⚠ 信封也不同:这套用 `code: "OK"` 而不是 `code: 0` —— 判成功一律用 isRaccoonOk()。
20
+ */
21
+ export const RACCOON_IAM_PREFIX = "/api/iam/v1";
13
22
 
14
23
  export const raccoonModelCatalogUrl = () => `${RACCOON_ORIGIN}${RACCOON_LLM_PREFIX}/model_catalog`;
15
24
  export const raccoonChatUrl = () => `${RACCOON_ORIGIN}${RACCOON_LLM_PREFIX}/chat/completions`;
16
- export const raccoonQrLoginUrl = () => `${RACCOON_ORIGIN}${RACCOON_AUTH_PREFIX}/login_with_qrcode_code`;
17
25
  export const raccoonRefreshUrl = () => `${RACCOON_ORIGIN}${RACCOON_AUTH_PREFIX}/refresh`;
18
26
  export const raccoonUserInfoUrl = () => `${RACCOON_ORIGIN}${RACCOON_AUTH_PREFIX}/user_info`;
19
27
  export const raccoonBalanceUrl = () => `${RACCOON_ORIGIN}${RACCOON_POINTS_PREFIX}/balance`;
@@ -21,13 +29,26 @@ export const raccoonBillsUrl = (limit = 50) =>
21
29
  `${RACCOON_ORIGIN}${RACCOON_POINTS_PREFIX}/bills?paging.limit=${Number(limit) || 50}&paging.offset=0`;
22
30
  export const raccoonLoginGrantUrl = () => `${RACCOON_ORIGIN}${RACCOON_DESKTOP_PREFIX}/login/points/grant`;
23
31
 
32
+ /** 服务端签发二维码(POST,body 可带 `{ product_codes: [] }`)。 */
33
+ export const raccoonQrCreateUrl = () => `${RACCOON_ORIGIN}${RACCOON_IAM_PREFIX}/auth/mp/qrcode`;
34
+ /** 轮询扫码状态(POST,body `{ qrcode_code }`)。 */
35
+ export const raccoonQrPollUrl = () => `${RACCOON_ORIGIN}${RACCOON_IAM_PREFIX}/auth/mp/qrcode/login`;
36
+ /** 取消二维码(PUT,body `{ qrcode_code, status: "canceled" }`)——过期/离开页面时礼貌回收。 */
37
+ export const raccoonQrCancelUrl = () => `${RACCOON_ORIGIN}${RACCOON_IAM_PREFIX}/auth/mp/qrcode`;
38
+
24
39
  /**
25
- * 扫码登录深链接:终端编成二维码,**必须用微信「扫一扫」**打开确认(浏览器直接打开会 404)。
26
- * 形如 `https://xiaohuanxiong.com/login/mp?code=<hex>&appname=商汤小浣熊官网`。
27
- * 实测:服务器对任意路径都回同一份 SPA 外壳(HTTP 200),但 SPA 路由表里没有 `login/mp`(只有 `login` + 兜底 `*`)→ 浏览器必 404,属正常而非链接失效。
40
+ * 扫码登录深链接:终端编成二维码,**必须用微信「扫一扫」**打开确认。
41
+ * code 与 scan_path 都由 `POST /auth/mp/qrcode` 下发(scan_path 实测为 `/login/mp`),
42
+ * 形如 `https://xiaohuanxiong.com/login/mp?code=qr-xxxx&appname=商汤小浣熊官网&source=platform`。
43
+ * `source=platform` 来自网站登录页实测(chunk c3.js):新版网关按它区分扫码来源,缺了会被判非法码。
44
+ * ⚠ scan_path 是**服务端数据**,拼进 URL 前必须校验(只允许站内单斜杠开头的相对路径),
45
+ * 否则一个被篡改的响应就能把用户导向任意外站。
28
46
  */
29
- export const raccoonQrPageUrl = (code) =>
30
- `${RACCOON_ORIGIN}/login/mp?code=${encodeURIComponent(String(code || ""))}&appname=${encodeURIComponent("商汤小浣熊官网")}`;
47
+ export const raccoonQrPageUrl = (code, scanPath = "/login/mp") => {
48
+ const path = typeof scanPath === "string" && /^\/[^\s\\#]*$/.test(scanPath) && !scanPath.startsWith("//") ? scanPath : "/login/mp";
49
+ return `${RACCOON_ORIGIN}${path}?code=${encodeURIComponent(String(code || ""))}`
50
+ + `&appname=${encodeURIComponent("商汤小浣熊官网")}&source=platform`;
51
+ };
31
52
 
32
53
  export const RACCOON_DEFAULT_CLIENT_VERSION = "v1.0.35";
33
54
  export const RACCOON_CLIENT_PLATFORM = "desktop-windows";
@@ -1,15 +1,23 @@
1
1
  // raccoon 上游统一信封:`{ code, message, details, data }`(与 pack.js 的 parseEnvelope 同形)。
2
- // 约定:code === 0 为成功;HTTP 状态码仅在 code 缺失时兜底(上游错误也可能带 200)。
2
+ // ⚠ 两代通道的成功码不同(2026-10 上游改版):旧 /api/web/* 用数字 `0`,新 /api/iam/v1/* 用字符串 `"OK"`。
3
+ // 故 `code` 原样保留(数字或字符串),判成功一律走 isRaccoonOk()——**不能**再用 `code === 0`:
4
+ // 字符串码一律不等于 0(把 "OK" 判成失败),而把它兜底成 0 又会把 `AUTH_QRCODE_EXPIRED` 判成成功。
3
5
  // 登录态类业务码:200001 未带 token、200003 登录态过期 —— 见 const.js 的 RACCOON_ERROR_KINDS。
4
6
  export function parseRaccoonEnvelope(payload, status = 200) {
5
7
  const record = typeof payload === "object" && payload !== null && !Array.isArray(payload) ? payload : {};
6
- const code = typeof record.code === "number" ? record.code : status >= 400 ? status : 0;
8
+ const raw = record.code;
9
+ const code = typeof raw === "number" || typeof raw === "string" ? raw : status >= 400 ? status : 0;
7
10
  const message = typeof record.message === "string" ? record.message : "";
8
11
  const details = typeof record.details === "string" ? record.details : "";
9
12
  const data = typeof record.data === "object" && record.data !== null && !Array.isArray(record.data) ? record.data : undefined;
10
13
  return { code, message, details, data, status };
11
14
  }
12
15
 
16
+ /** 业务成功判据(两代通道通用):数字 0,或新 IAM 通道的字符串 "OK"。 */
17
+ export function isRaccoonOk(envelope) {
18
+ return envelope?.code === 0 || envelope?.code === "OK";
19
+ }
20
+
13
21
  /** 人话错误文本:`message: details`,都没有时用调用方给的兜底句。 */
14
22
  export function raccoonEnvelopeText(envelope, fallback = "请求失败") {
15
23
  const parts = [envelope?.message, envelope?.details].filter((s) => typeof s === "string" && s.length > 0);
@@ -1,21 +1,25 @@
1
1
  // raccoon 扫码登录状态机与会话续期。
2
- // 实测依据:`POST /api/web/auth/v1/login_with_qrcode_code` **匿名可达**(HTTP 200 {"code":0,"data":{"status":"pending"}}),
3
- // 故整条登录链路不需要验证码、不需要浏览器组件(短信登录才要阿里云滑块,本期不做)。
4
- import { randomBytes } from "node:crypto";
2
+ // ⚠ 2026-10 上游改版(本机实测):扫码登录**不再是**「客户端自造 code + 旧 web 端点」。
3
+ // 新链路 `/api/iam/v1/auth/mp/qrcode` 里 code 由**服务端签发**(`qr-<uuid>`)并下发
4
+ // scan_path / expires_in / poll_interval。旧写法(`randomBytes(16)` 当 code)在新通道下
5
+ // 没有服务端会话 —— 微信扫码后查不到该码,症状正是「二维码已过期或已被使用」。
6
+ // 新通道同样匿名可达、不需要验证码,故整条链路仍不需要浏览器组件(短信登录才要滑块,本期不做)。
5
7
  import {
6
8
  RACCOON_LOGIN_POLL_INTERVAL_MS,
7
9
  RACCOON_LOGIN_TIMEOUT_MS,
8
10
  RACCOON_REQUEST_TIMEOUT_MS,
9
- raccoonQrLoginUrl,
11
+ raccoonQrCancelUrl,
12
+ raccoonQrCreateUrl,
10
13
  raccoonQrPageUrl,
14
+ raccoonQrPollUrl,
11
15
  raccoonRefreshUrl,
12
16
  raccoonUserInfoUrl,
13
17
  } from "./const.js";
14
18
  import { raccoonAnonymousHeaders, raccoonAuthHeaders } from "./headers.js";
15
19
  import { decodeJwtExpMs } from "./auth.js";
16
- import { parseRaccoonEnvelope, raccoonEnvelopeText } from "./envelope.js";
20
+ import { isRaccoonOk, parseRaccoonEnvelope, raccoonEnvelopeText } from "./envelope.js";
17
21
 
18
- /** 上游 `data.status` 四态(pack.js RACCOON_QR_STATUS 同形)。 */
22
+ /** 上游 `data.status` 四态(与网站登录页 chunk 实测一致)。 */
19
23
  export const RACCOON_QR_STATUS = Object.freeze({
20
24
  pending: "pending",
21
25
  logging: "logging",
@@ -23,24 +27,17 @@ export const RACCOON_QR_STATUS = Object.freeze({
23
27
  success: "success",
24
28
  });
25
29
 
26
- /** 一次性登录码:16 字节 hex(32 字符),既是二维码内容参数也是轮询凭据。 */
27
- export function createQrCode() {
28
- return randomBytes(16).toString("hex");
29
- }
30
-
31
- /** 二维码/链接内容:用户用手机扫码或直接打开它完成确认。 */
32
- export function buildQrUrl(code) {
33
- return raccoonQrPageUrl(code);
34
- }
30
+ /** 码在服务端不存在/已作废时 IAM 通道回的业务码。 */
31
+ export const RACCOON_QR_EXPIRED_CODE = "AUTH_QRCODE_EXPIRED";
35
32
 
36
33
  const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
37
34
 
38
- async function postJson(url, body, { fetchImpl = fetch, env = process.env, timeoutMs = RACCOON_REQUEST_TIMEOUT_MS } = {}) {
35
+ async function postJson(url, body, { fetchImpl = fetch, env = process.env, timeoutMs = RACCOON_REQUEST_TIMEOUT_MS, method = "POST" } = {}) {
39
36
  let res;
40
37
  try {
41
38
  res = await fetchImpl(url, {
42
- method: "POST",
43
- headers: raccoonAnonymousHeaders({ env }),
39
+ method,
40
+ headers: { ...raccoonAnonymousHeaders({ env }), "Accept-Language": "zh-CN" },
44
41
  body: JSON.stringify(body),
45
42
  signal: AbortSignal.timeout(timeoutMs),
46
43
  });
@@ -56,6 +53,46 @@ async function postJson(url, body, { fetchImpl = fetch, env = process.env, timeo
56
53
  return parseRaccoonEnvelope(parsed, res.status);
57
54
  }
58
55
 
56
+ /**
57
+ * 请服务端签发一个二维码会话。
58
+ *
59
+ * 返回 `{ qrcodeCode, scanPath, expiresIn, pollInterval, qrUrl }`。
60
+ * 任何字段不合契约都**抛错**而不是产出降级码 —— 自造 code 正是这次故障的根因,
61
+ * 再"兜底"一个扫不出来的码等于把同一类故障换个说法。
62
+ */
63
+ export async function createQrSession(opts = {}) {
64
+ const envelope = await postJson(raccoonQrCreateUrl(), { product_codes: [] }, opts);
65
+ if (!isRaccoonOk(envelope)) throw new Error(`raccoon: 二维码签发失败:${raccoonEnvelopeText(envelope)}`);
66
+ const code = typeof envelope.data?.qrcode_code === "string" ? envelope.data.qrcode_code.trim() : "";
67
+ if (!code) throw new Error("raccoon: 二维码签发响应缺少 qrcode_code");
68
+ const scanPath = typeof envelope.data.scan_path === "string" ? envelope.data.scan_path : "/login/mp";
69
+ const expiresIn = Number(envelope.data.expires_in);
70
+ const pollInterval = Number(envelope.data.poll_interval);
71
+ return {
72
+ qrcodeCode: code,
73
+ scanPath,
74
+ expiresInMs: Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn * 1000 : RACCOON_LOGIN_TIMEOUT_MS,
75
+ pollIntervalMs: Number.isFinite(pollInterval) && pollInterval > 0
76
+ ? Math.max(1000, Math.min(60_000, pollInterval * 1000))
77
+ : RACCOON_LOGIN_POLL_INTERVAL_MS,
78
+ qrUrl: raccoonQrPageUrl(code, scanPath),
79
+ };
80
+ }
81
+
82
+ /** 递归渲染用的二维码内容;scanPath 由签发响应给出。 */
83
+ export function buildQrUrl(code, scanPath) {
84
+ return raccoonQrPageUrl(code, scanPath);
85
+ }
86
+
87
+ /** 回收服务端会话(过期/退出时调用,失败不影响用户)。 */
88
+ export async function cancelQrSession(code, opts = {}) {
89
+ try {
90
+ await postJson(raccoonQrCancelUrl(), { qrcode_code: String(code || ""), status: "canceled" }, { ...opts, method: "PUT" });
91
+ } catch {
92
+ // 取消是礼貌动作,不是流程一部分:网络不通时静默即可(码自己会到期)
93
+ }
94
+ }
95
+
59
96
  /** 从登录成功信封里取凭据;缺 access_token 视为失败(不写半截凭据)。 */
60
97
  export function raccoonCredentialFromEnvelope(envelope) {
61
98
  const data = envelope?.data ?? {};
@@ -74,32 +111,44 @@ export function raccoonCredentialFromEnvelope(envelope) {
74
111
 
75
112
  /**
76
113
  * 轮询一轮。网络抖动一律折成 `pending`(用户还在扫码,不该因为一次超时把整轮打断);
77
- * `canceled` / `success` 是终态。
114
+ * `canceled` / `expired` / `success` 是终态。
78
115
  */
79
116
  export async function pollQrLoginOnce(code, opts = {}) {
80
117
  let envelope;
81
118
  try {
82
- envelope = await postJson(raccoonQrLoginUrl(), { qrcode_code: String(code || "") }, opts);
119
+ envelope = await postJson(raccoonQrPollUrl(), { qrcode_code: String(code || "") }, opts);
83
120
  } catch {
84
121
  return { status: RACCOON_QR_STATUS.pending };
85
122
  }
86
- if (envelope.code !== 0 || envelope.data === undefined) return { status: RACCOON_QR_STATUS.pending };
123
+ if (!isRaccoonOk(envelope)) {
124
+ // 码在服务端不存在/已作废 —— 必须区分出来:继续轮询只会一直失败,用户却以为还在等扫描
125
+ if (String(envelope.code) === RACCOON_QR_EXPIRED_CODE) return { status: "expired" };
126
+ return { status: RACCOON_QR_STATUS.pending };
127
+ }
128
+ if (envelope.data === undefined) return { status: RACCOON_QR_STATUS.pending };
87
129
  const status = typeof envelope.data.status === "string" ? envelope.data.status : "";
88
130
  const expiredAt = typeof envelope.data.expired_at === "string" ? envelope.data.expired_at : undefined;
131
+ const pollIntervalMs = Number.isFinite(Number(envelope.data.poll_interval)) && Number(envelope.data.poll_interval) > 0
132
+ ? Number(envelope.data.poll_interval) * 1000
133
+ : undefined;
134
+ const jitter = pollIntervalMs === undefined ? {} : { pollIntervalMs };
89
135
  if (status === RACCOON_QR_STATUS.canceled) return { status: RACCOON_QR_STATUS.canceled };
90
- if (status === RACCOON_QR_STATUS.logging) return { status: RACCOON_QR_STATUS.logging, ...(expiredAt ? { expiredAt } : {}) };
136
+ if (status === RACCOON_QR_STATUS.logging) return { status: RACCOON_QR_STATUS.logging, ...jitter, ...(expiredAt ? { expiredAt } : {}) };
91
137
  if (status === RACCOON_QR_STATUS.success) {
92
138
  try {
93
- return { status: RACCOON_QR_STATUS.success, credential: raccoonCredentialFromEnvelope(envelope) };
139
+ return { status: RACCOON_QR_STATUS.success, ...jitter, credential: raccoonCredentialFromEnvelope(envelope) };
94
140
  } catch {
95
- return { status: RACCOON_QR_STATUS.pending };
141
+ return { status: RACCOON_QR_STATUS.pending, ...jitter };
96
142
  }
97
143
  }
98
- return { status: RACCOON_QR_STATUS.pending };
144
+ return { status: RACCOON_QR_STATUS.pending, ...jitter };
99
145
  }
100
146
 
101
147
  /**
102
- * 轮询直到终态。返回 `{ status, credential? }`;
148
+ * 轮询直到终态。返回 `{ status, credential? }`。
149
+ *
150
+ * `deadlineMs` 用**服务端下发的有效期**(不是本地常量):码的生死由上游决定,
151
+ * 本地猜一个更长的窗口只会在码已作废后继续空转,把「已过期」拖成「超时」。
103
152
  * 超时返回 `{ status: "timeout" }`(调用方据此输出人话并非零退出)。
104
153
  */
105
154
  export async function pollQrLogin(code, {
@@ -113,6 +162,7 @@ export async function pollQrLogin(code, {
113
162
  } = {}) {
114
163
  const deadline = now() + timeoutMs;
115
164
  let ticks = 0;
165
+ let interval = intervalMs;
116
166
  for (;;) {
117
167
  const result = await pollQrLoginOnce(code, { fetchImpl, env });
118
168
  ticks += 1;
@@ -121,8 +171,11 @@ export async function pollQrLogin(code, {
121
171
  }
122
172
  if (result.status === RACCOON_QR_STATUS.success) return result;
123
173
  if (result.status === RACCOON_QR_STATUS.canceled) return { status: RACCOON_QR_STATUS.canceled };
174
+ if (result.status === "expired") return { status: "expired" };
175
+ // 服务端可以调快/调慢节奏,以它下发的为准(夹在 1s–60s,防被一个坏值卡住)
176
+ if (Number.isFinite(result.pollIntervalMs)) interval = Math.max(1000, Math.min(60_000, result.pollIntervalMs));
124
177
  if (now() >= deadline) return { status: "timeout" };
125
- await sleepFn(intervalMs);
178
+ await sleepFn(interval);
126
179
  }
127
180
  }
128
181
 
@@ -150,12 +203,13 @@ export async function refreshRaccoonCredential(credential, opts = {}) {
150
203
  throw new Error(`raccoon: 续期响应不是 JSON(HTTP ${res.status})`);
151
204
  }
152
205
  const envelope = parseRaccoonEnvelope(parsed, res.status);
153
- if (res.status === 401 || envelope.code === 200003) {
206
+ // 登录态失效判据两代都收:旧通道数字 200003,IAM 通道字符串 AUTH_ACCESS_TOKEN_EXPIRED
207
+ if (res.status === 401 || envelope.code === 200003 || String(envelope.code) === "AUTH_ACCESS_TOKEN_EXPIRED") {
154
208
  const err = new Error("raccoon: 登录态已过期,请重新登录");
155
209
  err.authExpired = true;
156
210
  throw err;
157
211
  }
158
- if (envelope.code !== 0) throw new Error(`raccoon: 续期失败:${raccoonEnvelopeText(envelope)}`);
212
+ if (!isRaccoonOk(envelope)) throw new Error(`raccoon: 续期失败:${raccoonEnvelopeText(envelope)}`);
159
213
  const accessToken = typeof envelope.data?.access_token === "string" ? envelope.data.access_token : "";
160
214
  if (!accessToken) throw new Error("raccoon: 续期响应缺少 access_token");
161
215
  const nextRefresh = typeof envelope.data.refresh_token === "string" && envelope.data.refresh_token.length > 0
@@ -181,7 +235,7 @@ export async function fetchRaccoonUserInfo(credential, opts = {}) {
181
235
  });
182
236
  if (!res.ok) return {};
183
237
  const envelope = parseRaccoonEnvelope(await res.json(), res.status);
184
- if (envelope.code !== 0 || envelope.data === undefined) return {};
238
+ if (!isRaccoonOk(envelope) || envelope.data === undefined) return {};
185
239
  const pick = (k) => (typeof envelope.data[k] === "string" ? envelope.data[k] : "");
186
240
  return {
187
241
  userId: pick("id"),
@@ -18,6 +18,33 @@ function defaultDirs() {
18
18
  return [...dirs];
19
19
  }
20
20
 
21
+ // 裁剪保留比例(按字节)。阈值 `st.size` 是**字节**,原先的回退 `content.slice(-512*1024)` 是**字符** ——
22
+ // 量纲不匹配:中文(3 字节/字符)下保留后字节数浮动,且 slice 按字符切会**切在半行**上
23
+ // (实测开头得到 `"el=workbuddy/deepseek totalMs="`,下一行接上去就把日志结构毁了)。
24
+ // 与 logs.js / model-trace.js 同口径:按字节留 3/4,且不截半行。
25
+ const KEEP_RATIO = 3 / 4;
26
+
27
+ function keepTailByBytes(text, maxBytes) {
28
+ const limit = Math.max(1, Math.floor(maxBytes * KEEP_RATIO));
29
+ if (Buffer.byteLength(text) <= limit) return text;
30
+ const lines = text.split("\n");
31
+ let bytes = 0;
32
+ let i = lines.length - 1;
33
+ for (; i >= 0; i--) {
34
+ const b = Buffer.byteLength(lines[i]) + 1;
35
+ if (bytes + b > limit) break;
36
+ bytes += b;
37
+ }
38
+ // 至少保留最后一条:单行超 limit 时否则返回空串 = 清空整个轮转日志。
39
+ // 判据是「结果为空」——文件尾有换行时 split 的最后一个元素是空串,`i === lines.length-1` 判不到内容行。
40
+ const kept = lines.slice(i + 1).join("\n");
41
+ if (!kept) {
42
+ const nonEmpty = lines.filter((l) => l !== "");
43
+ if (nonEmpty.length) return nonEmpty[nonEmpty.length - 1];
44
+ }
45
+ return kept;
46
+ }
47
+
21
48
  export function appendRotationLog({ uid, model, totalMs, balanceHit, error, clock = Date.now, fs: fsOverride, dirs: dirsOverride, maxBytes = 1024 * 1024 } = {}) {
22
49
  // 兼容旧调用:appendRotationLog({uid,model,totalMs,balanceHit,error}) 形式,clock/fs 可选
23
50
  const useFs = fsOverride || { appendFileSync, mkdirSync, readFileSync, writeFileSync, statSync, join };
@@ -32,8 +59,9 @@ export function appendRotationLog({ uid, model, totalMs, balanceHit, error, cloc
32
59
  try {
33
60
  const st = useFs.statSync(file);
34
61
  if (st.size > maxBytes) {
35
- const content = useFs.readFileSync(file, "utf8");
36
- useFs.writeFileSync(file, content.slice(-512 * 1024));
62
+ // 按字节回退(不截半行),与 logs.js / model-trace.js 同口径。
63
+ const kept = keepTailByBytes(useFs.readFileSync(file, "utf8"), maxBytes);
64
+ useFs.writeFileSync(file, kept.endsWith("\n") || !kept ? kept : `${kept}\n`);
37
65
  }
38
66
  } catch {}
39
67
  } catch {}
@@ -89,7 +89,11 @@ export function responsesToChatBody(req = {}) {
89
89
  messages.push({ role: "tool", tool_call_id: it.call_id || "", content: typeof it.output === "string" ? it.output : JSON.stringify(it.output ?? "") });
90
90
  }
91
91
  }
92
- if (!messages.length) messages.push({ role: "user", content: "hi" });
92
+ // 空会话直接抛错,**绝不伪造一句 "hi" 打上游**:那会把假提问写进用户上下文并白烧一发额度。
93
+ // 口径与 anthropic/request-body.js:83-84 一致(同一仓库同一类场景不该有两套策略)。
94
+ // 触发面比"空 input"更广:input 里全是未知 item 类型(如 `{type:"unknown_item"}`)时也走这里
95
+ // —— 静默发一句 hi 会让客户端拿到一个与它请求毫无关系的回答。
96
+ if (!messages.length) throw new Error("responses: 缺少用户消息");
93
97
  const body = { model, messages, stream: Boolean(req.stream) };
94
98
  if (Array.isArray(req.tools) && req.tools.length) {
95
99
  body.tools = req.tools.map((t) => (t?.type === "function"
@@ -49,12 +49,19 @@ function makeBufferedBody(chunk, handle, kind) {
49
49
  })();
50
50
  }
51
51
 
52
+ // 逐个调用并各自吞异常:**不能**写成 `await x.cancel().catch(()=>{})` —— 自定义/AI SDK 流
53
+ // 的 cancel() 可能返回 undefined(非 Promise),那种写法会抛 TypeError 并被外层 catch 吞掉,
54
+ // 于是后面的 handle.cancel() 根本不会执行 ⇒ 上游流没被真正取消(实测:bodyCancelled=true、
55
+ // handleCancelled=false)。与 stream-body.js 的 cancelBody 同口径,逐个 `Promise.resolve(...)`。
56
+ // 逐个调用并各自吞异常:**不能**写成 `await x.cancel().catch(()=>{})` —— 自定义/AI SDK 流
57
+ // 的 cancel() 可能返回 undefined(非 Promise),那种写法会抛 TypeError 并被外层 catch 吞掉,
58
+ // 于是后面的 handle.cancel() 根本不会执行 ⇒ 上游流没被真正取消(实测:bodyCancelled=true、
59
+ // handleCancelled=false)。与 stream-body.js 的 cancelBody 同口径,逐个 `Promise.resolve(...)`。
52
60
  async function cancelBody(body, handle, kind) {
53
- try {
54
- if (body && typeof body.cancel === "function") await body.cancel().catch(() => {});
55
- if (kind === "web" && handle && typeof handle.cancel === "function") await handle.cancel().catch(() => {});
56
- else if (handle && typeof handle.return === "function") await handle.return().catch(() => {});
57
- } catch {}
61
+ const settle = async (fn) => { try { await Promise.resolve(fn()); } catch { /* ignore */ } };
62
+ if (body && typeof body.cancel === "function") await settle(() => body.cancel());
63
+ if (kind === "web" && handle && typeof handle.cancel === "function") await settle(() => handle.cancel());
64
+ else if (handle && typeof handle.return === "function") await settle(() => handle.return());
58
65
  }
59
66
 
60
67
  function getFirstChunkPromise(body) {
@@ -9,6 +9,7 @@
9
9
  // 都必须先把 [DONE] 暂扣住**(tail 槽),否则错误永不可见、还会留下两个终止符。
10
10
  // 拆出原因:这些策略与 relay 本体同处一文件会破仓库 20KB 硬门,且语义本就独立(不碰上游读取与闸门计时)。
11
11
  import { json } from "./helpers.js";
12
+ import { isTrivialFrame } from "./stream-scan.js";
12
13
 
13
14
  // 暂扣上限:正常空轮的前缀就是几帧空 delta + 一个 [DONE];超过即说明不是这个形状,别为它囤内存。
14
15
  export const HOLD_MAX_FRAMES = 64;
@@ -137,27 +138,77 @@ export function createRevocablePrefix(res, detail, { maxFrames = HOLD_MAX_FRAMES
137
138
  try { res.write(t.buf); } catch { /* 下游已断开 */ }
138
139
  return true; // 告诉收场方:[DONE] 已经由这一帧发出去了,别再补第二个
139
140
  };
141
+ /**
142
+ * 暂扣一帧。终止帧进 tail 槽(无论是否已提交);否则进 pending(未提交时可整段撤销)。
143
+ * 返回 false = pending 超上限放不下,调用方必须改为提交,别默默丢字节。
144
+ *
145
+ * **必须自己拼 carry**:上一块的半截终止行扣在 carry 里,本块就是它的续块。若让调用方分别处理,
146
+ * 两块会走各自的路径(carry 被收场 `flushCarry` 补一个换行 → 终止帧被拆成两行、客户端认不出终止符),
147
+ * 更糟的是**本块会因「不含 data:」被判成非空帧而被迫提交**,直接摧毁空轮留口(BUG-40 实测:
148
+ * 空轮 + `[DONE]` 被 TCP 切两块时 heldOpen 由 true 变 false、wrotePayload 由 false 变 true,
149
+ * 救援静默失效)。拼接是字节流的本分:carry 之后紧跟的必然是该行的续字节。
150
+ */
151
+ function holdFrame(buf, size) {
152
+ let b = buf;
153
+ if (carry) {
154
+ // 只有「本块恰好补完那个终止行」才该拼;否则 carry 里是上一块**残缺的一行**,
155
+ // 必须按序先放出去(writeNow 会补换行),硬拼会把它与本帧粘成 `data: [DOdata: {...` ——
156
+ // 非法行,客户端认不出终止符(实测终止符数=0)。已提交时更是如此:此时本帧要直写。
157
+ const joined = Buffer.concat([toBuf(carry.buf) ?? Buffer.alloc(0), toBuf(buf) ?? Buffer.alloc(0)]);
158
+ if (committed && !splitTrailingDone(joined) && doneLinePrefix(joined) === 0) {
159
+ writeNow(buf, size); // writeNow 内部先 flushCarry(带换行),帧序与行完整性都保住
160
+ return true;
161
+ }
162
+ b = joined;
163
+ carry = null;
164
+ size = b.length;
165
+ }
166
+ // 本块夹带终止帧:头段照常走本函数,终止帧扣进 tail(行锚定,正文里的字面量不算)
167
+ const sp = splitTrailingDone(b);
168
+ if (sp) {
169
+ if (sp.head.length && !holdFrame(sp.head, sp.head.length)) return false;
170
+ if (tail) flushTail();
171
+ tail = { buf: sp.rest, size: sp.rest.length };
172
+ return true;
173
+ }
174
+ // 尾部是终止行前缀(终止帧被 TCP 切开):扣住等下一块拼上再判
175
+ const pl = doneLinePrefix(b);
176
+ if (pl > 0) {
177
+ const head = b.subarray(0, b.length - pl);
178
+ if (head.length && !holdFrame(head, head.length)) return false;
179
+ const keep = pl < b.length ? b.subarray(b.length - pl) : b;
180
+ carry = { buf: keep, size: keep.length };
181
+ return true;
182
+ }
183
+ if (committed) { writeNow(b, size); return true; }
184
+ if (pending.length >= maxFrames || pendingBytes + size > maxBytes) return false;
185
+ pending.push({ buf: b, size });
186
+ pendingBytes += size;
187
+ return true;
188
+ }
140
189
  return {
141
190
  get committed() { return committed; },
142
191
  get heldFrames() { return pending.length + (tail ? 1 : 0); },
192
+ hold: holdFrame,
143
193
  /**
144
- * 暂扣一帧。isDone=true 的帧进 tail 槽(无论是否已提交);否则进 pending(未提交时可整段撤销)。
145
- * 返回 false = pending 超上限放不下,调用方必须改为提交,别默默丢字节。
194
+ * 本块是否仍属「可撤销前缀」——必须连同 carry 一起判,且**半截终止行本身也算**。
195
+ * 只看本块会漏两种:① 续块(`NE]\n\n`)自身不含 `data:`,`isTrivialFrame` 必判 false;
196
+ * ② 首块(`data: [DO`)自身是半个终止帧,JSON 解析失败也判 false。两者都会让调用方走 submit
197
+ * 提交、留口失效(BUG-40)。凡尾部是终止行前缀的一律按可暂扣处理:它会进 carry 等下一块。
146
198
  */
147
- hold(buf, size, isDone = false) {
148
- if (isDone) {
149
- // 半截终止行若还扣着(上一块尾部像终止行前缀、本块却是独立完整终止帧),必须先放出去:
150
- // 直接塞进 tail 会让它和 [DONE] 粘成 `data: [DOdata: [DONE]` —— 非法行,客户端认不出终止符(实测数=0)。
151
- if (carry) flushCarry();
152
- if (tail) flushTail();
153
- tail = { buf, size };
154
- return true;
199
+ isTrivial(txt) {
200
+ const t = Buffer.from(String(txt ?? ""), "utf8");
201
+ // 尾部是终止行前缀(`data: [DO`):它是**残缺的一行**,会被扣进 carry 等续块,本身不含模型产出。
202
+ // 不认它就会走 submit 提交、留口失效(BUG-40)。这一判必须在有无 carry 时都跑——首块通常无 carry。
203
+ if (doneLinePrefix(t) > 0) return true;
204
+ if (carry) {
205
+ const joined = Buffer.concat([toBuf(carry.buf) ?? Buffer.alloc(0), t]);
206
+ // carry 是**上一块残缺的一行**,只有本块恰好补完它(成为完整终止帧,或仍是其前缀)才算续块。
207
+ // 否则本块与 carry 无关(如半截行后跟一个独立空帧):按序先放 carry、本块单独判,
208
+ // 硬拼会把两个无关的帧粘成非法行。
209
+ if (doneLinePrefix(joined) > 0 || splitTrailingDone(joined)) return true;
155
210
  }
156
- if (committed) { writeNow(buf, size); return true; }
157
- if (pending.length >= maxFrames || pendingBytes + size > maxBytes) return false;
158
- pending.push({ buf, size });
159
- pendingBytes += size;
160
- return true;
211
+ return isTrivialFrame(t.toString("utf8"));
161
212
  },
162
213
  /**
163
214
  * 提交:按序补写 pending(不含 tail),再写本帧,此后一律直写。
@@ -80,10 +80,7 @@ function talkConsumeLine(talk, raw, glued = false) {
80
80
  // 多一个属性就是日志噪音),也不随 detail 的展开/复制丢失统计口径。
81
81
  const tcIndexes = new WeakMap();
82
82
 
83
- /**
84
- * 工具调用去重计数:同一 `index` 的多个分片只算一次。
85
- * 分片缺 `index` 字段时退回 0(OpenAI 规范不要求每个 arguments 分片都重复 index)。
86
- */
83
+ /** 工具调用去重计数:同一 `index` 的多个分片只算一次;分片缺 `index` 时退回 0。 */
87
84
  function countToolCalls(detail, txt) {
88
85
  if (!detail || typeof detail !== "object") return;
89
86
  let seen = tcIndexes.get(detail);
@@ -136,9 +133,32 @@ export function captureTalkFallback(talk, text) {
136
133
  talkPush(talk, "content", String(text));
137
134
  }
138
135
 
136
+ // 判据必须跑在**完整行**上:TCP 会把一帧切两块,`"finish_reason"`/`"content"` 被切在中间时正则永不命中
137
+ // ⇒ sawFinishReason 恒 null(正常结束的流被 endTruncatedStream 误判成截断,向客户端写假 STREAM_TRUNCATED)、
138
+ // chars 归零(chatShaped 为真时正好命中空轮判据)。与 BUG-24/25 同型:那次是终止标记被切块,这次是观测层缺行缓冲。
139
+ const scanRemain = new WeakMap();
140
+ const SCAN_REMAIN_MAX = 1_000_000; // 半行上限:超限按完整行处理,宁可错判也不无界吃内存
141
+
142
+ /** 流收场回收:末尾无换行的残行按同一口径扫一次,否则末帧的 finish_reason 永远读不到。幂等。 */
143
+ export function flushScanRemainder(detail) {
144
+ if (!detail || typeof detail !== "object") return;
145
+ const rest = scanRemain.get(detail);
146
+ if (!rest) return;
147
+ scanRemain.delete(detail);
148
+ scanCompleteText(detail, rest);
149
+ }
150
+
139
151
  // 逐 SSE chunk 扫描:done/finish_reason/chat 形状/工具调用/usage/正文字符
140
152
  export function scanSseChunk(detail, txt) {
141
153
  if (detail.talk) { try { captureTalkSse(detail.talk, txt); } catch {} } // 纯旁路红线:捕获异常绝不外溢成转发失败(review P2-7:旧调用点裸奔在 try 外只是口头契约)
154
+ let buf = (scanRemain.get(detail) || "") + String(txt ?? "");
155
+ const rest = buf.slice(buf.lastIndexOf("\n") + 1);
156
+ if (rest.length > SCAN_REMAIN_MAX) { scanRemain.set(detail, ""); }
157
+ else { buf = buf.slice(0, buf.length - rest.length); scanRemain.set(detail, rest); }
158
+ if (buf) scanCompleteText(detail, buf);
159
+ }
160
+
161
+ function scanCompleteText(detail, txt) {
142
162
  try {
143
163
  if (txt.includes("[DONE]")) detail.sawDone = true;
144
164
  const m = txt.match(/"finish_reason"\s*:\s*"([^"]+)"/);
@@ -1,7 +1,7 @@
1
1
  import { performance } from "node:perf_hooks";
2
2
  import { applyFallbackHeaders } from "./fallback.js";
3
3
  import { json } from "./helpers.js";
4
- import { scanSseChunk, preflightMs, createTalkBucketIfEnabled, captureTalkFallback, isEmptyTurnDetail, holdableChunk, isTrivialFrame } from "./stream-scan.js"; // 逐帧观测累加器 + 锚点偏移 + 对话正文 + 空轮判据 + 错误包络暂扣 + 可撤销前缀判据(纯函数与建桶判据全在 stream-scan)
4
+ import { scanSseChunk, flushScanRemainder, preflightMs, createTalkBucketIfEnabled, captureTalkFallback, isEmptyTurnDetail, holdableChunk } from "./stream-scan.js"; // 逐帧观测累加器 + 锚点偏移 + 对话正文 + 空轮判据 + 错误包络暂扣(纯函数与建桶判据全在 stream-scan;可撤销判据走 prefix.isTrivial)
5
5
  import { holdEndEnabled, createRevocablePrefix, endEmptyTurnStream, endTruncatedStream, flushPrefixTail } from "./stream-hold.js"; // 空轮可撤销前缀 / 延后封口 / 终局收场(拆出去是为了不破 stream.js 20KB 硬门)
6
6
  import { chunkText, cancelBody, hasPayload, bodyChunks, enrichFirstReply, scanNonStreamReply } from "./stream-body.js"; // 读取层工具(字节→文本 / 取消上游 / 注释帧判定 / 自持 reader 迭代 / 首块富化 / 非流式先判后写),为让本文件退回 20KB 以内而拆出
7
7
 
@@ -233,15 +233,16 @@ export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort,
233
233
  // 注释帧等非数据帧:与改前一致直写(客户端视作噪声,不影响可撤销性)
234
234
  wroteAny = true;
235
235
  prefix.writeNow(outChunk, size);
236
- } else if (gained || !revocableHold || !isTrivialFrame(txt)) {
237
- // 有模型产出(正文/工具/思考)、留口已关、或看不懂的异形帧:按序补写暂扣前缀 + 本帧,
238
- // 这条流就此提交不可撤销(透传契约要求解析失败的帧必须原样走,绝不为救空轮扣下)
236
+ } else if (gained || !revocableHold || !prefix.isTrivial(txt)) {
237
+ // 有模型产出、留口已关、或看不懂的异形帧:按序补写暂扣前缀 + 本帧,此流就此提交不可撤销
238
+ //(透传契约:解析失败的帧必须原样走,绝不为救空轮扣下)。
239
+ // 可撤销判定走 prefix.isTrivial 而非裸 isTrivialFrame:半截终止行扣在 prefix 的 carry 里,
240
+ // 只看本块会把它的续块判成非空,留口静默失效(BUG-40)。
239
241
  wroteAny = true;
240
242
  wrotePayload = true; detail.wrotePayload = true;
241
243
  prefix.submit(outChunk, size);
242
- } else if (!prefix.hold(outChunk, size, txt.includes("[DONE]"))) {
243
- // 可撤销前缀:空 delta 暂扣(判为空轮时整段撤销,留给重拉的正文);
244
- // [DONE] 无论是否已提交都进 tail 槽 —— 错误帧必须能排在它前面,否则客户端一见 [DONE] 就收尾
244
+ } else if (!prefix.hold(outChunk, size)) {
245
+ // 可撤销前缀按序暂扣([DONE] 进 tail 槽让位给错误帧;终止帧识别收进 prefix,见 holdFrame)
245
246
  wroteAny = true;
246
247
  wrotePayload = true; detail.wrotePayload = true;
247
248
  prefix.submit(outChunk, size); // 暂扣超上限(异常形状)→ 被迫提交,退回旧行为
@@ -260,6 +261,9 @@ export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort,
260
261
  if (maxTimer) clearTimeout(maxTimer);
261
262
  if (stallTimer) clearTimeout(stallTimer);
262
263
  if (pingTimer) clearInterval(pingTimer);
264
+ // 末帧常无收尾换行:不回收残行,切块/缺换行的 finish_reason 就永远读不到,
265
+ // 下面所有依赖它的判定(截断收场、空轮判据)全都会被带偏。
266
+ try { flushScanRemainder(detail); } catch { /* 观测旁路:绝不外溢成转发失败 */ }
263
267
  }
264
268
  // 任一闸门到点且未写出真实数据 → 可安全 failover(注释帧对客户端无意义,不算已响应)
265
269
  if ((timedOut || stalled || tooLong) && !wrotePayload) {
@@ -38,6 +38,8 @@ export function createSseSerializer(captured = null) {
38
38
  let nextIndex = 0;
39
39
  const toolIndex = new Map();
40
40
  const toolDeltaIds = new Set();
41
+ // 「当前正在流式输入的工具」的 index:无 id 的 tool-input-delta 靠它归位(见该 case 的注释)
42
+ let lastToolIdx = null;
41
43
  // 加密思考往返:reasoning-start 的 providerMetadata 带 itemId + 加密态,随首帧透出
42
44
  let reasoningMeta = null;
43
45
  let reasoningFrameSent = false;
@@ -104,18 +106,30 @@ export function createSseSerializer(captured = null) {
104
106
  const idx = nextIndex++;
105
107
  toolIndex.set(part.id, idx);
106
108
  toolDeltaIds.add(part.id);
109
+ lastToolIdx = idx; // 后续无 id 的 delta 归到这个工具
107
110
  return ensureRole() + frame({
108
111
  tool_calls: [{ index: idx, id: part.id, type: "function", function: { name: part.toolName || "", arguments: "" } }],
109
112
  });
110
113
  }
111
114
  case "tool-input-delta": {
112
- const idx = toolIndex.has(part.id) ? toolIndex.get(part.id) : 0;
115
+ // 未知 id **绝不能回退到 index 0**:那会把这段参数写进另一个工具(实测:callA 占 index 0 时,
116
+ // 一个 id 丢失的 delta 让 funcA 的参数变成 `{"hijack":true}`),客户端会拿着被污染的参数去调用
117
+ // 错误的函数。改为按 `tool-call` 分支的口径分配新 index —— 宁可多一个空工具槽,也不污染已有工具。
118
+ // 无 id 的 delta 属于「当前正在流式输入的那个工具」:复用上一次用过的 index。
119
+ // 每个都分配新 index 会把一段参数拆成多个工具(实测:无 id 的 `{"a":` 与 `1}` 分别落到
120
+ // index 1 和 2,两个都是非法 JSON)—— 比旧代码的「都落 index 0」更糟。
121
+ let idx;
122
+ if (part.id && toolIndex.has(part.id)) idx = toolIndex.get(part.id);
123
+ else if (!part.id && lastToolIdx != null) idx = lastToolIdx; // 无 id:沿用当前工具
124
+ else { idx = nextIndex++; if (part.id) toolIndex.set(part.id, idx); }
125
+ lastToolIdx = idx;
113
126
  return frame({ tool_calls: [{ index: idx, function: { arguments: String(part.delta ?? "") } }] });
114
127
  }
115
128
  case "tool-call": {
116
129
  const id = part.toolCallId;
117
130
  const idx = toolIndex.has(id) ? toolIndex.get(id) : nextIndex++;
118
131
  toolIndex.set(id, idx);
132
+ lastToolIdx = idx; // 同 tool-input-start:后续无 id 的 delta 归到这个工具
119
133
  if (toolDeltaIds.has(id)) return null;
120
134
  const args = typeof part.input === "string" ? part.input : JSON.stringify(part.input ?? {});
121
135
  return ensureRole() + frame({ tool_calls: [{ index: idx, id, type: "function", function: { name: part.toolName || "", arguments: args } }] });