Skip to content

Commit f9e97c1

Browse files
Merge branch 'main' into fix_test_named_tool_use
2 parents 6b6054c + 0744755 commit f9e97c1

File tree

5 files changed

+427
-2
lines changed

5 files changed

+427
-2
lines changed

tests/distributed/test_events.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,3 +263,52 @@ def test_data_parallel_rank_tagging(publisher_config):
263263
pub_1.shutdown()
264264
sub_0.close()
265265
sub_1.close()
266+
267+
268+
def test_event_publisher_factory():
269+
"""Test event publisher factory creation behavior under different configurations"""
270+
from vllm.config.kv_events import KVEventsConfig
271+
from vllm.distributed.kv_events import ZmqEventPublisher
272+
273+
# test config is None
274+
publisher = EventPublisherFactory.create(None, DP_RANK)
275+
assert isinstance(publisher, NullEventPublisher)
276+
publisher.shutdown()
277+
278+
# test disable kv cache events
279+
config = KVEventsConfig(
280+
enable_kv_cache_events=False,
281+
publisher="zmq", # Even if zmq is specified, should return NullEventPublisher
282+
endpoint="tcp://localhost:5557",
283+
)
284+
publisher = EventPublisherFactory.create(config, DP_RANK)
285+
assert isinstance(publisher, NullEventPublisher)
286+
publisher.shutdown()
287+
288+
# test zmq publisher
289+
config = KVEventsConfig(
290+
enable_kv_cache_events=True,
291+
publisher="zmq",
292+
endpoint="inproc://test-factory-true",
293+
)
294+
publisher = EventPublisherFactory.create(config, DP_RANK)
295+
assert isinstance(publisher, ZmqEventPublisher)
296+
publisher.shutdown()
297+
298+
# test unknown publisher
299+
with pytest.raises(ValueError, match="Input should be"):
300+
KVEventsConfig(
301+
enable_kv_cache_events=True,
302+
publisher="unknown_publisher",
303+
endpoint="tcp://localhost:5557",
304+
)
305+
306+
# test publisher not specified
307+
config = KVEventsConfig(
308+
enable_kv_cache_events=True,
309+
# publisher not specified, should default to "zmq"
310+
endpoint="tcp://localhost:5557",
311+
)
312+
publisher = EventPublisherFactory.create(config, DP_RANK)
313+
assert isinstance(publisher, ZmqEventPublisher)
314+
publisher.shutdown()
Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3+
"""Test function call parsing in ResponsesRequest."""
4+
5+
import json
6+
7+
import pytest
8+
from openai.types.responses import ResponseFunctionToolCall
9+
10+
from vllm.entrypoints.openai.protocol import ResponsesRequest
11+
12+
13+
def test_function_call_dict_converted_to_object():
14+
"""Test that function_call dictionaries are correctly parsed into
15+
ResponseFunctionToolCall objects."""
16+
# Create a request with function_call as dict
17+
request_data = {
18+
"model": "gpt-oss",
19+
"input": [
20+
{
21+
"type": "function_call",
22+
"call_id": "fc_123",
23+
"name": "get_weather",
24+
"arguments": '{"location": "Boston", "unit": "celsius"}',
25+
}
26+
],
27+
}
28+
29+
request = ResponsesRequest(**request_data)
30+
31+
# Verify the input item is now a ResponseFunctionToolCall object
32+
assert len(request.input) == 1
33+
assert isinstance(request.input[0], ResponseFunctionToolCall)
34+
assert request.input[0].call_id == "fc_123"
35+
assert request.input[0].name == "get_weather"
36+
assert request.input[0].arguments == '{"location": "Boston", "unit": "celsius"}'
37+
38+
39+
def test_direct_function_call_object_preservation():
40+
"""Test that ResponseFunctionToolCall objects passed directly are preserved."""
41+
# Create a request with ResponseFunctionToolCall object
42+
function_call = ResponseFunctionToolCall(
43+
type="function_call",
44+
call_id="fc_456",
45+
name="get_stock_price",
46+
arguments='{"symbol": "AAPL"}',
47+
)
48+
49+
request_data = {"model": "gpt-oss", "input": [function_call]}
50+
51+
request = ResponsesRequest(**request_data)
52+
53+
# Verify the object is preserved
54+
assert len(request.input) == 1
55+
assert request.input[0] is function_call
56+
57+
58+
def test_mixed_input_types_with_function_calls():
59+
"""Test parsing with mixed input types including function calls."""
60+
61+
request_data = {
62+
"model": "gpt-oss",
63+
"input": [
64+
# Valid Message type
65+
{
66+
"type": "message",
67+
"role": "user",
68+
"content": [{"type": "input_text", "text": "What's the weather?"}],
69+
},
70+
# Function call that should be parsed
71+
{
72+
"type": "function_call",
73+
"call_id": "fc_789",
74+
"name": "check_weather",
75+
"arguments": '{"location": "NYC"}',
76+
},
77+
# Another function call
78+
{
79+
"type": "function_call",
80+
"call_id": "fc_790",
81+
"name": "get_time",
82+
"arguments": "{}",
83+
},
84+
],
85+
}
86+
87+
request = ResponsesRequest(**request_data)
88+
89+
# Verify mixed types are handled correctly
90+
assert len(request.input) == 3
91+
# First item should be validated as Message
92+
assert request.input[0]["type"] == "message"
93+
# Second item should be parsed to ResponseFunctionToolCall
94+
assert isinstance(request.input[1], ResponseFunctionToolCall)
95+
assert request.input[1].call_id == "fc_789"
96+
assert request.input[1].name == "check_weather"
97+
# Third item should also be parsed to ResponseFunctionToolCall
98+
assert isinstance(request.input[2], ResponseFunctionToolCall)
99+
assert request.input[2].call_id == "fc_790"
100+
assert request.input[2].name == "get_time"
101+
102+
103+
def test_function_call_with_complex_arguments():
104+
"""Test parsing function calls with complex nested arguments."""
105+
complex_args = {
106+
"query": "weather forecast",
107+
"filters": {
108+
"location": {"city": "San Francisco", "state": "CA"},
109+
"timeRange": {"start": "2024-01-01", "end": "2024-01-07"},
110+
"metrics": ["temperature", "humidity", "precipitation"],
111+
},
112+
"options": {"format": "detailed", "includeAlerts": True},
113+
}
114+
115+
request_data = {
116+
"model": "gpt-oss",
117+
"input": [
118+
{
119+
"type": "function_call",
120+
"call_id": "fc_complex",
121+
"name": "advanced_weather_query",
122+
"arguments": json.dumps(complex_args),
123+
}
124+
],
125+
}
126+
127+
request = ResponsesRequest(**request_data)
128+
129+
# Verify complex arguments are preserved correctly
130+
assert len(request.input) == 1
131+
assert isinstance(request.input[0], ResponseFunctionToolCall)
132+
assert request.input[0].call_id == "fc_complex"
133+
assert request.input[0].name == "advanced_weather_query"
134+
135+
# Parse the arguments back to verify they're intact
136+
parsed_args = json.loads(request.input[0].arguments)
137+
assert parsed_args == complex_args
138+
139+
140+
def test_invalid_function_call_fallback():
141+
"""Test that invalid function call dictionaries fall back gracefully."""
142+
# Missing required field 'call_id'
143+
request_data = {
144+
"model": "gpt-oss",
145+
"input": [
146+
{"type": "function_call", "name": "incomplete_function", "arguments": "{}"}
147+
],
148+
}
149+
150+
# This should not raise an error during model creation
151+
# The validator should keep the original dict and let Pydantic
152+
# handle validation
153+
with pytest.raises(ValueError):
154+
# Pydantic should raise a validation error for the invalid structure
155+
ResponsesRequest(**request_data)
156+
157+
158+
def test_string_input_not_affected():
159+
"""Test that string input is not affected by the validator."""
160+
request_data = {"model": "gpt-oss", "input": "This is a simple string input"}
161+
162+
request = ResponsesRequest(**request_data)
163+
164+
# Verify string input remains unchanged
165+
assert request.input == "This is a simple string input"
166+
167+
168+
def test_empty_list_input():
169+
"""Test that empty list input is handled correctly."""
170+
request_data = {"model": "gpt-oss", "input": []}
171+
172+
request = ResponsesRequest(**request_data)
173+
174+
# Verify empty list is preserved
175+
assert request.input == []
176+
177+
178+
def test_function_call_output_not_affected():
179+
"""Test that FunctionCallOutput is not affected by the function_call parsing."""
180+
181+
# Test with FunctionCallOutput as dict (should not be parsed)
182+
request_data = {
183+
"model": "gpt-oss",
184+
"input": [
185+
{
186+
"type": "function_call_output",
187+
"call_id": "fc_output_123",
188+
"output": "The weather in Boston is 72°F and sunny.",
189+
}
190+
],
191+
}
192+
193+
request = ResponsesRequest(**request_data)
194+
195+
# FunctionCallOutput should remain as dict (not converted to an object)
196+
assert len(request.input) == 1
197+
assert isinstance(request.input[0], dict)
198+
assert request.input[0]["type"] == "function_call_output"
199+
assert request.input[0]["call_id"] == "fc_output_123"
200+
assert request.input[0]["output"] == "The weather in Boston is 72°F and sunny."
201+
202+
203+
def test_mixed_function_call_and_output():
204+
"""Test that function_call is parsed while function_call_output is preserved."""
205+
request_data = {
206+
"model": "gpt-oss",
207+
"input": [
208+
# This should be parsed to ResponseFunctionToolCall
209+
{
210+
"type": "function_call",
211+
"call_id": "fc_call_456",
212+
"name": "get_weather",
213+
"arguments": '{"location": "NYC"}',
214+
},
215+
# This should remain as dict
216+
{
217+
"type": "function_call_output",
218+
"call_id": "fc_call_456",
219+
"output": "NYC weather is 68°F with light rain",
220+
},
221+
],
222+
}
223+
224+
request = ResponsesRequest(**request_data)
225+
226+
assert len(request.input) == 2
227+
228+
# First item should be parsed to ResponseFunctionToolCall
229+
assert isinstance(request.input[0], ResponseFunctionToolCall)
230+
assert request.input[0].call_id == "fc_call_456"
231+
assert request.input[0].name == "get_weather"
232+
233+
# Second item should remain as dict (FunctionCallOutput)
234+
assert isinstance(request.input[1], dict)
235+
assert request.input[1]["type"] == "function_call_output"
236+
assert request.input[1]["call_id"] == "fc_call_456"
237+
assert request.input[1]["output"] == "NYC weather is 68°F with light rain"
238+
239+
240+
def test_function_call_validation_failure_logs_debug(caplog):
241+
"""Test that validation failures are logged at debug level."""
242+
from unittest.mock import patch
243+
244+
request_data = {
245+
"model": "gpt-oss",
246+
"input": [
247+
{
248+
"type": "function_call",
249+
"name": "incomplete_function",
250+
"arguments": "{}", # Missing call_id
251+
}
252+
],
253+
}
254+
255+
# Mock the logger to verify debug was called
256+
with patch("vllm.entrypoints.openai.protocol.logger") as mock_logger:
257+
with pytest.raises(ValueError):
258+
ResponsesRequest(**request_data)
259+
260+
# Verify debug was called with expected message
261+
mock_logger.debug.assert_called_once()
262+
call_args = mock_logger.debug.call_args[0][0]
263+
assert "Failed to parse function_call" in call_args
264+
265+
266+
def test_validator_handles_iterator_input():
267+
"""Test that validator can handle ValidatorIterator input (Pydantic internal)."""
268+
269+
# This test simulates when Pydantic passes a ValidatorIterator instead of a list
270+
# This happened with complex nested structures containing reasoning + function_call
271+
272+
# Create test data that would normally be a list
273+
test_input_items = [
274+
{
275+
"type": "message",
276+
"role": "user",
277+
"content": [{"type": "input_text", "text": "Test"}],
278+
},
279+
{
280+
"type": "reasoning",
281+
"id": "rs_1",
282+
"summary": [{"type": "summary_text", "text": "Test reasoning"}],
283+
"content": [{"type": "reasoning_text", "text": "Test content"}],
284+
},
285+
{
286+
"type": "function_call",
287+
"call_id": "call_1",
288+
"name": "test_function",
289+
"arguments": '{"test": "value"}',
290+
"id": "fc_1",
291+
},
292+
]
293+
294+
# Mock data where input is an iterator (simulates Pydantic ValidatorIterator)
295+
mock_data = {
296+
"model": "test-model",
297+
"input": iter(test_input_items), # Iterator instead of list
298+
}
299+
300+
# This should NOT raise an error with the fixed validator
301+
try:
302+
request = ResponsesRequest(**mock_data)
303+
304+
# Verify the validator processed the data correctly
305+
assert len(request.input) == 3
306+
307+
# Verify function_call was converted to ResponseFunctionToolCall object
308+
function_call_item = None
309+
for item in request.input:
310+
if isinstance(item, ResponseFunctionToolCall):
311+
function_call_item = item
312+
break
313+
314+
assert function_call_item is not None
315+
assert function_call_item.call_id == "call_1"
316+
assert function_call_item.name == "test_function"
317+
318+
except Exception as e:
319+
pytest.fail(f"Validator should handle iterator input, but failed with: {e}")
320+
321+
322+
def test_validator_handles_empty_iterator():
323+
"""Test validator handles empty iterator gracefully."""
324+
mock_data = {
325+
"model": "test-model",
326+
"input": iter([]), # Empty iterator
327+
}
328+
329+
request = ResponsesRequest(**mock_data)
330+
assert request.input == []

vllm/distributed/kv_events.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,11 @@ def create(
353353
cls, config: KVEventsConfig | None, data_parallel_rank: int = 0
354354
) -> EventPublisher:
355355
"""Create publisher from a config mapping."""
356-
if config is None or config.publisher == "null":
356+
if (
357+
config is None
358+
or not config.enable_kv_cache_events
359+
or config.publisher == "null"
360+
):
357361
return NullEventPublisher()
358362

359363
config_dict = asdict(config)

0 commit comments

Comments
 (0)