litellm/tests/integration/routing/test_fallback_retry_header_contracts.py
devin-ai-integration[bot] d5173e3d70
test: move 76 live top-level tests to offline unit and integration coverage (#45352)
* test: move 76 live top-level tests to offline unit and integration coverage

* test: keep the mock-param opt-in guard valid once the top-level senders are gone

* test: address review on offline replacements

* docs(test): drop the deleted harness test file from the harness check command

* test: make the key rebind, team member delete and routes integration tests exercise the legacy paths

* test: make fallback, rpm and spend integration contracts deterministic and clean up their rows, assert the rpm limit in usage-based routing

* test: expect the no-deployments error at the rpm limit and cover the strategy check without pre-call checks

* test: hand member cleanups to the scenario instead of growing a budget list, flatten callback kinds

* test: scope the admin health check to the test's own deployment

---------

Co-authored-by: yuneng <yuneng@berri.ai>
2026-10-08 10:33:35 -07:00

291 lines
13 KiB
Python

import threading
import time
import uuid
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from typing import Final, Literal
import httpx
import pytest
from pydantic import JsonValue, TypeAdapter
from tests.integration._support.database import read_rows
from tests.integration._support.client import (
JSON_OBJECT,
Gateway,
Scenario,
eventually,
object_value,
string_value,
)
from tests.integration._support.openai_wire import chat_reply
from tests.integration._support.wire import Reply, Request, wire_server
UNREACHABLE_API_BASE: Final = "http://127.0.0.1:9/v1"
END_USER_REQUESTS: Final = 10
CONCURRENT_REQUESTS: Final = 25
DISTRIBUTION_REQUESTS: Final = 20
SLOW_UPSTREAM_SECONDS: Final = 3
HELD_REPLY_SECONDS: Final = 30
END_USER_ROW_SECONDS: Final = 70
CUSTOM_FALLBACK_TEXT: Final = "custom fallback prompt"
Caller = Literal["virtual-key", "master-key"]
JSON_OBJECTS: Final = TypeAdapter(list[dict[str, JsonValue]])
def _messages(text: str = "integration control") -> list[JsonValue]:
return [{"role": "user", "content": text}]
def _chat(
gateway: Gateway,
body: dict[str, JsonValue],
*,
key: str | None = None,
headers: dict[str, str] | None = None,
) -> httpx.Response:
return gateway.request("POST", "/v1/chat/completions", {"messages": _messages(), **body}, key=key, headers=headers)
def _body(response: httpx.Response) -> dict[str, JsonValue]:
return JSON_OBJECT.validate_json(response.content)
def _content(response: httpx.Response) -> str:
choices: Final = _body(response)["choices"]
assert isinstance(choices, list) and choices, response.text
return string_value(object_value(object_value(choices[0])["message"])["content"])
def _scripted_model(gateway: Gateway, scenario: Scenario, statuses: list[int], num_retries: int) -> str:
upstream_model: Final = f"integration-{uuid.uuid4().hex}"
script_url: Final = f"{gateway.upstream_url}/__scripts/{upstream_model}"
configured: Final = httpx.post(script_url, json={"statuses": statuses})
assert configured.status_code == 200, configured.text
scenario.cleanups.callback(httpx.delete, script_url)
return scenario.model(model=f"openai/{upstream_model}", num_retries=num_retries)
@dataclass(frozen=True, slots=True)
class UniqueModel:
name: str
upstream: str
deployment_id: str
def _unique_model(scenario: Scenario) -> UniqueModel:
upstream_model: Final = f"integration-{uuid.uuid4().hex}"
deployment_id: Final = f"integration-{uuid.uuid4().hex}"
model_info: Final[dict[str, JsonValue]] = {"id": deployment_id}
name: Final = scenario.model(model=f"openai/{upstream_model}", model_info=model_info)
return UniqueModel(name, upstream_model, deployment_id)
def _slow_reply(_: Request) -> Reply:
time.sleep(SLOW_UPSTREAM_SECONDS)
return chat_reply("chatcmpl-slow", "gpt-4o-mini", "late", stream=False)
def _fallback_reply(_: Request) -> Reply:
return chat_reply("chatcmpl-fallback", "gpt-4o-mini", "served by fallback", stream=False)
def _held_reply(release: threading.Event) -> Callable[[Request], Reply]:
def respond(_: Request) -> Reply:
assert release.wait(timeout=HELD_REPLY_SECONDS)
return chat_reply("chatcmpl-held", "gpt-4o-mini", "held", stream=False)
return respond
def _delete_auto_created_end_user(gateway: Gateway, user_id: str) -> None:
eventually(
lambda: read_rows('SELECT user_id FROM "LiteLLM_EndUserTable" WHERE user_id = %s', (user_id,)),
lambda rows: len(rows) == 1,
seconds=END_USER_ROW_SECONDS,
)
gateway.post("/end_user/delete", {"user_ids": [user_id]})
assert read_rows('SELECT user_id FROM "LiteLLM_EndUserTable" WHERE user_id = %s', (user_id,)) == []
def _status(gateway: Gateway, model: str) -> int:
return _chat(gateway, {"model": model}).status_code
def _served_model_id(gateway: Gateway, model: str) -> str:
response: Final = _chat(gateway, {"model": model})
assert response.status_code == 200, response.text
return response.headers["x-litellm-model-id"]
def _concurrent_round(gateway: Gateway, bad: str, good: str) -> tuple[list[int], list[int]]:
with ThreadPoolExecutor(max_workers=CONCURRENT_REQUESTS * 2) as pool:
bad_calls: Final = [pool.submit(_status, gateway, bad) for _ in range(CONCURRENT_REQUESTS)]
good_calls: Final = [pool.submit(_status, gateway, good) for _ in range(CONCURRENT_REQUESTS)]
return [call.result() for call in bad_calls], [call.result() for call in good_calls]
def _deployment(gateway: Gateway, scenario: Scenario, model_name: str, model: str = "openai/gpt-4o-mini") -> str:
created: Final = gateway.post(
"/model/new",
{
"model_name": model_name,
"litellm_params": {
"model": model,
"api_key": "integration-provider-key",
"api_base": f"{gateway.upstream_url}/v1",
},
},
)
deployment_id: Final = string_value(object_value(created["model_info"])["id"])
scenario.cleanups.callback(scenario.delete_model, deployment_id)
return deployment_id
@pytest.mark.parametrize("caller", ["virtual-key", "master-key"])
def test_end_user_budget_tpm_limit_rate_limits_their_requests(gateway: Gateway, caller: Caller) -> None:
with gateway.scenario() as scenario:
model: Final = scenario.model()
budget: Final = scenario.budget(tpm_limit=2)
end_user: Final = f"integration-{uuid.uuid4().hex}"
gateway.post("/end_user/new", {"user_id": end_user, "budget_id": budget})
scenario.cleanups.callback(gateway.post, "/end_user/delete", {"user_ids": [end_user]})
key: Final = scenario.key(models=[model]) if caller == "virtual-key" else gateway.key
control_user: Final = f"integration-{uuid.uuid4().hex}"
scenario.cleanups.callback(_delete_auto_created_end_user, gateway, control_user)
control: Final = _chat(gateway, {"model": model, "user": control_user}, key=key)
assert control.status_code == 200, control.text
statuses: Final = [
_chat(gateway, {"model": model, "user": end_user}, key=key).status_code for _ in range(END_USER_REQUESTS)
]
assert statuses.count(200) < 5, statuses
assert set(statuses) <= {200, 429}, statuses
def test_client_fallbacks_reach_an_allowed_model_and_name_a_denied_one(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
primary: Final = scenario.model(api_base=UNREACHABLE_API_BASE)
fallback: Final = _unique_model(scenario)
body: Final[dict[str, JsonValue]] = {"model": primary, "fallbacks": [fallback.name]}
served: Final = _chat(gateway, body, key=scenario.key(models=[primary, fallback.name]))
assert served.status_code == 200, served.text
assert _body(served)["model"] == fallback.upstream
assert served.headers["x-litellm-model-id"] == fallback.deployment_id
assert _content(served)
denied: Final = _chat(gateway, body, key=scenario.key(models=[primary]))
assert denied.status_code == 403, denied.text
assert fallback.name in denied.text
def test_client_fallback_with_custom_messages_sends_them_to_the_fallback(gateway: Gateway) -> None:
custom: Final = _messages(CUSTOM_FALLBACK_TEXT)
with gateway.scenario() as scenario, wire_server(_fallback_reply) as wire:
primary: Final = scenario.model(api_base=UNREACHABLE_API_BASE)
fallback: Final = scenario.model(api_base=wire.url)
body: Final[dict[str, JsonValue]] = {
"model": primary,
"fallbacks": [{"model": fallback, "messages": custom}],
}
served: Final = _chat(gateway, body, key=scenario.key(models=[primary, fallback]))
assert served.status_code == 200, served.text
assert _content(served) == "served by fallback"
forwarded: Final = [JSON_OBJECT.validate_json(request.body)["messages"] for request in wire.drain()]
assert forwarded == [custom]
denied: Final = _chat(gateway, body, key=scenario.key(models=[primary]))
assert denied.status_code == 403, denied.text
assert fallback in denied.text
assert wire.drain() == ()
def test_rate_limited_deployment_is_retried_and_reports_retry_counts(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
model: Final = _scripted_model(gateway, scenario, [429, 200], 50)
response: Final = _chat(gateway, {"model": model})
assert response.status_code == 200, response.text
assert response.headers["x-litellm-attempted-retries"] == "1"
assert response.headers["x-litellm-max-retries"] == "50"
def test_request_fallbacks_reroute_after_a_connection_failure(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
primary: Final = scenario.model(api_base=UNREACHABLE_API_BASE)
fallback: Final = _unique_model(scenario)
response: Final = _chat(gateway, {"model": primary, "fallbacks": [fallback.name]})
assert response.status_code == 200, response.text
assert _body(response)["model"] == fallback.upstream
assert response.headers["x-litellm-model-id"] == fallback.deployment_id
assert response.headers["x-litellm-attempted-fallbacks"] == "1"
def test_model_level_timeout_is_reported_on_a_timed_out_request(gateway: Gateway) -> None:
with gateway.scenario() as scenario, wire_server(_slow_reply) as wire:
response: Final = _chat(gateway, {"model": scenario.model(api_base=wire.url, timeout=1)})
assert response.status_code == 408, response.text
assert response.headers["x-litellm-timeout"] == "1.0"
def test_request_timeout_header_overrides_the_model_timeout(gateway: Gateway) -> None:
with gateway.scenario() as scenario, wire_server(_slow_reply) as wire:
response: Final = _chat(
gateway,
{"model": scenario.model(api_base=wire.url, timeout=1)},
headers={"x-litellm-timeout": "0.001"},
)
assert response.status_code == 408, response.text
assert response.headers["x-litellm-timeout"] == "0.001"
def test_failing_model_traffic_does_not_starve_concurrent_good_requests(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
bad: Final = scenario.model(api_base=UNREACHABLE_API_BASE)
good: Final = scenario.model()
for _ in range(2):
bad_calls, good_calls = _concurrent_round(gateway, bad, good)
assert good_calls == [200] * CONCURRENT_REQUESTS
assert 200 not in bad_calls
def test_rpm_limited_deployment_rejects_a_second_call_while_the_first_is_in_flight(gateway: Gateway) -> None:
release: Final = threading.Event()
with gateway.scenario() as scenario, wire_server(_held_reply(release)) as wire, ThreadPoolExecutor(1) as pool:
model: Final = scenario.model(api_base=wire.url, rpm=1)
first: Final = pool.submit(_chat, gateway, {"model": model})
try:
eventually(wire.received.qsize, lambda count: count == 1)
second: Final = _chat(gateway, {"model": model})
assert second.status_code == 429, second.text
assert wire.received.qsize() == 1
finally:
release.set()
assert first.result().status_code == 200
assert len(wire.drain()) == 1
def test_model_group_with_two_deployments_serves_from_both(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
first_id: Final = f"integration-{uuid.uuid4().hex}"
model: Final = scenario.model(model_info={"id": first_id})
second_id: Final = _deployment(gateway, scenario, model)
served_by: Final = {_served_model_id(gateway, model) for _ in range(DISTRIBUTION_REQUESTS)}
assert served_by == {first_id, second_id}
def test_unlisted_provider_model_resolves_through_a_wildcard_deployment(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
prefix: Final = f"integration{uuid.uuid4().hex}"
_deployment(gateway, scenario, f"{prefix}/*", "openai/*")
response: Final = _chat(gateway, {"model": f"{prefix}/gpt-4o-mini"})
assert response.status_code == 200, response.text
assert _content(response)
def test_comma_separated_models_fan_out_to_one_response_per_model(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
first: Final = _unique_model(scenario)
second: Final = _unique_model(scenario)
response: Final = _chat(gateway, {"model": f"{first.name},{second.name}"})
assert response.status_code == 200, response.text
replies: Final = JSON_OBJECTS.validate_json(response.content)
assert len(replies) == 2, replies
assert {string_value(reply["model"]) for reply in replies} == {first.upstream, second.upstream}