342 lines
11 KiB
Python
342 lines
11 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
|
import argparse
|
|
import json
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from transformers import AutoTokenizer, PreTrainedTokenizerBase
|
|
|
|
from vllm.benchmarks.datasets import BFCLDataset, get_samples
|
|
|
|
|
|
def _patch_hf_api(side_effect):
|
|
"""Return a patch context that swaps `hf_api()` to a stub whose
|
|
`.hf_hub_download` attribute uses `side_effect`."""
|
|
fake_api = MagicMock()
|
|
fake_api.hf_hub_download.side_effect = side_effect
|
|
return patch("vllm.benchmarks.datasets.datasets.hf_api", return_value=fake_api)
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def hf_tokenizer() -> PreTrainedTokenizerBase:
|
|
return AutoTokenizer.from_pretrained("openai-community/gpt2")
|
|
|
|
|
|
_FAKE_ROWS = {
|
|
"simple": [
|
|
{
|
|
"id": "simple_0",
|
|
"question": [
|
|
[
|
|
{
|
|
"role": "user",
|
|
"content": "What is 2+2?",
|
|
}
|
|
]
|
|
],
|
|
"function": [
|
|
{
|
|
"name": "add",
|
|
"description": "Add two numbers.",
|
|
"parameters": {
|
|
"type": "dict",
|
|
"properties": {
|
|
"a": {"type": "integer", "description": "first"},
|
|
"b": {"type": "float", "description": "second"},
|
|
},
|
|
"required": ["a", "b"],
|
|
},
|
|
}
|
|
],
|
|
},
|
|
],
|
|
"live_simple": [
|
|
{
|
|
"id": "live_simple_0",
|
|
"question": [[{"role": "user", "content": "Tell me the weather."}]],
|
|
"function": [
|
|
{
|
|
"name": "get_weather",
|
|
"description": "Get weather.",
|
|
"parameters": {
|
|
"type": "dict",
|
|
"properties": {
|
|
"city": {"type": "any", "description": "city"},
|
|
"coords": {"type": "tuple", "description": "coords"},
|
|
},
|
|
"required": ["city"],
|
|
},
|
|
}
|
|
],
|
|
},
|
|
],
|
|
}
|
|
|
|
|
|
def _write_fake_files(tmp_path: Path) -> dict[str, Path]:
|
|
"""Write fake BFCL JSONL files mimicking the HF repo layout."""
|
|
paths = {}
|
|
for category, rows in _FAKE_ROWS.items():
|
|
p = tmp_path / f"BFCL_v3_{category}.json"
|
|
with p.open("w") as f:
|
|
for row in rows:
|
|
f.write(json.dumps(row) + "\n")
|
|
paths[category] = p
|
|
return paths
|
|
|
|
|
|
def _args_for_bfcl(categories: list[str] | None) -> argparse.Namespace:
|
|
return argparse.Namespace(
|
|
dataset_name="hf",
|
|
dataset_path="gorilla-llm/Berkeley-Function-Calling-Leaderboard",
|
|
hf_name=None,
|
|
hf_subset=None,
|
|
hf_split=None,
|
|
hf_output_len=64,
|
|
disable_shuffle=True,
|
|
num_prompts=2,
|
|
no_oversample=False,
|
|
no_stream=True,
|
|
seed=0,
|
|
request_id_prefix="",
|
|
trust_remote_code=False,
|
|
skip_chat_template=False,
|
|
enable_multimodal_chat=False,
|
|
backend="openai-chat",
|
|
bfcl_categories=categories,
|
|
)
|
|
|
|
|
|
@pytest.mark.benchmark
|
|
def test_bfcl_dataset_translates_schema_and_attaches_tools(
|
|
hf_tokenizer: PreTrainedTokenizerBase, tmp_path: Path
|
|
) -> None:
|
|
"""BFCLDataset should translate schemas to OpenAI tool format, set
|
|
`messages` directly on SampleRequest, and attach tools/tool_choice via
|
|
request_overrides."""
|
|
paths = _write_fake_files(tmp_path)
|
|
|
|
def fake_download(_repo, filename, **_kwargs):
|
|
category = filename.removeprefix("BFCL_v3_").removesuffix(".json")
|
|
return str(paths[category])
|
|
|
|
args = _args_for_bfcl(categories=["simple", "live_simple"])
|
|
|
|
with _patch_hf_api(fake_download):
|
|
samples = get_samples(args, hf_tokenizer)
|
|
|
|
assert len(samples) == 2
|
|
for s in samples:
|
|
assert s.chat_messages is not None
|
|
assert isinstance(s.chat_messages, list)
|
|
assert s.chat_messages[0]["role"] == "user"
|
|
assert s.request_overrides is not None
|
|
assert "tools" in s.request_overrides
|
|
assert s.request_overrides["tool_choice"] == "auto"
|
|
# messages must NOT leak into request_overrides — it has its own
|
|
# typed field on SampleRequest.
|
|
assert "messages" not in s.request_overrides
|
|
tools = s.request_overrides["tools"]
|
|
assert len(tools) == 1
|
|
tool = tools[0]
|
|
assert tool["type"] == "function"
|
|
# Translated schema: dict -> object, float -> number,
|
|
# any -> string, tuple -> array.
|
|
params = tool["function"]["parameters"]
|
|
assert params["type"] == "object"
|
|
for prop in params["properties"].values():
|
|
assert prop["type"] in {"integer", "number", "string", "array"}
|
|
|
|
|
|
@pytest.mark.benchmark
|
|
def test_bfcl_dataset_requires_openai_chat_backend(
|
|
hf_tokenizer: PreTrainedTokenizerBase,
|
|
) -> None:
|
|
args = _args_for_bfcl(categories=["simple"])
|
|
args.backend = "openai"
|
|
|
|
with pytest.raises(ValueError, match="openai-chat"):
|
|
get_samples(args, hf_tokenizer)
|
|
|
|
|
|
@pytest.mark.benchmark
|
|
def test_bfcl_dataset_missing_category_raises_clear_error(
|
|
hf_tokenizer: PreTrainedTokenizerBase,
|
|
) -> None:
|
|
"""A typo'd category should produce an actionable ValueError, not an
|
|
opaque huggingface_hub exception."""
|
|
from huggingface_hub.errors import EntryNotFoundError
|
|
|
|
args = _args_for_bfcl(categories=["simpl"]) # typo
|
|
|
|
def raise_missing(_repo, filename, **_kwargs):
|
|
raise EntryNotFoundError(f"404 Not Found: {filename}")
|
|
|
|
with (
|
|
_patch_hf_api(raise_missing),
|
|
pytest.raises(ValueError, match=r"BFCL category 'simpl' not found"),
|
|
):
|
|
get_samples(args, hf_tokenizer)
|
|
|
|
|
|
@pytest.mark.benchmark
|
|
def test_chat_backend_uses_messages_field_when_set() -> None:
|
|
"""When RequestFuncInput.chat_messages is set, the chat backend must use
|
|
it verbatim and skip default content construction from `prompt`."""
|
|
import asyncio
|
|
|
|
from vllm.benchmarks.lib.endpoint_request_func import (
|
|
RequestFuncInput,
|
|
async_request_openai_chat_completions,
|
|
)
|
|
|
|
captured: dict = {}
|
|
|
|
class _FakeResp:
|
|
status = 500
|
|
reason = "stop-after-capture"
|
|
content = None
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *exc):
|
|
return False
|
|
|
|
class _FakeSession:
|
|
def post(self, url, json, headers): # noqa: A002
|
|
captured["url"] = url
|
|
captured["payload"] = json
|
|
return _FakeResp()
|
|
|
|
messages = [
|
|
{"role": "system", "content": "you are helpful"},
|
|
{"role": "user", "content": "call add(3, 4)"},
|
|
]
|
|
req = RequestFuncInput(
|
|
prompt="IGNORED",
|
|
api_url="http://localhost:0/v1/chat/completions",
|
|
prompt_len=10,
|
|
output_len=16,
|
|
model="test-model",
|
|
chat_messages=messages,
|
|
extra_body={"tools": [{"type": "function", "function": {"name": "add"}}]},
|
|
)
|
|
|
|
asyncio.run(
|
|
async_request_openai_chat_completions(
|
|
request_func_input=req, session=_FakeSession()
|
|
)
|
|
)
|
|
|
|
payload = captured["payload"]
|
|
assert payload["messages"] is messages, (
|
|
"chat backend must forward RequestFuncInput.chat_messages verbatim "
|
|
"instead of constructing a default user message from `prompt`"
|
|
)
|
|
# extra_body still merges in as before (shallow, per-request wins).
|
|
assert payload["tools"][0]["function"]["name"] == "add"
|
|
|
|
|
|
@pytest.mark.benchmark
|
|
def test_bfcl_prompt_len_includes_tools(tmp_path: Path) -> None:
|
|
"""prompt_len must reflect tokens from both messages *and* tool schemas,
|
|
so percentile buckets and input-distribution summaries aren't biased
|
|
low for tool-heavy traffic."""
|
|
paths = _write_fake_files(tmp_path)
|
|
|
|
def fake_download(_repo, filename, **_kwargs):
|
|
category = filename.removeprefix("BFCL_v3_").removesuffix(".json")
|
|
return str(paths[category])
|
|
|
|
captured: dict = {}
|
|
|
|
class _FakeTokenizer:
|
|
def apply_chat_template(
|
|
self, messages, tools=None, tokenize=False, add_generation_prompt=True
|
|
):
|
|
captured["tools"] = tools
|
|
base = " ".join(m.get("content", "") for m in messages)
|
|
tool_text = json.dumps(tools) if tools else ""
|
|
return base + " " + tool_text
|
|
|
|
def __call__(self, text):
|
|
# 1 "token" per whitespace-separated word.
|
|
return type("Enc", (), {"input_ids": text.split()})()
|
|
|
|
fake = _FakeTokenizer()
|
|
args = _args_for_bfcl(categories=["simple"])
|
|
args.num_prompts = 1
|
|
|
|
with _patch_hf_api(fake_download):
|
|
samples = get_samples(args, fake)
|
|
|
|
assert len(samples) == 1
|
|
assert captured["tools"] is not None, (
|
|
"apply_chat_template must be called with tools= so the schema "
|
|
"contributes to the prompt-length estimate"
|
|
)
|
|
assert len(captured["tools"]) == 1
|
|
assert captured["tools"][0]["function"]["name"] == "add"
|
|
|
|
# Sanity: prompt_len exceeds a messages-only estimate. The fake row's
|
|
# user message is "What is 2+2?" (3 whitespace-separated tokens).
|
|
assert samples[0].prompt_len > 3
|
|
|
|
|
|
@pytest.mark.benchmark
|
|
def test_bfcl_prompt_len_falls_back_when_tokenizer_rejects_tools(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Older tokenizers don't accept tools=; fallback must still produce a
|
|
non-zero prompt_len without crashing."""
|
|
paths = _write_fake_files(tmp_path)
|
|
|
|
def fake_download(_repo, filename, **_kwargs):
|
|
category = filename.removeprefix("BFCL_v3_").removesuffix(".json")
|
|
return str(paths[category])
|
|
|
|
class _LegacyTokenizer:
|
|
def apply_chat_template(self, messages, **kwargs):
|
|
if "tools" in kwargs:
|
|
raise TypeError("unexpected keyword argument 'tools'")
|
|
return " ".join(m.get("content", "") for m in messages)
|
|
|
|
def __call__(self, text):
|
|
return type("Enc", (), {"input_ids": text.split()})()
|
|
|
|
args = _args_for_bfcl(categories=["simple"])
|
|
args.num_prompts = 1
|
|
|
|
with _patch_hf_api(fake_download):
|
|
samples = get_samples(args, _LegacyTokenizer())
|
|
|
|
assert len(samples) == 1
|
|
assert samples[0].prompt_len > 0
|
|
|
|
|
|
@pytest.mark.benchmark
|
|
def test_bfcl_schema_translation_is_recursive() -> None:
|
|
"""_translate_schema must recurse into nested properties."""
|
|
input_schema = {
|
|
"type": "dict",
|
|
"properties": {
|
|
"nested": {
|
|
"type": "dict",
|
|
"properties": {
|
|
"value": {"type": "float"},
|
|
"tags": {"type": "tuple", "items": {"type": "any"}},
|
|
},
|
|
}
|
|
},
|
|
}
|
|
out = BFCLDataset._translate_schema(input_schema)
|
|
assert out["type"] == "object"
|
|
assert out["properties"]["nested"]["type"] == "object"
|
|
assert out["properties"]["nested"]["properties"]["value"]["type"] == "number"
|
|
assert out["properties"]["nested"]["properties"]["tags"]["type"] == "array"
|
|
nested_props = out["properties"]["nested"]["properties"]
|
|
assert nested_props["tags"]["items"]["type"] == "string"
|