From f15184edb0cb7fa1e07c010fad528c82df25b19a Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 12 Jul 2026 17:52:12 +0800 Subject: [PATCH] feat(codex): expose official routing and restore the built-in provider Let users switch between the built-in OpenAI provider and third-party Codex providers directly from the provider panel while takeover mode remains active. Centralize the frontend capability predicate so only the fixed codex-official seed receives native-login routing support, and keep copied UUID-based official entries clearly marked as unsupported. Add an idempotent backend command that recreates the deleted official seed, wire it into the add-provider flow, refresh localized guidance, and add mutation and provider-action regression coverage. --- src-tauri/src/commands/provider.rs | 8 +++ src-tauri/src/database/dao/providers.rs | 23 ++++++- src-tauri/src/lib.rs | 1 + .../providers/AddProviderDialog.tsx | 10 ++++ src/components/providers/ProviderCard.tsx | 33 ++++++++-- src/hooks/useProviderActions.ts | 15 ++++- src/i18n/locales/en.json | 8 ++- src/i18n/locales/ja.json | 8 ++- src/i18n/locales/zh-TW.json | 8 ++- src/i18n/locales/zh.json | 8 ++- src/lib/api/providers.ts | 4 ++ src/lib/query/mutations.ts | 13 ++++ src/utils/providerCapabilities.ts | 16 +++++ tests/hooks/useAddProviderMutation.test.tsx | 34 +++++++++++ tests/hooks/useProviderActions.test.tsx | 60 +++++++++++++++++++ 15 files changed, 228 insertions(+), 21 deletions(-) create mode 100644 src/utils/providerCapabilities.ts diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index fa6952e4c..6cbee9d2b 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -231,6 +231,14 @@ pub fn ensure_claude_desktop_official_provider(state: State<'_, AppState>) -> Re .map_err(|e| e.to_string()) } +#[tauri::command] +pub fn ensure_codex_official_provider(state: State<'_, AppState>) -> Result { + state + .db + .ensure_official_seed_by_id(crate::database::CODEX_OFFICIAL_PROVIDER_ID, AppType::Codex) + .map_err(|e| e.to_string()) +} + fn claude_provider_models_are_claude_safe(provider: &Provider) -> bool { let Some(env) = provider .settings_config diff --git a/src-tauri/src/database/dao/providers.rs b/src-tauri/src/database/dao/providers.rs index b86611348..1d8ab4d10 100644 --- a/src-tauri/src/database/dao/providers.rs +++ b/src-tauri/src/database/dao/providers.rs @@ -710,7 +710,9 @@ impl Database { #[cfg(test)] mod ensure_official_seed_tests { use crate::app_config::AppType; - use crate::database::{Database, CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID}; + use crate::database::{ + Database, CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID, CODEX_OFFICIAL_PROVIDER_ID, + }; #[test] fn ensure_inserts_when_missing() { @@ -769,6 +771,25 @@ mod ensure_official_seed_tests { ); } + #[test] + fn ensure_recreates_codex_official_seed_after_deletion() { + let db = Database::memory().expect("memory db"); + db.init_default_official_providers().expect("seed"); + db.delete_provider(AppType::Codex.as_str(), CODEX_OFFICIAL_PROVIDER_ID) + .expect("delete Codex official"); + + let inserted = db + .ensure_official_seed_by_id(CODEX_OFFICIAL_PROVIDER_ID, AppType::Codex) + .expect("ensure Codex official"); + assert!(inserted); + let provider = db + .get_provider_by_id(CODEX_OFFICIAL_PROVIDER_ID, AppType::Codex.as_str()) + .expect("query") + .expect("Codex official restored"); + assert_eq!(provider.category.as_deref(), Some("official")); + assert_eq!(provider.settings_config["auth"], serde_json::json!({})); + } + #[test] fn ensure_rejects_unknown_seed() { let db = Database::memory().expect("memory db"); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 870d0b771..86a78b234 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1199,6 +1199,7 @@ pub fn run() { commands::get_claude_desktop_default_routes, commands::import_claude_desktop_providers_from_claude, commands::ensure_claude_desktop_official_provider, + commands::ensure_codex_official_provider, commands::get_claude_config_status, commands::get_config_status, commands::get_claude_code_config_path, diff --git a/src/components/providers/AddProviderDialog.tsx b/src/components/providers/AddProviderDialog.tsx index 179f71cd2..11ba54412 100644 --- a/src/components/providers/AddProviderDialog.tsx +++ b/src/components/providers/AddProviderDialog.tsx @@ -31,6 +31,7 @@ interface AddProviderDialogProps { providerKey?: string; suggestedDefaults?: OpenClawSuggestedDefaults; ensureClaudeDesktopOfficialSeed?: boolean; + ensureCodexOfficialSeed?: boolean; }, ) => Promise | void; } @@ -116,6 +117,7 @@ export function AddProviderDialog({ providerKey?: string; suggestedDefaults?: OpenClawSuggestedDefaults; ensureClaudeDesktopOfficialSeed?: boolean; + ensureCodexOfficialSeed?: boolean; } = { name: values.name.trim(), notes: values.notes?.trim() || undefined, @@ -137,6 +139,14 @@ export function AddProviderDialog({ preset?.category === "official"; } + if (appId === "codex" && values.presetId) { + const presetIndex = parseInt(values.presetId.replace("codex-", "")); + const preset = codexProviderPresets[presetIndex]; + providerData.ensureCodexOfficialSeed = + values.presetCategory === "official" && + preset?.category === "official"; + } + // OpenCode/OpenClaw: pass providerKey for ID generation if ( (appId === "opencode" || appId === "openclaw" || appId === "hermes") && diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index 48a82a282..2c7800478 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -25,6 +25,7 @@ import { isCodexAnthropicWireApi, isCodexChatWireApi, } from "@/utils/providerConfigUtils"; +import { supportsOfficialProxyTakeover } from "@/utils/providerCapabilities"; import { useProviderHealth } from "@/lib/query/failover"; import { useUsageQuery } from "@/lib/query/queries"; @@ -208,8 +209,14 @@ export function ProviderCard({ // 并不兑现(绕过 UI 即可切换)→ 属虚保护,却以误伤 category 缺失的自定义供应商为代价。 // 3) 预设导入的官方一定带 category="official",category 缺失的「真官方」现实中≈不存在。 // 真官方就该有显式 category;手动新建官方应引导标注,而不是靠空字段猜。 + const supportsOfficialRouting = supportsOfficialProxyTakeover( + appId, + provider, + ); const isOfficialBlockedByProxy = - isProxyTakeover && provider.category === "official"; + isProxyTakeover && + provider.category === "official" && + !supportsOfficialRouting; const isCopilot = provider.meta?.providerType === PROVIDER_TYPES.GITHUB_COPILOT || provider.meta?.usage_script?.templateType === "github_copilot"; @@ -405,14 +412,28 @@ export function ProviderCard({ )} - {appId === "codex" && provider.category === "official" && ( - - {t("codex.noRoutingSupport", { - defaultValue: "不支持路由", - })} + {appId === "codex" && supportsOfficialRouting && ( + + {isProxyTakeover + ? t("codex.officialRouting", { + defaultValue: "官方账号路由", + }) + : t("codex.nativeLogin", { + defaultValue: "Codex 登录", + })} )} + {appId === "codex" && + provider.category === "official" && + !supportsOfficialRouting && ( + + {t("codex.noRoutingSupport", { + defaultValue: "不支持路由", + })} + + )} + {isProxyRunning && isInFailoverQueue && health && ( { const enhanced = injectCodingPlanUsageScript(activeApp, provider); @@ -236,8 +238,17 @@ export function useProviderActions( ); } - // Block official providers when proxy takeover is active - if (isProxyTakeover && provider.category === "official") { + // The built-in Codex official provider can reuse Codex's native ChatGPT + // login through local routing. Other official providers remain blocked. + const officialSupportsTakeover = supportsOfficialProxyTakeover( + activeApp, + provider, + ); + if ( + isProxyTakeover && + provider.category === "official" && + !officialSupportsTakeover + ) { toast.error( t("notifications.officialBlockedByProxy", { defaultValue: diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 316291d40..47ed23a76 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -181,7 +181,9 @@ }, "codex": { "needsRouting": "Needs Routing", - "noRoutingSupport": "No Routing Support" + "noRoutingSupport": "No Routing Support", + "officialRouting": "Official Account Routing", + "nativeLogin": "Codex Sign-in" }, "claudeDesktop": { "officialNotice": "Claude Desktop official providers use the built-in 1P sign-in. No API key or endpoint URL is required.", @@ -678,8 +680,8 @@ "skipClaudeOnboarding": "Skip Claude Code first-run confirmation", "skipClaudeOnboardingDescription": "When enabled, Claude Code will skip the first-run confirmation", "codexAuth": "Codex App Enhancements", - "preserveCodexOfficialAuthOnSwitch": "Keep official login when switching third-party providers", - "preserveCodexOfficialAuthOnSwitchDescription": "When enabled, you can use the Codex app's official plugins, mobile remote control, and other features while using third-party APIs.", + "preserveCodexOfficialAuthOnSwitch": "Keep official login for direct switches", + "preserveCodexOfficialAuthOnSwitchDescription": "Controls third-party switches when local routing is off. Takeover routing always preserves the Codex official login.", "unifyCodexSessionHistory": "Unified Codex session history", "unifyCodexSessionHistoryDescription": "When enabled, the official subscription runs under the shared \"custom\" provider id so official and third-party sessions appear in one history list, optionally migrating existing official sessions in (backed up first). When turning it off, the migrated sessions can be restored from backup. Note: resuming an old session across providers may fail because its encrypted_content reasoning can only be decrypted by the backend that created it.", "unifyCodexHistoryRestoreCompleted": "Official session history restored from backup ({{files}} session files, {{rows}} index rows)", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index f639b7bf9..eec5d2f27 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -181,7 +181,9 @@ }, "codex": { "needsRouting": "ルーティングが必要", - "noRoutingSupport": "ルーティング非対応" + "noRoutingSupport": "ルーティング非対応", + "officialRouting": "公式アカウントルーティング", + "nativeLogin": "Codex ログイン" }, "claudeDesktop": { "officialNotice": "Claude Desktop の公式プロバイダーは内蔵の 1P サインインを使用します。API キーやエンドポイントの設定は不要です。", @@ -678,8 +680,8 @@ "skipClaudeOnboarding": "Claude Code の初回確認をスキップ", "skipClaudeOnboardingDescription": "オンにすると Claude Code の初回インストール確認をスキップします", "codexAuth": "Codex アプリ拡張", - "preserveCodexOfficialAuthOnSwitch": "サードパーティ切替時に公式ログインを保持", - "preserveCodexOfficialAuthOnSwitchDescription": "オンにすると、サードパーティ API を使用する際に Codex アプリの公式プラグインやスマホからのリモート操作などの機能を利用できます。", + "preserveCodexOfficialAuthOnSwitch": "直接切替時に公式ログインを保持", + "preserveCodexOfficialAuthOnSwitchDescription": "ローカルルーティングが無効な場合のサードパーティ切替を制御します。ルーティング接管中は常に Codex の公式ログインを保持します。", "unifyCodexSessionHistory": "Codex セッション履歴を統一", "unifyCodexSessionHistoryDescription": "オンにすると、公式サブスクリプションも共有の custom プロバイダー ID で動作し、公式とサードパーティのセッションが同じ履歴リストに表示されます。既存の公式セッションの移行も選択できます(移行前に自動バックアップ)。オフにする際はバックアップから復元できます。注意:プロバイダーをまたいで古いセッションを再開すると、encrypted_content の推論内容を相手のバックエンドが復号できず、再開に失敗する場合があります。", "unifyCodexHistoryRestoreCompleted": "バックアップから公式セッション履歴を復元しました(セッションファイル {{files}} 件、インデックス {{rows}} 行)", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 2d0a19d4d..cd9a962ab 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -181,7 +181,9 @@ }, "codex": { "needsRouting": "需要路由", - "noRoutingSupport": "不支援路由" + "noRoutingSupport": "不支援路由", + "officialRouting": "官方帳號路由", + "nativeLogin": "Codex 登入" }, "claudeDesktop": { "officialNotice": "Claude Desktop 官方供應商使用應用程式內建的 1P 登入,無需設定 API Key 和 API 位址。", @@ -678,8 +680,8 @@ "skipClaudeOnboarding": "跳過 Claude Code 初次安裝確認", "skipClaudeOnboardingDescription": "開啟後跳過 Claude Code 初次安裝確認", "codexAuth": "Codex 應用增強", - "preserveCodexOfficialAuthOnSwitch": "切換第三方時保留官方登入", - "preserveCodexOfficialAuthOnSwitchDescription": "開啟後可以在使用第三方 API 的時候使用 Codex 應用的官方外掛、手機遠端操作等功能", + "preserveCodexOfficialAuthOnSwitch": "非接管切換時保留官方登入", + "preserveCodexOfficialAuthOnSwitchDescription": "控制未開啟本機路由時切換第三方供應商是否保留 Codex 官方登入;路由接管期間一律保留", "unifyCodexSessionHistory": "統一 Codex 會話歷史", "unifyCodexSessionHistoryDescription": "開啟後,官方訂閱將以共享的 custom 供應商標識執行,官方與第三方會話出現在同一歷史清單中,並可選擇把現有官方會話一併遷入(遷移前自動備份)。關閉開關時可按備份恢復遷入的會話。注意:跨供應商繼續舊會話時,對方後端可能無法解密會話中的 encrypted_content 推理內容,導致繼續失敗", "unifyCodexHistoryRestoreCompleted": "已按備份還原官方會話歷史({{files}} 個會話檔案、{{rows}} 條索引記錄)", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 427b7dc7a..5feb77c71 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -181,7 +181,9 @@ }, "codex": { "needsRouting": "需要路由", - "noRoutingSupport": "不支持路由" + "noRoutingSupport": "不支持路由", + "officialRouting": "官方账号路由", + "nativeLogin": "Codex 登录" }, "claudeDesktop": { "officialNotice": "Claude Desktop 官方供应商使用应用内置的 1P 登录,无需配置 API Key 和接口地址。", @@ -678,8 +680,8 @@ "skipClaudeOnboarding": "跳过 Claude Code 初次安装确认", "skipClaudeOnboardingDescription": "开启后跳过 Claude Code 初次安装确认", "codexAuth": "Codex 应用增强", - "preserveCodexOfficialAuthOnSwitch": "切换第三方时保留官方登录", - "preserveCodexOfficialAuthOnSwitchDescription": "开启后可以在使用第三方 API 的时候使用 Codex 应用的官方插件、手机远程操作等功能", + "preserveCodexOfficialAuthOnSwitch": "非接管切换时保留官方登录", + "preserveCodexOfficialAuthOnSwitchDescription": "控制未开启路由接管时切换第三方供应商是否保留 Codex 官方登录;路由接管期间始终保留", "unifyCodexSessionHistory": "统一 Codex 会话历史", "unifyCodexSessionHistoryDescription": "开启后,官方订阅将以共享的 custom 供应商标识运行,官方与第三方会话出现在同一历史列表中,并可选择把现有官方会话一并迁入(迁移前自动备份)。关闭开关时可按备份恢复迁入的会话。注意:跨供应商继续旧会话时,对方后端可能无法解密会话中的 encrypted_content 推理内容,导致继续失败", "unifyCodexHistoryRestoreCompleted": "已按备份还原官方会话历史({{files}} 个会话文件、{{rows}} 条索引记录)", diff --git a/src/lib/api/providers.ts b/src/lib/api/providers.ts index cbdb6a55e..d7bcc1270 100644 --- a/src/lib/api/providers.ts +++ b/src/lib/api/providers.ts @@ -103,6 +103,10 @@ export const providersApi = { return await invoke("ensure_claude_desktop_official_provider"); }, + async ensureCodexOfficialProvider(): Promise { + return await invoke("ensure_codex_official_provider"); + }, + async getClaudeDesktopStatus(): Promise { return await invoke("get_claude_desktop_status"); }, diff --git a/src/lib/query/mutations.ts b/src/lib/query/mutations.ts index 8ccd5f7fc..1e4e6e32d 100644 --- a/src/lib/query/mutations.ts +++ b/src/lib/query/mutations.ts @@ -10,6 +10,7 @@ import { generateUUID } from "@/utils/uuid"; import { openclawKeys } from "@/hooks/useOpenClaw"; import { invalidateHermesProviderCaches } from "@/hooks/useHermes"; import { usageKeys } from "@/lib/query/usage"; +import { CODEX_OFFICIAL_PROVIDER_ID } from "@/utils/providerCapabilities"; export const useAddProviderMutation = (appId: AppId) => { const queryClient = useQueryClient(); @@ -21,12 +22,14 @@ export const useAddProviderMutation = (appId: AppId) => { providerKey?: string; addToLive?: boolean; ensureClaudeDesktopOfficialSeed?: boolean; + ensureCodexOfficialSeed?: boolean; }, ) => { const { providerKey: _providerKey, addToLive, ensureClaudeDesktopOfficialSeed, + ensureCodexOfficialSeed, ...rest } = providerInput; @@ -40,6 +43,16 @@ export const useAddProviderMutation = (appId: AppId) => { return officialProvider; } + if (appId === "codex" && ensureCodexOfficialSeed) { + await providersApi.ensureCodexOfficialProvider(); + const providers = await providersApi.getAll(appId); + const officialProvider = providers[CODEX_OFFICIAL_PROVIDER_ID]; + if (!officialProvider) { + throw new Error("Codex official provider was not created"); + } + return officialProvider; + } + let id: string; if (appId === "opencode" || appId === "openclaw" || appId === "hermes") { diff --git a/src/utils/providerCapabilities.ts b/src/utils/providerCapabilities.ts new file mode 100644 index 000000000..6e86176e8 --- /dev/null +++ b/src/utils/providerCapabilities.ts @@ -0,0 +1,16 @@ +import type { AppId } from "@/lib/api"; +import type { Provider } from "@/types"; + +export const CODEX_OFFICIAL_PROVIDER_ID = "codex-official"; + +/** Keep the UI capability rule aligned with the Rust takeover policy. */ +export function supportsOfficialProxyTakeover( + appId: AppId, + provider: Pick, +): boolean { + return ( + appId === "codex" && + provider.id === CODEX_OFFICIAL_PROVIDER_ID && + provider.category === "official" + ); +} diff --git a/tests/hooks/useAddProviderMutation.test.tsx b/tests/hooks/useAddProviderMutation.test.tsx index b581383b1..5e8300d22 100644 --- a/tests/hooks/useAddProviderMutation.test.tsx +++ b/tests/hooks/useAddProviderMutation.test.tsx @@ -8,6 +8,7 @@ import type { Provider } from "@/types"; const apiMocks = vi.hoisted(() => ({ add: vi.fn(), ensureClaudeDesktopOfficialProvider: vi.fn(), + ensureCodexOfficialProvider: vi.fn(), getAll: vi.fn(), updateTrayMenu: vi.fn(), })); @@ -21,6 +22,8 @@ vi.mock("@/lib/api", () => ({ add: (...args: unknown[]) => apiMocks.add(...args), ensureClaudeDesktopOfficialProvider: (...args: unknown[]) => apiMocks.ensureClaudeDesktopOfficialProvider(...args), + ensureCodexOfficialProvider: (...args: unknown[]) => + apiMocks.ensureCodexOfficialProvider(...args), getAll: (...args: unknown[]) => apiMocks.getAll(...args), updateTrayMenu: (...args: unknown[]) => apiMocks.updateTrayMenu(...args), }, @@ -59,6 +62,7 @@ beforeEach(() => { apiMocks.ensureClaudeDesktopOfficialProvider .mockReset() .mockResolvedValue(true); + apiMocks.ensureCodexOfficialProvider.mockReset().mockResolvedValue(true); apiMocks.getAll.mockReset().mockResolvedValue({}); apiMocks.updateTrayMenu.mockReset().mockResolvedValue(true); uuidMocks.generateUUID.mockReset().mockReturnValue("generated-uuid"); @@ -133,4 +137,34 @@ describe("useAddProviderMutation", () => { expect(apiMocks.add).not.toHaveBeenCalled(); expect(persistedProvider).toEqual(seedProvider); }); + + it("recreates and returns the fixed Codex official seed", async () => { + const seedProvider: Provider = { + id: "codex-official", + name: "OpenAI Official", + settingsConfig: { auth: {}, config: "" }, + category: "official", + }; + apiMocks.getAll.mockResolvedValueOnce({ + "codex-official": seedProvider, + }); + const { wrapper } = createWrapper(); + const { result } = renderHook(() => useAddProviderMutation("codex"), { + wrapper, + }); + + const persistedProvider = await act(async () => + result.current.mutateAsync({ + name: "OpenAI Official", + settingsConfig: { auth: {}, config: "" }, + category: "official", + ensureCodexOfficialSeed: true, + }), + ); + + expect(apiMocks.ensureCodexOfficialProvider).toHaveBeenCalledTimes(1); + expect(apiMocks.getAll).toHaveBeenCalledWith("codex"); + expect(apiMocks.add).not.toHaveBeenCalled(); + expect(persistedProvider).toEqual(seedProvider); + }); }); diff --git a/tests/hooks/useProviderActions.test.tsx b/tests/hooks/useProviderActions.test.tsx index ab0558a56..8bcdac674 100644 --- a/tests/hooks/useProviderActions.test.tsx +++ b/tests/hooks/useProviderActions.test.tsx @@ -264,6 +264,66 @@ describe("useProviderActions", () => { expect(switchProviderMutateAsync).toHaveBeenCalledWith(provider.id); }); + it("allows the built-in Codex official provider during takeover", async () => { + switchProviderMutateAsync.mockResolvedValueOnce(undefined); + const { wrapper } = createWrapper(); + const provider = createProvider({ + id: "codex-official", + category: "official", + }); + + const { result } = renderHook( + () => useProviderActions("codex", true, true), + { wrapper }, + ); + + await act(async () => { + await result.current.switchProvider(provider); + }); + + expect(switchProviderMutateAsync).toHaveBeenCalledWith("codex-official"); + expect(toastErrorMock).not.toHaveBeenCalled(); + }); + + it("continues blocking other official providers during takeover", async () => { + const { wrapper } = createWrapper(); + const provider = createProvider({ + id: "claude-official", + category: "official", + }); + + const { result } = renderHook( + () => useProviderActions("claude", true, true), + { wrapper }, + ); + + await act(async () => { + await result.current.switchProvider(provider); + }); + + expect(switchProviderMutateAsync).not.toHaveBeenCalled(); + expect(toastErrorMock).toHaveBeenCalledTimes(1); + }); + + it("does not grant routing capability to a UUID Codex official copy", async () => { + const { wrapper } = createWrapper(); + const provider = createProvider({ + id: "generated-uuid", + category: "official", + }); + const { result } = renderHook( + () => useProviderActions("codex", true, true), + { wrapper }, + ); + + await act(async () => { + await result.current.switchProvider(provider); + }); + + expect(switchProviderMutateAsync).not.toHaveBeenCalled(); + expect(toastErrorMock).toHaveBeenCalledTimes(1); + }); + it("should sync plugin config when switching Claude provider with integration enabled", async () => { switchProviderMutateAsync.mockResolvedValueOnce(undefined); settingsApiGetMock.mockResolvedValueOnce({