oc-auth-switcher 0.4.0 → 0.6.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 +16 -20
- package/dist/cli.js +10 -49
- package/dist/index.js +82 -142
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -25,19 +25,17 @@ This is the **only** auth plugin you need for Anthropic. Do not also list `@ex-m
|
|
|
25
25
|
### 2. Add accounts
|
|
26
26
|
|
|
27
27
|
```bash
|
|
28
|
-
# Add
|
|
29
|
-
oc-auth-switcher add
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
oc-auth-switcher add fallback-1
|
|
33
|
-
oc-auth-switcher add fallback-2
|
|
28
|
+
# Add accounts in any order
|
|
29
|
+
oc-auth-switcher add work
|
|
30
|
+
oc-auth-switcher add personal
|
|
31
|
+
oc-auth-switcher add team
|
|
34
32
|
```
|
|
35
33
|
|
|
36
34
|
Each `add` command runs an OAuth flow — you'll be given a URL to open in your browser and prompted to paste the callback.
|
|
37
35
|
|
|
38
36
|
### 3. Use OpenCode normally
|
|
39
37
|
|
|
40
|
-
Metrics update automatically on every API request.
|
|
38
|
+
Metrics update automatically on every API request. The active account remains selected until it reaches a relevant utilization threshold or becomes unavailable, then the plugin switches to the available account with the most headroom.
|
|
41
39
|
|
|
42
40
|
## CLI Commands
|
|
43
41
|
|
|
@@ -50,8 +48,8 @@ oc-auth-switcher <command> [options]
|
|
|
50
48
|
| `add [name]` | Add a new account via OAuth |
|
|
51
49
|
| `reauth <name>` | Re-authenticate an existing account |
|
|
52
50
|
| `usage [--watch]` | Show utilization dashboard with progress bars |
|
|
53
|
-
| `config [options]` | View/modify thresholds
|
|
54
|
-
| `switch <name>` |
|
|
51
|
+
| `config [options]` | View/modify thresholds |
|
|
52
|
+
| `switch <name>` | Set the active account |
|
|
55
53
|
| `status` | Show current active account and rotation state |
|
|
56
54
|
| `remove <name>` | Remove an account from the pool |
|
|
57
55
|
|
|
@@ -61,11 +59,8 @@ oc-auth-switcher <command> [options]
|
|
|
61
59
|
# Set uniform threshold (default: 95%)
|
|
62
60
|
oc-auth-switcher config --threshold 0.95
|
|
63
61
|
|
|
64
|
-
# Set per-metric thresholds (5h, 7d, 7d-sonnet)
|
|
65
|
-
oc-auth-switcher config --thresholds 90,80,70
|
|
66
|
-
|
|
67
|
-
# Set primary recovery check interval (default: 60 min)
|
|
68
|
-
oc-auth-switcher config --interval 30
|
|
62
|
+
# Set per-metric thresholds (5h, 7d, 7d-sonnet, 7d-fable)
|
|
63
|
+
oc-auth-switcher config --thresholds 90,80,70,70
|
|
69
64
|
|
|
70
65
|
# Reset to defaults
|
|
71
66
|
oc-auth-switcher config --reset
|
|
@@ -82,12 +77,13 @@ Both files use atomic writes with `.bak` fallback for crash safety.
|
|
|
82
77
|
|
|
83
78
|
## Rotation Algorithm
|
|
84
79
|
|
|
85
|
-
-
|
|
86
|
-
- When
|
|
87
|
-
-
|
|
88
|
-
-
|
|
89
|
-
-
|
|
90
|
-
-
|
|
80
|
+
- Keep the active account while its model-relevant utilization remains below threshold
|
|
81
|
+
- When rotation is required, select the available account with the lowest maximum threshold-normalized utilization
|
|
82
|
+
- Break exact utilization ties by account array order
|
|
83
|
+
- If every account is exhausted, select the least-loaded account rather than failing
|
|
84
|
+
- Recovered accounts do not preempt a healthy active account
|
|
85
|
+
- Auth failures trigger a 10-minute cooldown per account
|
|
86
|
+
- Token refresh is handled automatically with retry and failover to other accounts
|
|
91
87
|
|
|
92
88
|
## Running the CLI
|
|
93
89
|
|
package/dist/cli.js
CHANGED
|
@@ -12,8 +12,8 @@ var configDir = path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(),
|
|
|
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
14
|
var DEFAULT_THRESHOLD = 0.95;
|
|
15
|
-
var DEFAULT_CHECK_INTERVAL = 60 * 60 * 1000;
|
|
16
15
|
var AUTH_FAILURE_COOLDOWN = 10 * 60 * 1000;
|
|
16
|
+
var REJECTION_FALLBACK_SECONDS = 60 * 60;
|
|
17
17
|
|
|
18
18
|
// node_modules/@ex-machina/opencode-anthropic-auth/dist/constants.js
|
|
19
19
|
var CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
|
@@ -159,13 +159,9 @@ var EMPTY_USAGE = {
|
|
|
159
159
|
function defaultState() {
|
|
160
160
|
return {
|
|
161
161
|
currentAccount: null,
|
|
162
|
-
selectionMode: "auto",
|
|
163
|
-
manualAccount: null,
|
|
164
|
-
lastRotationCheck: 0,
|
|
165
162
|
requestCount: 0,
|
|
166
163
|
config: {
|
|
167
|
-
threshold: DEFAULT_THRESHOLD
|
|
168
|
-
checkInterval: DEFAULT_CHECK_INTERVAL
|
|
164
|
+
threshold: DEFAULT_THRESHOLD
|
|
169
165
|
},
|
|
170
166
|
usage: {},
|
|
171
167
|
authFailures: {}
|
|
@@ -193,13 +189,9 @@ function normalizeState(raw) {
|
|
|
193
189
|
]));
|
|
194
190
|
return {
|
|
195
191
|
currentAccount: raw.currentAccount ?? defaults.currentAccount,
|
|
196
|
-
selectionMode: raw.selectionMode === "manual" && raw.manualAccount ? "manual" : "auto",
|
|
197
|
-
manualAccount: raw.manualAccount ?? defaults.manualAccount,
|
|
198
|
-
lastRotationCheck: raw.lastRotationCheck ?? defaults.lastRotationCheck,
|
|
199
192
|
requestCount: raw.requestCount ?? defaults.requestCount,
|
|
200
193
|
config: {
|
|
201
|
-
threshold: migratedThreshold
|
|
202
|
-
checkInterval: raw.config?.checkInterval ?? defaults.config.checkInterval
|
|
194
|
+
threshold: migratedThreshold
|
|
203
195
|
},
|
|
204
196
|
usage,
|
|
205
197
|
authFailures: raw.authFailures ?? defaults.authFailures
|
|
@@ -641,7 +633,6 @@ ${BOLD}${CYAN}=== Auth Switcher Configuration ===${RESET}
|
|
|
641
633
|
console.log(` Threshold (7d): ${(thresholds.weekly7d * 100).toFixed(0)}%`);
|
|
642
634
|
console.log(` Threshold (7d sonnet): ${(thresholds.weekly7dSonnet * 100).toFixed(0)}%`);
|
|
643
635
|
console.log(` Threshold (7d fable): ${(thresholds.weekly7dFable * 100).toFixed(0)}%`);
|
|
644
|
-
console.log(` Check interval: ${state.config.checkInterval / 60000} min`);
|
|
645
636
|
console.log();
|
|
646
637
|
console.log(`${DIM} Config file: ${STATE_FILE}${RESET}`);
|
|
647
638
|
console.log();
|
|
@@ -676,18 +667,9 @@ ${BOLD}${CYAN}=== Auth Switcher Configuration ===${RESET}
|
|
|
676
667
|
weekly7dFable: normalized[3] ?? currentFableThreshold
|
|
677
668
|
};
|
|
678
669
|
console.log(`${GREEN}Set per-metric thresholds: 5h=${(normalized[0] * 100).toFixed(0)}% 7d=${(normalized[1] * 100).toFixed(0)}% 7d-sonnet=${(normalized[2] * 100).toFixed(0)}% 7d-fable=${((normalized[3] ?? currentFableThreshold) * 100).toFixed(0)}%${RESET}`);
|
|
679
|
-
} else if (arg === "--interval" && args[i + 1]) {
|
|
680
|
-
const minutes = parseInt(args[++i], 10);
|
|
681
|
-
if (isNaN(minutes) || minutes < 1) {
|
|
682
|
-
console.error(`${RED}Interval must be a positive number of minutes${RESET}`);
|
|
683
|
-
process.exit(1);
|
|
684
|
-
}
|
|
685
|
-
state.config.checkInterval = minutes * 60 * 1000;
|
|
686
|
-
console.log(`${GREEN}Set check interval to ${minutes} minutes${RESET}`);
|
|
687
670
|
} else if (arg === "--reset") {
|
|
688
671
|
state.config.threshold = DEFAULT_THRESHOLD;
|
|
689
|
-
|
|
690
|
-
console.log(`${GREEN}Reset to defaults: threshold=${(DEFAULT_THRESHOLD * 100).toFixed(0)}% interval=${DEFAULT_CHECK_INTERVAL / 60000}min${RESET}`);
|
|
672
|
+
console.log(`${GREEN}Reset to default threshold: ${(DEFAULT_THRESHOLD * 100).toFixed(0)}%${RESET}`);
|
|
691
673
|
}
|
|
692
674
|
}
|
|
693
675
|
saveState(state);
|
|
@@ -707,20 +689,10 @@ ${BOLD}Available accounts:${RESET}`);
|
|
|
707
689
|
const tag = a.name === state2.currentAccount ? ` ${GREEN}[ACTIVE]${RESET}` : "";
|
|
708
690
|
console.log(` - ${a.name}${tag}`);
|
|
709
691
|
}
|
|
710
|
-
console.log(` - auto ${DIM}(resume automatic selection)${RESET}`);
|
|
711
692
|
console.error(`
|
|
712
|
-
${RED}Usage: oc-auth-switcher switch <account-name
|
|
693
|
+
${RED}Usage: oc-auth-switcher switch <account-name>${RESET}`);
|
|
713
694
|
process.exit(1);
|
|
714
695
|
}
|
|
715
|
-
if (name === "auto") {
|
|
716
|
-
const state2 = loadState();
|
|
717
|
-
state2.selectionMode = "auto";
|
|
718
|
-
state2.manualAccount = null;
|
|
719
|
-
state2.lastRotationCheck = Date.now();
|
|
720
|
-
saveState(state2);
|
|
721
|
-
console.log(`${GREEN}Automatic account selection resumed.${RESET}`);
|
|
722
|
-
return;
|
|
723
|
-
}
|
|
724
696
|
const account = data.accounts.find((a) => a.name === name);
|
|
725
697
|
if (!account) {
|
|
726
698
|
console.error(`${RED}Account "${name}" not found${RESET}`);
|
|
@@ -729,9 +701,6 @@ ${RED}Usage: oc-auth-switcher switch <account-name|auto>${RESET}`);
|
|
|
729
701
|
console.log(`${CYAN}Switching to account: ${name}...${RESET}`);
|
|
730
702
|
const state = loadState();
|
|
731
703
|
state.currentAccount = name;
|
|
732
|
-
state.selectionMode = "manual";
|
|
733
|
-
state.manualAccount = name;
|
|
734
|
-
state.lastRotationCheck = Date.now();
|
|
735
704
|
saveState(state);
|
|
736
705
|
console.log(`${GREEN}Switched to "${name}". Will take effect on the next API request.${RESET}`);
|
|
737
706
|
}
|
|
@@ -743,10 +712,8 @@ function cmdStatus() {
|
|
|
743
712
|
${BOLD}${CYAN}=== Auth Switcher Status ===${RESET}
|
|
744
713
|
`);
|
|
745
714
|
console.log(` Active account: ${BOLD}${state.currentAccount || "(none)"}${RESET}`);
|
|
746
|
-
console.log(` Selection mode: ${state.selectionMode}${state.manualAccount ? ` (${state.manualAccount})` : ""}`);
|
|
747
715
|
console.log(` Total accounts: ${data.accounts.length}`);
|
|
748
716
|
console.log(` Request count: ${state.requestCount}`);
|
|
749
|
-
console.log(` Last rotation: ${state.lastRotationCheck ? new Date(state.lastRotationCheck).toLocaleString() : "never"}`);
|
|
750
717
|
console.log();
|
|
751
718
|
const failures = Object.entries(state.authFailures).filter(([_, until]) => until > Date.now());
|
|
752
719
|
if (failures.length > 0) {
|
|
@@ -778,10 +745,6 @@ function cmdRemove(args) {
|
|
|
778
745
|
const state = loadState();
|
|
779
746
|
if (state.currentAccount === name) {
|
|
780
747
|
state.currentAccount = null;
|
|
781
|
-
if (state.manualAccount === name) {
|
|
782
|
-
state.selectionMode = "auto";
|
|
783
|
-
state.manualAccount = null;
|
|
784
|
-
}
|
|
785
748
|
saveState(state);
|
|
786
749
|
console.log(`${YELLOW}This was the active account. Rotation will pick a new one automatically.${RESET}`);
|
|
787
750
|
}
|
|
@@ -797,24 +760,22 @@ ${BOLD}COMMANDS:${RESET}
|
|
|
797
760
|
${CYAN}add${RESET} [name] Add a new Anthropic account via OAuth
|
|
798
761
|
${CYAN}reauth${RESET} <name> Re-authenticate an existing account
|
|
799
762
|
${CYAN}usage${RESET} [--watch] Show usage dashboard with utilization metrics
|
|
800
|
-
${CYAN}config${RESET} [options] View or modify threshold
|
|
801
|
-
${CYAN}switch${RESET} <name
|
|
763
|
+
${CYAN}config${RESET} [options] View or modify threshold configuration
|
|
764
|
+
${CYAN}switch${RESET} <name> Set the active account
|
|
802
765
|
${CYAN}status${RESET} Show current active account and rotation state
|
|
803
766
|
${CYAN}remove${RESET} <name> Remove an account from the pool
|
|
804
767
|
|
|
805
768
|
${BOLD}CONFIG OPTIONS:${RESET}
|
|
806
769
|
--threshold <0-1> Set uniform threshold (e.g., 0.90)
|
|
807
770
|
--thresholds <a,b,c[,d]> Set per-metric thresholds (5h,7d,7d-sonnet,7d-fable)
|
|
808
|
-
--interval <minutes> Set primary recovery check interval
|
|
809
771
|
--reset Reset to defaults
|
|
810
772
|
|
|
811
773
|
${BOLD}EXAMPLES:${RESET}
|
|
812
|
-
oc-auth-switcher add
|
|
813
|
-
oc-auth-switcher add
|
|
774
|
+
oc-auth-switcher add work
|
|
775
|
+
oc-auth-switcher add personal
|
|
814
776
|
oc-auth-switcher usage --watch
|
|
815
777
|
oc-auth-switcher config --threshold 0.90
|
|
816
|
-
oc-auth-switcher switch
|
|
817
|
-
oc-auth-switcher switch auto
|
|
778
|
+
oc-auth-switcher switch personal
|
|
818
779
|
`);
|
|
819
780
|
}
|
|
820
781
|
async function main() {
|
package/dist/index.js
CHANGED
|
@@ -414,8 +414,14 @@ var configDir = path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(),
|
|
|
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
416
|
var DEFAULT_THRESHOLD = 0.95;
|
|
417
|
-
var DEFAULT_CHECK_INTERVAL = 60 * 60 * 1000;
|
|
418
417
|
var AUTH_FAILURE_COOLDOWN = 10 * 60 * 1000;
|
|
418
|
+
var REJECTION_FALLBACK_SECONDS = 60 * 60;
|
|
419
|
+
var METRIC_MODEL_FAMILY = {
|
|
420
|
+
session5h: null,
|
|
421
|
+
weekly7d: null,
|
|
422
|
+
weekly7dSonnet: "sonnet",
|
|
423
|
+
weekly7dFable: "fable"
|
|
424
|
+
};
|
|
419
425
|
|
|
420
426
|
// src/accounts.ts
|
|
421
427
|
function normalizeAccount(raw) {
|
|
@@ -527,13 +533,9 @@ var EMPTY_USAGE = {
|
|
|
527
533
|
function defaultState() {
|
|
528
534
|
return {
|
|
529
535
|
currentAccount: null,
|
|
530
|
-
selectionMode: "auto",
|
|
531
|
-
manualAccount: null,
|
|
532
|
-
lastRotationCheck: 0,
|
|
533
536
|
requestCount: 0,
|
|
534
537
|
config: {
|
|
535
|
-
threshold: DEFAULT_THRESHOLD
|
|
536
|
-
checkInterval: DEFAULT_CHECK_INTERVAL
|
|
538
|
+
threshold: DEFAULT_THRESHOLD
|
|
537
539
|
},
|
|
538
540
|
usage: {},
|
|
539
541
|
authFailures: {}
|
|
@@ -561,13 +563,9 @@ function normalizeState(raw) {
|
|
|
561
563
|
]));
|
|
562
564
|
return {
|
|
563
565
|
currentAccount: raw.currentAccount ?? defaults.currentAccount,
|
|
564
|
-
selectionMode: raw.selectionMode === "manual" && raw.manualAccount ? "manual" : "auto",
|
|
565
|
-
manualAccount: raw.manualAccount ?? defaults.manualAccount,
|
|
566
|
-
lastRotationCheck: raw.lastRotationCheck ?? defaults.lastRotationCheck,
|
|
567
566
|
requestCount: raw.requestCount ?? defaults.requestCount,
|
|
568
567
|
config: {
|
|
569
|
-
threshold: migratedThreshold
|
|
570
|
-
checkInterval: raw.config?.checkInterval ?? defaults.config.checkInterval
|
|
568
|
+
threshold: migratedThreshold
|
|
571
569
|
},
|
|
572
570
|
usage,
|
|
573
571
|
authFailures: raw.authFailures ?? defaults.authFailures
|
|
@@ -708,7 +706,7 @@ function updateUsageFromHeaders(state, accountName, headers) {
|
|
|
708
706
|
if (!Number.isFinite(reset) || reset <= 0) {
|
|
709
707
|
const retryAfterHeader = headers.get("retry-after");
|
|
710
708
|
const retryAfter = retryAfterHeader === null ? NaN : Number(retryAfterHeader);
|
|
711
|
-
reset = Date.now() / 1000 + (Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter :
|
|
709
|
+
reset = Date.now() / 1000 + (Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : REJECTION_FALLBACK_SECONDS);
|
|
712
710
|
}
|
|
713
711
|
usage.rejected = {
|
|
714
712
|
utilization: 1,
|
|
@@ -735,53 +733,49 @@ function isTemporarilyUnavailable(state, accountName) {
|
|
|
735
733
|
}
|
|
736
734
|
return true;
|
|
737
735
|
}
|
|
738
|
-
function isOverThreshold(usage, state) {
|
|
736
|
+
function isOverThreshold(usage, state, modelFamily) {
|
|
739
737
|
if (!usage)
|
|
740
738
|
return false;
|
|
741
739
|
const thresholds = getThresholds(state.config);
|
|
742
|
-
|
|
740
|
+
if (usage.rejected.status?.toLowerCase() === "rejected" && isRejectionRelevant(usage.rejected.prefix, modelFamily))
|
|
741
|
+
return true;
|
|
742
|
+
return metricEntries(usage, thresholds, modelFamily).some((metric) => metric.threshold > 0 && metric.util >= metric.threshold);
|
|
743
743
|
}
|
|
744
|
-
function getUtilizationScore(usage, state) {
|
|
744
|
+
function getUtilizationScore(usage, state, modelFamily) {
|
|
745
745
|
if (!usage)
|
|
746
746
|
return 0;
|
|
747
747
|
const thresholds = getThresholds(state.config);
|
|
748
|
-
if (usage.rejected.status?.toLowerCase() === "rejected")
|
|
748
|
+
if (usage.rejected.status?.toLowerCase() === "rejected" && isRejectionRelevant(usage.rejected.prefix, modelFamily))
|
|
749
749
|
return Infinity;
|
|
750
|
-
const scores =
|
|
751
|
-
{ util: usage.session5h.utilization, threshold: thresholds.session5h },
|
|
752
|
-
{ util: usage.weekly7d.utilization, threshold: thresholds.weekly7d },
|
|
753
|
-
{ util: usage.weekly7dSonnet.utilization, threshold: thresholds.weekly7dSonnet },
|
|
754
|
-
{ util: usage.weekly7dFable.utilization, threshold: thresholds.weekly7dFable }
|
|
755
|
-
].filter((metric) => metric.threshold > 0).map((metric) => metric.util / metric.threshold);
|
|
750
|
+
const scores = metricEntries(usage, thresholds, modelFamily).filter((metric) => metric.threshold > 0).map((metric) => metric.util / metric.threshold);
|
|
756
751
|
return scores.length > 0 ? Math.max(...scores) : 0;
|
|
757
752
|
}
|
|
758
|
-
function
|
|
759
|
-
if (!
|
|
760
|
-
return
|
|
761
|
-
const
|
|
762
|
-
|
|
763
|
-
return usage.rejected.prefix ? `rejected rate limit (${usage.rejected.prefix})` : "rejected rate limit";
|
|
764
|
-
}
|
|
765
|
-
const metrics = [
|
|
766
|
-
{ name: "session5h", util: usage.session5h.utilization, thresh: thresholds.session5h },
|
|
767
|
-
{ name: "weekly7d", util: usage.weekly7d.utilization, thresh: thresholds.weekly7d },
|
|
768
|
-
{ name: "weekly7dSonnet", util: usage.weekly7dSonnet.utilization, thresh: thresholds.weekly7dSonnet },
|
|
769
|
-
{ name: "weekly7dFable", util: usage.weekly7dFable.utilization, thresh: thresholds.weekly7dFable }
|
|
770
|
-
];
|
|
771
|
-
const exceeded = metrics.filter((m) => m.thresh > 0 && m.util >= m.thresh).sort((a, b) => b.util / b.thresh - a.util / a.thresh);
|
|
772
|
-
return exceeded.length > 0 ? exceeded[0].name : null;
|
|
753
|
+
function getModelFamily(model) {
|
|
754
|
+
if (!model)
|
|
755
|
+
return;
|
|
756
|
+
const normalized = model.toLowerCase();
|
|
757
|
+
return ["fable", "sonnet", "opus"].find((family) => normalized.includes(family));
|
|
773
758
|
}
|
|
774
|
-
function
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
759
|
+
function isMetricRelevant(metric, modelFamily) {
|
|
760
|
+
const metricFamily = METRIC_MODEL_FAMILY[metric];
|
|
761
|
+
return modelFamily === undefined || metricFamily === null || metricFamily === modelFamily;
|
|
762
|
+
}
|
|
763
|
+
function rejectionFamily(prefix) {
|
|
764
|
+
if (!prefix)
|
|
765
|
+
return;
|
|
766
|
+
const normalized = prefix.toLowerCase();
|
|
767
|
+
return ["fable", "sonnet", "opus"].find((family) => normalized === `7d_${family}` || normalized === `anthropic-ratelimit-unified-7d_${family}`);
|
|
768
|
+
}
|
|
769
|
+
function isRejectionRelevant(prefix, modelFamily) {
|
|
770
|
+
const rejectedFamily = rejectionFamily(prefix);
|
|
771
|
+
return modelFamily === undefined || rejectedFamily === undefined || rejectedFamily === modelFamily;
|
|
772
|
+
}
|
|
773
|
+
function metricEntries(usage, thresholds, modelFamily) {
|
|
774
|
+
return Object.keys(METRIC_MODEL_FAMILY).filter((key) => isMetricRelevant(key, modelFamily)).map((key) => ({
|
|
775
|
+
name: key,
|
|
776
|
+
util: usage[key].utilization,
|
|
777
|
+
threshold: thresholds[key]
|
|
778
|
+
}));
|
|
785
779
|
}
|
|
786
780
|
function purgeExpiredCooldowns(state) {
|
|
787
781
|
const now = Date.now();
|
|
@@ -791,24 +785,32 @@ function purgeExpiredCooldowns(state) {
|
|
|
791
785
|
}
|
|
792
786
|
}
|
|
793
787
|
}
|
|
794
|
-
function findBestAvailable(candidates, state, exclude) {
|
|
788
|
+
function findBestAvailable(candidates, state, exclude, modelFamily) {
|
|
789
|
+
let best = null;
|
|
790
|
+
let bestScore = Infinity;
|
|
795
791
|
for (const acct of candidates) {
|
|
796
792
|
if (exclude.has(acct.name))
|
|
797
793
|
continue;
|
|
798
794
|
if (isTemporarilyUnavailable(state, acct.name))
|
|
799
795
|
continue;
|
|
800
|
-
if (!isOverThreshold(state.usage[acct.name], state)) {
|
|
801
|
-
|
|
796
|
+
if (!isOverThreshold(state.usage[acct.name], state, modelFamily)) {
|
|
797
|
+
const score = getUtilizationScore(state.usage[acct.name], state, modelFamily);
|
|
798
|
+
if (!best || score < bestScore) {
|
|
799
|
+
bestScore = score;
|
|
800
|
+
best = acct;
|
|
801
|
+
}
|
|
802
802
|
}
|
|
803
803
|
}
|
|
804
|
-
|
|
805
|
-
|
|
804
|
+
if (best)
|
|
805
|
+
return best;
|
|
806
|
+
best = null;
|
|
807
|
+
bestScore = Infinity;
|
|
806
808
|
for (const acct of candidates) {
|
|
807
809
|
if (exclude.has(acct.name))
|
|
808
810
|
continue;
|
|
809
811
|
if (isTemporarilyUnavailable(state, acct.name))
|
|
810
812
|
continue;
|
|
811
|
-
const score = getUtilizationScore(state.usage[acct.name], state);
|
|
813
|
+
const score = getUtilizationScore(state.usage[acct.name], state, modelFamily);
|
|
812
814
|
if (!best || score < bestScore) {
|
|
813
815
|
bestScore = score;
|
|
814
816
|
best = acct;
|
|
@@ -816,79 +818,29 @@ function findBestAvailable(candidates, state, exclude) {
|
|
|
816
818
|
}
|
|
817
819
|
return best;
|
|
818
820
|
}
|
|
819
|
-
function selectAccount(accounts, state) {
|
|
821
|
+
function selectAccount(accounts, state, model) {
|
|
820
822
|
if (accounts.length === 0) {
|
|
821
823
|
throw new Error("No accounts available");
|
|
822
824
|
}
|
|
823
825
|
purgeExpiredCooldowns(state);
|
|
824
|
-
|
|
825
|
-
const manual = accounts.find((account) => account.name === state.manualAccount);
|
|
826
|
-
if (manual) {
|
|
827
|
-
const unavailable = isTemporarilyUnavailable(state, manual.name);
|
|
828
|
-
const exhausted = isOverThreshold(state.usage[manual.name], state);
|
|
829
|
-
if (!unavailable && !exhausted) {
|
|
830
|
-
return {
|
|
831
|
-
account: manual,
|
|
832
|
-
switched: state.currentAccount !== manual.name,
|
|
833
|
-
reason: state.currentAccount !== manual.name ? `Manual selection — switching to ${manual.name}` : undefined
|
|
834
|
-
};
|
|
835
|
-
}
|
|
836
|
-
state.currentAccount = manual.name;
|
|
837
|
-
}
|
|
838
|
-
state.selectionMode = "auto";
|
|
839
|
-
state.manualAccount = null;
|
|
840
|
-
}
|
|
841
|
-
if (accounts.length === 1) {
|
|
842
|
-
return { account: accounts[0], switched: false };
|
|
843
|
-
}
|
|
844
|
-
const primary = accounts[0];
|
|
845
|
-
const fallbacks = accounts.slice(1);
|
|
846
|
-
if (!state.currentAccount) {
|
|
847
|
-
return {
|
|
848
|
-
account: primary,
|
|
849
|
-
switched: true,
|
|
850
|
-
reason: "Initial selection — using primary account"
|
|
851
|
-
};
|
|
852
|
-
}
|
|
826
|
+
const modelFamily = getModelFamily(model);
|
|
853
827
|
const currentIdx = accounts.findIndex((a) => a.name === state.currentAccount);
|
|
854
828
|
if (currentIdx < 0) {
|
|
829
|
+
const best = findBestAvailable(accounts, state, new Set, modelFamily);
|
|
830
|
+
const selected = best ?? accounts[0];
|
|
855
831
|
return {
|
|
856
|
-
account:
|
|
832
|
+
account: selected,
|
|
857
833
|
switched: true,
|
|
858
|
-
reason:
|
|
834
|
+
reason: `Selecting account ${selected.name}`
|
|
859
835
|
};
|
|
860
836
|
}
|
|
861
837
|
const current = accounts[currentIdx];
|
|
862
838
|
const currentUsage = state.usage[current.name];
|
|
863
|
-
const
|
|
864
|
-
const isPrimary = current.name === primary.name;
|
|
865
|
-
if (isPrimary) {
|
|
866
|
-
if (isOverThreshold(primaryUsage, state)) {
|
|
867
|
-
const exceededMetric = getExceededMetric(primaryUsage, state);
|
|
868
|
-
const best = findBestAvailable(fallbacks, state, new Set);
|
|
869
|
-
if (best) {
|
|
870
|
-
return {
|
|
871
|
-
account: best,
|
|
872
|
-
switched: true,
|
|
873
|
-
reason: `Primary exceeded ${exceededMetric} threshold — switching to ${best.name}`
|
|
874
|
-
};
|
|
875
|
-
}
|
|
876
|
-
return { account: primary, switched: false };
|
|
877
|
-
}
|
|
878
|
-
return { account: primary, switched: false };
|
|
879
|
-
}
|
|
880
|
-
const currentOverThreshold = isOverThreshold(currentUsage, state);
|
|
839
|
+
const currentOverThreshold = isOverThreshold(currentUsage, state, modelFamily);
|
|
881
840
|
const currentInCooldown = isTemporarilyUnavailable(state, current.name);
|
|
882
841
|
if (currentOverThreshold || currentInCooldown) {
|
|
883
842
|
const reason = currentInCooldown ? `${current.name} in auth-failure cooldown` : `${current.name} exceeded threshold`;
|
|
884
|
-
|
|
885
|
-
return {
|
|
886
|
-
account: primary,
|
|
887
|
-
switched: true,
|
|
888
|
-
reason: `${reason} — switching back to primary`
|
|
889
|
-
};
|
|
890
|
-
}
|
|
891
|
-
const best = findBestAvailable(accounts, state, new Set([current.name]));
|
|
843
|
+
const best = findBestAvailable(accounts, state, new Set([current.name]), modelFamily);
|
|
892
844
|
if (best && best.name !== current.name) {
|
|
893
845
|
return {
|
|
894
846
|
account: best,
|
|
@@ -898,29 +850,10 @@ function selectAccount(accounts, state) {
|
|
|
898
850
|
}
|
|
899
851
|
return { account: current, switched: false };
|
|
900
852
|
}
|
|
901
|
-
const now = Date.now();
|
|
902
|
-
const checkInterval = state.config.checkInterval;
|
|
903
|
-
const earliestReset = getEarliestReset(primaryUsage);
|
|
904
|
-
const timeSinceLastCheck = now - state.lastRotationCheck;
|
|
905
|
-
const shouldCheckPrimary = earliestReset > 0 && earliestReset <= now || timeSinceLastCheck >= checkInterval;
|
|
906
|
-
if (shouldCheckPrimary) {
|
|
907
|
-
state.lastRotationCheck = now;
|
|
908
|
-
if (!isOverThreshold(primaryUsage, state) && !isTemporarilyUnavailable(state, primary.name)) {
|
|
909
|
-
return {
|
|
910
|
-
account: primary,
|
|
911
|
-
switched: true,
|
|
912
|
-
reason: "Primary has recovered — switching back"
|
|
913
|
-
};
|
|
914
|
-
}
|
|
915
|
-
}
|
|
916
853
|
return { account: current, switched: false };
|
|
917
854
|
}
|
|
918
855
|
function markAuthFailure(state, accountName) {
|
|
919
856
|
state.authFailures[accountName] = Date.now() + AUTH_FAILURE_COOLDOWN;
|
|
920
|
-
if (state.selectionMode === "manual" && state.manualAccount === accountName) {
|
|
921
|
-
state.selectionMode = "auto";
|
|
922
|
-
state.manualAccount = null;
|
|
923
|
-
}
|
|
924
857
|
}
|
|
925
858
|
function clearAuthFailure(state, accountName) {
|
|
926
859
|
delete state.authFailures[accountName];
|
|
@@ -929,23 +862,27 @@ function clearAuthFailure(state, accountName) {
|
|
|
929
862
|
// src/index.ts
|
|
930
863
|
function selectionSnapshot(state) {
|
|
931
864
|
return {
|
|
932
|
-
|
|
933
|
-
manualAccount: state.manualAccount,
|
|
934
|
-
currentAccount: state.currentAccount,
|
|
935
|
-
lastRotationCheck: state.lastRotationCheck
|
|
865
|
+
currentAccount: state.currentAccount
|
|
936
866
|
};
|
|
937
867
|
}
|
|
938
868
|
function saveRequestState(state, initiallyLoaded) {
|
|
939
869
|
const onDisk = loadState();
|
|
940
|
-
const selectionChangedSinceLoad = onDisk.
|
|
870
|
+
const selectionChangedSinceLoad = onDisk.currentAccount !== initiallyLoaded.currentAccount;
|
|
941
871
|
if (selectionChangedSinceLoad) {
|
|
942
|
-
state.selectionMode = onDisk.selectionMode;
|
|
943
|
-
state.manualAccount = onDisk.manualAccount;
|
|
944
872
|
state.currentAccount = onDisk.currentAccount;
|
|
945
|
-
state.lastRotationCheck = onDisk.lastRotationCheck;
|
|
946
873
|
}
|
|
947
874
|
saveState(state);
|
|
948
875
|
}
|
|
876
|
+
function getRequestModel(body) {
|
|
877
|
+
if (typeof body !== "string")
|
|
878
|
+
return;
|
|
879
|
+
try {
|
|
880
|
+
const parsed = JSON.parse(body);
|
|
881
|
+
return typeof parsed.model === "string" ? parsed.model : undefined;
|
|
882
|
+
} catch {
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
949
886
|
var AuthSwitcherPlugin = async ({ client }) => {
|
|
950
887
|
return {
|
|
951
888
|
auth: {
|
|
@@ -1014,7 +951,8 @@ var AuthSwitcherPlugin = async ({ client }) => {
|
|
|
1014
951
|
}
|
|
1015
952
|
ensureAccountsInState(state, accounts.map((a) => a.name));
|
|
1016
953
|
resolveStaleMetrics(state);
|
|
1017
|
-
const
|
|
954
|
+
const model = getRequestModel(init?.body);
|
|
955
|
+
const selection = selectAccount(accounts, state, model);
|
|
1018
956
|
let account = selection.account;
|
|
1019
957
|
if (selection.switched) {
|
|
1020
958
|
await client.app.log({
|
|
@@ -1038,7 +976,8 @@ var AuthSwitcherPlugin = async ({ client }) => {
|
|
|
1038
976
|
updateAccountTokens(account.name, result.access, result.refresh, result.expires);
|
|
1039
977
|
} else {
|
|
1040
978
|
markAuthFailure(state, account.name);
|
|
1041
|
-
const
|
|
979
|
+
const available = accounts.filter((candidate) => !attemptedAccounts.has(candidate.name) && (!state.authFailures[candidate.name] || state.authFailures[candidate.name] <= Date.now()));
|
|
980
|
+
const next = available.length > 0 ? selectAccount(available, state, model).account : undefined;
|
|
1042
981
|
if (!next) {
|
|
1043
982
|
saveRequestState(state, initiallyLoadedSelection);
|
|
1044
983
|
throw new Error(`[oc-auth-switcher] All accounts failed token refresh`);
|
|
@@ -1070,7 +1009,8 @@ var AuthSwitcherPlugin = async ({ client }) => {
|
|
|
1070
1009
|
const isScopeError = errorBody.includes("scope") || errorBody.includes("unauthorized") || errorBody.includes("invalid");
|
|
1071
1010
|
if (isScopeError) {
|
|
1072
1011
|
markAuthFailure(state, account.name);
|
|
1073
|
-
const
|
|
1012
|
+
const available = accounts.filter((candidate) => !attemptedAccounts.has(candidate.name) && (!state.authFailures[candidate.name] || state.authFailures[candidate.name] <= Date.now()));
|
|
1013
|
+
const next = available.length > 0 ? selectAccount(available, state, model).account : undefined;
|
|
1074
1014
|
if (next) {
|
|
1075
1015
|
attemptedAccounts.add("__retried__");
|
|
1076
1016
|
account = next;
|