@ohgodtamit/pi-usage 0.1.0-alpha.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.
@@ -0,0 +1,704 @@
1
+ /**
2
+ * Active-provider detection and live quota fetching.
3
+ *
4
+ * The usage panel surfaces TWO independent signals about your active provider:
5
+ *
6
+ * 1. Live money quota — fetched from the provider's billing API when it has
7
+ * one (OpenRouter credits, OpenAI costs). This is the
8
+ * provider's own view of your account.
9
+ * 2. Rate-limit headers — captured from every provider HTTP response via the
10
+ * `after_provider_response` event. These are universal
11
+ * (Anthropic, OpenAI, OpenRouter, Google, … all return
12
+ * them) and reflect the live per-window limits applied
13
+ * to your current API key.
14
+ *
15
+ * API keys are resolved through the session's model registry, the same path pi
16
+ * uses for request-time credentials and OAuth refresh.
17
+ */
18
+ import type { Api, Model } from "@earendil-works/pi-ai";
19
+ import { type ModelRegistry, readStoredCredential } from "@earendil-works/pi-coding-agent";
20
+ import { classifyZaiLimits, type ZaiQuotaLimit } from "./zai.ts";
21
+
22
+ const HOUR = 60 * 60 * 1000;
23
+ const DAY = 24 * HOUR;
24
+
25
+ /** Resolve a provider's current request credential, including refreshed OAuth. */
26
+ export async function resolveApiKey(
27
+ modelRegistry: ModelRegistry,
28
+ provider: string,
29
+ ): Promise<string | undefined> {
30
+ if (!provider) return undefined;
31
+ try {
32
+ return await modelRegistry.getApiKeyForProvider(provider);
33
+ } catch (err) {
34
+ console.error(
35
+ `[usage] getApiKeyForProvider(${provider}) failed: ${err instanceof Error ? err.message : String(err)}`,
36
+ );
37
+ return undefined;
38
+ }
39
+ }
40
+
41
+ /** Check whether pi can currently resolve authentication for a provider. */
42
+ export async function hasProviderKey(
43
+ modelRegistry: ModelRegistry,
44
+ provider: string,
45
+ ): Promise<boolean> {
46
+ if (!provider) return false;
47
+ try {
48
+ return (await modelRegistry.getProviderAuth(provider)) !== undefined;
49
+ } catch (err) {
50
+ console.error(
51
+ `[usage] getProviderAuth(${provider}) failed: ${err instanceof Error ? err.message : String(err)}`,
52
+ );
53
+ return false;
54
+ }
55
+ }
56
+
57
+ export interface ActiveProvider {
58
+ provider: string;
59
+ modelId: string;
60
+ baseUrl: string;
61
+ api: string;
62
+ /** True when an API key for this provider is resolvable from the environment. */
63
+ hasKey: boolean;
64
+ }
65
+
66
+ /** Detect the currently active provider/model from the session context. */
67
+ export async function detectActiveProvider(
68
+ modelRegistry: ModelRegistry,
69
+ model: Model<Api> | undefined,
70
+ ): Promise<ActiveProvider | null> {
71
+ if (!model) return null;
72
+ const provider = model.provider ?? "";
73
+ return {
74
+ provider,
75
+ modelId: model.id ?? "",
76
+ baseUrl: model.baseUrl ?? "",
77
+ api: (model as { api?: string }).api ?? "",
78
+ hasKey: await hasProviderKey(modelRegistry, provider),
79
+ };
80
+ }
81
+
82
+ export interface RateLimitWindow {
83
+ /** "requests" | "tokens" | "input-tokens" | "output-tokens" */
84
+ resource: string;
85
+ /** Approximate window label, e.g. "tokens/min". Heuristic per provider tier. */
86
+ window: string;
87
+ limit: number;
88
+ remaining: number;
89
+ /** Epoch ms when the window resets, or 0 if unknown. */
90
+ resetMs: number;
91
+ }
92
+
93
+ export interface ProviderQuota {
94
+ active: ActiveProvider | null;
95
+ fetchedAt: number;
96
+ /** Live account credits (OpenRouter). Undefined when not applicable/available. */
97
+ credits?: { total: number; used: number; remaining: number };
98
+ /** Live provider spend in USD (OpenAI organization/costs API). Best-effort. */
99
+ spend5h?: number;
100
+ spend7d?: number;
101
+ monthlyLimit?: number;
102
+ /**
103
+ * Provider-native plan quotas (ZAI GLM coding plans, OpenAI Codex subscription):
104
+ * session (5h) and weekly (7d) windows reported directly by the upstream as a
105
+ * used percentage with a live reset countdown. These replace the session-derived bars.
106
+ */
107
+ planQuota?: {
108
+ plan: string;
109
+ session5h?: { usedPct: number; resetMs: number };
110
+ weekly?: { usedPct: number; resetMs: number };
111
+ webSearches?: { used: number; limit: number; resetMs: number };
112
+ /** Purchased credits balance (OpenAI Codex), when reported. */
113
+ credits?: { balance: number; unlimited: boolean };
114
+ };
115
+ /** Rate-limit windows captured from the most recent provider response. */
116
+ rateLimits: RateLimitWindow[];
117
+ /** "live" if a billing API responded, "headers" if only rate-limit headers, "none" otherwise. */
118
+ source: "live" | "headers" | "none";
119
+ /** Human hints (e.g. why live quota is unavailable). */
120
+ notes: string[];
121
+ error?: string;
122
+ }
123
+
124
+ /**
125
+ * Parse provider rate-limit headers into structured windows.
126
+ *
127
+ * Recognizes three conventions and de-dupes by resource:
128
+ * - Anthropic: `anthropic-ratelimit-{resource}-{limit|remaining|reset}`
129
+ * - OpenAI: `x-ratelimit-{limit|remaining|reset}-{resource}`
130
+ * - Generic: `x-ratelimit-{limit|remaining|reset}` (older/simpler APIs)
131
+ *
132
+ * Pi lowercases all header keys, so matching is case-insensitive by contract.
133
+ */
134
+ export function parseRateLimits(
135
+ headers: Record<string, string>,
136
+ now: number = Date.now(),
137
+ ): RateLimitWindow[] {
138
+ const windows: RateLimitWindow[] = [];
139
+ const seen = new Set<string>();
140
+
141
+ const anthropicRe =
142
+ /^anthropic-ratelimit-(requests|tokens|input-tokens|output-tokens)-(limit|remaining|reset)$/;
143
+ const openaiRe = /^x-ratelimit-(limit|remaining|reset)-(requests|tokens)$/;
144
+
145
+ const pushGroup = (
146
+ groups: Map<string, { limit?: number; remaining?: number; reset?: string }>,
147
+ origin: string,
148
+ ) => {
149
+ for (const [resource, g] of groups) {
150
+ const key = `${origin}:${resource}`;
151
+ if (seen.has(key)) continue;
152
+ if (g.limit == null && g.remaining == null) continue;
153
+ seen.add(key);
154
+ windows.push({
155
+ resource,
156
+ window: windowLabel(resource),
157
+ limit: g.limit ?? 0,
158
+ remaining: g.remaining ?? 0,
159
+ resetMs: parseReset(g.reset, now),
160
+ });
161
+ }
162
+ };
163
+
164
+ const anthropicGroups = new Map<string, { limit?: number; remaining?: number; reset?: string }>();
165
+ const openaiGroups = new Map<string, { limit?: number; remaining?: number; reset?: string }>();
166
+ for (const [kRaw, v] of Object.entries(headers)) {
167
+ const k = kRaw.toLowerCase();
168
+ const a = k.match(anthropicRe);
169
+ if (a) {
170
+ const [, resource, field] = a;
171
+ const g = anthropicGroups.get(resource) ?? {};
172
+ applyField(g, field, v);
173
+ anthropicGroups.set(resource, g);
174
+ continue;
175
+ }
176
+ const o = k.match(openaiRe);
177
+ if (o) {
178
+ const [, field, resource] = o;
179
+ const g = openaiGroups.get(resource) ?? {};
180
+ applyField(g, field, v);
181
+ openaiGroups.set(resource, g);
182
+ }
183
+ }
184
+ pushGroup(anthropicGroups, "anthropic");
185
+ pushGroup(openaiGroups, "openai");
186
+
187
+ // Generic single-window fallback (no resource distinction).
188
+ const gLimit = headers["x-ratelimit-limit"] ?? headers["ratelimit-limit"];
189
+ const gRemaining = headers["x-ratelimit-remaining"] ?? headers["ratelimit-remaining"];
190
+ const gReset = headers["x-ratelimit-reset"] ?? headers["ratelimit-reset"];
191
+ if ((gLimit || gRemaining) && !seen.has("generic")) {
192
+ windows.push({
193
+ resource: "requests",
194
+ window: "window",
195
+ limit: Number(gLimit) || 0,
196
+ remaining: Number(gRemaining) || 0,
197
+ resetMs: parseReset(gReset, now),
198
+ });
199
+ }
200
+
201
+ return windows;
202
+ }
203
+
204
+ function applyField(
205
+ g: { limit?: number; remaining?: number; reset?: string },
206
+ field: string,
207
+ value: string,
208
+ ): void {
209
+ if (field === "limit") g.limit = Number(value);
210
+ else if (field === "remaining") g.remaining = Number(value);
211
+ else g.reset = value;
212
+ }
213
+
214
+ function windowLabel(resource: string): string {
215
+ if (resource.includes("token")) return "tokens/min";
216
+ return "requests/min";
217
+ }
218
+
219
+ /**
220
+ * Parse a reset value into an epoch-ms timestamp.
221
+ *
222
+ * Handles ISO-8601 timestamps ("2024-01-01T12:00:00Z"), OpenAI-style durations
223
+ * ("6m0s", "500ms", "2h"), and bare-seconds integers.
224
+ */
225
+ export function parseReset(value: string | undefined, now: number): number {
226
+ if (!value) return 0;
227
+ const s = String(value).trim();
228
+ if (!s) return 0;
229
+
230
+ // ISO 8601 / RFC3339 timestamp.
231
+ if (/^\d{4}-\d{2}-\d{2}T/.test(s) || s.endsWith("Z")) {
232
+ const t = Date.parse(s);
233
+ if (!Number.isNaN(t)) return t;
234
+ }
235
+
236
+ // Duration: "1d2h3m4s500ms" (any subset, OpenAI style).
237
+ const dm = s.match(/^(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m(?!s))?(?:(\d+(?:\.\d+)?)s)?(?:(\d+)ms)?$/);
238
+ if (dm && s.match(/\d/)) {
239
+ const days = Number(dm[1] ?? 0);
240
+ const hours = Number(dm[2] ?? 0);
241
+ const mins = Number(dm[3] ?? 0);
242
+ const secs = Number(dm[4] ?? 0);
243
+ const ms = Number(dm[5] ?? 0);
244
+ const totalMs = ((days * 24 + hours) * 60 + mins) * 60 * 1000 + secs * 1000 + ms;
245
+ if (totalMs > 0) return now + totalMs;
246
+ }
247
+
248
+ // Plain number → seconds until reset (some providers).
249
+ if (/^\d+(\.\d+)?$/.test(s)) return now + Number(s) * 1000;
250
+
251
+ return 0;
252
+ }
253
+
254
+ /** Normalize a base URL so it ends with exactly `/v1`. Falls back to `<fallback>/v1` when not derivable. */
255
+ function ensureV1(baseUrl: string, fallback: string): string {
256
+ const trimmed = baseUrl.replace(/\/+$/, "");
257
+ if (/\/v\d+$/.test(trimmed)) return trimmed; // already has a version segment
258
+ if (trimmed) return `${trimmed}/v1`;
259
+ return `${fallback}/v1`;
260
+ }
261
+
262
+ /** Fetch OpenRouter account credits. Works with a normal API key. */
263
+ async function fetchOpenRouterCredits(
264
+ baseUrl: string,
265
+ apiKey: string,
266
+ signal?: AbortSignal,
267
+ ): Promise<{ total: number; used: number; remaining: number } | undefined> {
268
+ const root = ensureV1(baseUrl, "https://openrouter.ai");
269
+ const res = await fetch(`${root}/credits`, {
270
+ headers: { Authorization: `Bearer ${apiKey}` },
271
+ signal,
272
+ });
273
+ if (!res.ok) throw new Error(`OpenRouter /credits HTTP ${res.status}`);
274
+ const json = (await res.json()) as {
275
+ data?: { total_credits?: number; total_usage?: number };
276
+ };
277
+ const total = json?.data?.total_credits;
278
+ const used = json?.data?.total_usage;
279
+ if (typeof total !== "number" || typeof used !== "number") return undefined;
280
+ return { total, used, remaining: Math.max(0, total - used) };
281
+ }
282
+
283
+ /** Best-effort OpenAI spend for the 5h and 7d windows via the organization/costs API. */
284
+ async function fetchOpenAICosts(
285
+ baseUrl: string,
286
+ apiKey: string,
287
+ signal?: AbortSignal,
288
+ ): Promise<{ spend5h: number; spend7d: number; monthlyLimit?: number } | undefined> {
289
+ const root = ensureV1(baseUrl, "https://api.openai.com");
290
+ const nowS = Math.floor(Date.now() / 1000);
291
+ const start5 = Math.floor((Date.now() - 5 * HOUR) / 1000);
292
+ const start7 = Math.floor((Date.now() - 7 * DAY) / 1000);
293
+
294
+ const sumCosts = async (start: number): Promise<number> => {
295
+ const url = `${root}/organization/costs?start_time=${start}&end_time=${nowS}&limit=1`;
296
+ const res = await fetch(url, {
297
+ headers: { Authorization: `Bearer ${apiKey}` },
298
+ signal,
299
+ });
300
+ if (!res.ok) throw new Error(`OpenAI /costs HTTP ${res.status}`);
301
+ const json = (await res.json()) as {
302
+ data?: Array<{ results?: Array<{ cost?: { value?: number } }> }>;
303
+ };
304
+ let total = 0;
305
+ for (const bucket of json.data ?? []) {
306
+ for (const r of bucket.results ?? []) total += r.cost?.value ?? 0;
307
+ }
308
+ return total;
309
+ };
310
+
311
+ const [spend5h, spend7d] = await Promise.all([sumCosts(start5), sumCosts(start7)]);
312
+
313
+ // Monthly hard limit is best-effort; many keys can't read /subscription.
314
+ let monthlyLimit: number | undefined;
315
+ try {
316
+ const subRes = await fetch(`${root}/organization/subscription`, {
317
+ headers: { Authorization: `Bearer ${apiKey}` },
318
+ signal,
319
+ });
320
+ if (subRes.ok) {
321
+ const sub = (await subRes.json()) as {
322
+ plan?: { hard_limit_usd?: number };
323
+ };
324
+ const hard = sub?.plan?.hard_limit_usd;
325
+ if (typeof hard === "number") monthlyLimit = hard;
326
+ }
327
+ } catch {
328
+ // Optional; ignore.
329
+ }
330
+
331
+ return { spend5h, spend7d, monthlyLimit };
332
+ }
333
+
334
+ function errMsg(e: unknown): string {
335
+ return e instanceof Error ? e.message : String(e);
336
+ }
337
+
338
+ /** Shape of ZAI's /api/monitor/usage/quota/limit response. */
339
+ interface ZaiQuotaPayload {
340
+ code?: number;
341
+ success?: boolean;
342
+ data?: {
343
+ level?: string;
344
+ limits?: ZaiQuotaLimit[];
345
+ };
346
+ }
347
+
348
+ /**
349
+ * Fetch ZAI (Zhipu / GLM coding plans) native plan quota from the undocumented
350
+ * monitor endpoint used by the subscription UI. Verified schema (2026-06):
351
+ *
352
+ * GET https://api.z.ai/api/monitor/usage/quota/limit (intl)
353
+ * GET https://open.bigmodel.cn/api/monitor/usage/quota/limit (CN fallback)
354
+ * Authorization: Bearer <key>
355
+ *
356
+ * { code:200, success:true, data:{ level:"max", limits:[
357
+ * { type:"TOKENS_LIMIT", unit:3, number:5, percentage:81, nextResetTime:<ms> }, // 5h session
358
+ * { type:"TOKENS_LIMIT", unit:6, number:1, percentage:51, nextResetTime:<ms> }, // weekly
359
+ * { type:"TIME_LIMIT", unit:5, number:1, usage:4000, currentValue:0, remaining:4000, nextResetTime:<ms>, usageDetails:[...] } // web searches
360
+ * ]}}
361
+ *
362
+ * ZAI only exposes `percentage` for the token windows (absolute used/remaining
363
+ * are hidden), so we report the upstream percentage + reset countdown.
364
+ */
365
+ async function fetchZaiPlanQuota(
366
+ apiKey: string,
367
+ signal?: AbortSignal,
368
+ ): Promise<NonNullable<ProviderQuota["planQuota"]> | undefined> {
369
+ const endpoints = [
370
+ "https://api.z.ai/api/monitor/usage/quota/limit",
371
+ "https://open.bigmodel.cn/api/monitor/usage/quota/limit",
372
+ ];
373
+ const headers = {
374
+ Authorization: `Bearer ${apiKey}`,
375
+ Accept: "application/json",
376
+ };
377
+
378
+ let payload: ZaiQuotaPayload | null = null;
379
+
380
+ for (const url of endpoints) {
381
+ try {
382
+ const res = await fetch(url, { headers, signal });
383
+ if (res.status === 404) continue; // endpoint not available on this region
384
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
385
+ payload = (await res.json()) as ZaiQuotaPayload;
386
+ break;
387
+ } catch (e) {
388
+ // Try the next endpoint; if this was the last one, rethrow to the caller.
389
+ if (url === endpoints[endpoints.length - 1]) throw e;
390
+ }
391
+ }
392
+ if (!payload?.data?.limits) return undefined;
393
+ return classifyZaiLimits(payload.data.limits, payload.data.level ?? "");
394
+ }
395
+
396
+ /**
397
+ * Parse OpenAI Codex subscription quota from response headers captured via
398
+ * `after_provider_response`. This is the reliable path: the headers come fresh
399
+ * from pi's own authenticated Codex request, so there is no token/refresh
400
+ * management (unlike the `/wham/usage` REST endpoint, whose OAuth token in
401
+ * `~/.codex/auth.json` is frequently stale/rotated).
402
+ *
403
+ * Header families (authoritative: openai/codex rate_limits.rs):
404
+ * x-codex-primary-used-percent — 5h rolling window used % (0-100)
405
+ * x-codex-primary-reset-at — unix SECONDS of next reset
406
+ * x-codex-secondary-used-percent — 7-day rolling window used %
407
+ * x-codex-secondary-reset-at — unix SECONDS
408
+ * x-codex-credits-has-credits / -unlimited / -balance — purchased credits
409
+ * x-codex-limit-name — plan/limit display name
410
+ *
411
+ * Returns the planQuota shape (shared with ZAI) when any Codex window is present.
412
+ */
413
+ export function parseCodexQuota(
414
+ headers: Record<string, string>,
415
+ ): NonNullable<ProviderQuota["planQuota"]> | undefined {
416
+ const get = (name: string): string | undefined => {
417
+ // Pi lowercases header keys; be defensive about case either way.
418
+ return headers[name] ?? headers[name.toLowerCase()];
419
+ };
420
+ const num = (v: string | undefined): number | undefined => {
421
+ if (v == null || v === "") return undefined;
422
+ const n = Number(v);
423
+ return Number.isFinite(n) ? n : undefined;
424
+ };
425
+
426
+ const pctPrimary = num(get("x-codex-primary-used-percent"));
427
+ const pctSecondary = num(get("x-codex-secondary-used-percent"));
428
+ const resetPrimary = num(get("x-codex-primary-reset-at")); // unix seconds
429
+ const resetSecondary = num(get("x-codex-secondary-reset-at"));
430
+ const limitName = get("x-codex-limit-name");
431
+
432
+ if (pctPrimary == null && pctSecondary == null) return undefined;
433
+
434
+ // Credits (optional).
435
+ let credits: { balance: number; unlimited: boolean } | undefined;
436
+ const hasCredits = get("x-codex-credits-has-credits");
437
+ if (hasCredits != null) {
438
+ const balance = num(get("x-codex-credits-balance")) ?? 0;
439
+ const unlimited =
440
+ get("x-codex-credits-unlimited") === "true" || get("x-codex-credits-unlimited") === "1";
441
+ credits = { balance, unlimited };
442
+ }
443
+
444
+ return {
445
+ plan: limitName ?? "codex",
446
+ session5h:
447
+ pctPrimary != null && resetPrimary != null
448
+ ? { usedPct: pctPrimary, resetMs: resetPrimary * 1000 }
449
+ : undefined,
450
+ weekly:
451
+ pctSecondary != null && resetSecondary != null
452
+ ? { usedPct: pctSecondary, resetMs: resetSecondary * 1000 }
453
+ : undefined,
454
+ credits,
455
+ };
456
+ }
457
+
458
+ const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
459
+
460
+ interface CodexUsage {
461
+ plan_type?: string;
462
+ rate_limit?: {
463
+ primary_window?: {
464
+ used_percent?: number;
465
+ reset_at?: number;
466
+ limit_window_seconds?: number;
467
+ };
468
+ secondary_window?: {
469
+ used_percent?: number;
470
+ reset_at?: number;
471
+ limit_window_seconds?: number;
472
+ };
473
+ };
474
+ credits?: {
475
+ has_credits?: boolean;
476
+ unlimited?: boolean;
477
+ balance?: number | string;
478
+ };
479
+ rate_limit_reset_credits?: { available_count?: number };
480
+ }
481
+
482
+ function accountIdFromAccessToken(accessToken: string): string | undefined {
483
+ try {
484
+ const payload = accessToken.split(".")[1];
485
+ if (!payload) return undefined;
486
+ const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as Record<
487
+ string,
488
+ unknown
489
+ >;
490
+ const accountId = decoded["https://api.openai.com/auth.chatgpt_account_id"];
491
+ return typeof accountId === "string" && accountId ? accountId : undefined;
492
+ } catch {
493
+ return undefined;
494
+ }
495
+ }
496
+
497
+ /**
498
+ * Fetch OpenAI Codex subscription quota from the REST endpoint using pi's
499
+ * request-time credential resolver, which refreshes OAuth before returning.
500
+ */
501
+ export async function fetchCodexQuota(
502
+ modelRegistry: ModelRegistry,
503
+ provider = "openai-codex",
504
+ signal?: AbortSignal,
505
+ ): Promise<
506
+ | { quota: NonNullable<ProviderQuota["planQuota"]>; error?: string }
507
+ | { quota: undefined; error: string }
508
+ | undefined
509
+ > {
510
+ const accessToken = await resolveApiKey(modelRegistry, provider);
511
+ if (!accessToken) {
512
+ return {
513
+ quota: undefined,
514
+ error: `No Codex credentials in pi's auth for "${provider}" — run /login to authenticate.`,
515
+ };
516
+ }
517
+
518
+ // Prefer the claim from the freshly resolved access token. Older stored
519
+ // credentials are consulted only for providers whose token omits the claim.
520
+ let accountId = accountIdFromAccessToken(accessToken);
521
+ if (!accountId) {
522
+ try {
523
+ const credential = readStoredCredential(provider);
524
+ if (credential?.type === "oauth") {
525
+ accountId = typeof credential.accountId === "string" ? credential.accountId : undefined;
526
+ }
527
+ } catch {
528
+ // The account header is optional; quota fetching can still proceed.
529
+ }
530
+ }
531
+
532
+ // Fetch the quota.
533
+ const headers: Record<string, string> = {
534
+ Authorization: `Bearer ${accessToken}`,
535
+ Accept: "application/json",
536
+ };
537
+ if (accountId) headers["ChatGPT-Account-Id"] = accountId;
538
+ let json: CodexUsage;
539
+ try {
540
+ const res = await fetch(CODEX_USAGE_URL, { headers, signal });
541
+ if (res.status === 401) {
542
+ return {
543
+ quota: undefined,
544
+ error: "Codex token rejected by server — sign in to Codex CLI to refresh credentials.",
545
+ };
546
+ }
547
+ if (!res.ok) {
548
+ return {
549
+ quota: undefined,
550
+ error: `Codex usage HTTP ${res.status}`,
551
+ };
552
+ }
553
+ json = (await res.json()) as CodexUsage;
554
+ } catch (e) {
555
+ return {
556
+ quota: undefined,
557
+ error: `Codex usage fetch failed: ${errMsg(e)}`,
558
+ };
559
+ }
560
+
561
+ const rl = json.rate_limit;
562
+ const primary = rl?.primary_window;
563
+ const secondary = rl?.secondary_window;
564
+ const plan: string =
565
+ (rl && (rl as { limit_name?: string }).limit_name) || json.plan_type || "codex";
566
+
567
+ return {
568
+ quota: {
569
+ plan,
570
+ session5h:
571
+ primary?.used_percent != null && primary.reset_at != null
572
+ ? { usedPct: primary.used_percent, resetMs: primary.reset_at * 1000 }
573
+ : undefined,
574
+ weekly:
575
+ secondary?.used_percent != null && secondary.reset_at != null
576
+ ? {
577
+ usedPct: secondary.used_percent,
578
+ resetMs: secondary.reset_at * 1000,
579
+ }
580
+ : undefined,
581
+ credits: json.credits
582
+ ? {
583
+ balance: Number(json.credits.balance ?? 0),
584
+ unlimited: json.credits.unlimited ?? false,
585
+ }
586
+ : undefined,
587
+ },
588
+ };
589
+ }
590
+
591
+ /**
592
+ * Build a full provider quota snapshot: merge already-captured rate-limit
593
+ * headers with a fresh live fetch from the provider's billing API (if any).
594
+ *
595
+ * `capturedRateLimits` comes from the `after_provider_response` event in the
596
+ * orchestrator (index.ts), so it reflects the most recent real request made by
597
+ * the active provider.
598
+ */
599
+ export async function fetchProviderQuota(
600
+ modelRegistry: ModelRegistry,
601
+ active: ActiveProvider | null,
602
+ capturedRateLimits: RateLimitWindow[],
603
+ capturedHeaders: Record<string, string> = {},
604
+ signal?: AbortSignal,
605
+ ): Promise<ProviderQuota> {
606
+ const result: ProviderQuota = {
607
+ active,
608
+ fetchedAt: Date.now(),
609
+ rateLimits: capturedRateLimits,
610
+ source: "none",
611
+ notes: [],
612
+ };
613
+
614
+ if (!active) {
615
+ result.error = "No active model";
616
+ return result;
617
+ }
618
+
619
+ if (!active.hasKey) {
620
+ result.notes.push(
621
+ `No auth configured for "${active.provider}" — set a key via /login or an env var to enable live quota.`,
622
+ );
623
+ }
624
+
625
+ // Resolve the key through pi's request-time model registry auth chain.
626
+ // OpenAI Codex subscription quota uses the refreshed OAuth access token
627
+ // returned by that public extension API.
628
+ //
629
+ // Why NOT headers? pi's codex provider uses a WebSocket for streaming and
630
+ // only surfaces the HTTP/SSE response headers to `after_provider_response`.
631
+ // The WebSocket upgrade response (which carries `x-codex-*`) is NOT exposed
632
+ // to extensions, so the header path is unreliable for codex. The REST
633
+ // endpoint is the only reliable way to get live quota data.
634
+ if (!result.planQuota) {
635
+ const lowerKeys = Object.keys(capturedHeaders).map((k) => k.toLowerCase());
636
+ const hasCodexHeaders = lowerKeys.includes("x-codex-primary-used-percent");
637
+ const isCodex =
638
+ active.provider === "openai-codex" ||
639
+ active.provider.startsWith("openai-codex-") ||
640
+ hasCodexHeaders;
641
+ if (isCodex) {
642
+ // Prefer the REST fetch (works proactively, no request needed).
643
+ const rest = await fetchCodexQuota(modelRegistry, active.provider, signal);
644
+ if (rest) {
645
+ // Only set planQuota when we actually got real window data. On error
646
+ // (token expired / rejected), rest.quota is undefined so planQuota
647
+ // stays unset and the view falls to the subscription-hint branch,
648
+ // which shows the clear error instead of an empty "codex plan" label.
649
+ if (rest.quota) {
650
+ result.planQuota = rest.quota;
651
+ result.source = "live";
652
+ }
653
+ if (rest.error) result.notes.push(rest.error);
654
+ }
655
+ // Fallback: parse any captured headers (in case the user just made
656
+ // a request and some headers leaked through, or they were captured
657
+ // by pi's WS path in the future).
658
+ const pq = result.planQuota;
659
+ if (pq && !pq.session5h && !pq.weekly) {
660
+ const fromHeaders = parseCodexQuota(capturedHeaders);
661
+ if (fromHeaders) {
662
+ result.planQuota = fromHeaders;
663
+ result.source = result.source === "live" ? "live" : "headers";
664
+ }
665
+ }
666
+ }
667
+ }
668
+
669
+ const key = await resolveApiKey(modelRegistry, active.provider);
670
+
671
+ if (key) {
672
+ try {
673
+ if (active.provider === "openrouter") {
674
+ const credits = await fetchOpenRouterCredits(active.baseUrl, key, signal);
675
+ if (credits) {
676
+ result.credits = credits;
677
+ result.source = "live";
678
+ }
679
+ } else if (active.provider === "openai") {
680
+ const costs = await fetchOpenAICosts(active.baseUrl, key, signal);
681
+ if (costs) {
682
+ result.spend5h = costs.spend5h;
683
+ result.spend7d = costs.spend7d;
684
+ result.monthlyLimit = costs.monthlyLimit;
685
+ result.source = "live";
686
+ }
687
+ } else if (active.provider === "zai") {
688
+ // ZAI GLM coding plans expose a native 5h-session + weekly quota via an
689
+ // undocumented monitor endpoint. This is the authoritative upstream view
690
+ // (used/remaining % with reset countdown) — replaces session-derived bars.
691
+ const planQuota = await fetchZaiPlanQuota(key, signal);
692
+ if (planQuota) {
693
+ result.planQuota = planQuota;
694
+ result.source = "live";
695
+ }
696
+ }
697
+ } catch (e) {
698
+ result.notes.push(`Live quota fetch failed: ${errMsg(e)}`);
699
+ }
700
+ }
701
+
702
+ if (result.source === "none" && capturedRateLimits.length > 0) result.source = "headers";
703
+ return result;
704
+ }