main
py 289 lines 10.3 KB
Raw
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 DeveloperContent, Role
16
17 from vllm.entrypoints.openai.responses.harmony import response_input_to_harmony
18 from vllm.exceptions import VLLMValidationError
19
20 # ---------------------------------------------------------------------------
21 # Shared fixtures
22 # ---------------------------------------------------------------------------
23
24 _PREV_CALL = ResponseFunctionToolCall(
25 id="fc_test",
26 call_id="call_test",
27 name="get_weather",
28 arguments='{"location": "Paris"}',
29 type="function_call",
30 )
31
32 _REASONING_ITEM = ResponseReasoningItem(
33 id="rs_test",
34 type="reasoning",
35 content=[ReasoningTextContent(type="reasoning_text", text="Thinking hard.")],
36 summary=[],
37 status=None,
38 )
39
40
41 class TestResponseInputToHarmonyMessage:
42 """Unit tests for every message type handled by response_input_to_harmony."""
43
44 # -----------------------------------------------------------------------
45 # type="message" (or no type key)
46 # -----------------------------------------------------------------------
47
48 def test_user_message_string_content(self):
49 msg = response_input_to_harmony(
50 {"type": "message", "role": "user", "content": "Hello"},
51 prev_responses=[],
52 )
53
54 assert msg.author.role == Role.USER
55 assert msg.content[0].text == "Hello"
56 assert msg.channel is None
57
58 def test_no_type_key_defaults_to_message_branch(self):
59 """Omitting 'type' should fall through to the message branch."""
60 msg = response_input_to_harmony(
61 {"role": "user", "content": "Hello"},
62 prev_responses=[],
63 )
64
65 assert msg.author.role == Role.USER
66 assert msg.content[0].text == "Hello"
67
68 def test_system_message(self):
69 """System messages carry developer instructions and must be rendered
70 as developer messages with DeveloperContent."""
71 msg = response_input_to_harmony(
72 {"type": "message", "role": "system", "content": "Be helpful."},
73 prev_responses=[],
74 )
75
76 assert msg.author.role == Role.DEVELOPER
77 assert isinstance(msg.content[0], DeveloperContent)
78 assert msg.content[0].instructions == "Be helpful."
79
80 def test_assistant_message_gets_final_channel(self):
81 msg = response_input_to_harmony(
82 {"type": "message", "role": "assistant", "content": "The answer is 42."},
83 prev_responses=[],
84 )
85
86 assert msg.author.role == Role.ASSISTANT
87 assert msg.channel == "final"
88 assert msg.content[0].text == "The answer is 42."
89
90 def test_developer_message_gets_instructions_prefix(self):
91 """Developer messages must use DeveloperContent which adds the
92 '# Instructions' header the model was trained on."""
93 msg = response_input_to_harmony(
94 {"type": "message", "role": "developer", "content": "Be concise."},
95 prev_responses=[],
96 )
97
98 assert msg.author.role == Role.DEVELOPER
99 assert isinstance(msg.content[0], DeveloperContent)
100 assert msg.content[0].instructions == "Be concise."
101
102 def test_message_with_array_content(self):
103 msg = response_input_to_harmony(
104 {
105 "type": "message",
106 "role": "user",
107 "content": [
108 {"type": "text", "text": "Part one. "},
109 {"type": "text", "text": "Part two."},
110 ],
111 },
112 prev_responses=[],
113 )
114
115 assert msg.author.role == Role.USER
116 assert len(msg.content) == 2
117 assert msg.content[0].text == "Part one. "
118 assert msg.content[1].text == "Part two."
119
120 def test_developer_message_array_content_concatenated(self):
121 """Array content in developer messages is flattened and rendered
122 via DeveloperContent with the '# Instructions' header."""
123 msg = response_input_to_harmony(
124 {
125 "type": "message",
126 "role": "developer",
127 "content": [
128 {"type": "text", "text": "Rule 1."},
129 {"type": "text", "text": "Rule 2."},
130 ],
131 },
132 prev_responses=[],
133 )
134
135 assert msg.author.role == Role.DEVELOPER
136 assert isinstance(msg.content[0], DeveloperContent)
137 assert msg.content[0].instructions == "Rule 1.Rule 2."
138
139 # -----------------------------------------------------------------------
140 # type="reasoning"
141 # -----------------------------------------------------------------------
142
143 def test_reasoning_gets_analysis_channel(self):
144 msg = response_input_to_harmony(
145 {
146 "type": "reasoning",
147 "content": [
148 {"type": "reasoning_text", "text": "I should call get_weather."}
149 ],
150 },
151 prev_responses=[],
152 )
153
154 assert msg.author.role == Role.ASSISTANT
155 assert msg.channel == "analysis"
156 assert msg.content[0].text == "I should call get_weather."
157
158 def test_reasoning_pydantic_model_input(self):
159 """A Pydantic ResponseReasoningItem should be model_dump()'d before parsing."""
160 msg = response_input_to_harmony(_REASONING_ITEM, prev_responses=[])
161
162 assert msg.author.role == Role.ASSISTANT
163 assert msg.channel == "analysis"
164 assert msg.content[0].text == "Thinking hard."
165
166 # -----------------------------------------------------------------------
167 # type="function_call"
168 # -----------------------------------------------------------------------
169
170 def test_function_call_channel_recipient_and_content_type(self):
171 msg = response_input_to_harmony(
172 {
173 "type": "function_call",
174 "name": "get_weather",
175 "arguments": '{"location": "Paris"}',
176 },
177 prev_responses=[],
178 )
179
180 assert msg.author.role == Role.ASSISTANT
181 assert msg.channel == "commentary"
182 assert msg.recipient == "functions.get_weather"
183 assert msg.content_type == "json"
184 assert msg.content[0].text == '{"location": "Paris"}'
185
186 def test_function_call_empty_arguments(self):
187 msg = response_input_to_harmony(
188 {"type": "function_call", "name": "ping", "arguments": ""},
189 prev_responses=[],
190 )
191
192 assert msg.recipient == "functions.ping"
193 assert msg.content[0].text == ""
194
195 # -----------------------------------------------------------------------
196 # type="function_call_output"
197 # -----------------------------------------------------------------------
198
199 def test_function_call_output_channel_recipient_and_author_name(self):
200 msg = response_input_to_harmony(
201 {"type": "function_call_output", "call_id": "call_test", "output": "18°C"},
202 prev_responses=[_PREV_CALL],
203 )
204
205 assert msg.author.role == Role.TOOL
206 assert msg.author.name == "functions.get_weather"
207 assert msg.channel == "commentary"
208 assert msg.recipient == "assistant"
209 assert msg.content[0].text == "18°C"
210
211 def test_function_call_output_uses_most_recent_matching_call(self):
212 """When multiple prev_responses share a call_id, the last one wins
213 because the search is reversed."""
214 earlier = ResponseFunctionToolCall(
215 id="fc_old",
216 call_id="call_test",
217 name="old_func",
218 arguments="{}",
219 type="function_call",
220 )
221 later = ResponseFunctionToolCall(
222 id="fc_new",
223 call_id="call_test",
224 name="get_weather",
225 arguments="{}",
226 type="function_call",
227 )
228
229 msg = response_input_to_harmony(
230 {
231 "type": "function_call_output",
232 "call_id": "call_test",
233 "output": "result",
234 },
235 prev_responses=[earlier, later],
236 )
237
238 assert msg.author.name == "functions.get_weather"
239
240 def test_function_call_output_skips_non_function_call_items_in_prev_responses(
241 self,
242 ):
243 """ResponseReasoningItem entries in prev_responses should be ignored."""
244 msg = response_input_to_harmony(
245 {
246 "type": "function_call_output",
247 "call_id": "call_test",
248 "output": "18°C",
249 },
250 prev_responses=[_REASONING_ITEM, _PREV_CALL],
251 )
252
253 assert msg.author.name == "functions.get_weather"
254
255 def test_function_call_output_raises_if_no_matching_call(self):
256 with pytest.raises(
257 VLLMValidationError, match="No call message found for"
258 ) as exc_info:
259 response_input_to_harmony(
260 {
261 "type": "function_call_output",
262 "call_id": "no_such_id",
263 "output": "x",
264 },
265 prev_responses=[_PREV_CALL],
266 )
267 assert exc_info.value.parameter == "input"
268
269 def test_function_call_output_raises_on_empty_prev_responses(self):
270 with pytest.raises(
271 VLLMValidationError, match="No call message found for"
272 ) as exc_info:
273 response_input_to_harmony(
274 {"type": "function_call_output", "call_id": "call_test", "output": "x"},
275 prev_responses=[],
276 )
277 assert exc_info.value.parameter == "input"
278
279 # -----------------------------------------------------------------------
280 # Error cases
281 # -----------------------------------------------------------------------
282
283 def test_unknown_type_raises_validation_error(self):
284 with pytest.raises(VLLMValidationError, match="Unknown input type") as exc_info:
285 response_input_to_harmony(
286 {"type": "image_url", "url": "https://example.com/img.png"},
287 prev_responses=[],
288 )
289 assert exc_info.value.parameter == "input"