oc-auth-switcher 0.6.0 → 0.7.1
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/cli.js +397 -46
- package/dist/index.js +51 -16
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// src/accounts.ts
|
|
5
5
|
import fs from "fs";
|
|
6
6
|
import path2 from "path";
|
|
7
|
+
import { randomUUID } from "crypto";
|
|
7
8
|
|
|
8
9
|
// src/constants.ts
|
|
9
10
|
import path from "path";
|
|
@@ -14,6 +15,12 @@ var STATE_FILE = path.join(configDir, "auth-switcher-state.json");
|
|
|
14
15
|
var DEFAULT_THRESHOLD = 0.95;
|
|
15
16
|
var AUTH_FAILURE_COOLDOWN = 10 * 60 * 1000;
|
|
16
17
|
var REJECTION_FALLBACK_SECONDS = 60 * 60;
|
|
18
|
+
var METRIC_MODEL_FAMILY = {
|
|
19
|
+
session5h: null,
|
|
20
|
+
weekly7d: null,
|
|
21
|
+
weekly7dSonnet: "sonnet",
|
|
22
|
+
weekly7dFable: "fable"
|
|
23
|
+
};
|
|
17
24
|
|
|
18
25
|
// node_modules/@ex-machina/opencode-anthropic-auth/dist/constants.js
|
|
19
26
|
var CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
|
@@ -63,18 +70,52 @@ function safeReadJSON(filePath, fallback) {
|
|
|
63
70
|
}
|
|
64
71
|
return fallback;
|
|
65
72
|
}
|
|
66
|
-
|
|
73
|
+
var writeQueues = new Map;
|
|
74
|
+
var MAX_RENAME_ATTEMPTS = 3;
|
|
75
|
+
var RENAME_RETRY_CODES = new Set(["ENOENT", "EEXIST", "EPERM"]);
|
|
76
|
+
function errorCode(error) {
|
|
77
|
+
return error && typeof error === "object" && "code" in error ? String(error.code) : undefined;
|
|
78
|
+
}
|
|
79
|
+
async function writeJSON(filePath, data, options) {
|
|
67
80
|
ensureDir(filePath);
|
|
68
81
|
const content = JSON.stringify(data, null, 2);
|
|
69
|
-
const tmpPath = filePath + ".tmp";
|
|
70
82
|
const bakPath = filePath + ".bak";
|
|
83
|
+
const rename = options.rename ?? fs.promises.rename;
|
|
84
|
+
const retryDelayMs = options.retryDelayMs ?? 5;
|
|
71
85
|
if (fs.existsSync(filePath)) {
|
|
72
86
|
try {
|
|
73
|
-
fs.
|
|
87
|
+
await fs.promises.copyFile(filePath, bakPath);
|
|
74
88
|
} catch {}
|
|
75
89
|
}
|
|
76
|
-
|
|
77
|
-
|
|
90
|
+
let lastError;
|
|
91
|
+
for (let attempt = 1;attempt <= MAX_RENAME_ATTEMPTS; attempt++) {
|
|
92
|
+
const tmpPath = `${filePath}.${process.pid}-${randomUUID()}.tmp`;
|
|
93
|
+
try {
|
|
94
|
+
await fs.promises.writeFile(tmpPath, content, { mode: 384 });
|
|
95
|
+
await rename(tmpPath, filePath);
|
|
96
|
+
return;
|
|
97
|
+
} catch (error) {
|
|
98
|
+
lastError = error;
|
|
99
|
+
await fs.promises.rm(tmpPath, { force: true }).catch(() => {});
|
|
100
|
+
if (!RENAME_RETRY_CODES.has(errorCode(error) ?? "") || attempt === MAX_RENAME_ATTEMPTS) {
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
await new Promise((resolve) => setTimeout(resolve, retryDelayMs * attempt));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
throw lastError;
|
|
107
|
+
}
|
|
108
|
+
function safeWriteJSON(filePath, data, options = {}) {
|
|
109
|
+
const previous = writeQueues.get(filePath) ?? Promise.resolve();
|
|
110
|
+
const current = previous.catch(() => {}).then(() => writeJSON(filePath, data, options)).catch((error) => {
|
|
111
|
+
console.warn(`[oc-auth-switcher] Could not safely write ${path2.basename(filePath)}:`, error instanceof Error ? error.message : String(error));
|
|
112
|
+
});
|
|
113
|
+
writeQueues.set(filePath, current);
|
|
114
|
+
current.finally(() => {
|
|
115
|
+
if (writeQueues.get(filePath) === current)
|
|
116
|
+
writeQueues.delete(filePath);
|
|
117
|
+
});
|
|
118
|
+
return current;
|
|
78
119
|
}
|
|
79
120
|
function loadAccounts() {
|
|
80
121
|
const raw = safeReadJSON(ACCOUNTS_FILE, {
|
|
@@ -84,9 +125,9 @@ function loadAccounts() {
|
|
|
84
125
|
return { accounts };
|
|
85
126
|
}
|
|
86
127
|
function saveAccounts(data) {
|
|
87
|
-
safeWriteJSON(ACCOUNTS_FILE, data);
|
|
128
|
+
return safeWriteJSON(ACCOUNTS_FILE, data);
|
|
88
129
|
}
|
|
89
|
-
function addAccount(account) {
|
|
130
|
+
async function addAccount(account) {
|
|
90
131
|
const data = loadAccounts();
|
|
91
132
|
const idx = data.accounts.findIndex((a) => a.name === account.name);
|
|
92
133
|
if (idx >= 0) {
|
|
@@ -94,23 +135,23 @@ function addAccount(account) {
|
|
|
94
135
|
} else {
|
|
95
136
|
data.accounts.push(account);
|
|
96
137
|
}
|
|
97
|
-
saveAccounts(data);
|
|
138
|
+
await saveAccounts(data);
|
|
98
139
|
return data;
|
|
99
140
|
}
|
|
100
|
-
function removeAccount(name) {
|
|
141
|
+
async function removeAccount(name) {
|
|
101
142
|
const data = loadAccounts();
|
|
102
143
|
data.accounts = data.accounts.filter((a) => a.name !== name);
|
|
103
|
-
saveAccounts(data);
|
|
144
|
+
await saveAccounts(data);
|
|
104
145
|
return data;
|
|
105
146
|
}
|
|
106
|
-
function updateAccountTokens(name, access, refresh, expires) {
|
|
147
|
+
async function updateAccountTokens(name, access, refresh, expires) {
|
|
107
148
|
const data = loadAccounts();
|
|
108
149
|
const account = data.accounts.find((a) => a.name === name);
|
|
109
150
|
if (account) {
|
|
110
151
|
account.access = access;
|
|
111
152
|
account.refresh = refresh;
|
|
112
153
|
account.expires = expires;
|
|
113
|
-
saveAccounts(data);
|
|
154
|
+
await saveAccounts(data);
|
|
114
155
|
}
|
|
115
156
|
}
|
|
116
157
|
async function refreshAccountToken(refreshTokenValue) {
|
|
@@ -201,7 +242,7 @@ function loadState() {
|
|
|
201
242
|
return normalizeState(safeReadJSON(STATE_FILE, {}));
|
|
202
243
|
}
|
|
203
244
|
function saveState(state) {
|
|
204
|
-
safeWriteJSON(STATE_FILE, state);
|
|
245
|
+
return safeWriteJSON(STATE_FILE, state);
|
|
205
246
|
}
|
|
206
247
|
function getThresholds(config) {
|
|
207
248
|
if (typeof config.threshold === "number") {
|
|
@@ -257,10 +298,252 @@ function ensureAccountsInState(state, accountNames) {
|
|
|
257
298
|
}
|
|
258
299
|
|
|
259
300
|
// src/rotation.ts
|
|
301
|
+
function isTemporarilyUnavailable(state, accountName) {
|
|
302
|
+
const cooldownUntil = state.authFailures[accountName];
|
|
303
|
+
if (!cooldownUntil)
|
|
304
|
+
return false;
|
|
305
|
+
if (Date.now() > cooldownUntil) {
|
|
306
|
+
delete state.authFailures[accountName];
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
function isOverThreshold(usage, state, modelFamily) {
|
|
312
|
+
if (!usage)
|
|
313
|
+
return false;
|
|
314
|
+
const thresholds = getThresholds(state.config);
|
|
315
|
+
if (usage.rejected.status?.toLowerCase() === "rejected" && isRejectionRelevant(usage.rejected.prefix, modelFamily))
|
|
316
|
+
return true;
|
|
317
|
+
return metricEntries(usage, thresholds, modelFamily).some((metric) => metric.threshold > 0 && metric.util >= metric.threshold);
|
|
318
|
+
}
|
|
319
|
+
function getUtilizationScore(usage, state, modelFamily) {
|
|
320
|
+
if (!usage)
|
|
321
|
+
return 0;
|
|
322
|
+
const thresholds = getThresholds(state.config);
|
|
323
|
+
if (usage.rejected.status?.toLowerCase() === "rejected" && isRejectionRelevant(usage.rejected.prefix, modelFamily))
|
|
324
|
+
return Infinity;
|
|
325
|
+
const scores = metricEntries(usage, thresholds, modelFamily).filter((metric) => metric.threshold > 0).map((metric) => metric.util / metric.threshold);
|
|
326
|
+
return scores.length > 0 ? Math.max(...scores) : 0;
|
|
327
|
+
}
|
|
328
|
+
function getModelFamily(model) {
|
|
329
|
+
if (!model)
|
|
330
|
+
return;
|
|
331
|
+
const normalized = model.toLowerCase();
|
|
332
|
+
return ["fable", "sonnet", "opus"].find((family) => normalized.includes(family));
|
|
333
|
+
}
|
|
334
|
+
function isMetricRelevant(metric, modelFamily) {
|
|
335
|
+
const metricFamily = METRIC_MODEL_FAMILY[metric];
|
|
336
|
+
return modelFamily === undefined || metricFamily === null || metricFamily === modelFamily;
|
|
337
|
+
}
|
|
338
|
+
function rejectionFamily(prefix) {
|
|
339
|
+
if (!prefix)
|
|
340
|
+
return;
|
|
341
|
+
const normalized = prefix.toLowerCase();
|
|
342
|
+
return ["fable", "sonnet", "opus"].find((family) => normalized === `7d_${family}` || normalized === `anthropic-ratelimit-unified-7d_${family}`);
|
|
343
|
+
}
|
|
344
|
+
function isRejectionRelevant(prefix, modelFamily) {
|
|
345
|
+
const rejectedFamily = rejectionFamily(prefix);
|
|
346
|
+
return modelFamily === undefined || rejectedFamily === undefined || rejectedFamily === modelFamily;
|
|
347
|
+
}
|
|
348
|
+
function metricEntries(usage, thresholds, modelFamily) {
|
|
349
|
+
return Object.keys(METRIC_MODEL_FAMILY).filter((key) => isMetricRelevant(key, modelFamily)).map((key) => ({
|
|
350
|
+
name: key,
|
|
351
|
+
util: usage[key].utilization,
|
|
352
|
+
threshold: thresholds[key]
|
|
353
|
+
}));
|
|
354
|
+
}
|
|
355
|
+
function purgeExpiredCooldowns(state) {
|
|
356
|
+
const now = Date.now();
|
|
357
|
+
for (const name of Object.keys(state.authFailures)) {
|
|
358
|
+
if (state.authFailures[name] <= now) {
|
|
359
|
+
delete state.authFailures[name];
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
function findBestAvailable(candidates, state, exclude, modelFamily) {
|
|
364
|
+
let best = null;
|
|
365
|
+
let bestScore = Infinity;
|
|
366
|
+
for (const acct of candidates) {
|
|
367
|
+
if (exclude.has(acct.name))
|
|
368
|
+
continue;
|
|
369
|
+
if (isTemporarilyUnavailable(state, acct.name))
|
|
370
|
+
continue;
|
|
371
|
+
if (!isOverThreshold(state.usage[acct.name], state, modelFamily)) {
|
|
372
|
+
const score = getUtilizationScore(state.usage[acct.name], state, modelFamily);
|
|
373
|
+
if (!best || score < bestScore) {
|
|
374
|
+
bestScore = score;
|
|
375
|
+
best = acct;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (best)
|
|
380
|
+
return best;
|
|
381
|
+
best = null;
|
|
382
|
+
bestScore = Infinity;
|
|
383
|
+
for (const acct of candidates) {
|
|
384
|
+
if (exclude.has(acct.name))
|
|
385
|
+
continue;
|
|
386
|
+
if (isTemporarilyUnavailable(state, acct.name))
|
|
387
|
+
continue;
|
|
388
|
+
const score = getUtilizationScore(state.usage[acct.name], state, modelFamily);
|
|
389
|
+
if (!best || score < bestScore) {
|
|
390
|
+
bestScore = score;
|
|
391
|
+
best = acct;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return best;
|
|
395
|
+
}
|
|
396
|
+
function selectAccount(accounts, state, model) {
|
|
397
|
+
if (accounts.length === 0) {
|
|
398
|
+
throw new Error("No accounts available");
|
|
399
|
+
}
|
|
400
|
+
purgeExpiredCooldowns(state);
|
|
401
|
+
const modelFamily = getModelFamily(model);
|
|
402
|
+
const currentIdx = accounts.findIndex((a) => a.name === state.currentAccount);
|
|
403
|
+
if (currentIdx < 0) {
|
|
404
|
+
const best = findBestAvailable(accounts, state, new Set, modelFamily);
|
|
405
|
+
const selected = best ?? accounts[0];
|
|
406
|
+
return {
|
|
407
|
+
account: selected,
|
|
408
|
+
switched: true,
|
|
409
|
+
reason: `Selecting account ${selected.name}`
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
const current = accounts[currentIdx];
|
|
413
|
+
const currentUsage = state.usage[current.name];
|
|
414
|
+
const currentOverThreshold = isOverThreshold(currentUsage, state, modelFamily);
|
|
415
|
+
const currentInCooldown = isTemporarilyUnavailable(state, current.name);
|
|
416
|
+
if (currentOverThreshold || currentInCooldown) {
|
|
417
|
+
const reason = currentInCooldown ? `${current.name} in auth-failure cooldown` : `${current.name} exceeded threshold`;
|
|
418
|
+
const best = findBestAvailable(accounts, state, new Set([current.name]), modelFamily);
|
|
419
|
+
if (best && best.name !== current.name) {
|
|
420
|
+
return {
|
|
421
|
+
account: best,
|
|
422
|
+
switched: true,
|
|
423
|
+
reason: `${reason} \u2014 switching to ${best.name}`
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
return { account: current, switched: false };
|
|
427
|
+
}
|
|
428
|
+
return { account: current, switched: false };
|
|
429
|
+
}
|
|
260
430
|
function clearAuthFailure(state, accountName) {
|
|
261
431
|
delete state.authFailures[accountName];
|
|
262
432
|
}
|
|
263
433
|
|
|
434
|
+
// src/status.ts
|
|
435
|
+
var MODEL_FAMILIES = ["opus", "sonnet", "fable"];
|
|
436
|
+
function percentage(value) {
|
|
437
|
+
return `${(value * 100).toFixed(0)}%`;
|
|
438
|
+
}
|
|
439
|
+
function formatRelativeDuration(targetMs, nowMs = Date.now()) {
|
|
440
|
+
const difference = targetMs - nowMs;
|
|
441
|
+
const absoluteSeconds = Math.abs(difference) / 1000;
|
|
442
|
+
if (absoluteSeconds < 30)
|
|
443
|
+
return "now";
|
|
444
|
+
const units = [
|
|
445
|
+
["d", 86400],
|
|
446
|
+
["h", 3600],
|
|
447
|
+
["m", 60]
|
|
448
|
+
];
|
|
449
|
+
const parts = [];
|
|
450
|
+
let remaining = absoluteSeconds;
|
|
451
|
+
for (const [suffix, seconds] of units) {
|
|
452
|
+
const amount = Math.floor(remaining / seconds);
|
|
453
|
+
if (amount > 0) {
|
|
454
|
+
parts.push(`${amount}${suffix}`);
|
|
455
|
+
remaining -= amount * seconds;
|
|
456
|
+
}
|
|
457
|
+
if (parts.length === 2)
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
460
|
+
if (parts.length === 0)
|
|
461
|
+
parts.push(`${Math.max(1, Math.round(remaining))}s`);
|
|
462
|
+
return difference >= 0 ? `in ${parts.join(" ")}` : `${parts.join(" ")} ago`;
|
|
463
|
+
}
|
|
464
|
+
function displayRejectionPrefix(prefix) {
|
|
465
|
+
return prefix?.replace(/^anthropic-ratelimit-unified-/i, "");
|
|
466
|
+
}
|
|
467
|
+
function rejectionFamily2(prefix) {
|
|
468
|
+
if (!prefix)
|
|
469
|
+
return;
|
|
470
|
+
const normalized = prefix.toLowerCase();
|
|
471
|
+
return MODEL_FAMILIES.find((family) => normalized === `7d_${family}` || normalized === `anthropic-ratelimit-unified-7d_${family}`);
|
|
472
|
+
}
|
|
473
|
+
function tokenExpiryDescription(account, nowMs) {
|
|
474
|
+
if (account.expires > nowMs) {
|
|
475
|
+
return `expires ${formatRelativeDuration(account.expires, nowMs)}`;
|
|
476
|
+
}
|
|
477
|
+
if (account.refresh) {
|
|
478
|
+
return account.expires > 0 ? `expired ${formatRelativeDuration(account.expires, nowMs)}; refresh available` : "expired; refresh available";
|
|
479
|
+
}
|
|
480
|
+
return account.expires > 0 ? `expired/unrefreshable (${formatRelativeDuration(account.expires, nowMs)})` : "expired/unrefreshable";
|
|
481
|
+
}
|
|
482
|
+
function deriveAccountHealth(account, usage, thresholds, cooldownUntil, nowMs = Date.now()) {
|
|
483
|
+
const availability = Object.fromEntries(MODEL_FAMILIES.map((family) => [family, true]));
|
|
484
|
+
const reasons = [];
|
|
485
|
+
const metrics = usage ? Object.keys(METRIC_MODEL_FAMILY).filter((metric) => thresholds[metric] > 0).map((metric) => ({
|
|
486
|
+
metric,
|
|
487
|
+
utilization: usage[metric].utilization,
|
|
488
|
+
threshold: thresholds[metric],
|
|
489
|
+
family: METRIC_MODEL_FAMILY[metric]
|
|
490
|
+
})) : [];
|
|
491
|
+
for (const metric of metrics) {
|
|
492
|
+
if (metric.utilization < metric.threshold)
|
|
493
|
+
continue;
|
|
494
|
+
const families = metric.family ? [metric.family] : [...MODEL_FAMILIES];
|
|
495
|
+
for (const family of families)
|
|
496
|
+
availability[family] = false;
|
|
497
|
+
reasons.push({
|
|
498
|
+
kind: "metric",
|
|
499
|
+
message: `${metric.metric} ${percentage(metric.utilization)} >= ${percentage(metric.threshold)}${metric.family ? ` \u2014 ${metric.family} only` : ""}`,
|
|
500
|
+
families,
|
|
501
|
+
reset: usage?.[metric.metric].reset
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
if (usage?.rejected.status?.toLowerCase() === "rejected") {
|
|
505
|
+
const family = rejectionFamily2(usage.rejected.prefix);
|
|
506
|
+
const families = family ? [family] : [...MODEL_FAMILIES];
|
|
507
|
+
for (const blockedFamily of families)
|
|
508
|
+
availability[blockedFamily] = false;
|
|
509
|
+
const prefix = displayRejectionPrefix(usage.rejected.prefix);
|
|
510
|
+
reasons.push({
|
|
511
|
+
kind: "rejection",
|
|
512
|
+
message: `rejected${prefix ? ` (${prefix})` : ""}${family ? ` \u2014 ${family} only` : ""}`,
|
|
513
|
+
families,
|
|
514
|
+
reset: usage.rejected.reset
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
if (cooldownUntil && cooldownUntil > nowMs) {
|
|
518
|
+
for (const family of MODEL_FAMILIES)
|
|
519
|
+
availability[family] = false;
|
|
520
|
+
reasons.push({
|
|
521
|
+
kind: "cooldown",
|
|
522
|
+
message: `auth-failure cooldown \u2014 ${formatRelativeDuration(cooldownUntil, nowMs)}`,
|
|
523
|
+
families: [...MODEL_FAMILIES]
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
const hasUsableToken = !!account.access && account.expires > nowMs || !!account.refresh;
|
|
527
|
+
if (!hasUsableToken) {
|
|
528
|
+
for (const family of MODEL_FAMILIES)
|
|
529
|
+
availability[family] = false;
|
|
530
|
+
reasons.push({
|
|
531
|
+
kind: "token",
|
|
532
|
+
message: "expired/unrefreshable token",
|
|
533
|
+
families: [...MODEL_FAMILIES]
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
const highestUtilization = metrics.sort((a, b) => b.utilization / b.threshold - a.utilization / a.threshold)[0] ?? null;
|
|
537
|
+
const futureResets = reasons.map((reason) => reason.reset).filter((reset) => !!reset && reset * 1000 > nowMs);
|
|
538
|
+
return {
|
|
539
|
+
availability,
|
|
540
|
+
highestUtilization,
|
|
541
|
+
reasons,
|
|
542
|
+
earliestReset: futureResets.length > 0 ? Math.min(...futureResets) : undefined,
|
|
543
|
+
tokenExpiry: tokenExpiryDescription(account, nowMs)
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
264
547
|
// node_modules/@ex-machina/opencode-anthropic-auth/dist/pkce.js
|
|
265
548
|
function base64UrlEncode(bytes) {
|
|
266
549
|
let bin = "";
|
|
@@ -372,14 +655,15 @@ async function exchange(input, verifier, redirectUri, expectedState) {
|
|
|
372
655
|
|
|
373
656
|
// src/cli.ts
|
|
374
657
|
import { spawn } from "child_process";
|
|
375
|
-
var
|
|
376
|
-
var
|
|
377
|
-
var
|
|
378
|
-
var
|
|
379
|
-
var
|
|
380
|
-
var
|
|
381
|
-
var
|
|
382
|
-
var
|
|
658
|
+
var COLOR = !!process.stdout.isTTY;
|
|
659
|
+
var RESET = COLOR ? "\x1B[0m" : "";
|
|
660
|
+
var BOLD = COLOR ? "\x1B[1m" : "";
|
|
661
|
+
var DIM = COLOR ? "\x1B[2m" : "";
|
|
662
|
+
var RED = COLOR ? "\x1B[31m" : "";
|
|
663
|
+
var GREEN = COLOR ? "\x1B[32m" : "";
|
|
664
|
+
var YELLOW = COLOR ? "\x1B[33m" : "";
|
|
665
|
+
var BLUE = COLOR ? "\x1B[34m" : "";
|
|
666
|
+
var CYAN = COLOR ? "\x1B[36m" : "";
|
|
383
667
|
function progressBar(value, threshold, width = 30) {
|
|
384
668
|
const pct = Math.min(value, 1);
|
|
385
669
|
const filled = Math.round(pct * width);
|
|
@@ -471,7 +755,7 @@ ${BOLD}Starting OAuth flow for account: ${CYAN}${name}${RESET}
|
|
|
471
755
|
${RED}Authentication failed${RESET}`);
|
|
472
756
|
process.exit(1);
|
|
473
757
|
}
|
|
474
|
-
addAccount({
|
|
758
|
+
await addAccount({
|
|
475
759
|
name,
|
|
476
760
|
access: exchangeResult.access,
|
|
477
761
|
refresh: exchangeResult.refresh,
|
|
@@ -484,7 +768,7 @@ ${GREEN}Account "${name}" added successfully.${RESET}`);
|
|
|
484
768
|
if (data.accounts.length === 1) {
|
|
485
769
|
const state = loadState();
|
|
486
770
|
state.currentAccount = name;
|
|
487
|
-
saveState(state);
|
|
771
|
+
await saveState(state);
|
|
488
772
|
console.log(`
|
|
489
773
|
${YELLOW}This is the only account \u2014 set as active.${RESET}`);
|
|
490
774
|
}
|
|
@@ -533,10 +817,10 @@ ${RED}Re-authentication failed${RESET}`);
|
|
|
533
817
|
account.access = exchangeResult.access;
|
|
534
818
|
account.refresh = exchangeResult.refresh;
|
|
535
819
|
account.expires = exchangeResult.expires;
|
|
536
|
-
saveAccounts(data);
|
|
820
|
+
await saveAccounts(data);
|
|
537
821
|
const state = loadState();
|
|
538
822
|
clearAuthFailure(state, name);
|
|
539
|
-
saveState(state);
|
|
823
|
+
await saveState(state);
|
|
540
824
|
console.log(`
|
|
541
825
|
${GREEN}Account "${name}" re-authenticated successfully.${RESET}`);
|
|
542
826
|
}
|
|
@@ -549,7 +833,7 @@ async function refreshExpiredAccounts(data, state) {
|
|
|
549
833
|
account.access = result.access;
|
|
550
834
|
account.refresh = result.refresh;
|
|
551
835
|
account.expires = result.expires;
|
|
552
|
-
updateAccountTokens(account.name, result.access, result.refresh, result.expires);
|
|
836
|
+
await updateAccountTokens(account.name, result.access, result.refresh, result.expires);
|
|
553
837
|
clearAuthFailure(state, account.name);
|
|
554
838
|
}
|
|
555
839
|
}
|
|
@@ -560,7 +844,7 @@ async function cmdUsage(args) {
|
|
|
560
844
|
const data = loadAccounts();
|
|
561
845
|
const state = loadState();
|
|
562
846
|
await refreshExpiredAccounts(data, state);
|
|
563
|
-
saveState(state);
|
|
847
|
+
await saveState(state);
|
|
564
848
|
resolveStaleMetrics(state);
|
|
565
849
|
ensureAccountsInState(state, data.accounts.map((a) => a.name));
|
|
566
850
|
const thresholds = getThresholds(state.config);
|
|
@@ -622,7 +906,7 @@ ${BOLD}${CYAN}=== Auth Switcher Usage Dashboard ===${RESET}
|
|
|
622
906
|
new Promise(() => {});
|
|
623
907
|
}
|
|
624
908
|
}
|
|
625
|
-
function cmdConfig(args) {
|
|
909
|
+
async function cmdConfig(args) {
|
|
626
910
|
const state = loadState();
|
|
627
911
|
if (args.length === 0) {
|
|
628
912
|
const thresholds = getThresholds(state.config);
|
|
@@ -672,9 +956,9 @@ ${BOLD}${CYAN}=== Auth Switcher Configuration ===${RESET}
|
|
|
672
956
|
console.log(`${GREEN}Reset to default threshold: ${(DEFAULT_THRESHOLD * 100).toFixed(0)}%${RESET}`);
|
|
673
957
|
}
|
|
674
958
|
}
|
|
675
|
-
saveState(state);
|
|
959
|
+
await saveState(state);
|
|
676
960
|
}
|
|
677
|
-
function cmdSwitch(args) {
|
|
961
|
+
async function cmdSwitch(args) {
|
|
678
962
|
const data = loadAccounts();
|
|
679
963
|
if (data.accounts.length === 0) {
|
|
680
964
|
console.error(`${RED}No accounts configured.${RESET}`);
|
|
@@ -701,26 +985,93 @@ ${RED}Usage: oc-auth-switcher switch <account-name>${RESET}`);
|
|
|
701
985
|
console.log(`${CYAN}Switching to account: ${name}...${RESET}`);
|
|
702
986
|
const state = loadState();
|
|
703
987
|
state.currentAccount = name;
|
|
704
|
-
saveState(state);
|
|
988
|
+
await saveState(state);
|
|
705
989
|
console.log(`${GREEN}Switched to "${name}". Will take effect on the next API request.${RESET}`);
|
|
706
990
|
}
|
|
991
|
+
function healthLabel(health) {
|
|
992
|
+
const usable = MODEL_FAMILIES.filter((family) => health.availability[family]);
|
|
993
|
+
const unavailable = MODEL_FAMILIES.filter((family) => !health.availability[family]);
|
|
994
|
+
if (unavailable.length === 0)
|
|
995
|
+
return `${GREEN}healthy \u2014 all models usable${RESET}`;
|
|
996
|
+
if (usable.length === 0)
|
|
997
|
+
return `${RED}unavailable \u2014 all models${RESET}`;
|
|
998
|
+
return `${YELLOW}partial \u2014 usable: ${usable.join(", ")}; unavailable: ${unavailable.join(", ")}${RESET}`;
|
|
999
|
+
}
|
|
1000
|
+
function utilizationLabel(health) {
|
|
1001
|
+
const peak = health.highestUtilization;
|
|
1002
|
+
if (!peak)
|
|
1003
|
+
return `${DIM}no usage data${RESET}`;
|
|
1004
|
+
const utilization = `${(peak.utilization * 100).toFixed(0)}%`;
|
|
1005
|
+
const threshold = `${(peak.threshold * 100).toFixed(0)}%`;
|
|
1006
|
+
const family = peak.family ? ` \u2014 ${peak.family} only` : "";
|
|
1007
|
+
if (peak.utilization >= peak.threshold) {
|
|
1008
|
+
return `${RED}${peak.metric} ${utilization} >= ${threshold}${family}${RESET}`;
|
|
1009
|
+
}
|
|
1010
|
+
const color = peak.utilization >= peak.threshold * 0.8 ? YELLOW : GREEN;
|
|
1011
|
+
return `${color}${peak.metric} ${utilization} / ${threshold} threshold${family}${RESET}`;
|
|
1012
|
+
}
|
|
1013
|
+
function nextAccountSummary(accounts, state, activeName) {
|
|
1014
|
+
if (accounts.length === 0 || activeName && accounts.length === 1) {
|
|
1015
|
+
return "(none \u2014 no alternate account)";
|
|
1016
|
+
}
|
|
1017
|
+
const selections = MODEL_FAMILIES.map((family) => {
|
|
1018
|
+
const candidateState = structuredClone(state);
|
|
1019
|
+
candidateState.currentAccount = activeName;
|
|
1020
|
+
if (activeName)
|
|
1021
|
+
candidateState.authFailures[activeName] = Date.now() + 60000;
|
|
1022
|
+
const selected = selectAccount(accounts, candidateState, `claude-${family}`);
|
|
1023
|
+
return [
|
|
1024
|
+
family,
|
|
1025
|
+
selected.account.name === activeName ? null : selected.account.name
|
|
1026
|
+
];
|
|
1027
|
+
});
|
|
1028
|
+
const names = new Set(selections.map(([_, name]) => name));
|
|
1029
|
+
if (names.size === 1) {
|
|
1030
|
+
return selections[0][1] ? `${selections[0][1]} (all models)` : "(none \u2014 no usable alternate account)";
|
|
1031
|
+
}
|
|
1032
|
+
return selections.map(([family, name]) => `${family}: ${name ?? "none"}`).join(", ");
|
|
1033
|
+
}
|
|
707
1034
|
function cmdStatus() {
|
|
708
1035
|
const data = loadAccounts();
|
|
709
1036
|
const state = loadState();
|
|
710
1037
|
resolveStaleMetrics(state);
|
|
1038
|
+
const thresholds = getThresholds(state.config);
|
|
1039
|
+
const now = Date.now();
|
|
1040
|
+
const configuredActive = data.accounts.find((account) => account.name === state.currentAccount);
|
|
1041
|
+
const healthByAccount = new Map(data.accounts.map((account) => [
|
|
1042
|
+
account.name,
|
|
1043
|
+
deriveAccountHealth(account, state.usage[account.name], thresholds, state.authFailures[account.name], now)
|
|
1044
|
+
]));
|
|
711
1045
|
console.log(`
|
|
712
1046
|
${BOLD}${CYAN}=== Auth Switcher Status ===${RESET}
|
|
713
1047
|
`);
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
1048
|
+
if (configuredActive) {
|
|
1049
|
+
console.log(` Active account: ${BOLD}${configuredActive.name}${RESET} \u2014 ${healthLabel(healthByAccount.get(configuredActive.name))}`);
|
|
1050
|
+
} else {
|
|
1051
|
+
console.log(` Active account: ${BOLD}(none)${RESET}`);
|
|
1052
|
+
}
|
|
1053
|
+
console.log(` Next if unavailable: ${BOLD}${nextAccountSummary(data.accounts, state, configuredActive?.name ?? null)}${RESET}`);
|
|
1054
|
+
console.log(` Accounts: ${data.accounts.length} Requests: ${state.requestCount}`);
|
|
717
1055
|
console.log();
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
console.log(
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
1056
|
+
if (data.accounts.length === 0) {
|
|
1057
|
+
console.log(` ${DIM}No accounts configured. Run 'oc-auth-switcher add' to add one.${RESET}`);
|
|
1058
|
+
console.log();
|
|
1059
|
+
} else {
|
|
1060
|
+
console.log(` ${BOLD}Account health${RESET}`);
|
|
1061
|
+
for (const account of data.accounts) {
|
|
1062
|
+
const health = healthByAccount.get(account.name);
|
|
1063
|
+
const active = account.name === configuredActive?.name ? ` ${GREEN}[ACTIVE]${RESET}` : "";
|
|
1064
|
+
console.log(` ${BOLD}${account.name}${RESET}${active} \u2014 ${healthLabel(health)}`);
|
|
1065
|
+
console.log(` Peak: ${utilizationLabel(health)}`);
|
|
1066
|
+
for (const reason of health.reasons) {
|
|
1067
|
+
console.log(` ${RED}Reason: ${reason.message}${RESET}`);
|
|
1068
|
+
}
|
|
1069
|
+
if (health.earliestReset) {
|
|
1070
|
+
const resetMs = health.earliestReset * 1000;
|
|
1071
|
+
console.log(` Reset: ${new Date(resetMs).toLocaleString()} (${formatRelativeDuration(resetMs, now)})`);
|
|
1072
|
+
}
|
|
1073
|
+
const tokenColor = health.reasons.some((reason) => reason.kind === "token") ? RED : DIM;
|
|
1074
|
+
console.log(` ${tokenColor}Token: ${health.tokenExpiry}${RESET}`);
|
|
724
1075
|
}
|
|
725
1076
|
console.log();
|
|
726
1077
|
}
|
|
@@ -728,7 +1079,7 @@ ${BOLD}${CYAN}=== Auth Switcher Status ===${RESET}
|
|
|
728
1079
|
console.log(` ${DIM}State file: ${STATE_FILE}${RESET}`);
|
|
729
1080
|
console.log();
|
|
730
1081
|
}
|
|
731
|
-
function cmdRemove(args) {
|
|
1082
|
+
async function cmdRemove(args) {
|
|
732
1083
|
const name = args[0];
|
|
733
1084
|
if (!name) {
|
|
734
1085
|
console.error(`${RED}Usage: oc-auth-switcher remove <account-name>${RESET}`);
|
|
@@ -740,12 +1091,12 @@ function cmdRemove(args) {
|
|
|
740
1091
|
console.error(`${RED}Account "${name}" not found${RESET}`);
|
|
741
1092
|
process.exit(1);
|
|
742
1093
|
}
|
|
743
|
-
removeAccount(name);
|
|
1094
|
+
await removeAccount(name);
|
|
744
1095
|
console.log(`${GREEN}Account "${name}" removed.${RESET}`);
|
|
745
1096
|
const state = loadState();
|
|
746
1097
|
if (state.currentAccount === name) {
|
|
747
1098
|
state.currentAccount = null;
|
|
748
|
-
saveState(state);
|
|
1099
|
+
await saveState(state);
|
|
749
1100
|
console.log(`${YELLOW}This was the active account. Rotation will pick a new one automatically.${RESET}`);
|
|
750
1101
|
}
|
|
751
1102
|
}
|
|
@@ -796,18 +1147,18 @@ async function main() {
|
|
|
796
1147
|
break;
|
|
797
1148
|
case "config":
|
|
798
1149
|
case "c":
|
|
799
|
-
cmdConfig(commandArgs);
|
|
1150
|
+
await cmdConfig(commandArgs);
|
|
800
1151
|
break;
|
|
801
1152
|
case "switch":
|
|
802
1153
|
case "s":
|
|
803
|
-
cmdSwitch(commandArgs);
|
|
1154
|
+
await cmdSwitch(commandArgs);
|
|
804
1155
|
break;
|
|
805
1156
|
case "status":
|
|
806
1157
|
cmdStatus();
|
|
807
1158
|
break;
|
|
808
1159
|
case "remove":
|
|
809
1160
|
case "rm":
|
|
810
|
-
cmdRemove(commandArgs);
|
|
1161
|
+
await cmdRemove(commandArgs);
|
|
811
1162
|
break;
|
|
812
1163
|
case "help":
|
|
813
1164
|
case "--help":
|
package/dist/index.js
CHANGED
|
@@ -406,6 +406,7 @@ async function exchange(input, verifier, redirectUri, expectedState) {
|
|
|
406
406
|
// src/accounts.ts
|
|
407
407
|
import fs from "node:fs";
|
|
408
408
|
import path2 from "node:path";
|
|
409
|
+
import { randomUUID } from "node:crypto";
|
|
409
410
|
|
|
410
411
|
// src/constants.ts
|
|
411
412
|
import path from "node:path";
|
|
@@ -454,18 +455,52 @@ function safeReadJSON(filePath, fallback) {
|
|
|
454
455
|
}
|
|
455
456
|
return fallback;
|
|
456
457
|
}
|
|
457
|
-
|
|
458
|
+
var writeQueues = new Map;
|
|
459
|
+
var MAX_RENAME_ATTEMPTS = 3;
|
|
460
|
+
var RENAME_RETRY_CODES = new Set(["ENOENT", "EEXIST", "EPERM"]);
|
|
461
|
+
function errorCode(error) {
|
|
462
|
+
return error && typeof error === "object" && "code" in error ? String(error.code) : undefined;
|
|
463
|
+
}
|
|
464
|
+
async function writeJSON(filePath, data, options) {
|
|
458
465
|
ensureDir(filePath);
|
|
459
466
|
const content = JSON.stringify(data, null, 2);
|
|
460
|
-
const tmpPath = filePath + ".tmp";
|
|
461
467
|
const bakPath = filePath + ".bak";
|
|
468
|
+
const rename = options.rename ?? fs.promises.rename;
|
|
469
|
+
const retryDelayMs = options.retryDelayMs ?? 5;
|
|
462
470
|
if (fs.existsSync(filePath)) {
|
|
463
471
|
try {
|
|
464
|
-
fs.
|
|
472
|
+
await fs.promises.copyFile(filePath, bakPath);
|
|
465
473
|
} catch {}
|
|
466
474
|
}
|
|
467
|
-
|
|
468
|
-
|
|
475
|
+
let lastError;
|
|
476
|
+
for (let attempt = 1;attempt <= MAX_RENAME_ATTEMPTS; attempt++) {
|
|
477
|
+
const tmpPath = `${filePath}.${process.pid}-${randomUUID()}.tmp`;
|
|
478
|
+
try {
|
|
479
|
+
await fs.promises.writeFile(tmpPath, content, { mode: 384 });
|
|
480
|
+
await rename(tmpPath, filePath);
|
|
481
|
+
return;
|
|
482
|
+
} catch (error) {
|
|
483
|
+
lastError = error;
|
|
484
|
+
await fs.promises.rm(tmpPath, { force: true }).catch(() => {});
|
|
485
|
+
if (!RENAME_RETRY_CODES.has(errorCode(error) ?? "") || attempt === MAX_RENAME_ATTEMPTS) {
|
|
486
|
+
break;
|
|
487
|
+
}
|
|
488
|
+
await new Promise((resolve) => setTimeout(resolve, retryDelayMs * attempt));
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
throw lastError;
|
|
492
|
+
}
|
|
493
|
+
function safeWriteJSON(filePath, data, options = {}) {
|
|
494
|
+
const previous = writeQueues.get(filePath) ?? Promise.resolve();
|
|
495
|
+
const current = previous.catch(() => {}).then(() => writeJSON(filePath, data, options)).catch((error) => {
|
|
496
|
+
console.warn(`[oc-auth-switcher] Could not safely write ${path2.basename(filePath)}:`, error instanceof Error ? error.message : String(error));
|
|
497
|
+
});
|
|
498
|
+
writeQueues.set(filePath, current);
|
|
499
|
+
current.finally(() => {
|
|
500
|
+
if (writeQueues.get(filePath) === current)
|
|
501
|
+
writeQueues.delete(filePath);
|
|
502
|
+
});
|
|
503
|
+
return current;
|
|
469
504
|
}
|
|
470
505
|
function loadAccounts() {
|
|
471
506
|
const raw = safeReadJSON(ACCOUNTS_FILE, {
|
|
@@ -475,16 +510,16 @@ function loadAccounts() {
|
|
|
475
510
|
return { accounts };
|
|
476
511
|
}
|
|
477
512
|
function saveAccounts(data) {
|
|
478
|
-
safeWriteJSON(ACCOUNTS_FILE, data);
|
|
513
|
+
return safeWriteJSON(ACCOUNTS_FILE, data);
|
|
479
514
|
}
|
|
480
|
-
function updateAccountTokens(name, access, refresh, expires) {
|
|
515
|
+
async function updateAccountTokens(name, access, refresh, expires) {
|
|
481
516
|
const data = loadAccounts();
|
|
482
517
|
const account = data.accounts.find((a) => a.name === name);
|
|
483
518
|
if (account) {
|
|
484
519
|
account.access = access;
|
|
485
520
|
account.refresh = refresh;
|
|
486
521
|
account.expires = expires;
|
|
487
|
-
saveAccounts(data);
|
|
522
|
+
await saveAccounts(data);
|
|
488
523
|
}
|
|
489
524
|
}
|
|
490
525
|
async function refreshAccountToken(refreshTokenValue) {
|
|
@@ -575,7 +610,7 @@ function loadState() {
|
|
|
575
610
|
return normalizeState(safeReadJSON(STATE_FILE, {}));
|
|
576
611
|
}
|
|
577
612
|
function saveState(state) {
|
|
578
|
-
safeWriteJSON(STATE_FILE, state);
|
|
613
|
+
return safeWriteJSON(STATE_FILE, state);
|
|
579
614
|
}
|
|
580
615
|
function getThresholds(config) {
|
|
581
616
|
if (typeof config.threshold === "number") {
|
|
@@ -865,13 +900,13 @@ function selectionSnapshot(state) {
|
|
|
865
900
|
currentAccount: state.currentAccount
|
|
866
901
|
};
|
|
867
902
|
}
|
|
868
|
-
function saveRequestState(state, initiallyLoaded) {
|
|
903
|
+
async function saveRequestState(state, initiallyLoaded) {
|
|
869
904
|
const onDisk = loadState();
|
|
870
905
|
const selectionChangedSinceLoad = onDisk.currentAccount !== initiallyLoaded.currentAccount;
|
|
871
906
|
if (selectionChangedSinceLoad) {
|
|
872
907
|
state.currentAccount = onDisk.currentAccount;
|
|
873
908
|
}
|
|
874
|
-
saveState(state);
|
|
909
|
+
await saveState(state);
|
|
875
910
|
}
|
|
876
911
|
function getRequestModel(body) {
|
|
877
912
|
if (typeof body !== "string")
|
|
@@ -973,13 +1008,13 @@ var AuthSwitcherPlugin = async ({ client }) => {
|
|
|
973
1008
|
account.access = result.access;
|
|
974
1009
|
account.refresh = result.refresh;
|
|
975
1010
|
account.expires = result.expires;
|
|
976
|
-
updateAccountTokens(account.name, result.access, result.refresh, result.expires);
|
|
1011
|
+
await updateAccountTokens(account.name, result.access, result.refresh, result.expires);
|
|
977
1012
|
} else {
|
|
978
1013
|
markAuthFailure(state, account.name);
|
|
979
1014
|
const available = accounts.filter((candidate) => !attemptedAccounts.has(candidate.name) && (!state.authFailures[candidate.name] || state.authFailures[candidate.name] <= Date.now()));
|
|
980
1015
|
const next = available.length > 0 ? selectAccount(available, state, model).account : undefined;
|
|
981
1016
|
if (!next) {
|
|
982
|
-
saveRequestState(state, initiallyLoadedSelection);
|
|
1017
|
+
await saveRequestState(state, initiallyLoadedSelection);
|
|
983
1018
|
throw new Error(`[oc-auth-switcher] All accounts failed token refresh`);
|
|
984
1019
|
}
|
|
985
1020
|
account = next;
|
|
@@ -1021,7 +1056,7 @@ var AuthSwitcherPlugin = async ({ client }) => {
|
|
|
1021
1056
|
next.access = result.access;
|
|
1022
1057
|
next.refresh = result.refresh;
|
|
1023
1058
|
next.expires = result.expires;
|
|
1024
|
-
updateAccountTokens(next.name, result.access, result.refresh, result.expires);
|
|
1059
|
+
await updateAccountTokens(next.name, result.access, result.refresh, result.expires);
|
|
1025
1060
|
}
|
|
1026
1061
|
}
|
|
1027
1062
|
const retryHeaders = mergeHeaders(input, init);
|
|
@@ -1036,7 +1071,7 @@ var AuthSwitcherPlugin = async ({ client }) => {
|
|
|
1036
1071
|
});
|
|
1037
1072
|
updateUsageFromHeaders(state, next.name, retryResponse.headers);
|
|
1038
1073
|
clearAuthFailure(state, next.name);
|
|
1039
|
-
saveRequestState(state, initiallyLoadedSelection);
|
|
1074
|
+
await saveRequestState(state, initiallyLoadedSelection);
|
|
1040
1075
|
return createStrippedStream(retryResponse);
|
|
1041
1076
|
}
|
|
1042
1077
|
}
|
|
@@ -1044,7 +1079,7 @@ var AuthSwitcherPlugin = async ({ client }) => {
|
|
|
1044
1079
|
updateUsageFromHeaders(state, account.name, response.headers);
|
|
1045
1080
|
clearAuthFailure(state, account.name);
|
|
1046
1081
|
state.requestCount = (state.requestCount || 0) + 1;
|
|
1047
|
-
saveRequestState(state, initiallyLoadedSelection);
|
|
1082
|
+
await saveRequestState(state, initiallyLoadedSelection);
|
|
1048
1083
|
return createStrippedStream(response);
|
|
1049
1084
|
}
|
|
1050
1085
|
};
|