main
py 629 lines 21.5 KB
Raw
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 from openai_harmony import Message, Role
24
25 from tests.entrypoints.openai.utils import verify_harmony_messages
26 from vllm.entrypoints.openai.parser.harmony_utils import (
27 get_encoding,
28 get_system_message,
29 parse_chat_input_to_harmony_message,
30 render_for_completion,
31 )
32 from vllm.entrypoints.openai.responses.harmony import (
33 response_input_to_harmony,
34 response_previous_input_to_harmony,
35 )
36
37
38 def test_render_uses_legacy_tool_call_header():
39 tool_call = (
40 Message.from_role_and_content(Role.ASSISTANT, "print('hello')")
41 .with_channel("commentary")
42 .with_recipient("python")
43 .with_content_type("code")
44 )
45
46 rendered = get_encoding().decode(render_for_completion([tool_call]))
47
48 assert (
49 "<|start|>assistant to=python<|channel|>commentary code<|message|>" in rendered
50 )
51 assert "commentary to=python <|constrain|>code" not in rendered
52
53
54 # Use a fixed date so the system message is deterministic across both paths.
55 _DATE = "2025-01-01"
56
57
58 def _system():
59 return get_system_message(start_date=_DATE)
60
61
62 class TestResponseInputToHarmonyRenderParity:
63 """Each test drives the same conversation through both APIs and asserts
64 identical Harmony messages and rendered token sequences."""
65
66 # -----------------------------------------------------------------------
67 # Single-message cases
68 # -----------------------------------------------------------------------
69
70 def test_developer_message(self):
71 """Both APIs must render developer messages identically using
72 DeveloperContent (with the '# Instructions' header)."""
73 chat_msgs = parse_chat_input_to_harmony_message(
74 {"role": "developer", "content": "Be concise."}
75 )
76 resp_msgs = [
77 response_input_to_harmony(
78 {
79 "type": "message",
80 "role": "developer",
81 "content": "Be concise.",
82 },
83 prev_responses=[],
84 )
85 ]
86
87 expected = [{"role": "developer", "instructions": "Be concise."}]
88 verify_harmony_messages(chat_msgs, expected)
89 verify_harmony_messages(resp_msgs, expected)
90
91 assert render_for_completion([_system()] + chat_msgs) == render_for_completion(
92 [_system()] + resp_msgs
93 )
94
95 def test_user_message(self):
96 chat_msgs = parse_chat_input_to_harmony_message(
97 {"role": "user", "content": "What's the weather in Paris?"}
98 )
99 resp_msgs = [
100 response_input_to_harmony(
101 {
102 "type": "message",
103 "role": "user",
104 "content": "What's the weather in Paris?",
105 },
106 prev_responses=[],
107 )
108 ]
109
110 expected = [{"role": "user", "content": "What's the weather in Paris?"}]
111 verify_harmony_messages(chat_msgs, expected)
112 verify_harmony_messages(resp_msgs, expected)
113
114 assert render_for_completion([_system()] + chat_msgs) == render_for_completion(
115 [_system()] + resp_msgs
116 )
117
118 def test_assistant_final_message(self):
119 chat_msgs = parse_chat_input_to_harmony_message(
120 {"role": "assistant", "content": "It is 18°C in Paris."}
121 )
122 resp_msgs = [
123 response_input_to_harmony(
124 {
125 "type": "message",
126 "role": "assistant",
127 "content": "It is 18°C in Paris.",
128 },
129 prev_responses=[],
130 )
131 ]
132
133 expected = [
134 {"role": "assistant", "channel": "final", "content": "It is 18°C in Paris."}
135 ]
136 verify_harmony_messages(chat_msgs, expected)
137 verify_harmony_messages(resp_msgs, expected)
138
139 assert render_for_completion([_system()] + chat_msgs) == render_for_completion(
140 [_system()] + resp_msgs
141 )
142
143 def test_reasoning_item(self):
144 # Chat path: assistant message with only a reasoning field and no content.
145 chat_msgs = parse_chat_input_to_harmony_message(
146 {
147 "role": "assistant",
148 "reasoning": "I should call get_weather.",
149 "content": "",
150 }
151 )
152 resp_msgs = [
153 response_input_to_harmony(
154 {
155 "type": "reasoning",
156 "content": [
157 {"type": "reasoning_text", "text": "I should call get_weather."}
158 ],
159 },
160 prev_responses=[],
161 )
162 ]
163
164 expected = [
165 {
166 "role": "assistant",
167 "channel": "analysis",
168 "content": "I should call get_weather.",
169 }
170 ]
171 verify_harmony_messages(chat_msgs, expected)
172 verify_harmony_messages(resp_msgs, expected)
173
174 assert render_for_completion([_system()] + chat_msgs) == render_for_completion(
175 [_system()] + resp_msgs
176 )
177
178 def test_function_call(self):
179 chat_msgs = parse_chat_input_to_harmony_message(
180 {
181 "role": "assistant",
182 "tool_calls": [
183 {
184 "id": "call_1",
185 "function": {
186 "name": "get_weather",
187 "arguments": '{"location": "Paris"}',
188 },
189 }
190 ],
191 }
192 )
193 resp_msgs = [
194 response_input_to_harmony(
195 {
196 "type": "function_call",
197 "name": "get_weather",
198 "arguments": '{"location": "Paris"}',
199 },
200 prev_responses=[],
201 )
202 ]
203
204 expected = [
205 {
206 "role": "assistant",
207 "channel": "commentary",
208 "recipient": "functions.get_weather",
209 "content": '{"location": "Paris"}',
210 "content_type": "json",
211 }
212 ]
213 verify_harmony_messages(chat_msgs, expected)
214 verify_harmony_messages(resp_msgs, expected)
215
216 assert render_for_completion([_system()] + chat_msgs) == render_for_completion(
217 [_system()] + resp_msgs
218 )
219
220 def test_tool_output(self):
221 prev_call = ResponseFunctionToolCall(
222 id="fc_1",
223 call_id="call_1",
224 name="get_weather",
225 arguments='{"location": "Paris"}',
226 type="function_call",
227 )
228
229 chat_msgs = parse_chat_input_to_harmony_message(
230 {"role": "tool", "tool_call_id": "call_1", "content": "18°C, clear skies."},
231 tool_id_names={"call_1": "get_weather"},
232 )
233 resp_msgs = [
234 response_input_to_harmony(
235 {
236 "type": "function_call_output",
237 "call_id": "call_1",
238 "output": "18°C, clear skies.",
239 },
240 prev_responses=[prev_call],
241 )
242 ]
243
244 expected = [
245 {
246 "role": "tool",
247 "author_name": "functions.get_weather",
248 "channel": "commentary",
249 "recipient": "assistant",
250 "content": "18°C, clear skies.",
251 }
252 ]
253 verify_harmony_messages(chat_msgs, expected)
254 verify_harmony_messages(resp_msgs, expected)
255
256 assert render_for_completion([_system()] + chat_msgs) == render_for_completion(
257 [_system()] + resp_msgs
258 )
259
260 # -----------------------------------------------------------------------
261 # Combined and multi-turn cases
262 # -----------------------------------------------------------------------
263
264 def test_reasoning_combined_with_function_call(self):
265 """Chat API packs reasoning + tool_calls into one dict; responses API
266 represents them as two separate items. Both must produce the same two
267 Harmony messages in the same order: analysis then commentary."""
268 chat_msgs = parse_chat_input_to_harmony_message(
269 {
270 "role": "assistant",
271 "reasoning": "I should get the weather for Paris.",
272 "tool_calls": [
273 {
274 "id": "call_1",
275 "function": {
276 "name": "get_weather",
277 "arguments": '{"location": "Paris"}',
278 },
279 }
280 ],
281 }
282 )
283 resp_msgs = [
284 response_input_to_harmony(
285 {
286 "type": "reasoning",
287 "content": [
288 {
289 "type": "reasoning_text",
290 "text": "I should get the weather for Paris.",
291 }
292 ],
293 },
294 prev_responses=[],
295 ),
296 response_input_to_harmony(
297 {
298 "type": "function_call",
299 "name": "get_weather",
300 "arguments": '{"location": "Paris"}',
301 },
302 prev_responses=[],
303 ),
304 ]
305
306 expected = [
307 {
308 "role": "assistant",
309 "channel": "analysis",
310 "content": "I should get the weather for Paris.",
311 },
312 {
313 "role": "assistant",
314 "channel": "commentary",
315 "recipient": "functions.get_weather",
316 "content": '{"location": "Paris"}',
317 "content_type": "json",
318 },
319 ]
320 verify_harmony_messages(chat_msgs, expected)
321 verify_harmony_messages(resp_msgs, expected)
322
323 assert render_for_completion([_system()] + chat_msgs) == render_for_completion(
324 [_system()] + resp_msgs
325 )
326
327 def test_full_multi_turn_tool_call_conversation(self):
328 """Full conversation: user -> reasoning + tool_call -> tool_output -> final.
329
330 Both APIs must render the complete conversation to identical token sequences.
331 This exercises the entire input pipeline including all message types and
332 the Rust harmony encoder.
333 """
334 prev_call = ResponseFunctionToolCall(
335 id="fc_1",
336 call_id="call_1",
337 name="get_weather",
338 arguments='{"location": "Paris"}',
339 type="function_call",
340 )
341
342 # --- Chat completion API path ---
343 tool_id_names = {"call_1": "get_weather"}
344 chat_msgs = []
345 chat_msgs += parse_chat_input_to_harmony_message(
346 {"role": "user", "content": "What's the weather in Paris?"}
347 )
348 chat_msgs += parse_chat_input_to_harmony_message(
349 {
350 "role": "assistant",
351 "reasoning": "I should call get_weather for Paris.",
352 "tool_calls": [
353 {
354 "id": "call_1",
355 "function": {
356 "name": "get_weather",
357 "arguments": '{"location": "Paris"}',
358 },
359 }
360 ],
361 }
362 )
363 chat_msgs += parse_chat_input_to_harmony_message(
364 {"role": "tool", "tool_call_id": "call_1", "content": "18°C, clear skies."},
365 tool_id_names=tool_id_names,
366 )
367 chat_msgs += parse_chat_input_to_harmony_message(
368 {
369 "role": "assistant",
370 "content": "It is currently 18°C in Paris with clear skies.",
371 }
372 )
373
374 # --- Responses API path ---
375 resp_input = [
376 {
377 "type": "message",
378 "role": "user",
379 "content": "What's the weather in Paris?",
380 },
381 {
382 "type": "reasoning",
383 "content": [
384 {
385 "type": "reasoning_text",
386 "text": "I should call get_weather for Paris.",
387 }
388 ],
389 },
390 {
391 "type": "function_call",
392 "name": "get_weather",
393 "arguments": '{"location": "Paris"}',
394 },
395 {
396 "type": "function_call_output",
397 "call_id": "call_1",
398 "output": "18°C, clear skies.",
399 },
400 {
401 "type": "message",
402 "role": "assistant",
403 "content": "It is currently 18°C in Paris with clear skies.",
404 },
405 ]
406 resp_msgs = [
407 response_input_to_harmony(item, prev_responses=[prev_call])
408 for item in resp_input
409 ]
410
411 assert render_for_completion([_system()] + chat_msgs) == render_for_completion(
412 [_system()] + resp_msgs
413 )
414
415 def test_multi_turn_two_tool_calls_with_reasoning_between(self):
416 """Validates parity for a chain of two tool calls, each with its own
417 reasoning trace. Reasoning traces in between commentary-channel tool
418 calls must survive as analysis-channel messages in both paths.
419 """
420 first_reasoning = "I need current weather first."
421 second_reasoning = "Now I need the weekly forecast."
422
423 prev_call_1 = ResponseFunctionToolCall(
424 id="fc_1",
425 call_id="call_1",
426 name="get_weather",
427 arguments='{"location": "Paris"}',
428 type="function_call",
429 )
430 prev_call_2 = ResponseFunctionToolCall(
431 id="fc_2",
432 call_id="call_2",
433 name="get_forecast",
434 arguments='{"location": "Paris", "days": 7}',
435 type="function_call",
436 )
437
438 # --- Chat completion API path ---
439 tool_id_names = {"call_1": "get_weather", "call_2": "get_forecast"}
440 chat_msgs = []
441 chat_msgs += parse_chat_input_to_harmony_message(
442 {"role": "user", "content": "What's the weather and forecast for Paris?"}
443 )
444 # First reasoning + tool call
445 chat_msgs += parse_chat_input_to_harmony_message(
446 {
447 "role": "assistant",
448 "reasoning": first_reasoning,
449 "tool_calls": [
450 {
451 "id": "call_1",
452 "function": {
453 "name": "get_weather",
454 "arguments": '{"location": "Paris"}',
455 },
456 }
457 ],
458 }
459 )
460 chat_msgs += parse_chat_input_to_harmony_message(
461 {"role": "tool", "tool_call_id": "call_1", "content": "18°C, clear skies."},
462 tool_id_names=tool_id_names,
463 )
464 # Second reasoning + tool call
465 chat_msgs += parse_chat_input_to_harmony_message(
466 {
467 "role": "assistant",
468 "reasoning": second_reasoning,
469 "tool_calls": [
470 {
471 "id": "call_2",
472 "function": {
473 "name": "get_forecast",
474 "arguments": '{"location": "Paris", "days": 7}',
475 },
476 }
477 ],
478 }
479 )
480 chat_msgs += parse_chat_input_to_harmony_message(
481 {
482 "role": "tool",
483 "tool_call_id": "call_2",
484 "content": "Mon 17°C, Tue 19°C, Wed 16°C",
485 },
486 tool_id_names=tool_id_names,
487 )
488
489 # --- Responses API path ---
490 prev_responses = [prev_call_1, prev_call_2]
491 resp_input = [
492 {
493 "type": "message",
494 "role": "user",
495 "content": "What's the weather and forecast for Paris?",
496 },
497 # First reasoning + tool call
498 {
499 "type": "reasoning",
500 "content": [{"type": "reasoning_text", "text": first_reasoning}],
501 },
502 {
503 "type": "function_call",
504 "name": "get_weather",
505 "arguments": '{"location": "Paris"}',
506 },
507 {
508 "type": "function_call_output",
509 "call_id": "call_1",
510 "output": "18°C, clear skies.",
511 },
512 # Second reasoning + tool call
513 {
514 "type": "reasoning",
515 "content": [
516 {
517 "type": "reasoning_text",
518 "text": second_reasoning,
519 }
520 ],
521 },
522 {
523 "type": "function_call",
524 "name": "get_forecast",
525 "arguments": '{"location": "Paris", "days": 7}',
526 },
527 {
528 "type": "function_call_output",
529 "call_id": "call_2",
530 "output": "Mon 17°C, Tue 19°C, Wed 16°C",
531 },
532 ]
533 resp_msgs = [
534 response_input_to_harmony(item, prev_responses=prev_responses)
535 for item in resp_input
536 ]
537
538 chat_completion_tokens = render_for_completion([_system()] + chat_msgs)
539 responses_tokens = render_for_completion([_system()] + resp_msgs)
540
541 assert chat_completion_tokens == responses_tokens
542
543 rendered_prompt = get_encoding().decode(chat_completion_tokens)
544 assert first_reasoning in rendered_prompt
545 assert second_reasoning in rendered_prompt
546
547 def test_completed_turns_drop_reasoning(self):
548 """Validates that reasoning from completed turns is dropped, while
549 reasoning from the current in-progress tool-call turn is preserved
550 in both chat completions and responses previous_input_messages."""
551 first_turn_reasoning = "FIRST_TURN_REASONING"
552 second_turn_reasoning = "SECOND_TURN_REASONING"
553
554 chat_completion_msgs = []
555 for chat_message in [
556 {"role": "user", "content": "What is 2+2?"},
557 {
558 "role": "assistant",
559 "reasoning": first_turn_reasoning,
560 "content": "The answer is 4.",
561 },
562 {"role": "user", "content": "Now what is 3+3?"},
563 {
564 "role": "assistant",
565 "reasoning": second_turn_reasoning,
566 "tool_calls": [
567 {
568 "id": "call_1",
569 "function": {
570 "name": "calc",
571 "arguments": '{"a":3,"b":3}',
572 },
573 }
574 ],
575 },
576 ]:
577 chat_completion_msgs.extend(
578 parse_chat_input_to_harmony_message(chat_message)
579 )
580
581 responses_prev_input_msgs = []
582 for responses_message in [
583 {
584 "author": {"role": "user"},
585 "content": [{"type": "text", "text": "What is 2+2?"}],
586 },
587 {
588 "author": {"role": "assistant"},
589 "channel": "analysis",
590 "content": [{"type": "text", "text": first_turn_reasoning}],
591 },
592 {
593 "author": {"role": "assistant"},
594 "channel": "final",
595 "content": [{"type": "text", "text": "The answer is 4."}],
596 },
597 {
598 "author": {"role": "user"},
599 "content": [{"type": "text", "text": "Now what is 3+3?"}],
600 },
601 {
602 "author": {"role": "assistant"},
603 "channel": "analysis",
604 "content": [{"type": "text", "text": second_turn_reasoning}],
605 },
606 {
607 "author": {"role": "assistant"},
608 "channel": "commentary",
609 "recipient": "functions.calc",
610 "content_type": "json",
611 "content": [{"type": "text", "text": '{"a":3,"b":3}'}],
612 },
613 ]:
614 responses_prev_input_msgs.extend(
615 response_previous_input_to_harmony(responses_message)
616 )
617
618 chat_completion_tokens = render_for_completion(
619 [_system()] + chat_completion_msgs
620 )
621 responses_tokens = render_for_completion(
622 [_system()] + responses_prev_input_msgs
623 )
624
625 assert chat_completion_tokens == responses_tokens
626
627 rendered_prompt = get_encoding().decode(responses_tokens)
628 assert first_turn_reasoning not in rendered_prompt
629 assert second_turn_reasoning in rendered_prompt