mslxdff 0.1.134 → 0.1.136
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/chat-pipeline/serial-trial.js +11 -3
- package/src/cli/commands/stats.js +88 -0
- package/src/cli/commands/use-group.js +8 -4
- package/src/cli/help.js +2 -0
- package/src/cli/index.js +2 -0
- package/src/cli/status.js +4 -9
- package/src/model-capabilities/index.js +16 -3
- package/src/model-capabilities/merge.js +127 -0
- package/src/providers/classify.js +2 -2
- package/src/providers/dispatcher.js +1 -1
- package/src/providers/generic.js +47 -9
- package/src/providers/share-keys.js +1 -1
- package/src/routes/chat/relay-pipeline.js +13 -0
- package/src/routes/models-route.js +11 -1
- package/src/state/schemas/use-group.js +47 -6
- package/src/upstream-probe/rotate.js +1 -1
- package/src/usage/record.js +99 -0
- package/src/usage/report.js +152 -0
package/package.json
CHANGED
|
@@ -9,7 +9,13 @@ import { handlePeerRelay } from "../routes/chat/peer-handler.js";
|
|
|
9
9
|
import { handleBroadbandRelay } from "../routes/chat/broadband-handler.js";
|
|
10
10
|
import { handleViaRoute } from "../routes/chat/via-route-handler.js";
|
|
11
11
|
import { handleExhaustedLocal, handleExhaustedAll } from "../routes/chat/exhausted-handler.js";
|
|
12
|
-
import { shouldUseGroupForModel, isHardLocalOnly } from "../state/schemas/use-group.js";
|
|
12
|
+
import { shouldUseGroupForModel, isHardLocalOnly, isKeyProviderDirectOnly } from "../state/schemas/use-group.js";
|
|
13
|
+
|
|
14
|
+
function groupSkipReason(model) {
|
|
15
|
+
if (isHardLocalOnly(model)) return "provider local-only(禁组员,仅本机直连)";
|
|
16
|
+
if (isKeyProviderDirectOnly(model)) return "key provider default direct(仅本机直连,MSLXDFF_USE_GROUP_KEYS=1 可开组员)";
|
|
17
|
+
return "useGroup=off";
|
|
18
|
+
}
|
|
13
19
|
|
|
14
20
|
/**
|
|
15
21
|
* 串行 trial — 从 engine.js 抽出的第二段:via-route 单路径 → 串行 trial →
|
|
@@ -43,6 +49,8 @@ export async function runSerialTrial(ctx, deps = {}) {
|
|
|
43
49
|
} catch (e) {
|
|
44
50
|
evt("via-route-exception", { reqId, model: requested, error: errMsg(e) });
|
|
45
51
|
}
|
|
52
|
+
} else if (!useAuto && requested && requested.includes("/") && canForwardPeers && !lockModel && peers) {
|
|
53
|
+
evt("group-skip", { reqId, model: requested, reason: `${groupSkipReason(requested)} (via-route)` });
|
|
46
54
|
}
|
|
47
55
|
|
|
48
56
|
let lastErr = viaRouteLastErr;
|
|
@@ -143,7 +151,7 @@ export async function runSerialTrial(ctx, deps = {}) {
|
|
|
143
151
|
}
|
|
144
152
|
if (canForwardPeers) {
|
|
145
153
|
if (!shouldUseGroupForModel(model)) {
|
|
146
|
-
evt("group-skip", { reqId, model, reason:
|
|
154
|
+
evt("group-skip", { reqId, model, reason: `${groupSkipReason(model)} (peer)` });
|
|
147
155
|
} else {
|
|
148
156
|
const pr = await peerRelay({ model, body, lastErr, requested, useAuto, lockModel, auto, peers, handlerCtx, evt, logCall, mark, perf0, stages, startedAt, plugins, res });
|
|
149
157
|
if (pr.handled) return { done: true };
|
|
@@ -152,7 +160,7 @@ export async function runSerialTrial(ctx, deps = {}) {
|
|
|
152
160
|
}
|
|
153
161
|
if (groups) {
|
|
154
162
|
if (!shouldUseGroupForModel(model)) {
|
|
155
|
-
evt("group-skip", { reqId, model, reason:
|
|
163
|
+
evt("group-skip", { reqId, model, reason: `${groupSkipReason(model)} (broadband)` });
|
|
156
164
|
} else {
|
|
157
165
|
const br = await broadbandRelay({ model, body, hops, lastErr, requested, useAuto, lockModel, auto, groups, token, bus, logs, handlerCtx, evt, mark, perf0, stages, res, startedAt, plugins });
|
|
158
166
|
if (br.handled) return { done: true };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// `-stats` 模型用量报表:近 N 小时每模型 token 消耗 + 首字/总耗时/速度。
|
|
2
|
+
// 数据来自 src/usage/(逐请求 JSONL),不是 state.json 的终生 EMA —— 见 .scratch/stats-report/SPEC.md
|
|
3
|
+
import { usageReport } from "../../usage/report.js";
|
|
4
|
+
import { usageEnabled, usageKeepDays } from "../../usage/record.js";
|
|
5
|
+
|
|
6
|
+
const FLAGS = ["-stats", "--stats"];
|
|
7
|
+
|
|
8
|
+
export function isStatsFlag(args) {
|
|
9
|
+
return FLAGS.some((f) => args.includes(f));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function parseStatsArgs(args) {
|
|
13
|
+
const hoursIdx = args.findIndex((a) => a === "--hours" || a === "-hours");
|
|
14
|
+
const rawHours = hoursIdx >= 0 ? Number(args[hoursIdx + 1]) : NaN;
|
|
15
|
+
const hours = Number.isFinite(rawHours) && rawHours > 0 ? Math.min(168, Math.floor(rawHours)) : 24;
|
|
16
|
+
const modelIdx = args.findIndex((a) => a === "--model" || a === "-model");
|
|
17
|
+
const rawModel = modelIdx >= 0 && args[modelIdx + 1] && !String(args[modelIdx + 1]).startsWith("-") ? args[modelIdx + 1] : null;
|
|
18
|
+
return { hours, model: rawModel, json: args.includes("--json") };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function fmtTok(n) {
|
|
22
|
+
const v = Number(n) || 0;
|
|
23
|
+
if (v < 1000) return String(v);
|
|
24
|
+
if (v < 1_000_000) return `${(v / 1000).toFixed(1)}k`;
|
|
25
|
+
return `${(v / 1_000_000).toFixed(2)}M`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function fmtMs(v) {
|
|
29
|
+
if (v == null || !Number.isFinite(v)) return "—";
|
|
30
|
+
return v < 1000 ? `${v}ms` : `${(v / 1000).toFixed(1)}s`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function fmtTps(v) {
|
|
34
|
+
return v == null || !Number.isFinite(v) ? "—" : `${v} tok/s`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 中文字符按 2 列宽算,否则表格错位
|
|
38
|
+
function width(s) {
|
|
39
|
+
let w = 0;
|
|
40
|
+
for (const ch of String(s)) w += /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6]/.test(ch) ? 2 : 1;
|
|
41
|
+
return w;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function padW(s, w) {
|
|
45
|
+
const t = String(s);
|
|
46
|
+
return t + " ".repeat(Math.max(0, w - width(t)));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function rowText(id, r) {
|
|
50
|
+
return ` ${padW(id, 30)} ${padW(r.requests, 6)} ${padW(fmtTok(r.promptTokens), 8)} ${padW(fmtTok(r.completionTokens), 10)} ${padW(fmtTok(r.totalTokens), 8)} ${padW(fmtMs(r.avgTtfbMs), 7)} ${padW(fmtMs(r.avgTotalMs), 8)} ${fmtTps(r.avgTps)}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function renderStats(report, { hours = 24, model = null } = {}) {
|
|
54
|
+
const lines = [];
|
|
55
|
+
const { models, totals } = report;
|
|
56
|
+
if (!models.length) {
|
|
57
|
+
lines.push(`暂无用量记录(近 ${hours}h${model ? ` · 模型 ${model}` : ""})— 经 8989 网关发一次请求后出现`);
|
|
58
|
+
lines.push("提示:mslxdff -status 看当前体检 · mslxdff -log 20 看最近事件");
|
|
59
|
+
lines.push("说明:-chat 直连 mimo/big-pickle 不经网关,不计入本表");
|
|
60
|
+
return lines.join("\n");
|
|
61
|
+
}
|
|
62
|
+
lines.push(`模型用量(近 ${hours}h · 成功请求 ${totals.requests} 次 · ${models.length} 个模型)`);
|
|
63
|
+
lines.push(` ${padW("模型", 30)} ${padW("请求", 6)} ${padW("prompt", 8)} ${padW("输出", 10)} ${padW("合计", 8)} ${padW("首字", 7)} ${padW("总耗时", 8)} 速度`);
|
|
64
|
+
for (const m of models) lines.push(rowText(m.id, m));
|
|
65
|
+
lines.push(` ${"-".repeat(76)}`);
|
|
66
|
+
lines.push(rowText("合计", totals));
|
|
67
|
+
if (totals.reasoningTokens > 0) lines.push(` 其中思考 tokens:${fmtTok(totals.reasoningTokens)}`);
|
|
68
|
+
lines.push("");
|
|
69
|
+
lines.push("说明:速度 = 输出 tokens ÷ 生成耗时(总耗时−首字),按窗口加权;只统计成功请求。");
|
|
70
|
+
lines.push(` -chat 直连 mimo/big-pickle 不经 8989 网关,不计入。数据保留 ${usageKeepDays()} 天,mslxdff -stats --hours 1|--json|--model <id> 可调。`);
|
|
71
|
+
return lines.join("\n");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function handleStats(args) {
|
|
75
|
+
if (!isStatsFlag(args)) return false;
|
|
76
|
+
const { hours, model, json } = parseStatsArgs(args);
|
|
77
|
+
if (!usageEnabled()) {
|
|
78
|
+
console.log("用量记录已关闭(MSLXDFF_USAGE_LOG=0)—— 去掉该 env 后重启 daemon 即可恢复采集。");
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
const report = await usageReport({ hours, model, now: Date.now() });
|
|
82
|
+
if (json) {
|
|
83
|
+
console.log(JSON.stringify(report, null, 2));
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
console.log(renderStats(report, { hours, model }));
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { loadUseGroup, saveUseGroup, getEffectiveUseGroup, getUseGroupEnv } from "../../state/schemas/use-group.js";
|
|
1
|
+
import { loadUseGroup, saveUseGroup, getEffectiveUseGroup, getUseGroupEnv, getUseGroupKeysEnv } from "../../state/schemas/use-group.js";
|
|
2
2
|
import { argValue } from "../policy.js";
|
|
3
3
|
|
|
4
4
|
function parseInput(v) {
|
|
@@ -26,15 +26,18 @@ export async function handleUseGroup(args) {
|
|
|
26
26
|
const envVal = getUseGroupEnv();
|
|
27
27
|
const effective = getEffectiveUseGroup();
|
|
28
28
|
const stored = loadUseGroup();
|
|
29
|
+
const keysEnv = getUseGroupKeysEnv();
|
|
29
30
|
|
|
30
31
|
if (raw === null || raw === undefined || raw === "") {
|
|
31
32
|
// 查询模式
|
|
32
|
-
console.log(`use-group: ${effective ? "on" : "off"} (effective)`);
|
|
33
|
+
console.log(`use-group: ${effective ? "on" : "off"} (effective, 仅 opencode 免费池)`);
|
|
33
34
|
console.log(` stored: ${stored ? "on" : "off"} (state.json useGroup)`);
|
|
34
35
|
if (envVal !== null) console.log(` env MSLXDFF_USE_GROUP=${envVal ? "on" : "off"} (overrides stored)`);
|
|
36
|
+
console.log(` keys: ${keysEnv ? "on" : "off"} (env MSLXDFF_USE_GROUP_KEYS, 默认 off: 带 key 上游恒直连)`);
|
|
35
37
|
console.log(` default: on`);
|
|
36
|
-
console.log(` usage: mslxdff -use-group on|off (
|
|
38
|
+
console.log(` usage: mslxdff -use-group on|off (opencode 失败时是否走组员网络,默认 on)`);
|
|
37
39
|
console.log(` env: MSLXDFF_USE_GROUP=0|1 (优先级高于 state)`);
|
|
40
|
+
console.log(` env: MSLXDFF_USE_GROUP_KEYS=1 (把 key 供应商开回组员,cline/workbuddy 仍硬禁)`);
|
|
38
41
|
process.exit(0);
|
|
39
42
|
}
|
|
40
43
|
|
|
@@ -50,7 +53,8 @@ export async function handleUseGroup(args) {
|
|
|
50
53
|
|
|
51
54
|
saveUseGroup(parsed);
|
|
52
55
|
console.log(`use-group set to ${parsed ? "on" : "off"} (stored in state.json)`);
|
|
53
|
-
console.log(` ${parsed ? "允许" : "
|
|
56
|
+
console.log(` ${parsed ? "允许 opencode" : "opencode 也不再"}走组员网络(via-route/hedge/peer/broadband)`);
|
|
57
|
+
console.log(` 带 key 上游默认恒直连(不受本开关影响,MSLXDFF_USE_GROUP_KEYS=1 可开回,cline/workbuddy 仍硬禁)`);
|
|
54
58
|
if (!parsed) console.log(` 提示:所有请求将仅在本机重试,不再走组员中继`);
|
|
55
59
|
process.exit(0);
|
|
56
60
|
}
|
package/src/cli/help.js
CHANGED
|
@@ -5,6 +5,8 @@ Usage:
|
|
|
5
5
|
mslxdff start as a background daemon and exit (status + help if one is already running)
|
|
6
6
|
mslxdff -d start as a background daemon
|
|
7
7
|
mslxdff -status show current status (daemon/health/port/config, upstream providers, models + metrics/体检表, autostart/plugins, groups/peers, recent calls with ttfb/tps, last error)
|
|
8
|
+
mslxdff -stats [--hours N] [--json] [--model <id>] per-model token usage + speed over the last N hours (default 24; success requests only, from the local usage JSONL)
|
|
9
|
+
|
|
8
10
|
mslxdff -log [N] show last N events (default 10, e.g. -log 100)
|
|
9
11
|
mslxdff -models interactive picker: ↑/↓ select a model, Enter sets it as the default (non-TTY: plain list)
|
|
10
12
|
mslxdff -model list list the free models this proxy serves (cached)
|
package/src/cli/index.js
CHANGED
|
@@ -23,6 +23,8 @@ export async function run(args = process.argv.slice(2)) {
|
|
|
23
23
|
if (await handlePlugins(args)) return;
|
|
24
24
|
if (await handleChat(args)) return;
|
|
25
25
|
if (await handleStatus(args, VERSION)) return;
|
|
26
|
+
const { handleStats } = await import("./commands/stats.js");
|
|
27
|
+
if (await handleStats(args)) return;
|
|
26
28
|
|
|
27
29
|
const { handleModel } = await import("./commands/model.js");
|
|
28
30
|
if (await handleModel(args)) return;
|
package/src/cli/status.js
CHANGED
|
@@ -243,25 +243,20 @@ export async function printStatus(VERSION) {
|
|
|
243
243
|
}
|
|
244
244
|
|
|
245
245
|
const { recentCalls, lastError } = await import("../logs.js");
|
|
246
|
-
console.log("\nrecent calls: (gateway 持久化,最近5
|
|
246
|
+
console.log("\nrecent calls: (gateway 持久化,最近5条;token/速度看 mslxdff -stats)");
|
|
247
247
|
const calls = recentCalls(5);
|
|
248
248
|
if (calls.length) {
|
|
249
|
-
let sumDur = 0
|
|
249
|
+
let sumDur = 0;
|
|
250
250
|
for (const c of calls) {
|
|
251
251
|
if (Number.isFinite(c.durationMs)) sumDur += c.durationMs;
|
|
252
252
|
else if (Number.isFinite(c.totalMs)) sumDur += c.totalMs;
|
|
253
|
-
if (Number.isFinite(c.tps)) { sumTps += c.tps; tpsN++; } else if (Number.isFinite(c.charsPerSec)) { sumTps += c.charsPerSec; tpsN++; }
|
|
254
253
|
}
|
|
255
254
|
const avgDur = calls.length ? Math.round(sumDur / calls.length) : null;
|
|
256
|
-
|
|
257
|
-
console.log(` avg ${avgDur ? avgDur + "ms" : "—"}${avgTps ? ` · ${avgTps} tok/s` : ""} — mslxdff -log 20 查看详情`);
|
|
255
|
+
console.log(` avg ${avgDur ? avgDur + "ms" : "—"} — mslxdff -log 20 查看详情`);
|
|
258
256
|
for (const c of calls) {
|
|
259
257
|
const dur = c.totalMs ?? c.durationMs;
|
|
260
|
-
const ttfb = c.ttfbMs != null ? ` 首字${c.ttfbMs}ms` : "";
|
|
261
|
-
const tps = c.tps != null ? ` ${c.tps}tok/s` : (c.charsPerSec ? ` ${c.charsPerSec}ch/s` : "");
|
|
262
|
-
const tok = c.usage?.completion_tokens != null ? ` tok${c.usage.completion_tokens}` : (c.chars ? ` ch${c.chars}` : "");
|
|
263
258
|
const tm = fmtTs(c.ts);
|
|
264
|
-
console.log(` ${tm} ${(c.model || "-").padEnd(28)} ${String(c.status || "-").padEnd(4)} ${dur ? dur + "ms" : ""}${
|
|
259
|
+
console.log(` ${tm} ${(c.model || "-").padEnd(28)} ${String(c.status || "-").padEnd(4)} ${dur ? dur + "ms" : ""}${c.stream ? " stream" : ""}${c.auto ? " auto" : ""}`);
|
|
265
260
|
}
|
|
266
261
|
} else {
|
|
267
262
|
console.log(" (none yet — 发一次请求后出现,mslxdff -chat hi)");
|
|
@@ -117,8 +117,22 @@ export function createCapabilitiesService({
|
|
|
117
117
|
function providers() {
|
|
118
118
|
return [...capsIndex.keys()].sort();
|
|
119
119
|
}
|
|
120
|
-
|
|
121
|
-
|
|
120
|
+
// readyWarm:仅用内存/磁盘缓存热身,绝不网络请求。/models 富化用它避免冷启动阻塞;
|
|
121
|
+
// 冷缓存时后台拉新(不 await),下次请求即富化。
|
|
122
|
+
async function readyWarm() {
|
|
123
|
+
const t = now();
|
|
124
|
+
if (raw && t - loadedAt < ttl) return true;
|
|
125
|
+
const disk = readCache();
|
|
126
|
+
if (disk) {
|
|
127
|
+
raw = disk;
|
|
128
|
+
capsIndex = buildIndex(disk);
|
|
129
|
+
loadedAt = t;
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
ready().catch(() => {}); // 后台拉新,失败静默(/models 原样降级)
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
return { ready, readyWarm, get, list, providers, npmIndex: () => new Map(npmIndex) };
|
|
122
136
|
}
|
|
123
137
|
|
|
124
138
|
// 模块级单例:HTTP handler 懒加载,测试 _reset 后注入
|
|
@@ -128,7 +142,6 @@ export function globalCapabilities() {
|
|
|
128
142
|
return _global;
|
|
129
143
|
}
|
|
130
144
|
export function _resetGlobalCapabilities() { _global = null; }
|
|
131
|
-
|
|
132
145
|
// 缓存落盘位置:MSLXDFF_MODELS_DEV_CACHE 覆盖 > ~/.config/mslxdff/models-dev.json(与 state 同目录)
|
|
133
146
|
function defaultCacheFile() {
|
|
134
147
|
const override = process.env.MSLXDFF_MODELS_DEV_CACHE;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// /v1/models 能力富化(ADR-0022):把 models.dev 目录(+ workbuddy 原生字段兜底)
|
|
2
|
+
// 的能力合并进 /v1/models 每条 data 条目的 `capabilities` 子对象,客户端与 CLI 一眼可读。
|
|
3
|
+
// 逃生门:GET /v1/models?raw=1 保持 ADR-0016 的原始透传形状;codex 调用者恒原始 + 空 models:[]。
|
|
4
|
+
// 全程 best-effort:目录不可用 → 原样返回,绝不让 /models 因富化失败挂掉。
|
|
5
|
+
import { globalCapabilities } from "./index.js";
|
|
6
|
+
|
|
7
|
+
// "clinebot/deepseek/deepseek-v4-flash" → { provider: "clinebot", raw: "deepseek/deepseek-v4-flash" };裸 id 归 opencode
|
|
8
|
+
export function splitModelId(id) {
|
|
9
|
+
const s = String(id || "").trim();
|
|
10
|
+
const i = s.indexOf("/");
|
|
11
|
+
if (i > 0) return { provider: s.slice(0, i).toLowerCase(), raw: s.slice(i + 1) };
|
|
12
|
+
return { provider: "opencode", raw: s };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function stripFreeSuffix(raw) {
|
|
16
|
+
return raw.replace(/-free$/i, "");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// 上游原生 wire API:muse-spark* 只认 /responses(zen /chat 500 实测);
|
|
20
|
+
// models.dev 标 npm @ai-sdk/openai 的模型上游走 responses;其余 chat。
|
|
21
|
+
export function upstreamApiFor(caps, raw) {
|
|
22
|
+
const bare = stripFreeSuffix(String(raw || ""));
|
|
23
|
+
if (/^muse-spark/i.test(bare)) return "responses";
|
|
24
|
+
if (caps?.npm === "@ai-sdk/openai") return "responses";
|
|
25
|
+
return "chat";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// caps → 对外 capabilities 子对象(只填有值的键,未收录字段不硬造)
|
|
29
|
+
export function capsPayloadFor(caps, raw) {
|
|
30
|
+
if (!caps) return null;
|
|
31
|
+
const payload = {
|
|
32
|
+
reasoning: Boolean(caps.reasoning),
|
|
33
|
+
...(caps.effortType ? { effortType: caps.effortType } : {}),
|
|
34
|
+
...(Array.isArray(caps.effortValues) && caps.effortValues.length
|
|
35
|
+
? { effortValues: caps.effortValues.map(String) }
|
|
36
|
+
: {}),
|
|
37
|
+
...(caps.defaultEffort ? { defaultEffort: String(caps.defaultEffort) } : {}),
|
|
38
|
+
imageInput: Boolean(caps.imageInput),
|
|
39
|
+
inputModalities: Array.isArray(caps.inputModalities) && caps.inputModalities.length
|
|
40
|
+
? caps.inputModalities
|
|
41
|
+
: ["text"],
|
|
42
|
+
outputModalities: Array.isArray(caps.outputModalities) && caps.outputModalities.length
|
|
43
|
+
? caps.outputModalities
|
|
44
|
+
: ["text"],
|
|
45
|
+
toolCall: Boolean(caps.toolCall),
|
|
46
|
+
...(Number(caps.context) > 0 ? { context: Number(caps.context) } : {}),
|
|
47
|
+
...(Number(caps.maxOutput) > 0 ? { maxOutput: Number(caps.maxOutput) } : {}),
|
|
48
|
+
...((caps.costIn != null || caps.costOut != null)
|
|
49
|
+
? { costIn: Number(caps.costIn) || 0, costOut: Number(caps.costOut) || 0 }
|
|
50
|
+
: {}),
|
|
51
|
+
// 网关两侧端点都收:/v1/responses 复用 ChatPipeline 翻译层;upstreamApi 记上游原生协议
|
|
52
|
+
endpoints: ["chat", "responses"],
|
|
53
|
+
upstreamApi: upstreamApiFor(caps, raw),
|
|
54
|
+
};
|
|
55
|
+
return payload;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 单条 id 的目录匹配:精确裸 id → 剥 -free 后缀 → 二级厂商前缀(clinebot/deepseek/x → deepseek/x)
|
|
59
|
+
function lookupCaps(svc, provider, raw) {
|
|
60
|
+
return (
|
|
61
|
+
svc.get(provider, raw) ||
|
|
62
|
+
svc.get(provider, stripFreeSuffix(raw)) ||
|
|
63
|
+
null
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function lookupCapsDeep(svc, provider, raw) {
|
|
68
|
+
const direct = lookupCaps(svc, provider, raw);
|
|
69
|
+
if (direct) return direct;
|
|
70
|
+
const parts = raw.split("/");
|
|
71
|
+
if (parts.length >= 2) {
|
|
72
|
+
const head = parts[0];
|
|
73
|
+
const rest = parts.slice(1).join("/");
|
|
74
|
+
return svc.get(head, rest) || svc.get(head, stripFreeSuffix(rest)) || null;
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 把能力合并进 { object:"list", data:[...] } 的每条条目。
|
|
81
|
+
* capsSvc/wbSource 为测试接缝;生产用全局单例(models.dev 24h 缓存)+ workbuddy 动态源。
|
|
82
|
+
* wbSource 显式 null 禁用 workbuddy 兜底(测试用)。
|
|
83
|
+
*/
|
|
84
|
+
export async function mergeModelsList(data, { capsSvc, wbSource } = {}) {
|
|
85
|
+
const svc = capsSvc === undefined ? globalCapabilities() : capsSvc;
|
|
86
|
+
const entries = Array.isArray(data?.data) ? data.data : [];
|
|
87
|
+
if (!svc || !entries.length) return data;
|
|
88
|
+
// 只用内存/磁盘缓存热身;冷缓存绝不阻塞请求(后台拉新,下次请求即富化)
|
|
89
|
+
try {
|
|
90
|
+
const warmed = svc.readyWarm ? await svc.readyWarm() : await svc.ready();
|
|
91
|
+
if (!warmed) return data;
|
|
92
|
+
} catch {
|
|
93
|
+
return data;
|
|
94
|
+
}
|
|
95
|
+
// 第一遍:目录直查;workbuddy 未命中记下,第二遍走上游原生字段
|
|
96
|
+
const resolved = new Map(); // index -> caps
|
|
97
|
+
const needWb = []; // [index, raw]
|
|
98
|
+
entries.forEach((entry, i) => {
|
|
99
|
+
const id = String(entry?.id || "");
|
|
100
|
+
if (!id) return;
|
|
101
|
+
const { provider, raw } = splitModelId(id);
|
|
102
|
+
const caps = lookupCapsDeep(svc, provider, raw);
|
|
103
|
+
if (caps) resolved.set(i, caps);
|
|
104
|
+
else if (provider === "workbuddy" && wbSource !== null) needWb.push([i, raw]);
|
|
105
|
+
});
|
|
106
|
+
if (needWb.length) {
|
|
107
|
+
try {
|
|
108
|
+
const { workbuddyCapsFromModels, workbuddyAllModels } = await import("./index.js");
|
|
109
|
+
const wbMap = await workbuddyCapsFromModels(wbSource || workbuddyAllModels)();
|
|
110
|
+
for (const [i, raw] of needWb) {
|
|
111
|
+
const caps = wbMap?.[raw] || null;
|
|
112
|
+
if (caps) resolved.set(i, caps);
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
// workbuddy 源不可用:这些条目保持无能力,不拖垮整个列表
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (!resolved.size) return data;
|
|
119
|
+
const next = entries.map((entry, i) => {
|
|
120
|
+
const caps = resolved.get(i);
|
|
121
|
+
if (!caps) return entry;
|
|
122
|
+
const { raw } = splitModelId(String(entry?.id || ""));
|
|
123
|
+
const payload = capsPayloadFor(caps, raw);
|
|
124
|
+
return payload ? { ...entry, capabilities: payload } : entry;
|
|
125
|
+
});
|
|
126
|
+
return { ...data, data: next };
|
|
127
|
+
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// ADR-0015:供应商路由分类三态 + EMA 纯函数(零依赖,防循环 import)
|
|
2
|
-
// local-only:本机账号绑定(workbuddy
|
|
2
|
+
// local-only:本机账号绑定(workbuddy/cline 系),组员转发无效
|
|
3
3
|
// quota-pool:图额度不图速度(opencode free),直连先行、429 后组员兜底
|
|
4
4
|
// latency-compare:key/token 类,direct vs link+remote 比延迟
|
|
5
5
|
export function classifyProvider(id) {
|
|
6
6
|
const s = String(id || "").trim().toLowerCase();
|
|
7
|
-
if (s === "workbuddy") return "local-only";
|
|
7
|
+
if (s === "workbuddy" || s === "cline" || s === "clinebot") return "local-only";
|
|
8
8
|
if (s === "opencode") return "quota-pool";
|
|
9
9
|
return "latency-compare";
|
|
10
10
|
}
|
|
@@ -50,7 +50,7 @@ export function createProviderDispatcher(providers = [], opts = {}) {
|
|
|
50
50
|
// ADR-0008:本请求携带瞬时共享 key(shareKeys 由组员侧按 header 解析后传入)。
|
|
51
51
|
const sharedKeys = opts?.shareKeys?.[provider.id];
|
|
52
52
|
if (sharedKeys && sharedKeys.length && typeof provider.chatWithKeys === "function") {
|
|
53
|
-
return provider.chatWithKeys(forwarded, sharedKeys);
|
|
53
|
+
return provider.chatWithKeys(forwarded, sharedKeys, opts);
|
|
54
54
|
}
|
|
55
55
|
if (provider.id === "workbuddy" && workbuddyUid) {
|
|
56
56
|
return provider.chat(forwarded, { ...opts, workbuddyUid });
|
package/src/providers/generic.js
CHANGED
|
@@ -2,6 +2,8 @@ import { createKeyRing } from "./keyring.js";
|
|
|
2
2
|
import { loadProviderKeys, loadProviderBaseUrl, loadProviderModelsPath, loadProviderChatPath } from "../state.js";
|
|
3
3
|
import { envInt, joinUrl, getUndici, createAgent, collectApiKeysGeneric, createChatRunner, createListModelsRunner, createPreheatRunner } from "./base.js";
|
|
4
4
|
import { compatFetch } from "../compat.js";
|
|
5
|
+
import crypto from "node:crypto";
|
|
6
|
+
import { genId, opencodeUa, digestIdTail } from "../opencode-identity.js";
|
|
5
7
|
|
|
6
8
|
const { UndiciFetch } = getUndici();
|
|
7
9
|
|
|
@@ -12,6 +14,26 @@ function resolveBaseUrl(id, baseUrl) {
|
|
|
12
14
|
return "";
|
|
13
15
|
}
|
|
14
16
|
|
|
17
|
+
function isOpencodeHost(baseUrl) {
|
|
18
|
+
return /opencode\.ai/i.test(String(baseUrl || ""));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Console Go (zen/go) 要求 x-opencode-session 才能路由(缺失 → 400 MissingSessionID)。
|
|
22
|
+
// 会话取值:已合规(ses_ 前缀)直接用;客户端透传的任意串做 sha1 摘要派生(稳定+合规);
|
|
23
|
+
// 无则每请求 fresh(与 -chat curl / bench 直连行为一致)。
|
|
24
|
+
function resolveGoSession(sessionId) {
|
|
25
|
+
const raw = String(sessionId || "").trim();
|
|
26
|
+
if (/^ses_/.test(raw)) return raw;
|
|
27
|
+
if (raw) {
|
|
28
|
+
try {
|
|
29
|
+
return `ses_${digestIdTail(crypto.createHash("sha1").update(raw).digest())}`;
|
|
30
|
+
} catch {
|
|
31
|
+
return genId("ses_");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return genId("ses_");
|
|
35
|
+
}
|
|
36
|
+
|
|
15
37
|
export function createGenericProvider({
|
|
16
38
|
id,
|
|
17
39
|
baseUrl,
|
|
@@ -41,6 +63,7 @@ export function createGenericProvider({
|
|
|
41
63
|
const resolvedModelsPath = modelsPath || loadProviderModelsPath(id, file ? { file } : {});
|
|
42
64
|
const resolvedChatPath = chatPath || loadProviderChatPath(id, file ? { file } : {});
|
|
43
65
|
const ring = createKeyRing(collectApiKeysGeneric(id, apiKeys, apiKey, loadProviderKeys), { cooldownMs });
|
|
66
|
+
const goHost = isOpencodeHost(resolvedBase);
|
|
44
67
|
|
|
45
68
|
let dispatcher = null;
|
|
46
69
|
let agent = null;
|
|
@@ -53,7 +76,7 @@ export function createGenericProvider({
|
|
|
53
76
|
agent = a.agent; dispatcher = a.dispatcher;
|
|
54
77
|
}
|
|
55
78
|
|
|
56
|
-
function buildHeaders(body, key) {
|
|
79
|
+
function buildHeaders(body, key, opts) {
|
|
57
80
|
const isStream = body?.stream !== false;
|
|
58
81
|
const h = {
|
|
59
82
|
"Content-Type": "application/json",
|
|
@@ -61,20 +84,35 @@ export function createGenericProvider({
|
|
|
61
84
|
"User-Agent": "mslxdff",
|
|
62
85
|
};
|
|
63
86
|
if (key) h["Authorization"] = `Bearer ${key}`;
|
|
64
|
-
|
|
87
|
+
const out = { ...h, ...extraHeaders };
|
|
88
|
+
// ocgo(opencode.ai 域名):补 Console Go 路由所需的 opencode 身份头
|
|
89
|
+
if (goHost) {
|
|
90
|
+
out["User-Agent"] = opencodeUa();
|
|
91
|
+
out["x-opencode-client"] = "desktop";
|
|
92
|
+
out["x-opencode-session"] = resolveGoSession(opts?.sessionId);
|
|
93
|
+
out["x-opencode-request"] = genId("msg_");
|
|
94
|
+
out["x-opencode-project"] = "global";
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
65
97
|
}
|
|
66
98
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
99
|
+
// base 的 attemptOnce 只调 buildHeaders(body, key):用闭包把 opts.sessionId 带进去
|
|
100
|
+
function scopedRunner(activeRing, opts) {
|
|
101
|
+
return createChatRunner({
|
|
102
|
+
id, ring: activeRing, cooldownMs, retry, fetchImpl, dispatcher,
|
|
103
|
+
buildHeaders: (b, k) => buildHeaders(b, k, opts),
|
|
104
|
+
getUrl: () => joinUrl(resolvedBase, resolvedChatPath),
|
|
105
|
+
connectTimeoutMs,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
72
108
|
|
|
73
|
-
async function chat(body) {
|
|
109
|
+
async function chat(body, opts) {
|
|
110
|
+
const { runChat } = scopedRunner(ring, opts);
|
|
74
111
|
return runChat(body, ring, `MSLXDFF_${id.toUpperCase()}_KEY`);
|
|
75
112
|
}
|
|
76
|
-
async function chatWithKeys(body, keys) {
|
|
113
|
+
async function chatWithKeys(body, keys, opts) {
|
|
77
114
|
const tmp = createKeyRing(keys, { cooldownMs });
|
|
115
|
+
const { runChat } = scopedRunner(tmp, opts);
|
|
78
116
|
return runChat(body, tmp, "shared provider keys");
|
|
79
117
|
}
|
|
80
118
|
|
|
@@ -10,7 +10,7 @@ export const SHARE_KEYS_HEADER = "x-mslxdff-share-keys";
|
|
|
10
10
|
const NEVER_SHARE_IDS = new Set(["cline", "clinebot"]);
|
|
11
11
|
|
|
12
12
|
// 本节点应 cast key 到出站转发的供应商 id 集合 = 所有「本机有 key」的供应商,
|
|
13
|
-
// 减去:opencode(无 key 且恒排除)、local-only(workbuddy
|
|
13
|
+
// 减去:opencode(无 key 且恒排除)、local-only(workbuddy/cline系,本就不走组员)、刷新型凭据。
|
|
14
14
|
export function shareableProviderIds({ file } = {}) {
|
|
15
15
|
const ids = [];
|
|
16
16
|
for (const id of listProviderIdsWithKeys({ file })) {
|
|
@@ -2,6 +2,7 @@ import { runHook } from "../../plugins.js";
|
|
|
2
2
|
import { recordModelStats } from "../../state.js";
|
|
3
3
|
import { normalizeFullId } from "../../providers/model-id.js";
|
|
4
4
|
import { computeMetrics } from "../../metrics.js";
|
|
5
|
+
import { recordChatUsage } from "../../usage/record.js"; // 窗口报表唯一写入点(canonical 名单记防双计)— 见 .agents/notes/implemented/feature/2026-09-19-usage-report-jsonl.md
|
|
5
6
|
|
|
6
7
|
// 唯一/最后候选没有 failover 去向:首块闸门退化为纯"防连接泄漏",放宽避免误杀慢模型
|
|
7
8
|
// (参考 opencode:zen 通道不设超时;openai responses 硬编码 300s headerTimeout)
|
|
@@ -134,6 +135,15 @@ export function createRelayPipeline({
|
|
|
134
135
|
}
|
|
135
136
|
_evt("slow-model", { reqId, model: actual, elapsedMs: out.totalMs ?? (Date.now() - curStartedAt), threshold: C.STALL_TIMEOUT_MS, interrupted: true, detail: out.detail ?? null });
|
|
136
137
|
try { _logCall(actual, 200); } catch {}
|
|
138
|
+
// interrupted 的 200 也是真实消耗(最贵的长生成)——照常落 usage 标 interrupted:1,口径与 5c 一致 — 见 .agents/notes/implemented/feature/2026-09-19-usage-report-jsonl.md
|
|
139
|
+
if (out.status === 200) {
|
|
140
|
+
try {
|
|
141
|
+
const u = out.detail?.usage || null;
|
|
142
|
+
const t1 = Number.isFinite(out.totalMs) && out.totalMs > 0 ? out.totalMs : (Date.now() - curStartedAt);
|
|
143
|
+
const t0 = Number.isFinite(out.ttfMs) && out.ttfMs > 0 ? out.ttfMs : null;
|
|
144
|
+
recordChatUsage({ model: normalizeFullId(actual), via, usage: u, interrupted: 1, ttfbMs: t0, totalMs: t1, tps: null }).catch(() => {});
|
|
145
|
+
} catch {}
|
|
146
|
+
}
|
|
137
147
|
_evt("result", { reqId, model: actual, status: out.status, via, timing: upRes?._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, interrupted: true, detail: out.detail ?? null, fallback, requested, actual });
|
|
138
148
|
_evt("client-response", { requested, actual, via, fallback, status: out.status, reqId, interrupted: true });
|
|
139
149
|
if (plugins?.length) runHook(plugins, "request:completed", { reqId, requested, useAuto, hops, stream: Boolean(body?.stream), durationMs: Date.now() - curStartedAt, via, status: out.status, actual, interrupted: true, fallback }).catch(() => {});
|
|
@@ -183,6 +193,9 @@ export function createRelayPipeline({
|
|
|
183
193
|
const fullId = normalizeFullId(actual);
|
|
184
194
|
recordModelStats(fullId, { ttfbMs: ttfb, totalMs: total, tps, completionTokens: compTok });
|
|
185
195
|
if (fullId !== actual) recordModelStats(actual, { ttfbMs: ttfb, totalMs: total, tps, completionTokens: compTok });
|
|
196
|
+
// 窗口报表:逐请求落 usage(行形状由 usage/record.js 拥有,含 prompt/total ——
|
|
197
|
+
// state 的 modelStats 只存 completion 的 EMA)。只按 canonical 名记一次,避免双计。
|
|
198
|
+
recordChatUsage({ model: fullId, via, usage, ttfbMs: ttfb, totalMs: total, tps: m.tps }).catch(() => {});
|
|
186
199
|
} catch {}
|
|
187
200
|
}
|
|
188
201
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { json, errMsg } from "./helpers.js";
|
|
2
2
|
import { runHook } from "../plugins.js";
|
|
3
3
|
import { isModelAllowed } from "../state.js";
|
|
4
|
+
import { mergeModelsList } from "../model-capabilities/merge.js";
|
|
4
5
|
import { globalCapabilities } from "../model-capabilities/index.js";
|
|
5
6
|
|
|
6
7
|
// Codex 自定义 provider 拉目录要顶层 `models` 数组(codex-rs endpoint/models.rs 解 ModelsResponse{models}),
|
|
@@ -14,12 +15,21 @@ export function isCodexModelsCaller(req) {
|
|
|
14
15
|
return /(^|&)client_version=/.test(q);
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
export async function modelsHandler({ req, res, models, plugins }) {
|
|
18
|
+
export async function modelsHandler({ req, res, models, plugins, capabilities, wbSource }) {
|
|
18
19
|
if (!models) return json(res, 501, { error: "Models service not configured" });
|
|
19
20
|
const codex = isCodexModelsCaller(req);
|
|
20
21
|
const withCodex = (out) => (codex && out && typeof out === "object" ? { ...out, models: [] } : out);
|
|
21
22
|
try {
|
|
22
23
|
let data = await models.get();
|
|
24
|
+
// ADR-0022:默认把能力富化进每条条目(capabilities 子对象);?raw=1 逃生门回原始形状,
|
|
25
|
+
// codex 调用者保持原始 + 顶层空 models:[](富化白做)。best-effort,失败原样返回。
|
|
26
|
+
const qs = String(req?.url || "").split("?")[1] || "";
|
|
27
|
+
const rawMode = /(^|&)raw=1(&|$)/.test(qs);
|
|
28
|
+
if (!codex && !rawMode) {
|
|
29
|
+
try {
|
|
30
|
+
data = await mergeModelsList(data, { capsSvc: capabilities, wbSource });
|
|
31
|
+
} catch { /* 富化失败不阻塞 /models */ }
|
|
32
|
+
}
|
|
23
33
|
// 插件 hook:models:list — 返回数组可替换对外模型列表({object:"list",data:[...]} 或纯 id 数组)
|
|
24
34
|
if (plugins?.length) {
|
|
25
35
|
const ml = await runHook(plugins, "models:list", { data });
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defaultStateFile, readState, writeStateImmediate } from "../store.js";
|
|
2
2
|
import { classifyProvider } from "../../providers/classify.js";
|
|
3
|
+
import { getModelAlias, normalizeProviderId, DEFAULT_PROVIDER } from "../../providers/model-id.js";
|
|
3
4
|
|
|
4
5
|
function parseBool(v) {
|
|
5
6
|
if (typeof v === "boolean") return v;
|
|
@@ -36,23 +37,63 @@ export function getEffectiveUseGroup({ file = defaultStateFile() } = {}) {
|
|
|
36
37
|
return loadUseGroup({ file });
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
// key 供应商组员开关:默认 off(仅 opencode 免费池走 peer 兜底,其他带 key 上游恒直连)。
|
|
41
|
+
// MSLXDFF_USE_GROUP_KEYS=1/on 可显式开回来(调试/弱网应急);同样受全局 off 约束。
|
|
42
|
+
export function getUseGroupKeysEnv() {
|
|
43
|
+
const raw = process.env.MSLXDFF_USE_GROUP_KEYS;
|
|
44
|
+
if (raw === undefined || raw === null || raw === "") return false;
|
|
45
|
+
return parseBool(raw) === true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 取模型对应的供应商 head:兼容 canonical(workbuddy/xxx、clinebot/xxx)、
|
|
49
|
+
// dash 别名(clinebot-xxx → 还原后取 head)、裸 id(归 opencode)。
|
|
50
|
+
// oc/ 别名归一到 opencode(与 splitModelId 一致:别名表小写 key,先小写再归一)。
|
|
51
|
+
export function providerHeadOf(model) {
|
|
52
|
+
let s = String(model || "").trim();
|
|
53
|
+
if (!s) return "";
|
|
54
|
+
try {
|
|
55
|
+
const aliased = getModelAlias(s);
|
|
56
|
+
if (aliased) s = aliased;
|
|
57
|
+
} catch {}
|
|
58
|
+
const slash = s.indexOf("/");
|
|
59
|
+
if (slash > 0) {
|
|
60
|
+
try {
|
|
61
|
+
const head = normalizeProviderId(s.slice(0, slash).toLowerCase());
|
|
62
|
+
return String(head || "").toLowerCase();
|
|
63
|
+
} catch {
|
|
64
|
+
return s.slice(0, slash).toLowerCase();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return DEFAULT_PROVIDER;
|
|
68
|
+
}
|
|
69
|
+
|
|
39
70
|
// 全局开关:off 则所有供应商都不走组员网络(via-route/hedge/peer/broadband 全禁),仅本机直连
|
|
40
|
-
// workbuddy
|
|
71
|
+
// workbuddy/cline 系硬禁组员(ADR-0015 local-only):本机账号绑定(workbuddy auths/*.json + uid / cline refreshToken),
|
|
41
72
|
// 组员没有该账号转过去也用不了,且本地直连最快——无论全局开关一律仅本机直连。
|
|
42
|
-
// model 兼容 canonical(workbuddy/xxx)与 dash(workbuddy-xxx)两种形态。
|
|
73
|
+
// model 兼容 canonical(workbuddy/xxx、clinebot/xxx)与 dash(workbuddy-xxx、cline-xxx)两种形态。
|
|
43
74
|
export function isHardLocalOnly(model) {
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
const head = slash > 0 ? s.slice(0, slash) : s.split("-")[0];
|
|
75
|
+
const head = providerHeadOf(model);
|
|
76
|
+
if (!head) return false;
|
|
47
77
|
try {
|
|
48
78
|
return classifyProvider(head) === "local-only";
|
|
49
79
|
} catch {
|
|
50
|
-
return head === "workbuddy";
|
|
80
|
+
return head === "workbuddy" || head === "cline" || head === "clinebot";
|
|
51
81
|
}
|
|
52
82
|
}
|
|
53
83
|
|
|
84
|
+
// key 供应商默认直连(ADR-0023):opencode(quota-pool,裸 id/oc 前缀)沿用全局开关;
|
|
85
|
+
// 其余带前缀的一律仅本机直连,除非 MSLXDFF_USE_GROUP_KEYS=1 显式开回。
|
|
86
|
+
export function isKeyProviderDirectOnly(model) {
|
|
87
|
+
const head = providerHeadOf(model);
|
|
88
|
+
if (!head || head === "opencode") return false;
|
|
89
|
+
if (isHardLocalOnly(model)) return true;
|
|
90
|
+
if (getUseGroupKeysEnv()) return false;
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
|
|
54
94
|
export function shouldUseGroupForModel(model, { file = defaultStateFile() } = {}) {
|
|
55
95
|
if (isHardLocalOnly(model)) return false;
|
|
96
|
+
if (isKeyProviderDirectOnly(model)) return false;
|
|
56
97
|
return getEffectiveUseGroup({ file });
|
|
57
98
|
}
|
|
58
99
|
|
|
@@ -7,7 +7,7 @@ import { errMsg } from "../cli/util.js";
|
|
|
7
7
|
|
|
8
8
|
export const PROBE_DELAY_MS = Number(process.env.MSLXDFF_BENCH_DELAY_MS || 120) || 0;
|
|
9
9
|
|
|
10
|
-
// 从 providerConfigs 收集探针目标:local-only(workbuddy)/quota-pool(opencode) 排除,无 baseUrl 跳过
|
|
10
|
+
// 从 providerConfigs 收集探针目标:local-only(workbuddy/cline系)/quota-pool(opencode) 排除,无 baseUrl 跳过
|
|
11
11
|
export function probeTargetsFromState({ loadProviderConfigs, loadProviderKeys, loadProviderBaseUrl } = {}) {
|
|
12
12
|
const out = [];
|
|
13
13
|
const ids = new Set(Object.keys(loadProviderConfigs?.() || {}));
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// 逐请求 usage 落盘:按日切片的 JSONL,供 -stats 做时间窗口聚合。
|
|
2
|
+
// Note: 与 logs.js 的 calls/errors/events 不同,这里按日分片 + 保留期删旧文件,
|
|
3
|
+
// 不走 1MB 环形截断 —— 环形会把 24h 窗口的数据裁到末 100 行。见 .scratch/stats-report/SPEC.md
|
|
4
|
+
import { appendFile, mkdir, readdir, unlink } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { logDir } from "../logs.js";
|
|
7
|
+
|
|
8
|
+
const DEFAULT_KEEP_DAYS = 2;
|
|
9
|
+
const DAY_MS = 86_400_000;
|
|
10
|
+
|
|
11
|
+
export function usageEnabled() {
|
|
12
|
+
return process.env.MSLXDFF_USAGE_LOG !== "0";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function usageKeepDays() {
|
|
16
|
+
const n = Number(process.env.MSLXDFF_USAGE_KEEP_DAYS);
|
|
17
|
+
return Number.isInteger(n) && n > 0 ? n : DEFAULT_KEEP_DAYS;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function usageDir({ dir } = {}) {
|
|
21
|
+
return join(dir || logDir(), "usage");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function ymd(date) {
|
|
25
|
+
const y = date.getFullYear();
|
|
26
|
+
const m = String(date.getMonth() + 1).padStart(2, "0");
|
|
27
|
+
const d = String(date.getDate()).padStart(2, "0");
|
|
28
|
+
return `${y}-${m}-${d}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function usageFileFor(date, { dir } = {}) {
|
|
32
|
+
return join(usageDir({ dir }), `${ymd(date)}.jsonl`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 删掉早于保留期的日文件。文件名是 YYYY-MM-DD,可直接字典序比较。
|
|
36
|
+
export async function pruneUsage({ dir, keepDays, now = new Date() } = {}) {
|
|
37
|
+
const keep = Number.isInteger(keepDays) && keepDays > 0 ? keepDays : usageKeepDays();
|
|
38
|
+
const cutoff = ymd(new Date(now.getTime() - keep * DAY_MS));
|
|
39
|
+
const removed = [];
|
|
40
|
+
let files = [];
|
|
41
|
+
try {
|
|
42
|
+
files = await readdir(usageDir({ dir }));
|
|
43
|
+
} catch {
|
|
44
|
+
return removed; // 目录还不存在 = 无事可做
|
|
45
|
+
}
|
|
46
|
+
for (const f of files) {
|
|
47
|
+
if (!f.endsWith(".jsonl")) continue;
|
|
48
|
+
const day = f.slice(0, -".jsonl".length);
|
|
49
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(day) || day >= cutoff) continue;
|
|
50
|
+
try {
|
|
51
|
+
await unlink(join(usageDir({ dir }), f));
|
|
52
|
+
removed.push(f);
|
|
53
|
+
} catch {}
|
|
54
|
+
}
|
|
55
|
+
return removed;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let lastPrunedDay = null;
|
|
59
|
+
|
|
60
|
+
// 追加一行 usage。异步写,调用方可 fire-and-forget(不阻塞流式响应)。
|
|
61
|
+
// 清理是惰性的且每天最多触发一次,避免每个请求都去 readdir。
|
|
62
|
+
export async function recordUsage(entry, { dir, now = new Date() } = {}) {
|
|
63
|
+
if (!usageEnabled() || !entry || typeof entry !== "object") return null;
|
|
64
|
+
const row = { ts: now.getTime(), ...entry };
|
|
65
|
+
try {
|
|
66
|
+
await mkdir(usageDir({ dir }), { recursive: true });
|
|
67
|
+
await appendFile(usageFileFor(now, { dir }), JSON.stringify(row) + "\n");
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const today = ymd(now);
|
|
72
|
+
if (lastPrunedDay !== today) {
|
|
73
|
+
lastPrunedDay = today;
|
|
74
|
+
pruneUsage({ dir, now }).catch(() => {});
|
|
75
|
+
}
|
|
76
|
+
return row;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 从 relay 结果组装一行 usage —— 行形状由本模块拥有,调用方只交原始字段。
|
|
80
|
+
// usage 即 metrics.js 的 extractUsageFromJson 输出(prompt/completion/total/reasoning)。
|
|
81
|
+
export function recordChatUsage({ model, via, usage, ttfbMs, totalMs, tps, interrupted } = {}) {
|
|
82
|
+
return recordUsage({
|
|
83
|
+
model,
|
|
84
|
+
via,
|
|
85
|
+
interrupted,
|
|
86
|
+
prompt_tokens: usage?.prompt_tokens ?? 0,
|
|
87
|
+
completion_tokens: usage?.completion_tokens ?? 0,
|
|
88
|
+
total_tokens: usage?.total_tokens ?? 0,
|
|
89
|
+
reasoning_tokens: usage?.reasoning_tokens ?? 0,
|
|
90
|
+
ttfbMs: Number.isFinite(ttfbMs) ? ttfbMs : null,
|
|
91
|
+
totalMs: Number.isFinite(totalMs) ? totalMs : null,
|
|
92
|
+
tps: Number.isFinite(tps) ? tps : null,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 测试用:重置惰性清理标记,避免跨用例串味(置于文件末,业务函数在其上)
|
|
97
|
+
export function _resetPruneMarker() {
|
|
98
|
+
lastPrunedDay = null;
|
|
99
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// 窗口用量聚合:JSONL 行数组 + 时间窗口 → 每模型 token/速度报表。
|
|
2
|
+
// 纯函数(aggregateUsage)与薄 IO(readUsageRows / usageReport)分开,前者可离线单测。
|
|
3
|
+
// Note: 速度用加权口径 Σcompletion / ΣcompletionMs,不用算术平均 —— 短回答会把算术均值拉飞。
|
|
4
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { usageDir } from "./record.js";
|
|
7
|
+
|
|
8
|
+
const HOUR_MS = 3_600_000;
|
|
9
|
+
|
|
10
|
+
function tok(v) {
|
|
11
|
+
const n = Number(v);
|
|
12
|
+
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function ms(v) {
|
|
16
|
+
const n = Number(v);
|
|
17
|
+
return Number.isFinite(n) && n >= 0 ? n : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function blank(id) {
|
|
21
|
+
return {
|
|
22
|
+
id,
|
|
23
|
+
requests: 0,
|
|
24
|
+
promptTokens: 0,
|
|
25
|
+
completionTokens: 0,
|
|
26
|
+
totalTokens: 0,
|
|
27
|
+
reasoningTokens: 0,
|
|
28
|
+
ttfbSumMs: 0,
|
|
29
|
+
ttfbN: 0,
|
|
30
|
+
totalSumMs: 0,
|
|
31
|
+
totalN: 0,
|
|
32
|
+
completionMsSum: 0,
|
|
33
|
+
tpsTokSum: 0,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 把累计量收敛成对外字段:平均首字/平均总耗时/加权速度。
|
|
38
|
+
function finalize(a) {
|
|
39
|
+
return {
|
|
40
|
+
id: a.id,
|
|
41
|
+
requests: a.requests,
|
|
42
|
+
promptTokens: a.promptTokens,
|
|
43
|
+
completionTokens: a.completionTokens,
|
|
44
|
+
totalTokens: a.totalTokens,
|
|
45
|
+
reasoningTokens: a.reasoningTokens,
|
|
46
|
+
avgTtfbMs: a.ttfbN ? Math.round(a.ttfbSumMs / a.ttfbN) : null,
|
|
47
|
+
avgTotalMs: a.totalN ? Math.round(a.totalSumMs / a.totalN) : null,
|
|
48
|
+
avgTps: a.completionMsSum > 0 ? Number((a.tpsTokSum / (a.completionMsSum / 1000)).toFixed(1)) : null,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function fold(acc, r) {
|
|
53
|
+
acc.requests++;
|
|
54
|
+
const prompt = tok(r.prompt_tokens);
|
|
55
|
+
const comp = tok(r.completion_tokens);
|
|
56
|
+
acc.promptTokens += prompt;
|
|
57
|
+
acc.completionTokens += comp;
|
|
58
|
+
const total = tok(r.total_tokens);
|
|
59
|
+
acc.totalTokens += total || prompt + comp;
|
|
60
|
+
acc.reasoningTokens += tok(r.reasoning_tokens);
|
|
61
|
+
|
|
62
|
+
const ttfb = ms(r.ttfbMs ?? r.ttfb_ms);
|
|
63
|
+
if (ttfb != null) {
|
|
64
|
+
acc.ttfbSumMs += ttfb;
|
|
65
|
+
acc.ttfbN++;
|
|
66
|
+
}
|
|
67
|
+
const totalMs = ms(r.totalMs ?? r.total_ms);
|
|
68
|
+
if (totalMs != null && totalMs > 0) {
|
|
69
|
+
acc.totalSumMs += totalMs;
|
|
70
|
+
acc.totalN++;
|
|
71
|
+
}
|
|
72
|
+
// 生成阶段耗时 = 总耗时 - 首字(首字缺失按 0 计)
|
|
73
|
+
if (totalMs != null && totalMs > 0 && comp > 0) {
|
|
74
|
+
const compMs = Math.max(0, totalMs - (ttfb ?? 0));
|
|
75
|
+
if (compMs > 0) {
|
|
76
|
+
acc.completionMsSum += compMs;
|
|
77
|
+
acc.tpsTokSum += comp;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return acc;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 纯函数:只做过滤 + 归并,不碰磁盘。
|
|
84
|
+
export function aggregateUsage(rows, { hours = 24, now = Date.now(), model = null } = {}) {
|
|
85
|
+
const windowHours = Number.isFinite(Number(hours)) && Number(hours) > 0 ? Number(hours) : 24;
|
|
86
|
+
const untilN = Number(now);
|
|
87
|
+
const until = Number.isFinite(untilN) ? untilN : Date.now();
|
|
88
|
+
const since = until - windowHours * HOUR_MS;
|
|
89
|
+
const byModel = new Map();
|
|
90
|
+
const sum = blank(null);
|
|
91
|
+
let scanned = 0;
|
|
92
|
+
|
|
93
|
+
if (Array.isArray(rows)) {
|
|
94
|
+
for (const r of rows) {
|
|
95
|
+
if (!r || typeof r !== "object") continue;
|
|
96
|
+
const ts = Number(r.ts);
|
|
97
|
+
if (!Number.isFinite(ts) || ts < since || ts > until) continue;
|
|
98
|
+
const id = String(r.model || "").trim();
|
|
99
|
+
if (!id) continue;
|
|
100
|
+
// model 过滤同时接受 canonical 全称与裸 id(如 --model big-pickle 匹配 opencode/big-pickle 行)
|
|
101
|
+
if (model && id !== model && !id.endsWith("/" + model)) continue;
|
|
102
|
+
scanned++;
|
|
103
|
+
let acc = byModel.get(id);
|
|
104
|
+
if (!acc) { acc = blank(id); byModel.set(id, acc); }
|
|
105
|
+
fold(acc, r);
|
|
106
|
+
fold(sum, r);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const models = [...byModel.values()].map(finalize);
|
|
110
|
+
models.sort((a, b) => (b.totalTokens - a.totalTokens) || (b.requests - a.requests) || a.id.localeCompare(b.id));
|
|
111
|
+
return {
|
|
112
|
+
windowHours,
|
|
113
|
+
since,
|
|
114
|
+
until,
|
|
115
|
+
rows: scanned,
|
|
116
|
+
models,
|
|
117
|
+
totals: finalize(sum),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 读保留期内的日文件并只留窗口内的行(保留期默认 2 天,文件数很少,全读即可)。
|
|
122
|
+
export async function readUsageRows({ dir, hours = 24, now = Date.now() } = {}) {
|
|
123
|
+
const since = Number(now) - (Number(hours) > 0 ? Number(hours) : 24) * HOUR_MS;
|
|
124
|
+
let files = [];
|
|
125
|
+
try {
|
|
126
|
+
files = (await readdir(usageDir({ dir }))).filter((f) => f.endsWith(".jsonl")).sort();
|
|
127
|
+
} catch {
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
const rows = [];
|
|
131
|
+
for (const f of files) {
|
|
132
|
+
let text = "";
|
|
133
|
+
try {
|
|
134
|
+
text = await readFile(join(usageDir({ dir }), f), "utf8");
|
|
135
|
+
} catch {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
for (const line of text.split("\n")) {
|
|
139
|
+
if (!line) continue;
|
|
140
|
+
try {
|
|
141
|
+
const r = JSON.parse(line);
|
|
142
|
+
if (Number(r?.ts) >= since) rows.push(r);
|
|
143
|
+
} catch {}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return rows;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function usageReport({ dir, hours = 24, now = Date.now(), model = null } = {}) {
|
|
150
|
+
const rows = await readUsageRows({ dir, hours, now });
|
|
151
|
+
return aggregateUsage(rows, { hours, now, model });
|
|
152
|
+
}
|