mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
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:
parent
f57e4b812c
commit
afe5a240e5
6 changed files with 9889 additions and 1001 deletions
18
.github/workflows/check-ui-api-types.yml
vendored
18
.github/workflows/check-ui-api-types.yml
vendored
|
|
@ -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
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
2942
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
2942
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue