fix(bridges): refuse audio output on Responses, Messages and Completions before the provider call

These surfaces bridge to chat completions and cannot carry audio output, so a Gemini TTS model or an audio or modalities ask returned 200 with empty output while billing audio tokens. They now answer 400 before any provider call, and drop_params strips the audio ask instead
This commit is contained in:
mateo-berri 2026-10-29 02:06:06 -07:00
parent a66dc50efb
commit ec882bee32
8 changed files with 527 additions and 5 deletions

View file

@ -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

View file

@ -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)

View file

@ -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:

View file

@ -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

View file

@ -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")

View file

@ -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"]

View file

@ -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"]

View file

@ -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: