Files
MoviePilot/tests/test_llm_tool_schema.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

298 lines
10 KiB
Python
Raw Permalink 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.
"""
OpenAI 兼容请求中函数工具参数 schema 的规整契约测试。
Pydantic 为 `Optional[T]` 字段生成不带顶层 `type` 的 `anyOf`;把 OpenAI tools
转成 Gemini `functionDeclaration` 的网关不处理 `anyOf`,整次 Agent 请求会被 400
拒绝。这里用真实的 langchain-openai 请求构造与真实的 Agent 工具入参模型,固定
请求上线前的 schema 规整行为。
"""
import asyncio
import copy
import sys
from types import SimpleNamespace
from typing import Any
import pytest
from langchain_core.messages import HumanMessage
from langchain_core.tools import StructuredTool
from langchain_openai import ChatOpenAI
from app.agent.llm import helper as llm_module
from app.agent.middleware.skills import SkillToolInput
from app.agent.middleware.subagents import _SubAgentControlInput
from app.agent.tools.impl.api import MoviePilotApiInput
from app.agent.tools.impl.view_image import ViewImageInput
_PATCH_MARKER = "_moviepilot_test_tool_schema_patched"
_TOOL_INPUTS = {
"read_skill": SkillToolInput,
"subagent": _SubAgentControlInput,
"moviepilot_api": MoviePilotApiInput,
"view_image": ViewImageInput,
}
def _build_model(model_name: str, **kwargs: Any) -> ChatOpenAI:
"""
构造带独立补丁的 ChatOpenAI 子类实例,避免修补真实类或跨用例残留。
"""
class _PatchedChatOpenAI(ChatOpenAI):
"""
承载本用例工具 schema 补丁的模型类。
"""
llm_module._patch_tool_schema_request_support(
_PatchedChatOpenAI,
patch_marker=_PATCH_MARKER,
)
return _PatchedChatOpenAI(
model=model_name,
api_key="sk-test",
base_url="https://gateway.example/v1",
**kwargs,
)
def _build_tools() -> list[StructuredTool]:
"""
用真实 Agent 工具入参模型构造 LangChain 工具。
"""
async def _noop(**_kwargs: Any) -> str:
"""
提供构造工具所需的离线执行入口。
"""
return ""
return [
StructuredTool.from_function(
coroutine=_noop,
name=name,
description=f"{name} tool",
args_schema=args_schema,
)
for name, args_schema in _TOOL_INPUTS.items()
]
def _request_tools(model: ChatOpenAI, **bind_kwargs: Any) -> tuple[list[dict], list[dict]]:
"""
返回绑定后的原始工具定义与请求 payload 中的最终工具定义。
"""
bound = model.bind_tools(_build_tools(), **bind_kwargs)
bound_tools = bound.kwargs["tools"]
snapshot = copy.deepcopy(bound_tools)
payload = model._get_request_payload([HumanMessage("hi")], **bound.kwargs)
assert bound_tools == snapshot, "规整不得修改已绑定的工具定义"
return bound_tools, payload["tools"]
def _parameters(tools: list[dict], name: str) -> dict:
"""
按名称取 Chat Completions 或 Responses 格式工具的参数 schema。
"""
for tool in tools:
function = tool.get("function", tool)
if function.get("name") == name:
return function["parameters"]
raise AssertionError(f"缺少工具 {name}")
def _iter_schema_nodes(schema: dict, path: str = ""):
"""
遍历 properties/items/additionalProperties 位置上的全部 schema 节点。
"""
for name, child in (schema.get("properties") or {}).items():
yield f"{path}.{name}", child
yield from _iter_schema_nodes(child, f"{path}.{name}")
for keyword in ("items", "additionalProperties"):
child = schema.get(keyword)
if isinstance(child, dict):
yield f"{path}[{keyword}]", child
yield from _iter_schema_nodes(child, f"{path}[{keyword}]")
@pytest.mark.parametrize("use_responses_api", [False, True], ids=["chat", "responses"])
@pytest.mark.parametrize(
"bind_kwargs",
[{}, {"strict": False}, {"strict": True}],
ids=["strict-omitted", "strict-false", "strict-true"],
)
def test_non_gemini_requests_preserve_nullable_tool_parameters(
use_responses_api: bool,
bind_kwargs: dict[str, Any],
) -> None:
"""
两种协议的非 Gemini 工具保留可空语义,不依赖上游是否自动启用 strict。
"""
model = _build_model("gpt-4o", use_responses_api=use_responses_api)
bound_tools, tools = _request_tools(model, **bind_kwargs)
expected_tools = (
[{"type": "function", **tool["function"]} for tool in bound_tools]
if use_responses_api else bound_tools
)
assert tools == expected_tools
skill = _parameters(tools, "read_skill")
assert skill["properties"]["file"]["anyOf"] == [{"type": "string"}, {"type": "null"}]
assert skill["required"] == (["name", "file"] if bind_kwargs.get("strict") else ["name"])
def test_optional_fields_collapse_to_single_type_for_gemini_models() -> None:
"""
Gemini 的 Optional[T] 字段折叠为 T,保留字段描述、默认值与分支约束。
"""
_, tools = _request_tools(_build_model("gemini-2.5-flash"))
skill = _parameters(tools, "read_skill")
assert skill["properties"]["file"] == {
"type": "string",
"default": None,
"description": SkillToolInput.model_fields["file"].description,
}
assert skill["required"] == ["name"]
subagent = _parameters(tools, "subagent")["properties"]
assert subagent["description"]["type"] == "string"
assert subagent["timeout_ms"]["type"] == "integer"
assert subagent["tasks"]["type"] == "array"
assert subagent["tasks"]["items"]["properties"]["description"]["type"] == "string"
image_data = _parameters(tools, "view_image")["properties"]["image_data"]
assert image_data["type"] == "string"
assert "anyOf" not in image_data
def test_true_unions_keep_their_semantics_for_non_gemini_models() -> None:
"""
非 Gemini 模型的多类型联合及 null 分支保持原样。
"""
bound_tools, tools = _request_tools(_build_model("gpt-4o"))
body = _parameters(tools, "moviepilot_api")["properties"]["body"]
assert body == _parameters(bound_tools, "moviepilot_api")["properties"]["body"]
assert {variant.get("type") for variant in body["anyOf"]} == {
"object",
"array",
"string",
"null",
}
@pytest.mark.parametrize("model_name", ["gemini-2.5-flash", "google/Gemini-2.5-Pro"])
@pytest.mark.parametrize("use_responses_api", [False, True], ids=["chat", "responses"])
def test_every_schema_node_is_typed_for_gemini_models(
model_name: str,
use_responses_api: bool,
) -> None:
"""
Gemini 模型的每个参数节点都必须带 type,且不再依赖 anyOf/oneOf。
"""
_, tools = _request_tools(_build_model(model_name, use_responses_api=use_responses_api))
for name in _TOOL_INPUTS:
for path, node in _iter_schema_nodes(_parameters(tools, name)):
assert isinstance(node.get("type"), str), f"{name}{path} 缺少 type: {node}"
assert "anyOf" not in node and "oneOf" not in node, f"{name}{path}"
body = _parameters(tools, "moviepilot_api")["properties"]["body"]
assert body["type"] == "object"
assert "additionalProperties" not in body
assert body["description"] == MoviePilotApiInput.model_fields["body"].description
def test_responses_api_function_tools_are_normalized() -> None:
"""
Responses API 的扁平函数工具格式同样在上线前规整。
"""
model = _build_model("gemini-2.5-flash", use_responses_api=True)
_, tools = _request_tools(model)
assert tools[0]["type"] == "function"
assert "function" not in tools[0]
assert _parameters(tools, "read_skill")["properties"]["file"]["type"] == "string"
assert _parameters(tools, "moviepilot_api")["properties"]["body"]["type"] == "object"
@pytest.mark.parametrize("use_responses_api", [False, True], ids=["chat", "responses"])
def test_strict_tools_are_left_unchanged(use_responses_api: bool) -> None:
"""
strict 工具依赖 anyOf 表达可空必填字段,规整不得改变其语义。
"""
model = _build_model("gemini-2.5-flash", use_responses_api=use_responses_api)
bound_tools, tools = _request_tools(model, strict=True)
for name in _TOOL_INPUTS:
assert _parameters(tools, name) == _parameters(bound_tools, name)
assert all(tool.get("function", tool)["strict"] is True for tool in tools)
def test_normalization_is_idempotent_when_patched_twice() -> None:
"""
重复修补同一模型类不会重复包装请求构造。
"""
class _PatchedChatOpenAI(ChatOpenAI):
"""
验证修补标记的模型类。
"""
llm_module._patch_tool_schema_request_support(_PatchedChatOpenAI, patch_marker=_PATCH_MARKER)
patched = _PatchedChatOpenAI._get_request_payload
llm_module._patch_tool_schema_request_support(_PatchedChatOpenAI, patch_marker=_PATCH_MARKER)
assert _PatchedChatOpenAI._get_request_payload is patched
def test_openai_runtime_applies_tool_schema_patch(monkeypatch) -> None:
"""
OpenAI 兼容运行时构造的模型类必须挂上工具 schema 规整。
"""
class _FakeChatOpenAI:
"""
隔离模型初始化,只保留请求补丁需要的最小接口。
"""
def __init__(self, **kwargs: Any) -> None:
"""
记录模型身份与工具调用能力。
"""
self.model_name = kwargs["model"]
self.profile = {"tool_calling": True}
def _get_request_payload(self, input_, *, stop=None, **kwargs):
"""
返回工具定义,让真实补丁处理出站参数。
"""
return {"tools": kwargs.get("tools", [])}
monkeypatch.setitem(sys.modules, "langchain_openai", SimpleNamespace(ChatOpenAI=_FakeChatOpenAI))
monkeypatch.setattr(llm_module, "_patch_openai_responses_instructions_support", lambda: None)
model = asyncio.run(
llm_module.LLMHelper.get_llm(
provider="openai",
model="gemini-2.5-flash",
api_key="sk-test",
base_url="https://gateway.example/v1",
)
)
tool = {
"type": "function",
"function": {
"name": "read_skill",
"parameters": {
"type": "object",
"properties": {"file": {"anyOf": [{"type": "string"}, {"type": "null"}]}},
},
},
}
payload = model._get_request_payload([], tools=[tool])
assert payload["tools"][0]["function"]["parameters"]["properties"]["file"] == {"type": "string"}