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.
Files changed (4) hide show
  1. package/README.md +16 -20
  2. package/dist/cli.js +343 -66
  3. package/dist/index.js +25 -120
  4. 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 your primary account (first account = always preferred)
29
- oc-auth-switcher add primary
30
-
31
- # Add fallback accounts
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. When the primary account exceeds the utilization threshold, the plugin switches to the next available fallback. When the primary recovers, it switches back.
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 and check interval |
54
- | `switch <name>` | Manually switch to a specific account |
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
- - The first account in the pool is always "primary" (preferred)
86
- - When the primary exceeds any utilization threshold, switch to the first fallback under threshold
87
- - If all fallbacks are also over threshold, pick the one with the lowest utilization score
88
- - When on a fallback, periodically check if the primary has recovered (configurable interval)
89
- - Auth failures trigger a 1-hour cooldown per account
90
- - Token refresh is handled automatically with retry and fallback to other accounts
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 RESET = "\x1B[0m";
384
- var BOLD = "\x1B[1m";
385
- var DIM = "\x1B[2m";
386
- var RED = "\x1B[31m";
387
- var GREEN = "\x1B[32m";
388
- var YELLOW = "\x1B[33m";
389
- var BLUE = "\x1B[34m";
390
- var CYAN = "\x1B[36m";
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
- state.config.checkInterval = DEFAULT_CHECK_INTERVAL;
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|auto>${RESET}`);
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
- console.log(` Active account: ${BOLD}${state.currentAccount || "(none)"}${RESET}`);
746
- console.log(` Selection mode: ${state.selectionMode}${state.manualAccount ? ` (${state.manualAccount})` : ""}`);
747
- console.log(` Total accounts: ${data.accounts.length}`);
748
- console.log(` Request count: ${state.requestCount}`);
749
- console.log(` Last rotation: ${state.lastRotationCheck ? new Date(state.lastRotationCheck).toLocaleString() : "never"}`);
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
- const failures = Object.entries(state.authFailures).filter(([_, until]) => until > Date.now());
752
- if (failures.length > 0) {
753
- console.log(` ${RED}Auth cooldowns:${RESET}`);
754
- for (const [name, until] of failures) {
755
- const remaining = Math.ceil((until - Date.now()) / 60000);
756
- console.log(` - ${name}: ${remaining} min remaining`);
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/interval configuration
801
- ${CYAN}switch${RESET} <name|auto> Pin a specific account or resume automatic selection
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 primary
813
- oc-auth-switcher add fallback-1
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 fallback-1
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 : state.config.checkInterval / 1000);
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
- return acct;
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
- let best = null;
828
- let bestScore = Infinity;
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: primary,
832
+ account: selected,
881
833
  switched: true,
882
- reason: "Current account no longer in pool — switching to primary"
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
- selectionMode: state.selectionMode,
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.selectionMode !== initiallyLoaded.selectionMode || onDisk.manualAccount !== initiallyLoaded.manualAccount || onDisk.currentAccount !== initiallyLoaded.currentAccount || onDisk.lastRotationCheck !== initiallyLoaded.lastRotationCheck;
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 next = accounts.find((a) => !attemptedAccounts.has(a.name) && !state.authFailures[a.name]);
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 next = accounts.find((a) => !attemptedAccounts.has(a.name) && (!state.authFailures[a.name] || state.authFailures[a.name] <= Date.now()));
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oc-auth-switcher",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "OpenCode auth plugin for multi-account Anthropic Claude Max rotation with automatic failover.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",