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 <noreply@anthropic.com>
This commit is contained in:
Diego Piccinotti 2026-09-03 17:40:56 +02:00
parent 11a02b9581
commit d535120c92
2 changed files with 60 additions and 9 deletions

View file

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

View file

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