@pify/usage 0.4.0 → 0.5.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
@@ -45,19 +45,33 @@ Context window: 22.6k of 200.0k used (11%)
45
45
  - **Per-project spend** (v0.2): pi stores sessions one directory per project, so the dashboard can show where the money actually went — the top 5 projects by cost, all time.
46
46
  - **`usage_status` tool**: the agent can check session + today totals before committing to expensive work (subagent fan-outs, large reads).
47
47
 
48
- ## `/usage quota` (v0.3)
48
+ ## `/usage quota` (v0.5)
49
49
 
50
50
  The one command in this package that touches the network, and only when you run it:
51
51
 
52
52
  ```
53
- Quota (openrouter · sk-or-v1-abc...xyz)
54
- spent $0.32 (no credit limit on this key)
55
- window day $0.32 · week $0.32 · month $0.32
53
+ Quota (OpenRouter · sk-or-v1-abc...xyz)
54
+ spent $0.33 (no credit limit on this key)
55
+ window day $0.01 · week $0.33 · month $0.33
56
+
57
+ Quota (DeepSeek · granted 10 · topped up 100)
58
+ balance CNY 110.00
56
59
  ```
57
60
 
58
- The key is read from the same `~/.pi/agent/auth.json` pi already uses (or `OPENROUTER_API_KEY`) no second place to configure credentials and only the provider's own masked label is ever printed. An 8-second timeout, and any failure renders as `unavailable HTTP 401` rather than throwing.
61
+ **Documented endpoints only.** OpenRouter's `/api/v1/key` and DeepSeek's `/user/balance` are published APIs that report a real balance. The subscription-quota endpoints some plugins use for OpenAI, Anthropic and Gemini are undocumented private APIs reverse-engineered from vendor CLIs they break without notice and were never offered to third parties, so this package does not call them. Providers you have not configured are simply not shown; they are not failures.
62
+
63
+ **A credentialed request is pinned down** (v0.5), because it carries your provider key:
64
+
65
+ - HTTPS only, and the host must be on that provider's allowlist.
66
+ - Redirects are refused outright. Following one lets whatever answered choose where the next request goes, with the header already attached.
67
+ - A non-2xx body is **never read**. Error bodies echo request details back, and an echoed `Authorization` header pasted into a notification is exactly the leak this must not cause — the status alone becomes the message.
68
+ - Raw exception text is dropped rather than shown, and everything printed passes a redactor as a last line of defence.
69
+
70
+ **The key comes from pi** (v0.5): resolved through `modelRegistry`, not by reading `auth.json`. pi owns credential storage — env precedence, OAuth, whatever it grows next — and parsing that file here meant handling secrets this package has no business touching, with a stale copy of pi's rules. Only the provider's own masked label is ever printed.
71
+
72
+ An 8-second timeout per provider, and any failure renders as `unavailable — the provider rejected the key` rather than throwing.
59
73
 
60
- Only OpenRouter is implemented. The other providers report opaque rate-limit windows rather than a balance, and chasing all of them costs ~18k lines of per-provider contract maintenance (see `@narumitw/pi-usage` if you need them today).
74
+ The hardening, the multi-provider shape, and the documented-APIs-only stance are from [`@imdlan/pi-usage`](https://github.com/imdlan/pi-usage), which supports Z.ai as well.
61
75
 
62
76
  ## License
63
77
 
@@ -30,7 +30,8 @@ import { readFileSync } from "node:fs";
30
30
  import { addRecord, aggregate, recordFromEntry, windowTotals } from "../src/aggregate.ts";
31
31
  import { buildBreakdown, formatBreakdown } from "../src/context.ts";
32
32
  import { footerText, formatCost, formatTokens, historyBlock, sessionBlock } from "../src/format.ts";
33
- import { fetchOpenRouterQuota, quotaBlock } from "../src/quota.ts";
33
+ import { QUOTA_PROVIDERS, fetchQuota, quotaReport, type QuotaResult } from "../src/quota.ts";
34
+ import { redact } from "../src/redact.ts";
34
35
  import { scanSessions } from "../src/sessions.ts";
35
36
  import { emptyTotals, isRecord, type UsageTotals } from "../src/types.ts";
36
37
 
@@ -97,15 +98,26 @@ export default function usage(pi: ExtensionAPI) {
97
98
  * The key pi itself uses, read from the same auth.json — no second place to
98
99
  * configure credentials, and no key is ever printed.
99
100
  */
100
- function providerKey(provider: string): string {
101
+ /**
102
+ * Ask pi for the key rather than reading auth.json. pi owns credential
103
+ * storage — env precedence, OAuth, whatever it grows next — and parsing
104
+ * that file here meant handling secrets this package has no business
105
+ * touching, with a copy of pi's rules that would quietly go stale.
106
+ */
107
+ async function providerKey(ctx: UiContext, provider: string): Promise<string> {
108
+ const registry = ctx.modelRegistry as unknown as {
109
+ getApiKeyForProvider?: (id: string) => Promise<string | undefined>;
110
+ getProviderAuth?: (id: string) => Promise<{ auth?: { apiKey?: string } } | undefined>;
111
+ };
101
112
  try {
102
- const auth = JSON.parse(readFileSync(join(getAgentDir(), "auth.json"), "utf8")) as Record<string, unknown>;
103
- const entry = auth[provider];
104
- if (isRecord(entry) && typeof entry.key === "string") return entry.key;
113
+ const direct = await registry.getApiKeyForProvider?.(provider);
114
+ if (direct) return direct;
115
+ const auth = await registry.getProviderAuth?.(provider);
116
+ if (auth?.auth?.apiKey) return auth.auth.apiKey;
105
117
  } catch {
106
- // no auth.json, or unreadable fall through to the environment
118
+ // an unconfigured provider is not an error here
107
119
  }
108
- return process.env.OPENROUTER_API_KEY ?? "";
120
+ return "";
109
121
  }
110
122
 
111
123
  /**
@@ -161,10 +173,24 @@ export default function usage(pi: ExtensionAPI) {
161
173
  handler: async (args, ctx) => {
162
174
  if (!ctx.hasUI) return;
163
175
  if ((args ?? "").trim().toLowerCase() === "quota") {
164
- // The one networked call in this package, and only when asked for.
165
- ctx.ui.notify("Checking provider quota…", "info");
166
- const result = await fetchOpenRouterQuota(providerKey("openrouter"));
167
- ctx.ui.notify(quotaBlock(result), result.ok ? "info" : "warning");
176
+ // The one networked path in this package, and only when asked for.
177
+ // Providers with no key are skipped entirely rather than reported as
178
+ // broken: an unconfigured provider is not a failure.
179
+ const configured: Array<{ provider: (typeof QUOTA_PROVIDERS)[number]; key: string }> = [];
180
+ for (const provider of QUOTA_PROVIDERS) {
181
+ const key = await providerKey(ctx, provider.id);
182
+ if (key) configured.push({ provider, key });
183
+ }
184
+ if (configured.length === 0) {
185
+ ctx.ui.notify(quotaReport([]), "info");
186
+ return;
187
+ }
188
+ ctx.ui.notify(`Checking quota for ${configured.map((c) => c.provider.displayName).join(", ")}…`, "info");
189
+ const results: QuotaResult[] = [];
190
+ for (const { provider, key } of configured) {
191
+ results.push(await fetchQuota(provider, key));
192
+ }
193
+ ctx.ui.notify(redact(quotaReport(results)), results.every((r) => r.ok) ? "info" : "warning");
168
194
  return;
169
195
  }
170
196
  ctx.ui.notify(dashboard(ctx), "info");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/usage",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Token and cost reporting for pi sessions: live footer, local-history dashboard with per-project costs, opt-in provider quota",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/http.ts ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * The one networked path in this package, made boring on purpose.
3
+ *
4
+ * A quota call carries the user's provider key in an Authorization header, so
5
+ * the request has to be pinned down rather than merely aimed at the right URL:
6
+ *
7
+ * - HTTPS only, and the host must be on the caller's allowlist. A typo or a
8
+ * future edit cannot point a credentialed request somewhere new.
9
+ * - Redirects are refused outright (`redirect: "error"`). Following one means
10
+ * a provider — or anything that can answer as one — chooses where the next
11
+ * request goes, with the header already attached.
12
+ * - A non-2xx body is never read. Error bodies echo request details back, and
13
+ * an echoed Authorization header pasted into a notification is exactly the
14
+ * leak this package must not cause. The status alone is the message.
15
+ * - Everything is classified into a small set of reasons, so the caller can
16
+ * say something useful without carrying provider text around.
17
+ *
18
+ * `fetchImpl` is injectable so tests never touch the network.
19
+ *
20
+ * (Structure and the no-body-on-error rule are from imdlan/pi-usage.)
21
+ */
22
+
23
+ export type HttpFailureKind =
24
+ | "unsafe-url"
25
+ | "not-allowed"
26
+ | "auth"
27
+ | "forbidden"
28
+ | "rate-limited"
29
+ | "server"
30
+ | "http"
31
+ | "timeout"
32
+ | "network"
33
+ | "invalid-json";
34
+
35
+ export interface HttpFailure {
36
+ ok: false;
37
+ kind: HttpFailureKind;
38
+ reason: string;
39
+ status?: number;
40
+ }
41
+
42
+ export interface HttpSuccess<T> {
43
+ ok: true;
44
+ status: number;
45
+ data: T;
46
+ }
47
+
48
+ export type HttpResult<T> = HttpSuccess<T> | HttpFailure;
49
+
50
+ export interface ControlledGetOptions {
51
+ url: string;
52
+ headers?: Record<string, string>;
53
+ timeoutMs: number;
54
+ allowlist: readonly string[];
55
+ fetchImpl?: typeof fetch;
56
+ signal?: AbortSignal;
57
+ }
58
+
59
+ const TIMEOUT_SENTINEL = "pify-usage-timeout";
60
+
61
+ function fail(kind: HttpFailureKind, reason: string, status?: number): HttpFailure {
62
+ return { ok: false, kind, reason, ...(status === undefined ? {} : { status }) };
63
+ }
64
+
65
+ /** Reject anything that is not an allowlisted HTTPS host before dialling. */
66
+ export function checkUrl(url: string, allowlist: readonly string[]): HttpFailure | null {
67
+ let parsed: URL;
68
+ try {
69
+ parsed = new URL(url);
70
+ } catch {
71
+ return fail("unsafe-url", "invalid url");
72
+ }
73
+ if (parsed.protocol !== "https:") return fail("unsafe-url", "refused a non-HTTPS url");
74
+ if (!allowlist.includes(parsed.hostname)) {
75
+ return fail("not-allowed", `host ${parsed.hostname} is not on the allowlist`);
76
+ }
77
+ return null;
78
+ }
79
+
80
+ export function classifyStatus(status: number): HttpFailure {
81
+ if (status === 401) return fail("auth", "the provider rejected the key", status);
82
+ if (status === 403) return fail("forbidden", "the key is not allowed to read usage", status);
83
+ if (status === 429) return fail("rate-limited", "rate limited by the provider", status);
84
+ if (status >= 500) return fail("server", "the provider is having trouble", status);
85
+ return fail("http", `HTTP ${status}`, status);
86
+ }
87
+
88
+ /** GET JSON from an allowlisted host. Never throws; failure is a value. */
89
+ export async function controlledGetJson<T = unknown>(opts: ControlledGetOptions): Promise<HttpResult<T>> {
90
+ const urlProblem = checkUrl(opts.url, opts.allowlist);
91
+ if (urlProblem) return urlProblem;
92
+
93
+ const controller = new AbortController();
94
+ const timer = setTimeout(() => controller.abort(new Error(TIMEOUT_SENTINEL)), opts.timeoutMs);
95
+ if (opts.signal) {
96
+ if (opts.signal.aborted) controller.abort();
97
+ else opts.signal.addEventListener("abort", () => controller.abort(), { once: true });
98
+ }
99
+ const doFetch = opts.fetchImpl ?? (globalThis.fetch as typeof fetch);
100
+
101
+ let response: Response;
102
+ try {
103
+ response = await doFetch(opts.url, {
104
+ method: "GET",
105
+ headers: opts.headers ?? {},
106
+ signal: controller.signal,
107
+ redirect: "error",
108
+ });
109
+ } catch (err) {
110
+ if (controller.signal.aborted) {
111
+ const timedOut = String(controller.signal.reason ?? "").includes(TIMEOUT_SENTINEL);
112
+ return timedOut ? fail("timeout", "the provider did not answer in time") : fail("network", "request aborted");
113
+ }
114
+ const message = String((err as { message?: unknown })?.message ?? err).toLowerCase();
115
+ if (message.includes("redirect")) return fail("unsafe-url", "the provider tried to redirect us");
116
+ if (message.includes("timeout") || message.includes("timed out")) {
117
+ return fail("timeout", "the provider did not answer in time");
118
+ }
119
+ // The raw error is deliberately dropped: it can carry the request, and the
120
+ // request carries the key.
121
+ return fail("network", "could not reach the provider");
122
+ } finally {
123
+ clearTimeout(timer);
124
+ }
125
+
126
+ if (response.status >= 200 && response.status < 300) {
127
+ try {
128
+ return { ok: true, status: response.status, data: (await response.json()) as T };
129
+ } catch {
130
+ return fail("invalid-json", "the provider sent something that is not JSON", response.status);
131
+ }
132
+ }
133
+ return classifyStatus(response.status);
134
+ }
package/src/quota.ts CHANGED
@@ -1,20 +1,25 @@
1
1
  /**
2
- * Provider quota (v0.3). Everything else in this package is computed from
3
- * local files this is the one place that talks to a network, so it is
4
- * opt-in per call, short-timeout, and never blocks the dashboard: a provider
5
- * that is slow or down shows as unavailable next to the local numbers.
2
+ * Provider quota. Everything else in this package is computed from local
3
+ * files; this is the one place that talks to a network, so it is opt-in per
4
+ * call, short-timeout, pinned to allowlisted HTTPS hosts, and never blocks the
5
+ * dashboard: a provider that is slow or down shows as unavailable next to the
6
+ * local numbers.
6
7
  *
7
- * Only OpenRouter is implemented. @narumitw/pi-usage shows what the full set
8
- * costs roughly 18k lines of per-provider contract chasing and OpenRouter
9
- * is the one endpoint that reports a real balance rather than an opaque
10
- * rate-limit window.
8
+ * Documented endpoints only. OpenRouter's `/api/v1/key` and DeepSeek's
9
+ * `/user/balance` are both published APIs that report a real balance. The
10
+ * subscription-quota endpoints some plugins use for OpenAI, Anthropic and
11
+ * Gemini are undocumented private APIs reverse-engineered from vendor CLIs;
12
+ * they break without notice and were never offered to third parties, so this
13
+ * package does not call them. (That line is imdlan/pi-usage's, and it is a
14
+ * better reason than the maintenance cost this package cited before.)
11
15
  */
12
16
 
17
+ import { controlledGetJson, type HttpResult } from "./http.ts";
13
18
  import { finite, isRecord } from "./types.ts";
14
19
 
15
20
  export interface QuotaInfo {
16
21
  provider: string;
17
- /** Spend on this key, in USD, as the provider reports it. */
22
+ /** Spend on this key, in the provider's currency, as it reports it. */
18
23
  used: number | null;
19
24
  /** Hard credit limit, when the key has one. */
20
25
  limit: number | null;
@@ -25,6 +30,8 @@ export interface QuotaInfo {
25
30
  monthly: number | null;
26
31
  label: string | null;
27
32
  freeTier: boolean | null;
33
+ /** Currency for the amounts above; USD unless the provider says otherwise. */
34
+ currency: string;
28
35
  }
29
36
 
30
37
  export type QuotaResult =
@@ -37,6 +44,29 @@ function num(value: unknown): number | null {
37
44
  return typeof value === "number" && Number.isFinite(value) ? value : null;
38
45
  }
39
46
 
47
+ /** "110.00" — DeepSeek reports money as strings. */
48
+ function numeric(value: unknown): number | null {
49
+ if (typeof value === "number") return Number.isFinite(value) ? value : null;
50
+ if (typeof value !== "string" || !value.trim()) return null;
51
+ const parsed = Number.parseFloat(value);
52
+ return Number.isFinite(parsed) ? parsed : null;
53
+ }
54
+
55
+ function emptyQuota(provider: string): QuotaInfo {
56
+ return {
57
+ provider,
58
+ used: null,
59
+ limit: null,
60
+ remaining: null,
61
+ daily: null,
62
+ weekly: null,
63
+ monthly: null,
64
+ label: null,
65
+ freeTier: null,
66
+ currency: "USD",
67
+ };
68
+ }
69
+
40
70
  /**
41
71
  * Shape the /api/v1/key payload. OpenRouter reports `limit: null` for keys
42
72
  * with no cap, so "no limit" and "limit of zero" must not collapse together.
@@ -53,7 +83,7 @@ export function parseOpenRouterKey(payload: unknown): QuotaInfo | null {
53
83
  const remaining = num(data.limit_remaining) ?? (limit !== null && used !== null ? limit - used : null);
54
84
 
55
85
  return {
56
- provider: "openrouter",
86
+ ...emptyQuota("openrouter"),
57
87
  used: used === null ? null : finite(used),
58
88
  limit,
59
89
  remaining,
@@ -65,60 +95,134 @@ export function parseOpenRouterKey(payload: unknown): QuotaInfo | null {
65
95
  };
66
96
  }
67
97
 
68
- export type Fetcher = (url: string, init: { headers: Record<string, string>; signal: AbortSignal }) => Promise<{
69
- ok: boolean;
70
- status: number;
71
- json(): Promise<unknown>;
72
- }>;
98
+ /**
99
+ * DeepSeek's documented `/user/balance`: a list of per-currency balances. The
100
+ * first available one is reported; a key with no available balance is still a
101
+ * successful answer, showing zero rather than an error.
102
+ */
103
+ export function parseDeepSeekBalance(payload: unknown): QuotaInfo | null {
104
+ if (!isRecord(payload)) return null;
105
+ const infos = payload.balance_infos;
106
+ if (!Array.isArray(infos)) return null;
107
+
108
+ const first = infos.find((entry) => isRecord(entry) && numeric(entry.total_balance) !== null);
109
+ if (!isRecord(first)) {
110
+ return { ...emptyQuota("deepseek"), remaining: 0 };
111
+ }
112
+ const total = numeric(first.total_balance);
113
+ const granted = numeric(first.granted_balance);
114
+ const topped = numeric(first.topped_up_balance);
115
+ const parts = [
116
+ granted === null ? null : `granted ${granted}`,
117
+ topped === null ? null : `topped up ${topped}`,
118
+ ].filter((part): part is string => part !== null);
119
+
120
+ return {
121
+ ...emptyQuota("deepseek"),
122
+ remaining: total,
123
+ currency: typeof first.currency === "string" ? first.currency : "USD",
124
+ label: parts.length > 0 ? parts.join(" · ") : null,
125
+ freeTier: payload.is_available === false ? null : null,
126
+ };
127
+ }
73
128
 
74
- /** Fetch the OpenRouter key status. Never throws — failure is a result. */
75
- export async function fetchOpenRouterQuota(
129
+ export interface QuotaProvider {
130
+ /** pi's provider id, which is also how the key is looked up. */
131
+ id: string;
132
+ displayName: string;
133
+ url: string;
134
+ allowlist: readonly string[];
135
+ parse(payload: unknown): QuotaInfo | null;
136
+ }
137
+
138
+ export const QUOTA_PROVIDERS: readonly QuotaProvider[] = [
139
+ {
140
+ id: "openrouter",
141
+ displayName: "OpenRouter",
142
+ url: "https://openrouter.ai/api/v1/key",
143
+ allowlist: ["openrouter.ai"],
144
+ parse: parseOpenRouterKey,
145
+ },
146
+ {
147
+ id: "deepseek",
148
+ displayName: "DeepSeek",
149
+ url: "https://api.deepseek.com/user/balance",
150
+ allowlist: ["api.deepseek.com"],
151
+ parse: parseDeepSeekBalance,
152
+ },
153
+ ];
154
+
155
+ export function findProvider(id: string): QuotaProvider | undefined {
156
+ return QUOTA_PROVIDERS.find((provider) => provider.id === id.trim().toLowerCase());
157
+ }
158
+
159
+ /**
160
+ * Query one provider. Never throws — failure is a result, and its reason is
161
+ * this package's own wording, never the provider's response text.
162
+ */
163
+ export async function fetchQuota(
164
+ provider: QuotaProvider,
76
165
  apiKey: string,
77
- fetcher: Fetcher = globalThis.fetch as unknown as Fetcher,
166
+ fetchImpl?: typeof fetch,
78
167
  timeoutMs = QUOTA_TIMEOUT_MS,
79
168
  ): Promise<QuotaResult> {
80
- if (!apiKey.trim()) return { ok: false, provider: "openrouter", reason: "no API key configured" };
81
- try {
82
- const response = await fetcher("https://openrouter.ai/api/v1/key", {
83
- headers: { Authorization: `Bearer ${apiKey.trim()}` },
84
- signal: AbortSignal.timeout(timeoutMs),
85
- });
86
- if (!response.ok) {
87
- return { ok: false, provider: "openrouter", reason: `HTTP ${response.status}` };
88
- }
89
- const quota = parseOpenRouterKey(await response.json());
90
- if (!quota) return { ok: false, provider: "openrouter", reason: "unexpected response shape" };
91
- return { ok: true, quota };
92
- } catch (err) {
93
- return {
94
- ok: false,
95
- provider: "openrouter",
96
- reason: err instanceof Error ? err.message : String(err),
97
- };
98
- }
169
+ if (!apiKey.trim()) return { ok: false, provider: provider.id, reason: "no API key configured" };
170
+
171
+ const result: HttpResult<unknown> = await controlledGetJson({
172
+ url: provider.url,
173
+ headers: { Authorization: `Bearer ${apiKey.trim()}`, Accept: "application/json" },
174
+ timeoutMs,
175
+ allowlist: provider.allowlist,
176
+ fetchImpl,
177
+ });
178
+ if (!result.ok) return { ok: false, provider: provider.id, reason: result.reason };
179
+
180
+ const quota = provider.parse(result.data);
181
+ if (!quota) return { ok: false, provider: provider.id, reason: "the response did not match the documented shape" };
182
+ return { ok: true, quota };
99
183
  }
100
184
 
101
- function money(value: number | null): string {
185
+ function money(value: number | null, currency = "USD"): string {
102
186
  if (value === null) return "—";
103
- if (value === 0) return "$0";
104
- if (Math.abs(value) < 0.01) return "<$0.01";
105
- return `$${value.toFixed(2)}`;
187
+ const symbol = currency === "USD" ? "$" : `${currency} `;
188
+ if (value === 0) return `${symbol}0`;
189
+ if (Math.abs(value) < 0.01) return `<${symbol}0.01`;
190
+ return `${symbol}${value.toFixed(2)}`;
106
191
  }
107
192
 
108
193
  export function quotaBlock(result: QuotaResult): string {
109
- if (!result.ok) {
110
- return `Quota (${result.provider})\n unavailable ${result.reason}`;
111
- }
194
+ const id = result.ok ? result.quota.provider : result.provider;
195
+ const name = findProvider(id)?.displayName ?? id;
196
+ if (!result.ok) return `Quota (${name})\n unavailable — ${result.reason}`;
197
+
112
198
  const q = result.quota;
113
- const lines = [`Quota (${q.provider}${q.label ? ` · ${q.label}` : ""})`];
114
- lines.push(
115
- q.limit === null
116
- ? ` spent ${money(q.used)} (no credit limit on this key)`
117
- : ` spent ${money(q.used)} of ${money(q.limit)} · ${money(q.remaining)} left`,
118
- );
199
+ const lines = [`Quota (${name}${q.label ? ` · ${q.label}` : ""})`];
200
+ if (q.used !== null || q.limit !== null) {
201
+ lines.push(
202
+ q.limit === null
203
+ ? ` spent ${money(q.used, q.currency)} (no credit limit on this key)`
204
+ : ` spent ${money(q.used, q.currency)} of ${money(q.limit, q.currency)} · ${money(q.remaining, q.currency)} left`,
205
+ );
206
+ } else if (q.remaining !== null) {
207
+ lines.push(` balance ${money(q.remaining, q.currency)}`);
208
+ }
119
209
  if (q.daily !== null || q.weekly !== null || q.monthly !== null) {
120
- lines.push(` window day ${money(q.daily)} · week ${money(q.weekly)} · month ${money(q.monthly)}`);
210
+ lines.push(
211
+ ` window day ${money(q.daily, q.currency)} · week ${money(q.weekly, q.currency)} · month ${money(q.monthly, q.currency)}`,
212
+ );
121
213
  }
122
214
  if (q.freeTier) lines.push(" tier free");
123
215
  return lines.join("\n");
124
216
  }
217
+
218
+ /** What `/usage quota` prints: every provider that has a key, and nothing else. */
219
+ export function quotaReport(results: QuotaResult[]): string {
220
+ if (results.length === 0) {
221
+ return [
222
+ "No provider with a quota endpoint is configured.",
223
+ `Supported: ${QUOTA_PROVIDERS.map((p) => p.displayName).join(", ")}.`,
224
+ "Only documented endpoints are used — OpenAI, Anthropic and Gemini publish none for subscription quota.",
225
+ ].join("\n");
226
+ }
227
+ return results.map(quotaBlock).join("\n\n");
228
+ }
package/src/redact.ts ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Defence in depth for anything this package prints.
3
+ *
4
+ * Nothing here is supposed to see a secret: keys come from pi's own auth
5
+ * resolution and go straight into a header, error bodies are never read, and
6
+ * raw exception text is dropped rather than shown. This scrubber exists for
7
+ * the case where one of those is wrong — a key in a message costs the user a
8
+ * rotation, and the cost of running a regex over a notification is nothing.
9
+ *
10
+ * The patterns describe what to REMOVE. They are never used to find or store
11
+ * a secret. (Idea from imdlan/pi-usage.)
12
+ */
13
+
14
+ const REDACTED = "[redacted]";
15
+
16
+ const RULES: ReadonlyArray<{ re: RegExp; replacement: string }> = [
17
+ // Provider key shapes, longest-prefix first.
18
+ { re: /\bsk-or-v1-[A-Za-z0-9]{16,}/g, replacement: REDACTED },
19
+ { re: /\bsk-ant-[A-Za-z0-9_-]{16,}/g, replacement: REDACTED },
20
+ { re: /\bsk-[A-Za-z0-9_-]{16,}/g, replacement: REDACTED },
21
+ { re: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}/g, replacement: REDACTED },
22
+ { re: /\bAIza[0-9A-Za-z_-]{20,}/g, replacement: REDACTED },
23
+ { re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, replacement: REDACTED },
24
+ { re: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, replacement: REDACTED },
25
+ // Headers and assignments that name a secret, whatever the value looks like.
26
+ // To end of line, not to the first space: "Bearer <token>" is two words and
27
+ // stopping early leaves exactly the half that matters.
28
+ { re: /\b(?:proxy-)?authorization\s*[:=]\s*[^\r\n;,]+/gi, replacement: `authorization: ${REDACTED}` },
29
+ {
30
+ re: /\b(api[_-]?key|access[_-]?token|auth[_-]?token|secret|password)\b\s*[:=]\s*["']?[^\s"',;]+/gi,
31
+ replacement: `$1=${REDACTED}`,
32
+ },
33
+ // A URL's query string can carry a token; the path is enough to identify it.
34
+ { re: /(https?:\/\/[^\s?]+)\?[^\s]*/gi, replacement: "$1?[redacted]" },
35
+ ];
36
+
37
+ export function redact(text: string): string {
38
+ let out = text ?? "";
39
+ for (const { re, replacement } of RULES) out = out.replace(re, replacement);
40
+ return out;
41
+ }