@bacnh85/pi-sub 0.1.7 → 0.1.9

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 +38 -14
  2. package/index.ts +197 -10
  3. package/package.json +4 -2
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Pi extension that shows subscription usage for the currently selected supported model provider.
4
4
 
5
- Supports OpenAI Codex (`openai-codex`) with live usage windows from ChatGPT's usage endpoint, and OpenCode Go (`opencode-go`) with session cost tracking. Displays a subscription footer status after Pi's built-in status/token usage line.
5
+ Supports OpenAI Codex (`openai-codex`) with live usage windows from ChatGPT's usage endpoint, OpenCode Go (`opencode-go`) with session cost tracking, and Z.ai (`zai`) with GLM Coding Plan quota monitoring. Displays a subscription footer status after Pi's built-in status/token usage line.
6
6
 
7
7
  ## Install
8
8
 
@@ -30,24 +30,31 @@ pi -e ./extensions/pi-sub
30
30
 
31
31
  The footer status appears after Pi's built-in status/token usage line and includes:
32
32
 
33
- - active account email;
34
- - subscription plan, such as `Plus`;
35
- - 5-hour usage percentage and reset time;
36
- - weekly usage percentage and reset time/date;
37
- - active Codex model id.
33
+ - active account email/account label;
34
+ - subscription plan, such as `Plus` in `/sub` details;
35
+ - 5-hour remaining quota and reset countdown;
36
+ - weekly remaining quota and reset countdown.
38
37
 
39
38
  Example subscription line:
40
39
 
41
40
  ```text
42
- Sub · Plus · user@example.com · 5H 85% (18:12) · W 80% (08:00 on 2 Jul) · gpt-5-codex
41
+ (user@example.com) R:15%/2H W:20%/3D
43
42
  ```
44
43
 
45
44
  ### OpenCode Go
46
45
 
47
- OpenCode Go does not expose usage windows, so the footer shows only the accumulated session cost:
46
+ OpenCode Go does not expose a public usage-window API, so the footer shows the active account/key label and accumulated session cost:
48
47
 
49
48
  ```text
50
- OpenCode Go $0.23
49
+ OpenCode Go (OpenCode Go key#1a2b3c4d) $0.23
50
+ ```
51
+
52
+ ### Z.ai
53
+
54
+ Z.ai (GLM Coding Plan) shows the active account/key label plus 5-hour rolling and weekly remaining quota with reset countdowns:
55
+
56
+ ```text
57
+ (Z.ai key#1a2b3c4d) R:55%/2H W:80%/3D
51
58
  ```
52
59
 
53
60
  When the current model provider is not supported, `pi-sub` clears its subscription line and does not refresh subscription data.
@@ -63,16 +70,31 @@ When the current model provider is not supported, `pi-sub` clears its subscripti
63
70
  When Pi OpenAI Codex auth is available, `/sub` shows the active account usage:
64
71
 
65
72
  ```text
66
- ACCOUNT PLAN 5H USAGE WEEKLY USAGE LAST ACTIVITY
67
- * user@example.com Plus 85% (18:12) 80% (08:00 on 2 Jul) Now
73
+ ACCOUNT PLAN ROLLING WEEKLY LAST ACTIVITY
74
+ * user@example.com Plus 15%/2H 20%/3D Now
68
75
  ```
69
76
 
70
- For OpenCode Go, `/sub` shows the provider/model and session cost:
77
+ For OpenCode Go, `/sub` shows the provider/model, active account/key label, and session cost:
71
78
 
72
79
  ```text
73
80
  Provider: OpenCode Go · Model: kimi-k2.6 · Fetched: 14:23
74
- OpenCode Go does not expose usage windows.
75
81
  Session cost: $0.23
82
+
83
+ ACCOUNT PLAN LAST ACTIVITY
84
+ ------------------------------------------------------
85
+ * OpenCode Go key#1a2b3c4d Go Now
86
+
87
+ OpenCode Go does not expose usage windows.
88
+ ```
89
+
90
+ For Z.ai, `/sub` shows the rolling and weekly quota windows:
91
+
92
+ ```text
93
+ Provider: Z.ai · Model: glm-5.2 · Fetched: 14:23
94
+
95
+ ACCOUNT PLAN ROLLING WEEKLY LAST ACTIVITY
96
+ ------------------------------------------------------------------------
97
+ * Z.ai key#1a2b3c4d Pro 55%/2H 80%/3D Now
76
98
  ```
77
99
 
78
100
  ## Refresh behavior
@@ -92,7 +114,9 @@ Refreshes are cached briefly to avoid excessive usage endpoint calls.
92
114
  ## Requirements and troubleshooting
93
115
 
94
116
  - **OpenAI Codex**: Pi auth must contain an `openai-codex` OAuth entry in `~/.pi/agent/auth.json` or `$PI_CODING_AGENT_DIR/auth.json`. The entry must include `access` and `accountId` fields.
95
- - **OpenCode Go**: Pi auth must contain an `opencode-go` API key entry (via `/login` or env var). The entry must have a `key` field or an `accountId` field.
117
+ - **OpenCode Go**: Pi auth must contain an `opencode-go` API key entry (via `/login` or env var). The entry must have a `key` field or an `accountId` field. OpenCode Go/Zen usage windows and Zen balance are not shown because no public API is currently documented for those values.
118
+ - **Z.ai**: Pi auth must contain a `zai` entry in `auth.json` with a `key` field (the same API key used for Z.ai model access via `@czottmann/pi-zai-api`). The Z.ai provider must be registered (e.g., `pi install npm:@czottmann/pi-zai-api`).
119
+ - For API-key-only providers, account labels come from stored auth metadata (`email`, `label`, `name`, or `accountId`) when available; otherwise `pi-sub` displays a non-secret SHA-256 key fingerprint such as `Z.ai key#1a2b3c4d`.
96
120
  - `pi-sub` redacts auth/token-related errors and never prints credentials.
97
121
 
98
122
  ## Design notes
package/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Box, Text } from "@earendil-works/pi-tui";
3
+ import { createHash } from "node:crypto";
3
4
  import fs from "node:fs/promises";
4
5
  import os from "node:os";
5
6
  import path from "node:path";
@@ -12,6 +13,8 @@ const REFRESH_TTL_MS = 30_000;
12
13
  const REFRESH_DEBOUNCE_MS = 2_000;
13
14
  const CODEX_PROVIDER = "openai-codex";
14
15
  const OPC_PROVIDER = "opencode-go";
16
+ const ZAI_PROVIDER = "zai";
17
+ const ZAI_USAGE_URL = "https://api.z.ai/api/monitor/usage/quota/limit";
15
18
 
16
19
  type ModelLike = { provider?: string; id?: string } | undefined;
17
20
 
@@ -36,6 +39,10 @@ type PiAuthEntry = {
36
39
  expires?: number;
37
40
  accountId?: string;
38
41
  key?: string;
42
+ email?: string;
43
+ label?: string;
44
+ name?: string;
45
+ env?: Record<string, string>;
39
46
  };
40
47
 
41
48
  interface UsageWindow {
@@ -98,6 +105,10 @@ function isOpenCodeGoModel(model: ModelLike): boolean {
98
105
  return (model?.provider?.toLowerCase() ?? "") === OPC_PROVIDER;
99
106
  }
100
107
 
108
+ function isZaiModel(model: ModelLike): boolean {
109
+ return (model?.provider?.toLowerCase() ?? "") === ZAI_PROVIDER;
110
+ }
111
+
101
112
  function piAuthPath(): string {
102
113
  const configDir = process.env.PI_CODING_AGENT_DIR?.trim() || path.join(os.homedir(), ".pi", "agent");
103
114
  return path.join(configDir, "auth.json");
@@ -134,6 +145,46 @@ function accountFromPiAuth(entry: PiAuthEntry): SubscriptionAccountSnapshot {
134
145
  };
135
146
  }
136
147
 
148
+ function firstString(...values: unknown[]): string | undefined {
149
+ for (const value of values) {
150
+ if (typeof value !== "string") continue;
151
+ const trimmed = value.trim();
152
+ if (trimmed.length > 0) return trimmed;
153
+ }
154
+ return undefined;
155
+ }
156
+
157
+ function authEntryLabel(entry: PiAuthEntry | undefined): string | undefined {
158
+ return firstString(entry?.email, entry?.label, entry?.name, entry?.accountId);
159
+ }
160
+
161
+ function keyFingerprint(key: string | undefined): string | undefined {
162
+ if (!key) return undefined;
163
+ return createHash("sha256").update(key).digest("hex").slice(0, 8);
164
+ }
165
+
166
+ function authAccountLabel(providerLabel: string, entry: PiAuthEntry | undefined): string {
167
+ const label = authEntryLabel(entry);
168
+ if (label) return label;
169
+ const fingerprint = keyFingerprint(entry?.key);
170
+ return fingerprint ? `${providerLabel} key#${fingerprint}` : `${providerLabel} account`;
171
+ }
172
+
173
+ function authAccountSnapshot(providerLabel: string, entry: PiAuthEntry | undefined, defaults: Partial<SubscriptionAccountSnapshot> = {}): SubscriptionAccountSnapshot {
174
+ return {
175
+ id: firstString(entry?.accountId),
176
+ isActive: true,
177
+ accountLabel: authAccountLabel(providerLabel, entry),
178
+ lastActivity: "Now",
179
+ ...defaults,
180
+ };
181
+ }
182
+
183
+ function formatFooterAccount(account: SubscriptionAccountSnapshot | undefined): string | undefined {
184
+ const label = firstString(account?.accountLabel);
185
+ return label ? `(${label})` : undefined;
186
+ }
187
+
137
188
  function getCodexAccountId(entry: PiAuthEntry | undefined): string | undefined {
138
189
  if (!entry) return undefined;
139
190
  if (typeof entry.accountId === "string" && entry.accountId.length > 0) return entry.accountId;
@@ -238,22 +289,32 @@ async function readPiCodexAuth(): Promise<PiAuthEntry & { accountId: string }> {
238
289
  return { ...entry, accountId };
239
290
  }
240
291
 
241
- async function readOpenCodeGoAuth(): Promise<{ key?: string; accountId?: string }> {
292
+ async function readOpenCodeGoAuth(): Promise<{ key?: string; accountId?: string; account: SubscriptionAccountSnapshot }> {
242
293
  const auth = await readJsonFile<PiAuthFile>(piAuthPath());
243
294
  const entry = auth[OPC_PROVIDER];
244
295
  if (!entry?.key && !entry?.accountId) throw new Error("Missing opencode-go API key or accountId in Pi auth");
245
- if (typeof entry.key === "string") return { key: entry.key };
246
- return { accountId: typeof entry.accountId === "string" ? entry.accountId : undefined };
296
+ const account = authAccountSnapshot("OpenCode Go", entry, { plan: "Go" });
297
+ if (typeof entry.key === "string") return { key: entry.key, account };
298
+ return { accountId: typeof entry.accountId === "string" ? entry.accountId : undefined, account };
299
+ }
300
+
301
+ async function readZaiAuth(): Promise<{ key: string; account: SubscriptionAccountSnapshot }> {
302
+ const auth = await readJsonFile<PiAuthFile>(piAuthPath());
303
+ const entry = auth[ZAI_PROVIDER];
304
+ if (!entry?.key) throw new Error("Missing zai API key in Pi auth");
305
+ return { key: entry.key, account: authAccountSnapshot("Z.ai", entry) };
247
306
  }
248
307
 
249
308
  async function fetchUsageFromPiAuth(entry: PiAuthEntry, signal?: AbortSignal): Promise<UsageApiSnapshot | undefined> {
309
+ const accountId = getCodexAccountId(entry) ?? entry.accountId;
310
+ if (!accountId) throw new Error("Missing openai-codex OAuth entry in Pi auth");
250
311
  const timeoutSignal = AbortSignal.timeout(7_000);
251
312
  const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
252
313
  const response = await fetch(USAGE_ENDPOINT, {
253
314
  headers: {
254
315
  Accept: "application/json",
255
316
  Authorization: `Bearer ${entry.access}`,
256
- "ChatGPT-Account-Id": getCodexAccountId(entry) ?? entry.accountId,
317
+ "ChatGPT-Account-Id": accountId,
257
318
  "User-Agent": "pi-sub/0.1.0",
258
319
  },
259
320
  signal: combinedSignal,
@@ -267,6 +328,7 @@ function redactedError(error: unknown, provider = "Codex"): string {
267
328
  if (/ENOENT|no such file/i.test(message)) return "Pi auth not found";
268
329
  if (/missing openai-codex/i.test(message)) return "openai-codex auth not found";
269
330
  if (/missing opencode-go/i.test(message)) return "opencode-go auth not found";
331
+ if (/missing zai/i.test(message)) return "zai auth not found";
270
332
  if (/timed out|timeout|aborted/i.test(message)) return `${provider} usage refresh timed out`;
271
333
  if (/401|403|auth|token|unauthorized|forbidden/i.test(message)) return `${provider} auth unavailable`;
272
334
  return `${provider} usage unavailable`;
@@ -298,11 +360,12 @@ async function fetchCodexUsage(signal?: AbortSignal): Promise<SubscriptionUsageS
298
360
 
299
361
  async function fetchOpenCodeGoUsage(_signal?: AbortSignal): Promise<SubscriptionUsageSnapshot> {
300
362
  try {
301
- await readOpenCodeGoAuth();
363
+ const { account } = await readOpenCodeGoAuth();
302
364
  return {
303
365
  providerId: OPC_PROVIDER,
304
366
  providerDisplayName: "OpenCode Go",
305
- accounts: [],
367
+ accounts: [account],
368
+ activeAccount: account,
306
369
  fetchedAt: Date.now(),
307
370
  };
308
371
  } catch (error) {
@@ -316,6 +379,117 @@ async function fetchOpenCodeGoUsage(_signal?: AbortSignal): Promise<Subscription
316
379
  }
317
380
  }
318
381
 
382
+ // ---------------------------------------------------------------------------
383
+ // Z.ai adapter
384
+ // ---------------------------------------------------------------------------
385
+
386
+ interface ZaiLimitEntry {
387
+ type: string;
388
+ percentage: number;
389
+ nextResetTime?: number;
390
+ }
391
+
392
+ interface ZaiUsageApiResponse {
393
+ data?: {
394
+ limits?: ZaiLimitEntry[];
395
+ planName?: string;
396
+ plan?: string;
397
+ plan_type?: string;
398
+ packageName?: string;
399
+ };
400
+ }
401
+
402
+ interface ZaiUsageApiError {
403
+ code: number;
404
+ msg: string;
405
+ success: boolean;
406
+ }
407
+
408
+ function zaiLimitToUsageWindow(limit: ZaiLimitEntry): UsageWindow | undefined {
409
+ if (typeof limit.percentage !== "number") return undefined;
410
+ const percent = Math.round(limit.percentage);
411
+ const remaining = Math.max(0, 100 - percent);
412
+ // Z.ai returns nextResetTime in epoch milliseconds; format helpers expect seconds.
413
+ const resetAtSec = limit.nextResetTime ? limit.nextResetTime / 1000 : undefined;
414
+ const resetLabel = formatReset(resetAtSec);
415
+ const remainingLabel = formatRemainingTime(resetAtSec);
416
+ return {
417
+ percent,
418
+ remaining,
419
+ remainingLabel,
420
+ resetLabel,
421
+ resetsAt: limit.nextResetTime,
422
+ label: remainingLabel ? `${remaining}% (${remainingLabel})` : `${remaining}%`,
423
+ };
424
+ }
425
+
426
+ function zaiPlanLabel(response: ZaiUsageApiResponse): string | undefined {
427
+ const data = response.data;
428
+ return planLabel(firstString(data?.planName, data?.plan, data?.plan_type, data?.packageName));
429
+ }
430
+
431
+ async function fetchZaiUsage(signal?: AbortSignal): Promise<SubscriptionUsageSnapshot> {
432
+ try {
433
+ const { key: apiKey, account: authAccount } = await readZaiAuth();
434
+ const timeoutSignal = AbortSignal.timeout(7_000);
435
+ const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
436
+
437
+ const response = await fetch(ZAI_USAGE_URL, {
438
+ headers: {
439
+ Accept: "application/json",
440
+ Authorization: `Bearer ${apiKey}`,
441
+ "User-Agent": "pi-sub/0.1.0",
442
+ },
443
+ signal: combinedSignal,
444
+ });
445
+
446
+ const body = await response.json();
447
+
448
+ // Z.ai returns HTTP 200 even on auth errors: {"code":401,"msg":"...","success":false}
449
+ const apiError = body as ZaiUsageApiError;
450
+ if (typeof apiError.success === "boolean" && !apiError.success && apiError.msg) {
451
+ throw new Error(`Z.ai API error: ${apiError.msg}`);
452
+ }
453
+
454
+ const parsed = body as ZaiUsageApiResponse;
455
+ const tokenLimits = (parsed.data?.limits ?? [])
456
+ .filter((l) => l.type === "TOKENS_LIMIT")
457
+ .sort((a, b) => (a.nextResetTime ?? 0) - (b.nextResetTime ?? 0));
458
+
459
+ if (tokenLimits.length === 0) {
460
+ throw new Error("No TOKENS_LIMIT entries in Z.ai usage response");
461
+ }
462
+
463
+ // The limit with the nearest reset is the 5-hour rolling window;
464
+ // the next one (if present) is the weekly window.
465
+ const fiveHour = zaiLimitToUsageWindow(tokenLimits[0]);
466
+ const weekly = tokenLimits.length >= 2 ? zaiLimitToUsageWindow(tokenLimits[1]) : undefined;
467
+
468
+ const account: SubscriptionAccountSnapshot = {
469
+ ...authAccount,
470
+ plan: zaiPlanLabel(parsed) ?? authAccount.plan,
471
+ fiveHour,
472
+ weekly,
473
+ };
474
+
475
+ return {
476
+ providerId: ZAI_PROVIDER,
477
+ providerDisplayName: "Z.ai",
478
+ accounts: [account],
479
+ activeAccount: account,
480
+ fetchedAt: Date.now(),
481
+ };
482
+ } catch (error) {
483
+ return {
484
+ providerId: ZAI_PROVIDER,
485
+ providerDisplayName: "Z.ai",
486
+ accounts: [],
487
+ fetchedAt: Date.now(),
488
+ error: redactedError(error, "Z.ai"),
489
+ };
490
+ }
491
+ }
492
+
319
493
  const codexAdapter: SubscriptionProviderAdapter = {
320
494
  id: CODEX_PROVIDER,
321
495
  displayName: "Codex",
@@ -330,7 +504,14 @@ const openCodeGoAdapter: SubscriptionProviderAdapter = {
330
504
  fetchUsage: fetchOpenCodeGoUsage,
331
505
  };
332
506
 
333
- const adapters = [codexAdapter, openCodeGoAdapter];
507
+ const zaiAdapter: SubscriptionProviderAdapter = {
508
+ id: ZAI_PROVIDER,
509
+ displayName: "Z.ai",
510
+ isModelSupported: isZaiModel,
511
+ fetchUsage: fetchZaiUsage,
512
+ };
513
+
514
+ const adapters = [codexAdapter, openCodeGoAdapter, zaiAdapter];
334
515
 
335
516
  function supportedAdapter(model: ModelLike): SubscriptionProviderAdapter | undefined {
336
517
  return adapters.find((adapter) => adapter.isModelSupported(model));
@@ -397,9 +578,11 @@ function renderSubscriptionLine(ctx: ExtensionContext, state: State): void {
397
578
  color = "warning";
398
579
  } else {
399
580
  const account = snapshot.activeAccount;
400
- const segments = windowSegments(account);
581
+ const windowParts = windowSegments(account);
582
+ const accountPart = formatFooterAccount(account);
583
+ const segments = accountPart ? [accountPart, ...windowParts] : [...windowParts];
401
584
  const cost = snapshot.cost;
402
- const hasWindows = segments.length > 0;
585
+ const hasWindows = windowParts.length > 0;
403
586
  if (cost !== undefined && cost > 0) segments.push(`$${cost.toFixed(2)}`);
404
587
  if (segments.length === 0) {
405
588
  line = `Sub ${state.adapter.displayName}`;
@@ -530,7 +713,11 @@ function buildDetails(snapshot: SubscriptionUsageSnapshot | undefined, state: St
530
713
  });
531
714
 
532
715
  const costLine = snapshot.cost !== undefined ? `\nSession cost: $${snapshot.cost.toFixed(2)}` : "";
533
- return [`Provider: ${snapshot.providerDisplayName} · Model: ${state.model?.id ?? "unknown-model"} · Fetched: ${new Date(snapshot.fetchedAt).toLocaleTimeString()}${costLine}`, "", header, sep, ...body].join("\n");
716
+ const lines = [`Provider: ${snapshot.providerDisplayName} · Model: ${state.model?.id ?? "unknown-model"} · Fetched: ${new Date(snapshot.fetchedAt).toLocaleTimeString()}${costLine}`, "", header, sep, ...body];
717
+ if (!hasFiveHour && !hasWeekly && !hasMonthly) {
718
+ lines.push("", `${snapshot.providerDisplayName} does not expose usage windows.`);
719
+ }
720
+ return lines.join("\n");
534
721
  }
535
722
 
536
723
  export default function (pi: ExtensionAPI) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-sub",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Pi extension showing subscription usage for supported model providers.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,7 +33,9 @@
33
33
  "./index.ts"
34
34
  ]
35
35
  },
36
- "engines": { "node": ">=20.3.0" },
36
+ "engines": {
37
+ "node": ">=20.3.0"
38
+ },
37
39
  "peerDependencies": {
38
40
  "@earendil-works/pi-coding-agent": "*",
39
41
  "@earendil-works/pi-tui": "*"