Skip to content

Commit 533a432

Browse files
Michael Riad ZakyMichael Riad Zaky
authored andcommitted
add openapi snapshotting to CI pipeline
1 parent a94851f commit 533a432

5 files changed

Lines changed: 31914 additions & 10 deletions

File tree

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
name: Check Lazy OpenAPI Snapshot
2+
3+
on:
4+
pull_request:
5+
branches:
6+
- main
7+
- litellm_internal_staging
8+
- "litellm_**"
9+
10+
permissions:
11+
contents: read
12+
checks: write
13+
14+
concurrency:
15+
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
16+
cancel-in-progress: true
17+
18+
jobs:
19+
verify:
20+
runs-on: ubuntu-latest
21+
timeout-minutes: 10
22+
steps:
23+
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
24+
with:
25+
persist-credentials: false
26+
27+
- name: Set up Python
28+
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
29+
with:
30+
python-version: "3.12"
31+
32+
- name: Set up uv
33+
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
34+
with:
35+
version: "0.10.9"
36+
37+
- name: Cache uv dependencies
38+
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
39+
with:
40+
path: |
41+
~/.cache/uv
42+
.venv
43+
key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock') }}
44+
restore-keys: |
45+
${{ runner.os }}-uv-
46+
47+
- name: Install dependencies
48+
run: uv sync --frozen --all-groups --all-extras
49+
50+
- name: Regenerate snapshot to /tmp
51+
id: regen
52+
run: |
53+
cp litellm/proxy/_lazy_openapi_snapshot.json /tmp/snapshot.committed.json
54+
uv run --no-sync python -m litellm.proxy._lazy_openapi_snapshot
55+
mv litellm/proxy/_lazy_openapi_snapshot.json /tmp/snapshot.fresh.json
56+
mv /tmp/snapshot.committed.json litellm/proxy/_lazy_openapi_snapshot.json
57+
58+
- name: Compare
59+
id: diff
60+
continue-on-error: true
61+
run: |
62+
diff -q /tmp/snapshot.fresh.json litellm/proxy/_lazy_openapi_snapshot.json
63+
64+
- name: Mark neutral if drift
65+
if: steps.diff.outcome == 'failure'
66+
uses: LouisBrunner/checks-action@v2.0.0
67+
with:
68+
token: ${{ secrets.GITHUB_TOKEN }}
69+
name: lazy-openapi-snapshot
70+
conclusion: neutral
71+
output: |
72+
{
73+
"title": "Lazy openapi snapshot is stale",
74+
"summary": "Run `python -m litellm.proxy._lazy_openapi_snapshot` and commit the regenerated `litellm/proxy/_lazy_openapi_snapshot.json`. Not blocking — the snapshot will regenerate at release if not committed."
75+
}

litellm/proxy/_lazy_features.py

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -309,15 +309,81 @@ async def _load(self, feat: LazyFeature) -> None:
309309

310310

311311
def attach_lazy_features(app: "FastAPI") -> None:
312+
app.include_router(_make_warmup_router(app))
312313
app.add_middleware(LazyFeatureMiddleware, fastapi_app=app)
313314

314315

316+
def _make_warmup_router(app: "FastAPI") -> "APIRouter":
317+
"""POST /lazy/warm/{name}: load a feature and return its partial openapi
318+
so the Swagger plugin can merge in-place without a full /openapi.json refetch."""
319+
from fastapi import APIRouter, HTTPException
320+
from fastapi.openapi.utils import get_openapi
321+
322+
router = APIRouter()
323+
324+
@router.post("/lazy/warm/{name}", include_in_schema=False)
325+
async def warm(name: str):
326+
feat = next((f for f in LAZY_FEATURES if f.name == name), None)
327+
if feat is None:
328+
raise HTTPException(404, f"unknown lazy feature: {name}")
329+
if feat.persistent_swagger_stub:
330+
return {"stub_path": None, "paths": {}, "components": {"schemas": {}}}
331+
332+
already = any(
333+
any(getattr(r, "path", "").startswith(p) for p in feat.path_prefixes)
334+
for r in app.routes
335+
)
336+
if not already:
337+
loop = asyncio.get_running_loop()
338+
module = await loop.run_in_executor(
339+
None, importlib.import_module, feat.module_path
340+
)
341+
feat.register_fn(app, module)
342+
app.openapi_schema = None
343+
344+
feat_routes = [
345+
r
346+
for r in app.routes
347+
if any(getattr(r, "path", "").startswith(p) for p in feat.path_prefixes)
348+
]
349+
full = get_openapi(title=app.title, version=app.version, routes=feat_routes)
350+
# Force all operations under one tag so they group under a single Swagger
351+
# section — many lazy modules tag routes inconsistently.
352+
for path_ops in full.get("paths", {}).values():
353+
for op in path_ops.values():
354+
if isinstance(op, dict):
355+
op["tags"] = [feat.name]
356+
return {
357+
"stub_path": feat.path_prefixes[0],
358+
"paths": full.get("paths", {}),
359+
"components": {"schemas": full.get("components", {}).get("schemas", {})},
360+
}
361+
362+
return router
363+
364+
315365
def inject_lazy_stubs(schema: Dict) -> Dict:
316-
"""Stub openapi entries for unloaded features so Swagger renders sections."""
366+
"""Inject openapi entries for unloaded features. Uses the snapshot file
367+
when available (full route info), otherwise falls back to a single
368+
placeholder per feature."""
369+
from litellm.proxy._lazy_openapi_snapshot import load_snapshot
370+
371+
snapshot = load_snapshot()
317372
paths = schema.setdefault("paths", {})
373+
schemas = schema.setdefault("components", {}).setdefault("schemas", {})
374+
318375
for feat in LAZY_FEATURES:
319376
if feat.module_path in sys.modules and not feat.persistent_swagger_stub:
320377
continue
378+
379+
fragment = (snapshot or {}).get(feat.name)
380+
if fragment:
381+
for p, ops in fragment.get("paths", {}).items():
382+
paths.setdefault(p, ops)
383+
for name, sch in fragment.get("components", {}).get("schemas", {}).items():
384+
schemas.setdefault(name, sch)
385+
continue
386+
321387
prefix = feat.path_prefixes[0]
322388
if prefix in paths:
323389
continue
@@ -333,8 +399,12 @@ def inject_lazy_stubs(schema: Dict) -> Dict:
333399

334400
def lazy_tag_to_prefix() -> Dict[str, str]:
335401
"""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."""
402+
Returns empty when the snapshot is loaded — the plugin is unnecessary
403+
because /openapi.json already has full route info."""
404+
from litellm.proxy._lazy_openapi_snapshot import load_snapshot
405+
406+
if load_snapshot():
407+
return {}
338408
return {
339409
feat.name: feat.path_prefixes[0]
340410
for feat in LAZY_FEATURES

0 commit comments

Comments
 (0)