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
This commit is contained in:
YoVinchen
2026-02-25 22:11:52 +08:00
parent 87b08ce242
commit 6427ab2128
11 changed files with 142 additions and 14 deletions
+3
View File
@@ -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<String>,
/// 是否将 base_url 视为完整 API 端点(不拼接 endpoint 路径)
#[serde(rename = "isFullUrl", skip_serializing_if = "Option::is_none")]
pub is_full_url: Option<bool>,
}
impl ProviderManager {
+46 -6
View File
@@ -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=trueAnthropic 专有参数)
let filtered = query.map(|q| {
q.split('&')
.filter(|p| !p.starts_with("beta="))
.collect::<Vec<_>>()
.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) =
+1 -1
View File
@@ -244,7 +244,7 @@ function App() {
deleteProvider,
saveUsageScript,
setAsDefaultModel,
} = useProviderActions(activeApp);
} = useProviderActions(activeApp, isProxyRunning);
const disableOmoMutation = useDisableCurrentOmo();
const handleDisableOmo = () => {
@@ -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}
/>
)}
@@ -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<boolean>(() => {
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}
/>
)}
@@ -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 && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => onFullUrlChange(!isFullUrl)}
className={`flex items-center gap-1.5 px-2.5 py-1 text-xs rounded-md border transition-colors ${
isFullUrl
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/30"
}`}
>
<Link2 className="h-3.5 w-3.5" />
{isFullUrl
? t("providerForm.fullUrlEnabled", {
defaultValue: "完整 URL 模式",
})
: t("providerForm.fullUrlDisabled", {
defaultValue: "标记为完整 URL",
})}
</button>
{isFullUrl && (
<span className="text-xs text-muted-foreground">
{t("providerForm.fullUrlHint", {
defaultValue: "代理将直接使用此 URL,不拼接路径",
})}
</span>
)}
</div>
)}
{hint ? (
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg">
<p className="text-xs text-amber-600 dark:text-amber-400">{hint}</p>
+18 -2
View File
@@ -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],
);
// 删除供应商
+4
View File
@@ -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",
+4
View File
@@ -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": "認証フィールド",
+4
View File
@@ -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": "认证字段",
+2
View File
@@ -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 同步方式