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:
Mateo Wang 2026-10-28 18:37:29 -07:00 • committed by GitHub
parent e4932a4539
commit 7aa7948a47
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 243 additions and 5 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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