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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.134",
3
+ "version": "0.1.136",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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: isHardLocalOnly(model) ? "provider local-only(禁组员,仅本机直连)" : "useGroup=off (peer)" });
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: isHardLocalOnly(model) ? "provider local-only(禁组员,仅本机直连)" : "useGroup=off (broadband)" });
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 (本机失败时是否走组员网络,默认 on)`);
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 ? "允许" : "不再允许"}走组员网络(via-route/hedge/peer/broadband,全部供应商)`);
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条,含首字/tok/s)");
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, sumTps = 0, tpsN = 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
- const avgTps = tpsN ? Math.round(sumTps / tpsN) : null;
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" : ""}${ttfb}${tps}${tok}${c.auto ? " auto" : ""}`);
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
- return { ready, get, list, providers, npmIndex: () => new Map(npmIndex) };
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 });
@@ -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
- return { ...h, ...extraHeaders };
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
- const { runChat } = createChatRunner({
68
- id, ring, cooldownMs, retry, fetchImpl, dispatcher, buildHeaders,
69
- getUrl: () => joinUrl(resolvedBase, resolvedChatPath),
70
- connectTimeoutMs,
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 硬禁组员(ADR-0015 local-only):本机账号绑定(auths/workbuddy-*.json + uid),
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 s = String(model || "").trim().toLowerCase();
45
- const slash = s.indexOf("/");
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
+ }