From b13cf21fd10142b848130ddd2876a1d023a60ab1 Mon Sep 17 00:00:00 2001 From: yassin Date: Tue, 15 Sep 2026 22:06:05 +0000 Subject: [PATCH 1/2] fix(vertex_ai): bill Gemini Omni Interactions usage and Veo sampleCount on passthrough Route Vertex and Gemini /interactions create responses through the shared Interactions usage transformation so SpendLogs carry per-modality tokens and cost instead of zeros, and carry parameters.sampleCount into Veo usage as video_count so duration-based video cost is multiplied by the number of generated videos Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/cost_calculator.py | 20 +++-- litellm/llms/gemini/videos/transformation.py | 4 + .../llms/vertex_ai/videos/transformation.py | 13 ++- .../gemini_passthrough_logging_handler.py | 14 +++ .../vertex_passthrough_logging_handler.py | 73 ++++++++++++++- .../pass_through_endpoints/success_handler.py | 12 ++- .../test_gemini_video_transformation.py | 19 ++++ ...test_vertex_passthrough_logging_handler.py | 90 ++++++++++++++++++- .../test_vertex_video_transformation.py | 27 ++++++ ...test_gemini_passthrough_logging_handler.py | 51 ++++++++++- .../test_pass_through_endpoints.py | 20 +++++ tests/test_litellm/test_cost_calculator.py | 25 ++++++ 12 files changed, 356 insertions(+), 12 deletions(-) diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index 852713595d5..f81eb1539fd 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -1460,18 +1460,23 @@ def completion_cost( duration_seconds: float | None = None video_resolution: str | None = None provider_reported_cost: float | None = None + video_count: int = 1 if completion_response is not None and usage_obj: # Handle both dict and Pydantic Usage object if isinstance(usage_obj, dict): duration_seconds = usage_obj.get("duration_seconds", None) _vr = usage_obj.get("video_resolution", None) provider_reported_cost = usage_obj.get("provider_reported_cost_usd", None) + _vc = usage_obj.get("video_count", None) else: duration_seconds = getattr(usage_obj, "duration_seconds", None) _vr = getattr(usage_obj, "video_resolution", None) provider_reported_cost = getattr(usage_obj, "provider_reported_cost_usd", None) + _vc = getattr(usage_obj, "video_count", None) if _vr is not None: video_resolution = str(_vr).strip().lower() + if isinstance(_vc, int) and not isinstance(_vc, bool) and _vc > 1: + video_count = _vc if _video_model_info is None and provider_reported_cost is not None: return float(provider_reported_cost) @@ -1482,12 +1487,15 @@ def completion_cost( video_generation_cost, ) - return video_generation_cost( - model=model, - duration_seconds=duration_seconds, - custom_llm_provider=custom_llm_provider, - model_info=_video_model_info, - video_resolution=video_resolution, + return ( + video_generation_cost( + model=model, + duration_seconds=duration_seconds, + custom_llm_provider=custom_llm_provider, + model_info=_video_model_info, + video_resolution=video_resolution, + ) + * video_count ) # Fallback to default video cost calculation if no duration available return default_video_cost_calculator( diff --git a/litellm/llms/gemini/videos/transformation.py b/litellm/llms/gemini/videos/transformation.py index ff4c675b02f..a44717eb659 100644 --- a/litellm/llms/gemini/videos/transformation.py +++ b/litellm/llms/gemini/videos/transformation.py @@ -9,6 +9,7 @@ import litellm from litellm.constants import DEFAULT_GOOGLE_VIDEO_DURATION_SECONDS from litellm.images.utils import ImageEditRequestUtils from litellm.llms.base_llm.videos.transformation import BaseVideoConfig +from litellm.llms.vertex_ai.videos.transformation import veo_video_count_from_parameters from litellm.secret_managers.main import get_secret_str from litellm.types.llms.gemini import ( GeminiLongRunningOperationResponse, @@ -354,6 +355,9 @@ class GeminiVideoConfig(BaseVideoConfig): video_resolution: Final = _usage_video_resolution_from_parameters(parameters) if video_resolution is not None: usage_data["video_resolution"] = video_resolution + video_count: Final = veo_video_count_from_parameters(parameters) + if video_count is not None: + usage_data["video_count"] = video_count video_obj.usage = usage_data return video_obj diff --git a/litellm/llms/vertex_ai/videos/transformation.py b/litellm/llms/vertex_ai/videos/transformation.py index c66ad8e38b0..6525c6fa235 100644 --- a/litellm/llms/vertex_ai/videos/transformation.py +++ b/litellm/llms/vertex_ai/videos/transformation.py @@ -70,10 +70,18 @@ def _parse_veo_operation(raw_response: httpx.Response) -> _VeoOperation: return operation +def veo_video_count_from_parameters(parameters: Mapping[str, object]) -> int | None: + """Number of videos Veo generates for one request (``parameters.sampleCount``).""" + sample_count: Final = parameters.get("sampleCount") + if isinstance(sample_count, bool) or not isinstance(sample_count, int) or sample_count < 1: + return None + return sample_count + + def _build_vertex_video_usage_from_request_data( request_data: dict[str, Any] | None, ) -> dict[str, float | str]: - """Build usage metadata (duration, resolution) for video cost calculation.""" + """Build usage metadata (duration, resolution, video count) for video cost calculation.""" usage_data: Final[dict[str, float | str]] = {} if not request_data: return usage_data @@ -88,6 +96,9 @@ def _build_vertex_video_usage_from_request_data( res: Final = parameters.get("resolution") if res is not None and str(res).strip() != "": usage_data["video_resolution"] = str(res).strip().lower() + video_count: Final = veo_video_count_from_parameters(parameters) + if video_count is not None: + usage_data["video_count"] = video_count return usage_data diff --git a/litellm/proxy/pass_through_endpoints/llm_provider_handlers/gemini_passthrough_logging_handler.py b/litellm/proxy/pass_through_endpoints/llm_provider_handlers/gemini_passthrough_logging_handler.py index 64d8b2929b6..a95ee87fd31 100644 --- a/litellm/proxy/pass_through_endpoints/llm_provider_handlers/gemini_passthrough_logging_handler.py +++ b/litellm/proxy/pass_through_endpoints/llm_provider_handlers/gemini_passthrough_logging_handler.py @@ -12,6 +12,9 @@ from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( ModelResponseIterator as GeminiModelResponseIterator, ) from litellm.proxy._types import PassThroughEndpointLoggingTypedDict +from litellm.proxy.pass_through_endpoints.llm_provider_handlers.vertex_passthrough_logging_handler import ( + VertexPassthroughLoggingHandler, +) from litellm.types.utils import ( ModelResponse, TextCompletionResponse, @@ -40,6 +43,17 @@ class GeminiPassthroughLoggingHandler: request_body: dict, **kwargs, ) -> PassThroughEndpointLoggingTypedDict: + if VertexPassthroughLoggingHandler.is_interactions_route(url_route): + return VertexPassthroughLoggingHandler.interactions_passthrough_handler( + httpx_response=httpx_response, + request_body=request_body, + logging_obj=logging_obj, + kwargs=kwargs, + start_time=start_time, + end_time=end_time, + custom_llm_provider="gemini", + vertex_location=None, + ) if "predictLongRunning" in url_route: model = GeminiPassthroughLoggingHandler.extract_model_from_url(url_route) diff --git a/litellm/proxy/pass_through_endpoints/llm_provider_handlers/vertex_passthrough_logging_handler.py b/litellm/proxy/pass_through_endpoints/llm_provider_handlers/vertex_passthrough_logging_handler.py index 119a53c2411..0c4bc2a5181 100644 --- a/litellm/proxy/pass_through_endpoints/llm_provider_handlers/vertex_passthrough_logging_handler.py +++ b/litellm/proxy/pass_through_endpoints/llm_provider_handlers/vertex_passthrough_logging_handler.py @@ -1,7 +1,8 @@ import asyncio import re +from collections.abc import Mapping from datetime import datetime -from typing import TYPE_CHECKING, Any, Final, cast +from typing import TYPE_CHECKING, Any, Final, Literal, cast from urllib.parse import urlparse import httpx @@ -10,6 +11,9 @@ import litellm from litellm._logging import verbose_proxy_logger from litellm.constants import VERTEX_BATCH_PREDICTION_JOBS_ROUTE from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj +from litellm.litellm_core_utils.llm_cost_calc.usage_object_transformation import ( + InteractionsUsageObjectTransformation, +) from litellm.llms.vertex_ai.common_utils import ( get_vertex_ai_lyria_generation_cost, get_vertex_location_from_url, @@ -50,7 +54,63 @@ else: EndpointType = Any +def _interactions_model( + response_body: Mapping[str, object], + request_body: Mapping[str, object] | None, +) -> str | None: + response_model: Final = response_body.get("model") + if isinstance(response_model, str) and response_model: + return response_model + request_model: Final = (request_body or {}).get("model") + if isinstance(request_model, str) and request_model: + return request_model + return None + + class VertexPassthroughLoggingHandler: + @staticmethod + def is_interactions_route(url_route: str) -> bool: + return urlparse(url_route).path.rstrip("/").endswith("/interactions") + + @staticmethod + def interactions_passthrough_handler( + httpx_response: httpx.Response, + request_body: Mapping[str, object] | None, + logging_obj: LiteLLMLoggingObj, + kwargs: dict, + start_time: datetime, + end_time: datetime, + custom_llm_provider: Literal["vertex_ai", "gemini"], + vertex_location: str | None, + ) -> PassThroughEndpointLoggingTypedDict: + response_body: Final[Mapping[str, object]] = httpx_response.json() + usage_object: Final = response_body.get("usage") + model: Final = _interactions_model(response_body, request_body) + if model is None or not InteractionsUsageObjectTransformation.is_interactions_usage_object(usage_object): + return {"result": None, "kwargs": kwargs} + + litellm_model_response: Final = ModelResponse( + model=model, + usage=InteractionsUsageObjectTransformation.transform_interactions_usage_object( + cast(Mapping[str, Any], usage_object) + ), + ) + logging_kwargs: Final = ( + VertexPassthroughLoggingHandler._create_vertex_response_logging_payload_for_generate_content( + litellm_model_response=litellm_model_response, + model=model, + kwargs=kwargs, + start_time=start_time, + end_time=end_time, + logging_obj=logging_obj, + custom_llm_provider=custom_llm_provider, + vertex_location=vertex_location, + ) + ) + logging_kwargs["custom_llm_provider"] = custom_llm_provider + logging_obj.custom_llm_provider = custom_llm_provider + return {"result": litellm_model_response, "kwargs": logging_kwargs} + @staticmethod def vertex_passthrough_handler( httpx_response: httpx.Response, @@ -66,6 +126,17 @@ class VertexPassthroughLoggingHandler: vertex_location: Final = get_vertex_location_from_url(url_route) if vertex_location is not None: logging_obj.optional_params["vertex_location"] = vertex_location + if VertexPassthroughLoggingHandler.is_interactions_route(url_route): + return VertexPassthroughLoggingHandler.interactions_passthrough_handler( + httpx_response=httpx_response, + request_body=request_body, + logging_obj=logging_obj, + kwargs=kwargs, + start_time=start_time, + end_time=end_time, + custom_llm_provider="vertex_ai", + vertex_location=vertex_location, + ) if "predictLongRunning" in url_route: model = VertexPassthroughLoggingHandler.extract_model_from_url(url_route) diff --git a/litellm/proxy/pass_through_endpoints/success_handler.py b/litellm/proxy/pass_through_endpoints/success_handler.py index c38566375f4..dd8bd17eee9 100644 --- a/litellm/proxy/pass_through_endpoints/success_handler.py +++ b/litellm/proxy/pass_through_endpoints/success_handler.py @@ -361,7 +361,11 @@ class PassThroughEndpointLogging: def is_vertex_route(self, url_route: str) -> bool: if any(f":{method}" in url_route for method in self.TRACKED_VERTEX_METHOD_ROUTES): return True - return any(resource in url_route for resource in self.TRACKED_VERTEX_RESOURCE_ROUTES) + if any(resource in url_route for resource in self.TRACKED_VERTEX_RESOURCE_ROUTES): + return True + return "/locations/" in urlparse(url_route).path and VertexPassthroughLoggingHandler.is_interactions_route( + url_route + ) def is_anthropic_route(self, url_route: str): for route in self.TRACKED_ANTHROPIC_ROUTES: @@ -434,8 +438,12 @@ class PassThroughEndpointLogging: def is_gemini_route(self, url_route: str, custom_llm_provider: str | None = None): """Check if the URL route is a Gemini API route.""" + if custom_llm_provider != "gemini": + return False + if VertexPassthroughLoggingHandler.is_interactions_route(url_route): + return True for route in self.TRACKED_GEMINI_ROUTES: - if route in url_route and custom_llm_provider == "gemini": + if route in url_route: return True return False diff --git a/tests/test_litellm/llms/gemini/videos/test_gemini_video_transformation.py b/tests/test_litellm/llms/gemini/videos/test_gemini_video_transformation.py index 6f215deed4e..1ac451d17db 100644 --- a/tests/test_litellm/llms/gemini/videos/test_gemini_video_transformation.py +++ b/tests/test_litellm/llms/gemini/videos/test_gemini_video_transformation.py @@ -430,6 +430,25 @@ class TestGeminiVideoConfig: assert result.usage["video_resolution"] == "1080p" assert result.usage["duration_seconds"] == 8.0 + def test_transform_video_create_response_usage_includes_video_count(self): + """Regression for LIT-6896: sampleCount (number of generated videos) is copied into usage for billing.""" + mock_response = Mock(spec=httpx.Response) + mock_response.json.return_value = {"name": "operations/generate_1234567890"} + request_data = { + "instances": [{"prompt": "Test"}], + "parameters": {"durationSeconds": 8, "sampleCount": 3}, + } + result = self.config.transform_video_create_response( + model="gemini/veo-3.1-fast-generate-preview", + raw_response=mock_response, + logging_obj=self.mock_logging_obj, + custom_llm_provider="gemini", + request_data=request_data, + ) + assert result.usage is not None + assert result.usage["video_count"] == 3 + assert result.usage["duration_seconds"] == 8.0 + def test_transform_video_create_response_cost_tracking_with_different_durations( self, ): diff --git a/tests/test_litellm/llms/vertex_ai/test_vertex_passthrough_logging_handler.py b/tests/test_litellm/llms/vertex_ai/test_vertex_passthrough_logging_handler.py index 98010021bca..a9c5e94389c 100644 --- a/tests/test_litellm/llms/vertex_ai/test_vertex_passthrough_logging_handler.py +++ b/tests/test_litellm/llms/vertex_ai/test_vertex_passthrough_logging_handler.py @@ -9,7 +9,95 @@ import litellm from litellm.proxy.pass_through_endpoints.llm_provider_handlers.vertex_passthrough_logging_handler import ( VertexPassthroughLoggingHandler, ) -from litellm.types.utils import PassthroughCallTypes +from litellm.types.utils import ModelResponse, PassthroughCallTypes + +_OMNI_INTERACTIONS_USAGE: Final = { + "total_tokens": 4041, + "total_input_tokens": 12, + "input_tokens_by_modality": [{"modality": "text", "tokens": 12}], + "total_output_tokens": 4009, + "output_tokens_by_modality": [ + {"modality": "text", "tokens": 9}, + {"modality": "video", "tokens": 4000}, + ], + "total_tool_use_tokens": 0, + "total_thought_tokens": 20, +} + + +def test_interactions_create_response_logs_modality_usage_and_cost() -> None: + """ + Regression for LIT-6896: gemini-omni Interactions passthrough rows were logged + with zero tokens and zero spend. Input, text-output and video-output tokens + must land in usage, priced with the model's per-modality rates, and the + response id must stay the litellm_call_id so SpendLogs keep their request_id. + """ + logging_obj = MagicMock() + logging_obj.model_call_details = {} + logging_obj.optional_params = {} + logging_obj.litellm_call_id = "call-6896" + response = httpx.Response( + status_code=200, + json={ + "id": "interactions/abc", + "model": "gemini-omni-flash-preview", + "status": "completed", + "outputs": [{"type": "text", "text": "hi"}], + "usage": _OMNI_INTERACTIONS_USAGE, + }, + ) + + result = VertexPassthroughLoggingHandler.vertex_passthrough_handler( + httpx_response=response, + logging_obj=logging_obj, + url_route="https://aiplatform.googleapis.com/v1beta1/projects/p/locations/global/interactions", + result=response.text, + start_time=datetime.now(), + end_time=datetime.now(), + cache_hit=False, + request_body={"model": "gemini-omni-flash-preview", "input": [{"type": "text", "text": "say hi"}]}, + ) + + model_response = result["result"] + assert isinstance(model_response, ModelResponse) + assert model_response.id == "call-6896" + usage = model_response.usage + assert usage.prompt_tokens == 12 + assert usage.completion_tokens == 4009 + 20 + assert usage.completion_tokens_details.text_tokens == 9 + assert usage.completion_tokens_details.video_tokens == 4000 + + model_info = litellm.get_model_info(model="gemini-omni-flash-preview", custom_llm_provider="vertex_ai") + expected_cost = ( + 12 * model_info["input_cost_per_token"] + + (9 + 20) * model_info["output_cost_per_token"] + + 4000 * model_info["output_cost_per_video_token"] + ) + assert result["kwargs"]["response_cost"] == pytest.approx(expected_cost) + assert result["kwargs"]["custom_llm_provider"] == "vertex_ai" + assert logging_obj.model_call_details["model"] == "gemini-omni-flash-preview" + assert logging_obj.model_call_details["custom_llm_provider"] == "vertex_ai" + + +def test_interactions_response_without_usage_falls_back_to_generic_logging() -> None: + logging_obj = MagicMock() + logging_obj.model_call_details = {} + logging_obj.optional_params = {} + response = httpx.Response(status_code=200, json={"id": "interactions/abc", "status": "in_progress"}) + + result = VertexPassthroughLoggingHandler.vertex_passthrough_handler( + httpx_response=response, + logging_obj=logging_obj, + url_route="https://aiplatform.googleapis.com/v1beta1/projects/p/locations/global/interactions", + result=response.text, + start_time=datetime.now(), + end_time=datetime.now(), + cache_hit=False, + request_body={"agent": "projects/p/locations/global/reasoningEngines/1"}, + ) + + assert result["result"] is None + assert "response_cost" not in result["kwargs"] def test_lyria_predict_response_preserves_audio_response_and_logs_cost( diff --git a/tests/test_litellm/llms/vertex_ai/videos/test_vertex_video_transformation.py b/tests/test_litellm/llms/vertex_ai/videos/test_vertex_video_transformation.py index 04e46eab1b7..c192d22b3b7 100644 --- a/tests/test_litellm/llms/vertex_ai/videos/test_vertex_video_transformation.py +++ b/tests/test_litellm/llms/vertex_ai/videos/test_vertex_video_transformation.py @@ -717,6 +717,33 @@ class TestVertexAIVideoConfig: assert video_obj.usage["duration_seconds"] == 8.0 assert video_obj.usage["video_resolution"] == "1080p" + @pytest.mark.parametrize( + "sample_count,expected_video_count", + [(2, 2), (1, 1), (None, None), (0, None), ("2", None)], + ids=["two", "one", "unset", "zero", "string"], + ) + def test_transform_video_create_response_usage_includes_video_count(self, sample_count, expected_video_count): + """Regression for LIT-6896: sampleCount is the number of generated videos and must reach usage for billing.""" + mock_response = Mock(spec=httpx.Response) + mock_response.json.return_value = { + "name": "projects/p/locations/us-central1/publishers/google/models/veo-3.1-fast-generate-001/operations/op-1" + } + parameters = {"durationSeconds": 4, "resolution": "720p"} + if sample_count is not None: + parameters["sampleCount"] = sample_count + + video_obj = self.config.transform_video_create_response( + model="veo-3.1-fast-generate-001", + raw_response=mock_response, + logging_obj=self.mock_logging_obj, + custom_llm_provider="vertex_ai", + request_data={"instances": [{"prompt": "a red ball"}], "parameters": parameters}, + ) + + assert video_obj.usage is not None + assert video_obj.usage["duration_seconds"] == 4.0 + assert video_obj.usage.get("video_count") == expected_video_count + def test_transform_video_remix_request_not_supported(self): """Test that video remix raises NotImplementedError.""" with pytest.raises(NotImplementedError, match="Video remix is not supported"): diff --git a/tests/test_litellm/proxy/pass_through_endpoints/llm_provider_handlers/test_gemini_passthrough_logging_handler.py b/tests/test_litellm/proxy/pass_through_endpoints/llm_provider_handlers/test_gemini_passthrough_logging_handler.py index 61d1caacb91..b89ae530d6f 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/llm_provider_handlers/test_gemini_passthrough_logging_handler.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/llm_provider_handlers/test_gemini_passthrough_logging_handler.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest - +import litellm from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.proxy.pass_through_endpoints.llm_provider_handlers.gemini_passthrough_logging_handler import ( GeminiPassthroughLoggingHandler, @@ -397,3 +397,52 @@ class TestGeminiPassthroughLoggingHandler: assert mock_logging_obj.model_call_details["response_cost"] == expected_cost assert mock_logging_obj.model_call_details["model"] == "veo-2.0-generate-001" assert mock_logging_obj.model_call_details["custom_llm_provider"] == "gemini" + + def test_interactions_create_response_is_priced_as_gemini(self): + """Regression for LIT-6896: Gemini API Interactions passthrough must not log zero usage.""" + usage = { + "total_tokens": 1030, + "total_input_tokens": 10, + "input_tokens_by_modality": [{"modality": "text", "tokens": 10}], + "total_output_tokens": 1020, + "output_tokens_by_modality": [ + {"modality": "text", "tokens": 20}, + {"modality": "video", "tokens": 1000}, + ], + "total_tool_use_tokens": 0, + "total_thought_tokens": 0, + } + mock_httpx_response = MagicMock(spec=httpx.Response) + mock_httpx_response.json.return_value = { + "id": "interactions/abc", + "model": "gemini-omni-flash-preview", + "status": "completed", + "usage": usage, + } + mock_logging_obj = MagicMock(spec=LiteLLMLoggingObj) + mock_logging_obj.model_call_details = {} + mock_logging_obj.litellm_call_id = "call-6896" + + result = GeminiPassthroughLoggingHandler.gemini_passthrough_handler( + httpx_response=mock_httpx_response, + response_body=mock_httpx_response.json.return_value, + logging_obj=mock_logging_obj, + url_route="https://generativelanguage.googleapis.com/v1beta/interactions", + result="", + start_time=self.start_time, + end_time=self.end_time, + cache_hit=False, + request_body={"model": "gemini-omni-flash-preview", "input": "make a clip"}, + ) + + model_info = litellm.get_model_info(model="gemini-omni-flash-preview", custom_llm_provider="gemini") + expected_cost = ( + 10 * model_info["input_cost_per_token"] + + 20 * model_info["output_cost_per_token"] + + 1000 * model_info["output_cost_per_video_token"] + ) + assert result["result"].id == "call-6896" + assert result["result"].usage.completion_tokens_details.video_tokens == 1000 + assert result["kwargs"]["response_cost"] == pytest.approx(expected_cost) + assert result["kwargs"]["custom_llm_provider"] == "gemini" + assert mock_logging_obj.model_call_details["custom_llm_provider"] == "gemini" diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py index 11066d4ed38..988c4782f6c 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py @@ -496,6 +496,26 @@ def test_is_vertex_route_ignores_plain_predict_path_segment(): ) +def test_interactions_create_routes_are_tracked_for_vertex_and_gemini(): + """ + Regression for LIT-6896: Interactions API (gemini-omni) passthrough responses + were never handed to the Vertex/Gemini logging handlers, so SpendLogs rows + landed with zero tokens and zero spend. Only the create URL is billable; + GET/DELETE on an interaction id and non-Google `/interactions` URLs stay generic. + """ + handler = PassThroughEndpointLogging() + vertex_create = "https://aiplatform.googleapis.com/v1beta1/projects/p/locations/global/interactions" + gemini_create = "https://generativelanguage.googleapis.com/v1beta/interactions" + + assert handler.is_vertex_route(vertex_create) is True + assert handler.is_vertex_route(f"{vertex_create}/abc123") is False + assert handler.is_vertex_route("https://upstream.example.com/api/interactions") is False + + assert handler.is_gemini_route(gemini_create, custom_llm_provider="gemini") is True + assert handler.is_gemini_route(f"{gemini_create}/abc123", custom_llm_provider="gemini") is False + assert handler.is_gemini_route(gemini_create, custom_llm_provider=None) is False + + @pytest.mark.asyncio async def test_custom_passthrough_predict_path_logs_via_generic_handler(): """ diff --git a/tests/test_litellm/test_cost_calculator.py b/tests/test_litellm/test_cost_calculator.py index ef797ef8bcc..f7ea2c00891 100644 --- a/tests/test_litellm/test_cost_calculator.py +++ b/tests/test_litellm/test_cost_calculator.py @@ -4141,6 +4141,31 @@ def test_completion_cost_bills_interactions_video_output_at_video_rate(): assert cost == pytest.approx(expected) +@pytest.mark.parametrize("video_count", [2, 3]) +def test_completion_cost_multiplies_video_cost_by_generated_video_count(video_count: int) -> None: + """Regression for LIT-6896: a Veo request for N samples generates N videos and must be billed N times.""" + from litellm.types.videos.main import VideoObject + + def _video(usage: dict[str, object]) -> VideoObject: + return VideoObject(id="v", object="video", status="processing", model="veo-3.1-fast-generate-001", usage=usage) + + single_cost = completion_cost( + completion_response=_video({"duration_seconds": 4.0, "video_resolution": "720p"}), + model="veo-3.1-fast-generate-001", + custom_llm_provider="vertex_ai", + call_type="create_video", + ) + multi_cost = completion_cost( + completion_response=_video({"duration_seconds": 4.0, "video_resolution": "720p", "video_count": video_count}), + model="veo-3.1-fast-generate-001", + custom_llm_provider="vertex_ai", + call_type="create_video", + ) + + assert single_cost > 0 + assert multi_cost == pytest.approx(single_cost * video_count) + + @pytest.mark.parametrize( "batch_rate,expected_prompt,expected_completion", [ From 74a2c2eab1f86ada6bc4ccc55dfab64bd6d39219 Mon Sep 17 00:00:00 2001 From: yassin Date: Tue, 15 Sep 2026 23:34:31 +0000 Subject: [PATCH 2/2] refactor(vertex_ai): tighten Interactions route match and type the usage boundary Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/cost_calculator.py | 4 +--- .../llms/vertex_ai/videos/transformation.py | 1 - .../vertex_passthrough_logging_handler.py | 20 ++++++++++++++----- .../pass_through_endpoints/success_handler.py | 4 +--- .../test_pass_through_endpoints.py | 7 +++++++ 5 files changed, 24 insertions(+), 12 deletions(-) diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index f81eb1539fd..e6bb8da4c6a 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -1460,7 +1460,6 @@ def completion_cost( duration_seconds: float | None = None video_resolution: str | None = None provider_reported_cost: float | None = None - video_count: int = 1 if completion_response is not None and usage_obj: # Handle both dict and Pydantic Usage object if isinstance(usage_obj, dict): @@ -1475,8 +1474,7 @@ def completion_cost( _vc = getattr(usage_obj, "video_count", None) if _vr is not None: video_resolution = str(_vr).strip().lower() - if isinstance(_vc, int) and not isinstance(_vc, bool) and _vc > 1: - video_count = _vc + video_count = _vc if isinstance(_vc, int) and not isinstance(_vc, bool) and _vc > 1 else 1 if _video_model_info is None and provider_reported_cost is not None: return float(provider_reported_cost) diff --git a/litellm/llms/vertex_ai/videos/transformation.py b/litellm/llms/vertex_ai/videos/transformation.py index 6525c6fa235..dc9caa13224 100644 --- a/litellm/llms/vertex_ai/videos/transformation.py +++ b/litellm/llms/vertex_ai/videos/transformation.py @@ -71,7 +71,6 @@ def _parse_veo_operation(raw_response: httpx.Response) -> _VeoOperation: def veo_video_count_from_parameters(parameters: Mapping[str, object]) -> int | None: - """Number of videos Veo generates for one request (``parameters.sampleCount``).""" sample_count: Final = parameters.get("sampleCount") if isinstance(sample_count, bool) or not isinstance(sample_count, int) or sample_count < 1: return None diff --git a/litellm/proxy/pass_through_endpoints/llm_provider_handlers/vertex_passthrough_logging_handler.py b/litellm/proxy/pass_through_endpoints/llm_provider_handlers/vertex_passthrough_logging_handler.py index 0c4bc2a5181..cd226e80c6e 100644 --- a/litellm/proxy/pass_through_endpoints/llm_provider_handlers/vertex_passthrough_logging_handler.py +++ b/litellm/proxy/pass_through_endpoints/llm_provider_handlers/vertex_passthrough_logging_handler.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any, Final, Literal, cast from urllib.parse import urlparse import httpx +from pydantic import TypeAdapter import litellm from litellm._logging import verbose_proxy_logger @@ -53,6 +54,9 @@ else: EndpointType = Any +_VERTEX_INTERACTIONS_PATH: Final = re.compile(r"/projects/[^/]+/locations/[^/]+/interactions/?$") +_INTERACTIONS_RESPONSE_BODY: Final = TypeAdapter(dict[str, object]) + def _interactions_model( response_body: Mapping[str, object], @@ -72,18 +76,22 @@ class VertexPassthroughLoggingHandler: def is_interactions_route(url_route: str) -> bool: return urlparse(url_route).path.rstrip("/").endswith("/interactions") + @staticmethod + def is_vertex_interactions_route(url_route: str) -> bool: + return _VERTEX_INTERACTIONS_PATH.search(urlparse(url_route).path) is not None + @staticmethod def interactions_passthrough_handler( httpx_response: httpx.Response, request_body: Mapping[str, object] | None, logging_obj: LiteLLMLoggingObj, - kwargs: dict, + kwargs: dict[str, object], start_time: datetime, end_time: datetime, custom_llm_provider: Literal["vertex_ai", "gemini"], vertex_location: str | None, ) -> PassThroughEndpointLoggingTypedDict: - response_body: Final[Mapping[str, object]] = httpx_response.json() + response_body: Final = _INTERACTIONS_RESPONSE_BODY.validate_python(httpx_response.json()) usage_object: Final = response_body.get("usage") model: Final = _interactions_model(response_body, request_body) if model is None or not InteractionsUsageObjectTransformation.is_interactions_usage_object(usage_object): @@ -95,6 +103,7 @@ class VertexPassthroughLoggingHandler: cast(Mapping[str, Any], usage_object) ), ) + logging_obj.custom_llm_provider = custom_llm_provider logging_kwargs: Final = ( VertexPassthroughLoggingHandler._create_vertex_response_logging_payload_for_generate_content( litellm_model_response=litellm_model_response, @@ -107,9 +116,10 @@ class VertexPassthroughLoggingHandler: vertex_location=vertex_location, ) ) - logging_kwargs["custom_llm_provider"] = custom_llm_provider - logging_obj.custom_llm_provider = custom_llm_provider - return {"result": litellm_model_response, "kwargs": logging_kwargs} + return { + "result": litellm_model_response, + "kwargs": {**logging_kwargs, "custom_llm_provider": custom_llm_provider}, + } @staticmethod def vertex_passthrough_handler( diff --git a/litellm/proxy/pass_through_endpoints/success_handler.py b/litellm/proxy/pass_through_endpoints/success_handler.py index dd8bd17eee9..76a471302f4 100644 --- a/litellm/proxy/pass_through_endpoints/success_handler.py +++ b/litellm/proxy/pass_through_endpoints/success_handler.py @@ -363,9 +363,7 @@ class PassThroughEndpointLogging: return True if any(resource in url_route for resource in self.TRACKED_VERTEX_RESOURCE_ROUTES): return True - return "/locations/" in urlparse(url_route).path and VertexPassthroughLoggingHandler.is_interactions_route( - url_route - ) + return VertexPassthroughLoggingHandler.is_vertex_interactions_route(url_route) def is_anthropic_route(self, url_route: str): for route in self.TRACKED_ANTHROPIC_ROUTES: diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py index 988c4782f6c..cc1d408176c 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py @@ -510,6 +510,13 @@ def test_interactions_create_routes_are_tracked_for_vertex_and_gemini(): assert handler.is_vertex_route(vertex_create) is True assert handler.is_vertex_route(f"{vertex_create}/abc123") is False assert handler.is_vertex_route("https://upstream.example.com/api/interactions") is False + assert handler.is_vertex_route("https://upstream.example.com/locations/eu/interactions") is False + assert ( + handler.is_vertex_route( + "https://us-central1-aiplatform.googleapis.com/v1/projects/p/locations/us-central1/interactions" + ) + is True + ) assert handler.is_gemini_route(gemini_create, custom_llm_provider="gemini") is True assert handler.is_gemini_route(f"{gemini_create}/abc123", custom_llm_provider="gemini") is False