diff --git a/litellm/litellm_core_utils/audio_output_refusal.py b/litellm/litellm_core_utils/audio_output_refusal.py new file mode 100644 index 00000000000..e8f90bdf45f --- /dev/null +++ b/litellm/litellm_core_utils/audio_output_refusal.py @@ -0,0 +1,107 @@ +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from types import MappingProxyType +from typing import Final, NoReturn, TypeAlias + +from pydantic import ConfigDict, TypeAdapter, ValidationError +from typing_extensions import assert_never + +from litellm.exceptions import UnsupportedParamsError +from litellm.litellm_core_utils.tool_fields import drop_policy +from litellm.utils import is_gemini_tts_model + + +@dataclass(frozen=True, slots=True) +class AudioOnlyModel: + model: str + + +@dataclass(frozen=True, slots=True) +class AudioOutputRequested: + fields: tuple[str, ...] + + +AudioOutputRefusal: TypeAlias = AudioOnlyModel | AudioOutputRequested + +_MODALITY_LIST: Final = TypeAdapter(list[object], config=ConfigDict(strict=True)) + + +def _listed_modalities(value: object) -> tuple[object, ...] | None: + try: + return tuple(_MODALITY_LIST.validate_python(value)) + except ValidationError: + return None + + +def _asks_for_audio_modality(value: object) -> bool: + return "audio" in (_listed_modalities(value) or ()) + + +_AUDIO_OUTPUT_REQUESTS: Final[Mapping[str, Callable[[object], bool]]] = MappingProxyType( + {"audio": lambda value: value is not None, "modalities": _asks_for_audio_modality} +) + + +def _forwarded_modalities(value: object) -> object: + listed: Final = _listed_modalities(value) + if listed is None: + return value + return [modality for modality in listed if modality != "audio"] or None + + +def _without_audio_output(params: Mapping[str, object]) -> Mapping[str, object]: + modalities: Final = _forwarded_modalities(params.get("modalities")) + without_audio: Final = {key: value for key, value in params.items() if key not in _AUDIO_OUTPUT_REQUESTS} + return MappingProxyType(without_audio if modalities is None else {**without_audio, "modalities": modalities}) + + +def params_without_audio_output(params: Mapping[str, object], model: str) -> Mapping[str, object] | AudioOutputRefusal: + if is_gemini_tts_model(model): + return AudioOnlyModel(model) + policy: Final = drop_policy(params) + requested: Final = tuple( + field + for field, requests_audio in _AUDIO_OUTPUT_REQUESTS.items() + if field not in policy.additional_drop_params and requests_audio(params.get(field)) + ) + if not requested: + return params + if not policy.drop_params: + return AudioOutputRequested(requested) + return _without_audio_output(params) + + +def _refusal_message(refusal: AudioOutputRefusal, *, endpoint: str, model: str) -> str: + match refusal: + case AudioOnlyModel(): + return ( + f"{refusal.model} only answers with audio, which {endpoint} cannot return. " + "Call /v1/audio/speech or /v1/chat/completions instead." + ) + case AudioOutputRequested(fields=fields): + return ( + f"{endpoint} cannot return audio output, so it does not support {list(fields)} for model={model}. " + "Call /v1/chat/completions for audio, or set drop_params: true to drop these params." + ) + case _: + assert_never(refusal) + + +def raise_audio_output_refusal( + refusal: AudioOutputRefusal, *, endpoint: str, model: str, custom_llm_provider: str | None +) -> NoReturn: + raise UnsupportedParamsError( + status_code=400, + model=model, + llm_provider=custom_llm_provider, + message=_refusal_message(refusal, endpoint=endpoint, model=model), + ) + + +def forward_without_audio_output( + params: Mapping[str, object], *, endpoint: str, model: str, custom_llm_provider: str | None +) -> Mapping[str, object]: + forwarded: Final = params_without_audio_output(params, model) + if isinstance(forwarded, AudioOnlyModel | AudioOutputRequested): + raise_audio_output_refusal(forwarded, endpoint=endpoint, model=model, custom_llm_provider=custom_llm_provider) + return forwarded diff --git a/litellm/llms/anthropic/messages/adapters/handler.py b/litellm/llms/anthropic/messages/adapters/handler.py index 71ccfbaf915..7d3744911ad 100644 --- a/litellm/llms/anthropic/messages/adapters/handler.py +++ b/litellm/llms/anthropic/messages/adapters/handler.py @@ -13,6 +13,7 @@ from typing_extensions import TypedDict import litellm from litellm._logging import verbose_logger from litellm.litellm_core_utils.asyncify import run_async_function +from litellm.litellm_core_utils.audio_output_refusal import forward_without_audio_output from litellm.litellm_core_utils.core_helpers import drops_unsupported_params from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObject from litellm.llms.anthropic.messages.adapters.transformation import ( @@ -116,6 +117,16 @@ def _extract_proxy_litellm_metadata( return litellm_metadata, user_api_key_auth +def _kwargs_without_audio_output(model: str, request_kwargs: Mapping[str, object]) -> Mapping[str, object]: + provider: Final = request_kwargs.get("custom_llm_provider") + return forward_without_audio_output( + request_kwargs, + endpoint="/v1/messages", + model=model, + custom_llm_provider=provider if isinstance(provider, str) else None, + ) + + def _requested_router(kwargs: Mapping[str, object]) -> "Router | None": from litellm.router import Router @@ -744,6 +755,7 @@ class LiteLLMMessagesToCompletionTransformationHandler: proxy_litellm_metadata, user_api_key_auth = _extract_proxy_litellm_metadata(kwargs) request_kwargs: Final = _REQUEST_KWARGS.validate_python(kwargs) reject_malformed_tool_result_text(messages, model, request_kwargs) + forwarded_kwargs: Final = _kwargs_without_audio_output(model, request_kwargs) drop_invalid_edits: Final = drops_unsupported_params(request_kwargs.get("drop_params")) sub_call_logging_kwargs: Final = proxy_sub_call_logging_kwargs(request_kwargs) sub_call_trace_id: Final = proxy_sub_call_trace_id(request_kwargs) @@ -793,7 +805,7 @@ class LiteLLMMessagesToCompletionTransformationHandler: top_k=top_k, top_p=top_p, output_format=output_format, - extra_kwargs=kwargs, + extra_kwargs=forwarded_kwargs, ) completion_response: Final = await litellm.acompletion(**completion_kwargs) @@ -891,6 +903,7 @@ class LiteLLMMessagesToCompletionTransformationHandler: # loop. The async ``async_anthropic_messages_handler`` path is # unaffected because it ``await``s within the original event loop. litellm_router: Final[Router | None] = kwargs.pop("litellm_router", None) + forwarded_kwargs: Final = _kwargs_without_audio_output(model, _REQUEST_KWARGS.validate_python(kwargs)) # Skip the async bridge entirely when there is nothing for either the # polyfill or the client-history slice-only fallback to do. The vast @@ -940,7 +953,7 @@ class LiteLLMMessagesToCompletionTransformationHandler: top_k=top_k, top_p=top_p, output_format=output_format, - extra_kwargs=kwargs, + extra_kwargs=forwarded_kwargs, ) completion_response: Final = litellm.completion(**completion_kwargs) diff --git a/litellm/main.py b/litellm/main.py index 41c4302499e..7d5fd8ea8db 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -70,6 +70,7 @@ from litellm.constants import ( from litellm.exceptions import LiteLLMUnknownProvider from litellm.integrations.custom_logger import CustomLogger from litellm.litellm_core_utils.asyncify import asyncify, run_async_function +from litellm.litellm_core_utils.audio_output_refusal import forward_without_audio_output from litellm.litellm_core_utils.audio_utils.utils import ( calculate_request_duration, get_audio_file_for_health_check, @@ -8038,11 +8039,17 @@ def text_completion( if model is None: raise ValueError("model is not set. Set either via 'model' or 'engine' param.") kwargs["text_completion"] = True + audio_free_kwargs: Final = forward_without_audio_output( + _REQUEST_KWARGS_ADAPTER.validate_python(kwargs), + endpoint="/v1/completions", + model=model, + custom_llm_provider=custom_llm_provider, + ) response = completion( model=model, messages=messages, *args, - **kwargs, + **audio_free_kwargs, **optional_params, ) if kwargs.get("acompletion", False) is True: diff --git a/litellm/responses/main.py b/litellm/responses/main.py index d474ed3991f..dbf0e890190 100644 --- a/litellm/responses/main.py +++ b/litellm/responses/main.py @@ -29,6 +29,7 @@ from litellm.integrations.anthropic_cache_control_hook import ( configured_injection_points, ) from litellm.litellm_core_utils.asyncify import run_async_function +from litellm.litellm_core_utils.audio_output_refusal import forward_without_audio_output from litellm.litellm_core_utils.completion_timeout import CompletionTimeout, StreamKind from litellm.litellm_core_utils.core_helpers import drops_unsupported_params, normalize_drop_params from litellm.litellm_core_utils.get_llm_provider_logic import get_provider_affinity_header @@ -1754,6 +1755,9 @@ def responses( bridge_kwargs: Final = _bridge_kwargs(kwargs, responses_api_provider_config, allowed_openai_params) if isinstance(bridge_kwargs, _UntranslatableResponsesFields): _raise_untranslatable_responses_fields(bridge_kwargs, model, custom_llm_provider) + audio_free_kwargs: Final = forward_without_audio_output( + bridge_kwargs, endpoint="/v1/responses", model=model, custom_llm_provider=custom_llm_provider + ) return litellm_completion_transformation_handler.response_api_handler( previous_response_client_ids(previous_response_id, kwargs), model=model, @@ -1766,7 +1770,7 @@ def responses( extra_body=extra_body, timeout=timeout if timeout is not None else request_timeout, allowed_openai_params=allowed_openai_params, - **bridge_kwargs, + **audio_free_kwargs, ) # Get optional parameters for the responses API diff --git a/tests/unit/litellm_core_utils/test_audio_output_refusal.py b/tests/unit/litellm_core_utils/test_audio_output_refusal.py new file mode 100644 index 00000000000..9ee7d5b9b07 --- /dev/null +++ b/tests/unit/litellm_core_utils/test_audio_output_refusal.py @@ -0,0 +1,133 @@ +from collections.abc import Mapping +from typing import Final +from unittest.mock import patch + +import pytest + +import litellm +from litellm.exceptions import UnsupportedParamsError +from litellm.litellm_core_utils.audio_output_refusal import ( + AudioOnlyModel, + AudioOutputRefusal, + AudioOutputRequested, + forward_without_audio_output, + params_without_audio_output, + raise_audio_output_refusal, +) + +_TEXT_MODEL: Final = "gemini/gemini-3.8-flash" +_VOICE: Final = {"voice": "Kore", "format": "pcm16"} + + +@pytest.mark.parametrize( + "model", + ["gemini/gemini-3.8-flash-tts", "gemini-3.8-flash-tts", "vertex_ai/gemini-2.5-flash-preview-tts"], +) +@pytest.mark.parametrize( + "params", + [{}, {"drop_params": True}, {"audio": _VOICE, "drop_params": True}, {"additional_drop_params": ["audio"]}], + ids=["plain", "drop-params", "audio-under-drop-params", "additional-drop-params"], +) +def test_a_gemini_tts_model_is_refused_whatever_the_request_drops(model: str, params: Mapping[str, object]) -> None: + assert params_without_audio_output(params, model) == AudioOnlyModel(model) + + +@pytest.mark.parametrize( + ("params", "fields"), + [ + ({"audio": _VOICE}, ("audio",)), + ({"modalities": ["text", "audio"]}, ("modalities",)), + ({"audio": _VOICE, "modalities": ["audio"]}, ("audio", "modalities")), + ({"audio": _VOICE, "modalities": ["text", "audio"], "additional_drop_params": ["audio"]}, ("modalities",)), + ({"audio": _VOICE, "drop_params": False}, ("audio",)), + ], +) +def test_an_audio_output_ask_is_refused_without_drop_params( + params: Mapping[str, object], fields: tuple[str, ...] +) -> None: + with patch.object(litellm, "drop_params", False): + assert params_without_audio_output(params, _TEXT_MODEL) == AudioOutputRequested(fields) + + +@pytest.mark.parametrize( + "params", + [ + {}, + {"audio": None}, + {"modalities": ["text", "image"]}, + {"modalities": "audio"}, + {"audio": _VOICE, "additional_drop_params": ["audio"]}, + {"audio": _VOICE, "modalities": ["text", "audio"], "additional_drop_params": ["audio", "modalities"]}, + ], +) +def test_a_request_without_an_audio_output_ask_is_forwarded_as_is(params: Mapping[str, object]) -> None: + with patch.object(litellm, "drop_params", False): + assert params_without_audio_output(params, _TEXT_MODEL) is params + + +@pytest.mark.parametrize( + ("params", "forwarded"), + [ + ({"audio": _VOICE, "drop_params": True, "temperature": 0.2}, {"drop_params": True, "temperature": 0.2}), + ({"audio": _VOICE, "drop_params": "true"}, {"drop_params": "true"}), + ( + {"modalities": ["text", "image", "audio"], "drop_params": True}, + {"modalities": ["text", "image"], "drop_params": True}, + ), + ({"audio": _VOICE, "modalities": ["audio"], "drop_params": True}, {"drop_params": True}), + ], +) +def test_drop_params_forwards_the_request_without_its_audio_output_ask( + params: Mapping[str, object], forwarded: Mapping[str, object] +) -> None: + with patch.object(litellm, "drop_params", False): + assert params_without_audio_output(params, _TEXT_MODEL) == forwarded + + +def test_the_global_drop_params_setting_drops_the_audio_output_ask() -> None: + with patch.object(litellm, "drop_params", True): + assert params_without_audio_output({"audio": _VOICE, "user": "u-1"}, _TEXT_MODEL) == {"user": "u-1"} + + +@pytest.mark.parametrize( + ("refusal", "model", "expected"), + [ + ( + AudioOnlyModel("gemini-3.8-flash-tts"), + "gemini-3.8-flash-tts", + "gemini-3.8-flash-tts only answers with audio, which /v1/responses cannot return. " + "Call /v1/audio/speech or /v1/chat/completions instead.", + ), + ( + AudioOutputRequested(("audio", "modalities")), + "gemini-3.8-flash", + "/v1/responses cannot return audio output, so it does not support ['audio', 'modalities'] " + "for model=gemini-3.8-flash. Call /v1/chat/completions for audio, " + "or set drop_params: true to drop these params.", + ), + ], +) +def test_a_refusal_is_raised_as_a_400_naming_the_way_out( + refusal: AudioOutputRefusal, model: str, expected: str +) -> None: + with pytest.raises(UnsupportedParamsError) as error: + raise_audio_output_refusal(refusal, endpoint="/v1/responses", model=model, custom_llm_provider="gemini") + + assert error.value.status_code == 400 + assert error.value.llm_provider == "gemini" + assert error.value.model == model + assert error.value.message == f"litellm.UnsupportedParamsError: {expected}" + + +def test_forwarding_returns_the_cleaned_params_and_raises_a_refusal() -> None: + with patch.object(litellm, "drop_params", False): + forwarded: Final = forward_without_audio_output( + {"audio": _VOICE, "drop_params": True}, endpoint="/v1/messages", model=_TEXT_MODEL, custom_llm_provider=None + ) + with pytest.raises(UnsupportedParamsError) as error: + forward_without_audio_output( + {"audio": _VOICE}, endpoint="/v1/messages", model=_TEXT_MODEL, custom_llm_provider="gemini" + ) + + assert forwarded == {"drop_params": True} + assert error.value.message.startswith("litellm.UnsupportedParamsError: /v1/messages cannot return audio output") diff --git a/tests/unit/llms/anthropic/messages/adapters/test_handler_audio_output.py b/tests/unit/llms/anthropic/messages/adapters/test_handler_audio_output.py new file mode 100644 index 00000000000..f985c584f0a --- /dev/null +++ b/tests/unit/llms/anthropic/messages/adapters/test_handler_audio_output.py @@ -0,0 +1,131 @@ +from collections.abc import Mapping +from typing import Final +from unittest.mock import patch + +import httpx +import pytest +import respx +from pydantic import TypeAdapter +from typing_extensions import ReadOnly, TypedDict + +import litellm +from litellm.anthropic_interface import messages as anthropic_messages_api +from litellm.router import Router +from tests.unit.proxy.conftest import httpx_transport + +pytestmark: Final = pytest.mark.usefixtures(httpx_transport.__name__) + +_ANY_GEMINI_URL: Final = r"https://generativelanguage\.googleapis\.com/.*" +_VOICE: Final = {"voice": "Kore", "format": "pcm16"} +_MESSAGES: Final = [{"role": "user", "content": "Say hi"}] +_TEXT_REPLY: Final = { + "candidates": [{"content": {"role": "model", "parts": [{"text": "hi"}]}, "finishReason": "STOP", "index": 0}], + "usageMetadata": {"promptTokenCount": 2, "candidatesTokenCount": 1, "totalTokenCount": 3}, +} +_REFUSED_REQUESTS: Final = pytest.mark.parametrize( + ("model", "request_options"), + [ + ("gemini/gemini-3.8-flash-tts", {}), + ("gemini/gemini-3.8-flash-tts", {"drop_params": True}), + ("gemini/gemini-3.8-flash", {"audio": _VOICE}), + ("gemini/gemini-3.8-flash", {"modalities": ["text", "audio"]}), + ], + ids=["tts", "tts-under-drop-params", "audio-param", "audio-modality"], +) + + +class _GenerateContentBody(TypedDict): + generationConfig: ReadOnly[Mapping[str, object]] + + +_GENERATE_CONTENT_BODY: Final = TypeAdapter(_GenerateContentBody) + + +def _router(model: str) -> Router: + return Router(model_list=[{"model_name": "speaker", "litellm_params": {"model": model, "api_key": "test-key"}}]) + + +def _sent_generation_config(route: respx.Route) -> Mapping[str, object]: + return _GENERATE_CONTENT_BODY.validate_json(route.calls.last.request.content)["generationConfig"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("stream", [False, True]) +@_REFUSED_REQUESTS +async def test_an_audio_output_request_is_refused_before_gemini_is_called( + stream: bool, model: str, request_options: Mapping[str, object] +) -> None: + with patch.object(litellm, "drop_params", False), respx.mock(assert_all_called=False) as mock_router: + route: Final = mock_router.post(url__regex=_ANY_GEMINI_URL).mock( + return_value=httpx.Response(status_code=200, json=_TEXT_REPLY) + ) + with pytest.raises(litellm.UnsupportedParamsError) as error: + await _router(model).aanthropic_messages( + model="speaker", max_tokens=64, messages=_MESSAGES, stream=stream, **request_options + ) + + assert error.value.status_code == 400 + assert "/v1/messages" in error.value.message + assert not route.called + + +@_REFUSED_REQUESTS +def test_a_sync_audio_output_request_is_refused_before_gemini_is_called( + model: str, request_options: Mapping[str, object] +) -> None: + with patch.object(litellm, "drop_params", False), respx.mock(assert_all_called=False) as mock_router: + route: Final = mock_router.post(url__regex=_ANY_GEMINI_URL).mock( + return_value=httpx.Response(status_code=200, json=_TEXT_REPLY) + ) + with pytest.raises(litellm.UnsupportedParamsError) as error: + anthropic_messages_api.create( + model=model, max_tokens=64, messages=_MESSAGES, api_key="test-key", **request_options + ) + + assert error.value.status_code == 400 + assert "/v1/messages" in error.value.message + assert not route.called + + +@pytest.mark.asyncio +async def test_drop_params_sends_a_text_gemini_model_no_audio_output_ask() -> None: + with respx.mock() as mock_router: + route: Final = mock_router.post(url__regex=_ANY_GEMINI_URL).mock( + return_value=httpx.Response(status_code=200, json=_TEXT_REPLY) + ) + response: Final = await _router("gemini/gemini-3.8-flash").aanthropic_messages( + model="speaker", + max_tokens=64, + messages=_MESSAGES, + audio=_VOICE, + modalities=["text", "audio"], + drop_params=True, + ) + + assert isinstance(response, dict) + assert response["content"] == [{"type": "text", "text": "hi"}] + generation_config: Final = _sent_generation_config(route) + assert "speechConfig" not in generation_config + assert generation_config["responseModalities"] == ["TEXT"] + + +def test_sync_drop_params_sends_a_text_gemini_model_no_audio_output_ask() -> None: + with respx.mock() as mock_router: + route: Final = mock_router.post(url__regex=_ANY_GEMINI_URL).mock( + return_value=httpx.Response(status_code=200, json=_TEXT_REPLY) + ) + response: Final = anthropic_messages_api.create( + model="gemini/gemini-3.8-flash", + max_tokens=64, + messages=_MESSAGES, + api_key="test-key", + audio=_VOICE, + modalities=["text", "audio"], + drop_params=True, + ) + + assert isinstance(response, dict) + assert response["content"] == [{"type": "text", "text": "hi"}] + generation_config: Final = _sent_generation_config(route) + assert "speechConfig" not in generation_config + assert generation_config["responseModalities"] == ["TEXT"] diff --git a/tests/unit/responses/litellm_completion_transformation/test_google_ai_studio_responses_wire.py b/tests/unit/responses/litellm_completion_transformation/test_google_ai_studio_responses_wire.py index f63088a606f..490ed05b21a 100644 --- a/tests/unit/responses/litellm_completion_transformation/test_google_ai_studio_responses_wire.py +++ b/tests/unit/responses/litellm_completion_transformation/test_google_ai_studio_responses_wire.py @@ -1,6 +1,7 @@ import json -from collections.abc import AsyncIterable +from collections.abc import AsyncIterable, Mapping from typing import Final +from unittest.mock import patch import httpx import pytest @@ -258,3 +259,63 @@ async def test_gemini_streaming_function_call_preserves_thought_signature() -> N assert function_calls[0].name == "get_weather" assert function_calls[0].provider_specific_fields == {"thought_signature": signature} assert len(requests) == 1 + + +_ANY_GEMINI_URL: Final = r"https://generativelanguage\.googleapis\.com/.*" +_VOICE: Final = {"voice": "Kore", "format": "pcm16"} +_TEXT_REPLY: Final = { + "candidates": [{"content": {"role": "model", "parts": [{"text": "hi"}]}, "finishReason": "STOP", "index": 0}], + "usageMetadata": {"promptTokenCount": 2, "candidatesTokenCount": 1, "totalTokenCount": 3}, +} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("stream", [False, True]) +@pytest.mark.parametrize( + ("model", "request_options"), + [ + ("gemini/gemini-3.8-flash-tts", {}), + ("gemini/gemini-3.8-flash-tts", {"drop_params": True}), + ("gemini/gemini-3.8-flash", {"audio": _VOICE}), + ("gemini/gemini-3.8-flash", {"modalities": ["text", "audio"]}), + ], + ids=["tts", "tts-under-drop-params", "audio-param", "audio-modality"], +) +async def test_an_audio_output_request_is_refused_before_gemini_is_called( + stream: bool, model: str, request_options: Mapping[str, object] +) -> None: + with patch.object(litellm, "drop_params", False), respx.mock(assert_all_called=False) as mock_router: + route: Final = mock_router.post(url__regex=_ANY_GEMINI_URL).mock( + return_value=httpx.Response(status_code=200, json=_TEXT_REPLY) + ) + with pytest.raises(litellm.UnsupportedParamsError) as error: + await litellm.aresponses(model=model, api_key="test-key", input="Say hi", stream=stream, **request_options) + + assert error.value.status_code == 400 + assert "/v1/responses" in error.value.message + assert not route.called + + +@pytest.mark.asyncio +async def test_drop_params_sends_a_text_gemini_model_no_audio_output_ask() -> None: + with respx.mock() as mock_router: + route: Final = mock_router.post(url__regex=_ANY_GEMINI_URL).mock( + return_value=httpx.Response(status_code=200, json=_TEXT_REPLY) + ) + response: Final = await litellm.aresponses( + model="gemini/gemini-3.8-flash", + api_key="test-key", + input="Say hi", + audio=_VOICE, + modalities=["text", "audio"], + drop_params=True, + ) + requests: Final = tuple(route.calls) + + assert isinstance(response, ResponsesAPIResponse) + assert response.output_text == "hi" + assert len(requests) == 1 + generation_config: Final = _JSON_OBJECT.validate_json(requests[0].request.content)["generationConfig"] + assert isinstance(generation_config, dict) + assert "speechConfig" not in generation_config + assert generation_config["responseModalities"] == ["TEXT"] diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index f38bbcf4404..bb3f3decfc5 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -9323,6 +9323,72 @@ async def test_text_completion_stream_include_usage_returns_usage_on_final_chunk assert (final_usage.prompt_tokens, final_usage.completion_tokens, final_usage.total_tokens) == (3, 2, 5) +_ANY_GEMINI_URL: Final = r"https://generativelanguage\.googleapis\.com/.*" +_GEMINI_VOICE: Final = {"voice": "Kore", "format": "pcm16"} +_GEMINI_TEXT_REPLY: Final = { + "candidates": [{"content": {"role": "model", "parts": [{"text": "hi"}]}, "finishReason": "STOP", "index": 0}], + "usageMetadata": {"promptTokenCount": 2, "candidatesTokenCount": 1, "totalTokenCount": 3}, +} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync_mode", [True, False]) +@pytest.mark.parametrize( + ("model", "request_options"), + [ + ("gemini/gemini-3.8-flash-tts", {}), + ("gemini/gemini-3.8-flash-tts", {"drop_params": True}), + ("gemini/gemini-3.8-flash", {"audio": _GEMINI_VOICE}), + ("gemini/gemini-3.8-flash", {"modalities": ["text", "audio"]}), + ], + ids=["tts", "tts-under-drop-params", "audio-param", "audio-modality"], +) +async def test_text_completion_refuses_an_audio_output_request_before_gemini_is_called( + sync_mode: bool, model: str, request_options: Mapping[str, object], monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + monkeypatch.setattr(litellm, "drop_params", False) + request: Final = {"model": model, "prompt": "Say hi", "api_key": "test-key", **request_options} + with respx.mock(assert_all_called=False) as mock_router: + route: Final = mock_router.post(url__regex=_ANY_GEMINI_URL).mock( + return_value=httpx.Response(200, json=_GEMINI_TEXT_REPLY) + ) + with pytest.raises(litellm.UnsupportedParamsError) as error: + litellm.text_completion(**request) if sync_mode else await litellm.atext_completion(**request) + + assert error.value.status_code == 400 + assert "/v1/completions" in error.value.message + assert not route.called + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync_mode", [True, False]) +async def test_text_completion_drop_params_sends_a_text_gemini_model_no_audio_output_ask( + sync_mode: bool, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + request: Final = { + "model": "gemini/gemini-3.8-flash", + "prompt": "Say hi", + "api_key": "test-key", + "audio": _GEMINI_VOICE, + "modalities": ["text", "audio"], + "drop_params": True, + } + with respx.mock() as mock_router: + route: Final = mock_router.post(url__regex=_ANY_GEMINI_URL).mock( + return_value=httpx.Response(200, json=_GEMINI_TEXT_REPLY) + ) + response: Final = ( + litellm.text_completion(**request) if sync_mode else await litellm.atext_completion(**request) + ) + + assert response.choices[0].text == "hi" + generation_config: Final = json.loads(route.calls.last.request.content)["generationConfig"] + assert "speechConfig" not in generation_config + assert generation_config["responseModalities"] == ["TEXT"] + + def test_model_alias_map_resolves_the_outbound_model( respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch ) -> None: