mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-29 03:18:14 +08:00
289 lines
13 KiB
Python
289 lines
13 KiB
Python
"""
|
||
LLM 独占 HTTP 客户端必须随请求和 Agent 图确定性释放。
|
||
"""
|
||
|
||
import asyncio
|
||
import json
|
||
from types import SimpleNamespace
|
||
from unittest.mock import AsyncMock, Mock
|
||
|
||
import httpx
|
||
import pytest
|
||
|
||
from app.agent import orchestrator as agent_module
|
||
from app.agent.llm import helper as llm_module
|
||
from app.agent.llm.helper import LLMHelper, LLMTestError, LLMTestTimeout
|
||
from app.agent.orchestrator import MoviePilotAgent
|
||
from app.agent.tools.catalog import ToolCatalogSnapshot
|
||
from tests.agent_graph import publish_graph
|
||
|
||
|
||
@pytest.fixture
|
||
def anyio_backend():
|
||
"""
|
||
模型客户端只在创建它的 asyncio 事件循环内关闭。
|
||
"""
|
||
return "asyncio"
|
||
|
||
|
||
def _model(*, failure=None):
|
||
"""
|
||
提供独占同步、异步连接和可配置调用结果的模型。
|
||
"""
|
||
return SimpleNamespace(
|
||
http_client=SimpleNamespace(close=Mock(), is_closed=False),
|
||
http_async_client=SimpleNamespace(aclose=AsyncMock(), is_closed=False),
|
||
ainvoke=AsyncMock(return_value=SimpleNamespace(content="OK"), side_effect=failure),
|
||
)
|
||
|
||
|
||
@pytest.mark.anyio
|
||
@pytest.mark.parametrize("failure", [None, RuntimeError("failed"), TimeoutError(), asyncio.CancelledError()])
|
||
async def test_settings_test_closes_clients_on_every_exit(monkeypatch, failure):
|
||
"""
|
||
设置测试在成功、异常、超时和取消后都必须释放两个客户端。
|
||
"""
|
||
model = _model(failure=failure)
|
||
monkeypatch.setattr(LLMHelper, "get_llm", AsyncMock(return_value=model))
|
||
if failure is None:
|
||
assert (await LLMHelper.test_current_settings())["reply_preview"] == "OK"
|
||
else:
|
||
error_type = (asyncio.CancelledError if isinstance(failure, asyncio.CancelledError)
|
||
else LLMTestTimeout if isinstance(failure, TimeoutError) else LLMTestError)
|
||
with pytest.raises(error_type):
|
||
await LLMHelper.test_current_settings()
|
||
model.http_client.close.assert_called_once()
|
||
model.http_async_client.aclose.assert_awaited_once()
|
||
|
||
|
||
@pytest.mark.anyio
|
||
@pytest.mark.parametrize("failure", [None, RuntimeError("failed"), asyncio.CancelledError()])
|
||
async def test_chat_title_closes_temporary_model(monkeypatch, failure):
|
||
"""
|
||
标题模型不属于缓存图,调用结束时立即关闭。
|
||
"""
|
||
model = _model(failure=failure)
|
||
agent = MoviePilotAgent(session_id="title-clients", user_id="1")
|
||
monkeypatch.setattr(agent, "_initialize_llm", AsyncMock(return_value=model))
|
||
if failure is None:
|
||
await agent._generate_chat_title("测试标题")
|
||
else:
|
||
with pytest.raises(type(failure)):
|
||
await agent._generate_chat_title("测试标题")
|
||
model.http_client.close.assert_called_once()
|
||
model.http_async_client.aclose.assert_awaited_once()
|
||
|
||
|
||
@pytest.mark.anyio
|
||
async def test_graph_replacement_closes_only_retired_clients():
|
||
"""
|
||
替换缓存图时回收旧模型,保留新图直到失效;重复清理不重复关闭。
|
||
"""
|
||
old_model, new_model = _model(), _model()
|
||
agent = MoviePilotAgent(session_id="replace-clients", user_id="1")
|
||
publish_graph(agent, models=(old_model,))
|
||
publish_graph(agent, models=(new_model,))
|
||
await agent._graphs.collect()
|
||
old_model.http_async_client.aclose.assert_awaited_once()
|
||
new_model.http_async_client.aclose.assert_not_awaited()
|
||
assert await agent._invalidate_cached_agent() is True
|
||
assert await agent._invalidate_cached_agent() is True
|
||
new_model.http_async_client.aclose.assert_awaited_once()
|
||
old_model.http_async_client.aclose.assert_awaited_once()
|
||
|
||
|
||
@pytest.mark.anyio
|
||
async def test_unfinished_subagent_retains_model_until_cleanup_converges():
|
||
"""
|
||
仍在使用模型的子代理未退出时不得关闭连接,重试清理后才释放。
|
||
"""
|
||
model = _model()
|
||
agent = MoviePilotAgent(session_id="pending-clients", user_id="1")
|
||
publish_graph(agent, models=(model,), middlewares=(SimpleNamespace(close=AsyncMock(side_effect=[False, True])),))
|
||
assert await agent._invalidate_cached_agent() is False
|
||
model.http_async_client.aclose.assert_not_awaited()
|
||
assert await agent._invalidate_cached_agent() is True
|
||
model.http_async_client.aclose.assert_awaited_once()
|
||
|
||
|
||
@pytest.mark.anyio
|
||
async def test_unfinished_learning_retains_model_until_task_finishes():
|
||
"""
|
||
后台复盘的模型副本共享客户端,必须等其退出才能释放原图连接。
|
||
"""
|
||
model = _model()
|
||
agent = MoviePilotAgent(session_id="learning-clients", user_id="1")
|
||
publish_graph(agent, models=(model,))
|
||
task = asyncio.get_running_loop().create_future()
|
||
agent._learning = SimpleNamespace(run=SimpleNamespace(task=task))
|
||
assert await agent._invalidate_cached_agent() is False
|
||
model.http_async_client.aclose.assert_not_awaited()
|
||
task.set_result(None)
|
||
assert await agent._invalidate_cached_agent() is True
|
||
model.http_async_client.aclose.assert_awaited_once()
|
||
|
||
|
||
@pytest.mark.anyio
|
||
async def test_close_failure_retains_owner_and_still_closes_other_client():
|
||
"""
|
||
一个客户端关闭失败不得阻止另一个释放,Agent 保留失败 owner 供重试。
|
||
"""
|
||
model = _model()
|
||
model.http_async_client.aclose.side_effect = [RuntimeError("close failed"), None]
|
||
agent = MoviePilotAgent(session_id="retry-clients", user_id="1")
|
||
publish_graph(agent, models=(model,))
|
||
assert await agent._invalidate_cached_agent() is False
|
||
model.http_client.close.assert_called_once()
|
||
assert await agent._invalidate_cached_agent() is True
|
||
assert model.http_async_client.aclose.await_count == 2
|
||
|
||
|
||
@pytest.mark.anyio
|
||
async def test_real_httpx_clients_close_without_collecting_model():
|
||
"""
|
||
模型继续被强引用时,真实 httpx 客户端也应确定性进入关闭态。
|
||
"""
|
||
transport = httpx.MockTransport(lambda _request: httpx.Response(200, json={"ok": True}))
|
||
model = SimpleNamespace(
|
||
http_client=httpx.Client(transport=transport, trust_env=False),
|
||
http_async_client=httpx.AsyncClient(transport=transport, trust_env=False),
|
||
)
|
||
try:
|
||
await model.http_async_client.get("https://offline.example")
|
||
assert await LLMHelper.close_llm(model) is True
|
||
assert model.http_client.is_closed
|
||
assert model.http_async_client.is_closed
|
||
assert await LLMHelper.close_llm(model) is True
|
||
finally:
|
||
model.http_client.close()
|
||
await model.http_async_client.aclose()
|
||
|
||
|
||
@pytest.mark.anyio
|
||
@pytest.mark.parametrize("proxy", [None, "http://127.0.0.1:7890"])
|
||
@pytest.mark.parametrize("runtime", ["openai_compatible", "deepseek"])
|
||
async def test_real_models_expose_owned_clients_for_cleanup(monkeypatch, proxy, runtime):
|
||
"""
|
||
锁定版本的 SDK 在代理与直连模式下都暴露由本模型独占的两个客户端。
|
||
"""
|
||
runtime_owner = SimpleNamespace(resolve_runtime=AsyncMock(return_value={
|
||
"provider_id": "openai", "runtime": runtime,
|
||
"model_id": "deepseek-chat" if runtime == "deepseek" else "gpt-5-mini",
|
||
"api_key": "offline-key", "base_url": "https://offline.example/v1",
|
||
}))
|
||
monkeypatch.setattr(llm_module, "_resolve_llm_proxy", lambda _use_proxy: proxy)
|
||
model = await LLMHelper.get_llm(
|
||
provider="deepseek" if runtime == "deepseek" else "openai",
|
||
model=runtime_owner.resolve_runtime.return_value["model_id"],
|
||
api_key="offline-key", base_url="https://offline.example/v1",
|
||
api_protocol="chat_completions", thinking_level="off", web_search_mode="disabled",
|
||
provider_runtime=runtime_owner,
|
||
)
|
||
assert model.http_client is not None
|
||
assert model.http_async_client is not None
|
||
assert await LLMHelper.close_llm(model) is True
|
||
assert model.http_client.is_closed
|
||
assert model.http_async_client.is_closed
|
||
|
||
|
||
@pytest.mark.anyio
|
||
async def test_proxy_fin_releases_sockets_with_models_still_referenced(monkeypatch):
|
||
"""
|
||
本地代理先发送 FIN,32 轮真实 SDK 请求结束后均收到客户端 EOF,不依赖 GC。
|
||
"""
|
||
# SDK 的代理 transport 会合并系统代理;只允许本用例创建的回环代理。
|
||
monkeypatch.setattr("httpx._client.get_environment_proxies", lambda: {})
|
||
models = []
|
||
connections_closed = asyncio.Queue()
|
||
writers = []
|
||
|
||
async def proxy_response(reader, writer):
|
||
"""
|
||
回放 OpenAI 响应并半关闭连接,再观测客户端是否主动释放套接字。
|
||
"""
|
||
writers.append(writer)
|
||
try:
|
||
headers = await reader.readuntil(b"\r\n\r\n")
|
||
content_length = next(int(line.split(b":", 1)[1]) for line in headers.split(b"\r\n")
|
||
if line.lower().startswith(b"content-length:"))
|
||
await reader.readexactly(content_length)
|
||
body = json.dumps({
|
||
"id": "chatcmpl-offline", "object": "chat.completion", "created": 1,
|
||
"model": "gpt-5-mini", "choices": [{"index": 0, "finish_reason": "stop",
|
||
"message": {"role": "assistant", "content": "OK"}}],
|
||
}).encode()
|
||
writer.write(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: "
|
||
+ str(len(body)).encode() + b"\r\n\r\n" + body)
|
||
await writer.drain()
|
||
writer.write_eof()
|
||
connections_closed.put_nowait(await reader.read())
|
||
finally:
|
||
writer.close()
|
||
await writer.wait_closed()
|
||
|
||
server = await asyncio.start_server(proxy_response, "127.0.0.1", 0)
|
||
proxy_url = f"http://127.0.0.1:{server.sockets[0].getsockname()[1]}"
|
||
monkeypatch.setattr(llm_module, "_resolve_llm_proxy", lambda _use_proxy: proxy_url)
|
||
runtime_owner = SimpleNamespace(resolve_runtime=AsyncMock(return_value={
|
||
"provider_id": "openai", "runtime": "openai_compatible", "model_id": "gpt-5-mini",
|
||
"api_key": "offline-key", "base_url": "http://offline.invalid/v1",
|
||
}))
|
||
get_llm = LLMHelper.get_llm
|
||
|
||
async def make_model(**kwargs):
|
||
"""
|
||
保留真实模型强引用,排除析构或 GC 自动关闭掩盖生命周期缺陷。
|
||
"""
|
||
model = await get_llm(**kwargs)
|
||
models.append(model)
|
||
return model
|
||
|
||
monkeypatch.setattr(LLMHelper, "get_llm", make_model)
|
||
try:
|
||
for _ in range(32):
|
||
result = await LLMHelper.test_current_settings(
|
||
provider="openai", model="gpt-5-mini", api_key="offline-key",
|
||
api_protocol="chat_completions", thinking_level="off", web_search_mode="disabled",
|
||
provider_runtime=runtime_owner,
|
||
)
|
||
assert result["reply_preview"] == "OK"
|
||
assert await asyncio.wait_for(connections_closed.get(), timeout=2) == b""
|
||
assert all(model.http_client.is_closed and model.http_async_client.is_closed for model in models)
|
||
finally:
|
||
for model in models:
|
||
model.http_client.close()
|
||
await model.http_async_client.aclose()
|
||
server.close()
|
||
await server.wait_closed()
|
||
for writer in writers:
|
||
writer.close()
|
||
await writer.wait_closed()
|
||
|
||
|
||
@pytest.mark.anyio
|
||
@pytest.mark.parametrize("failure", [RuntimeError("second model failed"), asyncio.CancelledError()])
|
||
async def test_partial_graph_construction_closes_first_model(monkeypatch, failure):
|
||
"""
|
||
第二个模型构造失败或取消时,第一个已经创建的模型必须释放。
|
||
"""
|
||
model = _model()
|
||
agent = MoviePilotAgent(session_id="partial-models", user_id="1")
|
||
catalog = ToolCatalogSnapshot.from_tools([], plugin_revision=0, factory_revision="test")
|
||
monkeypatch.setattr(agent, "_resolve_llm_runtime_config", AsyncMock(return_value={}))
|
||
monkeypatch.setattr(agent, "_initialize_local_tool_catalogs", Mock(return_value=(catalog, catalog)))
|
||
monkeypatch.setattr(agent, "_initialize_mcp_tools", AsyncMock(return_value=[]))
|
||
monkeypatch.setattr(agent, "_initialize_subagent_mcp_tools", AsyncMock(return_value=[]))
|
||
monkeypatch.setattr(agent, "_initialize_llm", AsyncMock(side_effect=[model, failure]))
|
||
monkeypatch.setattr(agent, "_sync_model_profile", Mock())
|
||
monkeypatch.setattr(agent_module, "_get_plugin_tools_revision", lambda: 0)
|
||
monkeypatch.setattr(agent_module.agent_mcp_manager, "config_signature", lambda: "")
|
||
monkeypatch.setattr(agent_module.agent_mcp_manager, "list_enabled_tool_specs", AsyncMock(return_value=[]))
|
||
monkeypatch.setattr(agent_module.prompt_manager, "get_agent_prompt", Mock(return_value=""))
|
||
monkeypatch.setattr(LLMHelper, "get_server_tools", Mock(return_value=[]))
|
||
monkeypatch.setattr(agent_module.ServerToolRegistry, "resolve_web_search",
|
||
Mock(return_value=SimpleNamespace(use_local_web_search=False)))
|
||
with pytest.raises(type(failure)):
|
||
await agent._create_agent(streaming=True)
|
||
model.http_client.close.assert_called_once()
|
||
model.http_async_client.aclose.assert_awaited_once()
|