Skip to content

Commit a94851f

Browse files
Michael Riad ZakyMichael Riad Zaky
authored andcommitted
swagger: stub-inject unloaded lazy features and warm on dropdown expand
1 parent 8ce072d commit a94851f

2 files changed

Lines changed: 73 additions & 2 deletions

File tree

litellm/proxy/_lazy_features.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@
88

99
import asyncio
1010
import importlib
11+
import sys
1112
from dataclasses import dataclass, field
12-
from typing import TYPE_CHECKING, Callable, Tuple
13+
from typing import TYPE_CHECKING, Callable, Dict, Tuple
1314

1415
from starlette.types import Receive, Scope, Send
1516

@@ -46,6 +47,9 @@ class LazyFeature:
4647
# For routes whose path has a leading parameter (e.g. /{server}/authorize)
4748
# — startswith can't match those, so the matcher also checks endswith.
4849
path_suffixes: Tuple[str, ...] = ()
50+
# Keep the stub injected even after load — for mounted ASGI sub-apps
51+
# whose routes don't appear in the parent app's openapi spec.
52+
persistent_swagger_stub: bool = False
4953

5054

5155
LAZY_FEATURES: Tuple[LazyFeature, ...] = (
@@ -158,6 +162,7 @@ class LazyFeature:
158162
module_path="litellm.proxy._experimental.mcp_server.server",
159163
path_prefixes=("/mcp",),
160164
register_fn=_mount_app("/mcp", attr_name="app"),
165+
persistent_swagger_stub=True,
161166
),
162167
LazyFeature(
163168
name="config_overrides",
@@ -305,3 +310,33 @@ async def _load(self, feat: LazyFeature) -> None:
305310

306311
def attach_lazy_features(app: "FastAPI") -> None:
307312
app.add_middleware(LazyFeatureMiddleware, fastapi_app=app)
313+
314+
315+
def inject_lazy_stubs(schema: Dict) -> Dict:
316+
"""Stub openapi entries for unloaded features so Swagger renders sections."""
317+
paths = schema.setdefault("paths", {})
318+
for feat in LAZY_FEATURES:
319+
if feat.module_path in sys.modules and not feat.persistent_swagger_stub:
320+
continue
321+
prefix = feat.path_prefixes[0]
322+
if prefix in paths:
323+
continue
324+
paths[prefix] = {
325+
"get": {
326+
"tags": [feat.name],
327+
"summary": feat.name,
328+
"responses": {"200": {"description": "OK"}},
329+
}
330+
}
331+
return schema
332+
333+
334+
def lazy_tag_to_prefix() -> Dict[str, str]:
335+
"""feature.name -> first prefix, used by the Swagger warmup JS plugin.
336+
Excludes persistent-stub features (mounted sub-apps) — warming them
337+
triggers a streaming hit and no useful new routes appear."""
338+
return {
339+
feat.name: feat.path_prefixes[0]
340+
for feat in LAZY_FEATURES
341+
if not feat.persistent_swagger_stub
342+
}

litellm/proxy/proxy_server.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1019,6 +1019,11 @@ def get_openapi_schema():
10191019

10201020
openapi_schema = CustomOpenAPISpec.add_llm_api_request_schema_body(openapi_schema)
10211021

1022+
# Stub unloaded lazy features so they appear as Swagger sections.
1023+
from litellm.proxy._lazy_features import inject_lazy_stubs
1024+
1025+
openapi_schema = inject_lazy_stubs(openapi_schema)
1026+
10221027
# Fix Swagger UI execute path error when server_root_path is set
10231028
if server_root_path:
10241029
openapi_schema["servers"] = [{"url": "/" + server_root_path.strip("/")}]
@@ -1045,6 +1050,11 @@ def custom_openapi():
10451050

10461051
openapi_schema = CustomOpenAPISpec.add_llm_api_request_schema_body(openapi_schema)
10471052

1053+
# Stub unloaded lazy features so they appear as Swagger sections.
1054+
from litellm.proxy._lazy_features import inject_lazy_stubs
1055+
1056+
openapi_schema = inject_lazy_stubs(openapi_schema)
1057+
10481058
# Fix Swagger UI execute path error when server_root_path is set
10491059
if server_root_path:
10501060
openapi_schema["servers"] = [{"url": "/" + server_root_path.strip("/")}]
@@ -1472,14 +1482,40 @@ def mount_swagger_ui():
14721482

14731483
app.mount("/swagger", StaticFiles(directory=swagger_directory), name="swagger")
14741484

1485+
# On dropdown expand: one-time fetch to the prefix (triggers lazy load),
1486+
# then spec re-download so real routes replace the stub. Raw JS (no
1487+
# <script> tag) since it's injected inside the existing inline script.
1488+
from fastapi.responses import HTMLResponse
1489+
1490+
from litellm.proxy._lazy_features import lazy_tag_to_prefix
1491+
1492+
_lazy_plugin_js = (
1493+
"const TAG_TO_PREFIX = "
1494+
+ json.dumps(lazy_tag_to_prefix())
1495+
+ ";const warmedTags = new Set();const LazyLoadPlugin = () => ({"
1496+
"statePlugins:{layout:{wrapActions:{show:(ori,sys)=>(...args)=>{"
1497+
"const thing=args[0];let tag=null;"
1498+
"if(Array.isArray(thing)){for(const t of thing)if(TAG_TO_PREFIX[t])tag=t;}"
1499+
"if(tag&&!warmedTags.has(tag)){warmedTags.add(tag);"
1500+
"fetch(TAG_TO_PREFIX[tag]).finally(()=>setTimeout(()=>sys.specActions.download(),800));}"
1501+
"return ori(...args);}}}}});"
1502+
)
1503+
14751504
def swagger_monkey_patch(*args, **kwargs):
1476-
return get_swagger_ui_html(
1505+
response = get_swagger_ui_html(
14771506
*args,
14781507
**kwargs,
14791508
swagger_js_url=f"{custom_root_path_swagger_path}/swagger-ui-bundle.js",
14801509
swagger_css_url=f"{custom_root_path_swagger_path}/swagger-ui.css",
14811510
swagger_favicon_url=f"{custom_root_path_swagger_path}/favicon.png",
14821511
)
1512+
body = response.body.decode("utf-8")
1513+
body = body.replace(
1514+
"const ui = SwaggerUIBundle({",
1515+
_lazy_plugin_js + "const ui = SwaggerUIBundle({plugins:[LazyLoadPlugin],",
1516+
1,
1517+
)
1518+
return HTMLResponse(content=body)
14831519

14841520
applications.get_swagger_ui_html = swagger_monkey_patch
14851521

0 commit comments

Comments
 (0)