pi-fancy-footer 1.3.1 → 1.3.2
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 +5 -3
- package/package.json +1 -1
- package/src/provider-status.test.ts +290 -4
- package/src/provider-status.ts +106 -32
package/README.md
CHANGED
|
@@ -276,9 +276,10 @@ Notes:
|
|
|
276
276
|
from `~/.pi/agent/auth.json`, falling back to Codex CLI credentials in
|
|
277
277
|
`~/.codex/auth.json`. Claude uses pi Anthropic OAuth credentials from
|
|
278
278
|
`~/.pi/agent/auth.json` and reads Claude.ai usage for the 5-hour and weekly
|
|
279
|
-
windows. Status is cached under `~/.cache/pi-fancy-footer/provider-status
|
|
280
|
-
|
|
281
|
-
|
|
279
|
+
windows. Status is cached under `~/.cache/pi-fancy-footer/provider-status/`;
|
|
280
|
+
when a refresh fails, cached quota windows keep showing until their reset times
|
|
281
|
+
pass instead of hiding the widget. The widget is hidden when the active model
|
|
282
|
+
selection is not backed by the status provider.
|
|
282
283
|
- `provider-status` also refreshes from `x-codex-*` provider response headers
|
|
283
284
|
when pi exposes them, avoiding a separate Codex status request after provider
|
|
284
285
|
calls. Claude status refreshes from the Claude.ai usage endpoint, not
|
|
@@ -297,6 +298,7 @@ Notes:
|
|
|
297
298
|
- `pull-request-ci-status` shows GitHub Actions workflow runs for the current
|
|
298
299
|
PR head commit. It links to the relevant run and switches to failed as soon as
|
|
299
300
|
one workflow fails, even when other workflows are still running.
|
|
301
|
+
|
|
300
302
|
## 🧱 Gauge styles
|
|
301
303
|
|
|
302
304
|
The `gaugeStyle` setting controls the characters used by the `context-bar`
|
package/package.json
CHANGED
|
@@ -14,10 +14,7 @@ import {
|
|
|
14
14
|
providerStatusColor,
|
|
15
15
|
updateProviderStatusFromHeaders,
|
|
16
16
|
} from "./provider-status.ts";
|
|
17
|
-
import {
|
|
18
|
-
getGaugeStyle,
|
|
19
|
-
type ProviderStatusConfigSnapshot,
|
|
20
|
-
} from "./shared.ts";
|
|
17
|
+
import { getGaugeStyle, type ProviderStatusConfigSnapshot } from "./shared.ts";
|
|
21
18
|
|
|
22
19
|
const now = new Date("2026-05-06T10:00:00Z");
|
|
23
20
|
const providerStatusConfig: ProviderStatusConfigSnapshot = {
|
|
@@ -338,6 +335,295 @@ test("collectProviderStatus does not present expired cache as live status after
|
|
|
338
335
|
assert.match(snapshot?.error ?? "", /No usable Codex OAuth credentials/);
|
|
339
336
|
});
|
|
340
337
|
|
|
338
|
+
test("collectProviderStatus keeps cached quota in effect after a failed refresh", async (t) => {
|
|
339
|
+
const dir = await mkdtemp(join(tmpdir(), "pi-fancy-footer-test-"));
|
|
340
|
+
t.after(async () => {
|
|
341
|
+
await rm(dir, { recursive: true, force: true });
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
const previousHome = process.env.HOME;
|
|
345
|
+
const previousXdgCacheHome = process.env.XDG_CACHE_HOME;
|
|
346
|
+
const previousFetch = globalThis.fetch;
|
|
347
|
+
process.env.HOME = dir;
|
|
348
|
+
process.env.XDG_CACHE_HOME = join(dir, "cache");
|
|
349
|
+
globalThis.fetch = async () =>
|
|
350
|
+
new Response(JSON.stringify({ error: { message: "rate limited" } }), {
|
|
351
|
+
status: 429,
|
|
352
|
+
});
|
|
353
|
+
t.after(() => {
|
|
354
|
+
if (previousHome === undefined) delete process.env.HOME;
|
|
355
|
+
else process.env.HOME = previousHome;
|
|
356
|
+
if (previousXdgCacheHome === undefined) delete process.env.XDG_CACHE_HOME;
|
|
357
|
+
else process.env.XDG_CACHE_HOME = previousXdgCacheHome;
|
|
358
|
+
globalThis.fetch = previousFetch;
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
await mkdir(join(dir, ".pi", "agent"), { recursive: true });
|
|
362
|
+
await writeFile(
|
|
363
|
+
join(dir, ".pi", "agent", "auth.json"),
|
|
364
|
+
JSON.stringify({ anthropic: { access: "test-access-token" } }),
|
|
365
|
+
{ mode: 0o600 },
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
const futureResetAt = Math.ceil(Date.now() / 1000) + 3_600;
|
|
369
|
+
const cacheDir = join(
|
|
370
|
+
process.env.XDG_CACHE_HOME,
|
|
371
|
+
"pi-fancy-footer",
|
|
372
|
+
"provider-status",
|
|
373
|
+
);
|
|
374
|
+
await mkdir(cacheDir, { recursive: true });
|
|
375
|
+
await writeFile(
|
|
376
|
+
join(cacheDir, "anthropic.json"),
|
|
377
|
+
JSON.stringify({
|
|
378
|
+
provider: "anthropic",
|
|
379
|
+
source: "api",
|
|
380
|
+
fetchedAt: "2026-05-06T09:00:00Z",
|
|
381
|
+
state: "ok",
|
|
382
|
+
primary: {
|
|
383
|
+
label: "5h",
|
|
384
|
+
usedPercent: 5,
|
|
385
|
+
leftPercent: 95,
|
|
386
|
+
resetAt: futureResetAt,
|
|
387
|
+
},
|
|
388
|
+
secondary: {
|
|
389
|
+
label: "7d",
|
|
390
|
+
usedPercent: 12,
|
|
391
|
+
leftPercent: 88,
|
|
392
|
+
resetAt: futureResetAt,
|
|
393
|
+
},
|
|
394
|
+
url: "https://claude.ai/settings/usage",
|
|
395
|
+
}),
|
|
396
|
+
{ mode: 0o600 },
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
const snapshots = await collectProviderStatus({} as never, {
|
|
400
|
+
...anthropicProviderStatusConfig,
|
|
401
|
+
cacheTtlMs: 1,
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
assert.equal(snapshots.length, 1);
|
|
405
|
+
const snapshot = snapshots[0];
|
|
406
|
+
assert.equal(snapshot?.source, "cache");
|
|
407
|
+
assert.equal(snapshot?.state, "ok");
|
|
408
|
+
assert.deepEqual(snapshot?.primary, {
|
|
409
|
+
label: "5h",
|
|
410
|
+
usedPercent: 5,
|
|
411
|
+
leftPercent: 95,
|
|
412
|
+
resetAt: futureResetAt,
|
|
413
|
+
});
|
|
414
|
+
assert.deepEqual(snapshot?.secondary, {
|
|
415
|
+
label: "7d",
|
|
416
|
+
usedPercent: 12,
|
|
417
|
+
leftPercent: 88,
|
|
418
|
+
resetAt: futureResetAt,
|
|
419
|
+
});
|
|
420
|
+
assert.match(snapshot?.error ?? "", /429/);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("collectProviderStatus hides cached quota once its windows reset", async (t) => {
|
|
424
|
+
const dir = await mkdtemp(join(tmpdir(), "pi-fancy-footer-test-"));
|
|
425
|
+
t.after(async () => {
|
|
426
|
+
await rm(dir, { recursive: true, force: true });
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
const previousHome = process.env.HOME;
|
|
430
|
+
const previousXdgCacheHome = process.env.XDG_CACHE_HOME;
|
|
431
|
+
const previousFetch = globalThis.fetch;
|
|
432
|
+
process.env.HOME = dir;
|
|
433
|
+
process.env.XDG_CACHE_HOME = join(dir, "cache");
|
|
434
|
+
globalThis.fetch = async () => new Response("rate limited", { status: 429 });
|
|
435
|
+
t.after(() => {
|
|
436
|
+
if (previousHome === undefined) delete process.env.HOME;
|
|
437
|
+
else process.env.HOME = previousHome;
|
|
438
|
+
if (previousXdgCacheHome === undefined) delete process.env.XDG_CACHE_HOME;
|
|
439
|
+
else process.env.XDG_CACHE_HOME = previousXdgCacheHome;
|
|
440
|
+
globalThis.fetch = previousFetch;
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
await mkdir(join(dir, ".pi", "agent"), { recursive: true });
|
|
444
|
+
await writeFile(
|
|
445
|
+
join(dir, ".pi", "agent", "auth.json"),
|
|
446
|
+
JSON.stringify({ anthropic: { access: "test-access-token" } }),
|
|
447
|
+
{ mode: 0o600 },
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
const cacheDir = join(
|
|
451
|
+
process.env.XDG_CACHE_HOME,
|
|
452
|
+
"pi-fancy-footer",
|
|
453
|
+
"provider-status",
|
|
454
|
+
);
|
|
455
|
+
await mkdir(cacheDir, { recursive: true });
|
|
456
|
+
await writeFile(
|
|
457
|
+
join(cacheDir, "anthropic.json"),
|
|
458
|
+
JSON.stringify({
|
|
459
|
+
provider: "anthropic",
|
|
460
|
+
source: "api",
|
|
461
|
+
fetchedAt: "2026-05-06T09:00:00Z",
|
|
462
|
+
state: "ok",
|
|
463
|
+
primary: { label: "5h", usedPercent: 5, leftPercent: 95, resetAt: 1 },
|
|
464
|
+
secondary: {
|
|
465
|
+
label: "7d",
|
|
466
|
+
usedPercent: 12,
|
|
467
|
+
leftPercent: 88,
|
|
468
|
+
resetAt: 1,
|
|
469
|
+
},
|
|
470
|
+
url: "https://claude.ai/settings/usage",
|
|
471
|
+
}),
|
|
472
|
+
{ mode: 0o600 },
|
|
473
|
+
);
|
|
474
|
+
|
|
475
|
+
const snapshots = await collectProviderStatus({} as never, {
|
|
476
|
+
...anthropicProviderStatusConfig,
|
|
477
|
+
cacheTtlMs: 1,
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
assert.equal(snapshots.length, 1);
|
|
481
|
+
const snapshot = snapshots[0];
|
|
482
|
+
assert.equal(snapshot?.source, "api");
|
|
483
|
+
assert.equal(snapshot?.state, "unavailable");
|
|
484
|
+
assert.equal(snapshot?.primary, undefined);
|
|
485
|
+
assert.equal(snapshot?.secondary, undefined);
|
|
486
|
+
assert.match(snapshot?.error ?? "", /429/);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
test("collectProviderStatus keeps cached quota in effect after a failed auth refresh", async (t) => {
|
|
490
|
+
const dir = await mkdtemp(join(tmpdir(), "pi-fancy-footer-test-"));
|
|
491
|
+
t.after(async () => {
|
|
492
|
+
await rm(dir, { recursive: true, force: true });
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
const previousHome = process.env.HOME;
|
|
496
|
+
const previousXdgCacheHome = process.env.XDG_CACHE_HOME;
|
|
497
|
+
const previousFetch = globalThis.fetch;
|
|
498
|
+
process.env.HOME = dir;
|
|
499
|
+
process.env.XDG_CACHE_HOME = join(dir, "cache");
|
|
500
|
+
globalThis.fetch = async () => new Response("rate limited", { status: 429 });
|
|
501
|
+
t.after(() => {
|
|
502
|
+
if (previousHome === undefined) delete process.env.HOME;
|
|
503
|
+
else process.env.HOME = previousHome;
|
|
504
|
+
if (previousXdgCacheHome === undefined) delete process.env.XDG_CACHE_HOME;
|
|
505
|
+
else process.env.XDG_CACHE_HOME = previousXdgCacheHome;
|
|
506
|
+
globalThis.fetch = previousFetch;
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
await mkdir(join(dir, ".pi", "agent"), { recursive: true });
|
|
510
|
+
await writeFile(
|
|
511
|
+
join(dir, ".pi", "agent", "auth.json"),
|
|
512
|
+
JSON.stringify({
|
|
513
|
+
anthropic: {
|
|
514
|
+
access: "expired-access-token",
|
|
515
|
+
refresh: "test-refresh-token",
|
|
516
|
+
expires: Date.now() - 60_000,
|
|
517
|
+
},
|
|
518
|
+
}),
|
|
519
|
+
{ mode: 0o600 },
|
|
520
|
+
);
|
|
521
|
+
|
|
522
|
+
const futureResetAt = Math.ceil(Date.now() / 1000) + 3_600;
|
|
523
|
+
const cacheDir = join(
|
|
524
|
+
process.env.XDG_CACHE_HOME,
|
|
525
|
+
"pi-fancy-footer",
|
|
526
|
+
"provider-status",
|
|
527
|
+
);
|
|
528
|
+
await mkdir(cacheDir, { recursive: true });
|
|
529
|
+
await writeFile(
|
|
530
|
+
join(cacheDir, "anthropic.json"),
|
|
531
|
+
JSON.stringify({
|
|
532
|
+
provider: "anthropic",
|
|
533
|
+
source: "api",
|
|
534
|
+
fetchedAt: "2026-05-06T09:00:00Z",
|
|
535
|
+
state: "ok",
|
|
536
|
+
primary: {
|
|
537
|
+
label: "5h",
|
|
538
|
+
usedPercent: 5,
|
|
539
|
+
leftPercent: 95,
|
|
540
|
+
resetAt: futureResetAt,
|
|
541
|
+
},
|
|
542
|
+
url: "https://claude.ai/settings/usage",
|
|
543
|
+
}),
|
|
544
|
+
{ mode: 0o600 },
|
|
545
|
+
);
|
|
546
|
+
|
|
547
|
+
const snapshots = await collectProviderStatus({} as never, {
|
|
548
|
+
...anthropicProviderStatusConfig,
|
|
549
|
+
cacheTtlMs: 1,
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
assert.equal(snapshots.length, 1);
|
|
553
|
+
const snapshot = snapshots[0];
|
|
554
|
+
assert.equal(snapshot?.source, "cache");
|
|
555
|
+
assert.equal(snapshot?.state, "ok");
|
|
556
|
+
assert.equal(snapshot?.primary?.resetAt, futureResetAt);
|
|
557
|
+
assert.match(snapshot?.error ?? "", /429/);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
test("collectProviderStatus keeps cached quota in effect regardless of the failure cause", async (t) => {
|
|
561
|
+
const dir = await mkdtemp(join(tmpdir(), "pi-fancy-footer-test-"));
|
|
562
|
+
t.after(async () => {
|
|
563
|
+
await rm(dir, { recursive: true, force: true });
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
const previousHome = process.env.HOME;
|
|
567
|
+
const previousXdgCacheHome = process.env.XDG_CACHE_HOME;
|
|
568
|
+
const previousFetch = globalThis.fetch;
|
|
569
|
+
process.env.HOME = dir;
|
|
570
|
+
process.env.XDG_CACHE_HOME = join(dir, "cache");
|
|
571
|
+
// A non-retryable failure (e.g. revoked credentials) must not invalidate a
|
|
572
|
+
// quota window that has not reset yet.
|
|
573
|
+
globalThis.fetch = async () => new Response("forbidden", { status: 403 });
|
|
574
|
+
t.after(() => {
|
|
575
|
+
if (previousHome === undefined) delete process.env.HOME;
|
|
576
|
+
else process.env.HOME = previousHome;
|
|
577
|
+
if (previousXdgCacheHome === undefined) delete process.env.XDG_CACHE_HOME;
|
|
578
|
+
else process.env.XDG_CACHE_HOME = previousXdgCacheHome;
|
|
579
|
+
globalThis.fetch = previousFetch;
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
await mkdir(join(dir, ".pi", "agent"), { recursive: true });
|
|
583
|
+
await writeFile(
|
|
584
|
+
join(dir, ".pi", "agent", "auth.json"),
|
|
585
|
+
JSON.stringify({ anthropic: { access: "test-access-token" } }),
|
|
586
|
+
{ mode: 0o600 },
|
|
587
|
+
);
|
|
588
|
+
|
|
589
|
+
const futureResetAt = Math.ceil(Date.now() / 1000) + 3_600;
|
|
590
|
+
const cacheDir = join(
|
|
591
|
+
process.env.XDG_CACHE_HOME,
|
|
592
|
+
"pi-fancy-footer",
|
|
593
|
+
"provider-status",
|
|
594
|
+
);
|
|
595
|
+
await mkdir(cacheDir, { recursive: true });
|
|
596
|
+
await writeFile(
|
|
597
|
+
join(cacheDir, "anthropic.json"),
|
|
598
|
+
JSON.stringify({
|
|
599
|
+
provider: "anthropic",
|
|
600
|
+
source: "api",
|
|
601
|
+
fetchedAt: "2026-05-06T09:00:00Z",
|
|
602
|
+
state: "ok",
|
|
603
|
+
primary: {
|
|
604
|
+
label: "5h",
|
|
605
|
+
usedPercent: 5,
|
|
606
|
+
leftPercent: 95,
|
|
607
|
+
resetAt: futureResetAt,
|
|
608
|
+
},
|
|
609
|
+
url: "https://claude.ai/settings/usage",
|
|
610
|
+
}),
|
|
611
|
+
{ mode: 0o600 },
|
|
612
|
+
);
|
|
613
|
+
|
|
614
|
+
const snapshots = await collectProviderStatus({} as never, {
|
|
615
|
+
...anthropicProviderStatusConfig,
|
|
616
|
+
cacheTtlMs: 1,
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
assert.equal(snapshots.length, 1);
|
|
620
|
+
const snapshot = snapshots[0];
|
|
621
|
+
assert.equal(snapshot?.source, "cache");
|
|
622
|
+
assert.equal(snapshot?.state, "ok");
|
|
623
|
+
assert.equal(snapshot?.primary?.resetAt, futureResetAt);
|
|
624
|
+
assert.match(snapshot?.error ?? "", /403/);
|
|
625
|
+
});
|
|
626
|
+
|
|
341
627
|
test("updateProviderStatusFromHeaders honors disabled providers", async () => {
|
|
342
628
|
const updated = await updateProviderStatusFromHeaders(
|
|
343
629
|
{
|
package/src/provider-status.ts
CHANGED
|
@@ -28,6 +28,10 @@ const CLAUDE_SECONDARY_WINDOW_LABEL = "7d";
|
|
|
28
28
|
|
|
29
29
|
type HeaderLike = Record<string, string | number | boolean | undefined | null>;
|
|
30
30
|
|
|
31
|
+
type TokenRefreshResult =
|
|
32
|
+
| { ok: true; stdout: string }
|
|
33
|
+
| { ok: false; error: Error };
|
|
34
|
+
|
|
31
35
|
export interface ProviderStatusSource {
|
|
32
36
|
id: string;
|
|
33
37
|
label: string;
|
|
@@ -106,10 +110,7 @@ export function isProviderStatusRelevantToModel(
|
|
|
106
110
|
providerId: string,
|
|
107
111
|
model: ModelLike | string | undefined,
|
|
108
112
|
): boolean {
|
|
109
|
-
if (
|
|
110
|
-
providerId !== CODEX_SOURCE.id &&
|
|
111
|
-
providerId !== ANTHROPIC_SOURCE.id
|
|
112
|
-
) {
|
|
113
|
+
if (providerId !== CODEX_SOURCE.id && providerId !== ANTHROPIC_SOURCE.id) {
|
|
113
114
|
return true;
|
|
114
115
|
}
|
|
115
116
|
|
|
@@ -238,20 +239,76 @@ async function collectProviderStatusFromSource(
|
|
|
238
239
|
await writeProviderStatusCache(snapshot).catch(() => undefined);
|
|
239
240
|
return snapshot;
|
|
240
241
|
} catch (error) {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
return
|
|
245
|
-
|
|
246
|
-
source
|
|
247
|
-
|
|
248
|
-
state: "unavailable",
|
|
249
|
-
url: source.usageUrl,
|
|
250
|
-
error: error instanceof Error ? error.message : String(error),
|
|
251
|
-
};
|
|
242
|
+
// A refresh failed. The cached quota windows remain valid until they
|
|
243
|
+
// reset, regardless of why the refresh failed, so keep showing whichever
|
|
244
|
+
// windows are still in effect.
|
|
245
|
+
return (
|
|
246
|
+
displayableCachedStatus(cached, error) ??
|
|
247
|
+
unavailableProviderStatus(source, error)
|
|
248
|
+
);
|
|
252
249
|
}
|
|
253
250
|
}
|
|
254
251
|
|
|
252
|
+
// Projects a cached snapshot onto the windows that are still in effect, i.e.
|
|
253
|
+
// have not reset yet. Returns undefined when nothing is left to display.
|
|
254
|
+
function displayableCachedStatus(
|
|
255
|
+
cached: ProviderStatusSnapshot | undefined,
|
|
256
|
+
error: unknown,
|
|
257
|
+
now = new Date(),
|
|
258
|
+
): ProviderStatusSnapshot | undefined {
|
|
259
|
+
if (!cached) return undefined;
|
|
260
|
+
|
|
261
|
+
const primary = windowInEffect(cached.primary, now);
|
|
262
|
+
const secondary = windowInEffect(cached.secondary, now);
|
|
263
|
+
if (!primary && !secondary) return undefined;
|
|
264
|
+
|
|
265
|
+
const {
|
|
266
|
+
primary: _expiredPrimary,
|
|
267
|
+
secondary: _expiredSecondary,
|
|
268
|
+
...rest
|
|
269
|
+
} = cached;
|
|
270
|
+
return {
|
|
271
|
+
...rest,
|
|
272
|
+
source: "cache",
|
|
273
|
+
state: computeProviderStatusState(primary, secondary),
|
|
274
|
+
...(primary ? { primary } : {}),
|
|
275
|
+
...(secondary ? { secondary } : {}),
|
|
276
|
+
error: providerStatusErrorMessage(error),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// A window is in effect while its reset time is still in the future. Windows
|
|
281
|
+
// without a reset time have no intrinsic lifetime, so they are not shown once
|
|
282
|
+
// the refresh that would confirm them has failed.
|
|
283
|
+
function windowInEffect(
|
|
284
|
+
window: ProviderStatusWindow | undefined,
|
|
285
|
+
now: Date,
|
|
286
|
+
): ProviderStatusWindow | undefined {
|
|
287
|
+
if (!window?.resetAt) return undefined;
|
|
288
|
+
const resetAtMs = window.resetAt * 1000;
|
|
289
|
+
return Number.isFinite(resetAtMs) && resetAtMs > now.getTime()
|
|
290
|
+
? window
|
|
291
|
+
: undefined;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function unavailableProviderStatus(
|
|
295
|
+
source: ProviderStatusSource,
|
|
296
|
+
error: unknown,
|
|
297
|
+
): ProviderStatusSnapshot {
|
|
298
|
+
return {
|
|
299
|
+
provider: source.id,
|
|
300
|
+
source: "api",
|
|
301
|
+
fetchedAt: new Date().toISOString(),
|
|
302
|
+
state: "unavailable",
|
|
303
|
+
url: source.usageUrl,
|
|
304
|
+
error: providerStatusErrorMessage(error),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function providerStatusErrorMessage(error: unknown): string {
|
|
309
|
+
return error instanceof Error ? error.message : String(error);
|
|
310
|
+
}
|
|
311
|
+
|
|
255
312
|
export async function updateProviderStatusFromHeaders(
|
|
256
313
|
headers: HeaderLike,
|
|
257
314
|
config?: ProviderStatusConfigSnapshot,
|
|
@@ -495,7 +552,9 @@ async function resolveClaudeAuth(): Promise<AuthCredentials> {
|
|
|
495
552
|
);
|
|
496
553
|
}
|
|
497
554
|
|
|
498
|
-
async function refreshIfNeeded(
|
|
555
|
+
async function refreshIfNeeded(
|
|
556
|
+
auth: AuthCredentials,
|
|
557
|
+
): Promise<AuthCredentials> {
|
|
499
558
|
if (!auth.refreshToken || !auth.expiresAtMs) return auth;
|
|
500
559
|
if (auth.expiresAtMs > Date.now() + 5 * 60 * 1000) return auth;
|
|
501
560
|
return refreshAuth(auth);
|
|
@@ -522,12 +581,15 @@ async function refreshAuth(auth: AuthCredentials): Promise<AuthCredentials> {
|
|
|
522
581
|
client_id: refreshConfig.clientId,
|
|
523
582
|
}).toString();
|
|
524
583
|
|
|
525
|
-
const result = await
|
|
584
|
+
const result = await requestTokenRefresh(
|
|
585
|
+
body,
|
|
586
|
+
refreshConfig.tokenUrl,
|
|
587
|
+
`${refreshConfig.label} auth refresh`,
|
|
588
|
+
);
|
|
526
589
|
|
|
527
|
-
if (result.
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
);
|
|
590
|
+
if (!result.ok) throw result.error;
|
|
591
|
+
if (!result.stdout) {
|
|
592
|
+
throw new Error(`Failed to refresh ${refreshConfig.label} auth`);
|
|
531
593
|
}
|
|
532
594
|
|
|
533
595
|
const refreshed = JSON.parse(result.stdout) as Record<string, unknown>;
|
|
@@ -538,7 +600,8 @@ async function refreshAuth(auth: AuthCredentials): Promise<AuthCredentials> {
|
|
|
538
600
|
);
|
|
539
601
|
}
|
|
540
602
|
|
|
541
|
-
const refreshToken =
|
|
603
|
+
const refreshToken =
|
|
604
|
+
stringValue(refreshed.refresh_token) ?? auth.refreshToken;
|
|
542
605
|
const expiresIn = numberValue(refreshed.expires_in);
|
|
543
606
|
const next: AuthCredentials = {
|
|
544
607
|
...auth,
|
|
@@ -552,10 +615,11 @@ async function refreshAuth(auth: AuthCredentials): Promise<AuthCredentials> {
|
|
|
552
615
|
return next;
|
|
553
616
|
}
|
|
554
617
|
|
|
555
|
-
async function
|
|
618
|
+
async function requestTokenRefresh(
|
|
556
619
|
body: string,
|
|
557
620
|
tokenUrl: string,
|
|
558
|
-
|
|
621
|
+
label: string,
|
|
622
|
+
): Promise<TokenRefreshResult> {
|
|
559
623
|
try {
|
|
560
624
|
const response = await fetch(tokenUrl, {
|
|
561
625
|
method: "POST",
|
|
@@ -563,13 +627,19 @@ async function localRefresh(
|
|
|
563
627
|
body,
|
|
564
628
|
});
|
|
565
629
|
const text = await response.text();
|
|
566
|
-
if (!response.ok)
|
|
567
|
-
|
|
630
|
+
if (!response.ok) {
|
|
631
|
+
return {
|
|
632
|
+
ok: false,
|
|
633
|
+
error: new Error(
|
|
634
|
+
`${label} failed (${response.status}): ${text.slice(0, 500)}`,
|
|
635
|
+
),
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
return { ok: true, stdout: text };
|
|
568
639
|
} catch (error) {
|
|
569
640
|
return {
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
stderr: error instanceof Error ? error.message : String(error),
|
|
641
|
+
ok: false,
|
|
642
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
573
643
|
};
|
|
574
644
|
}
|
|
575
645
|
}
|
|
@@ -705,7 +775,7 @@ function mergeProviderStatus(
|
|
|
705
775
|
...update,
|
|
706
776
|
...(primary ? { primary } : {}),
|
|
707
777
|
...(secondary ? { secondary } : {}),
|
|
708
|
-
...(update.credits ?? existing.credits
|
|
778
|
+
...((update.credits ?? existing.credits)
|
|
709
779
|
? { credits: update.credits ?? existing.credits }
|
|
710
780
|
: {}),
|
|
711
781
|
state: computeProviderStatusState(primary, secondary),
|
|
@@ -762,7 +832,9 @@ function normalizeClaudeUsageWindow(
|
|
|
762
832
|
);
|
|
763
833
|
}
|
|
764
834
|
|
|
765
|
-
function windowLabelFromSeconds(
|
|
835
|
+
function windowLabelFromSeconds(
|
|
836
|
+
seconds: number | undefined,
|
|
837
|
+
): string | undefined {
|
|
766
838
|
if (seconds === undefined || seconds <= 0) return undefined;
|
|
767
839
|
if (seconds % 86_400 === 0) return `${seconds / 86_400}d`;
|
|
768
840
|
if (seconds % 3_600 === 0) return `${seconds / 3_600}h`;
|
|
@@ -854,7 +926,9 @@ function stringValue(value: unknown): string | undefined {
|
|
|
854
926
|
}
|
|
855
927
|
|
|
856
928
|
function numberValue(value: unknown): number | undefined {
|
|
857
|
-
return typeof value === "number" && Number.isFinite(value)
|
|
929
|
+
return typeof value === "number" && Number.isFinite(value)
|
|
930
|
+
? value
|
|
931
|
+
: undefined;
|
|
858
932
|
}
|
|
859
933
|
|
|
860
934
|
function numberString(value: string | undefined): number | undefined {
|