From 1caa2fa9c169046a38ecd379eb1cab206b540693 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:39:01 -0700 Subject: [PATCH] fix(proxy): keep an empty credential value empty after decryption --- .../common_utils/credential_hydration.py | 11 +++++--- litellm/proxy/proxy_server.py | 3 +- .../common_utils/test_credential_hydration.py | 28 +++++++++++++++++++ .../proxy/proxy_server/test_proxy_config.py | 17 +++++++++++ 4 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 tests/test_litellm/proxy/common_utils/test_credential_hydration.py diff --git a/litellm/proxy/common_utils/credential_hydration.py b/litellm/proxy/common_utils/credential_hydration.py index 1497de36d24..294169cce68 100644 --- a/litellm/proxy/common_utils/credential_hydration.py +++ b/litellm/proxy/common_utils/credential_hydration.py @@ -23,13 +23,16 @@ from litellm.types.router import ( from litellm.types.utils import CredentialItem +def decrypted_or_stored(key: str, value: str) -> str: + """The stored value decrypted, or as stored when it was never encrypted (a config.yaml value).""" + decrypted: Final = decrypt_value_helper(value=value, key=key) + return value if decrypted is None else decrypted + + def _decrypted(db_credential: CredentialItem) -> CredentialItem: """The stored credential with every value decrypted, leaving already-plaintext values alone.""" decrypted_values: Final = MappingProxyType( - { - key: decrypt_value_helper(value=value, key=key) or value - for key, value in db_credential.credential_values.items() - } + {key: decrypted_or_stored(key, value) for key, value in db_credential.credential_values.items()} ) return CredentialItem( credential_name=db_credential.credential_name, diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 381bb8f21b4..8dba65b493e 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -335,6 +335,7 @@ from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import ( ) from litellm.proxy.common_utils.callback_utils import initialize_callbacks_on_proxy from litellm.proxy.common_utils.config_sync_pubsub import ConfigSyncSubscriber +from litellm.proxy.common_utils.credential_hydration import decrypted_or_stored from litellm.proxy.common_utils.debug_utils import init_verbose_loggers from litellm.proxy.common_utils.debug_utils import router as debugging_endpoints_router from litellm.proxy.common_utils.encrypt_decrypt_utils import ( @@ -7933,7 +7934,7 @@ class ProxyConfig: decrypted_credential_values: Final = {} for k, v in credential_object.credential_values.items(): - decrypted_credential_values[k] = decrypt_value_helper(value=v, key=k) or v + decrypted_credential_values[k] = decrypted_or_stored(k, v) credential_object.credential_values = decrypted_credential_values return credential_object diff --git a/tests/test_litellm/proxy/common_utils/test_credential_hydration.py b/tests/test_litellm/proxy/common_utils/test_credential_hydration.py new file mode 100644 index 00000000000..f40b0114d71 --- /dev/null +++ b/tests/test_litellm/proxy/common_utils/test_credential_hydration.py @@ -0,0 +1,28 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +import litellm +from litellm.proxy.common_utils.credential_hydration import hydrate_named_credential_authoritative +from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_value_helper + + +@pytest.mark.asyncio +async def test_authoritative_hydrate_returns_an_encrypted_empty_value_as_empty(monkeypatch): + monkeypatch.setenv("LITELLM_SALT_KEY", "sk-hydration-test-salt") + row = { + "credential_name": "openai-wif", + "credential_values": { + "api_base": encrypt_value_helper(""), + "openai_service_account_id": encrypt_value_helper("user-1"), + }, + "credential_info": {"custom_llm_provider": "openai"}, + } + prisma = MagicMock() + prisma.db.litellm_credentialstable.find_unique = AsyncMock(return_value=row) + + with patch.object(litellm, "credential_list", []): # test-quality-ok: the row under test must win over memory + resolved = await hydrate_named_credential_authoritative("openai-wif", prisma) + + assert resolved is not None + assert resolved.credential_values == {"api_base": "", "openai_service_account_id": "user-1"} diff --git a/tests/test_litellm/proxy/proxy_server/test_proxy_config.py b/tests/test_litellm/proxy/proxy_server/test_proxy_config.py index dcfad8f6815..d4eeebe958a 100644 --- a/tests/test_litellm/proxy/proxy_server/test_proxy_config.py +++ b/tests/test_litellm/proxy/proxy_server/test_proxy_config.py @@ -2345,6 +2345,23 @@ def test_ProxyConfig__add_deployment_resolves_env_refs_on_arbitrary_field(monkey # --------------------------------------------------------------------------- +def test_ProxyConfig_decrypt_credentials_returns_an_encrypted_empty_value_as_empty(monkeypatch): + from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_value_helper + + monkeypatch.setenv("LITELLM_SALT_KEY", "sk-decrypt-credentials-test-salt") + decrypted = ProxyConfig().decrypt_credentials( + { + "credential_name": "openai-wif", + "credential_values": { + "api_base": encrypt_value_helper(""), + "openai_service_account_id": encrypt_value_helper("user-1"), + }, + "credential_info": {"custom_llm_provider": "openai"}, + } + ) + assert decrypted.credential_values == {"api_base": "", "openai_service_account_id": "user-1"} + + def test_ProxyConfig_decrypt_model_list_from_db_returns_decrypted(monkeypatch): monkeypatch.setattr( "litellm.proxy.proxy_server.decrypt_value_helper",