| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | """Unit tests for tool_calls Iterable → list materialisation. |
| 4 | |
| 5 | Regression tests for https://github.com/vllm-project/vllm/issues/34792. |
| 6 | |
| 7 | Setting VLLM_LOGGING_LEVEL=debug caused tool calling to break for Mistral |
| 8 | models because: |
| 9 | 1. The OpenAI Python SDK types tool_calls as Iterable[...] in |
| 10 | ChatCompletionAssistantMessageParam. |
| 11 | 2. Pydantic v2, when validating from Python objects (not from raw JSON), |
| 12 | wraps Iterable fields in a one-shot lazy iterator. |
| 13 | 3. Debug logging called model_dump_json() which consumed that iterator. |
| 14 | 4. The Mistral tokenizer then saw empty tool_calls and raised |
| 15 | "ValueError: Unexpected tool call id ...". |
| 16 | """ |
| 17 | |
| 18 | import pytest |
| 19 | |
| 20 | from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest |
| 21 | |
| 22 | |
| 23 | def _make_tool_call(tc_id: str, name: str, args: str) -> dict: |
| 24 | return { |
| 25 | "id": tc_id, |
| 26 | "type": "function", |
| 27 | "function": {"name": name, "arguments": args}, |
| 28 | } |
| 29 | |
| 30 | |
| 31 | def _make_request(messages: list) -> ChatCompletionRequest: |
| 32 | return ChatCompletionRequest( |
| 33 | model="test-model", |
| 34 | messages=messages, |
| 35 | ) |
| 36 | |
| 37 | |
| 38 | def test_tool_calls_list_preserved_after_model_dump(): |
| 39 | """tool_calls in assistant messages must be readable after model_dump_json. |
| 40 | |
| 41 | When the request is built from Python dicts (as in the Anthropic → OpenAI |
| 42 | conversion path), Pydantic v2 previously wrapped the Iterable tool_calls |
| 43 | in a one-shot iterator. model_dump_json() consumed it, leaving subsequent |
| 44 | readers (e.g. the Mistral tokenizer) with an empty sequence. |
| 45 | """ |
| 46 | tool_call = _make_tool_call("call_abc123", "get_weather", '{"city": "Paris"}') |
| 47 | messages = [ |
| 48 | {"role": "user", "content": "What is the weather in Paris?"}, |
| 49 | {"role": "assistant", "content": None, "tool_calls": [tool_call]}, |
| 50 | { |
| 51 | "role": "tool", |
| 52 | "tool_call_id": "call_abc123", |
| 53 | "content": '{"temperature": 20}', |
| 54 | }, |
| 55 | ] |
| 56 | |
| 57 | req = _make_request(messages) |
| 58 | |
| 59 | # Simulate debug logging: serialize the model (this was the trigger) |
| 60 | _ = req.model_dump_json() |
| 61 | |
| 62 | # The assistant message must still have accessible tool_calls afterwards |
| 63 | assistant_msg = req.messages[1] |
| 64 | assert isinstance(assistant_msg, dict) |
| 65 | tool_calls = assistant_msg.get("tool_calls") |
| 66 | assert tool_calls is not None, "tool_calls must not be None after model_dump_json" |
| 67 | assert isinstance(tool_calls, list), "tool_calls must be a list" |
| 68 | assert len(tool_calls) > 0, "tool_calls must not be empty after model_dump_json" |
| 69 | |
| 70 | |
| 71 | def test_tool_calls_from_generator_are_materialised(): |
| 72 | """tool_calls passed as a generator must be converted to list on validation.""" |
| 73 | tool_call = _make_tool_call("call_gen1", "search", '{"query": "vllm"}') |
| 74 | |
| 75 | def tool_calls_gen(): |
| 76 | yield tool_call |
| 77 | |
| 78 | messages = [ |
| 79 | {"role": "user", "content": "Search for vllm"}, |
| 80 | { |
| 81 | "role": "assistant", |
| 82 | "content": None, |
| 83 | "tool_calls": tool_calls_gen(), # one-shot generator |
| 84 | }, |
| 85 | ] |
| 86 | |
| 87 | req = _make_request(messages) |
| 88 | assistant_msg = req.messages[1] |
| 89 | assert isinstance(assistant_msg, dict) |
| 90 | |
| 91 | # Iterate twice — must not raise or return empty on second pass |
| 92 | tool_calls_first = list(assistant_msg.get("tool_calls", [])) |
| 93 | tool_calls_second = list(assistant_msg.get("tool_calls", [])) |
| 94 | |
| 95 | assert len(tool_calls_first) == 1, "First read must return the tool call" |
| 96 | assert len(tool_calls_second) == 1, "Second read must also return the tool call" |
| 97 | |
| 98 | |
| 99 | def test_tool_calls_list_passthrough(): |
| 100 | """tool_calls already provided as a list must remain a list.""" |
| 101 | tool_call = _make_tool_call("call_list1", "calculate", '{"expr": "2+2"}') |
| 102 | messages = [ |
| 103 | {"role": "user", "content": "Calculate 2+2"}, |
| 104 | {"role": "assistant", "content": None, "tool_calls": [tool_call]}, |
| 105 | ] |
| 106 | |
| 107 | req = _make_request(messages) |
| 108 | assistant_msg = req.messages[1] |
| 109 | assert isinstance(assistant_msg, dict) |
| 110 | assert isinstance(assistant_msg.get("tool_calls"), list) |
| 111 | |
| 112 | |
| 113 | def test_messages_without_tool_calls_unaffected(): |
| 114 | """Messages without tool_calls must be handled correctly.""" |
| 115 | messages = [ |
| 116 | {"role": "system", "content": "You are a helpful assistant."}, |
| 117 | {"role": "user", "content": "Hello!"}, |
| 118 | {"role": "assistant", "content": "Hi there!"}, |
| 119 | ] |
| 120 | |
| 121 | req = _make_request(messages) |
| 122 | # None of the messages should have tool_calls injected |
| 123 | for msg in req.messages: |
| 124 | assert isinstance(msg, dict) |
| 125 | assert msg.get("tool_calls") is None or msg.get("tool_calls") == [] |
| 126 | |
| 127 | |
| 128 | @pytest.mark.parametrize("num_tool_calls", [1, 3]) |
| 129 | def test_multiple_tool_calls_materialised(num_tool_calls: int): |
| 130 | """Multiple tool calls in a single message are all preserved.""" |
| 131 | tool_calls = [ |
| 132 | _make_tool_call(f"call_{i}", f"func_{i}", f'{{"arg": {i}}}') |
| 133 | for i in range(num_tool_calls) |
| 134 | ] |
| 135 | messages = [ |
| 136 | {"role": "user", "content": "Do things"}, |
| 137 | {"role": "assistant", "content": None, "tool_calls": iter(tool_calls)}, |
| 138 | ] |
| 139 | |
| 140 | req = _make_request(messages) |
| 141 | assistant_msg = req.messages[1] |
| 142 | assert isinstance(assistant_msg, dict) |
| 143 | |
| 144 | result_tool_calls = assistant_msg.get("tool_calls") |
| 145 | assert isinstance(result_tool_calls, list) |
| 146 | assert len(result_tool_calls) == num_tool_calls |
| 147 | |
| 148 | # Verify after model_dump_json too |
| 149 | _ = req.model_dump_json() |
| 150 | assert len(assistant_msg.get("tool_calls", [])) == num_tool_calls |