@cortexkit/anthropic-auth-core 1.1.0 → 1.1.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @cortexkit/anthropic-auth-core
2
2
 
3
- Shared Anthropic OAuth, quota, cache, fast mode, relay, dump, and request-signing helpers used by CortexKit's OpenCode and Pi integrations.
3
+ Shared Anthropic OAuth, quota, cache, cache-keepalive, fast mode, relay, dump, and request-signing helpers used by CortexKit's OpenCode and Pi integrations.
4
4
 
5
5
  User-facing packages:
6
6
 
@@ -20,6 +20,9 @@ export type OAuthAccount = {
20
20
  export type AccountOperationError = {
21
21
  message: string;
22
22
  checkedAt: number;
23
+ nextRetryAt?: number;
24
+ retryCount?: number;
25
+ tokenHash?: string;
23
26
  };
24
27
  export type AccountQuotaWindow = {
25
28
  usedPercent: number;
@@ -38,6 +41,10 @@ export type AccountStorage = {
38
41
  enabled?: boolean;
39
42
  intervalMinutes?: number;
40
43
  refreshBeforeExpiryMinutes?: number;
44
+ mainLastRefreshError?: AccountOperationError;
45
+ mainRefreshLeaseId?: string;
46
+ mainRefreshLeaseUntil?: number;
47
+ mainRefreshLeaseTokenHash?: string;
41
48
  };
42
49
  quota?: {
43
50
  enabled?: boolean;
@@ -55,6 +62,11 @@ export type AccountStorage = {
55
62
  claudeFast?: {
56
63
  enabled?: boolean;
57
64
  };
65
+ cacheKeep?: {
66
+ enabled?: boolean;
67
+ startHour?: number;
68
+ endHour?: number;
69
+ };
58
70
  relay?: {
59
71
  enabled?: boolean;
60
72
  url?: string;
@@ -85,7 +97,19 @@ export declare function isDumpPersistentlyEnabled(storage: AccountStorage | null
85
97
  export declare function setDumpPersistentEnabled(enabled: boolean, path?: string): Promise<AccountStorage>;
86
98
  export declare function isFastModePersistentlyEnabled(storage: AccountStorage | null): boolean;
87
99
  export declare function setFastModePersistentEnabled(enabled: boolean, path?: string): Promise<AccountStorage>;
100
+ export declare function setCacheKeepPersistentWindow(startHour: number, endHour: number, path?: string): Promise<AccountStorage>;
101
+ export declare function setCacheKeepPersistentEnabled(enabled: boolean, path?: string): Promise<AccountStorage>;
88
102
  export declare function shouldFallbackStatus(status: number, storage: AccountStorage | null): boolean;
103
+ export declare function getRefreshIntervalMs(storage: AccountStorage | null): number;
104
+ export declare function hashRefreshToken(refreshToken: string): string;
105
+ export declare function buildRefreshOperationError(input: {
106
+ error: unknown;
107
+ now: number;
108
+ refreshToken: string;
109
+ previous?: AccountOperationError;
110
+ }): AccountOperationError;
111
+ export declare function refreshBackoffActive(error: AccountOperationError | undefined, refreshToken: string | undefined, now: number): boolean;
112
+ export declare function formatRefreshBackoffMessage(error: AccountOperationError, now: number): string;
89
113
  export declare function getQuotaCheckIntervalMs(storage: AccountStorage | null): number;
90
114
  export declare function quotaSnapshotPassesPolicy(quota: OAuthQuotaSnapshot | undefined, storage: AccountStorage | null): boolean;
91
115
  export declare function getQuotaNextRefreshAt(quota: OAuthQuotaSnapshot | undefined, storage: AccountStorage | null, now: number): number;
package/dist/accounts.js CHANGED
@@ -1,13 +1,18 @@
1
- import { randomUUID } from 'node:crypto';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
3
3
  import { homedir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
- import { refreshClaudeOAuthToken } from "./auth.js";
5
+ import { ClaudeOAuthRefreshError, refreshClaudeOAuthToken } from "./auth.js";
6
6
  import { CACHE_1H_MODES, CLAUDE_CODE_VERSION, DEFAULT_CACHE_1H_MODE, } from "./constants.js";
7
+ import { log } from "./logger.js";
7
8
  export const ACCOUNT_FILE_NAME = 'anthropic-auth.json';
8
9
  export const QUOTA_URL = 'https://api.anthropic.com/api/oauth/usage';
9
10
  const DEFAULT_FALLBACK_ON = [401, 403, 429];
10
11
  const DEFAULT_REFRESH_BEFORE_EXPIRY_MINUTES = 30;
12
+ const DEFAULT_REFRESH_INTERVAL_MINUTES = 10;
13
+ const MIN_REFRESH_RETRY_DELAY_MS = 5 * 60_000;
14
+ const MAX_REFRESH_RETRY_DELAY_MS = 60 * 60_000;
15
+ const NON_TRANSIENT_REFRESH_RETRY_DELAY_MS = 24 * 60 * 60_000;
11
16
  const DEFAULT_QUOTA_CHECK_INTERVAL_MINUTES = 5;
12
17
  const DEFAULT_MINIMUM_REMAINING = {
13
18
  five_hour: 0,
@@ -63,7 +68,15 @@ function normalizeOperationError(value) {
63
68
  const checkedAt = Number(value.checkedAt);
64
69
  if (!Number.isFinite(checkedAt))
65
70
  return undefined;
66
- return { message: value.message, checkedAt };
71
+ const nextRetryAt = Number(value.nextRetryAt);
72
+ const retryCount = Number(value.retryCount);
73
+ return {
74
+ message: value.message,
75
+ checkedAt,
76
+ nextRetryAt: Number.isFinite(nextRetryAt) ? nextRetryAt : undefined,
77
+ retryCount: Number.isFinite(retryCount) ? retryCount : undefined,
78
+ tokenHash: typeof value.tokenHash === 'string' ? value.tokenHash : undefined,
79
+ };
67
80
  }
68
81
  function normalizeQuota(value) {
69
82
  if (!isRecord(value))
@@ -104,6 +117,7 @@ function normalizeStorage(value) {
104
117
  claudeCache: isRecord(value.claudeCache) ? value.claudeCache : undefined,
105
118
  dump: isRecord(value.dump) ? value.dump : undefined,
106
119
  claudeFast: isRecord(value.claudeFast) ? value.claudeFast : undefined,
120
+ cacheKeep: isRecord(value.cacheKeep) ? value.cacheKeep : undefined,
107
121
  relay: isRecord(value.relay) ? value.relay : undefined,
108
122
  accounts: value.accounts
109
123
  .map(normalizeAccount)
@@ -217,6 +231,33 @@ export async function setFastModePersistentEnabled(enabled, path = getAccountSto
217
231
  await saveAccounts(storage, path);
218
232
  return storage;
219
233
  }
234
+ export async function setCacheKeepPersistentWindow(startHour, endHour, path = getAccountStoragePath()) {
235
+ const storage = (await loadAccounts(path)) ?? {
236
+ version: 1,
237
+ main: { type: 'opencode', provider: 'anthropic' },
238
+ accounts: [],
239
+ };
240
+ storage.cacheKeep = {
241
+ enabled: true,
242
+ startHour,
243
+ endHour,
244
+ };
245
+ await saveAccounts(storage, path);
246
+ return storage;
247
+ }
248
+ export async function setCacheKeepPersistentEnabled(enabled, path = getAccountStoragePath()) {
249
+ const storage = (await loadAccounts(path)) ?? {
250
+ version: 1,
251
+ main: { type: 'opencode', provider: 'anthropic' },
252
+ accounts: [],
253
+ };
254
+ storage.cacheKeep = {
255
+ ...(storage.cacheKeep ?? {}),
256
+ enabled,
257
+ };
258
+ await saveAccounts(storage, path);
259
+ return storage;
260
+ }
220
261
  function getFallbackStatuses(storage) {
221
262
  return storage?.fallbackOn?.length ? storage.fallbackOn : DEFAULT_FALLBACK_ON;
222
263
  }
@@ -245,6 +286,54 @@ function refreshBeforeExpiryMs(storage) {
245
286
  DEFAULT_REFRESH_BEFORE_EXPIRY_MINUTES;
246
287
  return Math.max(0, minutes) * 60_000;
247
288
  }
289
+ export function getRefreshIntervalMs(storage) {
290
+ const minutes = storage?.refresh?.intervalMinutes ?? DEFAULT_REFRESH_INTERVAL_MINUTES;
291
+ return Math.max(1, minutes) * 60_000;
292
+ }
293
+ export function hashRefreshToken(refreshToken) {
294
+ return createHash('sha256').update(refreshToken).digest('hex');
295
+ }
296
+ function isTransientRefreshError(error) {
297
+ if (error instanceof ClaudeOAuthRefreshError) {
298
+ return error.status === 429 || error.status >= 500;
299
+ }
300
+ if (!(error instanceof Error))
301
+ return false;
302
+ return (error.message.includes('fetch failed') ||
303
+ ('code' in error &&
304
+ (error.code === 'ECONNRESET' ||
305
+ error.code === 'ECONNREFUSED' ||
306
+ error.code === 'ETIMEDOUT' ||
307
+ error.code === 'UND_ERR_CONNECT_TIMEOUT')));
308
+ }
309
+ export function buildRefreshOperationError(input) {
310
+ const tokenHash = hashRefreshToken(input.refreshToken);
311
+ const previousRetryCount = input.previous?.tokenHash === tokenHash
312
+ ? (input.previous.retryCount ?? 0)
313
+ : 0;
314
+ const retryCount = previousRetryCount + 1;
315
+ const delay = isTransientRefreshError(input.error)
316
+ ? Math.min(MAX_REFRESH_RETRY_DELAY_MS, MIN_REFRESH_RETRY_DELAY_MS * 2 ** Math.min(retryCount - 1, 6))
317
+ : NON_TRANSIENT_REFRESH_RETRY_DELAY_MS;
318
+ return {
319
+ message: formatErrorMessage(input.error),
320
+ checkedAt: input.now,
321
+ nextRetryAt: input.now + delay,
322
+ retryCount,
323
+ tokenHash,
324
+ };
325
+ }
326
+ export function refreshBackoffActive(error, refreshToken, now) {
327
+ if (!error?.nextRetryAt || error.nextRetryAt <= now)
328
+ return false;
329
+ if (!refreshToken)
330
+ return true;
331
+ return error.tokenHash === hashRefreshToken(refreshToken);
332
+ }
333
+ export function formatRefreshBackoffMessage(error, now) {
334
+ const seconds = Math.max(1, Math.ceil(((error.nextRetryAt ?? now) - now) / 1000));
335
+ return `Claude OAuth refresh is backed off for ${seconds}s after: ${error.message}`;
336
+ }
248
337
  export function getQuotaCheckIntervalMs(storage) {
249
338
  const minutes = storage?.quota?.checkIntervalMinutes ?? DEFAULT_QUOTA_CHECK_INTERVAL_MINUTES;
250
339
  return Math.max(1, minutes) * 60_000;
@@ -353,16 +442,21 @@ function formatErrorMessage(error) {
353
442
  return error instanceof Error ? error.message : String(error);
354
443
  }
355
444
  function recordRefreshError(account, error, now) {
356
- account.lastRefreshError = {
357
- message: formatErrorMessage(error),
358
- checkedAt: now,
359
- };
445
+ account.lastRefreshError = buildRefreshOperationError({
446
+ error,
447
+ now,
448
+ refreshToken: account.refresh,
449
+ previous: account.lastRefreshError,
450
+ });
360
451
  }
361
452
  function recordQuotaRefreshError(account, error, now) {
362
453
  account.lastQuotaRefreshError = {
363
454
  message: formatErrorMessage(error),
364
455
  checkedAt: now,
365
456
  };
457
+ if (error instanceof ClaudeOAuthRefreshError) {
458
+ recordRefreshError(account, error, now);
459
+ }
366
460
  }
367
461
  export class FallbackAccountManager {
368
462
  now;
@@ -383,22 +477,18 @@ export class FallbackAccountManager {
383
477
  await saveAccounts(storage, this.configPath);
384
478
  }
385
479
  startBackgroundRefresh() {
386
- void this.refreshDueAccounts().catch(() => { });
387
- void this.refreshQuotaForDueAccounts().catch(() => { });
480
+ const run = async () => {
481
+ await this.refreshDueAccounts();
482
+ await this.refreshQuotaForDueAccounts();
483
+ };
484
+ void run().catch(() => { });
388
485
  if (!this.refreshTimer) {
389
486
  this.refreshTimer = setInterval(() => {
390
- void this.refreshDueAccounts().catch(() => { });
487
+ void run().catch(() => { });
391
488
  }, BACKGROUND_TICK_MS);
392
489
  if ('unref' in this.refreshTimer)
393
490
  this.refreshTimer.unref();
394
491
  }
395
- if (!this.quotaTimer) {
396
- this.quotaTimer = setInterval(() => {
397
- void this.refreshQuotaForDueAccounts().catch(() => { });
398
- }, BACKGROUND_TICK_MS);
399
- if ('unref' in this.quotaTimer)
400
- this.quotaTimer.unref();
401
- }
402
492
  }
403
493
  stopBackgroundRefresh() {
404
494
  if (this.refreshTimer)
@@ -420,6 +510,11 @@ export class FallbackAccountManager {
420
510
  try {
421
511
  let next = account;
422
512
  if (tokenNeedsRefresh(next, storage, this.now())) {
513
+ const refreshError = next.lastRefreshError;
514
+ if (refreshError &&
515
+ refreshBackoffActive(refreshError, next.refresh, this.now())) {
516
+ throw new Error(formatRefreshBackoffMessage(refreshError, this.now()));
517
+ }
423
518
  next = await this.refreshAccount(next, storage);
424
519
  changed = true;
425
520
  }
@@ -462,11 +557,29 @@ export class FallbackAccountManager {
462
557
  continue;
463
558
  if (!tokenNeedsRefresh(account, storage, this.now()))
464
559
  continue;
560
+ if (refreshBackoffActive(account.lastRefreshError, account.refresh, this.now())) {
561
+ log('[refresh] fallback oauth skipped backoff', {
562
+ accountId: account.id,
563
+ nextRetryAt: account.lastRefreshError?.nextRetryAt,
564
+ retryCount: account.lastRefreshError?.retryCount,
565
+ });
566
+ continue;
567
+ }
465
568
  try {
569
+ log('[refresh] fallback oauth background due', {
570
+ accountId: account.id,
571
+ expiresInMs: account.expires
572
+ ? account.expires - this.now()
573
+ : undefined,
574
+ });
466
575
  await this.refreshAccount(account, storage);
467
576
  changed = true;
468
577
  }
469
578
  catch (error) {
579
+ log('[refresh] fallback oauth background failed', {
580
+ accountId: account.id,
581
+ error: error instanceof Error ? error.message : String(error),
582
+ });
470
583
  recordRefreshError(account, error, this.now());
471
584
  updateStoredAccount(storage, account);
472
585
  changed = true;
@@ -487,6 +600,9 @@ export class FallbackAccountManager {
487
600
  let next = account;
488
601
  try {
489
602
  if (tokenNeedsRefresh(next, storage, this.now())) {
603
+ if (refreshBackoffActive(next.lastRefreshError, next.refresh, this.now())) {
604
+ continue;
605
+ }
490
606
  next = await this.refreshAccount(next, storage);
491
607
  changed = true;
492
608
  }
@@ -517,6 +633,11 @@ export class FallbackAccountManager {
517
633
  let next = account;
518
634
  try {
519
635
  if (tokenNeedsRefresh(next, storage, this.now())) {
636
+ const refreshError = next.lastRefreshError;
637
+ if (refreshError &&
638
+ refreshBackoffActive(refreshError, next.refresh, this.now())) {
639
+ throw new Error(formatRefreshBackoffMessage(refreshError, this.now()));
640
+ }
520
641
  next = await this.refreshAccount(next, storage);
521
642
  changed = true;
522
643
  }
@@ -563,6 +684,13 @@ export class FallbackAccountManager {
563
684
  }
564
685
  const sourceAccount = latestAccount ?? account;
565
686
  const refreshToken = sourceAccount.refresh;
687
+ log('[refresh] fallback oauth refresh request start', {
688
+ accountId: sourceAccount.id,
689
+ force: options.force === true,
690
+ expiresInMs: sourceAccount.expires
691
+ ? sourceAccount.expires - this.now()
692
+ : undefined,
693
+ });
566
694
  const refreshed = await refreshClaudeOAuthToken({
567
695
  refreshToken,
568
696
  fetchImpl: this.fetchImpl,
@@ -575,6 +703,12 @@ export class FallbackAccountManager {
575
703
  refreshed.expires - refreshed.expiresIn * 1000;
576
704
  sourceAccount.lastRefreshError = undefined;
577
705
  updateStoredAccount(storage, sourceAccount);
706
+ log('[refresh] fallback oauth refresh succeeded', {
707
+ accountId: sourceAccount.id,
708
+ expiresInMs: sourceAccount.expires
709
+ ? sourceAccount.expires - this.now()
710
+ : undefined,
711
+ });
578
712
  return sourceAccount;
579
713
  }
580
714
  async refreshAccountQuota(account, storage) {
@@ -0,0 +1,92 @@
1
+ import type { AccountStorage } from './accounts.ts';
2
+ export declare const CLAUDE_CACHE_KEEP_COMMAND_NAME = "claude-cachekeep";
3
+ export declare const CACHE_KEEP_TTL_MS: number;
4
+ export declare const CACHE_KEEP_PREWARM_LEAD_MS: number;
5
+ export declare const CACHE_KEEP_TICK_MS = 60000;
6
+ export declare const CACHE_KEEP_EXTENDED_TTL_BETA = "extended-cache-ttl-2025-04-11";
7
+ export type CacheKeepWindow = {
8
+ startHour: number;
9
+ endHour: number;
10
+ };
11
+ export type CacheKeepCommandAction = {
12
+ type: 'status';
13
+ } | {
14
+ type: 'disable';
15
+ } | {
16
+ type: 'window';
17
+ startHour: number;
18
+ endHour: number;
19
+ } | {
20
+ type: 'usage';
21
+ };
22
+ export type CacheKeepStatus = {
23
+ enabled: boolean;
24
+ window?: CacheKeepWindow;
25
+ trackedSessions?: number;
26
+ nextPrewarmAt?: number;
27
+ hybridActive?: boolean;
28
+ };
29
+ export declare function parseCacheKeepCommandAction(input: string): CacheKeepCommandAction;
30
+ export declare function getCacheKeepWindow(storage: AccountStorage | null): CacheKeepWindow | undefined;
31
+ export declare function isCacheKeepPersistentlyEnabled(storage: AccountStorage | null): boolean;
32
+ export declare function isCacheKeepHybridActive(storage: AccountStorage | null): boolean;
33
+ export declare function isWithinCacheKeepWindow(window: CacheKeepWindow | undefined, now?: Date): boolean;
34
+ export declare function localDayKey(now?: Date): string;
35
+ export declare function buildCacheKeepStatusSummary(status: CacheKeepStatus): string;
36
+ export declare function executeCacheKeepCommand(input: {
37
+ argumentsText: string;
38
+ enabled?: boolean;
39
+ window?: CacheKeepWindow;
40
+ trackedSessions?: number;
41
+ nextPrewarmAt?: number;
42
+ hybridActive?: boolean;
43
+ }): string;
44
+ export declare function buildCacheKeepPrewarmBody(bodyText: string): Promise<{
45
+ ok: true;
46
+ bodyText: string;
47
+ } | {
48
+ ok: false;
49
+ reason: string;
50
+ }>;
51
+ export type CacheKeepTarget = {
52
+ id: string;
53
+ url: string;
54
+ headers: Record<string, string>;
55
+ bodyText: string;
56
+ cacheExpiresAt: number;
57
+ dayKey: string;
58
+ };
59
+ export declare class CacheKeepManager {
60
+ private readonly options;
61
+ private readonly targets;
62
+ private timer;
63
+ constructor(options: {
64
+ loadStorage: () => Promise<AccountStorage | null>;
65
+ fetchImpl?: typeof fetch;
66
+ now?: () => number;
67
+ prepareHeaders?: (headers: Headers, target: CacheKeepTarget) => Promise<Headers> | Headers;
68
+ log?: (message: string, data?: Record<string, unknown>) => void;
69
+ });
70
+ start(): void;
71
+ stop(): void;
72
+ stats(window?: CacheKeepWindow, now?: number): {
73
+ trackedSessions: number;
74
+ nextPrewarmAt: number | undefined;
75
+ };
76
+ track(input: {
77
+ sessionId?: string | null;
78
+ url: string;
79
+ headers: Headers;
80
+ bodyText: string;
81
+ storage: AccountStorage | null;
82
+ cacheMode: string;
83
+ }): {
84
+ tracked: boolean;
85
+ reason: string;
86
+ } | {
87
+ tracked: boolean;
88
+ reason?: undefined;
89
+ };
90
+ tick(): Promise<void>;
91
+ private prewarm;
92
+ }
@@ -0,0 +1,297 @@
1
+ import { CCH_PATTERN, signRequestBody } from "./cch.js";
2
+ import { orderClaudeCodeBody } from "./claude-code.js";
3
+ export const CLAUDE_CACHE_KEEP_COMMAND_NAME = 'claude-cachekeep';
4
+ export const CACHE_KEEP_TTL_MS = 60 * 60_000;
5
+ export const CACHE_KEEP_PREWARM_LEAD_MS = 5 * 60_000;
6
+ export const CACHE_KEEP_TICK_MS = 60_000;
7
+ export const CACHE_KEEP_EXTENDED_TTL_BETA = 'extended-cache-ttl-2025-04-11';
8
+ const STATUS_TITLE = '## Claude Cache Keep Status';
9
+ const ENABLED_TITLE = '## Claude Cache Keep Enabled';
10
+ const DISABLED_TITLE = '## Claude Cache Keep Disabled';
11
+ const USAGE_TITLE = '## Claude Cache Keep Usage';
12
+ const USAGE = 'Usage: `/claude-cachekeep`, `/claude-cachekeep off`, or `/claude-cachekeep HH-HH`.';
13
+ function padHour(hour) {
14
+ return String(hour).padStart(2, '0');
15
+ }
16
+ export function parseCacheKeepCommandAction(input) {
17
+ const trimmed = input.trim();
18
+ if (!trimmed)
19
+ return { type: 'status' };
20
+ if (trimmed === 'off')
21
+ return { type: 'disable' };
22
+ const match = /^(\d{1,2})-(\d{1,2})$/.exec(trimmed);
23
+ if (!match)
24
+ return { type: 'usage' };
25
+ const startHour = Number(match[1]);
26
+ const endHour = Number(match[2]);
27
+ if (!Number.isInteger(startHour) ||
28
+ !Number.isInteger(endHour) ||
29
+ startHour < 0 ||
30
+ startHour > 23 ||
31
+ endHour < 0 ||
32
+ endHour > 23 ||
33
+ startHour === endHour) {
34
+ return { type: 'usage' };
35
+ }
36
+ return { type: 'window', startHour, endHour };
37
+ }
38
+ export function getCacheKeepWindow(storage) {
39
+ const startHour = Number(storage?.cacheKeep?.startHour);
40
+ const endHour = Number(storage?.cacheKeep?.endHour);
41
+ if (!Number.isInteger(startHour) ||
42
+ !Number.isInteger(endHour) ||
43
+ startHour < 0 ||
44
+ startHour > 23 ||
45
+ endHour < 0 ||
46
+ endHour > 23 ||
47
+ startHour === endHour) {
48
+ return undefined;
49
+ }
50
+ return { startHour, endHour };
51
+ }
52
+ export function isCacheKeepPersistentlyEnabled(storage) {
53
+ return storage?.cacheKeep?.enabled === true && !!getCacheKeepWindow(storage);
54
+ }
55
+ export function isCacheKeepHybridActive(storage) {
56
+ return (isCacheKeepPersistentlyEnabled(storage) &&
57
+ storage?.claudeCache?.enabled === true &&
58
+ storage.claudeCache.mode === 'hybrid');
59
+ }
60
+ export function isWithinCacheKeepWindow(window, now = new Date()) {
61
+ if (!window)
62
+ return false;
63
+ const hour = now.getHours();
64
+ if (window.startHour < window.endHour) {
65
+ return hour >= window.startHour && hour < window.endHour;
66
+ }
67
+ return hour >= window.startHour || hour < window.endHour;
68
+ }
69
+ export function localDayKey(now = new Date()) {
70
+ return `${now.getFullYear()}-${padHour(now.getMonth() + 1)}-${padHour(now.getDate())}`;
71
+ }
72
+ function localWindowKey(window, now = new Date()) {
73
+ if (window &&
74
+ window.startHour > window.endHour &&
75
+ now.getHours() < window.endHour) {
76
+ const previous = new Date(now);
77
+ previous.setDate(previous.getDate() - 1);
78
+ return localDayKey(previous);
79
+ }
80
+ return localDayKey(now);
81
+ }
82
+ export function buildCacheKeepStatusSummary(status) {
83
+ const window = status.window
84
+ ? `${padHour(status.window.startHour)}-${padHour(status.window.endHour)}`
85
+ : 'not configured';
86
+ const lines = [
87
+ `Cache keep: ${status.enabled ? 'enabled' : 'disabled'}`,
88
+ `Window: ${window}`,
89
+ 'Mode requirement: `/claude-cache mode hybrid` must be active.',
90
+ ];
91
+ if (typeof status.hybridActive === 'boolean') {
92
+ lines.push(`Hybrid active: ${status.hybridActive ? 'yes' : 'no'}`);
93
+ }
94
+ if (typeof status.trackedSessions === 'number') {
95
+ lines.push(`Tracked sessions: ${status.trackedSessions}`);
96
+ }
97
+ if (status.nextPrewarmAt) {
98
+ lines.push(`Next prewarm: ${new Date(status.nextPrewarmAt).toLocaleString()}`);
99
+ }
100
+ return lines.join('\n');
101
+ }
102
+ export function executeCacheKeepCommand(input) {
103
+ const action = parseCacheKeepCommandAction(input.argumentsText);
104
+ const status = {
105
+ enabled: input.enabled ?? false,
106
+ window: input.window,
107
+ trackedSessions: input.trackedSessions,
108
+ nextPrewarmAt: input.nextPrewarmAt,
109
+ hybridActive: input.hybridActive,
110
+ };
111
+ if (action.type === 'status') {
112
+ return [STATUS_TITLE, '', buildCacheKeepStatusSummary(status)].join('\n');
113
+ }
114
+ if (action.type === 'disable') {
115
+ return [
116
+ DISABLED_TITLE,
117
+ '',
118
+ buildCacheKeepStatusSummary({ ...status, enabled: false }),
119
+ ].join('\n');
120
+ }
121
+ if (action.type === 'window') {
122
+ return [
123
+ ENABLED_TITLE,
124
+ '',
125
+ buildCacheKeepStatusSummary({
126
+ ...status,
127
+ enabled: true,
128
+ window: { startHour: action.startHour, endHour: action.endHour },
129
+ }),
130
+ ].join('\n');
131
+ }
132
+ return [USAGE_TITLE, '', USAGE, '', buildCacheKeepStatusSummary(status)].join('\n');
133
+ }
134
+ function hasExplicitCacheControl(value) {
135
+ if (!value || typeof value !== 'object')
136
+ return false;
137
+ if (Array.isArray(value))
138
+ return value.some(hasExplicitCacheControl);
139
+ const record = value;
140
+ const cacheControl = record.cache_control;
141
+ if (cacheControl &&
142
+ typeof cacheControl === 'object' &&
143
+ cacheControl.type === 'ephemeral') {
144
+ return true;
145
+ }
146
+ return Object.values(record).some(hasExplicitCacheControl);
147
+ }
148
+ export async function buildCacheKeepPrewarmBody(bodyText) {
149
+ let body;
150
+ try {
151
+ body = JSON.parse(bodyText);
152
+ }
153
+ catch {
154
+ return { ok: false, reason: 'body is not valid JSON' };
155
+ }
156
+ if (!hasExplicitCacheControl(body)) {
157
+ return { ok: false, reason: 'body has no explicit cache breakpoints' };
158
+ }
159
+ const warm = structuredClone(body);
160
+ warm.max_tokens = 0;
161
+ delete warm.stream;
162
+ const thinking = warm.thinking;
163
+ if (thinking?.type === 'enabled')
164
+ delete warm.thinking;
165
+ const outputConfig = warm.output_config;
166
+ if (outputConfig?.format)
167
+ delete warm.output_config;
168
+ const toolChoice = warm.tool_choice;
169
+ if (toolChoice?.type === 'tool' || toolChoice?.type === 'any') {
170
+ delete warm.tool_choice;
171
+ }
172
+ const unsignedBodyText = JSON.stringify(orderClaudeCodeBody(warm)).replace(CCH_PATTERN, 'cch=00000;');
173
+ const signedBodyText = await signRequestBody(unsignedBodyText);
174
+ return { ok: true, bodyText: signedBodyText };
175
+ }
176
+ export class CacheKeepManager {
177
+ options;
178
+ targets = new Map();
179
+ timer = null;
180
+ constructor(options) {
181
+ this.options = options;
182
+ }
183
+ start() {
184
+ if (this.timer)
185
+ return;
186
+ this.timer = setInterval(() => {
187
+ void this.tick().catch((error) => {
188
+ this.options.log?.('[cachekeep] tick failed', {
189
+ error: error instanceof Error ? error.message : String(error),
190
+ });
191
+ });
192
+ }, CACHE_KEEP_TICK_MS);
193
+ if ('unref' in this.timer)
194
+ this.timer.unref();
195
+ }
196
+ stop() {
197
+ if (this.timer)
198
+ clearInterval(this.timer);
199
+ this.timer = null;
200
+ }
201
+ stats(window, now = this.options.now?.() ?? Date.now()) {
202
+ const today = localWindowKey(window, new Date(now));
203
+ const targets = [...this.targets.values()].filter((target) => target.dayKey === today);
204
+ const nextPrewarmAt = targets.length
205
+ ? Math.min(...targets.map((target) => target.cacheExpiresAt - CACHE_KEEP_PREWARM_LEAD_MS))
206
+ : undefined;
207
+ return { trackedSessions: targets.length, nextPrewarmAt };
208
+ }
209
+ track(input) {
210
+ if (!input.sessionId)
211
+ return { tracked: false, reason: 'missing session id' };
212
+ if (!isCacheKeepPersistentlyEnabled(input.storage)) {
213
+ return { tracked: false, reason: 'cachekeep disabled' };
214
+ }
215
+ if (input.cacheMode !== 'hybrid') {
216
+ return { tracked: false, reason: 'cache mode is not hybrid' };
217
+ }
218
+ const now = this.options.now?.() ?? Date.now();
219
+ const window = getCacheKeepWindow(input.storage);
220
+ if (!isWithinCacheKeepWindow(window, new Date(now))) {
221
+ return { tracked: false, reason: 'outside configured window' };
222
+ }
223
+ const headers = {};
224
+ input.headers.forEach((value, key) => {
225
+ headers[key] = value;
226
+ });
227
+ this.targets.set(input.sessionId, {
228
+ id: input.sessionId,
229
+ url: input.url,
230
+ headers,
231
+ bodyText: input.bodyText,
232
+ cacheExpiresAt: now + CACHE_KEEP_TTL_MS,
233
+ dayKey: localWindowKey(window, new Date(now)),
234
+ });
235
+ this.start();
236
+ return { tracked: true };
237
+ }
238
+ async tick() {
239
+ const storage = await this.options.loadStorage();
240
+ const window = getCacheKeepWindow(storage);
241
+ const now = this.options.now?.() ?? Date.now();
242
+ const today = localWindowKey(window, new Date(now));
243
+ for (const [id, target] of this.targets) {
244
+ if (target.dayKey !== today)
245
+ this.targets.delete(id);
246
+ }
247
+ if (!this.targets.size) {
248
+ this.stop();
249
+ return;
250
+ }
251
+ if (!isCacheKeepHybridActive(storage))
252
+ return;
253
+ if (!isWithinCacheKeepWindow(window, new Date(now)))
254
+ return;
255
+ const dueAt = now + CACHE_KEEP_PREWARM_LEAD_MS;
256
+ for (const target of this.targets.values()) {
257
+ if (target.cacheExpiresAt > dueAt)
258
+ continue;
259
+ await this.prewarm(target, now);
260
+ }
261
+ }
262
+ async prewarm(target, now) {
263
+ const prewarm = await buildCacheKeepPrewarmBody(target.bodyText);
264
+ if (!prewarm.ok) {
265
+ this.options.log?.('[cachekeep] prewarm skipped', {
266
+ session: target.id,
267
+ reason: prewarm.reason,
268
+ });
269
+ this.targets.delete(target.id);
270
+ return;
271
+ }
272
+ const fetchImpl = this.options.fetchImpl ?? fetch;
273
+ const prewarmTarget = { ...target, bodyText: prewarm.bodyText };
274
+ const headers = this.options.prepareHeaders
275
+ ? await this.options.prepareHeaders(new Headers(target.headers), prewarmTarget)
276
+ : new Headers(target.headers);
277
+ headers.delete('content-length');
278
+ headers.delete('transfer-encoding');
279
+ const response = await fetchImpl(target.url, {
280
+ method: 'POST',
281
+ headers,
282
+ body: prewarm.bodyText,
283
+ });
284
+ if (!response.ok) {
285
+ this.options.log?.('[cachekeep] prewarm failed', {
286
+ session: target.id,
287
+ status: response.status,
288
+ body: await response.text().catch(() => ''),
289
+ });
290
+ target.cacheExpiresAt = now + CACHE_KEEP_PREWARM_LEAD_MS + 5 * 60_000;
291
+ return;
292
+ }
293
+ await response.body?.cancel().catch(() => { });
294
+ target.cacheExpiresAt = now + CACHE_KEEP_TTL_MS;
295
+ this.options.log?.('[cachekeep] prewarm succeeded', { session: target.id });
296
+ }
297
+ }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from './accounts.ts';
2
2
  export * from './auth.ts';
3
3
  export * from './cache1h.ts';
4
+ export * from './cachekeep.ts';
4
5
  export * from './cch.ts';
5
6
  export * from './claude-code.ts';
6
7
  export * from './constants.ts';
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from "./accounts.js";
2
2
  export * from "./auth.js";
3
3
  export * from "./cache1h.js";
4
+ export * from "./cachekeep.js";
4
5
  export * from "./cch.js";
5
6
  export * from "./claude-code.js";
6
7
  export * from "./constants.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/anthropic-auth-core",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",