From e50fc8ba75b9169e1818b6d59858e5b9924688ab Mon Sep 17 00:00:00 2001 From: kerry Date: Fri, 18 Sep 2026 05:25:52 +0000 Subject: [PATCH 1/2] fix(batches): bill Bedrock Titan embedding batch lines from inputTextTokenCount Titan embedding batch output carries the token count as a top-level inputTextTokenCount with no usage block, so the Bedrock batch cost parser recorded 0 tokens and 0 spend for every Titan embedding batch. Parse that field for embedding lines only and leave Converse and Anthropic shaped lines on their existing paths Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/batches/batch_utils.py | 6 +++ .../llms/bedrock/batches/transformation.py | 17 ++++++++- .../test_litellm/batches/test_batch_utils.py | 37 +++++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/litellm/batches/batch_utils.py b/litellm/batches/batch_utils.py index 26b4318da2d..22c105d602e 100644 --- a/litellm/batches/batch_utils.py +++ b/litellm/batches/batch_utils.py @@ -9,6 +9,7 @@ import litellm from litellm._logging import verbose_logger from litellm.litellm_core_utils.get_litellm_params import AWS_CREDENTIAL_KWARGS_KEYS from litellm.litellm_core_utils.llm_cost_calc.utils import parse_prompt_tokens_details +from litellm.llms.bedrock.batches.transformation import titan_embedding_usage_from_batch_output from litellm.llms.vertex_ai.batches.transformation import vertex_prompt_tokens_details from litellm.types.llms.openai import Batch from litellm.types.utils import ModelInfo, Usage @@ -673,6 +674,11 @@ def _get_batch_job_usage_from_response_body( from litellm.llms.anthropic.chat.transformation import AnthropicConfig from litellm.llms.bedrock.chat.converse_transformation import AmazonConverseConfig + titan_usage: Final = ( + titan_embedding_usage_from_batch_output(response_body) if custom_llm_provider == "bedrock" else None + ) + if titan_usage is not None: + return titan_usage usage_object: Final = response_body.get("usage", None) or {} if custom_llm_provider == "bedrock" and AmazonConverseConfig.is_converse_usage_shape(usage_object): return AmazonConverseConfig().usage_from_batch_output(usage_object) diff --git a/litellm/llms/bedrock/batches/transformation.py b/litellm/llms/bedrock/batches/transformation.py index 7729cdfdb0d..4f74e3f7035 100644 --- a/litellm/llms/bedrock/batches/transformation.py +++ b/litellm/llms/bedrock/batches/transformation.py @@ -1,6 +1,7 @@ import os import re import time +from collections.abc import Mapping from typing import TYPE_CHECKING, Any, Final, Literal, cast from httpx import Headers, Response @@ -26,7 +27,7 @@ from litellm.types.llms.openai import ( AllMessageValues, CreateBatchRequest, ) -from litellm.types.utils import LiteLLMBatch, LlmProviders +from litellm.types.utils import LiteLLMBatch, LlmProviders, Usage from ..base_aws_llm import BaseAWSLLM from ..common_utils import ( @@ -60,6 +61,20 @@ def _validate_bedrock_tags(raw_tags: object) -> list[BedrockTag]: ) from e +def titan_embedding_usage_from_batch_output(model_output: Mapping[str, object]) -> Usage | None: + """Titan embedding batch lines report usage as a top-level inputTextTokenCount, not a usage block.""" + if "embedding" not in model_output: + return None + input_text_token_count: Final = model_output.get("inputTextTokenCount") + if isinstance(input_text_token_count, bool) or not isinstance(input_text_token_count, int): + return None + return Usage( + prompt_tokens=input_text_token_count, + completion_tokens=0, + total_tokens=input_text_token_count, + ) + + class BedrockBatchesConfig(BaseAWSLLM, BaseBatchesConfig): """ Config for Bedrock Batches - handles batch job creation and management for Bedrock diff --git a/tests/test_litellm/batches/test_batch_utils.py b/tests/test_litellm/batches/test_batch_utils.py index da6475394a3..a2811864519 100644 --- a/tests/test_litellm/batches/test_batch_utils.py +++ b/tests/test_litellm/batches/test_batch_utils.py @@ -1755,6 +1755,43 @@ def test_bedrock_anthropic_shaped_batch_usage_still_parsed(): assert (usage.prompt_tokens, usage.completion_tokens, usage.total_tokens) == (18, 10, 28) +def test_bedrock_titan_embedding_batch_usage_is_parsed(): + """Titan embedding batch lines carry a top-level inputTextTokenCount and no usage block.""" + body = {"embedding": [0.1, 0.2], "embeddingsByType": {"float": [0.1, 0.2]}, "inputTextTokenCount": 17} + usage = bu._get_batch_job_usage_from_response_body(body, custom_llm_provider="bedrock") + assert (usage.prompt_tokens, usage.completion_tokens, usage.total_tokens) == (17, 0, 17) + + +def test_bedrock_titan_embedding_batch_is_billed(): + rows = [ + {"recordId": str(i), "modelOutput": {"embedding": [0.1], "inputTextTokenCount": count}} + for i, count in enumerate((10, 7)) + ] + result = bu._aggregate_batch_cost_usage_models( + entries=rows, + custom_llm_provider="bedrock", + model_name="amazon.titan-embed-text-v2:0", + model_info={"input_cost_per_token_batches": 1e-6, "output_cost_per_token_batches": 0.0}, + ) + assert (result.usage.prompt_tokens, result.usage.completion_tokens, result.usage.total_tokens) == (17, 0, 17) + assert result.cost == pytest.approx(17 * 1e-6) + + +@pytest.mark.parametrize( + "body", + [ + {"embedding": [0.1], "inputTextTokenCount": "17"}, + {"embedding": [0.1], "inputTextTokenCount": True}, + {"embedding": [0.1], "inputTextTokenCount": None}, + {"results": [{"outputText": "hi", "tokenCount": 2}], "inputTextTokenCount": 17}, + ], +) +def test_bedrock_input_text_token_count_outside_embedding_lines_is_not_billed(body): + """Only embedding lines are parsed here; Titan text generation lines are left as they were.""" + usage = bu._get_batch_job_usage_from_response_body(body, custom_llm_provider="bedrock") + assert usage.total_tokens == 0 + + def test_unparsable_bedrock_batch_usage_warns(caplog): """An unrecognized usage shape must be visible, not a silent $0.""" body = {"model": "amazon.titan-text-lite-v1", "usage": {"inputTextTokenCount": 42}} From 0247e9b634625e213c876aa7226f582daf3665e1 Mon Sep 17 00:00:00 2001 From: kerry Date: Fri, 18 Sep 2026 05:32:25 +0000 Subject: [PATCH 2/2] fix(batches): bill Titan binary embedding batch lines that only carry embeddingsByType Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/llms/bedrock/batches/transformation.py | 2 +- tests/test_litellm/batches/test_batch_utils.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/litellm/llms/bedrock/batches/transformation.py b/litellm/llms/bedrock/batches/transformation.py index 4f74e3f7035..ae0f8c5935b 100644 --- a/litellm/llms/bedrock/batches/transformation.py +++ b/litellm/llms/bedrock/batches/transformation.py @@ -63,7 +63,7 @@ def _validate_bedrock_tags(raw_tags: object) -> list[BedrockTag]: def titan_embedding_usage_from_batch_output(model_output: Mapping[str, object]) -> Usage | None: """Titan embedding batch lines report usage as a top-level inputTextTokenCount, not a usage block.""" - if "embedding" not in model_output: + if "embedding" not in model_output and "embeddingsByType" not in model_output: return None input_text_token_count: Final = model_output.get("inputTextTokenCount") if isinstance(input_text_token_count, bool) or not isinstance(input_text_token_count, int): diff --git a/tests/test_litellm/batches/test_batch_utils.py b/tests/test_litellm/batches/test_batch_utils.py index a2811864519..9a089112c70 100644 --- a/tests/test_litellm/batches/test_batch_utils.py +++ b/tests/test_litellm/batches/test_batch_utils.py @@ -1763,9 +1763,10 @@ def test_bedrock_titan_embedding_batch_usage_is_parsed(): def test_bedrock_titan_embedding_batch_is_billed(): + """Binary embedding rows carry only embeddingsByType and must bill like float rows.""" rows = [ - {"recordId": str(i), "modelOutput": {"embedding": [0.1], "inputTextTokenCount": count}} - for i, count in enumerate((10, 7)) + {"recordId": "0", "modelOutput": {"embedding": [0.1], "inputTextTokenCount": 10}}, + {"recordId": "1", "modelOutput": {"embeddingsByType": {"binary": [1, 0]}, "inputTextTokenCount": 7}}, ] result = bu._aggregate_batch_cost_usage_models( entries=rows,