From b2b15fb4586ed8ac30e238f24e3a5dd987096a39 Mon Sep 17 00:00:00 2001 From: Aishwary Dongre Date: Mon, 12 Oct 2026 13:48:34 +0530 Subject: [PATCH 1/2] fix(vertex-ai): map billableCharacterCount to usage.prompt_tokens_details.character_count For character-priced Vertex AI text embedding models (text-embedding-005, gemini-embedding-001, text-multilingual-embedding-002), the response includes metadata.billableCharacterCount but VertexTextEmbeddingConfig.transform_vertex_response_to_openai only summed token_count into prompt_tokens and discarded the character count. This maps metadata.billableCharacterCount to usage.prompt_tokens_details.character_count so callers can read the provider-reported billable character count. Fixes #45999 --- .../vertex_embeddings/transformation.py | 16 ++- ...text_embedding_billable_character_count.py | 123 ++++++++++++++++++ 2 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 tests/unit/llms/vertex_ai/test_vertex_text_embedding_billable_character_count.py diff --git a/litellm/llms/vertex_ai/vertex_embeddings/transformation.py b/litellm/llms/vertex_ai/vertex_embeddings/transformation.py index cab2bed8a60..cf797388e06 100644 --- a/litellm/llms/vertex_ai/vertex_embeddings/transformation.py +++ b/litellm/llms/vertex_ai/vertex_embeddings/transformation.py @@ -225,6 +225,7 @@ class VertexAITextEmbeddingConfig(LiteLLMBaseModel): # Import here to avoid circular import issues with litellm.__init__ from litellm.llms.vertex_ai.vertex_embeddings.bge import VertexBGEConfig + from litellm.types.llms.base import PromptTokensDetailsWrapper if VertexBGEConfig.is_bge_model(model): return VertexBGEConfig.transform_response(response=response, model=model, model_response=model_response) @@ -247,7 +248,20 @@ class VertexAITextEmbeddingConfig(LiteLLMBaseModel): model_response.object = "list" model_response.data = embedding_response model_response.model = model - usage: Final = Usage(prompt_tokens=input_tokens, completion_tokens=0, total_tokens=input_tokens) + + # Extract billable character count from metadata if present + billable_character_count: Final = response.get("metadata", {}).get("billableCharacterCount") + if billable_character_count is not None: + prompt_tokens_details: Final = PromptTokensDetailsWrapper(character_count=billable_character_count) + usage: Final = Usage( + prompt_tokens=input_tokens, + completion_tokens=0, + total_tokens=input_tokens, + prompt_tokens_details=prompt_tokens_details, + ) + else: + usage: Final = Usage(prompt_tokens=input_tokens, completion_tokens=0, total_tokens=input_tokens) + setattr(model_response, "usage", usage) return model_response diff --git a/tests/unit/llms/vertex_ai/test_vertex_text_embedding_billable_character_count.py b/tests/unit/llms/vertex_ai/test_vertex_text_embedding_billable_character_count.py new file mode 100644 index 00000000000..b1d449380b1 --- /dev/null +++ b/tests/unit/llms/vertex_ai/test_vertex_text_embedding_billable_character_count.py @@ -0,0 +1,123 @@ +""" +Test that Vertex AI text embedding responses preserve billableCharacterCount +in usage.prompt_tokens_details.character_count. + +Fixes: https://github.com/BerriAI/litellm/issues/45999 +""" + +import pytest + +from litellm.llms.vertex_ai.vertex_embeddings.transformation import ( + VertexAITextEmbeddingConfig, +) +from litellm.types.utils import EmbeddingResponse + + +class TestVertexTextEmbeddingBillableCharacterCount: + def setup_method(self): + self.config = VertexAITextEmbeddingConfig() + + def test_billable_character_count_mapped_to_usage_details(self): + """ + Test that metadata.billableCharacterCount from Vertex is mapped to + usage.prompt_tokens_details.character_count. + """ + # Sample Vertex AI text embedding response with billable character count + vertex_response = { + "predictions": [ + { + "embeddings": { + "statistics": {"truncated": False, "token_count": 8}, + "values": [0.1, 0.2, 0.3], # Sample embedding values + } + }, + { + "embeddings": { + "statistics": {"truncated": False, "token_count": 1}, + "values": [0.4, 0.5, 0.6], + } + }, + ], + "metadata": {"billableCharacterCount": 21}, + } + + model_response = EmbeddingResponse() + result = self.config.transform_vertex_response_to_openai( + response=vertex_response, + model="text-embedding-005", + model_response=model_response, + ) + + # Verify usage tokens + assert result.usage.prompt_tokens == 9 # 8 + 1 + assert result.usage.completion_tokens == 0 + assert result.usage.total_tokens == 9 + + # Verify billable character count is preserved + assert result.usage.prompt_tokens_details is not None + assert result.usage.prompt_tokens_details.character_count == 21 + + def test_no_character_count_when_metadata_missing(self): + """ + Test that usage.prompt_tokens_details is not set when metadata + is missing from the response. + """ + # Vertex response without metadata + vertex_response = { + "predictions": [ + { + "embeddings": { + "statistics": {"truncated": False, "token_count": 5}, + "values": [0.1, 0.2, 0.3], + } + } + ] + } + + model_response = EmbeddingResponse() + result = self.config.transform_vertex_response_to_openai( + response=vertex_response, + model="text-embedding-005", + model_response=model_response, + ) + + # Verify usage tokens + assert result.usage.prompt_tokens == 5 + assert result.usage.completion_tokens == 0 + assert result.usage.total_tokens == 5 + + # Verify no prompt_tokens_details when metadata is absent + assert result.usage.prompt_tokens_details is None + + def test_no_character_count_when_billable_count_missing(self): + """ + Test that usage.prompt_tokens_details is not set when metadata exists + but billableCharacterCount is missing. + """ + # Vertex response with metadata but no billableCharacterCount + vertex_response = { + "predictions": [ + { + "embeddings": { + "statistics": {"truncated": False, "token_count": 3}, + "values": [0.1, 0.2], + } + } + ], + "metadata": {"someOtherField": "value"}, + } + + model_response = EmbeddingResponse() + result = self.config.transform_vertex_response_to_openai( + response=vertex_response, + model="gemini-embedding-001", + model_response=model_response, + ) + + # Verify usage tokens + assert result.usage.prompt_tokens == 3 + assert result.usage.completion_tokens == 0 + assert result.usage.total_tokens == 3 + + # Verify no prompt_tokens_details when billableCharacterCount is absent + assert result.usage.prompt_tokens_details is None From db6656df1e5a959d70772b29ac1cb4ac2405bafe Mon Sep 17 00:00:00 2001 From: Aishwary Dongre Date: Thu, 15 Oct 2026 09:57:02 +0530 Subject: [PATCH 2/2] fix: correct import path and guard against null metadata - Import PromptTokensDetailsWrapper from litellm.types.utils (not base) - Guard against metadata: null to avoid AttributeError - Remove trailing whitespace Fixes review feedback from @VANDRANKI --- litellm/llms/vertex_ai/vertex_embeddings/transformation.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/litellm/llms/vertex_ai/vertex_embeddings/transformation.py b/litellm/llms/vertex_ai/vertex_embeddings/transformation.py index cf797388e06..e3dc3988b5c 100644 --- a/litellm/llms/vertex_ai/vertex_embeddings/transformation.py +++ b/litellm/llms/vertex_ai/vertex_embeddings/transformation.py @@ -225,7 +225,7 @@ class VertexAITextEmbeddingConfig(LiteLLMBaseModel): # Import here to avoid circular import issues with litellm.__init__ from litellm.llms.vertex_ai.vertex_embeddings.bge import VertexBGEConfig - from litellm.types.llms.base import PromptTokensDetailsWrapper + from litellm.types.utils import PromptTokensDetailsWrapper if VertexBGEConfig.is_bge_model(model): return VertexBGEConfig.transform_response(response=response, model=model, model_response=model_response) @@ -248,9 +248,10 @@ class VertexAITextEmbeddingConfig(LiteLLMBaseModel): model_response.object = "list" model_response.data = embedding_response model_response.model = model - + # Extract billable character count from metadata if present - billable_character_count: Final = response.get("metadata", {}).get("billableCharacterCount") + metadata: Final = response.get("metadata") or {} + billable_character_count: Final = metadata.get("billableCharacterCount") if billable_character_count is not None: prompt_tokens_details: Final = PromptTokensDetailsWrapper(character_count=billable_character_count) usage: Final = Usage(