@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.
- package/CHANGELOG.md +37 -0
- package/dist/types/auth-broker/remote-store.d.ts +2 -0
- package/dist/types/auth-broker/wire-schemas.d.ts +28 -0
- package/dist/types/auth-retry.d.ts +44 -20
- package/dist/types/auth-storage.d.ts +51 -10
- package/dist/types/error/flags.d.ts +1 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/providers/openai-chat-server-schema.d.ts +3 -3
- package/dist/types/providers/openai-shared.d.ts +2 -0
- package/dist/types/registry/oauth/types.d.ts +8 -0
- package/dist/types/usage/cursor.d.ts +3 -0
- package/dist/types/usage/openai-codex-reset.d.ts +1 -1
- package/dist/types/usage/openai-codex.d.ts +8 -1
- package/dist/types/usage/zai.d.ts +2 -1
- package/dist/types/usage.d.ts +4 -0
- package/dist/types/utils/idle-iterator.d.ts +6 -6
- package/package.json +4 -4
- package/src/auth-broker/discover.ts +22 -3
- package/src/auth-broker/remote-store.ts +103 -10
- package/src/auth-broker/wire-schemas.ts +4 -0
- package/src/auth-gateway/server.ts +3 -1
- package/src/auth-retry.ts +191 -53
- package/src/auth-storage.ts +668 -198
- package/src/error/auth-classify.ts +2 -1
- package/src/error/flags.ts +10 -0
- package/src/error/provider.ts +2 -2
- package/src/index.ts +1 -0
- package/src/providers/cursor.ts +10 -1
- package/src/providers/openai-chat-server-schema.ts +2 -1
- package/src/providers/openai-codex-responses.ts +74 -36
- package/src/providers/openai-shared.ts +6 -3
- package/src/registry/oauth/anthropic.ts +57 -24
- package/src/registry/oauth/oauth.html +1 -1
- package/src/registry/oauth/types.ts +8 -0
- package/src/stream.ts +19 -30
- package/src/usage/cursor.ts +192 -0
- package/src/usage/openai-codex-reset.ts +6 -2
- package/src/usage/openai-codex.ts +33 -0
- package/src/usage/zai.ts +49 -6
- package/src/usage.ts +4 -0
- package/src/utils/idle-iterator.ts +6 -6
package/src/auth-storage.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
1042
|
-
selection:
|
|
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
|
|
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
|
-
|
|
1111
|
+
secondaryRequiredDrain: number;
|
|
1054
1112
|
primaryUsed: number;
|
|
1055
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 (
|
|
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<
|
|
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:
|
|
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
|
|
2402
|
-
// Including the token hash when accountId/email are present
|
|
2403
|
-
// every OAuth refresh — usage data is per-account
|
|
2404
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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 === "
|
|
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:
|
|
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
|
|
3102
|
-
// IP, and falling back to the local per-credential fetch
|
|
3103
|
-
//
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
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
|
-
|
|
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.#
|
|
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?: {
|
|
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
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
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,
|
|
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 (
|
|
3438
|
-
const
|
|
3439
|
-
if (
|
|
3440
|
-
const
|
|
3441
|
-
if (
|
|
3442
|
-
const
|
|
3443
|
-
if (
|
|
3444
|
-
|
|
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
|
-
|
|
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 ===
|
|
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
|
-
/**
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
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 (
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
3517
|
-
left:
|
|
3518
|
-
right:
|
|
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
|
-
|
|
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(
|
|
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
|
-
#
|
|
3544
|
-
left:
|
|
3545
|
-
right:
|
|
3961
|
+
#compareUsageRankedCandidates(
|
|
3962
|
+
left: UsageRankedCandidate<AuthCredential>,
|
|
3963
|
+
right: UsageRankedCandidate<AuthCredential>,
|
|
3546
3964
|
planRequirement: OpenAICodexPlanRequirement,
|
|
3547
3965
|
): number {
|
|
3548
|
-
const priority = this.#
|
|
3966
|
+
const priority = this.#compareUsageRankedCandidatePriority(left, right, planRequirement);
|
|
3549
3967
|
return priority !== 0 ? priority : left.orderPos - right.orderPos;
|
|
3550
3968
|
}
|
|
3551
3969
|
|
|
3552
|
-
#
|
|
3553
|
-
candidates:
|
|
3554
|
-
sessionId: string | undefined,
|
|
3970
|
+
#orderUsageRankedCandidates<T extends AuthCredential>(
|
|
3971
|
+
candidates: UsageRankedCandidate<T>[],
|
|
3555
3972
|
planRequirement: OpenAICodexPlanRequirement,
|
|
3556
|
-
):
|
|
3557
|
-
candidates.sort((left, right) => this.#
|
|
3558
|
-
|
|
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
|
-
|
|
4087
|
+
secondaryRequiredDrain: this.#computeWindowRequiredDrain(
|
|
3730
4088
|
secondaryTarget,
|
|
3731
4089
|
nowMs,
|
|
3732
4090
|
strategy.windowDefaults.secondaryMs,
|
|
3733
4091
|
),
|
|
3734
4092
|
primaryUsed: this.#normalizeUsageFraction(primary),
|
|
3735
|
-
|
|
4093
|
+
primaryRequiredDrain: this.#computeWindowRequiredDrain(primary, nowMs, strategy.windowDefaults.primaryMs),
|
|
3736
4094
|
orderPos,
|
|
3737
4095
|
});
|
|
3738
4096
|
}
|
|
3739
|
-
return this.#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
4926
|
-
*
|
|
4927
|
-
*
|
|
4928
|
-
*
|
|
4929
|
-
*
|
|
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
|
|
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.#
|
|
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
|
|
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, {
|
|
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 (
|
|
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
|
|
5435
|
-
|
|
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();
|