@cortexkit/anthropic-auth-core 1.7.0 → 1.9.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.
- package/dist/accounts.d.ts +56 -7
- package/dist/accounts.js +279 -34
- package/dist/cachekeep.d.ts +6 -0
- package/dist/cachekeep.js +40 -5
- package/dist/claude-code.js +25 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/models.d.ts +20 -0
- package/dist/models.js +31 -0
- package/dist/quota-manager.d.ts +1 -0
- package/dist/quota-manager.js +32 -13
- package/dist/quotas.js +2 -1
- package/dist/relay.d.ts +1 -1
- package/dist/relay.js +215 -13
- package/package.json +1 -1
package/dist/accounts.d.ts
CHANGED
|
@@ -1,22 +1,35 @@
|
|
|
1
1
|
import { type Cache1hMode } from './constants.ts';
|
|
2
2
|
export declare const ACCOUNT_FILE_NAME = "anthropic-auth.json";
|
|
3
|
+
export declare const ACCOUNT_STATE_FILE_NAME = "anthropic-auth-state.json";
|
|
3
4
|
export declare const QUOTA_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
4
5
|
export type QuotaWindowName = 'five_hour' | 'seven_day';
|
|
5
|
-
export type
|
|
6
|
+
export type AccountBase = {
|
|
6
7
|
id: string;
|
|
7
8
|
label?: string;
|
|
9
|
+
enabled?: boolean;
|
|
10
|
+
addedAt?: number;
|
|
11
|
+
lastUsed?: number;
|
|
12
|
+
};
|
|
13
|
+
export type OAuthAccount = AccountBase & {
|
|
8
14
|
type: 'oauth';
|
|
9
15
|
access?: string;
|
|
10
16
|
refresh: string;
|
|
11
17
|
expires?: number;
|
|
12
|
-
enabled?: boolean;
|
|
13
|
-
addedAt?: number;
|
|
14
|
-
lastUsed?: number;
|
|
15
18
|
lastRefreshedAt?: number;
|
|
16
19
|
lastRefreshError?: AccountOperationError;
|
|
17
20
|
lastQuotaRefreshError?: AccountOperationError;
|
|
18
21
|
quota?: Partial<Record<QuotaWindowName, AccountQuotaWindow>>;
|
|
19
22
|
};
|
|
23
|
+
export type ApiKeyAccount = AccountBase & {
|
|
24
|
+
type: 'api';
|
|
25
|
+
apiKey?: string;
|
|
26
|
+
baseURL: string;
|
|
27
|
+
authHeader?: 'authorization-bearer' | 'x-api-key';
|
|
28
|
+
};
|
|
29
|
+
export type FallbackAccount = OAuthAccount | ApiKeyAccount;
|
|
30
|
+
export declare function isOAuthAccount(account: FallbackAccount): account is OAuthAccount;
|
|
31
|
+
export declare function isApiKeyAccount(account: FallbackAccount): account is ApiKeyAccount;
|
|
32
|
+
export declare function isValidApiBaseURL(value: string | undefined): boolean;
|
|
20
33
|
export type AccountOperationError = {
|
|
21
34
|
message: string;
|
|
22
35
|
checkedAt: number;
|
|
@@ -81,6 +94,14 @@ export type AccountStorage = {
|
|
|
81
94
|
claudeFast?: {
|
|
82
95
|
enabled?: boolean;
|
|
83
96
|
};
|
|
97
|
+
/**
|
|
98
|
+
* Zero out Anthropic OAuth model costs in the provider hook. Default: enabled
|
|
99
|
+
* (OAuth usage is quota-based, not per-token billed, so costs show as $0).
|
|
100
|
+
* Set `enabled: false` to opt out and display the provider's real model costs.
|
|
101
|
+
*/
|
|
102
|
+
costZeroing?: {
|
|
103
|
+
enabled?: boolean;
|
|
104
|
+
};
|
|
84
105
|
cacheKeep?: {
|
|
85
106
|
enabled?: boolean;
|
|
86
107
|
startHour?: number;
|
|
@@ -94,7 +115,33 @@ export type AccountStorage = {
|
|
|
94
115
|
transport?: 'http' | 'websocket';
|
|
95
116
|
};
|
|
96
117
|
killswitch?: KillswitchConfig;
|
|
97
|
-
accounts:
|
|
118
|
+
accounts: FallbackAccount[];
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* Whether Anthropic OAuth model costs should be zeroed in the provider hook.
|
|
122
|
+
* Defaults to enabled; only an explicit `costZeroing.enabled === false` opts out
|
|
123
|
+
* (to display the provider's real model costs).
|
|
124
|
+
*/
|
|
125
|
+
export declare function isCostZeroingEnabled(storage: Pick<AccountStorage, 'costZeroing'>): boolean;
|
|
126
|
+
export type AccountRuntimeEntry = Partial<Pick<OAuthAccount, 'access' | 'refresh' | 'expires' | 'lastUsed' | 'lastRefreshedAt' | 'lastRefreshError' | 'lastQuotaRefreshError' | 'quota'> & Pick<ApiKeyAccount, 'apiKey' | 'lastUsed'>>;
|
|
127
|
+
export type AccountRuntimeState = {
|
|
128
|
+
version: 1;
|
|
129
|
+
main?: {
|
|
130
|
+
quota?: OAuthQuotaSnapshot;
|
|
131
|
+
quotaCheckedAt?: number;
|
|
132
|
+
quotaToken?: string;
|
|
133
|
+
lastQuotaApiError?: AccountOperationError;
|
|
134
|
+
lastRefreshError?: AccountOperationError;
|
|
135
|
+
refreshLeaseId?: string;
|
|
136
|
+
refreshLeaseUntil?: number;
|
|
137
|
+
refreshLeaseTokenHash?: string;
|
|
138
|
+
};
|
|
139
|
+
accounts?: Record<string, AccountRuntimeEntry>;
|
|
140
|
+
};
|
|
141
|
+
export type AccountStateSaveScope = {
|
|
142
|
+
mainQuota?: boolean;
|
|
143
|
+
mainRefresh?: boolean;
|
|
144
|
+
accounts?: true | string[];
|
|
98
145
|
};
|
|
99
146
|
export type OAuthQuotaSnapshot = Partial<Record<QuotaWindowName, AccountQuotaWindow>>;
|
|
100
147
|
export type AccountManagerOptions = {
|
|
@@ -109,8 +156,10 @@ export type AccountRefreshError = {
|
|
|
109
156
|
message: string;
|
|
110
157
|
};
|
|
111
158
|
export declare function getAccountStoragePath(): string;
|
|
159
|
+
export declare function getAccountStatePath(configPath?: string): string;
|
|
112
160
|
export declare function loadAccounts(path?: string): Promise<AccountStorage | null>;
|
|
113
161
|
export declare function saveAccounts(storage: AccountStorage, path?: string): Promise<void>;
|
|
162
|
+
export declare function saveAccountState(storage: AccountStorage, path?: string, scope?: AccountStateSaveScope): Promise<void>;
|
|
114
163
|
export declare function acquireRefreshFileLock(options: {
|
|
115
164
|
name: string;
|
|
116
165
|
ttlMs: number;
|
|
@@ -202,11 +251,11 @@ export declare class FallbackAccountManager {
|
|
|
202
251
|
*/
|
|
203
252
|
private seedFallbackQuota;
|
|
204
253
|
load(): Promise<AccountStorage | null>;
|
|
205
|
-
save(storage: AccountStorage): Promise<void>;
|
|
254
|
+
save(storage: AccountStorage, accountIds?: string[]): Promise<void>;
|
|
206
255
|
startBackgroundRefresh(): void;
|
|
207
256
|
stopBackgroundRefresh(): void;
|
|
208
257
|
getUsableFallbackAccounts(existingStorage?: AccountStorage | null): Promise<OAuthAccount[]>;
|
|
209
|
-
markUsed(account:
|
|
258
|
+
markUsed(account: FallbackAccount): Promise<void>;
|
|
210
259
|
accountPassesQuotaPolicy(account: OAuthAccount, storage: AccountStorage | null): boolean;
|
|
211
260
|
/**
|
|
212
261
|
* Return the account with its quota overlaid from the unified QuotaManager
|
package/dist/accounts.js
CHANGED
|
@@ -8,7 +8,36 @@ import { log } from "./logger.js";
|
|
|
8
8
|
const setRefreshLockRenewalTimeout = globalThis.setTimeout.bind(globalThis);
|
|
9
9
|
const clearRefreshLockRenewalTimeout = globalThis.clearTimeout.bind(globalThis);
|
|
10
10
|
export const ACCOUNT_FILE_NAME = 'anthropic-auth.json';
|
|
11
|
+
export const ACCOUNT_STATE_FILE_NAME = 'anthropic-auth-state.json';
|
|
11
12
|
export const QUOTA_URL = 'https://api.anthropic.com/api/oauth/usage';
|
|
13
|
+
export function isOAuthAccount(account) {
|
|
14
|
+
return account.type === 'oauth';
|
|
15
|
+
}
|
|
16
|
+
export function isApiKeyAccount(account) {
|
|
17
|
+
return account.type === 'api';
|
|
18
|
+
}
|
|
19
|
+
export function isValidApiBaseURL(value) {
|
|
20
|
+
const raw = value?.trim();
|
|
21
|
+
if (!raw)
|
|
22
|
+
return false;
|
|
23
|
+
try {
|
|
24
|
+
const url = new URL(raw);
|
|
25
|
+
return ((url.protocol === 'http:' || url.protocol === 'https:') &&
|
|
26
|
+
!url.username &&
|
|
27
|
+
!url.password);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Whether Anthropic OAuth model costs should be zeroed in the provider hook.
|
|
35
|
+
* Defaults to enabled; only an explicit `costZeroing.enabled === false` opts out
|
|
36
|
+
* (to display the provider's real model costs).
|
|
37
|
+
*/
|
|
38
|
+
export function isCostZeroingEnabled(storage) {
|
|
39
|
+
return storage.costZeroing?.enabled !== false;
|
|
40
|
+
}
|
|
12
41
|
const DEFAULT_FALLBACK_ON = [401, 403, 429];
|
|
13
42
|
const MIN_REFRESH_BEFORE_EXPIRY_MINUTES = 240;
|
|
14
43
|
const DEFAULT_REFRESH_BEFORE_EXPIRY_MINUTES = MIN_REFRESH_BEFORE_EXPIRY_MINUTES;
|
|
@@ -40,28 +69,55 @@ export function getAccountStoragePath() {
|
|
|
40
69
|
return (process.env.OPENCODE_ANTHROPIC_AUTH_FILE?.trim() ||
|
|
41
70
|
join(getConfigDir(), ACCOUNT_FILE_NAME));
|
|
42
71
|
}
|
|
72
|
+
export function getAccountStatePath(configPath = getAccountStoragePath()) {
|
|
73
|
+
const explicit = process.env.OPENCODE_ANTHROPIC_AUTH_STATE_FILE?.trim();
|
|
74
|
+
if (explicit)
|
|
75
|
+
return explicit;
|
|
76
|
+
return configPath.endsWith(ACCOUNT_FILE_NAME)
|
|
77
|
+
? join(dirname(configPath), ACCOUNT_STATE_FILE_NAME)
|
|
78
|
+
: `${configPath}.state.json`;
|
|
79
|
+
}
|
|
43
80
|
function isRecord(value) {
|
|
44
81
|
return value != null && typeof value === 'object' && !Array.isArray(value);
|
|
45
82
|
}
|
|
83
|
+
function normalizeAccountBase(value) {
|
|
84
|
+
return {
|
|
85
|
+
id: typeof value.id === 'string' && value.id.trim()
|
|
86
|
+
? value.id.trim()
|
|
87
|
+
: randomUUID(),
|
|
88
|
+
label: typeof value.label === 'string' ? value.label : undefined,
|
|
89
|
+
enabled: typeof value.enabled === 'boolean' ? value.enabled : undefined,
|
|
90
|
+
addedAt: typeof value.addedAt === 'number' ? value.addedAt : undefined,
|
|
91
|
+
lastUsed: typeof value.lastUsed === 'number' ? value.lastUsed : undefined,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
46
94
|
function normalizeAccount(value) {
|
|
47
95
|
if (!isRecord(value))
|
|
48
96
|
return null;
|
|
97
|
+
if (value.type === 'api') {
|
|
98
|
+
const baseURL = typeof value.baseURL === 'string' ? value.baseURL.trim() : '';
|
|
99
|
+
const apiKey = typeof value.apiKey === 'string' ? value.apiKey.trim() : '';
|
|
100
|
+
if (!isValidApiBaseURL(baseURL))
|
|
101
|
+
return null;
|
|
102
|
+
const authHeader = value.authHeader === 'x-api-key' ? 'x-api-key' : 'authorization-bearer';
|
|
103
|
+
return {
|
|
104
|
+
...normalizeAccountBase(value),
|
|
105
|
+
type: 'api',
|
|
106
|
+
apiKey: apiKey || undefined,
|
|
107
|
+
baseURL,
|
|
108
|
+
authHeader,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
49
111
|
if (value.type !== 'oauth')
|
|
50
112
|
return null;
|
|
51
113
|
if (typeof value.refresh !== 'string' || !value.refresh.trim())
|
|
52
114
|
return null;
|
|
53
115
|
return {
|
|
54
|
-
|
|
55
|
-
? value.id.trim()
|
|
56
|
-
: randomUUID(),
|
|
57
|
-
label: typeof value.label === 'string' ? value.label : undefined,
|
|
116
|
+
...normalizeAccountBase(value),
|
|
58
117
|
type: 'oauth',
|
|
59
118
|
access: typeof value.access === 'string' ? value.access : undefined,
|
|
60
119
|
refresh: value.refresh,
|
|
61
120
|
expires: typeof value.expires === 'number' ? value.expires : undefined,
|
|
62
|
-
enabled: typeof value.enabled === 'boolean' ? value.enabled : undefined,
|
|
63
|
-
addedAt: typeof value.addedAt === 'number' ? value.addedAt : undefined,
|
|
64
|
-
lastUsed: typeof value.lastUsed === 'number' ? value.lastUsed : undefined,
|
|
65
121
|
lastRefreshedAt: typeof value.lastRefreshedAt === 'number'
|
|
66
122
|
? value.lastRefreshedAt
|
|
67
123
|
: undefined,
|
|
@@ -128,6 +184,7 @@ function normalizeStorage(value) {
|
|
|
128
184
|
claudeCache: isRecord(value.claudeCache) ? value.claudeCache : undefined,
|
|
129
185
|
dump: isRecord(value.dump) ? value.dump : undefined,
|
|
130
186
|
claudeFast: isRecord(value.claudeFast) ? value.claudeFast : undefined,
|
|
187
|
+
costZeroing: isRecord(value.costZeroing) ? value.costZeroing : undefined,
|
|
131
188
|
cacheKeep: isRecord(value.cacheKeep) ? value.cacheKeep : undefined,
|
|
132
189
|
relay: isRecord(value.relay) ? value.relay : undefined,
|
|
133
190
|
killswitch: isRecord(value.killswitch) ? value.killswitch : undefined,
|
|
@@ -136,41 +193,227 @@ function normalizeStorage(value) {
|
|
|
136
193
|
.filter((account) => account != null),
|
|
137
194
|
};
|
|
138
195
|
}
|
|
139
|
-
|
|
196
|
+
async function readJsonIfPresent(path) {
|
|
140
197
|
try {
|
|
141
|
-
|
|
142
|
-
return normalizeStorage(JSON.parse(raw));
|
|
198
|
+
return { exists: true, value: JSON.parse(await readFile(path, 'utf8')) };
|
|
143
199
|
}
|
|
144
200
|
catch (error) {
|
|
145
|
-
if (error.code === 'ENOENT')
|
|
146
|
-
return null;
|
|
147
|
-
|
|
201
|
+
if (error.code === 'ENOENT') {
|
|
202
|
+
return { exists: false, value: null };
|
|
203
|
+
}
|
|
204
|
+
return { exists: false, value: null };
|
|
148
205
|
}
|
|
149
206
|
}
|
|
207
|
+
function objectWithDefinedEntries(value) {
|
|
208
|
+
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
|
209
|
+
}
|
|
210
|
+
function mergeConfigAndState(configValue, stateValue) {
|
|
211
|
+
if (!isRecord(configValue))
|
|
212
|
+
return configValue;
|
|
213
|
+
const state = isRecord(stateValue) ? stateValue : {};
|
|
214
|
+
const mainState = isRecord(state.main) ? state.main : undefined;
|
|
215
|
+
const stateAccounts = isRecord(state.accounts) ? state.accounts : {};
|
|
216
|
+
const quotaConfig = isRecord(configValue.quota) ? configValue.quota : {};
|
|
217
|
+
const refreshConfig = isRecord(configValue.refresh) ? configValue.refresh : {};
|
|
218
|
+
const mainQuotaSource = mainState ?? quotaConfig;
|
|
219
|
+
const mainRefreshSource = mainState ?? refreshConfig;
|
|
220
|
+
const accounts = Array.isArray(configValue.accounts)
|
|
221
|
+
? configValue.accounts.map((account) => {
|
|
222
|
+
if (!isRecord(account))
|
|
223
|
+
return account;
|
|
224
|
+
const stateAccount = typeof account.id === 'string' && isRecord(stateAccounts[account.id])
|
|
225
|
+
? stateAccounts[account.id]
|
|
226
|
+
: {};
|
|
227
|
+
return { ...account, ...stateAccount };
|
|
228
|
+
})
|
|
229
|
+
: [];
|
|
230
|
+
return {
|
|
231
|
+
...configValue,
|
|
232
|
+
refresh: objectWithDefinedEntries({
|
|
233
|
+
...refreshConfig,
|
|
234
|
+
mainLastRefreshError: mainRefreshSource.lastRefreshError,
|
|
235
|
+
mainRefreshLeaseId: mainRefreshSource.refreshLeaseId,
|
|
236
|
+
mainRefreshLeaseUntil: mainRefreshSource.refreshLeaseUntil,
|
|
237
|
+
mainRefreshLeaseTokenHash: mainRefreshSource.refreshLeaseTokenHash,
|
|
238
|
+
}),
|
|
239
|
+
quota: objectWithDefinedEntries({
|
|
240
|
+
...quotaConfig,
|
|
241
|
+
mainQuota: mainQuotaSource.quota,
|
|
242
|
+
mainQuotaCheckedAt: mainQuotaSource.quotaCheckedAt,
|
|
243
|
+
mainQuotaToken: mainQuotaSource.quotaToken,
|
|
244
|
+
mainLastQuotaApiError: mainQuotaSource.lastQuotaApiError,
|
|
245
|
+
}),
|
|
246
|
+
accounts,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
export async function loadAccounts(path = getAccountStoragePath()) {
|
|
250
|
+
const config = await readJsonIfPresent(path);
|
|
251
|
+
if (!config.exists)
|
|
252
|
+
return null;
|
|
253
|
+
const state = await readJsonIfPresent(getAccountStatePath(path));
|
|
254
|
+
return normalizeStorage(mergeConfigAndState(config.value, state.value));
|
|
255
|
+
}
|
|
150
256
|
async function loadExistingTopLevelFields(path) {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const parsed = JSON.parse(raw);
|
|
154
|
-
return isRecord(parsed) ? parsed : {};
|
|
155
|
-
}
|
|
156
|
-
catch {
|
|
157
|
-
return {};
|
|
158
|
-
}
|
|
257
|
+
const existing = await readJsonIfPresent(path);
|
|
258
|
+
return isRecord(existing.value) ? existing.value : {};
|
|
159
259
|
}
|
|
160
260
|
function omitUndefinedTopLevel(value) {
|
|
161
261
|
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
|
162
262
|
}
|
|
163
|
-
|
|
263
|
+
function accountConfig(account) {
|
|
264
|
+
return objectWithDefinedEntries({
|
|
265
|
+
id: account.id,
|
|
266
|
+
label: account.label,
|
|
267
|
+
type: account.type,
|
|
268
|
+
enabled: account.enabled,
|
|
269
|
+
addedAt: account.addedAt,
|
|
270
|
+
baseURL: account.type === 'api' ? account.baseURL : undefined,
|
|
271
|
+
authHeader: account.type === 'api' ? account.authHeader : undefined,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
function accountRuntimeState(account) {
|
|
275
|
+
if (account.type === 'api') {
|
|
276
|
+
return objectWithDefinedEntries({
|
|
277
|
+
apiKey: account.apiKey,
|
|
278
|
+
lastUsed: account.lastUsed,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
return objectWithDefinedEntries({
|
|
282
|
+
access: account.access,
|
|
283
|
+
refresh: account.refresh,
|
|
284
|
+
expires: account.expires,
|
|
285
|
+
lastUsed: account.lastUsed,
|
|
286
|
+
lastRefreshedAt: account.lastRefreshedAt,
|
|
287
|
+
lastRefreshError: account.lastRefreshError,
|
|
288
|
+
lastQuotaRefreshError: account.lastQuotaRefreshError,
|
|
289
|
+
quota: account.quota,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
function configFromStorage(storage) {
|
|
293
|
+
const refresh = storage.refresh
|
|
294
|
+
? objectWithDefinedEntries({
|
|
295
|
+
enabled: storage.refresh.enabled,
|
|
296
|
+
intervalMinutes: storage.refresh.intervalMinutes,
|
|
297
|
+
refreshBeforeExpiryMinutes: storage.refresh.refreshBeforeExpiryMinutes,
|
|
298
|
+
})
|
|
299
|
+
: undefined;
|
|
300
|
+
const quota = storage.quota
|
|
301
|
+
? objectWithDefinedEntries({
|
|
302
|
+
enabled: storage.quota.enabled,
|
|
303
|
+
checkIntervalMinutes: storage.quota.checkIntervalMinutes,
|
|
304
|
+
refreshEveryNRequests: storage.quota.refreshEveryNRequests,
|
|
305
|
+
minimumRemaining: storage.quota.minimumRemaining,
|
|
306
|
+
failClosedOnUnknownQuota: storage.quota.failClosedOnUnknownQuota,
|
|
307
|
+
showToasts: storage.quota.showToasts,
|
|
308
|
+
})
|
|
309
|
+
: undefined;
|
|
310
|
+
return omitUndefinedTopLevel({
|
|
311
|
+
version: 1,
|
|
312
|
+
main: storage.main,
|
|
313
|
+
routing: storage.routing,
|
|
314
|
+
fallbackOn: storage.fallbackOn,
|
|
315
|
+
refresh,
|
|
316
|
+
quota,
|
|
317
|
+
claudeCache: storage.claudeCache,
|
|
318
|
+
dump: storage.dump,
|
|
319
|
+
claudeFast: storage.claudeFast,
|
|
320
|
+
costZeroing: storage.costZeroing,
|
|
321
|
+
cacheKeep: storage.cacheKeep,
|
|
322
|
+
relay: storage.relay,
|
|
323
|
+
killswitch: storage.killswitch,
|
|
324
|
+
accounts: storage.accounts.map(accountConfig),
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
function stateFromStorage(storage) {
|
|
328
|
+
const accounts = Object.fromEntries(storage.accounts.map((account) => [
|
|
329
|
+
account.id,
|
|
330
|
+
accountRuntimeState(account),
|
|
331
|
+
]));
|
|
332
|
+
return {
|
|
333
|
+
version: 1,
|
|
334
|
+
main: objectWithDefinedEntries({
|
|
335
|
+
quota: storage.quota?.mainQuota,
|
|
336
|
+
quotaCheckedAt: storage.quota?.mainQuotaCheckedAt,
|
|
337
|
+
quotaToken: storage.quota?.mainQuotaToken,
|
|
338
|
+
lastQuotaApiError: storage.quota?.mainLastQuotaApiError,
|
|
339
|
+
lastRefreshError: storage.refresh?.mainLastRefreshError,
|
|
340
|
+
refreshLeaseId: storage.refresh?.mainRefreshLeaseId,
|
|
341
|
+
refreshLeaseUntil: storage.refresh?.mainRefreshLeaseUntil,
|
|
342
|
+
refreshLeaseTokenHash: storage.refresh?.mainRefreshLeaseTokenHash,
|
|
343
|
+
}),
|
|
344
|
+
accounts,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
async function writeJsonAtomic(path, value) {
|
|
164
348
|
await mkdir(dirname(path), { recursive: true });
|
|
165
|
-
const existing = await loadExistingTopLevelFields(path);
|
|
166
|
-
const next = { ...existing, ...omitUndefinedTopLevel(storage) };
|
|
167
349
|
const tempPath = `${path}.${randomUUID()}.tmp`;
|
|
168
|
-
await writeFile(tempPath, `${JSON.stringify(
|
|
350
|
+
await writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, {
|
|
169
351
|
encoding: 'utf8',
|
|
170
352
|
mode: 0o600,
|
|
171
353
|
});
|
|
172
354
|
await rename(tempPath, path);
|
|
173
355
|
}
|
|
356
|
+
export async function saveAccounts(storage, path = getAccountStoragePath()) {
|
|
357
|
+
const existing = await loadExistingTopLevelFields(path);
|
|
358
|
+
const nextConfig = { ...existing, ...configFromStorage(storage) };
|
|
359
|
+
await writeJsonAtomic(path, nextConfig);
|
|
360
|
+
await writeJsonAtomic(getAccountStatePath(path), stateFromStorage(storage));
|
|
361
|
+
}
|
|
362
|
+
function applyMainQuotaStatePatch(state, storage) {
|
|
363
|
+
state.main = state.main ?? {};
|
|
364
|
+
state.main.quota = storage.quota?.mainQuota;
|
|
365
|
+
state.main.quotaCheckedAt = storage.quota?.mainQuotaCheckedAt;
|
|
366
|
+
state.main.quotaToken = storage.quota?.mainQuotaToken;
|
|
367
|
+
state.main.lastQuotaApiError = storage.quota?.mainLastQuotaApiError;
|
|
368
|
+
}
|
|
369
|
+
function applyMainRefreshStatePatch(state, storage) {
|
|
370
|
+
state.main = state.main ?? {};
|
|
371
|
+
state.main.lastRefreshError = storage.refresh?.mainLastRefreshError;
|
|
372
|
+
state.main.refreshLeaseId = storage.refresh?.mainRefreshLeaseId;
|
|
373
|
+
state.main.refreshLeaseUntil = storage.refresh?.mainRefreshLeaseUntil;
|
|
374
|
+
state.main.refreshLeaseTokenHash = storage.refresh?.mainRefreshLeaseTokenHash;
|
|
375
|
+
}
|
|
376
|
+
function pruneUndefined(value) {
|
|
377
|
+
if (Array.isArray(value))
|
|
378
|
+
return value.map(pruneUndefined);
|
|
379
|
+
if (!isRecord(value))
|
|
380
|
+
return value;
|
|
381
|
+
return Object.fromEntries(Object.entries(value)
|
|
382
|
+
.filter(([, entry]) => entry !== undefined)
|
|
383
|
+
.map(([key, entry]) => [key, pruneUndefined(entry)]));
|
|
384
|
+
}
|
|
385
|
+
export async function saveAccountState(storage, path = getAccountStoragePath(), scope = {
|
|
386
|
+
mainQuota: true,
|
|
387
|
+
mainRefresh: true,
|
|
388
|
+
accounts: true,
|
|
389
|
+
}) {
|
|
390
|
+
const statePath = getAccountStatePath(path);
|
|
391
|
+
const existing = (await readJsonIfPresent(statePath)).value;
|
|
392
|
+
const next = isRecord(existing)
|
|
393
|
+
? { ...existing, version: 1 }
|
|
394
|
+
: { version: 1 };
|
|
395
|
+
if (scope.mainQuota)
|
|
396
|
+
applyMainQuotaStatePatch(next, storage);
|
|
397
|
+
if (scope.mainRefresh)
|
|
398
|
+
applyMainRefreshStatePatch(next, storage);
|
|
399
|
+
if (scope.accounts) {
|
|
400
|
+
const ids = scope.accounts === true ? null : new Set(scope.accounts);
|
|
401
|
+
next.accounts = { ...(isRecord(next.accounts) ? next.accounts : {}) };
|
|
402
|
+
for (const account of storage.accounts) {
|
|
403
|
+
if (ids && !ids.has(account.id))
|
|
404
|
+
continue;
|
|
405
|
+
next.accounts[account.id] = accountRuntimeState(account);
|
|
406
|
+
}
|
|
407
|
+
if (ids) {
|
|
408
|
+
for (const id of ids) {
|
|
409
|
+
if (!storage.accounts.some((account) => account.id === id)) {
|
|
410
|
+
delete next.accounts[id];
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
await writeJsonAtomic(statePath, pruneUndefined(next));
|
|
416
|
+
}
|
|
174
417
|
export async function acquireRefreshFileLock(options) {
|
|
175
418
|
const accountPath = options.path ?? getAccountStoragePath();
|
|
176
419
|
const lockPath = `${accountPath}.${options.name}.lock`;
|
|
@@ -786,8 +1029,10 @@ export class FallbackAccountManager {
|
|
|
786
1029
|
async load() {
|
|
787
1030
|
return loadAccounts(this.configPath);
|
|
788
1031
|
}
|
|
789
|
-
async save(storage) {
|
|
790
|
-
await
|
|
1032
|
+
async save(storage, accountIds) {
|
|
1033
|
+
await saveAccountState(storage, this.configPath, {
|
|
1034
|
+
accounts: accountIds ?? true,
|
|
1035
|
+
});
|
|
791
1036
|
}
|
|
792
1037
|
startBackgroundRefresh() {
|
|
793
1038
|
const run = async () => {
|
|
@@ -818,7 +1063,7 @@ export class FallbackAccountManager {
|
|
|
818
1063
|
const usable = [];
|
|
819
1064
|
let changed = false;
|
|
820
1065
|
for (const account of storage.accounts) {
|
|
821
|
-
if (account.enabled === false)
|
|
1066
|
+
if (account.enabled === false || !isOAuthAccount(account))
|
|
822
1067
|
continue;
|
|
823
1068
|
try {
|
|
824
1069
|
let next = account;
|
|
@@ -898,7 +1143,7 @@ export class FallbackAccountManager {
|
|
|
898
1143
|
return;
|
|
899
1144
|
let changed = false;
|
|
900
1145
|
for (const account of storage.accounts) {
|
|
901
|
-
if (account.enabled === false)
|
|
1146
|
+
if (account.enabled === false || !isOAuthAccount(account))
|
|
902
1147
|
continue;
|
|
903
1148
|
if (!tokenNeedsRefresh(account, storage, this.now()))
|
|
904
1149
|
continue;
|
|
@@ -940,7 +1185,7 @@ export class FallbackAccountManager {
|
|
|
940
1185
|
return;
|
|
941
1186
|
let changed = false;
|
|
942
1187
|
for (const account of storage.accounts) {
|
|
943
|
-
if (account.enabled === false)
|
|
1188
|
+
if (account.enabled === false || !isOAuthAccount(account))
|
|
944
1189
|
continue;
|
|
945
1190
|
let next = account;
|
|
946
1191
|
try {
|
|
@@ -985,7 +1230,7 @@ export class FallbackAccountManager {
|
|
|
985
1230
|
const force = options.force ?? false;
|
|
986
1231
|
let changed = false;
|
|
987
1232
|
for (const account of storage.accounts) {
|
|
988
|
-
if (account.enabled === false)
|
|
1233
|
+
if (account.enabled === false || !isOAuthAccount(account))
|
|
989
1234
|
continue;
|
|
990
1235
|
let next = account;
|
|
991
1236
|
try {
|
|
@@ -1046,7 +1291,7 @@ export class FallbackAccountManager {
|
|
|
1046
1291
|
while (Date.now() < deadline) {
|
|
1047
1292
|
await new Promise((resolve) => setTimeout(resolve, FALLBACK_REFRESH_JOIN_POLL_MS));
|
|
1048
1293
|
const latestStorage = await this.load();
|
|
1049
|
-
const latestAccount = latestStorage?.accounts.find((candidate) => candidate.id === account.id);
|
|
1294
|
+
const latestAccount = latestStorage?.accounts.find((candidate) => candidate.id === account.id && isOAuthAccount(candidate));
|
|
1050
1295
|
if (!latestAccount)
|
|
1051
1296
|
continue;
|
|
1052
1297
|
const changed = latestAccount.access !== previous.access ||
|
|
@@ -1075,7 +1320,7 @@ export class FallbackAccountManager {
|
|
|
1075
1320
|
}
|
|
1076
1321
|
async refreshAccountNow(account, storage, options) {
|
|
1077
1322
|
let latestStorage = await this.load();
|
|
1078
|
-
let latestAccount = latestStorage?.accounts.find((candidate) => candidate.id === account.id);
|
|
1323
|
+
let latestAccount = latestStorage?.accounts.find((candidate) => candidate.id === account.id && isOAuthAccount(candidate));
|
|
1079
1324
|
if (latestAccount &&
|
|
1080
1325
|
!options.force &&
|
|
1081
1326
|
!tokenNeedsRefresh(latestAccount, latestStorage, this.now())) {
|
|
@@ -1101,7 +1346,7 @@ export class FallbackAccountManager {
|
|
|
1101
1346
|
}
|
|
1102
1347
|
try {
|
|
1103
1348
|
latestStorage = await this.load();
|
|
1104
|
-
latestAccount = latestStorage?.accounts.find((candidate) => candidate.id === account.id);
|
|
1349
|
+
latestAccount = latestStorage?.accounts.find((candidate) => candidate.id === account.id && isOAuthAccount(candidate));
|
|
1105
1350
|
if (latestAccount &&
|
|
1106
1351
|
!options.force &&
|
|
1107
1352
|
!tokenNeedsRefresh(latestAccount, latestStorage, this.now())) {
|
package/dist/cachekeep.d.ts
CHANGED
|
@@ -4,6 +4,9 @@ export declare const CACHE_KEEP_TTL_MS: number;
|
|
|
4
4
|
export declare const CACHE_KEEP_PREWARM_LEAD_MS: number;
|
|
5
5
|
export declare const CACHE_KEEP_TICK_MS = 60000;
|
|
6
6
|
export declare const CACHE_KEEP_EXTENDED_TTL_BETA = "extended-cache-ttl-2025-04-11";
|
|
7
|
+
export declare const CACHE_KEEP_MAX_TARGETS = 32;
|
|
8
|
+
export declare const CACHE_KEEP_MAX_BODY_BYTES: number;
|
|
9
|
+
export declare const CACHE_KEEP_STALE_TARGET_MS: number;
|
|
7
10
|
export type CacheKeepWindow = {
|
|
8
11
|
startHour: number;
|
|
9
12
|
endHour: number;
|
|
@@ -74,6 +77,9 @@ export declare class CacheKeepManager {
|
|
|
74
77
|
nextPrewarmAt: number | undefined;
|
|
75
78
|
};
|
|
76
79
|
trackedCount(): number;
|
|
80
|
+
private totalBodyBytes;
|
|
81
|
+
private evictOldestTarget;
|
|
82
|
+
private pruneTargets;
|
|
77
83
|
track(input: {
|
|
78
84
|
sessionId?: string | null;
|
|
79
85
|
url: string;
|
package/dist/cachekeep.js
CHANGED
|
@@ -5,6 +5,9 @@ export const CACHE_KEEP_TTL_MS = 60 * 60_000;
|
|
|
5
5
|
export const CACHE_KEEP_PREWARM_LEAD_MS = 5 * 60_000;
|
|
6
6
|
export const CACHE_KEEP_TICK_MS = 60_000;
|
|
7
7
|
export const CACHE_KEEP_EXTENDED_TTL_BETA = 'extended-cache-ttl-2025-04-11';
|
|
8
|
+
export const CACHE_KEEP_MAX_TARGETS = 32;
|
|
9
|
+
export const CACHE_KEEP_MAX_BODY_BYTES = 16 * 1024 * 1024;
|
|
10
|
+
export const CACHE_KEEP_STALE_TARGET_MS = 2 * CACHE_KEEP_TTL_MS;
|
|
8
11
|
const STATUS_TITLE = '## Claude Cache Keep Status';
|
|
9
12
|
const ENABLED_TITLE = '## Claude Cache Keep Enabled';
|
|
10
13
|
const DISABLED_TITLE = '## Claude Cache Keep Disabled';
|
|
@@ -208,6 +211,33 @@ export class CacheKeepManager {
|
|
|
208
211
|
trackedCount() {
|
|
209
212
|
return this.targets.size;
|
|
210
213
|
}
|
|
214
|
+
totalBodyBytes() {
|
|
215
|
+
return [...this.targets.values()].reduce((sum, target) => sum + target.bodyText.length, 0);
|
|
216
|
+
}
|
|
217
|
+
evictOldestTarget() {
|
|
218
|
+
const oldest = this.targets.keys().next().value;
|
|
219
|
+
if (oldest !== undefined)
|
|
220
|
+
this.targets.delete(oldest);
|
|
221
|
+
}
|
|
222
|
+
pruneTargets(now, today) {
|
|
223
|
+
for (const [id, target] of this.targets) {
|
|
224
|
+
if (today && target.dayKey !== today) {
|
|
225
|
+
this.targets.delete(id);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (now - target.cacheExpiresAt > CACHE_KEEP_STALE_TARGET_MS) {
|
|
229
|
+
this.targets.delete(id);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
while (this.targets.size > CACHE_KEEP_MAX_TARGETS)
|
|
233
|
+
this.evictOldestTarget();
|
|
234
|
+
while (this.totalBodyBytes() > CACHE_KEEP_MAX_BODY_BYTES) {
|
|
235
|
+
const before = this.targets.size;
|
|
236
|
+
this.evictOldestTarget();
|
|
237
|
+
if (this.targets.size === before)
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
211
241
|
track(input) {
|
|
212
242
|
if (!input.sessionId)
|
|
213
243
|
return { tracked: false, reason: 'missing session id' };
|
|
@@ -217,11 +247,18 @@ export class CacheKeepManager {
|
|
|
217
247
|
if (input.cacheMode !== 'hybrid') {
|
|
218
248
|
return { tracked: false, reason: 'cache mode is not hybrid' };
|
|
219
249
|
}
|
|
250
|
+
if (input.bodyText.length > CACHE_KEEP_MAX_BODY_BYTES) {
|
|
251
|
+
return { tracked: false, reason: 'body exceeds cachekeep memory budget' };
|
|
252
|
+
}
|
|
220
253
|
const now = this.options.now?.() ?? Date.now();
|
|
221
254
|
const window = getCacheKeepWindow(input.storage);
|
|
222
255
|
if (!isWithinCacheKeepWindow(window, new Date(now))) {
|
|
223
256
|
return { tracked: false, reason: 'outside configured window' };
|
|
224
257
|
}
|
|
258
|
+
const today = localWindowKey(window, new Date(now));
|
|
259
|
+
this.pruneTargets(now, today);
|
|
260
|
+
if (this.targets.has(input.sessionId))
|
|
261
|
+
this.targets.delete(input.sessionId);
|
|
225
262
|
const headers = {};
|
|
226
263
|
input.headers.forEach((value, key) => {
|
|
227
264
|
headers[key] = value;
|
|
@@ -232,8 +269,9 @@ export class CacheKeepManager {
|
|
|
232
269
|
headers,
|
|
233
270
|
bodyText: input.bodyText,
|
|
234
271
|
cacheExpiresAt: now + CACHE_KEEP_TTL_MS,
|
|
235
|
-
dayKey:
|
|
272
|
+
dayKey: today,
|
|
236
273
|
});
|
|
274
|
+
this.pruneTargets(now, today);
|
|
237
275
|
this.start();
|
|
238
276
|
return { tracked: true };
|
|
239
277
|
}
|
|
@@ -242,10 +280,7 @@ export class CacheKeepManager {
|
|
|
242
280
|
const window = getCacheKeepWindow(storage);
|
|
243
281
|
const now = this.options.now?.() ?? Date.now();
|
|
244
282
|
const today = localWindowKey(window, new Date(now));
|
|
245
|
-
|
|
246
|
-
if (target.dayKey !== today)
|
|
247
|
-
this.targets.delete(id);
|
|
248
|
-
}
|
|
283
|
+
this.pruneTargets(now, today);
|
|
249
284
|
if (!this.targets.size) {
|
|
250
285
|
this.stop();
|
|
251
286
|
return;
|
package/dist/claude-code.js
CHANGED
|
@@ -22,7 +22,10 @@ export function getClaudeCodeIdentity(seed) {
|
|
|
22
22
|
setBounded(identityCache, cacheKey, identity);
|
|
23
23
|
return identity;
|
|
24
24
|
}
|
|
25
|
+
const BOOTSTRAP_IDENTITY_CACHE_TTL_MS = 24 * 60 * 60_000;
|
|
26
|
+
const BOOTSTRAP_IDENTITY_NEGATIVE_TTL_MS = 5 * 60_000;
|
|
25
27
|
const bootstrapFetches = new Map();
|
|
28
|
+
const bootstrapResults = new Map();
|
|
26
29
|
async function fetchClaudeCodeAccountUuid(accessToken, model) {
|
|
27
30
|
if (!accessToken.startsWith('sk-ant-oat'))
|
|
28
31
|
return null;
|
|
@@ -61,16 +64,35 @@ export async function resolveClaudeCodeIdentity(accessToken, model) {
|
|
|
61
64
|
const identity = getClaudeCodeIdentity(accessToken);
|
|
62
65
|
if (!accessToken.startsWith('sk-ant-oat'))
|
|
63
66
|
return identity;
|
|
67
|
+
const now = Date.now();
|
|
68
|
+
const cachedResult = bootstrapResults.get(accessToken);
|
|
69
|
+
if (cachedResult && cachedResult.expiresAt > now) {
|
|
70
|
+
if (!cachedResult.accountUuid)
|
|
71
|
+
return identity;
|
|
72
|
+
const accountCacheKey = `account:${cachedResult.accountUuid}`;
|
|
73
|
+
const accountIdentity = identityCache.get(accountCacheKey);
|
|
74
|
+
if (accountIdentity) {
|
|
75
|
+
setBounded(identityCache, accessToken, accountIdentity);
|
|
76
|
+
return accountIdentity;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
64
79
|
let fetchPromise = bootstrapFetches.get(accessToken);
|
|
65
80
|
if (!fetchPromise) {
|
|
66
81
|
fetchPromise = fetchClaudeCodeAccountUuid(accessToken, model);
|
|
67
82
|
bootstrapFetches.set(accessToken, fetchPromise);
|
|
68
83
|
}
|
|
69
|
-
const accountUuid = await fetchPromise
|
|
70
|
-
if (!accountUuid) {
|
|
84
|
+
const accountUuid = await fetchPromise.finally(() => {
|
|
71
85
|
bootstrapFetches.delete(accessToken);
|
|
86
|
+
});
|
|
87
|
+
setBounded(bootstrapResults, accessToken, {
|
|
88
|
+
accountUuid,
|
|
89
|
+
expiresAt: now +
|
|
90
|
+
(accountUuid
|
|
91
|
+
? BOOTSTRAP_IDENTITY_CACHE_TTL_MS
|
|
92
|
+
: BOOTSTRAP_IDENTITY_NEGATIVE_TTL_MS),
|
|
93
|
+
});
|
|
94
|
+
if (!accountUuid)
|
|
72
95
|
return identity;
|
|
73
|
-
}
|
|
74
96
|
const accountCacheKey = `account:${accountUuid}`;
|
|
75
97
|
const accountIdentity = identityCache.get(accountCacheKey);
|
|
76
98
|
if (accountIdentity) {
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/models.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export declare const CLAUDE_FABLE_5_MODEL_ID = "claude-fable-5";
|
|
2
|
+
export declare const CLAUDE_MYTHOS_5_MODEL_ID = "claude-mythos-5";
|
|
3
|
+
export declare const CLAUDE_FABLE_MYTHOS_5_MODEL_IDS: readonly ["claude-fable-5", "claude-mythos-5"];
|
|
4
|
+
export type ClaudeFableMythos5ModelId = (typeof CLAUDE_FABLE_MYTHOS_5_MODEL_IDS)[number];
|
|
5
|
+
export declare const CLAUDE_FABLE_MYTHOS_5_PRICING: {
|
|
6
|
+
readonly input: 10;
|
|
7
|
+
readonly output: 50;
|
|
8
|
+
readonly cacheRead: 1;
|
|
9
|
+
readonly cacheWrite5m: 12.5;
|
|
10
|
+
readonly cacheWrite1h: 20;
|
|
11
|
+
};
|
|
12
|
+
export declare const CLAUDE_FABLE_MYTHOS_5_CONTEXT_WINDOW = 1000000;
|
|
13
|
+
export declare const CLAUDE_FABLE_MYTHOS_5_MAX_OUTPUT_TOKENS = 128000;
|
|
14
|
+
export declare const CLAUDE_FABLE_MYTHOS_5_RELEASE_DATE = "2026-06-09";
|
|
15
|
+
export declare const CLAUDE_FABLE_MYTHOS_5_MODEL_SPECS: Record<ClaudeFableMythos5ModelId, {
|
|
16
|
+
id: ClaudeFableMythos5ModelId;
|
|
17
|
+
name: string;
|
|
18
|
+
limited?: boolean;
|
|
19
|
+
}>;
|
|
20
|
+
export declare function isClaudeFableOrMythos5Model(model: unknown): boolean;
|
package/dist/models.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export const CLAUDE_FABLE_5_MODEL_ID = 'claude-fable-5';
|
|
2
|
+
export const CLAUDE_MYTHOS_5_MODEL_ID = 'claude-mythos-5';
|
|
3
|
+
export const CLAUDE_FABLE_MYTHOS_5_MODEL_IDS = [
|
|
4
|
+
CLAUDE_FABLE_5_MODEL_ID,
|
|
5
|
+
CLAUDE_MYTHOS_5_MODEL_ID,
|
|
6
|
+
];
|
|
7
|
+
export const CLAUDE_FABLE_MYTHOS_5_PRICING = {
|
|
8
|
+
input: 10,
|
|
9
|
+
output: 50,
|
|
10
|
+
cacheRead: 1,
|
|
11
|
+
cacheWrite5m: 12.5,
|
|
12
|
+
cacheWrite1h: 20,
|
|
13
|
+
};
|
|
14
|
+
export const CLAUDE_FABLE_MYTHOS_5_CONTEXT_WINDOW = 1_000_000;
|
|
15
|
+
export const CLAUDE_FABLE_MYTHOS_5_MAX_OUTPUT_TOKENS = 128_000;
|
|
16
|
+
export const CLAUDE_FABLE_MYTHOS_5_RELEASE_DATE = '2026-06-09';
|
|
17
|
+
export const CLAUDE_FABLE_MYTHOS_5_MODEL_SPECS = {
|
|
18
|
+
[CLAUDE_FABLE_5_MODEL_ID]: {
|
|
19
|
+
id: CLAUDE_FABLE_5_MODEL_ID,
|
|
20
|
+
name: 'Claude Fable 5',
|
|
21
|
+
},
|
|
22
|
+
[CLAUDE_MYTHOS_5_MODEL_ID]: {
|
|
23
|
+
id: CLAUDE_MYTHOS_5_MODEL_ID,
|
|
24
|
+
name: 'Claude Mythos 5',
|
|
25
|
+
limited: true,
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
export function isClaudeFableOrMythos5Model(model) {
|
|
29
|
+
return (typeof model === 'string' &&
|
|
30
|
+
CLAUDE_FABLE_MYTHOS_5_MODEL_IDS.some((id) => model === id || model.startsWith(`${id}-`)));
|
|
31
|
+
}
|
package/dist/quota-manager.d.ts
CHANGED
|
@@ -94,6 +94,7 @@ export declare class QuotaManager {
|
|
|
94
94
|
/** Minimum gap between consecutive quota API calls (ms). */
|
|
95
95
|
private static readonly API_CALL_GAP_MS;
|
|
96
96
|
private static fallbackInflightKey;
|
|
97
|
+
private static quotaLockName;
|
|
97
98
|
/**
|
|
98
99
|
* Serialize API calls through a shared gate so only one
|
|
99
100
|
* quota API request runs at a time, with a minimum gap
|
package/dist/quota-manager.js
CHANGED
|
@@ -284,6 +284,10 @@ export class QuotaManager {
|
|
|
284
284
|
static fallbackInflightKey(accountId, accessToken) {
|
|
285
285
|
return JSON.stringify([accountId, tokenFingerprint(accessToken)]);
|
|
286
286
|
}
|
|
287
|
+
static quotaLockName(accountId) {
|
|
288
|
+
const safeId = accountId.replace(/[^a-zA-Z0-9._-]+/g, '-');
|
|
289
|
+
return `opencode-fallback-quota-refresh-${safeId || 'account'}`;
|
|
290
|
+
}
|
|
287
291
|
/**
|
|
288
292
|
* Serialize API calls through a shared gate so only one
|
|
289
293
|
* quota API request runs at a time, with a minimum gap
|
|
@@ -367,20 +371,35 @@ export class QuotaManager {
|
|
|
367
371
|
return cached.quota;
|
|
368
372
|
throw new Error('Quota API rate-limited — try again later');
|
|
369
373
|
}
|
|
370
|
-
const
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
now: this.now,
|
|
374
|
+
const fileLock = await acquireRefreshFileLock({
|
|
375
|
+
name: QuotaManager.quotaLockName(accountId),
|
|
376
|
+
ttlMs: 30_000,
|
|
374
377
|
});
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
378
|
+
if (!fileLock) {
|
|
379
|
+
const cached = this.getFallback(accountId, accessToken);
|
|
380
|
+
if (cached)
|
|
381
|
+
return cached.quota;
|
|
382
|
+
throw new Error('Quota refresh is already in progress');
|
|
383
|
+
}
|
|
384
|
+
try {
|
|
385
|
+
const quota = await fetchOAuthQuotaSnapshot({
|
|
386
|
+
accessToken,
|
|
387
|
+
fetchImpl: this.fetchImpl,
|
|
388
|
+
now: this.now,
|
|
389
|
+
});
|
|
390
|
+
const now = this.now();
|
|
391
|
+
this.setFallback(accountId, {
|
|
392
|
+
quota,
|
|
393
|
+
refreshAfter: now + getQuotaCheckIntervalMs(this.storage),
|
|
394
|
+
checkedAt: now,
|
|
395
|
+
}, accessToken);
|
|
396
|
+
this.fallbackApiErrors.delete(accountId);
|
|
397
|
+
this.fallbackErrorTokenFps.delete(accountId);
|
|
398
|
+
return quota;
|
|
399
|
+
}
|
|
400
|
+
finally {
|
|
401
|
+
await fileLock.release();
|
|
402
|
+
}
|
|
384
403
|
}
|
|
385
404
|
catch (error) {
|
|
386
405
|
this._handleFallbackFetchError(accountId, accessToken, error);
|
package/dist/quotas.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isOAuthAccount } from "./accounts.js";
|
|
1
2
|
export const CLAUDE_QUOTAS_COMMAND_NAME = 'claude-quota';
|
|
2
3
|
const WINDOW_LABELS = {
|
|
3
4
|
five_hour: '5h',
|
|
@@ -60,7 +61,7 @@ function accountStoredError(account) {
|
|
|
60
61
|
export function buildFallbackQuotaSummaries(storage, errors = new Map()) {
|
|
61
62
|
if (!storage?.accounts.length)
|
|
62
63
|
return [];
|
|
63
|
-
return storage.accounts.map((account) => {
|
|
64
|
+
return storage.accounts.filter(isOAuthAccount).map((account) => {
|
|
64
65
|
const error = errors.get(account.id) ?? accountStoredError(account);
|
|
65
66
|
return {
|
|
66
67
|
name: accountName(account),
|
package/dist/relay.d.ts
CHANGED
|
@@ -25,4 +25,4 @@ export declare function sendViaRelay(options: {
|
|
|
25
25
|
affinity?: string | null;
|
|
26
26
|
optimisticResponse?: boolean;
|
|
27
27
|
}): Promise<Response>;
|
|
28
|
-
export declare const WORKER_SCRIPT = "\nasync function hashBody(body) {\n const bytes = new TextEncoder().encode(body)\n const digest = await crypto.subtle.digest('SHA-256', bytes)\n const hex = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')\n return 'sha256:' + hex\n}\n\nfunction applyPatch(base, patch) {\n return base.slice(0, patch.start) + patch.insert + base.slice(patch.start + patch.deleteCount)\n}\n\nfunction applyPatchSet(base, patch) {\n if (!Array.isArray(patch)) return applyPatch(base, patch)\n if (patch.length === 0) return base\n\n let result = ''\n let cursor = 0\n for (const item of patch) {\n if (item.start < cursor) return { error: 'overlapping patch', status: 400 }\n result += base.slice(cursor, item.start)\n result += item.insert\n cursor = item.start + item.deleteCount\n }\n return result + base.slice(cursor)\n}\n\nasync function readState(env, affinity) {\n const raw = await env.RELAY_STATE.get('session:' + affinity)\n return raw ? JSON.parse(raw) : null\n}\n\nasync function writeState(env, affinity, state) {\n await env.RELAY_STATE.put('session:' + affinity, JSON.stringify(state), { expirationTtl: 86400 })\n}\n\nasync function resolveBody(env, payload) {\n if (payload.mode === 'full_sync') return payload.body\n if (payload.mode === 'patch') {\n const state = await readState(env, payload.affinity)\n if (!state || state.hash !== payload.base_hash) {\n return { error: 'state mismatch', status: 409 }\n }\n return applyPatchSet(state.body, payload.patch)\n }\n return { error: 'unknown mode', status: 400 }\n}\n\nasync function prepareUpstream(env, payload) {\n if ((payload.protocol !== 1 && payload.protocol !== 2) || payload.type !== 'request' || !payload.affinity || !payload.upstream?.url || !payload.next_hash) {\n return { error: 'invalid payload', status: 400 }\n }\n\n const body = await resolveBody(env, payload)\n if (body && typeof body === 'object' && 'error' in body) return body\n\n if (typeof body !== 'string' || (await hashBody(body)) !== payload.next_hash) {\n return { error: 'hash mismatch', status: 409 }\n }\n\n const stateWrite = writeState(env, payload.affinity, { body, hash: payload.next_hash, revision: payload.revision }).catch(() => {})\n console.log(JSON.stringify({\n relay: 'opencode-anthropic-auth',\n transport: 'relay',\n mode: payload.mode,\n revision: payload.revision,\n affinity: String(payload.affinity).slice(0, 12),\n bodyBytes: body.length,\n }))\n\n return { body, stateWrite }\n}\n\nfunction resolveBodyFromState(state, payload) {\n if (payload.mode === 'full_sync') return payload.body\n if (payload.mode === 'patch') {\n if (!state || state.hash !== payload.base_hash) {\n return { error: 'state mismatch', status: 409 }\n }\n return applyPatchSet(state.body, payload.patch)\n }\n return { error: 'unknown mode', status: 400 }\n}\n\nfunction deferWorkerTask(task) {\n return new Promise((resolve) => setTimeout(resolve, 0)).then(task)\n}\n\nfunction checkpointWebSocketState(env, payload, body, nextState) {\n return deferWorkerTask(async () => {\n try {\n if ((await hashBody(body)) !== payload.next_hash) {\n throw new Error('hash mismatch')\n }\n await writeState(env, payload.affinity, nextState)\n } catch (error) {\n console.log(JSON.stringify({\n relay: 'opencode-anthropic-auth',\n transport: 'websocket',\n event: 'checkpoint_write_failed',\n affinity: String(payload.affinity).slice(0, 12),\n message: error instanceof Error ? error.message : String(error),\n }))\n }\n })\n}\n\nasync function prepareWebSocketUpstream(env, state, payload) {\n if (payload.protocol !== 2 || payload.type !== 'request' || !payload.id || !payload.affinity || !payload.upstream?.url || !payload.next_hash) {\n return { error: 'invalid payload', status: 400 }\n }\n\n const body = resolveBodyFromState(state, payload)\n if (body && typeof body === 'object' && 'error' in body) return body\n if (typeof body !== 'string') return { error: 'invalid body', status: 400 }\n if ((await hashBody(body)) !== payload.next_hash) {\n return { error: 'hash mismatch', status: 409 }\n }\n\n const nextState = { body, hash: payload.next_hash, revision: payload.revision }\n const checkpoint = checkpointWebSocketState(env, payload, body, nextState)\n const logAccepted = () => console.log(JSON.stringify({\n relay: 'opencode-anthropic-auth',\n transport: 'websocket',\n mode: payload.mode,\n revision: payload.revision,\n affinity: String(payload.affinity).slice(0, 12),\n bodyBytes: body.length,\n }))\n\n return { body, state: nextState, checkpoint, logAccepted }\n}\n\nfunction headersToObject(headers) {\n const result = {}\n for (const [key, value] of headers.entries()) result[key] = value\n return result\n}\n\nasync function handleRelayPayload(env, payload) {\n const prepared = await prepareUpstream(env, payload)\n if (prepared.error) return prepared\n const upstream = await fetch(payload.upstream.url, {\n method: payload.upstream.method || 'POST',\n headers: payload.upstream.headers,\n body: prepared.body,\n })\n return { upstream, stateWrite: prepared.stateWrite }\n}\n\nasync function handleWebSocket(socket, env, ctx, payload, getState, setState) {\n const heartbeat = setInterval(() => {\n try {\n socket.send(JSON.stringify({ protocol: 2, type: 'keepalive' }))\n } catch {\n clearInterval(heartbeat)\n }\n }, 15000)\n try {\n const result = await prepareWebSocketUpstream(env, getState(), payload)\n if (result.error) {\n socket.send(JSON.stringify({ protocol: 2, type: 'error', id: payload.id, status: result.status, message: result.error }))\n return\n }\n\n setState(result.state)\n socket.send(JSON.stringify({ protocol: 2, type: 'accepted', id: payload.id, hash: result.state.hash, revision: result.state.revision }))\n ctx?.waitUntil?.(deferWorkerTask(result.logAccepted))\n\n const upstreamPromise = fetch(payload.upstream.url, {\n method: payload.upstream.method || 'POST',\n headers: payload.upstream.headers,\n body: result.body,\n })\n ctx?.waitUntil?.(result.checkpoint)\n const upstream = await upstreamPromise\n socket.send(JSON.stringify({\n protocol: 2,\n type: 'response_start',\n id: payload.id,\n status: upstream.status,\n statusText: upstream.statusText,\n headers: headersToObject(upstream.headers),\n }))\n\n const reader = upstream.body?.getReader()\n if (reader) {\n while (true) {\n const { done, value } = await reader.read()\n if (done) break\n socket.send(value)\n }\n }\n socket.send(JSON.stringify({ protocol: 2, type: 'done', id: payload.id }))\n } catch (error) {\n socket.send(JSON.stringify({ protocol: 2, type: 'error', id: payload.id, status: 500, message: error instanceof Error ? error.message : String(error) }))\n } finally {\n clearInterval(heartbeat)\n }\n}\n\nexport default {\n async fetch(request, env, ctx) {\n if (request.headers.get('Upgrade') === 'websocket') {\n const url = new URL(request.url)\n const token = url.searchParams.get('token')\n const affinity = url.searchParams.get('affinity')\n if (token !== env.RELAY_TOKEN) return new Response('unauthorized', { status: 401 })\n if (!affinity) return new Response('missing affinity', { status: 400 })\n const pair = new WebSocketPair()\n const client = pair[0]\n const server = pair[1]\n server.binaryType = 'arraybuffer'\n let state = null\n let ready = false\n server.accept()\n const loadState = readState(env, affinity).then((loadedState) => {\n state = loadedState\n ready = true\n server.send(JSON.stringify({\n protocol: 2,\n type: 'ready',\n state: state ? { hash: state.hash, revision: state.revision } : null,\n }))\n }).catch((error) => {\n server.send(JSON.stringify({ protocol: 2, type: 'error', status: 500, message: error instanceof Error ? error.message : String(error) }))\n server.close(1011, 'state load failed')\n })\n ctx?.waitUntil?.(loadState)\n if (!ctx?.waitUntil) void loadState\n let busy = false\n server.addEventListener('message', (event) => {\n if (!ready) {\n server.send(JSON.stringify({ protocol: 2, type: 'error', status: 425, message: 'relay state is not ready' }))\n return\n }\n if (busy) {\n server.send(JSON.stringify({ protocol: 2, type: 'error', status: 429, message: 'request already in flight' }))\n return\n }\n let payload\n try {\n payload = JSON.parse(event.data)\n } catch {\n server.send(JSON.stringify({ protocol: 2, type: 'error', status: 400, message: 'invalid JSON payload' }))\n return\n }\n payload.affinity = affinity\n busy = true\n const run = handleWebSocket(server, env, ctx, payload, () => state, (nextState) => { state = nextState }).finally(() => { busy = false })\n ctx?.waitUntil?.(run)\n if (!ctx?.waitUntil) void run\n })\n return new Response(null, { status: 101, webSocket: client })\n }\n\n if (request.method === 'GET') {\n return Response.json({ status: 'ok', transports: ['http', 'websocket'] })\n }\n if (request.method !== 'POST') return new Response('method not allowed', { status: 405 })\n if (request.headers.get('x-relay-token') !== env.RELAY_TOKEN) {\n return new Response('unauthorized', { status: 401 })\n }\n\n try {\n const payload = await request.json()\n const result = await handleRelayPayload(env, payload)\n if (result.error) return Response.json({ error: result.error }, { status: result.status })\n\n if (result.stateWrite) ctx.waitUntil(result.stateWrite)\n\n const upstream = result.upstream\n return new Response(upstream.body, {\n status: upstream.status,\n statusText: upstream.statusText,\n headers: upstream.headers,\n })\n } catch (error) {\n return Response.json(\n { error: error instanceof Error ? error.message : 'internal relay error' },\n { status: 502 },\n )\n }\n },\n}\n";
|
|
28
|
+
export declare const WORKER_SCRIPT = "\nasync function hashBody(body) {\n const bytes = new TextEncoder().encode(body)\n const digest = await crypto.subtle.digest('SHA-256', bytes)\n const hex = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')\n return 'sha256:' + hex\n}\n\nfunction applyPatch(base, patch) {\n return base.slice(0, patch.start) + patch.insert + base.slice(patch.start + patch.deleteCount)\n}\n\nfunction applyPatchSet(base, patch) {\n if (!Array.isArray(patch)) return applyPatch(base, patch)\n if (patch.length === 0) return base\n\n let result = ''\n let cursor = 0\n for (const item of patch) {\n if (item.start < cursor) return { error: 'overlapping patch', status: 400 }\n result += base.slice(cursor, item.start)\n result += item.insert\n cursor = item.start + item.deleteCount\n }\n return result + base.slice(cursor)\n}\n\nasync function readState(env, affinity) {\n const raw = await env.RELAY_STATE.get('session:' + affinity)\n return raw ? JSON.parse(raw) : null\n}\n\nasync function writeState(env, affinity, state) {\n await env.RELAY_STATE.put('session:' + affinity, JSON.stringify(state), { expirationTtl: 86400 })\n}\n\nasync function resolveBody(env, payload) {\n if (payload.mode === 'full_sync') return payload.body\n if (payload.mode === 'patch') {\n const state = await readState(env, payload.affinity)\n if (!state || state.hash !== payload.base_hash) {\n return { error: 'state mismatch', status: 409 }\n }\n return applyPatchSet(state.body, payload.patch)\n }\n return { error: 'unknown mode', status: 400 }\n}\n\nasync function prepareUpstream(env, payload) {\n if ((payload.protocol !== 1 && payload.protocol !== 2) || payload.type !== 'request' || !payload.affinity || !payload.upstream?.url || !payload.next_hash) {\n return { error: 'invalid payload', status: 400 }\n }\n\n const body = await resolveBody(env, payload)\n if (body && typeof body === 'object' && 'error' in body) return body\n\n if (typeof body !== 'string' || (await hashBody(body)) !== payload.next_hash) {\n return { error: 'hash mismatch', status: 409 }\n }\n\n const stateWrite = writeState(env, payload.affinity, { body, hash: payload.next_hash, revision: payload.revision }).catch(() => {})\n console.log(JSON.stringify({\n relay: 'opencode-anthropic-auth',\n transport: 'relay',\n mode: payload.mode,\n revision: payload.revision,\n affinity: String(payload.affinity).slice(0, 12),\n bodyBytes: body.length,\n }))\n\n return { body, stateWrite }\n}\n\nfunction resolveBodyFromState(state, payload) {\n if (payload.mode === 'full_sync') return payload.body\n if (payload.mode === 'patch') {\n if (!state || state.hash !== payload.base_hash) {\n return { error: 'state mismatch', status: 409 }\n }\n return applyPatchSet(state.body, payload.patch)\n }\n return { error: 'unknown mode', status: 400 }\n}\n\nfunction deferWorkerTask(task) {\n return new Promise((resolve) => setTimeout(resolve, 0)).then(task)\n}\n\nconst WEBSOCKET_STREAM_FLUSH_BYTES = 1024\nconst WEBSOCKET_STREAM_FLUSH_MS = 20\n\nfunction createWebSocketStreamCoalescer(socket, options = {}) {\n const flushBytes = options.flushBytes || WEBSOCKET_STREAM_FLUSH_BYTES\n const flushMs = options.flushMs ?? WEBSOCKET_STREAM_FLUSH_MS\n let chunks = []\n let byteLength = 0\n let flushTimer = null\n const stats = {\n upstreamChunks: 0,\n upstreamBytes: 0,\n frames: 0,\n frameBytes: 0,\n }\n\n const clearFlushTimer = () => {\n if (flushTimer === null) return\n clearTimeout(flushTimer)\n flushTimer = null\n }\n\n const mergeChunks = () => {\n if (chunks.length === 1) return chunks[0]\n const frame = new Uint8Array(byteLength)\n let offset = 0\n for (const chunk of chunks) {\n frame.set(chunk, offset)\n offset += chunk.byteLength\n }\n return frame\n }\n\n const flush = () => {\n clearFlushTimer()\n if (byteLength === 0) return\n const frame = mergeChunks()\n chunks = []\n byteLength = 0\n socket.send(frame)\n stats.frames += 1\n stats.frameBytes += frame.byteLength\n }\n\n const scheduleFlush = () => {\n if (flushTimer !== null) return\n flushTimer = setTimeout(flush, flushMs)\n }\n\n return {\n push(value) {\n const chunk = value instanceof Uint8Array ? value : new Uint8Array(value)\n if (chunk.byteLength === 0) return\n stats.upstreamChunks += 1\n stats.upstreamBytes += chunk.byteLength\n chunks.push(chunk)\n byteLength += chunk.byteLength\n if (byteLength >= flushBytes) flush()\n else scheduleFlush()\n },\n flush,\n stats() {\n return { ...stats, pendingBytes: byteLength }\n },\n }\n}\n\nfunction checkpointWebSocketState(env, payload, body, nextState) {\n return deferWorkerTask(async () => {\n try {\n if ((await hashBody(body)) !== payload.next_hash) {\n throw new Error('hash mismatch')\n }\n await writeState(env, payload.affinity, nextState)\n } catch (error) {\n console.log(JSON.stringify({\n relay: 'opencode-anthropic-auth',\n transport: 'websocket',\n event: 'checkpoint_write_failed',\n affinity: String(payload.affinity).slice(0, 12),\n message: error instanceof Error ? error.message : String(error),\n }))\n }\n })\n}\n\nasync function prepareWebSocketUpstream(env, state, payload) {\n if (payload.protocol !== 2 || payload.type !== 'request' || !payload.id || !payload.affinity || !payload.upstream?.url || !payload.next_hash) {\n return { error: 'invalid payload', status: 400 }\n }\n\n const body = resolveBodyFromState(state, payload)\n if (body && typeof body === 'object' && 'error' in body) return body\n if (typeof body !== 'string') return { error: 'invalid body', status: 400 }\n if ((await hashBody(body)) !== payload.next_hash) {\n return { error: 'hash mismatch', status: 409 }\n }\n\n const nextState = { body, hash: payload.next_hash, revision: payload.revision }\n const checkpoint = checkpointWebSocketState(env, payload, body, nextState)\n const logAccepted = () => console.log(JSON.stringify({\n relay: 'opencode-anthropic-auth',\n transport: 'websocket',\n mode: payload.mode,\n revision: payload.revision,\n affinity: String(payload.affinity).slice(0, 12),\n bodyBytes: body.length,\n }))\n\n return { body, state: nextState, checkpoint, logAccepted }\n}\n\nfunction headersToObject(headers) {\n const result = {}\n for (const [key, value] of headers.entries()) result[key] = value\n return result\n}\n\nasync function handleRelayPayload(env, payload) {\n const prepared = await prepareUpstream(env, payload)\n if (prepared.error) return prepared\n const upstream = await fetch(payload.upstream.url, {\n method: payload.upstream.method || 'POST',\n headers: payload.upstream.headers,\n body: prepared.body,\n })\n return { upstream, stateWrite: prepared.stateWrite }\n}\n\nasync function handleWebSocket(socket, env, ctx, payload, getState, setState) {\n const heartbeat = setInterval(() => {\n try {\n socket.send(JSON.stringify({ protocol: 2, type: 'keepalive' }))\n } catch {\n clearInterval(heartbeat)\n }\n }, 15000)\n let streamCoalescer = null\n try {\n const result = await prepareWebSocketUpstream(env, getState(), payload)\n if (result.error) {\n socket.send(JSON.stringify({ protocol: 2, type: 'error', id: payload.id, status: result.status, message: result.error }))\n return\n }\n\n setState(result.state)\n socket.send(JSON.stringify({ protocol: 2, type: 'accepted', id: payload.id, hash: result.state.hash, revision: result.state.revision }))\n ctx?.waitUntil?.(deferWorkerTask(result.logAccepted))\n\n const upstreamPromise = fetch(payload.upstream.url, {\n method: payload.upstream.method || 'POST',\n headers: payload.upstream.headers,\n body: result.body,\n })\n ctx?.waitUntil?.(result.checkpoint)\n const upstream = await upstreamPromise\n socket.send(JSON.stringify({\n protocol: 2,\n type: 'response_start',\n id: payload.id,\n status: upstream.status,\n statusText: upstream.statusText,\n headers: headersToObject(upstream.headers),\n }))\n\n const reader = upstream.body?.getReader()\n if (reader) {\n streamCoalescer = createWebSocketStreamCoalescer(socket)\n while (true) {\n const { done, value } = await reader.read()\n if (done) break\n streamCoalescer.push(value)\n }\n streamCoalescer.flush()\n }\n const stats = streamCoalescer?.stats?.() || {}\n socket.send(JSON.stringify({ protocol: 2, type: 'done', id: payload.id, ...stats }))\n } catch (error) {\n try {\n streamCoalescer?.flush?.()\n } catch {}\n socket.send(JSON.stringify({ protocol: 2, type: 'error', id: payload.id, status: 500, message: error instanceof Error ? error.message : String(error) }))\n } finally {\n clearInterval(heartbeat)\n }\n}\n\nexport default {\n async fetch(request, env, ctx) {\n if (request.headers.get('Upgrade') === 'websocket') {\n const url = new URL(request.url)\n const token = url.searchParams.get('token')\n const affinity = url.searchParams.get('affinity')\n if (token !== env.RELAY_TOKEN) return new Response('unauthorized', { status: 401 })\n if (!affinity) return new Response('missing affinity', { status: 400 })\n const pair = new WebSocketPair()\n const client = pair[0]\n const server = pair[1]\n server.binaryType = 'arraybuffer'\n let state = null\n let ready = false\n server.accept()\n const loadState = readState(env, affinity).then((loadedState) => {\n state = loadedState\n ready = true\n server.send(JSON.stringify({\n protocol: 2,\n type: 'ready',\n state: state ? { hash: state.hash, revision: state.revision } : null,\n }))\n }).catch((error) => {\n server.send(JSON.stringify({ protocol: 2, type: 'error', status: 500, message: error instanceof Error ? error.message : String(error) }))\n server.close(1011, 'state load failed')\n })\n ctx?.waitUntil?.(loadState)\n if (!ctx?.waitUntil) void loadState\n let busy = false\n server.addEventListener('message', (event) => {\n if (!ready) {\n server.send(JSON.stringify({ protocol: 2, type: 'error', status: 425, message: 'relay state is not ready' }))\n return\n }\n if (busy) {\n server.send(JSON.stringify({ protocol: 2, type: 'error', status: 429, message: 'request already in flight' }))\n return\n }\n let payload\n try {\n payload = JSON.parse(event.data)\n } catch {\n server.send(JSON.stringify({ protocol: 2, type: 'error', status: 400, message: 'invalid JSON payload' }))\n return\n }\n payload.affinity = affinity\n busy = true\n const run = handleWebSocket(server, env, ctx, payload, () => state, (nextState) => { state = nextState }).finally(() => { busy = false })\n ctx?.waitUntil?.(run)\n if (!ctx?.waitUntil) void run\n })\n return new Response(null, { status: 101, webSocket: client })\n }\n\n if (request.method === 'GET') {\n return Response.json({ status: 'ok', transports: ['http', 'websocket'] })\n }\n if (request.method !== 'POST') return new Response('method not allowed', { status: 405 })\n if (request.headers.get('x-relay-token') !== env.RELAY_TOKEN) {\n return new Response('unauthorized', { status: 401 })\n }\n\n try {\n const payload = await request.json()\n const result = await handleRelayPayload(env, payload)\n if (result.error) return Response.json({ error: result.error }, { status: result.status })\n\n if (result.stateWrite) ctx.waitUntil(result.stateWrite)\n\n const upstream = result.upstream\n return new Response(upstream.body, {\n status: upstream.status,\n statusText: upstream.statusText,\n headers: upstream.headers,\n })\n } catch (error) {\n return Response.json(\n { error: error instanceof Error ? error.message : 'internal relay error' },\n { status: 502 },\n )\n }\n },\n}\n";
|
package/dist/relay.js
CHANGED
|
@@ -21,6 +21,10 @@ class RelayWebSocketConnectionResetError extends Error {
|
|
|
21
21
|
const sessionState = new Map();
|
|
22
22
|
const websocketSessions = new Map();
|
|
23
23
|
const loggedRelayConfigMessages = new Set();
|
|
24
|
+
const RELAY_SESSION_STATE_TTL_MS = 2 * 60 * 60_000;
|
|
25
|
+
const RELAY_WEBSOCKET_IDLE_TTL_MS = 30 * 60_000;
|
|
26
|
+
const MAX_RELAY_SESSION_STATES = 128;
|
|
27
|
+
const MAX_RELAY_WEBSOCKET_SESSIONS = 32;
|
|
24
28
|
let nextRequestId = 0;
|
|
25
29
|
function createRequestId() {
|
|
26
30
|
nextRequestId += 1;
|
|
@@ -136,14 +140,23 @@ function jsonHeaders(headers) {
|
|
|
136
140
|
}
|
|
137
141
|
return result;
|
|
138
142
|
}
|
|
139
|
-
async function postRelay(config, payload) {
|
|
143
|
+
async function postRelay(config, payload, signal) {
|
|
144
|
+
const stringifyStart = perfNowMs();
|
|
145
|
+
const body = JSON.stringify(payload);
|
|
146
|
+
relayPerfLog('http_payload_stringify', {
|
|
147
|
+
session: shortAffinity(payload.affinity),
|
|
148
|
+
ms: formatMs(perfNowMs() - stringifyStart),
|
|
149
|
+
relayBytes: body.length,
|
|
150
|
+
mode: payload.mode,
|
|
151
|
+
});
|
|
140
152
|
return fetch(config.url, {
|
|
141
153
|
method: 'POST',
|
|
142
154
|
headers: {
|
|
143
155
|
'content-type': 'application/json',
|
|
144
156
|
'x-relay-token': config.token,
|
|
145
157
|
},
|
|
146
|
-
body
|
|
158
|
+
body,
|
|
159
|
+
signal: signal ?? undefined,
|
|
147
160
|
});
|
|
148
161
|
}
|
|
149
162
|
function shortAffinity(affinity) {
|
|
@@ -155,6 +168,14 @@ function perfNowMs() {
|
|
|
155
168
|
function formatMs(value) {
|
|
156
169
|
return Math.round(value * 10) / 10;
|
|
157
170
|
}
|
|
171
|
+
function relayPerfLoggingEnabled() {
|
|
172
|
+
return process.env.OPENCODE_ANTHROPIC_AUTH_PERF === '1';
|
|
173
|
+
}
|
|
174
|
+
function relayPerfLog(stage, data) {
|
|
175
|
+
if (!relayPerfLoggingEnabled())
|
|
176
|
+
return;
|
|
177
|
+
relayLog(`[perf] relay ${stage} ${JSON.stringify(data)}`);
|
|
178
|
+
}
|
|
158
179
|
function createRelayPayload(options) {
|
|
159
180
|
const { input, init, headers, bodyText, affinity, previous, nextHash } = options;
|
|
160
181
|
const rewrittenUrl = input instanceof Request ? input.url : input.toString();
|
|
@@ -197,21 +218,25 @@ function createFullSyncPayload(payload, bodyText) {
|
|
|
197
218
|
};
|
|
198
219
|
}
|
|
199
220
|
function updateLocalRelayState(affinity, bodyText, nextHash, revision) {
|
|
221
|
+
const now = Date.now();
|
|
222
|
+
sessionState.delete(affinity);
|
|
200
223
|
sessionState.set(affinity, {
|
|
201
224
|
body: bodyText,
|
|
202
225
|
hash: nextHash,
|
|
203
226
|
revision,
|
|
227
|
+
updatedAt: now,
|
|
204
228
|
});
|
|
229
|
+
cleanupRelaySessionCaches(now);
|
|
205
230
|
}
|
|
206
231
|
async function sendRelayHttp(options) {
|
|
207
|
-
const { config, payload, bodyText, fallback } = options;
|
|
232
|
+
const { config, payload, bodyText, fallback, signal } = options;
|
|
208
233
|
let actualPayload = payload;
|
|
209
|
-
let response = await postRelay(config, actualPayload);
|
|
234
|
+
let response = await postRelay(config, actualPayload, signal);
|
|
210
235
|
if (response.status === 409 && actualPayload.mode === 'patch') {
|
|
211
236
|
relayLog(`state mismatch from relay session=${shortAffinity(actualPayload.affinity)}; retrying full_sync`);
|
|
212
237
|
await response.body?.cancel().catch(() => { });
|
|
213
238
|
actualPayload = createFullSyncPayload(actualPayload, bodyText);
|
|
214
|
-
response = await postRelay(config, actualPayload);
|
|
239
|
+
response = await postRelay(config, actualPayload, signal);
|
|
215
240
|
}
|
|
216
241
|
if (!response.ok && response.status >= 500 && config.fallbackToDirect) {
|
|
217
242
|
relayLog(`relay returned ${response.status}; falling back direct session=${shortAffinity(actualPayload.affinity)}`);
|
|
@@ -279,11 +304,27 @@ class PersistentRelaySession {
|
|
|
279
304
|
queue = Promise.resolve();
|
|
280
305
|
pending;
|
|
281
306
|
serverState;
|
|
307
|
+
lastUsedAt = Date.now();
|
|
282
308
|
constructor(config, affinity) {
|
|
283
309
|
this.config = config;
|
|
284
310
|
this.affinity = affinity;
|
|
285
311
|
}
|
|
312
|
+
touch(now = Date.now()) {
|
|
313
|
+
this.lastUsedAt = now;
|
|
314
|
+
}
|
|
315
|
+
canEvict(now = Date.now()) {
|
|
316
|
+
return (!this.pending &&
|
|
317
|
+
!this.connecting &&
|
|
318
|
+
now - this.lastUsedAt > RELAY_WEBSOCKET_IDLE_TTL_MS);
|
|
319
|
+
}
|
|
320
|
+
dispose() {
|
|
321
|
+
this.socket?.close();
|
|
322
|
+
this.socket = undefined;
|
|
323
|
+
this.serverState = undefined;
|
|
324
|
+
this.connecting = undefined;
|
|
325
|
+
}
|
|
286
326
|
send(payload, bodyText, optimisticResponse = false) {
|
|
327
|
+
this.touch();
|
|
287
328
|
const enqueuedAt = perfNowMs();
|
|
288
329
|
const start = this.queue
|
|
289
330
|
.catch(() => { })
|
|
@@ -584,7 +625,7 @@ class PersistentRelaySession {
|
|
|
584
625
|
return;
|
|
585
626
|
}
|
|
586
627
|
if (message.type === 'done') {
|
|
587
|
-
this.finishPending();
|
|
628
|
+
this.finishPending(message);
|
|
588
629
|
return;
|
|
589
630
|
}
|
|
590
631
|
if (message.type === 'error') {
|
|
@@ -609,12 +650,15 @@ class PersistentRelaySession {
|
|
|
609
650
|
this.failPending(error);
|
|
610
651
|
}
|
|
611
652
|
}
|
|
612
|
-
finishPending() {
|
|
653
|
+
finishPending(done) {
|
|
613
654
|
const pending = this.pending;
|
|
614
655
|
if (!pending)
|
|
615
656
|
return;
|
|
616
657
|
const finishedAt = perfNowMs();
|
|
617
|
-
|
|
658
|
+
const workerStats = done && 'frames' in done
|
|
659
|
+
? ` upstreamChunks=${done.upstreamChunks ?? 'unknown'} upstreamBytes=${done.upstreamBytes ?? 'unknown'} frames=${done.frames} frameBytes=${done.frameBytes ?? 'unknown'}`
|
|
660
|
+
: '';
|
|
661
|
+
relayLog(`perf websocket done session=${shortAffinity(this.affinity)} request=${pending.payload.id} sentMs=${formatMs(finishedAt - pending.sentAt)} streamMs=${pending.responseStartedAt == null ? 'unknown' : formatMs(finishedAt - pending.responseStartedAt)} chunks=${pending.streamChunkCount} bytes=${pending.streamByteCount}${workerStats}`);
|
|
618
662
|
clearTimeout(pending.timeout);
|
|
619
663
|
if (!pending.streamDone) {
|
|
620
664
|
pending.streamDone = true;
|
|
@@ -638,12 +682,48 @@ class PersistentRelaySession {
|
|
|
638
682
|
pending.rejectDone(error);
|
|
639
683
|
}
|
|
640
684
|
}
|
|
685
|
+
function cleanupRelaySessionCaches(now = Date.now()) {
|
|
686
|
+
for (const [affinity, state] of sessionState) {
|
|
687
|
+
if (now - state.updatedAt > RELAY_SESSION_STATE_TTL_MS) {
|
|
688
|
+
sessionState.delete(affinity);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
while (sessionState.size > MAX_RELAY_SESSION_STATES) {
|
|
692
|
+
const oldest = sessionState.keys().next().value;
|
|
693
|
+
if (oldest === undefined)
|
|
694
|
+
break;
|
|
695
|
+
sessionState.delete(oldest);
|
|
696
|
+
}
|
|
697
|
+
for (const [affinity, session] of websocketSessions) {
|
|
698
|
+
if (session.canEvict(now)) {
|
|
699
|
+
session.dispose();
|
|
700
|
+
websocketSessions.delete(affinity);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
while (websocketSessions.size > MAX_RELAY_WEBSOCKET_SESSIONS) {
|
|
704
|
+
let evicted = false;
|
|
705
|
+
for (const [affinity, session] of websocketSessions) {
|
|
706
|
+
if (!session.canEvict(now))
|
|
707
|
+
continue;
|
|
708
|
+
session.dispose();
|
|
709
|
+
websocketSessions.delete(affinity);
|
|
710
|
+
evicted = true;
|
|
711
|
+
break;
|
|
712
|
+
}
|
|
713
|
+
if (!evicted)
|
|
714
|
+
break;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
641
717
|
function getPersistentRelaySession(config, affinity) {
|
|
718
|
+
cleanupRelaySessionCaches();
|
|
642
719
|
const existing = websocketSessions.get(affinity);
|
|
643
|
-
if (existing)
|
|
720
|
+
if (existing) {
|
|
721
|
+
existing.touch();
|
|
644
722
|
return existing;
|
|
723
|
+
}
|
|
645
724
|
const session = new PersistentRelaySession(config, affinity);
|
|
646
725
|
websocketSessions.set(affinity, session);
|
|
726
|
+
cleanupRelaySessionCaches();
|
|
647
727
|
return session;
|
|
648
728
|
}
|
|
649
729
|
export async function sendViaRelay(options) {
|
|
@@ -658,8 +738,15 @@ export async function sendViaRelay(options) {
|
|
|
658
738
|
return fallback();
|
|
659
739
|
}
|
|
660
740
|
const bodyText = body;
|
|
741
|
+
const hashStart = perfNowMs();
|
|
661
742
|
const nextHash = hashBody(bodyText);
|
|
743
|
+
relayPerfLog('hash_body', {
|
|
744
|
+
session: shortAffinity(affinity),
|
|
745
|
+
ms: formatMs(perfNowMs() - hashStart),
|
|
746
|
+
bodyBytes: bodyText.length,
|
|
747
|
+
});
|
|
662
748
|
const previous = sessionState.get(affinity);
|
|
749
|
+
const payloadStart = perfNowMs();
|
|
663
750
|
const payload = createRelayPayload({
|
|
664
751
|
input,
|
|
665
752
|
init,
|
|
@@ -669,8 +756,16 @@ export async function sendViaRelay(options) {
|
|
|
669
756
|
previous,
|
|
670
757
|
nextHash,
|
|
671
758
|
});
|
|
759
|
+
relayPerfLog('create_payload', {
|
|
760
|
+
session: shortAffinity(affinity),
|
|
761
|
+
ms: formatMs(perfNowMs() - payloadStart),
|
|
762
|
+
mode: payload.mode,
|
|
763
|
+
bodyBytes: bodyText.length,
|
|
764
|
+
previousBytes: previous?.body.length,
|
|
765
|
+
});
|
|
672
766
|
try {
|
|
673
767
|
let result;
|
|
768
|
+
const sendStart = perfNowMs();
|
|
674
769
|
if (config.transport === 'websocket') {
|
|
675
770
|
try {
|
|
676
771
|
const session = getPersistentRelaySession(config, affinity);
|
|
@@ -678,19 +773,47 @@ export async function sendViaRelay(options) {
|
|
|
678
773
|
}
|
|
679
774
|
catch (error) {
|
|
680
775
|
relayLog(`websocket relay failed session=${shortAffinity(affinity)}; trying http relay: ${error instanceof Error ? error.message : String(error)}`);
|
|
681
|
-
result = await sendRelayHttp({
|
|
776
|
+
result = await sendRelayHttp({
|
|
777
|
+
config,
|
|
778
|
+
payload,
|
|
779
|
+
bodyText,
|
|
780
|
+
fallback,
|
|
781
|
+
signal: init?.signal,
|
|
782
|
+
});
|
|
682
783
|
}
|
|
683
784
|
}
|
|
684
785
|
else {
|
|
685
|
-
result = await sendRelayHttp({
|
|
786
|
+
result = await sendRelayHttp({
|
|
787
|
+
config,
|
|
788
|
+
payload,
|
|
789
|
+
bodyText,
|
|
790
|
+
fallback,
|
|
791
|
+
signal: init?.signal,
|
|
792
|
+
});
|
|
686
793
|
}
|
|
794
|
+
relayPerfLog('send', {
|
|
795
|
+
session: shortAffinity(affinity),
|
|
796
|
+
ms: formatMs(perfNowMs() - sendStart),
|
|
797
|
+
transport: result.transport,
|
|
798
|
+
protocol: result.protocol,
|
|
799
|
+
mode: result.payload.mode,
|
|
800
|
+
status: result.response.status,
|
|
801
|
+
usedRelay: result.usedRelay,
|
|
802
|
+
});
|
|
687
803
|
if (!result.usedRelay)
|
|
688
804
|
return result.response;
|
|
689
805
|
if (result.transport === 'http') {
|
|
690
806
|
updateLocalRelayState(affinity, bodyText, result.payload.next_hash, result.payload.revision);
|
|
691
807
|
}
|
|
808
|
+
const payloadStringifyStart = perfNowMs();
|
|
692
809
|
const actualPayloadBytes = JSON.stringify(result.payload).length;
|
|
810
|
+
relayPerfLog('payload_stringify', {
|
|
811
|
+
session: shortAffinity(affinity),
|
|
812
|
+
ms: formatMs(perfNowMs() - payloadStringifyStart),
|
|
813
|
+
relayBytes: actualPayloadBytes,
|
|
814
|
+
});
|
|
693
815
|
relayLog(`used relay transport=${result.transport} protocol=${result.protocol} mode=${result.payload.mode} status=${result.response.status} session=${shortAffinity(affinity)} bodyBytes=${bodyText.length} relayBytes=${actualPayloadBytes}`);
|
|
816
|
+
const dumpStart = perfNowMs();
|
|
694
817
|
await dumpRelayRequest({
|
|
695
818
|
affinity,
|
|
696
819
|
transport: result.transport,
|
|
@@ -702,6 +825,11 @@ export async function sendViaRelay(options) {
|
|
|
702
825
|
payload: result.payload,
|
|
703
826
|
relayBytes: actualPayloadBytes,
|
|
704
827
|
});
|
|
828
|
+
relayPerfLog('dump', {
|
|
829
|
+
session: shortAffinity(affinity),
|
|
830
|
+
ms: formatMs(perfNowMs() - dumpStart),
|
|
831
|
+
enabled: true,
|
|
832
|
+
});
|
|
705
833
|
return result.response;
|
|
706
834
|
}
|
|
707
835
|
catch (error) {
|
|
@@ -801,6 +929,73 @@ function deferWorkerTask(task) {
|
|
|
801
929
|
return new Promise((resolve) => setTimeout(resolve, 0)).then(task)
|
|
802
930
|
}
|
|
803
931
|
|
|
932
|
+
const WEBSOCKET_STREAM_FLUSH_BYTES = 1024
|
|
933
|
+
const WEBSOCKET_STREAM_FLUSH_MS = 20
|
|
934
|
+
|
|
935
|
+
function createWebSocketStreamCoalescer(socket, options = {}) {
|
|
936
|
+
const flushBytes = options.flushBytes || WEBSOCKET_STREAM_FLUSH_BYTES
|
|
937
|
+
const flushMs = options.flushMs ?? WEBSOCKET_STREAM_FLUSH_MS
|
|
938
|
+
let chunks = []
|
|
939
|
+
let byteLength = 0
|
|
940
|
+
let flushTimer = null
|
|
941
|
+
const stats = {
|
|
942
|
+
upstreamChunks: 0,
|
|
943
|
+
upstreamBytes: 0,
|
|
944
|
+
frames: 0,
|
|
945
|
+
frameBytes: 0,
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const clearFlushTimer = () => {
|
|
949
|
+
if (flushTimer === null) return
|
|
950
|
+
clearTimeout(flushTimer)
|
|
951
|
+
flushTimer = null
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const mergeChunks = () => {
|
|
955
|
+
if (chunks.length === 1) return chunks[0]
|
|
956
|
+
const frame = new Uint8Array(byteLength)
|
|
957
|
+
let offset = 0
|
|
958
|
+
for (const chunk of chunks) {
|
|
959
|
+
frame.set(chunk, offset)
|
|
960
|
+
offset += chunk.byteLength
|
|
961
|
+
}
|
|
962
|
+
return frame
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
const flush = () => {
|
|
966
|
+
clearFlushTimer()
|
|
967
|
+
if (byteLength === 0) return
|
|
968
|
+
const frame = mergeChunks()
|
|
969
|
+
chunks = []
|
|
970
|
+
byteLength = 0
|
|
971
|
+
socket.send(frame)
|
|
972
|
+
stats.frames += 1
|
|
973
|
+
stats.frameBytes += frame.byteLength
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const scheduleFlush = () => {
|
|
977
|
+
if (flushTimer !== null) return
|
|
978
|
+
flushTimer = setTimeout(flush, flushMs)
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
return {
|
|
982
|
+
push(value) {
|
|
983
|
+
const chunk = value instanceof Uint8Array ? value : new Uint8Array(value)
|
|
984
|
+
if (chunk.byteLength === 0) return
|
|
985
|
+
stats.upstreamChunks += 1
|
|
986
|
+
stats.upstreamBytes += chunk.byteLength
|
|
987
|
+
chunks.push(chunk)
|
|
988
|
+
byteLength += chunk.byteLength
|
|
989
|
+
if (byteLength >= flushBytes) flush()
|
|
990
|
+
else scheduleFlush()
|
|
991
|
+
},
|
|
992
|
+
flush,
|
|
993
|
+
stats() {
|
|
994
|
+
return { ...stats, pendingBytes: byteLength }
|
|
995
|
+
},
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
804
999
|
function checkpointWebSocketState(env, payload, body, nextState) {
|
|
805
1000
|
return deferWorkerTask(async () => {
|
|
806
1001
|
try {
|
|
@@ -871,6 +1066,7 @@ async function handleWebSocket(socket, env, ctx, payload, getState, setState) {
|
|
|
871
1066
|
clearInterval(heartbeat)
|
|
872
1067
|
}
|
|
873
1068
|
}, 15000)
|
|
1069
|
+
let streamCoalescer = null
|
|
874
1070
|
try {
|
|
875
1071
|
const result = await prepareWebSocketUpstream(env, getState(), payload)
|
|
876
1072
|
if (result.error) {
|
|
@@ -900,14 +1096,20 @@ async function handleWebSocket(socket, env, ctx, payload, getState, setState) {
|
|
|
900
1096
|
|
|
901
1097
|
const reader = upstream.body?.getReader()
|
|
902
1098
|
if (reader) {
|
|
1099
|
+
streamCoalescer = createWebSocketStreamCoalescer(socket)
|
|
903
1100
|
while (true) {
|
|
904
1101
|
const { done, value } = await reader.read()
|
|
905
1102
|
if (done) break
|
|
906
|
-
|
|
1103
|
+
streamCoalescer.push(value)
|
|
907
1104
|
}
|
|
1105
|
+
streamCoalescer.flush()
|
|
908
1106
|
}
|
|
909
|
-
|
|
1107
|
+
const stats = streamCoalescer?.stats?.() || {}
|
|
1108
|
+
socket.send(JSON.stringify({ protocol: 2, type: 'done', id: payload.id, ...stats }))
|
|
910
1109
|
} catch (error) {
|
|
1110
|
+
try {
|
|
1111
|
+
streamCoalescer?.flush?.()
|
|
1112
|
+
} catch {}
|
|
911
1113
|
socket.send(JSON.stringify({ protocol: 2, type: 'error', id: payload.id, status: 500, message: error instanceof Error ? error.message : String(error) }))
|
|
912
1114
|
} finally {
|
|
913
1115
|
clearInterval(heartbeat)
|