feat(proxy): resolve os.environ/ refs in stored credential values

The Add Model and credential forms advertise os.environ/<VAR> references on
Bedrock credential fields, but values saved through POST /credentials were
passed to providers literally, so UI-created multi-account Bedrock
credentials could not point at environment-held secrets. Expand the
references when a credential enters the in-memory list and when it is
hydrated from the database, mirroring the config loader's load-time
resolution. Unresolvable references are kept and logged instead of dropped,
so an optional param cannot silently fall back to ambient AWS credentials,
and *_ref federation pointer fields stay as written for use-time
dereferencing

Signed-off-by: Delweng <delweng@gmail.com>
This commit is contained in:
Delweng 2026-10-18 14:39:57 +08:00
parent f370235bb4
commit 08f7628cf3
4 changed files with 149 additions and 1 deletions

View file

@ -1,10 +1,51 @@
"""Utils for accessing credentials."""
from collections.abc import Mapping
from typing import Final
import litellm
from litellm._logging import verbose_logger
from litellm.secret_managers.main import get_secret_str
from litellm.types.router import holds_secret_pointer
from litellm.types.utils import CredentialItem
_ENV_REF_PREFIX: Final = "os.environ/"
def _resolved_credential_value(credential_name: str, key: str, value: object) -> object:
"""The environment variable's value for an ``os.environ/<VAR>`` reference, or the value itself.
An unresolvable reference is kept and logged rather than dropped, so a provider with optional
params cannot silently fall back to another account's ambient credentials. ``*_ref`` federation
fields are pointers their identity source dereferences at use time, so they stay as written.
"""
if not isinstance(value, str) or not value.startswith(_ENV_REF_PREFIX):
return value
if holds_secret_pointer(key):
return value
resolved: Final = get_secret_str(value)
if resolved is not None:
return resolved
verbose_logger.warning(
"credential '%s' value '%s' references unset environment variable %s; keeping the reference",
credential_name,
key,
value,
)
return value
def expand_credential_env_refs(
credential_name: str, values: Mapping[str, object]
) -> dict[str, object]: # mutable-ok: CredentialItem.credential_values is a mutable dict by contract
"""Resolve ``os.environ/<VAR>`` values against the environment.
Mirrors the config loader: references resolve once, when the credential enters the process, so
runtime reads stay plain and a rotated env var needs the credential to be re-saved or the proxy
restarted.
"""
return {key: _resolved_credential_value(credential_name, key, value) for key, value in values.items()}
class CredentialAccessor:
@staticmethod
@ -27,6 +68,11 @@ class CredentialAccessor:
def upsert_credentials(credentials: list[CredentialItem]):
"""Add a credential to the list of credentials."""
for credential in credentials:
credential.credential_values = expand_credential_env_refs(
credential.credential_name, credential.credential_values
)
credential_names: Final = [cred.credential_name for cred in litellm.credential_list]
for credential in credentials:

View file

@ -12,6 +12,7 @@ from types import MappingProxyType
from typing import Final, cast # noqa: TID251 # narrows untyped credential values at the decrypt boundary
import litellm
from litellm.litellm_core_utils.credential_accessor import expand_credential_env_refs
from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper
from litellm.proxy.utils import PrismaClient
from litellm.repositories.credentials_repository import CredentialsRepository
@ -78,7 +79,7 @@ def _decrypted(db_credential: CredentialItem) -> CredentialItem:
)
return CredentialItem(
credential_name=db_credential.credential_name,
credential_values=decrypted_values, # pyright: ignore[reportArgumentType] # declared dict[str, str], and pydantic copies this mapping into one on validation; LIT002 rules out building that dict here
credential_values=expand_credential_env_refs(db_credential.credential_name, decrypted_values), # pyright: ignore[reportArgumentType] # declared dict[str, str], and pydantic copies this mapping into one on validation; LIT002 rules out building that dict here
credential_info=db_credential.credential_info,
)

View file

@ -0,0 +1,71 @@
from typing import Final
from unittest.mock import patch
import litellm
from litellm.litellm_core_utils.credential_accessor import (
CredentialAccessor,
expand_credential_env_refs,
)
from litellm.models.credentials import CredentialItem
CREDENTIAL_NAME: Final = "bedrock-account-b"
def _credential(values: dict[str, object]) -> CredentialItem:
return CredentialItem(
credential_name=CREDENTIAL_NAME,
credential_values=values,
credential_info={"custom_llm_provider": "bedrock"},
)
def test_upsert_resolves_env_refs_so_deployments_read_effective_values(monkeypatch):
monkeypatch.setenv("AWS_ACCESS_KEY_ID_CRED_TEST", "ak-account-b")
with patch.object(litellm, "credential_list", []): # test-quality-ok: isolate the global list
CredentialAccessor.upsert_credentials(
[
_credential(
{
"aws_access_key_id": "os.environ/AWS_ACCESS_KEY_ID_CRED_TEST",
"aws_region_name": "us-east-1",
}
)
]
)
assert CredentialAccessor.get_credential_values(CREDENTIAL_NAME) == {
"aws_access_key_id": "ak-account-b",
"aws_region_name": "us-east-1",
}
def test_upsert_keeps_the_reference_when_the_variable_is_unset(monkeypatch):
monkeypatch.delenv("AWS_SECRET_ACCESS_KEY_CRED_TEST", raising=False)
with patch.object(litellm, "credential_list", []): # test-quality-ok: isolate the global list
CredentialAccessor.upsert_credentials(
[_credential({"aws_secret_access_key": "os.environ/AWS_SECRET_ACCESS_KEY_CRED_TEST"})]
)
assert CredentialAccessor.get_credential_values(CREDENTIAL_NAME) == {
"aws_secret_access_key": "os.environ/AWS_SECRET_ACCESS_KEY_CRED_TEST"
}
def test_expand_leaves_non_reference_values_alone():
values: Final = {
"aws_region_name": "us-east-1",
"aws_session_tags": ["team", "prod"],
"max_retries": 4,
}
assert expand_credential_env_refs(CREDENTIAL_NAME, values) == values
def test_expand_leaves_federation_pointer_fields_as_written(monkeypatch):
monkeypatch.setenv("JWKS_CRED_TEST_SIGNING_KEY", "pem-bytes")
values: Final = {
"anthropic_identity_source": "internal_issuer",
"anthropic_issuer_signing_key_ref": "os.environ/JWKS_CRED_TEST_SIGNING_KEY",
}
assert expand_credential_env_refs(CREDENTIAL_NAME, values) == values

View file

@ -7,6 +7,36 @@ from litellm.proxy.common_utils.credential_hydration import hydrate_named_creden
from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_value_helper
@pytest.mark.asyncio
async def test_authoritative_hydrate_resolves_env_refs_in_stored_values(monkeypatch):
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-hydration-test-salt")
monkeypatch.setenv("BEDROCK_HYDRATION_TEST_KEY", "ak-account-b")
monkeypatch.delenv("BEDROCK_HYDRATION_TEST_MISSING", raising=False)
row = {
"credential_name": "bedrock-account-b",
"credential_values": {
"aws_access_key_id": encrypt_value_helper("os.environ/BEDROCK_HYDRATION_TEST_KEY"),
"aws_secret_access_key": encrypt_value_helper("literal-secret"),
"aws_session_token": encrypt_value_helper("os.environ/BEDROCK_HYDRATION_TEST_MISSING"),
"anthropic_issuer_signing_key_ref": encrypt_value_helper("os.environ/BEDROCK_HYDRATION_TEST_KEY"),
},
"credential_info": {"custom_llm_provider": "bedrock"},
}
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("bedrock-account-b", prisma)
assert resolved is not None
assert resolved.credential_values == {
"aws_access_key_id": "ak-account-b",
"aws_secret_access_key": "literal-secret",
"aws_session_token": "os.environ/BEDROCK_HYDRATION_TEST_MISSING",
"anthropic_issuer_signing_key_ref": "os.environ/BEDROCK_HYDRATION_TEST_KEY",
}
@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")