Files
MoviePilot/tests/test_llm_request_timeout.py

134 lines
4.5 KiB
Python

"""
验证模型请求保留有限超时,覆盖真实 SDK 的同步和异步无响应连接。
"""
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from langchain_core.messages import HumanMessage
from openai import APITimeoutError
from app.agent.llm import helper as llm_module
from app.agent.llm.helper import LLMHelper
@pytest.fixture
def anyio_backend():
"""
本地 HTTP 服务和 SDK 客户端共享 asyncio 事件循环。
"""
return "asyncio"
async def _make_model(runtime, *, base_url="https://offline.example/v1"):
"""
注入离线供应商信息,使用锁定版本的真实模型 SDK。
"""
provider, model = {
"openai_compatible": ("alibaba", "qwen-test"),
"deepseek": ("deepseek", "deepseek-chat"),
"anthropic_compatible": ("anthropic", "claude-test"),
"google": ("google", "gemini-2.5-flash"),
}[runtime]
return await LLMHelper.get_llm(
provider=provider,
model=model,
api_key="offline-key",
use_proxy=False,
thinking_level="off",
api_protocol="chat_completions",
web_search_mode="disabled",
provider_runtime=SimpleNamespace(resolve_runtime=AsyncMock(return_value={
"provider_id": provider, "runtime": runtime,
"model_id": model, "api_key": "offline-key", "base_url": base_url,
})),
)
async def _close_model(model, runtime):
"""
在创建模型的循环内回收各 SDK 拥有的同步和异步连接。
"""
if runtime == "anthropic_compatible":
model._client.close()
await model._async_client.close()
elif runtime == "google":
await model.client.aio.aclose()
model.client.close()
else:
assert await LLMHelper.close_llm(model)
@pytest.mark.anyio
@pytest.mark.parametrize("proxy", [None, "http://127.0.0.1:7890"])
@pytest.mark.parametrize("runtime", [
"openai_compatible", "deepseek", "anthropic_compatible", "google",
])
async def test_real_models_have_finite_request_timeouts(monkeypatch, runtime, proxy):
"""
供应商请求层必须显式设置超时,不能以 None 覆盖 HTTP 客户端默认值。
"""
monkeypatch.setattr(llm_module, "_resolve_llm_proxy", lambda _use_proxy: proxy)
model = await _make_model(runtime)
try:
if runtime == "google":
assert model.timeout == 180
elif runtime == "anthropic_compatible":
assert model.default_request_timeout == 180
assert model._client.timeout == 180
assert model._async_client.timeout == 180
else:
assert model.request_timeout == 180
assert model.root_client.timeout == 180
assert model.root_async_client.timeout == 180
finally:
await _close_model(model, runtime)
@pytest.mark.anyio
@pytest.mark.parametrize("runtime", ["openai_compatible", "deepseek"])
@pytest.mark.parametrize("synchronous", [True, False])
async def test_silent_upstream_times_out_and_releases_connection(monkeypatch, runtime, synchronous):
"""
上游接受连接后不回包时,真实同步 generate 和异步调用均超时并断开连接。
"""
monkeypatch.setattr(llm_module, "_LLM_REQUEST_TIMEOUT", 0.05)
connections = set()
closed = asyncio.Queue()
async def silent_response(reader, writer):
"""
读完请求后保持静默,直到客户端超时关闭连接。
"""
connections.add(writer)
try:
await reader.read()
finally:
connections.discard(writer)
writer.close()
await writer.wait_closed()
closed.put_nowait(True)
server = await asyncio.start_server(silent_response, "127.0.0.1", 0)
base_url = f"http://127.0.0.1:{server.sockets[0].getsockname()[1]}/v1"
model = await _make_model(runtime, base_url=base_url)
# 本用例只验证一次真实读超时,避免 SDK 退避时间影响回归耗时。
model.root_client.max_retries = 0
model.root_async_client.max_retries = 0
try:
call = (
asyncio.to_thread(model.generate, [[HumanMessage(content="title")]])
if synchronous else model.ainvoke("title")
)
with pytest.raises(APITimeoutError):
await asyncio.wait_for(call, timeout=2)
assert await asyncio.wait_for(closed.get(), timeout=2)
finally:
for writer in tuple(connections):
writer.close()
server.close()
await server.wait_closed()
await _close_model(model, runtime)