litellm/tests/e2e/batches/batch_client.py
mateo-berri 8b7c801d61 test(e2e): pin openai_passthrough routing, cost logging, and file list isolation
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.
2026-08-20 03:03:35 -07:00

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)