token-stats-timer 1.0.13 → 1.0.15

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.
Files changed (3) hide show
  1. package/README.md +22 -2
  2. package/package.json +1 -1
  3. package/token-stats.ts +312 -89
package/README.md CHANGED
@@ -76,11 +76,31 @@ Footer 下行:cwd + git 分支 + 其他扩展状态。
76
76
 
77
77
  ## 命令
78
78
 
79
- - `/stats` —— 无参进入套餐配置(为当前 provider 选择/关闭配额套餐)
79
+ - `/stats` —— 无参进入套餐配置(为当前 provider 选择/关闭配额套餐;选 GLM 后会继续询问是否配置团队套餐凭证)
80
80
  - `/stats day [YYYY-MM-DD]` / `hour` / `week` / `month [YYYY-MM]` —— 统计查询
81
- - `/stats config` —— 显示样式 / 显示内容 / 配额刷新时间
81
+ - `/stats config` —— 显示样式 / 显示内容 / 配额刷新时间 / GLM 团队凭证
82
82
  - `/notify [on|off|test]` —— 通知开关 / 测试(无参查看状态)
83
83
 
84
+ ## GLM 团队套餐(Team Plan)
85
+
86
+ 个人版与团队版共用 `GET /api/monitor/usage/quota/limit`,区别在请求头:团队版需额外携带 `Bigmodel-Organization` / `Bigmodel-Project` 两个请求头并加 `?type=2`(api_key + 组织 ID + 项目 ID 三者缺一不可,仅国内站 `open.bigmodel.cn` 有团队档)。
87
+
88
+ 本插件在**组织 ID 与项目 ID 都配置**时才走团队查询,否则回退个人版查询:
89
+
90
+ - 启用 GLM 套餐后(`/stats` 选 GLM)会自动弹出团队凭证配置询问,可「✏️ 配置/修改」或「跳过」
91
+ - 随时可通过 `/stats config` → 「GLM 团队凭证」修改或清除
92
+ - 凭证保存在 `~/.pi/agent/extensions/token-stats/config.json` 的 `teamCredential` 字段:
93
+
94
+ ```json
95
+ {
96
+ "providerPlans": { "zai-coding-cn": "glm" },
97
+ "teamCredential": { "organization": "your-org-id", "project": "your-project-id" },
98
+ "ttl": 60
99
+ }
100
+ ```
101
+
102
+ > 组织 ID / 项目 ID 在 GLM Coding Plan 团队版后台「团队编程套餐」页面获取;如果你同时使用 Claude Code / Cursor 等工具并已在环境变量里配置,也可以在 config.json 里直接填上同名值。
103
+
84
104
  ## 与原包的兼容性
85
105
 
86
106
  - 显示/套餐配置沿用 `~/.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.13",
3
+ "version": "1.0.15",
4
4
  "description": "pi extension to display token usage and time spend.",
5
5
  "keywords": [
6
6
  "pi-package",
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 { Text } from "@earendil-works/pi-tui";
18
+ import { Markdown } from "@earendil-works/pi-tui";
18
19
  import {
19
20
  appendFile,
20
21
  mkdir,
@@ -100,6 +101,17 @@ interface DailyRecord {
100
101
 
101
102
  // ── 套餐用量类型 ──────────────────────────────────────────
102
103
 
104
+ /** GLM / 智谱团队套餐凭证:组织 ID + 项目 ID(二者齐全才走团队查询) */
105
+ interface TeamCredential {
106
+ organization: string;
107
+ project: string;
108
+ }
109
+
110
+ /** fetchQuota 额外参数(目前仅 GLM 团队版使用) */
111
+ interface QuotaFetchExtra {
112
+ team?: TeamCredential | null;
113
+ }
114
+
103
115
  interface TokenPlan {
104
116
  id: string;
105
117
  name: string;
@@ -108,12 +120,14 @@ interface TokenPlan {
108
120
  baseUrl: string;
109
121
  quotaPath: string;
110
122
  authHeader: (key: string) => Record<string, string>;
111
- fetchQuota: (plan: TokenPlan, key: string) => Promise<any>;
123
+ fetchQuota: (plan: TokenPlan, key: string, extra?: QuotaFetchExtra) => Promise<any>;
112
124
  format: (data: any) => { modelPrefix: string; display: string; color: 'ok' | 'warn' | 'err' };
113
125
  }
114
126
 
115
127
  interface TokenConfig {
116
128
  providerPlans: Record<string, string | null>;
129
+ /** GLM 团队套餐凭证(可选),经 /stats 菜单配置并持久化 */
130
+ teamCredential?: { organization: string; project: string };
117
131
  ttl: number;
118
132
  }
119
133
 
@@ -199,6 +213,41 @@ function formatTokenSpeed(tokensPerSecond: number): string {
199
213
  return `${Math.round(tokensPerSecond / 1000000)}M`;
200
214
  }
201
215
 
216
+ /**
217
+ * 生成 GitHub 风格 markdown 表格源码(由 Markdown 组件负责渲染对齐/换行)。
218
+ *
219
+ * @param headers 表头
220
+ * @param rows 数据行
221
+ * @param opts.aligns 每列对齐方式,缺省左对齐
222
+ * @param opts.totalRow 底部合计行
223
+ */
224
+ function renderTable(
225
+ headers: string[],
226
+ rows: string[][],
227
+ opts?: {
228
+ aligns?: Array<"left" | "right" | "center">;
229
+ totalRow?: string[];
230
+ },
231
+ ): string[] {
232
+ const aligns = opts?.aligns ?? [];
233
+ // 转义单元格内的 | 与换行,防止破坏表格结构
234
+ const esc = (s: string) => s.replace(/\|/g, "\\|").replace(/\n/g, " ");
235
+ const alignMark = (i: number) => {
236
+ const a = aligns[i] ?? "left";
237
+ return a === "right" ? "---:" : a === "center" ? ":---:" : "---";
238
+ };
239
+
240
+ const lines = [
241
+ `| ${headers.map(esc).join(" | ")} |`,
242
+ `| ${headers.map((_, i) => alignMark(i)).join(" | ")} |`,
243
+ ...rows.map((r) => `| ${r.map(esc).join(" | ")} |`),
244
+ ];
245
+ if (opts?.totalRow) {
246
+ lines.push(`| ${opts.totalRow.map(esc).join(" | ")} |`);
247
+ }
248
+ return lines;
249
+ }
250
+
202
251
  function isReasonableTokenSpeed(tokensPerSecond: number): boolean {
203
252
  return Number.isFinite(tokensPerSecond) && tokensPerSecond > 0 && tokensPerSecond <= MAX_REASONABLE_TOKEN_SPEED;
204
253
  }
@@ -334,15 +383,27 @@ const BUILTIN_PLANS: TokenPlan[] = [
334
383
  {
335
384
  id: "glm",
336
385
  name: "GLM (智谱)",
337
- matchProviders: ["zhipu-cn", "zhipu", "glm", "bigmodel"],
386
+ matchProviders: ["zhipu-cn", "zhipu", "glm", "bigmodel", "zai-coding-cn"],
338
387
  apiKeyEnv: "GLM_API_KEY",
339
388
  baseUrl: "https://open.bigmodel.cn",
340
389
  quotaPath: "/api/monitor/usage/quota/limit",
341
390
  authHeader: (key) => ({ Authorization: key }),
342
- fetchQuota: async (plan: TokenPlan, key: string) => {
343
- const r = await fetch(plan.baseUrl + plan.quotaPath, {
391
+ fetchQuota: async (plan: TokenPlan, key: string, extra?: QuotaFetchExtra) => {
392
+ const team = extra?.team;
393
+ const headers: Record<string, string> = {
394
+ ...plan.authHeader(key),
395
+ "Content-Type": "application/json",
396
+ };
397
+ // 团队套餐:同一 quota 端点加 ?type=2,并携带组织/项目 ID 请求头
398
+ // (api_key + 组织 ID + 项目 ID 三者缺一不可,仅国内站 open.bigmodel.cn 有团队版)
399
+ if (team) {
400
+ headers["Bigmodel-Organization"] = team.organization;
401
+ headers["Bigmodel-Project"] = team.project;
402
+ }
403
+ const url = plan.baseUrl + plan.quotaPath + (team ? "?type=2" : "");
404
+ const r = await fetch(url, {
344
405
  method: "GET",
345
- headers: { ...plan.authHeader(key), "Content-Type": "application/json" },
406
+ headers,
346
407
  signal: AbortSignal.timeout(5000),
347
408
  });
348
409
  if (!r.ok) throw new Error("GLM 配额查询 HTTP " + r.status);
@@ -350,23 +411,56 @@ const BUILTIN_PLANS: TokenPlan[] = [
350
411
  },
351
412
  format: (data: any) => {
352
413
  const limits = data?.data?.limits || [];
353
- const tokenLimits = limits.filter((x: any) => (x.type || "").toLowerCase() === "tokens_limit");
354
- if (tokenLimits.length === 0) return { modelPrefix: "", display: "无数据", color: "err" as const };
355
- let fiveHour = tokenLimits[0];
356
- let weekly = tokenLimits[1];
357
- if (fiveHour?.unit === 6) [fiveHour, weekly] = [weekly, fiveHour];
358
- const intervalRemaining = 100 - (fiveHour?.percentage ?? 0);
359
- const weeklyRemaining = 100 - (weekly?.percentage ?? 0);
414
+ // 个人版返回 TOKENS_LIMIT,团队版返回 CREDIT_LIMIT(大小写不敏感)必识
415
+ const isQuota = (t: any) => {
416
+ const s = String(t ?? "").toLowerCase();
417
+ return s === "tokens_limit" || s === "credit_limit";
418
+ };
419
+ const entries = limits.filter((x: any) => isQuota(x?.type));
420
+ if (entries.length === 0) return { modelPrefix: "", display: "无数据", color: "err" as const };
421
+
422
+ // 窗口分类锚定 unit(不依赖数组顺序,与 cc-switch parse_zhipu_token_tiers 一致):
423
+ // unit: 3 → 5h 滚动窗口;unit: 6 → 每周窗口(老/新套餐 number 7 / 1 均可能)
424
+ const byUnit = (u: number) => entries.find((x: any) => x?.unit === u);
425
+ let fiveHour: any = byUnit(3) ?? null;
426
+ let weekly: any = byUnit(6) ?? null;
427
+ // 兜底:unit 缺失/不识别 → 无 nextResetTime 优先归 5h,其余按重置时间升序补槽
428
+ if (!fiveHour || !weekly) {
429
+ const unclassified = entries
430
+ .filter((x: any) => x !== fiveHour && x !== weekly)
431
+ .sort((a: any, b: any) =>
432
+ (typeof a?.nextResetTime === "number" ? a.nextResetTime : Number.MIN_SAFE_INTEGER) -
433
+ (typeof b?.nextResetTime === "number" ? b.nextResetTime : Number.MIN_SAFE_INTEGER));
434
+ for (const e of unclassified) {
435
+ if (!fiveHour) fiveHour = e;
436
+ else if (!weekly) weekly = e;
437
+ }
438
+ }
439
+
440
+ // percentage 为已用比例 → 剩余 = 100 - 已用
441
+ const intervalRemaining = fiveHour ? 100 - (fiveHour.percentage ?? 0) : null;
442
+ const weeklyRemaining = weekly ? 100 - (weekly.percentage ?? 0) : null;
360
443
  const now = Date.now();
361
- const resets = tokenLimits
362
- .map((x: any) => x.nextResetTime)
444
+ const resets = entries
445
+ .map((x: any) => x?.nextResetTime)
363
446
  .filter((t: any) => typeof t === "number" && t > now);
364
447
  const nearestReset = resets.length > 0 ? Math.min(...resets) : null;
365
- return {
366
- modelPrefix: "",
367
- display: formatTokenPlanDisplay(intervalRemaining, weeklyRemaining, nearestReset),
368
- color: intervalRemaining < 20 || weeklyRemaining < 20 ? "err" as const : intervalRemaining < 50 || weeklyRemaining < 50 ? "warn" as const : "ok" as const,
369
- };
448
+
449
+ const both = intervalRemaining !== null && weeklyRemaining !== null;
450
+ const display =
451
+ intervalRemaining !== null && weeklyRemaining !== null
452
+ ? formatTokenPlanDisplay(intervalRemaining, weeklyRemaining, nearestReset)
453
+ : intervalRemaining !== null
454
+ ? `5h: ${Math.round(intervalRemaining)}%`
455
+ : weeklyRemaining !== null
456
+ ? `W: ${Math.round(weeklyRemaining)}%`
457
+ : "无数据";
458
+ const low = (v: number | null) => v !== null && v < 20;
459
+ const mid = (v: number | null) => v !== null && v < 50;
460
+ const color = low(intervalRemaining) || low(weeklyRemaining) ? "err" as const
461
+ : mid(intervalRemaining) || mid(weeklyRemaining) ? "warn" as const
462
+ : "ok" as const;
463
+ return { modelPrefix: "", display, color };
370
464
  },
371
465
  },
372
466
  {
@@ -1017,6 +1111,20 @@ export function createTokenStats(
1017
1111
  return null;
1018
1112
  }
1019
1113
 
1114
+ /**
1115
+ * 解析套餐的团队凭证(当前仅 GLM/智谱团队版)。
1116
+ * 组织 ID + 项目 ID 二者齐全才返回,否则返回 null(回退个人版查询)。
1117
+ * 来源:token-stats config.json 的 teamCredential(/stats 菜单配置)。
1118
+ */
1119
+ function resolveTeamCredential(plan: TokenPlan): TeamCredential | null {
1120
+ if (plan.id !== "glm") return null;
1121
+ const tc = tokenConfig?.teamCredential;
1122
+ const organization = tc?.organization?.trim() ?? "";
1123
+ const project = tc?.project?.trim() ?? "";
1124
+ if (organization && project) return { organization, project };
1125
+ return null;
1126
+ }
1127
+
1020
1128
  /**
1021
1129
  * 检测并处理 provider 变化。
1022
1130
  * 返回 true 表示发生了切换(供调用者决定是否要 force refresh)。
@@ -1138,7 +1246,11 @@ export function createTokenStats(
1138
1246
 
1139
1247
  // 5. 调接口
1140
1248
  try {
1141
- const data = await plan.fetchQuota(plan, key);
1249
+ const data = await plan.fetchQuota(
1250
+ plan,
1251
+ key,
1252
+ { team: resolveTeamCredential(plan) },
1253
+ );
1142
1254
  cache[plan.id] = { fetchedAt: Date.now(), ttl: ttlMs, data };
1143
1255
  await writeQuotaCache(cache);
1144
1256
  const fmt = plan.format(data);
@@ -1173,6 +1285,58 @@ export function createTokenStats(
1173
1285
  shared.requestRender?.();
1174
1286
  }
1175
1287
 
1288
+ /** config.json 无参流里的基础结构(避免 null 展开报错) */
1289
+ function baseTokenConfig(): TokenConfig {
1290
+ return { ...(tokenConfig ?? { providerPlans: {}, ttl: 60 }) };
1291
+ }
1292
+
1293
+ /**
1294
+ * GLM 团队套餐交互配置:提示用户填写组织 ID / 项目 ID 并持久化到 config.json,
1295
+ * 保存后 force 刷新使团队查询(?type=2)立即生效,并反馈查询结果。
1296
+ */
1297
+ async function promptGlmTeamConfig(ctx: ExtensionContext): Promise<void> {
1298
+ const cur = tokenConfig?.teamCredential;
1299
+ const curLabel =
1300
+ cur?.organization && cur?.project
1301
+ ? `${cur.organization} / ${cur.project}`
1302
+ : "未配置(按个人版查询)";
1303
+ const choice = await ctx.ui.select(
1304
+ `GLM 团队套餐凭证?当前:${curLabel}\n填写组织 ID + 项目 ID(二者齐全才走团队查询 ?type=2),跳过则维持个人版查询`,
1305
+ ["✏️ 配置/修改", "跳过"],
1306
+ );
1307
+ if (!choice || choice === "跳过") {
1308
+ await forceRefreshQuota(ctx);
1309
+ const errMsg = quotaState?.error ? formatQuotaError(quotaState) : "";
1310
+ ctx.ui.notify(
1311
+ quotaState?.error ? `GLM 配额查询失败:${errMsg}` : "GLM 配额已启用(个人版查询)",
1312
+ "info",
1313
+ );
1314
+ return;
1315
+ }
1316
+
1317
+ const organization = await ctx.ui.input("组织 ID (Organization)", cur?.organization ?? "");
1318
+ const project = await ctx.ui.input("项目 ID (Project)", cur?.project ?? "");
1319
+ const org = organization?.trim() ?? "";
1320
+ const proj = project?.trim() ?? "";
1321
+ if (!org || !proj) {
1322
+ ctx.ui.notify("组织/项目 ID 不能为空,团队凭证未保存", "warning");
1323
+ return;
1324
+ }
1325
+
1326
+ tokenConfig = {
1327
+ ...baseTokenConfig(),
1328
+ teamCredential: { organization: org, project: proj },
1329
+ };
1330
+ await saveTokenConfig(tokenConfig);
1331
+
1332
+ await forceRefreshQuota(ctx);
1333
+ const errMsg = quotaState?.error ? formatQuotaError(quotaState) : "";
1334
+ ctx.ui.notify(
1335
+ quotaState?.error ? `GLM 团队配额查询失败:${errMsg}` : "GLM 团队套餐配额已启用",
1336
+ "info",
1337
+ );
1338
+ }
1339
+
1176
1340
  /** 清空所有套餐缓存(session_start 调,避免跨 session 复用旧数据) */
1177
1341
  async function invalidateAllQuotaCache() {
1178
1342
  try {
@@ -1189,23 +1353,25 @@ export function createTokenStats(
1189
1353
  return total > 0 ? (d.sumCacheRead / total) * 100 : 0;
1190
1354
  }
1191
1355
 
1192
- function renderDaySummary(daily: DailyRecord): string {
1356
+ function renderDaySummary(daily: DailyRecord): string[] {
1193
1357
  const d = daily;
1194
1358
  const avgInput = d.count > 0 ? d.sumInput / d.count : 0;
1195
1359
  const avgOutput = d.count > 0 ? d.sumOutput / d.count : 0;
1196
1360
  const totalPrompt = d.sumInput + d.sumCacheRead + d.sumCacheWrite;
1197
1361
  const cacheHitRate = weightedCacheHitRate(d);
1198
1362
 
1199
- const lines = [
1200
- `对话次数: ${d.count}`,
1201
- `新增输入: ${formatTokens(d.sumInput)} (平均 ${formatTokens(avgInput)}/次,未命中缓存)`,
1202
- `缓存输入: ${formatTokens(d.sumCacheRead)}`,
1203
- `总输出: ${formatTokens(d.sumOutput)} (平均 ${formatTokens(avgOutput)}/次)`,
1204
- `总token: ${formatTokens(totalPrompt)} (新增 + 缓存)`,
1205
- `缓存命中率: ${cacheHitRate.toFixed(1)}%`,
1206
- `平均速率: ${(d.sumTokensPerSec / d.count).toFixed(1)} t/s`,
1207
- ];
1208
- return lines.join("\n");
1363
+ return renderTable(
1364
+ ["指标", "数值"],
1365
+ [
1366
+ ["对话次数", String(d.count)],
1367
+ ["新增输入", `${formatTokens(d.sumInput)}(平均 ${formatTokens(avgInput)}/次,未命中缓存)`],
1368
+ ["缓存输入", formatTokens(d.sumCacheRead)],
1369
+ ["总输出", `${formatTokens(d.sumOutput)}(平均 ${formatTokens(avgOutput)}/次)`],
1370
+ ["总token", `${formatTokens(totalPrompt)}(新增 + 缓存)`],
1371
+ ["缓存命中率", `${cacheHitRate.toFixed(1)}%`],
1372
+ ["平均速率", `${(d.sumTokensPerSec / d.count).toFixed(1)} t/s`],
1373
+ ],
1374
+ );
1209
1375
  }
1210
1376
 
1211
1377
  async function showStats(
@@ -1213,9 +1379,7 @@ export function createTokenStats(
1213
1379
  title: string,
1214
1380
  ctx: ExtensionContext,
1215
1381
  ) {
1216
- const theme = ctx.ui.theme;
1217
- const text = `${theme.fg("accent", theme.bold(title))}\n${theme.fg("dim", "─".repeat(42))}\n` +
1218
- lines.map((l) => theme.fg("dim", l)).join("\n");
1382
+ const text = `## ${title}\n\n${lines.join("\n")}`;
1219
1383
  pi.sendMessage({
1220
1384
  customType: "token-stats",
1221
1385
  content: text,
@@ -1241,7 +1405,7 @@ export function createTokenStats(
1241
1405
  }
1242
1406
 
1243
1407
  await showStats(
1244
- renderDaySummary(daily).split("\n"),
1408
+ renderDaySummary(daily),
1245
1409
  `Token 统计 | ${date}`,
1246
1410
  ctx,
1247
1411
  );
@@ -1265,18 +1429,18 @@ export function createTokenStats(
1265
1429
 
1266
1430
  records.sort((a, b) => a.hour - b.hour);
1267
1431
 
1268
- const lines = [
1269
- "次数 输入 输出 命中率 速率",
1270
- "─".repeat(40),
1271
- ...records.map((r) =>
1272
- `${String(r.hour).padStart(2, "0")} ` +
1273
- `${String(r.count).padStart(3)} ` +
1274
- `${formatTokens(r.sumInput).padStart(7)} ` +
1275
- `${formatTokens(r.sumOutput).padStart(7)} ` +
1276
- `${weightedCacheHitRate(r).toFixed(1).padStart(5)}% ` +
1277
- `${(r.sumTokensPerSec / r.count).toFixed(1).padStart(5)}`,
1278
- ),
1279
- ];
1432
+ const lines = renderTable(
1433
+ ["时间", "次数", "输入", "输出", "命中率", "速率"],
1434
+ records.map((r) => [
1435
+ String(r.hour).padStart(2, "0"),
1436
+ String(r.count),
1437
+ formatTokens(r.sumInput),
1438
+ formatTokens(r.sumOutput),
1439
+ `${weightedCacheHitRate(r).toFixed(1)}%`,
1440
+ `${(r.sumTokensPerSec / r.count).toFixed(1)}`,
1441
+ ]),
1442
+ { aligns: ["left", "right", "right", "right", "right", "right"] },
1443
+ );
1280
1444
 
1281
1445
  await showStats(lines, `按小时分布 | ${date}`, ctx);
1282
1446
  }
@@ -1305,23 +1469,25 @@ export function createTokenStats(
1305
1469
  return;
1306
1470
  }
1307
1471
 
1308
- const lines = [
1309
- "日期 次数 新增输入 缓存输入 输出 总token 命中率 速率",
1310
- "─".repeat(70),
1311
- ...weekRecords.map((r) => {
1472
+ const lines = renderTable(
1473
+ ["日期", "次数", "新增输入", "缓存输入", "输出", "总token", "命中率", "速率"],
1474
+ weekRecords.map((r) => {
1312
1475
  const totalPrompt = r.sumInput + r.sumCacheRead + r.sumCacheWrite;
1313
- return (
1314
- `${r.date} ` +
1315
- `${String(r.count).padStart(3)} ` +
1316
- `${formatTokens(r.sumInput).padStart(7)} ` +
1317
- `${formatTokens(r.sumCacheRead).padStart(7)} ` +
1318
- `${formatTokens(r.sumOutput).padStart(7)} ` +
1319
- `${formatTokens(totalPrompt).padStart(7)} ` +
1320
- `${weightedCacheHitRate(r).toFixed(1).padStart(5)}% ` +
1321
- `${(r.sumTokensPerSec / r.count).toFixed(1).padStart(5)}`
1322
- );
1476
+ return [
1477
+ r.date,
1478
+ String(r.count),
1479
+ formatTokens(r.sumInput),
1480
+ formatTokens(r.sumCacheRead),
1481
+ formatTokens(r.sumOutput),
1482
+ formatTokens(totalPrompt),
1483
+ `${weightedCacheHitRate(r).toFixed(1)}%`,
1484
+ `${(r.sumTokensPerSec / r.count).toFixed(1)}`,
1485
+ ];
1323
1486
  }),
1324
- ];
1487
+ {
1488
+ aligns: ["left", "right", "right", "right", "right", "right", "right", "right"],
1489
+ },
1490
+ );
1325
1491
 
1326
1492
  await showStats(lines, "本周每天汇总", ctx);
1327
1493
  }
@@ -1367,40 +1533,46 @@ export function createTokenStats(
1367
1533
  const totalPrompt = total.sumInput + total.sumCacheRead + total.sumCacheWrite;
1368
1534
  const cacheHitRate = weightedCacheHitRate(total);
1369
1535
 
1370
- const lines = [
1371
- "日期 次数 新增输入 缓存输入 输出 总token 命中率 速率",
1372
- "─".repeat(70),
1373
- ...monthRecords.map((r) => {
1536
+ const lines = renderTable(
1537
+ ["日期", "次数", "新增输入", "缓存输入", "输出", "总token", "命中率", "速率"],
1538
+ monthRecords.map((r) => {
1374
1539
  const tp = r.sumInput + r.sumCacheRead + r.sumCacheWrite;
1375
- return (
1376
- `${r.date} ` +
1377
- `${String(r.count).padStart(3)} ` +
1378
- `${formatTokens(r.sumInput).padStart(7)} ` +
1379
- `${formatTokens(r.sumCacheRead).padStart(7)} ` +
1380
- `${formatTokens(r.sumOutput).padStart(7)} ` +
1381
- `${formatTokens(tp).padStart(7)} ` +
1382
- `${weightedCacheHitRate(r).toFixed(1).padStart(5)}% ` +
1383
- `${(r.sumTokensPerSec / r.count).toFixed(1).padStart(5)}`
1384
- );
1540
+ return [
1541
+ r.date,
1542
+ String(r.count),
1543
+ formatTokens(r.sumInput),
1544
+ formatTokens(r.sumCacheRead),
1545
+ formatTokens(r.sumOutput),
1546
+ formatTokens(tp),
1547
+ `${weightedCacheHitRate(r).toFixed(1)}%`,
1548
+ `${(r.sumTokensPerSec / r.count).toFixed(1)}`,
1549
+ ];
1385
1550
  }),
1386
- "",
1387
- `合计 ${String(total.count).padStart(3)} ` +
1388
- `${formatTokens(total.sumInput).padStart(7)} ` +
1389
- `${formatTokens(total.sumCacheRead).padStart(7)} ` +
1390
- `${formatTokens(total.sumOutput).padStart(7)} ` +
1391
- `${formatTokens(totalPrompt).padStart(7)} ` +
1392
- `${cacheHitRate.toFixed(1).padStart(5)}% ` +
1393
- `${(total.sumTokensPerSec / total.count).toFixed(1).padStart(5)}`,
1394
- ];
1551
+ {
1552
+ aligns: ["left", "right", "right", "right", "right", "right", "right", "right"],
1553
+ totalRow: [
1554
+ "合计",
1555
+ String(total.count),
1556
+ formatTokens(total.sumInput),
1557
+ formatTokens(total.sumCacheRead),
1558
+ formatTokens(total.sumOutput),
1559
+ formatTokens(totalPrompt),
1560
+ `${cacheHitRate.toFixed(1)}%`,
1561
+ `${(total.sumTokensPerSec / total.count).toFixed(1)}`,
1562
+ ],
1563
+ },
1564
+ );
1395
1565
 
1396
1566
  await showStats(lines, `${month} 月度汇总`, ctx);
1397
1567
  }
1398
1568
 
1399
1569
  // ── 事件注册 ─────────────────────────────────────────
1400
1570
 
1401
- // ── message renderer: 渲染 /stats 发出的消息 ─────────
1402
- pi.registerMessageRenderer("token-stats", (message, _options, _theme) => {
1403
- return new Text(message.content, 0, 0);
1571
+ // ── message renderer: 渲染 /stats 发出的消息(markdown 表格)──
1572
+ pi.registerMessageRenderer("token-stats", (message, _options, theme) => {
1573
+ return new Markdown(message.content, 0, 0, getMarkdownTheme(), {
1574
+ color: (text) => theme.fg("dim", text),
1575
+ });
1404
1576
  });
1405
1577
 
1406
1578
  // ── turn_start: 记录时间 + 检测供应商切换 ──────────
@@ -1668,6 +1840,11 @@ export function createTokenStats(
1668
1840
  } catch { /* ctx 已失效(session 被替换),忽略 */ }
1669
1841
  shared.requestRender?.();
1670
1842
  }, (tokenConfig?.ttl || 60) * 1000);
1843
+ if (plan.id === "glm") {
1844
+ // GLM:继续询问团队套餐凭证(个人版/团队版二选一,内部会 force 查询并反馈)
1845
+ await promptGlmTeamConfig(ctx);
1846
+ return;
1847
+ }
1671
1848
  if (quotaState?.error) {
1672
1849
  // 仅当 quotaState 带有 error 字段时(key 缺失 / API 错误 / 网络错误 / 无数据)才提示"查询失败"
1673
1850
  const errMsg = formatQuotaError(quotaState);
@@ -1684,9 +1861,55 @@ export function createTokenStats(
1684
1861
  "显示样式",
1685
1862
  "显示内容",
1686
1863
  "刷新时间 (当前 " + (tokenConfig?.ttl || 60) + "s)",
1864
+ "GLM 团队凭证",
1687
1865
  ]);
1688
1866
  if (!subChoice) return;
1689
1867
 
1868
+ if (subChoice === "GLM 团队凭证") {
1869
+ const cur = tokenConfig?.teamCredential;
1870
+ const label =
1871
+ cur?.organization && cur?.project
1872
+ ? `${cur.organization} / ${cur.project}`
1873
+ : "未配置";
1874
+ const action = await ctx.ui.select("GLM 团队凭证(当前: " + label + ")", [
1875
+ "✏️ 配置/修改",
1876
+ "清除",
1877
+ "返回",
1878
+ ]);
1879
+ if (!action || action === "返回") return;
1880
+ if (action === "清除") {
1881
+ tokenConfig = {
1882
+ ...baseTokenConfig(),
1883
+ teamCredential: { organization: "", project: "" },
1884
+ };
1885
+ await saveTokenConfig(tokenConfig);
1886
+ await forceRefreshQuota(ctx);
1887
+ ctx.ui.notify("GLM 团队凭证已清除(恢复个人版查询)", "info");
1888
+ } else {
1889
+ const organization = await ctx.ui.input("组织 ID (Organization)", cur?.organization ?? "");
1890
+ const project = await ctx.ui.input("项目 ID (Project)", cur?.project ?? "");
1891
+ const org = organization?.trim() ?? "";
1892
+ const proj = project?.trim() ?? "";
1893
+ if (!org || !proj) {
1894
+ ctx.ui.notify("组织/项目 ID 不能为空,未保存", "warning");
1895
+ return;
1896
+ }
1897
+ tokenConfig = {
1898
+ ...baseTokenConfig(),
1899
+ teamCredential: { organization: org, project: proj },
1900
+ };
1901
+ await saveTokenConfig(tokenConfig);
1902
+ await forceRefreshQuota(ctx);
1903
+ const errMsg = quotaState?.error ? formatQuotaError(quotaState) : "";
1904
+ if (quotaState?.error) {
1905
+ ctx.ui.notify(`GLM 团队配额查询失败:${errMsg}`, "info");
1906
+ } else {
1907
+ ctx.ui.notify("GLM 团队凭证已保存(团队查询已生效)", "info");
1908
+ }
1909
+ }
1910
+ return;
1911
+ }
1912
+
1690
1913
  if (subChoice === "显示样式") {
1691
1914
  const catChoice = await ctx.ui.select("选择要配置的样式类别", [
1692
1915
  "上下文样式",