main
py 1,067 lines 35.2 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import pytest
5 from openai.types.responses import FunctionTool
6 from openai_harmony import DeveloperContent, Message, Role
7
8 from tests.entrypoints.openai.utils import verify_harmony_messages
9 from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionToolsParam
10 from vllm.entrypoints.openai.parser.harmony_utils import (
11 auto_drop_analysis_messages,
12 create_tool_definition,
13 extract_function_from_recipient,
14 get_system_message,
15 has_custom_tools,
16 is_function_recipient,
17 parse_chat_input_to_harmony_message,
18 )
19 from vllm.entrypoints.openai.responses.harmony import (
20 response_input_to_harmony,
21 response_previous_input_to_harmony,
22 )
23 from vllm.exceptions import VLLMValidationError
24
25 _TOOL_PARAMETERS = {
26 "type": "object",
27 "properties": {"status": {"type": "string"}},
28 "required": ["status"],
29 "additionalProperties": False,
30 }
31
32
33 class TestCreateToolDefinition:
34 def test_chat_completion_omitted_description_defaults_to_empty_string(self):
35 tool = ChatCompletionToolsParam(
36 function={
37 "name": "report_status",
38 "parameters": _TOOL_PARAMETERS,
39 }
40 )
41
42 tool_definition = create_tool_definition(tool)
43
44 assert tool_definition.name == "report_status"
45 assert tool_definition.description == ""
46 assert tool_definition.parameters == _TOOL_PARAMETERS
47
48 def test_chat_completion_none_description_defaults_to_empty_string(self):
49 tool = ChatCompletionToolsParam(
50 function={
51 "name": "report_status",
52 "description": None,
53 "parameters": _TOOL_PARAMETERS,
54 }
55 )
56
57 tool_definition = create_tool_definition(tool)
58
59 assert tool_definition.name == "report_status"
60 assert tool_definition.description == ""
61 assert tool_definition.parameters == _TOOL_PARAMETERS
62
63 def test_response_tool_none_description_defaults_to_empty_string(self):
64 tool = FunctionTool(
65 name="report_status",
66 description=None,
67 parameters=_TOOL_PARAMETERS,
68 type="function",
69 )
70
71 tool_definition = create_tool_definition(tool)
72
73 assert tool_definition.name == "report_status"
74 assert tool_definition.description == ""
75 assert tool_definition.parameters == _TOOL_PARAMETERS
76
77
78 class TestIsFunctionRecipient:
79 @pytest.mark.parametrize(
80 "recipient",
81 [
82 "functions.get_weather",
83 "functions.search_web",
84 "functions.math.sum",
85 ],
86 )
87 def test_functions_prefix_accepted(self, recipient):
88 assert is_function_recipient(recipient) is True
89
90 @pytest.mark.parametrize(
91 "recipient",
92 [
93 "get_weather",
94 "search_web",
95 "calculator",
96 "my-tool",
97 ],
98 )
99 def test_bare_function_name_accepted(self, recipient):
100 assert is_function_recipient(recipient) is True
101
102 @pytest.mark.parametrize(
103 "recipient",
104 [
105 "assistant",
106 ],
107 )
108 def test_assistant_rejected(self, recipient):
109 assert is_function_recipient(recipient) is False
110
111 @pytest.mark.parametrize(
112 "recipient",
113 [
114 "math.sum",
115 "code.run",
116 "namespace.tool_name",
117 "my.deeply.nested.tool",
118 ],
119 )
120 def test_dotted_function_names_accepted(self, recipient):
121 assert is_function_recipient(recipient) is True
122
123 @pytest.mark.parametrize(
124 "recipient",
125 [
126 "python",
127 "browser",
128 "container",
129 ],
130 )
131 def test_builtin_tool_names_rejected(self, recipient):
132 assert is_function_recipient(recipient) is False
133
134 @pytest.mark.parametrize(
135 "recipient",
136 [
137 "python.run",
138 "python.execute",
139 "browser.search",
140 "browser.open",
141 "container.exec",
142 ],
143 )
144 def test_builtin_dotted_variants_rejected(self, recipient):
145 assert is_function_recipient(recipient) is False
146
147 @pytest.mark.parametrize(
148 "recipient",
149 [
150 "",
151 "functions.",
152 ],
153 )
154 def test_empty_recipients_rejected(self, recipient):
155 assert is_function_recipient(recipient) is False
156
157 @pytest.mark.parametrize(
158 "recipient",
159 [
160 "<|start|>",
161 "<|end|>",
162 "<|channel|>",
163 ],
164 )
165 def test_harmony_tokens_rejected(self, recipient):
166 assert is_function_recipient(recipient) is False
167
168
169 class TestIsFunctionRecipientWithAllowedNames:
170 """Tests for is_function_recipient with allowed_function_tool_names."""
171
172 def test_prefixed_always_accepted(self):
173 """Functions. prefix is always accepted regardless of allowed names."""
174 fn_names = frozenset({"other_tool"})
175 assert is_function_recipient("functions.get_weather", fn_names) is True
176
177 def test_bare_name_accepted_when_in_allowed_names(self):
178 fn_names = frozenset({"get_weather", "search_web"})
179 assert is_function_recipient("get_weather", fn_names) is True
180 assert is_function_recipient("search_web", fn_names) is True
181
182 def test_bare_name_rejected_when_not_in_allowed_names(self):
183 fn_names = frozenset({"get_weather"})
184 assert is_function_recipient("unknown_tool", fn_names) is False
185
186 def test_dotted_name_accepted_when_in_allowed_names(self):
187 fn_names = frozenset({"math.sum", "namespace.tool_name"})
188 assert is_function_recipient("math.sum", fn_names) is True
189 assert is_function_recipient("namespace.tool_name", fn_names) is True
190
191 def test_dotted_name_rejected_when_not_in_allowed_names(self):
192 fn_names = frozenset({"get_weather"})
193 assert is_function_recipient("custom_server.search", fn_names) is False
194
195 def test_empty_allowed_names_rejects_bare_names(self):
196 """Empty frozenset means no function tools — bare names are not functions."""
197 fn_names: frozenset[str] = frozenset()
198 assert is_function_recipient("get_weather", fn_names) is False
199 assert is_function_recipient("math.sum", fn_names) is False
200
201 def test_builtin_tools_always_rejected(self):
202 fn_names = frozenset({"python", "browser", "container"})
203 assert is_function_recipient("python", fn_names) is False
204 assert is_function_recipient("browser", fn_names) is False
205 assert is_function_recipient("container", fn_names) is False
206
207 def test_builtin_dotted_always_rejected(self):
208 fn_names = frozenset({"python.run", "browser.search"})
209 assert is_function_recipient("python.run", fn_names) is False
210 assert is_function_recipient("browser.search", fn_names) is False
211
212 def test_none_allowed_names_uses_heuristic(self):
213 """When allowed names is None (Chat Completions), use heuristic."""
214 assert is_function_recipient("get_weather", None) is True
215 assert is_function_recipient("math.sum", None) is True
216 assert is_function_recipient("python", None) is False
217
218
219 class TestExtractFunctionFromRecipient:
220 @pytest.mark.parametrize(
221 "recipient,expected",
222 [
223 ("functions.get_weather", "get_weather"),
224 ("functions.search_web", "search_web"),
225 ("functions.", ""),
226 ],
227 )
228 def test_strips_functions_prefix(self, recipient, expected):
229 assert extract_function_from_recipient(recipient) == expected
230
231 @pytest.mark.parametrize(
232 "recipient",
233 [
234 "get_weather",
235 "calculator",
236 "my-tool",
237 ],
238 )
239 def test_bare_name_returned_as_is(self, recipient):
240 assert extract_function_from_recipient(recipient) == recipient
241
242 @pytest.mark.parametrize(
243 "recipient,expected",
244 [
245 ("functions.math.sum", "math.sum"),
246 ("math.sum", "math.sum"),
247 ("namespace.tool_name", "namespace.tool_name"),
248 ],
249 )
250 def test_dotted_function_name_extraction(self, recipient, expected):
251 assert extract_function_from_recipient(recipient) == expected
252
253
254 class TestCommonParseInputToHarmonyMessage:
255 """Tests for scenarios that are common to both Chat Completion
256 parse_chat_input_to_harmony_message and Responses API
257 response_previous_input_to_harmony functions.
258 """
259
260 @pytest.fixture(
261 params=[parse_chat_input_to_harmony_message, response_previous_input_to_harmony]
262 )
263 def parse_function(self, request):
264 return request.param
265
266 def test_assistant_message_with_tool_calls(self, parse_function):
267 """Test parsing assistant message with tool calls."""
268 chat_msg = {
269 "role": "assistant",
270 "tool_calls": [
271 {
272 "function": {
273 "name": "get_weather",
274 "arguments": '{"location": "San Francisco"}',
275 }
276 },
277 {
278 "function": {
279 "name": "search_web",
280 "arguments": '{"query": "latest news"}',
281 }
282 },
283 ],
284 }
285
286 messages = parse_function(chat_msg)
287
288 assert len(messages) == 2
289
290 # First tool call
291 assert messages[0].author.role == Role.ASSISTANT
292 assert messages[0].content[0].text == '{"location": "San Francisco"}'
293 assert messages[0].channel == "commentary"
294 assert messages[0].recipient == "functions.get_weather"
295 assert messages[0].content_type == "json"
296
297 # Second tool call
298 assert messages[1].author.role == Role.ASSISTANT
299 assert messages[1].content[0].text == '{"query": "latest news"}'
300 assert messages[1].channel == "commentary"
301 assert messages[1].recipient == "functions.search_web"
302 assert messages[1].content_type == "json"
303
304 def test_assistant_message_with_empty_tool_call_arguments(self, parse_function):
305 """Test parsing assistant message with tool call having None arguments."""
306 chat_msg = {
307 "role": "assistant",
308 "tool_calls": [
309 {
310 "function": {
311 "name": "get_current_time",
312 "arguments": None,
313 }
314 }
315 ],
316 }
317
318 messages = parse_function(chat_msg)
319
320 assert len(messages) == 1
321 assert messages[0].content[0].text == ""
322 assert messages[0].recipient == "functions.get_current_time"
323
324 def test_system_message(self, parse_function):
325 """Test parsing system messages, which are parsed into developer messages
326 with DeveloperContent."""
327 chat_msg = {
328 "role": "system",
329 "content": "You are a helpful assistant",
330 }
331
332 messages = parse_function(chat_msg)
333
334 assert len(messages) == 1
335 assert messages[0].author.role == Role.DEVELOPER
336 assert isinstance(messages[0].content[0], DeveloperContent)
337 assert messages[0].content[0].instructions == "You are a helpful assistant"
338
339 def test_developer_message(self, parse_function):
340 """Test parsing developer message."""
341 chat_msg = {
342 "role": "developer",
343 "content": "Use concise language",
344 }
345
346 messages = parse_function(chat_msg)
347
348 assert len(messages) == 1
349 assert messages[0].author.role == Role.DEVELOPER
350 assert isinstance(messages[0].content[0], DeveloperContent)
351 assert messages[0].content[0].instructions == "Use concise language"
352
353 def test_user_message_with_string_content(self, parse_function):
354 """Test parsing user message with string content."""
355 chat_msg = {
356 "role": "user",
357 "content": "What's the weather in San Francisco?",
358 }
359
360 messages = parse_function(chat_msg)
361
362 assert len(messages) == 1
363 assert messages[0].author.role == Role.USER
364 assert messages[0].content[0].text == "What's the weather in San Francisco?"
365
366 def test_user_message_with_array_content(self, parse_function):
367 """Test parsing user message with array content."""
368 chat_msg = {
369 "role": "user",
370 "content": [
371 {"text": "What's in this image? "},
372 {"text": "Please describe it."},
373 ],
374 }
375
376 messages = parse_function(chat_msg)
377
378 assert len(messages) == 1
379 assert messages[0].author.role == Role.USER
380 assert len(messages[0].content) == 2
381 assert messages[0].content[0].text == "What's in this image? "
382 assert messages[0].content[1].text == "Please describe it."
383
384 def test_assistant_message_with_string_content(self, parse_function):
385 """Test parsing assistant message with string content (no tool calls)."""
386 chat_msg = {
387 "role": "assistant",
388 "content": "Hello! How can I help you today?",
389 }
390
391 messages = parse_function(chat_msg)
392
393 assert len(messages) == 1
394 assert messages[0].author.role == Role.ASSISTANT
395 assert messages[0].content[0].text == "Hello! How can I help you today?"
396
397 def test_pydantic_model_input(self, parse_function):
398 """Test parsing Pydantic model input (has model_dump method)."""
399
400 class MockPydanticModel:
401 def model_dump(self, exclude_none=True):
402 return {
403 "role": "user",
404 "content": "Test message",
405 }
406
407 chat_msg = MockPydanticModel()
408 messages = parse_function(chat_msg)
409
410 assert len(messages) == 1
411 assert messages[0].author.role == Role.USER
412 assert messages[0].content[0].text == "Test message"
413
414 def test_tool_call_with_missing_function_fields(self, parse_function):
415 """Test parsing tool call with missing name or arguments."""
416 chat_msg = {
417 "role": "assistant",
418 "tool_calls": [
419 {
420 "function": {} # Missing both name and arguments
421 }
422 ],
423 }
424
425 messages = parse_function(chat_msg)
426
427 assert len(messages) == 1
428 assert messages[0].recipient == "functions."
429 assert messages[0].content[0].text == ""
430
431 def test_array_content_with_missing_text(self, parse_function):
432 """Test parsing array content where text field is missing."""
433 chat_msg = {
434 "role": "user",
435 "content": [
436 {}, # Missing text field
437 {"text": "actual text"},
438 ],
439 }
440
441 messages = parse_function(chat_msg)
442
443 assert len(messages) == 1
444 assert len(messages[0].content) == 2
445 assert messages[0].content[0].text == ""
446 assert messages[0].content[1].text == "actual text"
447
448
449 class TestParseChatInputToHarmonyMessage:
450 """Tests for scenarios that are specific to the Chat Completion API
451 parse_chat_input_to_harmony_message function.
452 """
453
454 def test_user_message_with_empty_content(self):
455 chat_msg = {
456 "role": "user",
457 "content": "",
458 }
459
460 messages = parse_chat_input_to_harmony_message(chat_msg)
461
462 verify_harmony_messages(
463 messages,
464 [
465 {
466 "role": "user",
467 "content": "",
468 },
469 ],
470 )
471
472 def test_user_message_with_none_content(self):
473 chat_msg = {
474 "role": "user",
475 "content": None,
476 }
477
478 messages = parse_chat_input_to_harmony_message(chat_msg)
479
480 verify_harmony_messages(
481 messages,
482 [
483 {
484 "role": "user",
485 "content": "",
486 },
487 ],
488 )
489
490 def test_assistant_message_with_empty_content(self):
491 chat_msg = {
492 "role": "assistant",
493 "content": "",
494 }
495
496 messages = parse_chat_input_to_harmony_message(chat_msg)
497
498 assert len(messages) == 0
499
500 def test_assistant_message_with_none_content(self):
501 chat_msg = {
502 "role": "assistant",
503 "content": None,
504 }
505
506 messages = parse_chat_input_to_harmony_message(chat_msg)
507
508 assert len(messages) == 0
509
510 def test_assistant_message_with_content_but_empty_reasoning(self):
511 chat_msg = {
512 "role": "assistant",
513 "content": "The answer is 4.",
514 "reasoning": "",
515 }
516
517 messages = parse_chat_input_to_harmony_message(chat_msg)
518
519 verify_harmony_messages(
520 messages,
521 [
522 {
523 "role": "assistant",
524 "channel": "final",
525 "content": "The answer is 4.",
526 },
527 ],
528 )
529
530 def test_assistant_message_with_reasoning_but_empty_content(self):
531 chat_msg = {
532 "role": "assistant",
533 "reasoning": "I'm thinking about the user's question.",
534 "content": "",
535 }
536
537 messages = parse_chat_input_to_harmony_message(chat_msg)
538
539 verify_harmony_messages(
540 messages,
541 [
542 {
543 "role": "assistant",
544 "channel": "analysis",
545 "content": "I'm thinking about the user's question.",
546 },
547 ],
548 )
549
550 def test_assistant_message_with_reasoning_but_none_content(self):
551 chat_msg = {
552 "role": "assistant",
553 "reasoning": "I'm thinking about the user's question.",
554 "content": None,
555 }
556
557 messages = parse_chat_input_to_harmony_message(chat_msg)
558
559 verify_harmony_messages(
560 messages,
561 [
562 {
563 "role": "assistant",
564 "channel": "analysis",
565 "content": "I'm thinking about the user's question.",
566 },
567 ],
568 )
569
570 def test_assistant_message_with_tool_calls_but_no_content(self):
571 chat_msg = {
572 "role": "assistant",
573 "tool_calls": [
574 {
575 "function": {
576 "name": "get_weather",
577 "arguments": '{"location": "San Francisco"}',
578 }
579 }
580 ],
581 }
582
583 messages = parse_chat_input_to_harmony_message(chat_msg)
584
585 verify_harmony_messages(
586 messages,
587 [
588 {
589 "role": "assistant",
590 "channel": "commentary",
591 "recipient": "functions.get_weather",
592 "content": '{"location": "San Francisco"}',
593 "content_type": "json",
594 },
595 ],
596 )
597
598 def test_assistant_message_with_tool_calls_and_content(self):
599 chat_msg = {
600 "role": "assistant",
601 "tool_calls": [
602 {
603 "function": {
604 "name": "get_weather",
605 "arguments": '{"location": "San Francisco"}',
606 }
607 }
608 ],
609 "content": "I'll call the tool.",
610 }
611
612 messages = parse_chat_input_to_harmony_message(chat_msg)
613
614 verify_harmony_messages(
615 messages,
616 [
617 {
618 "role": "assistant",
619 "channel": "commentary",
620 "content": "I'll call the tool.",
621 },
622 {
623 "role": "assistant",
624 "channel": "commentary",
625 "recipient": "functions.get_weather",
626 "content": '{"location": "San Francisco"}',
627 "content_type": "json",
628 },
629 ],
630 )
631
632 def test_assistant_message_with_tool_calls_and_reasoning(self):
633 chat_msg = {
634 "role": "assistant",
635 "tool_calls": [
636 {
637 "function": {
638 "name": "get_weather",
639 "arguments": '{"location": "San Francisco"}',
640 }
641 }
642 ],
643 "reasoning": "I should use the get_weather tool.",
644 }
645
646 messages = parse_chat_input_to_harmony_message(chat_msg)
647
648 verify_harmony_messages(
649 messages,
650 [
651 {
652 "role": "assistant",
653 "channel": "analysis",
654 "content": "I should use the get_weather tool.",
655 },
656 {
657 "role": "assistant",
658 "channel": "commentary",
659 "recipient": "functions.get_weather",
660 "content": '{"location": "San Francisco"}',
661 "content_type": "json",
662 },
663 ],
664 )
665
666 def test_assistant_message_with_tool_calls_and_reasoning_and_content(self):
667 chat_msg = {
668 "role": "assistant",
669 "tool_calls": [
670 {
671 "function": {
672 "name": "get_weather",
673 "arguments": '{"location": "San Francisco"}',
674 }
675 }
676 ],
677 "reasoning": "I should use the get_weather tool.",
678 "content": "I'll call the tool.",
679 }
680
681 messages = parse_chat_input_to_harmony_message(chat_msg)
682
683 verify_harmony_messages(
684 messages,
685 [
686 {
687 "role": "assistant",
688 "channel": "commentary",
689 "content": "I'll call the tool.",
690 },
691 {
692 "role": "assistant",
693 "channel": "analysis",
694 "content": "I should use the get_weather tool.",
695 },
696 {
697 "role": "assistant",
698 "channel": "commentary",
699 "recipient": "functions.get_weather",
700 "content": '{"location": "San Francisco"}',
701 "content_type": "json",
702 },
703 ],
704 )
705
706 def test_tool_message_with_string_content(self):
707 tool_id_names = {
708 "call_123": "get_weather",
709 }
710 chat_msg = {
711 "role": "tool",
712 "tool_call_id": "call_123",
713 "content": "The weather in San Francisco is sunny, 72°F",
714 }
715
716 messages = parse_chat_input_to_harmony_message(
717 chat_msg, tool_id_names=tool_id_names
718 )
719
720 verify_harmony_messages(
721 messages,
722 [
723 {
724 "role": "tool",
725 "name": "functions.get_weather",
726 "content": "The weather in San Francisco is sunny, 72°F",
727 "channel": "commentary",
728 },
729 ],
730 )
731
732 def test_tool_message_with_array_content(self):
733 tool_id_names = {
734 "call_123": "search_results",
735 }
736 chat_msg = {
737 "role": "tool",
738 "tool_call_id": "call_123",
739 "content": [
740 {"type": "text", "text": "Result 1: "},
741 {"type": "text", "text": "Result 2: "},
742 {
743 "type": "image",
744 "url": "http://example.com/img.png",
745 }, # Should be ignored
746 {"type": "text", "text": "Result 3"},
747 ],
748 }
749
750 messages = parse_chat_input_to_harmony_message(
751 chat_msg, tool_id_names=tool_id_names
752 )
753
754 verify_harmony_messages(
755 messages,
756 [
757 {
758 "role": "tool",
759 "name": "functions.search_results",
760 "content": "Result 1: Result 2: Result 3",
761 "channel": "commentary",
762 },
763 ],
764 )
765
766 def test_tool_message_with_empty_content(self):
767 tool_id_names = {
768 "call_123": "empty_tool",
769 }
770 chat_msg = {
771 "role": "tool",
772 "tool_call_id": "call_123",
773 "content": "",
774 }
775
776 messages = parse_chat_input_to_harmony_message(
777 chat_msg, tool_id_names=tool_id_names
778 )
779
780 verify_harmony_messages(
781 messages,
782 [
783 {
784 "role": "tool",
785 "name": "functions.empty_tool",
786 "content": "",
787 "channel": "commentary",
788 },
789 ],
790 )
791
792 def test_tool_message_with_none_content(self):
793 tool_id_names = {
794 "call_123": "empty_tool",
795 }
796 chat_msg = {
797 "role": "tool",
798 "tool_call_id": "call_123",
799 "content": None,
800 }
801
802 messages = parse_chat_input_to_harmony_message(
803 chat_msg, tool_id_names=tool_id_names
804 )
805
806 verify_harmony_messages(
807 messages,
808 [
809 {
810 "role": "tool",
811 "name": "functions.empty_tool",
812 "content": "",
813 "channel": "commentary",
814 },
815 ],
816 )
817
818
819 class TestAutoDropAnalysisMessages:
820 def test_no_analysis_messages(self) -> None:
821 messages = [
822 Message.from_role_and_content(
823 Role.ASSISTANT, "The answer is 4."
824 ).with_channel("final"),
825 ]
826 cleaned_messages = auto_drop_analysis_messages(messages)
827 assert cleaned_messages == messages
828
829 def test_only_analysis_message(self) -> None:
830 messages = [
831 Message.from_role_and_content(
832 Role.ASSISTANT, "I'm thinking about the user's question."
833 ).with_channel("analysis"),
834 ]
835 cleaned_messages = auto_drop_analysis_messages(messages)
836 assert cleaned_messages == messages
837
838 def test_multiple_analysis_messages_without_final_message(self) -> None:
839 messages = [
840 Message.from_role_and_content(
841 Role.ASSISTANT, "I'm thinking about the user's question."
842 ).with_channel("analysis"),
843 Message.from_role_and_content(
844 Role.ASSISTANT, "I'm thinking more."
845 ).with_channel("analysis"),
846 Message.from_role_and_content(
847 Role.ASSISTANT, "I'm thinking even more."
848 ).with_channel("analysis"),
849 ]
850 cleaned_messages = auto_drop_analysis_messages(messages)
851 assert cleaned_messages == messages
852
853 def test_only_final_message(self) -> None:
854 messages = [
855 Message.from_role_and_content(
856 Role.ASSISTANT, "The answer is 4."
857 ).with_channel("final"),
858 ]
859 cleaned_messages = auto_drop_analysis_messages(messages)
860 assert cleaned_messages == messages
861
862 def test_drops_one_analysis_messages_before_final_message(self) -> None:
863 messages = [
864 Message.from_role_and_content(
865 Role.ASSISTANT, "I'm thinking about the user's question."
866 ).with_channel("analysis"),
867 Message.from_role_and_content(
868 Role.ASSISTANT, "The answer is 4."
869 ).with_channel("final"),
870 Message.from_role_and_content(
871 Role.ASSISTANT, "I should think harder."
872 ).with_channel("analysis"),
873 ]
874 cleaned_messages = auto_drop_analysis_messages(messages)
875 # Should have dropped the first analysis message
876 assert cleaned_messages == messages[1:]
877
878 def test_drops_all_analysis_messages_before_final_message(self) -> None:
879 messages = [
880 Message.from_role_and_content(
881 Role.ASSISTANT, "I'm thinking about the user's question."
882 ).with_channel("analysis"),
883 Message.from_role_and_content(
884 Role.ASSISTANT, "I'm thinking more."
885 ).with_channel("analysis"),
886 Message.from_role_and_content(
887 Role.ASSISTANT, "I'm thinking even more."
888 ).with_channel("analysis"),
889 Message.from_role_and_content(
890 Role.ASSISTANT, "The answer is 4."
891 ).with_channel("final"),
892 Message.from_role_and_content(
893 Role.ASSISTANT, "I should think harder."
894 ).with_channel("analysis"),
895 ]
896 cleaned_messages = auto_drop_analysis_messages(messages)
897 # Should have dropped the first 3 analysis messages
898 assert cleaned_messages == messages[3:]
899
900 def test_multiple_analysis_messages_with_multiple_final_messages(self) -> None:
901 messages = [
902 Message.from_role_and_content(
903 Role.ASSISTANT, "I'm thinking about the user's question."
904 ).with_channel("analysis"),
905 Message.from_role_and_content(
906 Role.ASSISTANT, "I'm thinking more."
907 ).with_channel("analysis"),
908 Message.from_role_and_content(
909 Role.ASSISTANT, "I'm thinking even more."
910 ).with_channel("analysis"),
911 Message.from_role_and_content(
912 Role.ASSISTANT, "The answer is 4."
913 ).with_channel("final"),
914 Message.from_role_and_content(
915 Role.ASSISTANT, "I should think harder."
916 ).with_channel("analysis"),
917 Message.from_role_and_content(
918 Role.ASSISTANT, "The answer is 5."
919 ).with_channel("final"),
920 ]
921 cleaned_messages = auto_drop_analysis_messages(messages)
922 # Should have dropped all those analysis messages
923 assert len(cleaned_messages) == 2
924 assert cleaned_messages[0].content[0].text == "The answer is 4."
925 assert cleaned_messages[1].content[0].text == "The answer is 5."
926
927 def test_drops_non_assistant_analysis_messages(self) -> None:
928 messages = [
929 Message.from_role_and_content(
930 Role.TOOL, "The tool thinks we should think harder."
931 ).with_channel("analysis"),
932 Message.from_role_and_content(
933 Role.ASSISTANT, "The answer is 4."
934 ).with_channel("final"),
935 ]
936 cleaned_messages = auto_drop_analysis_messages(messages)
937 # Should have dropped the analysis message
938 assert cleaned_messages == messages[1:]
939
940
941 def test_has_custom_tools() -> None:
942 assert not has_custom_tools(set())
943 assert not has_custom_tools({"web_search_preview", "code_interpreter", "container"})
944 assert has_custom_tools({"others"})
945 assert has_custom_tools(
946 {"web_search_preview", "code_interpreter", "container", "others"}
947 )
948
949
950 class TestGetSystemMessage:
951 """Tests for get_system_message channel configuration."""
952
953 def test_commentary_channel_present_without_custom_tools(self) -> None:
954 """Commentary channel must be valid even without custom tools."""
955 sys_msg = get_system_message(with_custom_tools=False)
956 valid_channels = sys_msg.content[0].channel_config.valid_channels
957 assert "commentary" in valid_channels
958
959 def test_commentary_channel_present_with_custom_tools(self) -> None:
960 """Commentary channel present when custom tools are enabled."""
961 sys_msg = get_system_message(with_custom_tools=True)
962 valid_channels = sys_msg.content[0].channel_config.valid_channels
963 assert "commentary" in valid_channels
964
965 def test_all_standard_channels_present(self) -> None:
966 """All three standard Harmony channels should always be valid."""
967 for with_tools in (True, False):
968 sys_msg = get_system_message(with_custom_tools=with_tools)
969 valid_channels = sys_msg.content[0].channel_config.valid_channels
970 for channel in ("analysis", "commentary", "final"):
971 assert channel in valid_channels, (
972 f"{channel} missing when with_custom_tools={with_tools}"
973 )
974
975 def test_unsupported_reasoning_effort_raises_clear_error(self) -> None:
976 with pytest.raises(
977 VLLMValidationError,
978 match="reasoning_effort='max' is not supported by Harmony",
979 ) as exc_info:
980 get_system_message(reasoning_effort="max")
981 assert exc_info.value.parameter == "reasoning_effort"
982
983
984 class TestResponseInputToHarmonyReasoningItem:
985 """Tests for response_input_to_harmony handling of reasoning input items.
986
987 Per the OpenAI spec, ResponseReasoningItem.content is
988 Optional[List[Content]] = None. Clients like langchain-openai may omit
989 this field when constructing multi-turn input from previous responses.
990
991 Reasoning items with content are converted to Harmony messages on the
992 'analysis' channel. All content items are concatenated. Items without
993 content return None (skipped by the caller).
994 """
995
996 def test_reasoning_with_single_content(self):
997 """Test reasoning item with a single content entry."""
998 item = {
999 "type": "reasoning",
1000 "id": "rs_123",
1001 "content": [{"type": "reasoning_text", "text": "Thinking step by step"}],
1002 }
1003
1004 msg = response_input_to_harmony(item, prev_responses=[])
1005
1006 assert msg is not None
1007 assert msg.author.role == Role.ASSISTANT
1008 assert msg.content[0].text == "Thinking step by step"
1009 assert msg.channel == "analysis"
1010
1011 def test_reasoning_with_multiple_content_items(self):
1012 """Test reasoning item with multiple content entries concatenated."""
1013 item = {
1014 "type": "reasoning",
1015 "id": "rs_123",
1016 "content": [
1017 {"type": "reasoning_text", "text": "First, let me analyze"},
1018 {"type": "reasoning_text", "text": "Second, I should consider"},
1019 {"type": "reasoning_text", "text": "Finally, the answer is"},
1020 ],
1021 }
1022
1023 msg = response_input_to_harmony(item, prev_responses=[])
1024
1025 assert msg is not None
1026 assert msg.author.role == Role.ASSISTANT
1027 assert msg.content[0].text == (
1028 "First, let me analyze\nSecond, I should consider\nFinally, the answer is"
1029 )
1030 assert msg.channel == "analysis"
1031
1032 def test_reasoning_without_content_returns_none(self):
1033 """Test reasoning item without content field returns None."""
1034 item = {
1035 "type": "reasoning",
1036 "id": "rs_123",
1037 "summary": [{"type": "summary_text", "text": "Thinking about math"}],
1038 }
1039
1040 msg = response_input_to_harmony(item, prev_responses=[])
1041
1042 assert msg is None
1043
1044 def test_reasoning_with_none_content_returns_none(self):
1045 """Test reasoning item with content=None returns None."""
1046 item = {
1047 "type": "reasoning",
1048 "id": "rs_123",
1049 "content": None,
1050 "summary": [{"type": "summary_text", "text": "Thinking about math"}],
1051 }
1052
1053 msg = response_input_to_harmony(item, prev_responses=[])
1054
1055 assert msg is None
1056
1057 def test_reasoning_with_empty_content_returns_none(self):
1058 """Test reasoning item with empty content list returns None."""
1059 item = {
1060 "type": "reasoning",
1061 "id": "rs_123",
1062 "content": [],
1063 }
1064
1065 msg = response_input_to_harmony(item, prev_responses=[])
1066
1067 assert msg is None