From 1f7fb304ddafe9d78087158a96ccf134ea44cb52 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Wed, 27 May 2026 06:57:09 +0800 Subject: [PATCH] feat: add LLM proxy toggle --- app/agent/__init__.py | 5 + app/agent/llm/helper.py | 108 ++++++++++++++++---- app/agent/llm/provider.py | 148 +++++++++++++++++++++------- app/api/endpoints/llm.py | 5 + app/core/config.py | 2 + app/schemas/event.py | 3 +- tests/test_agent_tokens_events.py | 1 + tests/test_llm_helper_testcall.py | 43 ++++++++ tests/test_llm_provider_registry.py | 10 +- tests/test_system_llm_test.py | 7 ++ 10 files changed, 277 insertions(+), 55 deletions(-) diff --git a/app/agent/__init__.py b/app/agent/__init__.py index 1d95a9c4..dc78b69c 100644 --- a/app/agent/__init__.py +++ b/app/agent/__init__.py @@ -470,6 +470,7 @@ class MoviePilotAgent: base_url=settings.LLM_BASE_URL, base_url_preset=settings.LLM_BASE_URL_PRESET, user_agent=settings.LLM_USER_AGENT, + use_proxy=settings.LLM_USE_PROXY, thinking_level=None, ) selected_event = await eventmanager.async_send_event( @@ -502,6 +503,9 @@ class MoviePilotAgent: self._clean_optional_text(self._get_event_value(resolved_data, "user_agent")) or settings.LLM_USER_AGENT ) + use_proxy = self._get_event_value(resolved_data, "use_proxy") + if use_proxy is None: + use_proxy = settings.LLM_USE_PROXY thinking_level = self._clean_optional_text( self._get_event_value(resolved_data, "thinking_level") ) @@ -528,6 +532,7 @@ class MoviePilotAgent: "base_url": base_url, "base_url_preset": base_url_preset, "user_agent": user_agent, + "use_proxy": bool(use_proxy), "thinking_level": thinking_level, } return self._llm_runtime_config diff --git a/app/agent/llm/helper.py b/app/agent/llm/helper.py index 34ce43e8..cab53417 100644 --- a/app/agent/llm/helper.py +++ b/app/agent/llm/helper.py @@ -137,6 +137,57 @@ def _get_httpx_proxy_key() -> str: return "proxies" +def _resolve_llm_proxy(use_proxy: bool | None = None) -> str | None: + """ + 解析本次 LLM 调用应使用的系统代理地址。 + """ + should_use_proxy = settings.LLM_USE_PROXY if use_proxy is None else use_proxy + return settings.PROXY_HOST if should_use_proxy and settings.PROXY_HOST else None + + +def _build_httpx_proxy_kwargs(proxy_url: str | None) -> dict[str, str]: + """ + 构造兼容当前 httpx 版本的代理参数。 + """ + if not proxy_url: + return {} + return {_get_httpx_proxy_key(): proxy_url} + + +def _build_google_client_args(proxy_url: str | None) -> dict[str, Any]: + """ + 构造 Google SDK 透传给 httpx 的客户端参数。 + """ + return { + "trust_env": False, + **_build_httpx_proxy_kwargs(proxy_url), + } + + +def _build_httpx_client( + proxy_url: str | None, + *, + async_client: bool = False, + timeout: float | None = None, +): + """ + 构造显式代理策略的 httpx 客户端。 + + 当关闭 LLM 代理时也返回 trust_env=False 的客户端,避免 httpx 自动读取 + 进程环境变量中的代理配置。 + """ + import httpx + + client_cls = httpx.AsyncClient if async_client else httpx.Client + kwargs: dict[str, Any] = { + "trust_env": False, + **_build_httpx_proxy_kwargs(proxy_url), + } + if timeout is not None: + kwargs["timeout"] = timeout + return client_cls(**kwargs) + + def _deepseek_thinking_toggle(extra_body: Any) -> bool | None: """ 解析 DeepSeek extra_body 中显式传入的 thinking 开关。 @@ -733,6 +784,7 @@ class LLMHelper: base_url: str | None = None, base_url_preset: str | None = None, user_agent: str | None = None, + use_proxy: bool | None = None, ): """ 获取LLM实例 @@ -747,6 +799,7 @@ class LLMHelper: :param base_url: API Base URL。未显式传入时使用当前配置项 LLM_BASE_URL。 :param base_url_preset: Base URL 预设。未显式传入时使用当前配置项 LLM_BASE_URL_PRESET。 :param user_agent: OpenAI兼容接口请求 User-Agent。未显式传入时使用配置项 LLM_USER_AGENT。 + :param use_proxy: 是否为本次 LLM 调用使用系统代理。未显式传入时使用配置项 LLM_USE_PROXY。 :return: LLM实例 """ provider_name = str(provider if provider is not None else settings.LLM_PROVIDER).lower() @@ -772,6 +825,7 @@ class LLMHelper: base_url=base_url_value, base_url_preset_id=base_url_preset_value, user_agent=user_agent_value, + use_proxy=use_proxy, ) except Exception as err: logger.debug(f"LLM provider 目录不可用,回退到旧运行时逻辑: {err}") @@ -797,6 +851,7 @@ class LLMHelper: model=model_name, runtime=runtime, ) + llm_proxy = _resolve_llm_proxy(use_proxy) if runtime["runtime"] == "google": # 修补 Gemini 2.5 思考模型的 thought_signature 兼容性 @@ -807,18 +862,13 @@ class LLMHelper: # 会导致工具调用时报错 400 from langchain_google_genai import ChatGoogleGenerativeAI - client_args = None - if settings.PROXY_HOST: - proxy_key = _get_httpx_proxy_key() - client_args = {proxy_key: settings.PROXY_HOST} - model = ChatGoogleGenerativeAI( model=model_name, api_key=runtime["api_key"], retries=3, temperature=settings.LLM_TEMPERATURE, streaming=streaming, - client_args=client_args, + client_args=_build_google_client_args(llm_proxy), **thinking_kwargs, ) elif runtime["runtime"] == "deepseek": @@ -833,6 +883,8 @@ class LLMHelper: temperature=settings.LLM_TEMPERATURE, streaming=streaming, stream_usage=True, + http_client=_build_httpx_client(llm_proxy), + http_async_client=_build_httpx_client(llm_proxy, async_client=True), **thinking_kwargs, ) elif runtime["runtime"] in {"anthropic_compatible", "copilot_anthropic"}: @@ -846,7 +898,7 @@ class LLMHelper: temperature=settings.LLM_TEMPERATURE, streaming=streaming, stream_usage=True, - anthropic_proxy=settings.PROXY_HOST, + anthropic_proxy=llm_proxy, default_headers=default_headers, **thinking_kwargs, ) @@ -867,7 +919,15 @@ class LLMHelper: temperature=settings.LLM_TEMPERATURE, streaming=streaming, stream_usage=True, - openai_proxy=settings.PROXY_HOST, + openai_proxy=llm_proxy, + **( + {} + if llm_proxy + else { + "http_client": _build_httpx_client(llm_proxy), + "http_async_client": _build_httpx_client(llm_proxy, async_client=True), + } + ), default_headers=default_headers, use_responses_api=use_responses_api, **thinking_kwargs, @@ -945,6 +1005,7 @@ class LLMHelper: base_url: str | None = None, base_url_preset: str | None = None, user_agent: str | None = None, + use_proxy: bool | None = None, ) -> dict: """ 使用当前已保存配置执行一次最小 LLM 调用。 @@ -961,6 +1022,7 @@ class LLMHelper: base_url=base_url, base_url_preset=base_url_preset, user_agent=user_agent, + use_proxy=use_proxy, ) try: response = await asyncio.wait_for(llm.ainvoke(prompt), timeout=timeout) @@ -992,6 +1054,7 @@ class LLMHelper: base_url: str | None = None, base_url_preset: str | None = None, user_agent: str | None = None, + use_proxy: bool | None = None, force_refresh: bool = False, ) -> List[dict[str, Any]]: """ @@ -1010,6 +1073,7 @@ class LLMHelper: base_url=base_url, base_url_preset_id=base_url_preset, user_agent=user_agent, + use_proxy=use_proxy, force_refresh=force_refresh, ) except Exception as err: @@ -1017,7 +1081,10 @@ class LLMHelper: if provider == "google": return [ {"id": model_id, "name": model_id} - for model_id in await self._get_google_models(api_key or "") + for model_id in await self._get_google_models( + api_key or "", + use_proxy=use_proxy, + ) ] try: from app.agent.llm.provider import LLMProviderManager @@ -1039,24 +1106,23 @@ class LLMHelper: api_key or "", model_list_base_url, user_agent=user_agent, + use_proxy=use_proxy, ) ] @staticmethod - async def _get_google_models(api_key: str) -> List[str]: + async def _get_google_models(api_key: str, use_proxy: bool | None = None) -> List[str]: """获取Google模型列表(使用 google-genai SDK v1)""" try: from google import genai from google.genai.types import HttpOptions - http_options = None - if settings.PROXY_HOST: - proxy_key = _get_httpx_proxy_key() - proxy_args = {proxy_key: settings.PROXY_HOST} - http_options = HttpOptions( - client_args=proxy_args, - async_client_args=proxy_args, - ) + llm_proxy = _resolve_llm_proxy(use_proxy) + google_client_args = _build_google_client_args(llm_proxy) + http_options = HttpOptions( + client_args=google_client_args, + async_client_args=google_client_args, + ) client = genai.Client(api_key=api_key, http_options=http_options) models = await client.aio.models.list() @@ -1077,6 +1143,7 @@ class LLMHelper: api_key: str, base_url: str = None, user_agent: str | None = None, + use_proxy: bool | None = None, ) -> List[str]: """获取OpenAI兼容模型列表""" try: @@ -1092,6 +1159,11 @@ class LLMHelper: None, user_agent=user_agent, ), + http_client=_build_httpx_client( + _resolve_llm_proxy(use_proxy), + async_client=True, + timeout=15.0, + ), ) models = await client.models.list() await client.close() diff --git a/app/agent/llm/provider.py b/app/agent/llm/provider.py index 4e2e7e6c..e346a539 100644 --- a/app/agent/llm/provider.py +++ b/app/agent/llm/provider.py @@ -1085,14 +1085,20 @@ class LLMProviderManager(metaclass=Singleton): return builtin_specs + self._dynamic_provider_specs(builtin_specs) async def _get_provider_async( - self, provider_id: str, force_refresh: bool = False + self, + provider_id: str, + force_refresh: bool = False, + use_proxy: Optional[bool] = None, ) -> ProviderSpec: """异步获取指定 provider 的 ProviderSpec 实例。""" normalized_provider_id = self._normalize_provider_id(provider_id) try: return self.get_provider(normalized_provider_id) except LLMProviderError: - await self.get_models_dev_data(force_refresh=force_refresh) + await self.get_models_dev_data( + force_refresh=force_refresh, + use_proxy=use_proxy, + ) return self.get_provider(normalized_provider_id) def _serialize_provider(self, spec: ProviderSpec) -> dict[str, Any]: @@ -1132,11 +1138,16 @@ class LLMProviderManager(metaclass=Singleton): } async def list_providers_async( - self, force_refresh: bool = False + self, + force_refresh: bool = False, + use_proxy: Optional[bool] = None, ) -> list[dict[str, Any]]: """返回前端可渲染的 provider 目录,并优先补齐 models.dev 动态平台。""" try: - await self.get_models_dev_data(force_refresh=force_refresh) + await self.get_models_dev_data( + force_refresh=force_refresh, + use_proxy=use_proxy, + ) except Exception as err: logger.debug(f"加载 models.dev provider 目录失败,回退内置列表: {err}") return self.list_providers() @@ -1327,10 +1338,14 @@ class LLMProviderManager(metaclass=Singleton): params = httpx.Client.__init__.__code__.co_varnames return "proxy" if "proxy" in params else "proxies" - def _build_httpx_kwargs(self) -> dict[str, Any]: + def _build_httpx_kwargs(self, use_proxy: Optional[bool] = None) -> dict[str, Any]: """构造用于 httpx 客户端的参数,如代理等。""" - kwargs: dict[str, Any] = {"timeout": self._DEFAULT_TIMEOUT} - if settings.PROXY_HOST: + should_use_proxy = settings.LLM_USE_PROXY if use_proxy is None else use_proxy + kwargs: dict[str, Any] = { + "timeout": self._DEFAULT_TIMEOUT, + "trust_env": False, + } + if should_use_proxy and settings.PROXY_HOST: kwargs[self._httpx_proxy_key()] = settings.PROXY_HOST return kwargs @@ -1441,15 +1456,19 @@ class LLMProviderManager(metaclass=Singleton): except Exception as err: logger.warning(f"写入 models.dev 缓存失败: {err}") - async def _fetch_models_dev(self) -> dict[str, Any]: + async def _fetch_models_dev(self, use_proxy: Optional[bool] = None) -> dict[str, Any]: """通过网络请求获取最新 models.dev 数据。""" headers = {"User-Agent": "MoviePilot/1.0"} - async with httpx.AsyncClient(**self._build_httpx_kwargs()) as client: + async with httpx.AsyncClient(**self._build_httpx_kwargs(use_proxy)) as client: response = await client.get(self._MODELS_DEV_URL, headers=headers) response.raise_for_status() return response.json() - async def get_models_dev_data(self, force_refresh: bool = False) -> dict[str, Any]: + async def get_models_dev_data( + self, + force_refresh: bool = False, + use_proxy: Optional[bool] = None, + ) -> dict[str, Any]: """ 返回 models.dev 原始数据。 @@ -1475,7 +1494,7 @@ class LLMProviderManager(metaclass=Singleton): return cached try: - payload = await self._fetch_models_dev() + payload = await self._fetch_models_dev(use_proxy=use_proxy) self._models_dev_data = payload self._models_dev_loaded_at = now await self._write_models_dev_to_disk(payload) @@ -1499,9 +1518,13 @@ class LLMProviderManager(metaclass=Singleton): provider_id: str, base_url: Optional[str] = None, base_url_preset_id: Optional[str] = None, + use_proxy: Optional[bool] = None, ) -> dict[str, Any]: """获取指定 provider 在 models.dev 中的完整负载。""" - spec = await self._get_provider_async(provider_id) + spec = await self._get_provider_async( + provider_id, + use_proxy=use_proxy, + ) models_dev_provider_id = self._resolve_provider_models_dev_provider_id( spec, base_url, @@ -1509,7 +1532,9 @@ class LLMProviderManager(metaclass=Singleton): ) if not models_dev_provider_id: return {} - return (await self.get_models_dev_data()).get(models_dev_provider_id, {}) or {} + return ( + await self.get_models_dev_data(use_proxy=use_proxy) + ).get(models_dev_provider_id, {}) or {} async def _models_dev_model( self, @@ -1517,12 +1542,14 @@ class LLMProviderManager(metaclass=Singleton): model_id: str, base_url: Optional[str] = None, base_url_preset_id: Optional[str] = None, + use_proxy: Optional[bool] = None, ) -> dict[str, Any] | None: """获取指定模型的 models.dev 元数据。""" payload = await self._models_dev_provider_payload( provider_id, base_url=base_url, base_url_preset_id=base_url_preset_id, + use_proxy=use_proxy, ) models = payload.get("models") if isinstance(payload, dict) else None if not isinstance(models, dict): @@ -1621,19 +1648,23 @@ class LLMProviderManager(metaclass=Singleton): return normalized[:-3] return normalized - async def _list_models_from_google(self, api_key: str) -> list[dict[str, Any]]: + async def _list_models_from_google( + self, + api_key: str, + use_proxy: Optional[bool] = None, + ) -> list[dict[str, Any]]: """从 Google AI Studio 获取模型列表。""" from google import genai from google.genai.types import HttpOptions - http_options = None - if settings.PROXY_HOST: - proxy_key = self._httpx_proxy_key() - proxy_args = {proxy_key: settings.PROXY_HOST} - http_options = HttpOptions( - client_args=proxy_args, - async_client_args=proxy_args, - ) + should_use_proxy = settings.LLM_USE_PROXY if use_proxy is None else use_proxy + client_args: dict[str, Any] = {"trust_env": False} + if should_use_proxy and settings.PROXY_HOST: + client_args[self._httpx_proxy_key()] = settings.PROXY_HOST + http_options = HttpOptions( + client_args=client_args, + async_client_args=client_args, + ) client = genai.Client(api_key=api_key, http_options=http_options) response = await client.aio.models.list() @@ -1643,7 +1674,11 @@ class LLMProviderManager(metaclass=Singleton): if "generateContent" not in supported: continue model_id = model.name - metadata = await self._models_dev_model("google", model_id) or {} + metadata = await self._models_dev_model( + "google", + model_id, + use_proxy=use_proxy, + ) or {} results.append( self._normalize_model_record( model_id=model_id, @@ -1660,6 +1695,7 @@ class LLMProviderManager(metaclass=Singleton): api_key: str, base_url: str, default_headers: Optional[dict[str, str]] = None, + use_proxy: Optional[bool] = None, ) -> list[dict[str, Any]]: """通过 OpenAI 兼容接口获取模型列表。""" from openai import AsyncOpenAI @@ -1670,6 +1706,7 @@ class LLMProviderManager(metaclass=Singleton): default_headers=default_headers, timeout=15.0, max_retries=2, + http_client=httpx.AsyncClient(**self._build_httpx_kwargs(use_proxy)), ) results = [] response = await client.models.list() @@ -1678,6 +1715,7 @@ class LLMProviderManager(metaclass=Singleton): provider_id, model.id, base_url=base_url, + use_proxy=use_proxy, ) or {} results.append( self._normalize_model_record( @@ -1695,6 +1733,7 @@ class LLMProviderManager(metaclass=Singleton): transport: str = "openai", base_url: Optional[str] = None, base_url_preset_id: Optional[str] = None, + use_proxy: Optional[bool] = None, ) -> list[dict[str, Any]]: """ 某些 provider 没有统一稳定的 models.list 行为, @@ -1705,6 +1744,7 @@ class LLMProviderManager(metaclass=Singleton): provider_id, base_url=base_url, base_url_preset_id=base_url_preset_id, + use_proxy=use_proxy, ) models = payload.get("models") if isinstance(payload, dict) else None if not isinstance(models, dict): @@ -1741,9 +1781,13 @@ class LLMProviderManager(metaclass=Singleton): headers["Authorization"] = f"Bearer {token}" return headers - async def _list_models_from_copilot(self, token: str) -> list[dict[str, Any]]: + async def _list_models_from_copilot( + self, + token: str, + use_proxy: Optional[bool] = None, + ) -> list[dict[str, Any]]: """从 GitHub Copilot 端点获取模型列表。""" - async with httpx.AsyncClient(**self._build_httpx_kwargs()) as client: + async with httpx.AsyncClient(**self._build_httpx_kwargs(use_proxy)) as client: response = await client.get( "https://api.githubcopilot.com/models", headers=self._copilot_headers(token), @@ -1780,7 +1824,11 @@ class LLMProviderManager(metaclass=Singleton): limits = ((item.get("capabilities") or {}).get("limits") or {}) supports = ((item.get("capabilities") or {}).get("supports") or {}) - metadata = await self._models_dev_model("github-copilot", model_id) or {} + metadata = await self._models_dev_model( + "github-copilot", + model_id, + use_proxy=use_proxy, + ) or {} results.append( self._normalize_model_record( model_id=model_id, @@ -1811,6 +1859,7 @@ class LLMProviderManager(metaclass=Singleton): provider_id: str, base_url: Optional[str] = None, base_url_preset_id: Optional[str] = None, + use_proxy: Optional[bool] = None, ) -> list[dict[str, Any]]: """获取开启 OAuth 的 ChatGPT 模型列表。""" # ChatGPT OAuth 仍然是 chatgpt provider 专属能力,但模型目录不再维护 @@ -1819,6 +1868,7 @@ class LLMProviderManager(metaclass=Singleton): provider_id, base_url=base_url, base_url_preset_id=base_url_preset_id, + use_proxy=use_proxy, ) models = payload.get("models") if isinstance(payload, dict) else None if not isinstance(models, dict): @@ -1843,10 +1893,15 @@ class LLMProviderManager(metaclass=Singleton): base_url: Optional[str] = None, base_url_preset_id: Optional[str] = None, user_agent: Optional[str] = None, + use_proxy: Optional[bool] = None, force_refresh: bool = False, ) -> list[dict[str, Any]]: """返回标准化后的模型目录。""" - spec = await self._get_provider_async(provider_id, force_refresh=force_refresh) + spec = await self._get_provider_async( + provider_id, + force_refresh=force_refresh, + use_proxy=use_proxy, + ) resolved_model_list_strategy = self._resolve_provider_model_list_strategy( spec, base_url, @@ -1860,7 +1915,10 @@ class LLMProviderManager(metaclass=Singleton): # 对依赖 models.dev 的 provider 主动刷新一次缓存,保证“刷新模型列表” # 在使用目录型 provider 时也能拿到最新参数。 if force_refresh: - await self.get_models_dev_data(force_refresh=True) + await self.get_models_dev_data( + force_refresh=True, + use_proxy=use_proxy, + ) if resolved_model_list_strategy == "manual": # 万擎等推理点型平台没有稳定的全局模型目录,模型 ID 需要用户从控制台复制。 @@ -1873,13 +1931,20 @@ class LLMProviderManager(metaclass=Singleton): base_url=base_url, base_url_preset_id=base_url_preset_id, user_agent=user_agent, + use_proxy=use_proxy, ) if resolved_model_list_strategy == "google": - return await self._list_models_from_google(runtime["api_key"]) + return await self._list_models_from_google( + runtime["api_key"], + use_proxy=use_proxy, + ) if resolved_model_list_strategy == "github_copilot": - return await self._list_models_from_copilot(runtime["api_key"]) + return await self._list_models_from_copilot( + runtime["api_key"], + use_proxy=use_proxy, + ) if resolved_model_list_strategy == "chatgpt": if runtime.get("auth_mode") == "oauth": @@ -1887,6 +1952,7 @@ class LLMProviderManager(metaclass=Singleton): provider_id=provider_id, base_url=base_url, base_url_preset_id=base_url_preset_id, + use_proxy=use_proxy, ) return await self._list_models_from_openai_compatible( provider_id="chatgpt", @@ -1900,6 +1966,7 @@ class LLMProviderManager(metaclass=Singleton): runtime.get("default_headers"), user_agent, ), + use_proxy=use_proxy, ) if resolved_model_list_strategy == "anthropic_compatible": @@ -1908,6 +1975,7 @@ class LLMProviderManager(metaclass=Singleton): transport="anthropic", base_url=base_url, base_url_preset_id=base_url_preset_id, + use_proxy=use_proxy, ) if resolved_model_list_strategy == "models_dev_only": @@ -1916,6 +1984,7 @@ class LLMProviderManager(metaclass=Singleton): transport="openai", base_url=base_url, base_url_preset_id=base_url_preset_id, + use_proxy=use_proxy, ) # openai-compatible / deepseek 默认走官方 models 端点。 @@ -1931,6 +2000,7 @@ class LLMProviderManager(metaclass=Singleton): runtime.get("default_headers"), user_agent, ), + use_proxy=use_proxy, ) async def resolve_model_metadata( @@ -1939,6 +2009,7 @@ class LLMProviderManager(metaclass=Singleton): model_id: Optional[str], base_url: Optional[str] = None, base_url_preset_id: Optional[str] = None, + use_proxy: Optional[bool] = None, ) -> dict[str, Any] | None: """解析并返回指定模型在 models.dev 中的元数据。""" if not model_id: @@ -1948,13 +2019,18 @@ class LLMProviderManager(metaclass=Singleton): model_id, base_url=base_url, base_url_preset_id=base_url_preset_id, + use_proxy=use_proxy, ) if metadata: return metadata if provider_id == "chatgpt": - return await self._models_dev_model("openai", model_id) + return await self._models_dev_model( + "openai", + model_id, + use_proxy=use_proxy, + ) if provider_id == "openai": - models_dev = await self.get_models_dev_data() + models_dev = await self.get_models_dev_data(use_proxy=use_proxy) return models_dev.get("openai", {}).get("models", {}).get(model_id) return None @@ -2424,6 +2500,7 @@ class LLMProviderManager(metaclass=Singleton): base_url: Optional[str] = None, base_url_preset_id: Optional[str] = None, user_agent: Optional[str] = None, + use_proxy: Optional[bool] = None, ) -> dict[str, Any]: """ 解析 provider 运行时参数。 @@ -2435,7 +2512,10 @@ class LLMProviderManager(metaclass=Singleton): normalized_provider_id, base_url_preset_id, ) - spec = await self._get_provider_async(normalized_provider_id) + spec = await self._get_provider_async( + normalized_provider_id, + use_proxy=use_proxy, + ) resolved_runtime = self._resolve_provider_runtime( spec, base_url, @@ -2455,6 +2535,7 @@ class LLMProviderManager(metaclass=Singleton): base_url=base_url, base_url_preset_id=normalized_base_url_preset_id, user_agent=user_agent, + use_proxy=use_proxy, ) if item["id"] == model ), @@ -2474,6 +2555,7 @@ class LLMProviderManager(metaclass=Singleton): model, base_url=base_url, base_url_preset_id=normalized_base_url_preset_id, + use_proxy=use_proxy, ), "default_headers": None, "use_responses_api": None, diff --git a/app/api/endpoints/llm.py b/app/api/endpoints/llm.py index c5490f1f..871e96c4 100644 --- a/app/api/endpoints/llm.py +++ b/app/api/endpoints/llm.py @@ -36,6 +36,7 @@ class LlmTestRequest(BaseModel): base_url: Optional[str] = None base_url_preset: Optional[str] = None user_agent: Optional[str] = None + use_proxy: Optional[bool] = None class LlmProviderAuthStartRequest(BaseModel): @@ -77,6 +78,7 @@ async def get_llm_models( base_url: Optional[str] = None, base_url_preset: Optional[str] = None, user_agent: Optional[str] = None, + use_proxy: Optional[bool] = None, force_refresh: Optional[bool] = False, _: User = Depends(get_current_active_user_async), ): @@ -91,6 +93,7 @@ async def get_llm_models( base_url=base_url, base_url_preset=base_url_preset, user_agent=user_agent, + use_proxy=use_proxy, force_refresh=bool(force_refresh), ) return schemas.Response( @@ -250,6 +253,7 @@ async def llm_test( base_url=settings.LLM_BASE_URL, base_url_preset=settings.LLM_BASE_URL_PRESET, user_agent=settings.LLM_USER_AGENT, + use_proxy=settings.LLM_USE_PROXY, ) if not payload.provider: @@ -282,6 +286,7 @@ async def llm_test( base_url=payload.base_url, base_url_preset=payload.base_url_preset, user_agent=payload.user_agent, + use_proxy=payload.use_proxy, ) if not result.get("reply_preview"): return schemas.Response( diff --git a/app/core/config.py b/app/core/config.py index 50dfac70..8f034082 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -562,6 +562,8 @@ class ConfigModel(BaseModel): LLM_API_KEY: Optional[str] = None # LLM基础URL(用于自定义API端点) LLM_BASE_URL: Optional[str] = "https://api.deepseek.com" + # LLM调用是否使用系统代理 + LLM_USE_PROXY: bool = True # LLM Base URL 预设标识,用于区分同一 Base URL 下的不同模型目录 LLM_BASE_URL_PRESET: Optional[str] = None # LLM最大上下文Token数量(K) diff --git a/app/schemas/event.py b/app/schemas/event.py index 3da6761b..2be75dbf 100644 --- a/app/schemas/event.py +++ b/app/schemas/event.py @@ -69,7 +69,7 @@ class AgentLLMProviderEventData(ChainEventData): Agent LLM 供应商选择事件数据。 事件发出方会带入当前系统配置作为默认值;插件可覆盖 provider、base_url、 - api_key、model、user_agent 等字段,并通过 selected_provider_id 标记本次选择,方便 + api_key、model、user_agent、use_proxy 等字段,并通过 selected_provider_id 标记本次选择,方便 后续用量事件精确回写到同一个配额条目。 """ @@ -79,6 +79,7 @@ class AgentLLMProviderEventData(ChainEventData): model: Optional[str] = Field(default=None, description="模型名称") base_url_preset: Optional[str] = Field(default=None, description="Base URL 预设ID") user_agent: Optional[str] = Field(default=None, description="OpenAI兼容接口User-Agent") + use_proxy: Optional[bool] = Field(default=None, description="是否使用系统代理") thinking_level: Optional[str] = Field(default=None, description="思考模式级别") selected_provider_id: Optional[str] = Field(default=None, description="插件侧供应商ID") selected_provider_name: Optional[str] = Field(default=None, description="插件侧供应商名称") diff --git a/tests/test_agent_tokens_events.py b/tests/test_agent_tokens_events.py index 3da7067f..40bd1be2 100644 --- a/tests/test_agent_tokens_events.py +++ b/tests/test_agent_tokens_events.py @@ -107,6 +107,7 @@ class AgentTokensEventsTest(unittest.IsolatedAsyncioTestCase): base_url="https://tokens.example.com/v1", base_url_preset=None, user_agent="AgentTokens-UA/1.0", + use_proxy=True, thinking_level=None, ) self.assertEqual("provider-1", agent._llm_provider_selection["selected_provider_id"]) diff --git a/tests/test_llm_helper_testcall.py b/tests/test_llm_helper_testcall.py index 2c4c8775..0720ef2d 100644 --- a/tests/test_llm_helper_testcall.py +++ b/tests/test_llm_helper_testcall.py @@ -135,6 +135,7 @@ _stub_module( LLM_THINKING_LEVEL=None, LLM_TEMPERATURE=0.1, LLM_MAX_CONTEXT_TOKENS=64, + LLM_USE_PROXY=True, PROXY_HOST=None, ), ) @@ -188,6 +189,7 @@ class LlmHelperTestCallTest(unittest.TestCase): base_url="https://api.deepseek.com", base_url_preset="deepseek-default", user_agent=None, + use_proxy=None, ) self.assertEqual(result["provider"], "deepseek") self.assertEqual(result["model"], "deepseek-chat") @@ -489,6 +491,7 @@ class LlmHelperTestCallTest(unittest.TestCase): "base_url": "https://updated.example.com/v1", "base_url_preset_id": "updated-preset", "user_agent": None, + "use_proxy": None, }, ) self.assertEqual(len(llm_calls), 1) @@ -500,6 +503,46 @@ class LlmHelperTestCallTest(unittest.TestCase): ) self.assertEqual(llm_calls[0].get("default_headers"), {"X-Test": "1"}) + def test_get_llm_applies_proxy_only_when_enabled(self): + """LLM 构造时应按独立开关决定是否传入系统代理。""" + calls = [] + + class _FakeChatOpenAI: + def __init__(self, **kwargs): + calls.append(kwargs) + self.model = kwargs["model"] + self.profile = None + + with patch.object(llm_module.settings, "PROXY_HOST", "http://proxy.example.com:7890"), patch.dict( + sys.modules, + {"langchain_openai": SimpleNamespace(ChatOpenAI=_FakeChatOpenAI)}, + ): + asyncio.run( + llm_module.LLMHelper.get_llm( + provider="openai", + model="gpt-5-mini", + api_key="sk-test", + base_url="https://api.example.com/v1", + use_proxy=True, + ) + ) + asyncio.run( + llm_module.LLMHelper.get_llm( + provider="openai", + model="gpt-5-mini", + api_key="sk-test", + base_url="https://api.example.com/v1", + use_proxy=False, + ) + ) + + self.assertEqual(calls[0].get("openai_proxy"), "http://proxy.example.com:7890") + self.assertNotIn("http_client", calls[0]) + self.assertNotIn("http_async_client", calls[0]) + self.assertIsNone(calls[1].get("openai_proxy")) + self.assertIn("http_client", calls[1]) + self.assertIn("http_async_client", calls[1]) + def test_get_llm_passes_user_agent_as_openai_default_header(self): calls = [] diff --git a/tests/test_llm_provider_registry.py b/tests/test_llm_provider_registry.py index 340a3ca6..71960ca3 100644 --- a/tests/test_llm_provider_registry.py +++ b/tests/test_llm_provider_registry.py @@ -182,11 +182,15 @@ class LlmProviderRegistryTest(unittest.TestCase): with patch.object( manager, "get_models_dev_data", - AsyncMock(side_effect=lambda force_refresh=False: manager.__dict__.update({"_models_dev_data": payload}) or payload), + AsyncMock( + side_effect=lambda force_refresh=False, use_proxy=None: manager.__dict__.update( + {"_models_dev_data": payload} + ) or payload + ), ) as fetch_mock: providers = asyncio.run(manager.list_providers_async()) - fetch_mock.assert_awaited_once_with(force_refresh=False) + fetch_mock.assert_awaited_once_with(force_refresh=False, use_proxy=None) self.assertIn("frogbot", {item["id"] for item in providers}) def test_list_models_uses_dynamic_provider_after_refresh(self): @@ -207,7 +211,7 @@ class LlmProviderRegistryTest(unittest.TestCase): } } - async def _load_models_dev(force_refresh: bool = False): + async def _load_models_dev(force_refresh: bool = False, use_proxy=None): manager._models_dev_data = payload return payload diff --git a/tests/test_system_llm_test.py b/tests/test_system_llm_test.py index 14ed960f..cf4751bc 100644 --- a/tests/test_system_llm_test.py +++ b/tests/test_system_llm_test.py @@ -147,6 +147,8 @@ class LlmTestEndpointTest(unittest.TestCase): system_endpoint.settings, "LLM_BASE_URL_PRESET", "deepseek-default" ), patch.object( system_endpoint.settings, "LLM_USER_AGENT", "MoviePilot-Test/1.0" + ), patch.object( + system_endpoint.settings, "LLM_USE_PROXY", True ), patch.object( system_endpoint.LLMHelper, "test_current_settings", @@ -163,6 +165,7 @@ class LlmTestEndpointTest(unittest.TestCase): base_url="https://api.deepseek.com", base_url_preset="deepseek-default", user_agent="MoviePilot-Test/1.0", + use_proxy=True, ) self.assertTrue(resp.success) self.assertEqual(resp.data["provider"], "deepseek") @@ -188,6 +191,7 @@ class LlmTestEndpointTest(unittest.TestCase): base_url="https://example.com/v1", base_url_preset="openai-default", user_agent="MoviePilot-Custom/1.0", + use_proxy=False, ) with patch.object(system_endpoint.settings, "AI_AGENT_ENABLE", False), patch.object( @@ -212,6 +216,7 @@ class LlmTestEndpointTest(unittest.TestCase): base_url="https://example.com/v1", base_url_preset="openai-default", user_agent="MoviePilot-Custom/1.0", + use_proxy=False, ) self.assertTrue(resp.success) self.assertEqual(resp.data["provider"], "openai") @@ -234,6 +239,7 @@ class LlmTestEndpointTest(unittest.TestCase): base_url="https://api.deepseek.com", base_url_preset="deepseek-default", user_agent=None, + use_proxy=None, ) with patch.object(system_endpoint.settings, "AI_AGENT_ENABLE", False), patch.object( @@ -252,6 +258,7 @@ class LlmTestEndpointTest(unittest.TestCase): base_url="https://api.deepseek.com", base_url_preset="deepseek-default", user_agent=None, + use_proxy=None, ) self.assertTrue(resp.success)