Merge branch 'litellm_internal_staging' of https://github.com/BerriAI/litellm into litellm_gemini_maps_grounding_cost

# Conflicts:
#	litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py
This commit is contained in:
mateo-berri 2026-08-26 16:27:40 -07:00
commit dca5144dba
15 changed files with 880 additions and 107 deletions

View file

@ -3,19 +3,25 @@ Helper functions for health check calls.
"""
import base64
from collections.abc import Callable
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING, Final, Literal
from litellm.types.utils import LIST_BATCHES_SUPPORTED_PROVIDERS
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.types.utils import ImageResponse
# Minimal PDF for health checks - base64 encoded 1-page PDF with just "test"
TEST_PDF_URL = "data:application/pdf;base64,JVBERi0xLjQKJeLjz9MKMyAwIG9iago8PC9UeXBlIC9QYWdlCi9QYXJlbnQgMSAwIFIKL01lZGlhQm94IFswIDAgNjEyIDc5Ml0KL0NvbnRlbnRzIDQgMCBSCi9SZXNvdXJjZXMgPDwvRm9udCA8PC9GMSAyIDAgUj4+Pj4+PgplbmRvYmoKNCAwIG9iago8PC9MZW5ndGggNDQ+PgpzdHJlYW0KQlQKL0YxIDI0IFRmCjEwMCA3MDAgVGQKKHRlc3QpIFRqCkVUCmVuZHN0cmVhbQplbmRvYmoKMiAwIG9iago8PC9UeXBlIC9Gb250Ci9TdWJ0eXBlIC9UeXBlMQovQmFzZUZvbnQgL0hlbHZldGljYT4+CmVuZG9iagoxIDAgb2JqCjw8L1R5cGUgL1BhZ2VzCi9LaWRzIFszIDAgUl0KL0NvdW50IDE+PgplbmRvYmoKNSAwIG9iago8PC9UeXBlIC9DYXRhbG9nCi9QYWdlcyAxIDAgUj4+CmVuZG9iagp0cmFpbGVyCjw8L1NpemUgNgovUm9vdCA1IDAgUj4+CnN0YXJ0eHJlZgozMjQKJSVFT0Y="
# Minimal image for health checks - base64 encoded 512x512 solid-gray PNG
TEST_IMAGE_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAgAAAAIACAIAAAB7GkOtAAAFlklEQVR42u3VMQEAAAzCMKQjHQ97l0jo0xSAlyIBgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGACAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGACAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGACAAUgAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGACAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGACAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAADcDrctaAb6XeXAAAAAASUVORK5CYII="
# Minimal image for health checks - base64 encoded 512x512 blue circle on a white background PNG
TEST_IMAGE_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAgAAAAIACAIAAAB7GkOtAAAJk0lEQVR42u3VQREAIRADwVWCOmTjBVzwSLorCri6nbkAVBpPACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAAAgAAAIAZdY+HgEBgIRr/meeGgGA8EMvDAgAuPh6gACAi68HCAA4+mKAAICjLwYIALj7SoAAgLuvBAgA7r4pAQKAu29KgADg7psSIAA4/SYDCADuvikBAoDTbzKAAOD0mwwgADj9JgMIAE6/yQACgNNvMoAA4PSbDCAAOP0mAwgATr/JAAKA668BIAA4/TKAAOD0mwwgALj+pgEIAE6/yQACgOtvGoAA4PSbDCAAuP6mAQgATr/JAAKA628agADg9JsMIAC4/qYBCACuv2kAAoDrbxqAAOD0mwwgALj+pgEIAK6/aQACgOtvGoAA4PqbBiAArr+ZBiAATr+ZDCAArr+ZBiAArr+ZBiAArr+ZBiAArr+ZBggArr+ZBggArr+ZBggArr+ZBggArr+ZBggArr+ZBggArr+ZBggAAmAmAAKA62+mAQKA62+mAQKA62/m1xYAXH/TAAQA1980AAHA9TcNQAAEwEwAEADX30wDEADX30wDEADX30wDEADX30wDEAABMBMABMD1N9MABMD1N9MABEAAzAQAAXD9zTQAAXD9zTRAABAAMwEQAFx/Mw0QAFx/Mw0QAATATAAEANffTAMEANffTAMEAAEwEwABwPU30wABQADMBEAAXH8z0wABcP3NTAMEQADMTAAEwPU3Mw0QAAEwMwEQANffTAMQAAEwEwAEwPU30wAEQADMBAABcP3NNAABEAAzAUAAXH8zDUAABMBMABAA199MAwQAATATAAHA9TfTAAFAAMwEQABw/c00QAAQADMBEAAEwEwABMD1NzMNEAABMDMBEADX38w0QAAEwMwEQAAEwMwEQABcfzPTAAEQADMTAAEQADMTAAFw/c1MAwRAAMxMAARAAMxMAATA9TczDRAAATATAARAAMwEAAFw/c00AAEQADMBQAAEwEwAEADX30wDBAABMBMAAUAAzARAABAAMwEQAFx/Mw0QAAEwMwEQAAEwMwEQAAEwMwEQANffzDRAAATAzARAAATAzARAAATAzARAAATAzARAAFx/M9MAARAAMxMAARAAMxMAARAAMxMAARAAMxMAAXD9zUwDBEAAzEwABEAAzAQAARAAMwFAAATATAAQAAEwEwABQADMBEAAEAAzARAAXH8zDRAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAA19/MNEAANMDM9UcABMBMABAAATATAAHwBAJgJgACgACYCYAAIABmAiAA+IvMBEAABMDMBEAABMDMBEAABMDMBEAABMDMBEAABMDMBEAABMDMBEAABMDMBEAABMDMBEAABMDMBEAABMDMBEAANMDMXH8BEAAzEwABEAAzEwABEAAzEwABEAAzEwABEAAzEwABEAAzAUAABMBMABAADTBz/REAATATAAFAAMwEQAAQADMBEAAEwEwABAANMHP9BUAAzEwABEAAzEwABEAAzEwABEAAzEwABEADzMz1FwABMDMBEAABMDMBEAABMDMBEAANMDPXXwAEwMwEQAAEwMwEQAAEwMwEQAA0wMxcfwEQADMTAAEQADMBQAA0wMz1RwAEwEwAEAABMBMABEADzFx/AUAAzARAABAAMwEQADTAzPUXAATATAAEAAEwEwABQAPMXH8BEAAzEwABEAAzEwAB0AAzc/0FQADMTAAEQAPMzPUXAAEwMwEQAAEwMwEQAA0wM9dfAATAzARAADTAzFx/ARAAMwFAADTAzPVHAATATAAQAA0wc/0RAAEwEwAEQAPMXH8EQADMBAAB0AAz118AEAAzARAANMDM9RcABMBMAAQADTBz/QUAATATAAFAA8xcfwFAA8xcfwFAAMwEQADQADPXXwAEwMwEQAA0wMxcfwHQADNz/QVAAMxMAARAA8xcfwRAA8xcfwRAAMwEAAHQADPXHwHQADPXHwEQADMBQAA0wMz1RwA0wMz1RwAEwEwAEAANMHP9BQANMHP9BQANMHP9BQANMHP9BQABMBMAAUADzFx/AUADzFx/AUADzFx/AUADzFx/AUADzFx/AUADzPVHABAAEwAEAA0w1x8BQAPM9UcA0ABz/REANMBcfwQADTDXHwHQADPXHwHQADPXHwHQADPXHwHQADPXHwHQADPXHwGQATOnHwHQADPXHwHQADPXXwDQADPXXwDQADPXXwDQADPXXwCQAXP6EQA0wFx/BAANMNcfAUADzPVHAJABc/oRADTAXH8EABkwpx8BQAPM9UcAkAFz+hEANMBcfwQAGTCnHwFAA8z1RwCQAXP6EQBkwJx+BAANMNcfAUAGzOlHAJABc/oRAGTA6QcBQAacfhAAZMDpRwBABpx+BABkwOlHAEAGnH4EAJTA3UcAQAacfgQAlMDdRwBACdx9BACUwN1HAEAJ3H0EAJTA3UcAQAwcfQQAmmLgsyIA0NIDHw4BgJYe+DQIAISHwVMjAJDQDI+AAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACACAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAIAABNHpialFcmLajuAAAAAElFTkSuQmCC"
IMAGE_EDIT_HEALTH_CHECK_PROMPT: Final = (
"Add a small yellow star in the top right corner of this simple drawing of a blue circle on a white background"
)
def get_image_file_for_health_check() -> bytes:
@ -121,6 +127,17 @@ class HealthCheckHelpers:
else:
return await litellm.acompletion(**model_params)
@staticmethod
async def _image_edit_health_check(edit_request: Callable[[], Awaitable["ImageResponse"]]) -> "ImageResponse":
import litellm
try:
return await edit_request()
except litellm.BadRequestError as e:
if isinstance(e, litellm.ContentPolicyViolationError) or "moderation_blocked" in str(e):
return litellm.ImageResponse()
raise
@staticmethod
def get_mode_handlers(
model: str,
@ -195,10 +212,12 @@ class HealthCheckHelpers:
**_filter_model_params(model_params=model_params),
prompt=prompt,
),
"image_edit": lambda: litellm.aimage_edit(
**_filter_model_params(model_params=model_params),
image=get_image_file_for_health_check(),
prompt=prompt or "test",
"image_edit": lambda: HealthCheckHelpers._image_edit_health_check(
edit_request=lambda: litellm.aimage_edit(
**_filter_model_params(model_params=model_params),
image=get_image_file_for_health_check(),
prompt=IMAGE_EDIT_HEALTH_CHECK_PROMPT,
),
),
"video_generation": lambda: litellm.avideo_generation(
**_filter_model_params(model_params=model_params),

View file

@ -2,16 +2,17 @@
Translates from OpenAI's `/v1/chat/completions` to DeepSeek's `/v1/chat/completions`
"""
from collections.abc import Coroutine
from collections.abc import Coroutine, Mapping, Sequence
from typing import Any, Final, Literal, cast, overload
import litellm
from litellm.litellm_core_utils.prompt_templates.common_utils import (
handle_messages_with_content_list_to_str_conversion,
convert_content_list_to_str,
extract_search_results_text,
)
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.openai import AllMessageValues
from litellm.utils import supports_reasoning
from litellm.utils import supports_reasoning, supports_vision
from ...openai.chat.gpt_transformation import OpenAIGPTConfig
@ -117,13 +118,98 @@ class DeepSeekChatConfig(OpenAIGPTConfig):
self, messages: list[AllMessageValues], model: str, is_async: bool = False
) -> list[AllMessageValues] | Coroutine[Any, Any, list[AllMessageValues]]:
"""
DeepSeek does not support content in list format.
DeepSeek vision models accept image_url content blocks in user
messages (https://api-docs.deepseek.com/guides/vision), so those
content lists are forwarded as-is, with any search_results text
appended as a trailing text block. Every other message keeps the
historical string collapse (which also folds search_results text
into string content); a list with no extractable text stays
unchanged, matching what DeepSeek historically received.
"""
messages = handle_messages_with_content_list_to_str_conversion(messages)
forward_images: Final = any(
isinstance(message.get("content"), list) for message in messages
) and supports_vision(model=model, custom_llm_provider="deepseek")
transformed: Final = [ # mutable-ok: provider messages must stay JSON-array lists the base transform mutates
self._forward_or_collapse_content(message=message, forward_images=forward_images) for message in messages
]
if is_async:
return super()._transform_messages(messages=messages, model=model, is_async=True)
return super()._transform_messages(messages=transformed, model=model, is_async=True)
else:
return super()._transform_messages(messages=messages, model=model, is_async=False)
return super()._transform_messages(messages=transformed, model=model, is_async=False)
def _forward_or_collapse_content(self, message: AllMessageValues, forward_images: bool) -> AllMessageValues:
"""
Returns the vision-forwardable message with any search_results text
appended as a text block; every other message keeps the historical
string collapse, which extracts the text from a content list and
folds search_results text into string content.
"""
content: Final = message.get("content")
if (
forward_images
and isinstance(content, list)
and self._is_vision_forwardable_content(message=message, content=content)
):
return self._with_search_results_text_block(message=message, content=content)
collapsed: Final = convert_content_list_to_str(message=message)
if not collapsed or collapsed == content:
return message
collapsed_message: Final = {**message, "content": collapsed} # mutable-ok: wire messages are plain JSON dicts
return cast(AllMessageValues, collapsed_message) # cast-ok: TypedDict spread narrows to dict
def _is_vision_forwardable_content(self, message: AllMessageValues, content: Sequence[object]) -> bool:
"""
True only for a user message whose content list holds well-formed
text and image_url blocks with at least one image; a block missing
its payload falls back to the string collapse instead of crashing
or reaching the wire malformed. The model capability gate lives in
the caller.
"""
if message.get("role") != "user":
return False
if not all(self._is_forwardable_block(block) for block in content):
return False
return any(isinstance(block, dict) and block.get("type") == "image_url" for block in content)
@staticmethod
def _is_forwardable_block(block: object) -> bool:
"""A dict block typed text or image_url that carries its payload."""
if not isinstance(block, dict):
return False
block_type: Final = block.get("type")
if block_type == "image_url":
return DeepSeekChatConfig._is_image_url_payload(block.get("image_url"))
if block_type == "text":
return isinstance(block.get("text"), str)
return False
@staticmethod
def _is_image_url_payload(payload: object) -> bool:
"""A url string or an object carrying one, per the OpenAI image_url shape."""
if isinstance(payload, str):
return bool(payload)
if not isinstance(payload, Mapping):
return False
url: Final = payload.get("url")
return isinstance(url, str) and bool(url)
def _with_search_results_text_block(self, message: AllMessageValues, content: Sequence[object]) -> AllMessageValues:
"""
Appends the message's search_results text as a trailing text block,
keeping the context that the string collapse used to fold in, and
drops the non-OpenAI search_results key from the wire message.
"""
message_fields: Final = cast(Mapping[str, object], message) # cast-ok: search_results is not on the TypedDicts
search_text: Final = extract_search_results_text(message_fields.get("search_results"))
if not search_text:
return message
forwarded_content: Final = [*content, {"type": "text", "text": search_text}] # mutable-ok: JSON-array content
forwarded: Final = { # mutable-ok: wire messages are plain JSON dicts
**{key: value for key, value in message_fields.items() if key != "search_results"},
"content": forwarded_content,
}
return cast(AllMessageValues, forwarded) # cast-ok: TypedDict spread narrows to dict
def _thinking_mode_active(self, model: str, optional_params: dict) -> bool:
"""

View file

@ -13,6 +13,13 @@ class FireworksAIException(BaseLLMException):
def get_fireworks_session_id(litellm_params: dict) -> str | None:
"""
Session id to send as `x-session-affinity`, or None when the caller gave none.
Deliberately does not fall back to `litellm_trace_id`: that is generated per
request (`str(uuid.uuid4())` when absent), so using it pins every request to a
different Fireworks node and prompt caching never hits.
"""
params: Final = litellm_params
for key in ("litellm_session_id", "session_id"):
value = params.get(key)
@ -23,9 +30,6 @@ def get_fireworks_session_id(litellm_params: dict) -> str | None:
value = metadata.get("session_id")
if value:
return str(value)
value = params.get("litellm_trace_id")
if value:
return str(value)
return None

View file

@ -4,6 +4,7 @@ This file contains the transformation logic for the Gemini realtime API.
import json
from collections import OrderedDict
from collections.abc import Mapping
from typing import Any, Final, cast
import litellm
@ -72,6 +73,28 @@ MAP_GEMINI_FIELD_TO_OPENAI_EVENT: Final[dict[str, OpenAIRealtimeEventTypes | Res
_KNOWN_GEMINI_TOP_LEVEL_KEYS: Final[set] = {map_key.split(".", 1)[0] for map_key in MAP_GEMINI_FIELD_TO_OPENAI_EVENT}
OPENAI_STOCK_REALTIME_VOICES: Final[frozenset[str]] = frozenset(
{"alloy", "ash", "ballad", "cedar", "coral", "echo", "marin", "sage", "shimmer", "verse"}
)
def _gemini_live_speech_config(voice: object) -> Mapping[str, object] | None:
"""Build the Gemini Live speechConfig for a client-requested voice.
OpenAI stock voice names have no Gemini equivalent and Gemini Live closes
the session on an unknown voice, so they are dropped with a warning and
the model keeps its default voice. Every other name is forwarded verbatim.
"""
if isinstance(voice, str) and voice.lower() in OPENAI_STOCK_REALTIME_VOICES:
verbose_logger.warning(
"Gemini Realtime: voice %s is an OpenAI voice with no Gemini equivalent; "
"dropping it so the session keeps the model's default voice.",
voice,
)
return None
return VertexGeminiConfig()._map_audio_params({"voice": voice})
class GeminiRealtimeConfig(BaseRealtimeConfig):
_TOOL_CALL_ID_TO_NAME_MAX = 256 # LRU cap for call_id→name mapping
@ -282,12 +305,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
automaticActivityDetection=transformed_audio_activity_config
)
elif key == "voice":
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
VertexGeminiConfig,
)
vertex_gemini_config = VertexGeminiConfig()
speech_config = vertex_gemini_config._map_audio_params({"voice": value})
speech_config = _gemini_live_speech_config(value)
if speech_config:
optional_params["generationConfig"]["speechConfig"] = speech_config
if len(optional_params["generationConfig"]) == 0:
@ -365,10 +383,6 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
entry: Final = GeminiRealtimeConfig._model_cost_entry(model)
return bool(entry.get("gemini_native_audio") or entry.get("gemini_audio_only_live"))
@staticmethod
def _is_native_audio_model(model: str) -> bool:
return bool(GeminiRealtimeConfig._model_cost_entry(model).get("gemini_native_audio"))
@staticmethod
def _coerce_response_modalities(model: str, modalities: list[Any]) -> list[str]:
"""Map unsupported TEXT responseModalities to AUDIO for audio-only Live models."""
@ -384,7 +398,6 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
@staticmethod
def _finalize_gemini_live_setup(model: str, setup: dict[str, Any]) -> dict[str, Any]:
"""Drop fields Gemini Live native-audio rejects on ``setup``."""
generation_config: Final = setup.get("generationConfig")
if isinstance(generation_config, dict):
modalities: Final = generation_config.get("responseModalities")
@ -392,8 +405,6 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
generation_config["responseModalities"] = GeminiRealtimeConfig._coerce_response_modalities(
model, modalities
)
if GeminiRealtimeConfig._is_native_audio_model(model):
generation_config.pop("speechConfig", None)
return setup
def _handle_session_update(

View file

@ -19,31 +19,38 @@ def _chunk_kinds(item: Mapping[str, object]) -> frozenset[str]:
return frozenset(kind for chunk in chunks if isinstance(chunk, Mapping) for kind in chunk)
def _query_count(item: Mapping[str, object]) -> int:
def _queries(item: Mapping[str, object]) -> frozenset[str]:
queries: Final = item.get("webSearchQueries")
if not isinstance(queries, list):
return 0
return len([query for query in queries if query])
return frozenset()
return frozenset(query for query in queries if isinstance(query, str) and query)
def grounding_item_requests(item: Mapping[str, object]) -> GroundingRequests:
kinds: Final = _chunk_kinds(item)
queries: Final = _query_count(item)
if "maps" not in kinds and not item.get("googleMapsWidgetContextToken"):
return GroundingRequests(web_search_requests=queries or None, google_maps_grounding_requests=None)
if "web" in kinds:
return GroundingRequests(web_search_requests=queries or None, google_maps_grounding_requests=1)
return GroundingRequests(web_search_requests=None, google_maps_grounding_requests=max(queries, 1))
def _is_maps_item(item: Mapping[str, object]) -> bool:
return "maps" in _chunk_kinds(item) or bool(item.get("googleMapsWidgetContextToken"))
def _total(counts: Sequence[int | None]) -> int | None:
present: Final = tuple(count for count in counts if count is not None)
return sum(present) if present else None
def _attributes_queries_to_maps(item: Mapping[str, object]) -> bool:
return _is_maps_item(item) and "web" not in _chunk_kinds(item)
def calculate_grounding_requests(grounding_metadata: Sequence[Mapping[str, object]]) -> GroundingRequests:
per_item: Final = tuple(grounding_item_requests(item) for item in grounding_metadata if isinstance(item, Mapping))
return GroundingRequests(
web_search_requests=_total(tuple(item.web_search_requests for item in per_item)),
google_maps_grounding_requests=_total(tuple(item.google_maps_grounding_requests for item in per_item)),
"""Billable grounding requests across candidates, counting each distinct query once.
Duplicate queries within and across grounding metadata items collapse to the
distinct-query count (#36377), and empty strings are ignored. Maps grounding is
floored at one request whenever a candidate carries maps chunks or a widget token,
since per-prompt billing charges the prompt even when no query is reported.
"""
items: Final = tuple(item for item in grounding_metadata if isinstance(item, Mapping))
web_queries: Final = frozenset(
query for item in items if not _attributes_queries_to_maps(item) for query in _queries(item)
)
maps_queries: Final = frozenset(
query for item in items if _attributes_queries_to_maps(item) for query in _queries(item)
)
has_maps: Final = any(_is_maps_item(item) for item in items)
return GroundingRequests(
web_search_requests=len(web_queries) or None,
google_maps_grounding_requests=max(len(maps_queries), 1) if has_maps else None,
)

View file

@ -50570,6 +50570,32 @@
"supports_tool_choice": true,
"supports_vision": false
},
"deepseek-v4-flash-vision-exp": {
"cache_creation_input_token_cost": 0.0,
"cache_read_input_token_cost": 1.4e-08,
"input_cost_per_token": 4.4e-07,
"input_cost_per_token_cache_hit": 1.4e-08,
"litellm_provider": "deepseek",
"max_input_tokens": 1000000,
"max_output_tokens": 393216,
"max_tokens": 393216,
"mode": "chat",
"output_cost_per_token": 1.32e-06,
"source": "https://api-docs.deepseek.com/quick_start/pricing",
"supported_endpoints": [
"/v1/chat/completions"
],
"supports_assistant_prefill": true,
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true
},
"deepseek-v4-pro": {
"cache_creation_input_token_cost": 0.0,
"cache_read_input_token_cost": 4.4e-08,
@ -50622,6 +50648,32 @@
"supports_tool_choice": true,
"supports_vision": false
},
"deepseek/deepseek-v4-flash-vision-exp": {
"cache_creation_input_token_cost": 0.0,
"cache_read_input_token_cost": 1.4e-08,
"input_cost_per_token": 4.4e-07,
"input_cost_per_token_cache_hit": 1.4e-08,
"litellm_provider": "deepseek",
"max_input_tokens": 1000000,
"max_output_tokens": 393216,
"max_tokens": 393216,
"mode": "chat",
"output_cost_per_token": 1.32e-06,
"source": "https://api-docs.deepseek.com/quick_start/pricing",
"supported_endpoints": [
"/v1/chat/completions"
],
"supports_assistant_prefill": true,
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true
},
"deepseek/deepseek-v4-pro": {
"cache_creation_input_token_cost": 0.0,
"cache_read_input_token_cost": 4.4e-08,

View file

@ -50570,6 +50570,32 @@
"supports_tool_choice": true,
"supports_vision": false
},
"deepseek-v4-flash-vision-exp": {
"cache_creation_input_token_cost": 0.0,
"cache_read_input_token_cost": 1.4e-08,
"input_cost_per_token": 4.4e-07,
"input_cost_per_token_cache_hit": 1.4e-08,
"litellm_provider": "deepseek",
"max_input_tokens": 1000000,
"max_output_tokens": 393216,
"max_tokens": 393216,
"mode": "chat",
"output_cost_per_token": 1.32e-06,
"source": "https://api-docs.deepseek.com/quick_start/pricing",
"supported_endpoints": [
"/v1/chat/completions"
],
"supports_assistant_prefill": true,
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true
},
"deepseek-v4-pro": {
"cache_creation_input_token_cost": 0.0,
"cache_read_input_token_cost": 4.4e-08,
@ -50622,6 +50648,32 @@
"supports_tool_choice": true,
"supports_vision": false
},
"deepseek/deepseek-v4-flash-vision-exp": {
"cache_creation_input_token_cost": 0.0,
"cache_read_input_token_cost": 1.4e-08,
"input_cost_per_token": 4.4e-07,
"input_cost_per_token_cache_hit": 1.4e-08,
"litellm_provider": "deepseek",
"max_input_tokens": 1000000,
"max_output_tokens": 393216,
"max_tokens": 393216,
"mode": "chat",
"output_cost_per_token": 1.32e-06,
"source": "https://api-docs.deepseek.com/quick_start/pricing",
"supported_endpoints": [
"/v1/chat/completions"
],
"supports_assistant_prefill": true,
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true
},
"deepseek/deepseek-v4-pro": {
"cache_creation_input_token_cost": 0.0,
"cache_read_input_token_cost": 4.4e-08,

View file

@ -55,6 +55,40 @@
# never fires and a subprocess still reads the real keys the test believes it
# cleared. The manual restore underneath is skipped whenever the body raises,
# so every later test in that worker inherits a plain dict for an environment
# PGH005 an assertion on a mock attribute the library never defines. `assert
# m.called_once` and a bare `m.assert_called_once` both read as checks and
# neither is one: a Mock invents whatever attribute it is asked for, so the
# first is always truthy and the second is an attribute nobody calls
# F631 `assert (cond, "message")` asserts a two-element tuple, which is always
# truthy. The message meant to explain the failure is what stops the assertion
# from ever having one
# F634 `if (a, b):` branches on a tuple, so the branch is always taken and the
# condition it was written to test is never evaluated
# PT010 `pytest.raises()` with no exception type accepts anything the block raises,
# including the TypeError a refactor introduced
# PT030 the `pytest.warns` twin of PT011. `Warning` or `UserWarning` with no `match=`
# passes on any warning that broad
# PT031 the `pytest.warns` twin of PT012. Everything after the warning call is dead,
# so an `assert` sitting there is never checked
# B012 a `return`, `break` or `continue` inside `finally` discards whatever exception
# was in flight, so the AssertionError the test just raised is thrown away and
# the test reports green
# B013 a one-element tuple where the exception class was meant, which reads as a
# wider handler than it is
# B014 an exception named twice in one handler, or a subclass beside its parent. The
# second name does nothing, and it is usually the one someone meant to change
# B016 `raise "message"` raises a str, so the failure the test set up is replaced by
# a TypeError from the raise itself
# B022 `contextlib.suppress()` with no arguments suppresses nothing, so the call it
# wraps still raises
# B029 `except ():` catches nothing, so the recovery or skip written in that handler
# never happens
# B030 an `except` naming something that is not an exception class raises TypeError
# while unwinding, replacing the error under test
# F707 a bare `except:` ahead of another handler makes every handler below it
# unreachable
# PLE0704 a bare `raise` outside an except block raises RuntimeError instead of
# re-raising anything
#
# No target-version here on purpose: it resolves from requires-python (>=3.10), so
# 3.11-only builtins like BaseExceptionGroup are correctly flagged in a tree that
@ -83,4 +117,19 @@ lint.select = [
"B025",
"F632",
"B003",
"PGH005",
"F631",
"F634",
"PT010",
"PT030",
"PT031",
"B012",
"B013",
"B014",
"B016",
"B022",
"B029",
"B030",
"F707",
"PLE0704",
]

View file

@ -1,19 +1,41 @@
"""Test health check helper functions"""
import struct
import zlib
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import litellm
from litellm.constants import LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME
from litellm.litellm_core_utils.health_check_helpers import HealthCheckHelpers
from litellm.litellm_core_utils.health_check_helpers import (
IMAGE_EDIT_HEALTH_CHECK_PROMPT,
HealthCheckHelpers,
)
from litellm.main import ahealth_check
from litellm.proxy._types import UserAPIKeyAuth
from litellm.types.utils import LIST_BATCHES_SUPPORTED_PROVIDERS
def _png_chunks(png: bytes, offset: int = 8) -> tuple[tuple[bytes, bytes], ...]:
if offset >= len(png):
return ()
(length,) = struct.unpack(">I", png[offset : offset + 4])
chunk = (png[offset + 4 : offset + 8], png[offset + 8 : offset + 8 + length])
return (chunk, *_png_chunks(png, offset + 12 + length))
def _distinct_rgb_colors(png: bytes) -> set[bytes]:
width = int.from_bytes(png[16:20], "big")
raw = zlib.decompress(b"".join(data for tag, data in _png_chunks(png) if tag == b"IDAT"))
row_size = 1 + width * 3
rows = tuple(raw[i : i + row_size] for i in range(0, len(raw), row_size))
assert all(row[0] == 0 for row in rows)
return {bytes(row[i : i + 3]) for row in rows for i in range(1, row_size, 3)}
@pytest.mark.asyncio
async def test_image_edit_health_check_handler_uses_png_and_prompt():
async def test_image_edit_health_check_handler_uses_descriptive_prompt_and_multicolor_png():
model_params = {"model": "openai/gpt-image-1", "api_key": "sk-test"}
mode_handlers = HealthCheckHelpers.get_mode_handlers(
model="gpt-image-1",
@ -31,20 +53,76 @@ async def test_image_edit_health_check_handler_uses_png_and_prompt():
model="gpt-image-1",
custom_llm_provider="openai",
model_params=model_params,
prompt="edit this image",
prompt="test from litellm",
)["image_edit"]()
assert mock_aimage_edit.call_count == 2
default_call = mock_aimage_edit.call_args_list[0].kwargs
explicit_call = mock_aimage_edit.call_args_list[1].kwargs
assert default_call["model"] == "openai/gpt-image-1"
assert default_call["prompt"] == "test"
assert explicit_call["prompt"] == "edit this image"
image = default_call["image"]
for handler_call in mock_aimage_edit.call_args_list:
assert handler_call.kwargs["model"] == "openai/gpt-image-1"
assert handler_call.kwargs["prompt"] == IMAGE_EDIT_HEALTH_CHECK_PROMPT
image = mock_aimage_edit.call_args_list[0].kwargs["image"]
assert isinstance(image, bytes)
assert image.startswith(b"\x89PNG")
assert int.from_bytes(image[16:20], "big") == 512
assert int.from_bytes(image[20:24], "big") == 512
assert len(_distinct_rgb_colors(image)) >= 2
@pytest.mark.asyncio
async def test_ahealth_check_image_edit_treats_content_policy_violation_as_healthy():
moderation_error = litellm.ContentPolicyViolationError(
message="Your request was rejected as a result of our safety system.",
model="gpt-image-1",
llm_provider="openai",
)
with patch( # test-quality-ok: the public health-check path has no dependency injection seam
"litellm.aimage_edit", new_callable=AsyncMock, side_effect=moderation_error
):
result = await ahealth_check(
{"model": "gpt-image-1", "api_key": "sk-test"},
mode="image_edit",
)
assert "error" not in result
@pytest.mark.asyncio
async def test_ahealth_check_image_edit_treats_moderation_blocked_code_as_healthy():
moderation_blocked = litellm.BadRequestError(
message=(
'{"error": {"code": "moderation_blocked", "message": "Your request was blocked", '
'"moderation_stage": "output", "type": "invalid_request_error"}}'
),
model="gpt-image-1",
llm_provider="openai",
)
with patch( # test-quality-ok: the public health-check path has no dependency injection seam
"litellm.aimage_edit", new_callable=AsyncMock, side_effect=moderation_blocked
):
result = await ahealth_check(
{"model": "gpt-image-1", "api_key": "sk-test"},
mode="image_edit",
)
assert "error" not in result
@pytest.mark.asyncio
async def test_ahealth_check_image_edit_still_fails_on_non_moderation_errors():
auth_error = litellm.AuthenticationError(
message="Incorrect API key provided",
llm_provider="openai",
model="gpt-image-1",
)
with patch( # test-quality-ok: the public health-check path has no dependency injection seam
"litellm.aimage_edit", new_callable=AsyncMock, side_effect=auth_error
):
result = await ahealth_check(
{"model": "gpt-image-1", "api_key": "sk-bad"},
mode="image_edit",
)
assert "error" in result
@pytest.mark.asyncio
@ -88,9 +166,7 @@ def test_update_model_params_with_health_check_tracking_information():
# Verify that litellm_metadata was added
assert "litellm_metadata" in result
assert result["litellm_metadata"]["tags"] == [
LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME
]
assert result["litellm_metadata"]["tags"] == [LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME]
# Verify the auth setup was called
mock_add_auth.assert_called_once()
@ -169,16 +245,12 @@ async def test_ahealth_check_failure_masks_raw_request_headers():
if "Authorization" in headers:
auth_header = headers["Authorization"]
# Should be masked (e.g., "Be****90" or similar)
assert (
auth_header != f"Bearer {test_api_key}"
), "Authorization header must be masked"
assert (
auth_header != test_api_key
), "API key must not appear in Authorization header"
assert auth_header != f"Bearer {test_api_key}", "Authorization header must be masked"
assert auth_header != test_api_key, "API key must not appear in Authorization header"
# Masked headers typically have asterisks or are truncated
assert "*" in auth_header or len(auth_header) < len(
f"Bearer {test_api_key}"
), f"Authorization header should be masked but got: {auth_header}"
assert "*" in auth_header or len(auth_header) < len(f"Bearer {test_api_key}"), (
f"Authorization header should be masked but got: {auth_header}"
)
# Content-Type should remain unmasked (not sensitive)
if "Content-Type" in headers:
@ -257,9 +329,7 @@ async def test_batch_health_check_skips_bridge_when_no_logging_obj():
"litellm_metadata": litellm_metadata,
}
with patch(
"litellm.alist_batches", new_callable=AsyncMock, return_value={}
) as mock_alist:
with patch("litellm.alist_batches", new_callable=AsyncMock, return_value={}) as mock_alist:
await HealthCheckHelpers._batch_health_check(
custom_llm_provider="openai",
model_params={"model": "openai/gpt-4"},
@ -283,9 +353,7 @@ async def test_batch_health_check_uses_alist_batches_for_supported_providers():
"litellm_metadata": litellm_metadata,
}
with patch(
"litellm.alist_batches", new_callable=AsyncMock, return_value={}
) as mock_alist:
with patch("litellm.alist_batches", new_callable=AsyncMock, return_value={}) as mock_alist:
await HealthCheckHelpers._batch_health_check(
custom_llm_provider=provider,
model_params={"model": f"{provider}/some-model"},
@ -344,9 +412,7 @@ async def test_realtime_health_check_uses_model_level_vertex_params():
fake_vertex_base = MagicMock()
fake_vertex_base.get_vertex_region = MagicMock(return_value="us-central1")
fake_vertex_base._ensure_access_token_async = AsyncMock(
return_value=("model-level-token", "model-level-project")
)
fake_vertex_base._ensure_access_token_async = AsyncMock(return_value=("model-level-token", "model-level-project"))
connect_calls = []
with (
@ -381,8 +447,7 @@ async def test_realtime_health_check_uses_model_level_vertex_params():
custom_llm_provider="vertex_ai",
)
assert connect_calls[0]["url"] == (
"wss://us-central1-aiplatform.googleapis.com/ws/"
"google.cloud.aiplatform.v1.LlmBidiService/BidiGenerateContent"
"wss://us-central1-aiplatform.googleapis.com/ws/google.cloud.aiplatform.v1.LlmBidiService/BidiGenerateContent"
)
assert connect_calls[0]["additional_headers"] == {
"Authorization": "Bearer model-level-token",

View file

@ -1,3 +1,4 @@
import litellm
from litellm.llms.deepseek.chat.transformation import DeepSeekChatConfig
@ -108,6 +109,284 @@ def test_thinking_mode_active_bool_thinking_returns_false_without_crashing():
assert config._thinking_mode_active(model="deepseek-reasoner", optional_params={"thinking": True}) is False
class TestDeepSeekVisionMultimodalContent:
"""Image content lists are forwarded only for user messages on vision models."""
VISION_MODEL = "deepseek/deepseek-v4-flash-vision-exp"
NON_VISION_MODEL = "deepseek/deepseek-chat"
def setup_method(self):
self.config = DeepSeekChatConfig()
prior_entry = litellm.model_cost.get(self.VISION_MODEL)
self._prior_registry_entry = dict(prior_entry) if prior_entry is not None else None
litellm.register_model(
{
"deepseek/deepseek-v4-flash-vision-exp": {
"litellm_provider": "deepseek",
"mode": "chat",
"input_cost_per_token": 4.4e-07,
"output_cost_per_token": 1.32e-06,
"supports_vision": True,
}
}
)
def teardown_method(self):
if self._prior_registry_entry is None:
litellm.model_cost.pop(self.VISION_MODEL, None)
else:
litellm.model_cost[self.VISION_MODEL] = self._prior_registry_entry
@staticmethod
def _image_message(role="user"):
return {
"role": role,
"content": [
{"type": "text", "text": "what is in this image?"},
{
"type": "image_url",
"image_url": {"url": "https://example.com/image.jpg", "detail": "auto"},
},
],
}
def test_user_image_list_forwarded_on_vision_model(self):
result = self.config._transform_messages([self._image_message()], model=self.VISION_MODEL)
assert isinstance(result[0]["content"], list)
assert result[0]["content"][0]["type"] == "text"
assert result[0]["content"][1]["type"] == "image_url"
assert result[0]["content"][1]["image_url"]["url"] == "https://example.com/image.jpg"
def test_image_list_collapsed_on_non_vision_model(self):
result = self.config._transform_messages([self._image_message()], model=self.NON_VISION_MODEL)
assert result[0]["content"] == "what is in this image?"
def test_image_list_collapsed_on_non_user_roles_even_on_vision_model(self):
for role in ("assistant", "system"):
result = self.config._transform_messages([self._image_message(role=role)], model=self.VISION_MODEL)
assert result[0]["content"] == "what is in this image?"
def test_audio_block_collapsed_even_on_vision_model(self):
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "transcribe this"},
{"type": "input_audio", "input_audio": {"data": "UklGRg==", "format": "wav"}},
],
}
]
result = self.config._transform_messages(messages, model=self.VISION_MODEL)
assert result[0]["content"] == "transcribe this"
def test_typeless_image_block_collapses(self):
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "what is this"},
{"image_url": {"url": "https://example.com/image.jpg"}},
],
}
]
result = self.config._transform_messages(messages, model=self.VISION_MODEL)
assert result[0]["content"] == "what is this"
def test_text_only_content_list_collapses(self):
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "Hello "},
{"type": "text", "text": "world"},
],
}
]
result = self.config._transform_messages(messages, model=self.VISION_MODEL)
assert isinstance(result[0]["content"], str)
assert result[0]["content"] == "Hello world"
def test_search_results_text_appended_on_forwarded_message(self):
message = self._image_message()
message["search_results"] = [{"source": "kb", "content": [{"text": "article body"}]}]
result = self.config._transform_messages([message], model=self.VISION_MODEL)
content = result[0]["content"]
assert isinstance(content, list)
assert content[-1] == {"type": "text", "text": "kbarticle body"}
assert any(block.get("type") == "image_url" for block in content)
assert "search_results" not in result[0]
def test_search_results_text_kept_on_collapse(self):
messages = [
{
"role": "user",
"content": [{"type": "text", "text": "context: "}],
"search_results": [{"source": "kb", "content": [{"text": "article body"}]}],
}
]
result = self.config._transform_messages(messages, model=self.NON_VISION_MODEL)
assert result[0]["content"] == "context: kbarticle body"
def test_responses_shape_blocks_collapse_even_on_vision_model(self):
messages = [
{
"role": "user",
"content": [
{"type": "input_text", "text": "what is this?"},
{"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}},
],
}
]
result = self.config._transform_messages(messages, model=self.VISION_MODEL)
assert result[0]["content"] == "what is this?"
def test_image_block_missing_payload_collapses(self):
messages = [
{
"role": "user",
"content": [{"type": "text", "text": "hi"}, {"type": "image_url"}],
}
]
result = self.config._transform_messages(messages, model=self.VISION_MODEL)
assert result[0]["content"] == "hi"
def test_image_block_empty_payload_object_collapses(self):
for payload in ({}, {"url": ""}, {"detail": "auto"}, None, 42):
messages = [
{
"role": "user",
"content": [{"type": "text", "text": "hi"}, {"type": "image_url", "image_url": payload}],
}
]
result = self.config._transform_messages(messages, model=self.VISION_MODEL)
assert result[0]["content"] == "hi"
def test_image_block_string_payload_forwarded(self):
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "what is this?"},
{"type": "image_url", "image_url": "https://example.com/image.jpg"},
],
}
]
result = self.config._transform_messages(messages, model=self.VISION_MODEL)
content = result[0]["content"]
assert isinstance(content, list)
assert content[1]["image_url"] == {"url": "https://example.com/image.jpg"}
def test_text_block_missing_text_field_collapses(self):
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "hi"},
{"type": "text"},
{"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}},
],
}
]
result = self.config._transform_messages(messages, model=self.VISION_MODEL)
assert result[0]["content"] == "hi"
def test_string_content_search_results_folded_into_string(self):
messages = [
{
"role": "tool",
"tool_call_id": "call_1",
"content": "summarize the docs",
"search_results": [{"source": "kb", "content": [{"text": "article body"}]}],
}
]
result = self.config._transform_messages(messages, model=self.NON_VISION_MODEL)
assert result[0]["content"] == "summarize the docskbarticle body"
def test_plain_string_content_message_unchanged(self):
messages = [{"role": "user", "content": "hello"}]
result = self.config._transform_messages(messages, model=self.VISION_MODEL)
assert result[0] is messages[0]
def test_empty_content_list_untouched(self):
messages = [{"role": "user", "content": []}]
result = self.config._transform_messages(messages, model=self.NON_VISION_MODEL)
assert result[0]["content"] == []
def test_later_messages_still_collapsed_after_forwarded_one(self):
messages = [
self._image_message(),
{
"role": "user",
"content": [
{"type": "text", "text": "and "},
{"type": "text", "text": "then?"},
],
},
self._image_message(),
]
result = self.config._transform_messages(messages, model=self.VISION_MODEL)
assert isinstance(result[0]["content"], list)
assert result[1]["content"] == "and then?"
assert isinstance(result[2]["content"], list)
def test_transform_request_preserves_image_url_block(self):
body = self.config.transform_request(
model=self.VISION_MODEL,
messages=[self._image_message()],
optional_params={},
litellm_params={},
headers={},
)
content = body["messages"][0]["content"]
assert isinstance(content, list)
assert any(block.get("type") == "image_url" for block in content)
async def test_async_transform_request_preserves_image_url_block(self):
body = await self.config.async_transform_request(
model=self.VISION_MODEL,
messages=[self._image_message()],
optional_params={},
litellm_params={},
headers={},
)
content = body["messages"][0]["content"]
assert isinstance(content, list)
assert any(block.get("type") == "image_url" for block in content)
class TestDeepSeekThinkingParams:
"""Test thinking and reasoning_effort parameter handling for DeepSeek."""
@ -282,8 +561,6 @@ class TestDeepSeekThinkingParams:
result = self.config._drop_unsupported_tools(optional_params)
assert result["tools"] == [
{"type": "function", "function": {"name": "get_weather"}}
]
assert result["tools"] == [{"type": "function", "function": {"name": "get_weather"}}]
assert "tool_choice" not in result
assert result["parallel_tool_calls"] is True

View file

@ -73,7 +73,13 @@ def test_validate_environment_sets_session_affinity_from_session_id():
assert headers["x-session-affinity"] == "session-id-123"
def test_validate_environment_sets_session_affinity_from_trace_id():
def test_validate_environment_ignores_trace_id_for_session_affinity():
"""A trace id must not become the session id.
litellm_trace_id defaults to a fresh uuid4 per request, so pinning
x-session-affinity to it sent every request to a different Fireworks node and
prompt caching never hit (cached_tokens stayed 0 across identical prompts).
"""
config = FireworksAIConfig()
headers = config.validate_environment(
@ -85,7 +91,25 @@ def test_validate_environment_sets_session_affinity_from_trace_id():
api_key="test-key",
)
assert headers["x-session-affinity"] == "trace-id-123"
assert "x-session-affinity" not in headers
def test_validate_environment_prefers_session_id_over_trace_id():
config = FireworksAIConfig()
headers = config.validate_environment(
headers={},
model="accounts/fireworks/models/test-model",
messages=[],
optional_params={},
litellm_params={
"litellm_session_id": "session-123",
"litellm_trace_id": "trace-id-123",
},
api_key="test-key",
)
assert headers["x-session-affinity"] == "session-123"
def test_validate_environment_does_not_set_session_affinity_without_session_id():

View file

@ -1298,8 +1298,7 @@ def test_gemini_realtime_pipecat_ga_session_voice_and_tools(patch_gemini_audio_c
assert len(messages) == 1
setup = json.loads(messages[0])["setup"]
assert setup["generationConfig"]["responseModalities"] == ["AUDIO"]
# Native-audio Live rejects speechConfig on setup (see _finalize_gemini_live_setup).
assert "speechConfig" not in setup.get("generationConfig", {})
assert setup["generationConfig"]["speechConfig"]["voiceConfig"]["prebuiltVoiceConfig"]["voiceName"] == "Kore"
assert setup["tools"][0]["function_declarations"][0]["name"] == "terminate_call"
assert setup["realtimeInputConfig"]["automaticActivityDetection"]["disabled"] is False
@ -1843,20 +1842,6 @@ def test_is_audio_only_live_model_uses_cost_map(model, expected, patch_gemini_au
assert GeminiRealtimeConfig._is_audio_only_live_model(model) == expected
@pytest.mark.parametrize(
"model,expected",
[
("gemini-2.5-flash-native-audio-latest", True),
("gemini/gemini-2.5-flash-native-audio-latest", True),
("gemini-3.1-flash-live-preview", False),
("gemini/gemini-3.1-flash-live-preview", False),
("gemini-2.0-flash", False),
],
)
def test_is_native_audio_model_uses_cost_map(model, expected, patch_gemini_audio_cost_map_entries):
assert GeminiRealtimeConfig._is_native_audio_model(model) == expected
def test_is_setup_message_and_is_content_message():
config = GeminiRealtimeConfig()
assert config.is_setup_message({"setup": {}}) is True
@ -1865,3 +1850,17 @@ def test_is_setup_message_and_is_content_message():
assert config.is_content_message({"clientContent": {}}) is True
assert config.is_content_message({"toolResponse": {}}) is True
assert config.is_content_message({"setup": {}}) is False
def test_map_openai_params_drops_stock_voice_case_insensitively():
"""Regression: OpenAI stock voices are dropped regardless of casing so Gemini Live keeps its default voice.
Non-OpenAI names pass through verbatim.
"""
cfg = GeminiRealtimeConfig()
dropped = cfg.map_openai_params(optional_params={}, non_default_params={"voice": "Alloy"})
assert "speechConfig" not in dropped.get("generationConfig", {})
passthrough = cfg.map_openai_params(optional_params={}, non_default_params={"voice": "Kore"})
assert passthrough["generationConfig"]["speechConfig"]["voiceConfig"]["prebuiltVoiceConfig"]["voiceName"] == "Kore"

View file

@ -68,7 +68,7 @@ def test_url_context_grounding_chunks_without_queries_count_nothing():
assert result == GroundingRequests(web_search_requests=None, google_maps_grounding_requests=None)
def test_counters_sum_across_candidates():
def test_counters_count_distinct_queries_across_candidates():
result = calculate_grounding_requests(
[
{"webSearchQueries": ["a"]},
@ -76,7 +76,19 @@ def test_counters_sum_across_candidates():
{"webSearchQueries": ["b", "c"], "groundingChunks": [{"maps": {"uri": "https://maps.google.com/?cid=2"}}]},
]
)
assert result == GroundingRequests(web_search_requests=1, google_maps_grounding_requests=3)
assert result == GroundingRequests(web_search_requests=1, google_maps_grounding_requests=2)
def test_duplicate_queries_across_candidates_collapse_per_bucket():
result = calculate_grounding_requests(
[
{"webSearchQueries": ["shared", "web only"], "groundingChunks": [{"web": {"uri": "https://e.com"}}]},
{"webSearchQueries": ["shared"], "groundingChunks": [{"web": {"uri": "https://e.com"}}]},
{"webSearchQueries": ["maps q", "maps q"], "groundingChunks": [{"maps": {"uri": "https://m.com"}}]},
{"webSearchQueries": ["maps q"], "groundingChunks": [{"maps": {"uri": "https://m.com"}}]},
]
)
assert result == GroundingRequests(web_search_requests=2, google_maps_grounding_requests=1)
def test_empty_metadata_counts_nothing():

View file

@ -2,7 +2,7 @@ import asyncio
import json
import re
from copy import deepcopy
from typing import List, cast
from typing import Final, List, cast
from unittest.mock import MagicMock, patch
import pytest
@ -5735,3 +5735,25 @@ def test_accumulated_json_async_end_of_stream_drains_buffered_value():
result = asyncio.run(iterator.__anext__())
assert result is not None
assert result.choices[0].delta.content == "a"
def test_calculate_web_search_requests_counts_unique_queries():
"""Gemini 3 per_query billing charges per unique query executed, not per emitted string.
Regression for #36377: duplicate webSearchQueries within and across grounding
metadata items must collapse to the distinct-query count, and empty strings must
be ignored, matching Google's documented Grounding-with-Search billing rule.
"""
duplicates_in_one_item: Final = [
{"webSearchQueries": ["euro 2024 winner", "euro 2024 winner", "spain england final", ""]}
]
assert VertexGeminiConfig._calculate_web_search_requests(duplicates_in_one_item) == 2
duplicates_across_items: Final = [
{"webSearchQueries": ["euro 2024 winner"]},
{"webSearchQueries": ["euro 2024 winner", "spain england final"]},
]
assert VertexGeminiConfig._calculate_web_search_requests(duplicates_across_items) == 2
assert VertexGeminiConfig._calculate_web_search_requests([]) is None
assert VertexGeminiConfig._calculate_web_search_requests([{"webSearchQueries": ["", ""]}]) is None

View file

@ -15,7 +15,6 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
import websockets.exceptions # registers websockets.exceptions on the websockets namespace
import litellm
from litellm.llms.vertex_ai.realtime.transformation import VertexAIRealtimeConfig
@ -278,7 +277,7 @@ async def test_vertex_realtime_text_in_text_out():
SERVER_TURN_COMPLETE,
]
async def _backend_recv(decode=True): # noqa: ARG001
async def _backend_recv(decode=True):
if not upstream_messages:
# Signal normal connection close so the loop exits cleanly
raise websockets.exceptions.ConnectionClosedOK(None, None) # type: ignore[arg-type]
@ -462,3 +461,98 @@ def test_vertex_function_call_output_omits_id():
assert "id" not in function_response
assert function_response["name"] == "terminate_call"
assert function_response["response"] == {"status": "ok"}
def test_vertex_native_audio_keeps_requested_voice(patch_native_audio_cost_map_entry):
"""Regression: Vertex Live accepts speechConfig on native audio, so the client's voice must survive.
Stripping it silently dropped voice selection for every Vertex native-audio
session. TEXT is still coerced away, which Vertex does reject.
"""
cfg = VertexAIRealtimeConfig(
access_token="tok", project="my-proj", location="us-central1"
)
session_update = {
"type": "session.update",
"session": {
"output_modalities": ["text"],
"audio": {"output": {"voice": "Aoede"}},
},
}
messages = cfg.transform_realtime_request(
json.dumps(session_update),
_NATIVE_AUDIO_MODEL,
session_configuration_request=None,
)
generation_config = json.loads(messages[0])["setup"]["generationConfig"]
assert generation_config["speechConfig"]["voiceConfig"]["prebuiltVoiceConfig"]["voiceName"] == "Aoede"
assert generation_config["responseModalities"] == ["AUDIO"]
def test_google_ai_studio_native_audio_keeps_requested_voice(patch_native_audio_cost_map_entry):
"""Regression: AI Studio native-audio Live accepts speechConfig too, so the voice survives on both providers."""
from litellm.llms.gemini.realtime.transformation import GeminiRealtimeConfig
messages = GeminiRealtimeConfig().transform_realtime_request(
json.dumps(
{
"type": "session.update",
"session": {
"output_modalities": ["audio"],
"audio": {"output": {"voice": "Aoede"}},
},
}
),
_NATIVE_AUDIO_MODEL,
session_configuration_request=None,
)
generation_config = json.loads(messages[0])["setup"]["generationConfig"]
assert generation_config["speechConfig"]["voiceConfig"]["prebuiltVoiceConfig"]["voiceName"] == "Aoede"
def test_vertex_native_audio_drops_openai_stock_voice(patch_native_audio_cost_map_entry):
"""Regression: OpenAI stock voice names must be dropped, not forwarded verbatim.
Vertex Live closes the socket with 1007 on an unknown voice name, so a
client sending OpenAI's default voice would lose the session entirely.
Dropping the voice keeps the session alive on the model's default voice.
"""
cfg = VertexAIRealtimeConfig(
access_token="tok", project="my-proj", location="us-central1"
)
session_update = {
"type": "session.update",
"session": {"audio": {"output": {"voice": "alloy"}}},
}
messages = cfg.transform_realtime_request(
json.dumps(session_update),
_NATIVE_AUDIO_MODEL,
session_configuration_request=None,
)
generation_config = json.loads(messages[0])["setup"]["generationConfig"]
assert "speechConfig" not in generation_config
def test_vertex_native_audio_unmapped_voice_passes_through(patch_native_audio_cost_map_entry):
"""A voice name outside the OpenAI stock set is forwarded verbatim so Gemini-native names keep working."""
cfg = VertexAIRealtimeConfig(
access_token="tok", project="my-proj", location="us-central1"
)
session_update = {
"type": "session.update",
"session": {"audio": {"output": {"voice": "Kore"}}},
}
messages = cfg.transform_realtime_request(
json.dumps(session_update),
_NATIVE_AUDIO_MODEL,
session_configuration_request=None,
)
generation_config = json.loads(messages[0])["setup"]["generationConfig"]
assert generation_config["speechConfig"]["voiceConfig"]["prebuiltVoiceConfig"]["voiceName"] == "Kore"