@gajae-code/ai 0.18.11 → 0.19.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 (90) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/README.md +1 -1
  3. package/dist/types/auth-broker/wire-schemas.d.ts +30 -0
  4. package/dist/types/auth-storage.d.ts +98 -13
  5. package/dist/types/bedrock-claude-cache-policy.d.ts +1 -1
  6. package/dist/types/model-cache.d.ts +6 -4
  7. package/dist/types/model-manager.d.ts +2 -0
  8. package/dist/types/model-pricing.d.ts +8 -0
  9. package/dist/types/model-thinking.d.ts +11 -0
  10. package/dist/types/openai-completions-compat.d.ts +2 -1
  11. package/dist/types/provider-models/google.d.ts +2 -0
  12. package/dist/types/providers/amazon-bedrock.d.ts +17 -2
  13. package/dist/types/providers/anthropic.d.ts +25 -0
  14. package/dist/types/providers/aws-credential-config.d.ts +5 -0
  15. package/dist/types/providers/google-auth.d.ts +7 -0
  16. package/dist/types/providers/google-gemini-headers.d.ts +11 -6
  17. package/dist/types/providers/kiro-api-key.d.ts +5 -0
  18. package/dist/types/providers/openai-chat-server-schema.d.ts +2 -1
  19. package/dist/types/providers/openai-completions.d.ts +1 -1
  20. package/dist/types/providers/openai-responses-shared.d.ts +2 -1
  21. package/dist/types/stream.d.ts +33 -1
  22. package/dist/types/types.d.ts +26 -3
  23. package/dist/types/usage.d.ts +3 -0
  24. package/dist/types/utils/discovery/antigravity.d.ts +16 -1
  25. package/dist/types/utils/idle-iterator.d.ts +3 -2
  26. package/dist/types/utils/oauth/glm-zcode.d.ts +0 -30
  27. package/dist/types/utils/oauth/types.d.ts +9 -0
  28. package/package.json +4 -4
  29. package/src/auth-broker/remote-store.ts +62 -11
  30. package/src/auth-broker/wire-schemas.ts +2 -0
  31. package/src/auth-gateway/server.ts +3 -2
  32. package/src/auth-storage.ts +908 -246
  33. package/src/bedrock-claude-cache-policy.d.ts +1 -1
  34. package/src/bedrock-claude-cache-policy.ts +2 -2
  35. package/src/model-cache.ts +45 -10
  36. package/src/model-manager.ts +18 -6
  37. package/src/model-pricing.d.ts +8 -0
  38. package/src/model-pricing.ts +87 -0
  39. package/src/model-thinking.d.ts +11 -0
  40. package/src/model-thinking.ts +97 -12
  41. package/src/models.json +3176 -1670
  42. package/src/models.ts +10 -3
  43. package/src/openai-completions-compat.d.ts +2 -1
  44. package/src/openai-completions-compat.ts +5 -0
  45. package/src/provider-models/google.ts +11 -1
  46. package/src/provider-models/openai-compat.ts +26 -0
  47. package/src/providers/amazon-bedrock.d.ts +17 -2
  48. package/src/providers/amazon-bedrock.ts +76 -16
  49. package/src/providers/anthropic.d.ts +25 -0
  50. package/src/providers/anthropic.ts +230 -18
  51. package/src/providers/aws-credential-config.d.ts +5 -0
  52. package/src/providers/aws-credential-config.ts +29 -1
  53. package/src/providers/google-auth.d.ts +7 -0
  54. package/src/providers/google-auth.ts +165 -4
  55. package/src/providers/google-gemini-headers.d.ts +11 -6
  56. package/src/providers/google-gemini-headers.ts +16 -11
  57. package/src/providers/google-vertex.ts +2 -4
  58. package/src/providers/kiro-api-key.d.ts +5 -0
  59. package/src/providers/kiro-api-key.ts +379 -68
  60. package/src/providers/ollama.ts +19 -9
  61. package/src/providers/openai-anthropic-shim.ts +1 -0
  62. package/src/providers/openai-chat-server-schema.ts +1 -1
  63. package/src/providers/openai-chat-server.ts +14 -2
  64. package/src/providers/openai-codex-responses.ts +21 -14
  65. package/src/providers/openai-completions.d.ts +1 -1
  66. package/src/providers/openai-completions.ts +65 -4
  67. package/src/providers/openai-responses-server.ts +10 -1
  68. package/src/providers/openai-responses-shared.d.ts +2 -1
  69. package/src/providers/openai-responses-shared.ts +42 -1
  70. package/src/providers/openai-responses.ts +51 -10
  71. package/src/providers/pi-native-server.ts +1 -1
  72. package/src/providers/transform-messages.ts +32 -1
  73. package/src/stream.d.ts +33 -1
  74. package/src/stream.ts +111 -17
  75. package/src/types.d.ts +26 -3
  76. package/src/types.ts +55 -2
  77. package/src/usage/claude.ts +4 -2
  78. package/src/usage.ts +3 -0
  79. package/src/utils/discovery/antigravity.ts +192 -7
  80. package/src/utils/idle-iterator.d.ts +3 -2
  81. package/src/utils/idle-iterator.ts +30 -10
  82. package/src/utils/oauth/anthropic.ts +23 -22
  83. package/src/utils/oauth/api-key-validation.ts +34 -11
  84. package/src/utils/oauth/glm-zcode.d.ts +0 -30
  85. package/src/utils/oauth/glm-zcode.ts +15 -4
  86. package/src/utils/oauth/openai-codex.ts +41 -6
  87. package/src/utils/oauth/types.d.ts +9 -0
  88. package/src/utils/oauth/types.ts +9 -0
  89. package/src/utils/parse-bind.d.ts +35 -0
  90. package/src/utils/proxy.ts +35 -6
@@ -237,6 +237,8 @@ export interface CredentialInventoryRecord {
237
237
  accountId?: string;
238
238
  email?: string;
239
239
  projectId?: string;
240
+ organizationId?: string;
241
+ organizationName?: string;
240
242
  disabled: boolean;
241
243
  disabledCause: string | null;
242
244
  }
@@ -696,6 +698,26 @@ export interface CredentialDisabledEvent {
696
698
  */
697
699
  export type CredentialRankingMode = "balanced" | "earliest-reset";
698
700
 
701
+ /**
702
+ * One entry of a provider's ordered, persisted soft preference list.
703
+ *
704
+ * Entries are consulted in list order when a credential is selected. Usable
705
+ * entries under their cap are tried first, in list order; the remaining
706
+ * credentials follow in the normal ranking order. An entry whose most-used
707
+ * known usage window has reached `maxUsedFraction` is demoted behind every
708
+ * other credential, so it is used only when nothing else is available. A
709
+ * blocked or missing entry is skipped. Unknown usage never counts as over the
710
+ * cap.
711
+ *
712
+ * Runtime selectors (`--credential`, `--prefer-credential`) and session
713
+ * stickiness take precedence over configured preferences.
714
+ */
715
+ export interface ConfiguredCredentialPreference {
716
+ selector: AuthCredentialSelector;
717
+ /** Used fraction in (0, 1] at which this entry stops being preferred. Absent means only blocking moves past it. */
718
+ maxUsedFraction?: number;
719
+ }
720
+
699
721
  /**
700
722
  * Whether credential selection may probe provider usage endpoints.
701
723
  *
@@ -712,6 +734,8 @@ export type AuthStorageOptions = {
712
734
  usageProviderResolver?: (provider: Provider) => UsageProvider | undefined;
713
735
  rankingStrategyResolver?: (provider: Provider) => CredentialRankingStrategy | undefined;
714
736
  credentialRankingMode?: CredentialRankingMode;
737
+ /** Persisted ordered soft preferences keyed by provider id. */
738
+ credentialPreferences?: Readonly<Record<string, readonly ConfiguredCredentialPreference[]>>;
715
739
  usageFetch?: typeof fetch;
716
740
  usageRequestTimeoutMs?: number;
717
741
  usageLogger?: UsageLogger;
@@ -1129,14 +1153,64 @@ export type AuthApiKeyOptions = {
1129
1153
  /** Prefer one stored OAuth credential while preserving quota-triggered fallback. */
1130
1154
  preferredCredentialSelector?: AuthCredentialSelector;
1131
1155
  };
1132
- export type AuthCredentialSelectorKind = "id" | "email" | "account" | "project";
1156
+ export type AuthCredentialSelectorKind = "id" | "email" | "account" | "project" | "org";
1133
1157
 
1134
1158
  export interface AuthCredentialSelector {
1135
1159
  kind: AuthCredentialSelectorKind;
1136
1160
  value: string;
1137
1161
  }
1138
1162
 
1139
- type OAuthResolutionResult = { apiKey: string; credential: OAuthCredential };
1163
+ /**
1164
+ * Whether an OAuth credential belongs to the organization named by an `org:`
1165
+ * selector value: the organization id, or its display name (case-insensitive).
1166
+ */
1167
+ export function oauthCredentialMatchesOrganization(
1168
+ credential: Pick<OAuthCredentials, "organizationId" | "organizationName">,
1169
+ value: string,
1170
+ ): boolean {
1171
+ const wanted = value.trim().toLowerCase();
1172
+ if (wanted.length === 0) return false;
1173
+ return (
1174
+ credential.organizationId?.trim().toLowerCase() === wanted ||
1175
+ credential.organizationName?.trim().toLowerCase() === wanted
1176
+ );
1177
+ }
1178
+
1179
+ /**
1180
+ * The items an `org:` selector designates. An exact organization-id match wins
1181
+ * over display-name matches, because names are not unique: two organizations
1182
+ * can share a name, and a name can equal another organization's id. Callers
1183
+ * must treat more than one result as ambiguous rather than picking one.
1184
+ */
1185
+ export function selectOrganizationMatches<T>(
1186
+ items: readonly T[],
1187
+ value: string,
1188
+ organizationOf: (item: T) => Pick<OAuthCredentials, "organizationId" | "organizationName"> | undefined,
1189
+ ): T[] {
1190
+ const wanted = value.trim().toLowerCase();
1191
+ const matches = items.filter(item => {
1192
+ const organization = organizationOf(item);
1193
+ return organization !== undefined && oauthCredentialMatchesOrganization(organization, wanted);
1194
+ });
1195
+ const exact = matches.filter(item => organizationOf(item)?.organizationId?.trim().toLowerCase() === wanted);
1196
+ return exact.length > 0 ? exact : matches;
1197
+ }
1198
+
1199
+ /**
1200
+ * Human-facing identity of a stored OAuth credential. The organization name is
1201
+ * appended because one account (same email) can hold one credential per
1202
+ * organization.
1203
+ */
1204
+ export function formatOAuthIdentityLabel(
1205
+ credential: Pick<OAuthCredentials, "email" | "accountId" | "projectId" | "organizationName">,
1206
+ ): string | null {
1207
+ const base = credential.email ?? credential.accountId ?? credential.projectId ?? null;
1208
+ const organization = credential.organizationName?.trim();
1209
+ if (!organization) return base;
1210
+ return base ? `${base} (${organization})` : organization;
1211
+ }
1212
+
1213
+ type OAuthResolutionResult = { apiKey: string; credential: OAuthCredential; credentialRowId: number };
1140
1214
 
1141
1215
  /**
1142
1216
  * Refreshed OAuth access plus identity metadata returned by
@@ -1296,6 +1370,8 @@ function oauthNonTokenFieldsEqual(left: OAuthCredential, right: OAuthCredential)
1296
1370
  left.projectId === right.projectId &&
1297
1371
  left.enterpriseUrl === right.enterpriseUrl &&
1298
1372
  left.profileArn === right.profileArn &&
1373
+ left.organizationId === right.organizationId &&
1374
+ left.organizationName === right.organizationName &&
1299
1375
  left.mcpBinding?.resourceOrigin === right.mcpBinding?.resourceOrigin &&
1300
1376
  left.mcpBinding?.tokenEndpoint === right.mcpBinding?.tokenEndpoint
1301
1377
  );
@@ -1345,6 +1421,7 @@ function resolveMemoizedOAuthIdentityKey(provider: string, credential: OAuthCred
1345
1421
  credential.projectId ?? "",
1346
1422
  credential.enterpriseUrl ?? "",
1347
1423
  credential.profileArn ?? "",
1424
+ credential.organizationId ?? "",
1348
1425
  credential.access,
1349
1426
  credential.refresh,
1350
1427
  ].join("\u0000");
@@ -1448,6 +1525,15 @@ type ConfigApiKeyOwnerFork = {
1448
1525
  * Reads from storage on reload(), manages round-robin credential selection,
1449
1526
  * usage limit tracking, and OAuth token refresh.
1450
1527
  */
1528
+ /** Tier names that denote a model family rather than a plan or quota tier. */
1529
+ const MODEL_FAMILY_TIERS: ReadonlySet<string> = new Set(["opus", "sonnet", "haiku"]);
1530
+
1531
+ /** A recorded usage-limit backoff; `modelId` scopes it to one model or model family. */
1532
+ interface CredentialBackoffEntry {
1533
+ untilMs: number;
1534
+ modelId?: string;
1535
+ }
1536
+
1451
1537
  export class AuthStorage {
1452
1538
  static readonly #defaultBackoffMs = 60_000; // Default backoff when no reset time available
1453
1539
 
@@ -1472,6 +1558,10 @@ export class AuthStorage {
1472
1558
  #runtimeCredentialSelectors: Map<string, AuthCredentialSelector> = new Map();
1473
1559
  /** Soft runtime credential preference per provider; quota failures may rotate away from it. */
1474
1560
  #runtimePreferredCredentialSelectors: Map<string, AuthCredentialSelector> = new Map();
1561
+ /** Persisted ordered soft credential preferences per provider; usage caps and quota failures move past entries. */
1562
+ #configuredCredentialPreferences: Map<string, readonly ConfiguredCredentialPreference[]> = new Map();
1563
+ /** Providers already warned that their preference list names one stored row twice. */
1564
+ #warnedDuplicateCredentialPreferences = new Set<string>();
1475
1565
  /** Credential selectors explicitly attached to a credential session scope. */
1476
1566
  #sessionCredentialSelectors: Map<string, Map<string, AuthCredentialSelector>> = new Map();
1477
1567
  /** Explicit AUTO masks suppress both scoped and process-global selectors for a scope/provider. */
@@ -1485,7 +1575,7 @@ export class AuthStorage {
1485
1575
  /** Tracks the last used credential per provider for a session (used for rate-limit switching). */
1486
1576
  #sessionLastCredential: Map<string, Map<string, { type: AuthCredential["type"]; index: number }>> = new Map();
1487
1577
  /** Maps provider:type -> credentialIndex -> blockedUntilMs for temporary backoff. */
1488
- #credentialBackoff: Map<string, Map<number, number>> = new Map();
1578
+ #credentialBackoff: Map<string, Map<number, CredentialBackoffEntry[]>> = new Map();
1489
1579
  #usageProviderResolver?: (provider: Provider) => UsageProvider | undefined;
1490
1580
  #rankingStrategyResolver?: (provider: Provider) => CredentialRankingStrategy | undefined;
1491
1581
  #usageCache: UsageCache;
@@ -1562,6 +1652,11 @@ export class AuthStorage {
1562
1652
  : DEFAULT_USAGE_REQUEST_TIMEOUT_MS;
1563
1653
  this.#usageFetchLeaseMs = usageTimeoutForLease + USAGE_FETCH_LEASE_GRACE_MS;
1564
1654
  this.#credentialRankingMode = options.credentialRankingMode ?? "balanced";
1655
+ for (const [provider, preferences] of Object.entries(options.credentialPreferences ?? {})) {
1656
+ if (preferences.length > 0) {
1657
+ this.#configuredCredentialPreferences.set(resolveOAuthStorageProvider(provider), preferences);
1658
+ }
1659
+ }
1565
1660
  this.#refreshOAuthCredentialOverride = options.refreshOAuthCredential;
1566
1661
  this.#fetchUsageReportsOverride = options.fetchUsageReports;
1567
1662
  this.#fetchUsageReportsForProviderOverride = options.fetchUsageReportsForProvider;
@@ -1693,10 +1788,19 @@ export class AuthStorage {
1693
1788
  #hasConfigOverride(provider: string, owner?: object): boolean {
1694
1789
  return this.#configOverrideRegistration(provider, owner) !== undefined;
1695
1790
  }
1696
- getProviderEvidenceGeneration(provider: string, resolvedApiKey?: string, owner?: object): string {
1791
+ /**
1792
+ * Return provider-specific discovery evidence. `stagedConfigApiKey` lets a read-only
1793
+ * config candidate derive the same fingerprint before its owner-scoped override is published.
1794
+ */
1795
+ getProviderEvidenceGeneration(
1796
+ provider: string,
1797
+ resolvedApiKey?: string,
1798
+ owner?: object,
1799
+ stagedConfigApiKey?: { apiKey: string },
1800
+ ): string {
1697
1801
  const storageProvider = resolveOAuthStorageProvider(provider);
1698
1802
  provider = storageProvider;
1699
- const configOverride = this.#configOverrideRegistration(storageProvider, owner);
1803
+ const configOverride = stagedConfigApiKey ?? this.#configOverrideRegistration(storageProvider, owner);
1700
1804
  const ownerGeneration = owner ? this.#getOwnerProviderGeneration(owner, storageProvider) : 0;
1701
1805
  const ownerFallbackGeneration = owner ? this.#getOwnerFallbackGeneration(owner) : this.#fallbackGeneration;
1702
1806
  const generation = owner
@@ -2087,8 +2191,9 @@ export class AuthStorage {
2087
2191
  );
2088
2192
  }
2089
2193
  const allRows = this.#store.listCredentialInventory?.(storageProvider) ?? [];
2090
- const matchingCredentials = this.#getStoredCredentials(storageProvider).filter(entry =>
2091
- this.#credentialMatchesSelector(entry, selector),
2194
+ const matchingCredentials = this.#entriesDesignatedBySelector(
2195
+ this.#getStoredCredentials(storageProvider),
2196
+ selector,
2092
2197
  );
2093
2198
  const matchingIds = new Set(matchingCredentials.map(entry => entry.id));
2094
2199
  const matchingRows = allRows.filter(
@@ -2138,7 +2243,7 @@ export class AuthStorage {
2138
2243
  "ambiguous",
2139
2244
  storageProvider,
2140
2245
  selector,
2141
- `Selector ${this.#formatCredentialSelector(selector)} matches multiple OAuth credentials; choose id:<row-id>`,
2246
+ `Selector ${this.#formatCredentialSelector(selector)} matches multiple OAuth credentials (ids: ${matchingRows.map(row => row.id).join(", ")}); choose one with id:<row-id>`,
2142
2247
  matchingRows.map(row => row.id),
2143
2248
  );
2144
2249
  }
@@ -2185,10 +2290,9 @@ export class AuthStorage {
2185
2290
  this.#usageCache.deletePrefix?.("reports:");
2186
2291
  for (const [scopeId, selectors] of this.#sessionCredentialSelectors) {
2187
2292
  const selector = selectors.get(storageProvider);
2188
- const selected = selector
2189
- ? previousEntries.find(entry => this.#credentialMatchesSelector(entry, selector))
2190
- : undefined;
2191
- if (selected && removed.has(selected.id)) this.clearSessionCredentialSelector(storageProvider, scopeId);
2293
+ const selected = selector ? this.#entriesSelectedBySelector(previousEntries, selector) : [];
2294
+ if (selected.some(entry => removed.has(entry.id)))
2295
+ this.clearSessionCredentialSelector(storageProvider, scopeId);
2192
2296
  }
2193
2297
  for (const [sessionId, sticky] of this.#sessionLastCredential.get(storageProvider) ?? []) {
2194
2298
  if (removed.has(previousEntries[sticky.index]?.id ?? -1))
@@ -2308,6 +2412,19 @@ export class AuthStorage {
2308
2412
  return this.#getCredentialSelector(provider, undefined, sessionId) !== undefined;
2309
2413
  }
2310
2414
 
2415
+ /** Whether any runtime/session scope has a credential pin, including an unavailable hard pin. */
2416
+ hasAnyCredentialPin(provider: string): boolean {
2417
+ const storageProvider = resolveOAuthStorageProvider(provider);
2418
+ if (this.#runtimeCredentialSelectors.has(storageProvider)) return true;
2419
+ for (const selectors of this.#sessionCredentialSelectors.values()) {
2420
+ if (selectors.has(storageProvider)) return true;
2421
+ }
2422
+ for (const unavailable of this.#sessionCredentialUnavailable.values()) {
2423
+ if (unavailable.has(storageProvider)) return true;
2424
+ }
2425
+ return false;
2426
+ }
2427
+
2311
2428
  /**
2312
2429
  * Opaque stored row id of the credential this session is currently using.
2313
2430
  *
@@ -2327,6 +2444,82 @@ export class AuthStorage {
2327
2444
  return this.#getStoredCredentials(storageProvider)[session.index]?.id;
2328
2445
  }
2329
2446
 
2447
+ /** Return the stored OAuth row pinned by a credential selector without resolving tokens. */
2448
+ getSelectedOAuthCredentialRowId(
2449
+ provider: string,
2450
+ sessionId?: string,
2451
+ options?: Pick<AuthApiKeyOptions, "owner">,
2452
+ ): number | undefined {
2453
+ const storageProvider = resolveOAuthStorageProvider(provider);
2454
+ try {
2455
+ const selected = this.#resolveSelectedStoredCredential(storageProvider, options, sessionId);
2456
+ return selected?.credential.type === "oauth" ? selected.id : undefined;
2457
+ } catch {
2458
+ return undefined;
2459
+ }
2460
+ }
2461
+
2462
+ /** Return the usable OAuth row selected by a soft preferred-credential selector. */
2463
+ getPreferredOAuthCredentialRowId(provider: string): number | undefined {
2464
+ const preferredSelector = this.#getPreferredCredentialSelector(provider);
2465
+ if (!preferredSelector) return undefined;
2466
+ const selected = this.#findCredentialBySelector(provider, preferredSelector);
2467
+ if (selected?.credential.type !== "oauth" || !this.isCredentialAvailable(provider, selected.id)) return undefined;
2468
+ return selected.id;
2469
+ }
2470
+
2471
+ /** Return the stored API-key row pinned by a credential selector without resolving values. */
2472
+ getSelectedApiKeyCredentialRowId(
2473
+ provider: string,
2474
+ sessionId?: string,
2475
+ options?: Pick<AuthApiKeyOptions, "owner">,
2476
+ ): number | undefined {
2477
+ const storageProvider = resolveOAuthStorageProvider(provider);
2478
+ try {
2479
+ const selected = this.#resolveSelectedStoredCredential(storageProvider, options, sessionId);
2480
+ return selected?.credential.type === "api_key" ? selected.id : undefined;
2481
+ } catch {
2482
+ return undefined;
2483
+ }
2484
+ }
2485
+
2486
+ /**
2487
+ * Preview the stored API-key row request dispatch would try for this session.
2488
+ * Requires a session id so the deterministic session order is used instead
2489
+ * of advancing the provider's round-robin cursor. Does not resolve key values
2490
+ * or mutate session selection.
2491
+ */
2492
+ getProspectiveApiKeyCredentialRowId(
2493
+ provider: string,
2494
+ sessionId: string | undefined,
2495
+ options?: Pick<AuthApiKeyOptions, "owner">,
2496
+ ): number | undefined {
2497
+ if (!sessionId) return undefined;
2498
+ const storageProvider = resolveOAuthStorageProvider(provider);
2499
+ let selected: IndexedStoredCredential | undefined;
2500
+ try {
2501
+ selected = this.#resolveSelectedStoredCredential(storageProvider, options, sessionId);
2502
+ } catch {
2503
+ return undefined;
2504
+ }
2505
+ if (selected?.credential.type === "api_key") {
2506
+ const usable = this.#storedApiKeyUsability(storageProvider, selected.credential.key);
2507
+ return selected.credential.key.startsWith("!") && usable !== true ? undefined : selected.id;
2508
+ }
2509
+ const candidate = this.#selectApiKeyCredential(storageProvider, sessionId);
2510
+ if (!candidate) return undefined;
2511
+ const usable = this.#storedApiKeyUsability(storageProvider, candidate.credential.key);
2512
+ return candidate.credential.key.startsWith("!") && usable !== true ? undefined : candidate.id;
2513
+ }
2514
+
2515
+ /** Cached usability of a stored API key; never resolves command-backed values. */
2516
+ getStoredApiKeyUsability(provider: string, rowId: number): boolean | undefined {
2517
+ const storageProvider = resolveOAuthStorageProvider(provider);
2518
+ const entry = this.#getStoredCredentials(storageProvider).find(candidate => candidate.id === rowId);
2519
+ if (entry?.credential.type !== "api_key") return undefined;
2520
+ return this.#storedApiKeyUsability(storageProvider, entry.credential.key);
2521
+ }
2522
+
2330
2523
  /** Whether an enabled stored row is currently available outside quota backoff. */
2331
2524
  isCredentialAvailable(provider: string, rowId: number): boolean {
2332
2525
  const storageProvider = resolveOAuthStorageProvider(provider);
@@ -2782,28 +2975,30 @@ export class AuthStorage {
2782
2975
  return deduped.reverse();
2783
2976
  }
2784
2977
 
2785
- #pruneDuplicateStoredCredentials(provider: string, entries: StoredCredential[]): StoredCredential[] {
2786
- const seen = new Set<string>();
2787
- const kept: StoredCredential[] = [];
2788
- const removed: StoredCredential[] = [];
2978
+ #pruneDuplicateStoredCredentials(
2979
+ provider: string,
2980
+ entries: StoredCredential[],
2981
+ preferredCredentialId?: number,
2982
+ ): StoredCredential[] {
2983
+ const selectedByIdentity = new Map<string, number>();
2789
2984
  for (let index = entries.length - 1; index >= 0; index -= 1) {
2790
2985
  const entry = entries[index];
2791
- const credential = entry.credential;
2792
- if (credential.type !== "oauth") {
2793
- kept.push(entry);
2794
- continue;
2986
+ if (entry.credential.type !== "oauth") continue;
2987
+ const identityKey = this.#resolveOAuthDedupeIdentityKey(provider, entry.credential);
2988
+ if (!identityKey) continue;
2989
+ if (!selectedByIdentity.has(identityKey) || entry.id === preferredCredentialId) {
2990
+ selectedByIdentity.set(identityKey, entry.id);
2795
2991
  }
2796
- const identityKey = this.#resolveOAuthDedupeIdentityKey(provider, credential);
2797
- if (!identityKey) {
2798
- kept.push(entry);
2799
- continue;
2800
- }
2801
- if (seen.has(identityKey)) {
2802
- removed.push(entry);
2803
- continue;
2804
- }
2805
- seen.add(identityKey);
2806
- kept.push(entry);
2992
+ }
2993
+
2994
+ const selectedIds = new Set(selectedByIdentity.values());
2995
+ const kept: StoredCredential[] = [];
2996
+ const removed: StoredCredential[] = [];
2997
+ for (const entry of entries) {
2998
+ const identityKey =
2999
+ entry.credential.type === "oauth" ? this.#resolveOAuthDedupeIdentityKey(provider, entry.credential) : null;
3000
+ if (identityKey && !selectedIds.has(entry.id)) removed.push(entry);
3001
+ else kept.push(entry);
2807
3002
  }
2808
3003
  if (removed.length > 0) {
2809
3004
  for (const entry of removed) {
@@ -2812,7 +3007,7 @@ export class AuthStorage {
2812
3007
  this.#clearSelectorsForRemovedCredential(provider, new Set(removed.map(entry => entry.id)), entries);
2813
3008
  this.#resetProviderAssignments(provider);
2814
3009
  }
2815
- return kept.reverse();
3010
+ return kept;
2816
3011
  }
2817
3012
 
2818
3013
  /** Returns all credentials for a provider as an array */
@@ -2864,32 +3059,60 @@ export class AuthStorage {
2864
3059
  return order;
2865
3060
  }
2866
3061
 
2867
- /** Returns block expiry timestamp for a credential, cleaning up expired entries. */
2868
- #getCredentialBlockedUntil(providerKey: string, credentialIndex: number): number | undefined {
3062
+ /**
3063
+ * Returns the block expiry for a credential, cleaning up expired entries.
3064
+ * A block recorded for one model only applies to that model; a block without a
3065
+ * model applies to the whole credential. `modelId` is the requested model, so
3066
+ * omitting it asks only about credential-wide blocks.
3067
+ */
3068
+ #getCredentialBlockedUntil(providerKey: string, credentialIndex: number, modelId?: string): number | undefined {
2869
3069
  const backoffMap = this.#credentialBackoff.get(providerKey);
2870
3070
  if (!backoffMap) return undefined;
2871
- const blockedUntil = backoffMap.get(credentialIndex);
2872
- if (!blockedUntil) return undefined;
2873
- if (blockedUntil <= Date.now()) {
2874
- backoffMap.delete(credentialIndex);
2875
- if (backoffMap.size === 0) {
2876
- this.#credentialBackoff.delete(providerKey);
2877
- }
2878
- return undefined;
3071
+ const entries = backoffMap.get(credentialIndex);
3072
+ if (!entries || entries.length === 0) return undefined;
3073
+ const now = Date.now();
3074
+ const live = entries.filter(entry => entry.untilMs > now);
3075
+ if (live.length !== entries.length) {
3076
+ if (live.length > 0) backoffMap.set(credentialIndex, live);
3077
+ else backoffMap.delete(credentialIndex);
3078
+ if (backoffMap.size === 0) this.#credentialBackoff.delete(providerKey);
3079
+ }
3080
+ let blockedUntil: number | undefined;
3081
+ const wanted = modelId?.trim().toLowerCase();
3082
+ // A recorded scope may name a model or a model-family tier, so match a tier
3083
+ // against the requested model id the same way limit scoping does.
3084
+ const entryApplies = (entryModel: string): boolean => {
3085
+ if (wanted === undefined) return false;
3086
+ const scope = entryModel.toLowerCase();
3087
+ if (scope === wanted) return true;
3088
+ return MODEL_FAMILY_TIERS.has(scope) && wanted.includes(scope);
3089
+ };
3090
+ for (const entry of live) {
3091
+ if (entry.modelId !== undefined && !entryApplies(entry.modelId)) continue;
3092
+ if (blockedUntil === undefined || entry.untilMs > blockedUntil) blockedUntil = entry.untilMs;
2879
3093
  }
2880
3094
  return blockedUntil;
2881
3095
  }
2882
3096
 
2883
3097
  /** Checks if a credential is temporarily blocked due to usage limits. */
2884
- #isCredentialBlocked(providerKey: string, credentialIndex: number): boolean {
2885
- return this.#getCredentialBlockedUntil(providerKey, credentialIndex) !== undefined;
3098
+ #isCredentialBlocked(providerKey: string, credentialIndex: number, modelId?: string): boolean {
3099
+ return this.#getCredentialBlockedUntil(providerKey, credentialIndex, modelId) !== undefined;
2886
3100
  }
2887
3101
 
2888
- /** Marks a credential as blocked until the specified time. */
2889
- #markCredentialBlocked(providerKey: string, credentialIndex: number, blockedUntilMs: number): void {
2890
- const backoffMap = this.#credentialBackoff.get(providerKey) ?? new Map<number, number>();
2891
- const existing = backoffMap.get(credentialIndex) ?? 0;
2892
- backoffMap.set(credentialIndex, Math.max(existing, blockedUntilMs));
3102
+ /** Marks a credential as blocked until the specified time, optionally for one model. */
3103
+ #markCredentialBlocked(
3104
+ providerKey: string,
3105
+ credentialIndex: number,
3106
+ blockedUntilMs: number,
3107
+ modelId?: string,
3108
+ ): void {
3109
+ const scoped = modelId?.trim() || undefined;
3110
+ const backoffMap = this.#credentialBackoff.get(providerKey) ?? new Map<number, CredentialBackoffEntry[]>();
3111
+ const entries = backoffMap.get(credentialIndex) ?? [];
3112
+ const existing = entries.find(entry => entry.modelId === scoped);
3113
+ if (existing) existing.untilMs = Math.max(existing.untilMs, blockedUntilMs);
3114
+ else entries.push({ untilMs: blockedUntilMs, modelId: scoped });
3115
+ backoffMap.set(credentialIndex, entries);
2893
3116
  this.#credentialBackoff.set(providerKey, backoffMap);
2894
3117
  }
2895
3118
 
@@ -2949,16 +3172,60 @@ export class AuthStorage {
2949
3172
  return entry.credential.type === "oauth" && entry.credential.accountId === selector.value;
2950
3173
  case "project":
2951
3174
  return entry.credential.type === "oauth" && entry.credential.projectId === selector.value;
3175
+ case "org":
3176
+ return (
3177
+ entry.credential.type === "oauth" && oauthCredentialMatchesOrganization(entry.credential, selector.value)
3178
+ );
2952
3179
  }
2953
3180
  }
2954
3181
 
2955
3182
  #findCredentialBySelector(provider: string, selector: AuthCredentialSelector): IndexedStoredCredential | undefined {
2956
3183
  const stored = this.#getStoredCredentials(provider);
2957
- for (let index = 0; index < stored.length; index++) {
2958
- const entry = stored[index];
2959
- if (entry && this.#credentialMatchesSelector(entry, selector)) return { ...entry, index };
3184
+ const designated = this.#entriesDesignatedBySelector(stored, selector);
3185
+ // Organization names are not unique, so an organization selector naming several rows is rejected rather than
3186
+ // resolved to the first. Email/account/project selectors keep resolving to the first row: pins persisted in
3187
+ // config and session history use those kinds, and they are not organization-aware.
3188
+ if (selector.kind === "org" && designated.length > 1) {
3189
+ const ids = designated.map(entry => entry.id);
3190
+ throw new OAuthCredentialSelectorError(
3191
+ "ambiguous",
3192
+ resolveOAuthStorageProvider(provider),
3193
+ selector,
3194
+ `Selector ${this.#formatCredentialSelector(selector)} matches multiple stored credentials for ${provider} (ids: ${ids.join(", ")}); choose one with id:<row-id>`,
3195
+ ids,
3196
+ );
2960
3197
  }
2961
- return undefined;
3198
+ const [entry] = designated;
3199
+ return entry ? { ...entry, index: stored.indexOf(entry) } : undefined;
3200
+ }
3201
+
3202
+ /**
3203
+ * Rows whose removal invalidates a selector, mirroring {@link AuthStorage.#findCredentialBySelector}: the row an
3204
+ * email/account/project/id selector resolves to (the first match), or every row an `org:` selector designates,
3205
+ * because removing one of several designated organizations would otherwise retarget the selector to another.
3206
+ */
3207
+ #entriesSelectedBySelector<T extends StoredCredential>(
3208
+ entries: readonly T[],
3209
+ selector: AuthCredentialSelector,
3210
+ ): T[] {
3211
+ const designated = this.#entriesDesignatedBySelector(entries, selector);
3212
+ return selector.kind === "org" ? designated : designated.slice(0, 1);
3213
+ }
3214
+
3215
+ /**
3216
+ * Every stored row a selector designates. An `org:` value prefers an exact organization-id match over
3217
+ * display-name matches; more than one designated row means the selector is ambiguous.
3218
+ */
3219
+ #entriesDesignatedBySelector<T extends StoredCredential>(
3220
+ entries: readonly T[],
3221
+ selector: AuthCredentialSelector,
3222
+ ): T[] {
3223
+ if (selector.kind === "org") {
3224
+ return selectOrganizationMatches(entries, selector.value, entry =>
3225
+ entry.credential.type === "oauth" ? entry.credential : undefined,
3226
+ );
3227
+ }
3228
+ return entries.filter(entry => this.#credentialMatchesSelector(entry, selector));
2962
3229
  }
2963
3230
 
2964
3231
  #credentialMatchesInventorySelector(row: CredentialInventoryRecord, selector: AuthCredentialSelector): boolean {
@@ -2967,10 +3234,13 @@ export class AuthStorage {
2967
3234
  case "id":
2968
3235
  return String(row.id) === selector.value;
2969
3236
  case "email":
2970
- return row.identityLabel?.toLowerCase() === selector.value.toLowerCase();
3237
+ return (row.email ?? row.identityLabel)?.toLowerCase() === selector.value.toLowerCase();
2971
3238
  case "account":
3239
+ return (row.accountId ?? row.identityLabel) === selector.value;
2972
3240
  case "project":
2973
- return row.identityLabel === selector.value;
3241
+ return (row.projectId ?? row.identityLabel) === selector.value;
3242
+ case "org":
3243
+ return oauthCredentialMatchesOrganization(row, selector.value);
2974
3244
  }
2975
3245
  }
2976
3246
  #getCredentialSelector(
@@ -2995,18 +3265,152 @@ export class AuthStorage {
2995
3265
  );
2996
3266
  }
2997
3267
 
3268
+ /**
3269
+ * Providers whose configured preference list names one stored credential row through more than one entry, for
3270
+ * example `email:` and `account:` selectors for the same login. Startup rejects such a list.
3271
+ */
3272
+ findDuplicateCredentialPreferences(): Array<{ provider: string; credentialId: number }> {
3273
+ const duplicates: Array<{ provider: string; credentialId: number }> = [];
3274
+ for (const [provider, preferences] of this.#configuredCredentialPreferences) {
3275
+ const credentialId = this.#duplicateConfiguredPreferenceRow(provider, preferences);
3276
+ if (credentialId !== undefined) duplicates.push({ provider, credentialId });
3277
+ }
3278
+ return duplicates;
3279
+ }
3280
+
3281
+ /** Id of the first stored row that two entries of `preferences` both designate; undefined when every entry differs. */
3282
+ #duplicateConfiguredPreferenceRow(
3283
+ provider: string,
3284
+ preferences: readonly ConfiguredCredentialPreference[],
3285
+ ): number | undefined {
3286
+ const designatedIds = new Set<number>();
3287
+ for (const { selector } of preferences) {
3288
+ const credentialId = this.#findCredentialBySelector(provider, selector)?.id;
3289
+ if (credentialId === undefined) continue;
3290
+ if (designatedIds.has(credentialId)) return credentialId;
3291
+ designatedIds.add(credentialId);
3292
+ }
3293
+ return undefined;
3294
+ }
3295
+
3296
+ /**
3297
+ * Reorder `candidates` by the provider's configured preference list: usable entries under their cap first, in list
3298
+ * order; then the remaining candidates in their ranked order; then entries at or over their cap, in list order.
3299
+ * Missing and blocked entries are skipped and keep their ranked position. A list that names one stored row twice
3300
+ * (startup rejects it, but a later login can make two entries resolve to one row) is ignored as a whole rather than
3301
+ * applying one entry's position and cap and dropping the other's.
3302
+ */
3303
+ async #applyConfiguredCredentialPreferences(
3304
+ provider: string,
3305
+ providerKey: string,
3306
+ candidates: Array<{
3307
+ selection: OAuthCredentialSelection;
3308
+ usage: UsageReport | null;
3309
+ usageChecked: boolean;
3310
+ }>,
3311
+ strategy: CredentialRankingStrategy | undefined,
3312
+ options?: AuthApiKeyOptions,
3313
+ ): Promise<void> {
3314
+ const preferences = this.#configuredCredentialPreferences.get(resolveOAuthStorageProvider(provider));
3315
+ if (!preferences) return;
3316
+ const duplicateCredentialId = this.#duplicateConfiguredPreferenceRow(provider, preferences);
3317
+ if (duplicateCredentialId !== undefined) {
3318
+ const storageProvider = resolveOAuthStorageProvider(provider);
3319
+ if (!this.#warnedDuplicateCredentialPreferences.has(storageProvider)) {
3320
+ this.#warnedDuplicateCredentialPreferences.add(storageProvider);
3321
+ logger.warn("Configured credential preferences name one account twice; using normal ranking", {
3322
+ provider: storageProvider,
3323
+ credentialId: duplicateCredentialId,
3324
+ });
3325
+ }
3326
+ return;
3327
+ }
3328
+ type Candidate = (typeof candidates)[number];
3329
+ const matched: Array<{ candidate: Candidate; maxUsedFraction: number | undefined }> = [];
3330
+ const claimed = new Set<Candidate>();
3331
+ for (const preference of preferences) {
3332
+ const position = this.#findUsableOAuthCandidate(provider, providerKey, candidates, preference.selector);
3333
+ if (position < 0) continue;
3334
+ const candidate = candidates[position]!;
3335
+ claimed.add(candidate);
3336
+ matched.push({ candidate, maxUsedFraction: preference.maxUsedFraction });
3337
+ }
3338
+ if (matched.length === 0) return;
3339
+ // Ranking already fetched usage for strategy-backed providers; fetch only what a cap still needs.
3340
+ await Promise.all(
3341
+ matched.map(async ({ candidate, maxUsedFraction }) => {
3342
+ if (maxUsedFraction === undefined || candidate.usageChecked) return;
3343
+ candidate.usage = await this.#getUsageReport(provider, candidate.selection.credential, {
3344
+ baseUrl: options?.baseUrl,
3345
+ timeoutMs: this.#usageRequestTimeoutMs,
3346
+ signal: options?.signal,
3347
+ });
3348
+ candidate.usageChecked = true;
3349
+ }),
3350
+ );
3351
+ const preferred: Candidate[] = [];
3352
+ const capped: Candidate[] = [];
3353
+ for (const { candidate, maxUsedFraction } of matched) {
3354
+ const usedFraction =
3355
+ maxUsedFraction === undefined ? undefined : this.#maxKnownUsedFraction(candidate.usage, strategy);
3356
+ if (maxUsedFraction !== undefined && usedFraction !== undefined && usedFraction >= maxUsedFraction) {
3357
+ capped.push(candidate);
3358
+ } else {
3359
+ preferred.push(candidate);
3360
+ }
3361
+ }
3362
+ const rest = candidates.filter(candidate => !claimed.has(candidate));
3363
+ candidates.splice(0, candidates.length, ...preferred, ...rest, ...capped);
3364
+ }
3365
+
3366
+ /**
3367
+ * Position in `candidates` of the OAuth row `selector` designates, or -1 when it is missing, not OAuth, or blocked.
3368
+ * Candidates are matched by stored row id: `#findCredentialBySelector` indexes the full provider list (API-key rows
3369
+ * included) while candidate selections and the OAuth backoff map use OAuth-only indices.
3370
+ */
3371
+ #findUsableOAuthCandidate(
3372
+ provider: string,
3373
+ providerKey: string,
3374
+ candidates: ReadonlyArray<{ selection: OAuthCredentialSelection }>,
3375
+ selector: AuthCredentialSelector,
3376
+ ): number {
3377
+ const designated = this.#findCredentialBySelector(provider, selector);
3378
+ if (designated?.credential.type !== "oauth") return -1;
3379
+ const position = candidates.findIndex(candidate => candidate.selection.id === designated.id);
3380
+ if (position < 0) return -1;
3381
+ return this.#isCredentialBlocked(providerKey, candidates[position]!.selection.index) ? -1 : position;
3382
+ }
3383
+
3384
+ /** Highest reported used fraction across known usage windows; undefined when no window reports one. */
3385
+ #maxKnownUsedFraction(
3386
+ usage: UsageReport | null,
3387
+ strategy: CredentialRankingStrategy | undefined,
3388
+ ): number | undefined {
3389
+ if (!usage) return undefined;
3390
+ const limits = strategy ? Object.values(strategy.findWindowLimits(usage)) : usage.limits;
3391
+ let maxUsed: number | undefined;
3392
+ for (const limit of limits) {
3393
+ const usedFraction = limit?.amount.usedFraction;
3394
+ if (typeof usedFraction !== "number" || !Number.isFinite(usedFraction)) continue;
3395
+ maxUsed = maxUsed === undefined ? usedFraction : Math.max(maxUsed, usedFraction);
3396
+ }
3397
+ return maxUsed;
3398
+ }
3399
+
2998
3400
  #assertCredentialSelectorUsable(provider: string, selector: AuthCredentialSelector, owner?: object): void {
2999
3401
  if (this.#runtimeOverrides.has(provider)) {
3000
3402
  throw new Error(
3001
3403
  `Credential selector ${this.#formatCredentialSelector(selector)} cannot be used for ${provider} while a runtime API key override is active`,
3002
3404
  );
3003
3405
  }
3004
- if (this.#hasConfigOverride(provider, owner)) {
3406
+ const selected = this.#findCredentialBySelector(provider, selector);
3407
+ const configOverride = this.#configOverrideRegistration(provider, owner);
3408
+ if (configOverride && !(configOverride.envSourced && selected?.credential.type === "api_key")) {
3005
3409
  throw new Error(
3006
3410
  `Credential selector ${this.#formatCredentialSelector(selector)} cannot be used for ${provider} while a config API key override is active`,
3007
3411
  );
3008
3412
  }
3009
- if (!this.#findCredentialBySelector(provider, selector)) {
3413
+ if (!selected) {
3010
3414
  throw new Error(`No credential found for ${provider} matching ${this.#formatCredentialSelector(selector)}`);
3011
3415
  }
3012
3416
  }
@@ -3160,7 +3564,7 @@ export class AuthStorage {
3160
3564
  const updated = [...entries];
3161
3565
  updated[index] = { id: target.id, credential };
3162
3566
  const configurationGeneration = this.#getProviderConfigurationGeneration(provider);
3163
- this.#setStoredCredentials(provider, updated);
3567
+ this.#setStoredCredentials(provider, this.#pruneDuplicateStoredCredentials(provider, updated, target.id));
3164
3568
  // Callers use the refresh generation to discount configuration bumps caused by a refresh, so it only
3165
3569
  // advances when this refresh bumped configuration; token rotation for a known account does not.
3166
3570
  if (
@@ -3279,8 +3683,8 @@ export class AuthStorage {
3279
3683
  for (const [scopeId, selectors] of this.#sessionCredentialSelectors) {
3280
3684
  const selector = selectors.get(storageProvider);
3281
3685
  if (!selector) continue;
3282
- const selected = previousEntries.find(entry => this.#credentialMatchesSelector(entry, selector));
3283
- if (selected && removedIds.has(selected.id)) {
3686
+ const selected = this.#entriesSelectedBySelector(previousEntries, selector);
3687
+ if (selected.some(entry => removedIds.has(entry.id))) {
3284
3688
  this.markSessionCredentialUnavailable(scopeId, storageProvider, selector);
3285
3689
  }
3286
3690
  }
@@ -3510,16 +3914,12 @@ export class AuthStorage {
3510
3914
  getEffectiveCredentialType(
3511
3915
  provider: string,
3512
3916
  sessionId?: string,
3513
- options?: Pick<AuthApiKeyOptions, "owner">,
3917
+ options?: Pick<AuthApiKeyOptions, "owner" | "credentialSelector">,
3514
3918
  ): AuthCredential["type"] | undefined {
3515
3919
  const storageProvider = resolveOAuthStorageProvider(provider);
3516
3920
  let selected: ({ index: number } & StoredCredential) | undefined;
3517
3921
  try {
3518
- selected = this.#resolveSelectedStoredCredential(
3519
- storageProvider,
3520
- options?.owner ? { owner: options.owner } : undefined,
3521
- sessionId,
3522
- );
3922
+ selected = this.#resolveSelectedStoredCredential(storageProvider, options, sessionId);
3523
3923
  } catch {
3524
3924
  return undefined;
3525
3925
  }
@@ -3541,17 +3941,14 @@ export class AuthStorage {
3541
3941
  return undefined;
3542
3942
  }
3543
3943
 
3544
- /**
3545
- * Check whether configured auth is currently usable without resolving credentials.
3546
- */
3547
- hasUsableAuth(provider: string, options?: Pick<AuthApiKeyOptions, "owner">): boolean {
3944
+ /** Check whether configured auth is usable without resolving credentials or refreshing OAuth. */
3945
+ hasUsableAuth(
3946
+ provider: string,
3947
+ options?: Pick<AuthApiKeyOptions, "owner" | "credentialSelector"> & { sessionId?: string },
3948
+ ): boolean {
3548
3949
  const storageProvider = resolveOAuthStorageProvider(provider);
3549
3950
  try {
3550
- const selectedCredential = this.#resolveSelectedStoredCredential(
3551
- storageProvider,
3552
- options?.owner ? { owner: options.owner } : undefined,
3553
- undefined,
3554
- );
3951
+ const selectedCredential = this.#resolveSelectedStoredCredential(storageProvider, options, options?.sessionId);
3555
3952
  if (this.hasRuntimeApiKey(storageProvider)) return true;
3556
3953
  if (this.#hasConfigOverride(storageProvider, options?.owner)) return true;
3557
3954
  if (selectedCredential) {
@@ -4133,6 +4530,8 @@ export class AuthStorage {
4133
4530
  email: credential.email,
4134
4531
  enterpriseUrl: credential.enterpriseUrl,
4135
4532
  profileArn: credential.profileArn,
4533
+ organizationId: credential.organizationId,
4534
+ organizationName: credential.organizationName,
4136
4535
  mcpBinding: credential.mcpBinding,
4137
4536
  };
4138
4537
  }
@@ -4149,6 +4548,9 @@ export class AuthStorage {
4149
4548
  if (enterpriseUrl) parts.push(`enterprise:${enterpriseUrl}`);
4150
4549
  const profileArn = credential.profileArn?.trim();
4151
4550
  if (profileArn) parts.push(`profile:${profileArn}`);
4551
+ // One account can hold a credential per organization, each with its own limits.
4552
+ const organizationId = credential.organizationId?.trim();
4553
+ if (organizationId) parts.push(`org:${organizationId}`);
4152
4554
  // Only fall back to a secret-derived key when stable credential identity is unavailable.
4153
4555
  // Including the token hash when accountId/email/profileArn are present causes cache misses on
4154
4556
  // every OAuth refresh — usage data is per-account, not per-token.
@@ -4211,6 +4613,8 @@ export class AuthStorage {
4211
4613
  email: credential.email,
4212
4614
  enterpriseUrl: credential.enterpriseUrl,
4213
4615
  profileArn: credential.profileArn,
4616
+ organizationId: credential.organizationId,
4617
+ organizationName: credential.organizationName,
4214
4618
  mcpBinding: credential.mcpBinding,
4215
4619
  };
4216
4620
  }
@@ -4226,6 +4630,8 @@ export class AuthStorage {
4226
4630
  email: refreshed.email ?? credential.email,
4227
4631
  enterpriseUrl: refreshed.enterpriseUrl ?? credential.enterpriseUrl,
4228
4632
  profileArn: refreshed.profileArn ?? credential.profileArn,
4633
+ organizationId: refreshed.organizationId ?? credential.organizationId,
4634
+ organizationName: refreshed.organizationName ?? credential.organizationName,
4229
4635
  mcpBinding: credential.mcpBinding,
4230
4636
  };
4231
4637
  }
@@ -4245,7 +4651,8 @@ export class AuthStorage {
4245
4651
  entry.credential.accountId === previous.accountId &&
4246
4652
  entry.credential.email === previous.email &&
4247
4653
  entry.credential.projectId === previous.projectId &&
4248
- entry.credential.profileArn === previous.profileArn
4654
+ entry.credential.profileArn === previous.profileArn &&
4655
+ entry.credential.organizationId === previous.organizationId
4249
4656
  );
4250
4657
  });
4251
4658
  return match?.id;
@@ -4266,7 +4673,8 @@ export class AuthStorage {
4266
4673
  entry.credential.accountId === previous.accountId &&
4267
4674
  entry.credential.email === previous.email &&
4268
4675
  entry.credential.projectId === previous.projectId &&
4269
- entry.credential.profileArn === previous.profileArn
4676
+ entry.credential.profileArn === previous.profileArn &&
4677
+ entry.credential.organizationId === previous.organizationId
4270
4678
  );
4271
4679
  });
4272
4680
  if (index === -1) return;
@@ -4285,6 +4693,8 @@ export class AuthStorage {
4285
4693
  email: next.email,
4286
4694
  enterpriseUrl: next.enterpriseUrl,
4287
4695
  profileArn: next.profileArn ?? existing.profileArn,
4696
+ organizationId: next.organizationId ?? existing.organizationId,
4697
+ organizationName: next.organizationName ?? existing.organizationName,
4288
4698
  mcpBinding: next.mcpBinding ?? existing.mcpBinding,
4289
4699
  },
4290
4700
  persist,
@@ -4650,6 +5060,9 @@ export class AuthStorage {
4650
5060
  const email = this.#getUsageReportMetadataValue(report, "email");
4651
5061
  if (email) identifiers.push(`email:${email.toLowerCase()}`);
4652
5062
  if (report.provider === "openai-codex" || report.provider === "anthropic") {
5063
+ // One Anthropic account can sign in to several organizations; each has its own limits.
5064
+ const orgId = report.provider === "anthropic" ? this.#getUsageReportMetadataValue(report, "orgId") : undefined;
5065
+ if (orgId) return identifiers.map(identifier => `${report.provider}:${identifier}|org:${orgId}`.toLowerCase());
4653
5066
  return identifiers.map(identifier => `${report.provider}:${identifier.toLowerCase()}`);
4654
5067
  }
4655
5068
  const accountId = this.#getUsageReportMetadataValue(report, "accountId");
@@ -4749,15 +5162,40 @@ export class AuthStorage {
4749
5162
  }
4750
5163
 
4751
5164
  /** Returns true if usage indicates rate limit has been reached. */
4752
- #isUsageLimitReached(report: UsageReport): boolean {
4753
- return report.limits.some(limit => this.#isUsageLimitExhausted(limit));
5165
+ /**
5166
+ * True when an exhausted limit applies to `modelId`. A limit scoped to one
5167
+ * model (or one tier's model entry) does not exhaust the credential for other
5168
+ * models; a limit with no model scope and not marked shared-blocking applies to
5169
+ * every model.
5170
+ */
5171
+ #isUsageLimitReached(report: UsageReport, modelId?: string): boolean {
5172
+ return report.limits.some(
5173
+ limit => this.#limitAppliesToModel(limit, modelId) && this.#isUsageLimitExhausted(limit),
5174
+ );
5175
+ }
5176
+
5177
+ /**
5178
+ * Whether a usage limit constrains the requested model. Only model-family
5179
+ * tiers are treated as model scoping: `scope.tier` also carries plans
5180
+ * (Codex/Copilot) and quota tiers (Gemini), which apply to every model.
5181
+ */
5182
+ #limitAppliesToModel(limit: UsageLimit, modelId?: string): boolean {
5183
+ const wanted = modelId?.trim();
5184
+ // Without a requested model, every limit applies: callers that do not know
5185
+ // the model keep today's conservative "any exhausted limit blocks".
5186
+ if (wanted === undefined) return true;
5187
+ const scopedModel = limit.scope?.modelId?.trim();
5188
+ if (scopedModel) return wanted === scopedModel;
5189
+ const tier = limit.scope?.tier?.trim().toLowerCase();
5190
+ if (tier && MODEL_FAMILY_TIERS.has(tier)) return wanted.toLowerCase().includes(tier);
5191
+ return true;
4754
5192
  }
4755
5193
 
4756
5194
  /** Extracts the earliest reset timestamp from exhausted windows (in ms). */
4757
- #getUsageResetAtMs(report: UsageReport, nowMs: number): number | undefined {
5195
+ #getUsageResetAtMs(report: UsageReport, nowMs: number, modelId?: string): number | undefined {
4758
5196
  const candidates: number[] = [];
4759
5197
  for (const limit of report.limits) {
4760
- if (!this.#isUsageLimitExhausted(limit)) continue;
5198
+ if (!this.#limitAppliesToModel(limit, modelId) || !this.#isUsageLimitExhausted(limit)) continue;
4761
5199
  const window = limit.window;
4762
5200
  if (window?.resetsAt && window.resetsAt > nowMs) {
4763
5201
  candidates.push(window.resetsAt);
@@ -4767,6 +5205,33 @@ export class AuthStorage {
4767
5205
  return Math.min(...candidates);
4768
5206
  }
4769
5207
 
5208
+ /**
5209
+ * Backoff targets implied by an exhausted usage report: a credential-wide
5210
+ * until for limits that are not model-scoped, plus one entry per exhausted
5211
+ * model-scoped limit. A model-scoped limit must not take the whole credential
5212
+ * out of rotation for other models.
5213
+ */
5214
+ #exhaustedLimitBlockTargets(
5215
+ report: UsageReport,
5216
+ nowMs: number,
5217
+ ): { wideUntil?: number; scoped: Array<{ modelId: string; untilMs: number }> } {
5218
+ let wideUntil: number | undefined;
5219
+ const scoped: Array<{ modelId: string; untilMs: number }> = [];
5220
+ for (const limit of report.limits) {
5221
+ if (!this.#isUsageLimitExhausted(limit)) continue;
5222
+ const resetsAt = limit.window?.resetsAt;
5223
+ if (!resetsAt || resetsAt <= nowMs) continue;
5224
+ const scopeModel = limit.scope?.modelId?.trim();
5225
+ const tier = limit.scope?.tier?.trim().toLowerCase();
5226
+ if (!scopeModel && !(tier && MODEL_FAMILY_TIERS.has(tier))) {
5227
+ wideUntil = wideUntil === undefined ? resetsAt : Math.min(wideUntil, resetsAt);
5228
+ continue;
5229
+ }
5230
+ scoped.push({ modelId: scopeModel ?? tier!, untilMs: resetsAt });
5231
+ }
5232
+ return wideUntil === undefined ? { scoped } : { wideUntil, scoped };
5233
+ }
5234
+
4770
5235
  async #getUsageReport(
4771
5236
  provider: Provider,
4772
5237
  credential: OAuthCredential,
@@ -5295,7 +5760,15 @@ export class AuthStorage {
5295
5760
  async markUsageLimitReached(
5296
5761
  provider: string,
5297
5762
  sessionId: string | undefined,
5298
- options?: { retryAfterMs?: number; baseUrl?: string; signal?: AbortSignal; owner?: object; rowId?: number },
5763
+ options?: {
5764
+ retryAfterMs?: number;
5765
+ baseUrl?: string;
5766
+ signal?: AbortSignal;
5767
+ owner?: object;
5768
+ rowId?: number;
5769
+ /** Scope the block to one model; other models keep using the credential. */
5770
+ modelId?: string;
5771
+ },
5299
5772
  ): Promise<UsageLimitMarkResult> {
5300
5773
  provider = resolveOAuthStorageProvider(provider);
5301
5774
  const ownerOverride = this.#configOverrideRegistration(provider, options?.owner);
@@ -5316,11 +5789,19 @@ export class AuthStorage {
5316
5789
  const now = Date.now();
5317
5790
  let blockedUntil = now + (options?.retryAfterMs ?? AuthStorage.#defaultBackoffMs);
5318
5791
 
5792
+ // Model-scoped exhausted limits only constrain their own model; an
5793
+ // unscoped or shared limit raises the credential-wide backoff.
5794
+ let scopedBlocks: Array<{ modelId: string; untilMs: number }> = [];
5795
+ let reportHasWideLimit = false;
5319
5796
  if (initial.credential.type === "oauth" && this.#rankingStrategyResolver?.(provider)) {
5320
5797
  const report = await this.#getUsageReport(provider, initial.credential, options);
5321
- if (report && this.#isUsageLimitReached(report)) {
5322
- const resetAtMs = this.#getUsageResetAtMs(report, Date.now());
5323
- if (resetAtMs && resetAtMs > blockedUntil) blockedUntil = resetAtMs;
5798
+ if (report) {
5799
+ const targets = this.#exhaustedLimitBlockTargets(report, Date.now());
5800
+ if (targets.wideUntil !== undefined) {
5801
+ reportHasWideLimit = true;
5802
+ if (targets.wideUntil > blockedUntil) blockedUntil = targets.wideUntil;
5803
+ }
5804
+ scopedBlocks = targets.scoped;
5324
5805
  }
5325
5806
  }
5326
5807
 
@@ -5338,7 +5819,11 @@ export class AuthStorage {
5338
5819
  candidate.entry.credential.type === credentialKind &&
5339
5820
  candidate.entry.id !== failedRowId &&
5340
5821
  (!enabledIds || enabledIds.has(candidate.entry.id)) &&
5341
- !this.#isCredentialBlocked(this.#getProviderTypeKey(provider, credentialKind), candidate.index),
5822
+ !this.#isCredentialBlocked(
5823
+ this.#getProviderTypeKey(provider, credentialKind),
5824
+ candidate.index,
5825
+ options?.modelId,
5826
+ ),
5342
5827
  )
5343
5828
  .map(candidate => candidate.entry.id);
5344
5829
  };
@@ -5351,7 +5836,15 @@ export class AuthStorage {
5351
5836
  };
5352
5837
  }
5353
5838
  const providerKey = this.#getProviderTypeKey(provider, credentialKind);
5354
- this.#markCredentialBlocked(providerKey, targetIndex, blockedUntil);
5839
+ // A report whose only exhausted limits are model-scoped must not take the
5840
+ // whole credential out of rotation; the caller's retry-after backoff applies
5841
+ // to those models instead.
5842
+ if (!(scopedBlocks.length > 0 && !reportHasWideLimit)) {
5843
+ this.#markCredentialBlocked(providerKey, targetIndex, blockedUntil, options?.modelId);
5844
+ }
5845
+ for (const scoped of scopedBlocks) {
5846
+ this.#markCredentialBlocked(providerKey, targetIndex, scoped.untilMs, scoped.modelId);
5847
+ }
5355
5848
  return {
5356
5849
  state: "marked",
5357
5850
  failedRowId,
@@ -5360,6 +5853,30 @@ export class AuthStorage {
5360
5853
  };
5361
5854
  }
5362
5855
 
5856
+ /**
5857
+ * Opaque stored row IDs for credentials whose resolved key matches `apiKey`.
5858
+ * Unlike the session's sticky row pointer, these remain tied to the actual key
5859
+ * when concurrent requests share a credential session ID.
5860
+ * A row that cannot be resolved is skipped so an unrelated resolver failure
5861
+ * cannot prevent callers from using already-known credential identity.
5862
+ */
5863
+ async getCredentialRowIdsMatchingApiKey(provider: string, apiKey: string): Promise<number[]> {
5864
+ const storageProvider = resolveOAuthStorageProvider(provider);
5865
+ const rowIds: number[] = [];
5866
+ for (const entry of this.#getStoredCredentials(storageProvider)) {
5867
+ try {
5868
+ if (await this.#credentialMatchesApiKey(storageProvider, entry.credential, apiKey)) rowIds.push(entry.id);
5869
+ } catch (error) {
5870
+ logger.warn("Failed to resolve stored API key while matching credential rows", {
5871
+ provider: storageProvider,
5872
+ credentialId: entry.id,
5873
+ errorType: error instanceof Error ? error.name : typeof error,
5874
+ });
5875
+ }
5876
+ }
5877
+ return rowIds;
5878
+ }
5879
+
5363
5880
  /**
5364
5881
  * Mark the stored credential whose key matches `apiKey` usage-limited for
5365
5882
  * `retryAfterMs` (or the default backoff). Used when the caller knows
@@ -5492,7 +6009,11 @@ export class AuthStorage {
5492
6009
  args.order.map(async idx => {
5493
6010
  const selection = args.credentials[idx];
5494
6011
  if (!selection) return null;
5495
- const blockedUntil = this.#getCredentialBlockedUntil(args.providerKey, selection.index);
6012
+ const blockedUntil = this.#getCredentialBlockedUntil(
6013
+ args.providerKey,
6014
+ selection.index,
6015
+ args.options?.modelId,
6016
+ );
5496
6017
  if (blockedUntil !== undefined) return { selection, usage: null, usageChecked: false, blockedUntil };
5497
6018
  const usage = await this.#getUsageReport(args.provider, selection.credential, {
5498
6019
  baseUrl: args.options?.baseUrl,
@@ -5530,13 +6051,29 @@ export class AuthStorage {
5530
6051
  const { selection, usage, usageChecked } = result;
5531
6052
  let { blockedUntil } = result;
5532
6053
  let blocked = blockedUntil !== undefined;
5533
- if (!blocked && usage && this.#isUsageLimitReached(usage)) {
5534
- const resetAtMs = this.#getUsageResetAtMs(usage, nowMs);
6054
+ if (!blocked && usage && this.#isUsageLimitReached(usage, args.options?.modelId)) {
6055
+ const resetAtMs = this.#getUsageResetAtMs(usage, nowMs, args.options?.modelId);
5535
6056
  blockedUntil = resetAtMs ?? Date.now() + AuthStorage.#defaultBackoffMs;
5536
- this.#markCredentialBlocked(args.providerKey, selection.index, blockedUntil);
6057
+ const targets = this.#exhaustedLimitBlockTargets(usage, nowMs);
6058
+ if (targets.wideUntil === undefined && targets.scoped.length > 0) {
6059
+ for (const scoped of targets.scoped) {
6060
+ this.#markCredentialBlocked(args.providerKey, selection.index, scoped.untilMs, scoped.modelId);
6061
+ }
6062
+ } else {
6063
+ this.#markCredentialBlocked(args.providerKey, selection.index, blockedUntil);
6064
+ }
5537
6065
  blocked = true;
5538
6066
  }
5539
- const windows = usage ? strategy.findWindowLimits(usage) : undefined;
6067
+ // Rank on the windows that apply to the requested model so a limit scoped
6068
+ // to another model does not depress this credential's drain metrics.
6069
+ const scopedUsage =
6070
+ usage && args.options?.modelId
6071
+ ? {
6072
+ ...usage,
6073
+ limits: usage.limits.filter(limit => this.#limitAppliesToModel(limit, args.options?.modelId)),
6074
+ }
6075
+ : usage;
6076
+ const windows = scopedUsage ? strategy.findWindowLimits(scopedUsage) : undefined;
5540
6077
  const primary = windows?.primary;
5541
6078
  const secondary = windows?.secondary;
5542
6079
  const secondaryTarget = secondary ?? primary;
@@ -5663,7 +6200,8 @@ export class AuthStorage {
5663
6200
  // (no preference) and sessions whose preferred is blocked still rank, so we pick the account
5664
6201
  // with the most headroom proactively and fall back intelligently when rate-limited.
5665
6202
  const sessionPreferredIsAvailable =
5666
- sessionPreferredIndex !== undefined && !this.#isCredentialBlocked(providerKey, sessionPreferredIndex);
6203
+ sessionPreferredIndex !== undefined &&
6204
+ !this.#isCredentialBlocked(providerKey, sessionPreferredIndex, options?.modelId);
5667
6205
  const shouldRank = !selectedCredential && checkUsage && !sessionPreferredIsAvailable;
5668
6206
  const candidates = shouldRank
5669
6207
  ? await this.#rankOAuthSelections({ providerKey, provider, order, credentials, options, strategy: strategy! })
@@ -5685,27 +6223,25 @@ export class AuthStorage {
5685
6223
  options?.owner,
5686
6224
  );
5687
6225
  }
5688
- const preferredSelection = preferredSelector
5689
- ? this.#findCredentialBySelector(provider, preferredSelector)
5690
- : undefined;
5691
- if (
5692
- preferredSelection?.credential.type === "oauth" &&
5693
- !this.#isCredentialBlocked(providerKey, preferredSelection.index)
5694
- ) {
5695
- const preferredCandidate = candidates.findIndex(
5696
- candidate => candidate.selection.index === preferredSelection.index,
5697
- );
6226
+ const preferredCandidate = preferredSelector
6227
+ ? this.#findUsableOAuthCandidate(provider, providerKey, candidates, preferredSelector)
6228
+ : -1;
6229
+ if (preferredCandidate >= 0) {
5698
6230
  if (preferredCandidate > 0) {
5699
6231
  const [preferred] = candidates.splice(preferredCandidate, 1);
5700
6232
  candidates.unshift(preferred);
5701
6233
  }
6234
+ } else if (!preferredSelector && !sessionPreferredIsAvailable) {
6235
+ // Configured preferences choose a credential; a session that already sticks to a usable one keeps it, so
6236
+ // the list (and any usage fetch its caps need) is consulted only at selection time.
6237
+ await this.#applyConfiguredCredentialPreferences(provider, providerKey, candidates, strategy, options);
5702
6238
  }
5703
6239
  }
5704
6240
 
5705
6241
  if (!selectedCredential && sessionPreferredIndex !== undefined) {
5706
6242
  const sessionPreferredCandidate = candidates.findIndex(
5707
6243
  candidate =>
5708
- !this.#isCredentialBlocked(providerKey, candidate.selection.index) &&
6244
+ !this.#isCredentialBlocked(providerKey, candidate.selection.index, options?.modelId) &&
5709
6245
  candidate.selection.index === sessionPreferredIndex,
5710
6246
  );
5711
6247
  if (sessionPreferredCandidate > 0) {
@@ -5771,7 +6307,7 @@ export class AuthStorage {
5771
6307
  if (resolved) return resolved;
5772
6308
  }
5773
6309
 
5774
- if (fallback && this.#isCredentialBlocked(providerKey, fallback.selection.index)) {
6310
+ if (fallback && this.#isCredentialBlocked(providerKey, fallback.selection.index, options?.modelId)) {
5775
6311
  return this.#tryOAuthCredential(
5776
6312
  provider,
5777
6313
  fallback.selection,
@@ -5939,6 +6475,8 @@ export class AuthStorage {
5939
6475
  projectId: claim.credential.projectId,
5940
6476
  enterpriseUrl: claim.credential.enterpriseUrl,
5941
6477
  profileArn: claim.credential.profileArn,
6478
+ organizationId: claim.credential.organizationId,
6479
+ organizationName: claim.credential.organizationName,
5942
6480
  mcpBinding: claim.credential.mcpBinding,
5943
6481
  persistedByLease: true,
5944
6482
  };
@@ -6059,6 +6597,8 @@ export class AuthStorage {
6059
6597
  projectId: effectiveRefreshed.projectId ?? credential.projectId,
6060
6598
  enterpriseUrl: effectiveRefreshed.enterpriseUrl ?? credential.enterpriseUrl,
6061
6599
  profileArn: effectiveRefreshed.profileArn ?? credential.profileArn,
6600
+ organizationId: effectiveRefreshed.organizationId ?? credential.organizationId,
6601
+ organizationName: effectiveRefreshed.organizationName ?? credential.organizationName,
6062
6602
  mcpBinding: (effectiveRefreshed as RefreshedOAuthCredentials).mcpBinding ?? credential.mcpBinding,
6063
6603
  };
6064
6604
  if (refreshLease) {
@@ -6164,7 +6704,7 @@ export class AuthStorage {
6164
6704
  ): Promise<OAuthResolutionResult | undefined> {
6165
6705
  const { checkUsage, allowBlocked, prefetchedUsage = null, usagePrechecked = false } = usageOptions;
6166
6706
  if (!this.#reconcileOAuthCredentialSelection(provider, selection)) return undefined;
6167
- if (!allowBlocked && this.#isCredentialBlocked(providerKey, selection.index)) {
6707
+ if (!allowBlocked && this.#isCredentialBlocked(providerKey, selection.index, options?.modelId)) {
6168
6708
  return undefined;
6169
6709
  }
6170
6710
 
@@ -6186,8 +6726,8 @@ export class AuthStorage {
6186
6726
  });
6187
6727
  usageChecked = true;
6188
6728
  }
6189
- if (checkUsage && !allowBlocked && usage && this.#isUsageLimitReached(usage)) {
6190
- const resetAtMs = this.#getUsageResetAtMs(usage, Date.now());
6729
+ if (checkUsage && !allowBlocked && usage && this.#isUsageLimitReached(usage, options?.modelId)) {
6730
+ const resetAtMs = this.#getUsageResetAtMs(usage, Date.now(), options?.modelId);
6191
6731
  this.#markCredentialBlocked(
6192
6732
  providerKey,
6193
6733
  selection.index,
@@ -6247,6 +6787,8 @@ export class AuthStorage {
6247
6787
  projectId: result.newCredentials.projectId ?? selection.credential.projectId,
6248
6788
  enterpriseUrl: result.newCredentials.enterpriseUrl ?? selection.credential.enterpriseUrl,
6249
6789
  profileArn: result.newCredentials.profileArn ?? selection.credential.profileArn,
6790
+ organizationId: result.newCredentials.organizationId ?? selection.credential.organizationId,
6791
+ organizationName: result.newCredentials.organizationName ?? selection.credential.organizationName,
6250
6792
  mcpBinding: refreshedAuthority.mcpBinding,
6251
6793
  };
6252
6794
  this.#replaceCredentialAt(
@@ -6266,8 +6808,8 @@ export class AuthStorage {
6266
6808
  });
6267
6809
  usageChecked = true;
6268
6810
  }
6269
- if (checkUsage && !allowBlocked && usage && this.#isUsageLimitReached(usage)) {
6270
- const resetAtMs = this.#getUsageResetAtMs(usage, Date.now());
6811
+ if (checkUsage && !allowBlocked && usage && this.#isUsageLimitReached(usage, options?.modelId)) {
6812
+ const resetAtMs = this.#getUsageResetAtMs(usage, Date.now(), options?.modelId);
6271
6813
  this.#markCredentialBlocked(
6272
6814
  providerKey,
6273
6815
  selection.index,
@@ -6279,7 +6821,7 @@ export class AuthStorage {
6279
6821
  if (!this.#reconcileOAuthCredentialSelection(provider, selection)) return undefined;
6280
6822
  if (!authCredentialEquals(selection.credential, updated)) return undefined;
6281
6823
  this.#recordSessionCredential(provider, sessionId, "oauth", selection.index);
6282
- return { apiKey: result.apiKey, credential: updated };
6824
+ return { apiKey: result.apiKey, credential: updated, credentialRowId: selection.id };
6283
6825
  } catch (error) {
6284
6826
  if (options?.signal?.aborted) throw error;
6285
6827
  if (isSqliteError(error)) throw error;
@@ -6526,7 +7068,7 @@ export class AuthStorage {
6526
7068
  */
6527
7069
  async peekApiKey(
6528
7070
  provider: string,
6529
- options?: Pick<AuthApiKeyOptions, "owner"> & { sessionId?: string },
7071
+ options?: AuthApiKeyOptions & { sessionId?: string },
6530
7072
  ): Promise<string | undefined> {
6531
7073
  provider = resolveOAuthStorageProvider(provider);
6532
7074
  const runtimeKey = this.#runtimeOverrides.get(provider);
@@ -6537,11 +7079,7 @@ export class AuthStorage {
6537
7079
  if (configKey && !configOverride?.envSourced) return configKey;
6538
7080
  if (options?.sessionId && this.hasSessionCredentialUnavailable(provider, options.sessionId)) return undefined;
6539
7081
 
6540
- const selectedCredential = this.#resolveSelectedStoredCredential(
6541
- provider,
6542
- options?.owner ? { owner: options.owner } : undefined,
6543
- options?.sessionId,
6544
- );
7082
+ const selectedCredential = this.#resolveSelectedStoredCredential(provider, options, options?.sessionId);
6545
7083
  if (configKey) {
6546
7084
  // Env-sourced (`apiKeyEnv`) override: same precedence as getApiKey —
6547
7085
  // a stored api_key credential from `auth login` wins, stored OAuth
@@ -6576,7 +7114,7 @@ export class AuthStorage {
6576
7114
  // A hard selector is an identity boundary. If its selected row cannot
6577
7115
  // provide a current token, discovery must not continue into the shared
6578
7116
  // credential pool and silently query another account's catalog.
6579
- if (this.#getCredentialSelector(provider, undefined, options?.sessionId)) return undefined;
7117
+ if (this.#getCredentialSelector(provider, options, options?.sessionId)) return undefined;
6580
7118
 
6581
7119
  const attemptedApiKeyIndices = new Set<number>();
6582
7120
  for (;;) {
@@ -6626,6 +7164,31 @@ export class AuthStorage {
6626
7164
  * 10. Fallback resolver (models.yml custom providers, last-resort)
6627
7165
  */
6628
7166
  async getApiKey(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<string | undefined> {
7167
+ return this.#resolveApiKey(provider, sessionId, options);
7168
+ }
7169
+
7170
+ /**
7171
+ * Resolve an API key and retain the selected stored row identity across token refreshes
7172
+ * and concurrent changes to the session's sticky credential pointer.
7173
+ */
7174
+ async getApiKeyWithCredentialRowId(
7175
+ provider: string,
7176
+ sessionId?: string,
7177
+ options?: AuthApiKeyOptions,
7178
+ ): Promise<{ apiKey: string | undefined; credentialRowId: number | undefined }> {
7179
+ let credentialRowId: number | undefined;
7180
+ const apiKey = await this.#resolveApiKey(provider, sessionId, options, rowId => {
7181
+ credentialRowId = rowId;
7182
+ });
7183
+ return { apiKey, credentialRowId };
7184
+ }
7185
+
7186
+ async #resolveApiKey(
7187
+ provider: string,
7188
+ sessionId?: string,
7189
+ options?: AuthApiKeyOptions,
7190
+ onCredentialRowResolved?: (rowId: number) => void,
7191
+ ): Promise<string | undefined> {
6629
7192
  provider = resolveOAuthStorageProvider(provider);
6630
7193
  const selectedCredential = this.#resolveSelectedStoredCredential(provider, options, sessionId);
6631
7194
 
@@ -6647,7 +7210,12 @@ export class AuthStorage {
6647
7210
  // (validated at login, rotated on 401), while the pointed-to env
6648
7211
  // value can go stale with no recovery path — prefer the stored
6649
7212
  // credential. Stored OAuth credentials still yield to the override.
6650
- const storedApiKey = await this.#resolveStoredApiKeyOverEnvConfig(provider, selectedCredential, sessionId);
7213
+ const storedApiKey = await this.#resolveStoredApiKeyOverEnvConfig(
7214
+ provider,
7215
+ selectedCredential,
7216
+ sessionId,
7217
+ onCredentialRowResolved,
7218
+ );
6651
7219
  if (storedApiKey) return storedApiKey;
6652
7220
  return configKey;
6653
7221
  }
@@ -6662,14 +7230,19 @@ export class AuthStorage {
6662
7230
 
6663
7231
  if (selectedCredential?.credential.type === "api_key") {
6664
7232
  this.#recordSessionCredential(provider, sessionId, "api_key", selectedCredential.index);
6665
- return this.#resolveStoredApiKey(provider, selectedCredential.credential.key);
7233
+ const apiKey = await this.#resolveStoredApiKey(provider, selectedCredential.credential.key);
7234
+ if (apiKey) onCredentialRowResolved?.(selectedCredential.id);
7235
+ return apiKey;
6666
7236
  }
6667
7237
 
6668
7238
  let oauthAttempted = false;
6669
7239
  if (!selectedCredential && this.#getSessionCredential(provider, sessionId)?.type === "oauth") {
6670
7240
  const oauthResolved = await this.#resolveOAuthSelection(provider, sessionId, options);
6671
7241
  oauthAttempted = true;
6672
- if (oauthResolved) return oauthResolved.apiKey;
7242
+ if (oauthResolved) {
7243
+ onCredentialRowResolved?.(oauthResolved.credentialRowId);
7244
+ return oauthResolved.apiKey;
7245
+ }
6673
7246
  }
6674
7247
 
6675
7248
  const attemptedApiKeyIndices = new Set<number>();
@@ -6681,6 +7254,7 @@ export class AuthStorage {
6681
7254
  const resolved = await this.#resolveStoredApiKey(provider, apiKeySelection.credential.key);
6682
7255
  if (resolved) {
6683
7256
  this.#recordSessionCredential(provider, sessionId, "api_key", apiKeySelection.index);
7257
+ onCredentialRowResolved?.(apiKeySelection.id);
6684
7258
  return resolved;
6685
7259
  }
6686
7260
  }
@@ -6688,7 +7262,10 @@ export class AuthStorage {
6688
7262
 
6689
7263
  if (!oauthAttempted) {
6690
7264
  const oauthResolved = await this.#resolveOAuthSelection(provider, sessionId, options);
6691
- if (oauthResolved) return oauthResolved.apiKey;
7265
+ if (oauthResolved) {
7266
+ onCredentialRowResolved?.(oauthResolved.credentialRowId);
7267
+ return oauthResolved.apiKey;
7268
+ }
6692
7269
  }
6693
7270
 
6694
7271
  if (!selectedCredential) {
@@ -6699,6 +7276,7 @@ export class AuthStorage {
6699
7276
  const resolved = await this.#resolveStoredApiKey(provider, apiKeySelection.credential.key);
6700
7277
  if (resolved) {
6701
7278
  this.#recordSessionCredential(provider, sessionId, "api_key", apiKeySelection.index);
7279
+ onCredentialRowResolved?.(apiKeySelection.id);
6702
7280
  return resolved;
6703
7281
  }
6704
7282
  }
@@ -6717,21 +7295,25 @@ export class AuthStorage {
6717
7295
  /**
6718
7296
  * Resolve a stored api_key credential that outranks an env-sourced config
6719
7297
  * override (`apiKeyEnv`). Mirrors the api_key branches of {@link getApiKey}:
6720
- * the selector-pinned credential first, then the round-robin/session pool.
6721
- * Returns undefined when no stored api_key credential resolves, leaving the
6722
- * env-sourced override in effect.
7298
+ * a selected credential must resolve or fail closed; unpinned resolution then
7299
+ * tries the round-robin/session pool. Returns undefined when no unpinned
7300
+ * stored api_key credential resolves, leaving the env-sourced override in
7301
+ * effect.
6723
7302
  */
6724
7303
  async #resolveStoredApiKeyOverEnvConfig(
6725
7304
  provider: string,
6726
7305
  selectedCredential: ({ index: number } & StoredCredential) | undefined,
6727
7306
  sessionId?: string,
7307
+ onCredentialRowResolved?: (rowId: number) => void,
6728
7308
  ): Promise<string | undefined> {
6729
7309
  if (selectedCredential?.credential.type === "api_key") {
6730
7310
  const resolved = await this.#resolveStoredApiKey(provider, selectedCredential.credential.key);
6731
7311
  if (resolved) {
6732
7312
  this.#recordSessionCredential(provider, sessionId, "api_key", selectedCredential.index);
7313
+ onCredentialRowResolved?.(selectedCredential.id);
6733
7314
  return resolved;
6734
7315
  }
7316
+ throw new Error(`Selected credential for ${provider} (id:${selectedCredential.id}) is unavailable`);
6735
7317
  }
6736
7318
  const attemptedApiKeyIndices = new Set<number>();
6737
7319
  for (;;) {
@@ -6741,6 +7323,7 @@ export class AuthStorage {
6741
7323
  const resolved = await this.#resolveStoredApiKey(provider, apiKeySelection.credential.key);
6742
7324
  if (resolved) {
6743
7325
  this.#recordSessionCredential(provider, sessionId, "api_key", apiKeySelection.index);
7326
+ onCredentialRowResolved?.(apiKeySelection.id);
6744
7327
  return resolved;
6745
7328
  }
6746
7329
  }
@@ -7023,6 +7606,8 @@ export class AuthStorage {
7023
7606
  projectId: refreshed.projectId ?? target.credential.projectId,
7024
7607
  enterpriseUrl: refreshed.enterpriseUrl ?? target.credential.enterpriseUrl,
7025
7608
  profileArn: refreshed.profileArn ?? target.credential.profileArn,
7609
+ organizationId: refreshed.organizationId ?? target.credential.organizationId,
7610
+ organizationName: refreshed.organizationName ?? target.credential.organizationName,
7026
7611
  mcpBinding: refreshed.mcpBinding,
7027
7612
  };
7028
7613
  this.#replaceCredentialAt(provider, index, updated, !refreshed.persistedByLease, id);
@@ -7134,7 +7719,7 @@ export class AuthStorage {
7134
7719
  const credential = chosen.credential;
7135
7720
  const identity =
7136
7721
  credential.type === "oauth"
7137
- ? (credential.email ?? credential.accountId ?? credential.projectId ?? `cred ${chosen.id}`)
7722
+ ? (formatOAuthIdentityLabel(credential) ?? `cred ${chosen.id}`)
7138
7723
  : `cred ${chosen.id}`;
7139
7724
  return `${baseLabel} · ${preferredType} #${chosen.id} (${identity})`;
7140
7725
  }
@@ -7247,7 +7832,22 @@ function resolveProviderCredentialIdentityKey(provider: string, identifiers: str
7247
7832
 
7248
7833
  function resolveCredentialIdentityKey(provider: string, credential: AuthCredential): string | null {
7249
7834
  if (credential.type === "api_key") return null;
7250
- return resolveProviderCredentialIdentityKey(provider, extractOAuthCredentialIdentifiers(credential));
7835
+ return withOrganizationIdentity(
7836
+ resolveProviderCredentialIdentityKey(provider, extractOAuthCredentialIdentifiers(credential)),
7837
+ credential,
7838
+ );
7839
+ }
7840
+
7841
+ /**
7842
+ * Scope an account identity key to the credential's organization. One account
7843
+ * (same email) can sign in to several organizations, and each login yields an
7844
+ * independent credential with its own plan and limits, so they must not
7845
+ * replace or dedupe each other.
7846
+ */
7847
+ function withOrganizationIdentity(identityKey: string | null, credential: OAuthCredential): string | null {
7848
+ const organizationId = credential.organizationId?.trim();
7849
+ if (!identityKey || !organizationId) return identityKey;
7850
+ return `${identityKey}|org:${organizationId}`;
7251
7851
  }
7252
7852
 
7253
7853
  function resolveRowCredentialIdentityKey(provider: string, row: AuthRow): string | null {
@@ -7342,6 +7942,7 @@ function extractOAuthTokenIdentifiers(token: string | undefined): string[] | und
7342
7942
  */
7343
7943
  export class SqliteAuthCredentialStore implements AuthCredentialStore {
7344
7944
  #db: Database;
7945
+ #ownedStatements: Statement[] = [];
7345
7946
  #listActiveStmt: Statement;
7346
7947
  #listActiveByProviderStmt: Statement;
7347
7948
  #listAllStmt: Statement;
@@ -7367,53 +7968,89 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
7367
7968
  this.#db = db;
7368
7969
  this.#initializeSchema();
7369
7970
 
7370
- this.#listActiveStmt = this.#db.prepare(
7971
+ this.#listActiveStmt = this.#prepareOwnedStatement(
7371
7972
  "SELECT id, provider, credential_type, data, disabled_cause, identity_key, revision FROM auth_credentials WHERE disabled_cause IS NULL ORDER BY id ASC",
7372
7973
  );
7373
- this.#listActiveByProviderStmt = this.#db.prepare(
7974
+ this.#listActiveByProviderStmt = this.#prepareOwnedStatement(
7374
7975
  "SELECT id, provider, credential_type, data, disabled_cause, identity_key, revision FROM auth_credentials WHERE provider = ? AND disabled_cause IS NULL ORDER BY id ASC",
7375
7976
  );
7376
- this.#listAllStmt = this.#db.prepare(
7977
+ this.#listAllStmt = this.#prepareOwnedStatement(
7377
7978
  "SELECT id, provider, credential_type, data, disabled_cause, identity_key, revision FROM auth_credentials ORDER BY id ASC",
7378
7979
  );
7379
- this.#listAllByProviderStmt = this.#db.prepare(
7980
+ this.#listAllByProviderStmt = this.#prepareOwnedStatement(
7380
7981
  "SELECT id, provider, credential_type, data, disabled_cause, identity_key, revision FROM auth_credentials WHERE provider = ? ORDER BY id ASC",
7381
7982
  );
7382
- this.#listDisabledByProviderStmt = this.#db.prepare(
7983
+ this.#listDisabledByProviderStmt = this.#prepareOwnedStatement(
7383
7984
  "SELECT id, provider, credential_type, data, disabled_cause, identity_key, revision FROM auth_credentials WHERE provider = ? AND disabled_cause IS NOT NULL ORDER BY id ASC",
7384
7985
  );
7385
- this.#insertStmt = this.#db.prepare(
7986
+ this.#insertStmt = this.#prepareOwnedStatement(
7386
7987
  `INSERT INTO auth_credentials (provider, credential_type, data, identity_key, revision, created_at, updated_at) VALUES (?, ?, ?, ?, 1, ${SQLITE_NOW_EPOCH}, ${SQLITE_NOW_EPOCH}) RETURNING id`,
7387
7988
  );
7388
- this.#updateStmt = this.#db.prepare(
7989
+ this.#updateStmt = this.#prepareOwnedStatement(
7389
7990
  `UPDATE auth_credentials SET credential_type = ?, data = ?, identity_key = ?, revision = revision + 1, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ?`,
7390
7991
  );
7391
- this.#deleteStmt = this.#db.prepare(
7992
+ this.#deleteStmt = this.#prepareOwnedStatement(
7392
7993
  `UPDATE auth_credentials SET disabled_cause = ?, revision = revision + 1, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ?`,
7393
7994
  );
7394
- this.#deleteIfMatchesStmt = this.#db.prepare(
7995
+ this.#deleteIfMatchesStmt = this.#prepareOwnedStatement(
7395
7996
  `UPDATE auth_credentials SET disabled_cause = ?, revision = revision + 1, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ? AND data = ? AND disabled_cause IS NULL`,
7396
7997
  );
7397
- this.#deleteIfRevisionStmt = this.#db.prepare(
7998
+ this.#deleteIfRevisionStmt = this.#prepareOwnedStatement(
7398
7999
  `UPDATE auth_credentials SET disabled_cause = ?, revision = revision + 1, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ? AND revision = ? AND disabled_cause IS NULL`,
7399
8000
  );
7400
- this.#deleteByProviderStmt = this.#db.prepare(
8001
+ this.#deleteByProviderStmt = this.#prepareOwnedStatement(
7401
8002
  `UPDATE auth_credentials SET disabled_cause = ?, revision = revision + 1, updated_at = ${SQLITE_NOW_EPOCH} WHERE provider = ? AND disabled_cause IS NULL`,
7402
8003
  );
7403
- this.#hardDeleteStmt = this.#db.prepare("DELETE FROM auth_credentials WHERE id = ?");
7404
- this.#getCacheStmt = this.#db.prepare(
8004
+ this.#hardDeleteStmt = this.#prepareOwnedStatement("DELETE FROM auth_credentials WHERE id = ?");
8005
+ this.#getCacheStmt = this.#prepareOwnedStatement(
7405
8006
  `SELECT value FROM cache WHERE key = ? AND expires_at > ${SQLITE_NOW_EPOCH}`,
7406
8007
  );
7407
- this.#getCacheIncludingExpiredStmt = this.#db.prepare("SELECT value FROM cache WHERE key = ?");
7408
- this.#upsertCacheStmt = this.#db.prepare(
8008
+ this.#getCacheIncludingExpiredStmt = this.#prepareOwnedStatement("SELECT value FROM cache WHERE key = ?");
8009
+ this.#upsertCacheStmt = this.#prepareOwnedStatement(
7409
8010
  "INSERT INTO cache (key, value, expires_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at",
7410
8011
  );
7411
- this.#claimUsageFetchLeaseStmt = this.#db.prepare(
8012
+ this.#claimUsageFetchLeaseStmt = this.#prepareOwnedStatement(
7412
8013
  "INSERT INTO cache (key, value, expires_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at WHERE cache.expires_at <= ?",
7413
8014
  );
7414
- this.#releaseUsageFetchLeaseStmt = this.#db.prepare("DELETE FROM cache WHERE key = ? AND value = ?");
7415
- this.#deleteCachePrefixStmt = this.#db.prepare("DELETE FROM cache WHERE substr(key, 1, ?) = ?");
7416
- this.#deleteExpiredCacheStmt = this.#db.prepare(`DELETE FROM cache WHERE expires_at <= ${SQLITE_NOW_EPOCH}`);
8015
+ this.#releaseUsageFetchLeaseStmt = this.#prepareOwnedStatement("DELETE FROM cache WHERE key = ? AND value = ?");
8016
+ this.#deleteCachePrefixStmt = this.#prepareOwnedStatement("DELETE FROM cache WHERE substr(key, 1, ?) = ?");
8017
+ this.#deleteExpiredCacheStmt = this.#prepareOwnedStatement(
8018
+ `DELETE FROM cache WHERE expires_at <= ${SQLITE_NOW_EPOCH}`,
8019
+ );
8020
+ }
8021
+
8022
+ #prepareOwnedStatement(sql: string): Statement {
8023
+ const statement = this.#db.prepare(sql);
8024
+ this.#ownedStatements.push(statement);
8025
+ return statement;
8026
+ }
8027
+
8028
+ #withStatement<T>(sql: string, use: (statement: Statement) => T): T {
8029
+ const statement = this.#db.prepare(sql);
8030
+ let result!: T;
8031
+ let operationFailed = false;
8032
+ let operationError: unknown;
8033
+ try {
8034
+ result = use(statement);
8035
+ } catch (error) {
8036
+ operationFailed = true;
8037
+ operationError = error;
8038
+ }
8039
+
8040
+ try {
8041
+ statement.finalize();
8042
+ } catch (finalizeError) {
8043
+ if (operationFailed) {
8044
+ throw new AggregateError(
8045
+ [operationError, finalizeError],
8046
+ "SQLite statement operation and finalization failed",
8047
+ { cause: operationError },
8048
+ );
8049
+ }
8050
+ throw finalizeError;
8051
+ }
8052
+ if (operationFailed) throw operationError;
8053
+ return result;
7417
8054
  }
7418
8055
 
7419
8056
  static async open(dbPath: string = getAgentDbPath()): Promise<SqliteAuthCredentialStore> {
@@ -7489,33 +8126,40 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
7489
8126
  }
7490
8127
 
7491
8128
  #authCredentialsTableExists(): boolean {
7492
- const row = this.#db
7493
- .prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'auth_credentials'")
7494
- .get() as { present?: number } | undefined;
7495
- return row?.present === 1;
8129
+ return this.#withStatement(
8130
+ "SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'auth_credentials'",
8131
+ statement => {
8132
+ const row = statement.get() as { present?: number } | undefined;
8133
+ return row?.present === 1;
8134
+ },
8135
+ );
7496
8136
  }
7497
8137
 
7498
8138
  #readAuthSchemaVersion(): number | null {
7499
- const row = this.#db.prepare("SELECT version FROM auth_schema_version WHERE id = 1").get() as
7500
- | { version?: number }
7501
- | undefined;
7502
- return typeof row?.version === "number" ? row.version : null;
8139
+ return this.#withStatement("SELECT version FROM auth_schema_version WHERE id = 1", statement => {
8140
+ const row = statement.get() as { version?: number } | undefined;
8141
+ return typeof row?.version === "number" ? row.version : null;
8142
+ });
7503
8143
  }
7504
8144
 
7505
8145
  #writeAuthSchemaVersion(version: number): void {
7506
- this.#db.prepare("INSERT OR REPLACE INTO auth_schema_version(id, version) VALUES (1, ?)").run(version);
8146
+ this.#withStatement("INSERT OR REPLACE INTO auth_schema_version(id, version) VALUES (1, ?)", statement => {
8147
+ statement.run(version);
8148
+ });
7507
8149
  }
7508
8150
 
7509
8151
  #inferAuthSchemaVersion(): number {
7510
- const cols = this.#db.prepare("PRAGMA table_info(auth_credentials)").all() as Array<{ name?: string }>;
7511
- const hasDisabledCause = cols.some(column => column.name === "disabled_cause");
7512
- const hasIdentityKey = cols.some(column => column.name === "identity_key");
7513
- const hasAccountId = cols.some(column => column.name === "account_id");
7514
- const hasEmail = cols.some(column => column.name === "email");
7515
- if (hasIdentityKey) return 3;
7516
- if (hasAccountId || hasEmail) return 2;
7517
- if (hasDisabledCause) return 1;
7518
- return 0;
8152
+ return this.#withStatement("PRAGMA table_info(auth_credentials)", statement => {
8153
+ const cols = statement.all() as Array<{ name?: string }>;
8154
+ const hasDisabledCause = cols.some(column => column.name === "disabled_cause");
8155
+ const hasIdentityKey = cols.some(column => column.name === "identity_key");
8156
+ const hasAccountId = cols.some(column => column.name === "account_id");
8157
+ const hasEmail = cols.some(column => column.name === "email");
8158
+ if (hasIdentityKey) return 3;
8159
+ if (hasAccountId || hasEmail) return 2;
8160
+ if (hasDisabledCause) return 1;
8161
+ return 0;
8162
+ });
7519
8163
  }
7520
8164
 
7521
8165
  #createAuthCredentialsTable(): void {
@@ -7559,7 +8203,10 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
7559
8203
 
7560
8204
  #migrateAuthSchemaV0ToV1(): void {
7561
8205
  const migrate = this.#db.transaction(() => {
7562
- const v0Cols = this.#db.prepare("PRAGMA table_info(auth_credentials)").all() as Array<{ name?: string }>;
8206
+ const v0Cols = this.#withStatement(
8207
+ "PRAGMA table_info(auth_credentials)",
8208
+ statement => statement.all() as Array<{ name?: string }>,
8209
+ );
7563
8210
  const hasDisabled = v0Cols.some(col => col.name === "disabled");
7564
8211
 
7565
8212
  this.#db.run("ALTER TABLE auth_credentials RENAME TO auth_credentials_v0");
@@ -7635,24 +8282,27 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
7635
8282
  migrate.immediate();
7636
8283
  }
7637
8284
  #migrateAuthSchemaV4ToV5(): void {
7638
- const columns = this.#db.prepare("PRAGMA table_info(auth_credentials)").all() as Array<{ name?: string }>;
8285
+ const columns = this.#withStatement(
8286
+ "PRAGMA table_info(auth_credentials)",
8287
+ statement => statement.all() as Array<{ name?: string }>,
8288
+ );
7639
8289
  if (columns.some(column => column.name === "revision")) return;
7640
8290
  this.#db.run("ALTER TABLE auth_credentials ADD COLUMN revision INTEGER NOT NULL DEFAULT 1");
7641
8291
  }
7642
8292
 
7643
8293
  #backfillCredentialIdentityKeys(): void {
7644
- const rows = this.#db
7645
- .prepare(
7646
- "SELECT id, provider, credential_type, data, disabled_cause, identity_key, revision FROM auth_credentials WHERE identity_key IS NULL ORDER BY id ASC",
7647
- )
7648
- .all() as AuthRow[];
8294
+ const rows = this.#withStatement(
8295
+ "SELECT id, provider, credential_type, data, disabled_cause, identity_key, revision FROM auth_credentials WHERE identity_key IS NULL ORDER BY id ASC",
8296
+ statement => statement.all() as AuthRow[],
8297
+ );
7649
8298
  if (rows.length === 0) return;
7650
8299
 
7651
- const updateIdentity = this.#db.prepare("UPDATE auth_credentials SET identity_key = ? WHERE id = ?");
7652
- for (const row of rows) {
7653
- const identityKey = resolveRowCredentialIdentityKey(row.provider, row);
7654
- updateIdentity.run(identityKey, row.id);
7655
- }
8300
+ this.#withStatement("UPDATE auth_credentials SET identity_key = ? WHERE id = ?", updateIdentity => {
8301
+ for (const row of rows) {
8302
+ const identityKey = resolveRowCredentialIdentityKey(row.provider, row);
8303
+ updateIdentity.run(identityKey, row.id);
8304
+ }
8305
+ });
7656
8306
  }
7657
8307
 
7658
8308
  // ─── AuthCredentialStore interface ──────────────────────────────────────
@@ -7681,10 +8331,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
7681
8331
  for (const row of rows) {
7682
8332
  const credential = deserializeCredential(row);
7683
8333
  if (!credential) continue;
7684
- const identityLabel =
7685
- credential.type === "oauth"
7686
- ? (credential.email ?? credential.accountId ?? credential.projectId ?? null)
7687
- : null;
8334
+ const identityLabel = credential.type === "oauth" ? formatOAuthIdentityLabel(credential) : null;
7688
8335
  results.push({
7689
8336
  id: row.id,
7690
8337
  provider: row.provider,
@@ -7693,6 +8340,12 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
7693
8340
  ...(credential.type === "oauth" && credential.accountId ? { accountId: credential.accountId } : {}),
7694
8341
  ...(credential.type === "oauth" && credential.email ? { email: credential.email } : {}),
7695
8342
  ...(credential.type === "oauth" && credential.projectId ? { projectId: credential.projectId } : {}),
8343
+ ...(credential.type === "oauth" && credential.organizationId
8344
+ ? { organizationId: credential.organizationId }
8345
+ : {}),
8346
+ ...(credential.type === "oauth" && credential.organizationName
8347
+ ? { organizationName: credential.organizationName }
8348
+ : {}),
7696
8349
  disabled: row.disabled_cause !== null,
7697
8350
  disabledCause: row.disabled_cause,
7698
8351
  });
@@ -7715,11 +8368,13 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
7715
8368
  const remove = this.#db.transaction((): AuthCredentialHardRemovalResult => {
7716
8369
  const currentIds: number[] = [];
7717
8370
  for (const target of unique) {
7718
- const row = this.#db
7719
- .prepare("SELECT id, provider, credential_type, revision FROM auth_credentials WHERE id = ?")
7720
- .get(target.id) as
7721
- | { id?: number; provider?: string; credential_type?: string; revision?: number }
7722
- | undefined;
8371
+ const row = this.#withStatement(
8372
+ "SELECT id, provider, credential_type, revision FROM auth_credentials WHERE id = ?",
8373
+ statement =>
8374
+ statement.get(target.id) as
8375
+ | { id?: number; provider?: string; credential_type?: string; revision?: number }
8376
+ | undefined,
8377
+ );
7723
8378
  if (!row || row.provider !== provider || row.revision !== target.expectedRevision) {
7724
8379
  currentIds.push(row?.id ?? target.id);
7725
8380
  }
@@ -7727,12 +8382,16 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
7727
8382
  if (currentIds.length > 0) return { kind: "conflict", currentIds };
7728
8383
  for (const target of unique) {
7729
8384
  this.#hardDeleteStmt.run(target.id);
7730
- this.#db.prepare("DELETE FROM oauth_refresh_leases WHERE credential_id = ?").run(target.id);
7731
- this.#db.prepare("DELETE FROM cache WHERE key = ?").run(`${HEALTH_CACHE_PREFIX}${target.id}`);
8385
+ this.#withStatement("DELETE FROM oauth_refresh_leases WHERE credential_id = ?", statement => {
8386
+ statement.run(target.id);
8387
+ });
8388
+ this.#withStatement("DELETE FROM cache WHERE key = ?", statement => {
8389
+ statement.run(`${HEALTH_CACHE_PREFIX}${target.id}`);
8390
+ });
7732
8391
  }
7733
- this.#db
7734
- .prepare("DELETE FROM cache WHERE substr(key, 1, ?) = ?")
7735
- .run(`usage_cache:report:${provider}:`.length, `usage_cache:report:${provider}:`);
8392
+ this.#withStatement("DELETE FROM cache WHERE substr(key, 1, ?) = ?", statement => {
8393
+ statement.run(`usage_cache:report:${provider}:`.length, `usage_cache:report:${provider}:`);
8394
+ });
7736
8395
  return { kind: "removed", ids: unique.map(target => target.id) };
7737
8396
  });
7738
8397
  return remove.immediate();
@@ -7749,11 +8408,10 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
7749
8408
  const enteredAtMs = Date.now();
7750
8409
  const claim = this.#db.transaction((): OAuthRefreshLeaseClaim => {
7751
8410
  const effectiveNowMs = nowMs + Math.max(0, Date.now() - enteredAtMs);
7752
- const row = this.#db
7753
- .prepare(
7754
- "SELECT id, provider, credential_type, data, disabled_cause, identity_key, revision FROM auth_credentials WHERE id = ? AND disabled_cause IS NULL",
7755
- )
7756
- .get(credentialId) as AuthRow | undefined;
8411
+ const row = this.#withStatement(
8412
+ "SELECT id, provider, credential_type, data, disabled_cause, identity_key, revision FROM auth_credentials WHERE id = ? AND disabled_cause IS NULL",
8413
+ statement => statement.get(credentialId) as AuthRow | undefined,
8414
+ );
7757
8415
  const credential = row ? deserializeCredential(row) : null;
7758
8416
  if (credential?.type !== "oauth") return { kind: "missing" };
7759
8417
  if (
@@ -7770,14 +8428,16 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
7770
8428
  ) {
7771
8429
  return { kind: "adopted", credential };
7772
8430
  }
7773
- const active = this.#db
7774
- .prepare("SELECT owner, expires_at FROM oauth_refresh_leases WHERE credential_id = ?")
7775
- .get(credentialId) as { owner?: string; expires_at?: number } | undefined;
8431
+ const active = this.#withStatement(
8432
+ "SELECT owner, expires_at FROM oauth_refresh_leases WHERE credential_id = ?",
8433
+ statement => statement.get(credentialId) as { owner?: string; expires_at?: number } | undefined,
8434
+ );
7776
8435
  if (typeof active?.expires_at === "number" && active.expires_at > effectiveNowMs) {
7777
8436
  if (active.owner === owner) {
7778
- this.#db
7779
- .prepare("UPDATE oauth_refresh_leases SET expires_at = ? WHERE credential_id = ? AND owner = ?")
7780
- .run(effectiveNowMs + leaseMs, credentialId, owner);
8437
+ this.#withStatement(
8438
+ "UPDATE oauth_refresh_leases SET expires_at = ? WHERE credential_id = ? AND owner = ?",
8439
+ statement => statement.run(effectiveNowMs + leaseMs, credentialId, owner),
8440
+ );
7781
8441
  return {
7782
8442
  kind: "claimed",
7783
8443
  credential,
@@ -7791,13 +8451,14 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
7791
8451
  return { kind: "busy", expiresAt: active.expires_at };
7792
8452
  }
7793
8453
 
7794
- this.#db.prepare("DELETE FROM oauth_refresh_leases WHERE credential_id = ?").run(credentialId);
8454
+ this.#withStatement("DELETE FROM oauth_refresh_leases WHERE credential_id = ?", statement => {
8455
+ statement.run(credentialId);
8456
+ });
7795
8457
  const tokenFingerprint = crypto.createHash("sha256").update(credential.refresh).digest("hex");
7796
- this.#db
7797
- .prepare(
7798
- "INSERT INTO oauth_refresh_leases (credential_id, owner, token_fingerprint, expires_at) VALUES (?, ?, ?, ?)",
7799
- )
7800
- .run(credentialId, owner, tokenFingerprint, effectiveNowMs + leaseMs);
8458
+ this.#withStatement(
8459
+ "INSERT INTO oauth_refresh_leases (credential_id, owner, token_fingerprint, expires_at) VALUES (?, ?, ?, ?)",
8460
+ statement => statement.run(credentialId, owner, tokenFingerprint, effectiveNowMs + leaseMs),
8461
+ );
7801
8462
  return { kind: "claimed", credential, lease: { credentialId, owner, tokenFingerprint } };
7802
8463
  });
7803
8464
  return claim.immediate();
@@ -7805,22 +8466,24 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
7805
8466
 
7806
8467
  completeOAuthRefreshLease(lease: OAuthRefreshLease, credential: OAuthCredential): boolean {
7807
8468
  const serialized = serializeCredential(
7808
- (
7809
- this.#db.prepare("SELECT provider FROM auth_credentials WHERE id = ?").get(lease.credentialId) as
7810
- | { provider?: string }
7811
- | undefined
8469
+ this.#withStatement(
8470
+ "SELECT provider FROM auth_credentials WHERE id = ?",
8471
+ statement => statement.get(lease.credentialId) as { provider?: string } | undefined,
7812
8472
  )?.provider ?? "",
7813
8473
  credential,
7814
8474
  );
7815
8475
  if (!serialized) return false;
7816
8476
  const complete = this.#db.transaction(() => {
7817
- const leaseRow = this.#db
7818
- .prepare("SELECT owner, token_fingerprint FROM oauth_refresh_leases WHERE credential_id = ?")
7819
- .get(lease.credentialId) as { owner?: string; token_fingerprint?: string } | undefined;
8477
+ const leaseRow = this.#withStatement(
8478
+ "SELECT owner, token_fingerprint FROM oauth_refresh_leases WHERE credential_id = ?",
8479
+ statement =>
8480
+ statement.get(lease.credentialId) as { owner?: string; token_fingerprint?: string } | undefined,
8481
+ );
7820
8482
  if (leaseRow?.owner !== lease.owner || leaseRow.token_fingerprint !== lease.tokenFingerprint) return false;
7821
- const row = this.#db
7822
- .prepare("SELECT data FROM auth_credentials WHERE id = ? AND disabled_cause IS NULL")
7823
- .get(lease.credentialId) as { data?: string } | undefined;
8483
+ const row = this.#withStatement(
8484
+ "SELECT data FROM auth_credentials WHERE id = ? AND disabled_cause IS NULL",
8485
+ statement => statement.get(lease.credentialId) as { data?: string } | undefined,
8486
+ );
7824
8487
  if (
7825
8488
  !row ||
7826
8489
  crypto
@@ -7830,18 +8493,18 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
7830
8493
  )
7831
8494
  return false;
7832
8495
  this.#updateStmt.run(serialized.credentialType, serialized.data, serialized.identityKey, lease.credentialId);
7833
- this.#db
7834
- .prepare("DELETE FROM oauth_refresh_leases WHERE credential_id = ? AND owner = ?")
7835
- .run(lease.credentialId, lease.owner);
8496
+ this.#withStatement("DELETE FROM oauth_refresh_leases WHERE credential_id = ? AND owner = ?", statement => {
8497
+ statement.run(lease.credentialId, lease.owner);
8498
+ });
7836
8499
  return true;
7837
8500
  });
7838
8501
  return complete.immediate();
7839
8502
  }
7840
8503
 
7841
8504
  releaseOAuthRefreshLease(lease: OAuthRefreshLease): void {
7842
- this.#db
7843
- .prepare("DELETE FROM oauth_refresh_leases WHERE credential_id = ? AND owner = ?")
7844
- .run(lease.credentialId, lease.owner);
8505
+ this.#withStatement("DELETE FROM oauth_refresh_leases WHERE credential_id = ? AND owner = ?", statement => {
8506
+ statement.run(lease.credentialId, lease.owner);
8507
+ });
7845
8508
  }
7846
8509
 
7847
8510
  replaceAuthCredentialsForProvider(provider: string, credentials: AuthCredential[]): StoredAuthCredential[] {
@@ -8065,9 +8728,10 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
8065
8728
  }
8066
8729
 
8067
8730
  updateAuthCredential(id: number, credential: AuthCredential): void {
8068
- const providerRow = this.#db.prepare("SELECT provider FROM auth_credentials WHERE id = ?").get(id) as
8069
- | { provider?: string }
8070
- | undefined;
8731
+ const providerRow = this.#withStatement(
8732
+ "SELECT provider FROM auth_credentials WHERE id = ?",
8733
+ statement => statement.get(id) as { provider?: string } | undefined,
8734
+ );
8071
8735
  const provider = providerRow?.provider ?? "";
8072
8736
  const serialized = serializeCredential(provider, credential);
8073
8737
  if (!serialized) return;
@@ -8266,25 +8930,23 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
8266
8930
  close(): void {
8267
8931
  if (this.#closed) return;
8268
8932
  this.#closed = true;
8269
- this.#listActiveStmt.finalize();
8270
- this.#listActiveByProviderStmt.finalize();
8271
- this.#listAllStmt.finalize();
8272
- this.#listAllByProviderStmt.finalize();
8273
- this.#listDisabledByProviderStmt.finalize();
8274
- this.#insertStmt.finalize();
8275
- this.#updateStmt.finalize();
8276
- this.#deleteStmt.finalize();
8277
- this.#deleteIfMatchesStmt.finalize();
8278
- this.#deleteIfRevisionStmt.finalize();
8279
- this.#deleteByProviderStmt.finalize();
8280
- this.#hardDeleteStmt.finalize();
8281
- this.#getCacheStmt.finalize();
8282
- this.#getCacheIncludingExpiredStmt.finalize();
8283
- this.#upsertCacheStmt.finalize();
8284
- this.#claimUsageFetchLeaseStmt.finalize();
8285
- this.#releaseUsageFetchLeaseStmt.finalize();
8286
- this.#deleteCachePrefixStmt.finalize();
8287
- this.#deleteExpiredCacheStmt.finalize();
8288
- this.#db.close(true);
8933
+ const errors: unknown[] = [];
8934
+ for (const statement of this.#ownedStatements) {
8935
+ try {
8936
+ statement.finalize();
8937
+ } catch (error) {
8938
+ errors.push(error);
8939
+ }
8940
+ }
8941
+ this.#ownedStatements.length = 0;
8942
+ try {
8943
+ this.#db.close(true);
8944
+ } catch (error) {
8945
+ errors.push(error);
8946
+ }
8947
+ if (errors.length === 1) throw errors[0];
8948
+ if (errors.length > 1) {
8949
+ throw new AggregateError(errors, "Failed to close SQLite auth credential store", { cause: errors[0] });
8950
+ }
8289
8951
  }
8290
8952
  }