diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 0da0b490dc..66b81b71ce 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -19,6 +19,11 @@ The API route publishes 1,050,000 context / 922,000 max input metadata. Its `sol-pro`, `terra-pro`, and `luna-pro` virtual ids keep their selected public identity while the wire uses the base model plus `reasoning.mode: "pro"`. +If the built-in `openai` provider is missing or disabled, the dashboard Accounts picker and Codex +Auth page can restore it: absent rows are created from the canonical preset, disabled canonical +rows are re-enabled without replacing saved mode or model settings, and noncanonical `openai` +rows are not offered that recovery path. + Shipped v1 configs migrate automatically to marker 2 and one option-aware row. The original config is retained once at `~/.opencodex/config.json.pre-openai-tiers-v2.bak`; restore it with `cp ~/.opencodex/config.json.pre-openai-tiers-v2.bak ~/.opencodex/config.json`. diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index 76d08821cf..1cbab2364f 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -18,6 +18,8 @@ bare `gpt-5.6-sol` は Providers ページの Pool/Direct オプションに従 max input 922,000 で `*-pro` virtual ID は公開状態を維持し、wire でベースモデルと `reasoning.mode: "pro"` に切り替わります。 +組み込み `openai` が欠落または無効な場合、ダッシュボードの Accounts ピッカーと Codex Auth から復元できます。欠落行は正規プリセットから作成され、正規の無効行は保存済みのモードやモデル設定を置き換えずに再有効化され、非正規の `openai` 行にはその復元経路は出ません。 + 出荷版 v1 config は marker 2 の単一オプション行に自動移行されます。オリジナルは `~/.opencodex/config.json.pre-openai-tiers-v2.bak` に一度保存され、次のコマンドで復元します: `cp ~/.opencodex/config.json.pre-openai-tiers-v2.bak ~/.opencodex/config.json`。 diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index e98d8ecd97..cc4e305460 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -18,6 +18,8 @@ bare `gpt-5.6-sol`은 Providers 페이지의 Pool/Direct 옵션을 따르고, max input 922,000이며 `*-pro` virtual id는 공개 상태에 유지되고 wire에서 base 모델과 `reasoning.mode: "pro"`로 바뀝니다. +내장 `openai` 제공자가 없거나 비활성화된 경우 대시보드 Accounts 선택기와 Codex Auth 페이지에서 복구할 수 있습니다. 없는 항목은 정규 프리셋으로 만들고, 비활성화된 정규 항목은 저장된 모드/모델 설정을 바꾸지 않고 다시 켜며, 비정규 `openai` 항목에는 그 복구 경로를 제공하지 않습니다. + shipped v1 config는 marker 2의 단일 옵션 행으로 자동 이관됩니다. 원본은 `~/.opencodex/config.json.pre-openai-tiers-v2.bak`에 한 번 보존되며 다음 명령으로 복원합니다: `cp ~/.opencodex/config.json.pre-openai-tiers-v2.bak ~/.opencodex/config.json`. diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 0bafec83b6..5b9084868c 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -20,6 +20,11 @@ description: Все способы, которыми opencodex аутентиф виртуальные id `sol-pro`, `terra-pro` и `luna-pro` сохраняют выбранную публичную идентичность, тогда как в фактическом запросе используется базовая модель плюс `reasoning.mode: "pro"`. +Если встроенный провайдер `openai` отсутствует или отключён, его можно восстановить из выбора Accounts +на панели и со страницы Codex Auth: отсутствующие записи создаются из канонического пресета, отключённые +канонические записи включаются без замены сохранённого режима и настроек моделей, а неканонические +записи `openai` этот путь восстановления не получают. + Поставляемые v1-конфигурации автоматически мигрируют на маркер 2 и одну строку с поддержкой опций. Исходная конфигурация один раз сохраняется в `~/.opencodex/config.json.pre-openai-tiers-v2.bak`; восстановить её можно командой diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index c52a6dfa4d..24073ccc4b 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -17,6 +17,8 @@ bare `gpt-5.6-sol` 遵循 Providers 页面中的 Pool/Direct 选项, 922,000 max input;`*-pro` virtual id 保留在公开状态中,线上改写为 base 模型加 `reasoning.mode: "pro"`。 +若内置 `openai` 提供商缺失或已禁用,可在仪表盘 Accounts 选择器或 Codex Auth 页面恢复:缺失行会从规范预设创建,已禁用的规范行会在不替换已保存模式/模型设置的情况下重新启用,非规范的 `openai` 行不会提供该恢复路径。 + shipped v1 配置自动迁移到 marker 2 的单一选项行。原配置只保留一次到 `~/.opencodex/config.json.pre-openai-tiers-v2.bak`;恢复命令: `cp ~/.opencodex/config.json.pre-openai-tiers-v2.bak ~/.opencodex/config.json`。 diff --git a/gui/src/components/provider-catalog/ProviderCatalog.tsx b/gui/src/components/provider-catalog/ProviderCatalog.tsx index 47e1201b38..6f025bb0e1 100644 --- a/gui/src/components/provider-catalog/ProviderCatalog.tsx +++ b/gui/src/components/provider-catalog/ProviderCatalog.tsx @@ -152,8 +152,12 @@ export default function ProviderCatalog({ {t("modal.accountManage")} )} {onLogin && ( - )} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 57024d2fff..76639a7f82 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -610,6 +610,12 @@ export const de: Record = { "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.enableOpenaiFailed": "OpenAI-Anbieter konnte nicht aktiviert werden.", + "codexAuth.openaiPresetLoadFailed": "OpenAI-Anbieter-Preset konnte nicht geladen werden.", + "codexAuth.openaiPresetUnavailable": "OpenAI-Anbieter-Preset ist nicht verfügbar.", "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 2f9b40fe3f..b6712e9fb5 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -908,6 +908,12 @@ 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.enableOpenaiFailed": "Failed to enable the OpenAI provider.", + "codexAuth.openaiPresetLoadFailed": "Failed to load the OpenAI provider preset.", + "codexAuth.openaiPresetUnavailable": "OpenAI provider preset is unavailable.", "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 93e68fd825..afb6878065 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -862,6 +862,12 @@ export const ja: Record = { "codexAuth.accountModeDirectDesc": "リクエストはメインログインのみを使用します; 追加アカウントはプールモード用に保持されます。", "codexAuth.openaiMissing": "組み込みの OpenAI プロバイダーが設定されていません。", "codexAuth.openaiDisabled": "組み込みの OpenAI プロバイダーが無効です。", + "codexAuth.openaiUnavailableDesc": "OpenAI アカウントは引き続き利用できます。Codex リクエストをルーティングするにはプロバイダーを有効にしてください。", + "codexAuth.enableOpenai": "OpenAI を有効にする", + "codexAuth.enablingOpenai": "有効化中...", + "codexAuth.enableOpenaiFailed": "OpenAI プロバイダーを有効にできませんでした。", + "codexAuth.openaiPresetLoadFailed": "OpenAI プロバイダーのプリセットを読み込めませんでした。", + "codexAuth.openaiPresetUnavailable": "OpenAI プロバイダーのプリセットを利用できません。", "codexAuth.openProviders": "プロバイダーを開く", "codexAuth.add": "追加", "codexAuth.refreshQuota": "クォータを更新", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 563baa3144..6fea283bd1 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -627,6 +627,12 @@ export const ko: Record = { "codexAuth.accountModeDirectDesc": "요청은 메인 로그인만 사용하며, 추가 계정은 풀 모드용으로 계속 저장됩니다.", "codexAuth.openaiMissing": "내장 OpenAI 프로바이더가 설정되지 않았습니다.", "codexAuth.openaiDisabled": "내장 OpenAI 프로바이더가 비활성화되어 있습니다.", + "codexAuth.openaiUnavailableDesc": "OpenAI 계정은 그대로 사용할 수 있습니다. Codex 요청을 라우팅하려면 프로바이더를 활성화하세요.", + "codexAuth.enableOpenai": "OpenAI 활성화", + "codexAuth.enablingOpenai": "활성화 중...", + "codexAuth.enableOpenaiFailed": "OpenAI 공급자를 활성화하지 못했습니다.", + "codexAuth.openaiPresetLoadFailed": "OpenAI 공급자 프리셋을 불러오지 못했습니다.", + "codexAuth.openaiPresetUnavailable": "OpenAI 공급자 프리셋을 사용할 수 없습니다.", "codexAuth.openProviders": "프로바이더 열기", "codexAuth.add": "추가", "codexAuth.refreshQuota": "할당량 새로고침", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 990031214c..6b7da51540 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -907,6 +907,12 @@ export const ru: Record = { "codexAuth.accountModeDirectDesc": "Запросы используют только основной вход; добавленные аккаунты сохраняются для режима пула.", "codexAuth.openaiMissing": "Встроенный провайдер OpenAI не настроен.", "codexAuth.openaiDisabled": "Встроенный провайдер OpenAI отключён.", + "codexAuth.openaiUnavailableDesc": "Ваши аккаунты OpenAI по-прежнему доступны. Включите провайдера для маршрутизации запросов Codex.", + "codexAuth.enableOpenai": "Включить OpenAI", + "codexAuth.enablingOpenai": "Включение...", + "codexAuth.enableOpenaiFailed": "Не удалось включить провайдер OpenAI.", + "codexAuth.openaiPresetLoadFailed": "Не удалось загрузить пресет провайдера OpenAI.", + "codexAuth.openaiPresetUnavailable": "Пресет провайдера OpenAI недоступен.", "codexAuth.openProviders": "Открыть провайдеров", "codexAuth.add": "Добавить", "codexAuth.refreshQuota": "Обновить квоты", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 3935c3f749..c6ae2ae714 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -627,6 +627,12 @@ export const zh: Record = { "codexAuth.accountModeDirectDesc": "请求仅使用主登录;已添加账户会继续存储,供账户池模式使用。", "codexAuth.openaiMissing": "未配置内置 OpenAI 提供方。", "codexAuth.openaiDisabled": "内置 OpenAI 提供方已禁用。", + "codexAuth.openaiUnavailableDesc": "你的 OpenAI 账号仍然可用。启用提供方后即可路由 Codex 请求。", + "codexAuth.enableOpenai": "启用 OpenAI", + "codexAuth.enablingOpenai": "正在启用...", + "codexAuth.enableOpenaiFailed": "无法启用 OpenAI 提供方。", + "codexAuth.openaiPresetLoadFailed": "无法加载 OpenAI 提供方预设。", + "codexAuth.openaiPresetUnavailable": "OpenAI 提供方预设不可用。", "codexAuth.openProviders": "打开提供商", "codexAuth.add": "添加", "codexAuth.refreshQuota": "刷新额度", diff --git a/gui/src/pages/CodexAuth.tsx b/gui/src/pages/CodexAuth.tsx index 4ee6d9a44c..f3abed4234 100644 --- a/gui/src/pages/CodexAuth.tsx +++ b/gui/src/pages/CodexAuth.tsx @@ -2,6 +2,71 @@ 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, openAiAccountProviderState, OpenAiEnableError } from "../provider-payload"; + +export type OpenAiAccountBannerState = CodexAccountModeState | "invalid" | null; + +export function OpenAiAccountModeBanner({ + state, + busy, + onEnable, +}: { + state: OpenAiAccountBannerState; + 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")}

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

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

+ )} +
+ ); +} + +function openaiProviderFromConfig(config: unknown): { + adapter?: string; + authMode?: string; + baseUrl?: string; + disabled?: boolean; +} | undefined { + if (!config || typeof config !== "object") return undefined; + const providers = (config as { providers?: unknown }).providers; + if (!providers || typeof providers !== "object" || Array.isArray(providers)) return undefined; + if (!Object.hasOwn(providers, "openai")) return undefined; + const provider = (providers as Record).openai; + if (!provider || typeof provider !== "object" || Array.isArray(provider)) return undefined; + return provider as { + adapter?: string; + authMode?: string; + baseUrl?: string; + disabled?: boolean; + }; +} /** * Codex Auth page — a thin wrapper around CodexAccountPool (WP060 extraction). @@ -10,12 +75,24 @@ import { codexAccountModeState, type CodexAccountModeState } from "../codex-mult */ export default function CodexAuth({ apiBase }: { apiBase: string }) { const t = useT(); + const [bannerState, setBannerState] = useState(null); const [accountModeState, setAccountModeState] = useState(null); + const [enableBusy, setEnableBusy] = useState(false); + const [enableError, setEnableError] = useState(""); const loadMode = useCallback(async () => { try { const config = await fetch(`${apiBase}/api/config`).then(r => r.json()); - setAccountModeState(codexAccountModeState(config)); + const providerState = openAiAccountProviderState(openaiProviderFromConfig(config)); + if (providerState === "absent" || providerState === "disabled" || providerState === "invalid") { + setBannerState(providerState); + // Non-canonical / missing rows are not a live Codex account mode. + setAccountModeState(providerState === "disabled" ? "disabled" : "absent"); + return; + } + const mode = codexAccountModeState(config); + setBannerState(mode); + setAccountModeState(mode); } catch { /* banner degrades to no badge */ } }, [apiBase]); @@ -25,33 +102,33 @@ 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 { + // Recovery is gated on the same canonical checks as Providers → Accounts. + if (bannerState !== "absent" && bannerState !== "disabled") return; + await ensureOpenAiProvider(apiBase, bannerState); + await loadMode(); + } catch (error) { + if (error instanceof OpenAiEnableError) { + setEnableError(t(error.i18nKey)); + } else { + 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 1281c57fc0..a0c8553fab 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, OpenAiEnableError } from "../provider-payload"; import type { ProviderUpdatePatch } from "../components/provider-workspace/types"; import { oauthTosRisk } from "../oauth-tos-risk"; import { Notice } from "../ui"; @@ -407,10 +408,8 @@ export default function Providers({ apiBase }: { apiBase: string }) { } 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, @@ -431,7 +430,34 @@ export default function Providers({ apiBase }: { apiBase: string }) { } } - const onAccountLogin = (provider: string) => { + const onAccountLogin = async (provider: string) => { + if (provider === "openai") { + if (busy === "openai") return; + const configured = config.providers.openai; + const state = openAiAccountProviderState(configured); + if (state === "invalid") { + notify(t("codexAuth.openaiMissing"), false); + return; + } + if (state === "absent" || state === "disabled") { + setBusy("openai"); + try { + await ensureOpenAiProvider(apiBase, state); + await fetchConfig(); + } catch (error) { + if (error instanceof OpenAiEnableError) { + notify(t(error.i18nKey), false); + } else { + notify(error instanceof Error ? error.message : t("prov.saveFailed"), false); + } + return; + } finally { + if (aliveRef.current) setBusy(current => current === "openai" ? null : current); + } + } + setCodexLoginOpen(true); + return; + } if (isForwardProvider(provider)) { setCodexLoginOpen(true); return; @@ -451,6 +477,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { 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..2578b3a8ca 100644 --- a/gui/src/provider-payload.ts +++ b/gui/src/provider-payload.ts @@ -14,6 +14,47 @@ export interface ProviderPostPreset { provider?: ProviderPayload; } +/** Mirrors `isCanonicalOpenAiForwardProvider` (src/providers/openai-tiers.ts). */ +const CODEX_FORWARD_BASE_URL = "https://chatgpt.com/backend-api/codex"; + +function normalizedBaseUrl(value: string): string | undefined { + try { + const url = new URL(value.trim()); + if (url.username || url.password || url.search || url.hash) return undefined; + const path = url.pathname.replace(/\/+$/, ""); + return `${url.origin}${path}`; + } catch { + return undefined; + } +} + +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; baseUrl?: string; disabled?: boolean } | undefined, +): "absent" | "disabled" | "ready" | "invalid" { + if (!provider) return "absent"; + // Disabled and enabled rows share one canonical gate: adapter + authMode + baseUrl. + if ( + provider.adapter !== "openai-responses" + || provider.authMode !== "forward" + || typeof provider.baseUrl !== "string" + || normalizedBaseUrl(provider.baseUrl) !== CODEX_FORWARD_BASE_URL + ) { + return "invalid"; + } + return provider.disabled === true ? "disabled" : "ready"; +} + export type CodexPresetDescriptionKey = "prov.openaiPoolDesc" | "prov.openaiDirectDesc"; export function isReservedCodexForwardPreset(preset: ProviderPostPreset): boolean { @@ -62,11 +103,63 @@ 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, + }; +} + +/** Stable i18n keys for OpenAI enable failures (no hardcoded UI English). */ +export type OpenAiEnableErrorKey = + | "codexAuth.enableOpenaiFailed" + | "codexAuth.openaiPresetLoadFailed" + | "codexAuth.openaiPresetUnavailable"; + +export class OpenAiEnableError extends Error { + readonly i18nKey: OpenAiEnableErrorKey; + + constructor(i18nKey: OpenAiEnableErrorKey) { + super(i18nKey); + this.name = "OpenAiEnableError"; + this.i18nKey = i18nKey; + } +} + +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; + throw new OpenAiEnableError("codexAuth.enableOpenaiFailed"); + } + + const presetsResponse = await fetchImpl(`${apiBase}/api/provider-presets`); + if (!presetsResponse.ok) throw new OpenAiEnableError("codexAuth.openaiPresetLoadFailed"); + const data = await presetsResponse.json() as { providers?: ProviderPostPreset[] }; + const preset = data.providers?.find(provider => provider.id === "openai"); + if (!preset?.provider) throw new OpenAiEnableError("codexAuth.openaiPresetUnavailable"); + + const response = await fetchImpl(`${apiBase}/api/providers`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(buildReservedProviderPostBody(preset)), + }); + if (response.ok) return; + throw new OpenAiEnableError("codexAuth.enableOpenaiFailed"); +} 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..bf482c0b6a --- /dev/null +++ b/gui/tests/codex-auth-provider-enable.test.tsx @@ -0,0 +1,65 @@ +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=\"\""); +}); + +test("noncanonical disabled OpenAI rows do not offer built-in recovery", () => { + const html = renderToStaticMarkup( + + undefined} /> + , + ); + + expect(html).toContain("The built-in OpenAI provider is not configured."); + expect(html).toContain('href="#providers"'); + expect(html).not.toContain("Enable OpenAI"); + expect(html).not.toContain("Your OpenAI accounts are still available"); +}); diff --git a/gui/tests/codex-auth-recovery-interaction.test.tsx b/gui/tests/codex-auth-recovery-interaction.test.tsx new file mode 100644 index 0000000000..e41f38a85e --- /dev/null +++ b/gui/tests/codex-auth-recovery-interaction.test.tsx @@ -0,0 +1,203 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import CodexAuth from "../src/pages/CodexAuth"; + +/** + * Interaction-level recovery journey for Codex Auth. + * Banner markup tests alone cannot prove absent→POST, disabled→PATCH, + * error display, or post-success config refresh. + */ + +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previous: Record<(typeof globals)[number], unknown>; +let win: Window; +let host: HTMLElement; +let root: Root | null = null; +let originalFetch: typeof globalThis.fetch; +let requests: Array<{ method: string; path: string; body?: unknown }> = []; +let configProviders: Record = {}; + +const CANONICAL_OPENAI = { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "pool", +}; + +beforeEach(() => { + previous = Object.fromEntries(globals.map((k) => [k, Reflect.get(globalThis, k)])) as typeof previous; + originalFetch = globalThis.fetch; + win = new Window({ url: "http://localhost/" }); + Object.defineProperty(win.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: win.document }, + window: { configurable: true, value: win }, + navigator: { configurable: true, value: win.navigator }, + localStorage: { configurable: true, value: win.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + + requests = []; + configProviders = {}; + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + const method = init?.method ?? "GET"; + const entry: { method: string; path: string; body?: unknown } = { + method, + path: `${url.pathname}${url.search}`, + }; + if (typeof init?.body === "string") entry.body = JSON.parse(init.body); + requests.push(entry); + + if (url.pathname === "/api/config") { + return Response.json({ providers: configProviders }); + } + if (url.pathname === "/api/provider-presets") { + return Response.json({ + providers: [{ id: "openai", codexAccountMode: "pool", provider: { ...CANONICAL_OPENAI } }], + }); + } + if (url.pathname === "/api/providers" && method === "POST") { + configProviders = { openai: { ...CANONICAL_OPENAI } }; + return Response.json({ success: true, name: "openai" }); + } + if (url.pathname === "/api/providers" && method === "PATCH") { + const existing = configProviders.openai; + if (existing && typeof existing === "object") { + const next = { ...(existing as Record) }; + delete next.disabled; + configProviders = { openai: next }; + } + return Response.json({ success: true, name: "openai", disabled: false }); + } + if (url.pathname.startsWith("/api/codex-auth/accounts")) { + return Response.json({ + accounts: [{ id: "main", email: "main@example.test", isMain: true, hasCredential: true, quota: null }], + }); + } + if (url.pathname.startsWith("/api/codex-auth/active")) { + return Response.json({ activeCodexAccountId: null, autoSwitchThreshold: 80 }); + } + return Response.json({}); + }, + }); + + host = win.document.createElement("div") as unknown as HTMLElement; + win.document.body.appendChild(host as never); +}); + +afterEach(async () => { + if (root) { + const current = root; + await act(async () => { current.unmount(); }); + root = null; + } + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); + } + Object.defineProperty(globalThis, "fetch", { configurable: true, value: originalFetch }); + await win.happyDOM?.close?.(); +}); + +async function mountPage() { + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render( + + + , + ); + }); + // CodexAuth loads mode via setTimeout(0); pool controller settles shortly after. + await act(async () => { await new Promise((r) => setTimeout(r, 40)); }); +} + +function enableButton(): HTMLButtonElement { + const button = Array.from(host.querySelectorAll("button")).find((el) => + (el.textContent ?? "").includes("Enable OpenAI"), + ); + expect(button).toBeTruthy(); + return button as HTMLButtonElement; +} + +test("absent OpenAI: Enable posts the canonical preset and refreshes to Pool mode", async () => { + configProviders = {}; + await mountPage(); + + expect(host.textContent).toContain("Your OpenAI accounts are still available"); + expect(host.textContent).toContain("Enable OpenAI"); + + await act(async () => { + enableButton().dispatchEvent(new win.MouseEvent("click", { bubbles: true })); + await new Promise((r) => setTimeout(r, 40)); + }); + + expect(requests.some((r) => r.method === "GET" && r.path === "/api/provider-presets")).toBe(true); + expect(requests.some((r) => + r.method === "POST" + && r.path === "/api/providers" + && (r.body as { name?: string })?.name === "openai", + )).toBe(true); + expect(host.textContent).toContain("Pool mode"); + expect(host.textContent).not.toContain("Enable OpenAI"); + expect(host.querySelector('[role="alert"]')).toBeNull(); +}); + +test("disabled canonical OpenAI: Enable PATCHes disabled:false and refreshes", async () => { + configProviders = { + openai: { ...CANONICAL_OPENAI, disabled: true }, + }; + await mountPage(); + + expect(host.textContent).toContain("Enable OpenAI"); + + await act(async () => { + enableButton().dispatchEvent(new win.MouseEvent("click", { bubbles: true })); + await new Promise((r) => setTimeout(r, 40)); + }); + + expect(requests.some((r) => + r.method === "PATCH" + && r.path === "/api/providers?name=openai" + && JSON.stringify(r.body) === JSON.stringify({ disabled: false }), + )).toBe(true); + expect(host.textContent).toContain("Pool mode"); + expect(host.textContent).not.toContain("Enable OpenAI"); +}); + +test("absent OpenAI: preset load failure surfaces a localized error alert", async () => { + configProviders = {}; + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + const method = init?.method ?? "GET"; + requests.push({ method, path: `${url.pathname}${url.search}` }); + if (url.pathname === "/api/config") return Response.json({ providers: configProviders }); + if (url.pathname === "/api/provider-presets") return new Response("nope", { status: 500 }); + if (url.pathname.startsWith("/api/codex-auth/")) { + return Response.json({ accounts: [], activeCodexAccountId: null, autoSwitchThreshold: 80 }); + } + return Response.json({}); + }, + }); + + await mountPage(); + await act(async () => { + enableButton().dispatchEvent(new win.MouseEvent("click", { bubbles: true })); + await new Promise((r) => setTimeout(r, 40)); + }); + + const alert = host.querySelector('[role="alert"]'); + expect(alert).not.toBeNull(); + expect(alert!.textContent).toContain("Failed to load the OpenAI provider preset."); + expect(host.textContent).toContain("Enable OpenAI"); + expect(requests.some((r) => r.method === "POST" && r.path === "/api/providers")).toBe(false); +}); diff --git a/src/lib/destination-policy.ts b/src/lib/destination-policy.ts index 5bcb2347a2..54cffcbd19 100644 --- a/src/lib/destination-policy.ts +++ b/src/lib/destination-policy.ts @@ -133,10 +133,15 @@ export function assertProviderDestinationAllowed(name: string, provider: Pick, + options?: { allowBenchmarkAddresses?: boolean }, ): Promise { const syncError = providerDestinationConfigError(name, provider); if (syncError) return syncError; @@ -160,6 +165,14 @@ export async function providerDestinationResolvedError( const ipKind = isIP(address); const assessment = ipKind === 4 ? classifyIpv4(address) : ipKind === 6 ? classifyIpv6(normalizeHostname(address)) : null; if (!assessment || assessment.kind === "public") continue; + // Clash fake-IP only: 198.18/19 benchmark detail. Mixed dangerous sets still reject. + if ( + options?.allowBenchmarkAddresses + && assessment.kind === "private" + && assessment.detail === "benchmark address" + ) { + continue; + } if (assessment.kind === "metadata") return `baseUrl hostname ${hostname} resolves to a blocked metadata endpoint (${address})`; return `baseUrl hostname ${hostname} resolves to a ${assessment.detail} (${address}); set allowPrivateNetwork:true only for intentionally local/self-hosted providers`; } diff --git a/src/providers/openai-tiers.ts b/src/providers/openai-tiers.ts index 92b94cd294..4ad1ba7078 100644 --- a/src/providers/openai-tiers.ts +++ b/src/providers/openai-tiers.ts @@ -6,7 +6,7 @@ export const LEGACY_OPENAI_MULTI_PROVIDER_ID = "openai-multi"; export const OPENAI_API_PROVIDER_ID = "openai-apikey"; export const LEGACY_CHATGPT_PROVIDER_ID = "chatgpt"; -const CODEX_FORWARD_BASE_URL = "https://chatgpt.com/backend-api/codex"; +export const CODEX_FORWARD_BASE_URL = "https://chatgpt.com/backend-api/codex"; const LEGACY_OPENAI_MULTI_PREFIX = `${LEGACY_OPENAI_MULTI_PROVIDER_ID}/`; function canonicalCodexForwardProvider(mode: CodexAccountMode): OcxProviderConfig { diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 1f848c7837..d84dbae5b8 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -28,7 +28,7 @@ import { deriveProviderPresets } from "../../providers/derive"; import { providerCodexAccountMode } from "../../providers/registry"; import { routedSlug, slugEquals } from "../../providers/slug-codec"; import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; -import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; +import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; import { clearThreadAccountMap } from "../../codex/routing"; import { primeCodexPoolQuotas } from "../../codex/auth-api"; import { getProviderDiscoveryStatus } from "../../codex/model-cache"; @@ -101,7 +101,10 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise key !== "disabled"); + const enablingOpenAi = name === "openai" + && Object.hasOwn(rawBody, "disabled") + && rawBody.disabled === false + && config.providers[name]?.disabled === true; if (editorTouched) { const providerError = providerManagementConfigError(name, next); if (providerError) return jsonResponse({ error: providerError }, 400); const resolvedError = await providerDestinationResolvedError(name, next); if (resolvedError) return jsonResponse({ error: resolvedError }, 400); + } else if (enablingOpenAi) { + if (!isCanonicalOpenAiForwardProvider(next)) { + return jsonResponse({ error: "provider openai must be the canonical built-in provider" }, 400); + } + // Persist the byte-identical canonical URL so config.ts startup checks (case-sensitive) + // accept the row after we fill mode. Equivalent hosts like CHATGPT.com/:443 normalize here. + next.baseUrl = CODEX_FORWARD_BASE_URL; + // Fill missing mode so a disabled canonical row becomes a complete live openai entry. + if (next.codexAccountMode !== "pool" && next.codexAccountMode !== "direct") { + next.codexAccountMode = "pool"; + } + // Same DNS gate as POST: Clash fake-IP only. Never honor a persisted + // allowPrivateNetwork on this path — it must not bypass the built-in guard. + const resolvedError = await providerDestinationResolvedError( + "openai", + { baseUrl: CODEX_FORWARD_BASE_URL }, + { allowBenchmarkAddresses: true }, + ); + if (resolvedError) return jsonResponse({ error: resolvedError }, 400); + if (next.disabled === false) delete next.disabled; + // Canonical openai never uses private-network opt-in; drop a stale flag that + // was ignored for the DNS probe so it cannot linger on the live row. + delete next.allowPrivateNetwork; } const { saveConfig: save } = await import("../../config"); diff --git a/structure/08_openai-provider-tiers.md b/structure/08_openai-provider-tiers.md index c38d3b9417..a0da134877 100644 --- a/structure/08_openai-provider-tiers.md +++ b/structure/08_openai-provider-tiers.md @@ -69,8 +69,10 @@ The dashboard presents one OpenAI Codex card with accessible Pool/Direct control unchanged API-key card. `PATCH /api/providers?name=openai` persists exactly one `codexAccountMode`, clears affinity/quota cache, primes only when entering Pool, and does not refresh the model catalog or restart the proxy. Codex Auth shows an option-aware Pool/Direct banner, while -Models always shows one bare OpenAI group. Disabled or absent `openai` state remains neutral and is -never recreated by the UI. +Models always shows one bare OpenAI group. Disabled or absent canonical `openai` state can be +restored from the Accounts picker or Codex Auth through gated recovery: missing rows are created +from the canonical preset, disabled canonical rows are re-enabled without replacing saved mode or +model settings, and noncanonical `openai` rows never receive that recovery path. `GET /api/codex-auth/accounts?refresh=1` treats missing main credentials, HTTP 401, and allowlisted terminal 403 codes as `needsReauth`; generic permission failures remain non-terminal, and a diff --git a/tests/destination-policy-resolved.test.ts b/tests/destination-policy-resolved.test.ts index 81924b3741..e700c3a572 100644 --- a/tests/destination-policy-resolved.test.ts +++ b/tests/destination-policy-resolved.test.ts @@ -80,3 +80,70 @@ describe("providerDestinationResolvedError — DNS-resolved SSRF check (activati expect(lookupMock).not.toHaveBeenCalled(); }); }); + +describe("providerDestinationResolvedError — canonical openai Clash fake-IP exception", () => { + test("allows pure 198.18.0.0/15 benchmark answers when opted in", async () => { + lookupMock.mockResolvedValueOnce([ + { address: "198.18.0.30", family: 4 }, + { address: "198.19.1.2", family: 4 }, + ]); + expect(await providerDestinationResolvedError( + "openai", + provider("https://chatgpt.com/backend-api/codex"), + { allowBenchmarkAddresses: true }, + )).toBeNull(); + }); + + test("still rejects loopback, RFC1918, and metadata even with the opt-in", async () => { + lookupMock.mockResolvedValueOnce([{ address: "127.0.0.1", family: 4 }]); + expect(await providerDestinationResolvedError( + "openai", + provider("https://chatgpt.com/backend-api/codex"), + { allowBenchmarkAddresses: true }, + )).toContain("loopback address (127.0.0.1)"); + + lookupMock.mockResolvedValueOnce([{ address: "10.0.0.5", family: 4 }]); + expect(await providerDestinationResolvedError( + "openai", + provider("https://chatgpt.com/backend-api/codex"), + { allowBenchmarkAddresses: true }, + )).toContain("private-network address (10.0.0.5)"); + + lookupMock.mockResolvedValueOnce([{ address: "169.254.169.254", family: 4 }]); + expect(await providerDestinationResolvedError( + "openai", + provider("https://chatgpt.com/backend-api/codex"), + { allowBenchmarkAddresses: true }, + )).toContain("blocked metadata endpoint (169.254.169.254)"); + }); + + test("rejects mixed benchmark plus private or metadata answers", async () => { + lookupMock.mockResolvedValueOnce([ + { address: "198.18.0.30", family: 4 }, + { address: "10.0.0.5", family: 4 }, + ]); + expect(await providerDestinationResolvedError( + "openai", + provider("https://chatgpt.com/backend-api/codex"), + { allowBenchmarkAddresses: true }, + )).toContain("private-network address (10.0.0.5)"); + + lookupMock.mockResolvedValueOnce([ + { address: "198.18.0.30", family: 4 }, + { address: "169.254.169.254", family: 4 }, + ]); + expect(await providerDestinationResolvedError( + "openai", + provider("https://chatgpt.com/backend-api/codex"), + { allowBenchmarkAddresses: true }, + )).toContain("blocked metadata endpoint (169.254.169.254)"); + }); + + test("without the opt-in, benchmark answers are still rejected", async () => { + lookupMock.mockResolvedValueOnce([{ address: "198.18.0.30", family: 4 }]); + expect(await providerDestinationResolvedError( + "openai", + provider("https://chatgpt.com/backend-api/codex"), + )).toContain("benchmark address (198.18.0.30)"); + }); +}); diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index 94f7d3d303..5d7f31182c 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, setDefaultTimeout, spyOn, test } from "bun:test"; import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { saveCodexAccountCredential } from "../src/codex/account-store"; @@ -31,6 +31,7 @@ import { clearModelCache, markProviderDiscoveryFailed } from "../src/codex/model import type { OcxConfig } from "../src/types"; import { fakeChatGptJwt } from "./helpers/fake-chatgpt-jwt"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; +import * as destinationPolicy from "../src/lib/destination-policy"; // Full-suite Windows load: startServer + multi-step provider PATCH/GET flows exceed the // default 5s per-test budget (same flake class as 810fa115 / claude-management-api). @@ -994,6 +995,482 @@ describe("provider management validation", () => { } }); + test("canonical OpenAI POST passes allowBenchmarkAddresses into destination resolution", 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(null); + + 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).toHaveBeenCalledWith( + "openai", + expect.objectContaining({ baseUrl: canonicalDirect.baseUrl }), + { allowBenchmarkAddresses: true }, + ); + + resolvedError.mockResolvedValueOnce( + "baseUrl hostname custom.example.test resolves to a benchmark address (198.18.0.30); set allowPrivateNetwork:true only for intentionally local/self-hosted providers", + ); + const custom = await post({ + name: "custom", + provider: { adapter: "openai-chat", baseUrl: "https://custom.example.test/v1" }, + }); + expect(custom?.status).toBe(400); + expect(resolvedError).toHaveBeenCalledWith( + "custom", + expect.objectContaining({ baseUrl: "https://custom.example.test/v1" }), + { allowBenchmarkAddresses: false }, + ); + } finally { + resolvedError.mockRestore(); + } + }); + + test("canonical OpenAI POST still rejects non-benchmark private destination answers", 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 chatgpt.com resolves to a loopback address (127.0.0.1); set allowPrivateNetwork:true only for intentionally local/self-hosted providers"); + + try { + const request = new Request("http://127.0.0.1/api/providers", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "openai", provider: canonicalDirect }), + }); + const response = await handleManagementAPI(request, new URL(request.url), liveConfig, { + refreshCodexCatalog: async () => undefined, + }); + expect(response?.status).toBe(400); + expect(await response?.json()).toMatchObject({ + error: expect.stringContaining("loopback address"), + }); + expect(resolvedError).toHaveBeenCalledWith( + "openai", + expect.anything(), + { allowBenchmarkAddresses: true }, + ); + } finally { + resolvedError.mockRestore(); + } + }); + + test("disabled-only PATCH cannot re-enable a noncanonical openai row unchanged", 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, + hostname: "127.0.0.1", + defaultProvider: "extra", + openaiProviderTierVersion: 2, + providers: { + openai: { + adapter: "openai-chat", + baseUrl: "https://api.openai.com/v1", + authMode: "key", + apiKey: "sk-malformed", + disabled: true, + }, + extra: { + adapter: "openai-chat", + baseUrl: "https://extra.example.test/v1", + liveModels: false, + models: ["extra-model"], + }, + }, + }; + saveConfig(liveConfig); + + const server = startServer(0); + try { + const rejected = await fetch(new URL("/api/providers?name=openai", server.url), { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ disabled: false }), + }); + expect(rejected.status).toBe(400); + expect(await rejected.json()).toMatchObject({ + error: expect.stringContaining("canonical built-in provider"), + }); + + const persisted = loadConfig(); + expect(persisted.providers.openai).toMatchObject({ + adapter: "openai-chat", + baseUrl: "https://api.openai.com/v1", + authMode: "key", + disabled: true, + }); + } finally { + await server.stop(true); + } + }); + + test("disabled-only PATCH re-enables canonical openai and fills missing pool mode", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + saveConfig({ + port: 0, + hostname: "127.0.0.1", + defaultProvider: "extra", + openaiProviderTierVersion: 2, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + disabled: true, + }, + extra: { + adapter: "openai-chat", + baseUrl: "https://extra.example.test/v1", + liveModels: false, + models: ["extra-model"], + }, + }, + }); + + const server = startServer(0); + try { + const enabled = await fetch(new URL("/api/providers?name=openai", server.url), { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ disabled: false }), + }); + expect(enabled.status).toBe(200); + expect(await enabled.json()).toMatchObject({ success: true, name: "openai", disabled: false }); + + const persisted = loadConfig(); + expect(persisted.providers.openai).toEqual({ + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "pool", + }); + expect(persisted.providers.openai.disabled).toBeUndefined(); + } finally { + await server.stop(true); + } + }); + + test("disabled OpenAI recovery accepts pure Clash fake-IP via destination check", 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, + hostname: "127.0.0.1", + defaultProvider: "extra", + openaiProviderTierVersion: 2, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + disabled: true, + }, + extra: { + adapter: "openai-chat", + baseUrl: "https://extra.example.test/v1", + liveModels: false, + models: ["extra-model"], + }, + }, + }; + saveConfig(liveConfig); + const resolvedError = spyOn(destinationPolicy, "providerDestinationResolvedError") + .mockImplementation(async (_name, provider, options) => { + expect(provider).toEqual({ baseUrl: "https://chatgpt.com/backend-api/codex" }); + expect(options).toEqual({ allowBenchmarkAddresses: true }); + return null; // pure 198.18/19 allowed by the policy opt-in + }); + + try { + const request = new Request("http://127.0.0.1/api/providers?name=openai", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ disabled: false }), + }); + const response = await handleManagementAPI(request, new URL(request.url), liveConfig, { + refreshCodexCatalog: async () => undefined, + }); + expect(response?.status).toBe(200); + expect(resolvedError).toHaveBeenCalledTimes(1); + expect(liveConfig.providers.openai?.disabled).toBeUndefined(); + } finally { + resolvedError.mockRestore(); + } + }); + + test("disabled OpenAI recovery rejects loopback, RFC1918, and metadata and stays disabled", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + const disabledCanonical = { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + disabled: true, + } as const; + const liveConfig: OcxConfig = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "extra", + openaiProviderTierVersion: 2, + providers: { + openai: { ...disabledCanonical }, + extra: { + adapter: "openai-chat", + baseUrl: "https://extra.example.test/v1", + liveModels: false, + models: ["extra-model"], + }, + }, + }; + saveConfig(liveConfig); + + const failures = [ + "baseUrl hostname chatgpt.com resolves to a loopback address (127.0.0.1); set allowPrivateNetwork:true only for intentionally local/self-hosted providers", + "baseUrl hostname chatgpt.com resolves to a private-network address (10.0.0.5); set allowPrivateNetwork:true only for intentionally local/self-hosted providers", + "baseUrl hostname chatgpt.com resolves to a blocked metadata endpoint (169.254.169.254)", + ]; + const resolvedError = spyOn(destinationPolicy, "providerDestinationResolvedError"); + + try { + for (const error of failures) { + liveConfig.providers.openai = { ...disabledCanonical }; + saveConfig(liveConfig); + resolvedError.mockResolvedValueOnce(error); + + const request = new Request("http://127.0.0.1/api/providers?name=openai", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ disabled: false }), + }); + const response = await handleManagementAPI(request, new URL(request.url), liveConfig, { + refreshCodexCatalog: async () => undefined, + }); + expect(response?.status).toBe(400); + expect(await response?.json()).toMatchObject({ error }); + expect(liveConfig.providers.openai).toEqual(disabledCanonical); + expect(loadConfig().providers.openai).toMatchObject({ disabled: true }); + } + expect(resolvedError).toHaveBeenCalledTimes(failures.length); + expect(resolvedError).toHaveBeenCalledWith( + "openai", + { baseUrl: "https://chatgpt.com/backend-api/codex" }, + { allowBenchmarkAddresses: true }, + ); + } finally { + resolvedError.mockRestore(); + } + }); + + test("disabled OpenAI recovery ignores persisted allowPrivateNetwork for DNS guard", 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, + hostname: "127.0.0.1", + defaultProvider: "extra", + openaiProviderTierVersion: 2, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + allowPrivateNetwork: true, + disabled: true, + }, + extra: { + adapter: "openai-chat", + baseUrl: "https://extra.example.test/v1", + liveModels: false, + models: ["extra-model"], + }, + }, + }; + saveConfig(liveConfig); + const resolvedError = spyOn(destinationPolicy, "providerDestinationResolvedError") + .mockImplementation(async (_name, provider, options) => { + expect(provider).toEqual({ baseUrl: "https://chatgpt.com/backend-api/codex" }); + expect(Object.hasOwn(provider as object, "allowPrivateNetwork")).toBe(false); + expect(options).toEqual({ allowBenchmarkAddresses: true }); + return "baseUrl hostname chatgpt.com resolves to a private-network address (10.0.0.5); set allowPrivateNetwork:true only for intentionally local/self-hosted providers"; + }); + + try { + const request = new Request("http://127.0.0.1/api/providers?name=openai", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ disabled: false }), + }); + const response = await handleManagementAPI(request, new URL(request.url), liveConfig, { + refreshCodexCatalog: async () => undefined, + }); + expect(response?.status).toBe(400); + expect(liveConfig.providers.openai).toMatchObject({ + disabled: true, + allowPrivateNetwork: true, + }); + expect(loadConfig().providers.openai).toMatchObject({ + disabled: true, + allowPrivateNetwork: true, + }); + } finally { + resolvedError.mockRestore(); + } + }); + + test("disabled OpenAI recovery strips allowPrivateNetwork after successful re-enable", 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, + hostname: "127.0.0.1", + defaultProvider: "extra", + openaiProviderTierVersion: 2, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + allowPrivateNetwork: true, + disabled: true, + }, + extra: { + adapter: "openai-chat", + baseUrl: "https://extra.example.test/v1", + liveModels: false, + models: ["extra-model"], + }, + }, + }; + saveConfig(liveConfig); + const resolvedError = spyOn(destinationPolicy, "providerDestinationResolvedError") + .mockResolvedValue(null); + + try { + const request = new Request("http://127.0.0.1/api/providers?name=openai", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ disabled: false }), + }); + const response = await handleManagementAPI(request, new URL(request.url), liveConfig, { + refreshCodexCatalog: async () => undefined, + }); + expect(response?.status).toBe(200); + expect(liveConfig.providers.openai).toEqual({ + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "pool", + }); + expect(Object.hasOwn(liveConfig.providers.openai as object, "allowPrivateNetwork")).toBe(false); + expect(Object.hasOwn(loadConfig().providers.openai as object, "allowPrivateNetwork")).toBe(false); + } finally { + resolvedError.mockRestore(); + } + }); + + for (const [label, baseUrl] of [ + ["uppercase host", "https://CHATGPT.com/backend-api/codex"], + ["explicit :443 port", "https://chatgpt.com:443/backend-api/codex"], + ] as const) { + test(`disabled-only PATCH normalizes ${label} before save-and-reload`, async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + saveConfig({ + port: 0, + hostname: "127.0.0.1", + defaultProvider: "extra", + openaiProviderTierVersion: 2, + providers: { + openai: { + adapter: "openai-responses", + baseUrl, + authMode: "forward", + disabled: true, + }, + extra: { + adapter: "openai-chat", + baseUrl: "https://extra.example.test/v1", + liveModels: false, + models: ["extra-model"], + }, + }, + }); + + const server = startServer(0); + try { + const enabled = await fetch(new URL("/api/providers?name=openai", server.url), { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ disabled: false }), + }); + expect(enabled.status).toBe(200); + + const afterSave = loadConfig(); + expect(afterSave.providers.openai).toEqual({ + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "pool", + }); + + // Second load proves the persisted row survives config-schema restart checks. + const afterReload = loadConfig(); + expect(afterReload.providers.openai.baseUrl).toBe("https://chatgpt.com/backend-api/codex"); + expect(afterReload.providers.openai.codexAccountMode).toBe("pool"); + expect(afterReload.providers.openai.disabled).toBeUndefined(); + } finally { + await server.stop(true); + } + }); + } + 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..0cb8ad26ad 100644 --- a/tests/provider-payload.test.ts +++ b/tests/provider-payload.test.ts @@ -2,8 +2,12 @@ import { describe, expect, test } from "bun:test"; import { buildProviderPayload, buildProviderPostBody, + codexAccountProviderNames, codexPresetDescriptionKey, + ensureOpenAiProvider, isReservedCodexForwardPreset, + openAiAccountProviderState, + OpenAiEnableError, } from "../gui/src/provider-payload"; import { en } from "../gui/src/i18n/en"; import { ko } from "../gui/src/i18n/ko"; @@ -13,6 +17,137 @@ 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", + baseUrl: "https://chatgpt.com/backend-api/codex", + disabled: true, + })).toBe("disabled"); + expect(openAiAccountProviderState({ + adapter: "openai-responses", + authMode: "forward", + baseUrl: "https://chatgpt.com/backend-api/codex", + })).toBe("ready"); + expect(openAiAccountProviderState({ + adapter: "openai-responses", + authMode: "key", + baseUrl: "https://chatgpt.com/backend-api/codex", + disabled: true, + })).toBe("invalid"); + expect(openAiAccountProviderState({ + adapter: "openai-chat", + authMode: "forward", + baseUrl: "https://chatgpt.com/backend-api/codex", + disabled: true, + })).toBe("invalid"); + expect(openAiAccountProviderState({ + adapter: "openai-responses", + authMode: "forward", + baseUrl: "https://api.openai.com/v1", + 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("surfaces OpenAI enable failures as stable i18n keys", async () => { + const fetchImpl = (async (input: RequestInfo | URL) => { + const url = new URL(String(input)); + if (url.pathname === "/api/provider-presets") { + return new Response("nope", { status: 500 }); + } + return new Response("not found", { status: 404 }); + }) as typeof fetch; + + try { + await ensureOpenAiProvider("http://localhost:10100", "absent", fetchImpl); + expect.unreachable("expected OpenAiEnableError"); + } catch (error) { + expect(error).toBeInstanceOf(OpenAiEnableError); + expect((error as OpenAiEnableError).i18nKey).toBe("codexAuth.openaiPresetLoadFailed"); + } + }); + + test("missing openai preset seed maps to a stable i18n key", async () => { + const fetchImpl = (async (input: RequestInfo | URL) => { + const url = new URL(String(input)); + if (url.pathname === "/api/provider-presets") { + return Response.json({ providers: [{ id: "openai" }] }); + } + return new Response("not found", { status: 404 }); + }) as typeof fetch; + + try { + await ensureOpenAiProvider("http://localhost:10100", "absent", fetchImpl); + expect.unreachable("expected OpenAiEnableError"); + } catch (error) { + expect(error).toBeInstanceOf(OpenAiEnableError); + expect((error as OpenAiEnableError).i18nKey).toBe("codexAuth.openaiPresetUnavailable"); + } + }); + test("persists explicit API-key mode for built-in OAuth providers", () => { expect(buildProviderPayload({ name: "xai",