diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 23b7e6355a..128bf7165e 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -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", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 772cc84d3a..af059f87a4 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -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", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 2adafa1de9..e26a429648 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -859,6 +859,9 @@ export const ja: Record = { "codexAuth.accountModeDirectDesc": "リクエストはメインログインのみを使用します; 追加アカウントはプールモード用に保持されます。", "codexAuth.openaiMissing": "組み込みの OpenAI プロバイダーが設定されていません。", "codexAuth.openaiDisabled": "組み込みの OpenAI プロバイダーが無効です。", + "codexAuth.openaiUnavailableDesc": "OpenAI アカウントは引き続き利用できます。Codex リクエストをルーティングするにはプロバイダーを有効にしてください。", + "codexAuth.enableOpenai": "OpenAI を有効にする", + "codexAuth.enablingOpenai": "有効化中...", "codexAuth.openProviders": "プロバイダーを開く", "codexAuth.add": "追加", "codexAuth.refreshQuota": "クォータを更新", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 777ed16c9f..840e5a9b2d 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -621,6 +621,9 @@ export const ko: Record = { "codexAuth.accountModeDirectDesc": "요청은 메인 로그인만 사용하며, 추가 계정은 풀 모드용으로 계속 저장됩니다.", "codexAuth.openaiMissing": "내장 OpenAI 프로바이더가 설정되지 않았습니다.", "codexAuth.openaiDisabled": "내장 OpenAI 프로바이더가 비활성화되어 있습니다.", + "codexAuth.openaiUnavailableDesc": "OpenAI 계정은 그대로 사용할 수 있습니다. Codex 요청을 라우팅하려면 프로바이더를 활성화하세요.", + "codexAuth.enableOpenai": "OpenAI 활성화", + "codexAuth.enablingOpenai": "활성화 중...", "codexAuth.openProviders": "프로바이더 열기", "codexAuth.add": "추가", "codexAuth.refreshQuota": "할당량 새로고침", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index e3efe039aa..beb035de50 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -904,6 +904,9 @@ export const ru: Record = { "codexAuth.accountModeDirectDesc": "Запросы используют только основной вход; добавленные аккаунты сохраняются для режима пула.", "codexAuth.openaiMissing": "Встроенный провайдер OpenAI не настроен.", "codexAuth.openaiDisabled": "Встроенный провайдер OpenAI отключён.", + "codexAuth.openaiUnavailableDesc": "Ваши аккаунты OpenAI по-прежнему доступны. Включите провайдера для маршрутизации запросов Codex.", + "codexAuth.enableOpenai": "Включить OpenAI", + "codexAuth.enablingOpenai": "Включение...", "codexAuth.openProviders": "Открыть провайдеров", "codexAuth.add": "Добавить", "codexAuth.refreshQuota": "Обновить квоты", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 9b2e162d05..4baa02d101 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -621,6 +621,9 @@ export const zh: Record = { "codexAuth.accountModeDirectDesc": "请求仅使用主登录;已添加账户会继续存储,供账户池模式使用。", "codexAuth.openaiMissing": "未配置内置 OpenAI 提供方。", "codexAuth.openaiDisabled": "内置 OpenAI 提供方已禁用。", + "codexAuth.openaiUnavailableDesc": "你的 OpenAI 账号仍然可用。启用提供方后即可路由 Codex 请求。", + "codexAuth.enableOpenai": "启用 OpenAI", + "codexAuth.enablingOpenai": "正在启用...", "codexAuth.openProviders": "打开提供商", "codexAuth.add": "添加", "codexAuth.refreshQuota": "刷新额度", diff --git a/gui/src/pages/CodexAuth.tsx b/gui/src/pages/CodexAuth.tsx index 4ee6d9a44c..f158ffbad0 100644 --- a/gui/src/pages/CodexAuth.tsx +++ b/gui/src/pages/CodexAuth.tsx @@ -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 ( +
+
+ {t("codexAuth.accountModeTitle")} + {state === "pool" && {t("codexAuth.accountModePool")}} + {state === "direct" && {t("codexAuth.accountModeDirect")}} +
+ {state === "pool" && ( +

{t("codexAuth.accountModePoolDesc")}

+ )} + {state === "direct" && ( +

+ {t("codexAuth.accountModeDirectDesc")} {t("codexAuth.openProviders")} +

+ )} + {(state === "absent" || state === "disabled") && ( +
+

{t("codexAuth.openaiUnavailableDesc")}

+ +
+ )} +
+ ); +} /** * Codex Auth page — a thin wrapper around CodexAccountPool (WP060 extraction). @@ -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(null); + const [enableBusy, setEnableBusy] = useState(false); + const [enableError, setEnableError] = useState(""); const loadMode = useCallback(async () => { try { @@ -25,33 +65,28 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) { return () => { window.clearTimeout(timeout); window.clearInterval(iv); }; }, [loadMode]); - const banner = ( -
-
- {t("codexAuth.accountModeTitle")} - {accountModeState === "pool" && {t("codexAuth.accountModePool")}} - {accountModeState === "direct" && {t("codexAuth.accountModeDirect")}} -
- {accountModeState === "pool" && ( -

{t("codexAuth.accountModePoolDesc")}

- )} - {accountModeState === "direct" && ( -

- {t("codexAuth.accountModeDirectDesc")} {t("codexAuth.openProviders")} -

- )} - {accountModeState === "absent" && ( -

- {t("codexAuth.openaiMissing")} {t("codexAuth.openProviders")} -

- )} - {accountModeState === "disabled" && ( -

- {t("codexAuth.openaiDisabled")} {t("codexAuth.openProviders")} -

- )} -
- ); + 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 = <> + { void enableOpenAi(); }} + /> + {enableError &&
{enableError}
} + ; return ; } diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index 590b63a335..5d05ce2980 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -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"; @@ -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, @@ -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; + } if (isForwardProvider(provider)) { setCodexLoginOpen(true); return; @@ -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(); diff --git a/gui/src/provider-payload.ts b/gui/src/provider-payload.ts index 5b975a3bc5..364041ad4b 100644 --- a/gui/src/provider-payload.ts +++ b/gui/src/provider-payload.ts @@ -14,6 +14,25 @@ export interface ProviderPostPreset { provider?: ProviderPayload; } +export function codexAccountProviderNames( + providers: Record, +): 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 { @@ -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}`); + 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 { + 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"); +} diff --git a/gui/tests/codex-auth-provider-enable.test.tsx b/gui/tests/codex-auth-provider-enable.test.tsx new file mode 100644 index 0000000000..6fb822572d --- /dev/null +++ b/gui/tests/codex-auth-provider-enable.test.tsx @@ -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( + + undefined} /> + , + ); + + expect(html).toContain("Your OpenAI accounts are still available"); + expect(html).toContain("Enable OpenAI"); + expect(html).not.toContain('href="#providers"'); +}); + +test("disabled and busy OpenAI provider states keep the recovery action clear", () => { + const disabledHtml = renderToStaticMarkup( + + undefined} /> + , + ); + const busyHtml = renderToStaticMarkup( + + undefined} /> + , + ); + + expect(disabledHtml).toContain("Your OpenAI accounts are still available"); + expect(disabledHtml).toContain("Enable OpenAI"); + expect(busyHtml).toContain("Enabling..."); + expect(busyHtml).toContain("disabled=\"\""); +}); diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 1f848c7837..59a00529a3 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -101,7 +101,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { } }); + test("canonical OpenAI bypasses fake-IP DNS rejection without weakening custom providers", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + const liveConfig: OcxConfig = { + port: 0, + defaultProvider: "test-openai", + providers: { + "test-openai": { + adapter: "openai-chat", + baseUrl: "https://api.example.test/v1", + apiKey: "sk-secret-value", + }, + }, + }; + saveConfig(liveConfig); + const resolvedError = spyOn(destinationPolicy, "providerDestinationResolvedError") + .mockResolvedValue("baseUrl hostname resolves to a benchmark network (198.18.0.30)"); + + try { + const post = (body: unknown) => { + const request = new Request("http://127.0.0.1/api/providers", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + return handleManagementAPI(request, new URL(request.url), liveConfig, { + refreshCodexCatalog: async () => undefined, + }); + }; + const canonical = await post({ name: "openai", provider: canonicalDirect }); + expect(canonical?.status).toBe(200); + expect(resolvedError).not.toHaveBeenCalled(); + + const custom = await post({ + name: "custom", + provider: { adapter: "openai-chat", baseUrl: "https://custom.example.test/v1" }, + }); + expect(custom?.status).toBe(400); + expect(resolvedError).toHaveBeenCalledTimes(1); + } finally { + resolvedError.mockRestore(); + } + }); + test("provider mode PATCH is strict, persists live state, clears caches and affinity, and primes Pool only", async () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true }); diff --git a/tests/provider-payload.test.ts b/tests/provider-payload.test.ts index 5e2e01cc14..400630c4ef 100644 --- a/tests/provider-payload.test.ts +++ b/tests/provider-payload.test.ts @@ -2,8 +2,11 @@ import { describe, expect, test } from "bun:test"; import { buildProviderPayload, buildProviderPostBody, + codexAccountProviderNames, codexPresetDescriptionKey, + ensureOpenAiProvider, isReservedCodexForwardPreset, + openAiAccountProviderState, } from "../gui/src/provider-payload"; import { en } from "../gui/src/i18n/en"; import { ko } from "../gui/src/i18n/ko"; @@ -13,6 +16,86 @@ import { deriveProviderPresets, providerConfigSeed } from "../src/providers/deri import { PROVIDER_REGISTRY } from "../src/providers/registry"; describe("provider dashboard payload", () => { + test("keeps OpenAI in the account entry even when its provider is absent", () => { + expect(codexAccountProviderNames({ + custom: { authMode: "forward" }, + anthropic: { authMode: "oauth" }, + })).toEqual(["openai", "custom"]); + expect(codexAccountProviderNames({ + openai: { authMode: "forward" }, + })).toEqual(["openai"]); + }); + + test("rejects noncanonical OpenAI providers before enabling account login", () => { + expect(openAiAccountProviderState(undefined)).toBe("absent"); + expect(openAiAccountProviderState({ + adapter: "openai-responses", + authMode: "forward", + disabled: true, + })).toBe("disabled"); + expect(openAiAccountProviderState({ + adapter: "openai-responses", + authMode: "forward", + })).toBe("ready"); + expect(openAiAccountProviderState({ + adapter: "openai-responses", + authMode: "key", + disabled: true, + })).toBe("invalid"); + }); + + test("enables OpenAI through the existing provider preset and provider POST", async () => { + const requests: Array<{ path: string; method: string; body?: unknown }> = []; + const preset = deriveProviderPresets().find(row => row.id === "openai")!; + const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input)); + requests.push({ + path: url.pathname, + method: init?.method ?? "GET", + ...(typeof init?.body === "string" ? { body: JSON.parse(init.body) } : {}), + }); + if (url.pathname === "/api/provider-presets") { + return Response.json({ providers: [preset] }); + } + if (url.pathname === "/api/providers") { + return Response.json({ success: true, name: "openai" }); + } + return new Response("not found", { status: 404 }); + }) as typeof fetch; + + await ensureOpenAiProvider("http://localhost:10100", "absent", fetchImpl); + + expect(requests).toEqual([ + { path: "/api/provider-presets", method: "GET" }, + { + path: "/api/providers", + method: "POST", + body: { name: "openai", provider: preset.provider }, + }, + ]); + }); + + test("re-enables OpenAI without replacing its existing provider config", async () => { + const requests: Array<{ path: string; method: string; body?: unknown }> = []; + const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input)); + requests.push({ + path: `${url.pathname}${url.search}`, + method: init?.method ?? "GET", + ...(typeof init?.body === "string" ? { body: JSON.parse(init.body) } : {}), + }); + return Response.json({ success: true }); + }) as typeof fetch; + + await ensureOpenAiProvider("http://localhost:10100", "disabled", fetchImpl); + + expect(requests).toEqual([{ + path: "/api/providers?name=openai", + method: "PATCH", + body: { disabled: false }, + }]); + }); + test("persists explicit API-key mode for built-in OAuth providers", () => { expect(buildProviderPayload({ name: "xai",