oc-auth-switcher 0.2.0 → 0.3.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 (3) hide show
  1. package/dist/cli.js +80 -15
  2. package/dist/index.js +145 -15
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -152,11 +152,15 @@ var EMPTY_METRIC = { utilization: 0, reset: 0, status: "" };
152
152
  var EMPTY_USAGE = {
153
153
  session5h: { ...EMPTY_METRIC },
154
154
  weekly7d: { ...EMPTY_METRIC },
155
- weekly7dSonnet: { ...EMPTY_METRIC }
155
+ weekly7dSonnet: { ...EMPTY_METRIC },
156
+ weekly7dFable: { ...EMPTY_METRIC },
157
+ rejected: { ...EMPTY_METRIC }
156
158
  };
157
159
  function defaultState() {
158
160
  return {
159
161
  currentAccount: null,
162
+ selectionMode: "auto",
163
+ manualAccount: null,
160
164
  lastRotationCheck: 0,
161
165
  requestCount: 0,
162
166
  config: {
@@ -170,15 +174,35 @@ function defaultState() {
170
174
  function loadState() {
171
175
  const raw = safeReadJSON(STATE_FILE, {});
172
176
  const defaults = defaultState();
177
+ const threshold = raw.config?.threshold;
178
+ const migratedThreshold = typeof threshold === "object" && threshold !== null ? {
179
+ session5h: threshold.session5h ?? DEFAULT_THRESHOLD,
180
+ weekly7d: threshold.weekly7d ?? DEFAULT_THRESHOLD,
181
+ weekly7dSonnet: threshold.weekly7dSonnet ?? DEFAULT_THRESHOLD,
182
+ weekly7dFable: threshold.weekly7dFable ?? DEFAULT_THRESHOLD
183
+ } : threshold ?? defaults.config.threshold;
184
+ const usage = Object.fromEntries(Object.entries(raw.usage ?? {}).map(([name, accountUsage]) => [
185
+ name,
186
+ {
187
+ session5h: { ...EMPTY_METRIC, ...accountUsage?.session5h },
188
+ weekly7d: { ...EMPTY_METRIC, ...accountUsage?.weekly7d },
189
+ weekly7dSonnet: { ...EMPTY_METRIC, ...accountUsage?.weekly7dSonnet },
190
+ weekly7dFable: { ...EMPTY_METRIC, ...accountUsage?.weekly7dFable },
191
+ rejected: { ...EMPTY_METRIC, ...accountUsage?.rejected },
192
+ timestamp: accountUsage?.timestamp
193
+ }
194
+ ]));
173
195
  return {
174
196
  currentAccount: raw.currentAccount ?? defaults.currentAccount,
197
+ selectionMode: raw.selectionMode === "manual" && raw.manualAccount ? "manual" : "auto",
198
+ manualAccount: raw.manualAccount ?? defaults.manualAccount,
175
199
  lastRotationCheck: raw.lastRotationCheck ?? defaults.lastRotationCheck,
176
200
  requestCount: raw.requestCount ?? defaults.requestCount,
177
201
  config: {
178
- threshold: raw.config?.threshold ?? defaults.config.threshold,
202
+ threshold: migratedThreshold,
179
203
  checkInterval: raw.config?.checkInterval ?? defaults.config.checkInterval
180
204
  },
181
- usage: raw.usage ?? defaults.usage,
205
+ usage,
182
206
  authFailures: raw.authFailures ?? defaults.authFailures
183
207
  };
184
208
  }
@@ -190,16 +214,27 @@ function getThresholds(config) {
190
214
  return {
191
215
  session5h: config.threshold,
192
216
  weekly7d: config.threshold,
193
- weekly7dSonnet: config.threshold
217
+ weekly7dSonnet: config.threshold,
218
+ weekly7dFable: config.threshold
194
219
  };
195
220
  }
196
- return config.threshold;
221
+ return {
222
+ session5h: config.threshold.session5h ?? DEFAULT_THRESHOLD,
223
+ weekly7d: config.threshold.weekly7d ?? DEFAULT_THRESHOLD,
224
+ weekly7dSonnet: config.threshold.weekly7dSonnet ?? DEFAULT_THRESHOLD,
225
+ weekly7dFable: config.threshold.weekly7dFable ?? DEFAULT_THRESHOLD
226
+ };
197
227
  }
198
228
  function resolveStaleMetrics(state) {
199
229
  const now = Date.now() / 1000;
200
230
  for (const accountName of Object.keys(state.usage)) {
201
231
  const usage = state.usage[accountName];
202
- const metrics = ["session5h", "weekly7d", "weekly7dSonnet"];
232
+ const metrics = [
233
+ "session5h",
234
+ "weekly7d",
235
+ "weekly7dSonnet",
236
+ "weekly7dFable"
237
+ ];
203
238
  for (const key of metrics) {
204
239
  const metric = usage[key];
205
240
  if (metric.reset > 0 && metric.reset <= now) {
@@ -208,6 +243,9 @@ function resolveStaleMetrics(state) {
208
243
  metric.status = "";
209
244
  }
210
245
  }
246
+ if (usage.rejected.reset > 0 && usage.rejected.reset <= now) {
247
+ usage.rejected = { ...EMPTY_METRIC };
248
+ }
211
249
  }
212
250
  }
213
251
  function ensureAccountsInState(state, accountNames) {
@@ -216,7 +254,9 @@ function ensureAccountsInState(state, accountNames) {
216
254
  state.usage[name] = {
217
255
  session5h: { ...EMPTY_METRIC },
218
256
  weekly7d: { ...EMPTY_METRIC },
219
- weekly7dSonnet: { ...EMPTY_METRIC }
257
+ weekly7dSonnet: { ...EMPTY_METRIC },
258
+ weekly7dFable: { ...EMPTY_METRIC },
259
+ rejected: { ...EMPTY_METRIC }
220
260
  };
221
261
  }
222
262
  }
@@ -538,7 +578,7 @@ ${BOLD}${CYAN}=== Auth Switcher Usage Dashboard ===${RESET}
538
578
  `);
539
579
  console.log(` Active account: ${BOLD}${state.currentAccount || "(none)"}${RESET}`);
540
580
  console.log(` Total accounts: ${data.accounts.length}`);
541
- console.log(` Thresholds: 5h=${(thresholds.session5h * 100).toFixed(0)}% 7d=${(thresholds.weekly7d * 100).toFixed(0)}% 7d-sonnet=${(thresholds.weekly7dSonnet * 100).toFixed(0)}%`);
581
+ console.log(` Thresholds: 5h=${(thresholds.session5h * 100).toFixed(0)}% 7d=${(thresholds.weekly7d * 100).toFixed(0)}% 7d-sonnet=${(thresholds.weekly7dSonnet * 100).toFixed(0)}% 7d-fable=${(thresholds.weekly7dFable * 100).toFixed(0)}%`);
542
582
  console.log();
543
583
  if (data.accounts.length === 0) {
544
584
  console.log(` ${DIM}No accounts configured. Run 'oc-auth-switcher add' to add one.${RESET}
@@ -555,6 +595,10 @@ ${BOLD}${CYAN}=== Auth Switcher Usage Dashboard ===${RESET}
555
595
  console.log(` 5h session: ${progressBar(usage.session5h.utilization, thresholds.session5h)}`);
556
596
  console.log(` 7d weekly: ${progressBar(usage.weekly7d.utilization, thresholds.weekly7d)}`);
557
597
  console.log(` 7d sonnet: ${progressBar(usage.weekly7dSonnet.utilization, thresholds.weekly7dSonnet)}`);
598
+ console.log(` 7d fable: ${progressBar(usage.weekly7dFable.utilization, thresholds.weekly7dFable)}`);
599
+ if (usage.rejected.status === "rejected") {
600
+ console.log(` ${RED}Rate limit status: REJECTED${RESET}`);
601
+ }
558
602
  if (usage.timestamp) {
559
603
  console.log(` ${DIM}Last updated: ${usage.timestamp}${RESET}`);
560
604
  }
@@ -594,6 +638,7 @@ ${BOLD}${CYAN}=== Auth Switcher Configuration ===${RESET}
594
638
  console.log(` Threshold (5h): ${(thresholds.session5h * 100).toFixed(0)}%`);
595
639
  console.log(` Threshold (7d): ${(thresholds.weekly7d * 100).toFixed(0)}%`);
596
640
  console.log(` Threshold (7d sonnet): ${(thresholds.weekly7dSonnet * 100).toFixed(0)}%`);
641
+ console.log(` Threshold (7d fable): ${(thresholds.weekly7dFable * 100).toFixed(0)}%`);
597
642
  console.log(` Check interval: ${state.config.checkInterval / 60000} min`);
598
643
  console.log();
599
644
  console.log(`${DIM} Config file: ${STATE_FILE}${RESET}`);
@@ -612,17 +657,19 @@ ${BOLD}${CYAN}=== Auth Switcher Configuration ===${RESET}
612
657
  console.log(`${GREEN}Set uniform threshold to ${(val * 100).toFixed(0)}%${RESET}`);
613
658
  } else if (arg === "--thresholds" && args[i + 1]) {
614
659
  const parts = args[++i].split(",").map((s) => parseFloat(s.trim()));
615
- if (parts.length !== 3 || parts.some(isNaN)) {
616
- console.error(`${RED}--thresholds requires 3 comma-separated values (e.g., 90,80,70)${RESET}`);
660
+ if (parts.length !== 3 && parts.length !== 4 || parts.some(isNaN)) {
661
+ console.error(`${RED}--thresholds requires 3 or 4 comma-separated values (e.g., 90,80,70,70)${RESET}`);
617
662
  process.exit(1);
618
663
  }
619
664
  const normalized = parts.map((v) => v > 1 ? v / 100 : v);
665
+ const currentFableThreshold = getThresholds(state.config).weekly7dFable;
620
666
  state.config.threshold = {
621
667
  session5h: normalized[0],
622
668
  weekly7d: normalized[1],
623
- weekly7dSonnet: normalized[2]
669
+ weekly7dSonnet: normalized[2],
670
+ weekly7dFable: normalized[3] ?? currentFableThreshold
624
671
  };
625
- 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)}%${RESET}`);
672
+ 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}`);
626
673
  } else if (arg === "--interval" && args[i + 1]) {
627
674
  const minutes = parseInt(args[++i], 10);
628
675
  if (isNaN(minutes) || minutes < 1) {
@@ -654,10 +701,20 @@ ${BOLD}Available accounts:${RESET}`);
654
701
  const tag = a.name === state2.currentAccount ? ` ${GREEN}[ACTIVE]${RESET}` : "";
655
702
  console.log(` - ${a.name}${tag}`);
656
703
  }
704
+ console.log(` - auto ${DIM}(resume automatic selection)${RESET}`);
657
705
  console.error(`
658
- ${RED}Usage: oc-auth-switcher switch <account-name>${RESET}`);
706
+ ${RED}Usage: oc-auth-switcher switch <account-name|auto>${RESET}`);
659
707
  process.exit(1);
660
708
  }
709
+ if (name === "auto") {
710
+ const state2 = loadState();
711
+ state2.selectionMode = "auto";
712
+ state2.manualAccount = null;
713
+ state2.lastRotationCheck = Date.now();
714
+ saveState(state2);
715
+ console.log(`${GREEN}Automatic account selection resumed.${RESET}`);
716
+ return;
717
+ }
661
718
  const account = data.accounts.find((a) => a.name === name);
662
719
  if (!account) {
663
720
  console.error(`${RED}Account "${name}" not found${RESET}`);
@@ -666,6 +723,8 @@ ${RED}Usage: oc-auth-switcher switch <account-name>${RESET}`);
666
723
  console.log(`${CYAN}Switching to account: ${name}...${RESET}`);
667
724
  const state = loadState();
668
725
  state.currentAccount = name;
726
+ state.selectionMode = "manual";
727
+ state.manualAccount = name;
669
728
  state.lastRotationCheck = Date.now();
670
729
  saveState(state);
671
730
  console.log(`${GREEN}Switched to "${name}". Will take effect on the next API request.${RESET}`);
@@ -678,6 +737,7 @@ function cmdStatus() {
678
737
  ${BOLD}${CYAN}=== Auth Switcher Status ===${RESET}
679
738
  `);
680
739
  console.log(` Active account: ${BOLD}${state.currentAccount || "(none)"}${RESET}`);
740
+ console.log(` Selection mode: ${state.selectionMode}${state.manualAccount ? ` (${state.manualAccount})` : ""}`);
681
741
  console.log(` Total accounts: ${data.accounts.length}`);
682
742
  console.log(` Request count: ${state.requestCount}`);
683
743
  console.log(` Last rotation: ${state.lastRotationCheck ? new Date(state.lastRotationCheck).toLocaleString() : "never"}`);
@@ -712,6 +772,10 @@ function cmdRemove(args) {
712
772
  const state = loadState();
713
773
  if (state.currentAccount === name) {
714
774
  state.currentAccount = null;
775
+ if (state.manualAccount === name) {
776
+ state.selectionMode = "auto";
777
+ state.manualAccount = null;
778
+ }
715
779
  saveState(state);
716
780
  console.log(`${YELLOW}This was the active account. Rotation will pick a new one automatically.${RESET}`);
717
781
  }
@@ -728,13 +792,13 @@ ${BOLD}COMMANDS:${RESET}
728
792
  ${CYAN}reauth${RESET} <name> Re-authenticate an existing account
729
793
  ${CYAN}usage${RESET} [--watch] Show usage dashboard with utilization metrics
730
794
  ${CYAN}config${RESET} [options] View or modify threshold/interval configuration
731
- ${CYAN}switch${RESET} <name> Manually switch to a specific account
795
+ ${CYAN}switch${RESET} <name|auto> Pin a specific account or resume automatic selection
732
796
  ${CYAN}status${RESET} Show current active account and rotation state
733
797
  ${CYAN}remove${RESET} <name> Remove an account from the pool
734
798
 
735
799
  ${BOLD}CONFIG OPTIONS:${RESET}
736
800
  --threshold <0-1> Set uniform threshold (e.g., 0.90)
737
- --thresholds <a,b,c> Set per-metric thresholds (5h,7d,7d-sonnet)
801
+ --thresholds <a,b,c[,d]> Set per-metric thresholds (5h,7d,7d-sonnet,7d-fable)
738
802
  --interval <minutes> Set primary recovery check interval
739
803
  --reset Reset to defaults
740
804
 
@@ -744,6 +808,7 @@ ${BOLD}EXAMPLES:${RESET}
744
808
  oc-auth-switcher usage --watch
745
809
  oc-auth-switcher config --threshold 0.90
746
810
  oc-auth-switcher switch fallback-1
811
+ oc-auth-switcher switch auto
747
812
  `);
748
813
  }
749
814
  async function main() {
package/dist/index.js CHANGED
@@ -520,11 +520,15 @@ var EMPTY_METRIC = { utilization: 0, reset: 0, status: "" };
520
520
  var EMPTY_USAGE = {
521
521
  session5h: { ...EMPTY_METRIC },
522
522
  weekly7d: { ...EMPTY_METRIC },
523
- weekly7dSonnet: { ...EMPTY_METRIC }
523
+ weekly7dSonnet: { ...EMPTY_METRIC },
524
+ weekly7dFable: { ...EMPTY_METRIC },
525
+ rejected: { ...EMPTY_METRIC }
524
526
  };
525
527
  function defaultState() {
526
528
  return {
527
529
  currentAccount: null,
530
+ selectionMode: "auto",
531
+ manualAccount: null,
528
532
  lastRotationCheck: 0,
529
533
  requestCount: 0,
530
534
  config: {
@@ -538,15 +542,35 @@ function defaultState() {
538
542
  function loadState() {
539
543
  const raw = safeReadJSON(STATE_FILE, {});
540
544
  const defaults = defaultState();
545
+ const threshold = raw.config?.threshold;
546
+ const migratedThreshold = typeof threshold === "object" && threshold !== null ? {
547
+ session5h: threshold.session5h ?? DEFAULT_THRESHOLD,
548
+ weekly7d: threshold.weekly7d ?? DEFAULT_THRESHOLD,
549
+ weekly7dSonnet: threshold.weekly7dSonnet ?? DEFAULT_THRESHOLD,
550
+ weekly7dFable: threshold.weekly7dFable ?? DEFAULT_THRESHOLD
551
+ } : threshold ?? defaults.config.threshold;
552
+ const usage = Object.fromEntries(Object.entries(raw.usage ?? {}).map(([name, accountUsage]) => [
553
+ name,
554
+ {
555
+ session5h: { ...EMPTY_METRIC, ...accountUsage?.session5h },
556
+ weekly7d: { ...EMPTY_METRIC, ...accountUsage?.weekly7d },
557
+ weekly7dSonnet: { ...EMPTY_METRIC, ...accountUsage?.weekly7dSonnet },
558
+ weekly7dFable: { ...EMPTY_METRIC, ...accountUsage?.weekly7dFable },
559
+ rejected: { ...EMPTY_METRIC, ...accountUsage?.rejected },
560
+ timestamp: accountUsage?.timestamp
561
+ }
562
+ ]));
541
563
  return {
542
564
  currentAccount: raw.currentAccount ?? defaults.currentAccount,
565
+ selectionMode: raw.selectionMode === "manual" && raw.manualAccount ? "manual" : "auto",
566
+ manualAccount: raw.manualAccount ?? defaults.manualAccount,
543
567
  lastRotationCheck: raw.lastRotationCheck ?? defaults.lastRotationCheck,
544
568
  requestCount: raw.requestCount ?? defaults.requestCount,
545
569
  config: {
546
- threshold: raw.config?.threshold ?? defaults.config.threshold,
570
+ threshold: migratedThreshold,
547
571
  checkInterval: raw.config?.checkInterval ?? defaults.config.checkInterval
548
572
  },
549
- usage: raw.usage ?? defaults.usage,
573
+ usage,
550
574
  authFailures: raw.authFailures ?? defaults.authFailures
551
575
  };
552
576
  }
@@ -558,16 +582,27 @@ function getThresholds(config) {
558
582
  return {
559
583
  session5h: config.threshold,
560
584
  weekly7d: config.threshold,
561
- weekly7dSonnet: config.threshold
585
+ weekly7dSonnet: config.threshold,
586
+ weekly7dFable: config.threshold
562
587
  };
563
588
  }
564
- return config.threshold;
589
+ return {
590
+ session5h: config.threshold.session5h ?? DEFAULT_THRESHOLD,
591
+ weekly7d: config.threshold.weekly7d ?? DEFAULT_THRESHOLD,
592
+ weekly7dSonnet: config.threshold.weekly7dSonnet ?? DEFAULT_THRESHOLD,
593
+ weekly7dFable: config.threshold.weekly7dFable ?? DEFAULT_THRESHOLD
594
+ };
565
595
  }
566
596
  function resolveStaleMetrics(state) {
567
597
  const now = Date.now() / 1000;
568
598
  for (const accountName of Object.keys(state.usage)) {
569
599
  const usage = state.usage[accountName];
570
- const metrics = ["session5h", "weekly7d", "weekly7dSonnet"];
600
+ const metrics = [
601
+ "session5h",
602
+ "weekly7d",
603
+ "weekly7dSonnet",
604
+ "weekly7dFable"
605
+ ];
571
606
  for (const key of metrics) {
572
607
  const metric = usage[key];
573
608
  if (metric.reset > 0 && metric.reset <= now) {
@@ -576,6 +611,9 @@ function resolveStaleMetrics(state) {
576
611
  metric.status = "";
577
612
  }
578
613
  }
614
+ if (usage.rejected.reset > 0 && usage.rejected.reset <= now) {
615
+ usage.rejected = { ...EMPTY_METRIC };
616
+ }
579
617
  }
580
618
  }
581
619
  function ensureAccountsInState(state, accountNames) {
@@ -584,7 +622,9 @@ function ensureAccountsInState(state, accountNames) {
584
622
  state.usage[name] = {
585
623
  session5h: { ...EMPTY_METRIC },
586
624
  weekly7d: { ...EMPTY_METRIC },
587
- weekly7dSonnet: { ...EMPTY_METRIC }
625
+ weekly7dSonnet: { ...EMPTY_METRIC },
626
+ weekly7dFable: { ...EMPTY_METRIC },
627
+ rejected: { ...EMPTY_METRIC }
588
628
  };
589
629
  }
590
630
  }
@@ -594,7 +634,9 @@ function updateUsageFromHeaders(state, accountName, headers) {
594
634
  state.usage[accountName] = {
595
635
  session5h: { ...EMPTY_METRIC },
596
636
  weekly7d: { ...EMPTY_METRIC },
597
- weekly7dSonnet: { ...EMPTY_METRIC }
637
+ weekly7dSonnet: { ...EMPTY_METRIC },
638
+ weekly7dFable: { ...EMPTY_METRIC },
639
+ rejected: { ...EMPTY_METRIC }
598
640
  };
599
641
  }
600
642
  const usage = state.usage[accountName];
@@ -611,6 +653,10 @@ function updateUsageFromHeaders(state, accountName, headers) {
611
653
  {
612
654
  key: "weekly7dSonnet",
613
655
  prefix: "anthropic-ratelimit-unified-7d_sonnet"
656
+ },
657
+ {
658
+ key: "weekly7dFable",
659
+ prefix: "anthropic-ratelimit-unified-7d_fable"
614
660
  }
615
661
  ];
616
662
  for (const { key, prefix } of metricFamilies) {
@@ -629,18 +675,46 @@ function updateUsageFromHeaders(state, accountName, headers) {
629
675
  const val = Number(resetHeader);
630
676
  if (!isNaN(val)) {
631
677
  usage[key].reset = val;
678
+ updated = true;
632
679
  } else {
633
680
  const parsed = Date.parse(resetHeader);
634
681
  if (!isNaN(parsed)) {
635
682
  usage[key].reset = parsed / 1000;
683
+ updated = true;
636
684
  }
637
685
  }
638
686
  }
639
687
  if (statusHeader !== null) {
640
688
  usage[key].status = statusHeader;
689
+ updated = true;
641
690
  }
642
691
  }
643
692
  }
693
+ for (const [headerName, headerValue] of headers.entries()) {
694
+ const normalizedName = headerName.toLowerCase();
695
+ if (!/^anthropic-ratelimit-unified-(?:.*-)?status$/.test(normalizedName) || headerValue.toLowerCase() !== "rejected") {
696
+ continue;
697
+ }
698
+ const prefix = normalizedName.slice(0, -"-status".length);
699
+ const resetHeader = headers.get(`${prefix}-reset`) ?? headers.get("anthropic-ratelimit-unified-reset");
700
+ let reset = resetHeader ? Number(resetHeader) : NaN;
701
+ if (isNaN(reset) && resetHeader) {
702
+ const parsed = Date.parse(resetHeader);
703
+ if (!isNaN(parsed))
704
+ reset = parsed / 1000;
705
+ }
706
+ if (!Number.isFinite(reset) || reset <= 0) {
707
+ const retryAfterHeader = headers.get("retry-after");
708
+ const retryAfter = retryAfterHeader === null ? NaN : Number(retryAfterHeader);
709
+ reset = Date.now() / 1000 + (Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : state.config.checkInterval / 1000);
710
+ }
711
+ usage.rejected = {
712
+ utilization: 1,
713
+ reset: Math.max(usage.rejected.reset, reset),
714
+ status: "rejected"
715
+ };
716
+ updated = true;
717
+ }
644
718
  if (updated) {
645
719
  usage.timestamp = new Date().toISOString();
646
720
  }
@@ -662,22 +736,28 @@ function isOverThreshold(usage, state) {
662
736
  if (!usage)
663
737
  return false;
664
738
  const thresholds = getThresholds(state.config);
665
- return usage.session5h.utilization > thresholds.session5h || usage.weekly7d.utilization > thresholds.weekly7d || usage.weekly7dSonnet.utilization > thresholds.weekly7dSonnet;
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;
666
740
  }
667
741
  function getUtilizationScore(usage, state) {
668
742
  if (!usage)
669
743
  return 0;
670
744
  const thresholds = getThresholds(state.config);
671
- return Math.max(usage.session5h.utilization / thresholds.session5h, usage.weekly7d.utilization / thresholds.weekly7d, usage.weekly7dSonnet.utilization / thresholds.weekly7dSonnet);
745
+ if (usage.rejected.status?.toLowerCase() === "rejected")
746
+ 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);
672
748
  }
673
749
  function getExceededMetric(usage, state) {
674
750
  if (!usage)
675
751
  return null;
676
752
  const thresholds = getThresholds(state.config);
753
+ if (usage.rejected.status?.toLowerCase() === "rejected") {
754
+ return "rejected rate limit";
755
+ }
677
756
  const metrics = [
678
757
  { name: "session5h", util: usage.session5h.utilization, thresh: thresholds.session5h },
679
758
  { name: "weekly7d", util: usage.weekly7d.utilization, thresh: thresholds.weekly7d },
680
- { name: "weekly7dSonnet", util: usage.weekly7dSonnet.utilization, thresh: thresholds.weekly7dSonnet }
759
+ { name: "weekly7dSonnet", util: usage.weekly7dSonnet.utilization, thresh: thresholds.weekly7dSonnet },
760
+ { name: "weekly7dFable", util: usage.weekly7dFable.utilization, thresh: thresholds.weekly7dFable }
681
761
  ];
682
762
  const exceeded = metrics.filter((m) => m.util > m.thresh).sort((a, b) => b.util / b.thresh - a.util / a.thresh);
683
763
  return exceeded.length > 0 ? exceeded[0].name : null;
@@ -688,7 +768,9 @@ function getEarliestReset(usage) {
688
768
  const resets = [
689
769
  usage.session5h.reset,
690
770
  usage.weekly7d.reset,
691
- usage.weekly7dSonnet.reset
771
+ usage.weekly7dSonnet.reset,
772
+ usage.weekly7dFable.reset,
773
+ usage.rejected.reset
692
774
  ].filter((r) => r > 0);
693
775
  return resets.length > 0 ? Math.min(...resets) * 1000 : 0;
694
776
  }
@@ -730,6 +812,23 @@ function selectAccount(accounts, state) {
730
812
  throw new Error("No accounts available");
731
813
  }
732
814
  purgeExpiredCooldowns(state);
815
+ if (state.selectionMode === "manual" && state.manualAccount) {
816
+ const manual = accounts.find((account) => account.name === state.manualAccount);
817
+ if (manual) {
818
+ const unavailable = isTemporarilyUnavailable(state, manual.name);
819
+ const exhausted = isOverThreshold(state.usage[manual.name], state);
820
+ if (!unavailable && !exhausted) {
821
+ return {
822
+ account: manual,
823
+ switched: state.currentAccount !== manual.name,
824
+ reason: state.currentAccount !== manual.name ? `Manual selection — switching to ${manual.name}` : undefined
825
+ };
826
+ }
827
+ state.currentAccount = manual.name;
828
+ }
829
+ state.selectionMode = "auto";
830
+ state.manualAccount = null;
831
+ }
733
832
  if (accounts.length === 1) {
734
833
  return { account: accounts[0], switched: false };
735
834
  }
@@ -809,12 +908,35 @@ function selectAccount(accounts, state) {
809
908
  }
810
909
  function markAuthFailure(state, accountName) {
811
910
  state.authFailures[accountName] = Date.now() + AUTH_FAILURE_COOLDOWN;
911
+ if (state.selectionMode === "manual" && state.manualAccount === accountName) {
912
+ state.selectionMode = "auto";
913
+ state.manualAccount = null;
914
+ }
812
915
  }
813
916
  function clearAuthFailure(state, accountName) {
814
917
  delete state.authFailures[accountName];
815
918
  }
816
919
 
817
920
  // src/index.ts
921
+ function selectionSnapshot(state) {
922
+ return {
923
+ selectionMode: state.selectionMode,
924
+ manualAccount: state.manualAccount,
925
+ currentAccount: state.currentAccount,
926
+ lastRotationCheck: state.lastRotationCheck
927
+ };
928
+ }
929
+ function saveRequestState(state, initiallyLoaded) {
930
+ const onDisk = loadState();
931
+ const selectionChangedSinceLoad = onDisk.selectionMode !== initiallyLoaded.selectionMode || onDisk.manualAccount !== initiallyLoaded.manualAccount || onDisk.currentAccount !== initiallyLoaded.currentAccount || onDisk.lastRotationCheck !== initiallyLoaded.lastRotationCheck;
932
+ if (selectionChangedSinceLoad) {
933
+ state.selectionMode = onDisk.selectionMode;
934
+ state.manualAccount = onDisk.manualAccount;
935
+ state.currentAccount = onDisk.currentAccount;
936
+ state.lastRotationCheck = onDisk.lastRotationCheck;
937
+ }
938
+ saveState(state);
939
+ }
818
940
  var AuthSwitcherPlugin = async ({ client }) => {
819
941
  return {
820
942
  auth: {
@@ -836,6 +958,7 @@ var AuthSwitcherPlugin = async ({ client }) => {
836
958
  async fetch(input, init) {
837
959
  const { accounts } = loadAccounts();
838
960
  const state = loadState();
961
+ const initiallyLoadedSelection = selectionSnapshot(state);
839
962
  if (accounts.length === 0) {
840
963
  const auth2 = await getAuth();
841
964
  if (auth2.type !== "oauth")
@@ -885,7 +1008,13 @@ var AuthSwitcherPlugin = async ({ client }) => {
885
1008
  const selection = selectAccount(accounts, state);
886
1009
  let account = selection.account;
887
1010
  if (selection.switched) {
888
- console.log(`[oc-auth-switcher] ${selection.reason}`);
1011
+ await client.app.log({
1012
+ body: {
1013
+ service: "oc-auth-switcher",
1014
+ level: "info",
1015
+ message: selection.reason ?? "switched account"
1016
+ }
1017
+ }).catch(() => {});
889
1018
  }
890
1019
  state.currentAccount = account.name;
891
1020
  const attemptedAccounts = new Set;
@@ -902,6 +1031,7 @@ var AuthSwitcherPlugin = async ({ client }) => {
902
1031
  markAuthFailure(state, account.name);
903
1032
  const next = accounts.find((a) => !attemptedAccounts.has(a.name) && !state.authFailures[a.name]);
904
1033
  if (!next) {
1034
+ saveRequestState(state, initiallyLoadedSelection);
905
1035
  throw new Error(`[oc-auth-switcher] All accounts failed token refresh`);
906
1036
  }
907
1037
  account = next;
@@ -957,7 +1087,7 @@ var AuthSwitcherPlugin = async ({ client }) => {
957
1087
  });
958
1088
  updateUsageFromHeaders(state, next.name, retryResponse.headers);
959
1089
  clearAuthFailure(state, next.name);
960
- saveState(state);
1090
+ saveRequestState(state, initiallyLoadedSelection);
961
1091
  return createStrippedStream(retryResponse);
962
1092
  }
963
1093
  }
@@ -965,7 +1095,7 @@ var AuthSwitcherPlugin = async ({ client }) => {
965
1095
  updateUsageFromHeaders(state, account.name, response.headers);
966
1096
  clearAuthFailure(state, account.name);
967
1097
  state.requestCount = (state.requestCount || 0) + 1;
968
- saveState(state);
1098
+ saveRequestState(state, initiallyLoadedSelection);
969
1099
  return createStrippedStream(response);
970
1100
  }
971
1101
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oc-auth-switcher",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",