litellm/tests/unit/proxy/test__lazy_features.py
devin-ai-integration[bot] 03743ae020
feat(proxy): add LITELLM_DISABLE_LAZY_ROUTES to register optional routers at startup (#43911)
* feat(proxy): add LITELLM_DISABLE_LAZY_ROUTES to register optional routers at startup

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

* fix(proxy): register eager lazy routes at startup so late eager routes keep precedence

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

* refactor(proxy): share one optional-feature install path between lazy and eager registration

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

* docs(proxy): say eager lazy routes register at worker startup

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

* refactor(proxy): name the import callable passed to _install

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

* test(integration): prove a startup hook can drop eager lazy routes for good

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

* test(integration): strip the lazy-routes flag from the lazy-mode control proxies

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

* test(proxy): cover the lazy warmup route registering a feature

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

* ci(unit): run tests/unit/proxy/test__lazy_features.py in the proxy-server-core shard

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

* test(integration): audit cells for LITELLM_DISABLE_LAZY_ROUTES

Flag spellings, /openapi.json at boot, the warm-up route in both modes, /mcp/proxy ahead of the /mcp mount,
route table and OpenAPI parity with a fully warmed lazy proxy, a broken optional import in both modes, every
client SDK against the completion endpoints, a boot burst with a killed worker, and a restart

* fix(proxy): keep config pass-through routes ahead of eagerly registered features

With LITELLM_DISABLE_LAZY_ROUTES set, features registered before the proxy lifespan
added config pass-through endpoints, so a pass-through overlapping a feature path
(e.g. a self-hosted /langfuse) lost to the built-in route. Restore lazy mode's
registry order once startup finishes, without bringing back routes a startup hook removed

---------

Co-authored-by: ryan <ryan@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-01 22:50:41 +00:00

211 lines
8 KiB
Python

import sys
from collections.abc import AsyncGenerator, Mapping
from contextlib import asynccontextmanager
from types import ModuleType
from typing import Final
import pytest
from fastapi import APIRouter, FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel
from litellm.proxy._lazy_features import (
LazyFeature,
LazyFeatureMiddleware,
attach_lazy_features,
lazy_tag_to_prefix,
loaded_lazy_modules,
)
FLAG: Final = "LITELLM_DISABLE_LAZY_ROUTES"
WARMUP_PATH: Final = "/lazy/warm/{name}"
class _Operation(BaseModel):
tags: tuple[str, ...]
class _WarmupBody(BaseModel):
stub_path: str
paths: Mapping[str, Mapping[str, _Operation]]
def _feature_module(monkeypatch: pytest.MonkeyPatch, name: str, path: str) -> LazyFeature:
async def served() -> dict[str, str]:
return {"feature": name}
router: Final = APIRouter()
router.add_api_route(path, served, methods=["GET"])
module: Final = ModuleType(f"tests.unit.proxy.lazy_fixture_{name}")
module.router = router # pyright: ignore[reportAttributeAccessIssue] # fixture module built at test time
monkeypatch.setitem(sys.modules, module.__name__, module)
return LazyFeature(name=name, module_path=module.__name__, path_prefixes=(path,))
def _paths(app: FastAPI) -> tuple[str, ...]:
return tuple(str(getattr(route, "path", "")) for route in app.routes)
def _has_lazy_middleware(app: FastAPI) -> bool:
return any(middleware.cls is LazyFeatureMiddleware for middleware in app.user_middleware)
@pytest.mark.parametrize("value", ("1", "true", "TRUE", "yes", "on"))
def test_flag_registers_every_feature_at_startup(monkeypatch: pytest.MonkeyPatch, value: str) -> None:
monkeypatch.setenv(FLAG, value)
features: Final = (
_feature_module(monkeypatch, "alpha", "/alpha/list"),
_feature_module(monkeypatch, "beta", "/beta/list"),
)
app: Final = FastAPI()
attach_lazy_features(app, features)
assert WARMUP_PATH not in _paths(app)
assert not _has_lazy_middleware(app)
assert loaded_lazy_modules(app) == set()
with TestClient(app) as client:
at_startup: Final = _paths(app)
assert {"/alpha/list", "/beta/list"} <= set(at_startup)
assert loaded_lazy_modules(app) == {features[0].module_path, features[1].module_path}
assert client.get("/beta/list").json() == {"feature": "beta"}
assert client.post("/lazy/warm/alpha").status_code == 404
assert _paths(app) == at_startup, "first feature request changed the table"
def test_flag_registers_before_the_inner_lifespan_and_after_late_routes(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv(FLAG, "true")
features: Final = (_feature_module(monkeypatch, "epsilon", "/epsilon/{name}"),)
seen_by_inner_lifespan: Final[list[tuple[str, ...]]] = [] # mutable-ok: captured from inside the lifespan
@asynccontextmanager
async def inner_lifespan(app_: FastAPI) -> AsyncGenerator[None]:
seen_by_inner_lifespan.append(_paths(app_))
yield
async def late() -> dict[str, str]:
return {"feature": "late"}
app: Final = FastAPI(lifespan=inner_lifespan)
attach_lazy_features(app, features)
app.add_api_route("/epsilon/list", late, methods=["GET"])
with TestClient(app) as client:
assert client.get("/epsilon/list").json() == {"feature": "late"}, "late eager route must win, as in lazy mode"
assert client.get("/epsilon/x").json() == {"feature": "epsilon"}
assert seen_by_inner_lifespan == [_paths(app)], "startup hooks inside the proxy lifespan must see the full table"
def test_flag_lets_a_route_added_during_startup_beat_an_overlapping_feature_route(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv(FLAG, "true")
features: Final = (_feature_module(monkeypatch, "zeta", "/zeta/{endpoint:path}"),)
async def configured() -> dict[str, str]:
return {"feature": "configured"}
@asynccontextmanager
async def adds_a_pass_through(app_: FastAPI) -> AsyncGenerator[None]:
app_.add_api_route("/zeta/{subpath:path}", configured, methods=["GET"])
yield
app: Final = FastAPI(lifespan=adds_a_pass_through)
attach_lazy_features(app, features)
with TestClient(app) as client:
assert client.get("/zeta/health").json() == {"feature": "configured"}, (
"lazy mode routes this to startup's route"
)
def test_flag_does_not_bring_back_a_feature_route_removed_during_startup(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv(FLAG, "true")
features: Final = (
_feature_module(monkeypatch, "eta", "/eta/list"),
_feature_module(monkeypatch, "theta", "/theta/list"),
)
@asynccontextmanager
async def drops_eta(app_: FastAPI) -> AsyncGenerator[None]:
app_.router.routes[:] = [route for route in app_.router.routes if getattr(route, "path", "") != "/eta/list"]
yield
app: Final = FastAPI(lifespan=drops_eta)
attach_lazy_features(app, features)
with TestClient(app) as client:
assert client.get("/eta/list").status_code == 404
assert client.get("/theta/list").json() == {"feature": "theta"}
assert "/eta/list" not in _paths(app)
@pytest.mark.parametrize("value", (None, "", "0", "false", "off"))
def test_without_the_flag_features_still_mount_on_first_request(
monkeypatch: pytest.MonkeyPatch, value: str | None
) -> None:
if value is None:
monkeypatch.delenv(FLAG, raising=False)
else:
monkeypatch.setenv(FLAG, value)
features: Final = (_feature_module(monkeypatch, "gamma", "/gamma/list"),)
app: Final = FastAPI()
attach_lazy_features(app, features)
assert "/gamma/list" not in _paths(app)
assert WARMUP_PATH in _paths(app)
assert _has_lazy_middleware(app)
with TestClient(app) as client:
assert client.get("/gamma/list").json() == {"feature": "gamma"}
assert "/gamma/list" in _paths(app)
def test_flag_keeps_registering_after_one_feature_fails_to_import(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv(FLAG, "true")
broken: Final = LazyFeature(
name="broken", module_path="tests.unit.proxy.lazy_fixture_does_not_exist", path_prefixes=("/broken",)
)
healthy: Final = _feature_module(monkeypatch, "delta", "/delta/list")
app: Final = FastAPI()
attach_lazy_features(app, (broken, healthy))
with TestClient(app) as client:
assert "/delta/list" in _paths(app)
assert loaded_lazy_modules(app) == {broken.module_path, healthy.module_path}
assert client.get("/delta/list").json() == {"feature": "delta"}
assert client.get("/broken").status_code == 404
def test_flag_hides_the_swagger_warmup_plugin(monkeypatch: pytest.MonkeyPatch) -> None:
import litellm.proxy._lazy_openapi_snapshot as snapshot
monkeypatch.setattr(snapshot, "SNAPSHOT_FILE", snapshot.SNAPSHOT_FILE.with_name("missing-snapshot.json"))
monkeypatch.setenv(FLAG, "false")
assert lazy_tag_to_prefix() != {}, "control: without the flag and without a snapshot the plugin has tags"
monkeypatch.setenv(FLAG, "true")
assert lazy_tag_to_prefix() == {}
def test_without_the_flag_the_warmup_route_registers_a_feature_and_returns_its_paths(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv(FLAG, raising=False)
features: Final = (
_feature_module(monkeypatch, "alpha", "/alpha/list"),
_feature_module(monkeypatch, "beta", "/beta/list"),
)
app: Final = FastAPI()
attach_lazy_features(app, features)
with TestClient(app) as client:
assert client.post("/lazy/warm/zeta").status_code == 404
warmed: Final = client.post("/lazy/warm/alpha")
assert warmed.status_code == 200, warmed.text
body: Final = _WarmupBody.model_validate_json(warmed.text)
assert body.stub_path == "/alpha/list"
assert set(body.paths) == {"/alpha/list"}
assert body.paths["/alpha/list"]["get"].tags == ("alpha",)
assert loaded_lazy_modules(app) == {features[0].module_path}
assert "/alpha/list" in _paths(app) and "/beta/list" not in _paths(app)