litellm/tests/integration/routing/test_observed_routing.py
devin-ai-integration[bot] e226016aee
test(integration): cover health_routing endpoint contracts (#44744)
* test(integration): cover health_routing endpoint contracts

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): tighten health_routing contracts per review

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): close health_routing review gaps

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* style(integration): format observed routing fallback cells

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): close health_routing final review findings

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): resolve health_routing review findings

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: mateo <mateo@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-14 06:34:22 +00:00

650 lines
28 KiB
Python

import json
import uuid
from collections.abc import Callable
from pathlib import Path
from queue import SimpleQueue
from typing import Final
import httpx
import pytest
import yaml
from integration._support.client import JSON_OBJECT, Gateway, object_value
from integration._support.process import owned_proxy
from integration._support.wire import Reply, Request, wire_server
from pydantic import JsonValue
@pytest.mark.covers(
"other.routing.retries.several_attempts_reach_success_without_hidden_retries",
"other.routing.errors.nonretryable_and_exhausted_failures_remain_errors",
)
def test_retry_counts_and_public_errors_match_actual_provider_attempts(gateway: Gateway) -> None:
with (
httpx.Client(base_url=gateway.upstream_url, timeout=5, trust_env=False) as upstream,
gateway.scenario() as scenario,
):
original: Final = object_value(gateway.get("/router/settings")["current_values"])["num_retries"]
provider_model: Final = "errors-" + uuid.uuid4().hex
model: Final = scenario.model(model=f"openai/{provider_model}", input_cost_per_token=0, output_cost_per_token=0)
def remove() -> None:
response: Final = upstream.delete(f"/__scripts/{provider_model}")
assert response.status_code in (200, 404)
assert upstream.get(f"/__scripts/{provider_model}").status_code == 404
scenario.cleanups.callback(remove)
try:
for index, (retries, statuses, status, attempts) in enumerate(
(
(2, [500, 500, 200], 200, 3),
(2, [400, 200], 400, 1),
(1, [429, 429, 200], 429, 2),
(1, [500, 500, 200], 500, 2),
)
):
gateway.post("/config/update", {"router_settings": {"num_retries": retries}})
assert object_value(gateway.get("/router/settings")["current_values"])["num_retries"] == retries
upstream.post(f"/__scripts/{provider_model}", json={"statuses": statuses}).raise_for_status()
upstream.get("/__observations").raise_for_status()
response: Final = gateway.request(
"POST",
"/v1/chat/completions",
{"model": model, "messages": [{"role": "user", "content": f"{provider_model} {index}"}]},
)
assert response.status_code == status, response.text
requests: Final = upstream.get("/__observations").json()["requests"]
assert len(requests) == attempts
assert all(request["body"]["model"] == provider_model for request in requests)
assert upstream.get(f"/__scripts/{provider_model}").json()["remaining"] == statuses[attempts:]
if status == 200:
assert response.json()["usage"]["total_tokens"] == 40
else:
error: Final = response.json()["error"]
assert isinstance(error["message"], str) and "Controlled provider failure" in error["message"]
assert str(error["code"]) == str(status)
assert (
error["type"]
== {400: "invalid_request_error", 429: "throttling_error", 500: "internal_server_error"}[status]
)
assert error["param"] is None
assert "Traceback" not in response.text and 'File "' not in response.text
finally:
gateway.post("/config/update", {"router_settings": {"num_retries": original}})
assert object_value(gateway.get("/router/settings")["current_values"])["num_retries"] == original
@pytest.mark.covers("other.routing.fallback.loaded_configuration_selects_only_permitted_target")
def test_loaded_fallback_selects_expected_deployment_and_keeps_response_identity(tmp_path: Path) -> None:
from litellm import Router
def respond(request: Request) -> Reply:
model: Final = json.loads(request.body)["model"]
assert model in {"primary-wire", "fallback-wire", "unrelated-wire"}
if model == "primary-wire":
return Reply(
status=500,
body=b'{"error":{"message":"synthetic primary unavailable","type":"api_error","code":"500"}}',
)
return Reply(
body=json.dumps(
{
"id": "response-" + model,
"object": "chat.completion",
"created": 1,
"model": model,
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "served " + model},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 11, "completion_tokens": 4, "total_tokens": 15},
}
).encode()
)
with wire_server(respond) as wire:
path: Final = tmp_path / "fallback.yaml"
path.write_text(
yaml.safe_dump(
{
"model_list": [
{
"model_name": alias,
"litellm_params": {
"model": "openai/" + upstream,
"api_key": "synthetic-routing-key",
"api_base": wire.url + "/v1",
},
}
for alias, upstream in (
("primary", "primary-wire"),
("fallback", "fallback-wire"),
("unrelated", "unrelated-wire"),
)
],
"router_settings": {
"num_retries": 0,
"disable_cooldowns": True,
"fallbacks": [{"primary": ["fallback"]}],
},
}
)
)
loaded: Final = yaml.safe_load(path.read_text())
router: Final = Router(model_list=loaded["model_list"], **loaded["router_settings"])
try:
result: Final = router.completion(
model="primary", messages=[{"role": "user", "content": "fallback control"}]
)
assert result.id == "response-fallback-wire"
assert result.choices[0].message.content == "served fallback-wire"
assert result.choices[0].finish_reason == "stop"
assert result.usage.prompt_tokens == 11 and result.usage.completion_tokens == 4
assert tuple(json.loads(request.body)["model"] for request in wire.drain()) == (
"primary-wire",
"fallback-wire",
)
control: Final = router.completion(
model="unrelated", messages=[{"role": "user", "content": "independent route"}]
)
assert control.id == "response-unrelated-wire"
assert tuple(json.loads(request.body)["model"] for request in wire.drain()) == ("unrelated-wire",)
finally:
router.reset()
@pytest.mark.covers("other.routing.alias_update.persisted_target_changes_only_selected_route")
def test_saved_deployment_target_update_changes_wire_and_preserves_control(gateway: Gateway) -> None:
with (
httpx.Client(base_url=gateway.upstream_url, timeout=5, trust_env=False) as upstream,
gateway.scenario() as scenario,
):
prefix: Final = "target-" + uuid.uuid4().hex
model: Final = scenario.model(
model="openai/" + prefix + "-first", input_cost_per_token=0, output_cost_per_token=0
)
other: Final = scenario.model(
model="openai/" + prefix + "-control", input_cost_per_token=0, output_cost_per_token=0
)
target: Final = next(entry for entry in gateway.get("/model/info")["data"] if entry["model_name"] == model)
for generation, suffix in enumerate(("first", "second")):
if generation:
response: Final = gateway.request(
"PATCH",
f"/model/{target['model_info']['id']}/update",
{"litellm_params": {"model": "openai/" + prefix + "-second"}},
)
assert response.status_code == 200, response.text
upstream.get("/__observations").raise_for_status()
for alias in (model, other):
assert gateway.chat(alias, text=f"{prefix} generation {generation}")["usage"]["total_tokens"] == 40
requests: Final = upstream.get("/__observations").json()["requests"]
assert [request["body"]["model"] for request in requests] == [prefix + "-" + suffix, prefix + "-control"]
@pytest.mark.parametrize(
(
"case",
"primary_error",
"body_flag",
"expected_sequence",
"expected_model",
"expected_status",
"expected_attempted_fallbacks",
"stream_request",
"key_metadata",
),
(
("context", "context", None, ("primary-up", "big-up"), "big-up", 200, "1", False, False),
("content-policy", "content-policy", None, ("primary-up", "safe-up"), "safe-up", 200, "1", False, False),
("server-error", "server-error", None, ("primary-up", "general-up"), "general-up", 200, "1", False, False),
("disabled-context", "context", "disable_fallbacks", ("primary-up",), "primary-up", 400, None, False, False),
(
"disabled-server-error",
"server-error",
"disable_fallbacks",
("primary-up",),
"primary-up",
500,
None,
False,
False,
),
(
"disabled-mid-stream",
"mid-stream",
"disable_fallbacks",
("primary-up",),
"primary-up",
200,
"0",
True,
False,
),
("key-metadata-disabled", "context", None, ("primary-up",), "primary-up", 400, None, False, True),
("stream-context", "context", None, ("primary-up", "big-up"), "big-up", 200, "1", True, False),
(
"stream-mid-stream",
"mid-stream",
None,
("primary-up", "general-up"),
"general-up",
200,
"0",
True,
False,
),
("mock-context", None, "mock_testing_context_fallbacks", ("big-up",), "big-up", 200, "1", False, False),
(
"mock-content-policy",
None,
"mock_testing_content_policy_fallbacks",
("safe-up",),
"safe-up",
200,
"1",
False,
False,
),
("mock-server-error", None, "mock_testing_fallbacks", ("general-up",), "general-up", 200, "1", False, False),
("ordered-context", "context", None, ("primary-up", "big-up"), "big-up", 200, "1", False, False),
(
"ordered-content-policy",
"content-policy",
None,
("primary-up", "safe-up"),
"safe-up",
200,
"1",
False,
False,
),
(
"ordered-server-error",
"server-error",
None,
("primary-up", "primary-tier2-up"),
"primary-tier2-up",
200,
"1",
False,
False,
),
("body-context", "context", "body_fallbacks", ("primary-up", "big-up"), "big-up", 200, "1", False, False),
(
"body-content-policy",
"content-policy",
"body_fallbacks",
("primary-up", "safe-up"),
"safe-up",
200,
"1",
False,
False,
),
),
)
def test_health_routing_fallbacks_preserve_error_specific_targets_and_request_controls(
gateway: Gateway,
tmp_path: Path,
case: str,
primary_error: str | None,
body_flag: str | None,
expected_sequence: tuple[str, ...],
expected_model: str,
expected_status: int,
expected_attempted_fallbacks: str | None,
stream_request: bool,
key_metadata: bool,
) -> None:
prompt: Final = f"health-routing-{case}-{uuid.uuid4().hex}"
call_order: Final = SimpleQueue[str]()
def respond(model: str) -> Reply:
if stream_request:
served_chunk: Final = (
b"data: "
+ json.dumps(
{
"id": "response-" + model,
"object": "chat.completion.chunk",
"created": 1,
"model": model,
"choices": [
{
"index": 0,
"delta": {"role": "assistant", "content": "served " + model},
"finish_reason": "stop",
}
],
}
).encode()
+ b"\n\n"
)
usage_chunk: Final = (
b"data: "
+ json.dumps(
{
"id": "response-" + model,
"object": "chat.completion.chunk",
"created": 1,
"model": model,
"choices": [],
"usage": {"prompt_tokens": 11, "completion_tokens": 4, "total_tokens": 15},
}
).encode()
+ b"\n\n"
)
return Reply(
content_type="text/event-stream",
chunks=(served_chunk, usage_chunk, b"data: [DONE]\n\n"),
)
return Reply(
body=json.dumps(
{
"id": "response-" + model,
"object": "chat.completion",
"created": 1,
"model": model,
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "served " + model},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 11, "completion_tokens": 4, "total_tokens": 15},
}
).encode()
)
ordered: Final = case.startswith("ordered-")
body_fallbacks: Final = case.startswith("body-")
def handler(model: str) -> Callable[[Request], Reply]:
def handle(request: Request) -> Reply:
if request.method == "GET":
return Reply(body=b'{"object":"list","data":[]}')
actual_body: Final = json.loads(request.body)
assert request.target == "/v1/chat/completions", request.target
assert actual_body == {
"model": model,
"messages": [{"role": "user", "content": prompt}],
**({"stream": True, "stream_options": {"include_usage": True}} if stream_request else {}),
}, request.body.decode()
call_order.put(model)
if model == "primary-up" and primary_error == "context":
return Reply(
status=400,
body=b'{"error":{"message":"This model\'s maximum context length is 4096 tokens","type":"invalid_request_error","code":"context_length_exceeded"}}',
)
if model == "primary-up" and primary_error == "content-policy":
return Reply(
status=400,
body=b'{"error":{"message":"synthetic content policy rejection","type":"invalid_request_error","code":"content_policy_violation"}}',
)
if model == "primary-up" and primary_error == "server-error":
return Reply(
status=500,
body=b'{"error":{"message":"synthetic upstream failure","type":"api_error","code":"500"}}',
)
if model == "primary-up" and primary_error == "mid-stream":
empty_first: Final = (
b"data: "
+ json.dumps(
{
"id": "response-" + model,
"object": "chat.completion.chunk",
"created": 1,
"model": model,
"choices": [
{
"index": 0,
"delta": {"role": "assistant", "content": ""},
"finish_reason": None,
}
],
}
).encode()
+ b"\n\n"
)
return Reply(
content_type="text/event-stream",
chunks=(empty_first, b":" + b"x" * 4_000_000 + b"\n\n", empty_first),
abort_after=2,
)
return respond(model)
return handle
with (
wire_server(handler("primary-up")) as primary,
wire_server(handler("primary-tier2-up")) as primary_tier2,
wire_server(handler("big-up")) as big,
wire_server(handler("safe-up")) as safe,
wire_server(handler("general-up")) as general,
):
config: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
config["model_list"] = [
*(
(
{
"model_name": "primary",
"litellm_params": {
"model": "openai/primary-up",
"api_base": primary.url + "/v1",
"api_key": "synthetic-health-routing-key",
"order": 1,
},
},
{
"model_name": "primary",
"litellm_params": {
"model": "openai/primary-tier2-up",
"api_base": primary_tier2.url + "/v1",
"api_key": "synthetic-health-routing-key",
"order": 2,
},
},
)
if ordered
else (
{
"model_name": "primary",
"litellm_params": {
"model": "openai/primary-up",
"api_base": primary.url + "/v1",
"api_key": "synthetic-health-routing-key",
},
},
)
),
*(
{
"model_name": alias,
"litellm_params": {
"model": f"openai/{upstream_model}",
"api_base": wire.url + "/v1",
"api_key": "synthetic-health-routing-key",
},
}
for alias, upstream_model, wire in (
("big", "big-up", big),
("safe", "safe-up", safe),
("general", "general-up", general),
)
),
]
config["router_settings"] = {
"num_retries": 0,
"disable_cooldowns": True,
**(
{}
if body_fallbacks
else {
"context_window_fallbacks": [{"primary": ["big"]}],
"content_policy_fallbacks": [{"primary": ["safe"]}],
}
),
"fallbacks": [{"primary": ["general"]}],
}
config["general_settings"] = {
**config.get("general_settings", {}),
"dangerously_allow_mock_testing_request_params": True,
}
path: Final = tmp_path / f"health-routing-{case}.yaml"
path.write_text(yaml.safe_dump(config))
with gateway.scenario() as scenario, owned_proxy(gateway, tmp_path, {}, config=path) as candidate:
request_key: Final = scenario.key(metadata={"disable_fallbacks": True}) if key_metadata else candidate.key
request_body: Final[dict[str, JsonValue]] = {
"model": "primary",
"messages": [{"role": "user", "content": prompt}],
**({"stream": True} if stream_request else {}),
**(
{body_flag: True}
if body_flag
in (
"disable_fallbacks",
"mock_testing_context_fallbacks",
"mock_testing_content_policy_fallbacks",
"mock_testing_fallbacks",
)
else {}
),
**(
{
"context_window_fallbacks": [{"primary": ["big"]}],
"content_policy_fallbacks": [{"primary": ["safe"]}],
}
if body_fallbacks
else {}
),
}
if stream_request:
with candidate.client.stream(
"POST",
"/v1/chat/completions",
json=request_body,
headers={"Authorization": f"Bearer {request_key}"},
) as response:
response_text: Final = response.read().decode()
response_status: Final = response.status_code
response_headers: Final = response.headers
assert response_status == expected_status, response_text
assert response_headers.get("x-litellm-attempted-fallbacks") == expected_attempted_fallbacks, (
response_text
)
assert tuple(call_order.get_nowait() for _ in range(call_order.qsize())) == expected_sequence, (
response_text
)
event_lines: Final = tuple(
line.removeprefix("data:") for line in response_text.splitlines() if line.startswith("data:")
)
events: Final = tuple(
JSON_OBJECT.validate_json(event) for event in event_lines if event.strip() != "[DONE]"
)
if expected_status == 200 and not (primary_error == "mid-stream" and body_flag == "disable_fallbacks"):
assert tuple({**event, "created": 0} if "created" in event else event for event in events) == (
*(
(
{
"id": "response-primary-up",
"object": "chat.completion.chunk",
"created": 0,
"model": "primary",
"choices": [{"index": 0, "delta": {"role": "assistant", "content": ""}}],
},
)
if primary_error == "mid-stream"
else ()
),
{
"id": "response-" + expected_model,
"object": "chat.completion.chunk",
"created": 0,
"model": expected_model.removesuffix("-up"),
"choices": [
{
"index": 0,
"delta": {"role": "assistant", "content": "served " + expected_model},
}
],
},
{
"id": "response-" + expected_model,
"object": "chat.completion.chunk",
"created": 0,
"model": expected_model.removesuffix("-up"),
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
},
), response_text
return
assert len(events) == 2, response_text
first_event: Final = events[0]
assert ({**first_event, "created": 0} if "created" in first_event else first_event) == {
"id": "response-primary-up",
"object": "chat.completion.chunk",
"created": 0,
"model": "primary",
"choices": [{"index": 0, "delta": {"role": "assistant", "content": ""}}],
}, response_text
assert {key: events[1]["error"][key] for key in events[1]["error"] if key != "message"} == {
"type": None,
"param": None,
"code": "500",
}, response_text
message: Final = events[1]["error"]["message"]
assert isinstance(message, str), response_text
assert message.startswith("litellm.APIConnectionError: "), response_text
else:
non_stream_response: Final = candidate.request(
"POST", "/v1/chat/completions", request_body, key=request_key
)
assert non_stream_response.status_code == expected_status, non_stream_response.text
assert (
non_stream_response.headers.get("x-litellm-attempted-fallbacks") == expected_attempted_fallbacks
), non_stream_response.text
if expected_status == 400:
assert JSON_OBJECT.validate_json(non_stream_response.content) == {
"error": {
"message": (
"litellm.ContextWindowExceededError: litellm.BadRequestError: "
"ContextWindowExceededError: OpenAIException - This model's maximum context length "
"is 4096 tokens"
),
"type": "invalid_request_error",
"param": None,
"code": "400",
}
}, non_stream_response.text
elif expected_status == 500:
assert JSON_OBJECT.validate_json(non_stream_response.content) == {
"error": {
"message": "litellm.InternalServerError: InternalServerError: OpenAIException - "
"synthetic upstream failure",
"type": "internal_server_error",
"param": None,
"code": "500",
}
}, non_stream_response.text
else:
assert JSON_OBJECT.validate_json(non_stream_response.content) == {
"id": "response-" + expected_model,
"object": "chat.completion",
"created": 1,
"model": expected_model,
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "served " + expected_model},
"finish_reason": "stop",
"provider_specific_fields": {},
}
],
"usage": {"prompt_tokens": 11, "completion_tokens": 4, "total_tokens": 15},
}, non_stream_response.text
assert tuple(call_order.get_nowait() for _ in range(call_order.qsize())) == expected_sequence, (
non_stream_response.text
)