litellm/tests/e2e/batches/test_batches_e2e.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

289 lines
9.9 KiB
Python

"""Live e2e for the Batches API across every provider LiteLLM supports.
Synchronous tier only: a batch's completion window is 24h, so these never wait for
"completed". Each case uploads a tiny JSONL, creates the batch through one of the
four routing scenarios, asserts it was accepted (non-terminal status) and routed to
the right provider, then retrieves / cancels / lists where the provider supports it.
Everything created is deleted on teardown. Completion + cost tracking are out of
scope here (see COVERAGE.md).
Routing signal: for provider_fallback the raw batch id discriminates the provider;
for the encoded/unified/model_param scenarios the proxy re-encodes the id, so the
load-bearing signal is that create SUCCEEDS against that provider's own model - a
misroute to the wrong provider fails the create.
"""
from __future__ import annotations
import json
import os
import time
from typing import Callable
import pytest
from batch_client import (
BatchClient,
BatchCreateBody,
BatchObject,
FileObject,
is_model_access_denied,
is_result_access_denied,
)
from capabilities import (
BATCH_ID_SHAPE,
CAPABILITIES,
FILE_ID_SHAPE,
Capability,
matches_id_shape,
raw_id_matches_provider,
)
from e2e_http import (
FileUploadForm,
Result,
StreamingResponse,
require_successful_call,
unwrap,
)
from lifecycle import ResourceManager
pytestmark = pytest.mark.e2e
CREATED_BATCH_STATUSES = {"validating", "in_progress", "finalizing"}
BATCH_CANCEL_DELAY_SECONDS = 2
BATCH_TERMINAL_BEFORE_CANCEL = {"failed", "cancelled", "expired"}
def render_jsonl(model: str) -> bytes:
line = {
"custom_id": "req-1",
"method": "POST",
"url": "/v1/chat/completions",
"body": {
"model": model,
"messages": [{"role": "user", "content": "ping"}],
"max_tokens": 8,
},
}
return (json.dumps(line) + "\n").encode()
def upload_for_scenario(
client: BatchClient, cap: Capability, content: bytes, key: str
) -> Result[FileObject]:
if cap.scenario == "encoded":
return client.upload_file(
content=content,
form=FileUploadForm(purpose="batch"),
model=cap.model,
key=key,
)
if cap.scenario == "unified":
return client.upload_file(
content=content,
form=FileUploadForm(purpose="batch", target_model_names=cap.model),
key=key,
)
return client.upload_file(
content=content,
form=FileUploadForm(purpose="batch"),
key=key,
provider=cap.provider,
)
def create_for_scenario(
client: BatchClient, cap: Capability, file_id: str, key: str
) -> StreamingResponse:
if cap.scenario == "model_param":
return client.create_batch(
body=BatchCreateBody(input_file_id=file_id, model=cap.model), key=key
)
if cap.scenario == "provider_fallback":
return client.create_batch(
body=BatchCreateBody(input_file_id=file_id), key=key, provider=cap.provider
)
return client.create_batch(body=BatchCreateBody(input_file_id=file_id), key=key)
def op_provider(cap: Capability) -> str | None:
"""provider_fallback ids are raw, so retrieve/cancel/list/delete need the provider
hint; the other scenarios encode it into the id and route automatically."""
return cap.provider if cap.scenario == "provider_fallback" else None
def quietly(action: Callable[[], object]) -> Callable[[], None]:
"""Adapt a value-returning call into a best-effort cleanup the teardown can run."""
def run() -> None:
action()
return run
def assert_file_object(file: FileObject) -> None:
assert file.object == "file", f"file.object={file.object!r}"
assert file.purpose == "batch", f"file.purpose={file.purpose!r}"
assert file.bytes is not None and file.bytes > 0, f"file.bytes={file.bytes!r}"
assert file.status, "file.status missing"
assert (
file.created_at is not None and file.created_at > 0
), "file.created_at missing"
def assert_batch_object(batch: BatchObject) -> None:
assert batch.object == "batch", f"batch.object={batch.object!r}"
if batch.endpoint:
assert (
batch.endpoint == "/v1/chat/completions"
), f"batch.endpoint={batch.endpoint!r}"
assert batch.completion_window == "24h", f"window={batch.completion_window!r}"
assert batch.input_file_id, "batch.input_file_id missing"
assert (
batch.created_at is not None and batch.created_at > 0
), "batch.created_at missing"
@pytest.mark.parametrize("cap", CAPABILITIES, ids=[c.id for c in CAPABILITIES])
def test_batch_lifecycle(
cap: Capability, client: BatchClient, resources: ResourceManager
) -> None:
key = resources.key()
provider = op_provider(cap)
file = unwrap(upload_for_scenario(client, cap, render_jsonl(cap.jsonl_model), key))
resources.defer(
quietly(lambda: client.delete_file(file.id, key=key, provider=provider))
)
assert_file_object(file)
assert matches_id_shape(
FILE_ID_SHAPE[cap.scenario], file.id
), f"{cap.id}: file id {file.id!r} is not a {FILE_ID_SHAPE[cap.scenario]} id"
created = create_for_scenario(client, cap, file.id, key)
require_successful_call(created)
batch = BatchObject.model_validate_json(created.body)
resources.defer(
quietly(lambda: client.cancel_batch(batch.id, key=key, provider=provider))
)
assert batch.id, f"create returned no batch id (body={created.body[:200]})"
assert (
batch.status in CREATED_BATCH_STATUSES
), f"freshly created batch has non-transitional status {batch.status!r}"
assert_batch_object(batch)
assert matches_id_shape(
BATCH_ID_SHAPE[cap.scenario], batch.id
), f"{cap.id}: batch id {batch.id!r} is not a {BATCH_ID_SHAPE[cap.scenario]} id"
if cap.scenario == "provider_fallback":
assert raw_id_matches_provider(
cap.provider, batch.id
), f"{cap.provider} batch id {batch.id!r} not in that provider's native shape; misrouted?"
fetched = unwrap(client.retrieve_batch(batch.id, key=key, provider=provider))
assert_batch_object(fetched)
assert fetched.id == batch.id
assert (
fetched.input_file_id == batch.input_file_id
), "retrieve changed input_file_id"
assert fetched.status, "retrieved batch has no status"
if cap.can_cancel:
time.sleep(BATCH_CANCEL_DELAY_SECONDS)
pre_cancel = unwrap(client.retrieve_batch(batch.id, key=key, provider=provider))
assert (
pre_cancel.status not in BATCH_TERMINAL_BEFORE_CANCEL
), (
f"batch reached {pre_cancel.status!r} before cancel; "
"provider likely rejected the input"
)
if pre_cancel.status == "completed":
return
cancelled = unwrap(client.cancel_batch(batch.id, key=key, provider=provider))
assert cancelled.id == batch.id
assert cancelled.object == "batch"
# Vertex cancel is async: the job may still show its pre-cancel status
# briefly before transitioning to cancelling/cancelled.
valid_post_cancel = {"cancelling", "cancelled"}
if cap.provider == "vertex_ai":
valid_post_cancel |= CREATED_BATCH_STATUSES
assert cancelled.status in valid_post_cancel, (
f"unexpected post-cancel status {cancelled.status!r}"
)
if cap.can_list:
listed = unwrap(client.list_batches(key=key, provider=provider))
# OpenAI includes object="list"; Azure provider list often omits the envelope field.
if listed.object is not None:
assert listed.object == "list", f"list envelope object={listed.object!r}"
match = next((b for b in listed.data if b.id == batch.id), None)
assert match is not None, "created batch absent from list"
assert match.object == "batch"
def test_batch_key_model_access_denied(
client: BatchClient, resources: ResourceManager
) -> None:
key = resources.key(models=["openai-batch"])
denied_upload = client.upload_file(
content=render_jsonl("azure-batch"),
form=FileUploadForm(purpose="batch"),
model="azure-batch",
key=key,
)
assert is_result_access_denied(
denied_upload
), f"restricted key uploaded a file for a disallowed model: {denied_upload}"
raw_file = unwrap(
client.upload_file(
content=render_jsonl("openai-batch"),
form=FileUploadForm(purpose="batch"),
key=key,
provider="openai",
)
).id
resources.defer(
quietly(lambda: client.delete_file(raw_file, key=key, provider="openai"))
)
denied_create = client.create_batch(
body=BatchCreateBody(input_file_id=raw_file, model="azure-batch"), key=key
)
assert is_model_access_denied(
denied_create
), f"restricted key created a batch for a disallowed model (status {denied_create.status_code})"
def test_file_upload_and_delete_outputs(
client: BatchClient, resources: ResourceManager
) -> None:
key = resources.key()
file = unwrap(
client.upload_file(
content=render_jsonl("openai-batch"),
form=FileUploadForm(purpose="batch"),
model="openai-batch",
key=key,
)
)
assert_file_object(file)
deleted = unwrap(client.delete_file(file.id, key=key))
assert deleted.id, "delete response has no id"
assert deleted.object == "file", f"delete object={deleted.object!r}"
assert deleted.deleted is True, "file was not reported deleted"
def test_anthropic_batch_retrieve(client: BatchClient, scoped_key: str) -> None:
batch_id = os.environ.get("ANTHROPIC_BATCH_ID")
if not batch_id:
pytest.skip(
"set ANTHROPIC_BATCH_ID to a real anthropic batch id to exercise retrieve"
)
fetched = unwrap(
client.retrieve_batch(batch_id, key=scoped_key, provider="anthropic")
)
assert fetched.id == batch_id
assert fetched.status