oc-auth-switcher 0.5.0 → 0.7.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/README.md +16 -20
- package/dist/cli.js +343 -66
- package/dist/index.js +25 -120
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -25,19 +25,17 @@ This is the **only** auth plugin you need for Anthropic. Do not also list `@ex-m
|
|
|
25
25
|
### 2. Add accounts
|
|
26
26
|
|
|
27
27
|
```bash
|
|
28
|
-
# Add
|
|
29
|
-
oc-auth-switcher add
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
oc-auth-switcher add fallback-1
|
|
33
|
-
oc-auth-switcher add fallback-2
|
|
28
|
+
# Add accounts in any order
|
|
29
|
+
oc-auth-switcher add work
|
|
30
|
+
oc-auth-switcher add personal
|
|
31
|
+
oc-auth-switcher add team
|
|
34
32
|
```
|
|
35
33
|
|
|
36
34
|
Each `add` command runs an OAuth flow — you'll be given a URL to open in your browser and prompted to paste the callback.
|
|
37
35
|
|
|
38
36
|
### 3. Use OpenCode normally
|
|
39
37
|
|
|
40
|
-
Metrics update automatically on every API request.
|
|
38
|
+
Metrics update automatically on every API request. The active account remains selected until it reaches a relevant utilization threshold or becomes unavailable, then the plugin switches to the available account with the most headroom.
|
|
41
39
|
|
|
42
40
|
## CLI Commands
|
|
43
41
|
|
|
@@ -50,8 +48,8 @@ oc-auth-switcher <command> [options]
|
|
|
50
48
|
| `add [name]` | Add a new account via OAuth |
|
|
51
49
|
| `reauth <name>` | Re-authenticate an existing account |
|
|
52
50
|
| `usage [--watch]` | Show utilization dashboard with progress bars |
|
|
53
|
-
| `config [options]` | View/modify thresholds
|
|
54
|
-
| `switch <name>` |
|
|
51
|
+
| `config [options]` | View/modify thresholds |
|
|
52
|
+
| `switch <name>` | Set the active account |
|
|
55
53
|
| `status` | Show current active account and rotation state |
|
|
56
54
|
| `remove <name>` | Remove an account from the pool |
|
|
57
55
|
|
|
@@ -61,11 +59,8 @@ oc-auth-switcher <command> [options]
|
|
|
61
59
|
# Set uniform threshold (default: 95%)
|
|
62
60
|
oc-auth-switcher config --threshold 0.95
|
|
63
61
|
|
|
64
|
-
# Set per-metric thresholds (5h, 7d, 7d-sonnet)
|
|
65
|
-
oc-auth-switcher config --thresholds 90,80,70
|
|
66
|
-
|
|
67
|
-
# Set primary recovery check interval (default: 60 min)
|
|
68
|
-
oc-auth-switcher config --interval 30
|
|
62
|
+
# Set per-metric thresholds (5h, 7d, 7d-sonnet, 7d-fable)
|
|
63
|
+
oc-auth-switcher config --thresholds 90,80,70,70
|
|
69
64
|
|
|
70
65
|
# Reset to defaults
|
|
71
66
|
oc-auth-switcher config --reset
|
|
@@ -82,12 +77,13 @@ Both files use atomic writes with `.bak` fallback for crash safety.
|
|
|
82
77
|
|
|
83
78
|
## Rotation Algorithm
|
|
84
79
|
|
|
85
|
-
-
|
|
86
|
-
- When
|
|
87
|
-
-
|
|
88
|
-
-
|
|
89
|
-
-
|
|
90
|
-
-
|
|
80
|
+
- Keep the active account while its model-relevant utilization remains below threshold
|
|
81
|
+
- When rotation is required, select the available account with the lowest maximum threshold-normalized utilization
|
|
82
|
+
- Break exact utilization ties by account array order
|
|
83
|
+
- If every account is exhausted, select the least-loaded account rather than failing
|
|
84
|
+
- Recovered accounts do not preempt a healthy active account
|
|
85
|
+
- Auth failures trigger a 10-minute cooldown per account
|
|
86
|
+
- Token refresh is handled automatically with retry and failover to other accounts
|
|
91
87
|
|
|
92
88
|
## Running the CLI
|
|
93
89
|
|
package/dist/cli.js
CHANGED
|
@@ -12,8 +12,14 @@ var configDir = path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(),
|
|
|
12
12
|
var ACCOUNTS_FILE = path.join(configDir, "auth-switcher-accounts.json");
|
|
13
13
|
var STATE_FILE = path.join(configDir, "auth-switcher-state.json");
|
|
14
14
|
var DEFAULT_THRESHOLD = 0.95;
|
|
15
|
-
var DEFAULT_CHECK_INTERVAL = 60 * 60 * 1000;
|
|
16
15
|
var AUTH_FAILURE_COOLDOWN = 10 * 60 * 1000;
|
|
16
|
+
var REJECTION_FALLBACK_SECONDS = 60 * 60;
|
|
17
|
+
var METRIC_MODEL_FAMILY = {
|
|
18
|
+
session5h: null,
|
|
19
|
+
weekly7d: null,
|
|
20
|
+
weekly7dSonnet: "sonnet",
|
|
21
|
+
weekly7dFable: "fable"
|
|
22
|
+
};
|
|
17
23
|
|
|
18
24
|
// node_modules/@ex-machina/opencode-anthropic-auth/dist/constants.js
|
|
19
25
|
var CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
|
@@ -159,13 +165,9 @@ var EMPTY_USAGE = {
|
|
|
159
165
|
function defaultState() {
|
|
160
166
|
return {
|
|
161
167
|
currentAccount: null,
|
|
162
|
-
selectionMode: "auto",
|
|
163
|
-
manualAccount: null,
|
|
164
|
-
lastRotationCheck: 0,
|
|
165
168
|
requestCount: 0,
|
|
166
169
|
config: {
|
|
167
|
-
threshold: DEFAULT_THRESHOLD
|
|
168
|
-
checkInterval: DEFAULT_CHECK_INTERVAL
|
|
170
|
+
threshold: DEFAULT_THRESHOLD
|
|
169
171
|
},
|
|
170
172
|
usage: {},
|
|
171
173
|
authFailures: {}
|
|
@@ -193,13 +195,9 @@ function normalizeState(raw) {
|
|
|
193
195
|
]));
|
|
194
196
|
return {
|
|
195
197
|
currentAccount: raw.currentAccount ?? defaults.currentAccount,
|
|
196
|
-
selectionMode: raw.selectionMode === "manual" && raw.manualAccount ? "manual" : "auto",
|
|
197
|
-
manualAccount: raw.manualAccount ?? defaults.manualAccount,
|
|
198
|
-
lastRotationCheck: raw.lastRotationCheck ?? defaults.lastRotationCheck,
|
|
199
198
|
requestCount: raw.requestCount ?? defaults.requestCount,
|
|
200
199
|
config: {
|
|
201
|
-
threshold: migratedThreshold
|
|
202
|
-
checkInterval: raw.config?.checkInterval ?? defaults.config.checkInterval
|
|
200
|
+
threshold: migratedThreshold
|
|
203
201
|
},
|
|
204
202
|
usage,
|
|
205
203
|
authFailures: raw.authFailures ?? defaults.authFailures
|
|
@@ -265,10 +263,252 @@ function ensureAccountsInState(state, accountNames) {
|
|
|
265
263
|
}
|
|
266
264
|
|
|
267
265
|
// src/rotation.ts
|
|
266
|
+
function isTemporarilyUnavailable(state, accountName) {
|
|
267
|
+
const cooldownUntil = state.authFailures[accountName];
|
|
268
|
+
if (!cooldownUntil)
|
|
269
|
+
return false;
|
|
270
|
+
if (Date.now() > cooldownUntil) {
|
|
271
|
+
delete state.authFailures[accountName];
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
function isOverThreshold(usage, state, modelFamily) {
|
|
277
|
+
if (!usage)
|
|
278
|
+
return false;
|
|
279
|
+
const thresholds = getThresholds(state.config);
|
|
280
|
+
if (usage.rejected.status?.toLowerCase() === "rejected" && isRejectionRelevant(usage.rejected.prefix, modelFamily))
|
|
281
|
+
return true;
|
|
282
|
+
return metricEntries(usage, thresholds, modelFamily).some((metric) => metric.threshold > 0 && metric.util >= metric.threshold);
|
|
283
|
+
}
|
|
284
|
+
function getUtilizationScore(usage, state, modelFamily) {
|
|
285
|
+
if (!usage)
|
|
286
|
+
return 0;
|
|
287
|
+
const thresholds = getThresholds(state.config);
|
|
288
|
+
if (usage.rejected.status?.toLowerCase() === "rejected" && isRejectionRelevant(usage.rejected.prefix, modelFamily))
|
|
289
|
+
return Infinity;
|
|
290
|
+
const scores = metricEntries(usage, thresholds, modelFamily).filter((metric) => metric.threshold > 0).map((metric) => metric.util / metric.threshold);
|
|
291
|
+
return scores.length > 0 ? Math.max(...scores) : 0;
|
|
292
|
+
}
|
|
293
|
+
function getModelFamily(model) {
|
|
294
|
+
if (!model)
|
|
295
|
+
return;
|
|
296
|
+
const normalized = model.toLowerCase();
|
|
297
|
+
return ["fable", "sonnet", "opus"].find((family) => normalized.includes(family));
|
|
298
|
+
}
|
|
299
|
+
function isMetricRelevant(metric, modelFamily) {
|
|
300
|
+
const metricFamily = METRIC_MODEL_FAMILY[metric];
|
|
301
|
+
return modelFamily === undefined || metricFamily === null || metricFamily === modelFamily;
|
|
302
|
+
}
|
|
303
|
+
function rejectionFamily(prefix) {
|
|
304
|
+
if (!prefix)
|
|
305
|
+
return;
|
|
306
|
+
const normalized = prefix.toLowerCase();
|
|
307
|
+
return ["fable", "sonnet", "opus"].find((family) => normalized === `7d_${family}` || normalized === `anthropic-ratelimit-unified-7d_${family}`);
|
|
308
|
+
}
|
|
309
|
+
function isRejectionRelevant(prefix, modelFamily) {
|
|
310
|
+
const rejectedFamily = rejectionFamily(prefix);
|
|
311
|
+
return modelFamily === undefined || rejectedFamily === undefined || rejectedFamily === modelFamily;
|
|
312
|
+
}
|
|
313
|
+
function metricEntries(usage, thresholds, modelFamily) {
|
|
314
|
+
return Object.keys(METRIC_MODEL_FAMILY).filter((key) => isMetricRelevant(key, modelFamily)).map((key) => ({
|
|
315
|
+
name: key,
|
|
316
|
+
util: usage[key].utilization,
|
|
317
|
+
threshold: thresholds[key]
|
|
318
|
+
}));
|
|
319
|
+
}
|
|
320
|
+
function purgeExpiredCooldowns(state) {
|
|
321
|
+
const now = Date.now();
|
|
322
|
+
for (const name of Object.keys(state.authFailures)) {
|
|
323
|
+
if (state.authFailures[name] <= now) {
|
|
324
|
+
delete state.authFailures[name];
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
function findBestAvailable(candidates, state, exclude, modelFamily) {
|
|
329
|
+
let best = null;
|
|
330
|
+
let bestScore = Infinity;
|
|
331
|
+
for (const acct of candidates) {
|
|
332
|
+
if (exclude.has(acct.name))
|
|
333
|
+
continue;
|
|
334
|
+
if (isTemporarilyUnavailable(state, acct.name))
|
|
335
|
+
continue;
|
|
336
|
+
if (!isOverThreshold(state.usage[acct.name], state, modelFamily)) {
|
|
337
|
+
const score = getUtilizationScore(state.usage[acct.name], state, modelFamily);
|
|
338
|
+
if (!best || score < bestScore) {
|
|
339
|
+
bestScore = score;
|
|
340
|
+
best = acct;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (best)
|
|
345
|
+
return best;
|
|
346
|
+
best = null;
|
|
347
|
+
bestScore = Infinity;
|
|
348
|
+
for (const acct of candidates) {
|
|
349
|
+
if (exclude.has(acct.name))
|
|
350
|
+
continue;
|
|
351
|
+
if (isTemporarilyUnavailable(state, acct.name))
|
|
352
|
+
continue;
|
|
353
|
+
const score = getUtilizationScore(state.usage[acct.name], state, modelFamily);
|
|
354
|
+
if (!best || score < bestScore) {
|
|
355
|
+
bestScore = score;
|
|
356
|
+
best = acct;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return best;
|
|
360
|
+
}
|
|
361
|
+
function selectAccount(accounts, state, model) {
|
|
362
|
+
if (accounts.length === 0) {
|
|
363
|
+
throw new Error("No accounts available");
|
|
364
|
+
}
|
|
365
|
+
purgeExpiredCooldowns(state);
|
|
366
|
+
const modelFamily = getModelFamily(model);
|
|
367
|
+
const currentIdx = accounts.findIndex((a) => a.name === state.currentAccount);
|
|
368
|
+
if (currentIdx < 0) {
|
|
369
|
+
const best = findBestAvailable(accounts, state, new Set, modelFamily);
|
|
370
|
+
const selected = best ?? accounts[0];
|
|
371
|
+
return {
|
|
372
|
+
account: selected,
|
|
373
|
+
switched: true,
|
|
374
|
+
reason: `Selecting account ${selected.name}`
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
const current = accounts[currentIdx];
|
|
378
|
+
const currentUsage = state.usage[current.name];
|
|
379
|
+
const currentOverThreshold = isOverThreshold(currentUsage, state, modelFamily);
|
|
380
|
+
const currentInCooldown = isTemporarilyUnavailable(state, current.name);
|
|
381
|
+
if (currentOverThreshold || currentInCooldown) {
|
|
382
|
+
const reason = currentInCooldown ? `${current.name} in auth-failure cooldown` : `${current.name} exceeded threshold`;
|
|
383
|
+
const best = findBestAvailable(accounts, state, new Set([current.name]), modelFamily);
|
|
384
|
+
if (best && best.name !== current.name) {
|
|
385
|
+
return {
|
|
386
|
+
account: best,
|
|
387
|
+
switched: true,
|
|
388
|
+
reason: `${reason} \u2014 switching to ${best.name}`
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
return { account: current, switched: false };
|
|
392
|
+
}
|
|
393
|
+
return { account: current, switched: false };
|
|
394
|
+
}
|
|
268
395
|
function clearAuthFailure(state, accountName) {
|
|
269
396
|
delete state.authFailures[accountName];
|
|
270
397
|
}
|
|
271
398
|
|
|
399
|
+
// src/status.ts
|
|
400
|
+
var MODEL_FAMILIES = ["opus", "sonnet", "fable"];
|
|
401
|
+
function percentage(value) {
|
|
402
|
+
return `${(value * 100).toFixed(0)}%`;
|
|
403
|
+
}
|
|
404
|
+
function formatRelativeDuration(targetMs, nowMs = Date.now()) {
|
|
405
|
+
const difference = targetMs - nowMs;
|
|
406
|
+
const absoluteSeconds = Math.abs(difference) / 1000;
|
|
407
|
+
if (absoluteSeconds < 30)
|
|
408
|
+
return "now";
|
|
409
|
+
const units = [
|
|
410
|
+
["d", 86400],
|
|
411
|
+
["h", 3600],
|
|
412
|
+
["m", 60]
|
|
413
|
+
];
|
|
414
|
+
const parts = [];
|
|
415
|
+
let remaining = absoluteSeconds;
|
|
416
|
+
for (const [suffix, seconds] of units) {
|
|
417
|
+
const amount = Math.floor(remaining / seconds);
|
|
418
|
+
if (amount > 0) {
|
|
419
|
+
parts.push(`${amount}${suffix}`);
|
|
420
|
+
remaining -= amount * seconds;
|
|
421
|
+
}
|
|
422
|
+
if (parts.length === 2)
|
|
423
|
+
break;
|
|
424
|
+
}
|
|
425
|
+
if (parts.length === 0)
|
|
426
|
+
parts.push(`${Math.max(1, Math.round(remaining))}s`);
|
|
427
|
+
return difference >= 0 ? `in ${parts.join(" ")}` : `${parts.join(" ")} ago`;
|
|
428
|
+
}
|
|
429
|
+
function displayRejectionPrefix(prefix) {
|
|
430
|
+
return prefix?.replace(/^anthropic-ratelimit-unified-/i, "");
|
|
431
|
+
}
|
|
432
|
+
function rejectionFamily2(prefix) {
|
|
433
|
+
if (!prefix)
|
|
434
|
+
return;
|
|
435
|
+
const normalized = prefix.toLowerCase();
|
|
436
|
+
return MODEL_FAMILIES.find((family) => normalized === `7d_${family}` || normalized === `anthropic-ratelimit-unified-7d_${family}`);
|
|
437
|
+
}
|
|
438
|
+
function tokenExpiryDescription(account, nowMs) {
|
|
439
|
+
if (account.expires > nowMs) {
|
|
440
|
+
return `expires ${formatRelativeDuration(account.expires, nowMs)}`;
|
|
441
|
+
}
|
|
442
|
+
if (account.refresh) {
|
|
443
|
+
return account.expires > 0 ? `expired ${formatRelativeDuration(account.expires, nowMs)}; refresh available` : "expired; refresh available";
|
|
444
|
+
}
|
|
445
|
+
return account.expires > 0 ? `expired/unrefreshable (${formatRelativeDuration(account.expires, nowMs)})` : "expired/unrefreshable";
|
|
446
|
+
}
|
|
447
|
+
function deriveAccountHealth(account, usage, thresholds, cooldownUntil, nowMs = Date.now()) {
|
|
448
|
+
const availability = Object.fromEntries(MODEL_FAMILIES.map((family) => [family, true]));
|
|
449
|
+
const reasons = [];
|
|
450
|
+
const metrics = usage ? Object.keys(METRIC_MODEL_FAMILY).filter((metric) => thresholds[metric] > 0).map((metric) => ({
|
|
451
|
+
metric,
|
|
452
|
+
utilization: usage[metric].utilization,
|
|
453
|
+
threshold: thresholds[metric],
|
|
454
|
+
family: METRIC_MODEL_FAMILY[metric]
|
|
455
|
+
})) : [];
|
|
456
|
+
for (const metric of metrics) {
|
|
457
|
+
if (metric.utilization < metric.threshold)
|
|
458
|
+
continue;
|
|
459
|
+
const families = metric.family ? [metric.family] : [...MODEL_FAMILIES];
|
|
460
|
+
for (const family of families)
|
|
461
|
+
availability[family] = false;
|
|
462
|
+
reasons.push({
|
|
463
|
+
kind: "metric",
|
|
464
|
+
message: `${metric.metric} ${percentage(metric.utilization)} >= ${percentage(metric.threshold)}${metric.family ? ` \u2014 ${metric.family} only` : ""}`,
|
|
465
|
+
families,
|
|
466
|
+
reset: usage?.[metric.metric].reset
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
if (usage?.rejected.status?.toLowerCase() === "rejected") {
|
|
470
|
+
const family = rejectionFamily2(usage.rejected.prefix);
|
|
471
|
+
const families = family ? [family] : [...MODEL_FAMILIES];
|
|
472
|
+
for (const blockedFamily of families)
|
|
473
|
+
availability[blockedFamily] = false;
|
|
474
|
+
const prefix = displayRejectionPrefix(usage.rejected.prefix);
|
|
475
|
+
reasons.push({
|
|
476
|
+
kind: "rejection",
|
|
477
|
+
message: `rejected${prefix ? ` (${prefix})` : ""}${family ? ` \u2014 ${family} only` : ""}`,
|
|
478
|
+
families,
|
|
479
|
+
reset: usage.rejected.reset
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
if (cooldownUntil && cooldownUntil > nowMs) {
|
|
483
|
+
for (const family of MODEL_FAMILIES)
|
|
484
|
+
availability[family] = false;
|
|
485
|
+
reasons.push({
|
|
486
|
+
kind: "cooldown",
|
|
487
|
+
message: `auth-failure cooldown \u2014 ${formatRelativeDuration(cooldownUntil, nowMs)}`,
|
|
488
|
+
families: [...MODEL_FAMILIES]
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
const hasUsableToken = !!account.access && account.expires > nowMs || !!account.refresh;
|
|
492
|
+
if (!hasUsableToken) {
|
|
493
|
+
for (const family of MODEL_FAMILIES)
|
|
494
|
+
availability[family] = false;
|
|
495
|
+
reasons.push({
|
|
496
|
+
kind: "token",
|
|
497
|
+
message: "expired/unrefreshable token",
|
|
498
|
+
families: [...MODEL_FAMILIES]
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
const highestUtilization = metrics.sort((a, b) => b.utilization / b.threshold - a.utilization / a.threshold)[0] ?? null;
|
|
502
|
+
const futureResets = reasons.map((reason) => reason.reset).filter((reset) => !!reset && reset * 1000 > nowMs);
|
|
503
|
+
return {
|
|
504
|
+
availability,
|
|
505
|
+
highestUtilization,
|
|
506
|
+
reasons,
|
|
507
|
+
earliestReset: futureResets.length > 0 ? Math.min(...futureResets) : undefined,
|
|
508
|
+
tokenExpiry: tokenExpiryDescription(account, nowMs)
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
272
512
|
// node_modules/@ex-machina/opencode-anthropic-auth/dist/pkce.js
|
|
273
513
|
function base64UrlEncode(bytes) {
|
|
274
514
|
let bin = "";
|
|
@@ -380,14 +620,15 @@ async function exchange(input, verifier, redirectUri, expectedState) {
|
|
|
380
620
|
|
|
381
621
|
// src/cli.ts
|
|
382
622
|
import { spawn } from "child_process";
|
|
383
|
-
var
|
|
384
|
-
var
|
|
385
|
-
var
|
|
386
|
-
var
|
|
387
|
-
var
|
|
388
|
-
var
|
|
389
|
-
var
|
|
390
|
-
var
|
|
623
|
+
var COLOR = !!process.stdout.isTTY;
|
|
624
|
+
var RESET = COLOR ? "\x1B[0m" : "";
|
|
625
|
+
var BOLD = COLOR ? "\x1B[1m" : "";
|
|
626
|
+
var DIM = COLOR ? "\x1B[2m" : "";
|
|
627
|
+
var RED = COLOR ? "\x1B[31m" : "";
|
|
628
|
+
var GREEN = COLOR ? "\x1B[32m" : "";
|
|
629
|
+
var YELLOW = COLOR ? "\x1B[33m" : "";
|
|
630
|
+
var BLUE = COLOR ? "\x1B[34m" : "";
|
|
631
|
+
var CYAN = COLOR ? "\x1B[36m" : "";
|
|
391
632
|
function progressBar(value, threshold, width = 30) {
|
|
392
633
|
const pct = Math.min(value, 1);
|
|
393
634
|
const filled = Math.round(pct * width);
|
|
@@ -641,7 +882,6 @@ ${BOLD}${CYAN}=== Auth Switcher Configuration ===${RESET}
|
|
|
641
882
|
console.log(` Threshold (7d): ${(thresholds.weekly7d * 100).toFixed(0)}%`);
|
|
642
883
|
console.log(` Threshold (7d sonnet): ${(thresholds.weekly7dSonnet * 100).toFixed(0)}%`);
|
|
643
884
|
console.log(` Threshold (7d fable): ${(thresholds.weekly7dFable * 100).toFixed(0)}%`);
|
|
644
|
-
console.log(` Check interval: ${state.config.checkInterval / 60000} min`);
|
|
645
885
|
console.log();
|
|
646
886
|
console.log(`${DIM} Config file: ${STATE_FILE}${RESET}`);
|
|
647
887
|
console.log();
|
|
@@ -676,18 +916,9 @@ ${BOLD}${CYAN}=== Auth Switcher Configuration ===${RESET}
|
|
|
676
916
|
weekly7dFable: normalized[3] ?? currentFableThreshold
|
|
677
917
|
};
|
|
678
918
|
console.log(`${GREEN}Set per-metric thresholds: 5h=${(normalized[0] * 100).toFixed(0)}% 7d=${(normalized[1] * 100).toFixed(0)}% 7d-sonnet=${(normalized[2] * 100).toFixed(0)}% 7d-fable=${((normalized[3] ?? currentFableThreshold) * 100).toFixed(0)}%${RESET}`);
|
|
679
|
-
} else if (arg === "--interval" && args[i + 1]) {
|
|
680
|
-
const minutes = parseInt(args[++i], 10);
|
|
681
|
-
if (isNaN(minutes) || minutes < 1) {
|
|
682
|
-
console.error(`${RED}Interval must be a positive number of minutes${RESET}`);
|
|
683
|
-
process.exit(1);
|
|
684
|
-
}
|
|
685
|
-
state.config.checkInterval = minutes * 60 * 1000;
|
|
686
|
-
console.log(`${GREEN}Set check interval to ${minutes} minutes${RESET}`);
|
|
687
919
|
} else if (arg === "--reset") {
|
|
688
920
|
state.config.threshold = DEFAULT_THRESHOLD;
|
|
689
|
-
|
|
690
|
-
console.log(`${GREEN}Reset to defaults: threshold=${(DEFAULT_THRESHOLD * 100).toFixed(0)}% interval=${DEFAULT_CHECK_INTERVAL / 60000}min${RESET}`);
|
|
921
|
+
console.log(`${GREEN}Reset to default threshold: ${(DEFAULT_THRESHOLD * 100).toFixed(0)}%${RESET}`);
|
|
691
922
|
}
|
|
692
923
|
}
|
|
693
924
|
saveState(state);
|
|
@@ -707,20 +938,10 @@ ${BOLD}Available accounts:${RESET}`);
|
|
|
707
938
|
const tag = a.name === state2.currentAccount ? ` ${GREEN}[ACTIVE]${RESET}` : "";
|
|
708
939
|
console.log(` - ${a.name}${tag}`);
|
|
709
940
|
}
|
|
710
|
-
console.log(` - auto ${DIM}(resume automatic selection)${RESET}`);
|
|
711
941
|
console.error(`
|
|
712
|
-
${RED}Usage: oc-auth-switcher switch <account-name
|
|
942
|
+
${RED}Usage: oc-auth-switcher switch <account-name>${RESET}`);
|
|
713
943
|
process.exit(1);
|
|
714
944
|
}
|
|
715
|
-
if (name === "auto") {
|
|
716
|
-
const state2 = loadState();
|
|
717
|
-
state2.selectionMode = "auto";
|
|
718
|
-
state2.manualAccount = null;
|
|
719
|
-
state2.lastRotationCheck = Date.now();
|
|
720
|
-
saveState(state2);
|
|
721
|
-
console.log(`${GREEN}Automatic account selection resumed.${RESET}`);
|
|
722
|
-
return;
|
|
723
|
-
}
|
|
724
945
|
const account = data.accounts.find((a) => a.name === name);
|
|
725
946
|
if (!account) {
|
|
726
947
|
console.error(`${RED}Account "${name}" not found${RESET}`);
|
|
@@ -729,31 +950,93 @@ ${RED}Usage: oc-auth-switcher switch <account-name|auto>${RESET}`);
|
|
|
729
950
|
console.log(`${CYAN}Switching to account: ${name}...${RESET}`);
|
|
730
951
|
const state = loadState();
|
|
731
952
|
state.currentAccount = name;
|
|
732
|
-
state.selectionMode = "manual";
|
|
733
|
-
state.manualAccount = name;
|
|
734
|
-
state.lastRotationCheck = Date.now();
|
|
735
953
|
saveState(state);
|
|
736
954
|
console.log(`${GREEN}Switched to "${name}". Will take effect on the next API request.${RESET}`);
|
|
737
955
|
}
|
|
956
|
+
function healthLabel(health) {
|
|
957
|
+
const usable = MODEL_FAMILIES.filter((family) => health.availability[family]);
|
|
958
|
+
const unavailable = MODEL_FAMILIES.filter((family) => !health.availability[family]);
|
|
959
|
+
if (unavailable.length === 0)
|
|
960
|
+
return `${GREEN}healthy \u2014 all models usable${RESET}`;
|
|
961
|
+
if (usable.length === 0)
|
|
962
|
+
return `${RED}unavailable \u2014 all models${RESET}`;
|
|
963
|
+
return `${YELLOW}partial \u2014 usable: ${usable.join(", ")}; unavailable: ${unavailable.join(", ")}${RESET}`;
|
|
964
|
+
}
|
|
965
|
+
function utilizationLabel(health) {
|
|
966
|
+
const peak = health.highestUtilization;
|
|
967
|
+
if (!peak)
|
|
968
|
+
return `${DIM}no usage data${RESET}`;
|
|
969
|
+
const utilization = `${(peak.utilization * 100).toFixed(0)}%`;
|
|
970
|
+
const threshold = `${(peak.threshold * 100).toFixed(0)}%`;
|
|
971
|
+
const family = peak.family ? ` \u2014 ${peak.family} only` : "";
|
|
972
|
+
if (peak.utilization >= peak.threshold) {
|
|
973
|
+
return `${RED}${peak.metric} ${utilization} >= ${threshold}${family}${RESET}`;
|
|
974
|
+
}
|
|
975
|
+
const color = peak.utilization >= peak.threshold * 0.8 ? YELLOW : GREEN;
|
|
976
|
+
return `${color}${peak.metric} ${utilization} / ${threshold} threshold${family}${RESET}`;
|
|
977
|
+
}
|
|
978
|
+
function nextAccountSummary(accounts, state, activeName) {
|
|
979
|
+
if (accounts.length === 0 || activeName && accounts.length === 1) {
|
|
980
|
+
return "(none \u2014 no alternate account)";
|
|
981
|
+
}
|
|
982
|
+
const selections = MODEL_FAMILIES.map((family) => {
|
|
983
|
+
const candidateState = structuredClone(state);
|
|
984
|
+
candidateState.currentAccount = activeName;
|
|
985
|
+
if (activeName)
|
|
986
|
+
candidateState.authFailures[activeName] = Date.now() + 60000;
|
|
987
|
+
const selected = selectAccount(accounts, candidateState, `claude-${family}`);
|
|
988
|
+
return [
|
|
989
|
+
family,
|
|
990
|
+
selected.account.name === activeName ? null : selected.account.name
|
|
991
|
+
];
|
|
992
|
+
});
|
|
993
|
+
const names = new Set(selections.map(([_, name]) => name));
|
|
994
|
+
if (names.size === 1) {
|
|
995
|
+
return selections[0][1] ? `${selections[0][1]} (all models)` : "(none \u2014 no usable alternate account)";
|
|
996
|
+
}
|
|
997
|
+
return selections.map(([family, name]) => `${family}: ${name ?? "none"}`).join(", ");
|
|
998
|
+
}
|
|
738
999
|
function cmdStatus() {
|
|
739
1000
|
const data = loadAccounts();
|
|
740
1001
|
const state = loadState();
|
|
741
1002
|
resolveStaleMetrics(state);
|
|
1003
|
+
const thresholds = getThresholds(state.config);
|
|
1004
|
+
const now = Date.now();
|
|
1005
|
+
const configuredActive = data.accounts.find((account) => account.name === state.currentAccount);
|
|
1006
|
+
const healthByAccount = new Map(data.accounts.map((account) => [
|
|
1007
|
+
account.name,
|
|
1008
|
+
deriveAccountHealth(account, state.usage[account.name], thresholds, state.authFailures[account.name], now)
|
|
1009
|
+
]));
|
|
742
1010
|
console.log(`
|
|
743
1011
|
${BOLD}${CYAN}=== Auth Switcher Status ===${RESET}
|
|
744
1012
|
`);
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
1013
|
+
if (configuredActive) {
|
|
1014
|
+
console.log(` Active account: ${BOLD}${configuredActive.name}${RESET} \u2014 ${healthLabel(healthByAccount.get(configuredActive.name))}`);
|
|
1015
|
+
} else {
|
|
1016
|
+
console.log(` Active account: ${BOLD}(none)${RESET}`);
|
|
1017
|
+
}
|
|
1018
|
+
console.log(` Next if unavailable: ${BOLD}${nextAccountSummary(data.accounts, state, configuredActive?.name ?? null)}${RESET}`);
|
|
1019
|
+
console.log(` Accounts: ${data.accounts.length} Requests: ${state.requestCount}`);
|
|
750
1020
|
console.log();
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
console.log(
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
1021
|
+
if (data.accounts.length === 0) {
|
|
1022
|
+
console.log(` ${DIM}No accounts configured. Run 'oc-auth-switcher add' to add one.${RESET}`);
|
|
1023
|
+
console.log();
|
|
1024
|
+
} else {
|
|
1025
|
+
console.log(` ${BOLD}Account health${RESET}`);
|
|
1026
|
+
for (const account of data.accounts) {
|
|
1027
|
+
const health = healthByAccount.get(account.name);
|
|
1028
|
+
const active = account.name === configuredActive?.name ? ` ${GREEN}[ACTIVE]${RESET}` : "";
|
|
1029
|
+
console.log(` ${BOLD}${account.name}${RESET}${active} \u2014 ${healthLabel(health)}`);
|
|
1030
|
+
console.log(` Peak: ${utilizationLabel(health)}`);
|
|
1031
|
+
for (const reason of health.reasons) {
|
|
1032
|
+
console.log(` ${RED}Reason: ${reason.message}${RESET}`);
|
|
1033
|
+
}
|
|
1034
|
+
if (health.earliestReset) {
|
|
1035
|
+
const resetMs = health.earliestReset * 1000;
|
|
1036
|
+
console.log(` Reset: ${new Date(resetMs).toLocaleString()} (${formatRelativeDuration(resetMs, now)})`);
|
|
1037
|
+
}
|
|
1038
|
+
const tokenColor = health.reasons.some((reason) => reason.kind === "token") ? RED : DIM;
|
|
1039
|
+
console.log(` ${tokenColor}Token: ${health.tokenExpiry}${RESET}`);
|
|
757
1040
|
}
|
|
758
1041
|
console.log();
|
|
759
1042
|
}
|
|
@@ -778,10 +1061,6 @@ function cmdRemove(args) {
|
|
|
778
1061
|
const state = loadState();
|
|
779
1062
|
if (state.currentAccount === name) {
|
|
780
1063
|
state.currentAccount = null;
|
|
781
|
-
if (state.manualAccount === name) {
|
|
782
|
-
state.selectionMode = "auto";
|
|
783
|
-
state.manualAccount = null;
|
|
784
|
-
}
|
|
785
1064
|
saveState(state);
|
|
786
1065
|
console.log(`${YELLOW}This was the active account. Rotation will pick a new one automatically.${RESET}`);
|
|
787
1066
|
}
|
|
@@ -797,24 +1076,22 @@ ${BOLD}COMMANDS:${RESET}
|
|
|
797
1076
|
${CYAN}add${RESET} [name] Add a new Anthropic account via OAuth
|
|
798
1077
|
${CYAN}reauth${RESET} <name> Re-authenticate an existing account
|
|
799
1078
|
${CYAN}usage${RESET} [--watch] Show usage dashboard with utilization metrics
|
|
800
|
-
${CYAN}config${RESET} [options] View or modify threshold
|
|
801
|
-
${CYAN}switch${RESET} <name
|
|
1079
|
+
${CYAN}config${RESET} [options] View or modify threshold configuration
|
|
1080
|
+
${CYAN}switch${RESET} <name> Set the active account
|
|
802
1081
|
${CYAN}status${RESET} Show current active account and rotation state
|
|
803
1082
|
${CYAN}remove${RESET} <name> Remove an account from the pool
|
|
804
1083
|
|
|
805
1084
|
${BOLD}CONFIG OPTIONS:${RESET}
|
|
806
1085
|
--threshold <0-1> Set uniform threshold (e.g., 0.90)
|
|
807
1086
|
--thresholds <a,b,c[,d]> Set per-metric thresholds (5h,7d,7d-sonnet,7d-fable)
|
|
808
|
-
--interval <minutes> Set primary recovery check interval
|
|
809
1087
|
--reset Reset to defaults
|
|
810
1088
|
|
|
811
1089
|
${BOLD}EXAMPLES:${RESET}
|
|
812
|
-
oc-auth-switcher add
|
|
813
|
-
oc-auth-switcher add
|
|
1090
|
+
oc-auth-switcher add work
|
|
1091
|
+
oc-auth-switcher add personal
|
|
814
1092
|
oc-auth-switcher usage --watch
|
|
815
1093
|
oc-auth-switcher config --threshold 0.90
|
|
816
|
-
oc-auth-switcher switch
|
|
817
|
-
oc-auth-switcher switch auto
|
|
1094
|
+
oc-auth-switcher switch personal
|
|
818
1095
|
`);
|
|
819
1096
|
}
|
|
820
1097
|
async function main() {
|
package/dist/index.js
CHANGED
|
@@ -414,8 +414,8 @@ var configDir = path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(),
|
|
|
414
414
|
var ACCOUNTS_FILE = path.join(configDir, "auth-switcher-accounts.json");
|
|
415
415
|
var STATE_FILE = path.join(configDir, "auth-switcher-state.json");
|
|
416
416
|
var DEFAULT_THRESHOLD = 0.95;
|
|
417
|
-
var DEFAULT_CHECK_INTERVAL = 60 * 60 * 1000;
|
|
418
417
|
var AUTH_FAILURE_COOLDOWN = 10 * 60 * 1000;
|
|
418
|
+
var REJECTION_FALLBACK_SECONDS = 60 * 60;
|
|
419
419
|
var METRIC_MODEL_FAMILY = {
|
|
420
420
|
session5h: null,
|
|
421
421
|
weekly7d: null,
|
|
@@ -533,13 +533,9 @@ var EMPTY_USAGE = {
|
|
|
533
533
|
function defaultState() {
|
|
534
534
|
return {
|
|
535
535
|
currentAccount: null,
|
|
536
|
-
selectionMode: "auto",
|
|
537
|
-
manualAccount: null,
|
|
538
|
-
lastRotationCheck: 0,
|
|
539
536
|
requestCount: 0,
|
|
540
537
|
config: {
|
|
541
|
-
threshold: DEFAULT_THRESHOLD
|
|
542
|
-
checkInterval: DEFAULT_CHECK_INTERVAL
|
|
538
|
+
threshold: DEFAULT_THRESHOLD
|
|
543
539
|
},
|
|
544
540
|
usage: {},
|
|
545
541
|
authFailures: {}
|
|
@@ -567,13 +563,9 @@ function normalizeState(raw) {
|
|
|
567
563
|
]));
|
|
568
564
|
return {
|
|
569
565
|
currentAccount: raw.currentAccount ?? defaults.currentAccount,
|
|
570
|
-
selectionMode: raw.selectionMode === "manual" && raw.manualAccount ? "manual" : "auto",
|
|
571
|
-
manualAccount: raw.manualAccount ?? defaults.manualAccount,
|
|
572
|
-
lastRotationCheck: raw.lastRotationCheck ?? defaults.lastRotationCheck,
|
|
573
566
|
requestCount: raw.requestCount ?? defaults.requestCount,
|
|
574
567
|
config: {
|
|
575
|
-
threshold: migratedThreshold
|
|
576
|
-
checkInterval: raw.config?.checkInterval ?? defaults.config.checkInterval
|
|
568
|
+
threshold: migratedThreshold
|
|
577
569
|
},
|
|
578
570
|
usage,
|
|
579
571
|
authFailures: raw.authFailures ?? defaults.authFailures
|
|
@@ -714,7 +706,7 @@ function updateUsageFromHeaders(state, accountName, headers) {
|
|
|
714
706
|
if (!Number.isFinite(reset) || reset <= 0) {
|
|
715
707
|
const retryAfterHeader = headers.get("retry-after");
|
|
716
708
|
const retryAfter = retryAfterHeader === null ? NaN : Number(retryAfterHeader);
|
|
717
|
-
reset = Date.now() / 1000 + (Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter :
|
|
709
|
+
reset = Date.now() / 1000 + (Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : REJECTION_FALLBACK_SECONDS);
|
|
718
710
|
}
|
|
719
711
|
usage.rejected = {
|
|
720
712
|
utilization: 1,
|
|
@@ -758,27 +750,6 @@ function getUtilizationScore(usage, state, modelFamily) {
|
|
|
758
750
|
const scores = metricEntries(usage, thresholds, modelFamily).filter((metric) => metric.threshold > 0).map((metric) => metric.util / metric.threshold);
|
|
759
751
|
return scores.length > 0 ? Math.max(...scores) : 0;
|
|
760
752
|
}
|
|
761
|
-
function getExceededMetric(usage, state, modelFamily) {
|
|
762
|
-
if (!usage)
|
|
763
|
-
return null;
|
|
764
|
-
const thresholds = getThresholds(state.config);
|
|
765
|
-
if (usage.rejected.status?.toLowerCase() === "rejected" && isRejectionRelevant(usage.rejected.prefix, modelFamily)) {
|
|
766
|
-
return usage.rejected.prefix ? `rejected rate limit (${usage.rejected.prefix})` : "rejected rate limit";
|
|
767
|
-
}
|
|
768
|
-
const metrics = metricEntries(usage, thresholds, modelFamily).map(({ name, util, threshold }) => ({ name, util, thresh: threshold }));
|
|
769
|
-
const exceeded = metrics.filter((m) => m.thresh > 0 && m.util >= m.thresh).sort((a, b) => b.util / b.thresh - a.util / a.thresh);
|
|
770
|
-
return exceeded.length > 0 ? exceeded[0].name : null;
|
|
771
|
-
}
|
|
772
|
-
function getEarliestReset(usage, modelFamily) {
|
|
773
|
-
if (!usage)
|
|
774
|
-
return 0;
|
|
775
|
-
const resets = Object.keys(METRIC_MODEL_FAMILY).filter((key) => isMetricRelevant(key, modelFamily)).map((key) => usage[key].reset);
|
|
776
|
-
if (isRejectionRelevant(usage.rejected.prefix, modelFamily)) {
|
|
777
|
-
resets.push(usage.rejected.reset);
|
|
778
|
-
}
|
|
779
|
-
const activeResets = resets.filter((reset) => reset > 0);
|
|
780
|
-
return activeResets.length > 0 ? Math.min(...activeResets) * 1000 : 0;
|
|
781
|
-
}
|
|
782
753
|
function getModelFamily(model) {
|
|
783
754
|
if (!model)
|
|
784
755
|
return;
|
|
@@ -815,17 +786,25 @@ function purgeExpiredCooldowns(state) {
|
|
|
815
786
|
}
|
|
816
787
|
}
|
|
817
788
|
function findBestAvailable(candidates, state, exclude, modelFamily) {
|
|
789
|
+
let best = null;
|
|
790
|
+
let bestScore = Infinity;
|
|
818
791
|
for (const acct of candidates) {
|
|
819
792
|
if (exclude.has(acct.name))
|
|
820
793
|
continue;
|
|
821
794
|
if (isTemporarilyUnavailable(state, acct.name))
|
|
822
795
|
continue;
|
|
823
796
|
if (!isOverThreshold(state.usage[acct.name], state, modelFamily)) {
|
|
824
|
-
|
|
797
|
+
const score = getUtilizationScore(state.usage[acct.name], state, modelFamily);
|
|
798
|
+
if (!best || score < bestScore) {
|
|
799
|
+
bestScore = score;
|
|
800
|
+
best = acct;
|
|
801
|
+
}
|
|
825
802
|
}
|
|
826
803
|
}
|
|
827
|
-
|
|
828
|
-
|
|
804
|
+
if (best)
|
|
805
|
+
return best;
|
|
806
|
+
best = null;
|
|
807
|
+
bestScore = Infinity;
|
|
829
808
|
for (const acct of candidates) {
|
|
830
809
|
if (exclude.has(acct.name))
|
|
831
810
|
continue;
|
|
@@ -845,73 +824,22 @@ function selectAccount(accounts, state, model) {
|
|
|
845
824
|
}
|
|
846
825
|
purgeExpiredCooldowns(state);
|
|
847
826
|
const modelFamily = getModelFamily(model);
|
|
848
|
-
if (state.selectionMode === "manual" && state.manualAccount) {
|
|
849
|
-
const manual = accounts.find((account) => account.name === state.manualAccount);
|
|
850
|
-
if (manual) {
|
|
851
|
-
const unavailable = isTemporarilyUnavailable(state, manual.name);
|
|
852
|
-
const exhausted = isOverThreshold(state.usage[manual.name], state, modelFamily);
|
|
853
|
-
if (!unavailable && !exhausted) {
|
|
854
|
-
return {
|
|
855
|
-
account: manual,
|
|
856
|
-
switched: state.currentAccount !== manual.name,
|
|
857
|
-
reason: state.currentAccount !== manual.name ? `Manual selection — switching to ${manual.name}` : undefined
|
|
858
|
-
};
|
|
859
|
-
}
|
|
860
|
-
state.currentAccount = manual.name;
|
|
861
|
-
}
|
|
862
|
-
state.selectionMode = "auto";
|
|
863
|
-
state.manualAccount = null;
|
|
864
|
-
}
|
|
865
|
-
if (accounts.length === 1) {
|
|
866
|
-
return { account: accounts[0], switched: false };
|
|
867
|
-
}
|
|
868
|
-
const primary = accounts[0];
|
|
869
|
-
const fallbacks = accounts.slice(1);
|
|
870
|
-
if (!state.currentAccount) {
|
|
871
|
-
return {
|
|
872
|
-
account: primary,
|
|
873
|
-
switched: true,
|
|
874
|
-
reason: "Initial selection — using primary account"
|
|
875
|
-
};
|
|
876
|
-
}
|
|
877
827
|
const currentIdx = accounts.findIndex((a) => a.name === state.currentAccount);
|
|
878
828
|
if (currentIdx < 0) {
|
|
829
|
+
const best = findBestAvailable(accounts, state, new Set, modelFamily);
|
|
830
|
+
const selected = best ?? accounts[0];
|
|
879
831
|
return {
|
|
880
|
-
account:
|
|
832
|
+
account: selected,
|
|
881
833
|
switched: true,
|
|
882
|
-
reason:
|
|
834
|
+
reason: `Selecting account ${selected.name}`
|
|
883
835
|
};
|
|
884
836
|
}
|
|
885
837
|
const current = accounts[currentIdx];
|
|
886
838
|
const currentUsage = state.usage[current.name];
|
|
887
|
-
const primaryUsage = state.usage[primary.name];
|
|
888
|
-
const isPrimary = current.name === primary.name;
|
|
889
|
-
if (isPrimary) {
|
|
890
|
-
if (isOverThreshold(primaryUsage, state, modelFamily)) {
|
|
891
|
-
const exceededMetric = getExceededMetric(primaryUsage, state, modelFamily);
|
|
892
|
-
const best = findBestAvailable(fallbacks, state, new Set, modelFamily);
|
|
893
|
-
if (best) {
|
|
894
|
-
return {
|
|
895
|
-
account: best,
|
|
896
|
-
switched: true,
|
|
897
|
-
reason: `Primary exceeded ${exceededMetric} threshold — switching to ${best.name}`
|
|
898
|
-
};
|
|
899
|
-
}
|
|
900
|
-
return { account: primary, switched: false };
|
|
901
|
-
}
|
|
902
|
-
return { account: primary, switched: false };
|
|
903
|
-
}
|
|
904
839
|
const currentOverThreshold = isOverThreshold(currentUsage, state, modelFamily);
|
|
905
840
|
const currentInCooldown = isTemporarilyUnavailable(state, current.name);
|
|
906
841
|
if (currentOverThreshold || currentInCooldown) {
|
|
907
842
|
const reason = currentInCooldown ? `${current.name} in auth-failure cooldown` : `${current.name} exceeded threshold`;
|
|
908
|
-
if (!isOverThreshold(primaryUsage, state, modelFamily) && !isTemporarilyUnavailable(state, primary.name)) {
|
|
909
|
-
return {
|
|
910
|
-
account: primary,
|
|
911
|
-
switched: true,
|
|
912
|
-
reason: `${reason} — switching back to primary`
|
|
913
|
-
};
|
|
914
|
-
}
|
|
915
843
|
const best = findBestAvailable(accounts, state, new Set([current.name]), modelFamily);
|
|
916
844
|
if (best && best.name !== current.name) {
|
|
917
845
|
return {
|
|
@@ -922,29 +850,10 @@ function selectAccount(accounts, state, model) {
|
|
|
922
850
|
}
|
|
923
851
|
return { account: current, switched: false };
|
|
924
852
|
}
|
|
925
|
-
const now = Date.now();
|
|
926
|
-
const checkInterval = state.config.checkInterval;
|
|
927
|
-
const earliestReset = getEarliestReset(primaryUsage, modelFamily);
|
|
928
|
-
const timeSinceLastCheck = now - state.lastRotationCheck;
|
|
929
|
-
const shouldCheckPrimary = earliestReset > 0 && earliestReset <= now || timeSinceLastCheck >= checkInterval;
|
|
930
|
-
if (shouldCheckPrimary) {
|
|
931
|
-
state.lastRotationCheck = now;
|
|
932
|
-
if (!isOverThreshold(primaryUsage, state, modelFamily) && !isTemporarilyUnavailable(state, primary.name)) {
|
|
933
|
-
return {
|
|
934
|
-
account: primary,
|
|
935
|
-
switched: true,
|
|
936
|
-
reason: "Primary has recovered — switching back"
|
|
937
|
-
};
|
|
938
|
-
}
|
|
939
|
-
}
|
|
940
853
|
return { account: current, switched: false };
|
|
941
854
|
}
|
|
942
855
|
function markAuthFailure(state, accountName) {
|
|
943
856
|
state.authFailures[accountName] = Date.now() + AUTH_FAILURE_COOLDOWN;
|
|
944
|
-
if (state.selectionMode === "manual" && state.manualAccount === accountName) {
|
|
945
|
-
state.selectionMode = "auto";
|
|
946
|
-
state.manualAccount = null;
|
|
947
|
-
}
|
|
948
857
|
}
|
|
949
858
|
function clearAuthFailure(state, accountName) {
|
|
950
859
|
delete state.authFailures[accountName];
|
|
@@ -953,20 +862,14 @@ function clearAuthFailure(state, accountName) {
|
|
|
953
862
|
// src/index.ts
|
|
954
863
|
function selectionSnapshot(state) {
|
|
955
864
|
return {
|
|
956
|
-
|
|
957
|
-
manualAccount: state.manualAccount,
|
|
958
|
-
currentAccount: state.currentAccount,
|
|
959
|
-
lastRotationCheck: state.lastRotationCheck
|
|
865
|
+
currentAccount: state.currentAccount
|
|
960
866
|
};
|
|
961
867
|
}
|
|
962
868
|
function saveRequestState(state, initiallyLoaded) {
|
|
963
869
|
const onDisk = loadState();
|
|
964
|
-
const selectionChangedSinceLoad = onDisk.
|
|
870
|
+
const selectionChangedSinceLoad = onDisk.currentAccount !== initiallyLoaded.currentAccount;
|
|
965
871
|
if (selectionChangedSinceLoad) {
|
|
966
|
-
state.selectionMode = onDisk.selectionMode;
|
|
967
|
-
state.manualAccount = onDisk.manualAccount;
|
|
968
872
|
state.currentAccount = onDisk.currentAccount;
|
|
969
|
-
state.lastRotationCheck = onDisk.lastRotationCheck;
|
|
970
873
|
}
|
|
971
874
|
saveState(state);
|
|
972
875
|
}
|
|
@@ -1073,7 +976,8 @@ var AuthSwitcherPlugin = async ({ client }) => {
|
|
|
1073
976
|
updateAccountTokens(account.name, result.access, result.refresh, result.expires);
|
|
1074
977
|
} else {
|
|
1075
978
|
markAuthFailure(state, account.name);
|
|
1076
|
-
const
|
|
979
|
+
const available = accounts.filter((candidate) => !attemptedAccounts.has(candidate.name) && (!state.authFailures[candidate.name] || state.authFailures[candidate.name] <= Date.now()));
|
|
980
|
+
const next = available.length > 0 ? selectAccount(available, state, model).account : undefined;
|
|
1077
981
|
if (!next) {
|
|
1078
982
|
saveRequestState(state, initiallyLoadedSelection);
|
|
1079
983
|
throw new Error(`[oc-auth-switcher] All accounts failed token refresh`);
|
|
@@ -1105,7 +1009,8 @@ var AuthSwitcherPlugin = async ({ client }) => {
|
|
|
1105
1009
|
const isScopeError = errorBody.includes("scope") || errorBody.includes("unauthorized") || errorBody.includes("invalid");
|
|
1106
1010
|
if (isScopeError) {
|
|
1107
1011
|
markAuthFailure(state, account.name);
|
|
1108
|
-
const
|
|
1012
|
+
const available = accounts.filter((candidate) => !attemptedAccounts.has(candidate.name) && (!state.authFailures[candidate.name] || state.authFailures[candidate.name] <= Date.now()));
|
|
1013
|
+
const next = available.length > 0 ? selectAccount(available, state, model).account : undefined;
|
|
1109
1014
|
if (next) {
|
|
1110
1015
|
attemptedAccounts.add("__retried__");
|
|
1111
1016
|
account = next;
|