fix(proxy): regenerate lazy OpenAPI snapshot and guard it in CI

The committed snapshot behind /openapi.json for unloaded lazy features had drifted on 30 of 31 fragments and never had one for a2a_registration or gemini_agents, so those routes showed as placeholder GET stubs or old docstrings until traffic loaded them. Regenerate the snapshot and schema.d.ts, make the check-ui-api-types job and make check regenerate the snapshot and fail on drift, and make the generator refuse to write a snapshot when any feature fails to import so a broken import cannot silently drop fragments.
This commit is contained in:
mateo-berri 2026-08-26 14:32:04 -07:00
parent f57e4b812c
commit afe5a240e5
6 changed files with 9889 additions and 1001 deletions

View file

@ -83,6 +83,24 @@ jobs:
if: steps.changes.outputs.relevant == 'true'
run: uv run --no-sync prisma generate --schema litellm/proxy/schema.prisma
- name: Regenerate the lazy OpenAPI snapshot
if: steps.changes.outputs.relevant == 'true'
run: uv run --no-sync python -m litellm.proxy._lazy_openapi_snapshot
- name: Fail if the lazy OpenAPI snapshot is stale
if: steps.changes.outputs.relevant == 'true'
run: |
if ! git diff --exit-code -- litellm/proxy/_lazy_openapi_snapshot.json; then
echo "::error file=litellm/proxy/_lazy_openapi_snapshot.json::The lazy OpenAPI snapshot is out of sync with the lazily loaded routes."
echo ""
echo "A lazily loaded route or model changed without regenerating the snapshot that /openapi.json serves for unloaded features."
echo "To fix, run from the repo root:"
echo " uv run python -m litellm.proxy._lazy_openapi_snapshot"
echo "then run npm run gen:api from ui/litellm-dashboard and commit both files."
exit 1
fi
echo "_lazy_openapi_snapshot.json is in sync with the lazily loaded routes."
- name: Set up Node.js
if: steps.changes.outputs.relevant == 'true'
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0

File diff suppressed because it is too large Load diff

View file

@ -3,18 +3,25 @@ Per-feature OpenAPI snapshot for lazy-loaded routers.
The committed JSON is generated by `python -m litellm.proxy._lazy_openapi_snapshot`
and consumed at runtime so /openapi.json can show full route info for unloaded
features without importing them. No CI job regenerates this file; drift surfaces
only indirectly through check-ui-api-types.yml, which rebuilds schema.d.ts from
app.openapi() with the committed snapshot injected. After changing any lazily
loaded route or this generator, rerun the module and commit the JSON, then run
`npm run gen:api` in ui/litellm-dashboard and commit schema.d.ts.
features without importing them. check-ui-api-types.yml (mirrored locally by
`make check`) regenerates this file and fails when the committed copy differs,
then rebuilds schema.d.ts from app.openapi() with the snapshot injected. After
changing any lazily loaded route or this generator, rerun the module and commit
the JSON, then run `npm run gen:api` in ui/litellm-dashboard and commit schema.d.ts.
"""
import json
import re
import sys
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Final
from typing import TYPE_CHECKING, Final
if TYPE_CHECKING:
from fastapi import FastAPI
from litellm.proxy._lazy_features import LazyFeature
SNAPSHOT_FILE: Final = Path(__file__).parent / "_lazy_openapi_snapshot.json"
HTTP_METHOD_SUFFIXES: Final = {
@ -83,20 +90,30 @@ def _normalize_operation_ids(paths: dict[str, dict]) -> None:
break
def generate_snapshot() -> dict[str, dict]:
@dataclass(frozen=True, slots=True)
class SnapshotResult:
fragments: dict[str, dict]
skipped: tuple[str, ...]
def _register_feature(app: "FastAPI", feat: "LazyFeature") -> str | None:
import importlib
try:
feat.register_fn(app, importlib.import_module(feat.module_path))
except Exception as exc:
sys.stderr.write(f"warning: skip {feat.name}: {exc}\n")
return feat.name
return None
def generate_snapshot() -> SnapshotResult:
from fastapi.openapi.utils import get_openapi
from litellm.proxy._lazy_features import LAZY_FEATURES
from litellm.proxy.proxy_server import app, ensure_unique_openapi_operation_ids
for feat in LAZY_FEATURES:
try:
module = importlib.import_module(feat.module_path)
feat.register_fn(app, module)
except Exception as exc:
sys.stderr.write(f"warning: skip {feat.name}: {exc}\n")
skipped: Final = tuple(name for feat in LAZY_FEATURES if (name := _register_feature(app, feat)) is not None)
fragments: Final[dict[str, dict]] = {}
used_operation_ids: Final[set[str]] = set()
@ -124,10 +141,21 @@ def generate_snapshot() -> dict[str, dict]:
"paths": paths,
"components": {"schemas": full.get("components", {}).get("schemas", {})},
}
return fragments
return SnapshotResult(fragments=fragments, skipped=skipped)
def main(snapshot_file: Path = SNAPSHOT_FILE, generate: Callable[[], SnapshotResult] = generate_snapshot) -> int:
result: Final = generate()
if result.skipped:
sys.stderr.write(
f"error: {len(result.skipped)} feature(s) failed to import, so their fragments would vanish from the "
f"snapshot: {', '.join(result.skipped)}\n"
)
return 1
snapshot_file.write_text(json.dumps(result.fragments, indent=2, sort_keys=True) + "\n")
sys.stdout.write(f"wrote {len(result.fragments)} feature fragments to {snapshot_file}\n")
return 0
if __name__ == "__main__":
fragments: Final = generate_snapshot()
SNAPSHOT_FILE.write_text(json.dumps(fragments, indent=2, sort_keys=True) + "\n")
sys.stdout.write(f"wrote {len(fragments)} feature fragments to {SNAPSHOT_FILE}\n")
sys.exit(main())

View file

@ -13,7 +13,7 @@
# - tests/e2e Python -> `make lint-e2e-basedpyright` (test-linting.yml's e2e type-check step)
# + raw HTTP client ban (test-code-quality.yml's check_e2e_no_raw_requests)
# - dashboard -> prettier + eslint + lint budgets (test-litellm-ui-build.yml's frontend-lint)
# - proxy/types -> regenerate dashboard API types and fail on drift (check-ui-api-types.yml)
# - proxy/types -> regenerate the lazy OpenAPI snapshot and dashboard API types, fail on drift (check-ui-api-types.yml)
#
# Each block is skipped when no matching files are in scope, so unrelated commits
# stay fast. This is intentionally not auto-installed as a git hook (see
@ -244,7 +244,7 @@ fi
genapi_checks() {
local status=0
echo "check: checking dashboard API types are in sync (npm run gen:api)"
echo "check: checking the lazy OpenAPI snapshot and dashboard API types are in sync (npm run gen:api)"
# gen-api-types.mjs imports litellm.proxy.proxy_server, which needs the proxy deps
# and an up-to-date Prisma client; check-ui-api-types.yml installs those and runs
# prisma generate before gen:api, so mirror that here or a stale client can mask
@ -260,7 +260,14 @@ genapi_checks() {
elif ! uv run --no-sync python scripts/prisma_generate_if_needed.py; then
echo "✗ Could not regenerate Prisma client (prisma generate failed)." >&2
status=1
elif ! uv run --no-sync python -m litellm.proxy._lazy_openapi_snapshot; then
echo "✗ Could not regenerate the lazy OpenAPI snapshot (python -m litellm.proxy._lazy_openapi_snapshot failed)." >&2
status=1
elif ( cd ui/litellm-dashboard && LITELLM_PYTHON="uv run --no-sync python" npm run gen:api ); then
if ! git diff --quiet -- litellm/proxy/_lazy_openapi_snapshot.json; then
echo "✗ The lazy OpenAPI snapshot is stale; regenerated litellm/proxy/_lazy_openapi_snapshot.json. Stage it and commit; re-run make check only if other checks failed too." >&2
status=1
fi
if ! git diff --quiet -- ui/litellm-dashboard/src/lib/http/schema.d.ts; then
echo "✗ Dashboard API types are stale; regenerated src/lib/http/schema.d.ts. Stage it and commit; re-run make check only if other checks failed too." >&2
status=1

View file

@ -1,8 +1,9 @@
import json
import sys
from types import ModuleType, SimpleNamespace
from litellm.proxy._lazy_features import LazyFeature
from litellm.proxy._lazy_openapi_snapshot import _normalize_operation_ids
from litellm.proxy._lazy_openapi_snapshot import SnapshotResult, _normalize_operation_ids, main
def test_generate_snapshot_uses_shared_operation_id_reservations(monkeypatch):
@ -61,7 +62,7 @@ def test_generate_snapshot_uses_shared_operation_id_reservations(monkeypatch):
monkeypatch.setitem(sys.modules, "litellm.proxy.proxy_server", fake_proxy_server_module)
monkeypatch.setattr("fastapi.openapi.utils.get_openapi", fake_get_openapi)
fragments = _lazy_openapi_snapshot.generate_snapshot()
fragments = _lazy_openapi_snapshot.generate_snapshot().fragments
assert fragments["feature-a"]["paths"]["/feature-a/items"]["get"]["operationId"] == "shared_operation_id_get"
assert fragments["feature-b"]["paths"]["/feature-b/items"]["get"]["operationId"] == "shared_operation_id_get_2"
@ -106,7 +107,7 @@ def test_generate_snapshot_registers_transitively_imported_modules(monkeypatch):
monkeypatch.setitem(sys.modules, "litellm.proxy.proxy_server", fake_proxy_server_module)
monkeypatch.setattr("fastapi.openapi.utils.get_openapi", fake_get_openapi)
fragments = _lazy_openapi_snapshot.generate_snapshot()
fragments = _lazy_openapi_snapshot.generate_snapshot().fragments
assert fragments["transitive"]["paths"]["/transitive/items"]["get"]["tags"] == ["transitive"]
assert "/v1/{param}/deep/leaf" in fragments["transitive"]["paths"]
@ -144,3 +145,63 @@ def test_normalize_operation_ids_preserves_custom_ids():
operations = paths["/proxy/{endpoint}"]
assert operations["get"]["operationId"] == "custom_operation"
assert operations["post"]["operationId"] == "custom_operation"
def test_generate_snapshot_reports_features_whose_import_fails(monkeypatch):
from litellm.proxy import _lazy_openapi_snapshot
fake_app = SimpleNamespace(title="LiteLLM test", version="0.0.0", routes=[])
fake_module = ModuleType("fake_importable_feature")
monkeypatch.setitem(sys.modules, "fake_importable_feature", fake_module)
def register_fn(app, module):
app.routes.append(SimpleNamespace(path="/importable/items"))
fake_lazy_features_module = ModuleType("litellm.proxy._lazy_features")
fake_lazy_features_module.LAZY_FEATURES = [
LazyFeature(
name="importable",
module_path="fake_importable_feature",
path_prefixes=("/importable",),
register_fn=register_fn,
),
LazyFeature(
name="broken",
module_path="litellm.proxy.this_module_does_not_exist",
path_prefixes=("/broken",),
),
]
monkeypatch.setitem(sys.modules, "litellm.proxy._lazy_features", fake_lazy_features_module)
def fake_get_openapi(title, version, routes):
return {"paths": {route.path: {"get": {"operationId": "importable_get"}} for route in routes}}
fake_proxy_server_module = ModuleType("litellm.proxy.proxy_server")
fake_proxy_server_module.app = fake_app
fake_proxy_server_module.ensure_unique_openapi_operation_ids = lambda schema, reserved_operation_ids: schema
monkeypatch.setitem(sys.modules, "litellm.proxy.proxy_server", fake_proxy_server_module)
monkeypatch.setattr("fastapi.openapi.utils.get_openapi", fake_get_openapi)
result = _lazy_openapi_snapshot.generate_snapshot()
assert result.skipped == ("broken",)
assert sorted(result.fragments) == ["importable"]
def test_main_refuses_to_write_a_snapshot_missing_skipped_features(tmp_path, capsys):
snapshot_file = tmp_path / "snapshot.json"
result = SnapshotResult(fragments={"importable": {"paths": {}, "components": {"schemas": {}}}}, skipped=("broken",))
assert main(snapshot_file, generate=lambda: result) == 1
assert not snapshot_file.exists()
assert "broken" in capsys.readouterr().err
def test_main_writes_sorted_snapshot_when_every_feature_loads(tmp_path):
snapshot_file = tmp_path / "snapshot.json"
fragments = {"zeta": {"paths": {"/z": {}}, "components": {"schemas": {}}}, "alpha": {"paths": {}, "components": {"schemas": {}}}}
assert main(snapshot_file, generate=lambda: SnapshotResult(fragments=fragments, skipped=())) == 0
assert json.loads(snapshot_file.read_text()) == fragments
assert snapshot_file.read_text() == json.dumps(fragments, indent=2, sort_keys=True) + "\n"

File diff suppressed because it is too large Load diff