@oh-my-pi/pi-coding-agent 4.0.0 → 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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [4.0.1] - 2026-01-10
6
+ ### Added
7
+
8
+ - Added usage limit error detection to enable automatic credential switching when Codex accounts hit rate limits
9
+ - Added Codex usage API integration to proactively check account limits before credential selection
10
+ - Added credential backoff tracking to temporarily skip rate-limited accounts during selection
11
+ - Multi-credential usage-aware selection for OpenAI Codex OAuth accounts with automatic fallback when rate limits are reached
12
+ - Consistent session-to-credential hashing (FNV-1a) for stable credential assignment across sessions
13
+ - Codex usage API integration to detect and cache rate limit status per account
14
+ - Automatic mid-session credential switching when usage limits are hit
15
+
16
+ ### Changed
17
+
18
+ - Changed credential selection to use deterministic FNV-1a hashing for consistent session-to-credential mapping
19
+ - Changed OAuth credential resolution to try credentials in priority order, skipping blocked ones
20
+
5
21
  ## [4.0.0] - 2026-01-10
6
22
 
7
23
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "4.0.0",
3
+ "version": "4.0.1",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "ompConfig": {
@@ -39,10 +39,10 @@
39
39
  "prepublishOnly": "bun run generate-template && bun run clean && bun run build"
40
40
  },
41
41
  "dependencies": {
42
- "@oh-my-pi/pi-ai": "4.0.0",
43
- "@oh-my-pi/pi-agent-core": "4.0.0",
44
- "@oh-my-pi/pi-git-tool": "4.0.0",
45
- "@oh-my-pi/pi-tui": "4.0.0",
42
+ "@oh-my-pi/pi-ai": "4.0.1",
43
+ "@oh-my-pi/pi-agent-core": "4.0.1",
44
+ "@oh-my-pi/pi-git-tool": "4.0.1",
45
+ "@oh-my-pi/pi-tui": "4.0.1",
46
46
  "@openai/agents": "^0.3.7",
47
47
  "@sinclair/typebox": "^0.34.46",
48
48
  "ajv": "^8.17.1",
@@ -1981,12 +1981,16 @@ export class AgentSession {
1981
1981
  }
1982
1982
 
1983
1983
  private _isRetryableErrorMessage(errorMessage: string): boolean {
1984
- // Match: overloaded_error, rate limit, 429, 500, 502, 503, 504, service unavailable, connection error
1985
- return /overloaded|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server error|internal error|connection.?error/i.test(
1984
+ // Match: overloaded_error, rate limit, usage limit, 429, 500, 502, 503, 504, service unavailable, connection error
1985
+ return /overloaded|rate.?limit|usage.?limit|too many requests|429|500|502|503|504|service.?unavailable|server error|internal error|connection.?error/i.test(
1986
1986
  errorMessage,
1987
1987
  );
1988
1988
  }
1989
1989
 
1990
+ private _isUsageLimitErrorMessage(errorMessage: string): boolean {
1991
+ return /usage.?limit|usage_limit_reached|limit_reached/i.test(errorMessage);
1992
+ }
1993
+
1990
1994
  private _parseRetryAfterMsFromError(errorMessage: string): number | undefined {
1991
1995
  const now = Date.now();
1992
1996
  const retryAfterMsMatch = /retry-after-ms\s*[:=]\s*(\d+)/i.exec(errorMessage);
@@ -2063,14 +2067,30 @@ export class AgentSession {
2063
2067
  return false;
2064
2068
  }
2065
2069
 
2066
- const delayMs = settings.baseDelayMs * 2 ** (this._retryAttempt - 1);
2070
+ const errorMessage = message.errorMessage || "Unknown error";
2071
+ let delayMs = settings.baseDelayMs * 2 ** (this._retryAttempt - 1);
2072
+
2073
+ if (this.model && this._isUsageLimitErrorMessage(errorMessage)) {
2074
+ const retryAfterMs = this._parseRetryAfterMsFromError(errorMessage);
2075
+ const switched = await this._modelRegistry.authStorage.markUsageLimitReached(
2076
+ this.model.provider,
2077
+ this.sessionId,
2078
+ {
2079
+ retryAfterMs,
2080
+ baseUrl: this.model.baseUrl,
2081
+ },
2082
+ );
2083
+ if (switched) {
2084
+ delayMs = 0;
2085
+ }
2086
+ }
2067
2087
 
2068
2088
  this._emit({
2069
2089
  type: "auto_retry_start",
2070
2090
  attempt: this._retryAttempt,
2071
2091
  maxAttempts: settings.maxRetries,
2072
2092
  delayMs,
2073
- errorMessage: message.errorMessage || "Unknown error",
2093
+ errorMessage,
2074
2094
  });
2075
2095
 
2076
2096
  // Remove error message from agent state (keep in session for history)
@@ -46,6 +46,45 @@ export type AuthCredentialEntry = AuthCredential | AuthCredential[];
46
46
 
47
47
  export type AuthStorageData = Record<string, AuthCredentialEntry>;
48
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
+
49
88
  /**
50
89
  * Credential storage backed by a JSON file.
51
90
  * Reads from multiple fallback paths, writes to primary path.
@@ -55,13 +94,19 @@ export class AuthStorage {
55
94
  private static readonly lockRetryDelayMs = 50; // Polling interval when waiting for lock
56
95
  private static readonly lockTimeoutMs = 5000; // Max wait time before failing
57
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
58
99
 
59
100
  private data: AuthStorageData = {};
60
101
  private runtimeOverrides: Map<string, string> = new Map();
61
- /** 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). */
62
103
  private providerRoundRobinIndex: Map<string, number> = new Map();
63
- /** Maps provider:type -> sessionId -> credentialIndex for session-sticky credential assignment */
64
- 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();
65
110
  private fallbackResolver?: (provider: string) => string | undefined;
66
111
 
67
112
  /**
@@ -249,32 +294,85 @@ export class AuthStorage {
249
294
  }
250
295
 
251
296
  /**
252
- * Selects credential index with session affinity.
253
- * Sessions reuse their assigned credential; new sessions get next round-robin index.
254
- * 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.
255
299
  */
256
- private selectCredentialIndex(providerKey: string, sessionId: string | undefined, total: number): number {
300
+ private getHashedIndex(sessionId: string, total: number): number {
257
301
  if (total <= 1) return 0;
258
- 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
+ }
259
309
 
260
- const sessionMap = this.sessionCredentialIndexes.get(providerKey);
261
- const existing = sessionMap?.get(sessionId);
262
- if (existing !== undefined && existing < total) {
263
- 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;
264
338
  }
339
+ return true;
340
+ }
265
341
 
266
- // New session: assign next round-robin credential and cache the assignment
267
- const next = this.getNextRoundRobinIndex(providerKey, total);
268
- const updatedSessionMap = sessionMap ?? new Map<string, number>();
269
- updatedSessionMap.set(sessionId, next);
270
- this.sessionCredentialIndexes.set(providerKey, updatedSessionMap);
271
- 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);
272
370
  }
273
371
 
274
372
  /**
275
373
  * Selects a credential of the specified type for a provider.
276
374
  * Returns both the credential and its index in the original array (for updates/removal).
277
- * Uses session-sticky selection when multiple credentials exist.
375
+ * Uses deterministic hashing for session stickiness and skips blocked credentials when possible.
278
376
  */
279
377
  private selectCredentialByType<T extends AuthCredential["type"]>(
280
378
  provider: string,
@@ -292,8 +390,17 @@ export class AuthStorage {
292
390
  if (credentials.length === 1) return credentials[0];
293
391
 
294
392
  const providerKey = this.getProviderTypeKey(provider, type);
295
- const selectedIndex = this.selectCredentialIndex(providerKey, sessionId, credentials.length);
296
- 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;
297
404
  }
298
405
 
299
406
  /**
@@ -306,9 +413,10 @@ export class AuthStorage {
306
413
  this.providerRoundRobinIndex.delete(key);
307
414
  }
308
415
  }
309
- for (const key of this.sessionCredentialIndexes.keys()) {
416
+ this.sessionLastCredential.delete(provider);
417
+ for (const key of this.credentialBackoff.keys()) {
310
418
  if (key.startsWith(`${provider}:`)) {
311
- this.sessionCredentialIndexes.delete(key);
419
+ this.credentialBackoff.delete(key);
312
420
  }
313
421
  }
314
422
  }
@@ -495,6 +603,310 @@ export class AuthStorage {
495
603
  await this.remove(provider);
496
604
  }
497
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
+
498
910
  /**
499
911
  * Get API key for a provider.
500
912
  * Priority:
@@ -504,7 +916,7 @@ export class AuthStorage {
504
916
  * 4. Environment variable
505
917
  * 5. Fallback resolver (models.json custom providers)
506
918
  */
507
- async getApiKey(provider: string, sessionId?: string): Promise<string | undefined> {
919
+ async getApiKey(provider: string, sessionId?: string, options?: { baseUrl?: string }): Promise<string | undefined> {
508
920
  // Runtime override takes highest priority
509
921
  const runtimeKey = this.runtimeOverrides.get(provider);
510
922
  if (runtimeKey) {
@@ -513,29 +925,13 @@ export class AuthStorage {
513
925
 
514
926
  const apiKeySelection = this.selectCredentialByType(provider, "api_key", sessionId);
515
927
  if (apiKeySelection) {
928
+ this.recordSessionCredential(provider, sessionId, "api_key", apiKeySelection.index);
516
929
  return apiKeySelection.credential.key;
517
930
  }
518
931
 
519
- const oauthSelection = this.selectCredentialByType(provider, "oauth", sessionId);
520
- if (oauthSelection) {
521
- const oauthCreds: Record<string, OAuthCredentials> = {
522
- [provider]: oauthSelection.credential,
523
- };
524
-
525
- try {
526
- const result = await getOAuthApiKey(provider as OAuthProvider, oauthCreds);
527
- if (result) {
528
- this.replaceCredentialAt(provider, oauthSelection.index, { type: "oauth", ...result.newCredentials });
529
- await this.save();
530
- return result.apiKey;
531
- }
532
- } catch {
533
- this.removeCredentialAt(provider, oauthSelection.index);
534
- await this.save();
535
- if (this.getCredentialsForProvider(provider).some((credential) => credential.type === "oauth")) {
536
- return this.getApiKey(provider, sessionId);
537
- }
538
- }
932
+ const oauthKey = await this.resolveOAuthApiKey(provider, sessionId, options);
933
+ if (oauthKey) {
934
+ return oauthKey;
539
935
  }
540
936
 
541
937
  // Fall back to environment variable
@@ -391,14 +391,14 @@ export class ModelRegistry {
391
391
  * Get API key for a model.
392
392
  */
393
393
  async getApiKey(model: Model<Api>, sessionId?: string): Promise<string | undefined> {
394
- return this.authStorage.getApiKey(model.provider, sessionId);
394
+ return this.authStorage.getApiKey(model.provider, sessionId, { baseUrl: model.baseUrl });
395
395
  }
396
396
 
397
397
  /**
398
398
  * Get API key for a provider (e.g., "openai").
399
399
  */
400
- async getApiKeyForProvider(provider: string, sessionId?: string): Promise<string | undefined> {
401
- return this.authStorage.getApiKey(provider, sessionId);
400
+ async getApiKeyForProvider(provider: string, sessionId?: string, baseUrl?: string): Promise<string | undefined> {
401
+ return this.authStorage.getApiKey(provider, sessionId, { baseUrl });
402
402
  }
403
403
 
404
404
  /**