oc-auth-switcher 0.4.0 → 0.6.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 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,8 @@ 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
17
 
18
18
  // node_modules/@ex-machina/opencode-anthropic-auth/dist/constants.js
19
19
  var CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
@@ -159,13 +159,9 @@ var EMPTY_USAGE = {
159
159
  function defaultState() {
160
160
  return {
161
161
  currentAccount: null,
162
- selectionMode: "auto",
163
- manualAccount: null,
164
- lastRotationCheck: 0,
165
162
  requestCount: 0,
166
163
  config: {
167
- threshold: DEFAULT_THRESHOLD,
168
- checkInterval: DEFAULT_CHECK_INTERVAL
164
+ threshold: DEFAULT_THRESHOLD
169
165
  },
170
166
  usage: {},
171
167
  authFailures: {}
@@ -193,13 +189,9 @@ function normalizeState(raw) {
193
189
  ]));
194
190
  return {
195
191
  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
192
  requestCount: raw.requestCount ?? defaults.requestCount,
200
193
  config: {
201
- threshold: migratedThreshold,
202
- checkInterval: raw.config?.checkInterval ?? defaults.config.checkInterval
194
+ threshold: migratedThreshold
203
195
  },
204
196
  usage,
205
197
  authFailures: raw.authFailures ?? defaults.authFailures
@@ -641,7 +633,6 @@ ${BOLD}${CYAN}=== Auth Switcher Configuration ===${RESET}
641
633
  console.log(` Threshold (7d): ${(thresholds.weekly7d * 100).toFixed(0)}%`);
642
634
  console.log(` Threshold (7d sonnet): ${(thresholds.weekly7dSonnet * 100).toFixed(0)}%`);
643
635
  console.log(` Threshold (7d fable): ${(thresholds.weekly7dFable * 100).toFixed(0)}%`);
644
- console.log(` Check interval: ${state.config.checkInterval / 60000} min`);
645
636
  console.log();
646
637
  console.log(`${DIM} Config file: ${STATE_FILE}${RESET}`);
647
638
  console.log();
@@ -676,18 +667,9 @@ ${BOLD}${CYAN}=== Auth Switcher Configuration ===${RESET}
676
667
  weekly7dFable: normalized[3] ?? currentFableThreshold
677
668
  };
678
669
  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
670
  } else if (arg === "--reset") {
688
671
  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}`);
672
+ console.log(`${GREEN}Reset to default threshold: ${(DEFAULT_THRESHOLD * 100).toFixed(0)}%${RESET}`);
691
673
  }
692
674
  }
693
675
  saveState(state);
@@ -707,20 +689,10 @@ ${BOLD}Available accounts:${RESET}`);
707
689
  const tag = a.name === state2.currentAccount ? ` ${GREEN}[ACTIVE]${RESET}` : "";
708
690
  console.log(` - ${a.name}${tag}`);
709
691
  }
710
- console.log(` - auto ${DIM}(resume automatic selection)${RESET}`);
711
692
  console.error(`
712
- ${RED}Usage: oc-auth-switcher switch <account-name|auto>${RESET}`);
693
+ ${RED}Usage: oc-auth-switcher switch <account-name>${RESET}`);
713
694
  process.exit(1);
714
695
  }
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
696
  const account = data.accounts.find((a) => a.name === name);
725
697
  if (!account) {
726
698
  console.error(`${RED}Account "${name}" not found${RESET}`);
@@ -729,9 +701,6 @@ ${RED}Usage: oc-auth-switcher switch <account-name|auto>${RESET}`);
729
701
  console.log(`${CYAN}Switching to account: ${name}...${RESET}`);
730
702
  const state = loadState();
731
703
  state.currentAccount = name;
732
- state.selectionMode = "manual";
733
- state.manualAccount = name;
734
- state.lastRotationCheck = Date.now();
735
704
  saveState(state);
736
705
  console.log(`${GREEN}Switched to "${name}". Will take effect on the next API request.${RESET}`);
737
706
  }
@@ -743,10 +712,8 @@ function cmdStatus() {
743
712
  ${BOLD}${CYAN}=== Auth Switcher Status ===${RESET}
744
713
  `);
745
714
  console.log(` Active account: ${BOLD}${state.currentAccount || "(none)"}${RESET}`);
746
- console.log(` Selection mode: ${state.selectionMode}${state.manualAccount ? ` (${state.manualAccount})` : ""}`);
747
715
  console.log(` Total accounts: ${data.accounts.length}`);
748
716
  console.log(` Request count: ${state.requestCount}`);
749
- console.log(` Last rotation: ${state.lastRotationCheck ? new Date(state.lastRotationCheck).toLocaleString() : "never"}`);
750
717
  console.log();
751
718
  const failures = Object.entries(state.authFailures).filter(([_, until]) => until > Date.now());
752
719
  if (failures.length > 0) {
@@ -778,10 +745,6 @@ function cmdRemove(args) {
778
745
  const state = loadState();
779
746
  if (state.currentAccount === name) {
780
747
  state.currentAccount = null;
781
- if (state.manualAccount === name) {
782
- state.selectionMode = "auto";
783
- state.manualAccount = null;
784
- }
785
748
  saveState(state);
786
749
  console.log(`${YELLOW}This was the active account. Rotation will pick a new one automatically.${RESET}`);
787
750
  }
@@ -797,24 +760,22 @@ ${BOLD}COMMANDS:${RESET}
797
760
  ${CYAN}add${RESET} [name] Add a new Anthropic account via OAuth
798
761
  ${CYAN}reauth${RESET} <name> Re-authenticate an existing account
799
762
  ${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
763
+ ${CYAN}config${RESET} [options] View or modify threshold configuration
764
+ ${CYAN}switch${RESET} <name> Set the active account
802
765
  ${CYAN}status${RESET} Show current active account and rotation state
803
766
  ${CYAN}remove${RESET} <name> Remove an account from the pool
804
767
 
805
768
  ${BOLD}CONFIG OPTIONS:${RESET}
806
769
  --threshold <0-1> Set uniform threshold (e.g., 0.90)
807
770
  --thresholds <a,b,c[,d]> Set per-metric thresholds (5h,7d,7d-sonnet,7d-fable)
808
- --interval <minutes> Set primary recovery check interval
809
771
  --reset Reset to defaults
810
772
 
811
773
  ${BOLD}EXAMPLES:${RESET}
812
- oc-auth-switcher add primary
813
- oc-auth-switcher add fallback-1
774
+ oc-auth-switcher add work
775
+ oc-auth-switcher add personal
814
776
  oc-auth-switcher usage --watch
815
777
  oc-auth-switcher config --threshold 0.90
816
- oc-auth-switcher switch fallback-1
817
- oc-auth-switcher switch auto
778
+ oc-auth-switcher switch personal
818
779
  `);
819
780
  }
820
781
  async function main() {
package/dist/index.js CHANGED
@@ -414,8 +414,14 @@ 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
+ var METRIC_MODEL_FAMILY = {
420
+ session5h: null,
421
+ weekly7d: null,
422
+ weekly7dSonnet: "sonnet",
423
+ weekly7dFable: "fable"
424
+ };
419
425
 
420
426
  // src/accounts.ts
421
427
  function normalizeAccount(raw) {
@@ -527,13 +533,9 @@ var EMPTY_USAGE = {
527
533
  function defaultState() {
528
534
  return {
529
535
  currentAccount: null,
530
- selectionMode: "auto",
531
- manualAccount: null,
532
- lastRotationCheck: 0,
533
536
  requestCount: 0,
534
537
  config: {
535
- threshold: DEFAULT_THRESHOLD,
536
- checkInterval: DEFAULT_CHECK_INTERVAL
538
+ threshold: DEFAULT_THRESHOLD
537
539
  },
538
540
  usage: {},
539
541
  authFailures: {}
@@ -561,13 +563,9 @@ function normalizeState(raw) {
561
563
  ]));
562
564
  return {
563
565
  currentAccount: raw.currentAccount ?? defaults.currentAccount,
564
- selectionMode: raw.selectionMode === "manual" && raw.manualAccount ? "manual" : "auto",
565
- manualAccount: raw.manualAccount ?? defaults.manualAccount,
566
- lastRotationCheck: raw.lastRotationCheck ?? defaults.lastRotationCheck,
567
566
  requestCount: raw.requestCount ?? defaults.requestCount,
568
567
  config: {
569
- threshold: migratedThreshold,
570
- checkInterval: raw.config?.checkInterval ?? defaults.config.checkInterval
568
+ threshold: migratedThreshold
571
569
  },
572
570
  usage,
573
571
  authFailures: raw.authFailures ?? defaults.authFailures
@@ -708,7 +706,7 @@ function updateUsageFromHeaders(state, accountName, headers) {
708
706
  if (!Number.isFinite(reset) || reset <= 0) {
709
707
  const retryAfterHeader = headers.get("retry-after");
710
708
  const retryAfter = retryAfterHeader === null ? NaN : Number(retryAfterHeader);
711
- 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);
712
710
  }
713
711
  usage.rejected = {
714
712
  utilization: 1,
@@ -735,53 +733,49 @@ function isTemporarilyUnavailable(state, accountName) {
735
733
  }
736
734
  return true;
737
735
  }
738
- function isOverThreshold(usage, state) {
736
+ function isOverThreshold(usage, state, modelFamily) {
739
737
  if (!usage)
740
738
  return false;
741
739
  const thresholds = getThresholds(state.config);
742
- return usage.rejected.status?.toLowerCase() === "rejected" || thresholds.session5h > 0 && usage.session5h.utilization >= thresholds.session5h || thresholds.weekly7d > 0 && usage.weekly7d.utilization >= thresholds.weekly7d || thresholds.weekly7dSonnet > 0 && usage.weekly7dSonnet.utilization >= thresholds.weekly7dSonnet || thresholds.weekly7dFable > 0 && usage.weekly7dFable.utilization >= thresholds.weekly7dFable;
740
+ if (usage.rejected.status?.toLowerCase() === "rejected" && isRejectionRelevant(usage.rejected.prefix, modelFamily))
741
+ return true;
742
+ return metricEntries(usage, thresholds, modelFamily).some((metric) => metric.threshold > 0 && metric.util >= metric.threshold);
743
743
  }
744
- function getUtilizationScore(usage, state) {
744
+ function getUtilizationScore(usage, state, modelFamily) {
745
745
  if (!usage)
746
746
  return 0;
747
747
  const thresholds = getThresholds(state.config);
748
- if (usage.rejected.status?.toLowerCase() === "rejected")
748
+ if (usage.rejected.status?.toLowerCase() === "rejected" && isRejectionRelevant(usage.rejected.prefix, modelFamily))
749
749
  return Infinity;
750
- const scores = [
751
- { util: usage.session5h.utilization, threshold: thresholds.session5h },
752
- { util: usage.weekly7d.utilization, threshold: thresholds.weekly7d },
753
- { util: usage.weekly7dSonnet.utilization, threshold: thresholds.weekly7dSonnet },
754
- { util: usage.weekly7dFable.utilization, threshold: thresholds.weekly7dFable }
755
- ].filter((metric) => metric.threshold > 0).map((metric) => metric.util / metric.threshold);
750
+ const scores = metricEntries(usage, thresholds, modelFamily).filter((metric) => metric.threshold > 0).map((metric) => metric.util / metric.threshold);
756
751
  return scores.length > 0 ? Math.max(...scores) : 0;
757
752
  }
758
- function getExceededMetric(usage, state) {
759
- if (!usage)
760
- return null;
761
- const thresholds = getThresholds(state.config);
762
- if (usage.rejected.status?.toLowerCase() === "rejected") {
763
- return usage.rejected.prefix ? `rejected rate limit (${usage.rejected.prefix})` : "rejected rate limit";
764
- }
765
- const metrics = [
766
- { name: "session5h", util: usage.session5h.utilization, thresh: thresholds.session5h },
767
- { name: "weekly7d", util: usage.weekly7d.utilization, thresh: thresholds.weekly7d },
768
- { name: "weekly7dSonnet", util: usage.weekly7dSonnet.utilization, thresh: thresholds.weekly7dSonnet },
769
- { name: "weekly7dFable", util: usage.weekly7dFable.utilization, thresh: thresholds.weekly7dFable }
770
- ];
771
- const exceeded = metrics.filter((m) => m.thresh > 0 && m.util >= m.thresh).sort((a, b) => b.util / b.thresh - a.util / a.thresh);
772
- return exceeded.length > 0 ? exceeded[0].name : null;
753
+ function getModelFamily(model) {
754
+ if (!model)
755
+ return;
756
+ const normalized = model.toLowerCase();
757
+ return ["fable", "sonnet", "opus"].find((family) => normalized.includes(family));
773
758
  }
774
- function getEarliestReset(usage) {
775
- if (!usage)
776
- return 0;
777
- const resets = [
778
- usage.session5h.reset,
779
- usage.weekly7d.reset,
780
- usage.weekly7dSonnet.reset,
781
- usage.weekly7dFable.reset,
782
- usage.rejected.reset
783
- ].filter((r) => r > 0);
784
- return resets.length > 0 ? Math.min(...resets) * 1000 : 0;
759
+ function isMetricRelevant(metric, modelFamily) {
760
+ const metricFamily = METRIC_MODEL_FAMILY[metric];
761
+ return modelFamily === undefined || metricFamily === null || metricFamily === modelFamily;
762
+ }
763
+ function rejectionFamily(prefix) {
764
+ if (!prefix)
765
+ return;
766
+ const normalized = prefix.toLowerCase();
767
+ return ["fable", "sonnet", "opus"].find((family) => normalized === `7d_${family}` || normalized === `anthropic-ratelimit-unified-7d_${family}`);
768
+ }
769
+ function isRejectionRelevant(prefix, modelFamily) {
770
+ const rejectedFamily = rejectionFamily(prefix);
771
+ return modelFamily === undefined || rejectedFamily === undefined || rejectedFamily === modelFamily;
772
+ }
773
+ function metricEntries(usage, thresholds, modelFamily) {
774
+ return Object.keys(METRIC_MODEL_FAMILY).filter((key) => isMetricRelevant(key, modelFamily)).map((key) => ({
775
+ name: key,
776
+ util: usage[key].utilization,
777
+ threshold: thresholds[key]
778
+ }));
785
779
  }
786
780
  function purgeExpiredCooldowns(state) {
787
781
  const now = Date.now();
@@ -791,24 +785,32 @@ function purgeExpiredCooldowns(state) {
791
785
  }
792
786
  }
793
787
  }
794
- function findBestAvailable(candidates, state, exclude) {
788
+ function findBestAvailable(candidates, state, exclude, modelFamily) {
789
+ let best = null;
790
+ let bestScore = Infinity;
795
791
  for (const acct of candidates) {
796
792
  if (exclude.has(acct.name))
797
793
  continue;
798
794
  if (isTemporarilyUnavailable(state, acct.name))
799
795
  continue;
800
- if (!isOverThreshold(state.usage[acct.name], state)) {
801
- return acct;
796
+ if (!isOverThreshold(state.usage[acct.name], state, modelFamily)) {
797
+ const score = getUtilizationScore(state.usage[acct.name], state, modelFamily);
798
+ if (!best || score < bestScore) {
799
+ bestScore = score;
800
+ best = acct;
801
+ }
802
802
  }
803
803
  }
804
- let best = null;
805
- let bestScore = Infinity;
804
+ if (best)
805
+ return best;
806
+ best = null;
807
+ bestScore = Infinity;
806
808
  for (const acct of candidates) {
807
809
  if (exclude.has(acct.name))
808
810
  continue;
809
811
  if (isTemporarilyUnavailable(state, acct.name))
810
812
  continue;
811
- const score = getUtilizationScore(state.usage[acct.name], state);
813
+ const score = getUtilizationScore(state.usage[acct.name], state, modelFamily);
812
814
  if (!best || score < bestScore) {
813
815
  bestScore = score;
814
816
  best = acct;
@@ -816,79 +818,29 @@ function findBestAvailable(candidates, state, exclude) {
816
818
  }
817
819
  return best;
818
820
  }
819
- function selectAccount(accounts, state) {
821
+ function selectAccount(accounts, state, model) {
820
822
  if (accounts.length === 0) {
821
823
  throw new Error("No accounts available");
822
824
  }
823
825
  purgeExpiredCooldowns(state);
824
- if (state.selectionMode === "manual" && state.manualAccount) {
825
- const manual = accounts.find((account) => account.name === state.manualAccount);
826
- if (manual) {
827
- const unavailable = isTemporarilyUnavailable(state, manual.name);
828
- const exhausted = isOverThreshold(state.usage[manual.name], state);
829
- if (!unavailable && !exhausted) {
830
- return {
831
- account: manual,
832
- switched: state.currentAccount !== manual.name,
833
- reason: state.currentAccount !== manual.name ? `Manual selection — switching to ${manual.name}` : undefined
834
- };
835
- }
836
- state.currentAccount = manual.name;
837
- }
838
- state.selectionMode = "auto";
839
- state.manualAccount = null;
840
- }
841
- if (accounts.length === 1) {
842
- return { account: accounts[0], switched: false };
843
- }
844
- const primary = accounts[0];
845
- const fallbacks = accounts.slice(1);
846
- if (!state.currentAccount) {
847
- return {
848
- account: primary,
849
- switched: true,
850
- reason: "Initial selection — using primary account"
851
- };
852
- }
826
+ const modelFamily = getModelFamily(model);
853
827
  const currentIdx = accounts.findIndex((a) => a.name === state.currentAccount);
854
828
  if (currentIdx < 0) {
829
+ const best = findBestAvailable(accounts, state, new Set, modelFamily);
830
+ const selected = best ?? accounts[0];
855
831
  return {
856
- account: primary,
832
+ account: selected,
857
833
  switched: true,
858
- reason: "Current account no longer in pool — switching to primary"
834
+ reason: `Selecting account ${selected.name}`
859
835
  };
860
836
  }
861
837
  const current = accounts[currentIdx];
862
838
  const currentUsage = state.usage[current.name];
863
- const primaryUsage = state.usage[primary.name];
864
- const isPrimary = current.name === primary.name;
865
- if (isPrimary) {
866
- if (isOverThreshold(primaryUsage, state)) {
867
- const exceededMetric = getExceededMetric(primaryUsage, state);
868
- const best = findBestAvailable(fallbacks, state, new Set);
869
- if (best) {
870
- return {
871
- account: best,
872
- switched: true,
873
- reason: `Primary exceeded ${exceededMetric} threshold — switching to ${best.name}`
874
- };
875
- }
876
- return { account: primary, switched: false };
877
- }
878
- return { account: primary, switched: false };
879
- }
880
- const currentOverThreshold = isOverThreshold(currentUsage, state);
839
+ const currentOverThreshold = isOverThreshold(currentUsage, state, modelFamily);
881
840
  const currentInCooldown = isTemporarilyUnavailable(state, current.name);
882
841
  if (currentOverThreshold || currentInCooldown) {
883
842
  const reason = currentInCooldown ? `${current.name} in auth-failure cooldown` : `${current.name} exceeded threshold`;
884
- if (!isOverThreshold(primaryUsage, state) && !isTemporarilyUnavailable(state, primary.name)) {
885
- return {
886
- account: primary,
887
- switched: true,
888
- reason: `${reason} — switching back to primary`
889
- };
890
- }
891
- const best = findBestAvailable(accounts, state, new Set([current.name]));
843
+ const best = findBestAvailable(accounts, state, new Set([current.name]), modelFamily);
892
844
  if (best && best.name !== current.name) {
893
845
  return {
894
846
  account: best,
@@ -898,29 +850,10 @@ function selectAccount(accounts, state) {
898
850
  }
899
851
  return { account: current, switched: false };
900
852
  }
901
- const now = Date.now();
902
- const checkInterval = state.config.checkInterval;
903
- const earliestReset = getEarliestReset(primaryUsage);
904
- const timeSinceLastCheck = now - state.lastRotationCheck;
905
- const shouldCheckPrimary = earliestReset > 0 && earliestReset <= now || timeSinceLastCheck >= checkInterval;
906
- if (shouldCheckPrimary) {
907
- state.lastRotationCheck = now;
908
- if (!isOverThreshold(primaryUsage, state) && !isTemporarilyUnavailable(state, primary.name)) {
909
- return {
910
- account: primary,
911
- switched: true,
912
- reason: "Primary has recovered — switching back"
913
- };
914
- }
915
- }
916
853
  return { account: current, switched: false };
917
854
  }
918
855
  function markAuthFailure(state, accountName) {
919
856
  state.authFailures[accountName] = Date.now() + AUTH_FAILURE_COOLDOWN;
920
- if (state.selectionMode === "manual" && state.manualAccount === accountName) {
921
- state.selectionMode = "auto";
922
- state.manualAccount = null;
923
- }
924
857
  }
925
858
  function clearAuthFailure(state, accountName) {
926
859
  delete state.authFailures[accountName];
@@ -929,23 +862,27 @@ function clearAuthFailure(state, accountName) {
929
862
  // src/index.ts
930
863
  function selectionSnapshot(state) {
931
864
  return {
932
- selectionMode: state.selectionMode,
933
- manualAccount: state.manualAccount,
934
- currentAccount: state.currentAccount,
935
- lastRotationCheck: state.lastRotationCheck
865
+ currentAccount: state.currentAccount
936
866
  };
937
867
  }
938
868
  function saveRequestState(state, initiallyLoaded) {
939
869
  const onDisk = loadState();
940
- 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;
941
871
  if (selectionChangedSinceLoad) {
942
- state.selectionMode = onDisk.selectionMode;
943
- state.manualAccount = onDisk.manualAccount;
944
872
  state.currentAccount = onDisk.currentAccount;
945
- state.lastRotationCheck = onDisk.lastRotationCheck;
946
873
  }
947
874
  saveState(state);
948
875
  }
876
+ function getRequestModel(body) {
877
+ if (typeof body !== "string")
878
+ return;
879
+ try {
880
+ const parsed = JSON.parse(body);
881
+ return typeof parsed.model === "string" ? parsed.model : undefined;
882
+ } catch {
883
+ return;
884
+ }
885
+ }
949
886
  var AuthSwitcherPlugin = async ({ client }) => {
950
887
  return {
951
888
  auth: {
@@ -1014,7 +951,8 @@ var AuthSwitcherPlugin = async ({ client }) => {
1014
951
  }
1015
952
  ensureAccountsInState(state, accounts.map((a) => a.name));
1016
953
  resolveStaleMetrics(state);
1017
- const selection = selectAccount(accounts, state);
954
+ const model = getRequestModel(init?.body);
955
+ const selection = selectAccount(accounts, state, model);
1018
956
  let account = selection.account;
1019
957
  if (selection.switched) {
1020
958
  await client.app.log({
@@ -1038,7 +976,8 @@ var AuthSwitcherPlugin = async ({ client }) => {
1038
976
  updateAccountTokens(account.name, result.access, result.refresh, result.expires);
1039
977
  } else {
1040
978
  markAuthFailure(state, account.name);
1041
- 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;
1042
981
  if (!next) {
1043
982
  saveRequestState(state, initiallyLoadedSelection);
1044
983
  throw new Error(`[oc-auth-switcher] All accounts failed token refresh`);
@@ -1070,7 +1009,8 @@ var AuthSwitcherPlugin = async ({ client }) => {
1070
1009
  const isScopeError = errorBody.includes("scope") || errorBody.includes("unauthorized") || errorBody.includes("invalid");
1071
1010
  if (isScopeError) {
1072
1011
  markAuthFailure(state, account.name);
1073
- 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;
1074
1014
  if (next) {
1075
1015
  attemptedAccounts.add("__retried__");
1076
1016
  account = next;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oc-auth-switcher",
3
- "version": "0.4.0",
3
+ "version": "0.6.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",