Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions gui/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,9 @@ export const de = {
"codexAuth.accountModeDirectDesc": "Anfragen verwenden nur die Hauptanmeldung; hinzugefügte Konten bleiben für den Pool-Modus gespeichert.",
"codexAuth.openaiMissing": "Der integrierte OpenAI-Anbieter ist nicht konfiguriert.",
"codexAuth.openaiDisabled": "Der integrierte OpenAI-Anbieter ist deaktiviert.",
"codexAuth.openaiUnavailableDesc": "Deine OpenAI-Konten sind weiterhin verfügbar. Aktiviere den Anbieter, um Codex-Anfragen weiterzuleiten.",
"codexAuth.enableOpenai": "OpenAI aktivieren",
"codexAuth.enablingOpenai": "Wird aktiviert...",
"codexAuth.openProviders": "Anbieter öffnen",
"codexAuth.add": "Hinzufügen",
"codexAuth.refreshQuota": "Kontingente aktualisieren",
Expand Down
3 changes: 3 additions & 0 deletions gui/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -904,6 +904,9 @@ export const en = {
"codexAuth.accountModeDirectDesc": "Requests use only the main login; added accounts remain stored for Pool mode.",
"codexAuth.openaiMissing": "The built-in OpenAI provider is not configured.",
"codexAuth.openaiDisabled": "The built-in OpenAI provider is disabled.",
"codexAuth.openaiUnavailableDesc": "Your OpenAI accounts are still available. Enable the provider to route Codex requests.",
"codexAuth.enableOpenai": "Enable OpenAI",
"codexAuth.enablingOpenai": "Enabling...",
"codexAuth.openProviders": "Open Providers",
"codexAuth.add": "Add",
"codexAuth.refreshQuota": "Refresh quotas",
Expand Down
3 changes: 3 additions & 0 deletions gui/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -859,6 +859,9 @@ export const ja: Record<TKey, string> = {
"codexAuth.accountModeDirectDesc": "リクエストはメインログインのみを使用します; 追加アカウントはプールモード用に保持されます。",
"codexAuth.openaiMissing": "組み込みの OpenAI プロバイダーが設定されていません。",
"codexAuth.openaiDisabled": "組み込みの OpenAI プロバイダーが無効です。",
"codexAuth.openaiUnavailableDesc": "OpenAI アカウントは引き続き利用できます。Codex リクエストをルーティングするにはプロバイダーを有効にしてください。",
"codexAuth.enableOpenai": "OpenAI を有効にする",
"codexAuth.enablingOpenai": "有効化中...",
"codexAuth.openProviders": "プロバイダーを開く",
"codexAuth.add": "追加",
"codexAuth.refreshQuota": "クォータを更新",
Expand Down
3 changes: 3 additions & 0 deletions gui/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,9 @@ export const ko: Record<TKey, string> = {
"codexAuth.accountModeDirectDesc": "요청은 메인 로그인만 사용하며, 추가 계정은 풀 모드용으로 계속 저장됩니다.",
"codexAuth.openaiMissing": "내장 OpenAI 프로바이더가 설정되지 않았습니다.",
"codexAuth.openaiDisabled": "내장 OpenAI 프로바이더가 비활성화되어 있습니다.",
"codexAuth.openaiUnavailableDesc": "OpenAI 계정은 그대로 사용할 수 있습니다. Codex 요청을 라우팅하려면 프로바이더를 활성화하세요.",
"codexAuth.enableOpenai": "OpenAI 활성화",
"codexAuth.enablingOpenai": "활성화 중...",
"codexAuth.openProviders": "프로바이더 열기",
"codexAuth.add": "추가",
"codexAuth.refreshQuota": "할당량 새로고침",
Expand Down
3 changes: 3 additions & 0 deletions gui/src/i18n/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -904,6 +904,9 @@ export const ru: Record<TKey, string> = {
"codexAuth.accountModeDirectDesc": "Запросы используют только основной вход; добавленные аккаунты сохраняются для режима пула.",
"codexAuth.openaiMissing": "Встроенный провайдер OpenAI не настроен.",
"codexAuth.openaiDisabled": "Встроенный провайдер OpenAI отключён.",
"codexAuth.openaiUnavailableDesc": "Ваши аккаунты OpenAI по-прежнему доступны. Включите провайдера для маршрутизации запросов Codex.",
"codexAuth.enableOpenai": "Включить OpenAI",
"codexAuth.enablingOpenai": "Включение...",
"codexAuth.openProviders": "Открыть провайдеров",
"codexAuth.add": "Добавить",
"codexAuth.refreshQuota": "Обновить квоты",
Expand Down
3 changes: 3 additions & 0 deletions gui/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,9 @@ export const zh: Record<TKey, string> = {
"codexAuth.accountModeDirectDesc": "请求仅使用主登录;已添加账户会继续存储,供账户池模式使用。",
"codexAuth.openaiMissing": "未配置内置 OpenAI 提供方。",
"codexAuth.openaiDisabled": "内置 OpenAI 提供方已禁用。",
"codexAuth.openaiUnavailableDesc": "你的 OpenAI 账号仍然可用。启用提供方后即可路由 Codex 请求。",
"codexAuth.enableOpenai": "启用 OpenAI",
"codexAuth.enablingOpenai": "正在启用...",
"codexAuth.openProviders": "打开提供商",
"codexAuth.add": "添加",
"codexAuth.refreshQuota": "刷新额度",
Expand Down
89 changes: 62 additions & 27 deletions gui/src/pages/CodexAuth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,44 @@ import { useCallback, useEffect, useState } from "react";
import { useT } from "../i18n";
import CodexAccountPool from "../components/CodexAccountPool";
import { codexAccountModeState, type CodexAccountModeState } from "../codex-multi-state";
import { ensureOpenAiProvider } from "../provider-payload";

export function OpenAiAccountModeBanner({
state,
busy,
onEnable,
}: {
state: CodexAccountModeState | null;
busy: boolean;
onEnable: () => void;
}) {
const t = useT();
return (
<div className="panel" style={{ marginBottom: 16 }}>
<div className="row">
<strong>{t("codexAuth.accountModeTitle")}</strong>
{state === "pool" && <span className="badge badge-accent">{t("codexAuth.accountModePool")}</span>}
{state === "direct" && <span className="badge badge-green">{t("codexAuth.accountModeDirect")}</span>}
</div>
{state === "pool" && (
<p className="card-sub" style={{ margin: "6px 0 0" }}>{t("codexAuth.accountModePoolDesc")}</p>
)}
{state === "direct" && (
<p className="card-sub" style={{ margin: "6px 0 0" }}>
{t("codexAuth.accountModeDirectDesc")} <a href="#providers">{t("codexAuth.openProviders")}</a>
</p>
)}
{(state === "absent" || state === "disabled") && (
<div className="row" style={{ alignItems: "center", marginTop: 8 }}>
<p className="card-sub" style={{ flex: 1, margin: 0 }}>{t("codexAuth.openaiUnavailableDesc")}</p>
<button className="btn btn-primary btn-sm" disabled={busy} onClick={onEnable}>
{busy ? t("codexAuth.enablingOpenai") : t("codexAuth.enableOpenai")}
</button>
</div>
)}
</div>
);
}

/**
* Codex Auth page — a thin wrapper around CodexAccountPool (WP060 extraction).
Expand All @@ -11,6 +49,8 @@ import { codexAccountModeState, type CodexAccountModeState } from "../codex-mult
export default function CodexAuth({ apiBase }: { apiBase: string }) {
const t = useT();
const [accountModeState, setAccountModeState] = useState<CodexAccountModeState | null>(null);
const [enableBusy, setEnableBusy] = useState(false);
const [enableError, setEnableError] = useState("");

const loadMode = useCallback(async () => {
try {
Expand All @@ -25,33 +65,28 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) {
return () => { window.clearTimeout(timeout); window.clearInterval(iv); };
}, [loadMode]);

const banner = (
<div className="panel" style={{ marginBottom: 16 }}>
<div className="row">
<strong>{t("codexAuth.accountModeTitle")}</strong>
{accountModeState === "pool" && <span className="badge badge-accent">{t("codexAuth.accountModePool")}</span>}
{accountModeState === "direct" && <span className="badge badge-green">{t("codexAuth.accountModeDirect")}</span>}
</div>
{accountModeState === "pool" && (
<p className="card-sub" style={{ margin: "6px 0 0" }}>{t("codexAuth.accountModePoolDesc")}</p>
)}
{accountModeState === "direct" && (
<p className="card-sub" style={{ margin: "6px 0 0" }}>
{t("codexAuth.accountModeDirectDesc")} <a href="#providers">{t("codexAuth.openProviders")}</a>
</p>
)}
{accountModeState === "absent" && (
<p className="card-sub" style={{ margin: "8px 0 0" }}>
{t("codexAuth.openaiMissing")} <a href="#providers">{t("codexAuth.openProviders")}</a>
</p>
)}
{accountModeState === "disabled" && (
<p className="card-sub" style={{ margin: "8px 0 0" }}>
{t("codexAuth.openaiDisabled")} <a href="#providers">{t("codexAuth.openProviders")}</a>
</p>
)}
</div>
);
const enableOpenAi = async () => {
setEnableBusy(true);
setEnableError("");
try {
if (accountModeState !== "absent" && accountModeState !== "disabled") return;
await ensureOpenAiProvider(apiBase, accountModeState);
await loadMode();
} catch (error) {
setEnableError(error instanceof Error ? error.message : t("prov.saveFailed"));
} finally {
setEnableBusy(false);
}
};

const banner = <>
<OpenAiAccountModeBanner
state={accountModeState}
busy={enableBusy}
onEnable={() => { void enableOpenAi(); }}
/>
{enableError && <div className="notice notice-err" role="alert">{enableError}</div>}
</>;

return <CodexAccountPool apiBase={apiBase} accountModeState={accountModeState} banner={banner} />;
}
29 changes: 24 additions & 5 deletions gui/src/pages/Providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import ProviderWorkspaceShell, { type AddProviderIntent } from "../components/pr
import ProviderDetails from "../components/provider-workspace/ProviderDetails";
import { RemoveConfirmDialog, UnsavedLeaveDialog } from "../components/provider-workspace/ProviderDialogs";
import type { WorkspaceProvider } from "../provider-workspace/catalog";
import { codexAccountProviderNames, ensureOpenAiProvider, openAiAccountProviderState } from "../provider-payload";
import type { ProviderUpdatePatch } from "../components/provider-workspace/types";
import { oauthTosRisk } from "../oauth-tos-risk";
import { Notice } from "../ui";
Expand Down Expand Up @@ -501,10 +502,8 @@ export default function Providers({ apiBase, viewMode }: { apiBase: string; view
}

const addModalAccountRows = [
...Object.entries(config.providers)
.filter(([, prov]) => prov.authMode === "forward")
.sort(([a], [b]) => a.localeCompare(b))
.map(([name]) => ({
...codexAccountProviderNames(config.providers)
.map(name => ({
id: name,
label: formatProviderDisplayName(name),
kind: "codex" as const,
Expand All @@ -525,7 +524,26 @@ export default function Providers({ apiBase, viewMode }: { apiBase: string; view
}
}

const onAccountLogin = (provider: string) => {
const onAccountLogin = async (provider: string) => {
if (provider === "openai") {
const configured = config.providers.openai;
const state = openAiAccountProviderState(configured);
if (state === "invalid") {
notify(t("codexAuth.openaiMissing"), false);
return;
}
if (state === "absent" || state === "disabled") {
try {
await ensureOpenAiProvider(apiBase, state);
await fetchConfig();
} catch (error) {
notify(error instanceof Error ? error.message : t("prov.saveFailed"), false);
return;
}
}
setCodexLoginOpen(true);
return;
}
Comment on lines +527 to +546

@coderabbitai coderabbitai Bot Jul 25, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

No direct test coverage for onAccountLogin's new "openai" async branch.

The new absent/disabled/error-notify paths in this handler (lines 527-545) aren't covered by any test file in this batch — gui/tests/codex-auth-provider-enable.test.tsx (per graph evidence) only renders OpenAiAccountModeBanner from CodexAuth.tsx, not this Providers.tsx handler. Given this is one of the two primary recovery entry points called out in the PR objectives, a focused test (mocking fetch/ensureOpenAiProvider and asserting setCodexLoginOpen/notify behavior for absent, disabled, and error cases) would meaningfully de-risk this path.

Want me to draft a test for onAccountLogin's openai branch (absent → ensureOpenAiProvider success/failure, disabled → success/failure, already-enabled non-canonical → notify) next to the existing Providers.tsx GUI tests?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@gui/src/pages/Providers.tsx` around lines 527 - 545, Add focused tests for
the openai branch of onAccountLogin in Providers.tsx, covering absent and
disabled providers with successful ensureOpenAiProvider calls, failures that
trigger notify without opening the login dialog, and already-enabled
non-canonical configurations that notify instead of proceeding. Mock
ensureOpenAiProvider and fetchConfig as needed, and assert setCodexLoginOpen,
notify, and fetchConfig behavior for each case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed the risky decision branch in ba5d9ca without adding a brittle full Providers component mock. The OpenAI state decision is now a pure openAiAccountProviderState function with real tests for absent, canonical-disabled, canonical-ready, and noncanonical-disabled inputs; ensureOpenAiProvider already has request-level tests for absent POST and disabled PATCH. The handler consumes those tested results, while the existing browser validation covers opening the account flow. A direct handler mock would mostly duplicate React state plumbing rather than exercise additional behavior.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '== Relevant files =='
fd -t f . gui tests | rg '(provider-payload|Providers).*\.tsx?$|provider-payload.*test'

printf '%s\n' '== provider-payload exports and implementations =='
ast-grep outline gui/src/provider-payload.ts --items all --view expanded
rg -n -A35 -B8 '\b(openAiAccountProviderState|ensureOpenAiProvider|codexAccountProviderNames)\b' gui/src/provider-payload.ts

printf '%s\n' '== Focused test assertions =='
rg -n -A22 -B8 '\b(openAiAccountProviderState|ensureOpenAiProvider)\b' tests/provider-payload.test.ts

printf '%s\n' '== Commit metadata and changed paths =='
git show --no-ext-diff --format=fuller --name-status ba5d9ca1 --

Length of output: 14994


@apple-ouyang, the extraction is a good improvement and covers the core decision table without a brittle Providers.tsx component mock:

  • gui/src/provider-payload.ts Lines 28-34 now centralizes canonical/invalid/disabled classification.
  • tests/provider-payload.test.ts Lines 29-45 covers the stated absent, canonical-disabled, canonical-ready, and noncanonical-disabled cases.
  • tests/provider-payload.test.ts Lines 47-96 verifies the successful absent POST and disabled PATCH recovery requests.

However, I can’t mark the original finding addressed yet because the failure paths remain untested. In gui/src/pages/Providers.tsx, an ensureOpenAiProvider() rejection is caught, notified, and must return without calling setCodexLoginOpen(true). The current request-level tests only cover successful responses, and the cited browser validation does not demonstrate failed preset/POST/PATCH behavior.

A lightweight alternative to a full component mock would be to add rejection assertions for ensureOpenAiProvider itself (failed preset load, failed create, and failed enable). That would validate the error messages reaching the handler; a minimal UI-level failure assertion would still be needed to prove the modal stays closed.


🧬 Code Graph Analysis Results

gui/src/Providers.tsx (onAccountLogin handler for OpenAI) — ~line 310–345

const onAccountLogin = async (provider: string) => {
  if (provider === "openai") {
    const configured = config.providers.openai;
    const state = openAiAccountProviderState(configured);
    if (state === "invalid") {
      notify(t("codexAuth.openaiMissing"), false);
      return;
    }
    if (state === "absent" || state === "disabled") {
      try {
        await ensureOpenAiProvider(apiBase, state);
        await fetchConfig();
      } catch (error) {
        notify(error instanceof Error ? error.message : t("prov.saveFailed"), false);
        return;
      }
    }
    setCodexLoginOpen(true);
    return;
  }
  if (isForwardProvider(provider)) {
    setCodexLoginOpen(true);
    return;
  }
  // API-key rows have no OAuth login path (catalog hides the button).
  if (config.providers[provider]?.authMode === "oauth" || oauthProviders.includes(provider)) {
    requestLoginOAuth(provider);
  }
};

gui/src/provider-payload.ts (pure decision function) — lines 28–34

export function openAiAccountProviderState(
  provider: { adapter?: string; authMode?: string; disabled?: boolean } | undefined,
): "absent" | "disabled" | "ready" | "invalid" {
  if (!provider) return "absent";
  if (provider.adapter !== "openai-responses" || provider.authMode !== "forward") return "invalid";
  return provider.disabled === true ? "disabled" : "ready";
}

gui/src/provider-payload.ts (request-level ensure/enable handler) — lines 99–129

export async function ensureOpenAiProvider(
  apiBase: string,
  state: "absent" | "disabled",
  fetchImpl: typeof fetch = fetch,
): Promise<void> {
  if (state === "disabled") {
    const response = await fetchImpl(`${apiBase}/api/providers?name=openai`, {
      method: "PATCH",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ disabled: false }),
    });
    if (response.ok) return;
    const body = await response.json().catch(() => ({})) as { error?: unknown };
    throw new Error(typeof body.error === "string" ? body.error : "Failed to enable the OpenAI provider");
  }

  const presetsResponse = await fetchImpl(`${apiBase}/api/provider-presets`);
  if (!presetsResponse.ok) throw new Error("Failed to load the OpenAI provider preset");
  const data = await presetsResponse.json() as { providers?: ProviderPostPreset[] };
  const preset = data.providers?.find(provider => provider.id === "openai");
  if (!preset) throw new Error("OpenAI provider preset is unavailable");

  const response = await fetchImpl(`${apiBase}/api/providers`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(buildReservedProviderPostBody(preset)),
  });
  if (response.ok) return;
  const body = await response.json().catch(() => ({})) as { error?: unknown };
  throw new Error(typeof body.error === "string" ? body.error : "Failed to enable the OpenAI provider");
}

if (isForwardProvider(provider)) {
setCodexLoginOpen(true);
return;
Expand All @@ -545,6 +563,7 @@ export default function Providers({ apiBase, viewMode }: { apiBase: string; view
onAdded={() => {
setCodexLoginOpen(false);
notify(t("prov.loginOk", { provider: formatProviderDisplayName("openai"), cmd: "ocx sync" }), true);
void fetchConfig();
void fetchOauth();
void fetchProviderQuotas(true);
bumpModelsRefresh();
Expand Down
67 changes: 62 additions & 5 deletions gui/src/provider-payload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,25 @@ export interface ProviderPostPreset {
provider?: ProviderPayload;
}

export function codexAccountProviderNames(
providers: Record<string, { authMode?: string }>,
): string[] {
const configuredForward = Object.entries(providers)
.filter(([, provider]) => provider.authMode === "forward")
.map(([name]) => name)
.filter(name => name !== "openai")
.sort((a, b) => a.localeCompare(b));
return ["openai", ...configuredForward];
}

export function openAiAccountProviderState(
provider: { adapter?: string; authMode?: string; disabled?: boolean } | undefined,
): "absent" | "disabled" | "ready" | "invalid" {
if (!provider) return "absent";
if (provider.adapter !== "openai-responses" || provider.authMode !== "forward") return "invalid";
return provider.disabled === true ? "disabled" : "ready";
}

export type CodexPresetDescriptionKey = "prov.openaiPoolDesc" | "prov.openaiDirectDesc";

export function isReservedCodexForwardPreset(preset: ProviderPostPreset): boolean {
Expand Down Expand Up @@ -62,11 +81,49 @@ export function buildProviderPostBody(
form: ProviderPayloadForm,
): { name: string; provider: ProviderPayload } {
if (isReservedCodexForwardPreset(preset)) {
if (!preset.provider) throw new Error(`Missing canonical provider seed for ${preset.id}`);
return {
name: preset.id,
provider: JSON.parse(JSON.stringify(preset.provider)) as ProviderPayload,
};
return buildReservedProviderPostBody(preset);
}
return { name: form.name.trim(), provider: buildProviderPayload(form) };
}

function buildReservedProviderPostBody(
preset: ProviderPostPreset,
): { name: string; provider: ProviderPayload } {
if (!preset.provider) throw new Error(`Missing canonical provider seed for ${preset.id}`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Hardcoded English error strings bypass i18n.

buildReservedProviderPostBody and ensureOpenAiProvider throw plain English strings ("Missing canonical provider seed for ${preset.id}", "Failed to enable the OpenAI provider", "Failed to load the OpenAI provider preset", "OpenAI provider preset is unavailable"). Per the graph evidence in gui/src/pages/Providers.tsx (onAccountLogin) and gui/src/pages/CodexAuth.tsx (enableOpenAi), error.message is shown directly to the user via notify()/setEnableError(). Since this cohort already adds locale keys for the enable-flow UI copy, these thrown messages should use the same i18n mechanism (e.g. return an error code the caller maps to a t(...) key) instead of raw English text.

As per path instructions, "user-visible strings go through the i18n locale files rather than hardcoded text."

Also applies to: 111-113, 115-116, 119-119, 126-129

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@gui/src/provider-payload.ts` at line 92, Replace the hardcoded user-visible
errors in buildReservedProviderPostBody and ensureOpenAiProvider with the
existing i18n error-handling mechanism, such as propagating stable error codes
that callers map through t(...). Add or reuse locale keys for the missing seed,
enable failure, preset load failure, and unavailable preset cases, and ensure
onAccountLogin and enableOpenAi display translated messages instead of raw
error.message text.

Source: Path instructions

return {
name: preset.id,
provider: JSON.parse(JSON.stringify(preset.provider)) as ProviderPayload,
};
}

export async function ensureOpenAiProvider(
apiBase: string,
state: "absent" | "disabled",
fetchImpl: typeof fetch = fetch,
): Promise<void> {
if (state === "disabled") {
const response = await fetchImpl(`${apiBase}/api/providers?name=openai`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ disabled: false }),
Comment on lines +105 to +108

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Rebuild noncanonical disabled OpenAI providers

When an existing providers.openai row is disabled but is not the canonical Codex-forward provider (for example an older/API-key OpenAI config accepted from disk), this path only PATCHes disabled:false and the callers then open the Codex login flow as if OpenAI was restored. The provider remains key/custom mode, so Codex Auth can show Pool/Direct while requests still do not route through the Codex account provider; verify the canonical shape before this disabled fast path, or replace it from the canonical preset when it is not canonical.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ba5d9ca. The provider shape is now classified before the disabled path: only canonical openai-responses + forward rows can be re-enabled. Disabled legacy/API-key rows are treated as invalid and reported without mutation or opening the Codex login flow. The new pure-state regression test covers absent, canonical disabled, canonical ready, and noncanonical disabled cases.

});
if (response.ok) return;
const body = await response.json().catch(() => ({})) as { error?: unknown };
throw new Error(typeof body.error === "string" ? body.error : "Failed to enable the OpenAI provider");
}
Comment on lines +110 to +113

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated response-error parsing.

The failed-response handling (response.json().catch(...), extract error, throw fallback) is duplicated verbatim between the "disabled" PATCH path and the "absent" POST path.

♻️ Proposed refactor
+async function throwProviderApiError(response: Response, fallback: string): Promise<never> {
+  const body = await response.json().catch(() => ({})) as { error?: unknown };
+  throw new Error(typeof body.error === "string" ? body.error : fallback);
+}
+
 export async function ensureOpenAiProvider(
   apiBase: string,
   state: "absent" | "disabled",
   fetchImpl: typeof fetch = fetch,
 ): Promise<void> {
   if (state === "disabled") {
     const response = await fetchImpl(`${apiBase}/api/providers?name=openai`, {
       method: "PATCH",
       headers: { "Content-Type": "application/json" },
       body: JSON.stringify({ disabled: false }),
     });
     if (response.ok) return;
-    const body = await response.json().catch(() => ({})) as { error?: unknown };
-    throw new Error(typeof body.error === "string" ? body.error : "Failed to enable the OpenAI provider");
+    await throwProviderApiError(response, "Failed to enable the OpenAI provider");
   }
   ...
   if (response.ok) return;
-  const body = await response.json().catch(() => ({})) as { error?: unknown };
-  throw new Error(typeof body.error === "string" ? body.error : "Failed to enable the OpenAI provider");
+  await throwProviderApiError(response, "Failed to enable the OpenAI provider");
 }

Also applies to: 126-129

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@gui/src/provider-payload.ts` around lines 110 - 113, Extract the duplicated
failed-response parsing from the “disabled” PATCH and “absent” POST paths into a
shared helper in provider-payload.ts. Have both paths reuse it after their
response.ok checks, preserving the existing error extraction and fallback
message behavior.


const presetsResponse = await fetchImpl(`${apiBase}/api/provider-presets`);
if (!presetsResponse.ok) throw new Error("Failed to load the OpenAI provider preset");
const data = await presetsResponse.json() as { providers?: ProviderPostPreset[] };
const preset = data.providers?.find(provider => provider.id === "openai");
if (!preset) throw new Error("OpenAI provider preset is unavailable");

const response = await fetchImpl(`${apiBase}/api/providers`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(buildReservedProviderPostBody(preset)),
});
if (response.ok) return;
const body = await response.json().catch(() => ({})) as { error?: unknown };
throw new Error(typeof body.error === "string" ? body.error : "Failed to enable the OpenAI provider");
}
Comment on lines +99 to +129

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

No timeout on the recovery fetches.

None of the three fetchImpl calls in ensureOpenAiProvider set a timeout/abort signal. If the local management server stalls, the caller's busy state (Providers.tsx/CodexAuth.tsx) never resolves, leaving the "Enable OpenAI" action stuck with no way to retry short of a reload.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@gui/src/provider-payload.ts` around lines 99 - 129, Update
ensureOpenAiProvider so all three fetchImpl calls use a bounded timeout via an
AbortController signal, aborting stalled requests and allowing the caller to
resolve and retry. Apply the same timeout behavior to the PATCH, preset GET, and
provider POST requests without changing their existing success and error
handling.

52 changes: 52 additions & 0 deletions gui/tests/codex-auth-provider-enable.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { afterEach, beforeEach, expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import { LanguageProvider } from "../src/i18n/provider";
import { OpenAiAccountModeBanner } from "../src/pages/CodexAuth";

let previousLanguageDescriptor: PropertyDescriptor | undefined;

beforeEach(() => {
previousLanguageDescriptor = Object.getOwnPropertyDescriptor(globalThis.navigator, "language");
Object.defineProperty(globalThis.navigator, "language", {
configurable: true,
value: "en-US",
});
});

afterEach(() => {
if (previousLanguageDescriptor) {
Object.defineProperty(globalThis.navigator, "language", previousLanguageDescriptor);
} else {
Reflect.deleteProperty(globalThis.navigator, "language");
}
});

test("missing OpenAI provider offers an in-place enable action", () => {
const html = renderToStaticMarkup(
<LanguageProvider>
<OpenAiAccountModeBanner state="absent" busy={false} onEnable={() => undefined} />
</LanguageProvider>,
);

expect(html).toContain("Your OpenAI accounts are still available");
expect(html).toContain("Enable OpenAI");
expect(html).not.toContain('href="#providers"');
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

test("disabled and busy OpenAI provider states keep the recovery action clear", () => {
const disabledHtml = renderToStaticMarkup(
<LanguageProvider>
<OpenAiAccountModeBanner state="disabled" busy={false} onEnable={() => undefined} />
</LanguageProvider>,
);
const busyHtml = renderToStaticMarkup(
<LanguageProvider>
<OpenAiAccountModeBanner state="disabled" busy={true} onEnable={() => undefined} />
</LanguageProvider>,
);

expect(disabledHtml).toContain("Your OpenAI accounts are still available");
expect(disabledHtml).toContain("Enable OpenAI");
expect(busyHtml).toContain("Enabling...");
expect(busyHtml).toContain("disabled=\"\"");
});
4 changes: 3 additions & 1 deletion src/server/management/provider-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
}
// Hostname destinations additionally get a DNS-resolved SSRF check at write time —
// the sync check above only classifies literal IPs (review finding, PR #96).
const resolvedError = await providerDestinationResolvedError(name, prov);
const resolvedError = name === "openai" && isCanonicalOpenAiForwardProvider(prov)
? null
: await providerDestinationResolvedError(name, prov);
if (resolvedError) return jsonResponse({ error: resolvedError }, 400);
// Catalog providers (e.g. ollama-cloud) carry a models + vision/reasoning classification the GUI
// doesn't send — merge it in so the sidecars are gated correctly.
Expand Down
Loading
Loading