litellm/tests/e2e/batches/batch_client.py
Mateo Wang 326f17594e
fix(batches): return real batch token counts next to OpenAI's batch usage fields (#47740)
* fix(batches): read OpenAI batch usage into the batch token counts

* fix(batches): serve OpenAI's batch usage shape from retrieve and the cost poller

* fix(batches): keep the chat usage keys next to OpenAI's batch usage fields

Batch usage now carries every chat key it had plus OpenAI's five batch fields with the same counts, so poller-billed batches keep prompt_tokens and cache-write tokens, and older proxies still read the new rows. Usage with missing or null detail counts reads its real input and output counts.

* test(batches): check the batch usage counts in the e2e terminal-state test

* fix(types): apply nested include and exclude to both batch usage shapes

* fix(batches): log no cost for a batch retrieve that is not final yet

A retrieve now reads OpenAI's batch usage into the chat counts, so a retrieve
that saw the batch completed before its output file arrived priced those counts
for the logging callbacks, and the final retrieve priced the whole batch again.
A batch that is not final now logs no cost, as it did before.

Batch usage dumps also pass exclude_unset and exclude_defaults through to the
usage keys.

* fix(batches): pass through every usage detail field the provider sends

Batch dumps rebuilt input_tokens_details and output_tokens_details from the chat keys, which dropped any detail field past cached_tokens and reasoning_tokens that the merge base passed through. The dump now starts from the provider's stored details and only sets the two counts derived from the chat keys

* fix(batches): skip sync loggers on the cost poller's own retrieve and honor exclude_none on provider details

* fix(batches): keep sync logger events at $0 on poller-owned retrieves, pass unparsed provider counts through, log batch usage in chat shape

* fix(batches): store only chat usage extras on batch spend rows

Spend rows for batch retrieves copied OpenAI's batch fields (input_tokens,
output_tokens and their details) into metadata.additional_usage_values next
to the chat counts they duplicate. Build the row from the batch's chat usage,
shared with the logging payload through batch_chat_usage.
2026-10-29 18:29:30 -07:00

277 lines
8.6 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 typing import Final, Literal
from pydantic import BaseModel, ConfigDict, Field
from e2e_metadata import step
from proxy_client import ProxyClient
from e2e_http import (
FileUploadForm,
NoBody,
Result,
StreamingResponse,
UnknownApiError,
)
from models import LiteLLMParamsBody
UPLOAD_FILENAME = "batch_input.jsonl"
AZURE_FILE_EXPIRY_SECONDS: Final = 14 * 24 * 60 * 60
class ExpiringFileUploadForm(FileUploadForm):
expires_after_anchor: Literal["created_at"] = Field(default="created_at", alias="expires_after[anchor]")
expires_after_seconds: int = Field(default=AZURE_FILE_EXPIRY_SECONDS, alias="expires_after[seconds]")
def batch_upload_form(provider: str, *, target_model_names: str | None = None) -> FileUploadForm:
if provider == "azure":
return ExpiringFileUploadForm(target_model_names=target_model_names)
return FileUploadForm(target_model_names=target_model_names)
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
expires_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 BatchErrorItem(BaseModel):
code: str | None = None
line: int | None = None
message: str | None = None
class BatchErrorList(BaseModel):
object: str | None = None
data: list[BatchErrorItem] = []
class BatchUsage(BaseModel):
model_config = ConfigDict(frozen=True)
prompt_tokens: int | None = None
completion_tokens: int | None = None
total_tokens: int | None = None
input_tokens: int | None = None
output_tokens: 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
error_file_id: str | None = None
errors: BatchErrorList | None = None
metadata: dict[str, str] | None = None
completion_window: str | None = None
created_at: int | None = None
model: str | None = None
usage: BatchUsage | None = None
class BatchList(BaseModel):
object: str | None = None
data: list[BatchObject] = []
class FileDeleteResponse(BaseModel):
id: str
object: str | None = None
deleted: bool | None = None
class BatchCreateBody(BaseModel):
input_file_id: str
endpoint: str = "/v1/chat/completions"
completion_window: str = "24h"
model: str | None = None
metadata: dict[str, str] | None = None
class ModelQuery(BaseModel):
model: str | None = None
class BatchListQuery(BaseModel):
model: str | None = None
limit: int | 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
@step("Add a batch deployment named {model_name} that calls {litellm_params.model}")
def create_model(self, model_name: str, litellm_params: LiteLLMParamsBody) -> str:
return self.proxy.create_model(model_name, litellm_params, mode="batch")
@step("Delete the batch deployment")
def delete_model(self, model_id: str) -> None:
self.proxy.delete_model(model_id)
@step("Upload a batch input file to /v1/files")
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,
)
@step("Retrieve the uploaded file")
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,
)
@step("List the files the key can see from /v1/files")
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,
)
@step("Create a batch of {body.endpoint} requests from the uploaded file")
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,
)
@step("Retrieve the batch")
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,
)
@step("Cancel the batch")
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,
)
@step("List the batches the key can see from /v1/batches")
def list_batches(
self,
*,
key: str,
provider: str | None = None,
model: str | None = None,
limit: int | None = None,
) -> Result[BatchList]:
return self.proxy.transport.get(
_batches_path(provider),
headers=self.proxy.transport.bearer(key),
params=BatchListQuery(model=model, limit=limit),
response_type=BatchList,
)
@step("Delete the uploaded file")
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,
)
@step("Delete the uploaded file as the proxy admin")
def delete_file_as_admin(self, file_id: str, *, provider: str | None = None) -> Result[FileDeleteResponse]:
return self.proxy.transport.delete(
f"{_files_path(provider)}/{file_id}",
headers=self.proxy.transport.master,
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)