litellm/tests/test_litellm/batches/test_responses_batch_cost.py
mubashir1osmani 65af77c43b merge(litellm_internal_staging): reconcile batch observability with per-line resilience
Staging split batch output-line costing into _safe_output_line_stats /
_compute_output_line_stats / _output_line_cost so one uncostable line can no
longer zero a whole batch, and added _provider_output_file_id so model-encoded
output file ids decode before the fetch. This branch's pass/fail counting was
written against the pre-split shape, where every None line meant a provider
failure.

Keep staging's structure and layer the counts on a three-way classification: a
provider-reported failure yields PROVIDER_FAILED, a provider-successful line
litellm cannot price yields UNCOSTABLE and stays in successful_requests billed
at $0. Without that split a litellm-side pricing gap would be reported to the
customer as a failed request and the counts would stop reconciling with the
provider's own request_counts.

Route the error-file fetch through _provider_output_file_id too, and carry the
new dataclass return through the callers staging added after this branch
forked.
2026-08-24 19:08:40 -04:00

119 lines
4.5 KiB
Python

"""Token and spend reconciliation for /v1/responses batches.
Regression guard for https://github.com/BerriAI/litellm/issues/35363: a batch
output line built by the Responses API reports ``input_tokens`` /
``output_tokens`` where a chat line reports ``prompt_tokens`` /
``completion_tokens``. The usage object was constructed straight from the raw
dict, which accepts the unrecognized names without raising and yields zeros, so
a completed Responses batch reconciled to 0 tokens and $0.00 spend with no
error, and per-key budgets were never charged for it.
Line shape decides the parse, not the batch's declared endpoint, so an output
file mixing Responses-shaped and chat-shaped lines sums across both.
"""
from typing import Literal, get_args, get_type_hints
import pytest
import litellm
import litellm.batches.batch_utils as bu
from litellm.types.llms.openai import CreateBatchRequest
MODEL = "gpt-5.6"
def _responses_line(input_tokens: int, output_tokens: int) -> dict:
return {
"response": {
"status_code": 200,
"body": {
"model": MODEL,
"usage": {
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"total_tokens": input_tokens + output_tokens,
},
},
}
}
def _chat_line(prompt_tokens: int, completion_tokens: int) -> dict:
return {
"response": {
"status_code": 200,
"body": {
"model": MODEL,
"usage": {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": prompt_tokens + completion_tokens,
},
},
}
}
def test_responses_shaped_usage_maps_onto_prompt_and_completion_tokens():
"""The Responses names land on the chat-shaped counters instead of being
dropped for unrecognized keys."""
usage = bu._get_batch_job_usage_from_response_body(
{"usage": {"input_tokens": 100, "output_tokens": 50, "total_tokens": 150}}
)
assert (usage.prompt_tokens, usage.completion_tokens, usage.total_tokens) == (100, 50, 150)
async def test_responses_batch_reconciles_to_real_tokens_and_spend(local_model_cost_map):
"""A completed Responses batch records the provider's token counts and a
non-zero spend at the model's batch rates."""
model_info = litellm.get_model_info(model=MODEL, custom_llm_provider="openai")
input_tokens = 33
output_tokens = 57
result = await bu.calculate_batch_cost_and_usage(
file_content_dictionary=[_responses_line(input_tokens, output_tokens)],
custom_llm_provider="openai",
model_name=MODEL,
model_info=model_info,
)
assert (result.usage.prompt_tokens, result.usage.completion_tokens, result.usage.total_tokens) == (
input_tokens,
output_tokens,
input_tokens + output_tokens,
)
assert result.models == [MODEL]
assert result.cost == pytest.approx(
input_tokens * model_info["input_cost_per_token_batches"]
+ output_tokens * model_info["output_cost_per_token_batches"]
)
assert result.cost > 0.0
async def test_mixed_shape_batch_output_sums_across_both_line_shapes(local_model_cost_map):
"""An output file carrying both line shapes sums both. A fix keyed off the
batch's declared endpoint rather than each line's shape would miss this."""
model_info = litellm.get_model_info(model=MODEL, custom_llm_provider="openai")
result = await bu.calculate_batch_cost_and_usage(
file_content_dictionary=[_responses_line(100, 50), _chat_line(33, 57)],
custom_llm_provider="openai",
model_name=MODEL,
model_info=model_info,
)
assert (result.usage.prompt_tokens, result.usage.completion_tokens, result.usage.total_tokens) == (133, 107, 240)
assert result.cost == pytest.approx(
133 * model_info["input_cost_per_token_batches"] + 107 * model_info["output_cost_per_token_batches"]
)
def test_create_batch_endpoint_accepts_v1_responses():
"""A type-checked caller can pass endpoint="/v1/responses", which the runtime
already forwarded correctly."""
endpoint_annotation = get_type_hints(CreateBatchRequest)["endpoint"]
assert "/v1/responses" in get_args(endpoint_annotation)
for create_fn in (litellm.create_batch, litellm.acreate_batch):
assert "/v1/responses" in get_args(get_type_hints(create_fn)["endpoint"])