Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 54 additions & 1 deletion litellm/proxy/litellm_pre_call_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -1731,6 +1731,7 @@ async def add_litellm_data_to_request( # noqa: PLR0915
data=data,
user_api_key_dict=user_api_key_dict,
pre_alias_model_name=_pre_alias_model,
llm_router=llm_router,
)

## ENFORCED PARAMS CHECK
Expand Down Expand Up @@ -1864,6 +1865,7 @@ def _apply_credential_overrides_from_model_config(
data: dict,
user_api_key_dict: UserAPIKeyAuth,
pre_alias_model_name: Optional[str] = None,
llm_router: Optional[Router] = None,
) -> None:
"""
Walk the model_config precedence chain in team/project metadata.
Expand Down Expand Up @@ -1899,10 +1901,19 @@ def _apply_credential_overrides_from_model_config(
if not project_model_config and not team_model_config:
return

# Extract provider hint from model name (e.g. "azure/gpt-4" -> "azure")
# Extract provider hint from model name (e.g. "azure/gpt-4" -> "azure").
# When the user-facing name has no provider prefix, fall back to the
# deployment's litellm_params so multi-provider defaultconfig entries
# don't silently match the first dict key (#27516).
provider: Optional[str] = None
if "/" in model_name:
provider = model_name.split("/", 1)[0]
elif llm_router is not None:
provider = _resolve_provider_from_deployment(
llm_router=llm_router,
model_name=model_name,
pre_alias_model_name=pre_alias_model_name,
)

credential_name = _resolve_credential_from_model_config(
model_name=model_name,
Expand Down Expand Up @@ -1938,6 +1949,48 @@ def _apply_credential_overrides_from_model_config(
)


def _resolve_provider_from_deployment(
llm_router: Router,
model_name: str,
pre_alias_model_name: Optional[str] = None,
) -> Optional[str]:
"""
Resolve a provider hint from the deployment's litellm_params when the
user-facing model name has no provider prefix.

Tries the post-alias name first (the resolved model group), then the
pre-alias name. Returns None if no deployment is found or the deployment
has no usable provider info.
"""
candidates = [model_name]
if pre_alias_model_name and pre_alias_model_name != model_name:
candidates.append(pre_alias_model_name)

for name in candidates:
try:
deployment = llm_router.get_deployment_by_model_group_name(
model_group_name=name
)
except Exception:
deployment = None
Comment on lines +1974 to +1975

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Silent exception swallowing hides router bugs

The bare except Exception: deployment = None discards every error from get_deployment_by_model_group_name without any log output. If the router raises unexpectedly (e.g., due to a programming error, corrupted model list, or wrong return type), the provider hint silently becomes None and credential selection silently falls back to dict-insertion-order — the very bug this PR is fixing. A verbose_proxy_logger.debug or warning call here would make production failures diagnosable without changing the graceful-fallback semantics.

if deployment is None:
continue

litellm_params = getattr(deployment, "litellm_params", None)
if litellm_params is None:
continue

custom_provider = getattr(litellm_params, "custom_llm_provider", None)
if custom_provider:
return custom_provider

deployment_model = getattr(litellm_params, "model", "") or ""
Comment on lines +1969 to +1987

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 First-deployment-only lookup may return wrong provider in heterogeneous model groups

get_deployment_by_model_group_name always returns model_list[indices[0]] — the first registered deployment for a model group name. If an operator configures the same model group name (e.g. claude-sonnet-4.6) with deployments across multiple providers (bedrock in one region, anthropic direct in another), the returned provider hint depends solely on registration order, not on which deployment would actually be selected at call time. For the scenario in the bug report (single provider per group name), this is fine, but the assumption is invisible to callers and could silently mis-select credentials if the router configuration changes. A code comment or docstring note that the method returns only the first deployment would make the limitation explicit.

if "/" in deployment_model:
return deployment_model.split("/", 1)[0]

return None


def _resolve_credential_from_model_config(
model_name: str,
project_model_config: Optional[dict],
Expand Down
172 changes: 172 additions & 0 deletions tests/test_litellm/proxy/test_litellm_pre_call_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
_get_enforced_params,
_get_metadata_variable_name,
_resolve_credential_from_model_config,
_resolve_provider_from_deployment,
_update_model_if_key_alias_exists,
add_guardrails_from_policy_engine,
add_litellm_data_to_request,
Expand Down Expand Up @@ -4043,3 +4044,174 @@ class _DummyGuardrail(CustomGuardrail):
assert result == [
"my-guardrail"
], f"Expected guardrails from litellm_metadata fallback, got: {result}"


# ============================================================================
# Tests for #27516: provider hint resolution from deployment when the
# user-facing model name has no provider prefix.
# ============================================================================


def test_resolve_provider_from_deployment_uses_litellm_params_model():
"""When custom_llm_provider is unset, fall back to the prefix of model."""
router = MagicMock()
deployment = MagicMock()
deployment.litellm_params.model = "bedrock/us.anthropic.claude-sonnet-4-6"
deployment.litellm_params.custom_llm_provider = None
router.get_deployment_by_model_group_name.return_value = deployment

assert (
_resolve_provider_from_deployment(router, "claude-sonnet-4.6") == "bedrock"
)


def test_resolve_provider_from_deployment_prefers_custom_llm_provider():
"""Explicit custom_llm_provider on the deployment wins over model prefix."""
router = MagicMock()
deployment = MagicMock()
deployment.litellm_params.model = "us.anthropic.claude-sonnet-4-6"
deployment.litellm_params.custom_llm_provider = "bedrock"
router.get_deployment_by_model_group_name.return_value = deployment

assert (
_resolve_provider_from_deployment(router, "claude-sonnet-4.6") == "bedrock"
)


def test_resolve_provider_from_deployment_no_match():
"""No deployment for the model group -> None."""
router = MagicMock()
router.get_deployment_by_model_group_name.return_value = None
assert _resolve_provider_from_deployment(router, "unknown-model") is None


def test_resolve_provider_from_deployment_router_raises():
"""Router exceptions must not propagate — fall back to None."""
router = MagicMock()
router.get_deployment_by_model_group_name.side_effect = RuntimeError("boom")
assert _resolve_provider_from_deployment(router, "claude-sonnet-4.6") is None


def test_resolve_provider_from_deployment_falls_back_to_pre_alias():
"""If post-alias lookup fails, the pre-alias name is also tried."""
router = MagicMock()
deployment = MagicMock()
deployment.litellm_params.model = "bedrock/anthropic.claude-sonnet-4-6"
deployment.litellm_params.custom_llm_provider = None

def lookup(model_group_name):
if model_group_name == "pre-alias-name":
return deployment
return None

router.get_deployment_by_model_group_name.side_effect = lookup

result = _resolve_provider_from_deployment(
router, "post-alias-name", pre_alias_model_name="pre-alias-name"
)
assert result == "bedrock"


def test_apply_overrides_multi_provider_default_picks_correct_provider(
setup_test_credentials,
):
"""
Regression for #27516: when defaultconfig has multiple providers and the
request model has no '/' prefix, the deployment's custom_llm_provider must
drive provider matching instead of falling through to dict insertion order.
"""
litellm.credential_list.append(
CredentialItem(
credential_name="bedrock-team-1",
credential_info={},
credential_values={"api_key": "ABSK-bedrock-key-for-team-1"},
)
)
litellm.credential_list.append(
CredentialItem(
credential_name="gemini-team-1",
credential_info={},
credential_values={"api_key": "gemini-key-for-team-1"},
)
)

data = {"model": "claude-sonnet-4.6"}
user_api_key_dict = UserAPIKeyAuth(
api_key="test-key",
team_metadata={
"model_config": {
"defaultconfig": {
# gemini comes first in insertion order — the bug picked it.
"gemini": {"litellm_credentials": "gemini-team-1"},
"bedrock": {"litellm_credentials": "bedrock-team-1"},
}
}
},
)

router = MagicMock()
deployment = MagicMock()
deployment.litellm_params.model = "us.anthropic.claude-sonnet-4-6"
deployment.litellm_params.custom_llm_provider = "bedrock"
router.get_deployment_by_model_group_name.return_value = deployment

_apply_credential_overrides_from_model_config(
data=data,
user_api_key_dict=user_api_key_dict,
llm_router=router,
)
assert data["api_key"] == "ABSK-bedrock-key-for-team-1"


def test_apply_overrides_no_router_keeps_legacy_behaviour(setup_test_credentials):
"""
Without a router, the function still works for the single-provider case
(the historical behaviour). Multi-provider configs with no '/' prefix
keep the legacy first-entry behaviour because there is no way to
disambiguate — this preserves backwards compatibility.
"""
data = {"model": "gpt-4"}
user_api_key_dict = UserAPIKeyAuth(
api_key="test-key",
team_metadata={
"model_config": {
"defaultconfig": {
"azure": {"litellm_credentials": "hotel-azure-eastus"}
}
}
},
)
_apply_credential_overrides_from_model_config(
data=data, user_api_key_dict=user_api_key_dict, llm_router=None
)
assert data["api_base"] == "https://hotel-eastus.openai.azure.com/"
assert data["api_key"] == "key-hotel-eastus"


def test_apply_overrides_provider_prefix_in_model_skips_router_lookup(
setup_test_credentials,
):
"""
When the request model already has a 'provider/...' prefix, the router
lookup must be skipped — the explicit prefix is authoritative.
"""
data = {"model": "azure/gpt-4"}
user_api_key_dict = UserAPIKeyAuth(
api_key="test-key",
team_metadata={
"model_config": {
"defaultconfig": {
"azure": {"litellm_credentials": "hotel-azure-eastus"},
"bedrock": {"litellm_credentials": "hotel-rec-azure"},
}
}
},
)

router = MagicMock()
_apply_credential_overrides_from_model_config(
data=data, user_api_key_dict=user_api_key_dict, llm_router=router
)
assert data["api_base"] == "https://hotel-eastus.openai.azure.com/"
assert data["api_key"] == "key-hotel-eastus"
router.get_deployment_by_model_group_name.assert_not_called()
Loading