oc-auth-switcher 0.1.1 → 0.2.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
@@ -8,7 +8,7 @@ This plugin registers as the Anthropic auth provider for OpenCode. It uses [`@ex
8
8
 
9
9
  1. **Multi-account rotation** — maintains a pool of OAuth accounts and selects the best one on each request
10
10
  2. **Real-time metric capture** — reads Anthropic's rate-limit response headers on every API call (no manual pinging needed)
11
- 3. **Automatic failover** — when any utilization metric exceeds the threshold (default 90%), the next request automatically uses a different account
11
+ 3. **Automatic failover** — when any utilization metric exceeds the threshold (default 95%), the next request automatically uses a different account
12
12
 
13
13
  ## Setup
14
14
 
@@ -58,8 +58,8 @@ oc-auth-switcher <command> [options]
58
58
  ## Configuration
59
59
 
60
60
  ```bash
61
- # Set uniform threshold (default: 90%)
62
- oc-auth-switcher config --threshold 0.90
61
+ # Set uniform threshold (default: 95%)
62
+ oc-auth-switcher config --threshold 0.95
63
63
 
64
64
  # Set per-metric thresholds (5h, 7d, 7d-sonnet)
65
65
  oc-auth-switcher config --thresholds 90,80,70
@@ -89,6 +89,20 @@ Both files use atomic writes with `.bak` fallback for crash safety.
89
89
  - Auth failures trigger a 1-hour cooldown per account
90
90
  - Token refresh is handled automatically with retry and fallback to other accounts
91
91
 
92
+ ## Running the CLI
93
+
94
+ When installed via npm (as part of the OpenCode plugin):
95
+
96
+ ```bash
97
+ npx oc-auth-switcher <command>
98
+ ```
99
+
100
+ From the project directory (development):
101
+
102
+ ```bash
103
+ bun dist/cli.js <command>
104
+ ```
105
+
92
106
  ## Building from Source
93
107
 
94
108
  ```bash
package/dist/cli.js CHANGED
@@ -11,9 +11,26 @@ import os from "os";
11
11
  var configDir = path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "opencode");
12
12
  var ACCOUNTS_FILE = path.join(configDir, "auth-switcher-accounts.json");
13
13
  var STATE_FILE = path.join(configDir, "auth-switcher-state.json");
14
- var DEFAULT_THRESHOLD = 0.9;
14
+ var DEFAULT_THRESHOLD = 0.95;
15
15
  var DEFAULT_CHECK_INTERVAL = 60 * 60 * 1000;
16
- var AUTH_FAILURE_COOLDOWN = 60 * 60 * 1000;
16
+ var AUTH_FAILURE_COOLDOWN = 10 * 60 * 1000;
17
+
18
+ // node_modules/@ex-machina/opencode-anthropic-auth/dist/constants.js
19
+ var CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
20
+ var AUTHORIZE_URLS = {
21
+ console: "https://platform.claude.com/oauth/authorize",
22
+ max: "https://claude.ai/oauth/authorize"
23
+ };
24
+ var CODE_CALLBACK_URL = "https://platform.claude.com/oauth/code/callback";
25
+ var TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
26
+ var OAUTH_SCOPES = [
27
+ "org:create_api_key",
28
+ "user:profile",
29
+ "user:inference",
30
+ "user:sessions:claude_code",
31
+ "user:mcp_servers",
32
+ "user:file_upload"
33
+ ];
17
34
 
18
35
  // src/accounts.ts
19
36
  function normalizeAccount(raw) {
@@ -86,6 +103,49 @@ function removeAccount(name) {
86
103
  saveAccounts(data);
87
104
  return data;
88
105
  }
106
+ function updateAccountTokens(name, access, refresh, expires) {
107
+ const data = loadAccounts();
108
+ const account = data.accounts.find((a) => a.name === name);
109
+ if (account) {
110
+ account.access = access;
111
+ account.refresh = refresh;
112
+ account.expires = expires;
113
+ saveAccounts(data);
114
+ }
115
+ }
116
+ async function refreshAccountToken(refreshTokenValue) {
117
+ try {
118
+ const response = await fetch(TOKEN_URL, {
119
+ method: "POST",
120
+ headers: {
121
+ "Content-Type": "application/json",
122
+ Accept: "application/json, text/plain, */*",
123
+ "User-Agent": "axios/1.13.6"
124
+ },
125
+ body: JSON.stringify({
126
+ grant_type: "refresh_token",
127
+ refresh_token: refreshTokenValue,
128
+ client_id: CLIENT_ID
129
+ })
130
+ });
131
+ if (!response.ok) {
132
+ const body = await response.text().catch(() => "");
133
+ return { ok: false, error: `HTTP ${response.status}: ${body}` };
134
+ }
135
+ const json = await response.json();
136
+ return {
137
+ ok: true,
138
+ access: json.access_token,
139
+ refresh: json.refresh_token,
140
+ expires: Date.now() + json.expires_in * 1000
141
+ };
142
+ } catch (err) {
143
+ return {
144
+ ok: false,
145
+ error: err instanceof Error ? err.message : String(err)
146
+ };
147
+ }
148
+ }
89
149
 
90
150
  // src/state.ts
91
151
  var EMPTY_METRIC = { utilization: 0, reset: 0, status: "" };
@@ -167,23 +227,6 @@ function clearAuthFailure(state, accountName) {
167
227
  delete state.authFailures[accountName];
168
228
  }
169
229
 
170
- // node_modules/@ex-machina/opencode-anthropic-auth/dist/constants.js
171
- var CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
172
- var AUTHORIZE_URLS = {
173
- console: "https://platform.claude.com/oauth/authorize",
174
- max: "https://claude.ai/oauth/authorize"
175
- };
176
- var CODE_CALLBACK_URL = "https://platform.claude.com/oauth/code/callback";
177
- var TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
178
- var OAUTH_SCOPES = [
179
- "org:create_api_key",
180
- "user:profile",
181
- "user:inference",
182
- "user:sessions:claude_code",
183
- "user:mcp_servers",
184
- "user:file_upload"
185
- ];
186
-
187
230
  // node_modules/@ex-machina/opencode-anthropic-auth/dist/pkce.js
188
231
  function base64UrlEncode(bytes) {
189
232
  let bin = "";
@@ -294,6 +337,7 @@ async function exchange(input, verifier, redirectUri, expectedState) {
294
337
  }
295
338
 
296
339
  // src/cli.ts
340
+ import { spawn } from "child_process";
297
341
  var RESET = "\x1B[0m";
298
342
  var BOLD = "\x1B[1m";
299
343
  var DIM = "\x1B[2m";
@@ -315,6 +359,36 @@ function progressBar(value, threshold, width = 30) {
315
359
  const label = `${(value * 100).toFixed(1)}%`;
316
360
  return `${bar} ${color}${label}${RESET}`;
317
361
  }
362
+ function tryCopy(cmd, args, text) {
363
+ return new Promise((resolve) => {
364
+ try {
365
+ const child = spawn(cmd, args);
366
+ child.on("error", () => resolve(false));
367
+ child.on("close", (code) => resolve(code === 0));
368
+ child.stdin.on("error", () => resolve(false));
369
+ child.stdin.end(text);
370
+ } catch {
371
+ resolve(false);
372
+ }
373
+ });
374
+ }
375
+ async function copyToClipboard(text) {
376
+ switch (process.platform) {
377
+ case "darwin":
378
+ return tryCopy("pbcopy", [], text);
379
+ case "win32":
380
+ return tryCopy("clip", [], text);
381
+ default: {
382
+ if (await tryCopy("wl-copy", [], text))
383
+ return true;
384
+ if (await tryCopy("xclip", ["-selection", "clipboard"], text))
385
+ return true;
386
+ if (await tryCopy("xsel", ["--clipboard", "--input"], text))
387
+ return true;
388
+ return false;
389
+ }
390
+ }
391
+ }
318
392
  async function prompt(question) {
319
393
  process.stdout.write(question);
320
394
  const buf = [];
@@ -349,6 +423,10 @@ ${BOLD}Starting OAuth flow for account: ${CYAN}${name}${RESET}
349
423
  console.log(`${BOLD}Open this URL in your browser:${RESET}
350
424
  `);
351
425
  console.log(` ${BLUE}${authResult.url}${RESET}
426
+ `);
427
+ const copied = await copyToClipboard(authResult.url);
428
+ if (copied)
429
+ console.log(`${DIM}(URL copied to clipboard)${RESET}
352
430
  `);
353
431
  console.log(`${DIM}After authorizing, paste the callback URL or code below:${RESET}
354
432
  `);
@@ -406,6 +484,10 @@ ${BOLD}Re-authenticating account: ${CYAN}${name}${RESET}
406
484
  console.log(`${BOLD}Open this URL in your browser:${RESET}
407
485
  `);
408
486
  console.log(` ${BLUE}${authResult.url}${RESET}
487
+ `);
488
+ const copied = await copyToClipboard(authResult.url);
489
+ if (copied)
490
+ console.log(`${DIM}(URL copied to clipboard)${RESET}
409
491
  `);
410
492
  const callbackInput = await prompt(`${CYAN}Callback: ${RESET}`);
411
493
  const exchangeResult = await exchange(callbackInput, authResult.verifier, authResult.redirectUri, authResult.state);
@@ -424,11 +506,27 @@ ${RED}Re-authentication failed${RESET}`);
424
506
  console.log(`
425
507
  ${GREEN}Account "${name}" re-authenticated successfully.${RESET}`);
426
508
  }
427
- function cmdUsage(args) {
509
+ async function refreshExpiredAccounts(data, state) {
510
+ for (const account of data.accounts) {
511
+ if (account.access && account.expires > Date.now())
512
+ continue;
513
+ const result = await refreshAccountToken(account.refresh);
514
+ if (result.ok && result.access && result.refresh && result.expires) {
515
+ account.access = result.access;
516
+ account.refresh = result.refresh;
517
+ account.expires = result.expires;
518
+ updateAccountTokens(account.name, result.access, result.refresh, result.expires);
519
+ clearAuthFailure(state, account.name);
520
+ }
521
+ }
522
+ }
523
+ async function cmdUsage(args) {
428
524
  const watch = args.includes("--watch") || args.includes("-w");
429
- const showUsage = () => {
525
+ const showUsage = async () => {
430
526
  const data = loadAccounts();
431
527
  const state = loadState();
528
+ await refreshExpiredAccounts(data, state);
529
+ saveState(state);
432
530
  resolveStaleMetrics(state);
433
531
  ensureAccountsInState(state, data.accounts.map((a) => a.name));
434
532
  const thresholds = getThresholds(state.config);
@@ -470,11 +568,19 @@ ${BOLD}${CYAN}=== Auth Switcher Usage Dashboard ===${RESET}
470
568
  console.log();
471
569
  }
472
570
  };
473
- showUsage();
571
+ await showUsage();
474
572
  if (watch) {
475
573
  console.log(`${DIM}Refreshing every 5 seconds. Press Ctrl+C to stop.${RESET}
476
574
  `);
477
- setInterval(showUsage, 5000);
575
+ let running = false;
576
+ setInterval(() => {
577
+ if (running)
578
+ return;
579
+ running = true;
580
+ showUsage().catch((err) => console.error(`${RED}Refresh error:${RESET}`, err)).finally(() => {
581
+ running = false;
582
+ });
583
+ }, 5000);
478
584
  new Promise(() => {});
479
585
  }
480
586
  }
@@ -654,7 +760,7 @@ async function main() {
654
760
  break;
655
761
  case "usage":
656
762
  case "u":
657
- cmdUsage(commandArgs);
763
+ await cmdUsage(commandArgs);
658
764
  break;
659
765
  case "config":
660
766
  case "c":
package/dist/index.js CHANGED
@@ -413,9 +413,9 @@ import os from "node:os";
413
413
  var configDir = path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "opencode");
414
414
  var ACCOUNTS_FILE = path.join(configDir, "auth-switcher-accounts.json");
415
415
  var STATE_FILE = path.join(configDir, "auth-switcher-state.json");
416
- var DEFAULT_THRESHOLD = 0.9;
416
+ var DEFAULT_THRESHOLD = 0.95;
417
417
  var DEFAULT_CHECK_INTERVAL = 60 * 60 * 1000;
418
- var AUTH_FAILURE_COOLDOWN = 60 * 60 * 1000;
418
+ var AUTH_FAILURE_COOLDOWN = 10 * 60 * 1000;
419
419
 
420
420
  // src/accounts.ts
421
421
  function normalizeAccount(raw) {
@@ -481,6 +481,39 @@ function updateAccountTokens(name, access, refresh, expires) {
481
481
  saveAccounts(data);
482
482
  }
483
483
  }
484
+ async function refreshAccountToken(refreshTokenValue) {
485
+ try {
486
+ const response = await fetch(TOKEN_URL, {
487
+ method: "POST",
488
+ headers: {
489
+ "Content-Type": "application/json",
490
+ Accept: "application/json, text/plain, */*",
491
+ "User-Agent": "axios/1.13.6"
492
+ },
493
+ body: JSON.stringify({
494
+ grant_type: "refresh_token",
495
+ refresh_token: refreshTokenValue,
496
+ client_id: CLIENT_ID
497
+ })
498
+ });
499
+ if (!response.ok) {
500
+ const body = await response.text().catch(() => "");
501
+ return { ok: false, error: `HTTP ${response.status}: ${body}` };
502
+ }
503
+ const json = await response.json();
504
+ return {
505
+ ok: true,
506
+ access: json.access_token,
507
+ refresh: json.refresh_token,
508
+ expires: Date.now() + json.expires_in * 1000
509
+ };
510
+ } catch (err) {
511
+ return {
512
+ ok: false,
513
+ error: err instanceof Error ? err.message : String(err)
514
+ };
515
+ }
516
+ }
484
517
 
485
518
  // src/state.ts
486
519
  var EMPTY_METRIC = { utilization: 0, reset: 0, status: "" };
@@ -659,10 +692,44 @@ function getEarliestReset(usage) {
659
692
  ].filter((r) => r > 0);
660
693
  return resets.length > 0 ? Math.min(...resets) * 1000 : 0;
661
694
  }
695
+ function purgeExpiredCooldowns(state) {
696
+ const now = Date.now();
697
+ for (const name of Object.keys(state.authFailures)) {
698
+ if (state.authFailures[name] <= now) {
699
+ delete state.authFailures[name];
700
+ }
701
+ }
702
+ }
703
+ function findBestAvailable(candidates, state, exclude) {
704
+ for (const acct of candidates) {
705
+ if (exclude.has(acct.name))
706
+ continue;
707
+ if (isTemporarilyUnavailable(state, acct.name))
708
+ continue;
709
+ if (!isOverThreshold(state.usage[acct.name], state)) {
710
+ return acct;
711
+ }
712
+ }
713
+ let best = null;
714
+ let bestScore = Infinity;
715
+ for (const acct of candidates) {
716
+ if (exclude.has(acct.name))
717
+ continue;
718
+ if (isTemporarilyUnavailable(state, acct.name))
719
+ continue;
720
+ const score = getUtilizationScore(state.usage[acct.name], state);
721
+ if (score < bestScore) {
722
+ bestScore = score;
723
+ best = acct;
724
+ }
725
+ }
726
+ return best;
727
+ }
662
728
  function selectAccount(accounts, state) {
663
729
  if (accounts.length === 0) {
664
730
  throw new Error("No accounts available");
665
731
  }
732
+ purgeExpiredCooldowns(state);
666
733
  if (accounts.length === 1) {
667
734
  return { account: accounts[0], switched: false };
668
735
  }
@@ -690,40 +757,39 @@ function selectAccount(accounts, state) {
690
757
  if (isPrimary) {
691
758
  if (isOverThreshold(primaryUsage, state)) {
692
759
  const exceededMetric = getExceededMetric(primaryUsage, state);
693
- for (const fb of fallbacks) {
694
- if (isTemporarilyUnavailable(state, fb.name))
695
- continue;
696
- const fbUsage = state.usage[fb.name];
697
- if (!isOverThreshold(fbUsage, state)) {
698
- return {
699
- account: fb,
700
- switched: true,
701
- reason: `Primary exceeded ${exceededMetric} threshold — switching to ${fb.name}`
702
- };
703
- }
704
- }
705
- let bestFb = null;
706
- let bestScore = Infinity;
707
- for (const fb of fallbacks) {
708
- if (isTemporarilyUnavailable(state, fb.name))
709
- continue;
710
- const score = getUtilizationScore(state.usage[fb.name], state);
711
- if (score < bestScore) {
712
- bestScore = score;
713
- bestFb = fb;
714
- }
715
- }
716
- if (bestFb) {
760
+ const best = findBestAvailable(fallbacks, state, new Set);
761
+ if (best) {
717
762
  return {
718
- account: bestFb,
763
+ account: best,
719
764
  switched: true,
720
- reason: `Primary exceeded threshold, all fallbacks busy using least loaded: ${bestFb.name}`
765
+ reason: `Primary exceeded ${exceededMetric} threshold — switching to ${best.name}`
721
766
  };
722
767
  }
723
768
  return { account: primary, switched: false };
724
769
  }
725
770
  return { account: primary, switched: false };
726
771
  }
772
+ const currentOverThreshold = isOverThreshold(currentUsage, state);
773
+ const currentInCooldown = isTemporarilyUnavailable(state, current.name);
774
+ if (currentOverThreshold || currentInCooldown) {
775
+ const reason = currentInCooldown ? `${current.name} in auth-failure cooldown` : `${current.name} exceeded threshold`;
776
+ if (!isOverThreshold(primaryUsage, state) && !isTemporarilyUnavailable(state, primary.name)) {
777
+ return {
778
+ account: primary,
779
+ switched: true,
780
+ reason: `${reason} — switching back to primary`
781
+ };
782
+ }
783
+ const best = findBestAvailable(accounts, state, new Set([current.name]));
784
+ if (best && best.name !== current.name) {
785
+ return {
786
+ account: best,
787
+ switched: true,
788
+ reason: `${reason} — switching to ${best.name}`
789
+ };
790
+ }
791
+ return { account: current, switched: false };
792
+ }
727
793
  const now = Date.now();
728
794
  const checkInterval = state.config.checkInterval;
729
795
  const earliestReset = getEarliestReset(primaryUsage);
@@ -739,30 +805,6 @@ function selectAccount(accounts, state) {
739
805
  };
740
806
  }
741
807
  }
742
- if (!isTemporarilyUnavailable(state, current.name)) {
743
- return { account: current, switched: false };
744
- }
745
- for (const fb of fallbacks) {
746
- if (fb.name === current.name)
747
- continue;
748
- if (isTemporarilyUnavailable(state, fb.name))
749
- continue;
750
- const fbUsage = state.usage[fb.name];
751
- if (!isOverThreshold(fbUsage, state)) {
752
- return {
753
- account: fb,
754
- switched: true,
755
- reason: `Current account ${current.name} in cooldown — switching to ${fb.name}`
756
- };
757
- }
758
- }
759
- if (!isTemporarilyUnavailable(state, primary.name)) {
760
- return {
761
- account: primary,
762
- switched: true,
763
- reason: "All fallbacks unavailable — falling back to primary"
764
- };
765
- }
766
808
  return { account: current, switched: false };
767
809
  }
768
810
  function markAuthFailure(state, accountName) {
@@ -773,39 +815,6 @@ function clearAuthFailure(state, accountName) {
773
815
  }
774
816
 
775
817
  // src/index.ts
776
- async function refreshAccountToken(refreshTokenValue) {
777
- try {
778
- const response = await fetch(TOKEN_URL, {
779
- method: "POST",
780
- headers: {
781
- "Content-Type": "application/json",
782
- Accept: "application/json, text/plain, */*",
783
- "User-Agent": "axios/1.13.6"
784
- },
785
- body: JSON.stringify({
786
- grant_type: "refresh_token",
787
- refresh_token: refreshTokenValue,
788
- client_id: CLIENT_ID
789
- })
790
- });
791
- if (!response.ok) {
792
- const body = await response.text().catch(() => "");
793
- return { ok: false, error: `HTTP ${response.status}: ${body}` };
794
- }
795
- const json = await response.json();
796
- return {
797
- ok: true,
798
- access: json.access_token,
799
- refresh: json.refresh_token,
800
- expires: Date.now() + json.expires_in * 1000
801
- };
802
- } catch (err) {
803
- return {
804
- ok: false,
805
- error: err instanceof Error ? err.message : String(err)
806
- };
807
- }
808
- }
809
818
  var AuthSwitcherPlugin = async ({ client }) => {
810
819
  return {
811
820
  auth: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oc-auth-switcher",
3
- "version": "0.1.1",
3
+ "version": "0.2.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",