oc-chatgpt-multi-auth 4.12.3 → 4.13.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/LICENSE +0 -36
- package/README.md +35 -5
- package/config/opencode-modern.json +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +339 -218
- package/dist/index.js.map +1 -1
- package/dist/lib/accounts.d.ts +2 -1
- package/dist/lib/accounts.d.ts.map +1 -1
- package/dist/lib/accounts.js +2 -2
- package/dist/lib/accounts.js.map +1 -1
- package/dist/lib/config.d.ts +5 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +20 -0
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/prompts/codex-opencode-bridge.d.ts +1 -1
- package/dist/lib/prompts/codex-opencode-bridge.d.ts.map +1 -1
- package/dist/lib/prompts/codex-opencode-bridge.js +2 -0
- package/dist/lib/prompts/codex-opencode-bridge.js.map +1 -1
- package/dist/lib/prompts/codex.d.ts +1 -1
- package/dist/lib/prompts/codex.d.ts.map +1 -1
- package/dist/lib/prompts/codex.js +5 -0
- package/dist/lib/prompts/codex.js.map +1 -1
- package/dist/lib/request/fetch-helpers.d.ts +15 -2
- package/dist/lib/request/fetch-helpers.d.ts.map +1 -1
- package/dist/lib/request/fetch-helpers.js +69 -9
- package/dist/lib/request/fetch-helpers.js.map +1 -1
- package/dist/lib/request/request-transformer.d.ts.map +1 -1
- package/dist/lib/request/request-transformer.js +94 -7
- package/dist/lib/request/request-transformer.js.map +1 -1
- package/dist/lib/request/response-handler.d.ts +10 -1
- package/dist/lib/request/response-handler.d.ts.map +1 -1
- package/dist/lib/request/response-handler.js +51 -2
- package/dist/lib/request/response-handler.js.map +1 -1
- package/dist/lib/rotation.d.ts +4 -1
- package/dist/lib/rotation.d.ts.map +1 -1
- package/dist/lib/rotation.js +9 -12
- package/dist/lib/rotation.js.map +1 -1
- package/dist/lib/schemas.d.ts +5 -0
- package/dist/lib/schemas.d.ts.map +1 -1
- package/dist/lib/schemas.js +5 -0
- package/dist/lib/schemas.js.map +1 -1
- package/dist/lib/storage/paths.d.ts +6 -0
- package/dist/lib/storage/paths.d.ts.map +1 -1
- package/dist/lib/storage/paths.js +32 -1
- package/dist/lib/storage/paths.js.map +1 -1
- package/dist/lib/storage.d.ts.map +1 -1
- package/dist/lib/storage.js +42 -3
- package/dist/lib/storage.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -28,15 +28,16 @@ import { queuedRefresh } from "./lib/refresh-queue.js";
|
|
|
28
28
|
import { openBrowserUrl } from "./lib/auth/browser.js";
|
|
29
29
|
import { startLocalOAuthServer } from "./lib/auth/server.js";
|
|
30
30
|
import { promptLoginMode } from "./lib/cli.js";
|
|
31
|
-
import { getCodexMode, getRateLimitToastDebounceMs, getRetryAllAccountsMaxRetries, getRetryAllAccountsMaxWaitMs, getRetryAllAccountsRateLimited, getTokenRefreshSkewMs, getSessionRecovery, getAutoResume, getToastDurationMs, getPerProjectAccounts, loadPluginConfig, } from "./lib/config.js";
|
|
31
|
+
import { getCodexMode, getRateLimitToastDebounceMs, getRetryAllAccountsMaxRetries, getRetryAllAccountsMaxWaitMs, getRetryAllAccountsRateLimited, getTokenRefreshSkewMs, getSessionRecovery, getAutoResume, getToastDurationMs, getPerProjectAccounts, getEmptyResponseMaxRetries, getEmptyResponseRetryDelayMs, getPidOffsetEnabled, getFetchTimeoutMs, getStreamStallTimeoutMs, loadPluginConfig, } from "./lib/config.js";
|
|
32
32
|
import { AUTH_LABELS, CODEX_BASE_URL, DUMMY_API_KEY, LOG_STAGES, PLUGIN_NAME, PROVIDER_ID, ACCOUNT_LIMITS, } from "./lib/constants.js";
|
|
33
|
-
import { initLogger, logRequest, logDebug, logInfo, logWarn, logError } from "./lib/logger.js";
|
|
33
|
+
import { initLogger, logRequest, logDebug, logInfo, logWarn, logError, setCorrelationId, clearCorrelationId, } from "./lib/logger.js";
|
|
34
34
|
import { checkAndNotify } from "./lib/auto-update-checker.js";
|
|
35
35
|
import { handleContextOverflow } from "./lib/context-overflow.js";
|
|
36
36
|
import { AccountManager, getAccountIdCandidates, extractAccountEmail, extractAccountId, formatAccountLabel, formatCooldown, formatWaitTime, sanitizeEmail, shouldUpdateAccountIdFromToken, parseRateLimitReason, } from "./lib/accounts.js";
|
|
37
37
|
import { getStoragePath, loadAccounts, saveAccounts, setStoragePath, exportAccounts, importAccounts, StorageError, formatStorageErrorHint } from "./lib/storage.js";
|
|
38
38
|
import { createCodexHeaders, extractRequestUrl, handleErrorResponse, handleSuccessResponse, refreshAndUpdateToken, rewriteUrlForCodex, shouldRefreshToken, transformRequestForCodex, } from "./lib/request/fetch-helpers.js";
|
|
39
39
|
import { getRateLimitBackoff, RATE_LIMIT_SHORT_RETRY_THRESHOLD_MS, resetRateLimitBackoff, } from "./lib/request/rate-limit-backoff.js";
|
|
40
|
+
import { isEmptyResponse } from "./lib/request/response-handler.js";
|
|
40
41
|
import { addJitter } from "./lib/rotation.js";
|
|
41
42
|
import { buildTableHeader, buildTableRow } from "./lib/table-formatter.js";
|
|
42
43
|
import { getModelFamily, MODEL_FAMILIES } from "./lib/prompts/codex.js";
|
|
@@ -64,6 +65,21 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
64
65
|
let accountManagerPromise = null;
|
|
65
66
|
let loaderMutex = null;
|
|
66
67
|
const MIN_BACKOFF_MS = 100;
|
|
68
|
+
const runtimeMetrics = {
|
|
69
|
+
startedAt: Date.now(),
|
|
70
|
+
totalRequests: 0,
|
|
71
|
+
successfulRequests: 0,
|
|
72
|
+
failedRequests: 0,
|
|
73
|
+
rateLimitedResponses: 0,
|
|
74
|
+
serverErrors: 0,
|
|
75
|
+
networkErrors: 0,
|
|
76
|
+
authRefreshFailures: 0,
|
|
77
|
+
emptyResponseRetries: 0,
|
|
78
|
+
accountRotations: 0,
|
|
79
|
+
cumulativeLatencyMs: 0,
|
|
80
|
+
lastRequestAt: null,
|
|
81
|
+
lastError: null,
|
|
82
|
+
};
|
|
67
83
|
const resolveAccountSelection = (tokens) => {
|
|
68
84
|
const override = (process.env.CODEX_AUTH_ACCOUNT_ID ?? "").trim();
|
|
69
85
|
if (override) {
|
|
@@ -487,11 +503,16 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
487
503
|
const retryAllAccountsMaxRetries = getRetryAllAccountsMaxRetries(pluginConfig);
|
|
488
504
|
const toastDurationMs = getToastDurationMs(pluginConfig);
|
|
489
505
|
const perProjectAccounts = getPerProjectAccounts(pluginConfig);
|
|
506
|
+
const fetchTimeoutMs = getFetchTimeoutMs(pluginConfig);
|
|
507
|
+
const streamStallTimeoutMs = getStreamStallTimeoutMs(pluginConfig);
|
|
490
508
|
if (perProjectAccounts) {
|
|
491
509
|
setStoragePath(process.cwd());
|
|
492
510
|
}
|
|
493
511
|
const sessionRecoveryEnabled = getSessionRecovery(pluginConfig);
|
|
494
512
|
const autoResumeEnabled = getAutoResume(pluginConfig);
|
|
513
|
+
const emptyResponseMaxRetries = getEmptyResponseMaxRetries(pluginConfig);
|
|
514
|
+
const emptyResponseRetryDelayMs = getEmptyResponseRetryDelayMs(pluginConfig);
|
|
515
|
+
const pidOffsetEnabled = getPidOffsetEnabled(pluginConfig);
|
|
495
516
|
const recoveryHook = sessionRecoveryEnabled
|
|
496
517
|
? createSessionRecoveryHook({ client, directory: process.cwd() }, { sessionRecovery: true, autoResume: autoResumeEnabled })
|
|
497
518
|
: null;
|
|
@@ -520,249 +541,310 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
520
541
|
* @returns Response from Codex API
|
|
521
542
|
*/
|
|
522
543
|
async fetch(input, init) {
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
originalBody = JSON.parse(init.body);
|
|
534
|
-
}
|
|
535
|
-
catch {
|
|
536
|
-
logWarn("Failed to parse request body, using empty object");
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
const isStreaming = originalBody.stream === true;
|
|
540
|
-
const transformation = await transformRequestForCodex(init, url, userConfig, codexMode, originalBody);
|
|
541
|
-
const requestInit = transformation?.updatedInit ?? init;
|
|
542
|
-
const promptCacheKey = transformation?.body?.prompt_cache_key;
|
|
543
|
-
const model = transformation?.body.model;
|
|
544
|
-
const modelFamily = model ? getModelFamily(model) : "gpt-5.1";
|
|
545
|
-
const quotaKey = model ? `${modelFamily}:${model}` : modelFamily;
|
|
546
|
-
const abortSignal = requestInit?.signal ?? init?.signal ?? null;
|
|
547
|
-
const sleep = (ms) => new Promise((resolve, reject) => {
|
|
548
|
-
if (abortSignal?.aborted) {
|
|
549
|
-
reject(new Error("Aborted"));
|
|
550
|
-
return;
|
|
551
|
-
}
|
|
552
|
-
const timeout = setTimeout(() => {
|
|
553
|
-
cleanup();
|
|
554
|
-
resolve();
|
|
555
|
-
}, ms);
|
|
556
|
-
const onAbort = () => {
|
|
557
|
-
cleanup();
|
|
558
|
-
reject(new Error("Aborted"));
|
|
559
|
-
};
|
|
560
|
-
const cleanup = () => {
|
|
561
|
-
clearTimeout(timeout);
|
|
562
|
-
abortSignal?.removeEventListener("abort", onAbort);
|
|
563
|
-
};
|
|
564
|
-
abortSignal?.addEventListener("abort", onAbort, { once: true });
|
|
565
|
-
});
|
|
566
|
-
const sleepWithCountdown = async (totalMs, message, intervalMs = 5000) => {
|
|
567
|
-
const startTime = Date.now();
|
|
568
|
-
const endTime = startTime + totalMs;
|
|
569
|
-
while (Date.now() < endTime) {
|
|
570
|
-
if (abortSignal?.aborted) {
|
|
571
|
-
throw new Error("Aborted");
|
|
572
|
-
}
|
|
573
|
-
const remaining = Math.max(0, endTime - Date.now());
|
|
574
|
-
const waitLabel = formatWaitTime(remaining);
|
|
575
|
-
await showToast(`${message} (${waitLabel} remaining)`, "warning", { duration: Math.min(intervalMs + 1000, toastDurationMs) });
|
|
576
|
-
const sleepTime = Math.min(intervalMs, remaining);
|
|
577
|
-
if (sleepTime > 0) {
|
|
578
|
-
await sleep(sleepTime);
|
|
579
|
-
}
|
|
580
|
-
else {
|
|
581
|
-
break;
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
};
|
|
585
|
-
let allRateLimitedRetries = 0;
|
|
586
|
-
while (true) {
|
|
587
|
-
const accountCount = accountManager.getAccountCount();
|
|
588
|
-
const attempted = new Set();
|
|
589
|
-
while (attempted.size < Math.max(1, accountCount)) {
|
|
590
|
-
const account = accountManager.getCurrentOrNextForFamilyHybrid(modelFamily, model);
|
|
591
|
-
if (!account || attempted.has(account.index)) {
|
|
592
|
-
break;
|
|
593
|
-
}
|
|
594
|
-
attempted.add(account.index);
|
|
595
|
-
// Log account selection for debugging rotation
|
|
596
|
-
logDebug(`Using account ${account.index + 1}/${accountCount}: ${account.email ?? "unknown"} for ${modelFamily}`);
|
|
597
|
-
let accountAuth = accountManager.toAuthDetails(account);
|
|
544
|
+
try {
|
|
545
|
+
// Step 1: Extract and rewrite URL for Codex backend
|
|
546
|
+
const originalUrl = extractRequestUrl(input);
|
|
547
|
+
const url = rewriteUrlForCodex(originalUrl);
|
|
548
|
+
// Step 3: Transform request body with model-specific Codex instructions
|
|
549
|
+
// Instructions are fetched per model family (codex-max, codex, gpt-5.1)
|
|
550
|
+
// Capture original stream value before transformation
|
|
551
|
+
// generateText() sends no stream field, streamText() sends stream=true
|
|
552
|
+
let originalBody = {};
|
|
553
|
+
if (init?.body) {
|
|
598
554
|
try {
|
|
599
|
-
|
|
600
|
-
accountAuth = (await refreshAndUpdateToken(accountAuth, client));
|
|
601
|
-
accountManager.updateFromAuth(account, accountAuth);
|
|
602
|
-
accountManager.clearAuthFailures(account);
|
|
603
|
-
accountManager.saveToDiskDebounced();
|
|
604
|
-
}
|
|
555
|
+
originalBody = JSON.parse(init.body);
|
|
605
556
|
}
|
|
606
|
-
catch
|
|
607
|
-
|
|
608
|
-
const failures = accountManager.incrementAuthFailures(account);
|
|
609
|
-
const accountLabel = formatAccountLabel(account, account.index);
|
|
610
|
-
if (failures >= ACCOUNT_LIMITS.MAX_AUTH_FAILURES_BEFORE_REMOVAL) {
|
|
611
|
-
accountManager.removeAccount(account);
|
|
612
|
-
accountManager.saveToDiskDebounced();
|
|
613
|
-
await showToast(`Removed ${accountLabel} after ${failures} consecutive auth failures. Run 'opencode auth login' to re-add.`, "error", { duration: toastDurationMs * 2 });
|
|
614
|
-
continue;
|
|
615
|
-
}
|
|
616
|
-
accountManager.markAccountCoolingDown(account, ACCOUNT_LIMITS.AUTH_FAILURE_COOLDOWN_MS, "auth-failure");
|
|
617
|
-
accountManager.saveToDiskDebounced();
|
|
618
|
-
continue;
|
|
619
|
-
}
|
|
620
|
-
const hadAccountId = !!account.accountId;
|
|
621
|
-
// Prefer fresh token-derived ID over stored ID (fixes Business plan workspace issues)
|
|
622
|
-
const accountId = extractAccountId(accountAuth.access) ?? account.accountId;
|
|
623
|
-
if (!accountId) {
|
|
624
|
-
accountManager.markAccountCoolingDown(account, ACCOUNT_LIMITS.AUTH_FAILURE_COOLDOWN_MS, "auth-failure");
|
|
625
|
-
accountManager.saveToDiskDebounced();
|
|
626
|
-
continue;
|
|
557
|
+
catch {
|
|
558
|
+
logWarn("Failed to parse request body, using empty object");
|
|
627
559
|
}
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
560
|
+
}
|
|
561
|
+
const isStreaming = originalBody.stream === true;
|
|
562
|
+
const transformation = await transformRequestForCodex(init, url, userConfig, codexMode, originalBody);
|
|
563
|
+
const requestInit = transformation?.updatedInit ?? init;
|
|
564
|
+
const promptCacheKey = transformation?.body?.prompt_cache_key;
|
|
565
|
+
const model = transformation?.body.model;
|
|
566
|
+
const modelFamily = model ? getModelFamily(model) : "gpt-5.1";
|
|
567
|
+
const quotaKey = model ? `${modelFamily}:${model}` : modelFamily;
|
|
568
|
+
const threadIdCandidate = (process.env.CODEX_THREAD_ID ?? promptCacheKey ?? "")
|
|
569
|
+
.toString()
|
|
570
|
+
.trim() || undefined;
|
|
571
|
+
const requestCorrelationId = setCorrelationId(threadIdCandidate ? `${threadIdCandidate}:${Date.now()}` : undefined);
|
|
572
|
+
runtimeMetrics.lastRequestAt = Date.now();
|
|
573
|
+
const abortSignal = requestInit?.signal ?? init?.signal ?? null;
|
|
574
|
+
const sleep = (ms) => new Promise((resolve, reject) => {
|
|
575
|
+
if (abortSignal?.aborted) {
|
|
576
|
+
reject(new Error("Aborted"));
|
|
577
|
+
return;
|
|
639
578
|
}
|
|
640
|
-
const
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
});
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
579
|
+
const timeout = setTimeout(() => {
|
|
580
|
+
cleanup();
|
|
581
|
+
resolve();
|
|
582
|
+
}, ms);
|
|
583
|
+
const onAbort = () => {
|
|
584
|
+
cleanup();
|
|
585
|
+
reject(new Error("Aborted"));
|
|
586
|
+
};
|
|
587
|
+
const cleanup = () => {
|
|
588
|
+
clearTimeout(timeout);
|
|
589
|
+
abortSignal?.removeEventListener("abort", onAbort);
|
|
590
|
+
};
|
|
591
|
+
abortSignal?.addEventListener("abort", onAbort, { once: true });
|
|
592
|
+
});
|
|
593
|
+
const sleepWithCountdown = async (totalMs, message, intervalMs = 5000) => {
|
|
594
|
+
const startTime = Date.now();
|
|
595
|
+
const endTime = startTime + totalMs;
|
|
596
|
+
while (Date.now() < endTime) {
|
|
656
597
|
if (abortSignal?.aborted) {
|
|
657
|
-
|
|
658
|
-
fetchController.abort(abortSignal.reason ?? new Error("Aborted by user"));
|
|
598
|
+
throw new Error("Aborted");
|
|
659
599
|
}
|
|
660
|
-
|
|
661
|
-
|
|
600
|
+
const remaining = Math.max(0, endTime - Date.now());
|
|
601
|
+
const waitLabel = formatWaitTime(remaining);
|
|
602
|
+
await showToast(`${message} (${waitLabel} remaining)`, "warning", { duration: Math.min(intervalMs + 1000, toastDurationMs) });
|
|
603
|
+
const sleepTime = Math.min(intervalMs, remaining);
|
|
604
|
+
if (sleepTime > 0) {
|
|
605
|
+
await sleep(sleepTime);
|
|
662
606
|
}
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
...requestInit,
|
|
666
|
-
headers,
|
|
667
|
-
signal: fetchController.signal,
|
|
668
|
-
});
|
|
607
|
+
else {
|
|
608
|
+
break;
|
|
669
609
|
}
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
let allRateLimitedRetries = 0;
|
|
613
|
+
let emptyResponseRetries = 0;
|
|
614
|
+
while (true) {
|
|
615
|
+
const accountCount = accountManager.getAccountCount();
|
|
616
|
+
const attempted = new Set();
|
|
617
|
+
while (attempted.size < Math.max(1, accountCount)) {
|
|
618
|
+
const account = accountManager.getCurrentOrNextForFamilyHybrid(modelFamily, model, { pidOffsetEnabled });
|
|
619
|
+
if (!account || attempted.has(account.index)) {
|
|
675
620
|
break;
|
|
676
621
|
}
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
622
|
+
attempted.add(account.index);
|
|
623
|
+
// Log account selection for debugging rotation
|
|
624
|
+
logDebug(`Using account ${account.index + 1}/${accountCount}: ${account.email ?? "unknown"} for ${modelFamily}`);
|
|
625
|
+
let accountAuth = accountManager.toAuthDetails(account);
|
|
626
|
+
try {
|
|
627
|
+
if (shouldRefreshToken(accountAuth, tokenRefreshSkewMs)) {
|
|
628
|
+
accountAuth = (await refreshAndUpdateToken(accountAuth, client));
|
|
629
|
+
accountManager.updateFromAuth(account, accountAuth);
|
|
630
|
+
accountManager.clearAuthFailures(account);
|
|
631
|
+
accountManager.saveToDiskDebounced();
|
|
681
632
|
}
|
|
682
633
|
}
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
634
|
+
catch (err) {
|
|
635
|
+
logDebug(`[${PLUGIN_NAME}] Auth refresh failed for account: ${err?.message ?? String(err)}`);
|
|
636
|
+
runtimeMetrics.authRefreshFailures++;
|
|
637
|
+
runtimeMetrics.failedRequests++;
|
|
638
|
+
runtimeMetrics.accountRotations++;
|
|
639
|
+
runtimeMetrics.lastError = err?.message ?? String(err);
|
|
640
|
+
const failures = accountManager.incrementAuthFailures(account);
|
|
641
|
+
const accountLabel = formatAccountLabel(account, account.index);
|
|
642
|
+
if (failures >= ACCOUNT_LIMITS.MAX_AUTH_FAILURES_BEFORE_REMOVAL) {
|
|
643
|
+
accountManager.removeAccount(account);
|
|
644
|
+
accountManager.saveToDiskDebounced();
|
|
645
|
+
await showToast(`Removed ${accountLabel} after ${failures} consecutive auth failures. Run 'opencode auth login' to re-add.`, "error", { duration: toastDurationMs * 2 });
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
accountManager.markAccountCoolingDown(account, ACCOUNT_LIMITS.AUTH_FAILURE_COOLDOWN_MS, "auth-failure");
|
|
649
|
+
accountManager.saveToDiskDebounced();
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
const hadAccountId = !!account.accountId;
|
|
653
|
+
// Prefer fresh token-derived ID over stored ID (fixes Business plan workspace issues)
|
|
654
|
+
const accountId = extractAccountId(accountAuth.access) ?? account.accountId;
|
|
655
|
+
if (!accountId) {
|
|
656
|
+
accountManager.markAccountCoolingDown(account, ACCOUNT_LIMITS.AUTH_FAILURE_COOLDOWN_MS, "auth-failure");
|
|
657
|
+
accountManager.saveToDiskDebounced();
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
account.accountId = accountId;
|
|
661
|
+
if (!hadAccountId) {
|
|
662
|
+
account.accountIdSource = account.accountIdSource ?? "token";
|
|
663
|
+
}
|
|
664
|
+
account.email =
|
|
665
|
+
extractAccountEmail(accountAuth.access) ?? account.email;
|
|
666
|
+
if (accountCount > 1 &&
|
|
667
|
+
accountManager.shouldShowAccountToast(account.index, rateLimitToastDebounceMs)) {
|
|
668
|
+
const accountLabel = formatAccountLabel(account, account.index);
|
|
669
|
+
await showToast(`Using ${accountLabel} (${account.index + 1}/${accountCount})`, "info");
|
|
670
|
+
accountManager.markToastShown(account.index);
|
|
671
|
+
}
|
|
672
|
+
const headers = createCodexHeaders(requestInit, accountId, accountAuth.access, {
|
|
673
|
+
model,
|
|
674
|
+
promptCacheKey,
|
|
690
675
|
});
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
676
|
+
// Consume a token before making the request for proactive rate limiting
|
|
677
|
+
accountManager.consumeToken(account, modelFamily, model);
|
|
678
|
+
while (true) {
|
|
679
|
+
let response;
|
|
680
|
+
const fetchStart = performance.now();
|
|
681
|
+
// Merge user AbortSignal with timeout (Node 18 compatible - no AbortSignal.any)
|
|
682
|
+
const fetchController = new AbortController();
|
|
683
|
+
const requestTimeoutMs = fetchTimeoutMs;
|
|
684
|
+
const fetchTimeoutId = setTimeout(() => fetchController.abort(new Error("Request timeout")), requestTimeoutMs);
|
|
685
|
+
const onUserAbort = abortSignal
|
|
686
|
+
? () => fetchController.abort(abortSignal.reason ?? new Error("Aborted by user"))
|
|
687
|
+
: null;
|
|
688
|
+
if (abortSignal?.aborted) {
|
|
689
|
+
clearTimeout(fetchTimeoutId);
|
|
690
|
+
fetchController.abort(abortSignal.reason ?? new Error("Aborted by user"));
|
|
695
691
|
}
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
const errorType = detectErrorType(errorBody);
|
|
699
|
-
const toastContent = getRecoveryToastContent(errorType);
|
|
700
|
-
await showToast(`${toastContent.title}: ${toastContent.message}`, "warning", { duration: toastDurationMs });
|
|
701
|
-
logDebug(`[${PLUGIN_NAME}] Recoverable error detected: ${errorType}`);
|
|
692
|
+
else if (abortSignal && onUserAbort) {
|
|
693
|
+
abortSignal.addEventListener("abort", onUserAbort, { once: true });
|
|
702
694
|
}
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
695
|
+
try {
|
|
696
|
+
runtimeMetrics.totalRequests++;
|
|
697
|
+
response = await fetch(url, {
|
|
698
|
+
...requestInit,
|
|
699
|
+
headers,
|
|
700
|
+
signal: fetchController.signal,
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
catch (networkError) {
|
|
704
|
+
const errorMsg = networkError instanceof Error ? networkError.message : String(networkError);
|
|
705
|
+
logWarn(`Network error for account ${account.index + 1}: ${errorMsg}`);
|
|
706
|
+
runtimeMetrics.failedRequests++;
|
|
707
|
+
runtimeMetrics.networkErrors++;
|
|
708
|
+
runtimeMetrics.accountRotations++;
|
|
709
|
+
runtimeMetrics.lastError = errorMsg;
|
|
706
710
|
accountManager.refundToken(account, modelFamily, model);
|
|
707
711
|
accountManager.recordFailure(account, modelFamily, model);
|
|
708
712
|
break;
|
|
709
713
|
}
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
714
|
+
finally {
|
|
715
|
+
clearTimeout(fetchTimeoutId);
|
|
716
|
+
if (abortSignal && onUserAbort) {
|
|
717
|
+
abortSignal.removeEventListener("abort", onUserAbort);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
const fetchLatencyMs = Math.round(performance.now() - fetchStart);
|
|
721
|
+
logRequest(LOG_STAGES.RESPONSE, {
|
|
722
|
+
status: response.status,
|
|
723
|
+
ok: response.ok,
|
|
724
|
+
statusText: response.statusText,
|
|
725
|
+
latencyMs: fetchLatencyMs,
|
|
726
|
+
headers: Object.fromEntries(response.headers.entries()),
|
|
727
|
+
});
|
|
728
|
+
if (!response.ok) {
|
|
729
|
+
const contextOverflowResult = await handleContextOverflow(response, model);
|
|
730
|
+
if (contextOverflowResult.handled) {
|
|
731
|
+
return contextOverflowResult.response;
|
|
732
|
+
}
|
|
733
|
+
const { response: errorResponse, rateLimit, errorBody } = await handleErrorResponse(response, {
|
|
734
|
+
requestCorrelationId,
|
|
735
|
+
threadId: threadIdCandidate,
|
|
736
|
+
});
|
|
737
|
+
if (recoveryHook && errorBody && isRecoverableError(errorBody)) {
|
|
738
|
+
const errorType = detectErrorType(errorBody);
|
|
739
|
+
const toastContent = getRecoveryToastContent(errorType);
|
|
740
|
+
await showToast(`${toastContent.title}: ${toastContent.message}`, "warning", { duration: toastDurationMs });
|
|
741
|
+
logDebug(`[${PLUGIN_NAME}] Recoverable error detected: ${errorType}`);
|
|
742
|
+
}
|
|
743
|
+
// Handle 5xx server errors by rotating to another account
|
|
744
|
+
if (response.status >= 500 && response.status < 600) {
|
|
745
|
+
logWarn(`Server error ${response.status} for account ${account.index + 1}. Rotating to next account.`);
|
|
746
|
+
runtimeMetrics.failedRequests++;
|
|
747
|
+
runtimeMetrics.serverErrors++;
|
|
748
|
+
runtimeMetrics.accountRotations++;
|
|
749
|
+
runtimeMetrics.lastError = `HTTP ${response.status}`;
|
|
750
|
+
accountManager.refundToken(account, modelFamily, model);
|
|
751
|
+
accountManager.recordFailure(account, modelFamily, model);
|
|
752
|
+
break;
|
|
753
|
+
}
|
|
754
|
+
if (rateLimit) {
|
|
755
|
+
runtimeMetrics.rateLimitedResponses++;
|
|
756
|
+
const { attempt, delayMs } = getRateLimitBackoff(account.index, quotaKey, rateLimit.retryAfterMs);
|
|
757
|
+
const waitLabel = formatWaitTime(delayMs);
|
|
758
|
+
if (delayMs <= RATE_LIMIT_SHORT_RETRY_THRESHOLD_MS) {
|
|
759
|
+
if (accountManager.shouldShowAccountToast(account.index, rateLimitToastDebounceMs)) {
|
|
760
|
+
await showToast(`Rate limited. Retrying in ${waitLabel} (attempt ${attempt})...`, "warning", { duration: toastDurationMs });
|
|
761
|
+
accountManager.markToastShown(account.index);
|
|
762
|
+
}
|
|
763
|
+
await sleep(addJitter(Math.max(MIN_BACKOFF_MS, delayMs), 0.2));
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
accountManager.markRateLimitedWithReason(account, delayMs, modelFamily, parseRateLimitReason(rateLimit.code), model);
|
|
767
|
+
accountManager.recordRateLimit(account, modelFamily, model);
|
|
768
|
+
account.lastSwitchReason = "rate-limit";
|
|
769
|
+
runtimeMetrics.accountRotations++;
|
|
770
|
+
accountManager.saveToDiskDebounced();
|
|
771
|
+
logWarn(`Rate limited. Rotating account ${account.index + 1} (${account.email ?? "unknown"}).`);
|
|
772
|
+
if (accountManager.getAccountCount() > 1 &&
|
|
773
|
+
accountManager.shouldShowAccountToast(account.index, rateLimitToastDebounceMs)) {
|
|
774
|
+
await showToast(`Rate limited. Switching accounts (retry in ${waitLabel}).`, "warning", { duration: toastDurationMs });
|
|
716
775
|
accountManager.markToastShown(account.index);
|
|
717
776
|
}
|
|
718
|
-
|
|
719
|
-
continue;
|
|
777
|
+
break;
|
|
720
778
|
}
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
779
|
+
runtimeMetrics.failedRequests++;
|
|
780
|
+
runtimeMetrics.lastError = `HTTP ${response.status}`;
|
|
781
|
+
return errorResponse;
|
|
782
|
+
}
|
|
783
|
+
resetRateLimitBackoff(account.index, quotaKey);
|
|
784
|
+
runtimeMetrics.cumulativeLatencyMs += fetchLatencyMs;
|
|
785
|
+
const successResponse = await handleSuccessResponse(response, isStreaming, {
|
|
786
|
+
streamStallTimeoutMs,
|
|
787
|
+
});
|
|
788
|
+
if (!isStreaming && emptyResponseMaxRetries > 0) {
|
|
789
|
+
const clonedResponse = successResponse.clone();
|
|
790
|
+
try {
|
|
791
|
+
const bodyText = await clonedResponse.text();
|
|
792
|
+
const parsedBody = bodyText ? JSON.parse(bodyText) : null;
|
|
793
|
+
if (isEmptyResponse(parsedBody)) {
|
|
794
|
+
if (emptyResponseRetries < emptyResponseMaxRetries) {
|
|
795
|
+
emptyResponseRetries++;
|
|
796
|
+
runtimeMetrics.emptyResponseRetries++;
|
|
797
|
+
logWarn(`Empty response received (attempt ${emptyResponseRetries}/${emptyResponseMaxRetries}). Retrying...`);
|
|
798
|
+
await showToast(`Empty response. Retrying (${emptyResponseRetries}/${emptyResponseMaxRetries})...`, "warning", { duration: toastDurationMs });
|
|
799
|
+
accountManager.refundToken(account, modelFamily, model);
|
|
800
|
+
accountManager.recordFailure(account, modelFamily, model);
|
|
801
|
+
await sleep(addJitter(emptyResponseRetryDelayMs, 0.2));
|
|
802
|
+
break;
|
|
803
|
+
}
|
|
804
|
+
logWarn(`Empty response after ${emptyResponseMaxRetries} retries. Returning as-is.`);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
catch {
|
|
808
|
+
// Intentionally empty: non-JSON response bodies should be returned as-is
|
|
730
809
|
}
|
|
731
|
-
break;
|
|
732
810
|
}
|
|
733
|
-
|
|
811
|
+
accountManager.recordSuccess(account, modelFamily, model);
|
|
812
|
+
runtimeMetrics.successfulRequests++;
|
|
813
|
+
runtimeMetrics.lastError = null;
|
|
814
|
+
return successResponse;
|
|
734
815
|
}
|
|
735
|
-
resetRateLimitBackoff(account.index, quotaKey);
|
|
736
|
-
const successResponse = await handleSuccessResponse(response, isStreaming);
|
|
737
|
-
accountManager.recordSuccess(account, modelFamily, model);
|
|
738
|
-
return successResponse;
|
|
739
816
|
}
|
|
817
|
+
const waitMs = accountManager.getMinWaitTimeForFamily(modelFamily, model);
|
|
818
|
+
const count = accountManager.getAccountCount();
|
|
819
|
+
if (retryAllAccountsRateLimited &&
|
|
820
|
+
count > 0 &&
|
|
821
|
+
waitMs > 0 &&
|
|
822
|
+
(retryAllAccountsMaxWaitMs === 0 ||
|
|
823
|
+
waitMs <= retryAllAccountsMaxWaitMs) &&
|
|
824
|
+
allRateLimitedRetries < retryAllAccountsMaxRetries) {
|
|
825
|
+
const countdownMessage = `All ${count} account(s) rate-limited. Waiting`;
|
|
826
|
+
await sleepWithCountdown(addJitter(waitMs, 0.2), countdownMessage);
|
|
827
|
+
allRateLimitedRetries++;
|
|
828
|
+
continue;
|
|
829
|
+
}
|
|
830
|
+
const waitLabel = waitMs > 0 ? formatWaitTime(waitMs) : "a bit";
|
|
831
|
+
const message = count === 0
|
|
832
|
+
? "No Codex accounts configured. Run `opencode auth login`."
|
|
833
|
+
: waitMs > 0
|
|
834
|
+
? `All ${count} account(s) are rate-limited. Try again in ${waitLabel} or add another account with \`opencode auth login\`.`
|
|
835
|
+
: `All ${count} account(s) failed (server errors or auth issues). Check account health with \`codex-health\`.`;
|
|
836
|
+
runtimeMetrics.failedRequests++;
|
|
837
|
+
runtimeMetrics.lastError = message;
|
|
838
|
+
return new Response(JSON.stringify({ error: { message } }), {
|
|
839
|
+
status: waitMs > 0 ? 429 : 503,
|
|
840
|
+
headers: {
|
|
841
|
+
"content-type": "application/json; charset=utf-8",
|
|
842
|
+
},
|
|
843
|
+
});
|
|
740
844
|
}
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
count > 0 &&
|
|
745
|
-
waitMs > 0 &&
|
|
746
|
-
(retryAllAccountsMaxWaitMs === 0 ||
|
|
747
|
-
waitMs <= retryAllAccountsMaxWaitMs) &&
|
|
748
|
-
allRateLimitedRetries < retryAllAccountsMaxRetries) {
|
|
749
|
-
const countdownMessage = `All ${count} account(s) rate-limited. Waiting`;
|
|
750
|
-
await sleepWithCountdown(addJitter(waitMs, 0.2), countdownMessage);
|
|
751
|
-
allRateLimitedRetries++;
|
|
752
|
-
continue;
|
|
753
|
-
}
|
|
754
|
-
const waitLabel = waitMs > 0 ? formatWaitTime(waitMs) : "a bit";
|
|
755
|
-
const message = count === 0
|
|
756
|
-
? "No Codex accounts configured. Run `opencode auth login`."
|
|
757
|
-
: waitMs > 0
|
|
758
|
-
? `All ${count} account(s) are rate-limited. Try again in ${waitLabel} or add another account with \`opencode auth login\`.`
|
|
759
|
-
: `All ${count} account(s) failed (server errors or auth issues). Check account health with \`codex-health\`.`;
|
|
760
|
-
return new Response(JSON.stringify({ error: { message } }), {
|
|
761
|
-
status: waitMs > 0 ? 429 : 503,
|
|
762
|
-
headers: {
|
|
763
|
-
"content-type": "application/json; charset=utf-8",
|
|
764
|
-
},
|
|
765
|
-
});
|
|
845
|
+
}
|
|
846
|
+
finally {
|
|
847
|
+
clearCorrelationId();
|
|
766
848
|
}
|
|
767
849
|
},
|
|
768
850
|
};
|
|
@@ -1057,6 +1139,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
1057
1139
|
lines.push(" - Add account: opencode auth login");
|
|
1058
1140
|
lines.push(" - Switch account: codex-switch");
|
|
1059
1141
|
lines.push(" - Status details: codex-status");
|
|
1142
|
+
lines.push(" - Runtime metrics: codex-metrics");
|
|
1060
1143
|
return lines.join("\n");
|
|
1061
1144
|
},
|
|
1062
1145
|
}),
|
|
@@ -1158,6 +1241,44 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
|
|
|
1158
1241
|
return lines.join("\n");
|
|
1159
1242
|
},
|
|
1160
1243
|
}),
|
|
1244
|
+
"codex-metrics": tool({
|
|
1245
|
+
description: "Show runtime request metrics for this plugin process.",
|
|
1246
|
+
args: {},
|
|
1247
|
+
execute() {
|
|
1248
|
+
const now = Date.now();
|
|
1249
|
+
const uptimeMs = Math.max(0, now - runtimeMetrics.startedAt);
|
|
1250
|
+
const total = runtimeMetrics.totalRequests;
|
|
1251
|
+
const successful = runtimeMetrics.successfulRequests;
|
|
1252
|
+
const successRate = total > 0 ? ((successful / total) * 100).toFixed(1) : "0.0";
|
|
1253
|
+
const avgLatencyMs = successful > 0
|
|
1254
|
+
? Math.round(runtimeMetrics.cumulativeLatencyMs / successful)
|
|
1255
|
+
: 0;
|
|
1256
|
+
const lastRequest = runtimeMetrics.lastRequestAt !== null
|
|
1257
|
+
? `${formatWaitTime(now - runtimeMetrics.lastRequestAt)} ago`
|
|
1258
|
+
: "never";
|
|
1259
|
+
const lines = [
|
|
1260
|
+
"Codex Plugin Metrics:",
|
|
1261
|
+
"",
|
|
1262
|
+
`Uptime: ${formatWaitTime(uptimeMs)}`,
|
|
1263
|
+
`Total upstream requests: ${total}`,
|
|
1264
|
+
`Successful responses: ${successful}`,
|
|
1265
|
+
`Failed responses: ${runtimeMetrics.failedRequests}`,
|
|
1266
|
+
`Success rate: ${successRate}%`,
|
|
1267
|
+
`Average successful latency: ${avgLatencyMs}ms`,
|
|
1268
|
+
`Rate-limited responses: ${runtimeMetrics.rateLimitedResponses}`,
|
|
1269
|
+
`Server errors (5xx): ${runtimeMetrics.serverErrors}`,
|
|
1270
|
+
`Network errors: ${runtimeMetrics.networkErrors}`,
|
|
1271
|
+
`Auth refresh failures: ${runtimeMetrics.authRefreshFailures}`,
|
|
1272
|
+
`Account rotations: ${runtimeMetrics.accountRotations}`,
|
|
1273
|
+
`Empty-response retries: ${runtimeMetrics.emptyResponseRetries}`,
|
|
1274
|
+
`Last upstream request: ${lastRequest}`,
|
|
1275
|
+
];
|
|
1276
|
+
if (runtimeMetrics.lastError) {
|
|
1277
|
+
lines.push(`Last error: ${runtimeMetrics.lastError}`);
|
|
1278
|
+
}
|
|
1279
|
+
return Promise.resolve(lines.join("\n"));
|
|
1280
|
+
},
|
|
1281
|
+
}),
|
|
1161
1282
|
"codex-health": tool({
|
|
1162
1283
|
description: "Check health of all Codex accounts by validating refresh tokens.",
|
|
1163
1284
|
args: {},
|