litellm/tests/unit/test_router_order_fallback.py
devin-ai-integration[bot] e136e91273
Some checks are pending
CodSpeed Benchmarks / benchmarks (push) Waiting to run
Deploy main to gateway-dev / dispatch (push) Waiting to run
Publish lint base counts / publish (basedpyright, scripts/type_check_gate.py) (push) Waiting to run
Publish lint base counts / publish (openapi-docs, scripts/openapi_docs_gate.py) (push) Waiting to run
Publish lint base counts / publish (ruff-strict, scripts/ruff_strict_gate.py) (push) Waiting to run
Publish lint base counts / publish (test-quality, scripts/test_quality_gate.py) (push) Waiting to run
Publish lint base counts / publish (type-discipline, scripts/type_discipline_gate.py) (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
unit / lens-python-310 (push) Blocked by required conditions
unit / caching-local (push) Blocked by required conditions
unit / core-utils (push) Blocked by required conditions
unit / endpoints (push) Blocked by required conditions
unit / integrations (push) Blocked by required conditions
unit / llms-bedrock (push) Blocked by required conditions
unit / llms-providers (push) Blocked by required conditions
unit / mcp-elicitation (push) Blocked by required conditions
unit / proxy-feature-endpoints (push) Blocked by required conditions
unit / router (push) Blocked by required conditions
unit / endpoints-and-responses (push) Blocked by required conditions
unit / enterprise-routing (push) Blocked by required conditions
unit / guardrails-hooks (push) Blocked by required conditions
unit / logging-misc (push) Blocked by required conditions
unit / root (push) Blocked by required conditions
unit / proxy-utils (push) Blocked by required conditions
unit / assert-shard-coverage (push) Waiting to run
unit / enterprise-managed-files (push) Blocked by required conditions
unit / proxy-server (push) Blocked by required conditions
unit / rust-bridge (push) Waiting to run
unit / helm (push) Waiting to run
unit / OpenAI 2.20.0 compatibility (push) Waiting to run
unit / OpenAI 3.0.0 compatibility (push) Waiting to run
unit / coverage (push) Blocked by required conditions
unit / unit passed (push) Blocked by required conditions
LiteLLM Rust / rust-lint (push) Waiting to run
LiteLLM Rust / rust-test (push) Waiting to run
LiteLLM Rust / rust-wheel (push) Waiting to run
Terraform Provider / gofmt, vet, build, test (push) Waiting to run
Terraform Provider / Provider endpoints vs proxy OpenAPI schema (push) Waiting to run
unit / enterprise-package (push) Blocked by required conditions
unit / enterprise-repositories-secrets (push) Blocked by required conditions
unit / llms-anthropic (push) Blocked by required conditions
unit / llms-openai-meta (push) Blocked by required conditions
unit / llms-vertex-ai (push) Blocked by required conditions
unit / mcp-server (push) Blocked by required conditions
unit / proxy-auth (push) Blocked by required conditions
unit / proxy-endpoints (push) Blocked by required conditions
unit / proxy-extras (push) Blocked by required conditions
unit / proxy-hooks-client (push) Blocked by required conditions
unit / proxy-infra (push) Blocked by required conditions
unit / proxy-infra-root (push) Blocked by required conditions
unit / responses-caching-types (push) Blocked by required conditions
unit / rust-bridge-harness (push) Blocked by required conditions
unit / auth-checks (push) Blocked by required conditions
unit / budgets (push) Blocked by required conditions
unit / custom-logging (push) Blocked by required conditions
unit / db-and-spend (push) Blocked by required conditions
unit / jwt-and-keys (push) Blocked by required conditions
unit / key-generation (push) Blocked by required conditions
unit / mcp-oauth (push) Blocked by required conditions
unit / proxy-runtime (push) Blocked by required conditions
unit / proxy-server-core (push) Blocked by required conditions
unit / ui-unit (push) Waiting to run
unit / docs (push) Waiting to run
unit / OpenAI 3.25.0 compatibility (push) Waiting to run
unit / redis-py 5.3.1 compatibility (push) Blocked by required conditions
unit / redis-py 6.4.0 compatibility (push) Blocked by required conditions
unit / redis-py 7.4.1 compatibility (push) Blocked by required conditions
unit / redis-py 8.0.1 compatibility (push) Blocked by required conditions
GitHub Actions Security Analysis / zizmor (push) Waiting to run
fix(router): keep the prompt caching affinity pin alive as long as the prefix's cache_control ttl (#46084)
* fix(router): keep the prompt caching affinity pin alive as long as the prefix's cache_control ttl

A prefix marked cache_control ttl 1h lost its pinned deployment after the fixed 300 s, so the next request went to another deployment and paid a cold cache. The pin TTL now follows the longest ephemeral breakpoint in the cacheable prefix, tools included: 3600 s for a 1h breakpoint and 300 s otherwise.

* fix(router): give prompt cache pins their own tier and honour any declared ttl

Pins now live in a DualCache of their own instead of the router's 200-slot
in-memory tier, so an hour-long pin no longer evicts cooldowns and rate windows.
The pin lifetime follows any <n>s|m|h cache_control ttl, floored at 300 s, not
only the literal "1h".

* fix(router): hash the pin ttl off the event loop and flush prompt cache pins with the router cache

Compute the cache_control ttl scan through offload_token_count beside the
prefix hash, clear the dedicated pin cache in Router.flush_cache, and point
the order-fallback and priority-group pin tests at the pin cache they read

* fix(router): pin 1h prefixes only on 1h-capable models, cap the pin at 1h, and give pins 10k in-memory slots

* fix(router): match the 1h pin gate to what Bedrock forwards

Bedrock deployments now decide the 1h pin with is_claude_4_5_on_bedrock,
the same check that decides whether the 1h ttl reaches Bedrock, so an
application inference profile ARN keeps a 5 minute pin whatever its
base_model prices

* test(router): give the anthropic_messages pin fixture its provider

* fix(router): let each pin write set its own lifetime and require a positive 1h price

In a group mixing 1h and 5m deployments the in-memory pin kept the lifetime of its first write, so a 5m deployment could hold an hour-long pin. Each write now clears the in-memory entry first, matching Redis. The Bedrock gate uses supports_bedrock_prompt_cache_ttl after #46058, and a zero 1h price no longer counts as 1h support.

* test(router): integration cells for the one-hour prompt cache pin lifetime

Cover every surface the pin lifetime touches: chat and /v1/messages through
both SDKs, the responses bridge, response-cache hits, each provider family's
1h gate, malformed and duplicated ttl values, rewrites of one prefix, mixed
and cross-group routing, concurrency, every Redis attach path, the in-memory
slot pressure without Redis reads, a Redis outage and a killed worker

* fix(caching): bound the in-memory expiry heap and count prompt cache pins in the memory debug routes

* test(integration): cover prompt cache pins in the memory debug routes and the bounded expiration heap

* fix(caching): serialize in-memory cache writes so a heap rebuild cannot lose another thread's expiry entry

* fix(caching): keep the in-memory cache bounded when entries have no usable expiry

* test(caching): cover the response cache bound when cached entries have no usable ttl

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
2026-10-14 21:10:09 -07:00

830 lines
28 KiB
Python

"""
Tests for order-based fallback routing.
When deployments have `order` set in litellm_params, lower order deployments
should be tried first, and higher order deployments should be used as fallbacks
when lower order deployments fail.
"""
import json
from typing import Final, Optional
import httpx
import pytest
import respx
from openai import AsyncOpenAI
import litellm
from litellm import Router
from litellm.integrations.custom_logger import CustomLogger
from litellm.router_utils.prompt_caching_cache import PromptCachingCache
from litellm.types.router import RouterRateLimitError
from litellm.utils import get_deployment_order, get_order_filtered_deployments
# ---------------------------------------------------------------------------
# Unit tests for get_order_filtered_deployments
# ---------------------------------------------------------------------------
class TestGetOrderFilteredDeployments:
def _make_deployment(self, order: Optional[int], dep_id: str) -> dict:
params: dict = {"model": "gpt-4o", "api_key": "key"}
if order is not None:
params["order"] = order
return {
"model_name": "test-model",
"litellm_params": params,
"model_info": {"id": dep_id},
}
def test_returns_min_order_group(self):
deps = [
self._make_deployment(1, "a"),
self._make_deployment(2, "b"),
self._make_deployment(1, "c"),
]
result = get_order_filtered_deployments(deps)
assert len(result) == 2
assert all(d["model_info"]["id"] in ("a", "c") for d in result)
def test_target_order_filters_to_exact_level(self):
deps = [
self._make_deployment(1, "a"),
self._make_deployment(2, "b"),
self._make_deployment(3, "c"),
]
result = get_order_filtered_deployments(deps, target_order=2)
assert len(result) == 1
assert result[0]["model_info"]["id"] == "b"
def test_target_order_no_match_returns_empty(self):
deps = [
self._make_deployment(1, "a"),
self._make_deployment(2, "b"),
]
result = get_order_filtered_deployments(deps, target_order=99)
assert result == []
def test_target_order_no_match_does_not_reselect_lower_order(self):
deps = [
self._make_deployment(1, "a"),
self._make_deployment(2, "b"),
]
remaining_after_pre_call = [deps[0]]
result = get_order_filtered_deployments(remaining_after_pre_call, target_order=2)
assert result == []
def test_no_order_set_returns_all(self):
deps = [
self._make_deployment(None, "a"),
self._make_deployment(None, "b"),
]
result = get_order_filtered_deployments(deps)
assert len(result) == 2
def test_empty_list(self):
result = get_order_filtered_deployments([])
assert result == []
def test_single_order_returns_all_with_that_order(self):
deps = [
self._make_deployment(1, "a"),
self._make_deployment(1, "b"),
]
result = get_order_filtered_deployments(deps)
assert len(result) == 2
def test_get_deployment_order_returns_unvalidated_order():
assert get_deployment_order({"litellm_params": {"order": "first"}}) == "first"
# ---------------------------------------------------------------------------
# Integration tests for order-based fallback in Router
# ---------------------------------------------------------------------------
def test_router_order_without_pre_call_checks():
"""Order filtering should work even when enable_pre_call_checks=False (default)."""
router = Router(
model_list=[
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "key",
"mock_response": "from order 1",
"order": 1,
},
"model_info": {"id": "1"},
},
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "key",
"mock_response": "from order 2",
"order": 2,
},
"model_info": {"id": "2"},
},
],
num_retries=0,
enable_pre_call_checks=False,
)
for _ in range(20):
response = router.completion(
model="test-model",
messages=[{"role": "user", "content": "hi"}],
)
assert response._hidden_params["model_id"] == "1"
def test_router_order_no_fallback_when_healthy():
"""When order=1 is healthy, order=2 should never be used."""
router = Router(
model_list=[
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "key",
"mock_response": "from order 1",
"order": 1,
},
"model_info": {"id": "1"},
},
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "key",
"mock_response": "from order 2",
"order": 2,
},
"model_info": {"id": "2"},
},
],
num_retries=0,
)
for _ in range(50):
response = router.completion(
model="test-model",
messages=[{"role": "user", "content": "hi"}],
)
assert response._hidden_params["model_id"] == "1"
@pytest.mark.asyncio
async def test_router_order_fallback_on_failure():
"""When order=1 fails, order=2 should be tried as fallback."""
router = Router(
model_list=[
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "bad-key",
"mock_response": Exception("connection error"),
"order": 1,
},
"model_info": {"id": "1"},
},
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "good-key",
"mock_response": "success from order 2",
"order": 2,
},
"model_info": {"id": "2"},
},
],
num_retries=0,
)
response = await router.acompletion(
model="test-model",
messages=[{"role": "user", "content": "hi"}],
)
assert response._hidden_params["model_id"] == "2"
@pytest.mark.asyncio
async def test_router_order_fallback_three_levels():
"""When order=1 and order=2 both fail, order=3 should be tried."""
router = Router(
model_list=[
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "bad",
"mock_response": Exception("fail 1"),
"order": 1,
},
"model_info": {"id": "1"},
},
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "bad",
"mock_response": Exception("fail 2"),
"order": 2,
},
"model_info": {"id": "2"},
},
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "good",
"mock_response": "success from order 3",
"order": 3,
},
"model_info": {"id": "3"},
},
],
num_retries=0,
)
response = await router.acompletion(
model="test-model",
messages=[{"role": "user", "content": "hi"}],
)
assert response._hidden_params["model_id"] == "3"
@pytest.mark.asyncio
async def test_router_order_fallback_then_external_fallback():
"""When all order levels fail, external fallbacks should be tried."""
router = Router(
model_list=[
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "bad",
"mock_response": Exception("fail order 1"),
"order": 1,
},
"model_info": {"id": "1"},
},
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "bad",
"mock_response": Exception("fail order 2"),
"order": 2,
},
"model_info": {"id": "2"},
},
{
"model_name": "fallback-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "good",
"mock_response": "success from external fallback",
},
"model_info": {"id": "fallback"},
},
],
fallbacks=[{"test-model": ["fallback-model"]}],
num_retries=0,
)
response = await router.acompletion(
model="test-model",
messages=[{"role": "user", "content": "hi"}],
)
assert response._hidden_params["model_id"] == "fallback"
@pytest.mark.asyncio
async def test_router_order_fallback_with_non_standard_fallbacks():
"""Non-standard fallback formats (e.g. fallbacks=["model-name"]) passed
per-request should still be tried after all order levels are exhausted."""
router = Router(
model_list=[
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "bad",
"mock_response": Exception("fail order 1"),
"order": 1,
},
"model_info": {"id": "1"},
},
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "bad",
"mock_response": Exception("fail order 2"),
"order": 2,
},
"model_info": {"id": "2"},
},
{
"model_name": "fallback-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "good",
"mock_response": "success from non-standard fallback",
},
"model_info": {"id": "fallback"},
},
],
num_retries=0,
)
response = await router.acompletion(
model="test-model",
messages=[{"role": "user", "content": "hi"}],
fallbacks=["fallback-model"], # non-standard format, passed per-request
)
assert response._hidden_params["model_id"] == "fallback"
@pytest.mark.asyncio
async def test_router_order_fallback_with_wildcard_model_group():
"""Wildcard model groups should also advance across order levels."""
router = Router(
model_list=[
{
"model_name": "openai/*",
"litellm_params": {
"model": "openai/*",
"api_key": "bad",
"mock_response": Exception("fail order 1"),
"order": 1,
},
"model_info": {"id": "1"},
},
{
"model_name": "openai/*",
"litellm_params": {
"model": "openai/*",
"api_key": "good",
"mock_response": "success from wildcard order 2",
"order": 2,
},
"model_info": {"id": "2"},
},
],
num_retries=0,
)
response = await router.acompletion(
model="openai/gpt-4.1-mini",
messages=[{"role": "user", "content": "hi"}],
)
assert response._hidden_params["model_id"] == "2"
@pytest.mark.asyncio
async def test_router_order_fallback_with_hidden_model_group_alias():
router = Router(
model_list=[
{
"model_name": "canonical-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "bad",
"mock_response": Exception("fail order 1"),
"order": 1,
},
"model_info": {"id": "1"},
},
{
"model_name": "canonical-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "good",
"mock_response": "success from order 2",
"order": 2,
},
"model_info": {"id": "2"},
},
],
model_group_alias={"hidden-alias": {"model": "canonical-model", "hidden": True}},
num_retries=0,
)
assert "hidden-alias" not in {deployment["model_name"] for deployment in router.get_model_list() or []}
response = await router.acompletion(
model="hidden-alias",
messages=[{"role": "user", "content": "hi"}],
)
assert response._hidden_params["model_id"] == "2"
@pytest.mark.asyncio
async def test_router_order_fallback_does_not_reselect_order_1_when_order_2_is_filtered_out():
class _DropOrder2(CustomLogger):
async def async_filter_deployments(
self, model, healthy_deployments, messages, request_kwargs=None, parent_otel_span=None
):
return [d for d in healthy_deployments if get_deployment_order(d) != 2]
drop_order_2: Final = _DropOrder2()
router = Router(
model_list=[
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "key",
"mock_response": "litellm.RateLimitError",
"order": 1,
},
"model_info": {"id": "1"},
},
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "key",
"mock_response": "success from order 2",
"order": 2,
},
"model_info": {"id": "2"},
},
],
num_retries=0,
)
litellm.callbacks.append(drop_order_2)
try:
with pytest.raises(RouterRateLimitError, match="No deployments available") as exc_info:
await router.acompletion(
model="test-model",
messages=[{"role": "user", "content": "hi"}],
)
assert "success from order 2" not in str(exc_info.value)
finally:
litellm.callbacks.remove(drop_order_2)
@pytest.mark.asyncio
async def test_router_order_fallback_ignores_prompt_cache_pin_on_target_order():
messages = [{"role": "user", "content": "word " * 5000}]
router = Router(
model_list=[
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "bad",
"mock_response": Exception("azure peak load"),
"order": 1,
},
"model_info": {"id": "1"},
},
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "good",
"mock_response": "success from order 2",
"order": 2,
},
"model_info": {"id": "2"},
},
],
num_retries=0,
optional_pre_call_checks=["prompt_caching"],
)
await PromptCachingCache(cache=router.prompt_caching_pin_cache).async_add_model_id(
model_id="1",
messages=messages,
tools=None,
)
response = await router.acompletion(model="test-model", messages=messages)
assert response._hidden_params["model_id"] == "2"
@pytest.mark.asyncio
async def test_router_order_fallback_retries_keep_target_order():
seen_target_orders: Final = []
class _RecordTargetOrder(CustomLogger):
async def async_filter_deployments(
self, model, healthy_deployments, messages, request_kwargs=None, parent_otel_span=None
):
seen_target_orders.append((request_kwargs or {}).get("_target_order"))
return healthy_deployments
recorder: Final = _RecordTargetOrder()
router = Router(
model_list=[
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "bad",
"mock_response": Exception("fail order 1"),
"order": 1,
},
"model_info": {"id": "1"},
},
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "bad",
"mock_response": Exception("fail order 2"),
"order": 2,
},
"model_info": {"id": "2"},
},
],
num_retries=1,
)
litellm.callbacks.append(recorder)
try:
with pytest.raises(Exception, match="fail order 2"):
await router.acompletion(
model="test-model",
messages=[{"role": "user", "content": "hi"}],
)
finally:
litellm.callbacks.remove(recorder)
assert seen_target_orders.count(2) >= 2
@pytest.mark.asyncio
async def test_generic_api_call_strips_target_order_from_provider_kwargs():
captured: Final = {}
async def _fake_provider(**provider_kwargs):
captured.update(provider_kwargs)
return "ok"
router = Router(
model_list=[
{
"model_name": "test-model",
"litellm_params": {"model": "gpt-4o", "api_key": "key", "order": 2},
"model_info": {"id": "2"},
},
],
)
response = await router._ageneric_api_call_with_fallbacks_helper(
model="test-model",
original_generic_function=_fake_provider,
_target_order=2,
messages=[{"role": "user", "content": "hi"}],
)
assert response == "ok"
assert captured["model"] == "gpt-4o"
assert "_target_order" not in captured
@pytest.mark.asyncio
async def test_text_completion_order_fallback_hop_does_not_send_target_order_upstream():
upstream_bodies: Final[list[dict]] = []
def _upstream(request: httpx.Request) -> httpx.Response:
upstream_bodies.append(json.loads(request.content))
return httpx.Response(
200,
json={
"id": "cmpl-1",
"object": "text_completion",
"created": 0,
"model": "gpt-3.5-turbo-instruct",
"choices": [{"text": "ok from order 2", "index": 0, "logprobs": None, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
},
)
upstream_client: Final = AsyncOpenAI(
api_key="key",
base_url="http://upstream.test",
http_client=httpx.AsyncClient(transport=httpx.MockTransport(_upstream)),
)
router = Router(
model_list=[
{
"model_name": "test-model",
"litellm_params": {
"model": "text-completion-openai/gpt-3.5-turbo-instruct",
"api_key": "key",
"mock_response": Exception("fail order 1"),
"order": 1,
},
"model_info": {"id": "1"},
},
{
"model_name": "test-model",
"litellm_params": {
"model": "text-completion-openai/gpt-3.5-turbo-instruct",
"api_key": "key",
"api_base": "http://upstream.test",
"order": 2,
},
"model_info": {"id": "2"},
},
],
num_retries=0,
)
try:
response = await router.atext_completion(model="test-model", prompt="hi", client=upstream_client)
finally:
await upstream_client.close()
assert response._hidden_params["model_id"] == "2"
assert upstream_bodies
assert all("_target_order" not in body for body in upstream_bodies)
_OPENAI_RESPONSES_URL: Final = "https://api.openai.com/v1/responses"
_MANTLE_RESPONSES_URL: Final = "https://bedrock-mantle.us-east-1.api.aws/openai/v1/responses"
_OVERLOADED_UPSTREAM: Final = {"error": {"message": "overloaded", "type": "server_error", "code": "server_error"}}
def _completed_response_body(response_id: str, model: str, text: str) -> dict[str, object]:
return {
"id": response_id,
"object": "response",
"created_at": 0,
"status": "completed",
"model": model,
"output": [
{
"id": f"msg_{response_id}",
"type": "message",
"status": "completed",
"role": "assistant",
"content": [{"type": "output_text", "text": text, "annotations": []}],
}
],
"usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2},
}
def _responses_history_with_order_1_reasoning() -> list[dict]:
return [
{"type": "message", "role": "user", "content": "What is 17*23?"},
{
"type": "reasoning",
"id": "rs_order1",
"encrypted_content": "gAAAAA-minted-by-order-1",
"summary": [{"type": "summary_text", "text": "multiply 17 by 23"}],
},
{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "391"}]},
{"type": "message", "role": "user", "content": "And 19*21?"},
]
def _responses_history_without_order_1_encrypted_reasoning() -> list[dict]:
return [
{"type": "message", "role": "user", "content": "What is 17*23?"},
{"type": "reasoning", "summary": [{"type": "summary_text", "text": "multiply 17 by 23"}]},
{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "391"}]},
{"type": "message", "role": "user", "content": "And 19*21?"},
]
def _openai_then_mantle_order_router() -> Router:
return Router(
model_list=[
{
"model_name": "gpt-6-astra",
"litellm_params": {"model": "openai/gpt-6-astra", "api_key": "openai-key", "order": 1},
"model_info": {"id": "openai-order-1"},
},
{
"model_name": "gpt-6-astra",
"litellm_params": {
"model": "bedrock_mantle/openai.gpt-6-astra",
"api_key": "mantle-bearer-token",
"aws_region_name": "us-east-1",
"order": 2,
},
"model_info": {"id": "mantle-order-2"},
},
],
num_retries=0,
)
def _two_openai_orders_on_one_encryption_boundary_router() -> Router:
return Router(
model_list=[
{
"model_name": "gpt-6-astra",
"litellm_params": {
"model": "openai/gpt-6-astra",
"api_base": "https://api.openai.com/v1",
"api_key": "openai-key",
"order": 1,
},
"model_info": {"id": "openai-order-1"},
},
{
"model_name": "gpt-6-astra",
"litellm_params": {
"model": "openai/gpt-6-astra-mini",
"api_base": "https://api.openai.com/v1",
"api_key": "openai-key",
"order": 2,
},
"model_info": {"id": "openai-order-2"},
},
],
num_retries=0,
)
@pytest.mark.asyncio
async def test_responses_order_fallback_hop_drops_the_encrypted_reasoning_the_next_provider_cannot_decrypt(
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
):
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
openai_route: Final = respx_mock.post(_OPENAI_RESPONSES_URL).mock(
return_value=httpx.Response(500, json=_OVERLOADED_UPSTREAM)
)
mantle_route: Final = respx_mock.post(_MANTLE_RESPONSES_URL).mock(
return_value=httpx.Response(200, json=_completed_response_body("resp_mantle", "openai.gpt-6-astra", "399"))
)
response = await _openai_then_mantle_order_router().aresponses(
model="gpt-6-astra", input=_responses_history_with_order_1_reasoning(), store=False
)
assert response._hidden_params["model_id"] == "mantle-order-2"
assert json.loads(openai_route.calls.last.request.read())["input"] == _responses_history_with_order_1_reasoning()
assert (
json.loads(mantle_route.calls.last.request.read())["input"]
== _responses_history_without_order_1_encrypted_reasoning()
)
@pytest.mark.asyncio
async def test_responses_order_fallback_hop_keeps_the_encrypted_reasoning_the_same_boundary_can_decrypt(
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
):
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
openai_route: Final = respx_mock.post(_OPENAI_RESPONSES_URL).mock(
side_effect=[
httpx.Response(500, json=_OVERLOADED_UPSTREAM),
httpx.Response(200, json=_completed_response_body("resp_order2", "gpt-6-astra-mini", "399")),
]
)
response = await _two_openai_orders_on_one_encryption_boundary_router().aresponses(
model="gpt-6-astra", input=_responses_history_with_order_1_reasoning(), store=False
)
assert response._hidden_params["model_id"] == "openai-order-2"
assert [json.loads(call.request.read())["input"] for call in openai_route.calls] == [
_responses_history_with_order_1_reasoning(),
_responses_history_with_order_1_reasoning(),
]
def test_fallback_hop_reads_the_deployment_that_just_failed_from_the_metadata_bucket_it_writes():
router: Final = _two_openai_orders_on_one_encryption_boundary_router()
order_2: Final = router.get_deployment(model_id="openai-order-2").model_dump(exclude_none=True)
hop_input: Final = _responses_history_with_order_1_reasoning()
hop_kwargs: Final = {
"model": "gpt-6-astra",
"input": hop_input,
"fallback_depth": 1,
"metadata": {"model_info": {"id": "openai-order-1"}},
"litellm_metadata": {"previous_models": [{"deployment_id": None}]},
}
router._update_kwargs_with_deployment(deployment=order_2, kwargs=hop_kwargs)
assert hop_input == _responses_history_with_order_1_reasoning()
assert hop_kwargs["metadata"]["model_info"]["id"] == "openai-order-2"
def test_check_non_standard_fallback_format():
from litellm.router_utils.fallback_event_handlers import (
check_non_standard_fallback_format,
)
# Standard formats
assert check_non_standard_fallback_format([{"gpt-3.5-turbo": ["claude-3-haiku"]}]) == False
assert check_non_standard_fallback_format([{"model": ["qwen-backup"]}]) == False
assert check_non_standard_fallback_format([{"model": ["qwen-backup"], "region": ["us-east-1"]}]) == False
# Non-standard formats
assert check_non_standard_fallback_format([{"model": "qwen-backup"}]) == True
assert (
check_non_standard_fallback_format([{"model": "qwen-backup", "messages": [{"role": "user", "content": "hi"}]}])
== True
)
assert check_non_standard_fallback_format([{"model": ["qwen-backup"], "api_key": "some-key"}]) == True