@cortexkit/anthropic-auth-core 1.12.1 → 1.13.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,17 @@ 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
+ };
63
72
  export type RoutingMode = 'main-first' | 'fallback-first';
64
- export type KillswitchThresholds = Partial<Record<QuotaWindowName | '5h' | '1w', number>>;
73
+ export type KillswitchThresholds = Partial<Record<QuotaWindowName | '5h' | '1w' | 'scoped', number>>;
65
74
  export type KillswitchConfig = {
66
75
  enabled?: boolean;
67
76
  /** Thresholds for the main OAuth account (remaining % below which the account is killed). */
@@ -164,7 +173,6 @@ export type AccountStateSaveScope = {
164
173
  mainRefresh?: boolean;
165
174
  accounts?: true | string[];
166
175
  };
167
- export type OAuthQuotaSnapshot = Partial<Record<QuotaWindowName, AccountQuotaWindow>>;
168
176
  export type AccountManagerOptions = {
169
177
  now?: () => number;
170
178
  fetchImpl?: typeof fetch;
@@ -253,21 +261,36 @@ export declare function getPersistedMainQuota(storage: AccountStorage | null): {
253
261
  * Returns 0 when disabled (default).
254
262
  */
255
263
  export declare function getQuotaRefreshEveryNRequests(storage: AccountStorage | null): number;
264
+ export declare function getScopedQuotaWindowForModel(quota: OAuthQuotaSnapshot | undefined, model: unknown): AccountScopedQuotaWindow | undefined;
265
+ export declare function quotaSnapshotModelScopeIsExhausted(quota: OAuthQuotaSnapshot | undefined, model: unknown): boolean;
266
+ export declare function quotaSnapshotPassesModelScope(quota: OAuthQuotaSnapshot | undefined, model: unknown): boolean;
256
267
  export declare function quotaSnapshotPassesPolicy(quota: OAuthQuotaSnapshot | undefined, storage: AccountStorage | null): boolean;
257
- export declare const DEFAULT_KILLSWITCH_THRESHOLDS: Record<QuotaWindowName, number>;
268
+ export declare const DEFAULT_KILLSWITCH_THRESHOLDS: Record<QuotaWindowName | 'scoped', number>;
269
+ export declare function normalizeKillswitchThresholds(thresholds: KillswitchThresholds | undefined): Record<QuotaWindowName | 'scoped', number>;
258
270
  export declare function isKillswitchEnabled(storage: AccountStorage | null): boolean;
271
+ export declare function getKillswitchThresholdsForAccount(storage: AccountStorage | null, accountId?: string): Record<QuotaWindowName | 'scoped', number>;
259
272
  /**
260
273
  * Returns true if the account's quota is above its killswitch threshold.
261
274
  * When killswitch is disabled, always returns true.
275
+ *
276
+ * When `modelId` is provided, the per-account `scoped` threshold is also
277
+ * evaluated against the quota window matching that model — additive to the
278
+ * 5h/7d check. A model with no matching scoped window is unaffected.
262
279
  */
263
- export declare function killswitchPassesPolicy(quota: OAuthQuotaSnapshot | undefined, storage: AccountStorage | null, accountId?: string): boolean;
280
+ export declare function killswitchPassesPolicy(quota: OAuthQuotaSnapshot | undefined, storage: AccountStorage | null, accountId?: string, modelId?: string): boolean;
264
281
  /**
265
282
  * Find the earliest reset time across all accounts' quota windows.
266
283
  * Returns seconds from `now` until that reset, or 300 as a fallback.
284
+ *
285
+ * When `scopedModelId` is provided, ONLY the matched scoped window's
286
+ * `resetsAt` is considered — the 5h/7d resets are intentionally ignored
287
+ * so the retry hint reflects the weekly reset, not the sooner 5h reset
288
+ * (which would cause a retry-storm against a block that won't clear for
289
+ * days). With `scopedModelId` undefined, the 5h/7d behavior is unchanged.
267
290
  */
268
291
  export declare function killswitchRetryAfterSeconds(mainQuota: OAuthQuotaSnapshot | undefined, fallbackAccounts: Array<{
269
292
  quota?: OAuthQuotaSnapshot;
270
- }>, now: number): number;
293
+ }>, now: number, scopedModelId?: string): number;
271
294
  export declare function getKillswitchConfig(storage: AccountStorage | null): KillswitchConfig;
272
295
  export declare function setKillswitchPersistent(config: KillswitchConfig, path?: string): Promise<AccountStorage>;
273
296
  export declare function removeAccountPersistent(id: string, path?: string): Promise<boolean>;
@@ -279,10 +302,7 @@ export declare function fetchOAuthQuotaSnapshot(input: {
279
302
  accessToken: string;
280
303
  fetchImpl?: typeof fetch;
281
304
  now?: () => number;
282
- }): Promise<{
283
- five_hour: AccountQuotaWindow | undefined;
284
- seven_day: AccountQuotaWindow | undefined;
285
- }>;
305
+ }): Promise<OAuthQuotaSnapshot>;
286
306
  export declare function upsertAccount(storage: AccountStorage, account: FallbackAccount): void;
287
307
  export declare function removeAccount(storage: AccountStorage, id: string): boolean;
288
308
  export declare function reorderAccounts(storage: AccountStorage, orderedIds: string[]): void;
@@ -306,9 +326,13 @@ export declare class FallbackAccountManager {
306
326
  save(storage: AccountStorage, accountIds?: string[]): Promise<void>;
307
327
  startBackgroundRefresh(): void;
308
328
  stopBackgroundRefresh(): void;
309
- getUsableFallbackAccounts(existingStorage?: AccountStorage | null): Promise<OAuthAccount[]>;
329
+ getUsableFallbackAccounts(existingStorage?: AccountStorage | null, options?: {
330
+ modelId?: string;
331
+ }): Promise<OAuthAccount[]>;
310
332
  markUsed(account: FallbackAccount): Promise<void>;
311
- accountPassesQuotaPolicy(account: OAuthAccount, storage: AccountStorage | null): boolean;
333
+ accountPassesQuotaPolicy(account: OAuthAccount, storage: AccountStorage | null, options?: {
334
+ modelId?: string;
335
+ }): boolean;
312
336
  /**
313
337
  * Return the account with its quota overlaid from the unified QuotaManager
314
338
  * cache (token-bound) when available, so quota-policy decisions use the same
package/dist/accounts.js CHANGED
@@ -151,28 +151,63 @@ 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
+ if (Array.isArray(value.scoped)) {
182
+ const scoped = value.scoped
183
+ .map((entry) => {
184
+ if (!isRecord(entry))
185
+ return undefined;
186
+ const window = normalizeQuotaWindow(entry);
187
+ if (!window)
188
+ return undefined;
189
+ if (typeof entry.id !== 'string' || !entry.id.trim())
190
+ return undefined;
191
+ if (typeof entry.title !== 'string' || !entry.title.trim()) {
192
+ return undefined;
193
+ }
194
+ if (typeof entry.modelName !== 'string' || !entry.modelName.trim()) {
195
+ return undefined;
196
+ }
197
+ const modelId = typeof entry.modelId === 'string' && entry.modelId.trim()
198
+ ? entry.modelId.trim()
199
+ : undefined;
200
+ return {
201
+ ...window,
202
+ id: entry.id.trim(),
203
+ title: entry.title.trim(),
204
+ ...(modelId && { modelId }),
205
+ modelName: entry.modelName.trim(),
206
+ };
207
+ })
208
+ .filter((entry) => entry != null);
209
+ if (scoped.length)
210
+ quota.scoped = scoped;
176
211
  }
177
212
  return Object.keys(quota).length ? quota : undefined;
178
213
  }
@@ -225,6 +260,43 @@ async function readJsonIfPresent(path) {
225
260
  function objectWithDefinedEntries(value) {
226
261
  return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
227
262
  }
263
+ function numericField(value) {
264
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0;
265
+ }
266
+ function accountCredentialTimestamp(value) {
267
+ return Math.max(numericField(value.lastRefreshedAt), numericField(value.lastUsed), numericField(value.addedAt));
268
+ }
269
+ function legacyConfigCredentialsAreNewer(account, stateAccount) {
270
+ if (account.type !== 'oauth')
271
+ return false;
272
+ if (typeof account.refresh !== 'string' || !account.refresh.trim()) {
273
+ return false;
274
+ }
275
+ const tokenChanged = Boolean((typeof account.access === 'string' &&
276
+ typeof stateAccount.access === 'string' &&
277
+ account.access !== stateAccount.access) ||
278
+ (typeof account.refresh === 'string' &&
279
+ typeof stateAccount.refresh === 'string' &&
280
+ account.refresh !== stateAccount.refresh));
281
+ if (!tokenChanged)
282
+ return false;
283
+ return (accountCredentialTimestamp(account) >
284
+ accountCredentialTimestamp(stateAccount));
285
+ }
286
+ function mergeConfigAccountAndState(account, stateAccount) {
287
+ if (legacyConfigCredentialsAreNewer(account, stateAccount)) {
288
+ const merged = { ...stateAccount, ...account };
289
+ const configTimestamp = accountCredentialTimestamp(account);
290
+ if (configTimestamp > numericField(merged.lastRefreshedAt)) {
291
+ merged.lastRefreshedAt = configTimestamp;
292
+ }
293
+ delete merged.quota;
294
+ delete merged.lastRefreshError;
295
+ delete merged.lastQuotaRefreshError;
296
+ return merged;
297
+ }
298
+ return { ...account, ...stateAccount };
299
+ }
228
300
  function mergeConfigAndState(configValue, stateValue) {
229
301
  if (!isRecord(configValue))
230
302
  return configValue;
@@ -242,7 +314,7 @@ function mergeConfigAndState(configValue, stateValue) {
242
314
  const stateAccount = typeof account.id === 'string' && isRecord(stateAccounts[account.id])
243
315
  ? stateAccounts[account.id]
244
316
  : {};
245
- return { ...account, ...stateAccount };
317
+ return mergeConfigAccountAndState(account, stateAccount);
246
318
  })
247
319
  : [];
248
320
  return {
@@ -312,7 +384,7 @@ function accountRuntimeState(account) {
312
384
  });
313
385
  }
314
386
  function quotaSnapshotCheckedAt(quota) {
315
- return Math.max(quota?.five_hour?.checkedAt ?? 0, quota?.seven_day?.checkedAt ?? 0);
387
+ return Math.max(quota?.five_hour?.checkedAt ?? 0, quota?.seven_day?.checkedAt ?? 0, ...(quota?.scoped?.map((window) => window.checkedAt) ?? []));
316
388
  }
317
389
  function mergeAccountRuntimeState(existing, incoming) {
318
390
  if (!isRecord(existing))
@@ -320,10 +392,13 @@ function mergeAccountRuntimeState(existing, incoming) {
320
392
  const existingEntry = existing;
321
393
  const existingQuotaCheckedAt = quotaSnapshotCheckedAt(existingEntry.quota);
322
394
  const incomingQuotaCheckedAt = quotaSnapshotCheckedAt(incoming.quota);
395
+ const tokenChanged = Boolean((existingEntry.access &&
396
+ incoming.access &&
397
+ existingEntry.access !== incoming.access) ||
398
+ (existingEntry.refresh &&
399
+ incoming.refresh &&
400
+ existingEntry.refresh !== incoming.refresh));
323
401
  if (existingQuotaCheckedAt > incomingQuotaCheckedAt) {
324
- const tokenChanged = Boolean(existingEntry.access &&
325
- incoming.access &&
326
- existingEntry.access !== incoming.access);
327
402
  const existingRefreshAt = existingEntry.lastRefreshedAt ?? 0;
328
403
  const incomingRefreshAt = incoming.lastRefreshedAt ?? 0;
329
404
  if (tokenChanged && incomingRefreshAt <= existingRefreshAt) {
@@ -335,9 +410,27 @@ function mergeAccountRuntimeState(existing, incoming) {
335
410
  }
336
411
  return merged;
337
412
  }
413
+ const merged = { ...existingEntry, ...incoming };
414
+ if (tokenChanged) {
415
+ if ('quota' in incoming) {
416
+ merged.quota = existingEntry.quota;
417
+ if ('lastQuotaRefreshError' in existingEntry) {
418
+ merged.lastQuotaRefreshError = existingEntry.lastQuotaRefreshError;
419
+ }
420
+ else {
421
+ delete merged.lastQuotaRefreshError;
422
+ }
423
+ }
424
+ else {
425
+ delete merged.quota;
426
+ delete merged.lastQuotaRefreshError;
427
+ }
428
+ if (!('lastRefreshError' in incoming))
429
+ delete merged.lastRefreshError;
430
+ return merged;
431
+ }
338
432
  return {
339
- ...existingEntry,
340
- ...incoming,
433
+ ...merged,
341
434
  quota: existingEntry.quota,
342
435
  lastQuotaRefreshError: existingEntry.lastQuotaRefreshError,
343
436
  };
@@ -1003,6 +1096,37 @@ function failClosedOnUnknownQuota(storage) {
1003
1096
  return (storage?.quota?.failClosedOnUnknownQuota ??
1004
1097
  DEFAULT_FAIL_CLOSED_ON_UNKNOWN_QUOTA);
1005
1098
  }
1099
+ function scopedQuotaModelKey(model) {
1100
+ if (typeof model !== 'string')
1101
+ return null;
1102
+ const normalized = model.toLowerCase();
1103
+ if (normalized.includes('fable'))
1104
+ return 'fable';
1105
+ if (normalized.includes('mythos'))
1106
+ return 'mythos';
1107
+ return null;
1108
+ }
1109
+ export function getScopedQuotaWindowForModel(quota, model) {
1110
+ const key = scopedQuotaModelKey(model);
1111
+ if (!key)
1112
+ return undefined;
1113
+ return quota?.scoped?.find((window) => {
1114
+ const haystack = [window.modelId, window.modelName, window.title]
1115
+ .filter((value) => typeof value === 'string')
1116
+ .join(' ')
1117
+ .toLowerCase();
1118
+ return haystack.includes(key);
1119
+ });
1120
+ }
1121
+ export function quotaSnapshotModelScopeIsExhausted(quota, model) {
1122
+ const window = getScopedQuotaWindowForModel(quota, model);
1123
+ return Boolean(window &&
1124
+ Number.isFinite(window.remainingPercent) &&
1125
+ window.remainingPercent <= 0);
1126
+ }
1127
+ export function quotaSnapshotPassesModelScope(quota, model) {
1128
+ return !quotaSnapshotModelScopeIsExhausted(quota, model);
1129
+ }
1006
1130
  export function quotaSnapshotPassesPolicy(quota, storage) {
1007
1131
  if (!quotaEnabled(storage))
1008
1132
  return true;
@@ -1026,10 +1150,12 @@ export function quotaSnapshotPassesPolicy(quota, storage) {
1026
1150
  export const DEFAULT_KILLSWITCH_THRESHOLDS = {
1027
1151
  five_hour: 5,
1028
1152
  seven_day: 10,
1153
+ scoped: 0,
1029
1154
  };
1030
- function normalizeKillswitchThresholds(thresholds) {
1155
+ export function normalizeKillswitchThresholds(thresholds) {
1031
1156
  const fiveHour = thresholds?.five_hour ?? thresholds?.['5h'];
1032
1157
  const sevenDay = thresholds?.seven_day ?? thresholds?.['1w'];
1158
+ const scoped = thresholds?.scoped;
1033
1159
  return {
1034
1160
  five_hour: typeof fiveHour === 'number' && Number.isFinite(fiveHour)
1035
1161
  ? fiveHour
@@ -1037,12 +1163,15 @@ function normalizeKillswitchThresholds(thresholds) {
1037
1163
  seven_day: typeof sevenDay === 'number' && Number.isFinite(sevenDay)
1038
1164
  ? sevenDay
1039
1165
  : DEFAULT_KILLSWITCH_THRESHOLDS.seven_day,
1166
+ scoped: typeof scoped === 'number' && Number.isFinite(scoped)
1167
+ ? scoped
1168
+ : DEFAULT_KILLSWITCH_THRESHOLDS.scoped,
1040
1169
  };
1041
1170
  }
1042
1171
  export function isKillswitchEnabled(storage) {
1043
1172
  return storage?.killswitch?.enabled === true;
1044
1173
  }
1045
- function getKillswitchThresholdsForAccount(storage, accountId) {
1174
+ export function getKillswitchThresholdsForAccount(storage, accountId) {
1046
1175
  if (!storage?.killswitch)
1047
1176
  return DEFAULT_KILLSWITCH_THRESHOLDS;
1048
1177
  if (accountId && storage.killswitch.accounts?.[accountId]) {
@@ -1053,8 +1182,12 @@ function getKillswitchThresholdsForAccount(storage, accountId) {
1053
1182
  /**
1054
1183
  * Returns true if the account's quota is above its killswitch threshold.
1055
1184
  * When killswitch is disabled, always returns true.
1185
+ *
1186
+ * When `modelId` is provided, the per-account `scoped` threshold is also
1187
+ * evaluated against the quota window matching that model — additive to the
1188
+ * 5h/7d check. A model with no matching scoped window is unaffected.
1056
1189
  */
1057
- export function killswitchPassesPolicy(quota, storage, accountId) {
1190
+ export function killswitchPassesPolicy(quota, storage, accountId, modelId) {
1058
1191
  if (!isKillswitchEnabled(storage))
1059
1192
  return true;
1060
1193
  const thresholds = getKillswitchThresholdsForAccount(storage, accountId);
@@ -1075,6 +1208,22 @@ export function killswitchPassesPolicy(quota, storage, accountId) {
1075
1208
  if (window.remainingPercent < thresholds[key])
1076
1209
  return false;
1077
1210
  }
1211
+ // Scoped check is additive to the 5h/7d evaluation above and is an
1212
+ // INDEPENDENT block reason — it must run before the unknown-window
1213
+ // fail-closed decision, so an exhausted scoped window blocks even when
1214
+ // 5h/7d is missing/non-finite (the latter only changes the fall-through
1215
+ // for accounts that did not already block on scoped). A missing scoped
1216
+ // window (no carve-out for this model) is not "unknown quota" — only a
1217
+ // PRESENT window at/below threshold blocks. The comparison is inclusive
1218
+ // (`<=`) so the default 0 fires at exhaustion.
1219
+ if (modelId) {
1220
+ const scopedWindow = getScopedQuotaWindowForModel(quota, modelId);
1221
+ if (scopedWindow &&
1222
+ Number.isFinite(scopedWindow.remainingPercent) &&
1223
+ scopedWindow.remainingPercent <= thresholds.scoped) {
1224
+ return false;
1225
+ }
1226
+ }
1078
1227
  if (sawUnknownWindow)
1079
1228
  return !failClosedOnUnknownQuota(storage);
1080
1229
  return true;
@@ -1082,13 +1231,20 @@ export function killswitchPassesPolicy(quota, storage, accountId) {
1082
1231
  /**
1083
1232
  * Find the earliest reset time across all accounts' quota windows.
1084
1233
  * Returns seconds from `now` until that reset, or 300 as a fallback.
1234
+ *
1235
+ * When `scopedModelId` is provided, ONLY the matched scoped window's
1236
+ * `resetsAt` is considered — the 5h/7d resets are intentionally ignored
1237
+ * so the retry hint reflects the weekly reset, not the sooner 5h reset
1238
+ * (which would cause a retry-storm against a block that won't clear for
1239
+ * days). With `scopedModelId` undefined, the 5h/7d behavior is unchanged.
1085
1240
  */
1086
- export function killswitchRetryAfterSeconds(mainQuota, fallbackAccounts, now) {
1241
+ export function killswitchRetryAfterSeconds(mainQuota, fallbackAccounts, now, scopedModelId) {
1087
1242
  const resetTimes = [];
1088
1243
  const allQuotas = [mainQuota, ...fallbackAccounts.map((a) => a.quota)];
1089
1244
  for (const quota of allQuotas) {
1090
- for (const key of ['five_hour', 'seven_day']) {
1091
- const resetStr = quota?.[key]?.resetsAt;
1245
+ if (scopedModelId) {
1246
+ const scopedWindow = getScopedQuotaWindowForModel(quota, scopedModelId);
1247
+ const resetStr = scopedWindow?.resetsAt;
1092
1248
  if (!resetStr)
1093
1249
  continue;
1094
1250
  const resetTime = Date.parse(resetStr);
@@ -1096,6 +1252,17 @@ export function killswitchRetryAfterSeconds(mainQuota, fallbackAccounts, now) {
1096
1252
  resetTimes.push(resetTime);
1097
1253
  }
1098
1254
  }
1255
+ else {
1256
+ for (const key of ['five_hour', 'seven_day']) {
1257
+ const resetStr = quota?.[key]?.resetsAt;
1258
+ if (!resetStr)
1259
+ continue;
1260
+ const resetTime = Date.parse(resetStr);
1261
+ if (Number.isFinite(resetTime) && resetTime > now) {
1262
+ resetTimes.push(resetTime);
1263
+ }
1264
+ }
1265
+ }
1099
1266
  }
1100
1267
  if (!resetTimes.length)
1101
1268
  return 300;
@@ -1226,6 +1393,52 @@ function clampPercent(value) {
1226
1393
  return 100;
1227
1394
  return value;
1228
1395
  }
1396
+ function nonEmptyString(value) {
1397
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
1398
+ }
1399
+ function slugForQuotaIdentity(value) {
1400
+ return value
1401
+ .toLowerCase()
1402
+ .replace(/[^a-z0-9]+/g, '-')
1403
+ .replace(/^-+|-+$/g, '');
1404
+ }
1405
+ function mapScopedWeeklyLimits(limits, checkedAt) {
1406
+ if (!Array.isArray(limits))
1407
+ return undefined;
1408
+ const seen = new Set();
1409
+ const scoped = [];
1410
+ for (const limit of limits) {
1411
+ if (limit?.kind !== 'weekly_scoped' || limit.group !== 'weekly')
1412
+ continue;
1413
+ if (typeof limit.percent !== 'number' || !Number.isFinite(limit.percent)) {
1414
+ continue;
1415
+ }
1416
+ const modelName = nonEmptyString(limit.scope?.model?.display_name);
1417
+ if (!modelName)
1418
+ continue;
1419
+ const identity = nonEmptyString(limit.scope?.model?.id) ?? modelName;
1420
+ const slug = slugForQuotaIdentity(identity);
1421
+ if (!slug)
1422
+ continue;
1423
+ const id = `claude-weekly-scoped-${slug}`;
1424
+ if (seen.has(id))
1425
+ continue;
1426
+ seen.add(id);
1427
+ const usedPercent = clampPercent(limit.percent);
1428
+ const modelId = nonEmptyString(limit.scope?.model?.id);
1429
+ scoped.push({
1430
+ id,
1431
+ title: `${modelName} only`,
1432
+ ...(modelId && { modelId }),
1433
+ modelName,
1434
+ usedPercent,
1435
+ remainingPercent: clampPercent(100 - usedPercent),
1436
+ resetsAt: limit.resets_at,
1437
+ checkedAt,
1438
+ });
1439
+ }
1440
+ return scoped.length ? scoped : undefined;
1441
+ }
1229
1442
  function mapUsageWindow(window, checkedAt) {
1230
1443
  if (typeof window?.utilization !== 'number')
1231
1444
  return undefined;
@@ -1264,6 +1477,7 @@ export async function fetchOAuthQuotaSnapshot(input) {
1264
1477
  return {
1265
1478
  five_hour: mapUsageWindow(usage.five_hour, checkedAt),
1266
1479
  seven_day: mapUsageWindow(usage.seven_day, checkedAt),
1480
+ scoped: mapScopedWeeklyLimits(usage.limits, checkedAt),
1267
1481
  };
1268
1482
  }
1269
1483
  function updateStoredAccount(storage, account) {
@@ -1408,7 +1622,7 @@ export class FallbackAccountManager {
1408
1622
  this.refreshTimer = null;
1409
1623
  this.quotaTimer = null;
1410
1624
  }
1411
- async getUsableFallbackAccounts(existingStorage) {
1625
+ async getUsableFallbackAccounts(existingStorage, options = {}) {
1412
1626
  const storage = existingStorage !== undefined ? existingStorage : await this.load();
1413
1627
  if (!storage)
1414
1628
  return [];
@@ -1444,7 +1658,9 @@ export class FallbackAccountManager {
1444
1658
  // Single source of truth: evaluate quota policy from the unified
1445
1659
  // QuotaManager cache (the same source as the staleness check above) so
1446
1660
  // an active-route refresh that updated only the cache is not ignored.
1447
- if (this.accountPassesQuotaPolicy(this.quotaPolicyAccount(next), storage))
1661
+ if (this.accountPassesQuotaPolicy(this.quotaPolicyAccount(next), storage, {
1662
+ modelId: options.modelId,
1663
+ }))
1448
1664
  usable.push(next);
1449
1665
  }
1450
1666
  catch (error) {
@@ -1453,9 +1669,14 @@ export class FallbackAccountManager {
1453
1669
  accountId: account.id,
1454
1670
  error: formatErrorMessage(error),
1455
1671
  });
1456
- usable.push(account);
1672
+ if (this.accountPassesQuotaPolicy(this.quotaPolicyAccount(account), storage, {
1673
+ modelId: options.modelId,
1674
+ })) {
1675
+ usable.push(account);
1676
+ }
1457
1677
  }
1458
- else if (!failClosedOnUnknownQuota(storage)) {
1678
+ else if (!failClosedOnUnknownQuota(storage) &&
1679
+ quotaSnapshotPassesModelScope(account.quota, options.modelId)) {
1459
1680
  usable.push(account);
1460
1681
  }
1461
1682
  }
@@ -1474,8 +1695,9 @@ export class FallbackAccountManager {
1474
1695
  stored.lastUsed = this.now();
1475
1696
  await this.save(storage);
1476
1697
  }
1477
- accountPassesQuotaPolicy(account, storage) {
1478
- return quotaSnapshotPassesPolicy(account.quota, storage);
1698
+ accountPassesQuotaPolicy(account, storage, options = {}) {
1699
+ return (quotaSnapshotPassesPolicy(account.quota, storage) &&
1700
+ quotaSnapshotPassesModelScope(account.quota, options.modelId));
1479
1701
  }
1480
1702
  /**
1481
1703
  * 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.1",
3
+ "version": "1.13.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",