litellm/tests/e2e/batches/batch_client.py
Sameer Kankute a16d9c6f9e
test(e2e): add live batches suite across providers and routing scenarios (#30958)
* tests: add e2e tests for spend, budgets and llms

* style: make chained comparison of status_code clearer

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* remove e2e_tests folder

* test: add spend tracking tests

* fix: p0 issues, added types and shared functions for each test suite

* style: carry clearer status_code comparison into renamed e2e dir

* refactor: migrate to gateway client

* fix: add new tests, split gateway

* test(e2e): add live batches suite across providers and routing scenarios

* test(batches): cover real cost tracking on completed batch retrieve

* test(e2e): assert managed vs raw file and batch id shapes per routing scenario

* test(e2e): assert full response shape of each batches and files endpoint

* test(e2e): only accept transitional statuses for a freshly created batch

* test(prompt-factory): make test_convert_url deterministic with a data URL

picsum.photos is down (HTTP 522), so test_convert_url failed on every
run. Swap the live external image for an inline data: URL and assert the
round-trip through convert_url_to_base64 genuinely.

A data URL is already inline base64 image data, so convert_url_to_base64
now short-circuits it instead of attempting an impossible HTTP fetch;
add a regression for that branch in the mapped image_handling test

* fix: pass through async image data urls

* fix(image-handling): short-circuit data URLs in async path too

Bugbot flagged that convert_url_to_base64 returns data: base64 URLs
unchanged but async_convert_url_to_base64 still tried to fetch them,
so async OCR flows (Bedrock, Azure) would reject inline images the sync
path accepts. Add the same guard to the async function and a regression
test that asserts the async path returns the data URL without touching
the HTTP client

* Fix: openai batches lifecycle

* Fix: add e2e azure openai tests

* Fix e2e for vertex ai

* Add all models for testing

* test(managed-files): assert idempotent upsert in store_unified_file_id

store_unified_file_id switched from create to upsert to avoid
UniqueViolationError when re-storing the same unified_file_id (e.g.
batch output files stored before metadata is available). Update the
unit test to assert the upsert call and its create payload instead of
the removed create call.

* test(batches): reconcile vertex_ai native batch-id comment with fallback guard

* fix(test-config): keep rust-ocr models in model_list by moving files_settings after it

* fix(test-config): move batch models after OCR block to keep merge with internal_staging clean

* fix(batches): use '24hrs' completion window and allow managed-files listing with provider filter

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* style: ruff format transformation.py and endpoints.py

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(e2e/batches): set Azure raw_model to gpt-4.1-mini-batch to match deployed model

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(vertex-ai/batches): correct completion_window to 24h per Literal type definition

* test(vertex-ai/batches): align completion_window assertion to 24h

* fix: update managed file metadata on upsert

---------

Co-authored-by: mubashir1osmani <mubashir.osmani777@gmail.com>
Co-authored-by: Mateo Wang <277851410+mateo-berri@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 08:05:23 -07:00

165 lines
4.7 KiB
Python

"""Client for the batches e2e suite: file upload/download and the batch
operations (create / retrieve / cancel / list) over the shared Gateway.
`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 e2e_gateway import Gateway, build_gateway
from e2e_http import (
FileUploadForm,
NoBody,
Result,
StreamingResponse,
UnknownApiError,
)
class FileObject(BaseModel):
id: str
object: str | None = None
purpose: str | None = None
bytes: int | None = None
status: str | None = None
created_at: int | 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:
gateway: Gateway
def upload_file(
self,
*,
content: bytes,
form: FileUploadForm,
key: str,
model: str | None = None,
provider: str | None = None,
) -> Result[FileObject]:
return self.gateway.transport.upload(
_files_path(provider),
headers=self.gateway.transport.bearer(key),
form=form,
filename="batch_input.jsonl",
content=content,
params=ModelQuery(model=model),
response_type=FileObject,
)
def create_batch(
self, *, body: BatchCreateBody, key: str, provider: str | None = None
) -> StreamingResponse:
return self.gateway.transport.send(
_batches_path(provider),
headers=self.gateway.transport.bearer(key),
json=body,
)
def retrieve_batch(
self, batch_id: str, *, key: str, provider: str | None = None
) -> Result[BatchObject]:
return self.gateway.transport.get(
f"{_batches_path(provider)}/{batch_id}",
headers=self.gateway.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.gateway.transport.post(
f"{_batches_path(provider)}/{batch_id}/cancel",
headers=self.gateway.transport.bearer(key),
json=NoBody(),
response_type=BatchObject,
)
def list_batches(
self, *, key: str, provider: str | None = None
) -> Result[BatchList]:
return self.gateway.transport.get(
_batches_path(provider),
headers=self.gateway.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.gateway.transport.delete(
f"{_files_path(provider)}/{file_id}",
headers=self.gateway.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() -> BatchClient:
return BatchClient(gateway=build_gateway())