Files
2026-07-13 12:39:17 +08:00

318 lines
10 KiB
Python

import httpx
import litellm
import pytest
from httpx import Headers, Response
from litellm.exceptions import RateLimitError
from litellm.types.utils import Choices, Message, ModelResponse, Usage
from openai import APIConnectionError
from openai.types.chat.chat_completion import ChatCompletion, Choice
from openai.types.chat.chat_completion_message import ChatCompletionMessage
from openai.types.completion_usage import CompletionUsage
from agents.extensions.models.litellm_model import LitellmModel
from agents.model_settings import ModelSettings
from agents.models._retry_runtime import provider_managed_retries_disabled
from agents.models.interface import ModelTracing
from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel
from agents.retry import ModelRetryAdviceRequest, ModelRetrySettings
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_litellm_kwargs_forwarded(monkeypatch):
"""
Test that kwargs from ModelSettings are forwarded to litellm.acompletion.
"""
captured: dict[str, object] = {}
async def fake_acompletion(model, messages=None, **kwargs):
captured.update(kwargs)
msg = Message(role="assistant", content="test response")
choice = Choices(index=0, message=msg)
return ModelResponse(choices=[choice], usage=Usage(0, 0, 0))
monkeypatch.setattr(litellm, "acompletion", fake_acompletion)
settings = ModelSettings(
temperature=0.5,
extra_args={
"custom_param": "custom_value",
"seed": 42,
"stop": ["END"],
"logit_bias": {123: -100},
},
)
model = LitellmModel(model="test-model")
await model.get_response(
system_instructions=None,
input="test input",
model_settings=settings,
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
)
# Verify that all kwargs were passed through
assert captured["custom_param"] == "custom_value"
assert captured["seed"] == 42
assert captured["stop"] == ["END"]
assert captured["logit_bias"] == {123: -100}
# Verify regular parameters are still passed
assert captured["temperature"] == 0.5
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_openai_chatcompletions_kwargs_forwarded(monkeypatch):
"""
Test that kwargs from ModelSettings are forwarded to OpenAI chat completions API.
"""
captured: dict[str, object] = {}
class MockChatCompletions:
async def create(self, **kwargs):
captured.update(kwargs)
msg = ChatCompletionMessage(role="assistant", content="test response")
choice = Choice(index=0, message=msg, finish_reason="stop")
return ChatCompletion(
id="test-id",
created=0,
model="gpt-4",
object="chat.completion",
choices=[choice],
usage=CompletionUsage(completion_tokens=5, prompt_tokens=10, total_tokens=15),
)
class MockChat:
def __init__(self):
self.completions = MockChatCompletions()
class MockClient:
def __init__(self):
self.chat = MockChat()
self.base_url = "https://api.openai.com/v1"
settings = ModelSettings(
temperature=0.7,
extra_args={
"seed": 123,
"logit_bias": {456: 10},
"stop": ["STOP", "END"],
"user": "test-user",
},
)
mock_client = MockClient()
model = OpenAIChatCompletionsModel(model="gpt-4", openai_client=mock_client) # type: ignore
await model.get_response(
system_instructions="Test system",
input="test input",
model_settings=settings,
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
previous_response_id=None,
)
# Verify that all kwargs were passed through
assert captured["seed"] == 123
assert captured["logit_bias"] == {456: 10}
assert captured["stop"] == ["STOP", "END"]
assert captured["user"] == "test-user"
# Verify regular parameters are still passed
assert captured["temperature"] == 0.7
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_empty_kwargs_handling(monkeypatch):
"""
Test that empty or None kwargs are handled gracefully.
"""
captured: dict[str, object] = {}
async def fake_acompletion(model, messages=None, **kwargs):
captured.update(kwargs)
msg = Message(role="assistant", content="test response")
choice = Choices(index=0, message=msg)
return ModelResponse(choices=[choice], usage=Usage(0, 0, 0))
monkeypatch.setattr(litellm, "acompletion", fake_acompletion)
# Test with None kwargs
settings_none = ModelSettings(temperature=0.5, extra_args=None)
model = LitellmModel(model="test-model")
await model.get_response(
system_instructions=None,
input="test input",
model_settings=settings_none,
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
previous_response_id=None,
)
# Should work without error and include regular parameters
assert captured["temperature"] == 0.5
# Test with empty dict
captured.clear()
settings_empty = ModelSettings(temperature=0.3, extra_args={})
await model.get_response(
system_instructions=None,
input="test input",
model_settings=settings_empty,
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
previous_response_id=None,
)
# Should work without error and include regular parameters
assert captured["temperature"] == 0.3
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_reasoning_effort_falls_back_to_extra_args(monkeypatch):
"""
Ensure reasoning_effort from extra_args is promoted when reasoning settings are missing.
"""
captured: dict[str, object] = {}
async def fake_acompletion(model, messages=None, **kwargs):
captured.update(kwargs)
msg = Message(role="assistant", content="test response")
choice = Choices(index=0, message=msg)
return ModelResponse(choices=[choice], usage=Usage(0, 0, 0))
monkeypatch.setattr(litellm, "acompletion", fake_acompletion)
# GitHub issue context: https://github.com/openai/openai-agents-python/issues/1764.
settings = ModelSettings(
extra_args={"reasoning_effort": "none", "custom_param": "custom_value"}
)
model = LitellmModel(model="test-model")
await model.get_response(
system_instructions=None,
input="test input",
model_settings=settings,
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
previous_response_id=None,
)
assert captured["reasoning_effort"] == "none"
assert captured["custom_param"] == "custom_value"
assert settings.extra_args == {"reasoning_effort": "none", "custom_param": "custom_value"}
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_litellm_retry_settings_do_not_leak_and_disable_provider_retries_on_runner_retry(
monkeypatch,
):
"""Runner retries should disable LiteLLM's own retries without forwarding SDK retry config."""
captured: dict[str, object] = {}
async def fake_acompletion(model, messages=None, **kwargs):
captured.update(kwargs)
msg = Message(role="assistant", content="test response")
choice = Choices(index=0, message=msg)
return ModelResponse(choices=[choice], usage=Usage(0, 0, 0))
monkeypatch.setattr(litellm, "acompletion", fake_acompletion)
settings = ModelSettings(
retry=ModelRetrySettings(
max_retries=2,
backoff={"initial_delay": 0.25, "jitter": False},
),
extra_args={"max_retries": 7, "num_retries": 6, "custom_param": "custom_value"},
)
model = LitellmModel(model="test-model")
with provider_managed_retries_disabled(True):
await model.get_response(
system_instructions=None,
input="test input",
model_settings=settings,
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
)
assert settings.retry is not None
assert settings.retry.backoff is not None
assert captured["custom_param"] == "custom_value"
assert captured["max_retries"] == 0
assert captured["num_retries"] == 0
assert "retry" not in captured
def test_litellm_get_retry_advice_uses_response_headers() -> None:
"""LiteLLM retry advice should expose OpenAI-compatible retry headers."""
model = LitellmModel(model="test-model")
error = RateLimitError(
message="rate limited",
llm_provider="openai",
model="gpt-4o-mini",
response=Response(
status_code=429,
headers=Headers({"x-should-retry": "true", "retry-after-ms": "250"}),
),
)
advice = model.get_retry_advice(
ModelRetryAdviceRequest(
error=error,
attempt=1,
stream=False,
)
)
assert advice is not None
assert advice.suggested is True
assert advice.retry_after == 0.25
def test_litellm_get_retry_advice_keeps_stateful_transport_failures_ambiguous() -> None:
model = LitellmModel(model="test-model")
error = APIConnectionError(
message="connection error",
request=httpx.Request("POST", "https://api.openai.com/v1/responses"),
)
advice = model.get_retry_advice(
ModelRetryAdviceRequest(
error=error,
attempt=1,
stream=False,
previous_response_id="resp_prev",
)
)
assert advice is not None
assert advice.suggested is True
assert advice.replay_safety is None