oc-auth-switcher 0.7.2 → 0.9.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
@@ -1,6 +1,9 @@
1
1
  #!/usr/bin/env bun
2
2
  // @bun
3
3
 
4
+ // src/cli.ts
5
+ import { createHash } from "crypto";
6
+
4
7
  // src/accounts.ts
5
8
  import fs from "fs";
6
9
  import path2 from "path";
@@ -48,6 +51,11 @@ var OAUTH_SCOPES = [
48
51
  ];
49
52
 
50
53
  // src/accounts.ts
54
+ function isAccessTokenFresh(account, nowMs = Date.now()) {
55
+ if (account.expires == null || Number.isNaN(account.expires))
56
+ return true;
57
+ return !!account.access && account.expires > nowMs;
58
+ }
51
59
  function normalizeAccount(raw) {
52
60
  const name = raw.name || "unnamed";
53
61
  const access = raw.access || raw.accessToken || "";
@@ -255,6 +263,20 @@ function loadState(stateFile = STATE_FILE) {
255
263
  function saveState(state) {
256
264
  return safeWriteJSON(STATE_FILE, state);
257
265
  }
266
+ async function saveStateMerged(state, accountsToMerge) {
267
+ const onDisk = loadState();
268
+ for (const name of accountsToMerge) {
269
+ if (state.usage[name]) {
270
+ onDisk.usage[name] = state.usage[name];
271
+ }
272
+ if (state.authFailures[name] !== undefined) {
273
+ onDisk.authFailures[name] = state.authFailures[name];
274
+ } else {
275
+ delete onDisk.authFailures[name];
276
+ }
277
+ }
278
+ await safeWriteJSON(STATE_FILE, onDisk);
279
+ }
258
280
  function getThresholds(config) {
259
281
  if (typeof config.threshold === "number") {
260
282
  return {
@@ -307,6 +329,99 @@ function ensureAccountsInState(state, accountNames) {
307
329
  }
308
330
  }
309
331
  }
332
+ function updateUsageFromHeaders(state, accountName, headers) {
333
+ if (!state.usage[accountName]) {
334
+ state.usage[accountName] = {
335
+ session5h: { ...EMPTY_METRIC },
336
+ weekly7d: { ...EMPTY_METRIC },
337
+ weekly7dSonnet: { ...EMPTY_METRIC },
338
+ weekly7dFable: { ...EMPTY_METRIC },
339
+ rejected: { ...EMPTY_METRIC }
340
+ };
341
+ }
342
+ const usage = state.usage[accountName];
343
+ let updated = false;
344
+ const metricFamilies = [
345
+ {
346
+ key: "session5h",
347
+ prefix: "anthropic-ratelimit-unified-5h"
348
+ },
349
+ {
350
+ key: "weekly7d",
351
+ prefix: "anthropic-ratelimit-unified-7d"
352
+ },
353
+ {
354
+ key: "weekly7dSonnet",
355
+ prefix: "anthropic-ratelimit-unified-7d_sonnet"
356
+ },
357
+ {
358
+ key: "weekly7dFable",
359
+ prefix: "anthropic-ratelimit-unified-7d_fable"
360
+ }
361
+ ];
362
+ for (const { key, prefix } of metricFamilies) {
363
+ const utilHeader = headers.get(`${prefix}-utilization`);
364
+ const resetHeader = headers.get(`${prefix}-reset`);
365
+ const statusHeader = headers.get(`${prefix}-status`);
366
+ if (utilHeader !== null || resetHeader !== null || statusHeader !== null) {
367
+ if (utilHeader !== null) {
368
+ const val = parseFloat(utilHeader);
369
+ if (!isNaN(val)) {
370
+ usage[key].utilization = val;
371
+ updated = true;
372
+ }
373
+ }
374
+ if (resetHeader !== null) {
375
+ const val = Number(resetHeader);
376
+ if (!isNaN(val)) {
377
+ usage[key].reset = val;
378
+ updated = true;
379
+ } else {
380
+ const parsed = Date.parse(resetHeader);
381
+ if (!isNaN(parsed)) {
382
+ usage[key].reset = parsed / 1000;
383
+ updated = true;
384
+ }
385
+ }
386
+ }
387
+ if (statusHeader !== null) {
388
+ usage[key].status = statusHeader;
389
+ updated = true;
390
+ }
391
+ }
392
+ }
393
+ for (const [headerName, headerValue] of headers.entries()) {
394
+ const normalizedName = headerName.toLowerCase();
395
+ const statusMatch = normalizedName.match(/^anthropic-ratelimit-unified-(.+)-status$/);
396
+ if (!statusMatch || !quotaWindowScope(statusMatch[1]) || headerValue.toLowerCase() !== "rejected") {
397
+ continue;
398
+ }
399
+ const prefix = normalizedName.slice(0, -"-status".length);
400
+ const resetHeader = headers.get(`${prefix}-reset`) ?? headers.get("anthropic-ratelimit-unified-reset");
401
+ let reset = resetHeader ? Number(resetHeader) : NaN;
402
+ if (isNaN(reset) && resetHeader) {
403
+ const parsed = Date.parse(resetHeader);
404
+ if (!isNaN(parsed))
405
+ reset = parsed / 1000;
406
+ }
407
+ if (!Number.isFinite(reset) || reset <= 0) {
408
+ const retryAfterHeader = headers.get("retry-after");
409
+ const retryAfter = retryAfterHeader === null ? NaN : Number(retryAfterHeader);
410
+ reset = Date.now() / 1000 + (Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : REJECTION_FALLBACK_SECONDS);
411
+ }
412
+ usage.rejected = {
413
+ utilization: 1,
414
+ reset: Math.max(usage.rejected.reset, reset),
415
+ status: "rejected",
416
+ prefix
417
+ };
418
+ updated = true;
419
+ }
420
+ if (updated) {
421
+ usage.timestamp = new Date().toISOString();
422
+ }
423
+ return updated;
424
+ }
310
425
 
311
426
  // src/rotation.ts
312
427
  function isTemporarilyUnavailable(state, accountName) {
@@ -371,7 +486,7 @@ function purgeExpiredCooldowns(state) {
371
486
  }
372
487
  }
373
488
  }
374
- function findBestAvailable(candidates, state, exclude, modelFamily) {
489
+ function pickByScore(candidates, state, exclude, modelFamily, predicate) {
375
490
  let best = null;
376
491
  let bestScore = Infinity;
377
492
  for (const acct of candidates) {
@@ -379,22 +494,7 @@ function findBestAvailable(candidates, state, exclude, modelFamily) {
379
494
  continue;
380
495
  if (isTemporarilyUnavailable(state, acct.name))
381
496
  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))
497
+ if (!predicate(acct))
398
498
  continue;
399
499
  const score = getUtilizationScore(state.usage[acct.name], state, modelFamily);
400
500
  if (!best || score < bestScore) {
@@ -404,6 +504,12 @@ function findBestAvailable(candidates, state, exclude, modelFamily) {
404
504
  }
405
505
  return best;
406
506
  }
507
+ function findBestAvailable(candidates, state, exclude, modelFamily) {
508
+ const underThreshold = (acct) => !isOverThreshold(state.usage[acct.name], state, modelFamily);
509
+ const fresh = (acct) => isAccessTokenFresh(acct);
510
+ const stale = (acct) => !isAccessTokenFresh(acct);
511
+ 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);
512
+ }
407
513
  function selectAccount(accounts, state, model) {
408
514
  if (accounts.length === 0) {
409
515
  throw new Error("No accounts available");
@@ -438,6 +544,9 @@ function selectAccount(accounts, state, model) {
438
544
  }
439
545
  return { account: current, switched: false };
440
546
  }
547
+ function markAuthFailure(state, accountName) {
548
+ state.authFailures[accountName] = Date.now() + AUTH_FAILURE_COOLDOWN;
549
+ }
441
550
  function clearAuthFailure(state, accountName) {
442
551
  delete state.authFailures[accountName];
443
552
  }
@@ -692,6 +801,11 @@ function progressBar(value, threshold, width = 30) {
692
801
  const label = `${(value * 100).toFixed(1)}%`;
693
802
  return `${bar} ${color}${label}${RESET}`;
694
803
  }
804
+ function resetSuffix(metric, now) {
805
+ if (!metric.reset || metric.reset * 1000 <= now)
806
+ return "";
807
+ return ` ${DIM}resets ${formatRelativeDuration(metric.reset * 1000, now)}${RESET}`;
808
+ }
695
809
  function tryCopy(cmd, args, text) {
696
810
  return new Promise((resolve) => {
697
811
  try {
@@ -853,6 +967,132 @@ async function refreshExpiredAccounts(data, state) {
853
967
  }
854
968
  }
855
969
  }
970
+ var PROBE_CONCURRENCY = 3;
971
+ var PROBE_API_URL = "https://api.anthropic.com/v1/messages?beta=true";
972
+ var PROBE_MODELS = [
973
+ "claude-sonnet-4-5",
974
+ "claude-opus-4-5"
975
+ ];
976
+ var PROBE_BETA_HEADERS = "oauth-2025-04-20,interleaved-thinking-2025-05-14";
977
+ var PROBE_USER_AGENT = "claude-cli/2.1.87 (external, cli)";
978
+ var PROBE_VERSION = "2023-06-01";
979
+ var CLAUDE_CODE_VERSION = "2.1.87";
980
+ var CLAUDE_CODE_IDENTITY = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
981
+ var CCH_SALT = "59cf53e54c78";
982
+ var CCH_POSITIONS = [4, 7, 20];
983
+ function computeCCH(messageText) {
984
+ return createHash("sha256").update(messageText).digest("hex").slice(0, 5);
985
+ }
986
+ function computeVersionSuffix(messageText) {
987
+ const chars = CCH_POSITIONS.map((i) => messageText[i] || "0").join("");
988
+ return createHash("sha256").update(`${CCH_SALT}${chars}${CLAUDE_CODE_VERSION}`).digest("hex").slice(0, 3);
989
+ }
990
+ function buildBillingHeader(messageText) {
991
+ const suffix = computeVersionSuffix(messageText);
992
+ const cch = computeCCH(messageText);
993
+ return "x-anthropic-billing-header: " + `cc_version=${CLAUDE_CODE_VERSION}.${suffix}; ` + `cc_entrypoint=sdk-cli; ` + `cch=${cch};`;
994
+ }
995
+ async function probeAccount(account, state) {
996
+ const result = { name: account.name, success: false };
997
+ if (!account.access || account.expires <= Date.now()) {
998
+ if (!account.refresh) {
999
+ result.error = "missing refresh token";
1000
+ return result;
1001
+ }
1002
+ const refreshResult = await refreshAccountToken(account.refresh);
1003
+ if (!refreshResult.ok || !refreshResult.access) {
1004
+ result.error = `token refresh failed: ${refreshResult.error || "unknown"}`;
1005
+ markAuthFailure(state, account.name);
1006
+ return result;
1007
+ }
1008
+ account.access = refreshResult.access;
1009
+ account.refresh = refreshResult.refresh;
1010
+ account.expires = refreshResult.expires;
1011
+ await updateAccountTokens(account.name, refreshResult.access, refreshResult.refresh, refreshResult.expires);
1012
+ clearAuthFailure(state, account.name);
1013
+ }
1014
+ let lastStatus = 0;
1015
+ let lastBody = "";
1016
+ for (const model of PROBE_MODELS) {
1017
+ try {
1018
+ const messageText = ".";
1019
+ const billingHeader = buildBillingHeader(messageText);
1020
+ const response = await fetch(PROBE_API_URL, {
1021
+ method: "POST",
1022
+ headers: {
1023
+ authorization: `Bearer ${account.access}`,
1024
+ "anthropic-beta": PROBE_BETA_HEADERS,
1025
+ "anthropic-version": PROBE_VERSION,
1026
+ "user-agent": PROBE_USER_AGENT,
1027
+ "content-type": "application/json"
1028
+ },
1029
+ body: JSON.stringify({
1030
+ model,
1031
+ max_tokens: 1,
1032
+ system: [
1033
+ { type: "text", text: billingHeader },
1034
+ { type: "text", text: CLAUDE_CODE_IDENTITY }
1035
+ ],
1036
+ messages: [{ role: "user", content: messageText }]
1037
+ })
1038
+ });
1039
+ lastStatus = response.status;
1040
+ const updated = updateUsageFromHeaders(state, account.name, response.headers);
1041
+ if (response.status === 401 || response.status === 403) {
1042
+ result.error = `auth error ${response.status}`;
1043
+ markAuthFailure(state, account.name);
1044
+ return result;
1045
+ }
1046
+ if (response.status === 429 && updated) {
1047
+ result.success = true;
1048
+ result.model = model;
1049
+ clearAuthFailure(state, account.name);
1050
+ return result;
1051
+ }
1052
+ if (response.ok || updated) {
1053
+ result.success = true;
1054
+ result.model = model;
1055
+ clearAuthFailure(state, account.name);
1056
+ return result;
1057
+ }
1058
+ lastBody = await response.text().catch(() => "");
1059
+ if (response.status === 400 && lastBody.includes("model")) {
1060
+ continue;
1061
+ }
1062
+ } catch (err) {
1063
+ result.error = err instanceof Error ? err.message : String(err);
1064
+ return result;
1065
+ }
1066
+ }
1067
+ result.error = `HTTP ${lastStatus}: ${lastBody.slice(0, 100)}`;
1068
+ return result;
1069
+ }
1070
+ async function probeAllAccounts(accounts) {
1071
+ const state = loadState();
1072
+ const results = [];
1073
+ const probeErrors = [];
1074
+ for (let i = 0;i < accounts.length; i += PROBE_CONCURRENCY) {
1075
+ const batch = accounts.slice(i, i + PROBE_CONCURRENCY);
1076
+ const batchResults = await Promise.allSettled(batch.map((account) => probeAccount(account, state)));
1077
+ for (const settled of batchResults) {
1078
+ if (settled.status === "fulfilled") {
1079
+ results.push(settled.value);
1080
+ if (!settled.value.success && settled.value.error) {
1081
+ probeErrors.push(`${settled.value.name}: ${settled.value.error}`);
1082
+ }
1083
+ } else {
1084
+ probeErrors.push(`unexpected error: ${settled.reason}`);
1085
+ }
1086
+ }
1087
+ }
1088
+ await saveStateMerged(state, accounts.map((a) => a.name));
1089
+ if (probeErrors.length > 0) {
1090
+ for (const err of probeErrors) {
1091
+ console.log(` ${YELLOW}Warning: probe failed for ${err}${RESET}`);
1092
+ }
1093
+ }
1094
+ return { state, results };
1095
+ }
856
1096
  async function cmdUsage(args) {
857
1097
  const watch = args.includes("--watch") || args.includes("-w");
858
1098
  const showUsage = async () => {
@@ -886,10 +1126,10 @@ ${BOLD}${CYAN}=== Auth Switcher Usage Dashboard ===${RESET}
886
1126
  const tag = isActive ? `${GREEN} [ACTIVE]${RESET}` : isCooling ? `${RED} [COOLDOWN]${RESET}` : "";
887
1127
  console.log(` ${BOLD}${account.name}${RESET}${tag}`);
888
1128
  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)}`);
1129
+ console.log(` 5h session: ${progressBar(usage.session5h.utilization, thresholds.session5h)}${resetSuffix(usage.session5h, now)}`);
1130
+ console.log(` 7d weekly: ${progressBar(usage.weekly7d.utilization, thresholds.weekly7d)}${resetSuffix(usage.weekly7d, now)}`);
1131
+ console.log(` 7d sonnet: ${progressBar(usage.weekly7dSonnet.utilization, thresholds.weekly7dSonnet)}${resetSuffix(usage.weekly7dSonnet, now)}`);
1132
+ console.log(` 7d fable: ${progressBar(usage.weekly7dFable.utilization, thresholds.weekly7dFable)}${resetSuffix(usage.weekly7dFable, now)}`);
893
1133
  const rejection = deriveAccountHealth(account, usage, thresholds, state.authFailures[account.name], now).reasons.find((reason) => reason.kind === "rejection");
894
1134
  if (rejection) {
895
1135
  const reset = rejection.reset ? ` \u2014 resets ${formatResetTime(rejection.reset, now)}` : "";
@@ -908,6 +1148,18 @@ ${BOLD}${CYAN}=== Auth Switcher Usage Dashboard ===${RESET}
908
1148
  console.log();
909
1149
  }
910
1150
  };
1151
+ const accountsData = loadAccounts();
1152
+ if (accountsData.accounts.length > 0) {
1153
+ process.stdout.write(`${DIM} Probing ${accountsData.accounts.length} account(s) for current usage...${RESET}`);
1154
+ const { results } = await probeAllAccounts(accountsData.accounts);
1155
+ const successCount = results.filter((r) => r.success).length;
1156
+ const models = [...new Set(results.filter((r) => r.model).map((r) => r.model))];
1157
+ process.stdout.write(`\r\x1B[K`);
1158
+ if (successCount < accountsData.accounts.length) {
1159
+ console.log(`${DIM} Probed ${successCount}/${accountsData.accounts.length} accounts successfully${models.length ? ` (${models.join(", ")})` : ""}${RESET}
1160
+ `);
1161
+ }
1162
+ }
911
1163
  await showUsage();
912
1164
  if (watch) {
913
1165
  console.log(`${DIM}Refreshing every 5 seconds. Press Ctrl+C to stop.${RESET}
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.9.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",