261 lines
9.4 KiB
Python
261 lines
9.4 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
|
"""Regression tests for Responses API tool-calling request adjustment.
|
|
|
|
Covers two bugs on the ``/v1/responses`` path that broke streaming tool
|
|
calling for parsers relying on special-token delimiters (Gemma4):
|
|
|
|
1. :class:`Gemma4ToolParser.adjust_request` used an
|
|
``isinstance(request, ChatCompletionRequest)`` guard, so a
|
|
:class:`ResponsesRequest` with tools never had
|
|
``skip_special_tokens`` flipped to ``False``. The default (``True``)
|
|
stripped ``<|tool_call>`` / ``<tool_call|>`` delimiters, causing
|
|
:meth:`Gemma4ToolParser.extract_tool_calls_streaming` to fall through
|
|
to the content branch and leak the raw ``call:fn{...}`` body via
|
|
``response.output_text.delta``.
|
|
|
|
2. :meth:`ToolParser.adjust_request` built
|
|
:class:`ResponseTextConfig` in two steps (bare constructor then
|
|
``.format = ...``). Under Pydantic v2 the later assignment is not
|
|
tracked in ``__fields_set__``, which can drop the nested config from
|
|
``model_dump``. It also passed a ``description`` kwarg carrying the
|
|
wrong-purpose string ``"Response format for tool calling"``.
|
|
|
|
3. :class:`Gemma4EngineToolParser` (the engine-based parser, #45588) sets
|
|
``supports_required_and_named=False`` but did not skip the forced
|
|
``structured_outputs`` JSON for ``required``/named tool choice. The model
|
|
was constrained to JSON the native parser cannot read, so the call leaked
|
|
as content with empty ``tool_calls``. ``adjust_request`` now skips that
|
|
constraint so Gemma4 emits its native ``<|tool_call>`` syntax.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from openai.types.responses.tool_param import FunctionToolParam
|
|
|
|
from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
|
|
from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
|
|
from vllm.tool_parsers.abstract_tool_parser import ToolParser
|
|
from vllm.tool_parsers.gemma4_engine_tool_parser import (
|
|
Gemma4EngineToolParser as Gemma4ToolParser,
|
|
)
|
|
|
|
|
|
def _get_weather_tool() -> FunctionToolParam:
|
|
return FunctionToolParam(
|
|
type="function",
|
|
name="get_weather",
|
|
description="Get current weather for a city",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {"city": {"type": "string"}},
|
|
"required": ["city"],
|
|
},
|
|
strict=True,
|
|
)
|
|
|
|
|
|
def _build_responses_request(*, tool_choice: str | dict[str, Any]) -> ResponsesRequest:
|
|
return ResponsesRequest(
|
|
model="gemma4-test",
|
|
input=[{"role": "user", "content": "What is the weather in Hanoi?"}],
|
|
tools=[_get_weather_tool()],
|
|
tool_choice=tool_choice,
|
|
stream=True,
|
|
max_output_tokens=200,
|
|
)
|
|
|
|
|
|
def _build_chat_request(
|
|
*,
|
|
tool_choice: str | dict[str, Any],
|
|
chat_template_kwargs: dict[str, Any] | None = None,
|
|
) -> ChatCompletionRequest:
|
|
data: dict[str, Any] = {
|
|
"model": "gemma4-test",
|
|
"messages": [{"role": "user", "content": "What is the weather in Hanoi?"}],
|
|
"tools": [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "get_weather",
|
|
"description": "Get current weather for a city",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {"city": {"type": "string"}},
|
|
"required": ["city"],
|
|
},
|
|
},
|
|
}
|
|
],
|
|
"tool_choice": tool_choice,
|
|
}
|
|
if chat_template_kwargs is not None:
|
|
data["chat_template_kwargs"] = chat_template_kwargs
|
|
return ChatCompletionRequest.model_validate(data)
|
|
|
|
|
|
class _StubTokenizer:
|
|
"""Minimal tokenizer stub to satisfy ``Gemma4EngineToolParser.__init__``."""
|
|
|
|
_VOCAB: dict[str, int] = {
|
|
"<|tool_call>": 256_000,
|
|
"<tool_call|>": 256_001,
|
|
'<|"|>': 52,
|
|
"<|channel>": 256_002,
|
|
"<channel|>": 256_003,
|
|
}
|
|
|
|
def get_vocab(self) -> dict[str, int]:
|
|
return dict(self._VOCAB)
|
|
|
|
@property
|
|
def all_special_tokens(self) -> list[str]:
|
|
return list(self._VOCAB.keys())
|
|
|
|
@property
|
|
def all_special_ids(self) -> list[int]:
|
|
return list(self._VOCAB.values())
|
|
|
|
|
|
def test_gemma4_adjust_request_sets_skip_special_tokens_on_responses() -> None:
|
|
"""``Gemma4ToolParser.adjust_request`` must flip
|
|
``skip_special_tokens=False`` for both ``ChatCompletionRequest`` and
|
|
``ResponsesRequest`` so that ``<|tool_call>`` delimiters reach the
|
|
streaming extractor. The previous
|
|
``isinstance(ChatCompletionRequest)`` guard omitted the Responses
|
|
path, causing raw ``call:fn{...}`` text to leak via
|
|
``response.output_text.delta``.
|
|
"""
|
|
parser = Gemma4ToolParser(_StubTokenizer())
|
|
|
|
request = _build_responses_request(tool_choice="auto")
|
|
assert request.skip_special_tokens is True, (
|
|
"Precondition: ResponsesRequest.skip_special_tokens default is True"
|
|
)
|
|
|
|
parser.adjust_request(request)
|
|
|
|
assert request.skip_special_tokens is False
|
|
|
|
|
|
def test_tool_parser_adjust_request_builds_valid_response_text_config() -> None:
|
|
"""``ToolParser.adjust_request`` must produce a ``ResponseTextConfig``
|
|
whose dumped form contains the JSON schema under the ``schema`` alias
|
|
and does not leak the unrelated ``"Response format for tool calling"``
|
|
description string that the previous two-step construction injected.
|
|
"""
|
|
parser = ToolParser.__new__(ToolParser)
|
|
parser.model_tokenizer = None
|
|
|
|
request = _build_responses_request(tool_choice="required")
|
|
ToolParser.adjust_request(parser, request)
|
|
|
|
assert request.text is not None
|
|
assert request.text.format is not None
|
|
assert request.text.format.type == "json_schema"
|
|
|
|
dump: dict[str, Any] = request.text.model_dump(mode="json", by_alias=True)
|
|
fmt = dump.get("format") or {}
|
|
assert fmt.get("type") == "json_schema"
|
|
assert fmt.get("name") == "tool_calling_response"
|
|
assert fmt.get("strict") is True
|
|
# Nested config must be present under the alias. Two-step Pydantic v2
|
|
# construction could drop it from __fields_set__.
|
|
assert "schema" in fmt and isinstance(fmt["schema"], dict)
|
|
# The old code passed a wrong-purpose string; valid field should now
|
|
# either be absent or None (the openai-python default).
|
|
assert fmt.get("description") in (None, "")
|
|
|
|
|
|
def test_gemma4_required_skips_structured_outputs_chatcompletion() -> None:
|
|
"""required + ChatCompletion: ``Gemma4EngineToolParser`` must skip the
|
|
forced JSON ``structured_outputs`` so the model emits its native
|
|
``<|tool_call>`` syntax. The base parser constrained output to JSON the
|
|
native parser cannot read, leaking it as content with empty
|
|
``tool_calls`` (regression after #45588).
|
|
"""
|
|
parser = Gemma4ToolParser(_StubTokenizer())
|
|
request = _build_chat_request(tool_choice="required")
|
|
|
|
parser.adjust_request(request)
|
|
|
|
assert request.structured_outputs is None
|
|
assert request.skip_special_tokens is False
|
|
|
|
|
|
def test_gemma4_named_skips_structured_outputs_chatcompletion() -> None:
|
|
"""named + ChatCompletion: the forced single-function JSON schema must be
|
|
skipped, same as ``required``.
|
|
"""
|
|
parser = Gemma4ToolParser(_StubTokenizer())
|
|
request = _build_chat_request(
|
|
tool_choice={"type": "function", "function": {"name": "get_weather"}}
|
|
)
|
|
|
|
parser.adjust_request(request)
|
|
|
|
assert request.structured_outputs is None
|
|
assert request.skip_special_tokens is False
|
|
|
|
|
|
def test_gemma4_required_skips_structured_outputs_responses() -> None:
|
|
"""required + Responses: the forced JSON schema (``request.text``) must be
|
|
skipped so the native delimiters reach the extractor.
|
|
"""
|
|
parser = Gemma4ToolParser(_StubTokenizer())
|
|
request = _build_responses_request(tool_choice="required")
|
|
|
|
parser.adjust_request(request)
|
|
|
|
assert request.text is None
|
|
assert request.skip_special_tokens is False
|
|
|
|
|
|
def test_gemma4_named_skips_structured_outputs_responses() -> None:
|
|
"""named (``ToolChoiceFunction``) + Responses: the forced single-function
|
|
JSON schema must be skipped.
|
|
"""
|
|
parser = Gemma4ToolParser(_StubTokenizer())
|
|
request = _build_responses_request(
|
|
tool_choice={"type": "function", "name": "get_weather"}
|
|
)
|
|
|
|
parser.adjust_request(request)
|
|
|
|
assert request.text is None
|
|
assert request.skip_special_tokens is False
|
|
|
|
|
|
def test_gemma4_keeps_special_tokens_with_tools_thinking_disabled() -> None:
|
|
"""tools active + thinking disabled: ``skip_special_tokens`` must stay
|
|
False so ``<|tool_call>`` delimiters reach the extractor. The merged
|
|
enable_thinking early-return stripped them, breaking tool calling when
|
|
thinking is off.
|
|
"""
|
|
parser = Gemma4ToolParser(_StubTokenizer())
|
|
request = _build_chat_request(
|
|
tool_choice="auto", chat_template_kwargs={"enable_thinking": False}
|
|
)
|
|
|
|
parser.adjust_request(request)
|
|
|
|
assert request.skip_special_tokens is False
|
|
|
|
|
|
def test_gemma4_keeps_skip_special_tokens_false_when_nothing_to_preserve() -> None:
|
|
"""No active tools + thinking disabled: ``skip_special_tokens`` stays
|
|
``False`` because the parser engine's ``__DROP__`` terminal mechanism
|
|
strips unconfigured special tokens automatically.
|
|
"""
|
|
parser = Gemma4ToolParser(_StubTokenizer())
|
|
request = _build_chat_request(
|
|
tool_choice="none", chat_template_kwargs={"enable_thinking": False}
|
|
)
|
|
|
|
parser.adjust_request(request)
|
|
|
|
assert request.skip_special_tokens is False
|