oc-auth-switcher 0.7.2 → 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
@@ -48,6 +48,11 @@ var OAUTH_SCOPES = [
48
48
  ];
49
49
 
50
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
+ }
51
56
  function normalizeAccount(raw) {
52
57
  const name = raw.name || "unnamed";
53
58
  const access = raw.access || raw.accessToken || "";
@@ -371,7 +376,7 @@ function purgeExpiredCooldowns(state) {
371
376
  }
372
377
  }
373
378
  }
374
- function findBestAvailable(candidates, state, exclude, modelFamily) {
379
+ function pickByScore(candidates, state, exclude, modelFamily, predicate) {
375
380
  let best = null;
376
381
  let bestScore = Infinity;
377
382
  for (const acct of candidates) {
@@ -379,22 +384,7 @@ function findBestAvailable(candidates, state, exclude, modelFamily) {
379
384
  continue;
380
385
  if (isTemporarilyUnavailable(state, acct.name))
381
386
  continue;
382
- if (!isOverThreshold(state.usage[acct.name], state, modelFamily)) {
383
- const score = getUtilizationScore(state.usage[acct.name], state, modelFamily);
384
- if (!best || score < bestScore) {
385
- bestScore = score;
386
- best = acct;
387
- }
388
- }
389
- }
390
- if (best)
391
- return best;
392
- best = null;
393
- bestScore = Infinity;
394
- for (const acct of candidates) {
395
- if (exclude.has(acct.name))
396
- continue;
397
- if (isTemporarilyUnavailable(state, acct.name))
387
+ if (!predicate(acct))
398
388
  continue;
399
389
  const score = getUtilizationScore(state.usage[acct.name], state, modelFamily);
400
390
  if (!best || score < bestScore) {
@@ -404,6 +394,12 @@ function findBestAvailable(candidates, state, exclude, modelFamily) {
404
394
  }
405
395
  return best;
406
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
+ }
407
403
  function selectAccount(accounts, state, model) {
408
404
  if (accounts.length === 0) {
409
405
  throw new Error("No accounts available");
@@ -692,6 +688,11 @@ function progressBar(value, threshold, width = 30) {
692
688
  const label = `${(value * 100).toFixed(1)}%`;
693
689
  return `${bar} ${color}${label}${RESET}`;
694
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
+ }
695
696
  function tryCopy(cmd, args, text) {
696
697
  return new Promise((resolve) => {
697
698
  try {
@@ -886,10 +887,10 @@ ${BOLD}${CYAN}=== Auth Switcher Usage Dashboard ===${RESET}
886
887
  const tag = isActive ? `${GREEN} [ACTIVE]${RESET}` : isCooling ? `${RED} [COOLDOWN]${RESET}` : "";
887
888
  console.log(` ${BOLD}${account.name}${RESET}${tag}`);
888
889
  if (usage) {
889
- console.log(` 5h session: ${progressBar(usage.session5h.utilization, thresholds.session5h)}`);
890
- console.log(` 7d weekly: ${progressBar(usage.weekly7d.utilization, thresholds.weekly7d)}`);
891
- console.log(` 7d sonnet: ${progressBar(usage.weekly7dSonnet.utilization, thresholds.weekly7dSonnet)}`);
892
- console.log(` 7d fable: ${progressBar(usage.weekly7dFable.utilization, thresholds.weekly7dFable)}`);
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)}`);
893
894
  const rejection = deriveAccountHealth(account, usage, thresholds, state.authFailures[account.name], now).reasons.find((reason) => reason.kind === "rejection");
894
895
  if (rejection) {
895
896
  const reset = rejection.reset ? ` \u2014 resets ${formatResetTime(rejection.reset, now)}` : "";
package/dist/index.js CHANGED
@@ -433,6 +433,11 @@ function quotaWindowScope(prefix) {
433
433
  }
434
434
 
435
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
+ }
436
441
  function normalizeAccount(raw) {
437
442
  const name = raw.name || "unnamed";
438
443
  const access = raw.access || raw.accessToken || "";
@@ -832,7 +837,7 @@ function purgeExpiredCooldowns(state) {
832
837
  }
833
838
  }
834
839
  }
835
- function findBestAvailable(candidates, state, exclude, modelFamily) {
840
+ function pickByScore(candidates, state, exclude, modelFamily, predicate) {
836
841
  let best = null;
837
842
  let bestScore = Infinity;
838
843
  for (const acct of candidates) {
@@ -840,22 +845,7 @@ function findBestAvailable(candidates, state, exclude, modelFamily) {
840
845
  continue;
841
846
  if (isTemporarilyUnavailable(state, acct.name))
842
847
  continue;
843
- if (!isOverThreshold(state.usage[acct.name], state, modelFamily)) {
844
- const score = getUtilizationScore(state.usage[acct.name], state, modelFamily);
845
- if (!best || score < bestScore) {
846
- bestScore = score;
847
- best = acct;
848
- }
849
- }
850
- }
851
- if (best)
852
- return best;
853
- best = null;
854
- bestScore = Infinity;
855
- for (const acct of candidates) {
856
- if (exclude.has(acct.name))
857
- continue;
858
- if (isTemporarilyUnavailable(state, acct.name))
848
+ if (!predicate(acct))
859
849
  continue;
860
850
  const score = getUtilizationScore(state.usage[acct.name], state, modelFamily);
861
851
  if (!best || score < bestScore) {
@@ -865,6 +855,12 @@ function findBestAvailable(candidates, state, exclude, modelFamily) {
865
855
  }
866
856
  return best;
867
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
+ }
868
864
  function selectAccount(accounts, state, model) {
869
865
  if (accounts.length === 0) {
870
866
  throw new Error("No accounts available");
@@ -906,6 +902,96 @@ function clearAuthFailure(state, accountName) {
906
902
  delete state.authFailures[accountName];
907
903
  }
908
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
+
909
995
  // src/index.ts
910
996
  function selectionSnapshot(state) {
911
997
  return {
@@ -1014,7 +1100,7 @@ var AuthSwitcherPlugin = async ({ client }) => {
1014
1100
  const attemptedAccounts = new Set;
1015
1101
  while (true) {
1016
1102
  attemptedAccounts.add(account.name);
1017
- if (!account.access || account.expires <= Date.now()) {
1103
+ if (!isAccessTokenFresh(account)) {
1018
1104
  const result = await refreshAccountToken(account.refresh);
1019
1105
  if (result.ok && result.access && result.refresh && result.expires) {
1020
1106
  account.access = result.access;
@@ -1043,6 +1129,19 @@ var AuthSwitcherPlugin = async ({ client }) => {
1043
1129
  body = rewriteRequestBody(body);
1044
1130
  }
1045
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
+ };
1046
1145
  const response = await fetch(rewritten.input, {
1047
1146
  ...init,
1048
1147
  body,
@@ -1051,48 +1150,29 @@ var AuthSwitcherPlugin = async ({ client }) => {
1051
1150
  tls: { rejectUnauthorized: false }
1052
1151
  }
1053
1152
  });
1054
- if ((response.status === 401 || response.status === 403) && !attemptedAccounts.has("__retried__")) {
1055
- const errorBody = await response.clone().text().catch(() => "");
1056
- const isScopeError = errorBody.includes("scope") || errorBody.includes("unauthorized") || errorBody.includes("invalid");
1057
- if (isScopeError) {
1058
- markAuthFailure(state, account.name);
1059
- const available = accounts.filter((candidate) => !attemptedAccounts.has(candidate.name) && (!state.authFailures[candidate.name] || state.authFailures[candidate.name] <= Date.now()));
1060
- const next = available.length > 0 ? selectAccount(available, state, model).account : undefined;
1061
- if (next) {
1062
- attemptedAccounts.add("__retried__");
1063
- account = next;
1064
- state.currentAccount = next.name;
1065
- if (!next.access || next.expires <= Date.now()) {
1066
- const result = await refreshAccountToken(next.refresh);
1067
- if (result.ok && result.access && result.refresh && result.expires) {
1068
- next.access = result.access;
1069
- next.refresh = result.refresh;
1070
- next.expires = result.expires;
1071
- await updateAccountTokens(next.name, result.access, result.refresh, result.expires);
1072
- }
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
1073
1166
  }
1074
- const retryHeaders = mergeHeaders(input, init);
1075
- setOAuthHeaders(retryHeaders, next.access);
1076
- const retryResponse = await fetch(rewritten.input, {
1077
- ...init,
1078
- body,
1079
- headers: retryHeaders,
1080
- ...isInsecure() && {
1081
- tls: { rejectUnauthorized: false }
1082
- }
1083
- });
1084
- updateUsageFromHeaders(state, next.name, retryResponse.headers);
1085
- clearAuthFailure(state, next.name);
1086
- await saveRequestState(state, initiallyLoadedSelection);
1087
- return createStrippedStream(retryResponse);
1088
- }
1167
+ }).catch(() => {});
1089
1168
  }
1090
- }
1091
- updateUsageFromHeaders(state, account.name, response.headers);
1092
- clearAuthFailure(state, account.name);
1093
- 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);
1094
1174
  await saveRequestState(state, initiallyLoadedSelection);
1095
- return createStrippedStream(response);
1175
+ return createStrippedStream(finalResponse);
1096
1176
  }
1097
1177
  };
1098
1178
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oc-auth-switcher",
3
- "version": "0.7.2",
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",