--- a/src/agents/extensions/models/litellm_model.py +++ b/src/agents/extensions/models/litellm_model.py @@ -572,13 +572,6 @@ if tracing.include_data(): span.span_data.input = converted_messages - parallel_tool_calls = ( - True - if model_settings.parallel_tool_calls and tools and len(tools) > 0 - else False - if model_settings.parallel_tool_calls is False - else None - ) tool_choice = Converter.convert_tool_choice(model_settings.tool_choice) response_format = Converter.convert_response_format(output_schema) @@ -588,6 +581,10 @@ converted_tools.append(Converter.convert_handoff_tool(handoff)) converted_tools = _to_dump_compatible(converted_tools) + + # Only forward parallel_tool_calls when tools (function tools or handoffs) are sent. + # Providers such as Azure OpenAI reject parallel_tool_calls without tools. + parallel_tool_calls = model_settings.parallel_tool_calls if converted_tools else None if _debug.DONT_LOG_MODEL_DATA: logger.debug("Calling LLM") --- a/tests/models/test_litellm_parallel_tool_calls.py +++ b/tests/models/test_litellm_parallel_tool_calls.py @@ -0,0 +1,121 @@ +"""Regression tests: LitellmModel only forwards parallel_tool_calls when tools are sent. + +Providers such as Azure OpenAI reject requests containing parallel_tool_calls without tools. +""" + +from __future__ import annotations + +from typing import Any + +import litellm +import pytest +from litellm.types.utils import Choices, Message, ModelResponse, Usage + +from agents import Agent, function_tool, handoff +from agents.extensions.models.litellm_model import LitellmModel +from agents.model_settings import ModelSettings +from agents.models.interface import ModelTracing +from agents.tracing import generation_span + + +@function_tool +def lookup() -> str: + """Return a deterministic result.""" + return "ok" + + +def _install_fake(monkeypatch, captured: dict[str, Any], *, stream: bool = False) -> None: + async def fake_acompletion(model, messages=None, **kwargs): + captured.update(kwargs) + if stream: + + async def _empty(): + if False: # pragma: no cover + yield None + + return _empty() + msg = Message(role="assistant", content="ok") + choice = Choices(index=0, message=msg) + return ModelResponse(choices=[choice], usage=Usage(0, 0, 0)) + + monkeypatch.setattr(litellm, "acompletion", fake_acompletion) + + +def _tools_and_handoffs(kind: str) -> tuple[list[Any], list[Any]]: + if kind == "none": + return [], [] + if kind == "tool": + return [lookup], [] + if kind == "handoff": + return [], [handoff(Agent(name="other"))] + raise AssertionError(kind) + + +CASES = [ + # (setting, kind, expected) + (False, "none", None), + (True, "none", None), + (None, "none", None), + (False, "tool", False), + (True, "tool", True), + (None, "tool", None), + (False, "handoff", False), + (True, "handoff", True), + (None, "handoff", None), +] + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +@pytest.mark.parametrize("setting,kind,expected", CASES) +async def test_parallel_tool_calls_non_stream(monkeypatch, setting, kind, expected): + captured: dict[str, Any] = {} + _install_fake(monkeypatch, captured) + tools, handoffs = _tools_and_handoffs(kind) + model = LitellmModel(model="azure/gpt-4o") + + await model.get_response( + system_instructions=None, + input="hi", + model_settings=ModelSettings(parallel_tool_calls=setting), + tools=tools, + output_schema=None, + handoffs=handoffs, + tracing=ModelTracing.DISABLED, + previous_response_id=None, + ) + + assert captured["parallel_tool_calls"] is expected + if kind == "none": + assert captured["tools"] is None + else: + assert captured["tools"] + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +@pytest.mark.parametrize("setting,kind,expected", CASES) +async def test_parallel_tool_calls_stream(monkeypatch, setting, kind, expected): + captured: dict[str, Any] = {} + _install_fake(monkeypatch, captured, stream=True) + tools, handoffs = _tools_and_handoffs(kind) + model = LitellmModel(model="azure/gpt-4o") + + with generation_span(disabled=True) as span: + response, _stream = await model._fetch_response( + None, + "hi", + ModelSettings(parallel_tool_calls=setting), + tools, + None, + handoffs, + span, + ModelTracing.DISABLED, + stream=True, + ) + + assert captured["stream"] is True + assert captured["parallel_tool_calls"] is expected + assert response.parallel_tool_calls is bool(expected) + if kind == "none": + assert captured["tools"] is None