mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 03:56:43 +08:00
feat: support llm user agent
This commit is contained in:
@@ -469,6 +469,7 @@ class MoviePilotAgent:
|
|||||||
api_key=settings.LLM_API_KEY,
|
api_key=settings.LLM_API_KEY,
|
||||||
base_url=settings.LLM_BASE_URL,
|
base_url=settings.LLM_BASE_URL,
|
||||||
base_url_preset=settings.LLM_BASE_URL_PRESET,
|
base_url_preset=settings.LLM_BASE_URL_PRESET,
|
||||||
|
user_agent=getattr(settings, "LLM_USER_AGENT", None),
|
||||||
thinking_level=None,
|
thinking_level=None,
|
||||||
)
|
)
|
||||||
selected_event = await eventmanager.async_send_event(
|
selected_event = await eventmanager.async_send_event(
|
||||||
@@ -497,6 +498,10 @@ class MoviePilotAgent:
|
|||||||
self._clean_optional_text(self._get_event_value(resolved_data, "base_url_preset"))
|
self._clean_optional_text(self._get_event_value(resolved_data, "base_url_preset"))
|
||||||
or settings.LLM_BASE_URL_PRESET
|
or settings.LLM_BASE_URL_PRESET
|
||||||
)
|
)
|
||||||
|
user_agent = (
|
||||||
|
self._clean_optional_text(self._get_event_value(resolved_data, "user_agent"))
|
||||||
|
or getattr(settings, "LLM_USER_AGENT", None)
|
||||||
|
)
|
||||||
thinking_level = self._clean_optional_text(
|
thinking_level = self._clean_optional_text(
|
||||||
self._get_event_value(resolved_data, "thinking_level")
|
self._get_event_value(resolved_data, "thinking_level")
|
||||||
)
|
)
|
||||||
@@ -522,6 +527,7 @@ class MoviePilotAgent:
|
|||||||
"api_key": api_key,
|
"api_key": api_key,
|
||||||
"base_url": base_url,
|
"base_url": base_url,
|
||||||
"base_url_preset": base_url_preset,
|
"base_url_preset": base_url_preset,
|
||||||
|
"user_agent": user_agent,
|
||||||
"thinking_level": thinking_level,
|
"thinking_level": thinking_level,
|
||||||
}
|
}
|
||||||
return self._llm_runtime_config
|
return self._llm_runtime_config
|
||||||
|
|||||||
+56
-5
@@ -602,6 +602,7 @@ class LLMHelper:
|
|||||||
model_name: str | None,
|
model_name: str | None,
|
||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
base_url: str | None = None,
|
base_url: str | None = None,
|
||||||
|
user_agent: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
在 provider 目录不可用时回退到旧的直接构造逻辑。
|
在 provider 目录不可用时回退到旧的直接构造逻辑。
|
||||||
@@ -625,12 +626,36 @@ class LLMHelper:
|
|||||||
"model_id": model_name,
|
"model_id": model_name,
|
||||||
"api_key": api_key_value,
|
"api_key": api_key_value,
|
||||||
"base_url": base_url_value,
|
"base_url": base_url_value,
|
||||||
"default_headers": None,
|
"default_headers": LLMHelper._build_openai_default_headers(
|
||||||
|
None,
|
||||||
|
user_agent=user_agent,
|
||||||
|
),
|
||||||
"use_responses_api": None,
|
"use_responses_api": None,
|
||||||
"model_record": None,
|
"model_record": None,
|
||||||
"model_metadata": None,
|
"model_metadata": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_openai_default_headers(
|
||||||
|
default_headers: dict[str, str] | None = None,
|
||||||
|
user_agent: str | None = None,
|
||||||
|
) -> dict[str, str] | None:
|
||||||
|
"""
|
||||||
|
合并 OpenAI 兼容接口默认请求头。
|
||||||
|
|
||||||
|
:param default_headers: provider 运行时已解析的默认请求头
|
||||||
|
:param user_agent: 用户配置的 User-Agent,非空时写入标准请求头
|
||||||
|
:return: 可传给 OpenAI SDK 的请求头字典
|
||||||
|
"""
|
||||||
|
headers = dict(default_headers or {})
|
||||||
|
normalized_user_agent = str(user_agent or "").strip()
|
||||||
|
if normalized_user_agent:
|
||||||
|
for key in list(headers.keys()):
|
||||||
|
if key.lower() == "user-agent":
|
||||||
|
headers.pop(key)
|
||||||
|
headers["User-Agent"] = normalized_user_agent
|
||||||
|
return headers or None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _resolve_thinking_level(
|
def _resolve_thinking_level(
|
||||||
cls,
|
cls,
|
||||||
@@ -675,6 +700,7 @@ class LLMHelper:
|
|||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
base_url: str | None = None,
|
base_url: str | None = None,
|
||||||
base_url_preset: str | None = None,
|
base_url_preset: str | None = None,
|
||||||
|
user_agent: str | None = None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
获取LLM实例
|
获取LLM实例
|
||||||
@@ -688,6 +714,7 @@ class LLMHelper:
|
|||||||
:param api_key: API Key。未显式传入时使用当前配置项 LLM_API_KEY。对于某些提供商(如 DeepSeek),可能需要同时提供 base_url。
|
:param api_key: API Key。未显式传入时使用当前配置项 LLM_API_KEY。对于某些提供商(如 DeepSeek),可能需要同时提供 base_url。
|
||||||
:param base_url: API Base URL。未显式传入时使用当前配置项 LLM_BASE_URL。
|
:param base_url: API Base URL。未显式传入时使用当前配置项 LLM_BASE_URL。
|
||||||
:param base_url_preset: Base URL 预设。未显式传入时使用当前配置项 LLM_BASE_URL_PRESET。
|
:param base_url_preset: Base URL 预设。未显式传入时使用当前配置项 LLM_BASE_URL_PRESET。
|
||||||
|
:param user_agent: OpenAI兼容接口请求 User-Agent。未显式传入时使用配置项 LLM_USER_AGENT。
|
||||||
:return: LLM实例
|
:return: LLM实例
|
||||||
"""
|
"""
|
||||||
provider_name = str(provider if provider is not None else settings.LLM_PROVIDER).lower()
|
provider_name = str(provider if provider is not None else settings.LLM_PROVIDER).lower()
|
||||||
@@ -697,6 +724,9 @@ class LLMHelper:
|
|||||||
base_url_preset_value = (
|
base_url_preset_value = (
|
||||||
base_url_preset if base_url_preset is not None else settings.LLM_BASE_URL_PRESET
|
base_url_preset if base_url_preset is not None else settings.LLM_BASE_URL_PRESET
|
||||||
)
|
)
|
||||||
|
user_agent_value = (
|
||||||
|
user_agent if user_agent is not None else getattr(settings, "LLM_USER_AGENT", None)
|
||||||
|
)
|
||||||
normalized_thinking_level = cls._resolve_thinking_level(
|
normalized_thinking_level = cls._resolve_thinking_level(
|
||||||
thinking_level=thinking_level,
|
thinking_level=thinking_level,
|
||||||
)
|
)
|
||||||
@@ -711,6 +741,7 @@ class LLMHelper:
|
|||||||
api_key=api_key_value,
|
api_key=api_key_value,
|
||||||
base_url=base_url_value,
|
base_url=base_url_value,
|
||||||
base_url_preset_id=base_url_preset_value,
|
base_url_preset_id=base_url_preset_value,
|
||||||
|
user_agent=user_agent_value,
|
||||||
)
|
)
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
logger.debug(f"LLM provider 目录不可用,回退到旧运行时逻辑: {err}")
|
logger.debug(f"LLM provider 目录不可用,回退到旧运行时逻辑: {err}")
|
||||||
@@ -719,8 +750,13 @@ class LLMHelper:
|
|||||||
model_name=model_name,
|
model_name=model_name,
|
||||||
api_key=api_key_value,
|
api_key=api_key_value,
|
||||||
base_url=base_url_value,
|
base_url=base_url_value,
|
||||||
|
user_agent=user_agent_value,
|
||||||
)
|
)
|
||||||
model_name = runtime.get("model_id") or model_name
|
model_name = runtime.get("model_id") or model_name
|
||||||
|
default_headers = cls._build_openai_default_headers(
|
||||||
|
runtime.get("default_headers"),
|
||||||
|
user_agent=user_agent_value,
|
||||||
|
)
|
||||||
thinking_kwargs = cls._build_thinking_kwargs(
|
thinking_kwargs = cls._build_thinking_kwargs(
|
||||||
provider=provider_name,
|
provider=provider_name,
|
||||||
model=model_name,
|
model=model_name,
|
||||||
@@ -776,7 +812,7 @@ class LLMHelper:
|
|||||||
streaming=streaming,
|
streaming=streaming,
|
||||||
stream_usage=True,
|
stream_usage=True,
|
||||||
anthropic_proxy=settings.PROXY_HOST,
|
anthropic_proxy=settings.PROXY_HOST,
|
||||||
default_headers=runtime.get("default_headers"),
|
default_headers=default_headers,
|
||||||
**thinking_kwargs,
|
**thinking_kwargs,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -797,7 +833,7 @@ class LLMHelper:
|
|||||||
streaming=streaming,
|
streaming=streaming,
|
||||||
stream_usage=True,
|
stream_usage=True,
|
||||||
openai_proxy=settings.PROXY_HOST,
|
openai_proxy=settings.PROXY_HOST,
|
||||||
default_headers=runtime.get("default_headers"),
|
default_headers=default_headers,
|
||||||
use_responses_api=runtime.get("use_responses_api"),
|
use_responses_api=runtime.get("use_responses_api"),
|
||||||
**thinking_kwargs,
|
**thinking_kwargs,
|
||||||
)
|
)
|
||||||
@@ -873,6 +909,7 @@ class LLMHelper:
|
|||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
base_url: str | None = None,
|
base_url: str | None = None,
|
||||||
base_url_preset: str | None = None,
|
base_url_preset: str | None = None,
|
||||||
|
user_agent: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
使用当前已保存配置执行一次最小 LLM 调用。
|
使用当前已保存配置执行一次最小 LLM 调用。
|
||||||
@@ -888,6 +925,7 @@ class LLMHelper:
|
|||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
base_url=base_url,
|
base_url=base_url,
|
||||||
base_url_preset=base_url_preset,
|
base_url_preset=base_url_preset,
|
||||||
|
user_agent=user_agent,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
response = await asyncio.wait_for(llm.ainvoke(prompt), timeout=timeout)
|
response = await asyncio.wait_for(llm.ainvoke(prompt), timeout=timeout)
|
||||||
@@ -918,6 +956,7 @@ class LLMHelper:
|
|||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
base_url: str | None = None,
|
base_url: str | None = None,
|
||||||
base_url_preset: str | None = None,
|
base_url_preset: str | None = None,
|
||||||
|
user_agent: str | None = None,
|
||||||
force_refresh: bool = False,
|
force_refresh: bool = False,
|
||||||
) -> List[dict[str, Any]]:
|
) -> List[dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
@@ -935,6 +974,7 @@ class LLMHelper:
|
|||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
base_url=base_url,
|
base_url=base_url,
|
||||||
base_url_preset_id=base_url_preset,
|
base_url_preset_id=base_url_preset,
|
||||||
|
user_agent=user_agent,
|
||||||
force_refresh=force_refresh,
|
force_refresh=force_refresh,
|
||||||
)
|
)
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
@@ -963,6 +1003,7 @@ class LLMHelper:
|
|||||||
provider,
|
provider,
|
||||||
api_key or "",
|
api_key or "",
|
||||||
model_list_base_url,
|
model_list_base_url,
|
||||||
|
user_agent=user_agent,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -997,7 +1038,10 @@ class LLMHelper:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _get_openai_compatible_models(
|
async def _get_openai_compatible_models(
|
||||||
provider: str, api_key: str, base_url: str = None
|
provider: str,
|
||||||
|
api_key: str,
|
||||||
|
base_url: str = None,
|
||||||
|
user_agent: str | None = None,
|
||||||
) -> List[str]:
|
) -> List[str]:
|
||||||
"""获取OpenAI兼容模型列表"""
|
"""获取OpenAI兼容模型列表"""
|
||||||
try:
|
try:
|
||||||
@@ -1006,7 +1050,14 @@ class LLMHelper:
|
|||||||
if provider == "deepseek":
|
if provider == "deepseek":
|
||||||
base_url = base_url or "https://api.deepseek.com"
|
base_url = base_url or "https://api.deepseek.com"
|
||||||
|
|
||||||
client = AsyncOpenAI(api_key=api_key, base_url=base_url)
|
client = AsyncOpenAI(
|
||||||
|
api_key=api_key,
|
||||||
|
base_url=base_url,
|
||||||
|
default_headers=LLMHelper._build_openai_default_headers(
|
||||||
|
None,
|
||||||
|
user_agent=user_agent,
|
||||||
|
),
|
||||||
|
)
|
||||||
models = await client.models.list()
|
models = await client.models.list()
|
||||||
await client.close()
|
await client.close()
|
||||||
return [model.id for model in models.data]
|
return [model.id for model in models.data]
|
||||||
|
|||||||
@@ -1166,6 +1166,23 @@ class LLMProviderManager(metaclass=Singleton):
|
|||||||
return None
|
return None
|
||||||
return value.rstrip("/")
|
return value.rstrip("/")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _merge_user_agent_header(
|
||||||
|
default_headers: Optional[dict[str, str]],
|
||||||
|
user_agent: Optional[str],
|
||||||
|
) -> Optional[dict[str, str]]:
|
||||||
|
"""
|
||||||
|
合并用户配置的 OpenAI 兼容接口 User-Agent 请求头。
|
||||||
|
"""
|
||||||
|
headers = dict(default_headers or {})
|
||||||
|
normalized_user_agent = str(user_agent or "").strip()
|
||||||
|
if normalized_user_agent:
|
||||||
|
for key in list(headers.keys()):
|
||||||
|
if key.lower() == "user-agent":
|
||||||
|
headers.pop(key)
|
||||||
|
headers["User-Agent"] = normalized_user_agent
|
||||||
|
return headers or None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _default_base_url_for_provider(cls, spec: ProviderSpec) -> Optional[str]:
|
def _default_base_url_for_provider(cls, spec: ProviderSpec) -> Optional[str]:
|
||||||
"""获取 provider 的默认 Base URL。"""
|
"""获取 provider 的默认 Base URL。"""
|
||||||
@@ -1825,6 +1842,7 @@ class LLMProviderManager(metaclass=Singleton):
|
|||||||
api_key: Optional[str] = None,
|
api_key: Optional[str] = None,
|
||||||
base_url: Optional[str] = None,
|
base_url: Optional[str] = None,
|
||||||
base_url_preset_id: Optional[str] = None,
|
base_url_preset_id: Optional[str] = None,
|
||||||
|
user_agent: Optional[str] = None,
|
||||||
force_refresh: bool = False,
|
force_refresh: bool = False,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""返回标准化后的模型目录。"""
|
"""返回标准化后的模型目录。"""
|
||||||
@@ -1854,6 +1872,7 @@ class LLMProviderManager(metaclass=Singleton):
|
|||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
base_url=base_url,
|
base_url=base_url,
|
||||||
base_url_preset_id=base_url_preset_id,
|
base_url_preset_id=base_url_preset_id,
|
||||||
|
user_agent=user_agent,
|
||||||
)
|
)
|
||||||
|
|
||||||
if resolved_model_list_strategy == "google":
|
if resolved_model_list_strategy == "google":
|
||||||
@@ -1877,7 +1896,10 @@ class LLMProviderManager(metaclass=Singleton):
|
|||||||
runtime["base_url"],
|
runtime["base_url"],
|
||||||
base_url_preset_id=base_url_preset_id,
|
base_url_preset_id=base_url_preset_id,
|
||||||
),
|
),
|
||||||
default_headers=runtime.get("default_headers"),
|
default_headers=self._merge_user_agent_header(
|
||||||
|
runtime.get("default_headers"),
|
||||||
|
user_agent,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
if resolved_model_list_strategy == "anthropic_compatible":
|
if resolved_model_list_strategy == "anthropic_compatible":
|
||||||
@@ -1905,7 +1927,10 @@ class LLMProviderManager(metaclass=Singleton):
|
|||||||
runtime["base_url"],
|
runtime["base_url"],
|
||||||
base_url_preset_id=base_url_preset_id,
|
base_url_preset_id=base_url_preset_id,
|
||||||
),
|
),
|
||||||
default_headers=runtime.get("default_headers"),
|
default_headers=self._merge_user_agent_header(
|
||||||
|
runtime.get("default_headers"),
|
||||||
|
user_agent,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def resolve_model_metadata(
|
async def resolve_model_metadata(
|
||||||
@@ -2398,6 +2423,7 @@ class LLMProviderManager(metaclass=Singleton):
|
|||||||
api_key: Optional[str] = None,
|
api_key: Optional[str] = None,
|
||||||
base_url: Optional[str] = None,
|
base_url: Optional[str] = None,
|
||||||
base_url_preset_id: Optional[str] = None,
|
base_url_preset_id: Optional[str] = None,
|
||||||
|
user_agent: Optional[str] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
解析 provider 运行时参数。
|
解析 provider 运行时参数。
|
||||||
@@ -2428,6 +2454,7 @@ class LLMProviderManager(metaclass=Singleton):
|
|||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
base_url=base_url,
|
base_url=base_url,
|
||||||
base_url_preset_id=normalized_base_url_preset_id,
|
base_url_preset_id=normalized_base_url_preset_id,
|
||||||
|
user_agent=user_agent,
|
||||||
)
|
)
|
||||||
if item["id"] == model
|
if item["id"] == model
|
||||||
),
|
),
|
||||||
@@ -2470,7 +2497,10 @@ class LLMProviderManager(metaclass=Singleton):
|
|||||||
"runtime": "chatgpt",
|
"runtime": "chatgpt",
|
||||||
"api_key": auth["access_token"],
|
"api_key": auth["access_token"],
|
||||||
"base_url": self._CHATGPT_CODEX_BASE_URL,
|
"base_url": self._CHATGPT_CODEX_BASE_URL,
|
||||||
"default_headers": headers,
|
"default_headers": self._merge_user_agent_header(
|
||||||
|
headers,
|
||||||
|
user_agent,
|
||||||
|
),
|
||||||
"use_responses_api": True,
|
"use_responses_api": True,
|
||||||
"auth_mode": "oauth",
|
"auth_mode": "oauth",
|
||||||
}
|
}
|
||||||
@@ -2484,6 +2514,10 @@ class LLMProviderManager(metaclass=Singleton):
|
|||||||
"api_key": normalized_api_key,
|
"api_key": normalized_api_key,
|
||||||
"base_url": normalized_base_url
|
"base_url": normalized_base_url
|
||||||
or self._default_base_url_for_provider(spec),
|
or self._default_base_url_for_provider(spec),
|
||||||
|
"default_headers": self._merge_user_agent_header(
|
||||||
|
None,
|
||||||
|
user_agent,
|
||||||
|
),
|
||||||
"auth_mode": "api_key",
|
"auth_mode": "api_key",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -2508,9 +2542,12 @@ class LLMProviderManager(metaclass=Singleton):
|
|||||||
else "github_copilot",
|
else "github_copilot",
|
||||||
"api_key": token,
|
"api_key": token,
|
||||||
"base_url": "https://api.githubcopilot.com",
|
"base_url": "https://api.githubcopilot.com",
|
||||||
"default_headers": self._copilot_headers(
|
"default_headers": self._merge_user_agent_header(
|
||||||
token,
|
self._copilot_headers(
|
||||||
include_auth=transport == "anthropic",
|
token,
|
||||||
|
include_auth=transport == "anthropic",
|
||||||
|
),
|
||||||
|
user_agent,
|
||||||
),
|
),
|
||||||
"auth_mode": "oauth" if auth else "api_key",
|
"auth_mode": "oauth" if auth else "api_key",
|
||||||
}
|
}
|
||||||
@@ -2543,6 +2580,10 @@ class LLMProviderManager(metaclass=Singleton):
|
|||||||
"base_url": self._normalize_base_url_for_anthropic(
|
"base_url": self._normalize_base_url_for_anthropic(
|
||||||
effective_base_url
|
effective_base_url
|
||||||
),
|
),
|
||||||
|
"default_headers": self._merge_user_agent_header(
|
||||||
|
None,
|
||||||
|
user_agent,
|
||||||
|
),
|
||||||
"auth_mode": "api_key",
|
"auth_mode": "api_key",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -2557,6 +2598,7 @@ class LLMProviderManager(metaclass=Singleton):
|
|||||||
{
|
{
|
||||||
"api_key": normalized_api_key,
|
"api_key": normalized_api_key,
|
||||||
"base_url": effective_base_url,
|
"base_url": effective_base_url,
|
||||||
|
"default_headers": self._merge_user_agent_header(None, user_agent),
|
||||||
"auth_mode": "api_key",
|
"auth_mode": "api_key",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
class LlmTestRequest(BaseModel):
|
class LlmTestRequest(BaseModel):
|
||||||
|
"""
|
||||||
|
LLM 测试调用请求参数。
|
||||||
|
"""
|
||||||
|
|
||||||
enabled: Optional[bool] = None
|
enabled: Optional[bool] = None
|
||||||
provider: Optional[str] = None
|
provider: Optional[str] = None
|
||||||
model: Optional[str] = None
|
model: Optional[str] = None
|
||||||
@@ -31,9 +35,14 @@ class LlmTestRequest(BaseModel):
|
|||||||
api_key: Optional[str] = None
|
api_key: Optional[str] = None
|
||||||
base_url: Optional[str] = None
|
base_url: Optional[str] = None
|
||||||
base_url_preset: Optional[str] = None
|
base_url_preset: Optional[str] = None
|
||||||
|
user_agent: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class LlmProviderAuthStartRequest(BaseModel):
|
class LlmProviderAuthStartRequest(BaseModel):
|
||||||
|
"""
|
||||||
|
LLM 提供商授权启动请求参数。
|
||||||
|
"""
|
||||||
|
|
||||||
provider: str
|
provider: str
|
||||||
method: str
|
method: str
|
||||||
|
|
||||||
@@ -67,6 +76,7 @@ async def get_llm_models(
|
|||||||
api_key: Optional[str] = None,
|
api_key: Optional[str] = None,
|
||||||
base_url: Optional[str] = None,
|
base_url: Optional[str] = None,
|
||||||
base_url_preset: Optional[str] = None,
|
base_url_preset: Optional[str] = None,
|
||||||
|
user_agent: Optional[str] = None,
|
||||||
force_refresh: Optional[bool] = False,
|
force_refresh: Optional[bool] = False,
|
||||||
_: User = Depends(get_current_active_user_async),
|
_: User = Depends(get_current_active_user_async),
|
||||||
):
|
):
|
||||||
@@ -80,6 +90,7 @@ async def get_llm_models(
|
|||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
base_url=base_url,
|
base_url=base_url,
|
||||||
base_url_preset=base_url_preset,
|
base_url_preset=base_url_preset,
|
||||||
|
user_agent=user_agent,
|
||||||
force_refresh=bool(force_refresh),
|
force_refresh=bool(force_refresh),
|
||||||
)
|
)
|
||||||
return schemas.Response(
|
return schemas.Response(
|
||||||
@@ -238,6 +249,7 @@ async def llm_test(
|
|||||||
api_key=settings.LLM_API_KEY,
|
api_key=settings.LLM_API_KEY,
|
||||||
base_url=settings.LLM_BASE_URL,
|
base_url=settings.LLM_BASE_URL,
|
||||||
base_url_preset=settings.LLM_BASE_URL_PRESET,
|
base_url_preset=settings.LLM_BASE_URL_PRESET,
|
||||||
|
user_agent=getattr(settings, "LLM_USER_AGENT", None),
|
||||||
)
|
)
|
||||||
|
|
||||||
if not payload.provider:
|
if not payload.provider:
|
||||||
@@ -269,6 +281,7 @@ async def llm_test(
|
|||||||
api_key=payload.api_key,
|
api_key=payload.api_key,
|
||||||
base_url=payload.base_url,
|
base_url=payload.base_url,
|
||||||
base_url_preset=payload.base_url_preset,
|
base_url_preset=payload.base_url_preset,
|
||||||
|
user_agent=payload.user_agent,
|
||||||
)
|
)
|
||||||
if not result.get("reply_preview"):
|
if not result.get("reply_preview"):
|
||||||
return schemas.Response(
|
return schemas.Response(
|
||||||
|
|||||||
@@ -564,6 +564,8 @@ class ConfigModel(BaseModel):
|
|||||||
LLM_BASE_URL: Optional[str] = "https://api.deepseek.com"
|
LLM_BASE_URL: Optional[str] = "https://api.deepseek.com"
|
||||||
# LLM Base URL 预设标识,用于区分同一 Base URL 下的不同模型目录
|
# LLM Base URL 预设标识,用于区分同一 Base URL 下的不同模型目录
|
||||||
LLM_BASE_URL_PRESET: Optional[str] = None
|
LLM_BASE_URL_PRESET: Optional[str] = None
|
||||||
|
# LLM OpenAI兼容接口请求User-Agent
|
||||||
|
LLM_USER_AGENT: Optional[str] = None
|
||||||
# LLM最大上下文Token数量(K)
|
# LLM最大上下文Token数量(K)
|
||||||
LLM_MAX_CONTEXT_TOKENS: int = 64
|
LLM_MAX_CONTEXT_TOKENS: int = 64
|
||||||
# LLM温度参数
|
# LLM温度参数
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ class AgentLLMProviderEventData(ChainEventData):
|
|||||||
Agent LLM 供应商选择事件数据。
|
Agent LLM 供应商选择事件数据。
|
||||||
|
|
||||||
事件发出方会带入当前系统配置作为默认值;插件可覆盖 provider、base_url、
|
事件发出方会带入当前系统配置作为默认值;插件可覆盖 provider、base_url、
|
||||||
api_key、model 等字段,并通过 selected_provider_id 标记本次选择,方便
|
api_key、model、user_agent 等字段,并通过 selected_provider_id 标记本次选择,方便
|
||||||
后续用量事件精确回写到同一个配额条目。
|
后续用量事件精确回写到同一个配额条目。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -78,6 +78,7 @@ class AgentLLMProviderEventData(ChainEventData):
|
|||||||
api_key: Optional[str] = Field(default=None, description="API Key")
|
api_key: Optional[str] = Field(default=None, description="API Key")
|
||||||
model: Optional[str] = Field(default=None, description="模型名称")
|
model: Optional[str] = Field(default=None, description="模型名称")
|
||||||
base_url_preset: Optional[str] = Field(default=None, description="Base URL 预设ID")
|
base_url_preset: Optional[str] = Field(default=None, description="Base URL 预设ID")
|
||||||
|
user_agent: Optional[str] = Field(default=None, description="OpenAI兼容接口User-Agent")
|
||||||
thinking_level: Optional[str] = Field(default=None, description="思考模式级别")
|
thinking_level: Optional[str] = Field(default=None, description="思考模式级别")
|
||||||
selected_provider_id: Optional[str] = Field(default=None, description="插件侧供应商ID")
|
selected_provider_id: Optional[str] = Field(default=None, description="插件侧供应商ID")
|
||||||
selected_provider_name: Optional[str] = Field(default=None, description="插件侧供应商名称")
|
selected_provider_name: Optional[str] = Field(default=None, description="插件侧供应商名称")
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ class AgentTokensEventsTest(unittest.IsolatedAsyncioTestCase):
|
|||||||
data.api_key = "sk-agent-token"
|
data.api_key = "sk-agent-token"
|
||||||
data.model = "free-model"
|
data.model = "free-model"
|
||||||
data.base_url_preset = None
|
data.base_url_preset = None
|
||||||
|
data.user_agent = "AgentTokens-UA/1.0"
|
||||||
data.selected_provider_id = "provider-1"
|
data.selected_provider_id = "provider-1"
|
||||||
data.selected_provider_name = "Free Provider"
|
data.selected_provider_name = "Free Provider"
|
||||||
data.source = "AgentTokens"
|
data.source = "AgentTokens"
|
||||||
@@ -105,6 +106,7 @@ class AgentTokensEventsTest(unittest.IsolatedAsyncioTestCase):
|
|||||||
api_key="sk-agent-token",
|
api_key="sk-agent-token",
|
||||||
base_url="https://tokens.example.com/v1",
|
base_url="https://tokens.example.com/v1",
|
||||||
base_url_preset=None,
|
base_url_preset=None,
|
||||||
|
user_agent="AgentTokens-UA/1.0",
|
||||||
thinking_level=None,
|
thinking_level=None,
|
||||||
)
|
)
|
||||||
self.assertEqual("provider-1", agent._llm_provider_selection["selected_provider_id"])
|
self.assertEqual("provider-1", agent._llm_provider_selection["selected_provider_id"])
|
||||||
|
|||||||
@@ -118,6 +118,10 @@ def _build_fake_openai_modules(chat_openai_cls=_FakeChatOpenAIForPatch):
|
|||||||
}, base_module
|
}, base_module
|
||||||
|
|
||||||
|
|
||||||
|
_ORIGINAL_STUBBED_MODULES = {
|
||||||
|
name: sys.modules.get(name)
|
||||||
|
for name in ("app.core.config", "app.log")
|
||||||
|
}
|
||||||
sys.modules.pop("app.agent.llm.helper", None)
|
sys.modules.pop("app.agent.llm.helper", None)
|
||||||
_stub_module(
|
_stub_module(
|
||||||
"app.core.config",
|
"app.core.config",
|
||||||
@@ -127,6 +131,7 @@ _stub_module(
|
|||||||
LLM_API_KEY="global-key",
|
LLM_API_KEY="global-key",
|
||||||
LLM_BASE_URL="https://global.example.com",
|
LLM_BASE_URL="https://global.example.com",
|
||||||
LLM_BASE_URL_PRESET=None,
|
LLM_BASE_URL_PRESET=None,
|
||||||
|
LLM_USER_AGENT=None,
|
||||||
LLM_THINKING_LEVEL=None,
|
LLM_THINKING_LEVEL=None,
|
||||||
LLM_TEMPERATURE=0.1,
|
LLM_TEMPERATURE=0.1,
|
||||||
LLM_MAX_CONTEXT_TOKENS=64,
|
LLM_MAX_CONTEXT_TOKENS=64,
|
||||||
@@ -140,6 +145,11 @@ spec = importlib.util.spec_from_file_location("test_llm_module", module_path)
|
|||||||
llm_module = importlib.util.module_from_spec(spec)
|
llm_module = importlib.util.module_from_spec(spec)
|
||||||
assert spec and spec.loader
|
assert spec and spec.loader
|
||||||
spec.loader.exec_module(llm_module)
|
spec.loader.exec_module(llm_module)
|
||||||
|
for _module_name, _module in _ORIGINAL_STUBBED_MODULES.items():
|
||||||
|
if _module is None:
|
||||||
|
sys.modules.pop(_module_name, None)
|
||||||
|
else:
|
||||||
|
sys.modules[_module_name] = _module
|
||||||
|
|
||||||
|
|
||||||
class LlmHelperTestCallTest(unittest.TestCase):
|
class LlmHelperTestCallTest(unittest.TestCase):
|
||||||
@@ -177,6 +187,7 @@ class LlmHelperTestCallTest(unittest.TestCase):
|
|||||||
api_key="sk-test",
|
api_key="sk-test",
|
||||||
base_url="https://api.deepseek.com",
|
base_url="https://api.deepseek.com",
|
||||||
base_url_preset="deepseek-default",
|
base_url_preset="deepseek-default",
|
||||||
|
user_agent=None,
|
||||||
)
|
)
|
||||||
self.assertEqual(result["provider"], "deepseek")
|
self.assertEqual(result["provider"], "deepseek")
|
||||||
self.assertEqual(result["model"], "deepseek-chat")
|
self.assertEqual(result["model"], "deepseek-chat")
|
||||||
@@ -436,7 +447,7 @@ class LlmHelperTestCallTest(unittest.TestCase):
|
|||||||
"model_id": kwargs["model"],
|
"model_id": kwargs["model"],
|
||||||
"api_key": kwargs["api_key"],
|
"api_key": kwargs["api_key"],
|
||||||
"base_url": kwargs["base_url"],
|
"base_url": kwargs["base_url"],
|
||||||
"default_headers": None,
|
"default_headers": {"X-Test": "1"},
|
||||||
"use_responses_api": None,
|
"use_responses_api": None,
|
||||||
"model_record": None,
|
"model_record": None,
|
||||||
"model_metadata": None,
|
"model_metadata": None,
|
||||||
@@ -477,6 +488,7 @@ class LlmHelperTestCallTest(unittest.TestCase):
|
|||||||
"api_key": "updated-key",
|
"api_key": "updated-key",
|
||||||
"base_url": "https://updated.example.com/v1",
|
"base_url": "https://updated.example.com/v1",
|
||||||
"base_url_preset_id": "updated-preset",
|
"base_url_preset_id": "updated-preset",
|
||||||
|
"user_agent": None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.assertEqual(len(llm_calls), 1)
|
self.assertEqual(len(llm_calls), 1)
|
||||||
@@ -486,6 +498,36 @@ class LlmHelperTestCallTest(unittest.TestCase):
|
|||||||
llm_calls[0].get("base_url"),
|
llm_calls[0].get("base_url"),
|
||||||
"https://updated.example.com/v1",
|
"https://updated.example.com/v1",
|
||||||
)
|
)
|
||||||
|
self.assertEqual(llm_calls[0].get("default_headers"), {"X-Test": "1"})
|
||||||
|
|
||||||
|
def test_get_llm_passes_user_agent_as_openai_default_header(self):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
class _FakeChatOpenAI:
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
calls.append(kwargs)
|
||||||
|
self.model = kwargs["model"]
|
||||||
|
self.profile = None
|
||||||
|
|
||||||
|
with 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",
|
||||||
|
user_agent="MoviePilot-Test/1.0",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(calls), 1)
|
||||||
|
self.assertEqual(
|
||||||
|
calls[0].get("default_headers"),
|
||||||
|
{"User-Agent": "MoviePilot-Test/1.0"},
|
||||||
|
)
|
||||||
|
|
||||||
def test_get_llm_keeps_openai_patch_global_without_model_marker(self):
|
def test_get_llm_keeps_openai_patch_global_without_model_marker(self):
|
||||||
class _FakeProviderManager:
|
class _FakeProviderManager:
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ from types import ModuleType
|
|||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
|
||||||
|
_ORIGINAL_STUBBED_MODULES = {}
|
||||||
|
|
||||||
|
|
||||||
def _stub_module(name: str, **attrs):
|
def _stub_module(name: str, **attrs):
|
||||||
|
if name not in _ORIGINAL_STUBBED_MODULES:
|
||||||
|
_ORIGINAL_STUBBED_MODULES[name] = sys.modules.get(name)
|
||||||
module = sys.modules.get(name)
|
module = sys.modules.get(name)
|
||||||
if module is None:
|
if module is None:
|
||||||
module = ModuleType(name)
|
module = ModuleType(name)
|
||||||
@@ -36,7 +41,15 @@ _stub_module("app.helper.sites", SitesHelper=_Dummy)
|
|||||||
_stub_module("app.chain.mediaserver", MediaServerChain=_Dummy)
|
_stub_module("app.chain.mediaserver", MediaServerChain=_Dummy)
|
||||||
_stub_module("app.chain.search", SearchChain=_Dummy)
|
_stub_module("app.chain.search", SearchChain=_Dummy)
|
||||||
_stub_module("app.chain.system", SystemChain=_Dummy)
|
_stub_module("app.chain.system", SystemChain=_Dummy)
|
||||||
_stub_module("app.core.event", eventmanager=_Dummy())
|
_stub_module(
|
||||||
|
"app.agent.llm",
|
||||||
|
LLMHelper=_Dummy,
|
||||||
|
LLMProviderManager=_Dummy,
|
||||||
|
LLMTestError=_DummyError,
|
||||||
|
LLMTestTimeout=_DummyError,
|
||||||
|
render_auth_result_html=lambda success, message: message,
|
||||||
|
)
|
||||||
|
_stub_module("app.core.event", eventmanager=_Dummy(), Event=_Dummy, EventManager=_Dummy)
|
||||||
_stub_module("app.core.metainfo", MetaInfo=_Dummy)
|
_stub_module("app.core.metainfo", MetaInfo=_Dummy)
|
||||||
_stub_module("app.core.module", ModuleManager=_Dummy)
|
_stub_module("app.core.module", ModuleManager=_Dummy)
|
||||||
_stub_module(
|
_stub_module(
|
||||||
@@ -79,6 +92,12 @@ _stub_module("version", APP_VERSION="test")
|
|||||||
|
|
||||||
from app.api.endpoints import llm as system_endpoint
|
from app.api.endpoints import llm as system_endpoint
|
||||||
|
|
||||||
|
for _module_name, _module in _ORIGINAL_STUBBED_MODULES.items():
|
||||||
|
if _module is None:
|
||||||
|
sys.modules.pop(_module_name, None)
|
||||||
|
else:
|
||||||
|
sys.modules[_module_name] = _module
|
||||||
|
|
||||||
|
|
||||||
class LlmTestEndpointTest(unittest.TestCase):
|
class LlmTestEndpointTest(unittest.TestCase):
|
||||||
def test_llm_test_requires_ai_agent_enabled(self):
|
def test_llm_test_requires_ai_agent_enabled(self):
|
||||||
@@ -126,6 +145,8 @@ class LlmTestEndpointTest(unittest.TestCase):
|
|||||||
system_endpoint.settings, "LLM_BASE_URL", "https://api.deepseek.com"
|
system_endpoint.settings, "LLM_BASE_URL", "https://api.deepseek.com"
|
||||||
), patch.object(
|
), patch.object(
|
||||||
system_endpoint.settings, "LLM_BASE_URL_PRESET", "deepseek-default"
|
system_endpoint.settings, "LLM_BASE_URL_PRESET", "deepseek-default"
|
||||||
|
), patch.object(
|
||||||
|
system_endpoint.settings, "LLM_USER_AGENT", "MoviePilot-Test/1.0", create=True
|
||||||
), patch.object(
|
), patch.object(
|
||||||
system_endpoint.LLMHelper,
|
system_endpoint.LLMHelper,
|
||||||
"test_current_settings",
|
"test_current_settings",
|
||||||
@@ -141,6 +162,7 @@ class LlmTestEndpointTest(unittest.TestCase):
|
|||||||
api_key="sk-test",
|
api_key="sk-test",
|
||||||
base_url="https://api.deepseek.com",
|
base_url="https://api.deepseek.com",
|
||||||
base_url_preset="deepseek-default",
|
base_url_preset="deepseek-default",
|
||||||
|
user_agent="MoviePilot-Test/1.0",
|
||||||
)
|
)
|
||||||
self.assertTrue(resp.success)
|
self.assertTrue(resp.success)
|
||||||
self.assertEqual(resp.data["provider"], "deepseek")
|
self.assertEqual(resp.data["provider"], "deepseek")
|
||||||
@@ -165,6 +187,7 @@ class LlmTestEndpointTest(unittest.TestCase):
|
|||||||
api_key="sk-live",
|
api_key="sk-live",
|
||||||
base_url="https://example.com/v1",
|
base_url="https://example.com/v1",
|
||||||
base_url_preset="openai-default",
|
base_url_preset="openai-default",
|
||||||
|
user_agent="MoviePilot-Custom/1.0",
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch.object(system_endpoint.settings, "AI_AGENT_ENABLE", False), patch.object(
|
with patch.object(system_endpoint.settings, "AI_AGENT_ENABLE", False), patch.object(
|
||||||
@@ -188,6 +211,7 @@ class LlmTestEndpointTest(unittest.TestCase):
|
|||||||
api_key="sk-live",
|
api_key="sk-live",
|
||||||
base_url="https://example.com/v1",
|
base_url="https://example.com/v1",
|
||||||
base_url_preset="openai-default",
|
base_url_preset="openai-default",
|
||||||
|
user_agent="MoviePilot-Custom/1.0",
|
||||||
)
|
)
|
||||||
self.assertTrue(resp.success)
|
self.assertTrue(resp.success)
|
||||||
self.assertEqual(resp.data["provider"], "openai")
|
self.assertEqual(resp.data["provider"], "openai")
|
||||||
@@ -209,6 +233,7 @@ class LlmTestEndpointTest(unittest.TestCase):
|
|||||||
api_key="sk-live",
|
api_key="sk-live",
|
||||||
base_url="https://api.deepseek.com",
|
base_url="https://api.deepseek.com",
|
||||||
base_url_preset="deepseek-default",
|
base_url_preset="deepseek-default",
|
||||||
|
user_agent=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch.object(system_endpoint.settings, "AI_AGENT_ENABLE", False), patch.object(
|
with patch.object(system_endpoint.settings, "AI_AGENT_ENABLE", False), patch.object(
|
||||||
@@ -226,6 +251,7 @@ class LlmTestEndpointTest(unittest.TestCase):
|
|||||||
api_key="sk-live",
|
api_key="sk-live",
|
||||||
base_url="https://api.deepseek.com",
|
base_url="https://api.deepseek.com",
|
||||||
base_url_preset="deepseek-default",
|
base_url_preset="deepseek-default",
|
||||||
|
user_agent=None,
|
||||||
)
|
)
|
||||||
self.assertTrue(resp.success)
|
self.assertTrue(resp.success)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user