From f937668eeae2bf95e31ca95a2770f45eda0cc3ca Mon Sep 17 00:00:00 2001 From: Jonathan Wrede Date: Sat, 9 May 2026 20:33:33 +0000 Subject: [PATCH] fix(router): add _update_kwargs_with_deployment to aspeech() aspeech() is the only router method that does not call _update_kwargs_with_deployment(). Without it, the deployment UUID is not stored in kwargs metadata, so get_router_model_id() returns None and the cost calculator falls back to the bare model name with no pricing entry -- spend is always $0 for custom-priced TTS deployments. Also replaces inline client-selection logic and manual default_litellm_params loop with the shared helpers already used by other router methods (_get_async_openai_model_client, _update_kwargs_with_deployment). Fixes #27390 --- litellm/router.py | 29 ++--- .../test_router_aspeech_deployment_kwargs.py | 119 ++++++++++++++++++ 2 files changed, 126 insertions(+), 22 deletions(-) create mode 100644 tests/test_litellm/test_router_aspeech_deployment_kwargs.py diff --git a/litellm/router.py b/litellm/router.py index 7512ee387dc..4fb73e464fd 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -3582,29 +3582,14 @@ class Router: request_kwargs=kwargs, ) self._update_kwargs_before_fallbacks(model=model, kwargs=kwargs) - data = deployment["litellm_params"].copy() - data["model"] - for k, v in self.default_litellm_params.items(): - if ( - k not in kwargs - ): # prioritize model-specific params > default router params - kwargs[k] = v - elif k == "metadata": - kwargs[k].update(v) - - potential_model_client = self._get_client( - deployment=deployment, kwargs=kwargs, client_type="async" + self._update_kwargs_with_deployment( + deployment=deployment, kwargs=kwargs, function_name="aspeech" + ) + data = deployment["litellm_params"].copy() + + model_client = self._get_async_openai_model_client( + deployment=deployment, kwargs=kwargs ) - # check if provided keys == client keys # - dynamic_api_key = kwargs.get("api_key", None) - if ( - dynamic_api_key is not None - and potential_model_client is not None - and dynamic_api_key != potential_model_client.api_key - ): - model_client = None - else: - model_client = potential_model_client response = await litellm.aspeech( **{ diff --git a/tests/test_litellm/test_router_aspeech_deployment_kwargs.py b/tests/test_litellm/test_router_aspeech_deployment_kwargs.py new file mode 100644 index 00000000000..08caa358782 --- /dev/null +++ b/tests/test_litellm/test_router_aspeech_deployment_kwargs.py @@ -0,0 +1,119 @@ +""" +Tests that Router.aspeech() calls _update_kwargs_with_deployment() so +deployment metadata (model_info, model_id) is stored in kwargs for cost +calculation. + +Without this call, get_router_model_id() returns None, the cost +calculator falls back to the bare model name (no pricing entry), and +spend is always $0 for custom-priced TTS deployments. + +See: https://github.com/BerriAI/litellm/issues/27390 +""" + +import os +import sys +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +sys.path.insert( + 0, os.path.abspath("../..") +) # Adds the parent directory to the system path + +from litellm import Router + + +def _make_tts_router() -> Router: + return Router( + model_list=[ + { + "model_name": "my-tts", + "litellm_params": { + "model": "openai/tts-1", + "api_key": "fake-key", + }, + "model_info": { + "id": "test-deployment-id", + "mode": "audio_speech", + "input_cost_per_character": 0.000015, + }, + }, + ], + ) + + +class TestAspeechDeploymentKwargs: + @pytest.mark.asyncio + async def test_aspeech_calls_update_kwargs_with_deployment(self): + """aspeech must call _update_kwargs_with_deployment so deployment + metadata is available for cost calculation.""" + router = _make_tts_router() + + with ( + patch.object( + router, + "async_get_available_deployment", + new_callable=AsyncMock, + return_value={ + "model_name": "my-tts", + "litellm_params": { + "model": "openai/tts-1", + "api_key": "fake-key", + }, + "model_info": { + "id": "test-deployment-id", + }, + }, + ), + patch.object( + router, + "_update_kwargs_with_deployment", + wraps=router._update_kwargs_with_deployment, + ) as mock_update, + patch("litellm.aspeech", new_callable=AsyncMock, return_value=MagicMock()), + ): + await router.aspeech(model="my-tts", input="hello world", voice="alloy") + + mock_update.assert_called_once() + call_kwargs = mock_update.call_args + assert call_kwargs.kwargs.get("function_name") == "aspeech" or ( + len(call_kwargs.args) >= 3 and call_kwargs.args[2] == "aspeech" + ) + + @pytest.mark.asyncio + async def test_aspeech_stores_model_info_in_metadata(self): + """After aspeech, kwargs metadata should contain model_info with + the deployment ID needed for cost lookup.""" + router = _make_tts_router() + + deployment = { + "model_name": "my-tts", + "litellm_params": { + "model": "openai/tts-1", + "api_key": "fake-key", + }, + "model_info": { + "id": "test-deployment-id", + "input_cost_per_character": 0.000015, + }, + } + captured_kwargs = {} + + async def capture_aspeech(**kwargs): + captured_kwargs.update(kwargs) + return MagicMock() + + with ( + patch.object( + router, + "async_get_available_deployment", + new_callable=AsyncMock, + return_value=deployment, + ), + patch("litellm.aspeech", side_effect=capture_aspeech), + ): + await router.aspeech(model="my-tts", input="hello world", voice="alloy") + + metadata = captured_kwargs.get("metadata", {}) + assert metadata.get("model_info") is not None + assert metadata["model_info"]["id"] == "test-deployment-id"