diff --git a/litellm/proxy/_lazy_features.py b/litellm/proxy/_lazy_features.py index fdd15a89aa5..c435234cbbc 100644 --- a/litellm/proxy/_lazy_features.py +++ b/litellm/proxy/_lazy_features.py @@ -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) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 9a3905da950..52d64588f3c 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -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 diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index fc9d0a96163..f6aebc9546d 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -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,