mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-13 03:59:12 +00:00
* test: move routing, caching and callback tests out of local_testing Moves the 37 routing, caching and callback files in tests/local_testing into mirrored tests/unit paths and tests/integration sdk and security groups per the migration manifest, and deletes the 8 rows it marks for deletion * test: cover least-busy routing and tighten migrated router, cache and pass-through tests Add router-level least-busy tests for the three legacy rows that had no destination, move completion fallback and model alias tests to their mirror files, move router batch tests next to the router, and strengthen tests whose mutants survived (specific deployment, batch pair grouping, custom cache key, router cache ttl). Add a unit test for configured pass-through Authorization forwarding. * test: tighten migrated callback, proxy config and cache ttl tests Assert the full StandardLoggingPayload key set and the wire user on callback tests, add an async embedding failure callback test, drive the prometheus redis failure through a real RedisCache, use the real salt key in proxy config tests, pin the redis ttl value and rename fallback test helpers * test: fix semantic cache assertions and order-dependent router and callback tests Count semantic cache embeddings on the route, drain async cache writes before the second lookup, echo the requested model from the callback provider mock so response_cost does not depend on model_cost entries other tests register, and move the two-key authorization assertion to the auth failure test it belongs to * test: cover model_list fan-out and the usage-based routing tpm limit lost in the move * test: wait for the streamed cache entry in redis and drop deleted files from the hosted mock guard * test: restore sync callback, audio redaction and langfuse export coverage, keep redis-compat filter on the moved file * ci: run the remaining router-keyword legacy tests on one node * test: refuse the redis write in-process instead of resolving an invalid host --------- Co-authored-by: yuneng <yuneng@berri.ai>
352 lines
11 KiB
Python
352 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
from typing import Final
|
|
from unittest.mock import AsyncMock, Mock
|
|
|
|
import httpx
|
|
import openai
|
|
import pytest
|
|
import respx
|
|
|
|
import litellm
|
|
from litellm import Router
|
|
import time
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
|
|
@pytest.fixture
|
|
def restore_request_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(litellm, "request_timeout", litellm.request_timeout)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"num_retries, expected_call_count",
|
|
[(0, 1), (1, 2), (2, 3), (3, 4)],
|
|
)
|
|
@pytest.mark.usefixtures("fake_provider_credentials", "restore_request_timeout")
|
|
def test_router_timeout_with_retries_anthropic_model(num_retries, expected_call_count):
|
|
"""
|
|
If request hits custom timeout, ensure it's retried.
|
|
"""
|
|
from litellm.llms.custom_httpx.http_handler import HTTPHandler
|
|
|
|
litellm.num_retries = num_retries
|
|
litellm.request_timeout = 0.000001
|
|
|
|
router = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "claude-3-haiku",
|
|
"litellm_params": {
|
|
"model": f"anthropic/{os.environ.get('CI_CD_DEFAULT_ANTHROPIC_MODEL', 'claude-haiku-4-5-20251001')}",
|
|
},
|
|
}
|
|
],
|
|
)
|
|
|
|
custom_client = HTTPHandler()
|
|
|
|
with patch.object(custom_client, "post", new=MagicMock()) as mock_client:
|
|
try:
|
|
|
|
def delayed_response(*args, **kwargs):
|
|
time.sleep(0.01) # Exceeds the 0.000001 timeout
|
|
raise TimeoutError("Request timed out.")
|
|
|
|
mock_client.side_effect = delayed_response
|
|
|
|
router.completion(
|
|
model="claude-3-haiku",
|
|
messages=[{"role": "user", "content": "hello, who are u"}],
|
|
client=custom_client,
|
|
)
|
|
except litellm.Timeout:
|
|
pass
|
|
|
|
assert mock_client.call_count == expected_call_count
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"stream",
|
|
[
|
|
True,
|
|
False,
|
|
],
|
|
)
|
|
def test_unit_test_streaming_timeout(stream):
|
|
import os
|
|
from dotenv import load_dotenv
|
|
import litellm
|
|
from litellm.router import Router, RetryPolicy, AllowedFailsPolicy
|
|
|
|
litellm.set_verbose = True
|
|
|
|
model_list = [
|
|
{
|
|
"model_name": "llama3",
|
|
"litellm_params": {
|
|
"model": "watsonx/meta-llama/llama-3-1-8b-instruct",
|
|
"api_base": os.getenv("WATSONX_URL_US_SOUTH"),
|
|
"api_key": os.getenv("WATSONX_API_KEY"),
|
|
"project_id": os.getenv("WATSONX_PROJECT_ID_US_SOUTH"),
|
|
"timeout": 0.01,
|
|
"stream_timeout": 0.0000001,
|
|
},
|
|
},
|
|
{
|
|
"model_name": "bedrock-anthropic",
|
|
"litellm_params": {
|
|
"model": "bedrock/anthropic.claude-3-5-haiku-20241022-v1:0",
|
|
"timeout": 0.01,
|
|
"stream_timeout": 0.0000001,
|
|
},
|
|
},
|
|
{
|
|
"model_name": "llama3-fallback",
|
|
"litellm_params": {
|
|
"model": "gpt-3.5-turbo",
|
|
"api_key": os.getenv("OPENAI_API_KEY"),
|
|
},
|
|
},
|
|
]
|
|
|
|
router = Router(model_list=model_list)
|
|
|
|
stream_timeout = 0.0000001
|
|
normal_timeout = 0.01
|
|
|
|
args = {
|
|
"kwargs": {"stream": stream},
|
|
"data": {"timeout": normal_timeout, "stream_timeout": stream_timeout},
|
|
}
|
|
|
|
assert router._get_stream_timeout(**args) == stream_timeout
|
|
|
|
assert router._get_non_stream_timeout(**args) == normal_timeout
|
|
|
|
stream_timeout_val = router._get_timeout(
|
|
kwargs={"stream": stream},
|
|
data={"timeout": normal_timeout, "stream_timeout": stream_timeout},
|
|
)
|
|
|
|
if stream:
|
|
assert stream_timeout_val == stream_timeout
|
|
else:
|
|
assert stream_timeout_val == normal_timeout
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bedrock_timeout_preserves_the_provider_timeout_error(
|
|
respx_mock: respx.MockRouter,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
|
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "test-access-key")
|
|
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "test-secret-key")
|
|
monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1")
|
|
model_id: Final = "us.anthropic.claude-haiku-4-5-20251001-v1:0"
|
|
route: Final = respx_mock.post(
|
|
url__regex=r"^https://bedrock-runtime\.us-east-1\.amazonaws\.com/.*"
|
|
).mock(side_effect=httpx.ReadTimeout("upstream timeout"))
|
|
router: Final = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "bedrock-test",
|
|
"litellm_params": {
|
|
"model": f"bedrock/{model_id}",
|
|
"aws_region_name": "us-east-1",
|
|
"timeout": 0.0001,
|
|
},
|
|
}
|
|
],
|
|
num_retries=0,
|
|
)
|
|
|
|
with pytest.raises(openai.APITimeoutError) as exc_info:
|
|
await router.acompletion(
|
|
model="bedrock-test",
|
|
messages=[{"role": "user", "content": "timeout test"}],
|
|
)
|
|
|
|
assert route.call_count == 1
|
|
assert route.calls[0].request.url.path.endswith(f"/model/{model_id}/converse")
|
|
|
|
|
|
def test_stream_timeout_uses_the_configured_fallback(
|
|
respx_mock: respx.MockRouter,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
|
stream_body: Final = "\n\n".join(
|
|
(
|
|
f"data: {json.dumps({'id': 'chatcmpl-fallback', 'object': 'chat.completion.chunk', 'created': 1, 'model': 'fallback', 'choices': [{'index': 0, 'delta': {'content': 'fallback'}, 'finish_reason': 'stop'}]})}",
|
|
"data: [DONE]",
|
|
"",
|
|
)
|
|
)
|
|
route: Final = respx_mock.post("https://api.openai.com/v1/chat/completions").mock(
|
|
side_effect=[
|
|
httpx.ReadTimeout("primary stream timed out"),
|
|
httpx.Response(200, text=stream_body),
|
|
]
|
|
)
|
|
router: Final = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "primary",
|
|
"litellm_params": {"model": "openai/primary", "api_key": "test-key"},
|
|
},
|
|
{
|
|
"model_name": "fallback",
|
|
"litellm_params": {"model": "openai/fallback", "api_key": "test-key"},
|
|
},
|
|
],
|
|
fallbacks=[{"primary": ["fallback"]}],
|
|
num_retries=0,
|
|
)
|
|
|
|
response: Final = router.completion(
|
|
model="primary",
|
|
messages=[{"role": "user", "content": "stream timeout test"}],
|
|
stream=True,
|
|
)
|
|
chunks: Final = tuple(response)
|
|
|
|
assert tuple(chunk.choices[0].delta.content for chunk in chunks if chunk.choices[0].delta.content) == (
|
|
"fallback",
|
|
)
|
|
request_bodies: Final = tuple(json.loads(call.request.content) for call in route.calls)
|
|
assert route.call_count == 2
|
|
assert tuple(body["model"] for body in request_bodies) == ("primary", "fallback")
|
|
assert tuple(body["stream"] for body in request_bodies) == (True, True)
|
|
|
|
|
|
def test_openai_timeout_raises_provider_timeout_for_scripted_failure(
|
|
respx_mock: respx.MockRouter,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
|
route: Final = respx_mock.post("https://api.openai.com/v1/chat/completions").mock(
|
|
side_effect=httpx.ReadTimeout("scripted upstream timeout")
|
|
)
|
|
|
|
with pytest.raises(openai.APITimeoutError):
|
|
litellm.completion(
|
|
model="openai/gpt-4o-mini",
|
|
api_key="scripted-timeout-key",
|
|
timeout=0.01,
|
|
messages=[{"role": "user", "content": "timeout"}],
|
|
num_retries=0,
|
|
)
|
|
|
|
assert route.call_count == 1
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("async_mode", "stream"),
|
|
((False, False), (False, True), (True, False), (True, True)),
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_anthropic_timeout_raises_for_sync_and_streaming(
|
|
async_mode: bool,
|
|
stream: bool,
|
|
respx_mock: respx.MockRouter,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
|
route: Final = respx_mock.post(
|
|
"https://api.anthropic.com/v1/messages"
|
|
).mock(side_effect=httpx.ReadTimeout("scripted Anthropic timeout"))
|
|
request_kwargs: Final = {
|
|
"model": "anthropic/claude-haiku-4-5-20251001",
|
|
"messages": [{"role": "user", "content": "timeout contract"}],
|
|
"api_key": "scripted-anthropic-key",
|
|
"timeout": 0.001,
|
|
"stream": stream,
|
|
}
|
|
|
|
async def run_async_request() -> None:
|
|
response: Final = await litellm.acompletion(**request_kwargs)
|
|
if stream:
|
|
_ = tuple([chunk async for chunk in response])
|
|
|
|
def run_sync_request() -> None:
|
|
response: Final = litellm.completion(**request_kwargs)
|
|
if stream:
|
|
_ = tuple(response)
|
|
|
|
if async_mode:
|
|
with pytest.raises(litellm.Timeout):
|
|
await run_async_request()
|
|
else:
|
|
with pytest.raises(litellm.Timeout):
|
|
run_sync_request()
|
|
|
|
assert route.call_count == 1
|
|
|
|
|
|
def test_openai_router_timeout_raises_after_httpx_read_timeout(
|
|
respx_mock: respx.MockRouter,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
|
route: Final = respx_mock.post(
|
|
"https://api.openai.com/v1/chat/completions"
|
|
).mock(side_effect=httpx.ReadTimeout("scripted OpenAI timeout"))
|
|
router: Final = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "timeout-model",
|
|
"litellm_params": {
|
|
"model": "openai/gpt-4o-mini",
|
|
"api_key": "scripted-openai-key",
|
|
"timeout": 0.001,
|
|
},
|
|
}
|
|
],
|
|
num_retries=0,
|
|
)
|
|
|
|
with pytest.raises(litellm.Timeout):
|
|
router.completion(
|
|
model="timeout-model",
|
|
messages=[{"role": "user", "content": "timeout contract"}],
|
|
)
|
|
|
|
assert route.call_count == 1
|
|
|
|
|
|
def test_azure_router_timeout_raises_after_httpx_read_timeout(
|
|
respx_mock: respx.MockRouter,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
|
route: Final = respx_mock.post(
|
|
"https://azure.example.com/openai/deployments/gpt-4o-mini/chat/completions"
|
|
).mock(side_effect=httpx.ReadTimeout("scripted Azure timeout"))
|
|
router: Final = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "azure-timeout",
|
|
"litellm_params": {
|
|
"model": "azure/gpt-4o-mini",
|
|
"api_base": "https://azure.example.com",
|
|
"api_version": "2024-10-21",
|
|
"api_key": "scripted-azure-key",
|
|
"timeout": 0.001,
|
|
},
|
|
}
|
|
],
|
|
num_retries=0,
|
|
)
|
|
|
|
with pytest.raises(litellm.Timeout):
|
|
router.completion(
|
|
model="azure-timeout",
|
|
messages=[{"role": "user", "content": "timeout contract"}],
|
|
)
|
|
|
|
assert route.call_count == 1
|