oc-auth-switcher 0.6.0 → 0.7.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 +333 -17
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -14,6 +14,12 @@ var STATE_FILE = path.join(configDir, "auth-switcher-state.json");
14
14
  var DEFAULT_THRESHOLD = 0.95;
15
15
  var AUTH_FAILURE_COOLDOWN = 10 * 60 * 1000;
16
16
  var REJECTION_FALLBACK_SECONDS = 60 * 60;
17
+ var METRIC_MODEL_FAMILY = {
18
+ session5h: null,
19
+ weekly7d: null,
20
+ weekly7dSonnet: "sonnet",
21
+ weekly7dFable: "fable"
22
+ };
17
23
 
18
24
  // node_modules/@ex-machina/opencode-anthropic-auth/dist/constants.js
19
25
  var CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
@@ -257,10 +263,252 @@ function ensureAccountsInState(state, accountNames) {
257
263
  }
258
264
 
259
265
  // src/rotation.ts
266
+ function isTemporarilyUnavailable(state, accountName) {
267
+ const cooldownUntil = state.authFailures[accountName];
268
+ if (!cooldownUntil)
269
+ return false;
270
+ if (Date.now() > cooldownUntil) {
271
+ delete state.authFailures[accountName];
272
+ return false;
273
+ }
274
+ return true;
275
+ }
276
+ function isOverThreshold(usage, state, modelFamily) {
277
+ if (!usage)
278
+ return false;
279
+ const thresholds = getThresholds(state.config);
280
+ if (usage.rejected.status?.toLowerCase() === "rejected" && isRejectionRelevant(usage.rejected.prefix, modelFamily))
281
+ return true;
282
+ return metricEntries(usage, thresholds, modelFamily).some((metric) => metric.threshold > 0 && metric.util >= metric.threshold);
283
+ }
284
+ function getUtilizationScore(usage, state, modelFamily) {
285
+ if (!usage)
286
+ return 0;
287
+ const thresholds = getThresholds(state.config);
288
+ if (usage.rejected.status?.toLowerCase() === "rejected" && isRejectionRelevant(usage.rejected.prefix, modelFamily))
289
+ return Infinity;
290
+ const scores = metricEntries(usage, thresholds, modelFamily).filter((metric) => metric.threshold > 0).map((metric) => metric.util / metric.threshold);
291
+ return scores.length > 0 ? Math.max(...scores) : 0;
292
+ }
293
+ function getModelFamily(model) {
294
+ if (!model)
295
+ return;
296
+ const normalized = model.toLowerCase();
297
+ return ["fable", "sonnet", "opus"].find((family) => normalized.includes(family));
298
+ }
299
+ function isMetricRelevant(metric, modelFamily) {
300
+ const metricFamily = METRIC_MODEL_FAMILY[metric];
301
+ return modelFamily === undefined || metricFamily === null || metricFamily === modelFamily;
302
+ }
303
+ function rejectionFamily(prefix) {
304
+ if (!prefix)
305
+ return;
306
+ const normalized = prefix.toLowerCase();
307
+ return ["fable", "sonnet", "opus"].find((family) => normalized === `7d_${family}` || normalized === `anthropic-ratelimit-unified-7d_${family}`);
308
+ }
309
+ function isRejectionRelevant(prefix, modelFamily) {
310
+ const rejectedFamily = rejectionFamily(prefix);
311
+ return modelFamily === undefined || rejectedFamily === undefined || rejectedFamily === modelFamily;
312
+ }
313
+ function metricEntries(usage, thresholds, modelFamily) {
314
+ return Object.keys(METRIC_MODEL_FAMILY).filter((key) => isMetricRelevant(key, modelFamily)).map((key) => ({
315
+ name: key,
316
+ util: usage[key].utilization,
317
+ threshold: thresholds[key]
318
+ }));
319
+ }
320
+ function purgeExpiredCooldowns(state) {
321
+ const now = Date.now();
322
+ for (const name of Object.keys(state.authFailures)) {
323
+ if (state.authFailures[name] <= now) {
324
+ delete state.authFailures[name];
325
+ }
326
+ }
327
+ }
328
+ function findBestAvailable(candidates, state, exclude, modelFamily) {
329
+ let best = null;
330
+ let bestScore = Infinity;
331
+ for (const acct of candidates) {
332
+ if (exclude.has(acct.name))
333
+ continue;
334
+ if (isTemporarilyUnavailable(state, acct.name))
335
+ continue;
336
+ if (!isOverThreshold(state.usage[acct.name], state, modelFamily)) {
337
+ const score = getUtilizationScore(state.usage[acct.name], state, modelFamily);
338
+ if (!best || score < bestScore) {
339
+ bestScore = score;
340
+ best = acct;
341
+ }
342
+ }
343
+ }
344
+ if (best)
345
+ return best;
346
+ best = null;
347
+ bestScore = Infinity;
348
+ for (const acct of candidates) {
349
+ if (exclude.has(acct.name))
350
+ continue;
351
+ if (isTemporarilyUnavailable(state, acct.name))
352
+ continue;
353
+ const score = getUtilizationScore(state.usage[acct.name], state, modelFamily);
354
+ if (!best || score < bestScore) {
355
+ bestScore = score;
356
+ best = acct;
357
+ }
358
+ }
359
+ return best;
360
+ }
361
+ function selectAccount(accounts, state, model) {
362
+ if (accounts.length === 0) {
363
+ throw new Error("No accounts available");
364
+ }
365
+ purgeExpiredCooldowns(state);
366
+ const modelFamily = getModelFamily(model);
367
+ const currentIdx = accounts.findIndex((a) => a.name === state.currentAccount);
368
+ if (currentIdx < 0) {
369
+ const best = findBestAvailable(accounts, state, new Set, modelFamily);
370
+ const selected = best ?? accounts[0];
371
+ return {
372
+ account: selected,
373
+ switched: true,
374
+ reason: `Selecting account ${selected.name}`
375
+ };
376
+ }
377
+ const current = accounts[currentIdx];
378
+ const currentUsage = state.usage[current.name];
379
+ const currentOverThreshold = isOverThreshold(currentUsage, state, modelFamily);
380
+ const currentInCooldown = isTemporarilyUnavailable(state, current.name);
381
+ if (currentOverThreshold || currentInCooldown) {
382
+ const reason = currentInCooldown ? `${current.name} in auth-failure cooldown` : `${current.name} exceeded threshold`;
383
+ const best = findBestAvailable(accounts, state, new Set([current.name]), modelFamily);
384
+ if (best && best.name !== current.name) {
385
+ return {
386
+ account: best,
387
+ switched: true,
388
+ reason: `${reason} \u2014 switching to ${best.name}`
389
+ };
390
+ }
391
+ return { account: current, switched: false };
392
+ }
393
+ return { account: current, switched: false };
394
+ }
260
395
  function clearAuthFailure(state, accountName) {
261
396
  delete state.authFailures[accountName];
262
397
  }
263
398
 
399
+ // src/status.ts
400
+ var MODEL_FAMILIES = ["opus", "sonnet", "fable"];
401
+ function percentage(value) {
402
+ return `${(value * 100).toFixed(0)}%`;
403
+ }
404
+ function formatRelativeDuration(targetMs, nowMs = Date.now()) {
405
+ const difference = targetMs - nowMs;
406
+ const absoluteSeconds = Math.abs(difference) / 1000;
407
+ if (absoluteSeconds < 30)
408
+ return "now";
409
+ const units = [
410
+ ["d", 86400],
411
+ ["h", 3600],
412
+ ["m", 60]
413
+ ];
414
+ const parts = [];
415
+ let remaining = absoluteSeconds;
416
+ for (const [suffix, seconds] of units) {
417
+ const amount = Math.floor(remaining / seconds);
418
+ if (amount > 0) {
419
+ parts.push(`${amount}${suffix}`);
420
+ remaining -= amount * seconds;
421
+ }
422
+ if (parts.length === 2)
423
+ break;
424
+ }
425
+ if (parts.length === 0)
426
+ parts.push(`${Math.max(1, Math.round(remaining))}s`);
427
+ return difference >= 0 ? `in ${parts.join(" ")}` : `${parts.join(" ")} ago`;
428
+ }
429
+ function displayRejectionPrefix(prefix) {
430
+ return prefix?.replace(/^anthropic-ratelimit-unified-/i, "");
431
+ }
432
+ function rejectionFamily2(prefix) {
433
+ if (!prefix)
434
+ return;
435
+ const normalized = prefix.toLowerCase();
436
+ return MODEL_FAMILIES.find((family) => normalized === `7d_${family}` || normalized === `anthropic-ratelimit-unified-7d_${family}`);
437
+ }
438
+ function tokenExpiryDescription(account, nowMs) {
439
+ if (account.expires > nowMs) {
440
+ return `expires ${formatRelativeDuration(account.expires, nowMs)}`;
441
+ }
442
+ if (account.refresh) {
443
+ return account.expires > 0 ? `expired ${formatRelativeDuration(account.expires, nowMs)}; refresh available` : "expired; refresh available";
444
+ }
445
+ return account.expires > 0 ? `expired/unrefreshable (${formatRelativeDuration(account.expires, nowMs)})` : "expired/unrefreshable";
446
+ }
447
+ function deriveAccountHealth(account, usage, thresholds, cooldownUntil, nowMs = Date.now()) {
448
+ const availability = Object.fromEntries(MODEL_FAMILIES.map((family) => [family, true]));
449
+ const reasons = [];
450
+ const metrics = usage ? Object.keys(METRIC_MODEL_FAMILY).filter((metric) => thresholds[metric] > 0).map((metric) => ({
451
+ metric,
452
+ utilization: usage[metric].utilization,
453
+ threshold: thresholds[metric],
454
+ family: METRIC_MODEL_FAMILY[metric]
455
+ })) : [];
456
+ for (const metric of metrics) {
457
+ if (metric.utilization < metric.threshold)
458
+ continue;
459
+ const families = metric.family ? [metric.family] : [...MODEL_FAMILIES];
460
+ for (const family of families)
461
+ availability[family] = false;
462
+ reasons.push({
463
+ kind: "metric",
464
+ message: `${metric.metric} ${percentage(metric.utilization)} >= ${percentage(metric.threshold)}${metric.family ? ` \u2014 ${metric.family} only` : ""}`,
465
+ families,
466
+ reset: usage?.[metric.metric].reset
467
+ });
468
+ }
469
+ if (usage?.rejected.status?.toLowerCase() === "rejected") {
470
+ const family = rejectionFamily2(usage.rejected.prefix);
471
+ const families = family ? [family] : [...MODEL_FAMILIES];
472
+ for (const blockedFamily of families)
473
+ availability[blockedFamily] = false;
474
+ const prefix = displayRejectionPrefix(usage.rejected.prefix);
475
+ reasons.push({
476
+ kind: "rejection",
477
+ message: `rejected${prefix ? ` (${prefix})` : ""}${family ? ` \u2014 ${family} only` : ""}`,
478
+ families,
479
+ reset: usage.rejected.reset
480
+ });
481
+ }
482
+ if (cooldownUntil && cooldownUntil > nowMs) {
483
+ for (const family of MODEL_FAMILIES)
484
+ availability[family] = false;
485
+ reasons.push({
486
+ kind: "cooldown",
487
+ message: `auth-failure cooldown \u2014 ${formatRelativeDuration(cooldownUntil, nowMs)}`,
488
+ families: [...MODEL_FAMILIES]
489
+ });
490
+ }
491
+ const hasUsableToken = !!account.access && account.expires > nowMs || !!account.refresh;
492
+ if (!hasUsableToken) {
493
+ for (const family of MODEL_FAMILIES)
494
+ availability[family] = false;
495
+ reasons.push({
496
+ kind: "token",
497
+ message: "expired/unrefreshable token",
498
+ families: [...MODEL_FAMILIES]
499
+ });
500
+ }
501
+ const highestUtilization = metrics.sort((a, b) => b.utilization / b.threshold - a.utilization / a.threshold)[0] ?? null;
502
+ const futureResets = reasons.map((reason) => reason.reset).filter((reset) => !!reset && reset * 1000 > nowMs);
503
+ return {
504
+ availability,
505
+ highestUtilization,
506
+ reasons,
507
+ earliestReset: futureResets.length > 0 ? Math.min(...futureResets) : undefined,
508
+ tokenExpiry: tokenExpiryDescription(account, nowMs)
509
+ };
510
+ }
511
+
264
512
  // node_modules/@ex-machina/opencode-anthropic-auth/dist/pkce.js
265
513
  function base64UrlEncode(bytes) {
266
514
  let bin = "";
@@ -372,14 +620,15 @@ async function exchange(input, verifier, redirectUri, expectedState) {
372
620
 
373
621
  // src/cli.ts
374
622
  import { spawn } from "child_process";
375
- var RESET = "\x1B[0m";
376
- var BOLD = "\x1B[1m";
377
- var DIM = "\x1B[2m";
378
- var RED = "\x1B[31m";
379
- var GREEN = "\x1B[32m";
380
- var YELLOW = "\x1B[33m";
381
- var BLUE = "\x1B[34m";
382
- var CYAN = "\x1B[36m";
623
+ var COLOR = !!process.stdout.isTTY;
624
+ var RESET = COLOR ? "\x1B[0m" : "";
625
+ var BOLD = COLOR ? "\x1B[1m" : "";
626
+ var DIM = COLOR ? "\x1B[2m" : "";
627
+ var RED = COLOR ? "\x1B[31m" : "";
628
+ var GREEN = COLOR ? "\x1B[32m" : "";
629
+ var YELLOW = COLOR ? "\x1B[33m" : "";
630
+ var BLUE = COLOR ? "\x1B[34m" : "";
631
+ var CYAN = COLOR ? "\x1B[36m" : "";
383
632
  function progressBar(value, threshold, width = 30) {
384
633
  const pct = Math.min(value, 1);
385
634
  const filled = Math.round(pct * width);
@@ -704,23 +953,90 @@ ${RED}Usage: oc-auth-switcher switch <account-name>${RESET}`);
704
953
  saveState(state);
705
954
  console.log(`${GREEN}Switched to "${name}". Will take effect on the next API request.${RESET}`);
706
955
  }
956
+ function healthLabel(health) {
957
+ const usable = MODEL_FAMILIES.filter((family) => health.availability[family]);
958
+ const unavailable = MODEL_FAMILIES.filter((family) => !health.availability[family]);
959
+ if (unavailable.length === 0)
960
+ return `${GREEN}healthy \u2014 all models usable${RESET}`;
961
+ if (usable.length === 0)
962
+ return `${RED}unavailable \u2014 all models${RESET}`;
963
+ return `${YELLOW}partial \u2014 usable: ${usable.join(", ")}; unavailable: ${unavailable.join(", ")}${RESET}`;
964
+ }
965
+ function utilizationLabel(health) {
966
+ const peak = health.highestUtilization;
967
+ if (!peak)
968
+ return `${DIM}no usage data${RESET}`;
969
+ const utilization = `${(peak.utilization * 100).toFixed(0)}%`;
970
+ const threshold = `${(peak.threshold * 100).toFixed(0)}%`;
971
+ const family = peak.family ? ` \u2014 ${peak.family} only` : "";
972
+ if (peak.utilization >= peak.threshold) {
973
+ return `${RED}${peak.metric} ${utilization} >= ${threshold}${family}${RESET}`;
974
+ }
975
+ const color = peak.utilization >= peak.threshold * 0.8 ? YELLOW : GREEN;
976
+ return `${color}${peak.metric} ${utilization} / ${threshold} threshold${family}${RESET}`;
977
+ }
978
+ function nextAccountSummary(accounts, state, activeName) {
979
+ if (accounts.length === 0 || activeName && accounts.length === 1) {
980
+ return "(none \u2014 no alternate account)";
981
+ }
982
+ const selections = MODEL_FAMILIES.map((family) => {
983
+ const candidateState = structuredClone(state);
984
+ candidateState.currentAccount = activeName;
985
+ if (activeName)
986
+ candidateState.authFailures[activeName] = Date.now() + 60000;
987
+ const selected = selectAccount(accounts, candidateState, `claude-${family}`);
988
+ return [
989
+ family,
990
+ selected.account.name === activeName ? null : selected.account.name
991
+ ];
992
+ });
993
+ const names = new Set(selections.map(([_, name]) => name));
994
+ if (names.size === 1) {
995
+ return selections[0][1] ? `${selections[0][1]} (all models)` : "(none \u2014 no usable alternate account)";
996
+ }
997
+ return selections.map(([family, name]) => `${family}: ${name ?? "none"}`).join(", ");
998
+ }
707
999
  function cmdStatus() {
708
1000
  const data = loadAccounts();
709
1001
  const state = loadState();
710
1002
  resolveStaleMetrics(state);
1003
+ const thresholds = getThresholds(state.config);
1004
+ const now = Date.now();
1005
+ const configuredActive = data.accounts.find((account) => account.name === state.currentAccount);
1006
+ const healthByAccount = new Map(data.accounts.map((account) => [
1007
+ account.name,
1008
+ deriveAccountHealth(account, state.usage[account.name], thresholds, state.authFailures[account.name], now)
1009
+ ]));
711
1010
  console.log(`
712
1011
  ${BOLD}${CYAN}=== Auth Switcher Status ===${RESET}
713
1012
  `);
714
- console.log(` Active account: ${BOLD}${state.currentAccount || "(none)"}${RESET}`);
715
- console.log(` Total accounts: ${data.accounts.length}`);
716
- console.log(` Request count: ${state.requestCount}`);
1013
+ if (configuredActive) {
1014
+ console.log(` Active account: ${BOLD}${configuredActive.name}${RESET} \u2014 ${healthLabel(healthByAccount.get(configuredActive.name))}`);
1015
+ } else {
1016
+ console.log(` Active account: ${BOLD}(none)${RESET}`);
1017
+ }
1018
+ console.log(` Next if unavailable: ${BOLD}${nextAccountSummary(data.accounts, state, configuredActive?.name ?? null)}${RESET}`);
1019
+ console.log(` Accounts: ${data.accounts.length} Requests: ${state.requestCount}`);
717
1020
  console.log();
718
- const failures = Object.entries(state.authFailures).filter(([_, until]) => until > Date.now());
719
- if (failures.length > 0) {
720
- console.log(` ${RED}Auth cooldowns:${RESET}`);
721
- for (const [name, until] of failures) {
722
- const remaining = Math.ceil((until - Date.now()) / 60000);
723
- console.log(` - ${name}: ${remaining} min remaining`);
1021
+ if (data.accounts.length === 0) {
1022
+ console.log(` ${DIM}No accounts configured. Run 'oc-auth-switcher add' to add one.${RESET}`);
1023
+ console.log();
1024
+ } else {
1025
+ console.log(` ${BOLD}Account health${RESET}`);
1026
+ for (const account of data.accounts) {
1027
+ const health = healthByAccount.get(account.name);
1028
+ const active = account.name === configuredActive?.name ? ` ${GREEN}[ACTIVE]${RESET}` : "";
1029
+ console.log(` ${BOLD}${account.name}${RESET}${active} \u2014 ${healthLabel(health)}`);
1030
+ console.log(` Peak: ${utilizationLabel(health)}`);
1031
+ for (const reason of health.reasons) {
1032
+ console.log(` ${RED}Reason: ${reason.message}${RESET}`);
1033
+ }
1034
+ if (health.earliestReset) {
1035
+ const resetMs = health.earliestReset * 1000;
1036
+ console.log(` Reset: ${new Date(resetMs).toLocaleString()} (${formatRelativeDuration(resetMs, now)})`);
1037
+ }
1038
+ const tokenColor = health.reasons.some((reason) => reason.kind === "token") ? RED : DIM;
1039
+ console.log(` ${tokenColor}Token: ${health.tokenExpiry}${RESET}`);
724
1040
  }
725
1041
  console.log();
726
1042
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oc-auth-switcher",
3
- "version": "0.6.0",
3
+ "version": "0.7.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",