token-stats-timer 1.0.13 → 1.0.14

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 +199 -20
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.14",
4
4
  "description": "pi extension to display token usage and time spend.",
5
5
  "keywords": [
6
6
  "pi-package",
package/token-stats.ts CHANGED
@@ -100,6 +100,17 @@ interface DailyRecord {
100
100
 
101
101
  // ── 套餐用量类型 ──────────────────────────────────────────
102
102
 
103
+ /** GLM / 智谱团队套餐凭证:组织 ID + 项目 ID(二者齐全才走团队查询) */
104
+ interface TeamCredential {
105
+ organization: string;
106
+ project: string;
107
+ }
108
+
109
+ /** fetchQuota 额外参数(目前仅 GLM 团队版使用) */
110
+ interface QuotaFetchExtra {
111
+ team?: TeamCredential | null;
112
+ }
113
+
103
114
  interface TokenPlan {
104
115
  id: string;
105
116
  name: string;
@@ -108,12 +119,14 @@ interface TokenPlan {
108
119
  baseUrl: string;
109
120
  quotaPath: string;
110
121
  authHeader: (key: string) => Record<string, string>;
111
- fetchQuota: (plan: TokenPlan, key: string) => Promise<any>;
122
+ fetchQuota: (plan: TokenPlan, key: string, extra?: QuotaFetchExtra) => Promise<any>;
112
123
  format: (data: any) => { modelPrefix: string; display: string; color: 'ok' | 'warn' | 'err' };
113
124
  }
114
125
 
115
126
  interface TokenConfig {
116
127
  providerPlans: Record<string, string | null>;
128
+ /** GLM 团队套餐凭证(可选),经 /stats 菜单配置并持久化 */
129
+ teamCredential?: { organization: string; project: string };
117
130
  ttl: number;
118
131
  }
119
132
 
@@ -334,15 +347,27 @@ const BUILTIN_PLANS: TokenPlan[] = [
334
347
  {
335
348
  id: "glm",
336
349
  name: "GLM (智谱)",
337
- matchProviders: ["zhipu-cn", "zhipu", "glm", "bigmodel"],
350
+ matchProviders: ["zhipu-cn", "zhipu", "glm", "bigmodel", "zai-coding-cn"],
338
351
  apiKeyEnv: "GLM_API_KEY",
339
352
  baseUrl: "https://open.bigmodel.cn",
340
353
  quotaPath: "/api/monitor/usage/quota/limit",
341
354
  authHeader: (key) => ({ Authorization: key }),
342
- fetchQuota: async (plan: TokenPlan, key: string) => {
343
- const r = await fetch(plan.baseUrl + plan.quotaPath, {
355
+ fetchQuota: async (plan: TokenPlan, key: string, extra?: QuotaFetchExtra) => {
356
+ const team = extra?.team;
357
+ const headers: Record<string, string> = {
358
+ ...plan.authHeader(key),
359
+ "Content-Type": "application/json",
360
+ };
361
+ // 团队套餐:同一 quota 端点加 ?type=2,并携带组织/项目 ID 请求头
362
+ // (api_key + 组织 ID + 项目 ID 三者缺一不可,仅国内站 open.bigmodel.cn 有团队版)
363
+ if (team) {
364
+ headers["Bigmodel-Organization"] = team.organization;
365
+ headers["Bigmodel-Project"] = team.project;
366
+ }
367
+ const url = plan.baseUrl + plan.quotaPath + (team ? "?type=2" : "");
368
+ const r = await fetch(url, {
344
369
  method: "GET",
345
- headers: { ...plan.authHeader(key), "Content-Type": "application/json" },
370
+ headers,
346
371
  signal: AbortSignal.timeout(5000),
347
372
  });
348
373
  if (!r.ok) throw new Error("GLM 配额查询 HTTP " + r.status);
@@ -350,23 +375,56 @@ const BUILTIN_PLANS: TokenPlan[] = [
350
375
  },
351
376
  format: (data: any) => {
352
377
  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);
378
+ // 个人版返回 TOKENS_LIMIT,团队版返回 CREDIT_LIMIT(大小写不敏感)必识
379
+ const isQuota = (t: any) => {
380
+ const s = String(t ?? "").toLowerCase();
381
+ return s === "tokens_limit" || s === "credit_limit";
382
+ };
383
+ const entries = limits.filter((x: any) => isQuota(x?.type));
384
+ if (entries.length === 0) return { modelPrefix: "", display: "无数据", color: "err" as const };
385
+
386
+ // 窗口分类锚定 unit(不依赖数组顺序,与 cc-switch parse_zhipu_token_tiers 一致):
387
+ // unit: 3 → 5h 滚动窗口;unit: 6 → 每周窗口(老/新套餐 number 7 / 1 均可能)
388
+ const byUnit = (u: number) => entries.find((x: any) => x?.unit === u);
389
+ let fiveHour: any = byUnit(3) ?? null;
390
+ let weekly: any = byUnit(6) ?? null;
391
+ // 兜底:unit 缺失/不识别 → 无 nextResetTime 优先归 5h,其余按重置时间升序补槽
392
+ if (!fiveHour || !weekly) {
393
+ const unclassified = entries
394
+ .filter((x: any) => x !== fiveHour && x !== weekly)
395
+ .sort((a: any, b: any) =>
396
+ (typeof a?.nextResetTime === "number" ? a.nextResetTime : Number.MIN_SAFE_INTEGER) -
397
+ (typeof b?.nextResetTime === "number" ? b.nextResetTime : Number.MIN_SAFE_INTEGER));
398
+ for (const e of unclassified) {
399
+ if (!fiveHour) fiveHour = e;
400
+ else if (!weekly) weekly = e;
401
+ }
402
+ }
403
+
404
+ // percentage 为已用比例 → 剩余 = 100 - 已用
405
+ const intervalRemaining = fiveHour ? 100 - (fiveHour.percentage ?? 0) : null;
406
+ const weeklyRemaining = weekly ? 100 - (weekly.percentage ?? 0) : null;
360
407
  const now = Date.now();
361
- const resets = tokenLimits
362
- .map((x: any) => x.nextResetTime)
408
+ const resets = entries
409
+ .map((x: any) => x?.nextResetTime)
363
410
  .filter((t: any) => typeof t === "number" && t > now);
364
411
  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
- };
412
+
413
+ const both = intervalRemaining !== null && weeklyRemaining !== null;
414
+ const display =
415
+ intervalRemaining !== null && weeklyRemaining !== null
416
+ ? formatTokenPlanDisplay(intervalRemaining, weeklyRemaining, nearestReset)
417
+ : intervalRemaining !== null
418
+ ? `5h: ${Math.round(intervalRemaining)}%`
419
+ : weeklyRemaining !== null
420
+ ? `W: ${Math.round(weeklyRemaining)}%`
421
+ : "无数据";
422
+ const low = (v: number | null) => v !== null && v < 20;
423
+ const mid = (v: number | null) => v !== null && v < 50;
424
+ const color = low(intervalRemaining) || low(weeklyRemaining) ? "err" as const
425
+ : mid(intervalRemaining) || mid(weeklyRemaining) ? "warn" as const
426
+ : "ok" as const;
427
+ return { modelPrefix: "", display, color };
370
428
  },
371
429
  },
372
430
  {
@@ -1017,6 +1075,20 @@ export function createTokenStats(
1017
1075
  return null;
1018
1076
  }
1019
1077
 
1078
+ /**
1079
+ * 解析套餐的团队凭证(当前仅 GLM/智谱团队版)。
1080
+ * 组织 ID + 项目 ID 二者齐全才返回,否则返回 null(回退个人版查询)。
1081
+ * 来源:token-stats config.json 的 teamCredential(/stats 菜单配置)。
1082
+ */
1083
+ function resolveTeamCredential(plan: TokenPlan): TeamCredential | null {
1084
+ if (plan.id !== "glm") return null;
1085
+ const tc = tokenConfig?.teamCredential;
1086
+ const organization = tc?.organization?.trim() ?? "";
1087
+ const project = tc?.project?.trim() ?? "";
1088
+ if (organization && project) return { organization, project };
1089
+ return null;
1090
+ }
1091
+
1020
1092
  /**
1021
1093
  * 检测并处理 provider 变化。
1022
1094
  * 返回 true 表示发生了切换(供调用者决定是否要 force refresh)。
@@ -1138,7 +1210,11 @@ export function createTokenStats(
1138
1210
 
1139
1211
  // 5. 调接口
1140
1212
  try {
1141
- const data = await plan.fetchQuota(plan, key);
1213
+ const data = await plan.fetchQuota(
1214
+ plan,
1215
+ key,
1216
+ { team: resolveTeamCredential(plan) },
1217
+ );
1142
1218
  cache[plan.id] = { fetchedAt: Date.now(), ttl: ttlMs, data };
1143
1219
  await writeQuotaCache(cache);
1144
1220
  const fmt = plan.format(data);
@@ -1173,6 +1249,58 @@ export function createTokenStats(
1173
1249
  shared.requestRender?.();
1174
1250
  }
1175
1251
 
1252
+ /** config.json 无参流里的基础结构(避免 null 展开报错) */
1253
+ function baseTokenConfig(): TokenConfig {
1254
+ return { ...(tokenConfig ?? { providerPlans: {}, ttl: 60 }) };
1255
+ }
1256
+
1257
+ /**
1258
+ * GLM 团队套餐交互配置:提示用户填写组织 ID / 项目 ID 并持久化到 config.json,
1259
+ * 保存后 force 刷新使团队查询(?type=2)立即生效,并反馈查询结果。
1260
+ */
1261
+ async function promptGlmTeamConfig(ctx: ExtensionContext): Promise<void> {
1262
+ const cur = tokenConfig?.teamCredential;
1263
+ const curLabel =
1264
+ cur?.organization && cur?.project
1265
+ ? `${cur.organization} / ${cur.project}`
1266
+ : "未配置(按个人版查询)";
1267
+ const choice = await ctx.ui.select(
1268
+ `GLM 团队套餐凭证?当前:${curLabel}\n填写组织 ID + 项目 ID(二者齐全才走团队查询 ?type=2),跳过则维持个人版查询`,
1269
+ ["✏️ 配置/修改", "跳过"],
1270
+ );
1271
+ if (!choice || choice === "跳过") {
1272
+ await forceRefreshQuota(ctx);
1273
+ const errMsg = quotaState?.error ? formatQuotaError(quotaState) : "";
1274
+ ctx.ui.notify(
1275
+ quotaState?.error ? `GLM 配额查询失败:${errMsg}` : "GLM 配额已启用(个人版查询)",
1276
+ "info",
1277
+ );
1278
+ return;
1279
+ }
1280
+
1281
+ const organization = await ctx.ui.input("组织 ID (Organization)", cur?.organization ?? "");
1282
+ const project = await ctx.ui.input("项目 ID (Project)", cur?.project ?? "");
1283
+ const org = organization?.trim() ?? "";
1284
+ const proj = project?.trim() ?? "";
1285
+ if (!org || !proj) {
1286
+ ctx.ui.notify("组织/项目 ID 不能为空,团队凭证未保存", "warning");
1287
+ return;
1288
+ }
1289
+
1290
+ tokenConfig = {
1291
+ ...baseTokenConfig(),
1292
+ teamCredential: { organization: org, project: proj },
1293
+ };
1294
+ await saveTokenConfig(tokenConfig);
1295
+
1296
+ await forceRefreshQuota(ctx);
1297
+ const errMsg = quotaState?.error ? formatQuotaError(quotaState) : "";
1298
+ ctx.ui.notify(
1299
+ quotaState?.error ? `GLM 团队配额查询失败:${errMsg}` : "GLM 团队套餐配额已启用",
1300
+ "info",
1301
+ );
1302
+ }
1303
+
1176
1304
  /** 清空所有套餐缓存(session_start 调,避免跨 session 复用旧数据) */
1177
1305
  async function invalidateAllQuotaCache() {
1178
1306
  try {
@@ -1668,6 +1796,11 @@ export function createTokenStats(
1668
1796
  } catch { /* ctx 已失效(session 被替换),忽略 */ }
1669
1797
  shared.requestRender?.();
1670
1798
  }, (tokenConfig?.ttl || 60) * 1000);
1799
+ if (plan.id === "glm") {
1800
+ // GLM:继续询问团队套餐凭证(个人版/团队版二选一,内部会 force 查询并反馈)
1801
+ await promptGlmTeamConfig(ctx);
1802
+ return;
1803
+ }
1671
1804
  if (quotaState?.error) {
1672
1805
  // 仅当 quotaState 带有 error 字段时(key 缺失 / API 错误 / 网络错误 / 无数据)才提示"查询失败"
1673
1806
  const errMsg = formatQuotaError(quotaState);
@@ -1684,9 +1817,55 @@ export function createTokenStats(
1684
1817
  "显示样式",
1685
1818
  "显示内容",
1686
1819
  "刷新时间 (当前 " + (tokenConfig?.ttl || 60) + "s)",
1820
+ "GLM 团队凭证",
1687
1821
  ]);
1688
1822
  if (!subChoice) return;
1689
1823
 
1824
+ if (subChoice === "GLM 团队凭证") {
1825
+ const cur = tokenConfig?.teamCredential;
1826
+ const label =
1827
+ cur?.organization && cur?.project
1828
+ ? `${cur.organization} / ${cur.project}`
1829
+ : "未配置";
1830
+ const action = await ctx.ui.select("GLM 团队凭证(当前: " + label + ")", [
1831
+ "✏️ 配置/修改",
1832
+ "清除",
1833
+ "返回",
1834
+ ]);
1835
+ if (!action || action === "返回") return;
1836
+ if (action === "清除") {
1837
+ tokenConfig = {
1838
+ ...baseTokenConfig(),
1839
+ teamCredential: { organization: "", project: "" },
1840
+ };
1841
+ await saveTokenConfig(tokenConfig);
1842
+ await forceRefreshQuota(ctx);
1843
+ ctx.ui.notify("GLM 团队凭证已清除(恢复个人版查询)", "info");
1844
+ } else {
1845
+ const organization = await ctx.ui.input("组织 ID (Organization)", cur?.organization ?? "");
1846
+ const project = await ctx.ui.input("项目 ID (Project)", cur?.project ?? "");
1847
+ const org = organization?.trim() ?? "";
1848
+ const proj = project?.trim() ?? "";
1849
+ if (!org || !proj) {
1850
+ ctx.ui.notify("组织/项目 ID 不能为空,未保存", "warning");
1851
+ return;
1852
+ }
1853
+ tokenConfig = {
1854
+ ...baseTokenConfig(),
1855
+ teamCredential: { organization: org, project: proj },
1856
+ };
1857
+ await saveTokenConfig(tokenConfig);
1858
+ await forceRefreshQuota(ctx);
1859
+ const errMsg = quotaState?.error ? formatQuotaError(quotaState) : "";
1860
+ if (quotaState?.error) {
1861
+ ctx.ui.notify(`GLM 团队配额查询失败:${errMsg}`, "info");
1862
+ } else {
1863
+ ctx.ui.notify("GLM 团队凭证已保存(团队查询已生效)", "info");
1864
+ }
1865
+ }
1866
+ return;
1867
+ }
1868
+
1690
1869
  if (subChoice === "显示样式") {
1691
1870
  const catChoice = await ctx.ui.select("选择要配置的样式类别", [
1692
1871
  "上下文样式",