mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-25 05:54:53 +00:00
* test(proxy): keep answering requests while a cold /openapi.json builds * fix(proxy): build the OpenAPI schema off the event loop so a cold /openapi.json no longer stalls the worker * perf(proxy): reuse the rendered /openapi.json body until the schema changes * fix(proxy): scope the openapi build's pydantic patch to the build The schema build swapped a process-wide pydantic hook, which live requests saw once the build moved to a thread, and overlapping builds could leave it installed. The hook now checks a context variable set only inside the build. The proxy test counts liveliness answers instead of timing them. * test(proxy): hold the cold openapi build on an event instead of pacing on a clock * fix(proxy): serve a replaced app.openapi like FastAPI's own docs route * test(proxy): build the cold-docs test on the proxy module's current app * test(proxy): freeze the model that holds the cold docs build
154 lines
5.7 KiB
Python
154 lines
5.7 KiB
Python
import asyncio
|
|
import itertools
|
|
import threading
|
|
from _collections_abc import dict_items
|
|
from collections.abc import Callable
|
|
from typing import Final
|
|
|
|
import httpx
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
|
|
from litellm.proxy.common_utils.custom_openapi_spec import JsonValue
|
|
from litellm.proxy.common_utils.openapi_route import OpenAPISchema, serve_openapi_off_the_event_loop
|
|
|
|
UNBLOCK_TIMEOUT_SECONDS: Final = 5
|
|
|
|
|
|
def _route_paths(app: FastAPI) -> Callable[[], tuple[str, ...]]:
|
|
return lambda: tuple(str(getattr(route, "path", "")) for route in app.routes)
|
|
|
|
|
|
def _client(app: FastAPI, root_path: str = "") -> httpx.AsyncClient:
|
|
return httpx.AsyncClient(transport=httpx.ASGITransport(app=app, root_path=root_path), base_url="http://proxy")
|
|
|
|
|
|
async def _let_other_tasks_run() -> None:
|
|
for _ in range(50):
|
|
await asyncio.sleep(0)
|
|
|
|
|
|
async def test_other_requests_are_answered_while_the_schema_builds():
|
|
app: Final = FastAPI()
|
|
app.add_api_route("/ping", lambda: "pong")
|
|
build_started: Final = threading.Event()
|
|
ping_answered: Final = threading.Event()
|
|
|
|
def build(paths: tuple[str, ...]) -> OpenAPISchema:
|
|
build_started.set()
|
|
return {"ping_answered_during_build": ping_answered.wait(UNBLOCK_TIMEOUT_SECONDS)}
|
|
|
|
serve_openapi_off_the_event_loop(app, _route_paths(app), build)
|
|
async with _client(app) as client:
|
|
schema_request: Final = asyncio.ensure_future(client.get("/openapi.json"))
|
|
await asyncio.to_thread(build_started.wait, UNBLOCK_TIMEOUT_SECONDS)
|
|
ping: Final = await client.get("/ping")
|
|
ping_answered.set()
|
|
schema: Final = await schema_request
|
|
|
|
assert (ping.json(), schema.json()) == ("pong", {"ping_answered_during_build": True})
|
|
|
|
|
|
async def test_a_route_added_during_the_build_is_in_the_next_schema():
|
|
app: Final = FastAPI()
|
|
build_started: Final = threading.Event()
|
|
may_finish: Final = threading.Event()
|
|
|
|
def build(paths: tuple[str, ...]) -> OpenAPISchema:
|
|
build_started.set()
|
|
may_finish.wait(UNBLOCK_TIMEOUT_SECONDS)
|
|
return {"paths": [*paths]}
|
|
|
|
serve_openapi_off_the_event_loop(app, _route_paths(app), build)
|
|
async with _client(app) as client:
|
|
first_request: Final = asyncio.ensure_future(client.get("/openapi.json"))
|
|
await asyncio.to_thread(build_started.wait, UNBLOCK_TIMEOUT_SECONDS)
|
|
app.add_api_route("/registered-mid-build", lambda: "late")
|
|
may_finish.set()
|
|
first: Final = await first_request
|
|
second: Final = await client.get("/openapi.json")
|
|
|
|
assert ("/registered-mid-build" in first.json()["paths"], "/registered-mid-build" in second.json()["paths"]) == (
|
|
False,
|
|
True,
|
|
)
|
|
|
|
|
|
async def test_concurrent_cold_requests_share_one_build():
|
|
app: Final = FastAPI()
|
|
build_numbers: Final = itertools.count()
|
|
build_started: Final = threading.Event()
|
|
may_finish: Final = threading.Event()
|
|
|
|
def build(paths: tuple[str, ...]) -> OpenAPISchema:
|
|
build_started.set()
|
|
may_finish.wait(UNBLOCK_TIMEOUT_SECONDS)
|
|
return {"build": next(build_numbers)}
|
|
|
|
serve_openapi_off_the_event_loop(app, _route_paths(app), build)
|
|
async with _client(app) as client:
|
|
first_request: Final = asyncio.ensure_future(client.get("/openapi.json"))
|
|
await asyncio.to_thread(build_started.wait, UNBLOCK_TIMEOUT_SECONDS)
|
|
second_request: Final = asyncio.ensure_future(client.get("/openapi.json"))
|
|
await _let_other_tasks_run()
|
|
may_finish.set()
|
|
responses: Final = await asyncio.gather(first_request, second_request)
|
|
|
|
assert [response.json() for response in responses] == [{"build": 0}, {"build": 0}]
|
|
|
|
|
|
async def test_warm_requests_reuse_the_rendered_schema():
|
|
app: Final = FastAPI()
|
|
renders: Final = itertools.count()
|
|
|
|
class RenderCountingSchema(dict[str, JsonValue]):
|
|
def items(self) -> "dict_items[str, JsonValue]":
|
|
_ = next(renders)
|
|
return super().items()
|
|
|
|
serve_openapi_off_the_event_loop(app, _route_paths(app), lambda paths: RenderCountingSchema(paths=[*paths]))
|
|
async with _client(app) as client:
|
|
bodies: Final = [(await client.get("/openapi.json")).content for _ in range(3)]
|
|
|
|
assert (len(set(bodies)), next(renders)) == (1, 1)
|
|
|
|
|
|
async def test_a_rebuilt_schema_is_served_instead_of_the_previous_one():
|
|
app: Final = FastAPI()
|
|
builds: Final = itertools.count()
|
|
serve_openapi_off_the_event_loop(app, _route_paths(app), lambda paths: {"build": next(builds)})
|
|
|
|
async with _client(app) as client:
|
|
first: Final = await client.get("/openapi.json")
|
|
app.openapi_schema = None
|
|
second: Final = await client.get("/openapi.json")
|
|
|
|
assert (first.json(), second.json()) == ({"build": 0}, {"build": 1})
|
|
|
|
|
|
|
|
async def test_an_app_that_replaces_its_openapi_function_serves_that_schema():
|
|
app: Final = FastAPI()
|
|
serve_openapi_off_the_event_loop(app, _route_paths(app), lambda paths: {"built_by": "the proxy"})
|
|
app.openapi = lambda: {"built_by": "the operator"}
|
|
|
|
async with _client(app) as client:
|
|
response: Final = await client.get("/openapi.json")
|
|
|
|
assert response.json() == {"built_by": "the operator"}
|
|
|
|
@pytest.mark.parametrize(
|
|
("servers", "expected"),
|
|
[
|
|
([{"url": "https://docs.example"}], [{"url": "/api"}, {"url": "https://docs.example"}]),
|
|
([{"url": "/api"}], [{"url": "/api"}]),
|
|
],
|
|
)
|
|
async def test_the_root_path_is_listed_first_among_the_servers(servers: JsonValue, expected: JsonValue):
|
|
app: Final = FastAPI()
|
|
serve_openapi_off_the_event_loop(app, _route_paths(app), lambda paths: {"servers": servers})
|
|
|
|
async with _client(app, root_path="/api") as client:
|
|
response: Final = await client.get("/openapi.json")
|
|
|
|
assert response.json()["servers"] == expected
|