| 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" |