@narumitw/pi-usage 0.59.0 → 0.60.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,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-usage",
3
- "version": "0.59.0",
3
+ "version": "0.60.0",
4
4
  "description": "Pi extension that shows current-account usage and DeepSeek API balance for supported providers.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/format.ts CHANGED
@@ -269,6 +269,10 @@ function formatOpenRouterReport(lines: string[], report: UsageReport): void {
269
269
 
270
270
  function formatOpenCodeZenReport(lines: string[], report: UsageReport): void {
271
271
  for (const bucket of report.buckets) {
272
+ if (bucket.unit === "percent" && bucket.used !== undefined) {
273
+ lines.push(`${`${bucket.label}:`.padEnd(VALUE_COLUMN)}${formatPercentBucket(bucket)}`);
274
+ continue;
275
+ }
272
276
  const reset = bucket.resetsAt ? ` (resets ${formatReset(bucket.resetsAt)})` : "";
273
277
  const used = bucket.used ?? "unavailable";
274
278
  lines.push(`${`${bucket.label}:`.padEnd(VALUE_COLUMN)}${used}% used${reset}`);
@@ -469,8 +473,7 @@ function formatXaiReport(lines: string[], report: UsageReport): void {
469
473
  if (included) {
470
474
  let value = "unavailable";
471
475
  if (included.unit === "percent" && included.used !== undefined) {
472
- value = `${included.used}% used`;
473
- if (included.remaining !== undefined) value += ` · ${included.remaining}% left`;
476
+ value = formatPercentBar(included);
474
477
  } else if (included.used !== undefined) {
475
478
  value = `${formatUsd(included.used)} used`;
476
479
  if (included.limit !== undefined) value += ` of ${formatUsd(included.limit)}`;
@@ -497,12 +500,13 @@ function formatXaiReport(lines: string[], report: UsageReport): void {
497
500
 
498
501
  function formatZaiReport(lines: string[], report: UsageReport): void {
499
502
  for (const bucket of report.buckets) {
503
+ if (bucket.unit === "percent" && bucket.used !== undefined) {
504
+ lines.push(`${`${bucket.label}:`.padEnd(VALUE_COLUMN)}${formatPercentBucket(bucket)}`);
505
+ continue;
506
+ }
500
507
  const reset = bucket.resetsAt ? ` (resets ${formatReset(bucket.resetsAt)})` : "";
501
508
  let value = "unavailable";
502
- if (bucket.unit === "percent" && bucket.used !== undefined) {
503
- value = `${bucket.used}% used`;
504
- if (bucket.remaining !== undefined) value += ` · ${bucket.remaining}% left`;
505
- } else if (bucket.used !== undefined && bucket.limit !== undefined) {
509
+ if (bucket.used !== undefined && bucket.limit !== undefined) {
506
510
  value = `${bucket.used} of ${bucket.limit} used`;
507
511
  if (bucket.remaining !== undefined) value += ` · ${bucket.remaining} left`;
508
512
  } else if (bucket.used !== undefined) {
@@ -637,10 +641,13 @@ function compactLimitLabel(label: string): string {
637
641
  }
638
642
 
639
643
  function formatPercentBucket(bucket: UsageBucket): string {
644
+ return `${formatPercentBar(bucket)}${bucket.resetsAt ? ` (resets ${formatReset(bucket.resetsAt)})` : ""}`;
645
+ }
646
+
647
+ function formatPercentBar(bucket: UsageBucket): string {
640
648
  const remaining = clampPercent(bucket.remaining ?? 0);
641
649
  const filled = Math.round((remaining / 100) * BAR_SEGMENTS);
642
- const reset = bucket.resetsAt ? ` (resets ${formatReset(bucket.resetsAt)})` : "";
643
- return `[${"█".repeat(filled)}${"░".repeat(BAR_SEGMENTS - filled)}] ${remaining.toFixed(0)}% left${reset}`;
650
+ return `[${"█".repeat(filled)}${"░".repeat(BAR_SEGMENTS - filled)}] ${remaining.toFixed(0)}% left`;
644
651
  }
645
652
 
646
653
  function formatWindowLabel(
package/src/index.ts CHANGED
@@ -52,7 +52,7 @@ export { normalizeOpenCodeZenPayload } from "./providers/opencode-zen.js";
52
52
  export { normalizeOpenRouterKeyPayload } from "./providers/openrouter.js";
53
53
  export { normalizeVercelAIGatewayCreditsPayload } from "./providers/vercel-ai-gateway.js";
54
54
  export { normalizeXaiBillingPayload } from "./providers/xai.js";
55
- export { normalizeZaiQuotaPayload } from "./providers/zai.js";
55
+ export { normalizeZaiQuotaPayload, normalizeZaiSubscriptionPayload } from "./providers/zai.js";
56
56
  export {
57
57
  adapterForProvider,
58
58
  isStaleExtensionContextError,
@@ -1,5 +1,12 @@
1
1
  import { sanitizeDisplayText } from "../core.js";
2
- import type { UsageBucket, UsageMetric, UsageReport, ZaiQuotaPayload } from "../types.js";
2
+ import type {
3
+ UsageBucket,
4
+ UsageMetric,
5
+ UsageReport,
6
+ ZaiPlanInfo,
7
+ ZaiQuotaPayload,
8
+ ZaiSubscriptionPayload,
9
+ } from "../types.js";
3
10
 
4
11
  const FIVE_HOUR_WINDOW_MINUTES = 300;
5
12
  const WEEKLY_WINDOW_MINUTES = 10_080;
@@ -9,6 +16,7 @@ export function normalizeZaiQuotaPayload(
9
16
  providerName: string,
10
17
  payload: ZaiQuotaPayload,
11
18
  capturedAt: number,
19
+ plan?: ZaiPlanInfo,
12
20
  ): UsageReport {
13
21
  const data = asObject(payload.data);
14
22
  if (!data) throw new Error("Z.AI quota response data was not an object.");
@@ -26,14 +34,20 @@ export function normalizeZaiQuotaPayload(
26
34
  addCountBucket(buckets, limit, "mcp-monthly", "MCP monthly allowance");
27
35
  addUsageDetailMetrics(metrics, limit.usageDetails);
28
36
  } else if (isPlanUsage && unit === 3) {
29
- addPercentBucket(buckets, limit, "five-hour", "5h window", FIVE_HOUR_WINDOW_MINUTES);
37
+ addPercentBucket(
38
+ buckets,
39
+ limit,
40
+ "five-hour",
41
+ sessionWindowLabel(limit),
42
+ sessionWindowMinutes(limit),
43
+ );
30
44
  } else if (isPlanUsage && unit === 6) {
31
45
  const used = asNonnegativeNumber(limit.currentValue);
32
46
  const quota = asNonnegativeNumber(limit.usage);
33
47
  if (used !== undefined && quota !== undefined) {
34
- addCountBucket(buckets, limit, "weekly", "Weekly window", WEEKLY_WINDOW_MINUTES);
48
+ addCountBucket(buckets, limit, "weekly", "Weekly window", weeklyWindowMinutes(limit));
35
49
  } else {
36
- addPercentBucket(buckets, limit, "weekly", "Weekly window", WEEKLY_WINDOW_MINUTES);
50
+ addPercentBucket(buckets, limit, "weekly", "Weekly window", weeklyWindowMinutes(limit));
37
51
  }
38
52
  }
39
53
  }
@@ -43,7 +57,12 @@ export function normalizeZaiQuotaPayload(
43
57
 
44
58
  const notes: string[] = [];
45
59
  const level = asString(data.level);
46
- if (level) notes.push(`Plan: ${level}`);
60
+ const planLabel = plan?.name ?? level;
61
+ if (planLabel) {
62
+ notes.push(
63
+ plan?.renewsAt ? `Plan: ${planLabel} · renews ${plan.renewsAt}` : `Plan: ${planLabel}`,
64
+ );
65
+ }
47
66
 
48
67
  return {
49
68
  providerId,
@@ -57,6 +76,79 @@ export function normalizeZaiQuotaPayload(
57
76
  };
58
77
  }
59
78
 
79
+ // The undocumented subscription endpoint can include historical products. Prefer the current,
80
+ // valid product and fail closed to the quota plan level when only explicitly inactive products exist.
81
+ export function normalizeZaiSubscriptionPayload(
82
+ payload: ZaiSubscriptionPayload,
83
+ ): ZaiPlanInfo | undefined {
84
+ if (payload.success === false) return undefined;
85
+ if (typeof payload.code === "number" && payload.code !== 0 && payload.code !== 200) {
86
+ return undefined;
87
+ }
88
+ if (!Array.isArray(payload.data)) return undefined;
89
+
90
+ const candidates: Array<{
91
+ plan: ZaiPlanInfo;
92
+ status?: string;
93
+ inCurrentPeriod?: boolean;
94
+ }> = [];
95
+ for (const raw of payload.data) {
96
+ const entry = asObject(raw);
97
+ if (!entry) continue;
98
+ const name = asString(entry.productName);
99
+ if (!name) continue;
100
+ const renewsAt = planRenewalDate(entry.nextRenewTime);
101
+ const status = asString(entry.status)?.toUpperCase();
102
+ const inCurrentPeriod = asBoolean(entry.inCurrentPeriod);
103
+ candidates.push({
104
+ plan: { name, ...(renewsAt !== undefined ? { renewsAt } : {}) },
105
+ ...(status !== undefined ? { status } : {}),
106
+ ...(inCurrentPeriod !== undefined ? { inCurrentPeriod } : {}),
107
+ });
108
+ }
109
+
110
+ const hasStateMetadata = candidates.some(
111
+ (candidate) => candidate.status !== undefined || candidate.inCurrentPeriod !== undefined,
112
+ );
113
+ if (!hasStateMetadata) return candidates[0]?.plan;
114
+ return (
115
+ candidates.find(
116
+ (candidate) => candidate.inCurrentPeriod === true && candidate.status === "VALID",
117
+ )?.plan ??
118
+ candidates.find(
119
+ (candidate) => candidate.inCurrentPeriod === true && candidate.status === undefined,
120
+ )?.plan ??
121
+ candidates.find(
122
+ (candidate) => candidate.status === "VALID" && candidate.inCurrentPeriod === undefined,
123
+ )?.plan
124
+ );
125
+ }
126
+
127
+ function planRenewalDate(value: unknown): string | undefined {
128
+ if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}/u.test(value)) return value.slice(0, 10);
129
+ const millis = asNonnegativeNumber(value);
130
+ if (millis === undefined || millis === 0) return undefined;
131
+ return new Date(millis).toISOString().slice(0, 10);
132
+ }
133
+
134
+ // Z.AI encodes each window as a (unit, number) pair — unit 3 counts hours and unit 6 counts
135
+ // weeks — so the payload's number drives the window length and session label; the established
136
+ // 5-hour and weekly constants remain the fallback when the payload omits it.
137
+ function sessionWindowMinutes(limit: Record<string, unknown>): number {
138
+ const hours = asPositiveNumber(limit.number);
139
+ return hours === undefined ? FIVE_HOUR_WINDOW_MINUTES : Math.round(hours * 60);
140
+ }
141
+
142
+ function sessionWindowLabel(limit: Record<string, unknown>): string {
143
+ const minutes = sessionWindowMinutes(limit);
144
+ return minutes === FIVE_HOUR_WINDOW_MINUTES ? "5h window" : `${Math.round(minutes / 60)}h window`;
145
+ }
146
+
147
+ function weeklyWindowMinutes(limit: Record<string, unknown>): number {
148
+ const weeks = asPositiveNumber(limit.number);
149
+ return weeks === undefined ? WEEKLY_WINDOW_MINUTES : Math.round(weeks * WEEKLY_WINDOW_MINUTES);
150
+ }
151
+
60
152
  function addPercentBucket(
61
153
  buckets: UsageBucket[],
62
154
  limit: Record<string, unknown>,
@@ -130,6 +222,18 @@ function asNonnegativeNumber(value: unknown): number | undefined {
130
222
  return value;
131
223
  }
132
224
 
225
+ function asPositiveNumber(value: unknown): number | undefined {
226
+ const number = asNonnegativeNumber(value);
227
+ return number !== undefined && number > 0 ? number : undefined;
228
+ }
229
+
230
+ function asBoolean(value: unknown): boolean | undefined {
231
+ if (typeof value === "boolean") return value;
232
+ if (value === 1) return true;
233
+ if (value === 0) return false;
234
+ return undefined;
235
+ }
236
+
133
237
  function asEpochSeconds(value: unknown): number | undefined {
134
238
  const millis = asNonnegativeNumber(value);
135
239
  if (millis === undefined) return undefined;
package/src/query.ts CHANGED
@@ -25,7 +25,7 @@ import { normalizeOpenCodeZenPayload } from "./providers/opencode-zen.js";
25
25
  import { normalizeOpenRouterKeyPayload } from "./providers/openrouter.js";
26
26
  import { normalizeVercelAIGatewayCreditsPayload } from "./providers/vercel-ai-gateway.js";
27
27
  import { normalizeXaiBillingPayload } from "./providers/xai.js";
28
- import { normalizeZaiQuotaPayload } from "./providers/zai.js";
28
+ import { normalizeZaiQuotaPayload, normalizeZaiSubscriptionPayload } from "./providers/zai.js";
29
29
  import type {
30
30
  BasetenBillingUsagePayload,
31
31
  CodexBackendPayload,
@@ -46,7 +46,9 @@ import type {
46
46
  VercelAIGatewayCreditsPayload,
47
47
  XaiBillingPayload,
48
48
  XaiUserPayload,
49
+ ZaiPlanInfo,
49
50
  ZaiQuotaPayload,
51
+ ZaiSubscriptionPayload,
50
52
  } from "./types.js";
51
53
 
52
54
  const BASETEN_BILLING_USAGE_URL = "https://api.baseten.co/v1/billing/usage_summary";
@@ -295,35 +297,16 @@ export const SUPPORTED_ADAPTERS: readonly UsageProviderAdapter[] = [
295
297
  id: "zai",
296
298
  displayName: "Z.AI",
297
299
  semantics: { kind: "consumer-subscription", label: "GLM Coding Plan usage" },
298
- async query(auth, signal, timeoutMs) {
299
- const payload = await fetchProviderJson(
300
- zaiMonitorUrl(auth.model.baseUrl),
301
- zaiMonitorAuth(auth),
302
- signal,
303
- timeoutMs,
304
- "Z.AI quota endpoint",
305
- );
306
- return normalizeZaiQuotaPayload("zai", "Z.AI", payload as ZaiQuotaPayload, Date.now());
300
+ async query(auth, signal, timeoutMs, guard) {
301
+ return queryZaiUsage("zai", "Z.AI", auth, signal, timeoutMs, guard);
307
302
  },
308
303
  },
309
304
  {
310
305
  id: "zai-coding-cn",
311
306
  displayName: "Z.AI Coding CN",
312
307
  semantics: { kind: "consumer-subscription", label: "GLM Coding Plan usage" },
313
- async query(auth, signal, timeoutMs) {
314
- const payload = await fetchProviderJson(
315
- zaiMonitorUrl(auth.model.baseUrl),
316
- zaiMonitorAuth(auth),
317
- signal,
318
- timeoutMs,
319
- "Z.AI Coding CN quota endpoint",
320
- );
321
- return normalizeZaiQuotaPayload(
322
- "zai-coding-cn",
323
- "Z.AI Coding CN",
324
- payload as ZaiQuotaPayload,
325
- Date.now(),
326
- );
308
+ async query(auth, signal, timeoutMs, guard) {
309
+ return queryZaiUsage("zai-coding-cn", "Z.AI Coding CN", auth, signal, timeoutMs, guard);
327
310
  },
328
311
  },
329
312
  ];
@@ -1123,10 +1106,14 @@ function fireworksBillingSummaryUrl(accountId: string, startedAt: number): strin
1123
1106
  return url.toString();
1124
1107
  }
1125
1108
 
1126
- function zaiMonitorUrl(baseUrl: string | undefined): string {
1109
+ function zaiOrigin(baseUrl: string | undefined): string {
1127
1110
  const base = baseUrl?.trim();
1128
1111
  if (!base) throw new Error("Z.AI model base URL is unavailable.");
1129
- return `${new URL(base).origin}/api/monitor/usage/quota/limit`;
1112
+ return new URL(base).origin;
1113
+ }
1114
+
1115
+ function zaiMonitorUrl(baseUrl: string | undefined): string {
1116
+ return `${zaiOrigin(baseUrl)}/api/monitor/usage/quota/limit`;
1130
1117
  }
1131
1118
 
1132
1119
  function zaiMonitorAuth(auth: ResolvedUsageAuth): ResolvedUsageAuth {
@@ -1137,6 +1124,55 @@ function zaiMonitorAuth(auth: ResolvedUsageAuth): ResolvedUsageAuth {
1137
1124
  return { ...auth, headers: { ...auth.headers, Authorization: token } };
1138
1125
  }
1139
1126
 
1127
+ async function queryZaiUsage(
1128
+ providerId: "zai" | "zai-coding-cn",
1129
+ providerName: string,
1130
+ auth: ResolvedUsageAuth,
1131
+ signal: AbortSignal,
1132
+ timeoutMs: number,
1133
+ guard: UsageRequestGuard | undefined,
1134
+ ): Promise<UsageReport> {
1135
+ if (!guard) throw new Error("Z.AI usage requires request-boundary revalidation.");
1136
+ const startedAt = Date.now();
1137
+ await guard();
1138
+ const payload = (await fetchProviderJson(
1139
+ zaiMonitorUrl(auth.model.baseUrl),
1140
+ zaiMonitorAuth(auth),
1141
+ signal,
1142
+ remainingTimeout(timeoutMs, startedAt, `fetching ${providerName} quota`),
1143
+ `${providerName} quota endpoint`,
1144
+ )) as ZaiQuotaPayload;
1145
+ await guard();
1146
+ const planTimeoutMs = timeoutMs - (Date.now() - startedAt);
1147
+ const plan = await fetchZaiPlan(providerName, auth, signal, planTimeoutMs);
1148
+ return normalizeZaiQuotaPayload(providerId, providerName, payload, Date.now(), plan);
1149
+ }
1150
+
1151
+ // The subscription endpoint is undocumented and may not exist on every official origin. It only
1152
+ // contributes the plan name and renewal date, so any non-abort failure is swallowed instead of
1153
+ // blanking the required quota report.
1154
+ async function fetchZaiPlan(
1155
+ providerName: string,
1156
+ auth: ResolvedUsageAuth,
1157
+ signal: AbortSignal,
1158
+ timeoutMs: number,
1159
+ ): Promise<ZaiPlanInfo | undefined> {
1160
+ if (timeoutMs <= 0 || signal.aborted) return undefined;
1161
+ try {
1162
+ const payload = (await fetchProviderJson(
1163
+ `${zaiOrigin(auth.model.baseUrl)}/api/biz/subscription/list`,
1164
+ zaiMonitorAuth(auth),
1165
+ signal,
1166
+ timeoutMs,
1167
+ `${providerName} plan endpoint`,
1168
+ )) as ZaiSubscriptionPayload;
1169
+ return normalizeZaiSubscriptionPayload(payload);
1170
+ } catch (error) {
1171
+ if (isAbortError(error)) throw error;
1172
+ return undefined;
1173
+ }
1174
+ }
1175
+
1140
1176
  function isAbortError(error: unknown): boolean {
1141
1177
  return error instanceof Error && error.name === "AbortError";
1142
1178
  }
package/src/types.ts CHANGED
@@ -157,6 +157,17 @@ export type ZaiQuotaPayload = {
157
157
  data?: unknown;
158
158
  };
159
159
 
160
+ export type ZaiSubscriptionPayload = {
161
+ code?: unknown;
162
+ success?: unknown;
163
+ data?: unknown;
164
+ };
165
+
166
+ export interface ZaiPlanInfo {
167
+ name: string;
168
+ renewsAt?: string;
169
+ }
170
+
160
171
  export type KimiCodingUsagePayload = {
161
172
  usage?: unknown;
162
173
  limits?: unknown;
package/src/usage.ts CHANGED
@@ -285,6 +285,8 @@ export default function usageExtension(
285
285
  "moonshotai-cn",
286
286
  "vercel-ai-gateway",
287
287
  "xai",
288
+ "zai",
289
+ "zai-coding-cn",
288
290
  ].includes(adapter.id);
289
291
  const requestContextChanged = () =>
290
292
  expectedSessionGeneration !== sessionGeneration ||