Files
MoviePilot/tests/test_agent_api_lazy_imports.py
InfinityPacer 5c70757a81 fix(auth): align v3 authentication contracts
按已确认的 V3 AUTH-001 交付门禁合并:后端认证与用户资料契约已完成本地前后端联调,Architecture、Coverage、单测分片和 Pylint 全部通过。
2026-08-31 04:57:34 +08:00

482 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Agent API 路由与禁用响应的延迟加载合同。"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
def _run_isolated(script: str, config_dir: Path) -> dict:
"""在隔离解释器中执行路由探针,并返回末行 JSON 结果。"""
env = os.environ.copy()
env.update(
{
"AI_AGENT_ENABLE": "false",
"API_TOKEN": "test-agent-api-token-1234",
"CONFIG_DIR": str(config_dir),
"PYTHONDONTWRITEBYTECODE": "1",
}
)
completed = subprocess.run(
[sys.executable, "-c", script],
check=True,
capture_output=True,
text=True,
env=env,
)
lines = [line for line in completed.stdout.splitlines() if line.strip()]
return json.loads(lines[-1])
def test_full_api_openapi_keeps_agent_runtime_cold(tmp_path: Path) -> None:
"""完整路由与 OpenAPI 注册不得物化 Agent、工具或模型运行时。"""
result = _run_isolated(
r"""
import json
import socket
import sys
import types
network_attempts = []
def block_network(*args, **kwargs):
network_attempts.append(repr(args[:2]))
raise AssertionError("router import attempted network access")
socket.create_connection = block_network
socket.getaddrinfo = block_network
socket.socket.connect = block_network
sites = types.ModuleType("app.application.site.sites")
sites.SitesHelper = type("SitesHelper", (), {})
sites.__file__ = "<test-stub>"
sys.modules["app.application.site.sites"] = sites
from fastapi import FastAPI
from app.startup.initializers.routers import init_routers
app = FastAPI()
init_routers(app)
paths = set(app.openapi()["paths"])
required_paths = {
"/api/v1/message/agent/stream",
"/api/v1/message/agent/sessions",
"/api/v1/openai/v1/chat/completions",
"/api/v1/openai/v1/responses",
"/api/v1/anthropic/v1/messages",
"/api/v1/llm/manage",
"/api/v1/mcp",
"/api/v1/mcp/tools",
}
forbidden = (
"app.agent.callback",
"app.agent.llm.helper",
"app.agent.orchestrator",
"app.agent.tools.base",
"app.agent.tools.catalog",
"app.agent.tools.factory",
"app.agent.tools.impl",
"langgraph",
)
loaded = sorted(
name
for name in sys.modules
if any(name == prefix or name.startswith(prefix + ".") for prefix in forbidden)
)
print(json.dumps({
"loaded": loaded,
"missing_paths": sorted(required_paths - paths),
"network_attempts": network_attempts,
}))
""",
tmp_path / "router-import",
)
assert result == {
"loaded": [],
"missing_paths": [],
"network_attempts": [],
}
def test_disabled_protocol_requests_preserve_503_without_runtime_load(
tmp_path: Path,
) -> None:
"""禁用态兼容协议保持 503并且不会因构造响应加载 Agent。"""
result = _run_isolated(
r"""
import asyncio
import json
import socket
import sys
import types
from types import SimpleNamespace
network_attempts = []
def block_network(*args, **kwargs):
network_attempts.append(repr(args[:2]))
raise AssertionError("disabled request attempted network access")
socket.create_connection = block_network
socket.getaddrinfo = block_network
socket.socket.connect = block_network
sites = types.ModuleType("app.application.site.sites")
sites.SitesHelper = type("SitesHelper", (), {})
sites.__file__ = "<test-stub>"
sys.modules["app.application.site.sites"] = sites
from fastapi.security import HTTPAuthorizationCredentials
from app import schemas
from app.api.endpoints import anthropic, openai
from app.api.endpoints.anthropic import messages as anthropic_messages
from app.api.endpoints.openai import chat_completions, responses
from app.application.configuration import ApiRuntimeConfig
from app.runtime.config import settings
runtime_config = ApiRuntimeConfig(
60, False, settings.AI_AGENT_ENABLE,
api_token=settings.API_TOKEN,
)
anthropic.get_api_runtime_config_snapshot = lambda: runtime_config
openai.get_api_runtime_config_snapshot = lambda: runtime_config
anthropic.validate_api_credential_identity = lambda: None
openai.validate_api_credential_identity = lambda: None
credentials = HTTPAuthorizationCredentials(
scheme="Bearer",
credentials=settings.API_TOKEN,
)
request = SimpleNamespace(headers={})
async def run_requests():
chat_response = await chat_completions(
payload=schemas.OpenAIChatCompletionsRequest(
messages=[schemas.OpenAIChatMessage(role="user", content="hello")]
),
request=request,
credentials=credentials,
)
responses_response = await responses(
payload=schemas.OpenAIResponsesRequest(input="hello"),
credentials=credentials,
)
anthropic_response = await anthropic_messages(
payload=schemas.AnthropicMessagesRequest(
messages=[schemas.AnthropicMessage(role="user", content="hello")]
),
x_api_key=settings.API_TOKEN,
)
return chat_response, responses_response, anthropic_response
protocol_responses = asyncio.run(run_requests())
forbidden = (
"app.agent.callback",
"app.agent.llm.helper",
"app.agent.orchestrator",
"app.agent.tools.base",
"app.agent.tools.catalog",
"app.agent.tools.factory",
"app.agent.tools.impl",
"langgraph",
)
loaded = sorted(
name
for name in sys.modules
if any(name == prefix or name.startswith(prefix + ".") for prefix in forbidden)
)
print(json.dumps({
"loaded": loaded,
"network_attempts": network_attempts,
"status_codes": [response.status_code for response in protocol_responses],
"bodies": [json.loads(response.body) for response in protocol_responses],
}, ensure_ascii=False))
""",
tmp_path / "disabled-requests",
)
assert result["loaded"] == []
assert result["network_attempts"] == []
assert result["status_codes"] == [503, 503, 503]
assert result["bodies"][0]["error"]["code"] == "ai_agent_disabled"
assert result["bodies"][1]["error"]["code"] == "ai_agent_disabled"
assert result["bodies"][2]["error"]["type"] == "api_error"
def test_runtime_agent_type_factories_are_single_flight(tmp_path: Path) -> None:
"""并发首次解析必须返回同一 class避免会话复用误判构造器已变化。"""
result = _run_isolated(
r"""
import json
import sys
import threading
import time
import types
sites = types.ModuleType("app.application.site.sites")
sites.SitesHelper = type("SitesHelper", (), {})
sites.__file__ = "<test-stub>"
sys.modules["app.application.site.sites"] = sites
from app.agent import web
from app.api.endpoints import openai
def exercise(module, factory_name, getter_name):
calls = []
call_lock = threading.Lock()
start = threading.Barrier(8)
class RuntimeAgent:
pass
def get_runtime_type():
with call_lock:
calls.append(1)
time.sleep(0.02)
return RuntimeAgent
setattr(module, getter_name, get_runtime_type)
factory = getattr(module, factory_name)
results = []
def resolve():
start.wait()
results.append(factory())
threads = [threading.Thread(target=resolve) for _ in range(8)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
return len(calls), all(result is results[0] for result in results)
web_calls, web_identity = exercise(
web,
"_get_web_agent_type",
"get_moviepilot_agent_type",
)
collecting_calls, collecting_identity = exercise(
openai,
"_get_collecting_agent_type",
"get_moviepilot_agent_type",
)
print(json.dumps({
"web_calls": web_calls,
"web_identity": web_identity,
"collecting_calls": collecting_calls,
"collecting_identity": collecting_identity,
}))
""",
tmp_path / "agent-type-single-flight",
)
assert result == {
"web_calls": 1,
"web_identity": True,
"collecting_calls": 1,
"collecting_identity": True,
}
def test_persistent_protocol_agent_rebinds_stream_queue_without_stale_output(
tmp_path: Path,
) -> None:
"""稳定协议会话复用 Agent 时必须保留 handler identity 并切换请求队列。"""
result = _run_isolated(
r"""
import asyncio
import json
import sys
import types
sites = types.ModuleType("app.application.site.sites")
sites.SitesHelper = type("SitesHelper", (), {})
sites.__file__ = "<test-stub>"
sys.modules["app.application.site.sites"] = sites
from app.api.endpoints import openai
class RuntimeAgent:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
self.stream_handler = object()
self._compiled_agent_bundle = object()
class TestStreamingHandler(openai._OpenAIStreamingHandlerMixin):
pass
openai._get_openai_streaming_handler_type = lambda: TestStreamingHandler
agent_type = openai._build_collecting_agent_type(RuntimeAgent)
agent = agent_type(session_id="stable", user_id="api")
first_queue = asyncio.Queue()
second_queue = asyncio.Queue()
agent.configure_protocol_request(stream_mode=True, event_queue=first_queue)
handler = agent.stream_handler
handler._event_queue.put_nowait("first")
compiled_bundle = object()
agent._compiled_agent_bundle = compiled_bundle
agent.configure_protocol_request(stream_mode=True, event_queue=second_queue)
agent.release_protocol_request(first_queue)
handler._event_queue.put_nowait("second")
agent.release_protocol_request(second_queue)
print(json.dumps({
"same_handler": agent.stream_handler is handler,
"same_bundle": agent._compiled_agent_bundle is compiled_bundle,
"first": first_queue.get_nowait(),
"first_empty": first_queue.empty(),
"second": second_queue.get_nowait(),
"second_empty": second_queue.empty(),
"released": handler._event_queue is None,
}))
""",
tmp_path / "protocol-stream-rebind",
)
assert result == {
"same_handler": True,
"same_bundle": True,
"first": "first",
"first_empty": True,
"second": "second",
"second_empty": True,
"released": True,
}
def test_protocol_routes_follow_agent_service_lifecycle(tmp_path: Path) -> None:
"""服务未运行时返回 503运行态仍执行原有兼容协议响应流程。"""
result = _run_isolated(
r"""
import asyncio
import json
import socket
import sys
import types
from types import SimpleNamespace
network_attempts = []
def block_network(*args, **kwargs):
network_attempts.append(repr(args[:2]))
raise AssertionError("protocol lifecycle test attempted network access")
socket.create_connection = block_network
socket.getaddrinfo = block_network
socket.socket.connect = block_network
sites = types.ModuleType("app.application.site.sites")
sites.SitesHelper = type("SitesHelper", (), {})
sites.__file__ = "<test-stub>"
sys.modules["app.application.site.sites"] = sites
from fastapi.security import HTTPAuthorizationCredentials
from app import schemas
from app.api.endpoints import anthropic, openai
from app.application.configuration import ApiRuntimeConfig
from app.runtime.config import settings
settings.AI_AGENT_ENABLE = True
runtime_config = ApiRuntimeConfig(
60, False, True,
api_token=settings.API_TOKEN,
)
anthropic.get_api_runtime_config_snapshot = lambda: runtime_config
openai.get_api_runtime_config_snapshot = lambda: runtime_config
anthropic.validate_api_credential_identity = lambda: None
openai.validate_api_credential_identity = lambda: None
credentials = HTTPAuthorizationCredentials(
scheme="Bearer",
credentials=settings.API_TOKEN,
)
request = SimpleNamespace(headers={})
chat_payload = schemas.OpenAIChatCompletionsRequest(
messages=[schemas.OpenAIChatMessage(role="user", content="hello")]
)
anthropic_payload = schemas.AnthropicMessagesRequest(
messages=[schemas.AnthropicMessage(role="user", content="hello")]
)
async def run_unavailable():
return (
await openai.chat_completions(chat_payload, request, credentials),
await anthropic.messages(
anthropic_payload,
x_api_key=settings.API_TOKEN,
),
)
unavailable = asyncio.run(run_unavailable())
class RuntimeAgent:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
self.stream_handler = object()
self._compiled_agent_bundle = None
async def process(self, _prompt, **_kwargs):
return "runtime reply"
class RunningManager:
async def process_message(self, **kwargs):
agent = kwargs["agent_factory"](
session_id=kwargs["session_id"],
user_id=kwargs["user_id"],
channel=kwargs["channel"],
source=kwargs["source"],
username=kwargs["username"],
)
kwargs["agent_setup"](agent)
return await agent.process(
kwargs["message"],
images=kwargs["images"],
files=kwargs["files"],
)
async def clear_session(self, **_kwargs):
return None
running_manager = RunningManager()
openai.get_running_agent_manager = lambda: running_manager
anthropic.get_running_agent_manager = lambda: running_manager
openai.get_moviepilot_agent_type = lambda: RuntimeAgent
async def run_available():
return (
await openai.chat_completions(chat_payload, request, credentials),
await anthropic.messages(
anthropic_payload,
x_api_key=settings.API_TOKEN,
),
)
available = asyncio.run(run_available())
openai_body = json.loads(available[0].body)
print(json.dumps({
"unavailable_status": [response.status_code for response in unavailable],
"unavailable_codes": [
json.loads(unavailable[0].body)["error"]["code"],
json.loads(unavailable[1].body)["error"]["type"],
],
"available_openai": openai_body["choices"][0]["message"]["content"],
"available_anthropic": available[1].content[0].text,
"network_attempts": network_attempts,
}, ensure_ascii=False))
""",
tmp_path / "protocol-service-lifecycle",
)
assert result == {
"unavailable_status": [503, 503],
"unavailable_codes": ["ai_agent_unavailable", "api_error"],
"available_openai": "runtime reply",
"available_anthropic": "runtime reply",
"network_attempts": [],
}