@narumitw/pi-usage 0.52.3 → 0.53.0

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@narumitw/pi-usage",
3
- "version": "0.52.3",
4
- "description": "Pi extension that shows current-account usage for Codex, GitHub Copilot, OpenRouter, and OpenCode Zen.",
3
+ "version": "0.53.0",
4
+ "description": "Pi extension that shows current-account usage for Codex, GitHub Copilot, OpenRouter, OpenCode Zen, and Z.AI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "private": false,
@@ -15,7 +15,9 @@
15
15
  "copilot",
16
16
  "openrouter",
17
17
  "opencode",
18
- "zen"
18
+ "zen",
19
+ "zai",
20
+ "glm"
19
21
  ],
20
22
  "files": [
21
23
  "src",
package/src/format.ts CHANGED
@@ -19,7 +19,9 @@ export function formatUsageReport(report: UsageReport, displayState: UsageDispla
19
19
  else if (report.providerId === "github-copilot") formatGitHubCopilotReport(lines, report);
20
20
  else if (report.providerId === "openrouter") formatOpenRouterReport(lines, report);
21
21
  else if (report.providerId === "opencode-go") formatOpenCodeZenReport(lines, report);
22
- else formatGenericReport(lines, report);
22
+ else if (report.providerId === "zai" || report.providerId === "zai-coding-cn") {
23
+ formatZaiReport(lines, report);
24
+ } else formatGenericReport(lines, report);
23
25
 
24
26
  if (report.notes) {
25
27
  for (const note of report.notes) lines.push(note);
@@ -160,6 +162,30 @@ function formatOpenCodeZenStatusline(report: UsageReport): string | undefined {
160
162
  return parts.length > 1 ? parts.join(" ") : undefined;
161
163
  }
162
164
 
165
+ function formatZaiReport(lines: string[], report: UsageReport): void {
166
+ for (const bucket of report.buckets) {
167
+ const reset = bucket.resetsAt ? ` (resets ${formatReset(bucket.resetsAt)})` : "";
168
+ let value = "unavailable";
169
+ if (bucket.unit === "percent" && bucket.used !== undefined) {
170
+ value = `${bucket.used}% used`;
171
+ if (bucket.remaining !== undefined) value += ` · ${bucket.remaining}% left`;
172
+ } else if (bucket.used !== undefined && bucket.limit !== undefined) {
173
+ value = `${bucket.used} of ${bucket.limit} used`;
174
+ if (bucket.remaining !== undefined) value += ` · ${bucket.remaining} left`;
175
+ } else if (bucket.used !== undefined) {
176
+ value = `${bucket.used} used`;
177
+ } else if (bucket.remaining !== undefined) {
178
+ value = `${bucket.remaining} left`;
179
+ }
180
+ lines.push(`${`${bucket.label}:`.padEnd(VALUE_COLUMN)}${value}${reset}`);
181
+ }
182
+ for (const metric of report.metrics) {
183
+ lines.push(
184
+ `${`${metric.label}:`.padEnd(VALUE_COLUMN)}${formatMetricValue(metric.value, metric.unit)}`,
185
+ );
186
+ }
187
+ }
188
+
163
189
  function formatGenericReport(lines: string[], report: UsageReport): void {
164
190
  for (const bucket of report.buckets) {
165
191
  lines.push(
package/src/index.ts CHANGED
@@ -36,6 +36,7 @@ export { normalizeCodexBackendPayload } from "./providers/codex.js";
36
36
  export { normalizeGitHubCopilotUsagePayload } from "./providers/github-copilot.js";
37
37
  export { normalizeOpenCodeZenPayload } from "./providers/opencode-zen.js";
38
38
  export { normalizeOpenRouterKeyPayload } from "./providers/openrouter.js";
39
+ export { normalizeZaiQuotaPayload } from "./providers/zai.js";
39
40
  export {
40
41
  adapterForProvider,
41
42
  isStaleExtensionContextError,
@@ -0,0 +1,150 @@
1
+ import { sanitizeDisplayText } from "../core.js";
2
+ import type { UsageBucket, UsageMetric, UsageReport, ZaiQuotaPayload } from "../types.js";
3
+
4
+ const FIVE_HOUR_WINDOW_MINUTES = 300;
5
+ const WEEKLY_WINDOW_MINUTES = 10_080;
6
+
7
+ export function normalizeZaiQuotaPayload(
8
+ providerId: string,
9
+ providerName: string,
10
+ payload: ZaiQuotaPayload,
11
+ capturedAt: number,
12
+ ): UsageReport {
13
+ const data = asObject(payload.data);
14
+ if (!data) throw new Error("Z.AI quota response data was not an object.");
15
+ const limits = Array.isArray(data.limits) ? (data.limits as unknown[]) : [];
16
+
17
+ const buckets: UsageBucket[] = [];
18
+ const metrics: UsageMetric[] = [];
19
+ for (const raw of limits) {
20
+ const limit = asObject(raw);
21
+ if (!limit) continue;
22
+ const type = asString(limit.type);
23
+ const unit = asNonnegativeNumber(limit.unit);
24
+ const isPlanUsage = type === "TOKENS_LIMIT" || type === "CREDIT_LIMIT";
25
+ if (type === "TIME_LIMIT") {
26
+ addCountBucket(buckets, limit, "mcp-monthly", "MCP monthly allowance");
27
+ addUsageDetailMetrics(metrics, limit.usageDetails);
28
+ } else if (isPlanUsage && unit === 3) {
29
+ addPercentBucket(buckets, limit, "five-hour", "5h window", FIVE_HOUR_WINDOW_MINUTES);
30
+ } else if (isPlanUsage && unit === 6) {
31
+ const used = asNonnegativeNumber(limit.currentValue);
32
+ const quota = asNonnegativeNumber(limit.usage);
33
+ if (used !== undefined && quota !== undefined) {
34
+ addCountBucket(buckets, limit, "weekly", "Weekly window", WEEKLY_WINDOW_MINUTES);
35
+ } else {
36
+ addPercentBucket(buckets, limit, "weekly", "Weekly window", WEEKLY_WINDOW_MINUTES);
37
+ }
38
+ }
39
+ }
40
+ if (buckets.length === 0) {
41
+ throw new Error("Z.AI quota endpoint returned no displayable usage data.");
42
+ }
43
+
44
+ const notes: string[] = [];
45
+ const level = asString(data.level);
46
+ if (level) notes.push(`Plan: ${level}`);
47
+
48
+ return {
49
+ providerId,
50
+ providerName,
51
+ capturedAt,
52
+ source: "zai-quota",
53
+ semantics: { kind: "consumer-subscription", label: "GLM Coding Plan usage" },
54
+ buckets,
55
+ metrics,
56
+ ...(notes.length > 0 ? { notes } : {}),
57
+ };
58
+ }
59
+
60
+ function addPercentBucket(
61
+ buckets: UsageBucket[],
62
+ limit: Record<string, unknown>,
63
+ id: string,
64
+ label: string,
65
+ windowMinutes: number,
66
+ ): void {
67
+ const used = asNonnegativeNumber(limit.percentage);
68
+ if (used === undefined) return;
69
+ const percent = clampPercent(used);
70
+ const resetsAt = asEpochSeconds(limit.nextResetTime);
71
+ buckets.push({
72
+ id,
73
+ label,
74
+ used: percent,
75
+ remaining: 100 - percent,
76
+ limit: 100,
77
+ unit: "percent",
78
+ windowMinutes,
79
+ ...(resetsAt !== undefined ? { resetsAt } : {}),
80
+ });
81
+ }
82
+
83
+ function addCountBucket(
84
+ buckets: UsageBucket[],
85
+ limit: Record<string, unknown>,
86
+ id: string,
87
+ label: string,
88
+ windowMinutes?: number,
89
+ ): void {
90
+ const used = asNonnegativeNumber(limit.currentValue);
91
+ const quota = asNonnegativeNumber(limit.usage);
92
+ if (used === undefined || quota === undefined) return;
93
+ const resetsAt = asEpochSeconds(limit.nextResetTime);
94
+ buckets.push({
95
+ id,
96
+ label,
97
+ used,
98
+ remaining: Math.max(0, quota - used),
99
+ limit: quota,
100
+ unit: "count",
101
+ ...(windowMinutes !== undefined ? { windowMinutes } : {}),
102
+ ...(resetsAt !== undefined ? { resetsAt } : {}),
103
+ });
104
+ }
105
+
106
+ function addUsageDetailMetrics(metrics: UsageMetric[], value: unknown): void {
107
+ if (!Array.isArray(value)) return;
108
+ for (const raw of value) {
109
+ const detail = asObject(raw);
110
+ if (!detail) continue;
111
+ const label = asString(detail.modelCode);
112
+ const usage = asNonnegativeNumber(detail.usage);
113
+ if (!label || usage === undefined) continue;
114
+ metrics.push({ id: `mcp-${kebabCase(label)}`, label, value: usage, unit: "count" });
115
+ }
116
+ }
117
+
118
+ function asObject(value: unknown): Record<string, unknown> | undefined {
119
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
120
+ return value as Record<string, unknown>;
121
+ }
122
+
123
+ function asString(value: unknown): string | undefined {
124
+ if (typeof value !== "string") return undefined;
125
+ return sanitizeDisplayText(value, 80) || undefined;
126
+ }
127
+
128
+ function asNonnegativeNumber(value: unknown): number | undefined {
129
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return undefined;
130
+ return value;
131
+ }
132
+
133
+ function asEpochSeconds(value: unknown): number | undefined {
134
+ const millis = asNonnegativeNumber(value);
135
+ if (millis === undefined) return undefined;
136
+ return Math.floor(millis / 1000);
137
+ }
138
+
139
+ function kebabCase(label: string): string {
140
+ return (
141
+ label
142
+ .toLowerCase()
143
+ .replace(/[^a-z0-9]+/gu, "-")
144
+ .replace(/^-+|-+$/gu, "") || "tool"
145
+ );
146
+ }
147
+
148
+ function clampPercent(value: number): number {
149
+ return Math.min(100, Math.max(0, value));
150
+ }
package/src/query.ts CHANGED
@@ -9,6 +9,7 @@ import { normalizeCodexBackendPayload } from "./providers/codex.js";
9
9
  import { normalizeGitHubCopilotUsagePayload } from "./providers/github-copilot.js";
10
10
  import { normalizeOpenCodeZenPayload } from "./providers/opencode-zen.js";
11
11
  import { normalizeOpenRouterKeyPayload } from "./providers/openrouter.js";
12
+ import { normalizeZaiQuotaPayload } from "./providers/zai.js";
12
13
  import type {
13
14
  CodexBackendPayload,
14
15
  GitHubCopilotUsagePayload,
@@ -18,6 +19,7 @@ import type {
18
19
  ResolvedUsageAuth,
19
20
  UsageProviderAdapter,
20
21
  UsageReport,
22
+ ZaiQuotaPayload,
21
23
  } from "./types.js";
22
24
 
23
25
  const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
@@ -96,6 +98,43 @@ export const SUPPORTED_ADAPTERS: readonly UsageProviderAdapter[] = [
96
98
  return normalizeOpenCodeZenPayload(payload as OpenCodeZenPayload, Date.now());
97
99
  },
98
100
  },
101
+ {
102
+ id: "zai",
103
+ displayName: "Z.AI",
104
+ semantics: { kind: "consumer-subscription", label: "GLM Coding Plan usage" },
105
+ publishesStatusline: false,
106
+ async query(auth, signal, timeoutMs) {
107
+ const payload = await fetchProviderJson(
108
+ zaiMonitorUrl(auth.model.baseUrl),
109
+ zaiMonitorAuth(auth),
110
+ signal,
111
+ timeoutMs,
112
+ "Z.AI quota endpoint",
113
+ );
114
+ return normalizeZaiQuotaPayload("zai", "Z.AI", payload as ZaiQuotaPayload, Date.now());
115
+ },
116
+ },
117
+ {
118
+ id: "zai-coding-cn",
119
+ displayName: "Z.AI Coding CN",
120
+ semantics: { kind: "consumer-subscription", label: "GLM Coding Plan usage" },
121
+ publishesStatusline: false,
122
+ async query(auth, signal, timeoutMs) {
123
+ const payload = await fetchProviderJson(
124
+ zaiMonitorUrl(auth.model.baseUrl),
125
+ zaiMonitorAuth(auth),
126
+ signal,
127
+ timeoutMs,
128
+ "Z.AI Coding CN quota endpoint",
129
+ );
130
+ return normalizeZaiQuotaPayload(
131
+ "zai-coding-cn",
132
+ "Z.AI Coding CN",
133
+ payload as ZaiQuotaPayload,
134
+ Date.now(),
135
+ );
136
+ },
137
+ },
99
138
  ];
100
139
 
101
140
  export function adapterForProvider(
@@ -470,6 +509,8 @@ function hasOfficialUrlOrigin(value: string, providerId: string): boolean {
470
509
  if (providerId === "openai-codex") return url.origin === "https://chatgpt.com";
471
510
  if (providerId === "openrouter") return url.origin === "https://openrouter.ai";
472
511
  if (providerId === "opencode-go") return url.origin === "https://opencode.ai";
512
+ if (providerId === "zai") return url.origin === "https://api.z.ai";
513
+ if (providerId === "zai-coding-cn") return url.origin === "https://open.bigmodel.cn";
473
514
  if (providerId === "github-copilot") {
474
515
  return (
475
516
  url.protocol === "https:" && /^api\.[a-z0-9-]+\.githubcopilot\.com$/u.test(url.hostname)
@@ -495,6 +536,20 @@ function hasHeader(headers: Record<string, string>, name: string): boolean {
495
536
  return Object.keys(headers).some((key) => key.toLowerCase() === name.toLowerCase());
496
537
  }
497
538
 
539
+ function zaiMonitorUrl(baseUrl: string | undefined): string {
540
+ const base = baseUrl?.trim();
541
+ if (!base) throw new Error("Z.AI model base URL is unavailable.");
542
+ return `${new URL(base).origin}/api/monitor/usage/quota/limit`;
543
+ }
544
+
545
+ function zaiMonitorAuth(auth: ResolvedUsageAuth): ResolvedUsageAuth {
546
+ const authorization = headerValue(auth.headers, "Authorization");
547
+ const token =
548
+ authorization === undefined ? undefined : (bearerToken(authorization) ?? authorization);
549
+ if (token === undefined || token === authorization) return auth;
550
+ return { ...auth, headers: { ...auth.headers, Authorization: token } };
551
+ }
552
+
498
553
  function isAbortError(error: unknown): boolean {
499
554
  return error instanceof Error && error.name === "AbortError";
500
555
  }
package/src/types.ts CHANGED
@@ -58,6 +58,7 @@ export interface UsageProviderAdapter {
58
58
  id: string;
59
59
  displayName: string;
60
60
  semantics: UsageSemantics;
61
+ publishesStatusline?: boolean;
61
62
  query(auth: ResolvedUsageAuth, signal: AbortSignal, timeoutMs: number): Promise<UsageReport>;
62
63
  }
63
64
 
@@ -97,6 +98,10 @@ export type OpenCodeZenPayload = {
97
98
  usage?: unknown;
98
99
  };
99
100
 
101
+ export type ZaiQuotaPayload = {
102
+ data?: unknown;
103
+ };
104
+
100
105
  export type CodexBackendPayload = {
101
106
  plan_type?: unknown;
102
107
  rate_limit?: unknown;
package/src/usage.ts CHANGED
@@ -136,6 +136,11 @@ export default function usageExtension(
136
136
  model: PiModel,
137
137
  shouldSchedule: boolean,
138
138
  ) => {
139
+ if (adapterForProvider(model.provider)?.publishesStatusline === false) {
140
+ clearStatusTimer();
141
+ safeSetStatus(ctx, undefined);
142
+ return;
143
+ }
139
144
  if (outcome.state.status === "unsupported") {
140
145
  clearStatusTimer();
141
146
  safeSetStatus(ctx, undefined);
@@ -345,6 +350,10 @@ export default function usageExtension(
345
350
  clearStatus(ctx);
346
351
  return;
347
352
  }
353
+ if (adapter.publishesStatusline === false) {
354
+ clearStatus(ctx);
355
+ return;
356
+ }
348
357
  statusGeneration += 1;
349
358
  const generation = statusGeneration;
350
359
  statusController?.abort();