@oh-my-pi/pi-coding-agent 6.9.0 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/CHANGELOG.md +173 -51
  2. package/examples/sdk/04-skills.ts +1 -1
  3. package/package.json +6 -5
  4. package/src/cli/stats-cli.ts +191 -0
  5. package/src/core/agent-session.ts +214 -4
  6. package/src/core/auth-storage.ts +524 -202
  7. package/src/core/bash-executor.ts +1 -1
  8. package/src/core/extensions/index.ts +2 -0
  9. package/src/core/extensions/runner.ts +31 -0
  10. package/src/core/extensions/types.ts +24 -0
  11. package/src/core/messages.ts +48 -0
  12. package/src/core/model-registry.ts +7 -0
  13. package/src/core/python-executor.ts +29 -8
  14. package/src/core/python-gateway-coordinator.ts +55 -1
  15. package/src/core/python-prelude.py +201 -8
  16. package/src/core/session-manager.ts +10 -1
  17. package/src/core/tools/bash.ts +5 -7
  18. package/src/core/tools/find.ts +18 -5
  19. package/src/core/tools/index.ts +1 -1
  20. package/src/core/tools/lsp/index.ts +13 -2
  21. package/src/core/tools/patch/applicator.ts +115 -17
  22. package/src/core/tools/patch/index.ts +1 -1
  23. package/src/core/tools/patch/normalize.ts +185 -10
  24. package/src/core/tools/python.ts +445 -86
  25. package/src/core/tools/read.ts +4 -4
  26. package/src/core/tools/task/executor.ts +2 -6
  27. package/src/core/tools/task/index.ts +30 -12
  28. package/src/core/tools/task/render.ts +163 -30
  29. package/src/core/tools/task/template.ts +37 -0
  30. package/src/core/tools/task/types.ts +6 -2
  31. package/src/core/tools/task/worker.ts +1 -1
  32. package/src/index.ts +2 -0
  33. package/src/main.ts +12 -0
  34. package/src/modes/interactive/components/python-execution.ts +180 -0
  35. package/src/modes/interactive/components/welcome.ts +1 -0
  36. package/src/modes/interactive/controllers/command-controller.ts +395 -0
  37. package/src/modes/interactive/controllers/input-controller.ts +83 -8
  38. package/src/modes/interactive/interactive-mode.ts +16 -1
  39. package/src/modes/interactive/theme/dark.json +2 -9
  40. package/src/modes/interactive/theme/defaults/alabaster.json +2 -8
  41. package/src/modes/interactive/theme/defaults/amethyst.json +2 -9
  42. package/src/modes/interactive/theme/defaults/anthracite.json +2 -9
  43. package/src/modes/interactive/theme/defaults/basalt.json +89 -88
  44. package/src/modes/interactive/theme/defaults/birch.json +2 -8
  45. package/src/modes/interactive/theme/defaults/dark-abyss.json +2 -8
  46. package/src/modes/interactive/theme/defaults/dark-arctic.json +2 -9
  47. package/src/modes/interactive/theme/defaults/dark-aurora.json +3 -2
  48. package/src/modes/interactive/theme/defaults/dark-catppuccin.json +2 -1
  49. package/src/modes/interactive/theme/defaults/dark-cavern.json +2 -8
  50. package/src/modes/interactive/theme/defaults/dark-copper.json +3 -2
  51. package/src/modes/interactive/theme/defaults/dark-cosmos.json +2 -8
  52. package/src/modes/interactive/theme/defaults/dark-cyberpunk.json +2 -9
  53. package/src/modes/interactive/theme/defaults/dark-dracula.json +2 -9
  54. package/src/modes/interactive/theme/defaults/dark-eclipse.json +2 -8
  55. package/src/modes/interactive/theme/defaults/dark-ember.json +3 -2
  56. package/src/modes/interactive/theme/defaults/dark-equinox.json +2 -8
  57. package/src/modes/interactive/theme/defaults/dark-forest.json +2 -9
  58. package/src/modes/interactive/theme/defaults/dark-github.json +2 -9
  59. package/src/modes/interactive/theme/defaults/dark-gruvbox.json +2 -9
  60. package/src/modes/interactive/theme/defaults/dark-lavender.json +3 -2
  61. package/src/modes/interactive/theme/defaults/dark-lunar.json +2 -8
  62. package/src/modes/interactive/theme/defaults/dark-midnight.json +3 -2
  63. package/src/modes/interactive/theme/defaults/dark-monochrome.json +2 -9
  64. package/src/modes/interactive/theme/defaults/dark-monokai.json +2 -9
  65. package/src/modes/interactive/theme/defaults/dark-nebula.json +2 -8
  66. package/src/modes/interactive/theme/defaults/dark-nord.json +2 -9
  67. package/src/modes/interactive/theme/defaults/dark-ocean.json +2 -9
  68. package/src/modes/interactive/theme/defaults/dark-one.json +2 -9
  69. package/src/modes/interactive/theme/defaults/dark-rainforest.json +2 -8
  70. package/src/modes/interactive/theme/defaults/dark-reef.json +2 -8
  71. package/src/modes/interactive/theme/defaults/dark-retro.json +2 -9
  72. package/src/modes/interactive/theme/defaults/dark-rose-pine.json +2 -1
  73. package/src/modes/interactive/theme/defaults/dark-sakura.json +3 -2
  74. package/src/modes/interactive/theme/defaults/dark-slate.json +3 -2
  75. package/src/modes/interactive/theme/defaults/dark-solarized.json +2 -1
  76. package/src/modes/interactive/theme/defaults/dark-solstice.json +2 -8
  77. package/src/modes/interactive/theme/defaults/dark-starfall.json +2 -8
  78. package/src/modes/interactive/theme/defaults/dark-sunset.json +2 -9
  79. package/src/modes/interactive/theme/defaults/dark-swamp.json +2 -8
  80. package/src/modes/interactive/theme/defaults/dark-synthwave.json +2 -1
  81. package/src/modes/interactive/theme/defaults/dark-taiga.json +2 -8
  82. package/src/modes/interactive/theme/defaults/dark-terminal.json +3 -2
  83. package/src/modes/interactive/theme/defaults/dark-tokyo-night.json +2 -9
  84. package/src/modes/interactive/theme/defaults/dark-tundra.json +2 -8
  85. package/src/modes/interactive/theme/defaults/dark-twilight.json +2 -8
  86. package/src/modes/interactive/theme/defaults/dark-volcanic.json +2 -8
  87. package/src/modes/interactive/theme/defaults/graphite.json +2 -9
  88. package/src/modes/interactive/theme/defaults/light-arctic.json +2 -1
  89. package/src/modes/interactive/theme/defaults/light-aurora-day.json +2 -8
  90. package/src/modes/interactive/theme/defaults/light-canyon.json +2 -8
  91. package/src/modes/interactive/theme/defaults/light-catppuccin.json +2 -1
  92. package/src/modes/interactive/theme/defaults/light-cirrus.json +2 -8
  93. package/src/modes/interactive/theme/defaults/light-coral.json +3 -2
  94. package/src/modes/interactive/theme/defaults/light-cyberpunk.json +2 -9
  95. package/src/modes/interactive/theme/defaults/light-dawn.json +2 -8
  96. package/src/modes/interactive/theme/defaults/light-dunes.json +2 -8
  97. package/src/modes/interactive/theme/defaults/light-eucalyptus.json +3 -2
  98. package/src/modes/interactive/theme/defaults/light-forest.json +2 -9
  99. package/src/modes/interactive/theme/defaults/light-frost.json +3 -2
  100. package/src/modes/interactive/theme/defaults/light-github.json +2 -1
  101. package/src/modes/interactive/theme/defaults/light-glacier.json +2 -8
  102. package/src/modes/interactive/theme/defaults/light-gruvbox.json +2 -9
  103. package/src/modes/interactive/theme/defaults/light-haze.json +2 -8
  104. package/src/modes/interactive/theme/defaults/light-honeycomb.json +3 -2
  105. package/src/modes/interactive/theme/defaults/light-lagoon.json +2 -8
  106. package/src/modes/interactive/theme/defaults/light-lavender.json +3 -2
  107. package/src/modes/interactive/theme/defaults/light-meadow.json +2 -8
  108. package/src/modes/interactive/theme/defaults/light-mint.json +3 -2
  109. package/src/modes/interactive/theme/defaults/light-monochrome.json +2 -1
  110. package/src/modes/interactive/theme/defaults/light-ocean.json +2 -9
  111. package/src/modes/interactive/theme/defaults/light-one.json +2 -8
  112. package/src/modes/interactive/theme/defaults/light-opal.json +2 -8
  113. package/src/modes/interactive/theme/defaults/light-orchard.json +2 -8
  114. package/src/modes/interactive/theme/defaults/light-paper.json +3 -2
  115. package/src/modes/interactive/theme/defaults/light-prism.json +2 -8
  116. package/src/modes/interactive/theme/defaults/light-retro.json +2 -9
  117. package/src/modes/interactive/theme/defaults/light-sand.json +3 -2
  118. package/src/modes/interactive/theme/defaults/light-savanna.json +2 -8
  119. package/src/modes/interactive/theme/defaults/light-solarized.json +2 -1
  120. package/src/modes/interactive/theme/defaults/light-soleil.json +2 -8
  121. package/src/modes/interactive/theme/defaults/light-sunset.json +2 -9
  122. package/src/modes/interactive/theme/defaults/light-synthwave.json +2 -9
  123. package/src/modes/interactive/theme/defaults/light-tokyo-night.json +2 -9
  124. package/src/modes/interactive/theme/defaults/light-wetland.json +2 -8
  125. package/src/modes/interactive/theme/defaults/light-zenith.json +2 -8
  126. package/src/modes/interactive/theme/defaults/limestone.json +2 -8
  127. package/src/modes/interactive/theme/defaults/mahogany.json +2 -9
  128. package/src/modes/interactive/theme/defaults/marble.json +2 -8
  129. package/src/modes/interactive/theme/defaults/obsidian.json +89 -88
  130. package/src/modes/interactive/theme/defaults/onyx.json +89 -88
  131. package/src/modes/interactive/theme/defaults/pearl.json +2 -8
  132. package/src/modes/interactive/theme/defaults/porcelain.json +89 -88
  133. package/src/modes/interactive/theme/defaults/quartz.json +2 -8
  134. package/src/modes/interactive/theme/defaults/sandstone.json +2 -8
  135. package/src/modes/interactive/theme/defaults/titanium.json +88 -87
  136. package/src/modes/interactive/theme/light.json +2 -8
  137. package/src/modes/interactive/theme/theme-schema.json +5 -0
  138. package/src/modes/interactive/theme/theme.ts +7 -0
  139. package/src/modes/interactive/types.ts +7 -1
  140. package/src/modes/interactive/utils/ui-helpers.ts +20 -0
  141. package/src/prompts/system/system-prompt.md +88 -78
  142. package/src/prompts/tools/python.md +39 -2
  143. package/src/prompts/tools/task.md +8 -13
@@ -3,10 +3,15 @@
3
3
  * Handles loading, saving, and refreshing credentials from agent.db.
4
4
  */
5
5
 
6
+ import { Buffer } from "node:buffer";
6
7
  import { dirname, join } from "node:path";
7
8
  import {
9
+ antigravityUsageProvider,
10
+ claudeUsageProvider,
8
11
  getEnvApiKey,
9
12
  getOAuthApiKey,
13
+ githubCopilotUsageProvider,
14
+ googleGeminiCliUsageProvider,
10
15
  loginAnthropic,
11
16
  loginAntigravity,
12
17
  loginCursor,
@@ -16,6 +21,16 @@ import {
16
21
  type OAuthController,
17
22
  type OAuthCredentials,
18
23
  type OAuthProvider,
24
+ openaiCodexUsageProvider,
25
+ type Provider,
26
+ type UsageCache,
27
+ type UsageCacheEntry,
28
+ type UsageCredential,
29
+ type UsageLimit,
30
+ type UsageLogger,
31
+ type UsageProvider,
32
+ type UsageReport,
33
+ zaiUsageProvider,
19
34
  } from "@oh-my-pi/pi-ai";
20
35
  import { logger } from "@oh-my-pi/pi-utils";
21
36
  import { getAgentDbPath, getAuthPath } from "../config";
@@ -61,43 +76,64 @@ export interface SerializedAuthStorage {
61
76
  */
62
77
  type StoredCredential = { id: number; credential: AuthCredential };
63
78
 
64
- /** Rate limit window from Codex usage API (primary or secondary quota). */
65
- type CodexUsageWindow = {
66
- usedPercent?: number;
67
- limitWindowSeconds?: number;
68
- resetAt?: number; // Unix timestamp (seconds)
79
+ export type AuthStorageOptions = {
80
+ usageProviderResolver?: (provider: Provider) => UsageProvider | undefined;
81
+ usageCache?: UsageCache;
82
+ usageFetch?: typeof fetch;
83
+ usageNow?: () => number;
84
+ usageLogger?: UsageLogger;
69
85
  };
70
86
 
71
- /** Parsed usage data from Codex /wham/usage endpoint. */
72
- type CodexUsage = {
73
- allowed?: boolean;
74
- limitReached?: boolean;
75
- primary?: CodexUsageWindow;
76
- secondary?: CodexUsageWindow;
77
- };
87
+ const DEFAULT_USAGE_PROVIDERS: UsageProvider[] = [
88
+ openaiCodexUsageProvider,
89
+ antigravityUsageProvider,
90
+ googleGeminiCliUsageProvider,
91
+ claudeUsageProvider,
92
+ zaiUsageProvider,
93
+ githubCopilotUsageProvider,
94
+ ];
78
95
 
79
- /** Cached usage entry with TTL for avoiding redundant API calls. */
80
- type CodexUsageCacheEntry = {
81
- fetchedAt: number;
82
- expiresAt: number;
83
- usage?: CodexUsage;
84
- };
96
+ const DEFAULT_USAGE_PROVIDER_MAP = new Map<Provider, UsageProvider>(
97
+ DEFAULT_USAGE_PROVIDERS.map((provider) => [provider.id, provider]),
98
+ );
85
99
 
86
- function isRecord(value: unknown): value is Record<string, unknown> {
87
- return !!value && typeof value === "object" && !Array.isArray(value);
100
+ const USAGE_CACHE_PREFIX = "usage_cache:";
101
+
102
+ function resolveDefaultUsageProvider(provider: Provider): UsageProvider | undefined {
103
+ return DEFAULT_USAGE_PROVIDER_MAP.get(provider);
88
104
  }
89
105
 
90
- function toNumber(value: unknown): number | undefined {
91
- if (typeof value === "number" && Number.isFinite(value)) return value;
92
- if (typeof value === "string" && value.trim()) {
93
- const parsed = Number(value);
94
- return Number.isFinite(parsed) ? parsed : undefined;
106
+ function parseUsageCacheEntry(raw: string): UsageCacheEntry | undefined {
107
+ try {
108
+ const parsed = JSON.parse(raw) as { value?: UsageReport | null; expiresAt?: unknown };
109
+ const expiresAt = typeof parsed.expiresAt === "number" ? parsed.expiresAt : undefined;
110
+ if (!expiresAt || !Number.isFinite(expiresAt)) return undefined;
111
+ return { value: parsed.value ?? null, expiresAt };
112
+ } catch {
113
+ return undefined;
95
114
  }
96
- return undefined;
97
115
  }
98
116
 
99
- function toBoolean(value: unknown): boolean | undefined {
100
- return typeof value === "boolean" ? value : undefined;
117
+ class AuthStorageUsageCache implements UsageCache {
118
+ constructor(private storage: AgentStorage) {}
119
+
120
+ get(key: string): UsageCacheEntry | undefined {
121
+ const raw = this.storage.getCache(`${USAGE_CACHE_PREFIX}${key}`);
122
+ if (!raw) return undefined;
123
+ const entry = parseUsageCacheEntry(raw);
124
+ if (!entry) return undefined;
125
+ if (entry.expiresAt <= Date.now()) return undefined;
126
+ return entry;
127
+ }
128
+
129
+ set(key: string, entry: UsageCacheEntry): void {
130
+ const payload = JSON.stringify({ value: entry.value ?? null, expiresAt: entry.expiresAt });
131
+ this.storage.setCache(`${USAGE_CACHE_PREFIX}${key}`, payload, Math.floor(entry.expiresAt / 1000));
132
+ }
133
+
134
+ cleanup(): void {
135
+ this.storage.cleanExpiredCache();
136
+ }
101
137
  }
102
138
 
103
139
  /**
@@ -105,14 +141,11 @@ function toBoolean(value: unknown): boolean | undefined {
105
141
  * Reads from SQLite and migrates legacy auth.json paths.
106
142
  */
107
143
  export class AuthStorage {
108
- private static readonly codexUsageCacheTtlMs = 60_000; // Cache usage data for 1 minute
109
144
  private static readonly defaultBackoffMs = 60_000; // Default backoff when no reset time available
110
- private static readonly cacheCleanupIntervalMs = 300_000; // Clean expired cache every 5 minutes
111
145
 
112
146
  /** Provider -> credentials cache, populated from agent.db on reload(). */
113
147
  private data: Map<string, StoredCredential[]> = new Map();
114
148
  private storage: AgentStorage;
115
- private lastCacheCleanup = 0;
116
149
  /** Resolved path to agent.db (derived from authPath or used directly if .db). */
117
150
  private dbPath: string;
118
151
  private runtimeOverrides: Map<string, string> = new Map();
@@ -122,8 +155,11 @@ export class AuthStorage {
122
155
  private sessionLastCredential: Map<string, Map<string, { type: AuthCredential["type"]; index: number }>> = new Map();
123
156
  /** Maps provider:type -> credentialIndex -> blockedUntilMs for temporary backoff. */
124
157
  private credentialBackoff: Map<string, Map<number, number>> = new Map();
125
- /** Cached usage info for providers that expose usage endpoints. */
126
- private codexUsageCache: Map<string, CodexUsageCacheEntry> = new Map();
158
+ private usageProviderResolver?: (provider: Provider) => UsageProvider | undefined;
159
+ private usageCache?: UsageCache;
160
+ private usageFetch: typeof fetch;
161
+ private usageNow: () => number;
162
+ private usageLogger?: UsageLogger;
127
163
  private fallbackResolver?: (provider: string) => string | undefined;
128
164
 
129
165
  /**
@@ -133,16 +169,27 @@ export class AuthStorage {
133
169
  constructor(
134
170
  private authPath: string,
135
171
  private fallbackPaths: string[] = [],
172
+ options: AuthStorageOptions = {},
136
173
  ) {
137
174
  this.dbPath = AuthStorage.resolveDbPath(authPath);
138
175
  this.storage = AgentStorage.open(this.dbPath);
176
+ this.usageProviderResolver = options.usageProviderResolver ?? resolveDefaultUsageProvider;
177
+ this.usageCache = options.usageCache ?? new AuthStorageUsageCache(this.storage);
178
+ this.usageFetch = options.usageFetch ?? fetch;
179
+ this.usageNow = options.usageNow ?? Date.now;
180
+ this.usageLogger =
181
+ options.usageLogger ??
182
+ ({
183
+ debug: (message, meta) => logger.debug(message, meta),
184
+ warn: (message, meta) => logger.warn(message, meta),
185
+ } satisfies UsageLogger);
139
186
  }
140
187
 
141
188
  /**
142
189
  * Create an in-memory AuthStorage instance from serialized data.
143
190
  * Used by subagent workers to bypass discovery and use parent's credentials.
144
191
  */
145
- static fromSerialized(data: SerializedAuthStorage): AuthStorage {
192
+ static fromSerialized(data: SerializedAuthStorage, options: AuthStorageOptions = {}): AuthStorage {
146
193
  const instance = Object.create(AuthStorage.prototype) as AuthStorage;
147
194
  const authPath = data.authPath ?? data.dbPath ?? getAuthPath();
148
195
  instance.authPath = authPath;
@@ -154,8 +201,16 @@ export class AuthStorage {
154
201
  instance.providerRoundRobinIndex = new Map();
155
202
  instance.sessionLastCredential = new Map();
156
203
  instance.credentialBackoff = new Map();
157
- instance.codexUsageCache = new Map();
158
- instance.lastCacheCleanup = 0;
204
+ instance.usageProviderResolver = options.usageProviderResolver ?? resolveDefaultUsageProvider;
205
+ instance.usageCache = options.usageCache ?? new AuthStorageUsageCache(instance.storage);
206
+ instance.usageFetch = options.usageFetch ?? fetch;
207
+ instance.usageNow = options.usageNow ?? Date.now;
208
+ instance.usageLogger =
209
+ options.usageLogger ??
210
+ ({
211
+ debug: (message, meta) => logger.debug(message, meta),
212
+ warn: (message, meta) => logger.warn(message, meta),
213
+ } satisfies UsageLogger);
159
214
 
160
215
  for (const [provider, creds] of Object.entries(data.credentials)) {
161
216
  instance.data.set(
@@ -257,7 +312,15 @@ export class AuthStorage {
257
312
  list.push({ id: record.id, credential: record.credential });
258
313
  grouped.set(record.provider, list);
259
314
  }
260
- this.data = grouped;
315
+
316
+ const dedupedGrouped = new Map<string, StoredCredential[]>();
317
+ for (const [provider, entries] of grouped.entries()) {
318
+ const deduped = this.pruneDuplicateStoredCredentials(provider, entries);
319
+ if (deduped.length > 0) {
320
+ dedupedGrouped.set(provider, deduped);
321
+ }
322
+ }
323
+ this.data = dedupedGrouped;
261
324
  }
262
325
 
263
326
  /**
@@ -283,6 +346,115 @@ export class AuthStorage {
283
346
  }
284
347
  }
285
348
 
349
+ private getOAuthIdentifiers(credential: OAuthCredential): string[] {
350
+ const identifiers: string[] = [];
351
+ const accountId = credential.accountId?.trim();
352
+ if (accountId) identifiers.push(`account:${accountId}`);
353
+ const email = credential.email?.trim().toLowerCase();
354
+ if (email) identifiers.push(`email:${email}`);
355
+ if (identifiers.length > 0) return identifiers;
356
+ const tokenIdentifiers = this.getOAuthIdentifiersFromToken(credential.access) ?? [];
357
+ for (const identifier of tokenIdentifiers) {
358
+ identifiers.push(identifier);
359
+ }
360
+ if (identifiers.length > 0) return identifiers;
361
+ const refreshIdentifiers = this.getOAuthIdentifiersFromToken(credential.refresh) ?? [];
362
+ for (const identifier of refreshIdentifiers) {
363
+ identifiers.push(identifier);
364
+ }
365
+ return identifiers;
366
+ }
367
+
368
+ private getOAuthIdentifiersFromToken(token: string | undefined): string[] | undefined {
369
+ if (!token) return undefined;
370
+ const parts = token.split(".");
371
+ if (parts.length !== 3) return undefined;
372
+ const payloadRaw = parts[1];
373
+ try {
374
+ const payload = JSON.parse(
375
+ Buffer.from(payloadRaw.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"),
376
+ ) as Record<string, unknown>;
377
+ if (!payload || typeof payload !== "object") return undefined;
378
+ const identifiers: string[] = [];
379
+ const email = typeof payload.email === "string" ? payload.email.trim().toLowerCase() : undefined;
380
+ if (email) identifiers.push(`email:${email}`);
381
+ const accountId =
382
+ typeof payload.account_id === "string"
383
+ ? payload.account_id
384
+ : typeof payload.accountId === "string"
385
+ ? payload.accountId
386
+ : typeof payload.user_id === "string"
387
+ ? payload.user_id
388
+ : typeof payload.sub === "string"
389
+ ? payload.sub
390
+ : undefined;
391
+ const trimmedAccountId = accountId?.trim();
392
+ if (trimmedAccountId) identifiers.push(`account:${trimmedAccountId}`);
393
+ return identifiers.length > 0 ? identifiers : undefined;
394
+ } catch {
395
+ return undefined;
396
+ }
397
+ }
398
+
399
+ private dedupeOAuthCredentials(credentials: AuthCredential[]): AuthCredential[] {
400
+ const seen = new Set<string>();
401
+ const deduped: AuthCredential[] = [];
402
+ for (let index = credentials.length - 1; index >= 0; index -= 1) {
403
+ const credential = credentials[index];
404
+ if (credential.type !== "oauth") {
405
+ deduped.push(credential);
406
+ continue;
407
+ }
408
+ const identifiers = this.getOAuthIdentifiers(credential);
409
+ if (identifiers.length === 0) {
410
+ deduped.push(credential);
411
+ continue;
412
+ }
413
+ if (identifiers.some((identifier) => seen.has(identifier))) {
414
+ continue;
415
+ }
416
+ for (const identifier of identifiers) {
417
+ seen.add(identifier);
418
+ }
419
+ deduped.push(credential);
420
+ }
421
+ return deduped.reverse();
422
+ }
423
+
424
+ private pruneDuplicateStoredCredentials(provider: string, entries: StoredCredential[]): StoredCredential[] {
425
+ const seen = new Set<string>();
426
+ const kept: StoredCredential[] = [];
427
+ const removed: StoredCredential[] = [];
428
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
429
+ const entry = entries[index];
430
+ const credential = entry.credential;
431
+ if (credential.type !== "oauth") {
432
+ kept.push(entry);
433
+ continue;
434
+ }
435
+ const identifiers = this.getOAuthIdentifiers(credential);
436
+ if (identifiers.length === 0) {
437
+ kept.push(entry);
438
+ continue;
439
+ }
440
+ if (identifiers.some((identifier) => seen.has(identifier))) {
441
+ removed.push(entry);
442
+ continue;
443
+ }
444
+ for (const identifier of identifiers) {
445
+ seen.add(identifier);
446
+ }
447
+ kept.push(entry);
448
+ }
449
+ if (removed.length > 0) {
450
+ for (const entry of removed) {
451
+ this.storage.deleteAuthCredential(entry.id);
452
+ }
453
+ this.resetProviderAssignments(provider);
454
+ }
455
+ return kept.reverse();
456
+ }
457
+
286
458
  /** Returns all credentials for a provider as an array */
287
459
  private getCredentialsForProvider(provider: string): AuthCredential[] {
288
460
  return this.getStoredCredentials(provider).map((entry) => entry.credential);
@@ -469,7 +641,8 @@ export class AuthStorage {
469
641
  */
470
642
  async set(provider: string, credential: AuthCredentialEntry): Promise<void> {
471
643
  const normalized = Array.isArray(credential) ? credential : [credential];
472
- const stored = this.storage.replaceAuthCredentialsForProvider(provider, normalized);
644
+ const deduped = this.dedupeOAuthCredentials(normalized);
645
+ const stored = this.storage.replaceAuthCredentialsForProvider(provider, deduped);
473
646
  this.setStoredCredentials(
474
647
  provider,
475
648
  stored.map((record) => ({ id: record.id, credential: record.credential })),
@@ -611,202 +784,335 @@ export class AuthStorage {
611
784
  }
612
785
 
613
786
  // ─────────────────────────────────────────────────────────────────────────────
614
- // Codex Usage API Integration
615
- // Queries ChatGPT/Codex usage endpoints to detect rate limits before they occur.
787
+ // Usage API Integration
788
+ // Queries provider usage endpoints to detect rate limits before they occur.
616
789
  // ─────────────────────────────────────────────────────────────────────────────
617
790
 
618
- /** Normalizes Codex base URL to include /backend-api path. */
619
- private normalizeCodexBaseUrl(baseUrl?: string): string {
620
- const fallback = "https://chatgpt.com/backend-api";
621
- const trimmed = baseUrl?.trim() ? baseUrl.trim() : fallback;
622
- const base = trimmed.replace(/\/+$/, "");
623
- const lower = base.toLowerCase();
624
- if (
625
- (lower.startsWith("https://chatgpt.com") || lower.startsWith("https://chat.openai.com")) &&
626
- !lower.includes("/backend-api")
627
- ) {
628
- return `${base}/backend-api`;
629
- }
630
- return base;
791
+ private buildUsageCredential(credential: OAuthCredential): UsageCredential {
792
+ return {
793
+ type: "oauth",
794
+ accessToken: credential.access,
795
+ refreshToken: credential.refresh,
796
+ expiresAt: credential.expires,
797
+ accountId: credential.accountId,
798
+ projectId: credential.projectId,
799
+ email: credential.email,
800
+ enterpriseUrl: credential.enterpriseUrl,
801
+ };
631
802
  }
632
803
 
633
- private getCodexUsagePath(baseUrl: string): string {
634
- return baseUrl.includes("/backend-api") ? "wham/usage" : "api/codex/usage";
804
+ private getUsageReportMetadataValue(report: UsageReport, key: string): string | undefined {
805
+ const metadata = report.metadata;
806
+ if (!metadata || typeof metadata !== "object") return undefined;
807
+ const value = metadata[key];
808
+ return typeof value === "string" ? value.trim() : undefined;
635
809
  }
636
810
 
637
- private buildCodexUsageUrl(baseUrl: string, path: string): string {
638
- const normalized = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
639
- return `${normalized}${path.replace(/^\/+/, "")}`;
811
+ private getUsageReportScopeAccountId(report: UsageReport): string | undefined {
812
+ const ids = new Set<string>();
813
+ for (const limit of report.limits) {
814
+ const accountId = limit.scope.accountId?.trim();
815
+ if (accountId) ids.add(accountId);
816
+ }
817
+ if (ids.size === 1) return [...ids][0];
818
+ return undefined;
640
819
  }
641
820
 
642
- private getCodexUsageCacheKey(accountId: string, baseUrl: string): string {
643
- return `${baseUrl}|${accountId}`;
821
+ private getUsageReportIdentifiers(report: UsageReport): string[] {
822
+ const identifiers: string[] = [];
823
+ const email = this.getUsageReportMetadataValue(report, "email");
824
+ if (email) identifiers.push(`email:${email.toLowerCase()}`);
825
+ const accountId = this.getUsageReportMetadataValue(report, "accountId");
826
+ if (accountId) identifiers.push(`account:${accountId}`);
827
+ const account = this.getUsageReportMetadataValue(report, "account");
828
+ if (account) identifiers.push(`account:${account}`);
829
+ const user = this.getUsageReportMetadataValue(report, "user");
830
+ if (user) identifiers.push(`account:${user}`);
831
+ const username = this.getUsageReportMetadataValue(report, "username");
832
+ if (username) identifiers.push(`account:${username}`);
833
+ const scopeAccountId = this.getUsageReportScopeAccountId(report);
834
+ if (scopeAccountId) identifiers.push(`account:${scopeAccountId}`);
835
+ return identifiers.map((identifier) => `${report.provider}:${identifier.toLowerCase()}`);
644
836
  }
645
837
 
646
- private extractCodexUsageWindow(window: unknown): CodexUsageWindow | undefined {
647
- if (!isRecord(window)) return undefined;
648
- const usedPercent = toNumber(window.used_percent);
649
- const limitWindowSeconds = toNumber(window.limit_window_seconds);
650
- const resetAt = toNumber(window.reset_at);
651
- if (usedPercent === undefined && limitWindowSeconds === undefined && resetAt === undefined) return undefined;
652
- return { usedPercent, limitWindowSeconds, resetAt };
653
- }
838
+ private mergeUsageReportGroup(reports: UsageReport[]): UsageReport {
839
+ if (reports.length === 1) return reports[0];
840
+ const sorted = [...reports].sort((a, b) => {
841
+ const limitDiff = b.limits.length - a.limits.length;
842
+ if (limitDiff !== 0) return limitDiff;
843
+ return (b.fetchedAt ?? 0) - (a.fetchedAt ?? 0);
844
+ });
845
+ const base = sorted[0];
846
+ const mergedLimits = [...base.limits];
847
+ const limitIds = new Set(mergedLimits.map((limit) => limit.id));
848
+ const mergedMetadata: Record<string, unknown> = { ...(base.metadata ?? {}) };
849
+ let fetchedAt = base.fetchedAt;
850
+
851
+ for (const report of sorted.slice(1)) {
852
+ fetchedAt = Math.max(fetchedAt, report.fetchedAt);
853
+ for (const limit of report.limits) {
854
+ if (!limitIds.has(limit.id)) {
855
+ limitIds.add(limit.id);
856
+ mergedLimits.push(limit);
857
+ }
858
+ }
859
+ if (report.metadata) {
860
+ for (const [key, value] of Object.entries(report.metadata)) {
861
+ if (mergedMetadata[key] === undefined) {
862
+ mergedMetadata[key] = value;
863
+ }
864
+ }
865
+ }
866
+ }
654
867
 
655
- private extractCodexUsage(payload: unknown): CodexUsage | undefined {
656
- if (!isRecord(payload)) return undefined;
657
- const rateLimit = isRecord(payload.rate_limit) ? payload.rate_limit : undefined;
658
- if (!rateLimit) return undefined;
659
- const primary = this.extractCodexUsageWindow(rateLimit.primary_window);
660
- const secondary = this.extractCodexUsageWindow(rateLimit.secondary_window);
661
- const usage: CodexUsage = {
662
- allowed: toBoolean(rateLimit.allowed),
663
- limitReached: toBoolean(rateLimit.limit_reached),
664
- primary,
665
- secondary,
868
+ return {
869
+ ...base,
870
+ fetchedAt,
871
+ limits: mergedLimits,
872
+ metadata: Object.keys(mergedMetadata).length > 0 ? mergedMetadata : undefined,
666
873
  };
667
- if (!primary && !secondary && usage.allowed === undefined && usage.limitReached === undefined) return undefined;
668
- return usage;
669
874
  }
670
875
 
671
- /** Returns true if usage indicates rate limit has been reached. */
672
- private isCodexUsageLimitReached(usage: CodexUsage): boolean {
673
- if (usage.allowed === false || usage.limitReached === true) return true;
674
- if (usage.primary?.usedPercent !== undefined && usage.primary.usedPercent >= 100) return true;
675
- if (usage.secondary?.usedPercent !== undefined && usage.secondary.usedPercent >= 100) return true;
876
+ private dedupeUsageReports(reports: UsageReport[]): UsageReport[] {
877
+ const groups: UsageReport[][] = [];
878
+ const idToGroup = new Map<string, number>();
879
+
880
+ for (const report of reports) {
881
+ const identifiers = this.getUsageReportIdentifiers(report);
882
+ let groupIndex: number | undefined;
883
+ for (const identifier of identifiers) {
884
+ const existing = idToGroup.get(identifier);
885
+ if (existing !== undefined) {
886
+ groupIndex = existing;
887
+ break;
888
+ }
889
+ }
890
+ if (groupIndex === undefined) {
891
+ groupIndex = groups.length;
892
+ groups.push([]);
893
+ }
894
+ groups[groupIndex].push(report);
895
+ for (const identifier of identifiers) {
896
+ idToGroup.set(identifier, groupIndex);
897
+ }
898
+ }
899
+
900
+ const deduped = groups.map((group) => this.mergeUsageReportGroup(group));
901
+ if (deduped.length !== reports.length) {
902
+ this.usageLogger?.debug("Usage reports deduped", {
903
+ before: reports.length,
904
+ after: deduped.length,
905
+ });
906
+ }
907
+ return deduped;
908
+ }
909
+
910
+ private isUsageLimitExhausted(limit: UsageLimit): boolean {
911
+ if (limit.status === "exhausted") return true;
912
+ const amount = limit.amount;
913
+ if (amount.usedFraction !== undefined && amount.usedFraction >= 1) return true;
914
+ if (amount.remainingFraction !== undefined && amount.remainingFraction <= 0) return true;
915
+ if (amount.used !== undefined && amount.limit !== undefined && amount.used >= amount.limit) return true;
916
+ if (amount.remaining !== undefined && amount.remaining <= 0) return true;
917
+ if (amount.unit === "percent" && amount.used !== undefined && amount.used >= 100) return true;
676
918
  return false;
677
919
  }
678
920
 
679
- /** Extracts the earliest reset timestamp from usage windows (in ms). */
680
- private getCodexResetAtMs(usage: CodexUsage): number | undefined {
681
- const now = Date.now();
921
+ /** Returns true if usage indicates rate limit has been reached. */
922
+ private isUsageLimitReached(report: UsageReport): boolean {
923
+ return report.limits.some((limit) => this.isUsageLimitExhausted(limit));
924
+ }
925
+
926
+ /** Extracts the earliest reset timestamp from exhausted windows (in ms). */
927
+ private getUsageResetAtMs(report: UsageReport, nowMs: number): number | undefined {
682
928
  const candidates: number[] = [];
683
- const addCandidate = (value: number | undefined) => {
684
- if (!value) return;
685
- const ms = value > 1_000_000_000_000 ? value : value * 1000;
686
- if (Number.isFinite(ms) && ms > now) {
687
- candidates.push(ms);
688
- }
689
- };
690
- const useAll = usage.limitReached === true || usage.allowed === false;
691
- if (useAll) {
692
- addCandidate(usage.primary?.resetAt);
693
- addCandidate(usage.secondary?.resetAt);
694
- } else {
695
- if (usage.primary?.usedPercent !== undefined && usage.primary.usedPercent >= 100) {
696
- addCandidate(usage.primary.resetAt);
929
+ for (const limit of report.limits) {
930
+ if (!this.isUsageLimitExhausted(limit)) continue;
931
+ const window = limit.window;
932
+ if (window?.resetsAt && window.resetsAt > nowMs) {
933
+ candidates.push(window.resetsAt);
697
934
  }
698
- if (usage.secondary?.usedPercent !== undefined && usage.secondary.usedPercent >= 100) {
699
- addCandidate(usage.secondary.resetAt);
935
+ if (window?.resetInMs && window.resetInMs > 0) {
936
+ const resetAt = nowMs + window.resetInMs;
937
+ if (resetAt > nowMs) candidates.push(resetAt);
700
938
  }
701
939
  }
702
940
  if (candidates.length === 0) return undefined;
703
941
  return Math.min(...candidates);
704
942
  }
705
943
 
706
- private getCodexUsageExpiryMs(usage: CodexUsage, nowMs: number): number {
707
- const resetAtMs = this.getCodexResetAtMs(usage);
708
- if (this.isCodexUsageLimitReached(usage)) {
709
- if (resetAtMs) return resetAtMs;
710
- return nowMs + AuthStorage.defaultBackoffMs;
711
- }
712
- const defaultExpiry = nowMs + AuthStorage.codexUsageCacheTtlMs;
713
- if (!resetAtMs) return defaultExpiry;
714
- return Math.min(defaultExpiry, resetAtMs);
715
- }
944
+ private async getUsageReport(
945
+ provider: Provider,
946
+ credential: OAuthCredential,
947
+ options?: { baseUrl?: string },
948
+ ): Promise<UsageReport | null> {
949
+ const resolver = this.usageProviderResolver;
950
+ const cache = this.usageCache;
951
+ if (!resolver || !cache) return null;
716
952
 
717
- /** Fetches usage data from Codex API. */
718
- private async fetchCodexUsage(credential: OAuthCredential, baseUrl?: string): Promise<CodexUsage | undefined> {
719
- const accountId = credential.accountId;
720
- if (!accountId) return undefined;
953
+ const providerImpl = resolver(provider);
954
+ if (!providerImpl) return null;
721
955
 
722
- const normalizedBase = this.normalizeCodexBaseUrl(baseUrl);
723
- const url = this.buildCodexUsageUrl(normalizedBase, this.getCodexUsagePath(normalizedBase));
724
- const headers = {
725
- authorization: `Bearer ${credential.access}`,
726
- "chatgpt-account-id": accountId,
727
- "openai-beta": "responses=experimental",
728
- originator: "codex_cli_rs",
956
+ const params = {
957
+ provider,
958
+ credential: this.buildUsageCredential(credential),
959
+ baseUrl: options?.baseUrl,
729
960
  };
730
961
 
731
- try {
732
- const response = await fetch(url, { headers });
733
- if (!response.ok) {
734
- logger.debug("AuthStorage codex usage fetch failed", {
735
- status: response.status,
736
- statusText: response.statusText,
737
- });
738
- return undefined;
739
- }
962
+ if (providerImpl.supports && !providerImpl.supports(params)) return null;
740
963
 
741
- const payload = (await response.json()) as unknown;
742
- return this.extractCodexUsage(payload);
964
+ try {
965
+ return await providerImpl.fetchUsage(params, {
966
+ cache,
967
+ fetch: this.usageFetch,
968
+ now: this.usageNow,
969
+ logger: this.usageLogger,
970
+ });
743
971
  } catch (error) {
744
- logger.debug("AuthStorage codex usage fetch error", { error: String(error) });
745
- return undefined;
972
+ logger.debug("AuthStorage usage fetch failed", {
973
+ provider,
974
+ error: String(error),
975
+ });
976
+ return null;
746
977
  }
747
978
  }
748
979
 
749
- /** Gets usage data with caching to avoid redundant API calls. */
750
- private async getCodexUsage(credential: OAuthCredential, baseUrl?: string): Promise<CodexUsage | undefined> {
751
- const accountId = credential.accountId;
752
- if (!accountId) return undefined;
753
-
754
- const normalizedBase = this.normalizeCodexBaseUrl(baseUrl);
755
- const cacheKey = this.getCodexUsageCacheKey(accountId, normalizedBase);
756
- const now = Date.now();
980
+ async fetchUsageReports(options?: {
981
+ baseUrlResolver?: (provider: Provider) => string | undefined;
982
+ }): Promise<UsageReport[] | null> {
983
+ const resolver = this.usageProviderResolver;
984
+ const cache = this.usageCache;
985
+ if (!resolver || !cache) return null;
986
+
987
+ const tasks: Array<Promise<UsageReport | null>> = [];
988
+ const providers = new Set<string>([
989
+ ...this.data.keys(),
990
+ ...DEFAULT_USAGE_PROVIDERS.map((provider) => provider.id),
991
+ ]);
992
+ this.usageLogger?.debug("Usage fetch requested", {
993
+ providers: Array.from(providers).sort(),
994
+ });
995
+ for (const provider of providers) {
996
+ const providerImpl = resolver(provider as Provider);
997
+ if (!providerImpl) continue;
998
+ const baseUrl = options?.baseUrlResolver?.(provider as Provider);
999
+ let entries = this.getStoredCredentials(provider);
1000
+ if (entries.length > 0) {
1001
+ const dedupedEntries = this.pruneDuplicateStoredCredentials(provider, entries);
1002
+ if (dedupedEntries.length !== entries.length) {
1003
+ this.setStoredCredentials(provider, dedupedEntries);
1004
+ }
1005
+ entries = dedupedEntries;
1006
+ }
757
1007
 
758
- if (now - this.lastCacheCleanup > AuthStorage.cacheCleanupIntervalMs) {
759
- this.lastCacheCleanup = now;
760
- this.storage.cleanExpiredCache();
761
- }
1008
+ if (entries.length === 0) {
1009
+ const runtimeKey = this.runtimeOverrides.get(provider);
1010
+ const envKey = getEnvApiKey(provider);
1011
+ const apiKey = runtimeKey ?? envKey;
1012
+ if (!apiKey) {
1013
+ continue;
1014
+ }
1015
+ const params = {
1016
+ provider: provider as Provider,
1017
+ credential: { type: "api_key", apiKey } satisfies UsageCredential,
1018
+ baseUrl,
1019
+ };
1020
+ if (providerImpl.supports && !providerImpl.supports(params)) {
1021
+ continue;
1022
+ }
1023
+ this.usageLogger?.debug("Usage fetch queued", {
1024
+ provider,
1025
+ credentialType: "api_key",
1026
+ baseUrl,
1027
+ });
1028
+ tasks.push(
1029
+ providerImpl
1030
+ .fetchUsage(params, {
1031
+ cache,
1032
+ fetch: this.usageFetch,
1033
+ now: this.usageNow,
1034
+ logger: this.usageLogger,
1035
+ })
1036
+ .catch((error) => {
1037
+ logger.debug("AuthStorage usage fetch failed", {
1038
+ provider,
1039
+ error: String(error),
1040
+ });
1041
+ return null;
1042
+ }),
1043
+ );
1044
+ continue;
1045
+ }
762
1046
 
763
- // Check in-memory cache first (fastest)
764
- const memCached = this.codexUsageCache.get(cacheKey);
765
- if (memCached && memCached.expiresAt > now) {
766
- return memCached.usage;
767
- }
1047
+ for (const entry of entries) {
1048
+ const credential = entry.credential;
1049
+ const usageCredential: UsageCredential =
1050
+ credential.type === "api_key"
1051
+ ? { type: "api_key", apiKey: credential.key }
1052
+ : this.buildUsageCredential(credential);
1053
+ const params = {
1054
+ provider: provider as Provider,
1055
+ credential: usageCredential,
1056
+ baseUrl,
1057
+ };
1058
+
1059
+ if (providerImpl.supports && !providerImpl.supports(params)) {
1060
+ continue;
1061
+ }
768
1062
 
769
- // Check DB cache (survives restarts)
770
- const dbCached = this.storage.getCache(`codex_usage:${cacheKey}`);
771
- if (dbCached) {
772
- try {
773
- const parsed = JSON.parse(dbCached) as CodexUsage;
774
- // Store in memory for faster subsequent access
775
- this.codexUsageCache.set(cacheKey, {
776
- fetchedAt: now,
777
- expiresAt: now + AuthStorage.codexUsageCacheTtlMs,
778
- usage: parsed,
1063
+ this.usageLogger?.debug("Usage fetch queued", {
1064
+ provider,
1065
+ credentialType: usageCredential.type,
1066
+ baseUrl,
1067
+ accountId: usageCredential.accountId,
1068
+ email: usageCredential.email,
779
1069
  });
780
- return parsed;
781
- } catch {
782
- // Invalid cache, continue to fetch
783
- }
784
- }
785
1070
 
786
- // Fetch from API
787
- const usage = await this.fetchCodexUsage(credential, normalizedBase);
788
- if (usage) {
789
- const expiresAt = this.getCodexUsageExpiryMs(usage, now);
790
- this.codexUsageCache.set(cacheKey, { fetchedAt: now, expiresAt, usage });
791
- // Store in DB with 60s TTL
792
- this.storage.setCache(
793
- `codex_usage:${cacheKey}`,
794
- JSON.stringify(usage),
795
- Math.floor((now + AuthStorage.codexUsageCacheTtlMs) / 1000),
796
- );
797
- return usage;
1071
+ tasks.push(
1072
+ providerImpl
1073
+ .fetchUsage(params, {
1074
+ cache,
1075
+ fetch: this.usageFetch,
1076
+ now: this.usageNow,
1077
+ logger: this.usageLogger,
1078
+ })
1079
+ .catch((error) => {
1080
+ logger.debug("AuthStorage usage fetch failed", {
1081
+ provider,
1082
+ error: String(error),
1083
+ });
1084
+ return null;
1085
+ }),
1086
+ );
1087
+ }
798
1088
  }
799
1089
 
800
- this.codexUsageCache.set(cacheKey, {
801
- fetchedAt: now,
802
- expiresAt: now + AuthStorage.defaultBackoffMs,
1090
+ if (tasks.length === 0) return [];
1091
+ const results = await Promise.all(tasks);
1092
+ const reports = results.filter((report): report is UsageReport => report !== null);
1093
+ const deduped = this.dedupeUsageReports(reports);
1094
+ this.usageLogger?.debug("Usage fetch resolved", {
1095
+ reports: deduped.map((report) => {
1096
+ const accountLabel =
1097
+ this.getUsageReportMetadataValue(report, "email") ??
1098
+ this.getUsageReportMetadataValue(report, "accountId") ??
1099
+ this.getUsageReportMetadataValue(report, "account") ??
1100
+ this.getUsageReportMetadataValue(report, "user") ??
1101
+ this.getUsageReportMetadataValue(report, "username") ??
1102
+ this.getUsageReportScopeAccountId(report);
1103
+ return {
1104
+ provider: report.provider,
1105
+ limits: report.limits.length,
1106
+ account: accountLabel,
1107
+ };
1108
+ }),
803
1109
  });
804
- return undefined;
1110
+ return deduped;
805
1111
  }
806
1112
 
807
1113
  /**
808
1114
  * Marks the current session's credential as temporarily blocked due to usage limits.
809
- * Queries the Codex usage API to determine accurate reset time.
1115
+ * Uses usage reports to determine accurate reset time when available.
810
1116
  * Returns true if a credential was blocked, enabling automatic fallback to the next credential.
811
1117
  */
812
1118
  async markUsageLimitReached(
@@ -818,15 +1124,15 @@ export class AuthStorage {
818
1124
  if (!sessionCredential) return false;
819
1125
 
820
1126
  const providerKey = this.getProviderTypeKey(provider, sessionCredential.type);
821
- const now = Date.now();
1127
+ const now = this.usageNow();
822
1128
  let blockedUntil = now + (options?.retryAfterMs ?? AuthStorage.defaultBackoffMs);
823
1129
 
824
1130
  if (provider === "openai-codex" && sessionCredential.type === "oauth") {
825
1131
  const credential = this.getCredentialsForProvider(provider)[sessionCredential.index];
826
1132
  if (credential?.type === "oauth") {
827
- const usage = await this.getCodexUsage(credential, options?.baseUrl);
828
- if (usage) {
829
- const resetAtMs = this.getCodexResetAtMs(usage);
1133
+ const report = await this.getUsageReport(provider, credential, options);
1134
+ if (report && this.isUsageLimitReached(report)) {
1135
+ const resetAtMs = this.getUsageResetAtMs(report, this.usageNow());
830
1136
  if (resetAtMs && resetAtMs > blockedUntil) {
831
1137
  blockedUntil = resetAtMs;
832
1138
  }
@@ -848,7 +1154,7 @@ export class AuthStorage {
848
1154
 
849
1155
  /**
850
1156
  * Resolves an OAuth API key, trying credentials in priority order.
851
- * Skips blocked credentials and checks usage limits for Codex accounts.
1157
+ * Skips blocked credentials and checks usage limits for providers with usage data.
852
1158
  * Falls back to earliest-unblocking credential if all are blocked.
853
1159
  */
854
1160
  private async resolveOAuthApiKey(
@@ -902,14 +1208,18 @@ export class AuthStorage {
902
1208
  return undefined;
903
1209
  }
904
1210
 
1211
+ let usage: UsageReport | null = null;
1212
+ let usageChecked = false;
1213
+
905
1214
  if (checkUsage) {
906
- const usage = await this.getCodexUsage(selection.credential, options?.baseUrl);
907
- if (usage && this.isCodexUsageLimitReached(usage)) {
908
- const resetAtMs = this.getCodexResetAtMs(usage);
1215
+ usage = await this.getUsageReport(provider, selection.credential, options);
1216
+ usageChecked = true;
1217
+ if (usage && this.isUsageLimitReached(usage)) {
1218
+ const resetAtMs = this.getUsageResetAtMs(usage, this.usageNow());
909
1219
  this.markCredentialBlocked(
910
1220
  providerKey,
911
1221
  selection.index,
912
- resetAtMs ?? Date.now() + AuthStorage.defaultBackoffMs,
1222
+ resetAtMs ?? this.usageNow() + AuthStorage.defaultBackoffMs,
913
1223
  );
914
1224
  return undefined;
915
1225
  }
@@ -923,17 +1233,29 @@ export class AuthStorage {
923
1233
  const result = await getOAuthApiKey(provider as OAuthProvider, oauthCreds);
924
1234
  if (!result) return undefined;
925
1235
 
926
- const updated: OAuthCredential = { type: "oauth", ...result.newCredentials };
1236
+ const updated: OAuthCredential = {
1237
+ type: "oauth",
1238
+ access: result.newCredentials.access,
1239
+ refresh: result.newCredentials.refresh,
1240
+ expires: result.newCredentials.expires,
1241
+ accountId: result.newCredentials.accountId ?? selection.credential.accountId,
1242
+ email: result.newCredentials.email ?? selection.credential.email,
1243
+ projectId: result.newCredentials.projectId ?? selection.credential.projectId,
1244
+ enterpriseUrl: result.newCredentials.enterpriseUrl ?? selection.credential.enterpriseUrl,
1245
+ };
927
1246
  this.replaceCredentialAt(provider, selection.index, updated);
928
1247
 
929
1248
  if (checkUsage) {
930
- const usage = await this.getCodexUsage(updated, options?.baseUrl);
931
- if (usage && this.isCodexUsageLimitReached(usage)) {
932
- const resetAtMs = this.getCodexResetAtMs(usage);
1249
+ const sameAccount = selection.credential.accountId === updated.accountId;
1250
+ if (!usageChecked || !sameAccount) {
1251
+ usage = await this.getUsageReport(provider, updated, options);
1252
+ }
1253
+ if (usage && this.isUsageLimitReached(usage)) {
1254
+ const resetAtMs = this.getUsageResetAtMs(usage, this.usageNow());
933
1255
  this.markCredentialBlocked(
934
1256
  providerKey,
935
1257
  selection.index,
936
- resetAtMs ?? Date.now() + AuthStorage.defaultBackoffMs,
1258
+ resetAtMs ?? this.usageNow() + AuthStorage.defaultBackoffMs,
937
1259
  );
938
1260
  return undefined;
939
1261
  }