mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-29 06:33:28 +00:00
fix(health): ask OpenAI and Azure audio chat models for audio output in the health check probe (#47730)
* fix(health): ask OpenAI and Azure audio chat models for audio output in the health check probe * fix(health): check the probed model when health_check_model is set, and mark Azure audio models as audio output A deployment with an audio base_model and a text health_check_model got the audio fields sent to its text probe model. The Azure gpt-audio and gpt-4o audio preview cost-map entries now set supports_audio_output, so a dated Azure name like azure/gpt-audio-1.5-2026-02-23 gets the audio probe too * fix(health): read litellm_params.base_model, and drop the stored base_model when Test Connect swaps the model * fix(health): judge the audio probe on a swapped-in health_check_params model, and keep Test Connect's stored base_model * fix(health): look up a provider-prefixed base_model under its own provider and keep text-only operator modalities
This commit is contained in:
parent
e4932a4539
commit
7aa7948a47
6 changed files with 243 additions and 5 deletions
|
|
@ -6101,6 +6101,7 @@
|
|||
"text",
|
||||
"audio"
|
||||
],
|
||||
"supports_audio_output": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_native_streaming": true,
|
||||
"supports_parallel_function_calling": true,
|
||||
|
|
@ -6133,6 +6134,7 @@
|
|||
"text",
|
||||
"audio"
|
||||
],
|
||||
"supports_audio_output": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_native_streaming": true,
|
||||
"supports_parallel_function_calling": true,
|
||||
|
|
@ -6164,6 +6166,7 @@
|
|||
"text",
|
||||
"audio"
|
||||
],
|
||||
"supports_audio_output": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_native_streaming": true,
|
||||
"supports_parallel_function_calling": true,
|
||||
|
|
@ -6230,6 +6233,7 @@
|
|||
"text",
|
||||
"audio"
|
||||
],
|
||||
"supports_audio_output": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_native_streaming": true,
|
||||
"supports_parallel_function_calling": true,
|
||||
|
|
@ -6293,6 +6297,7 @@
|
|||
"text",
|
||||
"audio"
|
||||
],
|
||||
"supports_audio_output": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_native_streaming": true,
|
||||
"supports_parallel_function_calling": true,
|
||||
|
|
@ -6369,6 +6374,7 @@
|
|||
"text",
|
||||
"audio"
|
||||
],
|
||||
"supports_audio_output": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_native_streaming": true,
|
||||
"supports_parallel_function_calling": true,
|
||||
|
|
|
|||
|
|
@ -131,6 +131,52 @@ def _should_inject_health_check_max_tokens(model_info: Mapping[str, object], mod
|
|||
return (mode or "chat") in _MAX_TOKEN_SUPPORT_MODES
|
||||
|
||||
|
||||
_AUDIO_OUTPUT_PROBE_PROVIDERS: Final[frozenset[str]] = frozenset({"openai", "azure"})
|
||||
|
||||
|
||||
def _health_check_requires_audio_output(
|
||||
model_info: Mapping[str, object], probe_params: Mapping[str, object], mode: str | None, deployment_model: str | None
|
||||
) -> bool:
|
||||
if (mode or "chat") != "chat":
|
||||
return False
|
||||
operator_modalities: Final = probe_params.get("modalities")
|
||||
if isinstance(operator_modalities, list) and "audio" not in operator_modalities:
|
||||
return False
|
||||
model: Final = probe_params.get("model")
|
||||
if not isinstance(model, str):
|
||||
return False
|
||||
provider_param: Final = probe_params.get("custom_llm_provider")
|
||||
try:
|
||||
_, provider, _, _ = litellm.get_llm_provider(
|
||||
model=model, custom_llm_provider=provider_param if isinstance(provider_param, str) else None
|
||||
)
|
||||
except litellm.exceptions.BadRequestError:
|
||||
return False
|
||||
if provider not in _AUDIO_OUTPUT_PROBE_PROVIDERS:
|
||||
return False
|
||||
base_model: Final = model_info.get("base_model") or probe_params.get("base_model")
|
||||
checked_model: Final = base_model if isinstance(base_model, str) and model == deployment_model else model
|
||||
checked_model_prefix: Final = checked_model.partition("/")[0]
|
||||
return litellm.supports_audio_output(
|
||||
model=checked_model,
|
||||
custom_llm_provider=checked_model_prefix if checked_model_prefix in _AUDIO_OUTPUT_PROBE_PROVIDERS else provider,
|
||||
)
|
||||
|
||||
|
||||
def _health_check_audio_output_params(
|
||||
model_info: Mapping[str, object], probe_params: Mapping[str, object], mode: str | None, deployment_model: str | None
|
||||
) -> Mapping[str, object]:
|
||||
if not _health_check_requires_audio_output(model_info, probe_params, mode, deployment_model):
|
||||
return MappingProxyType({})
|
||||
voice: Final = model_info.get("health_check_voice")
|
||||
return MappingProxyType(
|
||||
{
|
||||
"modalities": ["text", "audio"],
|
||||
"audio": {"voice": voice if isinstance(voice, str) else "alloy", "format": "wav"},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Health-check modes that forward `reasoning_effort` to the provider (chat-style calls).
|
||||
_HEALTH_CHECK_MODES_SUPPORTING_REASONING_EFFORT: Final = frozenset((None, "chat", "completion"))
|
||||
|
||||
|
|
@ -782,9 +828,12 @@ def _resolve_health_check_max_tokens(model_info: dict, litellm_params: dict) ->
|
|||
|
||||
|
||||
_PROBE_PARAMS_ADAPTER: Final = TypeAdapter(dict[str, object])
|
||||
_MODEL_INFO_ADAPTER: Final = TypeAdapter(Mapping[str, object])
|
||||
|
||||
|
||||
def update_litellm_params_for_health_check(model_info: dict, litellm_params: dict) -> dict:
|
||||
def update_litellm_params_for_health_check(
|
||||
model_info: dict, litellm_params: dict, deployment_model: str | None = None
|
||||
) -> dict:
|
||||
"""
|
||||
Update the litellm params for health check.
|
||||
|
||||
|
|
@ -800,7 +849,15 @@ def update_litellm_params_for_health_check(model_info: dict, litellm_params: dic
|
|||
- updates the `model` param with the `health_check_model` if it exists Doc: https://docs.litellm.ai/docs/proxy/health#wildcard-routes
|
||||
- updates the `voice` param with the `health_check_voice` for `audio_speech` mode if it exists Doc: https://docs.litellm.ai/docs/proxy/health#text-to-speech-models
|
||||
- for Bedrock models with region routing (bedrock/region/model), strips the litellm routing prefix but preserves the model ID, and pins `custom_llm_provider` to `bedrock` (only when the deployment hasn't already set one, so an explicit `bedrock_converse` survives) so the bare model id still resolves to the provider (e.g. cross-region ids like `us.cohere.embed-v4:0`)
|
||||
- asks OpenAI and Azure chat models that support audio output for `modalities` text + audio and a wav
|
||||
`audio` in the `health_check_voice` (default "alloy"), since those models reject text-only requests.
|
||||
`base_model` (from `model_info`, then `litellm_params`) decides it only while the probe still targets
|
||||
`deployment_model` (default: the incoming `litellm_params` model), so a `health_check_model` or a `model`
|
||||
in `health_check_params` is judged on its own. Keys already set through `health_check_params` are kept,
|
||||
and `modalities` set there without "audio" turns the audio fields off
|
||||
"""
|
||||
incoming_model: Final = _PROBE_PARAMS_ADAPTER.validate_python(litellm_params).get("model")
|
||||
base_model_owner: Final = deployment_model or (incoming_model if isinstance(incoming_model, str) else None)
|
||||
mode: Final = resolve_health_check_mode(
|
||||
model_info,
|
||||
litellm_params, # any-ok: untyped router config dict
|
||||
|
|
@ -875,7 +932,14 @@ def update_litellm_params_for_health_check(model_info: dict, litellm_params: dic
|
|||
)
|
||||
|
||||
probe_params: Final = _PROBE_PARAMS_ADAPTER.validate_python(litellm_params)
|
||||
return {key: value for key, value in probe_params.items() if not is_model_capability_field(key)}
|
||||
audio_output_params: Final = _health_check_audio_output_params(
|
||||
_MODEL_INFO_ADAPTER.validate_python(model_info), probe_params, mode, base_model_owner
|
||||
)
|
||||
return {
|
||||
key: value
|
||||
for key, value in {**audio_output_params, **probe_params}.items()
|
||||
if not is_model_capability_field(key)
|
||||
}
|
||||
|
||||
|
||||
_update_litellm_params_for_health_check: Final = update_litellm_params_for_health_check
|
||||
|
|
|
|||
|
|
@ -2330,9 +2330,11 @@ async def test_model_connection(
|
|||
stored_params=_OBJECT_MAPPING.validate_python(config_litellm_params),
|
||||
request_params=_OBJECT_MAPPING.validate_python(request_litellm_params),
|
||||
)
|
||||
stored_model: Final = _OBJECT_MAPPING.validate_python(config_litellm_params).get("model")
|
||||
litellm_params = update_litellm_params_for_health_check(
|
||||
model_info=dict(probe_model_info),
|
||||
litellm_params=litellm_params,
|
||||
deployment_model=stored_model if isinstance(stored_model, str) else None,
|
||||
)
|
||||
|
||||
## Auth check, on the final probe params so health_check_params cannot retarget it afterwards
|
||||
|
|
|
|||
|
|
@ -6101,6 +6101,7 @@
|
|||
"text",
|
||||
"audio"
|
||||
],
|
||||
"supports_audio_output": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_native_streaming": true,
|
||||
"supports_parallel_function_calling": true,
|
||||
|
|
@ -6133,6 +6134,7 @@
|
|||
"text",
|
||||
"audio"
|
||||
],
|
||||
"supports_audio_output": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_native_streaming": true,
|
||||
"supports_parallel_function_calling": true,
|
||||
|
|
@ -6164,6 +6166,7 @@
|
|||
"text",
|
||||
"audio"
|
||||
],
|
||||
"supports_audio_output": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_native_streaming": true,
|
||||
"supports_parallel_function_calling": true,
|
||||
|
|
@ -6230,6 +6233,7 @@
|
|||
"text",
|
||||
"audio"
|
||||
],
|
||||
"supports_audio_output": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_native_streaming": true,
|
||||
"supports_parallel_function_calling": true,
|
||||
|
|
@ -6293,6 +6297,7 @@
|
|||
"text",
|
||||
"audio"
|
||||
],
|
||||
"supports_audio_output": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_native_streaming": true,
|
||||
"supports_parallel_function_calling": true,
|
||||
|
|
@ -6369,6 +6374,7 @@
|
|||
"text",
|
||||
"audio"
|
||||
],
|
||||
"supports_audio_output": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_native_streaming": true,
|
||||
"supports_parallel_function_calling": true,
|
||||
|
|
|
|||
|
|
@ -394,7 +394,7 @@ async def test_test_model_connection_loads_config_from_router():
|
|||
mock_run_with_timeout = AsyncMock(return_value=mock_health_check_result)
|
||||
|
||||
# Mock _update_litellm_params_for_health_check
|
||||
def mock_update_params(model_info, litellm_params):
|
||||
def mock_update_params(model_info, litellm_params, deployment_model: str | None = None):
|
||||
# Just return params with messages added
|
||||
params = litellm_params.copy()
|
||||
params["messages"] = [{"role": "user", "content": "test"}]
|
||||
|
|
@ -545,7 +545,7 @@ async def test_test_model_connection_uses_model_info_id_to_disambiguate_duplicat
|
|||
mock_ahealth_check = AsyncMock(return_value=mock_health_check_result)
|
||||
mock_run_with_timeout = AsyncMock(return_value=mock_health_check_result)
|
||||
|
||||
def mock_update_params(model_info, litellm_params):
|
||||
def mock_update_params(model_info, litellm_params, deployment_model: str | None = None):
|
||||
params = litellm_params.copy()
|
||||
params["messages"] = [{"role": "user", "content": "test"}]
|
||||
return params
|
||||
|
|
@ -656,7 +656,7 @@ async def test_test_model_connection_falls_back_to_deployments_zero_without_id()
|
|||
mock_ahealth_check = AsyncMock(return_value=mock_health_check_result)
|
||||
mock_run_with_timeout = AsyncMock(return_value=mock_health_check_result)
|
||||
|
||||
def mock_update_params(model_info, litellm_params):
|
||||
def mock_update_params(model_info, litellm_params, deployment_model: str | None = None):
|
||||
params = litellm_params.copy()
|
||||
params["messages"] = [{"role": "user", "content": "test"}]
|
||||
return params
|
||||
|
|
@ -902,6 +902,47 @@ async def test_test_model_connection_overridden_model_probe_params_follow_the_pr
|
|||
assert "max_tokens" not in ahealth_check.call_args.kwargs["model_params"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("deployment_params", "deployment_model_info"),
|
||||
[
|
||||
({"model": "azure/my-audio-deployment"}, {"id": "audio-id", "base_model": "azure/gpt-audio-1.5"}),
|
||||
({"model": "azure/my-audio-deployment", "base_model": "azure/gpt-audio-1.5"}, {"id": "audio-id"}),
|
||||
],
|
||||
ids=["model_info.base_model", "litellm_params.base_model"],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("request_params", "expects_audio_probe"),
|
||||
[({}, True), ({"model": "azure/my-text-deployment"}, False)],
|
||||
ids=["own model", "swapped model"],
|
||||
)
|
||||
async def test_test_model_connection_stored_audio_base_model_follows_the_stored_model(
|
||||
deployment_params: Mapping[str, str],
|
||||
deployment_model_info: Mapping[str, str],
|
||||
request_params: Mapping[str, str],
|
||||
expects_audio_probe: bool,
|
||||
):
|
||||
deployment: Final = MappingProxyType(
|
||||
{
|
||||
"model_name": "custom-name-audio",
|
||||
"litellm_params": {**deployment_params, "api_key": "fake-azure-key", "api_base": "https://x.openai.azure.com"},
|
||||
"model_info": deployment_model_info,
|
||||
}
|
||||
)
|
||||
with _test_connection_probe(deployment) as ahealth_check:
|
||||
await health_test_model_connection(
|
||||
request=MagicMock(),
|
||||
mode=None,
|
||||
litellm_params=dict(request_params),
|
||||
model_info={"id": "audio-id"},
|
||||
user_api_key_dict=UserAPIKeyAuth(user_id="test-user", token="test-token"),
|
||||
)
|
||||
|
||||
probe_params: Final = ahealth_check.call_args.kwargs["model_params"]
|
||||
assert ("modalities" in probe_params) is expects_audio_probe
|
||||
assert probe_params.get("base_model") == deployment_params.get("base_model")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("params_mode", [123, ["chat"], {"mode": "chat"}, False], ids=["int", "list", "dict", "bool"])
|
||||
async def test_test_model_connection_non_string_params_mode_is_a_bad_request(params_mode: object):
|
||||
|
|
|
|||
|
|
@ -840,6 +840,125 @@ def test_health_check_params_apply_to_non_chat_modes():
|
|||
assert "max_tokens" not in updated
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("model_info", "litellm_params"),
|
||||
[
|
||||
({}, {"model": "azure/gpt-audio-mini"}),
|
||||
({}, {"model": "openai/gpt-audio-mini"}),
|
||||
({"base_model": "azure/gpt-audio-mini"}, {"model": "azure/my-audio-deployment"}),
|
||||
({}, {"model": "azure/my-audio-deployment", "base_model": "azure/gpt-audio-1.5"}),
|
||||
(
|
||||
{"base_model": "azure/gpt-4o-mini", "health_check_model": "azure/gpt-audio-mini"},
|
||||
{"model": "azure/my-text-deployment"},
|
||||
),
|
||||
(
|
||||
{"base_model": "gpt-4o-mini", "health_check_params": {"model": "openai/gpt-audio-mini"}},
|
||||
{"model": "openai/my-text-model"},
|
||||
),
|
||||
({"base_model": "openai/gpt-audio-1.5"}, {"model": "azure/my-audio-deployment"}),
|
||||
],
|
||||
)
|
||||
def test_openai_and_azure_audio_chat_probe_asks_for_audio_output(
|
||||
model_info: dict[str, str], litellm_params: dict[str, str]
|
||||
):
|
||||
updated: Final = update_litellm_params_for_health_check(model_info, litellm_params)
|
||||
|
||||
assert updated["modalities"] == ["text", "audio"]
|
||||
assert updated["audio"] == {"voice": "alloy", "format": "wav"}
|
||||
assert updated["max_tokens"] == 16
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("model_info", "litellm_params"),
|
||||
[
|
||||
({}, {"model": "azure/gpt-4o-mini"}),
|
||||
({}, {"model": "gemini/lyria-3-clip-preview"}),
|
||||
({}, {"model": "azure/my-audio-deployment"}),
|
||||
({}, {"model": "unmapped-model-name"}),
|
||||
({"mode": "responses"}, {"model": "openai/gpt-audio-mini"}),
|
||||
({"mode": "completion"}, {"model": "openai/gpt-audio-mini"}),
|
||||
({"base_model": "gemini/lyria-3-clip-preview"}, {"model": "azure/my-audio-deployment"}),
|
||||
(
|
||||
{"base_model": "azure/gpt-audio-mini", "health_check_model": "azure/gpt-4o-mini"},
|
||||
{"model": "azure/my-audio-deployment"},
|
||||
),
|
||||
(
|
||||
{"health_check_model": "azure/gpt-4o-mini"},
|
||||
{"model": "azure/my-audio-deployment", "base_model": "azure/gpt-audio-mini"},
|
||||
),
|
||||
(
|
||||
{"base_model": "gpt-audio-mini", "health_check_params": {"model": "openai/gpt-5.4-mini"}},
|
||||
{"model": "openai/gpt-audio-mini"},
|
||||
),
|
||||
(
|
||||
{"health_check_params": {"model": "openai/gpt-5.4-mini"}},
|
||||
{"model": "openai/gpt-audio-mini", "base_model": "gpt-audio-mini"},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_probe_without_openai_audio_chat_model_asks_for_no_audio_output(
|
||||
model_info: dict[str, str], litellm_params: dict[str, str]
|
||||
):
|
||||
updated: Final = update_litellm_params_for_health_check(model_info, litellm_params)
|
||||
|
||||
assert "modalities" not in updated
|
||||
assert "audio" not in updated
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"operator_params",
|
||||
[
|
||||
{"modalities": ["audio", "text"], "audio": {"voice": "verse", "format": "mp3"}},
|
||||
{"audio": {"voice": "verse", "format": "mp3"}},
|
||||
],
|
||||
)
|
||||
def test_audio_chat_probe_keeps_audio_params_set_in_health_check_params(operator_params: dict[str, object]):
|
||||
updated: Final = update_litellm_params_for_health_check(
|
||||
{"health_check_params": operator_params}, {"model": "azure/gpt-audio-mini"}
|
||||
)
|
||||
|
||||
assert {key: updated[key] for key in operator_params} == operator_params
|
||||
assert updated["modalities"] == operator_params.get("modalities", ["text", "audio"])
|
||||
|
||||
|
||||
_AUDIO_OUTPUT_CHAT_MODELS: Final = tuple(
|
||||
sorted(
|
||||
name
|
||||
for name, info in litellm.model_cost.items()
|
||||
if info.get("litellm_provider") in ("openai", "azure")
|
||||
and info.get("mode") == "chat"
|
||||
and "audio" in (info.get("supported_output_modalities") or [])
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cost_map_model", _AUDIO_OUTPUT_CHAT_MODELS)
|
||||
def test_every_audio_output_chat_model_in_the_cost_map_gets_the_audio_probe(cost_map_model: str):
|
||||
provider: Final = litellm.model_cost[cost_map_model]["litellm_provider"]
|
||||
updated: Final = update_litellm_params_for_health_check(
|
||||
{"base_model": cost_map_model}, {"model": f"{provider}/my-audio-deployment"}
|
||||
)
|
||||
|
||||
assert updated["modalities"] == ["text", "audio"]
|
||||
|
||||
|
||||
def test_text_only_modalities_in_health_check_params_get_no_default_audio():
|
||||
updated: Final = update_litellm_params_for_health_check(
|
||||
{"health_check_params": {"modalities": ["text"]}}, {"model": "openai/gpt-audio-mini"}
|
||||
)
|
||||
|
||||
assert updated["modalities"] == ["text"]
|
||||
assert "audio" not in updated
|
||||
|
||||
|
||||
def test_audio_chat_probe_uses_health_check_voice():
|
||||
updated: Final = update_litellm_params_for_health_check(
|
||||
{"health_check_voice": "verse"}, {"model": "openai/gpt-audio-mini"}
|
||||
)
|
||||
|
||||
assert updated["audio"] == {"voice": "verse", "format": "wav"}
|
||||
|
||||
|
||||
async def _pegasus_health_check_request_body(
|
||||
model_info: dict[str, object], monkeypatch: pytest.MonkeyPatch
|
||||
) -> dict[str, object]:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue