mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-29 03:18:14 +08:00
fix(agent): restore API bodies before direct request dispatch
This commit is contained in:
@@ -413,6 +413,9 @@ class MoviePilotApiTool(MoviePilotTool):
|
||||
"""
|
||||
执行一个白名单 MoviePilot API 操作。
|
||||
|
||||
HTTP/MCP/CLI 的直接调用不经过输入模型,因此也要还原顶层 JSON
|
||||
字符串请求体,并把还原后的值用于校验和实际请求。
|
||||
|
||||
:param operation_id: 稳定 API 操作标识
|
||||
:param path_params: 路径参数
|
||||
:param query: 查询参数
|
||||
@@ -430,6 +433,7 @@ class MoviePilotApiTool(MoviePilotTool):
|
||||
ensure_ascii=False,
|
||||
)
|
||||
try:
|
||||
body = coerce_string_body(body)
|
||||
arguments: dict[str, Any] = {"operation_id": operation_id, **kwargs}
|
||||
if path_params is not None:
|
||||
arguments["path_params"] = path_params
|
||||
|
||||
@@ -260,6 +260,7 @@ curl -sS "$MP_MCP_URL/tools/moviepilot_api/schema" \
|
||||
- 不传 `url`、`method`、请求头或认证凭据;固定 operation 由服务端映射到业务接口。
|
||||
- 不将 `media_id` 等业务字段平铺到顶层,也不为无请求体的操作补 `body: {}`。
|
||||
- `body` 使用原生 JSON 对象或数组;只有合同允许时才传 `null`。当前字符串请求体仅用于 `system.upgrade.dev` 的固定值 `"dev"`。
|
||||
- 若模型把整个 `body` 编码成 JSON 字符串,各工具入口会在校验和发送前还原一层对象、数组或 `null`;嵌套字符串保持原值,非法请求体仍按 operation 合同拒绝。客户端应直接传原生 JSON 值。
|
||||
- 媒体标识必须与返回它的 `media_source` 配对。不同操作的类型字段与枚举可能不同,不要把 `movie`、`电影` 或 `type`、`mtype`、`type_name` 相互套用。
|
||||
- 下载、整理、订阅等操作优先复用前序查询返回的媒体上下文、种子上下文、存储项目和精确 ID,不根据标题猜测资源身份或文件路径。
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
MOVIEPILOT_API_SKILL_VERSION = "60"
|
||||
MOVIEPILOT_API_SKILL_VERSION = "61"
|
||||
DATABASE_OPERATION_SKILL_VERSION = "13"
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: moviepilot-api
|
||||
version: 60
|
||||
version: 61
|
||||
description: >-
|
||||
Use this skill for MoviePilot product operations such as media search, torrent
|
||||
search, downloads, subscriptions, library checks, sites, storage, workflows,
|
||||
@@ -154,6 +154,9 @@ downloader mappings when switching path modes.
|
||||
identifiers and use the documented pagination fields. Pass object and array
|
||||
bodies as native JSON values, and pass null only when the selected operation
|
||||
allows it. The only string body is the literal `"dev"` for `system.upgrade.dev`.
|
||||
If a model JSON-encodes the whole body, every gateway entrypoint restores one
|
||||
layer of object, array, or null before validation and dispatch. Nested string
|
||||
values stay unchanged, and the selected operation's contract still applies.
|
||||
Never flatten operation fields next to `operation_id`: put each in its declared
|
||||
`path_params`, `query`, or `body` container. Do not copy pagination or filters
|
||||
from another operation. An `invalid_input` response names the failing field
|
||||
|
||||
168
tests/test_agent_api_bodies.py
Normal file
168
tests/test_agent_api_bodies.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
验证 API 请求体在 Agent、MCP 和直接工具入口实际发送时保持合同类型。
|
||||
"""
|
||||
|
||||
import json
|
||||
from copy import deepcopy
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.agent.api.executor import ApiExecutionContext, MoviePilotApiExecutor
|
||||
from app.agent.tools.impl.api import MoviePilotApiTool
|
||||
from app.agent.tools.manager import MoviePilotToolsManager
|
||||
|
||||
|
||||
async def _invoke_gateway(
|
||||
entrypoint: str, gateway: MoviePilotApiTool, arguments: dict[str, Any],
|
||||
) -> str:
|
||||
"""
|
||||
从各公开入口进入同一真实网关,不跳过工具管理器的参数处理。
|
||||
|
||||
:param entrypoint: 直接调用、LangChain 或 HTTP/MCP/CLI 共用管理器入口
|
||||
:param gateway: 仅替换 HTTP 传输的真实网关
|
||||
:param arguments: 模型或客户端提交的原始参数
|
||||
:return: 供调用方读取的 JSON 结果
|
||||
"""
|
||||
if entrypoint == "langchain":
|
||||
return await gateway.ainvoke(arguments)
|
||||
if entrypoint == "manager":
|
||||
manager = MoviePilotToolsManager(session_id="body-test", user_id="1")
|
||||
manager.tools = [gateway]
|
||||
try:
|
||||
return await manager.call_tool(gateway.name, arguments)
|
||||
finally:
|
||||
await manager.close()
|
||||
return await gateway.run(**arguments)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_transport(monkeypatch: pytest.MonkeyPatch) -> tuple[MoviePilotApiTool, AsyncMock]:
|
||||
"""
|
||||
保留真实合同、权限和执行器,仅把令牌签发及 HTTP 出站替换为内存响应。
|
||||
"""
|
||||
response = SimpleNamespace(
|
||||
status_code=200,
|
||||
headers={},
|
||||
json=lambda: {"success": True},
|
||||
aclose=AsyncMock(),
|
||||
)
|
||||
request = AsyncMock(return_value=response)
|
||||
executor = MoviePilotApiExecutor(
|
||||
context=ApiExecutionContext(user_id="1", username="admin", is_admin=True),
|
||||
request_factory=Mock(return_value=SimpleNamespace(request=request)),
|
||||
)
|
||||
monkeypatch.setattr(executor, "_build_headers", lambda: {})
|
||||
monkeypatch.setattr(executor, "_resolve_base_url", lambda: "http://127.0.0.1:3001")
|
||||
gateway = MoviePilotApiTool(session_id="body-test", user_id="1", executor=executor)
|
||||
gateway.set_agent_context({"is_admin": True})
|
||||
return gateway, request
|
||||
|
||||
|
||||
@pytest.mark.parametrize("entrypoint", ["direct", "langchain", "manager"])
|
||||
@pytest.mark.parametrize("json_encoded", [False, True], ids=["native", "encoded"])
|
||||
@pytest.mark.parametrize(
|
||||
("operation_id", "body"),
|
||||
[
|
||||
("download.add", {"torrent_in": {
|
||||
"title": "天气之子", "enclosure": "https://example.invalid/test.torrent",
|
||||
"description": "{\"原始副标题\": \"不应被递归解码\"}",
|
||||
}, "media_source": "tmdb", "media_id": "568160"}),
|
||||
("transfer.history.delete", {"id": 7}),
|
||||
("slash.run", {"command": "/help"}),
|
||||
("media.classification.policy.preview", {"input": {
|
||||
"kind": "media", "media": {"title": "天气之子"},
|
||||
}}),
|
||||
("site.priorities.update", [{"id": 3, "pri": 1}]),
|
||||
("system.restart", None),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_sends_restored_body_to_http_transport(
|
||||
api_transport: tuple[MoviePilotApiTool, AsyncMock],
|
||||
entrypoint: str,
|
||||
json_encoded: bool,
|
||||
operation_id: str,
|
||||
body: Any,
|
||||
) -> None:
|
||||
"""
|
||||
对象、数组和空值必须实际按原生类型发送,不能只在校验副本中还原。
|
||||
"""
|
||||
gateway, request = api_transport
|
||||
arguments = {
|
||||
"operation_id": operation_id,
|
||||
"body": json.dumps(body, ensure_ascii=False) if json_encoded else deepcopy(body),
|
||||
}
|
||||
original = deepcopy(arguments)
|
||||
|
||||
result = await _invoke_gateway(entrypoint, gateway, arguments)
|
||||
|
||||
assert json.loads(result)["success"] is True
|
||||
request.assert_awaited_once()
|
||||
assert request.call_args.kwargs["json"] == body
|
||||
assert arguments == original
|
||||
|
||||
|
||||
@pytest.mark.parametrize("entrypoint", ["direct", "langchain", "manager"])
|
||||
@pytest.mark.parametrize("json_encoded", [False, True], ids=["native", "encoded"])
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_restores_get_body_before_moving_it_to_query(
|
||||
api_transport: tuple[MoviePilotApiTool, AsyncMock],
|
||||
entrypoint: str,
|
||||
json_encoded: bool,
|
||||
) -> None:
|
||||
"""
|
||||
兼容的 GET 对象请求体还原后才合并到查询参数,不得作为字符串发出。
|
||||
"""
|
||||
gateway, request = api_transport
|
||||
body = {"page": 2, "count": 20}
|
||||
result = await _invoke_gateway(entrypoint, gateway, {
|
||||
"operation_id": "site.rss",
|
||||
"body": json.dumps(body) if json_encoded else body,
|
||||
})
|
||||
|
||||
assert json.loads(result)["success"] is True
|
||||
request.assert_awaited_once()
|
||||
assert request.call_args.kwargs["json"] is None
|
||||
assert request.call_args.kwargs["params"] == body
|
||||
|
||||
|
||||
@pytest.mark.parametrize("entrypoint", ["direct", "langchain", "manager"])
|
||||
@pytest.mark.parametrize("body", [
|
||||
"not-json", "{", "true", "1", '"dev"', "null", "[]",
|
||||
json.dumps(json.dumps({"torrent_in": {"title": "双重编码"}})),
|
||||
])
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_rejects_invalid_bodies_before_http_request(
|
||||
api_transport: tuple[MoviePilotApiTool, AsyncMock], entrypoint: str, body: str,
|
||||
) -> None:
|
||||
"""
|
||||
兼容还原只处理一层,错误形状、标量和非法 JSON 仍须在出站前拒绝。
|
||||
"""
|
||||
gateway, request = api_transport
|
||||
result = await _invoke_gateway(entrypoint, gateway, {
|
||||
"operation_id": "download.add", "body": body,
|
||||
})
|
||||
|
||||
assert json.loads(result)["error"] == "invalid_input"
|
||||
request.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("entrypoint", ["direct", "langchain", "manager"])
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_preserves_exact_dev_literal(
|
||||
api_transport: tuple[MoviePilotApiTool, AsyncMock], entrypoint: str,
|
||||
) -> None:
|
||||
"""
|
||||
唯一合法的固定字符串请求体必须保持原值发送。
|
||||
"""
|
||||
gateway, request = api_transport
|
||||
result = await _invoke_gateway(entrypoint, gateway, {
|
||||
"operation_id": "system.upgrade.dev", "body": "dev",
|
||||
})
|
||||
|
||||
assert json.loads(result)["success"] is True
|
||||
request.assert_awaited_once()
|
||||
assert request.call_args.kwargs["json"] == "dev"
|
||||
@@ -130,7 +130,7 @@ def test_modified_builtin_skills_have_incremented_versions() -> None:
|
||||
"database-operation": "13",
|
||||
"feedback-issue": "12",
|
||||
"anysearch": "3",
|
||||
"moviepilot-api": "60",
|
||||
"moviepilot-api": "61",
|
||||
"moviepilot-update": "7",
|
||||
"organize-files": "6",
|
||||
"transfer-failed-retry": "6",
|
||||
|
||||
Reference in New Issue
Block a user