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 +1 -1
- package/src/anthropic/count-tokens.js +10 -3
- package/src/cli/commands/provider/raccoon-login.js +31 -16
- package/src/logs.js +11 -1
- package/src/model-trace.js +8 -1
- package/src/providers/raccoon/const.js +27 -6
- package/src/providers/raccoon/envelope.js +10 -2
- package/src/providers/raccoon/login.js +84 -30
- package/src/providers/workbuddy/rotation-log.js +30 -2
- package/src/responses/translate.js +5 -1
- package/src/routes/hedge.js +12 -5
- package/src/routes/stream-hold.js +66 -15
- package/src/routes/stream-scan.js +24 -4
- package/src/routes/stream.js +11 -7
- package/src/upstream-engine/sdk/sse.js +15 -1
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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(
|
|
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("❌
|
|
75
|
-
|
|
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
|
-
|
|
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) {
|
package/src/model-trace.js
CHANGED
|
@@ -160,7 +160,14 @@ function keepTailByBytes(text, maxBytes) {
|
|
|
160
160
|
if (bytes + b > limit) break;
|
|
161
161
|
bytes += b;
|
|
162
162
|
}
|
|
163
|
-
|
|
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
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
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
|
-
|
|
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`
|
|
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
|
-
/**
|
|
27
|
-
export
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
36
|
-
useFs.
|
|
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
|
-
|
|
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"
|
package/src/routes/hedge.js
CHANGED
|
@@ -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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
*
|
|
145
|
-
*
|
|
194
|
+
* 本块是否仍属「可撤销前缀」——必须连同 carry 一起判,且**半截终止行本身也算**。
|
|
195
|
+
* 只看本块会漏两种:① 续块(`NE]\n\n`)自身不含 `data:`,`isTrivialFrame` 必判 false;
|
|
196
|
+
* ② 首块(`data: [DO`)自身是半个终止帧,JSON 解析失败也判 false。两者都会让调用方走 submit
|
|
197
|
+
* 提交、留口失效(BUG-40)。凡尾部是终止行前缀的一律按可暂扣处理:它会进 carry 等下一块。
|
|
146
198
|
*/
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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*"([^"]+)"/);
|
package/src/routes/stream.js
CHANGED
|
@@ -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
|
|
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 || !
|
|
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
|
|
243
|
-
//
|
|
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
|
-
|
|
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 } }] });
|