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.
This commit is contained in:
Jason
2026-07-12 17:52:12 +08:00
parent f2c6d48e19
commit f15184edb0
15 changed files with 228 additions and 21 deletions
@@ -31,6 +31,7 @@ interface AddProviderDialogProps {
providerKey?: string;
suggestedDefaults?: OpenClawSuggestedDefaults;
ensureClaudeDesktopOfficialSeed?: boolean;
ensureCodexOfficialSeed?: boolean;
},
) => Promise<void> | 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") &&
+27 -6
View File
@@ -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({
</span>
)}
{appId === "codex" && provider.category === "official" && (
<span className="inline-flex items-center rounded-md bg-slate-200 px-1.5 py-0.5 text-[10px] font-semibold text-slate-700 dark:bg-slate-700/60 dark:text-slate-200">
{t("codex.noRoutingSupport", {
defaultValue: "不支持路由",
})}
{appId === "codex" && supportsOfficialRouting && (
<span className="inline-flex items-center rounded-md bg-sky-100 px-1.5 py-0.5 text-[10px] font-semibold text-sky-700 dark:bg-sky-900/40 dark:text-sky-300">
{isProxyTakeover
? t("codex.officialRouting", {
defaultValue: "官方账号路由",
})
: t("codex.nativeLogin", {
defaultValue: "Codex 登录",
})}
</span>
)}
{appId === "codex" &&
provider.category === "official" &&
!supportsOfficialRouting && (
<span className="inline-flex items-center rounded-md bg-slate-200 px-1.5 py-0.5 text-[10px] font-semibold text-slate-700 dark:bg-slate-700/60 dark:text-slate-200">
{t("codex.noRoutingSupport", {
defaultValue: "不支持路由",
})}
</span>
)}
{isProxyRunning && isInFailoverQueue && health && (
<ProviderHealthBadge
consecutiveFailures={health.consecutive_failures}
+13 -2
View File
@@ -25,6 +25,7 @@ import {
isCodexAnthropicWireApi,
isCodexChatWireApi,
} from "@/utils/providerConfigUtils";
import { supportsOfficialProxyTakeover } from "@/utils/providerCapabilities";
/**
* Hook for managing provider actions (add, update, delete, switch)
@@ -78,6 +79,7 @@ export function useProviderActions(
suggestedDefaults?: OpenClawSuggestedDefaults;
addToLive?: boolean;
ensureClaudeDesktopOfficialSeed?: boolean;
ensureCodexOfficialSeed?: boolean;
},
) => {
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:
+5 -3
View File
@@ -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)",
+5 -3
View File
@@ -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}} 行)",
+5 -3
View File
@@ -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}} 條索引記錄)",
+5 -3
View File
@@ -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}} 条索引记录)",
+4
View File
@@ -103,6 +103,10 @@ export const providersApi = {
return await invoke("ensure_claude_desktop_official_provider");
},
async ensureCodexOfficialProvider(): Promise<boolean> {
return await invoke("ensure_codex_official_provider");
},
async getClaudeDesktopStatus(): Promise<ClaudeDesktopStatus> {
return await invoke("get_claude_desktop_status");
},
+13
View File
@@ -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") {
+16
View File
@@ -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<Provider, "id" | "category">,
): boolean {
return (
appId === "codex" &&
provider.id === CODEX_OFFICIAL_PROVIDER_ID &&
provider.category === "official"
);
}