diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index 19e8e3ace..7e13d5150 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -286,6 +286,13 @@ async fn handle_claude_transform( }) } +fn endpoint_with_query(uri: &axum::http::Uri, endpoint: &str) -> String { + match uri.query() { + Some(query) => format!("{endpoint}?{query}"), + None => endpoint.to_string(), + } +} + // ============================================================================ // Codex API 处理器 // ============================================================================ @@ -293,11 +300,13 @@ async fn handle_claude_transform( /// 处理 /v1/chat/completions 请求(OpenAI Chat Completions API - Codex CLI) pub async fn handle_chat_completions( State(state): State, + uri: axum::http::Uri, headers: axum::http::HeaderMap, Json(body): Json, ) -> Result { let mut ctx = RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?; + let endpoint = endpoint_with_query(&uri, "/chat/completions"); let is_stream = body .get("stream") @@ -308,7 +317,7 @@ pub async fn handle_chat_completions( let result = match forwarder .forward_with_retry( &AppType::Codex, - "/chat/completions", + &endpoint, body, headers, ctx.get_providers(), @@ -334,11 +343,13 @@ pub async fn handle_chat_completions( /// 处理 /v1/responses 请求(OpenAI Responses API - Codex CLI 透传) pub async fn handle_responses( State(state): State, + uri: axum::http::Uri, headers: axum::http::HeaderMap, Json(body): Json, ) -> Result { let mut ctx = RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?; + let endpoint = endpoint_with_query(&uri, "/responses"); let is_stream = body .get("stream") @@ -349,7 +360,7 @@ pub async fn handle_responses( let result = match forwarder .forward_with_retry( &AppType::Codex, - "/responses", + &endpoint, body, headers, ctx.get_providers(), @@ -375,11 +386,13 @@ pub async fn handle_responses( /// 处理 /v1/responses/compact 请求(OpenAI Responses Compact API - Codex CLI 透传) pub async fn handle_responses_compact( State(state): State, + uri: axum::http::Uri, headers: axum::http::HeaderMap, Json(body): Json, ) -> Result { let mut ctx = RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?; + let endpoint = endpoint_with_query(&uri, "/responses/compact"); let is_stream = body .get("stream") @@ -390,7 +403,7 @@ pub async fn handle_responses_compact( let result = match forwarder .forward_with_retry( &AppType::Codex, - "/responses/compact", + &endpoint, body, headers, ctx.get_providers(), diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index c9b3bc5fa..eb5482e8b 100644 --- a/src-tauri/src/services/stream_check.rs +++ b/src-tauri/src/services/stream_check.rs @@ -225,6 +225,7 @@ impl StreamCheckService { &model_to_test, test_prompt, request_timeout, + provider, ) .await } @@ -441,18 +442,14 @@ impl StreamCheckService { model: &str, test_prompt: &str, timeout: std::time::Duration, + provider: &Provider, ) -> Result<(u16, String), AppError> { - let base = base_url.trim_end_matches('/'); - // Codex CLI 的 base_url 语义:base_url 是 API base(可能已包含 /v1 或其他自定义前缀), - // Responses 端点为 `/responses`。 - // - // 兼容:如果 base_url 配成纯 origin(如 https://api.openai.com),则需要补 `/v1`。 - // 优先尝试 `{base}/responses`,若 404 再回退 `{base}/v1/responses`。 - let urls = if base.ends_with("/v1") { - vec![format!("{base}/responses")] - } else { - vec![format!("{base}/responses"), format!("{base}/v1/responses")] - }; + let is_full_url = provider + .meta + .as_ref() + .and_then(|meta| meta.is_full_url) + .unwrap_or(false); + let urls = Self::resolve_codex_stream_urls(base_url, is_full_url); // 解析模型名和推理等级 (支持 model@level 或 model#level 格式) let (actual_model, reasoning_effort) = Self::parse_model_with_effort(model); @@ -743,6 +740,20 @@ impl StreamCheckService { format!("{base}/v1/messages") } } + + fn resolve_codex_stream_urls(base_url: &str, is_full_url: bool) -> Vec { + if is_full_url { + return vec![base_url.to_string()]; + } + + let base = base_url.trim_end_matches('/'); + + if base.ends_with("/v1") { + vec![format!("{base}/responses")] + } else { + vec![format!("{base}/responses"), format!("{base}/v1/responses")] + } + } } #[cfg(test)] @@ -905,4 +916,35 @@ mod tests { assert_eq!(url, "https://api.anthropic.com/v1/messages"); } + + #[test] + fn test_resolve_codex_stream_urls_for_full_url_mode() { + let urls = StreamCheckService::resolve_codex_stream_urls( + "https://relay.example/custom/responses", + true, + ); + + assert_eq!(urls, vec!["https://relay.example/custom/responses"]); + } + + #[test] + fn test_resolve_codex_stream_urls_for_v1_base() { + let urls = + StreamCheckService::resolve_codex_stream_urls("https://api.openai.com/v1", false); + + assert_eq!(urls, vec!["https://api.openai.com/v1/responses"]); + } + + #[test] + fn test_resolve_codex_stream_urls_for_origin_base() { + let urls = StreamCheckService::resolve_codex_stream_urls("https://api.openai.com", false); + + assert_eq!( + urls, + vec![ + "https://api.openai.com/responses", + "https://api.openai.com/v1/responses", + ] + ); + } } diff --git a/src/components/providers/forms/CodexFormFields.tsx b/src/components/providers/forms/CodexFormFields.tsx index 694d8b711..38434fc69 100644 --- a/src/components/providers/forms/CodexFormFields.tsx +++ b/src/components/providers/forms/CodexFormFields.tsx @@ -22,6 +22,8 @@ interface CodexFormFieldsProps { shouldShowSpeedTest: boolean; codexBaseUrl: string; onBaseUrlChange: (url: string) => void; + isFullUrl: boolean; + onFullUrlChange: (value: boolean) => void; isEndpointModalOpen: boolean; onEndpointModalToggle: (open: boolean) => void; onCustomEndpointsChange?: (endpoints: string[]) => void; @@ -49,6 +51,8 @@ export function CodexFormFields({ shouldShowSpeedTest, codexBaseUrl, onBaseUrlChange, + isFullUrl, + onFullUrlChange, isEndpointModalOpen, onEndpointModalToggle, onCustomEndpointsChange, @@ -93,6 +97,9 @@ export function CodexFormFields({ onChange={onBaseUrlChange} placeholder={t("providerForm.codexApiEndpointPlaceholder")} hint={t("providerForm.codexApiHint")} + showFullUrlToggle + isFullUrl={isFullUrl} + onFullUrlChange={onFullUrlChange} onManageClick={() => onEndpointModalToggle(true)} /> )} diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 37fea7707..cead4fc5f 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -162,8 +162,9 @@ export function ProviderForm({ const [endpointAutoSelect, setEndpointAutoSelect] = useState( () => initialData?.meta?.endpointAutoSelect ?? true, ); + const supportsFullUrl = appId === "claude" || appId === "codex"; const [localIsFullUrl, setLocalIsFullUrl] = useState(() => { - if (appId !== "claude") return false; + if (!supportsFullUrl) return false; return initialData?.meta?.isFullUrl ?? false; }); @@ -206,7 +207,7 @@ export function ProviderForm({ } setEndpointAutoSelect(initialData?.meta?.endpointAutoSelect ?? true); setLocalIsFullUrl( - appId === "claude" ? (initialData?.meta?.isFullUrl ?? false) : false, + supportsFullUrl ? (initialData?.meta?.isFullUrl ?? false) : false, ); setTestConfig(initialData?.meta?.testConfig ?? { enabled: false }); setProxyConfig(initialData?.meta?.proxyConfig ?? { enabled: false }); @@ -219,7 +220,7 @@ export function ProviderForm({ initialData?.meta?.pricingModelSource, ), }); - }, [appId, initialData]); + }, [appId, initialData, supportsFullUrl]); const defaultValues: ProviderFormData = useMemo( () => ({ @@ -949,7 +950,7 @@ export function ProviderForm({ ? localApiKeyField : undefined, isFullUrl: - appId === "claude" && category !== "official" && localIsFullUrl + supportsFullUrl && category !== "official" && localIsFullUrl ? true : undefined, }; @@ -1432,6 +1433,8 @@ export function ProviderForm({ shouldShowSpeedTest={shouldShowSpeedTest} codexBaseUrl={codexBaseUrl} onBaseUrlChange={handleCodexBaseUrlChange} + isFullUrl={localIsFullUrl} + onFullUrlChange={setLocalIsFullUrl} isEndpointModalOpen={isCodexEndpointModalOpen} onEndpointModalToggle={setIsCodexEndpointModalOpen} onCustomEndpointsChange={ diff --git a/src/hooks/useProviderActions.ts b/src/hooks/useProviderActions.ts index 2531659b4..4f925d402 100644 --- a/src/hooks/useProviderActions.ts +++ b/src/hooks/useProviderActions.ts @@ -139,13 +139,17 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) { // 切换供应商 const switchProvider = useCallback( async (provider: Provider) => { - if ( - activeApp === "claude" && + const requiresProxyForSwitch = + !isProxyRunning && provider.category !== "official" && - (provider.meta?.isFullUrl || - provider.meta?.apiFormat === "openai_chat" || - provider.meta?.apiFormat === "openai_responses") && - !isProxyRunning + ((activeApp === "claude" && + (provider.meta?.isFullUrl || + provider.meta?.apiFormat === "openai_chat" || + provider.meta?.apiFormat === "openai_responses")) || + (activeApp === "codex" && provider.meta?.isFullUrl)); + + if ( + requiresProxyForSwitch ) { toast.warning( t("notifications.proxyRequiredForSwitch", { diff --git a/tests/hooks/useProviderActions.test.tsx b/tests/hooks/useProviderActions.test.tsx index f85a60bef..d6d858d72 100644 --- a/tests/hooks/useProviderActions.test.tsx +++ b/tests/hooks/useProviderActions.test.tsx @@ -213,6 +213,28 @@ describe("useProviderActions", () => { expect(settingsApiGetMock).not.toHaveBeenCalled(); }); + it("blocks switching Codex full URL providers when proxy is not running", async () => { + const { wrapper } = createWrapper(); + const provider = createProvider({ + category: "custom", + meta: { + isFullUrl: true, + }, + }); + + const { result } = renderHook(() => useProviderActions("codex", false), { + wrapper, + }); + + await act(async () => { + await result.current.switchProvider(provider); + }); + + expect(switchProviderMutateAsync).not.toHaveBeenCalled(); + expect(toastWarningMock).toHaveBeenCalledTimes(1); + expect(settingsApiGetMock).not.toHaveBeenCalled(); + }); + it("should sync plugin config when switching Claude provider with integration enabled", async () => { switchProviderMutateAsync.mockResolvedValueOnce(undefined); settingsApiGetMock.mockResolvedValueOnce({