From 3ead9d16884c652ccbabe618f7526d74b3f4743e Mon Sep 17 00:00:00 2001 From: Emerson Gomes Date: Fri, 19 Jun 2026 16:43:02 -0500 Subject: [PATCH 01/17] feat(vertex): add Lyria model support --- .../llms/vertex_ai/interactions/__init__.py | 3 + ...odel_prices_and_context_window_backup.json | 81 +++++++++++++++++++ .../vertex_passthrough_logging_handler.py | 69 ++++++++++++++++ model_prices_and_context_window.json | 81 +++++++++++++++++++ ...test_vertex_passthrough_logging_handler.py | 68 ++++++++++++++++ tests/test_litellm/test_utils.py | 37 +++++++++ 6 files changed, 339 insertions(+) create mode 100644 tests/test_litellm/llms/vertex_ai/test_vertex_passthrough_logging_handler.py diff --git a/litellm/llms/vertex_ai/interactions/__init__.py b/litellm/llms/vertex_ai/interactions/__init__.py index e69de29bb2d..3e2309d21ef 100644 --- a/litellm/llms/vertex_ai/interactions/__init__.py +++ b/litellm/llms/vertex_ai/interactions/__init__.py @@ -0,0 +1,3 @@ +from .transformation import VertexAIInteractionsConfig + +__all__ = ["VertexAIInteractionsConfig"] diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index d8a8f84b032..e854e8fc9bb 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -45511,6 +45511,87 @@ "output_cost_per_token": 4e-07, "supports_tool_choice": true }, + "vertex_ai/lyria-002": { + "litellm_provider": "vertex_ai", + "max_audio_length_hours": 0.009111111111111111, + "max_audio_per_prompt": 4, + "mode": "chat", + "output_cost_per_second": 0.002, + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing#lyria", + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "audio" + ], + "supports_audio_output": true + }, + "vertex_ai/lyria-3-clip-preview": { + "input_cost_per_token": 0, + "litellm_provider": "vertex_ai", + "max_input_tokens": 131072, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_image": 0.04, + "output_cost_per_token": 0, + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing#lyria", + "supported_endpoints": [ + "/v1beta/interactions" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "audio" + ], + "supported_regions": [ + "global" + ], + "supports_audio_input": false, + "supports_audio_output": true, + "supports_function_calling": false, + "supports_image_input": true, + "supports_prompt_caching": false, + "supports_response_schema": false, + "supports_system_messages": false, + "supports_vision": true, + "supports_web_search": false + }, + "vertex_ai/lyria-3-pro-preview": { + "input_cost_per_token": 0, + "litellm_provider": "vertex_ai", + "max_input_tokens": 131072, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_image": 0.08, + "output_cost_per_token": 0, + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing#lyria", + "supported_endpoints": [ + "/v1beta/interactions" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "audio" + ], + "supported_regions": [ + "global" + ], + "supports_audio_input": false, + "supports_audio_output": true, + "supports_function_calling": false, + "supports_image_input": true, + "supports_prompt_caching": false, + "supports_response_schema": false, + "supports_system_messages": false, + "supports_vision": true, + "supports_web_search": false + }, "vertex_ai/meta/llama-3.1-405b-instruct-maas": { "input_cost_per_token": 5e-06, "litellm_provider": "vertex_ai-llama_models", 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 49ec18013b5..a9f68f8380b 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 @@ -46,6 +46,7 @@ else: # Define EndpointType locally to avoid import issues EndpointType = Any +_LYRIA_SECONDS_PER_AUDIO_PREDICTION = 30 class VertexPassthroughLoggingHandler: @@ -270,6 +271,16 @@ class VertexPassthroughLoggingHandler: _json_response: Final[dict[str, object]] = httpx_response.json() litellm_prediction_response: ModelResponse | EmbeddingResponse | ImageResponse = ModelResponse() + if VertexPassthroughLoggingHandler._is_lyria_predict_response( + model=model, + json_response=_json_response, + ): + return VertexPassthroughLoggingHandler._handle_lyria_predict_response( + json_response=_json_response, + logging_obj=logging_obj, + model=model, + kwargs=kwargs, + ) if vertex_image_generation_class.is_image_generation_response(_json_response): litellm_prediction_response = vertex_image_generation_class.process_image_generation_response( _json_response, @@ -323,6 +334,64 @@ class VertexPassthroughLoggingHandler: "kwargs": kwargs, } + @staticmethod + def _handle_lyria_predict_response( + json_response: dict, + logging_obj: LiteLLMLoggingObj, + model: str, + kwargs: dict, + ) -> PassThroughEndpointLoggingTypedDict: + prediction_count: Final = ( + VertexPassthroughLoggingHandler._get_lyria_audio_prediction_count( + json_response=json_response + ) + ) + model_info: Final = litellm.model_cost.get(f"vertex_ai/{model}", {}) + response_cost: Final = ( + model_info.get("output_cost_per_second", 0.0) + * _LYRIA_SECONDS_PER_AUDIO_PREDICTION + * prediction_count + ) + + logging_obj.model = model + logging_obj.model_call_details["model"] = model + logging_obj.model_call_details["custom_llm_provider"] = "vertex_ai" + logging_obj.custom_llm_provider = "vertex_ai" + logging_obj.model_call_details["response_cost"] = response_cost + + kwargs["response_cost"] = response_cost + kwargs["model"] = model + kwargs["custom_llm_provider"] = "vertex_ai" + + standard_pass_through_response_object: Final[StandardPassThroughResponseObject] = { + "response": json_response, + } + return { + "result": standard_pass_through_response_object, + "kwargs": kwargs, + } + + @staticmethod + def _is_lyria_predict_response(model: str, json_response: dict) -> bool: + return ( + model == "lyria-002" + and VertexPassthroughLoggingHandler._get_lyria_audio_prediction_count( + json_response=json_response + ) + > 0 + ) + + @staticmethod + def _get_lyria_audio_prediction_count(json_response: dict) -> int: + predictions: Final = json_response.get("predictions") + if not isinstance(predictions, list): + return 0 + return sum( + 1 + for prediction in predictions + if isinstance(prediction, dict) and prediction.get("audioContent") + ) + @staticmethod def _extract_embed_content_input(request_body: dict | None, batch: bool) -> str: """Extract raw input text from an :embedContent or :batchEmbedContents request body for token counting.""" diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index d8a8f84b032..e854e8fc9bb 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -45511,6 +45511,87 @@ "output_cost_per_token": 4e-07, "supports_tool_choice": true }, + "vertex_ai/lyria-002": { + "litellm_provider": "vertex_ai", + "max_audio_length_hours": 0.009111111111111111, + "max_audio_per_prompt": 4, + "mode": "chat", + "output_cost_per_second": 0.002, + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing#lyria", + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "audio" + ], + "supports_audio_output": true + }, + "vertex_ai/lyria-3-clip-preview": { + "input_cost_per_token": 0, + "litellm_provider": "vertex_ai", + "max_input_tokens": 131072, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_image": 0.04, + "output_cost_per_token": 0, + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing#lyria", + "supported_endpoints": [ + "/v1beta/interactions" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "audio" + ], + "supported_regions": [ + "global" + ], + "supports_audio_input": false, + "supports_audio_output": true, + "supports_function_calling": false, + "supports_image_input": true, + "supports_prompt_caching": false, + "supports_response_schema": false, + "supports_system_messages": false, + "supports_vision": true, + "supports_web_search": false + }, + "vertex_ai/lyria-3-pro-preview": { + "input_cost_per_token": 0, + "litellm_provider": "vertex_ai", + "max_input_tokens": 131072, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_image": 0.08, + "output_cost_per_token": 0, + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing#lyria", + "supported_endpoints": [ + "/v1beta/interactions" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "audio" + ], + "supported_regions": [ + "global" + ], + "supports_audio_input": false, + "supports_audio_output": true, + "supports_function_calling": false, + "supports_image_input": true, + "supports_prompt_caching": false, + "supports_response_schema": false, + "supports_system_messages": false, + "supports_vision": true, + "supports_web_search": false + }, "vertex_ai/meta/llama-3.1-405b-instruct-maas": { "input_cost_per_token": 5e-06, "litellm_provider": "vertex_ai-llama_models", 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 new file mode 100644 index 00000000000..7af67cc0795 --- /dev/null +++ b/tests/test_litellm/llms/vertex_ai/test_vertex_passthrough_logging_handler.py @@ -0,0 +1,68 @@ +from datetime import datetime +from unittest.mock import MagicMock + +import httpx +import litellm +import pytest + +from litellm.proxy.pass_through_endpoints.llm_provider_handlers.vertex_passthrough_logging_handler import ( + VertexPassthroughLoggingHandler, +) + + +def test_lyria_predict_response_preserves_audio_response_and_logs_cost( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setitem( + litellm.model_cost, + "vertex_ai/lyria-002", + {"output_cost_per_second": 0.002}, + ) + logging_obj = MagicMock() + logging_obj.model_call_details = {} + response = httpx.Response( + status_code=200, + json={ + "predictions": [ + { + "audioContent": "clip-1", + "mimeType": "audio/wav", + }, + { + "audioContent": "clip-2", + "mimeType": "audio/wav", + }, + ] + }, + ) + + result = VertexPassthroughLoggingHandler.vertex_passthrough_handler( + httpx_response=response, + logging_obj=logging_obj, + url_route="/v1/projects/test/locations/us-central1/publishers/google/models/lyria-002:predict", + result=response.text, + start_time=datetime.now(), + end_time=datetime.now(), + cache_hit=False, + request_body={"instances": [{"prompt": "ambient piano"}]}, + ) + + assert result["result"] == { + "response": { + "predictions": [ + { + "audioContent": "clip-1", + "mimeType": "audio/wav", + }, + { + "audioContent": "clip-2", + "mimeType": "audio/wav", + }, + ] + } + } + assert result["kwargs"]["model"] == "lyria-002" + assert result["kwargs"]["custom_llm_provider"] == "vertex_ai" + assert result["kwargs"]["response_cost"] == pytest.approx(0.12) + assert logging_obj.model == "lyria-002" + assert logging_obj.model_call_details["response_cost"] == pytest.approx(0.12) diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index 200cfd02197..d8255e9ff1b 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -1067,6 +1067,7 @@ def test_aaamodel_prices_and_context_window_json_is_valid(): "/v1/images/variations", "/v1/images/edits", "/v1/batch", + "/v1beta/interactions", "/v1/audio/transcriptions", "/v1/audio/speech", "/v1/ocr", @@ -2833,6 +2834,42 @@ def test_gemini_lyria_3_preview_models_in_cost_map(): assert clip["output_cost_per_image"] == 0.04 +def test_vertex_ai_lyria_models_in_cost_map(): + import json + from pathlib import Path + + json_path = Path(__file__).parents[2] / "model_prices_and_context_window.json" + with open(json_path) as f: + model_cost = json.load(f) + + lyria_2 = model_cost.get("vertex_ai/lyria-002") + clip = model_cost.get("vertex_ai/lyria-3-clip-preview") + pro = model_cost.get("vertex_ai/lyria-3-pro-preview") + + assert lyria_2 is not None + assert clip is not None + assert pro is not None + assert lyria_2["litellm_provider"] == "vertex_ai" + assert clip["litellm_provider"] == "vertex_ai" + assert pro["litellm_provider"] == "vertex_ai" + assert lyria_2["output_cost_per_second"] == 0.002 + assert lyria_2["supported_modalities"] == ["text"] + assert lyria_2["supported_output_modalities"] == ["audio"] + assert lyria_2["supports_audio_output"] is True + assert clip["output_cost_per_image"] == 0.04 + assert pro["output_cost_per_image"] == 0.08 + assert clip["supported_endpoints"] == ["/v1beta/interactions"] + assert pro["supported_endpoints"] == ["/v1beta/interactions"] + assert clip["supported_modalities"] == ["text", "image"] + assert pro["supported_modalities"] == ["text", "image"] + assert clip["supported_regions"] == ["global"] + assert pro["supported_regions"] == ["global"] + assert clip["supports_audio_output"] is True + assert pro["supports_audio_output"] is True + assert clip["supports_image_input"] is True + assert pro["supports_image_input"] is True + + def test_model_info_for_fireworks_short_form_models(): """ Test that fireworks_ai short-form model entries (fireworks_ai/) From 514e9a1ee62e52480343d084b8f87b54c67f5a41 Mon Sep 17 00:00:00 2001 From: Emerson Gomes Date: Fri, 19 Jun 2026 17:44:54 -0500 Subject: [PATCH 02/17] fix(vertex): address lyria review feedback --- ...odel_prices_and_context_window_backup.json | 1 + .../vertex_passthrough_logging_handler.py | 44 ++++++++++-------- model_prices_and_context_window.json | 1 + ...test_vertex_passthrough_logging_handler.py | 46 ++++++++++++++++++- tests/test_litellm/test_utils.py | 2 + 5 files changed, 75 insertions(+), 19 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index e854e8fc9bb..1a1d92805e2 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -45512,6 +45512,7 @@ "supports_tool_choice": true }, "vertex_ai/lyria-002": { + "audio_seconds_per_prediction": 30, "litellm_provider": "vertex_ai", "max_audio_length_hours": 0.009111111111111111, "max_audio_per_prompt": 4, 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 a9f68f8380b..6b2d7763fa0 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 @@ -44,9 +44,7 @@ else: PassThroughEndpointLogging = Any LiteLLMBatch = Any -# Define EndpointType locally to avoid import issues EndpointType = Any -_LYRIA_SECONDS_PER_AUDIO_PREDICTION = 30 class VertexPassthroughLoggingHandler: @@ -271,11 +269,11 @@ class VertexPassthroughLoggingHandler: _json_response: Final[dict[str, object]] = httpx_response.json() litellm_prediction_response: ModelResponse | EmbeddingResponse | ImageResponse = ModelResponse() - if VertexPassthroughLoggingHandler._is_lyria_predict_response( + if VertexPassthroughLoggingHandler._is_audio_predict_response( model=model, json_response=_json_response, ): - return VertexPassthroughLoggingHandler._handle_lyria_predict_response( + return VertexPassthroughLoggingHandler._handle_audio_predict_response( json_response=_json_response, logging_obj=logging_obj, model=model, @@ -335,23 +333,19 @@ class VertexPassthroughLoggingHandler: } @staticmethod - def _handle_lyria_predict_response( + def _handle_audio_predict_response( json_response: dict, logging_obj: LiteLLMLoggingObj, model: str, kwargs: dict, ) -> PassThroughEndpointLoggingTypedDict: - prediction_count: Final = ( - VertexPassthroughLoggingHandler._get_lyria_audio_prediction_count( - json_response=json_response - ) + prediction_count: Final = VertexPassthroughLoggingHandler._get_audio_prediction_count( + json_response=json_response ) - model_info: Final = litellm.model_cost.get(f"vertex_ai/{model}", {}) response_cost: Final = ( - model_info.get("output_cost_per_second", 0.0) - * _LYRIA_SECONDS_PER_AUDIO_PREDICTION - * prediction_count - ) + VertexPassthroughLoggingHandler._get_audio_prediction_unit_cost(model=model) + or 0.0 + ) * prediction_count logging_obj.model = model logging_obj.model_call_details["model"] = model @@ -372,17 +366,31 @@ class VertexPassthroughLoggingHandler: } @staticmethod - def _is_lyria_predict_response(model: str, json_response: dict) -> bool: + def _is_audio_predict_response(model: str, json_response: dict) -> bool: return ( - model == "lyria-002" - and VertexPassthroughLoggingHandler._get_lyria_audio_prediction_count( + VertexPassthroughLoggingHandler._get_audio_prediction_count( json_response=json_response ) > 0 + and VertexPassthroughLoggingHandler._get_audio_prediction_unit_cost( + model=model + ) + is not None ) @staticmethod - def _get_lyria_audio_prediction_count(json_response: dict) -> int: + def _get_audio_prediction_unit_cost(model: str) -> float | None: + model_info: Final = litellm.model_cost.get(f"vertex_ai/{model}", {}) + output_cost_per_second: Final = model_info.get("output_cost_per_second") + audio_seconds_per_prediction: Final = model_info.get("audio_seconds_per_prediction") + if not isinstance(output_cost_per_second, (int, float)) or not isinstance( + audio_seconds_per_prediction, (int, float) + ): + return None + return float(output_cost_per_second * audio_seconds_per_prediction) + + @staticmethod + def _get_audio_prediction_count(json_response: dict) -> int: predictions: Final = json_response.get("predictions") if not isinstance(predictions, list): return 0 diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index e854e8fc9bb..1a1d92805e2 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -45512,6 +45512,7 @@ "supports_tool_choice": true }, "vertex_ai/lyria-002": { + "audio_seconds_per_prediction": 30, "litellm_provider": "vertex_ai", "max_audio_length_hours": 0.009111111111111111, "max_audio_per_prompt": 4, 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 7af67cc0795..9f65fa09b1b 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 @@ -16,7 +16,10 @@ def test_lyria_predict_response_preserves_audio_response_and_logs_cost( monkeypatch.setitem( litellm.model_cost, "vertex_ai/lyria-002", - {"output_cost_per_second": 0.002}, + { + "audio_seconds_per_prediction": 30, + "output_cost_per_second": 0.002, + }, ) logging_obj = MagicMock() logging_obj.model_call_details = {} @@ -66,3 +69,44 @@ def test_lyria_predict_response_preserves_audio_response_and_logs_cost( assert result["kwargs"]["response_cost"] == pytest.approx(0.12) assert logging_obj.model == "lyria-002" assert logging_obj.model_call_details["response_cost"] == pytest.approx(0.12) + + +def test_audio_predict_response_uses_model_map_metadata( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setitem( + litellm.model_cost, + "vertex_ai/music-audio-preview", + { + "audio_seconds_per_prediction": 12, + "output_cost_per_second": 0.5, + }, + ) + logging_obj = MagicMock() + logging_obj.model_call_details = {} + response = httpx.Response( + status_code=200, + json={ + "predictions": [ + { + "audioContent": "clip", + "mimeType": "audio/wav", + } + ] + }, + ) + + result = VertexPassthroughLoggingHandler.vertex_passthrough_handler( + httpx_response=response, + logging_obj=logging_obj, + url_route="/v1/projects/test/locations/us-central1/publishers/google/models/music-audio-preview:predict", + result=response.text, + start_time=datetime.now(), + end_time=datetime.now(), + cache_hit=False, + request_body={"instances": [{"prompt": "ambient piano"}]}, + ) + + assert result["kwargs"]["model"] == "music-audio-preview" + assert result["kwargs"]["response_cost"] == pytest.approx(6.0) + assert logging_obj.model_call_details["response_cost"] == pytest.approx(6.0) diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index d8255e9ff1b..6ae7c27b758 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -949,6 +949,7 @@ def test_aaamodel_prices_and_context_window_json_is_valid(): "max_tokens": {"type": "number"}, "metadata": {"type": "object"}, "provider_specific_entry": {"type": "object"}, + "audio_seconds_per_prediction": {"type": "number"}, "mode": { "type": "string", "enum": [ @@ -2852,6 +2853,7 @@ def test_vertex_ai_lyria_models_in_cost_map(): assert lyria_2["litellm_provider"] == "vertex_ai" assert clip["litellm_provider"] == "vertex_ai" assert pro["litellm_provider"] == "vertex_ai" + assert lyria_2["audio_seconds_per_prediction"] == 30 assert lyria_2["output_cost_per_second"] == 0.002 assert lyria_2["supported_modalities"] == ["text"] assert lyria_2["supported_output_modalities"] == ["audio"] From e00fe023a973860345f56278233398a3f8a5b4ff Mon Sep 17 00:00:00 2001 From: Emerson Gomes Date: Wed, 15 Jul 2026 19:25:19 -0500 Subject: [PATCH 03/17] test(models): validate Lyria audio metadata --- tests/test_litellm/test_utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index 6ae7c27b758..7bec649099a 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -944,6 +944,8 @@ def test_aaamodel_prices_and_context_window_json_is_valid(): "code_interpreter_cost_per_session": {"type": "number"}, "inference_geo": {"type": "string"}, "litellm_provider": {"type": "string"}, + "max_audio_length_hours": {"type": "number"}, + "max_audio_per_prompt": {"type": "number"}, "max_input_tokens": {"type": "number"}, "max_output_tokens": {"type": "number"}, "max_tokens": {"type": "number"}, From b96844dd0c23a084108191df7ff37423a40f862f Mon Sep 17 00:00:00 2001 From: Emerson Gomes Date: Wed, 15 Jul 2026 19:56:53 -0500 Subject: [PATCH 04/17] feat(vertex): expose Lyria through audio speech --- litellm/cost_calculator.py | 12 +- .../text_to_speech/transformation.py | 160 ++++++++++ litellm/main.py | 6 +- ...odel_prices_and_context_window_backup.json | 15 +- .../vertex_passthrough_logging_handler.py | 12 +- litellm/proxy/proxy_server.py | 11 +- litellm/types/utils.py | 4 + litellm/utils.py | 6 + model_prices_and_context_window.json | 15 +- ...test_vertex_passthrough_logging_handler.py | 33 ++ .../text_to_speech/test_transformation.py | 298 +++++++++++++++++- tests/test_litellm/test_cost_calculator.py | 18 ++ tests/test_litellm/test_utils.py | 15 +- 13 files changed, 565 insertions(+), 40 deletions(-) diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index b83e9b395a8..2bac5e234bc 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -496,9 +496,19 @@ def cost_per_token( # see this https://learn.microsoft.com/en-us/azure/ai-services/openai/concepts/models if call_type == "speech" or call_type == "aspeech": speech_model_info = litellm.get_model_info(model=model_without_prefix, custom_llm_provider=custom_llm_provider) - cost_metric: Final = select_cost_metric_for_model(speech_model_info) prompt_cost: float = 0.0 completion_cost: float = 0.0 + if not speech_model_info.get("input_cost_per_character") and not speech_model_info.get( + "input_cost_per_token" + ): + output_cost_per_generation: Final = speech_model_info.get("output_cost_per_image") + output_cost_per_second: Final = speech_model_info.get("output_cost_per_second") + audio_seconds_per_prediction: Final = speech_model_info.get("audio_seconds_per_prediction") + if output_cost_per_generation is not None: + return prompt_cost, float(output_cost_per_generation) + if output_cost_per_second is not None and audio_seconds_per_prediction is not None: + return prompt_cost, float(output_cost_per_second) * float(audio_seconds_per_prediction) + cost_metric: Final = select_cost_metric_for_model(speech_model_info) if cost_metric == "cost_per_character": if prompt_characters is None: raise ValueError( diff --git a/litellm/llms/vertex_ai/text_to_speech/transformation.py b/litellm/llms/vertex_ai/text_to_speech/transformation.py index 332f892ae6b..ad39aa35f8c 100644 --- a/litellm/llms/vertex_ai/text_to_speech/transformation.py +++ b/litellm/llms/vertex_ai/text_to_speech/transformation.py @@ -12,6 +12,8 @@ from typing import TYPE_CHECKING, Any, Final, Union import httpx +import litellm +from litellm.exceptions import UnsupportedParamsError from litellm.litellm_core_utils.audio_utils.utils import ( speech_media_type_from_audio_bytes, ) @@ -471,3 +473,161 @@ class VertexAITextToSpeechConfig(BaseTextToSpeechConfig, VertexBase): # Initialize the HttpxBinaryResponseContent instance return HttpxBinaryResponseContent(response) + + +class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): + LYRIA_MODELS = { + "lyria-002", + "lyria-3-clip-preview", + "lyria-3-pro-preview", + } + + @classmethod + def is_lyria_model(cls, model: str) -> bool: + return model.removeprefix("vertex_ai/") in cls.LYRIA_MODELS + + def get_supported_openai_params(self, model: str) -> list: + return ["response_format"] + + def map_openai_params( + self, + model: str, + optional_params: dict, + voice: str | dict | None = None, + drop_params: bool = False, + kwargs: dict = {}, + ) -> tuple[str | None, dict]: + mapped_params = dict(optional_params) + base_model = model.removeprefix("vertex_ai/") + unsupported_params = [param for param in ("speed", "instructions") if mapped_params.get(param) is not None] + if unsupported_params: + if drop_params or litellm.drop_params: + for param in unsupported_params: + mapped_params.pop(param, None) + else: + raise UnsupportedParamsError( + status_code=400, + message=( + f"Vertex AI {base_model} does not support the OpenAI parameters: " + f"{', '.join(unsupported_params)}. To drop unsupported openai params " + "from the call, set `litellm.drop_params = True`" + ), + ) + response_format = mapped_params.get("response_format") + supported_formats = ( + {"wav"} if base_model == "lyria-002" else {"mp3", "wav"} if base_model == "lyria-3-pro-preview" else {"mp3"} + ) + if response_format is not None and response_format not in supported_formats: + if drop_params or litellm.drop_params: + mapped_params.pop("response_format", None) + else: + raise UnsupportedParamsError( + status_code=400, + message=( + f"Vertex AI {base_model} does not support response_format={response_format!r}. " + f"Supported values: {', '.join(sorted(supported_formats))}. " + "To drop unsupported openai params from the call, set `litellm.drop_params = True`" + ), + ) + return voice if isinstance(voice, str) else None, mapped_params + + def get_complete_url( + self, + model: str, + api_base: str | None, + litellm_params: dict, + ) -> str: + base_model = model.removeprefix("vertex_ai/") + project = self.safe_get_vertex_ai_project(litellm_params) + if project is None: + _, project = self._ensure_access_token( + credentials=self.safe_get_vertex_ai_credentials(litellm_params), + project_id=None, + custom_llm_provider="vertex_ai", + ) + if base_model.startswith("lyria-3-"): + from litellm.llms.vertex_ai.interactions.transformation import ( + VertexAIInteractionsConfig, + ) + + return VertexAIInteractionsConfig().get_complete_url( + api_base=api_base, + model=base_model, + litellm_params={**litellm_params, "vertex_project": project}, + ) + location = self.safe_get_vertex_ai_location(litellm_params) or self.get_default_vertex_location() + base_url = self.get_api_base(api_base=api_base, vertex_location=location).rstrip("/") + return f"{base_url}/v1/projects/{project}/locations/{location}/publishers/google/models/{base_model}:predict" + + def transform_text_to_speech_request( + self, + model: str, + input: str, + voice: str | None, + optional_params: dict, + litellm_params: dict, + headers: dict, + ) -> TextToSpeechRequestData: + access_token, project = self._ensure_access_token( + credentials=self.safe_get_vertex_ai_credentials(litellm_params), + project_id=self.safe_get_vertex_ai_project(litellm_params), + custom_llm_provider="vertex_ai", + ) + headers.update( + { + "Authorization": f"Bearer {access_token}", + "x-goog-user-project": project, + "Content-Type": "application/json", + } + ) + base_model = model.removeprefix("vertex_ai/") + if base_model == "lyria-002": + request_body = { + "instances": [{"prompt": input}], + "parameters": {"sample_count": 1}, + } + else: + request_body = {"model": base_model, "input": input} + if optional_params.get("response_format") == "wav": + request_body["response_format"] = { + "type": "audio", + "mime_type": "audio/wav", + } + return TextToSpeechRequestData(dict_body=request_body, headers=headers) + + def transform_text_to_speech_response( + self, + model: str, + raw_response: httpx.Response, + logging_obj: "LiteLLMLoggingObj", + ) -> "HttpxBinaryResponseContent": + from litellm.types.llms.openai import HttpxBinaryResponseContent + + response_json = raw_response.json() + base_model = model.removeprefix("vertex_ai/") + audio_data: str | None = None + mime_type: str | None = None + if base_model == "lyria-002": + predictions = response_json.get("predictions") or [] + if predictions: + audio_data = predictions[0].get("audioContent") or predictions[0].get("bytesBase64Encoded") + mime_type = predictions[0].get("mimeType") + else: + for step in response_json.get("steps") or response_json.get("outputs") or []: + content_items = step.get("content") or [] if step.get("type") == "model_output" else [step] + for content in content_items: + if content.get("type") == "audio" and content.get("data"): + audio_data = content["data"] + mime_type = content.get("mime_type") + if audio_data is None: + raise ValueError(f"No generated audio found in Vertex AI {base_model} response") + mime_type = mime_type or ("audio/wav" if base_model == "lyria-002" else "audio/mpeg") + response = HttpxBinaryResponseContent( + httpx.Response( + status_code=raw_response.status_code, + content=base64.b64decode(audio_data), + headers={"content-type": mime_type}, + ) + ) + response._hidden_params = {"audio_mime_type": mime_type} + return response diff --git a/litellm/main.py b/litellm/main.py index 0128e4defe5..55db92d44b3 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -8235,6 +8235,7 @@ def speech( ) elif custom_llm_provider == "vertex_ai" or custom_llm_provider == "vertex_ai_beta": from litellm.llms.vertex_ai.text_to_speech.transformation import ( + VertexAILyriaTextToSpeechConfig, VertexAITextToSpeechConfig, ) @@ -8259,7 +8260,10 @@ def speech( # Vertex AI Text-to-Speech (Google Cloud TTS) if text_to_speech_provider_config is None: - text_to_speech_provider_config = VertexAITextToSpeechConfig() + if VertexAILyriaTextToSpeechConfig.is_lyria_model(model): + text_to_speech_provider_config = VertexAILyriaTextToSpeechConfig() + else: + text_to_speech_provider_config = VertexAITextToSpeechConfig() # Cast to specific Vertex AI config type to access dispatch method vertex_config: Final = cast(VertexAITextToSpeechConfig, text_to_speech_provider_config) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 1a1d92805e2..2955a0ffa4e 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -45516,9 +45516,12 @@ "litellm_provider": "vertex_ai", "max_audio_length_hours": 0.009111111111111111, "max_audio_per_prompt": 4, - "mode": "chat", + "mode": "audio_speech", "output_cost_per_second": 0.002, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing#lyria", + "supported_endpoints": [ + "/v1/audio/speech" + ], "supported_modalities": [ "text" ], @@ -45533,12 +45536,13 @@ "max_input_tokens": 131072, "max_output_tokens": 8192, "max_tokens": 8192, - "mode": "chat", + "mode": "audio_speech", "output_cost_per_image": 0.04, "output_cost_per_token": 0, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing#lyria", "supported_endpoints": [ - "/v1beta/interactions" + "/v1beta/interactions", + "/v1/audio/speech" ], "supported_modalities": [ "text", @@ -45566,12 +45570,13 @@ "max_input_tokens": 131072, "max_output_tokens": 8192, "max_tokens": 8192, - "mode": "chat", + "mode": "audio_speech", "output_cost_per_image": 0.08, "output_cost_per_token": 0, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing#lyria", "supported_endpoints": [ - "/v1beta/interactions" + "/v1beta/interactions", + "/v1/audio/speech" ], "supported_modalities": [ "text", 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 6b2d7763fa0..ab3b24f470f 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 @@ -368,14 +368,8 @@ class VertexPassthroughLoggingHandler: @staticmethod def _is_audio_predict_response(model: str, json_response: dict) -> bool: return ( - VertexPassthroughLoggingHandler._get_audio_prediction_count( - json_response=json_response - ) - > 0 - and VertexPassthroughLoggingHandler._get_audio_prediction_unit_cost( - model=model - ) - is not None + VertexPassthroughLoggingHandler._get_audio_prediction_count(json_response=json_response) > 0 + and VertexPassthroughLoggingHandler._get_audio_prediction_unit_cost(model=model) is not None ) @staticmethod @@ -397,7 +391,7 @@ class VertexPassthroughLoggingHandler: return sum( 1 for prediction in predictions - if isinstance(prediction, dict) and prediction.get("audioContent") + if isinstance(prediction, dict) and (prediction.get("audioContent") or prediction.get("bytesBase64Encoded")) ) @staticmethod diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 27132c90e05..f11cbc92224 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -11176,9 +11176,14 @@ async def audio_speech( upstream_content_type: Final = ( response.response.headers.get("content-type") if isinstance(response, HttpxBinaryResponseContent) else None ) - media_type: Final = resolve_speech_media_type( - upstream_content_type=upstream_content_type, - response_format=requested_format if isinstance(requested_format, str) else None, + hidden_audio_mime_type: Final = hidden_params.get("audio_mime_type") + media_type: Final = ( + hidden_audio_mime_type + if isinstance(hidden_audio_mime_type, str) + else resolve_speech_media_type( + upstream_content_type=upstream_content_type, + response_format=requested_format if isinstance(requested_format, str) else None, + ) ) return StreamingResponse( diff --git a/litellm/types/utils.py b/litellm/types/utils.py index ee6f09e05dc..d2a09639362 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -310,6 +310,9 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False): output_cost_per_video_per_second: float | None # only for vertex ai models output_cost_per_audio_per_second: float | None # only for vertex ai models output_cost_per_second: float | None # for OpenAI Speech models + audio_seconds_per_prediction: float | None + max_audio_length_hours: float | None + max_audio_per_prompt: int | None output_cost_per_second_1080p: ( float | None ) # video_generation tier: key output_cost_per_second_ (e.g. 1080p, 720p) @@ -333,6 +336,7 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False): "image_generation", "chat", "audio_transcription", + "audio_speech", "responses", "ocr", "realtime", diff --git a/litellm/utils.py b/litellm/utils.py index ba456fc353b..7c8974906ed 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -5880,6 +5880,9 @@ def _get_model_info_helper( "output_cost_per_token_above_512k_tokens", None ), output_cost_per_second=_model_info.get("output_cost_per_second", None), + audio_seconds_per_prediction=_model_info.get("audio_seconds_per_prediction", None), + max_audio_length_hours=_model_info.get("max_audio_length_hours", None), + max_audio_per_prompt=_model_info.get("max_audio_per_prompt", None), output_cost_per_second_1080p=_model_info.get("output_cost_per_second_1080p", None), output_cost_per_second_480p=_model_info.get("output_cost_per_second_480p", None), output_cost_per_second_4k=_model_info.get("output_cost_per_second_4k", None), @@ -9415,9 +9418,12 @@ class ProviderConfigManager: # mapping would drop response_format before the bridge sees it (LIT-6501) return None from litellm.llms.vertex_ai.text_to_speech.transformation import ( + VertexAILyriaTextToSpeechConfig, VertexAITextToSpeechConfig, ) + if VertexAILyriaTextToSpeechConfig.is_lyria_model(model): + return VertexAILyriaTextToSpeechConfig() return VertexAITextToSpeechConfig() elif litellm.LlmProviders.MINIMAX == provider: from litellm.llms.minimax.text_to_speech.transformation import ( diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 1a1d92805e2..2955a0ffa4e 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -45516,9 +45516,12 @@ "litellm_provider": "vertex_ai", "max_audio_length_hours": 0.009111111111111111, "max_audio_per_prompt": 4, - "mode": "chat", + "mode": "audio_speech", "output_cost_per_second": 0.002, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing#lyria", + "supported_endpoints": [ + "/v1/audio/speech" + ], "supported_modalities": [ "text" ], @@ -45533,12 +45536,13 @@ "max_input_tokens": 131072, "max_output_tokens": 8192, "max_tokens": 8192, - "mode": "chat", + "mode": "audio_speech", "output_cost_per_image": 0.04, "output_cost_per_token": 0, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing#lyria", "supported_endpoints": [ - "/v1beta/interactions" + "/v1beta/interactions", + "/v1/audio/speech" ], "supported_modalities": [ "text", @@ -45566,12 +45570,13 @@ "max_input_tokens": 131072, "max_output_tokens": 8192, "max_tokens": 8192, - "mode": "chat", + "mode": "audio_speech", "output_cost_per_image": 0.08, "output_cost_per_token": 0, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing#lyria", "supported_endpoints": [ - "/v1beta/interactions" + "/v1beta/interactions", + "/v1/audio/speech" ], "supported_modalities": [ "text", 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 9f65fa09b1b..ccce19f1634 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 @@ -110,3 +110,36 @@ def test_audio_predict_response_uses_model_map_metadata( assert result["kwargs"]["model"] == "music-audio-preview" assert result["kwargs"]["response_cost"] == pytest.approx(6.0) assert logging_obj.model_call_details["response_cost"] == pytest.approx(6.0) + + +def test_audio_predict_response_supports_bytes_base64_encoded( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setitem( + litellm.model_cost, + "vertex_ai/lyria-002", + { + "audio_seconds_per_prediction": 30, + "output_cost_per_second": 0.002, + }, + ) + logging_obj = MagicMock() + logging_obj.model_call_details = {} + response = httpx.Response( + status_code=200, + json={"predictions": [{"bytesBase64Encoded": "clip"}]}, + ) + + result = VertexPassthroughLoggingHandler.vertex_passthrough_handler( + httpx_response=response, + logging_obj=logging_obj, + url_route="/v1/projects/test/locations/us-central1/publishers/google/models/lyria-002:predict", + result=response.text, + start_time=datetime.now(), + end_time=datetime.now(), + cache_hit=False, + request_body={"instances": [{"prompt": "ambient piano"}]}, + ) + + assert result["kwargs"]["response_cost"] == pytest.approx(0.06) + assert logging_obj.model_call_details["response_cost"] == pytest.approx(0.06) diff --git a/tests/test_litellm/llms/vertex_ai/text_to_speech/test_transformation.py b/tests/test_litellm/llms/vertex_ai/text_to_speech/test_transformation.py index fba337b5f2c..910bc977a79 100644 --- a/tests/test_litellm/llms/vertex_ai/text_to_speech/test_transformation.py +++ b/tests/test_litellm/llms/vertex_ai/text_to_speech/test_transformation.py @@ -4,11 +4,13 @@ from unittest.mock import MagicMock, Mock, patch import httpx import pytest - import litellm from litellm.llms.vertex_ai.text_to_speech.transformation import ( + VertexAILyriaTextToSpeechConfig, VertexAITextToSpeechConfig, ) +from litellm.types.utils import LlmProviders +from litellm.utils import ProviderConfigManager class TestVertexAITextToSpeechConfig: @@ -41,9 +43,7 @@ class TestVertexAITextToSpeechConfig: @patch.object(VertexAITextToSpeechConfig, "_ensure_access_token") @patch.object(VertexAITextToSpeechConfig, "_get_token_and_url") - def test_transform_text_to_speech_request_body( - self, mock_get_token, mock_ensure_token - ): + def test_transform_text_to_speech_request_body(self, mock_get_token, mock_ensure_token): """Test that transform_text_to_speech_request generates correct request body""" # Mock authentication mock_ensure_token.return_value = ("mock-token", "test-project") @@ -104,9 +104,7 @@ class TestVertexAITextToSpeechConfig: config = VertexAITextToSpeechConfig() # Test with a Chirp3 HD voice - voice_str, voice_dict = config._map_voice_to_vertex_format( - "en-US-Chirp3-HD-Charon" - ) + voice_str, voice_dict = config._map_voice_to_vertex_format("en-US-Chirp3-HD-Charon") assert voice_str == "en-US-Chirp3-HD-Charon" assert voice_dict is not None @@ -169,6 +167,284 @@ def test_transform_text_to_speech_response_leaves_unknown_bytes_unlabeled(): assert result.response.content == raw_pcm +class TestVertexAILyriaTextToSpeechConfig: + @pytest.mark.parametrize( + "model", + ["lyria-002", "vertex_ai/lyria-3-clip-preview", "lyria-3-pro-preview"], + ) + def test_provider_config_manager_selects_lyria_config(self, model): + config = ProviderConfigManager.get_provider_text_to_speech_config( + model=model, + provider=LlmProviders.VERTEX_AI, + ) + + assert isinstance(config, VertexAILyriaTextToSpeechConfig) + + def test_get_complete_url_for_lyria_2(self): + config = VertexAILyriaTextToSpeechConfig() + + url = config.get_complete_url( + model="lyria-002", + api_base=None, + litellm_params={ + "vertex_project": "music-project", + "vertex_location": "europe-west4", + }, + ) + + assert url == ( + "https://europe-west4-aiplatform.googleapis.com/v1/projects/music-project/" + "locations/europe-west4/publishers/google/models/lyria-002:predict" + ) + + def test_get_complete_url_for_lyria_3(self): + config = VertexAILyriaTextToSpeechConfig() + + url = config.get_complete_url( + model="lyria-3-pro-preview", + api_base=None, + litellm_params={"vertex_project": "music-project"}, + ) + + assert url == ("https://aiplatform.googleapis.com/v1beta1/projects/music-project/locations/global/interactions") + + @pytest.mark.parametrize( + ("model", "response_format", "expected_body"), + [ + ( + "lyria-002", + "wav", + { + "instances": [{"prompt": "A bright synth track"}], + "parameters": {"sample_count": 1}, + }, + ), + ( + "lyria-3-clip-preview", + "mp3", + { + "model": "lyria-3-clip-preview", + "input": "A bright synth track", + }, + ), + ( + "lyria-3-pro-preview", + "wav", + { + "model": "lyria-3-pro-preview", + "input": "A bright synth track", + "response_format": { + "type": "audio", + "mime_type": "audio/wav", + }, + }, + ), + ], + ) + @patch.object(VertexAILyriaTextToSpeechConfig, "_ensure_access_token") + def test_transform_request( + self, + mock_ensure_token, + model, + response_format, + expected_body, + ): + mock_ensure_token.return_value = ("mock-token", "music-project") + config = VertexAILyriaTextToSpeechConfig() + + request = config.transform_text_to_speech_request( + model=model, + input="A bright synth track", + voice="alloy", + optional_params={"response_format": response_format}, + litellm_params={"vertex_project": "music-project"}, + headers={}, + ) + + assert request["dict_body"] == expected_body + assert request["headers"]["Authorization"] == "Bearer mock-token" + assert request["headers"]["x-goog-user-project"] == "music-project" + + @pytest.mark.parametrize( + ("model", "response_json", "expected_audio", "expected_mime_type"), + [ + ( + "lyria-002", + { + "predictions": [ + { + "bytesBase64Encoded": "bHlyaWEtMi1hdWRpbw==", + } + ] + }, + b"lyria-2-audio", + "audio/wav", + ), + ( + "lyria-3-pro-preview", + { + "steps": [ + { + "type": "model_output", + "content": [ + {"type": "text", "text": "Generated lyrics"}, + { + "type": "audio", + "data": "bHlyaWEtMy1hdWRpbw==", + "mime_type": "audio/mpeg", + }, + ], + } + ] + }, + b"lyria-3-audio", + "audio/mpeg", + ), + ( + "lyria-3-clip-preview", + { + "outputs": [ + {"type": "text", "text": "Generated lyrics"}, + { + "type": "audio", + "data": "bHlyaWEtMy1hdWRpbw==", + "mime_type": "audio/mpeg", + }, + ] + }, + b"lyria-3-audio", + "audio/mpeg", + ), + ], + ) + def test_transform_response( + self, + model, + response_json, + expected_audio, + expected_mime_type, + ): + config = VertexAILyriaTextToSpeechConfig() + raw_response = httpx.Response(200, json=response_json) + + response = config.transform_text_to_speech_response( + model=model, + raw_response=raw_response, + logging_obj=MagicMock(), + ) + + assert response.content == expected_audio + assert response._hidden_params["audio_mime_type"] == expected_mime_type + + @pytest.mark.parametrize( + ("model", "response_format"), + [ + ("lyria-002", "mp3"), + ("lyria-3-clip-preview", "wav"), + ("lyria-3-pro-preview", "opus"), + ], + ) + def test_rejects_unsupported_response_format(self, model, response_format): + config = VertexAILyriaTextToSpeechConfig() + + with pytest.raises(litellm.UnsupportedParamsError): + config.map_openai_params( + model=model, + optional_params={"response_format": response_format}, + ) + + @pytest.mark.parametrize("param", ["speed", "instructions"]) + def test_rejects_unsupported_openai_params(self, param): + config = VertexAILyriaTextToSpeechConfig() + + with pytest.raises(litellm.UnsupportedParamsError): + config.map_openai_params( + model="lyria-3-pro-preview", + optional_params={param: "unsupported"}, + ) + + @pytest.mark.parametrize( + ("model", "response_format", "response_json", "expected_url", "expected_body"), + [ + ( + "lyria-002", + "wav", + { + "predictions": [ + { + "audioContent": "bHlyaWEtMi1hdWRpbw==", + "mimeType": "audio/wav", + } + ] + }, + "https://us-central1-aiplatform.googleapis.com/v1/projects/music-project/locations/us-central1/publishers/google/models/lyria-002:predict", + { + "instances": [{"prompt": "A bright synth track"}], + "parameters": {"sample_count": 1}, + }, + ), + ( + "lyria-3-pro-preview", + "mp3", + { + "steps": [ + { + "type": "model_output", + "content": [ + { + "type": "audio", + "data": "bHlyaWEtMy1hdWRpbw==", + "mime_type": "audio/mpeg", + } + ], + } + ] + }, + "https://aiplatform.googleapis.com/v1beta1/projects/music-project/locations/global/interactions", + { + "model": "lyria-3-pro-preview", + "input": "A bright synth track", + }, + ), + ], + ) + def test_litellm_speech_dispatches_to_lyria_api( + self, + model, + response_format, + response_json, + expected_url, + expected_body, + ): + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = response_json + with ( + patch.object( + VertexAILyriaTextToSpeechConfig, + "_ensure_access_token", + return_value=("mock-token", "music-project"), + ), + patch( + "litellm.llms.custom_httpx.llm_http_handler.HTTPHandler.post", + return_value=mock_response, + ) as mock_post, + ): + response = litellm.speech( + model=f"vertex_ai/{model}", + input="A bright synth track", + voice="alloy", + response_format=response_format, + vertex_project="music-project", + vertex_location="us-central1", + ) + + assert response.content in {b"lyria-2-audio", b"lyria-3-audio"} + mock_post.assert_called_once() + assert mock_post.call_args.kwargs["url"] == expected_url + assert mock_post.call_args.kwargs["json"] == expected_body + + @patch("litellm.llms.custom_httpx.llm_http_handler.HTTPHandler.post") @patch.object(VertexAITextToSpeechConfig, "_ensure_access_token") @patch.object(VertexAITextToSpeechConfig, "_get_token_and_url") @@ -182,9 +458,7 @@ def test_litellm_speech_vertex_ai_chirp(mock_get_token, mock_ensure_token, mock_ # Mock HTTP response mock_response = Mock(spec=httpx.Response) - mock_response.content = ( - b'{"audioContent": "SGVsbG8gV29ybGQ="}' # base64 encoded "Hello World" - ) + mock_response.content = b'{"audioContent": "SGVsbG8gV29ybGQ="}' # base64 encoded "Hello World" mock_response.status_code = 200 mock_response.headers = {"content-type": "application/json"} mock_response.json.return_value = {"audioContent": "SGVsbG8gV29ybGQ="} @@ -203,9 +477,7 @@ def test_litellm_speech_vertex_ai_chirp(mock_get_token, mock_ensure_token, mock_ call_kwargs = mock_post.call_args.kwargs # Verify the URL is the Google Cloud TTS API - assert ( - call_kwargs["url"] == "https://texttospeech.googleapis.com/v1/text:synthesize" - ) + assert call_kwargs["url"] == "https://texttospeech.googleapis.com/v1/text:synthesize" # Verify request body structure assert "json" in call_kwargs diff --git a/tests/test_litellm/test_cost_calculator.py b/tests/test_litellm/test_cost_calculator.py index 7c2174018e8..ffc71c76d03 100644 --- a/tests/test_litellm/test_cost_calculator.py +++ b/tests/test_litellm/test_cost_calculator.py @@ -146,6 +146,24 @@ def test_cost_calculator_with_response_cost_in_additional_headers(): assert result == 1000 +@pytest.mark.parametrize( + ("model", "expected_cost"), + [ + ("vertex_ai/lyria-002", 0.06), + ("vertex_ai/lyria-3-clip-preview", 0.04), + ("vertex_ai/lyria-3-pro-preview", 0.08), + ], +) +def test_vertex_lyria_speech_cost(model, expected_cost, _local_model_cost_map): + cost = completion_cost( + model=model, + prompt="A bright synth track", + call_type="speech", + ) + + assert cost == pytest.approx(expected_cost) + + def test_baseten_model_api_pricing_entries(_local_model_cost_map): expected_pricing = { diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index 7bec649099a..1b0b27032ce 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -2855,15 +2855,25 @@ def test_vertex_ai_lyria_models_in_cost_map(): assert lyria_2["litellm_provider"] == "vertex_ai" assert clip["litellm_provider"] == "vertex_ai" assert pro["litellm_provider"] == "vertex_ai" + assert lyria_2["mode"] == "audio_speech" + assert clip["mode"] == "audio_speech" + assert pro["mode"] == "audio_speech" assert lyria_2["audio_seconds_per_prediction"] == 30 assert lyria_2["output_cost_per_second"] == 0.002 assert lyria_2["supported_modalities"] == ["text"] assert lyria_2["supported_output_modalities"] == ["audio"] assert lyria_2["supports_audio_output"] is True + assert lyria_2["supported_endpoints"] == ["/v1/audio/speech"] assert clip["output_cost_per_image"] == 0.04 assert pro["output_cost_per_image"] == 0.08 - assert clip["supported_endpoints"] == ["/v1beta/interactions"] - assert pro["supported_endpoints"] == ["/v1beta/interactions"] + assert clip["supported_endpoints"] == [ + "/v1beta/interactions", + "/v1/audio/speech", + ] + assert pro["supported_endpoints"] == [ + "/v1beta/interactions", + "/v1/audio/speech", + ] assert clip["supported_modalities"] == ["text", "image"] assert pro["supported_modalities"] == ["text", "image"] assert clip["supported_regions"] == ["global"] @@ -2873,7 +2883,6 @@ def test_vertex_ai_lyria_models_in_cost_map(): assert clip["supports_image_input"] is True assert pro["supports_image_input"] is True - def test_model_info_for_fireworks_short_form_models(): """ Test that fireworks_ai short-form model entries (fireworks_ai/) From f18cb0cdb48ce0338a30af940d557842eee4fb19 Mon Sep 17 00:00:00 2001 From: Emerson Gomes Date: Wed, 15 Jul 2026 20:28:00 -0500 Subject: [PATCH 05/17] fix(vertex): make Lyria routing and billing data-driven --- litellm/llms/vertex_ai/common_utils.py | 35 +++++++++++ .../text_to_speech/transformation.py | 36 ++++++----- ...odel_prices_and_context_window_backup.json | 19 +++++- litellm/types/utils.py | 2 + litellm/utils.py | 2 + model_prices_and_context_window.json | 19 +++++- .../text_to_speech/test_transformation.py | 62 +++++++++++++++++++ tests/test_litellm/test_utils.py | 17 +++++ 8 files changed, 172 insertions(+), 20 deletions(-) diff --git a/litellm/llms/vertex_ai/common_utils.py b/litellm/llms/vertex_ai/common_utils.py index a36c920dda0..8a4c1e68623 100644 --- a/litellm/llms/vertex_ai/common_utils.py +++ b/litellm/llms/vertex_ai/common_utils.py @@ -1,9 +1,12 @@ import re from copy import deepcopy from enum import Enum +from functools import lru_cache from typing import Any, Final, Literal, cast, get_type_hints import httpx +from pydantic import TypeAdapter, ValidationError +from typing_extensions import NotRequired, TypedDict import litellm from litellm._logging import verbose_logger @@ -21,6 +24,38 @@ from litellm.types.utils import TokenCountResponse from litellm.utils import supports_response_schema, supports_system_messages +class VertexAILyriaModelInfo(TypedDict): + vertex_ai_audio_api: Literal["lyria_predict", "lyria_interactions"] + supported_audio_formats: tuple[Literal["mp3", "wav"], ...] + output_cost_per_image: NotRequired[float] + + +_VERTEX_AI_LYRIA_MODEL_INFO_ADAPTER = TypeAdapter(VertexAILyriaModelInfo) + + +def _validate_vertex_ai_lyria_model_info(raw_model_info: object) -> VertexAILyriaModelInfo | None: + if raw_model_info is None: + return None + try: + return _VERTEX_AI_LYRIA_MODEL_INFO_ADAPTER.validate_python(raw_model_info) + except ValidationError: + return None + + +@lru_cache(maxsize=32) +def _get_bundled_vertex_ai_lyria_model_info(model_key: str) -> VertexAILyriaModelInfo | None: + from litellm.litellm_core_utils.get_model_cost_map import GetModelCostMap + + bundled_model_info = GetModelCostMap.load_local_model_cost_map().get(model_key) + return _validate_vertex_ai_lyria_model_info(bundled_model_info) + + +def get_vertex_ai_lyria_model_info(model: str) -> VertexAILyriaModelInfo | None: + model_key = model if model.startswith("vertex_ai/") else f"vertex_ai/{model}" + runtime_model_info = _validate_vertex_ai_lyria_model_info(litellm.model_cost.get(model_key)) + return runtime_model_info or _get_bundled_vertex_ai_lyria_model_info(model_key) + + class VertexAIError(BaseLLMException): def __init__( self, diff --git a/litellm/llms/vertex_ai/text_to_speech/transformation.py b/litellm/llms/vertex_ai/text_to_speech/transformation.py index ad39aa35f8c..d56f151a8c2 100644 --- a/litellm/llms/vertex_ai/text_to_speech/transformation.py +++ b/litellm/llms/vertex_ai/text_to_speech/transformation.py @@ -21,6 +21,10 @@ from litellm.llms.base_llm.text_to_speech.transformation import ( BaseTextToSpeechConfig, TextToSpeechRequestData, ) +from litellm.llms.vertex_ai.common_utils import ( + VertexAILyriaModelInfo, + get_vertex_ai_lyria_model_info, +) from litellm.llms.vertex_ai.vertex_llm_base import VertexBase from litellm.types.llms.vertex_ai import VERTEX_CREDENTIALS_TYPES from litellm.types.llms.vertex_ai_text_to_speech import ( @@ -476,15 +480,16 @@ class VertexAITextToSpeechConfig(BaseTextToSpeechConfig, VertexBase): class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): - LYRIA_MODELS = { - "lyria-002", - "lyria-3-clip-preview", - "lyria-3-pro-preview", - } - @classmethod def is_lyria_model(cls, model: str) -> bool: - return model.removeprefix("vertex_ai/") in cls.LYRIA_MODELS + return get_vertex_ai_lyria_model_info(model=model) is not None + + @staticmethod + def _get_model_info(model: str) -> VertexAILyriaModelInfo: + model_info = get_vertex_ai_lyria_model_info(model=model) + if model_info is None: + raise ValueError(f"Vertex AI model {model!r} does not declare a Lyria audio API") + return model_info def get_supported_openai_params(self, model: str) -> list: return ["response_format"] @@ -499,6 +504,7 @@ class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): ) -> tuple[str | None, dict]: mapped_params = dict(optional_params) base_model = model.removeprefix("vertex_ai/") + model_info = self._get_model_info(model=model) unsupported_params = [param for param in ("speed", "instructions") if mapped_params.get(param) is not None] if unsupported_params: if drop_params or litellm.drop_params: @@ -514,9 +520,7 @@ class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): ), ) response_format = mapped_params.get("response_format") - supported_formats = ( - {"wav"} if base_model == "lyria-002" else {"mp3", "wav"} if base_model == "lyria-3-pro-preview" else {"mp3"} - ) + supported_formats = frozenset(model_info["supported_audio_formats"]) if response_format is not None and response_format not in supported_formats: if drop_params or litellm.drop_params: mapped_params.pop("response_format", None) @@ -538,6 +542,7 @@ class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): litellm_params: dict, ) -> str: base_model = model.removeprefix("vertex_ai/") + model_info = self._get_model_info(model=model) project = self.safe_get_vertex_ai_project(litellm_params) if project is None: _, project = self._ensure_access_token( @@ -545,7 +550,7 @@ class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): project_id=None, custom_llm_provider="vertex_ai", ) - if base_model.startswith("lyria-3-"): + if model_info["vertex_ai_audio_api"] == "lyria_interactions": from litellm.llms.vertex_ai.interactions.transformation import ( VertexAIInteractionsConfig, ) @@ -581,7 +586,8 @@ class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): } ) base_model = model.removeprefix("vertex_ai/") - if base_model == "lyria-002": + model_info = self._get_model_info(model=model) + if model_info["vertex_ai_audio_api"] == "lyria_predict": request_body = { "instances": [{"prompt": input}], "parameters": {"sample_count": 1}, @@ -605,9 +611,10 @@ class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): response_json = raw_response.json() base_model = model.removeprefix("vertex_ai/") + model_info = self._get_model_info(model=model) audio_data: str | None = None mime_type: str | None = None - if base_model == "lyria-002": + if model_info["vertex_ai_audio_api"] == "lyria_predict": predictions = response_json.get("predictions") or [] if predictions: audio_data = predictions[0].get("audioContent") or predictions[0].get("bytesBase64Encoded") @@ -621,7 +628,8 @@ class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): mime_type = content.get("mime_type") if audio_data is None: raise ValueError(f"No generated audio found in Vertex AI {base_model} response") - mime_type = mime_type or ("audio/wav" if base_model == "lyria-002" else "audio/mpeg") + default_format = model_info["supported_audio_formats"][0] + mime_type = mime_type or {"mp3": "audio/mpeg", "wav": "audio/wav"}[default_format] response = HttpxBinaryResponseContent( httpx.Response( status_code=raw_response.status_code, diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 2955a0ffa4e..9986d5056d1 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -45519,6 +45519,9 @@ "mode": "audio_speech", "output_cost_per_second": 0.002, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing#lyria", + "supported_audio_formats": [ + "wav" + ], "supported_endpoints": [ "/v1/audio/speech" ], @@ -45528,7 +45531,8 @@ "supported_output_modalities": [ "audio" ], - "supports_audio_output": true + "supports_audio_output": true, + "vertex_ai_audio_api": "lyria_predict" }, "vertex_ai/lyria-3-clip-preview": { "input_cost_per_token": 0, @@ -45540,6 +45544,9 @@ "output_cost_per_image": 0.04, "output_cost_per_token": 0, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing#lyria", + "supported_audio_formats": [ + "mp3" + ], "supported_endpoints": [ "/v1beta/interactions", "/v1/audio/speech" @@ -45562,7 +45569,8 @@ "supports_response_schema": false, "supports_system_messages": false, "supports_vision": true, - "supports_web_search": false + "supports_web_search": false, + "vertex_ai_audio_api": "lyria_interactions" }, "vertex_ai/lyria-3-pro-preview": { "input_cost_per_token": 0, @@ -45574,6 +45582,10 @@ "output_cost_per_image": 0.08, "output_cost_per_token": 0, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing#lyria", + "supported_audio_formats": [ + "mp3", + "wav" + ], "supported_endpoints": [ "/v1beta/interactions", "/v1/audio/speech" @@ -45596,7 +45608,8 @@ "supports_response_schema": false, "supports_system_messages": false, "supports_vision": true, - "supports_web_search": false + "supports_web_search": false, + "vertex_ai_audio_api": "lyria_interactions" }, "vertex_ai/meta/llama-3.1-405b-instruct-maas": { "input_cost_per_token": 5e-06, diff --git a/litellm/types/utils.py b/litellm/types/utils.py index d2a09639362..bb0485be7c1 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -168,6 +168,8 @@ class ProviderSpecificModelInfo(TypedDict, total=False): default_reasoning_effort: ReadOnly[Literal["none", "minimal", "low", "medium", "high", "xhigh"] | None] supports_output_config: bool | None supports_image_size: bool | None + supported_audio_formats: list[Literal["mp3", "wav"]] | None + vertex_ai_audio_api: Literal["lyria_predict", "lyria_interactions"] | None bedrock_output_config_effort_ceiling: Literal["low", "medium", "high", "max", "xhigh"] | None bedrock_converse_supports_strict_tools: bool | None diff --git a/litellm/utils.py b/litellm/utils.py index 7c8974906ed..d6a68b3ce6e 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -5940,6 +5940,8 @@ def _get_model_info_helper( provider_specific_entry=_model_info.get("provider_specific_entry", None), uses_embed_content=_model_info.get("uses_embed_content", None), supports_image_size=_model_info.get("supports_image_size", None), + supported_audio_formats=_model_info.get("supported_audio_formats", None), + vertex_ai_audio_api=_model_info.get("vertex_ai_audio_api", None), ) for cost_key, cost_value in _model_info.items(): if cost_key not in returned_model_info and _ABOVE_THRESHOLD_COST_KEY.search(cost_key) is not None: diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 2955a0ffa4e..9986d5056d1 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -45519,6 +45519,9 @@ "mode": "audio_speech", "output_cost_per_second": 0.002, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing#lyria", + "supported_audio_formats": [ + "wav" + ], "supported_endpoints": [ "/v1/audio/speech" ], @@ -45528,7 +45531,8 @@ "supported_output_modalities": [ "audio" ], - "supports_audio_output": true + "supports_audio_output": true, + "vertex_ai_audio_api": "lyria_predict" }, "vertex_ai/lyria-3-clip-preview": { "input_cost_per_token": 0, @@ -45540,6 +45544,9 @@ "output_cost_per_image": 0.04, "output_cost_per_token": 0, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing#lyria", + "supported_audio_formats": [ + "mp3" + ], "supported_endpoints": [ "/v1beta/interactions", "/v1/audio/speech" @@ -45562,7 +45569,8 @@ "supports_response_schema": false, "supports_system_messages": false, "supports_vision": true, - "supports_web_search": false + "supports_web_search": false, + "vertex_ai_audio_api": "lyria_interactions" }, "vertex_ai/lyria-3-pro-preview": { "input_cost_per_token": 0, @@ -45574,6 +45582,10 @@ "output_cost_per_image": 0.08, "output_cost_per_token": 0, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing#lyria", + "supported_audio_formats": [ + "mp3", + "wav" + ], "supported_endpoints": [ "/v1beta/interactions", "/v1/audio/speech" @@ -45596,7 +45608,8 @@ "supports_response_schema": false, "supports_system_messages": false, "supports_vision": true, - "supports_web_search": false + "supports_web_search": false, + "vertex_ai_audio_api": "lyria_interactions" }, "vertex_ai/meta/llama-3.1-405b-instruct-maas": { "input_cost_per_token": 5e-06, diff --git a/tests/test_litellm/llms/vertex_ai/text_to_speech/test_transformation.py b/tests/test_litellm/llms/vertex_ai/text_to_speech/test_transformation.py index 910bc977a79..a1bb203e67e 100644 --- a/tests/test_litellm/llms/vertex_ai/text_to_speech/test_transformation.py +++ b/tests/test_litellm/llms/vertex_ai/text_to_speech/test_transformation.py @@ -180,6 +180,68 @@ class TestVertexAILyriaTextToSpeechConfig: assert isinstance(config, VertexAILyriaTextToSpeechConfig) + @pytest.mark.parametrize( + ("model", "vertex_ai_audio_api", "supported_audio_formats", "expected_url"), + [ + ( + "future-lyria-predict", + "lyria_predict", + ["wav"], + "https://us-central1-aiplatform.googleapis.com/v1/projects/music-project/locations/" + "us-central1/publishers/google/models/future-lyria-predict:predict", + ), + ( + "future-music-interactions", + "lyria_interactions", + ["mp3", "wav"], + "https://aiplatform.googleapis.com/v1beta1/projects/music-project/locations/global/interactions", + ), + ], + ) + def test_dispatches_from_model_metadata( + self, + monkeypatch, + model, + vertex_ai_audio_api, + supported_audio_formats, + expected_url, + ): + monkeypatch.setitem( + litellm.model_cost, + f"vertex_ai/{model}", + { + "vertex_ai_audio_api": vertex_ai_audio_api, + "supported_audio_formats": supported_audio_formats, + }, + ) + + config = ProviderConfigManager.get_provider_text_to_speech_config( + model=model, + provider=LlmProviders.VERTEX_AI, + ) + + assert isinstance(config, VertexAILyriaTextToSpeechConfig) + assert ( + config.get_complete_url( + model=model, + api_base=None, + litellm_params={ + "vertex_project": "music-project", + "vertex_location": "us-central1", + }, + ) + == expected_url + ) + + def test_vertex_chirp_does_not_select_lyria_config(self): + config = ProviderConfigManager.get_provider_text_to_speech_config( + model="chirp", + provider=LlmProviders.VERTEX_AI, + ) + + assert isinstance(config, VertexAITextToSpeechConfig) + assert not isinstance(config, VertexAILyriaTextToSpeechConfig) + def test_get_complete_url_for_lyria_2(self): config = VertexAILyriaTextToSpeechConfig() diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index 1b0b27032ce..a179a82fcb2 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -1048,6 +1048,17 @@ def test_aaamodel_prices_and_context_window_json_is_valid(): "supports_sampling_params": {"type": "boolean"}, "supports_output_config": {"type": "boolean"}, "supports_speed": {"type": "boolean"}, + "supported_audio_formats": { + "type": "array", + "items": { + "type": "string", + "enum": ["mp3", "wav"], + }, + }, + "vertex_ai_audio_api": { + "type": "string", + "enum": ["lyria_predict", "lyria_interactions"], + }, "bedrock_output_config_effort_ceiling": { "type": "string", "enum": ["low", "medium", "high", "max", "xhigh"], @@ -2863,9 +2874,15 @@ def test_vertex_ai_lyria_models_in_cost_map(): assert lyria_2["supported_modalities"] == ["text"] assert lyria_2["supported_output_modalities"] == ["audio"] assert lyria_2["supports_audio_output"] is True + assert lyria_2["supported_audio_formats"] == ["wav"] + assert lyria_2["vertex_ai_audio_api"] == "lyria_predict" assert lyria_2["supported_endpoints"] == ["/v1/audio/speech"] assert clip["output_cost_per_image"] == 0.04 assert pro["output_cost_per_image"] == 0.08 + assert clip["supported_audio_formats"] == ["mp3"] + assert pro["supported_audio_formats"] == ["mp3", "wav"] + assert clip["vertex_ai_audio_api"] == "lyria_interactions" + assert pro["vertex_ai_audio_api"] == "lyria_interactions" assert clip["supported_endpoints"] == [ "/v1beta/interactions", "/v1/audio/speech", From e9e2fbb4385e412273ce03c6e0038d013316ce8a Mon Sep 17 00:00:00 2001 From: Emerson Gomes Date: Wed, 12 Aug 2026 12:31:44 -0500 Subject: [PATCH 06/17] style(vertex-ai): modernize Lyria tests --- .../llms/vertex_ai/test_vertex_passthrough_logging_handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ccce19f1634..1e8d569d3d1 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 @@ -2,9 +2,9 @@ from datetime import datetime from unittest.mock import MagicMock import httpx -import litellm import pytest +import litellm from litellm.proxy.pass_through_endpoints.llm_provider_handlers.vertex_passthrough_logging_handler import ( VertexPassthroughLoggingHandler, ) From bd5123564c66d1eb68c253ae6d2405c1e383104c Mon Sep 17 00:00:00 2001 From: Emerson Gomes Date: Wed, 12 Aug 2026 12:56:06 -0500 Subject: [PATCH 07/17] fix(vertex-ai): classify Lyria model metadata --- ci_cd/generate_model_prices_schema.py | 21 ++++++++++++ .../text_to_speech/transformation.py | 2 +- .../vertex_passthrough_logging_handler.py | 3 +- model_prices_and_context_window.schema.json | 33 +++++++++++++++++++ 4 files changed, 56 insertions(+), 3 deletions(-) diff --git a/ci_cd/generate_model_prices_schema.py b/ci_cd/generate_model_prices_schema.py index 57cc742d5c4..ee0dad25c81 100644 --- a/ci_cd/generate_model_prices_schema.py +++ b/ci_cd/generate_model_prices_schema.py @@ -58,6 +58,11 @@ OBJECT_KEYS: dict[str, JsonSchema] = { } ARRAY_KEYS: dict[str, JsonSchema] = { + "supported_audio_formats": { + "type": "array", + "description": "Audio container formats the model can return.", + "items": {"type": "string", "enum": ["mp3", "wav"]}, + }, "supported_endpoints": { "type": "array", "description": "OpenAI-style API routes this model can be called through, e.g. /v1/chat/completions.", @@ -116,6 +121,10 @@ ARRAY_KEYS: dict[str, JsonSchema] = { } INTEGER_KEYS: dict[str, JsonSchema] = { + "max_audio_per_prompt": { + **NONNEG_INTEGER, + "description": "Maximum number of audio outputs accepted or generated per prompt.", + }, "max_tokens": { **NONNEG_INTEGER, "description": "Legacy field: max output tokens if the provider specifies it, else max input tokens.", @@ -141,6 +150,14 @@ INTEGER_KEYS: dict[str, JsonSchema] = { } NUMBER_KEYS: dict[str, JsonSchema] = { + "audio_seconds_per_prediction": { + **NONNEG_NUMBER, + "description": "Audio duration, in seconds, produced by one prediction.", + }, + "max_audio_length_hours": { + **NONNEG_NUMBER, + "description": "Maximum generated audio duration, expressed in hours.", + }, "regional_processing_uplift_multiplier_eu": { "type": "number", "minimum": 1, @@ -231,6 +248,10 @@ def string_key_schemas(modes: tuple) -> dict[str, JsonSchema]: }, "comment": STRING, "audio_transcription_config": STRING, + "vertex_ai_audio_api": { + "type": "string", + "enum": ["lyria_predict", "lyria_interactions"], + }, } diff --git a/litellm/llms/vertex_ai/text_to_speech/transformation.py b/litellm/llms/vertex_ai/text_to_speech/transformation.py index d56f151a8c2..373f6c28f9e 100644 --- a/litellm/llms/vertex_ai/text_to_speech/transformation.py +++ b/litellm/llms/vertex_ai/text_to_speech/transformation.py @@ -500,7 +500,7 @@ class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): optional_params: dict, voice: str | dict | None = None, drop_params: bool = False, - kwargs: dict = {}, + kwargs: dict | None = None, ) -> tuple[str | None, dict]: mapped_params = dict(optional_params) base_model = model.removeprefix("vertex_ai/") 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 ab3b24f470f..4d348b51055 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 @@ -343,8 +343,7 @@ class VertexPassthroughLoggingHandler: json_response=json_response ) response_cost: Final = ( - VertexPassthroughLoggingHandler._get_audio_prediction_unit_cost(model=model) - or 0.0 + VertexPassthroughLoggingHandler._get_audio_prediction_unit_cost(model=model) or 0.0 ) * prediction_count logging_obj.model = model diff --git a/model_prices_and_context_window.schema.json b/model_prices_and_context_window.schema.json index 9e370e5406a..58ca91f3977 100644 --- a/model_prices_and_context_window.schema.json +++ b/model_prices_and_context_window.schema.json @@ -53,6 +53,11 @@ "type": "number", "minimum": 0 }, + "audio_seconds_per_prediction": { + "type": "number", + "minimum": 0, + "description": "Audio duration, in seconds, produced by one prediction." + }, "audio_transcription_config": { "type": "string" }, @@ -363,6 +368,16 @@ "type": "string", "description": "LiteLLM provider slug; one of https://docs.litellm.ai/docs/providers." }, + "max_audio_length_hours": { + "type": "number", + "minimum": 0, + "description": "Maximum generated audio duration, expressed in hours." + }, + "max_audio_per_prompt": { + "type": "integer", + "minimum": 0, + "description": "Maximum number of audio outputs accepted or generated per prompt." + }, "max_input_tokens": { "type": "integer", "minimum": 0, @@ -603,6 +618,17 @@ "type": "string", "description": "URL of the provider pricing/model page this entry was taken from." }, + "supported_audio_formats": { + "type": "array", + "description": "Audio container formats the model can return.", + "items": { + "type": "string", + "enum": [ + "mp3", + "wav" + ] + } + }, "supported_endpoints": { "type": "array", "description": "OpenAI-style API routes this model can be called through, e.g. /v1/chat/completions.", @@ -826,6 +852,13 @@ "uses_embed_content": { "type": "boolean" }, + "vertex_ai_audio_api": { + "type": "string", + "enum": [ + "lyria_predict", + "lyria_interactions" + ] + }, "web_search_billing_unit": { "type": "string", "description": "Whether web search is billed per query or per prompt.", From 6ec53f284617cc52adfb78f5c8e0b03b5a3c125e Mon Sep 17 00:00:00 2001 From: Emerson Gomes Date: Wed, 12 Aug 2026 13:33:33 -0500 Subject: [PATCH 08/17] style(vertex-ai): satisfy Lyria quality gates --- litellm/cost_calculator.py | 10 +- litellm/llms/vertex_ai/common_utils.py | 16 +- .../llms/vertex_ai/interactions/__init__.py | 2 +- .../text_to_speech/transformation.py | 147 +++++++++++------- litellm/main.py | 8 +- .../vertex_passthrough_logging_handler.py | 49 ++++-- litellm/types/utils.py | 10 +- 7 files changed, 153 insertions(+), 89 deletions(-) diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index 2bac5e234bc..5dcbb1d5f37 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -498,16 +498,14 @@ def cost_per_token( speech_model_info = litellm.get_model_info(model=model_without_prefix, custom_llm_provider=custom_llm_provider) prompt_cost: float = 0.0 completion_cost: float = 0.0 - if not speech_model_info.get("input_cost_per_character") and not speech_model_info.get( - "input_cost_per_token" - ): + if not speech_model_info.get("input_cost_per_character") and not speech_model_info.get("input_cost_per_token"): output_cost_per_generation: Final = speech_model_info.get("output_cost_per_image") - output_cost_per_second: Final = speech_model_info.get("output_cost_per_second") + speech_output_cost_per_second: Final = speech_model_info.get("output_cost_per_second") audio_seconds_per_prediction: Final = speech_model_info.get("audio_seconds_per_prediction") if output_cost_per_generation is not None: return prompt_cost, float(output_cost_per_generation) - if output_cost_per_second is not None and audio_seconds_per_prediction is not None: - return prompt_cost, float(output_cost_per_second) * float(audio_seconds_per_prediction) + if speech_output_cost_per_second is not None and audio_seconds_per_prediction is not None: + return prompt_cost, float(speech_output_cost_per_second) * float(audio_seconds_per_prediction) cost_metric: Final = select_cost_metric_for_model(speech_model_info) if cost_metric == "cost_per_character": if prompt_characters is None: diff --git a/litellm/llms/vertex_ai/common_utils.py b/litellm/llms/vertex_ai/common_utils.py index 8a4c1e68623..8885d19c1c0 100644 --- a/litellm/llms/vertex_ai/common_utils.py +++ b/litellm/llms/vertex_ai/common_utils.py @@ -6,7 +6,7 @@ from typing import Any, Final, Literal, cast, get_type_hints import httpx from pydantic import TypeAdapter, ValidationError -from typing_extensions import NotRequired, TypedDict +from typing_extensions import NotRequired, ReadOnly, TypedDict import litellm from litellm._logging import verbose_logger @@ -25,12 +25,12 @@ from litellm.utils import supports_response_schema, supports_system_messages class VertexAILyriaModelInfo(TypedDict): - vertex_ai_audio_api: Literal["lyria_predict", "lyria_interactions"] - supported_audio_formats: tuple[Literal["mp3", "wav"], ...] - output_cost_per_image: NotRequired[float] + vertex_ai_audio_api: ReadOnly[Literal["lyria_predict", "lyria_interactions"]] + supported_audio_formats: ReadOnly[tuple[Literal["mp3", "wav"], ...]] + output_cost_per_image: NotRequired[ReadOnly[float]] -_VERTEX_AI_LYRIA_MODEL_INFO_ADAPTER = TypeAdapter(VertexAILyriaModelInfo) +_VERTEX_AI_LYRIA_MODEL_INFO_ADAPTER: Final = TypeAdapter(VertexAILyriaModelInfo) def _validate_vertex_ai_lyria_model_info(raw_model_info: object) -> VertexAILyriaModelInfo | None: @@ -46,13 +46,13 @@ def _validate_vertex_ai_lyria_model_info(raw_model_info: object) -> VertexAILyri def _get_bundled_vertex_ai_lyria_model_info(model_key: str) -> VertexAILyriaModelInfo | None: from litellm.litellm_core_utils.get_model_cost_map import GetModelCostMap - bundled_model_info = GetModelCostMap.load_local_model_cost_map().get(model_key) + bundled_model_info: Final = GetModelCostMap.load_local_model_cost_map().get(model_key) return _validate_vertex_ai_lyria_model_info(bundled_model_info) def get_vertex_ai_lyria_model_info(model: str) -> VertexAILyriaModelInfo | None: - model_key = model if model.startswith("vertex_ai/") else f"vertex_ai/{model}" - runtime_model_info = _validate_vertex_ai_lyria_model_info(litellm.model_cost.get(model_key)) + model_key: Final = model if model.startswith("vertex_ai/") else f"vertex_ai/{model}" + runtime_model_info: Final = _validate_vertex_ai_lyria_model_info(litellm.model_cost.get(model_key)) return runtime_model_info or _get_bundled_vertex_ai_lyria_model_info(model_key) diff --git a/litellm/llms/vertex_ai/interactions/__init__.py b/litellm/llms/vertex_ai/interactions/__init__.py index 3e2309d21ef..f6f86f65e87 100644 --- a/litellm/llms/vertex_ai/interactions/__init__.py +++ b/litellm/llms/vertex_ai/interactions/__init__.py @@ -1,3 +1,3 @@ from .transformation import VertexAIInteractionsConfig -__all__ = ["VertexAIInteractionsConfig"] +__all__ = ("VertexAIInteractionsConfig",) diff --git a/litellm/llms/vertex_ai/text_to_speech/transformation.py b/litellm/llms/vertex_ai/text_to_speech/transformation.py index 373f6c28f9e..81f1cbd5157 100644 --- a/litellm/llms/vertex_ai/text_to_speech/transformation.py +++ b/litellm/llms/vertex_ai/text_to_speech/transformation.py @@ -8,7 +8,7 @@ Reference: https://cloud.google.com/text-to-speech/docs/reference/rest/v1/text/s import base64 from collections.abc import Coroutine from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Final, Union +from typing import TYPE_CHECKING, Any, Final, TypeAlias, Union import httpx @@ -40,6 +40,10 @@ else: LiteLLMLoggingObj = Any HttpxBinaryResponseContent = Any +_LyriaVoice: TypeAlias = ( + str | dict | None +) # mutable-ok: inherited interface supports structured provider voice dictionaries + class VertexAITextToSpeechConfig(BaseTextToSpeechConfig, VertexBase): """ @@ -486,26 +490,34 @@ class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): @staticmethod def _get_model_info(model: str) -> VertexAILyriaModelInfo: - model_info = get_vertex_ai_lyria_model_info(model=model) + model_info: Final = get_vertex_ai_lyria_model_info(model=model) if model_info is None: raise ValueError(f"Vertex AI model {model!r} does not declare a Lyria audio API") return model_info - def get_supported_openai_params(self, model: str) -> list: - return ["response_format"] + def get_supported_openai_params( + self, model: str + ) -> list: # mutable-ok: inherited provider interface returns a concrete parameter list + return [ # mutable-ok: inherited provider interface requires a concrete parameter list + "response_format" + ] def map_openai_params( self, model: str, - optional_params: dict, - voice: str | dict | None = None, + optional_params: dict, # mutable-ok: inherited provider interface accepts a concrete parameter dictionary + voice: _LyriaVoice = None, drop_params: bool = False, - kwargs: dict | None = None, - ) -> tuple[str | None, dict]: - mapped_params = dict(optional_params) - base_model = model.removeprefix("vertex_ai/") - model_info = self._get_model_info(model=model) - unsupported_params = [param for param in ("speed", "instructions") if mapped_params.get(param) is not None] + kwargs: dict | None = None, # mutable-ok: inherited provider interface accepts a concrete keyword dictionary + ) -> tuple[str | None, dict]: # mutable-ok: inherited provider interface returns concrete mapped parameters + mapped_params: Final = dict( # mutable-ok: mapping drops unsupported parameters before provider dispatch + optional_params + ) + base_model: Final = model.removeprefix("vertex_ai/") + model_info: Final = self._get_model_info(model=model) + unsupported_params: Final = tuple( + param for param in ("speed", "instructions") if mapped_params.get(param) is not None + ) if unsupported_params: if drop_params or litellm.drop_params: for param in unsupported_params: @@ -519,8 +531,8 @@ class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): "from the call, set `litellm.drop_params = True`" ), ) - response_format = mapped_params.get("response_format") - supported_formats = frozenset(model_info["supported_audio_formats"]) + response_format: Final = mapped_params.get("response_format") + supported_formats: Final = frozenset(model_info["supported_audio_formats"]) if response_format is not None and response_format not in supported_formats: if drop_params or litellm.drop_params: mapped_params.pop("response_format", None) @@ -539,17 +551,20 @@ class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): self, model: str, api_base: str | None, - litellm_params: dict, + litellm_params: dict, # mutable-ok: inherited provider interface accepts concrete LiteLLM parameters ) -> str: - base_model = model.removeprefix("vertex_ai/") - model_info = self._get_model_info(model=model) - project = self.safe_get_vertex_ai_project(litellm_params) - if project is None: - _, project = self._ensure_access_token( + base_model: Final = model.removeprefix("vertex_ai/") + model_info: Final = self._get_model_info(model=model) + configured_project: Final = self.safe_get_vertex_ai_project(litellm_params) + project: Final = ( + self._ensure_access_token( credentials=self.safe_get_vertex_ai_credentials(litellm_params), project_id=None, custom_llm_provider="vertex_ai", - ) + )[1] + if configured_project is None + else configured_project + ) if model_info["vertex_ai_audio_api"] == "lyria_interactions": from litellm.llms.vertex_ai.interactions.transformation import ( VertexAIInteractionsConfig, @@ -558,10 +573,13 @@ class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): return VertexAIInteractionsConfig().get_complete_url( api_base=api_base, model=base_model, - litellm_params={**litellm_params, "vertex_project": project}, + litellm_params={ # mutable-ok: interactions dispatch expects a concrete parameter dictionary + **litellm_params, + "vertex_project": project, + }, ) - location = self.safe_get_vertex_ai_location(litellm_params) or self.get_default_vertex_location() - base_url = self.get_api_base(api_base=api_base, vertex_location=location).rstrip("/") + location: Final = self.safe_get_vertex_ai_location(litellm_params) or self.get_default_vertex_location() + base_url: Final = self.get_api_base(api_base=api_base, vertex_location=location).rstrip("/") return f"{base_url}/v1/projects/{project}/locations/{location}/publishers/google/models/{base_model}:predict" def transform_text_to_speech_request( @@ -569,9 +587,9 @@ class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): model: str, input: str, voice: str | None, - optional_params: dict, - litellm_params: dict, - headers: dict, + optional_params: dict, # mutable-ok: inherited provider interface accepts concrete mapped parameters + litellm_params: dict, # mutable-ok: inherited provider interface accepts concrete LiteLLM parameters + headers: dict, # mutable-ok: inherited provider interface accepts and updates concrete HTTP headers ) -> TextToSpeechRequestData: access_token, project = self._ensure_access_token( credentials=self.safe_get_vertex_ai_credentials(litellm_params), @@ -579,23 +597,32 @@ class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): custom_llm_provider="vertex_ai", ) headers.update( - { + { # mutable-ok: HTTP dispatch requires a concrete header dictionary "Authorization": f"Bearer {access_token}", "x-goog-user-project": project, "Content-Type": "application/json", } ) - base_model = model.removeprefix("vertex_ai/") - model_info = self._get_model_info(model=model) + base_model: Final = model.removeprefix("vertex_ai/") + model_info: Final = self._get_model_info(model=model) if model_info["vertex_ai_audio_api"] == "lyria_predict": - request_body = { - "instances": [{"prompt": input}], - "parameters": {"sample_count": 1}, + request_body = { # mutable-ok: predict dispatch requires a concrete provider request dictionary; rebind-ok: exactly one provider API shape initializes the request + "instances": [ # mutable-ok: predict dispatch requires a concrete instances list + {"prompt": input} # mutable-ok: predict dispatch requires a concrete instance dictionary + ], + "parameters": { # mutable-ok: predict dispatch requires a concrete parameters dictionary + "sample_count": 1 + }, } else: - request_body = {"model": base_model, "input": input} + request_body = { # mutable-ok: interactions dispatch requires a concrete provider request dictionary; rebind-ok: exactly one provider API shape initializes the request + "model": base_model, + "input": input, + } if optional_params.get("response_format") == "wav": - request_body["response_format"] = { + request_body[ + "response_format" + ] = { # mutable-ok: interactions dispatch requires a nested response-format dictionary "type": "audio", "mime_type": "audio/wav", } @@ -609,33 +636,49 @@ class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): ) -> "HttpxBinaryResponseContent": from litellm.types.llms.openai import HttpxBinaryResponseContent - response_json = raw_response.json() - base_model = model.removeprefix("vertex_ai/") - model_info = self._get_model_info(model=model) - audio_data: str | None = None - mime_type: str | None = None + response_json: Final = raw_response.json() + base_model: Final = model.removeprefix("vertex_ai/") + model_info: Final = self._get_model_info(model=model) + audio_data: str | None = None # rebind-ok: response parsing discovers audio data in provider-specific shapes + mime_type: str | None = None # rebind-ok: response parsing discovers the MIME type beside the audio payload if model_info["vertex_ai_audio_api"] == "lyria_predict": - predictions = response_json.get("predictions") or [] + predictions: Final = response_json.get("predictions") or () if predictions: - audio_data = predictions[0].get("audioContent") or predictions[0].get("bytesBase64Encoded") - mime_type = predictions[0].get("mimeType") + audio_data = predictions[0].get("audioContent") or predictions[0].get( + "bytesBase64Encoded" + ) # rebind-ok: predict response supplies the generated audio value + mime_type = predictions[0].get("mimeType") # rebind-ok: predict response supplies its audio MIME type else: - for step in response_json.get("steps") or response_json.get("outputs") or []: - content_items = step.get("content") or [] if step.get("type") == "model_output" else [step] + for step in response_json.get("steps") or response_json.get("outputs") or (): + content_items = step.get("content") or () if step.get("type") == "model_output" else (step,) for content in content_items: if content.get("type") == "audio" and content.get("data"): - audio_data = content["data"] - mime_type = content.get("mime_type") + audio_data = content[ + "data" + ] # rebind-ok: interactions response supplies the generated audio value + mime_type = content.get( + "mime_type" + ) # rebind-ok: interactions response supplies its audio MIME type if audio_data is None: raise ValueError(f"No generated audio found in Vertex AI {base_model} response") - default_format = model_info["supported_audio_formats"][0] - mime_type = mime_type or {"mp3": "audio/mpeg", "wav": "audio/wav"}[default_format] - response = HttpxBinaryResponseContent( + default_format: Final = model_info["supported_audio_formats"][0] + mime_type = ( + mime_type + or { # mutable-ok: short-lived lookup selects the default response MIME type; rebind-ok: absent provider MIME type falls back to model metadata + "mp3": "audio/mpeg", + "wav": "audio/wav", + }[default_format] + ) + response: Final = HttpxBinaryResponseContent( httpx.Response( status_code=raw_response.status_code, content=base64.b64decode(audio_data), - headers={"content-type": mime_type}, + headers={ # mutable-ok: httpx requires a concrete response header dictionary + "content-type": mime_type + }, ) ) - response._hidden_params = {"audio_mime_type": mime_type} + response._hidden_params = { # mutable-ok: response metadata is a concrete dictionary + "audio_mime_type": mime_type + } return response diff --git a/litellm/main.py b/litellm/main.py index 55db92d44b3..040af897256 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -8261,9 +8261,13 @@ def speech( # Vertex AI Text-to-Speech (Google Cloud TTS) if text_to_speech_provider_config is None: if VertexAILyriaTextToSpeechConfig.is_lyria_model(model): - text_to_speech_provider_config = VertexAILyriaTextToSpeechConfig() + text_to_speech_provider_config = ( + VertexAILyriaTextToSpeechConfig() + ) # rebind-ok: model metadata selects the Lyria provider implementation else: - text_to_speech_provider_config = VertexAITextToSpeechConfig() + text_to_speech_provider_config = ( + VertexAITextToSpeechConfig() + ) # rebind-ok: non-Lyria Vertex models use the standard TTS implementation # Cast to specific Vertex AI config type to access dispatch method vertex_config: Final = cast(VertexAITextToSpeechConfig, text_to_speech_provider_config) 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 4d348b51055..53df964e541 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 @@ -334,10 +334,10 @@ class VertexPassthroughLoggingHandler: @staticmethod def _handle_audio_predict_response( - json_response: dict, + json_response: dict, # mutable-ok: passthrough logging receives the decoded provider response dictionary logging_obj: LiteLLMLoggingObj, model: str, - kwargs: dict, + kwargs: dict, # mutable-ok: passthrough logging enriches the shared callback metadata dictionary ) -> PassThroughEndpointLoggingTypedDict: prediction_count: Final = VertexPassthroughLoggingHandler._get_audio_prediction_count( json_response=json_response @@ -346,26 +346,41 @@ class VertexPassthroughLoggingHandler: VertexPassthroughLoggingHandler._get_audio_prediction_unit_cost(model=model) or 0.0 ) * prediction_count - logging_obj.model = model - logging_obj.model_call_details["model"] = model - logging_obj.model_call_details["custom_llm_provider"] = "vertex_ai" - logging_obj.custom_llm_provider = "vertex_ai" - logging_obj.model_call_details["response_cost"] = response_cost + logging_obj.model = model # rebind-ok: passthrough attribution records the resolved Vertex model + logging_obj.model_call_details[ # rebind-ok: passthrough attribution enriches callback metadata + "model" + ] = model + logging_obj.model_call_details[ # rebind-ok: passthrough attribution enriches callback metadata + "custom_llm_provider" + ] = "vertex_ai" + logging_obj.custom_llm_provider = ( # rebind-ok: attribution records the resolved provider + "vertex_ai" + ) + logging_obj.model_call_details[ # rebind-ok: passthrough attribution enriches callback metadata + "response_cost" + ] = response_cost - kwargs["response_cost"] = response_cost - kwargs["model"] = model - kwargs["custom_llm_provider"] = "vertex_ai" + kwargs[ # rebind-ok: callback metadata is enriched for downstream hooks + "response_cost" + ] = response_cost + kwargs["model"] = model # rebind-ok: callback metadata records the resolved model + kwargs["custom_llm_provider"] = "vertex_ai" # rebind-ok: callback metadata records the resolved provider - standard_pass_through_response_object: Final[StandardPassThroughResponseObject] = { + standard_pass_through_response_object: Final[ + StandardPassThroughResponseObject + ] = { # mutable-ok: callback contract requires a concrete response dictionary "response": json_response, } - return { + return { # mutable-ok: passthrough logging contract requires a concrete result dictionary "result": standard_pass_through_response_object, "kwargs": kwargs, } @staticmethod - def _is_audio_predict_response(model: str, json_response: dict) -> bool: + def _is_audio_predict_response( + model: str, + json_response: dict, # mutable-ok: predicate inspects the decoded provider response dictionary without mutation + ) -> bool: return ( VertexPassthroughLoggingHandler._get_audio_prediction_count(json_response=json_response) > 0 and VertexPassthroughLoggingHandler._get_audio_prediction_unit_cost(model=model) is not None @@ -373,7 +388,9 @@ class VertexPassthroughLoggingHandler: @staticmethod def _get_audio_prediction_unit_cost(model: str) -> float | None: - model_info: Final = litellm.model_cost.get(f"vertex_ai/{model}", {}) + model_info: Final = litellm.model_cost.get(f"vertex_ai/{model}") + if model_info is None: + return None output_cost_per_second: Final = model_info.get("output_cost_per_second") audio_seconds_per_prediction: Final = model_info.get("audio_seconds_per_prediction") if not isinstance(output_cost_per_second, (int, float)) or not isinstance( @@ -383,7 +400,9 @@ class VertexPassthroughLoggingHandler: return float(output_cost_per_second * audio_seconds_per_prediction) @staticmethod - def _get_audio_prediction_count(json_response: dict) -> int: + def _get_audio_prediction_count( + json_response: dict, # mutable-ok: counter inspects the decoded provider response dictionary without mutation + ) -> int: predictions: Final = json_response.get("predictions") if not isinstance(predictions, list): return 0 diff --git a/litellm/types/utils.py b/litellm/types/utils.py index bb0485be7c1..6c645e70bee 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -168,8 +168,8 @@ class ProviderSpecificModelInfo(TypedDict, total=False): default_reasoning_effort: ReadOnly[Literal["none", "minimal", "low", "medium", "high", "xhigh"] | None] supports_output_config: bool | None supports_image_size: bool | None - supported_audio_formats: list[Literal["mp3", "wav"]] | None - vertex_ai_audio_api: Literal["lyria_predict", "lyria_interactions"] | None + supported_audio_formats: ReadOnly[Sequence[Literal["mp3", "wav"]] | None] + vertex_ai_audio_api: ReadOnly[Literal["lyria_predict", "lyria_interactions"] | None] bedrock_output_config_effort_ceiling: Literal["low", "medium", "high", "max", "xhigh"] | None bedrock_converse_supports_strict_tools: bool | None @@ -312,9 +312,9 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False): output_cost_per_video_per_second: float | None # only for vertex ai models output_cost_per_audio_per_second: float | None # only for vertex ai models output_cost_per_second: float | None # for OpenAI Speech models - audio_seconds_per_prediction: float | None - max_audio_length_hours: float | None - max_audio_per_prompt: int | None + audio_seconds_per_prediction: ReadOnly[float | None] + max_audio_length_hours: ReadOnly[float | None] + max_audio_per_prompt: ReadOnly[int | None] output_cost_per_second_1080p: ( float | None ) # video_generation tier: key output_cost_per_second_ (e.g. 1080p, 720p) From abb655daa8851f15ce534a378761c268c811d942 Mon Sep 17 00:00:00 2001 From: Emerson Gomes Date: Sun, 30 Aug 2026 09:58:28 -0500 Subject: [PATCH 09/17] fix(vertex-ai): encode Lyria predict URL path segments Percent-encode project, location, and model as single path segments. Lyria 3 speech builds the interactions URL through staging's minter with the already resolved project --- .../text_to_speech/transformation.py | 20 ++++++++++-- .../text_to_speech/test_transformation.py | 32 +++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/litellm/llms/vertex_ai/text_to_speech/transformation.py b/litellm/llms/vertex_ai/text_to_speech/transformation.py index 81f1cbd5157..17c4f0ed0c0 100644 --- a/litellm/llms/vertex_ai/text_to_speech/transformation.py +++ b/litellm/llms/vertex_ai/text_to_speech/transformation.py @@ -17,6 +17,7 @@ from litellm.exceptions import UnsupportedParamsError from litellm.litellm_core_utils.audio_utils.utils import ( speech_media_type_from_audio_bytes, ) +from litellm.litellm_core_utils.url_utils import encode_url_path_segment from litellm.llms.base_llm.text_to_speech.transformation import ( BaseTextToSpeechConfig, TextToSpeechRequestData, @@ -570,17 +571,32 @@ class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): VertexAIInteractionsConfig, ) - return VertexAIInteractionsConfig().get_complete_url( + resolved_project: Final = project + + def mint_access_token( + _credentials: VERTEX_CREDENTIALS_TYPES | None, + project_id: str | None, + ) -> tuple[str, str]: + return "", project_id or resolved_project + + return VertexAIInteractionsConfig(mint_access_token=mint_access_token).get_complete_url( api_base=api_base, model=base_model, litellm_params={ # mutable-ok: interactions dispatch expects a concrete parameter dictionary **litellm_params, "vertex_project": project, + "vertex_location": "global", }, ) location: Final = self.safe_get_vertex_ai_location(litellm_params) or self.get_default_vertex_location() base_url: Final = self.get_api_base(api_base=api_base, vertex_location=location).rstrip("/") - return f"{base_url}/v1/projects/{project}/locations/{location}/publishers/google/models/{base_model}:predict" + encoded_project: Final = encode_url_path_segment(project, field_name="project") + encoded_location: Final = encode_url_path_segment(location, field_name="location") + encoded_model: Final = encode_url_path_segment(base_model, field_name="model") + return ( + f"{base_url}/v1/projects/{encoded_project}/locations/{encoded_location}" + f"/publishers/google/models/{encoded_model}:predict" + ) def transform_text_to_speech_request( self, diff --git a/tests/test_litellm/llms/vertex_ai/text_to_speech/test_transformation.py b/tests/test_litellm/llms/vertex_ai/text_to_speech/test_transformation.py index a1bb203e67e..3fc5278d3dd 100644 --- a/tests/test_litellm/llms/vertex_ai/text_to_speech/test_transformation.py +++ b/tests/test_litellm/llms/vertex_ai/text_to_speech/test_transformation.py @@ -1,4 +1,5 @@ import base64 +from typing import Final from unittest.mock import MagicMock, Mock, patch import httpx @@ -259,6 +260,37 @@ class TestVertexAILyriaTextToSpeechConfig: "locations/europe-west4/publishers/google/models/lyria-002:predict" ) + def test_get_complete_url_encodes_injected_predict_path_segments(self, monkeypatch: pytest.MonkeyPatch) -> None: + injected: Final = ( + "victim-project/locations/us-central1/publishers/google/models/other-model:predict?ignored=" + ) + encoded: Final = ( + "victim-project%2Flocations%2Fus-central1%2Fpublishers%2Fgoogle" + "%2Fmodels%2Fother-model%3Apredict%3Fignored%3D" + ) + monkeypatch.setitem( + litellm.model_cost, + f"vertex_ai/{injected}", + { + "vertex_ai_audio_api": "lyria_predict", + "supported_audio_formats": ["wav"], + }, + ) + + url: Final = VertexAILyriaTextToSpeechConfig().get_complete_url( + model=injected, + api_base="https://us-central1-aiplatform.googleapis.com", + litellm_params={ + "vertex_project": injected, + "vertex_location": injected, + }, + ) + + assert url == ( + "https://us-central1-aiplatform.googleapis.com" + f"/v1/projects/{encoded}/locations/{encoded}/publishers/google/models/{encoded}:predict" + ) + def test_get_complete_url_for_lyria_3(self): config = VertexAILyriaTextToSpeechConfig() From e18df8f09e7aef60ddb76979ec7a4ddeae5f5aeb Mon Sep 17 00:00:00 2001 From: Emerson Gomes Date: Sun, 30 Aug 2026 09:58:28 -0500 Subject: [PATCH 10/17] fix(vertex-ai): fall back to bundled Lyria costs Prefer runtime model_cost when both numeric fields are present, then bundled Lyria metadata so stale maps still bill 0.002 * 30 --- litellm/llms/vertex_ai/common_utils.py | 2 + .../vertex_passthrough_logging_handler.py | 20 ++++++++-- ...test_vertex_passthrough_logging_handler.py | 39 +++++++++++++++++++ 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/litellm/llms/vertex_ai/common_utils.py b/litellm/llms/vertex_ai/common_utils.py index 8885d19c1c0..b649d8dac0e 100644 --- a/litellm/llms/vertex_ai/common_utils.py +++ b/litellm/llms/vertex_ai/common_utils.py @@ -28,6 +28,8 @@ class VertexAILyriaModelInfo(TypedDict): vertex_ai_audio_api: ReadOnly[Literal["lyria_predict", "lyria_interactions"]] supported_audio_formats: ReadOnly[tuple[Literal["mp3", "wav"], ...]] output_cost_per_image: NotRequired[ReadOnly[float]] + output_cost_per_second: NotRequired[ReadOnly[float]] + audio_seconds_per_prediction: NotRequired[ReadOnly[float]] _VERTEX_AI_LYRIA_MODEL_INFO_ADAPTER: Final = TypeAdapter(VertexAILyriaModelInfo) 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 53df964e541..f7625d71168 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,5 +1,6 @@ import asyncio import re +from collections.abc import Mapping from datetime import datetime from typing import TYPE_CHECKING, Any, Final, cast from urllib.parse import urlparse @@ -10,7 +11,10 @@ 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.llms.vertex_ai.common_utils import get_vertex_location_from_url +from litellm.llms.vertex_ai.common_utils import ( + get_vertex_ai_lyria_model_info, + get_vertex_location_from_url, +) from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( ModelResponseIterator as VertexModelResponseIterator, ) @@ -388,8 +392,18 @@ class VertexPassthroughLoggingHandler: @staticmethod def _get_audio_prediction_unit_cost(model: str) -> float | None: - model_info: Final = litellm.model_cost.get(f"vertex_ai/{model}") - if model_info is None: + runtime_unit_cost: Final = VertexPassthroughLoggingHandler._audio_prediction_unit_cost_from_model_info( + model_info=litellm.model_cost.get(f"vertex_ai/{model}") + ) + if runtime_unit_cost is not None: + return runtime_unit_cost + return VertexPassthroughLoggingHandler._audio_prediction_unit_cost_from_model_info( + model_info=get_vertex_ai_lyria_model_info(model=model) + ) + + @staticmethod + def _audio_prediction_unit_cost_from_model_info(model_info: object) -> float | None: + if not isinstance(model_info, Mapping): return None output_cost_per_second: Final = model_info.get("output_cost_per_second") audio_seconds_per_prediction: Final = model_info.get("audio_seconds_per_prediction") 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 1e8d569d3d1..f3d28d58c22 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 @@ -1,4 +1,5 @@ from datetime import datetime +from typing import Final from unittest.mock import MagicMock import httpx @@ -143,3 +144,41 @@ def test_audio_predict_response_supports_bytes_base64_encoded( assert result["kwargs"]["response_cost"] == pytest.approx(0.06) assert logging_obj.model_call_details["response_cost"] == pytest.approx(0.06) + + +def test_lyria_predict_cost_falls_back_to_bundled_map_when_runtime_map_omits_model( + monkeypatch: pytest.MonkeyPatch, +) -> None: + stale_runtime_model_cost: Final = { + key: value for key, value in litellm.model_cost.items() if key != "vertex_ai/lyria-002" + } + monkeypatch.setattr(litellm, "model_cost", stale_runtime_model_cost) + logging_obj = MagicMock() + logging_obj.model_call_details = {} + response = httpx.Response( + status_code=200, + json={ + "predictions": [ + { + "audioContent": "clip", + "mimeType": "audio/wav", + } + ] + }, + ) + + result = VertexPassthroughLoggingHandler.vertex_passthrough_handler( + httpx_response=response, + logging_obj=logging_obj, + url_route="/v1/projects/test/locations/us-central1/publishers/google/models/lyria-002:predict", + result=response.text, + start_time=datetime.now(), + end_time=datetime.now(), + cache_hit=False, + request_body={"instances": [{"prompt": "ambient piano"}]}, + ) + + assert "vertex_ai/lyria-002" not in litellm.model_cost + assert result["kwargs"]["model"] == "lyria-002" + assert result["kwargs"]["response_cost"] == pytest.approx(0.06) + assert logging_obj.model_call_details["response_cost"] == pytest.approx(0.06) From c784e604acc7791e882391a512b4836366fef0e1 Mon Sep 17 00:00:00 2001 From: Emerson Gomes Date: Sun, 30 Aug 2026 10:15:50 -0500 Subject: [PATCH 11/17] test(vertex-ai): drop Lyria auth patches from transform tests Subclass the Lyria transformer to stub token minting, and mark the remaining litellm.speech patches so TQ008 stays within budget. --- .../vertex_ai/text_to_speech/test_transformation.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/test_litellm/llms/vertex_ai/text_to_speech/test_transformation.py b/tests/test_litellm/llms/vertex_ai/text_to_speech/test_transformation.py index 3fc5278d3dd..457c3f76dbb 100644 --- a/tests/test_litellm/llms/vertex_ai/text_to_speech/test_transformation.py +++ b/tests/test_litellm/llms/vertex_ai/text_to_speech/test_transformation.py @@ -335,16 +335,17 @@ class TestVertexAILyriaTextToSpeechConfig: ), ], ) - @patch.object(VertexAILyriaTextToSpeechConfig, "_ensure_access_token") def test_transform_request( self, - mock_ensure_token, model, response_format, expected_body, ): - mock_ensure_token.return_value = ("mock-token", "music-project") - config = VertexAILyriaTextToSpeechConfig() + class _LyriaConfig(VertexAILyriaTextToSpeechConfig): + def _ensure_access_token(self, *args: object, **kwargs: object) -> tuple[str, str]: + return "mock-token", "music-project" + + config = _LyriaConfig() request = config.transform_text_to_speech_request( model=model, @@ -514,12 +515,12 @@ class TestVertexAILyriaTextToSpeechConfig: mock_response.status_code = 200 mock_response.json.return_value = response_json with ( - patch.object( + patch.object( # test-quality-ok: litellm.speech has no seam for Vertex token minting VertexAILyriaTextToSpeechConfig, "_ensure_access_token", return_value=("mock-token", "music-project"), ), - patch( + patch( # test-quality-ok: litellm.speech has no seam for the HTTP handler "litellm.llms.custom_httpx.llm_http_handler.HTTPHandler.post", return_value=mock_response, ) as mock_post, From cab3801f23aff8c5ac73f0f38d212ab74c9392b6 Mon Sep 17 00:00:00 2001 From: Emerson Gomes Date: Sun, 30 Aug 2026 12:00:42 -0500 Subject: [PATCH 12/17] fix(vertex-ai): simplify Lyria provider typing --- .../llms/vertex_ai/interactions/__init__.py | 3 -- .../text_to_speech/transformation.py | 30 ++++++++++--------- litellm/types/llms/openai.py | 3 ++ 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/litellm/llms/vertex_ai/interactions/__init__.py b/litellm/llms/vertex_ai/interactions/__init__.py index f6f86f65e87..e69de29bb2d 100644 --- a/litellm/llms/vertex_ai/interactions/__init__.py +++ b/litellm/llms/vertex_ai/interactions/__init__.py @@ -1,3 +0,0 @@ -from .transformation import VertexAIInteractionsConfig - -__all__ = ("VertexAIInteractionsConfig",) diff --git a/litellm/llms/vertex_ai/text_to_speech/transformation.py b/litellm/llms/vertex_ai/text_to_speech/transformation.py index 17c4f0ed0c0..2047be2ea37 100644 --- a/litellm/llms/vertex_ai/text_to_speech/transformation.py +++ b/litellm/llms/vertex_ai/text_to_speech/transformation.py @@ -621,8 +621,8 @@ class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): ) base_model: Final = model.removeprefix("vertex_ai/") model_info: Final = self._get_model_info(model=model) - if model_info["vertex_ai_audio_api"] == "lyria_predict": - request_body = { # mutable-ok: predict dispatch requires a concrete provider request dictionary; rebind-ok: exactly one provider API shape initializes the request + request_body: Final[dict[str, object]] = ( # mutable-ok: HTTP dispatch requires a concrete provider payload + { # mutable-ok: predict dispatch requires a concrete provider request dictionary "instances": [ # mutable-ok: predict dispatch requires a concrete instances list {"prompt": input} # mutable-ok: predict dispatch requires a concrete instance dictionary ], @@ -630,18 +630,22 @@ class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): "sample_count": 1 }, } - else: - request_body = { # mutable-ok: interactions dispatch requires a concrete provider request dictionary; rebind-ok: exactly one provider API shape initializes the request + if model_info["vertex_ai_audio_api"] == "lyria_predict" + else { # mutable-ok: interactions dispatch requires a concrete provider request dictionary "model": base_model, "input": input, + **( + { # mutable-ok: interactions dispatch requires a nested response-format dictionary + "response_format": { # mutable-ok: interactions response format is a concrete provider payload + "type": "audio", + "mime_type": "audio/wav", + } + } + if optional_params.get("response_format") == "wav" + else {} # mutable-ok: no response override is merged for non-WAV output + ), } - if optional_params.get("response_format") == "wav": - request_body[ - "response_format" - ] = { # mutable-ok: interactions dispatch requires a nested response-format dictionary - "type": "audio", - "mime_type": "audio/wav", - } + ) return TextToSpeechRequestData(dict_body=request_body, headers=headers) def transform_text_to_speech_response( @@ -694,7 +698,5 @@ class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): }, ) ) - response._hidden_params = { # mutable-ok: response metadata is a concrete dictionary - "audio_mime_type": mime_type - } + response.set_audio_mime_type(mime_type) return response diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index 32d88da0085..0b13191d977 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -119,6 +119,9 @@ class HttpxBinaryResponseContent(_HttpxBinaryResponseContent): return self._hidden_params["response_cost"] = response_cost + def set_audio_mime_type(self, audio_mime_type: str) -> None: + self._hidden_params["audio_mime_type"] = audio_mime_type + class NotGiven: """ From 82edb9e901c7b87a99a7b5c318b3db333ca6165b Mon Sep 17 00:00:00 2001 From: Emerson Gomes Date: Sun, 6 Sep 2026 00:09:27 -0500 Subject: [PATCH 13/17] fix(vertex): preserve Lyria pricing fallback and audio MIME --- litellm/cost_calculator.py | 14 +++---- litellm/llms/vertex_ai/common_utils.py | 22 ++++++++++- .../text_to_speech/transformation.py | 4 +- .../vertex_passthrough_logging_handler.py | 6 +-- ...test_vertex_passthrough_logging_handler.py | 27 +++++++++++--- .../text_to_speech/test_transformation.py | 17 +++++++++ tests/test_litellm/test_cost_calculator.py | 37 +++++++++++++++++-- 7 files changed, 103 insertions(+), 24 deletions(-) diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index 5dcbb1d5f37..fa8e1a5264c 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -495,17 +495,15 @@ def cost_per_token( # see this https://learn.microsoft.com/en-us/azure/ai-services/openai/concepts/models if call_type == "speech" or call_type == "aspeech": + if custom_llm_provider in ("vertex_ai", "vertex_ai_beta"): + from litellm.llms.vertex_ai.common_utils import get_vertex_ai_lyria_generation_cost + + lyria_generation_cost: Final = get_vertex_ai_lyria_generation_cost(model_without_prefix) + if lyria_generation_cost is not None: + return 0.0, lyria_generation_cost speech_model_info = litellm.get_model_info(model=model_without_prefix, custom_llm_provider=custom_llm_provider) prompt_cost: float = 0.0 completion_cost: float = 0.0 - if not speech_model_info.get("input_cost_per_character") and not speech_model_info.get("input_cost_per_token"): - output_cost_per_generation: Final = speech_model_info.get("output_cost_per_image") - speech_output_cost_per_second: Final = speech_model_info.get("output_cost_per_second") - audio_seconds_per_prediction: Final = speech_model_info.get("audio_seconds_per_prediction") - if output_cost_per_generation is not None: - return prompt_cost, float(output_cost_per_generation) - if speech_output_cost_per_second is not None and audio_seconds_per_prediction is not None: - return prompt_cost, float(speech_output_cost_per_second) * float(audio_seconds_per_prediction) cost_metric: Final = select_cost_metric_for_model(speech_model_info) if cost_metric == "cost_per_character": if prompt_characters is None: diff --git a/litellm/llms/vertex_ai/common_utils.py b/litellm/llms/vertex_ai/common_utils.py index b649d8dac0e..3895ea25434 100644 --- a/litellm/llms/vertex_ai/common_utils.py +++ b/litellm/llms/vertex_ai/common_utils.py @@ -2,6 +2,7 @@ import re from copy import deepcopy from enum import Enum from functools import lru_cache +from types import MappingProxyType from typing import Any, Final, Literal, cast, get_type_hints import httpx @@ -55,7 +56,26 @@ def _get_bundled_vertex_ai_lyria_model_info(model_key: str) -> VertexAILyriaMode def get_vertex_ai_lyria_model_info(model: str) -> VertexAILyriaModelInfo | None: model_key: Final = model if model.startswith("vertex_ai/") else f"vertex_ai/{model}" runtime_model_info: Final = _validate_vertex_ai_lyria_model_info(litellm.model_cost.get(model_key)) - return runtime_model_info or _get_bundled_vertex_ai_lyria_model_info(model_key) + bundled_model_info: Final = _get_bundled_vertex_ai_lyria_model_info(model_key) + if runtime_model_info is None: + return bundled_model_info + if bundled_model_info is None: + return runtime_model_info + return _validate_vertex_ai_lyria_model_info(MappingProxyType({**bundled_model_info, **runtime_model_info})) + + +def get_vertex_ai_lyria_generation_cost(model: str) -> float | None: + model_info: Final = get_vertex_ai_lyria_model_info(model) + if model_info is None: + return None + generation_cost: Final = model_info.get("output_cost_per_image") + if generation_cost is not None: + return generation_cost + cost_per_second: Final = model_info.get("output_cost_per_second") + seconds_per_prediction: Final = model_info.get("audio_seconds_per_prediction") + if cost_per_second is None or seconds_per_prediction is None: + return None + return cost_per_second * seconds_per_prediction class VertexAIError(BaseLLMException): diff --git a/litellm/llms/vertex_ai/text_to_speech/transformation.py b/litellm/llms/vertex_ai/text_to_speech/transformation.py index 2047be2ea37..5a93aca3ac9 100644 --- a/litellm/llms/vertex_ai/text_to_speech/transformation.py +++ b/litellm/llms/vertex_ai/text_to_speech/transformation.py @@ -681,9 +681,11 @@ class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): ) # rebind-ok: interactions response supplies its audio MIME type if audio_data is None: raise ValueError(f"No generated audio found in Vertex AI {base_model} response") + decoded_audio: Final = base64.b64decode(audio_data) default_format: Final = model_info["supported_audio_formats"][0] mime_type = ( mime_type + or speech_media_type_from_audio_bytes(decoded_audio) or { # mutable-ok: short-lived lookup selects the default response MIME type; rebind-ok: absent provider MIME type falls back to model metadata "mp3": "audio/mpeg", "wav": "audio/wav", @@ -692,7 +694,7 @@ class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): response: Final = HttpxBinaryResponseContent( httpx.Response( status_code=raw_response.status_code, - content=base64.b64decode(audio_data), + content=decoded_audio, headers={ # mutable-ok: httpx requires a concrete response header dictionary "content-type": mime_type }, 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 f7625d71168..afdbaf3b06c 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 @@ -12,7 +12,7 @@ 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.llms.vertex_ai.common_utils import ( - get_vertex_ai_lyria_model_info, + get_vertex_ai_lyria_generation_cost, get_vertex_location_from_url, ) from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( @@ -397,9 +397,7 @@ class VertexPassthroughLoggingHandler: ) if runtime_unit_cost is not None: return runtime_unit_cost - return VertexPassthroughLoggingHandler._audio_prediction_unit_cost_from_model_info( - model_info=get_vertex_ai_lyria_model_info(model=model) - ) + return get_vertex_ai_lyria_generation_cost(model=model) @staticmethod def _audio_prediction_unit_cost_from_model_info(model_info: object) -> float | None: 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 f3d28d58c22..179301a9c81 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 @@ -146,13 +146,27 @@ def test_audio_predict_response_supports_bytes_base64_encoded( assert logging_obj.model_call_details["response_cost"] == pytest.approx(0.06) -def test_lyria_predict_cost_falls_back_to_bundled_map_when_runtime_map_omits_model( +@pytest.mark.parametrize( + "missing_fields", + ( + None, + ("output_cost_per_second",), + ("audio_seconds_per_prediction",), + ("output_cost_per_second", "audio_seconds_per_prediction"), + ), +) +def test_lyria_predict_cost_falls_back_to_bundled_map_when_runtime_metadata_is_incomplete( monkeypatch: pytest.MonkeyPatch, + missing_fields: tuple[str, ...] | None, ) -> None: - stale_runtime_model_cost: Final = { - key: value for key, value in litellm.model_cost.items() if key != "vertex_ai/lyria-002" - } - monkeypatch.setattr(litellm, "model_cost", stale_runtime_model_cost) + if missing_fields is None: + monkeypatch.delitem(litellm.model_cost, "vertex_ai/lyria-002") + else: + monkeypatch.setitem( + litellm.model_cost, + "vertex_ai/lyria-002", + {key: value for key, value in litellm.model_cost["vertex_ai/lyria-002"].items() if key not in missing_fields}, + ) logging_obj = MagicMock() logging_obj.model_call_details = {} response = httpx.Response( @@ -178,7 +192,8 @@ def test_lyria_predict_cost_falls_back_to_bundled_map_when_runtime_map_omits_mod request_body={"instances": [{"prompt": "ambient piano"}]}, ) - assert "vertex_ai/lyria-002" not in litellm.model_cost + if missing_fields is None: + assert "vertex_ai/lyria-002" not in litellm.model_cost assert result["kwargs"]["model"] == "lyria-002" assert result["kwargs"]["response_cost"] == pytest.approx(0.06) assert logging_obj.model_call_details["response_cost"] == pytest.approx(0.06) diff --git a/tests/test_litellm/llms/vertex_ai/text_to_speech/test_transformation.py b/tests/test_litellm/llms/vertex_ai/text_to_speech/test_transformation.py index 457c3f76dbb..4f4df69780a 100644 --- a/tests/test_litellm/llms/vertex_ai/text_to_speech/test_transformation.py +++ b/tests/test_litellm/llms/vertex_ai/text_to_speech/test_transformation.py @@ -169,6 +169,23 @@ def test_transform_text_to_speech_response_leaves_unknown_bytes_unlabeled(): class TestVertexAILyriaTextToSpeechConfig: + def test_response_without_mime_type_uses_audio_container(self) -> None: + audio: Final = b"RIFF\x24\x00\x00\x00WAVEfmt \x10\x00\x00\x00" + raw_response: Final = httpx.Response( + 200, + json={"outputs": [{"type": "audio", "data": base64.b64encode(audio).decode()}]}, + ) + + response: Final = VertexAILyriaTextToSpeechConfig().transform_text_to_speech_response( + model="lyria-3-pro-preview", + raw_response=raw_response, + logging_obj=MagicMock(), + ) + + assert response.content == audio + assert response.response.headers["content-type"] == "audio/wav" + assert response._hidden_params["audio_mime_type"] == "audio/wav" + @pytest.mark.parametrize( "model", ["lyria-002", "vertex_ai/lyria-3-clip-preview", "lyria-3-pro-preview"], diff --git a/tests/test_litellm/test_cost_calculator.py b/tests/test_litellm/test_cost_calculator.py index ffc71c76d03..aa630c4916b 100644 --- a/tests/test_litellm/test_cost_calculator.py +++ b/tests/test_litellm/test_cost_calculator.py @@ -1,6 +1,7 @@ import json from pathlib import Path +from typing import Final import pytest @@ -154,14 +155,42 @@ def test_cost_calculator_with_response_cost_in_additional_headers(): ("vertex_ai/lyria-3-pro-preview", 0.08), ], ) -def test_vertex_lyria_speech_cost(model, expected_cost, _local_model_cost_map): - cost = completion_cost( +@pytest.mark.parametrize("runtime_state", ("complete", "missing", "routing_only", "custom_zero", "custom_price")) +@pytest.mark.parametrize("call_type", ("speech", "aspeech")) +def test_vertex_lyria_speech_cost( + model: str, + expected_cost: float, + _local_model_cost_map: None, + monkeypatch: pytest.MonkeyPatch, + runtime_state: str, + call_type: str, +) -> None: + model_info: Final = litellm.model_cost[model] + if runtime_state == "missing": + monkeypatch.delitem(litellm.model_cost, model) + elif runtime_state == "routing_only": + monkeypatch.setitem( + litellm.model_cost, + model, + { + key: value + for key, value in model_info.items() + if key not in ("output_cost_per_image", "output_cost_per_second", "audio_seconds_per_prediction") + }, + ) + elif runtime_state in ("custom_zero", "custom_price"): + cost_key: Final = "output_cost_per_image" if "output_cost_per_image" in model_info else "output_cost_per_second" + multiplier: Final = 0 if runtime_state == "custom_zero" else 2 + monkeypatch.setitem(litellm.model_cost, model, {**model_info, cost_key: model_info[cost_key] * multiplier}) + + cost: Final = completion_cost( model=model, prompt="A bright synth track", - call_type="speech", + call_type=call_type, ) - assert cost == pytest.approx(expected_cost) + expected: Final = 0 if runtime_state == "custom_zero" else expected_cost * (2 if runtime_state == "custom_price" else 1) + assert cost == pytest.approx(expected) def test_baseten_model_api_pricing_entries(_local_model_cost_map): From fd24cce2c3d0893e70d88f1e2d6ab3427a09736c Mon Sep 17 00:00:00 2001 From: Emerson Gomes Date: Sun, 6 Sep 2026 00:15:08 -0500 Subject: [PATCH 14/17] test(vertex): isolate Lyria fallback from the remote catalog --- .../llms/vertex_ai/test_vertex_passthrough_logging_handler.py | 1 + 1 file changed, 1 insertion(+) 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 179301a9c81..2cf6689261e 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 @@ -158,6 +158,7 @@ def test_audio_predict_response_supports_bytes_base64_encoded( def test_lyria_predict_cost_falls_back_to_bundled_map_when_runtime_metadata_is_incomplete( monkeypatch: pytest.MonkeyPatch, missing_fields: tuple[str, ...] | None, + local_model_cost_map: None, ) -> None: if missing_fields is None: monkeypatch.delitem(litellm.model_cost, "vertex_ai/lyria-002") From 6be78fa850d4afc89527488115acb5117a6a34be Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:34:31 -0700 Subject: [PATCH 15/17] fix(vertex_ai): bill Lyria per generation, not per audio second Google prices Lyria per generated clip, so every Vertex Lyria entry in the price map now carries a single output_cost_per_image and both the speech and the passthrough cost paths read that one field. The old output_cost_per_second and audio_seconds_per_prediction pair assumed a 30 second clip, which does not match the 32.768 second WAV Vertex returns, and no other model in the map priced audio that way Drops max_audio_length_hours and max_audio_per_prompt from the price map, its schema, the generator, and ModelInfo, since nothing reads them, and drops the audio_mime_type hidden param for the same reason: the response already carries the resolved content type on its own header Folds the per-model bundled catalog lookups into one cached parse of the local cost map, validated with a TypeAdapter over a ReadOnly TypedDict --- ci_cd/generate_model_prices_schema.py | 12 ----- litellm/cost_calculator.py | 16 +++--- litellm/llms/vertex_ai/common_utils.py | 51 ++++++++++--------- .../text_to_speech/transformation.py | 27 +++------- litellm/main.py | 13 ++--- ...odel_prices_and_context_window_backup.json | 7 +-- .../vertex_passthrough_logging_handler.py | 28 +--------- litellm/proxy/proxy_server.py | 11 ++-- litellm/types/llms/openai.py | 3 -- litellm/types/utils.py | 3 -- litellm/utils.py | 3 -- model_prices_and_context_window.json | 7 +-- model_prices_and_context_window.schema.json | 15 ------ .../vertex_ai/test_vertex_ai_common_utils.py | 41 +++++++++++++++ ...test_vertex_passthrough_logging_handler.py | 41 ++++++++------- .../text_to_speech/test_transformation.py | 36 ++++++------- tests/test_litellm/test_cost_calculator.py | 13 +++-- tests/test_litellm/test_utils.py | 6 +-- 18 files changed, 141 insertions(+), 192 deletions(-) diff --git a/ci_cd/generate_model_prices_schema.py b/ci_cd/generate_model_prices_schema.py index ee0dad25c81..ab29b70bdd4 100644 --- a/ci_cd/generate_model_prices_schema.py +++ b/ci_cd/generate_model_prices_schema.py @@ -121,10 +121,6 @@ ARRAY_KEYS: dict[str, JsonSchema] = { } INTEGER_KEYS: dict[str, JsonSchema] = { - "max_audio_per_prompt": { - **NONNEG_INTEGER, - "description": "Maximum number of audio outputs accepted or generated per prompt.", - }, "max_tokens": { **NONNEG_INTEGER, "description": "Legacy field: max output tokens if the provider specifies it, else max input tokens.", @@ -150,14 +146,6 @@ INTEGER_KEYS: dict[str, JsonSchema] = { } NUMBER_KEYS: dict[str, JsonSchema] = { - "audio_seconds_per_prediction": { - **NONNEG_NUMBER, - "description": "Audio duration, in seconds, produced by one prediction.", - }, - "max_audio_length_hours": { - **NONNEG_NUMBER, - "description": "Maximum generated audio duration, expressed in hours.", - }, "regional_processing_uplift_multiplier_eu": { "type": "number", "minimum": 1, diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index fa8e1a5264c..eabf2c6249b 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -80,6 +80,7 @@ from litellm.llms.together_ai.cost_calculator import ( get_model_params_and_category, has_together_registry_pricing, ) +from litellm.llms.vertex_ai.common_utils import get_vertex_ai_lyria_generation_cost from litellm.llms.vertex_ai.cost_calculator import ( cost_per_character as google_cost_per_character, ) @@ -495,16 +496,17 @@ def cost_per_token( # see this https://learn.microsoft.com/en-us/azure/ai-services/openai/concepts/models if call_type == "speech" or call_type == "aspeech": - if custom_llm_provider in ("vertex_ai", "vertex_ai_beta"): - from litellm.llms.vertex_ai.common_utils import get_vertex_ai_lyria_generation_cost - - lyria_generation_cost: Final = get_vertex_ai_lyria_generation_cost(model_without_prefix) - if lyria_generation_cost is not None: - return 0.0, lyria_generation_cost + lyria_generation_cost: Final = ( + get_vertex_ai_lyria_generation_cost(model=model_without_prefix) + if custom_llm_provider in ("vertex_ai", "vertex_ai_beta") + else None + ) + if lyria_generation_cost is not None: + return 0.0, lyria_generation_cost speech_model_info = litellm.get_model_info(model=model_without_prefix, custom_llm_provider=custom_llm_provider) + cost_metric: Final = select_cost_metric_for_model(speech_model_info) prompt_cost: float = 0.0 completion_cost: float = 0.0 - cost_metric: Final = select_cost_metric_for_model(speech_model_info) if cost_metric == "cost_per_character": if prompt_characters is None: raise ValueError( diff --git a/litellm/llms/vertex_ai/common_utils.py b/litellm/llms/vertex_ai/common_utils.py index 3895ea25434..38cdf334cc3 100644 --- a/litellm/llms/vertex_ai/common_utils.py +++ b/litellm/llms/vertex_ai/common_utils.py @@ -1,4 +1,5 @@ import re +from collections.abc import Mapping from copy import deepcopy from enum import Enum from functools import lru_cache @@ -29,8 +30,6 @@ class VertexAILyriaModelInfo(TypedDict): vertex_ai_audio_api: ReadOnly[Literal["lyria_predict", "lyria_interactions"]] supported_audio_formats: ReadOnly[tuple[Literal["mp3", "wav"], ...]] output_cost_per_image: NotRequired[ReadOnly[float]] - output_cost_per_second: NotRequired[ReadOnly[float]] - audio_seconds_per_prediction: NotRequired[ReadOnly[float]] _VERTEX_AI_LYRIA_MODEL_INFO_ADAPTER: Final = TypeAdapter(VertexAILyriaModelInfo) @@ -45,37 +44,41 @@ def _validate_vertex_ai_lyria_model_info(raw_model_info: object) -> VertexAILyri return None -@lru_cache(maxsize=32) -def _get_bundled_vertex_ai_lyria_model_info(model_key: str) -> VertexAILyriaModelInfo | None: +@lru_cache(maxsize=1) +def _bundled_vertex_ai_lyria_model_infos() -> Mapping[str, VertexAILyriaModelInfo]: from litellm.litellm_core_utils.get_model_cost_map import GetModelCostMap - bundled_model_info: Final = GetModelCostMap.load_local_model_cost_map().get(model_key) - return _validate_vertex_ai_lyria_model_info(bundled_model_info) + return MappingProxyType( + { + model_key: lyria_model_info + for model_key, raw_model_info in GetModelCostMap.load_local_model_cost_map().items() + if (lyria_model_info := _validate_vertex_ai_lyria_model_info(raw_model_info)) is not None + } + ) + + +def _vertex_ai_lyria_model_key(model: str) -> str: + return model if model.startswith("vertex_ai/") else f"vertex_ai/{model}" + + +def _vertex_ai_lyria_generation_cost(model_info: VertexAILyriaModelInfo | None) -> float | None: + return None if model_info is None else model_info.get("output_cost_per_image") def get_vertex_ai_lyria_model_info(model: str) -> VertexAILyriaModelInfo | None: - model_key: Final = model if model.startswith("vertex_ai/") else f"vertex_ai/{model}" + model_key: Final = _vertex_ai_lyria_model_key(model) runtime_model_info: Final = _validate_vertex_ai_lyria_model_info(litellm.model_cost.get(model_key)) - bundled_model_info: Final = _get_bundled_vertex_ai_lyria_model_info(model_key) - if runtime_model_info is None: - return bundled_model_info - if bundled_model_info is None: - return runtime_model_info - return _validate_vertex_ai_lyria_model_info(MappingProxyType({**bundled_model_info, **runtime_model_info})) + return runtime_model_info or _bundled_vertex_ai_lyria_model_infos().get(model_key) def get_vertex_ai_lyria_generation_cost(model: str) -> float | None: - model_info: Final = get_vertex_ai_lyria_model_info(model) - if model_info is None: - return None - generation_cost: Final = model_info.get("output_cost_per_image") - if generation_cost is not None: - return generation_cost - cost_per_second: Final = model_info.get("output_cost_per_second") - seconds_per_prediction: Final = model_info.get("audio_seconds_per_prediction") - if cost_per_second is None or seconds_per_prediction is None: - return None - return cost_per_second * seconds_per_prediction + model_key: Final = _vertex_ai_lyria_model_key(model) + runtime_cost: Final = _vertex_ai_lyria_generation_cost( + _validate_vertex_ai_lyria_model_info(litellm.model_cost.get(model_key)) + ) + if runtime_cost is not None: + return runtime_cost + return _vertex_ai_lyria_generation_cost(_bundled_vertex_ai_lyria_model_infos().get(model_key)) class VertexAIError(BaseLLMException): diff --git a/litellm/llms/vertex_ai/text_to_speech/transformation.py b/litellm/llms/vertex_ai/text_to_speech/transformation.py index 5a93aca3ac9..f489366b8d8 100644 --- a/litellm/llms/vertex_ai/text_to_speech/transformation.py +++ b/litellm/llms/vertex_ai/text_to_speech/transformation.py @@ -15,6 +15,7 @@ import httpx import litellm from litellm.exceptions import UnsupportedParamsError from litellm.litellm_core_utils.audio_utils.utils import ( + DEFAULT_SPEECH_MEDIA_TYPE, speech_media_type_from_audio_bytes, ) from litellm.litellm_core_utils.url_utils import encode_url_path_segment @@ -571,13 +572,11 @@ class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): VertexAIInteractionsConfig, ) - resolved_project: Final = project - def mint_access_token( _credentials: VERTEX_CREDENTIALS_TYPES | None, project_id: str | None, ) -> tuple[str, str]: - return "", project_id or resolved_project + return "", project_id or project return VertexAIInteractionsConfig(mint_access_token=mint_access_token).get_complete_url( api_base=api_base, @@ -681,24 +680,12 @@ class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): ) # rebind-ok: interactions response supplies its audio MIME type if audio_data is None: raise ValueError(f"No generated audio found in Vertex AI {base_model} response") - decoded_audio: Final = base64.b64decode(audio_data) - default_format: Final = model_info["supported_audio_formats"][0] - mime_type = ( - mime_type - or speech_media_type_from_audio_bytes(decoded_audio) - or { # mutable-ok: short-lived lookup selects the default response MIME type; rebind-ok: absent provider MIME type falls back to model metadata - "mp3": "audio/mpeg", - "wav": "audio/wav", - }[default_format] - ) - response: Final = HttpxBinaryResponseContent( + binary_data: Final = base64.b64decode(audio_data) + media_type: Final = mime_type or speech_media_type_from_audio_bytes(binary_data) or DEFAULT_SPEECH_MEDIA_TYPE + return HttpxBinaryResponseContent( httpx.Response( status_code=raw_response.status_code, - content=decoded_audio, - headers={ # mutable-ok: httpx requires a concrete response header dictionary - "content-type": mime_type - }, + content=binary_data, + headers=MappingProxyType({"content-type": media_type}), ) ) - response.set_audio_mime_type(mime_type) - return response diff --git a/litellm/main.py b/litellm/main.py index 040af897256..44afce08c0a 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -8260,14 +8260,11 @@ def speech( # Vertex AI Text-to-Speech (Google Cloud TTS) if text_to_speech_provider_config is None: - if VertexAILyriaTextToSpeechConfig.is_lyria_model(model): - text_to_speech_provider_config = ( - VertexAILyriaTextToSpeechConfig() - ) # rebind-ok: model metadata selects the Lyria provider implementation - else: - text_to_speech_provider_config = ( - VertexAITextToSpeechConfig() - ) # rebind-ok: non-Lyria Vertex models use the standard TTS implementation + text_to_speech_provider_config = ( # rebind-ok: model metadata selects the Vertex TTS implementation + VertexAILyriaTextToSpeechConfig() + if VertexAILyriaTextToSpeechConfig.is_lyria_model(model) + else VertexAITextToSpeechConfig() + ) # Cast to specific Vertex AI config type to access dispatch method vertex_config: Final = cast(VertexAITextToSpeechConfig, text_to_speech_provider_config) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 9986d5056d1..0bc6c59a4b7 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -45512,12 +45512,9 @@ "supports_tool_choice": true }, "vertex_ai/lyria-002": { - "audio_seconds_per_prediction": 30, "litellm_provider": "vertex_ai", - "max_audio_length_hours": 0.009111111111111111, - "max_audio_per_prompt": 4, - "mode": "audio_speech", - "output_cost_per_second": 0.002, + "mode": "audio_speech", + "output_cost_per_image": 0.06, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing#lyria", "supported_audio_formats": [ "wav" 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 afdbaf3b06c..119a53c2411 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,6 +1,5 @@ import asyncio import re -from collections.abc import Mapping from datetime import datetime from typing import TYPE_CHECKING, Any, Final, cast from urllib.parse import urlparse @@ -346,9 +345,7 @@ class VertexPassthroughLoggingHandler: prediction_count: Final = VertexPassthroughLoggingHandler._get_audio_prediction_count( json_response=json_response ) - response_cost: Final = ( - VertexPassthroughLoggingHandler._get_audio_prediction_unit_cost(model=model) or 0.0 - ) * prediction_count + response_cost: Final = (get_vertex_ai_lyria_generation_cost(model=model) or 0.0) * prediction_count logging_obj.model = model # rebind-ok: passthrough attribution records the resolved Vertex model logging_obj.model_call_details[ # rebind-ok: passthrough attribution enriches callback metadata @@ -387,30 +384,9 @@ class VertexPassthroughLoggingHandler: ) -> bool: return ( VertexPassthroughLoggingHandler._get_audio_prediction_count(json_response=json_response) > 0 - and VertexPassthroughLoggingHandler._get_audio_prediction_unit_cost(model=model) is not None + and get_vertex_ai_lyria_generation_cost(model=model) is not None ) - @staticmethod - def _get_audio_prediction_unit_cost(model: str) -> float | None: - runtime_unit_cost: Final = VertexPassthroughLoggingHandler._audio_prediction_unit_cost_from_model_info( - model_info=litellm.model_cost.get(f"vertex_ai/{model}") - ) - if runtime_unit_cost is not None: - return runtime_unit_cost - return get_vertex_ai_lyria_generation_cost(model=model) - - @staticmethod - def _audio_prediction_unit_cost_from_model_info(model_info: object) -> float | None: - if not isinstance(model_info, Mapping): - return None - output_cost_per_second: Final = model_info.get("output_cost_per_second") - audio_seconds_per_prediction: Final = model_info.get("audio_seconds_per_prediction") - if not isinstance(output_cost_per_second, (int, float)) or not isinstance( - audio_seconds_per_prediction, (int, float) - ): - return None - return float(output_cost_per_second * audio_seconds_per_prediction) - @staticmethod def _get_audio_prediction_count( json_response: dict, # mutable-ok: counter inspects the decoded provider response dictionary without mutation diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index f11cbc92224..27132c90e05 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -11176,14 +11176,9 @@ async def audio_speech( upstream_content_type: Final = ( response.response.headers.get("content-type") if isinstance(response, HttpxBinaryResponseContent) else None ) - hidden_audio_mime_type: Final = hidden_params.get("audio_mime_type") - media_type: Final = ( - hidden_audio_mime_type - if isinstance(hidden_audio_mime_type, str) - else resolve_speech_media_type( - upstream_content_type=upstream_content_type, - response_format=requested_format if isinstance(requested_format, str) else None, - ) + media_type: Final = resolve_speech_media_type( + upstream_content_type=upstream_content_type, + response_format=requested_format if isinstance(requested_format, str) else None, ) return StreamingResponse( diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index 0b13191d977..32d88da0085 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -119,9 +119,6 @@ class HttpxBinaryResponseContent(_HttpxBinaryResponseContent): return self._hidden_params["response_cost"] = response_cost - def set_audio_mime_type(self, audio_mime_type: str) -> None: - self._hidden_params["audio_mime_type"] = audio_mime_type - class NotGiven: """ diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 6c645e70bee..e29944919ba 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -312,9 +312,6 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False): output_cost_per_video_per_second: float | None # only for vertex ai models output_cost_per_audio_per_second: float | None # only for vertex ai models output_cost_per_second: float | None # for OpenAI Speech models - audio_seconds_per_prediction: ReadOnly[float | None] - max_audio_length_hours: ReadOnly[float | None] - max_audio_per_prompt: ReadOnly[int | None] output_cost_per_second_1080p: ( float | None ) # video_generation tier: key output_cost_per_second_ (e.g. 1080p, 720p) diff --git a/litellm/utils.py b/litellm/utils.py index d6a68b3ce6e..f6b40d33a79 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -5880,9 +5880,6 @@ def _get_model_info_helper( "output_cost_per_token_above_512k_tokens", None ), output_cost_per_second=_model_info.get("output_cost_per_second", None), - audio_seconds_per_prediction=_model_info.get("audio_seconds_per_prediction", None), - max_audio_length_hours=_model_info.get("max_audio_length_hours", None), - max_audio_per_prompt=_model_info.get("max_audio_per_prompt", None), output_cost_per_second_1080p=_model_info.get("output_cost_per_second_1080p", None), output_cost_per_second_480p=_model_info.get("output_cost_per_second_480p", None), output_cost_per_second_4k=_model_info.get("output_cost_per_second_4k", None), diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 9986d5056d1..0bc6c59a4b7 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -45512,12 +45512,9 @@ "supports_tool_choice": true }, "vertex_ai/lyria-002": { - "audio_seconds_per_prediction": 30, "litellm_provider": "vertex_ai", - "max_audio_length_hours": 0.009111111111111111, - "max_audio_per_prompt": 4, - "mode": "audio_speech", - "output_cost_per_second": 0.002, + "mode": "audio_speech", + "output_cost_per_image": 0.06, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing#lyria", "supported_audio_formats": [ "wav" diff --git a/model_prices_and_context_window.schema.json b/model_prices_and_context_window.schema.json index 58ca91f3977..6faa957dc16 100644 --- a/model_prices_and_context_window.schema.json +++ b/model_prices_and_context_window.schema.json @@ -53,11 +53,6 @@ "type": "number", "minimum": 0 }, - "audio_seconds_per_prediction": { - "type": "number", - "minimum": 0, - "description": "Audio duration, in seconds, produced by one prediction." - }, "audio_transcription_config": { "type": "string" }, @@ -368,16 +363,6 @@ "type": "string", "description": "LiteLLM provider slug; one of https://docs.litellm.ai/docs/providers." }, - "max_audio_length_hours": { - "type": "number", - "minimum": 0, - "description": "Maximum generated audio duration, expressed in hours." - }, - "max_audio_per_prompt": { - "type": "integer", - "minimum": 0, - "description": "Maximum number of audio outputs accepted or generated per prompt." - }, "max_input_tokens": { "type": "integer", "minimum": 0, diff --git a/tests/test_litellm/llms/vertex_ai/test_vertex_ai_common_utils.py b/tests/test_litellm/llms/vertex_ai/test_vertex_ai_common_utils.py index d1d751989ea..48376872a72 100644 --- a/tests/test_litellm/llms/vertex_ai/test_vertex_ai_common_utils.py +++ b/tests/test_litellm/llms/vertex_ai/test_vertex_ai_common_utils.py @@ -1696,3 +1696,44 @@ def test_vertex_text_embedding_request_includes_labels_from_metadata(): }, ) assert req.get("labels") == {"project_id": "cost-center-1"} + + +@pytest.mark.parametrize( + ("model", "expected_api"), + [ + ("lyria-002", "lyria_predict"), + ("vertex_ai/lyria-002", "lyria_predict"), + ("lyria-3-clip-preview", "lyria_interactions"), + ("lyria-3-pro-preview", "lyria_interactions"), + ], +) +def test_get_vertex_ai_lyria_model_info_resolves_audio_api(model, expected_api): + from litellm.llms.vertex_ai.common_utils import get_vertex_ai_lyria_model_info + + model_info = get_vertex_ai_lyria_model_info(model=model) + + assert model_info is not None + assert model_info["vertex_ai_audio_api"] == expected_api + + +@pytest.mark.parametrize("model", ["en-US-Studio-O", "gemini-2.5-flash-preview-tts", "chirp-3-hd-charon"]) +def test_get_vertex_ai_lyria_model_info_is_none_for_non_lyria_speech_models(model): + from litellm.llms.vertex_ai.common_utils import get_vertex_ai_lyria_model_info + + assert get_vertex_ai_lyria_model_info(model=model) is None + + +def test_get_vertex_ai_lyria_model_info_falls_back_to_bundled_map(monkeypatch): + import litellm + from litellm.llms.vertex_ai.common_utils import get_vertex_ai_lyria_model_info + + stale_runtime_model_cost = { + key: value for key, value in litellm.model_cost.items() if not key.startswith("vertex_ai/lyria") + } + monkeypatch.setattr(litellm, "model_cost", stale_runtime_model_cost) + + model_info = get_vertex_ai_lyria_model_info(model="lyria-3-pro-preview") + + assert model_info is not None + assert model_info["vertex_ai_audio_api"] == "lyria_interactions" + assert model_info["supported_audio_formats"] == ("mp3", "wav") 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 2cf6689261e..682dadfe854 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 @@ -18,8 +18,9 @@ def test_lyria_predict_response_preserves_audio_response_and_logs_cost( litellm.model_cost, "vertex_ai/lyria-002", { - "audio_seconds_per_prediction": 30, - "output_cost_per_second": 0.002, + "vertex_ai_audio_api": "lyria_predict", + "supported_audio_formats": ["wav"], + "output_cost_per_image": 0.06, }, ) logging_obj = MagicMock() @@ -79,8 +80,9 @@ def test_audio_predict_response_uses_model_map_metadata( litellm.model_cost, "vertex_ai/music-audio-preview", { - "audio_seconds_per_prediction": 12, - "output_cost_per_second": 0.5, + "vertex_ai_audio_api": "lyria_predict", + "supported_audio_formats": ["wav"], + "output_cost_per_image": 0.5, }, ) logging_obj = MagicMock() @@ -109,8 +111,8 @@ def test_audio_predict_response_uses_model_map_metadata( ) assert result["kwargs"]["model"] == "music-audio-preview" - assert result["kwargs"]["response_cost"] == pytest.approx(6.0) - assert logging_obj.model_call_details["response_cost"] == pytest.approx(6.0) + assert result["kwargs"]["response_cost"] == pytest.approx(0.5) + assert logging_obj.model_call_details["response_cost"] == pytest.approx(0.5) def test_audio_predict_response_supports_bytes_base64_encoded( @@ -120,8 +122,9 @@ def test_audio_predict_response_supports_bytes_base64_encoded( litellm.model_cost, "vertex_ai/lyria-002", { - "audio_seconds_per_prediction": 30, - "output_cost_per_second": 0.002, + "vertex_ai_audio_api": "lyria_predict", + "supported_audio_formats": ["wav"], + "output_cost_per_image": 0.06, }, ) logging_obj = MagicMock() @@ -146,27 +149,23 @@ def test_audio_predict_response_supports_bytes_base64_encoded( assert logging_obj.model_call_details["response_cost"] == pytest.approx(0.06) -@pytest.mark.parametrize( - "missing_fields", - ( - None, - ("output_cost_per_second",), - ("audio_seconds_per_prediction",), - ("output_cost_per_second", "audio_seconds_per_prediction"), - ), -) +@pytest.mark.parametrize("runtime_entry_is_missing", (True, False)) def test_lyria_predict_cost_falls_back_to_bundled_map_when_runtime_metadata_is_incomplete( monkeypatch: pytest.MonkeyPatch, - missing_fields: tuple[str, ...] | None, + runtime_entry_is_missing: bool, local_model_cost_map: None, ) -> None: - if missing_fields is None: + if runtime_entry_is_missing: monkeypatch.delitem(litellm.model_cost, "vertex_ai/lyria-002") else: monkeypatch.setitem( litellm.model_cost, "vertex_ai/lyria-002", - {key: value for key, value in litellm.model_cost["vertex_ai/lyria-002"].items() if key not in missing_fields}, + { + key: value + for key, value in litellm.model_cost["vertex_ai/lyria-002"].items() + if key != "output_cost_per_image" + }, ) logging_obj = MagicMock() logging_obj.model_call_details = {} @@ -193,7 +192,7 @@ def test_lyria_predict_cost_falls_back_to_bundled_map_when_runtime_metadata_is_i request_body={"instances": [{"prompt": "ambient piano"}]}, ) - if missing_fields is None: + if runtime_entry_is_missing: assert "vertex_ai/lyria-002" not in litellm.model_cost assert result["kwargs"]["model"] == "lyria-002" assert result["kwargs"]["response_cost"] == pytest.approx(0.06) diff --git a/tests/test_litellm/llms/vertex_ai/text_to_speech/test_transformation.py b/tests/test_litellm/llms/vertex_ai/text_to_speech/test_transformation.py index 4f4df69780a..b5eec42b569 100644 --- a/tests/test_litellm/llms/vertex_ai/text_to_speech/test_transformation.py +++ b/tests/test_litellm/llms/vertex_ai/text_to_speech/test_transformation.py @@ -169,23 +169,6 @@ def test_transform_text_to_speech_response_leaves_unknown_bytes_unlabeled(): class TestVertexAILyriaTextToSpeechConfig: - def test_response_without_mime_type_uses_audio_container(self) -> None: - audio: Final = b"RIFF\x24\x00\x00\x00WAVEfmt \x10\x00\x00\x00" - raw_response: Final = httpx.Response( - 200, - json={"outputs": [{"type": "audio", "data": base64.b64encode(audio).decode()}]}, - ) - - response: Final = VertexAILyriaTextToSpeechConfig().transform_text_to_speech_response( - model="lyria-3-pro-preview", - raw_response=raw_response, - logging_obj=MagicMock(), - ) - - assert response.content == audio - assert response.response.headers["content-type"] == "audio/wav" - assert response._hidden_params["audio_mime_type"] == "audio/wav" - @pytest.mark.parametrize( "model", ["lyria-002", "vertex_ai/lyria-3-clip-preview", "lyria-3-pro-preview"], @@ -385,11 +368,11 @@ class TestVertexAILyriaTextToSpeechConfig: { "predictions": [ { - "bytesBase64Encoded": "bHlyaWEtMi1hdWRpbw==", + "bytesBase64Encoded": "UklGRiQAAABXQVZFZm10IA==", } ] }, - b"lyria-2-audio", + b"RIFF$\x00\x00\x00WAVEfmt ", "audio/wav", ), ( @@ -427,6 +410,19 @@ class TestVertexAILyriaTextToSpeechConfig: b"lyria-3-audio", "audio/mpeg", ), + ( + "lyria-3-pro-preview", + { + "outputs": [ + { + "type": "audio", + "data": "UklGRiQAAABXQVZFZm10IA==", + } + ] + }, + b"RIFF$\x00\x00\x00WAVEfmt ", + "audio/wav", + ), ], ) def test_transform_response( @@ -446,7 +442,7 @@ class TestVertexAILyriaTextToSpeechConfig: ) assert response.content == expected_audio - assert response._hidden_params["audio_mime_type"] == expected_mime_type + assert response.response.headers["content-type"] == expected_mime_type @pytest.mark.parametrize( ("model", "response_format"), diff --git a/tests/test_litellm/test_cost_calculator.py b/tests/test_litellm/test_cost_calculator.py index aa630c4916b..3236253a10a 100644 --- a/tests/test_litellm/test_cost_calculator.py +++ b/tests/test_litellm/test_cost_calculator.py @@ -172,16 +172,15 @@ def test_vertex_lyria_speech_cost( monkeypatch.setitem( litellm.model_cost, model, - { - key: value - for key, value in model_info.items() - if key not in ("output_cost_per_image", "output_cost_per_second", "audio_seconds_per_prediction") - }, + {key: value for key, value in model_info.items() if key != "output_cost_per_image"}, ) elif runtime_state in ("custom_zero", "custom_price"): - cost_key: Final = "output_cost_per_image" if "output_cost_per_image" in model_info else "output_cost_per_second" multiplier: Final = 0 if runtime_state == "custom_zero" else 2 - monkeypatch.setitem(litellm.model_cost, model, {**model_info, cost_key: model_info[cost_key] * multiplier}) + monkeypatch.setitem( + litellm.model_cost, + model, + {**model_info, "output_cost_per_image": model_info["output_cost_per_image"] * multiplier}, + ) cost: Final = completion_cost( model=model, diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index a179a82fcb2..f934b2aca1e 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -944,14 +944,11 @@ def test_aaamodel_prices_and_context_window_json_is_valid(): "code_interpreter_cost_per_session": {"type": "number"}, "inference_geo": {"type": "string"}, "litellm_provider": {"type": "string"}, - "max_audio_length_hours": {"type": "number"}, - "max_audio_per_prompt": {"type": "number"}, "max_input_tokens": {"type": "number"}, "max_output_tokens": {"type": "number"}, "max_tokens": {"type": "number"}, "metadata": {"type": "object"}, "provider_specific_entry": {"type": "object"}, - "audio_seconds_per_prediction": {"type": "number"}, "mode": { "type": "string", "enum": [ @@ -2869,8 +2866,7 @@ def test_vertex_ai_lyria_models_in_cost_map(): assert lyria_2["mode"] == "audio_speech" assert clip["mode"] == "audio_speech" assert pro["mode"] == "audio_speech" - assert lyria_2["audio_seconds_per_prediction"] == 30 - assert lyria_2["output_cost_per_second"] == 0.002 + assert lyria_2["output_cost_per_image"] == 0.06 assert lyria_2["supported_modalities"] == ["text"] assert lyria_2["supported_output_modalities"] == ["audio"] assert lyria_2["supports_audio_output"] is True From 02b44820c4dd5da47abf11e9f1b2dbe2fdce80f5 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:59:57 -0700 Subject: [PATCH 16/17] test(vertex_ai): keep imagen predict passthrough off the Lyria audio path The new Lyria passthrough branch runs before the image-generation branch and keys on the same `predictions[0].bytesBase64Encoded` shape imagen returns, so only the cost-map lookup separates them. Cover an imagen predict response end to end so a future change that drops that lookup fails here instead of misbilling images as audio. --- ...test_vertex_passthrough_logging_handler.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) 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 682dadfe854..98010021bca 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,6 +9,7 @@ import litellm from litellm.proxy.pass_through_endpoints.llm_provider_handlers.vertex_passthrough_logging_handler import ( VertexPassthroughLoggingHandler, ) +from litellm.types.utils import PassthroughCallTypes def test_lyria_predict_response_preserves_audio_response_and_logs_cost( @@ -197,3 +198,33 @@ def test_lyria_predict_cost_falls_back_to_bundled_map_when_runtime_metadata_is_i assert result["kwargs"]["model"] == "lyria-002" assert result["kwargs"]["response_cost"] == pytest.approx(0.06) assert logging_obj.model_call_details["response_cost"] == pytest.approx(0.06) + + +def test_image_predict_response_is_not_billed_as_audio( + local_model_cost_map: None, +) -> None: + logging_obj = MagicMock() + logging_obj.model_call_details = {} + response = httpx.Response( + status_code=200, + json={"predictions": [{"bytesBase64Encoded": "frame", "mimeType": "image/png"}]}, + ) + + result = VertexPassthroughLoggingHandler.vertex_passthrough_handler( + httpx_response=response, + logging_obj=logging_obj, + url_route=( + "/v1/projects/test/locations/us-central1/publishers/google/models/imagen-4.0-generate-001:predict" + ), + result=response.text, + start_time=datetime.now(), + end_time=datetime.now(), + cache_hit=False, + request_body={"instances": [{"prompt": "a red cube"}]}, + ) + + assert isinstance(result["result"], litellm.ImageResponse) + assert logging_obj.call_type == PassthroughCallTypes.passthrough_image_generation.value + assert result["kwargs"]["response_cost"] == pytest.approx( + litellm.model_cost["vertex_ai/imagen-4.0-generate-001"]["output_cost_per_image"] + ) From 11e45ad95361eb37c00f9cce799bf544fd9990a2 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:00:01 -0700 Subject: [PATCH 17/17] fix(vertex_ai): mark the Lyria 3 catalog entries text-only `vertex_ai/lyria-3-clip-preview` and `vertex_ai/lyria-3-pro-preview` were registered with `supports_vision`, `supports_image_input`, and an `image` modality, which contradicts their `gemini/lyria-3-*` siblings and makes /model/info advertise image input on text-to-music models. --- .../model_prices_and_context_window_backup.json | 14 +++++--------- model_prices_and_context_window.json | 14 +++++--------- tests/test_litellm/test_utils.py | 11 +++++++---- 3 files changed, 17 insertions(+), 22 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 0bc6c59a4b7..5bdf6ed4fb8 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -45513,7 +45513,7 @@ }, "vertex_ai/lyria-002": { "litellm_provider": "vertex_ai", - "mode": "audio_speech", + "mode": "audio_speech", "output_cost_per_image": 0.06, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing#lyria", "supported_audio_formats": [ @@ -45549,8 +45549,7 @@ "/v1/audio/speech" ], "supported_modalities": [ - "text", - "image" + "text" ], "supported_output_modalities": [ "audio" @@ -45561,11 +45560,10 @@ "supports_audio_input": false, "supports_audio_output": true, "supports_function_calling": false, - "supports_image_input": true, "supports_prompt_caching": false, "supports_response_schema": false, "supports_system_messages": false, - "supports_vision": true, + "supports_vision": false, "supports_web_search": false, "vertex_ai_audio_api": "lyria_interactions" }, @@ -45588,8 +45586,7 @@ "/v1/audio/speech" ], "supported_modalities": [ - "text", - "image" + "text" ], "supported_output_modalities": [ "audio" @@ -45600,11 +45597,10 @@ "supports_audio_input": false, "supports_audio_output": true, "supports_function_calling": false, - "supports_image_input": true, "supports_prompt_caching": false, "supports_response_schema": false, "supports_system_messages": false, - "supports_vision": true, + "supports_vision": false, "supports_web_search": false, "vertex_ai_audio_api": "lyria_interactions" }, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 0bc6c59a4b7..5bdf6ed4fb8 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -45513,7 +45513,7 @@ }, "vertex_ai/lyria-002": { "litellm_provider": "vertex_ai", - "mode": "audio_speech", + "mode": "audio_speech", "output_cost_per_image": 0.06, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing#lyria", "supported_audio_formats": [ @@ -45549,8 +45549,7 @@ "/v1/audio/speech" ], "supported_modalities": [ - "text", - "image" + "text" ], "supported_output_modalities": [ "audio" @@ -45561,11 +45560,10 @@ "supports_audio_input": false, "supports_audio_output": true, "supports_function_calling": false, - "supports_image_input": true, "supports_prompt_caching": false, "supports_response_schema": false, "supports_system_messages": false, - "supports_vision": true, + "supports_vision": false, "supports_web_search": false, "vertex_ai_audio_api": "lyria_interactions" }, @@ -45588,8 +45586,7 @@ "/v1/audio/speech" ], "supported_modalities": [ - "text", - "image" + "text" ], "supported_output_modalities": [ "audio" @@ -45600,11 +45597,10 @@ "supports_audio_input": false, "supports_audio_output": true, "supports_function_calling": false, - "supports_image_input": true, "supports_prompt_caching": false, "supports_response_schema": false, "supports_system_messages": false, - "supports_vision": true, + "supports_vision": false, "supports_web_search": false, "vertex_ai_audio_api": "lyria_interactions" }, diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index f934b2aca1e..d0916adce9d 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -2887,14 +2887,17 @@ def test_vertex_ai_lyria_models_in_cost_map(): "/v1beta/interactions", "/v1/audio/speech", ] - assert clip["supported_modalities"] == ["text", "image"] - assert pro["supported_modalities"] == ["text", "image"] + assert clip["supported_modalities"] == ["text"] + assert pro["supported_modalities"] == ["text"] + assert clip["supports_vision"] is False + assert pro["supports_vision"] is False + assert "supports_image_input" not in clip + assert "supports_image_input" not in pro assert clip["supported_regions"] == ["global"] assert pro["supported_regions"] == ["global"] assert clip["supports_audio_output"] is True assert pro["supports_audio_output"] is True - assert clip["supports_image_input"] is True - assert pro["supports_image_input"] is True + def test_model_info_for_fireworks_short_form_models(): """