oc-auth-switcher 0.8.0 → 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.
Files changed (2) hide show
  1. package/dist/cli.js +251 -0
  2. package/package.json +1 -1
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";
@@ -260,6 +263,20 @@ function loadState(stateFile = STATE_FILE) {
260
263
  function saveState(state) {
261
264
  return safeWriteJSON(STATE_FILE, state);
262
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
+ }
263
280
  function getThresholds(config) {
264
281
  if (typeof config.threshold === "number") {
265
282
  return {
@@ -312,6 +329,99 @@ function ensureAccountsInState(state, accountNames) {
312
329
  }
313
330
  }
314
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
+ }
315
425
 
316
426
  // src/rotation.ts
317
427
  function isTemporarilyUnavailable(state, accountName) {
@@ -434,6 +544,9 @@ function selectAccount(accounts, state, model) {
434
544
  }
435
545
  return { account: current, switched: false };
436
546
  }
547
+ function markAuthFailure(state, accountName) {
548
+ state.authFailures[accountName] = Date.now() + AUTH_FAILURE_COOLDOWN;
549
+ }
437
550
  function clearAuthFailure(state, accountName) {
438
551
  delete state.authFailures[accountName];
439
552
  }
@@ -854,6 +967,132 @@ async function refreshExpiredAccounts(data, state) {
854
967
  }
855
968
  }
856
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
+ }
857
1096
  async function cmdUsage(args) {
858
1097
  const watch = args.includes("--watch") || args.includes("-w");
859
1098
  const showUsage = async () => {
@@ -909,6 +1148,18 @@ ${BOLD}${CYAN}=== Auth Switcher Usage Dashboard ===${RESET}
909
1148
  console.log();
910
1149
  }
911
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
+ }
912
1163
  await showUsage();
913
1164
  if (watch) {
914
1165
  console.log(`${DIM}Refreshing every 5 seconds. Press Ctrl+C to stop.${RESET}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oc-auth-switcher",
3
- "version": "0.8.0",
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",