@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 +1 -1
- package/dist/accounts.d.ts +24 -0
- package/dist/accounts.js +151 -17
- package/dist/cachekeep.d.ts +92 -0
- package/dist/cachekeep.js +297 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
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
|
|
package/dist/accounts.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
358
|
-
|
|
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
|
-
|
|
387
|
-
|
|
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
|
|
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
package/dist/index.js
CHANGED