[Refactor][GPT-OSS] Harmony Responses API Refactor to use HarmonyParser (#47185)
Signed-off-by: Yifan Zong <yzong@redhat.com>
yzong-rh committed
Jun 30, 2026 at 19:23 UTC
b1190d03cc13531ede563c4466215f9840baa933
9 files changed
+424
-854
tests/entrypoints/openai/responses/conftest.py
+7
-1
@@ -251,7 +251,13 @@ def _validate_field_consistency(events: list) -> None:
251
"response.reasoning_part.added",
252
):
253
_assert_item_fields(event, etype, active_item_id, active_output_index)
254
- active_content_index = getattr(event, "content_index", None)
254
+ content_index = getattr(event, "content_index", None)
255
+ if active_content_index is None:
256
+ assert content_index == 0, (
257
+ f"{etype} for a new item must start at content_index 0, "
258
+ f"got {content_index}"
259
+ )
260
+ active_content_index = content_index
261
continue
262
263
# --- all other item-level events --------------------------
tests/entrypoints/openai/responses/test_harmony.py
+2
-1
@@ -454,6 +454,7 @@ async def test_streaming(client: OpenAI, model_name: str, background: bool):
454
if event.type == "response.output_item.added":
455
assert event.item.id != current_item_id
456
current_item_id = event.item.id
457
+ current_content_index = -1
458
elif event.type in [
459
"response.output_text.delta",
460
"response.reasoning_text.delta",
@@ -465,7 +466,7 @@ async def test_streaming(client: OpenAI, model_name: str, background: bool):
466
"response.content_part.added",
467
"response.reasoning_part.added",
468
]:
468
- assert event.content_index != current_content_index
469
+ assert event.content_index == current_content_index + 1
470
current_content_index = event.content_index
471
elif event.type in [
472
"response.output_text.delta",
tests/entrypoints/openai/responses/test_harmony_utils.py
+126
-417
@@ -2,8 +2,10 @@
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
"""Unit tests for vllm.entrypoints.openai.responses.harmony."""
4
5
+import pytest
6
from openai.types.responses import (
7
ResponseFunctionToolCall,
8
+ ResponseFunctionWebSearch,
9
ResponseOutputMessage,
10
ResponseReasoningItem,
11
)
@@ -12,7 +14,6 @@ from openai_harmony import Author, Message, Role, TextContent
14
15
from vllm.entrypoints.openai.responses.harmony import (
16
harmony_to_response_output,
15
- parser_state_to_response_output,
17
response_previous_input_to_harmony,
18
)
19
@@ -95,7 +96,8 @@ class TestResponsePreviousInputToHarmony:
96
class TestHarmonyToResponseOutput:
97
"""Tests for harmony_to_response_output function."""
98
98
- def test_commentary_with_no_recipient_creates_message(self):
99
+ @pytest.mark.parametrize("incomplete", [False, True])
100
+ def test_commentary_with_no_recipient_creates_message(self, incomplete):
101
"""Test that commentary with recipient=None (preambles) creates message items.
102
103
Per Harmony format, preambles are intended to be shown to end-users,
@@ -108,13 +110,15 @@ class TestHarmonyToResponseOutput:
110
message = message.with_channel("commentary")
111
# recipient is None by default, representing a preamble
112
111
- output_items = harmony_to_response_output(message)
113
+ output_items = harmony_to_response_output(
114
+ message, frozenset(), incomplete=incomplete
115
+ )
116
117
assert len(output_items) == 1
118
assert isinstance(output_items[0], ResponseOutputMessage)
119
assert output_items[0].type == "message"
120
assert output_items[0].role == "assistant"
117
- assert output_items[0].status == "completed"
121
+ assert output_items[0].status == ("incomplete" if incomplete else "completed")
122
assert len(output_items[0].content) == 1
123
assert output_items[0].content[0].type == "output_text"
124
assert (
@@ -122,82 +126,148 @@ class TestHarmonyToResponseOutput:
126
== "I will now search for the weather information."
127
)
128
125
- def test_commentary_with_function_recipient_creates_function_call(self):
126
- """Test commentary with recipient='functions.X' creates function calls."""
127
- message = Message.from_role_and_content(
128
- Role.ASSISTANT, '{"location": "San Francisco", "units": "celsius"}'
129
+ @pytest.mark.parametrize("channel", ["commentary", "comment", "analysis", "final"])
130
+ @pytest.mark.parametrize(
131
+ ("recipient", "fn_names", "expected_name"),
132
+ [
133
+ ("functions.get_weather", frozenset(), "get_weather"),
134
+ ("get_weather", frozenset({"get_weather"}), "get_weather"),
135
+ ("math.sum", frozenset({"math.sum"}), "math.sum"),
136
+ ],
137
+ )
138
+ @pytest.mark.parametrize("incomplete", [False, True])
139
+ def test_function_recipient_creates_function_call(
140
+ self, channel, recipient, fn_names, expected_name, incomplete
141
+ ):
142
+ """Function recipients create function calls across channels."""
143
+ content = '{"location": "San Francisco"}'
144
+ if recipient == "math.sum":
145
+ content = '{"a": 1, "b": 2}'
146
+
147
+ message = Message.from_role_and_content(Role.ASSISTANT, content)
148
+ message = message.with_channel(channel)
149
+ message = message.with_recipient(recipient)
150
+
151
+ output_items = harmony_to_response_output(
152
+ message, fn_names, incomplete=incomplete
153
)
130
- message = message.with_channel("commentary")
131
- message = message.with_recipient("functions.get_weather")
132
-
133
- output_items = harmony_to_response_output(message)
154
155
assert len(output_items) == 1
156
assert isinstance(output_items[0], ResponseFunctionToolCall)
157
assert output_items[0].type == "function_call"
138
- assert output_items[0].name == "get_weather"
139
- assert (
140
- output_items[0].arguments
141
- == '{"location": "San Francisco", "units": "celsius"}'
142
- )
158
+ assert output_items[0].name == expected_name
159
+ assert output_items[0].arguments == content
160
assert output_items[0].call_id.startswith("call_")
161
assert output_items[0].id.startswith("fc_")
145
- assert output_items[0].status == "completed"
146
-
147
- def test_commentary_with_python_recipient_creates_reasoning(self):
148
- """Test that commentary with recipient='python' creates reasoning items."""
149
- message = Message.from_role_and_content(
150
- Role.ASSISTANT, "import numpy as np\nprint(np.array([1, 2, 3]))"
162
+ assert output_items[0].status == ("incomplete" if incomplete else "completed")
163
+
164
+ @pytest.mark.parametrize("channel", ["commentary", "comment", "analysis", "final"])
165
+ @pytest.mark.parametrize(
166
+ ("recipient", "content"),
167
+ [
168
+ ("python", "import numpy as np\nprint(np.array([1, 2, 3]))"),
169
+ ("browser", "Navigating to the specified URL"),
170
+ ("container", "Running command in container"),
171
+ ],
172
+ )
173
+ @pytest.mark.parametrize("incomplete", [False, True])
174
+ def test_builtin_recipient_creates_reasoning(
175
+ self, channel, recipient, content, incomplete
176
+ ):
177
+ """Built-in recipients create reasoning items."""
178
+ message = Message.from_role_and_content(Role.ASSISTANT, content)
179
+ message = message.with_channel(channel)
180
+ message = message.with_recipient(recipient)
181
+
182
+ output_items = harmony_to_response_output(
183
+ message, frozenset(), incomplete=incomplete
184
)
152
- message = message.with_channel("commentary")
153
- message = message.with_recipient("python")
154
-
155
- output_items = harmony_to_response_output(message)
185
186
assert len(output_items) == 1
187
assert isinstance(output_items[0], ResponseReasoningItem)
188
assert output_items[0].type == "reasoning"
160
- assert (
161
- output_items[0].content[0].text
162
- == "import numpy as np\nprint(np.array([1, 2, 3]))"
189
+ assert output_items[0].content[0].text == content
190
+ assert output_items[0].status is None
191
+
192
+ @pytest.mark.parametrize("channel", ["commentary", "comment", "analysis", "final"])
193
+ @pytest.mark.parametrize(
194
+ ("recipient", "fn_names", "content", "expected_name", "expected_server_label"),
195
+ [
196
+ (
197
+ "get_weather",
198
+ frozenset(),
199
+ '{"arg": "value"}',
200
+ "get_weather",
201
+ "get_weather",
202
+ ),
203
+ (
204
+ "not_get_weather",
205
+ frozenset({"get_weather"}),
206
+ '{"arg": "value"}',
207
+ "not_get_weather",
208
+ "not_get_weather",
209
+ ),
210
+ ("repo_browser.list", frozenset(), '{"cmd": "ls"}', "list", "repo_browser"),
211
+ ],
212
+ )
213
+ @pytest.mark.parametrize("incomplete", [False, True])
214
+ def test_non_function_non_builtin_recipient_creates_mcp_call(
215
+ self,
216
+ channel,
217
+ recipient,
218
+ fn_names,
219
+ content,
220
+ expected_name,
221
+ expected_server_label,
222
+ incomplete,
223
+ ):
224
+ """Non-function, non-built-in recipients create MCP calls."""
225
+ message = Message.from_role_and_content(Role.ASSISTANT, content)
226
+ message = message.with_channel(channel)
227
+ message = message.with_recipient(recipient)
228
+
229
+ output_items = harmony_to_response_output(
230
+ message, fn_names, incomplete=incomplete
231
)
232
165
- def test_commentary_with_browser_recipient_creates_reasoning(self):
166
- """Test that commentary with recipient='browser' creates reasoning items."""
233
+ assert len(output_items) == 1
234
+ assert isinstance(output_items[0], McpCall)
235
+ assert output_items[0].type == "mcp_call"
236
+ assert output_items[0].name == expected_name
237
+ assert output_items[0].server_label == expected_server_label
238
+ assert output_items[0].arguments == content
239
+ assert output_items[0].status == ("incomplete" if incomplete else "completed")
240
+
241
+ @pytest.mark.parametrize("incomplete", [False, True])
242
+ def test_browser_search_recipient_respects_incomplete(self, incomplete):
243
+ """browser.search emits a web search call unless the item is incomplete."""
244
message = Message.from_role_and_content(
168
- Role.ASSISTANT, "Navigating to the specified URL"
245
+ Role.ASSISTANT, '{"query": "weather in San Francisco"}'
246
)
247
message = message.with_channel("commentary")
171
- message = message.with_recipient("browser")
172
-
173
- output_items = harmony_to_response_output(message)
174
-
175
- assert len(output_items) == 1
176
- assert isinstance(output_items[0], ResponseReasoningItem)
177
- assert output_items[0].type == "reasoning"
178
- assert output_items[0].content[0].text == "Navigating to the specified URL"
248
+ message = message.with_recipient("browser.search")
249
180
- def test_commentary_with_container_recipient_creates_reasoning(self):
181
- """Test that commentary with recipient='container' creates reasoning items."""
182
- message = Message.from_role_and_content(
183
- Role.ASSISTANT, "Running command in container"
250
+ output_items = harmony_to_response_output(
251
+ message, frozenset(), incomplete=incomplete
252
)
185
- message = message.with_channel("commentary")
186
- message = message.with_recipient("container")
253
188
- output_items = harmony_to_response_output(message)
254
+ if incomplete:
255
+ assert output_items == []
256
+ return
257
258
assert len(output_items) == 1
191
- assert isinstance(output_items[0], ResponseReasoningItem)
192
- assert output_items[0].type == "reasoning"
193
- assert output_items[0].content[0].text == "Running command in container"
259
+ assert isinstance(output_items[0], ResponseFunctionWebSearch)
260
+ assert output_items[0].type == "web_search_call"
261
+ assert output_items[0].status == "completed"
262
+ assert output_items[0].action.type == "search"
263
+ assert output_items[0].action.query == "cursor:weather in San Francisco"
264
265
def test_commentary_with_empty_content_and_no_recipient(self):
266
"""Test edge case: empty commentary with recipient=None."""
267
message = Message.from_role_and_content(Role.ASSISTANT, "")
268
message = message.with_channel("commentary")
269
200
- output_items = harmony_to_response_output(message)
270
+ output_items = harmony_to_response_output(message, frozenset())
271
272
assert len(output_items) == 1
273
assert isinstance(output_items[0], ResponseOutputMessage)
@@ -212,7 +282,7 @@ class TestHarmonyToResponseOutput:
282
message = Message.from_role_and_contents(Role.ASSISTANT, contents)
283
message = message.with_channel("commentary")
284
215
- output_items = harmony_to_response_output(message)
285
+ output_items = harmony_to_response_output(message, frozenset())
286
287
# _parse_final_message returns single ResponseOutputMessage with
288
# multiple contents
@@ -232,7 +302,7 @@ class TestHarmonyToResponseOutput:
302
message = message.with_channel("commentary")
303
message = message.with_recipient("functions.get_weather")
304
235
- output_items = harmony_to_response_output(message)
305
+ output_items = harmony_to_response_output(message, frozenset())
306
307
assert len(output_items) == 2
308
assert all(isinstance(item, ResponseFunctionToolCall) for item in output_items)
@@ -241,21 +311,6 @@ class TestHarmonyToResponseOutput:
311
assert output_items[0].arguments == '{"location": "San Francisco"}'
312
assert output_items[1].arguments == '{"location": "New York"}'
313
244
- def test_commentary_with_unknown_recipient_creates_mcp_call(self):
245
- """Test that commentary with unknown recipient creates MCP call."""
246
- message = Message.from_role_and_content(Role.ASSISTANT, '{"arg": "value"}')
247
- message = message.with_channel("commentary")
248
- message = message.with_recipient("custom_tool")
249
-
250
- fn_names = frozenset({"other_tool"})
251
- output_items = harmony_to_response_output(message, fn_names)
252
-
253
- assert len(output_items) == 1
254
- assert isinstance(output_items[0], McpCall)
255
- assert output_items[0].type == "mcp_call"
256
- assert output_items[0].name == "custom_tool"
257
- assert output_items[0].server_label == "custom_tool"
258
-
314
def test_analysis_channel_creates_reasoning(self):
315
"""Test that analysis channel creates reasoning items."""
316
message = Message.from_role_and_content(
@@ -263,7 +318,7 @@ class TestHarmonyToResponseOutput:
318
)
319
message = message.with_channel("analysis")
320
266
- output_items = harmony_to_response_output(message)
321
+ output_items = harmony_to_response_output(message, frozenset())
322
323
assert len(output_items) == 1
324
assert isinstance(output_items[0], ResponseReasoningItem)
@@ -283,352 +338,6 @@ class TestHarmonyToResponseOutput:
338
"The weather is sunny, 72°F",
339
)
340
286
- output_items = harmony_to_response_output(message)
287
-
288
- assert len(output_items) == 0
289
-
290
-
291
-class TestHarmonyToResponseOutputWithFunctionToolNames:
292
- """Tests for bare function name handling with function_tool_names."""
293
-
294
- def test_bare_name_creates_function_call_when_in_tool_names(self):
295
- """Bare function name matching a known tool creates function call."""
296
- message = Message.from_role_and_content(
297
- Role.ASSISTANT, '{"location": "San Francisco"}'
298
- )
299
- message = message.with_channel("commentary")
300
- message = message.with_recipient("get_weather")
301
-
302
- fn_names = frozenset({"get_weather"})
303
- output_items = harmony_to_response_output(message, fn_names)
304
-
305
- assert len(output_items) == 1
306
- assert isinstance(output_items[0], ResponseFunctionToolCall)
307
- assert output_items[0].type == "function_call"
308
- assert output_items[0].name == "get_weather"
309
- assert output_items[0].arguments == '{"location": "San Francisco"}'
310
-
311
- def test_bare_name_creates_mcp_call_when_not_in_tool_names(self):
312
- """Bare name not matching any known tool creates MCP call."""
313
- message = Message.from_role_and_content(Role.ASSISTANT, '{"arg": "value"}')
314
- message = message.with_channel("commentary")
315
- message = message.with_recipient("custom_tool")
316
-
317
- fn_names = frozenset({"get_weather"})
318
- output_items = harmony_to_response_output(message, fn_names)
319
-
320
- assert len(output_items) == 1
321
- assert isinstance(output_items[0], McpCall)
322
- assert output_items[0].type == "mcp_call"
323
-
324
- def test_dotted_function_name_creates_function_call(self):
325
- """Dotted function name in tool names creates function call."""
326
- message = Message.from_role_and_content(Role.ASSISTANT, '{"a": 1, "b": 2}')
327
- message = message.with_channel("commentary")
328
- message = message.with_recipient("math.sum")
329
-
330
- fn_names = frozenset({"math.sum"})
331
- output_items = harmony_to_response_output(message, fn_names)
332
-
333
- assert len(output_items) == 1
334
- assert isinstance(output_items[0], ResponseFunctionToolCall)
335
- assert output_items[0].name == "math.sum"
336
-
337
- def test_empty_tool_names_defaults_to_mcp(self):
338
- """With empty function_tool_names, bare names become MCP calls."""
339
- message = Message.from_role_and_content(Role.ASSISTANT, '{"arg": "value"}')
340
- message = message.with_channel("commentary")
341
- message = message.with_recipient("get_weather")
342
-
341
output_items = harmony_to_response_output(message, frozenset())
342
345
- assert len(output_items) == 1
346
- assert isinstance(output_items[0], McpCall)
347
-
348
- def test_prefixed_name_always_function_call(self):
349
- """functions. prefix always creates function call even with empty tool names."""
350
- message = Message.from_role_and_content(Role.ASSISTANT, '{"arg": "value"}')
351
- message = message.with_channel("commentary")
352
- message = message.with_recipient("functions.get_weather")
353
-
354
- output_items = harmony_to_response_output(message, frozenset())
355
-
356
- assert len(output_items) == 1
357
- assert isinstance(output_items[0], ResponseFunctionToolCall)
358
- assert output_items[0].name == "get_weather"
359
-
360
-
361
-class TestParserStateWithFunctionToolNames:
362
- """Tests for parser_state_to_response_output with function_tool_names."""
363
-
364
- def test_bare_name_creates_function_call(self):
365
- from unittest.mock import Mock
366
-
367
- parser = Mock()
368
- parser.current_content = '{"arg": "value"}'
369
- parser.current_role = Role.ASSISTANT
370
- parser.current_channel = "commentary"
371
- parser.current_recipient = "get_weather"
372
-
373
- fn_names = frozenset({"get_weather"})
374
- items = parser_state_to_response_output(parser, fn_names)
375
-
376
- assert len(items) == 1
377
- assert isinstance(items[0], ResponseFunctionToolCall)
378
- assert items[0].name == "get_weather"
379
- assert items[0].status == "in_progress"
380
-
381
- def test_bare_name_creates_mcp_when_not_in_tool_names(self):
382
- from unittest.mock import Mock
383
-
384
- parser = Mock()
385
- parser.current_content = '{"arg": "value"}'
386
- parser.current_role = Role.ASSISTANT
387
- parser.current_channel = "commentary"
388
- parser.current_recipient = "unknown_tool"
389
-
390
- fn_names = frozenset({"get_weather"})
391
- items = parser_state_to_response_output(parser, fn_names)
392
-
393
- assert len(items) == 1
394
- assert isinstance(items[0], McpCall)
395
- assert items[0].name == "unknown_tool"
396
-
397
-
398
-class TestToolCallsOnNonStandardChannels:
399
- """Tests verifying tool calls are detected regardless of channel."""
400
-
401
- def test_function_call_on_comment_channel(self):
402
- message = Message.from_role_and_content(Role.ASSISTANT, '{"query": "weather"}')
403
- message = message.with_channel("comment")
404
- message = message.with_recipient("functions.get_weather")
405
-
406
- output_items = harmony_to_response_output(message)
407
-
408
- assert len(output_items) == 1
409
- assert isinstance(output_items[0], ResponseFunctionToolCall)
410
- assert output_items[0].type == "function_call"
411
- assert output_items[0].name == "get_weather"
412
-
413
- def test_bare_function_on_comment_channel(self):
414
- message = Message.from_role_and_content(Role.ASSISTANT, '{"query": "weather"}')
415
- message = message.with_channel("comment")
416
- message = message.with_recipient("get_weather")
417
-
418
- fn_names = frozenset({"get_weather"})
419
- output_items = harmony_to_response_output(message, fn_names)
420
-
421
- assert len(output_items) == 1
422
- assert isinstance(output_items[0], ResponseFunctionToolCall)
423
- assert output_items[0].name == "get_weather"
424
-
425
- def test_parser_state_comment_channel_function(self):
426
- from unittest.mock import Mock
427
-
428
- parser = Mock()
429
- parser.current_content = '{"arg": "value"}'
430
- parser.current_role = Role.ASSISTANT
431
- parser.current_channel = "comment"
432
- parser.current_recipient = "functions.get_weather"
433
-
434
- items = parser_state_to_response_output(parser)
435
-
436
- assert len(items) == 1
437
- assert isinstance(items[0], ResponseFunctionToolCall)
438
- assert items[0].name == "get_weather"
439
-
440
- def test_parser_state_comment_channel_mcp(self):
441
- from unittest.mock import Mock
442
-
443
- parser = Mock()
444
- parser.current_content = '{"arg": "value"}'
445
- parser.current_role = Role.ASSISTANT
446
- parser.current_channel = "comment"
447
- parser.current_recipient = "mcp.server.tool"
448
-
449
- fn_names: frozenset[str] = frozenset()
450
- items = parser_state_to_response_output(parser, fn_names)
451
-
452
- assert len(items) == 1
453
- assert isinstance(items[0], McpCall)
454
-
455
-
456
-def test_parse_mcp_call_basic() -> None:
457
- """Test that MCP calls are parsed with correct type and server_label."""
458
- message = Message.from_role_and_content(Role.ASSISTANT, '{"path": "/tmp"}')
459
- message = message.with_recipient("filesystem")
460
- message = message.with_channel("commentary")
461
-
462
- fn_names: frozenset[str] = frozenset()
463
- output_items = harmony_to_response_output(message, fn_names)
464
-
465
- assert len(output_items) == 1
466
- assert isinstance(output_items[0], McpCall)
467
- assert output_items[0].type == "mcp_call"
468
- assert output_items[0].name == "filesystem"
469
- assert output_items[0].server_label == "filesystem"
470
- assert output_items[0].arguments == '{"path": "/tmp"}'
471
- assert output_items[0].status == "completed"
472
-
473
-
474
-def test_parse_mcp_call_dotted_recipient() -> None:
475
- """Test that dotted recipients extract the tool name correctly."""
476
- message = Message.from_role_and_content(Role.ASSISTANT, '{"cmd": "ls"}')
477
- message = message.with_recipient("repo_browser.list")
478
- message = message.with_channel("commentary")
479
-
480
- fn_names: frozenset[str] = frozenset()
481
- output_items = harmony_to_response_output(message, fn_names)
482
-
483
- assert len(output_items) == 1
484
- assert isinstance(output_items[0], McpCall)
485
- assert output_items[0].name == "list"
486
- assert output_items[0].server_label == "repo_browser"
487
-
488
-
489
-def test_mcp_vs_function_call() -> None:
490
- """Test that function calls are not parsed as MCP calls."""
491
- func_message = Message.from_role_and_content(Role.ASSISTANT, '{"arg": "value"}')
492
- func_message = func_message.with_recipient("functions.my_tool")
493
- func_message = func_message.with_channel("commentary")
494
-
495
- func_items = harmony_to_response_output(func_message)
496
-
497
- assert len(func_items) == 1
498
- assert not isinstance(func_items[0], McpCall)
499
- assert func_items[0].type == "function_call"
500
-
501
-
502
-def test_mcp_vs_builtin_tools() -> None:
503
- """Test that built-in tools (python, container) are not parsed as MCP calls."""
504
- # Test python (built-in tool) - should be reasoning, not MCP
505
- python_message = Message.from_role_and_content(Role.ASSISTANT, "print('hello')")
506
- python_message = python_message.with_recipient("python")
507
- python_message = python_message.with_channel("commentary")
508
-
509
- python_items = harmony_to_response_output(python_message)
510
-
511
- assert len(python_items) == 1
512
- assert not isinstance(python_items[0], McpCall)
513
- assert python_items[0].type == "reasoning"
514
-
515
-
516
-def test_parser_state_to_response_output_commentary_channel() -> None:
517
- """Test parser_state_to_response_output with commentary
518
- channel and various recipients."""
519
- from unittest.mock import Mock
520
-
521
- # Test 1: functions.* recipient -> should return function tool call
522
- parser_func = Mock()
523
- parser_func.current_content = '{"arg": "value"}'
524
- parser_func.current_role = Role.ASSISTANT
525
- parser_func.current_channel = "commentary"
526
- parser_func.current_recipient = "functions.my_tool"
527
-
528
- func_items = parser_state_to_response_output(parser_func)
529
-
530
- assert len(func_items) == 1
531
- assert not isinstance(func_items[0], McpCall)
532
- assert func_items[0].type == "function_call"
533
- assert func_items[0].name == "my_tool"
534
- assert func_items[0].status == "in_progress"
535
-
536
- # Test 2: MCP tool (not builtin) -> should return MCP call
537
- parser_mcp = Mock()
538
- parser_mcp.current_content = '{"path": "/tmp"}'
539
- parser_mcp.current_role = Role.ASSISTANT
540
- parser_mcp.current_channel = "commentary"
541
- parser_mcp.current_recipient = "filesystem"
542
-
543
- fn_names: frozenset[str] = frozenset()
544
- mcp_items = parser_state_to_response_output(parser_mcp, fn_names)
545
-
546
- assert len(mcp_items) == 1
547
- assert isinstance(mcp_items[0], McpCall)
548
- assert mcp_items[0].type == "mcp_call"
549
- assert mcp_items[0].name == "filesystem"
550
- assert mcp_items[0].server_label == "filesystem"
551
- assert mcp_items[0].status == "in_progress"
552
-
553
- # Test 3: Built-in tool (python)
554
- # should NOT return MCP call, returns reasoning (internal tool interaction)
555
- parser_builtin = Mock()
556
- parser_builtin.current_content = "print('hello')"
557
- parser_builtin.current_role = Role.ASSISTANT
558
- parser_builtin.current_channel = "commentary"
559
- parser_builtin.current_recipient = "python"
560
-
561
- builtin_items = parser_state_to_response_output(parser_builtin)
562
-
563
- # Built-in tools explicitly return reasoning
564
- assert len(builtin_items) == 1
565
- assert not isinstance(builtin_items[0], McpCall)
566
- assert builtin_items[0].type == "reasoning"
567
-
568
- # Test 4: No recipient (preamble) → should return message, not reasoning
569
- parser_preamble = Mock()
570
- parser_preamble.current_content = "I'll search for that information now."
571
- parser_preamble.current_role = Role.ASSISTANT
572
- parser_preamble.current_channel = "commentary"
573
- parser_preamble.current_recipient = None
574
-
575
- preamble_items = parser_state_to_response_output(parser_preamble)
576
-
577
- assert len(preamble_items) == 1
578
- assert isinstance(preamble_items[0], ResponseOutputMessage)
579
- assert preamble_items[0].type == "message"
580
- assert preamble_items[0].content[0].text == "I'll search for that information now."
581
- assert preamble_items[0].status == "incomplete" # streaming
582
-
583
-
584
-def test_parser_state_to_response_output_analysis_channel() -> None:
585
- """Test parser_state_to_response_output with analysis
586
- channel and various recipients."""
587
- from unittest.mock import Mock
588
-
589
- # Test 1: functions.* recipient -> should return function tool call
590
- parser_func = Mock()
591
- parser_func.current_content = '{"arg": "value"}'
592
- parser_func.current_role = Role.ASSISTANT
593
- parser_func.current_channel = "analysis"
594
- parser_func.current_recipient = "functions.my_tool"
595
-
596
- func_items = parser_state_to_response_output(parser_func)
597
-
598
- assert len(func_items) == 1
599
- assert not isinstance(func_items[0], McpCall)
600
- assert func_items[0].type == "function_call"
601
- assert func_items[0].name == "my_tool"
602
- assert func_items[0].status == "in_progress"
603
-
604
- # Test 2: MCP tool (not builtin) -> should return MCP call
605
- parser_mcp = Mock()
606
- parser_mcp.current_content = '{"query": "test"}'
607
- parser_mcp.current_role = Role.ASSISTANT
608
- parser_mcp.current_channel = "analysis"
609
- parser_mcp.current_recipient = "database"
610
-
611
- fn_names: frozenset[str] = frozenset()
612
- mcp_items = parser_state_to_response_output(parser_mcp, fn_names)
613
-
614
- assert len(mcp_items) == 1
615
- assert isinstance(mcp_items[0], McpCall)
616
- assert mcp_items[0].type == "mcp_call"
617
- assert mcp_items[0].name == "database"
618
- assert mcp_items[0].server_label == "database"
619
- assert mcp_items[0].status == "in_progress"
620
-
621
- # Test 3: Built-in tool (container)
622
- # should NOT return MCP call, falls through to reasoning
623
- parser_builtin = Mock()
624
- parser_builtin.current_content = "docker run"
625
- parser_builtin.current_role = Role.ASSISTANT
626
- parser_builtin.current_channel = "analysis"
627
- parser_builtin.current_recipient = "container"
628
-
629
- builtin_items = parser_state_to_response_output(parser_builtin)
630
-
631
- # Should fall through to reasoning logic
632
- assert len(builtin_items) == 1
633
- assert not isinstance(builtin_items[0], McpCall)
634
- assert builtin_items[0].type == "reasoning"
343
+ assert len(output_items) == 0
tests/entrypoints/openai/responses/test_serving_responses.py
+51
-13
@@ -51,6 +51,7 @@ from vllm.entrypoints.openai.responses.streaming_events import (
51
)
52
from vllm.inputs import tokens_input
53
from vllm.outputs import CompletionOutput, RequestOutput
54
+from vllm.parser.harmony import Segment
55
from vllm.sampling_params import SamplingParams
56
57
@@ -534,13 +535,9 @@ class TestHarmonyPreambleStreaming:
535
"""Tests for preamble (commentary with no recipient) streaming events."""
536
537
@staticmethod
537
- def _make_ctx(*, channel, recipient, delta="hello"):
538
- """Build a lightweight mock StreamingHarmonyContext."""
539
- ctx = MagicMock()
540
- ctx.last_content_delta = delta
541
- ctx.parser.current_channel = channel
542
- ctx.parser.current_recipient = recipient
543
- return ctx
538
+ def _make_segment(*, channel, recipient, delta="hello"):
539
+ """Build a lightweight segment for Harmony streaming tests."""
540
+ return Segment(channel=channel, recipient=recipient, delta=delta)
541
542
@staticmethod
543
def _make_previous_item(*, channel, recipient, text="preamble text"):
@@ -559,10 +556,10 @@ class TestHarmonyPreambleStreaming:
556
emit_content_delta_events,
557
)
558
562
- ctx = self._make_ctx(channel="commentary", recipient=None)
559
+ segment = self._make_segment(channel="commentary", recipient=None)
560
state = StreamingState()
561
565
- events = emit_content_delta_events(ctx, state)
562
+ events = emit_content_delta_events(segment, state)
563
564
type_names = [e.type for e in events]
565
assert "response.output_text.delta" in type_names
@@ -574,13 +571,13 @@ class TestHarmonyPreambleStreaming:
571
emit_content_delta_events,
572
)
573
577
- ctx = self._make_ctx(channel="commentary", recipient=None, delta="w")
574
+ segment = self._make_segment(channel="commentary", recipient=None, delta="w")
575
state = StreamingState()
576
state.sent_output_item_added = True
577
state.current_item_id = "msg_test"
578
state.current_content_index = 0
579
583
- events = emit_content_delta_events(ctx, state)
580
+ events = emit_content_delta_events(segment, state)
581
582
type_names = [e.type for e in events]
583
assert "response.output_text.delta" in type_names
@@ -592,13 +589,13 @@ class TestHarmonyPreambleStreaming:
589
emit_content_delta_events,
590
)
591
595
- ctx = self._make_ctx(
592
+ segment = self._make_segment(
593
channel="commentary",
594
recipient="functions.get_weather",
595
)
596
state = StreamingState()
597
601
- events = emit_content_delta_events(ctx, state)
598
+ events = emit_content_delta_events(segment, state)
599
600
type_names = [e.type for e in events]
601
assert "response.output_text.delta" not in type_names
@@ -612,6 +609,7 @@ class TestHarmonyPreambleStreaming:
609
610
previous = self._make_previous_item(channel="commentary", recipient=None)
611
state = StreamingState()
612
+ state.sent_output_item_added = True
613
state.current_item_id = "msg_test"
614
state.current_output_index = 0
615
state.current_content_index = 0
@@ -634,13 +632,53 @@ class TestHarmonyPreambleStreaming:
632
channel="commentary", recipient="functions.get_weather"
633
)
634
state = StreamingState()
635
+ state.is_first_function_call_delta = True
636
state.current_item_id = "fc_test"
637
+ state.current_call_id = "call_test"
638
639
events = emit_previous_item_done_events(previous, state)
640
641
type_names = [e.type for e in events]
642
assert "response.output_text.done" not in type_names
643
644
+ @pytest.mark.xfail(
645
+ reason=(
646
+ "TODO: Ensure added/in-progress events are emitted for zero-delta items."
647
+ "So we can safely emit done events for zero-delta items."
648
+ ),
649
+ strict=True,
650
+ )
651
+ def test_zero_delta_items_should_preserve_streaming_lifecycle(
652
+ self,
653
+ ) -> None:
654
+ """Zero-delta Harmony items should still produce a coherent lifecycle."""
655
+ from vllm.entrypoints.openai.responses.streaming_events import (
656
+ emit_previous_item_done_events,
657
+ )
658
+
659
+ cases: list[tuple[str, str | None, str]] = [
660
+ ("commentary", None, "msg_stale"),
661
+ ("analysis", None, "msg_stale"),
662
+ ("commentary", "functions.get_weather", "fc_stale"),
663
+ ("commentary", "python", "tool_stale"),
664
+ ("commentary", "repo_browser.list", "mcp_stale"),
665
+ ]
666
+
667
+ for channel, recipient, current_item_id in cases:
668
+ previous = self._make_previous_item(channel=channel, recipient=recipient)
669
+ state = StreamingState()
670
+ state.current_item_id = current_item_id
671
+ state.current_call_id = "call_stale"
672
+ state.current_content_index = 0
673
+
674
+ events = emit_previous_item_done_events(
675
+ previous, state, function_tool_names=None
676
+ )
677
+
678
+ type_names = [e.type for e in events]
679
+ assert "response.output_item.added" in type_names
680
+ assert "response.output_item.done" in type_names
681
+
682
683
def _make_simple_context_with_output(text, token_ids, response_parser=None):
684
"""Create a SimpleContext with a RequestOutput containing the given text."""
tests/entrypoints/unit_tests/test_context.py
+116
-89
@@ -1,18 +1,18 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
-from unittest.mock import MagicMock, patch
4
+from unittest.mock import patch
5
6
import pytest
7
-from openai_harmony import Author, Message, Role, StreamState, TextContent
7
+from openai_harmony import Author, Message, Role, TextContent
8
9
from vllm.entrypoints.openai.responses.context import (
10
HarmonyContext,
11
SimpleContext,
12
- StreamingHarmonyContext,
12
TurnMetrics,
13
)
14
from vllm.outputs import CompletionOutput, RequestOutput
15
+from vllm.parser.harmony import ChunkResult, HarmonyParser, Segment
16
17
18
def create_mock_request_output(
@@ -68,25 +68,59 @@ async def generate_mock_outputs(
68
)
69
70
71
-@pytest.fixture
72
-def mock_parser():
73
- """Set up a mock parser for tests."""
74
- with patch(
75
- "vllm.entrypoints.openai.responses.context.get_streamable_parser_for_assistant"
76
- ) as mock_parser_factory:
77
- # Create a mock parser object
78
- parser = MagicMock()
79
- parser.messages = []
80
- parser.current_channel = None
81
- parser.state = StreamState.EXPECT_START
82
- mock_parser_factory.return_value = parser
83
- yield parser
71
+class FakeHarmonyParser(HarmonyParser):
72
+ def __init__(self):
73
+ # Skip HarmonyParser initialization and script outputs directly.
74
+ self.reasoning_parser = None
75
+ self.tool_parser = None
76
+ self._chunk_results: list[ChunkResult] = []
77
+ self._flush_results: list[Segment | None] = []
78
+ self.processed_chunks: list[list[int]] = []
79
+
80
+ def enqueue_chunk_result(
81
+ self,
82
+ segments: list[Segment] | None = None,
83
+ reasoning_token_count: int = 0,
84
+ ) -> None:
85
+ self._chunk_results.append(
86
+ ChunkResult(
87
+ segments=[] if segments is None else segments,
88
+ reasoning_token_count=reasoning_token_count,
89
+ )
90
+ )
91
+
92
+ def enqueue_flush_result(self, segment: Segment | None) -> None:
93
+ self._flush_results.append(segment)
94
+
95
+ def process_chunk(self, token_ids) -> ChunkResult:
96
+ self.processed_chunks.append(list(token_ids))
97
+ if self._chunk_results:
98
+ return self._chunk_results.pop(0)
99
+ return ChunkResult(segments=[], reasoning_token_count=0)
100
+
101
+ def flush(self) -> Segment | None:
102
+ if self._flush_results:
103
+ return self._flush_results.pop(0)
104
+ return None
105
+
106
+
107
+def make_harmony_context(
108
+ messages=None, available_tools=None, function_tool_names=None
109
+) -> tuple[HarmonyContext, FakeHarmonyParser]:
110
+ fake_parser = FakeHarmonyParser()
111
+ context = HarmonyContext(
112
+ messages=[] if messages is None else messages,
113
+ available_tools=[] if available_tools is None else available_tools,
114
+ function_tool_names=function_tool_names,
115
+ response_parser=fake_parser,
116
+ )
117
+ return context, fake_parser
118
119
120
def test_single_turn_token_counting():
121
"""Test token counting behavior for a single turn."""
122
# Create a context
89
- context = HarmonyContext(messages=[], available_tools=[])
123
+ context, _ = make_harmony_context()
124
125
# Create a mock RequestOutput with specific token counts
126
mock_output = create_mock_request_output(
@@ -118,7 +152,7 @@ def test_single_turn_token_counting():
152
async def test_multi_turn_token_counting():
153
"""Test token counting behavior across multiple turns with tool output."""
154
# Create a context
121
- context = HarmonyContext(messages=[], available_tools=["browser"])
155
+ context, _ = make_harmony_context(available_tools=["browser"])
156
157
# Simulate a conversation with 3 turns
158
# Turn 1: prefill 5, decode 3, tool 7
@@ -177,7 +211,7 @@ async def test_multi_turn_token_counting():
211
212
def test_empty_output_tokens():
213
"""Test behavior when RequestOutput has empty output tokens."""
180
- context = HarmonyContext(messages=[], available_tools=[])
214
+ context, _ = make_harmony_context()
215
216
# Create a RequestOutput with empty output tokens
217
mock_output = create_mock_request_output(
@@ -197,7 +231,7 @@ def test_empty_output_tokens():
231
232
def test_missing_prompt_token_ids():
233
"""Test behavior when RequestOutput has None prompt_token_ids."""
200
- context = HarmonyContext(messages=[], available_tools=[])
234
+ context, _ = make_harmony_context()
235
236
mock_output = create_mock_request_output(
237
prompt_token_ids=None, # No prompt token IDs
@@ -216,12 +250,10 @@ def test_missing_prompt_token_ids():
250
assert context.num_tool_output_tokens == 0
251
252
219
-def test_reasoning_tokens_counting(mock_parser):
253
+def test_reasoning_tokens_counting():
254
"""Test that reasoning tokens are counted correctly."""
221
- context = HarmonyContext(messages=[], available_tools=[])
222
-
223
- # Mock parser to simulate reasoning channel
224
- mock_parser.current_channel = "analysis" # Reasoning channel
255
+ context, parser = make_harmony_context()
256
+ parser.enqueue_chunk_result(reasoning_token_count=4)
257
258
mock_output = create_mock_request_output(
259
prompt_token_ids=[1, 2, 3],
@@ -236,13 +268,11 @@ def test_reasoning_tokens_counting(mock_parser):
268
assert context.num_output_tokens == 4
269
270
239
-def test_preamble_tokens_not_counted_as_reasoning(mock_parser):
271
+def test_preamble_tokens_not_counted_as_reasoning():
272
"""Preambles (commentary with no recipient) are visible user text,
273
not hidden reasoning. They must NOT inflate num_reasoning_tokens."""
242
- context = HarmonyContext(messages=[], available_tools=[])
243
-
244
- mock_parser.current_channel = "commentary"
245
- mock_parser.current_recipient = None # preamble
274
+ context, parser = make_harmony_context()
275
+ parser.enqueue_chunk_result(reasoning_token_count=0)
276
277
mock_output = create_mock_request_output(
278
prompt_token_ids=[1, 2, 3],
@@ -255,13 +285,11 @@ def test_preamble_tokens_not_counted_as_reasoning(mock_parser):
285
assert context.num_output_tokens == 3
286
287
258
-def test_commentary_with_recipient_counted_as_reasoning(mock_parser):
288
+def test_commentary_with_recipient_counted_as_reasoning():
289
"""Commentary directed at a tool (recipient != None) is hidden from
290
the user, so it should still count as reasoning tokens."""
261
- context = HarmonyContext(messages=[], available_tools=[])
262
-
263
- mock_parser.current_channel = "commentary"
264
- mock_parser.current_recipient = "python"
291
+ context, parser = make_harmony_context()
292
+ parser.enqueue_chunk_result(reasoning_token_count=3)
293
294
mock_output = create_mock_request_output(
295
prompt_token_ids=[1, 2, 3],
@@ -276,7 +304,7 @@ def test_commentary_with_recipient_counted_as_reasoning(mock_parser):
304
305
def test_zero_tokens_edge_case():
306
"""Test behavior with all zero token counts."""
279
- context = HarmonyContext(messages=[], available_tools=[])
307
+ context, _ = make_harmony_context()
308
309
# Create a request with empty lists (not None) for both prompt and
310
# output tokens
@@ -299,10 +327,7 @@ def test_zero_tokens_edge_case():
327
@pytest.mark.asyncio
328
async def test_single_turn_no_tool_output():
329
"""Test that first turn never generates tool output tokens."""
302
- context = HarmonyContext(
303
- messages=[],
304
- available_tools=["browser"], # Tools available
305
- )
330
+ context, _ = make_harmony_context(available_tools=["browser"])
331
332
# Even with large prompt in first turn, no tool tokens should be counted
333
mock_output = create_mock_request_output(
@@ -324,7 +349,7 @@ async def test_negative_tool_tokens_edge_case():
349
tokens. We should log an error and clamp the value to 0."""
350
# Use patch to check if logger.error was called
351
with patch("vllm.entrypoints.openai.responses.context.logger.error") as mock_log:
327
- context = HarmonyContext(messages=[], available_tools=["browser"])
352
+ context, _ = make_harmony_context(available_tools=["browser"])
353
354
# First turn
355
mock_output1 = create_mock_request_output(
@@ -360,15 +385,15 @@ async def test_negative_tool_tokens_edge_case():
385
386
387
@pytest.mark.asyncio
363
-async def test_streaming_multi_turn_token_counting(mock_parser):
388
+async def test_streaming_multi_turn_token_counting():
389
"""Test token counting for streaming multi-turn conversations.
390
366
- This test focuses on how StreamingHarmonyContext counts tokens in a
391
+ This test focuses on how HarmonyContext counts tokens in a
392
multi-turn conversation with streaming (token-by-token) outputs and
393
message boundaries.
394
"""
395
# Create a streaming context
371
- context = StreamingHarmonyContext(messages=[], available_tools=["browser"])
396
+ context, parser = make_harmony_context(available_tools=["browser"])
397
398
num_prompt_tokens = [3, 8, 13]
399
num_output_tokens = [3, 3, 2]
@@ -413,10 +438,8 @@ async def test_streaming_multi_turn_token_counting(mock_parser):
438
assert context.num_tool_output_tokens == 0 # No tool output in first turn
439
assert context.first_tok_of_message is True # Ready for next message
440
416
- # Second turn: reasoning tokens in analysis channel
417
- mock_parser.current_channel = "analysis" # Set to reasoning channel
418
-
441
# First token of second turn
442
+ parser.enqueue_chunk_result(reasoning_token_count=1)
443
context.append_output(
444
create_mock_request_output(
445
prompt_token_ids=[
@@ -436,6 +459,7 @@ async def test_streaming_multi_turn_token_counting(mock_parser):
459
)
460
461
# More tokens in reasoning channel
462
+ parser.enqueue_chunk_result(reasoning_token_count=1)
463
context.append_output(
464
create_mock_request_output(
465
output_token_ids=[202],
@@ -443,6 +467,7 @@ async def test_streaming_multi_turn_token_counting(mock_parser):
467
)
468
)
469
470
+ parser.enqueue_chunk_result(reasoning_token_count=1)
471
context.append_output(
472
create_mock_request_output(
473
output_token_ids=[203],
@@ -460,9 +485,6 @@ async def test_streaming_multi_turn_token_counting(mock_parser):
485
expected_tool_tokens = 8 - 3 - 3 # = 2
486
assert context.num_tool_output_tokens == expected_tool_tokens
487
463
- # Third turn: regular output channel
464
- mock_parser.current_channel = "final" # Switch back to regular channel
465
-
488
# Third turn (with more cached tokens)
489
context.append_output(
490
create_mock_request_output(
@@ -520,13 +542,8 @@ async def test_streaming_multi_turn_token_counting(mock_parser):
542
543
544
@pytest.mark.asyncio
523
-async def test_streaming_message_synchronization(mock_parser):
524
- """Test message synchronization logic from lines 413-417 in context.py.
525
-
526
- This test verifies that when parser.messages contains more messages than
527
- the context's _messages (minus initial messages), the context properly
528
- extends its message list with the new parser messages.
529
- """
545
+async def test_streaming_message_synchronization():
546
+ """Completed messages from append-local and flush segments sync into context."""
547
548
# Create a streaming context with some initial messages
549
initial_messages = [
@@ -536,23 +553,30 @@ async def test_streaming_message_synchronization(mock_parser):
553
recipient=Role.ASSISTANT,
554
)
555
]
539
- context = StreamingHarmonyContext(messages=initial_messages, available_tools=[])
556
+ context, parser = make_harmony_context(messages=initial_messages)
557
558
# Verify initial state
559
assert len(context._messages) == 1
560
assert context.num_init_messages == 1
561
545
- # Mock parser to have more messages than context
546
- # Simulate parser having processed 3 new messages
547
- mock_parser.messages = [
548
- Message(
549
- author=Author(role=Role.ASSISTANT, name="assistant"),
550
- content=[TextContent(text="Response 1")],
551
- recipient=Role.USER,
552
- ),
553
- ]
562
+ response_text = "First response"
563
+ message = Message(
564
+ author=Author(role=Role.ASSISTANT, name="assistant"),
565
+ content=[TextContent(text=response_text)],
566
+ recipient=Role.USER,
567
+ )
568
+ parser.enqueue_chunk_result(
569
+ segments=[
570
+ Segment(
571
+ channel="commentary",
572
+ recipient=None,
573
+ delta="",
574
+ completed_message=message,
575
+ )
576
+ ]
577
+ )
578
555
- # This should trigger the message synchronization logic
579
+ # This should sync the completed message from the latest append
580
context.append_output(
581
create_mock_request_output(
582
prompt_token_ids=[1, 2, 3], output_token_ids=[101], finished=False
@@ -563,36 +587,39 @@ async def test_streaming_message_synchronization(mock_parser):
587
assert len(context._messages) == 2
588
589
# Verify the new messages were added correctly
566
- assert context._messages[1].content[0].text == "Response 1"
590
+ assert context._messages[1].content[0].text == response_text
591
568
- # Test the specific condition from line 413-414:
569
- # len(self._messages) - self.num_init_messages < len(self.parser.messages)
592
messages_minus_init = len(context._messages) - context.num_init_messages
571
- parser_messages_count = len(mock_parser.messages)
593
+ assert messages_minus_init == 1
594
573
- # After synchronization, they should be equal (no longer less than)
574
- assert messages_minus_init == parser_messages_count
575
-
576
- # Test edge case: add one more parser message
577
- mock_parser.messages.append(
578
- Message(
579
- author=Author(role=Role.ASSISTANT, name="assistant"),
580
- content=[TextContent(text="Response 4")],
581
- recipient=Role.USER,
582
- )
595
+ response_text = "Second response"
596
+ message = Message(
597
+ author=Author(role=Role.ASSISTANT, name="assistant"),
598
+ content=[TextContent(text=response_text)],
599
+ recipient=Role.USER,
600
)
584
-
585
- # Create another output to trigger synchronization again
586
- mock_output2 = create_mock_request_output(
587
- prompt_token_ids=[1, 2, 3], output_token_ids=[102], finished=True
601
+ flush_segment = Segment(
602
+ channel="commentary",
603
+ recipient=None,
604
+ delta="",
605
+ completed_message=message,
606
)
607
+ parser.enqueue_flush_result(flush_segment)
608
590
- context.append_output(mock_output2)
609
+ # Create another output to trigger synchronization via flush()
610
+ context.append_output(
611
+ create_mock_request_output(
612
+ prompt_token_ids=[1, 2, 3], output_token_ids=[102], finished=True
613
+ )
614
+ )
615
592
- # Verify the fourth message was added, num_init_messages is still 1
616
+ # Verify the flushed response was added, num_init_messages is still 1
617
assert len(context._messages) == 3
618
assert context.num_init_messages == 1
595
- assert context._messages[2].content[0].text == "Response 4"
619
+ assert context._messages[2].content[0].text == response_text
620
+ assert context.last_append_flush_status is True
621
+ assert len(context.last_append_segments) == 1
622
+ assert context.last_append_segments[0].completed_message is message
623
624
625
def test_turn_metrics_copy_and_reset():
vllm/entrypoints/openai/responses/context.py
+37
-120
@@ -18,7 +18,7 @@ from openai.types.responses.response_output_item import McpCall
18
from openai.types.responses.response_output_message import ResponseOutputMessage
19
from openai.types.responses.response_output_text import ResponseOutputText
20
from openai.types.responses.tool import Mcp
21
-from openai_harmony import Author, Message, Role, StreamState, TextContent
21
+from openai_harmony import Author, HarmonyError, Message, Role, TextContent
22
23
from vllm import envs
24
from vllm.entrypoints.chat_utils import (
@@ -29,11 +29,7 @@ from vllm.entrypoints.mcp.tool_server import ToolServer
29
from vllm.entrypoints.openai.engine.protocol import (
30
FunctionCall,
31
)
32
-from vllm.entrypoints.openai.parser.harmony_utils import (
33
- get_encoding,
34
- get_streamable_parser_for_assistant,
35
- render_for_completion,
36
-)
32
+from vllm.entrypoints.openai.parser.harmony_utils import render_for_completion
33
from vllm.entrypoints.openai.responses.protocol import (
34
ResponseInputOutputItem,
35
ResponseRawMessageAndToken,
@@ -597,18 +593,21 @@ class HarmonyContext(ConversationContext):
593
self,
594
messages: list,
595
available_tools: list[str],
600
- function_tool_names: frozenset[str] | None = None,
596
+ function_tool_names: frozenset[str],
597
response_parser: Parser | None = None,
598
):
599
+ from vllm.parser.harmony import HarmonyParser, Segment
600
+
601
+ assert isinstance(response_parser, HarmonyParser)
602
+
603
self._messages = messages
604
- self.response_parser = response_parser
604
+ self.response_parser: HarmonyParser = response_parser
605
self.finish_reason: str | None = None
606
self.available_tools = available_tools
607
self.function_tool_names = function_tool_names
608
self._tool_sessions: dict[str, ClientSession | Tool] = {}
609
self.called_tools: set[str] = set()
610
611
- self.parser = get_streamable_parser_for_assistant()
611
self.num_init_messages = len(messages)
612
self.num_prompt_tokens = 0
613
self.num_output_tokens = 0
@@ -616,44 +615,47 @@ class HarmonyContext(ConversationContext):
615
self.num_reasoning_tokens = 0
616
self.num_tool_output_tokens = 0
617
618
+ self.last_append_segments: list[Segment] = []
619
+ self.last_append_flush_status: bool | HarmonyError = False
620
+
621
# Turn tracking - replaces multiple individual tracking variables
622
self.current_turn_metrics = TurnMetrics()
623
# Track metrics for all turns
624
self.all_turn_metrics: list[TurnMetrics] = []
625
self.is_first_turn = True
624
- self.first_tok_of_message = True # For streaming support
626
+ self.first_tok_of_message = True
627
self.kv_transfer_params: dict[str, Any] | None = None
628
627
- def _update_num_reasoning_tokens(self):
628
- channel = self.parser.current_channel
629
- if channel == "analysis":
630
- self.num_reasoning_tokens += 1
631
- elif channel == "commentary" and self.parser.current_recipient is not None:
632
- # Tool interactions (python/browser/container) are hidden.
633
- # Preambles (recipient=None) are visible user text.
634
- self.num_reasoning_tokens += 1
635
-
629
def append_output(self, output: RequestOutput) -> None:
630
+ if self.first_tok_of_message:
631
+ self.finish_reason = None
632
+ self._update_prefill_token_usage(output)
633
+
634
output_token_ids = output.outputs[0].token_ids
638
- self.parser = get_streamable_parser_for_assistant()
639
- for token_id in output_token_ids:
640
- self.parser.process(token_id)
641
- # Check if the current token is part of reasoning content
642
- self._update_num_reasoning_tokens()
643
- self._update_prefill_token_usage(output)
635
+ result = self.response_parser.process_chunk(output_token_ids)
636
+ segments = result.segments
637
+ self.num_reasoning_tokens += result.reasoning_token_count
638
+
639
+ self.first_tok_of_message = output.finished
640
self._update_decode_token_usage(output)
641
if output.kv_transfer_params is not None:
642
self.kv_transfer_params = output.kv_transfer_params
647
- # Append current turn to all turn list for next turn's calculations
648
- self.all_turn_metrics.append(self.current_turn_metrics.copy())
649
- self.current_turn_metrics.reset()
650
- # append_output is called only once before tool calling
651
- # in non-streaming case
652
- # so we can append all the parser messages to _messages
653
- output_msgs = self.parser.messages
654
- # The responses finish reason is set in the last message
655
- self.finish_reason = output.outputs[0].finish_reason
656
- self._messages.extend(output_msgs)
643
+
644
+ if output.finished:
645
+ self.finish_reason = output.outputs[0].finish_reason
646
+ flushed = self.response_parser.flush()
647
+ if flushed is not None:
648
+ segments.append(flushed)
649
+ self.last_append_flush_status = flushed is not None
650
+ self.all_turn_metrics.append(self.current_turn_metrics.copy())
651
+ self.current_turn_metrics.reset()
652
+
653
+ self.last_append_segments = segments
654
+ self._messages.extend(
655
+ segment.completed_message
656
+ for segment in segments
657
+ if segment.completed_message is not None
658
+ )
659
660
def append_tool_output(self, output: list[Message]) -> None:
661
output_msgs = output
@@ -920,88 +922,3 @@ class HarmonyContext(ConversationContext):
922
for tool in self.called_tools
923
)
924
)
923
-
924
-
925
-class StreamingHarmonyContext(HarmonyContext):
926
- def __init__(self, *args, **kwargs):
927
- super().__init__(*args, **kwargs)
928
- self.last_output = None
929
-
930
- self.parser = get_streamable_parser_for_assistant()
931
- self.encoding = get_encoding()
932
- self.last_tok = None
933
- self.first_tok_of_message = True
934
- self.last_content_delta = None
935
-
936
- @property
937
- def messages(self) -> list:
938
- return self._messages
939
-
940
- def append_output(self, output: RequestOutput) -> None:
941
- # append_output is called for each output token in streaming case,
942
- # so we only want to add the prompt tokens once for each message.
943
- self.last_content_delta = None
944
- if self.first_tok_of_message:
945
- self._update_prefill_token_usage(output)
946
- # Reset self.first_tok_of_message if needed:
947
- # if the current token is the last one of the current message
948
- # (finished=True), then the next token processed will mark the
949
- # beginning of a new message
950
- self.first_tok_of_message = output.finished
951
- last_delta_text = ""
952
- for tok in output.outputs[0].token_ids:
953
- self.parser.process(tok)
954
- last_delta_text += self.parser.last_content_delta or ""
955
- if last_delta_text:
956
- self.last_content_delta = last_delta_text
957
- self._update_decode_token_usage(output)
958
- if output.kv_transfer_params is not None:
959
- self.kv_transfer_params = output.kv_transfer_params
960
-
961
- # For streaming, update previous turn when message is complete
962
- if output.finished:
963
- self.all_turn_metrics.append(self.current_turn_metrics.copy())
964
- self.current_turn_metrics.reset()
965
- # Check if the current token is part of reasoning content
966
- self._update_num_reasoning_tokens()
967
- self.last_tok = tok
968
- if len(self._messages) - self.num_init_messages < len(self.parser.messages):
969
- self._messages.extend(
970
- self.parser.messages[len(self._messages) - self.num_init_messages :]
971
- )
972
-
973
- def append_tool_output(self, output: list[Message]) -> None:
974
- # Handle the case of tool output in direct message format
975
- assert len(output) == 1, "Tool output should be a single message"
976
- msg = output[0]
977
- # Sometimes the recipient is not set for tool messages,
978
- # so we set it to "assistant"
979
- if msg.author.role == Role.TOOL and msg.recipient is None:
980
- msg.recipient = "assistant"
981
- toks = self.encoding.render(msg)
982
- for tok in toks:
983
- self.parser.process(tok)
984
- self.last_tok = toks[-1]
985
- # TODO: add tool_output messages to self._messages
986
-
987
- def is_expecting_start(self) -> bool:
988
- return self.parser.state == StreamState.EXPECT_START
989
-
990
- def is_assistant_action_turn(self) -> bool:
991
- return self.last_tok in self.encoding.stop_tokens_for_assistant_actions()
992
-
993
- def render_for_completion(self) -> list[int]:
994
- # now this list of tokens as next turn's starting tokens
995
- # `<|start|>assistant`,
996
- # we need to process them in parser.
997
- rendered_tokens = super().render_for_completion()
998
-
999
- last_n = -1
1000
- to_process = []
1001
- while rendered_tokens[last_n] != self.last_tok:
1002
- to_process.append(rendered_tokens[last_n])
1003
- last_n -= 1
1004
- for tok in reversed(to_process):
1005
- self.parser.process(tok)
1006
-
1007
- return rendered_tokens
vllm/entrypoints/openai/responses/harmony.py
+26
-128
@@ -27,7 +27,7 @@ from openai.types.responses.response_output_item import McpCall
27
from openai.types.responses.response_reasoning_item import (
28
Content as ResponseReasoningTextContent,
29
)
30
-from openai_harmony import Author, Message, Role, StreamableParser, TextContent
30
+from openai_harmony import Author, Message, Role, TextContent
31
32
from vllm.entrypoints.openai.parser.harmony_utils import (
33
BUILTIN_TOOL_TO_MCP_SERVER_LABEL,
@@ -306,7 +306,9 @@ def _parse_browser_tool_call(message: Message, recipient: str) -> ResponseOutput
306
)
307
308
309
-def _parse_function_call(message: Message, recipient: str) -> list[ResponseOutputItem]:
309
+def _parse_function_call(
310
+ message: Message, recipient: str, incomplete: bool = False
311
+) -> list[ResponseOutputItem]:
312
"""Parse function calls into function tool call items."""
313
function_name = extract_function_from_recipient(recipient)
314
output_items = []
@@ -318,7 +320,7 @@ def _parse_function_call(message: Message, recipient: str) -> list[ResponseOutpu
320
type="function_call",
321
name=function_name,
322
id=f"fc_{random_id}",
321
- status="completed",
323
+ status="incomplete" if incomplete else "completed",
324
)
325
output_items.append(response_item)
326
return output_items
@@ -341,7 +343,9 @@ def _parse_reasoning(message: Message) -> list[ResponseOutputItem]:
343
return output_items
344
345
344
-def _parse_final_message(message: Message) -> ResponseOutputItem:
346
+def _parse_final_message(
347
+ message: Message, incomplete: bool = False
348
+) -> ResponseOutputItem:
349
"""Parse final channel messages into output message items."""
350
contents = []
351
for content in message.content:
@@ -356,7 +360,7 @@ def _parse_final_message(message: Message) -> ResponseOutputItem:
360
id=f"msg_{random_uuid()}",
361
content=contents,
362
role=message.author.role,
359
- status="completed",
363
+ status="incomplete" if incomplete else "completed",
364
type="message",
365
)
366
@@ -381,7 +385,9 @@ def _parse_mcp_recipient(recipient: str) -> tuple[str, str]:
385
return server_label, tool_name
386
387
384
-def _parse_mcp_call(message: Message, recipient: str) -> list[ResponseOutputItem]:
388
+def _parse_mcp_call(
389
+ message: Message, recipient: str, incomplete: bool = False
390
+) -> list[ResponseOutputItem]:
391
"""Parse MCP calls into MCP call items."""
392
# Handle built-in tools that need server_label mapping
393
if recipient in BUILTIN_TOOL_TO_MCP_SERVER_LABEL:
@@ -398,7 +404,7 @@ def _parse_mcp_call(message: Message, recipient: str) -> list[ResponseOutputItem
404
name=tool_name,
405
server_label=server_label,
406
id=f"mcp_{random_uuid()}",
401
- status="completed",
407
+ status="incomplete" if incomplete else "completed",
408
)
409
output_items.append(response_item)
410
return output_items
@@ -406,6 +412,7 @@ def _parse_mcp_call(message: Message, recipient: str) -> list[ResponseOutputItem
412
413
def _parse_message_no_recipient(
414
message: Message,
415
+ incomplete: bool = False,
416
) -> list[ResponseOutputItem]:
417
"""Parse a Harmony message with no recipient based on its channel."""
418
if message.channel == "analysis":
@@ -415,7 +422,7 @@ def _parse_message_no_recipient(
422
# Per Harmony format, preambles (commentary with no recipient) and
423
# final channel content are both intended to be shown to end-users.
424
# See: https://cookbook.openai.com/articles/openai-harmony
418
- return [_parse_final_message(message)]
425
+ return [_parse_final_message(message, incomplete=incomplete)]
426
427
raise ValueError(f"Unknown channel: {message.channel}")
428
@@ -427,7 +434,8 @@ def _parse_message_no_recipient(
434
435
def harmony_to_response_output(
436
message: Message,
430
- function_tool_names: frozenset[str] | None = None,
437
+ function_tool_names: frozenset[str],
438
+ incomplete: bool = False,
439
) -> list[ResponseOutputItem]:
440
"""Parse a Harmony message into a list of output response items.
441
@@ -445,11 +453,14 @@ def harmony_to_response_output(
453
if recipient is not None:
454
# Browser tool calls (browser.search, browser.open, browser.find)
455
if recipient.startswith("browser."):
448
- output_items.append(_parse_browser_tool_call(message, recipient))
456
+ if not incomplete:
457
+ output_items.append(_parse_browser_tool_call(message, recipient))
458
459
# Function calls (with or without "functions." prefix)
460
elif is_function_recipient(recipient, function_tool_names):
452
- output_items.extend(_parse_function_call(message, recipient))
461
+ output_items.extend(
462
+ _parse_function_call(message, recipient, incomplete=incomplete)
463
+ )
464
465
# Built-in MCP tools (python, browser, container)
466
elif recipient in BUILTIN_TOOL_TO_MCP_SERVER_LABEL:
@@ -457,125 +468,12 @@ def harmony_to_response_output(
468
469
# All other recipients are MCP calls
470
else:
460
- output_items.extend(_parse_mcp_call(message, recipient))
471
+ output_items.extend(
472
+ _parse_mcp_call(message, recipient, incomplete=incomplete)
473
+ )
474
475
# No recipient - handle based on channel for non-tool messages
476
else:
464
- output_items.extend(_parse_message_no_recipient(message))
477
+ output_items.extend(_parse_message_no_recipient(message, incomplete=incomplete))
478
479
return output_items
467
-
468
-
469
-def parser_state_to_response_output(
470
- parser: StreamableParser,
471
- function_tool_names: frozenset[str] | None = None,
472
-) -> list[ResponseOutputItem]:
473
- """Extract in-progress response items from incomplete parser state.
474
-
475
- Called when the parser has buffered content that hasn't formed a
476
- complete message yet (e.g., generation was cut short).
477
- """
478
- if not parser.current_content:
479
- return []
480
- if parser.current_role != Role.ASSISTANT:
481
- return []
482
- current_recipient = parser.current_recipient
483
- if current_recipient is not None and current_recipient.startswith("browser."):
484
- return []
485
-
486
- if current_recipient:
487
- if is_function_recipient(current_recipient, function_tool_names):
488
- rid = random_uuid()
489
- return [
490
- ResponseFunctionToolCall(
491
- arguments=parser.current_content,
492
- call_id=f"call_{rid}",
493
- type="function_call",
494
- name=extract_function_from_recipient(current_recipient),
495
- id=f"fc_{rid}",
496
- status="in_progress",
497
- )
498
- ]
499
- # Built-in MCP tools (python, browser, container)
500
- elif current_recipient in BUILTIN_TOOL_TO_MCP_SERVER_LABEL:
501
- return [
502
- ResponseReasoningItem(
503
- id=f"rs_{random_uuid()}",
504
- summary=[],
505
- type="reasoning",
506
- content=[
507
- ResponseReasoningTextContent(
508
- text=parser.current_content, type="reasoning_text"
509
- )
510
- ],
511
- status=None,
512
- )
513
- ]
514
- # All other recipients are MCP calls
515
- else:
516
- rid = random_uuid()
517
- server_label, tool_name = _parse_mcp_recipient(current_recipient)
518
- return [
519
- McpCall(
520
- arguments=parser.current_content,
521
- type="mcp_call",
522
- name=tool_name,
523
- server_label=server_label,
524
- id=f"mcp_{rid}",
525
- status="in_progress",
526
- )
527
- ]
528
-
529
- if parser.current_channel == "commentary":
530
- # Per Harmony format, preambles (commentary with no recipient) are
531
- # intended to be shown to end-users, unlike analysis channel content.
532
- output_text = ResponseOutputText(
533
- text=parser.current_content,
534
- annotations=[],
535
- type="output_text",
536
- logprobs=None,
537
- )
538
- return [
539
- ResponseOutputMessage(
540
- id=f"msg_{random_uuid()}",
541
- content=[output_text],
542
- role="assistant",
543
- status="incomplete",
544
- type="message",
545
- )
546
- ]
547
-
548
- if parser.current_channel == "analysis":
549
- return [
550
- ResponseReasoningItem(
551
- id=f"rs_{random_uuid()}",
552
- summary=[],
553
- type="reasoning",
554
- content=[
555
- ResponseReasoningTextContent(
556
- text=parser.current_content, type="reasoning_text"
557
- )
558
- ],
559
- status=None,
560
- )
561
- ]
562
-
563
- if parser.current_channel == "final":
564
- output_text = ResponseOutputText(
565
- text=parser.current_content,
566
- annotations=[], # TODO
567
- type="output_text",
568
- logprobs=None, # TODO
569
- )
570
- text_item = ResponseOutputMessage(
571
- id=f"msg_{random_uuid()}",
572
- content=[output_text],
573
- role="assistant",
574
- # if the parser still has messages (ie if the generator got cut
575
- # abruptly), this should be incomplete
576
- status="incomplete",
577
- type="message",
578
- )
579
- return [text_item]
580
-
581
- return []
vllm/entrypoints/openai/responses/serving.py
+37
-46
@@ -54,12 +54,10 @@ from vllm.entrypoints.openai.responses.context import (
54
HarmonyContext,
55
ParsableContext,
56
SimpleContext,
57
- StreamingHarmonyContext,
57
)
58
from vllm.entrypoints.openai.responses.harmony import (
59
construct_harmony_previous_input_messages,
60
harmony_to_response_output,
62
- parser_state_to_response_output,
61
response_input_to_harmony,
62
)
63
from vllm.entrypoints.openai.responses.protocol import (
@@ -462,20 +460,12 @@ class OpenAIServingResponses(OpenAIServing):
460
context: ConversationContext
461
function_tool_names = extract_function_tool_names(request.tools)
462
if self.use_harmony:
465
- if request.stream:
466
- context = StreamingHarmonyContext(
467
- messages,
468
- available_tools,
469
- function_tool_names,
470
- response_parser=response_parser,
471
- )
472
- else:
473
- context = HarmonyContext(
474
- messages,
475
- available_tools,
476
- function_tool_names,
477
- response_parser=response_parser,
478
- )
463
+ context = HarmonyContext(
464
+ messages,
465
+ available_tools,
466
+ function_tool_names,
467
+ response_parser=response_parser,
468
+ )
469
else:
470
if envs.VLLM_USE_EXPERIMENTAL_PARSER_CONTEXT:
471
# This is a feature in development for parsing
@@ -718,7 +708,7 @@ class OpenAIServingResponses(OpenAIServing):
708
709
# Create inputs for the next turn.
710
# Render the next prompt token ids and update sampling_params.
721
- if isinstance(context, (HarmonyContext, StreamingHarmonyContext)):
711
+ if isinstance(context, HarmonyContext):
712
token_ids = context.render_for_completion()
713
engine_input = tokens_input(token_ids)
714
@@ -814,7 +804,20 @@ class OpenAIServingResponses(OpenAIServing):
804
output_messages: ResponseInputOutputMessage | None = None
805
if self.use_harmony:
806
assert isinstance(context, HarmonyContext)
817
- output = self._make_response_output_items_with_harmony(context)
807
+ output = []
808
+ harmony_msgs = context.messages[context.num_init_messages :]
809
+ if harmony_msgs:
810
+ fn_names = context.function_tool_names
811
+ for msg in harmony_msgs[:-1]:
812
+ output.extend(harmony_to_response_output(msg, fn_names))
813
+ output.extend(
814
+ harmony_to_response_output(
815
+ harmony_msgs[-1],
816
+ fn_names,
817
+ incomplete=context.last_append_flush_status,
818
+ )
819
+ )
820
+
821
if request.enable_response_messages:
822
input_messages = context.messages[: context.num_init_messages]
823
output_messages = context.messages[context.num_init_messages :]
@@ -1092,21 +1095,6 @@ class OpenAIServingResponses(OpenAIServing):
1095
)
1096
]
1097
1095
- def _make_response_output_items_with_harmony(
1096
- self,
1097
- context: HarmonyContext,
1098
- ) -> list[ResponseOutputItem]:
1099
- output_items: list[ResponseOutputItem] = []
1100
- num_init_messages = context.num_init_messages
1101
- fn_names = context.function_tool_names
1102
- for msg in context.messages[num_init_messages:]:
1103
- output_items.extend(harmony_to_response_output(msg, fn_names))
1104
- # Handle the generation stopped in the middle (if any).
1105
- last_items = parser_state_to_response_output(context.parser, fn_names)
1106
- if last_items:
1107
- output_items.extend(last_items)
1108
- return output_items
1109
-
1098
def _get_harmony_builtin_tool_descriptions(
1099
self, request: ResponsesRequest, tool_types: set[str]
1100
) -> dict[str, str | None]:
@@ -1423,27 +1411,30 @@ class OpenAIServingResponses(OpenAIServing):
1411
state = StreamingState()
1412
1413
async for ctx in result_generator:
1426
- assert isinstance(ctx, StreamingHarmonyContext)
1414
+ assert isinstance(ctx, HarmonyContext)
1415
1416
# finish_reason='error' indicates a retryable error
1417
self._raise_if_error(ctx.finish_reason, request.request_id)
1418
1431
- if ctx.is_expecting_start():
1432
- if len(ctx.parser.messages) > 0:
1433
- previous_item = ctx.parser.messages[-1]
1434
- for event in emit_previous_item_done_events(
1435
- previous_item, state, ctx.function_tool_names
1419
+ for segment in ctx.last_append_segments:
1420
+ if segment.delta:
1421
+ for event in emit_content_delta_events(
1422
+ segment, state, ctx.function_tool_names
1423
):
1424
yield _increment_sequence_number_and_return(event)
1438
- state.reset_for_new_item()
1425
1440
- # Stream the output of a harmony message
1441
- for event in emit_content_delta_events(ctx, state):
1442
- yield _increment_sequence_number_and_return(event)
1426
+ elif completed_message := segment.completed_message:
1427
+ # TODO: Fix browser emitted as MCP calls
1428
+ for event in emit_previous_item_done_events(
1429
+ completed_message, state, ctx.function_tool_names
1430
+ ):
1431
+ yield _increment_sequence_number_and_return(event)
1432
1444
- # Stream tool call outputs
1445
- for event in emit_tool_action_events(ctx, state, self.tool_server):
1446
- yield _increment_sequence_number_and_return(event)
1433
+ for event in emit_tool_action_events(
1434
+ completed_message, state, self.tool_server
1435
+ ):
1436
+ yield _increment_sequence_number_and_return(event)
1437
+ state.reset_for_new_item()
1438
1439
async def responses_stream_generator(
1440
self,
vllm/entrypoints/openai/responses/streaming_events.py
+22
-39
@@ -66,13 +66,13 @@ from vllm.entrypoints.openai.parser.harmony_utils import (
66
extract_function_from_recipient,
67
is_function_recipient,
68
)
69
-from vllm.entrypoints.openai.responses.context import StreamingHarmonyContext
69
from vllm.entrypoints.openai.responses.protocol import (
70
ResponseReasoningPartAddedEvent,
71
ResponseReasoningPartDoneEvent,
72
StreamingResponsesResponse,
73
)
74
from vllm.outputs import CompletionOutput
75
+from vllm.parser.harmony import Segment
76
from vllm.utils import random_uuid
77
78
TOOL_NAME_TO_MCP_SERVER_LABEL: Final[dict[str, str]] = {
@@ -110,6 +110,7 @@ class StreamingState:
110
def reset_for_new_item(self) -> None:
111
"""Reset state when expecting a new output item."""
112
self.current_output_index += 1
113
+ self.current_content_index = -1
114
self.sent_output_item_added = False
115
self.is_first_function_call_delta = False
116
self.current_call_id = ""
@@ -558,20 +559,21 @@ def emit_mcp_completion_events(
559
560
561
def emit_content_delta_events(
561
- ctx: StreamingHarmonyContext,
562
+ segment: Segment,
563
state: StreamingState,
564
+ function_tool_names: frozenset[str] | None = None,
565
) -> list[StreamingResponsesResponse]:
566
"""Emit events for content delta streaming based on channel type.
567
568
This is a Harmony-specific dispatcher that extracts values from the
567
- Harmony context and delegates to shared leaf helpers.
569
+ latest append segment and delegates to shared leaf helpers.
570
"""
569
- delta = ctx.last_content_delta
571
+ delta = segment.delta
572
if not delta:
573
return []
574
573
- channel = ctx.parser.current_channel
574
- recipient = ctx.parser.current_recipient
575
+ channel = segment.channel
576
+ recipient = segment.recipient
577
578
if channel in ("final", "commentary") and recipient is None:
579
# Preambles (commentary with no recipient) and final messages
@@ -580,7 +582,7 @@ def emit_content_delta_events(
582
elif channel == "analysis" and recipient is None:
583
return emit_reasoning_delta_events(delta, state)
584
elif recipient is not None:
583
- fn_names = ctx.function_tool_names
585
+ fn_names = function_tool_names
586
if is_function_recipient(recipient, fn_names):
587
function_name = extract_function_from_recipient(recipient)
588
return emit_function_call_delta_events(delta, function_name, state)
@@ -604,6 +606,12 @@ def emit_previous_item_done_events(
606
This is a Harmony-specific dispatcher that extracts values from the
607
Harmony parser's message object and delegates to shared leaf helpers.
608
"""
609
+ if not state.sent_output_item_added and not state.is_first_function_call_delta:
610
+ # Suppress done events for items had no delta and thus had no
611
+ # added/in-progress lifecycle events. This is a bug.
612
+ # TODO: Ensure added/in-progress events are emitted for zero-delta items.
613
+ return []
614
+
615
text = previous_item.content[0].text
616
if previous_item.recipient is not None:
617
# Deal with tool call
@@ -769,47 +777,22 @@ def emit_code_interpreter_completion_events(
777
778
779
def emit_tool_action_events(
772
- ctx: StreamingHarmonyContext,
780
+ previous_item: HarmonyMessage,
781
state: StreamingState,
782
tool_server: ToolServer | None,
783
) -> list[StreamingResponsesResponse]:
776
- """Emit events for tool action turn."""
777
- if not ctx.is_assistant_action_turn() or len(ctx.parser.messages) == 0:
778
- return []
779
-
780
- events: list[StreamingResponsesResponse] = []
781
- previous_item = ctx.parser.messages[-1]
782
-
784
+ """Emit events for a completed assistant action turn."""
785
# Handle browser tool
786
if (
785
- tool_server is not None
786
- and tool_server.has_tool("browser")
787
+ previous_item.author.role == "assistant"
788
and previous_item.recipient is not None
789
and previous_item.recipient.startswith("browser.")
790
+ and tool_server is not None
791
+ and tool_server.has_tool("browser")
792
):
790
- events.extend(emit_browser_tool_events(previous_item, state))
791
-
792
- # Handle tool completion
793
- if (
794
- tool_server is not None
795
- and previous_item.recipient is not None
796
- and state.current_item_id is not None
797
- and state.sent_output_item_added
798
- ):
799
- recipient = previous_item.recipient
800
- fn_names = ctx.function_tool_names
801
- if recipient == "python":
802
- events.extend(emit_code_interpreter_completion_events(previous_item, state))
803
- elif recipient.startswith("mcp.") or is_mcp_tool_by_namespace(
804
- recipient, fn_names
805
- ):
806
- events.extend(
807
- emit_mcp_completion_events(
808
- recipient, previous_item.content[0].text, state
809
- )
810
- )
793
+ return emit_browser_tool_events(previous_item, state)
794
812
- return events
795
+ return []
796
797
798
# =====================================================================