From d535120c922bbff4db30c504b6fd7b4c396f0388 Mon Sep 17 00:00:00 2001 From: Diego Piccinotti Date: Thu, 3 Sep 2026 17:40:56 +0200 Subject: [PATCH] fix(proxy): do not fall back to another provider credential when the resolved provider is not in the entry _extract_credential_from_entry used the provider hint only as a preference. When the resolved provider had no key in the model_config entry it fell through to the first credential in the entry, so a team whose defaultconfig only named anthropic had that key injected into requests the router resolved to openai, and OpenAI rejected them with a 401 With this change a resolved provider that the entry does not configure returns None, so the deployment keeps its own credentials. The first-entry fallback is kept for the case where no provider could be resolved at all, which is the historical single-provider behaviour #27517 preserved on purpose The changed assertion in test_extract_credential_provider_hint_prefers_exact_match and the new test_apply_overrides_single_provider_default_does_not_leak_to_other_providers both fail on the parent commit and pass here Co-Authored-By: Claude Fable 5.1 --- litellm/proxy/litellm_pre_call_utils.py | 14 +++-- .../proxy/test_litellm_pre_call_utils.py | 55 ++++++++++++++++++- 2 files changed, 60 insertions(+), 9 deletions(-) diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index 1d440448c2f..72a0e0166d9 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -2605,21 +2605,23 @@ def _extract_credential_from_entry(entry: dict, provider: str | None = None) -> Entry structure: {"azure": {"litellm_credentials": "name"}, ...} - When provider is given (e.g. "azure"), tries an exact provider match first. - Falls back to the first credential found across all provider keys. + When provider is given (e.g. "azure") and the entry has a key for it, that + provider's credential is used. When provider is given but the entry has no + key for it, the entry does not apply to this request and None is returned, + so the deployment keeps its own credentials. Only when no provider could be + resolved at all does this fall back to the first credential in the entry. """ if not isinstance(entry, dict): return None - # Prefer exact provider match when provider hint is available - if provider and provider in entry: - provider_config = entry[provider] + if provider: + provider_config = entry.get(provider) if isinstance(provider_config, dict): credential_name = provider_config.get("litellm_credentials") if credential_name: return credential_name + return None - # Fall back to first available provider for provider_config in entry.values(): if isinstance(provider_config, dict): credential_name = provider_config.get("litellm_credentials") diff --git a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py index 8366e5546a9..cce16331b29 100644 --- a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py +++ b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py @@ -4967,9 +4967,9 @@ def test_extract_credential_provider_hint_prefers_exact_match(): result = _extract_credential_from_entry(entry) assert result in ("openai-cred", "azure-cred") - # Unknown provider falls back to first available - result = _extract_credential_from_entry(entry, provider="bedrock") - assert result in ("openai-cred", "azure-cred") + # A resolved provider that the entry does not configure gets no credential, + # otherwise a bedrock request would be sent with an openai or azure key + assert _extract_credential_from_entry(entry, provider="bedrock") is None def test_resolve_provider_hint_from_model_name(): @@ -5965,6 +5965,55 @@ def test_apply_overrides_provider_prefix_in_model_skips_router_lookup( router.get_deployment_by_model_group_name.assert_not_called() +def test_apply_overrides_single_provider_default_does_not_leak_to_other_providers( + setup_test_credentials, +): + """ + A team whose defaultconfig only names anthropic must not have that key + injected into requests the router resolves to a different provider. Before + this fix an unprefixed gpt-4o-mini request on such a team was sent to OpenAI + with the team's sk-ant key and got a 401 back. + """ + litellm.credential_list.append( + CredentialItem( + credential_name="anthropic-team-1", + credential_info={}, + credential_values={"api_key": "sk-ant-key-for-team-1"}, + ) + ) + team_metadata = { + "model_config": {"defaultconfig": {"anthropic": {"litellm_credentials": "anthropic-team-1"}}} + } + + router = MagicMock() + openai_deployment = MagicMock() + openai_deployment.litellm_params.model = "openai/gpt-4o-mini" + openai_deployment.litellm_params.custom_llm_provider = "openai" + anthropic_deployment = MagicMock() + anthropic_deployment.litellm_params.model = "anthropic/claude-sonnet-4-6" + anthropic_deployment.litellm_params.custom_llm_provider = "anthropic" + router.get_deployment_by_model_group_name.side_effect = lambda model_group_name: { + "gpt-4o-mini": openai_deployment, + "claude-sonnet-4.6": anthropic_deployment, + }.get(model_group_name) + + openai_data = {"model": "gpt-4o-mini"} + _apply_credential_overrides_from_model_config( + data=openai_data, + user_api_key_dict=UserAPIKeyAuth(api_key="test-key", team_metadata=team_metadata), + llm_router=router, + ) + assert "api_key" not in openai_data + + anthropic_data = {"model": "claude-sonnet-4.6"} + _apply_credential_overrides_from_model_config( + data=anthropic_data, + user_api_key_dict=UserAPIKeyAuth(api_key="test-key", team_metadata=team_metadata), + llm_router=router, + ) + assert anthropic_data["api_key"] == "sk-ant-key-for-team-1" + + def _make_request_mock(path: str, headers: dict) -> MagicMock: request_mock = MagicMock(spec=Request) request_mock.url = MagicMock()