From c245fb80d2b4eebe3241fd2164cab25bcc472889 Mon Sep 17 00:00:00 2001 From: eureka928 Date: Sun, 8 Feb 2026 03:19:24 +0100 Subject: [PATCH] feat: add Codex OAuth connect to Model/Provider settings page Add a "Connect via Codex" button to the OpenAI BYOK provider panel in the Models settings page. The OAuth PKCE flow obtains an OpenAI API key and saves it as a provider, with a marker config for state detection. - Add handleCodexOAuth, saveCodexAsProvider, handleCodexDisconnect - Extract refreshProviderForm helper to deduplicate provider list sync - Show "or" divider between Codex OAuth and manual API key entry - Disconnect properly revokes token, removes marker, and resets provider - Add i18n keys for connect/disconnect/status UI text --- src/i18n/locales/en-us/setting.json | 13 +- src/pages/Setting/Models.tsx | 313 +++++++++++++++++++++++++--- 2 files changed, 291 insertions(+), 35 deletions(-) diff --git a/src/i18n/locales/en-us/setting.json b/src/i18n/locales/en-us/setting.json index a3f166c1..ea3244e5 100644 --- a/src/i18n/locales/en-us/setting.json +++ b/src/i18n/locales/en-us/setting.json @@ -131,9 +131,18 @@ "failed-to-install-notion-mcp": "Failed to install Notion MCP", "google-calendar-installed-successfully": "Google Calendar installed successfully", "failed-to-install-google-calendar": "Failed to install Google Calendar", - "codex-installed-successfully": "Codex installed successfully", - "failed-to-install-codex": "Failed to install Codex", + "codex-installed-successfully": "Codex connected successfully", + "failed-to-install-codex": "Failed to connect Codex", "codex-integration": "OpenAI Codex integration via OAuth", + "or": "or", + "connect-via-codex": "Connect via Codex", + "codex-connected": "Codex Connected", + "codex-connecting": "Connecting...", + "codex-disconnect": "Disconnect", + "codex-disconnected": "Codex disconnected successfully", + "please-complete-authorization-in-browser": "Please complete authorization in your browser", + "authorization-cancelled": "Authorization was cancelled", + "authorization-failed": "Authorization failed", "notion-workspace-integration": "Notion workspace integration for reading and managing Notion pages", "google-calendar-integration": "Google Calendar integration for managing events and schedules", "mcp-server-already-exists": "MCP server \"{{name}}\" already exists", diff --git a/src/pages/Setting/Models.tsx b/src/pages/Setting/Models.tsx index 07c3b80a..701946e7 100644 --- a/src/pages/Setting/Models.tsx +++ b/src/pages/Setting/Models.tsx @@ -13,6 +13,8 @@ // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import { + fetchDelete, + fetchGet, fetchPost, proxyFetchDelete, proxyFetchGet, @@ -50,6 +52,7 @@ import { ChevronDown, ChevronUp, Cloud, + ExternalLink, Eye, EyeOff, Info, @@ -86,6 +89,10 @@ import zaiImage from '@/assets/model/zai.svg'; const LOCAL_PROVIDER_NAMES = ['ollama', 'vllm', 'sglang', 'lmstudio']; const DEFAULT_OLLAMA_ENDPOINT = 'http://localhost:11434/v1'; +// OAuth polling constants +const OAUTH_POLL_INTERVAL_MS = 2000; +const OAUTH_POLL_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes + // Sidebar tab types type SidebarTab = | 'cloud' @@ -207,6 +214,10 @@ export default function SettingModels() { modelId: string; } | null>(null); + // Codex OAuth state + const [codexConnecting, setCodexConnecting] = useState(false); + const [codexConnected, setCodexConnected] = useState(false); + // Load provider list and populate form useEffect(() => { (async () => { @@ -304,6 +315,22 @@ export default function SettingModels() { setLocalPrefer(false); setCloudPrefer(false); } + + // Detect Codex OAuth connection via marker config + try { + const configs = await proxyFetchGet('/api/configs'); + const configList = Array.isArray(configs) ? configs : []; + const hasCodex = configList.some( + (c: any) => + c.config_group?.toLowerCase() === 'codex' && + c.config_name === 'CODEX_OAUTH_TOKEN' && + c.config_value && + String(c.config_value).length > 0 + ); + setCodexConnected(hasCodex); + } catch { + // ignore config check failure + } } catch (e) { console.error('Error fetching providers:', e); // ignore error @@ -555,39 +582,7 @@ export default function SettingModels() { } else { await proxyFetchPost('/api/provider', data); } - // add: refresh provider list after saving, update form and switch editable status - const res = await proxyFetchGet('/api/providers'); - const providerList = Array.isArray(res) ? res : res.items || []; - setForm((f) => - f.map((fi, i) => { - const item = items[i]; - const found = providerList.find( - (p: any) => p.provider_name === item.id - ); - if (found) { - return { - ...fi, - provider_id: found.id, - apiKey: found.api_key || '', - apiHost: found.endpoint_url || '', - is_valid: !!found.is_valid, - prefer: found.prefer ?? false, - externalConfig: fi.externalConfig - ? fi.externalConfig.map((ec) => { - if ( - found.encrypted_config && - found.encrypted_config[ec.key] !== undefined - ) { - return { ...ec, value: found.encrypted_config[ec.key] }; - } - return ec; - }) - : undefined, - }; - } - return fi; - }) - ); + await refreshProviderForm(); // Check if this was a pending default model selection if ( @@ -942,6 +937,204 @@ export default function SettingModels() { return _hasApiKey && _hasApiId; }; + // Refresh provider list from backend and sync form state + const refreshProviderForm = async () => { + const res = await proxyFetchGet('/api/providers'); + const providerList = Array.isArray(res) ? res : res.items || []; + setForm((f) => + f.map((fi, i) => { + const item = items[i]; + const found = providerList.find( + (p: any) => p.provider_name === item.id + ); + if (found) { + return { + ...fi, + provider_id: found.id, + apiKey: found.api_key || '', + apiHost: found.endpoint_url || '', + is_valid: !!found.is_valid, + prefer: found.prefer ?? false, + model_type: found.model_type ?? '', + externalConfig: fi.externalConfig + ? fi.externalConfig.map((ec) => { + if ( + found.encrypted_config && + found.encrypted_config[ec.key] !== undefined + ) { + return { ...ec, value: found.encrypted_config[ec.key] }; + } + return ec; + }) + : undefined, + }; + } + return fi; + }) + ); + }; + + // Save or update the Codex OAuth marker config + const saveCodexMarkerConfig = async () => { + const existingConfigs = await proxyFetchGet('/api/configs'); + const existing = Array.isArray(existingConfigs) + ? existingConfigs.find( + (c: any) => + c.config_group?.toLowerCase() === 'codex' && + c.config_name === 'CODEX_OAUTH_TOKEN' + ) + : null; + + const configPayload = { + config_group: 'Codex', + config_name: 'CODEX_OAUTH_TOKEN', + config_value: 'exists', + }; + + if (existing) { + await proxyFetchPut(`/api/configs/${existing.id}`, configPayload); + } else { + await proxyFetchPost('/api/configs', configPayload); + } + }; + + // Codex OAuth: connect via PKCE flow and save as OpenAI provider + const handleCodexOAuth = async (idx: number) => { + setCodexConnecting(true); + try { + const response = await fetchPost('/install/tool/codex'); + if (response.success) { + await saveCodexAsProvider(response, idx); + toast.success(t('setting.codex-installed-successfully')); + setCodexConnected(true); + } else if (response.status === 'authorizing') { + toast.info(t('setting.please-complete-authorization-in-browser')); + const start = Date.now(); + while (Date.now() - start < OAUTH_POLL_TIMEOUT_MS) { + try { + const statusResp = await fetchGet('/oauth/status/codex'); + if (statusResp?.status === 'success') { + const finalize = await fetchPost('/install/tool/codex'); + if (finalize?.success) { + await saveCodexAsProvider(finalize, idx); + toast.success(t('setting.codex-installed-successfully')); + setCodexConnected(true); + } + return; + } else if ( + statusResp?.status === 'failed' || + statusResp?.status === 'cancelled' + ) { + const msg = + statusResp?.error || + (statusResp?.status === 'cancelled' + ? t('setting.authorization-cancelled') + : t('setting.authorization-failed')); + toast.error(msg); + return; + } + } catch (err) { + console.error('Polling Codex OAuth status failed', err); + } + await new Promise((r) => setTimeout(r, OAUTH_POLL_INTERVAL_MS)); + } + } else { + toast.error( + response.error || + response.message || + t('setting.failed-to-install-codex') + ); + } + } catch (error: any) { + toast.error(error.message || t('setting.failed-to-install-codex')); + } finally { + setCodexConnecting(false); + } + }; + + // Save Codex OAuth token as an OpenAI provider and persist marker config + const saveCodexAsProvider = async (installResponse: any, idx: number) => { + if (!installResponse?.access_token) return; + try { + const providerPayload = { + provider_name: installResponse.provider_name || 'openai', + api_key: installResponse.access_token, + endpoint_url: + installResponse.endpoint_url || 'https://api.openai.com/v1', + model_type: form[idx]?.model_type || 'gpt-4.1', + is_vaild: 2, + }; + + if (form[idx]?.provider_id) { + await proxyFetchPut( + `/api/provider/${form[idx].provider_id}`, + providerPayload + ); + } else { + await proxyFetchPost('/api/provider', providerPayload); + } + await refreshProviderForm(); + } catch (providerError) { + console.warn('Failed to save Codex token as provider', providerError); + } + + try { + await saveCodexMarkerConfig(); + } catch (configError) { + console.warn('Failed to persist Codex marker config', configError); + } + }; + + // Disconnect Codex OAuth: revoke token, remove marker config, reset provider + const handleCodexDisconnect = async (idx: number) => { + try { + await fetchDelete('/uninstall/tool/codex'); + } catch { + // ignore cleanup failure + } + // Remove marker config + try { + const existingConfigs = await proxyFetchGet('/api/configs'); + const existing = Array.isArray(existingConfigs) + ? existingConfigs.find( + (c: any) => + c.config_group?.toLowerCase() === 'codex' && + c.config_name === 'CODEX_OAUTH_TOKEN' + ) + : null; + if (existing) { + await proxyFetchDelete(`/api/configs/${existing.id}`); + } + } catch { + // ignore config cleanup failure + } + // Delete the provider so the form doesn't show a revoked key + const { provider_id } = form[idx]; + if (provider_id) { + try { + await proxyFetchDelete(`/api/provider/${provider_id}`); + } catch { + // ignore + } + } + setForm((prev) => + prev.map((fi, i) => { + if (i !== idx) return fi; + return { + ...fi, + apiKey: '', + apiHost: '', + is_valid: false, + model_type: '', + provider_id: undefined, + prefer: false, + }; + }) + ); + setCodexConnected(false); + toast.success(t('setting.codex-disconnected')); + }; + const [subscription, setSubscription] = useState(null); const fetchSubscription = async () => { const res = await proxyFetchGet('/api/subscription'); @@ -1272,6 +1465,60 @@ export default function SettingModels() { {item.description} + {/* Codex OAuth — only for OpenAI provider */} + {item.id === 'openai' && ( + <> +
+
+ + + {t('setting.connect-via-codex')} + +
+ {codexConnected ? ( +
+ + {t('setting.codex-connected')} + + +
+ ) : ( + + )} +
+ {!codexConnected && ( +
+
+ + {t('setting.or')} + +
+
+ )} + + )}
{/* API Key Setting */}