@cortexkit/anthropic-auth-core 1.15.1 → 1.16.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 +14 -2
- package/dist/accounts.js +102 -22
- package/dist/cachekeep-registry.d.ts +21 -0
- package/dist/cachekeep-registry.js +124 -0
- package/dist/cachekeep.d.ts +16 -1
- package/dist/cachekeep.js +93 -14
- package/dist/dump.d.ts +19 -0
- package/dist/dump.js +162 -10
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/quota-manager.d.ts +4 -3
- package/dist/quota-manager.js +15 -7
- package/dist/routing.d.ts +3 -1
- package/dist/routing.js +20 -2
- package/dist/sticky-routing.d.ts +86 -0
- package/dist/sticky-routing.js +450 -0
- package/package.json +1 -1
package/dist/dump.d.ts
CHANGED
|
@@ -13,6 +13,25 @@ export declare function isDumpEnabled(): boolean;
|
|
|
13
13
|
export declare function setDumpEnabled(enabled: boolean): void;
|
|
14
14
|
export declare function resetDumpState(): void;
|
|
15
15
|
export declare function getDumpDirectory(): string;
|
|
16
|
+
export declare function writeDumpFile(path: string, contents: string): Promise<boolean>;
|
|
17
|
+
/**
|
|
18
|
+
* Caps completed dump artifacts in the configured directory. A zero-byte cap
|
|
19
|
+
* disables sweeping. Custom directories may contain unrelated files: only
|
|
20
|
+
* recognized final artifact names and stale partials are eligible. Symlinks,
|
|
21
|
+
* fresh partial writes, and files younger than the newness floor remain
|
|
22
|
+
* protected.
|
|
23
|
+
*/
|
|
24
|
+
export declare function sweepDumpDirectory(options: {
|
|
25
|
+
dumpDir?: string;
|
|
26
|
+
maxBytes?: number;
|
|
27
|
+
protectedPaths?: readonly string[];
|
|
28
|
+
now?: number;
|
|
29
|
+
minAgeMs?: number;
|
|
30
|
+
partialStaleMs?: number;
|
|
31
|
+
}): Promise<{
|
|
32
|
+
removed: number;
|
|
33
|
+
freedBytes: number;
|
|
34
|
+
}>;
|
|
16
35
|
export declare function parseDumpCommandAction(argumentsText: string): DumpCommandAction;
|
|
17
36
|
export declare function buildDumpStatusSummary(input?: {
|
|
18
37
|
enabled?: boolean;
|
package/dist/dump.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
|
-
import { mkdir, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { lstat, mkdir, readdir, rename, unlink, writeFile, } from 'node:fs/promises';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
5
|
import { extractBillingHeaderCCH } from "./cch.js";
|
|
6
|
-
import { isSecretKey, relayLog } from "./logger.js";
|
|
6
|
+
import { isSecretKey, logger, relayLog } from "./logger.js";
|
|
7
7
|
export const CLAUDE_DUMP_COMMAND_NAME = 'claude-dump';
|
|
8
8
|
const DUMP_STATUS_TITLE = '## Claude Dump Status';
|
|
9
9
|
const DUMP_ENABLED_TITLE = '## Claude Dump Enabled';
|
|
@@ -11,9 +11,21 @@ const DUMP_DISABLED_TITLE = '## Claude Dump Disabled';
|
|
|
11
11
|
const DUMP_USAGE_TITLE = '## Claude Dump Usage';
|
|
12
12
|
const DUMP_USAGE = 'Usage: `/claude-dump`, `/claude-dump on`, or `/claude-dump off`.';
|
|
13
13
|
const DUMP_DIR_ENV = 'OPENCODE_ANTHROPIC_AUTH_DUMP_DIR';
|
|
14
|
+
const DUMP_MAX_BYTES_ENV = 'OPENCODE_ANTHROPIC_AUTH_DUMP_MAX_BYTES';
|
|
14
15
|
const DEFAULT_DUMP_DIR = join(tmpdir(), 'opencode-anthropic-auth-dumps');
|
|
16
|
+
const DEFAULT_DUMP_MAX_BYTES = 512 * 1024 * 1024;
|
|
17
|
+
const DUMP_SWEEP_INTERVAL_MS = 5 * 60 * 1000;
|
|
18
|
+
const DUMP_SWEEP_NEWNESS_FLOOR_MS = 60 * 1000;
|
|
19
|
+
const DUMP_PARTIAL_STALE_MS = 10 * 60 * 1000;
|
|
20
|
+
const DUMP_ARTIFACT_SUFFIX_PATTERN = /\.(body|meta|relay|request)\.json$/;
|
|
21
|
+
// Earlier builds emitted five-digit counters; current counters grow without truncation.
|
|
22
|
+
const DUMP_ARTIFACT_ID_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z-\d{5,}-(.+)$/;
|
|
23
|
+
const DUMP_SEGMENT_PATTERN = '[a-zA-Z0-9._-]';
|
|
24
|
+
const DIRECT_DUMP_PATTERN = new RegExp(`^${DUMP_SEGMENT_PATTERN}{1,80}-direct(?:-${DUMP_SEGMENT_PATTERN}{1,80})?$`);
|
|
25
|
+
const RELAY_DUMP_PATTERN = new RegExp(`^${DUMP_SEGMENT_PATTERN}{1,80}-(?:http|websocket)-p[12]-(?:full_sync|patch)$`);
|
|
15
26
|
let dumpEnabled = false;
|
|
16
27
|
let nextDumpId = 0;
|
|
28
|
+
let lastDumpSweepAt = 0;
|
|
17
29
|
const DIRECT_DUMP_PREVIOUS_BODY_LIMIT = 100;
|
|
18
30
|
const directDumpPreviousBodies = new Map();
|
|
19
31
|
export function isDumpEnabled() {
|
|
@@ -25,11 +37,150 @@ export function setDumpEnabled(enabled) {
|
|
|
25
37
|
export function resetDumpState() {
|
|
26
38
|
dumpEnabled = false;
|
|
27
39
|
nextDumpId = 0;
|
|
40
|
+
lastDumpSweepAt = 0;
|
|
28
41
|
directDumpPreviousBodies.clear();
|
|
29
42
|
}
|
|
30
43
|
export function getDumpDirectory() {
|
|
31
44
|
return process.env[DUMP_DIR_ENV] || DEFAULT_DUMP_DIR;
|
|
32
45
|
}
|
|
46
|
+
function getDumpMaxBytes() {
|
|
47
|
+
const configured = Number(process.env[DUMP_MAX_BYTES_ENV]);
|
|
48
|
+
return Number.isFinite(configured) && configured >= 0
|
|
49
|
+
? Math.floor(configured)
|
|
50
|
+
: DEFAULT_DUMP_MAX_BYTES;
|
|
51
|
+
}
|
|
52
|
+
function isConfiguredDumpDirectory(path) {
|
|
53
|
+
return resolve(path) === resolve(getDumpDirectory());
|
|
54
|
+
}
|
|
55
|
+
function isDumpArtifactFileName(name) {
|
|
56
|
+
const stem = name.replace(DUMP_ARTIFACT_SUFFIX_PATTERN, '');
|
|
57
|
+
if (stem === name)
|
|
58
|
+
return false;
|
|
59
|
+
const match = DUMP_ARTIFACT_ID_PATTERN.exec(stem);
|
|
60
|
+
const requestPath = match?.[1];
|
|
61
|
+
if (!requestPath)
|
|
62
|
+
return false;
|
|
63
|
+
return (DIRECT_DUMP_PATTERN.test(requestPath) ||
|
|
64
|
+
RELAY_DUMP_PATTERN.test(requestPath));
|
|
65
|
+
}
|
|
66
|
+
function isDumpPartialFileName(name) {
|
|
67
|
+
if (!name.endsWith('.partial'))
|
|
68
|
+
return false;
|
|
69
|
+
const partialStem = name.slice(0, -8);
|
|
70
|
+
if (isDumpArtifactFileName(partialStem))
|
|
71
|
+
return true;
|
|
72
|
+
const separator = partialStem.lastIndexOf('.');
|
|
73
|
+
if (separator < 0)
|
|
74
|
+
return false;
|
|
75
|
+
const nonce = partialStem.slice(separator + 1);
|
|
76
|
+
return (/^[a-f0-9]{24}$/.test(nonce) &&
|
|
77
|
+
isDumpArtifactFileName(partialStem.slice(0, separator)));
|
|
78
|
+
}
|
|
79
|
+
export async function writeDumpFile(path, contents) {
|
|
80
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
81
|
+
const nonce = randomBytes(12).toString('hex');
|
|
82
|
+
const partialPath = `${path}.${nonce}.partial`;
|
|
83
|
+
let created = false;
|
|
84
|
+
try {
|
|
85
|
+
await writeFile(partialPath, contents, { encoding: 'utf8', flag: 'wx' });
|
|
86
|
+
created = true;
|
|
87
|
+
await rename(partialPath, path);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
if (error.code === 'EEXIST')
|
|
92
|
+
continue;
|
|
93
|
+
if (created)
|
|
94
|
+
await unlink(partialPath).catch(() => { });
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Caps completed dump artifacts in the configured directory. A zero-byte cap
|
|
102
|
+
* disables sweeping. Custom directories may contain unrelated files: only
|
|
103
|
+
* recognized final artifact names and stale partials are eligible. Symlinks,
|
|
104
|
+
* fresh partial writes, and files younger than the newness floor remain
|
|
105
|
+
* protected.
|
|
106
|
+
*/
|
|
107
|
+
export async function sweepDumpDirectory(options) {
|
|
108
|
+
const dumpDir = options.dumpDir ?? getDumpDirectory();
|
|
109
|
+
const maxBytes = options.maxBytes ?? getDumpMaxBytes();
|
|
110
|
+
const now = options.now ?? Date.now();
|
|
111
|
+
const minAgeMs = options.minAgeMs ?? DUMP_SWEEP_NEWNESS_FLOOR_MS;
|
|
112
|
+
const partialStaleMs = options.partialStaleMs ?? DUMP_PARTIAL_STALE_MS;
|
|
113
|
+
const emptyResult = { removed: 0, freedBytes: 0 };
|
|
114
|
+
if (!isConfiguredDumpDirectory(dumpDir) || maxBytes <= 0)
|
|
115
|
+
return emptyResult;
|
|
116
|
+
try {
|
|
117
|
+
const dumpDirStats = await lstat(dumpDir);
|
|
118
|
+
if (dumpDirStats.isSymbolicLink())
|
|
119
|
+
return emptyResult;
|
|
120
|
+
const protectedPaths = new Set((options.protectedPaths ?? []).map((path) => resolve(path)));
|
|
121
|
+
const entries = await readdir(dumpDir, { withFileTypes: true });
|
|
122
|
+
const files = [];
|
|
123
|
+
await Promise.all(entries.map(async (entry) => {
|
|
124
|
+
if (!entry.isFile())
|
|
125
|
+
return;
|
|
126
|
+
const partial = isDumpPartialFileName(entry.name);
|
|
127
|
+
if (!partial && !isDumpArtifactFileName(entry.name))
|
|
128
|
+
return;
|
|
129
|
+
const path = join(dumpDir, entry.name);
|
|
130
|
+
try {
|
|
131
|
+
const stats = await lstat(path);
|
|
132
|
+
if (!stats.isFile() || stats.isSymbolicLink())
|
|
133
|
+
return;
|
|
134
|
+
files.push({
|
|
135
|
+
path,
|
|
136
|
+
size: stats.size,
|
|
137
|
+
mtimeMs: stats.mtimeMs,
|
|
138
|
+
partial,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
// Files can disappear while concurrent dump requests finish.
|
|
143
|
+
}
|
|
144
|
+
}));
|
|
145
|
+
let totalBytes = files.reduce((total, file) => total + file.size, 0);
|
|
146
|
+
files.sort((left, right) => left.mtimeMs - right.mtimeMs || left.path.localeCompare(right.path));
|
|
147
|
+
let removed = 0;
|
|
148
|
+
let freedBytes = 0;
|
|
149
|
+
for (const file of files) {
|
|
150
|
+
if (protectedPaths.has(resolve(file.path)))
|
|
151
|
+
continue;
|
|
152
|
+
const ageMs = now - file.mtimeMs;
|
|
153
|
+
const stalePartial = file.partial && ageMs >= partialStaleMs;
|
|
154
|
+
if (!stalePartial && totalBytes <= maxBytes)
|
|
155
|
+
continue;
|
|
156
|
+
if (file.partial ? !stalePartial : ageMs < minAgeMs)
|
|
157
|
+
continue;
|
|
158
|
+
try {
|
|
159
|
+
await unlink(file.path);
|
|
160
|
+
totalBytes -= file.size;
|
|
161
|
+
freedBytes += file.size;
|
|
162
|
+
removed += 1;
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
// Dump cleanup is best-effort and must not affect request handling.
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (removed > 0) {
|
|
169
|
+
logger.debug('dump', 'removed old dump files', { removed, freedBytes });
|
|
170
|
+
}
|
|
171
|
+
return { removed, freedBytes };
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return emptyResult;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function scheduleDumpSweep(dumpDir, protectedPaths) {
|
|
178
|
+
const now = Date.now();
|
|
179
|
+
if (now - lastDumpSweepAt < DUMP_SWEEP_INTERVAL_MS)
|
|
180
|
+
return;
|
|
181
|
+
lastDumpSweepAt = now;
|
|
182
|
+
void sweepDumpDirectory({ dumpDir, protectedPaths }).catch(() => { });
|
|
183
|
+
}
|
|
33
184
|
export function parseDumpCommandAction(argumentsText) {
|
|
34
185
|
const normalized = argumentsText.trim().split(/\s+/).filter(Boolean);
|
|
35
186
|
if (normalized.length === 0)
|
|
@@ -228,7 +379,7 @@ async function dumpRequest(input) {
|
|
|
228
379
|
return;
|
|
229
380
|
nextDumpId += 1;
|
|
230
381
|
const affinity = input.affinity?.trim() || 'session-unknown';
|
|
231
|
-
const id = `${new Date().toISOString().replace(/[:.]/g, '-')}-${String(nextDumpId).padStart(
|
|
382
|
+
const id = `${new Date().toISOString().replace(/[:.]/g, '-')}-${String(nextDumpId).padStart(6, '0')}-${dumpFileSessionSegment(affinity)}-${dumpRequestSegment(input)}`;
|
|
232
383
|
const dumpDir = getDumpDirectory();
|
|
233
384
|
const prefix = join(dumpDir, id);
|
|
234
385
|
const files = {
|
|
@@ -259,19 +410,20 @@ async function dumpRequest(input) {
|
|
|
259
410
|
files,
|
|
260
411
|
};
|
|
261
412
|
const writes = [
|
|
262
|
-
|
|
263
|
-
|
|
413
|
+
writeDumpFile(files.body, input.bodyText),
|
|
414
|
+
writeDumpFile(files.metadata, `${JSON.stringify(metadata, null, 2)}\n`),
|
|
264
415
|
];
|
|
265
416
|
if (input.payload !== undefined && files.relay) {
|
|
266
|
-
writes.push(
|
|
417
|
+
writes.push(writeDumpFile(files.relay, `${JSON.stringify(redactForDump(input.payload), null, 2)}\n`));
|
|
267
418
|
}
|
|
268
419
|
if (input.request !== undefined && files.request) {
|
|
269
|
-
writes.push(
|
|
420
|
+
writes.push(writeDumpFile(files.request, `${JSON.stringify(redactForDump({
|
|
270
421
|
...input.request,
|
|
271
422
|
headers: headersToRecord(input.request.headers),
|
|
272
|
-
}), null, 2)}\n
|
|
423
|
+
}), null, 2)}\n`));
|
|
273
424
|
}
|
|
274
425
|
await Promise.all(writes);
|
|
426
|
+
scheduleDumpSweep(dumpDir, Object.values(files));
|
|
275
427
|
relayLog(`dumped request id=${id} session=${shortAffinity(affinity)} body=${files.body} meta=${files.metadata}`);
|
|
276
428
|
}
|
|
277
429
|
catch (error) {
|
package/dist/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export * from './accounts.ts';
|
|
|
2
2
|
export * from './auth.ts';
|
|
3
3
|
export * from './cache1h.ts';
|
|
4
4
|
export * from './cachekeep.ts';
|
|
5
|
+
export * from './cachekeep-registry.ts';
|
|
5
6
|
export * from './cch.ts';
|
|
6
7
|
export * from './claude-code.ts';
|
|
7
8
|
export * from './commands/account.ts';
|
|
@@ -18,3 +19,4 @@ export * from './quota-manager.ts';
|
|
|
18
19
|
export * from './quotas.ts';
|
|
19
20
|
export * from './relay.ts';
|
|
20
21
|
export * from './routing.ts';
|
|
22
|
+
export * from './sticky-routing.ts';
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,7 @@ export * from "./accounts.js";
|
|
|
2
2
|
export * from "./auth.js";
|
|
3
3
|
export * from "./cache1h.js";
|
|
4
4
|
export * from "./cachekeep.js";
|
|
5
|
+
export * from "./cachekeep-registry.js";
|
|
5
6
|
export * from "./cch.js";
|
|
6
7
|
export * from "./claude-code.js";
|
|
7
8
|
export * from "./commands/account.js";
|
|
@@ -18,3 +19,4 @@ export * from "./quota-manager.js";
|
|
|
18
19
|
export * from "./quotas.js";
|
|
19
20
|
export * from "./relay.js";
|
|
20
21
|
export * from "./routing.js";
|
|
22
|
+
export * from "./sticky-routing.js";
|
package/dist/quota-manager.d.ts
CHANGED
|
@@ -67,14 +67,15 @@ export declare class QuotaManager {
|
|
|
67
67
|
* Fire-and-forget refresh. Does not await, swallows errors.
|
|
68
68
|
*/
|
|
69
69
|
refreshMainInBackground(accessToken: string): void;
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
private scopedWindowIsStale;
|
|
71
|
+
isMainStale(modelId?: string): boolean;
|
|
72
|
+
isFallbackStale(accountId: string, accessToken?: string, modelId?: string): boolean;
|
|
72
73
|
shouldRefreshOnRequestCount(requestCount: number): boolean;
|
|
73
74
|
/**
|
|
74
75
|
* Combined check: should a refresh happen right now?
|
|
75
76
|
* True if main is stale by time OR triggered by request count.
|
|
76
77
|
*/
|
|
77
|
-
needsRefresh(requestCount: number): boolean;
|
|
78
|
+
needsRefresh(requestCount: number, modelId?: string): boolean;
|
|
78
79
|
updateStorage(storage: AccountStorage | null): void;
|
|
79
80
|
/**
|
|
80
81
|
* Seed/update the main quota cache from persisted state. This is deliberately
|
package/dist/quota-manager.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Handles deduplication, rate-limiting (429 backoff), and staleness.
|
|
7
7
|
*/
|
|
8
8
|
import { createHash } from 'node:crypto';
|
|
9
|
-
import { acquireRefreshFileLock, buildQuotaOperationError, fetchOAuthQuotaSnapshot, getPersistedMainQuota, getQuotaCheckIntervalMs, getQuotaNextRefreshAt, getQuotaRefreshEveryNRequests, isQuotaPolicyAuthError, quotaBackoffActive, } from "./accounts.js";
|
|
9
|
+
import { acquireRefreshFileLock, buildQuotaOperationError, fetchOAuthQuotaSnapshot, getPersistedMainQuota, getQuotaCheckIntervalMs, getQuotaNextRefreshAt, getQuotaRefreshEveryNRequests, getScopedQuotaWindowForModel, isQuotaPolicyAuthError, quotaBackoffActive, } from "./accounts.js";
|
|
10
10
|
// Capture real setTimeout before tests can mock globalThis.setTimeout
|
|
11
11
|
const nativeSetTimeout = globalThis.setTimeout;
|
|
12
12
|
/**
|
|
@@ -190,17 +190,24 @@ export class QuotaManager {
|
|
|
190
190
|
// =========================================================================
|
|
191
191
|
// Staleness queries
|
|
192
192
|
// =========================================================================
|
|
193
|
-
|
|
193
|
+
scopedWindowIsStale(entry, modelId) {
|
|
194
|
+
const scoped = getScopedQuotaWindowForModel(entry.quota, modelId);
|
|
195
|
+
return Boolean(scoped &&
|
|
196
|
+
this.now() - scoped.checkedAt >= getQuotaCheckIntervalMs(this.storage));
|
|
197
|
+
}
|
|
198
|
+
isMainStale(modelId) {
|
|
194
199
|
if (!this.main)
|
|
195
200
|
return true;
|
|
196
|
-
return this.now() >= this.main.refreshAfter
|
|
201
|
+
return (this.now() >= this.main.refreshAfter ||
|
|
202
|
+
this.scopedWindowIsStale(this.main, modelId));
|
|
197
203
|
}
|
|
198
|
-
isFallbackStale(accountId, accessToken) {
|
|
204
|
+
isFallbackStale(accountId, accessToken, modelId) {
|
|
199
205
|
// Token-aware: a credential change invalidates the entry (treated as stale).
|
|
200
206
|
const entry = this.getFallback(accountId, accessToken);
|
|
201
207
|
if (!entry)
|
|
202
208
|
return true;
|
|
203
|
-
return this.now() >= entry.refreshAfter
|
|
209
|
+
return (this.now() >= entry.refreshAfter ||
|
|
210
|
+
this.scopedWindowIsStale(entry, modelId));
|
|
204
211
|
}
|
|
205
212
|
shouldRefreshOnRequestCount(requestCount) {
|
|
206
213
|
const everyN = getQuotaRefreshEveryNRequests(this.storage);
|
|
@@ -212,8 +219,9 @@ export class QuotaManager {
|
|
|
212
219
|
* Combined check: should a refresh happen right now?
|
|
213
220
|
* True if main is stale by time OR triggered by request count.
|
|
214
221
|
*/
|
|
215
|
-
needsRefresh(requestCount) {
|
|
216
|
-
return this.isMainStale() ||
|
|
222
|
+
needsRefresh(requestCount, modelId) {
|
|
223
|
+
return (this.isMainStale(modelId) ||
|
|
224
|
+
this.shouldRefreshOnRequestCount(requestCount));
|
|
217
225
|
}
|
|
218
226
|
// =========================================================================
|
|
219
227
|
// Config
|
package/dist/routing.d.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { type AccountStorage, type RoutingMode } from './accounts.ts';
|
|
2
2
|
export declare const CLAUDE_ROUTING_COMMAND_NAME = "claude-routing";
|
|
3
|
-
export declare const ROUTING_MODES: readonly ["main-first", "fallback-first"];
|
|
3
|
+
export declare const ROUTING_MODES: readonly ["main-first", "fallback-first", "sticky-balanced"];
|
|
4
4
|
export declare const DEFAULT_ROUTING_MODE: RoutingMode;
|
|
5
5
|
type RoutingCommandAction = {
|
|
6
6
|
type: 'status';
|
|
7
7
|
} | {
|
|
8
8
|
type: 'mode';
|
|
9
9
|
mode: RoutingMode;
|
|
10
|
+
} | {
|
|
11
|
+
type: 'reset';
|
|
10
12
|
} | {
|
|
11
13
|
type: 'usage';
|
|
12
14
|
};
|
package/dist/routing.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { createEmptyStorage, getAccountStoragePath, loadAccounts, saveAccounts, } from "./accounts.js";
|
|
2
2
|
export const CLAUDE_ROUTING_COMMAND_NAME = 'claude-routing';
|
|
3
|
-
export const ROUTING_MODES = [
|
|
3
|
+
export const ROUTING_MODES = [
|
|
4
|
+
'main-first',
|
|
5
|
+
'fallback-first',
|
|
6
|
+
'sticky-balanced',
|
|
7
|
+
];
|
|
4
8
|
export const DEFAULT_ROUTING_MODE = 'main-first';
|
|
5
|
-
const ROUTING_USAGE = 'Usage: `/claude-routing`, `/claude-routing main-first`,
|
|
9
|
+
const ROUTING_USAGE = 'Usage: `/claude-routing`, `/claude-routing main-first`, `/claude-routing fallback-first`, `/claude-routing sticky-balanced`, or `/claude-routing reset`.';
|
|
6
10
|
function normalizeRoutingMode(value) {
|
|
7
11
|
return typeof value === 'string' &&
|
|
8
12
|
ROUTING_MODES.includes(value)
|
|
@@ -25,6 +29,8 @@ export function parseRoutingCommandAction(argumentsText) {
|
|
|
25
29
|
const tokens = argumentsText.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
26
30
|
if (tokens.length === 0)
|
|
27
31
|
return { type: 'status' };
|
|
32
|
+
if (tokens.length === 1 && tokens[0] === 'reset')
|
|
33
|
+
return { type: 'reset' };
|
|
28
34
|
if (tokens.length === 1 && ROUTING_MODES.includes(tokens[0])) {
|
|
29
35
|
return { type: 'mode', mode: tokens[0] };
|
|
30
36
|
}
|
|
@@ -39,6 +45,9 @@ function routingDescription(mode) {
|
|
|
39
45
|
if (mode === 'fallback-first') {
|
|
40
46
|
return 'Try usable fallback accounts before the main account. If no fallback succeeds, try the main account.';
|
|
41
47
|
}
|
|
48
|
+
if (mode === 'sticky-balanced') {
|
|
49
|
+
return 'Assign each session to a quota-weighted OAuth account, keep it sticky across transient failures, and migrate only for confirmed long-lived exhaustion or permanent account failure.';
|
|
50
|
+
}
|
|
42
51
|
return 'Try the main account first. Use fallback accounts only when quota policy or fallback errors require it.';
|
|
43
52
|
}
|
|
44
53
|
export function buildRoutingStatusSummary(input) {
|
|
@@ -66,6 +75,15 @@ export function executeRoutingCommand(input) {
|
|
|
66
75
|
...buildRoutingStatusSummary({ mode: action.mode }).split('\n').slice(2),
|
|
67
76
|
].join('\n');
|
|
68
77
|
}
|
|
78
|
+
if (action.type === 'reset') {
|
|
79
|
+
return [
|
|
80
|
+
'## Claude Routing Assignment Reset',
|
|
81
|
+
'',
|
|
82
|
+
'The current session will be assigned again on its next request.',
|
|
83
|
+
'',
|
|
84
|
+
...buildRoutingStatusSummary({ mode }).split('\n').slice(2),
|
|
85
|
+
].join('\n');
|
|
86
|
+
}
|
|
69
87
|
return [
|
|
70
88
|
'## Claude Routing Usage',
|
|
71
89
|
'',
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { type AccountStorage, type OAuthQuotaSnapshot } from './accounts.ts';
|
|
2
|
+
export declare const STICKY_ROUTING_MAIN_ACCOUNT_ID = "main";
|
|
3
|
+
export declare const STICKY_ROUTING_SHORT_RESET_GRACE_MS: number;
|
|
4
|
+
export declare const STICKY_ROUTING_ASSIGNMENT_TTL_MS: number;
|
|
5
|
+
export type StickyRouteFamily = 'fable' | 'opus' | 'general';
|
|
6
|
+
export type StickyRouteCandidate = {
|
|
7
|
+
accountId: string;
|
|
8
|
+
quota: OAuthQuotaSnapshot;
|
|
9
|
+
order: number;
|
|
10
|
+
};
|
|
11
|
+
export type StickyRouteAssignment = {
|
|
12
|
+
accountId: string;
|
|
13
|
+
family: StickyRouteFamily;
|
|
14
|
+
assignedAt: number;
|
|
15
|
+
lastSeenAt: number;
|
|
16
|
+
initialInputBytes: number;
|
|
17
|
+
quotaCheckedAt: number;
|
|
18
|
+
};
|
|
19
|
+
export type StickyRouteResolution = {
|
|
20
|
+
accountId: string;
|
|
21
|
+
assignment: StickyRouteAssignment;
|
|
22
|
+
created: boolean;
|
|
23
|
+
migrated: boolean;
|
|
24
|
+
};
|
|
25
|
+
export type StickyQuotaFailureDecision = {
|
|
26
|
+
action: 'retain';
|
|
27
|
+
reason: 'not-exhausted' | 'unknown';
|
|
28
|
+
} | {
|
|
29
|
+
action: 'hold';
|
|
30
|
+
reason: 'five-hour-short-reset';
|
|
31
|
+
retryAfterSeconds: number;
|
|
32
|
+
} | {
|
|
33
|
+
action: 'migrate';
|
|
34
|
+
reason: 'five-hour' | 'seven-day' | 'model-scoped';
|
|
35
|
+
};
|
|
36
|
+
export declare function stickyQuotaSnapshotIsFresh(quota: OAuthQuotaSnapshot | undefined, storage: AccountStorage | null, now?: number, modelId?: string): boolean;
|
|
37
|
+
export declare function stickyRouteFamilyForModel(model: unknown): StickyRouteFamily;
|
|
38
|
+
export declare function stickyRouteCandidateWeight(input: {
|
|
39
|
+
candidate: StickyRouteCandidate;
|
|
40
|
+
family: StickyRouteFamily;
|
|
41
|
+
modelId?: string;
|
|
42
|
+
storage: AccountStorage | null;
|
|
43
|
+
now?: number;
|
|
44
|
+
}): number;
|
|
45
|
+
export declare function stickyRouteKnownFableExhausted(candidate: StickyRouteCandidate, storage: AccountStorage | null, now?: number): boolean;
|
|
46
|
+
export declare function decideStickyQuotaFailure(input: {
|
|
47
|
+
quota: OAuthQuotaSnapshot | undefined;
|
|
48
|
+
modelId?: string;
|
|
49
|
+
now?: number;
|
|
50
|
+
}): StickyQuotaFailureDecision;
|
|
51
|
+
export declare function getStickyRoutingStatePath(accountStoragePath: string): string;
|
|
52
|
+
export declare function stickyRetryAfterWithJitter(sessionId: string, retryAfterSeconds: number): number;
|
|
53
|
+
export declare class StickySessionRouter {
|
|
54
|
+
private readonly options;
|
|
55
|
+
private loaded;
|
|
56
|
+
private state;
|
|
57
|
+
private stateMtimeNs;
|
|
58
|
+
private touchChain;
|
|
59
|
+
constructor(options: {
|
|
60
|
+
path: string;
|
|
61
|
+
now?: () => number;
|
|
62
|
+
assignmentTtlMs?: number;
|
|
63
|
+
});
|
|
64
|
+
private now;
|
|
65
|
+
private readState;
|
|
66
|
+
private readStateMtimeNs;
|
|
67
|
+
private refreshStateIfChanged;
|
|
68
|
+
private acquireLock;
|
|
69
|
+
private assignmentIsActive;
|
|
70
|
+
private prune;
|
|
71
|
+
private writeState;
|
|
72
|
+
private scheduleTouch;
|
|
73
|
+
private selectCandidate;
|
|
74
|
+
resolve(input: {
|
|
75
|
+
sessionId: string;
|
|
76
|
+
family: StickyRouteFamily;
|
|
77
|
+
modelId?: string;
|
|
78
|
+
candidates: readonly StickyRouteCandidate[];
|
|
79
|
+
retainAccountIds: ReadonlySet<string>;
|
|
80
|
+
storage: AccountStorage | null;
|
|
81
|
+
inputBytes: number;
|
|
82
|
+
preferredAccountId?: string;
|
|
83
|
+
excludeAccountIds?: ReadonlySet<string>;
|
|
84
|
+
}): Promise<StickyRouteResolution | null>;
|
|
85
|
+
clear(sessionId: string): Promise<void>;
|
|
86
|
+
}
|