@bacnh85/pi-sub 0.1.13 → 0.1.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.
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, 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.
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 GLM Coding Plan — both the international (`zai`) and China (`zai-coding-cn`, `open.bigmodel.cn`) endpoints — with quota monitoring. Displays a subscription footer status after Pi's built-in status/token usage line.
6
6
 
7
7
  ## Install
8
8
 
@@ -53,6 +53,14 @@ Z.ai (GLM Coding Plan) shows the active account/key label, 5-hour rolling and we
53
53
  (Z.ai key#1a2b3c4d) R:55%/2H W:80%/3D 42 tok/s
54
54
  ```
55
55
 
56
+ ### Z.ai Coding Plan (China)
57
+
58
+ The built-in `zai-coding-cn` provider targets the domestic BigModel endpoint (`open.bigmodel.cn`) and returns the same GLM Coding Plan quota format as the international `zai` provider, so the footer and `/sub` detail behave identically, distinguished only by the `Z.ai (CN)` label:
59
+
60
+ ```text
61
+ (Z.ai (CN) key#1a2b3c4d) R:55%/2H W:80%/3D 42 tok/s
62
+ ```
63
+
56
64
  ### Tokens per second
57
65
 
58
66
  `pi-sub` tracks each response's tokens-per-second (tok/s) speed by measuring the time from provider request to message completion against the response's output token count. The last response's speed is shown in the footer next to usage data. The `/sub` detail view shows both the last response speed and the session-wide average.
@@ -104,6 +112,8 @@ Last response: 42 tok/s · Session avg: 39 tok/s
104
112
  * Z.ai key#1a2b3c4d Pro 55%/2H 80%/3D Now
105
113
  ```
106
114
 
115
+ For Z.ai Coding Plan (China), the `/sub` detail is the same with a `Z.ai (CN)` provider/account label:
116
+
107
117
  ## Refresh behavior
108
118
 
109
119
  `pi-sub` refreshes usage data:
@@ -123,9 +133,10 @@ Refreshes are cached briefly to avoid excessive usage endpoint calls.
123
133
  - **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.
124
134
  - **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.
125
135
  - **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`).
136
+ - **Z.ai Coding Plan (China)**: The built-in `zai-coding-cn` provider targets `https://open.bigmodel.cn/api/coding/paas/v4`. Pi auth must contain a `zai-coding-cn` entry with a `key` field (set via `/login` or the `ZAI_CODING_CN_API_KEY` env var). Quota is read from the BigModel endpoint `https://open.bigmodel.cn/api/monitor/usage/quota/limit`.
126
137
  - 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`.
127
138
  - `pi-sub` redacts auth/token-related errors and never prints credentials.
128
139
 
129
140
  ## Design notes
130
141
 
131
- 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).
142
+ 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), OpenCode Go (session cost only), and the Z.ai GLM Coding Plan (international `zai` and China `zai-coding-cn`, which share a quota response format and are served by one parameterized adapter).
@@ -1,6 +1,5 @@
1
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import { readStoredCredential, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { createHash } from "node:crypto";
3
- import fs from "node:fs/promises";
4
3
  import os from "node:os";
5
4
  import path from "node:path";
6
5
 
@@ -14,6 +13,8 @@ const CODEX_PROVIDER = "openai-codex";
14
13
  const OPC_PROVIDER = "opencode-go";
15
14
  const ZAI_PROVIDER = "zai";
16
15
  const ZAI_USAGE_URL = "https://api.z.ai/api/monitor/usage/quota/limit";
16
+ const ZAI_CODING_CN_PROVIDER = "zai-coding-cn";
17
+ const ZAI_CODING_CN_USAGE_URL = "https://open.bigmodel.cn/api/monitor/usage/quota/limit";
17
18
 
18
19
  type ModelLike = { provider?: string; id?: string } | undefined;
19
20
 
@@ -28,8 +29,6 @@ type UsageApiSnapshot = {
28
29
  plan_type?: string;
29
30
  };
30
31
 
31
- type PiAuthFile = Record<string, PiAuthEntry | undefined>;
32
-
33
32
  type PiAuthEntry = {
34
33
  type?: string;
35
34
  access?: string;
@@ -104,15 +103,15 @@ function isZaiModel(model: ModelLike): boolean {
104
103
  return (model?.provider?.toLowerCase() ?? "") === ZAI_PROVIDER;
105
104
  }
106
105
 
106
+ function isZaiCodingCnModel(model: ModelLike): boolean {
107
+ return (model?.provider?.toLowerCase() ?? "") === ZAI_CODING_CN_PROVIDER;
108
+ }
109
+
107
110
  function piAuthPath(): string {
108
111
  const configDir = process.env.PI_CODING_AGENT_DIR?.trim() || path.join(os.homedir(), ".pi", "agent");
109
112
  return path.join(configDir, "auth.json");
110
113
  }
111
114
 
112
- async function readJsonFile<T>(file: string): Promise<T> {
113
- return JSON.parse(await fs.readFile(file, "utf8")) as T;
114
- }
115
-
116
115
  function decodeJwtPayload(token: string | undefined): Record<string, any> | undefined {
117
116
  if (!token) return undefined;
118
117
  const parts = token.split(".");
@@ -274,25 +273,22 @@ function parseUsageResponse(body: unknown): UsageApiSnapshot | undefined {
274
273
  }
275
274
 
276
275
  async function readPiCodexAuth(): Promise<PiAuthEntry & { accountId: string }> {
277
- const auth = await readJsonFile<PiAuthFile>(piAuthPath());
278
- const entry = auth[CODEX_PROVIDER];
276
+ const entry = readStoredCredential(CODEX_PROVIDER, piAuthPath()) as PiAuthEntry | undefined;
279
277
  const accountId = getCodexAccountId(entry);
280
278
  if (!entry?.access || !accountId) throw new Error("Missing openai-codex OAuth entry in Pi auth");
281
279
  return { ...entry, accountId };
282
280
  }
283
281
 
284
282
  async function readOpenCodeGoAuth(): Promise<SubscriptionAccountSnapshot> {
285
- const auth = await readJsonFile<PiAuthFile>(piAuthPath());
286
- const entry = auth[OPC_PROVIDER];
283
+ const entry = readStoredCredential(OPC_PROVIDER, piAuthPath()) as PiAuthEntry | undefined;
287
284
  if (!entry?.key && !entry?.accountId) throw new Error("Missing opencode-go API key or accountId in Pi auth");
288
285
  return authAccountSnapshot("OpenCode Go", entry, { plan: "Go" });
289
286
  }
290
287
 
291
- async function readZaiAuth(): Promise<{ key: string; account: SubscriptionAccountSnapshot }> {
292
- const auth = await readJsonFile<PiAuthFile>(piAuthPath());
293
- const entry = auth[ZAI_PROVIDER];
294
- if (!entry?.key) throw new Error("Missing zai API key in Pi auth");
295
- return { key: entry.key, account: authAccountSnapshot("Z.ai", entry) };
288
+ async function readZaiAuth(providerId: string, label: string): Promise<{ key: string; account: SubscriptionAccountSnapshot }> {
289
+ const entry = readStoredCredential(providerId, piAuthPath()) as PiAuthEntry | undefined;
290
+ if (!entry?.key) throw new Error(`Missing ${providerId} API key in Pi auth`);
291
+ return { key: entry.key, account: authAccountSnapshot(label, entry) };
296
292
  }
297
293
 
298
294
  async function fetchUsageFromPiAuth(entry: PiAuthEntry, signal?: AbortSignal): Promise<UsageApiSnapshot | undefined> {
@@ -382,6 +378,7 @@ interface ZaiUsageApiResponse {
382
378
  plan?: string;
383
379
  plan_type?: string;
384
380
  packageName?: string;
381
+ level?: string;
385
382
  };
386
383
  }
387
384
 
@@ -409,75 +406,81 @@ function zaiLimitToUsageWindow(limit: ZaiLimitEntry): UsageWindow | undefined {
409
406
 
410
407
  function zaiPlanLabel(response: ZaiUsageApiResponse): string | undefined {
411
408
  const data = response.data;
412
- return planLabel(firstString(data?.planName, data?.plan, data?.plan_type, data?.packageName));
413
- }
409
+ return planLabel(firstString(data?.planName, data?.plan, data?.plan_type, data?.packageName, data?.level));
410
+ }
411
+
412
+ // Factory: the international `zai` and China `zai-coding-cn` endpoints share an
413
+ // identical quota response; only the provider id, host, and label differ.
414
+ function zaiUsageAdapter(providerId: string, usageUrl: string, displayName: string): { fetchUsage(signal?: AbortSignal): Promise<SubscriptionUsageSnapshot> } {
415
+ async function fetchUsage(signal?: AbortSignal): Promise<SubscriptionUsageSnapshot> {
416
+ try {
417
+ const { key: apiKey, account: authAccount } = await readZaiAuth(providerId, displayName);
418
+ const timeoutSignal = AbortSignal.timeout(7_000);
419
+ const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
420
+
421
+ const response = await fetch(usageUrl, {
422
+ headers: {
423
+ Accept: "application/json",
424
+ Authorization: `Bearer ${apiKey}`,
425
+ "User-Agent": "pi-sub/0.1.0",
426
+ },
427
+ signal: combinedSignal,
428
+ });
429
+
430
+ const body = await response.json();
431
+
432
+ // Z.ai / BigModel return HTTP 200 even on auth errors: {"code":401,"msg":"...","success":false}
433
+ // Also handle missing success field, empty msg, or presence of code.
434
+ const apiError = body as ZaiUsageApiError;
435
+ if (apiError.code >= 400 || (typeof apiError.success === "boolean" && !apiError.success) || (apiError.msg && apiError.msg.length > 0 && apiError.success === undefined)) {
436
+ const message = apiError.msg || `HTTP status ${apiError.code}`;
437
+ throw new Error(`${displayName} API error: ${message}`);
438
+ }
414
439
 
415
- async function fetchZaiUsage(signal?: AbortSignal): Promise<SubscriptionUsageSnapshot> {
416
- try {
417
- const { key: apiKey, account: authAccount } = await readZaiAuth();
418
- const timeoutSignal = AbortSignal.timeout(7_000);
419
- const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
420
-
421
- const response = await fetch(ZAI_USAGE_URL, {
422
- headers: {
423
- Accept: "application/json",
424
- Authorization: `Bearer ${apiKey}`,
425
- "User-Agent": "pi-sub/0.1.0",
426
- },
427
- signal: combinedSignal,
428
- });
429
-
430
- const body = await response.json();
431
-
432
- // Z.ai returns HTTP 200 even on auth errors: {"code":401,"msg":"...","success":false}
433
- // Also handle missing success field, empty msg, or presence of code.
434
- const apiError = body as ZaiUsageApiError;
435
- if (apiError.code >= 400 || (typeof apiError.success === "boolean" && !apiError.success) || (apiError.msg && apiError.msg.length > 0 && apiError.success === undefined)) {
436
- const message = apiError.msg || `HTTP status ${apiError.code}`;
437
- throw new Error(`Z.ai API error: ${message}`);
438
- }
440
+ const parsed = body as ZaiUsageApiResponse;
441
+ const tokenLimits = (parsed.data?.limits ?? [])
442
+ .filter((l) => l.type === "TOKENS_LIMIT")
443
+ .sort((a, b) => (a.nextResetTime ?? 0) - (b.nextResetTime ?? 0));
439
444
 
440
- const parsed = body as ZaiUsageApiResponse;
441
- const tokenLimits = (parsed.data?.limits ?? [])
442
- .filter((l) => l.type === "TOKENS_LIMIT")
443
- .sort((a, b) => (a.nextResetTime ?? 0) - (b.nextResetTime ?? 0));
445
+ if (tokenLimits.length === 0) {
446
+ throw new Error(`No TOKENS_LIMIT entries in ${displayName} usage response`);
447
+ }
444
448
 
445
- if (tokenLimits.length === 0) {
446
- throw new Error("No TOKENS_LIMIT entries in Z.ai usage response");
449
+ // The limit with the nearest reset is the 5-hour rolling window;
450
+ // the next one (if present) is the weekly window.
451
+ const fiveHour = zaiLimitToUsageWindow(tokenLimits[0]);
452
+ const weekly = tokenLimits.length >= 2 ? zaiLimitToUsageWindow(tokenLimits[1]) : undefined;
453
+
454
+ const account: SubscriptionAccountSnapshot = {
455
+ ...authAccount,
456
+ plan: zaiPlanLabel(parsed) ?? authAccount.plan,
457
+ fiveHour,
458
+ weekly,
459
+ };
460
+
461
+ return {
462
+ providerDisplayName: displayName,
463
+ accounts: [account],
464
+ activeAccount: account,
465
+ fetchedAt: Date.now(),
466
+ };
467
+ } catch (error) {
468
+ return {
469
+ providerDisplayName: displayName,
470
+ accounts: [],
471
+ fetchedAt: Date.now(),
472
+ error: redactedError(error, displayName),
473
+ };
447
474
  }
448
-
449
- // The limit with the nearest reset is the 5-hour rolling window;
450
- // the next one (if present) is the weekly window.
451
- const fiveHour = zaiLimitToUsageWindow(tokenLimits[0]);
452
- const weekly = tokenLimits.length >= 2 ? zaiLimitToUsageWindow(tokenLimits[1]) : undefined;
453
-
454
- const account: SubscriptionAccountSnapshot = {
455
- ...authAccount,
456
- plan: zaiPlanLabel(parsed) ?? authAccount.plan,
457
- fiveHour,
458
- weekly,
459
- };
460
-
461
- return {
462
- providerDisplayName: "Z.ai",
463
- accounts: [account],
464
- activeAccount: account,
465
- fetchedAt: Date.now(),
466
- };
467
- } catch (error) {
468
- return {
469
- providerDisplayName: "Z.ai",
470
- accounts: [],
471
- fetchedAt: Date.now(),
472
- error: redactedError(error, "Z.ai"),
473
- };
474
475
  }
476
+ return { fetchUsage };
475
477
  }
476
478
 
477
479
  function supportedAdapter(model: ModelLike): SubscriptionProviderAdapter | undefined {
478
480
  if (isCodexModel(model)) return { id: CODEX_PROVIDER, displayName: "Codex", fetchUsage: fetchCodexUsage };
479
481
  if (isOpenCodeGoModel(model)) return { id: OPC_PROVIDER, displayName: "OpenCode Go", fetchUsage: fetchOpenCodeGoUsage };
480
- if (isZaiModel(model)) return { id: ZAI_PROVIDER, displayName: "Z.ai", fetchUsage: fetchZaiUsage };
482
+ if (isZaiModel(model)) return { id: ZAI_PROVIDER, displayName: "Z.ai", ...zaiUsageAdapter(ZAI_PROVIDER, ZAI_USAGE_URL, "Z.ai") };
483
+ if (isZaiCodingCnModel(model)) return { id: ZAI_CODING_CN_PROVIDER, displayName: "Z.ai (CN)", ...zaiUsageAdapter(ZAI_CODING_CN_PROVIDER, ZAI_CODING_CN_USAGE_URL, "Z.ai (CN)") };
481
484
  return undefined;
482
485
  }
483
486
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-sub",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "Pi extension showing subscription usage for supported model providers.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -38,6 +38,6 @@
38
38
  "node": ">=20.3.0"
39
39
  },
40
40
  "peerDependencies": {
41
- "@earendil-works/pi-coding-agent": "*"
41
+ "@earendil-works/pi-coding-agent": ">=0.80.8 <0.81.0"
42
42
  }
43
43
  }