@cortexkit/anthropic-auth-core 1.12.2 → 1.14.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.
@@ -19,7 +19,7 @@ export type OAuthAccount = AccountBase & {
19
19
  lastRefreshedAt?: number;
20
20
  lastRefreshError?: AccountOperationError;
21
21
  lastQuotaRefreshError?: AccountOperationError;
22
- quota?: Partial<Record<QuotaWindowName, AccountQuotaWindow>>;
22
+ quota?: OAuthQuotaSnapshot;
23
23
  };
24
24
  export type ApiKeyAccount = AccountBase & {
25
25
  type: 'api';
@@ -60,8 +60,18 @@ export type AccountQuotaWindow = {
60
60
  resetsAt?: string;
61
61
  checkedAt: number;
62
62
  };
63
+ export type AccountScopedQuotaWindow = AccountQuotaWindow & {
64
+ id: string;
65
+ title: string;
66
+ modelId?: string;
67
+ modelName: string;
68
+ };
69
+ export type OAuthQuotaSnapshot = Partial<Record<QuotaWindowName, AccountQuotaWindow>> & {
70
+ scoped?: AccountScopedQuotaWindow[];
71
+ checkedAt?: number;
72
+ };
63
73
  export type RoutingMode = 'main-first' | 'fallback-first';
64
- export type KillswitchThresholds = Partial<Record<QuotaWindowName | '5h' | '1w', number>>;
74
+ export type KillswitchThresholds = Partial<Record<QuotaWindowName | '5h' | '1w' | 'scoped', number>>;
65
75
  export type KillswitchConfig = {
66
76
  enabled?: boolean;
67
77
  /** Thresholds for the main OAuth account (remaining % below which the account is killed). */
@@ -164,7 +174,6 @@ export type AccountStateSaveScope = {
164
174
  mainRefresh?: boolean;
165
175
  accounts?: true | string[];
166
176
  };
167
- export type OAuthQuotaSnapshot = Partial<Record<QuotaWindowName, AccountQuotaWindow>>;
168
177
  export type AccountManagerOptions = {
169
178
  now?: () => number;
170
179
  fetchImpl?: typeof fetch;
@@ -253,21 +262,36 @@ export declare function getPersistedMainQuota(storage: AccountStorage | null): {
253
262
  * Returns 0 when disabled (default).
254
263
  */
255
264
  export declare function getQuotaRefreshEveryNRequests(storage: AccountStorage | null): number;
265
+ export declare function getScopedQuotaWindowForModel(quota: OAuthQuotaSnapshot | undefined, model: unknown): AccountScopedQuotaWindow | undefined;
266
+ export declare function quotaSnapshotModelScopeIsExhausted(quota: OAuthQuotaSnapshot | undefined, model: unknown): boolean;
267
+ export declare function quotaSnapshotPassesModelScope(quota: OAuthQuotaSnapshot | undefined, model: unknown): boolean;
256
268
  export declare function quotaSnapshotPassesPolicy(quota: OAuthQuotaSnapshot | undefined, storage: AccountStorage | null): boolean;
257
- export declare const DEFAULT_KILLSWITCH_THRESHOLDS: Record<QuotaWindowName, number>;
269
+ export declare const DEFAULT_KILLSWITCH_THRESHOLDS: Record<QuotaWindowName | 'scoped', number>;
270
+ export declare function normalizeKillswitchThresholds(thresholds: KillswitchThresholds | undefined): Record<QuotaWindowName | 'scoped', number>;
258
271
  export declare function isKillswitchEnabled(storage: AccountStorage | null): boolean;
272
+ export declare function getKillswitchThresholdsForAccount(storage: AccountStorage | null, accountId?: string): Record<QuotaWindowName | 'scoped', number>;
259
273
  /**
260
274
  * Returns true if the account's quota is above its killswitch threshold.
261
275
  * When killswitch is disabled, always returns true.
276
+ *
277
+ * When `modelId` is provided, the per-account `scoped` threshold is also
278
+ * evaluated against the quota window matching that model — additive to the
279
+ * 5h/7d check. A model with no matching scoped window is unaffected.
262
280
  */
263
- export declare function killswitchPassesPolicy(quota: OAuthQuotaSnapshot | undefined, storage: AccountStorage | null, accountId?: string): boolean;
281
+ export declare function killswitchPassesPolicy(quota: OAuthQuotaSnapshot | undefined, storage: AccountStorage | null, accountId?: string, modelId?: string): boolean;
264
282
  /**
265
283
  * Find the earliest reset time across all accounts' quota windows.
266
284
  * Returns seconds from `now` until that reset, or 300 as a fallback.
285
+ *
286
+ * When `scopedModelId` is provided, ONLY the matched scoped window's
287
+ * `resetsAt` is considered — the 5h/7d resets are intentionally ignored
288
+ * so the retry hint reflects the weekly reset, not the sooner 5h reset
289
+ * (which would cause a retry-storm against a block that won't clear for
290
+ * days). With `scopedModelId` undefined, the 5h/7d behavior is unchanged.
267
291
  */
268
292
  export declare function killswitchRetryAfterSeconds(mainQuota: OAuthQuotaSnapshot | undefined, fallbackAccounts: Array<{
269
293
  quota?: OAuthQuotaSnapshot;
270
- }>, now: number): number;
294
+ }>, now: number, scopedModelId?: string): number;
271
295
  export declare function getKillswitchConfig(storage: AccountStorage | null): KillswitchConfig;
272
296
  export declare function setKillswitchPersistent(config: KillswitchConfig, path?: string): Promise<AccountStorage>;
273
297
  export declare function removeAccountPersistent(id: string, path?: string): Promise<boolean>;
@@ -279,10 +303,7 @@ export declare function fetchOAuthQuotaSnapshot(input: {
279
303
  accessToken: string;
280
304
  fetchImpl?: typeof fetch;
281
305
  now?: () => number;
282
- }): Promise<{
283
- five_hour: AccountQuotaWindow | undefined;
284
- seven_day: AccountQuotaWindow | undefined;
285
- }>;
306
+ }): Promise<OAuthQuotaSnapshot>;
286
307
  export declare function upsertAccount(storage: AccountStorage, account: FallbackAccount): void;
287
308
  export declare function removeAccount(storage: AccountStorage, id: string): boolean;
288
309
  export declare function reorderAccounts(storage: AccountStorage, orderedIds: string[]): void;
@@ -306,9 +327,13 @@ export declare class FallbackAccountManager {
306
327
  save(storage: AccountStorage, accountIds?: string[]): Promise<void>;
307
328
  startBackgroundRefresh(): void;
308
329
  stopBackgroundRefresh(): void;
309
- getUsableFallbackAccounts(existingStorage?: AccountStorage | null): Promise<OAuthAccount[]>;
330
+ getUsableFallbackAccounts(existingStorage?: AccountStorage | null, options?: {
331
+ modelId?: string;
332
+ }): Promise<OAuthAccount[]>;
310
333
  markUsed(account: FallbackAccount): Promise<void>;
311
- accountPassesQuotaPolicy(account: OAuthAccount, storage: AccountStorage | null): boolean;
334
+ accountPassesQuotaPolicy(account: OAuthAccount, storage: AccountStorage | null, options?: {
335
+ modelId?: string;
336
+ }): boolean;
312
337
  /**
313
338
  * Return the account with its quota overlaid from the unified QuotaManager
314
339
  * cache (token-bound) when available, so quota-policy decisions use the same
package/dist/accounts.js CHANGED
@@ -151,28 +151,74 @@ function normalizeOperationError(value) {
151
151
  permanent: typeof value.permanent === 'boolean' ? value.permanent : undefined,
152
152
  };
153
153
  }
154
+ function normalizeQuotaWindow(value) {
155
+ if (!isRecord(value))
156
+ return undefined;
157
+ const usedPercent = Number(value.usedPercent);
158
+ const remainingPercent = Number(value.remainingPercent);
159
+ const checkedAt = Number(value.checkedAt);
160
+ if (!Number.isFinite(usedPercent) ||
161
+ !Number.isFinite(remainingPercent) ||
162
+ !Number.isFinite(checkedAt)) {
163
+ return undefined;
164
+ }
165
+ return {
166
+ usedPercent,
167
+ remainingPercent,
168
+ checkedAt,
169
+ resetsAt: typeof value.resetsAt === 'string' ? value.resetsAt : undefined,
170
+ };
171
+ }
154
172
  function normalizeQuota(value) {
155
173
  if (!isRecord(value))
156
174
  return undefined;
157
175
  const quota = {};
158
176
  for (const key of ['five_hour', 'seven_day']) {
159
- const window = value[key];
160
- if (!isRecord(window))
161
- continue;
162
- const usedPercent = Number(window.usedPercent);
163
- const remainingPercent = Number(window.remainingPercent);
164
- const checkedAt = Number(window.checkedAt);
165
- if (!Number.isFinite(usedPercent) ||
166
- !Number.isFinite(remainingPercent) ||
167
- !Number.isFinite(checkedAt)) {
168
- continue;
169
- }
170
- quota[key] = {
171
- usedPercent,
172
- remainingPercent,
173
- checkedAt,
174
- resetsAt: typeof window.resetsAt === 'string' ? window.resetsAt : undefined,
175
- };
177
+ const normalized = normalizeQuotaWindow(value[key]);
178
+ if (normalized)
179
+ quota[key] = normalized;
180
+ }
181
+ // Persist a top-level snapshot checkedAt through normalize so the
182
+ // mergeAccountRuntimeState freshness comparison stays meaningful when the
183
+ // snapshot has no per-window checkedAt (e.g. {scoped:[]}). Pre-feature
184
+ // inputs without this key are unaffected — only on-disk snapshots that
185
+ // already carry it reach this branch.
186
+ if (typeof value.checkedAt === 'number' && Number.isFinite(value.checkedAt)) {
187
+ quota.checkedAt = value.checkedAt;
188
+ }
189
+ if (Array.isArray(value.scoped)) {
190
+ const scoped = value.scoped
191
+ .map((entry) => {
192
+ if (!isRecord(entry))
193
+ return undefined;
194
+ const window = normalizeQuotaWindow(entry);
195
+ if (!window)
196
+ return undefined;
197
+ if (typeof entry.id !== 'string' || !entry.id.trim())
198
+ return undefined;
199
+ if (typeof entry.title !== 'string' || !entry.title.trim()) {
200
+ return undefined;
201
+ }
202
+ if (typeof entry.modelName !== 'string' || !entry.modelName.trim()) {
203
+ return undefined;
204
+ }
205
+ const modelId = typeof entry.modelId === 'string' && entry.modelId.trim()
206
+ ? entry.modelId.trim()
207
+ : undefined;
208
+ return {
209
+ ...window,
210
+ id: entry.id.trim(),
211
+ title: entry.title.trim(),
212
+ ...(modelId && { modelId }),
213
+ modelName: entry.modelName.trim(),
214
+ };
215
+ })
216
+ .filter((entry) => entry != null);
217
+ // Preserve empty `[]` so a downstream reader can distinguish "scoped
218
+ // owned by anthropic-auth, none visible" from "no scoped data on this
219
+ // snapshot". Pre-feature inputs without a `scoped` key are not affected
220
+ // — only inputs that already carried an array reach this line.
221
+ quota.scoped = scoped;
176
222
  }
177
223
  return Object.keys(quota).length ? quota : undefined;
178
224
  }
@@ -225,6 +271,43 @@ async function readJsonIfPresent(path) {
225
271
  function objectWithDefinedEntries(value) {
226
272
  return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
227
273
  }
274
+ function numericField(value) {
275
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0;
276
+ }
277
+ function accountCredentialTimestamp(value) {
278
+ return Math.max(numericField(value.lastRefreshedAt), numericField(value.lastUsed), numericField(value.addedAt));
279
+ }
280
+ function legacyConfigCredentialsAreNewer(account, stateAccount) {
281
+ if (account.type !== 'oauth')
282
+ return false;
283
+ if (typeof account.refresh !== 'string' || !account.refresh.trim()) {
284
+ return false;
285
+ }
286
+ const tokenChanged = Boolean((typeof account.access === 'string' &&
287
+ typeof stateAccount.access === 'string' &&
288
+ account.access !== stateAccount.access) ||
289
+ (typeof account.refresh === 'string' &&
290
+ typeof stateAccount.refresh === 'string' &&
291
+ account.refresh !== stateAccount.refresh));
292
+ if (!tokenChanged)
293
+ return false;
294
+ return (accountCredentialTimestamp(account) >
295
+ accountCredentialTimestamp(stateAccount));
296
+ }
297
+ function mergeConfigAccountAndState(account, stateAccount) {
298
+ if (legacyConfigCredentialsAreNewer(account, stateAccount)) {
299
+ const merged = { ...stateAccount, ...account };
300
+ const configTimestamp = accountCredentialTimestamp(account);
301
+ if (configTimestamp > numericField(merged.lastRefreshedAt)) {
302
+ merged.lastRefreshedAt = configTimestamp;
303
+ }
304
+ delete merged.quota;
305
+ delete merged.lastRefreshError;
306
+ delete merged.lastQuotaRefreshError;
307
+ return merged;
308
+ }
309
+ return { ...account, ...stateAccount };
310
+ }
228
311
  function mergeConfigAndState(configValue, stateValue) {
229
312
  if (!isRecord(configValue))
230
313
  return configValue;
@@ -242,7 +325,7 @@ function mergeConfigAndState(configValue, stateValue) {
242
325
  const stateAccount = typeof account.id === 'string' && isRecord(stateAccounts[account.id])
243
326
  ? stateAccounts[account.id]
244
327
  : {};
245
- return { ...account, ...stateAccount };
328
+ return mergeConfigAccountAndState(account, stateAccount);
246
329
  })
247
330
  : [];
248
331
  return {
@@ -312,7 +395,7 @@ function accountRuntimeState(account) {
312
395
  });
313
396
  }
314
397
  function quotaSnapshotCheckedAt(quota) {
315
- return Math.max(quota?.five_hour?.checkedAt ?? 0, quota?.seven_day?.checkedAt ?? 0);
398
+ return Math.max(quota?.five_hour?.checkedAt ?? 0, quota?.seven_day?.checkedAt ?? 0, ...(quota?.scoped?.map((window) => window.checkedAt) ?? []), quota?.checkedAt ?? 0);
316
399
  }
317
400
  function mergeAccountRuntimeState(existing, incoming) {
318
401
  if (!isRecord(existing))
@@ -320,10 +403,13 @@ function mergeAccountRuntimeState(existing, incoming) {
320
403
  const existingEntry = existing;
321
404
  const existingQuotaCheckedAt = quotaSnapshotCheckedAt(existingEntry.quota);
322
405
  const incomingQuotaCheckedAt = quotaSnapshotCheckedAt(incoming.quota);
406
+ const tokenChanged = Boolean((existingEntry.access &&
407
+ incoming.access &&
408
+ existingEntry.access !== incoming.access) ||
409
+ (existingEntry.refresh &&
410
+ incoming.refresh &&
411
+ existingEntry.refresh !== incoming.refresh));
323
412
  if (existingQuotaCheckedAt > incomingQuotaCheckedAt) {
324
- const tokenChanged = Boolean(existingEntry.access &&
325
- incoming.access &&
326
- existingEntry.access !== incoming.access);
327
413
  const existingRefreshAt = existingEntry.lastRefreshedAt ?? 0;
328
414
  const incomingRefreshAt = incoming.lastRefreshedAt ?? 0;
329
415
  if (tokenChanged && incomingRefreshAt <= existingRefreshAt) {
@@ -335,9 +421,27 @@ function mergeAccountRuntimeState(existing, incoming) {
335
421
  }
336
422
  return merged;
337
423
  }
424
+ const merged = { ...existingEntry, ...incoming };
425
+ if (tokenChanged) {
426
+ if ('quota' in incoming) {
427
+ merged.quota = existingEntry.quota;
428
+ if ('lastQuotaRefreshError' in existingEntry) {
429
+ merged.lastQuotaRefreshError = existingEntry.lastQuotaRefreshError;
430
+ }
431
+ else {
432
+ delete merged.lastQuotaRefreshError;
433
+ }
434
+ }
435
+ else {
436
+ delete merged.quota;
437
+ delete merged.lastQuotaRefreshError;
438
+ }
439
+ if (!('lastRefreshError' in incoming))
440
+ delete merged.lastRefreshError;
441
+ return merged;
442
+ }
338
443
  return {
339
- ...existingEntry,
340
- ...incoming,
444
+ ...merged,
341
445
  quota: existingEntry.quota,
342
446
  lastQuotaRefreshError: existingEntry.lastQuotaRefreshError,
343
447
  };
@@ -1003,6 +1107,37 @@ function failClosedOnUnknownQuota(storage) {
1003
1107
  return (storage?.quota?.failClosedOnUnknownQuota ??
1004
1108
  DEFAULT_FAIL_CLOSED_ON_UNKNOWN_QUOTA);
1005
1109
  }
1110
+ function scopedQuotaModelKey(model) {
1111
+ if (typeof model !== 'string')
1112
+ return null;
1113
+ const normalized = model.toLowerCase();
1114
+ if (normalized.includes('fable'))
1115
+ return 'fable';
1116
+ if (normalized.includes('mythos'))
1117
+ return 'mythos';
1118
+ return null;
1119
+ }
1120
+ export function getScopedQuotaWindowForModel(quota, model) {
1121
+ const key = scopedQuotaModelKey(model);
1122
+ if (!key)
1123
+ return undefined;
1124
+ return quota?.scoped?.find((window) => {
1125
+ const haystack = [window.modelId, window.modelName, window.title]
1126
+ .filter((value) => typeof value === 'string')
1127
+ .join(' ')
1128
+ .toLowerCase();
1129
+ return haystack.includes(key);
1130
+ });
1131
+ }
1132
+ export function quotaSnapshotModelScopeIsExhausted(quota, model) {
1133
+ const window = getScopedQuotaWindowForModel(quota, model);
1134
+ return Boolean(window &&
1135
+ Number.isFinite(window.remainingPercent) &&
1136
+ window.remainingPercent <= 0);
1137
+ }
1138
+ export function quotaSnapshotPassesModelScope(quota, model) {
1139
+ return !quotaSnapshotModelScopeIsExhausted(quota, model);
1140
+ }
1006
1141
  export function quotaSnapshotPassesPolicy(quota, storage) {
1007
1142
  if (!quotaEnabled(storage))
1008
1143
  return true;
@@ -1026,10 +1161,12 @@ export function quotaSnapshotPassesPolicy(quota, storage) {
1026
1161
  export const DEFAULT_KILLSWITCH_THRESHOLDS = {
1027
1162
  five_hour: 5,
1028
1163
  seven_day: 10,
1164
+ scoped: 0,
1029
1165
  };
1030
- function normalizeKillswitchThresholds(thresholds) {
1166
+ export function normalizeKillswitchThresholds(thresholds) {
1031
1167
  const fiveHour = thresholds?.five_hour ?? thresholds?.['5h'];
1032
1168
  const sevenDay = thresholds?.seven_day ?? thresholds?.['1w'];
1169
+ const scoped = thresholds?.scoped;
1033
1170
  return {
1034
1171
  five_hour: typeof fiveHour === 'number' && Number.isFinite(fiveHour)
1035
1172
  ? fiveHour
@@ -1037,12 +1174,15 @@ function normalizeKillswitchThresholds(thresholds) {
1037
1174
  seven_day: typeof sevenDay === 'number' && Number.isFinite(sevenDay)
1038
1175
  ? sevenDay
1039
1176
  : DEFAULT_KILLSWITCH_THRESHOLDS.seven_day,
1177
+ scoped: typeof scoped === 'number' && Number.isFinite(scoped)
1178
+ ? scoped
1179
+ : DEFAULT_KILLSWITCH_THRESHOLDS.scoped,
1040
1180
  };
1041
1181
  }
1042
1182
  export function isKillswitchEnabled(storage) {
1043
1183
  return storage?.killswitch?.enabled === true;
1044
1184
  }
1045
- function getKillswitchThresholdsForAccount(storage, accountId) {
1185
+ export function getKillswitchThresholdsForAccount(storage, accountId) {
1046
1186
  if (!storage?.killswitch)
1047
1187
  return DEFAULT_KILLSWITCH_THRESHOLDS;
1048
1188
  if (accountId && storage.killswitch.accounts?.[accountId]) {
@@ -1053,8 +1193,12 @@ function getKillswitchThresholdsForAccount(storage, accountId) {
1053
1193
  /**
1054
1194
  * Returns true if the account's quota is above its killswitch threshold.
1055
1195
  * When killswitch is disabled, always returns true.
1196
+ *
1197
+ * When `modelId` is provided, the per-account `scoped` threshold is also
1198
+ * evaluated against the quota window matching that model — additive to the
1199
+ * 5h/7d check. A model with no matching scoped window is unaffected.
1056
1200
  */
1057
- export function killswitchPassesPolicy(quota, storage, accountId) {
1201
+ export function killswitchPassesPolicy(quota, storage, accountId, modelId) {
1058
1202
  if (!isKillswitchEnabled(storage))
1059
1203
  return true;
1060
1204
  const thresholds = getKillswitchThresholdsForAccount(storage, accountId);
@@ -1075,6 +1219,22 @@ export function killswitchPassesPolicy(quota, storage, accountId) {
1075
1219
  if (window.remainingPercent < thresholds[key])
1076
1220
  return false;
1077
1221
  }
1222
+ // Scoped check is additive to the 5h/7d evaluation above and is an
1223
+ // INDEPENDENT block reason — it must run before the unknown-window
1224
+ // fail-closed decision, so an exhausted scoped window blocks even when
1225
+ // 5h/7d is missing/non-finite (the latter only changes the fall-through
1226
+ // for accounts that did not already block on scoped). A missing scoped
1227
+ // window (no carve-out for this model) is not "unknown quota" — only a
1228
+ // PRESENT window at/below threshold blocks. The comparison is inclusive
1229
+ // (`<=`) so the default 0 fires at exhaustion.
1230
+ if (modelId) {
1231
+ const scopedWindow = getScopedQuotaWindowForModel(quota, modelId);
1232
+ if (scopedWindow &&
1233
+ Number.isFinite(scopedWindow.remainingPercent) &&
1234
+ scopedWindow.remainingPercent <= thresholds.scoped) {
1235
+ return false;
1236
+ }
1237
+ }
1078
1238
  if (sawUnknownWindow)
1079
1239
  return !failClosedOnUnknownQuota(storage);
1080
1240
  return true;
@@ -1082,13 +1242,20 @@ export function killswitchPassesPolicy(quota, storage, accountId) {
1082
1242
  /**
1083
1243
  * Find the earliest reset time across all accounts' quota windows.
1084
1244
  * Returns seconds from `now` until that reset, or 300 as a fallback.
1245
+ *
1246
+ * When `scopedModelId` is provided, ONLY the matched scoped window's
1247
+ * `resetsAt` is considered — the 5h/7d resets are intentionally ignored
1248
+ * so the retry hint reflects the weekly reset, not the sooner 5h reset
1249
+ * (which would cause a retry-storm against a block that won't clear for
1250
+ * days). With `scopedModelId` undefined, the 5h/7d behavior is unchanged.
1085
1251
  */
1086
- export function killswitchRetryAfterSeconds(mainQuota, fallbackAccounts, now) {
1252
+ export function killswitchRetryAfterSeconds(mainQuota, fallbackAccounts, now, scopedModelId) {
1087
1253
  const resetTimes = [];
1088
1254
  const allQuotas = [mainQuota, ...fallbackAccounts.map((a) => a.quota)];
1089
1255
  for (const quota of allQuotas) {
1090
- for (const key of ['five_hour', 'seven_day']) {
1091
- const resetStr = quota?.[key]?.resetsAt;
1256
+ if (scopedModelId) {
1257
+ const scopedWindow = getScopedQuotaWindowForModel(quota, scopedModelId);
1258
+ const resetStr = scopedWindow?.resetsAt;
1092
1259
  if (!resetStr)
1093
1260
  continue;
1094
1261
  const resetTime = Date.parse(resetStr);
@@ -1096,6 +1263,17 @@ export function killswitchRetryAfterSeconds(mainQuota, fallbackAccounts, now) {
1096
1263
  resetTimes.push(resetTime);
1097
1264
  }
1098
1265
  }
1266
+ else {
1267
+ for (const key of ['five_hour', 'seven_day']) {
1268
+ const resetStr = quota?.[key]?.resetsAt;
1269
+ if (!resetStr)
1270
+ continue;
1271
+ const resetTime = Date.parse(resetStr);
1272
+ if (Number.isFinite(resetTime) && resetTime > now) {
1273
+ resetTimes.push(resetTime);
1274
+ }
1275
+ }
1276
+ }
1099
1277
  }
1100
1278
  if (!resetTimes.length)
1101
1279
  return 300;
@@ -1226,6 +1404,52 @@ function clampPercent(value) {
1226
1404
  return 100;
1227
1405
  return value;
1228
1406
  }
1407
+ function nonEmptyString(value) {
1408
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
1409
+ }
1410
+ function slugForQuotaIdentity(value) {
1411
+ return value
1412
+ .toLowerCase()
1413
+ .replace(/[^a-z0-9]+/g, '-')
1414
+ .replace(/^-+|-+$/g, '');
1415
+ }
1416
+ function mapScopedWeeklyLimits(limits, checkedAt) {
1417
+ if (!Array.isArray(limits))
1418
+ return [];
1419
+ const seen = new Set();
1420
+ const scoped = [];
1421
+ for (const limit of limits) {
1422
+ if (limit?.kind !== 'weekly_scoped' || limit.group !== 'weekly')
1423
+ continue;
1424
+ if (typeof limit.percent !== 'number' || !Number.isFinite(limit.percent)) {
1425
+ continue;
1426
+ }
1427
+ const modelName = nonEmptyString(limit.scope?.model?.display_name);
1428
+ if (!modelName)
1429
+ continue;
1430
+ const identity = nonEmptyString(limit.scope?.model?.id) ?? modelName;
1431
+ const slug = slugForQuotaIdentity(identity);
1432
+ if (!slug)
1433
+ continue;
1434
+ const id = `claude-weekly-scoped-${slug}`;
1435
+ if (seen.has(id))
1436
+ continue;
1437
+ seen.add(id);
1438
+ const usedPercent = clampPercent(limit.percent);
1439
+ const modelId = nonEmptyString(limit.scope?.model?.id);
1440
+ scoped.push({
1441
+ id,
1442
+ title: `${modelName} only`,
1443
+ ...(modelId && { modelId }),
1444
+ modelName,
1445
+ usedPercent,
1446
+ remainingPercent: clampPercent(100 - usedPercent),
1447
+ resetsAt: limit.resets_at,
1448
+ checkedAt,
1449
+ });
1450
+ }
1451
+ return scoped;
1452
+ }
1229
1453
  function mapUsageWindow(window, checkedAt) {
1230
1454
  if (typeof window?.utilization !== 'number')
1231
1455
  return undefined;
@@ -1264,6 +1488,8 @@ export async function fetchOAuthQuotaSnapshot(input) {
1264
1488
  return {
1265
1489
  five_hour: mapUsageWindow(usage.five_hour, checkedAt),
1266
1490
  seven_day: mapUsageWindow(usage.seven_day, checkedAt),
1491
+ scoped: mapScopedWeeklyLimits(usage.limits, checkedAt),
1492
+ checkedAt,
1267
1493
  };
1268
1494
  }
1269
1495
  function updateStoredAccount(storage, account) {
@@ -1408,7 +1634,7 @@ export class FallbackAccountManager {
1408
1634
  this.refreshTimer = null;
1409
1635
  this.quotaTimer = null;
1410
1636
  }
1411
- async getUsableFallbackAccounts(existingStorage) {
1637
+ async getUsableFallbackAccounts(existingStorage, options = {}) {
1412
1638
  const storage = existingStorage !== undefined ? existingStorage : await this.load();
1413
1639
  if (!storage)
1414
1640
  return [];
@@ -1444,7 +1670,9 @@ export class FallbackAccountManager {
1444
1670
  // Single source of truth: evaluate quota policy from the unified
1445
1671
  // QuotaManager cache (the same source as the staleness check above) so
1446
1672
  // an active-route refresh that updated only the cache is not ignored.
1447
- if (this.accountPassesQuotaPolicy(this.quotaPolicyAccount(next), storage))
1673
+ if (this.accountPassesQuotaPolicy(this.quotaPolicyAccount(next), storage, {
1674
+ modelId: options.modelId,
1675
+ }))
1448
1676
  usable.push(next);
1449
1677
  }
1450
1678
  catch (error) {
@@ -1453,9 +1681,14 @@ export class FallbackAccountManager {
1453
1681
  accountId: account.id,
1454
1682
  error: formatErrorMessage(error),
1455
1683
  });
1456
- usable.push(account);
1684
+ if (this.accountPassesQuotaPolicy(this.quotaPolicyAccount(account), storage, {
1685
+ modelId: options.modelId,
1686
+ })) {
1687
+ usable.push(account);
1688
+ }
1457
1689
  }
1458
- else if (!failClosedOnUnknownQuota(storage)) {
1690
+ else if (!failClosedOnUnknownQuota(storage) &&
1691
+ quotaSnapshotPassesModelScope(account.quota, options.modelId)) {
1459
1692
  usable.push(account);
1460
1693
  }
1461
1694
  }
@@ -1474,8 +1707,9 @@ export class FallbackAccountManager {
1474
1707
  stored.lastUsed = this.now();
1475
1708
  await this.save(storage);
1476
1709
  }
1477
- accountPassesQuotaPolicy(account, storage) {
1478
- return quotaSnapshotPassesPolicy(account.quota, storage);
1710
+ accountPassesQuotaPolicy(account, storage, options = {}) {
1711
+ return (quotaSnapshotPassesPolicy(account.quota, storage) &&
1712
+ quotaSnapshotPassesModelScope(account.quota, options.modelId));
1479
1713
  }
1480
1714
  /**
1481
1715
  * Return the account with its quota overlaid from the unified QuotaManager
@@ -61,6 +61,7 @@ export type CacheKeepTarget = {
61
61
  bodyText: string;
62
62
  cacheExpiresAt: number;
63
63
  dayKey: string;
64
+ oauthAccountId?: string;
64
65
  };
65
66
  export declare class CacheKeepManager {
66
67
  private readonly options;
@@ -89,6 +90,7 @@ export declare class CacheKeepManager {
89
90
  bodyText: string;
90
91
  storage: AccountStorage | null;
91
92
  cacheMode: string;
93
+ oauthAccountId?: string;
92
94
  }): {
93
95
  tracked: boolean;
94
96
  reason: string;
package/dist/cachekeep.js CHANGED
@@ -287,6 +287,7 @@ export class CacheKeepManager {
287
287
  bodyText: input.bodyText,
288
288
  cacheExpiresAt: now + CACHE_KEEP_TTL_MS,
289
289
  dayKey: today,
290
+ oauthAccountId: input.oauthAccountId,
290
291
  });
291
292
  this.pruneTargets(now, today);
292
293
  this.start();
@@ -12,6 +12,7 @@ export type KillswitchCommandAction = {
12
12
  account: string;
13
13
  fh: number;
14
14
  sd: number;
15
+ scoped?: number;
15
16
  }>;
16
17
  } | {
17
18
  type: 'usage';
@@ -1,4 +1,4 @@
1
- import { DEFAULT_KILLSWITCH_THRESHOLDS as DEFAULT_THRESHOLDS } from "./accounts.js";
1
+ import { DEFAULT_KILLSWITCH_THRESHOLDS as DEFAULT_THRESHOLDS, normalizeKillswitchThresholds, } from "./accounts.js";
2
2
  export const KILLSWITCH_COMMAND_NAME = 'claude-killswitch';
3
3
  export function parseKillswitchCommandAction(argumentsText) {
4
4
  const parts = argumentsText.trim().split(/\s+/).filter(Boolean);
@@ -11,15 +11,21 @@ export function parseKillswitchCommandAction(argumentsText) {
11
11
  if (parts[0] === 'set') {
12
12
  const entries = [];
13
13
  for (let i = 1; i < parts.length; i++) {
14
- const match = parts[i]?.match(/^([^:]+):(\d+),(\d+)$/);
14
+ const match = parts[i]?.match(/^([^:]+):(\d+),(\d+)(?:,(\d+))?$/);
15
15
  if (!match)
16
16
  return { type: 'usage' };
17
- const [, account, fhStr, sdStr] = match;
18
- entries.push({
17
+ const [, account, fhStr, sdStr, scopedStr] = match;
18
+ const scoped = typeof scopedStr === 'string' && scopedStr.length > 0
19
+ ? Number.parseInt(scopedStr, 10)
20
+ : undefined;
21
+ const entry = {
19
22
  account,
20
23
  fh: Number.parseInt(fhStr, 10),
21
24
  sd: Number.parseInt(sdStr, 10),
22
- });
25
+ };
26
+ if (scoped !== undefined)
27
+ entry.scoped = scoped;
28
+ entries.push(entry);
23
29
  }
24
30
  if (entries.length === 0)
25
31
  return { type: 'usage' };
@@ -36,17 +42,13 @@ function buildStatusTable(config, accountIds) {
36
42
  ];
37
43
  if (enabled) {
38
44
  lines.push('');
39
- lines.push('| Account | 5h threshold | 1w threshold |');
40
- lines.push('| ------- | ------------ | ------------ |');
41
- const mainT = config.main ?? {};
42
- const fh = mainT.five_hour ?? mainT['5h'] ?? DEFAULT_THRESHOLDS.five_hour;
43
- const sd = mainT.seven_day ?? mainT['1w'] ?? DEFAULT_THRESHOLDS.seven_day;
44
- lines.push(`| main | \u2265 ${fh}% | \u2265 ${sd}% |`);
45
+ lines.push('| Account | 5h threshold | 1w threshold | Scoped |');
46
+ lines.push('| ------- | ------------ | ------------ | ------ |');
47
+ const mainT = normalizeKillswitchThresholds(config.main);
48
+ lines.push(`| main | \u2265 ${mainT.five_hour}% | \u2265 ${mainT.seven_day}% | \u2264 ${mainT.scoped}% |`);
45
49
  for (const id of accountIds) {
46
- const t = config.accounts?.[id] ?? config.main ?? {};
47
- const afh = t.five_hour ?? t['5h'] ?? DEFAULT_THRESHOLDS.five_hour;
48
- const asd = t.seven_day ?? t['1w'] ?? DEFAULT_THRESHOLDS.seven_day;
49
- lines.push(`| ${id} | \u2265 ${afh}% | \u2265 ${asd}% |`);
50
+ const t = normalizeKillswitchThresholds(config.accounts?.[id] ?? config.main);
51
+ lines.push(`| ${id} | \u2265 ${t.five_hour}% | \u2265 ${t.seven_day}% | \u2264 ${t.scoped}% |`);
50
52
  }
51
53
  }
52
54
  return lines.join('\n');
@@ -59,6 +61,7 @@ const USAGE_TEXT = [
59
61
  `/${KILLSWITCH_COMMAND_NAME} on — enable with current or default thresholds`,
60
62
  `/${KILLSWITCH_COMMAND_NAME} off — disable`,
61
63
  `/${KILLSWITCH_COMMAND_NAME} set all:5,10 — set all accounts to 5h≥5%, 1w≥10%`,
64
+ `/${KILLSWITCH_COMMAND_NAME} set main:3,8,0 — set 5h≥3%, 1w≥8%, scoped≤0%`,
62
65
  `/${KILLSWITCH_COMMAND_NAME} set main:3,8 work-alt:5,10 — per-account`,
63
66
  '```',
64
67
  ].join('\n');
@@ -102,6 +105,8 @@ export function executeKillswitchCommand(input) {
102
105
  five_hour: entry.fh,
103
106
  seven_day: entry.sd,
104
107
  };
108
+ if (entry.scoped !== undefined)
109
+ thresholds.scoped = entry.scoped;
105
110
  if (entry.account === 'main') {
106
111
  updated.main = thresholds;
107
112
  }
package/dist/quotas.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AccountQuotaWindow, AccountStorage, OAuthQuotaSnapshot, QuotaWindowName } from './accounts.ts';
1
+ import type { AccountStorage, OAuthQuotaSnapshot } from './accounts.ts';
2
2
  export declare const CLAUDE_QUOTAS_COMMAND_NAME = "claude-quota";
3
3
  export type QuotaAccountSummary = {
4
4
  name: string;
@@ -13,7 +13,7 @@ export declare function buildFallbackQuotaSummaries(storage: AccountStorage | nu
13
13
  name: string;
14
14
  role: "fallback";
15
15
  enabled: boolean;
16
- quota: Partial<Record<QuotaWindowName, AccountQuotaWindow>> | undefined;
16
+ quota: OAuthQuotaSnapshot | undefined;
17
17
  lastRefreshedAt: number | undefined;
18
18
  }[];
19
19
  export declare function buildClaudeQuotaSummary(input: {
package/dist/quotas.js CHANGED
@@ -52,6 +52,13 @@ function formatWindow(key, window, now) {
52
52
  `${formatReset(window.resetsAt, now)}, checked ${formatAge(window.checkedAt, now)})`,
53
53
  ].join('');
54
54
  }
55
+ function formatScopedWindow(window, now) {
56
+ return [
57
+ ` - ${window.title}: ${formatPercent(window.remainingPercent)} remaining`,
58
+ ` (${formatPercent(window.usedPercent)} used`,
59
+ `${formatReset(window.resetsAt, now)}, checked ${formatAge(window.checkedAt, now)})`,
60
+ ].join('');
61
+ }
55
62
  function accountName(account) {
56
63
  return account.label?.trim() || account.id;
57
64
  }
@@ -95,6 +102,9 @@ export function buildClaudeQuotaSummary(input) {
95
102
  }
96
103
  lines.push(formatWindow('five_hour', account.quota?.five_hour, now));
97
104
  lines.push(formatWindow('seven_day', account.quota?.seven_day, now));
105
+ for (const window of account.quota?.scoped ?? []) {
106
+ lines.push(formatScopedWindow(window, now));
107
+ }
98
108
  lines.push('');
99
109
  }
100
110
  return lines.join('\n').trimEnd();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/anthropic-auth-core",
3
- "version": "1.12.2",
3
+ "version": "1.14.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",