main
py 488 lines 17.3 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Harmony ↔ Responses API conversion utilities.
4
5 Handles two directions:
6 1. Response Input → Harmony Messages (input parsing)
7 2. Harmony Messages → Response Output Items (output parsing)
8 """
9
10 import json
11
12 from openai.types.responses import (
13 ResponseFunctionToolCall,
14 ResponseOutputItem,
15 ResponseOutputMessage,
16 ResponseOutputText,
17 ResponseReasoningItem,
18 )
19 from openai.types.responses.response_function_web_search import (
20 ActionFind,
21 ActionOpenPage,
22 ActionSearch,
23 ResponseFunctionWebSearch,
24 )
25 from openai.types.responses.response_output_item import McpCall
26 from openai.types.responses.response_reasoning_item import (
27 Content as ResponseReasoningTextContent,
28 )
29 from openai_harmony import Author, Message, Role, TextContent
30
31 from vllm.entrypoints.openai.parser.harmony_utils import (
32 BUILTIN_TOOL_TO_MCP_SERVER_LABEL,
33 extract_function_from_recipient,
34 flatten_input_text_content,
35 get_system_or_developer_message,
36 is_function_recipient,
37 )
38 from vllm.entrypoints.openai.responses.protocol import (
39 ResponseInputOutputItem,
40 ResponsesRequest,
41 )
42 from vllm.exceptions import VLLMValidationError
43 from vllm.logger import init_logger
44 from vllm.utils import random_uuid
45
46 logger = init_logger(__name__)
47
48 # ---------------------------------------------------------------------------
49 # 1. Private helpers for input parsing
50 # ---------------------------------------------------------------------------
51
52
53 def _parse_harmony_format_message(chat_msg: dict) -> Message:
54 """Reconstruct a Message from Harmony-format dict,
55 preserving channel, recipient, and content_type."""
56 author_dict = chat_msg["author"]
57 role = author_dict.get("role")
58 name = author_dict.get("name")
59
60 raw_content = chat_msg.get("content", "")
61 if isinstance(raw_content, list):
62 # TODO: Support refusal and non-text content types.
63 contents = [TextContent(text=c.get("text", "")) for c in raw_content]
64 elif isinstance(raw_content, str):
65 contents = [TextContent(text=raw_content)]
66 else:
67 contents = [TextContent(text="")]
68
69 if name:
70 msg = Message(author=Author.new(Role(role), name), content=contents)
71 else:
72 msg = Message.from_role_and_contents(Role(role), contents)
73
74 channel = chat_msg.get("channel")
75 if channel:
76 msg = msg.with_channel(channel)
77 recipient = chat_msg.get("recipient")
78 if recipient:
79 msg = msg.with_recipient(recipient)
80 content_type = chat_msg.get("content_type")
81 if content_type:
82 msg = msg.with_content_type(content_type)
83
84 return msg
85
86
87 def _parse_chat_format_message(chat_msg: dict) -> list[Message]:
88 """Parse an OpenAI chat-format dict into Harmony messages."""
89 role = chat_msg.get("role")
90 if role is None:
91 raise VLLMValidationError(
92 f"Message has no 'role' key: {chat_msg}",
93 parameter="input",
94 )
95
96 # Assistant message with tool calls
97 tool_calls = chat_msg.get("tool_calls")
98 if role == "assistant" and tool_calls:
99 msgs: list[Message] = []
100 for call in tool_calls:
101 func = call.get("function", {})
102 name = func.get("name", "")
103 arguments = func.get("arguments", "") or ""
104 msg = Message.from_role_and_content(Role.ASSISTANT, arguments)
105 msg = msg.with_channel("commentary")
106 msg = msg.with_recipient(f"functions.{name}")
107 msg = msg.with_content_type("json")
108 msgs.append(msg)
109 return msgs
110
111 # Tool role message (tool output)
112 if role == "tool":
113 name = chat_msg.get("name", "")
114 if name and not name.startswith("functions."):
115 name = f"functions.{name}"
116 content = flatten_input_text_content(chat_msg.get("content")) or ""
117 # NOTE: .with_recipient("assistant") is required on tool messages
118 # to match parse_chat_input_to_harmony_message behavior and ensure
119 # proper routing in the Harmony protocol.
120 msg = (
121 Message.from_author_and_content(Author.new(Role.TOOL, name), content)
122 .with_channel("commentary")
123 .with_recipient("assistant")
124 )
125 return [msg]
126
127 # System/developer messages into proper DeveloperContent
128 if role in ("system", "developer"):
129 text = flatten_input_text_content(chat_msg.get("content"))
130 if text:
131 msg = get_system_or_developer_message(role, text)
132 return [msg]
133 return []
134
135 # Default: user/assistant messages
136 content = chat_msg.get("content", "")
137 if isinstance(content, str):
138 contents = [TextContent(text=content)]
139 else:
140 # TODO: Support refusal.
141 contents = [TextContent(text=c.get("text", "")) for c in content]
142 msg = Message.from_role_and_contents(role, contents)
143 return [msg]
144
145
146 # ---------------------------------------------------------------------------
147 # 2. Public input parsing functions
148 # ---------------------------------------------------------------------------
149
150
151 def response_input_to_harmony(
152 response_msg: ResponseInputOutputItem,
153 prev_responses: list[ResponseOutputItem | ResponseReasoningItem],
154 ) -> Message | None:
155 """Convert a single ResponseInputOutputItem into a Harmony Message.
156
157 Returns None for reasoning items with empty or absent content so
158 the caller can skip them.
159 """
160 if not isinstance(response_msg, dict):
161 response_msg = response_msg.model_dump()
162 if "type" not in response_msg or response_msg["type"] == "message":
163 role = response_msg["role"]
164 content = response_msg["content"]
165 if role in ("system", "developer"):
166 text = flatten_input_text_content(content)
167 if text:
168 msg = get_system_or_developer_message(role, text)
169 else:
170 # Empty content — skip, no message emitted.
171 return None
172 elif isinstance(content, str):
173 msg = Message.from_role_and_content(role, content)
174 else:
175 contents = [TextContent(text=c.get("text", "")) for c in content]
176 msg = Message.from_role_and_contents(role, contents)
177 if role == "assistant":
178 msg = msg.with_channel("final")
179 elif response_msg["type"] == "function_call_output":
180 call_id = response_msg["call_id"]
181 call_response: ResponseFunctionToolCall | None = None
182 for prev_response in reversed(prev_responses):
183 if (
184 isinstance(prev_response, ResponseFunctionToolCall)
185 and prev_response.call_id == call_id
186 ):
187 call_response = prev_response
188 break
189 if call_response is None:
190 raise VLLMValidationError(
191 f"No call message found for {call_id}",
192 parameter="input",
193 )
194 msg = Message.from_author_and_content(
195 Author.new(Role.TOOL, f"functions.{call_response.name}"),
196 response_msg["output"],
197 )
198 msg = msg.with_channel("commentary")
199 msg = msg.with_recipient("assistant")
200 elif response_msg["type"] == "reasoning":
201 content = response_msg.get("content")
202 if content and len(content) >= 1:
203 reasoning_text = "\n".join(item["text"] for item in content)
204 msg = Message.from_role_and_content(Role.ASSISTANT, reasoning_text)
205 msg = msg.with_channel("analysis")
206 else:
207 return None
208 elif response_msg["type"] == "function_call":
209 msg = Message.from_role_and_content(Role.ASSISTANT, response_msg["arguments"])
210 msg = msg.with_channel("commentary")
211 msg = msg.with_recipient(f"functions.{response_msg['name']}")
212 msg = msg.with_content_type("json")
213 else:
214 raise VLLMValidationError(
215 f"Unknown input type: {response_msg['type']}",
216 parameter="input",
217 )
218 return msg
219
220
221 def response_previous_input_to_harmony(chat_msg) -> list[Message]:
222 """Parse a message from request.previous_input_messages
223 into Harmony messages.
224
225 Supports both OpenAI chat format ({"role": "..."}) and
226 Harmony format ({"author": {"role": "..."}}).
227 """
228 if not isinstance(chat_msg, dict):
229 chat_msg = chat_msg.model_dump(exclude_none=True)
230
231 if "author" in chat_msg and isinstance(chat_msg.get("author"), dict):
232 return [_parse_harmony_format_message(chat_msg)]
233
234 return _parse_chat_format_message(chat_msg)
235
236
237 def construct_harmony_previous_input_messages(
238 request: ResponsesRequest,
239 ) -> list[Message]:
240 """Build a Harmony message list from request.previous_input_messages.
241
242 Filters out system/developer messages to match OpenAI behavior where
243 instructions are always taken from the most recent Responses API request.
244 """
245 messages: list[Message] = []
246 if request.previous_input_messages:
247 for message in request.previous_input_messages:
248 # Handle both Message objects and dictionary inputs
249 if isinstance(message, Message):
250 message_role = message.author.role
251 if message_role == Role.SYSTEM or message_role == Role.DEVELOPER:
252 continue
253 messages.append(message)
254 else:
255 harmony_messages = response_previous_input_to_harmony(message)
256 for harmony_msg in harmony_messages:
257 message_role = harmony_msg.author.role
258 if message_role == Role.SYSTEM or message_role == Role.DEVELOPER:
259 continue
260 messages.append(harmony_msg)
261 return messages
262
263
264 # ---------------------------------------------------------------------------
265 # 3. Private helpers for output parsing
266 # ---------------------------------------------------------------------------
267
268
269 def _parse_browser_tool_call(message: Message, recipient: str) -> ResponseOutputItem:
270 """Parse browser tool calls (search, open, find) into web search items."""
271 if len(message.content) != 1:
272 raise ValueError("Invalid number of contents in browser message")
273 content = message.content[0]
274
275 # Parse JSON args (with retry detection)
276 try:
277 browser_call = json.loads(content.text)
278 except json.JSONDecodeError:
279 logger.warning(
280 "Invalid JSON in browser tool call, using error placeholder: %s",
281 content.text,
282 )
283 json_retry_output_message = (
284 f"Invalid JSON args, caught and retried: {content.text}"
285 )
286 browser_call = {
287 "query": json_retry_output_message,
288 "url": json_retry_output_message,
289 "pattern": json_retry_output_message,
290 }
291
292 # Create appropriate action based on recipient
293 if recipient == "browser.search":
294 action = ActionSearch(
295 query=f"cursor:{browser_call.get('query', '')}", type="search"
296 )
297 elif recipient == "browser.open":
298 action = ActionOpenPage(
299 url=f"cursor:{browser_call.get('url', '')}", type="open_page"
300 )
301 elif recipient == "browser.find":
302 action = ActionFind(
303 pattern=browser_call.get("pattern", ""),
304 url=f"cursor:{browser_call.get('url', '')}",
305 type="find_in_page",
306 )
307 else:
308 raise ValueError(f"Unknown browser action: {recipient}")
309
310 return ResponseFunctionWebSearch(
311 id=f"ws_{random_uuid()}",
312 action=action,
313 status="completed",
314 type="web_search_call",
315 )
316
317
318 def _parse_function_call(
319 message: Message, recipient: str, incomplete: bool = False
320 ) -> list[ResponseOutputItem]:
321 """Parse function calls into function tool call items."""
322 function_name = extract_function_from_recipient(recipient)
323 output_items = []
324 for content in message.content:
325 random_id = random_uuid()
326 response_item = ResponseFunctionToolCall(
327 arguments=content.text,
328 call_id=f"call_{random_id}",
329 type="function_call",
330 name=function_name,
331 id=f"fc_{random_id}",
332 status="incomplete" if incomplete else "completed",
333 )
334 output_items.append(response_item)
335 return output_items
336
337
338 def _parse_reasoning(message: Message) -> list[ResponseOutputItem]:
339 """Parse reasoning/analysis content into reasoning items."""
340 output_items = []
341 for content in message.content:
342 reasoning_item = ResponseReasoningItem(
343 id=f"rs_{random_uuid()}",
344 summary=[],
345 type="reasoning",
346 content=[
347 ResponseReasoningTextContent(text=content.text, type="reasoning_text")
348 ],
349 status=None,
350 )
351 output_items.append(reasoning_item)
352 return output_items
353
354
355 def _parse_final_message(
356 message: Message, incomplete: bool = False
357 ) -> ResponseOutputItem:
358 """Parse final channel messages into output message items."""
359 contents = []
360 for content in message.content:
361 output_text = ResponseOutputText(
362 text=content.text,
363 annotations=[], # TODO
364 type="output_text",
365 logprobs=None, # TODO
366 )
367 contents.append(output_text)
368 return ResponseOutputMessage(
369 id=f"msg_{random_uuid()}",
370 content=contents,
371 role=message.author.role,
372 status="incomplete" if incomplete else "completed",
373 type="message",
374 )
375
376
377 def _parse_mcp_recipient(recipient: str) -> tuple[str, str]:
378 """Parse MCP recipient into (server_label, tool_name).
379
380 For dotted recipients like "repo_browser.list":
381 - server_label: "repo_browser" (namespace/server)
382 - tool_name: "list" (specific tool)
383
384 For simple recipients like "filesystem":
385 - server_label: "filesystem"
386 - tool_name: "filesystem"
387 """
388 if "." in recipient:
389 server_label = recipient.split(".")[0]
390 tool_name = recipient.split(".")[-1]
391 else:
392 server_label = recipient
393 tool_name = recipient
394 return server_label, tool_name
395
396
397 def _parse_mcp_call(
398 message: Message, recipient: str, incomplete: bool = False
399 ) -> list[ResponseOutputItem]:
400 """Parse MCP calls into MCP call items."""
401 # Handle built-in tools that need server_label mapping
402 if recipient in BUILTIN_TOOL_TO_MCP_SERVER_LABEL:
403 server_label = BUILTIN_TOOL_TO_MCP_SERVER_LABEL[recipient]
404 tool_name = recipient
405 else:
406 server_label, tool_name = _parse_mcp_recipient(recipient)
407
408 output_items = []
409 for content in message.content:
410 response_item = McpCall(
411 arguments=content.text,
412 type="mcp_call",
413 name=tool_name,
414 server_label=server_label,
415 id=f"mcp_{random_uuid()}",
416 status="incomplete" if incomplete else "completed",
417 )
418 output_items.append(response_item)
419 return output_items
420
421
422 def _parse_message_no_recipient(
423 message: Message,
424 incomplete: bool = False,
425 ) -> list[ResponseOutputItem]:
426 """Parse a Harmony message with no recipient based on its channel."""
427 if message.channel == "analysis":
428 return _parse_reasoning(message)
429
430 if message.channel in ("commentary", "final"):
431 # Per Harmony format, preambles (commentary with no recipient) and
432 # final channel content are both intended to be shown to end-users.
433 # See: https://cookbook.openai.com/articles/openai-harmony
434 return [_parse_final_message(message, incomplete=incomplete)]
435
436 raise ValueError(f"Unknown channel: {message.channel}")
437
438
439 # ---------------------------------------------------------------------------
440 # 4. Public output parsing functions
441 # ---------------------------------------------------------------------------
442
443
444 def harmony_to_response_output(
445 message: Message,
446 function_tool_names: frozenset[str],
447 incomplete: bool = False,
448 ) -> list[ResponseOutputItem]:
449 """Parse a Harmony message into a list of output response items.
450
451 This is the main dispatcher that routes based on channel and recipient.
452 """
453 if message.author.role != "assistant":
454 # This is a message from a tool to the assistant (e.g., search result).
455 # Don't include it in the final output for now. This aligns with
456 # OpenAI's behavior on models like o4-mini.
457 return []
458
459 output_items: list[ResponseOutputItem] = []
460 recipient = message.recipient
461
462 if recipient is not None:
463 # Browser tool calls (browser.search, browser.open, browser.find)
464 if recipient.startswith("browser."):
465 if not incomplete:
466 output_items.append(_parse_browser_tool_call(message, recipient))
467
468 # Function calls (with or without "functions." prefix)
469 elif is_function_recipient(recipient, function_tool_names):
470 output_items.extend(
471 _parse_function_call(message, recipient, incomplete=incomplete)
472 )
473
474 # Built-in MCP tools (python, browser, container)
475 elif recipient in BUILTIN_TOOL_TO_MCP_SERVER_LABEL:
476 output_items.extend(_parse_reasoning(message))
477
478 # All other recipients are MCP calls
479 else:
480 output_items.extend(
481 _parse_mcp_call(message, recipient, incomplete=incomplete)
482 )
483
484 # No recipient - handle based on channel for non-tool messages
485 else:
486 output_items.extend(_parse_message_no_recipient(message, incomplete=incomplete))
487
488 return output_items