fix(proxy): key lazy openapi stubs off registered features, not sys.modules

This commit is contained in:
mateo-berri 2026-08-26 15:18:55 -07:00
parent 632a007967
commit fd751a5023
3 changed files with 100 additions and 12 deletions

View file

@ -8,8 +8,8 @@ omits each feature's routes until the feature is warmed.
import asyncio
import importlib
import sys
from collections.abc import Callable
from collections.abc import Set as AbstractSet
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Final
@ -397,11 +397,27 @@ def _make_warmup_router(app: "FastAPI") -> "APIRouter":
return router
def inject_lazy_stubs(schema: dict) -> dict:
"""Inject openapi entries for unloaded features. Uses the snapshot file
when available (full route info), otherwise falls back to a single
placeholder per feature. Any failure logs and returns the schema unchanged
so /openapi.json never 500s on a cosmetic injection bug."""
def loaded_lazy_modules(app: "FastAPI") -> frozenset[str]:
"""The set of lazy feature modules whose routers are actually registered
on this app (tracked by _force_load), empty before the middleware ever ran.
sys.modules is the wrong signal: boot code imports several feature modules
(mcp_management, cloudzero, vantage, config_overrides) without mounting
their routers, and their stubs must still be injected."""
loaded: Final = getattr(app.state, "lazy_loaded", None)
if not isinstance(loaded, set):
return frozenset()
return frozenset(m for m in loaded if isinstance(m, str))
def inject_lazy_stubs(
schema: dict,
loaded_modules: AbstractSet[str],
features: tuple[LazyFeature, ...] = LAZY_FEATURES,
) -> dict:
"""Inject openapi entries for features not in loaded_modules. Uses the
snapshot file when available (full route info), otherwise falls back to a
single placeholder per feature. Any failure logs and returns the schema
unchanged so /openapi.json never 500s on a cosmetic injection bug."""
try:
from litellm.proxy._lazy_openapi_snapshot import load_snapshot
@ -409,8 +425,8 @@ def inject_lazy_stubs(schema: dict) -> dict:
paths: Final = schema.setdefault("paths", {})
schemas: Final = schema.setdefault("components", {}).setdefault("schemas", {})
for feat in LAZY_FEATURES:
if feat.module_path in sys.modules and not feat.persistent_swagger_stub:
for feat in features:
if feat.module_path in loaded_modules and not feat.persistent_swagger_stub:
continue
fragment = (snapshot or {}).get(feat.name)

View file

@ -1502,9 +1502,9 @@ def get_openapi_schema():
openapi_schema = CustomOpenAPISpec.add_llm_api_request_schema_body(openapi_schema)
# Stub unloaded lazy features so they appear as Swagger sections.
from litellm.proxy._lazy_features import inject_lazy_stubs
from litellm.proxy._lazy_features import inject_lazy_stubs, loaded_lazy_modules
openapi_schema = inject_lazy_stubs(openapi_schema)
openapi_schema = inject_lazy_stubs(openapi_schema, loaded_lazy_modules(app))
openapi_schema = ensure_unique_openapi_operation_ids(openapi_schema)
# Fix Swagger UI execute path error when server_root_path is set
@ -1534,9 +1534,9 @@ def custom_openapi():
openapi_schema = CustomOpenAPISpec.add_llm_api_request_schema_body(openapi_schema)
# Stub unloaded lazy features so they appear as Swagger sections.
from litellm.proxy._lazy_features import inject_lazy_stubs
from litellm.proxy._lazy_features import inject_lazy_stubs, loaded_lazy_modules
openapi_schema = inject_lazy_stubs(openapi_schema)
openapi_schema = inject_lazy_stubs(openapi_schema, loaded_lazy_modules(app))
openapi_schema = ensure_unique_openapi_operation_ids(openapi_schema)
# Fix Swagger UI execute path error when server_root_path is set

View file

@ -9152,6 +9152,78 @@ class TestLazyFeatureMiddleware:
)
class TestInjectLazyStubs:
"""Stub injection keys off the app-tracked loaded set, never sys.modules:
proxy boot imports several feature modules (mcp_management, cloudzero,
vantage, config_overrides) without mounting their routers, and their
/openapi.json entries must survive that (LIT-6275)."""
def test_imported_but_unregistered_module_still_gets_stub(self):
import sys
from litellm.proxy._lazy_features import LazyFeature, inject_lazy_stubs
feat = LazyFeature(
name="dummy_lazy_test",
module_path="json",
path_prefixes=("/dummy-lazy-test",),
)
assert feat.module_path in sys.modules
schema = inject_lazy_stubs({"paths": {}}, loaded_modules=frozenset(), features=(feat,))
assert "/dummy-lazy-test" in schema["paths"]
def test_registered_module_gets_no_stub(self):
from litellm.proxy._lazy_features import LazyFeature, inject_lazy_stubs
feat = LazyFeature(
name="dummy_lazy_test",
module_path="json",
path_prefixes=("/dummy-lazy-test",),
)
schema = inject_lazy_stubs({"paths": {}}, loaded_modules=frozenset({"json"}), features=(feat,))
assert "/dummy-lazy-test" not in schema["paths"]
def test_snapshot_fragments_injected_for_boot_imported_features(self):
from litellm.proxy._lazy_features import LAZY_FEATURES, inject_lazy_stubs
from litellm.proxy._lazy_openapi_snapshot import load_snapshot
snapshot = load_snapshot()
assert snapshot
boot_imported = tuple(
f for f in LAZY_FEATURES if f.name in ("mcp_management", "cloudzero", "vantage", "config_overrides")
)
assert len(boot_imported) == 4
schema = inject_lazy_stubs({"paths": {}}, loaded_modules=frozenset(), features=boot_imported)
for feat in boot_imported:
missing = [p for p in snapshot[feat.name]["paths"] if p not in schema["paths"]]
assert not missing, f"{feat.name} snapshot paths missing from /openapi.json: {missing}"
def test_persistent_stub_survives_load(self):
from litellm.proxy._lazy_features import LazyFeature, inject_lazy_stubs
feat = LazyFeature(
name="dummy_lazy_test",
module_path="json",
path_prefixes=("/dummy-lazy-test",),
persistent_swagger_stub=True,
)
schema = inject_lazy_stubs({"paths": {}}, loaded_modules=frozenset({"json"}), features=(feat,))
assert "/dummy-lazy-test" in schema["paths"]
def test_loaded_lazy_modules_reads_app_state(self):
from fastapi import FastAPI
from litellm.proxy._lazy_features import loaded_lazy_modules
app = FastAPI()
assert loaded_lazy_modules(app) == frozenset()
app.state.lazy_loaded = {"litellm.proxy.spend_tracking.cloudzero_endpoints"}
assert loaded_lazy_modules(app) == frozenset({"litellm.proxy.spend_tracking.cloudzero_endpoints"})
@pytest.mark.asyncio
async def test_get_current_spend_redis_clean_miss_skips_stale_in_memory():
"""When Redis is reachable and cleanly returns None (TTL expired,