mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
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:
parent
a66dc50efb
commit
ec882bee32
8 changed files with 527 additions and 5 deletions
107
litellm/litellm_core_utils/audio_output_refusal.py
Normal file
107
litellm/litellm_core_utils/audio_output_refusal.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
133
tests/unit/litellm_core_utils/test_audio_output_refusal.py
Normal file
133
tests/unit/litellm_core_utils/test_audio_output_refusal.py
Normal 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")
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue