@bacnh85/pi-sub 0.1.6 → 0.1.8

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 +42 -7
  2. package/index.ts +135 -26
  3. package/package.json +1 -1
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
- V1 supports OpenAI Codex models by reading Pi's auth state from `~/.pi/agent/auth.json` (or `$PI_CODING_AGENT_DIR/auth.json`) and displays a subscription footer status after Pi's built-in status/token usage line only while the active Pi model provider is `openai-codex`.
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
 
@@ -26,6 +26,8 @@ pi -e ./extensions/pi-sub
26
26
 
27
27
  ## What it shows
28
28
 
29
+ ### OpenAI Codex
30
+
29
31
  The footer status appears after Pi's built-in status/token usage line and includes:
30
32
 
31
33
  - active account email;
@@ -40,6 +42,22 @@ Example subscription line:
40
42
  Sub · Plus · user@example.com · 5H 85% (18:12) · W 80% (08:00 on 2 Jul) · gpt-5-codex
41
43
  ```
42
44
 
45
+ ### OpenCode Go
46
+
47
+ OpenCode Go does not expose usage windows, so the footer shows only the accumulated session cost:
48
+
49
+ ```text
50
+ OpenCode Go $0.23
51
+ ```
52
+
53
+ ### Z.ai
54
+
55
+ Z.ai (GLM Coding Plan) shows 5-hour rolling and weekly quota percentages with reset times:
56
+
57
+ ```text
58
+ R:55%(18:12) W:80%(08:00 on 2 Jul)
59
+ ```
60
+
43
61
  When the current model provider is not supported, `pi-sub` clears its subscription line and does not refresh subscription data.
44
62
 
45
63
  ## Commands
@@ -57,6 +75,24 @@ ACCOUNT PLAN 5H USAGE WEEKLY USAGE LAST ACTIVITY
57
75
  * user@example.com Plus 85% (18:12) 80% (08:00 on 2 Jul) Now
58
76
  ```
59
77
 
78
+ For OpenCode Go, `/sub` shows the provider/model and session cost:
79
+
80
+ ```text
81
+ Provider: OpenCode Go · Model: kimi-k2.6 · Fetched: 14:23
82
+ OpenCode Go does not expose usage windows.
83
+ Session cost: $0.23
84
+ ```
85
+
86
+ For Z.ai, `/sub` shows the rolling and weekly quota windows:
87
+
88
+ ```text
89
+ Provider: Z.ai · Model: glm-5.2 · Fetched: 14:23
90
+
91
+ ACCOUNT PLAN ROLLING WEEKLY LAST ACTIVITY
92
+ ------------------------------------------------------------------------
93
+ * Z.ai subscription ? 55%/18:12 80%/08:00 on 2 Jul Now
94
+ ```
95
+
60
96
  ## Refresh behavior
61
97
 
62
98
  `pi-sub` refreshes usage data:
@@ -73,12 +109,11 @@ Refreshes are cached briefly to avoid excessive usage endpoint calls.
73
109
 
74
110
  ## Requirements and troubleshooting
75
111
 
76
- - Pi auth must contain an `openai-codex` OAuth entry in `~/.pi/agent/auth.json` or `$PI_CODING_AGENT_DIR/auth.json`.
77
- - The entry must include `access` and `accountId` fields.
78
- - `pi-sub` redacts auth/token-related errors and never prints Codex tokens.
79
-
80
- If usage is unavailable, verify that Pi can use the `openai-codex` provider and that the auth file exists.
112
+ - **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.
113
+ - **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.
114
+ - **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`).
115
+ - `pi-sub` redacts auth/token-related errors and never prints credentials.
81
116
 
82
117
  ## Design notes
83
118
 
84
- The extension is named `pi-sub` rather than `pi-codex-usage` so future subscription providers can be added as separate adapters. V1 intentionally supports only OpenAI Codex and vendors only the small amount of behavior needed for this extension instead of invoking `codex-auth` directly.
119
+ The extension is named `pi-sub` rather than `pi-codex-usage` so future subscription providers can be added as separate adapters. Supports OpenAI Codex (live usage API) and OpenCode Go (session cost only).
package/index.ts CHANGED
@@ -12,6 +12,8 @@ const REFRESH_TTL_MS = 30_000;
12
12
  const REFRESH_DEBOUNCE_MS = 2_000;
13
13
  const CODEX_PROVIDER = "openai-codex";
14
14
  const OPC_PROVIDER = "opencode-go";
15
+ const ZAI_PROVIDER = "zai";
16
+ const ZAI_USAGE_URL = "https://api.z.ai/api/monitor/usage/quota/limit";
15
17
 
16
18
  type ModelLike = { provider?: string; id?: string } | undefined;
17
19
 
@@ -98,6 +100,10 @@ function isOpenCodeGoModel(model: ModelLike): boolean {
98
100
  return (model?.provider?.toLowerCase() ?? "") === OPC_PROVIDER;
99
101
  }
100
102
 
103
+ function isZaiModel(model: ModelLike): boolean {
104
+ return (model?.provider?.toLowerCase() ?? "") === ZAI_PROVIDER;
105
+ }
106
+
101
107
  function piAuthPath(): string {
102
108
  const configDir = process.env.PI_CODING_AGENT_DIR?.trim() || path.join(os.homedir(), ".pi", "agent");
103
109
  return path.join(configDir, "auth.json");
@@ -230,25 +236,6 @@ function parseUsageResponse(body: unknown): UsageApiSnapshot | undefined {
230
236
  };
231
237
  }
232
238
 
233
- function parseOpcodeUsageResponse(body: unknown): UsageApiSnapshot | undefined {
234
- if (!body || typeof body !== "object") return undefined;
235
- const root = body as any;
236
- const usage = root.usage ?? root;
237
- const parseWindow = (window: any): UsageApiWindow | undefined => {
238
- if (!window || typeof window !== "object" || typeof window.used_percent !== "number") return undefined;
239
- return {
240
- used_percent: window.used_percent,
241
- limit_window_seconds: typeof window.limit_window_seconds === "number" ? window.limit_window_seconds : undefined,
242
- reset_at: typeof window.reset_at === "number" ? window.reset_at : typeof window.reset_ts === "number" ? window.reset_ts : undefined,
243
- };
244
- };
245
- return {
246
- primary: parseWindow(usage.rolling ?? usage.primary_window),
247
- secondary: parseWindow(usage.weekly ?? usage.secondary_window),
248
- plan_type: typeof root.plan_type === "string" ? root.plan_type : typeof usage.plan_type === "string" ? usage.plan_type : undefined,
249
- };
250
- }
251
-
252
239
  async function readPiCodexAuth(): Promise<PiAuthEntry & { accountId: string }> {
253
240
  const auth = await readJsonFile<PiAuthFile>(piAuthPath());
254
241
  const entry = auth[CODEX_PROVIDER];
@@ -257,11 +244,19 @@ async function readPiCodexAuth(): Promise<PiAuthEntry & { accountId: string }> {
257
244
  return { ...entry, accountId };
258
245
  }
259
246
 
260
- async function readOpenCodeGoAuth(): Promise<{ key: string }> {
247
+ async function readOpenCodeGoAuth(): Promise<{ key?: string; accountId?: string }> {
261
248
  const auth = await readJsonFile<PiAuthFile>(piAuthPath());
262
249
  const entry = auth[OPC_PROVIDER];
263
- if (!entry?.key || typeof entry.key !== "string") throw new Error("Missing opencode-go API key in Pi auth");
264
- return { key: entry.key };
250
+ if (!entry?.key && !entry?.accountId) throw new Error("Missing opencode-go API key or accountId in Pi auth");
251
+ if (typeof entry.key === "string") return { key: entry.key };
252
+ return { accountId: typeof entry.accountId === "string" ? entry.accountId : undefined };
253
+ }
254
+
255
+ async function readZaiAuth(): Promise<string> {
256
+ const auth = await readJsonFile<PiAuthFile>(piAuthPath());
257
+ const entry = auth[ZAI_PROVIDER];
258
+ if (!entry?.key) throw new Error("Missing zai API key in Pi auth");
259
+ return entry.key;
265
260
  }
266
261
 
267
262
  async function fetchUsageFromPiAuth(entry: PiAuthEntry, signal?: AbortSignal): Promise<UsageApiSnapshot | undefined> {
@@ -285,6 +280,7 @@ function redactedError(error: unknown, provider = "Codex"): string {
285
280
  if (/ENOENT|no such file/i.test(message)) return "Pi auth not found";
286
281
  if (/missing openai-codex/i.test(message)) return "openai-codex auth not found";
287
282
  if (/missing opencode-go/i.test(message)) return "opencode-go auth not found";
283
+ if (/missing zai/i.test(message)) return "zai auth not found";
288
284
  if (/timed out|timeout|aborted/i.test(message)) return `${provider} usage refresh timed out`;
289
285
  if (/401|403|auth|token|unauthorized|forbidden/i.test(message)) return `${provider} auth unavailable`;
290
286
  return `${provider} usage unavailable`;
@@ -314,7 +310,7 @@ async function fetchCodexUsage(signal?: AbortSignal): Promise<SubscriptionUsageS
314
310
  }
315
311
  }
316
312
 
317
- async function fetchOpenCodeGoUsage(signal?: AbortSignal): Promise<SubscriptionUsageSnapshot> {
313
+ async function fetchOpenCodeGoUsage(_signal?: AbortSignal): Promise<SubscriptionUsageSnapshot> {
318
314
  try {
319
315
  await readOpenCodeGoAuth();
320
316
  return {
@@ -322,7 +318,6 @@ async function fetchOpenCodeGoUsage(signal?: AbortSignal): Promise<SubscriptionU
322
318
  providerDisplayName: "OpenCode Go",
323
319
  accounts: [],
324
320
  fetchedAt: Date.now(),
325
- error: "OpenCode Go usage tracking not yet implemented",
326
321
  };
327
322
  } catch (error) {
328
323
  return {
@@ -335,6 +330,109 @@ async function fetchOpenCodeGoUsage(signal?: AbortSignal): Promise<SubscriptionU
335
330
  }
336
331
  }
337
332
 
333
+ // ---------------------------------------------------------------------------
334
+ // Z.ai adapter
335
+ // ---------------------------------------------------------------------------
336
+
337
+ interface ZaiLimitEntry {
338
+ type: string;
339
+ percentage: number;
340
+ nextResetTime?: number;
341
+ }
342
+
343
+ interface ZaiUsageApiResponse {
344
+ data?: {
345
+ limits?: ZaiLimitEntry[];
346
+ };
347
+ }
348
+
349
+ interface ZaiUsageApiError {
350
+ code: number;
351
+ msg: string;
352
+ success: boolean;
353
+ }
354
+
355
+ function zaiLimitToUsageWindow(limit: ZaiLimitEntry): UsageWindow | undefined {
356
+ if (typeof limit.percentage !== "number") return undefined;
357
+ const percent = Math.round(limit.percentage);
358
+ const remaining = Math.max(0, 100 - percent);
359
+ // Z.ai returns nextResetTime in epoch milliseconds; format helpers expect seconds.
360
+ const resetAtSec = limit.nextResetTime ? limit.nextResetTime / 1000 : undefined;
361
+ const resetLabel = formatReset(resetAtSec);
362
+ const remainingLabel = formatRemainingTime(resetAtSec);
363
+ return {
364
+ percent,
365
+ remaining,
366
+ remainingLabel,
367
+ resetLabel,
368
+ resetsAt: limit.nextResetTime,
369
+ label: remainingLabel ? `${remaining}% (${remainingLabel})` : `${remaining}%`,
370
+ };
371
+ }
372
+
373
+ async function fetchZaiUsage(signal?: AbortSignal): Promise<SubscriptionUsageSnapshot> {
374
+ try {
375
+ const apiKey = await readZaiAuth();
376
+ const timeoutSignal = AbortSignal.timeout(7_000);
377
+ const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
378
+
379
+ const response = await fetch(ZAI_USAGE_URL, {
380
+ headers: {
381
+ Accept: "application/json",
382
+ Authorization: `Bearer ${apiKey}`,
383
+ "User-Agent": "pi-sub/0.1.0",
384
+ },
385
+ signal: combinedSignal,
386
+ });
387
+
388
+ const body = await response.json();
389
+
390
+ // Z.ai returns HTTP 200 even on auth errors: {"code":401,"msg":"...","success":false}
391
+ const apiError = body as ZaiUsageApiError;
392
+ if (typeof apiError.success === "boolean" && !apiError.success && apiError.msg) {
393
+ throw new Error(`Z.ai API error: ${apiError.msg}`);
394
+ }
395
+
396
+ const parsed = body as ZaiUsageApiResponse;
397
+ const tokenLimits = (parsed.data?.limits ?? [])
398
+ .filter((l) => l.type === "TOKENS_LIMIT")
399
+ .sort((a, b) => (a.nextResetTime ?? 0) - (b.nextResetTime ?? 0));
400
+
401
+ if (tokenLimits.length === 0) {
402
+ throw new Error("No TOKENS_LIMIT entries in Z.ai usage response");
403
+ }
404
+
405
+ // The limit with the nearest reset is the 5-hour rolling window;
406
+ // the next one (if present) is the weekly window.
407
+ const fiveHour = zaiLimitToUsageWindow(tokenLimits[0]);
408
+ const weekly = tokenLimits.length >= 2 ? zaiLimitToUsageWindow(tokenLimits[1]) : undefined;
409
+
410
+ const account: SubscriptionAccountSnapshot = {
411
+ isActive: true,
412
+ accountLabel: "Z.ai subscription",
413
+ fiveHour,
414
+ weekly,
415
+ lastActivity: "Now",
416
+ };
417
+
418
+ return {
419
+ providerId: ZAI_PROVIDER,
420
+ providerDisplayName: "Z.ai",
421
+ accounts: [account],
422
+ activeAccount: account,
423
+ fetchedAt: Date.now(),
424
+ };
425
+ } catch (error) {
426
+ return {
427
+ providerId: ZAI_PROVIDER,
428
+ providerDisplayName: "Z.ai",
429
+ accounts: [],
430
+ fetchedAt: Date.now(),
431
+ error: redactedError(error, "Z.ai"),
432
+ };
433
+ }
434
+ }
435
+
338
436
  const codexAdapter: SubscriptionProviderAdapter = {
339
437
  id: CODEX_PROVIDER,
340
438
  displayName: "Codex",
@@ -349,7 +447,14 @@ const openCodeGoAdapter: SubscriptionProviderAdapter = {
349
447
  fetchUsage: fetchOpenCodeGoUsage,
350
448
  };
351
449
 
352
- const adapters = [codexAdapter, openCodeGoAdapter];
450
+ const zaiAdapter: SubscriptionProviderAdapter = {
451
+ id: ZAI_PROVIDER,
452
+ displayName: "Z.ai",
453
+ isModelSupported: isZaiModel,
454
+ fetchUsage: fetchZaiUsage,
455
+ };
456
+
457
+ const adapters = [codexAdapter, openCodeGoAdapter, zaiAdapter];
353
458
 
354
459
  function supportedAdapter(model: ModelLike): SubscriptionProviderAdapter | undefined {
355
460
  return adapters.find((adapter) => adapter.isModelSupported(model));
@@ -511,7 +616,11 @@ function buildDetails(snapshot: SubscriptionUsageSnapshot | undefined, state: St
511
616
  if (!state.adapter) return `Subscription tracking inactive for current model provider (${state.model?.provider ?? "unknown"}).`;
512
617
  if (!snapshot) return "Subscription usage has not been loaded yet.";
513
618
  if (snapshot.error) return `${snapshot.providerDisplayName}: ${snapshot.error}`;
514
- if (snapshot.accounts.length === 0) return `${snapshot.providerDisplayName}: no accounts found.`;
619
+ if (snapshot.accounts.length === 0) {
620
+ const costLine = snapshot.cost !== undefined && snapshot.cost > 0 ? `\nSession cost: $${snapshot.cost.toFixed(2)}` : "";
621
+ const modelInfo = state.model?.id ? ` · Model: ${state.model.id}` : "";
622
+ return `Provider: ${snapshot.providerDisplayName}${modelInfo} · Fetched: ${new Date(snapshot.fetchedAt).toLocaleTimeString()}\n${snapshot.providerDisplayName} does not expose usage windows.${costLine}`;
623
+ }
515
624
 
516
625
  const columns: { key: string; label: string; get: (a: SubscriptionAccountSnapshot) => string }[] = [
517
626
  { key: "account", label: "ACCOUNT", get: (a) => a.accountLabel ?? "unknown" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-sub",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Pi extension showing subscription usage for supported model providers.",
5
5
  "type": "module",
6
6
  "license": "MIT",