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.
Files changed (49) hide show
  1. package/LICENSE +0 -36
  2. package/README.md +35 -5
  3. package/config/opencode-modern.json +5 -0
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +339 -218
  6. package/dist/index.js.map +1 -1
  7. package/dist/lib/accounts.d.ts +2 -1
  8. package/dist/lib/accounts.d.ts.map +1 -1
  9. package/dist/lib/accounts.js +2 -2
  10. package/dist/lib/accounts.js.map +1 -1
  11. package/dist/lib/config.d.ts +5 -0
  12. package/dist/lib/config.d.ts.map +1 -1
  13. package/dist/lib/config.js +20 -0
  14. package/dist/lib/config.js.map +1 -1
  15. package/dist/lib/prompts/codex-opencode-bridge.d.ts +1 -1
  16. package/dist/lib/prompts/codex-opencode-bridge.d.ts.map +1 -1
  17. package/dist/lib/prompts/codex-opencode-bridge.js +2 -0
  18. package/dist/lib/prompts/codex-opencode-bridge.js.map +1 -1
  19. package/dist/lib/prompts/codex.d.ts +1 -1
  20. package/dist/lib/prompts/codex.d.ts.map +1 -1
  21. package/dist/lib/prompts/codex.js +5 -0
  22. package/dist/lib/prompts/codex.js.map +1 -1
  23. package/dist/lib/request/fetch-helpers.d.ts +15 -2
  24. package/dist/lib/request/fetch-helpers.d.ts.map +1 -1
  25. package/dist/lib/request/fetch-helpers.js +69 -9
  26. package/dist/lib/request/fetch-helpers.js.map +1 -1
  27. package/dist/lib/request/request-transformer.d.ts.map +1 -1
  28. package/dist/lib/request/request-transformer.js +94 -7
  29. package/dist/lib/request/request-transformer.js.map +1 -1
  30. package/dist/lib/request/response-handler.d.ts +10 -1
  31. package/dist/lib/request/response-handler.d.ts.map +1 -1
  32. package/dist/lib/request/response-handler.js +51 -2
  33. package/dist/lib/request/response-handler.js.map +1 -1
  34. package/dist/lib/rotation.d.ts +4 -1
  35. package/dist/lib/rotation.d.ts.map +1 -1
  36. package/dist/lib/rotation.js +9 -12
  37. package/dist/lib/rotation.js.map +1 -1
  38. package/dist/lib/schemas.d.ts +5 -0
  39. package/dist/lib/schemas.d.ts.map +1 -1
  40. package/dist/lib/schemas.js +5 -0
  41. package/dist/lib/schemas.js.map +1 -1
  42. package/dist/lib/storage/paths.d.ts +6 -0
  43. package/dist/lib/storage/paths.d.ts.map +1 -1
  44. package/dist/lib/storage/paths.js +32 -1
  45. package/dist/lib/storage/paths.js.map +1 -1
  46. package/dist/lib/storage.d.ts.map +1 -1
  47. package/dist/lib/storage.js +42 -3
  48. package/dist/lib/storage.js.map +1 -1
  49. 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
- // Step 1: Extract and rewrite URL for Codex backend
524
- const originalUrl = extractRequestUrl(input);
525
- const url = rewriteUrlForCodex(originalUrl);
526
- // Step 3: Transform request body with model-specific Codex instructions
527
- // Instructions are fetched per model family (codex-max, codex, gpt-5.1)
528
- // Capture original stream value before transformation
529
- // generateText() sends no stream field, streamText() sends stream=true
530
- let originalBody = {};
531
- if (init?.body) {
532
- try {
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
- if (shouldRefreshToken(accountAuth, tokenRefreshSkewMs)) {
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 (err) {
607
- logDebug(`[${PLUGIN_NAME}] Auth refresh failed for account: ${err?.message ?? String(err)}`);
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
- account.accountId = accountId;
629
- if (!hadAccountId) {
630
- account.accountIdSource = account.accountIdSource ?? "token";
631
- }
632
- account.email =
633
- extractAccountEmail(accountAuth.access) ?? account.email;
634
- if (accountCount > 1 &&
635
- accountManager.shouldShowAccountToast(account.index, rateLimitToastDebounceMs)) {
636
- const accountLabel = formatAccountLabel(account, account.index);
637
- await showToast(`Using ${accountLabel} (${account.index + 1}/${accountCount})`, "info");
638
- accountManager.markToastShown(account.index);
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 headers = createCodexHeaders(requestInit, accountId, accountAuth.access, {
641
- model,
642
- promptCacheKey,
643
- });
644
- // Consume a token before making the request for proactive rate limiting
645
- accountManager.consumeToken(account, modelFamily, model);
646
- while (true) {
647
- let response;
648
- const fetchStart = performance.now();
649
- // Merge user AbortSignal with timeout (Node 18 compatible - no AbortSignal.any)
650
- const fetchController = new AbortController();
651
- const fetchTimeoutMs = 60000;
652
- const fetchTimeoutId = setTimeout(() => fetchController.abort(new Error("Request timeout")), fetchTimeoutMs);
653
- const onUserAbort = abortSignal
654
- ? () => fetchController.abort(abortSignal.reason ?? new Error("Aborted by user"))
655
- : null;
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
- clearTimeout(fetchTimeoutId);
658
- fetchController.abort(abortSignal.reason ?? new Error("Aborted by user"));
598
+ throw new Error("Aborted");
659
599
  }
660
- else if (abortSignal && onUserAbort) {
661
- abortSignal.addEventListener("abort", onUserAbort, { once: true });
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
- try {
664
- response = await fetch(url, {
665
- ...requestInit,
666
- headers,
667
- signal: fetchController.signal,
668
- });
607
+ else {
608
+ break;
669
609
  }
670
- catch (networkError) {
671
- const errorMsg = networkError instanceof Error ? networkError.message : String(networkError);
672
- logWarn(`Network error for account ${account.index + 1}: ${errorMsg}`);
673
- accountManager.refundToken(account, modelFamily, model);
674
- accountManager.recordFailure(account, modelFamily, model);
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
- finally {
678
- clearTimeout(fetchTimeoutId);
679
- if (abortSignal && onUserAbort) {
680
- abortSignal.removeEventListener("abort", onUserAbort);
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
- const fetchLatencyMs = Math.round(performance.now() - fetchStart);
684
- logRequest(LOG_STAGES.RESPONSE, {
685
- status: response.status,
686
- ok: response.ok,
687
- statusText: response.statusText,
688
- latencyMs: fetchLatencyMs,
689
- headers: Object.fromEntries(response.headers.entries()),
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
- if (!response.ok) {
692
- const contextOverflowResult = await handleContextOverflow(response, model);
693
- if (contextOverflowResult.handled) {
694
- return contextOverflowResult.response;
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
- const { response: errorResponse, rateLimit, errorBody } = await handleErrorResponse(response);
697
- if (recoveryHook && errorBody && isRecoverableError(errorBody)) {
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
- // Handle 5xx server errors by rotating to another account
704
- if (response.status >= 500 && response.status < 600) {
705
- logWarn(`Server error ${response.status} for account ${account.index + 1}. Rotating to next account.`);
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
- if (rateLimit) {
711
- const { attempt, delayMs } = getRateLimitBackoff(account.index, quotaKey, rateLimit.retryAfterMs);
712
- const waitLabel = formatWaitTime(delayMs);
713
- if (delayMs <= RATE_LIMIT_SHORT_RETRY_THRESHOLD_MS) {
714
- if (accountManager.shouldShowAccountToast(account.index, rateLimitToastDebounceMs)) {
715
- await showToast(`Rate limited. Retrying in ${waitLabel} (attempt ${attempt})...`, "warning", { duration: toastDurationMs });
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
- await sleep(addJitter(Math.max(MIN_BACKOFF_MS, delayMs), 0.2));
719
- continue;
777
+ break;
720
778
  }
721
- accountManager.markRateLimitedWithReason(account, delayMs, modelFamily, parseRateLimitReason(rateLimit.code), model);
722
- accountManager.recordRateLimit(account, modelFamily, model);
723
- account.lastSwitchReason = "rate-limit";
724
- accountManager.saveToDiskDebounced();
725
- logWarn(`Rate limited. Rotating account ${account.index + 1} (${account.email ?? "unknown"}).`);
726
- if (accountManager.getAccountCount() > 1 &&
727
- accountManager.shouldShowAccountToast(account.index, rateLimitToastDebounceMs)) {
728
- await showToast(`Rate limited. Switching accounts (retry in ${waitLabel}).`, "warning", { duration: toastDurationMs });
729
- accountManager.markToastShown(account.index);
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
- return errorResponse;
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
- const waitMs = accountManager.getMinWaitTimeForFamily(modelFamily, model);
742
- const count = accountManager.getAccountCount();
743
- if (retryAllAccountsRateLimited &&
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: {},