A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 3, 21:30 UTC
[Bugfix] Fix empty channel/recipient in harmony for /v1/responses (#35540)
Signed-off-by: kg6-sleipnir <christopherhazen42@gmail.com> Signed-off-by: chazen <45186108+kg6-sleipnir@users.noreply.github.com> Co-authored-by: Chauncey <chaunceyjiang@gmail.com>
chazen committed
May 12, 2026 at 04:45 UTC
7f65f8442827196970b1f3b09cb56ca2f6ea7c5d
3 files changed
+768
tests/entrypoints/openai/parser/test_harmony_render_parity.py
new
+492
@@ -0,0 +1,492 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Cross-API render parity tests.
4
+
5
+Verifies that the chat completion input path (parse_chat_input_to_harmony_message)
6
+and the responses API input path (response_input_to_harmony) produce identical
7
+Harmony messages and identical rendered token sequences when given equivalent
8
+conversation representations.
9
+
10
+The chat completion API encodes reasoning and tool calls as fields on a single
11
+assistant message dict; the responses API encodes them as separate typed items
12
+in request.input. Both paths must converge on the same Harmony message list and
13
+therefore the same rendered prompt.
14
+
15
+Each test:
16
+ 1. Builds Harmony messages from each path for a single message or sequence.
17
+ 2. Asserts message-level properties (role, channel, recipient, content)
18
+ using verify_harmony_messages.
19
+ 3. Asserts that render_for_completion produces identical token sequences.
20
+"""
21
+
22
+from openai.types.responses import ResponseFunctionToolCall
23
+
24
+from tests.entrypoints.openai.utils import verify_harmony_messages
25
+from vllm.entrypoints.openai.parser.harmony_utils import (
26
+ get_system_message,
27
+ parse_chat_input_to_harmony_message,
28
+ render_for_completion,
29
+)
30
+from vllm.entrypoints.openai.responses.harmony import response_input_to_harmony
31
+
32
+# Use a fixed date so the system message is deterministic across both paths.
33
+_DATE = "2025-01-01"
34
+
35
+
36
+def _system():
37
+ return get_system_message(start_date=_DATE)
38
+
39
+
40
+class TestResponseInputToHarmonyRenderParity:
41
+ """Each test drives the same conversation through both APIs and asserts
42
+ identical Harmony messages and rendered token sequences."""
43
+
44
+ # -----------------------------------------------------------------------
45
+ # Single-message cases
46
+ # -----------------------------------------------------------------------
47
+
48
+ def test_user_message(self):
49
+ chat_msgs = parse_chat_input_to_harmony_message(
50
+ {"role": "user", "content": "What's the weather in Paris?"}
51
+ )
52
+ resp_msgs = [
53
+ response_input_to_harmony(
54
+ {
55
+ "type": "message",
56
+ "role": "user",
57
+ "content": "What's the weather in Paris?",
58
+ },
59
+ prev_responses=[],
60
+ )
61
+ ]
62
+
63
+ expected = [{"role": "user", "content": "What's the weather in Paris?"}]
64
+ verify_harmony_messages(chat_msgs, expected)
65
+ verify_harmony_messages(resp_msgs, expected)
66
+
67
+ assert render_for_completion([_system()] + chat_msgs) == render_for_completion(
68
+ [_system()] + resp_msgs
69
+ )
70
+
71
+ def test_assistant_final_message(self):
72
+ chat_msgs = parse_chat_input_to_harmony_message(
73
+ {"role": "assistant", "content": "It is 18°C in Paris."}
74
+ )
75
+ resp_msgs = [
76
+ response_input_to_harmony(
77
+ {
78
+ "type": "message",
79
+ "role": "assistant",
80
+ "content": "It is 18°C in Paris.",
81
+ },
82
+ prev_responses=[],
83
+ )
84
+ ]
85
+
86
+ expected = [
87
+ {"role": "assistant", "channel": "final", "content": "It is 18°C in Paris."}
88
+ ]
89
+ verify_harmony_messages(chat_msgs, expected)
90
+ verify_harmony_messages(resp_msgs, expected)
91
+
92
+ assert render_for_completion([_system()] + chat_msgs) == render_for_completion(
93
+ [_system()] + resp_msgs
94
+ )
95
+
96
+ def test_reasoning_item(self):
97
+ # Chat path: assistant message with only a reasoning field and no content.
98
+ chat_msgs = parse_chat_input_to_harmony_message(
99
+ {
100
+ "role": "assistant",
101
+ "reasoning": "I should call get_weather.",
102
+ "content": "",
103
+ }
104
+ )
105
+ resp_msgs = [
106
+ response_input_to_harmony(
107
+ {
108
+ "type": "reasoning",
109
+ "content": [
110
+ {"type": "reasoning_text", "text": "I should call get_weather."}
111
+ ],
112
+ },
113
+ prev_responses=[],
114
+ )
115
+ ]
116
+
117
+ expected = [
118
+ {
119
+ "role": "assistant",
120
+ "channel": "analysis",
121
+ "content": "I should call get_weather.",
122
+ }
123
+ ]
124
+ verify_harmony_messages(chat_msgs, expected)
125
+ verify_harmony_messages(resp_msgs, expected)
126
+
127
+ assert render_for_completion([_system()] + chat_msgs) == render_for_completion(
128
+ [_system()] + resp_msgs
129
+ )
130
+
131
+ def test_function_call(self):
132
+ chat_msgs = parse_chat_input_to_harmony_message(
133
+ {
134
+ "role": "assistant",
135
+ "tool_calls": [
136
+ {
137
+ "id": "call_1",
138
+ "function": {
139
+ "name": "get_weather",
140
+ "arguments": '{"location": "Paris"}',
141
+ },
142
+ }
143
+ ],
144
+ }
145
+ )
146
+ resp_msgs = [
147
+ response_input_to_harmony(
148
+ {
149
+ "type": "function_call",
150
+ "name": "get_weather",
151
+ "arguments": '{"location": "Paris"}',
152
+ },
153
+ prev_responses=[],
154
+ )
155
+ ]
156
+
157
+ expected = [
158
+ {
159
+ "role": "assistant",
160
+ "channel": "commentary",
161
+ "recipient": "functions.get_weather",
162
+ "content": '{"location": "Paris"}',
163
+ "content_type": "json",
164
+ }
165
+ ]
166
+ verify_harmony_messages(chat_msgs, expected)
167
+ verify_harmony_messages(resp_msgs, expected)
168
+
169
+ assert render_for_completion([_system()] + chat_msgs) == render_for_completion(
170
+ [_system()] + resp_msgs
171
+ )
172
+
173
+ def test_tool_output(self):
174
+ prev_call = ResponseFunctionToolCall(
175
+ id="fc_1",
176
+ call_id="call_1",
177
+ name="get_weather",
178
+ arguments='{"location": "Paris"}',
179
+ type="function_call",
180
+ )
181
+
182
+ chat_msgs = parse_chat_input_to_harmony_message(
183
+ {"role": "tool", "tool_call_id": "call_1", "content": "18°C, clear skies."},
184
+ tool_id_names={"call_1": "get_weather"},
185
+ )
186
+ resp_msgs = [
187
+ response_input_to_harmony(
188
+ {
189
+ "type": "function_call_output",
190
+ "call_id": "call_1",
191
+ "output": "18°C, clear skies.",
192
+ },
193
+ prev_responses=[prev_call],
194
+ )
195
+ ]
196
+
197
+ expected = [
198
+ {
199
+ "role": "tool",
200
+ "author_name": "functions.get_weather",
201
+ "channel": "commentary",
202
+ "recipient": "assistant",
203
+ "content": "18°C, clear skies.",
204
+ }
205
+ ]
206
+ verify_harmony_messages(chat_msgs, expected)
207
+ verify_harmony_messages(resp_msgs, expected)
208
+
209
+ assert render_for_completion([_system()] + chat_msgs) == render_for_completion(
210
+ [_system()] + resp_msgs
211
+ )
212
+
213
+ # -----------------------------------------------------------------------
214
+ # Combined and multi-turn cases
215
+ # -----------------------------------------------------------------------
216
+
217
+ def test_reasoning_combined_with_function_call(self):
218
+ """Chat API packs reasoning + tool_calls into one dict; responses API
219
+ represents them as two separate items. Both must produce the same two
220
+ Harmony messages in the same order: analysis then commentary."""
221
+ chat_msgs = parse_chat_input_to_harmony_message(
222
+ {
223
+ "role": "assistant",
224
+ "reasoning": "I should get the weather for Paris.",
225
+ "tool_calls": [
226
+ {
227
+ "id": "call_1",
228
+ "function": {
229
+ "name": "get_weather",
230
+ "arguments": '{"location": "Paris"}',
231
+ },
232
+ }
233
+ ],
234
+ }
235
+ )
236
+ resp_msgs = [
237
+ response_input_to_harmony(
238
+ {
239
+ "type": "reasoning",
240
+ "content": [
241
+ {
242
+ "type": "reasoning_text",
243
+ "text": "I should get the weather for Paris.",
244
+ }
245
+ ],
246
+ },
247
+ prev_responses=[],
248
+ ),
249
+ response_input_to_harmony(
250
+ {
251
+ "type": "function_call",
252
+ "name": "get_weather",
253
+ "arguments": '{"location": "Paris"}',
254
+ },
255
+ prev_responses=[],
256
+ ),
257
+ ]
258
+
259
+ expected = [
260
+ {
261
+ "role": "assistant",
262
+ "channel": "analysis",
263
+ "content": "I should get the weather for Paris.",
264
+ },
265
+ {
266
+ "role": "assistant",
267
+ "channel": "commentary",
268
+ "recipient": "functions.get_weather",
269
+ "content": '{"location": "Paris"}',
270
+ "content_type": "json",
271
+ },
272
+ ]
273
+ verify_harmony_messages(chat_msgs, expected)
274
+ verify_harmony_messages(resp_msgs, expected)
275
+
276
+ assert render_for_completion([_system()] + chat_msgs) == render_for_completion(
277
+ [_system()] + resp_msgs
278
+ )
279
+
280
+ def test_full_multi_turn_tool_call_conversation(self):
281
+ """Full conversation: user -> reasoning + tool_call -> tool_output -> final.
282
+
283
+ Both APIs must render the complete conversation to identical token sequences.
284
+ This exercises the entire input pipeline including all message types and
285
+ the Rust harmony encoder.
286
+ """
287
+ prev_call = ResponseFunctionToolCall(
288
+ id="fc_1",
289
+ call_id="call_1",
290
+ name="get_weather",
291
+ arguments='{"location": "Paris"}',
292
+ type="function_call",
293
+ )
294
+
295
+ # --- Chat completion API path ---
296
+ tool_id_names = {"call_1": "get_weather"}
297
+ chat_msgs = []
298
+ chat_msgs += parse_chat_input_to_harmony_message(
299
+ {"role": "user", "content": "What's the weather in Paris?"}
300
+ )
301
+ chat_msgs += parse_chat_input_to_harmony_message(
302
+ {
303
+ "role": "assistant",
304
+ "reasoning": "I should call get_weather for Paris.",
305
+ "tool_calls": [
306
+ {
307
+ "id": "call_1",
308
+ "function": {
309
+ "name": "get_weather",
310
+ "arguments": '{"location": "Paris"}',
311
+ },
312
+ }
313
+ ],
314
+ }
315
+ )
316
+ chat_msgs += parse_chat_input_to_harmony_message(
317
+ {"role": "tool", "tool_call_id": "call_1", "content": "18°C, clear skies."},
318
+ tool_id_names=tool_id_names,
319
+ )
320
+ chat_msgs += parse_chat_input_to_harmony_message(
321
+ {
322
+ "role": "assistant",
323
+ "content": "It is currently 18°C in Paris with clear skies.",
324
+ }
325
+ )
326
+
327
+ # --- Responses API path ---
328
+ resp_input = [
329
+ {
330
+ "type": "message",
331
+ "role": "user",
332
+ "content": "What's the weather in Paris?",
333
+ },
334
+ {
335
+ "type": "reasoning",
336
+ "content": [
337
+ {
338
+ "type": "reasoning_text",
339
+ "text": "I should call get_weather for Paris.",
340
+ }
341
+ ],
342
+ },
343
+ {
344
+ "type": "function_call",
345
+ "name": "get_weather",
346
+ "arguments": '{"location": "Paris"}',
347
+ },
348
+ {
349
+ "type": "function_call_output",
350
+ "call_id": "call_1",
351
+ "output": "18°C, clear skies.",
352
+ },
353
+ {
354
+ "type": "message",
355
+ "role": "assistant",
356
+ "content": "It is currently 18°C in Paris with clear skies.",
357
+ },
358
+ ]
359
+ resp_msgs = [
360
+ response_input_to_harmony(item, prev_responses=[prev_call])
361
+ for item in resp_input
362
+ ]
363
+
364
+ assert render_for_completion([_system()] + chat_msgs) == render_for_completion(
365
+ [_system()] + resp_msgs
366
+ )
367
+
368
+ def test_multi_turn_two_tool_calls_with_reasoning_between(self):
369
+ """Validates parity for a chain of two tool calls, each with its own
370
+ reasoning trace. Reasoning traces in between commentary-channel tool
371
+ calls must survive as analysis-channel messages in both paths.
372
+ """
373
+ prev_call_1 = ResponseFunctionToolCall(
374
+ id="fc_1",
375
+ call_id="call_1",
376
+ name="get_weather",
377
+ arguments='{"location": "Paris"}',
378
+ type="function_call",
379
+ )
380
+ prev_call_2 = ResponseFunctionToolCall(
381
+ id="fc_2",
382
+ call_id="call_2",
383
+ name="get_forecast",
384
+ arguments='{"location": "Paris", "days": 7}',
385
+ type="function_call",
386
+ )
387
+
388
+ # --- Chat completion API path ---
389
+ tool_id_names = {"call_1": "get_weather", "call_2": "get_forecast"}
390
+ chat_msgs = []
391
+ chat_msgs += parse_chat_input_to_harmony_message(
392
+ {"role": "user", "content": "What's the weather and forecast for Paris?"}
393
+ )
394
+ # First reasoning + tool call
395
+ chat_msgs += parse_chat_input_to_harmony_message(
396
+ {
397
+ "role": "assistant",
398
+ "reasoning": "I need current weather first.",
399
+ "tool_calls": [
400
+ {
401
+ "id": "call_1",
402
+ "function": {
403
+ "name": "get_weather",
404
+ "arguments": '{"location": "Paris"}',
405
+ },
406
+ }
407
+ ],
408
+ }
409
+ )
410
+ chat_msgs += parse_chat_input_to_harmony_message(
411
+ {"role": "tool", "tool_call_id": "call_1", "content": "18°C, clear skies."},
412
+ tool_id_names=tool_id_names,
413
+ )
414
+ # Second reasoning + tool call
415
+ chat_msgs += parse_chat_input_to_harmony_message(
416
+ {
417
+ "role": "assistant",
418
+ "reasoning": "Now I need the weekly forecast.",
419
+ "tool_calls": [
420
+ {
421
+ "id": "call_2",
422
+ "function": {
423
+ "name": "get_forecast",
424
+ "arguments": '{"location": "Paris", "days": 7}',
425
+ },
426
+ }
427
+ ],
428
+ }
429
+ )
430
+ chat_msgs += parse_chat_input_to_harmony_message(
431
+ {
432
+ "role": "tool",
433
+ "tool_call_id": "call_2",
434
+ "content": "Mon 17°C, Tue 19°C, Wed 16°C",
435
+ },
436
+ tool_id_names=tool_id_names,
437
+ )
438
+
439
+ # --- Responses API path ---
440
+ prev_responses = [prev_call_1, prev_call_2]
441
+ resp_input = [
442
+ {
443
+ "type": "message",
444
+ "role": "user",
445
+ "content": "What's the weather and forecast for Paris?",
446
+ },
447
+ # First reasoning + tool call
448
+ {
449
+ "type": "reasoning",
450
+ "content": [
451
+ {"type": "reasoning_text", "text": "I need current weather first."}
452
+ ],
453
+ },
454
+ {
455
+ "type": "function_call",
456
+ "name": "get_weather",
457
+ "arguments": '{"location": "Paris"}',
458
+ },
459
+ {
460
+ "type": "function_call_output",
461
+ "call_id": "call_1",
462
+ "output": "18°C, clear skies.",
463
+ },
464
+ # Second reasoning + tool call
465
+ {
466
+ "type": "reasoning",
467
+ "content": [
468
+ {
469
+ "type": "reasoning_text",
470
+ "text": "Now I need the weekly forecast.",
471
+ }
472
+ ],
473
+ },
474
+ {
475
+ "type": "function_call",
476
+ "name": "get_forecast",
477
+ "arguments": '{"location": "Paris", "days": 7}',
478
+ },
479
+ {
480
+ "type": "function_call_output",
481
+ "call_id": "call_2",
482
+ "output": "Mon 17°C, Tue 19°C, Wed 16°C",
483
+ },
484
+ ]
485
+ resp_msgs = [
486
+ response_input_to_harmony(item, prev_responses=prev_responses)
487
+ for item in resp_input
488
+ ]
489
+
490
+ assert render_for_completion([_system()] + chat_msgs) == render_for_completion(
491
+ [_system()] + resp_msgs
492
+ )
tests/entrypoints/openai/responses/test_response_input_to_harmony.py
new
+274
@@ -0,0 +1,274 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Unit tests for response_input_to_harmony.
4
+
5
+Covers every type branch in the function and verifies that each produced
6
+Harmony Message has the correct role, channel, recipient, content_type,
7
+author name, and text content.
8
+"""
9
+
10
+import pytest
11
+from openai.types.responses import ResponseFunctionToolCall, ResponseReasoningItem
12
+from openai.types.responses.response_reasoning_item import (
13
+ Content as ReasoningTextContent,
14
+)
15
+from openai_harmony import Role
16
+
17
+from vllm.entrypoints.openai.responses.harmony import response_input_to_harmony
18
+
19
+# ---------------------------------------------------------------------------
20
+# Shared fixtures
21
+# ---------------------------------------------------------------------------
22
+
23
+_PREV_CALL = ResponseFunctionToolCall(
24
+ id="fc_test",
25
+ call_id="call_test",
26
+ name="get_weather",
27
+ arguments='{"location": "Paris"}',
28
+ type="function_call",
29
+)
30
+
31
+_REASONING_ITEM = ResponseReasoningItem(
32
+ id="rs_test",
33
+ type="reasoning",
34
+ content=[ReasoningTextContent(type="reasoning_text", text="Thinking hard.")],
35
+ summary=[],
36
+ status=None,
37
+)
38
+
39
+
40
+class TestResponseInputToHarmonyMessage:
41
+ """Unit tests for every message type handled by response_input_to_harmony."""
42
+
43
+ # -----------------------------------------------------------------------
44
+ # type="message" (or no type key)
45
+ # -----------------------------------------------------------------------
46
+
47
+ def test_user_message_string_content(self):
48
+ msg = response_input_to_harmony(
49
+ {"type": "message", "role": "user", "content": "Hello"},
50
+ prev_responses=[],
51
+ )
52
+
53
+ assert msg.author.role == Role.USER
54
+ assert msg.content[0].text == "Hello"
55
+ assert msg.channel is None
56
+
57
+ def test_no_type_key_defaults_to_message_branch(self):
58
+ """Omitting 'type' should fall through to the message branch."""
59
+ msg = response_input_to_harmony(
60
+ {"role": "user", "content": "Hello"},
61
+ prev_responses=[],
62
+ )
63
+
64
+ assert msg.author.role == Role.USER
65
+ assert msg.content[0].text == "Hello"
66
+
67
+ def test_system_message(self):
68
+ msg = response_input_to_harmony(
69
+ {"type": "message", "role": "system", "content": "Be helpful."},
70
+ prev_responses=[],
71
+ )
72
+
73
+ assert msg.author.role == Role.SYSTEM
74
+ assert msg.content[0].text == "Be helpful."
75
+ assert msg.channel is None
76
+
77
+ def test_assistant_message_gets_final_channel(self):
78
+ msg = response_input_to_harmony(
79
+ {"type": "message", "role": "assistant", "content": "The answer is 42."},
80
+ prev_responses=[],
81
+ )
82
+
83
+ assert msg.author.role == Role.ASSISTANT
84
+ assert msg.channel == "final"
85
+ assert msg.content[0].text == "The answer is 42."
86
+
87
+ def test_developer_message_gets_instructions_prefix(self):
88
+ msg = response_input_to_harmony(
89
+ {"type": "message", "role": "developer", "content": "Be concise."},
90
+ prev_responses=[],
91
+ )
92
+
93
+ assert msg.author.role == Role.DEVELOPER
94
+ assert msg.content[0].text == "Instructions:\nBe concise."
95
+ assert msg.channel is None
96
+
97
+ def test_message_with_array_content(self):
98
+ msg = response_input_to_harmony(
99
+ {
100
+ "type": "message",
101
+ "role": "user",
102
+ "content": [
103
+ {"type": "text", "text": "Part one. "},
104
+ {"type": "text", "text": "Part two."},
105
+ ],
106
+ },
107
+ prev_responses=[],
108
+ )
109
+
110
+ assert msg.author.role == Role.USER
111
+ assert len(msg.content) == 2
112
+ assert msg.content[0].text == "Part one. "
113
+ assert msg.content[1].text == "Part two."
114
+
115
+ def test_developer_message_array_content_gets_prefix_on_each_part(self):
116
+ msg = response_input_to_harmony(
117
+ {
118
+ "type": "message",
119
+ "role": "developer",
120
+ "content": [
121
+ {"type": "text", "text": "Rule 1."},
122
+ {"type": "text", "text": "Rule 2."},
123
+ ],
124
+ },
125
+ prev_responses=[],
126
+ )
127
+
128
+ assert msg.content[0].text == "Instructions:\nRule 1."
129
+ assert msg.content[1].text == "Instructions:\nRule 2."
130
+
131
+ # -----------------------------------------------------------------------
132
+ # type="reasoning"
133
+ # -----------------------------------------------------------------------
134
+
135
+ def test_reasoning_gets_analysis_channel(self):
136
+ msg = response_input_to_harmony(
137
+ {
138
+ "type": "reasoning",
139
+ "content": [
140
+ {"type": "reasoning_text", "text": "I should call get_weather."}
141
+ ],
142
+ },
143
+ prev_responses=[],
144
+ )
145
+
146
+ assert msg.author.role == Role.ASSISTANT
147
+ assert msg.channel == "analysis"
148
+ assert msg.content[0].text == "I should call get_weather."
149
+
150
+ def test_reasoning_pydantic_model_input(self):
151
+ """A Pydantic ResponseReasoningItem should be model_dump()'d before parsing."""
152
+ msg = response_input_to_harmony(_REASONING_ITEM, prev_responses=[])
153
+
154
+ assert msg.author.role == Role.ASSISTANT
155
+ assert msg.channel == "analysis"
156
+ assert msg.content[0].text == "Thinking hard."
157
+
158
+ # -----------------------------------------------------------------------
159
+ # type="function_call"
160
+ # -----------------------------------------------------------------------
161
+
162
+ def test_function_call_channel_recipient_and_content_type(self):
163
+ msg = response_input_to_harmony(
164
+ {
165
+ "type": "function_call",
166
+ "name": "get_weather",
167
+ "arguments": '{"location": "Paris"}',
168
+ },
169
+ prev_responses=[],
170
+ )
171
+
172
+ assert msg.author.role == Role.ASSISTANT
173
+ assert msg.channel == "commentary"
174
+ assert msg.recipient == "functions.get_weather"
175
+ assert msg.content_type == "json"
176
+ assert msg.content[0].text == '{"location": "Paris"}'
177
+
178
+ def test_function_call_empty_arguments(self):
179
+ msg = response_input_to_harmony(
180
+ {"type": "function_call", "name": "ping", "arguments": ""},
181
+ prev_responses=[],
182
+ )
183
+
184
+ assert msg.recipient == "functions.ping"
185
+ assert msg.content[0].text == ""
186
+
187
+ # -----------------------------------------------------------------------
188
+ # type="function_call_output"
189
+ # -----------------------------------------------------------------------
190
+
191
+ def test_function_call_output_channel_recipient_and_author_name(self):
192
+ msg = response_input_to_harmony(
193
+ {"type": "function_call_output", "call_id": "call_test", "output": "18°C"},
194
+ prev_responses=[_PREV_CALL],
195
+ )
196
+
197
+ assert msg.author.role == Role.TOOL
198
+ assert msg.author.name == "functions.get_weather"
199
+ assert msg.channel == "commentary"
200
+ assert msg.recipient == "assistant"
201
+ assert msg.content[0].text == "18°C"
202
+
203
+ def test_function_call_output_uses_most_recent_matching_call(self):
204
+ """When multiple prev_responses share a call_id, the last one wins
205
+ because the search is reversed."""
206
+ earlier = ResponseFunctionToolCall(
207
+ id="fc_old",
208
+ call_id="call_test",
209
+ name="old_func",
210
+ arguments="{}",
211
+ type="function_call",
212
+ )
213
+ later = ResponseFunctionToolCall(
214
+ id="fc_new",
215
+ call_id="call_test",
216
+ name="get_weather",
217
+ arguments="{}",
218
+ type="function_call",
219
+ )
220
+
221
+ msg = response_input_to_harmony(
222
+ {
223
+ "type": "function_call_output",
224
+ "call_id": "call_test",
225
+ "output": "result",
226
+ },
227
+ prev_responses=[earlier, later],
228
+ )
229
+
230
+ assert msg.author.name == "functions.get_weather"
231
+
232
+ def test_function_call_output_skips_non_function_call_items_in_prev_responses(
233
+ self,
234
+ ):
235
+ """ResponseReasoningItem entries in prev_responses should be ignored."""
236
+ msg = response_input_to_harmony(
237
+ {
238
+ "type": "function_call_output",
239
+ "call_id": "call_test",
240
+ "output": "18°C",
241
+ },
242
+ prev_responses=[_REASONING_ITEM, _PREV_CALL],
243
+ )
244
+
245
+ assert msg.author.name == "functions.get_weather"
246
+
247
+ def test_function_call_output_raises_if_no_matching_call(self):
248
+ with pytest.raises(ValueError, match="No call message found for"):
249
+ response_input_to_harmony(
250
+ {
251
+ "type": "function_call_output",
252
+ "call_id": "no_such_id",
253
+ "output": "x",
254
+ },
255
+ prev_responses=[_PREV_CALL],
256
+ )
257
+
258
+ def test_function_call_output_raises_on_empty_prev_responses(self):
259
+ with pytest.raises(ValueError, match="No call message found for"):
260
+ response_input_to_harmony(
261
+ {"type": "function_call_output", "call_id": "call_test", "output": "x"},
262
+ prev_responses=[],
263
+ )
264
+
265
+ # -----------------------------------------------------------------------
266
+ # Error cases
267
+ # -----------------------------------------------------------------------
268
+
269
+ def test_unknown_type_raises_value_error(self):
270
+ with pytest.raises(ValueError, match="Unknown input type"):
271
+ response_input_to_harmony(
272
+ {"type": "image_url", "url": "https://example.com/img.png"},
273
+ prev_responses=[],
274
+ )
vllm/entrypoints/openai/responses/harmony.py
+2
@@ -175,6 +175,8 @@ def response_input_to_harmony(
175
Author.new(Role.TOOL, f"functions.{call_response.name}"),
176
response_msg["output"],
177
)
178
+ msg = msg.with_channel("commentary")
179
+ msg = msg.with_recipient("assistant")
180
elif response_msg["type"] == "reasoning":
181
content = response_msg.get("content")
182
if content and len(content) >= 1: