mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
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:
commit
dca5144dba
15 changed files with 880 additions and 107 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue