mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-11 08:38:24 +00:00
1209 lines
43 KiB
Python
1209 lines
43 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
from typing import Final, Literal
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
import respx
|
|
|
|
import litellm
|
|
from litellm import Router
|
|
import os
|
|
from tests.fake_openai_endpoint import FAKE_OPENAI_API_BASE
|
|
from litellm.integrations.custom_logger import CustomLogger
|
|
from litellm._logging import verbose_logger, verbose_proxy_logger, verbose_router_logger
|
|
from litellm.llms.custom_httpx.async_client_cleanup import close_litellm_async_clients
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_fallbacks_streaming():
|
|
"""Test that router.acompletion with stream=True and mock_response works correctly."""
|
|
litellm.set_verbose = False
|
|
model_list = [
|
|
{
|
|
"model_name": "azure/gpt-3.5-turbo",
|
|
"litellm_params": {
|
|
"model": "azure/gpt-4.1-mini",
|
|
"api_key": "fake-key",
|
|
"api_version": "2024-01-01",
|
|
"api_base": "https://fake.openai.azure.com",
|
|
},
|
|
"tpm": 240000,
|
|
"rpm": 1800,
|
|
},
|
|
{
|
|
"model_name": "gpt-4o-mini",
|
|
"litellm_params": {
|
|
"model": "gpt-4o-mini",
|
|
"api_key": "fake-key",
|
|
},
|
|
"tpm": 1000000,
|
|
"rpm": 9000,
|
|
},
|
|
]
|
|
|
|
router = Router(
|
|
model_list=model_list,
|
|
fallbacks=[{"azure/gpt-3.5-turbo": ["gpt-4o-mini"]}],
|
|
set_verbose=False,
|
|
)
|
|
customHandler = MyCustomHandler()
|
|
litellm.callbacks = [customHandler]
|
|
user_message = "Hello, how are you?"
|
|
try:
|
|
response = await router.acompletion(
|
|
model="azure/gpt-3.5-turbo",
|
|
messages=[{"role": "user", "content": user_message}],
|
|
stream=True,
|
|
mock_response="This is a mock streaming response",
|
|
)
|
|
chunks = []
|
|
async for chunk in response:
|
|
chunks.append(chunk)
|
|
assert len(chunks) > 0, "Expected at least one streaming chunk"
|
|
router.reset()
|
|
except litellm.Timeout as e:
|
|
pass
|
|
except Exception as e:
|
|
pytest.fail(f"An exception occurred: {e}")
|
|
finally:
|
|
router.reset()
|
|
|
|
|
|
@pytest.mark.parametrize("sync_mode", [True, False])
|
|
@pytest.mark.parametrize("litellm_module_fallbacks", [True, False])
|
|
@pytest.mark.asyncio
|
|
async def test_default_model_fallbacks(sync_mode, litellm_module_fallbacks):
|
|
"""
|
|
Related issue - https://github.com/BerriAI/litellm/issues/3623
|
|
|
|
If model misconfigured, setup a default model for generic fallback
|
|
"""
|
|
if litellm_module_fallbacks:
|
|
litellm.default_fallbacks = ["my-good-model"]
|
|
router = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "bad-model",
|
|
"litellm_params": {
|
|
"model": "openai/my-bad-model",
|
|
"api_key": "my-bad-api-key",
|
|
},
|
|
},
|
|
{
|
|
"model_name": "my-good-model",
|
|
"litellm_params": {
|
|
"model": "gpt-4o",
|
|
"api_key": os.getenv("OPENAI_API_KEY"),
|
|
},
|
|
},
|
|
],
|
|
default_fallbacks=(
|
|
["my-good-model"] if litellm_module_fallbacks is False else None
|
|
),
|
|
)
|
|
|
|
if sync_mode:
|
|
response = router.completion(
|
|
model="bad-model",
|
|
messages=[{"role": "user", "content": "Hey, how's it going?"}],
|
|
mock_testing_fallbacks=True,
|
|
mock_response="Hey! nice day",
|
|
)
|
|
else:
|
|
response = await router.acompletion(
|
|
model="bad-model",
|
|
messages=[{"role": "user", "content": "Hey, how's it going?"}],
|
|
mock_testing_fallbacks=True,
|
|
mock_response="Hey! nice day",
|
|
)
|
|
|
|
assert isinstance(response, litellm.ModelResponse)
|
|
assert response.model is not None and response.model == "gpt-4o"
|
|
|
|
|
|
@pytest.mark.parametrize("sync_mode", [True, False])
|
|
@pytest.mark.asyncio
|
|
async def test_client_side_fallbacks_list(sync_mode):
|
|
"""
|
|
|
|
Tests Client Side Fallbacks
|
|
|
|
User can pass "fallbacks": ["gpt-3.5-turbo"] and this should work
|
|
|
|
"""
|
|
router = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "bad-model",
|
|
"litellm_params": {
|
|
"model": "openai/my-bad-model",
|
|
"api_key": "my-bad-api-key",
|
|
},
|
|
},
|
|
{
|
|
"model_name": "my-good-model",
|
|
"litellm_params": {
|
|
"model": "gpt-4o",
|
|
"api_key": os.getenv("OPENAI_API_KEY"),
|
|
},
|
|
},
|
|
],
|
|
)
|
|
|
|
if sync_mode:
|
|
response = router.completion(
|
|
model="bad-model",
|
|
messages=[{"role": "user", "content": "Hey, how's it going?"}],
|
|
fallbacks=["my-good-model"],
|
|
mock_testing_fallbacks=True,
|
|
mock_response="Hey! nice day",
|
|
)
|
|
else:
|
|
response = await router.acompletion(
|
|
model="bad-model",
|
|
messages=[{"role": "user", "content": "Hey, how's it going?"}],
|
|
fallbacks=["my-good-model"],
|
|
mock_testing_fallbacks=True,
|
|
mock_response="Hey! nice day",
|
|
)
|
|
|
|
assert isinstance(response, litellm.ModelResponse)
|
|
assert response.model is not None and response.model == "gpt-4o"
|
|
|
|
|
|
@pytest.mark.parametrize("sync_mode", [True, False])
|
|
@pytest.mark.parametrize("content_filter_response_exception", [True, False])
|
|
@pytest.mark.parametrize("fallback_type", ["model-specific", "default"])
|
|
@pytest.mark.asyncio
|
|
async def test_router_content_policy_fallbacks(
|
|
sync_mode, content_filter_response_exception, fallback_type
|
|
):
|
|
os.environ["LITELLM_LOG"] = "DEBUG"
|
|
|
|
if content_filter_response_exception:
|
|
mock_response = Exception("content filtering policy")
|
|
else:
|
|
mock_response = litellm.ModelResponse(
|
|
choices=[litellm.Choices(finish_reason="content_filter")],
|
|
model="gpt-3.5-turbo",
|
|
usage=litellm.Usage(prompt_tokens=10, completion_tokens=0, total_tokens=10),
|
|
)
|
|
router = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "claude-sonnet-4-5-20250929",
|
|
"litellm_params": {
|
|
"model": "anthropic/claude-sonnet-4-5-20250929",
|
|
"api_key": "",
|
|
"mock_response": mock_response,
|
|
},
|
|
},
|
|
{
|
|
"model_name": "my-fallback-model",
|
|
"litellm_params": {
|
|
"model": "openai/my-fake-model",
|
|
"api_key": "",
|
|
"mock_response": "This works!",
|
|
},
|
|
},
|
|
{
|
|
"model_name": "my-default-fallback-model",
|
|
"litellm_params": {
|
|
"model": "openai/my-fake-model",
|
|
"api_key": "",
|
|
"mock_response": "This works 2!",
|
|
},
|
|
},
|
|
{
|
|
"model_name": "my-general-model",
|
|
"litellm_params": {
|
|
"model": "anthropic/claude-sonnet-4-5-20250929",
|
|
"api_key": "",
|
|
"mock_response": Exception("Should not have called this."),
|
|
},
|
|
},
|
|
{
|
|
"model_name": "my-context-window-model",
|
|
"litellm_params": {
|
|
"model": "anthropic/claude-sonnet-4-5-20250929",
|
|
"api_key": "",
|
|
"mock_response": Exception("Should not have called this."),
|
|
},
|
|
},
|
|
],
|
|
content_policy_fallbacks=(
|
|
[{"claude-sonnet-4-5-20250929": ["my-fallback-model"]}]
|
|
if fallback_type == "model-specific"
|
|
else None
|
|
),
|
|
default_fallbacks=(
|
|
["my-default-fallback-model"] if fallback_type == "default" else None
|
|
),
|
|
)
|
|
|
|
if sync_mode is True:
|
|
response = router.completion(
|
|
model="claude-sonnet-4-5-20250929",
|
|
messages=[{"role": "user", "content": "Hey, how's it going?"}],
|
|
)
|
|
else:
|
|
response = await router.acompletion(
|
|
model="claude-sonnet-4-5-20250929",
|
|
messages=[{"role": "user", "content": "Hey, how's it going?"}],
|
|
)
|
|
|
|
assert response.model == "my-fake-model"
|
|
|
|
|
|
def mock_post_streaming(url: str, **kwargs: object) -> MagicMock:
|
|
response: Final = MagicMock()
|
|
response.status_code = 529
|
|
response.headers = {"Content-Type": "application/json"}
|
|
response.return_value = {"detail": "Overloaded!"}
|
|
return response
|
|
|
|
|
|
@pytest.mark.usefixtures("fake_provider_credentials")
|
|
@pytest.mark.parametrize("sync_mode", [True, False])
|
|
@pytest.mark.asyncio
|
|
async def test_anthropic_streaming_fallbacks(sync_mode):
|
|
litellm.set_verbose = True
|
|
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
|
|
|
|
if sync_mode:
|
|
client = HTTPHandler(concurrent_limit=1)
|
|
else:
|
|
client = AsyncHTTPHandler(concurrent_limit=1)
|
|
|
|
router = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "anthropic/claude-sonnet-4-5-20250929",
|
|
"litellm_params": {
|
|
"model": "anthropic/claude-sonnet-4-5-20250929",
|
|
},
|
|
},
|
|
{
|
|
"model_name": "gpt-3.5-turbo",
|
|
"litellm_params": {
|
|
"model": "gpt-3.5-turbo",
|
|
"mock_response": "Hey, how's it going?",
|
|
},
|
|
},
|
|
],
|
|
fallbacks=[{"anthropic/claude-sonnet-4-5-20250929": ["gpt-3.5-turbo"]}],
|
|
num_retries=0,
|
|
)
|
|
|
|
with patch.object(client, "post", side_effect=mock_post_streaming) as mock_client:
|
|
chunks = []
|
|
if sync_mode:
|
|
response = router.completion(
|
|
model="anthropic/claude-sonnet-4-5-20250929",
|
|
messages=[{"role": "user", "content": "Hey, how's it going?"}],
|
|
stream=True,
|
|
client=client,
|
|
)
|
|
for chunk in response:
|
|
print(chunk)
|
|
chunks.append(chunk)
|
|
else:
|
|
response = await router.acompletion(
|
|
model="anthropic/claude-sonnet-4-5-20250929",
|
|
messages=[{"role": "user", "content": "Hey, how's it going?"}],
|
|
stream=True,
|
|
client=client,
|
|
)
|
|
async for chunk in response:
|
|
print(chunk)
|
|
chunks.append(chunk)
|
|
print(f"RETURNED response: {response}")
|
|
|
|
mock_client.assert_called_once()
|
|
print(chunks)
|
|
assert len(chunks) > 0
|
|
|
|
|
|
def test_router_fallbacks_with_custom_model_costs():
|
|
"""
|
|
Tests prod use-case where a custom model is registered with a different provider + custom costs.
|
|
|
|
Goal: make sure custom model doesn't override default model costs.
|
|
"""
|
|
|
|
default_model_info = litellm.get_model_info(model="claude-sonnet-4-5-20250929")
|
|
|
|
model_list = [
|
|
{
|
|
"model_name": "claude-sonnet-4-5-20250929",
|
|
"litellm_params": {
|
|
"model": "claude-sonnet-4-5-20250929",
|
|
"api_key": os.environ.get("ANTHROPIC_API_KEY", "fake-key"),
|
|
"input_cost_per_token": 30,
|
|
"output_cost_per_token": 60,
|
|
"mock_response": "Hello! How can I help you today?",
|
|
},
|
|
},
|
|
{
|
|
"model_name": "claude-3-5-sonnet-aihubmix",
|
|
"litellm_params": {
|
|
"model": "openai/claude-sonnet-4-5-20250929",
|
|
"input_cost_per_token": 0.000003, # 3$/M
|
|
"output_cost_per_token": 0.000015, # 15$/M
|
|
"api_base": FAKE_OPENAI_API_BASE,
|
|
"api_key": "my-fake-key",
|
|
"mock_response": "Hello! How can I help you today?",
|
|
},
|
|
},
|
|
]
|
|
|
|
router = Router(
|
|
model_list=model_list,
|
|
fallbacks=[{"claude-sonnet-4-5-20250929": ["claude-3-5-sonnet-aihubmix"]}],
|
|
)
|
|
|
|
router.completion(
|
|
model="claude-3-5-sonnet-aihubmix",
|
|
messages=[{"role": "user", "content": "Hey, how's it going?"}],
|
|
)
|
|
|
|
model_info = litellm.get_model_info(model="claude-sonnet-4-5-20250929")
|
|
|
|
print(f"key: {model_info['key']}")
|
|
|
|
assert model_info["litellm_provider"] == "anthropic"
|
|
|
|
response = router.completion(
|
|
model="claude-sonnet-4-5-20250929",
|
|
messages=[{"role": "user", "content": "Hey, how's it going?"}],
|
|
)
|
|
|
|
print(f"response_cost: {response._hidden_params['response_cost']}")
|
|
|
|
assert response._hidden_params["response_cost"] > 10
|
|
|
|
model_info = litellm.get_model_info(model="claude-sonnet-4-5-20250929")
|
|
|
|
print(f"key: {model_info['key']}")
|
|
|
|
assert model_info["input_cost_per_token"] == default_model_info["input_cost_per_token"]
|
|
assert model_info["output_cost_per_token"] == default_model_info["output_cost_per_token"]
|
|
|
|
|
|
def test_router_fallbacks_with_wildcard_model_name():
|
|
router = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "openai/*",
|
|
"litellm_params": {
|
|
"model": "openai/*",
|
|
"api_key": os.getenv("OPENAI_API_KEY"),
|
|
},
|
|
},
|
|
{
|
|
"model_name": "claude-3-haiku",
|
|
"litellm_params": {
|
|
"model": "claude-haiku-4-5-20251001",
|
|
"api_key": os.getenv("ANTHROPIC_API_KEY"),
|
|
"mock_response": "Hi this is claude!",
|
|
},
|
|
},
|
|
],
|
|
fallbacks=[{"gpt-3.5-turbo": ["claude-3-haiku"]}],
|
|
)
|
|
|
|
response = router.completion(
|
|
model="openai/gpt-3.5-turbo",
|
|
messages=[{"role": "user", "content": "Hey, how's it going?"}],
|
|
mock_testing_fallbacks=True,
|
|
)
|
|
|
|
print(response)
|
|
assert response["choices"][0]["message"]["content"] == "Hi this is claude!"
|
|
|
|
|
|
def test_get_fallback_model_group():
|
|
from litellm.router_utils.fallback_event_handlers import get_fallback_model_group
|
|
|
|
args = {
|
|
"fallbacks": [
|
|
{"gpt-3.5-turbo": ["claude-3-haiku"]},
|
|
{"*": ["claude-3-sonnet"]},
|
|
],
|
|
"model_group": "openai/gpt-3.5-turbo",
|
|
}
|
|
fallback_model_group, _ = get_fallback_model_group(**args)
|
|
assert fallback_model_group == ["claude-3-haiku"]
|
|
|
|
|
|
@pytest.mark.parametrize("expected_attempted_fallbacks", [0])
|
|
@pytest.mark.asyncio
|
|
async def test_router_attempted_fallbacks_in_response(
|
|
expected_attempted_fallbacks: int,
|
|
) -> None:
|
|
router: Final = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "working-fake-endpoint",
|
|
"litellm_params": {
|
|
"model": "openai/working-fake-endpoint",
|
|
"api_key": "test-key",
|
|
"mock_response": "Hello",
|
|
},
|
|
},
|
|
{
|
|
"model_name": "badly-configured-openai-endpoint",
|
|
"litellm_params": {
|
|
"model": "openai/my-fake-model",
|
|
"api_base": "https://example.invalid",
|
|
},
|
|
},
|
|
],
|
|
fallbacks=[{"badly-configured-openai-endpoint": ["working-fake-endpoint"]}],
|
|
)
|
|
|
|
response: Final = router.completion(
|
|
model="working-fake-endpoint",
|
|
messages=[{"role": "user", "content": "Hey, how's it going?"}],
|
|
)
|
|
|
|
assert (
|
|
response._hidden_params["additional_headers"]["x-litellm-attempted-fallbacks"] == expected_attempted_fallbacks
|
|
)
|
|
|
|
|
|
class MyCustomHandler(CustomLogger):
|
|
success: bool = False
|
|
failure: bool = False
|
|
previous_models: int = 0
|
|
|
|
def log_pre_api_call(self, model, messages, kwargs):
|
|
print(f"Pre-API Call")
|
|
print(
|
|
f"previous_models: {kwargs['litellm_params']['metadata'].get('previous_models', None)}"
|
|
)
|
|
self.previous_models = len(
|
|
kwargs["litellm_params"]["metadata"].get("previous_models", [])
|
|
) # {"previous_models": [{"model": litellm_model_name, "exception_type": AuthenticationError, "exception_string": <complete_traceback>}]}
|
|
print(f"self.previous_models: {self.previous_models}")
|
|
|
|
def log_post_api_call(self, kwargs, response_obj, start_time, end_time):
|
|
print(
|
|
f"Post-API Call - response object: {response_obj}; model: {kwargs['model']}"
|
|
)
|
|
|
|
def log_stream_event(self, kwargs, response_obj, start_time, end_time):
|
|
print(f"On Stream")
|
|
|
|
def async_log_stream_event(self, kwargs, response_obj, start_time, end_time):
|
|
print(f"On Stream")
|
|
|
|
def log_success_event(self, kwargs, response_obj, start_time, end_time):
|
|
print(f"On Success")
|
|
|
|
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
|
|
print(f"On Success")
|
|
|
|
def log_failure_event(self, kwargs, response_obj, start_time, end_time):
|
|
print(f"On Failure")
|
|
|
|
|
|
def test_async_fallbacks(caplog, respx_mock: respx.MockRouter, monkeypatch):
|
|
monkeypatch.setattr(litellm, "set_verbose", False)
|
|
monkeypatch.setattr(litellm, "success_callback", [])
|
|
monkeypatch.setattr(litellm, "failure_callback", [])
|
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
|
respx_mock.post("https://api.openai.com/v1/chat/completions").mock(
|
|
return_value=httpx.Response(
|
|
401,
|
|
json={
|
|
"error": {
|
|
"message": "Incorrect API key provided: bad-key.",
|
|
"type": "invalid_request_error",
|
|
"code": "invalid_api_key",
|
|
}
|
|
},
|
|
)
|
|
)
|
|
verbose_router_logger.setLevel(level=logging.INFO)
|
|
verbose_logger.setLevel(logging.CRITICAL + 1)
|
|
verbose_proxy_logger.setLevel(logging.CRITICAL + 1)
|
|
model_list = [
|
|
{
|
|
"model_name": "azure/gpt-3.5-turbo",
|
|
"litellm_params": {
|
|
"model": "azure/gpt-4.1-mini",
|
|
"api_key": os.getenv("AZURE_AI_API_KEY"),
|
|
"api_version": os.getenv("AZURE_API_VERSION"),
|
|
"api_base": os.getenv("AZURE_AI_API_BASE"),
|
|
"mock_response": "Hello world",
|
|
},
|
|
"tpm": 240000,
|
|
"rpm": 1800,
|
|
},
|
|
{
|
|
"model_name": "gpt-3.5-turbo",
|
|
"litellm_params": {
|
|
"model": "gpt-3.5-turbo",
|
|
"api_key": "bad-key",
|
|
},
|
|
"tpm": 1000000,
|
|
"rpm": 9000,
|
|
},
|
|
]
|
|
|
|
router = Router(
|
|
model_list=model_list,
|
|
fallbacks=[{"gpt-3.5-turbo": ["azure/gpt-3.5-turbo"]}],
|
|
num_retries=1,
|
|
)
|
|
|
|
user_message = "Hello, how are you?"
|
|
messages = [{"content": user_message, "role": "user"}]
|
|
|
|
async def _make_request():
|
|
try:
|
|
await router.acompletion(model="gpt-3.5-turbo", messages=messages, max_tokens=1)
|
|
router.reset()
|
|
except litellm.Timeout:
|
|
pass
|
|
except Exception as e:
|
|
pytest.fail(f"An exception occurred: {e}")
|
|
finally:
|
|
router.reset()
|
|
await close_litellm_async_clients()
|
|
|
|
asyncio.run(_make_request())
|
|
captured_logs = [rec.message for rec in caplog.records]
|
|
|
|
captured_logs = [
|
|
log
|
|
for log in captured_logs
|
|
if "Task exception was never retrieved" not in log
|
|
and "Task was destroyed but it is pending" not in log
|
|
and "get_available_deployment" not in log
|
|
and "Selected deployment for model" not in log
|
|
and "in the Langfuse queue" not in log
|
|
and "Unclosed client session" not in log
|
|
and "Unclosed connector" not in log
|
|
]
|
|
|
|
print("\n Captured caplog records - ", captured_logs)
|
|
|
|
expected_logs = [
|
|
"Falling back to model_group = azure/gpt-3.5-turbo",
|
|
"litellm.acompletion(model=azure/gpt-4.1-mini)\x1b[32m 200 OK\x1b[0m",
|
|
"Successful fallback b/w models.",
|
|
]
|
|
|
|
assert captured_logs[-3:] == expected_logs
|
|
|
|
|
|
def _fallback_router(
|
|
fallbacks: list[dict[str, list[str]]] | None = None,
|
|
default_fallbacks: list[str] | None = None,
|
|
) -> Router:
|
|
return Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "primary",
|
|
"litellm_params": {
|
|
"model": "openai/primary",
|
|
"api_key": "test-key",
|
|
"api_base": "https://fallback-migration.local/v1",
|
|
},
|
|
},
|
|
{
|
|
"model_name": "backup",
|
|
"litellm_params": {
|
|
"model": "openai/backup",
|
|
"api_key": "test-key",
|
|
"api_base": "https://fallback-migration.local/v1",
|
|
},
|
|
},
|
|
],
|
|
fallbacks=fallbacks if fallbacks is not None else [{"primary": ["backup"]}],
|
|
default_fallbacks=default_fallbacks,
|
|
num_retries=0,
|
|
)
|
|
|
|
|
|
def _fallback_chat_response(model: str, content: str) -> httpx.Response:
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"id": "chatcmpl-fallback-migration",
|
|
"object": "chat.completion",
|
|
"created": 1,
|
|
"model": model,
|
|
"choices": [
|
|
{
|
|
"index": 0,
|
|
"message": {"role": "assistant", "content": content},
|
|
"finish_reason": "stop",
|
|
}
|
|
],
|
|
},
|
|
)
|
|
|
|
|
|
def _fallback_unavailable_response(status_code: int = 503) -> httpx.Response:
|
|
return httpx.Response(
|
|
status_code,
|
|
json={
|
|
"error": {
|
|
"message": "primary unavailable",
|
|
"type": "server_error",
|
|
"code": "service_unavailable",
|
|
}
|
|
},
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_static_fallback_routes_after_service_unavailable(
|
|
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
|
router: Final = _fallback_router()
|
|
route: Final = respx_mock.post("https://fallback-migration.local/v1/chat/completions").mock(
|
|
side_effect=(
|
|
_fallback_unavailable_response(),
|
|
_fallback_chat_response("backup", "served by backup"),
|
|
)
|
|
)
|
|
|
|
response: Final = await router.acompletion(
|
|
model="primary", messages=[{"role": "user", "content": "fallback prompt"}]
|
|
)
|
|
|
|
outbound_models: Final = tuple(json.loads(call.request.content)["model"] for call in route.calls)
|
|
assert outbound_models == ("primary", "backup")
|
|
assert response.choices[0].message.content == "served by backup"
|
|
|
|
|
|
def test_dynamic_fallback_routes_sync_request(respx_mock: respx.MockRouter) -> None:
|
|
router: Final = _fallback_router(fallbacks=[])
|
|
route: Final = respx_mock.post("https://fallback-migration.local/v1/chat/completions").mock(
|
|
side_effect=(
|
|
_fallback_unavailable_response(),
|
|
_fallback_chat_response("backup", "served by backup"),
|
|
)
|
|
)
|
|
|
|
response: Final = router.completion(
|
|
model="primary",
|
|
messages=[{"role": "user", "content": "fallback prompt"}],
|
|
fallbacks=[{"primary": ["backup"]}],
|
|
)
|
|
|
|
outbound_models: Final = tuple(json.loads(call.request.content)["model"] for call in route.calls)
|
|
assert outbound_models == ("primary", "backup")
|
|
assert response.choices[0].message.content == "served by backup"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dynamic_fallback_routes_async_request(
|
|
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
|
router: Final = _fallback_router(fallbacks=[])
|
|
route: Final = respx_mock.post("https://fallback-migration.local/v1/chat/completions").mock(
|
|
side_effect=(
|
|
_fallback_unavailable_response(),
|
|
_fallback_chat_response("backup", "served by backup"),
|
|
)
|
|
)
|
|
|
|
response: Final = await router.acompletion(
|
|
model="primary",
|
|
messages=[{"role": "user", "content": "fallback prompt"}],
|
|
fallbacks=[{"primary": ["backup"]}],
|
|
)
|
|
|
|
outbound_models: Final = tuple(json.loads(call.request.content)["model"] for call in route.calls)
|
|
assert outbound_models == ("primary", "backup")
|
|
assert response.choices[0].message.content == "served by backup"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_disable_fallbacks_stops_after_primary_error(
|
|
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
|
router: Final = _fallback_router()
|
|
route: Final = respx_mock.post("https://fallback-migration.local/v1/chat/completions").mock(
|
|
return_value=_fallback_unavailable_response()
|
|
)
|
|
|
|
with pytest.raises(litellm.ServiceUnavailableError):
|
|
await router.acompletion(
|
|
model="primary",
|
|
messages=[{"role": "user", "content": "fallback prompt"}],
|
|
disable_fallbacks=True,
|
|
)
|
|
|
|
assert route.call_count == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fallback_preserves_original_messages(
|
|
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
|
router: Final = _fallback_router()
|
|
route: Final = respx_mock.post("https://fallback-migration.local/v1/chat/completions").mock(
|
|
side_effect=(
|
|
_fallback_unavailable_response(),
|
|
_fallback_chat_response("backup", "served by backup"),
|
|
)
|
|
)
|
|
messages: Final = [{"role": "user", "content": "preserve this prompt"}]
|
|
|
|
await router.acompletion(model="primary", messages=messages)
|
|
|
|
outbound_messages: Final = tuple(json.loads(call.request.content)["messages"] for call in route.calls)
|
|
assert outbound_messages == (messages, messages)
|
|
|
|
|
|
@pytest.mark.parametrize("sync_mode", [True, False], ids=["sync", "async"])
|
|
@pytest.mark.asyncio
|
|
async def test_embedding_fallback_routes_after_primary_error(
|
|
sync_mode: bool, respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
|
router: Final = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "primary-embedding",
|
|
"litellm_params": {
|
|
"model": "openai/text-embedding-3-small",
|
|
"api_key": "test-key",
|
|
"api_base": "https://fallback-migration.local/v1",
|
|
},
|
|
"model_info": {"id": "primary-embedding-deployment"},
|
|
},
|
|
{
|
|
"model_name": "backup-embedding",
|
|
"litellm_params": {
|
|
"model": "openai/text-embedding-3-small",
|
|
"api_key": "test-key",
|
|
"api_base": "https://fallback-migration.local/v1",
|
|
},
|
|
"model_info": {"id": "backup-embedding-deployment"},
|
|
},
|
|
],
|
|
fallbacks=[{"primary-embedding": ["backup-embedding"]}],
|
|
num_retries=0,
|
|
)
|
|
route: Final = respx_mock.post("https://fallback-migration.local/v1/embeddings").mock(
|
|
side_effect=(
|
|
_fallback_unavailable_response(401),
|
|
httpx.Response(
|
|
200,
|
|
json={
|
|
"object": "list",
|
|
"data": [{"object": "embedding", "index": 0, "embedding": [0.1, 0.2]}],
|
|
"model": "text-embedding-3-small",
|
|
"usage": {"prompt_tokens": 1, "total_tokens": 1},
|
|
},
|
|
),
|
|
)
|
|
)
|
|
|
|
if sync_mode:
|
|
response: Final = router.embedding(model="primary-embedding", input="fallback prompt")
|
|
else:
|
|
response: Final = await router.aembedding(model="primary-embedding", input="fallback prompt")
|
|
|
|
outbound_models: Final = tuple(json.loads(call.request.content)["model"] for call in route.calls)
|
|
assert outbound_models == ("text-embedding-3-small", "text-embedding-3-small")
|
|
assert len(response.data) == 1
|
|
assert response._hidden_params["model_id"] == "backup-embedding-deployment"
|
|
assert route.call_count == 2
|
|
|
|
|
|
def test_model_id_fallback_returns_selected_deployment(
|
|
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
|
router: Final = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "primary",
|
|
"litellm_params": {
|
|
"model": "openai/primary",
|
|
"api_key": "test-key",
|
|
"api_base": "https://fallback-migration.local/v1",
|
|
},
|
|
"model_info": {"id": "primary-deployment"},
|
|
},
|
|
{
|
|
"model_name": "backup",
|
|
"litellm_params": {
|
|
"model": "openai/backup",
|
|
"api_key": "test-key",
|
|
"api_base": "https://fallback-migration.local/v1",
|
|
},
|
|
"model_info": {"id": "deployment-123"},
|
|
}
|
|
],
|
|
fallbacks=[{"primary": ["deployment-123"]}],
|
|
num_retries=0,
|
|
)
|
|
route: Final = respx_mock.post("https://fallback-migration.local/v1/chat/completions").mock(
|
|
side_effect=(
|
|
_fallback_unavailable_response(),
|
|
_fallback_chat_response("backup", "fallback by deployment id"),
|
|
)
|
|
)
|
|
|
|
response: Final = router.completion(
|
|
model="primary",
|
|
messages=[{"role": "user", "content": "fallback prompt"}],
|
|
)
|
|
outbound_models: Final = tuple(json.loads(call.request.content)["model"] for call in route.calls)
|
|
|
|
assert response._hidden_params["model_id"] == "deployment-123"
|
|
assert outbound_models == ("primary", "backup")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_default_fallback_serves_after_primary_error(
|
|
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
|
router: Final = _fallback_router(fallbacks=[], default_fallbacks=["backup"])
|
|
route: Final = respx_mock.post("https://fallback-migration.local/v1/chat/completions").mock(
|
|
side_effect=(
|
|
_fallback_unavailable_response(),
|
|
_fallback_chat_response("backup", "served by default fallback"),
|
|
)
|
|
)
|
|
|
|
response: Final = await router.acompletion(
|
|
model="primary", messages=[{"role": "user", "content": "fallback prompt"}]
|
|
)
|
|
|
|
outbound_models: Final = tuple(json.loads(call.request.content)["model"] for call in route.calls)
|
|
assert outbound_models == ("primary", "backup")
|
|
assert response.choices[0].message.content == "served by default fallback"
|
|
|
|
|
|
def test_usage_based_routing_falls_back_after_rpm_exhaustion() -> None:
|
|
router: Final = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": model_name,
|
|
"litellm_params": {
|
|
"model": "openai/gpt-4o-mini",
|
|
"api_key": "test-key",
|
|
},
|
|
"model_info": {"id": deployment_id},
|
|
"rpm": rpm,
|
|
}
|
|
for model_name, deployment_id, rpm in (
|
|
("primary", "1", 1),
|
|
("backup", "2", 1),
|
|
("limited", "3", 0),
|
|
("available", "4", 10),
|
|
)
|
|
],
|
|
fallbacks=[
|
|
{"primary": ["backup"]},
|
|
{"backup": ["limited"]},
|
|
{"limited": ["available"]},
|
|
],
|
|
routing_strategy="usage-based-routing-v2",
|
|
num_retries=0,
|
|
)
|
|
responses: Final = tuple(
|
|
router.completion(
|
|
model="primary",
|
|
messages=[{"role": "user", "content": "usage-based fallback"}],
|
|
mock_response="fallback response",
|
|
)
|
|
for _ in range(11)
|
|
)
|
|
|
|
assert responses[0]._hidden_params["model_id"] == "1"
|
|
assert responses[-1]._hidden_params["model_id"] == "4"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fallback_request_does_not_retry_primary_when_retries_are_disabled(
|
|
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
|
router: Final = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "primary",
|
|
"litellm_params": {
|
|
"model": "openai/primary",
|
|
"api_key": "test-key",
|
|
"api_base": "https://fallback-migration.local/v1",
|
|
},
|
|
}
|
|
],
|
|
num_retries=0,
|
|
)
|
|
route: Final = respx_mock.post("https://fallback-migration.local/v1/chat/completions").mock(
|
|
return_value=_fallback_unavailable_response()
|
|
)
|
|
|
|
with pytest.raises(litellm.ServiceUnavailableError):
|
|
await router.acompletion(
|
|
model="primary", messages=[{"role": "user", "content": "retry control"}]
|
|
)
|
|
|
|
assert route.call_count == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_default_and_model_fallbacks_do_not_repeat_failed_models(
|
|
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
|
router: Final = _fallback_router(default_fallbacks=["primary"])
|
|
route: Final = respx_mock.post("https://fallback-migration.local/v1/chat/completions").mock(
|
|
side_effect=(
|
|
_fallback_unavailable_response(401),
|
|
_fallback_unavailable_response(),
|
|
_fallback_chat_response("primary", "unexpected repeat"),
|
|
)
|
|
)
|
|
|
|
with pytest.raises(litellm.AuthenticationError):
|
|
await router.acompletion(
|
|
model="primary", messages=[{"role": "user", "content": "fallback prompt"}]
|
|
)
|
|
|
|
outbound_models: Final = tuple(json.loads(call.request.content)["model"] for call in route.calls)
|
|
assert outbound_models == ("primary", "backup")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unknown_default_fallback_raises_after_primary_failure(
|
|
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
|
monkeypatch.setattr(litellm, "expose_router_debug_in_errors", True)
|
|
router: Final = _fallback_router(fallbacks=[], default_fallbacks=["missing"])
|
|
route: Final = respx_mock.post("https://fallback-migration.local/v1/chat/completions").mock(
|
|
return_value=_fallback_unavailable_response()
|
|
)
|
|
|
|
with pytest.raises(litellm.ServiceUnavailableError) as exc_info:
|
|
await router.acompletion(
|
|
model="primary", messages=[{"role": "user", "content": "fallback prompt"}]
|
|
)
|
|
|
|
assert route.call_count == 1
|
|
assert "missing" in str(exc_info.value)
|
|
|
|
|
|
def _context_error_response_stream(
|
|
*, event_type: str = "error", preceding_event: dict[str, object] | None = None, completed: bool = False
|
|
) -> httpx.Response:
|
|
response: Final = {
|
|
"id": "resp_backup" if completed else "resp_primary",
|
|
"object": "response",
|
|
"created_at": 1700000000,
|
|
"model": "backup" if completed else "primary",
|
|
"status": "completed" if completed else "in_progress",
|
|
"output": [],
|
|
"parallel_tool_calls": True,
|
|
"tool_choice": "auto",
|
|
"tools": [],
|
|
"temperature": 1.0,
|
|
"top_p": 1.0,
|
|
}
|
|
error: Final = {
|
|
"type": "invalid_request_error",
|
|
"code": "context_length_exceeded",
|
|
"message": "Your input exceeds the context window of this model. Please adjust your input and try again.",
|
|
}
|
|
terminal: Final = (
|
|
{"type": "response.completed", "response": response, "sequence_number": 2}
|
|
if completed
|
|
else {"type": event_type, "error": error, "sequence_number": 2}
|
|
if event_type == "error"
|
|
else {"type": event_type, "response": {**response, "status": "failed", "error": error}, "sequence_number": 2}
|
|
)
|
|
events: Final = (
|
|
{"type": "response.created", "response": response, "sequence_number": 0},
|
|
*((preceding_event,) if preceding_event is not None else ()),
|
|
terminal,
|
|
)
|
|
return httpx.Response(
|
|
200,
|
|
headers={"content-type": "text/event-stream"},
|
|
content="".join(f"data: {json.dumps(event)}\n\n" for event in events).encode(),
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("event_type", ("error", "response.failed"))
|
|
@pytest.mark.parametrize("context_fallback", (False, True))
|
|
async def test_responses_context_error_uses_the_configured_fallback_before_output(
|
|
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, event_type: str, context_fallback: bool
|
|
) -> None:
|
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
|
router: Final = _fallback_router(fallbacks=[{"primary": ["missing"]}] if context_fallback else None)
|
|
route: Final = respx_mock.post("https://fallback-migration.local/v1/responses").mock(
|
|
side_effect=(
|
|
_context_error_response_stream(event_type=event_type),
|
|
_context_error_response_stream(completed=True),
|
|
)
|
|
)
|
|
stream: Final = await router.aresponses(
|
|
model="primary",
|
|
input="oversized prompt",
|
|
stream=True,
|
|
context_window_fallbacks=[{"primary": ["backup"]}] if context_fallback else None,
|
|
)
|
|
events: Final = [event async for event in stream]
|
|
assert tuple(json.loads(call.request.content)["model"] for call in route.calls) == ("primary", "backup")
|
|
assert tuple(event.type for event in events) == ("response.created", "response.completed")
|
|
assert events[0].response.id == events[-1].response.id
|
|
assert events[-1].response.model == "backup"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("barrier", ("text", "tool", "disabled", "unconfigured"))
|
|
async def test_responses_context_error_does_not_restart_committed_or_disabled_streams(
|
|
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, barrier: str
|
|
) -> None:
|
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
|
router: Final = _fallback_router(fallbacks=[] if barrier == "unconfigured" else None)
|
|
preceding: Final = {
|
|
"text": {
|
|
"type": "response.output_text.delta",
|
|
"delta": "already visible",
|
|
"item_id": "msg_primary",
|
|
"output_index": 0,
|
|
"content_index": 0,
|
|
"sequence_number": 1,
|
|
},
|
|
"tool": {
|
|
"type": "response.output_item.added",
|
|
"item": {
|
|
"type": "function_call", "id": "fc_primary", "call_id": "call_primary",
|
|
"name": "lookup", "arguments": "",
|
|
},
|
|
"output_index": 0,
|
|
"sequence_number": 1,
|
|
},
|
|
}.get(barrier)
|
|
route: Final = respx_mock.post("https://fallback-migration.local/v1/responses").mock(
|
|
return_value=_context_error_response_stream(preceding_event=preceding)
|
|
)
|
|
stream: Final = await router.aresponses(
|
|
model="primary", input="oversized prompt", stream=True, disable_fallbacks=barrier == "disabled"
|
|
)
|
|
with pytest.raises(litellm.ContextWindowExceededError):
|
|
async for _ in stream:
|
|
pass
|
|
assert route.call_count == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("sync_mode", (False, True))
|
|
@pytest.mark.parametrize("emission", ("role", "text", "tool"))
|
|
async def test_chat_context_error_falls_back_after_role_only_but_not_after_output(
|
|
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, sync_mode: bool, emission: str
|
|
) -> None:
|
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
|
router: Final = _fallback_router(fallbacks=[{"primary": ["missing"]}])
|
|
delta: Final = (
|
|
{"role": "assistant", "tool_calls": [{
|
|
"index": 0, "id": "call_primary", "type": "function", "function": {"name": "lookup", "arguments": ""}
|
|
}]}
|
|
if emission == "tool"
|
|
else {"role": "assistant", "content": "visible" if emission == "text" else ""}
|
|
)
|
|
first_chunk: Final = {
|
|
"id": "chatcmpl-primary", "object": "chat.completion.chunk", "created": 1700000000, "model": "primary",
|
|
"choices": [{"index": 0, "delta": delta}],
|
|
}
|
|
error: Final = {"error": {
|
|
"type": "invalid_request_error", "code": "context_length_exceeded",
|
|
"message": "This model's maximum context length is 128000 tokens. Your messages resulted in 130000 tokens.",
|
|
}}
|
|
backup: Final = {
|
|
"id": "chatcmpl-backup", "object": "chat.completion.chunk", "created": 1700000000, "model": "backup",
|
|
"choices": [{"index": 0, "delta": {"role": "assistant", "content": "backup answer"}, "finish_reason": "stop"}],
|
|
}
|
|
route: Final = respx_mock.post("https://fallback-migration.local/v1/chat/completions").mock(
|
|
side_effect=(
|
|
httpx.Response(200, content=f"data: {json.dumps(first_chunk)}\n\ndata: {json.dumps(error)}\n\n".encode()),
|
|
httpx.Response(200, content=f"data: {json.dumps(backup)}\n\ndata: [DONE]\n\n".encode()),
|
|
)
|
|
)
|
|
request: Final = {
|
|
"model": "primary", "messages": [{"role": "user", "content": "oversized prompt"}], "stream": True,
|
|
"context_window_fallbacks": [{"primary": ["backup"]}],
|
|
}
|
|
stream: Final = router.completion(**request) if sync_mode else await router.acompletion(**request)
|
|
if emission != "role":
|
|
with pytest.raises(litellm.ContextWindowExceededError):
|
|
tuple(stream) if sync_mode else tuple([chunk async for chunk in stream])
|
|
assert route.call_count == 1
|
|
return
|
|
chunks: Final = tuple(stream) if sync_mode else tuple([chunk async for chunk in stream])
|
|
assert tuple(json.loads(call.request.content)["model"] for call in route.calls) == ("primary", "backup")
|
|
assert "".join(chunk.choices[0].delta.content or "" for chunk in chunks if chunk.choices) == "backup answer"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"wrapped,has_output,generated_content,disqualifying,recoverable",
|
|
(
|
|
(False, False, "", False, True),
|
|
(False, True, "", False, False),
|
|
(True, False, "", False, True),
|
|
(True, True, "", False, False),
|
|
(True, False, "already visible", False, False),
|
|
(True, False, "", True, False),
|
|
),
|
|
)
|
|
def test_context_stream_fallback_obeys_all_output_receipts(
|
|
wrapped: bool, has_output: bool, generated_content: str, disqualifying: bool, recoverable: bool
|
|
) -> None:
|
|
from litellm.exceptions import MidStreamFallbackError
|
|
from litellm.router import _context_window_stream_fallback_error
|
|
|
|
original: Final = litellm.ContextWindowExceededError(message="too long", model="primary", llm_provider="openai")
|
|
error: Final = (
|
|
MidStreamFallbackError(
|
|
message="too long", model="primary", llm_provider="openai", original_exception=original,
|
|
is_pre_first_chunk=True, generated_content=generated_content, emitted_disqualifying_content=disqualifying,
|
|
)
|
|
if wrapped
|
|
else original
|
|
)
|
|
if not recoverable:
|
|
with pytest.raises(litellm.ContextWindowExceededError) as raised:
|
|
_context_window_stream_fallback_error(error, "primary", "openai", has_output)
|
|
assert raised.value is original
|
|
return
|
|
recovered: Final = _context_window_stream_fallback_error(error, "primary", "openai", has_output)
|
|
assert recovered.original_exception is original
|
|
assert recovered.is_pre_first_chunk is True
|
|
if wrapped:
|
|
assert recovered is error
|
|
|
|
|
|
def test_context_stream_fallback_preserves_other_errors_for_existing_continuation_policy() -> None:
|
|
from litellm.exceptions import MidStreamFallbackError
|
|
from litellm.router import _context_window_stream_fallback_error
|
|
|
|
error: Final = MidStreamFallbackError(
|
|
message="provider unavailable", model="primary", llm_provider="openai", generated_content="already visible"
|
|
)
|
|
assert _context_window_stream_fallback_error(error, "primary", "openai", True) is error
|