Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 16 additions & 7 deletions litellm/batches/batch_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,11 @@ def _batch_cost_calculator(
"""
Calculate the cost of a batch based on the output file id
"""
# Handle Vertex AI with specialized method
if custom_llm_provider == "vertex_ai" and model_name:
if (
custom_llm_provider == "vertex_ai"
and model_name
and getattr(litellm, "disable_vertex_batch_output_transformation", False)
):
batch_cost, _ = calculate_vertex_ai_batch_cost_and_usage(
file_content_dictionary, model_name
)
Expand All @@ -136,10 +139,13 @@ def calculate_vertex_ai_batch_cost_and_usage(
model_name: Optional[str] = None,
) -> Tuple[float, Usage]:
"""
Calculate both cost and usage from Vertex AI batch responses.
Calculate both cost and usage from raw Vertex AI batch responses.

Vertex AI batch output lines have format:
{"request": ..., "status": "", "response": {"candidates": [...], "usageMetadata": {...}}}
Used only when ``litellm.disable_vertex_batch_output_transformation = True``.
In that case the GCS predictions.jsonl is returned as-is, with each line in
the native Vertex format:

{"request": ..., "response": {"candidates": [...], "usageMetadata": {...}}}

usageMetadata contains promptTokenCount, candidatesTokenCount, totalTokenCount.
"""
Expand Down Expand Up @@ -362,8 +368,11 @@ def _get_batch_job_total_usage_from_file_content(
"""
Get the tokens of a batch job from the file content
"""
# Handle Vertex AI with specialized method
if custom_llm_provider == "vertex_ai" and model_name:
if (
custom_llm_provider == "vertex_ai"
and model_name
and getattr(litellm, "disable_vertex_batch_output_transformation", False)
):
_, batch_usage = calculate_vertex_ai_batch_cost_and_usage(
file_content_dictionary, model_name
)
Expand Down
20 changes: 20 additions & 0 deletions litellm/cost_calculator.py
Original file line number Diff line number Diff line change
Expand Up @@ -2120,6 +2120,26 @@ def batch_cost_calculator(
)
except Exception:
model_info = None
elif not any(
model_info.get(k) is not None
for k in (
"input_cost_per_token_batches",
"input_cost_per_token",
"output_cost_per_token_batches",
"output_cost_per_token",
)
):
Comment thread
cursor[bot] marked this conversation as resolved.
# model_info was provided (e.g. deployment metadata with only id/db_model)
# but carries no pricing fields. Fall back to the global pricing table so
# that standard model pricing is used instead of silently returning $0.
try:
global_info = litellm.get_model_info(
model=model, custom_llm_provider=custom_llm_provider
)
if global_info:
model_info = global_info
except Exception:
pass

if not model_info:
return 0.0, 0.0
Expand Down
32 changes: 32 additions & 0 deletions tests/batches_tests/test_batch_custom_pricing.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
through to `batch_cost_calculator`.
"""

import litellm
import pytest

from litellm.batches.batch_utils import (
Expand Down Expand Up @@ -60,6 +61,37 @@ def _make_batch_output_line(prompt_tokens: int = 10, completion_tokens: int = 5)
# --- tests ---


def test_batch_cost_calculator_explicit_zero_pricing_not_overridden_by_global(
monkeypatch,
):
"""
Explicit ``0`` / ``0.0`` pricing must count as present so we do not fall back
to the global pricing table (truthiness would treat zero as missing).
"""
usage = Usage(prompt_tokens=1000, completion_tokens=500, total_tokens=1500)

def fake_get_model_info(*args, **kwargs):
return {
"input_cost_per_token_batches": 1e-3,
"output_cost_per_token_batches": 2e-3,
}

monkeypatch.setattr(litellm, "get_model_info", fake_get_model_info)

prompt_cost, completion_cost = batch_cost_calculator(
usage=usage,
model="any-model",
custom_llm_provider="openai",
model_info={
"input_cost_per_token_batches": 0.0,
"output_cost_per_token_batches": 0.0,
},
)

assert prompt_cost == 0.0
assert completion_cost == 0.0


def test_batch_cost_calculator_uses_custom_model_info():
"""batch_cost_calculator should use model_info override when provided."""
usage = Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -589,3 +589,158 @@ def test_should_handle_missing_usage_metadata_gracefully(self):
assert usage.prompt_tokens == 0
assert usage.completion_tokens == 0
assert usage.total_tokens == 0

def test_openai_shaped_output_records_nonzero_cost_and_usage(self):
"""
Regression test for the bug where Vertex batch cost/usage was always 0.

After PR #25627 (transform_file_content_response), the GCS predictions.jsonl
is rewritten into OpenAI batch shape before the cost-tracking path sees it.
With disable_vertex_batch_output_transformation=False (default), the content
is OpenAI-shaped, so _batch_cost_calculator must fall through to the generic
path rather than calling calculate_vertex_ai_batch_cost_and_usage (which only
reads raw usageMetadata fields).
"""
import litellm
from litellm.batches.batch_utils import (
_batch_cost_calculator,
_get_batch_job_total_usage_from_file_content,
)

openai_shaped_responses = [
{
"id": "batch_req_abc123",
"custom_id": "request-1",
"response": {
"status_code": 200,
"request_id": "chatcmpl-xyz",
"body": {
"id": "chatcmpl-xyz",
"object": "chat.completion",
"model": "gemini-2.0-flash-001",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "Hello!"},
"finish_reason": "stop",
}
],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 5,
"total_tokens": 15,
},
},
},
"error": None,
},
{
"id": "batch_req_def456",
"custom_id": "request-2",
"response": {
"status_code": 200,
"request_id": "chatcmpl-uvw",
"body": {
"id": "chatcmpl-uvw",
"object": "chat.completion",
"model": "gemini-2.0-flash-001",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "World!"},
"finish_reason": "stop",
}
],
"usage": {
"prompt_tokens": 8,
"completion_tokens": 3,
"total_tokens": 11,
},
},
},
"error": None,
},
]

original_flag = getattr(
litellm, "disable_vertex_batch_output_transformation", False
)
try:
litellm.disable_vertex_batch_output_transformation = False

cost = _batch_cost_calculator(
file_content_dictionary=openai_shaped_responses,
custom_llm_provider="vertex_ai",
model_name="gemini-2.0-flash-001",
)
usage = _get_batch_job_total_usage_from_file_content(
file_content_dictionary=openai_shaped_responses,
custom_llm_provider="vertex_ai",
model_name="gemini-2.0-flash-001",
)
finally:
litellm.disable_vertex_batch_output_transformation = original_flag

assert (
usage.prompt_tokens == 18
), f"expected 18 prompt tokens, got {usage.prompt_tokens}"
assert (
usage.completion_tokens == 8
), f"expected 8 completion tokens, got {usage.completion_tokens}"
assert (
usage.total_tokens == 26
), f"expected 26 total tokens, got {usage.total_tokens}"
assert (
cost > 0
), f"expected non-zero cost for completed Vertex batch, got {cost}"

def test_raw_vertex_output_still_works_when_transformation_disabled(self):
"""
When disable_vertex_batch_output_transformation=True the GCS file is returned
as raw Vertex predictions.jsonl; the specialized reader must be used.
"""
import litellm
from litellm.batches.batch_utils import (
_batch_cost_calculator,
_get_batch_job_total_usage_from_file_content,
)

raw_vertex_responses = [
{
"request": {"contents": [{"role": "user", "parts": [{"text": "hi"}]}]},
"status": "",
"response": {
"candidates": [{"content": {"parts": [{"text": "Hello!"}]}}],
"usageMetadata": {
"promptTokenCount": 10,
"candidatesTokenCount": 5,
"totalTokenCount": 15,
},
},
"processed_time": "2026-01-01T00:00:00Z",
},
]

original_flag = getattr(
litellm, "disable_vertex_batch_output_transformation", False
)
try:
litellm.disable_vertex_batch_output_transformation = True

cost = _batch_cost_calculator(
file_content_dictionary=raw_vertex_responses,
custom_llm_provider="vertex_ai",
model_name="gemini-2.0-flash-001",
)
usage = _get_batch_job_total_usage_from_file_content(
file_content_dictionary=raw_vertex_responses,
custom_llm_provider="vertex_ai",
model_name="gemini-2.0-flash-001",
)
finally:
litellm.disable_vertex_batch_output_transformation = original_flag

assert usage.prompt_tokens == 10
assert usage.completion_tokens == 5
assert usage.total_tokens == 15
assert cost > 0, "raw Vertex shape should also produce non-zero cost"
Loading