fix(router): preserve fallback aliases and check synchronous text calls
Some checks failed
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) Has been cancelled
Terraform Provider / Provider endpoints vs proxy OpenAPI schema (push) Has been cancelled

This commit is contained in:
Mateo 2026-10-16 18:44:07 -07:00
parent 7e527c39e7
commit 2ba59fb460
4 changed files with 105 additions and 7 deletions

View file

@ -2519,6 +2519,17 @@ class ProxyBaseLLMRequestProcessing:
original_model,
fallback_models,
)
raw_router_settings: Final = _DECODED_JSON.validate_python(
_REQUEST_FIELDS.validate_python(rate_limited_data).get("router_settings_override")
)
group_alias: Final = _DECODED_JSON.validate_python(
_REQUEST_FIELDS.validate_python(raw_router_settings).get("model_group_alias")
if isinstance(raw_router_settings, Mapping)
else None
)
key_aliases: Final[object] = cast( # cast-ok: auth aliases are dynamically typed at runtime
object, user_api_key_dict.aliases
)
try:
for fallback_model in fallback_models:
if fallback_model == original_model:
@ -2534,8 +2545,17 @@ class ProxyBaseLLMRequestProcessing:
self.data = independent_snapshot(pristine)
self.data["model"] = fallback_model
try:
probe_data = {
**_REQUEST_FIELDS.validate_python(pristine),
"model": resolve_served_model(
fallback_model,
_DECODED_JSON.validate_python(litellm.model_alias_map),
key_aliases,
group_alias,
),
}
if (
endpoint_unsupported_by_model(route_type, self.data, llm_router, user_api_key_dict.team_id)
endpoint_unsupported_by_model(route_type, probe_data, llm_router, user_api_key_dict.team_id)
is not None
):
continue

View file

@ -5534,12 +5534,13 @@ class Router:
kwargs.setdefault("metadata", {}).update({"model_group": model})
# pick the one that is available (lowest TPM/RPM)
deployment: Final = self.get_available_deployment(
model=model,
messages=messages,
specific_deployment=kwargs.pop("specific_deployment", None),
request_kwargs=kwargs,
)
with endpoint_selection("text_completion", model=model):
deployment: Final = self.get_available_deployment(
model=model,
messages=messages,
specific_deployment=kwargs.pop("specific_deployment", None),
request_kwargs=kwargs,
)
data: Final = deployment["litellm_params"].copy()
for k, v in self.default_litellm_params.items():

View file

@ -1439,3 +1439,47 @@ def test_a_compaction_endpoint_declaration_keeps_the_model_available_for_compact
_served_by_every_worker(rig.gateway, frozenset(identities))
response: Final = rig.gateway.request("POST", "/responses/compact", {"model": model, "input": marker})
_assert_served(rig, response, marker, "/v1/responses/compact")
@pytest.mark.parametrize("alias_source", ["key_alias", "key_router", "team_router"])
def test_local_rate_limit_fallbacks_resolve_saved_aliases(rig: _Rig, tmp_path: Path, alias_source: str) -> None:
suffix: Final = uuid.uuid4().hex[:12]
names: Final = {label: f"gate-fallback-alias-{label}-{suffix}" for label in ("primary", "target", "alias")}
shared: Final = _CONFIG.validate_python(yaml.safe_load((_TESTS_ROOT / "integration/proxy_config.yaml").read_text()))
config: Final = {
**shared,
"model_list": [
_routed_entry(names["primary"], rig.api_base, f"openai/{_CHAT_MODEL}", {"mode": "chat"}),
_routed_entry(names["target"], rig.api_base, f"openai/{_CHAT_MODEL}", {"mode": "chat"}),
],
"router_settings": {
**object_value(shared["router_settings"]),
"num_retries": 0,
"fallbacks": [{names["primary"]: [names["alias"]]}],
},
}
path: Final = tmp_path / "fallback-alias.yaml"
path.write_text(yaml.safe_dump(config, sort_keys=False))
aliases: Final = {names["alias"]: names["target"]}
with (
owned_proxy_process(rig.gateway, tmp_path, {}, config=path, workers=2) as owned,
owned.gateway.scenario() as scenario,
):
team: Final = (
scenario.team(router_settings={"model_group_alias": aliases}) if alias_source == "team_router" else None
)
key: Final = scenario.key(
team_id=team,
aliases=aliases if alias_source == "key_alias" else {},
router_settings={"model_group_alias": aliases} if alias_source == "key_router" else None,
model_rpm_limit={names["primary"]: 1},
)
first: Final = owned.gateway.request(
"POST", "/v1/chat/completions", _chat_body(names["primary"], _marker()), key=key
)
assert first.status_code == 200, first.text
marker: Final = _marker()
second: Final = owned.gateway.request(
"POST", "/v1/chat/completions", _chat_body(names["primary"], marker), key=key
)
_assert_fallback_answered(rig.upstream, second, marker)

View file

@ -11285,6 +11285,39 @@ def test_is_deployment_blocked_static_helper_reflects_blocked_flag():
)
def test_synchronous_text_completion_rejects_a_restricted_model_before_the_provider() -> None:
model: Final = "text-completion-openai/unit-text-model"
router: Final = Router(
model_list=[
{
"model_name": model,
"litellm_params": {
"model": model,
"api_base": "https://sync-router.invalid/v1",
"api_key": "unit-provider-key",
},
"model_info": {"enforce_supported_endpoints": True, "supported_endpoints": ["/v1/embeddings"]},
}
],
num_retries=0,
)
with respx.mock(assert_all_called=False) as upstream:
provider: Final = upstream.post("https://sync-router.invalid/v1/completions").respond(
200,
json={
"id": "cmpl-unit",
"object": "text_completion",
"created": 1,
"model": "unit-text-model",
"choices": [{"text": "provider called", "index": 0, "finish_reason": "stop", "logprobs": None}],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
},
)
with pytest.raises(litellm.BadRequestError, match="/v1/completions is not supported"):
router.text_completion(model=model, prompt="test")
assert provider.call_count == 0
def test_endpoint_preflight_returns_unavailable_model_errors_as_values() -> None:
router: Final = Router(
model_list=[