mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-13 03:59:12 +00:00
* feat(lens): consume the independent Lens service and shared UI * fix(lens): keep embedded setup stable and vendor UI before builds * chore(lens): pin embedded UI provenance to published Lens source * fix(lens): complete embedded UI extraction across CI builds * docs(lens): record latest main and removal-boundary validation * fix(lens): wire external chart credentials and ingestion modes * test(lens): cover adapter failures and fix extraction CI * docs(lens): refresh bundled chart setup instructions * test(lens): restore gateway adapter test package marker * build(lens): pin clean-cut UI and supported storage chart * test(ui): await guardrail scope menu before checking options * perf(lens): omit unused trace-team lookup from product requests * docs(lens): record final-image adapter permission checks * test(lens): assert delegated investigation authorization * refactor(lens): remove copied trace runtime and preserve spend logging * test(lens): verify independent gateway image upgrades and outages * test(lens): pin the chart qualification database image * fix(lens): update embedded UI metadata filters * test: wait for Lens chart pod identity convergence * fix(e2e): select Lens chart pods by deployment ownership * fix(e2e): select Helm migration ownership for Lens upgrades * test(lens): retain migration Jobs through rollout assertions * docs(helm): require existing database for migration hooks * test(lens): qualify release boundaries with embedded navigation update * docs(lens): record browser qualification for embedded tabs * fix(lens): remove unrelated CI and guardrail test changes * test(lens): give qualification tenants unique key aliases * feat(lens): embed the latest canonical Lens interface * fix(lens): qualify split inference through the gateway * fix(lens): preserve scoped auth and isolate delegated credentials * chore(lens): remove unrelated documentation and lint changes * fix(lens): bound concurrent forwarding buffers through response delivery * fix(lens): preserve OAuth2 dispatch without inference policies * chore(lens): adopt current shared onboarding UI * fix(lens): refresh shared setup UI and review context * fix(lens): bound uploads before gateway authentication * docs(lens): remove extraction evidence from the gateway repo * docs(lens): refresh shared setup prompt for paired releases
306 lines
12 KiB
Python
306 lines
12 KiB
Python
import asyncio
|
|
import gc
|
|
from collections.abc import AsyncIterator, Mapping
|
|
from typing import Annotated, Final
|
|
|
|
import httpx
|
|
import pytest
|
|
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Response
|
|
from starlette.requests import ClientDisconnect, Request
|
|
from starlette.types import Message
|
|
|
|
from litellm.proxy._types import LitellmUserRoles
|
|
from litellm.proxy.common_utils.http_parsing_utils import read_request_body
|
|
from litellm.proxy.lens.adapter import Connection, Identity, forward, router
|
|
from litellm.proxy.lens.buffering import FORWARD_BUFFER_BUDGET, BodyReservation, BufferBudget, LensRoute
|
|
from litellm.tracing.remote import MAX_RESPONSE_BYTES, LensConnection
|
|
|
|
IDENTITY: Final = Identity(
|
|
user_role=LitellmUserRoles.PROXY_ADMIN,
|
|
user_id="admin",
|
|
team_id=None,
|
|
org_id=None,
|
|
token=None,
|
|
models=(),
|
|
log_team_ids=(),
|
|
)
|
|
CONNECTION: Final = Connection(LensConnection("http://lens.test", "fixture-service-secret-32-characters"), "x" * 32)
|
|
|
|
|
|
def request(body: bytes) -> Request:
|
|
async def receive() -> Message:
|
|
return {"type": "http.request", "body": body, "more_body": False}
|
|
|
|
return Request(
|
|
{"type": "http", "method": "POST", "path": "/lens/datasets", "headers": [], "query_string": b""},
|
|
receive,
|
|
)
|
|
|
|
|
|
async def send_response(response: Response) -> None:
|
|
messages: list[Message] = []
|
|
|
|
async def receive() -> Message:
|
|
return {"type": "http.disconnect"}
|
|
|
|
async def send(message: Message) -> None:
|
|
messages.append(message)
|
|
|
|
await response({"type": "http"}, receive, send)
|
|
assert messages[-1]["body"] == b"result"
|
|
|
|
|
|
async def fetch(client: httpx.AsyncClient, budget: BufferBudget, body: bytes = b"data") -> Response:
|
|
return await forward(request(body), IDENTITY, "datasets", CONNECTION, client, 1000, budget=budget)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_body_budget_is_shared_and_held_until_the_downstream_send_finishes() -> None:
|
|
budget: Final = BufferBudget(10)
|
|
sending: Final = asyncio.Event()
|
|
finish: Final = asyncio.Event()
|
|
|
|
async def receive() -> Message:
|
|
return {"type": "http.disconnect"}
|
|
|
|
async def send(message: Message) -> None:
|
|
if message["type"] == "http.response.body":
|
|
sending.set()
|
|
await finish.wait()
|
|
|
|
async with httpx.AsyncClient(
|
|
transport=httpx.MockTransport(lambda _: httpx.Response(200, content=b"result"))
|
|
) as client:
|
|
first: Final = await fetch(client, budget)
|
|
task: Final = asyncio.create_task(first({"type": "http"}, receive, send))
|
|
await asyncio.wait_for(sending.wait(), timeout=1)
|
|
try:
|
|
with pytest.raises(HTTPException) as full:
|
|
await fetch(client, budget)
|
|
assert full.value.status_code == 503
|
|
assert full.value.headers == {"Retry-After": "1"}
|
|
finally:
|
|
finish.set()
|
|
await task
|
|
await send_response(await fetch(client, budget))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("body,payload", [(b"oversized request", b""), (b"data", b"oversized response")])
|
|
async def test_capacity_failure_releases_partial_request_and_response_reservations(body: bytes, payload: bytes) -> None:
|
|
budget: Final = BufferBudget(10)
|
|
async with httpx.AsyncClient(
|
|
transport=httpx.MockTransport(lambda _: httpx.Response(200, content=payload))
|
|
) as client:
|
|
with pytest.raises(HTTPException) as full:
|
|
await fetch(client, budget, body)
|
|
assert full.value.status_code == 503
|
|
async with httpx.AsyncClient(
|
|
transport=httpx.MockTransport(lambda _: httpx.Response(200, content=b"result"))
|
|
) as client:
|
|
await send_response(await fetch(client, budget))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("cancel", [False, True])
|
|
async def test_upstream_failure_or_cancellation_releases_request_bytes(cancel: bool) -> None:
|
|
budget: Final = BufferBudget(10)
|
|
|
|
async def unavailable(incoming: httpx.Request) -> httpx.Response:
|
|
assert incoming.content == b"data"
|
|
if cancel:
|
|
raise asyncio.CancelledError
|
|
raise httpx.ConnectError("Lens is unavailable")
|
|
|
|
async with httpx.AsyncClient(transport=httpx.MockTransport(unavailable)) as client:
|
|
with pytest.raises(asyncio.CancelledError if cancel else HTTPException):
|
|
await fetch(client, budget)
|
|
async with httpx.AsyncClient(
|
|
transport=httpx.MockTransport(lambda _: httpx.Response(200, content=b"result"))
|
|
) as client:
|
|
await send_response(await fetch(client, budget))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("cancel", [False, True])
|
|
async def test_downstream_failure_or_cancellation_releases_response_bytes(cancel: bool) -> None:
|
|
budget: Final = BufferBudget(10)
|
|
|
|
async def receive() -> Message:
|
|
return {"type": "http.disconnect"}
|
|
|
|
async def broken_send(message: Message) -> None:
|
|
if cancel:
|
|
raise asyncio.CancelledError
|
|
raise OSError("Browser disconnected")
|
|
|
|
async with httpx.AsyncClient(
|
|
transport=httpx.MockTransport(lambda _: httpx.Response(200, content=b"result"))
|
|
) as client:
|
|
response: Final = await fetch(client, budget)
|
|
with pytest.raises(asyncio.CancelledError if cancel else OSError):
|
|
await response({"type": "http"}, receive, broken_send)
|
|
await send_response(await fetch(client, budget))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_discarded_unsent_response_releases_the_body_reservation() -> None:
|
|
budget: Final = BufferBudget(10)
|
|
async with httpx.AsyncClient(
|
|
transport=httpx.MockTransport(lambda _: httpx.Response(200, content=b"result"))
|
|
) as client:
|
|
response = await fetch(client, budget)
|
|
assert response.body == b"result"
|
|
del response
|
|
gc.collect()
|
|
await send_response(await fetch(client, budget))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_capacity_rejection_does_not_consume_an_unbounded_incoming_stream() -> None:
|
|
budget: Final = BufferBudget(4)
|
|
consumed: list[bytes] = []
|
|
chunks: Final = iter((b"data", b"next", b"must-not-be-read"))
|
|
|
|
async def receive() -> Mapping[str, object]:
|
|
chunk: Final = next(chunks)
|
|
consumed.append(chunk)
|
|
return {"type": "http.request", "body": chunk, "more_body": True}
|
|
|
|
incoming: Final = Request(
|
|
{"type": "http", "method": "POST", "path": "/lens/datasets", "headers": [], "query_string": b""},
|
|
receive,
|
|
)
|
|
async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _: httpx.Response(200))) as client:
|
|
with pytest.raises(HTTPException) as full:
|
|
await forward(incoming, IDENTITY, "datasets", CONNECTION, client, 1000, budget=budget)
|
|
assert full.value.status_code == 503
|
|
assert consumed == [b"data", b"next"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("path", ["/lens/datasets", "/lens/service"])
|
|
async def test_router_rejects_capacity_before_authentication_reads_the_upload(path: str) -> None:
|
|
|
|
app: Final = FastAPI()
|
|
app.include_router(router)
|
|
occupied: Final = BodyReservation(FORWARD_BUFFER_BUDGET)
|
|
occupied.reserve(FORWARD_BUFFER_BUDGET.capacity - 4)
|
|
consumed: list[bytes] = []
|
|
|
|
async def chunks() -> AsyncIterator[bytes]:
|
|
for chunk in (b"part", b"next", b"must-not-be-read"):
|
|
consumed.append(chunk)
|
|
yield chunk
|
|
|
|
try:
|
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://gateway.test") as client:
|
|
response: Final = await client.request(
|
|
"GET" if path.endswith("service") else "POST", path, content=chunks()
|
|
)
|
|
assert response.status_code == 503
|
|
assert response.headers["retry-after"] == "1"
|
|
assert consumed == [b"part", b"next"]
|
|
occupied.reserve(4)
|
|
finally:
|
|
occupied.release()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_router_rejects_oversized_chunked_upload_before_authentication() -> None:
|
|
|
|
app: Final = FastAPI()
|
|
app.include_router(router)
|
|
|
|
async def chunks() -> AsyncIterator[bytes]:
|
|
yield b" " * MAX_RESPONSE_BYTES
|
|
yield b"x"
|
|
pytest.fail("Upload continued after the per-request limit")
|
|
|
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://gateway.test") as client:
|
|
response: Final = await client.post("/lens/datasets", content=chunks())
|
|
assert response.status_code == 413
|
|
assert response.json() == {"detail": "Lens request is too large"}
|
|
remaining: Final = BodyReservation(FORWARD_BUFFER_BUDGET)
|
|
remaining.reserve(FORWARD_BUFFER_BUDGET.capacity)
|
|
remaining.release()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("cancel", [False, True])
|
|
async def test_admission_releases_upload_on_disconnect_or_cancellation(cancel: bool) -> None:
|
|
|
|
app: Final = FastAPI()
|
|
app.include_router(router)
|
|
|
|
async def chunks() -> AsyncIterator[bytes]:
|
|
yield b"partial upload"
|
|
if cancel:
|
|
raise asyncio.CancelledError
|
|
raise ClientDisconnect
|
|
|
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://gateway.test") as client:
|
|
with pytest.raises(asyncio.CancelledError if cancel else ClientDisconnect):
|
|
await client.post("/lens/datasets", content=chunks())
|
|
remaining: Final = BodyReservation(FORWARD_BUFFER_BUDGET)
|
|
remaining.reserve(FORWARD_BUFFER_BUDGET.capacity)
|
|
remaining.release()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("reject_auth", [False, True])
|
|
async def test_admitted_bytes_survive_auth_parsing_and_remain_reserved_until_sent(reject_auth: bool) -> None:
|
|
|
|
app: Final = FastAPI()
|
|
router: Final = APIRouter(route_class=LensRoute)
|
|
sending: Final = asyncio.Event()
|
|
finish: Final = asyncio.Event()
|
|
occupied: Final = BodyReservation(FORWARD_BUFFER_BUDGET)
|
|
occupied.reserve(FORWARD_BUFFER_BUDGET.capacity - 10)
|
|
observed: list[Message] = []
|
|
|
|
async def auth_reader(request: Request) -> None:
|
|
assert await read_request_body(request) == {}
|
|
if reject_auth:
|
|
raise HTTPException(401, "Invalid key")
|
|
|
|
async with httpx.AsyncClient(
|
|
transport=httpx.MockTransport(lambda _: httpx.Response(200, content=b"result"))
|
|
) as upstream:
|
|
|
|
@router.post("/lens/datasets")
|
|
async def endpoint(request: Request, auth: Annotated[None, Depends(auth_reader)]) -> Response:
|
|
return await forward(request, IDENTITY, "datasets", CONNECTION, upstream, 1000)
|
|
|
|
@app.post("/ordinary")
|
|
async def ordinary(request: Request) -> Response:
|
|
return Response(await request.body())
|
|
|
|
app.include_router(router)
|
|
|
|
async def send(message: Message) -> None:
|
|
observed.append(message)
|
|
if message["type"] == "http.response.body":
|
|
sending.set()
|
|
await finish.wait()
|
|
|
|
incoming: Final = request(b" {} ")
|
|
task: Final = asyncio.create_task(app(incoming.scope, incoming.receive, send))
|
|
try:
|
|
await asyncio.wait_for(sending.wait(), 2)
|
|
async with httpx.AsyncClient(
|
|
transport=httpx.ASGITransport(app=app), base_url="http://gateway.test"
|
|
) as client:
|
|
blocked: Final = await client.post("/lens/datasets", content=b" {} " * 2)
|
|
unrelated: Final = await client.post("/ordinary", content=b"ordinary body")
|
|
assert blocked.status_code == 503
|
|
assert unrelated.content == b"ordinary body"
|
|
assert observed[0]["status"] == (401 if reject_auth else 200)
|
|
assert observed[-1]["body"] == (b'{"detail":"Invalid key"}' if reject_auth else b"result")
|
|
finally:
|
|
finish.set()
|
|
await task
|
|
occupied.release()
|
|
remaining: Final = BodyReservation(FORWARD_BUFFER_BUDGET)
|
|
remaining.reserve(FORWARD_BUFFER_BUDGET.capacity)
|
|
remaining.release()
|