From 6427ab21282173216cffa0de03e6d2a563c41d11 Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Wed, 25 Feb 2026 22:11:52 +0800 Subject: [PATCH] feat(proxy): add isFullUrl toggle for full API endpoint mode - provider.rs/types.ts: add is_full_url field to ProviderMeta - forwarder.rs: when isFullUrl is set, use base_url directly instead of appending endpoint path; also handle query passthrough and strip beta=true when transforming to /v1/chat/completions - EndpointField.tsx: add Link2 icon toggle button for full URL mode - ClaudeFormFields.tsx: pass through isFullUrl/onFullUrlChange props - ProviderForm.tsx: manage localIsFullUrl state, persist to meta on save - useProviderActions.ts: block switching to isFullUrl or openai_chat providers when proxy is not running, show warning toast - App.tsx: pass isProxyRunning to useProviderActions - i18n: add fullUrlEnabled/fullUrlDisabled/fullUrlHint and proxyRequiredForSwitch translations for zh/en/ja --- src-tauri/src/provider.rs | 3 ++ src-tauri/src/proxy/forwarder.rs | 52 ++++++++++++++++--- src/App.tsx | 2 +- .../providers/forms/ClaudeFormFields.tsx | 9 ++++ .../providers/forms/ProviderForm.tsx | 19 +++++-- .../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 + 11 files changed, 142 insertions(+), 14 deletions(-) diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 0581e746..7be01a96 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -240,6 +240,9 @@ pub struct ProviderMeta { /// - "ANTHROPIC_API_KEY": 少数供应商需要原生 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, } impl ProviderManager { diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index c26ceabf..9e7986ea 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -749,15 +749,55 @@ impl RequestForwarder { // 检查是否需要格式转换 let needs_transform = adapter.needs_transform(provider); - let effective_endpoint = - if needs_transform && adapter.name() == "Claude" && endpoint == "/v1/messages" { - "/v1/chat/completions" + // 检查 isFullUrl 模式:直接使用 base_url 作为完整 API 端点 + let is_full_url = provider + .meta + .as_ref() + .and_then(|m| m.is_full_url) + .unwrap_or(false); + + let url = if is_full_url { + // 全链接模式:直接使用 base_url,将客户端 query 追加 + let query = endpoint.split_once('?').map(|(_, q)| q); + match query { + Some(q) if !q.is_empty() => { + if base_url.contains('?') { + format!("{base_url}&{q}") + } else { + format!("{base_url}?{q}") + } + } + _ => base_url.clone(), + } + } else { + // 正常模式:endpoint 可能带 query string,需拆分路径和参数 + let effective_endpoint: String = if needs_transform && adapter.name() == "Claude" { + let (path, query) = match endpoint.split_once('?') { + Some((p, q)) => (p, Some(q)), + None => (endpoint, None), + }; + if path == "/v1/messages" || path == "/claude/v1/messages" { + // 转换到 chat/completions 时剥离 beta=true(Anthropic 专有参数) + let filtered = query.map(|q| { + q.split('&') + .filter(|p| !p.starts_with("beta=")) + .collect::>() + .join("&") + }); + match filtered.as_deref() { + Some(q) if !q.is_empty() => format!("/v1/chat/completions?{q}"), + _ => "/v1/chat/completions".to_string(), + } + } else { + endpoint.to_string() + } } else { - endpoint + endpoint.to_string() }; - // 使用适配器构建 URL - let url = adapter.build_url(&base_url, effective_endpoint); + // 使用适配器构建 URL + adapter.build_url(&base_url, &effective_endpoint) + }; // 应用模型映射(独立于格式转换) let (mapped_body, _original_model, _mapped_model) = diff --git a/src/App.tsx b/src/App.tsx index f18164b7..f0cc614b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -244,7 +244,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 f9ac424c..22192ff8 100644 --- a/src/components/providers/forms/ClaudeFormFields.tsx +++ b/src/components/providers/forms/ClaudeFormFields.tsx @@ -76,6 +76,10 @@ interface ClaudeFormFieldsProps { // Auth Key Field (ANTHROPIC_AUTH_TOKEN vs 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(); @@ -179,6 +185,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 2f7d33ca..4b1a70bd 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -203,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({ @@ -249,6 +252,11 @@ export function ProviderForm({ return initialData?.meta?.apiFormat ?? "anthropic"; }); + const [localIsFullUrl, setLocalIsFullUrl] = useState(() => { + if (appId !== "claude") return false; + return initialData?.meta?.isFullUrl ?? false; + }); + const handleApiFormatChange = useCallback((format: ClaudeApiFormat) => { setLocalApiFormat(format); }, []); @@ -826,6 +834,10 @@ export function ProviderForm({ appId === "claude" && category !== "official" ? localApiKeyField : undefined, + isFullUrl: + appId === "claude" && category !== "official" && localIsFullUrl + ? true + : undefined, }; onSubmit(payload); @@ -1241,10 +1253,7 @@ export function ProviderForm({ providerId={providerId} shouldShowApiKey={ hasApiKeyField(form.getValues("settingsConfig"), "claude") && - shouldShowApiKey( - form.getValues("settingsConfig"), - isEditMode, - ) + shouldShowApiKey(form.getValues("settingsConfig"), isEditMode) } apiKey={apiKey} onApiKeyChange={handleApiKeyChange} @@ -1279,6 +1288,8 @@ export function ProviderForm({ onApiFormatChange={handleApiFormatChange} apiKeyField={localApiKeyField} onApiKeyFieldChange={handleApiKeyFieldChange} + isFullUrl={localIsFullUrl} + onFullUrlChange={setLocalIsFullUrl} /> )} diff --git a/src/components/providers/forms/shared/EndpointField.tsx b/src/components/providers/forms/shared/EndpointField.tsx index ccdea7d9..c01c2dd0 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 { Zap, Link2 } 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,不拼接路径", + })} + + )} +
+ )} {hint ? (

{hint}

diff --git a/src/hooks/useProviderActions.ts b/src/hooks/useProviderActions.ts index d6b3cd17..a6eb4440 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(); @@ -133,6 +133,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") && + !isProxyRunning + ) { + toast.warning( + t("notifications.proxyRequiredForSwitch", { + defaultValue: "此供应商需要代理服务,请先启动代理", + }), + ); + return; + } + try { const result = await switchProviderMutation.mutateAsync(provider.id); await syncClaudePlugin(provider); @@ -185,7 +201,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 b4deab8a..e75e8081 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -158,6 +158,7 @@ "settingsSaved": "Settings saved", "settingsSaveFailed": "Failed to save settings: {{error}}", "openAIChatFormatHint": "This provider uses OpenAI Chat format and requires the proxy service to be enabled", + "proxyRequiredForSwitch": "This provider requires the proxy service. Please start the proxy first", "openLinkFailed": "Failed to open link", "openclawModelsRegistered": "Models have been registered to /model list", "openclawDefaultModelSet": "Set as default model", @@ -653,6 +654,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)", "authField": "Auth Field", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index bfa30d4d..0aaf61e6 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -158,6 +158,7 @@ "settingsSaved": "設定を保存しました", "settingsSaveFailed": "設定の保存に失敗しました: {{error}}", "openAIChatFormatHint": "このプロバイダーは OpenAI Chat フォーマットを使用しており、プロキシサービスの有効化が必要です", + "proxyRequiredForSwitch": "このプロバイダーにはプロキシが必要です。先にプロキシを起動してください", "openLinkFailed": "リンクを開けませんでした", "openclawModelsRegistered": "モデルが /model リストに登録されました", "openclawDefaultModelSet": "デフォルトモデルに設定しました", @@ -653,6 +654,9 @@ "anthropicReasoningModel": "推論モデル(Thinking)", "apiFormat": "API フォーマット", "apiFormatHint": "プロバイダー API の入力フォーマットを選択", + "fullUrlEnabled": "フル URL モード", + "fullUrlDisabled": "フル URL として設定", + "fullUrlHint": "プロキシはこの URL をそのまま使用", "apiFormatAnthropic": "Anthropic Messages(ネイティブ)", "apiFormatOpenAIChat": "OpenAI Chat Completions(プロキシが必要)", "authField": "認証フィールド", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index f5f872c0..58f4b64b 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -158,6 +158,7 @@ "settingsSaved": "设置已保存", "settingsSaveFailed": "保存设置失败:{{error}}", "openAIChatFormatHint": "此供应商使用 OpenAI Chat 格式,需要开启代理服务才能正常使用", + "proxyRequiredForSwitch": "此供应商需要代理服务,请先启动代理", "openLinkFailed": "链接打开失败", "openclawModelsRegistered": "模型已注册到 /model 列表", "openclawDefaultModelSet": "已设为默认模型", @@ -653,6 +654,9 @@ "anthropicReasoningModel": "推理模型 (Thinking)", "apiFormat": "API 格式", "apiFormatHint": "选择供应商 API 的输入格式", + "fullUrlEnabled": "完整 URL 模式", + "fullUrlDisabled": "标记为完整 URL", + "fullUrlHint": "代理将直接使用此 URL,不拼接路径", "apiFormatAnthropic": "Anthropic Messages (原生)", "apiFormatOpenAIChat": "OpenAI Chat Completions (需开启代理)", "authField": "认证字段", diff --git a/src/types.ts b/src/types.ts index a6c769e6..f82eae65 100644 --- a/src/types.ts +++ b/src/types.ts @@ -150,6 +150,8 @@ export interface ProviderMeta { // - "ANTHROPIC_AUTH_TOKEN" (默认): 大多数第三方/聚合供应商 // - "ANTHROPIC_API_KEY": 少数供应商需要原生 API Key apiKeyField?: "ANTHROPIC_AUTH_TOKEN" | "ANTHROPIC_API_KEY"; + // 是否将 base_url 视为完整 API 端点(代理直接使用此 URL,不拼接路径) + isFullUrl?: boolean; } // Skill 同步方式