@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.
- package/CHANGELOG.md +103 -0
- package/README.md +44 -3
- package/docs/extensions.md +29 -4
- package/docs/sdk.md +3 -3
- package/package.json +5 -5
- package/src/cli/args.ts +8 -0
- package/src/config.ts +5 -15
- package/src/core/agent-session.ts +217 -51
- package/src/core/auth-storage.ts +456 -47
- package/src/core/bash-executor.ts +79 -14
- package/src/core/custom-commands/types.ts +1 -1
- package/src/core/custom-tools/types.ts +1 -1
- package/src/core/export-html/index.ts +33 -1
- package/src/core/export-html/template.css +99 -0
- package/src/core/export-html/template.generated.ts +1 -1
- package/src/core/export-html/template.js +133 -8
- package/src/core/extensions/index.ts +22 -4
- package/src/core/extensions/loader.ts +152 -214
- package/src/core/extensions/runner.ts +139 -79
- package/src/core/extensions/types.ts +143 -19
- package/src/core/extensions/wrapper.ts +5 -8
- package/src/core/hooks/types.ts +1 -1
- package/src/core/index.ts +2 -1
- package/src/core/keybindings.ts +4 -1
- package/src/core/model-registry.ts +4 -4
- package/src/core/model-resolver.ts +35 -26
- package/src/core/sdk.ts +96 -76
- package/src/core/settings-manager.ts +45 -14
- package/src/core/system-prompt.ts +5 -15
- package/src/core/tools/bash.ts +115 -54
- package/src/core/tools/find.ts +86 -7
- package/src/core/tools/grep.ts +27 -6
- package/src/core/tools/index.ts +15 -6
- package/src/core/tools/ls.ts +49 -18
- package/src/core/tools/render-utils.ts +2 -1
- package/src/core/tools/task/worker.ts +35 -12
- package/src/core/tools/web-search/auth.ts +37 -32
- package/src/core/tools/web-search/providers/anthropic.ts +35 -22
- package/src/index.ts +101 -9
- package/src/main.ts +60 -20
- package/src/migrations.ts +47 -2
- package/src/modes/index.ts +2 -2
- package/src/modes/interactive/components/assistant-message.ts +25 -7
- package/src/modes/interactive/components/bash-execution.ts +5 -0
- package/src/modes/interactive/components/branch-summary-message.ts +5 -0
- package/src/modes/interactive/components/compaction-summary-message.ts +5 -0
- package/src/modes/interactive/components/countdown-timer.ts +38 -0
- package/src/modes/interactive/components/custom-editor.ts +8 -0
- package/src/modes/interactive/components/custom-message.ts +5 -0
- package/src/modes/interactive/components/footer.ts +2 -5
- package/src/modes/interactive/components/hook-input.ts +29 -20
- package/src/modes/interactive/components/hook-selector.ts +52 -38
- package/src/modes/interactive/components/index.ts +39 -0
- package/src/modes/interactive/components/login-dialog.ts +160 -0
- package/src/modes/interactive/components/model-selector.ts +10 -2
- package/src/modes/interactive/components/session-selector.ts +5 -1
- package/src/modes/interactive/components/settings-defs.ts +9 -0
- package/src/modes/interactive/components/status-line/segments.ts +3 -3
- package/src/modes/interactive/components/tool-execution.ts +9 -16
- package/src/modes/interactive/components/tree-selector.ts +1 -6
- package/src/modes/interactive/interactive-mode.ts +466 -215
- package/src/modes/interactive/theme/theme.ts +50 -2
- package/src/modes/print-mode.ts +78 -31
- package/src/modes/rpc/rpc-mode.ts +186 -78
- package/src/modes/rpc/rpc-types.ts +10 -3
- package/src/prompts/system-prompt.md +36 -28
- package/src/utils/clipboard.ts +90 -50
- package/src/utils/image-convert.ts +1 -1
- package/src/utils/image-resize.ts +1 -1
- package/src/utils/tools-manager.ts +2 -2
package/src/core/auth-storage.ts
CHANGED
|
@@ -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
|
-
/**
|
|
61
|
-
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();
|
|
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
|
-
*
|
|
250
|
-
*
|
|
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
|
|
300
|
+
private getHashedIndex(sessionId: string, total: number): number {
|
|
254
301
|
if (total <= 1) return 0;
|
|
255
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
|
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
|
|
293
|
-
|
|
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
|
-
|
|
416
|
+
this.sessionLastCredential.delete(provider);
|
|
417
|
+
for (const key of this.credentialBackoff.keys()) {
|
|
307
418
|
if (key.startsWith(`${provider}:`)) {
|
|
308
|
-
this.
|
|
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(
|
|
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
|
|
507
|
-
if (
|
|
508
|
-
|
|
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
|