oc-auth-switcher 0.7.1 → 0.8.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
@@ -47,7 +47,7 @@ oc-auth-switcher <command> [options]
47
47
  |---------|-------------|
48
48
  | `add [name]` | Add a new account via OAuth |
49
49
  | `reauth <name>` | Re-authenticate an existing account |
50
- | `usage [--watch]` | Show utilization dashboard with progress bars |
50
+ | `usage [--watch]` | Show utilization dashboard with progress bars and reset countdowns |
51
51
  | `config [options]` | View/modify thresholds |
52
52
  | `switch <name>` | Set the active account |
53
53
  | `status` | Show current active account and rotation state |
package/dist/cli.js CHANGED
@@ -21,6 +21,14 @@ var METRIC_MODEL_FAMILY = {
21
21
  weekly7dSonnet: "sonnet",
22
22
  weekly7dFable: "fable"
23
23
  };
24
+ var UNIFIED_RATE_LIMIT_PREFIX = "anthropic-ratelimit-unified-";
25
+ var QUOTA_WINDOW_SCOPE_PATTERN = /^\d+[hmd](?:_[a-z0-9.\-]+)?$/i;
26
+ function quotaWindowScope(prefix) {
27
+ if (!prefix)
28
+ return;
29
+ const scope = prefix.toLowerCase().startsWith(UNIFIED_RATE_LIMIT_PREFIX) ? prefix.slice(UNIFIED_RATE_LIMIT_PREFIX.length) : prefix;
30
+ return QUOTA_WINDOW_SCOPE_PATTERN.test(scope) ? scope : undefined;
31
+ }
24
32
 
25
33
  // node_modules/@ex-machina/opencode-anthropic-auth/dist/constants.js
26
34
  var CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
@@ -40,6 +48,11 @@ var OAUTH_SCOPES = [
40
48
  ];
41
49
 
42
50
  // src/accounts.ts
51
+ function isAccessTokenFresh(account, nowMs = Date.now()) {
52
+ if (account.expires == null || Number.isNaN(account.expires))
53
+ return true;
54
+ return !!account.access && account.expires > nowMs;
55
+ }
43
56
  function normalizeAccount(raw) {
44
57
  const name = raw.name || "unnamed";
45
58
  const access = raw.access || raw.accessToken || "";
@@ -217,17 +230,20 @@ function normalizeState(raw) {
217
230
  weekly7dSonnet: threshold.weekly7dSonnet ?? DEFAULT_THRESHOLD,
218
231
  weekly7dFable: threshold.weekly7dFable ?? DEFAULT_THRESHOLD
219
232
  } : threshold ?? defaults.config.threshold;
220
- const usage = Object.fromEntries(Object.entries(raw.usage ?? {}).map(([name, accountUsage]) => [
221
- name,
222
- {
223
- session5h: { ...EMPTY_METRIC, ...accountUsage?.session5h },
224
- weekly7d: { ...EMPTY_METRIC, ...accountUsage?.weekly7d },
225
- weekly7dSonnet: { ...EMPTY_METRIC, ...accountUsage?.weekly7dSonnet },
226
- weekly7dFable: { ...EMPTY_METRIC, ...accountUsage?.weekly7dFable },
227
- rejected: { ...EMPTY_METRIC, ...accountUsage?.rejected },
228
- timestamp: accountUsage?.timestamp
229
- }
230
- ]));
233
+ const usage = Object.fromEntries(Object.entries(raw.usage ?? {}).map(([name, accountUsage]) => {
234
+ const rejected = { ...EMPTY_METRIC, ...accountUsage?.rejected };
235
+ return [
236
+ name,
237
+ {
238
+ session5h: { ...EMPTY_METRIC, ...accountUsage?.session5h },
239
+ weekly7d: { ...EMPTY_METRIC, ...accountUsage?.weekly7d },
240
+ weekly7dSonnet: { ...EMPTY_METRIC, ...accountUsage?.weekly7dSonnet },
241
+ weekly7dFable: { ...EMPTY_METRIC, ...accountUsage?.weekly7dFable },
242
+ rejected: rejected.status?.toLowerCase() === "rejected" && !quotaWindowScope(rejected.prefix) ? { ...EMPTY_METRIC } : rejected,
243
+ timestamp: accountUsage?.timestamp
244
+ }
245
+ ];
246
+ }));
231
247
  return {
232
248
  currentAccount: raw.currentAccount ?? defaults.currentAccount,
233
249
  requestCount: raw.requestCount ?? defaults.requestCount,
@@ -238,8 +254,8 @@ function normalizeState(raw) {
238
254
  authFailures: raw.authFailures ?? defaults.authFailures
239
255
  };
240
256
  }
241
- function loadState() {
242
- return normalizeState(safeReadJSON(STATE_FILE, {}));
257
+ function loadState(stateFile = STATE_FILE) {
258
+ return normalizeState(safeReadJSON(stateFile, {}));
243
259
  }
244
260
  function saveState(state) {
245
261
  return safeWriteJSON(STATE_FILE, state);
@@ -360,7 +376,7 @@ function purgeExpiredCooldowns(state) {
360
376
  }
361
377
  }
362
378
  }
363
- function findBestAvailable(candidates, state, exclude, modelFamily) {
379
+ function pickByScore(candidates, state, exclude, modelFamily, predicate) {
364
380
  let best = null;
365
381
  let bestScore = Infinity;
366
382
  for (const acct of candidates) {
@@ -368,22 +384,7 @@ function findBestAvailable(candidates, state, exclude, modelFamily) {
368
384
  continue;
369
385
  if (isTemporarilyUnavailable(state, acct.name))
370
386
  continue;
371
- if (!isOverThreshold(state.usage[acct.name], state, modelFamily)) {
372
- const score = getUtilizationScore(state.usage[acct.name], state, modelFamily);
373
- if (!best || score < bestScore) {
374
- bestScore = score;
375
- best = acct;
376
- }
377
- }
378
- }
379
- if (best)
380
- return best;
381
- best = null;
382
- bestScore = Infinity;
383
- for (const acct of candidates) {
384
- if (exclude.has(acct.name))
385
- continue;
386
- if (isTemporarilyUnavailable(state, acct.name))
387
+ if (!predicate(acct))
387
388
  continue;
388
389
  const score = getUtilizationScore(state.usage[acct.name], state, modelFamily);
389
390
  if (!best || score < bestScore) {
@@ -393,6 +394,12 @@ function findBestAvailable(candidates, state, exclude, modelFamily) {
393
394
  }
394
395
  return best;
395
396
  }
397
+ function findBestAvailable(candidates, state, exclude, modelFamily) {
398
+ const underThreshold = (acct) => !isOverThreshold(state.usage[acct.name], state, modelFamily);
399
+ const fresh = (acct) => isAccessTokenFresh(acct);
400
+ const stale = (acct) => !isAccessTokenFresh(acct);
401
+ return pickByScore(candidates, state, exclude, modelFamily, (acct) => underThreshold(acct) && fresh(acct)) ?? pickByScore(candidates, state, exclude, modelFamily, (acct) => underThreshold(acct) && stale(acct)) ?? pickByScore(candidates, state, exclude, modelFamily, fresh) ?? pickByScore(candidates, state, exclude, modelFamily, stale);
402
+ }
396
403
  function selectAccount(accounts, state, model) {
397
404
  if (accounts.length === 0) {
398
405
  throw new Error("No accounts available");
@@ -461,6 +468,10 @@ function formatRelativeDuration(targetMs, nowMs = Date.now()) {
461
468
  parts.push(`${Math.max(1, Math.round(remaining))}s`);
462
469
  return difference >= 0 ? `in ${parts.join(" ")}` : `${parts.join(" ")} ago`;
463
470
  }
471
+ function formatResetTime(reset, nowMs = Date.now()) {
472
+ const resetMs = reset * 1000;
473
+ return `${new Date(resetMs).toLocaleString()} (${formatRelativeDuration(resetMs, nowMs)})`;
474
+ }
464
475
  function displayRejectionPrefix(prefix) {
465
476
  return prefix?.replace(/^anthropic-ratelimit-unified-/i, "");
466
477
  }
@@ -677,6 +688,11 @@ function progressBar(value, threshold, width = 30) {
677
688
  const label = `${(value * 100).toFixed(1)}%`;
678
689
  return `${bar} ${color}${label}${RESET}`;
679
690
  }
691
+ function resetSuffix(metric, now) {
692
+ if (!metric.reset || metric.reset * 1000 <= now)
693
+ return "";
694
+ return ` ${DIM}resets ${formatRelativeDuration(metric.reset * 1000, now)}${RESET}`;
695
+ }
680
696
  function tryCopy(cmd, args, text) {
681
697
  return new Promise((resolve) => {
682
698
  try {
@@ -865,17 +881,20 @@ ${BOLD}${CYAN}=== Auth Switcher Usage Dashboard ===${RESET}
865
881
  }
866
882
  for (const account of data.accounts) {
867
883
  const usage = state.usage[account.name];
884
+ const now = Date.now();
868
885
  const isActive = account.name === state.currentAccount;
869
- const isCooling = !!state.authFailures[account.name] && state.authFailures[account.name] > Date.now();
886
+ const isCooling = !!state.authFailures[account.name] && state.authFailures[account.name] > now;
870
887
  const tag = isActive ? `${GREEN} [ACTIVE]${RESET}` : isCooling ? `${RED} [COOLDOWN]${RESET}` : "";
871
888
  console.log(` ${BOLD}${account.name}${RESET}${tag}`);
872
889
  if (usage) {
873
- console.log(` 5h session: ${progressBar(usage.session5h.utilization, thresholds.session5h)}`);
874
- console.log(` 7d weekly: ${progressBar(usage.weekly7d.utilization, thresholds.weekly7d)}`);
875
- console.log(` 7d sonnet: ${progressBar(usage.weekly7dSonnet.utilization, thresholds.weekly7dSonnet)}`);
876
- console.log(` 7d fable: ${progressBar(usage.weekly7dFable.utilization, thresholds.weekly7dFable)}`);
877
- if (usage.rejected.status === "rejected") {
878
- console.log(` ${RED}Rate limit status: REJECTED${RESET}`);
890
+ console.log(` 5h session: ${progressBar(usage.session5h.utilization, thresholds.session5h)}${resetSuffix(usage.session5h, now)}`);
891
+ console.log(` 7d weekly: ${progressBar(usage.weekly7d.utilization, thresholds.weekly7d)}${resetSuffix(usage.weekly7d, now)}`);
892
+ console.log(` 7d sonnet: ${progressBar(usage.weekly7dSonnet.utilization, thresholds.weekly7dSonnet)}${resetSuffix(usage.weekly7dSonnet, now)}`);
893
+ console.log(` 7d fable: ${progressBar(usage.weekly7dFable.utilization, thresholds.weekly7dFable)}${resetSuffix(usage.weekly7dFable, now)}`);
894
+ const rejection = deriveAccountHealth(account, usage, thresholds, state.authFailures[account.name], now).reasons.find((reason) => reason.kind === "rejection");
895
+ if (rejection) {
896
+ const reset = rejection.reset ? ` \u2014 resets ${formatResetTime(rejection.reset, now)}` : "";
897
+ console.log(` ${RED}Rate limit status: ${rejection.message}${reset}${RESET}`);
879
898
  }
880
899
  if (usage.timestamp) {
881
900
  console.log(` ${DIM}Last updated: ${usage.timestamp}${RESET}`);
@@ -1067,8 +1086,7 @@ ${BOLD}${CYAN}=== Auth Switcher Status ===${RESET}
1067
1086
  console.log(` ${RED}Reason: ${reason.message}${RESET}`);
1068
1087
  }
1069
1088
  if (health.earliestReset) {
1070
- const resetMs = health.earliestReset * 1000;
1071
- console.log(` Reset: ${new Date(resetMs).toLocaleString()} (${formatRelativeDuration(resetMs, now)})`);
1089
+ console.log(` Reset: ${formatResetTime(health.earliestReset, now)}`);
1072
1090
  }
1073
1091
  const tokenColor = health.reasons.some((reason) => reason.kind === "token") ? RED : DIM;
1074
1092
  console.log(` ${tokenColor}Token: ${health.tokenExpiry}${RESET}`);
package/dist/index.js CHANGED
@@ -423,8 +423,21 @@ var METRIC_MODEL_FAMILY = {
423
423
  weekly7dSonnet: "sonnet",
424
424
  weekly7dFable: "fable"
425
425
  };
426
+ var UNIFIED_RATE_LIMIT_PREFIX = "anthropic-ratelimit-unified-";
427
+ var QUOTA_WINDOW_SCOPE_PATTERN = /^\d+[hmd](?:_[a-z0-9.\-]+)?$/i;
428
+ function quotaWindowScope(prefix) {
429
+ if (!prefix)
430
+ return;
431
+ const scope = prefix.toLowerCase().startsWith(UNIFIED_RATE_LIMIT_PREFIX) ? prefix.slice(UNIFIED_RATE_LIMIT_PREFIX.length) : prefix;
432
+ return QUOTA_WINDOW_SCOPE_PATTERN.test(scope) ? scope : undefined;
433
+ }
426
434
 
427
435
  // src/accounts.ts
436
+ function isAccessTokenFresh(account, nowMs = Date.now()) {
437
+ if (account.expires == null || Number.isNaN(account.expires))
438
+ return true;
439
+ return !!account.access && account.expires > nowMs;
440
+ }
428
441
  function normalizeAccount(raw) {
429
442
  const name = raw.name || "unnamed";
430
443
  const access = raw.access || raw.accessToken || "";
@@ -585,17 +598,20 @@ function normalizeState(raw) {
585
598
  weekly7dSonnet: threshold.weekly7dSonnet ?? DEFAULT_THRESHOLD,
586
599
  weekly7dFable: threshold.weekly7dFable ?? DEFAULT_THRESHOLD
587
600
  } : threshold ?? defaults.config.threshold;
588
- const usage = Object.fromEntries(Object.entries(raw.usage ?? {}).map(([name, accountUsage]) => [
589
- name,
590
- {
591
- session5h: { ...EMPTY_METRIC, ...accountUsage?.session5h },
592
- weekly7d: { ...EMPTY_METRIC, ...accountUsage?.weekly7d },
593
- weekly7dSonnet: { ...EMPTY_METRIC, ...accountUsage?.weekly7dSonnet },
594
- weekly7dFable: { ...EMPTY_METRIC, ...accountUsage?.weekly7dFable },
595
- rejected: { ...EMPTY_METRIC, ...accountUsage?.rejected },
596
- timestamp: accountUsage?.timestamp
597
- }
598
- ]));
601
+ const usage = Object.fromEntries(Object.entries(raw.usage ?? {}).map(([name, accountUsage]) => {
602
+ const rejected = { ...EMPTY_METRIC, ...accountUsage?.rejected };
603
+ return [
604
+ name,
605
+ {
606
+ session5h: { ...EMPTY_METRIC, ...accountUsage?.session5h },
607
+ weekly7d: { ...EMPTY_METRIC, ...accountUsage?.weekly7d },
608
+ weekly7dSonnet: { ...EMPTY_METRIC, ...accountUsage?.weekly7dSonnet },
609
+ weekly7dFable: { ...EMPTY_METRIC, ...accountUsage?.weekly7dFable },
610
+ rejected: rejected.status?.toLowerCase() === "rejected" && !quotaWindowScope(rejected.prefix) ? { ...EMPTY_METRIC } : rejected,
611
+ timestamp: accountUsage?.timestamp
612
+ }
613
+ ];
614
+ }));
599
615
  return {
600
616
  currentAccount: raw.currentAccount ?? defaults.currentAccount,
601
617
  requestCount: raw.requestCount ?? defaults.requestCount,
@@ -606,8 +622,8 @@ function normalizeState(raw) {
606
622
  authFailures: raw.authFailures ?? defaults.authFailures
607
623
  };
608
624
  }
609
- function loadState() {
610
- return normalizeState(safeReadJSON(STATE_FILE, {}));
625
+ function loadState(stateFile = STATE_FILE) {
626
+ return normalizeState(safeReadJSON(stateFile, {}));
611
627
  }
612
628
  function saveState(state) {
613
629
  return safeWriteJSON(STATE_FILE, state);
@@ -727,7 +743,8 @@ function updateUsageFromHeaders(state, accountName, headers) {
727
743
  }
728
744
  for (const [headerName, headerValue] of headers.entries()) {
729
745
  const normalizedName = headerName.toLowerCase();
730
- if (!/^anthropic-ratelimit-unified-(?:.*-)?status$/.test(normalizedName) || headerValue.toLowerCase() !== "rejected") {
746
+ const statusMatch = normalizedName.match(/^anthropic-ratelimit-unified-(.+)-status$/);
747
+ if (!statusMatch || !quotaWindowScope(statusMatch[1]) || headerValue.toLowerCase() !== "rejected") {
731
748
  continue;
732
749
  }
733
750
  const prefix = normalizedName.slice(0, -"-status".length);
@@ -820,7 +837,7 @@ function purgeExpiredCooldowns(state) {
820
837
  }
821
838
  }
822
839
  }
823
- function findBestAvailable(candidates, state, exclude, modelFamily) {
840
+ function pickByScore(candidates, state, exclude, modelFamily, predicate) {
824
841
  let best = null;
825
842
  let bestScore = Infinity;
826
843
  for (const acct of candidates) {
@@ -828,22 +845,7 @@ function findBestAvailable(candidates, state, exclude, modelFamily) {
828
845
  continue;
829
846
  if (isTemporarilyUnavailable(state, acct.name))
830
847
  continue;
831
- if (!isOverThreshold(state.usage[acct.name], state, modelFamily)) {
832
- const score = getUtilizationScore(state.usage[acct.name], state, modelFamily);
833
- if (!best || score < bestScore) {
834
- bestScore = score;
835
- best = acct;
836
- }
837
- }
838
- }
839
- if (best)
840
- return best;
841
- best = null;
842
- bestScore = Infinity;
843
- for (const acct of candidates) {
844
- if (exclude.has(acct.name))
845
- continue;
846
- if (isTemporarilyUnavailable(state, acct.name))
848
+ if (!predicate(acct))
847
849
  continue;
848
850
  const score = getUtilizationScore(state.usage[acct.name], state, modelFamily);
849
851
  if (!best || score < bestScore) {
@@ -853,6 +855,12 @@ function findBestAvailable(candidates, state, exclude, modelFamily) {
853
855
  }
854
856
  return best;
855
857
  }
858
+ function findBestAvailable(candidates, state, exclude, modelFamily) {
859
+ const underThreshold = (acct) => !isOverThreshold(state.usage[acct.name], state, modelFamily);
860
+ const fresh = (acct) => isAccessTokenFresh(acct);
861
+ const stale = (acct) => !isAccessTokenFresh(acct);
862
+ return pickByScore(candidates, state, exclude, modelFamily, (acct) => underThreshold(acct) && fresh(acct)) ?? pickByScore(candidates, state, exclude, modelFamily, (acct) => underThreshold(acct) && stale(acct)) ?? pickByScore(candidates, state, exclude, modelFamily, fresh) ?? pickByScore(candidates, state, exclude, modelFamily, stale);
863
+ }
856
864
  function selectAccount(accounts, state, model) {
857
865
  if (accounts.length === 0) {
858
866
  throw new Error("No accounts available");
@@ -894,6 +902,96 @@ function clearAuthFailure(state, accountName) {
894
902
  delete state.authFailures[accountName];
895
903
  }
896
904
 
905
+ // src/retry.ts
906
+ var RATE_LIMIT_STATUSES = new Set([429]);
907
+ function isReplayableBody(body) {
908
+ return body == null || typeof body === "string";
909
+ }
910
+ function isRateLimitStatus(status) {
911
+ return RATE_LIMIT_STATUSES.has(status);
912
+ }
913
+ function finalizeRequestAccounting(state, accountName, response) {
914
+ updateUsageFromHeaders(state, accountName, response.headers);
915
+ if (response.ok) {
916
+ clearAuthFailure(state, accountName);
917
+ state.requestCount = (state.requestCount || 0) + 1;
918
+ }
919
+ }
920
+ async function retryAcrossAccounts(initial, account, options) {
921
+ const { accounts, state, model, bodyReplayable, attemptedAccounts, send, log } = options;
922
+ attemptedAccounts.add(account.name);
923
+ let response = initial;
924
+ let current = account;
925
+ let authRetryUsed = false;
926
+ const maxFetches = Math.max(accounts.length, 1) + 1;
927
+ let fetches = 1;
928
+ while (fetches < maxFetches) {
929
+ if (!bodyReplayable)
930
+ break;
931
+ if (isRateLimitStatus(response.status)) {
932
+ updateUsageFromHeaders(state, current.name, response.headers);
933
+ attemptedAccounts.add(current.name);
934
+ const available = accounts.filter((candidate) => !attemptedAccounts.has(candidate.name));
935
+ if (available.length === 0)
936
+ break;
937
+ const next = selectAccount(available, state, model).account;
938
+ if (next.name === current.name || attemptedAccounts.has(next.name))
939
+ break;
940
+ await log(`${current.name} rate-limited (HTTP ${response.status}) — retrying on ${next.name}`);
941
+ const prepared = await ensureFreshToken(next, state);
942
+ if (!prepared) {
943
+ attemptedAccounts.add(next.name);
944
+ continue;
945
+ }
946
+ current = prepared;
947
+ response = await send(current);
948
+ fetches += 1;
949
+ attemptedAccounts.add(current.name);
950
+ continue;
951
+ }
952
+ if ((response.status === 401 || response.status === 403) && !authRetryUsed) {
953
+ const errorBody = await response.clone().text().catch(() => "");
954
+ const isScopeError = errorBody.includes("scope") || errorBody.includes("unauthorized") || errorBody.includes("invalid");
955
+ if (!isScopeError)
956
+ break;
957
+ markAuthFailure(state, current.name);
958
+ attemptedAccounts.add(current.name);
959
+ authRetryUsed = true;
960
+ const available = accounts.filter((candidate) => !attemptedAccounts.has(candidate.name) && (!state.authFailures[candidate.name] || state.authFailures[candidate.name] <= Date.now()));
961
+ if (available.length === 0)
962
+ break;
963
+ const next = selectAccount(available, state, model).account;
964
+ if (next.name === current.name)
965
+ break;
966
+ await log(`${current.name} auth failure (HTTP ${response.status}) — retrying on ${next.name}`);
967
+ const prepared = await ensureFreshToken(next, state);
968
+ if (!prepared)
969
+ break;
970
+ current = prepared;
971
+ response = await send(current);
972
+ fetches += 1;
973
+ attemptedAccounts.add(current.name);
974
+ continue;
975
+ }
976
+ break;
977
+ }
978
+ return { response, account: current };
979
+ }
980
+ async function ensureFreshToken(account, state) {
981
+ if (isAccessTokenFresh(account))
982
+ return account;
983
+ const result = await refreshAccountToken(account.refresh);
984
+ if (result.ok && result.access && result.refresh && result.expires) {
985
+ account.access = result.access;
986
+ account.refresh = result.refresh;
987
+ account.expires = result.expires;
988
+ await updateAccountTokens(account.name, result.access, result.refresh, result.expires);
989
+ return account;
990
+ }
991
+ markAuthFailure(state, account.name);
992
+ return null;
993
+ }
994
+
897
995
  // src/index.ts
898
996
  function selectionSnapshot(state) {
899
997
  return {
@@ -1002,7 +1100,7 @@ var AuthSwitcherPlugin = async ({ client }) => {
1002
1100
  const attemptedAccounts = new Set;
1003
1101
  while (true) {
1004
1102
  attemptedAccounts.add(account.name);
1005
- if (!account.access || account.expires <= Date.now()) {
1103
+ if (!isAccessTokenFresh(account)) {
1006
1104
  const result = await refreshAccountToken(account.refresh);
1007
1105
  if (result.ok && result.access && result.refresh && result.expires) {
1008
1106
  account.access = result.access;
@@ -1031,6 +1129,19 @@ var AuthSwitcherPlugin = async ({ client }) => {
1031
1129
  body = rewriteRequestBody(body);
1032
1130
  }
1033
1131
  const rewritten = rewriteUrl(input);
1132
+ const bodyReplayable = isReplayableBody(init?.body);
1133
+ const sendWithAccount = async (acct) => {
1134
+ const headers = mergeHeaders(input, init);
1135
+ setOAuthHeaders(headers, acct.access);
1136
+ return fetch(rewritten.input, {
1137
+ ...init,
1138
+ body,
1139
+ headers,
1140
+ ...isInsecure() && {
1141
+ tls: { rejectUnauthorized: false }
1142
+ }
1143
+ });
1144
+ };
1034
1145
  const response = await fetch(rewritten.input, {
1035
1146
  ...init,
1036
1147
  body,
@@ -1039,48 +1150,29 @@ var AuthSwitcherPlugin = async ({ client }) => {
1039
1150
  tls: { rejectUnauthorized: false }
1040
1151
  }
1041
1152
  });
1042
- if ((response.status === 401 || response.status === 403) && !attemptedAccounts.has("__retried__")) {
1043
- const errorBody = await response.clone().text().catch(() => "");
1044
- const isScopeError = errorBody.includes("scope") || errorBody.includes("unauthorized") || errorBody.includes("invalid");
1045
- if (isScopeError) {
1046
- markAuthFailure(state, account.name);
1047
- const available = accounts.filter((candidate) => !attemptedAccounts.has(candidate.name) && (!state.authFailures[candidate.name] || state.authFailures[candidate.name] <= Date.now()));
1048
- const next = available.length > 0 ? selectAccount(available, state, model).account : undefined;
1049
- if (next) {
1050
- attemptedAccounts.add("__retried__");
1051
- account = next;
1052
- state.currentAccount = next.name;
1053
- if (!next.access || next.expires <= Date.now()) {
1054
- const result = await refreshAccountToken(next.refresh);
1055
- if (result.ok && result.access && result.refresh && result.expires) {
1056
- next.access = result.access;
1057
- next.refresh = result.refresh;
1058
- next.expires = result.expires;
1059
- await updateAccountTokens(next.name, result.access, result.refresh, result.expires);
1060
- }
1153
+ const retried = await retryAcrossAccounts(response, account, {
1154
+ accounts,
1155
+ state,
1156
+ model,
1157
+ bodyReplayable,
1158
+ attemptedAccounts,
1159
+ send: sendWithAccount,
1160
+ log: async (message) => {
1161
+ await client.app.log({
1162
+ body: {
1163
+ service: "oc-auth-switcher",
1164
+ level: "info",
1165
+ message
1061
1166
  }
1062
- const retryHeaders = mergeHeaders(input, init);
1063
- setOAuthHeaders(retryHeaders, next.access);
1064
- const retryResponse = await fetch(rewritten.input, {
1065
- ...init,
1066
- body,
1067
- headers: retryHeaders,
1068
- ...isInsecure() && {
1069
- tls: { rejectUnauthorized: false }
1070
- }
1071
- });
1072
- updateUsageFromHeaders(state, next.name, retryResponse.headers);
1073
- clearAuthFailure(state, next.name);
1074
- await saveRequestState(state, initiallyLoadedSelection);
1075
- return createStrippedStream(retryResponse);
1076
- }
1167
+ }).catch(() => {});
1077
1168
  }
1078
- }
1079
- updateUsageFromHeaders(state, account.name, response.headers);
1080
- clearAuthFailure(state, account.name);
1081
- state.requestCount = (state.requestCount || 0) + 1;
1169
+ });
1170
+ account = retried.account;
1171
+ state.currentAccount = account.name;
1172
+ const finalResponse = retried.response;
1173
+ finalizeRequestAccounting(state, account.name, finalResponse);
1082
1174
  await saveRequestState(state, initiallyLoadedSelection);
1083
- return createStrippedStream(response);
1175
+ return createStrippedStream(finalResponse);
1084
1176
  }
1085
1177
  };
1086
1178
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oc-auth-switcher",
3
- "version": "0.7.1",
3
+ "version": "0.8.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",