[Bugfix][Responses API] Preserve built-in tool output call IDs (#55596)
Signed-off-by: CorgiBoyG <111257566+CorgiBoyG@users.noreply.github.com> Co-authored-by: Flora Feng <4florafeng@gmail.com>
RongJie G committed
Sep 30, 2026 at 06:45 UTC
0755e69e75838babf3eca3fcdb74799b92aa3ea2
3 files changed
+78
-10
tests/entrypoints/openai/responses/test_parsable_context_unit.py
+64
-1
@@ -6,10 +6,13 @@ These tests verify that ParsableContext correctly delegates to the unified
6
Parser (via parse) and properly builds response output items.
7
"""
8
9
+import json
10
from collections.abc import Sequence
10
-from unittest.mock import MagicMock
11
+from types import SimpleNamespace
12
+from unittest.mock import AsyncMock, MagicMock
13
14
import pytest
15
+from openai.types.responses import ResponseFunctionToolCall
16
17
from vllm.entrypoints.generate.base.protocol import (
18
DeltaMessage,
@@ -17,6 +20,7 @@ from vllm.entrypoints.generate.base.protocol import (
20
FunctionCall,
21
ToolCall,
22
)
23
+from vllm.entrypoints.mcp.tool import HarmonyPythonTool
24
from vllm.entrypoints.openai.responses.context import ParsableContext
25
from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
26
from vllm.outputs import CompletionOutput, RequestOutput
@@ -317,6 +321,65 @@ def test_process_extracts_tool_calls():
321
assert tool_item.status == "completed"
322
323
324
+@pytest.mark.parametrize(
325
+ ("tool_name", "session_name", "dispatched_name", "arguments"),
326
+ [
327
+ ("code_interpreter", "python", "python", {"code": "print(42)"}),
328
+ ("web_search_preview", "browser", "search", {"query": "vLLM"}),
329
+ ("container.exec", "container", "exec", {"cmd": ["pwd"]}),
330
+ ],
331
+)
332
+@pytest.mark.asyncio
333
+async def test_builtin_tool_output_preserves_function_call_id(
334
+ tool_name, session_name, dispatched_name, arguments
335
+):
336
+ """Built-in tool outputs remain correlated with their originating call."""
337
+ tool_session = MagicMock()
338
+ tool_session.call_tool = AsyncMock(
339
+ return_value=SimpleNamespace(content=[SimpleNamespace(text="result")])
340
+ )
341
+ context = _make_context(None, available_tools=[session_name])
342
+ tool_call = ResponseFunctionToolCall(
343
+ id=f"fc_{session_name}",
344
+ call_id=f"call_{session_name}",
345
+ type="function_call",
346
+ name=tool_name,
347
+ arguments=json.dumps(arguments),
348
+ )
349
+ context.response_messages.append(tool_call)
350
+ context._tool_sessions[session_name] = tool_session
351
+
352
+ output = await context.call_tool()
353
+
354
+ tool_session.call_tool.assert_awaited_once_with(dispatched_name, arguments)
355
+ assert output[0].call_id == tool_call.call_id
356
+
357
+
358
+@pytest.mark.asyncio
359
+async def test_local_python_tool_output_preserves_function_call_id():
360
+ """Local code-interpreter output remains correlated with its call."""
361
+
362
+ async def process(_):
363
+ yield SimpleNamespace(content=[SimpleNamespace(text="result")])
364
+
365
+ python_tool = object.__new__(HarmonyPythonTool)
366
+ python_tool.python_tool = MagicMock(process=process)
367
+ context = _make_context(None, available_tools=["python"])
368
+ tool_call = ResponseFunctionToolCall(
369
+ id="fc_python",
370
+ call_id="call_python",
371
+ type="function_call",
372
+ name="code_interpreter",
373
+ arguments='{"code": "print(42)"}',
374
+ )
375
+ context.response_messages.append(tool_call)
376
+ context._tool_sessions["python"] = python_tool
377
+
378
+ output = await context.call_tool()
379
+
380
+ assert output[0].call_id == tool_call.call_id
381
+
382
+
383
# ---------------------------------------------------------------------------
384
# Tests: finish_reason tracking
385
# ---------------------------------------------------------------------------
vllm/entrypoints/mcp/tool.py
+1
-1
@@ -173,7 +173,7 @@ class HarmonyPythonTool(Tool):
173
processed = ResponseFunctionToolCallOutputItem(
174
id=f"fco_{random_uuid()}",
175
type="function_call_output",
176
- call_id=f"call_{random_uuid()}",
176
+ call_id=last_msg.call_id,
177
output=msg.content[0].text,
178
status="completed",
179
)
vllm/entrypoints/openai/responses/context.py
+13
-8
@@ -24,7 +24,6 @@ from vllm import envs
24
from vllm.entrypoints.chat_utils import (
25
ChatTemplateContentFormatOption,
26
)
27
-from vllm.entrypoints.generate.base.protocol import FunctionCall
27
from vllm.entrypoints.mcp.tool import Tool
28
from vllm.entrypoints.mcp.tool_server import ToolServer
29
from vllm.entrypoints.openai.parser.harmony_utils import render_for_completion
@@ -440,7 +439,9 @@ class ParsableContext(ConversationContext):
439
return False
440
441
async def call_python_tool(
443
- self, tool_session: Union["ClientSession", Tool], last_msg: FunctionCall
442
+ self,
443
+ tool_session: Union["ClientSession", Tool],
444
+ last_msg: ResponseFunctionToolCall,
445
) -> list[ResponseInputOutputItem]:
446
self.called_tools.add("python")
447
if isinstance(tool_session, Tool):
@@ -455,7 +456,7 @@ class ParsableContext(ConversationContext):
456
message = ResponseFunctionToolCallOutputItem(
457
id=f"mcpo_{random_uuid()}",
458
type="function_call_output",
458
- call_id=f"call_{random_uuid()}",
459
+ call_id=last_msg.call_id,
460
output=result_str,
461
status="completed",
462
)
@@ -463,7 +464,9 @@ class ParsableContext(ConversationContext):
464
return [message]
465
466
async def call_search_tool(
466
- self, tool_session: Union["ClientSession", Tool], last_msg: FunctionCall
467
+ self,
468
+ tool_session: Union["ClientSession", Tool],
469
+ last_msg: ResponseFunctionToolCall,
470
) -> list[ResponseInputOutputItem]:
471
self.called_tools.add("browser")
472
if isinstance(tool_session, Tool):
@@ -481,7 +484,7 @@ class ParsableContext(ConversationContext):
484
message = ResponseFunctionToolCallOutputItem(
485
id=f"fco_{random_uuid()}",
486
type="function_call_output",
484
- call_id=f"call_{random_uuid()}",
487
+ call_id=last_msg.call_id,
488
output=result_str,
489
status="completed",
490
)
@@ -489,8 +492,10 @@ class ParsableContext(ConversationContext):
492
return [message]
493
494
async def call_container_tool(
492
- self, tool_session: Union["ClientSession", Tool], last_msg: Message
493
- ) -> list[Message]:
495
+ self,
496
+ tool_session: Union["ClientSession", Tool],
497
+ last_msg: ResponseFunctionToolCall,
498
+ ) -> list[ResponseInputOutputItem]:
499
"""Call container tool. Expect this to be run in a stateful docker
500
with command line terminal.
501
The official container tool would at least
@@ -523,7 +528,7 @@ class ParsableContext(ConversationContext):
528
message = ResponseFunctionToolCallOutputItem(
529
id=f"fco_{random_uuid()}",
530
type="function_call_output",
526
- call_id=f"call_{random_uuid()}",
531
+ call_id=last_msg.call_id,
532
output=result_str,
533
status="completed",
534
)