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

120 lines
4.4 KiB
Python

"""Lifecycle contract and resource cleanup for stateful e2e tests.
Shared by every e2e suite under tests/e2e/. The proxy under test is
long-lived and never reset between tests, so anything a test creates (keys,
customers, teams, orgs, users, guardrails, budgets, ...) persists unless
explicitly deleted. Every check follows an init -> run -> teardown lifecycle;
teardown releases each resource init() created, even when run() raises.
In pytest terms (see conftest.py): the `resources` fixture's setup is init(),
the test body is run(), and the fixture's teardown is teardown().
"""
from dataclasses import dataclass, field
from typing import Callable, List, Protocol, runtime_checkable
from e2e_gateway import Gateway
from models import KeyGenerateBody
@runtime_checkable
class E2ECase(Protocol):
"""A stateful e2e check run against a long-lived proxy.
init() acquires resources, run() exercises behaviour and asserts, teardown()
releases everything init() created. teardown() must run even if init() fails
partway or run() raises.
"""
def init(self) -> None: ...
def run(self) -> None: ...
def teardown(self) -> None: ...
def run_case(case: E2ECase) -> None:
"""Drive a case through its lifecycle: init -> run -> teardown.
teardown always runs - even when init() fails partway or run() raises (or
skips) - so resources the case already registered on the long-lived proxy are
released. init() is inside the try because cases register cleanups
progressively (e.g. create team, then user, then key), and a failure after
the first creation must still release what came before.
"""
try:
case.init()
case.run()
finally:
case.teardown()
@runtime_checkable
class ResourceClient(Protocol):
"""Proxy operations the convenience creators use. Resource types without a
creator here are handled generically via ResourceManager.defer(). The Gateway
satisfies this."""
def generate_key(self, body: KeyGenerateBody) -> str: ...
def delete_key(self, key: str) -> None: ...
def delete_customers(self, user_ids: List[str]) -> None: ...
@runtime_checkable
class GatewayProvider(Protocol):
"""Every suite's client exposes the shared Gateway, which the resources fixture
uses for cleanup. The client adds its own route methods on top."""
@property
def gateway(self) -> Gateway: ...
@dataclass
class ResourceManager:
"""Registry of teardown actions for resources a test creates on the stateful
proxy.
Not limited to any resource type: register a cleanup with ``defer()`` for a
key, customer, team, org, user, guardrail, budget, MCP server - anything with
a delete. The two most common resources have sugar (``key``, ``customer``);
everything else is ``resources.defer(lambda: client.delete_team(team_id))``.
Cleanups run LIFO (so a resource is removed before whatever it depends on) and
best-effort (one failing cleanup never blocks the rest).
"""
client: ResourceClient
_cleanups: List[Callable[[], None]] = field(
default_factory=list
) # mutable-ok: append-only teardown registry
def init(self) -> None:
"""No global setup needed today; present for lifecycle symmetry."""
return None
def defer(self, cleanup: Callable[[], None]) -> None:
"""Register a teardown action for any resource the test just created."""
self._cleanups.append(cleanup)
def key(self, models: list[str] | None = None, user_id: str | None = "e2e-test-user") -> str:
"""Create a virtual key; delete it on teardown. `models` restricts which
models the key may call (None/[] means all). `user_id` is required for
managed-batch ACL: the proxy stores created_by=user_id and checks it on
retrieve/cancel; None here means the 403 guard fires."""
key = self.client.generate_key(KeyGenerateBody(models=models or [], user_id=user_id))
self.defer(lambda: self.client.delete_key(key))
return key
def customer(self, customer_id: str) -> str:
"""Track an end-user id (from the `user` param); delete it on teardown."""
self.defer(lambda: self.client.delete_customers([customer_id]))
return customer_id
def teardown(self) -> None:
for cleanup in reversed(self._cleanups):
try:
cleanup()
except Exception:
pass # best-effort: a failed cleanup must not block the rest