A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 14, 05:30 UTC
[gpt-oss] Harmony changes with container tool support (#23386)
Signed-off-by: zhiweiz <zhiweiz@fb.com> Signed-off-by: Aaron Pham <contact@aarnphm.xyz> Signed-off-by: Lu Fang <30275821+houseroad@users.noreply.github.com> Co-authored-by: zhiweiz <zhiweiz@fb.com> Co-authored-by: Aaron Pham <contact@aarnphm.xyz> Co-authored-by: Simon Mo <simon.mo@hey.com> Co-authored-by: Lu Fang <30275821+houseroad@users.noreply.github.com>
zhiweiz committed
Sep 8, 2025 at 19:03 UTC
170129eb283aa225f20666a434c02822f643242e
5 files changed
+170
-27
vllm/entrypoints/context.py
+79
-8
@@ -1,5 +1,7 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+import asyncio
4
+import contextlib
5
import json
6
import logging
7
from abc import ABC, abstractmethod
@@ -57,9 +59,14 @@ class ConversationContext(ABC):
59
60
@abstractmethod
61
async def init_tool_sessions(self, tool_server: Optional[ToolServer],
60
- exit_stack: AsyncExitStack) -> None:
62
+ exit_stack: AsyncExitStack,
63
+ request_id: str) -> None:
64
pass
65
66
+ @abstractmethod
67
+ async def cleanup_session(self) -> None:
68
+ raise NotImplementedError("Should not be called.")
69
+
70
71
class SimpleContext(ConversationContext):
72
@@ -89,9 +96,13 @@ class SimpleContext(ConversationContext):
96
raise NotImplementedError("Should not be called.")
97
98
async def init_tool_sessions(self, tool_server: Optional[ToolServer],
92
- exit_stack: AsyncExitStack) -> None:
99
+ exit_stack: AsyncExitStack,
100
+ request_id: str) -> None:
101
pass
102
103
+ async def cleanup_session(self) -> None:
104
+ raise NotImplementedError("Should not be called.")
105
+
106
107
class HarmonyContext(ConversationContext):
108
@@ -103,6 +114,7 @@ class HarmonyContext(ConversationContext):
114
self._messages = messages
115
self.available_tools = available_tools
116
self._tool_sessions: dict[str, Union[ClientSession, Tool]] = {}
117
+ self.called_tools: set[str] = set()
118
119
self.parser = get_streamable_parser_for_assistant()
120
self.num_init_messages = len(messages)
@@ -234,7 +246,8 @@ class HarmonyContext(ConversationContext):
246
last_msg = self.messages[-1]
247
recipient = last_msg.recipient
248
return recipient is not None and (recipient.startswith("browser.")
237
- or recipient.startswith("python"))
249
+ or recipient.startswith("python") or
250
+ recipient.startswith("container."))
251
252
async def call_tool(self) -> list[Message]:
253
if not self.messages:
@@ -248,6 +261,9 @@ class HarmonyContext(ConversationContext):
261
elif recipient.startswith("python"):
262
return await self.call_python_tool(
263
self._tool_sessions["python"], last_msg)
264
+ elif recipient.startswith("container."):
265
+ return await self.call_container_tool(
266
+ self._tool_sessions["container"], last_msg)
267
raise ValueError("No tool call found")
268
269
def render_for_completion(self) -> list[int]:
@@ -256,6 +272,7 @@ class HarmonyContext(ConversationContext):
272
async def call_search_tool(self, tool_session: Union["ClientSession",
273
Tool],
274
last_msg: Message) -> list[Message]:
275
+ self.called_tools.add("browser")
276
if isinstance(tool_session, Tool):
277
return await tool_session.get_result(self)
278
tool_name = last_msg.recipient.split(".")[1]
@@ -265,12 +282,16 @@ class HarmonyContext(ConversationContext):
282
content = TextContent(text=result_str)
283
author = Author(role=Role.TOOL, name=last_msg.recipient)
284
return [
268
- Message(author=author, content=[content], recipient=Role.ASSISTANT)
285
+ Message(author=author,
286
+ content=[content],
287
+ recipient=Role.ASSISTANT,
288
+ channel=last_msg.channel)
289
]
290
291
async def call_python_tool(self, tool_session: Union["ClientSession",
292
Tool],
293
last_msg: Message) -> list[Message]:
294
+ self.called_tools.add("python")
295
if isinstance(tool_session, Tool):
296
return await tool_session.get_result(self)
297
param = {
@@ -290,13 +311,63 @@ class HarmonyContext(ConversationContext):
311
]
312
313
async def init_tool_sessions(self, tool_server: Optional[ToolServer],
293
- exit_stack: AsyncExitStack) -> None:
314
+ exit_stack: AsyncExitStack,
315
+ request_id: str) -> None:
316
if tool_server:
317
for tool_name in self.available_tools:
318
if tool_name not in self._tool_sessions:
297
- self._tool_sessions[
298
- tool_name] = await exit_stack.enter_async_context(
299
- tool_server.new_session(tool_name))
319
+ tool_session = await exit_stack.enter_async_context(
320
+ tool_server.new_session(tool_name, request_id))
321
+ self._tool_sessions[tool_name] = tool_session
322
+ exit_stack.push_async_exit(self.cleanup_session)
323
+
324
+ async def call_container_tool(self, tool_session: Union["ClientSession",
325
+ Tool],
326
+ last_msg: Message) -> list[Message]:
327
+ """
328
+ Call container tool. Expect this to be run in a stateful docker
329
+ with command line terminal.
330
+ The official container tool would at least
331
+ expect the following format:
332
+ - for tool name: exec
333
+ - args:
334
+ {
335
+ "cmd":List[str] "command to execute",
336
+ "workdir":optional[str] "current working directory",
337
+ "env":optional[object/dict] "environment variables",
338
+ "session_name":optional[str] "session name",
339
+ "timeout":optional[int] "timeout in seconds",
340
+ "user":optional[str] "user name",
341
+ }
342
+ """
343
+ self.called_tools.add("container")
344
+ if isinstance(tool_session, Tool):
345
+ return await tool_session.get_result(self)
346
+ tool_name = last_msg.recipient.split(".")[1].split(" ")[0]
347
+ args = json.loads(last_msg.content[0].text)
348
+ result = await tool_session.call_tool(tool_name, args)
349
+ result_str = result.content[0].text
350
+ content = TextContent(text=result_str)
351
+ author = Author(role=Role.TOOL, name=last_msg.recipient)
352
+ return [
353
+ Message(author=author,
354
+ content=[content],
355
+ recipient=Role.ASSISTANT,
356
+ channel=last_msg.channel)
357
+ ]
358
+
359
+ async def cleanup_session(self, *args, **kwargs) -> None:
360
+ """Can be used as coro to used in __aexit__"""
361
+
362
+ async def cleanup_tool_session(tool_session):
363
+ if not isinstance(tool_session, Tool):
364
+ logger.info("Cleaning up tool session for %s",
365
+ tool_session._client_info)
366
+ with contextlib.suppress(Exception):
367
+ await tool_session.call_tool("cleanup_session", {})
368
+
369
+ await asyncio.gather(*(cleanup_tool_session(self._tool_sessions[tool])
370
+ for tool in self.called_tools))
371
372
373
class StreamingHarmonyContext(HarmonyContext):
vllm/entrypoints/harmony_utils.py
+44
-6
@@ -16,11 +16,13 @@ from openai.types.responses.response_function_web_search import (
16
from openai.types.responses.response_reasoning_item import (
17
Content as ResponseReasoningTextContent)
18
from openai.types.responses.tool import Tool
19
-from openai_harmony import (Author, Conversation, DeveloperContent,
20
- HarmonyEncodingName, Message, ReasoningEffort,
21
- Role, StreamableParser, SystemContent, TextContent,
22
- ToolDescription, load_harmony_encoding)
19
+from openai_harmony import (Author, ChannelConfig, Conversation,
20
+ DeveloperContent, HarmonyEncodingName, Message,
21
+ ReasoningEffort, Role, StreamableParser,
22
+ SystemContent, TextContent, ToolDescription,
23
+ load_harmony_encoding)
24
25
+from vllm import envs
26
from vllm.entrypoints.openai.protocol import (ChatCompletionToolsParam,
27
ResponseInputOutputItem)
28
from vllm.utils import random_uuid
@@ -33,6 +35,20 @@ REASONING_EFFORT = {
35
36
_harmony_encoding = None
37
38
+# Builtin tools that should be included in the system message when
39
+# they are available and requested by the user.
40
+# Tool args are provided by MCP tool descriptions. Output
41
+# of the tools are stringified.
42
+BUILTIN_TOOLS = {
43
+ "web_search_preview",
44
+ "code_interpreter",
45
+ "container",
46
+}
47
+
48
+
49
+def has_custom_tools(tool_types: list[str]) -> bool:
50
+ return not set(tool_types).issubset(BUILTIN_TOOLS)
51
+
52
53
def get_encoding():
54
global _harmony_encoding
@@ -48,10 +64,19 @@ def get_system_message(
64
start_date: Optional[str] = None,
65
browser_description: Optional[str] = None,
66
python_description: Optional[str] = None,
67
+ container_description: Optional[str] = None,
68
+ instructions: Optional[str] = None,
69
+ with_custom_tools: bool = False,
70
) -> Message:
71
sys_msg_content = SystemContent.new()
72
if model_identity is not None:
73
sys_msg_content = sys_msg_content.with_model_identity(model_identity)
74
+ if (instructions is not None
75
+ and envs.VLLM_GPT_OSS_HARMONY_SYSTEM_INSTRUCTIONS):
76
+ current_identity = sys_msg_content.model_identity
77
+ new_identity = (f'{current_identity}\n{instructions}'
78
+ if current_identity else instructions)
79
+ sys_msg_content = sys_msg_content.with_model_identity(new_identity)
80
if reasoning_effort is not None:
81
sys_msg_content = sys_msg_content.with_reasoning_effort(
82
REASONING_EFFORT[reasoning_effort])
@@ -63,6 +88,14 @@ def get_system_message(
88
sys_msg_content = sys_msg_content.with_tools(browser_description)
89
if python_description is not None:
90
sys_msg_content = sys_msg_content.with_tools(python_description)
91
+ if container_description is not None:
92
+ sys_msg_content = sys_msg_content.with_tools(container_description)
93
+ if not with_custom_tools:
94
+ channel_config = sys_msg_content.channel_config
95
+ invalid_channel = "commentary"
96
+ new_config = ChannelConfig.require_channels(
97
+ [c for c in channel_config.valid_channels if c != invalid_channel])
98
+ sys_msg_content = sys_msg_content.with_channel_config(new_config)
99
sys_msg = Message.from_role_and_content(Role.SYSTEM, sys_msg_content)
100
return sys_msg
101
@@ -86,14 +119,17 @@ def get_developer_message(
119
tools: Optional[list[Union[Tool, ChatCompletionToolsParam]]] = None,
120
) -> Message:
121
dev_msg_content = DeveloperContent.new()
89
- if instructions is not None:
122
+ if (instructions is not None
123
+ and not envs.VLLM_GPT_OSS_HARMONY_SYSTEM_INSTRUCTIONS):
124
dev_msg_content = dev_msg_content.with_instructions(instructions)
125
if tools is not None:
126
function_tools: list[Union[Tool, ChatCompletionToolsParam]] = []
127
for tool in tools:
94
- if tool.type in ("web_search_preview", "code_interpreter"):
128
+ if tool.type in ("web_search_preview", "code_interpreter",
129
+ "container"):
130
# These are built-in tools that are added to the system message.
131
pass
132
+
133
elif tool.type == "function":
134
function_tools.append(tool)
135
else:
@@ -136,6 +172,8 @@ def parse_response_input(
172
TextContent(text=text_prefix + c["text"]) for c in content
173
]
174
msg = Message.from_role_and_contents(role, contents)
175
+ if role == "assistant":
176
+ msg = msg.with_channel("final")
177
elif response_msg["type"] == "function_call_output":
178
call_id = response_msg["call_id"]
179
call_response: Optional[ResponseFunctionToolCall] = None
vllm/entrypoints/openai/serving_responses.py
+26
-7
@@ -44,8 +44,9 @@ from vllm.entrypoints.context import (ConversationContext, HarmonyContext,
44
SimpleContext, StreamingHarmonyContext)
45
from vllm.entrypoints.harmony_utils import (
46
get_developer_message, get_stop_tokens_for_assistant_actions,
47
- get_system_message, get_user_message, parse_output_message,
48
- parse_remaining_state, parse_response_input, render_for_completion)
47
+ get_system_message, get_user_message, has_custom_tools,
48
+ parse_output_message, parse_remaining_state, parse_response_input,
49
+ render_for_completion)
50
from vllm.entrypoints.logger import RequestLogger
51
# yapf conflicts with isort for this block
52
# yapf: disable
@@ -266,6 +267,8 @@ class OpenAIServingResponses(OpenAIServing):
267
builtin_tool_list.append("browser")
268
if self.tool_server.has_tool("python"):
269
builtin_tool_list.append("python")
270
+ if self.tool_server.has_tool("container"):
271
+ builtin_tool_list.append("container")
272
273
if self.tool_server is not None:
274
available_tools = builtin_tool_list
@@ -448,7 +451,8 @@ class OpenAIServingResponses(OpenAIServing):
451
452
async with AsyncExitStack() as exit_stack:
453
try:
451
- await context.init_tool_sessions(self.tool_server, exit_stack)
454
+ await context.init_tool_sessions(self.tool_server, exit_stack,
455
+ request.request_id)
456
async for _ in result_generator:
457
pass
458
except asyncio.CancelledError:
@@ -710,13 +714,21 @@ class OpenAIServingResponses(OpenAIServing):
714
# New conversation.
715
reasoning_effort = (request.reasoning.effort
716
if request.reasoning else None)
717
+ # Temporary: OpenAI types doesn't have container tool
718
+ # so we used MCP to cover that, up for change
719
tool_types = [tool.type for tool in request.tools]
720
+ if envs.VLLM_GPT_OSS_USE_CONTAINER_TOOL:
721
+ tool_types.append("container")
722
enable_browser = ("web_search_preview" in tool_types
723
and self.tool_server is not None
724
and self.tool_server.has_tool("browser"))
725
enable_code_interpreter = ("code_interpreter" in tool_types
726
and self.tool_server is not None
727
and self.tool_server.has_tool("python"))
728
+ enable_container = ("container" in tool_types
729
+ and self.tool_server is not None
730
+ and self.tool_server.has_tool("container"))
731
+ with_custom_tools = has_custom_tools(tool_types)
732
sys_msg = get_system_message(
733
reasoning_effort=reasoning_effort,
734
browser_description=self.tool_server.get_tool_description(
@@ -725,11 +737,17 @@ class OpenAIServingResponses(OpenAIServing):
737
python_description=self.tool_server.get_tool_description(
738
"python") if enable_code_interpreter
739
and self.tool_server is not None else None,
740
+ container_description=self.tool_server.get_tool_description(
741
+ "container")
742
+ if enable_container and self.tool_server is not None else None,
743
+ instructions=request.instructions,
744
+ with_custom_tools=with_custom_tools,
745
)
746
messages.append(sys_msg)
730
- dev_msg = get_developer_message(request.instructions,
731
- request.tools)
732
- messages.append(dev_msg)
747
+ if with_custom_tools:
748
+ dev_msg = get_developer_message(
749
+ instructions=request.instructions, tools=request.tools)
750
+ messages.append(dev_msg)
751
else:
752
# Continue the previous conversation.
753
# FIXME(woosuk): Currently, request params like reasoning and
@@ -1613,7 +1631,8 @@ class OpenAIServingResponses(OpenAIServing):
1631
async with AsyncExitStack() as exit_stack:
1632
processer = None
1633
if self.use_harmony:
1616
- await context.init_tool_sessions(self.tool_server, exit_stack)
1634
+ await context.init_tool_sessions(self.tool_server, exit_stack,
1635
+ request.request_id)
1636
processer = self._process_harmony_streaming_events
1637
else:
1638
processer = self._process_simple_streaming_events
vllm/entrypoints/tool_server.py
+10
-6
@@ -86,7 +86,8 @@ class ToolServer(ABC):
86
pass
87
88
@abstractmethod
89
- def new_session(self, tool_name: str) -> AbstractAsyncContextManager[Any]:
89
+ def new_session(self, tool_name: str,
90
+ session_id: str) -> AbstractAsyncContextManager[Any]:
91
"""
92
Create a session for the tool.
93
"""
@@ -124,7 +125,8 @@ class MCPToolServer(ToolServer):
125
description=tool.description,
126
parameters=tool.inputSchema)
127
for tool in list_tools_response.tools
127
- ])
128
+ ],
129
+ )
130
self.harmony_tool_descriptions[tool_from_mcp.name] = tool_from_mcp
131
if tool_from_mcp.name not in self.urls:
132
self.urls[tool_from_mcp.name] = url
@@ -142,14 +144,16 @@ class MCPToolServer(ToolServer):
144
return self.harmony_tool_descriptions.get(tool_name)
145
146
@asynccontextmanager
145
- async def new_session(self, tool_name: str):
147
+ async def new_session(self, tool_name: str, session_id: str):
148
from mcp import ClientSession
149
from mcp.client.sse import sse_client
150
url = self.urls.get(tool_name)
151
+ headers = {"x-session-id": session_id}
152
if not url:
153
raise KeyError(f"Tool '{tool_name}' is not supported")
151
- async with sse_client(url=url) as streams, ClientSession(
152
- *streams) as session:
154
+ async with sse_client(url=url,
155
+ headers=headers) as streams, ClientSession(
156
+ *streams) as session:
157
await session.initialize()
158
yield session
159
@@ -182,7 +186,7 @@ class DemoToolServer(ToolServer):
186
raise ValueError(f"Unknown tool {tool_name}")
187
188
@asynccontextmanager
185
- async def new_session(self, tool_name: str):
189
+ async def new_session(self, tool_name: str, session_id: str):
190
if tool_name not in self.tools:
191
raise KeyError(f"Tool '{tool_name}' is not supported")
192
yield self.tools[tool_name]
vllm/envs.py
+11
@@ -168,6 +168,8 @@ if TYPE_CHECKING:
168
VLLM_ALLREDUCE_USE_SYMM_MEM: bool = False
169
VLLM_TUNED_CONFIG_FOLDER: Optional[str] = None
170
VLLM_DISABLE_PAD_FOR_CUDAGRAPH: bool = False
171
+ VLLM_GPT_OSS_USE_CONTAINER_TOOL: bool = False
172
+ VLLM_GPT_OSS_HARMONY_SYSTEM_INSTRUCTIONS: bool = False
173
VLLM_CUSTOM_SCOPES_FOR_PROFILING: bool = False
174
175
@@ -1201,6 +1203,15 @@ environment_variables: dict[str, Callable[[], Any]] = {
1203
"VLLM_TUNED_CONFIG_FOLDER":
1204
lambda: os.getenv("VLLM_TUNED_CONFIG_FOLDER", None),
1205
1206
+ # Allows vllm use container tool
1207
+ "VLLM_GPT_OSS_USE_CONTAINER_TOOL":
1208
+ lambda: bool(int(os.getenv("VLLM_GPT_OSS_USE_CONTAINER_TOOL", "0"))),
1209
+
1210
+ # Allows harmony instructions to be injected on system messages
1211
+ "VLLM_GPT_OSS_HARMONY_SYSTEM_INSTRUCTIONS":
1212
+ lambda: bool(
1213
+ int(os.getenv("VLLM_GPT_OSS_HARMONY_SYSTEM_INSTRUCTIONS", "0"))),
1214
+
1215
# Add optional custom scopes for profiling, disable to avoid overheads
1216
"VLLM_CUSTOM_SCOPES_FOR_PROFILING":
1217
lambda: bool(int(os.getenv("VLLM_CUSTOM_SCOPES_FOR_PROFILING", "0"))),