@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 +19 -4
- package/extensions/usage.ts +42 -70
- package/package.json +1 -1
- package/skills/usage/SKILL.md +8 -4
- package/src/quota-auth.ts +65 -0
- package/src/quota.ts +118 -10
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.
|
|
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
|
-
**
|
|
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
|
|
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
|
-
**
|
|
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
|
|
package/extensions/usage.ts
CHANGED
|
@@ -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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
package/skills/usage/SKILL.md
CHANGED
|
@@ -17,9 +17,13 @@ This project has the `@pify/usage` extension installed: a live footer
|
|
|
17
17
|
|
|
18
18
|
## Notes
|
|
19
19
|
|
|
20
|
-
-
|
|
21
|
-
session's message usage — no network, no tokens spent asking.
|
|
22
|
-
- `/usage` (
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
"
|
|
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");
|