@oh-my-pi/pi-ai 16.5.0 → 16.5.2

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 (41) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/types/auth-broker/remote-store.d.ts +2 -0
  3. package/dist/types/auth-broker/wire-schemas.d.ts +28 -0
  4. package/dist/types/auth-retry.d.ts +44 -20
  5. package/dist/types/auth-storage.d.ts +51 -10
  6. package/dist/types/error/flags.d.ts +1 -0
  7. package/dist/types/index.d.ts +1 -0
  8. package/dist/types/providers/openai-chat-server-schema.d.ts +3 -3
  9. package/dist/types/providers/openai-shared.d.ts +2 -0
  10. package/dist/types/registry/oauth/types.d.ts +8 -0
  11. package/dist/types/usage/cursor.d.ts +3 -0
  12. package/dist/types/usage/openai-codex-reset.d.ts +1 -1
  13. package/dist/types/usage/openai-codex.d.ts +8 -1
  14. package/dist/types/usage/zai.d.ts +2 -1
  15. package/dist/types/usage.d.ts +4 -0
  16. package/dist/types/utils/idle-iterator.d.ts +6 -6
  17. package/package.json +4 -4
  18. package/src/auth-broker/discover.ts +22 -3
  19. package/src/auth-broker/remote-store.ts +103 -10
  20. package/src/auth-broker/wire-schemas.ts +4 -0
  21. package/src/auth-gateway/server.ts +3 -1
  22. package/src/auth-retry.ts +191 -53
  23. package/src/auth-storage.ts +668 -198
  24. package/src/error/auth-classify.ts +2 -1
  25. package/src/error/flags.ts +10 -0
  26. package/src/error/provider.ts +2 -2
  27. package/src/index.ts +1 -0
  28. package/src/providers/cursor.ts +10 -1
  29. package/src/providers/openai-chat-server-schema.ts +2 -1
  30. package/src/providers/openai-codex-responses.ts +74 -36
  31. package/src/providers/openai-shared.ts +6 -3
  32. package/src/registry/oauth/anthropic.ts +57 -24
  33. package/src/registry/oauth/oauth.html +1 -1
  34. package/src/registry/oauth/types.ts +8 -0
  35. package/src/stream.ts +19 -30
  36. package/src/usage/cursor.ts +192 -0
  37. package/src/usage/openai-codex-reset.ts +6 -2
  38. package/src/usage/openai-codex.ts +33 -0
  39. package/src/usage/zai.ts +49 -6
  40. package/src/usage.ts +4 -0
  41. package/src/utils/idle-iterator.ts +6 -6
@@ -8,6 +8,7 @@
8
8
  * - `SqliteAuthCredentialStore`: concrete SQLite-backed implementation
9
9
  */
10
10
  import { Database, type Statement } from "bun:sqlite";
11
+ import { createHash } from "node:crypto";
11
12
  import * as fs from "node:fs/promises";
12
13
  import * as path from "node:path";
13
14
  import { getAgentDbPath, logger } from "@oh-my-pi/pi-utils";
@@ -42,6 +43,7 @@ import type {
42
43
  } from "./usage";
43
44
  import { resolveUsedFraction } from "./usage";
44
45
  import { claudeRankingStrategy, claudeUsageProvider } from "./usage/claude";
46
+ import { cursorUsageProvider } from "./usage/cursor";
45
47
  import { googleGeminiCliUsageProvider } from "./usage/gemini";
46
48
  import { githubCopilotUsageProvider } from "./usage/github-copilot";
47
49
  import { antigravityRankingStrategy, antigravityUsageProvider } from "./usage/google-antigravity";
@@ -55,9 +57,22 @@ import {
55
57
  listCodexResetCredits,
56
58
  } from "./usage/openai-codex-reset";
57
59
  import { opencodeGoUsageProvider } from "./usage/opencode-go";
58
- import { zaiUsageProvider } from "./usage/zai";
60
+ import { zaiRankingStrategy, zaiUsageProvider } from "./usage/zai";
59
61
 
60
62
  const USAGE_RANKING_METRIC_EPSILON = 1e-9;
63
+ /**
64
+ * Primary (short, e.g. 5h) window used-fraction at or above which a candidate
65
+ * is demoted behind cooler siblings during ranking: a nearly exhausted short
66
+ * window means an imminent mid-session block, so drain urgency defers to it.
67
+ */
68
+ const PRIMARY_WINDOW_HOT_FRACTION = 0.85;
69
+ const OAUTH_BEARER_FINGERPRINT_HISTORY_LIMIT = 8;
70
+
71
+ /** SHA-256 bearer fingerprint, so superseded OAuth token bytes never enter the identity cache. */
72
+ function fingerprintOAuthBearer(bearer: string): string {
73
+ return createHash("sha256").update(bearer).digest("base64url");
74
+ }
75
+ const SESSION_STICKY_CACHE_PREFIX = "session:sticky:";
61
76
 
62
77
  // ─────────────────────────────────────────────────────────────────────────────
63
78
  // Credential Types
@@ -160,8 +175,11 @@ export interface CredentialHealthResult {
160
175
  type: AuthCredential["type"];
161
176
  /** OAuth email if known on the stored credential or surfaced by the probe. */
162
177
  email?: string;
163
- /** OAuth account id / org id if known. */
178
+ /** OAuth account id if known. */
164
179
  accountId?: string;
180
+ /** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */
181
+ orgId?: string;
182
+ orgName?: string;
165
183
  /** `true` when the refresh token lives on a remote broker (sentinel was present). */
166
184
  remoteRefresh?: true;
167
185
  ok: boolean | null;
@@ -337,6 +355,8 @@ export interface AuthCredentialStore {
337
355
  deleteAuthCredentialsForProvider(provider: string, disabledCause: string): void;
338
356
  getCache(key: string, options?: { includeExpired?: boolean }): string | null;
339
357
  setCache(key: string, value: string, expiresAtSec: number): void;
358
+ /** Drop all cache rows whose keys start with the supplied prefix. */
359
+ deleteCachePrefix?(prefix: string): void;
340
360
  cleanExpiredCache(): void;
341
361
  /** Non-expired block for one (credential, providerKey, scope) key, or undefined. */
342
362
  getCredentialBlock?(credentialId: number, providerKey: string, blockScope: string): number | undefined;
@@ -566,6 +586,7 @@ const DEFAULT_USAGE_PROVIDERS: UsageProvider[] = [
566
586
  zaiUsageProvider,
567
587
  opencodeGoUsageProvider,
568
588
  githubCopilotUsageProvider,
589
+ cursorUsageProvider,
569
590
  ];
570
591
 
571
592
  const DEFAULT_USAGE_PROVIDER_MAP = new Map<Provider, UsageProvider>(
@@ -600,6 +621,11 @@ const DEFAULT_USAGE_REQUEST_TIMEOUT_MS = 10_000;
600
621
  const USAGE_REPORT_CACHE_KEY_VERSION_OVERRIDES: Partial<Record<Provider, number>> = {
601
622
  "google-antigravity": 2,
602
623
  zai: 2,
624
+ // v2: cache identity gained an `org:` component so two subscriptions on one
625
+ // account email stop sharing a slot. The bump also retires pre-org entries —
626
+ // otherwise an org-less credential could replay another org's cached pool
627
+ // (incl. the 24h last-good fallback) via the old bare email/account key.
628
+ anthropic: 2,
603
629
  };
604
630
  const DEFAULT_OAUTH_REFRESH_TIMEOUT_MS = 10_000;
605
631
  /**
@@ -682,7 +708,7 @@ type AuthApiKeyOptions = {
682
708
  */
683
709
  forceRefresh?: boolean;
684
710
  };
685
- type OAuthResolutionResult = { apiKey: string; credential: OAuthCredential };
711
+ type OAuthResolutionResult = { apiKey: string; credential: OAuthCredential; credentialId?: number };
686
712
 
687
713
  /**
688
714
  * Refreshed OAuth access plus identity metadata returned by
@@ -700,6 +726,22 @@ export interface OAuthAccess {
700
726
  projectId?: string;
701
727
  enterpriseUrl?: string;
702
728
  apiEndpoint?: string;
729
+ /** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */
730
+ orgId?: string;
731
+ orgName?: string;
732
+ }
733
+
734
+ /**
735
+ * Identity slice of the credential a successful {@link AuthStorage.login}
736
+ * stored — lets callers confirm WHICH account (and for Anthropic, which
737
+ * organization/subscription) was added, without exposing tokens.
738
+ */
739
+ export interface OAuthLoginIdentity {
740
+ type: "oauth" | "api_key";
741
+ email?: string;
742
+ accountId?: string;
743
+ orgId?: string;
744
+ orgName?: string;
703
745
  }
704
746
 
705
747
  export interface OAuthAccessFailure {
@@ -709,6 +751,9 @@ export interface OAuthAccessFailure {
709
751
  projectId?: string;
710
752
  enterpriseUrl?: string;
711
753
  apiEndpoint?: string;
754
+ /** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */
755
+ orgId?: string;
756
+ orgName?: string;
712
757
  error: string;
713
758
  }
714
759
 
@@ -722,6 +767,9 @@ export interface OAuthAccountIdentity {
722
767
  accountId?: string;
723
768
  email?: string;
724
769
  projectId?: string;
770
+ /** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */
771
+ orgId?: string;
772
+ orgName?: string;
725
773
  }
726
774
 
727
775
  export type OAuthAccessResolution = ({ ok: true } & OAuthAccess) | ({ ok: false } & OAuthAccessFailure);
@@ -738,6 +786,9 @@ export interface OAuthAccountSummary {
738
786
  email?: string;
739
787
  projectId?: string;
740
788
  enterpriseUrl?: string;
789
+ /** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */
790
+ orgId?: string;
791
+ orgName?: string;
741
792
  }
742
793
  export interface InvalidateCredentialMatchingOptions {
743
794
  signal?: AbortSignal;
@@ -911,6 +962,7 @@ const DEFAULT_RANKING_STRATEGIES = new Map<Provider, CredentialRankingStrategy>(
911
962
  ["openai-codex", codexRankingStrategy],
912
963
  ["anthropic", claudeRankingStrategy],
913
964
  ["google-antigravity", antigravityRankingStrategy],
965
+ ["zai", zaiRankingStrategy],
914
966
  ]);
915
967
 
916
968
  function resolveDefaultRankingStrategy(provider: Provider): CredentialRankingStrategy | undefined {
@@ -1035,26 +1087,34 @@ class AuthStorageUsageCache implements UsageCache {
1035
1087
  // ─────────────────────────────────────────────────────────────────────────────
1036
1088
 
1037
1089
  type StoredCredential = { id: number; credential: AuthCredential };
1038
- type OAuthSelection = { credential: OAuthCredential; index: number };
1090
+ type CredentialSelection<T extends AuthCredential> = { credential: T; index: number };
1091
+ type OAuthSelection = CredentialSelection<OAuthCredential>;
1092
+ type ApiKeySelection = CredentialSelection<ApiKeyCredential>;
1039
1093
  type StoredOAuthSelection = { credentialId: number; credential: OAuthCredential; index: number };
1040
1094
 
1041
- type OAuthCandidate = {
1042
- selection: OAuthSelection;
1095
+ type UsageCandidate<T extends AuthCredential> = {
1096
+ selection: CredentialSelection<T>;
1043
1097
  usage: UsageReport | null;
1044
1098
  usageChecked: boolean;
1045
1099
  };
1046
1100
 
1047
- type RankedOAuthCandidate = OAuthCandidate & {
1101
+ type OAuthCandidate = UsageCandidate<OAuthCredential>;
1102
+ type ApiKeyCandidate = UsageCandidate<ApiKeyCredential>;
1103
+ type UsageRankingResult<T extends AuthCredential> = UsageCandidate<T> & { blockedUntil: number | undefined };
1104
+
1105
+ type UsageRankedCandidate<T extends AuthCredential> = UsageCandidate<T> & {
1048
1106
  blocked: boolean;
1049
1107
  blockedUntil?: number;
1050
1108
  hasPriorityBoost: boolean;
1051
1109
  planPriority: number;
1052
1110
  secondaryUsed: number;
1053
- secondaryDrainRate: number;
1111
+ secondaryRequiredDrain: number;
1054
1112
  primaryUsed: number;
1055
- primaryDrainRate: number;
1113
+ primaryRequiredDrain: number;
1056
1114
  orderPos: number;
1057
1115
  };
1116
+ type RankedOAuthCandidate = UsageRankedCandidate<OAuthCredential>;
1117
+ type RankedApiKeyCandidate = UsageRankedCandidate<ApiKeyCredential>;
1058
1118
 
1059
1119
  // ─────────────────────────────────────────────────────────────────────────────
1060
1120
  // AuthStorage Class
@@ -1076,6 +1136,8 @@ export class AuthStorage {
1076
1136
  #providerRoundRobinIndex: Map<string, number> = new Map();
1077
1137
  /** Tracks the last used credential per provider for a session (used for rate-limit switching). */
1078
1138
  #sessionLastCredential: Map<string, Map<string, { type: AuthCredential["type"]; index: number }>> = new Map();
1139
+ /** Recent bearer fingerprints resolved for each durable OAuth row; used only for delayed usage-limit attribution. */
1140
+ #oauthBearerFingerprints: Map<string, Map<number, string[]>> = new Map();
1079
1141
  /** Maps provider:type -> credentialIndex -> blockedUntilMs for temporary backoff. */
1080
1142
  #credentialBackoff: Map<string, Map<number, number>> = new Map();
1081
1143
  /** Earliest time a freshly-set in-memory block may be cleared by live usage reconciliation. */
@@ -1329,6 +1391,16 @@ export class AuthStorage {
1329
1391
  #setStoredCredentials(provider: string, credentials: StoredCredential[]): void {
1330
1392
  const current = this.#data.get(provider) ?? [];
1331
1393
  if (storedCredentialArraysEqual(current, credentials)) return;
1394
+ const trackedBearerFingerprints = this.#oauthBearerFingerprints.get(provider);
1395
+ if (trackedBearerFingerprints) {
1396
+ const activeOAuthIds = new Set(
1397
+ credentials.filter(entry => entry.credential.type === "oauth").map(entry => entry.id),
1398
+ );
1399
+ for (const credentialId of trackedBearerFingerprints.keys()) {
1400
+ if (!activeOAuthIds.has(credentialId)) trackedBearerFingerprints.delete(credentialId);
1401
+ }
1402
+ if (trackedBearerFingerprints.size === 0) this.#oauthBearerFingerprints.delete(provider);
1403
+ }
1332
1404
  if (credentials.length === 0) {
1333
1405
  this.#data.delete(provider);
1334
1406
  } else {
@@ -1337,6 +1409,26 @@ export class AuthStorage {
1337
1409
  this.#bumpGeneration("credentials");
1338
1410
  }
1339
1411
 
1412
+ #recordOAuthBearerCredentialId(provider: string, bearer: string, credentialId: number | undefined): void {
1413
+ if (credentialId === undefined) return;
1414
+ const fingerprint = fingerprintOAuthBearer(bearer);
1415
+ const byCredentialId = this.#oauthBearerFingerprints.get(provider) ?? new Map<number, string[]>();
1416
+ const history = byCredentialId.get(credentialId) ?? [];
1417
+ const nextHistory = history.filter(previous => previous !== fingerprint);
1418
+ nextHistory.push(fingerprint);
1419
+ if (nextHistory.length > OAUTH_BEARER_FINGERPRINT_HISTORY_LIMIT) nextHistory.shift();
1420
+ byCredentialId.set(credentialId, nextHistory);
1421
+ this.#oauthBearerFingerprints.set(provider, byCredentialId);
1422
+ }
1423
+
1424
+ #findOAuthCredentialIdForBearer(provider: string, bearer: string): number | undefined {
1425
+ const fingerprint = fingerprintOAuthBearer(bearer);
1426
+ for (const [credentialId, history] of this.#oauthBearerFingerprints.get(provider) ?? []) {
1427
+ if (history.includes(fingerprint)) return credentialId;
1428
+ }
1429
+ return undefined;
1430
+ }
1431
+
1340
1432
  #resolveOAuthDedupeIdentityKey(provider: string, credential: OAuthCredential): string | null {
1341
1433
  return resolveCredentialIdentityKey(provider, credential);
1342
1434
  }
@@ -1598,7 +1690,7 @@ export class AuthStorage {
1598
1690
  try {
1599
1691
  const credentialId = this.#getStoredCredentials(provider)[index]?.id;
1600
1692
  if (credentialId !== undefined) {
1601
- const cacheKey = `session:sticky:${provider}:${sessionId}`;
1693
+ const cacheKey = `${SESSION_STICKY_CACHE_PREFIX}${provider}:${sessionId}`;
1602
1694
  const cacheValue = JSON.stringify({ type, index, credentialId });
1603
1695
  // Expires in 30 days
1604
1696
  const expiresAtSec = Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60;
@@ -1620,7 +1712,7 @@ export class AuthStorage {
1620
1712
  return sessionMap.get(sessionId);
1621
1713
  }
1622
1714
  try {
1623
- const cacheKey = `session:sticky:${provider}:${sessionId}`;
1715
+ const cacheKey = `${SESSION_STICKY_CACHE_PREFIX}${provider}:${sessionId}`;
1624
1716
  const raw = this.#store.getCache(cacheKey);
1625
1717
  if (raw) {
1626
1718
  const val = JSON.parse(raw) as { type: AuthCredential["type"]; index: number; credentialId?: number };
@@ -1664,7 +1756,7 @@ export class AuthStorage {
1664
1756
  }
1665
1757
  }
1666
1758
  try {
1667
- const cacheKey = `session:sticky:${provider}:${sessionId}`;
1759
+ const cacheKey = `${SESSION_STICKY_CACHE_PREFIX}${provider}:${sessionId}`;
1668
1760
  this.#store.setCache(cacheKey, "", 0);
1669
1761
  } catch (err) {
1670
1762
  logger.debug("Failed to clear session sticky credential from persistent store cache", { err });
@@ -1706,6 +1798,157 @@ export class AuthStorage {
1706
1798
  return fallback;
1707
1799
  }
1708
1800
 
1801
+ async #rankApiKeySelections(args: {
1802
+ providerKey: string;
1803
+ provider: string;
1804
+ order: number[];
1805
+ credentials: ApiKeySelection[];
1806
+ options?: AuthApiKeyOptions;
1807
+ strategy: CredentialRankingStrategy;
1808
+ rankingContext: CredentialRankingContext;
1809
+ blockScope?: string;
1810
+ }): Promise<ApiKeyCandidate[]> {
1811
+ const nowMs = Date.now();
1812
+ const { strategy } = args;
1813
+ const ranked: RankedApiKeyCandidate[] = [];
1814
+ const usageTimeout = Math.max(5000, this.#usageRequestTimeoutMs * 1.5);
1815
+ const usagePromise: Promise<Array<UsageRankingResult<ApiKeyCredential> | null>> = Promise.all(
1816
+ args.order.map(async idx => {
1817
+ const selection = args.credentials[idx];
1818
+ if (!selection) return null;
1819
+ const blockedUntil = this.#getCredentialBlockedUntil(
1820
+ args.provider,
1821
+ args.providerKey,
1822
+ selection.index,
1823
+ args.blockScope,
1824
+ );
1825
+ if (blockedUntil !== undefined) {
1826
+ return { selection, usage: null, usageChecked: false, blockedUntil };
1827
+ }
1828
+ const usage = await this.#getUsageReport(args.provider, selection.credential, {
1829
+ ...args.options,
1830
+ timeoutMs: this.#usageRequestTimeoutMs,
1831
+ });
1832
+ return { selection, usage, usageChecked: true, blockedUntil: undefined };
1833
+ }),
1834
+ );
1835
+ const timeoutSignal = Promise.withResolvers<null>();
1836
+ const timer = setTimeout(() => timeoutSignal.resolve(null), usageTimeout);
1837
+ timer.unref?.();
1838
+ const usageResults = await Promise.race([usagePromise, timeoutSignal.promise]).then(result => {
1839
+ clearTimeout(timer);
1840
+ if (result) return result;
1841
+ return args.order.map(idx => {
1842
+ const selection = args.credentials[idx];
1843
+ if (!selection) return null;
1844
+ const blockedUntil = this.#getCredentialBlockedUntil(
1845
+ args.provider,
1846
+ args.providerKey,
1847
+ selection.index,
1848
+ args.blockScope,
1849
+ );
1850
+ return { selection, usage: null, usageChecked: false, blockedUntil };
1851
+ });
1852
+ });
1853
+
1854
+ for (let orderPos = 0; orderPos < usageResults.length; orderPos += 1) {
1855
+ const result = usageResults[orderPos];
1856
+ if (!result) continue;
1857
+ const { selection, usage, usageChecked } = result;
1858
+ let { blockedUntil } = result;
1859
+ let blocked = blockedUntil !== undefined;
1860
+ const scopedLimits = usage ? this.#getScopedUsageLimits(strategy, usage, args.rankingContext) : undefined;
1861
+ if (!blocked && scopedLimits && this.#isUsageLimitReached(scopedLimits)) {
1862
+ const resetAtMs = this.#getUsageResetAtMs(scopedLimits, nowMs);
1863
+ blockedUntil = resetAtMs ?? Date.now() + AuthStorage.#defaultBackoffMs;
1864
+ this.#markCredentialBlocked(
1865
+ args.provider,
1866
+ args.providerKey,
1867
+ selection.index,
1868
+ blockedUntil,
1869
+ args.blockScope,
1870
+ );
1871
+ blocked = true;
1872
+ }
1873
+ const windows = usage ? strategy.findWindowLimits(usage, args.rankingContext) : undefined;
1874
+ const primary = windows?.primary;
1875
+ const secondary = windows?.secondary;
1876
+ const secondaryTarget = secondary ?? primary;
1877
+ ranked.push({
1878
+ selection,
1879
+ usage,
1880
+ usageChecked,
1881
+ blocked,
1882
+ blockedUntil,
1883
+ hasPriorityBoost: strategy.hasPriorityBoost?.(primary) ?? false,
1884
+ planPriority: 0,
1885
+ secondaryUsed: this.#normalizeUsageFraction(secondaryTarget),
1886
+ secondaryRequiredDrain: this.#computeWindowRequiredDrain(
1887
+ secondaryTarget,
1888
+ nowMs,
1889
+ strategy.windowDefaults.secondaryMs,
1890
+ ),
1891
+ primaryUsed: this.#normalizeUsageFraction(primary),
1892
+ primaryRequiredDrain: this.#computeWindowRequiredDrain(primary, nowMs, strategy.windowDefaults.primaryMs),
1893
+ orderPos,
1894
+ });
1895
+ }
1896
+ return this.#orderUsageRankedCandidates(ranked, "none");
1897
+ }
1898
+
1899
+ async #selectApiKeyCredential(
1900
+ provider: string,
1901
+ sessionId: string | undefined,
1902
+ options: AuthApiKeyOptions | undefined,
1903
+ filter?: (credential: ApiKeyCredential) => boolean,
1904
+ ): Promise<ApiKeySelection | undefined> {
1905
+ const credentials = this.#getCredentialsForProvider(provider)
1906
+ .map((credential, index) => ({ credential, index }))
1907
+ .filter((entry): entry is ApiKeySelection => {
1908
+ if (entry.credential.type !== "api_key") return false;
1909
+ return filter?.(entry.credential) ?? true;
1910
+ });
1911
+
1912
+ if (credentials.length === 0) return undefined;
1913
+ if (credentials.length === 1) return credentials[0];
1914
+
1915
+ const providerKey = this.#getProviderTypeKey(provider, "api_key");
1916
+ const order = this.#getCredentialOrder(providerKey, sessionId, credentials.length);
1917
+ const fallback = credentials[order[0]];
1918
+ const strategy = this.#rankingStrategyResolver?.(provider);
1919
+ if (!strategy) {
1920
+ for (const idx of order) {
1921
+ const candidate = credentials[idx];
1922
+ if (!this.#isCredentialBlocked(provider, providerKey, candidate.index)) {
1923
+ return candidate;
1924
+ }
1925
+ }
1926
+ return fallback;
1927
+ }
1928
+
1929
+ const rankingContext: CredentialRankingContext = { modelId: options?.modelId };
1930
+ const blockScope = strategy.blockScope?.(rankingContext);
1931
+ const candidates = await this.#rankApiKeySelections({
1932
+ providerKey,
1933
+ provider,
1934
+ order,
1935
+ credentials,
1936
+ options,
1937
+ strategy,
1938
+ rankingContext,
1939
+ blockScope,
1940
+ });
1941
+ return candidates[0]?.selection ?? fallback;
1942
+ }
1943
+
1944
+ #clearProviderSessionCredentialCache(provider: string): void {
1945
+ try {
1946
+ this.#store.deleteCachePrefix?.(`${SESSION_STICKY_CACHE_PREFIX}${provider}:`);
1947
+ } catch (err) {
1948
+ logger.debug("Failed to clear provider session sticky credentials from persistent store cache", { err });
1949
+ }
1950
+ }
1951
+
1709
1952
  /**
1710
1953
  * Clears round-robin and session assignment state for a provider.
1711
1954
  * Called when credentials are added/removed to prevent stale index references.
@@ -1717,6 +1960,7 @@ export class AuthStorage {
1717
1960
  }
1718
1961
  }
1719
1962
  this.#sessionLastCredential.delete(provider);
1963
+ this.#clearProviderSessionCredentialCache(provider);
1720
1964
  for (const key of this.#credentialBackoff.keys()) {
1721
1965
  if (key.startsWith(`${provider}:`)) {
1722
1966
  this.#credentialBackoff.delete(key);
@@ -2059,6 +2303,8 @@ export class AuthStorage {
2059
2303
  projectId: refreshed.projectId ?? current.projectId,
2060
2304
  enterpriseUrl: refreshed.enterpriseUrl ?? current.enterpriseUrl,
2061
2305
  apiEndpoint: refreshed.apiEndpoint ?? current.apiEndpoint,
2306
+ orgId: refreshed.orgId ?? current.orgId,
2307
+ orgName: refreshed.orgName ?? current.orgName,
2062
2308
  };
2063
2309
  if (this.#store.tryUpdateAuthCredentialIfMatches) {
2064
2310
  if (
@@ -2283,7 +2529,13 @@ export class AuthStorage {
2283
2529
  if (typeof preferred.projectId === "string" && preferred.projectId.length > 0) {
2284
2530
  identity.projectId = preferred.projectId;
2285
2531
  }
2286
- if (!identity.accountId && !identity.email && !identity.projectId) return undefined;
2532
+ if (typeof preferred.orgId === "string" && preferred.orgId.length > 0) {
2533
+ identity.orgId = preferred.orgId;
2534
+ }
2535
+ if (typeof preferred.orgName === "string" && preferred.orgName.length > 0) {
2536
+ identity.orgName = preferred.orgName;
2537
+ }
2538
+ if (!identity.accountId && !identity.email && !identity.projectId && !identity.orgId) return undefined;
2287
2539
  return identity;
2288
2540
  }
2289
2541
 
@@ -2304,7 +2556,10 @@ export class AuthStorage {
2304
2556
  }
2305
2557
 
2306
2558
  /**
2307
- * Login to an OAuth provider.
2559
+ * Login to an OAuth provider. Resolves with the stored credential's
2560
+ * identity slice (or `undefined` when nothing was stored) so callers can
2561
+ * surface which account — and for Anthropic, which organization — the
2562
+ * login registered.
2308
2563
  */
2309
2564
  async login(
2310
2565
  provider: OAuthProviderId,
@@ -2314,7 +2569,7 @@ export class AuthStorage {
2314
2569
  /** onPrompt is required for some providers (github-copilot, openai-codex) */
2315
2570
  onPrompt: (prompt: { message: string; placeholder?: string }) => Promise<string>;
2316
2571
  },
2317
- ): Promise<void> {
2572
+ ): Promise<OAuthLoginIdentity | undefined> {
2318
2573
  // Only paste-code providers (fixed non-loopback redirect, e.g. GitLab Duo
2319
2574
  // Agent's vscode:// URI) get a default manual-code prompt. For loopback OAuth
2320
2575
  // providers the `OAuthCallbackFlow` would otherwise race this readline prompt
@@ -2342,7 +2597,7 @@ export class AuthStorage {
2342
2597
  if (typeof result === "string") {
2343
2598
  // Some flows (e.g. ollama) return "" to signal that no key was entered.
2344
2599
  if (!result) {
2345
- return;
2600
+ return undefined;
2346
2601
  }
2347
2602
  const newCredential: ApiKeyCredential = { type: "api_key", key: result, source: "login" };
2348
2603
  const stored = this.#store.upsertAuthCredentialRemote
@@ -2353,13 +2608,20 @@ export class AuthStorage {
2353
2608
  stored.map(entry => ({ id: entry.id, credential: entry.credential })),
2354
2609
  );
2355
2610
  this.#resetProviderAssignments(provider);
2356
- return;
2611
+ return { type: "api_key" };
2357
2612
  }
2358
2613
  const newCredential: OAuthCredential = { type: "oauth", ...result };
2359
2614
  // Use #upsertOAuthCredential to upsert the new credential.
2360
2615
  // Any legacy api_key rows from older versions will be cleaned up so they do not
2361
2616
  // shadow the new OAuth row, while preserving other active OAuth credentials.
2362
2617
  await this.#upsertOAuthCredential(def.storeCredentialsAs ?? provider, newCredential);
2618
+ return {
2619
+ type: "oauth",
2620
+ email: newCredential.email,
2621
+ accountId: newCredential.accountId,
2622
+ orgId: newCredential.orgId,
2623
+ orgName: newCredential.orgName,
2624
+ };
2363
2625
  }
2364
2626
 
2365
2627
  /**
@@ -2374,7 +2636,13 @@ export class AuthStorage {
2374
2636
  // Queries provider usage endpoints to detect rate limits before they occur.
2375
2637
  // ─────────────────────────────────────────────────────────────────────────────
2376
2638
 
2377
- #buildUsageCredential(credential: OAuthCredential): UsageCredential {
2639
+ #buildUsageCredential(credential: AuthCredential): UsageCredential {
2640
+ if (credential.type === "api_key") {
2641
+ return {
2642
+ type: "api_key",
2643
+ apiKey: credential.key,
2644
+ };
2645
+ }
2378
2646
  return {
2379
2647
  type: "oauth",
2380
2648
  accessToken: credential.access,
@@ -2383,6 +2651,8 @@ export class AuthStorage {
2383
2651
  accountId: credential.accountId,
2384
2652
  projectId: credential.projectId,
2385
2653
  email: credential.email,
2654
+ orgId: credential.orgId,
2655
+ orgName: credential.orgName,
2386
2656
  enterpriseUrl: credential.enterpriseUrl,
2387
2657
  apiEndpoint: credential.apiEndpoint,
2388
2658
  };
@@ -2394,14 +2664,17 @@ export class AuthStorage {
2394
2664
  if (accountId) parts.push(`account:${accountId}`);
2395
2665
  const email = credential.email?.trim().toLowerCase();
2396
2666
  if (email) parts.push(`email:${email}`);
2667
+ const orgId = credential.orgId?.trim();
2668
+ if (orgId) parts.push(`org:${orgId}`);
2397
2669
  const projectId = credential.projectId?.trim();
2398
2670
  if (projectId) parts.push(`project:${projectId}`);
2399
2671
  const enterpriseUrl = credential.enterpriseUrl?.trim().toLowerCase();
2400
2672
  if (enterpriseUrl) parts.push(`enterprise:${enterpriseUrl}`);
2401
- // Only fall back to a secret-derived key when a stable account identifier is unavailable.
2402
- // Including the token hash when accountId/email are present causes cache misses on
2403
- // every OAuth refresh — usage data is per-account, not per-token.
2404
- const hasStableIdentifier = Boolean(accountId || email);
2673
+ // Only fall back to a secret-derived key when a stable account identifier is
2674
+ // unavailable. Including the token hash when accountId/email/orgId are present
2675
+ // causes cache misses on every OAuth refresh — usage data is per-account (or
2676
+ // per-org for org-only anthropic rows), not per-token.
2677
+ const hasStableIdentifier = Boolean(accountId || email || orgId);
2405
2678
  if (!hasStableIdentifier) {
2406
2679
  const secret = credential.apiKey?.trim() || credential.refreshToken?.trim() || credential.accessToken?.trim();
2407
2680
  if (secret) {
@@ -2462,6 +2735,8 @@ export class AuthStorage {
2462
2735
  accountId: credential.accountId,
2463
2736
  projectId: credential.projectId,
2464
2737
  email: credential.email,
2738
+ orgId: credential.orgId,
2739
+ orgName: credential.orgName,
2465
2740
  enterpriseUrl: credential.enterpriseUrl,
2466
2741
  apiEndpoint: credential.apiEndpoint,
2467
2742
  };
@@ -2502,6 +2777,8 @@ export class AuthStorage {
2502
2777
  email: refreshed.email ?? credential.email,
2503
2778
  enterpriseUrl: refreshed.enterpriseUrl ?? credential.enterpriseUrl,
2504
2779
  apiEndpoint: refreshed.apiEndpoint ?? credential.apiEndpoint,
2780
+ orgId: refreshed.orgId ?? credential.orgId,
2781
+ orgName: refreshed.orgName ?? credential.orgName,
2505
2782
  };
2506
2783
  }
2507
2784
 
@@ -2512,14 +2789,20 @@ export class AuthStorage {
2512
2789
  */
2513
2790
  #findStoredCredentialIdForUsageCredential(provider: Provider, previous: UsageCredential): number | undefined {
2514
2791
  const entries = this.#getStoredCredentials(provider);
2792
+ // Broker-backed rows all carry REMOTE_REFRESH_SENTINEL as their refresh
2793
+ // token — it identifies nothing, and comparing it would match the FIRST
2794
+ // OAuth row regardless of which account/org is being refreshed.
2795
+ const previousRefresh =
2796
+ previous.refreshToken && previous.refreshToken !== REMOTE_REFRESH_SENTINEL ? previous.refreshToken : undefined;
2515
2797
  const match = entries.find(entry => {
2516
2798
  if (entry.credential.type !== "oauth") return false;
2517
- if (previous.refreshToken && entry.credential.refresh === previous.refreshToken) return true;
2799
+ if (previousRefresh && entry.credential.refresh === previousRefresh) return true;
2518
2800
  if (previous.accessToken && entry.credential.access === previous.accessToken) return true;
2519
2801
  return (
2520
2802
  entry.credential.accountId === previous.accountId &&
2521
2803
  entry.credential.email === previous.email &&
2522
- entry.credential.projectId === previous.projectId
2804
+ entry.credential.projectId === previous.projectId &&
2805
+ entry.credential.orgId === previous.orgId
2523
2806
  );
2524
2807
  });
2525
2808
  return match?.id;
@@ -2527,14 +2810,18 @@ export class AuthStorage {
2527
2810
 
2528
2811
  #persistRefreshedUsageCredential(provider: Provider, previous: UsageCredential, next: UsageCredential): void {
2529
2812
  const entries = this.#getStoredCredentials(provider);
2813
+ // Same sentinel rule as #findStoredCredentialIdForUsageCredential above.
2814
+ const previousRefresh =
2815
+ previous.refreshToken && previous.refreshToken !== REMOTE_REFRESH_SENTINEL ? previous.refreshToken : undefined;
2530
2816
  const index = entries.findIndex(entry => {
2531
2817
  if (entry.credential.type !== "oauth") return false;
2532
- if (previous.refreshToken && entry.credential.refresh === previous.refreshToken) return true;
2818
+ if (previousRefresh && entry.credential.refresh === previousRefresh) return true;
2533
2819
  if (previous.accessToken && entry.credential.access === previous.accessToken) return true;
2534
2820
  return (
2535
2821
  entry.credential.accountId === previous.accountId &&
2536
2822
  entry.credential.email === previous.email &&
2537
- entry.credential.projectId === previous.projectId
2823
+ entry.credential.projectId === previous.projectId &&
2824
+ entry.credential.orgId === previous.orgId
2538
2825
  );
2539
2826
  });
2540
2827
  if (index === -1) return;
@@ -2550,6 +2837,8 @@ export class AuthStorage {
2550
2837
  email: next.email,
2551
2838
  enterpriseUrl: next.enterpriseUrl,
2552
2839
  apiEndpoint: next.apiEndpoint,
2840
+ orgId: next.orgId ?? existing.orgId,
2841
+ orgName: next.orgName ?? existing.orgName,
2553
2842
  });
2554
2843
  }
2555
2844
 
@@ -2649,11 +2938,30 @@ export class AuthStorage {
2649
2938
  if (providerImpl.supports && !providerImpl.supports(params)) return null;
2650
2939
 
2651
2940
  try {
2652
- return await providerImpl.fetchUsage(params, {
2941
+ const report = await providerImpl.fetchUsage(params, {
2653
2942
  fetch: this.#usageFetch,
2654
2943
  logger: this.#usageLogger,
2655
2944
  listUsageCosts: query => this.#store.listUsageCosts?.(query) ?? [],
2656
2945
  });
2946
+ // Attribute the report to the credential's organization. The orgId and
2947
+ // orgName fallbacks apply independently: Claude's usage endpoint stamps
2948
+ // orgId from the `anthropic-organization-id` response header but never
2949
+ // carries a display name, so the stored name must still be attached.
2950
+ // Never attach the stored name over a DIFFERENT org's report.
2951
+ if (report && params.credential.orgId !== undefined) {
2952
+ const metadata = report.metadata ?? {};
2953
+ const sameOrg = metadata.orgId === undefined || metadata.orgId === params.credential.orgId;
2954
+ const needsOrgId = metadata.orgId === undefined;
2955
+ const needsOrgName = sameOrg && params.credential.orgName !== undefined && metadata.orgName === undefined;
2956
+ if (needsOrgId || needsOrgName) {
2957
+ report.metadata = {
2958
+ ...metadata,
2959
+ ...(needsOrgId ? { orgId: params.credential.orgId } : {}),
2960
+ ...(needsOrgName ? { orgName: params.credential.orgName } : {}),
2961
+ };
2962
+ }
2963
+ }
2964
+ return report;
2657
2965
  } catch (error) {
2658
2966
  logger.debug("AuthStorage usage fetch failed", {
2659
2967
  provider: request.provider,
@@ -2817,6 +3125,8 @@ export class AuthStorage {
2817
3125
  options?: { sessionId?: string; baseUrl?: string },
2818
3126
  ): boolean {
2819
3127
  if (this.#fetchUsageReportsOverride) return false;
3128
+ const parseHeaders = this.#usageProviderResolver?.(provider)?.parseRateLimitHeaders;
3129
+ if (!parseHeaders) return false;
2820
3130
 
2821
3131
  const credential = this.#resolveActiveOAuthCredential(provider, options?.sessionId);
2822
3132
  if (!credential) return false;
@@ -2825,15 +3135,20 @@ export class AuthStorage {
2825
3135
  this.#buildUsageRequestForOauth(provider, credential, options?.baseUrl),
2826
3136
  );
2827
3137
  const now = Date.now();
2828
- const last = this.#usageHeaderIngestAt.get(cacheKey);
2829
- if (last !== undefined && now - last < USAGE_HEADER_INGEST_INTERVAL_MS) return false;
2830
-
2831
- const parsedReport = this.#usageProviderResolver?.(provider)?.parseRateLimitHeaders?.(headers, now);
3138
+ const parsedReport = parseHeaders(headers, now);
2832
3139
  if (!parsedReport) return false;
3140
+ // Throttled to one ingest per interval — except when a window reads
3141
+ // exhausted: that snapshot must land immediately so the next getApiKey
3142
+ // blocks the credential instead of burning a wire 429 on the wall.
3143
+ const exhausted = parsedReport.limits.some(limit => this.#isUsageLimitExhausted(limit));
3144
+ const last = this.#usageHeaderIngestAt.get(cacheKey);
3145
+ if (!exhausted && last !== undefined && now - last < USAGE_HEADER_INGEST_INTERVAL_MS) return false;
2833
3146
  const metadata: Record<string, unknown> = { ...(parsedReport.metadata ?? {}) };
2834
3147
  if (credential.accountId && metadata.accountId === undefined) metadata.accountId = credential.accountId;
2835
3148
  if (credential.email && metadata.email === undefined) metadata.email = credential.email;
2836
3149
  if (credential.projectId && metadata.projectId === undefined) metadata.projectId = credential.projectId;
3150
+ if (credential.orgId && metadata.orgId === undefined) metadata.orgId = credential.orgId;
3151
+ if (credential.orgName && metadata.orgName === undefined) metadata.orgName = credential.orgName;
2837
3152
  const report: UsageReport = { ...parsedReport, metadata };
2838
3153
 
2839
3154
  const storeIngest = this.#store.ingestUsageReport?.bind(this.#store);
@@ -2960,7 +3275,28 @@ export class AuthStorage {
2960
3275
  const identifiers: string[] = [];
2961
3276
  const email = this.#getUsageReportMetadataValue(report, "email");
2962
3277
  if (email) identifiers.push(`email:${email.toLowerCase()}`);
2963
- if (report.provider === "openai-codex" || report.provider === "anthropic") {
3278
+ if (report.provider === "anthropic") {
3279
+ // Anthropic: one account email can hold several organizations
3280
+ // (Team seat + personal Max). Reports from different orgs must not
3281
+ // merge — scope every identifier by org when the report carries one.
3282
+ // When the email could not be recovered, fall back to the account
3283
+ // (identical across orgs, hence the org qualifier is what keeps two
3284
+ // subscriptions apart) so no-email reports still merge per org.
3285
+ // Org-less reports (pre-upgrade caches) keep their bare identifiers
3286
+ // and only merge among themselves.
3287
+ if (identifiers.length === 0) {
3288
+ const accountId =
3289
+ this.#getUsageReportMetadataValue(report, "accountId") ?? this.#getUsageReportScopeAccountId(report);
3290
+ if (accountId) identifiers.push(`account:${accountId}`);
3291
+ }
3292
+ const orgId = this.#getUsageReportMetadataValue(report, "orgId");
3293
+ if (orgId) {
3294
+ if (identifiers.length === 0) return [`anthropic:org:${orgId.toLowerCase()}`];
3295
+ return identifiers.map(identifier => `anthropic:org:${orgId.toLowerCase()}|${identifier.toLowerCase()}`);
3296
+ }
3297
+ return identifiers.map(identifier => `anthropic:${identifier.toLowerCase()}`);
3298
+ }
3299
+ if (report.provider === "openai-codex") {
2964
3300
  return identifiers.map(identifier => `${report.provider}:${identifier.toLowerCase()}`);
2965
3301
  }
2966
3302
  const projectId =
@@ -3094,26 +3430,36 @@ export class AuthStorage {
3094
3430
 
3095
3431
  async #getUsageReport(
3096
3432
  provider: Provider,
3097
- credential: OAuthCredential,
3433
+ credential: AuthCredential,
3098
3434
  options?: { baseUrl?: string; timeoutMs?: number; signal?: AbortSignal },
3099
3435
  ): Promise<UsageReport | null> {
3100
3436
  // Store-level hook (e.g. `RemoteAuthCredentialStore`) is authoritative
3101
- // when present: the broker already aggregates usage from a less-throttled
3102
- // IP, and falling back to the local per-credential fetch would defeat the
3103
- // whole point of routing through it.
3104
- const storeHook = this.#store.getUsageReport?.bind(this.#store);
3105
- if (storeHook) {
3106
- const report = await storeHook(provider, credential, options?.signal);
3107
- if (report) {
3108
- this.#reconcileCodexUsageBlock(
3109
- this.#buildUsageRequestForOauth(provider, credential, options?.baseUrl),
3110
- report,
3111
- );
3437
+ // when present for OAuth: the broker already aggregates usage from a
3438
+ // less-throttled IP, and falling back to the local per-credential fetch
3439
+ // would defeat the point of routing through it. API-key credentials do
3440
+ // not have a broker per-credential hook, so they use the normal cached
3441
+ // provider fetch path.
3442
+ if (credential.type === "oauth") {
3443
+ const storeHook = this.#store.getUsageReport?.bind(this.#store);
3444
+ if (storeHook) {
3445
+ const report = await storeHook(provider, credential, options?.signal);
3446
+ if (report) {
3447
+ this.#reconcileCodexUsageBlock(
3448
+ this.#buildUsageRequestForOauth(provider, credential, options?.baseUrl),
3449
+ report,
3450
+ );
3451
+ }
3452
+ return report;
3112
3453
  }
3113
- return report;
3454
+ }
3455
+ const usageCredential = this.#buildUsageCredential(credential);
3456
+ if (credential.type === "api_key") {
3457
+ const resolvedApiKey = await this.#configValueResolver(credential.key);
3458
+ if (!resolvedApiKey) return null;
3459
+ usageCredential.apiKey = resolvedApiKey;
3114
3460
  }
3115
3461
  return this.#fetchUsageCached(
3116
- this.#buildUsageRequestForOauth(provider, credential, options?.baseUrl),
3462
+ this.#buildUsageRequest(provider, usageCredential, options?.baseUrl),
3117
3463
  options?.timeoutMs ?? this.#usageRequestTimeoutMs,
3118
3464
  );
3119
3465
  }
@@ -3274,6 +3620,8 @@ export class AuthStorage {
3274
3620
  if (row.credential.type === "oauth") {
3275
3621
  if (row.credential.email) base.email = row.credential.email;
3276
3622
  if (row.credential.accountId) base.accountId = row.credential.accountId;
3623
+ if (row.credential.orgId) base.orgId = row.credential.orgId;
3624
+ if (row.credential.orgName) base.orgName = row.credential.orgName;
3277
3625
  if (row.credential.refresh === REMOTE_REFRESH_SENTINEL) base.remoteRefresh = true;
3278
3626
  }
3279
3627
 
@@ -3401,6 +3749,39 @@ export class AuthStorage {
3401
3749
  return results;
3402
3750
  }
3403
3751
 
3752
+ async #resolveCredentialTarget(
3753
+ provider: string,
3754
+ sessionId: string | undefined,
3755
+ options?: { credentialId?: number; apiKey?: string },
3756
+ ): Promise<{ type: AuthCredential["type"]; index: number; explicit: boolean } | undefined> {
3757
+ const explicit = options?.credentialId !== undefined || options?.apiKey !== undefined;
3758
+ if (explicit) {
3759
+ const latestRows = this.#store.listAuthCredentials(provider);
3760
+ this.#setStoredCredentials(
3761
+ provider,
3762
+ latestRows.map(row => ({ id: row.id, credential: row.credential })),
3763
+ );
3764
+ }
3765
+ if (options?.credentialId !== undefined) {
3766
+ const stored = this.#getStoredCredentials(provider);
3767
+ const index = stored.findIndex(entry => entry.id === options.credentialId);
3768
+ const entry = index === -1 ? undefined : stored[index];
3769
+ if (entry) return { type: entry.credential.type, index, explicit: true };
3770
+ }
3771
+ if (options?.apiKey !== undefined) {
3772
+ const stored = this.#getStoredCredentials(provider);
3773
+ for (let index = 0; index < stored.length; index++) {
3774
+ const entry = stored[index];
3775
+ if (entry && (await this.#credentialMatchesApiKey(entry.credential, options.apiKey))) {
3776
+ return { type: entry.credential.type, index, explicit: true };
3777
+ }
3778
+ }
3779
+ }
3780
+ if (explicit) return undefined;
3781
+ const sessionCredential = this.#getSessionCredential(provider, sessionId);
3782
+ return sessionCredential ? { ...sessionCredential, explicit: false } : undefined;
3783
+ }
3784
+
3404
3785
  /**
3405
3786
  * Marks the current session's credential as temporarily blocked due to usage limits.
3406
3787
  * Uses usage reports to determine accurate reset time when available.
@@ -3411,52 +3792,72 @@ export class AuthStorage {
3411
3792
  async markUsageLimitReached(
3412
3793
  provider: string,
3413
3794
  sessionId: string | undefined,
3414
- options?: { retryAfterMs?: number; baseUrl?: string; modelId?: string; apiKey?: string; signal?: AbortSignal },
3795
+ options?: {
3796
+ retryAfterMs?: number;
3797
+ baseUrl?: string;
3798
+ modelId?: string;
3799
+ apiKey?: string;
3800
+ credentialId?: number;
3801
+ signal?: AbortSignal;
3802
+ },
3415
3803
  ): Promise<UsageLimitMarkResult> {
3416
- let sessionCredential: { type: AuthCredential["type"]; index: number } | undefined;
3417
- if (options?.apiKey) {
3418
- const stored = this.#getStoredCredentials(provider);
3419
- for (let index = 0; index < stored.length; index++) {
3420
- const entry = stored[index];
3421
- if (entry && (await this.#credentialMatchesApiKey(entry.credential, options.apiKey))) {
3422
- sessionCredential = { type: entry.credential.type, index };
3423
- break;
3424
- }
3425
- }
3804
+ let sessionCredential = await this.#resolveCredentialTarget(provider, sessionId, {
3805
+ credentialId: options?.credentialId,
3806
+ apiKey: options?.apiKey,
3807
+ });
3808
+ if (!sessionCredential && options?.credentialId === undefined && options?.apiKey !== undefined) {
3809
+ // Account quota survives OAuth bearer rotation. Attribute a delayed
3810
+ // usage-limit response through the durable row id captured when this
3811
+ // exact bearer was resolved; never use this alias for hard auth errors.
3812
+ const credentialId = this.#findOAuthCredentialIdForBearer(provider, options.apiKey);
3813
+ const index =
3814
+ credentialId === undefined
3815
+ ? -1
3816
+ : this.#getStoredCredentials(provider).findIndex(
3817
+ entry => entry.id === credentialId && entry.credential.type === "oauth",
3818
+ );
3819
+ if (index >= 0) sessionCredential = { type: "oauth", index, explicit: true };
3426
3820
  }
3427
- sessionCredential ??= this.#getSessionCredential(provider, sessionId);
3428
3821
  if (!sessionCredential) return { switched: false };
3822
+ const target = this.#getStoredCredentials(provider)[sessionCredential.index];
3823
+ if (!target || target.credential.type !== sessionCredential.type) return { switched: false };
3824
+ const credentialType = sessionCredential.type;
3825
+ const targetCredentialId = target.id;
3429
3826
 
3430
- const providerKey = this.#getProviderTypeKey(provider, sessionCredential.type);
3827
+ const providerKey = this.#getProviderTypeKey(provider, credentialType);
3431
3828
  const strategy = this.#rankingStrategyResolver?.(provider);
3432
3829
  const rankingContext: CredentialRankingContext = { modelId: options?.modelId };
3433
3830
  const blockScope = strategy?.blockScope?.(rankingContext);
3434
3831
  const now = Date.now();
3435
3832
  let blockedUntil = now + (options?.retryAfterMs ?? AuthStorage.#defaultBackoffMs);
3436
3833
 
3437
- if (sessionCredential.type === "oauth" && strategy) {
3438
- const credential = this.#getCredentialsForProvider(provider)[sessionCredential.index];
3439
- if (credential?.type === "oauth") {
3440
- const report = await this.#getUsageReport(provider, credential, options);
3441
- if (report) {
3442
- const scopedLimits = this.#getScopedUsageLimits(strategy, report, rankingContext);
3443
- if (this.#isUsageLimitReached(scopedLimits)) {
3444
- const resetAtMs = this.#getUsageResetAtMs(scopedLimits, Date.now());
3445
- if (resetAtMs && resetAtMs > blockedUntil) {
3446
- blockedUntil = resetAtMs;
3447
- }
3834
+ if (credentialType === "oauth" && target.credential.type === "oauth" && strategy) {
3835
+ const report = await this.#getUsageReport(provider, target.credential, options);
3836
+ if (report) {
3837
+ const scopedLimits = this.#getScopedUsageLimits(strategy, report, rankingContext);
3838
+ if (this.#isUsageLimitReached(scopedLimits)) {
3839
+ const resetAtMs = this.#getUsageResetAtMs(scopedLimits, Date.now());
3840
+ if (resetAtMs && resetAtMs > blockedUntil) {
3841
+ blockedUntil = resetAtMs;
3448
3842
  }
3449
3843
  }
3450
3844
  }
3451
3845
  }
3452
3846
 
3453
- this.#markCredentialBlocked(provider, providerKey, sessionCredential.index, blockedUntil, blockScope);
3847
+ // Usage lookup may refresh, disable, or remove a row. Re-resolve its
3848
+ // durable id before applying positional in-memory and persisted blocks.
3849
+ const targetIndex = this.#getStoredCredentials(provider).findIndex(
3850
+ entry => entry.id === targetCredentialId && entry.credential.type === credentialType,
3851
+ );
3852
+ if (targetIndex >= 0) {
3853
+ this.#markCredentialBlocked(provider, providerKey, targetIndex, blockedUntil, blockScope);
3854
+ }
3454
3855
 
3455
3856
  const remainingCredentials = this.#getCredentialsForProvider(provider)
3456
3857
  .map((credential, index) => ({ credential, index }))
3457
3858
  .filter(
3458
3859
  (entry): entry is { credential: AuthCredential; index: number } =>
3459
- entry.credential.type === sessionCredential.type && entry.index !== sessionCredential.index,
3860
+ entry.credential.type === credentialType && entry.index !== targetIndex,
3460
3861
  );
3461
3862
 
3462
3863
  let retryAtMs: number | undefined;
@@ -3489,33 +3890,33 @@ export class AuthStorage {
3489
3890
  return Math.min(Math.max(usedFraction, 0), 1);
3490
3891
  }
3491
3892
 
3492
- /** Computes `usedFraction / elapsedHours` — consumption rate per hour within the current window. Lower drain rate = less pressure = preferred. */
3493
- #computeWindowDrainRate(limit: UsageLimit | undefined, nowMs: number, fallbackDurationMs: number): number {
3494
- const usedFraction = this.#normalizeUsageFraction(limit);
3495
- const durationMs = limit?.window?.durationMs ?? fallbackDurationMs;
3496
- if (!Number.isFinite(durationMs) || durationMs <= 0) {
3497
- return usedFraction;
3498
- }
3893
+ /**
3894
+ * Computes the required drain rate: `headroomFraction / remainingHours`
3895
+ * how fast the window's remaining quota must be consumed to fully use it
3896
+ * before it resets and expires. Higher = more headroom at risk of expiring
3897
+ * unused = ranked first, so selection chases quota that is about to be
3898
+ * wasted ("use it or lose it"). Without a reset clock the headroom
3899
+ * fraction alone is returned, degrading to most-headroom-first.
3900
+ */
3901
+ #computeWindowRequiredDrain(limit: UsageLimit | undefined, nowMs: number, fallbackDurationMs: number): number {
3902
+ const headroom = 1 - this.#normalizeUsageFraction(limit);
3903
+ if (headroom <= 0) return 0;
3499
3904
  const resetAt = this.#resolveWindowResetAt(limit?.window);
3500
- if (!Number.isFinite(resetAt)) {
3501
- return usedFraction;
3502
- }
3503
- const remainingWindowMs = (resetAt as number) - nowMs;
3504
- const clampedRemainingWindowMs = Math.min(Math.max(remainingWindowMs, 0), durationMs);
3505
- const elapsedMs = durationMs - clampedRemainingWindowMs;
3506
- if (elapsedMs <= 0) {
3507
- return usedFraction;
3508
- }
3509
- const elapsedHours = elapsedMs / (60 * 60 * 1000);
3510
- if (!Number.isFinite(elapsedHours) || elapsedHours <= 0) {
3511
- return usedFraction;
3905
+ if (resetAt === undefined) return headroom;
3906
+ const durationMs = limit?.window?.durationMs ?? fallbackDurationMs;
3907
+ let remainingMs = resetAt - nowMs;
3908
+ if (Number.isFinite(durationMs) && durationMs > 0) {
3909
+ remainingMs = Math.min(remainingMs, durationMs);
3512
3910
  }
3513
- return usedFraction / elapsedHours;
3911
+ // Floor at one minute: a stale report whose reset already passed must
3912
+ // not produce an unbounded urgency score.
3913
+ const remainingHours = Math.max(remainingMs, 60_000) / (60 * 60 * 1000);
3914
+ return headroom / remainingHours;
3514
3915
  }
3515
3916
 
3516
- #compareRankedOAuthCandidatePriority(
3517
- left: RankedOAuthCandidate,
3518
- right: RankedOAuthCandidate,
3917
+ #compareUsageRankedCandidatePriority(
3918
+ left: UsageRankedCandidate<AuthCredential>,
3919
+ right: UsageRankedCandidate<AuthCredential>,
3519
3920
  planRequirement: OpenAICodexPlanRequirement,
3520
3921
  ): number {
3521
3922
  if (left.blocked !== right.blocked) return left.blocked ? 1 : -1;
@@ -3529,91 +3930,49 @@ export class AuthStorage {
3529
3930
  return left.planPriority - right.planPriority;
3530
3931
  }
3531
3932
  if (left.hasPriorityBoost !== right.hasPriorityBoost) return left.hasPriorityBoost ? -1 : 1;
3532
- let metric = compareUsageRankingMetric(left.secondaryDrainRate, right.secondaryDrainRate);
3933
+ // Short-window guard: candidates whose primary (e.g. 5h) window is
3934
+ // nearly exhausted rank behind cool ones regardless of drain urgency —
3935
+ // overflow lands on the next-most-urgent cool account instead.
3936
+ const leftHot = left.primaryUsed >= PRIMARY_WINDOW_HOT_FRACTION;
3937
+ const rightHot = right.primaryUsed >= PRIMARY_WINDOW_HOT_FRACTION;
3938
+ if (leftHot !== rightHot) return leftHot ? 1 : -1;
3939
+ // Usage-backed candidates outrank unmeasured ones: required-drain
3940
+ // scores are only comparable between measured windows, and the
3941
+ // clockless headroom fallback (0..1) must not let an account whose
3942
+ // usage fetch failed shadow a measured sibling.
3943
+ const leftMeasured = left.usage !== null;
3944
+ const rightMeasured = right.usage !== null;
3945
+ if (leftMeasured !== rightMeasured) return leftMeasured ? -1 : 1;
3946
+ // Required drain, descending: the account whose remaining quota must
3947
+ // burn fastest to avoid expiring unused at its reset comes first, so
3948
+ // staggered resets land at ~100% utilization instead of stranding
3949
+ // headroom that a cooler sibling could have absorbed.
3950
+ let metric = compareUsageRankingMetric(right.secondaryRequiredDrain, left.secondaryRequiredDrain);
3533
3951
  if (metric !== 0) return metric;
3534
3952
  metric = compareUsageRankingMetric(left.secondaryUsed, right.secondaryUsed);
3535
3953
  if (metric !== 0) return metric;
3536
- metric = compareUsageRankingMetric(left.primaryDrainRate, right.primaryDrainRate);
3954
+ metric = compareUsageRankingMetric(right.primaryRequiredDrain, left.primaryRequiredDrain);
3537
3955
  if (metric !== 0) return metric;
3538
3956
  metric = compareUsageRankingMetric(left.primaryUsed, right.primaryUsed);
3539
3957
  if (metric !== 0) return metric;
3540
3958
  return 0;
3541
3959
  }
3542
3960
 
3543
- #compareRankedOAuthCandidates(
3544
- left: RankedOAuthCandidate,
3545
- right: RankedOAuthCandidate,
3961
+ #compareUsageRankedCandidates(
3962
+ left: UsageRankedCandidate<AuthCredential>,
3963
+ right: UsageRankedCandidate<AuthCredential>,
3546
3964
  planRequirement: OpenAICodexPlanRequirement,
3547
3965
  ): number {
3548
- const priority = this.#compareRankedOAuthCandidatePriority(left, right, planRequirement);
3966
+ const priority = this.#compareUsageRankedCandidatePriority(left, right, planRequirement);
3549
3967
  return priority !== 0 ? priority : left.orderPos - right.orderPos;
3550
3968
  }
3551
3969
 
3552
- #orderRankedOAuthCandidates(
3553
- candidates: RankedOAuthCandidate[],
3554
- sessionId: string | undefined,
3970
+ #orderUsageRankedCandidates<T extends AuthCredential>(
3971
+ candidates: UsageRankedCandidate<T>[],
3555
3972
  planRequirement: OpenAICodexPlanRequirement,
3556
- ): OAuthCandidate[] {
3557
- candidates.sort((left, right) => this.#compareRankedOAuthCandidates(left, right, planRequirement));
3558
- if (!sessionId) {
3559
- return candidates.map(candidate => ({
3560
- selection: candidate.selection,
3561
- usage: candidate.usage,
3562
- usageChecked: candidate.usageChecked,
3563
- }));
3564
- }
3565
-
3566
- const unblocked = candidates.filter(candidate => !candidate.blocked);
3567
- if (unblocked.length <= 1) {
3568
- return candidates.map(candidate => ({
3569
- selection: candidate.selection,
3570
- usage: candidate.usage,
3571
- usageChecked: candidate.usageChecked,
3572
- }));
3573
- }
3574
-
3575
- const priorityByCandidate = new Map<RankedOAuthCandidate, number>();
3576
- let bucketIndex = 0;
3577
- let previous = unblocked[0];
3578
- const bucketByCandidate = new Map<RankedOAuthCandidate, number>();
3579
- for (const candidate of unblocked) {
3580
- if (
3581
- candidate !== previous &&
3582
- this.#compareRankedOAuthCandidatePriority(previous, candidate, planRequirement) !== 0
3583
- ) {
3584
- bucketIndex += 1;
3585
- }
3586
- bucketByCandidate.set(candidate, bucketIndex);
3587
- previous = candidate;
3588
- }
3589
- const maxBucket = bucketIndex;
3590
- for (const candidate of unblocked) {
3591
- const bucket = bucketByCandidate.get(candidate) ?? 0;
3592
- priorityByCandidate.set(candidate, maxBucket === 0 ? 0 : 1 - bucket / maxBucket);
3593
- }
3594
-
3595
- let totalWeight = 0;
3596
- for (const candidate of unblocked) {
3597
- totalWeight += 1 + (priorityByCandidate.get(candidate) ?? 0);
3598
- }
3599
-
3600
- const hit = ((Bun.hash.xxHash32(sessionId) >>> 0) / 2 ** 32) * totalWeight;
3601
- let cursor = 0;
3602
- let selected = unblocked[unblocked.length - 1];
3603
- for (const candidate of unblocked) {
3604
- cursor += 1 + (priorityByCandidate.get(candidate) ?? 0);
3605
- if (hit < cursor) {
3606
- selected = candidate;
3607
- break;
3608
- }
3609
- }
3610
-
3611
- const ordered = [
3612
- selected,
3613
- ...unblocked.filter(candidate => candidate !== selected),
3614
- ...candidates.filter(candidate => candidate.blocked),
3615
- ];
3616
- return ordered.map(candidate => ({
3973
+ ): UsageCandidate<T>[] {
3974
+ candidates.sort((left, right) => this.#compareUsageRankedCandidates(left, right, planRequirement));
3975
+ return candidates.map(candidate => ({
3617
3976
  selection: candidate.selection,
3618
3977
  usage: candidate.usage,
3619
3978
  usageChecked: candidate.usageChecked,
@@ -3627,7 +3986,6 @@ export class AuthStorage {
3627
3986
  planRequirement: OpenAICodexPlanRequirement;
3628
3987
  credentials: OAuthSelection[];
3629
3988
  options?: AuthApiKeyOptions;
3630
- sessionId?: string;
3631
3989
  strategy: CredentialRankingStrategy;
3632
3990
  rankingContext: CredentialRankingContext;
3633
3991
  blockScope?: string;
@@ -3726,17 +4084,17 @@ export class AuthStorage {
3726
4084
  hasPriorityBoost: strategy.hasPriorityBoost?.(primary) ?? false,
3727
4085
  planPriority: getOpenAICodexPlanPriority(usage, args.planRequirement),
3728
4086
  secondaryUsed: this.#normalizeUsageFraction(secondaryTarget),
3729
- secondaryDrainRate: this.#computeWindowDrainRate(
4087
+ secondaryRequiredDrain: this.#computeWindowRequiredDrain(
3730
4088
  secondaryTarget,
3731
4089
  nowMs,
3732
4090
  strategy.windowDefaults.secondaryMs,
3733
4091
  ),
3734
4092
  primaryUsed: this.#normalizeUsageFraction(primary),
3735
- primaryDrainRate: this.#computeWindowDrainRate(primary, nowMs, strategy.windowDefaults.primaryMs),
4093
+ primaryRequiredDrain: this.#computeWindowRequiredDrain(primary, nowMs, strategy.windowDefaults.primaryMs),
3736
4094
  orderPos,
3737
4095
  });
3738
4096
  }
3739
- return this.#orderRankedOAuthCandidates(ranked, args.sessionId, args.planRequirement);
4097
+ return this.#orderUsageRankedCandidates(ranked, args.planRequirement);
3740
4098
  }
3741
4099
 
3742
4100
  /**
@@ -3804,7 +4162,6 @@ export class AuthStorage {
3804
4162
  order: rankingOrder,
3805
4163
  credentials,
3806
4164
  options,
3807
- sessionId,
3808
4165
  strategy: strategy!,
3809
4166
  rankingContext,
3810
4167
  blockScope,
@@ -4174,6 +4531,8 @@ export class AuthStorage {
4174
4531
  projectId: result.newCredentials.projectId ?? selection.credential.projectId,
4175
4532
  enterpriseUrl: result.newCredentials.enterpriseUrl ?? selection.credential.enterpriseUrl,
4176
4533
  apiEndpoint: result.newCredentials.apiEndpoint ?? selection.credential.apiEndpoint,
4534
+ orgId: result.newCredentials.orgId ?? selection.credential.orgId,
4535
+ orgName: result.newCredentials.orgName ?? selection.credential.orgName,
4177
4536
  };
4178
4537
  if (credentialId !== undefined) {
4179
4538
  const idx = this.#replaceCredentialById(provider, credentialId, updated);
@@ -4208,8 +4567,9 @@ export class AuthStorage {
4208
4567
  }
4209
4568
  }
4210
4569
  }
4570
+ this.#recordOAuthBearerCredentialId(provider, result.apiKey, credentialId);
4211
4571
  this.#recordSessionCredential(provider, sessionId, "oauth", selection.index);
4212
- return { apiKey: result.apiKey, credential: updated };
4572
+ return { apiKey: result.apiKey, credential: updated, credentialId };
4213
4573
  } catch (error) {
4214
4574
  const errorMsg = String(error);
4215
4575
  // Only remove credentials for definitive auth failures
@@ -4371,12 +4731,11 @@ export class AuthStorage {
4371
4731
  if (oauthResolved) {
4372
4732
  return oauthResolved.apiKey;
4373
4733
  }
4374
-
4375
- const loginApiKeySelection = this.#selectCredentialByType(
4734
+ const loginApiKeySelection = await this.#selectApiKeyCredential(
4376
4735
  provider,
4377
- "api_key",
4378
4736
  sessionId,
4379
- credential => credential.type === "api_key" && credential.source === "login",
4737
+ options,
4738
+ credential => credential.source === "login",
4380
4739
  );
4381
4740
  if (loginApiKeySelection) {
4382
4741
  this.#recordSessionCredential(provider, sessionId, "api_key", loginApiKeySelection.index);
@@ -4390,12 +4749,11 @@ export class AuthStorage {
4390
4749
 
4391
4750
  const envKey = getEnvApiKey(provider);
4392
4751
  if (envKey) return envKey;
4393
-
4394
- const apiKeySelection = this.#selectCredentialByType(
4752
+ const apiKeySelection = await this.#selectApiKeyCredential(
4395
4753
  provider,
4396
- "api_key",
4397
4754
  sessionId,
4398
- credential => credential.type !== "api_key" || credential.source !== "login",
4755
+ options,
4756
+ credential => credential.source !== "login",
4399
4757
  );
4400
4758
  if (apiKeySelection) {
4401
4759
  this.#recordSessionCredential(provider, sessionId, "api_key", apiKeySelection.index);
@@ -4434,14 +4792,17 @@ export class AuthStorage {
4434
4792
  }
4435
4793
  const resolved = await this.#resolveOAuthSelection(provider, sessionId, options);
4436
4794
  if (!resolved) return undefined;
4437
- const { credential } = resolved;
4795
+ const { credential, credentialId } = resolved;
4438
4796
  return {
4439
4797
  accessToken: credential.access,
4798
+ credentialId,
4440
4799
  accountId: credential.accountId,
4441
4800
  email: credential.email,
4442
4801
  projectId: credential.projectId,
4443
4802
  enterpriseUrl: credential.enterpriseUrl,
4444
4803
  apiEndpoint: credential.apiEndpoint,
4804
+ orgId: credential.orgId,
4805
+ orgName: credential.orgName,
4445
4806
  };
4446
4807
  }
4447
4808
 
@@ -4476,6 +4837,8 @@ export class AuthStorage {
4476
4837
  email: selection.credential.email,
4477
4838
  projectId: selection.credential.projectId,
4478
4839
  enterpriseUrl: selection.credential.enterpriseUrl,
4840
+ orgId: selection.credential.orgId,
4841
+ orgName: selection.credential.orgName,
4479
4842
  error: "OAuth access unavailable",
4480
4843
  };
4481
4844
  }
@@ -4488,6 +4851,8 @@ export class AuthStorage {
4488
4851
  email: credential.email,
4489
4852
  projectId: credential.projectId,
4490
4853
  enterpriseUrl: credential.enterpriseUrl,
4854
+ orgId: credential.orgId,
4855
+ orgName: credential.orgName,
4491
4856
  };
4492
4857
  } catch (error) {
4493
4858
  return {
@@ -4497,6 +4862,8 @@ export class AuthStorage {
4497
4862
  email: selection.credential.email,
4498
4863
  projectId: selection.credential.projectId,
4499
4864
  enterpriseUrl: selection.credential.enterpriseUrl,
4865
+ orgId: selection.credential.orgId,
4866
+ orgName: selection.credential.orgName,
4500
4867
  error: error instanceof Error ? error.message : String(error),
4501
4868
  };
4502
4869
  }
@@ -4519,6 +4886,8 @@ export class AuthStorage {
4519
4886
  email: selection.credential.email,
4520
4887
  projectId: selection.credential.projectId,
4521
4888
  enterpriseUrl: selection.credential.enterpriseUrl,
4889
+ orgId: selection.credential.orgId,
4890
+ orgName: selection.credential.orgName,
4522
4891
  }));
4523
4892
  }
4524
4893
 
@@ -4922,41 +5291,50 @@ export class AuthStorage {
4922
5291
  }
4923
5292
 
4924
5293
  /**
4925
- * Rotate away from the session's current credential after a retryable auth
4926
- * error — step (c) of the auth-retry policy. Stateless: looks up the
4927
- * session-sticky credential (no API-key matching needed), applies the
4928
- * storage action for the error class, then clears the sticky so the next
4929
- * {@link AuthStorage.getApiKey} for this session picks a sibling.
5294
+ * Rotate away from the credential that failed after a retryable auth error —
5295
+ * step (c) of the auth-retry policy. Prefer the failed stored row id supplied
5296
+ * in `options.credentialId`, then the failed bearer supplied in
5297
+ * `options.apiKey`, so overlapping requests cannot redirect rotation through
5298
+ * stale session stickiness. Fall back to the session-sticky credential only
5299
+ * when neither explicit target is available. For hard-auth errors, an explicit
5300
+ * target that no longer matches storage returns `false` without mutation.
5301
+ * Delayed usage-limit errors may instead recover the durable OAuth row from
5302
+ * the bearer fingerprint recorded when the request resolved.
4930
5303
  *
4931
5304
  * - usage-limit / account-rate-limit error → {@link AuthStorage.markUsageLimitReached}
4932
5305
  * (temporary block via its own backoff — default plus server usage-report
4933
5306
  * reset; sticky left intact so the next resolve re-ranks around the block).
4934
5307
  * - otherwise (hard 401 / auth failure) → mark the credential suspect (or
4935
- * reload when no broker hook is wired) and block it, then drop the sticky.
5308
+ * reload when no broker hook is wired) and block it, then drop matching
5309
+ * sticky state.
4936
5310
  *
4937
5311
  * Returns whether another usable credential of the same type remains.
4938
5312
  */
4939
5313
  async rotateSessionCredential(
4940
5314
  provider: string,
4941
5315
  sessionId: string | undefined,
4942
- options?: { error?: unknown; modelId?: string; apiKey?: string; signal?: AbortSignal },
5316
+ options?: { error?: unknown; modelId?: string; apiKey?: string; credentialId?: number; signal?: AbortSignal },
4943
5317
  ): Promise<boolean> {
4944
- const sessionCredential = this.#getSessionCredential(provider, sessionId);
4945
- if (!sessionCredential) return false;
4946
-
4947
5318
  const error = options?.error;
4948
5319
  const status = AIError.status(error);
4949
5320
  const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
4950
- if (isUsageLimitOutcome(status, message)) {
5321
+ if (AIError.isUsageLimit(error) || isUsageLimitOutcome(status, message)) {
4951
5322
  return (
4952
5323
  await this.markUsageLimitReached(provider, sessionId, {
4953
5324
  modelId: options?.modelId,
4954
5325
  apiKey: options?.apiKey,
5326
+ credentialId: options?.credentialId,
4955
5327
  signal: options?.signal,
4956
5328
  })
4957
5329
  ).switched;
4958
5330
  }
4959
5331
 
5332
+ const sessionCredential = await this.#resolveCredentialTarget(provider, sessionId, {
5333
+ credentialId: options?.credentialId,
5334
+ apiKey: options?.apiKey,
5335
+ });
5336
+ if (!sessionCredential) return false;
5337
+
4960
5338
  const providerKey = this.#getProviderTypeKey(provider, sessionCredential.type);
4961
5339
  // Snapshot sibling availability before mutating so a soft-deleting
4962
5340
  // suspect hook can't reindex the answer out from under us.
@@ -4967,7 +5345,13 @@ export class AuthStorage {
4967
5345
  !this.#isCredentialBlocked(provider, providerKey, index),
4968
5346
  );
4969
5347
  const target = this.#getStoredCredentials(provider)[sessionCredential.index];
4970
- this.#clearSessionCredential(provider, sessionId);
5348
+ const sticky = this.#getSessionCredential(provider, sessionId);
5349
+ if (
5350
+ !sessionCredential.explicit ||
5351
+ (sticky?.type === sessionCredential.type && sticky.index === sessionCredential.index)
5352
+ ) {
5353
+ this.#clearSessionCredential(provider, sessionId);
5354
+ }
4971
5355
  this.#markCredentialBlocked(
4972
5356
  provider,
4973
5357
  providerKey,
@@ -4998,19 +5382,32 @@ export class AuthStorage {
4998
5382
  *
4999
5383
  * - initial (`error: undefined`) → resolve the session credential.
5000
5384
  * - step (b) `!lastChance` → force-refresh the SAME session-sticky credential.
5001
- * - step (c) `lastChance` → rotate to a sibling credential, then re-resolve.
5385
+ * - step (c) `lastChance` → rotate to a sibling and re-resolve, unless quota exhaustion has no sibling.
5002
5386
  *
5003
5387
  * Used by web-search providers and other consumers that hold an AuthStorage
5004
5388
  * directly (no ModelRegistry in scope).
5005
5389
  */
5006
5390
  resolver(provider: string, options?: { sessionId?: string; baseUrl?: string; modelId?: string }): ApiKeyResolver {
5007
5391
  const { sessionId, baseUrl, modelId } = options ?? {};
5008
- return async ({ lastChance, error, signal }) => {
5392
+ return async ({ lastChance, error, signal, previousKey }) => {
5009
5393
  if (error === undefined) {
5010
5394
  return this.getApiKey(provider, sessionId, { baseUrl, modelId, signal });
5011
5395
  }
5012
5396
  if (lastChance) {
5013
- await this.rotateSessionCredential(provider, sessionId, { error, modelId, signal });
5397
+ const switched = await this.rotateSessionCredential(provider, sessionId, {
5398
+ error,
5399
+ modelId,
5400
+ signal,
5401
+ apiKey: previousKey,
5402
+ });
5403
+ if (!switched) {
5404
+ const status = AIError.status(error);
5405
+ const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
5406
+ // Preserve no-sibling quota backoff instead of re-resolving an
5407
+ // already-blocked fallback. Hard-auth declines still re-resolve
5408
+ // because a peer may have refreshed the failed bearer.
5409
+ if (AIError.isUsageLimit(error) || isUsageLimitOutcome(status, message)) return undefined;
5410
+ }
5014
5411
  return this.getApiKey(provider, sessionId, { baseUrl, modelId, signal });
5015
5412
  }
5016
5413
  return this.getApiKey(provider, sessionId, { baseUrl, modelId, forceRefresh: true, signal });
@@ -5133,6 +5530,8 @@ export class AuthStorage {
5133
5530
  projectId: refreshed.projectId ?? attempted.projectId,
5134
5531
  enterpriseUrl: refreshed.enterpriseUrl ?? attempted.enterpriseUrl,
5135
5532
  apiEndpoint: refreshed.apiEndpoint ?? attempted.apiEndpoint,
5533
+ orgId: refreshed.orgId ?? attempted.orgId,
5534
+ orgName: refreshed.orgName ?? attempted.orgName,
5136
5535
  };
5137
5536
  // Persist by id: the array may have been reordered/shrunk while the
5138
5537
  // refresh was in flight, so the pre-await positional index is unsafe. A
@@ -5400,7 +5799,26 @@ function toStoredAuthCredential(row: AuthRow, credential: AuthCredential): Store
5400
5799
 
5401
5800
  function resolveProviderCredentialIdentityKey(provider: string, identifiers: string[]): string | null {
5402
5801
  const emailIdentifier = identifiers.find(identifier => identifier.startsWith("email:"));
5403
- if ((provider === "openai-codex" || provider === "anthropic") && emailIdentifier) return emailIdentifier;
5802
+ if (provider === "anthropic") {
5803
+ // One Anthropic account email can hold several organizations (e.g. a
5804
+ // Team seat plus a personal Max plan), each with its own org-scoped
5805
+ // token and limit pools. Scope identity by org so both subscriptions
5806
+ // can be stored side by side. The qualifier rides on whichever base
5807
+ // identity is available — the account UUID is IDENTICAL across the
5808
+ // orgs of one login account, so an unqualified account/project
5809
+ // fallback would still collapse two subscriptions whenever the email
5810
+ // could not be recovered. Org-less credentials (rows written before
5811
+ // org capture existed) keep their bare key.
5812
+ const base =
5813
+ emailIdentifier ??
5814
+ identifiers.find(identifier => identifier.startsWith("account:")) ??
5815
+ identifiers.find(identifier => identifier.startsWith("project:"));
5816
+ const orgIdentifier = identifiers.find(identifier => identifier.startsWith("org:"));
5817
+ if (base) return orgIdentifier ? `${base}|${orgIdentifier}` : base;
5818
+ // No base identity at all: the org alone still distinguishes the row.
5819
+ return orgIdentifier ?? null;
5820
+ }
5821
+ if (provider === "openai-codex" && emailIdentifier) return emailIdentifier;
5404
5822
  const accountIdentifier = identifiers.find(identifier => identifier.startsWith("account:"));
5405
5823
  if (accountIdentifier) return accountIdentifier;
5406
5824
  if (emailIdentifier) return emailIdentifier;
@@ -5431,8 +5849,47 @@ function matchesReplacementCredential(
5431
5849
  if (incoming.type === "api_key") {
5432
5850
  return existing.type === "api_key" && existing.key === incoming.key;
5433
5851
  }
5434
- const incomingIdentityKey = resolveCredentialIdentityKey(provider, incoming);
5435
- return incomingIdentityKey !== null && incomingIdentityKey === existingIdentityKey;
5852
+ const incomingIdentifiers = extractOAuthCredentialIdentifiers(incoming);
5853
+ const incomingIdentityKey = resolveProviderCredentialIdentityKey(provider, incomingIdentifiers);
5854
+ if (incomingIdentityKey === null) return false;
5855
+ if (incomingIdentityKey === existingIdentityKey) return true;
5856
+ if (existingIdentityKey === null) return false;
5857
+ // One-way upgrade, applied only when the INCOMING identity key carries the
5858
+ // org qualifier (only anthropic keys do, so other providers never reach the
5859
+ // checks below). An org-scoped login `org:<o>` claims (and re-keys) any
5860
+ // existing row that denotes the same subscription:
5861
+ // - `org:<o>` — org-only row stored when identity recovery failed, claimed
5862
+ // once a later same-org login recovers a base identity;
5863
+ // - `<b>` for any base identity `<b>` (email/account/project) the incoming
5864
+ // credential carries — a pre-org legacy row, mirroring the pre-org
5865
+ // replace behavior;
5866
+ // - `<b>|org:<o>` for any such base — the same subscription keyed by a
5867
+ // different base, e.g. an account-keyed row stored while the email could
5868
+ // not be recovered, claimed once a later login recovers the email;
5869
+ // - any same-org row whose STORED credential shares a base identity with
5870
+ // the incoming one — a stored credential can retain identifiers its key
5871
+ // does not use (an email-keyed row also carries the account UUID), so a
5872
+ // later login that loses the email but keeps the account still updates
5873
+ // its row instead of duplicating the subscription.
5874
+ // The reverse stays a non-match: an org-less credential only ever replaces
5875
+ // via exact key equality above and must never clobber an org-scoped row.
5876
+ const orgIdentifier = incomingIdentifiers.find(identifier => identifier.startsWith("org:"));
5877
+ if (orgIdentifier === undefined) return false;
5878
+ if (incomingIdentityKey !== orgIdentifier && !incomingIdentityKey.endsWith(`|${orgIdentifier}`)) return false;
5879
+ if (existingIdentityKey === orgIdentifier) return true;
5880
+ const existingIdentifiers =
5881
+ existing.type === "oauth" && existingIdentityKey.endsWith(`|${orgIdentifier}`)
5882
+ ? extractOAuthCredentialIdentifiers(existing)
5883
+ : null;
5884
+ for (const identifier of incomingIdentifiers) {
5885
+ const isBase =
5886
+ identifier.startsWith("email:") || identifier.startsWith("account:") || identifier.startsWith("project:");
5887
+ if (!isBase) continue;
5888
+ if (existingIdentityKey === identifier) return true;
5889
+ if (existingIdentityKey === `${identifier}|${orgIdentifier}`) return true;
5890
+ if (existingIdentifiers?.includes(identifier)) return true;
5891
+ }
5892
+ return false;
5436
5893
  }
5437
5894
 
5438
5895
  function extractOAuthCredentialIdentifiers(credential: OAuthCredential): string[] {
@@ -5443,6 +5900,8 @@ function extractOAuthCredentialIdentifiers(credential: OAuthCredential): string[
5443
5900
  if (email) identifiers.add(`email:${email}`);
5444
5901
  const projectId = normalizeStoredAccountId(credential.projectId);
5445
5902
  if (projectId) identifiers.add(`project:${projectId}`);
5903
+ const orgId = normalizeStoredAccountId(credential.orgId);
5904
+ if (orgId) identifiers.add(`org:${orgId}`);
5446
5905
  const accessIdentifiers = extractOAuthTokenIdentifiers(credential.access) ?? [];
5447
5906
  for (const identifier of accessIdentifiers) {
5448
5907
  identifiers.add(identifier);
@@ -5519,6 +5978,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
5519
5978
  #getCacheStmt: Statement;
5520
5979
  #getCacheIncludingExpiredStmt: Statement;
5521
5980
  #upsertCacheStmt: Statement;
5981
+ #deleteCachePrefixStmt: Statement;
5522
5982
  #deleteExpiredCacheStmt: Statement;
5523
5983
  #updateIfMatchesWithLeaseStmt: Statement;
5524
5984
  #deleteIfMatchesWithLeaseStmt: Statement;
@@ -5597,6 +6057,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
5597
6057
  this.#upsertCacheStmt = this.#db.prepare(
5598
6058
  "INSERT INTO cache (key, value, expires_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at",
5599
6059
  );
6060
+ this.#deleteCachePrefixStmt = this.#db.prepare("DELETE FROM cache WHERE substr(key, 1, ?) = ?");
5600
6061
  this.#deleteExpiredCacheStmt = this.#db.prepare(`DELETE FROM cache WHERE expires_at <= ${SQLITE_NOW_EPOCH}`);
5601
6062
  this.#getCredentialBlockStmt = this.#db.prepare(
5602
6063
  "SELECT blocked_until_ms, updated_at FROM auth_credential_blocks WHERE credential_id = ? AND provider_key = ? AND block_scope = ? AND blocked_until_ms > ?",
@@ -6301,6 +6762,15 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
6301
6762
  }
6302
6763
  }
6303
6764
 
6765
+ /** Drop all cache rows whose keys start with the supplied prefix. */
6766
+ deleteCachePrefix(prefix: string): void {
6767
+ try {
6768
+ this.#deleteCachePrefixStmt.run(prefix.length, prefix);
6769
+ } catch {
6770
+ // Ignore cache delete failures
6771
+ }
6772
+ }
6773
+
6304
6774
  cleanExpiredCache(): void {
6305
6775
  try {
6306
6776
  this.#deleteExpiredCacheStmt.run();