token-stats-timer 1.0.14 → 1.0.16
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 +7 -1
- package/package.json +1 -1
- package/token-stats.ts +188 -71
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
|
|
|
@@ -101,6 +101,12 @@ Footer 下行:cwd + git 分支 + 其他扩展状态。
|
|
|
101
101
|
|
|
102
102
|
> 组织 ID / 项目 ID 在 GLM Coding Plan 团队版后台「团队编程套餐」页面获取;如果你同时使用 Claude Code / Cursor 等工具并已在环境变量里配置,也可以在 config.json 里直接填上同名值。
|
|
103
103
|
|
|
104
|
+
## OpenCode Go 余额
|
|
105
|
+
|
|
106
|
+
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 小时 / 周 / 月。
|
|
107
|
+
|
|
108
|
+
footer 显示 `5h: X% W: Y% M: Z% ⏱ ...`(剩余比例 = 100 - 已用),后三个子项可分别用 `/stats config` → 显示内容 的「5h额度 / 周额度 / 月额度 / 刷新时间」开关控制。
|
|
109
|
+
|
|
104
110
|
## 与原包的兼容性
|
|
105
111
|
|
|
106
112
|
- 显示/套餐配置沿用 `~/.pi/agent/extensions/token-stats/`(原 token-stats 的配置直接生效)
|
package/package.json
CHANGED
package/token-stats.ts
CHANGED
|
@@ -13,8 +13,9 @@ import type {
|
|
|
13
13
|
ExtensionContext,
|
|
14
14
|
Theme,
|
|
15
15
|
} from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
16
17
|
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
17
|
-
import {
|
|
18
|
+
import { Markdown } from "@earendil-works/pi-tui";
|
|
18
19
|
import {
|
|
19
20
|
appendFile,
|
|
20
21
|
mkdir,
|
|
@@ -150,6 +151,7 @@ export type DisplayKey =
|
|
|
150
151
|
| "context" // 容量(ctx%)
|
|
151
152
|
| "quota5h" // 5h 额度
|
|
152
153
|
| "quotaWeek" // 周额度
|
|
154
|
+
| "quotaMonth" // 月额度(OpenCode Go 等)
|
|
153
155
|
| "quotaClock" // 刷新时间(⏱)
|
|
154
156
|
|
|
155
157
|
export interface DisplayConfig {
|
|
@@ -212,6 +214,41 @@ function formatTokenSpeed(tokensPerSecond: number): string {
|
|
|
212
214
|
return `${Math.round(tokensPerSecond / 1000000)}M`;
|
|
213
215
|
}
|
|
214
216
|
|
|
217
|
+
/**
|
|
218
|
+
* 生成 GitHub 风格 markdown 表格源码(由 Markdown 组件负责渲染对齐/换行)。
|
|
219
|
+
*
|
|
220
|
+
* @param headers 表头
|
|
221
|
+
* @param rows 数据行
|
|
222
|
+
* @param opts.aligns 每列对齐方式,缺省左对齐
|
|
223
|
+
* @param opts.totalRow 底部合计行
|
|
224
|
+
*/
|
|
225
|
+
function renderTable(
|
|
226
|
+
headers: string[],
|
|
227
|
+
rows: string[][],
|
|
228
|
+
opts?: {
|
|
229
|
+
aligns?: Array<"left" | "right" | "center">;
|
|
230
|
+
totalRow?: string[];
|
|
231
|
+
},
|
|
232
|
+
): string[] {
|
|
233
|
+
const aligns = opts?.aligns ?? [];
|
|
234
|
+
// 转义单元格内的 | 与换行,防止破坏表格结构
|
|
235
|
+
const esc = (s: string) => s.replace(/\|/g, "\\|").replace(/\n/g, " ");
|
|
236
|
+
const alignMark = (i: number) => {
|
|
237
|
+
const a = aligns[i] ?? "left";
|
|
238
|
+
return a === "right" ? "---:" : a === "center" ? ":---:" : "---";
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const lines = [
|
|
242
|
+
`| ${headers.map(esc).join(" | ")} |`,
|
|
243
|
+
`| ${headers.map((_, i) => alignMark(i)).join(" | ")} |`,
|
|
244
|
+
...rows.map((r) => `| ${r.map(esc).join(" | ")} |`),
|
|
245
|
+
];
|
|
246
|
+
if (opts?.totalRow) {
|
|
247
|
+
lines.push(`| ${opts.totalRow.map(esc).join(" | ")} |`);
|
|
248
|
+
}
|
|
249
|
+
return lines;
|
|
250
|
+
}
|
|
251
|
+
|
|
215
252
|
function isReasonableTokenSpeed(tokensPerSecond: number): boolean {
|
|
216
253
|
return Number.isFinite(tokensPerSecond) && tokensPerSecond > 0 && tokensPerSecond <= MAX_REASONABLE_TOKEN_SPEED;
|
|
217
254
|
}
|
|
@@ -507,6 +544,73 @@ const BUILTIN_PLANS: TokenPlan[] = [
|
|
|
507
544
|
};
|
|
508
545
|
},
|
|
509
546
|
},
|
|
547
|
+
{
|
|
548
|
+
id: "opencode-go",
|
|
549
|
+
name: "OpenCode Go",
|
|
550
|
+
matchProviders: ["opencode-go"],
|
|
551
|
+
apiKeyEnv: "OPENCODE_API_KEY",
|
|
552
|
+
baseUrl: "https://opencode.ai",
|
|
553
|
+
quotaPath: "/zen/go/v1/usage",
|
|
554
|
+
authHeader: (key) => ({ Authorization: "Bearer " + key }),
|
|
555
|
+
fetchQuota: async (plan: TokenPlan, key: string) => {
|
|
556
|
+
const r = await fetch(plan.baseUrl + plan.quotaPath, {
|
|
557
|
+
method: "GET",
|
|
558
|
+
headers: {
|
|
559
|
+
Authorization: "Bearer " + key,
|
|
560
|
+
"Content-Type": "application/json",
|
|
561
|
+
},
|
|
562
|
+
signal: AbortSignal.timeout(5000),
|
|
563
|
+
});
|
|
564
|
+
if (!r.ok) throw new Error("OpenCode Go 配额查询 HTTP " + r.status);
|
|
565
|
+
return await r.json();
|
|
566
|
+
},
|
|
567
|
+
format: (data: any) => {
|
|
568
|
+
// 官方 /v1/usage:percent 为已用比例,remaining = 100 - percent
|
|
569
|
+
const u = (data && typeof data === "object" ? (data.usage ?? data) : {}) as any;
|
|
570
|
+
const win = (k: string): any => {
|
|
571
|
+
const w = u?.[k];
|
|
572
|
+
if (w && w.status === "ok" && typeof w.percent === "number") return w;
|
|
573
|
+
return null;
|
|
574
|
+
};
|
|
575
|
+
const rolling = win("rolling");
|
|
576
|
+
const weekly = win("weekly");
|
|
577
|
+
const monthly = win("monthly");
|
|
578
|
+
if (!rolling && !weekly && !monthly) {
|
|
579
|
+
return { modelPrefix: "", display: "无数据", color: "err" as const };
|
|
580
|
+
}
|
|
581
|
+
const now = Date.now();
|
|
582
|
+
const resets = [rolling, weekly, monthly]
|
|
583
|
+
.filter((w): w is any => !!w)
|
|
584
|
+
.map((w) => {
|
|
585
|
+
const t = typeof w.resetsAt === "string" ? Date.parse(w.resetsAt) : NaN;
|
|
586
|
+
return Number.isFinite(t) && t > now ? t : null;
|
|
587
|
+
})
|
|
588
|
+
.filter((t): t is number => t !== null);
|
|
589
|
+
const nearestReset = resets.length > 0 ? Math.min(...resets) : null;
|
|
590
|
+
|
|
591
|
+
const rem = (w: any) => (w ? 100 - w.percent : null);
|
|
592
|
+
const r = rem(rolling);
|
|
593
|
+
const wk = rem(weekly);
|
|
594
|
+
const mo = rem(monthly);
|
|
595
|
+
const parts: string[] = [];
|
|
596
|
+
if (r !== null) parts.push(`5h: ${Math.round(r)}%`);
|
|
597
|
+
if (wk !== null) parts.push(`W: ${Math.round(wk)}%`);
|
|
598
|
+
if (mo !== null) parts.push(`M: ${Math.round(mo)}%`);
|
|
599
|
+
let display = parts.join(" ");
|
|
600
|
+
if (nearestReset) {
|
|
601
|
+
const diff = nearestReset - now;
|
|
602
|
+
if (diff > 0 && diff < 30 * 24 * 60 * 60 * 1000) {
|
|
603
|
+
display += ` ⏱ ${formatDuration(diff)}`;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
const low = (v: number | null) => v !== null && v < 20;
|
|
607
|
+
const mid = (v: number | null) => v !== null && v < 50;
|
|
608
|
+
const color = low(r) || low(wk) || low(mo) ? "err" as const
|
|
609
|
+
: mid(r) || mid(wk) || mid(mo) ? "warn" as const
|
|
610
|
+
: "ok" as const;
|
|
611
|
+
return { modelPrefix: "", display, color };
|
|
612
|
+
},
|
|
613
|
+
},
|
|
510
614
|
];
|
|
511
615
|
|
|
512
616
|
const DEFAULT_TOKEN_CONFIG: TokenConfig = { providerPlans: {}, ttl: 60 };
|
|
@@ -521,6 +625,7 @@ const DEFAULT_DISPLAY_CONFIG: DisplayConfig = {
|
|
|
521
625
|
context: true,
|
|
522
626
|
quota5h: true,
|
|
523
627
|
quotaWeek: true,
|
|
628
|
+
quotaMonth: true,
|
|
524
629
|
quotaClock: true,
|
|
525
630
|
},
|
|
526
631
|
contextStyle: "pct-window",
|
|
@@ -750,6 +855,10 @@ export function createTokenStats(
|
|
|
750
855
|
const m = fullDisplay.match(/\bW:\s+\d+%/);
|
|
751
856
|
if (m) filteredParts.push(m[0]);
|
|
752
857
|
}
|
|
858
|
+
if (cfg.quotaMonth) {
|
|
859
|
+
const m = fullDisplay.match(/\bM:\s+\d+%/);
|
|
860
|
+
if (m) filteredParts.push(m[0]);
|
|
861
|
+
}
|
|
753
862
|
if (cfg.quotaClock) {
|
|
754
863
|
const m = fullDisplay.match(/⏱\s*\d+[hm]/);
|
|
755
864
|
if (m) filteredParts.push(m[0]);
|
|
@@ -1317,23 +1426,25 @@ export function createTokenStats(
|
|
|
1317
1426
|
return total > 0 ? (d.sumCacheRead / total) * 100 : 0;
|
|
1318
1427
|
}
|
|
1319
1428
|
|
|
1320
|
-
function renderDaySummary(daily: DailyRecord): string {
|
|
1429
|
+
function renderDaySummary(daily: DailyRecord): string[] {
|
|
1321
1430
|
const d = daily;
|
|
1322
1431
|
const avgInput = d.count > 0 ? d.sumInput / d.count : 0;
|
|
1323
1432
|
const avgOutput = d.count > 0 ? d.sumOutput / d.count : 0;
|
|
1324
1433
|
const totalPrompt = d.sumInput + d.sumCacheRead + d.sumCacheWrite;
|
|
1325
1434
|
const cacheHitRate = weightedCacheHitRate(d);
|
|
1326
1435
|
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1436
|
+
return renderTable(
|
|
1437
|
+
["指标", "数值"],
|
|
1438
|
+
[
|
|
1439
|
+
["对话次数", String(d.count)],
|
|
1440
|
+
["新增输入", `${formatTokens(d.sumInput)}(平均 ${formatTokens(avgInput)}/次,未命中缓存)`],
|
|
1441
|
+
["缓存输入", formatTokens(d.sumCacheRead)],
|
|
1442
|
+
["总输出", `${formatTokens(d.sumOutput)}(平均 ${formatTokens(avgOutput)}/次)`],
|
|
1443
|
+
["总token", `${formatTokens(totalPrompt)}(新增 + 缓存)`],
|
|
1444
|
+
["缓存命中率", `${cacheHitRate.toFixed(1)}%`],
|
|
1445
|
+
["平均速率", `${(d.sumTokensPerSec / d.count).toFixed(1)} t/s`],
|
|
1446
|
+
],
|
|
1447
|
+
);
|
|
1337
1448
|
}
|
|
1338
1449
|
|
|
1339
1450
|
async function showStats(
|
|
@@ -1341,9 +1452,7 @@ export function createTokenStats(
|
|
|
1341
1452
|
title: string,
|
|
1342
1453
|
ctx: ExtensionContext,
|
|
1343
1454
|
) {
|
|
1344
|
-
const
|
|
1345
|
-
const text = `${theme.fg("accent", theme.bold(title))}\n${theme.fg("dim", "─".repeat(42))}\n` +
|
|
1346
|
-
lines.map((l) => theme.fg("dim", l)).join("\n");
|
|
1455
|
+
const text = `## ${title}\n\n${lines.join("\n")}`;
|
|
1347
1456
|
pi.sendMessage({
|
|
1348
1457
|
customType: "token-stats",
|
|
1349
1458
|
content: text,
|
|
@@ -1369,7 +1478,7 @@ export function createTokenStats(
|
|
|
1369
1478
|
}
|
|
1370
1479
|
|
|
1371
1480
|
await showStats(
|
|
1372
|
-
renderDaySummary(daily)
|
|
1481
|
+
renderDaySummary(daily),
|
|
1373
1482
|
`Token 统计 | ${date}`,
|
|
1374
1483
|
ctx,
|
|
1375
1484
|
);
|
|
@@ -1393,18 +1502,18 @@ export function createTokenStats(
|
|
|
1393
1502
|
|
|
1394
1503
|
records.sort((a, b) => a.hour - b.hour);
|
|
1395
1504
|
|
|
1396
|
-
const lines =
|
|
1397
|
-
"
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
`${
|
|
1404
|
-
`${
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1505
|
+
const lines = renderTable(
|
|
1506
|
+
["时间", "次数", "输入", "输出", "命中率", "速率"],
|
|
1507
|
+
records.map((r) => [
|
|
1508
|
+
String(r.hour).padStart(2, "0"),
|
|
1509
|
+
String(r.count),
|
|
1510
|
+
formatTokens(r.sumInput),
|
|
1511
|
+
formatTokens(r.sumOutput),
|
|
1512
|
+
`${weightedCacheHitRate(r).toFixed(1)}%`,
|
|
1513
|
+
`${(r.sumTokensPerSec / r.count).toFixed(1)}`,
|
|
1514
|
+
]),
|
|
1515
|
+
{ aligns: ["left", "right", "right", "right", "right", "right"] },
|
|
1516
|
+
);
|
|
1408
1517
|
|
|
1409
1518
|
await showStats(lines, `按小时分布 | ${date}`, ctx);
|
|
1410
1519
|
}
|
|
@@ -1433,23 +1542,25 @@ export function createTokenStats(
|
|
|
1433
1542
|
return;
|
|
1434
1543
|
}
|
|
1435
1544
|
|
|
1436
|
-
const lines =
|
|
1437
|
-
"日期
|
|
1438
|
-
|
|
1439
|
-
...weekRecords.map((r) => {
|
|
1545
|
+
const lines = renderTable(
|
|
1546
|
+
["日期", "次数", "新增输入", "缓存输入", "输出", "总token", "命中率", "速率"],
|
|
1547
|
+
weekRecords.map((r) => {
|
|
1440
1548
|
const totalPrompt = r.sumInput + r.sumCacheRead + r.sumCacheWrite;
|
|
1441
|
-
return
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
`${weightedCacheHitRate(r).toFixed(1)
|
|
1449
|
-
`${(r.sumTokensPerSec / r.count).toFixed(1)
|
|
1450
|
-
|
|
1549
|
+
return [
|
|
1550
|
+
r.date,
|
|
1551
|
+
String(r.count),
|
|
1552
|
+
formatTokens(r.sumInput),
|
|
1553
|
+
formatTokens(r.sumCacheRead),
|
|
1554
|
+
formatTokens(r.sumOutput),
|
|
1555
|
+
formatTokens(totalPrompt),
|
|
1556
|
+
`${weightedCacheHitRate(r).toFixed(1)}%`,
|
|
1557
|
+
`${(r.sumTokensPerSec / r.count).toFixed(1)}`,
|
|
1558
|
+
];
|
|
1451
1559
|
}),
|
|
1452
|
-
|
|
1560
|
+
{
|
|
1561
|
+
aligns: ["left", "right", "right", "right", "right", "right", "right", "right"],
|
|
1562
|
+
},
|
|
1563
|
+
);
|
|
1453
1564
|
|
|
1454
1565
|
await showStats(lines, "本周每天汇总", ctx);
|
|
1455
1566
|
}
|
|
@@ -1495,40 +1606,46 @@ export function createTokenStats(
|
|
|
1495
1606
|
const totalPrompt = total.sumInput + total.sumCacheRead + total.sumCacheWrite;
|
|
1496
1607
|
const cacheHitRate = weightedCacheHitRate(total);
|
|
1497
1608
|
|
|
1498
|
-
const lines =
|
|
1499
|
-
"日期
|
|
1500
|
-
|
|
1501
|
-
...monthRecords.map((r) => {
|
|
1609
|
+
const lines = renderTable(
|
|
1610
|
+
["日期", "次数", "新增输入", "缓存输入", "输出", "总token", "命中率", "速率"],
|
|
1611
|
+
monthRecords.map((r) => {
|
|
1502
1612
|
const tp = r.sumInput + r.sumCacheRead + r.sumCacheWrite;
|
|
1503
|
-
return
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
`${weightedCacheHitRate(r).toFixed(1)
|
|
1511
|
-
`${(r.sumTokensPerSec / r.count).toFixed(1)
|
|
1512
|
-
|
|
1613
|
+
return [
|
|
1614
|
+
r.date,
|
|
1615
|
+
String(r.count),
|
|
1616
|
+
formatTokens(r.sumInput),
|
|
1617
|
+
formatTokens(r.sumCacheRead),
|
|
1618
|
+
formatTokens(r.sumOutput),
|
|
1619
|
+
formatTokens(tp),
|
|
1620
|
+
`${weightedCacheHitRate(r).toFixed(1)}%`,
|
|
1621
|
+
`${(r.sumTokensPerSec / r.count).toFixed(1)}`,
|
|
1622
|
+
];
|
|
1513
1623
|
}),
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1624
|
+
{
|
|
1625
|
+
aligns: ["left", "right", "right", "right", "right", "right", "right", "right"],
|
|
1626
|
+
totalRow: [
|
|
1627
|
+
"合计",
|
|
1628
|
+
String(total.count),
|
|
1629
|
+
formatTokens(total.sumInput),
|
|
1630
|
+
formatTokens(total.sumCacheRead),
|
|
1631
|
+
formatTokens(total.sumOutput),
|
|
1632
|
+
formatTokens(totalPrompt),
|
|
1633
|
+
`${cacheHitRate.toFixed(1)}%`,
|
|
1634
|
+
`${(total.sumTokensPerSec / total.count).toFixed(1)}`,
|
|
1635
|
+
],
|
|
1636
|
+
},
|
|
1637
|
+
);
|
|
1523
1638
|
|
|
1524
1639
|
await showStats(lines, `${month} 月度汇总`, ctx);
|
|
1525
1640
|
}
|
|
1526
1641
|
|
|
1527
1642
|
// ── 事件注册 ─────────────────────────────────────────
|
|
1528
1643
|
|
|
1529
|
-
// ── message renderer: 渲染 /stats
|
|
1530
|
-
pi.registerMessageRenderer("token-stats", (message, _options,
|
|
1531
|
-
return new
|
|
1644
|
+
// ── message renderer: 渲染 /stats 发出的消息(markdown 表格)──
|
|
1645
|
+
pi.registerMessageRenderer("token-stats", (message, _options, theme) => {
|
|
1646
|
+
return new Markdown(message.content, 0, 0, getMarkdownTheme(), {
|
|
1647
|
+
color: (text) => theme.fg("dim", text),
|
|
1648
|
+
});
|
|
1532
1649
|
});
|
|
1533
1650
|
|
|
1534
1651
|
// ── turn_start: 记录时间 + 检测供应商切换 ──────────
|
|
@@ -1925,12 +2042,12 @@ export function createTokenStats(
|
|
|
1925
2042
|
} else if (subChoice === "显示内容") {
|
|
1926
2043
|
const itemLabels: DisplayKey[] = [
|
|
1927
2044
|
"input", "output", "totalTokens", "cacheHit", "speed", "context",
|
|
1928
|
-
"quota5h", "quotaWeek", "quotaClock",
|
|
2045
|
+
"quota5h", "quotaWeek", "quotaMonth", "quotaClock",
|
|
1929
2046
|
];
|
|
1930
2047
|
const itemNames: Record<DisplayKey, string> = {
|
|
1931
2048
|
input: "输入", output: "输出", totalTokens: "总token",
|
|
1932
2049
|
cacheHit: "缓存命中", speed: "速度", context: "容量",
|
|
1933
|
-
quota5h: "5h额度", quotaWeek: "周额度", quotaClock: "刷新时间",
|
|
2050
|
+
quota5h: "5h额度", quotaWeek: "周额度", quotaMonth: "月额度", quotaClock: "刷新时间",
|
|
1934
2051
|
};
|
|
1935
2052
|
while (true) {
|
|
1936
2053
|
const options = itemLabels.map(k =>
|