@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/README.md CHANGED
@@ -154,6 +154,20 @@ OpenRouter documents the distinction between credit and rate limits in its [API
154
154
 
155
155
  The fixed endpoint is queried only when the candidate OpenCode Go model and the resolved provider-auth base URL, when present, use the official `https://opencode.ai` origin; other origins fail before sending the credential.
156
156
 
157
+ ### Z.AI (GLM Coding Plan)
158
+
159
+ - Provider ID: `zai` and `zai-coding-cn`
160
+ - Semantics: GLM Coding Plan quota windowsβ€”the rolling 5-hour and weekly plan-usage windows plus the monthly MCP allowance
161
+ - Source: Z.AI's undocumented `GET {origin}/api/monitor/usage/quota/limit` endpoint, also used by its official coding plugin, with the origin derived from the model base URL (`https://api.z.ai` or `https://open.bigmodel.cn`)
162
+ - Displayed data: explicit used and remaining values, reset times, provider-reported per-tool MCP details, and the reported plan level. Windows that report only a percentage remain percent-based
163
+ - Statusline: not published; Z.AI is queried only through `/usage` actions
164
+
165
+ The monitor endpoint is not a published API contract and may return legacy `TOKENS_LIMIT` or newer `CREDIT_LIMIT` window names.
166
+ The extension classifies both forms by the provider's window unit and does not label provider-reported counts as tokens or calls.
167
+ The quota monitor expects the raw API key without a `Bearer` prefix, so the extension strips a `Bearer` prefix from the resolved authorization before sending it to the monitor endpoint.
168
+ Fingerprinting and redaction keep using the original resolved credential.
169
+ Only the official `api.z.ai` and `open.bigmodel.cn` origins are queried; other origins fail before sending the credential.
170
+
157
171
  ## 🧭 Current and configured accounts
158
172
 
159
173
  `Current` means the provider and credential used by Pi's selected model.
@@ -168,8 +182,8 @@ After the active runtime credential changes, the next command, turn, or schedule
168
182
 
169
183
  ## πŸ“Š Statusline behavior
170
184
 
171
- The `usage` status item is active only for the selected model provider.
172
- It refreshes every five minutes while the session remains on a supported provider and is cleared when the model changes to an unsupported provider.
185
+ The `usage` status item is active only for selected providers that publish statusline usage.
186
+ It refreshes every five minutes while the session remains on such a provider and is cleared when the model changes to an unsupported or menu-only provider.
173
187
 
174
188
  Manual another-provider and all-provider queries never publish to the statusline.
175
189
  `@narumitw/pi-statusline` supplies the default `πŸ“Š` icon; `pi-usage` publishes text-only values.
@@ -205,7 +219,7 @@ Protocol v1 interoperability is characterized for the repository's supported Pi
205
219
  ## 🚧 Limitations
206
220
 
207
221
  - Only providers with a meaningful usage source and verifiable Pi runtime auth are supported.
208
- - GitHub Copilot quota and OpenAI Codex reset redemption use undocumented provider endpoints that may change without notice.
222
+ - GitHub Copilot quota, Z.AI quota, and OpenAI Codex reset redemption use undocumented provider endpoints that may change without notice.
209
223
  - Codex reset redemption requires a current ChatGPT OAuth credential from Pi's login or a compatible credential source; Codex API keys cannot redeem earned subscription resets.
210
224
  - Credentials resolved for custom provider base URLs are never forwarded to the providers' official usage endpoints; effective auth origin validation requires Pi 0.81.0 or newer.
211
225
  - Provider reports are snapshots and may themselves be delayed by the provider.
@@ -235,7 +249,7 @@ packages/pi-usage/
235
249
  β”‚ β”œβ”€β”€ codex-resets.ts # Codex reset auth, API contracts, and normalization
236
250
  β”‚ β”œβ”€β”€ format.ts # Provider-aware notifications and statusline text
237
251
  β”‚ β”œβ”€β”€ core.ts # Cache, concurrency, fingerprint, and redaction helpers
238
- β”‚ β”œβ”€β”€ providers/ # Codex, GitHub Copilot, and OpenRouter normalization adapters
252
+ β”‚ β”œβ”€β”€ providers/ # Provider-specific usage normalization adapters
239
253
  β”‚ └── types.ts # Common presentation and adapter contracts
240
254
  β”œβ”€β”€ test/
241
255
  β”œβ”€β”€ README.md
package/dist/index.ts CHANGED
@@ -682,6 +682,120 @@ function asNonnegativeNumber3(value) {
682
682
  return value;
683
683
  }
684
684
 
685
+ // src/providers/zai.ts
686
+ var FIVE_HOUR_WINDOW_MINUTES = 300;
687
+ var WEEKLY_WINDOW_MINUTES = 10080;
688
+ function normalizeZaiQuotaPayload(providerId, providerName, payload, capturedAt) {
689
+ const data = asObject5(payload.data);
690
+ if (!data) throw new Error("Z.AI quota response data was not an object.");
691
+ const limits = Array.isArray(data.limits) ? data.limits : [];
692
+ const buckets = [];
693
+ const metrics = [];
694
+ for (const raw of limits) {
695
+ const limit = asObject5(raw);
696
+ if (!limit) continue;
697
+ const type = asString5(limit.type);
698
+ const unit = asNonnegativeNumber4(limit.unit);
699
+ const isPlanUsage = type === "TOKENS_LIMIT" || type === "CREDIT_LIMIT";
700
+ if (type === "TIME_LIMIT") {
701
+ addCountBucket(buckets, limit, "mcp-monthly", "MCP monthly allowance");
702
+ addUsageDetailMetrics(metrics, limit.usageDetails);
703
+ } else if (isPlanUsage && unit === 3) {
704
+ addPercentBucket(buckets, limit, "five-hour", "5h window", FIVE_HOUR_WINDOW_MINUTES);
705
+ } else if (isPlanUsage && unit === 6) {
706
+ const used = asNonnegativeNumber4(limit.currentValue);
707
+ const quota = asNonnegativeNumber4(limit.usage);
708
+ if (used !== void 0 && quota !== void 0) {
709
+ addCountBucket(buckets, limit, "weekly", "Weekly window", WEEKLY_WINDOW_MINUTES);
710
+ } else {
711
+ addPercentBucket(buckets, limit, "weekly", "Weekly window", WEEKLY_WINDOW_MINUTES);
712
+ }
713
+ }
714
+ }
715
+ if (buckets.length === 0) {
716
+ throw new Error("Z.AI quota endpoint returned no displayable usage data.");
717
+ }
718
+ const notes = [];
719
+ const level = asString5(data.level);
720
+ if (level) notes.push(`Plan: ${level}`);
721
+ return {
722
+ providerId,
723
+ providerName,
724
+ capturedAt,
725
+ source: "zai-quota",
726
+ semantics: { kind: "consumer-subscription", label: "GLM Coding Plan usage" },
727
+ buckets,
728
+ metrics,
729
+ ...notes.length > 0 ? { notes } : {}
730
+ };
731
+ }
732
+ function addPercentBucket(buckets, limit, id, label, windowMinutes) {
733
+ const used = asNonnegativeNumber4(limit.percentage);
734
+ if (used === void 0) return;
735
+ const percent = clampPercent3(used);
736
+ const resetsAt = asEpochSeconds2(limit.nextResetTime);
737
+ buckets.push({
738
+ id,
739
+ label,
740
+ used: percent,
741
+ remaining: 100 - percent,
742
+ limit: 100,
743
+ unit: "percent",
744
+ windowMinutes,
745
+ ...resetsAt !== void 0 ? { resetsAt } : {}
746
+ });
747
+ }
748
+ function addCountBucket(buckets, limit, id, label, windowMinutes) {
749
+ const used = asNonnegativeNumber4(limit.currentValue);
750
+ const quota = asNonnegativeNumber4(limit.usage);
751
+ if (used === void 0 || quota === void 0) return;
752
+ const resetsAt = asEpochSeconds2(limit.nextResetTime);
753
+ buckets.push({
754
+ id,
755
+ label,
756
+ used,
757
+ remaining: Math.max(0, quota - used),
758
+ limit: quota,
759
+ unit: "count",
760
+ ...windowMinutes !== void 0 ? { windowMinutes } : {},
761
+ ...resetsAt !== void 0 ? { resetsAt } : {}
762
+ });
763
+ }
764
+ function addUsageDetailMetrics(metrics, value) {
765
+ if (!Array.isArray(value)) return;
766
+ for (const raw of value) {
767
+ const detail = asObject5(raw);
768
+ if (!detail) continue;
769
+ const label = asString5(detail.modelCode);
770
+ const usage = asNonnegativeNumber4(detail.usage);
771
+ if (!label || usage === void 0) continue;
772
+ metrics.push({ id: `mcp-${kebabCase(label)}`, label, value: usage, unit: "count" });
773
+ }
774
+ }
775
+ function asObject5(value) {
776
+ if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
777
+ return value;
778
+ }
779
+ function asString5(value) {
780
+ if (typeof value !== "string") return void 0;
781
+ return sanitizeDisplayText(value, 80) || void 0;
782
+ }
783
+ function asNonnegativeNumber4(value) {
784
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return void 0;
785
+ return value;
786
+ }
787
+ function asEpochSeconds2(value) {
788
+ const millis = asNonnegativeNumber4(value);
789
+ if (millis === void 0) return void 0;
790
+ return Math.floor(millis / 1e3);
791
+ }
792
+ function kebabCase(label) {
793
+ return label.toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-+|-+$/gu, "") || "tool";
794
+ }
795
+ function clampPercent3(value) {
796
+ return Math.min(100, Math.max(0, value));
797
+ }
798
+
685
799
  // src/query.ts
686
800
  var CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
687
801
  var GITHUB_COPILOT_USAGE_URL = "https://api.github.com/copilot_internal/user";
@@ -756,6 +870,43 @@ var SUPPORTED_ADAPTERS = [
756
870
  );
757
871
  return normalizeOpenCodeZenPayload(payload, Date.now());
758
872
  }
873
+ },
874
+ {
875
+ id: "zai",
876
+ displayName: "Z.AI",
877
+ semantics: { kind: "consumer-subscription", label: "GLM Coding Plan usage" },
878
+ publishesStatusline: false,
879
+ async query(auth, signal, timeoutMs) {
880
+ const payload = await fetchProviderJson(
881
+ zaiMonitorUrl(auth.model.baseUrl),
882
+ zaiMonitorAuth(auth),
883
+ signal,
884
+ timeoutMs,
885
+ "Z.AI quota endpoint"
886
+ );
887
+ return normalizeZaiQuotaPayload("zai", "Z.AI", payload, Date.now());
888
+ }
889
+ },
890
+ {
891
+ id: "zai-coding-cn",
892
+ displayName: "Z.AI Coding CN",
893
+ semantics: { kind: "consumer-subscription", label: "GLM Coding Plan usage" },
894
+ publishesStatusline: false,
895
+ async query(auth, signal, timeoutMs) {
896
+ const payload = await fetchProviderJson(
897
+ zaiMonitorUrl(auth.model.baseUrl),
898
+ zaiMonitorAuth(auth),
899
+ signal,
900
+ timeoutMs,
901
+ "Z.AI Coding CN quota endpoint"
902
+ );
903
+ return normalizeZaiQuotaPayload(
904
+ "zai-coding-cn",
905
+ "Z.AI Coding CN",
906
+ payload,
907
+ Date.now()
908
+ );
909
+ }
759
910
  }
760
911
  ];
761
912
  function adapterForProvider(providerId) {
@@ -954,7 +1105,7 @@ function resolveGitHubCopilotUsageAuth(auth, model, salt, candidates, standalone
954
1105
  const matches = /* @__PURE__ */ new Map();
955
1106
  for (const candidate of candidates) {
956
1107
  try {
957
- const credential = asObject5(candidate);
1108
+ const credential = asObject6(candidate);
958
1109
  if (credential?.type !== "oauth") continue;
959
1110
  sawOAuth = true;
960
1111
  const storedAccess2 = typeof credential.access === "string" && credential.access ? credential.access : void 0;
@@ -1016,7 +1167,7 @@ function bearerToken(authorization) {
1016
1167
  const match = /^Bearer\s+(.+)$/iu.exec(authorization ?? "");
1017
1168
  return match?.[1];
1018
1169
  }
1019
- function asObject5(value) {
1170
+ function asObject6(value) {
1020
1171
  if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
1021
1172
  return value;
1022
1173
  }
@@ -1037,6 +1188,8 @@ function hasOfficialUrlOrigin(value, providerId) {
1037
1188
  if (providerId === "openai-codex") return url.origin === "https://chatgpt.com";
1038
1189
  if (providerId === "openrouter") return url.origin === "https://openrouter.ai";
1039
1190
  if (providerId === "opencode-go") return url.origin === "https://opencode.ai";
1191
+ if (providerId === "zai") return url.origin === "https://api.z.ai";
1192
+ if (providerId === "zai-coding-cn") return url.origin === "https://open.bigmodel.cn";
1040
1193
  if (providerId === "github-copilot") {
1041
1194
  return url.protocol === "https:" && /^api\.[a-z0-9-]+\.githubcopilot\.com$/u.test(url.hostname);
1042
1195
  }
@@ -1054,6 +1207,17 @@ function headerValue(headers, name) {
1054
1207
  function hasHeader(headers, name) {
1055
1208
  return Object.keys(headers).some((key) => key.toLowerCase() === name.toLowerCase());
1056
1209
  }
1210
+ function zaiMonitorUrl(baseUrl) {
1211
+ const base = baseUrl?.trim();
1212
+ if (!base) throw new Error("Z.AI model base URL is unavailable.");
1213
+ return `${new URL(base).origin}/api/monitor/usage/quota/limit`;
1214
+ }
1215
+ function zaiMonitorAuth(auth) {
1216
+ const authorization = headerValue(auth.headers, "Authorization");
1217
+ const token = authorization === void 0 ? void 0 : bearerToken(authorization) ?? authorization;
1218
+ if (token === void 0 || token === authorization) return auth;
1219
+ return { ...auth, headers: { ...auth.headers, Authorization: token } };
1220
+ }
1057
1221
  function isAbortError(error) {
1058
1222
  return error instanceof Error && error.name === "AbortError";
1059
1223
  }
@@ -1196,7 +1360,7 @@ function normalizeCodexResetCreditsPayload(payload) {
1196
1360
  if (rawCredits !== void 0 && !Array.isArray(rawCredits)) {
1197
1361
  throw new Error("Codex reset credits response returned invalid credits.");
1198
1362
  }
1199
- const options = (rawCredits ?? []).map(asObject6).filter((credit) => Boolean(credit)).filter((credit) => credit.status === "available" && credit.reset_type === "codex_rate_limits").map(normalizeResetOption).sort(
1363
+ const options = (rawCredits ?? []).map(asObject7).filter((credit) => Boolean(credit)).filter((credit) => credit.status === "available" && credit.reset_type === "codex_rate_limits").map(normalizeResetOption).sort(
1200
1364
  (left, right) => (left.expiresAt ?? Number.MAX_SAFE_INTEGER) - (right.expiresAt ?? Number.MAX_SAFE_INTEGER)
1201
1365
  ).slice(0, Math.min(availableCount, MAX_RESET_OPTIONS));
1202
1366
  if (availableCount > 0 && options.length === 0) {
@@ -1211,7 +1375,7 @@ function selectCodexResetCredential(candidates, resolvedAccess, resolvedAccountI
1211
1375
  const matches = /* @__PURE__ */ new Map();
1212
1376
  for (const candidate of candidates) {
1213
1377
  try {
1214
- const credential = asObject6(candidate);
1378
+ const credential = asObject7(candidate);
1215
1379
  if (credential?.type !== "oauth") continue;
1216
1380
  sawOAuth = true;
1217
1381
  const storedAccess = asNonemptyString(credential.access);
@@ -1250,7 +1414,7 @@ function codexAccountIdFromAccessToken(access) {
1250
1414
  const parts = access.split(".");
1251
1415
  if (parts.length !== 3 || !parts[1]) return void 0;
1252
1416
  const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
1253
- const claims = asObject6(asObject6(payload)?.["https://api.openai.com/auth"]);
1417
+ const claims = asObject7(asObject7(payload)?.["https://api.openai.com/auth"]);
1254
1418
  return validHeaderValue(claims?.chatgpt_account_id);
1255
1419
  } catch {
1256
1420
  return void 0;
@@ -1282,7 +1446,7 @@ function normalizeResetOption(credit) {
1282
1446
  function isCodexResetOutcomeCode(value) {
1283
1447
  return value === "reset" || value === "nothing_to_reset" || value === "no_credit" || value === "already_redeemed";
1284
1448
  }
1285
- function asObject6(value) {
1449
+ function asObject7(value) {
1286
1450
  if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
1287
1451
  return value;
1288
1452
  }
@@ -1330,7 +1494,9 @@ function formatUsageReport(report, displayState) {
1330
1494
  else if (report.providerId === "github-copilot") formatGitHubCopilotReport(lines, report);
1331
1495
  else if (report.providerId === "openrouter") formatOpenRouterReport(lines, report);
1332
1496
  else if (report.providerId === "opencode-go") formatOpenCodeZenReport(lines, report);
1333
- else formatGenericReport(lines, report);
1497
+ else if (report.providerId === "zai" || report.providerId === "zai-coding-cn") {
1498
+ formatZaiReport(lines, report);
1499
+ } else formatGenericReport(lines, report);
1334
1500
  if (report.notes) {
1335
1501
  for (const note of report.notes) lines.push(note);
1336
1502
  }
@@ -1417,7 +1583,7 @@ function compactGitHubCopilotQuotaKind(bucket) {
1417
1583
  }
1418
1584
  function percentRemaining(bucket) {
1419
1585
  if (!bucket.limit || bucket.remaining === void 0) return 0;
1420
- return Math.round(clampPercent3(bucket.remaining / bucket.limit * 100));
1586
+ return Math.round(clampPercent4(bucket.remaining / bucket.limit * 100));
1421
1587
  }
1422
1588
  function formatOpenRouterReport(lines, report) {
1423
1589
  const limit = report.buckets.find((bucket) => bucket.id === "key-limit");
@@ -1444,10 +1610,33 @@ function formatOpenCodeZenStatusline(report) {
1444
1610
  for (const bucket of report.buckets) {
1445
1611
  if (bucket.used === void 0) continue;
1446
1612
  const compact = bucket.id === "rolling" ? "r" : bucket.id === "weekly" ? "w" : "m";
1447
- parts.push(`${clampPercent3(bucket.used).toFixed(0)}% ${compact}`);
1613
+ parts.push(`${clampPercent4(bucket.used).toFixed(0)}% ${compact}`);
1448
1614
  }
1449
1615
  return parts.length > 1 ? parts.join(" ") : void 0;
1450
1616
  }
1617
+ function formatZaiReport(lines, report) {
1618
+ for (const bucket of report.buckets) {
1619
+ const reset = bucket.resetsAt ? ` (resets ${formatReset(bucket.resetsAt)})` : "";
1620
+ let value = "unavailable";
1621
+ if (bucket.unit === "percent" && bucket.used !== void 0) {
1622
+ value = `${bucket.used}% used`;
1623
+ if (bucket.remaining !== void 0) value += ` \xB7 ${bucket.remaining}% left`;
1624
+ } else if (bucket.used !== void 0 && bucket.limit !== void 0) {
1625
+ value = `${bucket.used} of ${bucket.limit} used`;
1626
+ if (bucket.remaining !== void 0) value += ` \xB7 ${bucket.remaining} left`;
1627
+ } else if (bucket.used !== void 0) {
1628
+ value = `${bucket.used} used`;
1629
+ } else if (bucket.remaining !== void 0) {
1630
+ value = `${bucket.remaining} left`;
1631
+ }
1632
+ lines.push(`${`${bucket.label}:`.padEnd(VALUE_COLUMN)}${value}${reset}`);
1633
+ }
1634
+ for (const metric of report.metrics) {
1635
+ lines.push(
1636
+ `${`${metric.label}:`.padEnd(VALUE_COLUMN)}${formatMetricValue(metric.value, metric.unit)}`
1637
+ );
1638
+ }
1639
+ }
1451
1640
  function formatGenericReport(lines, report) {
1452
1641
  for (const bucket of report.buckets) {
1453
1642
  lines.push(
@@ -1472,7 +1661,7 @@ function formatCodexStatusline(report, model) {
1472
1661
  if (bucket.remaining === void 0) continue;
1473
1662
  const fallback = bucket.id.endsWith(":secondary") ? "weekly" : "5h";
1474
1663
  parts.push(
1475
- `${clampPercent3(bucket.remaining).toFixed(0)}% ${formatWindowLabel(bucket.windowMinutes, fallback, true)}`
1664
+ `${clampPercent4(bucket.remaining).toFixed(0)}% ${formatWindowLabel(bucket.windowMinutes, fallback, true)}`
1476
1665
  );
1477
1666
  }
1478
1667
  return parts.length > 1 ? parts.join(" ") : formatCodexCreditsStatus(report);
@@ -1539,7 +1728,7 @@ function compactLimitLabel(label) {
1539
1728
  return (suffix || normalized).toLowerCase().replace(/\s+/g, " ");
1540
1729
  }
1541
1730
  function formatPercentBucket(bucket) {
1542
- const remaining = clampPercent3(bucket.remaining ?? 0);
1731
+ const remaining = clampPercent4(bucket.remaining ?? 0);
1543
1732
  const filled = Math.round(remaining / 100 * BAR_SEGMENTS);
1544
1733
  const reset = bucket.resetsAt ? ` (resets ${formatReset(bucket.resetsAt)})` : "";
1545
1734
  return `[${"\u2588".repeat(filled)}${"\u2591".repeat(BAR_SEGMENTS - filled)}] ${remaining.toFixed(0)}% left${reset}`;
@@ -1572,7 +1761,7 @@ function formatReset(epochSeconds) {
1572
1761
  function capitalize(value) {
1573
1762
  return `${value[0]?.toUpperCase() ?? ""}${value.slice(1)}`;
1574
1763
  }
1575
- function clampPercent3(value) {
1764
+ function clampPercent4(value) {
1576
1765
  return Math.min(100, Math.max(0, value));
1577
1766
  }
1578
1767
 
@@ -1971,6 +2160,11 @@ function usageExtension(pi, dependencies = {}) {
1971
2160
  statusRefreshTimer.unref?.();
1972
2161
  };
1973
2162
  const publishStatus = (ctx, outcome, model, shouldSchedule) => {
2163
+ if (adapterForProvider(model.provider)?.publishesStatusline === false) {
2164
+ clearStatusTimer();
2165
+ safeSetStatus(ctx, void 0);
2166
+ return;
2167
+ }
1974
2168
  if (outcome.state.status === "unsupported") {
1975
2169
  clearStatusTimer();
1976
2170
  safeSetStatus(ctx, void 0);
@@ -2153,6 +2347,10 @@ function usageExtension(pi, dependencies = {}) {
2153
2347
  clearStatus(ctx);
2154
2348
  return;
2155
2349
  }
2350
+ if (adapter.publishesStatusline === false) {
2351
+ clearStatus(ctx);
2352
+ return;
2353
+ }
2156
2354
  statusGeneration += 1;
2157
2355
  const generation = statusGeneration;
2158
2356
  statusController?.abort();
@@ -2743,6 +2941,7 @@ export {
2743
2941
  normalizeOpenCodeZenPayload,
2744
2942
  normalizeOpenRouterKeyPayload,
2745
2943
  normalizeUsageSettings,
2944
+ normalizeZaiQuotaPayload,
2746
2945
  providerIsConfigured,
2747
2946
  queryProviderUsage,
2748
2947
  redactUsageError,