@cortexkit/anthropic-auth-core 1.19.1 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,40 @@
1
+ import { type AccountStorage, type OAuthQuotaSnapshot } from './accounts.ts';
2
+ export declare const QUOTA_HEADER_FEED_SCHEMA_VERSION = 2;
3
+ export declare const QUOTA_HEADER_FEED_LEASE_MS = 180000;
4
+ export type QuotaHeaderFeedIdentity = {
5
+ identity_source: 'credential_id';
6
+ credential_id: string;
7
+ } | {
8
+ identity_source: 'account_ref';
9
+ account_ref: string;
10
+ } | {
11
+ identity_source: 'none';
12
+ };
13
+ export type QuotaHeaderFeedEntry = QuotaHeaderFeedIdentity & {
14
+ schema_version: typeof QUOTA_HEADER_FEED_SCHEMA_VERSION;
15
+ provider: 'anthropic';
16
+ configured_account_count: number;
17
+ observed_at_ms: number;
18
+ quota: Pick<OAuthQuotaSnapshot, 'five_hour' | 'seven_day' | 'bindingWindow' | 'fallbackAdvised' | 'scoped' | 'extraUsage'>;
19
+ };
20
+ export declare function getDefaultQuotaHeaderFeedDirectory(): string;
21
+ export declare function configuredAnthropicOAuthAccountCount(input: {
22
+ storage: AccountStorage | null;
23
+ mainOAuthConfigured: boolean;
24
+ }): number;
25
+ export declare class QuotaHeaderFeedRegistry {
26
+ private readonly options;
27
+ private readonly filePath;
28
+ private writeChain;
29
+ constructor(options?: {
30
+ directory?: string;
31
+ now?: () => number;
32
+ leaseMs?: number;
33
+ instanceId?: string;
34
+ });
35
+ publish(entry: QuotaHeaderFeedEntry & {
36
+ accountKey: string;
37
+ }): Promise<void>;
38
+ list(): Promise<QuotaHeaderFeedEntry[]>;
39
+ dispose(): Promise<void>;
40
+ }
@@ -0,0 +1,248 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { chmod, mkdir, readdir, readFile, rename, rm, writeFile, } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { isOAuthAccount, } from './accounts.js';
6
+ export const QUOTA_HEADER_FEED_SCHEMA_VERSION = 2;
7
+ export const QUOTA_HEADER_FEED_LEASE_MS = 180_000;
8
+ export function getDefaultQuotaHeaderFeedDirectory() {
9
+ return (process.env.OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR ??
10
+ join(tmpdir(), 'opencode-anthropic-auth', 'quota-header-feed'));
11
+ }
12
+ export function configuredAnthropicOAuthAccountCount(input) {
13
+ const fallbackCount = input.storage?.accounts.filter(isOAuthAccount).length ?? 0;
14
+ return fallbackCount + (input.mainOAuthConfigured ? 1 : 0);
15
+ }
16
+ function validIdentity(entry) {
17
+ if (entry.schema_version !== QUOTA_HEADER_FEED_SCHEMA_VERSION ||
18
+ entry.provider !== 'anthropic')
19
+ return false;
20
+ if (!Number.isFinite(entry.configured_account_count) ||
21
+ !Number.isFinite(entry.observed_at_ms))
22
+ return false;
23
+ if (!entry.quota || typeof entry.quota !== 'object')
24
+ return false;
25
+ if (entry.identity_source === 'none') {
26
+ return !('credential_id' in entry) && !('account_ref' in entry);
27
+ }
28
+ if (entry.identity_source === 'credential_id') {
29
+ return (typeof entry.credential_id === 'string' &&
30
+ entry.credential_id.length > 0 &&
31
+ entry.credential_id !== 'main' &&
32
+ !('account_ref' in entry));
33
+ }
34
+ if (entry.identity_source === 'account_ref') {
35
+ return (typeof entry.account_ref === 'string' &&
36
+ entry.account_ref.length > 0 &&
37
+ entry.account_ref !== 'main' &&
38
+ !('credential_id' in entry));
39
+ }
40
+ return false;
41
+ }
42
+ function validatePublishEntry(entry) {
43
+ if (entry.schema_version !== QUOTA_HEADER_FEED_SCHEMA_VERSION ||
44
+ entry.provider !== 'anthropic' ||
45
+ !validIdentity(entry)) {
46
+ throw new Error('Invalid quota header feed entry');
47
+ }
48
+ }
49
+ function finiteNumber(value) {
50
+ return typeof value === 'number' && Number.isFinite(value);
51
+ }
52
+ function projectQuotaWindow(value) {
53
+ if (!value || typeof value !== 'object')
54
+ return undefined;
55
+ const candidate = value;
56
+ if (!finiteNumber(candidate.usedPercent) ||
57
+ !finiteNumber(candidate.remainingPercent) ||
58
+ !finiteNumber(candidate.checkedAt)) {
59
+ return undefined;
60
+ }
61
+ return {
62
+ usedPercent: candidate.usedPercent,
63
+ remainingPercent: candidate.remainingPercent,
64
+ ...(typeof candidate.resetsAt === 'string' && {
65
+ resetsAt: candidate.resetsAt,
66
+ }),
67
+ checkedAt: candidate.checkedAt,
68
+ };
69
+ }
70
+ function projectScopedQuotaWindow(value) {
71
+ if (!value || typeof value !== 'object')
72
+ return undefined;
73
+ const candidate = value;
74
+ const window = projectQuotaWindow(candidate);
75
+ if (!window ||
76
+ typeof candidate.id !== 'string' ||
77
+ !candidate.id.trim() ||
78
+ typeof candidate.title !== 'string' ||
79
+ !candidate.title.trim() ||
80
+ typeof candidate.modelName !== 'string' ||
81
+ !candidate.modelName.trim()) {
82
+ return undefined;
83
+ }
84
+ return {
85
+ ...window,
86
+ id: candidate.id,
87
+ title: candidate.title,
88
+ ...(typeof candidate.modelId === 'string' &&
89
+ candidate.modelId.trim() && {
90
+ modelId: candidate.modelId,
91
+ }),
92
+ modelName: candidate.modelName,
93
+ };
94
+ }
95
+ function projectQuotaMoney(value) {
96
+ if (!value || typeof value !== 'object')
97
+ return undefined;
98
+ const candidate = value;
99
+ if (!finiteNumber(candidate.amountMinor) ||
100
+ typeof candidate.currency !== 'string' ||
101
+ !candidate.currency.trim() ||
102
+ !finiteNumber(candidate.exponent)) {
103
+ return undefined;
104
+ }
105
+ return {
106
+ amountMinor: candidate.amountMinor,
107
+ currency: candidate.currency,
108
+ exponent: candidate.exponent,
109
+ };
110
+ }
111
+ function projectExtraUsage(value) {
112
+ if (!value || typeof value !== 'object')
113
+ return undefined;
114
+ const candidate = value;
115
+ const used = projectQuotaMoney(candidate.used);
116
+ const limit = projectQuotaMoney(candidate.limit);
117
+ if (!used || !limit || typeof candidate.exhausted !== 'boolean') {
118
+ return undefined;
119
+ }
120
+ return {
121
+ used,
122
+ limit,
123
+ ...(finiteNumber(candidate.utilizationPercent) && {
124
+ utilizationPercent: candidate.utilizationPercent,
125
+ }),
126
+ ...(typeof candidate.severity === 'string' && {
127
+ severity: candidate.severity,
128
+ }),
129
+ exhausted: candidate.exhausted,
130
+ };
131
+ }
132
+ function projectQuota(quota) {
133
+ const fiveHour = projectQuotaWindow(quota.five_hour);
134
+ const sevenDay = projectQuotaWindow(quota.seven_day);
135
+ const scoped = Array.isArray(quota.scoped)
136
+ ? quota.scoped
137
+ .map(projectScopedQuotaWindow)
138
+ .filter((entry) => entry != null)
139
+ : undefined;
140
+ const extraUsage = projectExtraUsage(quota.extraUsage);
141
+ return {
142
+ ...(fiveHour && { five_hour: fiveHour }),
143
+ ...(sevenDay && { seven_day: sevenDay }),
144
+ ...(typeof quota.bindingWindow === 'string' && {
145
+ bindingWindow: quota.bindingWindow,
146
+ }),
147
+ ...(typeof quota.fallbackAdvised === 'boolean' && {
148
+ fallbackAdvised: quota.fallbackAdvised,
149
+ }),
150
+ ...(scoped && { scoped }),
151
+ ...(extraUsage && { extraUsage }),
152
+ };
153
+ }
154
+ export class QuotaHeaderFeedRegistry {
155
+ options;
156
+ filePath;
157
+ writeChain = Promise.resolve();
158
+ constructor(options = {}) {
159
+ this.options = options;
160
+ const instanceId = options.instanceId ?? `${process.pid}-${randomUUID()}`;
161
+ this.filePath = join(options.directory ?? getDefaultQuotaHeaderFeedDirectory(), `${instanceId}.json`);
162
+ }
163
+ publish(entry) {
164
+ const { accountKey, quota, ...entryWithoutQuota } = entry;
165
+ const cleanEntry = {
166
+ ...entryWithoutQuota,
167
+ quota: projectQuota(quota),
168
+ };
169
+ try {
170
+ validatePublishEntry(cleanEntry);
171
+ }
172
+ catch (error) {
173
+ return Promise.reject(error);
174
+ }
175
+ if (!accountKey)
176
+ return Promise.reject(new Error('Invalid quota header feed account key'));
177
+ this.writeChain = this.writeChain
178
+ .catch(() => { })
179
+ .then(async () => {
180
+ const directory = this.options.directory ?? getDefaultQuotaHeaderFeedDirectory();
181
+ await mkdir(directory, { recursive: true, mode: 0o700 });
182
+ await chmod(directory, 0o700);
183
+ let entries = {};
184
+ try {
185
+ const record = JSON.parse(await readFile(this.filePath, 'utf8'));
186
+ if (record.version === QUOTA_HEADER_FEED_SCHEMA_VERSION &&
187
+ record.entries &&
188
+ typeof record.entries === 'object')
189
+ entries = record.entries;
190
+ }
191
+ catch { }
192
+ entries[accountKey] = cleanEntry;
193
+ const tempPath = `${this.filePath}.${randomUUID()}.tmp`;
194
+ try {
195
+ await writeFile(tempPath, `${JSON.stringify({ version: QUOTA_HEADER_FEED_SCHEMA_VERSION, entries })}\n`, { mode: 0o600 });
196
+ await chmod(tempPath, 0o600);
197
+ await rename(tempPath, this.filePath);
198
+ }
199
+ finally {
200
+ await rm(tempPath, { force: true });
201
+ }
202
+ });
203
+ return this.writeChain;
204
+ }
205
+ async list() {
206
+ await this.writeChain.catch(() => { });
207
+ const directory = this.options.directory ?? getDefaultQuotaHeaderFeedDirectory();
208
+ const now = this.options.now?.() ?? Date.now();
209
+ const leaseMs = this.options.leaseMs ?? QUOTA_HEADER_FEED_LEASE_MS;
210
+ let names;
211
+ try {
212
+ names = (await readdir(directory)).filter((name) => name.endsWith('.json'));
213
+ }
214
+ catch {
215
+ return [];
216
+ }
217
+ const newest = new Map();
218
+ await Promise.all(names.map(async (name) => {
219
+ try {
220
+ const record = JSON.parse(await readFile(join(directory, name), 'utf8'));
221
+ if (record.version !== QUOTA_HEADER_FEED_SCHEMA_VERSION ||
222
+ !record.entries ||
223
+ typeof record.entries !== 'object')
224
+ return;
225
+ for (const [accountKey, candidate] of Object.entries(record.entries)) {
226
+ if (!candidate ||
227
+ typeof candidate !== 'object' ||
228
+ candidate.schema_version !== QUOTA_HEADER_FEED_SCHEMA_VERSION ||
229
+ candidate.provider !== 'anthropic' ||
230
+ !validIdentity(candidate))
231
+ continue;
232
+ const observed = candidate.observed_at_ms;
233
+ if (observed > now || now - observed >= leaseMs)
234
+ continue;
235
+ const existing = newest.get(accountKey);
236
+ if (!existing || observed > existing.observed_at_ms)
237
+ newest.set(accountKey, candidate);
238
+ }
239
+ }
240
+ catch { }
241
+ }));
242
+ return [...newest.values()].sort((a, b) => a.observed_at_ms - b.observed_at_ms);
243
+ }
244
+ async dispose() {
245
+ await this.writeChain.catch(() => { });
246
+ await rm(this.filePath, { force: true });
247
+ }
248
+ }
@@ -49,11 +49,13 @@ export function normalizeQuotaHeaders(headers, now = Date.now()) {
49
49
  return snapshot;
50
50
  }
51
51
  export function mergeHeaderQuotaSnapshot(existing, incoming) {
52
+ const { scoped: existingScoped, extraUsage: existingExtraUsage, ...existingWithoutPollOwnedFields } = existing ?? {};
53
+ const { scoped: _incomingScoped, extraUsage: _incomingExtraUsage, ...incomingWithoutPollOwnedFields } = incoming;
52
54
  return {
53
- ...existing,
54
- ...incoming,
55
- scoped: existing && 'scoped' in existing ? existing.scoped : undefined,
56
- extraUsage: existing?.extraUsage,
55
+ ...existingWithoutPollOwnedFields,
56
+ ...incomingWithoutPollOwnedFields,
57
+ ...(Array.isArray(existingScoped) && { scoped: existingScoped }),
58
+ ...(existingExtraUsage != null && { extraUsage: existingExtraUsage }),
57
59
  bindingWindow: existing?.bindingWindowSource === 'poll'
58
60
  ? existing.bindingWindow
59
61
  : (incoming.bindingWindow ?? existing?.bindingWindow),
@@ -12,24 +12,34 @@ export type QuotaEntry = {
12
12
  refreshAfter: number;
13
13
  checkedAt: number;
14
14
  };
15
+ export type QuotaRefreshResult = {
16
+ quota: OAuthQuotaSnapshot;
17
+ /** False when backoff or a cross-process lock served the existing cache. */
18
+ fetched: boolean;
19
+ };
15
20
  export type QuotaManagerOptions = {
16
21
  storage: AccountStorage | null;
17
22
  fetchImpl?: typeof fetch;
18
23
  now?: () => number;
19
- onMainQuotaFetched?: (quota: OAuthQuotaSnapshot, checkedAt: number, tokenFingerprint: string, fetchStartedAt: number) => void;
20
- onApiError?: (error: AccountOperationError) => void;
24
+ onMainQuotaFetched?: (quota: OAuthQuotaSnapshot, checkedAt: number, tokenFingerprint: string, fetchStartedAt: number, quotaErrorGeneration: number, quotaErrorClearedAt: number | undefined) => void;
25
+ onApiError?: (error: AccountOperationError, quotaErrorGeneration: number) => void;
21
26
  };
22
27
  export declare class QuotaManager {
23
28
  private main;
24
- private mainTokenFp;
29
+ private mainAccountId;
30
+ private mainQuotaIdentityKnown;
31
+ private mainQuotaIdentityStrict;
32
+ private mainGeneration;
33
+ private mainQuotaErrorGeneration;
34
+ private mainQuotaErrorClearedAt;
25
35
  private fallbacks;
26
- private fallbackTokenFps;
36
+ private fallbackAuthLineages;
37
+ private fallbackGenerations;
27
38
  private inflightMain;
28
- private inflightMainFp;
39
+ private inflightMainAccountId;
29
40
  private inflightFallbacks;
30
41
  private mainLastApiError;
31
42
  private fallbackApiErrors;
32
- private fallbackErrorTokenFps;
33
43
  private apiGate;
34
44
  private lastApiCallAt;
35
45
  private storage;
@@ -39,26 +49,26 @@ export declare class QuotaManager {
39
49
  private readonly onApiError;
40
50
  constructor(opts: QuotaManagerOptions);
41
51
  /**
42
- * Cached main quota entry. Pass the live access token to enforce token
43
- * binding: if the cached entry was produced by a different token (main
44
- * account switched), it is dropped and null is returned so the caller
45
- * refetches for the current account. Called without a token (e.g. for
46
- * display) it returns whatever is cached.
52
+ * Cached main quota entry, scoped to the account behind the main credential.
53
+ * An unresolved identity keeps a retained cache unverified rather than exposing it.
47
54
  */
48
- getMain(accessToken?: string): QuotaEntry | null;
55
+ getMain(mainAccountId?: string): QuotaEntry | null;
49
56
  /**
50
- * Cached fallback quota entry. Pass the live access token to enforce token
51
- * binding: if the entry was produced by a different token (account re-login),
52
- * it is dropped and null is returned so the caller refetches.
57
+ * Cached fallback quota entry, scoped to the configured account id.
53
58
  */
54
- getFallback(accountId: string, accessToken?: string): QuotaEntry | null;
59
+ getFallback(accountId: string, account?: Pick<OAuthAccount, 'authLineageId'>): QuotaEntry | null;
55
60
  getAllFallbacks(): Map<string, QuotaEntry>;
56
- setMain(accessToken: string, entry: QuotaEntry): void;
57
- setFallback(accountId: string, entry: QuotaEntry, accessToken?: string): void;
58
- pushMainFromHeaders(accessToken: string, incoming: OAuthQuotaSnapshot): QuotaEntry;
59
- pushFallbackFromHeaders(accountId: string, accessToken: string, incoming: OAuthQuotaSnapshot): QuotaEntry;
60
- refreshMain(accessToken: string): Promise<OAuthQuotaSnapshot>;
61
- refreshFallback(accountId: string, accessToken: string): Promise<OAuthQuotaSnapshot>;
61
+ setMain(mainAccountId: string | undefined, entry: QuotaEntry): void;
62
+ /** Bind quota state to the account behind the current main credential. */
63
+ setMainQuotaAccountIdentity(accountIdentity: string | undefined, strict?: boolean): number | undefined;
64
+ setFallback(accountId: string, entry: QuotaEntry, account: Pick<OAuthAccount, 'authLineageId'> | undefined): void;
65
+ clearFallback(accountId: string): void;
66
+ pushMainFromHeaders(mainAccountId: string | undefined, incoming: OAuthQuotaSnapshot): QuotaEntry;
67
+ pushFallbackFromHeaders(accountId: string, incoming: OAuthQuotaSnapshot, account: Pick<OAuthAccount, 'authLineageId'> | undefined): QuotaEntry | null;
68
+ refreshMain(mainAccountIdOrAccessToken: string | undefined, accessToken?: string, expectedIdentityGeneration?: number): Promise<OAuthQuotaSnapshot>;
69
+ refreshMainWithMetadata(mainAccountIdOrAccessToken: string | undefined, accessToken?: string, expectedIdentityGeneration?: number): Promise<QuotaRefreshResult>;
70
+ refreshFallback(accountId: string, accessToken: string, account: Pick<OAuthAccount, 'authLineageId'> | undefined): Promise<OAuthQuotaSnapshot>;
71
+ refreshFallbackWithMetadata(accountId: string, accessToken: string, account: Pick<OAuthAccount, 'authLineageId'> | undefined): Promise<QuotaRefreshResult>;
62
72
  refreshAllFallbacks(accounts: OAuthAccount[]): Promise<void>;
63
73
  /**
64
74
  * Fire-and-forget refresh. Does not await, swallows errors.
@@ -66,7 +76,7 @@ export declare class QuotaManager {
66
76
  refreshMainInBackground(accessToken: string): void;
67
77
  private scopedWindowIsStale;
68
78
  isMainStale(modelId?: string): boolean;
69
- isFallbackStale(accountId: string, accessToken?: string, modelId?: string): boolean;
79
+ isFallbackStale(accountId: string, _accessToken?: string, modelId?: string, account?: Pick<OAuthAccount, 'authLineageId'>): boolean;
70
80
  shouldRefreshOnRequestCount(requestCount: number): boolean;
71
81
  /**
72
82
  * Combined check: should a refresh happen right now?
@@ -80,7 +90,7 @@ export declare class QuotaManager {
80
90
  * can stop this process from showing "checking…" or making a redundant quota
81
91
  * API call.
82
92
  */
83
- seedMainFromStorage(storage: AccountStorage | null, accessToken?: string): void;
93
+ seedMainFromStorage(storage: AccountStorage | null, mainAccountId?: string): void;
84
94
  private seedMainBackoffFromStorage;
85
95
  /**
86
96
  * Seed fallback cache entries from persisted account.quota data.
@@ -96,12 +106,18 @@ export declare class QuotaManager {
96
106
  /**
97
107
  * Whether a specific fallback account's quota API is in backoff.
98
108
  */
99
- isFallbackBackedOff(accountId: string, accessToken?: string): boolean;
109
+ isFallbackBackedOff(accountId: string): boolean;
100
110
  getLastApiError(): AccountOperationError | undefined;
111
+ getMainQuotaErrorGeneration(): number;
112
+ getMainQuotaIdentityGeneration(): number;
113
+ getMainQuotaErrorClearedAt(): number | undefined;
114
+ clearMainBackoff(): number | undefined;
101
115
  /** Minimum gap between consecutive quota API calls (ms). */
102
116
  private static readonly API_CALL_GAP_MS;
103
117
  private static fallbackInflightKey;
104
118
  private static quotaLockName;
119
+ private bindFallbackLineage;
120
+ private acceptFallbackQuotaObservation;
105
121
  /**
106
122
  * Serialize API calls through a shared gate so only one
107
123
  * quota API request runs at a time, with a minimum gap