mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
Adds a black-box e2e suite under tests/e2e/batches/ for the batches and files API. Nothing is imported from the litellm codebase; the tests drive the live proxy over HTTP and verify state through the generated prisma client, so they catch real regressions rather than re-asserting internal calls. It covers the gemini managed-files upload, verified by reading the file back ACTIVE from the provider; a managed-object poll-cap guard for the #23472 OOM that seeds more than one page of rows into real Postgres and watches the poll cycle in the proxy logs; and a vertex streaming-upload memory guard for the LIT-3382 OOM (gated, for an environment with real memory headroom). The pure helpers have unit coverage, and the memory sampler raises rather than passing vacuously when the cgroup read is unavailable
344 lines
10 KiB
Python
344 lines
10 KiB
Python
"""The ONLY module permitted to call ``requests.*``.
|
|
|
|
Enforced by tests/code_coverage_tests/check_e2e_no_raw_requests.py. Every request
|
|
body / query / header / response is a pydantic model; outcomes are a tagged union
|
|
(``Result[R]``) so callers ``match`` on them instead of catching exceptions.
|
|
|
|
Named e2e_http (not http) so it does not shadow the stdlib ``http`` package that
|
|
requests itself imports.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Generic, Iterator, Literal, NewType, TypeVar, cast
|
|
|
|
import pytest
|
|
import requests
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
|
|
URL = NewType("URL", str)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class MultipartFile:
|
|
"""The file part of a multipart/form-data upload. Raw bytes (not a pydantic
|
|
model) since the content is passed straight to the encoder, never serialized to
|
|
JSON - and a batch JSONL can be large enough that a copy would matter."""
|
|
|
|
filename: str
|
|
content: bytes
|
|
content_type: str
|
|
field_name: str = "file"
|
|
|
|
|
|
class Headers(BaseModel):
|
|
"""Base for header models. Subclasses may alias to hyphenated header names
|
|
(e.g. ``x-litellm-api-key``); serialization uses by_alias."""
|
|
|
|
model_config = ConfigDict(populate_by_name=True)
|
|
|
|
|
|
class AuthHeaders(Headers):
|
|
# litellm accepts either; set whichever the call needs, leave the other None.
|
|
authorization: str | None = None
|
|
x_litellm_api_key: str | None = Field(default=None, alias="x-litellm-api-key")
|
|
|
|
|
|
class NoBody(BaseModel):
|
|
"""Empty body/query for routes that take none."""
|
|
|
|
|
|
# ---------- Result types ----------
|
|
|
|
R = TypeVar("R", bound=BaseModel)
|
|
|
|
|
|
class Success(BaseModel, Generic[R]):
|
|
kind: Literal["success"] = "success"
|
|
data: R
|
|
|
|
|
|
class NetworkError(BaseModel):
|
|
kind: Literal["network"] = "network"
|
|
message: str
|
|
|
|
|
|
class UnauthorizedError(BaseModel):
|
|
kind: Literal["unauthorized"] = "unauthorized"
|
|
|
|
|
|
class RateLimitedError(BaseModel):
|
|
kind: Literal["rate_limited"] = "rate_limited"
|
|
retry_after_seconds: int | None = None
|
|
# litellm overloads 429 for budget_exceeded too, so keep the body to tell them apart.
|
|
body: str = ""
|
|
|
|
|
|
class ValidationError(BaseModel):
|
|
kind: Literal["validation"] = "validation"
|
|
message: str
|
|
|
|
|
|
class UnknownApiError(BaseModel):
|
|
kind: Literal["unknown"] = "unknown"
|
|
status_code: int
|
|
body: str
|
|
|
|
|
|
type Result[R: BaseModel] = (
|
|
Success[R]
|
|
| NetworkError
|
|
| UnauthorizedError
|
|
| RateLimitedError
|
|
| ValidationError
|
|
| UnknownApiError
|
|
)
|
|
|
|
|
|
class ProbeResult(BaseModel):
|
|
"""A route's reachability: status + body, no schema validation. Healthy ==
|
|
route exists (not 404) and the handler did not crash (not 5xx)."""
|
|
|
|
status_code: int
|
|
body: str
|
|
|
|
@property
|
|
def healthy(self) -> bool:
|
|
return 200 <= self.status_code < 500 and self.status_code != 404
|
|
|
|
|
|
class StreamingResponse(BaseModel):
|
|
"""Raw outcome for calls whose body is provider-native or streamed: status, the
|
|
x-litellm-call-id header (== SpendLogs.request_id), the content-type (which
|
|
tells streaming `text/event-stream` from non-streaming `application/json`), and
|
|
the body. Used by passthrough and streaming, where one validated JSON model
|
|
does not fit."""
|
|
|
|
status_code: int
|
|
call_id: str | None = None # x-litellm-call-id header
|
|
content_type: str | None = None
|
|
body: str
|
|
chunks: int = 0 # streamed events (0 for non-streaming)
|
|
|
|
@property
|
|
def ok(self) -> bool:
|
|
return 200 <= self.status_code < 300
|
|
|
|
@property
|
|
def is_streaming(self) -> bool:
|
|
return "text/event-stream" in (self.content_type or "")
|
|
|
|
|
|
def _hdr(resp: requests.Response, name: str) -> str | None:
|
|
value = resp.headers.get(name)
|
|
return value if isinstance(value, str) else None
|
|
|
|
|
|
def unwrap[R: BaseModel](result: Result[R]) -> R:
|
|
match result:
|
|
case Success(data=data):
|
|
return data
|
|
case _:
|
|
raise AssertionError(result)
|
|
|
|
|
|
def is_ok[R: BaseModel](result: Result[R]) -> bool:
|
|
match result:
|
|
case Success():
|
|
return True
|
|
case _:
|
|
return False
|
|
|
|
|
|
def require_successful_call(result: StreamingResponse) -> None:
|
|
"""A call that should have succeeded but didn't is a hard failure, never a skip:
|
|
if the proxy can't make a call it's expected to, the test must fail."""
|
|
if result.ok:
|
|
return
|
|
pytest.fail(
|
|
f"upstream call failed (status {result.status_code}); body={result.body[:300]}"
|
|
)
|
|
|
|
|
|
def _headers(headers: BaseModel) -> dict[str, str]:
|
|
dumped: dict[str, object] = headers.model_dump(by_alias=True, exclude_none=True)
|
|
return {key: str(value) for key, value in dumped.items()}
|
|
|
|
|
|
def _params(params: BaseModel | None) -> dict[str, str]:
|
|
if params is None:
|
|
return {}
|
|
dumped: dict[str, object] = params.model_dump(by_alias=True, exclude_none=True)
|
|
return {key: str(value) for key, value in dumped.items()}
|
|
|
|
|
|
def _classify[R: BaseModel](
|
|
resp: requests.Response, response_type: type[R]
|
|
) -> Result[R]:
|
|
if resp.status_code == 401:
|
|
return UnauthorizedError()
|
|
if resp.status_code == 429:
|
|
return RateLimitedError(body=resp.text)
|
|
if not resp.ok:
|
|
return UnknownApiError(status_code=resp.status_code, body=resp.text)
|
|
try:
|
|
return Success(data=response_type.model_validate(resp.json()))
|
|
except Exception as exc: # noqa: BLE001 - any parse/validation failure is a value
|
|
return ValidationError(message=str(exc))
|
|
|
|
|
|
def post[R: BaseModel](
|
|
url: URL,
|
|
*,
|
|
headers: BaseModel,
|
|
json: BaseModel,
|
|
response_type: type[R],
|
|
timeout: float = 30.0,
|
|
) -> Result[R]:
|
|
try:
|
|
resp = requests.post(
|
|
str(url),
|
|
headers=_headers(headers),
|
|
json=json.model_dump(by_alias=True, exclude_none=True),
|
|
timeout=timeout,
|
|
)
|
|
except requests.RequestException as exc:
|
|
return NetworkError(message=str(exc))
|
|
return _classify(resp, response_type)
|
|
|
|
|
|
def get[R: BaseModel](
|
|
url: URL,
|
|
*,
|
|
headers: BaseModel,
|
|
params: BaseModel,
|
|
response_type: type[R],
|
|
timeout: float = 30.0,
|
|
) -> Result[R]:
|
|
try:
|
|
resp = requests.get(
|
|
str(url),
|
|
headers=_headers(headers),
|
|
params=params.model_dump(by_alias=True, exclude_none=True),
|
|
timeout=timeout,
|
|
)
|
|
except requests.RequestException as exc:
|
|
return NetworkError(message=str(exc))
|
|
return _classify(resp, response_type)
|
|
|
|
|
|
def delete[R: BaseModel](
|
|
url: URL,
|
|
*,
|
|
headers: BaseModel,
|
|
json: BaseModel,
|
|
response_type: type[R],
|
|
timeout: float = 30.0,
|
|
) -> Result[R]:
|
|
try:
|
|
resp = requests.delete(
|
|
str(url),
|
|
headers=_headers(headers),
|
|
json=json.model_dump(by_alias=True, exclude_none=True),
|
|
timeout=timeout,
|
|
)
|
|
except requests.RequestException as exc:
|
|
return NetworkError(message=str(exc))
|
|
return _classify(resp, response_type)
|
|
|
|
|
|
def probe(
|
|
url: URL, *, headers: BaseModel, params: BaseModel, timeout: float = 30.0
|
|
) -> ProbeResult:
|
|
try:
|
|
resp = requests.get(
|
|
str(url),
|
|
headers=_headers(headers),
|
|
params=params.model_dump(by_alias=True, exclude_none=True),
|
|
timeout=timeout,
|
|
)
|
|
except requests.RequestException as exc:
|
|
return ProbeResult(status_code=-1, body=str(exc))
|
|
return ProbeResult(status_code=resp.status_code, body=resp.text)
|
|
|
|
|
|
def _streaming_outcome(resp: requests.Response, stream: bool) -> StreamingResponse:
|
|
call_id = _hdr(resp, "x-litellm-call-id")
|
|
content_type = _hdr(resp, "content-type")
|
|
if not stream or not (200 <= resp.status_code < 300):
|
|
return StreamingResponse(
|
|
status_code=resp.status_code,
|
|
call_id=call_id,
|
|
content_type=content_type,
|
|
body=resp.text,
|
|
)
|
|
lines = cast("Iterator[bytes]", resp.iter_lines())
|
|
chunks = sum(1 for line in lines if line)
|
|
return StreamingResponse(
|
|
status_code=resp.status_code,
|
|
call_id=call_id,
|
|
content_type=content_type,
|
|
body="<streamed>",
|
|
chunks=chunks,
|
|
)
|
|
|
|
|
|
def send(
|
|
url: URL,
|
|
*,
|
|
headers: BaseModel,
|
|
json: BaseModel,
|
|
params: BaseModel | None = None,
|
|
stream: bool = False,
|
|
timeout: float = 60.0,
|
|
) -> StreamingResponse:
|
|
"""Raw POST returning the unparsed HTTP outcome: status, full body, and the
|
|
x-litellm-call-id header. For native/passthrough bodies and for calls judged by
|
|
status rather than a typed JSON model (e.g. a budget block is a non-2xx). With
|
|
``stream=True`` the SSE body is consumed and its events counted instead."""
|
|
try:
|
|
resp = requests.post(
|
|
str(url),
|
|
headers=_headers(headers),
|
|
params=_params(params),
|
|
json=json.model_dump(by_alias=True, exclude_none=True),
|
|
stream=stream,
|
|
timeout=timeout,
|
|
)
|
|
except requests.RequestException as exc:
|
|
return StreamingResponse(status_code=-1, body=str(exc))
|
|
return _streaming_outcome(resp, stream)
|
|
|
|
|
|
def stream(
|
|
url: URL, *, headers: BaseModel, json: BaseModel, timeout: float = 60.0
|
|
) -> StreamingResponse:
|
|
"""Streaming (SSE) call: consumes the stream counting events, and captures the
|
|
x-litellm-call-id + content-type headers. Body is elided."""
|
|
return send(url, headers=headers, json=json, stream=True, timeout=timeout)
|
|
|
|
|
|
def upload[R: BaseModel](
|
|
url: URL,
|
|
*,
|
|
headers: BaseModel,
|
|
form: BaseModel,
|
|
file: MultipartFile,
|
|
response_type: type[R],
|
|
timeout: float = 120.0,
|
|
) -> Result[R]:
|
|
"""multipart/form-data POST (e.g. /v1/files): the text fields come from `form`,
|
|
the file part from `file`. requests sets the multipart Content-Type + boundary,
|
|
so `headers` must carry only auth (no Content-Type)."""
|
|
try:
|
|
resp = requests.post(
|
|
str(url),
|
|
headers=_headers(headers),
|
|
data=form.model_dump(by_alias=True, exclude_none=True),
|
|
files={file.field_name: (file.filename, file.content, file.content_type)},
|
|
timeout=timeout,
|
|
)
|
|
except requests.RequestException as exc:
|
|
return NetworkError(message=str(exc))
|
|
return _classify(resp, response_type)
|