oc-auth-switcher 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -171,8 +171,7 @@ function defaultState() {
171
171
  authFailures: {}
172
172
  };
173
173
  }
174
- function loadState() {
175
- const raw = safeReadJSON(STATE_FILE, {});
174
+ function normalizeState(raw) {
176
175
  const defaults = defaultState();
177
176
  const threshold = raw.config?.threshold;
178
177
  const migratedThreshold = typeof threshold === "object" && threshold !== null ? {
@@ -206,6 +205,9 @@ function loadState() {
206
205
  authFailures: raw.authFailures ?? defaults.authFailures
207
206
  };
208
207
  }
208
+ function loadState() {
209
+ return normalizeState(safeReadJSON(STATE_FILE, {}));
210
+ }
209
211
  function saveState(state) {
210
212
  safeWriteJSON(STATE_FILE, state);
211
213
  }
@@ -649,8 +651,8 @@ ${BOLD}${CYAN}=== Auth Switcher Configuration ===${RESET}
649
651
  const arg = args[i];
650
652
  if (arg === "--threshold" && args[i + 1]) {
651
653
  const val = parseFloat(args[++i]);
652
- if (isNaN(val) || val < 0 || val > 1) {
653
- console.error(`${RED}Threshold must be between 0 and 1 (e.g., 0.90)${RESET}`);
654
+ if (isNaN(val) || val <= 0 || val > 1) {
655
+ console.error(`${RED}Threshold must be greater than 0 and at most 1 (e.g., 0.90)${RESET}`);
654
656
  process.exit(1);
655
657
  }
656
658
  state.config.threshold = val;
@@ -662,6 +664,10 @@ ${BOLD}${CYAN}=== Auth Switcher Configuration ===${RESET}
662
664
  process.exit(1);
663
665
  }
664
666
  const normalized = parts.map((v) => v > 1 ? v / 100 : v);
667
+ if (normalized.some((v) => v <= 0 || v > 1)) {
668
+ console.error(`${RED}Thresholds must each be greater than 0 and at most 100%${RESET}`);
669
+ process.exit(1);
670
+ }
665
671
  const currentFableThreshold = getThresholds(state.config).weekly7dFable;
666
672
  state.config.threshold = {
667
673
  session5h: normalized[0],
package/dist/index.js CHANGED
@@ -416,6 +416,12 @@ var STATE_FILE = path.join(configDir, "auth-switcher-state.json");
416
416
  var DEFAULT_THRESHOLD = 0.95;
417
417
  var DEFAULT_CHECK_INTERVAL = 60 * 60 * 1000;
418
418
  var AUTH_FAILURE_COOLDOWN = 10 * 60 * 1000;
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) {
@@ -539,8 +545,7 @@ function defaultState() {
539
545
  authFailures: {}
540
546
  };
541
547
  }
542
- function loadState() {
543
- const raw = safeReadJSON(STATE_FILE, {});
548
+ function normalizeState(raw) {
544
549
  const defaults = defaultState();
545
550
  const threshold = raw.config?.threshold;
546
551
  const migratedThreshold = typeof threshold === "object" && threshold !== null ? {
@@ -574,6 +579,9 @@ function loadState() {
574
579
  authFailures: raw.authFailures ?? defaults.authFailures
575
580
  };
576
581
  }
582
+ function loadState() {
583
+ return normalizeState(safeReadJSON(STATE_FILE, {}));
584
+ }
577
585
  function saveState(state) {
578
586
  safeWriteJSON(STATE_FILE, state);
579
587
  }
@@ -711,7 +719,8 @@ function updateUsageFromHeaders(state, accountName, headers) {
711
719
  usage.rejected = {
712
720
  utilization: 1,
713
721
  reset: Math.max(usage.rejected.reset, reset),
714
- status: "rejected"
722
+ status: "rejected",
723
+ prefix
715
724
  };
716
725
  updated = true;
717
726
  }
@@ -732,47 +741,70 @@ function isTemporarilyUnavailable(state, accountName) {
732
741
  }
733
742
  return true;
734
743
  }
735
- function isOverThreshold(usage, state) {
744
+ function isOverThreshold(usage, state, modelFamily) {
736
745
  if (!usage)
737
746
  return false;
738
747
  const thresholds = getThresholds(state.config);
739
- return usage.rejected.status?.toLowerCase() === "rejected" || usage.session5h.utilization > thresholds.session5h || usage.weekly7d.utilization > thresholds.weekly7d || usage.weekly7dSonnet.utilization > thresholds.weekly7dSonnet || usage.weekly7dFable.utilization > thresholds.weekly7dFable;
748
+ if (usage.rejected.status?.toLowerCase() === "rejected" && isRejectionRelevant(usage.rejected.prefix, modelFamily))
749
+ return true;
750
+ return metricEntries(usage, thresholds, modelFamily).some((metric) => metric.threshold > 0 && metric.util >= metric.threshold);
740
751
  }
741
- function getUtilizationScore(usage, state) {
752
+ function getUtilizationScore(usage, state, modelFamily) {
742
753
  if (!usage)
743
754
  return 0;
744
755
  const thresholds = getThresholds(state.config);
745
- if (usage.rejected.status?.toLowerCase() === "rejected")
756
+ if (usage.rejected.status?.toLowerCase() === "rejected" && isRejectionRelevant(usage.rejected.prefix, modelFamily))
746
757
  return Infinity;
747
- return Math.max(usage.session5h.utilization / thresholds.session5h, usage.weekly7d.utilization / thresholds.weekly7d, usage.weekly7dSonnet.utilization / thresholds.weekly7dSonnet, usage.weekly7dFable.utilization / thresholds.weekly7dFable);
758
+ const scores = metricEntries(usage, thresholds, modelFamily).filter((metric) => metric.threshold > 0).map((metric) => metric.util / metric.threshold);
759
+ return scores.length > 0 ? Math.max(...scores) : 0;
748
760
  }
749
- function getExceededMetric(usage, state) {
761
+ function getExceededMetric(usage, state, modelFamily) {
750
762
  if (!usage)
751
763
  return null;
752
764
  const thresholds = getThresholds(state.config);
753
- if (usage.rejected.status?.toLowerCase() === "rejected") {
754
- return "rejected rate limit";
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";
755
767
  }
756
- const metrics = [
757
- { name: "session5h", util: usage.session5h.utilization, thresh: thresholds.session5h },
758
- { name: "weekly7d", util: usage.weekly7d.utilization, thresh: thresholds.weekly7d },
759
- { name: "weekly7dSonnet", util: usage.weekly7dSonnet.utilization, thresh: thresholds.weekly7dSonnet },
760
- { name: "weekly7dFable", util: usage.weekly7dFable.utilization, thresh: thresholds.weekly7dFable }
761
- ];
762
- const exceeded = metrics.filter((m) => m.util > m.thresh).sort((a, b) => b.util / b.thresh - a.util / a.thresh);
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);
763
770
  return exceeded.length > 0 ? exceeded[0].name : null;
764
771
  }
765
- function getEarliestReset(usage) {
772
+ function getEarliestReset(usage, modelFamily) {
766
773
  if (!usage)
767
774
  return 0;
768
- const resets = [
769
- usage.session5h.reset,
770
- usage.weekly7d.reset,
771
- usage.weekly7dSonnet.reset,
772
- usage.weekly7dFable.reset,
773
- usage.rejected.reset
774
- ].filter((r) => r > 0);
775
- return resets.length > 0 ? Math.min(...resets) * 1000 : 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
+ function getModelFamily(model) {
783
+ if (!model)
784
+ return;
785
+ const normalized = model.toLowerCase();
786
+ return ["fable", "sonnet", "opus"].find((family) => normalized.includes(family));
787
+ }
788
+ function isMetricRelevant(metric, modelFamily) {
789
+ const metricFamily = METRIC_MODEL_FAMILY[metric];
790
+ return modelFamily === undefined || metricFamily === null || metricFamily === modelFamily;
791
+ }
792
+ function rejectionFamily(prefix) {
793
+ if (!prefix)
794
+ return;
795
+ const normalized = prefix.toLowerCase();
796
+ return ["fable", "sonnet", "opus"].find((family) => normalized === `7d_${family}` || normalized === `anthropic-ratelimit-unified-7d_${family}`);
797
+ }
798
+ function isRejectionRelevant(prefix, modelFamily) {
799
+ const rejectedFamily = rejectionFamily(prefix);
800
+ return modelFamily === undefined || rejectedFamily === undefined || rejectedFamily === modelFamily;
801
+ }
802
+ function metricEntries(usage, thresholds, modelFamily) {
803
+ return Object.keys(METRIC_MODEL_FAMILY).filter((key) => isMetricRelevant(key, modelFamily)).map((key) => ({
804
+ name: key,
805
+ util: usage[key].utilization,
806
+ threshold: thresholds[key]
807
+ }));
776
808
  }
777
809
  function purgeExpiredCooldowns(state) {
778
810
  const now = Date.now();
@@ -782,13 +814,13 @@ function purgeExpiredCooldowns(state) {
782
814
  }
783
815
  }
784
816
  }
785
- function findBestAvailable(candidates, state, exclude) {
817
+ function findBestAvailable(candidates, state, exclude, modelFamily) {
786
818
  for (const acct of candidates) {
787
819
  if (exclude.has(acct.name))
788
820
  continue;
789
821
  if (isTemporarilyUnavailable(state, acct.name))
790
822
  continue;
791
- if (!isOverThreshold(state.usage[acct.name], state)) {
823
+ if (!isOverThreshold(state.usage[acct.name], state, modelFamily)) {
792
824
  return acct;
793
825
  }
794
826
  }
@@ -799,24 +831,25 @@ function findBestAvailable(candidates, state, exclude) {
799
831
  continue;
800
832
  if (isTemporarilyUnavailable(state, acct.name))
801
833
  continue;
802
- const score = getUtilizationScore(state.usage[acct.name], state);
803
- if (score < bestScore) {
834
+ const score = getUtilizationScore(state.usage[acct.name], state, modelFamily);
835
+ if (!best || score < bestScore) {
804
836
  bestScore = score;
805
837
  best = acct;
806
838
  }
807
839
  }
808
840
  return best;
809
841
  }
810
- function selectAccount(accounts, state) {
842
+ function selectAccount(accounts, state, model) {
811
843
  if (accounts.length === 0) {
812
844
  throw new Error("No accounts available");
813
845
  }
814
846
  purgeExpiredCooldowns(state);
847
+ const modelFamily = getModelFamily(model);
815
848
  if (state.selectionMode === "manual" && state.manualAccount) {
816
849
  const manual = accounts.find((account) => account.name === state.manualAccount);
817
850
  if (manual) {
818
851
  const unavailable = isTemporarilyUnavailable(state, manual.name);
819
- const exhausted = isOverThreshold(state.usage[manual.name], state);
852
+ const exhausted = isOverThreshold(state.usage[manual.name], state, modelFamily);
820
853
  if (!unavailable && !exhausted) {
821
854
  return {
822
855
  account: manual,
@@ -854,9 +887,9 @@ function selectAccount(accounts, state) {
854
887
  const primaryUsage = state.usage[primary.name];
855
888
  const isPrimary = current.name === primary.name;
856
889
  if (isPrimary) {
857
- if (isOverThreshold(primaryUsage, state)) {
858
- const exceededMetric = getExceededMetric(primaryUsage, state);
859
- const best = findBestAvailable(fallbacks, state, new Set);
890
+ if (isOverThreshold(primaryUsage, state, modelFamily)) {
891
+ const exceededMetric = getExceededMetric(primaryUsage, state, modelFamily);
892
+ const best = findBestAvailable(fallbacks, state, new Set, modelFamily);
860
893
  if (best) {
861
894
  return {
862
895
  account: best,
@@ -868,18 +901,18 @@ function selectAccount(accounts, state) {
868
901
  }
869
902
  return { account: primary, switched: false };
870
903
  }
871
- const currentOverThreshold = isOverThreshold(currentUsage, state);
904
+ const currentOverThreshold = isOverThreshold(currentUsage, state, modelFamily);
872
905
  const currentInCooldown = isTemporarilyUnavailable(state, current.name);
873
906
  if (currentOverThreshold || currentInCooldown) {
874
907
  const reason = currentInCooldown ? `${current.name} in auth-failure cooldown` : `${current.name} exceeded threshold`;
875
- if (!isOverThreshold(primaryUsage, state) && !isTemporarilyUnavailable(state, primary.name)) {
908
+ if (!isOverThreshold(primaryUsage, state, modelFamily) && !isTemporarilyUnavailable(state, primary.name)) {
876
909
  return {
877
910
  account: primary,
878
911
  switched: true,
879
912
  reason: `${reason} — switching back to primary`
880
913
  };
881
914
  }
882
- const best = findBestAvailable(accounts, state, new Set([current.name]));
915
+ const best = findBestAvailable(accounts, state, new Set([current.name]), modelFamily);
883
916
  if (best && best.name !== current.name) {
884
917
  return {
885
918
  account: best,
@@ -891,12 +924,12 @@ function selectAccount(accounts, state) {
891
924
  }
892
925
  const now = Date.now();
893
926
  const checkInterval = state.config.checkInterval;
894
- const earliestReset = getEarliestReset(primaryUsage);
927
+ const earliestReset = getEarliestReset(primaryUsage, modelFamily);
895
928
  const timeSinceLastCheck = now - state.lastRotationCheck;
896
929
  const shouldCheckPrimary = earliestReset > 0 && earliestReset <= now || timeSinceLastCheck >= checkInterval;
897
930
  if (shouldCheckPrimary) {
898
931
  state.lastRotationCheck = now;
899
- if (!isOverThreshold(primaryUsage, state) && !isTemporarilyUnavailable(state, primary.name)) {
932
+ if (!isOverThreshold(primaryUsage, state, modelFamily) && !isTemporarilyUnavailable(state, primary.name)) {
900
933
  return {
901
934
  account: primary,
902
935
  switched: true,
@@ -937,6 +970,16 @@ function saveRequestState(state, initiallyLoaded) {
937
970
  }
938
971
  saveState(state);
939
972
  }
973
+ function getRequestModel(body) {
974
+ if (typeof body !== "string")
975
+ return;
976
+ try {
977
+ const parsed = JSON.parse(body);
978
+ return typeof parsed.model === "string" ? parsed.model : undefined;
979
+ } catch {
980
+ return;
981
+ }
982
+ }
940
983
  var AuthSwitcherPlugin = async ({ client }) => {
941
984
  return {
942
985
  auth: {
@@ -1005,7 +1048,8 @@ var AuthSwitcherPlugin = async ({ client }) => {
1005
1048
  }
1006
1049
  ensureAccountsInState(state, accounts.map((a) => a.name));
1007
1050
  resolveStaleMetrics(state);
1008
- const selection = selectAccount(accounts, state);
1051
+ const model = getRequestModel(init?.body);
1052
+ const selection = selectAccount(accounts, state, model);
1009
1053
  let account = selection.account;
1010
1054
  if (selection.switched) {
1011
1055
  await client.app.log({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oc-auth-switcher",
3
- "version": "0.3.0",
3
+ "version": "0.5.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",
@@ -10,6 +10,7 @@
10
10
  "scripts": {
11
11
  "build": "bun build src/index.ts --outdir dist --target node --format esm && bun build src/cli.ts --outdir dist --target bun --format esm",
12
12
  "dev": "bun run build --watch",
13
+ "test": "bun test",
13
14
  "prepublishOnly": "bun run build"
14
15
  },
15
16
  "peerDependencies": {