@ravshansbox/pi-quota 0.4.1 → 0.5.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 (3) hide show
  1. package/README.md +3 -2
  2. package/index.ts +68 -17
  3. package/package.json +3 -2
package/README.md CHANGED
@@ -14,11 +14,12 @@ Add `-l` to install it in project settings.
14
14
 
15
15
  Pi loads the extension from `./index.ts` and shows the remaining quota for the provider backing the active model above the editor.
16
16
 
17
- - Polls Anthropic and OpenAI Codex usage endpoints at every 10-minute wall-clock mark (`HH:00`, `HH:10`, `HH:20`, `HH:30`, `HH:40`, `HH:50`); polls immediately on session start, then aligns to the next mark
17
+ - Calls the Anthropic usage endpoint once on session start, then reads the Anthropic quota from the rate limit headers of each Anthropic response
18
+ - Polls the OpenAI Codex usage endpoint on session start, then at every 10-minute wall-clock mark (`HH:00`, `HH:10`, `HH:20`, `HH:30`, `HH:40`, `HH:50`)
18
19
  - Shows a widget above the editor for the provider backing the **active model**, and only when quota data for that provider has been polled; switching models re-renders it immediately
19
20
  - The text stays compact, e.g. `93% 1h29m · 67% 3d13h`, where the 5-hour window comes first and the 7-day window second, and each window shows its remaining percentage followed by time until reset
20
21
  - Available resets, when present, are appended as `· 2x 5h` (count, then time until the soonest expires)
21
- - Both providers are still polled whenever their credentials exist, so quota is already fresh when the active model changes
22
+ - Both providers are checked on session start whenever their credentials exist, so quota is already known when the active model changes
22
23
  - Refreshes Anthropic and OpenAI Codex OAuth access tokens from `~/.pi/agent/auth.json` when needed, writes updated credentials back (re-reading the file first to avoid clobbering concurrent updates), and notifies on the first successful refresh per provider each session
23
24
  - Appends poll and token-refresh errors to `~/.pi/agent/pi-quota.log`
24
25
 
package/index.ts CHANGED
@@ -113,6 +113,31 @@ function formatResetTime(reset: Date): string {
113
113
  return 'now';
114
114
  }
115
115
 
116
+ function parseAnthropicHeaderWindow(headers: Record<string, string>, name: string) {
117
+ const utilization = headers[`anthropic-ratelimit-unified-${name}-utilization`];
118
+ const reset = headers[`anthropic-ratelimit-unified-${name}-reset`];
119
+ return {
120
+ remaining: utilization === undefined ? null : clampPercent(100 - Number(utilization) * 100),
121
+ reset: reset === undefined ? null : new Date(Number(reset) * 1000),
122
+ };
123
+ }
124
+
125
+ function parseAnthropicHeaders(headers: Record<string, string>): QuotaState | null {
126
+ const fiveHour = parseAnthropicHeaderWindow(headers, '5h');
127
+ const sevenDay = parseAnthropicHeaderWindow(headers, '7d');
128
+ if (fiveHour.remaining === null && sevenDay.remaining === null) return null;
129
+ return {
130
+ provider: 'anthropic',
131
+ fiveHourRemaining: fiveHour.remaining,
132
+ fiveHourReset: fiveHour.reset,
133
+ sevenDayRemaining: sevenDay.remaining,
134
+ sevenDayReset: sevenDay.reset,
135
+ resetsAvailable: 0,
136
+ resetSoonestExpiry: null,
137
+ lastUpdated: new Date(),
138
+ };
139
+ }
140
+
116
141
  async function loadConfig(): Promise<QuotaConfig | null> {
117
142
  try {
118
143
  const settingsPath = join(getAgentDir(), 'settings.json');
@@ -168,6 +193,7 @@ export default function (pi: ExtensionAPI) {
168
193
  const states: QuotaState[] = [];
169
194
  const refreshNotified = new Set<string>();
170
195
  let checkTimer: ReturnType<typeof setTimeout> | null = null;
196
+ let anthropicPingTimer: ReturnType<typeof setTimeout> | null = null;
171
197
  let ctxRef: ExtensionContext | null = null;
172
198
  let codexRedeemAttempted = false;
173
199
  let cycleRunning = false;
@@ -272,14 +298,29 @@ export default function (pi: ExtensionAPI) {
272
298
  child.unref();
273
299
  }
274
300
 
301
+ function scheduleAnthropicPing(state: QuotaState) {
302
+ if (anthropicPingTimer) clearTimeout(anthropicPingTimer);
303
+ anthropicPingTimer = null;
304
+ const now = Date.now();
305
+ const resets = [state.fiveHourReset, state.sevenDayReset]
306
+ .map((reset) => reset?.getTime())
307
+ .filter((time): time is number => time !== undefined && time > now);
308
+ if (resets.length === 0) return;
309
+ anthropicPingTimer = setTimeout(
310
+ () => pingProviderOnReset('anthropic'),
311
+ Math.min(...resets) - now,
312
+ );
313
+ }
314
+
275
315
  function updateState(parsed: QuotaState) {
316
+ if (parsed.provider === 'anthropic') scheduleAnthropicPing(parsed);
276
317
  const existing = states.find((s) => s.provider === parsed.provider);
277
318
  if (!existing) {
278
319
  states.push(parsed);
279
320
  return;
280
321
  }
281
322
 
282
- const shouldPing = shouldPingOnReset(existing, parsed);
323
+ const shouldPing = parsed.provider !== 'anthropic' && shouldPingOnReset(existing, parsed);
283
324
 
284
325
  existing.fiveHourRemaining = parsed.fiveHourRemaining;
285
326
  existing.fiveHourReset = parsed.fiveHourReset;
@@ -438,12 +479,12 @@ export default function (pi: ExtensionAPI) {
438
479
  }
439
480
  }
440
481
 
441
- async function pollQuotaStatus() {
482
+ async function pollQuotaStatus(includeAnthropic: boolean) {
442
483
  try {
443
484
  const auth = await loadAuth();
444
485
  if (!auth) return;
445
486
 
446
- const anthropicAuth = await ensureAnthropicAccess(auth);
487
+ const anthropicAuth = includeAnthropic ? await ensureAnthropicAccess(auth) : undefined;
447
488
  if (anthropicAuth?.access) {
448
489
  const response = await fetch('https://api.anthropic.com/api/oauth/usage', {
449
490
  headers: {
@@ -560,7 +601,7 @@ export default function (pi: ExtensionAPI) {
560
601
 
561
602
  if (result.code === 'reset') {
562
603
  ctxRef?.ui.notify('pi-quota: saved reset redeemed successfully', 'info');
563
- await pollQuotaStatus();
604
+ await pollQuotaStatus(false);
564
605
  updateStatus();
565
606
  } else {
566
607
  logError(`Reset redeem returned code: ${result.code}`);
@@ -568,11 +609,11 @@ export default function (pi: ExtensionAPI) {
568
609
  }
569
610
  }
570
611
 
571
- async function runCycle() {
612
+ async function runCycle(includeAnthropic: boolean) {
572
613
  if (cycleRunning) return;
573
614
  cycleRunning = true;
574
615
  try {
575
- await pollQuotaStatus();
616
+ await pollQuotaStatus(includeAnthropic);
576
617
  await tryAutoRedeemCodexReset();
577
618
  updateStatus();
578
619
  } catch (error) {
@@ -586,34 +627,44 @@ export default function (pi: ExtensionAPI) {
586
627
  const next = nextMarkAfter(new Date());
587
628
  const delay = Math.max(0, next.getTime() - Date.now());
588
629
  checkTimer = setTimeout(async () => {
589
- await runCycle();
630
+ await runCycle(false);
590
631
  scheduleCheck();
591
632
  }, delay);
592
633
  }
593
634
 
635
+ function stopTimers() {
636
+ if (checkTimer) clearTimeout(checkTimer);
637
+ if (anthropicPingTimer) clearTimeout(anthropicPingTimer);
638
+ checkTimer = null;
639
+ anthropicPingTimer = null;
640
+ }
641
+
594
642
  pi.on('session_start', (_event, ctx) => {
595
- if (checkTimer) {
596
- clearTimeout(checkTimer);
597
- checkTimer = null;
598
- }
643
+ stopTimers();
599
644
  states.length = 0;
600
645
  refreshNotified.clear();
601
646
  ctxRef = ctx;
602
647
  codexRedeemAttempted = false;
603
648
 
604
- void runCycle();
649
+ void runCycle(true);
605
650
  scheduleCheck();
606
651
  });
607
652
 
653
+ pi.on('after_provider_response', (event, ctx) => {
654
+ if (ctx.model?.provider !== modelProviderNames.anthropic) return;
655
+ const next = parseAnthropicHeaders(event.headers);
656
+ if (!next) return;
657
+ ctxRef = ctx;
658
+ updateState(next);
659
+ updateStatus();
660
+ });
661
+
608
662
  pi.on('model_select', (_event, ctx) => {
609
663
  ctxRef = ctx;
610
664
  updateStatus();
611
665
  });
612
666
 
613
- pi.on('session_shutdown', async () => {
614
- if (checkTimer) {
615
- clearTimeout(checkTimer);
616
- checkTimer = null;
617
- }
667
+ pi.on('session_shutdown', () => {
668
+ stopTimers();
618
669
  });
619
670
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ravshansbox/pi-quota",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Anthropic and OpenAI Codex quota status extension for pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -28,7 +28,8 @@
28
28
  "lint:fix": "oxlint --fix",
29
29
  "format": "oxfmt",
30
30
  "format:check": "oxfmt --check",
31
- "check": "npm run typecheck && npm run lint && npm run format:check"
31
+ "test": "node --test",
32
+ "check": "npm run typecheck && npm run lint && npm run format:check && npm test"
32
33
  },
33
34
  "peerDependencies": {
34
35
  "@earendil-works/pi-coding-agent": "*",