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
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// 保热心跳(用户 2026-10-18 拍板):每 ≤120s 主动访问一次「每个组员 + 每个在 picks 的上游供应商」,
|
|
2
|
+
// 保持连接热度,防「久未使用 → 首发 fetch failed / 上游冷启动 26s」。
|
|
3
|
+
//
|
|
4
|
+
// ⚠ 必须用**与 chat 完全相同的那条连接池**,否则白热(用户 2026-10-18 纠正):
|
|
5
|
+
// - 组员 → warmPeerHealth()(getPeerDispatcher,正是 peer 转发复用的池);
|
|
6
|
+
// - 供应商 → provider.preheat()(各家 preheat 经各自 agent/extDispatcher 打自己的 models 端点,
|
|
7
|
+
// 与 chat 同池:opencode 走 v6/warp 池、generic/custom 走各自 agent)。
|
|
8
|
+
// 绝不能裸 compatFetch / directProbe——那热的是全局池的 socket,真流量复用的是另一条,等于没热。
|
|
9
|
+
//
|
|
10
|
+
// 为什么需要它(现网取证 2026-10-18 16:16,req mvdjwol5-aysc):三个组员同时 `fetch failed`(TCP 都没建立),
|
|
11
|
+
// 根因是两端 keep-alive 打架——服务端 Node 默认 5s 掐闲置连接,而组长 undici 池账面 30s 内以为 socket 还活着,
|
|
12
|
+
// 复用死 socket 即失败。已把服务端 keepAliveTimeout 提到 65s(server.js)治本;本心跳是**补强**:
|
|
13
|
+
// ① 间隔 120s < 家里 NAT/防火墙更短的空闲回收窗(很多路由 60~120s 就丢映射,单靠 65s keep-alive 挡不住);
|
|
14
|
+
// ② 顺带让上游对我们出口保持"温热"(opencode 实测固定 session 1.2s、冷机器 26s+)。
|
|
15
|
+
//
|
|
16
|
+
// 零额度消耗(全部经各家 preheat:GET models / GET /health,不打 chat)。永不抛,只记事件。
|
|
17
|
+
import { warmPeerHealth } from "../routes/peers.js";
|
|
18
|
+
|
|
19
|
+
export function keepWarmIntervalMs(env = process.env) {
|
|
20
|
+
const n = Number(env.MSLXDFF_KEEP_WARM_MS);
|
|
21
|
+
return Number.isInteger(n) && n >= 0 ? n : 120_000; // 0 = 关闭
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 从 picks 里算出要保热的「供应商头」集合:裸 id(无 `/`)归 opencode(免费池);带前缀取首段。
|
|
26
|
+
* 只保留出现过的供应商,天然覆盖"每个在 picks 的接口"。opencode 恒排第一(免费池是主力、最该保热)。
|
|
27
|
+
* @param {string[]} picks
|
|
28
|
+
* @returns {string[]} 供应商 id 列表(含 "opencode"),已去重、opencode 置顶
|
|
29
|
+
*/
|
|
30
|
+
export function providerHeadsFromPicks(picks = []) {
|
|
31
|
+
const heads = new Set();
|
|
32
|
+
for (const id of picks) {
|
|
33
|
+
const s = String(id || "").trim();
|
|
34
|
+
if (!s) continue;
|
|
35
|
+
heads.add(s.includes("/") ? s.slice(0, s.indexOf("/")) : "opencode");
|
|
36
|
+
}
|
|
37
|
+
const out = [...heads];
|
|
38
|
+
if (out.includes("opencode")) return ["opencode", ...out.filter((h) => h !== "opencode")];
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 单轮保热:组员各 warmPeerHealth、picks 供应商各 preheat。返回汇总(供事件取证)。绝不抛。
|
|
44
|
+
* @param {{peers:{url:string}[], picks:string[], byId?:Map<string,{preheat?:Function}>, upstream?:{preheat?:Function},
|
|
45
|
+
* warmPeer?:Function, evt?:Function}} deps
|
|
46
|
+
*/
|
|
47
|
+
export async function warmOnce({
|
|
48
|
+
peers = [],
|
|
49
|
+
picks = [],
|
|
50
|
+
byId = null,
|
|
51
|
+
upstream = null,
|
|
52
|
+
warmPeer = warmPeerHealth,
|
|
53
|
+
evt = () => {},
|
|
54
|
+
} = {}) {
|
|
55
|
+
const staticPeers = peers.filter((p) => p?.url && !String(p.url).startsWith("relay://")); // broadband 走 relay 队列,不直连
|
|
56
|
+
let peerOk = 0, peerFail = 0;
|
|
57
|
+
for (const p of staticPeers) {
|
|
58
|
+
try {
|
|
59
|
+
const r = await warmPeer(String(p.url));
|
|
60
|
+
if (r?.ok) peerOk += 1; else peerFail += 1;
|
|
61
|
+
} catch { peerFail += 1; }
|
|
62
|
+
}
|
|
63
|
+
const heads = providerHeadsFromPicks(picks);
|
|
64
|
+
let provOk = 0, provFail = 0, provSkip = 0;
|
|
65
|
+
for (const head of heads) {
|
|
66
|
+
try {
|
|
67
|
+
// 供应商预热一律走它自己的 preheat(chat 同池)。opencode 特判:**多 provider 装配**下
|
|
68
|
+
// providers 数组里含 opencode,直接 byId 取;单 provider 模式下 byId 为空,退回 upstream.preheat。
|
|
69
|
+
const inst = byId?.get?.(head);
|
|
70
|
+
const preheat = inst?.preheat || (head === "opencode" ? upstream?.preheat : null);
|
|
71
|
+
if (typeof preheat !== "function") { provSkip += 1; continue; } // 本地号型/无域名供应商:本就没有可保热的域
|
|
72
|
+
const r = await preheat.call(inst || upstream);
|
|
73
|
+
if (r?.ok) provOk += 1; else provFail += 1;
|
|
74
|
+
} catch { provFail += 1; }
|
|
75
|
+
}
|
|
76
|
+
evt("keep-warm", { peers: staticPeers.length, peerOk, peerFail, providers: heads.length, provOk, provFail, provSkip });
|
|
77
|
+
return { peers: staticPeers.length, peerOk, peerFail, providers: heads.length, provOk, provFail, provSkip };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* daemon 装配:起一个 unref 的定时器,每 keepWarmIntervalMs 跑一轮 warmOnce。
|
|
82
|
+
* 关(=0)返回 null。依赖注入以便测试;生产用 setupProviders 的 ctx(providers/upstream/peers)。
|
|
83
|
+
*/
|
|
84
|
+
export function startKeepWarm({
|
|
85
|
+
peers,
|
|
86
|
+
providers = [],
|
|
87
|
+
upstream,
|
|
88
|
+
env = process.env,
|
|
89
|
+
evt = () => {},
|
|
90
|
+
logs,
|
|
91
|
+
loadModelPicks,
|
|
92
|
+
warmPeer = warmPeerHealth,
|
|
93
|
+
} = {}) {
|
|
94
|
+
const interval = keepWarmIntervalMs(env);
|
|
95
|
+
if (interval === 0) return null;
|
|
96
|
+
const byId = new Map((providers || []).map((p) => [p.id, p]));
|
|
97
|
+
const emit = (type, data) => {
|
|
98
|
+
const entry = { ts: Date.now(), type, ...data };
|
|
99
|
+
try { evt(type, data); } catch {}
|
|
100
|
+
try { logs?.appendEvent?.(entry); } catch {}
|
|
101
|
+
};
|
|
102
|
+
const runTick = async () => {
|
|
103
|
+
try {
|
|
104
|
+
const picks = loadModelPicks ? loadModelPicks() : [];
|
|
105
|
+
await warmOnce({
|
|
106
|
+
peers: peers?.all?.() || [],
|
|
107
|
+
picks: Array.isArray(picks) ? picks : [],
|
|
108
|
+
byId,
|
|
109
|
+
upstream,
|
|
110
|
+
warmPeer,
|
|
111
|
+
evt: emit,
|
|
112
|
+
});
|
|
113
|
+
} catch { /* 保热永不影响主链路 */ }
|
|
114
|
+
};
|
|
115
|
+
console.log(`[keep-warm] enabled: every ${Math.round(interval / 1000)}s 经 chat 同池访问每个组员(/health) + 每个 picks 供应商(preheat) 保热,设 MSLXDFF_KEEP_WARM_MS=0 关`);
|
|
116
|
+
void runTick(); // 启动即热一次,别等第一个间隔
|
|
117
|
+
const timer = setInterval(() => { void runTick(); }, interval);
|
|
118
|
+
timer.unref?.();
|
|
119
|
+
return { stop: () => clearInterval(timer), interval, tick: runTick };
|
|
120
|
+
}
|
package/src/server.js
CHANGED
|
@@ -11,6 +11,13 @@ export function startServer({ router, signals = true, host, onBeforeClose }, por
|
|
|
11
11
|
res.end(JSON.stringify({ error: String(err?.message || err) }));
|
|
12
12
|
});
|
|
13
13
|
});
|
|
14
|
+
// 空闲 keep-alive 连接必须活得比**客户端池**(打我们的组员/peer 用 undici keepAliveTimeout=30s,
|
|
15
|
+
// routes/peers.js:64)更久,否则出现两端超时打架:服务端 Node 默认 5s 就单方面掐掉闲置连接,
|
|
16
|
+
// 而组长池里的 socket 账面上还活着(<30s),下一次真请求复用死 socket ⇒ `fetch failed`
|
|
17
|
+
//(现网 2026-10-18 16:16:三个组员同时 fetch failed,peer-race 全灭、收场 502——间歇性、下一发又好的形态)。
|
|
18
|
+
// 65s = 30s 客户端余量 + 一倍缓冲;headersTimeout 必须 > keepAliveTimeout(Node 断言要求),取 70s。
|
|
19
|
+
server.keepAliveTimeout = Number(process.env.MSLXDFF_KEEPALIVE_TIMEOUT_MS) || 65_000;
|
|
20
|
+
server.headersTimeout = Number(process.env.MSLXDFF_HEADERS_TIMEOUT_MS) || 70_000;
|
|
14
21
|
|
|
15
22
|
const ready = () =>
|
|
16
23
|
new Promise((resolve, reject) => {
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 上游 usage 里的「缓存读数」候选链 —— 全仓单一来源。
|
|
3
|
+
*
|
|
4
|
+
* 为什么独立成档:同一份上游 usage 有**两个**消费方,两边的候选顺序必须一字不差,
|
|
5
|
+
* 否则「/v1/messages 的客户端 usage」与「我们自己的日志/报表」会各说一套:
|
|
6
|
+
* · `src/anthropic/translate.js` → Anthropic 形状的 client usage;
|
|
7
|
+
* · `src/metrics.js`(extractUsageFromJson)→ detail.usage / usage/*.jsonl / -stats。
|
|
8
|
+
*
|
|
9
|
+
* 候选顺序与语义照抄 cc-switch(`providers/transform.rs:760-798`):
|
|
10
|
+
* · 命中(cache read):`cache_read_input_tokens` → `input_tokens_details.cached_tokens`
|
|
11
|
+
* → `prompt_tokens_details.cached_tokens` → `prompt_cache_hit_tokens`(DeepSeek 系)。
|
|
12
|
+
* **每环必须「存在且 > 0」才采信**:有些中转把靠前字段硬编码桩 0,真命中只出现在后面的
|
|
13
|
+
* 字段里(cc-switch #8045/#8041,否则命中被 0 掩蔽)。全链无 >0 命中 → 0(=没有可用命中读数)。
|
|
14
|
+
* · 写入(cache creation):`cache_creation_input_tokens` → `prompt_tokens_details.cache_write_tokens`
|
|
15
|
+
* → `input_tokens_details.cache_write_tokens`。这里**显式 0 也算已报**(与命中相反),
|
|
16
|
+
* 故全链无字段时返回 null 表示「上游没报」,0 表示「报了 0」。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const num = (v) => (v == null || v === "" ? null : Number.isFinite(Number(v)) ? Number(v) : null);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 缓存命中(读)token 数:候选须「存在且 >0」才采信,未发现命中返回 0。
|
|
23
|
+
* @param {any} usage 上游原始 usage 对象
|
|
24
|
+
* @returns {number}
|
|
25
|
+
*/
|
|
26
|
+
export function cacheReadTokens(usage) {
|
|
27
|
+
const u = usage && typeof usage === "object" ? usage : {};
|
|
28
|
+
const candidates = [
|
|
29
|
+
u.cache_read_input_tokens,
|
|
30
|
+
u.input_tokens_details?.cached_tokens,
|
|
31
|
+
u.prompt_tokens_details?.cached_tokens,
|
|
32
|
+
u.prompt_cache_hit_tokens,
|
|
33
|
+
];
|
|
34
|
+
for (const v of candidates) {
|
|
35
|
+
const n = num(v);
|
|
36
|
+
if (n != null && n > 0) return n;
|
|
37
|
+
}
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 缓存写入(creation)token 数:任一候选字段存在即以它为准(显式 0 也算已报);全缺返回 null。
|
|
43
|
+
* @param {any} usage 上游原始 usage 对象
|
|
44
|
+
* @returns {number|null}
|
|
45
|
+
*/
|
|
46
|
+
export function cacheWriteTokens(usage) {
|
|
47
|
+
const u = usage && typeof usage === "object" ? usage : {};
|
|
48
|
+
const candidates = [
|
|
49
|
+
u.cache_creation_input_tokens,
|
|
50
|
+
u.prompt_tokens_details?.cache_write_tokens,
|
|
51
|
+
u.input_tokens_details?.cache_write_tokens,
|
|
52
|
+
];
|
|
53
|
+
for (const v of candidates) {
|
|
54
|
+
const n = num(v);
|
|
55
|
+
if (n != null) return n;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
package/src/usage/record.js
CHANGED
|
@@ -92,6 +92,9 @@ export function recordChatUsage({ model, via, usage, ttfbMs, totalMs, tps, inter
|
|
|
92
92
|
reasoning_chars: Number.isFinite(rc) && rc > 0 ? Math.trunc(rc) : 0,
|
|
93
93
|
// 上游是否明确上报过思考 tokens(metrics.js 只在数值有限时才写 reasoning_tokens)
|
|
94
94
|
reasoning_reported: Number.isFinite(usage?.reasoning_tokens) ? 1 : 0,
|
|
95
|
+
// —— 缓存读数(候选链单一来源 = usage/cache-fields.js;metrics 投影里「字段缺失=上游没报」)——
|
|
96
|
+
cache_read_tokens: Number.isFinite(Number(usage?.cache_read_input_tokens)) ? Number(usage.cache_read_input_tokens) : null,
|
|
97
|
+
cache_creation_tokens: Number.isFinite(Number(usage?.cache_creation_input_tokens)) ? Number(usage.cache_creation_input_tokens) : null,
|
|
95
98
|
// 调用方没表态时整字段不落盘:「未知」≠「非流式」,覆盖度分母宁低不假高
|
|
96
99
|
...(stream === undefined ? {} : { stream: stream ? 1 : 0 }),
|
|
97
100
|
ttfbMs: Number.isFinite(ttfbMs) ? ttfbMs : null,
|
package/src/usage/report.js
CHANGED
|
@@ -55,6 +55,9 @@ function blank(id) {
|
|
|
55
55
|
reasoningEstimatedRows: 0,
|
|
56
56
|
reasoningChars: 0,
|
|
57
57
|
streamRequests: 0,
|
|
58
|
+
cacheReadTokens: 0,
|
|
59
|
+
cacheCreationTokens: 0,
|
|
60
|
+
cacheRows: 0,
|
|
58
61
|
};
|
|
59
62
|
}
|
|
60
63
|
|
|
@@ -77,6 +80,13 @@ function finalize(a) {
|
|
|
77
80
|
ttfSamples: a.ttfbN,
|
|
78
81
|
streamRequests: a.streamRequests,
|
|
79
82
|
avgTps: a.completionMsSum > 0 ? Number((a.tpsTokSum / (a.completionMsSum / 1000)).toFixed(1)) : null,
|
|
83
|
+
cacheReadTokens: a.cacheReadTokens,
|
|
84
|
+
cacheCreationTokens: a.cacheCreationTokens,
|
|
85
|
+
cacheRows: a.cacheRows,
|
|
86
|
+
// 命中率 = 缓存读 ÷ 输入总数(上游 prompt_tokens **含**缓存命中,故分母用输入总数;
|
|
87
|
+
// 实测佐证:cache 命中那发 prompt_tokens=429 = 客户端 input 45 + cache_read 384)。
|
|
88
|
+
// 窗口内没有任何命中读数时给 null → 渲染成 —,绝不拿 0 冒充「没缓存」。
|
|
89
|
+
cacheHitRate: a.cacheRows > 0 && a.promptTokens > 0 ? Number((a.cacheReadTokens / a.promptTokens).toFixed(4)) : null,
|
|
80
90
|
};
|
|
81
91
|
}
|
|
82
92
|
|
|
@@ -95,6 +105,10 @@ function fold(acc, r) {
|
|
|
95
105
|
acc.reasoningChars += tok(r.reasoning_chars);
|
|
96
106
|
// 首字样本分母:非流式行既无首字也不该摊进分母;缺 stream 的旧行按未知保守计入
|
|
97
107
|
if (r.stream !== 0) acc.streamRequests++;
|
|
108
|
+
// 缓存读数:只有上游真报了命中(字段存在且 >0)才算「有读数」——分母不拿未报的请求凑
|
|
109
|
+
const cr = tok(r.cache_read_tokens);
|
|
110
|
+
if (cr > 0) { acc.cacheReadTokens += cr; acc.cacheRows++; }
|
|
111
|
+
acc.cacheCreationTokens += tok(r.cache_creation_tokens);
|
|
98
112
|
|
|
99
113
|
const ttfb = ms(r.ttfbMs ?? r.ttfb_ms);
|
|
100
114
|
if (ttfb != null) {
|