@pify/usage 0.12.1 → 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
  ```
@@ -64,10 +75,20 @@ Context window: 22.6k of 200.0k used (11%)
64
75
  Tool results ██·························· 8% 16.8k
65
76
  Conversation ···························· 1% 2.4k
66
77
  Free space █████████████████████████··· 89% 177.4k
78
+
79
+ Heaviest tool schemas (14 tools, ~3.9k of the prefix):
80
+ mcp__docs__search 1.6k mcp:docs
81
+ workflow 520
82
+ mcp__docs__fetch 410 mcp:docs
83
+ bash 290
84
+ … and 10 more, ~1.1k
85
+ by source: mcp:docs ~2.0k, builtin/ext ~1.9k
67
86
  ```
68
87
 
69
88
  "Why am I at 60%?" usually has a boring answer — one `read` of a 4,000-line file — and this is where you find it.
70
89
 
90
+ The **Tool definitions** row is broken out below the window into its heaviest tools, biggest first, so a fat MCP schema or a costly server is nameable rather than a lump: every active tool's `name + description + parameters` is a prefix cost paid on *every* request, and the `by source` line totals the builtins and extensions against each MCP server. (This is the live, per-session version of what the cross-harness "subagent-tax" measures.)
91
+
71
92
  Computed entirely from what pi already holds: the assembled system prompt, the files and skills embedded in it, the enabled tool definitions, and the entries that would be sent. No network, no model call.
72
93
 
73
94
  Context files and skills are counted only when their text is genuinely embedded in the prompt, and the system-prompt row is the remainder after subtracting them, so the rows sum to the whole instead of double-counting. When the provider reports more than can be attributed, the difference is shown as **Other** rather than quietly dropped. Image blocks (a `read` of a screenshot, a pasted image) are charged at pi's flat 4,800 chars each, not at the size of their base64 bytes, so one screenshot no longer reads as hundreds of thousands of tokens.
@@ -91,7 +112,11 @@ Quota (DeepSeek · granted 10 · topped up 100)
91
112
  balance CNY 110.00
92
113
  ```
93
114
 
94
- **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.
95
120
 
96
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.
97
122
 
@@ -101,9 +126,9 @@ Quota (DeepSeek · granted 10 · topped up 100)
101
126
  - Redirects are refused outright. Following one lets whatever answered choose where the next request goes, with the header already attached.
102
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.
103
128
  - Raw exception text is dropped rather than shown, and everything printed passes a redactor as a last line of defence.
104
- - 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.
105
130
 
106
- **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.
107
132
 
108
133
  ## Tool
109
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,
@@ -29,14 +27,15 @@ import { join } from "node:path";
29
27
  import { readFileSync } from "node:fs";
30
28
 
31
29
  import { addRecord, aggregate, recordFromEntry, windowTotals } from "../src/aggregate.ts";
32
- import { buildBreakdown, contextTokensFromUsage, formatBreakdown, resolveUsedTokens } from "../src/context.ts";
30
+ import { buildBreakdown, contextTokensFromUsage, formatBreakdown, formatToolTax, resolveUsedTokens } from "../src/context.ts";
33
31
  import { contextGauge, footerText, formatCost, formatTokens, historyBlock, sessionBlock } from "../src/format.ts";
34
32
  import { childSpendTotal, onChildSpend, resetChildSpend } from "../src/child-cost.ts";
35
33
  import { buildByDayCsv, buildByModelCsv, buildByProjectCsv, buildTotalsJson } from "../src/export.ts";
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
@@ -281,7 +258,8 @@ export default function usage(pi: ExtensionAPI) {
281
258
  const systemPrompt = host.getSystemPrompt?.() ?? "";
282
259
  const options = host.getSystemPromptOptions?.() ?? {};
283
260
  const selected = new Set(options.selectedTools ?? []);
284
- const allTools = (pi as unknown as { getAllTools?: () => Array<{ name?: string }> }).getAllTools?.() ?? [];
261
+ const allTools =
262
+ (pi as unknown as { getAllTools?: () => Array<{ name?: string; description?: string; parameters?: unknown }> }).getAllTools?.() ?? [];
285
263
  const tools = selected.size > 0 ? allTools.filter((t) => selected.has(t.name ?? "")) : allTools;
286
264
 
287
265
  let skillsText = "";
@@ -316,10 +294,13 @@ export default function usage(pi: ExtensionAPI) {
316
294
  providerTokens: lastPromptTokens || null,
317
295
  estimate: breakdown.attributed,
318
296
  });
319
- return formatBreakdown(breakdown, reported);
297
+ // Break the "Tool definitions" row into its heaviest tools, biggest first,
298
+ // so a costly MCP server or a fat schema is nameable rather than a lump.
299
+ const taxLines = formatToolTax(tools);
300
+ return taxLines.length > 0 ? `${formatBreakdown(breakdown, reported)}\n\n${taxLines.join("\n")}` : formatBreakdown(breakdown, reported);
320
301
  }
321
302
 
322
- pi.registerCommand("usage", {
303
+ const usageCommand: Parameters<ExtensionAPI["registerCommand"]>[1] = {
323
304
  description: "Token and cost dashboard: /usage [context | quota | export]",
324
305
  handler: async (args, ctx) => {
325
306
  if (!ctx.hasUI) return;
@@ -356,39 +337,34 @@ export default function usage(pi: ExtensionAPI) {
356
337
  return;
357
338
  }
358
339
  if ((args ?? "").trim().toLowerCase() === "quota") {
359
- // The one networked path in this package, and only when asked for.
360
- // Providers with no key are skipped entirely rather than reported as
361
- // broken: an unconfigured provider is not a failure.
362
- const configured: Array<{ provider: (typeof QUOTA_PROVIDERS)[number]; key: string }> = [];
363
- for (const provider of QUOTA_PROVIDERS) {
364
- const key = await providerKey(ctx, provider.id);
365
- if (key) configured.push({ provider, key });
366
- }
367
- if (configured.length === 0) {
368
- const passive = [...rateLimits.values()]
369
- .map((s) => formatRateLimit(s))
370
- .filter((line): line is string => line !== null);
371
- ctx.ui.notify(
372
- passive.length > 0 ? redact(`${passive.join("\n")}\n\n${quotaReport([])}`) : quotaReport([]),
373
- "info",
374
- );
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");
375
348
  return;
376
349
  }
377
- ctx.ui.notify(`Checking quota for ${configured.map((c) => c.provider.displayName).join(", ")}…`, "info");
378
- const results: QuotaResult[] = [];
379
- for (const { provider, key } of configured) {
380
- 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;
381
357
  }
382
- const passive = [...rateLimits.values()]
383
- .map((s) => formatRateLimit(s))
384
- .filter((line): line is string => line !== null);
385
- const passiveBlock = passive.length > 0 ? `${passive.join("\n")}\n\n` : "";
386
- 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");
387
361
  return;
388
362
  }
389
363
  ctx.ui.notify(dashboard(ctx), "info");
390
364
  },
391
- });
365
+ };
366
+ pi.registerCommand("usage", usageCommand);
367
+ pi.registerCommand("pify-usage", usageCommand);
392
368
 
393
369
  pi.registerCommand("context", {
394
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.12.1",
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.
package/src/context.ts CHANGED
@@ -170,6 +170,62 @@ function foldEntries(entries: unknown[]): { conversation: number; toolResults: n
170
170
  return { conversation, toolResults, thinking };
171
171
  }
172
172
 
173
+ /** One tool's share of the schema tax: its estimated token cost and where it came from. */
174
+ export interface ToolTax {
175
+ name: string;
176
+ tokens: number;
177
+ /** "builtin/ext", or "mcp:<server>" for an MCP tool. */
178
+ source: string;
179
+ }
180
+
181
+ /** The server an MCP tool belongs to (mcp__<server>__<tool>), or null. */
182
+ export function mcpServerOf(name: string): string | null {
183
+ const m = /^mcp__(.+?)__/.exec(name);
184
+ return m ? m[1]! : null;
185
+ }
186
+
187
+ /**
188
+ * Every active tool ranked by the tokens its schema (name + description +
189
+ * parameters) adds to the prefix of every request. The "Tool definitions" row
190
+ * in the breakdown is this sum; this says which tools make it up, biggest
191
+ * first — the live, per-session answer to what @pify-adjacent "subagent-tax"
192
+ * measures across harnesses. Same chars/4 estimate as everything else here.
193
+ */
194
+ export function toolTax(tools: BreakdownInput["tools"]): ToolTax[] {
195
+ return tools
196
+ .map((tool) => {
197
+ const name = String(tool.name ?? "");
198
+ const server = mcpServerOf(name);
199
+ return {
200
+ name,
201
+ tokens: estimateTextTokens({ name, description: tool.description, parameters: tool.parameters }),
202
+ source: server ? `mcp:${server}` : "builtin/ext",
203
+ };
204
+ })
205
+ .sort((a, b) => b.tokens - a.tokens || a.name.localeCompare(b.name));
206
+ }
207
+
208
+ /** A compact "heaviest tool schemas" block: the top `max`, then a by-source subtotal. */
209
+ export function formatToolTax(tools: BreakdownInput["tools"], max = 8): string[] {
210
+ const ranked = toolTax(tools);
211
+ if (ranked.length === 0) return [];
212
+ const total = ranked.reduce((sum, t) => sum + t.tokens, 0);
213
+ const width = Math.min(ranked.length, max) > 0 ? ranked.slice(0, max).reduce((m, t) => Math.max(m, t.name.length), 0) : 0;
214
+ const lines = [`Heaviest tool schemas (${ranked.length} ${ranked.length === 1 ? "tool" : "tools"}, ~${formatTokens(total)} of the prefix):`];
215
+ for (const t of ranked.slice(0, max)) {
216
+ lines.push(` ${t.name.padEnd(width)} ${formatTokens(t.tokens).padStart(6)} ${t.source === "builtin/ext" ? "" : t.source}`.trimEnd());
217
+ }
218
+ if (ranked.length > max) {
219
+ const rest = ranked.slice(max).reduce((sum, t) => sum + t.tokens, 0);
220
+ lines.push(` … and ${ranked.length - max} more, ~${formatTokens(rest)}`);
221
+ }
222
+ const bySource = new Map<string, number>();
223
+ for (const t of ranked) bySource.set(t.source, (bySource.get(t.source) ?? 0) + t.tokens);
224
+ const subtotals = [...bySource.entries()].sort((a, b) => b[1] - a[1]).map(([s, n]) => `${s} ~${formatTokens(n)}`);
225
+ if (subtotals.length > 1) lines.push(` by source: ${subtotals.join(", ")}`);
226
+ return lines;
227
+ }
228
+
173
229
  export function buildBreakdown(input: BreakdownInput): ContextBreakdown {
174
230
  const memory = input.contextFiles.reduce(
175
231
  (sum, file) => sum + embeddedTokens(input.systemPrompt, file.content ?? ""),
@@ -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");