chore: import upstream snapshot with attribution
pre-commit / pre-run-check (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:55:37 +08:00
commit 7ce4c8e27e
5900 changed files with 1668062 additions and 0 deletions
View File
+68
View File
@@ -0,0 +1,68 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import pytest
import pytest_asyncio
from huggingface_hub import snapshot_download
from tests.utils import RemoteOpenAIServer
from vllm.platforms import current_platform
from .utils import ARGS, CONFIGS, ServerConfig
# select models to test based on command line arguments
def pytest_addoption(parser):
parser.addoption("--models", nargs="+", help="Specify one or more models to test")
parser.addoption(
"--extended",
action="store_true",
default=False,
help="invoke extended tests requiring large GPUs",
)
# for each server config, download the model and return the config
@pytest.fixture(scope="session", params=CONFIGS.keys())
def server_config(request):
extended = request.config.getoption("--extended")
models = request.config.getoption("--models")
config_keys_to_test = [
key
for key in CONFIGS
if (models is None or key in models)
and (extended or not CONFIGS[key].get("extended", False))
]
config_key = request.param
if config_key not in config_keys_to_test:
pytest.skip(f"Skipping config '{config_key}'")
config = CONFIGS[config_key]
if current_platform.is_rocm() and not config.get("supports_rocm", True):
pytest.skip(
"The {} model can't be tested on the ROCm platform".format(config["model"])
)
# download model and tokenizer using transformers
snapshot_download(config["model"])
yield CONFIGS[request.param]
# run this for each server config
@pytest.fixture(scope="session")
def server(request, server_config: ServerConfig):
model = server_config["model"]
args_for_model = server_config["arguments"]
with RemoteOpenAIServer(
model, ARGS + args_for_model, max_wait_seconds=480
) as server:
yield server
@pytest_asyncio.fixture
async def client(server: RemoteOpenAIServer):
async with server.get_async_client() as async_client:
yield async_client
View File
+43
View File
@@ -0,0 +1,43 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import pytest
import pytest_asyncio
from huggingface_hub import snapshot_download
from tests.utils import RemoteOpenAIServer
from vllm.platforms import current_platform
from .utils import ARGS, CONFIGS, ServerConfig
# for each server config, download the model and return the config
@pytest.fixture(scope="package", params=CONFIGS.keys())
def server_config(request):
config = CONFIGS[request.param]
if current_platform.is_rocm() and not config.get("supports_rocm", True):
pytest.skip(
"The {} model can't be tested on the ROCm platform".format(config["model"])
)
# download model and tokenizer using transformers
snapshot_download(config["model"])
yield CONFIGS[request.param]
# run this for each server config
@pytest.fixture(scope="package")
def server(request, server_config: ServerConfig):
model = server_config["model"]
args_for_model = server_config["arguments"]
with RemoteOpenAIServer(
model, ARGS + args_for_model, max_wait_seconds=480
) as server:
yield server
@pytest_asyncio.fixture
async def client(server: RemoteOpenAIServer):
async with server.get_async_client() as async_client:
yield async_client
@@ -0,0 +1,507 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import json
from dataclasses import dataclass, field
import openai
import pytest
from tests.tool_use.utils import (
MESSAGES_ASKING_FOR_PARALLEL_TOOLS,
MESSAGES_ASKING_FOR_TOOLS,
MESSAGES_WITH_TOOL_RESPONSE,
MESSAGES_WITHOUT_TOOLS,
SEARCH_TOOL,
SEED,
WEATHER_TOOL,
ensure_system_prompt,
)
from .utils import ServerConfig
def _requires_tool_parser(server_config: ServerConfig) -> None:
r"""Skip test if server was not started with --tool-call-parser."""
if "--tool-call-parser" not in server_config.get("arguments", []):
pytest.skip(
f"Skipping: {server_config['model']} not configured with --tool-call-parser"
)
def _is_pre_v11(server_config: ServerConfig) -> bool:
r"""Pre-v11 Mistral models lack grammar-based tool call enforcement."""
return "7B" in server_config.get("model", "")
@dataclass
class StreamedToolCallResult:
r"""Accumulated result from streaming a single tool call."""
function_name: str | None = None
function_args_str: str = ""
tool_call_id: str | None = None
role_name: str | None = None
finish_reason_count: int = 0
finish_reason: str | None = None
async def _collect_streamed_tool_call(
stream: openai.AsyncStream,
*,
expected_finish_reason: str = "tool_calls",
) -> StreamedToolCallResult:
result = StreamedToolCallResult()
async for chunk in stream:
if chunk.choices[0].finish_reason:
result.finish_reason_count += 1
result.finish_reason = chunk.choices[0].finish_reason
assert chunk.choices[0].finish_reason == expected_finish_reason
if chunk.choices[0].delta.role:
assert not result.role_name or result.role_name == "assistant"
result.role_name = "assistant"
streamed_tool_calls = chunk.choices[0].delta.tool_calls
if streamed_tool_calls and len(streamed_tool_calls) > 0:
assert len(streamed_tool_calls) == 1
tool_call = streamed_tool_calls[0]
if tool_call.id:
assert not result.tool_call_id
result.tool_call_id = tool_call.id
if tool_call.function:
if tool_call.function.name:
assert result.function_name is None
result.function_name = tool_call.function.name
if tool_call.function.arguments:
result.function_args_str += tool_call.function.arguments
return result
@dataclass
class StreamedContentResult:
r"""Accumulated result from streaming a content-only response."""
chunks: list[str] = field(default_factory=list)
finish_reason_count: int = 0
finish_reason: str | None = None
role_sent: bool = False
async def _collect_streamed_content(
stream: openai.AsyncStream,
*,
expected_finish_reason: str | None = None,
no_tool_calls: bool = True,
) -> StreamedContentResult:
r"""Consume a streaming response and collect text content."""
result = StreamedContentResult()
async for chunk in stream:
delta = chunk.choices[0].delta
if delta.role:
assert not result.role_sent
assert delta.role == "assistant"
result.role_sent = True
if delta.content:
result.chunks.append(delta.content)
if chunk.choices[0].finish_reason is not None:
result.finish_reason_count += 1
result.finish_reason = chunk.choices[0].finish_reason
if expected_finish_reason is not None:
assert result.finish_reason == expected_finish_reason
if no_tool_calls:
assert not delta.tool_calls or len(delta.tool_calls) == 0
return result
@dataclass
class StreamedParallelToolCallResult:
r"""Accumulated result from streaming parallel tool calls."""
function_names: list[str] = field(default_factory=list)
function_args_strs: list[str] = field(default_factory=list)
tool_call_ids: list[str] = field(default_factory=list)
role_name: str | None = None
finish_reason_count: int = 0
async def _collect_streamed_parallel_tool_calls(
stream: openai.AsyncStream,
) -> StreamedParallelToolCallResult:
r"""Consume a streaming response and collect parallel tool calls."""
result = StreamedParallelToolCallResult()
tool_call_idx: int = -1
async for chunk in stream:
if chunk.choices[0].finish_reason:
result.finish_reason_count += 1
assert chunk.choices[0].finish_reason == "tool_calls"
if chunk.choices[0].delta.role:
assert not result.role_name or result.role_name == "assistant"
result.role_name = "assistant"
streamed_tool_calls = chunk.choices[0].delta.tool_calls
if streamed_tool_calls and len(streamed_tool_calls) > 0:
assert len(streamed_tool_calls) == 1
tool_call = streamed_tool_calls[0]
if tool_call.index != tool_call_idx:
tool_call_idx = tool_call.index
result.function_args_strs.append("")
result.tool_call_ids.append("")
if tool_call.id:
result.tool_call_ids[tool_call.index] = tool_call.id
if tool_call.function:
if tool_call.function.name:
result.function_names.append(tool_call.function.name)
if tool_call.function.arguments:
result.function_args_strs[tool_call.index] += (
tool_call.function.arguments
)
return result
# test: a tool_choice with mistral-tokenizer results in an ID of length 9
@pytest.mark.asyncio
async def test_tool_call_with_tool_choice(
client: openai.AsyncOpenAI, server_config: ServerConfig
) -> None:
_requires_tool_parser(server_config)
models = await client.models.list()
model_name: str = models.data[0].id
chat_completion = await client.chat.completions.create(
messages=ensure_system_prompt(MESSAGES_ASKING_FOR_TOOLS, server_config),
temperature=0,
max_completion_tokens=100,
model=model_name,
tools=[WEATHER_TOOL],
tool_choice=WEATHER_TOOL,
logprobs=False,
seed=SEED,
)
choice = chat_completion.choices[0]
assert choice.finish_reason != "tool_calls" # "stop" or "length"
assert choice.message.role == "assistant"
assert choice.message.tool_calls is None or len(choice.message.tool_calls) == 1
assert len(choice.message.tool_calls[0].id) == 9 # length of 9 for mistral
_NOT_SET = object()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"tools, tool_choice, streaming_id_len_pre_v11",
[
pytest.param(
[WEATHER_TOOL, SEARCH_TOOL],
_NOT_SET,
9,
id="auto",
),
pytest.param(
[WEATHER_TOOL],
"required",
30,
id="required",
),
],
)
async def test_tool_call_auto_or_required(
client: openai.AsyncOpenAI,
server_config: ServerConfig,
tools: list,
tool_choice: object,
streaming_id_len_pre_v11: int,
) -> None:
_requires_tool_parser(server_config)
models = await client.models.list()
model_name: str = models.data[0].id
create_kwargs: dict = {
"messages": ensure_system_prompt(MESSAGES_ASKING_FOR_TOOLS, server_config),
"temperature": 0,
"max_completion_tokens": 100,
"model": model_name,
"tools": tools,
"logprobs": False,
"seed": SEED,
}
if tool_choice is not _NOT_SET:
create_kwargs["tool_choice"] = tool_choice
# --- non-streaming ---
chat_completion = await client.chat.completions.create(**create_kwargs)
choice = chat_completion.choices[0]
tool_calls = choice.message.tool_calls
assert choice.finish_reason == "tool_calls"
assert tool_calls is not None and len(tool_calls) >= 1
assert tool_calls[0].function.name == "get_current_weather"
parsed_arguments = json.loads(tool_calls[0].function.arguments)
assert "city" in parsed_arguments
assert len(tool_calls[0].id) == 9
# --- streaming ---
stream = await client.chat.completions.create(**create_kwargs, stream=True)
result = await _collect_streamed_tool_call(stream)
assert result.finish_reason_count == 1
assert result.role_name == "assistant"
assert result.function_name == "get_current_weather"
streamed_args = json.loads(result.function_args_str)
assert isinstance(result.tool_call_id, str)
if _is_pre_v11(server_config):
assert len(result.tool_call_id) == streaming_id_len_pre_v11
else:
assert len(result.tool_call_id) == 9
assert parsed_arguments == streamed_args
@pytest.mark.asyncio
async def test_tool_call_none_with_tools(
client: openai.AsyncOpenAI, server_config: ServerConfig
) -> None:
_requires_tool_parser(server_config)
models = await client.models.list()
model_name: str = models.data[0].id
# --- non-streaming ---
chat_completion = await client.chat.completions.create(
messages=ensure_system_prompt(MESSAGES_ASKING_FOR_TOOLS, server_config),
temperature=0,
max_completion_tokens=100,
model=model_name,
tools=[WEATHER_TOOL],
tool_choice="none",
logprobs=False,
seed=SEED,
)
choice = chat_completion.choices[0]
assert choice.finish_reason != "tool_calls"
assert choice.message.tool_calls is None or len(choice.message.tool_calls) == 0
assert choice.message.content is not None
# Without grammar enforcement, pre-v11 models may still emit [TOOL_CALLS]
if not _is_pre_v11(server_config):
assert "[TOOL_CALLS]" not in choice.message.content
non_streaming_content = choice.message.content
# --- streaming ---
stream = await client.chat.completions.create(
messages=ensure_system_prompt(MESSAGES_ASKING_FOR_TOOLS, server_config),
temperature=0,
max_completion_tokens=100,
model=model_name,
tools=[WEATHER_TOOL],
tool_choice="none",
logprobs=False,
seed=SEED,
stream=True,
)
# Pre-v11 models lack grammar enforcement, so the model may still
# emit tool calls even with tool_choice="none".
pre_v11 = _is_pre_v11(server_config)
result = await _collect_streamed_content(stream, no_tool_calls=not pre_v11)
assert result.finish_reason_count == 1
if not pre_v11:
assert result.finish_reason != "tool_calls"
streamed_content = "".join(result.chunks)
if not pre_v11:
assert "[TOOL_CALLS]" not in streamed_content
assert streamed_content == non_streaming_content
@pytest.mark.asyncio
async def test_chat_without_tools(
client: openai.AsyncOpenAI, server_config: ServerConfig
) -> None:
models = await client.models.list()
model_name: str = models.data[0].id
# --- non-streaming ---
chat_completion = await client.chat.completions.create(
messages=ensure_system_prompt(MESSAGES_WITHOUT_TOOLS, server_config),
temperature=0,
max_completion_tokens=150,
model=model_name,
logprobs=False,
seed=SEED,
)
choice = chat_completion.choices[0]
output_text = choice.message.content
assert output_text is not None and len(output_text) > 0
assert choice.finish_reason != "tool_calls"
assert choice.message.tool_calls is None or len(choice.message.tool_calls) == 0
# --- streaming ---
stream = await client.chat.completions.create(
messages=ensure_system_prompt(MESSAGES_WITHOUT_TOOLS, server_config),
temperature=0,
max_completion_tokens=150,
model=model_name,
logprobs=False,
seed=SEED,
stream=True,
)
result = await _collect_streamed_content(
stream, expected_finish_reason=choice.finish_reason
)
assert result.role_sent
assert result.finish_reason_count == 1
assert len(result.chunks)
assert "".join(result.chunks) == output_text
@pytest.mark.asyncio
async def test_tool_call_with_results(
client: openai.AsyncOpenAI, server_config: ServerConfig
) -> None:
_requires_tool_parser(server_config)
models = await client.models.list()
model_name: str = models.data[0].id
# --- non-streaming ---
chat_completion = await client.chat.completions.create(
messages=ensure_system_prompt(MESSAGES_WITH_TOOL_RESPONSE, server_config),
temperature=0,
max_completion_tokens=100,
model=model_name,
tools=[WEATHER_TOOL, SEARCH_TOOL],
logprobs=False,
seed=SEED,
)
choice = chat_completion.choices[0]
assert choice.finish_reason != "tool_calls"
assert choice.message.tool_calls is None or len(choice.message.tool_calls) == 0
assert choice.message.content is not None
assert "98" in choice.message.content
# --- streaming ---
stream = await client.chat.completions.create(
messages=ensure_system_prompt(MESSAGES_WITH_TOOL_RESPONSE, server_config),
temperature=0,
max_completion_tokens=100,
model=model_name,
tools=[WEATHER_TOOL, SEARCH_TOOL],
logprobs=False,
seed=SEED,
stream=True,
)
result = await _collect_streamed_content(
stream, expected_finish_reason=choice.finish_reason
)
assert result.role_sent
assert result.finish_reason_count == 1
assert len(result.chunks)
assert "".join(result.chunks) == choice.message.content
def _requires_parallel(server_config: ServerConfig) -> None:
r"""Skip test if the model does not support parallel tool calls."""
if not server_config.get("supports_parallel"):
pytest.skip(
f"Skipping: {server_config['model']} does not support parallel tool calls"
)
@pytest.mark.asyncio
async def test_tool_call_parallel(
client: openai.AsyncOpenAI, server_config: ServerConfig
) -> None:
_requires_tool_parser(server_config)
_requires_parallel(server_config)
models = await client.models.list()
model_name: str = models.data[0].id
# --- non-streaming ---
chat_completion = await client.chat.completions.create(
messages=ensure_system_prompt(
MESSAGES_ASKING_FOR_PARALLEL_TOOLS, server_config
),
temperature=0,
max_completion_tokens=200,
model=model_name,
tools=[WEATHER_TOOL],
logprobs=False,
seed=SEED,
)
choice = chat_completion.choices[0]
tool_calls = choice.message.tool_calls
assert choice.finish_reason == "tool_calls"
assert tool_calls is not None and len(tool_calls) >= 2
for tc in tool_calls:
assert tc.type == "function"
assert tc.function.name == "get_current_weather"
assert isinstance(tc.function.arguments, str)
parsed = json.loads(tc.function.arguments)
assert "city" in parsed
assert len(tc.id) == 9
non_streaming_tool_calls = tool_calls
# --- streaming ---
stream = await client.chat.completions.create(
messages=ensure_system_prompt(
MESSAGES_ASKING_FOR_PARALLEL_TOOLS, server_config
),
temperature=0,
max_completion_tokens=200,
model=model_name,
tools=[WEATHER_TOOL],
logprobs=False,
seed=SEED,
stream=True,
)
result = await _collect_streamed_parallel_tool_calls(stream)
assert result.finish_reason_count == 1
assert result.role_name == "assistant"
assert len(result.function_names) >= 2
assert all(name == "get_current_weather" for name in result.function_names)
assert len(result.tool_call_ids) >= 2
assert all(isinstance(tid, str) and len(tid) == 9 for tid in result.tool_call_ids)
for args_str in result.function_args_strs:
streamed_args = json.loads(args_str)
assert "city" in streamed_args
assert len(result.function_names) == len(non_streaming_tool_calls)
+46
View File
@@ -0,0 +1,46 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from tests.tool_use.utils import ServerConfig
ARGS: list[str] = ["--max-model-len", "1024"]
CONFIGS: dict[str, ServerConfig] = {
"mistral": {
"model": "mistralai/Mistral-7B-Instruct-v0.3",
"arguments": [
"--tokenizer-mode",
"mistral",
"--tool-call-parser",
"mistral",
"--enable-auto-tool-choice",
"--enforce-eager",
"--no-enable-prefix-caching",
'--ignore-patterns="consolidated.safetensors"',
],
"system_prompt": "You are a helpful assistant with access to tools. If a tool"
" that you have would be helpful to answer a user query, "
"call the tool. Otherwise, answer the user's query directly "
"without calling a tool. DO NOT CALL A TOOL THAT IS IRRELEVANT "
"to the user's question - just respond to it normally.",
},
"ministral-3b": {
"model": "mistralai/Ministral-3-3B-Instruct-2512",
"arguments": [
"--tokenizer-mode",
"mistral",
"--tool-call-parser",
"mistral",
"--enable-auto-tool-choice",
"--enforce-eager",
"--no-enable-prefix-caching",
],
"system_prompt": "You are a helpful assistant with access to tools. If a tool"
" that you have would be helpful to answer a user query, "
"call the tool. Otherwise, answer the user's query directly "
"without calling a tool. DO NOT CALL A TOOL THAT IS IRRELEVANT "
"to the user's question - just respond to it normally.",
"supports_parallel": True,
},
}
@@ -0,0 +1,183 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import pytest
from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
def test_chat_completion_request_with_no_tools():
# tools key is not present
request = ChatCompletionRequest.model_validate(
{
"messages": [{"role": "user", "content": "Hello"}],
"model": "facebook/opt-125m",
}
)
assert request.tool_choice == "none"
# tools key is None
request = ChatCompletionRequest.model_validate(
{
"messages": [{"role": "user", "content": "Hello"}],
"model": "facebook/opt-125m",
"tools": None,
}
)
assert request.tool_choice == "none"
# tools key present but empty -- should be rejected
with pytest.raises(ValueError, match="must not be an empty array"):
ChatCompletionRequest.model_validate(
{
"messages": [{"role": "user", "content": "Hello"}],
"model": "facebook/opt-125m",
"tools": [],
}
)
@pytest.mark.parametrize("tool_choice", ["auto", "required"])
def test_chat_completion_request_with_tool_choice_but_no_tools(tool_choice):
with pytest.raises(
ValueError, match="When using `tool_choice`, `tools` must be set."
):
ChatCompletionRequest.model_validate(
{
"messages": [{"role": "user", "content": "Hello"}],
"model": "facebook/opt-125m",
"tool_choice": tool_choice,
}
)
with pytest.raises(
ValueError, match="When using `tool_choice`, `tools` must be set."
):
ChatCompletionRequest.model_validate(
{
"messages": [{"role": "user", "content": "Hello"}],
"model": "facebook/opt-125m",
"tool_choice": tool_choice,
"tools": None,
}
)
def test_reasoning_content_normalized_to_reasoning():
request = ChatCompletionRequest.model_validate(
{
"messages": [
{"role": "user", "content": "What is 2+2?"},
{
"role": "assistant",
"content": "4",
"reasoning_content": "2+2 equals 4",
},
{"role": "user", "content": "Are you sure?"},
],
"model": "facebook/opt-125m",
}
)
assistant_msg = request.messages[1]
assert assistant_msg.get("reasoning") == "2+2 equals 4"
assert "reasoning_content" not in assistant_msg
def test_reasoning_takes_precedence_over_reasoning_content():
request = ChatCompletionRequest.model_validate(
{
"messages": [
{"role": "user", "content": "What is 2+2?"},
{
"role": "assistant",
"content": "4",
"reasoning": "from reasoning field",
"reasoning_content": "from reasoning_content field",
},
],
"model": "facebook/opt-125m",
}
)
assistant_msg = request.messages[1]
assert assistant_msg.get("reasoning") == "from reasoning field"
assert "reasoning_content" not in assistant_msg
def test_no_reasoning_fields_unchanged():
request = ChatCompletionRequest.model_validate(
{
"messages": [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi"},
],
"model": "facebook/opt-125m",
}
)
assistant_msg = request.messages[1]
assert assistant_msg.get("reasoning") is None
assert "reasoning_content" not in assistant_msg
SAMPLE_TOOL = {
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the weather",
"parameters": {
"type": "object",
"properties": {"location": {"type": "string"}},
},
},
}
def test_structured_outputs_with_named_tool_choice_rejected():
"""structured_outputs cannot be combined with a named tool_choice."""
with pytest.raises(
ValueError,
match="structured outputs or tools, not both",
):
ChatCompletionRequest.model_validate(
{
"messages": [{"role": "user", "content": "Hello"}],
"model": "facebook/opt-125m",
"tools": [SAMPLE_TOOL],
"tool_choice": {
"type": "function",
"function": {"name": "get_weather"},
},
"structured_outputs": {"json": {"type": "object"}},
}
)
def test_structured_outputs_with_auto_tool_choice_allowed():
"""structured_outputs with tool_choice 'auto' should be allowed."""
request = ChatCompletionRequest.model_validate(
{
"messages": [{"role": "user", "content": "Hello"}],
"model": "facebook/opt-125m",
"tools": [SAMPLE_TOOL],
"tool_choice": "auto",
"structured_outputs": {"json": {"type": "object"}},
}
)
assert request.tool_choice == "auto"
def test_multiple_structured_outputs_rejected():
"""Only one kind of structured output constraint is allowed."""
with pytest.raises(
ValueError,
match="You can only use one kind of constraints",
):
ChatCompletionRequest.model_validate(
{
"messages": [{"role": "user", "content": "Hello"}],
"model": "facebook/opt-125m",
"structured_outputs": {
"json": {"type": "object"},
"regex": ".*",
},
}
)
+200
View File
@@ -0,0 +1,200 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import openai
import pytest
from .utils import (
MESSAGES_WITHOUT_TOOLS,
SEED,
WEATHER_TOOL,
ServerConfig,
ensure_system_prompt,
)
# test: make sure chat completions without tools provided work even when tools
# are enabled. This makes sure tool call chat templates work, AND that the tool
# parser stream processing doesn't change the output of the model.
@pytest.mark.asyncio
async def test_chat_completion_without_tools(
client: openai.AsyncOpenAI, server_config: ServerConfig
):
models = await client.models.list()
model_name: str = models.data[0].id
chat_completion = await client.chat.completions.create(
messages=ensure_system_prompt(MESSAGES_WITHOUT_TOOLS, server_config),
temperature=0,
max_completion_tokens=150,
model=model_name,
logprobs=False,
seed=SEED,
)
choice = chat_completion.choices[0]
stop_reason = chat_completion.choices[0].finish_reason
output_text = chat_completion.choices[0].message.content
# check to make sure we got text
assert output_text is not None
assert len(output_text) > 0
assert stop_reason != "tool_calls"
# check to make sure no tool calls were returned
assert choice.message.tool_calls is None or len(choice.message.tool_calls) == 0
# make the same request, streaming
stream = await client.chat.completions.create(
messages=ensure_system_prompt(MESSAGES_WITHOUT_TOOLS, server_config),
temperature=0,
max_completion_tokens=150,
model=model_name,
logprobs=False,
seed=SEED,
stream=True,
)
chunks: list[str] = []
finish_reason_count = 0
role_sent: bool = False
# assemble streamed chunks
async for chunk in stream:
delta = chunk.choices[0].delta
# make sure the role is assistant
if delta.role:
assert not role_sent
assert delta.role == "assistant"
role_sent = True
if delta.content:
chunks.append(delta.content)
if chunk.choices[0].finish_reason is not None:
finish_reason_count += 1
assert chunk.choices[0].finish_reason == choice.finish_reason
# make sure tool call chunks aren't being streamed
assert not delta.tool_calls or len(delta.tool_calls) == 0
# make sure the role was sent, only 1 finish reason was sent, that chunks
# were in fact sent, and that the chunks match non-streaming
assert role_sent
assert finish_reason_count == 1
assert len(chunks)
assert "".join(chunks) == output_text
# test: conversation with tools enabled and provided that should not invoke
# tools, to make sure we can still get normal chat completion responses
# and that they won't be parsed as tools
@pytest.mark.asyncio
async def test_chat_completion_with_tools(
client: openai.AsyncOpenAI, server_config: ServerConfig
):
models = await client.models.list()
model_name: str = models.data[0].id
chat_completion = await client.chat.completions.create(
messages=ensure_system_prompt(MESSAGES_WITHOUT_TOOLS, server_config),
temperature=0,
max_completion_tokens=150,
model=model_name,
tools=[WEATHER_TOOL],
logprobs=False,
seed=SEED,
)
choice = chat_completion.choices[0]
stop_reason = chat_completion.choices[0].finish_reason
output_text = chat_completion.choices[0].message.content
# check to make sure we got text
assert output_text is not None
assert stop_reason != "tool_calls"
assert len(output_text) > 0
# check to make sure no tool calls were returned
assert choice.message.tool_calls is None or len(choice.message.tool_calls) == 0
# make the same request, streaming
stream = await client.chat.completions.create(
messages=ensure_system_prompt(MESSAGES_WITHOUT_TOOLS, server_config),
temperature=0,
max_completion_tokens=150,
model=model_name,
logprobs=False,
tools=[WEATHER_TOOL],
seed=SEED,
stream=True,
)
chunks: list[str] = []
finish_reason_count = 0
role_sent: bool = False
# assemble streamed chunks
async for chunk in stream:
delta = chunk.choices[0].delta
# make sure the role is assistant
if delta.role:
assert delta.role == "assistant"
role_sent = True
if delta.content:
chunks.append(delta.content)
if chunk.choices[0].finish_reason is not None:
finish_reason_count += 1
# make sure tool call chunks aren't being streamed
assert not delta.tool_calls or len(delta.tool_calls) == 0
# make sure the role was sent, only 1 finish reason was sent, that chunks
# were in fact sent, and that the chunks match non-streaming
assert role_sent
assert finish_reason_count == 1
assert chunk.choices[0].finish_reason == stop_reason
assert chunk.choices[0].finish_reason != "tool_calls"
assert len(chunks)
assert "".join(chunks) == output_text
# Regression test for https://github.com/vllm-project/vllm/issues/32006
# Engine crash when combining response_format: json_object with
# tool_choice: required
@pytest.mark.asyncio
@pytest.mark.timeout(120)
async def test_response_format_with_tool_choice_required(
client: openai.AsyncOpenAI, server_config: ServerConfig
):
"""
Test that combining response_format: json_object with tool_choice: required
doesn't crash the engine.
Before the fix, this would cause a validation error:
"You can only use one kind of structured outputs constraint but multiple
are specified" because both json_object and json (from tool schema) would
be set in StructuredOutputsParams.
"""
models = await client.models.list()
model_name: str = models.data[0].id
# This combination previously crashed the engine
chat_completion = await client.chat.completions.create(
messages=ensure_system_prompt(
[{"role": "user", "content": "What is the weather in Dallas, Texas?"}],
server_config,
),
temperature=0,
max_completion_tokens=150,
model=model_name,
tools=[WEATHER_TOOL],
tool_choice="required",
response_format={"type": "json_object"},
)
# The fix clears response_format when tool_choice forces tool calling,
# so the request should complete successfully with tool calls
choice = chat_completion.choices[0]
assert choice.finish_reason == "tool_calls"
assert choice.message.tool_calls is not None
assert len(choice.message.tool_calls) > 0
@@ -0,0 +1,260 @@
# 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
+297
View File
@@ -0,0 +1,297 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import json
import openai
import pytest
from .utils import (
MESSAGES_ASKING_FOR_PARALLEL_TOOLS,
MESSAGES_WITH_PARALLEL_TOOL_RESPONSE,
SEARCH_TOOL,
SEED,
WEATHER_TOOL,
ServerConfig,
ensure_system_prompt,
)
def apply_parallel_tool_system_prompt(
messages,
server_config: ServerConfig,
):
if server_config["model"] == "ibm-granite/granite-3.0-8b-instruct":
return ensure_system_prompt(messages, server_config)
return messages
# test: getting the model to generate parallel tool calls (streaming/not)
# when requested. NOTE that not all models may support this, so some exclusions
# may be added in the future. e.g. llama 3.1 models are not designed to support
# parallel tool calls.
@pytest.mark.asyncio
async def test_parallel_tool_calls(
client: openai.AsyncOpenAI, server_config: ServerConfig
):
if not server_config.get("supports_parallel", True):
pytest.skip(
"The {} model doesn't support parallel tool calls".format(
server_config["model"]
)
)
models = await client.models.list()
model_name: str = models.data[0].id
messages = apply_parallel_tool_system_prompt(
MESSAGES_ASKING_FOR_PARALLEL_TOOLS, server_config
)
chat_completion = await client.chat.completions.create(
messages=messages,
temperature=0,
max_completion_tokens=200,
model=model_name,
tools=[WEATHER_TOOL, SEARCH_TOOL],
logprobs=False,
seed=SEED,
)
choice = chat_completion.choices[0]
stop_reason = chat_completion.choices[0].finish_reason
non_streamed_tool_calls = chat_completion.choices[0].message.tool_calls
# make sure 2 tool calls are present
assert choice.message.role == "assistant"
assert non_streamed_tool_calls is not None
assert len(non_streamed_tool_calls) == 2
for tool_call in non_streamed_tool_calls:
# make sure the tool includes a function and ID
assert tool_call.type == "function"
assert tool_call.function is not None
assert isinstance(tool_call.id, str)
assert len(tool_call.id) >= 9
# make sure the weather tool was called correctly
assert tool_call.function.name == WEATHER_TOOL["function"]["name"]
assert isinstance(tool_call.function.arguments, str)
parsed_arguments = json.loads(tool_call.function.arguments)
assert isinstance(parsed_arguments, dict)
assert isinstance(parsed_arguments.get("city"), str)
assert isinstance(parsed_arguments.get("state"), str)
assert stop_reason == "tool_calls"
# make the same request, streaming
stream = await client.chat.completions.create(
model=model_name,
messages=messages,
temperature=0,
max_completion_tokens=200,
tools=[WEATHER_TOOL, SEARCH_TOOL],
logprobs=False,
seed=SEED,
stream=True,
)
role_name: str | None = None
finish_reason_count: int = 0
tool_call_names: list[str] = []
tool_call_args: list[str] = []
tool_call_idx: int = -1
tool_call_id_count: int = 0
async for chunk in stream:
# if there's a finish reason make sure it's tools
if chunk.choices[0].finish_reason:
finish_reason_count += 1
assert chunk.choices[0].finish_reason == "tool_calls"
# if a role is being streamed make sure it wasn't already set to
# something else
if chunk.choices[0].delta.role:
assert not role_name or role_name == "assistant"
role_name = "assistant"
# a chunk may carry >1 tool-call delta at a parallel-call boundary
streamed_tool_calls = chunk.choices[0].delta.tool_calls
for tool_call in streamed_tool_calls or []:
# deltas arrive in non-decreasing index order
assert tool_call.index >= tool_call_idx
# if a new tool is being called, set up empty arguments
if tool_call.index != tool_call_idx:
tool_call_idx = tool_call.index
tool_call_args.append("")
# if a tool call ID is streamed, make sure one hasn't been already
if tool_call.id:
tool_call_id_count += 1
assert isinstance(tool_call.id, str) and (len(tool_call.id) >= 9)
# if parts of the function start being streamed
if tool_call.function:
# if the function name is defined, set it. it should be streamed
# IN ENTIRETY, exactly one time.
if tool_call.function.name:
assert isinstance(tool_call.function.name, str)
tool_call_names.append(tool_call.function.name)
if tool_call.function.arguments:
# make sure they're a string and then add them to the list
assert isinstance(tool_call.function.arguments, str)
tool_call_args[tool_call.index] += tool_call.function.arguments
assert finish_reason_count == 1
assert role_name == "assistant"
assert len(non_streamed_tool_calls) == len(tool_call_names) == len(tool_call_args)
for i in range(2):
assert non_streamed_tool_calls[i].function.name == tool_call_names[i]
streamed_args = json.loads(tool_call_args[i])
non_streamed_args = json.loads(non_streamed_tool_calls[i].function.arguments)
assert streamed_args == non_streamed_args
# test: providing parallel tool calls back to the model to get a response
# (streaming/not)
@pytest.mark.asyncio
async def test_parallel_tool_calls_with_results(
client: openai.AsyncOpenAI, server_config: ServerConfig
):
if not server_config.get("supports_parallel", True):
pytest.skip(
"The {} model doesn't support parallel tool calls".format(
server_config["model"]
)
)
models = await client.models.list()
model_name: str = models.data[0].id
messages = apply_parallel_tool_system_prompt(
MESSAGES_WITH_PARALLEL_TOOL_RESPONSE, server_config
)
chat_completion = await client.chat.completions.create(
messages=messages,
temperature=0,
max_completion_tokens=200,
model=model_name,
tools=[WEATHER_TOOL, SEARCH_TOOL],
logprobs=False,
seed=SEED,
)
choice = chat_completion.choices[0]
assert choice.finish_reason != "tool_calls" # "stop" or "length"
assert choice.message.role == "assistant"
assert choice.message.tool_calls is None or len(choice.message.tool_calls) == 0
assert choice.message.content is not None
assert "98" in choice.message.content # Dallas temp in tool response
assert "78" in choice.message.content # Orlando temp in tool response
stream = await client.chat.completions.create(
messages=messages,
temperature=0,
max_completion_tokens=200,
model=model_name,
tools=[WEATHER_TOOL, SEARCH_TOOL],
logprobs=False,
seed=SEED,
stream=True,
)
chunks: list[str] = []
finish_reason_count = 0
role_sent: bool = False
async for chunk in stream:
delta = chunk.choices[0].delta
if delta.role:
assert not role_sent
assert delta.role == "assistant"
role_sent = True
if delta.content:
chunks.append(delta.content)
if chunk.choices[0].finish_reason is not None:
finish_reason_count += 1
assert chunk.choices[0].finish_reason == choice.finish_reason
assert not delta.tool_calls or len(delta.tool_calls) == 0
assert role_sent
assert finish_reason_count == 1
assert len(chunks)
assert "".join(chunks) == choice.message.content
@pytest.mark.asyncio
async def test_parallel_tool_calls_false(
client: openai.AsyncOpenAI, server_config: ServerConfig
):
"""
Ensure only one tool call is returned when parallel_tool_calls is False.
"""
models = await client.models.list()
model_name: str = models.data[0].id
messages = apply_parallel_tool_system_prompt(
MESSAGES_ASKING_FOR_PARALLEL_TOOLS, server_config
)
chat_completion = await client.chat.completions.create(
messages=messages,
temperature=0,
max_completion_tokens=200,
model=model_name,
tools=[WEATHER_TOOL, SEARCH_TOOL],
logprobs=False,
seed=SEED,
parallel_tool_calls=False,
)
stop_reason = chat_completion.choices[0].finish_reason
non_streamed_tool_calls = chat_completion.choices[0].message.tool_calls
# make sure only 1 tool call is present
assert len(non_streamed_tool_calls) == 1
assert stop_reason == "tool_calls"
# make the same request, streaming
stream = await client.chat.completions.create(
model=model_name,
messages=messages,
temperature=0,
max_completion_tokens=200,
tools=[WEATHER_TOOL, SEARCH_TOOL],
logprobs=False,
seed=SEED,
parallel_tool_calls=False,
stream=True,
)
finish_reason_count: int = 0
tool_call_id_count: int = 0
async for chunk in stream:
# if there's a finish reason make sure it's tools
if chunk.choices[0].finish_reason:
finish_reason_count += 1
assert chunk.choices[0].finish_reason == "tool_calls"
streamed_tool_calls = chunk.choices[0].delta.tool_calls
if streamed_tool_calls and len(streamed_tool_calls) > 0:
tool_call = streamed_tool_calls[0]
if tool_call.id:
tool_call_id_count += 1
# make sure only 1 streaming tool call is present
assert tool_call_id_count == 1
assert finish_reason_count == 1
@@ -0,0 +1,184 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import pytest
from pydantic import ValidationError
from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
SAMPLE_TOOL = {
"type": "function",
"name": "get_weather",
"description": "Get current weather",
"parameters": {
"type": "object",
"properties": {"location": {"type": "string", "description": "City name"}},
"required": ["location"],
},
}
NAMED_TOOL_CHOICE = {
"type": "function",
"name": "get_weather",
}
def test_responses_request_with_no_tools():
# tools key is not present — defaults tool_choice to "none"
request = ResponsesRequest.model_validate({"input": "Hello", "model": "test-model"})
assert request.tool_choice == "none"
# tools key present but empty
request = ResponsesRequest.model_validate(
{"input": "Hello", "model": "test-model", "tools": []}
)
assert request.tool_choice == "none"
def test_responses_request_no_tools_tool_choice_none():
request = ResponsesRequest.model_validate(
{"input": "Hello", "model": "test-model", "tool_choice": "none"}
)
assert request.tool_choice == "none"
def test_responses_request_no_tools_tool_choice_auto():
request = ResponsesRequest.model_validate(
{"input": "Hello", "model": "test-model", "tool_choice": "auto"}
)
assert request.tool_choice == "none"
@pytest.mark.parametrize("tools", [None, []])
def test_responses_request_required_without_tools(tools):
kwargs = {"input": "Hello", "model": "test-model", "tool_choice": "required"}
if tools is not None:
kwargs["tools"] = tools
with pytest.raises(
ValidationError, match="Tool choice 'required' must be specified"
):
ResponsesRequest.model_validate(kwargs)
def test_responses_request_named_tool_choice_without_tools():
with pytest.raises(ValidationError, match="not found in 'tools' parameter"):
ResponsesRequest.model_validate(
{
"input": "Hello",
"model": "test-model",
"tool_choice": NAMED_TOOL_CHOICE,
}
)
def test_responses_request_with_tools_default_tool_choice():
request = ResponsesRequest.model_validate(
{"input": "Hello", "model": "test-model", "tools": [SAMPLE_TOOL]}
)
assert request.tool_choice == "auto"
def test_responses_request_with_tools_tool_choice_none():
request = ResponsesRequest.model_validate(
{
"input": "Hello",
"model": "test-model",
"tools": [SAMPLE_TOOL],
"tool_choice": "none",
}
)
assert request.tool_choice == "none"
def test_responses_request_named_tool_choice_matching():
request = ResponsesRequest.model_validate(
{
"input": "Hello",
"model": "test-model",
"tools": [SAMPLE_TOOL],
"tool_choice": NAMED_TOOL_CHOICE,
}
)
assert request.tool_choice.type == "function"
assert request.tool_choice.name == "get_weather"
def test_responses_request_named_tool_choice_not_matching():
with pytest.raises(ValidationError, match="not found in 'tools' parameter"):
ResponsesRequest.model_validate(
{
"input": "Hello",
"model": "test-model",
"tools": [SAMPLE_TOOL],
"tool_choice": {"type": "function", "name": "nonexistent"},
}
)
def test_responses_request_with_tools_tool_choice_auto():
request = ResponsesRequest.model_validate(
{
"input": "Hello",
"model": "test-model",
"tools": [SAMPLE_TOOL],
"tool_choice": "auto",
}
)
assert request.tool_choice == "auto"
def test_responses_request_with_tools_tool_choice_required():
request = ResponsesRequest.model_validate(
{
"input": "Hello",
"model": "test-model",
"tools": [SAMPLE_TOOL],
"tool_choice": "required",
}
)
assert request.tool_choice == "required"
def test_responses_request_empty_tools_tool_choice_none():
request = ResponsesRequest.model_validate(
{"input": "Hello", "model": "test-model", "tools": [], "tool_choice": "none"}
)
assert request.tool_choice == "none"
def test_responses_request_empty_tools_tool_choice_auto():
request = ResponsesRequest.model_validate(
{"input": "Hello", "model": "test-model", "tools": [], "tool_choice": "auto"}
)
assert request.tool_choice == "none"
@pytest.mark.parametrize(
"tool_choice",
[
{"type": "function"},
{"type": "function", "name": ""},
],
)
def test_responses_request_named_tool_choice_missing_name(tool_choice):
with pytest.raises(ValidationError, match="not found in 'tools' parameter"):
ResponsesRequest.model_validate(
{
"input": "Hello",
"model": "test-model",
"tools": [SAMPLE_TOOL],
"tool_choice": tool_choice,
}
)
def test_responses_request_empty_tools_named_tool_choice():
with pytest.raises(ValidationError, match="not found in 'tools' parameter"):
ResponsesRequest.model_validate(
{
"input": "Hello",
"model": "test-model",
"tools": [],
"tool_choice": NAMED_TOOL_CHOICE,
}
)
+211
View File
@@ -0,0 +1,211 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import json
import openai
import pytest
from .utils import (
MESSAGES_ASKING_FOR_TOOLS,
MESSAGES_WITH_TOOL_RESPONSE,
SEARCH_TOOL,
SEED,
WEATHER_TOOL,
ServerConfig,
ensure_system_prompt,
)
# test: request a chat completion that should return tool calls, so we know they
# are parsable
@pytest.mark.asyncio
async def test_tool_call_and_choice(
client: openai.AsyncOpenAI, server_config: ServerConfig
):
models = await client.models.list()
model_name: str = models.data[0].id
messages = ensure_system_prompt(MESSAGES_ASKING_FOR_TOOLS, server_config)
chat_completion = await client.chat.completions.create(
messages=messages,
temperature=0,
max_completion_tokens=100,
model=model_name,
tools=[WEATHER_TOOL, SEARCH_TOOL],
logprobs=False,
seed=SEED,
)
choice = chat_completion.choices[0]
stop_reason = chat_completion.choices[0].finish_reason
tool_calls = chat_completion.choices[0].message.tool_calls
# make sure a tool call is present
assert choice.message.role == "assistant"
assert tool_calls is not None
assert len(tool_calls) == 1
assert tool_calls[0].type == "function"
assert tool_calls[0].function is not None
assert isinstance(tool_calls[0].id, str)
assert len(tool_calls[0].id) >= 9
# make sure the weather tool was called (classic example) with arguments
assert tool_calls[0].function.name == WEATHER_TOOL["function"]["name"]
assert tool_calls[0].function.arguments is not None
assert isinstance(tool_calls[0].function.arguments, str)
# make sure the arguments parse properly
parsed_arguments = json.loads(tool_calls[0].function.arguments)
assert isinstance(parsed_arguments, dict)
assert isinstance(parsed_arguments.get("city"), str)
assert isinstance(parsed_arguments.get("state"), str)
assert parsed_arguments.get("city") == "Dallas"
assert parsed_arguments.get("state") == "TX"
assert stop_reason == "tool_calls"
function_name: str | None = None
function_args_str: str = ""
tool_call_id: str | None = None
role_name: str | None = None
finish_reason_count: int = 0
# make the same request, streaming
stream = await client.chat.completions.create(
model=model_name,
messages=messages,
temperature=0,
max_completion_tokens=100,
tools=[WEATHER_TOOL, SEARCH_TOOL],
logprobs=False,
seed=SEED,
stream=True,
)
async for chunk in stream:
assert chunk.choices[0].index == 0
if chunk.choices[0].finish_reason:
finish_reason_count += 1
assert chunk.choices[0].finish_reason == "tool_calls"
# if a role is being streamed make sure it wasn't already set to
# something else
if chunk.choices[0].delta.role:
assert not role_name or role_name == "assistant"
role_name = "assistant"
# if a tool call is streamed make sure there's exactly one
# (based on the request parameters
streamed_tool_calls = chunk.choices[0].delta.tool_calls
if streamed_tool_calls and len(streamed_tool_calls) > 0:
assert len(streamed_tool_calls) == 1
tool_call = streamed_tool_calls[0]
# if a tool call ID is streamed, make sure one hasn't been already
if tool_call.id:
assert not tool_call_id
tool_call_id = tool_call.id
# if parts of the function start being streamed
if tool_call.function:
# if the function name is defined, set it. it should be streamed
# IN ENTIRETY, exactly one time.
if tool_call.function.name:
assert function_name is None
assert isinstance(tool_call.function.name, str)
function_name = tool_call.function.name
if tool_call.function.arguments:
assert isinstance(tool_call.function.arguments, str)
function_args_str += tool_call.function.arguments
assert finish_reason_count == 1
assert role_name == "assistant"
assert isinstance(tool_call_id, str) and (len(tool_call_id) >= 9)
# validate the name and arguments
assert function_name == WEATHER_TOOL["function"]["name"]
assert function_name == tool_calls[0].function.name
assert isinstance(function_args_str, str)
# validate arguments
streamed_args = json.loads(function_args_str)
assert isinstance(streamed_args, dict)
assert isinstance(streamed_args.get("city"), str)
assert isinstance(streamed_args.get("state"), str)
assert streamed_args.get("city") == "Dallas"
assert streamed_args.get("state") == "TX"
# make sure everything matches non-streaming except for ID
assert function_name == tool_calls[0].function.name
assert choice.message.role == role_name
assert choice.message.tool_calls[0].function.name == function_name
# compare streamed with non-streamed args dict-wise, not string-wise
# because character-to-character comparison might not work e.g. the tool
# call parser adding extra spaces or something like that. we care about the
# dicts matching not byte-wise match
assert parsed_arguments == streamed_args
# test: providing tools and results back to model to get a non-tool response
# (streaming/not)
@pytest.mark.asyncio
async def test_tool_call_with_results(client: openai.AsyncOpenAI):
models = await client.models.list()
model_name: str = models.data[0].id
chat_completion = await client.chat.completions.create(
messages=MESSAGES_WITH_TOOL_RESPONSE,
temperature=0,
max_completion_tokens=100,
model=model_name,
tools=[WEATHER_TOOL, SEARCH_TOOL],
logprobs=False,
seed=SEED,
)
choice = chat_completion.choices[0]
assert choice.finish_reason != "tool_calls" # "stop" or "length"
assert choice.message.role == "assistant"
assert choice.message.tool_calls is None or len(choice.message.tool_calls) == 0
assert choice.message.content is not None
assert "98" in choice.message.content # the temperature from the response
stream = await client.chat.completions.create(
messages=MESSAGES_WITH_TOOL_RESPONSE,
temperature=0,
max_completion_tokens=100,
model=model_name,
tools=[WEATHER_TOOL, SEARCH_TOOL],
logprobs=False,
seed=SEED,
stream=True,
)
chunks: list[str] = []
finish_reason_count = 0
role_sent: bool = False
async for chunk in stream:
delta = chunk.choices[0].delta
if delta.role:
assert not role_sent
assert delta.role == "assistant"
role_sent = True
if delta.content:
chunks.append(delta.content)
if chunk.choices[0].finish_reason is not None:
finish_reason_count += 1
assert chunk.choices[0].finish_reason == choice.finish_reason
assert not delta.tool_calls or len(delta.tool_calls) == 0
assert role_sent
assert finish_reason_count == 1
assert len(chunks)
assert "".join(chunks) == choice.message.content
+396
View File
@@ -0,0 +1,396 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import json
from copy import deepcopy
import pytest
import regex as re
from openai.types.responses import FunctionTool, WebSearchTool
from pydantic import TypeAdapter
from vllm.entrypoints.openai.chat_completion.protocol import (
ChatCompletionToolsParam,
)
from vllm.tool_parsers.streaming import extract_required_tool_call_streaming
from vllm.tool_parsers.utils import (
find_tool_properties,
get_json_schema_from_tools,
)
pytestmark = pytest.mark.cpu_test
EXAMPLE_TOOLS = [
{
"type": "function",
"function": {
"name": "get_current_weather",
"description": "Get the current weather in a given location",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "The city to find the weather for"
", e.g. 'San Francisco'",
},
},
"required": ["city"],
"additionalProperties": False,
},
},
"strict": True,
},
{
"type": "function",
"function": {
"name": "get_forecast",
"description": "Get the weather forecast for a given location",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "The city to get the forecast for, e.g. "
"'New York'",
},
"days": {
"type": "integer",
"description": "Number of days to get the forecast for (1-7)",
},
},
"required": ["city", "days"],
"additionalProperties": False,
},
},
"strict": True,
},
]
def _compile_and_check(
tools: list[ChatCompletionToolsParam], sample_output, should_match: bool
):
# self = MagicMock(tool_choice="required", tools=tools)
# schema = ChatCompletionRequest._get_json_schema_from_tool(self)
schema = get_json_schema_from_tools(tools=tools, tool_choice="required")
assert isinstance(schema, dict)
# use build_regex_from_schema used in JSONLogitsProcessor to create Guide
from outlines_core.json_schema import build_regex_from_schema
regex = build_regex_from_schema(json.dumps(schema))
compiled = re.compile(regex)
matches = compiled.fullmatch(json.dumps(sample_output)) is not None
assert matches == should_match
VALID_TOOL_OUTPUTS = [
([{"name": "get_current_weather", "parameters": {"city": "Vienna"}}], True),
(
[
{"name": "get_current_weather", "parameters": {"city": "Vienna"}},
{"name": "get_current_weather", "parameters": {"city": "Berlin"}},
],
True,
),
([{"name": "get_forecast", "parameters": {"city": "Vienna", "days": 7}}], True),
(
[
{"name": "get_forecast", "parameters": {"city": "Vienna", "days": 7}},
{"name": "get_current_weather", "parameters": {"city": "Vienna"}},
],
True,
),
(
[
{"name": "get_forecast", "parameters": {"city": "Vienna", "days": 7}},
{"name": "get_current_weather", "parameters": {"city": "Vienna"}},
{"name": "get_forecast", "parameters": {"city": "Berlin", "days": 7}},
{"name": "get_current_weather", "parameters": {"city": "Berlin"}},
],
True,
),
]
VALID_TOOLS = [t[0] for t in VALID_TOOL_OUTPUTS]
@pytest.mark.parametrize(
"sample_output, should_match",
VALID_TOOL_OUTPUTS
+ [
(None, False),
([], False), # empty list cannot be generated
({}, False), # empty object cannot be generated
([{}], False), # list with empty object cannot be generated
(
[
{ # function without required parameters cannot be generated
"name": "get_current_weather"
}
],
False,
),
(
[
{ # function without required parameters cannot be generated
"name": "get_current_weather",
"parameters": {},
}
],
False,
),
(
[
{ # function without required parameters cannot be generated
"name": "get_current_weather",
"parameters": None,
}
],
False,
),
(
{ # tool call without lists cannot be generated
"name": "get_current_weather",
"parameters": {"city": "Vienna"},
},
False,
),
(
[
{ # tool call with extra parameters cannot be generated
"name": "get_current_weather",
"parameters": {"city": "Vienna", "extra": "value"},
}
],
False,
),
(
[
{ # tool call where parameters are first cannot be generated
"parameters": {"city": "Vienna"},
"name": "get_current_weather",
}
],
False,
),
(
[
{ # tool call without all required parameters cannot be generated
"name": "get_forecast",
"parameters": {"city": "Vienna"},
}
],
False,
),
( # tool call with incorrect name/parameters cannot be generated
[{"name": "get_weather", "parameters": {"city": "Vienna", "days": 7}}],
False,
),
( # tool call with both valid and empty function cannot be generated
[{"name": "get_current_weather", "parameters": {"city": "Vienna"}}, {}],
False,
),
],
)
def test_structured_outputs_json(sample_output, should_match):
_compile_and_check(
tools=TypeAdapter(list[ChatCompletionToolsParam]).validate_python(
EXAMPLE_TOOLS
),
sample_output=sample_output,
should_match=should_match,
)
def update_parameters_none(tool: ChatCompletionToolsParam) -> ChatCompletionToolsParam:
tool.function.parameters = None
return tool
def update_parameters_empty_dict(
tool: ChatCompletionToolsParam,
) -> ChatCompletionToolsParam:
tool.function.parameters = {}
return tool
@pytest.mark.parametrize(
"sample_output, should_match",
[
(None, False),
([], False), # empty list cannot be generated
({}, False), # empty object cannot be generated
([{}], False), # list with empty object cannot be generated
(
[
{ # function without required parameters cannot be generated
"name": "get_current_weather"
}
],
False,
),
(
[
{ # function without required parameters cannot be generated
"name": "get_current_weather",
"parameters": None,
}
],
False,
),
(
[
{ # function with extra parameters cannot be generated
"name": "get_current_weather",
"parameters": {"extra": "value"},
}
],
False,
),
(
[
{ # only function with empty parameters object is valid
"name": "get_current_weather",
"parameters": {},
}
],
True,
),
],
)
@pytest.mark.parametrize(
"update_parameters", [update_parameters_none, update_parameters_empty_dict]
)
def test_structured_outputs_json_without_parameters(
sample_output, should_match, update_parameters
):
updated_tools = [deepcopy(EXAMPLE_TOOLS[0])]
tools = TypeAdapter(list[ChatCompletionToolsParam]).validate_python(updated_tools)
tools = list(map(update_parameters, tools))
assert all(
[
tool.function.parameters is None or tool.function.parameters == {}
for tool in tools
]
)
_compile_and_check(
tools=tools, sample_output=sample_output, should_match=should_match
)
def _collect_required_tool_streaming_json(output_json: str, delta_len: int) -> str:
previous_text = ""
function_name_returned = False
messages = []
for i in range(0, len(output_json), delta_len):
delta_text = output_json[i : i + delta_len]
current_text = previous_text + delta_text
delta_message, function_name_returned = extract_required_tool_call_streaming(
previous_text=previous_text,
current_text=current_text,
delta_text=delta_text,
function_name_returned=function_name_returned,
tool_call_idx=None,
tool_call_id_type="random",
)
if delta_message:
messages.append(delta_message)
previous_text = current_text
assert len(messages) > 0
combined_messages = "["
for message in messages:
if message.tool_calls[0].function.name:
if len(combined_messages) > 1:
combined_messages += "},"
combined_messages += (
'{"name": "'
+ message.tool_calls[0].function.name
+ '", "parameters": '
+ message.tool_calls[0].function.arguments
)
else:
combined_messages += message.tool_calls[0].function.arguments
combined_messages += "}]"
return combined_messages
@pytest.mark.parametrize("output", VALID_TOOLS)
@pytest.mark.parametrize("empty_params", [False, True])
@pytest.mark.parametrize("delta_len", [1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
def test_streaming_output_valid(output, empty_params, delta_len):
output = deepcopy(output)
if empty_params:
output = [{"name": o["name"], "parameters": {}} for o in output]
output_json = json.dumps(output)
combined_messages = _collect_required_tool_streaming_json(output_json, delta_len)
assert json.loads(combined_messages) == output
assert json.dumps(json.loads(combined_messages)) == output_json
@pytest.mark.parametrize(
"city",
[
"a { b",
"a } b",
"a }} b",
'a " } b',
r"a \ } b",
],
)
@pytest.mark.parametrize("delta_len", [1, 2, 3, 8, 9999])
def test_streaming_output_valid_with_braces_in_string(city, delta_len):
output = [{"name": "get_current_weather", "parameters": {"city": city}}]
output_json = json.dumps(output)
combined_messages = _collect_required_tool_streaming_json(output_json, delta_len)
assert json.loads(combined_messages) == output
assert json.dumps(json.loads(combined_messages)) == output_json
def test_streaming_output_valid_with_trailing_extra_data():
output = [{"name": "get_current_weather", "parameters": {"city": "Vienna"}}]
output_json = json.dumps(output) + "\nDONE"
combined_messages = _collect_required_tool_streaming_json(output_json, delta_len=3)
assert json.loads(combined_messages) == output
FUNCTION_TOOL = FunctionTool(
type="function",
name="get_weather",
parameters={
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
)
WEB_SEARCH_TOOL = WebSearchTool(type="web_search")
class TestNonFunctionToolsSkipped:
"""Non-function tools (web_search, etc.) must be silently skipped
by the tool-schema utilities instead of raising TypeError."""
def test_find_tool_properties_skips_web_search(self):
tools = [WEB_SEARCH_TOOL, FUNCTION_TOOL]
props = find_tool_properties(tools, "get_weather")
assert props == {"city": {"type": "string"}}
def test_find_tool_properties_only_non_function_tools(self):
props = find_tool_properties([WEB_SEARCH_TOOL], "get_weather")
assert props == {}
def test_get_json_schema_with_mixed_tools(self):
tools = [WEB_SEARCH_TOOL, FUNCTION_TOOL]
schema = get_json_schema_from_tools(tools=tools, tool_choice="required")
assert isinstance(schema, dict)
any_of = schema["items"]["anyOf"]
assert len(any_of) == 1
assert any_of[0]["properties"]["name"]["enum"] == ["get_weather"]
+381
View File
@@ -0,0 +1,381 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from copy import deepcopy
from typing import Any
from openai.types.chat import ChatCompletionMessageParam, ChatCompletionToolParam
from typing_extensions import TypedDict
from tests.utils import VLLM_PATH
class ServerConfig(TypedDict, total=False):
model: str
arguments: list[str]
system_prompt: str | None
supports_parallel: bool | None
supports_rocm: bool | None
extended: bool | None # tests do not run in CI automatically
def patch_system_prompt(
messages: list[dict[str, Any]], system_prompt: str
) -> list[dict[str, Any]]:
new_messages = deepcopy(messages)
if new_messages[0]["role"] == "system":
new_messages[0]["content"] = system_prompt
else:
new_messages.insert(0, {"role": "system", "content": system_prompt})
return new_messages
def ensure_system_prompt(
messages: list[dict[str, Any]], config: ServerConfig
) -> list[dict[str, Any]]:
prompt = config.get("system_prompt")
if prompt:
return patch_system_prompt(messages, prompt)
else:
return messages
# universal args for all models go here. also good if you need to test locally
# and change type or KV cache quantization or something.
SEED = 42
ARGS: list[str] = [
"--enable-auto-tool-choice",
"--max-model-len",
"1024",
"--max-num-seqs",
"256",
]
CONFIGS: dict[str, ServerConfig] = {
"hermes": {
"model": "NousResearch/Hermes-3-Llama-3.1-8B",
"arguments": [
"--enforce-eager",
"--no-enable-prefix-caching",
"--tool-call-parser",
"hermes",
"--chat-template",
str(VLLM_PATH / "examples/tool_chat_template_hermes.jinja"),
],
"system_prompt": "You are a helpful assistant with access to tools. If a tool"
" that you have would be helpful to answer a user query, "
"call the tool. Otherwise, answer the user's query directly "
"without calling a tool. DO NOT CALL A TOOL THAT IS IRRELEVANT "
"to the user's question - just respond to it normally.",
},
"llama": {
"model": "meta-llama/Meta-Llama-3.1-8B-Instruct",
"arguments": [
"--enforce-eager",
"--no-enable-prefix-caching",
"--tool-call-parser",
"llama3_json",
"--chat-template",
str(VLLM_PATH / "examples/tool_chat_template_llama3.1_json.jinja"),
],
"supports_parallel": False,
},
"llama3.2": {
"model": "meta-llama/Llama-3.2-3B-Instruct",
"arguments": [
"--enforce-eager",
"--no-enable-prefix-caching",
"--tool-call-parser",
"llama3_json",
"--chat-template",
str(VLLM_PATH / "examples/tool_chat_template_llama3.2_json.jinja"),
],
"supports_parallel": False,
},
"llama4": {
"model": "meta-llama/Llama-4-Scout-17B-16E-Instruct",
"arguments": [
"--enforce-eager",
"--no-enable-prefix-caching",
"--tool-call-parser",
"llama4_pythonic",
"--chat-template",
str(VLLM_PATH / "examples/tool_chat_template_llama4_pythonic.jinja"),
"-tp",
"4",
],
"supports_parallel": False,
"extended": True,
},
"llama4_json": {
"model": "meta-llama/Llama-4-Scout-17B-16E-Instruct",
"arguments": [
"--enforce-eager",
"--no-enable-prefix-caching",
"-tp",
"4",
"--distributed-executor-backend",
"mp",
"--tool-call-parser",
"llama4_json",
"--chat-template",
str(VLLM_PATH / "examples/tool_chat_template_llama4_json.jinja"),
],
"supports_parallel": True,
"extended": True,
},
"mistral-7b": {
"model": "mistralai/Mistral-7B-Instruct-v0.3",
"arguments": [
"--enforce-eager",
"--no-enable-prefix-caching",
"--tokenizer_mode",
"hf",
"--load_format",
"hf",
"--config_format",
"hf",
"--tool-call-parser",
"mistral",
"--chat-template",
str(VLLM_PATH / "examples/tool_chat_template_mistral.jinja"),
'--ignore-patterns="consolidated.safetensors"',
],
"system_prompt": "You are a helpful assistant with access to tools. If a tool"
" that you have would be helpful to answer a user query, "
"call the tool. Otherwise, answer the user's query directly "
"without calling a tool. DO NOT CALL A TOOL THAT IS IRRELEVANT "
"to the user's question - just respond to it normally.",
"supports_parallel": True,
},
"mistral-small-3.2": {
"model": "mistralai/Mistral-Small-3.2-24B-Instruct-2506",
"arguments": [
"--enforce-eager",
"--no-enable-prefix-caching",
"--tool-call-parser",
"mistral",
"--tokenizer-mode",
"mistral",
"--config-format",
"mistral",
"--load-format",
"mistral",
"--tensor-parallel-size",
"4",
'--ignore-patterns="consolidated.safetensors"',
],
"system_prompt": "You are a helpful assistant with access to tools. If a tool"
" that you have would be helpful to answer a user query, "
"call the tool. Otherwise, answer the user's query directly "
"without calling a tool. DO NOT CALL A TOOL THAT IS IRRELEVANT "
"to the user's question - just respond to it normally.",
"supports_parallel": True,
"extended": True,
},
# FIXME: This test currently fails, need to debug why.
# "granite20b": {
# "model": "mbayser/granite-20b-functioncalling-FP8-KV",
# "arguments": [
# "--tool-call-parser",
# "granite-20b-fc",
# "--chat-template",
# str(VLLM_PATH / "examples/tool_chat_template_granite_20b_fc.jinja"),
# "--max_num_seqs",
# "1",
# "--enforce-eager",
# "--cpu-offload-gb",
# "20",
# ],
# "supports_parallel": False,
# "supports_rocm": False,
# },
"granite-3.0-8b": {
"model": "ibm-granite/granite-3.0-8b-instruct",
"arguments": [
"--enforce-eager",
"--no-enable-prefix-caching",
"--tool-call-parser",
"granite",
"--chat-template",
str(VLLM_PATH / "examples/tool_chat_template_granite.jinja"),
],
"system_prompt": "You are a helpful AI assistant with access to tools. "
"Use two-letter US state abbreviations in weather tool arguments. "
"When a tool is required to answer the user query, respond with "
"<|tool_call|> followed by a JSON list of tools used.",
},
"granite-3.1-8b": {
"model": "ibm-granite/granite-3.1-8b-instruct",
"arguments": [
"--enforce-eager",
"--no-enable-prefix-caching",
"--tool-call-parser",
"granite",
],
"supports_parallel": True,
},
"internlm": {
"model": "internlm/internlm2_5-7b-chat",
"arguments": [
"--enforce-eager",
"--no-enable-prefix-caching",
"--tool-call-parser",
"internlm",
"--chat-template",
str(VLLM_PATH / "examples/tool_chat_template_internlm2_tool.jinja"),
"--trust_remote_code",
],
"supports_parallel": False,
},
"toolACE": {
"model": "Team-ACE/ToolACE-8B",
"arguments": [
"--enforce-eager",
"--no-enable-prefix-caching",
"--tool-call-parser",
"pythonic",
"--chat-template",
str(VLLM_PATH / "examples/tool_chat_template_toolace.jinja"),
],
"supports_parallel": True,
},
}
WEATHER_TOOL: ChatCompletionToolParam = {
"type": "function",
"function": {
"name": "get_current_weather",
"description": "Get the current weather in a given location",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "The city to find the weather for, "
"e.g. 'San Francisco'",
},
"state": {
"type": "string",
"description": "must the two-letter abbreviation for the state "
"that the city is in, e.g. 'CA' which would "
"mean 'California'",
},
"unit": {
"type": "string",
"description": "The unit to fetch the temperature in",
"enum": ["celsius", "fahrenheit"],
},
},
},
},
}
SEARCH_TOOL: ChatCompletionToolParam = {
"type": "function",
"function": {
"name": "web_search",
"description": "Search the internet and get a summary of the top "
"10 webpages. Should only be used if you don't know "
"the answer to a user query, and the results are likely"
"to be able to be found with a web search",
"parameters": {
"type": "object",
"properties": {
"search_term": {
"type": "string",
"description": "The term to use in the search. This should"
"ideally be keywords to search for, not a"
"natural-language question",
}
},
"required": ["search_term"],
},
},
}
MESSAGES_WITHOUT_TOOLS: list[ChatCompletionMessageParam] = [
{"role": "user", "content": "Hi! How are you?"},
{"role": "assistant", "content": "I'm doing great! How can I assist you?"},
{"role": "user", "content": "Can you tell me a joke please?"},
]
MESSAGES_ASKING_FOR_TOOLS: list[ChatCompletionMessageParam] = [
{"role": "user", "content": "What is the weather in Dallas, Texas in Fahrenheit?"}
]
MESSAGES_WITH_TOOL_RESPONSE: list[ChatCompletionMessageParam] = [
{"role": "user", "content": "What is the weather in Dallas, Texas in Fahrenheit?"},
{
"role": "assistant",
"tool_calls": [
{
"id": "chatcmpl-tool-03e6481b146e408e9523d9c956696295",
"type": "function",
"function": {
"name": WEATHER_TOOL["function"]["name"],
"arguments": '{"city": "Dallas", "state": "TX", '
'"unit": "fahrenheit"}',
},
}
],
},
{
"role": "tool",
"tool_call_id": "chatcmpl-tool-03e6481b146e408e9523d9c956696295",
"content": "The weather in Dallas is 98 degrees fahrenheit, with partly"
"cloudy skies and a low chance of rain.",
},
]
MESSAGES_ASKING_FOR_PARALLEL_TOOLS: list[ChatCompletionMessageParam] = [
{
"role": "user",
"content": "What is the weather in Dallas, Texas and Orlando, Florida in "
"Fahrenheit?",
}
]
MESSAGES_WITH_PARALLEL_TOOL_RESPONSE: list[ChatCompletionMessageParam] = [
{
"role": "user",
"content": "What is the weather in Dallas, Texas and Orlando, Florida in "
"Fahrenheit?",
},
{
"role": "assistant",
"tool_calls": [
{
"id": "chatcmpl-tool-03e6481b146e408e9523d9c956696295",
"type": "function",
"function": {
"name": WEATHER_TOOL["function"]["name"],
"arguments": '{"city": "Dallas", "state": "TX", '
'"unit": "fahrenheit"}',
},
},
{
"id": "chatcmpl-tool-d027061e1bd21cda48bee7da829c1f5b",
"type": "function",
"function": {
"name": WEATHER_TOOL["function"]["name"],
"arguments": '{"city": "Orlando", "state": "Fl", '
'"unit": "fahrenheit"}',
},
},
],
},
{
"role": "tool",
"tool_call_id": "chatcmpl-tool-03e6481b146e408e9523d9c956696295",
"content": "The weather in Dallas TX is 98 degrees fahrenheit with mostly "
"cloudy skies and a chance of rain in the evening.",
},
{
"role": "tool",
"tool_call_id": "chatcmpl-tool-d027061e1bd21cda48bee7da829c1f5b",
"content": "The weather in Orlando FL is 78 degrees fahrenheit with clear"
"skies.",
},
]