diff --git a/src-tauri/src/proxy/url_builder.rs b/src-tauri/src/proxy/url_builder.rs index e9fe14fbe..d62db0bc6 100644 --- a/src-tauri/src/proxy/url_builder.rs +++ b/src-tauri/src/proxy/url_builder.rs @@ -3,6 +3,7 @@ //! 提供统一的 URL 构建逻辑,供前端预览和后端代理使用。 use crate::app_config::AppType; +use crate::proxy::providers::{ClaudeAdapter, CodexAdapter, ProviderAdapter}; use crate::proxy::url_utils::{dedup_v1_v1_boundary_safe, split_url_suffix}; use serde::{Deserialize, Serialize}; @@ -34,15 +35,25 @@ impl ApiPathPatterns { Self { direct_endpoint: "/v1/messages", proxy_endpoint: "/v1/chat/completions", - // Chat 格式只检测 /chat/completions - full_url_patterns: &["/v1/chat/completions", "/chat/completions"], + // 与运行时 ClaudeAdapter 保持一致:同时识别 messages/chat 两类完整路径 + full_url_patterns: &[ + "/v1/messages", + "/messages", + "/v1/chat/completions", + "/chat/completions", + ], } } else { Self { direct_endpoint: "/v1/messages", proxy_endpoint: "/v1/messages", - // 原生格式只检测 /messages - full_url_patterns: &["/v1/messages", "/messages"], + // 与运行时 ClaudeAdapter 保持一致:同时识别 messages/chat 两类完整路径 + full_url_patterns: &[ + "/v1/messages", + "/messages", + "/v1/chat/completions", + "/chat/completions", + ], } } } @@ -126,6 +137,21 @@ pub fn build_smart_url(base_url: &str, endpoint: &str, full_url_patterns: &[&str format!("{url}{suffix}") } +fn build_runtime_like_url( + app_type: &AppType, + base_url: &str, + endpoint: &str, + is_proxy: bool, +) -> String { + match app_type { + // Claude 代理预览需要展示运行时附加参数(如 ?beta=true) + AppType::Claude if is_proxy => ClaudeAdapter::new().build_url(base_url, endpoint), + // Codex/OpenCode 预览复用运行时 /v1 归一化规则 + AppType::Codex | AppType::OpenCode => CodexAdapter::new().build_url(base_url, endpoint), + _ => build_direct_url(base_url, endpoint), + } +} + /// 构建 URL 预览 /// /// 根据 app_type、base_url 和 api_format 计算直连和代理模式的请求地址。 @@ -145,14 +171,19 @@ pub fn build_url_preview( let is_full_url = url_ends_with_api_path(base_url, patterns.full_url_patterns); - // 直连地址:始终硬拼接默认后缀 - let direct_url = build_direct_url(base_url, patterns.direct_endpoint); - // 代理地址:智能检测 - let proxy_url = build_smart_url( - base_url, - patterns.proxy_endpoint, - patterns.full_url_patterns, - ); + // 直连地址:默认硬拼接;Codex/OpenCode 复用运行时规则(含 origin-only /v1 归一化) + let direct_url = build_runtime_like_url(app_type, base_url, patterns.direct_endpoint, false); + // 代理地址:Claude/Codex/OpenCode 复用运行时规则;Gemini 继续使用通用智能拼接 + let proxy_url = match app_type { + AppType::Claude | AppType::Codex | AppType::OpenCode => { + build_runtime_like_url(app_type, base_url, patterns.proxy_endpoint, true) + } + _ => build_smart_url( + base_url, + patterns.proxy_endpoint, + patterns.full_url_patterns, + ), + }; UrlPreview { direct_url, @@ -180,7 +211,12 @@ pub fn check_proxy_requirement( if preview.is_full_url { // 检查是否以直连后缀结尾 let direct_suffixes: &[&str] = match app_type { - AppType::Claude => &["/v1/messages", "/messages"], + AppType::Claude => &[ + "/v1/messages", + "/messages", + "/v1/chat/completions", + "/chat/completions", + ], AppType::Codex => &["/v1/responses", "/responses"], _ => return None, }; @@ -190,8 +226,10 @@ pub fn check_proxy_requirement( } } - // 如果直连地址和代理地址不同,需要代理 - if preview.direct_url != preview.proxy_url { + // 如果直连地址和代理地址路径不同,需要代理(忽略查询参数差异,如 Claude ?beta=true) + let (direct_base, _) = split_url_suffix(&preview.direct_url); + let (proxy_base, _) = split_url_suffix(&preview.proxy_url); + if direct_base != proxy_base { return Some("url_mismatch"); } @@ -210,7 +248,10 @@ mod tests { Some("anthropic"), ); assert_eq!(preview.direct_url, "https://api.example.com/v1/messages"); - assert_eq!(preview.proxy_url, "https://api.example.com/v1/messages"); + assert_eq!( + preview.proxy_url, + "https://api.example.com/v1/messages?beta=true" + ); assert!(!preview.is_full_url); } @@ -224,7 +265,7 @@ mod tests { assert_eq!(preview.direct_url, "https://api.example.com/v1/messages"); assert_eq!( preview.proxy_url, - "https://api.example.com/v1/chat/completions" + "https://api.example.com/v1/chat/completions?beta=true" ); assert!(!preview.is_full_url); } @@ -241,7 +282,10 @@ mod tests { preview.direct_url, "https://api.example.com/v1/messages/v1/messages" ); - assert_eq!(preview.proxy_url, "https://api.example.com/v1/messages"); + assert_eq!( + preview.proxy_url, + "https://api.example.com/v1/messages?beta=true" + ); assert!(preview.is_full_url); } @@ -257,10 +301,19 @@ mod tests { assert!(!preview.is_full_url); } + #[test] + fn test_build_url_preview_codex_origin_normalizes_v1() { + let preview = + build_url_preview(&AppType::Codex, "https://api.openai.com", Some("responses")); + assert_eq!(preview.direct_url, "https://api.openai.com/v1/responses"); + assert_eq!(preview.proxy_url, "https://api.openai.com/v1/responses"); + assert!(!preview.is_full_url); + } + #[test] fn test_build_url_preview_codex_chat_proxy_endpoint() { let preview = build_url_preview(&AppType::Codex, "https://api.openai.com", Some("chat")); - assert_eq!(preview.direct_url, "https://api.openai.com/responses"); + assert_eq!(preview.direct_url, "https://api.openai.com/v1/responses"); assert_eq!( preview.proxy_url, "https://api.openai.com/v1/chat/completions" @@ -270,16 +323,13 @@ mod tests { #[test] fn test_build_url_preview_codex_full_url() { - // 全链接时:直连会硬拼接后缀,代理保持原地址 + // 全链接时:直连/代理均保持原地址(运行时适配器规则) let preview = build_url_preview( &AppType::Codex, "https://api.example.com/v1/responses", Some("responses"), ); - assert_eq!( - preview.direct_url, - "https://api.example.com/v1/responses/responses" - ); + assert_eq!(preview.direct_url, "https://api.example.com/v1/responses"); assert_eq!(preview.proxy_url, "https://api.example.com/v1/responses"); assert!(preview.is_full_url); } @@ -314,6 +364,13 @@ mod tests { assert_eq!(result, Some("full_url")); } + #[test] + fn test_check_proxy_requirement_codex_origin_none() { + let result = + check_proxy_requirement(&AppType::Codex, "https://api.openai.com", Some("responses")); + assert_eq!(result, None); + } + #[test] fn test_check_proxy_requirement_none() { let result = check_proxy_requirement( @@ -340,8 +397,11 @@ mod tests { Some("anthropic"), ); assert_eq!(preview.direct_url, "https://api.example.com/v1/v1/messages"); - // 代理地址智能拼接,会去重 - assert_eq!(preview.proxy_url, "https://api.example.com/v1/messages"); + // 代理地址按运行时规则构建,会去重并附加 ?beta=true + assert_eq!( + preview.proxy_url, + "https://api.example.com/v1/messages?beta=true" + ); } #[test] @@ -371,14 +431,14 @@ mod tests { ); assert_eq!( preview.proxy_url, - "https://api.example.com/v1/messages#frag" + "https://api.example.com/v1/messages?beta=true#frag" ); assert!(preview.is_full_url); } #[test] - fn test_full_url_detection_by_api_format() { - // anthropic 格式:只有 /messages 结尾才是全链接 + fn test_claude_full_url_detection_is_api_format_agnostic() { + // 与运行时 ClaudeAdapter 一致:/messages 与 /chat/completions 都视为全链接 let preview = build_url_preview( &AppType::Claude, "https://api.example.com/v1/messages", @@ -391,9 +451,8 @@ mod tests { "https://api.example.com/v1/chat/completions", Some("anthropic"), ); - assert!(!preview.is_full_url); // /chat/completions 对于 anthropic 格式不是全链接 + assert!(preview.is_full_url); - // openai_chat 格式:只有 /chat/completions 结尾才是全链接 let preview = build_url_preview( &AppType::Claude, "https://api.example.com/v1/chat/completions", @@ -406,6 +465,6 @@ mod tests { "https://api.example.com/v1/messages", Some("openai_chat"), ); - assert!(!preview.is_full_url); // /messages 对于 openai_chat 格式不是全链接 + assert!(preview.is_full_url); } } diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index b60e18a86..e32ff30b4 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -35,7 +35,11 @@ import { } from "@/config/opencodeProviderPresets"; import { OpenCodeFormFields } from "./OpenCodeFormFields"; import type { UniversalProviderPreset } from "@/config/universalProviderPresets"; -import { applyTemplateValues } from "@/utils/providerConfigUtils"; +import { + applyTemplateValues, + extractCodexWireApi, + setCodexWireApi, +} from "@/utils/providerConfigUtils"; import { mergeProviderMeta } from "@/utils/providerMetaUtils"; import { getCodexCustomTemplate } from "@/config/codexTemplates"; import CodexConfigEditor from "./CodexConfigEditor"; @@ -46,7 +50,7 @@ import { Label } from "@/components/ui/label"; import { ProviderPresetSelector } from "./ProviderPresetSelector"; import { BasicFormFields } from "./BasicFormFields"; import { ClaudeFormFields } from "./ClaudeFormFields"; -import { CodexFormFields } from "./CodexFormFields"; +import { CodexFormFields, type CodexApiFormat } from "./CodexFormFields"; import { GeminiFormFields } from "./GeminiFormFields"; import { OmoFormFields } from "./OmoFormFields"; import { type OmoGlobalConfigFieldsRef } from "./OmoGlobalConfigFields"; @@ -357,14 +361,26 @@ export function ProviderForm({ onConfigChange: (config) => form.setValue("settingsConfig", config), }); - const [localApiFormat, setLocalApiFormat] = useState(() => { - if (appId !== "claude") return "anthropic"; - return initialData?.meta?.apiFormat ?? "anthropic"; - }); + const [localClaudeApiFormat, setLocalClaudeApiFormat] = + useState(() => { + if (appId !== "claude") return "anthropic"; + return initialData?.meta?.apiFormat === "openai_chat" + ? "openai_chat" + : "anthropic"; + }); - const handleApiFormatChange = useCallback((format: ClaudeApiFormat) => { - setLocalApiFormat(format); - }, []); + const [localCodexApiFormat, setLocalCodexApiFormat] = + useState(() => { + if (appId !== "codex") return "responses"; + if (initialData?.meta?.apiFormat === "chat") return "chat"; + if (initialData?.meta?.apiFormat === "responses") return "responses"; + + const initialConfig = initialData?.settingsConfig?.config; + if (typeof initialConfig === "string") { + return extractCodexWireApi(initialConfig) ?? "responses"; + } + return "responses"; + }); const { codexAuth, @@ -374,6 +390,7 @@ export function ProviderForm({ codexModelName, codexAuthError, setCodexAuth, + setCodexConfig, handleCodexApiKeyChange, handleCodexBaseUrlChange, handleCodexModelNameChange, @@ -392,6 +409,18 @@ export function ProviderForm({ [originalHandleCodexConfigChange, debouncedValidate], ); + const handleClaudeApiFormatChange = useCallback((format: ClaudeApiFormat) => { + setLocalClaudeApiFormat(format); + }, []); + + const handleCodexApiFormatChange = useCallback( + (format: CodexApiFormat) => { + setLocalCodexApiFormat(format); + setCodexConfig((prev) => setCodexWireApi(prev, format)); + }, + [setCodexConfig], + ); + useEffect(() => { if (appId === "codex" && !initialData && selectedPresetId === "custom") { const template = getCodexCustomTemplate(); @@ -1166,6 +1195,13 @@ export function ProviderForm({ } } + const providerApiFormat = + appId === "claude" && category !== "official" + ? localClaudeApiFormat + : appId === "codex" && category !== "official" + ? localCodexApiFormat + : undefined; + const baseMeta: ProviderMeta | undefined = payload.meta ?? (initialData?.meta ? { ...initialData.meta } : undefined); payload.meta = { @@ -1180,10 +1216,7 @@ export function ProviderForm({ pricingConfig.enabled && pricingConfig.pricingModelSource !== "inherit" ? pricingConfig.pricingModelSource : undefined, - apiFormat: - appId === "claude" && category !== "official" - ? localApiFormat - : undefined, + apiFormat: providerApiFormat, }; onSubmit(payload); @@ -1277,6 +1310,9 @@ export function ProviderForm({ if (appId === "codex") { const template = getCodexCustomTemplate(); + setLocalCodexApiFormat( + extractCodexWireApi(template.config) ?? "responses", + ); resetCodexConfig(template.auth, template.config); } if (appId === "gemini") { @@ -1311,6 +1347,7 @@ export function ProviderForm({ const auth = preset.auth ?? {}; const config = preset.config ?? ""; + setLocalCodexApiFormat(extractCodexWireApi(config) ?? "responses"); resetCodexConfig(auth, config); form.reset({ @@ -1381,9 +1418,9 @@ export function ProviderForm({ ); if (preset.apiFormat) { - setLocalApiFormat(preset.apiFormat); + setLocalClaudeApiFormat(preset.apiFormat); } else { - setLocalApiFormat("anthropic"); + setLocalClaudeApiFormat("anthropic"); } form.reset({ @@ -1518,8 +1555,8 @@ export function ProviderForm({ defaultOpusModel={defaultOpusModel} onModelChange={handleModelChange} speedTestEndpoints={speedTestEndpoints} - apiFormat={localApiFormat} - onApiFormatChange={handleApiFormatChange} + apiFormat={localClaudeApiFormat} + onApiFormatChange={handleClaudeApiFormatChange} /> )} @@ -1547,6 +1584,9 @@ export function ProviderForm({ modelName={codexModelName} onModelNameChange={handleCodexModelNameChange} speedTestEndpoints={speedTestEndpoints} + apiFormat={localCodexApiFormat} + onApiFormatChange={handleCodexApiFormatChange} + shouldShowApiFormatSelector={category !== "official"} /> )} diff --git a/src/components/providers/forms/shared/EndpointField.tsx b/src/components/providers/forms/shared/EndpointField.tsx index 51b20807e..d2431b0e6 100644 --- a/src/components/providers/forms/shared/EndpointField.tsx +++ b/src/components/providers/forms/shared/EndpointField.tsx @@ -49,6 +49,8 @@ export function EndpointField({ // 调用后端 API 获取 URL 预览 useEffect(() => { if (!value || !appType || !showUrlPreview) { + // 标记当前所有已发出的请求为过期,避免旧请求回写 + lastRequestIdRef.current += 1; setUrlPreview(null); return; } @@ -114,7 +116,7 @@ export function EndpointField({

{t("providerForm.directRequestUrlDesc", { - defaultValue: "CLI 硬拼接默认后缀后的实际请求地址", + defaultValue: "CLI 直连模式下的实际请求地址", })}

diff --git a/src/components/proxy/ProxyToggle.tsx b/src/components/proxy/ProxyToggle.tsx index 8d9a4c3cb..f1b5008ee 100644 --- a/src/components/proxy/ProxyToggle.tsx +++ b/src/components/proxy/ProxyToggle.tsx @@ -6,6 +6,7 @@ */ import { Radio, Loader2 } from "lucide-react"; +import { useState } from "react"; import { Switch } from "@/components/ui/switch"; import { useProxyStatus } from "@/hooks/useProxyStatus"; import { cn } from "@/lib/utils"; @@ -14,6 +15,7 @@ import { toast } from "sonner"; import type { AppId } from "@/lib/api"; import { proxyApi } from "@/lib/api/proxy"; import type { Provider } from "@/types"; +import { extractProviderBaseUrl } from "@/utils/providerBaseUrl"; interface ProxyToggleProps { className?: string; @@ -21,48 +23,13 @@ interface ProxyToggleProps { currentProvider?: Provider | null; } -/** - * 从 provider 配置中提取 base URL - */ -function extractBaseUrl(provider: Provider, appId: AppId): string | null { - try { - const config = provider.settingsConfig; - if (!config) return null; - - if (appId === "claude") { - const envUrl = config?.env?.ANTHROPIC_BASE_URL; - return typeof envUrl === "string" ? envUrl.trim() : null; - } - - if (appId === "codex" || appId === "opencode") { - const tomlConfig = config?.config; - if (typeof tomlConfig === "string") { - const match = tomlConfig.match(/base_url\s*=\s*(['"])([^'"]+)\1/); - return match?.[2]?.trim() || null; - } - const baseUrl = config?.base_url; - return typeof baseUrl === "string" ? baseUrl.trim() : null; - } - - if (appId === "gemini") { - const envUrl = config?.env?.GOOGLE_GEMINI_BASE_URL; - if (typeof envUrl === "string") return envUrl.trim(); - const baseUrl = config?.GEMINI_API_BASE || config?.base_url; - return typeof baseUrl === "string" ? baseUrl.trim() : null; - } - - return null; - } catch { - return null; - } -} - export function ProxyToggle({ className, activeApp, currentProvider, }: ProxyToggleProps) { const { t } = useTranslation(); + const [isCheckingRequirement, setIsCheckingRequirement] = useState(false); const { isRunning, takeoverStatus, setTakeoverForApp, isPending, status } = useProxyStatus(); @@ -73,36 +40,45 @@ export function ProxyToggle({ currentProvider && currentProvider.category !== "official" ) { - const baseUrl = extractBaseUrl(currentProvider, activeApp); + const baseUrl = extractProviderBaseUrl(currentProvider, activeApp); const apiFormat = currentProvider.meta?.apiFormat; + setIsCheckingRequirement(true); + try { + let proxyRequirement: string | null = null; - if (baseUrl) { - try { - const proxyRequirement = await proxyApi.checkProxyRequirement( + // 先按 API 格式做硬性判断(baseUrl 缺失时仍需提示) + if (activeApp === "claude" && apiFormat === "openai_chat") { + proxyRequirement = "openai_chat_format"; + } + + if (!proxyRequirement && baseUrl) { + proxyRequirement = await proxyApi.checkProxyRequirement( activeApp, baseUrl, apiFormat, ); - - if (proxyRequirement) { - const warningKey = - proxyRequirement === "openai_chat_format" - ? "notifications.openAIChatFormatWarningOnDisable" - : proxyRequirement === "url_mismatch" - ? "notifications.urlMismatchWarningOnDisable" - : "notifications.fullUrlWarningOnDisable"; - - toast.warning( - t(warningKey, { - defaultValue: - "当前供应商配置可能依赖代理模式,关闭代理后可能无法正常工作。建议更换为基础地址配置或保持代理开启。", - }), - { duration: 6000, closeButton: true }, - ); - } - } catch (error) { - console.error("Failed to check proxy requirement:", error); } + + if (proxyRequirement) { + const warningKey = + proxyRequirement === "openai_chat_format" + ? "notifications.openAIChatFormatWarningOnDisable" + : proxyRequirement === "url_mismatch" + ? "notifications.urlMismatchWarningOnDisable" + : "notifications.fullUrlWarningOnDisable"; + + toast.warning( + t(warningKey, { + defaultValue: + "当前供应商配置可能依赖代理模式,关闭代理后可能无法正常工作。建议更换为基础地址配置或保持代理开启。", + }), + { duration: 6000, closeButton: true }, + ); + } + } catch (error) { + console.error("Failed to check proxy requirement:", error); + } finally { + setIsCheckingRequirement(false); } } @@ -155,7 +131,7 @@ export function ProxyToggle({ )} title={tooltipText} > - {isPending ? ( + {isPending || isCheckingRequirement ? ( ) : ( ); diff --git a/src/hooks/useProviderActions.ts b/src/hooks/useProviderActions.ts index c8d894d0e..5153d069b 100644 --- a/src/hooks/useProviderActions.ts +++ b/src/hooks/useProviderActions.ts @@ -12,55 +12,7 @@ import { useSwitchProviderMutation, } from "@/lib/query"; import { extractErrorMessage } from "@/utils/errorUtils"; - -/** - * 从 Codex TOML 配置字符串中提取 base_url - */ -function extractCodexBaseUrlFromToml(tomlConfig: string | null): string | null { - if (!tomlConfig || typeof tomlConfig !== "string") return null; - // 匹配 base_url = "xxx" 或 base_url = 'xxx' - const match = tomlConfig.match(/base_url\s*=\s*(['"])([^'"]+)\1/); - return match?.[2]?.trim() || null; -} - -/** - * 从 provider 配置中提取 base URL - */ -function extractBaseUrl(provider: Provider, appId: AppId): string | null { - try { - const config = provider.settingsConfig; - if (!config) return null; - - if (appId === "claude") { - // Claude: env.ANTHROPIC_BASE_URL - const envUrl = config?.env?.ANTHROPIC_BASE_URL; - return typeof envUrl === "string" ? envUrl.trim() : null; - } - - if (appId === "codex" || appId === "opencode") { - // Codex: base_url 存储在 config 字段中(TOML 字符串) - const tomlConfig = config?.config; - if (typeof tomlConfig === "string") { - return extractCodexBaseUrlFromToml(tomlConfig); - } - // 回退:尝试直接获取 base_url 字段 - const baseUrl = config?.base_url; - return typeof baseUrl === "string" ? baseUrl.trim() : null; - } - - if (appId === "gemini") { - // Gemini: env.GOOGLE_GEMINI_BASE_URL 或 GEMINI_API_BASE - const envUrl = config?.env?.GOOGLE_GEMINI_BASE_URL; - if (typeof envUrl === "string") return envUrl.trim(); - const baseUrl = config?.GEMINI_API_BASE || config?.base_url; - return typeof baseUrl === "string" ? baseUrl.trim() : null; - } - - return null; - } catch { - return null; - } -} +import { extractProviderBaseUrl } from "@/utils/providerBaseUrl"; interface UseProviderActionsOptions { /** 代理服务是否正在运行 */ @@ -160,13 +112,18 @@ export function useProviderActions( } // 提取 base URL 和 API 格式 - const baseUrl = extractBaseUrl(provider, activeApp); + const baseUrl = extractProviderBaseUrl(provider, activeApp); const apiFormat = provider.meta?.apiFormat; // 调用后端 API 检查是否需要代理(前后端使用相同逻辑) let proxyRequirement: string | null = null; let proxyRequirementCheckFailed = false; - if (baseUrl) { + // 先按 API 格式做硬性判断(baseUrl 缺失时仍需拦截) + if (activeApp === "claude" && apiFormat === "openai_chat") { + proxyRequirement = "openai_chat_format"; + } + + if (!proxyRequirement && baseUrl) { try { proxyRequirement = await proxyApi.checkProxyRequirement( activeApp, diff --git a/src/types.ts b/src/types.ts index 94af23ea7..2b3c477b5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -140,10 +140,14 @@ export interface ProviderMeta { costMultiplier?: string; // 供应商计费模式来源 pricingModelSource?: string; - // Claude API 格式(仅 Claude 供应商使用) + // 供应商 API 格式 + // Claude: // - "anthropic": 原生 Anthropic Messages API 格式,直接透传 // - "openai_chat": OpenAI Chat Completions 格式,需要格式转换 - apiFormat?: "anthropic" | "openai_chat"; + // Codex: + // - "responses": OpenAI Responses API + // - "chat": OpenAI Chat Completions API + apiFormat?: "anthropic" | "openai_chat" | "responses" | "chat"; } // Skill 同步方式 diff --git a/src/utils/providerBaseUrl.ts b/src/utils/providerBaseUrl.ts new file mode 100644 index 000000000..c05a8deab --- /dev/null +++ b/src/utils/providerBaseUrl.ts @@ -0,0 +1,43 @@ +import type { AppId } from "@/lib/api"; +import type { Provider } from "@/types"; +import { extractCodexBaseUrl } from "@/utils/providerConfigUtils"; + +/** + * 从 provider 配置中提取 base URL(用于代理检查/预览) + */ +export function extractProviderBaseUrl( + provider: Provider, + appId: AppId, +): string | null { + try { + const config = provider.settingsConfig; + if (!config) return null; + + if (appId === "claude") { + const envUrl = config?.env?.ANTHROPIC_BASE_URL; + return typeof envUrl === "string" ? envUrl.trim() : null; + } + + if (appId === "codex" || appId === "opencode") { + const tomlConfig = config?.config; + if (typeof tomlConfig === "string") { + return extractCodexBaseUrl(tomlConfig) ?? null; + } + + const baseUrl = config?.base_url; + return typeof baseUrl === "string" ? baseUrl.trim() : null; + } + + if (appId === "gemini") { + const envUrl = config?.env?.GOOGLE_GEMINI_BASE_URL; + if (typeof envUrl === "string") return envUrl.trim(); + + const baseUrl = config?.GEMINI_API_BASE || config?.base_url; + return typeof baseUrl === "string" ? baseUrl.trim() : null; + } + + return null; + } catch { + return null; + } +} diff --git a/src/utils/providerConfigUtils.ts b/src/utils/providerConfigUtils.ts index 6658a2333..1c1c7fb4d 100644 --- a/src/utils/providerConfigUtils.ts +++ b/src/utils/providerConfigUtils.ts @@ -468,6 +468,58 @@ export const setCodexBaseUrl = ( return `${prefix}${replacementLine}\n`; }; +// 从 Codex 的 TOML 配置文本中提取 wire_api 字段(responses/chat) +export const extractCodexWireApi = ( + configText: string | undefined | null, +): "responses" | "chat" | undefined => { + try { + const raw = typeof configText === "string" ? configText : ""; + const text = normalizeQuotes(raw); + if (!text) return undefined; + + const m = text.match(/^wire_api\s*=\s*(['"])(responses|chat)\1/m); + if (!m || !m[2]) return undefined; + return m[2] === "chat" ? "chat" : "responses"; + } catch { + return undefined; + } +}; + +// 在 Codex 的 TOML 配置文本中写入或更新 wire_api 字段 +export const setCodexWireApi = ( + configText: string, + wireApi: "responses" | "chat", +): string => { + const normalizedText = normalizeQuotes(configText); + const replacementLine = `wire_api = "${wireApi}"`; + const pattern = /^wire_api\s*=\s*(["'])(responses|chat)\1/m; + + if (pattern.test(normalizedText)) { + return normalizedText.replace(pattern, replacementLine); + } + + // 优先插入到 base_url 后,提升可读性 + const baseUrlPattern = /^base_url\s*=\s*["'][^"']+["']/m; + const match = normalizedText.match(baseUrlPattern); + if (match && match.index !== undefined) { + const endOfLine = normalizedText.indexOf("\n", match.index); + if (endOfLine !== -1) { + return ( + normalizedText.slice(0, endOfLine + 1) + + replacementLine + + "\n" + + normalizedText.slice(endOfLine + 1) + ); + } + } + + const prefix = + normalizedText && !normalizedText.endsWith("\n") + ? `${normalizedText}\n` + : normalizedText; + return `${prefix}${replacementLine}\n`; +}; + // ========== Codex model name utils ========== // 从 Codex 的 TOML 配置文本中提取 model 字段(支持单/双引号)