mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
Five e2e tests over routes a customer drives through the gateway, each one
pinning a fix that currently has no live coverage.
The dedicated /openai_passthrough prefix used to be swallowed by the
provider-scoped /{provider}/v1/files and /{provider}/v1/batches routes, which
bound "openai_passthrough" as a provider name and failed inside the gateway
before ever reaching OpenAI. Two tests now upload a file and list batches
through that prefix and assert OpenAI's own objects come back.
Streamed /openai_passthrough/v1/responses and /openai_passthrough/v1/embeddings
are relayed to OpenAI but still have to be costed, since the customer budgets
against this traffic. Both used to land a row the gateway could not use: the
streamed responses call logged a zero-cost row under a random id, and
embeddings wrote no row at all. Each test now reconciles the logged spend and
token counts against the response the caller was actually served.
GET /v1/files narrowed its data to the caller's own rows but left first_id and
last_id addressing the shared provider account's page, handing any caller raw
provider file ids belonging to other tenants. The new test asserts both cursors
address rows in the page the caller can see.
ResourceManager.defer now accepts any callable rather than one returning None,
so a delete that answers with a response model can be deferred as-is.
207 lines
6.2 KiB
Python
207 lines
6.2 KiB
Python
"""Client for the batches e2e suite: file upload/download and the batch
|
|
operations (create / retrieve / cancel / list) over the shared ProxyClient.
|
|
|
|
Batch deployments are registered at runtime via /model/new (see conftest.py),
|
|
not baked into the proxy config. `create_batch` returns the raw HTTP outcome
|
|
(StreamingResponse) so a 403 model access denial and a provider-native batch
|
|
body both surface; the test parses BatchObject from the body. A `provider` arg
|
|
routes a call to /{provider}/v1/..., which the provider-fallback scenario needs
|
|
(its ids are raw, not model-encoded). The request/response models are
|
|
co-located here because only this suite uses them.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
|
|
from pydantic import BaseModel
|
|
|
|
from proxy_client import ProxyClient
|
|
from e2e_http import (
|
|
FileUploadForm,
|
|
NoBody,
|
|
Result,
|
|
StreamingResponse,
|
|
UnknownApiError,
|
|
)
|
|
from models import LiteLLMParamsBody
|
|
|
|
UPLOAD_FILENAME = "batch_input.jsonl"
|
|
|
|
|
|
class FileObject(BaseModel):
|
|
id: str
|
|
object: str | None = None
|
|
purpose: str | None = None
|
|
filename: str | None = None
|
|
bytes: int | None = None
|
|
status: str | None = None
|
|
created_at: int | None = None
|
|
|
|
|
|
class FileList(BaseModel):
|
|
"""GET /v1/files page. The cursors are modelled because they are part of the
|
|
page's isolation contract: they must address rows in `data`, never rows the
|
|
caller was not allowed to see."""
|
|
|
|
object: str | None = None
|
|
data: list[FileObject] = []
|
|
first_id: str | None = None
|
|
last_id: str | None = None
|
|
has_more: bool | None = None
|
|
|
|
|
|
class BatchObject(BaseModel):
|
|
id: str
|
|
object: str | None = None
|
|
status: str
|
|
endpoint: str | None = None
|
|
input_file_id: str | None = None
|
|
output_file_id: str | None = None
|
|
completion_window: str | None = None
|
|
created_at: int | None = None
|
|
model: str | None = None
|
|
|
|
|
|
class BatchList(BaseModel):
|
|
object: str | None = None
|
|
data: list[BatchObject] = []
|
|
|
|
|
|
class FileDeleteResponse(BaseModel):
|
|
id: str
|
|
object: str | None = None
|
|
deleted: bool
|
|
|
|
|
|
class BatchCreateBody(BaseModel):
|
|
input_file_id: str
|
|
endpoint: str = "/v1/chat/completions"
|
|
completion_window: str = "24h"
|
|
model: str | None = None
|
|
|
|
|
|
class ModelQuery(BaseModel):
|
|
model: str | None = None
|
|
|
|
|
|
def is_model_access_denied(resp: StreamingResponse) -> bool:
|
|
"""True if the proxy rejected the call because the key may not access the model."""
|
|
return resp.status_code == 403 and "key_model_access_denied" in resp.body
|
|
|
|
|
|
def is_result_access_denied[R: BaseModel](result: Result[R]) -> bool:
|
|
match result:
|
|
case UnknownApiError(status_code=403, body=body):
|
|
return "key_model_access_denied" in body
|
|
case _:
|
|
return False
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class BatchClient:
|
|
proxy: ProxyClient
|
|
|
|
def create_model(self, model_name: str, litellm_params: LiteLLMParamsBody) -> str:
|
|
return self.proxy.create_model(model_name, litellm_params, mode="batch")
|
|
|
|
def delete_model(self, model_id: str) -> None:
|
|
self.proxy.delete_model(model_id)
|
|
|
|
def upload_file(
|
|
self,
|
|
*,
|
|
content: bytes,
|
|
form: FileUploadForm,
|
|
key: str,
|
|
model: str | None = None,
|
|
provider: str | None = None,
|
|
) -> Result[FileObject]:
|
|
return self.proxy.transport.upload(
|
|
_files_path(provider),
|
|
headers=self.proxy.transport.bearer(key),
|
|
form=form,
|
|
filename=UPLOAD_FILENAME,
|
|
content=content,
|
|
params=ModelQuery(model=model),
|
|
response_type=FileObject,
|
|
)
|
|
|
|
def retrieve_file(
|
|
self, file_id: str, *, key: str, provider: str | None = None
|
|
) -> Result[FileObject]:
|
|
return self.proxy.transport.get(
|
|
f"{_files_path(provider)}/{file_id}",
|
|
headers=self.proxy.transport.bearer(key),
|
|
params=NoBody(),
|
|
response_type=FileObject,
|
|
)
|
|
|
|
def list_files(self, *, key: str, provider: str | None = None) -> Result[FileList]:
|
|
return self.proxy.transport.get(
|
|
_files_path(provider),
|
|
headers=self.proxy.transport.bearer(key),
|
|
params=NoBody(),
|
|
response_type=FileList,
|
|
)
|
|
|
|
def create_batch(
|
|
self, *, body: BatchCreateBody, key: str, provider: str | None = None
|
|
) -> StreamingResponse:
|
|
return self.proxy.transport.send(
|
|
_batches_path(provider),
|
|
headers=self.proxy.transport.bearer(key),
|
|
json=body,
|
|
)
|
|
|
|
def retrieve_batch(
|
|
self, batch_id: str, *, key: str, provider: str | None = None
|
|
) -> Result[BatchObject]:
|
|
return self.proxy.transport.get(
|
|
f"{_batches_path(provider)}/{batch_id}",
|
|
headers=self.proxy.transport.bearer(key),
|
|
params=NoBody(),
|
|
response_type=BatchObject,
|
|
)
|
|
|
|
def cancel_batch(
|
|
self, batch_id: str, *, key: str, provider: str | None = None
|
|
) -> Result[BatchObject]:
|
|
return self.proxy.transport.post(
|
|
f"{_batches_path(provider)}/{batch_id}/cancel",
|
|
headers=self.proxy.transport.bearer(key),
|
|
json=NoBody(),
|
|
response_type=BatchObject,
|
|
)
|
|
|
|
def list_batches(
|
|
self, *, key: str, provider: str | None = None
|
|
) -> Result[BatchList]:
|
|
return self.proxy.transport.get(
|
|
_batches_path(provider),
|
|
headers=self.proxy.transport.bearer(key),
|
|
params=NoBody(),
|
|
response_type=BatchList,
|
|
)
|
|
|
|
def delete_file(
|
|
self, file_id: str, *, key: str, provider: str | None = None
|
|
) -> Result[FileDeleteResponse]:
|
|
return self.proxy.transport.delete(
|
|
f"{_files_path(provider)}/{file_id}",
|
|
headers=self.proxy.transport.bearer(key),
|
|
json=NoBody(),
|
|
response_type=FileDeleteResponse,
|
|
)
|
|
|
|
|
|
def _files_path(provider: str | None) -> str:
|
|
return f"/{provider}/v1/files" if provider else "/v1/files"
|
|
|
|
|
|
def _batches_path(provider: str | None) -> str:
|
|
return f"/{provider}/v1/batches" if provider else "/v1/batches"
|
|
|
|
|
|
def build_client(proxy: ProxyClient) -> BatchClient:
|
|
return BatchClient(proxy=proxy)
|