oc-auth-switcher 0.7.2 → 0.8.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 +1 -1
- package/dist/cli.js +22 -21
- package/dist/index.js +137 -57
- package/package.json +1 -1
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
|
@@ -48,6 +48,11 @@ var OAUTH_SCOPES = [
|
|
|
48
48
|
];
|
|
49
49
|
|
|
50
50
|
// src/accounts.ts
|
|
51
|
+
function isAccessTokenFresh(account, nowMs = Date.now()) {
|
|
52
|
+
if (account.expires == null || Number.isNaN(account.expires))
|
|
53
|
+
return true;
|
|
54
|
+
return !!account.access && account.expires > nowMs;
|
|
55
|
+
}
|
|
51
56
|
function normalizeAccount(raw) {
|
|
52
57
|
const name = raw.name || "unnamed";
|
|
53
58
|
const access = raw.access || raw.accessToken || "";
|
|
@@ -371,7 +376,7 @@ function purgeExpiredCooldowns(state) {
|
|
|
371
376
|
}
|
|
372
377
|
}
|
|
373
378
|
}
|
|
374
|
-
function
|
|
379
|
+
function pickByScore(candidates, state, exclude, modelFamily, predicate) {
|
|
375
380
|
let best = null;
|
|
376
381
|
let bestScore = Infinity;
|
|
377
382
|
for (const acct of candidates) {
|
|
@@ -379,22 +384,7 @@ function findBestAvailable(candidates, state, exclude, modelFamily) {
|
|
|
379
384
|
continue;
|
|
380
385
|
if (isTemporarilyUnavailable(state, acct.name))
|
|
381
386
|
continue;
|
|
382
|
-
if (!
|
|
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))
|
|
387
|
+
if (!predicate(acct))
|
|
398
388
|
continue;
|
|
399
389
|
const score = getUtilizationScore(state.usage[acct.name], state, modelFamily);
|
|
400
390
|
if (!best || score < bestScore) {
|
|
@@ -404,6 +394,12 @@ function findBestAvailable(candidates, state, exclude, modelFamily) {
|
|
|
404
394
|
}
|
|
405
395
|
return best;
|
|
406
396
|
}
|
|
397
|
+
function findBestAvailable(candidates, state, exclude, modelFamily) {
|
|
398
|
+
const underThreshold = (acct) => !isOverThreshold(state.usage[acct.name], state, modelFamily);
|
|
399
|
+
const fresh = (acct) => isAccessTokenFresh(acct);
|
|
400
|
+
const stale = (acct) => !isAccessTokenFresh(acct);
|
|
401
|
+
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);
|
|
402
|
+
}
|
|
407
403
|
function selectAccount(accounts, state, model) {
|
|
408
404
|
if (accounts.length === 0) {
|
|
409
405
|
throw new Error("No accounts available");
|
|
@@ -692,6 +688,11 @@ function progressBar(value, threshold, width = 30) {
|
|
|
692
688
|
const label = `${(value * 100).toFixed(1)}%`;
|
|
693
689
|
return `${bar} ${color}${label}${RESET}`;
|
|
694
690
|
}
|
|
691
|
+
function resetSuffix(metric, now) {
|
|
692
|
+
if (!metric.reset || metric.reset * 1000 <= now)
|
|
693
|
+
return "";
|
|
694
|
+
return ` ${DIM}resets ${formatRelativeDuration(metric.reset * 1000, now)}${RESET}`;
|
|
695
|
+
}
|
|
695
696
|
function tryCopy(cmd, args, text) {
|
|
696
697
|
return new Promise((resolve) => {
|
|
697
698
|
try {
|
|
@@ -886,10 +887,10 @@ ${BOLD}${CYAN}=== Auth Switcher Usage Dashboard ===${RESET}
|
|
|
886
887
|
const tag = isActive ? `${GREEN} [ACTIVE]${RESET}` : isCooling ? `${RED} [COOLDOWN]${RESET}` : "";
|
|
887
888
|
console.log(` ${BOLD}${account.name}${RESET}${tag}`);
|
|
888
889
|
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)}`);
|
|
890
|
+
console.log(` 5h session: ${progressBar(usage.session5h.utilization, thresholds.session5h)}${resetSuffix(usage.session5h, now)}`);
|
|
891
|
+
console.log(` 7d weekly: ${progressBar(usage.weekly7d.utilization, thresholds.weekly7d)}${resetSuffix(usage.weekly7d, now)}`);
|
|
892
|
+
console.log(` 7d sonnet: ${progressBar(usage.weekly7dSonnet.utilization, thresholds.weekly7dSonnet)}${resetSuffix(usage.weekly7dSonnet, now)}`);
|
|
893
|
+
console.log(` 7d fable: ${progressBar(usage.weekly7dFable.utilization, thresholds.weekly7dFable)}${resetSuffix(usage.weekly7dFable, now)}`);
|
|
893
894
|
const rejection = deriveAccountHealth(account, usage, thresholds, state.authFailures[account.name], now).reasons.find((reason) => reason.kind === "rejection");
|
|
894
895
|
if (rejection) {
|
|
895
896
|
const reset = rejection.reset ? ` \u2014 resets ${formatResetTime(rejection.reset, now)}` : "";
|
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
|
|
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 (!
|
|
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
|
|
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
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
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(
|
|
1175
|
+
return createStrippedStream(finalResponse);
|
|
1096
1176
|
}
|
|
1097
1177
|
};
|
|
1098
1178
|
},
|