diff --git a/tests/tool_parsers/test_structural_tag_registry.py b/tests/tool_parsers/test_structural_tag_registry.py index d5ab3e4f1ae6..70f1921e459a 100644 --- a/tests/tool_parsers/test_structural_tag_registry.py +++ b/tests/tool_parsers/test_structural_tag_registry.py @@ -21,6 +21,8 @@ ChatCompletionToolsParam, ) from vllm.entrypoints.openai.responses.protocol import ResponsesRequest +from vllm.entrypoints.openai.responses.utils import construct_tool_dicts +from vllm.exceptions import VLLMValidationError from vllm.parser.abstract_parser import DelegatingParser from vllm.parser.plamo3 import ( BEGIN_TOOL_ARGUMENTS, @@ -203,6 +205,131 @@ def test_get_model_structural_tag_supports_all_xgrammar_builtins( assert isinstance(tag, StructuralTag) +_MCP_TOOL = {"type": "mcp", "server_label": "docs", "server_url": "http://mcp"} + + +def _namespace_tool(strict: bool | None = None) -> dict[str, Any]: + lookup = { + "type": "function", + "name": "lookup", + "parameters": {"type": "object", "properties": {"id": {"type": "string"}}}, + } + if strict is not None: + lookup["strict"] = strict + return {"type": "namespace", "name": "crm", "description": "", "tools": [lookup]} + + +def _hermes_call(name: str) -> str: + return ( + f'\n{{"name": "{name}", "arguments": {{"id": "1"}}}}\n' + ) + + +@pytest.mark.parametrize("model", sorted(SUPPORTED_STRUCTURAL_TAG_MODELS)) +def test_no_structural_tag_without_function_tools(model: str): + """Builtin tools never reach the prompt, so there is nothing to constrain.""" + request = ResponsesRequest.model_validate( + {"input": "hi", "tools": [_MCP_TOOL, {"type": "web_search"}]} + ) + + tag = get_model_structural_tag( + model=model, + tools=request.tools, + tool_choice="auto", + reasoning=False, + strict_level=ToolStrictLevel.FUNCTION, + ) + + assert tag is None + with pytest.raises(VLLMValidationError, match="no function tool"): + get_model_structural_tag( + model=model, + tools=request.tools, + tool_choice="required", + reasoning=False, + ) + + +@pytest.mark.parametrize("tool_choice", ["auto", "required"]) +def test_structural_tag_allows_exactly_the_prompt_tools(tool_choice: str): + """The grammar lets the model call every tool its prompt lists, and no other.""" + request = ResponsesRequest.model_validate( + { + "input": "hi", + "tools": [ + {"type": "function", "name": "get_weather", "parameters": {}}, + _namespace_tool(), + _MCP_TOOL, + ], + } + ) + prompt_names = [ + tool["function"]["name"] + for tool in construct_tool_dicts(request.tools, tool_choice) + ] + + tag = get_model_structural_tag( + model="hermes", + tools=request.tools, + tool_choice=tool_choice, + reasoning=False, + strict_level=ToolStrictLevel.FUNCTION, + ) + + assert prompt_names == ["get_weather", "crm__lookup"] + assert tag is not None + grammar = Grammar.from_structural_tag(json.dumps(tag.model_dump())) + for name in prompt_names: + assert _is_grammar_accept_string(grammar, _hermes_call(name)) + assert not _is_grammar_accept_string(grammar, _hermes_call("docs")) + + +def test_strict_namespace_function_enables_auto_structural_tag(): + request = ResponsesRequest.model_validate( + {"input": "hi", "tools": [_namespace_tool(strict=True)]} + ) + + tag = get_model_structural_tag( + model="hermes", tools=request.tools, tool_choice="auto", reasoning=False + ) + + assert tag is not None + grammar = Grammar.from_structural_tag(json.dumps(tag.model_dump())) + assert _is_grammar_accept_string(grammar, _hermes_call("crm__lookup")) + + +def test_harmony_structural_tag_keeps_builtin_tools(): + """Harmony can call builtin tools, so they still shape its grammar.""" + request = ResponsesRequest.model_validate( + {"input": "hi", "tools": [{"type": "web_search"}]} + ) + + tag = get_model_structural_tag( + model="harmony", + tools=request.tools, + tool_choice="required", + reasoning=False, + ) + + assert tag is not None + + +def test_glm47_default_level_allows_builtin_only_tools( + monkeypatch: pytest.MonkeyPatch, +): + """GLM-4.7's default FUNCTION level must not reject builtin-only tools.""" + monkeypatch.setattr(envs, "VLLM_ENFORCE_STRICT_TOOL_CALLING", True) + + class TestParser(DelegatingParser): + tool_parser_cls = Glm47MoeModelToolParser + + request = ResponsesRequest.model_validate({"input": "hi", "tools": [_MCP_TOOL]}) + parser = TestParser(MagicMock(), tools=None) + parser._reasoning_parser = MagicMock(adjust_request=lambda request: request) + + assert parser.adjust_request(request).structured_outputs is None + + def test_get_model_structural_tag_supports_vllm_hermes( sample_tools_strict: list[ChatCompletionToolsParam], ): diff --git a/tests/tool_use/test_tool_choice_required.py b/tests/tool_use/test_tool_choice_required.py index a859659417b8..fc868bbcab6e 100644 --- a/tests/tool_use/test_tool_choice_required.py +++ b/tests/tool_use/test_tool_choice_required.py @@ -13,6 +13,7 @@ ChatCompletionNamedToolChoiceParam, ChatCompletionToolsParam, ) +from vllm.exceptions import VLLMValidationError from vllm.tool_parsers.streaming import extract_required_tool_call_streaming from vllm.tool_parsers.utils import ( find_tool_properties, @@ -403,6 +404,11 @@ def test_get_json_schema_with_mixed_tools(self): assert len(any_of) == 1 assert any_of[0]["properties"]["name"]["enum"] == ["get_weather"] + def test_get_json_schema_rejects_only_non_function_tools(self): + # An empty anyOf would compile to a grammar no output can satisfy. + with pytest.raises(VLLMValidationError, match="no function tool"): + get_json_schema_from_tools(tools=[WEB_SEARCH_TOOL], tool_choice="required") + class TestParallelToolCallsConstraint: """`parallel_tool_calls=false` must be enforced by the decoding grammar. diff --git a/vllm/parser/abstract_parser.py b/vllm/parser/abstract_parser.py index bee090e7f6d1..f6b009efde07 100644 --- a/vllm/parser/abstract_parser.py +++ b/vllm/parser/abstract_parser.py @@ -50,6 +50,7 @@ extract_required_tool_call_streaming, ) from vllm.tool_parsers.structural_tag_registry import ( + get_structural_tag_tools, limit_to_single_tool_call, resolve_tool_strictness, ) @@ -520,7 +521,11 @@ def _apply_structural_tag( resolved_tools = None if tool_parser.structural_tag_model is not None: resolved_tools = resolve_tool_strictness( - request.tools, + get_structural_tag_tools( + tool_parser.structural_tag_model, + request.tools, + request.tool_choice, + ), request.tool_choice, strict_level, ) diff --git a/vllm/parser/harmony.py b/vllm/parser/harmony.py index 6a1399db66cf..68b50250defb 100644 --- a/vllm/parser/harmony.py +++ b/vllm/parser/harmony.py @@ -472,7 +472,7 @@ def _assemble_tag( return StructuralTag(format=SequenceFormat(elements=tags)) -@register_vllm_structural_tag("harmony") +@register_vllm_structural_tag("harmony", builtin_tools=True) def get_harmony_structural_tag( tools: list[FunctionToolParam], builtin_tools: list[BuiltinToolParam], diff --git a/vllm/tool_parsers/structural_tag_registry.py b/vllm/tool_parsers/structural_tag_registry.py index a1ee263dc2a2..1d3a7c4a4c7f 100644 --- a/vllm/tool_parsers/structural_tag_registry.py +++ b/vllm/tool_parsers/structural_tag_registry.py @@ -39,6 +39,7 @@ ChatCompletionToolsParam, ) from vllm.tool_parsers.tool_strict_level import ToolStrictLevel +from vllm.tool_parsers.utils import get_function_tools, require_function_tools ToolChoice: TypeAlias = ( Literal["none", "auto", "required"] @@ -85,20 +86,60 @@ ) _VLLM_STRUCTURAL_TAG_REGISTRY: dict[str, StructuralTagBuilder] = {} +_BUILTIN_TOOL_STRUCTURAL_TAG_MODELS: set[str] = set() def register_vllm_structural_tag( model: str, + *, + builtin_tools: bool = False, ) -> Callable[[StructuralTagBuilder], StructuralTagBuilder]: - """Register a vLLM-owned structural tag builder.""" + """Register a vLLM-owned structural tag builder. + + Args: + model: Structural tag model key. + builtin_tools: Whether the model can call non-function tools, so its + builder receives every request tool instead of only the function + tools from ``get_function_tools``. + + """ def decorator(func: StructuralTagBuilder) -> StructuralTagBuilder: _VLLM_STRUCTURAL_TAG_REGISTRY[model] = func + if builtin_tools: + _BUILTIN_TOOL_STRUCTURAL_TAG_MODELS.add(model) return func return decorator +def get_structural_tag_tools( + model: str, + tools: Sequence[ChatCompletionToolsParam | ResponsesTool], + tool_choice: ToolChoice, +) -> Sequence[ChatCompletionToolsParam | ResponsesTool]: + """Return the tools a structural tag for ``model`` lets the model call. + + Most models are only shown function tools, so their grammar must cover + exactly those: a tool missing from the prompt cannot be called, and one + missing from the grammar is shown but blocked. + + Raises: + VLLMValidationError: ``tool_choice`` requires a call, but the model + has no tool it can call. + + """ + if model in _BUILTIN_TOOL_STRUCTURAL_TAG_MODELS: + return tools + if ( + tool_choice is None + or tool_choice == "auto" + or getattr(tool_choice, "mode", None) == "auto" + ): + return get_function_tools(tools) + return require_function_tools(tools) + + def _tool_is_strict(tool: ChatCompletionToolsParam | ResponsesTool) -> bool: if isinstance(tool, FunctionTool): return tool.strict is True @@ -164,6 +205,9 @@ def get_model_structural_tag( if not tools or tool_choice == "none": return None + tools = get_structural_tag_tools(model, tools, tool_choice) + if not tools: + return None tools = resolve_tool_strictness(tools, tool_choice, strict_level) if tools is None: return None diff --git a/vllm/tool_parsers/utils.py b/vllm/tool_parsers/utils.py index a2992261daaf..03bcf1b652cc 100644 --- a/vllm/tool_parsers/utils.py +++ b/vllm/tool_parsers/utils.py @@ -6,6 +6,7 @@ import keyword as _python_keyword import math import warnings +from collections.abc import Sequence from dataclasses import dataclass from json import JSONDecodeError, JSONDecoder from typing import Any, TypeAlias @@ -29,6 +30,7 @@ ChatCompletionNamedToolChoiceParam, ChatCompletionToolsParam, ) +from vllm.exceptions import VLLMValidationError from vllm.logger import init_logger Tool: TypeAlias = ChatCompletionToolsParam | ResponsesTool @@ -179,43 +181,74 @@ def flat_namespace_tool_name(namespace: str, name: str) -> str: return f"{namespace}{_NAMESPACE_TOOL_SEPARATOR}{name}" +def get_function_tools( + tools: Sequence[Tool], +) -> list[ChatCompletionToolsParam | FunctionTool]: + """Return the function tools the model is shown, in request order. + + This is the single source for both the chat template's tool list and the + tools a tool-call grammar constrains, so the two cannot drift apart. + Namespace functions are flattened to ``__``; other + Responses tool types are never rendered for the model and are dropped. + """ + function_tools: list[ChatCompletionToolsParam | FunctionTool] = [] + for tool in tools: + if isinstance(tool, NamespaceTool): + function_tools.extend( + FunctionTool.model_validate( + { + **namespaced_tool.model_dump(by_alias=True), + "name": flat_namespace_tool_name( + tool.name, namespaced_tool.name + ), + } + ) + for namespaced_tool in tool.tools + if namespaced_tool.type == "function" + ) + elif isinstance(tool, (FunctionTool, ChatCompletionToolsParam)): + function_tools.append(tool) + return function_tools + + +def require_function_tools( + tools: Sequence[Tool], +) -> list[ChatCompletionToolsParam | FunctionTool]: + """Return ``get_function_tools(tools)``, rejecting a request that has none. + + Raises: + VLLMValidationError: ``tools`` has no tool the model can call, so a + tool choice that requires a call can never be satisfied. + + """ + function_tools = get_function_tools(tools) + if not function_tools: + raise VLLMValidationError( + "tool_choice requires a function call, but tools contains no " + "function tool the model can call.", + parameter="tool_choice", + ) + return function_tools + + def iter_response_function_tool_info( tool: ResponsesTool, ) -> list[tuple[str, dict[str, Any] | None]]: - if isinstance(tool, FunctionTool): - return [(tool.name, tool.parameters)] - if not isinstance(tool, NamespaceTool): - return [] - - namespace = tool.name return [ - ( - flat_namespace_tool_name(namespace, namespaced_tool.name), - namespaced_tool.parameters, - ) - for namespaced_tool in tool.tools - if namespaced_tool.type == "function" + (function_tool.name, function_tool.parameters) + for function_tool in get_function_tools([tool]) + if isinstance(function_tool, FunctionTool) ] def iter_response_function_tool_dicts( tools: list[ResponsesTool], ) -> list[dict[str, Any]]: - function_tools: list[dict[str, Any]] = [] - for tool in tools: - if isinstance(tool, NamespaceTool): - namespace = tool.name - for namespaced_tool in tool.tools: - if namespaced_tool.type != "function": - continue - tool_dict = namespaced_tool.model_dump() - tool_dict["name"] = flat_namespace_tool_name( - namespace, namespaced_tool.name - ) - function_tools.append(tool_dict) - elif isinstance(tool, FunctionTool): - function_tools.append(tool.model_dump()) - return function_tools + return [ + tool.model_dump() + for tool in get_function_tools(tools) + if isinstance(tool, FunctionTool) + ] def build_responses_tool_call_name_map( @@ -353,19 +386,8 @@ def _get_json_schema_from_tools( tools: list[Tool], parallel_tool_calls: bool | None = None, ) -> dict: - fn_tool_schemas: list[dict[str, Any]] = [] - fn_tools: list[Tool] = [] - for tool in tools: - if isinstance(tool, (FunctionTool, NamespaceTool)): - fn_tool_schemas.extend( - _get_tool_schema_from_name_and_params(name, params) - for name, params in iter_response_function_tool_info(tool) - ) - if isinstance(tool, FunctionTool): - fn_tools.append(tool) - elif _is_function_tool(tool): - fn_tool_schemas.append(_get_tool_schema_from_tool(tool)) - fn_tools.append(tool) + fn_tools: list[Tool] = list(require_function_tools(tools)) + fn_tool_schemas = [_get_tool_schema_from_tool(tool) for tool in fn_tools] json_schema: dict[str, Any] = { "type": "array", "minItems": 1,