@cortexkit/anthropic-auth-core 1.1.1 → 1.1.3
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 +15 -0
- package/dist/accounts.js +112 -5
- 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
|
@@ -62,6 +62,11 @@ export type AccountStorage = {
|
|
|
62
62
|
claudeFast?: {
|
|
63
63
|
enabled?: boolean;
|
|
64
64
|
};
|
|
65
|
+
cacheKeep?: {
|
|
66
|
+
enabled?: boolean;
|
|
67
|
+
startHour?: number;
|
|
68
|
+
endHour?: number;
|
|
69
|
+
};
|
|
65
70
|
relay?: {
|
|
66
71
|
enabled?: boolean;
|
|
67
72
|
url?: string;
|
|
@@ -84,6 +89,14 @@ export type AccountRefreshError = {
|
|
|
84
89
|
export declare function getAccountStoragePath(): string;
|
|
85
90
|
export declare function loadAccounts(path?: string): Promise<AccountStorage | null>;
|
|
86
91
|
export declare function saveAccounts(storage: AccountStorage, path?: string): Promise<void>;
|
|
92
|
+
export declare function acquireRefreshFileLock(options: {
|
|
93
|
+
name: string;
|
|
94
|
+
ttlMs: number;
|
|
95
|
+
path?: string;
|
|
96
|
+
now?: () => number;
|
|
97
|
+
}): Promise<{
|
|
98
|
+
release: () => Promise<void>;
|
|
99
|
+
} | null>;
|
|
87
100
|
export declare function isCache1hPersistentlyEnabled(storage: AccountStorage | null): boolean;
|
|
88
101
|
export declare function getCache1hPersistentMode(storage: AccountStorage | null): Cache1hMode;
|
|
89
102
|
export declare function setCache1hPersistentEnabled(enabled: boolean, mode?: Cache1hMode, path?: string): Promise<AccountStorage>;
|
|
@@ -92,6 +105,8 @@ export declare function isDumpPersistentlyEnabled(storage: AccountStorage | null
|
|
|
92
105
|
export declare function setDumpPersistentEnabled(enabled: boolean, path?: string): Promise<AccountStorage>;
|
|
93
106
|
export declare function isFastModePersistentlyEnabled(storage: AccountStorage | null): boolean;
|
|
94
107
|
export declare function setFastModePersistentEnabled(enabled: boolean, path?: string): Promise<AccountStorage>;
|
|
108
|
+
export declare function setCacheKeepPersistentWindow(startHour: number, endHour: number, path?: string): Promise<AccountStorage>;
|
|
109
|
+
export declare function setCacheKeepPersistentEnabled(enabled: boolean, path?: string): Promise<AccountStorage>;
|
|
95
110
|
export declare function shouldFallbackStatus(status: number, storage: AccountStorage | null): boolean;
|
|
96
111
|
export declare function getRefreshIntervalMs(storage: AccountStorage | null): number;
|
|
97
112
|
export declare function hashRefreshToken(refreshToken: string): string;
|
package/dist/accounts.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
-
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
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
|
-
const
|
|
11
|
+
const MIN_REFRESH_BEFORE_EXPIRY_MINUTES = 240;
|
|
12
|
+
const DEFAULT_REFRESH_BEFORE_EXPIRY_MINUTES = MIN_REFRESH_BEFORE_EXPIRY_MINUTES;
|
|
11
13
|
const DEFAULT_REFRESH_INTERVAL_MINUTES = 10;
|
|
12
14
|
const MIN_REFRESH_RETRY_DELAY_MS = 5 * 60_000;
|
|
13
15
|
const MAX_REFRESH_RETRY_DELAY_MS = 60 * 60_000;
|
|
@@ -116,6 +118,7 @@ function normalizeStorage(value) {
|
|
|
116
118
|
claudeCache: isRecord(value.claudeCache) ? value.claudeCache : undefined,
|
|
117
119
|
dump: isRecord(value.dump) ? value.dump : undefined,
|
|
118
120
|
claudeFast: isRecord(value.claudeFast) ? value.claudeFast : undefined,
|
|
121
|
+
cacheKeep: isRecord(value.cacheKeep) ? value.cacheKeep : undefined,
|
|
119
122
|
relay: isRecord(value.relay) ? value.relay : undefined,
|
|
120
123
|
accounts: value.accounts
|
|
121
124
|
.map(normalizeAccount)
|
|
@@ -157,6 +160,51 @@ export async function saveAccounts(storage, path = getAccountStoragePath()) {
|
|
|
157
160
|
});
|
|
158
161
|
await rename(tempPath, path);
|
|
159
162
|
}
|
|
163
|
+
export async function acquireRefreshFileLock(options) {
|
|
164
|
+
const accountPath = options.path ?? getAccountStoragePath();
|
|
165
|
+
const lockDir = `${accountPath}.${options.name}.lock`;
|
|
166
|
+
const ownerPath = join(lockDir, 'owner.json');
|
|
167
|
+
const ownerId = randomUUID();
|
|
168
|
+
const now = options.now ?? Date.now;
|
|
169
|
+
async function tryAcquire() {
|
|
170
|
+
try {
|
|
171
|
+
await mkdir(lockDir, { mode: 0o700 });
|
|
172
|
+
await writeFile(ownerPath, `${JSON.stringify({ ownerId, expiresAt: now() + options.ttlMs })}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
if (error.code !== 'EEXIST')
|
|
177
|
+
throw error;
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (!(await tryAcquire())) {
|
|
182
|
+
try {
|
|
183
|
+
const owner = JSON.parse(await readFile(ownerPath, 'utf8'));
|
|
184
|
+
if (Number(owner?.expiresAt) > now())
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
// Broken owner metadata is treated as stale.
|
|
189
|
+
}
|
|
190
|
+
await rm(lockDir, { recursive: true, force: true }).catch(() => { });
|
|
191
|
+
if (!(await tryAcquire()))
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
release: async () => {
|
|
196
|
+
try {
|
|
197
|
+
const owner = JSON.parse(await readFile(ownerPath, 'utf8'));
|
|
198
|
+
if (owner?.ownerId !== ownerId)
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
await rm(lockDir, { recursive: true, force: true }).catch(() => { });
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
160
208
|
export function isCache1hPersistentlyEnabled(storage) {
|
|
161
209
|
return storage?.claudeCache?.enabled === true;
|
|
162
210
|
}
|
|
@@ -229,6 +277,33 @@ export async function setFastModePersistentEnabled(enabled, path = getAccountSto
|
|
|
229
277
|
await saveAccounts(storage, path);
|
|
230
278
|
return storage;
|
|
231
279
|
}
|
|
280
|
+
export async function setCacheKeepPersistentWindow(startHour, endHour, path = getAccountStoragePath()) {
|
|
281
|
+
const storage = (await loadAccounts(path)) ?? {
|
|
282
|
+
version: 1,
|
|
283
|
+
main: { type: 'opencode', provider: 'anthropic' },
|
|
284
|
+
accounts: [],
|
|
285
|
+
};
|
|
286
|
+
storage.cacheKeep = {
|
|
287
|
+
enabled: true,
|
|
288
|
+
startHour,
|
|
289
|
+
endHour,
|
|
290
|
+
};
|
|
291
|
+
await saveAccounts(storage, path);
|
|
292
|
+
return storage;
|
|
293
|
+
}
|
|
294
|
+
export async function setCacheKeepPersistentEnabled(enabled, path = getAccountStoragePath()) {
|
|
295
|
+
const storage = (await loadAccounts(path)) ?? {
|
|
296
|
+
version: 1,
|
|
297
|
+
main: { type: 'opencode', provider: 'anthropic' },
|
|
298
|
+
accounts: [],
|
|
299
|
+
};
|
|
300
|
+
storage.cacheKeep = {
|
|
301
|
+
...(storage.cacheKeep ?? {}),
|
|
302
|
+
enabled,
|
|
303
|
+
};
|
|
304
|
+
await saveAccounts(storage, path);
|
|
305
|
+
return storage;
|
|
306
|
+
}
|
|
232
307
|
function getFallbackStatuses(storage) {
|
|
233
308
|
return storage?.fallbackOn?.length ? storage.fallbackOn : DEFAULT_FALLBACK_ON;
|
|
234
309
|
}
|
|
@@ -255,7 +330,7 @@ function refreshEnabled(storage) {
|
|
|
255
330
|
function refreshBeforeExpiryMs(storage) {
|
|
256
331
|
const minutes = storage?.refresh?.refreshBeforeExpiryMinutes ??
|
|
257
332
|
DEFAULT_REFRESH_BEFORE_EXPIRY_MINUTES;
|
|
258
|
-
return Math.max(
|
|
333
|
+
return Math.max(MIN_REFRESH_BEFORE_EXPIRY_MINUTES, minutes) * 60_000;
|
|
259
334
|
}
|
|
260
335
|
export function getRefreshIntervalMs(storage) {
|
|
261
336
|
const minutes = storage?.refresh?.intervalMinutes ?? DEFAULT_REFRESH_INTERVAL_MINUTES;
|
|
@@ -278,7 +353,11 @@ function isTransientRefreshError(error) {
|
|
|
278
353
|
error.code === 'UND_ERR_CONNECT_TIMEOUT')));
|
|
279
354
|
}
|
|
280
355
|
export function buildRefreshOperationError(input) {
|
|
281
|
-
const
|
|
356
|
+
const tokenHash = hashRefreshToken(input.refreshToken);
|
|
357
|
+
const previousRetryCount = input.previous?.tokenHash === tokenHash
|
|
358
|
+
? (input.previous.retryCount ?? 0)
|
|
359
|
+
: 0;
|
|
360
|
+
const retryCount = previousRetryCount + 1;
|
|
282
361
|
const delay = isTransientRefreshError(input.error)
|
|
283
362
|
? Math.min(MAX_REFRESH_RETRY_DELAY_MS, MIN_REFRESH_RETRY_DELAY_MS * 2 ** Math.min(retryCount - 1, 6))
|
|
284
363
|
: NON_TRANSIENT_REFRESH_RETRY_DELAY_MS;
|
|
@@ -287,7 +366,7 @@ export function buildRefreshOperationError(input) {
|
|
|
287
366
|
checkedAt: input.now,
|
|
288
367
|
nextRetryAt: input.now + delay,
|
|
289
368
|
retryCount,
|
|
290
|
-
tokenHash
|
|
369
|
+
tokenHash,
|
|
291
370
|
};
|
|
292
371
|
}
|
|
293
372
|
export function refreshBackoffActive(error, refreshToken, now) {
|
|
@@ -525,13 +604,28 @@ export class FallbackAccountManager {
|
|
|
525
604
|
if (!tokenNeedsRefresh(account, storage, this.now()))
|
|
526
605
|
continue;
|
|
527
606
|
if (refreshBackoffActive(account.lastRefreshError, account.refresh, this.now())) {
|
|
607
|
+
log('[refresh] fallback oauth skipped backoff', {
|
|
608
|
+
accountId: account.id,
|
|
609
|
+
nextRetryAt: account.lastRefreshError?.nextRetryAt,
|
|
610
|
+
retryCount: account.lastRefreshError?.retryCount,
|
|
611
|
+
});
|
|
528
612
|
continue;
|
|
529
613
|
}
|
|
530
614
|
try {
|
|
615
|
+
log('[refresh] fallback oauth background due', {
|
|
616
|
+
accountId: account.id,
|
|
617
|
+
expiresInMs: account.expires
|
|
618
|
+
? account.expires - this.now()
|
|
619
|
+
: undefined,
|
|
620
|
+
});
|
|
531
621
|
await this.refreshAccount(account, storage);
|
|
532
622
|
changed = true;
|
|
533
623
|
}
|
|
534
624
|
catch (error) {
|
|
625
|
+
log('[refresh] fallback oauth background failed', {
|
|
626
|
+
accountId: account.id,
|
|
627
|
+
error: error instanceof Error ? error.message : String(error),
|
|
628
|
+
});
|
|
535
629
|
recordRefreshError(account, error, this.now());
|
|
536
630
|
updateStoredAccount(storage, account);
|
|
537
631
|
changed = true;
|
|
@@ -636,6 +730,13 @@ export class FallbackAccountManager {
|
|
|
636
730
|
}
|
|
637
731
|
const sourceAccount = latestAccount ?? account;
|
|
638
732
|
const refreshToken = sourceAccount.refresh;
|
|
733
|
+
log('[refresh] fallback oauth refresh request start', {
|
|
734
|
+
accountId: sourceAccount.id,
|
|
735
|
+
force: options.force === true,
|
|
736
|
+
expiresInMs: sourceAccount.expires
|
|
737
|
+
? sourceAccount.expires - this.now()
|
|
738
|
+
: undefined,
|
|
739
|
+
});
|
|
639
740
|
const refreshed = await refreshClaudeOAuthToken({
|
|
640
741
|
refreshToken,
|
|
641
742
|
fetchImpl: this.fetchImpl,
|
|
@@ -648,6 +749,12 @@ export class FallbackAccountManager {
|
|
|
648
749
|
refreshed.expires - refreshed.expiresIn * 1000;
|
|
649
750
|
sourceAccount.lastRefreshError = undefined;
|
|
650
751
|
updateStoredAccount(storage, sourceAccount);
|
|
752
|
+
log('[refresh] fallback oauth refresh succeeded', {
|
|
753
|
+
accountId: sourceAccount.id,
|
|
754
|
+
expiresInMs: sourceAccount.expires
|
|
755
|
+
? sourceAccount.expires - this.now()
|
|
756
|
+
: undefined,
|
|
757
|
+
});
|
|
651
758
|
return sourceAccount;
|
|
652
759
|
}
|
|
653
760
|
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