@aemonge-dev/pi-stardeck 0.5.0 → 0.6.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.
Files changed (29) hide show
  1. package/README.md +3 -2
  2. package/docs/architecture.md +8 -5
  3. package/licenses/README.md +9 -7
  4. package/package.json +2 -2
  5. package/packages/footer/README.md +15 -0
  6. package/packages/footer/index.ts +37 -0
  7. package/packages/footer/package.json +32 -0
  8. package/packages/footer/src/protocol.ts +10 -0
  9. package/packages/footer/src/quota.ts +26 -0
  10. package/packages/footer/src/telemetry.ts +59 -0
  11. package/packages/pi-stardeck/README.md +7 -6
  12. package/packages/status/package.json +1 -1
  13. package/src/health.ts +0 -1
  14. package/src/index.ts +3 -4
  15. package/licenses/pi-fancy-footer-LICENSE +0 -21
  16. package/packages/pi-fancy-footer-full-palette/LICENSE +0 -21
  17. package/packages/pi-fancy-footer-full-palette/LOCAL-CHANGES.md +0 -41
  18. package/packages/pi-fancy-footer-full-palette/README.md +0 -468
  19. package/packages/pi-fancy-footer-full-palette/package.json +0 -52
  20. package/packages/pi-fancy-footer-full-palette/src/api.ts +0 -204
  21. package/packages/pi-fancy-footer-full-palette/src/config-editor.ts +0 -634
  22. package/packages/pi-fancy-footer-full-palette/src/config.ts +0 -852
  23. package/packages/pi-fancy-footer-full-palette/src/data-widgets.ts +0 -259
  24. package/packages/pi-fancy-footer-full-palette/src/git.ts +0 -130
  25. package/packages/pi-fancy-footer-full-palette/src/index.ts +0 -479
  26. package/packages/pi-fancy-footer-full-palette/src/layout.ts +0 -309
  27. package/packages/pi-fancy-footer-full-palette/src/provider-status.ts +0 -1466
  28. package/packages/pi-fancy-footer-full-palette/src/render.ts +0 -1061
  29. package/packages/pi-fancy-footer-full-palette/src/shared.ts +0 -903
@@ -1,1466 +0,0 @@
1
- import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
- import { dirname, join } from "node:path";
3
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
- import {
5
- type GaugeSegment,
6
- type GaugeStyleDef,
7
- type ProviderStatusConfigSnapshot,
8
- type ProviderStatusScopedWindow,
9
- type ProviderStatusSnapshot,
10
- type ProviderStatusState,
11
- type ProviderStatusWindow,
12
- buildGauge,
13
- displayedGaugePercent,
14
- formatGaugePercent,
15
- gaugeSeverity,
16
- } from "./shared.ts";
17
-
18
- export const CODEX_USAGE_URL = "https://chatgpt.com/codex/settings/usage";
19
- const CODEX_USAGE_ENDPOINT = "https://chatgpt.com/backend-api/wham/usage";
20
- const CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
21
- const CODEX_TOKEN_URL = "https://auth.openai.com/oauth/token";
22
- const CODEX_PRIMARY_WINDOW_LABEL = "5h";
23
- const CODEX_SECONDARY_WINDOW_LABEL = "7d";
24
- export const CLAUDE_USAGE_URL = "https://claude.ai/settings/usage";
25
- const CLAUDE_USAGE_ENDPOINT = "https://api.anthropic.com/api/oauth/usage";
26
- const CLAUDE_CLIENT_ID = "https://claude.ai/oauth/claude-code-client-metadata";
27
- const CLAUDE_TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
28
- const CLAUDE_PRIMARY_WINDOW_LABEL = "5h";
29
- const CLAUDE_SECONDARY_WINDOW_LABEL = "7d";
30
- export const ZAI_QUOTA_ENDPOINT = "https://api.z.ai/api/monitor/usage/quota/limit";
31
- const ZAI_PROVIDER_ID = "zai";
32
-
33
- type HeaderLike = Record<string, string | number | boolean | undefined | null>;
34
-
35
- type TokenRefreshResult = { ok: true; stdout: string } | { ok: false; error: Error };
36
-
37
- export interface ProviderStatusSource {
38
- id: string;
39
- label: string;
40
- usageUrl: string;
41
- preserveMissingWindows: boolean;
42
- fetch(pi: ExtensionAPI): Promise<ProviderStatusSnapshot>;
43
- parseHeaders(headers: HeaderLike, now?: Date): ProviderStatusSnapshot | undefined;
44
- }
45
-
46
- const CODEX_SOURCE: ProviderStatusSource = {
47
- id: "openai-codex",
48
- label: "Codex",
49
- usageUrl: CODEX_USAGE_URL,
50
- preserveMissingWindows: false,
51
- fetch: fetchCodexProviderStatus,
52
- parseHeaders: parseCodexRateLimitHeaders,
53
- };
54
-
55
- const ANTHROPIC_SOURCE: ProviderStatusSource = {
56
- id: "anthropic",
57
- label: "Claude",
58
- usageUrl: CLAUDE_USAGE_URL,
59
- preserveMissingWindows: true,
60
- fetch: fetchClaudeProviderStatus,
61
- parseHeaders: () => undefined,
62
- };
63
-
64
- const ZAI_SOURCE: ProviderStatusSource = {
65
- id: ZAI_PROVIDER_ID,
66
- label: "GLM",
67
- usageUrl: ZAI_QUOTA_ENDPOINT,
68
- preserveMissingWindows: false,
69
- fetch: fetchZaiProviderStatus,
70
- parseHeaders: () => undefined,
71
- };
72
-
73
- export const PROVIDER_STATUS_SOURCES: readonly ProviderStatusSource[] = [
74
- CODEX_SOURCE,
75
- ANTHROPIC_SOURCE,
76
- ZAI_SOURCE,
77
- ];
78
-
79
- type ModelLike = {
80
- id?: unknown;
81
- name?: unknown;
82
- displayName?: unknown;
83
- provider?: unknown;
84
- providerId?: unknown;
85
- };
86
-
87
- function modelValue(value: unknown): string {
88
- return typeof value === "string" ? value.trim().toLowerCase() : "";
89
- }
90
-
91
- function looksLikeOpenAIModel(value: string): boolean {
92
- if (!value) return false;
93
-
94
- const normalized = value.replace(/[/_:\s]+/g, "-");
95
- return (
96
- normalized.includes("openai") ||
97
- normalized.includes("codex") ||
98
- normalized.includes("chatgpt") ||
99
- /(^|-)gpt(?:[0-9-]|$)/.test(normalized) ||
100
- /(^|-)o[134](?:-|$)/.test(normalized)
101
- );
102
- }
103
-
104
- function looksLikeAnthropicModel(value: string): boolean {
105
- if (!value) return false;
106
-
107
- const normalized = value.replace(/[/_:\s]+/g, "-");
108
- return (
109
- normalized.includes("anthropic") ||
110
- normalized.includes("claude") ||
111
- /(^|-)(sonnet|opus|haiku|fable)(?:-|$)/.test(normalized)
112
- );
113
- }
114
-
115
- function looksLikeZaiModel(value: string): boolean {
116
- if (!value) return false;
117
-
118
- const normalized = value.replace(/[/_:\s]+/g, "-");
119
- return (
120
- normalized.includes("zai") ||
121
- normalized.includes("zhipu") ||
122
- normalized.includes("bigmodel") ||
123
- normalized.includes("chatglm") ||
124
- /(^|-)glm(?:[0-9.-]|$)/.test(normalized)
125
- );
126
- }
127
-
128
- function looksLikeProviderModel(providerId: string, value: string): boolean {
129
- if (providerId === CODEX_SOURCE.id) return looksLikeOpenAIModel(value);
130
- if (providerId === ANTHROPIC_SOURCE.id) return looksLikeAnthropicModel(value);
131
- if (providerId === ZAI_SOURCE.id) return looksLikeZaiModel(value);
132
- return true;
133
- }
134
-
135
- export function isProviderStatusRelevantToModel(
136
- providerId: string,
137
- model: ModelLike | string | undefined,
138
- ): boolean {
139
- if (
140
- providerId !== CODEX_SOURCE.id &&
141
- providerId !== ANTHROPIC_SOURCE.id &&
142
- providerId !== ZAI_SOURCE.id
143
- ) {
144
- return true;
145
- }
146
-
147
- if (typeof model === "string") {
148
- return looksLikeProviderModel(providerId, modelValue(model));
149
- }
150
- if (!model) return false;
151
-
152
- const provider = modelValue(model.provider) || modelValue(model.providerId);
153
- if (provider) return looksLikeProviderModel(providerId, provider);
154
-
155
- return [
156
- modelValue(model.id),
157
- modelValue(model.name),
158
- modelValue(model.displayName),
159
- ].some((value) => looksLikeProviderModel(providerId, value));
160
- }
161
-
162
- // Splits an identifier into lowercase alphanumeric tokens, so
163
- // "eu.anthropic.claude-fable-5" becomes ["eu", "anthropic", "claude", "fable", "5"].
164
- function modelTokens(value: string): string[] {
165
- return value
166
- .toLowerCase()
167
- .split(/[^a-z0-9]+/)
168
- .filter((token) => token.length > 0);
169
- }
170
-
171
- // A scoped window applies when every token of the provider's scope name occurs
172
- // in the active model's identifier: scope "Fable" matches "claude-fable-5", and
173
- // scope "Opus 4.5" matches "claude-opus-4-5-20251101" but not "claude-opus-4-1".
174
- function scopeMatchesModel(
175
- scopeModel: string,
176
- model: ModelLike | string | undefined,
177
- ): boolean {
178
- const scopeTokens = modelTokens(scopeModel);
179
- if (scopeTokens.length === 0) return false;
180
-
181
- const candidates =
182
- typeof model === "string"
183
- ? [model]
184
- : model
185
- ? [model.id, model.name, model.displayName]
186
- : [];
187
-
188
- return candidates.some((candidate) => {
189
- if (typeof candidate !== "string") return false;
190
- const tokens = new Set(modelTokens(candidate));
191
- return scopeTokens.every((token) => tokens.has(token));
192
- });
193
- }
194
-
195
- /**
196
- * Resolves model-scoped quota windows against the active model. Scoped windows
197
- * replace the account-wide window that carries the same label, so a Fable
198
- * session shows the weekly Fable cap in the existing weekly slot instead of the
199
- * looser all-models number. When both caps are known the one with less headroom
200
- * wins: whichever runs out first is the limit that stops the session, so the
201
- * footer never reports more headroom than actually remains.
202
- */
203
- export function projectProviderStatusForModel(
204
- snapshot: ProviderStatusSnapshot,
205
- model: ModelLike | string | undefined,
206
- ): ProviderStatusSnapshot {
207
- if (!snapshot.scoped || snapshot.scoped.length === 0) return snapshot;
208
-
209
- const applicable = snapshot.scoped.filter((window) =>
210
- scopeMatchesModel(window.model, model),
211
- );
212
- if (applicable.length === 0) return snapshot;
213
-
214
- const primary = applyScopedWindow(snapshot.primary, applicable);
215
- const secondary = applyScopedWindow(snapshot.secondary, applicable);
216
- if (primary === snapshot.primary && secondary === snapshot.secondary) {
217
- return snapshot;
218
- }
219
-
220
- return {
221
- ...snapshot,
222
- ...(primary ? { primary } : {}),
223
- ...(secondary ? { secondary } : {}),
224
- state: computeProviderStatusState(primary, secondary),
225
- };
226
- }
227
-
228
- export interface ProviderStatusQuotaWindow {
229
- label: string;
230
- percent: number;
231
- resetAt?: number;
232
- }
233
-
234
- /**
235
- * Collapses relevant provider snapshots into the deck's quota window list: the
236
- * primary and secondary windows of every model-relevant snapshot, ordered
237
- * shortest-window-first and pessimistic across providers — when two providers
238
- * report the same label, the one with less headroom wins, mirroring the footer's
239
- * never-promise-more-headroom rule.
240
- */
241
- export function providerStatusQuotaWindows(
242
- snapshots: Iterable<ProviderStatusSnapshot>,
243
- model: ModelLike | string | undefined,
244
- ): ProviderStatusQuotaWindow[] {
245
- const byLabel = new Map<string, ProviderStatusQuotaWindow>();
246
- for (const snapshot of snapshots) {
247
- if (!isProviderStatusRelevantToModel(snapshot.provider, model)) continue;
248
- const projected = projectProviderStatusForModel(snapshot, model);
249
- for (const window of [projected.primary, projected.secondary]) {
250
- if (!window || window.usageUnknown) continue;
251
- const next: ProviderStatusQuotaWindow = {
252
- label: window.label,
253
- percent: window.usedPercent,
254
- ...(window.resetAt !== undefined ? { resetAt: window.resetAt } : {}),
255
- };
256
- const existing = byLabel.get(window.label);
257
- if (!existing || next.percent > existing.percent) {
258
- byLabel.set(window.label, next);
259
- }
260
- }
261
- }
262
- return Array.from(byLabel.values()).slice(0, 2);
263
- }
264
-
265
- function applyScopedWindow(
266
- window: ProviderStatusWindow | undefined,
267
- scoped: readonly ProviderStatusScopedWindow[],
268
- ): ProviderStatusWindow | undefined {
269
- const matches = scoped.filter((candidate) => candidate.label === window?.label);
270
- if (matches.length === 0) return window;
271
-
272
- let strictest = window;
273
- for (const match of matches) {
274
- if (strictest) {
275
- if (match.usageUnknown) continue;
276
- if (!strictest.usageUnknown && match.usedPercent <= strictest.usedPercent) {
277
- continue;
278
- }
279
- }
280
- const { model: _model, ...replacement } = match;
281
- strictest = replacement;
282
- }
283
- return strictest;
284
- }
285
-
286
- function enabledProviderStatusSources(
287
- config: Pick<ProviderStatusConfigSnapshot, "providers">,
288
- ): readonly ProviderStatusSource[] {
289
- return PROVIDER_STATUS_SOURCES.filter((source) =>
290
- config.providers.includes(source.id),
291
- );
292
- }
293
-
294
- interface AuthCredentials {
295
- provider: "openai-codex" | "anthropic";
296
- source: "pi" | "codex";
297
- path: string;
298
- accessToken: string;
299
- refreshToken?: string;
300
- accountId?: string;
301
- expiresAtMs?: number;
302
- raw: Record<string, unknown>;
303
- }
304
-
305
- export function resetCountdownText(
306
- window: Pick<ProviderStatusWindow, "usedPercent" | "resetAt" | "usageUnknown">,
307
- role: "primary" | "secondary",
308
- config: Pick<ProviderStatusConfigSnapshot, "showReset" | "resetMinUsedPercent">,
309
- nowMs: number,
310
- ): string {
311
- const roleEnabled =
312
- config.showReset === "all" ||
313
- (config.showReset === "primary" && role === "primary");
314
- const displayedUsedPercent = displayedGaugePercent(window.usedPercent);
315
- if (
316
- !roleEnabled ||
317
- window.usageUnknown ||
318
- window.resetAt === undefined ||
319
- displayedUsedPercent < config.resetMinUsedPercent
320
- ) {
321
- return "";
322
- }
323
- return formatResetCountdown(window.resetAt, nowMs);
324
- }
325
-
326
- export function formatProviderStatusText(
327
- snapshot: ProviderStatusSnapshot | undefined,
328
- config: Pick<
329
- ProviderStatusConfigSnapshot,
330
- "showCredits" | "showReset" | "resetMinUsedPercent"
331
- >,
332
- nowMs = Date.now(),
333
- ): string {
334
- if (!snapshot) return "";
335
- const hasWindows = snapshot.primary !== undefined || snapshot.secondary !== undefined;
336
- if (
337
- snapshot.state === "unavailable" &&
338
- !hasWindows &&
339
- (!config.showCredits || !snapshot.credits)
340
- ) {
341
- return "";
342
- }
343
-
344
- const parts: string[] = [];
345
- for (const [role, window] of [
346
- ["primary", snapshot.primary],
347
- ["secondary", snapshot.secondary],
348
- ] as const) {
349
- if (!window) continue;
350
- let part = window.usageUnknown
351
- ? `${window.label}:—`
352
- : `${window.label}:${formatGaugePercent(window.usedPercent)}`;
353
- const reset = resetCountdownText(window, role, config, nowMs);
354
- if (reset) part += ` ${reset}`;
355
- parts.push(part);
356
- }
357
- if (config.showCredits && snapshot.credits) {
358
- parts.push(`cr:${snapshot.credits}`);
359
- }
360
-
361
- return parts.join(" ");
362
- }
363
-
364
- export function providerStatusColor(
365
- snapshot: ProviderStatusSnapshot | undefined,
366
- ): "success" | "warning" | "error" | "dim" {
367
- if (!snapshot || snapshot.state === "unavailable") return "dim";
368
- if (snapshot.state === "error") return "error";
369
- if (snapshot.state === "warning") return "warning";
370
- return "success";
371
- }
372
-
373
- export interface ProviderStatusGaugeSegment extends GaugeSegment {
374
- label: string;
375
- role: "primary" | "secondary";
376
- usedPercent: number;
377
- resetAt?: number;
378
- usageUnknown?: true;
379
- }
380
-
381
- export function buildProviderStatusGauge(
382
- snapshot: ProviderStatusSnapshot | undefined,
383
- style: GaugeStyleDef,
384
- cells: number,
385
- ): ProviderStatusGaugeSegment[] {
386
- if (!snapshot) return [];
387
- const segments: ProviderStatusGaugeSegment[] = [];
388
- for (const [role, window] of [
389
- ["primary", snapshot.primary],
390
- ["secondary", snapshot.secondary],
391
- ] as const) {
392
- if (!window) continue;
393
- const gauge = buildGauge(
394
- window.usageUnknown ? 0 : window.usedPercent,
395
- style,
396
- cells,
397
- );
398
- segments.push({
399
- label: window.label,
400
- role,
401
- usedPercent: window.usedPercent,
402
- ...(window.resetAt !== undefined ? { resetAt: window.resetAt } : {}),
403
- ...(window.usageUnknown ? { usageUnknown: true as const } : {}),
404
- ...gauge,
405
- ...(window.usageUnknown ? { percentText: "—" } : {}),
406
- });
407
- }
408
- return segments;
409
- }
410
-
411
- export async function collectProviderStatus(
412
- pi: ExtensionAPI,
413
- config: ProviderStatusConfigSnapshot,
414
- ): Promise<ProviderStatusSnapshot[]> {
415
- const snapshots = await Promise.all(
416
- enabledProviderStatusSources(config).map((source) =>
417
- collectProviderStatusFromSource(pi, source, config),
418
- ),
419
- );
420
- return snapshots;
421
- }
422
-
423
- async function collectProviderStatusFromSource(
424
- pi: ExtensionAPI,
425
- source: ProviderStatusSource,
426
- config: ProviderStatusConfigSnapshot,
427
- ): Promise<ProviderStatusSnapshot> {
428
- const cached = await readProviderStatusCache(source.id);
429
- if (isProviderStatusFresh(cached, config.cacheTtlMs)) {
430
- // A window can reset within the cache TTL, which leaves the cached
431
- // percentages describing the previous period.
432
- return (
433
- displayableCachedStatus(cached, { requireResetAt: false }) ?? {
434
- ...cached,
435
- source: "cache",
436
- }
437
- );
438
- }
439
-
440
- try {
441
- // Anthropic refreshes can be partial and report only the weekly window.
442
- // Codex refreshes are authoritative: OpenAI can remove a window and promote
443
- // the weekly window to primary, so retaining a missing window would show a
444
- // stale or duplicated quota.
445
- const fresh = await source.fetch(pi);
446
- const merged = source.preserveMissingWindows
447
- ? mergeProviderStatus(
448
- displayableCachedStatus(cached, { requireResetAt: true }),
449
- fresh,
450
- )
451
- : fresh;
452
- const { error: _staleError, ...snapshot } = merged;
453
- await writeProviderStatusCache(snapshot).catch(() => undefined);
454
- return snapshot;
455
- } catch (error) {
456
- // A refresh failed. The cached quota windows remain valid until they
457
- // reset, regardless of why the refresh failed, so keep showing whichever
458
- // windows are still in effect.
459
- return (
460
- displayableCachedStatus(cached, { requireResetAt: true, error }) ??
461
- unavailableProviderStatus(source, error)
462
- );
463
- }
464
- }
465
-
466
- interface CachedStatusProjection {
467
- /**
468
- * Drop windows that carry no reset time. Without one there is nothing to
469
- * confirm the window still exists, which matters once the refresh that would
470
- * have confirmed it failed.
471
- */
472
- requireResetAt: boolean;
473
- error?: unknown;
474
- }
475
-
476
- // Projects a cached snapshot onto the current period: windows that have not
477
- // reset yet keep their usage, and windows past their reset time retain their
478
- // identity with unknown usage. Returns undefined when nothing is displayable.
479
- function displayableCachedStatus(
480
- cached: ProviderStatusSnapshot | undefined,
481
- projection: CachedStatusProjection,
482
- now = new Date(),
483
- ): ProviderStatusSnapshot | undefined {
484
- if (!cached) return undefined;
485
-
486
- const { requireResetAt, error } = projection;
487
- const primary = windowInEffect(cached.primary, now, requireResetAt);
488
- const secondary = windowInEffect(cached.secondary, now, requireResetAt);
489
- if (!primary && !secondary) return undefined;
490
-
491
- const scoped = cached.scoped
492
- ?.map((window) => {
493
- const projected = windowInEffect(window, now, requireResetAt);
494
- return projected ? { ...projected, model: window.model } : undefined;
495
- })
496
- .filter((window): window is ProviderStatusScopedWindow => window !== undefined);
497
- const {
498
- primary: _expiredPrimary,
499
- secondary: _expiredSecondary,
500
- scoped: _expiredScoped,
501
- ...rest
502
- } = cached;
503
- return {
504
- ...rest,
505
- source: "cache",
506
- state: computeProviderStatusState(primary, secondary),
507
- ...(primary ? { primary } : {}),
508
- ...(secondary ? { secondary } : {}),
509
- ...(scoped && scoped.length > 0 ? { scoped } : {}),
510
- ...(error === undefined ? {} : { error: providerStatusErrorMessage(error) }),
511
- };
512
- }
513
-
514
- // A cached window describes the current period while its reset time is still in
515
- // the future. Once that time passes, its cached usage no longer applies. Retain
516
- // the window identity with unknown usage until a refresh confirms the new
517
- // period, which keeps the footer populated without claiming unconfirmed
518
- // headroom.
519
- function windowInEffect(
520
- window: ProviderStatusWindow | undefined,
521
- now: Date,
522
- requireResetAt: boolean,
523
- ): ProviderStatusWindow | undefined {
524
- if (!window) return undefined;
525
- const resetAtMs = (window.resetAt ?? 0) * 1000;
526
- if (!window.resetAt || !Number.isFinite(resetAtMs)) {
527
- return requireResetAt ? undefined : window;
528
- }
529
- if (resetAtMs > now.getTime()) return window;
530
- // The stale reset time stays so the window keeps its identity for merging and
531
- // ordering. Current-period usage remains unknown until the provider reports it.
532
- return {
533
- ...window,
534
- usedPercent: 0,
535
- leftPercent: 100,
536
- usageUnknown: true,
537
- };
538
- }
539
-
540
- function unavailableProviderStatus(
541
- source: ProviderStatusSource,
542
- error: unknown,
543
- ): ProviderStatusSnapshot {
544
- return {
545
- provider: source.id,
546
- source: "api",
547
- fetchedAt: new Date().toISOString(),
548
- state: "unavailable",
549
- url: source.usageUrl,
550
- error: providerStatusErrorMessage(error),
551
- };
552
- }
553
-
554
- function providerStatusErrorMessage(error: unknown): string {
555
- return error instanceof Error ? error.message : String(error);
556
- }
557
-
558
- export async function updateProviderStatusFromHeaders(
559
- headers: HeaderLike,
560
- config?: ProviderStatusConfigSnapshot,
561
- ): Promise<ProviderStatusSnapshot[]> {
562
- const sources = config
563
- ? enabledProviderStatusSources(config)
564
- : PROVIDER_STATUS_SOURCES;
565
-
566
- const updated: ProviderStatusSnapshot[] = [];
567
- for (const source of sources) {
568
- const parsed = source.parseHeaders(headers);
569
- if (!parsed) continue;
570
-
571
- const cached = await readProviderStatusCache(source.id);
572
- const freshCached =
573
- config && isProviderStatusFresh(cached, config.cacheTtlMs)
574
- ? (displayableCachedStatus(cached, { requireResetAt: false }) ?? cached)
575
- : undefined;
576
- const merged = mergeProviderStatus(freshCached, parsed, {
577
- // A duration-bearing weekly primary with no secondary is an explicit
578
- // weekly-only Codex layout, not a sparse update.
579
- preserveMissingWindows:
580
- source.preserveMissingWindows || !isWeeklyOnlyCodexStatus(parsed),
581
- });
582
- await writeProviderStatusCache(merged).catch(() => undefined);
583
- updated.push(merged);
584
- }
585
- return updated;
586
- }
587
-
588
- export function parseCodexRateLimitHeaders(
589
- headers: HeaderLike,
590
- now = new Date(),
591
- ): ProviderStatusSnapshot | undefined {
592
- const primary = parseHeaderWindow(
593
- headers,
594
- "x-codex-primary",
595
- CODEX_PRIMARY_WINDOW_LABEL,
596
- now,
597
- );
598
- const secondary = parseHeaderWindow(
599
- headers,
600
- "x-codex-secondary",
601
- CODEX_SECONDARY_WINDOW_LABEL,
602
- now,
603
- );
604
- const credits = headerValue(headers, "x-codex-credits-balance");
605
- if (!primary && !secondary && credits === undefined) return undefined;
606
-
607
- return {
608
- provider: "openai-codex",
609
- source: "headers",
610
- fetchedAt: now.toISOString(),
611
- state: computeProviderStatusState(primary, secondary),
612
- ...(primary ? { primary } : {}),
613
- ...(secondary ? { secondary } : {}),
614
- ...(credits ? { credits } : {}),
615
- url: CODEX_USAGE_URL,
616
- };
617
- }
618
-
619
- export function normalizeCodexUsageResponse(
620
- value: unknown,
621
- now = new Date(),
622
- ): ProviderStatusSnapshot | undefined {
623
- if (!value || typeof value !== "object" || Array.isArray(value)) {
624
- return undefined;
625
- }
626
-
627
- const obj = value as Record<string, unknown>;
628
- const rateLimit = objectValue(obj.rate_limit);
629
- const primary = normalizeApiWindow(
630
- objectValue(rateLimit?.primary_window),
631
- CODEX_PRIMARY_WINDOW_LABEL,
632
- now,
633
- );
634
- const secondary = normalizeApiWindow(
635
- objectValue(rateLimit?.secondary_window),
636
- CODEX_SECONDARY_WINDOW_LABEL,
637
- now,
638
- );
639
- const creditsObj = objectValue(obj.credits);
640
- const credits = stringValue(creditsObj?.balance);
641
- if (!primary && !secondary && credits === undefined) return undefined;
642
-
643
- return {
644
- provider: "openai-codex",
645
- source: "api",
646
- fetchedAt: now.toISOString(),
647
- state: computeProviderStatusState(primary, secondary),
648
- ...(primary ? { primary } : {}),
649
- ...(secondary ? { secondary } : {}),
650
- ...(credits !== undefined ? { credits } : {}),
651
- url: CODEX_USAGE_URL,
652
- };
653
- }
654
-
655
- export function normalizeClaudeUsageResponse(
656
- value: unknown,
657
- now = new Date(),
658
- ): ProviderStatusSnapshot | undefined {
659
- const obj = objectValue(value);
660
- if (!obj) return undefined;
661
-
662
- const primary = normalizeClaudeUsageWindow(
663
- objectValue(obj.five_hour),
664
- CLAUDE_PRIMARY_WINDOW_LABEL,
665
- now,
666
- );
667
- const secondary = normalizeClaudeUsageWindow(
668
- objectValue(obj.seven_day),
669
- CLAUDE_SECONDARY_WINDOW_LABEL,
670
- now,
671
- );
672
- // The flat five_hour/seven_day fields are the legacy shape, and the sibling
673
- // seven_day_* keys are already null stubs. Fall back to the account-wide
674
- // entries in `limits` so a response that drops the flat fields still reports
675
- // quota instead of hiding the widget.
676
- const limits = normalizeClaudeLimits(obj.limits, now);
677
- const resolvedPrimary = primary ?? limits?.account.get(CLAUDE_PRIMARY_WINDOW_LABEL);
678
- const resolvedSecondary =
679
- secondary ?? limits?.account.get(CLAUDE_SECONDARY_WINDOW_LABEL);
680
- if (!resolvedPrimary && !resolvedSecondary) return undefined;
681
-
682
- return {
683
- provider: "anthropic",
684
- source: "api",
685
- fetchedAt: now.toISOString(),
686
- state: computeProviderStatusState(resolvedPrimary, resolvedSecondary),
687
- ...(resolvedPrimary ? { primary: resolvedPrimary } : {}),
688
- ...(resolvedSecondary ? { secondary: resolvedSecondary } : {}),
689
- // An empty array records that the provider reported no scoped caps, which
690
- // retires cached ones. Omitting the key means the response carried no
691
- // `limits` at all, so cached scoped windows survive.
692
- ...(limits ? { scoped: limits.scoped } : {}),
693
- url: CLAUDE_USAGE_URL,
694
- };
695
- }
696
-
697
- interface ClaudeLimitWindows {
698
- /** Account-wide windows, keyed by window label. */
699
- account: Map<string, ProviderStatusWindow>;
700
- scoped: ProviderStatusScopedWindow[];
701
- }
702
-
703
- // Anthropic reports per-model caps in the `limits` array rather than the flat
704
- // `seven_day_*` keys, which stay null even when a model-scoped cap is in force:
705
- // a weekly Fable limit arrives as kind "weekly_scoped" with
706
- // scope.model.display_name "Fable". Entries scoped to something other than a
707
- // model, such as a surface, are ignored because the footer cannot match them.
708
- // Returns undefined when the response carries no `limits` array at all, which
709
- // callers distinguish from an array that reports no scoped caps.
710
- function normalizeClaudeLimits(
711
- value: unknown,
712
- now: Date,
713
- ): ClaudeLimitWindows | undefined {
714
- if (!Array.isArray(value)) return undefined;
715
-
716
- const account = new Map<string, ProviderStatusWindow>();
717
- const scoped = new Map<string, ProviderStatusScopedWindow>();
718
- const scopedIsActive = new Set<string>();
719
-
720
- for (const entry of value) {
721
- const limit = objectValue(entry);
722
- if (!limit) continue;
723
-
724
- const label = claudeLimitWindowLabel(stringValue(limit.group));
725
- if (!label) continue;
726
-
727
- const usedPercent = numberValue(limit.percent);
728
- if (usedPercent === undefined) continue;
729
-
730
- const window = windowFromUsedPercent(
731
- label,
732
- usedPercent,
733
- resetAtFromTimestamp(stringValue(limit.resets_at)),
734
- now,
735
- );
736
-
737
- const scope = objectValue(limit.scope);
738
- if (!scope) {
739
- if (!account.has(label)) account.set(label, window);
740
- continue;
741
- }
742
-
743
- const model = stringValue(objectValue(scope.model)?.display_name);
744
- if (!model) continue;
745
-
746
- // Anthropic can report more than one cap for the same model and window.
747
- // `is_active` marks which of them is currently in force, so it breaks ties
748
- // between duplicates. It is deliberately not used to filter: it is false
749
- // even for the account-wide windows that always apply, so treating it as a
750
- // gate would drop scoped caps that simply are not binding yet.
751
- const key = `${label}\u0000${model.toLowerCase()}`;
752
- const isActive = limit.is_active === true;
753
- if (scoped.has(key) && !(isActive && !scopedIsActive.has(key))) continue;
754
- scoped.set(key, { ...window, model });
755
- if (isActive) scopedIsActive.add(key);
756
- }
757
-
758
- return { account, scoped: Array.from(scoped.values()) };
759
- }
760
-
761
- function claudeLimitWindowLabel(group: string | undefined): string | undefined {
762
- if (group === "session") return CLAUDE_PRIMARY_WINDOW_LABEL;
763
- if (group === "weekly") return CLAUDE_SECONDARY_WINDOW_LABEL;
764
- return undefined;
765
- }
766
-
767
- export function normalizeZaiQuotaResponse(
768
- value: unknown,
769
- now = new Date(),
770
- ): ProviderStatusSnapshot | undefined {
771
- const envelope = objectValue(value);
772
- const data = objectValue(envelope?.data) ?? envelope;
773
- const limits = Array.isArray(data?.limits) ? data.limits : undefined;
774
- if (!limits) return undefined;
775
-
776
- const windows: ProviderStatusWindow[] = [];
777
- for (const entry of limits) {
778
- const limit = objectValue(entry);
779
- if (!limit) continue;
780
-
781
- const type = stringValue(limit.type) ?? "";
782
- // TIME_LIMIT is the monthly MCP allowance. The deck renders two windows and the
783
- // token/credit pair is the pair that gates coding sessions.
784
- if (type.includes("TIME")) continue;
785
-
786
- const usedPercent = zaiLimitPercent(limit);
787
- if (usedPercent === undefined) continue;
788
-
789
- const resetAt = normalizeResetAt(numberValue(limit.nextResetTime));
790
- windows.push(
791
- windowFromUsedPercent(
792
- zaiWindowLabel(type, resetAt, now),
793
- usedPercent,
794
- resetAt,
795
- now,
796
- ),
797
- );
798
- }
799
-
800
- // z.ai reports neither window ordering nor explicit durations: each limit carries
801
- // only a reset deadline. The nearest reset is the short coding window and the next
802
- // one the weekly cap, so windows sort by reset time before the two slots are filled.
803
- windows.sort((a, b) => (a.resetAt ?? Infinity) - (b.resetAt ?? Infinity));
804
- const [primary, secondary] = windows;
805
- if (!primary) return undefined;
806
-
807
- return {
808
- provider: ZAI_PROVIDER_ID,
809
- source: "api",
810
- fetchedAt: now.toISOString(),
811
- state: computeProviderStatusState(primary, secondary),
812
- primary,
813
- ...(secondary ? { secondary } : {}),
814
- url: ZAI_QUOTA_ENDPOINT,
815
- };
816
- }
817
-
818
- function zaiLimitPercent(limit: Record<string, unknown>): number | undefined {
819
- const percent = numberValue(limit.percentage);
820
- if (percent !== undefined) return percent;
821
- const usage = numberValue(limit.usage);
822
- const current = numberValue(limit.currentValue);
823
- if (usage !== undefined && usage > 0 && current !== undefined) {
824
- return (current / usage) * 100;
825
- }
826
- return undefined;
827
- }
828
-
829
- function zaiWindowLabel(type: string, resetAt: number | undefined, now: Date): string {
830
- if (type.includes("TOKENS") || type.includes("SESSION")) return "5h";
831
- if (type.includes("WEEK")) return "7d";
832
- if (resetAt === undefined) return "5h";
833
- const days = Math.max(0, (resetAt * 1000 - now.getTime()) / 86_400_000);
834
- if (days <= 2) return "5h";
835
- if (days <= 15) return "7d";
836
- return `${Math.max(1, Math.round(days))}d`;
837
- }
838
-
839
- export function isProviderStatusFresh(
840
- snapshot: ProviderStatusSnapshot | undefined,
841
- maxAgeMs: number,
842
- ): snapshot is ProviderStatusSnapshot {
843
- if (!snapshot) return false;
844
- const fetchedAt = Date.parse(snapshot.fetchedAt);
845
- if (!Number.isFinite(fetchedAt)) return false;
846
- const age = Date.now() - fetchedAt;
847
- return age >= 0 && age <= maxAgeMs;
848
- }
849
-
850
- async function fetchCodexProviderStatus(
851
- _pi: ExtensionAPI,
852
- ): Promise<ProviderStatusSnapshot> {
853
- let auth = await resolveCodexAuth();
854
-
855
- for (let attempt = 0; attempt < 2; attempt++) {
856
- const response = await fetch(CODEX_USAGE_ENDPOINT, {
857
- headers: {
858
- authorization: `Bearer ${auth.accessToken}`,
859
- accept: "application/json",
860
- "user-agent": "pi-fancy-footer",
861
- ...(auth.accountId ? { "chatgpt-account-id": auth.accountId } : {}),
862
- },
863
- });
864
- const text = await response.text();
865
-
866
- if (response.ok) {
867
- const parsed = normalizeCodexUsageResponse(JSON.parse(text));
868
- if (parsed) return parsed;
869
- throw new Error("Codex usage response did not contain quota data");
870
- }
871
-
872
- if (
873
- (response.status === 401 || response.status === 403) &&
874
- attempt === 0 &&
875
- auth.refreshToken
876
- ) {
877
- auth = await refreshAuth(auth);
878
- continue;
879
- }
880
-
881
- throw new Error(
882
- `Codex usage request failed (${response.status}): ${text.slice(0, 500)}`,
883
- );
884
- }
885
-
886
- throw new Error("Codex usage request failed after auth refresh");
887
- }
888
-
889
- async function fetchClaudeProviderStatus(
890
- _pi: ExtensionAPI,
891
- ): Promise<ProviderStatusSnapshot> {
892
- let auth = await resolveClaudeAuth();
893
-
894
- for (let attempt = 0; attempt < 2; attempt++) {
895
- const response = await fetch(CLAUDE_USAGE_ENDPOINT, {
896
- headers: {
897
- authorization: `Bearer ${auth.accessToken}`,
898
- accept: "application/json",
899
- "user-agent": "pi-fancy-footer",
900
- },
901
- });
902
- const text = await response.text();
903
-
904
- if (response.ok) {
905
- const parsed = normalizeClaudeUsageResponse(JSON.parse(text));
906
- if (parsed) return parsed;
907
- throw new Error("Claude usage response did not contain quota data");
908
- }
909
-
910
- if (
911
- (response.status === 401 || response.status === 403) &&
912
- attempt === 0 &&
913
- auth.refreshToken
914
- ) {
915
- auth = await refreshAuth(auth);
916
- continue;
917
- }
918
-
919
- throw new Error(
920
- `Claude usage request failed (${response.status}): ${text.slice(0, 500)}`,
921
- );
922
- }
923
-
924
- throw new Error("Claude usage request failed after auth refresh");
925
- }
926
-
927
- async function fetchZaiProviderStatus(
928
- _pi: ExtensionAPI,
929
- ): Promise<ProviderStatusSnapshot> {
930
- const apiKey = await resolveZaiApiKey();
931
- const response = await fetch(ZAI_QUOTA_ENDPOINT, {
932
- headers: {
933
- authorization: `Bearer ${apiKey}`,
934
- accept: "application/json",
935
- "user-agent": "pi-fancy-footer",
936
- },
937
- });
938
- const text = await response.text();
939
- if (!response.ok) {
940
- throw new Error(
941
- `z.ai quota request failed (${response.status}): ${text.slice(0, 500)}`,
942
- );
943
- }
944
-
945
- let body: unknown;
946
- try {
947
- body = JSON.parse(text);
948
- } catch {
949
- throw new Error("z.ai quota response was not valid JSON");
950
- }
951
- // z.ai answers auth and plan errors with HTTP 200 and an error envelope, so
952
- // success is decided by the body, not the status code.
953
- const envelope = objectValue(body);
954
- if (!envelope || envelope.success !== true) {
955
- const code = numberValue(envelope?.code);
956
- const message = stringValue(envelope?.msg) ?? "unknown error";
957
- throw new Error(`z.ai quota request rejected (${code ?? "no code"}): ${message}`);
958
- }
959
-
960
- const parsed = normalizeZaiQuotaResponse(body);
961
- if (!parsed) throw new Error("z.ai quota response did not contain quota data");
962
- return parsed;
963
- }
964
-
965
- // pi stores the z.ai credential as a static API key, either literal or as a
966
- // `${ENV_VAR}` reference resolved from the process environment at runtime.
967
- function expandZaiKeyReference(key: string): string {
968
- const reference = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/.exec(key.trim());
969
- if (!reference) return key.trim();
970
- const fromEnv = process.env[reference[1]]?.trim();
971
- if (fromEnv) return fromEnv;
972
- throw new Error(`z.ai API key references unset environment variable ${reference[1]}`);
973
- }
974
-
975
- async function resolveZaiApiKey(): Promise<string> {
976
- let key: string | undefined;
977
- try {
978
- const raw = JSON.parse(
979
- await readFile(homePath(".pi/agent/auth.json"), "utf8"),
980
- ) as unknown;
981
- key = stringValue(objectValue(objectValue(raw)?.zai)?.key);
982
- } catch {
983
- key = undefined;
984
- }
985
- if (!key) {
986
- throw new Error(
987
- "No usable z.ai API key found. Store a literal key or ${ENV} reference via pi auth first.",
988
- );
989
- }
990
- return expandZaiKeyReference(key);
991
- }
992
-
993
- async function resolveCodexAuth(): Promise<AuthCredentials> {
994
- const pi = await readAuthFile("pi", homePath(".pi/agent/auth.json"), "openai-codex");
995
- if (pi) return refreshIfNeeded(pi);
996
-
997
- const codex = await readAuthFile(
998
- "codex",
999
- homePath(".codex/auth.json"),
1000
- "openai-codex",
1001
- );
1002
- if (codex) return refreshIfNeeded(codex);
1003
-
1004
- throw new Error(
1005
- "No usable Codex OAuth credentials found. Run pi /login for OpenAI Codex or `codex login` first.",
1006
- );
1007
- }
1008
-
1009
- async function resolveClaudeAuth(): Promise<AuthCredentials> {
1010
- const pi = await readAuthFile("pi", homePath(".pi/agent/auth.json"), "anthropic");
1011
- if (pi) return refreshIfNeeded(pi);
1012
-
1013
- throw new Error(
1014
- "No usable Anthropic OAuth credentials found. Run pi /login for Claude/Anthropic first.",
1015
- );
1016
- }
1017
-
1018
- async function refreshIfNeeded(auth: AuthCredentials): Promise<AuthCredentials> {
1019
- if (!auth.refreshToken || !auth.expiresAtMs) return auth;
1020
- if (auth.expiresAtMs > Date.now() + 5 * 60 * 1000) return auth;
1021
- return refreshAuth(auth);
1022
- }
1023
-
1024
- async function refreshAuth(auth: AuthCredentials): Promise<AuthCredentials> {
1025
- if (!auth.refreshToken) return auth;
1026
-
1027
- const refreshConfig =
1028
- auth.provider === "anthropic"
1029
- ? {
1030
- clientId: CLAUDE_CLIENT_ID,
1031
- tokenUrl: CLAUDE_TOKEN_URL,
1032
- label: "Anthropic",
1033
- }
1034
- : {
1035
- clientId: CODEX_CLIENT_ID,
1036
- tokenUrl: CODEX_TOKEN_URL,
1037
- label: "Codex",
1038
- };
1039
- const body = new URLSearchParams({
1040
- grant_type: "refresh_token",
1041
- refresh_token: auth.refreshToken,
1042
- client_id: refreshConfig.clientId,
1043
- }).toString();
1044
-
1045
- const result = await requestTokenRefresh(
1046
- body,
1047
- refreshConfig.tokenUrl,
1048
- `${refreshConfig.label} auth refresh`,
1049
- );
1050
-
1051
- if (!result.ok) throw result.error;
1052
- if (!result.stdout) {
1053
- throw new Error(`Failed to refresh ${refreshConfig.label} auth`);
1054
- }
1055
-
1056
- const refreshed = JSON.parse(result.stdout) as Record<string, unknown>;
1057
- const accessToken = stringValue(refreshed.access_token);
1058
- if (!accessToken) {
1059
- throw new Error(`${refreshConfig.label} auth refresh did not return access_token`);
1060
- }
1061
-
1062
- const refreshToken = stringValue(refreshed.refresh_token) ?? auth.refreshToken;
1063
- const expiresIn = numberValue(refreshed.expires_in);
1064
- const next: AuthCredentials = {
1065
- ...auth,
1066
- accessToken,
1067
- refreshToken,
1068
- ...(expiresIn !== undefined ? { expiresAtMs: Date.now() + expiresIn * 1000 } : {}),
1069
- };
1070
- await persistAuth(next);
1071
- return next;
1072
- }
1073
-
1074
- async function requestTokenRefresh(
1075
- body: string,
1076
- tokenUrl: string,
1077
- label: string,
1078
- ): Promise<TokenRefreshResult> {
1079
- try {
1080
- const response = await fetch(tokenUrl, {
1081
- method: "POST",
1082
- headers: { "content-type": "application/x-www-form-urlencoded" },
1083
- body,
1084
- });
1085
- const text = await response.text();
1086
- if (!response.ok) {
1087
- return {
1088
- ok: false,
1089
- error: new Error(`${label} failed (${response.status}): ${text.slice(0, 500)}`),
1090
- };
1091
- }
1092
- return { ok: true, stdout: text };
1093
- } catch (error) {
1094
- return {
1095
- ok: false,
1096
- error: error instanceof Error ? error : new Error(String(error)),
1097
- };
1098
- }
1099
- }
1100
-
1101
- async function readAuthFile(
1102
- source: "pi" | "codex",
1103
- path: string,
1104
- provider: AuthCredentials["provider"],
1105
- ): Promise<AuthCredentials | undefined> {
1106
- try {
1107
- const raw = JSON.parse(await readFile(path, "utf8")) as unknown;
1108
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
1109
- return source === "pi"
1110
- ? parsePiAuth(raw as Record<string, unknown>, path, provider)
1111
- : parseCodexAuth(raw as Record<string, unknown>, path);
1112
- } catch {
1113
- return undefined;
1114
- }
1115
- }
1116
-
1117
- function parsePiAuth(
1118
- raw: Record<string, unknown>,
1119
- path: string,
1120
- provider: AuthCredentials["provider"],
1121
- ): AuthCredentials | undefined {
1122
- const entry = objectValue(raw[provider]);
1123
- const accessToken = stringValue(entry?.access);
1124
- if (!accessToken) return undefined;
1125
- return {
1126
- provider,
1127
- source: "pi",
1128
- path,
1129
- accessToken,
1130
- ...(stringValue(entry?.refresh)
1131
- ? { refreshToken: stringValue(entry?.refresh) }
1132
- : {}),
1133
- ...(stringValue(entry?.accountId)
1134
- ? { accountId: stringValue(entry?.accountId) }
1135
- : {}),
1136
- ...(numberValue(entry?.expires)
1137
- ? { expiresAtMs: numberValue(entry?.expires) }
1138
- : {}),
1139
- raw,
1140
- };
1141
- }
1142
-
1143
- function parseCodexAuth(
1144
- raw: Record<string, unknown>,
1145
- path: string,
1146
- ): AuthCredentials | undefined {
1147
- const tokens = objectValue(raw.tokens);
1148
- const accessToken = stringValue(tokens?.access_token);
1149
- if (!accessToken) return undefined;
1150
- return {
1151
- provider: "openai-codex",
1152
- source: "codex",
1153
- path,
1154
- accessToken,
1155
- ...(stringValue(tokens?.refresh_token)
1156
- ? { refreshToken: stringValue(tokens?.refresh_token) }
1157
- : {}),
1158
- ...(stringValue(tokens?.account_id)
1159
- ? { accountId: stringValue(tokens?.account_id) }
1160
- : {}),
1161
- raw,
1162
- };
1163
- }
1164
-
1165
- async function persistAuth(auth: AuthCredentials): Promise<void> {
1166
- const raw = JSON.parse(await readFile(auth.path, "utf8")) as Record<string, unknown>;
1167
-
1168
- if (auth.source === "pi") {
1169
- const entry = objectValue(raw[auth.provider]);
1170
- if (entry) {
1171
- entry.access = auth.accessToken;
1172
- if (auth.refreshToken) entry.refresh = auth.refreshToken;
1173
- if (auth.expiresAtMs) entry.expires = auth.expiresAtMs;
1174
- if (auth.accountId) entry.accountId = auth.accountId;
1175
- }
1176
- } else {
1177
- const tokens = objectValue(raw.tokens);
1178
- if (tokens) {
1179
- tokens.access_token = auth.accessToken;
1180
- if (auth.refreshToken) tokens.refresh_token = auth.refreshToken;
1181
- }
1182
- raw.last_refresh = new Date().toISOString();
1183
- }
1184
-
1185
- await writeJsonAtomic(auth.path, raw);
1186
- }
1187
-
1188
- async function readProviderStatusCache(
1189
- providerId: string,
1190
- ): Promise<ProviderStatusSnapshot | undefined> {
1191
- try {
1192
- const parsed = JSON.parse(
1193
- await readFile(providerStatusCachePath(providerId), "utf8"),
1194
- ) as unknown;
1195
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1196
- return undefined;
1197
- }
1198
- return parsed as ProviderStatusSnapshot;
1199
- } catch {
1200
- return undefined;
1201
- }
1202
- }
1203
-
1204
- async function writeProviderStatusCache(
1205
- snapshot: ProviderStatusSnapshot,
1206
- ): Promise<void> {
1207
- await writeJsonAtomic(providerStatusCachePath(snapshot.provider), snapshot);
1208
- }
1209
-
1210
- async function writeJsonAtomic(path: string, value: unknown): Promise<void> {
1211
- await mkdir(dirname(path), { recursive: true });
1212
- const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
1213
- await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
1214
- await rename(tmp, path);
1215
- }
1216
-
1217
- function mergeProviderStatus(
1218
- existing: ProviderStatusSnapshot | undefined,
1219
- update: ProviderStatusSnapshot,
1220
- options: { preserveMissingWindows?: boolean } = {},
1221
- ): ProviderStatusSnapshot {
1222
- if (!existing) return update;
1223
-
1224
- const preserveMissingWindows = options.preserveMissingWindows ?? true;
1225
- const windows = new Map<string, ProviderStatusWindow>();
1226
- if (preserveMissingWindows) {
1227
- for (const window of providerStatusWindows(existing)) {
1228
- windows.set(window.label, window);
1229
- }
1230
- }
1231
- for (const window of providerStatusWindows(update)) {
1232
- windows.set(window.label, window);
1233
- }
1234
-
1235
- const [primary, secondary] = Array.from(windows.values()).sort(
1236
- (a, b) => windowDurationMinutes(a.label) - windowDurationMinutes(b.label),
1237
- );
1238
- const {
1239
- primary: _existingPrimary,
1240
- secondary: _existingSecondary,
1241
- scoped: _existingScoped,
1242
- credits: existingCredits,
1243
- error: _existingError,
1244
- ...existingBase
1245
- } = existing;
1246
- const {
1247
- primary: _updatePrimary,
1248
- secondary: _updateSecondary,
1249
- scoped: _updateScoped,
1250
- credits: updateCredits,
1251
- ...updateBase
1252
- } = update;
1253
- const credits = updateCredits ?? existingCredits;
1254
- // Unlike the quota windows, scoped caps are never merged: a response that
1255
- // reports its scoped limits reports all of them, so keeping cached entries
1256
- // that the provider dropped would leave a retired cap on screen until its old
1257
- // reset time. Cached scoped caps survive only when the update carries none,
1258
- // i.e. when the response had no `limits` array to speak for them.
1259
- const scoped = update.scoped ?? existing.scoped;
1260
-
1261
- return {
1262
- ...existingBase,
1263
- ...updateBase,
1264
- ...(primary ? { primary } : {}),
1265
- ...(secondary ? { secondary } : {}),
1266
- ...(scoped && scoped.length > 0 ? { scoped } : {}),
1267
- ...(credits !== undefined ? { credits } : {}),
1268
- state: computeProviderStatusState(primary, secondary),
1269
- };
1270
- }
1271
-
1272
- function providerStatusWindows(
1273
- snapshot: ProviderStatusSnapshot,
1274
- ): ProviderStatusWindow[] {
1275
- return [snapshot.primary, snapshot.secondary].filter(
1276
- (window): window is ProviderStatusWindow => window !== undefined,
1277
- );
1278
- }
1279
-
1280
- function windowDurationMinutes(label: string): number {
1281
- const match = label.match(/^(\d+(?:\.\d+)?)(m|h|d)$/);
1282
- if (!match) return Number.POSITIVE_INFINITY;
1283
- const value = Number(match[1]);
1284
- if (!Number.isFinite(value)) return Number.POSITIVE_INFINITY;
1285
- if (match[2] === "d") return value * 24 * 60;
1286
- if (match[2] === "h") return value * 60;
1287
- return value;
1288
- }
1289
-
1290
- function isWeeklyOnlyCodexStatus(snapshot: ProviderStatusSnapshot): boolean {
1291
- return (
1292
- snapshot.provider === CODEX_SOURCE.id &&
1293
- snapshot.primary?.label === CODEX_SECONDARY_WINDOW_LABEL &&
1294
- snapshot.secondary === undefined
1295
- );
1296
- }
1297
-
1298
- function parseHeaderWindow(
1299
- headers: HeaderLike,
1300
- prefix: string,
1301
- label: string,
1302
- now: Date,
1303
- ): ProviderStatusWindow | undefined {
1304
- const usedPercent = numberString(headerValue(headers, `${prefix}-used-percent`));
1305
- const resetAt = normalizeResetAt(
1306
- numberString(headerValue(headers, `${prefix}-reset-at`)),
1307
- );
1308
- const windowMinutes = numberString(headerValue(headers, `${prefix}-window-minutes`));
1309
- if (usedPercent === undefined && resetAt === undefined) return undefined;
1310
- const durationLabel =
1311
- windowMinutes === undefined
1312
- ? undefined
1313
- : windowLabelFromSeconds(windowMinutes * 60);
1314
- return windowFromUsedPercent(durationLabel ?? label, usedPercent ?? 0, resetAt, now);
1315
- }
1316
-
1317
- function normalizeApiWindow(
1318
- value: Record<string, unknown> | undefined,
1319
- fallbackLabel: string,
1320
- now: Date,
1321
- ): ProviderStatusWindow | undefined {
1322
- if (!value) return undefined;
1323
- const usedPercent = numberValue(value.used_percent);
1324
- const resetAt = normalizeResetAt(numberValue(value.reset_at));
1325
- if (usedPercent === undefined && resetAt === undefined) return undefined;
1326
- const label =
1327
- windowLabelFromSeconds(numberValue(value.limit_window_seconds)) ?? fallbackLabel;
1328
- return windowFromUsedPercent(label, usedPercent ?? 0, resetAt, now);
1329
- }
1330
-
1331
- function normalizeClaudeUsageWindow(
1332
- value: Record<string, unknown> | undefined,
1333
- fallbackLabel: string,
1334
- now: Date,
1335
- ): ProviderStatusWindow | undefined {
1336
- if (!value) return undefined;
1337
- const usedPercent =
1338
- numberValue(value.utilization) ?? numberString(stringValue(value.utilization));
1339
- if (usedPercent === undefined) return undefined;
1340
-
1341
- return windowFromUsedPercent(
1342
- fallbackLabel,
1343
- usedPercent,
1344
- resetAtFromTimestamp(stringValue(value.resets_at)),
1345
- now,
1346
- );
1347
- }
1348
-
1349
- function windowLabelFromSeconds(seconds: number | undefined): string | undefined {
1350
- if (seconds === undefined || seconds <= 0) return undefined;
1351
- if (seconds % 86_400 === 0) return `${seconds / 86_400}d`;
1352
- if (seconds % 3_600 === 0) return `${seconds / 3_600}h`;
1353
- if (seconds % 60 === 0) return `${seconds / 60}m`;
1354
- return undefined;
1355
- }
1356
-
1357
- function windowFromUsedPercent(
1358
- label: string,
1359
- usedPercent: number,
1360
- resetAt: number | undefined,
1361
- _now: Date,
1362
- ): ProviderStatusWindow {
1363
- const clampedUsed = Math.max(0, Math.min(100, usedPercent));
1364
- return {
1365
- label,
1366
- usedPercent: clampedUsed,
1367
- leftPercent: Math.max(0, Math.min(100, 100 - clampedUsed)),
1368
- ...(resetAt !== undefined ? { resetAt } : {}),
1369
- };
1370
- }
1371
-
1372
- function computeProviderStatusState(
1373
- primary: ProviderStatusWindow | undefined,
1374
- secondary: ProviderStatusWindow | undefined,
1375
- ): ProviderStatusState {
1376
- const values = [primary, secondary]
1377
- .filter(
1378
- (window): window is ProviderStatusWindow =>
1379
- window !== undefined && !window.usageUnknown,
1380
- )
1381
- .map((window) => window.leftPercent);
1382
- if (values.length === 0) return "unavailable";
1383
- const severity = gaugeSeverity(Math.min(...values));
1384
- return severity === "success" ? "ok" : severity;
1385
- }
1386
-
1387
- export function formatResetCountdown(resetAt: number, nowMs = Date.now()): string {
1388
- if (!Number.isFinite(resetAt) || !Number.isFinite(nowMs) || resetAt <= 0) {
1389
- return "";
1390
- }
1391
-
1392
- const remainingMs = resetAt * 1000 - nowMs;
1393
- if (!Number.isFinite(remainingMs) || remainingMs <= 0) return "";
1394
-
1395
- const minuteMs = 60_000;
1396
- const hourMs = 60 * minuteMs;
1397
- const dayMs = 24 * hourMs;
1398
- if (remainingMs >= dayMs) {
1399
- const days = Math.floor(remainingMs / dayMs);
1400
- const hours = Math.floor((remainingMs % dayMs) / hourMs);
1401
- return `~${days}d${hours > 0 ? `${hours}h` : ""}`;
1402
- }
1403
- if (remainingMs >= hourMs) {
1404
- const hours = Math.floor(remainingMs / hourMs);
1405
- const minutes = Math.floor((remainingMs % hourMs) / minuteMs);
1406
- return `~${hours}h${minutes > 0 ? `${minutes}m` : ""}`;
1407
- }
1408
- if (remainingMs >= minuteMs) {
1409
- return `~${Math.floor(remainingMs / minuteMs)}m`;
1410
- }
1411
- return "~now";
1412
- }
1413
-
1414
- function normalizeResetAt(value: number | undefined): number | undefined {
1415
- if (value === undefined) return undefined;
1416
- return value > 10_000_000_000 ? Math.round(value / 1000) : value;
1417
- }
1418
-
1419
- function resetAtFromTimestamp(value: string | undefined): number | undefined {
1420
- if (!value) return undefined;
1421
- const parsed = Date.parse(value);
1422
- return Number.isFinite(parsed) ? Math.round(parsed / 1000) : undefined;
1423
- }
1424
-
1425
- function providerStatusCachePath(providerId: string): string {
1426
- const base =
1427
- process.env.XDG_CACHE_HOME ||
1428
- join(process.env.HOME || process.env.USERPROFILE || ".", ".cache");
1429
- return join(base, "pi-fancy-footer", "provider-status", `${providerId}.json`);
1430
- }
1431
-
1432
- function homePath(relative: string): string {
1433
- return join(process.env.HOME || process.env.USERPROFILE || ".", relative);
1434
- }
1435
-
1436
- function headerValue(headers: HeaderLike, name: string): string | undefined {
1437
- const wanted = name.toLowerCase();
1438
- for (const [key, value] of Object.entries(headers)) {
1439
- if (key.toLowerCase() === wanted && value !== undefined && value !== null) {
1440
- return String(value);
1441
- }
1442
- }
1443
- return undefined;
1444
- }
1445
-
1446
- function objectValue(value: unknown): Record<string, unknown> | undefined {
1447
- return value && typeof value === "object" && !Array.isArray(value)
1448
- ? (value as Record<string, unknown>)
1449
- : undefined;
1450
- }
1451
-
1452
- function stringValue(value: unknown): string | undefined {
1453
- if (typeof value === "string" && value.length > 0) return value;
1454
- if (typeof value === "number" && Number.isFinite(value)) return String(value);
1455
- return undefined;
1456
- }
1457
-
1458
- function numberValue(value: unknown): number | undefined {
1459
- return typeof value === "number" && Number.isFinite(value) ? value : undefined;
1460
- }
1461
-
1462
- function numberString(value: string | undefined): number | undefined {
1463
- if (value === undefined || value.trim() === "") return undefined;
1464
- const parsed = Number(value);
1465
- return Number.isFinite(parsed) ? parsed : undefined;
1466
- }