mslxdff 0.1.200 → 0.1.207
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/docs/cli_help_mini.md +1 -1
- package/package.json +1 -1
- package/src/anthropic/canonical-json.js +55 -0
- package/src/anthropic/request-body.js +103 -46
- package/src/anthropic/request-shape.js +60 -0
- package/src/anthropic/session-id.js +58 -0
- package/src/anthropic/tool-media.js +75 -0
- package/src/anthropic/translate.js +15 -6
- package/src/chat-pipeline/index.js +8 -40
- package/src/chat-pipeline/serial-trial.js +6 -10
- package/src/chat-pipeline/terminal-verdict.js +1 -16
- package/src/cli/commands/stats.js +11 -2
- package/src/cli/policy.js +2 -2
- package/src/metrics.js +10 -1
- package/src/peers.js +5 -2
- package/src/providers/raccoon/const.js +28 -13
- package/src/providers/raccoon/credits.js +12 -2
- package/src/providers/raccoon/crypto.js +43 -0
- package/src/providers/raccoon/password-login.js +18 -3
- package/src/routes/helpers.js +13 -3
- package/src/routes/messages-route.js +12 -2
- package/src/routes/peers.js +33 -3
- package/src/runtime/bootstrap.js +8 -0
- package/src/runtime/keep-warm.js +120 -0
- package/src/server.js +7 -0
- package/src/usage/cache-fields.js +58 -0
- package/src/usage/record.js +3 -0
- package/src/usage/report.js +14 -0
|
@@ -13,7 +13,7 @@ import { shouldUseGroupForModel, isHardLocalOnly, isKeyProviderDirectOnly } from
|
|
|
13
13
|
import { summarizeRequest, upstreamEcho } from "../model-trace.js";
|
|
14
14
|
import { isEmptyTurnError } from "../routes/chat/relay-pipeline.js";
|
|
15
15
|
import { emptyRetryCfg, emptyRaiseCap, withRaisedMaxTokens, withOutputFloor, emptyNudgeCfg, withEmptyNudge, computeNextDelay, emptyTurnBudgetMs, emptyTurnMinRaiseTo } from "./empty-turn.js";
|
|
16
|
-
import { classifyTerminalUpstream, readVerdictText, terminalShortcircuitOff
|
|
16
|
+
import { classifyTerminalUpstream, readVerdictText, terminalShortcircuitOff } from "./terminal-verdict.js";
|
|
17
17
|
|
|
18
18
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
19
19
|
|
|
@@ -39,7 +39,7 @@ export async function runSerialTrial(ctx, deps = {}) {
|
|
|
39
39
|
} = deps;
|
|
40
40
|
const {
|
|
41
41
|
order, reqId, requested, body, hops, useAuto, lockModel, plugins,
|
|
42
|
-
auto, upstream, peers, groups, bus, token, canFallback, canForwardPeers,
|
|
42
|
+
auto, upstream, peers, groups, bus, token, canFallback, canForwardPeers,
|
|
43
43
|
perf0, stages, mark, evt, logCall, logError, done, handlerCtx,
|
|
44
44
|
res, startedAt, logs,
|
|
45
45
|
} = ctx;
|
|
@@ -60,15 +60,13 @@ export async function runSerialTrial(ctx, deps = {}) {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
let lastErr = viaRouteLastErr;
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
const canFailover = (
|
|
66
|
-
canFallback && (!poolFallback || isPoolSwitchWorthy(err ?? lastErr) || poolWorthy);
|
|
63
|
+
// 指定模型只走单候选(order=[requested]),组员/组长接力在同模型内进行;候选间不换模型。
|
|
64
|
+
// canFallback 仅对 auto 的多候选生效。poolFallback/"同池互备"已移除:指定模型绝不漂移到别的模型。
|
|
65
|
+
const canFailover = () => canFallback;
|
|
67
66
|
// 请求级空轮等待累计(跨候选):阶梯是「每发候选」各算的,不设顶就会 N×(2+8+30) 把客户端吊在门外
|
|
68
67
|
let requestWaitedMs = 0;
|
|
69
68
|
candidate: for (let idx = 0; idx < order.length; idx++) {
|
|
70
69
|
const model = order[idx];
|
|
71
|
-
poolWorthy = false; // 每候选独立记账:上一发 429 不代表这一发也 429
|
|
72
70
|
handlerCtx.model = model;
|
|
73
71
|
handlerCtx.orderLen = order.length;
|
|
74
72
|
handlerCtx.idx = idx;
|
|
@@ -159,8 +157,6 @@ export async function runSerialTrial(ctx, deps = {}) {
|
|
|
159
157
|
// 终局判决短路(判据与现网取证见 terminal-verdict.js)
|
|
160
158
|
const verdict = classifyTerminalUpstream(upRes.status, upRaw);
|
|
161
159
|
const terminalOn = verdict.terminal && !terminalShortcircuitOff();
|
|
162
|
-
// BUG-42 记账点:本机直连时上游判过额度/终局 ⇒ 换兄弟有账可依(接力冲不掉,详见 ADR-0061)
|
|
163
|
-
if (isPoolSwitchWorthy({ status: upRes.status, terminal: verdict.terminal })) poolWorthy = true;
|
|
164
160
|
// 一行话把「判决」与「原文」一起落 errors.log(不分两行:同一发失败在 errors.log 里只该有一条)
|
|
165
161
|
logError(model, upRes.status, `upstream ${upRes.status}${upBody ? ` body=${upBody.slice(0, 300)}` : ""}${terminalOn ? ` · 终局判决 ${verdict.kind}(跳过组员接力)` : ""}`);
|
|
166
162
|
evt("upstream-error", { reqId, model, status: upRes.status, message: upMsg.slice(0, 300), timing: upRes._t ?? null, ...upstreamEcho(upRes) });
|
|
@@ -274,7 +270,7 @@ export async function runSerialTrial(ctx, deps = {}) {
|
|
|
274
270
|
if (br.handled) return { done: true };
|
|
275
271
|
}
|
|
276
272
|
}
|
|
277
|
-
if (canFailover(
|
|
273
|
+
if (canFailover()) { evt("fallback", { reqId, from: model, to: order[idx + 1] ?? null, reason: lastErr?.message || `upstream ${lastErr?.status ?? 502}` }); continue; }
|
|
278
274
|
await exhaustedLocal({ res, body, lastErr, order, handlerCtx: { ...handlerCtx, model, reqId }, evt, logCall, mark, perf0, stages, done, requested, useAuto });
|
|
279
275
|
return { done: true };
|
|
280
276
|
}
|
|
@@ -33,8 +33,7 @@ const FREE_TIER_PATTERNS = [
|
|
|
33
33
|
// 上游口径变化,套餐缺失则是**换号换节点都不会变**的判决,属本判据射程内却曾被漏判。
|
|
34
34
|
// 实测(errors.log,2026-10-17 两次逐字相同,cline/cline-pass/deepseek-v4.1-flash):
|
|
35
35
|
// `{"error":{"code":"ENTITLEMENT_ERROR","message":"Error 403: the user is not subscribed to required model plan"}}`
|
|
36
|
-
// ⇒ 旧版 classifyTerminalUpstream 返回 {terminal:false}
|
|
37
|
-
// (终局为假 ⇒ 同池互备路径一并关闭),指定 free 模型的调用方只能吃 403 收场。
|
|
36
|
+
// ⇒ 旧版 classifyTerminalUpstream 返回 {terminal:false},被误当普通 403 照跑组员接力收场。
|
|
38
37
|
// 文案兜底另收一条:防上游改 code 名后只保留人类可读的 message。
|
|
39
38
|
/ENTITLEMENT_ERROR/i,
|
|
40
39
|
/not subscribed to required model plan/i,
|
|
@@ -131,17 +130,3 @@ export function terminalShortcircuitOff(env = process.env) {
|
|
|
131
130
|
const s = String(raw).trim().toLowerCase();
|
|
132
131
|
return s === "0" || s === "false" || s === "off" || s === "no";
|
|
133
132
|
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* 「这一发失败值得换个同池模型再试」vs「换了也一样」的分界(BUG-7 的收窄口径)。
|
|
137
|
-
* 只有额度/判决类失败才允许指定 free 模型横向切同池兄弟:
|
|
138
|
-
* - 429:免费池的限流几乎都是额度维度,换个模型常能立刻出字;
|
|
139
|
-
* - terminal(具名额度判决 / 模型 id 不存在):换兄弟模型可能命中另一个可用 id。
|
|
140
|
-
* 参数错(400)、上游 5xx、fetch 异常一律**不**横向切——那会把「客户端自己发错了」伪装成换模型成功,
|
|
141
|
-
* 也白烧别的模型额度。
|
|
142
|
-
*/
|
|
143
|
-
export function isPoolSwitchWorthy(lastErr) {
|
|
144
|
-
if (!lastErr) return false;
|
|
145
|
-
if (lastErr.terminal) return true;
|
|
146
|
-
return Number(lastErr.status) === 429;
|
|
147
|
-
}
|
|
@@ -72,7 +72,7 @@ function renderTable(headers, rows, aligns = []) {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
function tokenRow(id, r) {
|
|
75
|
-
return [id, r.requests, fmtTok(r.promptTokens), fmtTok(r.completionTokens), reasoningCell(r), fmtTok(r.totalTokens)];
|
|
75
|
+
return [id, r.requests, fmtTok(r.promptTokens), fmtTok(r.completionTokens), cacheCell(r), reasoningCell(r), fmtTok(r.totalTokens)];
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
// 思考列的四态渲染:~ 只跟估算/混合来源;none(未知)显示 —,绝不显示成 0 冒充「不思考」
|
|
@@ -82,6 +82,14 @@ function reasoningCell(r) {
|
|
|
82
82
|
return r.reasoningSource === "estimated" || r.reasoningSource === "mixed" ? `${text}~` : text;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
// 缓存命中列:`N (p%)` = 上游上报的缓存读 token ÷ 窗口输入总数(上游 prompt_tokens 含命中)。
|
|
86
|
+
// 窗口内没有任何命中读数(cacheRows=0)显示 —:不拿 0 冒充「这个模型没缓存」。
|
|
87
|
+
function cacheCell(r) {
|
|
88
|
+
if (!r.cacheRows || !(r.cacheReadTokens > 0)) return "—";
|
|
89
|
+
const tokens = fmtTok(r.cacheReadTokens);
|
|
90
|
+
return r.cacheHitRate == null ? tokens : `${tokens} (${(r.cacheHitRate * 100).toFixed(1)}%)`;
|
|
91
|
+
}
|
|
92
|
+
|
|
85
93
|
function performanceRow(id, r) {
|
|
86
94
|
const samples = Number(r.streamRequests) || 0;
|
|
87
95
|
const ttfb = samples > 0 ? fmtMs(r.avgTtfbMs) : "—"; // 无流式样本时不拿空样本算出的均值糊人
|
|
@@ -106,7 +114,7 @@ export function renderStats(report, { hours = null, model = null } = {}) {
|
|
|
106
114
|
lines.push(`成功请求:${totals.requests} 次 · 模型:${models.length} 个`);
|
|
107
115
|
lines.push("");
|
|
108
116
|
lines.push("Token 用量");
|
|
109
|
-
lines.push(renderTable(["模型", "请求", "输入", "输出", "思考", "合计"], tokenRows, ["left", right, right, right, right, right]));
|
|
117
|
+
lines.push(renderTable(["模型", "请求", "输入", "输出", "缓存命中", "思考", "合计"], tokenRows, ["left", right, right, right, right, right, right]));
|
|
110
118
|
lines.push("");
|
|
111
119
|
lines.push("响应性能");
|
|
112
120
|
lines.push(renderTable(["模型", "首字", "总耗时", "速度", "首字样本"], performanceRows, ["left", right, right, right, right]));
|
|
@@ -115,6 +123,7 @@ export function renderStats(report, { hours = null, model = null } = {}) {
|
|
|
115
123
|
lines.push("思考:数值后带 ~ = 按思考字符÷4 估算(上游未上报);— = 未上报且无可估内容;0 = 上游明确上报本轮无思考。");
|
|
116
124
|
lines.push("首字样本:n/N = 有首字样本数/流式请求数,N=0 时首字显示 —(样本太少的均值不可当全量真值)。");
|
|
117
125
|
lines.push("速度 = 输出 tokens ÷ 生成耗时(总耗时−首字),按窗口加权;只统计成功请求。");
|
|
126
|
+
lines.push("缓存命中:N (p%) = 上游上报的缓存读 token ÷ 窗口输入总数(上游输入总数含命中);— = 窗口内没有命中读数(上游未报或确无命中)。");
|
|
118
127
|
lines.push("口径差异:-status / -model stats 的首字仍取自 state 的终生 EMA(按转发入口量),与本表**不同源**。");
|
|
119
128
|
lines.push("范围:只含经 8989 网关的成功请求;不含失败请求和 -chat 直连。");
|
|
120
129
|
const keepDays = usageKeepDays();
|
package/src/cli/policy.js
CHANGED
|
@@ -99,11 +99,11 @@ export function slowCooldownMs() {
|
|
|
99
99
|
}
|
|
100
100
|
export function peerCooldownMs() {
|
|
101
101
|
const n = Number(process.env.MSLXDFF_PEER_COOLDOWN_MS);
|
|
102
|
-
return Number.isInteger(n) && n > 0 ? n :
|
|
102
|
+
return Number.isInteger(n) && n > 0 ? n : 10_000;
|
|
103
103
|
}
|
|
104
104
|
export function peerLimitCooldownMs() {
|
|
105
105
|
const n = Number(process.env.MSLXDFF_PEER_LIMIT_COOLDOWN_MS);
|
|
106
|
-
return Number.isInteger(n) && n > 0 ? n :
|
|
106
|
+
return Number.isInteger(n) && n > 0 ? n : 10 * 60_000;
|
|
107
107
|
}
|
|
108
108
|
export function peerHeatMs() {
|
|
109
109
|
const n = Number(process.env.MSLXDFF_PEER_HEAT_MS);
|
package/src/metrics.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { normalizeFullId, toFullId } from "./providers/model-id.js";
|
|
2
|
+
import { cacheReadTokens, cacheWriteTokens } from "./usage/cache-fields.js";
|
|
2
3
|
|
|
3
4
|
export { normalizeFullId, toFullId };
|
|
4
5
|
|
|
@@ -33,7 +34,15 @@ export function extractUsageFromJson(parsed) {
|
|
|
33
34
|
// reasoning_tokens 透传
|
|
34
35
|
const rt = u.completion_tokens_details?.reasoning_tokens ?? u.reasoning_tokens;
|
|
35
36
|
if (Number.isFinite(Number(rt))) out.reasoning_tokens = Number(rt);
|
|
36
|
-
|
|
37
|
+
// 「什么都没报」的判据只看原有三个核心字段:只有缓存字段的空包仍算未报(保持原语义)
|
|
38
|
+
if (!Object.keys(out).length) return null;
|
|
39
|
+
// 缓存读数:候选链单一来源 = usage/cache-fields.js(与 /v1/messages 的 usage 翻译同源)。
|
|
40
|
+
// 命中只在上游真报 >0 时落字段;写入字段存在即落(显式 0 也算已报)——「未知」与「0」必须可区分。
|
|
41
|
+
const cr = cacheReadTokens(u);
|
|
42
|
+
if (cr > 0) out.cache_read_input_tokens = cr;
|
|
43
|
+
const cw = cacheWriteTokens(u);
|
|
44
|
+
if (cw != null) out.cache_creation_input_tokens = cw;
|
|
45
|
+
return out;
|
|
37
46
|
}
|
|
38
47
|
|
|
39
48
|
export function extractUsageFromSseText(sseText) {
|
package/src/peers.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { loadPeers, savePeers, loadPeerErrors, savePeerErrors, loadPeerStats, savePeerStats } from "./state.js";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
// 组员失败冷却分级(2026-10-18 用户拍板):
|
|
4
|
+
// 非额度失败(502/5xx/超时,可能几秒就恢复)→ 短冷却 10s,到点自动重新纳入候选、继续挨个重试;
|
|
5
|
+
// 额度类(连续 2 次 429)→ 长冷却 10min(额度当天不会因快重试恢复,反复撞只会白烧配额)。
|
|
6
|
+
export const DEFAULT_PEER_COOLDOWN_MS = 10_000;
|
|
7
|
+
export const DEFAULT_PEER_LIMIT_COOLDOWN_MS = 10 * 60_000;
|
|
5
8
|
export const DEFAULT_PEER_HEAT_MS = 5 * 60_000;
|
|
6
9
|
export const DEFAULT_MAX_HOPS = 3;
|
|
7
10
|
export const DEFAULT_BROADBAND_STALE_MS = 90_000;
|
|
@@ -45,27 +45,42 @@ export const raccoonQrPollUrl = () => `${RACCOON_ORIGIN}${RACCOON_IAM_PREFIX}/au
|
|
|
45
45
|
export const raccoonQrCancelUrl = () => `${RACCOON_ORIGIN}${RACCOON_IAM_PREFIX}/auth/mp/qrcode`;
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
|
-
* 密码登录(手机号 +
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* `
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
48
|
+
* 密码登录(手机号 + 密码)—— **走官方前端同款 web 端点,参数须 AES 加密**。
|
|
49
|
+
*
|
|
50
|
+
* 端点选择(2026-10-18 真实账号实测三选一):
|
|
51
|
+
* `POST /api/web/auth/v1/login_with_password`(官方前端 `loginWithPassword` 同款)
|
|
52
|
+
* → 200 `{code:0, data:{access_token, refresh_token}}` ✅ 与 refresh/chat/credits 同族,且给 refresh_token
|
|
53
|
+
* `POST /api/iam/v1/auth/login/password/phone`
|
|
54
|
+
* → 200 `{code:"OK", data:{access_token, token_type, expires_at}}` —— **无 refresh_token**,
|
|
55
|
+
* token 一过期就得重新输密码,对多号长期使用是硬伤,故弃用。
|
|
56
|
+
* 两处都要求 phone/password **加密**(算法与 key 见 `crypto.js`);明文一律回
|
|
57
|
+
* `AUTH_ACCOUNT_OR_PASSWORD_INVALID`(用「密码错」的口径掩盖「格式错」,极难自查)。
|
|
58
|
+
*
|
|
59
|
+
* body 契约:`{ nation_code: "86", phone: <加密>, password: <加密> }`
|
|
60
|
+
* · nation_code 是**明文数字字符串**(数字 86 被判 body invalid)
|
|
61
|
+
* · phone / password 各自 Base64(IV ‖ AES-128-CFB 密文) —— 见 crypto.js
|
|
57
62
|
* ⚠ 密码只在此处作为请求体出现,**绝不落盘、绝不进日志**。
|
|
58
63
|
*/
|
|
59
|
-
export const raccoonPasswordLoginUrl = () => `${RACCOON_ORIGIN}${
|
|
64
|
+
export const raccoonPasswordLoginUrl = () => `${RACCOON_ORIGIN}${RACCOON_AUTH_PREFIX}/login_with_password`;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 登录参数加密 key(AES-128-CFB)。**上游公开常量**,打包在任意客户端里(app.asar 原文)。
|
|
68
|
+
* 照抄它只为对齐协议;传输保密性来自 HTTPS,不来自这个 key。
|
|
69
|
+
*/
|
|
70
|
+
export const RACCOON_PASSWORD_ENC_KEY = "senseraccoon2023";
|
|
60
71
|
|
|
61
72
|
/**
|
|
62
73
|
* 密码登录失败码 → 人话。命中即抛「可操作」的错误,未命中一律透出上游 message。
|
|
63
|
-
*
|
|
74
|
+
* 表里只放**本机实测见过**的码,不臆造(上游加了新码就走 default 兜底透出原文)。
|
|
75
|
+
* 200004 —— web 通道(本项目所用)实测:错误密码回
|
|
76
|
+
* `{code:200004,message:"phone_or_password_verify_error"}`;
|
|
77
|
+
* 该 message 是英文码不是人话,必须靠本表翻译。
|
|
78
|
+
* AUTH_ACCOUNT_OR_PASSWORD_INVALID —— IAM 通道同义码(本项目已不用该端点,保留以防回退)。
|
|
79
|
+
* COMMON_INVALID_ARGUMENT —— 缺 nation_code/空字段实测(IAM 通道)。
|
|
64
80
|
*/
|
|
65
81
|
export const RACCOON_PASSWORD_ERRORS = {
|
|
82
|
+
200004: "手机号或密码不正确",
|
|
66
83
|
AUTH_ACCOUNT_OR_PASSWORD_INVALID: "手机号或密码不正确",
|
|
67
|
-
AUTH_ACCOUNT_NOT_FOUND: "该手机号未注册过小浣熊(先去官网注册,或改用扫码登录)",
|
|
68
|
-
AUTH_ACCOUNT_LOCKED: "账号已被锁定,请到官网处理后再试",
|
|
69
84
|
COMMON_INVALID_ARGUMENT: "登录参数不完整(需要 国家码 + 手机号 + 密码)",
|
|
70
85
|
};
|
|
71
86
|
|
|
@@ -126,7 +126,7 @@ async function readBills(opts) {
|
|
|
126
126
|
/**
|
|
127
127
|
* 领取「桌面端登录奖励」(每号一次性)。返回三种非抛错结果之一:
|
|
128
128
|
* - `{ claimed: true, points }` 本次真到账(账单里出现了登录奖励那笔)
|
|
129
|
-
* - `{ claimed: false, alreadyClaimed: true, points }`
|
|
129
|
+
* - `{ claimed: false, alreadyClaimed: true, points }` 历史已领(账单查到,或上游回 `granted:false`)
|
|
130
130
|
* - `{ claimed: false, phantom: true, points: 0 }` 上游回了 code:0 但账单没有这笔 —— 虚回,如实报未到账
|
|
131
131
|
* - `{ claimed: false, unverified: true, points: 0 }` 账单查不动,无法确认到账(不猜、不谎报)
|
|
132
132
|
* grant 请求本身失败(业务码非 0)且账单也查不到已领 → 抛错(如实透出)。
|
|
@@ -145,7 +145,17 @@ export async function claimRaccoonLoginReward({ credential, fetchImpl = fetch, e
|
|
|
145
145
|
envelope = { code: -1, message: error?.message ?? String(error), status: 0, data: undefined };
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
//
|
|
148
|
+
// 上游用 `data.granted` 直接回答「这次到底发没发」—— 这是比账单更权威的判据,优先采信。
|
|
149
|
+
// `granted === false` ⇒ 本号此前已发过(一次性礼包),**不是「未到账」**。
|
|
150
|
+
// 为什么不能只靠账单:重度使用的号(如 daemon 正在跑的那个)账单会被「对话消耗」刷满,
|
|
151
|
+
// 分页窗口里根本翻不到那条陈年 reward_grant —— 此时只信账单就会把「早就领过」
|
|
152
|
+
// 误报成「上游回执成功但账单查不到 → 未到账」(实测踩到,教训)。
|
|
153
|
+
// 官方前端同款判据:granted 为真才弹「积分已到账」,为假就静默(见 app.asar grantDesktopLoginPoints 调用点)。
|
|
154
|
+
if (envelope?.code === 0 && envelope.data?.granted === false) {
|
|
155
|
+
return { claimed: false, alreadyClaimed: true, points: RACCOON_LOGIN_REWARD_POINTS };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// 未给 granted 判据(如上游老版本/测试桩)时回退账单复查 —— 到账凭据仍以账单为准。
|
|
149
159
|
const after = await readBills(opts);
|
|
150
160
|
const landed = after === undefined ? undefined : findLoginRewardInBills(after);
|
|
151
161
|
if (landed) {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// raccoon 登录参数加密(手机号 / 密码)—— 单一源。
|
|
2
|
+
//
|
|
3
|
+
// 取证(2026-10-18:app.asar webpack module 68284 原文 + 本机真实账号实测):
|
|
4
|
+
// 官方登录页拿到输入后先调 `U.yv()` 再拼 body,而 `yv` 的真身就是这段:
|
|
5
|
+
// function c(e, t = "senseraccoon2023") {
|
|
6
|
+
// const a = CryptoJS.enc.Utf8.parse(t);
|
|
7
|
+
// const i = CryptoJS.lib.WordArray.random(16); // 随机 IV
|
|
8
|
+
// const r = CryptoJS.AES.encrypt(e, a, { iv: i, mode: CFB, padding: NoPadding });
|
|
9
|
+
// return i.concat(r.ciphertext).toString(CryptoJS.enc.Base64);
|
|
10
|
+
// }
|
|
11
|
+
// 即 **key 与模式都硬编码在前端**:AES-128-CFB、key = "senseraccoon2023"、无 padding,
|
|
12
|
+
// 输出 Base64(IV(16 字节) ‖ 密文)。手机号与密码走的是同一个函数 —— 两个字段都要加密。
|
|
13
|
+
//
|
|
14
|
+
// 为什么必须加密(否则极难自查):明文发送时服务端解得空串再去比对,一律回
|
|
15
|
+
// `AUTH_ACCOUNT_OR_PASSWORD_INVALID`("手机号或密码不正确")—— 用「密码错」的口径
|
|
16
|
+
// 掩盖了「格式错」。本机用真实账号取证:明文 → 401;按本算法加密 → 200。
|
|
17
|
+
// 且只加密手机号、密码留明文也是 401,可证**两个字段都加密**。
|
|
18
|
+
//
|
|
19
|
+
// ⚠ 这个 key 是**上游公开常量**(打包进任意客户端),不是本项目的机密;照抄它只为对齐协议,
|
|
20
|
+
// 不代表传输具备保密性 —— 真正的保护来自 HTTPS,以及本项目「密码不落盘/不打印」的纪律。
|
|
21
|
+
import { createCipheriv, randomBytes } from "node:crypto";
|
|
22
|
+
import { RACCOON_PASSWORD_ENC_KEY } from "./const.js";
|
|
23
|
+
|
|
24
|
+
const IV_BYTES = 16;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 加密单个登录字段(手机号或密码)→ Base64(IV ‖ AES-128-CFB 密文)。
|
|
28
|
+
*
|
|
29
|
+
* 与上游前端**逐位对齐**:同 key、同模式(CFB128,对齐 CryptoJS 的 `mode.CFB`)、
|
|
30
|
+
* 同 padding(NoPadding —— CFB 是流模式,密文长度恒等于明文长度)、同拼接顺序。
|
|
31
|
+
* 每次调用生成新的随机 IV(上游亦然):同一输入两次调用密文不同,这是正确行为。
|
|
32
|
+
*/
|
|
33
|
+
export function raccoonEncryptField(plain, keyStr = RACCOON_PASSWORD_ENC_KEY) {
|
|
34
|
+
const key = Buffer.from(String(keyStr ?? ""), "utf8");
|
|
35
|
+
if (key.length !== 16) throw new Error("raccoon: 登录加密 key 必须是 16 字节(AES-128)");
|
|
36
|
+
const iv = randomBytes(IV_BYTES);
|
|
37
|
+
const cipher = createCipheriv("aes-128-cfb", key, iv);
|
|
38
|
+
const body = Buffer.concat([
|
|
39
|
+
cipher.update(Buffer.from(String(plain ?? ""), "utf8")),
|
|
40
|
+
cipher.final(),
|
|
41
|
+
]);
|
|
42
|
+
return Buffer.concat([iv, body]).toString("base64");
|
|
43
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// raccoon 密码登录(手机号 + 密码)—— 多账号自动化添加的捷径(免扫码)。
|
|
2
|
-
//
|
|
2
|
+
// 契约、端点选择理由与「为什么必须加密」见 const.js 的 raccoonPasswordLoginUrl 注释;
|
|
3
|
+
// 加密算法与 key 见 crypto.js(单一源)。
|
|
3
4
|
//
|
|
4
|
-
// ⚠
|
|
5
|
+
// ⚠ 安全红线:密码**只作为请求体出现一次**(且出站前已被加密)。本模块不落盘、不打印、不记日志;
|
|
5
6
|
// 抛出的错误里也绝不带上请求体原文(上游 message 是它自己的文案,安全)。
|
|
6
7
|
import {
|
|
7
8
|
RACCOON_PASSWORD_ERRORS,
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
import { raccoonAnonymousHeaders } from "./headers.js";
|
|
12
13
|
import { isRaccoonOk, parseRaccoonEnvelope, raccoonEnvelopeText } from "./envelope.js";
|
|
13
14
|
import { decodeJwtExpMs } from "./auth.js";
|
|
15
|
+
import { raccoonEncryptField } from "./crypto.js";
|
|
14
16
|
|
|
15
17
|
/** nation_code 归一:接受 `"86"` / `"+86"` / `86`,统一成不带加号的数字串。 */
|
|
16
18
|
export function normalizeNationCode(raw) {
|
|
@@ -39,6 +41,9 @@ function passwordLoginError(envelope, status) {
|
|
|
39
41
|
* 返回 `{ access_token, refresh_token, expires_at?, office_identity? }`;
|
|
40
42
|
* 失败抛「可操作」的错误(码表命中给人话,未命中透出上游文案)。
|
|
41
43
|
* 本地校验先于出网:手机号/密码为空当场报错,不白跑一次请求。
|
|
44
|
+
*
|
|
45
|
+
* ⚠ 出站前 phone/password 都经 `raccoonEncryptField` 加密(上游契约,见 const.js)——
|
|
46
|
+
* 发明文只会换来一句「手机号或密码不正确」,把格式错误伪装成密码错误。
|
|
42
47
|
*/
|
|
43
48
|
export async function loginRaccoonWithPassword({ phone, password, nationCode = "86" } = {}, {
|
|
44
49
|
fetchImpl = fetch,
|
|
@@ -51,12 +56,20 @@ export async function loginRaccoonWithPassword({ phone, password, nationCode = "
|
|
|
51
56
|
if (!ph) throw new Error("raccoon: 手机号不能为空");
|
|
52
57
|
if (String(password ?? "").length === 0) throw new Error("raccoon: 密码不能为空");
|
|
53
58
|
|
|
59
|
+
// 未知错误码时透出上游 message 而非裸码 —— 上游新旧通道对同一错误的形态不同:
|
|
60
|
+
// 新 IAM 把 message 放顶层(AUTH_ACCOUNT_OR_PASSWORD_INVALID),旧 web 只有 message/details。
|
|
61
|
+
const body = {
|
|
62
|
+
nation_code: nc,
|
|
63
|
+
phone: raccoonEncryptField(ph),
|
|
64
|
+
password: raccoonEncryptField(String(password)),
|
|
65
|
+
};
|
|
66
|
+
|
|
54
67
|
let res;
|
|
55
68
|
try {
|
|
56
69
|
res = await fetchImpl(raccoonPasswordLoginUrl(), {
|
|
57
70
|
method: "POST",
|
|
58
71
|
headers: raccoonAnonymousHeaders({ env }),
|
|
59
|
-
body: JSON.stringify(
|
|
72
|
+
body: JSON.stringify(body),
|
|
60
73
|
signal: AbortSignal.timeout(timeoutMs),
|
|
61
74
|
});
|
|
62
75
|
} catch (error) {
|
|
@@ -70,6 +83,8 @@ export async function loginRaccoonWithPassword({ phone, password, nationCode = "
|
|
|
70
83
|
throw new Error(`raccoon: 密码登录响应不是 JSON(HTTP ${res.status})`);
|
|
71
84
|
}
|
|
72
85
|
const envelope = parseRaccoonEnvelope(parsed, res.status);
|
|
86
|
+
// 上游把错误码同时塞在 `code` 与 `message`:web 通道 `{code:"AUTH_...",message:"AUTH_..."}`
|
|
87
|
+
// 时 message 是裸码,不能当人话用 —— 故这里把 code 也纳入,让码表能命中。
|
|
73
88
|
if (!isRaccoonOk(envelope)) throw passwordLoginError(envelope, res.status);
|
|
74
89
|
|
|
75
90
|
const data = envelope.data ?? {};
|
package/src/routes/helpers.js
CHANGED
|
@@ -44,13 +44,23 @@ export function notFound(res) {
|
|
|
44
44
|
return json(res, 404, { error: "Not Found" });
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// 入站请求体读取:**必须先收齐字节再一次性 decode**。
|
|
48
|
+
//
|
|
49
|
+
// 不要写成 `let data = ""; req.on("data", c => data += c)`:`c` 是 Buffer,`+=` 会按**每个 TCP 块**
|
|
50
|
+
// 各自隐式 `toString("utf8")` —— 多字节字符(绝大多数汉字/emoji)被切在块中间时,两半各自解成
|
|
51
|
+
// `U+FFFD`「�」。切块位置随网络随机 ⇒ **同一份请求体不同轮次坏的位置不同**,而这份 body 会原样
|
|
52
|
+
// 转发给上游:从坏点起的字节前缀每轮都变,上游的前缀缓存从那里起永远命中不了。
|
|
53
|
+
// 实测(2026-10-18,raccoon 会话 talk/full 语料):同一会话相邻两轮的同一条历史消息里
|
|
54
|
+
// 「个」↔「�」来回跳(成 U+FFFD),首差异落在历史 21%–43% 处;命中一次的对照实验里
|
|
55
|
+
// 同前缀两连发 cache_read=384/429,说明上游缓存本来是好的、是被我们这份坏字节废掉的。
|
|
47
56
|
export function readBody(req) {
|
|
48
57
|
return new Promise((resolve, reject) => {
|
|
49
|
-
|
|
50
|
-
req.on("data", (c) => (
|
|
58
|
+
const chunks = [];
|
|
59
|
+
req.on("data", (c) => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(String(c), "utf8")));
|
|
51
60
|
req.on("end", () => {
|
|
52
61
|
try {
|
|
53
|
-
|
|
62
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
63
|
+
resolve(text ? JSON.parse(text) : {});
|
|
54
64
|
} catch (err) {
|
|
55
65
|
reject(err);
|
|
56
66
|
}
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
} from "../anthropic/translate.js";
|
|
24
24
|
import { estimateTokens } from "../anthropic/count-tokens.js";
|
|
25
25
|
import { sseToChatJson } from "../anthropic/sse-aggregate.js";
|
|
26
|
+
import { claudeSessionId } from "../anthropic/session-id.js";
|
|
26
27
|
import {
|
|
27
28
|
detectSearchRequest,
|
|
28
29
|
runWebSearch,
|
|
@@ -308,6 +309,15 @@ export async function messagesHandler(ctx) {
|
|
|
308
309
|
}));
|
|
309
310
|
const pipeline = createChatPipeline(ctx);
|
|
310
311
|
const fakeReq = withBody(req, chatBody);
|
|
312
|
+
// 会话身份(照抄 cc-switch session.rs:107-231):Claude Code 把会话 id 放在**请求体 metadata**里
|
|
313
|
+
// (metadata.user_id 的 JSON session_id / 旧 `_session_` 后缀 / metadata.session_id),只读 header 会漏,
|
|
314
|
+
// 上游的缓存亲和与粘性路由就拿不到稳定 key ⇒ 每轮换 key。这里补进既有的 x-session-id 通道
|
|
315
|
+
// (chat-pipeline 已消费该头;不改管线,只影响 /v1/messages)。
|
|
316
|
+
const sid = claudeSessionId(req.headers, body);
|
|
317
|
+
if (sid) {
|
|
318
|
+
fakeReq.headers = { ...fakeReq.headers, "x-session-id": fakeReq.headers?.["x-session-id"] || sid.sessionId };
|
|
319
|
+
alog("session", JSON.stringify({ source: sid.source, id: sid.sessionId.slice(0, 12) }));
|
|
320
|
+
}
|
|
311
321
|
// ADR-0055:请求侧 token 估算 thunk(路由层也 memoize,与决策 4「每请求至多算一次」对齐;
|
|
312
322
|
// translator 内部还有一层 memoize,双保险)。
|
|
313
323
|
let estCache;
|
|
@@ -336,11 +346,11 @@ export async function messagesHandler(ctx) {
|
|
|
336
346
|
}
|
|
337
347
|
// 上游偶发非 chat 形状(无 choices):包成单 text 块,绝不 500(照 responses-route.js 先例)
|
|
338
348
|
if (!Array.isArray(chatJson.choices)) {
|
|
339
|
-
const r = chatJsonToAnthropic({ choices: [{ message: { content: String(text).slice(0, 8000) } }] }, chatBody.model, { estInput });
|
|
349
|
+
const r = chatJsonToAnthropic({ choices: [{ message: { content: String(text).slice(0, 8000) } }] }, chatBody.model, { estInput, thinking: emitThinking() });
|
|
340
350
|
alog("done-nonchat", JSON.stringify({ ms: Date.now() - t0, bytes: text.length }));
|
|
341
351
|
return json(res, 200, r);
|
|
342
352
|
}
|
|
343
|
-
const out = chatJsonToAnthropic(chatJson, chatBody.model, { estInput });
|
|
353
|
+
const out = chatJsonToAnthropic(chatJson, chatBody.model, { estInput, thinking: emitThinking() });
|
|
344
354
|
alog("done-json", JSON.stringify({ ms: Date.now() - t0, status, blocks: out.content.length, stop: out.stop_reason, usage: out.usage }));
|
|
345
355
|
return json(res, 200, out);
|
|
346
356
|
}
|
package/src/routes/peers.js
CHANGED
|
@@ -5,7 +5,6 @@ import { runHook } from "../plugins.js";
|
|
|
5
5
|
import { buildShareKeysHeader, SHARE_KEYS_HEADER } from "../providers/share-keys.js";
|
|
6
6
|
import { compatFetch, timeoutSignal, getUndici } from "../compat.js";
|
|
7
7
|
|
|
8
|
-
const PEER_TIMEOUT_MS = 30_000;
|
|
9
8
|
// peer 阶段总预算:三批候选(ordered / orderedByLastError / coolingByLastError)共享同一个
|
|
10
9
|
// 截止时间,而不是每批各吃满一次等头超时。实测最坏路径是三批串行各 30s(≈90s),
|
|
11
10
|
// 客户端表现为"随机转圈";预算到点即停,把余下时间还给本地兜底。
|
|
@@ -21,6 +20,15 @@ import { summarizeRequest } from "../model-trace.js";
|
|
|
21
20
|
const PEER_BODY_TIMEOUT_MS = 120_000;
|
|
22
21
|
export { PEER_BODY_TIMEOUT_MS };
|
|
23
22
|
|
|
23
|
+
// undici dispatcher 的「收到响应头」时限(headersTimeout)。**必须与 bodyTimeout 同档**:组员节点
|
|
24
|
+
// 自己也压着响应头等首块(ADR-0043 首块前不 flush),undici 层若是 30s,连接会在组建还没吐头时被
|
|
25
|
+
// 掐成 `fetch failed (UND_ERR_HEADERS_TIMEOUT)` —— 实测三个组员全部精确卡 ~30620ms abort,组网
|
|
26
|
+
// 兜底退化成「每次白等 30s」(BUG-1 的 dispatcher 层残留:当时只改了应用层 abort 定时器,漏了这行)。
|
|
27
|
+
// 提到 120s 不会让请求真的挂 120s:真正的闸门是应用层 peer 阶段总预算(peerStageCtx,缺省 45s),
|
|
28
|
+
// 到点即 abort;undici 只做最后兜底,与本文件 PEER_BODY_TIMEOUT_MS / forwardToPeer 的等待上限同口径。
|
|
29
|
+
const PEER_HEADERS_TIMEOUT_MS = PEER_BODY_TIMEOUT_MS;
|
|
30
|
+
export { PEER_HEADERS_TIMEOUT_MS };
|
|
31
|
+
|
|
24
32
|
function peerConnectTimeoutMs() {
|
|
25
33
|
const n = Number(process.env.MSLXDFF_PEER_CONNECT_TIMEOUT_MS);
|
|
26
34
|
return Number.isInteger(n) && n > 0 ? n : DEFAULT_PEER_CONNECT_TIMEOUT_MS;
|
|
@@ -52,14 +60,14 @@ export function peerStageCtx(ctx = {}) {
|
|
|
52
60
|
// 连接级超时(DNS + TCP/TLS 握手):黑洞节点(端口挂起)必须快速失败,
|
|
53
61
|
// 不能占用 30s 的响应超时(曾把一次请求拖到 92s)。仅 undici 可用时生效。
|
|
54
62
|
let peerDispatcher = null;
|
|
55
|
-
function getPeerDispatcher() {
|
|
63
|
+
export function getPeerDispatcher() {
|
|
56
64
|
if (peerDispatcher) return peerDispatcher;
|
|
57
65
|
const { Agent } = getUndici();
|
|
58
66
|
if (!Agent) return null;
|
|
59
67
|
try {
|
|
60
68
|
peerDispatcher = new Agent({
|
|
61
69
|
connect: { timeout: peerConnectTimeoutMs() },
|
|
62
|
-
headersTimeout:
|
|
70
|
+
headersTimeout: PEER_HEADERS_TIMEOUT_MS,
|
|
63
71
|
bodyTimeout: PEER_BODY_TIMEOUT_MS,
|
|
64
72
|
keepAliveTimeout: 30_000,
|
|
65
73
|
keepAliveMaxTimeout: 60_000,
|
|
@@ -68,6 +76,28 @@ function getPeerDispatcher() {
|
|
|
68
76
|
return peerDispatcher;
|
|
69
77
|
}
|
|
70
78
|
|
|
79
|
+
/**
|
|
80
|
+
* 保热:用**组员转发同一条连接池**(getPeerDispatcher)打 GET /health,把 (本机→组员) 的连接
|
|
81
|
+
* 保持在温热、且**正是 chat 会复用的那条 socket**(裸 fetch 的全局池热的是另一条,等于白热)。
|
|
82
|
+
* /health 无鉴权、无副作用、零额度。绝不抛;返回 {ok}。
|
|
83
|
+
*/
|
|
84
|
+
export async function warmPeerHealth(url, { timeoutMs = 5000 } = {}) {
|
|
85
|
+
const base = String(url || "").replace(/\/+$/, "");
|
|
86
|
+
if (!base) return { ok: false, error: "no url" };
|
|
87
|
+
const dispatcher = getPeerDispatcher();
|
|
88
|
+
try {
|
|
89
|
+
const res = await compatFetch(`${base}/health`, {
|
|
90
|
+
headers: { Accept: "application/json" },
|
|
91
|
+
signal: timeoutSignal(timeoutMs),
|
|
92
|
+
...(dispatcher ? { dispatcher } : {}),
|
|
93
|
+
});
|
|
94
|
+
try { if (res.body) await res.text().catch(() => {}); } catch {}
|
|
95
|
+
return { ok: Boolean(res?.ok), status: res?.status };
|
|
96
|
+
} catch (e) {
|
|
97
|
+
return { ok: false, error: errMsg(e) };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
71
101
|
// 跨 caller 中继必须剥离 reasoning 加密态:encrypted_content 由上游按 caller(出口)签发,
|
|
72
102
|
// 换个节点转发会被拒(400 "reasoning encrypted_content was not issued to this caller")。
|
|
73
103
|
// 明文 reasoning_content 保留(不绑定 caller,thinking 模式需要)。
|
package/src/runtime/bootstrap.js
CHANGED
|
@@ -37,6 +37,14 @@ export async function startDaemonMain(VERSION) {
|
|
|
37
37
|
startGroupSync({ peers: ctx.peers, groups: ctx.groups, logs });
|
|
38
38
|
startBroadband({ token: ctx.token, upstream: ctx.upstream });
|
|
39
39
|
|
|
40
|
+
// 保热心跳:每 ≤120s 经 **chat 同池**访问每个组员(/health) + 每个 picks 供应商(preheat),
|
|
41
|
+
// 防「久未使用 → 首发 fetch failed / 上游冷启动」(现网 2026-10-18 16:16 三组员同时 fetch failed)。
|
|
42
|
+
{
|
|
43
|
+
const { startKeepWarm } = await import("./keep-warm.js");
|
|
44
|
+
const { loadModelPicks } = await import("../state.js");
|
|
45
|
+
startKeepWarm({ peers: ctx.peers, providers: ctx.providers, upstream: ctx.upstream, logs, loadModelPicks: () => loadModelPicks({}) });
|
|
46
|
+
}
|
|
47
|
+
|
|
40
48
|
const { setupAutoUpdate } = await import("./auto-update.js");
|
|
41
49
|
setupAutoUpdate({ VERSION, bus, logs });
|
|
42
50
|
|