main
py 1,255 lines 41.1 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Integration tests for the Harmony-based Responses API."""
4
5 from __future__ import annotations
6
7 import importlib.util
8 import json
9 import logging
10 import time
11 from typing import Any
12
13 import pytest
14 import pytest_asyncio
15 import requests
16 from openai import NotFoundError, OpenAI
17 from openai_harmony import Message
18
19 from tests.utils import RemoteOpenAIServer
20
21 from .conftest import (
22 BASE_TEST_ENV,
23 events_contain_type,
24 has_output_type,
25 retry_for_tool_call,
26 retry_streaming_for,
27 validate_streaming_event_stack,
28 )
29
30 logger = logging.getLogger(__name__)
31
32 MODEL_NAME = "openai/gpt-oss-20b"
33
34 GET_WEATHER_SCHEMA = {
35 "type": "function",
36 "name": "get_weather",
37 "description": "Get current temperature for provided coordinates in celsius.", # noqa
38 "parameters": {
39 "type": "object",
40 "properties": {
41 "latitude": {"type": "number"},
42 "longitude": {"type": "number"},
43 },
44 "required": ["latitude", "longitude"],
45 "additionalProperties": False,
46 },
47 "strict": True,
48 }
49
50
51 def get_weather(latitude, longitude):
52 try:
53 response = requests.get(
54 f"https://api.open-meteo.com/v1/forecast?"
55 f"latitude={latitude}&longitude={longitude}"
56 f"&current=temperature_2m,wind_speed_10m"
57 f"&hourly=temperature_2m,relative_humidity_2m,"
58 f"wind_speed_10m",
59 timeout=10,
60 )
61 data = response.json()
62 return data["current"]["temperature_2m"]
63 except (requests.RequestException, KeyError) as e:
64 logger.warning(
65 "External weather API call failed (%s), "
66 "returning fake value. This does not affect "
67 "test correctness — only the tool-calling "
68 "protocol is under test.",
69 e,
70 )
71 return 15.0
72
73
74 def get_place_to_travel():
75 return "Paris"
76
77
78 def get_horoscope(sign):
79 return f"{sign}: Next Tuesday you will befriend a baby otter."
80
81
82 def call_function(name, args):
83 logger.info("Calling function %s with args %s", name, args)
84 dispatch = {
85 "get_weather": lambda: get_weather(**args),
86 "get_place_to_travel": lambda: get_place_to_travel(),
87 "get_horoscope": lambda: get_horoscope(**args),
88 }
89 if name not in dispatch:
90 raise ValueError(f"Unknown function: {name}")
91 result = dispatch[name]()
92 logger.info("Function %s returned: %s", name, result)
93 return result
94
95
96 @pytest.fixture(scope="module")
97 def server():
98 assert importlib.util.find_spec("gpt_oss") is not None, (
99 "Harmony tests require gpt_oss package to be installed"
100 )
101 args = [
102 "--enforce-eager",
103 "--tool-server",
104 "demo",
105 "--max_model_len",
106 "5000",
107 ]
108 env_dict = {
109 **BASE_TEST_ENV,
110 "VLLM_ENABLE_RESPONSES_API_STORE": "1",
111 "PYTHON_EXECUTION_BACKEND": "dangerously_use_uv",
112 "VLLM_GPT_OSS_SYSTEM_TOOL_MCP_LABELS": (
113 "code_interpreter,container,web_search_preview"
114 ),
115 "VLLM_GPT_OSS_HARMONY_SYSTEM_INSTRUCTIONS": "1",
116 }
117 with RemoteOpenAIServer(MODEL_NAME, args, env_dict=env_dict) as remote_server:
118 yield remote_server
119
120
121 @pytest_asyncio.fixture
122 async def client(server):
123 async with server.get_async_client() as async_client:
124 yield async_client
125
126
127 @pytest.mark.asyncio
128 @pytest.mark.parametrize("model_name", [MODEL_NAME])
129 async def test_basic(client: OpenAI, model_name: str):
130 response = await client.responses.create(
131 model=model_name,
132 input="What is 123 * 456?",
133 )
134 assert response is not None
135 print("response: ", response)
136 assert response.status == "completed"
137
138
139 @pytest.mark.asyncio
140 @pytest.mark.parametrize("model_name", [MODEL_NAME])
141 async def test_basic_with_instructions(client: OpenAI, model_name: str):
142 response = await client.responses.create(
143 model=model_name,
144 input="What is 123 * 456?",
145 instructions="Respond in Korean.",
146 )
147 assert response is not None
148 assert response.status == "completed"
149
150
151 @pytest.mark.asyncio
152 @pytest.mark.parametrize("model_name", [MODEL_NAME])
153 async def test_basic_with_reasoning_effort(client: OpenAI, model_name: str):
154 response = await client.responses.create(
155 model=model_name,
156 input="What is the capital of South Korea?",
157 reasoning={"effort": "low"},
158 )
159 assert response is not None
160 assert response.status == "completed"
161
162
163 @pytest.mark.asyncio
164 @pytest.mark.parametrize("model_name", [MODEL_NAME])
165 async def test_max_tokens(client: OpenAI, model_name: str):
166 response = await client.responses.create(
167 model=model_name,
168 input="What is the first paragraph of Moby Dick?",
169 reasoning={"effort": "low"},
170 max_output_tokens=30,
171 )
172 assert response is not None
173 assert response.status == "incomplete"
174 assert response.incomplete_details.reason == "max_output_tokens"
175
176
177 @pytest.mark.asyncio
178 @pytest.mark.parametrize("model_name", [MODEL_NAME])
179 async def test_chat(client: OpenAI, model_name: str):
180 response = await client.responses.create(
181 model=model_name,
182 input=[
183 {"role": "system", "content": "Respond in Korean."},
184 {"role": "user", "content": "Hello!"},
185 {"role": "assistant", "content": "Hello! How can I help you today?"},
186 {"role": "user", "content": "What is 123 * 456? Explain your answer."},
187 ],
188 )
189 assert response is not None
190 assert response.status == "completed"
191
192
193 @pytest.mark.asyncio
194 @pytest.mark.parametrize("model_name", [MODEL_NAME])
195 async def test_chat_with_input_type(client: OpenAI, model_name: str):
196 response = await client.responses.create(
197 model=model_name,
198 input=[
199 {
200 "role": "user",
201 "content": [{"type": "input_text", "text": "What is 123 * 456?"}],
202 },
203 ],
204 )
205 assert response is not None
206 assert response.status == "completed"
207
208
209 @pytest.mark.asyncio
210 @pytest.mark.parametrize("model_name", [MODEL_NAME])
211 async def test_structured_output(client: OpenAI, model_name: str):
212 response = await client.responses.create(
213 model=model_name,
214 input=[
215 {"role": "system", "content": "Extract the event information."},
216 {
217 "role": "user",
218 "content": "Alice and Bob are going to a science fair on Friday.",
219 },
220 ],
221 text={
222 "format": {
223 "type": "json_schema",
224 "name": "calendar_event",
225 "schema": {
226 "type": "object",
227 "properties": {
228 "name": {"type": "string"},
229 "date": {"type": "string"},
230 "participants": {
231 "type": "array",
232 "items": {"type": "string"},
233 },
234 },
235 "required": ["name", "date", "participants"],
236 "additionalProperties": False,
237 },
238 "description": "A calendar event.",
239 "strict": True,
240 }
241 },
242 )
243 assert response is not None
244 assert response.status == "completed"
245
246
247 @pytest.mark.asyncio
248 @pytest.mark.parametrize("model_name", [MODEL_NAME])
249 async def test_structured_output_with_parse(client: OpenAI, model_name: str):
250 from pydantic import BaseModel
251
252 class CalendarEvent(BaseModel):
253 name: str
254 date: str
255 participants: list[str]
256
257 response = await client.responses.parse(
258 model=model_name,
259 input="Alice and Bob are going to a science fair on Friday",
260 instructions="Extract the event information",
261 text_format=CalendarEvent,
262 )
263 assert response is not None
264 assert response.status == "completed"
265
266
267 @pytest.mark.asyncio
268 @pytest.mark.parametrize("model_name", [MODEL_NAME])
269 async def test_store(client: OpenAI, model_name: str):
270 for store in [True, False]:
271 response = await client.responses.create(
272 model=model_name,
273 input="What is 123 * 456?",
274 store=store,
275 )
276 assert response is not None
277
278 try:
279 _retrieved_response = await client.responses.retrieve(response.id)
280 is_not_found = False
281 except NotFoundError:
282 is_not_found = True
283
284 assert is_not_found == (not store), (
285 f"store={store}: expected not_found={not store}, got {is_not_found}"
286 )
287
288
289 @pytest.mark.asyncio
290 @pytest.mark.parametrize("model_name", [MODEL_NAME])
291 async def test_background(client: OpenAI, model_name: str):
292 response = await client.responses.create(
293 model=model_name,
294 input="What is 123 * 456?",
295 background=True,
296 )
297 assert response is not None
298
299 retries = 0
300 max_retries = 30
301 while retries < max_retries:
302 response = await client.responses.retrieve(response.id)
303 if response.status == "completed":
304 break
305 time.sleep(1)
306 retries += 1
307
308 assert response.status == "completed"
309
310
311 @pytest.mark.asyncio
312 @pytest.mark.parametrize("model_name", [MODEL_NAME])
313 async def test_background_cancel(client: OpenAI, model_name: str):
314 response = await client.responses.create(
315 model=model_name,
316 input="Write a long story about a cat.",
317 background=True,
318 )
319 assert response is not None
320 time.sleep(1)
321
322 cancelled_response = await client.responses.cancel(response.id)
323 assert cancelled_response is not None
324
325
326 @pytest.mark.asyncio
327 @pytest.mark.parametrize("model_name", [MODEL_NAME])
328 async def test_stateful_multi_turn(client: OpenAI, model_name: str):
329 response1 = await client.responses.create(
330 model=model_name, input="What is 123 * 456?"
331 )
332 assert response1.status == "completed"
333
334 response2 = await client.responses.create(
335 model=model_name,
336 input="What if I increase both numbers by 1?",
337 previous_response_id=response1.id,
338 )
339 assert response2.status == "completed"
340
341 response3 = await client.responses.create(
342 model=model_name,
343 input="Divide the result by 2.",
344 previous_response_id=response2.id,
345 )
346 assert response3.status == "completed"
347
348
349 @pytest.mark.asyncio
350 @pytest.mark.parametrize("model_name", [MODEL_NAME])
351 async def test_streaming_types(
352 pairs_of_event_types: dict[str, str], client: OpenAI, model_name: str
353 ):
354 stream = await client.responses.create(
355 model=model_name,
356 input="tell me a story about a cat in 20 words",
357 reasoning={"effort": "low"},
358 tools=[],
359 stream=True,
360 background=False,
361 )
362 events = []
363 async for event in stream:
364 events.append(event)
365
366 validate_streaming_event_stack(events, pairs_of_event_types)
367
368
369 @pytest.mark.asyncio
370 @pytest.mark.parametrize("model_name", [MODEL_NAME])
371 @pytest.mark.parametrize("tool_choice", ["auto", "required"])
372 async def test_function_calling_with_streaming_types(
373 pairs_of_event_types: dict[str, str],
374 client: OpenAI,
375 model_name: str,
376 tool_choice: str,
377 ):
378 """Streaming event nesting for function-calling responses."""
379
380 def _has_function_events(evts: list) -> bool:
381 return events_contain_type(evts, "function_call_arguments")
382
383 events = await retry_streaming_for(
384 client,
385 model=model_name,
386 validate_events=_has_function_events,
387 input=[{"role": "user", "content": "What's the weather like in Paris today?"}],
388 tools=[GET_WEATHER_SCHEMA],
389 tool_choice=tool_choice,
390 temperature=0.0,
391 )
392
393 validate_streaming_event_stack(events, pairs_of_event_types)
394
395
396 @pytest.mark.asyncio
397 @pytest.mark.parametrize("model_name", [MODEL_NAME])
398 @pytest.mark.parametrize("background", [True, False])
399 async def test_streaming(client: OpenAI, model_name: str, background: bool):
400 # TODO: Add back when web search and code interpreter are available in CI
401 prompts = [
402 "tell me a story about a cat in 20 words",
403 "What is 123 * 456? Use python to calculate the result.",
404 # "When did Jensen found NVIDIA? Search it and answer the year only.",
405 ]
406
407 for prompt in prompts:
408 stream = await client.responses.create(
409 model=model_name,
410 input=prompt,
411 reasoning={"effort": "low"},
412 tools=[
413 # {
414 # "type": "web_search_preview"
415 # },
416 {"type": "code_interpreter", "container": {"type": "auto"}},
417 ],
418 stream=True,
419 background=background,
420 extra_body={"enable_response_messages": True},
421 )
422
423 current_item_id = ""
424 current_content_index = -1
425
426 events = []
427 current_event_mode = None
428 resp_id = None
429 checked_response_completed = False
430
431 async for event in stream:
432 if event.type == "response.created":
433 resp_id = event.response.id
434
435 # Validate custom fields on response-level events
436 if event.type in [
437 "response.completed",
438 "response.in_progress",
439 "response.created",
440 ]:
441 assert "input_messages" in event.response.model_extra
442 assert "output_messages" in event.response.model_extra
443 if event.type == "response.completed":
444 # make sure the serialization of content works
445 for msg in event.response.model_extra["output_messages"]:
446 # make sure we can convert the messages back into harmony
447 Message.from_dict(msg)
448
449 for msg in event.response.model_extra["input_messages"]:
450 # make sure we can convert the messages back into harmony
451 Message.from_dict(msg)
452 checked_response_completed = True
453
454 if current_event_mode != event.type:
455 current_event_mode = event.type
456 logger.debug("[%s] ", event.type)
457
458 # Verify item IDs
459 if event.type == "response.output_item.added":
460 assert event.item.id != current_item_id
461 current_item_id = event.item.id
462 current_content_index = -1
463 elif event.type in [
464 "response.output_text.delta",
465 "response.reasoning_text.delta",
466 ]:
467 assert event.item_id == current_item_id
468
469 # Verify content indices
470 if event.type == "response.content_part.added":
471 assert event.content_index == current_content_index + 1
472 current_content_index = event.content_index
473 elif event.type in [
474 "response.output_text.delta",
475 "response.reasoning_text.delta",
476 ]:
477 assert event.content_index == current_content_index
478
479 events.append(event)
480
481 assert len(events) > 0
482 assert events[-1].response.output, "Final response should have output"
483 assert checked_response_completed
484
485 if background:
486 starting_after = 5
487 async with await client.responses.retrieve(
488 response_id=resp_id, stream=True, starting_after=starting_after
489 ) as replay_stream:
490 counter = starting_after
491 async for event in replay_stream:
492 counter += 1
493 assert event == events[counter]
494 assert counter == len(events) - 1
495
496
497 @pytest.mark.asyncio
498 @pytest.mark.parametrize("model_name", [MODEL_NAME])
499 @pytest.mark.skip(reason="Web search tool is not available in CI yet.")
500 async def test_web_search(client: OpenAI, model_name: str):
501 response = await client.responses.create(
502 model=model_name,
503 input="Who is the president of South Korea as of now?",
504 tools=[{"type": "web_search_preview"}],
505 )
506 assert response is not None
507 assert response.status == "completed"
508
509
510 @pytest.mark.asyncio
511 @pytest.mark.parametrize("model_name", [MODEL_NAME])
512 async def test_code_interpreter(client: OpenAI, model_name: str):
513 timeout_value = client.timeout * 3
514 client_with_timeout = client.with_options(timeout=timeout_value)
515
516 response = await client_with_timeout.responses.create(
517 model=model_name,
518 input=(
519 "What's the first 4 digits after the decimal point of "
520 "cube root of `19910212 * 20250910`? "
521 "Show only the digits. The python interpreter is not stateful "
522 "and you must print to see the output."
523 ),
524 tools=[{"type": "code_interpreter", "container": {"type": "auto"}}],
525 temperature=0.0,
526 )
527 assert response is not None
528 assert response.status == "completed"
529 assert response.usage.output_tokens_details.tool_output_tokens > 0
530
531 for item in response.output:
532 if item.type == "message":
533 output_string = item.content[0].text
534 assert "5846" in output_string, (
535 f"Expected '5846' in output, got: {output_string}"
536 )
537
538
539 @pytest.mark.asyncio
540 @pytest.mark.parametrize("model_name", [MODEL_NAME])
541 async def test_reasoning_item(client: OpenAI, model_name: str):
542 response = await client.responses.create(
543 model=model_name,
544 input=[
545 {"type": "message", "content": "Hello.", "role": "user"},
546 {
547 "type": "reasoning",
548 "id": "lol",
549 "content": [
550 {"type": "reasoning_text", "text": "We need to respond: greeting."}
551 ],
552 "summary": [],
553 },
554 ],
555 temperature=0.0,
556 )
557 assert response is not None
558 assert response.status == "completed"
559
560
561 @pytest.mark.asyncio
562 @pytest.mark.parametrize("model_name", [MODEL_NAME])
563 @pytest.mark.parametrize("tool_choice", ["auto", "required"])
564 async def test_function_calling(client: OpenAI, model_name: str, tool_choice: str):
565 tools = [GET_WEATHER_SCHEMA]
566
567 response = await retry_for_tool_call(
568 client,
569 model=model_name,
570 expected_tool_type="function_call",
571 input="What's the weather like in Paris today?",
572 tools=tools,
573 tool_choice=tool_choice,
574 temperature=0.0,
575 extra_body={"request_id": f"test_function_calling_non_resp_{tool_choice}"},
576 )
577 assert response.status == "completed"
578 assert has_output_type(response, "function_call"), (
579 f"Expected function_call in output, got: "
580 f"{[getattr(o, 'type', None) for o in response.output]}"
581 )
582
583 tool_call = next(o for o in response.output if o.type == "function_call")
584 args = json.loads(tool_call.arguments)
585 result = call_function(tool_call.name, args)
586
587 response_2 = await client.responses.create(
588 model=model_name,
589 input=[
590 {
591 "type": "function_call_output",
592 "call_id": tool_call.call_id,
593 "output": str(result),
594 }
595 ],
596 tools=tools,
597 previous_response_id=response.id,
598 temperature=0.0,
599 )
600 assert response_2.status == "completed"
601 assert response_2.output_text is not None
602
603 # NOTE: chain-of-thought should be removed.
604 response_3 = await client.responses.create(
605 model=model_name,
606 input="What's the weather like in Paris today?",
607 tools=tools,
608 previous_response_id=response_2.id,
609 temperature=0.0,
610 )
611 assert response_3.status == "completed"
612 assert response_3.output_text is not None
613
614
615 @pytest.mark.asyncio
616 @pytest.mark.parametrize("model_name", [MODEL_NAME])
617 @pytest.mark.parametrize("tool_choice", ["auto", "required"])
618 async def test_function_calling_multi_turn(
619 client: OpenAI, model_name: str, tool_choice: str
620 ):
621 """Multi-tool, multi-turn function calling with retry at API level."""
622 tools = [
623 {
624 "type": "function",
625 "name": "get_place_to_travel",
626 "description": "Get a random place to travel",
627 "parameters": {
628 "type": "object",
629 "properties": {},
630 "required": [],
631 "additionalProperties": False,
632 },
633 "strict": True,
634 },
635 GET_WEATHER_SCHEMA,
636 ]
637
638 # Turn 1: model should call one of the tools
639 response = await retry_for_tool_call(
640 client,
641 model=model_name,
642 expected_tool_type="function_call",
643 input="Help me plan a trip to a random place. And tell me the weather there.",
644 tools=tools,
645 tool_choice=tool_choice,
646 temperature=0.0,
647 )
648 assert response.status == "completed"
649 assert has_output_type(response, "function_call"), (
650 f"Turn 1: expected function_call, got: "
651 f"{[getattr(o, 'type', None) for o in response.output]}"
652 )
653
654 tool_call = next(o for o in response.output if o.type == "function_call")
655 result = call_function(tool_call.name, json.loads(tool_call.arguments))
656
657 # Turn 2
658 response_2 = await retry_for_tool_call(
659 client,
660 model=model_name,
661 expected_tool_type="function_call",
662 input=[
663 {
664 "type": "function_call_output",
665 "call_id": tool_call.call_id,
666 "output": str(result),
667 }
668 ],
669 tools=tools,
670 tool_choice=tool_choice,
671 previous_response_id=response.id,
672 temperature=0.0,
673 )
674 assert response_2.status == "completed"
675
676 # If model produced another tool call, execute it
677 if has_output_type(response_2, "function_call"):
678 tool_call_2 = next(o for o in response_2.output if o.type == "function_call")
679 result_2 = call_function(tool_call_2.name, json.loads(tool_call_2.arguments))
680 response_3 = await client.responses.create(
681 model=model_name,
682 input=[
683 {
684 "type": "function_call_output",
685 "call_id": tool_call_2.call_id,
686 "output": str(result_2),
687 }
688 ],
689 tools=tools,
690 previous_response_id=response_2.id,
691 temperature=0.0,
692 )
693 assert response_3.status == "completed"
694 assert response_3.output_text is not None
695 else:
696 # Model went straight to answering - acceptable but unexpected.
697 # Log as warning so it shows up in CI without failing the test.
698 assert response_2.output_text is not None
699 pytest.xfail(
700 "Model went straight to answering instead of calling a "
701 "second tool. Valid behaviour but not the expected path."
702 "If this happens consistently, the prompt or model may have "
703 "changed behaviour."
704 )
705
706
707 @pytest.mark.asyncio
708 @pytest.mark.parametrize("model_name", [MODEL_NAME])
709 async def test_system_message_with_tools(client: OpenAI, model_name: str):
710 from vllm.entrypoints.openai.parser.harmony_utils import get_system_message
711
712 # Commentary channel should always be present (needed for preambles)
713 # regardless of whether custom tools are enabled
714 for with_tools in (True, False):
715 sys_msg = get_system_message(with_custom_tools=with_tools)
716 valid_channels = sys_msg.content[0].channel_config.valid_channels
717 assert "commentary" in valid_channels, (
718 f"commentary channel missing when with_custom_tools={with_tools}"
719 )
720
721
722 @pytest.mark.asyncio
723 @pytest.mark.parametrize("model_name", [MODEL_NAME])
724 @pytest.mark.parametrize("tool_choice", ["auto", "required"])
725 async def test_function_calling_full_history(
726 client: OpenAI, model_name: str, tool_choice: str
727 ):
728 tools = [GET_WEATHER_SCHEMA]
729
730 input_messages = [
731 {"role": "user", "content": "What's the weather like in Paris today?"}
732 ]
733
734 response = await retry_for_tool_call(
735 client,
736 model=model_name,
737 expected_tool_type="function_call",
738 input=input_messages,
739 tools=tools,
740 tool_choice=tool_choice,
741 temperature=0.0,
742 )
743 assert response.status == "completed"
744
745 tool_call = next((o for o in response.output if o.type == "function_call"), None)
746 assert tool_call is not None, (
747 f"Expected function_call in output, got: "
748 f"{[getattr(o, 'type', None) for o in response.output]}"
749 )
750
751 result = call_function(tool_call.name, json.loads(tool_call.arguments))
752
753 input_messages.extend(response.output)
754 input_messages.append(
755 { # append result message
756 "type": "function_call_output",
757 "call_id": tool_call.call_id,
758 "output": str(result),
759 }
760 )
761
762 response_2 = await client.responses.create(
763 model=model_name,
764 input=input_messages,
765 tools=tools,
766 temperature=0.0,
767 )
768 assert response_2.status == "completed"
769 assert response_2.output_text is not None
770
771
772 @pytest.mark.asyncio
773 @pytest.mark.parametrize("model_name", [MODEL_NAME])
774 @pytest.mark.parametrize("tool_choice", ["auto", "required"])
775 async def test_function_calling_with_stream(
776 client: OpenAI, model_name: str, tool_choice: str
777 ):
778 """Function calling via streaming, with retry for non-determinism."""
779 tools = [GET_WEATHER_SCHEMA]
780 input_list = [
781 {"role": "user", "content": "What's the weather like in Paris today?"},
782 ]
783
784 def _has_function_call(evts: list) -> bool:
785 return any(
786 getattr(e, "type", "") == "response.output_item.added"
787 and getattr(getattr(e, "item", None), "type", None) == "function_call"
788 for e in evts
789 )
790
791 events = await retry_streaming_for(
792 client,
793 model=model_name,
794 validate_events=_has_function_call,
795 input=input_list,
796 tools=tools,
797 tool_choice=tool_choice,
798 temperature=0.0,
799 )
800
801 # Parse tool calls from events
802 final_tool_calls: dict[int, Any] = {}
803 for event in events:
804 if event.type == "response.output_item.added":
805 if getattr(event.item, "type", None) == "function_call":
806 final_tool_calls[event.output_index] = event.item
807 elif event.type == "response.function_call_arguments.delta":
808 tc = final_tool_calls.get(event.output_index)
809 if tc:
810 tc.arguments += event.delta
811 elif event.type == "response.function_call_arguments.done":
812 tc = final_tool_calls.get(event.output_index)
813 if tc:
814 assert event.arguments == tc.arguments
815
816 # Find get_weather call
817 tool_call = None
818 result = None
819 for tc in final_tool_calls.values():
820 if getattr(tc, "type", None) == "function_call" and tc.name == "get_weather":
821 args = json.loads(tc.arguments)
822 result = call_function(tc.name, args)
823 tool_call = tc
824 input_list.append(tc)
825 break
826
827 assert tool_call is not None, (
828 "Expected model to call 'get_weather', "
829 f"but got: {[getattr(tc, 'name', None) for tc in final_tool_calls.values()]}"
830 )
831
832 # Second turn with the tool result
833 response = await client.responses.create(
834 model=model_name,
835 input=input_list
836 + [
837 {
838 "type": "function_call_output",
839 "call_id": tool_call.call_id,
840 "output": str(result),
841 }
842 ],
843 tools=tools,
844 stream=True,
845 temperature=0.0,
846 )
847 async for event in response:
848 # check that no function call events in the stream
849 assert event.type != "response.function_call_arguments.delta"
850 assert event.type != "response.function_call_arguments.done"
851 # check that the response contains output text
852 if event.type == "response.completed":
853 assert len(event.response.output) > 0
854 assert event.response.output_text is not None
855
856
857 @pytest.mark.asyncio
858 @pytest.mark.parametrize("model_name", [MODEL_NAME])
859 @pytest.mark.parametrize("tool_choice", ["auto", "required"])
860 async def test_function_calling_no_code_interpreter_events(
861 client: OpenAI, model_name: str, tool_choice: str
862 ):
863 """Verify that function calls don't trigger code_interpreter events.
864
865 Uses retry_streaming_for to handle non-determinism: the model might not
866 always produce a function_call, but if it does, code_interpreter events
867 should NEVER appear.
868 """
869 tools = [GET_WEATHER_SCHEMA]
870 input_list = [
871 {"role": "user", "content": "What's the weather like in Paris today?"},
872 ]
873
874 def _has_function_call(evts: list) -> bool:
875 return any(
876 getattr(e, "type", "") == "response.output_item.added"
877 and getattr(getattr(e, "item", None), "type", None) == "function_call"
878 for e in evts
879 )
880
881 events = await retry_streaming_for(
882 client,
883 model=model_name,
884 validate_events=_has_function_call,
885 input=input_list,
886 tools=tools,
887 tool_choice=tool_choice,
888 temperature=0.0,
889 )
890
891 event_types_seen = {e.type for e in events}
892 function_call_found = _has_function_call(events)
893
894 assert function_call_found, (
895 f"Expected to see a function_call after retries. "
896 f"Event types: {sorted(event_types_seen)}"
897 )
898
899 # The actual invariant under test
900 for event in events:
901 assert "code_interpreter" not in event.type, (
902 f"Found code_interpreter event '{event.type}' during function call. "
903 "Function calls should only emit function_call events."
904 )
905
906 # Verify we saw the correct function call event types
907 assert (
908 "response.function_call_arguments.delta" in event_types_seen
909 or "response.function_call_arguments.done" in event_types_seen
910 ), "Expected to see function_call_arguments events"
911
912
913 @pytest.mark.asyncio
914 @pytest.mark.parametrize("model_name", [MODEL_NAME])
915 @pytest.mark.skip(
916 reason="This test is flaky in CI, needs investigation and "
917 "potential fixes in the code interpreter MCP implementation."
918 )
919 async def test_code_interpreter_streaming(
920 client: OpenAI,
921 model_name: str,
922 pairs_of_event_types: dict[str, str],
923 ):
924 tools = [{"type": "code_interpreter", "container": {"type": "auto"}}]
925 input_text = (
926 "Calculate 123 * 456 using python. "
927 "The python interpreter is not stateful and you must "
928 "print to see the output."
929 )
930
931 def _has_code_interpreter(evts: list) -> bool:
932 return events_contain_type(evts, "code_interpreter")
933
934 events = await retry_streaming_for(
935 client,
936 model=model_name,
937 validate_events=_has_code_interpreter,
938 input=input_text,
939 tools=tools,
940 temperature=0.0,
941 instructions=(
942 "You must use the Python tool to execute code. Never simulate execution."
943 ),
944 )
945
946 event_types = [e.type for e in events]
947 event_types_set = set(event_types)
948 logger.info(
949 "\n====== Code Interpreter Streaming Diagnostics ======\n"
950 "Event count: %d\n"
951 "Event types (in order): %s\n"
952 "Unique event types: %s\n"
953 "====================================================",
954 len(events),
955 event_types,
956 sorted(event_types_set),
957 )
958
959 # Structural validation (pairing, ordering, field consistency)
960 validate_streaming_event_stack(events, pairs_of_event_types)
961
962 # Validate code interpreter item fields
963 for event in events:
964 if (
965 event.type == "response.output_item.added"
966 and hasattr(event.item, "type")
967 and event.item.type == "code_interpreter_call"
968 ):
969 assert event.item.status == "in_progress"
970 elif event.type == "response.code_interpreter_call_code.done":
971 assert event.code is not None
972 elif (
973 event.type == "response.output_item.done"
974 and hasattr(event.item, "type")
975 and event.item.type == "code_interpreter_call"
976 ):
977 assert event.item.status == "completed"
978 assert event.item.code is not None
979
980
981 @pytest.mark.asyncio
982 @pytest.mark.parametrize("model_name", [MODEL_NAME])
983 async def test_mcp_tool_multi_turn(client: OpenAI, model_name: str, server):
984 """MCP tools work across multiple turns via previous_response_id."""
985 tools = [{"type": "mcp", "server_label": "code_interpreter"}]
986 instructions = (
987 "You must use the Python tool to execute code. Never simulate execution."
988 )
989
990 # First turn
991 response1 = await retry_for_tool_call(
992 client,
993 model=model_name,
994 expected_tool_type="mcp_call",
995 input="Calculate 1234 * 4567 using python tool and print the result.",
996 tools=tools,
997 temperature=0.0,
998 instructions=instructions,
999 extra_body={"enable_response_messages": True},
1000 )
1001 assert response1.status == "completed"
1002
1003 # Verify MCP call in output_messages
1004 tool_call_found = any(
1005 (msg.get("recipient") or "").startswith("python")
1006 for msg in response1.output_messages
1007 )
1008 parsed_output_messages = [
1009 Message.from_dict(msg) for msg in response1.output_messages
1010 ]
1011 tool_response_found = any(
1012 (msg.author.role == "tool" and (msg.author.name or "").startswith("python"))
1013 for msg in parsed_output_messages
1014 )
1015 assert tool_call_found, "MCP tool call not found in output_messages"
1016 assert tool_response_found, "MCP tool response not found in output_messages"
1017
1018 # No developer messages expected for elevated tools
1019 developer_msgs = [
1020 msg
1021 for msg in (Message.from_dict(raw) for raw in response1.input_messages)
1022 if msg.author.role == "developer"
1023 ]
1024 assert len(developer_msgs) == 0, "No developer message expected for elevated tools"
1025
1026 # Second turn
1027 response2 = await client.responses.create(
1028 model=model_name,
1029 input="Now divide that result by 2.",
1030 tools=tools,
1031 temperature=0.0,
1032 instructions=instructions,
1033 previous_response_id=response1.id,
1034 extra_body={"enable_response_messages": True},
1035 )
1036 assert response2.status == "completed"
1037
1038
1039 @pytest.mark.asyncio
1040 @pytest.mark.parametrize("model_name", [MODEL_NAME])
1041 async def test_output_messages_enabled(client: OpenAI, model_name: str, server):
1042 response = await client.responses.create(
1043 model=model_name,
1044 input="What is the capital of South Korea?",
1045 extra_body={"enable_response_messages": True},
1046 )
1047
1048 assert response is not None
1049 assert response.status == "completed"
1050 assert len(response.input_messages) > 0
1051 assert len(response.output_messages) > 0
1052
1053
1054 @pytest.mark.asyncio
1055 @pytest.mark.parametrize("model_name", [MODEL_NAME])
1056 @pytest.mark.parametrize("tool_choice", ["auto", "required"])
1057 async def test_function_call_with_previous_input_messages(
1058 client: OpenAI, model_name: str, tool_choice: str
1059 ):
1060 """Multi-turn function calling using previous_input_messages."""
1061 tools = [
1062 {
1063 "type": "function",
1064 "name": "get_horoscope",
1065 "description": "Get today's horoscope for an astrological sign.",
1066 "parameters": {
1067 "type": "object",
1068 "properties": {"sign": {"type": "string"}},
1069 "required": ["sign"],
1070 "additionalProperties": False,
1071 },
1072 "strict": True,
1073 }
1074 ]
1075
1076 # Step 1: Get a function call from the model
1077 response = await retry_for_tool_call(
1078 client,
1079 model=model_name,
1080 expected_tool_type="function_call",
1081 input="What is the horoscope for Aquarius today?",
1082 tools=tools,
1083 tool_choice=tool_choice,
1084 temperature=0.0,
1085 extra_body={"enable_response_messages": True},
1086 max_output_tokens=1000,
1087 )
1088 assert response.status == "completed"
1089
1090 function_call = next(
1091 (item for item in response.output if item.type == "function_call"),
1092 None,
1093 )
1094 assert function_call is not None, (
1095 f"Expected function_call, got: "
1096 f"{[getattr(o, 'type', None) for o in response.output]}"
1097 )
1098 assert function_call.name == "get_horoscope"
1099
1100 args = json.loads(function_call.arguments)
1101 result = call_function(function_call.name, args)
1102
1103 # Step 2: Build full conversation history
1104 previous_messages = (
1105 response.input_messages
1106 + response.output_messages
1107 + [
1108 {
1109 "role": "tool",
1110 "name": "functions.get_horoscope",
1111 "content": [{"type": "text", "text": str(result)}],
1112 }
1113 ]
1114 )
1115
1116 # Step 3: Second call with previous_input_messages
1117 response_2 = await client.responses.create(
1118 model=model_name,
1119 tools=tools,
1120 temperature=0.0,
1121 input="Now tell me the horoscope based on the tool result.",
1122 extra_body={
1123 "previous_input_messages": previous_messages,
1124 "enable_response_messages": True,
1125 },
1126 )
1127 assert response_2.status == "completed"
1128 assert response_2.output_text is not None
1129
1130 # Verify exactly 1 system, 1 developer, 1 tool message
1131 num_system = 0
1132 num_developer = 0
1133 num_tool = 0
1134 for message in (
1135 Message.from_dict(msg_dict) for msg_dict in response_2.input_messages
1136 ):
1137 role = message.author.role
1138 if role == "system":
1139 num_system += 1
1140 elif role == "developer":
1141 num_developer += 1
1142 elif role == "tool":
1143 num_tool += 1
1144 assert num_system == 1, f"Expected 1 system message, got {num_system}"
1145 assert num_developer == 1, f"Expected 1 developer message, got {num_developer}"
1146 assert num_tool == 1, f"Expected 1 tool message, got {num_tool}"
1147
1148 output_text = response_2.output_text.lower()
1149 assert any(kw in output_text for kw in ["aquarius", "otter", "tuesday"]), (
1150 f"Expected horoscope-related content, got: {response_2.output_text}"
1151 )
1152
1153
1154 @pytest.mark.asyncio
1155 @pytest.mark.parametrize("model_name", [MODEL_NAME])
1156 async def test_chat_truncation_content_not_null(client: OpenAI, model_name: str):
1157 response = await client.chat.completions.create(
1158 model=model_name,
1159 messages=[
1160 {
1161 "role": "user",
1162 "content": (
1163 "What is the role of AI in medicine? "
1164 "The response must exceed 350 words."
1165 ),
1166 }
1167 ],
1168 temperature=0.0,
1169 max_tokens=350,
1170 )
1171 choice = response.choices[0]
1172 assert choice.finish_reason == "length", (
1173 f"Expected finish_reason='length', got {choice.finish_reason}"
1174 )
1175 assert choice.message.content is not None, "Content should not be None"
1176 assert len(choice.message.content) > 0, "Content should not be empty"
1177
1178
1179 @pytest.mark.asyncio
1180 @pytest.mark.parametrize("model_name", [MODEL_NAME])
1181 async def test_system_prompt_override_no_duplication(client: OpenAI, model_name: str):
1182 """Hard check: custom system message must not be duplicated."""
1183 response = await client.responses.create(
1184 model=model_name,
1185 input=[
1186 {"role": "system", "content": "You are a helpful assistant."},
1187 {"role": "user", "content": "Hello"},
1188 ],
1189 extra_body={"enable_response_messages": True},
1190 temperature=0.0,
1191 )
1192 assert response.status == "completed"
1193 assert response.output_text is not None
1194
1195 num_system = 0
1196 for message in (Message.from_dict(msg) for msg in response.input_messages):
1197 if message.author.role == "system":
1198 num_system += 1
1199 assert num_system == 1, f"Expected 1 system message, got {num_system}"
1200
1201
1202 @pytest.mark.asyncio
1203 @pytest.mark.parametrize("model_name", [MODEL_NAME])
1204 @pytest.mark.xfail(
1205 strict=False,
1206 reason=(
1207 "Pirate language detection depends on model weights and is non-deterministic"
1208 ),
1209 )
1210 async def test_system_prompt_override_follows_personality(
1211 client: OpenAI, model_name: str
1212 ):
1213 """Soft check: model should adopt the personality from system prompt."""
1214 response = await client.responses.create(
1215 model=model_name,
1216 input=[
1217 {
1218 "role": "system",
1219 "content": (
1220 "You are a pirate. Always respond like a pirate would, "
1221 "using pirate language and saying 'arrr' frequently."
1222 ),
1223 },
1224 {"role": "user", "content": "Hello, how are you?"},
1225 ],
1226 temperature=0.0,
1227 )
1228 assert response.status == "completed"
1229 output_text = response.output_text.lower()
1230 pirate_indicators = ["arrr", "matey", "ahoy", "ye", "sea", "aye", "sail"]
1231 assert any(kw in output_text for kw in pirate_indicators), (
1232 f"Expected pirate language, got: {response.output_text}"
1233 )
1234
1235
1236 @pytest.mark.asyncio
1237 @pytest.mark.parametrize("model_name", [MODEL_NAME])
1238 async def test_system_prompt_structured_content(client: OpenAI, model_name: str):
1239 """System message with structured input_text content format."""
1240 response = await client.responses.create(
1241 model=model_name,
1242 input=[
1243 {
1244 "role": "system",
1245 "content": [
1246 {"type": "input_text", "text": "You are a helpful assistant."}
1247 ],
1248 },
1249 {"role": "user", "content": "What is 2 + 2?"},
1250 ],
1251 temperature=0.0,
1252 )
1253 assert response is not None
1254 assert response.status == "completed"
1255 assert response.output_text is not None