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 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` —— 无参进入套餐配置(为当前 provider 选择/关闭配额套餐;选 GLM 后会继续询问是否配置团队套餐凭证)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "token-stats-timer",
3
- "version": "1.0.15",
3
+ "version": "1.0.17",
4
4
  "description": "pi extension to display token usage and time spend.",
5
5
  "keywords": [
6
6
  "pi-package",
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(lines, "本周每天汇总", ctx);
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
- await showStats(lines, `${month} 月度汇总`, ctx);
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 =>