oc-auth-switcher 0.2.1 → 0.4.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 +90 -19
  2. package/dist/index.js +151 -18
  3. package/package.json +2 -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: {
@@ -167,21 +171,43 @@ function defaultState() {
167
171
  authFailures: {}
168
172
  };
169
173
  }
170
- function loadState() {
171
- const raw = safeReadJSON(STATE_FILE, {});
174
+ function normalizeState(raw) {
172
175
  const defaults = defaultState();
176
+ const threshold = raw.config?.threshold;
177
+ const migratedThreshold = typeof threshold === "object" && threshold !== null ? {
178
+ session5h: threshold.session5h ?? DEFAULT_THRESHOLD,
179
+ weekly7d: threshold.weekly7d ?? DEFAULT_THRESHOLD,
180
+ weekly7dSonnet: threshold.weekly7dSonnet ?? DEFAULT_THRESHOLD,
181
+ weekly7dFable: threshold.weekly7dFable ?? DEFAULT_THRESHOLD
182
+ } : threshold ?? defaults.config.threshold;
183
+ const usage = Object.fromEntries(Object.entries(raw.usage ?? {}).map(([name, accountUsage]) => [
184
+ name,
185
+ {
186
+ session5h: { ...EMPTY_METRIC, ...accountUsage?.session5h },
187
+ weekly7d: { ...EMPTY_METRIC, ...accountUsage?.weekly7d },
188
+ weekly7dSonnet: { ...EMPTY_METRIC, ...accountUsage?.weekly7dSonnet },
189
+ weekly7dFable: { ...EMPTY_METRIC, ...accountUsage?.weekly7dFable },
190
+ rejected: { ...EMPTY_METRIC, ...accountUsage?.rejected },
191
+ timestamp: accountUsage?.timestamp
192
+ }
193
+ ]));
173
194
  return {
174
195
  currentAccount: raw.currentAccount ?? defaults.currentAccount,
196
+ selectionMode: raw.selectionMode === "manual" && raw.manualAccount ? "manual" : "auto",
197
+ manualAccount: raw.manualAccount ?? defaults.manualAccount,
175
198
  lastRotationCheck: raw.lastRotationCheck ?? defaults.lastRotationCheck,
176
199
  requestCount: raw.requestCount ?? defaults.requestCount,
177
200
  config: {
178
- threshold: raw.config?.threshold ?? defaults.config.threshold,
201
+ threshold: migratedThreshold,
179
202
  checkInterval: raw.config?.checkInterval ?? defaults.config.checkInterval
180
203
  },
181
- usage: raw.usage ?? defaults.usage,
204
+ usage,
182
205
  authFailures: raw.authFailures ?? defaults.authFailures
183
206
  };
184
207
  }
208
+ function loadState() {
209
+ return normalizeState(safeReadJSON(STATE_FILE, {}));
210
+ }
185
211
  function saveState(state) {
186
212
  safeWriteJSON(STATE_FILE, state);
187
213
  }
@@ -190,16 +216,27 @@ function getThresholds(config) {
190
216
  return {
191
217
  session5h: config.threshold,
192
218
  weekly7d: config.threshold,
193
- weekly7dSonnet: config.threshold
219
+ weekly7dSonnet: config.threshold,
220
+ weekly7dFable: config.threshold
194
221
  };
195
222
  }
196
- return config.threshold;
223
+ return {
224
+ session5h: config.threshold.session5h ?? DEFAULT_THRESHOLD,
225
+ weekly7d: config.threshold.weekly7d ?? DEFAULT_THRESHOLD,
226
+ weekly7dSonnet: config.threshold.weekly7dSonnet ?? DEFAULT_THRESHOLD,
227
+ weekly7dFable: config.threshold.weekly7dFable ?? DEFAULT_THRESHOLD
228
+ };
197
229
  }
198
230
  function resolveStaleMetrics(state) {
199
231
  const now = Date.now() / 1000;
200
232
  for (const accountName of Object.keys(state.usage)) {
201
233
  const usage = state.usage[accountName];
202
- const metrics = ["session5h", "weekly7d", "weekly7dSonnet"];
234
+ const metrics = [
235
+ "session5h",
236
+ "weekly7d",
237
+ "weekly7dSonnet",
238
+ "weekly7dFable"
239
+ ];
203
240
  for (const key of metrics) {
204
241
  const metric = usage[key];
205
242
  if (metric.reset > 0 && metric.reset <= now) {
@@ -208,6 +245,9 @@ function resolveStaleMetrics(state) {
208
245
  metric.status = "";
209
246
  }
210
247
  }
248
+ if (usage.rejected.reset > 0 && usage.rejected.reset <= now) {
249
+ usage.rejected = { ...EMPTY_METRIC };
250
+ }
211
251
  }
212
252
  }
213
253
  function ensureAccountsInState(state, accountNames) {
@@ -216,7 +256,9 @@ function ensureAccountsInState(state, accountNames) {
216
256
  state.usage[name] = {
217
257
  session5h: { ...EMPTY_METRIC },
218
258
  weekly7d: { ...EMPTY_METRIC },
219
- weekly7dSonnet: { ...EMPTY_METRIC }
259
+ weekly7dSonnet: { ...EMPTY_METRIC },
260
+ weekly7dFable: { ...EMPTY_METRIC },
261
+ rejected: { ...EMPTY_METRIC }
220
262
  };
221
263
  }
222
264
  }
@@ -538,7 +580,7 @@ ${BOLD}${CYAN}=== Auth Switcher Usage Dashboard ===${RESET}
538
580
  `);
539
581
  console.log(` Active account: ${BOLD}${state.currentAccount || "(none)"}${RESET}`);
540
582
  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)}%`);
583
+ 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
584
  console.log();
543
585
  if (data.accounts.length === 0) {
544
586
  console.log(` ${DIM}No accounts configured. Run 'oc-auth-switcher add' to add one.${RESET}
@@ -555,6 +597,10 @@ ${BOLD}${CYAN}=== Auth Switcher Usage Dashboard ===${RESET}
555
597
  console.log(` 5h session: ${progressBar(usage.session5h.utilization, thresholds.session5h)}`);
556
598
  console.log(` 7d weekly: ${progressBar(usage.weekly7d.utilization, thresholds.weekly7d)}`);
557
599
  console.log(` 7d sonnet: ${progressBar(usage.weekly7dSonnet.utilization, thresholds.weekly7dSonnet)}`);
600
+ console.log(` 7d fable: ${progressBar(usage.weekly7dFable.utilization, thresholds.weekly7dFable)}`);
601
+ if (usage.rejected.status === "rejected") {
602
+ console.log(` ${RED}Rate limit status: REJECTED${RESET}`);
603
+ }
558
604
  if (usage.timestamp) {
559
605
  console.log(` ${DIM}Last updated: ${usage.timestamp}${RESET}`);
560
606
  }
@@ -594,6 +640,7 @@ ${BOLD}${CYAN}=== Auth Switcher Configuration ===${RESET}
594
640
  console.log(` Threshold (5h): ${(thresholds.session5h * 100).toFixed(0)}%`);
595
641
  console.log(` Threshold (7d): ${(thresholds.weekly7d * 100).toFixed(0)}%`);
596
642
  console.log(` Threshold (7d sonnet): ${(thresholds.weekly7dSonnet * 100).toFixed(0)}%`);
643
+ console.log(` Threshold (7d fable): ${(thresholds.weekly7dFable * 100).toFixed(0)}%`);
597
644
  console.log(` Check interval: ${state.config.checkInterval / 60000} min`);
598
645
  console.log();
599
646
  console.log(`${DIM} Config file: ${STATE_FILE}${RESET}`);
@@ -604,25 +651,31 @@ ${BOLD}${CYAN}=== Auth Switcher Configuration ===${RESET}
604
651
  const arg = args[i];
605
652
  if (arg === "--threshold" && args[i + 1]) {
606
653
  const val = parseFloat(args[++i]);
607
- if (isNaN(val) || val < 0 || val > 1) {
608
- 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}`);
609
656
  process.exit(1);
610
657
  }
611
658
  state.config.threshold = val;
612
659
  console.log(`${GREEN}Set uniform threshold to ${(val * 100).toFixed(0)}%${RESET}`);
613
660
  } else if (arg === "--thresholds" && args[i + 1]) {
614
661
  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}`);
662
+ if (parts.length !== 3 && parts.length !== 4 || parts.some(isNaN)) {
663
+ console.error(`${RED}--thresholds requires 3 or 4 comma-separated values (e.g., 90,80,70,70)${RESET}`);
617
664
  process.exit(1);
618
665
  }
619
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
+ }
671
+ const currentFableThreshold = getThresholds(state.config).weekly7dFable;
620
672
  state.config.threshold = {
621
673
  session5h: normalized[0],
622
674
  weekly7d: normalized[1],
623
- weekly7dSonnet: normalized[2]
675
+ weekly7dSonnet: normalized[2],
676
+ weekly7dFable: normalized[3] ?? currentFableThreshold
624
677
  };
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}`);
678
+ 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
679
  } else if (arg === "--interval" && args[i + 1]) {
627
680
  const minutes = parseInt(args[++i], 10);
628
681
  if (isNaN(minutes) || minutes < 1) {
@@ -654,10 +707,20 @@ ${BOLD}Available accounts:${RESET}`);
654
707
  const tag = a.name === state2.currentAccount ? ` ${GREEN}[ACTIVE]${RESET}` : "";
655
708
  console.log(` - ${a.name}${tag}`);
656
709
  }
710
+ console.log(` - auto ${DIM}(resume automatic selection)${RESET}`);
657
711
  console.error(`
658
- ${RED}Usage: oc-auth-switcher switch <account-name>${RESET}`);
712
+ ${RED}Usage: oc-auth-switcher switch <account-name|auto>${RESET}`);
659
713
  process.exit(1);
660
714
  }
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
+ }
661
724
  const account = data.accounts.find((a) => a.name === name);
662
725
  if (!account) {
663
726
  console.error(`${RED}Account "${name}" not found${RESET}`);
@@ -666,6 +729,8 @@ ${RED}Usage: oc-auth-switcher switch <account-name>${RESET}`);
666
729
  console.log(`${CYAN}Switching to account: ${name}...${RESET}`);
667
730
  const state = loadState();
668
731
  state.currentAccount = name;
732
+ state.selectionMode = "manual";
733
+ state.manualAccount = name;
669
734
  state.lastRotationCheck = Date.now();
670
735
  saveState(state);
671
736
  console.log(`${GREEN}Switched to "${name}". Will take effect on the next API request.${RESET}`);
@@ -678,6 +743,7 @@ function cmdStatus() {
678
743
  ${BOLD}${CYAN}=== Auth Switcher Status ===${RESET}
679
744
  `);
680
745
  console.log(` Active account: ${BOLD}${state.currentAccount || "(none)"}${RESET}`);
746
+ console.log(` Selection mode: ${state.selectionMode}${state.manualAccount ? ` (${state.manualAccount})` : ""}`);
681
747
  console.log(` Total accounts: ${data.accounts.length}`);
682
748
  console.log(` Request count: ${state.requestCount}`);
683
749
  console.log(` Last rotation: ${state.lastRotationCheck ? new Date(state.lastRotationCheck).toLocaleString() : "never"}`);
@@ -712,6 +778,10 @@ function cmdRemove(args) {
712
778
  const state = loadState();
713
779
  if (state.currentAccount === name) {
714
780
  state.currentAccount = null;
781
+ if (state.manualAccount === name) {
782
+ state.selectionMode = "auto";
783
+ state.manualAccount = null;
784
+ }
715
785
  saveState(state);
716
786
  console.log(`${YELLOW}This was the active account. Rotation will pick a new one automatically.${RESET}`);
717
787
  }
@@ -728,13 +798,13 @@ ${BOLD}COMMANDS:${RESET}
728
798
  ${CYAN}reauth${RESET} <name> Re-authenticate an existing account
729
799
  ${CYAN}usage${RESET} [--watch] Show usage dashboard with utilization metrics
730
800
  ${CYAN}config${RESET} [options] View or modify threshold/interval configuration
731
- ${CYAN}switch${RESET} <name> Manually switch to a specific account
801
+ ${CYAN}switch${RESET} <name|auto> Pin a specific account or resume automatic selection
732
802
  ${CYAN}status${RESET} Show current active account and rotation state
733
803
  ${CYAN}remove${RESET} <name> Remove an account from the pool
734
804
 
735
805
  ${BOLD}CONFIG OPTIONS:${RESET}
736
806
  --threshold <0-1> Set uniform threshold (e.g., 0.90)
737
- --thresholds <a,b,c> Set per-metric thresholds (5h,7d,7d-sonnet)
807
+ --thresholds <a,b,c[,d]> Set per-metric thresholds (5h,7d,7d-sonnet,7d-fable)
738
808
  --interval <minutes> Set primary recovery check interval
739
809
  --reset Reset to defaults
740
810
 
@@ -744,6 +814,7 @@ ${BOLD}EXAMPLES:${RESET}
744
814
  oc-auth-switcher usage --watch
745
815
  oc-auth-switcher config --threshold 0.90
746
816
  oc-auth-switcher switch fallback-1
817
+ oc-auth-switcher switch auto
747
818
  `);
748
819
  }
749
820
  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: {
@@ -535,21 +539,43 @@ function defaultState() {
535
539
  authFailures: {}
536
540
  };
537
541
  }
538
- function loadState() {
539
- const raw = safeReadJSON(STATE_FILE, {});
542
+ function normalizeState(raw) {
540
543
  const defaults = defaultState();
544
+ const threshold = raw.config?.threshold;
545
+ const migratedThreshold = typeof threshold === "object" && threshold !== null ? {
546
+ session5h: threshold.session5h ?? DEFAULT_THRESHOLD,
547
+ weekly7d: threshold.weekly7d ?? DEFAULT_THRESHOLD,
548
+ weekly7dSonnet: threshold.weekly7dSonnet ?? DEFAULT_THRESHOLD,
549
+ weekly7dFable: threshold.weekly7dFable ?? DEFAULT_THRESHOLD
550
+ } : threshold ?? defaults.config.threshold;
551
+ const usage = Object.fromEntries(Object.entries(raw.usage ?? {}).map(([name, accountUsage]) => [
552
+ name,
553
+ {
554
+ session5h: { ...EMPTY_METRIC, ...accountUsage?.session5h },
555
+ weekly7d: { ...EMPTY_METRIC, ...accountUsage?.weekly7d },
556
+ weekly7dSonnet: { ...EMPTY_METRIC, ...accountUsage?.weekly7dSonnet },
557
+ weekly7dFable: { ...EMPTY_METRIC, ...accountUsage?.weekly7dFable },
558
+ rejected: { ...EMPTY_METRIC, ...accountUsage?.rejected },
559
+ timestamp: accountUsage?.timestamp
560
+ }
561
+ ]));
541
562
  return {
542
563
  currentAccount: raw.currentAccount ?? defaults.currentAccount,
564
+ selectionMode: raw.selectionMode === "manual" && raw.manualAccount ? "manual" : "auto",
565
+ manualAccount: raw.manualAccount ?? defaults.manualAccount,
543
566
  lastRotationCheck: raw.lastRotationCheck ?? defaults.lastRotationCheck,
544
567
  requestCount: raw.requestCount ?? defaults.requestCount,
545
568
  config: {
546
- threshold: raw.config?.threshold ?? defaults.config.threshold,
569
+ threshold: migratedThreshold,
547
570
  checkInterval: raw.config?.checkInterval ?? defaults.config.checkInterval
548
571
  },
549
- usage: raw.usage ?? defaults.usage,
572
+ usage,
550
573
  authFailures: raw.authFailures ?? defaults.authFailures
551
574
  };
552
575
  }
576
+ function loadState() {
577
+ return normalizeState(safeReadJSON(STATE_FILE, {}));
578
+ }
553
579
  function saveState(state) {
554
580
  safeWriteJSON(STATE_FILE, state);
555
581
  }
@@ -558,16 +584,27 @@ function getThresholds(config) {
558
584
  return {
559
585
  session5h: config.threshold,
560
586
  weekly7d: config.threshold,
561
- weekly7dSonnet: config.threshold
587
+ weekly7dSonnet: config.threshold,
588
+ weekly7dFable: config.threshold
562
589
  };
563
590
  }
564
- return config.threshold;
591
+ return {
592
+ session5h: config.threshold.session5h ?? DEFAULT_THRESHOLD,
593
+ weekly7d: config.threshold.weekly7d ?? DEFAULT_THRESHOLD,
594
+ weekly7dSonnet: config.threshold.weekly7dSonnet ?? DEFAULT_THRESHOLD,
595
+ weekly7dFable: config.threshold.weekly7dFable ?? DEFAULT_THRESHOLD
596
+ };
565
597
  }
566
598
  function resolveStaleMetrics(state) {
567
599
  const now = Date.now() / 1000;
568
600
  for (const accountName of Object.keys(state.usage)) {
569
601
  const usage = state.usage[accountName];
570
- const metrics = ["session5h", "weekly7d", "weekly7dSonnet"];
602
+ const metrics = [
603
+ "session5h",
604
+ "weekly7d",
605
+ "weekly7dSonnet",
606
+ "weekly7dFable"
607
+ ];
571
608
  for (const key of metrics) {
572
609
  const metric = usage[key];
573
610
  if (metric.reset > 0 && metric.reset <= now) {
@@ -576,6 +613,9 @@ function resolveStaleMetrics(state) {
576
613
  metric.status = "";
577
614
  }
578
615
  }
616
+ if (usage.rejected.reset > 0 && usage.rejected.reset <= now) {
617
+ usage.rejected = { ...EMPTY_METRIC };
618
+ }
579
619
  }
580
620
  }
581
621
  function ensureAccountsInState(state, accountNames) {
@@ -584,7 +624,9 @@ function ensureAccountsInState(state, accountNames) {
584
624
  state.usage[name] = {
585
625
  session5h: { ...EMPTY_METRIC },
586
626
  weekly7d: { ...EMPTY_METRIC },
587
- weekly7dSonnet: { ...EMPTY_METRIC }
627
+ weekly7dSonnet: { ...EMPTY_METRIC },
628
+ weekly7dFable: { ...EMPTY_METRIC },
629
+ rejected: { ...EMPTY_METRIC }
588
630
  };
589
631
  }
590
632
  }
@@ -594,7 +636,9 @@ function updateUsageFromHeaders(state, accountName, headers) {
594
636
  state.usage[accountName] = {
595
637
  session5h: { ...EMPTY_METRIC },
596
638
  weekly7d: { ...EMPTY_METRIC },
597
- weekly7dSonnet: { ...EMPTY_METRIC }
639
+ weekly7dSonnet: { ...EMPTY_METRIC },
640
+ weekly7dFable: { ...EMPTY_METRIC },
641
+ rejected: { ...EMPTY_METRIC }
598
642
  };
599
643
  }
600
644
  const usage = state.usage[accountName];
@@ -611,6 +655,10 @@ function updateUsageFromHeaders(state, accountName, headers) {
611
655
  {
612
656
  key: "weekly7dSonnet",
613
657
  prefix: "anthropic-ratelimit-unified-7d_sonnet"
658
+ },
659
+ {
660
+ key: "weekly7dFable",
661
+ prefix: "anthropic-ratelimit-unified-7d_fable"
614
662
  }
615
663
  ];
616
664
  for (const { key, prefix } of metricFamilies) {
@@ -629,18 +677,47 @@ function updateUsageFromHeaders(state, accountName, headers) {
629
677
  const val = Number(resetHeader);
630
678
  if (!isNaN(val)) {
631
679
  usage[key].reset = val;
680
+ updated = true;
632
681
  } else {
633
682
  const parsed = Date.parse(resetHeader);
634
683
  if (!isNaN(parsed)) {
635
684
  usage[key].reset = parsed / 1000;
685
+ updated = true;
636
686
  }
637
687
  }
638
688
  }
639
689
  if (statusHeader !== null) {
640
690
  usage[key].status = statusHeader;
691
+ updated = true;
641
692
  }
642
693
  }
643
694
  }
695
+ for (const [headerName, headerValue] of headers.entries()) {
696
+ const normalizedName = headerName.toLowerCase();
697
+ if (!/^anthropic-ratelimit-unified-(?:.*-)?status$/.test(normalizedName) || headerValue.toLowerCase() !== "rejected") {
698
+ continue;
699
+ }
700
+ const prefix = normalizedName.slice(0, -"-status".length);
701
+ const resetHeader = headers.get(`${prefix}-reset`) ?? headers.get("anthropic-ratelimit-unified-reset");
702
+ let reset = resetHeader ? Number(resetHeader) : NaN;
703
+ if (isNaN(reset) && resetHeader) {
704
+ const parsed = Date.parse(resetHeader);
705
+ if (!isNaN(parsed))
706
+ reset = parsed / 1000;
707
+ }
708
+ if (!Number.isFinite(reset) || reset <= 0) {
709
+ const retryAfterHeader = headers.get("retry-after");
710
+ const retryAfter = retryAfterHeader === null ? NaN : Number(retryAfterHeader);
711
+ reset = Date.now() / 1000 + (Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : state.config.checkInterval / 1000);
712
+ }
713
+ usage.rejected = {
714
+ utilization: 1,
715
+ reset: Math.max(usage.rejected.reset, reset),
716
+ status: "rejected",
717
+ prefix
718
+ };
719
+ updated = true;
720
+ }
644
721
  if (updated) {
645
722
  usage.timestamp = new Date().toISOString();
646
723
  }
@@ -662,24 +739,36 @@ function isOverThreshold(usage, state) {
662
739
  if (!usage)
663
740
  return false;
664
741
  const thresholds = getThresholds(state.config);
665
- return usage.session5h.utilization > thresholds.session5h || usage.weekly7d.utilization > thresholds.weekly7d || usage.weekly7dSonnet.utilization > thresholds.weekly7dSonnet;
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;
666
743
  }
667
744
  function getUtilizationScore(usage, state) {
668
745
  if (!usage)
669
746
  return 0;
670
747
  const thresholds = getThresholds(state.config);
671
- return Math.max(usage.session5h.utilization / thresholds.session5h, usage.weekly7d.utilization / thresholds.weekly7d, usage.weekly7dSonnet.utilization / thresholds.weekly7dSonnet);
748
+ if (usage.rejected.status?.toLowerCase() === "rejected")
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);
756
+ return scores.length > 0 ? Math.max(...scores) : 0;
672
757
  }
673
758
  function getExceededMetric(usage, state) {
674
759
  if (!usage)
675
760
  return null;
676
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
+ }
677
765
  const metrics = [
678
766
  { name: "session5h", util: usage.session5h.utilization, thresh: thresholds.session5h },
679
767
  { name: "weekly7d", util: usage.weekly7d.utilization, thresh: thresholds.weekly7d },
680
- { name: "weekly7dSonnet", util: usage.weekly7dSonnet.utilization, thresh: thresholds.weekly7dSonnet }
768
+ { name: "weekly7dSonnet", util: usage.weekly7dSonnet.utilization, thresh: thresholds.weekly7dSonnet },
769
+ { name: "weekly7dFable", util: usage.weekly7dFable.utilization, thresh: thresholds.weekly7dFable }
681
770
  ];
682
- const exceeded = metrics.filter((m) => m.util > m.thresh).sort((a, b) => b.util / b.thresh - a.util / a.thresh);
771
+ const exceeded = metrics.filter((m) => m.thresh > 0 && m.util >= m.thresh).sort((a, b) => b.util / b.thresh - a.util / a.thresh);
683
772
  return exceeded.length > 0 ? exceeded[0].name : null;
684
773
  }
685
774
  function getEarliestReset(usage) {
@@ -688,7 +777,9 @@ function getEarliestReset(usage) {
688
777
  const resets = [
689
778
  usage.session5h.reset,
690
779
  usage.weekly7d.reset,
691
- usage.weekly7dSonnet.reset
780
+ usage.weekly7dSonnet.reset,
781
+ usage.weekly7dFable.reset,
782
+ usage.rejected.reset
692
783
  ].filter((r) => r > 0);
693
784
  return resets.length > 0 ? Math.min(...resets) * 1000 : 0;
694
785
  }
@@ -718,7 +809,7 @@ function findBestAvailable(candidates, state, exclude) {
718
809
  if (isTemporarilyUnavailable(state, acct.name))
719
810
  continue;
720
811
  const score = getUtilizationScore(state.usage[acct.name], state);
721
- if (score < bestScore) {
812
+ if (!best || score < bestScore) {
722
813
  bestScore = score;
723
814
  best = acct;
724
815
  }
@@ -730,6 +821,23 @@ function selectAccount(accounts, state) {
730
821
  throw new Error("No accounts available");
731
822
  }
732
823
  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
+ }
733
841
  if (accounts.length === 1) {
734
842
  return { account: accounts[0], switched: false };
735
843
  }
@@ -809,12 +917,35 @@ function selectAccount(accounts, state) {
809
917
  }
810
918
  function markAuthFailure(state, accountName) {
811
919
  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
+ }
812
924
  }
813
925
  function clearAuthFailure(state, accountName) {
814
926
  delete state.authFailures[accountName];
815
927
  }
816
928
 
817
929
  // src/index.ts
930
+ function selectionSnapshot(state) {
931
+ return {
932
+ selectionMode: state.selectionMode,
933
+ manualAccount: state.manualAccount,
934
+ currentAccount: state.currentAccount,
935
+ lastRotationCheck: state.lastRotationCheck
936
+ };
937
+ }
938
+ function saveRequestState(state, initiallyLoaded) {
939
+ const onDisk = loadState();
940
+ const selectionChangedSinceLoad = onDisk.selectionMode !== initiallyLoaded.selectionMode || onDisk.manualAccount !== initiallyLoaded.manualAccount || onDisk.currentAccount !== initiallyLoaded.currentAccount || onDisk.lastRotationCheck !== initiallyLoaded.lastRotationCheck;
941
+ if (selectionChangedSinceLoad) {
942
+ state.selectionMode = onDisk.selectionMode;
943
+ state.manualAccount = onDisk.manualAccount;
944
+ state.currentAccount = onDisk.currentAccount;
945
+ state.lastRotationCheck = onDisk.lastRotationCheck;
946
+ }
947
+ saveState(state);
948
+ }
818
949
  var AuthSwitcherPlugin = async ({ client }) => {
819
950
  return {
820
951
  auth: {
@@ -836,6 +967,7 @@ var AuthSwitcherPlugin = async ({ client }) => {
836
967
  async fetch(input, init) {
837
968
  const { accounts } = loadAccounts();
838
969
  const state = loadState();
970
+ const initiallyLoadedSelection = selectionSnapshot(state);
839
971
  if (accounts.length === 0) {
840
972
  const auth2 = await getAuth();
841
973
  if (auth2.type !== "oauth")
@@ -908,6 +1040,7 @@ var AuthSwitcherPlugin = async ({ client }) => {
908
1040
  markAuthFailure(state, account.name);
909
1041
  const next = accounts.find((a) => !attemptedAccounts.has(a.name) && !state.authFailures[a.name]);
910
1042
  if (!next) {
1043
+ saveRequestState(state, initiallyLoadedSelection);
911
1044
  throw new Error(`[oc-auth-switcher] All accounts failed token refresh`);
912
1045
  }
913
1046
  account = next;
@@ -963,7 +1096,7 @@ var AuthSwitcherPlugin = async ({ client }) => {
963
1096
  });
964
1097
  updateUsageFromHeaders(state, next.name, retryResponse.headers);
965
1098
  clearAuthFailure(state, next.name);
966
- saveState(state);
1099
+ saveRequestState(state, initiallyLoadedSelection);
967
1100
  return createStrippedStream(retryResponse);
968
1101
  }
969
1102
  }
@@ -971,7 +1104,7 @@ var AuthSwitcherPlugin = async ({ client }) => {
971
1104
  updateUsageFromHeaders(state, account.name, response.headers);
972
1105
  clearAuthFailure(state, account.name);
973
1106
  state.requestCount = (state.requestCount || 0) + 1;
974
- saveState(state);
1107
+ saveRequestState(state, initiallyLoadedSelection);
975
1108
  return createStrippedStream(response);
976
1109
  }
977
1110
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oc-auth-switcher",
3
- "version": "0.2.1",
3
+ "version": "0.4.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": {