@gajae-code/ai 0.15.4 → 0.15.6

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 (77) hide show
  1. package/CHANGELOG.md +27 -1
  2. package/dist/types/auth-broker/client.d.ts +6 -2
  3. package/dist/types/auth-broker/remote-store.d.ts +14 -2
  4. package/dist/types/auth-broker/types.d.ts +6 -0
  5. package/dist/types/auth-broker/wire-schemas.d.ts +19 -0
  6. package/dist/types/auth-gateway/server.d.ts +39 -5
  7. package/dist/types/auth-gateway/types.d.ts +16 -2
  8. package/dist/types/auth-storage.d.ts +116 -34
  9. package/dist/types/provider-models/openai-compat.d.ts +1 -0
  10. package/dist/types/provider-models/special.d.ts +2 -1
  11. package/dist/types/providers/kiro-api-key.d.ts +50 -0
  12. package/dist/types/providers/kiro-codewhisperer.d.ts +3 -0
  13. package/dist/types/providers/register-builtins.d.ts +12 -12
  14. package/dist/types/stream.d.ts +2 -1
  15. package/dist/types/types.d.ts +35 -24
  16. package/dist/types/utils/fallback-transport.d.ts +7 -0
  17. package/dist/types/utils/json-parse.d.ts +5 -3
  18. package/dist/types/utils/oauth/api-key-login.d.ts +4 -1
  19. package/dist/types/utils/oauth/api-key-validation.d.ts +12 -6
  20. package/dist/types/utils/oauth/commandcode.d.ts +1 -0
  21. package/dist/types/utils/oauth/types.d.ts +1 -1
  22. package/dist/types/utils/retry.d.ts +2 -0
  23. package/dist/types/utils/tool-call-healing.d.ts +4 -4
  24. package/package.json +3 -3
  25. package/src/auth-broker/client.ts +41 -13
  26. package/src/auth-broker/redact.ts +25 -1
  27. package/src/auth-broker/remote-store.ts +374 -115
  28. package/src/auth-broker/server.ts +131 -91
  29. package/src/auth-broker/types.ts +6 -0
  30. package/src/auth-broker/wire-schemas.ts +6 -0
  31. package/src/auth-gateway/server.ts +447 -79
  32. package/src/auth-gateway/types.ts +28 -2
  33. package/src/auth-storage.ts +742 -157
  34. package/src/cli.ts +1 -0
  35. package/src/model-thinking.ts +16 -0
  36. package/src/models.json +1054 -0
  37. package/src/models.ts +9 -1
  38. package/src/provider-models/descriptors.ts +3 -1
  39. package/src/provider-models/openai-compat.ts +41 -1
  40. package/src/provider-models/special.ts +15 -3
  41. package/src/providers/anthropic.ts +7 -1
  42. package/src/providers/azure-openai-responses.ts +4 -1
  43. package/src/providers/cursor.ts +256 -101
  44. package/src/providers/gitlab-duo.ts +18 -1
  45. package/src/providers/google-gemini-cli.ts +3 -0
  46. package/src/providers/google-shared.ts +3 -0
  47. package/src/providers/kiro-api-key.d.ts +50 -0
  48. package/src/providers/kiro-api-key.ts +786 -0
  49. package/src/providers/kiro-codewhisperer.d.ts +3 -0
  50. package/src/providers/kiro-codewhisperer.ts +34 -9
  51. package/src/providers/ollama.ts +3 -0
  52. package/src/providers/openai-codex-responses.ts +24 -6
  53. package/src/providers/openai-completions.ts +11 -1
  54. package/src/providers/openai-responses-shared.ts +23 -2
  55. package/src/providers/openai-responses.ts +10 -1
  56. package/src/providers/pi-native-client.ts +1 -0
  57. package/src/providers/pi-native-server.ts +24 -0
  58. package/src/providers/register-builtins.d.ts +12 -12
  59. package/src/providers/register-builtins.ts +16 -3
  60. package/src/stream.d.ts +2 -1
  61. package/src/stream.ts +180 -70
  62. package/src/types.d.ts +35 -24
  63. package/src/types.ts +40 -23
  64. package/src/utils/fallback-transport.d.ts +7 -0
  65. package/src/utils/fallback-transport.ts +21 -4
  66. package/src/utils/json-parse.d.ts +5 -3
  67. package/src/utils/json-parse.ts +6 -6
  68. package/src/utils/oauth/api-key-login.ts +13 -2
  69. package/src/utils/oauth/api-key-validation.ts +242 -41
  70. package/src/utils/oauth/commandcode.ts +17 -0
  71. package/src/utils/oauth/index.ts +20 -5
  72. package/src/utils/oauth/types.d.ts +1 -1
  73. package/src/utils/oauth/types.ts +1 -0
  74. package/src/utils/retry.d.ts +2 -0
  75. package/src/utils/retry.ts +15 -2
  76. package/src/utils/tool-call-healing.d.ts +4 -4
  77. package/src/utils/tool-call-healing.ts +4 -4
@@ -47,6 +47,20 @@ export type ApiKeyCredential = {
47
47
  key: string;
48
48
  };
49
49
 
50
+ /**
51
+ * Extracts the bearer token from the structured API-key form used by OAuth
52
+ * providers that need to carry token metadata alongside the access token.
53
+ */
54
+ export function extractStructuredApiKeyToken(apiKey: string): string | undefined {
55
+ if (!apiKey.startsWith("{")) return undefined;
56
+ try {
57
+ const parsed = JSON.parse(apiKey) as { token?: unknown };
58
+ return typeof parsed.token === "string" ? parsed.token : undefined;
59
+ } catch {
60
+ return undefined;
61
+ }
62
+ }
63
+
50
64
  export interface MCPOAuthBinding {
51
65
  /** Exact HTTP(S) origin of the MCP resource endpoint. */
52
66
  resourceOrigin: string;
@@ -369,6 +383,7 @@ export interface AuthCredentialSnapshotEntry {
369
383
  provider: string;
370
384
  credential: SnapshotCredential;
371
385
  identityKey: string | null;
386
+ revision?: number;
372
387
  }
373
388
 
374
389
  export type AuthCredentialIfAbsentReason =
@@ -430,8 +445,21 @@ export type OAuthRefreshLeaseClaim =
430
445
  | { kind: "busy"; expiresAt: number }
431
446
  | { kind: "missing" };
432
447
 
448
+ /**
449
+ * Store-owned ticket that orders a provider admission against remote
450
+ * credential snapshot application. The ticket is intentionally released at
451
+ * provider admission, not response completion.
452
+ */
453
+ export interface CredentialDispatchTicket {
454
+ release(): void;
455
+ }
456
+
433
457
  export interface AuthCredentialStore {
434
458
  close(): void;
459
+ refreshSnapshot?(signal?: AbortSignal): Promise<unknown>;
460
+ onSnapshotChanged?(listener: () => void): () => void;
461
+ /** Order provider admission with remote snapshot authority application. */
462
+ acquireCredentialDispatchTicket?(provider: Provider, signal?: AbortSignal): Promise<CredentialDispatchTicket>;
435
463
  listAuthCredentials(provider?: string): StoredAuthCredential[];
436
464
  /** Payload-free account inventory; active and soft-disabled rows are included. */
437
465
  listCredentialInventory?(provider?: string): CredentialInventoryRecord[];
@@ -445,12 +473,15 @@ export interface AuthCredentialStore {
445
473
  updateAuthCredential(id: number, credential: AuthCredential): void;
446
474
  deleteAuthCredential(id: number, disabledCause: string): void;
447
475
  tryDisableAuthCredentialIfMatches(id: number, expectedData: string, disabledCause: string): boolean;
476
+ tryDisableAuthCredentialIfRevision?(id: number, expectedRevision: number, disabledCause: string): boolean;
448
477
  replaceAuthCredentialsForProvider(provider: string, credentials: AuthCredential[]): StoredAuthCredential[];
449
478
  upsertAuthCredentialForProvider(provider: string, credential: AuthCredential): StoredAuthCredential[];
450
479
  upsertAuthCredentialForProviderIfAbsent(provider: string, credential: AuthCredential): AuthCredentialIfAbsentResult;
451
480
  deleteAuthCredentialsForProvider(provider: string, disabledCause: string): void;
452
481
  getCache(key: string, options?: { includeExpired?: boolean }): string | null;
453
482
  setCache(key: string, value: string, expiresAtSec: number): void;
483
+ /** Atomically allocate a durable sequence for broker restart epochs. */
484
+ allocateMonotonicSequence(key: string, expiresAtSec: number): number;
454
485
  deleteCachePrefix?(prefix: string): void;
455
486
  cleanExpiredCache(): void;
456
487
  /**
@@ -514,6 +545,7 @@ export interface AuthCredentialStore {
514
545
  * `signal` propagates the agent's cancel down to the broker fetch.
515
546
  */
516
547
  fetchUsageReports?(signal?: AbortSignal): Promise<UsageReport[] | null>;
548
+ fetchUsageReportsForProvider?(provider: Provider, signal?: AbortSignal): Promise<UsageReport[] | null>;
517
549
  /** Synchronous, zero-network usage presentation peek. */
518
550
  peekCachedUsagePresentation?(provider: Provider, credentialId: number): CachedUsagePresentation | undefined;
519
551
  /** Record a safe usage observation after an explicit fetch/check. */
@@ -552,6 +584,21 @@ export interface AuthCredentialStore {
552
584
  * {@link AuthStorage.invalidateCredentialMatching} fall back to `reload()`.
553
585
  */
554
586
  markCredentialSuspect?(credentialId: number, opts?: { signal?: AbortSignal }): Promise<void>;
587
+ /**
588
+ * Optional async write hook to disable one credential through an authoritative
589
+ * remote store. Remote clients MUST use this hook instead of the synchronous
590
+ * local delete methods when an OAuth refresh fails definitively.
591
+ *
592
+ * Returns `false` when the row is already absent (for example, a peer
593
+ * disabled it first). Implementations MUST NOT treat a failed remote write as
594
+ * a successful local deletion.
595
+ */
596
+ disableAuthCredentialRemote?(
597
+ credentialId: number,
598
+ disabledCause: string,
599
+ signal?: AbortSignal,
600
+ expectedRevision?: number,
601
+ ): Promise<boolean>;
555
602
  /**
556
603
  * Optional async write hook for upserting a single credential. When present,
557
604
  * `AuthStorage.#upsertOAuthCredential` routes through this instead of the
@@ -675,6 +722,7 @@ export type AuthStorageOptions = {
675
722
  * AuthStorage caller surfaces that to its own consumer unchanged.
676
723
  */
677
724
  fetchUsageReports?: (signal?: AbortSignal) => Promise<UsageReport[] | null>;
725
+ fetchUsageReportsForProvider?: (provider: Provider, signal?: AbortSignal) => Promise<UsageReport[] | null>;
678
726
  };
679
727
 
680
728
  // ─────────────────────────────────────────────────────────────────────────────
@@ -995,9 +1043,11 @@ type UsageRequestDescriptor = {
995
1043
  baseUrl?: string;
996
1044
  };
997
1045
 
998
- type AuthApiKeyOptions = {
1046
+ export type AuthApiKeyOptions = {
999
1047
  baseUrl?: string;
1000
1048
  modelId?: string;
1049
+ /** Select config registrations owned by one caller (for example a ModelRegistry). */
1050
+ owner?: object;
1001
1051
  /**
1002
1052
  * Caller's cancel signal. Threaded into any broker-bound OAuth refresh so
1003
1053
  * `ESC` / request abort actually kills a hung broker fetch instead of
@@ -1036,6 +1086,7 @@ export interface OAuthAccess {
1036
1086
  export interface InvalidateCredentialMatchingOptions {
1037
1087
  signal?: AbortSignal;
1038
1088
  sessionId?: string;
1089
+ owner?: object;
1039
1090
  }
1040
1091
 
1041
1092
  function isAbortSignalOption(
@@ -1054,17 +1105,40 @@ function safeUsageReport(report: UsageReport): SafeUsageReport {
1054
1105
  }
1055
1106
 
1056
1107
  function scrubHealthReason(reason: unknown, secrets: readonly string[] = []): string {
1057
- let value = reason instanceof Error ? reason.message : String(reason);
1108
+ let value = (reason instanceof Error ? reason.message : String(reason)).replace(/\u001b\[[0-?]*[ -/]*[@-~]/gu, "");
1058
1109
  for (const secret of secrets) {
1059
- if (secret.length > 0) value = value.split(secret).join("[redacted]");
1110
+ if (secret.length > 0) {
1111
+ value = value.split(secret).join("[redacted]");
1112
+ const escaped = [...secret].map(char => char.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&"));
1113
+ value = value.replace(new RegExp(escaped.join("\\s*"), "gu"), "[redacted]");
1114
+ }
1060
1115
  }
1061
1116
  value = value.replace(/bearer\s+[^\s,;]+/gi, "Bearer [redacted]");
1062
1117
  value = value.replace(/(api[_-]?key|token|secret|authorization)[=:]\s*[^\s,;]+/gi, "$1=[redacted]");
1063
- value = value.replace(/[\r\n\t ]+/g, " ").trim();
1118
+ value = value
1119
+ .replace(/[\x00-\x1f\x7f-\x9f]+/gu, " ")
1120
+ .replace(/[\r\n\t ]+/g, " ")
1121
+ .trim();
1064
1122
  if (value.length > 256) value = `${value.slice(0, 253)}...`;
1065
1123
  return value || "credential check failed";
1066
1124
  }
1067
1125
 
1126
+ /** Read optional broker error detail without allowing hostile objects to escape classification. */
1127
+ /** @internal Tested directly because hostile accessors must preserve the original error identity. */
1128
+ export function readBrokerErrorBody(error: unknown): string | undefined {
1129
+ if (error === null || (typeof error !== "object" && typeof error !== "function")) return undefined;
1130
+ try {
1131
+ if (!("body" in error)) return undefined;
1132
+ const body = (error as { body?: unknown }).body;
1133
+ return typeof body === "string" ? body : undefined;
1134
+ } catch {
1135
+ // Preserve the original provider error and its terminal behavior. A proxy or
1136
+ // throwing accessor is untrusted provider data, not a reason to replace the
1137
+ // refresh failure with an inspection error.
1138
+ throw error;
1139
+ }
1140
+ }
1141
+
1068
1142
  function requiresOpenAICodexProModel(provider: string, modelId: string | undefined): boolean {
1069
1143
  return provider === "openai-codex" && typeof modelId === "string" && modelId.includes("-spark");
1070
1144
  }
@@ -1213,7 +1287,15 @@ class AuthStorageUsageCache implements UsageCache {
1213
1287
  // In-memory representation
1214
1288
  // ─────────────────────────────────────────────────────────────────────────────
1215
1289
 
1216
- type StoredCredential = { id: number; credential: AuthCredential };
1290
+ type StoredCredential = { id: number; credential: AuthCredential; revision?: number };
1291
+ type IndexedStoredCredential<T extends AuthCredential = AuthCredential> = {
1292
+ id: number;
1293
+ credential: T;
1294
+ index: number;
1295
+ revision?: number;
1296
+ };
1297
+ type OAuthCredentialSelection = IndexedStoredCredential<OAuthCredential>;
1298
+ type ConfigApiKeyRegistration = { apiKey: string; envSourced: boolean; order: number };
1217
1299
 
1218
1300
  // ─────────────────────────────────────────────────────────────────────────────
1219
1301
  // AuthStorage Class
@@ -1231,6 +1313,18 @@ export class AuthStorage {
1231
1313
  #data: Map<string, StoredCredential[]> = new Map();
1232
1314
  #runtimeOverrides: Map<string, string> = new Map();
1233
1315
  #configOverrides: Map<string, string> = new Map();
1316
+ /** Effective config override registrations, including owner-scoped model registries. */
1317
+ #configOverrideRegistrations: Map<string, Map<object, ConfigApiKeyRegistration>> = new Map();
1318
+ #unownedConfigOverrides: Map<string, ConfigApiKeyRegistration> = new Map();
1319
+ #configOverrideOrder = 0;
1320
+ /**
1321
+ * Providers whose config override was resolved from a models.yml `apiKeyEnv`
1322
+ * indirection rather than a literal `apiKey` pin. An env pointer is not a
1323
+ * pinned secret: the pointed-to value (shell env, trusted env files) can go
1324
+ * stale silently, and the 401 rotation machinery cannot repair it. A stored
1325
+ * api_key credential from `auth login` therefore outranks it.
1326
+ */
1327
+ #configOverrideEnvSourced: Set<string> = new Set();
1234
1328
  #runtimeCredentialSelectors: Map<string, AuthCredentialSelector> = new Map();
1235
1329
  /** Soft runtime credential preference per provider; quota failures may rotate away from it. */
1236
1330
  #runtimePreferredCredentialSelectors: Map<string, AuthCredentialSelector> = new Map();
@@ -1256,12 +1350,14 @@ export class AuthStorage {
1256
1350
  #credentialRankingMode: CredentialRankingMode = "balanced";
1257
1351
  #usageLogger?: UsageLogger;
1258
1352
  #fallbackResolver?: (provider: string) => string | undefined;
1353
+ #ownedFallbackResolvers: Map<object, (provider: string) => string | undefined> = new Map();
1259
1354
  #store: AuthCredentialStore;
1260
1355
  #configValueResolver: (config: string, cacheScope?: string) => Promise<string | undefined>;
1261
1356
  #resolvedStoredApiKeyValues: Map<string, Map<string, { fingerprint: string; usable: boolean }>> = new Map();
1262
1357
  #storedApiKeyResolutionInFlight: Map<string, Map<string, Promise<string | undefined>>> = new Map();
1263
1358
  #refreshOAuthCredentialOverride?: AuthStorageOptions["refreshOAuthCredential"];
1264
1359
  #fetchUsageReportsOverride?: AuthStorageOptions["fetchUsageReports"];
1360
+ #fetchUsageReportsForProviderOverride?: AuthStorageOptions["fetchUsageReportsForProvider"];
1265
1361
  #sourceLabel?: string;
1266
1362
  #credentialDisabledListeners: Set<(event: CredentialDisabledEvent) => void | Promise<void>> = new Set();
1267
1363
  /**
@@ -1291,6 +1387,10 @@ export class AuthStorage {
1291
1387
 
1292
1388
  constructor(store: AuthCredentialStore, options: AuthStorageOptions = {}) {
1293
1389
  this.#store = store;
1390
+ store.onSnapshotChanged?.(() => {
1391
+ this.#reloadCredentialRowsFromStore();
1392
+ void this.reload();
1393
+ });
1294
1394
  this.#configValueResolver = options.configValueResolver ?? defaultConfigValueResolver;
1295
1395
  this.#usageProviderResolver = options.usageProviderResolver ?? resolveDefaultUsageProvider;
1296
1396
  this.#rankingStrategyResolver = options.rankingStrategyResolver ?? resolveDefaultRankingStrategy;
@@ -1300,6 +1400,7 @@ export class AuthStorage {
1300
1400
  this.#credentialRankingMode = options.credentialRankingMode ?? "balanced";
1301
1401
  this.#refreshOAuthCredentialOverride = options.refreshOAuthCredential;
1302
1402
  this.#fetchUsageReportsOverride = options.fetchUsageReports;
1403
+ this.#fetchUsageReportsForProviderOverride = options.fetchUsageReportsForProvider;
1303
1404
  this.#sourceLabel = options.sourceLabel;
1304
1405
  if (options.onCredentialDisabled) {
1305
1406
  // Constructor-registered subscribers are permanent for this AuthStorage's lifetime;
@@ -1344,6 +1445,15 @@ export class AuthStorage {
1344
1445
  getGeneration(): number {
1345
1446
  return this.#generation;
1346
1447
  }
1448
+ getCache(key: string, options?: { includeExpired?: boolean }): string | null {
1449
+ return this.#store.getCache(key, options);
1450
+ }
1451
+ setCache(key: string, value: string, expiresAtSec: number): void {
1452
+ this.#store.setCache(key, value, expiresAtSec);
1453
+ }
1454
+ allocateMonotonicSequence(key: string, expiresAtSec: number): number {
1455
+ return this.#store.allocateMonotonicSequence(key, expiresAtSec);
1456
+ }
1347
1457
  getProviderConfigurationGeneration(provider: string): number {
1348
1458
  return this.#getProviderConfigurationGeneration(provider);
1349
1459
  }
@@ -1356,13 +1466,29 @@ export class AuthStorage {
1356
1466
  #getProviderConfigurationGeneration(provider: string): number {
1357
1467
  return this.#providerConfigurationGenerations.get(resolveOAuthStorageProvider(provider)) ?? 1;
1358
1468
  }
1359
- getProviderEvidenceGeneration(provider: string, resolvedApiKey?: string): string {
1469
+ #configOverrideRegistration(provider: string, owner?: object): ConfigApiKeyRegistration | undefined {
1360
1470
  const storageProvider = resolveOAuthStorageProvider(provider);
1471
+ if (owner) {
1472
+ return (
1473
+ this.#configOverrideRegistrations.get(storageProvider)?.get(owner) ??
1474
+ this.#unownedConfigOverrides.get(storageProvider)
1475
+ );
1476
+ }
1477
+ const apiKey = this.#configOverrides.get(storageProvider);
1478
+ return apiKey === undefined
1479
+ ? undefined
1480
+ : { apiKey, envSourced: this.#configOverrideEnvSourced.has(storageProvider), order: 0 };
1481
+ }
1482
+ #hasConfigOverride(provider: string, owner?: object): boolean {
1483
+ return this.#configOverrideRegistration(provider, owner) !== undefined;
1484
+ }
1485
+ getProviderEvidenceGeneration(provider: string, resolvedApiKey?: string, owner?: object): string {
1486
+ const storageProvider = resolveOAuthStorageProvider(provider);
1487
+ provider = storageProvider;
1361
1488
  const evidenceApiKey = resolvedApiKey;
1489
+ const configOverride = this.#configOverrideRegistration(storageProvider, owner);
1362
1490
  const storedLiteral =
1363
- evidenceApiKey === undefined ||
1364
- this.#runtimeOverrides.has(storageProvider) ||
1365
- this.#configOverrides.has(storageProvider)
1491
+ evidenceApiKey === undefined || this.#runtimeOverrides.has(storageProvider) || configOverride !== undefined
1366
1492
  ? undefined
1367
1493
  : this.#getCredentialsForProvider(storageProvider).find(
1368
1494
  (credential): credential is Extract<AuthCredential, { type: "api_key" }> =>
@@ -1379,7 +1505,7 @@ export class AuthStorage {
1379
1505
  }
1380
1506
  let selectedCredential: ({ index: number } & StoredCredential) | undefined;
1381
1507
  try {
1382
- selectedCredential = this.#resolveSelectedStoredCredential(provider, undefined, undefined);
1508
+ selectedCredential = this.#resolveSelectedStoredCredential(provider, owner ? { owner } : undefined, undefined);
1383
1509
  } catch {
1384
1510
  return crypto
1385
1511
  .createHash("sha256")
@@ -1395,7 +1521,7 @@ export class AuthStorage {
1395
1521
  credential.type === "oauth" && Number.isFinite(credential.expires) && credential.expires > Date.now(),
1396
1522
  );
1397
1523
  const effectiveEnvKey =
1398
- this.#runtimeOverrides.get(provider) || this.#configOverrides.get(provider) || hasApiKey || hasUsableOAuth
1524
+ this.#runtimeOverrides.get(provider) || configOverride?.apiKey || hasApiKey || hasUsableOAuth
1399
1525
  ? undefined
1400
1526
  : getEnvApiKey(provider);
1401
1527
  const storedApiKeyFingerprint = credentials
@@ -1543,12 +1669,21 @@ export class AuthStorage {
1543
1669
  }
1544
1670
  }
1545
1671
 
1546
- /** Set the selector derived from a durable session pin or a session seed. */
1547
- setSessionCredentialSelector(scopeId: string, provider: string, selector: AuthCredentialSelector): void {
1672
+ /**
1673
+ * Set the selector derived from a durable session pin or a session seed.
1674
+ * `owner` scopes config-override validation to one ModelRegistry; omitted
1675
+ * owners retain process-wide caller semantics.
1676
+ */
1677
+ setSessionCredentialSelector(
1678
+ scopeId: string,
1679
+ provider: string,
1680
+ selector: AuthCredentialSelector,
1681
+ owner?: object,
1682
+ ): void {
1548
1683
  const scope = scopeId.trim();
1549
1684
  if (!scope) throw new Error("Credential scope id must not be empty");
1550
1685
  const storageProvider = resolveOAuthStorageProvider(provider);
1551
- this.#assertCredentialSelectorUsable(storageProvider, selector);
1686
+ this.#assertCredentialSelectorUsable(storageProvider, selector, owner);
1552
1687
  const selectors = this.#sessionCredentialSelectors.get(scope) ?? new Map<string, AuthCredentialSelector>();
1553
1688
  selectors.set(storageProvider, selector);
1554
1689
  this.#sessionCredentialSelectors.set(scope, selectors);
@@ -1607,23 +1742,32 @@ export class AuthStorage {
1607
1742
  }
1608
1743
 
1609
1744
  /** @internal Return cache provenance for an exact stored literal API-key row without resolving its value. */
1610
- getStoredLiteralApiKeyEvidenceGeneration(provider: string, selector: AuthCredentialSelector): string | undefined {
1745
+ getStoredLiteralApiKeyEvidenceGeneration(
1746
+ provider: string,
1747
+ selector: AuthCredentialSelector,
1748
+ owner?: object,
1749
+ ): string | undefined {
1611
1750
  if (selector.kind !== "id") return undefined;
1612
1751
  const storageProvider = resolveOAuthStorageProvider(provider);
1613
- if (this.#runtimeOverrides.has(storageProvider) || this.#configOverrides.has(storageProvider)) return undefined;
1752
+ if (this.#runtimeOverrides.has(storageProvider) || this.#hasConfigOverride(storageProvider, owner))
1753
+ return undefined;
1614
1754
  const selected = this.#findCredentialBySelector(storageProvider, selector);
1615
1755
  if (selected?.credential.type !== "api_key") return undefined;
1616
1756
  const key = selected.credential.key;
1617
1757
  if (!key || key.startsWith("!") || process.env[key] !== undefined) return undefined;
1618
- return this.getProviderEvidenceGeneration(storageProvider, key);
1758
+ return this.getProviderEvidenceGeneration(storageProvider, key, owner);
1619
1759
  }
1620
1760
 
1621
- /** Validate and canonicalize an OAuth-only selector for account pinning. */
1622
- resolveOAuthPinTarget(provider: string, selector: AuthCredentialSelector): OAuthPinTarget {
1761
+ /**
1762
+ * Validate and canonicalize an OAuth-only selector for account pinning.
1763
+ * `owner` scopes config-override checks to one ModelRegistry; omitted owners
1764
+ * retain process-wide caller semantics.
1765
+ */
1766
+ resolveOAuthPinTarget(provider: string, selector: AuthCredentialSelector, owner?: object): OAuthPinTarget {
1623
1767
  const storageProvider = resolveOAuthStorageProvider(provider);
1624
1768
  if (
1625
1769
  this.#runtimeOverrides.has(storageProvider) ||
1626
- this.#configOverrides.has(storageProvider) ||
1770
+ this.#hasConfigOverride(storageProvider, owner) ||
1627
1771
  getEnvApiKey(storageProvider)
1628
1772
  ) {
1629
1773
  throw new OAuthCredentialSelectorError(
@@ -1820,8 +1964,8 @@ export class AuthStorage {
1820
1964
  }
1821
1965
 
1822
1966
  /** Whether a provider is currently authenticated by a config API-key override. */
1823
- hasConfigApiKey(provider: string): boolean {
1824
- return Boolean(this.#configOverrides.get(resolveOAuthStorageProvider(provider)));
1967
+ hasConfigApiKey(provider: string, owner?: object): boolean {
1968
+ return Boolean(this.#configOverrideRegistration(provider, owner)?.apiKey);
1825
1969
  }
1826
1970
 
1827
1971
  /**
@@ -1882,7 +2026,8 @@ export class AuthStorage {
1882
2026
  * runtime API-key override (`--api-key`), or a config-sourced API key
1883
2027
  * (`models.yml` `apiKey`) would each re-decide the credential on the very
1884
2028
  * next {@link AuthStorage.getApiKey} call and make this switch appear to
1885
- * silently do nothing.
2029
+ * silently do nothing. `owner` scopes the config-override check to one
2030
+ * ModelRegistry; omitted owners retain process-wide caller semantics.
1886
2031
  *
1887
2032
  * Deliberately does not touch credential-blocked state: if the target row
1888
2033
  * is still backoff-blocked from a prior quota failure, the existing
@@ -1890,14 +2035,19 @@ export class AuthStorage {
1890
2035
  * falls back to a usable account instead of re-issuing a request that would
1891
2036
  * just draw another 429/quota error.
1892
2037
  */
1893
- switchSessionCredential(provider: string, sessionId: string, selector: AuthCredentialSelector): void {
2038
+ switchSessionCredential(
2039
+ provider: string,
2040
+ sessionId: string,
2041
+ selector: AuthCredentialSelector,
2042
+ owner?: object,
2043
+ ): void {
1894
2044
  const storageProvider = resolveOAuthStorageProvider(provider);
1895
2045
  if (this.#runtimeOverrides.has(storageProvider)) {
1896
2046
  throw new Error(
1897
2047
  `Cannot switch credential for ${provider}: a runtime API key override (--api-key) is active and always wins`,
1898
2048
  );
1899
2049
  }
1900
- if (this.#configOverrides.has(storageProvider)) {
2050
+ if (this.#hasConfigOverride(storageProvider, owner)) {
1901
2051
  throw new Error(
1902
2052
  `Cannot switch credential for ${provider}: a config API key override (models.yml) is active and always wins`,
1903
2053
  );
@@ -1926,38 +2076,122 @@ export class AuthStorage {
1926
2076
  *
1927
2077
  * Lower priority than {@link setRuntimeApiKey} so a CLI `--api-key`
1928
2078
  * still wins for the duration of a single invocation.
2079
+ *
2080
+ * `options.owner` scopes the override to one registry or other caller. The
2081
+ * unscoped form is process-wide and is retained for standalone callers.
2082
+ *
2083
+ * `options.envSourced` marks the value as resolved from a models.yml
2084
+ * `apiKeyEnv` indirection. Unlike a literal pin, an env pointer only says
2085
+ * where to look for a key; when the user has since run `auth login`, the
2086
+ * stored api_key credential is the fresher, actively-managed secret and
2087
+ * wins over the indirection (stored OAuth credentials still yield, so a
2088
+ * custom-endpoint bearer is never replaced by an upstream OAuth token).
1929
2089
  */
1930
- setConfigApiKey(provider: string, apiKey: string): void {
2090
+ setConfigApiKey(provider: string, apiKey: string, options: { envSourced?: boolean; owner?: object } = {}): void {
1931
2091
  const storageProvider = resolveOAuthStorageProvider(provider);
1932
- this.#configOverrides.set(storageProvider, apiKey);
1933
- this.#bumpGeneration("set-config-api-key", storageProvider);
2092
+ const registration = {
2093
+ apiKey,
2094
+ envSourced: options.envSourced === true,
2095
+ order: ++this.#configOverrideOrder,
2096
+ };
2097
+ if (options.owner) {
2098
+ const registrations = this.#configOverrideRegistrations.get(storageProvider) ?? new Map();
2099
+ registrations.set(options.owner, registration);
2100
+ this.#configOverrideRegistrations.set(storageProvider, registrations);
2101
+ } else {
2102
+ this.#unownedConfigOverrides.set(storageProvider, registration);
2103
+ }
2104
+ this.#reconcileConfigApiKey(storageProvider, "set-config-api-key", true);
1934
2105
  }
1935
2106
 
1936
2107
  /**
1937
2108
  * Remove a single config-sourced API key override.
1938
2109
  */
1939
- removeConfigApiKey(provider: string): void {
2110
+ removeConfigApiKey(provider: string, owner?: object): void {
1940
2111
  const storageProvider = resolveOAuthStorageProvider(provider);
1941
- if (this.#configOverrides.delete(storageProvider)) this.#bumpGeneration("remove-config-api-key", storageProvider);
2112
+ if (owner) {
2113
+ const registrations = this.#configOverrideRegistrations.get(storageProvider);
2114
+ if (!registrations?.delete(owner)) return;
2115
+ if (registrations.size === 0) this.#configOverrideRegistrations.delete(storageProvider);
2116
+ } else {
2117
+ if (!this.#unownedConfigOverrides.delete(storageProvider)) return;
2118
+ }
2119
+ this.#reconcileConfigApiKey(storageProvider, "remove-config-api-key", true);
1942
2120
  }
1943
2121
 
1944
2122
  /**
1945
- * Drop every config-sourced API key. Called by `ModelRegistry` before
1946
- * re-parsing `models.yml` so removed entries actually disappear.
2123
+ * Drop config-sourced API keys. An owner removes only its own registrations;
2124
+ * the unscoped form remains an explicit global reset for callers that own the
2125
+ * entire AuthStorage instance.
1947
2126
  */
1948
- clearConfigApiKeys(): void {
1949
- const providers = [...this.#configOverrides.keys()];
1950
- if (providers.length === 0) return;
1951
- this.#configOverrides.clear();
1952
- for (const provider of providers) this.#bumpGeneration("clear-config-api-keys", provider);
2127
+ clearConfigApiKeys(owner?: object): void {
2128
+ if (owner) {
2129
+ const providers = [...this.#configOverrideRegistrations.entries()]
2130
+ .filter(([, registrations]) => registrations.has(owner))
2131
+ .map(([provider]) => provider);
2132
+ for (const provider of providers) this.removeConfigApiKey(provider, owner);
2133
+ return;
2134
+ }
2135
+ const providers = new Set([
2136
+ ...this.#configOverrides.keys(),
2137
+ ...this.#unownedConfigOverrides.keys(),
2138
+ ...this.#configOverrideRegistrations.keys(),
2139
+ ]);
2140
+ this.#unownedConfigOverrides.clear();
2141
+ this.#configOverrideRegistrations.clear();
2142
+ for (const provider of providers) this.#reconcileConfigApiKey(provider, "clear-config-api-keys", true);
2143
+ }
2144
+
2145
+ #reconcileConfigApiKey(provider: string, reason: string, forceGeneration = false): void {
2146
+ const previous = this.#configOverrides.get(provider);
2147
+ const previousEnvSourced = this.#configOverrideEnvSourced.has(provider);
2148
+ let winner: { apiKey: string; envSourced: boolean; order: number } | undefined =
2149
+ this.#unownedConfigOverrides.get(provider);
2150
+ for (const registration of this.#configOverrideRegistrations.get(provider)?.values() ?? []) {
2151
+ if (!winner || registration.order > winner.order) winner = registration;
2152
+ }
2153
+ if (!winner) {
2154
+ this.#configOverrides.delete(provider);
2155
+ this.#configOverrideEnvSourced.delete(provider);
2156
+ } else {
2157
+ this.#configOverrides.set(provider, winner.apiKey);
2158
+ if (winner.envSourced) this.#configOverrideEnvSourced.add(provider);
2159
+ else this.#configOverrideEnvSourced.delete(provider);
2160
+ }
2161
+ const current = this.#configOverrides.get(provider);
2162
+ const currentEnvSourced = this.#configOverrideEnvSourced.has(provider);
2163
+ if (forceGeneration || previous !== current || previousEnvSourced !== currentEnvSourced) {
2164
+ this.#bumpGeneration(reason, provider);
2165
+ }
1953
2166
  }
1954
2167
 
1955
2168
  /**
1956
2169
  * Set a fallback resolver for API keys not found in storage or env vars.
1957
2170
  * Used for custom provider keys from models.json.
1958
2171
  */
1959
- setFallbackResolver(resolver: (provider: string) => string | undefined): void {
2172
+ setFallbackResolver(resolver: (provider: string) => string | undefined, owner?: object): () => void {
2173
+ if (owner) {
2174
+ this.#ownedFallbackResolvers.set(owner, resolver);
2175
+ return () => {
2176
+ if (this.#ownedFallbackResolvers.get(owner) !== resolver) return;
2177
+ this.#ownedFallbackResolvers.delete(owner);
2178
+ };
2179
+ }
1960
2180
  this.#fallbackResolver = resolver;
2181
+ return () => {
2182
+ if (this.#fallbackResolver === resolver) this.#fallbackResolver = undefined;
2183
+ };
2184
+ }
2185
+
2186
+ #resolveFallback(provider: string, owner?: object): string | undefined {
2187
+ if (owner) {
2188
+ return this.#ownedFallbackResolvers.get(owner)?.(provider) ?? this.#fallbackResolver?.(provider);
2189
+ }
2190
+ for (const resolver of [...this.#ownedFallbackResolvers.values()].reverse()) {
2191
+ const value = resolver(provider);
2192
+ if (value !== undefined) return value;
2193
+ }
2194
+ return this.#fallbackResolver?.(provider);
1961
2195
  }
1962
2196
 
1963
2197
  /**
@@ -1965,11 +2199,27 @@ export class AuthStorage {
1965
2199
  */
1966
2200
  async reload(): Promise<void> {
1967
2201
  await this.#store.waitForReady?.();
2202
+ this.#reloadCredentialRowsFromStore();
2203
+ }
2204
+
2205
+ /**
2206
+ * Acquire a store-owned provider-admission ticket when the backing store
2207
+ * provides one (for example, a remote broker snapshot store). Local stores
2208
+ * need no additional ordering and return `undefined`.
2209
+ */
2210
+ async acquireCredentialDispatchTicket(
2211
+ provider: Provider,
2212
+ signal?: AbortSignal,
2213
+ ): Promise<CredentialDispatchTicket | undefined> {
2214
+ return this.#store.acquireCredentialDispatchTicket?.(resolveOAuthStorageProvider(provider), signal);
2215
+ }
2216
+
2217
+ #reloadCredentialRowsFromStore(): void {
1968
2218
  const records = this.#store.listAuthCredentials();
1969
2219
  const grouped = new Map<string, StoredCredential[]>();
1970
2220
  for (const record of records) {
1971
2221
  const list = grouped.get(record.provider) ?? [];
1972
- list.push({ id: record.id, credential: record.credential });
2222
+ list.push({ id: record.id, credential: record.credential, revision: record.revision });
1973
2223
  grouped.set(record.provider, list);
1974
2224
  }
1975
2225
 
@@ -2010,6 +2260,12 @@ export class AuthStorage {
2010
2260
  #setStoredCredentials(provider: string, credentials: StoredCredential[]): void {
2011
2261
  const current = this.#data.get(provider) ?? [];
2012
2262
  if (storedCredentialArraysEqual(current, credentials)) return;
2263
+ const identityOrderChanged =
2264
+ current.length !== credentials.length ||
2265
+ current.some(
2266
+ (entry, index) =>
2267
+ entry.id !== credentials[index]?.id || entry.credential.type !== credentials[index]?.credential.type,
2268
+ );
2013
2269
  this.#resolvedStoredApiKeyValues.delete(provider);
2014
2270
  this.#storedApiKeyResolutionInFlight.delete(provider);
2015
2271
  if (credentials.length === 0) {
@@ -2017,6 +2273,7 @@ export class AuthStorage {
2017
2273
  } else {
2018
2274
  this.#data.set(provider, credentials);
2019
2275
  }
2276
+ if (identityOrderChanged) this.#resetProviderAssignments(resolveOAuthStorageProvider(provider));
2020
2277
  this.#bumpGeneration("credentials", provider);
2021
2278
  }
2022
2279
 
@@ -2217,10 +2474,7 @@ export class AuthStorage {
2217
2474
  }
2218
2475
  }
2219
2476
 
2220
- #findCredentialBySelector(
2221
- provider: string,
2222
- selector: AuthCredentialSelector,
2223
- ): ({ index: number } & StoredCredential) | undefined {
2477
+ #findCredentialBySelector(provider: string, selector: AuthCredentialSelector): IndexedStoredCredential | undefined {
2224
2478
  const stored = this.#getStoredCredentials(provider);
2225
2479
  for (let index = 0; index < stored.length; index++) {
2226
2480
  const entry = stored[index];
@@ -2263,13 +2517,13 @@ export class AuthStorage {
2263
2517
  );
2264
2518
  }
2265
2519
 
2266
- #assertCredentialSelectorUsable(provider: string, selector: AuthCredentialSelector): void {
2520
+ #assertCredentialSelectorUsable(provider: string, selector: AuthCredentialSelector, owner?: object): void {
2267
2521
  if (this.#runtimeOverrides.has(provider)) {
2268
2522
  throw new Error(
2269
2523
  `Credential selector ${this.#formatCredentialSelector(selector)} cannot be used for ${provider} while a runtime API key override is active`,
2270
2524
  );
2271
2525
  }
2272
- if (this.#configOverrides.has(provider)) {
2526
+ if (this.#hasConfigOverride(provider, owner)) {
2273
2527
  throw new Error(
2274
2528
  `Credential selector ${this.#formatCredentialSelector(selector)} cannot be used for ${provider} while a config API key override is active`,
2275
2529
  );
@@ -2285,8 +2539,8 @@ export class AuthStorage {
2285
2539
  * to an OAuth row specifically — the soft-preference/quota-fallback path is
2286
2540
  * meaningless for a single static API key.
2287
2541
  */
2288
- #assertPreferredCredentialSelectorUsable(provider: string, selector: AuthCredentialSelector): void {
2289
- if (this.#runtimeOverrides.has(provider) || this.#configOverrides.has(provider)) {
2542
+ #assertPreferredCredentialSelectorUsable(provider: string, selector: AuthCredentialSelector, owner?: object): void {
2543
+ if (this.#runtimeOverrides.has(provider) || this.#hasConfigOverride(provider, owner)) {
2290
2544
  throw new Error(
2291
2545
  `Preferred credential selector ${this.#formatCredentialSelector(selector)} cannot be used for ${provider} while an API key override is active`,
2292
2546
  );
@@ -2303,10 +2557,10 @@ export class AuthStorage {
2303
2557
  provider: string,
2304
2558
  options?: AuthApiKeyOptions,
2305
2559
  sessionId?: string,
2306
- ): ({ index: number } & StoredCredential) | undefined {
2560
+ ): IndexedStoredCredential | undefined {
2307
2561
  const selector = this.#getCredentialSelector(provider, options, sessionId);
2308
2562
  if (!selector) return undefined;
2309
- this.#assertCredentialSelectorUsable(resolveOAuthStorageProvider(provider), selector);
2563
+ this.#assertCredentialSelectorUsable(resolveOAuthStorageProvider(provider), selector, options?.owner);
2310
2564
  const selected = this.#findCredentialBySelector(provider, selector);
2311
2565
  if (!selected) {
2312
2566
  throw new Error(`No credential found for ${provider} matching ${this.#formatCredentialSelector(selector)}`);
@@ -2323,13 +2577,23 @@ export class AuthStorage {
2323
2577
  type: T,
2324
2578
  sessionId?: string,
2325
2579
  isUsable?: (credential: Extract<AuthCredential, { type: T }>, index: number) => boolean | undefined,
2326
- ): { credential: Extract<AuthCredential, { type: T }>; index: number } | undefined {
2327
- const credentials = this.#getCredentialsForProvider(provider)
2328
- .map((credential, index) => ({ credential, index }))
2580
+ ): IndexedStoredCredential<Extract<AuthCredential, { type: T }>> | undefined {
2581
+ const credentials = this.#getStoredCredentials(provider)
2582
+ .map((entry, index) => ({ entry, index }))
2329
2583
  .filter(
2330
- (entry): entry is { credential: Extract<AuthCredential, { type: T }>; index: number } =>
2331
- entry.credential.type === type,
2332
- );
2584
+ (
2585
+ item,
2586
+ ): item is {
2587
+ entry: StoredCredential & { credential: Extract<AuthCredential, { type: T }> };
2588
+ index: number;
2589
+ } => item.entry.credential.type === type,
2590
+ )
2591
+ .map(({ entry, index }) => ({
2592
+ id: entry.id,
2593
+ credential: entry.credential,
2594
+ index,
2595
+ revision: entry.revision,
2596
+ }));
2333
2597
 
2334
2598
  if (credentials.length === 0) return undefined;
2335
2599
 
@@ -2355,12 +2619,14 @@ export class AuthStorage {
2355
2619
  sessionId?: string,
2356
2620
  excludedIndices: ReadonlySet<number> = new Set(),
2357
2621
  includeKnownUnusable = false,
2358
- ): { credential: ApiKeyCredential; index: number } | undefined {
2359
- const credentials = this.#getCredentialsForProvider(provider)
2360
- .map((credential, index) => ({ credential, index }))
2622
+ ): IndexedStoredCredential<ApiKeyCredential> | undefined {
2623
+ const credentials = this.#getStoredCredentials(provider)
2624
+ .map((entry, index) => ({ entry, index }))
2361
2625
  .filter(
2362
- (entry): entry is { credential: ApiKeyCredential; index: number } => entry.credential.type === "api_key",
2363
- );
2626
+ (item): item is { entry: StoredCredential & { credential: ApiKeyCredential }; index: number } =>
2627
+ item.entry.credential.type === "api_key",
2628
+ )
2629
+ .map(({ entry, index }) => ({ id: entry.id, credential: entry.credential, index, revision: entry.revision }));
2364
2630
  if (credentials.length === 0) return undefined;
2365
2631
 
2366
2632
  const providerKey = this.#getProviderTypeKey(provider, "api_key");
@@ -2399,11 +2665,20 @@ export class AuthStorage {
2399
2665
  }
2400
2666
 
2401
2667
  /** Updates a credential at index after OAuth token refresh. */
2402
- #replaceCredentialAt(provider: string, index: number, credential: AuthCredential, persist = true): void {
2668
+ #replaceCredentialAt(
2669
+ provider: string,
2670
+ index: number,
2671
+ credential: AuthCredential,
2672
+ persist = true,
2673
+ expectedId?: number,
2674
+ ): void {
2403
2675
  const entries = this.#getStoredCredentials(provider);
2404
2676
  if (index < 0 || index >= entries.length) return;
2405
2677
  const target = entries[index];
2406
- if (persist) this.#store.updateAuthCredential(target.id, credential);
2678
+ if (expectedId !== undefined && target.id !== expectedId) {
2679
+ throw new Error("Credential authority changed during refresh");
2680
+ }
2681
+ if (persist && !this.#store.refreshSnapshot) this.#store.updateAuthCredential(target.id, credential);
2407
2682
  const updated = [...entries];
2408
2683
  updated[index] = { id: target.id, credential };
2409
2684
  this.#setStoredCredentials(provider, updated);
@@ -2434,15 +2709,18 @@ export class AuthStorage {
2434
2709
  index: number,
2435
2710
  expectedCredential: AuthCredential,
2436
2711
  disabledCause: string,
2712
+ expectedId?: number,
2437
2713
  ): boolean {
2438
2714
  const entries = this.#getStoredCredentials(provider);
2439
- if (index < 0 || index >= entries.length) return false;
2440
- const target = entries[index];
2715
+ const targetIndex = expectedId === undefined ? index : entries.findIndex(entry => entry.id === expectedId);
2716
+ if (targetIndex < 0 || targetIndex >= entries.length) return false;
2717
+ const target = entries[targetIndex];
2718
+ if (expectedId !== undefined && target.id !== expectedId) return false;
2441
2719
  const serialized = serializeCredential(provider, expectedCredential);
2442
2720
  if (!serialized) return false;
2443
2721
  const disabled = this.#store.tryDisableAuthCredentialIfMatches(target.id, serialized.data, disabledCause);
2444
2722
  if (!disabled) return false;
2445
- const updated = entries.filter((_value, idx) => idx !== index);
2723
+ const updated = entries.filter((_value, idx) => idx !== targetIndex);
2446
2724
  this.#setStoredCredentials(provider, updated);
2447
2725
  this.#clearSelectorsForRemovedCredential(provider, new Set([target.id]), entries);
2448
2726
  this.#resetProviderAssignments(provider);
@@ -2479,6 +2757,35 @@ export class AuthStorage {
2479
2757
  this.#emitCredentialDisabled({ provider, disabledCause });
2480
2758
  }
2481
2759
 
2760
+ /**
2761
+ * Disable one credential through an authoritative remote store and reconcile
2762
+ * this AuthStorage instance only after that write succeeds. Remote stores
2763
+ * must never be mutated through the synchronous local delete path because
2764
+ * their snapshots do not own persistence authority.
2765
+ */
2766
+ async #disableCredentialRemotely(
2767
+ provider: string,
2768
+ credentialId: number,
2769
+ disabledCause: string,
2770
+ signal?: AbortSignal,
2771
+ expectedRevision?: number,
2772
+ ): Promise<boolean> {
2773
+ const disable = this.#store.disableAuthCredentialRemote?.bind(this.#store);
2774
+ if (!disable) return false;
2775
+ const disabled = await disable(credentialId, disabledCause, signal, expectedRevision);
2776
+ if (!disabled) return false;
2777
+ const entries = this.#getStoredCredentials(provider);
2778
+ if (!entries.some(entry => entry.id === credentialId)) return true;
2779
+ this.#setStoredCredentials(
2780
+ provider,
2781
+ entries.filter(entry => entry.id !== credentialId),
2782
+ );
2783
+ this.#clearSelectorsForRemovedCredential(provider, new Set([credentialId]), entries);
2784
+ this.#resetProviderAssignments(provider);
2785
+ this.#emitCredentialDisabled({ provider, disabledCause });
2786
+ return true;
2787
+ }
2788
+
2482
2789
  /** Clear every selector whose durable/in-memory target was just removed. */
2483
2790
  #clearSelectorsForRemovedCredential(
2484
2791
  provider: string,
@@ -2553,7 +2860,7 @@ export class AuthStorage {
2553
2860
  : this.#store.replaceAuthCredentialsForProvider(storageProvider, deduped);
2554
2861
  this.#setStoredCredentials(
2555
2862
  storageProvider,
2556
- stored.map(record => ({ id: record.id, credential: record.credential })),
2863
+ stored.map(record => ({ id: record.id, credential: record.credential, revision: record.revision })),
2557
2864
  );
2558
2865
  this.#resetProviderAssignments(storageProvider);
2559
2866
  }
@@ -2568,6 +2875,7 @@ export class AuthStorage {
2568
2875
  provider: entry.provider,
2569
2876
  credential: redacted,
2570
2877
  identityKey: resolveCredentialIdentityKey(provider, persisted),
2878
+ ...(entry.revision === undefined ? {} : { revision: entry.revision }),
2571
2879
  };
2572
2880
  });
2573
2881
  }
@@ -2584,14 +2892,15 @@ export class AuthStorage {
2584
2892
  async importCredentialIfAbsent(
2585
2893
  provider: string,
2586
2894
  credential: AuthCredential,
2895
+ owner?: object,
2587
2896
  ): Promise<AuthCredentialIfAbsentSnapshotResult> {
2588
2897
  const storageProvider = resolveOAuthStorageProvider(provider);
2589
2898
  if (this.#runtimeOverrides.has(storageProvider))
2590
2899
  return this.#snapshotSkipResult(storageProvider, "skipped-existing-runtime");
2591
- if (this.#configOverrides.has(storageProvider))
2900
+ if (this.#hasConfigOverride(storageProvider, owner))
2592
2901
  return this.#snapshotSkipResult(storageProvider, "skipped-existing-config");
2593
2902
  if (getEnvApiKey(storageProvider)) return this.#snapshotSkipResult(storageProvider, "skipped-existing-env");
2594
- if (this.#fallbackResolver?.(storageProvider))
2903
+ if (this.#resolveFallback(storageProvider, owner))
2595
2904
  return this.#snapshotSkipResult(storageProvider, "skipped-existing-fallback");
2596
2905
 
2597
2906
  const result = this.#store.upsertAuthCredentialRemoteIfAbsent
@@ -2599,7 +2908,7 @@ export class AuthStorage {
2599
2908
  : this.#store.upsertAuthCredentialForProviderIfAbsent(storageProvider, credential);
2600
2909
  this.#setStoredCredentials(
2601
2910
  storageProvider,
2602
- result.entries.map(entry => ({ id: entry.id, credential: entry.credential })),
2911
+ result.entries.map(entry => ({ id: entry.id, credential: entry.credential, revision: entry.revision })),
2603
2912
  );
2604
2913
  this.#resetProviderAssignments(storageProvider);
2605
2914
  if (result.inserted) this.#invalidateUsageCacheForProvider(storageProvider);
@@ -2617,7 +2926,7 @@ export class AuthStorage {
2617
2926
  : this.#store.upsertAuthCredentialForProvider(provider, credential);
2618
2927
  this.#setStoredCredentials(
2619
2928
  provider,
2620
- stored.map(record => ({ id: record.id, credential: record.credential })),
2929
+ stored.map(record => ({ id: record.id, credential: record.credential, revision: record.revision })),
2621
2930
  );
2622
2931
  this.#resetProviderAssignments(provider);
2623
2932
  this.#invalidateUsageCacheForProvider(provider);
@@ -2667,23 +2976,44 @@ export class AuthStorage {
2667
2976
  * Check if any form of auth is configured for a provider.
2668
2977
  * Unlike getApiKey(), this doesn't refresh OAuth tokens.
2669
2978
  */
2670
- #hasConfiguredAuth(storageProvider: string): boolean {
2979
+ #hasConfiguredAuth(storageProvider: string, owner?: object): boolean {
2671
2980
  if (this.hasRuntimeApiKey(storageProvider)) return true;
2672
- if (this.#configOverrides.has(storageProvider)) return true;
2981
+ if (this.#hasConfigOverride(storageProvider, owner)) return true;
2673
2982
  if (this.#getCredentialsForProvider(storageProvider).length > 0) return true;
2674
2983
  if (getEnvApiKey(storageProvider)) return true;
2675
- if (this.#fallbackResolver?.(storageProvider)) return true;
2984
+ if (this.#resolveFallback(storageProvider, owner)) return true;
2676
2985
  return false;
2677
2986
  }
2678
2987
 
2679
- hasAuth(provider: string, sessionId?: string): boolean {
2988
+ disableCredentialByIdIfRevision(id: number, expectedRevision: number, disabledCause: string): boolean {
2989
+ const cause = normalizeDisabledCause(disabledCause);
2990
+ if (this.#store.tryDisableAuthCredentialIfRevision?.(id, expectedRevision, cause) !== true) return false;
2991
+ for (const [provider, entries] of this.#data) {
2992
+ if (!entries.some(entry => entry.id === id)) continue;
2993
+ this.#setStoredCredentials(
2994
+ provider,
2995
+ entries.filter(entry => entry.id !== id),
2996
+ );
2997
+ this.#clearSelectorsForRemovedCredential(provider, new Set([id]), entries);
2998
+ this.#resetProviderAssignments(provider);
2999
+ this.#emitCredentialDisabled({ provider, disabledCause: cause });
3000
+ return true;
3001
+ }
3002
+ return false;
3003
+ }
3004
+
3005
+ hasAuth(provider: string, sessionId?: string, options?: Pick<AuthApiKeyOptions, "owner">): boolean {
2680
3006
  const storageProvider = resolveOAuthStorageProvider(provider);
2681
3007
  try {
2682
- this.#resolveSelectedStoredCredential(storageProvider, undefined, sessionId);
3008
+ this.#resolveSelectedStoredCredential(
3009
+ storageProvider,
3010
+ options?.owner ? { owner: options.owner } : undefined,
3011
+ sessionId,
3012
+ );
2683
3013
  } catch {
2684
3014
  return false;
2685
3015
  }
2686
- return this.#hasConfiguredAuth(storageProvider);
3016
+ return this.#hasConfiguredAuth(storageProvider, options?.owner);
2687
3017
  }
2688
3018
 
2689
3019
  /**
@@ -2691,15 +3021,24 @@ export class AuthStorage {
2691
3021
  * Mirrors getApiKey selector validation, overrides, session OAuth stickiness,
2692
3022
  * cached command-key usability, OAuth retry, and environment fallback order.
2693
3023
  */
2694
- getEffectiveCredentialType(provider: string, sessionId?: string): AuthCredential["type"] | undefined {
3024
+ getEffectiveCredentialType(
3025
+ provider: string,
3026
+ sessionId?: string,
3027
+ options?: Pick<AuthApiKeyOptions, "owner">,
3028
+ ): AuthCredential["type"] | undefined {
2695
3029
  const storageProvider = resolveOAuthStorageProvider(provider);
2696
3030
  let selected: ({ index: number } & StoredCredential) | undefined;
2697
3031
  try {
2698
- selected = this.#resolveSelectedStoredCredential(storageProvider, undefined, sessionId);
3032
+ selected = this.#resolveSelectedStoredCredential(
3033
+ storageProvider,
3034
+ options?.owner ? { owner: options.owner } : undefined,
3035
+ sessionId,
3036
+ );
2699
3037
  } catch {
2700
3038
  return undefined;
2701
3039
  }
2702
- if (this.hasRuntimeApiKey(storageProvider) || this.#configOverrides.has(storageProvider)) return "api_key";
3040
+ if (this.hasRuntimeApiKey(storageProvider) || this.#hasConfigOverride(storageProvider, options?.owner))
3041
+ return "api_key";
2703
3042
  if (selected) return selected.credential.type;
2704
3043
 
2705
3044
  const credentials = this.#getCredentialsForProvider(storageProvider);
@@ -2712,19 +3051,23 @@ export class AuthStorage {
2712
3051
  }
2713
3052
  if (credentials.some(credential => credential.type === "oauth")) return "oauth";
2714
3053
  if (apiKeys.length > 0) return "api_key";
2715
- if (getEnvApiKey(storageProvider) || this.#fallbackResolver?.(storageProvider)) return "api_key";
3054
+ if (getEnvApiKey(storageProvider) || this.#resolveFallback(storageProvider, options?.owner)) return "api_key";
2716
3055
  return undefined;
2717
3056
  }
2718
3057
 
2719
3058
  /**
2720
3059
  * Check whether configured auth is currently usable without resolving credentials.
2721
3060
  */
2722
- hasUsableAuth(provider: string): boolean {
3061
+ hasUsableAuth(provider: string, options?: Pick<AuthApiKeyOptions, "owner">): boolean {
2723
3062
  const storageProvider = resolveOAuthStorageProvider(provider);
2724
3063
  try {
2725
- const selectedCredential = this.#resolveSelectedStoredCredential(storageProvider, undefined, undefined);
3064
+ const selectedCredential = this.#resolveSelectedStoredCredential(
3065
+ storageProvider,
3066
+ options?.owner ? { owner: options.owner } : undefined,
3067
+ undefined,
3068
+ );
2726
3069
  if (this.hasRuntimeApiKey(storageProvider)) return true;
2727
- if (this.#configOverrides.has(storageProvider)) return true;
3070
+ if (this.#hasConfigOverride(storageProvider, options?.owner)) return true;
2728
3071
  if (selectedCredential) {
2729
3072
  if (selectedCredential.credential.type === "api_key") {
2730
3073
  return (
@@ -2765,7 +3108,7 @@ export class AuthStorage {
2765
3108
  } catch {
2766
3109
  return false;
2767
3110
  }
2768
- return Boolean(getEnvApiKey(storageProvider) || this.#fallbackResolver?.(storageProvider));
3111
+ return Boolean(getEnvApiKey(storageProvider) || this.#resolveFallback(storageProvider, options?.owner));
2769
3112
  }
2770
3113
 
2771
3114
  /**
@@ -2778,10 +3121,14 @@ export class AuthStorage {
2778
3121
  /**
2779
3122
  * Get OAuth credentials for a provider.
2780
3123
  */
2781
- getOAuthCredential(provider: string, sessionId?: string): OAuthCredential | undefined {
3124
+ getOAuthCredential(
3125
+ provider: string,
3126
+ sessionId?: string,
3127
+ options?: Pick<AuthApiKeyOptions, "owner">,
3128
+ ): OAuthCredential | undefined {
2782
3129
  const selected = this.#resolveSelectedStoredCredential(
2783
3130
  resolveOAuthStorageProvider(provider),
2784
- undefined,
3131
+ options?.owner ? { owner: options.owner } : undefined,
2785
3132
  sessionId,
2786
3133
  );
2787
3134
  if (selected?.credential.type === "oauth") return selected.credential;
@@ -2798,7 +3145,11 @@ export class AuthStorage {
2798
3145
  * first call before any `getApiKey` has been issued, or single-credential setups).
2799
3146
  * Returns `undefined` when no OAuth credential carries an `accountId`.
2800
3147
  */
2801
- getOAuthAccountId(provider: string, sessionId?: string): string | undefined {
3148
+ getOAuthAccountId(
3149
+ provider: string,
3150
+ sessionId?: string,
3151
+ options?: Pick<AuthApiKeyOptions, "owner">,
3152
+ ): string | undefined {
2802
3153
  provider = resolveOAuthStorageProvider(provider);
2803
3154
  const allCredentials = this.#getCredentialsForProvider(provider);
2804
3155
  const oauthCredentials = allCredentials.filter((c): c is OAuthCredential => c.type === "oauth");
@@ -2806,11 +3157,15 @@ export class AuthStorage {
2806
3157
 
2807
3158
  // Runtime / config overrides bypass OAuth account_uuid attribution — the
2808
3159
  // caller is authenticating with an explicit key, not the broker's OAuth.
2809
- if (this.#runtimeOverrides.has(provider) || this.#configOverrides.has(provider)) return undefined;
3160
+ if (this.#runtimeOverrides.has(provider) || this.#hasConfigOverride(provider, options?.owner)) return undefined;
2810
3161
 
2811
3162
  // Prefer the session-sticky credential when available.
2812
3163
 
2813
- const scopedSelection = this.#resolveSelectedStoredCredential(provider, undefined, sessionId);
3164
+ const scopedSelection = this.#resolveSelectedStoredCredential(
3165
+ provider,
3166
+ options?.owner ? { owner: options.owner } : undefined,
3167
+ sessionId,
3168
+ );
2814
3169
  if (scopedSelection?.credential.type === "api_key") return undefined;
2815
3170
  if (scopedSelection?.credential.type === "oauth") {
2816
3171
  const accountId = scopedSelection.credential.accountId;
@@ -2826,7 +3181,7 @@ export class AuthStorage {
2826
3181
  // account_uuid injection would misattribute traffic. Only apply this guard when
2827
3182
  // sessionPref is absent; a recorded OAuth sticky (sessionPref.type === "oauth") must
2828
3183
  // NOT be blocked even if an env key also happens to exist.
2829
- if (!sessionPref && (getEnvApiKey(provider) || this.#fallbackResolver?.(provider))) return undefined;
3184
+ if (!sessionPref && (getEnvApiKey(provider) || this.#resolveFallback(provider, options?.owner))) return undefined;
2830
3185
  // Resolve the sticky index against the full credential list — the index is
2831
3186
  // recorded against the unfiltered provider array (by #recordSessionCredential /
2832
3187
  // #tryOAuthCredential), not the OAuth-only subset, so dereferencing it into the
@@ -2951,6 +3306,16 @@ export class AuthStorage {
2951
3306
  credentials = await loginKimi(ctrl);
2952
3307
  break;
2953
3308
  }
3309
+ case "kiro": {
3310
+ const { loginKiro } = await import("./utils/oauth/kiro");
3311
+ credentials = await loginKiro({
3312
+ onAuth: (url, instructions) => ctrl.onAuth({ url, instructions }),
3313
+ onPrompt: ctrl.onPrompt,
3314
+ onProgress: ctrl.onProgress,
3315
+ signal: ctrl.signal,
3316
+ });
3317
+ break;
3318
+ }
2954
3319
  case "kilo": {
2955
3320
  const { loginKilo } = await import("./utils/oauth/kilo");
2956
3321
  credentials = await loginKilo(ctrl);
@@ -2982,6 +3347,12 @@ export class AuthStorage {
2982
3347
  await saveApiKeyCredential(apiKey);
2983
3348
  return;
2984
3349
  }
3350
+ case "commandcode-goat": {
3351
+ const { loginCommandCode } = await import("./utils/oauth/commandcode");
3352
+ const apiKey = await loginCommandCode(ctrl);
3353
+ await saveApiKeyCredential(apiKey);
3354
+ return;
3355
+ }
2985
3356
  case "lm-studio": {
2986
3357
  const { loginLmStudio } = await import("./utils/oauth/lm-studio");
2987
3358
  const apiKey = await loginLmStudio(ctrl);
@@ -3525,7 +3896,9 @@ export class AuthStorage {
3525
3896
  }
3526
3897
  return lastGood;
3527
3898
  })().finally(() => {
3528
- this.#usageRequestInFlight.delete(cacheKey);
3899
+ if (this.#usageRequestInFlight.get(cacheKey) === promise) {
3900
+ this.#usageRequestInFlight.delete(cacheKey);
3901
+ }
3529
3902
  });
3530
3903
 
3531
3904
  this.#usageRequestInFlight.set(cacheKey, promise);
@@ -3533,6 +3906,7 @@ export class AuthStorage {
3533
3906
  }
3534
3907
 
3535
3908
  #collectUsageRequests(options?: {
3909
+ provider?: Provider;
3536
3910
  baseUrlResolver?: (provider: Provider) => string | undefined;
3537
3911
  }): UsageRequestDescriptor[] {
3538
3912
  const resolver = this.#usageProviderResolver;
@@ -3546,6 +3920,7 @@ export class AuthStorage {
3546
3920
 
3547
3921
  for (const providerId of providers) {
3548
3922
  const provider = providerId as Provider;
3923
+ if (options?.provider && options.provider !== provider) continue;
3549
3924
  const providerImpl = resolver(provider);
3550
3925
  if (!providerImpl) continue;
3551
3926
  const baseUrl = options?.baseUrlResolver?.(provider);
@@ -3745,6 +4120,7 @@ export class AuthStorage {
3745
4120
  }
3746
4121
 
3747
4122
  async fetchUsageReports(options?: {
4123
+ provider?: Provider;
3748
4124
  baseUrlResolver?: (provider: Provider) => string | undefined;
3749
4125
  /** Caller's cancel signal; only rejects this caller, never the shared upstream fetch. */
3750
4126
  signal?: AbortSignal;
@@ -3755,6 +4131,15 @@ export class AuthStorage {
3755
4131
  // `RemoteAuthCredentialStore` implements the store hook so a gateway
3756
4132
  // backed by a broker automatically routes usage to the broker without
3757
4133
  // needing the caller to wire it explicitly.
4134
+ const scopedStoreFetch = options?.provider
4135
+ ? (this.#fetchUsageReportsForProviderOverride ?? this.#store.fetchUsageReportsForProvider?.bind(this.#store))
4136
+ : undefined;
4137
+ if (scopedStoreFetch && options?.provider) {
4138
+ return raceUsageWithSignal(scopedStoreFetch(options.provider), options.signal);
4139
+ }
4140
+ if (options?.provider && (this.#fetchUsageReportsOverride || this.#store.fetchUsageReports)) {
4141
+ throw new Error("Provider-scoped usage fetch is unavailable");
4142
+ }
3758
4143
  const override = this.#fetchUsageReportsOverride ?? this.#store.fetchUsageReports?.bind(this.#store);
3759
4144
  if (override) {
3760
4145
  // Reuse the in-flight map so concurrent callers (widget poll + format
@@ -3767,7 +4152,9 @@ export class AuthStorage {
3767
4152
  // Don't forward the caller signal into the shared fetch — first caller's
3768
4153
  // abort would otherwise cancel the upstream for every peer.
3769
4154
  shared = override().finally(() => {
3770
- this.#usageReportsInFlight.delete(OVERRIDE_KEY);
4155
+ if (this.#usageReportsInFlight.get(OVERRIDE_KEY) === shared) {
4156
+ this.#usageReportsInFlight.delete(OVERRIDE_KEY);
4157
+ }
3771
4158
  });
3772
4159
  this.#usageReportsInFlight.set(OVERRIDE_KEY, shared);
3773
4160
  }
@@ -3836,7 +4223,9 @@ export class AuthStorage {
3836
4223
  }
3837
4224
  return resolved;
3838
4225
  })().finally(() => {
3839
- this.#usageReportsInFlight.delete(cacheKey);
4226
+ if (this.#usageReportsInFlight.get(cacheKey) === promise) {
4227
+ this.#usageReportsInFlight.delete(cacheKey);
4228
+ }
3840
4229
  });
3841
4230
 
3842
4231
  this.#usageReportsInFlight.set(cacheKey, promise);
@@ -4170,9 +4559,11 @@ export class AuthStorage {
4170
4559
  async markUsageLimitReached(
4171
4560
  provider: string,
4172
4561
  sessionId: string | undefined,
4173
- options?: { retryAfterMs?: number; baseUrl?: string; signal?: AbortSignal },
4562
+ options?: { retryAfterMs?: number; baseUrl?: string; signal?: AbortSignal; owner?: object },
4174
4563
  ): Promise<boolean> {
4175
4564
  provider = resolveOAuthStorageProvider(provider);
4565
+ const ownerOverride = this.#configOverrideRegistration(provider, options?.owner);
4566
+ if (ownerOverride && !ownerOverride.envSourced) return false;
4176
4567
  const sessionCredential = this.#getSessionCredential(provider, sessionId);
4177
4568
  if (!sessionCredential) return false;
4178
4569
 
@@ -4272,12 +4663,12 @@ export class AuthStorage {
4272
4663
  providerKey: string;
4273
4664
  provider: string;
4274
4665
  order: number[];
4275
- credentials: Array<{ credential: OAuthCredential; index: number }>;
4666
+ credentials: OAuthCredentialSelection[];
4276
4667
  options?: AuthApiKeyOptions;
4277
4668
  strategy: CredentialRankingStrategy;
4278
4669
  }): Promise<
4279
4670
  Array<{
4280
- selection: { credential: OAuthCredential; index: number };
4671
+ selection: OAuthCredentialSelection;
4281
4672
  usage: UsageReport | null;
4282
4673
  usageChecked: boolean;
4283
4674
  }>
@@ -4285,7 +4676,7 @@ export class AuthStorage {
4285
4676
  const nowMs = Date.now();
4286
4677
  const { strategy } = args;
4287
4678
  const ranked: Array<{
4288
- selection: { credential: OAuthCredential; index: number };
4679
+ selection: OAuthCredentialSelection;
4289
4680
  usage: UsageReport | null;
4290
4681
  usageChecked: boolean;
4291
4682
  blocked: boolean;
@@ -4433,18 +4824,29 @@ export class AuthStorage {
4433
4824
  return undefined;
4434
4825
  }
4435
4826
  const selectedCredential = this.#resolveSelectedStoredCredential(provider, options, sessionId);
4436
- const selectedOAuthCredential =
4827
+ const selectedOAuthCredential: OAuthCredentialSelection | undefined =
4437
4828
  selectedCredential?.credential.type === "oauth"
4438
- ? { credential: selectedCredential.credential, index: selectedCredential.index }
4829
+ ? {
4830
+ id: selectedCredential.id,
4831
+ credential: selectedCredential.credential,
4832
+ index: selectedCredential.index,
4833
+ revision: selectedCredential.revision,
4834
+ }
4439
4835
  : undefined;
4440
4836
  if (selectedCredential && !selectedOAuthCredential) return undefined;
4441
4837
  const credentials = selectedOAuthCredential
4442
4838
  ? [selectedOAuthCredential]
4443
- : this.#getCredentialsForProvider(provider)
4444
- .map((credential, index) => ({ credential, index }))
4839
+ : this.#getStoredCredentials(provider)
4445
4840
  .filter(
4446
- (entry): entry is { credential: OAuthCredential; index: number } => entry.credential.type === "oauth",
4447
- );
4841
+ (entry): entry is StoredCredential & { credential: OAuthCredential } =>
4842
+ entry.credential.type === "oauth",
4843
+ )
4844
+ .map((entry, index) => ({
4845
+ id: entry.id,
4846
+ credential: entry.credential,
4847
+ index,
4848
+ revision: entry.revision,
4849
+ }));
4448
4850
 
4449
4851
  if (credentials.length === 0) return undefined;
4450
4852
 
@@ -4467,7 +4869,7 @@ export class AuthStorage {
4467
4869
  ? await this.#rankOAuthSelections({ providerKey, provider, order, credentials, options, strategy: strategy! })
4468
4870
  : order
4469
4871
  .map(idx => credentials[idx])
4470
- .filter((selection): selection is { credential: OAuthCredential; index: number } => Boolean(selection))
4872
+ .filter((selection): selection is OAuthCredentialSelection => Boolean(selection))
4471
4873
  .map(selection => ({ selection, usage: null, usageChecked: false }));
4472
4874
 
4473
4875
  // Soft `--prefer-credential` preference: reorder the preferred row to the
@@ -4477,7 +4879,11 @@ export class AuthStorage {
4477
4879
  if (!selectedCredential) {
4478
4880
  const preferredSelector = this.#getPreferredCredentialSelector(provider, options);
4479
4881
  if (preferredSelector) {
4480
- this.#assertPreferredCredentialSelectorUsable(resolveOAuthStorageProvider(provider), preferredSelector);
4882
+ this.#assertPreferredCredentialSelectorUsable(
4883
+ resolveOAuthStorageProvider(provider),
4884
+ preferredSelector,
4885
+ options?.owner,
4886
+ );
4481
4887
  }
4482
4888
  const preferredSelection = preferredSelector
4483
4889
  ? this.#findCredentialBySelector(provider, preferredSelector)
@@ -4509,14 +4915,15 @@ export class AuthStorage {
4509
4915
  }
4510
4916
  await Promise.all(
4511
4917
  candidates.map(async candidate => {
4918
+ if (!this.#reconcileOAuthCredentialSelection(provider, candidate.selection)) return;
4512
4919
  if (Date.now() + OAUTH_REFRESH_SKEW_MS < candidate.selection.credential.expires) return;
4513
- const latestCredential = this.#getCredentialsForProvider(provider)[candidate.selection.index];
4920
+ const latestCredential = candidate.selection.credential;
4514
4921
  if (latestCredential?.type === "oauth" && Date.now() + OAUTH_REFRESH_SKEW_MS < latestCredential.expires) {
4515
4922
  candidate.selection.credential = latestCredential;
4516
4923
  return;
4517
4924
  }
4518
4925
  try {
4519
- const credentialId = this.#getStoredCredentials(provider)[candidate.selection.index]?.id;
4926
+ const credentialId = candidate.selection.id;
4520
4927
  const refreshedCredentials = await this.#refreshOAuthCredential(
4521
4928
  provider,
4522
4929
  candidate.selection.credential,
@@ -4528,12 +4935,14 @@ export class AuthStorage {
4528
4935
  ...refreshedCredentials,
4529
4936
  type: "oauth",
4530
4937
  };
4938
+ if (!this.#reconcileOAuthCredentialSelection(provider, candidate.selection)) return;
4531
4939
  candidate.selection.credential = updated;
4532
4940
  this.#replaceCredentialAt(
4533
4941
  provider,
4534
4942
  candidate.selection.index,
4535
4943
  updated,
4536
4944
  !refreshedCredentials.persistedByLease,
4945
+ credentialId,
4537
4946
  );
4538
4947
  } catch {}
4539
4948
  }),
@@ -4759,6 +5168,15 @@ export class AuthStorage {
4759
5168
  }
4760
5169
  try {
4761
5170
  const refreshed = await Promise.race([refreshPromise, cancellation.promise]);
5171
+ let effectiveRefreshed = refreshed;
5172
+ if (this.#refreshOAuthCredentialOverride && this.#store.refreshSnapshot) {
5173
+ await this.#store.refreshSnapshot();
5174
+ const accepted = this.#store.listAuthCredentials(provider).find(row => row.id === credentialId)?.credential;
5175
+ if (accepted?.type !== "oauth") {
5176
+ throw new Error("Credential authority changed during refresh");
5177
+ }
5178
+ effectiveRefreshed = accepted;
5179
+ }
4762
5180
  // Return the FULL authority of the effective credential: rotated
4763
5181
  // tokens from upstream plus the identity metadata and MCP binding of
4764
5182
  // the (possibly guard-adopted) credential that was actually
@@ -4767,12 +5185,12 @@ export class AuthStorage {
4767
5185
  // next refresh token to the wrong endpoint — or relabel rotated
4768
5186
  // tokens with stale identity.
4769
5187
  const authority: RefreshedOAuthCredentials = {
4770
- ...refreshed,
4771
- accountId: refreshed.accountId ?? credential.accountId,
4772
- email: refreshed.email ?? credential.email,
4773
- projectId: refreshed.projectId ?? credential.projectId,
4774
- enterpriseUrl: refreshed.enterpriseUrl ?? credential.enterpriseUrl,
4775
- mcpBinding: (refreshed as RefreshedOAuthCredentials).mcpBinding ?? credential.mcpBinding,
5188
+ ...effectiveRefreshed,
5189
+ accountId: effectiveRefreshed.accountId ?? credential.accountId,
5190
+ email: effectiveRefreshed.email ?? credential.email,
5191
+ projectId: effectiveRefreshed.projectId ?? credential.projectId,
5192
+ enterpriseUrl: effectiveRefreshed.enterpriseUrl ?? credential.enterpriseUrl,
5193
+ mcpBinding: (effectiveRefreshed as RefreshedOAuthCredentials).mcpBinding ?? credential.mcpBinding,
4776
5194
  };
4777
5195
  if (refreshLease) {
4778
5196
  const completeLease = this.#store.completeOAuthRefreshLease?.bind(this.#store);
@@ -4801,15 +5219,28 @@ export class AuthStorage {
4801
5219
  }
4802
5220
  }
4803
5221
 
5222
+ #reconcileOAuthCredentialSelection(provider: string, selection: OAuthCredentialSelection): boolean {
5223
+ const entries = this.#getStoredCredentials(provider);
5224
+ const index = entries.findIndex(entry => entry.id === selection.id);
5225
+ if (index === -1) return false;
5226
+ const current = entries[index];
5227
+ if (current?.credential.type !== "oauth") return false;
5228
+ selection.index = index;
5229
+ selection.credential = current.credential;
5230
+ selection.revision = current.revision;
5231
+ return true;
5232
+ }
5233
+
4804
5234
  async #prepareOAuthCredentialForRequest(
4805
5235
  provider: string,
4806
- selection: { credential: OAuthCredential; index: number },
5236
+ selection: OAuthCredentialSelection,
4807
5237
  options: AuthApiKeyOptions | undefined,
4808
5238
  ): Promise<boolean> {
5239
+ if (!this.#reconcileOAuthCredentialSelection(provider, selection)) return false;
4809
5240
  const prepare = this.#store.prepareForRequest?.bind(this.#store);
4810
5241
  if (!prepare) return true;
4811
5242
  const stored = this.#getStoredCredentials(provider);
4812
- const selected = stored[selection.index];
5243
+ const selected = stored.find(entry => entry.id === selection.id);
4813
5244
  if (selected?.credential.type !== "oauth") return false;
4814
5245
 
4815
5246
  const prepared = await prepare(selected.id, { signal: options?.signal });
@@ -4817,21 +5248,22 @@ export class AuthStorage {
4817
5248
  const latestRows = this.#store.listAuthCredentials(provider);
4818
5249
  this.#setStoredCredentials(
4819
5250
  provider,
4820
- latestRows.map(row => ({ id: row.id, credential: row.credential })),
5251
+ latestRows.map(row => ({ id: row.id, credential: row.credential, revision: row.revision })),
4821
5252
  );
4822
- const latestIndex = latestRows.findIndex(row => row.id === selected.id);
5253
+ const latestIndex = latestRows.findIndex(row => row.id === selection.id);
4823
5254
  if (latestIndex === -1) return false;
4824
5255
  const latest = latestRows[latestIndex];
4825
5256
  if (latest?.credential.type !== "oauth") return false;
4826
5257
  selection.index = latestIndex;
4827
5258
  selection.credential = latest.credential;
5259
+ selection.revision = latest.revision;
4828
5260
  return true;
4829
5261
  }
4830
5262
 
4831
5263
  /** Attempts to use a single OAuth credential, checking usage and refreshing token. */
4832
5264
  async #tryOAuthCredential(
4833
5265
  provider: Provider,
4834
- selection: { credential: OAuthCredential; index: number },
5266
+ selection: OAuthCredentialSelection,
4835
5267
  providerKey: string,
4836
5268
  sessionId: string | undefined,
4837
5269
  options: AuthApiKeyOptions | undefined,
@@ -4851,6 +5283,7 @@ export class AuthStorage {
4851
5283
  usagePrechecked = false,
4852
5284
  enforceProRequirement,
4853
5285
  } = usageOptions;
5286
+ if (!this.#reconcileOAuthCredentialSelection(provider, selection)) return undefined;
4854
5287
  if (!allowBlocked && this.#isCredentialBlocked(providerKey, selection.index)) {
4855
5288
  return undefined;
4856
5289
  }
@@ -4890,6 +5323,8 @@ export class AuthStorage {
4890
5323
  }
4891
5324
 
4892
5325
  try {
5326
+ if (!this.#reconcileOAuthCredentialSelection(provider, selection)) return undefined;
5327
+ const selectionCredentialId = selection.id;
4893
5328
  let result: { newCredentials: OAuthCredentials; apiKey: string } | null;
4894
5329
  // The refresh result carries the effective (possibly guard-adopted)
4895
5330
  // credential's binding; `updated` must persist it or the next refresh
@@ -4900,7 +5335,7 @@ export class AuthStorage {
4900
5335
  const refreshedCredentials = await this.#refreshOAuthCredential(
4901
5336
  provider,
4902
5337
  selection.credential,
4903
- this.#getStoredCredentials(provider)[selection.index]?.id,
5338
+ selectionCredentialId,
4904
5339
  options?.signal,
4905
5340
  );
4906
5341
  refreshedAuthority = refreshedCredentials;
@@ -4917,7 +5352,7 @@ export class AuthStorage {
4917
5352
  const refreshedCredentials = await this.#refreshOAuthCredential(
4918
5353
  provider,
4919
5354
  selection.credential,
4920
- this.#getStoredCredentials(provider)[selection.index]?.id,
5355
+ selectionCredentialId,
4921
5356
  options?.signal,
4922
5357
  );
4923
5358
  refreshedAuthority = refreshedCredentials;
@@ -4938,7 +5373,13 @@ export class AuthStorage {
4938
5373
  enterpriseUrl: result.newCredentials.enterpriseUrl ?? selection.credential.enterpriseUrl,
4939
5374
  mcpBinding: refreshedAuthority.mcpBinding,
4940
5375
  };
4941
- this.#replaceCredentialAt(provider, selection.index, updated, !refreshedAuthority.persistedByLease);
5376
+ this.#replaceCredentialAt(
5377
+ provider,
5378
+ selection.index,
5379
+ updated,
5380
+ !refreshedAuthority.persistedByLease,
5381
+ selectionCredentialId,
5382
+ );
4942
5383
 
4943
5384
  if ((checkUsage && !allowBlocked) || requiresProModel) {
4944
5385
  const sameAccount = selection.credential.accountId === updated.accountId;
@@ -4962,10 +5403,17 @@ export class AuthStorage {
4962
5403
  return undefined;
4963
5404
  }
4964
5405
  }
5406
+ if (!this.#reconcileOAuthCredentialSelection(provider, selection)) return undefined;
5407
+ if (!authCredentialEquals(selection.credential, updated)) return undefined;
4965
5408
  this.#recordSessionCredential(provider, sessionId, "oauth", selection.index);
4966
5409
  return { apiKey: result.apiKey, credential: updated };
4967
5410
  } catch (error) {
4968
- const errorMsg = String(error);
5411
+ // Auth-broker errors retain the sanitized upstream body separately from
5412
+ // their transport message. Include that body for failure classification
5413
+ // (the broker's 500 envelope otherwise hides `invalid_grant`) while
5414
+ // preserving the original error object for callers and diagnostics.
5415
+ const brokerBody = readBrokerErrorBody(error);
5416
+ const errorMsg = [String(error), brokerBody].filter((part): part is string => Boolean(part)).join(" ");
4969
5417
  // Peer-rotation recovery runs before ANY failure classification: a
4970
5418
  // concurrent process may have rotated the refresh token, which
4971
5419
  // invalidates the snapshot token we just attempted. Re-read the row —
@@ -4984,7 +5432,7 @@ export class AuthStorage {
4984
5432
  // selection snapshot would misread that adoption as a fresh peer
4985
5433
  // rotation and loop reload-retry instead of classifying the failure.
4986
5434
  const attemptedRefreshToken = getAttemptedRefreshToken(error) ?? selection.credential.refresh;
4987
- const attemptedCredentialId = this.#getStoredCredentials(provider)[selection.index]?.id;
5435
+ const attemptedCredentialId = selection.id;
4988
5436
  if (attemptedCredentialId !== undefined) {
4989
5437
  const latestRow = this.#store.listAuthCredentials(provider).find(row => row.id === attemptedCredentialId);
4990
5438
  const latestCredential = latestRow?.credential;
@@ -5040,6 +5488,7 @@ export class AuthStorage {
5040
5488
  selection.index,
5041
5489
  selection.credential,
5042
5490
  `oauth refresh failed: ${errorMsg}`,
5491
+ attemptedCredentialId,
5043
5492
  );
5044
5493
  if (!disabled) {
5045
5494
  // The CAS predicate compares the row's serialized `data`, so it also
@@ -5060,7 +5509,35 @@ export class AuthStorage {
5060
5509
  index: selection.index,
5061
5510
  credentialId: attemptedCredentialId,
5062
5511
  });
5063
- this.#disableCredentialById(provider, attemptedCredentialId, `oauth refresh failed: ${errorMsg}`);
5512
+ const disabledCause = `oauth refresh failed: ${errorMsg}`;
5513
+ if (this.#store.disableAuthCredentialRemote) {
5514
+ try {
5515
+ const disabled = await this.#disableCredentialRemotely(
5516
+ provider,
5517
+ attemptedCredentialId,
5518
+ disabledCause,
5519
+ options?.signal,
5520
+ selection.revision,
5521
+ );
5522
+ if (!disabled) {
5523
+ await this.reload();
5524
+ return this.#resolveOAuthSelection(provider, sessionId, options, reloadsUsed + 1);
5525
+ }
5526
+ } catch (disableError) {
5527
+ // A failed broker mutation must not replace the provider's
5528
+ // original authentication failure. We also deliberately do
5529
+ // not remove the row locally: remote authority may still be
5530
+ // active and can only be changed by its broker.
5531
+ logger.warn("OAuth refresh remote disable failed", {
5532
+ provider,
5533
+ credentialId: attemptedCredentialId,
5534
+ error: String(disableError),
5535
+ });
5536
+ throw error;
5537
+ }
5538
+ } else {
5539
+ this.#disableCredentialById(provider, attemptedCredentialId, disabledCause);
5540
+ }
5064
5541
  } else {
5065
5542
  logger.debug("OAuth refresh disable lost CAS; reloading after peer rotation", {
5066
5543
  provider,
@@ -5159,15 +5636,27 @@ export class AuthStorage {
5159
5636
  * and get a best-effort token. For GitHub Copilot we preserve enterprise
5160
5637
  * routing metadata so discovery can hit the correct host.
5161
5638
  */
5162
- async peekApiKey(provider: string): Promise<string | undefined> {
5639
+ async peekApiKey(provider: string, options?: Pick<AuthApiKeyOptions, "owner">): Promise<string | undefined> {
5163
5640
  provider = resolveOAuthStorageProvider(provider);
5164
5641
  const runtimeKey = this.#runtimeOverrides.get(provider);
5165
5642
  if (runtimeKey) return runtimeKey;
5166
5643
 
5167
- const configKey = this.#configOverrides.get(provider);
5168
- if (configKey) return configKey;
5644
+ const configOverride = this.#configOverrideRegistration(provider, options?.owner);
5645
+ const configKey = configOverride?.apiKey;
5646
+ if (configKey && !configOverride?.envSourced) return configKey;
5169
5647
 
5170
- const selectedCredential = this.#resolveSelectedStoredCredential(provider, undefined, undefined);
5648
+ const selectedCredential = this.#resolveSelectedStoredCredential(
5649
+ provider,
5650
+ options?.owner ? { owner: options.owner } : undefined,
5651
+ undefined,
5652
+ );
5653
+ if (configKey) {
5654
+ // Env-sourced (`apiKeyEnv`) override: same precedence as getApiKey —
5655
+ // a stored api_key credential from `auth login` wins, stored OAuth
5656
+ // still yields to the indirection.
5657
+ const storedApiKey = await this.#resolveStoredApiKeyOverEnvConfig(provider, selectedCredential, undefined);
5658
+ return storedApiKey ?? configKey;
5659
+ }
5171
5660
  if (selectedCredential?.credential.type === "api_key") {
5172
5661
  return this.#resolveStoredApiKey(provider, selectedCredential.credential.key);
5173
5662
  }
@@ -5210,20 +5699,23 @@ export class AuthStorage {
5210
5699
  }
5211
5700
  }
5212
5701
 
5213
- return getEnvApiKey(provider) || this.#fallbackResolver?.(provider);
5702
+ return getEnvApiKey(provider) || this.#resolveFallback(provider, options?.owner);
5214
5703
  }
5215
5704
 
5216
5705
  /**
5217
5706
  * Get API key for a provider.
5218
5707
  * Priority:
5219
5708
  * 1. Runtime override (CLI --api-key)
5220
- * 2. Config override (models.yml `providers.<name>.apiKey`)
5221
- * 3. Session-selected OAuth credential, when present
5222
- * 4. Usable or unresolved API key from storage
5223
- * 5. OAuth token from storage (auto-refreshed)
5224
- * 6. Previously unusable command-backed API key retry
5225
- * 7. Environment variable
5226
- * 8. Fallback resolver (models.yml custom providers, last-resort)
5709
+ * 2. Config override (models.yml `providers.<name>.apiKey` literal pin)
5710
+ * 3. Stored api_key credential from `auth login`, when the config override
5711
+ * is only an `apiKeyEnv` indirection
5712
+ * 4. Config override sourced from models.yml `providers.<name>.apiKeyEnv`
5713
+ * 5. Session-selected OAuth credential, when present
5714
+ * 6. Usable or unresolved API key from storage
5715
+ * 7. OAuth token from storage (auto-refreshed)
5716
+ * 8. Previously unusable command-backed API key retry
5717
+ * 9. Environment variable
5718
+ * 10. Fallback resolver (models.yml custom providers, last-resort)
5227
5719
  */
5228
5720
  async getApiKey(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<string | undefined> {
5229
5721
  provider = resolveOAuthStorageProvider(provider);
@@ -5238,8 +5730,19 @@ export class AuthStorage {
5238
5730
  // (e.g. an auth-gateway) and supplied the bearer for that endpoint —
5239
5731
  // honor it instead of forwarding an upstream OAuth token that the proxy
5240
5732
  // won't accept.
5241
- const configKey = this.#configOverrides.get(provider);
5242
- if (configKey) return configKey;
5733
+ const configOverride = this.#configOverrideRegistration(provider, options?.owner);
5734
+ const configKey = configOverride?.apiKey;
5735
+ if (configKey) {
5736
+ if (!configOverride?.envSourced) return configKey;
5737
+ // The override is an `apiKeyEnv` indirection, not a pinned value. A
5738
+ // stored api_key credential from `auth login` is actively managed
5739
+ // (validated at login, rotated on 401), while the pointed-to env
5740
+ // value can go stale with no recovery path — prefer the stored
5741
+ // credential. Stored OAuth credentials still yield to the override.
5742
+ const storedApiKey = await this.#resolveStoredApiKeyOverEnvConfig(provider, selectedCredential, sessionId);
5743
+ if (storedApiKey) return storedApiKey;
5744
+ return configKey;
5745
+ }
5243
5746
 
5244
5747
  if (selectedCredential?.credential.type === "api_key") {
5245
5748
  this.#recordSessionCredential(provider, sessionId, "api_key", selectedCredential.index);
@@ -5292,7 +5795,39 @@ export class AuthStorage {
5292
5795
  if (sessionId) this.#sessionLastCredential.get(provider)?.delete(sessionId);
5293
5796
  const envKey = getEnvApiKey(provider);
5294
5797
  if (envKey) return envKey;
5295
- return this.#fallbackResolver?.(provider) ?? undefined;
5798
+ return this.#resolveFallback(provider, options?.owner) ?? undefined;
5799
+ }
5800
+
5801
+ /**
5802
+ * Resolve a stored api_key credential that outranks an env-sourced config
5803
+ * override (`apiKeyEnv`). Mirrors the api_key branches of {@link getApiKey}:
5804
+ * the selector-pinned credential first, then the round-robin/session pool.
5805
+ * Returns undefined when no stored api_key credential resolves, leaving the
5806
+ * env-sourced override in effect.
5807
+ */
5808
+ async #resolveStoredApiKeyOverEnvConfig(
5809
+ provider: string,
5810
+ selectedCredential: ({ index: number } & StoredCredential) | undefined,
5811
+ sessionId?: string,
5812
+ ): Promise<string | undefined> {
5813
+ if (selectedCredential?.credential.type === "api_key") {
5814
+ const resolved = await this.#resolveStoredApiKey(provider, selectedCredential.credential.key);
5815
+ if (resolved) {
5816
+ this.#recordSessionCredential(provider, sessionId, "api_key", selectedCredential.index);
5817
+ return resolved;
5818
+ }
5819
+ }
5820
+ const attemptedApiKeyIndices = new Set<number>();
5821
+ for (;;) {
5822
+ const apiKeySelection = this.#selectApiKeyCredential(provider, sessionId, attemptedApiKeyIndices);
5823
+ if (!apiKeySelection) return undefined;
5824
+ attemptedApiKeyIndices.add(apiKeySelection.index);
5825
+ const resolved = await this.#resolveStoredApiKey(provider, apiKeySelection.credential.key);
5826
+ if (resolved) {
5827
+ this.#recordSessionCredential(provider, sessionId, "api_key", apiKeySelection.index);
5828
+ return resolved;
5829
+ }
5830
+ }
5296
5831
  }
5297
5832
 
5298
5833
  /**
@@ -5319,7 +5854,7 @@ export class AuthStorage {
5319
5854
  // Runtime / config overrides intentionally short-circuit OAuth: when the
5320
5855
  // user has pinned an API key, they expect the OAuth identity to be
5321
5856
  // suppressed (same contract as `getOAuthAccountId`).
5322
- if (this.#runtimeOverrides.has(provider) || this.#configOverrides.has(provider)) {
5857
+ if (this.#runtimeOverrides.has(provider) || this.#hasConfigOverride(provider, options?.owner)) {
5323
5858
  return undefined;
5324
5859
  }
5325
5860
  const resolved = await this.#resolveOAuthSelection(provider, sessionId, options);
@@ -5335,13 +5870,7 @@ export class AuthStorage {
5335
5870
  }
5336
5871
 
5337
5872
  #extractStructuredApiKeyToken(apiKey: string): string | undefined {
5338
- if (!apiKey.startsWith("{")) return undefined;
5339
- try {
5340
- const parsed = JSON.parse(apiKey) as { token?: unknown };
5341
- return typeof parsed.token === "string" ? parsed.token : undefined;
5342
- } catch {
5343
- return undefined;
5344
- }
5873
+ return extractStructuredApiKeyToken(apiKey);
5345
5874
  }
5346
5875
 
5347
5876
  async #credentialMatchesApiKey(provider: string, credential: AuthCredential, apiKey: string): Promise<boolean> {
@@ -5371,6 +5900,9 @@ export class AuthStorage {
5371
5900
  const signal = isAbortSignalOption(optionsOrSignal) ? optionsOrSignal : optionsOrSignal?.signal;
5372
5901
  const sessionId = isAbortSignalOption(optionsOrSignal) ? undefined : optionsOrSignal?.sessionId;
5373
5902
  const storageProvider = resolveOAuthStorageProvider(provider);
5903
+ const owner = isAbortSignalOption(optionsOrSignal) ? undefined : optionsOrSignal?.owner;
5904
+ const ownerOverride = this.#configOverrideRegistration(storageProvider, owner);
5905
+ if (ownerOverride && !ownerOverride.envSourced && ownerOverride.apiKey === apiKey) return false;
5374
5906
  const stored = this.#getStoredCredentials(storageProvider);
5375
5907
  let matched: { id: number; type: AuthCredential["type"]; index: number } | undefined;
5376
5908
  for (let index = 0; index < stored.length; index++) {
@@ -5403,7 +5935,7 @@ export class AuthStorage {
5403
5935
  const latestRows = this.#store.listAuthCredentials(storageProvider);
5404
5936
  this.#setStoredCredentials(
5405
5937
  storageProvider,
5406
- latestRows.map(row => ({ id: row.id, credential: row.credential })),
5938
+ latestRows.map(row => ({ id: row.id, credential: row.credential, revision: row.revision })),
5407
5939
  );
5408
5940
  return true;
5409
5941
  }
@@ -5430,6 +5962,7 @@ export class AuthStorage {
5430
5962
  provider,
5431
5963
  credential: redacted,
5432
5964
  identityKey: resolveCredentialIdentityKey(provider, credential),
5965
+ ...(entry.revision === undefined ? {} : { revision: entry.revision }),
5433
5966
  });
5434
5967
  }
5435
5968
  }
@@ -5565,12 +6098,14 @@ export class AuthStorage {
5565
6098
  enterpriseUrl: refreshed.enterpriseUrl ?? target.credential.enterpriseUrl,
5566
6099
  mcpBinding: refreshed.mcpBinding,
5567
6100
  };
5568
- this.#replaceCredentialAt(provider, index, updated, !refreshed.persistedByLease);
6101
+ this.#replaceCredentialAt(provider, index, updated, !refreshed.persistedByLease, id);
6102
+ const persisted = this.#store.listAuthCredentials(provider).find(entry => entry.id === id);
5569
6103
  return {
5570
6104
  id,
5571
6105
  provider,
5572
6106
  credential: { ...updated, refresh: REMOTE_REFRESH_SENTINEL },
5573
6107
  identityKey: resolveCredentialIdentityKey(provider, updated),
6108
+ ...(persisted?.revision === undefined ? {} : { revision: persisted.revision }),
5574
6109
  };
5575
6110
  }
5576
6111
  throw new Error(`No credential with id=${id}`);
@@ -5610,7 +6145,7 @@ export class AuthStorage {
5610
6145
  const stored = this.#store.upsertAuthCredentialForProvider(provider, credential);
5611
6146
  this.#setStoredCredentials(
5612
6147
  provider,
5613
- stored.map(entry => ({ id: entry.id, credential: entry.credential })),
6148
+ stored.map(entry => ({ id: entry.id, credential: entry.credential, revision: entry.revision })),
5614
6149
  );
5615
6150
  this.#resetProviderAssignments(provider);
5616
6151
  return this.#toSnapshotEntries(provider, stored);
@@ -5621,26 +6156,39 @@ export class AuthStorage {
5621
6156
  *
5622
6157
  * Surfaces four layers, highest precedence first:
5623
6158
  * 1. Runtime override (`--api-key`).
5624
- * 2. Config override (`models.yml` `providers.<name>.apiKey`).
6159
+ * 2. Config override (`models.yml` `providers.<name>.apiKey` literal pin,
6160
+ * or an `apiKeyEnv` indirection when no stored api_key credential
6161
+ * outranks it).
5625
6162
  * 3. Stored credential (the one this session is currently sticky to, or the
5626
6163
  * one round-robin would pick next when no session id is supplied).
5627
6164
  * 4. Env var / fallback resolver — when no stored credential exists.
5628
6165
  *
5629
6166
  * The string is purely informational; consumers must not parse it.
5630
6167
  */
5631
- describeCredentialSource(provider: string, sessionId?: string): string | undefined {
6168
+ describeCredentialSource(
6169
+ provider: string,
6170
+ sessionId?: string,
6171
+ options?: Pick<AuthApiKeyOptions, "owner">,
6172
+ ): string | undefined {
6173
+ provider = resolveOAuthStorageProvider(provider);
5632
6174
  if (this.#runtimeOverrides.has(provider)) {
5633
6175
  return "runtime override (--api-key)";
5634
6176
  }
5635
- if (this.#configOverrides.has(provider)) {
5636
- return "config override (models.yml)";
6177
+ const configOverride = this.#configOverrideRegistration(provider, options?.owner);
6178
+ if (configOverride) {
6179
+ // An `apiKeyEnv` indirection loses to a stored api_key credential
6180
+ // (see getApiKey); describe the credential that actually wins.
6181
+ const shadowed = this.#getStoredCredentials(provider).some(entry => entry.credential.type === "api_key");
6182
+ if (!configOverride.envSourced || !shadowed) {
6183
+ return "config override (models.yml)";
6184
+ }
5637
6185
  }
5638
6186
 
5639
6187
  const baseLabel = this.#sourceLabel ?? "local store";
5640
6188
  const stored = this.#getStoredCredentials(provider);
5641
6189
  if (stored.length === 0) {
5642
6190
  if (getEnvApiKey(provider)) return `env ${baseLabel ? `(fallback over ${baseLabel})` : ""}`.trim();
5643
- if (this.#fallbackResolver?.(provider) !== undefined) return `fallback resolver`;
6191
+ if (this.#resolveFallback(provider, options?.owner) !== undefined) return `fallback resolver`;
5644
6192
  return undefined;
5645
6193
  }
5646
6194
 
@@ -5874,6 +6422,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
5874
6422
  #updateStmt: Statement;
5875
6423
  #deleteStmt: Statement;
5876
6424
  #deleteIfMatchesStmt: Statement;
6425
+ #deleteIfRevisionStmt: Statement;
5877
6426
  #deleteByProviderStmt: Statement;
5878
6427
  #hardDeleteStmt: Statement;
5879
6428
  #getCacheStmt: Statement;
@@ -5914,6 +6463,9 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
5914
6463
  this.#deleteIfMatchesStmt = this.#db.prepare(
5915
6464
  `UPDATE auth_credentials SET disabled_cause = ?, revision = revision + 1, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ? AND data = ? AND disabled_cause IS NULL`,
5916
6465
  );
6466
+ this.#deleteIfRevisionStmt = this.#db.prepare(
6467
+ `UPDATE auth_credentials SET disabled_cause = ?, revision = revision + 1, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ? AND revision = ? AND disabled_cause IS NULL`,
6468
+ );
5917
6469
  this.#deleteByProviderStmt = this.#db.prepare(
5918
6470
  `UPDATE auth_credentials SET disabled_cause = ?, revision = revision + 1, updated_at = ${SQLITE_NOW_EPOCH} WHERE provider = ? AND disabled_cause IS NULL`,
5919
6471
  );
@@ -6350,6 +6902,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
6350
6902
  id: row.id,
6351
6903
  credential: deserializeCredential(row),
6352
6904
  identityKey: resolveRowCredentialIdentityKey(providerName, row),
6905
+ revision: row.revision,
6353
6906
  }));
6354
6907
 
6355
6908
  const result: StoredAuthCredential[] = [];
@@ -6366,7 +6919,13 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
6366
6919
  if (match) {
6367
6920
  matchedExistingIds.add(match.id);
6368
6921
  this.#updateStmt.run(serialized.credentialType, serialized.data, serialized.identityKey, match.id);
6369
- result.push({ id: match.id, provider: providerName, credential, disabledCause: null });
6922
+ result.push({
6923
+ id: match.id,
6924
+ provider: providerName,
6925
+ credential,
6926
+ disabledCause: null,
6927
+ revision: match.revision + 1,
6928
+ });
6370
6929
  } else {
6371
6930
  const row = this.#insertStmt.get(
6372
6931
  providerName,
@@ -6375,7 +6934,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
6375
6934
  serialized.identityKey,
6376
6935
  ) as { id?: number } | undefined;
6377
6936
  if (row?.id) {
6378
- result.push({ id: row.id, provider: providerName, credential, disabledCause: null });
6937
+ result.push({ id: row.id, provider: providerName, credential, disabledCause: null, revision: 1 });
6379
6938
  }
6380
6939
  }
6381
6940
  }
@@ -6468,6 +7027,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
6468
7027
  id: number;
6469
7028
  credential: AuthCredential;
6470
7029
  identityKey: string | null;
7030
+ revision: number;
6471
7031
  }> = [];
6472
7032
  for (const row of existingRows) {
6473
7033
  const activeCredential = deserializeCredential(row);
@@ -6476,6 +7036,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
6476
7036
  id: row.id,
6477
7037
  credential: activeCredential,
6478
7038
  identityKey: resolveRowCredentialIdentityKey(providerName, row),
7039
+ revision: row.revision,
6479
7040
  });
6480
7041
  }
6481
7042
  if (existing.length > 0) {
@@ -6508,6 +7069,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
6508
7069
  provider: providerName,
6509
7070
  credential: row.credential,
6510
7071
  disabledCause: null,
7072
+ revision: row.revision,
6511
7073
  })),
6512
7074
  };
6513
7075
  }
@@ -6595,6 +7157,17 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
6595
7157
  }
6596
7158
  }
6597
7159
 
7160
+ tryDisableAuthCredentialIfRevision(id: number, expectedRevision: number, disabledCause: string): boolean {
7161
+ try {
7162
+ const result = this.#deleteIfRevisionStmt.run(normalizeDisabledCause(disabledCause), id, expectedRevision) as {
7163
+ changes: number;
7164
+ };
7165
+ return result.changes === 1;
7166
+ } catch {
7167
+ return false;
7168
+ }
7169
+ }
7170
+
6598
7171
  deleteAuthCredentialsForProvider(provider: string, disabledCause: string): void {
6599
7172
  try {
6600
7173
  this.#deleteByProviderStmt.run(normalizeDisabledCause(disabledCause), provider);
@@ -6621,6 +7194,17 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
6621
7194
  }
6622
7195
  }
6623
7196
 
7197
+ allocateMonotonicSequence(key: string, expiresAtSec: number): number {
7198
+ const allocate = this.#db.transaction(() => {
7199
+ const row = this.#getCacheIncludingExpiredStmt.get(key) as { value?: string } | undefined;
7200
+ const current = Number(row?.value);
7201
+ const next = Number.isSafeInteger(current) && current >= 0 ? current + 1 : 1;
7202
+ this.#upsertCacheStmt.run(key, String(next), expiresAtSec);
7203
+ return next;
7204
+ });
7205
+ return allocate();
7206
+ }
7207
+
6624
7208
  deleteCachePrefix(prefix: string): void {
6625
7209
  if (prefix.length === 0) return;
6626
7210
  try {
@@ -6715,6 +7299,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
6715
7299
  this.#updateStmt.finalize();
6716
7300
  this.#deleteStmt.finalize();
6717
7301
  this.#deleteIfMatchesStmt.finalize();
7302
+ this.#deleteIfRevisionStmt.finalize();
6718
7303
  this.#deleteByProviderStmt.finalize();
6719
7304
  this.#hardDeleteStmt.finalize();
6720
7305
  this.#getCacheStmt.finalize();