From b12d12790b5b80c768b0f44a4d89834793f17d4c Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Thu, 5 Feb 2026 14:30:15 +0800 Subject: [PATCH] refactor(proxy): unify URL building logic in backend Move URL preview and proxy requirement checks from frontend to a centralized Rust module. This ensures consistency between the endpoint field preview and actual proxy behavior. --- src-tauri/src/commands/proxy.rs | 40 ++ src-tauri/src/lib.rs | 3 + src-tauri/src/proxy/mod.rs | 1 + src-tauri/src/proxy/url_builder.rs | 350 ++++++++++++++++++ src/App.tsx | 5 +- .../providers/forms/shared/EndpointField.tsx | 169 +++------ src/components/proxy/ProxyToggle.tsx | 82 +++- src/hooks/useProviderActions.ts | 99 ++--- src/i18n/locales/en.json | 12 +- src/i18n/locales/ja.json | 12 +- src/i18n/locales/zh.json | 12 +- src/lib/api/proxy.ts | 36 ++ 12 files changed, 640 insertions(+), 181 deletions(-) create mode 100644 src-tauri/src/proxy/url_builder.rs diff --git a/src-tauri/src/commands/proxy.rs b/src-tauri/src/commands/proxy.rs index c0daa601c..b64cab721 100644 --- a/src-tauri/src/commands/proxy.rs +++ b/src-tauri/src/commands/proxy.rs @@ -407,3 +407,43 @@ pub async fn get_circuit_breaker_stats( let _ = (state, provider_id, app_type); Ok(None) } + +// ==================== URL 预览相关命令 ==================== + +use crate::app_config::AppType; +use crate::proxy::url_builder::{self, UrlPreview}; + +/// 构建 URL 预览 +/// +/// 根据 app_type、base_url 和 api_format 计算直连和代理模式的请求地址。 +/// 这个命令与后端代理的 URL 构建逻辑保持一致。 +#[tauri::command] +pub fn build_url_preview( + app_type: String, + base_url: String, + api_format: Option, +) -> Result { + let app = app_type.parse::().map_err(|e| e.to_string())?; + Ok(url_builder::build_url_preview( + &app, + &base_url, + api_format.as_deref(), + )) +} + +/// 检查是否需要代理 +/// +/// 返回需要代理的原因(openai_chat_format, full_url, url_mismatch), +/// 或 null 表示不需要代理。 +#[tauri::command] +pub fn check_proxy_requirement( + app_type: String, + base_url: String, + api_format: Option, +) -> Result, String> { + let app = app_type.parse::().map_err(|e| e.to_string())?; + Ok( + url_builder::check_proxy_requirement(&app, &base_url, api_format.as_deref()) + .map(|s| s.to_string()), + ) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 45d15605b..30644bb57 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -911,6 +911,9 @@ pub fn run() { commands::get_circuit_breaker_config, commands::update_circuit_breaker_config, commands::get_circuit_breaker_stats, + // URL preview (for endpoint field) + commands::build_url_preview, + commands::check_proxy_requirement, // Failover queue management commands::get_failover_queue, commands::get_available_providers_for_failover, diff --git a/src-tauri/src/proxy/mod.rs b/src-tauri/src/proxy/mod.rs index 338b845f6..49337795b 100644 --- a/src-tauri/src/proxy/mod.rs +++ b/src-tauri/src/proxy/mod.rs @@ -23,6 +23,7 @@ pub(crate) mod server; pub mod session; pub mod thinking_rectifier; pub(crate) mod types; +pub mod url_builder; pub mod usage; // 公开导出给外部使用(commands, services等模块需要) diff --git a/src-tauri/src/proxy/url_builder.rs b/src-tauri/src/proxy/url_builder.rs new file mode 100644 index 000000000..2524ecf65 --- /dev/null +++ b/src-tauri/src/proxy/url_builder.rs @@ -0,0 +1,350 @@ +//! URL 构建工具模块 +//! +//! 提供统一的 URL 构建逻辑,供前端预览和后端代理使用。 + +use crate::app_config::AppType; +use serde::{Deserialize, Serialize}; + +/// URL 预览结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UrlPreview { + /// 直连模式请求地址 + pub direct_url: String, + /// 代理模式请求地址 + pub proxy_url: String, + /// 是否为全链接(base_url 已包含 API 路径) + pub is_full_url: bool, +} + +/// API 路径模式 +struct ApiPathPatterns { + /// 直连模式默认端点 + direct_endpoint: &'static str, + /// 代理模式端点(根据 api_format 可能不同) + proxy_endpoint: &'static str, + /// 识别为全链接的路径后缀 + full_url_patterns: &'static [&'static str], +} + +impl ApiPathPatterns { + fn for_claude(api_format: Option<&str>) -> Self { + // 根据 API 格式决定端点和全链接检测模式 + if api_format == Some("openai_chat") { + Self { + direct_endpoint: "/v1/messages", + proxy_endpoint: "/v1/chat/completions", + // Chat 格式只检测 /chat/completions + full_url_patterns: &["/v1/chat/completions", "/chat/completions"], + } + } else { + Self { + direct_endpoint: "/v1/messages", + proxy_endpoint: "/v1/messages", + // 原生格式只检测 /messages + full_url_patterns: &["/v1/messages", "/messages"], + } + } + } + + fn for_codex(_api_format: Option<&str>) -> Self { + // Codex 只支持 /responses 端点 + Self { + direct_endpoint: "/responses", + proxy_endpoint: "/responses", + full_url_patterns: &["/v1/responses", "/responses"], + } + } + + fn for_gemini() -> Self { + Self { + direct_endpoint: "/v1beta/models", + proxy_endpoint: "/v1beta/models", + full_url_patterns: &["/v1beta/models"], + } + } +} + +/// 检测 URL 是否以指定的 API 路径结尾 +fn url_ends_with_api_path(url: &str, patterns: &[&str]) -> bool { + let trimmed = url.trim_end_matches('/').to_lowercase(); + // 移除查询参数 + let path_part = trimmed.split('?').next().unwrap_or(&trimmed); + patterns + .iter() + .any(|pattern| path_part.ends_with(&pattern.to_lowercase())) +} + +/// 硬拼接 URL(用于直连地址) +/// +/// 始终将 endpoint 拼接到 base_url 后面,不做任何智能检测或去重。 +fn build_direct_url(base_url: &str, endpoint: &str) -> String { + let base_trimmed = base_url.trim_end_matches('/'); + let endpoint_trimmed = endpoint.trim_start_matches('/'); + + // 直接拼接,不做任何去重 + format!("{base_trimmed}/{endpoint_trimmed}") +} + +/// 智能构建 URL(用于代理地址) +/// +/// 如果 base_url 已经以 API 路径结尾,直接返回;否则追加 endpoint。 +pub fn build_smart_url(base_url: &str, endpoint: &str, full_url_patterns: &[&str]) -> String { + let base_trimmed = base_url.trim_end_matches('/'); + let endpoint_trimmed = endpoint.trim_start_matches('/'); + + // 检测 base_url 是否已经以 API 路径结尾 + if url_ends_with_api_path(base_trimmed, full_url_patterns) { + return base_trimmed.to_string(); + } + + // 拼接 URL + let mut url = format!("{base_trimmed}/{endpoint_trimmed}"); + + // 去重 /v1/v1 + while url.contains("/v1/v1") { + url = url.replace("/v1/v1", "/v1"); + } + + url +} + +/// 构建 URL 预览 +/// +/// 根据 app_type、base_url 和 api_format 计算直连和代理模式的请求地址。 +/// - 直连地址:始终硬拼接默认后缀 +/// - 代理地址:智能检测,如果已包含 API 路径则不重复拼接 +pub fn build_url_preview( + app_type: &AppType, + base_url: &str, + api_format: Option<&str>, +) -> UrlPreview { + let patterns = match app_type { + AppType::Claude => ApiPathPatterns::for_claude(api_format), + AppType::Codex => ApiPathPatterns::for_codex(api_format), + AppType::Gemini => ApiPathPatterns::for_gemini(), + AppType::OpenCode => ApiPathPatterns::for_codex(api_format), // OpenCode 使用 Codex 逻辑 + }; + + 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, + ); + + UrlPreview { + direct_url, + proxy_url, + is_full_url, + } +} + +/// 检查是否需要代理 +/// +/// 返回需要代理的原因,None 表示不需要代理 +pub fn check_proxy_requirement( + app_type: &AppType, + base_url: &str, + api_format: Option<&str>, +) -> Option<&'static str> { + // Claude OpenAI Chat 格式必须开启代理(需要格式转换) + if matches!(app_type, AppType::Claude) && api_format == Some("openai_chat") { + return Some("openai_chat_format"); + } + + let preview = build_url_preview(app_type, base_url, api_format); + + // 如果是全链接且以直连后缀结尾,需要代理 + if preview.is_full_url { + // 检查是否以直连后缀结尾 + let direct_suffixes: &[&str] = match app_type { + AppType::Claude => &["/v1/messages", "/messages"], + AppType::Codex => &["/v1/responses", "/responses"], + _ => return None, + }; + + if url_ends_with_api_path(base_url, direct_suffixes) { + return Some("full_url"); + } + } + + // 如果直连地址和代理地址不同,需要代理 + if preview.direct_url != preview.proxy_url { + return Some("url_mismatch"); + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_url_preview_claude_anthropic() { + let preview = build_url_preview( + &AppType::Claude, + "https://api.example.com", + 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!(!preview.is_full_url); + } + + #[test] + fn test_build_url_preview_claude_openai_chat() { + let preview = build_url_preview( + &AppType::Claude, + "https://api.example.com", + Some("openai_chat"), + ); + assert_eq!(preview.direct_url, "https://api.example.com/v1/messages"); + assert_eq!( + preview.proxy_url, + "https://api.example.com/v1/chat/completions" + ); + assert!(!preview.is_full_url); + } + + #[test] + fn test_build_url_preview_claude_full_url() { + // 全链接时:直连会硬拼接后缀,代理保持原地址 + let preview = build_url_preview( + &AppType::Claude, + "https://api.example.com/v1/messages", + Some("anthropic"), + ); + assert_eq!( + preview.direct_url, + "https://api.example.com/v1/messages/v1/messages" + ); + assert_eq!(preview.proxy_url, "https://api.example.com/v1/messages"); + assert!(preview.is_full_url); + } + + #[test] + fn test_build_url_preview_codex_responses() { + let preview = build_url_preview( + &AppType::Codex, + "https://api.openai.com/v1", + 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_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.proxy_url, "https://api.example.com/v1/responses"); + assert!(preview.is_full_url); + } + + #[test] + fn test_check_proxy_requirement_claude_openai_chat() { + let result = check_proxy_requirement( + &AppType::Claude, + "https://api.example.com", + Some("openai_chat"), + ); + assert_eq!(result, Some("openai_chat_format")); + } + + #[test] + fn test_check_proxy_requirement_claude_full_url() { + let result = check_proxy_requirement( + &AppType::Claude, + "https://api.example.com/v1/messages", + Some("anthropic"), + ); + assert_eq!(result, Some("full_url")); + } + + #[test] + fn test_check_proxy_requirement_codex_full_url() { + let result = check_proxy_requirement( + &AppType::Codex, + "https://api.example.com/v1/responses", + Some("responses"), + ); + assert_eq!(result, Some("full_url")); + } + + #[test] + fn test_check_proxy_requirement_none() { + let result = check_proxy_requirement( + &AppType::Claude, + "https://api.example.com", + Some("anthropic"), + ); + assert_eq!(result, None); + } + + #[test] + fn test_v1_dedup() { + // 代理地址使用 build_smart_url,会去重 /v1/v1 + let url = build_smart_url("https://api.example.com/v1", "/v1/messages", &[]); + assert_eq!(url, "https://api.example.com/v1/messages"); + } + + #[test] + fn test_direct_url_no_dedup() { + // 直连地址硬拼接,不做任何去重 + let preview = build_url_preview( + &AppType::Claude, + "https://api.example.com/v1", + 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"); + } + + #[test] + fn test_full_url_detection_by_api_format() { + // anthropic 格式:只有 /messages 结尾才是全链接 + let preview = build_url_preview( + &AppType::Claude, + "https://api.example.com/v1/messages", + Some("anthropic"), + ); + assert!(preview.is_full_url); + + let preview = build_url_preview( + &AppType::Claude, + "https://api.example.com/v1/chat/completions", + Some("anthropic"), + ); + assert!(!preview.is_full_url); // /chat/completions 对于 anthropic 格式不是全链接 + + // openai_chat 格式:只有 /chat/completions 结尾才是全链接 + let preview = build_url_preview( + &AppType::Claude, + "https://api.example.com/v1/chat/completions", + Some("openai_chat"), + ); + assert!(preview.is_full_url); + + let preview = build_url_preview( + &AppType::Claude, + "https://api.example.com/v1/messages", + Some("openai_chat"), + ); + assert!(!preview.is_full_url); // /messages 对于 openai_chat 格式不是全链接 + } +} diff --git a/src/App.tsx b/src/App.tsx index 222821aeb..fad867bb2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -933,7 +933,10 @@ function App() { <> {activeApp !== "opencode" && ( <> - +
void; manageButtonLabel?: string; - // 新增:应用类型和 API 格式 + // 应用类型和 API 格式 appType?: AppType; - apiFormat?: ClaudeApiFormat | CodexApiFormat; + apiFormat?: string; // 是否显示请求地址预览 showUrlPreview?: boolean; } @@ -78,65 +39,36 @@ export function EndpointField({ showUrlPreview = true, }: EndpointFieldProps) { const { t } = useTranslation(); + const [urlPreview, setUrlPreview] = useState(null); const defaultManageLabel = t("providerForm.manageAndTest", { defaultValue: "管理和测速", }); - // 根据 appType 和 apiFormat 计算直连和代理后缀 - const suffixes = useMemo(() => { - if (!appType) return null; - - if (appType === "claude") { - // Claude: 直连固定 /v1/messages,代理根据 apiFormat 决定 - return { - direct: "/v1/messages", - proxy: - apiFormat === "openai_chat" ? "/v1/chat/completions" : "/v1/messages", - }; + // 调用后端 API 获取 URL 预览 + useEffect(() => { + if (!value || !appType || !showUrlPreview) { + setUrlPreview(null); + return; } - if (appType === "codex") { - // Codex: 直连固定 /responses(base URL 已含 /v1),代理根据 apiFormat 决定 - return { - direct: "/responses", - proxy: apiFormat === "chat" ? "/chat/completions" : "/responses", - }; - } + // 防抖:延迟 300ms 后请求 + const timer = setTimeout(async () => { + try { + const preview = await proxyApi.buildUrlPreview( + appType, + value, + apiFormat, + ); + setUrlPreview(preview); + } catch (error) { + console.error("Failed to build URL preview:", error); + setUrlPreview(null); + } + }, 300); - if (appType === "gemini") { - // Gemini: 两种模式相同 - return { - direct: "/v1beta/models", - proxy: "/v1beta/models", - }; - } - - return null; - }, [appType, apiFormat]); - - // 检测 URL 是否已以 API 路径结尾(全链接) - const urlEndsWithApiPath = useMemo(() => { - if (!value) return false; - const urlPath = extractUrlPath(value); - return API_PATH_SUFFIX_PATTERNS.some((pattern) => - urlPath.endsWith(pattern.toLowerCase()), - ); - }, [value]); - - // 构建直连模式请求 URL - const directUrlPreview = useMemo(() => { - if (!value || !suffixes) return null; - if (urlEndsWithApiPath) return value; - return buildUrl(value, suffixes.direct); - }, [value, suffixes, urlEndsWithApiPath]); - - // 构建代理模式请求 URL - const proxyUrlPreview = useMemo(() => { - if (!value || !suffixes) return null; - if (urlEndsWithApiPath) return value; - return buildUrl(value, suffixes.proxy); - }, [value, suffixes, urlEndsWithApiPath]); + return () => clearTimeout(timer); + }, [value, appType, apiFormat, showUrlPreview]); return (
@@ -163,51 +95,48 @@ export function EndpointField({ /> {/* 请求地址预览 */} - {showUrlPreview && directUrlPreview && ( + {showUrlPreview && urlPreview && (
- {/* 直连模式请求地址 */} + {/* CLI 直连请求地址 */}

{t("providerForm.directRequestUrl", { - defaultValue: "直连请求地址:", + defaultValue: "CLI 直连请求地址:", })}

- {directUrlPreview} + {urlPreview.direct_url}

{t("providerForm.directRequestUrlDesc", { - defaultValue: "不开启代理时,客户端直接请求此地址", + defaultValue: "CLI 硬拼接默认后缀后的实际请求地址", })}

- {/* 代理模式请求地址 */} - {proxyUrlPreview && ( -
-

- - {t("providerForm.proxyRequestUrl", { - defaultValue: "代理请求地址:", - })} -

-

- {proxyUrlPreview} -

-

- {t("providerForm.proxyRequestUrlDesc", { - defaultValue: - "开启代理后,代理服务会将请求转发到此地址(支持格式转换)", - })} -

-
- )} + {/* CCS 代理请求地址 */} +
+

+ + {t("providerForm.proxyRequestUrl", { + defaultValue: "CCS 代理请求地址:", + })} +

+

+ {urlPreview.proxy_url} +

+

+ {t("providerForm.proxyRequestUrlDesc", { + defaultValue: "CCS 智能拼接后转发到上游的地址", + })} +

+
)} {/* 全链接警告 */} - {urlEndsWithApiPath && ( + {urlPreview?.is_full_url && (
diff --git a/src/components/proxy/ProxyToggle.tsx b/src/components/proxy/ProxyToggle.tsx index 462c3d9bb..588f44780 100644 --- a/src/components/proxy/ProxyToggle.tsx +++ b/src/components/proxy/ProxyToggle.tsx @@ -10,19 +10,99 @@ import { Switch } from "@/components/ui/switch"; import { useProxyStatus } from "@/hooks/useProxyStatus"; import { cn } from "@/lib/utils"; import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; import type { AppId } from "@/lib/api"; +import { proxyApi } from "@/lib/api/proxy"; +import type { Provider } from "@/types"; interface ProxyToggleProps { className?: string; activeApp: AppId; + currentProvider?: Provider | null; } -export function ProxyToggle({ className, activeApp }: ProxyToggleProps) { +/** + * 从 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") { + 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 { isRunning, takeoverStatus, setTakeoverForApp, isPending, status } = useProxyStatus(); const handleToggle = async (checked: boolean) => { + // 关闭代理时,检查当前供应商是否是全链接配置 + if ( + !checked && + currentProvider && + currentProvider.category !== "official" + ) { + const baseUrl = extractBaseUrl(currentProvider, activeApp); + const apiFormat = currentProvider.meta?.apiFormat; + + if (baseUrl) { + try { + const proxyRequirement = await proxyApi.checkProxyRequirement( + activeApp, + baseUrl, + apiFormat, + ); + + if (proxyRequirement) { + // 显示警告但仍允许关闭 + toast.warning( + t("notifications.fullUrlWarningOnDisable", { + defaultValue: + "当前供应商配置了完整 API 路径或特殊格式,关闭代理后可能无法正常工作。建议更换为基础地址配置或保持代理开启。", + }), + { + duration: 6000, + closeButton: true, + }, + ); + } + } catch (error) { + console.error("Failed to check proxy requirement:", error); + } + } + } + try { await setTakeoverForApp({ appType: activeApp, enabled: checked }); } catch (error) { diff --git a/src/hooks/useProviderActions.ts b/src/hooks/useProviderActions.ts index c49c6ba65..a907f92c0 100644 --- a/src/hooks/useProviderActions.ts +++ b/src/hooks/useProviderActions.ts @@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; import { providersApi, settingsApi, type AppId } from "@/lib/api"; +import { proxyApi } from "@/lib/api/proxy"; import type { Provider, UsageScript } from "@/types"; import { useAddProviderMutation, @@ -12,17 +13,6 @@ import { } from "@/lib/query"; import { extractErrorMessage } from "@/utils/errorUtils"; -// API 路径后缀模式,用于检测是否是全链接 -const API_PATH_SUFFIX_PATTERNS = [ - "/v1/messages", - "/messages", - "/v1/chat/completions", - "/chat/completions", - "/v1/responses", - "/responses", - "/v1beta/models", -]; - /** * 从 Codex TOML 配置字符串中提取 base_url */ @@ -72,20 +62,6 @@ function extractBaseUrl(provider: Provider, appId: AppId): string | null { } } -/** - * 检测 URL 是否以 API 路径后缀结尾(全链接) - */ -function isFullApiUrl(url: string | null): boolean { - if (!url) return false; - - // 移除查询参数和尾部斜杠 - const pathPart = url.split("?")[0].replace(/\/+$/, "").toLowerCase(); - - return API_PATH_SUFFIX_PATTERNS.some((pattern) => - pathPart.endsWith(pattern.toLowerCase()), - ); -} - interface UseProviderActionsOptions { /** 代理服务是否正在运行 */ isProxyRunning?: boolean; @@ -166,28 +142,63 @@ export function useProviderActions( // 切换供应商 const switchProvider = useCallback( async (provider: Provider) => { - // 检测是否是全链接配置(URL 以 API 路径结尾) - const baseUrl = extractBaseUrl(provider, activeApp); - const hasFullApiUrl = isFullApiUrl(baseUrl); + // 官方供应商不需要检查 + if (provider.category === "official") { + try { + await switchProviderMutation.mutateAsync(provider.id); + await syncClaudePlugin(provider); + toast.success( + t("notifications.switchSuccess", { defaultValue: "切换成功!" }), + { + closeButton: true, + }, + ); + } catch { + // 错误提示由 mutation 处理 + } + return; + } - // 检测是否是 Claude OpenAI Chat 格式(需要代理进行格式转换) - const isClaudeOpenAIChatFormat = - activeApp === "claude" && - provider.category !== "official" && - provider.meta?.apiFormat === "openai_chat"; + // 提取 base URL 和 API 格式 + const baseUrl = extractBaseUrl(provider, activeApp); + const apiFormat = provider.meta?.apiFormat; + + // 调用后端 API 检查是否需要代理(前后端使用相同逻辑) + let proxyRequirement: string | null = null; + if (baseUrl) { + try { + proxyRequirement = await proxyApi.checkProxyRequirement( + activeApp, + baseUrl, + apiFormat, + ); + } catch (error) { + console.error("Failed to check proxy requirement:", error); + } + } // 如果需要代理但代理未激活,阻止切换并提示 - const needsProxy = hasFullApiUrl || isClaudeOpenAIChatFormat; - if (needsProxy && !(isProxyRunning && isTakeoverActive)) { - const message = isClaudeOpenAIChatFormat - ? t("notifications.openAIChatFormatRequiresProxy", { - defaultValue: - "此供应商使用 OpenAI Chat 格式,需要开启代理服务进行格式转换才能正常使用。请先开启代理并接管当前应用。", - }) - : t("notifications.fullUrlRequiresProxy", { - defaultValue: - "此供应商配置了完整 API 路径,需要开启代理服务才能正常使用。请先开启代理并接管当前应用。", - }); + if (proxyRequirement && !(isProxyRunning && isTakeoverActive)) { + let message: string; + + if (proxyRequirement === "openai_chat_format") { + message = t("notifications.openAIChatFormatRequiresProxy", { + defaultValue: + "此供应商使用 OpenAI Chat 格式,需要开启代理服务进行格式转换才能正常使用。请先开启代理并接管当前应用。", + }); + } else if (proxyRequirement === "full_url") { + // 用户填了全链接(如 /v1/messages 结尾) + message = t("notifications.fullUrlRequiresProxy", { + defaultValue: + "此供应商配置了完整 API 路径,直连模式下客户端可能会重复追加路径。请先开启代理并接管当前应用。", + }); + } else { + // url_mismatch: 直连地址和代理地址不匹配 + message = t("notifications.urlMismatchRequiresProxy", { + defaultValue: + "此供应商的请求地址配置与 API 格式不匹配,直连模式下无法正常工作。请先开启代理并接管当前应用。", + }); + } toast.warning(message, { duration: 6000, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 2088ecb2a..aaea3a2d9 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -158,7 +158,9 @@ "settingsSaveFailed": "Failed to save settings: {{error}}", "openAIChatFormatHint": "This provider uses OpenAI Chat format and requires the proxy service to be enabled", "openAIChatFormatRequiresProxy": "This provider uses OpenAI Chat format and requires the proxy service for format conversion. Please enable the proxy and takeover the current app first.", - "fullUrlRequiresProxy": "This provider is configured with a full API path and requires the proxy service to work properly. Please enable the proxy and takeover the current app first." + "fullUrlRequiresProxy": "This provider is configured with a full API path. The client may append duplicate paths in direct connection mode. Please enable the proxy and takeover the current app first.", + "urlMismatchRequiresProxy": "This provider's request URL configuration does not match the API format. It cannot work properly in direct connection mode. Please enable the proxy and takeover the current app first.", + "fullUrlWarningOnDisable": "The current provider is configured with a full API path or special format. It may not work properly after disabling the proxy. Consider switching to a base URL configuration or keep the proxy enabled." }, "confirm": { "deleteProvider": "Delete Provider", @@ -495,10 +497,10 @@ "codexApiFormatResponses": "OpenAI Responses (Default)", "codexApiFormatChat": "OpenAI Chat Completions", "codexApiFormatHint": "Select the API format supported by the provider", - "directRequestUrl": "Direct request URL:", - "directRequestUrlDesc": "When proxy is disabled, the client requests this URL directly", - "proxyRequestUrl": "Proxy request URL:", - "proxyRequestUrlDesc": "When proxy is enabled, requests are forwarded to this URL (with format conversion)", + "directRequestUrl": "CLI Direct Request URL:", + "directRequestUrlDesc": "Actual request URL after CLI appends default suffix", + "proxyRequestUrl": "CCS Proxy Request URL:", + "proxyRequestUrlDesc": "URL forwarded to upstream after CCS smart path detection", "fullUrlWarningTitle": "Full API path detected", "fullUrlWarning": "The URL contains a full API path. This configuration only works in proxy mode. For direct connection mode, please enter only the base URL.", "fillSupplierName": "Please fill in provider name", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index ee4b854ac..8f367e43a 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -158,7 +158,9 @@ "settingsSaveFailed": "設定の保存に失敗しました: {{error}}", "openAIChatFormatHint": "このプロバイダーは OpenAI Chat フォーマットを使用しており、プロキシサービスの有効化が必要です", "openAIChatFormatRequiresProxy": "このプロバイダーは OpenAI Chat フォーマットを使用しており、フォーマット変換のためにプロキシサービスが必要です。先にプロキシを有効にして、現在のアプリをテイクオーバーしてください。", - "fullUrlRequiresProxy": "このプロバイダーは完全な API パスで構成されており、プロキシサービスが必要です。先にプロキシを有効にして、現在のアプリをテイクオーバーしてください。" + "fullUrlRequiresProxy": "このプロバイダーは完全な API パスで構成されています。直接接続モードではクライアントがパスを重複追加する可能性があります。先にプロキシを有効にして、現在のアプリをテイクオーバーしてください。", + "urlMismatchRequiresProxy": "このプロバイダーのリクエスト URL 設定が API フォーマットと一致しません。直接接続モードでは正常に動作しません。先にプロキシを有効にして、現在のアプリをテイクオーバーしてください。", + "fullUrlWarningOnDisable": "現在のプロバイダーは完全な API パスまたは特殊なフォーマットで構成されています。プロキシを無効にすると正常に動作しない可能性があります。ベース URL 設定に変更するか、プロキシを有効のままにすることをお勧めします。" }, "confirm": { "deleteProvider": "プロバイダーを削除", @@ -495,10 +497,10 @@ "codexApiFormatResponses": "OpenAI Responses(デフォルト)", "codexApiFormatChat": "OpenAI Chat Completions", "codexApiFormatHint": "プロバイダーがサポートする API フォーマットを選択", - "directRequestUrl": "直接接続リクエスト URL:", - "directRequestUrlDesc": "プロキシ無効時、クライアントはこの URL に直接リクエストします", - "proxyRequestUrl": "プロキシリクエスト URL:", - "proxyRequestUrlDesc": "プロキシ有効時、リクエストはこの URL に転送されます(フォーマット変換対応)", + "directRequestUrl": "CLI 直接リクエスト URL:", + "directRequestUrlDesc": "CLI がデフォルトサフィックスを追加した後の実際のリクエスト URL", + "proxyRequestUrl": "CCS プロキシリクエスト URL:", + "proxyRequestUrlDesc": "CCS がスマートパス検出後にアップストリームへ転送する URL", "fullUrlWarningTitle": "完全な API パスを検出", "fullUrlWarning": "URL には完全な API パスが含まれています。この設定はプロキシモードでのみ有効です。直接接続モードではベース URL のみを入力してください。", "fillSupplierName": "プロバイダー名を入力してください", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 8205a169b..b0d6af8e9 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -158,7 +158,9 @@ "settingsSaveFailed": "保存设置失败:{{error}}", "openAIChatFormatHint": "此供应商使用 OpenAI Chat 格式,需要开启代理服务才能正常使用", "openAIChatFormatRequiresProxy": "此供应商使用 OpenAI Chat 格式,需要开启代理服务进行格式转换才能正常使用。请先开启代理并接管当前应用。", - "fullUrlRequiresProxy": "此供应商配置了完整 API 路径,需要开启代理服务才能正常使用。请先开启代理并接管当前应用。" + "fullUrlRequiresProxy": "此供应商配置了完整 API 路径,直连模式下客户端可能会重复追加路径。请先开启代理并接管当前应用。", + "urlMismatchRequiresProxy": "此供应商的请求地址配置与 API 格式不匹配,直连模式下无法正常工作。请先开启代理并接管当前应用。", + "fullUrlWarningOnDisable": "当前供应商配置了完整 API 路径或特殊格式,关闭代理后可能无法正常工作。建议更换为基础地址配置或保持代理开启。" }, "confirm": { "deleteProvider": "删除供应商", @@ -495,10 +497,10 @@ "codexApiFormatResponses": "OpenAI Responses (默认)", "codexApiFormatChat": "OpenAI Chat Completions", "codexApiFormatHint": "选择供应商支持的 API 格式", - "directRequestUrl": "直连请求地址:", - "directRequestUrlDesc": "不开启代理时,客户端直接请求此地址", - "proxyRequestUrl": "代理请求地址:", - "proxyRequestUrlDesc": "开启代理后,代理服务会将请求转发到此地址(支持格式转换)", + "directRequestUrl": "CLI 直连请求地址:", + "directRequestUrlDesc": "CLI 硬拼接默认后缀后的实际请求地址", + "proxyRequestUrl": "CCS 代理请求地址:", + "proxyRequestUrlDesc": "CCS 智能拼接后转发到上游的地址", "fullUrlWarningTitle": "检测到完整 API 路径", "fullUrlWarning": "填写了包含 API 路径的完整地址,此配置仅在代理模式下生效。直连模式下请只填写基础地址。", "fillSupplierName": "请填写供应商名称", diff --git a/src/lib/api/proxy.ts b/src/lib/api/proxy.ts index 83f203134..4a3f3f6d1 100644 --- a/src/lib/api/proxy.ts +++ b/src/lib/api/proxy.ts @@ -117,4 +117,40 @@ export const proxyApi = { async setPricingModelSource(appType: string, value: string): Promise { return invoke("set_pricing_model_source", { appType, value }); }, + + // ========== URL 预览 API ========== + + /** + * 构建 URL 预览 + * 根据 app_type、base_url 和 api_format 计算直连和代理模式的请求地址 + */ + async buildUrlPreview( + appType: string, + baseUrl: string, + apiFormat?: string, + ): Promise { + return invoke("build_url_preview", { appType, baseUrl, apiFormat }); + }, + + /** + * 检查是否需要代理 + * 返回需要代理的原因,null 表示不需要代理 + */ + async checkProxyRequirement( + appType: string, + baseUrl: string, + apiFormat?: string, + ): Promise { + return invoke("check_proxy_requirement", { appType, baseUrl, apiFormat }); + }, }; + +/** URL 预览结果 */ +export interface UrlPreview { + /** 直连模式请求地址 */ + direct_url: string; + /** 代理模式请求地址 */ + proxy_url: string; + /** 是否为全链接(base_url 已包含 API 路径) */ + is_full_url: boolean; +}