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)
This commit is contained in:
YoVinchen
2026-03-16 14:22:54 +08:00
parent 36bbdc36f5
commit eeda9adb03
15 changed files with 313 additions and 55 deletions
+3
View File
@@ -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<String>,
/// 是否将 base_url 视为完整 API 端点(不拼接 endpoint 路径)
#[serde(rename = "isFullUrl", skip_serializing_if = "Option::is_none")]
pub is_full_url: Option<bool>,
/// 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.
+112 -12
View File
@@ -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<String> {
.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<String> {
let filtered = query.map(|query| {
query
.split('&')
.filter(|pair| !pair.is_empty() && !pair.starts_with("beta="))
.collect::<Vec<_>>()
.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<String>) {
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();
+7 -1
View File
@@ -61,12 +61,18 @@ pub async fn get_status(State(state): State<ProxyState>) -> Result<Json<ProxySta
/// - 现在 OpenRouter 已推出 Claude Code 兼容接口,默认不再启用该转换(逻辑保留以备回退)
pub async fn handle_messages(
State(state): State<ProxyState>,
uri: axum::http::Uri,
headers: axum::http::HeaderMap,
Json(body): Json<Value>,
) -> Result<axum::response::Response, ProxyError> {
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(),
+7 -21
View File
@@ -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]
+61 -16
View File
@@ -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");
}
}
+1 -1
View File
@@ -255,7 +255,7 @@ function App() {
deleteProvider,
saveUsageScript,
setAsDefaultModel,
} = useProviderActions(activeApp);
} = useProviderActions(activeApp, isProxyRunning);
const disableOmoMutation = useDisableCurrentOmo();
const handleDisableOmo = () => {
@@ -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}
/>
)}
@@ -160,6 +160,10 @@ export function ProviderForm({
const [endpointAutoSelect, setEndpointAutoSelect] = useState<boolean>(
() => initialData?.meta?.endpointAutoSelect ?? true,
);
const [localIsFullUrl, setLocalIsFullUrl] = useState<boolean>(() => {
if (appId !== "claude") return false;
return initialData?.meta?.isFullUrl ?? false;
});
const [testConfig, setTestConfig] = useState<ProviderTestConfig>(
() => 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({
<Button variant="outline" type="button" onClick={onCancel}>
{t("common.cancel")}
</Button>
<Button type="submit" disabled={isSubmitting}>{submitLabel}</Button>
<Button type="submit" disabled={isSubmitting}>
{submitLabel}
</Button>
</div>
)}
</form>
@@ -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 ? (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => onFullUrlChange(!isFullUrl)}
className={`flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs transition-colors ${
isFullUrl
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:border-foreground/30 hover:text-foreground"
}`}
>
<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>
) : null}
</div>
) : null}
{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();
@@ -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],
);
// 删除供应商
+4
View File
@@ -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)",
+4
View File
@@ -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(プロキシが必要)",
+4
View File
@@ -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 (需开启代理)",
+2
View File
@@ -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;
}
+28
View File
@@ -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({