318 lines
10 KiB
Python
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
|