@gajae-code/ai 0.16.7 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/dist/types/auth-storage.d.ts +23 -4
  3. package/dist/types/models.d.ts +14 -0
  4. package/dist/types/provider-models/special.d.ts +12 -0
  5. package/dist/types/providers/anthropic.d.ts +1 -1
  6. package/dist/types/providers/cursor.d.ts +33 -21
  7. package/dist/types/providers/devin-acp.d.ts +157 -0
  8. package/dist/types/providers/google-gemini-headers.d.ts +1 -1
  9. package/dist/types/providers/mock.d.ts +2 -0
  10. package/dist/types/providers/openai-responses-shared.d.ts +21 -1
  11. package/dist/types/providers/register-builtins.d.ts +1 -0
  12. package/dist/types/types.d.ts +52 -11
  13. package/dist/types/utils/block-symbols.d.ts +15 -5
  14. package/dist/types/utils/fallback-transport.d.ts +4 -1
  15. package/dist/types/utils.d.ts +13 -0
  16. package/package.json +4 -3
  17. package/src/api-registry.ts +1 -0
  18. package/src/auth-broker/redact.ts +10 -2
  19. package/src/auth-gateway/server.ts +56 -3
  20. package/src/auth-storage.ts +330 -116
  21. package/src/model-manager.ts +21 -2
  22. package/src/models.d.ts +14 -0
  23. package/src/models.json +117 -0
  24. package/src/models.ts +18 -0
  25. package/src/provider-models/descriptors.ts +7 -0
  26. package/src/provider-models/openai-compat.ts +14 -0
  27. package/src/provider-models/special.ts +39 -0
  28. package/src/providers/anthropic.d.ts +1 -1
  29. package/src/providers/anthropic.ts +1 -1
  30. package/src/providers/azure-openai-responses.ts +10 -1
  31. package/src/providers/cursor.d.ts +33 -21
  32. package/src/providers/cursor.ts +2024 -508
  33. package/src/providers/devin-acp.d.ts +157 -0
  34. package/src/providers/devin-acp.ts +1103 -0
  35. package/src/providers/google-gemini-headers.d.ts +1 -1
  36. package/src/providers/google-gemini-headers.ts +1 -1
  37. package/src/providers/mock.ts +16 -1
  38. package/src/providers/openai-chat-server.ts +3 -3
  39. package/src/providers/openai-codex-responses.ts +27 -17
  40. package/src/providers/openai-responses-server.ts +5 -5
  41. package/src/providers/openai-responses-shared.d.ts +21 -1
  42. package/src/providers/openai-responses-shared.ts +60 -6
  43. package/src/providers/openai-responses.ts +10 -1
  44. package/src/providers/register-builtins.d.ts +1 -0
  45. package/src/providers/register-builtins.ts +21 -1
  46. package/src/stream.ts +14 -0
  47. package/src/types.d.ts +52 -11
  48. package/src/types.ts +70 -8
  49. package/src/utils/block-symbols.d.ts +15 -5
  50. package/src/utils/block-symbols.ts +16 -6
  51. package/src/utils/discovery/cursor.ts +3 -2
  52. package/src/utils/fallback-transport.d.ts +4 -1
  53. package/src/utils/fallback-transport.ts +12 -5
  54. package/src/utils.d.ts +13 -0
  55. package/src/utils.ts +17 -0
  56. package/dist/types/utils/codex-entitlement.d.ts +0 -22
  57. package/src/utils/codex-entitlement.d.ts +0 -22
  58. package/src/utils/codex-entitlement.ts +0 -57
@@ -26,12 +26,6 @@ import type {
26
26
  UsageReport,
27
27
  } from "./usage";
28
28
 
29
- import {
30
- classifyOpenAICodexProEntitlement,
31
- formatOpenAICodexChatGPTEntitlementError,
32
- requiresOpenAICodexProModel,
33
- requiresStrictOpenAICodexProModel,
34
- } from "./utils/codex-entitlement";
35
29
  import { getOAuthApiKey, getOAuthProvider, refreshOAuthToken, resolveOAuthStorageProvider } from "./utils/oauth";
36
30
  import { loginDeepInfra } from "./utils/oauth/deepinfra";
37
31
  import { loginDeepSeek } from "./utils/oauth/deepseek";
@@ -1146,28 +1140,6 @@ export function readBrokerErrorBody(error: unknown): string | undefined {
1146
1140
  }
1147
1141
  }
1148
1142
 
1149
- function getUsagePlanType(report: UsageReport | null): string | undefined {
1150
- const metadata = report?.metadata;
1151
- if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return undefined;
1152
- const planType = (metadata as { planType?: unknown }).planType;
1153
- return typeof planType === "string" ? planType.toLowerCase() : undefined;
1154
- }
1155
-
1156
- function getOpenAICodexPlanPriority(report: UsageReport | null): number {
1157
- const entitlement = classifyOpenAICodexProEntitlement(getUsagePlanType(report));
1158
- if (entitlement === "entitled") return 0;
1159
- if (entitlement === "denied") return 2;
1160
- return 1;
1161
- }
1162
-
1163
- function hasOpenAICodexProPlan(report: UsageReport | null): boolean {
1164
- return classifyOpenAICodexProEntitlement(getUsagePlanType(report)) === "entitled";
1165
- }
1166
-
1167
- function hasKnownOpenAICodexNonProPlan(report: UsageReport | null): boolean {
1168
- return classifyOpenAICodexProEntitlement(getUsagePlanType(report)) === "denied";
1169
- }
1170
-
1171
1143
  function resolveDefaultRankingStrategy(provider: Provider): CredentialRankingStrategy | undefined {
1172
1144
  return DEFAULT_RANKING_STRATEGIES.get(provider);
1173
1145
  }
@@ -1210,6 +1182,28 @@ function raceUsageWithSignal<T>(promise: Promise<T>, signal: AbortSignal | undef
1210
1182
  });
1211
1183
  }
1212
1184
 
1185
+ /**
1186
+ * Distinguish an internal deadline from a caller-owned cancellation.
1187
+ *
1188
+ * `AbortSignal.timeout(...)` (and `AbortSignal.any([...])` when the timeout leg
1189
+ * fires) aborts with a `TimeoutError` reason, whereas a caller's
1190
+ * `AbortController.abort()` surfaces an `AbortError` (or a custom reason). The
1191
+ * probe callers (`#checkCredentialHealth`, `#fetchUsageUncached`) pass a
1192
+ * timeout-derived signal: a refresh cut short by that internal deadline is a
1193
+ * genuine failure — the rotating refresh token may already have been consumed
1194
+ * upstream — and MUST update the replay guard. Only a true caller cancellation
1195
+ * may skip the guard update.
1196
+ */
1197
+ function isTimeoutAbort(signal: AbortSignal): boolean {
1198
+ const reason: unknown = signal.reason;
1199
+ return (
1200
+ typeof reason === "object" &&
1201
+ reason !== null &&
1202
+ "name" in reason &&
1203
+ (reason as { name?: unknown }).name === "TimeoutError"
1204
+ );
1205
+ }
1206
+
1213
1207
  function raceCredentialRefreshWithSignal<T>(
1214
1208
  promise: Promise<T>,
1215
1209
  signal: AbortSignal | undefined,
@@ -1225,6 +1219,17 @@ function raceCredentialRefreshWithSignal<T>(
1225
1219
  });
1226
1220
  }
1227
1221
 
1222
+ function oauthNonTokenFieldsEqual(left: OAuthCredential, right: OAuthCredential): boolean {
1223
+ return (
1224
+ left.accountId === right.accountId &&
1225
+ left.email === right.email &&
1226
+ left.projectId === right.projectId &&
1227
+ left.enterpriseUrl === right.enterpriseUrl &&
1228
+ left.mcpBinding?.resourceOrigin === right.mcpBinding?.resourceOrigin &&
1229
+ left.mcpBinding?.tokenEndpoint === right.mcpBinding?.tokenEndpoint
1230
+ );
1231
+ }
1232
+
1228
1233
  function authCredentialEquals(left: AuthCredential, right: AuthCredential): boolean {
1229
1234
  if (left.type !== right.type) return false;
1230
1235
  if (left.type === "api_key") {
@@ -1235,15 +1240,60 @@ function authCredentialEquals(left: AuthCredential, right: AuthCredential): bool
1235
1240
  left.access === right.access &&
1236
1241
  left.refresh === right.refresh &&
1237
1242
  left.expires === right.expires &&
1238
- left.accountId === right.accountId &&
1239
- left.email === right.email &&
1240
- left.projectId === right.projectId &&
1241
- left.enterpriseUrl === right.enterpriseUrl &&
1242
- left.mcpBinding?.resourceOrigin === right.mcpBinding?.resourceOrigin &&
1243
- left.mcpBinding?.tokenEndpoint === right.mcpBinding?.tokenEndpoint
1243
+ oauthNonTokenFieldsEqual(left, right)
1244
1244
  );
1245
1245
  }
1246
1246
 
1247
+ function parseStructuredOAuthKey(apiKey: string): { token: string; projectId?: string } | undefined {
1248
+ if (!apiKey.startsWith("{")) return undefined;
1249
+ try {
1250
+ const parsed = JSON.parse(apiKey) as { token?: unknown; projectId?: unknown };
1251
+ if (typeof parsed.token !== "string") return undefined;
1252
+ return { token: parsed.token, projectId: typeof parsed.projectId === "string" ? parsed.projectId : undefined };
1253
+ } catch {
1254
+ return undefined;
1255
+ }
1256
+ }
1257
+
1258
+ // Identity keys decode token claims and evidence is computed on hot catalog paths. Key the memo by every
1259
+ // field that can feed the identity so an in-place credential mutation can never reuse a stale result.
1260
+ const OAUTH_IDENTITY_MEMO_LIMIT = 256;
1261
+ const oauthIdentityMemo = new Map<string, string | null>();
1262
+
1263
+ function resolveMemoizedOAuthIdentityKey(provider: string, credential: OAuthCredential): string | null {
1264
+ const memoKey = [
1265
+ provider,
1266
+ credential.accountId ?? "",
1267
+ credential.email ?? "",
1268
+ credential.projectId ?? "",
1269
+ credential.enterpriseUrl ?? "",
1270
+ credential.access,
1271
+ credential.refresh,
1272
+ ].join("\u0000");
1273
+ const cached = oauthIdentityMemo.get(memoKey);
1274
+ if (cached !== undefined) return cached;
1275
+ const identityKey = resolveCredentialIdentityKey(provider, credential);
1276
+ if (oauthIdentityMemo.size >= OAUTH_IDENTITY_MEMO_LIMIT) oauthIdentityMemo.clear();
1277
+ oauthIdentityMemo.set(memoKey, identityKey);
1278
+ return identityKey;
1279
+ }
1280
+
1281
+ function isOAuthTokenRotationOnly(provider: string, left: StoredCredential[], right: StoredCredential[]): boolean {
1282
+ if (left.length !== right.length || left.length === 0) return false;
1283
+ for (let index = 0; index < left.length; index += 1) {
1284
+ const leftEntry = left[index];
1285
+ const rightEntry = right[index];
1286
+ if (!leftEntry || !rightEntry || leftEntry.id !== rightEntry.id) return false;
1287
+ if (authCredentialEquals(leftEntry.credential, rightEntry.credential)) continue;
1288
+ const previous = leftEntry.credential;
1289
+ const next = rightEntry.credential;
1290
+ if (previous.type !== "oauth" || next.type !== "oauth" || !oauthNonTokenFieldsEqual(previous, next)) return false;
1291
+ const identityKey = resolveMemoizedOAuthIdentityKey(provider, previous);
1292
+ if (!identityKey || identityKey !== resolveMemoizedOAuthIdentityKey(provider, next)) return false;
1293
+ }
1294
+ return true;
1295
+ }
1296
+
1247
1297
  function storedCredentialArraysEqual(left: StoredCredential[], right: StoredCredential[]): boolean {
1248
1298
  if (left.length !== right.length) return false;
1249
1299
  for (let index = 0; index < left.length; index += 1) {
@@ -1340,6 +1390,8 @@ export class AuthStorage {
1340
1390
  #sessionCredentialSelectors: Map<string, Map<string, AuthCredentialSelector>> = new Map();
1341
1391
  /** Explicit AUTO masks suppress both scoped and process-global selectors for a scope/provider. */
1342
1392
  #sessionCredentialAutoMasks: Map<string, Set<string>> = new Map();
1393
+ /** Hard-pin failures that must remain unavailable until an explicit user choice. */
1394
+ #sessionCredentialUnavailable: Map<string, Map<string, AuthCredentialSelector>> = new Map();
1343
1395
  /** Reference counts for sessions sharing one credential scope (top-level + subagents). */
1344
1396
  #credentialScopeLeases: Map<string, number> = new Map();
1345
1397
  /** Tracks next credential index per provider:type key for round-robin distribution (non-session use). */
@@ -1381,6 +1433,8 @@ export class AuthStorage {
1381
1433
  #providerGenerations = new Map<string, number>();
1382
1434
  #providerConfigurationGenerations = new Map<string, number>();
1383
1435
  #providerOAuthRefreshGenerations = new Map<string, number>();
1436
+ /** Recent access tokens replaced by same-account rotation, keyed by storage provider and row id. */
1437
+ #rotatedOAuthAccessTokens = new Map<string, string[]>();
1384
1438
  #generationListeners: Set<(generation: number) => void> = new Set();
1385
1439
  #oauthRefreshInFlight: Map<number, Promise<AuthCredentialSnapshotEntry>> = new Map();
1386
1440
  #oauthCredentialRefreshInFlight: Map<number, Promise<RefreshedOAuthCredentials>> = new Map();
@@ -1446,6 +1500,7 @@ export class AuthStorage {
1446
1500
  this.#credentialScopeLeases.clear();
1447
1501
  this.#sessionCredentialSelectors.clear();
1448
1502
  this.#sessionCredentialAutoMasks.clear();
1503
+ this.#sessionCredentialUnavailable.clear();
1449
1504
  this.#sessionLastCredential.clear();
1450
1505
  this.#store.close();
1451
1506
  }
@@ -1520,9 +1575,10 @@ export class AuthStorage {
1520
1575
  .update(`${this.#getProviderGeneration(storageProvider)}\u0000unavailable-selector`)
1521
1576
  .digest("hex");
1522
1577
  }
1523
- const credentials = selectedCredential
1524
- ? [selectedCredential.credential]
1525
- : this.#getCredentialsForProvider(provider);
1578
+ const storedEntries: StoredCredential[] = selectedCredential
1579
+ ? [selectedCredential]
1580
+ : this.#getStoredCredentials(provider);
1581
+ const credentials = storedEntries.map(entry => entry.credential);
1526
1582
  const hasApiKey = credentials.some(credential => credential.type === "api_key");
1527
1583
  const hasUsableOAuth = credentials.some(
1528
1584
  credential =>
@@ -1545,14 +1601,60 @@ export class AuthStorage {
1545
1601
  }`;
1546
1602
  })
1547
1603
  .join("\u0001");
1548
- const storedOAuthFingerprint = credentials
1549
- .filter((credential): credential is Extract<AuthCredential, { type: "oauth" }> => credential.type === "oauth")
1550
- .map(credential => `${credential.expires}\u0000${credential.expires > Date.now() ? "usable" : "expired"}`)
1604
+ // Account-backed OAuth rows fingerprint by row, identity, request metadata, and usability rather than token
1605
+ // expiry, so a refresh keeps discovery evidence valid. Rows without a resolvable identity keep the expiry
1606
+ // fingerprint.
1607
+ const now = Date.now();
1608
+ const oauthIdentityKeys = storedEntries.map(entry =>
1609
+ entry.credential.type === "oauth" ? resolveMemoizedOAuthIdentityKey(storageProvider, entry.credential) : null,
1610
+ );
1611
+ const storedOAuthFingerprint = storedEntries
1612
+ .map((entry, index) => {
1613
+ const credential = entry.credential;
1614
+ if (credential.type !== "oauth") return undefined;
1615
+ const usability = credential.expires > now ? "usable" : "expired";
1616
+ const identityKey = oauthIdentityKeys[index];
1617
+ if (!identityKey) return `${credential.expires}\u0000${usability}`;
1618
+ return [
1619
+ entry.id,
1620
+ identityKey,
1621
+ usability,
1622
+ credential.projectId ?? "",
1623
+ credential.enterpriseUrl ?? "",
1624
+ credential.mcpBinding?.resourceOrigin ?? "",
1625
+ credential.mcpBinding?.tokenEndpoint ?? "",
1626
+ ].join("\u0000");
1627
+ })
1628
+ .filter(fingerprint => fingerprint !== undefined)
1551
1629
  .join("\u0001");
1630
+ // A key resolved from an account-backed OAuth row is covered by that row's fingerprint. Hashing the raw
1631
+ // token, or a structured key carrying it, would make every refresh look like a different credential.
1632
+ const identityBackedEntries = storedEntries.filter((_, index) => oauthIdentityKeys[index]);
1633
+ let oauthKeyEntry: StoredCredential | undefined;
1634
+ if (evidenceApiKey !== undefined && identityBackedEntries.length > 0) {
1635
+ const structuredKey = parseStructuredOAuthKey(evidenceApiKey);
1636
+ const keyToken = structuredKey ? structuredKey.token : evidenceApiKey;
1637
+ // Callers may still hold the key they resolved before the row rotated, so the row's recently rotated
1638
+ // tokens map to it as well. A structured key for another project stays distinct.
1639
+ const matchesRow = (entry: StoredCredential, tokens: readonly string[] | undefined): boolean => {
1640
+ const credential = entry.credential;
1641
+ if (credential.type !== "oauth" || keyToken.length === 0 || !tokens?.includes(keyToken)) return false;
1642
+ return structuredKey?.projectId === undefined || structuredKey.projectId === credential.projectId;
1643
+ };
1644
+ // Current tokens win over rotated ones so a row's old token can never shadow another row's live token.
1645
+ oauthKeyEntry =
1646
+ identityBackedEntries.find(
1647
+ entry => entry.credential.type === "oauth" && matchesRow(entry, [entry.credential.access]),
1648
+ ) ??
1649
+ identityBackedEntries.find(entry =>
1650
+ matchesRow(entry, this.#rotatedOAuthAccessTokens.get(`${storageProvider}\u0000${entry.id}`)),
1651
+ );
1652
+ }
1653
+ const evidenceKeyFingerprint = oauthKeyEntry ? `oauth-row:${oauthKeyEntry.id}` : (evidenceApiKey ?? "");
1552
1654
  return crypto
1553
1655
  .createHash("sha256")
1554
1656
  .update(
1555
- `${this.#getProviderGeneration(storageProvider)}\u0000${effectiveEnvKey ?? ""}\u0000${storedApiKeyFingerprint}\u0000${storedOAuthFingerprint}\u0000${evidenceApiKey ?? ""}`,
1657
+ `${this.#getProviderGeneration(storageProvider)}\u0000${effectiveEnvKey ?? ""}\u0000${storedApiKeyFingerprint}\u0000${storedOAuthFingerprint}\u0000${evidenceKeyFingerprint}`,
1556
1658
  )
1557
1659
  .digest("hex");
1558
1660
  }
@@ -1672,6 +1774,7 @@ export class AuthStorage {
1672
1774
  this.#credentialScopeLeases.delete(scope);
1673
1775
  this.#sessionCredentialSelectors.delete(scope);
1674
1776
  this.#sessionCredentialAutoMasks.delete(scope);
1777
+ this.#sessionCredentialUnavailable.delete(scope);
1675
1778
  for (const [provider, sessions] of this.#sessionLastCredential) {
1676
1779
  if (!sessions.delete(scope)) continue;
1677
1780
  if (sessions.size === 0) this.#sessionLastCredential.delete(provider);
@@ -1693,6 +1796,7 @@ export class AuthStorage {
1693
1796
  if (!scope) throw new Error("Credential scope id must not be empty");
1694
1797
  const storageProvider = resolveOAuthStorageProvider(provider);
1695
1798
  this.#assertCredentialSelectorUsable(storageProvider, selector, owner);
1799
+ this.#sessionCredentialUnavailable.get(scope)?.delete(storageProvider);
1696
1800
  const selectors = this.#sessionCredentialSelectors.get(scope) ?? new Map<string, AuthCredentialSelector>();
1697
1801
  selectors.set(storageProvider, selector);
1698
1802
  this.#sessionCredentialSelectors.set(scope, selectors);
@@ -1706,6 +1810,7 @@ export class AuthStorage {
1706
1810
  if (!scope) throw new Error("Credential scope id must not be empty");
1707
1811
  const storageProvider = resolveOAuthStorageProvider(provider);
1708
1812
  this.#sessionCredentialSelectors.get(scope)?.delete(storageProvider);
1813
+ this.#sessionCredentialUnavailable.get(scope)?.delete(storageProvider);
1709
1814
  const masks = this.#sessionCredentialAutoMasks.get(scope) ?? new Set<string>();
1710
1815
  masks.add(storageProvider);
1711
1816
  this.#sessionCredentialAutoMasks.set(scope, masks);
@@ -1725,6 +1830,28 @@ export class AuthStorage {
1725
1830
  if (changed) this.#bumpGeneration("clear-session-credential-selector", storageProvider);
1726
1831
  }
1727
1832
 
1833
+ /** Preserve a failed hard pin as unavailable instead of allowing AUTO fallback. */
1834
+ markSessionCredentialUnavailable(scopeId: string, provider: string, selector: AuthCredentialSelector): void {
1835
+ const scope = scopeId.trim();
1836
+ if (!scope) throw new Error("Credential scope id must not be empty");
1837
+ const storageProvider = resolveOAuthStorageProvider(provider);
1838
+ const unavailable = this.#sessionCredentialUnavailable.get(scope) ?? new Map<string, AuthCredentialSelector>();
1839
+ unavailable.set(storageProvider, selector);
1840
+ this.#sessionCredentialUnavailable.set(scope, unavailable);
1841
+ this.#sessionCredentialSelectors.get(scope)?.delete(storageProvider);
1842
+ this.#sessionCredentialAutoMasks.get(scope)?.delete(storageProvider);
1843
+ this.#bumpGeneration("mark-session-credential-unavailable", storageProvider);
1844
+ }
1845
+
1846
+ /** Return a failed hard pin retained for this scope, if any. */
1847
+ hasSessionCredentialUnavailable(provider: string, scopeId?: string): boolean {
1848
+ const scope = scopeId?.trim();
1849
+ return (
1850
+ scope !== undefined &&
1851
+ this.#sessionCredentialUnavailable.get(scope)?.has(resolveOAuthStorageProvider(provider)) === true
1852
+ );
1853
+ }
1854
+
1728
1855
  /** Whether the effective selection for a scope is explicitly pinned (AUTO masks are not pins). */
1729
1856
  hasSessionCredentialSelector(provider: string, scopeId?: string): boolean {
1730
1857
  if (!scopeId) return false;
@@ -2277,15 +2404,45 @@ export class AuthStorage {
2277
2404
  (entry, index) =>
2278
2405
  entry.id !== credentials[index]?.id || entry.credential.type !== credentials[index]?.credential.type,
2279
2406
  );
2280
- this.#resolvedStoredApiKeyValues.delete(provider);
2281
- this.#storedApiKeyResolutionInFlight.delete(provider);
2407
+ // Refreshing tokens for the same OAuth account is not a configuration change: keep the provider's
2408
+ // evidence and configuration generations so catalogs discovered for that account stay current.
2409
+ const storageProvider = resolveOAuthStorageProvider(provider);
2410
+ const tokenRotationOnly =
2411
+ !identityOrderChanged && isOAuthTokenRotationOnly(storageProvider, current, credentials);
2412
+ if (tokenRotationOnly) {
2413
+ this.#rememberRotatedAccessTokens(storageProvider, current, credentials);
2414
+ } else {
2415
+ this.#forgetRotatedAccessTokens(storageProvider);
2416
+ this.#resolvedStoredApiKeyValues.delete(provider);
2417
+ this.#storedApiKeyResolutionInFlight.delete(provider);
2418
+ }
2282
2419
  if (credentials.length === 0) {
2283
2420
  this.#data.delete(provider);
2284
2421
  } else {
2285
2422
  this.#data.set(provider, credentials);
2286
2423
  }
2287
- if (identityOrderChanged) this.#resetProviderAssignments(resolveOAuthStorageProvider(provider));
2288
- this.#bumpGeneration("credentials", provider);
2424
+ if (identityOrderChanged) this.#resetProviderAssignments(storageProvider);
2425
+ if (tokenRotationOnly) this.#bumpGeneration("oauth-token-rotation");
2426
+ else this.#bumpGeneration("credentials", provider);
2427
+ }
2428
+
2429
+ #rememberRotatedAccessTokens(storageProvider: string, previous: StoredCredential[], next: StoredCredential[]): void {
2430
+ for (let index = 0; index < previous.length; index += 1) {
2431
+ const before = previous[index];
2432
+ const after = next[index];
2433
+ if (before?.credential.type !== "oauth" || after?.credential.type !== "oauth") continue;
2434
+ if (!before.credential.access || before.credential.access === after.credential.access) continue;
2435
+ const key = `${storageProvider}\u0000${before.id}`;
2436
+ const history = [before.credential.access, ...(this.#rotatedOAuthAccessTokens.get(key) ?? [])].slice(0, 4);
2437
+ this.#rotatedOAuthAccessTokens.set(key, history);
2438
+ }
2439
+ }
2440
+
2441
+ #forgetRotatedAccessTokens(storageProvider: string): void {
2442
+ const prefix = `${storageProvider}\u0000`;
2443
+ for (const key of [...this.#rotatedOAuthAccessTokens.keys()]) {
2444
+ if (key.startsWith(prefix)) this.#rotatedOAuthAccessTokens.delete(key);
2445
+ }
2289
2446
  }
2290
2447
 
2291
2448
  #resolveOAuthDedupeIdentityKey(provider: string, credential: OAuthCredential): string | null {
@@ -2692,10 +2849,15 @@ export class AuthStorage {
2692
2849
  if (persist && !this.#store.refreshSnapshot) this.#store.updateAuthCredential(target.id, credential);
2693
2850
  const updated = [...entries];
2694
2851
  updated[index] = { id: target.id, credential };
2852
+ const configurationGeneration = this.#getProviderConfigurationGeneration(provider);
2695
2853
  this.#setStoredCredentials(provider, updated);
2854
+ // Callers use the refresh generation to discount configuration bumps caused by a refresh, so it only
2855
+ // advances when this refresh bumped configuration; token rotation for a known account does not.
2696
2856
  if (
2857
+ configurationGeneration !== this.#getProviderConfigurationGeneration(provider) &&
2697
2858
  credential.type === "oauth" &&
2698
2859
  target.credential.type === "oauth" &&
2860
+ oauthNonTokenFieldsEqual(credential, target.credential) &&
2699
2861
  (credential.access !== target.credential.access ||
2700
2862
  credential.refresh !== target.credential.refresh ||
2701
2863
  credential.expires !== target.credential.expires)
@@ -2808,7 +2970,9 @@ export class AuthStorage {
2808
2970
  const selector = selectors.get(storageProvider);
2809
2971
  if (!selector) continue;
2810
2972
  const selected = previousEntries.find(entry => this.#credentialMatchesSelector(entry, selector));
2811
- if (selected && removedIds.has(selected.id)) this.clearSessionCredentialSelector(storageProvider, scopeId);
2973
+ if (selected && removedIds.has(selected.id)) {
2974
+ this.markSessionCredentialUnavailable(scopeId, storageProvider, selector);
2975
+ }
2812
2976
  }
2813
2977
  for (const [sessionId, sticky] of this.#sessionLastCredential.get(storageProvider) ?? []) {
2814
2978
  if (removedIds.has(previousEntries[sticky.index]?.id ?? -1))
@@ -4563,50 +4727,86 @@ export class AuthStorage {
4563
4727
  }
4564
4728
 
4565
4729
  /**
4566
- * Marks the current session's credential as temporarily blocked due to usage limits.
4567
- * Uses usage reports to determine accurate reset time when available.
4568
- * Returns true if a credential was blocked, enabling automatic fallback to the next credential.
4730
+ * Marks the explicit stored row, or the session row captured at entry, as usage-limited.
4731
+ * Re-finds that same row after the usage lookup; a vanished row marks nothing.
4732
+ * Returns whether another credential of the same type remains unblocked.
4569
4733
  */
4570
4734
  async markUsageLimitReached(
4571
4735
  provider: string,
4572
4736
  sessionId: string | undefined,
4573
- options?: { retryAfterMs?: number; baseUrl?: string; signal?: AbortSignal; owner?: object },
4737
+ options?: { retryAfterMs?: number; baseUrl?: string; signal?: AbortSignal; owner?: object; rowId?: number },
4574
4738
  ): Promise<boolean> {
4575
4739
  provider = resolveOAuthStorageProvider(provider);
4576
4740
  const ownerOverride = this.#configOverrideRegistration(provider, options?.owner);
4577
4741
  if (ownerOverride && !ownerOverride.envSourced) return false;
4742
+ const entries = this.#getStoredCredentials(provider);
4578
4743
  const sessionCredential = this.#getSessionCredential(provider, sessionId);
4579
- if (!sessionCredential) return false;
4580
-
4581
- const providerKey = this.#getProviderTypeKey(provider, sessionCredential.type);
4744
+ const initial =
4745
+ options?.rowId !== undefined
4746
+ ? entries.find(entry => entry.id === options.rowId)
4747
+ : sessionCredential
4748
+ ? entries[sessionCredential.index]
4749
+ : undefined;
4750
+ if (!initial) return false;
4582
4751
  const now = Date.now();
4583
4752
  let blockedUntil = now + (options?.retryAfterMs ?? AuthStorage.#defaultBackoffMs);
4584
4753
 
4585
- if (sessionCredential.type === "oauth" && this.#rankingStrategyResolver?.(provider)) {
4586
- const credential = this.#getCredentialsForProvider(provider)[sessionCredential.index];
4587
- if (credential?.type === "oauth") {
4588
- const report = await this.#getUsageReport(provider, credential, options);
4589
- if (report && this.#isUsageLimitReached(report)) {
4590
- const resetAtMs = this.#getUsageResetAtMs(report, Date.now());
4591
- if (resetAtMs && resetAtMs > blockedUntil) {
4592
- blockedUntil = resetAtMs;
4593
- }
4594
- }
4754
+ if (initial.credential.type === "oauth" && this.#rankingStrategyResolver?.(provider)) {
4755
+ const report = await this.#getUsageReport(provider, initial.credential, options);
4756
+ if (report && this.#isUsageLimitReached(report)) {
4757
+ const resetAtMs = this.#getUsageResetAtMs(report, Date.now());
4758
+ if (resetAtMs && resetAtMs > blockedUntil) blockedUntil = resetAtMs;
4595
4759
  }
4596
4760
  }
4597
4761
 
4598
- this.#markCredentialBlocked(providerKey, sessionCredential.index, blockedUntil);
4762
+ // Never consult the possibly reassigned sticky pointer after the await.
4763
+ const current = this.#getStoredCredentials(provider);
4764
+ const targetIndex = current.findIndex(entry => entry.id === initial.id);
4765
+ const target = current[targetIndex];
4766
+ if (!target) return false;
4767
+ const providerKey = this.#getProviderTypeKey(provider, target.credential.type);
4768
+ this.#markCredentialBlocked(providerKey, targetIndex, blockedUntil);
4599
4769
 
4600
4770
  const remainingCredentials = this.#getCredentialsForProvider(provider)
4601
4771
  .map((credential, index) => ({ credential, index }))
4602
4772
  .filter(
4603
4773
  (entry): entry is { credential: AuthCredential; index: number } =>
4604
- entry.credential.type === sessionCredential.type && entry.index !== sessionCredential.index,
4774
+ entry.credential.type === target.credential.type && entry.index !== targetIndex,
4605
4775
  );
4606
4776
 
4607
4777
  return remainingCredentials.some(candidate => !this.#isCredentialBlocked(providerKey, candidate.index));
4608
4778
  }
4609
4779
 
4780
+ /**
4781
+ * Mark the stored credential whose key matches `apiKey` usage-limited for
4782
+ * `retryAfterMs` (or the default backoff). Used when the caller knows
4783
+ * exactly which leased credential failed (e.g. the auth-gateway leases a
4784
+ * concrete row without a session binding) so bookkeeping cannot land on the
4785
+ * wrong credential. The credential is never invalidated or marked suspect:
4786
+ * the block is a bounded backoff only. Returns false when no stored
4787
+ * credential matches the key.
4788
+ */
4789
+ async markUsageLimitReachedMatching(
4790
+ provider: string,
4791
+ apiKey: string,
4792
+ options?: { retryAfterMs?: number },
4793
+ ): Promise<boolean> {
4794
+ const storageProvider = resolveOAuthStorageProvider(provider);
4795
+ const stored = this.#getStoredCredentials(storageProvider);
4796
+ for (let index = 0; index < stored.length; index++) {
4797
+ const entry = stored[index];
4798
+ if (entry && (await this.#credentialMatchesApiKey(storageProvider, entry.credential, apiKey))) {
4799
+ this.#markCredentialBlocked(
4800
+ this.#getProviderTypeKey(storageProvider, entry.credential.type),
4801
+ index,
4802
+ Date.now() + (options?.retryAfterMs ?? AuthStorage.#defaultBackoffMs),
4803
+ );
4804
+ return true;
4805
+ }
4806
+ }
4807
+ return false;
4808
+ }
4809
+
4610
4810
  /**
4611
4811
  * Earliest instant at which any currently blocked stored credential for this
4612
4812
  * provider becomes usable again. Undefined when nothing is blocked.
@@ -4787,11 +4987,6 @@ export class AuthStorage {
4787
4987
  if (leftBlockedUntil !== rightBlockedUntil) return leftBlockedUntil - rightBlockedUntil;
4788
4988
  return left.orderPos - right.orderPos;
4789
4989
  }
4790
- if (requiresOpenAICodexProModel(args.provider, args.options?.modelId)) {
4791
- const leftPlanPriority = getOpenAICodexPlanPriority(left.usage);
4792
- const rightPlanPriority = getOpenAICodexPlanPriority(right.usage);
4793
- if (leftPlanPriority !== rightPlanPriority) return leftPlanPriority - rightPlanPriority;
4794
- }
4795
4990
  if (left.hasPriorityBoost !== right.hasPriorityBoost) return left.hasPriorityBoost ? -1 : 1;
4796
4991
  if (this.#credentialRankingMode === "earliest-reset" && left.resetAtMs !== right.resetAtMs) {
4797
4992
  // Earliest-expiry-first: drain the soonest-to-reset account before
@@ -4827,6 +5022,15 @@ export class AuthStorage {
4827
5022
  options?: AuthApiKeyOptions,
4828
5023
  reloadsUsed = 0,
4829
5024
  ): Promise<OAuthResolutionResult | undefined> {
5025
+ const unavailableSelector =
5026
+ sessionId && !options?.credentialSelector
5027
+ ? this.#sessionCredentialUnavailable.get(sessionId)?.get(resolveOAuthStorageProvider(provider))
5028
+ : undefined;
5029
+ if (unavailableSelector) {
5030
+ throw new Error(
5031
+ `Selected credential for ${provider} (${this.#formatCredentialSelector(unavailableSelector)}) is unavailable`,
5032
+ );
5033
+ }
4830
5034
  if (reloadsUsed > MAX_OAUTH_RESOLUTION_RELOADS) {
4831
5035
  logger.warn("OAuth credential resolution exhausted its reload budget", {
4832
5036
  provider,
@@ -4835,6 +5039,10 @@ export class AuthStorage {
4835
5039
  return undefined;
4836
5040
  }
4837
5041
  const selectedCredential = this.#resolveSelectedStoredCredential(provider, options, sessionId);
5042
+ const sessionSelector =
5043
+ sessionId && !options?.credentialSelector && selectedCredential?.credential.type === "oauth"
5044
+ ? { kind: "id" as const, value: String(selectedCredential.id) }
5045
+ : undefined;
4838
5046
  const selectedOAuthCredential: OAuthCredentialSelection | undefined =
4839
5047
  selectedCredential?.credential.type === "oauth"
4840
5048
  ? {
@@ -4864,9 +5072,7 @@ export class AuthStorage {
4864
5072
  const providerKey = this.#getProviderTypeKey(provider, "oauth");
4865
5073
  const order = selectedCredential ? [0] : this.#getCredentialOrder(providerKey, sessionId, credentials.length);
4866
5074
  const strategy = this.#rankingStrategyResolver?.(provider);
4867
- const requiresProModel = requiresOpenAICodexProModel(provider, options?.modelId);
4868
- const checkUsage =
4869
- strategy !== undefined && (selectedCredential !== undefined || credentials.length > 1 || requiresProModel);
5075
+ const checkUsage = strategy !== undefined && (selectedCredential !== undefined || credentials.length > 1);
4870
5076
  const sessionCredential = this.#getSessionCredential(provider, sessionId);
4871
5077
  const sessionPreferredIndex = sessionCredential?.type === "oauth" ? sessionCredential.index : undefined;
4872
5078
  // Skip ranking only when the session already has a working preferred credential — re-ranking
@@ -4875,7 +5081,7 @@ export class AuthStorage {
4875
5081
  // with the most headroom proactively and fall back intelligently when rate-limited.
4876
5082
  const sessionPreferredIsAvailable =
4877
5083
  sessionPreferredIndex !== undefined && !this.#isCredentialBlocked(providerKey, sessionPreferredIndex);
4878
- const shouldRank = !selectedCredential && checkUsage && (!sessionPreferredIsAvailable || requiresProModel);
5084
+ const shouldRank = !selectedCredential && checkUsage && !sessionPreferredIsAvailable;
4879
5085
  const candidates = shouldRank
4880
5086
  ? await this.#rankOAuthSelections({ providerKey, provider, order, credentials, options, strategy: strategy! })
4881
5087
  : order
@@ -4913,7 +5119,7 @@ export class AuthStorage {
4913
5119
  }
4914
5120
  }
4915
5121
 
4916
- if (!selectedCredential && sessionPreferredIndex !== undefined && !requiresProModel) {
5122
+ if (!selectedCredential && sessionPreferredIndex !== undefined) {
4917
5123
  const sessionPreferredCandidate = candidates.findIndex(
4918
5124
  candidate =>
4919
5125
  !this.#isCredentialBlocked(providerKey, candidate.selection.index) &&
@@ -4961,24 +5167,6 @@ export class AuthStorage {
4961
5167
  }),
4962
5168
  );
4963
5169
 
4964
- // Skip the Pro-plan filter when no candidate is confirmed Pro, so users with only
4965
- // non-Pro accounts can still attempt Spark requests (e.g. trial/grandfathered access).
4966
- const enforceProRequirement =
4967
- requiresProModel && candidates.some(candidate => hasOpenAICodexProPlan(candidate.usage));
4968
- // Spark retains its historical Plus fallback for grandfathered accounts.
4969
- // Sol is different: confirmed Free/Plus plans cannot call it, so reject the
4970
- // model before returning an OAuth bearer and letting the turn fail remotely.
4971
- // Unknown plan names still reach the provider because the usage endpoint is
4972
- // authoritative and future tiers must not be denied by a client-side guess.
4973
- const strictProRequirement = requiresStrictOpenAICodexProModel(provider, options?.modelId);
4974
- if (
4975
- strictProRequirement &&
4976
- candidates.length > 0 &&
4977
- candidates.every(candidate => hasKnownOpenAICodexNonProPlan(candidate.usage))
4978
- ) {
4979
- throw new Error(formatOpenAICodexChatGPTEntitlementError(options?.modelId));
4980
- }
4981
-
4982
5170
  const fallback = candidates[0];
4983
5171
 
4984
5172
  for (const candidate of candidates) {
@@ -4993,9 +5181,9 @@ export class AuthStorage {
4993
5181
  allowBlocked: false,
4994
5182
  prefetchedUsage: candidate.usage,
4995
5183
  usagePrechecked: candidate.usageChecked,
4996
- enforceProRequirement,
4997
5184
  },
4998
5185
  reloadsUsed,
5186
+ sessionSelector,
4999
5187
  );
5000
5188
  if (resolved) return resolved;
5001
5189
  }
@@ -5012,9 +5200,9 @@ export class AuthStorage {
5012
5200
  allowBlocked: true,
5013
5201
  prefetchedUsage: fallback.usage,
5014
5202
  usagePrechecked: fallback.usageChecked,
5015
- enforceProRequirement,
5016
5203
  },
5017
5204
  reloadsUsed,
5205
+ sessionSelector,
5018
5206
  );
5019
5207
  }
5020
5208
 
@@ -5229,6 +5417,18 @@ export class AuthStorage {
5229
5417
  }
5230
5418
  return authority;
5231
5419
  } catch (error) {
5420
+ // A genuine caller cancellation (e.g. the agent's ESC) is not a refresh
5421
+ // failure. Rethrow before any failure classification so it never poisons
5422
+ // the replay guard (which would temp-block the credential on the next
5423
+ // request) or otherwise mutate its health state. But an INTERNAL deadline
5424
+ // is a real failure: an `AbortSignal.timeout(...)` from an internal probe
5425
+ // (`#checkCredentialHealth` / `#fetchUsageUncached`) may have cut short a
5426
+ // dial that already consumed the rotating refresh token upstream, so its
5427
+ // failure must still be memoized — otherwise the same (credential, token)
5428
+ // pair is immediately eligible for a second refresh, replaying the token
5429
+ // and tripping provider reuse detection. Skip the guard update only for a
5430
+ // caller-owned abort, never for an internal timeout.
5431
+ if (signal?.aborted && !isTimeoutAbort(signal)) throw error;
5232
5432
  if (localDial && credentialId !== undefined) {
5233
5433
  for (const [key, entry] of this.#recentOAuthRefreshFailures) {
5234
5434
  if (entry.expiresAt <= Date.now()) this.#recentOAuthRefreshFailures.delete(key);
@@ -5298,17 +5498,11 @@ export class AuthStorage {
5298
5498
  allowBlocked: boolean;
5299
5499
  prefetchedUsage?: UsageReport | null;
5300
5500
  usagePrechecked?: boolean;
5301
- enforceProRequirement?: boolean;
5302
5501
  },
5303
5502
  reloadsUsed = 0,
5503
+ sessionSelector?: AuthCredentialSelector,
5304
5504
  ): Promise<OAuthResolutionResult | undefined> {
5305
- const {
5306
- checkUsage,
5307
- allowBlocked,
5308
- prefetchedUsage = null,
5309
- usagePrechecked = false,
5310
- enforceProRequirement,
5311
- } = usageOptions;
5505
+ const { checkUsage, allowBlocked, prefetchedUsage = null, usagePrechecked = false } = usageOptions;
5312
5506
  if (!this.#reconcileOAuthCredentialSelection(provider, selection)) return undefined;
5313
5507
  if (!allowBlocked && this.#isCredentialBlocked(providerKey, selection.index)) {
5314
5508
  return undefined;
@@ -5318,12 +5512,10 @@ export class AuthStorage {
5318
5512
  return undefined;
5319
5513
  }
5320
5514
 
5321
- const requiresProModel = requiresOpenAICodexProModel(provider, options?.modelId);
5322
- const applyProFilter = enforceProRequirement ?? requiresProModel;
5323
5515
  let usage: UsageReport | null = null;
5324
5516
  let usageChecked = false;
5325
5517
 
5326
- if ((checkUsage && !allowBlocked) || requiresProModel) {
5518
+ if (checkUsage && !allowBlocked) {
5327
5519
  if (usagePrechecked) {
5328
5520
  usage = prefetchedUsage;
5329
5521
  usageChecked = true;
@@ -5334,9 +5526,6 @@ export class AuthStorage {
5334
5526
  });
5335
5527
  usageChecked = true;
5336
5528
  }
5337
- if (applyProFilter && !hasOpenAICodexProPlan(usage)) {
5338
- return undefined;
5339
- }
5340
5529
  if (checkUsage && !allowBlocked && usage && this.#isUsageLimitReached(usage)) {
5341
5530
  const resetAtMs = this.#getUsageResetAtMs(usage, Date.now());
5342
5531
  this.#markCredentialBlocked(
@@ -5407,7 +5596,7 @@ export class AuthStorage {
5407
5596
  selectionCredentialId,
5408
5597
  );
5409
5598
 
5410
- if ((checkUsage && !allowBlocked) || requiresProModel) {
5599
+ if (checkUsage && !allowBlocked) {
5411
5600
  const sameAccount = selection.credential.accountId === updated.accountId;
5412
5601
  if (!usageChecked || !sameAccount) {
5413
5602
  usage = await this.#getUsageReport(provider, updated, {
@@ -5416,9 +5605,6 @@ export class AuthStorage {
5416
5605
  });
5417
5606
  usageChecked = true;
5418
5607
  }
5419
- if (applyProFilter && !hasOpenAICodexProPlan(usage)) {
5420
- return undefined;
5421
- }
5422
5608
  if (checkUsage && !allowBlocked && usage && this.#isUsageLimitReached(usage)) {
5423
5609
  const resetAtMs = this.#getUsageResetAtMs(usage, Date.now());
5424
5610
  this.#markCredentialBlocked(
@@ -5434,6 +5620,7 @@ export class AuthStorage {
5434
5620
  this.#recordSessionCredential(provider, sessionId, "oauth", selection.index);
5435
5621
  return { apiKey: result.apiKey, credential: updated };
5436
5622
  } catch (error) {
5623
+ if (options?.signal?.aborted) throw error;
5437
5624
  if (isSqliteError(error)) throw error;
5438
5625
  // Auth-broker errors retain the sanitized upstream body separately from
5439
5626
  // their transport message. Include that body for failure classification
@@ -5517,6 +5704,9 @@ export class AuthStorage {
5517
5704
  `oauth refresh failed: ${errorMsg}`,
5518
5705
  attemptedCredentialId,
5519
5706
  );
5707
+ if (sessionSelector && sessionId) {
5708
+ this.markSessionCredentialUnavailable(sessionId, provider, sessionSelector);
5709
+ }
5520
5710
  if (!disabled) {
5521
5711
  // The CAS predicate compares the row's serialized `data`, so it also
5522
5712
  // misses when nothing was rotated: the row may have been replaced by
@@ -5529,7 +5719,12 @@ export class AuthStorage {
5529
5719
  // peer rotation to clobber — so apply it directly instead of looping.
5530
5720
  const stillHoldsAttemptedToken =
5531
5721
  attemptedCredentialId !== undefined &&
5532
- this.#credentialRowHoldsRefreshToken(provider, attemptedCredentialId, attemptedRefreshToken);
5722
+ (this.#credentialRowHoldsRefreshToken(provider, attemptedCredentialId, attemptedRefreshToken) ||
5723
+ this.#credentialRowHoldsRefreshToken(
5724
+ provider,
5725
+ attemptedCredentialId,
5726
+ selection.credential.refresh,
5727
+ ));
5533
5728
  if (stillHoldsAttemptedToken && attemptedCredentialId !== undefined) {
5534
5729
  logger.warn("OAuth refresh disable CAS mismatched an unrotated row; disabling by id", {
5535
5730
  provider,
@@ -5587,6 +5782,9 @@ export class AuthStorage {
5587
5782
  }
5588
5783
  if (this.#getCredentialSelector(provider, options, sessionId)) {
5589
5784
  const selector = this.#getCredentialSelector(provider, options, sessionId);
5785
+ if (selector && sessionId && !options?.credentialSelector) {
5786
+ this.markSessionCredentialUnavailable(sessionId, provider, selector);
5787
+ }
5590
5788
  throw new Error(
5591
5789
  `Selected credential for ${provider} (${selector ? this.#formatCredentialSelector(selector) : "unknown"}) is unavailable`,
5592
5790
  );
@@ -5663,7 +5861,10 @@ export class AuthStorage {
5663
5861
  * and get a best-effort token. For GitHub Copilot we preserve enterprise
5664
5862
  * routing metadata so discovery can hit the correct host.
5665
5863
  */
5666
- async peekApiKey(provider: string, options?: Pick<AuthApiKeyOptions, "owner">): Promise<string | undefined> {
5864
+ async peekApiKey(
5865
+ provider: string,
5866
+ options?: Pick<AuthApiKeyOptions, "owner"> & { sessionId?: string },
5867
+ ): Promise<string | undefined> {
5667
5868
  provider = resolveOAuthStorageProvider(provider);
5668
5869
  const runtimeKey = this.#runtimeOverrides.get(provider);
5669
5870
  if (runtimeKey) return runtimeKey;
@@ -5671,11 +5872,12 @@ export class AuthStorage {
5671
5872
  const configOverride = this.#configOverrideRegistration(provider, options?.owner);
5672
5873
  const configKey = configOverride?.apiKey;
5673
5874
  if (configKey && !configOverride?.envSourced) return configKey;
5875
+ if (options?.sessionId && this.hasSessionCredentialUnavailable(provider, options.sessionId)) return undefined;
5674
5876
 
5675
5877
  const selectedCredential = this.#resolveSelectedStoredCredential(
5676
5878
  provider,
5677
5879
  options?.owner ? { owner: options.owner } : undefined,
5678
- undefined,
5880
+ options?.sessionId,
5679
5881
  );
5680
5882
  if (configKey) {
5681
5883
  // Env-sourced (`apiKeyEnv`) override: same precedence as getApiKey —
@@ -5702,6 +5904,10 @@ export class AuthStorage {
5702
5904
  }
5703
5905
  return undefined;
5704
5906
  }
5907
+ // A hard selector is an identity boundary. If its selected row cannot
5908
+ // provide a current token, discovery must not continue into the shared
5909
+ // credential pool and silently query another account's catalog.
5910
+ if (this.#getCredentialSelector(provider, undefined, options?.sessionId)) return undefined;
5705
5911
 
5706
5912
  const attemptedApiKeyIndices = new Set<number>();
5707
5913
  for (;;) {
@@ -5770,6 +5976,14 @@ export class AuthStorage {
5770
5976
  if (storedApiKey) return storedApiKey;
5771
5977
  return configKey;
5772
5978
  }
5979
+ if (sessionId && !options?.credentialSelector) {
5980
+ const unavailableSelector = this.#sessionCredentialUnavailable.get(sessionId)?.get(provider);
5981
+ if (unavailableSelector) {
5982
+ throw new Error(
5983
+ `Selected credential for ${provider} (${this.#formatCredentialSelector(unavailableSelector)}) is unavailable`,
5984
+ );
5985
+ }
5986
+ }
5773
5987
 
5774
5988
  if (selectedCredential?.credential.type === "api_key") {
5775
5989
  this.#recordSessionCredential(provider, sessionId, "api_key", selectedCredential.index);