oc-auth-switcher 0.7.2 → 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.
- package/README.md +1 -1
- package/dist/cli.js +273 -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
|
@@ -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";
|
|
@@ -48,6 +51,11 @@ var OAUTH_SCOPES = [
|
|
|
48
51
|
];
|
|
49
52
|
|
|
50
53
|
// src/accounts.ts
|
|
54
|
+
function isAccessTokenFresh(account, nowMs = Date.now()) {
|
|
55
|
+
if (account.expires == null || Number.isNaN(account.expires))
|
|
56
|
+
return true;
|
|
57
|
+
return !!account.access && account.expires > nowMs;
|
|
58
|
+
}
|
|
51
59
|
function normalizeAccount(raw) {
|
|
52
60
|
const name = raw.name || "unnamed";
|
|
53
61
|
const access = raw.access || raw.accessToken || "";
|
|
@@ -255,6 +263,20 @@ function loadState(stateFile = STATE_FILE) {
|
|
|
255
263
|
function saveState(state) {
|
|
256
264
|
return safeWriteJSON(STATE_FILE, state);
|
|
257
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
|
+
}
|
|
258
280
|
function getThresholds(config) {
|
|
259
281
|
if (typeof config.threshold === "number") {
|
|
260
282
|
return {
|
|
@@ -307,6 +329,99 @@ function ensureAccountsInState(state, accountNames) {
|
|
|
307
329
|
}
|
|
308
330
|
}
|
|
309
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
|
+
}
|
|
310
425
|
|
|
311
426
|
// src/rotation.ts
|
|
312
427
|
function isTemporarilyUnavailable(state, accountName) {
|
|
@@ -371,7 +486,7 @@ function purgeExpiredCooldowns(state) {
|
|
|
371
486
|
}
|
|
372
487
|
}
|
|
373
488
|
}
|
|
374
|
-
function
|
|
489
|
+
function pickByScore(candidates, state, exclude, modelFamily, predicate) {
|
|
375
490
|
let best = null;
|
|
376
491
|
let bestScore = Infinity;
|
|
377
492
|
for (const acct of candidates) {
|
|
@@ -379,22 +494,7 @@ function findBestAvailable(candidates, state, exclude, modelFamily) {
|
|
|
379
494
|
continue;
|
|
380
495
|
if (isTemporarilyUnavailable(state, acct.name))
|
|
381
496
|
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))
|
|
497
|
+
if (!predicate(acct))
|
|
398
498
|
continue;
|
|
399
499
|
const score = getUtilizationScore(state.usage[acct.name], state, modelFamily);
|
|
400
500
|
if (!best || score < bestScore) {
|
|
@@ -404,6 +504,12 @@ function findBestAvailable(candidates, state, exclude, modelFamily) {
|
|
|
404
504
|
}
|
|
405
505
|
return best;
|
|
406
506
|
}
|
|
507
|
+
function findBestAvailable(candidates, state, exclude, modelFamily) {
|
|
508
|
+
const underThreshold = (acct) => !isOverThreshold(state.usage[acct.name], state, modelFamily);
|
|
509
|
+
const fresh = (acct) => isAccessTokenFresh(acct);
|
|
510
|
+
const stale = (acct) => !isAccessTokenFresh(acct);
|
|
511
|
+
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);
|
|
512
|
+
}
|
|
407
513
|
function selectAccount(accounts, state, model) {
|
|
408
514
|
if (accounts.length === 0) {
|
|
409
515
|
throw new Error("No accounts available");
|
|
@@ -438,6 +544,9 @@ function selectAccount(accounts, state, model) {
|
|
|
438
544
|
}
|
|
439
545
|
return { account: current, switched: false };
|
|
440
546
|
}
|
|
547
|
+
function markAuthFailure(state, accountName) {
|
|
548
|
+
state.authFailures[accountName] = Date.now() + AUTH_FAILURE_COOLDOWN;
|
|
549
|
+
}
|
|
441
550
|
function clearAuthFailure(state, accountName) {
|
|
442
551
|
delete state.authFailures[accountName];
|
|
443
552
|
}
|
|
@@ -692,6 +801,11 @@ function progressBar(value, threshold, width = 30) {
|
|
|
692
801
|
const label = `${(value * 100).toFixed(1)}%`;
|
|
693
802
|
return `${bar} ${color}${label}${RESET}`;
|
|
694
803
|
}
|
|
804
|
+
function resetSuffix(metric, now) {
|
|
805
|
+
if (!metric.reset || metric.reset * 1000 <= now)
|
|
806
|
+
return "";
|
|
807
|
+
return ` ${DIM}resets ${formatRelativeDuration(metric.reset * 1000, now)}${RESET}`;
|
|
808
|
+
}
|
|
695
809
|
function tryCopy(cmd, args, text) {
|
|
696
810
|
return new Promise((resolve) => {
|
|
697
811
|
try {
|
|
@@ -853,6 +967,132 @@ async function refreshExpiredAccounts(data, state) {
|
|
|
853
967
|
}
|
|
854
968
|
}
|
|
855
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
|
+
}
|
|
856
1096
|
async function cmdUsage(args) {
|
|
857
1097
|
const watch = args.includes("--watch") || args.includes("-w");
|
|
858
1098
|
const showUsage = async () => {
|
|
@@ -886,10 +1126,10 @@ ${BOLD}${CYAN}=== Auth Switcher Usage Dashboard ===${RESET}
|
|
|
886
1126
|
const tag = isActive ? `${GREEN} [ACTIVE]${RESET}` : isCooling ? `${RED} [COOLDOWN]${RESET}` : "";
|
|
887
1127
|
console.log(` ${BOLD}${account.name}${RESET}${tag}`);
|
|
888
1128
|
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)}`);
|
|
1129
|
+
console.log(` 5h session: ${progressBar(usage.session5h.utilization, thresholds.session5h)}${resetSuffix(usage.session5h, now)}`);
|
|
1130
|
+
console.log(` 7d weekly: ${progressBar(usage.weekly7d.utilization, thresholds.weekly7d)}${resetSuffix(usage.weekly7d, now)}`);
|
|
1131
|
+
console.log(` 7d sonnet: ${progressBar(usage.weekly7dSonnet.utilization, thresholds.weekly7dSonnet)}${resetSuffix(usage.weekly7dSonnet, now)}`);
|
|
1132
|
+
console.log(` 7d fable: ${progressBar(usage.weekly7dFable.utilization, thresholds.weekly7dFable)}${resetSuffix(usage.weekly7dFable, now)}`);
|
|
893
1133
|
const rejection = deriveAccountHealth(account, usage, thresholds, state.authFailures[account.name], now).reasons.find((reason) => reason.kind === "rejection");
|
|
894
1134
|
if (rejection) {
|
|
895
1135
|
const reset = rejection.reset ? ` \u2014 resets ${formatResetTime(rejection.reset, now)}` : "";
|
|
@@ -908,6 +1148,18 @@ ${BOLD}${CYAN}=== Auth Switcher Usage Dashboard ===${RESET}
|
|
|
908
1148
|
console.log();
|
|
909
1149
|
}
|
|
910
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
|
+
}
|
|
911
1163
|
await showUsage();
|
|
912
1164
|
if (watch) {
|
|
913
1165
|
console.log(`${DIM}Refreshing every 5 seconds. Press Ctrl+C to stop.${RESET}
|
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
|
},
|