token-stats-timer 1.0.15 → 1.0.17
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/README.md +9 -2
- package/package.json +1 -1
- package/token-stats.ts +229 -7
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ Footer 上行(左对齐):
|
|
|
16
16
|
- `↑ ↓ Σ CH` —— 累计输入 / 输出 / 总量 / 缓存命中率
|
|
17
17
|
- `⚡` —— 实时速率(2s rolling window,无流时回落到平均速率)
|
|
18
18
|
- 上下文占用(样式可配)
|
|
19
|
-
- `5h: W: ⏱` —— 套餐剩余(MiniMax / GLM / Kimi / DeepSeek 内置套餐,需在 /stats 里为当前 provider 启用)
|
|
19
|
+
- `5h: W: ⏱` —— 套餐剩余(MiniMax / GLM / Kimi / DeepSeek / OpenCode Go 内置套餐,需在 /stats 里为当前 provider 启用)
|
|
20
20
|
|
|
21
21
|
Footer 下行:cwd + git 分支 + 其他扩展状态。
|
|
22
22
|
|
|
@@ -76,8 +76,9 @@ Footer 下行:cwd + git 分支 + 其他扩展状态。
|
|
|
76
76
|
|
|
77
77
|
## 命令
|
|
78
78
|
|
|
79
|
-
- `/stats` ——
|
|
79
|
+
- `/stats` —— 无参默认显示**当天 token 统计**(等价 `/stats day`)
|
|
80
80
|
- `/stats day [YYYY-MM-DD]` / `hour` / `week` / `month [YYYY-MM]` —— 统计查询
|
|
81
|
+
- `/stats limit` —— 套餐配置(为当前 provider 选择/关闭配额套餐;选 GLM 后会继续询问是否配置团队套餐凭证)
|
|
81
82
|
- `/stats config` —— 显示样式 / 显示内容 / 配额刷新时间 / GLM 团队凭证
|
|
82
83
|
- `/notify [on|off|test]` —— 通知开关 / 测试(无参查看状态)
|
|
83
84
|
|
|
@@ -101,6 +102,12 @@ Footer 下行:cwd + git 分支 + 其他扩展状态。
|
|
|
101
102
|
|
|
102
103
|
> 组织 ID / 项目 ID 在 GLM Coding Plan 团队版后台「团队编程套餐」页面获取;如果你同时使用 Claude Code / Cursor 等工具并已在环境变量里配置,也可以在 config.json 里直接填上同名值。
|
|
103
104
|
|
|
105
|
+
## OpenCode Go 余额
|
|
106
|
+
|
|
107
|
+
OpenCode Go 订阅(`opencode-go` provider,baseUrl `https://opencode.ai/zen/go/v1`)使用官方配额接口 `GET /zen/go/v1/usage`(`Authorization: Bearer <key>`,即 auth.json 里 `opencode-go` 的 `key` 或环境变量 `OPENCODE_API_KEY`),返回三个滚动窗口的已用百分比:5 小时 / 周 / 月。
|
|
108
|
+
|
|
109
|
+
footer 显示 `5h: X% W: Y% M: Z% ⏱ ...`(剩余比例 = 100 - 已用),后三个子项可分别用 `/stats config` → 显示内容 的「5h额度 / 周额度 / 月额度 / 刷新时间」开关控制。
|
|
110
|
+
|
|
104
111
|
## 与原包的兼容性
|
|
105
112
|
|
|
106
113
|
- 显示/套餐配置沿用 `~/.pi/agent/extensions/token-stats/`(原 token-stats 的配置直接生效)
|
package/package.json
CHANGED
package/token-stats.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
appendFile,
|
|
21
21
|
mkdir,
|
|
22
22
|
readFile,
|
|
23
|
+
readdir,
|
|
23
24
|
writeFile,
|
|
24
25
|
} from "node:fs/promises";
|
|
25
26
|
import { join } from "node:path";
|
|
@@ -151,6 +152,7 @@ export type DisplayKey =
|
|
|
151
152
|
| "context" // 容量(ctx%)
|
|
152
153
|
| "quota5h" // 5h 额度
|
|
153
154
|
| "quotaWeek" // 周额度
|
|
155
|
+
| "quotaMonth" // 月额度(OpenCode Go 等)
|
|
154
156
|
| "quotaClock" // 刷新时间(⏱)
|
|
155
157
|
|
|
156
158
|
export interface DisplayConfig {
|
|
@@ -248,6 +250,134 @@ function renderTable(
|
|
|
248
250
|
return lines;
|
|
249
251
|
}
|
|
250
252
|
|
|
253
|
+
// ── 按模型统计(读取 raw 明细,按 model 聚合)────────────────────
|
|
254
|
+
|
|
255
|
+
/** 读取若干日期的原始逐条记录 */
|
|
256
|
+
async function readRawRecordsForDates(dates: string[]): Promise<RawRecord[]> {
|
|
257
|
+
const out: RawRecord[] = [];
|
|
258
|
+
for (const d of dates) {
|
|
259
|
+
try {
|
|
260
|
+
const content = await readFile(join(RAW_DIR, `${d}.jsonl`), "utf-8");
|
|
261
|
+
for (const line of content.trim().split("\n")) {
|
|
262
|
+
if (line) out.push(JSON.parse(line));
|
|
263
|
+
}
|
|
264
|
+
} catch {
|
|
265
|
+
// 该日无原始数据
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return out;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** 读取 [startDate, endDate] 闭区间内所有日期的原始记录 */
|
|
272
|
+
async function readRawRecordsInRange(
|
|
273
|
+
startDate: string,
|
|
274
|
+
endDate: string,
|
|
275
|
+
): Promise<RawRecord[]> {
|
|
276
|
+
const dates: string[] = [];
|
|
277
|
+
try {
|
|
278
|
+
const files = await readdir(RAW_DIR);
|
|
279
|
+
for (const f of files) {
|
|
280
|
+
if (!f.endsWith(".jsonl")) continue;
|
|
281
|
+
const d = f.slice(0, 10);
|
|
282
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(d) && d >= startDate && d <= endDate) {
|
|
283
|
+
dates.push(d);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
} catch {
|
|
287
|
+
// RAW_DIR 不存在
|
|
288
|
+
}
|
|
289
|
+
return readRawRecordsForDates(dates);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
interface ModelAgg {
|
|
293
|
+
count: number;
|
|
294
|
+
input: number;
|
|
295
|
+
cacheRead: number;
|
|
296
|
+
cacheWrite: number;
|
|
297
|
+
output: number;
|
|
298
|
+
tokensPerSecSum: number;
|
|
299
|
+
hitRateSum: number;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** 按模型生成 markdown 用量表(模型行按总token降序,末尾合计) */
|
|
303
|
+
function renderModelBreakdown(records: RawRecord[]): string[] {
|
|
304
|
+
if (records.length === 0) return ["", "> 按模型:该范围暂无明细数据"];
|
|
305
|
+
|
|
306
|
+
const byModel = new Map<string, ModelAgg>();
|
|
307
|
+
for (const r of records) {
|
|
308
|
+
const key = r.model || "unknown";
|
|
309
|
+
const agg = byModel.get(key) ?? {
|
|
310
|
+
count: 0,
|
|
311
|
+
input: 0,
|
|
312
|
+
cacheRead: 0,
|
|
313
|
+
cacheWrite: 0,
|
|
314
|
+
output: 0,
|
|
315
|
+
tokensPerSecSum: 0,
|
|
316
|
+
hitRateSum: 0,
|
|
317
|
+
};
|
|
318
|
+
agg.count++;
|
|
319
|
+
agg.input += r.input;
|
|
320
|
+
agg.cacheRead += r.cacheRead;
|
|
321
|
+
agg.cacheWrite += r.cacheWrite;
|
|
322
|
+
agg.output += r.output;
|
|
323
|
+
agg.tokensPerSecSum += r.tokensPerSec;
|
|
324
|
+
agg.hitRateSum += r.cacheHitRate;
|
|
325
|
+
byModel.set(key, agg);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// 与汇总口径一致:总token = 新增输入 + 缓存输入
|
|
329
|
+
const totalTokensOf = (a: ModelAgg) => a.input + a.cacheRead + a.cacheWrite;
|
|
330
|
+
const rows = [...byModel.entries()].sort(
|
|
331
|
+
(a, b) => totalTokensOf(b[1]) - totalTokensOf(a[1]),
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
const total: ModelAgg = {
|
|
335
|
+
count: 0, input: 0, cacheRead: 0, cacheWrite: 0,
|
|
336
|
+
output: 0, tokensPerSecSum: 0, hitRateSum: 0,
|
|
337
|
+
};
|
|
338
|
+
const body: string[][] = rows.map(([model, agg]) => {
|
|
339
|
+
total.count += agg.count;
|
|
340
|
+
total.input += agg.input;
|
|
341
|
+
total.cacheRead += agg.cacheRead;
|
|
342
|
+
total.cacheWrite += agg.cacheWrite;
|
|
343
|
+
total.output += agg.output;
|
|
344
|
+
total.tokensPerSecSum += agg.tokensPerSecSum;
|
|
345
|
+
total.hitRateSum += agg.hitRateSum;
|
|
346
|
+
return [
|
|
347
|
+
model,
|
|
348
|
+
String(agg.count),
|
|
349
|
+
formatTokens(agg.input),
|
|
350
|
+
formatTokens(agg.cacheRead),
|
|
351
|
+
formatTokens(agg.output),
|
|
352
|
+
formatTokens(totalTokensOf(agg)),
|
|
353
|
+
`${(agg.count > 0 ? agg.hitRateSum / agg.count : 0).toFixed(1)}%`,
|
|
354
|
+
`${(agg.count > 0 ? agg.tokensPerSecSum / agg.count : 0).toFixed(1)}`,
|
|
355
|
+
];
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
return [
|
|
359
|
+
"",
|
|
360
|
+
"**按模型**",
|
|
361
|
+
...renderTable(
|
|
362
|
+
["模型", "次数", "新增输入", "缓存输入", "输出", "总token", "命中率", "速率"],
|
|
363
|
+
body,
|
|
364
|
+
{
|
|
365
|
+
aligns: ["left", "right", "right", "right", "right", "right", "right", "right"],
|
|
366
|
+
totalRow: [
|
|
367
|
+
"合计",
|
|
368
|
+
String(total.count),
|
|
369
|
+
formatTokens(total.input),
|
|
370
|
+
formatTokens(total.cacheRead),
|
|
371
|
+
formatTokens(total.output),
|
|
372
|
+
formatTokens(total.input + total.cacheRead + total.cacheWrite),
|
|
373
|
+
`${(total.count > 0 ? total.hitRateSum / total.count : 0).toFixed(1)}%`,
|
|
374
|
+
`${(total.count > 0 ? total.tokensPerSecSum / total.count : 0).toFixed(1)}`,
|
|
375
|
+
],
|
|
376
|
+
},
|
|
377
|
+
),
|
|
378
|
+
];
|
|
379
|
+
}
|
|
380
|
+
|
|
251
381
|
function isReasonableTokenSpeed(tokensPerSecond: number): boolean {
|
|
252
382
|
return Number.isFinite(tokensPerSecond) && tokensPerSecond > 0 && tokensPerSecond <= MAX_REASONABLE_TOKEN_SPEED;
|
|
253
383
|
}
|
|
@@ -543,6 +673,73 @@ const BUILTIN_PLANS: TokenPlan[] = [
|
|
|
543
673
|
};
|
|
544
674
|
},
|
|
545
675
|
},
|
|
676
|
+
{
|
|
677
|
+
id: "opencode-go",
|
|
678
|
+
name: "OpenCode Go",
|
|
679
|
+
matchProviders: ["opencode-go"],
|
|
680
|
+
apiKeyEnv: "OPENCODE_API_KEY",
|
|
681
|
+
baseUrl: "https://opencode.ai",
|
|
682
|
+
quotaPath: "/zen/go/v1/usage",
|
|
683
|
+
authHeader: (key) => ({ Authorization: "Bearer " + key }),
|
|
684
|
+
fetchQuota: async (plan: TokenPlan, key: string) => {
|
|
685
|
+
const r = await fetch(plan.baseUrl + plan.quotaPath, {
|
|
686
|
+
method: "GET",
|
|
687
|
+
headers: {
|
|
688
|
+
Authorization: "Bearer " + key,
|
|
689
|
+
"Content-Type": "application/json",
|
|
690
|
+
},
|
|
691
|
+
signal: AbortSignal.timeout(5000),
|
|
692
|
+
});
|
|
693
|
+
if (!r.ok) throw new Error("OpenCode Go 配额查询 HTTP " + r.status);
|
|
694
|
+
return await r.json();
|
|
695
|
+
},
|
|
696
|
+
format: (data: any) => {
|
|
697
|
+
// 官方 /v1/usage:percent 为已用比例,remaining = 100 - percent
|
|
698
|
+
const u = (data && typeof data === "object" ? (data.usage ?? data) : {}) as any;
|
|
699
|
+
const win = (k: string): any => {
|
|
700
|
+
const w = u?.[k];
|
|
701
|
+
if (w && w.status === "ok" && typeof w.percent === "number") return w;
|
|
702
|
+
return null;
|
|
703
|
+
};
|
|
704
|
+
const rolling = win("rolling");
|
|
705
|
+
const weekly = win("weekly");
|
|
706
|
+
const monthly = win("monthly");
|
|
707
|
+
if (!rolling && !weekly && !monthly) {
|
|
708
|
+
return { modelPrefix: "", display: "无数据", color: "err" as const };
|
|
709
|
+
}
|
|
710
|
+
const now = Date.now();
|
|
711
|
+
const resets = [rolling, weekly, monthly]
|
|
712
|
+
.filter((w): w is any => !!w)
|
|
713
|
+
.map((w) => {
|
|
714
|
+
const t = typeof w.resetsAt === "string" ? Date.parse(w.resetsAt) : NaN;
|
|
715
|
+
return Number.isFinite(t) && t > now ? t : null;
|
|
716
|
+
})
|
|
717
|
+
.filter((t): t is number => t !== null);
|
|
718
|
+
const nearestReset = resets.length > 0 ? Math.min(...resets) : null;
|
|
719
|
+
|
|
720
|
+
const rem = (w: any) => (w ? 100 - w.percent : null);
|
|
721
|
+
const r = rem(rolling);
|
|
722
|
+
const wk = rem(weekly);
|
|
723
|
+
const mo = rem(monthly);
|
|
724
|
+
const parts: string[] = [];
|
|
725
|
+
if (r !== null) parts.push(`5h: ${Math.round(r)}%`);
|
|
726
|
+
if (wk !== null) parts.push(`W: ${Math.round(wk)}%`);
|
|
727
|
+
if (mo !== null) parts.push(`M: ${Math.round(mo)}%`);
|
|
728
|
+
let display = parts.join(" ");
|
|
729
|
+
if (nearestReset) {
|
|
730
|
+
const diff = nearestReset - now;
|
|
731
|
+
if (diff > 0 && diff < 30 * 24 * 60 * 60 * 1000) {
|
|
732
|
+
display += ` ⏱ ${formatDuration(diff)}`;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
const low = (v: number | null) => v !== null && v < 20;
|
|
736
|
+
const mid = (v: number | null) => v !== null && v < 50;
|
|
737
|
+
const color = low(r) || low(wk) || low(mo) ? "err" as const
|
|
738
|
+
: mid(r) || mid(wk) || mid(mo) ? "warn" as const
|
|
739
|
+
: "ok" as const;
|
|
740
|
+
return { modelPrefix: "", display, color };
|
|
741
|
+
},
|
|
742
|
+
},
|
|
546
743
|
];
|
|
547
744
|
|
|
548
745
|
const DEFAULT_TOKEN_CONFIG: TokenConfig = { providerPlans: {}, ttl: 60 };
|
|
@@ -557,6 +754,7 @@ const DEFAULT_DISPLAY_CONFIG: DisplayConfig = {
|
|
|
557
754
|
context: true,
|
|
558
755
|
quota5h: true,
|
|
559
756
|
quotaWeek: true,
|
|
757
|
+
quotaMonth: true,
|
|
560
758
|
quotaClock: true,
|
|
561
759
|
},
|
|
562
760
|
contextStyle: "pct-window",
|
|
@@ -786,6 +984,10 @@ export function createTokenStats(
|
|
|
786
984
|
const m = fullDisplay.match(/\bW:\s+\d+%/);
|
|
787
985
|
if (m) filteredParts.push(m[0]);
|
|
788
986
|
}
|
|
987
|
+
if (cfg.quotaMonth) {
|
|
988
|
+
const m = fullDisplay.match(/\bM:\s+\d+%/);
|
|
989
|
+
if (m) filteredParts.push(m[0]);
|
|
990
|
+
}
|
|
789
991
|
if (cfg.quotaClock) {
|
|
790
992
|
const m = fullDisplay.match(/⏱\s*\d+[hm]/);
|
|
791
993
|
if (m) filteredParts.push(m[0]);
|
|
@@ -1405,7 +1607,7 @@ export function createTokenStats(
|
|
|
1405
1607
|
}
|
|
1406
1608
|
|
|
1407
1609
|
await showStats(
|
|
1408
|
-
renderDaySummary(daily),
|
|
1610
|
+
[...renderDaySummary(daily), ...renderModelBreakdown(await readRawRecordsForDates([date]))],
|
|
1409
1611
|
`Token 统计 | ${date}`,
|
|
1410
1612
|
ctx,
|
|
1411
1613
|
);
|
|
@@ -1489,7 +1691,11 @@ export function createTokenStats(
|
|
|
1489
1691
|
},
|
|
1490
1692
|
);
|
|
1491
1693
|
|
|
1492
|
-
await showStats(
|
|
1694
|
+
await showStats(
|
|
1695
|
+
[...lines, ...renderModelBreakdown(await readRawRecordsInRange(sevenDaysAgo, today))],
|
|
1696
|
+
"本周每天汇总",
|
|
1697
|
+
ctx,
|
|
1698
|
+
);
|
|
1493
1699
|
}
|
|
1494
1700
|
|
|
1495
1701
|
function getMonthStr(date: Date = new Date()): string {
|
|
@@ -1563,7 +1769,17 @@ export function createTokenStats(
|
|
|
1563
1769
|
},
|
|
1564
1770
|
);
|
|
1565
1771
|
|
|
1566
|
-
|
|
1772
|
+
const monthDates = monthRecords.map((r) => r.date).sort();
|
|
1773
|
+
await showStats(
|
|
1774
|
+
[
|
|
1775
|
+
...lines,
|
|
1776
|
+
...renderModelBreakdown(
|
|
1777
|
+
await readRawRecordsInRange(monthDates[0], monthDates[monthDates.length - 1]),
|
|
1778
|
+
),
|
|
1779
|
+
],
|
|
1780
|
+
`${month} 月度汇总`,
|
|
1781
|
+
ctx,
|
|
1782
|
+
);
|
|
1567
1783
|
}
|
|
1568
1784
|
|
|
1569
1785
|
// ── 事件注册 ─────────────────────────────────────────
|
|
@@ -1784,12 +2000,18 @@ export function createTokenStats(
|
|
|
1784
2000
|
// ── /stats 命令 ─────────────────────────────────────
|
|
1785
2001
|
|
|
1786
2002
|
pi.registerCommand("stats", {
|
|
1787
|
-
description: "Token 统计 (day | hour | week | month | config)
|
|
2003
|
+
description: "Token 统计 (day | hour | week | month | config | limit) 无参默认显示当天统计;limit 进入套餐配置",
|
|
1788
2004
|
handler: async (args, ctx) => {
|
|
1789
2005
|
const arg = args.trim();
|
|
1790
2006
|
|
|
1791
|
-
// 无参 →
|
|
2007
|
+
// 无参 → 当天统计(等价于 /stats day)
|
|
1792
2008
|
if (!arg) {
|
|
2009
|
+
await showDay(getDateStr(), ctx);
|
|
2010
|
+
return;
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
// limit → 套餐配置(原无参行为)
|
|
2014
|
+
if (arg === "limit") {
|
|
1793
2015
|
const provider = ctx.model?.provider;
|
|
1794
2016
|
if (!provider) {
|
|
1795
2017
|
ctx.ui.notify("无法获取当前供应商,请先切换对话", "warning");
|
|
@@ -1969,12 +2191,12 @@ export function createTokenStats(
|
|
|
1969
2191
|
} else if (subChoice === "显示内容") {
|
|
1970
2192
|
const itemLabels: DisplayKey[] = [
|
|
1971
2193
|
"input", "output", "totalTokens", "cacheHit", "speed", "context",
|
|
1972
|
-
"quota5h", "quotaWeek", "quotaClock",
|
|
2194
|
+
"quota5h", "quotaWeek", "quotaMonth", "quotaClock",
|
|
1973
2195
|
];
|
|
1974
2196
|
const itemNames: Record<DisplayKey, string> = {
|
|
1975
2197
|
input: "输入", output: "输出", totalTokens: "总token",
|
|
1976
2198
|
cacheHit: "缓存命中", speed: "速度", context: "容量",
|
|
1977
|
-
quota5h: "5h额度", quotaWeek: "周额度", quotaClock: "刷新时间",
|
|
2199
|
+
quota5h: "5h额度", quotaWeek: "周额度", quotaMonth: "月额度", quotaClock: "刷新时间",
|
|
1978
2200
|
};
|
|
1979
2201
|
while (true) {
|
|
1980
2202
|
const options = itemLabels.map(k =>
|