@oh-my-pi/pi-coding-agent 3.37.1 → 4.0.1

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 (70) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/README.md +44 -3
  3. package/docs/extensions.md +29 -4
  4. package/docs/sdk.md +3 -3
  5. package/package.json +5 -5
  6. package/src/cli/args.ts +8 -0
  7. package/src/config.ts +5 -15
  8. package/src/core/agent-session.ts +217 -51
  9. package/src/core/auth-storage.ts +456 -47
  10. package/src/core/bash-executor.ts +79 -14
  11. package/src/core/custom-commands/types.ts +1 -1
  12. package/src/core/custom-tools/types.ts +1 -1
  13. package/src/core/export-html/index.ts +33 -1
  14. package/src/core/export-html/template.css +99 -0
  15. package/src/core/export-html/template.generated.ts +1 -1
  16. package/src/core/export-html/template.js +133 -8
  17. package/src/core/extensions/index.ts +22 -4
  18. package/src/core/extensions/loader.ts +152 -214
  19. package/src/core/extensions/runner.ts +139 -79
  20. package/src/core/extensions/types.ts +143 -19
  21. package/src/core/extensions/wrapper.ts +5 -8
  22. package/src/core/hooks/types.ts +1 -1
  23. package/src/core/index.ts +2 -1
  24. package/src/core/keybindings.ts +4 -1
  25. package/src/core/model-registry.ts +4 -4
  26. package/src/core/model-resolver.ts +35 -26
  27. package/src/core/sdk.ts +96 -76
  28. package/src/core/settings-manager.ts +45 -14
  29. package/src/core/system-prompt.ts +5 -15
  30. package/src/core/tools/bash.ts +115 -54
  31. package/src/core/tools/find.ts +86 -7
  32. package/src/core/tools/grep.ts +27 -6
  33. package/src/core/tools/index.ts +15 -6
  34. package/src/core/tools/ls.ts +49 -18
  35. package/src/core/tools/render-utils.ts +2 -1
  36. package/src/core/tools/task/worker.ts +35 -12
  37. package/src/core/tools/web-search/auth.ts +37 -32
  38. package/src/core/tools/web-search/providers/anthropic.ts +35 -22
  39. package/src/index.ts +101 -9
  40. package/src/main.ts +60 -20
  41. package/src/migrations.ts +47 -2
  42. package/src/modes/index.ts +2 -2
  43. package/src/modes/interactive/components/assistant-message.ts +25 -7
  44. package/src/modes/interactive/components/bash-execution.ts +5 -0
  45. package/src/modes/interactive/components/branch-summary-message.ts +5 -0
  46. package/src/modes/interactive/components/compaction-summary-message.ts +5 -0
  47. package/src/modes/interactive/components/countdown-timer.ts +38 -0
  48. package/src/modes/interactive/components/custom-editor.ts +8 -0
  49. package/src/modes/interactive/components/custom-message.ts +5 -0
  50. package/src/modes/interactive/components/footer.ts +2 -5
  51. package/src/modes/interactive/components/hook-input.ts +29 -20
  52. package/src/modes/interactive/components/hook-selector.ts +52 -38
  53. package/src/modes/interactive/components/index.ts +39 -0
  54. package/src/modes/interactive/components/login-dialog.ts +160 -0
  55. package/src/modes/interactive/components/model-selector.ts +10 -2
  56. package/src/modes/interactive/components/session-selector.ts +5 -1
  57. package/src/modes/interactive/components/settings-defs.ts +9 -0
  58. package/src/modes/interactive/components/status-line/segments.ts +3 -3
  59. package/src/modes/interactive/components/tool-execution.ts +9 -16
  60. package/src/modes/interactive/components/tree-selector.ts +1 -6
  61. package/src/modes/interactive/interactive-mode.ts +466 -215
  62. package/src/modes/interactive/theme/theme.ts +50 -2
  63. package/src/modes/print-mode.ts +78 -31
  64. package/src/modes/rpc/rpc-mode.ts +186 -78
  65. package/src/modes/rpc/rpc-types.ts +10 -3
  66. package/src/prompts/system-prompt.md +36 -28
  67. package/src/utils/clipboard.ts +90 -50
  68. package/src/utils/image-convert.ts +1 -1
  69. package/src/utils/image-resize.ts +1 -1
  70. package/src/utils/tools-manager.ts +2 -2
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * Credential storage for API keys and OAuth tokens.
3
3
  * Handles loading, saving, and refreshing credentials from auth.json.
4
+ *
5
+ * Uses file locking to prevent race conditions when multiple pi instances
6
+ * try to refresh tokens simultaneously.
4
7
  */
5
8
 
6
9
  import {
@@ -43,6 +46,45 @@ export type AuthCredentialEntry = AuthCredential | AuthCredential[];
43
46
 
44
47
  export type AuthStorageData = Record<string, AuthCredentialEntry>;
45
48
 
49
+ /** Rate limit window from Codex usage API (primary or secondary quota). */
50
+ type CodexUsageWindow = {
51
+ usedPercent?: number;
52
+ limitWindowSeconds?: number;
53
+ resetAt?: number; // Unix timestamp (seconds)
54
+ };
55
+
56
+ /** Parsed usage data from Codex /wham/usage endpoint. */
57
+ type CodexUsage = {
58
+ allowed?: boolean;
59
+ limitReached?: boolean;
60
+ primary?: CodexUsageWindow;
61
+ secondary?: CodexUsageWindow;
62
+ };
63
+
64
+ /** Cached usage entry with TTL for avoiding redundant API calls. */
65
+ type CodexUsageCacheEntry = {
66
+ fetchedAt: number;
67
+ expiresAt: number;
68
+ usage?: CodexUsage;
69
+ };
70
+
71
+ function isRecord(value: unknown): value is Record<string, unknown> {
72
+ return !!value && typeof value === "object" && !Array.isArray(value);
73
+ }
74
+
75
+ function toNumber(value: unknown): number | undefined {
76
+ if (typeof value === "number" && Number.isFinite(value)) return value;
77
+ if (typeof value === "string" && value.trim()) {
78
+ const parsed = Number(value);
79
+ return Number.isFinite(parsed) ? parsed : undefined;
80
+ }
81
+ return undefined;
82
+ }
83
+
84
+ function toBoolean(value: unknown): boolean | undefined {
85
+ return typeof value === "boolean" ? value : undefined;
86
+ }
87
+
46
88
  /**
47
89
  * Credential storage backed by a JSON file.
48
90
  * Reads from multiple fallback paths, writes to primary path.
@@ -52,13 +94,19 @@ export class AuthStorage {
52
94
  private static readonly lockRetryDelayMs = 50; // Polling interval when waiting for lock
53
95
  private static readonly lockTimeoutMs = 5000; // Max wait time before failing
54
96
  private static readonly lockStaleMs = 30000; // Age threshold for auto-removing orphaned locks
97
+ private static readonly codexUsageCacheTtlMs = 60_000; // Cache usage data for 1 minute
98
+ private static readonly defaultBackoffMs = 60_000; // Default backoff when no reset time available
55
99
 
56
100
  private data: AuthStorageData = {};
57
101
  private runtimeOverrides: Map<string, string> = new Map();
58
- /** Tracks next credential index per provider:type key for round-robin distribution */
102
+ /** Tracks next credential index per provider:type key for round-robin distribution (non-session use). */
59
103
  private providerRoundRobinIndex: Map<string, number> = new Map();
60
- /** Maps provider:type -> sessionId -> credentialIndex for session-sticky credential assignment */
61
- private sessionCredentialIndexes: Map<string, Map<string, number>> = new Map();
104
+ /** Tracks the last used credential per provider for a session (used for rate-limit switching). */
105
+ private sessionLastCredential: Map<string, Map<string, { type: AuthCredential["type"]; index: number }>> = new Map();
106
+ /** Maps provider:type -> credentialIndex -> blockedUntilMs for temporary backoff. */
107
+ private credentialBackoff: Map<string, Map<number, number>> = new Map();
108
+ /** Cached usage info for providers that expose usage endpoints. */
109
+ private codexUsageCache: Map<string, CodexUsageCacheEntry> = new Map();
62
110
  private fallbackResolver?: (provider: string) => string | undefined;
63
111
 
64
112
  /**
@@ -246,32 +294,85 @@ export class AuthStorage {
246
294
  }
247
295
 
248
296
  /**
249
- * Selects credential index with session affinity.
250
- * Sessions reuse their assigned credential; new sessions get next round-robin index.
251
- * This ensures a session always uses the same credential for consistency.
297
+ * FNV-1a hash for deterministic session-to-credential mapping.
298
+ * Ensures the same session always starts with the same credential.
252
299
  */
253
- private selectCredentialIndex(providerKey: string, sessionId: string | undefined, total: number): number {
300
+ private getHashedIndex(sessionId: string, total: number): number {
254
301
  if (total <= 1) return 0;
255
- if (!sessionId) return 0;
302
+ let hash = 2166136261; // FNV offset basis
303
+ for (let i = 0; i < sessionId.length; i++) {
304
+ hash ^= sessionId.charCodeAt(i);
305
+ hash = Math.imul(hash, 16777619); // FNV prime
306
+ }
307
+ return (hash >>> 0) % total;
308
+ }
256
309
 
257
- const sessionMap = this.sessionCredentialIndexes.get(providerKey);
258
- const existing = sessionMap?.get(sessionId);
259
- if (existing !== undefined && existing < total) {
260
- return existing;
310
+ /**
311
+ * Returns credential indices in priority order for selection.
312
+ * With sessionId: starts from hashed index (consistent per session).
313
+ * Without sessionId: starts from round-robin index (load balancing).
314
+ * Order wraps around so all credentials are tried if earlier ones are blocked.
315
+ */
316
+ private getCredentialOrder(providerKey: string, sessionId: string | undefined, total: number): number[] {
317
+ if (total <= 1) return [0];
318
+ const start = sessionId ? this.getHashedIndex(sessionId, total) : this.getNextRoundRobinIndex(providerKey, total);
319
+ const order: number[] = [];
320
+ for (let i = 0; i < total; i++) {
321
+ order.push((start + i) % total);
322
+ }
323
+ return order;
324
+ }
325
+
326
+ /** Checks if a credential is temporarily blocked due to usage limits. */
327
+ private isCredentialBlocked(providerKey: string, credentialIndex: number): boolean {
328
+ const backoffMap = this.credentialBackoff.get(providerKey);
329
+ if (!backoffMap) return false;
330
+ const blockedUntil = backoffMap.get(credentialIndex);
331
+ if (!blockedUntil) return false;
332
+ if (blockedUntil <= Date.now()) {
333
+ backoffMap.delete(credentialIndex);
334
+ if (backoffMap.size === 0) {
335
+ this.credentialBackoff.delete(providerKey);
336
+ }
337
+ return false;
261
338
  }
339
+ return true;
340
+ }
262
341
 
263
- // New session: assign next round-robin credential and cache the assignment
264
- const next = this.getNextRoundRobinIndex(providerKey, total);
265
- const updatedSessionMap = sessionMap ?? new Map<string, number>();
266
- updatedSessionMap.set(sessionId, next);
267
- this.sessionCredentialIndexes.set(providerKey, updatedSessionMap);
268
- return next;
342
+ /** Marks a credential as blocked until the specified time. */
343
+ private markCredentialBlocked(providerKey: string, credentialIndex: number, blockedUntilMs: number): void {
344
+ const backoffMap = this.credentialBackoff.get(providerKey) ?? new Map<number, number>();
345
+ const existing = backoffMap.get(credentialIndex) ?? 0;
346
+ backoffMap.set(credentialIndex, Math.max(existing, blockedUntilMs));
347
+ this.credentialBackoff.set(providerKey, backoffMap);
348
+ }
349
+
350
+ /** Records which credential was used for a session (for rate-limit switching). */
351
+ private recordSessionCredential(
352
+ provider: string,
353
+ sessionId: string | undefined,
354
+ type: AuthCredential["type"],
355
+ index: number,
356
+ ): void {
357
+ if (!sessionId) return;
358
+ const sessionMap = this.sessionLastCredential.get(provider) ?? new Map();
359
+ sessionMap.set(sessionId, { type, index });
360
+ this.sessionLastCredential.set(provider, sessionMap);
361
+ }
362
+
363
+ /** Retrieves the last credential used by a session. */
364
+ private getSessionCredential(
365
+ provider: string,
366
+ sessionId: string | undefined,
367
+ ): { type: AuthCredential["type"]; index: number } | undefined {
368
+ if (!sessionId) return undefined;
369
+ return this.sessionLastCredential.get(provider)?.get(sessionId);
269
370
  }
270
371
 
271
372
  /**
272
373
  * Selects a credential of the specified type for a provider.
273
374
  * Returns both the credential and its index in the original array (for updates/removal).
274
- * Uses session-sticky selection when multiple credentials exist.
375
+ * Uses deterministic hashing for session stickiness and skips blocked credentials when possible.
275
376
  */
276
377
  private selectCredentialByType<T extends AuthCredential["type"]>(
277
378
  provider: string,
@@ -289,8 +390,17 @@ export class AuthStorage {
289
390
  if (credentials.length === 1) return credentials[0];
290
391
 
291
392
  const providerKey = this.getProviderTypeKey(provider, type);
292
- const selectedIndex = this.selectCredentialIndex(providerKey, sessionId, credentials.length);
293
- return credentials[selectedIndex];
393
+ const order = this.getCredentialOrder(providerKey, sessionId, credentials.length);
394
+ const fallback = credentials[order[0]];
395
+
396
+ for (const idx of order) {
397
+ const candidate = credentials[idx];
398
+ if (!this.isCredentialBlocked(providerKey, candidate.index)) {
399
+ return candidate;
400
+ }
401
+ }
402
+
403
+ return fallback;
294
404
  }
295
405
 
296
406
  /**
@@ -303,9 +413,10 @@ export class AuthStorage {
303
413
  this.providerRoundRobinIndex.delete(key);
304
414
  }
305
415
  }
306
- for (const key of this.sessionCredentialIndexes.keys()) {
416
+ this.sessionLastCredential.delete(provider);
417
+ for (const key of this.credentialBackoff.keys()) {
307
418
  if (key.startsWith(`${provider}:`)) {
308
- this.sessionCredentialIndexes.delete(key);
419
+ this.credentialBackoff.delete(key);
309
420
  }
310
421
  }
311
422
  }
@@ -434,6 +545,10 @@ export class AuthStorage {
434
545
  onAuth: (info: { url: string; instructions?: string }) => void;
435
546
  onPrompt: (prompt: { message: string; placeholder?: string }) => Promise<string>;
436
547
  onProgress?: (message: string) => void;
548
+ /** For providers with local callback servers (e.g., openai-codex), races with browser callback */
549
+ onManualCodeInput?: () => Promise<string>;
550
+ /** For cancellation support (e.g., github-copilot polling) */
551
+ signal?: AbortSignal;
437
552
  },
438
553
  ): Promise<void> {
439
554
  let credentials: OAuthCredentials;
@@ -450,16 +565,22 @@ export class AuthStorage {
450
565
  onAuth: (url, instructions) => callbacks.onAuth({ url, instructions }),
451
566
  onPrompt: callbacks.onPrompt,
452
567
  onProgress: callbacks.onProgress,
568
+ signal: callbacks.signal,
453
569
  });
454
570
  break;
455
571
  case "google-gemini-cli":
456
- credentials = await loginGeminiCli(callbacks.onAuth, callbacks.onProgress);
572
+ credentials = await loginGeminiCli(callbacks.onAuth, callbacks.onProgress, callbacks.onManualCodeInput);
457
573
  break;
458
574
  case "google-antigravity":
459
- credentials = await loginAntigravity(callbacks.onAuth, callbacks.onProgress);
575
+ credentials = await loginAntigravity(callbacks.onAuth, callbacks.onProgress, callbacks.onManualCodeInput);
460
576
  break;
461
577
  case "openai-codex":
462
- credentials = await loginOpenAICodex(callbacks);
578
+ credentials = await loginOpenAICodex({
579
+ onAuth: callbacks.onAuth,
580
+ onPrompt: callbacks.onPrompt,
581
+ onProgress: callbacks.onProgress,
582
+ onManualCodeInput: callbacks.onManualCodeInput,
583
+ });
463
584
  break;
464
585
  default:
465
586
  throw new Error(`Unknown OAuth provider: ${provider}`);
@@ -482,6 +603,310 @@ export class AuthStorage {
482
603
  await this.remove(provider);
483
604
  }
484
605
 
606
+ // ─────────────────────────────────────────────────────────────────────────────
607
+ // Codex Usage API Integration
608
+ // Queries ChatGPT/Codex usage endpoints to detect rate limits before they occur.
609
+ // ─────────────────────────────────────────────────────────────────────────────
610
+
611
+ /** Normalizes Codex base URL to include /backend-api path. */
612
+ private normalizeCodexBaseUrl(baseUrl?: string): string {
613
+ const fallback = "https://chatgpt.com/backend-api";
614
+ const trimmed = baseUrl?.trim() ? baseUrl.trim() : fallback;
615
+ const base = trimmed.replace(/\/+$/, "");
616
+ const lower = base.toLowerCase();
617
+ if (
618
+ (lower.startsWith("https://chatgpt.com") || lower.startsWith("https://chat.openai.com")) &&
619
+ !lower.includes("/backend-api")
620
+ ) {
621
+ return `${base}/backend-api`;
622
+ }
623
+ return base;
624
+ }
625
+
626
+ private getCodexUsagePath(baseUrl: string): string {
627
+ return baseUrl.includes("/backend-api") ? "wham/usage" : "api/codex/usage";
628
+ }
629
+
630
+ private buildCodexUsageUrl(baseUrl: string, path: string): string {
631
+ const normalized = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
632
+ return `${normalized}${path.replace(/^\/+/, "")}`;
633
+ }
634
+
635
+ private getCodexUsageCacheKey(accountId: string, baseUrl: string): string {
636
+ return `${baseUrl}|${accountId}`;
637
+ }
638
+
639
+ private extractCodexUsageWindow(window: unknown): CodexUsageWindow | undefined {
640
+ if (!isRecord(window)) return undefined;
641
+ const usedPercent = toNumber(window.used_percent);
642
+ const limitWindowSeconds = toNumber(window.limit_window_seconds);
643
+ const resetAt = toNumber(window.reset_at);
644
+ if (usedPercent === undefined && limitWindowSeconds === undefined && resetAt === undefined) return undefined;
645
+ return { usedPercent, limitWindowSeconds, resetAt };
646
+ }
647
+
648
+ private extractCodexUsage(payload: unknown): CodexUsage | undefined {
649
+ if (!isRecord(payload)) return undefined;
650
+ const rateLimit = isRecord(payload.rate_limit) ? payload.rate_limit : undefined;
651
+ if (!rateLimit) return undefined;
652
+ const primary = this.extractCodexUsageWindow(rateLimit.primary_window);
653
+ const secondary = this.extractCodexUsageWindow(rateLimit.secondary_window);
654
+ const usage: CodexUsage = {
655
+ allowed: toBoolean(rateLimit.allowed),
656
+ limitReached: toBoolean(rateLimit.limit_reached),
657
+ primary,
658
+ secondary,
659
+ };
660
+ if (!primary && !secondary && usage.allowed === undefined && usage.limitReached === undefined) return undefined;
661
+ return usage;
662
+ }
663
+
664
+ /** Returns true if usage indicates rate limit has been reached. */
665
+ private isCodexUsageLimitReached(usage: CodexUsage): boolean {
666
+ if (usage.allowed === false || usage.limitReached === true) return true;
667
+ if (usage.primary?.usedPercent !== undefined && usage.primary.usedPercent >= 100) return true;
668
+ if (usage.secondary?.usedPercent !== undefined && usage.secondary.usedPercent >= 100) return true;
669
+ return false;
670
+ }
671
+
672
+ /** Extracts the earliest reset timestamp from usage windows (in ms). */
673
+ private getCodexResetAtMs(usage: CodexUsage): number | undefined {
674
+ const now = Date.now();
675
+ const candidates: number[] = [];
676
+ const addCandidate = (value: number | undefined) => {
677
+ if (!value) return;
678
+ const ms = value > 1_000_000_000_000 ? value : value * 1000;
679
+ if (Number.isFinite(ms) && ms > now) {
680
+ candidates.push(ms);
681
+ }
682
+ };
683
+ const useAll = usage.limitReached === true || usage.allowed === false;
684
+ if (useAll) {
685
+ addCandidate(usage.primary?.resetAt);
686
+ addCandidate(usage.secondary?.resetAt);
687
+ } else {
688
+ if (usage.primary?.usedPercent !== undefined && usage.primary.usedPercent >= 100) {
689
+ addCandidate(usage.primary.resetAt);
690
+ }
691
+ if (usage.secondary?.usedPercent !== undefined && usage.secondary.usedPercent >= 100) {
692
+ addCandidate(usage.secondary.resetAt);
693
+ }
694
+ }
695
+ if (candidates.length === 0) return undefined;
696
+ return Math.min(...candidates);
697
+ }
698
+
699
+ private getCodexUsageExpiryMs(usage: CodexUsage, nowMs: number): number {
700
+ const resetAtMs = this.getCodexResetAtMs(usage);
701
+ if (this.isCodexUsageLimitReached(usage)) {
702
+ if (resetAtMs) return resetAtMs;
703
+ return nowMs + AuthStorage.defaultBackoffMs;
704
+ }
705
+ const defaultExpiry = nowMs + AuthStorage.codexUsageCacheTtlMs;
706
+ if (!resetAtMs) return defaultExpiry;
707
+ return Math.min(defaultExpiry, resetAtMs);
708
+ }
709
+
710
+ /** Fetches usage data from Codex API. */
711
+ private async fetchCodexUsage(credential: OAuthCredential, baseUrl?: string): Promise<CodexUsage | undefined> {
712
+ const accountId = credential.accountId;
713
+ if (!accountId) return undefined;
714
+
715
+ const normalizedBase = this.normalizeCodexBaseUrl(baseUrl);
716
+ const url = this.buildCodexUsageUrl(normalizedBase, this.getCodexUsagePath(normalizedBase));
717
+ const headers = {
718
+ authorization: `Bearer ${credential.access}`,
719
+ "chatgpt-account-id": accountId,
720
+ "openai-beta": "responses=experimental",
721
+ originator: "codex_cli_rs",
722
+ };
723
+
724
+ try {
725
+ const response = await fetch(url, { headers });
726
+ if (!response.ok) {
727
+ logger.debug("AuthStorage codex usage fetch failed", {
728
+ status: response.status,
729
+ statusText: response.statusText,
730
+ });
731
+ return undefined;
732
+ }
733
+
734
+ const payload = (await response.json()) as unknown;
735
+ return this.extractCodexUsage(payload);
736
+ } catch (error) {
737
+ logger.debug("AuthStorage codex usage fetch error", { error: String(error) });
738
+ return undefined;
739
+ }
740
+ }
741
+
742
+ /** Gets usage data with caching to avoid redundant API calls. */
743
+ private async getCodexUsage(credential: OAuthCredential, baseUrl?: string): Promise<CodexUsage | undefined> {
744
+ const accountId = credential.accountId;
745
+ if (!accountId) return undefined;
746
+
747
+ const normalizedBase = this.normalizeCodexBaseUrl(baseUrl);
748
+ const cacheKey = this.getCodexUsageCacheKey(accountId, normalizedBase);
749
+ const now = Date.now();
750
+ const cached = this.codexUsageCache.get(cacheKey);
751
+ if (cached && cached.expiresAt > now) {
752
+ return cached.usage;
753
+ }
754
+
755
+ const usage = await this.fetchCodexUsage(credential, normalizedBase);
756
+ if (usage) {
757
+ const expiresAt = this.getCodexUsageExpiryMs(usage, now);
758
+ this.codexUsageCache.set(cacheKey, { fetchedAt: now, expiresAt, usage });
759
+ return usage;
760
+ }
761
+
762
+ this.codexUsageCache.set(cacheKey, {
763
+ fetchedAt: now,
764
+ expiresAt: now + AuthStorage.defaultBackoffMs,
765
+ });
766
+ return undefined;
767
+ }
768
+
769
+ /**
770
+ * Marks the current session's credential as temporarily blocked due to usage limits.
771
+ * Queries the Codex usage API to determine accurate reset time.
772
+ * Returns true if a credential was blocked, enabling automatic fallback to the next credential.
773
+ */
774
+ async markUsageLimitReached(
775
+ provider: string,
776
+ sessionId: string | undefined,
777
+ options?: { retryAfterMs?: number; baseUrl?: string },
778
+ ): Promise<boolean> {
779
+ const sessionCredential = this.getSessionCredential(provider, sessionId);
780
+ if (!sessionCredential) return false;
781
+
782
+ const providerKey = this.getProviderTypeKey(provider, sessionCredential.type);
783
+ const now = Date.now();
784
+ let blockedUntil = now + (options?.retryAfterMs ?? AuthStorage.defaultBackoffMs);
785
+
786
+ if (provider === "openai-codex" && sessionCredential.type === "oauth") {
787
+ const credential = this.getCredentialsForProvider(provider)[sessionCredential.index];
788
+ if (credential?.type === "oauth") {
789
+ const usage = await this.getCodexUsage(credential, options?.baseUrl);
790
+ if (usage) {
791
+ const resetAtMs = this.getCodexResetAtMs(usage);
792
+ if (resetAtMs && resetAtMs > blockedUntil) {
793
+ blockedUntil = resetAtMs;
794
+ }
795
+ }
796
+ }
797
+ }
798
+
799
+ this.markCredentialBlocked(providerKey, sessionCredential.index, blockedUntil);
800
+ return true;
801
+ }
802
+
803
+ /**
804
+ * Resolves an OAuth API key, trying credentials in priority order.
805
+ * Skips blocked credentials and checks usage limits for Codex accounts.
806
+ * Falls back to earliest-unblocking credential if all are blocked.
807
+ */
808
+ private async resolveOAuthApiKey(
809
+ provider: string,
810
+ sessionId?: string,
811
+ options?: { baseUrl?: string },
812
+ ): Promise<string | undefined> {
813
+ const credentials = this.getCredentialsForProvider(provider)
814
+ .map((credential, index) => ({ credential, index }))
815
+ .filter((entry): entry is { credential: OAuthCredential; index: number } => entry.credential.type === "oauth");
816
+
817
+ if (credentials.length === 0) return undefined;
818
+
819
+ const providerKey = this.getProviderTypeKey(provider, "oauth");
820
+ const order = this.getCredentialOrder(providerKey, sessionId, credentials.length);
821
+ const fallback = credentials[order[0]];
822
+ const checkUsage = provider === "openai-codex" && credentials.length > 1;
823
+
824
+ for (const idx of order) {
825
+ const selection = credentials[idx];
826
+ const apiKey = await this.tryOAuthCredential(
827
+ provider,
828
+ selection,
829
+ providerKey,
830
+ sessionId,
831
+ options,
832
+ checkUsage,
833
+ false,
834
+ );
835
+ if (apiKey) return apiKey;
836
+ }
837
+
838
+ if (fallback && this.isCredentialBlocked(providerKey, fallback.index)) {
839
+ return this.tryOAuthCredential(provider, fallback, providerKey, sessionId, options, checkUsage, true);
840
+ }
841
+
842
+ return undefined;
843
+ }
844
+
845
+ /** Attempts to use a single OAuth credential, checking usage and refreshing token. */
846
+ private async tryOAuthCredential(
847
+ provider: string,
848
+ selection: { credential: OAuthCredential; index: number },
849
+ providerKey: string,
850
+ sessionId: string | undefined,
851
+ options: { baseUrl?: string } | undefined,
852
+ checkUsage: boolean,
853
+ allowBlocked: boolean,
854
+ ): Promise<string | undefined> {
855
+ if (!allowBlocked && this.isCredentialBlocked(providerKey, selection.index)) {
856
+ return undefined;
857
+ }
858
+
859
+ if (checkUsage) {
860
+ const usage = await this.getCodexUsage(selection.credential, options?.baseUrl);
861
+ if (usage && this.isCodexUsageLimitReached(usage)) {
862
+ const resetAtMs = this.getCodexResetAtMs(usage);
863
+ this.markCredentialBlocked(
864
+ providerKey,
865
+ selection.index,
866
+ resetAtMs ?? Date.now() + AuthStorage.defaultBackoffMs,
867
+ );
868
+ return undefined;
869
+ }
870
+ }
871
+
872
+ const oauthCreds: Record<string, OAuthCredentials> = {
873
+ [provider]: selection.credential,
874
+ };
875
+
876
+ try {
877
+ const result = await getOAuthApiKey(provider as OAuthProvider, oauthCreds);
878
+ if (!result) return undefined;
879
+
880
+ const updated: OAuthCredential = { type: "oauth", ...result.newCredentials };
881
+ this.replaceCredentialAt(provider, selection.index, updated);
882
+ await this.save();
883
+
884
+ if (checkUsage) {
885
+ const usage = await this.getCodexUsage(updated, options?.baseUrl);
886
+ if (usage && this.isCodexUsageLimitReached(usage)) {
887
+ const resetAtMs = this.getCodexResetAtMs(usage);
888
+ this.markCredentialBlocked(
889
+ providerKey,
890
+ selection.index,
891
+ resetAtMs ?? Date.now() + AuthStorage.defaultBackoffMs,
892
+ );
893
+ return undefined;
894
+ }
895
+ }
896
+
897
+ this.recordSessionCredential(provider, sessionId, "oauth", selection.index);
898
+ return result.apiKey;
899
+ } catch {
900
+ this.removeCredentialAt(provider, selection.index);
901
+ await this.save();
902
+ if (this.getCredentialsForProvider(provider).some((credential) => credential.type === "oauth")) {
903
+ return this.getApiKey(provider, sessionId, options);
904
+ }
905
+ }
906
+
907
+ return undefined;
908
+ }
909
+
485
910
  /**
486
911
  * Get API key for a provider.
487
912
  * Priority:
@@ -491,7 +916,7 @@ export class AuthStorage {
491
916
  * 4. Environment variable
492
917
  * 5. Fallback resolver (models.json custom providers)
493
918
  */
494
- async getApiKey(provider: string, sessionId?: string): Promise<string | undefined> {
919
+ async getApiKey(provider: string, sessionId?: string, options?: { baseUrl?: string }): Promise<string | undefined> {
495
920
  // Runtime override takes highest priority
496
921
  const runtimeKey = this.runtimeOverrides.get(provider);
497
922
  if (runtimeKey) {
@@ -500,29 +925,13 @@ export class AuthStorage {
500
925
 
501
926
  const apiKeySelection = this.selectCredentialByType(provider, "api_key", sessionId);
502
927
  if (apiKeySelection) {
928
+ this.recordSessionCredential(provider, sessionId, "api_key", apiKeySelection.index);
503
929
  return apiKeySelection.credential.key;
504
930
  }
505
931
 
506
- const oauthSelection = this.selectCredentialByType(provider, "oauth", sessionId);
507
- if (oauthSelection) {
508
- const oauthCreds: Record<string, OAuthCredentials> = {
509
- [provider]: oauthSelection.credential,
510
- };
511
-
512
- try {
513
- const result = await getOAuthApiKey(provider as OAuthProvider, oauthCreds);
514
- if (result) {
515
- this.replaceCredentialAt(provider, oauthSelection.index, { type: "oauth", ...result.newCredentials });
516
- await this.save();
517
- return result.apiKey;
518
- }
519
- } catch {
520
- this.removeCredentialAt(provider, oauthSelection.index);
521
- await this.save();
522
- if (this.getCredentialsForProvider(provider).some((credential) => credential.type === "oauth")) {
523
- return this.getApiKey(provider, sessionId);
524
- }
525
- }
932
+ const oauthKey = await this.resolveOAuthApiKey(provider, sessionId, options);
933
+ if (oauthKey) {
934
+ return oauthKey;
526
935
  }
527
936
 
528
937
  // Fall back to environment variable