@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 +16 -0
- package/package.json +5 -5
- package/src/core/agent-session.ts +24 -4
- package/src/core/auth-storage.ts +440 -44
- package/src/core/model-registry.ts +3 -3
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.
|
|
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.
|
|
43
|
-
"@oh-my-pi/pi-agent-core": "4.0.
|
|
44
|
-
"@oh-my-pi/pi-git-tool": "4.0.
|
|
45
|
-
"@oh-my-pi/pi-tui": "4.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
|
|
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
|
|
2093
|
+
errorMessage,
|
|
2074
2094
|
});
|
|
2075
2095
|
|
|
2076
2096
|
// Remove error message from agent state (keep in session for history)
|
package/src/core/auth-storage.ts
CHANGED
|
@@ -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
|
-
/**
|
|
64
|
-
private
|
|
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
|
-
*
|
|
253
|
-
*
|
|
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
|
|
300
|
+
private getHashedIndex(sessionId: string, total: number): number {
|
|
257
301
|
if (total <= 1) return 0;
|
|
258
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
|
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
|
|
296
|
-
|
|
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
|
-
|
|
416
|
+
this.sessionLastCredential.delete(provider);
|
|
417
|
+
for (const key of this.credentialBackoff.keys()) {
|
|
310
418
|
if (key.startsWith(`${provider}:`)) {
|
|
311
|
-
this.
|
|
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
|
|
520
|
-
if (
|
|
521
|
-
|
|
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
|
/**
|