litellm/tests/unit/batches/test_batch_line_items.py
Kerry Lu c6c303a618
fix(batches): apply the Vertex regional endpoint uplift to batch cost (#47466)
* fix(batches): apply the Vertex regional endpoint uplift to batch cost

batch_cost_calculator takes vertex_location and multiplies by the model's
regional_endpoint_uplift_multiplier through get_vertex_regional_endpoint_uplift,
and every batch cost path (aretrieve_batch pricing, completed-batch logging,
native Vertex batch rows, the proxy batch cost poll and its per-line items)
resolves the deployment's location and passes it down

Co-authored-by: Mrun25 <165297479+Mrun25@users.noreply.github.com>

* test(logging): point the resolver tests at resolve_vertex_location_for_cost

---------

Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Mrun25 <165297479+Mrun25@users.noreply.github.com>
2026-10-20 01:51:51 +00:00

459 lines
18 KiB
Python

import json
import logging
from hashlib import sha256
import pytest
import litellm
from litellm.batches.batch_line_items import (
BatchLineItemSource,
batch_line_item_parts,
dispatch_batch_line_items,
line_item_metadata,
without_request_and_response,
)
from litellm.batches.batch_utils import calculate_batch_cost_and_usage, get_file_content_as_dictionary
from litellm.constants import BATCH_LINE_ITEMS_MAX_PART_BYTES
from litellm.integrations.batch_line_item_objects import batch_line_items_ndjson, batch_line_items_object_name
from litellm.integrations.custom_logger import CustomLogger
from litellm.llms.vertex_ai.files.transformation import VertexAIFilesConfig, _OpenAIToVertexBatchUploadStream
from litellm.types.utils import ModelInfo, StandardBatchLineItemsPayload
INPUT_RATE = 0.001
OUTPUT_RATE = 0.002
FINISHED_AT = 1791494130
ALICE_KEY_HASH = sha256(b"sk-alice").hexdigest()
def _jsonl(*lines: dict) -> bytes:
return "".join(json.dumps(line) + "\n" for line in lines).encode()
def _request(custom_id: str, text: str) -> dict:
return {
"custom_id": custom_id,
"method": "POST",
"url": "/v1/chat/completions",
"body": {"model": "gpt-4o-mini", "messages": [{"role": "user", "content": text}]},
}
def _success(custom_id: str, prompt_tokens: int, completion_tokens: int) -> dict:
return {
"id": f"batch_req_{custom_id}",
"custom_id": custom_id,
"response": {
"status_code": 200,
"body": {
"model": "gpt-4o-mini",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}}],
"usage": {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": prompt_tokens + completion_tokens,
},
},
},
"error": None,
}
def _error(custom_id: str) -> dict:
return {
"id": f"batch_req_{custom_id}",
"custom_id": custom_id,
"response": {"status_code": 400, "body": {"error": {"message": "max_tokens is too large"}}},
"error": None,
}
def _source(**overrides) -> BatchLineItemSource:
fields = {
"batch_id": "bGl0ZWxsbV9wcm94eTtiYXRjaA",
"finished_at": FINISHED_AT,
"model": "gpt-4o-mini",
"custom_llm_provider": "openai",
"cost_provider": "openai",
"model_info": ModelInfo(input_cost_per_token_batches=INPUT_RATE, output_cost_per_token_batches=OUTPUT_RATE),
"metadata": line_item_metadata(
{"user_api_key_hash": ALICE_KEY_HASH, "user_api_key_team_id": "team-alpha", "tags": ["env:prod"]}
),
}
return BatchLineItemSource(**{**fields, **overrides})
def _whole_batch(source: BatchLineItemSource, input_content: bytes, result_contents: tuple[bytes, ...]):
"""Every line of a batch small enough for one part."""
(payload,) = batch_line_item_parts(source, input_content, result_contents, BATCH_LINE_ITEMS_MAX_PART_BYTES)
return payload
def _payload() -> StandardBatchLineItemsPayload:
return _whole_batch(
_source(),
_jsonl(_request("ok-1", "first question"), _request("bad-1", "second question")),
(_jsonl(_success("ok-1", 100, 50)), _jsonl(_error("bad-1"))),
)
class _RecordingLogger(CustomLogger):
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self.received: list[StandardBatchLineItemsPayload] = []
async def async_log_batch_line_items(self, payload: StandardBatchLineItemsPayload) -> None:
self.received.append(payload)
class _FailingLogger(CustomLogger):
async def async_log_batch_line_items(self, payload: StandardBatchLineItemsPayload) -> None:
raise RuntimeError("bucket is gone")
@pytest.mark.asyncio
async def test_each_result_line_is_paired_with_the_request_that_shares_its_custom_id():
payload = _whole_batch(
_source(),
_jsonl(_request("ok-1", "first"), _request("bad-1", "second"), _request("never-ran", "third")),
(_jsonl(_success("ok-1", 100, 50)), _jsonl(_error("bad-1"))),
)
assert payload["batch_id"] == "bGl0ZWxsbV9wcm94eTtiYXRjaA"
assert payload["finished_at"] == FINISHED_AT
succeeded, failed = payload["lines"]
assert succeeded == {
"batch_id": "bGl0ZWxsbV9wcm94eTtiYXRjaA",
"custom_id": "ok-1",
"status": "success",
"request": _request("ok-1", "first"),
"response": _success("ok-1", 100, 50),
"usage": {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150},
"response_cost": pytest.approx(100 * INPUT_RATE + 50 * OUTPUT_RATE),
"model": "gpt-4o-mini",
"custom_llm_provider": "openai",
"metadata": {
"user_api_key_hash": ALICE_KEY_HASH,
"user_api_key_alias": None,
"user_api_key_team_id": "team-alpha",
"user_api_key_team_alias": None,
"user_api_key_user_id": None,
"user_api_key_org_id": None,
},
}
assert failed["custom_id"] == "bad-1"
assert failed["status"] == "failure"
assert failed["request"] == _request("bad-1", "second")
assert failed["response"] == _error("bad-1")
assert failed["usage"] is None
assert failed["response_cost"] == 0
@pytest.mark.asyncio
async def test_line_costs_add_up_to_the_batch_cost_the_spend_row_records():
outputs = _jsonl(_success("a", 100, 50), _success("b", 7, 3), _error("c"))
payload = _whole_batch(_source(), b"", (outputs,))
aggregate = await calculate_batch_cost_and_usage(
get_file_content_as_dictionary(outputs), "openai", model_name="gpt-4o-mini", model_info=_source().model_info
)
assert [line["response_cost"] for line in payload["lines"]] == pytest.approx(
[100 * INPUT_RATE + 50 * OUTPUT_RATE, 7 * INPUT_RATE + 3 * OUTPUT_RATE, 0]
)
assert sum(line["response_cost"] for line in payload["lines"]) == pytest.approx(aggregate.cost)
@pytest.mark.asyncio
async def test_anthropic_results_without_an_input_file_keep_their_status_and_usage():
succeeded = {
"custom_id": "a-1",
"result": {
"type": "succeeded",
"message": {"model": "claude-sonnet-5-5", "usage": {"input_tokens": 10, "output_tokens": 4}},
},
}
errored = {"custom_id": "a-2", "result": {"type": "errored", "error": {"type": "invalid_request_error"}}}
payload = _whole_batch(
_source(model="claude-sonnet-5-5", custom_llm_provider="anthropic", cost_provider="anthropic"),
b"",
(_jsonl(succeeded, errored),),
)
first, second = payload["lines"]
assert (first["custom_id"], first["status"], first["request"]) == ("a-1", "success", None)
assert first["usage"] == {"prompt_tokens": 10, "completion_tokens": 4, "total_tokens": 14}
assert (second["custom_id"], second["status"], second["usage"]) == ("a-2", "failure", None)
@pytest.mark.asyncio
async def test_bedrock_lines_pair_by_record_id():
request = {"recordId": "r-1", "modelInput": {"messages": [{"role": "user", "content": "hi"}]}}
result = {**request, "modelOutput": {"usage": {"input_tokens": 3, "output_tokens": 2}}}
payload = _whole_batch(
_source(custom_llm_provider="bedrock", cost_provider="bedrock"), _jsonl(request), (_jsonl(result),)
)
(line,) = payload["lines"]
assert (line["custom_id"], line["status"], line["request"]) == ("r-1", "success", request)
def test_vertex_lines_pair_with_the_input_row_that_carries_their_custom_id():
vertex_input = b"\n".join(
_OpenAIToVertexBatchUploadStream(
_jsonl(_request("vx-0", "first"), _request("vx-1", "second")),
VertexAIFilesConfig()._map_openai_to_vertex_params,
).iter_bytes()
)
vertex_rows = get_file_content_as_dictionary(vertex_input)
assert all("custom_id" not in row for row in vertex_rows)
payload = _whole_batch(
_source(model="gemini-2.5-flash", custom_llm_provider="vertex_ai", cost_provider="vertex_ai"),
vertex_input,
(_jsonl(_success("vx-1", 4, 2), _success("vx-0", 3, 1)),),
)
assert [(line["custom_id"], line["request"]) for line in payload["lines"]] == [
("vx-1", vertex_rows[1]),
("vx-0", vertex_rows[0]),
]
def test_vertex_lines_on_a_regional_location_cost_the_uplift_over_global():
outputs = _jsonl(_success("vx-0", 100, 50))
model_info = ModelInfo(
input_cost_per_token_batches=INPUT_RATE,
output_cost_per_token_batches=OUTPUT_RATE,
regional_endpoint_uplift_multiplier=1.1,
)
def line_cost(vertex_location: str) -> float:
source = _source(
model="gemini-3.5-flash",
custom_llm_provider="vertex_ai",
cost_provider="vertex_ai",
model_info=model_info,
vertex_location=vertex_location,
)
(line,) = _whole_batch(source, b"", (outputs,))["lines"]
return line["response_cost"]
assert line_cost("global") == pytest.approx(100 * INPUT_RATE + 50 * OUTPUT_RATE)
assert line_cost("us-central1") == pytest.approx(
line_cost("global") * model_info["regional_endpoint_uplift_multiplier"]
)
def test_a_vertex_embeddings_entry_split_into_several_rows_pairs_with_none_of_them():
embeddings = {
"custom_id": "emb-1",
"method": "POST",
"url": "/v1/embeddings",
"body": {"model": "gemini-embedding-001", "input": ["first", "second"]},
}
vertex_input = b"\n".join(
_OpenAIToVertexBatchUploadStream(
_jsonl(embeddings, _request("vx-0", "chat")), VertexAIFilesConfig()._map_openai_to_vertex_params
).iter_bytes()
)
vertex_rows = get_file_content_as_dictionary(vertex_input)
assert [row.get("key") for row in vertex_rows] == ["emb-1#0/2", "emb-1#1/2", None]
payload = _whole_batch(
_source(model="gemini-2.5-flash", custom_llm_provider="vertex_ai", cost_provider="vertex_ai"),
vertex_input,
(_jsonl(_success("emb-1", 2, 0), _success("vx-0", 3, 1)),),
)
assert [(line["custom_id"], line["request"]) for line in payload["lines"]] == [
("emb-1", None),
("vx-0", vertex_rows[2]),
]
def test_a_vertex_input_row_without_a_custom_id_pairs_with_nothing():
unlabeled = {"request": {"contents": [{"role": "user", "parts": [{"text": "hi"}]}]}}
payload = _whole_batch(
_source(model="gemini-2.5-flash", custom_llm_provider="vertex_ai", cost_provider="vertex_ai"),
_jsonl(unlabeled),
(_jsonl({**_success("x", 3, 1), "custom_id": None}),),
)
(line,) = payload["lines"]
assert (line["custom_id"], line["request"]) == (None, None)
@pytest.mark.parametrize(
"vertex_input",
[
pytest.param(
b"\n".join(
_OpenAIToVertexBatchUploadStream(
_jsonl(_request("unknown", "first"), {**_request("vx-1", "second"), "custom_id": None}),
VertexAIFilesConfig()._map_openai_to_vertex_params,
).iter_bytes()
),
id="custom-id-unknown-next-to-a-null-one",
),
pytest.param(
_jsonl(
{"request": {"contents": [{"role": "user", "parts": [{"text": "first"}]}], "labels": {"team": "a"}}},
{"request": {"contents": [{"role": "user", "parts": [{"text": "second"}]}]}},
),
id="native-rows-with-other-labels-next-to-unlabeled-ones",
),
],
)
def test_a_vertex_line_read_as_unknown_pairs_with_no_input_row(vertex_input):
payload = _whole_batch(
_source(model="gemini-2.5-flash", custom_llm_provider="vertex_ai", cost_provider="vertex_ai"),
vertex_input,
(_jsonl(_success("unknown", 3, 1), _success("unknown", 4, 2)),),
)
assert [(line["custom_id"], line["request"]) for line in payload["lines"]] == [("unknown", None)] * 2
@pytest.mark.asyncio
async def test_redaction_drops_only_the_request_and_response():
payload = _payload()
redacted = without_request_and_response(payload)
assert redacted["batch_id"] == payload["batch_id"]
assert redacted["finished_at"] == payload["finished_at"]
assert redacted["part"] == payload["part"]
assert [{**line, "request": None, "response": None} for line in payload["lines"]] == list(redacted["lines"])
assert all(line["request"] is not None for line in payload["lines"])
@pytest.mark.asyncio
async def test_the_object_holds_one_json_line_per_batch_line_under_its_finished_day():
payload = _payload()
ndjson = batch_line_items_ndjson(payload)
assert [json.loads(line) for line in ndjson.splitlines()] == list(payload["lines"])
assert ndjson.endswith("}\n")
assert (
batch_line_items_object_name(payload)
== "batch_line_items/2026-10-08/bGl0ZWxsbV9wcm94eTtiYXRjaA/part-00000.jsonl"
)
assert (
batch_line_items_object_name(
{**payload, "batch_id": "projects/p/locations/l/batchPredictionJobs/9", "part": 12}
)
== "batch_line_items/2026-10-08/projects_p_locations_l_batchPredictionJobs_9/part-00012.jsonl"
)
def _raw_size(request: dict, result: dict) -> int:
return len(json.dumps(request)) + len(json.dumps(result))
@pytest.mark.parametrize(
"extra_bytes, parts",
[(0, [["a", "b"], ["c"]]), (1, [["a", "b", "c"]])],
ids=["closes once its raw lines reach the size", "stays open below the size"],
)
def test_a_part_closes_once_its_raw_request_and_result_lines_reach_the_part_size(extra_bytes, parts):
requests = [_request(custom_id, f"question {custom_id}") for custom_id in "abc"]
results = [_success(custom_id, 10, 5) for custom_id in "abc"]
max_part_bytes = _raw_size(requests[0], results[0]) + _raw_size(requests[1], results[1]) + extra_bytes
payloads = list(batch_line_item_parts(_source(), _jsonl(*requests), (_jsonl(*results),), max_part_bytes))
assert [payload["part"] for payload in payloads] == list(range(len(parts)))
assert [[line["custom_id"] for line in payload["lines"]] for payload in payloads] == parts
assert [line["request"] for payload in payloads for line in payload["lines"]] == requests
def test_blank_and_unreadable_input_lines_leave_the_other_lines_paired():
input_content = b"\n".join(
[json.dumps(_request("a", "first")).encode(), b"", b"not json", json.dumps(_request("b", "second")).encode()]
)
payload = _whole_batch(_source(), input_content, (_jsonl(_success("a", 1, 1), _success("b", 1, 1)),))
assert [line["request"] for line in payload["lines"]] == [_request("a", "first"), _request("b", "second")]
def test_files_with_no_readable_line_make_no_part():
assert list(batch_line_item_parts(_source(), b"", (b"not json\n", b""), BATCH_LINE_ITEMS_MAX_PART_BYTES)) == []
@pytest.mark.parametrize(
"saved, recorded",
[
(ALICE_KEY_HASH, ALICE_KEY_HASH),
("ya29.opaque-oauth2-access-token", sha256(b"ya29.opaque-oauth2-access-token").hexdigest()),
("litellm_proxy_master_key", sha256(b"litellm_proxy_master_key").hexdigest()),
(None, None),
],
ids=["virtual key hash", "oauth2 token", "master key alias", "nothing saved"],
)
def test_the_creator_credential_is_recorded_only_as_a_hash(saved, recorded):
assert line_item_metadata({"user_api_key_hash": saved})["user_api_key_hash"] == recorded
def test_a_line_holding_a_lone_surrogate_is_escaped_so_the_object_still_encodes_as_utf8():
payload = _payload()
(line, *_) = payload["lines"]
surrogate_line = {**line, "custom_id": "emoji-\ud83d", "response": {"body": {"content": "caf\u00e9 \ud83d"}}}
readable_line = {**line, "custom_id": "caf\u00e9"}
ndjson = batch_line_items_ndjson({**payload, "lines": (surrogate_line, readable_line)})
surrogate_text, readable_text = ndjson.encode("utf-8").decode("utf-8").splitlines()
assert json.loads(surrogate_text) == surrogate_line
assert "\\ud83d" in surrogate_text
assert json.loads(readable_text) == readable_line
assert '"custom_id": "caf\u00e9"' in readable_text
@pytest.mark.asyncio
async def test_a_logger_gets_the_request_and_response_unless_message_logging_is_off(monkeypatch):
payload = _payload()
open_logger, quiet_logger, legacy_logger = (
_RecordingLogger(),
_RecordingLogger(turn_off_message_logging=True),
_RecordingLogger(message_logging=False),
)
monkeypatch.setattr(litellm, "batch_line_item_callbacks", (open_logger, quiet_logger, legacy_logger))
await dispatch_batch_line_items(payload, redact_bodies=False)
assert open_logger.received == [payload]
assert quiet_logger.received == [without_request_and_response(payload)]
assert legacy_logger.received == [without_request_and_response(payload)]
@pytest.mark.asyncio
async def test_redacted_dispatch_never_hands_a_logger_the_bodies(monkeypatch):
payload = _payload()
logger = _RecordingLogger()
monkeypatch.setattr(litellm, "batch_line_item_callbacks", (logger,))
await dispatch_batch_line_items(payload, redact_bodies=True)
assert logger.received == [without_request_and_response(payload)]
@pytest.mark.asyncio
async def test_a_failing_or_unknown_sink_is_logged_and_the_other_sinks_still_get_the_lines(monkeypatch, caplog):
payload = _payload()
logger = _RecordingLogger()
monkeypatch.setattr(litellm, "batch_line_item_callbacks", (_FailingLogger(), "langfuse", logger))
with caplog.at_level(logging.ERROR, logger="LiteLLM"):
await dispatch_batch_line_items(payload, redact_bodies=False)
assert logger.received == [payload]
failures = [record.getMessage() for record in caplog.records if record.levelno == logging.ERROR]
assert len(failures) == 2
assert all(payload["batch_id"] in message for message in failures)
assert any("bucket is gone" in message for message in failures)
assert any("'langfuse'" in message for message in failures)