@bacnh85/pi-sub 0.1.14 → 0.1.16

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).
@@ -13,6 +13,8 @@ const CODEX_PROVIDER = "openai-codex";
13
13
  const OPC_PROVIDER = "opencode-go";
14
14
  const ZAI_PROVIDER = "zai";
15
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";
16
18
 
17
19
  type ModelLike = { provider?: string; id?: string } | undefined;
18
20
 
@@ -101,6 +103,10 @@ function isZaiModel(model: ModelLike): boolean {
101
103
  return (model?.provider?.toLowerCase() ?? "") === ZAI_PROVIDER;
102
104
  }
103
105
 
106
+ function isZaiCodingCnModel(model: ModelLike): boolean {
107
+ return (model?.provider?.toLowerCase() ?? "") === ZAI_CODING_CN_PROVIDER;
108
+ }
109
+
104
110
  function piAuthPath(): string {
105
111
  const configDir = process.env.PI_CODING_AGENT_DIR?.trim() || path.join(os.homedir(), ".pi", "agent");
106
112
  return path.join(configDir, "auth.json");
@@ -279,10 +285,10 @@ async function readOpenCodeGoAuth(): Promise<SubscriptionAccountSnapshot> {
279
285
  return authAccountSnapshot("OpenCode Go", entry, { plan: "Go" });
280
286
  }
281
287
 
282
- async function readZaiAuth(): Promise<{ key: string; account: SubscriptionAccountSnapshot }> {
283
- const entry = readStoredCredential(ZAI_PROVIDER, piAuthPath()) as PiAuthEntry | undefined;
284
- if (!entry?.key) throw new Error("Missing zai API key in Pi auth");
285
- 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) };
286
292
  }
287
293
 
288
294
  async function fetchUsageFromPiAuth(entry: PiAuthEntry, signal?: AbortSignal): Promise<UsageApiSnapshot | undefined> {
@@ -372,6 +378,7 @@ interface ZaiUsageApiResponse {
372
378
  plan?: string;
373
379
  plan_type?: string;
374
380
  packageName?: string;
381
+ level?: string;
375
382
  };
376
383
  }
377
384
 
@@ -399,75 +406,81 @@ function zaiLimitToUsageWindow(limit: ZaiLimitEntry): UsageWindow | undefined {
399
406
 
400
407
  function zaiPlanLabel(response: ZaiUsageApiResponse): string | undefined {
401
408
  const data = response.data;
402
- return planLabel(firstString(data?.planName, data?.plan, data?.plan_type, data?.packageName));
403
- }
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
+ }
404
439
 
405
- async function fetchZaiUsage(signal?: AbortSignal): Promise<SubscriptionUsageSnapshot> {
406
- try {
407
- const { key: apiKey, account: authAccount } = await readZaiAuth();
408
- const timeoutSignal = AbortSignal.timeout(7_000);
409
- const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
410
-
411
- const response = await fetch(ZAI_USAGE_URL, {
412
- headers: {
413
- Accept: "application/json",
414
- Authorization: `Bearer ${apiKey}`,
415
- "User-Agent": "pi-sub/0.1.0",
416
- },
417
- signal: combinedSignal,
418
- });
419
-
420
- const body = await response.json();
421
-
422
- // Z.ai returns HTTP 200 even on auth errors: {"code":401,"msg":"...","success":false}
423
- // Also handle missing success field, empty msg, or presence of code.
424
- const apiError = body as ZaiUsageApiError;
425
- if (apiError.code >= 400 || (typeof apiError.success === "boolean" && !apiError.success) || (apiError.msg && apiError.msg.length > 0 && apiError.success === undefined)) {
426
- const message = apiError.msg || `HTTP status ${apiError.code}`;
427
- throw new Error(`Z.ai API error: ${message}`);
428
- }
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));
429
444
 
430
- const parsed = body as ZaiUsageApiResponse;
431
- const tokenLimits = (parsed.data?.limits ?? [])
432
- .filter((l) => l.type === "TOKENS_LIMIT")
433
- .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
+ }
434
448
 
435
- if (tokenLimits.length === 0) {
436
- 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
+ };
437
474
  }
438
-
439
- // The limit with the nearest reset is the 5-hour rolling window;
440
- // the next one (if present) is the weekly window.
441
- const fiveHour = zaiLimitToUsageWindow(tokenLimits[0]);
442
- const weekly = tokenLimits.length >= 2 ? zaiLimitToUsageWindow(tokenLimits[1]) : undefined;
443
-
444
- const account: SubscriptionAccountSnapshot = {
445
- ...authAccount,
446
- plan: zaiPlanLabel(parsed) ?? authAccount.plan,
447
- fiveHour,
448
- weekly,
449
- };
450
-
451
- return {
452
- providerDisplayName: "Z.ai",
453
- accounts: [account],
454
- activeAccount: account,
455
- fetchedAt: Date.now(),
456
- };
457
- } catch (error) {
458
- return {
459
- providerDisplayName: "Z.ai",
460
- accounts: [],
461
- fetchedAt: Date.now(),
462
- error: redactedError(error, "Z.ai"),
463
- };
464
475
  }
476
+ return { fetchUsage };
465
477
  }
466
478
 
467
479
  function supportedAdapter(model: ModelLike): SubscriptionProviderAdapter | undefined {
468
480
  if (isCodexModel(model)) return { id: CODEX_PROVIDER, displayName: "Codex", fetchUsage: fetchCodexUsage };
469
481
  if (isOpenCodeGoModel(model)) return { id: OPC_PROVIDER, displayName: "OpenCode Go", fetchUsage: fetchOpenCodeGoUsage };
470
- 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)") };
471
484
  return undefined;
472
485
  }
473
486
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-sub",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
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": ">=0.80.8 <0.81.0"
41
+ "@earendil-works/pi-coding-agent": ">=0.80.8 <0.82.0"
42
42
  }
43
43
  }