Skip to content

Commit 6505605

Browse files
alecsolderAlec Solderyeqcharlotte
authored andcommitted
[Frontend] Responses API MCP tools for built in tools and to pass through headers (vllm-project#24628)
Signed-off-by: Alec Solder <alecs@fb.com> Signed-off-by: Alec S <10566873+alecsolder@users.noreply.github.com> Co-authored-by: Alec Solder <alecs@fb.com> Co-authored-by: Ye (Charlotte) Qi <yeq@meta.com>
1 parent 8693b67 commit 6505605

File tree

8 files changed

+463
-29
lines changed

8 files changed

+463
-29
lines changed
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3+
4+
import pytest
5+
import pytest_asyncio
6+
from openai import OpenAI
7+
8+
from ...utils import RemoteOpenAIServer
9+
10+
MODEL_NAME = "openai/gpt-oss-20b"
11+
12+
13+
@pytest.fixture(scope="module")
14+
def monkeypatch_module():
15+
from _pytest.monkeypatch import MonkeyPatch
16+
mpatch = MonkeyPatch()
17+
yield mpatch
18+
mpatch.undo()
19+
20+
21+
@pytest.fixture(scope="module")
22+
def mcp_disabled_server(monkeypatch_module: pytest.MonkeyPatch):
23+
args = ["--enforce-eager", "--tool-server", "demo"]
24+
25+
with monkeypatch_module.context() as m:
26+
m.setenv("VLLM_ENABLE_RESPONSES_API_STORE", "1")
27+
m.setenv("PYTHON_EXECUTION_BACKEND", "dangerously_use_uv")
28+
with RemoteOpenAIServer(MODEL_NAME, args) as remote_server:
29+
yield remote_server
30+
31+
32+
@pytest.fixture(scope="function")
33+
def mcp_enabled_server(monkeypatch_module: pytest.MonkeyPatch):
34+
args = ["--enforce-eager", "--tool-server", "demo"]
35+
36+
with monkeypatch_module.context() as m:
37+
m.setenv("VLLM_ENABLE_RESPONSES_API_STORE", "1")
38+
m.setenv("PYTHON_EXECUTION_BACKEND", "dangerously_use_uv")
39+
m.setenv("GPT_OSS_SYSTEM_TOOL_MCP_LABELS",
40+
"code_interpreter,container")
41+
with RemoteOpenAIServer(MODEL_NAME, args) as remote_server:
42+
yield remote_server
43+
44+
45+
@pytest_asyncio.fixture
46+
async def mcp_disabled_client(mcp_disabled_server):
47+
async with mcp_disabled_server.get_async_client() as async_client:
48+
yield async_client
49+
50+
51+
@pytest_asyncio.fixture
52+
async def mcp_enabled_client(mcp_enabled_server):
53+
async with mcp_enabled_server.get_async_client() as async_client:
54+
yield async_client
55+
56+
57+
@pytest.mark.asyncio
58+
@pytest.mark.parametrize("model_name", [MODEL_NAME])
59+
@pytest.mark.skip(reason="Code interpreter tool is not available in CI yet.")
60+
async def test_mcp_tool_env_flag_enabled(mcp_enabled_client: OpenAI,
61+
model_name: str):
62+
response = await mcp_enabled_client.responses.create(
63+
model=model_name,
64+
# TODO: Ideally should be able to set max tool calls
65+
# to prevent multi-turn, but it is not currently supported
66+
# would speed up the test
67+
input=("What's the first 4 digits after the decimal point of "
68+
"cube root of `19910212 * 20250910`? "
69+
"Show only the digits. The python interpreter is not stateful "
70+
"and you must print to see the output."),
71+
tools=[{
72+
"type": "mcp",
73+
"server_label": "code_interpreter",
74+
# URL unused for DemoToolServer
75+
"server_url": "http://localhost:8888"
76+
}],
77+
)
78+
assert response is not None
79+
assert response.status == "completed"
80+
assert response.usage.output_tokens_details.tool_output_tokens > 0
81+
82+
83+
@pytest.mark.asyncio
84+
@pytest.mark.parametrize("model_name", [MODEL_NAME])
85+
@pytest.mark.skip(reason="Code interpreter tool is not available in CI yet.")
86+
async def test_mcp_tool_env_flag_disabled(mcp_disabled_client: OpenAI,
87+
model_name: str):
88+
response = await mcp_disabled_client.responses.create(
89+
model=model_name,
90+
# TODO: Ideally should be able to set max tool calls
91+
# to prevent multi-turn, but it is not currently supported
92+
# would speed up the test
93+
input=("What's the first 4 digits after the decimal point of "
94+
"cube root of `19910212 * 20250910`? "
95+
"Show only the digits. The python interpreter is not stateful "
96+
"and you must print to see the output."),
97+
tools=[{
98+
"type": "mcp",
99+
"server_label": "code_interpreter",
100+
# URL unused for DemoToolServer
101+
"server_url": "http://localhost:8888"
102+
}],
103+
)
104+
assert response is not None
105+
assert response.status == "completed"
106+
assert response.usage.output_tokens_details.tool_output_tokens == 0

tests/entrypoints/openai/test_response_api_with_harmony.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -454,7 +454,13 @@ async def test_web_search(client: OpenAI, model_name: str):
454454
async def test_code_interpreter(client: OpenAI, model_name: str):
455455
response = await client.responses.create(
456456
model=model_name,
457-
input="Multiply 64548*15151 using builtin python interpreter.",
457+
# TODO: Ideally should be able to set max tool calls
458+
# to prevent multi-turn, but it is not currently supported
459+
# would speed up the test
460+
input=("What's the first 4 digits after the decimal point of "
461+
"cube root of `19910212 * 20250910`? "
462+
"Show only the digits. The python interpreter is not stateful "
463+
"and you must print to see the output."),
458464
tools=[{
459465
"type": "code_interpreter",
460466
"container": {
@@ -464,6 +470,7 @@ async def test_code_interpreter(client: OpenAI, model_name: str):
464470
)
465471
assert response is not None
466472
assert response.status == "completed"
473+
assert response.usage.output_tokens_details.tool_output_tokens > 0
467474

468475

469476
def get_weather(latitude, longitude):

tests/test_envs.py

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3+
4+
import os
5+
from unittest.mock import patch
6+
7+
import pytest
8+
9+
from vllm.envs import env_list_with_choices, env_with_choices
10+
11+
12+
class TestEnvWithChoices:
13+
"""Test cases for env_with_choices function."""
14+
15+
def test_default_value_returned_when_env_not_set(self):
16+
"""Test default is returned when env var is not set."""
17+
env_func = env_with_choices("NONEXISTENT_ENV", "default",
18+
["option1", "option2"])
19+
assert env_func() == "default"
20+
21+
def test_none_default_returned_when_env_not_set(self):
22+
"""Test that None is returned when env not set and default is None."""
23+
env_func = env_with_choices("NONEXISTENT_ENV", None,
24+
["option1", "option2"])
25+
assert env_func() is None
26+
27+
def test_valid_value_returned_case_sensitive(self):
28+
"""Test that valid value is returned in case sensitive mode."""
29+
with patch.dict(os.environ, {"TEST_ENV": "option1"}):
30+
env_func = env_with_choices("TEST_ENV",
31+
"default", ["option1", "option2"],
32+
case_sensitive=True)
33+
assert env_func() == "option1"
34+
35+
def test_valid_lowercase_value_returned_case_insensitive(self):
36+
"""Test that lowercase value is accepted in case insensitive mode."""
37+
with patch.dict(os.environ, {"TEST_ENV": "option1"}):
38+
env_func = env_with_choices("TEST_ENV",
39+
"default", ["OPTION1", "OPTION2"],
40+
case_sensitive=False)
41+
assert env_func() == "option1"
42+
43+
def test_valid_uppercase_value_returned_case_insensitive(self):
44+
"""Test that uppercase value is accepted in case insensitive mode."""
45+
with patch.dict(os.environ, {"TEST_ENV": "OPTION1"}):
46+
env_func = env_with_choices("TEST_ENV",
47+
"default", ["option1", "option2"],
48+
case_sensitive=False)
49+
assert env_func() == "OPTION1"
50+
51+
def test_invalid_value_raises_error_case_sensitive(self):
52+
"""Test that invalid value raises ValueError in case sensitive mode."""
53+
with patch.dict(os.environ, {"TEST_ENV": "invalid"}):
54+
env_func = env_with_choices("TEST_ENV",
55+
"default", ["option1", "option2"],
56+
case_sensitive=True)
57+
with pytest.raises(ValueError,
58+
match="Invalid value 'invalid' for TEST_ENV"):
59+
env_func()
60+
61+
def test_case_mismatch_raises_error_case_sensitive(self):
62+
"""Test that case mismatch raises ValueError in case sensitive mode."""
63+
with patch.dict(os.environ, {"TEST_ENV": "OPTION1"}):
64+
env_func = env_with_choices("TEST_ENV",
65+
"default", ["option1", "option2"],
66+
case_sensitive=True)
67+
with pytest.raises(ValueError,
68+
match="Invalid value 'OPTION1' for TEST_ENV"):
69+
env_func()
70+
71+
def test_invalid_value_raises_error_case_insensitive(self):
72+
"""Test that invalid value raises ValueError when case insensitive."""
73+
with patch.dict(os.environ, {"TEST_ENV": "invalid"}):
74+
env_func = env_with_choices("TEST_ENV",
75+
"default", ["option1", "option2"],
76+
case_sensitive=False)
77+
with pytest.raises(ValueError,
78+
match="Invalid value 'invalid' for TEST_ENV"):
79+
env_func()
80+
81+
def test_callable_choices_resolved_correctly(self):
82+
"""Test that callable choices are resolved correctly."""
83+
84+
def get_choices():
85+
return ["dynamic1", "dynamic2"]
86+
87+
with patch.dict(os.environ, {"TEST_ENV": "dynamic1"}):
88+
env_func = env_with_choices("TEST_ENV", "default", get_choices)
89+
assert env_func() == "dynamic1"
90+
91+
def test_callable_choices_with_invalid_value(self):
92+
"""Test that callable choices raise error for invalid values."""
93+
94+
def get_choices():
95+
return ["dynamic1", "dynamic2"]
96+
97+
with patch.dict(os.environ, {"TEST_ENV": "invalid"}):
98+
env_func = env_with_choices("TEST_ENV", "default", get_choices)
99+
with pytest.raises(ValueError,
100+
match="Invalid value 'invalid' for TEST_ENV"):
101+
env_func()
102+
103+
104+
class TestEnvListWithChoices:
105+
"""Test cases for env_list_with_choices function."""
106+
107+
def test_default_list_returned_when_env_not_set(self):
108+
"""Test that default list is returned when env var is not set."""
109+
env_func = env_list_with_choices("NONEXISTENT_ENV",
110+
["default1", "default2"],
111+
["option1", "option2"])
112+
assert env_func() == ["default1", "default2"]
113+
114+
def test_empty_default_list_returned_when_env_not_set(self):
115+
"""Test that empty default list is returned when env not set."""
116+
env_func = env_list_with_choices("NONEXISTENT_ENV", [],
117+
["option1", "option2"])
118+
assert env_func() == []
119+
120+
def test_single_valid_value_parsed_correctly(self):
121+
"""Test that single valid value is parsed correctly."""
122+
with patch.dict(os.environ, {"TEST_ENV": "option1"}):
123+
env_func = env_list_with_choices("TEST_ENV", [],
124+
["option1", "option2"])
125+
assert env_func() == ["option1"]
126+
127+
def test_multiple_valid_values_parsed_correctly(self):
128+
"""Test that multiple valid values are parsed correctly."""
129+
with patch.dict(os.environ, {"TEST_ENV": "option1,option2"}):
130+
env_func = env_list_with_choices("TEST_ENV", [],
131+
["option1", "option2"])
132+
assert env_func() == ["option1", "option2"]
133+
134+
def test_values_with_whitespace_trimmed(self):
135+
"""Test that values with whitespace are trimmed correctly."""
136+
with patch.dict(os.environ, {"TEST_ENV": " option1 , option2 "}):
137+
env_func = env_list_with_choices("TEST_ENV", [],
138+
["option1", "option2"])
139+
assert env_func() == ["option1", "option2"]
140+
141+
def test_empty_values_filtered_out(self):
142+
"""Test that empty values are filtered out."""
143+
with patch.dict(os.environ, {"TEST_ENV": "option1,,option2,"}):
144+
env_func = env_list_with_choices("TEST_ENV", [],
145+
["option1", "option2"])
146+
assert env_func() == ["option1", "option2"]
147+
148+
def test_empty_string_returns_default(self):
149+
"""Test that empty string returns default."""
150+
with patch.dict(os.environ, {"TEST_ENV": ""}):
151+
env_func = env_list_with_choices("TEST_ENV", ["default"],
152+
["option1", "option2"])
153+
assert env_func() == ["default"]
154+
155+
def test_only_commas_returns_default(self):
156+
"""Test that string with only commas returns default."""
157+
with patch.dict(os.environ, {"TEST_ENV": ",,,"}):
158+
env_func = env_list_with_choices("TEST_ENV", ["default"],
159+
["option1", "option2"])
160+
assert env_func() == ["default"]
161+
162+
def test_case_sensitive_validation(self):
163+
"""Test case sensitive validation."""
164+
with patch.dict(os.environ, {"TEST_ENV": "option1,OPTION2"}):
165+
env_func = env_list_with_choices("TEST_ENV", [],
166+
["option1", "option2"],
167+
case_sensitive=True)
168+
with pytest.raises(ValueError,
169+
match="Invalid value 'OPTION2' in TEST_ENV"):
170+
env_func()
171+
172+
def test_case_insensitive_validation(self):
173+
"""Test case insensitive validation."""
174+
with patch.dict(os.environ, {"TEST_ENV": "OPTION1,option2"}):
175+
env_func = env_list_with_choices("TEST_ENV", [],
176+
["option1", "option2"],
177+
case_sensitive=False)
178+
assert env_func() == ["OPTION1", "option2"]
179+
180+
def test_invalid_value_in_list_raises_error(self):
181+
"""Test that invalid value in list raises ValueError."""
182+
with patch.dict(os.environ, {"TEST_ENV": "option1,invalid,option2"}):
183+
env_func = env_list_with_choices("TEST_ENV", [],
184+
["option1", "option2"])
185+
with pytest.raises(ValueError,
186+
match="Invalid value 'invalid' in TEST_ENV"):
187+
env_func()
188+
189+
def test_callable_choices_resolved_correctly(self):
190+
"""Test that callable choices are resolved correctly."""
191+
192+
def get_choices():
193+
return ["dynamic1", "dynamic2"]
194+
195+
with patch.dict(os.environ, {"TEST_ENV": "dynamic1,dynamic2"}):
196+
env_func = env_list_with_choices("TEST_ENV", [], get_choices)
197+
assert env_func() == ["dynamic1", "dynamic2"]
198+
199+
def test_callable_choices_with_invalid_value(self):
200+
"""Test that callable choices raise error for invalid values."""
201+
202+
def get_choices():
203+
return ["dynamic1", "dynamic2"]
204+
205+
with patch.dict(os.environ, {"TEST_ENV": "dynamic1,invalid"}):
206+
env_func = env_list_with_choices("TEST_ENV", [], get_choices)
207+
with pytest.raises(ValueError,
208+
match="Invalid value 'invalid' in TEST_ENV"):
209+
env_func()
210+
211+
def test_duplicate_values_preserved(self):
212+
"""Test that duplicate values in the list are preserved."""
213+
with patch.dict(os.environ, {"TEST_ENV": "option1,option1,option2"}):
214+
env_func = env_list_with_choices("TEST_ENV", [],
215+
["option1", "option2"])
216+
assert env_func() == ["option1", "option1", "option2"]

0 commit comments

Comments
 (0)