fix(router): keep a model's own provider prefix for generic SDK calls

Generic passthrough calls inferred the provider from the bare model name, so an
azure_ai/gpt-* deployment on an Azure OpenAI host flipped to azure and
get_llm_provider re-prefixed the deployment name into azure_ai/gpt-5.4-mini, a
404 DeploymentNotFound. provider_for_generic_call takes the declared
custom_llm_provider first, then the model's own prefix, and only infers for
unprefixed models
This commit is contained in:
mateo-berri 2026-09-04 23:32:26 -07:00
parent cf275cf442
commit a276690ce2
4 changed files with 91 additions and 21 deletions

View file

@ -135,6 +135,7 @@ from litellm.router_utils.common_utils import (
_is_proxy_admin_request,
filter_team_based_models,
filter_web_search_deployments,
provider_for_generic_call,
resolve_model_group_alias,
truncate_fallback_error_detail,
warn_on_provider_credential_mismatch,
@ -5045,17 +5046,7 @@ class Router:
kwargs=kwargs, model=model, model_name=model_name
)
# Get custom_llm_provider from deployment params
try:
custom_llm_provider = data.get("custom_llm_provider")
_, inferred_custom_llm_provider, _, _ = get_llm_provider(
model=data["model"],
custom_llm_provider=custom_llm_provider,
api_base=data.get("api_base"),
)
custom_llm_provider = custom_llm_provider or inferred_custom_llm_provider
except Exception:
custom_llm_provider = None
custom_llm_provider: Final = provider_for_generic_call(data)
response_kwargs: Final = {
**data,
@ -5566,16 +5557,7 @@ class Router:
# Perform pre-call checks for routing strategy
self.routing_strategy_pre_call_checks(deployment=deployment)
try:
custom_llm_provider = data.get("custom_llm_provider")
_, inferred_custom_llm_provider, _, _ = get_llm_provider(
model=data["model"],
custom_llm_provider=custom_llm_provider,
api_base=data.get("api_base"),
)
custom_llm_provider = custom_llm_provider or inferred_custom_llm_provider
except Exception:
custom_llm_provider = None
custom_llm_provider: Final = provider_for_generic_call(data)
response: Final = original_function(
**{

View file

@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Final
if TYPE_CHECKING:
from litellm.types.llms.openai import OpenAIFileObject
import litellm
from litellm._logging import verbose_logger, verbose_router_logger
from litellm.constants import ROUTER_FALLBACK_ERROR_DETAIL_MAX_CHARS
from litellm.exceptions import BadRequestError
@ -244,6 +245,32 @@ PROVIDER_SCOPED_CREDENTIAL_PARAMS: Final[Mapping[str, frozenset[str]]] = Mapping
)
def provider_for_generic_call(litellm_params: Mapping[str, object]) -> str | None:
"""
The provider the router hands a deployment's generic SDK call, or None when it cannot be resolved.
A model that carries its own provider prefix keeps that prefix even where get_llm_provider
would resolve it to a sibling provider (azure_ai/<openai model> on an Azure OpenAI host
resolves to azure): the SDK call still receives the prefixed model, and an explicit provider
that contradicts the prefix makes get_llm_provider re-prefix it into a deployment name that
does not exist upstream.
"""
declared: Final = litellm_params.get("custom_llm_provider")
if isinstance(declared, str) and declared:
return declared
model: Final = litellm_params.get("model")
if not isinstance(model, str) or not model:
return None
prefix: Final = model.split("/", 1)[0]
if "/" in model and prefix in litellm.provider_list:
return prefix
try:
_, inferred, _, _ = get_llm_provider(model=model)
except BadRequestError:
return None
return inferred
def warn_on_provider_credential_mismatch(model_name: str, litellm_params: Mapping[str, object]) -> str | None:
"""
Warn when a deployment carries one provider's credentials but resolves to another.

View file

@ -12,6 +12,7 @@ from litellm.router_utils.common_utils import (
add_model_file_id_mappings,
filter_team_based_models,
filter_web_search_deployments,
provider_for_generic_call,
resolve_model_group_alias,
truncate_fallback_error_detail,
PROVIDER_SCOPED_CREDENTIAL_PARAMS,
@ -756,3 +757,20 @@ class TestWarnOnProviderCredentialMismatch:
)
is None
)
@pytest.mark.parametrize(
("litellm_params", "expected"),
[
({"model": "azure_ai/gpt-5.4-mini", "custom_llm_provider": "azure"}, "azure"),
({"model": "azure_ai/gpt-5.4-mini", "api_base": "https://my-resource.openai.azure.com"}, "azure_ai"),
({"model": "cohere/command-r"}, "cohere"),
({"model": "gpt-5.4-mini"}, "openai"),
({"model": "no-provider-knows-this-model"}, None),
({"api_base": "https://my-resource.openai.azure.com"}, None),
],
ids=["declared_wins", "prefix_beats_host_flip", "prefix_beats_cohere_chat_flip", "unprefixed_inferred", "unknown", "no_model"],
)
def test_provider_for_generic_call(litellm_params, expected, monkeypatch):
monkeypatch.setenv("AZURE_AI_API_BASE", "https://unrelated.openai.azure.com")
assert provider_for_generic_call(litellm_params) == expected

View file

@ -12938,3 +12938,46 @@ async def test_router_retry_policy_controls_upstream_attempt_count(
await router.acompletion(model="gpt-5.6", messages=[{"role": "user", "content": "hi"}])
assert upstream.call_count == expected_upstream_calls
@pytest.mark.asyncio
async def test_generic_call_keeps_the_deployment_name_of_an_azure_ai_model_on_an_azure_openai_host(monkeypatch):
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
router = litellm.Router(
model_list=[
{
"model_name": "aoai-gpt",
"litellm_params": {
"model": "azure_ai/gpt-5.4-mini",
"api_base": "https://my-resource.openai.azure.com",
"api_key": "deployment-key",
},
}
]
)
with respx.mock(assert_all_called=True) as respx_mock:
upstream = respx_mock.post(host="my-resource.openai.azure.com", path__regex=r"^/openai/.*responses$").mock(
return_value=httpx.Response(
200,
json={
"id": "resp_1",
"object": "response",
"created_at": 1,
"status": "completed",
"model": "gpt-5.4-mini",
"output": [
{
"type": "message",
"id": "msg_1",
"role": "assistant",
"status": "completed",
"content": [{"type": "output_text", "text": "hi", "annotations": []}],
}
],
},
)
)
await router.aresponses(model="aoai-gpt", input="hi")
assert json.loads(upstream.calls.last.request.content)["model"] == "gpt-5.4-mini"