@pify/usage 0.13.0 → 0.14.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
@@ -20,10 +20,21 @@ Folded from each message's `usage.cost`, which pi already computes. It survives
20
20
 
21
21
  When [`@pify/subagent`](https://github.com/pifydev/subagent), [`@pify/swarm`](https://github.com/pifydev/swarm) or [`@pify/workflow`](https://github.com/pifydev/workflow) run child agents, those children are separate in-memory sessions whose spend never reaches this branch — a session that delegates used to look cheaper than it was. Those packages now add every child message's cost to a process-wide tally, and the footer shows it as a separate ` · agents $0.12` beside the session's own figure (the `/usage` session block has the same line, with tokens). It is kept apart on purpose: the session numbers stay what the branch says, and the agents figure is what the delegation cost on top. The tally survives `/reload` and starts over with a new session.
22
22
 
23
- On a Claude subscription (OAuth Pro/Max) the footer also carries the wall you are approaching: the headers `/usage quota` reads were already captured on every response, but only that command showed them, so the weekly or 5-hour limit used to arrive as a stalled turn. Now a `⚠ 7d 8% · resets 14:05` segment appears once the binding window is below 15%, or whenever Anthropic marks the subscription as anything but allowed, and stays out of the way otherwise. Other providers carry no such headers, so they never show it.
23
+ On a Claude subscription (OAuth Pro/Max) the footer also carries the wall you are approaching: the headers `/usage quota` reads were already captured on every response, but only that command showed them, so the weekly or 5-hour limit used to arrive as a stalled turn. Now a `⚠ 7d 8% · resets 14:05` segment appears once the binding window is below 15%, or whenever Anthropic marks the subscription as anything but allowed, and stays out of the way otherwise. This passive warning uses Anthropic's own response headers. Copilot and Codex subscription quotas are fetched explicitly, as described below.
24
24
 
25
25
  The gauge on the right is how full the context window is — the third mid-session question, next to tokens and cost. It was computed for the `/usage` dashboard but shown only there; now it is live. It appears once there is a window to measure against (so not under `-p`) and turns to `⚠` past 90%, the point where "how full" stops being trivia and becomes a decision. For the breakdown of what filled it, run `/context`.
26
26
 
27
+ After `/usage quota`, the active Copilot or Codex provider also shows its cached remaining quota, before the session figures so compact status lines keep it visible:
28
+
29
+ ```
30
+ Copilot premium 49% left · cached · 📊 12.3k tok · $0.45
31
+ Codex 5h 75% left · 7d 40% left · cached · 📊 12.3k tok · $0.45
32
+ ```
33
+
34
+ There is no background polling. Values live only in memory, are cleared on session start/reload, and are marked `stale` on the next footer redraw after fifteen minutes or a reported reset. Run the command again to refresh; the dashboard gives reset times in UTC. Missing or rejected data is not shown as zero remaining. Only the active provider's quota is displayed when switching models.
35
+
36
+ The status key is namespaced as `pify-usage`. `/pify-usage` is an alias for every `/usage` subcommand, so a second extension registering `/usage` need not make this dashboard inaccessible. Pi disambiguates conflicting commands as `/usage:1`, `/usage:2`, etc.; bare `/usage` is unavailable in that case. `/pify-usage` stays unambiguous, while `/usage` remains backward compatible when this is the only usage extension.
37
+
27
38
  ## `/usage`
28
39
 
29
40
  ```
@@ -101,7 +112,11 @@ Quota (DeepSeek · granted 10 · topped up 100)
101
112
  balance CNY 110.00
102
113
  ```
103
114
 
104
- **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 available for some other providers 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. A provider you have not configured is simply not shown; that is not a failure.
115
+ **Balances and subscription windows are different.** OpenRouter's `/api/v1/key` and DeepSeek's `/user/balance` are published balance APIs. Copilot premium interactions (or AI credits when token-based billing is reported) and Codex's primary/secondary windows are percentages **remaining**, not money or per-request rate limits. Unlimited quota is labelled explicitly. Only the active model's provider is checked; unused configured keys (for example an OpenRouter key) are not inspected or queried. Missing or unsupported credentials for that active provider produce an explicit unavailable reason.
116
+
117
+ Copilot uses `https://api.github.com/copilot_internal/user`, published as `entitlementUrl` in [Microsoft's contributor documentation](https://github.com/microsoft/vscode-copilot-chat/blob/main/CONTRIBUTING.md). Codex uses `https://chatgpt.com/backend-api/wham/usage`, as implemented in [OpenAI's client](https://github.com/openai/codex/blob/main/codex-rs/backend-client/src/client.rs). OpenAI separately documents the [app-server `account/rateLimits/read` interface](https://developers.openai.com/codex/app-server/#auth-endpoints); that is not a versioned contract for the direct HTTP route. These published client endpoints may change without notice. An unrecognized response renders as unavailable rather than guessing a balance.
118
+
119
+ `/usage quota` (or `/pify-usage quota`) is explicit consent for one quota request to the active provider's endpoint, with no recurring refresh. Sessions, model changes, `usage_status`, and footer redraws do not make quota requests.
105
120
 
106
121
  **Rate limits, for free.** OpenAI, Anthropic and Gemini publish no balance API, but every completion response carries rate-limit headers, and pi hands them to extensions through `after_provider_response` — before the stream is read, at no cost when unsubscribed. So `/usage quota` also shows what the current provider reported on its last call — requests and tokens left in the window, and for an Anthropic OAuth (Pro/Max) subscription the two unified windows it actually reports: the fraction of the 5-hour and 7-day windows still free (from the `-5h-utilization` / `-7d-utilization` headers, which state the fraction *used*), which of the two is currently binding, an `allowed_warning` / `rejected` status when the window is under pressure, and the reset time. All captured passively from calls the session already made, with no extra request and no credential to handle.
107
122
 
@@ -111,9 +126,9 @@ Quota (DeepSeek · granted 10 · topped up 100)
111
126
  - Redirects are refused outright. Following one lets whatever answered choose where the next request goes, with the header already attached.
112
127
  - 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.
113
128
  - Raw exception text is dropped rather than shown, and everything printed passes a redactor as a last line of defence.
114
- - An 8-second timeout per provider; any failure renders as `unavailable — the provider rejected the key` rather than throwing.
129
+ - An 8-second timeout per quota HTTP request; any failure renders as unavailable rather than throwing. Pi's own OAuth refresh may take additional time.
115
130
 
116
- **The key comes from pi**, resolved through `modelRegistry` rather than by reading `auth.json`. pi owns credential storage — env precedence, OAuth, whatever it grows next — and parsing that file here would mean handling secrets this package has no business touching, using a stale copy of pi's rules. Only the provider's own masked label is ever printed.
131
+ **Credentials come from pi's public APIs**, not a second credential store or a package-owned `auth.json` parser. Balances use `modelRegistry` resolution. Both subscription endpoints require Pi OAuth login: Copilot uses the original GitHub login token obtained with Pi's `readStoredCredential` API, **never** the Copilot inference token. The token and endpoint are resolved together: public GitHub uses `api.github.com`, and GitHub Enterprise Cloud data-residency logins on `<tenant>.ghe.com` use only `api.<tenant>.ghe.com`. The allowlist contains that matching API host alone; Enterprise credentials never fall back to public GitHub. Other/custom Enterprise authorities are reported as unsupported without sending a request; Codex resolves its OAuth access token through Pi, which may refresh and persist it. Ordinary API keys are not sent to these subscription endpoints. The Codex account-id header is extracted locally from the access token's account-id claim; no other JWT claims or inference headers are forwarded. Credentials are never printed or persisted by this package, and no `gh` subprocess is involved.
117
132
 
118
133
  ## Tool
119
134
 
@@ -10,16 +10,14 @@
10
10
  * tmustier's lesson) with a per-file mtime cache. The usage_status tool
11
11
  * lets the agent itself check consumption mid-session.
12
12
  *
13
- * Everything above is local: no network, no LLM tokens. The single exception
14
- * is /usage quota (v0.3), which asks OpenRouter what this key has spent —
15
- * opt-in per call, 8s timeout, and a failure prints as "unavailable" beside
16
- * the local numbers. Other providers stay out: @narumitw/pi-usage shows the
17
- * full set costs ~18k lines of per-provider contract chasing, and OpenRouter
18
- * is the one that reports a real balance rather than an opaque window.
13
+ * Everything above is local: no network, no LLM tokens. The exception is
14
+ * /usage quota: opt-in balance/subscription requests, 8s timeout per provider.
15
+ * The footer shows cached Copilot/Codex percentages, never background polling.
19
16
  */
20
17
  import {
21
18
  formatSkillsForPrompt,
22
19
  getAgentDir,
20
+ readStoredCredential,
23
21
  SettingsManager,
24
22
  type ExtensionAPI,
25
23
  type ExtensionContext,
@@ -36,7 +34,8 @@ import { buildByDayCsv, buildByModelCsv, buildByProjectCsv, buildTotalsJson } fr
36
34
  import { emptyCacheHealth, observeCacheHealth } from "../src/cache-health.ts";
37
35
  import { mkdirSync, writeFileSync } from "node:fs";
38
36
  import { tmpdir } from "node:os";
39
- import { QUOTA_PROVIDERS, fetchQuota, quotaReport, type QuotaResult } from "../src/quota.ts";
37
+ import { QUOTA_PROVIDERS, fetchQuota, quotaReport, quotaFooter, type QuotaResult } from "../src/quota.ts";
38
+ import { resolveQuotaAuth } from "../src/quota-auth.ts";
40
39
  import { redact } from "../src/redact.ts";
41
40
  import { scanSessions, persistScanCache, restoreScanCache } from "../src/sessions.ts";
42
41
  import { parseRateLimit, formatRateLimit, type RateLimitSnapshot, footerQuotaSegment } from "../src/ratelimit.ts";
@@ -58,6 +57,7 @@ export default function usage(pi: ExtensionAPI) {
58
57
  * free from responses the session already made, no extra network call.
59
58
  */
60
59
  const rateLimits = new Map<string, RateLimitSnapshot>();
60
+ const quotas = new Map<string, { result: QuotaResult; fetchedAt: number }>();
61
61
 
62
62
  // after_provider_response carries the status and full headers of every
63
63
  // provider call, before the stream is read; gated on hasHandlers, so
@@ -82,15 +82,14 @@ export default function usage(pi: ExtensionAPI) {
82
82
  function updateFooter(ctx: UiContext): void {
83
83
  if (!ctx.hasUI) return;
84
84
  const base = footerText(session, childSpendTotal());
85
- if (!base) {
86
- ctx.ui.setStatus("usage", undefined);
87
- return;
88
- }
89
- const gauge = contextGauge(contextInfo(ctx).pct);
90
- // The subscription wall, from the headers already captured for this
91
- // provider: quiet while healthy, a short warning once it is close.
92
- const quota = footerQuotaSegment(rateLimits.get((ctx.model as { provider?: string } | null)?.provider ?? ""));
93
- ctx.ui.setStatus("usage", [base, gauge, quota].filter(Boolean).join(" · "));
85
+ const gauge = base ? contextGauge(contextInfo(ctx).pct) : "";
86
+ const provider = ctx.model?.provider ?? "";
87
+ const cached = quotas.get(provider);
88
+ const subscription = quotaFooter(cached?.result, cached?.fetchedAt ?? 0);
89
+ const passive = footerQuotaSegment(rateLimits.get(provider));
90
+ // Quota first: compact footers may truncate the rest. Namespace our status
91
+ // so another usage extension cannot overwrite or clear it.
92
+ ctx.ui.setStatus("pify-usage", [subscription, base, gauge, passive].filter(Boolean).join(" · ") || undefined);
94
93
  }
95
94
 
96
95
  /**
@@ -158,7 +157,10 @@ export default function usage(pi: ExtensionAPI) {
158
157
  updateFooter(ctx);
159
158
  });
160
159
 
160
+ pi.on("model_select", async (_event, ctx) => updateFooter(ctx));
161
+
161
162
  pi.on("session_start", async (event, ctx) => {
163
+ quotas.clear();
162
164
  // Rebuild the session totals from the branch so /reload keeps the count.
163
165
  session = emptyTotals();
164
166
  lastPromptTokens = 0;
@@ -212,37 +214,12 @@ export default function usage(pi: ExtensionAPI) {
212
214
  stopChildSpend?.();
213
215
  stopChildSpend = null;
214
216
  footerCtx = null;
215
- if (ctx.hasUI) ctx.ui.setStatus("usage", undefined);
217
+ quotas.clear();
218
+ if (ctx.hasUI) ctx.ui.setStatus("pify-usage", undefined);
216
219
  });
217
220
 
218
221
  // ── Command & tool ───────────────────────────────────────────────────
219
222
 
220
- /**
221
- * The key pi itself uses, read from the same auth.json — no second place to
222
- * configure credentials, and no key is ever printed.
223
- */
224
- /**
225
- * Ask pi for the key rather than reading auth.json. pi owns credential
226
- * storage — env precedence, OAuth, whatever it grows next — and parsing
227
- * that file here meant handling secrets this package has no business
228
- * touching, with a copy of pi's rules that would quietly go stale.
229
- */
230
- async function providerKey(ctx: UiContext, provider: string): Promise<string> {
231
- const registry = ctx.modelRegistry as unknown as {
232
- getApiKeyForProvider?: (id: string) => Promise<string | undefined>;
233
- getProviderAuth?: (id: string) => Promise<{ auth?: { apiKey?: string } } | undefined>;
234
- };
235
- try {
236
- const direct = await registry.getApiKeyForProvider?.(provider);
237
- if (direct) return direct;
238
- const auth = await registry.getProviderAuth?.(provider);
239
- if (auth?.auth?.apiKey) return auth.auth.apiKey;
240
- } catch {
241
- // an unconfigured provider is not an error here
242
- }
243
- return "";
244
- }
245
-
246
223
  /**
247
224
  * Where the context window went, computed from what pi already holds:
248
225
  * the assembled system prompt, the context files and skills embedded in
@@ -323,7 +300,7 @@ export default function usage(pi: ExtensionAPI) {
323
300
  return taxLines.length > 0 ? `${formatBreakdown(breakdown, reported)}\n\n${taxLines.join("\n")}` : formatBreakdown(breakdown, reported);
324
301
  }
325
302
 
326
- pi.registerCommand("usage", {
303
+ const usageCommand: Parameters<ExtensionAPI["registerCommand"]>[1] = {
327
304
  description: "Token and cost dashboard: /usage [context | quota | export]",
328
305
  handler: async (args, ctx) => {
329
306
  if (!ctx.hasUI) return;
@@ -360,39 +337,34 @@ export default function usage(pi: ExtensionAPI) {
360
337
  return;
361
338
  }
362
339
  if ((args ?? "").trim().toLowerCase() === "quota") {
363
- // The one networked path in this package, and only when asked for.
364
- // Providers with no key are skipped entirely rather than reported as
365
- // broken: an unconfigured provider is not a failure.
366
- const configured: Array<{ provider: (typeof QUOTA_PROVIDERS)[number]; key: string }> = [];
367
- for (const provider of QUOTA_PROVIDERS) {
368
- const key = await providerKey(ctx, provider.id);
369
- if (key) configured.push({ provider, key });
370
- }
371
- if (configured.length === 0) {
372
- const passive = [...rateLimits.values()]
373
- .map((s) => formatRateLimit(s))
374
- .filter((line): line is string => line !== null);
375
- ctx.ui.notify(
376
- passive.length > 0 ? redact(`${passive.join("\n")}\n\n${quotaReport([])}`) : quotaReport([]),
377
- "info",
378
- );
340
+ // Only the active provider: a configured balance key does not mean it is in use.
341
+ const id = ctx.model?.provider ?? "";
342
+ const provider = QUOTA_PROVIDERS.find(p => p.id === id);
343
+ const snapshot = rateLimits.get(id);
344
+ const passive = snapshot ? formatRateLimit(snapshot) : null;
345
+ if (!provider) {
346
+ updateFooter(ctx);
347
+ ctx.ui.notify(redact(passive ?? "No quota endpoint is available for the active provider."), "info");
379
348
  return;
380
349
  }
381
- ctx.ui.notify(`Checking quota for ${configured.map((c) => c.provider.displayName).join(", ")}…`, "info");
382
- const results: QuotaResult[] = [];
383
- for (const { provider, key } of configured) {
384
- results.push(await fetchQuota(provider, key));
350
+ const auth = await resolveQuotaAuth(provider, ctx.modelRegistry, readStoredCredential);
351
+ let result: QuotaResult;
352
+ if (auth.ok) {
353
+ ctx.ui.notify(`Checking quota for ${provider.displayName}…`, "info");
354
+ result = await fetchQuota(auth.provider, auth.apiKey);
355
+ } else {
356
+ result = auth;
385
357
  }
386
- const passive = [...rateLimits.values()]
387
- .map((s) => formatRateLimit(s))
388
- .filter((line): line is string => line !== null);
389
- const passiveBlock = passive.length > 0 ? `${passive.join("\n")}\n\n` : "";
390
- ctx.ui.notify(redact(passiveBlock + quotaReport(results)), results.every((r) => r.ok) ? "info" : "warning");
358
+ quotas.set(id, { result, fetchedAt: Date.now() });
359
+ updateFooter(ctx);
360
+ ctx.ui.notify(redact((passive ? `${passive}\n\n` : "") + quotaReport([result])), result.ok ? "info" : "warning");
391
361
  return;
392
362
  }
393
363
  ctx.ui.notify(dashboard(ctx), "info");
394
364
  },
395
- });
365
+ };
366
+ pi.registerCommand("usage", usageCommand);
367
+ pi.registerCommand("pify-usage", usageCommand);
396
368
 
397
369
  pi.registerCommand("context", {
398
370
  description: "Where the context window went: /context",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/usage",
3
- "version": "0.13.0",
3
+ "version": "0.14.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",
@@ -17,9 +17,13 @@ This project has the `@pify/usage` extension installed: a live footer
17
17
 
18
18
  ## Notes
19
19
 
20
- - All numbers are computed locally from pi's session files and the current
21
- session's message usage — no network, no tokens spent asking.
22
- - `/usage` (user command) shows the full dashboard: session breakdown,
23
- today/7d/30d history, and per-model totals.
20
+ - Session/history metrics are computed locally from pi's session files and
21
+ the current session's message usage — no network, no tokens spent asking.
22
+ - `/usage` (or `/pify-usage` when another extension owns `/usage`) shows
23
+ the full dashboard: session breakdown, today/7d/30d history, and per-model totals.
24
+ - `/usage quota` is explicitly opt-in network access, not part of
25
+ `usage_status`. It checks only the active provider, including Pi OAuth
26
+ Copilot (GitHub.com or matching GHE Cloud tenant)/Codex quotas; the active subscription's cached percentage remaining
27
+ appears in the footer. Do not run quota checks just to report local costs.
24
28
  - Costs come from pi's own per-message usage.cost; if a model has no price
25
29
  configured, its cost reads as $0 while tokens still count.
@@ -0,0 +1,65 @@
1
+ import { isRecord } from "./types.ts";
2
+ import type { QuotaProvider } from "./quota.ts";
3
+
4
+ interface QuotaRegistry {
5
+ getApiKeyForProvider?: (id: string) => Promise<string | undefined>;
6
+ getProviderAuth?: (id: string) => Promise<{ auth?: { apiKey?: string } } | undefined>;
7
+ }
8
+
9
+ export type QuotaAuthResult =
10
+ | { ok: true; provider: QuotaProvider; apiKey: string }
11
+ | { ok: false; provider: string; reason: string };
12
+
13
+ /** Bind login authority to API authority; never infer it from an inference token. */
14
+ function githubQuotaHost(value: unknown): string | null {
15
+ if (value === undefined || value === null || value === "") return "api.github.com";
16
+ if (typeof value !== "string") return null;
17
+ try {
18
+ const url = new URL(value.includes("://") ? value : `https://${value}`);
19
+ if (url.protocol !== "https:" || url.username || url.password || url.port ||
20
+ url.pathname !== "/" || url.search || url.hash) return null;
21
+ if (url.hostname === "github.com") return "api.github.com";
22
+ if (/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.ghe\.com$/.test(url.hostname)) return `api.${url.hostname}`;
23
+ } catch {
24
+ // An unsupported authority must not fall back to public GitHub.
25
+ }
26
+ return null;
27
+ }
28
+
29
+ /** Resolve token AND endpoint together through Pi's public credential APIs. */
30
+ export async function resolveQuotaAuth(
31
+ provider: QuotaProvider,
32
+ registry: QuotaRegistry,
33
+ readCredential: (provider: string) => unknown,
34
+ ): Promise<QuotaAuthResult> {
35
+ const unavailable = (reason: string): QuotaAuthResult => ({ ok: false, provider: provider.id, reason });
36
+ try {
37
+ if (provider.id === "github-copilot" || provider.id === "openai-codex") {
38
+ const credential = readCredential(provider.id);
39
+ if (!isRecord(credential) || credential.type !== "oauth") return unavailable("Pi OAuth login is required for this subscription quota");
40
+ if (provider.id === "github-copilot") {
41
+ const host = githubQuotaHost(credential.enterpriseUrl);
42
+ if (!host) return unavailable("unsupported GitHub OAuth authority; no quota request was sent");
43
+ // Pi stores the GitHub login token in refresh; access is the inference token.
44
+ const apiKey = typeof credential.refresh === "string" ? credential.refresh.trim() : "";
45
+ if (!apiKey) return unavailable("GitHub OAuth login token is unavailable; the inference token cannot be used");
46
+ return { ok: true, apiKey, provider: {
47
+ ...provider, url: `https://${host}/copilot_internal/user`, allowlist: [host],
48
+ } };
49
+ }
50
+ }
51
+ const direct = await registry.getApiKeyForProvider?.(provider.id);
52
+ const apiKey = direct || (await registry.getProviderAuth?.(provider.id))?.auth?.apiKey || "";
53
+ if (!apiKey.trim()) return unavailable("Pi has no credential for the active provider");
54
+ if (provider.id === "openai-codex") {
55
+ // A stored login must not authorize forwarding an unrelated resolved API key.
56
+ const refreshed = readCredential(provider.id);
57
+ if (!isRecord(refreshed) || refreshed.type !== "oauth" || refreshed.access !== apiKey) {
58
+ return unavailable("resolved credential does not match the Codex OAuth login; no quota request was sent");
59
+ }
60
+ }
61
+ return { ok: true, apiKey, provider };
62
+ } catch {
63
+ return unavailable("Pi credential resolution failed; no quota request was sent");
64
+ }
65
+ }
package/src/quota.ts CHANGED
@@ -5,19 +5,25 @@
5
5
  * dashboard: a provider that is slow or down shows as unavailable next to the
6
6
  * local numbers.
7
7
  *
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.)
8
+ * OpenRouter and DeepSeek expose balance APIs. Copilot and Codex expose
9
+ * subscription quotas through endpoints published by their official clients,
10
+ * without the same versioned REST contract. All four are opt-in per call;
11
+ * polling is never started by a session, a model change, or a footer redraw.
15
12
  */
16
13
 
17
14
  import { controlledGetJson, type HttpResult } from "./http.ts";
18
15
  import { finite, isRecord } from "./types.ts";
19
16
 
17
+ export interface QuotaWindow {
18
+ label: string;
19
+ remainingPercent: number | null;
20
+ resetAt: number | null;
21
+ unlimited: boolean;
22
+ }
23
+
20
24
  export interface QuotaInfo {
25
+ /** Subscription windows are percentages, not monetary balances. */
26
+ windows?: QuotaWindow[];
21
27
  provider: string;
22
28
  /** Spend on this key, in the provider's currency, as it reports it. */
23
29
  used: number | null;
@@ -126,6 +132,61 @@ export function parseDeepSeekBalance(payload: unknown): QuotaInfo | null {
126
132
  };
127
133
  }
128
134
 
135
+ /** Copilot premium interactions (or AI credits on token-based billing). */
136
+ export function parseCopilotQuota(payload: unknown): QuotaInfo | null {
137
+ if (!isRecord(payload) || !isRecord(payload.quota_snapshots)) return null;
138
+ const snapshot = payload.quota_snapshots.premium_interactions;
139
+ if (!isRecord(snapshot)) return null;
140
+ const remaining = num(snapshot.quota_remaining) ?? num(snapshot.remaining);
141
+ const limit = num(snapshot.entitlement);
142
+ const percent = num(snapshot.percent_remaining) ??
143
+ (remaining !== null && limit !== null && limit > 0 ? remaining / limit * 100 : null);
144
+ const unlimited = snapshot.unlimited === true || (remaining === 0 && limit === 0 && percent === 100);
145
+ if (percent === null && !unlimited) return null;
146
+ const reset = payload.quota_reset_date ?? payload.quota_reset_date_utc;
147
+ const resetAt = typeof reset === "string" ? Date.parse(reset) : Number.NaN;
148
+ return {
149
+ ...emptyQuota("github-copilot"),
150
+ windows: [{
151
+ label: payload.token_based_billing === true ? "credits" : "premium",
152
+ remainingPercent: percent === null || unlimited ? null : Math.max(0, Math.min(100, percent)),
153
+ resetAt: Number.isFinite(resetAt) ? resetAt : null,
154
+ unlimited,
155
+ }],
156
+ };
157
+ }
158
+
159
+ /** The ChatGPT-authenticated Codex client's primary and secondary windows. */
160
+ export function parseCodexQuota(payload: unknown): QuotaInfo | null {
161
+ if (!isRecord(payload) || !isRecord(payload.rate_limit)) return null;
162
+ const windows: QuotaWindow[] = [];
163
+ for (const [key, fallback] of [["primary_window", "primary"], ["secondary_window", "secondary"]] as const) {
164
+ const window = payload.rate_limit[key];
165
+ if (!isRecord(window)) continue;
166
+ const used = num(window.used_percent);
167
+ if (used === null || used < 0) continue;
168
+ const seconds = num(window.limit_window_seconds);
169
+ const label = seconds !== null && seconds > 0 && seconds % 86400 === 0 ? `${seconds / 86400}d` :
170
+ seconds !== null && seconds > 0 && seconds % 3600 === 0 ? `${seconds / 3600}h` : fallback;
171
+ const epoch = num(window.reset_at);
172
+ const resetAt = epoch === null ? Number.NaN : epoch * 1000;
173
+ windows.push({ label, remainingPercent: Math.max(0, Math.min(100, 100 - used)),
174
+ resetAt: Number.isFinite(new Date(resetAt).getTime()) ? resetAt : null, unlimited: false });
175
+ }
176
+ return windows.length > 0 ? { ...emptyQuota("openai-codex"), windows } : null;
177
+ }
178
+
179
+ function codexAccountId(accessToken: string): string | undefined {
180
+ try {
181
+ const payload: unknown = JSON.parse(Buffer.from(accessToken.split(".")[1] ?? "", "base64url").toString("utf8"));
182
+ if (!isRecord(payload) || !isRecord(payload["https://api.openai.com/auth"])) return undefined;
183
+ const account = payload["https://api.openai.com/auth"].chatgpt_account_id;
184
+ return typeof account === "string" && account.length > 0 && !/[\r\n]/.test(account) ? account : undefined;
185
+ } catch {
186
+ return undefined;
187
+ }
188
+ }
189
+
129
190
  export interface QuotaProvider {
130
191
  /** pi's provider id, which is also how the key is looked up. */
131
192
  id: string;
@@ -136,6 +197,20 @@ export interface QuotaProvider {
136
197
  }
137
198
 
138
199
  export const QUOTA_PROVIDERS: readonly QuotaProvider[] = [
200
+ {
201
+ id: "github-copilot",
202
+ displayName: "GitHub Copilot",
203
+ url: "https://api.github.com/copilot_internal/user",
204
+ allowlist: ["api.github.com"],
205
+ parse: parseCopilotQuota,
206
+ },
207
+ {
208
+ id: "openai-codex",
209
+ displayName: "Codex",
210
+ url: "https://chatgpt.com/backend-api/wham/usage",
211
+ allowlist: ["chatgpt.com"],
212
+ parse: parseCodexQuota,
213
+ },
139
214
  {
140
215
  id: "openrouter",
141
216
  displayName: "OpenRouter",
@@ -168,9 +243,18 @@ export async function fetchQuota(
168
243
  ): Promise<QuotaResult> {
169
244
  if (!apiKey.trim()) return { ok: false, provider: provider.id, reason: "no API key configured" };
170
245
 
246
+ const headers: Record<string, string> = { Authorization: `Bearer ${apiKey.trim()}`, Accept: "application/json" };
247
+ if (provider.id === "github-copilot") {
248
+ headers["X-GitHub-Api-Version"] = "2022-11-28";
249
+ headers["User-Agent"] = "pify-usage";
250
+ }
251
+ if (provider.id === "openai-codex") {
252
+ const accountId = codexAccountId(apiKey.trim());
253
+ if (accountId) headers["ChatGPT-Account-Id"] = accountId;
254
+ }
171
255
  const result: HttpResult<unknown> = await controlledGetJson({
172
256
  url: provider.url,
173
- headers: { Authorization: `Bearer ${apiKey.trim()}`, Accept: "application/json" },
257
+ headers,
174
258
  timeoutMs,
175
259
  allowlist: provider.allowlist,
176
260
  fetchImpl,
@@ -211,17 +295,41 @@ export function quotaBlock(result: QuotaResult): string {
211
295
  ` window day ${money(q.daily, q.currency)} · week ${money(q.weekly, q.currency)} · month ${money(q.monthly, q.currency)}`,
212
296
  );
213
297
  }
298
+ for (const window of q.windows ?? []) {
299
+ const remaining = windowRemaining(window);
300
+ lines.push(` ${window.label} ${remaining}${window.resetAt === null ? "" : ` · resets ${new Date(window.resetAt).toISOString()}`}`);
301
+ }
214
302
  if (q.freeTier) lines.push(" tier free");
215
303
  return lines.join("\n");
216
304
  }
217
305
 
218
- /** What `/usage quota` prints: every provider that has a key, and nothing else. */
306
+ function windowRemaining(window: QuotaWindow): string {
307
+ return window.unlimited ? "unlimited" : window.remainingPercent === null ? "unknown" : `${Math.round(window.remainingPercent)}% left`;
308
+ }
309
+
310
+ /** Cached subscription data never masquerades as a live counter. No network here. */
311
+ export function quotaFooter(result: QuotaResult | undefined, fetchedAt: number, now = Date.now()): string {
312
+ if (!result) return "";
313
+ const id = result.ok ? result.quota.provider : result.provider;
314
+ const name = id === "github-copilot" ? "Copilot" : id === "openai-codex" ? "Codex" : null;
315
+ if (!name) return "";
316
+ if (!result.ok) return `${name} quota unavailable`;
317
+ const windows = result.quota.windows;
318
+ if (!windows?.length) return "";
319
+ if (now - fetchedAt >= 15 * 60 * 1000 || windows.some(w => w.resetAt !== null && now >= w.resetAt)) {
320
+ return `${name} quota stale · /pify-usage quota`;
321
+ }
322
+ const parts = windows.map(w => `${w.label} ${windowRemaining(w)}`);
323
+ return `${name} ${parts.join(" · ")} · cached`;
324
+ }
325
+
326
+ /** What `/usage quota` prints: every provider that has suitable credentials. */
219
327
  export function quotaReport(results: QuotaResult[]): string {
220
328
  if (results.length === 0) {
221
329
  return [
222
330
  "No provider with a quota endpoint is configured.",
223
331
  `Supported: ${QUOTA_PROVIDERS.map((p) => p.displayName).join(", ")}.`,
224
- "Only documented balance endpoints are polled. OpenAI, Anthropic and Gemini publish no balance API, but their rate-limit headers are read passively from ordinary responses — shown above when the session has made a call.",
332
+ "Balances use documented endpoints and API keys. Copilot and Codex use published client endpoints and require Pi OAuth login; their contracts may change. Quota requests are opt-in, with no background polling. Rate-limit headers are read passively from ordinary responses — shown above when the session has made a call.",
225
333
  ].join("\n");
226
334
  }
227
335
  return results.map(quotaBlock).join("\n\n");