@narumitw/pi-usage 0.59.0 → 0.60.1

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.1",
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
@@ -54,13 +54,39 @@ export class UsageCache {
54
54
  }
55
55
 
56
56
  export function fingerprintResolvedAuth(
57
- auth: { apiKey?: string; headers?: Record<string, string> },
57
+ auth: {
58
+ apiKey?: string;
59
+ headers?: Record<string, string | null>;
60
+ baseUrl?: string;
61
+ env?: Record<string, string>;
62
+ source?: string;
63
+ providerAuth?: {
64
+ apiKey?: string;
65
+ headers?: Record<string, string | null>;
66
+ baseUrl?: string;
67
+ };
68
+ },
58
69
  salt: Uint8Array,
59
70
  ): string {
60
71
  const headers = Object.entries(auth.headers ?? {})
61
72
  .map(([name, value]) => [name.toLowerCase(), value] as const)
62
73
  .sort(([left], [right]) => left.localeCompare(right));
63
- const canonical = JSON.stringify({ apiKey: auth.apiKey ?? "", headers });
74
+ const env = Object.entries(auth.env ?? {}).sort(([left], [right]) => left.localeCompare(right));
75
+ const providerHeaders = Object.entries(auth.providerAuth?.headers ?? {})
76
+ .map(([name, value]) => [name.toLowerCase(), value] as const)
77
+ .sort(([left], [right]) => left.localeCompare(right));
78
+ const canonical = JSON.stringify({
79
+ apiKey: auth.apiKey ?? "",
80
+ headers,
81
+ baseUrl: auth.baseUrl ?? "",
82
+ env,
83
+ source: auth.source ?? "",
84
+ providerAuth: {
85
+ apiKey: auth.providerAuth?.apiKey ?? "",
86
+ headers: providerHeaders,
87
+ baseUrl: auth.providerAuth?.baseUrl ?? "",
88
+ },
89
+ });
64
90
  return createHmac("sha256", salt).update(canonical).digest("hex");
65
91
  }
66
92
 
package/src/format.ts CHANGED
@@ -94,6 +94,9 @@ export function formatProviderStates(states: readonly ProviderUsageState[]): str
94
94
  .map((state) => {
95
95
  if (state.status === "ready") return formatUsageReport(state.report, state.displayState);
96
96
  const label = state.displayState === "current" ? "Current" : "Configured";
97
+ if (state.status === "selection-required") {
98
+ return `${state.providerName} · ${label}\nSelection required: choose this provider's ${state.singularLabel} by viewing it individually.`;
99
+ }
97
100
  const status =
98
101
  state.status === "auth-unavailable"
99
102
  ? "Authentication unavailable"
@@ -269,6 +272,10 @@ function formatOpenRouterReport(lines: string[], report: UsageReport): void {
269
272
 
270
273
  function formatOpenCodeZenReport(lines: string[], report: UsageReport): void {
271
274
  for (const bucket of report.buckets) {
275
+ if (bucket.unit === "percent" && bucket.used !== undefined) {
276
+ lines.push(`${`${bucket.label}:`.padEnd(VALUE_COLUMN)}${formatPercentBucket(bucket)}`);
277
+ continue;
278
+ }
272
279
  const reset = bucket.resetsAt ? ` (resets ${formatReset(bucket.resetsAt)})` : "";
273
280
  const used = bucket.used ?? "unavailable";
274
281
  lines.push(`${`${bucket.label}:`.padEnd(VALUE_COLUMN)}${used}% used${reset}`);
@@ -469,8 +476,7 @@ function formatXaiReport(lines: string[], report: UsageReport): void {
469
476
  if (included) {
470
477
  let value = "unavailable";
471
478
  if (included.unit === "percent" && included.used !== undefined) {
472
- value = `${included.used}% used`;
473
- if (included.remaining !== undefined) value += ` · ${included.remaining}% left`;
479
+ value = formatPercentBar(included);
474
480
  } else if (included.used !== undefined) {
475
481
  value = `${formatUsd(included.used)} used`;
476
482
  if (included.limit !== undefined) value += ` of ${formatUsd(included.limit)}`;
@@ -497,12 +503,13 @@ function formatXaiReport(lines: string[], report: UsageReport): void {
497
503
 
498
504
  function formatZaiReport(lines: string[], report: UsageReport): void {
499
505
  for (const bucket of report.buckets) {
506
+ if (bucket.unit === "percent" && bucket.used !== undefined) {
507
+ lines.push(`${`${bucket.label}:`.padEnd(VALUE_COLUMN)}${formatPercentBucket(bucket)}`);
508
+ continue;
509
+ }
500
510
  const reset = bucket.resetsAt ? ` (resets ${formatReset(bucket.resetsAt)})` : "";
501
511
  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) {
512
+ if (bucket.used !== undefined && bucket.limit !== undefined) {
506
513
  value = `${bucket.used} of ${bucket.limit} used`;
507
514
  if (bucket.remaining !== undefined) value += ` · ${bucket.remaining} left`;
508
515
  } else if (bucket.used !== undefined) {
@@ -637,10 +644,13 @@ function compactLimitLabel(label: string): string {
637
644
  }
638
645
 
639
646
  function formatPercentBucket(bucket: UsageBucket): string {
647
+ return `${formatPercentBar(bucket)}${bucket.resetsAt ? ` (resets ${formatReset(bucket.resetsAt)})` : ""}`;
648
+ }
649
+
650
+ function formatPercentBar(bucket: UsageBucket): string {
640
651
  const remaining = clampPercent(bucket.remaining ?? 0);
641
652
  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}`;
653
+ return `[${"█".repeat(filled)}${"░".repeat(BAR_SEGMENTS - filled)}] ${remaining.toFixed(0)}% left`;
644
654
  }
645
655
 
646
656
  function formatWindowLabel(
package/src/index.ts CHANGED
@@ -36,6 +36,7 @@ export { normalizeBasetenBillingUsagePayload } from "./providers/baseten.js";
36
36
  export { normalizeCodexBackendPayload } from "./providers/codex.js";
37
37
  export { normalizeDeepSeekBalancePayload } from "./providers/deepseek.js";
38
38
  export {
39
+ createFireworksAdapter,
39
40
  normalizeFireworksAccountsPayload,
40
41
  normalizeFireworksBillingSummaryPayload,
41
42
  } from "./providers/fireworks.js";
@@ -52,7 +53,7 @@ export { normalizeOpenCodeZenPayload } from "./providers/opencode-zen.js";
52
53
  export { normalizeOpenRouterKeyPayload } from "./providers/openrouter.js";
53
54
  export { normalizeVercelAIGatewayCreditsPayload } from "./providers/vercel-ai-gateway.js";
54
55
  export { normalizeXaiBillingPayload } from "./providers/xai.js";
55
- export { normalizeZaiQuotaPayload } from "./providers/zai.js";
56
+ export { normalizeZaiQuotaPayload, normalizeZaiSubscriptionPayload } from "./providers/zai.js";
56
57
  export {
57
58
  adapterForProvider,
58
59
  isStaleExtensionContextError,
@@ -67,6 +68,7 @@ export type {
67
68
  UsageSettings,
68
69
  UsageSettingsRuntime,
69
70
  UsageSettingsState,
71
+ UsageTargetPublicationCheck,
70
72
  } from "./settings.js";
71
73
  export {
72
74
  createUsageSettingsRuntime,
@@ -90,13 +92,24 @@ export type {
90
92
  UsageMetric,
91
93
  UsageModel,
92
94
  UsageProviderAdapter,
95
+ UsageProviderTarget,
93
96
  UsageQuerySettings,
94
97
  UsageReport,
98
+ UsageRequestGuard,
95
99
  UsageSemantics,
96
100
  UsageSemanticsKind,
101
+ UsageTargetResolver,
97
102
  UsageUnit,
98
103
  VercelAIGatewayCreditsPayload,
99
104
  XaiBillingPayload,
100
105
  XaiUserPayload,
101
106
  } from "./types.js";
102
107
  export { default } from "./usage.js";
108
+ export type { UsageTargetResolution, UsageTargetSelectOptions } from "./usage-targets.js";
109
+ export {
110
+ createUsageTargetSelectOptions,
111
+ isBoundedTargetId,
112
+ listUsageTargets,
113
+ normalizeUsageTargets,
114
+ resolveUsageTarget,
115
+ } from "./usage-targets.js";
@@ -2,7 +2,9 @@ import { sanitizeDisplayText } from "../core.js";
2
2
  import type {
3
3
  FireworksAccountsPayload,
4
4
  FireworksBillingSummaryPayload,
5
+ ResolvedUsageAuth,
5
6
  UsageMetric,
7
+ UsageProviderAdapter,
6
8
  UsageReport,
7
9
  } from "../types.js";
8
10
 
@@ -14,6 +16,9 @@ const INT64_MIN = -(2n ** 63n);
14
16
  const INT64_MAX = 2n ** 63n - 1n;
15
17
  const MAX_UNITS_CHARS = 20;
16
18
  const MAX_NANOS_CHARS = 11;
19
+ const FIREWORKS_BILLING_SUMMARY_ORIGIN = "https://api.fireworks.ai";
20
+ const FIREWORKS_SPEND_WINDOW_DAYS = 30;
21
+ const FIREWORKS_MAX_ACCOUNT_PAGES = 5;
17
22
 
18
23
  const SERIES_KEYS = ["serverless", "dedicated", "training", "other"] as const;
19
24
  type SeriesKey = (typeof SERIES_KEYS)[number];
@@ -53,6 +58,77 @@ export function normalizeFireworksAccountsPayload(payload: FireworksAccountsPayl
53
58
  return accounts;
54
59
  }
55
60
 
61
+ type FetchProviderJson = (
62
+ url: string,
63
+ auth: ResolvedUsageAuth,
64
+ signal: AbortSignal,
65
+ timeoutMs: number,
66
+ description: string,
67
+ request?: { redirect?: RequestRedirect },
68
+ ) => Promise<Record<string, unknown>>;
69
+
70
+ export function createFireworksAdapter(fetchProviderJson: FetchProviderJson): UsageProviderAdapter {
71
+ return {
72
+ id: "fireworks",
73
+ displayName: "Fireworks",
74
+ semantics: { kind: "api-key", label: "Fireworks API spend" },
75
+ targets: {
76
+ singularLabel: "account",
77
+ pluralLabel: "accounts",
78
+ async list(auth, signal, timeoutMs, guard) {
79
+ const startedAt = Date.now();
80
+ const accounts: string[] = [];
81
+ let pageToken: string | undefined;
82
+ for (let page = 0; page < FIREWORKS_MAX_ACCOUNT_PAGES; page += 1) {
83
+ await guard();
84
+ const payload = (await fetchProviderJson(
85
+ fireworksAccountsUrl(pageToken),
86
+ auth,
87
+ signal,
88
+ remainingTimeout(timeoutMs, startedAt, "fetching Fireworks accounts"),
89
+ "Fireworks accounts endpoint",
90
+ { redirect: "error" },
91
+ )) as FireworksAccountsPayload;
92
+ await guard();
93
+ for (const accountId of normalizeFireworksAccountsPayload(payload)) {
94
+ if (accounts.includes(accountId)) {
95
+ throw new Error(`Fireworks accounts listing repeated ${accountId}.`);
96
+ }
97
+ accounts.push(accountId);
98
+ }
99
+ pageToken = fireworksNextPageToken(payload.nextPageToken);
100
+ if (!pageToken) break;
101
+ }
102
+ if (pageToken) {
103
+ throw new Error(
104
+ `Fireworks account listing exceeded ${FIREWORKS_MAX_ACCOUNT_PAGES} pages.`,
105
+ );
106
+ }
107
+ return accounts.map((id) => ({ id, label: id }));
108
+ },
109
+ },
110
+ async query(auth, signal, timeoutMs, guard, targetId) {
111
+ if (!guard) throw new Error("Fireworks API spend requires request-boundary revalidation.");
112
+ if (!isFireworksAccountId(targetId)) {
113
+ throw new Error("Fireworks billing requires a safe selected account slug.");
114
+ }
115
+ const startedAt = Date.now();
116
+ await guard();
117
+ const billingWindowAt = Date.now();
118
+ const payload = (await fetchProviderJson(
119
+ fireworksBillingSummaryUrl(targetId, billingWindowAt),
120
+ auth,
121
+ signal,
122
+ remainingTimeout(timeoutMs, startedAt, "fetching Fireworks rated spend"),
123
+ "Fireworks billing summary endpoint",
124
+ { redirect: "error" },
125
+ )) as FireworksBillingSummaryPayload;
126
+ await guard();
127
+ return normalizeFireworksBillingSummaryPayload(payload, targetId, Date.now());
128
+ },
129
+ };
130
+ }
131
+
56
132
  export function normalizeFireworksBillingSummaryPayload(
57
133
  payload: FireworksBillingSummaryPayload,
58
134
  accountId: string,
@@ -192,6 +268,42 @@ function formatMoneyAmount(amount: bigint): string {
192
268
  return `${negative ? "-" : ""}${units.toString()}${nanos ? `.${nanos}` : ""}`;
193
269
  }
194
270
 
271
+ function fireworksAccountsUrl(pageToken: string | undefined): string {
272
+ const url = new URL("/v1/accounts", FIREWORKS_BILLING_SUMMARY_ORIGIN);
273
+ url.searchParams.set("pageSize", "200");
274
+ if (pageToken !== undefined) url.searchParams.set("pageToken", pageToken);
275
+ return url.toString();
276
+ }
277
+
278
+ function fireworksNextPageToken(value: unknown): string | undefined {
279
+ if (value === undefined || value === null) return undefined;
280
+ if (typeof value !== "string" || !value || value.length > 512) {
281
+ throw new Error("Fireworks accounts listing returned an invalid page token.");
282
+ }
283
+ return value;
284
+ }
285
+
286
+ function fireworksBillingSummaryUrl(accountId: string, startedAt: number): string {
287
+ const dayMs = 24 * 60 * 60 * 1000;
288
+ const dayFloor = (time: number) => `${new Date(time).toISOString().slice(0, 10)}T00:00:00Z`;
289
+ const url = new URL(
290
+ `/v1/accounts/${accountId}/billing/summary`,
291
+ FIREWORKS_BILLING_SUMMARY_ORIGIN,
292
+ );
293
+ url.searchParams.set(
294
+ "startTime",
295
+ dayFloor(startedAt - (FIREWORKS_SPEND_WINDOW_DAYS - 1) * dayMs),
296
+ );
297
+ url.searchParams.set("endTime", dayFloor(startedAt + dayMs));
298
+ return url.toString();
299
+ }
300
+
301
+ function remainingTimeout(timeoutMs: number, startedAt: number, description: string): number {
302
+ const remaining = timeoutMs - (Date.now() - startedAt);
303
+ if (remaining <= 0) throw new Error(`Timed out while ${description}.`);
304
+ return remaining;
305
+ }
306
+
195
307
  function asObject(value: unknown): Record<string, unknown> | undefined {
196
308
  if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
197
309
  return value as Record<string, unknown>;
@@ -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;