# 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"