From eeda9adb034c8ecde434e6d91507b38f7e7f7534 Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Mon, 16 Mar 2026 14:22:54 +0800 Subject: [PATCH] feat(proxy): add full URL mode and refactor endpoint rewriting - Add `isFullUrl` provider meta to treat base_url as complete API endpoint - Remove hardcoded `?beta=true` from Claude adapter, pass through from client - Refactor forwarder endpoint rewriting with proper query string handling - Block provider switching when proxy is required but not running - Add full URL toggle UI in endpoint field with i18n (zh/en/ja) --- src-tauri/src/provider.rs | 3 + src-tauri/src/proxy/forwarder.rs | 124 ++++++++++++++++-- src-tauri/src/proxy/handlers.rs | 8 +- src-tauri/src/proxy/providers/claude.rs | 28 +--- src-tauri/src/services/stream_check.rs | 77 ++++++++--- src/App.tsx | 2 +- .../providers/forms/ClaudeFormFields.tsx | 9 ++ .../providers/forms/ProviderForm.tsx | 18 ++- .../providers/forms/shared/EndpointField.tsx | 37 +++++- src/hooks/useProviderActions.ts | 20 ++- src/i18n/locales/en.json | 4 + src/i18n/locales/ja.json | 4 + src/i18n/locales/zh.json | 4 + src/types.ts | 2 + tests/hooks/useProviderActions.test.tsx | 28 ++++ 15 files changed, 313 insertions(+), 55 deletions(-) diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index ed5e3514b..aee8ad5ee 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -245,6 +245,9 @@ pub struct ProviderMeta { /// Claude 认证字段名("ANTHROPIC_AUTH_TOKEN" 或 "ANTHROPIC_API_KEY") #[serde(rename = "apiKeyField", skip_serializing_if = "Option::is_none")] pub api_key_field: Option, + /// 是否将 base_url 视为完整 API 端点(不拼接 endpoint 路径) + #[serde(rename = "isFullUrl", skip_serializing_if = "Option::is_none")] + pub is_full_url: Option, /// Prompt cache key for OpenAI-compatible endpoints. /// When set, injected into converted requests to improve cache hit rate. /// If not set, provider ID is used automatically during format conversion. diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 154495792..6c4c255ae 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -792,21 +792,30 @@ impl RequestForwarder { // 检查是否需要格式转换 let needs_transform = adapter.needs_transform(provider); - let effective_endpoint = - if needs_transform && adapter.name() == "Claude" && endpoint == "/v1/messages" { - // 根据 api_format 选择目标端点 + let is_full_url = provider + .meta + .as_ref() + .and_then(|meta| meta.is_full_url) + .unwrap_or(false); + + let (effective_endpoint, passthrough_query) = + if needs_transform && adapter.name() == "Claude" { let api_format = super::providers::get_claude_api_format(provider); - if api_format == "openai_responses" { - "/v1/responses" - } else { - "/v1/chat/completions" - } + rewrite_claude_transform_endpoint(endpoint, &api_format) } else { - endpoint + ( + endpoint.to_string(), + split_endpoint_and_query(endpoint) + .1 + .map(ToString::to_string), + ) }; - // 使用适配器构建 URL - let url = adapter.build_url(&base_url, effective_endpoint); + let url = if is_full_url { + append_query_to_full_url(&base_url, passthrough_query.as_deref()) + } else { + adapter.build_url(&base_url, &effective_endpoint) + }; // 应用模型映射(独立于格式转换) let (mapped_body, _original_model, _mapped_model) = @@ -887,7 +896,7 @@ impl RequestForwarder { // 流式请求保守禁用压缩,避免上游压缩 SSE 在连接中断时触发解压错误。 // 非流式请求不显式设置 Accept-Encoding,让 reqwest 自动协商压缩并透明解压。 - if should_force_identity_encoding(effective_endpoint, &filtered_body, headers) { + if should_force_identity_encoding(&effective_endpoint, &filtered_body, headers) { request = request.header("accept-encoding", "identity"); } @@ -1094,6 +1103,70 @@ fn extract_json_error_message(body: &Value) -> Option { .find_map(|value| value.as_str().map(ToString::to_string)) } +fn split_endpoint_and_query(endpoint: &str) -> (&str, Option<&str>) { + endpoint + .split_once('?') + .map_or((endpoint, None), |(path, query)| (path, Some(query))) +} + +fn filter_beta_query(query: Option<&str>) -> Option { + let filtered = query.map(|query| { + query + .split('&') + .filter(|pair| !pair.is_empty() && !pair.starts_with("beta=")) + .collect::>() + .join("&") + }); + + match filtered.as_deref() { + Some("") | None => None, + Some(_) => filtered, + } +} + +fn is_claude_messages_path(path: &str) -> bool { + matches!(path, "/v1/messages" | "/claude/v1/messages") +} + +fn rewrite_claude_transform_endpoint(endpoint: &str, api_format: &str) -> (String, Option) { + let (path, query) = split_endpoint_and_query(endpoint); + let filtered_query = if is_claude_messages_path(path) { + filter_beta_query(query) + } else { + query.map(ToString::to_string) + }; + + if !is_claude_messages_path(path) { + return (endpoint.to_string(), filtered_query); + } + + let target_path = if api_format == "openai_responses" { + "/v1/responses" + } else { + "/v1/chat/completions" + }; + + let rewritten = match filtered_query.as_deref() { + Some(query) if !query.is_empty() => format!("{target_path}?{query}"), + _ => target_path.to_string(), + }; + + (rewritten, filtered_query) +} + +fn append_query_to_full_url(base_url: &str, query: Option<&str>) -> String { + match query { + Some(query) if !query.is_empty() => { + if base_url.contains('?') { + format!("{base_url}&{query}") + } else { + format!("{base_url}?{query}") + } + } + _ => base_url.to_string(), + } +} + fn should_force_identity_encoding( endpoint: &str, body: &Value, @@ -1202,6 +1275,33 @@ mod tests { assert_eq!(summary, "line1 line2..."); } + #[test] + fn rewrite_claude_transform_endpoint_strips_beta_for_chat_completions() { + let (endpoint, passthrough_query) = + rewrite_claude_transform_endpoint("/v1/messages?beta=true&foo=bar", "openai_chat"); + + assert_eq!(endpoint, "/v1/chat/completions?foo=bar"); + assert_eq!(passthrough_query.as_deref(), Some("foo=bar")); + } + + #[test] + fn rewrite_claude_transform_endpoint_strips_only_beta_for_responses() { + let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint( + "/claude/v1/messages?beta=true&x-id=1", + "openai_responses", + ); + + assert_eq!(endpoint, "/v1/responses?x-id=1"); + assert_eq!(passthrough_query.as_deref(), Some("x-id=1")); + } + + #[test] + fn append_query_to_full_url_preserves_existing_query_string() { + let url = append_query_to_full_url("https://relay.example/api?foo=bar", Some("x-id=1")); + + assert_eq!(url, "https://relay.example/api?foo=bar&x-id=1"); + } + #[test] fn force_identity_for_stream_flag_requests() { let headers = HeaderMap::new(); diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index 3bfbac0ea..19e8e3ace 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -61,12 +61,18 @@ pub async fn get_status(State(state): State) -> Result, + uri: axum::http::Uri, headers: axum::http::HeaderMap, Json(body): Json, ) -> Result { let mut ctx = RequestContext::new(&state, &body, &headers, AppType::Claude, "Claude", "claude").await?; + let endpoint = uri + .path_and_query() + .map(|path_and_query| path_and_query.as_str()) + .unwrap_or(uri.path()); + let is_stream = body .get("stream") .and_then(|s| s.as_bool()) @@ -77,7 +83,7 @@ pub async fn handle_messages( let result = match forwarder .forward_with_retry( &AppType::Claude, - "/v1/messages", + endpoint, body.clone(), headers, ctx.get_providers(), diff --git a/src-tauri/src/proxy/providers/claude.rs b/src-tauri/src/proxy/providers/claude.rs index fe33930c8..bb7157c23 100644 --- a/src-tauri/src/proxy/providers/claude.rs +++ b/src-tauri/src/proxy/providers/claude.rs @@ -261,6 +261,8 @@ impl ProviderAdapter for ClaudeAdapter { // // 现在 OpenRouter 已推出 Claude Code 兼容接口,因此默认直接透传 endpoint。 // 如需回退旧逻辑,可在 forwarder 中根据 needs_transform 改写 endpoint。 + // + // `?beta=true` 由客户端请求决定,代理不再硬编码追加。 let mut base = format!( "{}/{}", @@ -273,19 +275,7 @@ impl ProviderAdapter for ClaudeAdapter { base = base.replace("/v1/v1", "/v1"); } - // 为 Claude 原生 /v1/messages 端点添加 ?beta=true 参数 - // 这是某些上游服务(如 DuckCoding)验证请求来源的关键参数 - // 注意:不要为 OpenAI Chat Completions (/v1/chat/completions) 添加此参数 - // 当 apiFormat="openai_chat" 时,请求会转发到 /v1/chat/completions, - // 但该端点是 OpenAI 标准,不支持 ?beta=true 参数 - if endpoint.contains("/v1/messages") - && !endpoint.contains("/v1/chat/completions") - && !endpoint.contains('?') - { - format!("{base}?beta=true") - } else { - base - } + base } fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder { @@ -522,23 +512,20 @@ mod tests { #[test] fn test_build_url_anthropic() { let adapter = ClaudeAdapter::new(); - // /v1/messages 端点会自动添加 ?beta=true 参数 let url = adapter.build_url("https://api.anthropic.com", "/v1/messages"); - assert_eq!(url, "https://api.anthropic.com/v1/messages?beta=true"); + assert_eq!(url, "https://api.anthropic.com/v1/messages"); } #[test] fn test_build_url_openrouter() { let adapter = ClaudeAdapter::new(); - // /v1/messages 端点会自动添加 ?beta=true 参数 let url = adapter.build_url("https://openrouter.ai/api", "/v1/messages"); - assert_eq!(url, "https://openrouter.ai/api/v1/messages?beta=true"); + assert_eq!(url, "https://openrouter.ai/api/v1/messages"); } #[test] fn test_build_url_no_beta_for_other_endpoints() { let adapter = ClaudeAdapter::new(); - // 非 /v1/messages 端点不添加 ?beta=true let url = adapter.build_url("https://api.anthropic.com", "/v1/complete"); assert_eq!(url, "https://api.anthropic.com/v1/complete"); } @@ -546,9 +533,8 @@ mod tests { #[test] fn test_build_url_preserve_existing_query() { let adapter = ClaudeAdapter::new(); - // 已有查询参数时不重复添加 - let url = adapter.build_url("https://api.anthropic.com", "/v1/messages?foo=bar"); - assert_eq!(url, "https://api.anthropic.com/v1/messages?foo=bar"); + let url = adapter.build_url("https://api.anthropic.com", "/v1/messages?beta=true"); + assert_eq!(url, "https://api.anthropic.com/v1/messages?beta=true"); } #[test] diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index 158f615bf..601bf33dc 100644 --- a/src-tauri/src/services/stream_check.rs +++ b/src-tauri/src/services/stream_check.rs @@ -312,22 +312,12 @@ impl StreamCheckService { .unwrap_or("anthropic"); let is_openai_chat = api_format == "openai_chat"; - - // URL: /v1/chat/completions for openai_chat, /v1/messages?beta=true for anthropic - let url = if is_openai_chat { - if base.ends_with("/v1") { - format!("{base}/chat/completions") - } else { - format!("{base}/v1/chat/completions") - } - } else { - // ?beta=true is required by some relay services to verify request origin - if base.ends_with("/v1") { - format!("{base}/messages?beta=true") - } else { - format!("{base}/v1/messages?beta=true") - } - }; + let is_full_url = provider + .meta + .as_ref() + .and_then(|meta| meta.is_full_url) + .unwrap_or(false); + let url = Self::build_claude_stream_check_url(base, is_openai_chat, is_full_url); // Body: identical structure for minimal test (both APIs accept messages array) let body = json!({ @@ -692,6 +682,28 @@ impl StreamCheckService { other => other, } } + + fn build_claude_stream_check_url( + base_url: &str, + is_openai_chat: bool, + is_full_url: bool, + ) -> String { + if is_full_url { + return base_url.to_string(); + } + + if is_openai_chat { + if base_url.ends_with("/v1") { + format!("{base_url}/chat/completions") + } else { + format!("{base_url}/v1/chat/completions") + } + } else if base_url.ends_with("/v1") { + format!("{base_url}/messages?beta=true") + } else { + format!("{base_url}/v1/messages?beta=true") + } + } } #[cfg(test)] @@ -794,4 +806,37 @@ mod tests { assert_eq!(claude_auth, AuthStrategy::ClaudeAuth); assert_eq!(bearer, AuthStrategy::Bearer); } + + #[test] + fn test_build_claude_stream_check_url_for_full_url_mode() { + let url = StreamCheckService::build_claude_stream_check_url( + "https://relay.example/v1/chat/completions", + true, + true, + ); + + assert_eq!(url, "https://relay.example/v1/chat/completions"); + } + + #[test] + fn test_build_claude_stream_check_url_for_openai_chat() { + let url = StreamCheckService::build_claude_stream_check_url( + "https://relay.example/v1", + true, + false, + ); + + assert_eq!(url, "https://relay.example/v1/chat/completions"); + } + + #[test] + fn test_build_claude_stream_check_url_for_anthropic() { + let url = StreamCheckService::build_claude_stream_check_url( + "https://relay.example", + false, + false, + ); + + assert_eq!(url, "https://relay.example/v1/messages?beta=true"); + } } diff --git a/src/App.tsx b/src/App.tsx index 5ee35ddc5..b39b01460 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -255,7 +255,7 @@ function App() { deleteProvider, saveUsageScript, setAsDefaultModel, - } = useProviderActions(activeApp); + } = useProviderActions(activeApp, isProxyRunning); const disableOmoMutation = useDisableCurrentOmo(); const handleDisableOmo = () => { diff --git a/src/components/providers/forms/ClaudeFormFields.tsx b/src/components/providers/forms/ClaudeFormFields.tsx index 6b8ceef31..3d3e82b46 100644 --- a/src/components/providers/forms/ClaudeFormFields.tsx +++ b/src/components/providers/forms/ClaudeFormFields.tsx @@ -76,6 +76,10 @@ interface ClaudeFormFieldsProps { // Auth Field (ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY) apiKeyField: ClaudeApiKeyField; onApiKeyFieldChange: (field: ClaudeApiKeyField) => void; + + // Full URL mode + isFullUrl: boolean; + onFullUrlChange: (value: boolean) => void; } export function ClaudeFormFields({ @@ -112,6 +116,8 @@ export function ClaudeFormFields({ onApiFormatChange, apiKeyField, onApiKeyFieldChange, + isFullUrl, + onFullUrlChange, }: ClaudeFormFieldsProps) { const { t } = useTranslation(); @@ -181,6 +187,9 @@ export function ClaudeFormFields({ : t("providerForm.apiHint") } onManageClick={() => onEndpointModalToggle(true)} + showFullUrlToggle={true} + isFullUrl={isFullUrl} + onFullUrlChange={onFullUrlChange} /> )} diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 7d7898d07..d63cc2659 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -160,6 +160,10 @@ export function ProviderForm({ const [endpointAutoSelect, setEndpointAutoSelect] = useState( () => initialData?.meta?.endpointAutoSelect ?? true, ); + const [localIsFullUrl, setLocalIsFullUrl] = useState(() => { + if (appId !== "claude") return false; + return initialData?.meta?.isFullUrl ?? false; + }); const [testConfig, setTestConfig] = useState( () => initialData?.meta?.testConfig ?? { enabled: false }, @@ -199,6 +203,9 @@ export function ProviderForm({ setDraftCustomEndpoints([]); } setEndpointAutoSelect(initialData?.meta?.endpointAutoSelect ?? true); + setLocalIsFullUrl( + appId === "claude" ? (initialData?.meta?.isFullUrl ?? false) : false, + ); setTestConfig(initialData?.meta?.testConfig ?? { enabled: false }); setProxyConfig(initialData?.meta?.proxyConfig ?? { enabled: false }); setPricingConfig({ @@ -897,6 +904,10 @@ export function ProviderForm({ localApiKeyField !== "ANTHROPIC_AUTH_TOKEN" ? localApiKeyField : undefined, + isFullUrl: + appId === "claude" && category !== "official" && localIsFullUrl + ? true + : undefined, }; await onSubmit(payload); @@ -1136,6 +1147,7 @@ export function ProviderForm({ } setLocalApiKeyField(preset.apiKeyField ?? "ANTHROPIC_AUTH_TOKEN"); + setLocalIsFullUrl(false); form.reset({ name: preset.nameKey ? t(preset.nameKey) : preset.name, @@ -1344,6 +1356,8 @@ export function ProviderForm({ onApiFormatChange={handleApiFormatChange} apiKeyField={localApiKeyField} onApiKeyFieldChange={handleApiKeyFieldChange} + isFullUrl={localIsFullUrl} + onFullUrlChange={setLocalIsFullUrl} /> )} @@ -1607,7 +1621,9 @@ export function ProviderForm({ - + )} diff --git a/src/components/providers/forms/shared/EndpointField.tsx b/src/components/providers/forms/shared/EndpointField.tsx index ccdea7d9a..ead988891 100644 --- a/src/components/providers/forms/shared/EndpointField.tsx +++ b/src/components/providers/forms/shared/EndpointField.tsx @@ -1,7 +1,7 @@ import { useTranslation } from "react-i18next"; import { FormLabel } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { Zap } from "lucide-react"; +import { Link2, Zap } from "lucide-react"; interface EndpointFieldProps { id: string; @@ -13,6 +13,9 @@ interface EndpointFieldProps { showManageButton?: boolean; onManageClick?: () => void; manageButtonLabel?: string; + showFullUrlToggle?: boolean; + isFullUrl?: boolean; + onFullUrlChange?: (value: boolean) => void; } export function EndpointField({ @@ -25,6 +28,9 @@ export function EndpointField({ showManageButton = true, onManageClick, manageButtonLabel, + showFullUrlToggle = false, + isFullUrl = false, + onFullUrlChange, }: EndpointFieldProps) { const { t } = useTranslation(); @@ -55,6 +61,35 @@ export function EndpointField({ placeholder={placeholder} autoComplete="off" /> + {showFullUrlToggle && onFullUrlChange ? ( +
+ + {isFullUrl ? ( + + {t("providerForm.fullUrlHint", { + defaultValue: "代理将直接使用此 URL,不拼接路径", + })} + + ) : null} +
+ ) : null} {hint ? (

{hint}

diff --git a/src/hooks/useProviderActions.ts b/src/hooks/useProviderActions.ts index bc11722db..2531659b4 100644 --- a/src/hooks/useProviderActions.ts +++ b/src/hooks/useProviderActions.ts @@ -23,7 +23,7 @@ import { openclawKeys } from "@/hooks/useOpenClaw"; * Hook for managing provider actions (add, update, delete, switch) * Extracts business logic from App.tsx */ -export function useProviderActions(activeApp: AppId) { +export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) { const { t } = useTranslation(); const queryClient = useQueryClient(); @@ -139,6 +139,22 @@ export function useProviderActions(activeApp: AppId) { // 切换供应商 const switchProvider = useCallback( async (provider: Provider) => { + if ( + activeApp === "claude" && + provider.category !== "official" && + (provider.meta?.isFullUrl || + provider.meta?.apiFormat === "openai_chat" || + provider.meta?.apiFormat === "openai_responses") && + !isProxyRunning + ) { + toast.warning( + t("notifications.proxyRequiredForSwitch", { + defaultValue: "此供应商需要代理服务,请先启动代理", + }), + ); + return; + } + try { const result = await switchProviderMutation.mutateAsync(provider.id); await syncClaudePlugin(provider); @@ -192,7 +208,7 @@ export function useProviderActions(activeApp: AppId) { // 错误提示由 mutation 处理 } }, - [switchProviderMutation, syncClaudePlugin, activeApp, t], + [switchProviderMutation, syncClaudePlugin, activeApp, isProxyRunning, t], ); // 删除供应商 diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index c96e7fe49..87247ddd3 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -176,6 +176,7 @@ "settingsSaveFailed": "Failed to save settings: {{error}}", "openAIChatFormatHint": "This provider uses OpenAI Chat format and requires the proxy service to be enabled", "openAIFormatHint": "This provider uses OpenAI-compatible format and requires the proxy service to be enabled", + "proxyRequiredForSwitch": "This provider requires the proxy service. Start the proxy first.", "openLinkFailed": "Failed to open link", "openclawModelsRegistered": "Models have been registered to /model list", "openclawDefaultModelSet": "Set as default model", @@ -745,6 +746,9 @@ "anthropicReasoningModel": "Reasoning Model (Thinking)", "apiFormat": "API Format", "apiFormatHint": "Select the input format for the provider's API", + "fullUrlEnabled": "Full URL Mode", + "fullUrlDisabled": "Mark as Full URL", + "fullUrlHint": "Proxy will use this URL as-is", "apiFormatAnthropic": "Anthropic Messages (Native)", "apiFormatOpenAIChat": "OpenAI Chat Completions (Requires proxy)", "apiFormatOpenAIResponses": "OpenAI Responses API (Requires proxy)", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 0a4e6b517..c910b27d5 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -176,6 +176,7 @@ "settingsSaveFailed": "設定の保存に失敗しました: {{error}}", "openAIChatFormatHint": "このプロバイダーは OpenAI Chat フォーマットを使用しており、プロキシサービスの有効化が必要です", "openAIFormatHint": "このプロバイダーは OpenAI 互換フォーマットを使用しており、プロキシサービスの有効化が必要です", + "proxyRequiredForSwitch": "このプロバイダーにはプロキシサービスが必要です。先にプロキシを起動してください", "openLinkFailed": "リンクを開けませんでした", "openclawModelsRegistered": "モデルが /model リストに登録されました", "openclawDefaultModelSet": "デフォルトモデルに設定しました", @@ -745,6 +746,9 @@ "anthropicReasoningModel": "推論モデル(Thinking)", "apiFormat": "API フォーマット", "apiFormatHint": "プロバイダー API の入力フォーマットを選択", + "fullUrlEnabled": "フル URL モード", + "fullUrlDisabled": "フル URL として設定", + "fullUrlHint": "プロキシはこの URL をそのまま使用", "apiFormatAnthropic": "Anthropic Messages(ネイティブ)", "apiFormatOpenAIChat": "OpenAI Chat Completions(プロキシが必要)", "apiFormatOpenAIResponses": "OpenAI Responses API(プロキシが必要)", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 3b2e6df06..d5be97660 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -176,6 +176,7 @@ "settingsSaveFailed": "保存设置失败:{{error}}", "openAIChatFormatHint": "此供应商使用 OpenAI Chat 格式,需要开启代理服务才能正常使用", "openAIFormatHint": "此供应商使用 OpenAI 兼容格式,需要开启代理服务才能正常使用", + "proxyRequiredForSwitch": "此供应商需要代理服务,请先启动代理", "openLinkFailed": "链接打开失败", "openclawModelsRegistered": "模型已注册到 /model 列表", "openclawDefaultModelSet": "已设为默认模型", @@ -745,6 +746,9 @@ "anthropicReasoningModel": "推理模型 (Thinking)", "apiFormat": "API 格式", "apiFormatHint": "选择供应商 API 的输入格式", + "fullUrlEnabled": "完整 URL 模式", + "fullUrlDisabled": "标记为完整 URL", + "fullUrlHint": "代理将直接使用此 URL,不拼接路径", "apiFormatAnthropic": "Anthropic Messages (原生)", "apiFormatOpenAIChat": "OpenAI Chat Completions (需开启代理)", "apiFormatOpenAIResponses": "OpenAI Responses API (需开启代理)", diff --git a/src/types.ts b/src/types.ts index 9c6455870..f22797da3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -151,6 +151,8 @@ export interface ProviderMeta { apiFormat?: "anthropic" | "openai_chat" | "openai_responses"; // Claude 认证字段名 apiKeyField?: ClaudeApiKeyField; + // 是否将 base_url 视为完整 API 端点(代理直接使用此 URL,不拼接路径) + isFullUrl?: boolean; // Prompt cache key for OpenAI-compatible endpoints (improves cache hit rate) promptCacheKey?: string; } diff --git a/tests/hooks/useProviderActions.test.tsx b/tests/hooks/useProviderActions.test.tsx index 5f447326e..f85a60bef 100644 --- a/tests/hooks/useProviderActions.test.tsx +++ b/tests/hooks/useProviderActions.test.tsx @@ -7,11 +7,15 @@ import type { Provider, UsageScript } from "@/types"; const toastSuccessMock = vi.fn(); const toastErrorMock = vi.fn(); +const toastInfoMock = vi.fn(); +const toastWarningMock = vi.fn(); vi.mock("sonner", () => ({ toast: { success: (...args: unknown[]) => toastSuccessMock(...args), error: (...args: unknown[]) => toastErrorMock(...args), + info: (...args: unknown[]) => toastInfoMock(...args), + warning: (...args: unknown[]) => toastWarningMock(...args), }, })); @@ -116,6 +120,8 @@ beforeEach(() => { openclawApiSetDefaultModelMock.mockReset(); toastSuccessMock.mockReset(); toastErrorMock.mockReset(); + toastInfoMock.mockReset(); + toastWarningMock.mockReset(); addProviderMutation.isPending = false; updateProviderMutation.isPending = false; @@ -185,6 +191,28 @@ describe("useProviderActions", () => { expect(settingsApiApplyMock).not.toHaveBeenCalled(); }); + it("blocks switching providers that require proxy when proxy is not running", async () => { + const { wrapper } = createWrapper(); + const provider = createProvider({ + category: "custom", + meta: { + apiFormat: "openai_chat", + }, + }); + + const { result } = renderHook(() => useProviderActions("claude", 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({