[Frontend] Add MCP tool streaming support to Responses API (#31761)
Signed-off-by: Daniel Salib <danielsalib@meta.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from openai import OpenAI
|
||||
@@ -13,199 +14,6 @@ from ...utils import RemoteOpenAIServer
|
||||
MODEL_NAME = "openai/gpt-oss-20b"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def monkeypatch_module():
|
||||
from _pytest.monkeypatch import MonkeyPatch
|
||||
|
||||
mpatch = MonkeyPatch()
|
||||
yield mpatch
|
||||
mpatch.undo()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def mcp_disabled_server(monkeypatch_module: pytest.MonkeyPatch):
|
||||
args = ["--enforce-eager", "--tool-server", "demo"]
|
||||
|
||||
with monkeypatch_module.context() as m:
|
||||
m.setenv("VLLM_ENABLE_RESPONSES_API_STORE", "1")
|
||||
m.setenv("PYTHON_EXECUTION_BACKEND", "dangerously_use_uv")
|
||||
# Helps the model follow instructions better
|
||||
m.setenv("VLLM_GPT_OSS_HARMONY_SYSTEM_INSTRUCTIONS", "1")
|
||||
with RemoteOpenAIServer(MODEL_NAME, args) as remote_server:
|
||||
yield remote_server
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def mcp_enabled_server(monkeypatch_module: pytest.MonkeyPatch):
|
||||
args = ["--enforce-eager", "--tool-server", "demo"]
|
||||
|
||||
with monkeypatch_module.context() as m:
|
||||
m.setenv("VLLM_ENABLE_RESPONSES_API_STORE", "1")
|
||||
m.setenv("PYTHON_EXECUTION_BACKEND", "dangerously_use_uv")
|
||||
m.setenv("VLLM_GPT_OSS_SYSTEM_TOOL_MCP_LABELS", "code_interpreter,container")
|
||||
# Helps the model follow instructions better
|
||||
m.setenv("VLLM_GPT_OSS_HARMONY_SYSTEM_INSTRUCTIONS", "1")
|
||||
with RemoteOpenAIServer(MODEL_NAME, args) as remote_server:
|
||||
yield remote_server
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def mcp_disabled_client(mcp_disabled_server):
|
||||
async with mcp_disabled_server.get_async_client() as async_client:
|
||||
yield async_client
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def mcp_enabled_client(mcp_enabled_server):
|
||||
async with mcp_enabled_server.get_async_client() as async_client:
|
||||
yield async_client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("model_name", [MODEL_NAME])
|
||||
async def test_mcp_tool_env_flag_enabled(mcp_enabled_client: OpenAI, model_name: str):
|
||||
response = await mcp_enabled_client.responses.create(
|
||||
model=model_name,
|
||||
input=(
|
||||
"Execute the following code: "
|
||||
"import random; print(random.randint(1, 1000000))"
|
||||
),
|
||||
instructions=(
|
||||
"You must use the Python tool to execute code. Never simulate execution."
|
||||
),
|
||||
tools=[
|
||||
{
|
||||
"type": "mcp",
|
||||
"server_label": "code_interpreter",
|
||||
# URL unused for DemoToolServer
|
||||
"server_url": "http://localhost:8888",
|
||||
}
|
||||
],
|
||||
extra_body={"enable_response_messages": True},
|
||||
)
|
||||
assert response is not None
|
||||
assert response.status == "completed"
|
||||
# Verify output messages: Tool calls and responses on analysis channel
|
||||
tool_call_found = False
|
||||
tool_response_found = False
|
||||
for message in response.output_messages:
|
||||
recipient = message.get("recipient")
|
||||
if recipient and recipient.startswith("python"):
|
||||
tool_call_found = True
|
||||
assert message.get("channel") == "analysis", (
|
||||
"Tool call should be on analysis channel"
|
||||
)
|
||||
author = message.get("author", {})
|
||||
if (
|
||||
author.get("role") == "tool"
|
||||
and author.get("name")
|
||||
and author.get("name").startswith("python")
|
||||
):
|
||||
tool_response_found = True
|
||||
assert message.get("channel") == "analysis", (
|
||||
"Tool response should be on analysis channel"
|
||||
)
|
||||
|
||||
assert tool_call_found, "Should have found at least one Python tool call"
|
||||
assert tool_response_found, "Should have found at least one Python tool response"
|
||||
for message in response.input_messages:
|
||||
assert message.get("author").get("role") != "developer", (
|
||||
"No developer messages should be present with valid mcp tool"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("model_name", [MODEL_NAME])
|
||||
async def test_mcp_tool_with_allowed_tools_star(
|
||||
mcp_enabled_client: OpenAI, model_name: str
|
||||
):
|
||||
"""Test MCP tool with allowed_tools=['*'] to select all available tools.
|
||||
|
||||
This E2E test verifies that the "*" wildcard works end-to-end.
|
||||
See test_serving_responses.py for detailed unit tests of "*" normalization.
|
||||
"""
|
||||
response = await mcp_enabled_client.responses.create(
|
||||
model=model_name,
|
||||
input=(
|
||||
"Execute the following code: "
|
||||
"import random; print(random.randint(1, 1000000))"
|
||||
),
|
||||
instructions=(
|
||||
"You must use the Python tool to execute code. Never simulate execution."
|
||||
),
|
||||
tools=[
|
||||
{
|
||||
"type": "mcp",
|
||||
"server_label": "code_interpreter",
|
||||
"server_url": "http://localhost:8888",
|
||||
# Using "*" to allow all tools from this MCP server
|
||||
"allowed_tools": ["*"],
|
||||
}
|
||||
],
|
||||
extra_body={"enable_response_messages": True},
|
||||
)
|
||||
assert response is not None
|
||||
assert response.status == "completed"
|
||||
# Verify tool calls work with allowed_tools=["*"]
|
||||
tool_call_found = False
|
||||
for message in response.output_messages:
|
||||
recipient = message.get("recipient")
|
||||
if recipient and recipient.startswith("python"):
|
||||
tool_call_found = True
|
||||
break
|
||||
assert tool_call_found, "Should have found at least one Python tool call with '*'"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("model_name", [MODEL_NAME])
|
||||
async def test_mcp_tool_env_flag_disabled(mcp_disabled_client: OpenAI, model_name: str):
|
||||
response = await mcp_disabled_client.responses.create(
|
||||
model=model_name,
|
||||
input=(
|
||||
"Execute the following code if the tool is present: "
|
||||
"import random; print(random.randint(1, 1000000))"
|
||||
),
|
||||
tools=[
|
||||
{
|
||||
"type": "mcp",
|
||||
"server_label": "code_interpreter",
|
||||
# URL unused for DemoToolServer
|
||||
"server_url": "http://localhost:8888",
|
||||
}
|
||||
],
|
||||
extra_body={"enable_response_messages": True},
|
||||
)
|
||||
assert response is not None
|
||||
assert response.status == "completed"
|
||||
# Verify output messages: No tool calls and responses
|
||||
tool_call_found = False
|
||||
tool_response_found = False
|
||||
for message in response.output_messages:
|
||||
recipient = message.get("recipient")
|
||||
if recipient and recipient.startswith("python"):
|
||||
tool_call_found = True
|
||||
assert message.get("channel") == "analysis", (
|
||||
"Tool call should be on analysis channel"
|
||||
)
|
||||
author = message.get("author", {})
|
||||
if (
|
||||
author.get("role") == "tool"
|
||||
and author.get("name")
|
||||
and author.get("name").startswith("python")
|
||||
):
|
||||
tool_response_found = True
|
||||
assert message.get("channel") == "analysis", (
|
||||
"Tool response should be on analysis channel"
|
||||
)
|
||||
|
||||
assert not tool_call_found, "Should not have a python call"
|
||||
assert not tool_response_found, "Should not have a tool response"
|
||||
for message in response.input_messages:
|
||||
assert message.get("author").get("role") != "developer", (
|
||||
"No developer messages should be present without a valid tool"
|
||||
)
|
||||
|
||||
|
||||
def test_get_tool_description():
|
||||
"""Test MCPToolServer.get_tool_description filtering logic.
|
||||
|
||||
@@ -249,7 +57,10 @@ def test_get_tool_description():
|
||||
assert result.tools[1].name == "tool3"
|
||||
|
||||
# Single tool
|
||||
result = server.get_tool_description("test_server", allowed_tools=["tool2"])
|
||||
result = server.get_tool_description(
|
||||
"test_server",
|
||||
allowed_tools=["tool2"],
|
||||
)
|
||||
assert len(result.tools) == 1
|
||||
assert result.tools[0].name == "tool2"
|
||||
|
||||
@@ -259,3 +70,283 @@ def test_get_tool_description():
|
||||
|
||||
# Empty list - returns None
|
||||
assert server.get_tool_description("test_server", allowed_tools=[]) is None
|
||||
|
||||
|
||||
class TestMCPEnabled:
|
||||
"""Tests that require MCP tools to be enabled via environment variable."""
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def monkeypatch_class(self):
|
||||
from _pytest.monkeypatch import MonkeyPatch
|
||||
|
||||
mpatch = MonkeyPatch()
|
||||
yield mpatch
|
||||
mpatch.undo()
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def mcp_enabled_server(self, monkeypatch_class: pytest.MonkeyPatch):
|
||||
args = ["--enforce-eager", "--tool-server", "demo"]
|
||||
|
||||
with monkeypatch_class.context() as m:
|
||||
m.setenv("VLLM_ENABLE_RESPONSES_API_STORE", "1")
|
||||
m.setenv("PYTHON_EXECUTION_BACKEND", "dangerously_use_uv")
|
||||
m.setenv(
|
||||
"VLLM_GPT_OSS_SYSTEM_TOOL_MCP_LABELS", "code_interpreter,container"
|
||||
)
|
||||
# Helps the model follow instructions better
|
||||
m.setenv("VLLM_GPT_OSS_HARMONY_SYSTEM_INSTRUCTIONS", "1")
|
||||
with RemoteOpenAIServer(MODEL_NAME, args) as remote_server:
|
||||
yield remote_server
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def mcp_enabled_client(self, mcp_enabled_server):
|
||||
async with mcp_enabled_server.get_async_client() as async_client:
|
||||
yield async_client
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("model_name", [MODEL_NAME])
|
||||
async def test_mcp_tool_env_flag_enabled(
|
||||
self, mcp_enabled_client: OpenAI, model_name: str
|
||||
):
|
||||
response = await mcp_enabled_client.responses.create(
|
||||
model=model_name,
|
||||
input=(
|
||||
"Execute the following code: "
|
||||
"import random; print(random.randint(1, 1000000))"
|
||||
),
|
||||
instructions=(
|
||||
"You must use the Python tool to execute code. "
|
||||
"Never simulate execution."
|
||||
),
|
||||
tools=[
|
||||
{
|
||||
"type": "mcp",
|
||||
"server_label": "code_interpreter",
|
||||
# URL unused for DemoToolServer
|
||||
"server_url": "http://localhost:8888",
|
||||
}
|
||||
],
|
||||
extra_body={"enable_response_messages": True},
|
||||
)
|
||||
assert response is not None
|
||||
assert response.status == "completed"
|
||||
# Verify output messages: Tool calls and responses on analysis channel
|
||||
tool_call_found = False
|
||||
tool_response_found = False
|
||||
for message in response.output_messages:
|
||||
recipient = message.get("recipient")
|
||||
if recipient and recipient.startswith("python"):
|
||||
tool_call_found = True
|
||||
assert message.get("channel") == "analysis", (
|
||||
"Tool call should be on analysis channel"
|
||||
)
|
||||
author = message.get("author", {})
|
||||
if (
|
||||
author.get("role") == "tool"
|
||||
and author.get("name")
|
||||
and author.get("name").startswith("python")
|
||||
):
|
||||
tool_response_found = True
|
||||
assert message.get("channel") == "analysis", (
|
||||
"Tool response should be on analysis channel"
|
||||
)
|
||||
|
||||
assert tool_call_found, "Should have found at least one Python tool call"
|
||||
assert tool_response_found, (
|
||||
"Should have found at least one Python tool response"
|
||||
)
|
||||
for message in response.input_messages:
|
||||
assert message.get("author").get("role") != "developer", (
|
||||
"No developer messages should be present with valid mcp tool"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("model_name", [MODEL_NAME])
|
||||
async def test_mcp_tool_with_allowed_tools_star(
|
||||
self, mcp_enabled_client: OpenAI, model_name: str
|
||||
):
|
||||
"""Test MCP tool with allowed_tools=['*'] to select all available
|
||||
tools.
|
||||
|
||||
This E2E test verifies that the "*" wildcard works end-to-end.
|
||||
See test_serving_responses.py for detailed unit tests of "*"
|
||||
normalization.
|
||||
"""
|
||||
response = await mcp_enabled_client.responses.create(
|
||||
model=model_name,
|
||||
input=(
|
||||
"Execute the following code: "
|
||||
"import random; print(random.randint(1, 1000000))"
|
||||
),
|
||||
instructions=(
|
||||
"You must use the Python tool to execute code. "
|
||||
"Never simulate execution."
|
||||
),
|
||||
tools=[
|
||||
{
|
||||
"type": "mcp",
|
||||
"server_label": "code_interpreter",
|
||||
"server_url": "http://localhost:8888",
|
||||
# Using "*" to allow all tools from this MCP server
|
||||
"allowed_tools": ["*"],
|
||||
}
|
||||
],
|
||||
extra_body={"enable_response_messages": True},
|
||||
)
|
||||
assert response is not None
|
||||
assert response.status == "completed"
|
||||
# Verify tool calls work with allowed_tools=["*"]
|
||||
tool_call_found = False
|
||||
for message in response.output_messages:
|
||||
recipient = message.get("recipient")
|
||||
if recipient and recipient.startswith("python"):
|
||||
tool_call_found = True
|
||||
break
|
||||
assert tool_call_found, (
|
||||
"Should have found at least one Python tool call with '*'"
|
||||
)
|
||||
|
||||
@pytest.mark.flaky(reruns=3)
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("model_name", [MODEL_NAME])
|
||||
async def test_mcp_tool_calling_streaming_types(
|
||||
self, mcp_enabled_client: OpenAI, model_name: str
|
||||
):
|
||||
pairs_of_event_types = {
|
||||
"response.completed": "response.created",
|
||||
"response.output_item.done": "response.output_item.added",
|
||||
"response.content_part.done": "response.content_part.added",
|
||||
"response.output_text.done": "response.output_text.delta",
|
||||
"response.reasoning_text.done": "response.reasoning_text.delta",
|
||||
"response.reasoning_part.done": "response.reasoning_part.added",
|
||||
"response.mcp_call_arguments.done": ("response.mcp_call_arguments.delta"),
|
||||
"response.mcp_call.completed": "response.mcp_call.in_progress",
|
||||
}
|
||||
|
||||
tools = [
|
||||
{
|
||||
"type": "mcp",
|
||||
"server_label": "code_interpreter",
|
||||
}
|
||||
]
|
||||
input_text = "What is 13 * 24? Use python to calculate the result."
|
||||
|
||||
stream_response = await mcp_enabled_client.responses.create(
|
||||
model=model_name,
|
||||
input=input_text,
|
||||
tools=tools,
|
||||
stream=True,
|
||||
instructions=(
|
||||
"You must use the Python tool to execute code. "
|
||||
"Never simulate execution."
|
||||
),
|
||||
)
|
||||
|
||||
stack_of_event_types = []
|
||||
saw_mcp_type = False
|
||||
async for event in stream_response:
|
||||
if event.type == "response.created":
|
||||
stack_of_event_types.append(event.type)
|
||||
elif event.type == "response.completed":
|
||||
assert stack_of_event_types[-1] == pairs_of_event_types[event.type]
|
||||
stack_of_event_types.pop()
|
||||
elif (
|
||||
event.type.endswith("added")
|
||||
or event.type == "response.mcp_call.in_progress"
|
||||
):
|
||||
stack_of_event_types.append(event.type)
|
||||
elif event.type.endswith("delta"):
|
||||
if stack_of_event_types[-1] == event.type:
|
||||
continue
|
||||
stack_of_event_types.append(event.type)
|
||||
elif (
|
||||
event.type.endswith("done")
|
||||
or event.type == "response.mcp_call.completed"
|
||||
):
|
||||
assert stack_of_event_types[-1] == pairs_of_event_types[event.type]
|
||||
if "mcp_call" in event.type:
|
||||
saw_mcp_type = True
|
||||
stack_of_event_types.pop()
|
||||
|
||||
assert len(stack_of_event_types) == 0
|
||||
assert saw_mcp_type, "Should have seen at least one mcp call"
|
||||
|
||||
|
||||
class TestMCPDisabled:
|
||||
"""Tests that verify behavior when MCP tools are disabled."""
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def monkeypatch_class(self):
|
||||
from _pytest.monkeypatch import MonkeyPatch
|
||||
|
||||
mpatch = MonkeyPatch()
|
||||
yield mpatch
|
||||
mpatch.undo()
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def mcp_disabled_server(self, monkeypatch_class: pytest.MonkeyPatch):
|
||||
args = ["--enforce-eager", "--tool-server", "demo"]
|
||||
|
||||
with monkeypatch_class.context() as m:
|
||||
m.setenv("VLLM_ENABLE_RESPONSES_API_STORE", "1")
|
||||
m.setenv("PYTHON_EXECUTION_BACKEND", "dangerously_use_uv")
|
||||
# Helps the model follow instructions better
|
||||
m.setenv("VLLM_GPT_OSS_HARMONY_SYSTEM_INSTRUCTIONS", "1")
|
||||
with RemoteOpenAIServer(MODEL_NAME, args) as remote_server:
|
||||
yield remote_server
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def mcp_disabled_client(self, mcp_disabled_server):
|
||||
async with mcp_disabled_server.get_async_client() as async_client:
|
||||
yield async_client
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("model_name", [MODEL_NAME])
|
||||
async def test_mcp_tool_env_flag_disabled(
|
||||
self, mcp_disabled_client: OpenAI, model_name: str
|
||||
):
|
||||
response = await mcp_disabled_client.responses.create(
|
||||
model=model_name,
|
||||
input=(
|
||||
"Execute the following code if the tool is present: "
|
||||
"import random; print(random.randint(1, 1000000))"
|
||||
),
|
||||
tools=[
|
||||
{
|
||||
"type": "mcp",
|
||||
"server_label": "code_interpreter",
|
||||
# URL unused for DemoToolServer
|
||||
"server_url": "http://localhost:8888",
|
||||
}
|
||||
],
|
||||
extra_body={"enable_response_messages": True},
|
||||
)
|
||||
assert response is not None
|
||||
assert response.status == "completed"
|
||||
# Verify output messages: No tool calls and responses
|
||||
tool_call_found = False
|
||||
tool_response_found = False
|
||||
for message in response.output_messages:
|
||||
recipient = message.get("recipient")
|
||||
if recipient and recipient.startswith("python"):
|
||||
tool_call_found = True
|
||||
assert message.get("channel") == "analysis", (
|
||||
"Tool call should be on analysis channel"
|
||||
)
|
||||
author = message.get("author", {})
|
||||
if (
|
||||
author.get("role") == "tool"
|
||||
and author.get("name")
|
||||
and author.get("name").startswith("python")
|
||||
):
|
||||
tool_response_found = True
|
||||
assert message.get("channel") == "analysis", (
|
||||
"Tool response should be on analysis channel"
|
||||
)
|
||||
|
||||
assert not tool_call_found, "Should not have a python call"
|
||||
assert not tool_response_found, "Should not have a tool response"
|
||||
for message in response.input_messages:
|
||||
assert message.get("author").get("role") != "developer", (
|
||||
"No developer messages should be present without a valid tool"
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
import importlib
|
||||
import importlib.util
|
||||
import json
|
||||
import time
|
||||
@@ -44,6 +43,8 @@ def server():
|
||||
env_dict = dict(
|
||||
VLLM_ENABLE_RESPONSES_API_STORE="1",
|
||||
PYTHON_EXECUTION_BACKEND="dangerously_use_uv",
|
||||
VLLM_GPT_OSS_SYSTEM_TOOL_MCP_LABELS="code_interpreter,container,web_search_preview",
|
||||
VLLM_GPT_OSS_HARMONY_SYSTEM_INSTRUCTIONS="1",
|
||||
)
|
||||
|
||||
with RemoteOpenAIServer(MODEL_NAME, args, env_dict=env_dict) as remote_server:
|
||||
@@ -855,6 +856,237 @@ async def test_function_calling_with_stream(client: OpenAI, model_name: str):
|
||||
assert event.response.output_text is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("model_name", [MODEL_NAME])
|
||||
async def test_function_calling_no_code_interpreter_events(
|
||||
client: OpenAI, model_name: str
|
||||
):
|
||||
"""Verify that function calls don't trigger code_interpreter events.
|
||||
|
||||
This test ensures that function calls (functions.*) use their own
|
||||
function_call event types and don't incorrectly emit code_interpreter
|
||||
events during streaming.
|
||||
"""
|
||||
tools = [GET_WEATHER_SCHEMA]
|
||||
input_list = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "What's the weather like in Paris today?",
|
||||
}
|
||||
]
|
||||
stream_response = await client.responses.create(
|
||||
model=model_name,
|
||||
input=input_list,
|
||||
tools=tools,
|
||||
stream=True,
|
||||
)
|
||||
|
||||
# Track which event types we see
|
||||
event_types_seen = set()
|
||||
function_call_found = False
|
||||
|
||||
async for event in stream_response:
|
||||
event_types_seen.add(event.type)
|
||||
|
||||
if (
|
||||
event.type == "response.output_item.added"
|
||||
and event.item.type == "function_call"
|
||||
):
|
||||
function_call_found = True
|
||||
|
||||
# Ensure NO code_interpreter events are emitted for function calls
|
||||
assert "code_interpreter" not in event.type, (
|
||||
"Found code_interpreter event "
|
||||
f"'{event.type}' during function call. Function calls should only "
|
||||
"emit function_call events, not code_interpreter events."
|
||||
)
|
||||
|
||||
# Verify we actually saw a function call
|
||||
assert function_call_found, "Expected to see a function_call in the stream"
|
||||
|
||||
# Verify we saw the correct function call event types
|
||||
assert (
|
||||
"response.function_call_arguments.delta" in event_types_seen
|
||||
or "response.function_call_arguments.done" in event_types_seen
|
||||
), "Expected to see function_call_arguments events"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("model_name", [MODEL_NAME])
|
||||
async def test_mcp_code_interpreter_streaming(client: OpenAI, model_name: str, server):
|
||||
tools = [
|
||||
{
|
||||
"type": "mcp",
|
||||
"server_label": "code_interpreter",
|
||||
}
|
||||
]
|
||||
input_text = (
|
||||
"Calculate 15 * 32 using python. "
|
||||
"The python interpreter is not stateful and you must print to see the output."
|
||||
)
|
||||
|
||||
stream_response = await client.responses.create(
|
||||
model=model_name,
|
||||
input=input_text,
|
||||
tools=tools,
|
||||
stream=True,
|
||||
temperature=0.0,
|
||||
instructions=(
|
||||
"You must use the Python tool to execute code. Never simulate execution."
|
||||
),
|
||||
)
|
||||
|
||||
mcp_call_added = False
|
||||
mcp_call_in_progress = False
|
||||
mcp_arguments_delta_seen = False
|
||||
mcp_arguments_done = False
|
||||
mcp_call_completed = False
|
||||
mcp_item_done = False
|
||||
|
||||
code_interpreter_events_seen = False
|
||||
|
||||
async for event in stream_response:
|
||||
if "code_interpreter" in event.type:
|
||||
code_interpreter_events_seen = True
|
||||
|
||||
if event.type == "response.output_item.added":
|
||||
if hasattr(event.item, "type") and event.item.type == "mcp_call":
|
||||
mcp_call_added = True
|
||||
assert event.item.name == "python"
|
||||
assert event.item.server_label == "code_interpreter"
|
||||
|
||||
elif event.type == "response.mcp_call.in_progress":
|
||||
mcp_call_in_progress = True
|
||||
|
||||
elif event.type == "response.mcp_call_arguments.delta":
|
||||
mcp_arguments_delta_seen = True
|
||||
assert event.delta is not None
|
||||
|
||||
elif event.type == "response.mcp_call_arguments.done":
|
||||
mcp_arguments_done = True
|
||||
assert event.name == "python"
|
||||
assert event.arguments is not None
|
||||
|
||||
elif event.type == "response.mcp_call.completed":
|
||||
mcp_call_completed = True
|
||||
|
||||
elif (
|
||||
event.type == "response.output_item.done"
|
||||
and hasattr(event.item, "type")
|
||||
and event.item.type == "mcp_call"
|
||||
):
|
||||
mcp_item_done = True
|
||||
assert event.item.name == "python"
|
||||
assert event.item.status == "completed"
|
||||
|
||||
assert mcp_call_added, "MCP call was not added"
|
||||
assert mcp_call_in_progress, "MCP call in_progress event not seen"
|
||||
assert mcp_arguments_delta_seen, "MCP arguments delta event not seen"
|
||||
assert mcp_arguments_done, "MCP arguments done event not seen"
|
||||
assert mcp_call_completed, "MCP call completed event not seen"
|
||||
assert mcp_item_done, "MCP item done event not seen"
|
||||
|
||||
assert not code_interpreter_events_seen, (
|
||||
"Should not see code_interpreter events when using MCP type"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("model_name", [MODEL_NAME])
|
||||
async def test_mcp_tool_multi_turn(client: OpenAI, model_name: str, server):
|
||||
"""Test MCP tool calling across multiple turns.
|
||||
|
||||
This test verifies that MCP tools work correctly in multi-turn conversations,
|
||||
maintaining state across turns via the previous_response_id mechanism.
|
||||
"""
|
||||
tools = [
|
||||
{
|
||||
"type": "mcp",
|
||||
"server_label": "code_interpreter",
|
||||
}
|
||||
]
|
||||
|
||||
# First turn - make a calculation
|
||||
response1 = await client.responses.create(
|
||||
model=model_name,
|
||||
input="Calculate 123 * 456 using python and print the result.",
|
||||
tools=tools,
|
||||
temperature=0.0,
|
||||
instructions=(
|
||||
"You must use the Python tool to execute code. Never simulate execution."
|
||||
),
|
||||
extra_body={"enable_response_messages": True},
|
||||
)
|
||||
|
||||
assert response1 is not None
|
||||
assert response1.status == "completed"
|
||||
|
||||
# Verify MCP call in first response by checking output_messages
|
||||
tool_call_found = False
|
||||
tool_response_found = False
|
||||
for message in response1.output_messages:
|
||||
recipient = message.get("recipient")
|
||||
if recipient and recipient.startswith("python"):
|
||||
tool_call_found = True
|
||||
|
||||
author = message.get("author", {})
|
||||
if (
|
||||
author.get("role") == "tool"
|
||||
and author.get("name")
|
||||
and author.get("name").startswith("python")
|
||||
):
|
||||
tool_response_found = True
|
||||
|
||||
# Verify MCP tools were actually used
|
||||
assert tool_call_found, "MCP tool call not found in output_messages"
|
||||
assert tool_response_found, "MCP tool response not found in output_messages"
|
||||
|
||||
# Verify input messages: Should have system message with tool, NO developer message
|
||||
developer_messages = [
|
||||
msg for msg in response1.input_messages if msg["author"]["role"] == "developer"
|
||||
]
|
||||
assert len(developer_messages) == 0, (
|
||||
"No developer message expected for elevated tools"
|
||||
)
|
||||
|
||||
# Second turn - reference previous calculation
|
||||
response2 = await client.responses.create(
|
||||
model=model_name,
|
||||
input="Now divide that result by 2.",
|
||||
tools=tools,
|
||||
temperature=0.0,
|
||||
instructions=(
|
||||
"You must use the Python tool to execute code. Never simulate execution."
|
||||
),
|
||||
previous_response_id=response1.id,
|
||||
extra_body={"enable_response_messages": True},
|
||||
)
|
||||
|
||||
assert response2 is not None
|
||||
assert response2.status == "completed"
|
||||
|
||||
# Verify input messages are correct: should have two messages -
|
||||
# one to the python recipient on analysis channel and one from tool role
|
||||
mcp_recipient_messages = []
|
||||
tool_role_messages = []
|
||||
for msg in response2.input_messages:
|
||||
if msg["author"]["role"] == "assistant":
|
||||
# Check if this is a message to MCP recipient on analysis channel
|
||||
if msg.get("channel") == "analysis" and msg.get("recipient"):
|
||||
recipient = msg.get("recipient")
|
||||
if recipient.startswith("code_interpreter") or recipient == "python":
|
||||
mcp_recipient_messages.append(msg)
|
||||
elif msg["author"]["role"] == "tool":
|
||||
tool_role_messages.append(msg)
|
||||
|
||||
assert len(mcp_recipient_messages) > 0, (
|
||||
"Expected message(s) to MCP recipient on analysis channel"
|
||||
)
|
||||
assert len(tool_role_messages) > 0, (
|
||||
"Expected message(s) from tool role after MCP call"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("model_name", [MODEL_NAME])
|
||||
async def test_output_messages_enabled(client: OpenAI, model_name: str, server):
|
||||
|
||||
Reference in New Issue
Block a user