@narumitw/pi-usage 0.60.4 → 0.60.6

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/docs/providers.md CHANGED
@@ -259,6 +259,13 @@ The extension classifies both forms by the provider's window unit and does not l
259
259
  The quota monitor expects a raw API key without a `Bearer` prefix.
260
260
  The extension removes that prefix from resolved authorization before sending it to the monitor endpoint.
261
261
  Fingerprinting and redaction keep using the original resolved credential.
262
- A credential with no coding plan answers HTTP 200 without a data object; that is reported as `Unsupported` rather than a query failure, so the statusline stays empty.
262
+ Quota errors use fixed English hints for the [documented Z.AI business error codes](https://docs.z.ai/api-reference/api-code), accepting nested `error.code` and the monitor endpoint's top-level `code` as strings or numbers.
263
+ An explicit nested code takes precedence; when it is absent, the top-level code is used.
264
+ For example, `1113` reports insufficient balance or no resource package, and `1309` reports an expired GLM Coding Plan.
265
+ When a failed HTTP response has no business error code, the hint uses its HTTP status; unknown business codes use a generic API failure message.
266
+ The monitor's top-level `code: 500` is not a documented business code and is not treated as HTTP 500 or proof that the credential has no Coding Plan.
267
+ Error classification never matches or echoes provider `msg` or `error.message` text, which may contain credentials.
268
+ Quota errors and missing or malformed quota data remain query failures with a statusline error hint, a 30-second request backoff, and scheduled retries; they no longer clear the statusline as unsupported.
269
+ An accepted Z.AI query failure discards the matching cached report, so the next turn after backoff expires retries instead of restoring stale usage.
263
270
  The plan endpoint only contributes the plan name and renewal date; when it is unavailable or fails, the quota windows remain reported and the plan note falls back to the quota response's plan level.
264
271
  Only the official `api.z.ai` and `open.bigmodel.cn` origins are queried; other origins fail before sending the credential.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-usage",
3
- "version": "0.60.4",
3
+ "version": "0.60.6",
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/core.ts CHANGED
@@ -36,6 +36,10 @@ export class UsageCache {
36
36
  this.entries.set(key, { createdAt: now, report });
37
37
  }
38
38
 
39
+ delete(providerId: string, fingerprint: string): void {
40
+ this.entries.delete(cacheKey(providerId, fingerprint));
41
+ }
42
+
39
43
  clearProvider(providerId: string): void {
40
44
  for (const key of this.entries.keys()) {
41
45
  if (key.startsWith(`${providerId}:`)) this.entries.delete(key);
@@ -198,10 +202,6 @@ export function errorMessage(error: unknown): string {
198
202
  return sanitizeDisplayText(error instanceof Error ? error.message : String(error), 600);
199
203
  }
200
204
 
201
- // A credential the provider cannot meter is not a query failure: it is reported as unsupported, so
202
- // the statusline stays empty instead of holding an error chip that no retry will clear.
203
- export class UsageUnsupportedError extends Error {}
204
-
205
205
  export function abortError(): Error {
206
206
  return Object.assign(new Error("Usage query aborted."), { name: "AbortError" });
207
207
  }
@@ -0,0 +1,94 @@
1
+ // Fixed messages from https://docs.z.ai/api-reference/api-code. Never interpolate provider text:
2
+ // error messages and their template parameters can contain credentials or terminal controls.
3
+ const ERROR_MESSAGES: Readonly<Record<string, string>> = {
4
+ "1000": "Authentication failed. Check your API key.",
5
+ "1001": "Authentication header missing. Check your API key.",
6
+ "1003": "Authentication token expired. Obtain a new token.",
7
+ "1005": "Two-factor authentication required.",
8
+ "1113": "Insufficient balance or no resource package. Recharge your account.",
9
+ "1200": "API call error. Try again later.",
10
+ "1210": "Invalid API parameter. Check the API documentation.",
11
+ "1211": "Unknown model. Check the model ID.",
12
+ "1212": "This model does not support the requested method.",
13
+ "1213": "A required parameter is missing.",
14
+ "1214": "Invalid parameter. Check the API documentation.",
15
+ "1215": "Conflicting parameters. Check the API documentation.",
16
+ "1220": "Access denied. Check your permissions.",
17
+ "1221": "This API has been taken offline.",
18
+ "1222": "This API does not exist.",
19
+ "1230": "API processing error. Try again later.",
20
+ "1234": "Network error. Try again later.",
21
+ "1261": "Prompt too long. Reduce the input length.",
22
+ "1301": "Content rejected by the safety policy.",
23
+ "1302": "Request rate limit reached. Try again later.",
24
+ "1305": "Service overloaded. Try again later.",
25
+ "1308": "Usage limit reached. Wait for the quota reset.",
26
+ "1309": "GLM Coding Plan expired. Renew your subscription.",
27
+ "1310": "Weekly or monthly limit exhausted. Wait for the quota reset.",
28
+ "1311": "Your subscription does not include this model.",
29
+ "1313": "Request frequency restricted by the Fair Usage Policy. Contact support.",
30
+ "1314": "Enterprise package expired. Contact your administrator.",
31
+ "1315": "This API key requires an enterprise coding package scenario. Replace the key.",
32
+ "1316": "5-hour limit reached; insufficient balance for extra usage. Wait for reset.",
33
+ "1317": "7-day limit reached; insufficient balance for extra usage. Wait for reset.",
34
+ "1318": "5-hour limit reached; extra usage blocked by monthly spend limit.",
35
+ "1319": "7-day limit reached; extra usage blocked by monthly spend limit.",
36
+ "1320": "5-hour limit reached; extra usage blocked by monthly spend limit.",
37
+ "1321": "7-day limit reached; extra usage blocked by monthly spend limit.",
38
+ };
39
+
40
+ const HTTP_MESSAGES: Readonly<Record<number, string>> = {
41
+ 400: "Invalid request. Check the API documentation.",
42
+ 401: "Authentication failed. Check your API key.",
43
+ 403: "Access denied. Check your permissions.",
44
+ 429: "Request or usage limit reached. Try again later.",
45
+ 500: "Internal error. Try again later.",
46
+ };
47
+
48
+ export function zaiPayloadError(payload: unknown): string | undefined {
49
+ const object = asObject(payload);
50
+ if (!object) return undefined;
51
+ const nested = asObject(object.error);
52
+ // Missing nested metadata must not hide a top-level code; explicit nested values retain priority.
53
+ const rawCode = nested?.code === undefined ? object.code : nested.code;
54
+ const code = errorCode(rawCode);
55
+ if (
56
+ object.error === undefined &&
57
+ object.success !== false &&
58
+ (rawCode === undefined || code === "0" || code === "200")
59
+ ) {
60
+ return undefined;
61
+ }
62
+ return code && code !== "0" && code !== "200"
63
+ ? `Z.AI ${code}: ${ERROR_MESSAGES[code] ?? "API request failed."}`
64
+ : "Z.AI: API request failed.";
65
+ }
66
+
67
+ export function zaiResponseError(status: number, text: string): string | undefined {
68
+ let payload: unknown;
69
+ try {
70
+ payload = JSON.parse(text) as unknown;
71
+ } catch {
72
+ // Do not expose JSON parser excerpts, response bodies, or statusText.
73
+ if (status >= 200 && status < 300) return "Z.AI: Invalid JSON response.";
74
+ }
75
+ const error = zaiPayloadError(payload);
76
+ if (error) return error;
77
+ if (status < 200 || status >= 300) {
78
+ return `Z.AI HTTP ${status}: ${HTTP_MESSAGES[status] ?? "API request failed."}`;
79
+ }
80
+ return undefined;
81
+ }
82
+
83
+ function errorCode(value: unknown): string | undefined {
84
+ if (typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 9999) {
85
+ return String(value);
86
+ }
87
+ return typeof value === "string" && /^(?:0|[1-9]\d{0,3})$/u.test(value) ? value : undefined;
88
+ }
89
+
90
+ function asObject(value: unknown): Record<string, unknown> | undefined {
91
+ return value !== null && typeof value === "object" && !Array.isArray(value)
92
+ ? (value as Record<string, unknown>)
93
+ : undefined;
94
+ }
@@ -1,4 +1,4 @@
1
- import { sanitizeDisplayText, UsageUnsupportedError } from "../core.js";
1
+ import { sanitizeDisplayText } from "../core.js";
2
2
  import type {
3
3
  UsageBucket,
4
4
  UsageMetric,
@@ -8,6 +8,8 @@ import type {
8
8
  ZaiSubscriptionPayload,
9
9
  } from "../types.js";
10
10
 
11
+ import { zaiPayloadError } from "./zai-errors.js";
12
+
11
13
  const FIVE_HOUR_WINDOW_MINUTES = 300;
12
14
  const WEEKLY_WINDOW_MINUTES = 10_080;
13
15
 
@@ -18,15 +20,10 @@ export function normalizeZaiQuotaPayload(
18
20
  capturedAt: number,
19
21
  plan?: ZaiPlanInfo,
20
22
  ): UsageReport {
23
+ const error = zaiPayloadError(payload);
24
+ if (error) throw new Error(error);
21
25
  const data = asObject(payload.data);
22
- // A credential with no coding plan answers HTTP 200 with no data object:
23
- // {"code":500,"msg":"当前用户不存在coding plan","success":false}. API usage is not metered.
24
- if (!data) {
25
- const reason = asString(payload.msg);
26
- throw new UsageUnsupportedError(
27
- `No GLM Coding Plan on this Z.AI credential; API usage is not metered.${reason ? ` Z.AI reported: ${reason}` : ""}`,
28
- );
29
- }
26
+ if (!data) throw new Error("Z.AI quota response data was not an object.");
30
27
  const limits = Array.isArray(data.limits) ? (data.limits as unknown[]) : [];
31
28
 
32
29
  const buckets: UsageBucket[] = [];
@@ -88,10 +85,7 @@ export function normalizeZaiQuotaPayload(
88
85
  export function normalizeZaiSubscriptionPayload(
89
86
  payload: ZaiSubscriptionPayload,
90
87
  ): ZaiPlanInfo | undefined {
91
- if (payload.success === false) return undefined;
92
- if (typeof payload.code === "number" && payload.code !== 0 && payload.code !== 200) {
93
- return undefined;
94
- }
88
+ if (zaiPayloadError(payload)) return undefined;
95
89
  if (!Array.isArray(payload.data)) return undefined;
96
90
 
97
91
  const candidates: Array<{
package/src/query.ts CHANGED
@@ -2,12 +2,7 @@
2
2
  // separating that security boundary would duplicate request and redaction policy across providers.
3
3
  import { randomBytes } from "node:crypto";
4
4
  import { type ExtensionContext, readStoredCredential } from "@earendil-works/pi-coding-agent";
5
- import {
6
- errorMessage,
7
- fingerprintResolvedAuth,
8
- redactUsageError,
9
- UsageUnsupportedError,
10
- } from "./core.js";
5
+ import { errorMessage, fingerprintResolvedAuth, redactUsageError } from "./core.js";
11
6
  import {
12
7
  fallbackOAuthCredentialCandidates,
13
8
  type OAuthCredentialCandidateReader,
@@ -29,6 +24,7 @@ import { normalizeOpenRouterKeyPayload } from "./providers/openrouter.js";
29
24
  import { normalizeVercelAIGatewayCreditsPayload } from "./providers/vercel-ai-gateway.js";
30
25
  import { normalizeXaiBillingPayload } from "./providers/xai.js";
31
26
  import { normalizeZaiQuotaPayload, normalizeZaiSubscriptionPayload } from "./providers/zai.js";
27
+ import { zaiResponseError } from "./providers/zai-errors.js";
32
28
  import type {
33
29
  BasetenBillingUsagePayload,
34
30
  CodexBackendPayload,
@@ -266,6 +262,7 @@ export const SUPPORTED_ADAPTERS: readonly UsageProviderAdapter[] = [
266
262
  {
267
263
  id: "zai",
268
264
  displayName: "Z.AI",
265
+ invalidateCacheOnFailure: true,
269
266
  semantics: { kind: "consumer-subscription", label: "GLM Coding Plan usage" },
270
267
  async query(auth, signal, timeoutMs, guard) {
271
268
  return queryZaiUsage("zai", "Z.AI", auth, signal, timeoutMs, guard);
@@ -274,6 +271,7 @@ export const SUPPORTED_ADAPTERS: readonly UsageProviderAdapter[] = [
274
271
  {
275
272
  id: "zai-coding-cn",
276
273
  displayName: "Z.AI Coding CN",
274
+ invalidateCacheOnFailure: true,
277
275
  semantics: { kind: "consumer-subscription", label: "GLM Coding Plan usage" },
278
276
  async query(auth, signal, timeoutMs, guard) {
279
277
  return queryZaiUsage("zai-coding-cn", "Z.AI Coding CN", auth, signal, timeoutMs, guard);
@@ -552,11 +550,7 @@ export async function queryProviderUsage(
552
550
  );
553
551
  } catch (error) {
554
552
  if (isStaleExtensionContextError(error) || isAbortError(error)) throw error;
555
- const message = redactUsageError(errorMessage(error), auth.secrets);
556
- // Redaction rebuilds the error, so the unsupported signal has to be carried across.
557
- throw error instanceof UsageUnsupportedError
558
- ? new UsageUnsupportedError(message)
559
- : new Error(message);
553
+ throw new Error(redactUsageError(errorMessage(error), auth.secrets));
560
554
  }
561
555
  }
562
556
 
@@ -636,6 +630,7 @@ export async function fetchProviderJson(
636
630
  body?: Record<string, unknown>;
637
631
  redirect?: RequestRedirect;
638
632
  userAgent?: boolean;
633
+ responseError?: (status: number, text: string) => string | undefined;
639
634
  } = {},
640
635
  ): Promise<Record<string, unknown>> {
641
636
  const controller = new AbortController();
@@ -674,6 +669,8 @@ export async function fetchProviderJson(
674
669
  );
675
670
  if (controller.signal.aborted)
676
671
  throw Object.assign(new Error("Usage query aborted."), { name: "AbortError" });
672
+ const responseError = request.responseError?.(response.status, text);
673
+ if (responseError) throw new Error(responseError);
677
674
  if (!response.ok) {
678
675
  throw new Error(
679
676
  `${description} returned ${response.status} ${response.statusText}: ${redactUsageError(text, auth.secrets)}`,
@@ -1110,6 +1107,7 @@ async function queryZaiUsage(
1110
1107
  signal,
1111
1108
  remainingTimeout(timeoutMs, startedAt, `fetching ${providerName} quota`),
1112
1109
  `${providerName} quota endpoint`,
1110
+ { responseError: zaiResponseError },
1113
1111
  )) as ZaiQuotaPayload;
1114
1112
  await guard();
1115
1113
  const planTimeoutMs = timeoutMs - (Date.now() - startedAt);
@@ -1134,6 +1132,7 @@ async function fetchZaiPlan(
1134
1132
  signal,
1135
1133
  timeoutMs,
1136
1134
  `${providerName} plan endpoint`,
1135
+ { responseError: zaiResponseError },
1137
1136
  )) as ZaiSubscriptionPayload;
1138
1137
  return normalizeZaiSubscriptionPayload(payload);
1139
1138
  } catch (error) {
package/src/types.ts CHANGED
@@ -91,6 +91,8 @@ export interface UsageProviderAdapter {
91
91
  displayName: string;
92
92
  semantics: UsageSemantics;
93
93
  publishesStatusline?: boolean;
94
+ /** Invalidate the matching ready report when the latest query fails; default preserves it. */
95
+ invalidateCacheOnFailure?: boolean;
94
96
  targets?: UsageTargetResolver;
95
97
  query(
96
98
  auth: ResolvedUsageAuth,
@@ -191,11 +193,14 @@ export type OpenCodeZenPayload = {
191
193
  };
192
194
 
193
195
  export type ZaiQuotaPayload = {
194
- msg?: unknown;
196
+ error?: unknown;
197
+ code?: unknown;
198
+ success?: unknown;
195
199
  data?: unknown;
196
200
  };
197
201
 
198
202
  export type ZaiSubscriptionPayload = {
203
+ error?: unknown;
199
204
  code?: unknown;
200
205
  success?: unknown;
201
206
  data?: unknown;
package/src/usage.ts CHANGED
@@ -28,7 +28,6 @@ import {
28
28
  errorMessage,
29
29
  runWithConcurrency,
30
30
  UsageCache,
31
- UsageUnsupportedError,
32
31
  } from "./core.js";
33
32
  import { formatProviderStates, formatUsageStatusline } from "./format.js";
34
33
  import { createOAuthCredentialCandidateReader } from "./oauth-credential-source.js";
@@ -111,10 +110,7 @@ export default function usageExtension(
111
110
  const createRedemptionId = dependencies.createRedemptionId ?? randomUUID;
112
111
  const settingsRuntime = dependencies.settingsRuntime ?? createUsageSettingsRuntime();
113
112
  const cache = new UsageCache(CACHE_TTL_MS);
114
- const failureBackoff = new Map<
115
- string,
116
- { until: number; message: string; status: "query-failed" | "unsupported" }
117
- >();
113
+ const failureBackoff = new Map<string, { until: number; message: string }>();
118
114
  const latestQueries = new Map<string, number>();
119
115
  const activeControllers = new Set<AbortController>();
120
116
  let querySequence = 0;
@@ -363,7 +359,7 @@ export default function usageExtension(
363
359
  providerId: adapter.id,
364
360
  providerName,
365
361
  displayState,
366
- status: previousDiscoveryFailure.status,
362
+ status: "query-failed",
367
363
  message: previousDiscoveryFailure.message,
368
364
  },
369
365
  fingerprint: auth.fingerprint,
@@ -424,7 +420,7 @@ export default function usageExtension(
424
420
  providerId: adapter.id,
425
421
  providerName,
426
422
  displayState,
427
- status: previousFailure.status,
423
+ status: "query-failed",
428
424
  message: previousFailure.message,
429
425
  },
430
426
  fingerprint: auth.fingerprint,
@@ -483,18 +479,16 @@ export default function usageExtension(
483
479
  );
484
480
  }
485
481
  const message = errorMessage(error);
486
- const status = error instanceof UsageUnsupportedError ? "unsupported" : "query-failed";
487
482
  const now = Date.now();
488
483
  for (const [key, failure] of failureBackoff) {
489
484
  if (failure.until <= now) failureBackoff.delete(key);
490
485
  }
491
- // Unsupported is throttled like a failure, so a credential the provider cannot meter does
492
- // not re-query on every turn; the entry carries its status so the replay stays unsupported.
493
486
  if (queryId === undefined || latestQueries.get(failureKey) === queryId) {
487
+ if (adapter.invalidateCacheOnFailure) cache.delete(adapter.id, queryFingerprint);
494
488
  setBoundedMap(
495
489
  failureBackoff,
496
490
  failureKey,
497
- { until: now + FAILURE_BACKOFF_MS, message, status },
491
+ { until: now + FAILURE_BACKOFF_MS, message },
498
492
  MAX_ACCOUNT_STATES,
499
493
  );
500
494
  }
@@ -503,7 +497,7 @@ export default function usageExtension(
503
497
  providerId: adapter.id,
504
498
  providerName,
505
499
  displayState,
506
- status,
500
+ status: "query-failed",
507
501
  message,
508
502
  },
509
503
  fingerprint: auth.fingerprint,