This commit is contained in:
devin-ai-integration[bot] 2026-10-05 21:32:08 +00:00 • committed by GitHub
commit 4dcbf83d31
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1280 additions and 478 deletions

View file

@ -78,6 +78,7 @@ COPY litellm-proxy-extras/pyproject.toml litellm-proxy-extras/
# Install third-party dependencies (cached unless pyproject.toml/uv.lock change)
RUN uv sync --frozen --no-install-project --no-install-workspace --no-default-groups --no-editable \
--extra proxy \
--extra legacy-encryption \
--extra proxy-runtime \
--extra extra_proxy \
--extra semantic-router \
@ -100,6 +101,7 @@ RUN sed -i 's/\r$//' docker/build_admin_ui.sh && chmod +x docker/build_admin_ui.
# Install project and workspace packages (fast - deps already cached)
RUN uv sync --frozen --no-default-groups --no-editable \
--extra proxy \
--extra legacy-encryption \
--extra proxy-runtime \
--extra extra_proxy \
--extra semantic-router \

View file

@ -43,6 +43,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=litellm-proxy-extras/pyproject.toml,target=litellm-proxy-extras/pyproject.toml \
uv sync --frozen --no-install-project --no-install-workspace --no-default-groups --no-editable \
--extra proxy \
--extra legacy-encryption \
--extra proxy-runtime \
--extra extra_proxy \
--extra semantic-router \
@ -55,6 +56,7 @@ COPY . .
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-default-groups --no-editable \
--extra proxy \
--extra legacy-encryption \
--extra proxy-runtime \
--extra extra_proxy \
--extra semantic-router \

View file

@ -76,6 +76,7 @@ COPY litellm-proxy-extras/pyproject.toml litellm-proxy-extras/
# Install third-party dependencies (cached unless pyproject.toml/uv.lock change)
RUN uv sync --frozen --no-install-project --no-install-workspace --no-default-groups --no-editable \
--extra proxy \
--extra legacy-encryption \
--extra proxy-runtime \
--extra extra_proxy \
--extra semantic-router \
@ -98,6 +99,7 @@ RUN sed -i 's/\r$//' docker/build_admin_ui.sh && chmod +x docker/build_admin_ui.
# Install project and workspace packages (fast - deps already cached)
RUN uv sync --frozen --no-default-groups --no-editable \
--extra proxy \
--extra legacy-encryption \
--extra proxy-runtime \
--extra extra_proxy \
--extra semantic-router \

View file

@ -80,6 +80,7 @@ COPY litellm-proxy-extras/pyproject.toml litellm-proxy-extras/
RUN --mount=type=cache,target=/app/.cache/uv,id=litellm-uv-cache \
uv sync --frozen --no-install-project --no-install-workspace --no-default-groups --no-editable \
--extra proxy \
--extra legacy-encryption \
--extra proxy-runtime \
--extra extra_proxy \
--extra semantic-router \
@ -107,6 +108,7 @@ RUN mkdir -p /var/lib/litellm/ui /var/lib/litellm/assets && \
RUN --mount=type=cache,target=/app/.cache/uv,id=litellm-uv-cache \
uv sync --frozen --no-default-groups --no-editable \
--extra proxy \
--extra legacy-encryption \
--extra proxy-runtime \
--extra extra_proxy \
--extra semantic-router \

View file

@ -59,6 +59,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=litellm-proxy-extras/pyproject.toml,target=litellm-proxy-extras/pyproject.toml \
uv sync --frozen --no-install-project --no-install-workspace --no-default-groups --no-editable \
--extra proxy \
--extra legacy-encryption \
--extra proxy-runtime \
--extra extra_proxy \
--extra semantic-router \
@ -71,6 +72,7 @@ COPY . .
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-default-groups --no-editable \
--extra proxy \
--extra legacy-encryption \
--extra proxy-runtime \
--extra extra_proxy \
--extra semantic-router \

View file

@ -14,9 +14,7 @@ from typing_extensions import assert_never
from litellm._logging import verbose_logger
from litellm.proxy._experimental.mcp_server.oauth_utils import TOKEN_NO_CACHE_HEADERS
from litellm.proxy.common_utils.encrypt_decrypt_utils import (
_V2_GCM_PREFIX, # pyright: ignore[reportPrivateUsage] # reuse the encrypted credential's format discriminator
)
from litellm.proxy.common_utils.encrypt_decrypt_utils import is_versioned_gcm
from litellm.types.mcp_server.mcp_server_manager import MCPServer
if TYPE_CHECKING:
@ -99,7 +97,7 @@ async def _opaque_bearer_is_gateway_credential(token: str) -> bool:
user_api_key_cache,
)
if is_envelope(token) or is_refresh_envelope(token) or token.startswith(_V2_GCM_PREFIX):
if is_envelope(token) or is_refresh_envelope(token) or is_versioned_gcm(token):
return True
try:
if ExperimentalUIJWTToken.get_key_object_from_ui_hash_key(token) is not None:

View file

@ -258,7 +258,7 @@ def _drop_stale_minted_on_client_rotation(merged: dict[str, object], new_creds:
}
def _is_global_env_var_scope(scope: object) -> bool:
def is_global_env_var_scope(scope: object) -> bool:
"""``scope="user"`` entries are placeholders the user fills in; everything
else (including a missing scope) is an admin-supplied global value."""
return scope != MCPEnvVarScope.user and scope != "user"
@ -273,7 +273,7 @@ def _encrypt_global_env_var_values(env_vars: Iterable[dict[str, str]]) -> None:
secrets and are stored verbatim.
"""
for entry in env_vars:
if not _is_global_env_var_scope(entry.get("scope")):
if not is_global_env_var_scope(entry.get("scope")):
continue
value = entry.get("value")
if value:
@ -294,7 +294,7 @@ def decrypt_global_env_var_values(env_vars: Iterable[MCPEnvVar | dict[str, str]]
for entry in env_vars:
is_dict = isinstance(entry, dict)
scope = entry.get("scope") if is_dict else getattr(entry, "scope", None)
if not _is_global_env_var_scope(scope):
if not is_global_env_var_scope(scope):
continue
value = entry.get("value") if is_dict else getattr(entry, "value", None)
if not value:
@ -374,7 +374,7 @@ def _reencrypt_global_env_var_values(
rebuilt: Final = [dict(v) for v in entries]
rotated = False
for entry in rebuilt:
if not _is_global_env_var_scope(entry.get("scope")):
if not is_global_env_var_scope(entry.get("scope")):
continue
value = entry.get("value")
if not value:
@ -794,20 +794,22 @@ async def _db_find_user_env_var_rows(
return await _user_env_var_actions(prisma_client).find_many(where=where)
MCP_CREDENTIAL_SECRET_FIELDS: Final = (
"auth_value",
"client_id",
"client_secret",
"client_private_key",
"aws_access_key_id",
"aws_secret_access_key",
"aws_session_token",
)
def decrypt_credentials(
credentials: MCPCredentials,
) -> MCPCredentials:
"""Decrypt all secret fields in an MCPCredentials dict using the global salt key."""
secret_fields: Final = [
"auth_value",
"client_id",
"client_secret",
"client_private_key",
"aws_access_key_id",
"aws_secret_access_key",
"aws_session_token",
]
for field in secret_fields:
for field in MCP_CREDENTIAL_SECRET_FIELDS:
value = credentials.get(field)
if value is not None and isinstance(value, str):
credentials[field] = decrypt_value_helper(
@ -1536,7 +1538,7 @@ async def rotate_mcp_server_credentials_master_key(prisma_client: PrismaClient,
def _decode_user_credential(stored: str) -> str | None:
"""Read back a value persisted in ``LiteLLM_MCPUserCredentials.credential_b64``.
Tries nacl decryption first (current write format). Falls back to a
Tries the at-rest decryptor first (current write format). Falls back to a
plain ``urlsafe_b64decode`` for rows persisted by older code that wrote
the credential without encryption. Returns ``None`` when neither path
yields a valid string.

View file

@ -33,14 +33,13 @@ defend against non-UTF-8 field content that cannot survive JSON parsing.
from __future__ import annotations
import base64
from datetime import datetime, timedelta
from typing import Final, Literal, TypeAlias
import jwt
from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationError
from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value, encrypt_value
from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_if_encrypted_with, encrypt_value_helper
ENVELOPE_PREFIX: Final = "llm_env_"
"""Marker prefix on every serialized ACCESS envelope so the edge can cheaply tell an envelope
@ -540,22 +539,15 @@ def _decode_claims(
def _encrypt_grant_blob(plaintext: str, encryption_key: SecretStr) -> str:
ciphertext: Final = bytes(encrypt_value(value=plaintext, signing_key=encryption_key.get_secret_value()))
return base64.urlsafe_b64encode(ciphertext).decode("ascii")
return encrypt_value_helper(value=plaintext, new_encryption_key=encryption_key.get_secret_value())
def _decrypt_grant(
blob: str,
encryption_key: SecretStr,
) -> UpstreamTokenGrant | DecryptFailed | MalformedPayload:
from nacl.exceptions import CryptoError
try:
plaintext: Final = decrypt_value(
value=base64.urlsafe_b64decode(blob),
signing_key=encryption_key.get_secret_value(),
)
except (CryptoError, ValueError):
plaintext: Final = decrypt_if_encrypted_with(value=blob, signing_key=encryption_key.get_secret_value())
if plaintext is None:
return DecryptFailed()
try:
return UpstreamTokenGrant.model_validate_json(plaintext)
@ -567,14 +559,8 @@ def _decrypt_refresh(
blob: str,
encryption_key: SecretStr,
) -> RefreshCredential | DecryptFailed | MalformedPayload:
from nacl.exceptions import CryptoError
try:
plaintext: Final = decrypt_value(
value=base64.urlsafe_b64decode(blob),
signing_key=encryption_key.get_secret_value(),
)
except (CryptoError, ValueError):
plaintext: Final = decrypt_if_encrypted_with(value=blob, signing_key=encryption_key.get_secret_value())
if plaintext is None:
return DecryptFailed()
try:
return RefreshCredential.model_validate_json(plaintext)

View file

@ -29,11 +29,11 @@ def encryption():
)
@click.pass_context
def migrate(ctx: click.Context, check_only: bool, dry_run: bool):
"""Re-encrypt at-rest credentials into the AES-256-GCM (v2:gcm:) format.
"""Re-encrypt at-rest credentials into the versioned AES-256-GCM (v3:gcm:) format.
Requires the proxy to be started with
``general_settings.encryption_algorithm: aes-256-gcm``. Idempotent and
resumable; safe to re-run after an interruption.
Requires the proxy to write ``aes-256-gcm`` (the default; refused while
``general_settings.encryption_algorithm: xsalsa20-poly1305`` is set). Idempotent
and resumable; safe to re-run after an interruption.
Examples:
lite encryption migrate --check # attestation scan, no writes

View file

@ -12,7 +12,7 @@ from types import MappingProxyType
from typing import Final
import litellm
from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper
from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper, legacy_unreadable
from litellm.proxy.utils import PrismaClient
from litellm.repositories.credentials_repository import CredentialsRepository
from litellm.router_utils.clientside_credential_handler import clientside_credential_keys
@ -58,20 +58,26 @@ def stored_credential_provider(credential_provider: object) -> str | None:
return lowered if lowered in _LITELLM_PROVIDER_IDS else None
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)."""
def decrypted_or_stored(key: str, value: str) -> str | None:
"""The stored value decrypted, as stored when it was never encrypted (a config.yaml value), or None when it
is legacy ciphertext this install has no PyNaCl to read: a credential, never the ciphertext blob."""
decrypted: Final = decrypt_value_helper(value=value, key=key)
return value if decrypted is None else decrypted
if decrypted is not None:
return decrypted
return None if legacy_unreadable(value) else value
def decrypted_values(values: Mapping[str, str]) -> Mapping[str, str]:
"""``values`` with every entry decrypted, plaintext entries kept, and unreadable legacy entries dropped."""
resolved: Final = {key: decrypted_or_stored(key, value) for key, value in values.items()}
return MappingProxyType({key: value for key, value in resolved.items() if value is not None})
def _decrypted(db_credential: CredentialItem) -> CredentialItem:
"""The stored credential with every value decrypted, leaving already-plaintext values alone."""
decrypted_values: Final = MappingProxyType(
{key: decrypted_or_stored(key, value) for key, value in db_credential.credential_values.items()}
)
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=decrypted_values(db_credential.credential_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

@ -1,27 +1,47 @@
import base64
import hashlib
import os
from collections.abc import Mapping
from typing import Final, Literal, cast
from collections.abc import Iterable, Mapping
from typing import TYPE_CHECKING, Final, Literal, cast
from pydantic import TypeAdapter, ValidationError
from litellm._logging import verbose_proxy_logger
from litellm.proxy.common_utils.fips import FipsModeError, is_fips_mode
# Versioned ciphertext marker for AES-256-GCM values.
# Format: "v2:gcm:" + base64url(nonce(12) || ciphertext || tag(16)).
if TYPE_CHECKING:
from nacl.secret import SecretBox
# Versioned ciphertext markers for AES-256-GCM values, each
# "<version>:gcm:" + base64url(nonce(12) || ciphertext || tag(16)).
# v3 derives the key with HKDF-SHA256, v2 with a raw SHA-256 of the salt key.
# Legacy XSalsa20-Poly1305 (nacl) values carry no marker; the colon in the
# prefix can never appear in base64url(nacl output), so the prefix check is an
# unambiguous discriminator between the two formats on read.
# unambiguous discriminator between the formats on read.
_V3_GCM_PREFIX: Final = "v3:gcm:"
_V2_GCM_PREFIX: Final = "v2:gcm:"
_GCM_PREFIXES: Final = (_V3_GCM_PREFIX, _V2_GCM_PREFIX)
_HKDF_INFO: Final = b"litellm-at-rest-v3"
# general_settings key selecting the at-rest encryption algorithm for new writes.
# Default preserves the legacy algorithm so existing deployments are byte-for-byte
# unchanged until they explicitly opt in. Decrypt is always format-detecting, so
# flipping this flag forward (or back) never strands previously-written data.
_ENCRYPTION_ALGORITHM_SETTING: Final = "encryption_algorithm"
_ALGO_AES_GCM: Final = "aes-256-gcm"
_ALGO_XSALSA20: Final = "xsalsa20-poly1305"
_NACL_MIN_CIPHERTEXT_BYTES: Final = 40
_LEGACY_ENCRYPTION_HELP: Final = (
"Install the legacy-encryption extra (pip install 'litellm[legacy-encryption]') on a non-FIPS image and "
"re-encrypt stored credentials with `litellm-proxy encryption migrate` (POST /credentials/migrate-encryption) "
"before running without PyNaCl"
)
class LegacyEncryptionUnavailableError(RuntimeError):
pass
def is_versioned_gcm(value: str) -> bool:
return value.startswith(_GCM_PREFIXES)
def _get_salt_key():
from litellm.proxy.proxy_server import master_key
@ -35,49 +55,48 @@ def _get_salt_key():
def _get_encryption_algorithm() -> str:
"""
Resolve the configured at-rest encryption algorithm for *new writes*.
"""Resolve the at-rest encryption algorithm for new writes from ``general_settings.encryption_algorithm``.
Read from ``general_settings.encryption_algorithm`` at write time. Defaults to
the legacy XSalsa20-Poly1305 algorithm so deployments that have not opted in
keep producing byte-for-byte identical ciphertext.
Defaults to AES-256-GCM. ``xsalsa20-poly1305`` stays available as an explicit opt-in for deployments that
still need byte-for-byte legacy output, except under ``LITELLM_FIPS_MODE`` where it is refused.
"""
try:
from litellm.proxy.proxy_server import general_settings
algo: Final = general_settings.get(_ENCRYPTION_ALGORITHM_SETTING, _ALGO_XSALSA20)
except Exception:
# general_settings may not be importable in some contexts (e.g. SDK-only
# use of these helpers). Fall back to the legacy algorithm.
return _ALGO_XSALSA20
if isinstance(algo, str) and algo.lower() == _ALGO_AES_GCM:
algo: Final = general_settings.get(_ENCRYPTION_ALGORITHM_SETTING, _ALGO_AES_GCM)
except Exception: # noqa: BLE001 # proxy_server is not importable in SDK-only use of these helpers
return _ALGO_AES_GCM
if not isinstance(algo, str) or algo.lower() != _ALGO_XSALSA20:
return _ALGO_AES_GCM
if is_fips_mode():
raise FipsModeError(
f"general_settings.{_ENCRYPTION_ALGORITHM_SETTING}={_ALGO_XSALSA20} is not allowed under "
f"LITELLM_FIPS_MODE: XSalsa20-Poly1305 is not a FIPS approved algorithm. Remove the setting to write "
f"{_ALGO_AES_GCM}"
)
return _ALGO_XSALSA20
def _derive_key(signing_key: str) -> bytes:
"""Derive a 32-byte key from the salt/master key (shared by both algorithms).
Known limitation: this is a single-pass, unsalted ``SHA-256`` of the key, not
a dedicated KDF (HKDF/PBKDF2). It is the *same* derivation the legacy nacl
path already uses, so the AES path introduces no new weakness and stays
interoperable with existing key sourcing; AES-256-GCM's per-value 12-byte
random nonce gives the unique (key, nonce) pairs GCM requires. Moving both
algorithms to HKDF-SHA256 would be more defensible in an audit but is a
separate, coordinated change (it must re-derive or re-encrypt existing data).
"""
import hashlib
def _derive_key_sha256(signing_key: str) -> bytes:
"""Historical derivation shared by legacy nacl values and ``v2:gcm:`` values: one unsalted SHA-256."""
return hashlib.sha256(signing_key.encode()).digest()
def _derive_key_hkdf(signing_key: str) -> bytes:
"""Derivation for ``v3:gcm:`` values: HKDF-SHA256 with a fixed info string."""
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
return HKDF(algorithm=hashes.SHA256(), length=32, salt=None, info=_HKDF_INFO).derive(signing_key.encode())
def _seal_aes_gcm(value: str, signing_key: str, aad: bytes | None) -> bytes:
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
nonce: Final = os.urandom(12)
# AESGCM.encrypt returns ciphertext || tag(16); wire format is nonce || that.
return nonce + AESGCM(_derive_key(signing_key)).encrypt(nonce, value.encode("utf-8"), aad)
return nonce + AESGCM(_derive_key_sha256(signing_key)).encrypt(nonce, value.encode("utf-8"), aad)
def _open_aes_gcm(sealed: bytes, signing_key: str, aad: bytes | None) -> str:
@ -86,19 +105,33 @@ def _open_aes_gcm(sealed: bytes, signing_key: str, aad: bytes | None) -> str:
# An empty plaintext still serializes to nonce(12) || tag(16) = 28 bytes, so a
# short/empty buffer here is a corrupt value: let AESGCM.decrypt raise and be
# swallowed by the caller (returns None/original), same as legacy.
return AESGCM(_derive_key(signing_key)).decrypt(sealed[:12], sealed[12:], aad).decode("utf-8")
return AESGCM(_derive_key_sha256(signing_key)).decrypt(sealed[:12], sealed[12:], aad).decode("utf-8")
def _encrypt_aes_gcm(value: str, signing_key: str) -> str:
"""Encrypt under AES-256-GCM and return the versioned ``v2:gcm:`` string."""
sealed: Final = _seal_aes_gcm(value=value, signing_key=signing_key, aad=None)
return _V2_GCM_PREFIX + base64.urlsafe_b64encode(sealed).decode("utf-8")
"""Encrypt under AES-256-GCM and return the versioned ``v3:gcm:`` string."""
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
nonce: Final = os.urandom(12)
# AESGCM.encrypt returns ciphertext || tag(16); wire format is nonce || that.
blob: Final = AESGCM(_derive_key_hkdf(signing_key)).encrypt(nonce, value.encode("utf-8"), None)
return _V3_GCM_PREFIX + base64.urlsafe_b64encode(nonce + blob).decode("utf-8")
def _decrypt_aes_gcm(value: str, signing_key: str) -> str:
"""Decrypt a versioned ``v2:gcm:`` string produced by :func:`_encrypt_aes_gcm`."""
sealed: Final = base64.urlsafe_b64decode(value[len(_V2_GCM_PREFIX) :])
return _open_aes_gcm(sealed=sealed, signing_key=signing_key, aad=None)
"""Decrypt a versioned ``v3:gcm:`` or ``v2:gcm:`` string, deriving the key the way its version was written."""
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
if value.startswith(_V3_GCM_PREFIX):
key, prefix = _derive_key_hkdf(signing_key), _V3_GCM_PREFIX
else:
key, prefix = _derive_key_sha256(signing_key), _V2_GCM_PREFIX
raw: Final = base64.urlsafe_b64decode(value[len(prefix) :])
# An empty plaintext still serializes to nonce(12) || tag(16) = 28 bytes, so a
# short/empty buffer here is a corrupt value: let AESGCM.decrypt raise and be
# swallowed by decrypt_value_helper (returns None/original), same as legacy.
nonce, blob = raw[:12], raw[12:]
return AESGCM(key).decrypt(nonce, blob, None).decode("utf-8")
def encrypt_bearer_token(value: str, prefix: str) -> str:
@ -129,7 +162,7 @@ def encrypt_value_helper(value: str, new_encryption_key: str | None = None):
try:
if isinstance(value, str):
if _get_encryption_algorithm() == _ALGO_AES_GCM:
# AES path: the v2:gcm: output is already a base64url string, so it
# AES path: the v3:gcm: output is already a base64url string, so it
# is returned directly with no extra base64 wrapper.
return _encrypt_aes_gcm(value=value, signing_key=cast(str, signing_key))
@ -160,19 +193,26 @@ def _legacy_ciphertext_bytes(value: str) -> bytes:
def _decrypt_with_signing_key(value: str, signing_key: str) -> str:
# Versioned AES-256-GCM values are detected before any base64 decode.
# The prefix is the algorithm tag the legacy nacl format never carried.
if value.startswith(_V2_GCM_PREFIX):
if is_versioned_gcm(value):
return _decrypt_aes_gcm(value=value, signing_key=signing_key)
return decrypt_value(value=_legacy_ciphertext_bytes(value), signing_key=signing_key)
def decrypt_if_encrypted_with(value: str, signing_key: str) -> str | None:
"""None unless value is a ciphertext under signing_key."""
"""None unless value is a ciphertext under signing_key.
A legacy ciphertext met without PyNaCl installed is logged with the re-encrypt path and read as None,
never handed back as if it were the plaintext.
"""
try:
# base64 decoding skips characters outside its alphabet, so "" and "*" decode to no bytes,
# which decrypt_value reads as an empty plaintext under any key.
decodes_to_nothing: Final = not value.startswith(_V2_GCM_PREFIX) and not _legacy_ciphertext_bytes(value)
decodes_to_nothing: Final = not is_versioned_gcm(value) and not _legacy_ciphertext_bytes(value)
return None if decodes_to_nothing else _decrypt_with_signing_key(value=value, signing_key=signing_key)
except LegacyEncryptionUnavailableError as error:
verbose_proxy_logger.error("%s", error)
return None
except Exception: # noqa: BLE001 # base64, nacl and AES-GCM each raise their own "not a ciphertext" type
return None
@ -191,6 +231,9 @@ def decrypt_value_helper(
# if it's not str - do not decrypt it, return the value
return value
except LegacyEncryptionUnavailableError as error:
verbose_proxy_logger.error("Cannot decrypt value for key: %s. %s", key, error)
return None
except Exception as e:
error_message = f"Error decrypting value for key: {key}, Did your master_key/salt key change recently? \nError: {e}\nSet permanent salt key - https://docs.litellm.ai/docs/proxy/prod#5-set-litellm-salt-key"
if exception_type == "debug":
@ -206,50 +249,58 @@ def decrypt_value_helper(
return None
def encrypt_value(value: str, signing_key: str):
import hashlib
def legacy_encryption_available() -> bool:
"""True when PyNaCl is importable, so legacy xsalsa20-poly1305 ciphertext can be read."""
try:
import nacl.secret # noqa: F401 # probe only
except ImportError:
return False
return True
import nacl.secret
import nacl.utils
# get 32 byte master key #
hash_object: Final = hashlib.sha256(signing_key.encode())
hash_bytes: Final = hash_object.digest()
def needs_legacy_reader(value: object) -> bool:
"""True for a stored string that only PyNaCl can tell apart from plaintext: non empty and unprefixed."""
return isinstance(value, str) and value != "" and not is_versioned_gcm(value)
# initialize secret box #
box: Final = nacl.secret.SecretBox(hash_bytes)
# encode message #
value_bytes: Final = value.encode("utf-8")
def legacy_unreadable(value: object) -> bool:
"""True for a stored value that only PyNaCl could read and PyNaCl is missing: such a value must read as unset,
never as a plaintext credential."""
return needs_legacy_reader(value) and not legacy_encryption_available()
encrypted: Final = box.encrypt(value_bytes)
return encrypted
def require_legacy_reader_for(values: Iterable[object], purpose: str) -> None:
"""Refuse a decrypt-then-rewrite pass when PyNaCl is missing and one of the values is unprefixed: it would
read as unreadable and be dropped, double wrapped or miscounted as plaintext. Versioned gcm and non string
values never need PyNaCl, so a fully migrated store passes."""
if legacy_encryption_available() or not any(needs_legacy_reader(value) for value in values):
return
raise LegacyEncryptionUnavailableError(
f"Cannot {purpose}: PyNaCl is needed to read legacy {_ALGO_XSALSA20} values. {_LEGACY_ENCRYPTION_HELP}"
)
def _legacy_secret_box(signing_key: str, purpose: str) -> "SecretBox":
try:
import nacl.secret
except ImportError as error:
raise LegacyEncryptionUnavailableError(
f"Cannot {purpose} with the legacy {_ALGO_XSALSA20} algorithm: PyNaCl is not installed. "
f"{_LEGACY_ENCRYPTION_HELP}"
) from error
return nacl.secret.SecretBox(_derive_key_sha256(signing_key))
def encrypt_value(value: str, signing_key: str) -> bytes:
return bytes(_legacy_secret_box(signing_key, "encrypt").encrypt(value.encode("utf-8")))
def decrypt_value(value: bytes, signing_key: str) -> str:
import hashlib
import nacl.secret
import nacl.utils
# get 32 byte master key #
hash_object: Final = hashlib.sha256(signing_key.encode())
hash_bytes: Final = hash_object.digest()
# initialize secret box #
box: Final = nacl.secret.SecretBox(hash_bytes)
# Convert the bytes object to a string
try:
if len(value) == 0:
return ""
plaintext = box.decrypt(value)
plaintext = plaintext.decode("utf-8")
return plaintext
except Exception as e:
raise e
if len(value) == 0:
return ""
if len(value) < _NACL_MIN_CIPHERTEXT_BYTES:
raise ValueError(f"Value of {len(value)} bytes is too short to be a {_ALGO_XSALSA20} ciphertext")
return _legacy_secret_box(signing_key, "decrypt a stored value").decrypt(value).decode("utf-8")
class SecretMapDecodeError(RuntimeError):

View file

@ -2,9 +2,9 @@
At-rest credential re-encryption migration.
Switches every encrypted-at-rest value from the legacy XSalsa20-Poly1305 (nacl)
format to the versioned AES-256-GCM (``v2:gcm:``) format produced by
``encrypt_decrypt_utils`` when ``general_settings.encryption_algorithm`` is set to
``aes-256-gcm``.
format to the versioned AES-256-GCM (``v3:gcm:``) format produced by
``encrypt_decrypt_utils`` by default (``general_settings.encryption_algorithm``
not set to the legacy ``xsalsa20-poly1305``).
Design properties (see case 2026-06-24 fix plan):
@ -12,7 +12,7 @@ Design properties (see case 2026-06-24 fix plan):
it re-encrypts existing ciphertext under the same derived key but in the new
AES format. This is achieved by decrypting with the format-detecting reader and
re-encrypting through ``encrypt_value_helper`` with the AES gate enabled.
* **Idempotent.** A value already carrying the ``v2:gcm:`` prefix is recognised
* **Idempotent.** A value already carrying a ``v3:gcm:`` or ``v2:gcm:`` prefix is recognised
and left untouched, so re-running the migration is a no-op on migrated rows.
* **Resumable.** Walkers commit per row (or per small table), so an interrupted
run leaves a clean mixed state that a re-run completes.
@ -31,6 +31,7 @@ config rows, and the SSO config table.
"""
import json
from collections.abc import Iterator
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Final, Literal, cast
@ -43,12 +44,14 @@ if TYPE_CHECKING:
from litellm.proxy.common_utils.encrypt_decrypt_utils import (
_ALGO_AES_GCM,
_ENCRYPTION_ALGORITHM_SETTING,
_V2_GCM_PREFIX,
SecretMapDecodeError,
_get_encryption_algorithm,
_get_salt_key,
decode_secret_map,
decrypt_value_helper,
encrypt_value_helper,
is_versioned_gcm,
require_legacy_reader_for,
)
ValueClass = Literal["migrated", "legacy", "plaintext", "undecryptable", "not-a-string"]
@ -60,7 +63,7 @@ class LocationReport:
location: str
scanned: int = 0
migrated: int = 0 # values rewritten to v2 this run
migrated: int = 0 # values rewritten to versioned AES this run
already_v2: int = 0 # values already migrated (skipped)
plaintext: int = 0 # legacy-plaintext values (no ciphertext to migrate)
undecryptable: int = 0 # could not decrypt — preserved, not overwritten
@ -125,15 +128,15 @@ class MigrationReport:
def is_migrated(value: object) -> bool:
"""True if ``value`` is already an AES-256-GCM (``v2:gcm:``) ciphertext."""
return isinstance(value, str) and value.startswith(_V2_GCM_PREFIX)
"""True if ``value`` is already a versioned AES-256-GCM (``v3:gcm:`` or ``v2:gcm:``) ciphertext."""
return isinstance(value, str) and is_versioned_gcm(value)
def classify_value(value: object, key: str = "scan") -> ValueClass:
"""Classify a stored value for the residual scanner.
* ``not-a-string`` — not a string (numbers/bools/None left as-is on disk).
* ``migrated`` — carries the ``v2:gcm:`` prefix.
* ``migrated`` — carries a versioned ``gcm`` prefix.
* ``legacy`` — decrypts under the legacy nacl reader (still needs migrating).
* ``plaintext`` — a non-empty string that does not decrypt and is not v2;
treated as legacy plaintext (nothing to migrate).
@ -145,8 +148,9 @@ def classify_value(value: object, key: str = "scan") -> ValueClass:
return "not-a-string"
if value == "":
return "plaintext"
if value.startswith(_V2_GCM_PREFIX):
if is_versioned_gcm(value):
return "migrated"
require_legacy_reader_for((value,), "scan stored encryption")
decrypted: Final = decrypt_value_helper(value=value, key=key, exception_type="debug", return_original_value=False)
if decrypted is None:
# Did not decrypt under nacl and has no v2 marker: legacy plaintext.
@ -157,15 +161,16 @@ def classify_value(value: object, key: str = "scan") -> ValueClass:
def reencrypt_value(value: object, key: str = "migrate") -> object:
"""Re-encrypt a single stored string into the configured (AES) format.
Returns the value unchanged if it is not a string, is already ``v2:``, or
Returns the value unchanged if it is not a string, is already versioned AES, or
cannot be decrypted (skip-on-undecryptable). Otherwise decrypts under the
format-detecting reader and re-encrypts through ``encrypt_value_helper``
(which writes AES when the gate is on).
"""
if not isinstance(value, str) or value == "":
return value
if value.startswith(_V2_GCM_PREFIX):
if is_versioned_gcm(value):
return value # idempotent: already migrated
require_legacy_reader_for((value,), "migrate stored encryption")
decrypted: Final = decrypt_value_helper(value=value, key=key, exception_type="debug", return_original_value=False)
if decrypted is None:
# Either legacy plaintext (no ciphertext to migrate) or corrupt. Either
@ -195,13 +200,11 @@ def _assert_aes_gate_enabled() -> None:
Running the migration with the gate off would decrypt then re-encrypt right
back into the legacy format — a no-op that silently fails the migration.
"""
from litellm.proxy.proxy_server import general_settings
algo: Final = general_settings.get(_ENCRYPTION_ALGORITHM_SETTING)
if not (isinstance(algo, str) and algo.lower() == _ALGO_AES_GCM):
algo: Final = _get_encryption_algorithm()
if algo != _ALGO_AES_GCM:
raise RuntimeError(
"Encryption migration requires general_settings.encryption_algorithm: "
f"'{_ALGO_AES_GCM}'. Current value: {algo!r}. Set it before migrating "
f"Encryption migration requires general_settings.{_ENCRYPTION_ALGORITHM_SETTING}: "
f"'{_ALGO_AES_GCM}' (the default). Current value: {algo!r}. Remove the legacy opt-in before migrating "
"so re-encrypted values are written in the AES-256-GCM format."
)
@ -430,7 +433,7 @@ def _classify_callback_value(value: object) -> ValueClass:
Encrypted callback vars carry the ``litellm_enc::`` marker in front of the
ciphertext; strip it, then classify the inner value the same way the
covered-table scanner does (``v2:gcm:`` prefix -> migrated, nacl-decryptable
covered-table scanner does (versioned ``gcm`` prefix -> migrated, nacl-decryptable
-> legacy, otherwise plaintext). Detecting legacy by decrypt rather than by a
re-encrypt delta is what makes the ``check_encryption`` attestation correct
even when run with the AES write gate off.
@ -463,8 +466,11 @@ _COVERED_TABLE_SPECS: Final = [
("mcp_server", "litellm_mcpservertable", ("credentials", "env_vars", "static_headers", "env"), ()),
("mcp_user_credentials", "litellm_mcpusercredentials", (), ("credential_b64",)),
("mcp_user_env_vars", "litellm_mcpuserenvvars", (), ("values_b64",)),
("mcp_oauth_client", "litellm_mcpserveroauthclient", ("credentials",), ()),
("search_tools", "litellm_searchtoolstable", ("litellm_params",), ()),
("sso_identity_assertion", "litellm_ssoidentityassertion", (), ("assertion_b64",)),
]
_MCP_CREDENTIAL_TABLES: Final = frozenset({"litellm_mcpservertable", "litellm_mcpserveroauthclient"})
def _iter_encrypted_strings(obj: object):
@ -485,6 +491,31 @@ def _iter_encrypted_strings(obj: object):
stack.extend(cur)
def _mcp_encrypted_leaves(col: str, raw: object) -> Iterator[str]:
"""Only the strings an MCP column encrypts at rest: a credentials blob keeps auth_type, scopes and urls in
plaintext and env_vars keeps every name and every per user placeholder, so without PyNaCl those must not be
mistaken for legacy ciphertext and refuse the scan"""
from litellm.proxy._experimental.mcp_server.db import MCP_CREDENTIAL_SECRET_FIELDS, is_global_env_var_scope
if col == "credentials":
if not isinstance(raw, dict):
return iter(())
return (v for k, v in raw.items() if k in MCP_CREDENTIAL_SECRET_FIELDS and isinstance(v, str))
if not isinstance(raw, list):
return iter(())
return (
e["value"]
for e in raw
if isinstance(e, dict) and is_global_env_var_scope(e.get("scope")) and isinstance(e.get("value"), str)
)
def _encrypted_leaves(db_attr: str, col: str, raw: object) -> Iterator[str]:
if db_attr in _MCP_CREDENTIAL_TABLES and col in ("credentials", "env_vars"):
return _mcp_encrypted_leaves(col, raw)
return _iter_encrypted_strings(raw)
def _classify_into_report(report: LocationReport, value: str) -> None:
"""Classify one stored string and bump the matching read-only counter.
@ -495,14 +526,27 @@ def _classify_into_report(report: LocationReport, value: str) -> None:
report.count(classify_value(value, key="scan"))
def _secret_map_ciphertext(value: object) -> object:
"""The stored ciphertext behind an MCP secret map: the JSON quoted string is unwrapped, a plain map or
anything that is not JSON is returned as is for ``decode_secret_map`` to judge."""
if not isinstance(value, str) or not value.lstrip().startswith('"'):
return value
try:
return json.loads(value)
except ValueError:
return value
def _classify_secret_map(value: object, key: str) -> ValueClass | None:
ciphertext: Final = _secret_map_ciphertext(value)
if isinstance(ciphertext, str) and not ciphertext.lstrip().startswith("{"):
require_legacy_reader_for((ciphertext,), "scan stored encryption")
try:
decoded: Final = decode_secret_map(value, key=key)
except SecretMapDecodeError:
return "undecryptable"
if not decoded:
return None
ciphertext: Final = json.loads(value) if isinstance(value, str) and value.lstrip().startswith('"') else value
return "migrated" if is_migrated(ciphertext) else "legacy"
@ -535,7 +579,7 @@ async def _scan_one_table(
raw = json.loads(raw)
except (ValueError, TypeError):
pass
for s in _iter_encrypted_strings(raw):
for s in _encrypted_leaves(db_attr, col, raw):
_classify_into_report(report, s)
for col in scalar_columns:
v = getattr(row, col, None)
@ -565,7 +609,7 @@ async def _scan_config_env_vars(prisma_client: object) -> LocationReport:
return report
async def _scan_covered_tables(prisma_client: object) -> list[LocationReport]:
async def scan_covered_tables(prisma_client: object) -> list[LocationReport]:
"""Read-only classification of every rotation-covered table. No writes."""
reports: Final[list[LocationReport]] = []
for location, db_attr, json_cols, scalar_cols in _COVERED_TABLE_SPECS:
@ -598,7 +642,7 @@ async def _migrate_covered_tables(prisma_client: object, user_api_key_dict: obje
_rotate_master_key,
)
pre: Final = {r.location: r for r in await _scan_covered_tables(prisma_client)}
pre: Final = {r.location: r for r in await scan_covered_tables(prisma_client)}
current_key: Final = _get_salt_key()
if current_key is None:
@ -612,7 +656,7 @@ async def _migrate_covered_tables(prisma_client: object, user_api_key_dict: obje
new_master_key=current_key, # same key, algorithm-only switch
)
post: Final = await _scan_covered_tables(prisma_client)
post: Final = await scan_covered_tables(prisma_client)
for post_report in post:
pre_report = pre.get(post_report.location)
pre_legacy = pre_report.legacy if pre_report else 0
@ -629,8 +673,8 @@ async def migrate_encryption(
) -> MigrationReport:
"""Run the full at-rest re-encryption migration.
Requires ``general_settings.encryption_algorithm == 'aes-256-gcm'`` so writes
are produced in the AES format. Idempotent and resumable: re-running skips
Requires the proxy to write ``aes-256-gcm`` (the default) so re-encrypted
values are produced in the AES format. Idempotent and resumable: re-running skips
already-migrated values and finishes any partial run.
A ``dry_run`` performs no writes: the covered tables are scanned read-only
@ -645,7 +689,7 @@ async def migrate_encryption(
# delegate to the rotation path (with bracketing scans for counts); on a dry
# run only classify them read-only.
if dry_run:
for covered in await _scan_covered_tables(prisma_client):
for covered in await scan_covered_tables(prisma_client):
report.add(covered)
else:
for covered in await _migrate_covered_tables(prisma_client, user_api_key_dict):
@ -673,7 +717,7 @@ async def check_encryption(prisma_client: object) -> MigrationReport:
report: Final = MigrationReport()
# Rotation-covered tables (read-only classification).
for covered in await _scan_covered_tables(prisma_client):
for covered in await scan_covered_tables(prisma_client):
report.add(covered)
# Net-new walker locations, in dry-run (read-only) mode.

View file

@ -81,6 +81,10 @@ from litellm.proxy.common_utils.config_sync_pubsub import (
coordination_redis_cache,
publish_config_change,
)
from litellm.proxy.common_utils.encrypt_decrypt_utils import (
LegacyEncryptionUnavailableError,
legacy_encryption_available,
)
from litellm.proxy.common_utils.rbac_utils import check_org_admin_can_generate_keys
from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time
from litellm.proxy.common_utils.user_api_key_cache import AUTH_OBJECTS_TARGET, UserApiKeyCache
@ -5216,6 +5220,16 @@ async def delete_key_aliases(
)
async def _require_legacy_reader_for_stored_values(prisma_client: PrismaClient) -> None:
"""Without PyNaCl, scan the rotation covered tables read only so an unprefixed stored value refuses the
rotation before any row is rewritten. A fully migrated store passes; with PyNaCl installed nothing runs"""
if legacy_encryption_available():
return
from litellm.proxy.management_endpoints.credential_migration import scan_covered_tables
await scan_covered_tables(prisma_client)
async def _rotate_master_key(
prisma_client: PrismaClient,
user_api_key_dict: UserAPIKeyAuth,
@ -5239,6 +5253,14 @@ async def _rotate_master_key(
from litellm.proxy.proxy_server import proxy_config
try:
await _require_legacy_reader_for_stored_values(prisma_client)
except LegacyEncryptionUnavailableError as error:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": str(error)},
) from error
try:
models: list | None = cast( # cast-ok: find_many returns a real list, which TableActions widens to Sequence
"list[object]", await _prisma_table(ModelRepository(prisma_client)).find_many()
@ -5410,9 +5432,9 @@ async def migrate_encryption_endpoint(
),
):
"""
Re-encrypt all at-rest credentials into the AES-256-GCM (``v2:gcm:``) format.
Re-encrypt all at-rest credentials into the versioned AES-256-GCM (``v3:gcm:``) format.
Admin only. Requires ``general_settings.encryption_algorithm: aes-256-gcm``.
Admin only. Requires the proxy to write ``aes-256-gcm`` (the default).
Idempotent and resumable — re-running skips already-migrated values. Pass
``dry_run=true`` for a non-mutating scan (equivalent to ``--check``).
"""

View file

@ -414,13 +414,14 @@ from litellm.proxy.common_utils.callback_utils import initialize_callbacks_on_pr
from litellm.proxy.common_utils.codex_model_catalog import codex_model_list_body
from litellm.proxy.common_utils.config_includes import resolve_include_file_path, resolve_includes
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.credential_hydration import decrypted_values
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.discoverable_model_filter import discoverable_rows, undiscoverable_model_names
from litellm.proxy.common_utils.encrypt_decrypt_utils import (
decrypt_value_helper,
encrypt_value_helper,
require_legacy_reader_for,
)
from litellm.proxy.common_utils.error_body_call_id import JSON_OBJECT, error_body_call_id, with_call_id
from litellm.proxy.common_utils.fips import (
@ -7695,6 +7696,9 @@ class ProxyConfig:
_decrypt_and_set_db_env_variables): this is a write path, and
loading values into os.environ is the read path's responsibility.
"""
require_legacy_reader_for(
environment_variables.values(), "re-encrypt environment variables for the config save"
)
decrypted_env_vars: Final = self._decrypt_db_variables(environment_variables)
return self._encrypt_env_variables(
environment_variables=decrypted_env_vars,
@ -9145,16 +9149,10 @@ class ProxyConfig:
await initialize_pass_through_endpoints_in_db()
def decrypt_credentials(self, credential: dict | BaseModel) -> CredentialItem:
if isinstance(credential, dict):
credential_object = CredentialItem(**credential)
elif isinstance(credential, BaseModel):
credential_object = CredentialItem(**credential.model_dump())
decrypted_credential_values: Final = {}
for k, v in credential_object.credential_values.items():
decrypted_credential_values[k] = decrypted_or_stored(k, v)
credential_object.credential_values = decrypted_credential_values
credential_object: Final = CredentialItem(
**(credential if isinstance(credential, dict) else credential.model_dump())
)
credential_object.credential_values = dict(decrypted_values(credential_object.credential_values))
return credential_object
async def delete_credentials(self, db_credentials: list[CredentialItem]):

View file

@ -55,6 +55,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=litellm-proxy-extras/pyproject.toml,target=litellm-proxy-extras/pyproject.toml \
uv sync --frozen --no-install-project --no-install-workspace --no-default-groups --no-editable \
--extra proxy \
--extra legacy-encryption \
--extra extra_proxy \
--python python3.13
@ -64,6 +65,7 @@ COPY . .
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-default-groups --no-editable \
--extra proxy \
--extra legacy-encryption \
--extra extra_proxy \
--python python3.13

View file

@ -68,7 +68,6 @@ proxy = [
"PyJWT>=2.13.0,<3.0",
"python-multipart>=0.0.27,<1.0",
"cryptography>=49.0.0,<51.0",
"pynacl>=1.6.2,<2.0",
"websockets>=15.0.1,<16.0",
"boto3>=1.43.1,<2.0",
"azure-identity>=1.25.2,<2.0",
@ -122,6 +121,8 @@ utils = [
"numpydoc>=1.8.0,<2.0",
]
caching = ["diskcache>=5.6.3,<6.0"]
# Reads and writes the legacy XSalsa20-Poly1305 at-rest format; the FIPS image omits it
legacy-encryption = ["pynacl>=1.6.2,<2.0"]
mcp = ["mcp>=2.2.0,<3", "httpx2>=2.5.0,<3", "pydantic>=2.12.0,<3"]
# Driver for the MongoDB Atlas vector store; Atlas Vector Search has no HTTP query API.
# The floor is 4.9 because that is the release AsyncMongoClient landed in.
@ -253,6 +254,7 @@ proxy-dev = [
"opentelemetry-instrumentation-fastapi==0.54b1",
"azure-identity==1.25.2",
"a2a-sdk==1.1.0",
"pynacl>=1.6.2,<2.0",
]
ci = [
"psutil==7.2.2",

View file

@ -0,0 +1,263 @@
"""At-rest encryption writes the versioned AES-256-GCM format and keeps reading every older format.
The proxy, Postgres and the router run for real. Older rows are built by hand from the wire formats litellm
owns (nacl SecretBox and AES-GCM under the raw SHA-256 derivation) and inserted straight into the model table,
the way a deployment upgraded in place would find them. Every read is proven at the scripted upstream: the
Authorization header it receives is the plaintext key the proxy decrypted.
"""
import base64
import hashlib
import json
import os
import signal
import uuid
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import Final
import httpx
import nacl.secret
import psycopg
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from tests.integration._support.client import JSON_OBJECT, Gateway, Scenario, eventually, object_value, string_value
from tests.integration._support.database import read_rows
from tests.integration._support.process import owned_proxy_process
V3_PREFIX: Final = "v3:gcm:"
V2_PREFIX: Final = "v2:gcm:"
def _salt_key() -> str:
return os.environ.get("LITELLM_SALT_KEY", "sk-integration-salt")
def _sha256_key() -> bytes:
return hashlib.sha256(_salt_key().encode()).digest()
def _legacy_nacl_ciphertext(plaintext: str) -> str:
sealed: Final = nacl.secret.SecretBox(_sha256_key()).encrypt(plaintext.encode())
return base64.urlsafe_b64encode(bytes(sealed)).decode()
def _legacy_v2_ciphertext(plaintext: str) -> str:
nonce: Final = os.urandom(12)
blob: Final = AESGCM(_sha256_key()).encrypt(nonce, plaintext.encode(), None)
return V2_PREFIX + base64.urlsafe_b64encode(nonce + blob).decode()
def _stored_api_key(model_id: str) -> str:
rows: Final = read_rows(
"SELECT litellm_params->>'api_key' AS api_key FROM \"LiteLLM_ProxyModelTable\" WHERE model_id = %s",
(model_id,),
)
assert len(rows) == 1, rows
return string_value(rows[0]["api_key"])
def _stored_credential_api_key(name: str) -> str:
rows: Final = read_rows(
"SELECT credential_values->>'api_key' AS api_key FROM \"LiteLLM_CredentialsTable\" WHERE credential_name = %s",
(name,),
)
assert len(rows) == 1, rows
return string_value(rows[0]["api_key"])
def _insert_model_row(gateway: Gateway, scenario: Scenario, ciphertext: str) -> tuple[str, str]:
model_id: Final = f"enc-{uuid.uuid4().hex}"
model_name: Final = f"integration-{uuid.uuid4().hex}"
params: Final = {
"model": "openai/gpt-4o-mini",
"api_key": ciphertext,
"api_base": f"{gateway.upstream_url}/v1",
}
with psycopg.connect(os.environ["DATABASE_URL"], autocommit=True) as connection:
connection.execute(
'INSERT INTO "LiteLLM_ProxyModelTable" '
"(model_id, model_name, litellm_params, model_info, created_by, updated_by) "
"VALUES (%s, %s, %s::jsonb, %s::jsonb, %s, %s)",
(model_id, model_name, json.dumps(params), json.dumps({"id": model_id}), "integration", "integration"),
)
scenario.cleanups.callback(_delete_model_row_if_present, gateway, model_id)
return model_id, model_name
def _delete_model_row_if_present(gateway: Gateway, model_id: str) -> None:
response: Final = gateway.request("POST", "/model/delete", {"id": model_id})
assert response.status_code in (200, 400, 404), response.text
with psycopg.connect(os.environ["DATABASE_URL"], autocommit=True) as connection:
connection.execute('DELETE FROM "LiteLLM_ProxyModelTable" WHERE model_id = %s', (model_id,))
def _new_model(gateway: Gateway, scenario: Scenario, api_key: str) -> tuple[str, str]:
model_name: Final = f"integration-{uuid.uuid4().hex}"
created: Final = gateway.post(
"/model/new",
{
"model_name": model_name,
"litellm_params": {
"model": "openai/gpt-4o-mini",
"api_key": api_key,
"api_base": f"{gateway.upstream_url}/v1",
},
"model_info": {},
},
)
model_id: Final = string_value(object_value(created["model_info"])["id"])
scenario.cleanups.callback(scenario.delete_model, model_id)
return model_id, model_name
def _drain_upstream(gateway: Gateway) -> tuple[tuple[str, str], ...]:
with httpx.Client(base_url=gateway.upstream_url, timeout=15, trust_env=False) as upstream:
observed: Final = upstream.get("/__observations")
observed.raise_for_status()
requests: Final = JSON_OBJECT.validate_json(observed.content)["requests"]
assert isinstance(requests, list), requests
entries: Final = tuple(object_value(value) for value in requests)
return tuple((json.dumps(entry["body"]), string_value(entry["authorization"])) for entry in entries)
def _authorizations_for(observed: tuple[tuple[str, str], ...], marker: str) -> tuple[str, ...]:
return tuple(authorization for body, authorization in observed if marker in body)
def _chat_reaches_upstream_with(gateway: Gateway, model_name: str, plaintext_key: str) -> None:
marker: Final = f"marker-{uuid.uuid4().hex}"
response: Final = eventually(
lambda: gateway.request(
"POST", "/v1/chat/completions", {"model": model_name, "messages": [{"role": "user", "content": marker}]}
),
lambda observed: observed.status_code == 200,
seconds=70,
)
assert response.status_code == 200, response.text
assert _authorizations_for(_drain_upstream(gateway), marker) == (f"Bearer {plaintext_key}",)
def _model_table_count(gateway: Gateway, counter: str) -> int:
report: Final = object_value(gateway.get("/credentials/migrate-encryption/check")["report"])
value: Final = object_value(object_value(report["locations"])["model_table"])[counter]
assert isinstance(value, int), report
return value
def test_new_model_api_key_is_stored_as_v3_gcm_and_decrypts_for_the_upstream_call(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
plaintext_key: Final = f"sk-upstream-{uuid.uuid4().hex}"
model_id, model_name = _new_model(gateway, scenario, plaintext_key)
stored: Final = _stored_api_key(model_id)
assert stored.startswith(V3_PREFIX), f"expected a {V3_PREFIX} ciphertext, stored {stored!r}"
assert plaintext_key not in stored
_chat_reaches_upstream_with(gateway, model_name, plaintext_key)
def test_new_credential_values_are_stored_as_v3_gcm(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
name: Final = f"credential-{uuid.uuid4().hex}"
plaintext_key: Final = f"sk-credential-{uuid.uuid4().hex}"
gateway.post(
"/credentials",
{"credential_name": name, "credential_values": {"api_key": plaintext_key}, "credential_info": {}},
)
scenario.cleanups.callback(gateway.request, "DELETE", f"/credentials/{name}")
stored: Final = _stored_credential_api_key(name)
assert stored.startswith(V3_PREFIX), f"expected a {V3_PREFIX} ciphertext, stored {stored!r}"
assert plaintext_key not in stored
def test_legacy_nacl_model_row_still_decrypts_for_the_upstream_call(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
plaintext_key: Final = f"sk-nacl-{uuid.uuid4().hex}"
_, model_name = _insert_model_row(gateway, scenario, _legacy_nacl_ciphertext(plaintext_key))
_chat_reaches_upstream_with(gateway, model_name, plaintext_key)
def test_v2_gcm_model_row_still_decrypts_for_the_upstream_call(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
plaintext_key: Final = f"sk-v2-{uuid.uuid4().hex}"
_, model_name = _insert_model_row(gateway, scenario, _legacy_v2_ciphertext(plaintext_key))
_chat_reaches_upstream_with(gateway, model_name, plaintext_key)
def test_encryption_check_counts_v3_as_migrated_and_nacl_as_legacy(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
migrated_before: Final = _model_table_count(gateway, "already_v2")
legacy_before: Final = _model_table_count(gateway, "legacy")
_new_model(gateway, scenario, f"sk-upstream-{uuid.uuid4().hex}")
_insert_model_row(gateway, scenario, _legacy_nacl_ciphertext(f"sk-nacl-{uuid.uuid4().hex}"))
_insert_model_row(gateway, scenario, _legacy_v2_ciphertext(f"sk-v2-{uuid.uuid4().hex}"))
migrated_after: Final = _model_table_count(gateway, "already_v2")
assert migrated_after == migrated_before + 3 + 1, (
"expected every litellm_params value of the new model (model, api_key, api_base) plus the v2 row "
f"counted as migrated, before {migrated_before} after {migrated_after}"
)
assert _model_table_count(gateway, "legacy") == legacy_before + 1
def test_migrate_encryption_runs_under_default_settings_and_rewrites_legacy_rows(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
plaintext_key: Final = f"sk-nacl-{uuid.uuid4().hex}"
model_id, model_name = _insert_model_row(gateway, scenario, _legacy_nacl_ciphertext(plaintext_key))
migrated: Final = gateway.request("POST", "/credentials/migrate-encryption", {})
assert migrated.status_code == 200, migrated.text
rewritten: Final = _stored_api_key(model_id)
assert rewritten.startswith(V3_PREFIX), f"expected a {V3_PREFIX} ciphertext, stored {rewritten!r}"
_chat_reaches_upstream_with(gateway, model_name, plaintext_key)
def test_undecryptable_model_row_does_not_stop_other_models_from_serving(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
_insert_model_row(gateway, scenario, base64.b64encode(os.urandom(48)).decode())
healthy_key: Final = f"sk-upstream-{uuid.uuid4().hex}"
_, healthy_model = _new_model(gateway, scenario, healthy_key)
_chat_reaches_upstream_with(gateway, healthy_model, healthy_key)
assert gateway.request("GET", "/health/liveliness").status_code == 200
def _burst_request(gateway: Gateway, model: str, marker: str) -> tuple[str, int]:
try:
response: Final = gateway.request(
"POST", "/v1/chat/completions", {"model": model, "messages": [{"role": "user", "content": marker}]}
)
return marker, response.status_code
except httpx.TransportError:
return marker, 0
def test_proxy_restart_mid_burst_reloads_every_encryption_format_from_the_database(
gateway: Gateway, tmp_path: Path
) -> None:
with gateway.scenario() as scenario:
keys: Final = (f"sk-nacl-{uuid.uuid4().hex}", f"sk-v2-{uuid.uuid4().hex}", f"sk-upstream-{uuid.uuid4().hex}")
_, nacl_model = _insert_model_row(gateway, scenario, _legacy_nacl_ciphertext(keys[0]))
_, v2_model = _insert_model_row(gateway, scenario, _legacy_v2_ciphertext(keys[1]))
_, v3_model = _new_model(gateway, scenario, keys[2])
models: Final = (nacl_model, v2_model, v3_model)
expected: Final = dict(zip(models, (f"Bearer {key}" for key in keys), strict=True))
markers: Final = tuple(f"burst-{uuid.uuid4().hex}" for _ in range(24))
with owned_proxy_process(gateway, tmp_path, {}, workers=2) as first:
for model, key in zip(models, keys, strict=True):
_chat_reaches_upstream_with(first.gateway, model, key)
with ThreadPoolExecutor(max_workers=24) as pool:
futures: Final = tuple(
pool.submit(_burst_request, first.gateway, models[index % 3], marker)
for index, marker in enumerate(markers)
)
eventually(lambda: sum(future.done() for future in futures), lambda done: done >= 6, seconds=60)
os.killpg(first.process.pid, signal.SIGTERM)
outcomes: Final = tuple(future.result(timeout=120) for future in futures)
served: Final = frozenset(marker for marker, status in outcomes if status == 200)
assert served, outcomes
observed: Final = _drain_upstream(gateway)
for index, marker in enumerate(markers):
seen = _authorizations_for(observed, marker)
assert len(seen) <= 1, (marker, seen)
if marker in served:
assert seen == (expected[models[index % 3]],), (marker, seen)
with owned_proxy_process(gateway, tmp_path, {}, workers=2) as restarted:
for model, key in zip(models, keys, strict=True):
_chat_reaches_upstream_with(restarted.gateway, model, key)

View file

@ -50,7 +50,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import
open_refresh_envelope,
user_identity,
)
from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value, encrypt_value
from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_if_encrypted_with, encrypt_value
_NOW = datetime(2026, 7, 9, 12, 0, 0, tzinfo=timezone.utc)
_SIGNING_KEY = "unit-test-signing-key-0123456789abcdef0123456789abcdef"
@ -136,7 +136,8 @@ def test_minimal_grant_round_trips_without_none_leakage_into_claims():
claims = _unverified_claims(token)
blob = claims["grant"]
assert isinstance(blob, str)
plaintext = decrypt_value(value=base64.urlsafe_b64decode(blob), signing_key=_ENCRYPTION_KEY)
plaintext = decrypt_if_encrypted_with(blob, _ENCRYPTION_KEY)
assert plaintext is not None
assert set(json.loads(plaintext)) == {"access_token", "token_type"}
opened = open_envelope(token, _KEYS, _NOW)
assert isinstance(opened, OpenedEnvelope)

View file

@ -4,7 +4,7 @@ Tests for the encrypted-at-rest persistence of MCP user credentials.
The ``LiteLLM_MCPUserCredentials.credential_b64`` column previously stored
both BYOK API keys and OAuth2 access tokens as plain ``urlsafe_b64encode``
of the raw value, leaving credentials readable from any DB read. The fix
runs every write through ``encrypt_value_helper`` (nacl SecretBox) and
runs every write through ``encrypt_value_helper`` (AES-256-GCM by default) and
keeps a plain-base64 fallback on read so existing rows continue to work.
"""
@ -515,7 +515,7 @@ async def test_secret_maps_create_update_round_trip(map_algorithm: str, field: s
created: Final = await create_mcp_server(prisma, create, touched_by="test")
first: Final = table.rows["srv-map"][field]
assert isinstance(first, str) and isinstance(json.loads(first), str)
assert json.loads(first).startswith("v2:gcm:") is (map_algorithm == "aes-256-gcm")
assert json.loads(first).startswith("v3:gcm:") is (map_algorithm == "aes-256-gcm")
assert "sensitive-secret" not in first and "TEMPLATE" not in first
assert getattr(created, field) == original == getattr(create, field)
assert decode_secret_map(first, key=field) == original

View file

@ -1,3 +1,6 @@
import base64
import hashlib
import sys
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@ -26,3 +29,32 @@ async def test_authoritative_hydrate_returns_an_encrypted_empty_value_as_empty(m
assert resolved is not None
assert resolved.credential_values == {"api_base": "", "openai_service_account_id": "user-1"}
def _legacy_nacl_ciphertext(plaintext: str, salt_key: str) -> str:
import nacl.secret
box = nacl.secret.SecretBox(hashlib.sha256(salt_key.encode()).digest())
return base64.urlsafe_b64encode(bytes(box.encrypt(plaintext.encode()))).decode()
@pytest.mark.asyncio
async def test_authoritative_hydrate_without_pynacl_drops_a_legacy_value_instead_of_serving_the_blob(monkeypatch):
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-hydration-test-salt")
legacy = _legacy_nacl_ciphertext("sk-legacy-upstream", "sk-hydration-test-salt")
row = {
"credential_name": "openai-legacy",
"credential_values": {"api_key": legacy, "api_base": encrypt_value_helper("https://api.example.test")},
"credential_info": {"custom_llm_provider": "openai"},
}
prisma = MagicMock()
prisma.db.litellm_credentialstable.find_unique = AsyncMock(return_value=row)
monkeypatch.setitem(sys.modules, "nacl", None)
monkeypatch.setitem(sys.modules, "nacl.secret", None)
with patch.object(litellm, "credential_list", []): # test-quality-ok: the row under test must win over memory
resolved = await hydrate_named_credential_authoritative("openai-legacy", prisma)
assert resolved is not None
assert resolved.credential_values == {"api_base": "https://api.example.test"}
assert legacy not in resolved.credential_values.values()

View file

@ -1,26 +1,47 @@
"""
Tests for the at-rest credential encryption chokepoint.
Covers the AES-256-GCM (``v2:gcm:``) path, the ``encryption_algorithm`` config
gate, and the backward-compatibility guarantees that let legacy XSalsa20-Poly1305
(nacl) ciphertext and new AES values coexist and decrypt correctly.
Covers the AES-256-GCM default (``v3:gcm:`` written under HKDF-SHA256), the
``encryption_algorithm`` legacy opt-in and its FIPS refusal, and the dual-read
guarantees that keep ``v2:gcm:`` (raw SHA-256 AES) and unprefixed
XSalsa20-Poly1305 (nacl) ciphertext decrypting after the default flip.
"""
import base64
import hashlib
import logging
import os
import re
import sys
import pytest
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from litellm.proxy import proxy_server
from litellm.proxy.common_utils.encrypt_decrypt_utils import (
_V2_GCM_PREFIX,
_V3_GCM_PREFIX,
LegacyEncryptionUnavailableError,
_get_encryption_algorithm,
decrypt_bearer_token,
decrypt_if_encrypted_with,
decrypt_value_helper,
encrypt_bearer_token,
encrypt_value,
encrypt_value_helper,
is_versioned_gcm,
legacy_encryption_available,
require_legacy_reader_for,
)
from litellm.proxy.common_utils.fips import FipsModeError
_SALT_KEY = "sk-salt-aes-1234"
def _use_legacy(monkeypatch):
"""Opt the write-time algorithm back into XSalsa20-Poly1305 for the duration of a test."""
monkeypatch.setattr(proxy_server, "general_settings", {"encryption_algorithm": "xsalsa20-poly1305"})
def _use_aes(monkeypatch):
@ -28,124 +49,227 @@ def _use_aes(monkeypatch):
monkeypatch.setattr(proxy_server, "general_settings", {"encryption_algorithm": "aes-256-gcm"})
def _sha256_key(signing_key: str = _SALT_KEY) -> bytes:
return hashlib.sha256(signing_key.encode()).digest()
def _hkdf_key(signing_key: str = _SALT_KEY) -> bytes:
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
return HKDF(algorithm=hashes.SHA256(), length=32, salt=None, info=b"litellm-at-rest-v3").derive(
signing_key.encode()
)
def _v2_ciphertext(plaintext: str, key: bytes) -> str:
"""Build a ``v2:gcm:`` value the way the previous release wrote it: AES-GCM under raw SHA-256."""
nonce = os.urandom(12)
return (
_V2_GCM_PREFIX + base64.urlsafe_b64encode(nonce + AESGCM(key).encrypt(nonce, plaintext.encode(), None)).decode()
)
def _legacy_nacl_ciphertext(plaintext: str, key: bytes) -> str:
"""Build an unprefixed value the way pre-AES releases wrote it: XSalsa20-Poly1305 under raw SHA-256."""
import nacl.secret
return base64.urlsafe_b64encode(bytes(nacl.secret.SecretBox(key).encrypt(plaintext.encode()))).decode()
@pytest.fixture(autouse=True)
def _salt_key(monkeypatch):
# Dominant convention in the test_litellm/ tree: set the key via env.
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-salt-aes-1234")
# Ensure the legacy default is in force unless a test opts into AES.
monkeypatch.setenv("LITELLM_SALT_KEY", _SALT_KEY)
monkeypatch.delenv("LITELLM_FIPS_MODE", raising=False)
monkeypatch.setattr(proxy_server, "general_settings", {})
yield
def test_aes_gcm_round_trip(monkeypatch):
"""A value written under AES-256-GCM is tagged v2:gcm: and decrypts back."""
_use_aes(monkeypatch)
def test_default_write_is_v3_gcm_and_round_trips():
ct = encrypt_value_helper("super-secret")
assert ct.startswith(_V2_GCM_PREFIX)
assert ct.startswith(_V3_GCM_PREFIX), ct
assert decrypt_value_helper(ct, key="t") == "super-secret"
def test_default_is_legacy_algorithm(monkeypatch):
"""With no config, writes stay on the legacy algorithm (no v2: marker)."""
ct = encrypt_value_helper("legacy-secret")
def test_explicit_aes_setting_writes_v3_gcm(monkeypatch):
monkeypatch.setattr(proxy_server, "general_settings", {"encryption_algorithm": "AES-256-GCM"})
assert not ct.startswith(_V2_GCM_PREFIX)
assert decrypt_value_helper(ct, key="t") == "legacy-secret"
assert encrypt_value_helper("secret").startswith(_V3_GCM_PREFIX)
def test_legacy_nacl_value_still_decrypts_after_flag_flip(monkeypatch):
"""A value written under the old algorithm decrypts unchanged once AES is on.
This is the mixed-format readback guarantee: decrypt is format-detecting, so
flipping the flag forward never strands previously-written data.
"""
legacy = encrypt_value_helper("legacy-secret") # default = xsalsa20
assert not legacy.startswith(_V2_GCM_PREFIX)
_use_aes(monkeypatch)
# New writes are now AES, but the old value must still come back.
assert decrypt_value_helper(legacy, key="t") == "legacy-secret"
assert encrypt_value_helper("fresh").startswith(_V2_GCM_PREFIX)
def test_v2_prefix_is_idempotent_marker(monkeypatch):
"""The migration's skip-check: an already-v2 value is recognized by its prefix.
Re-encrypting an AES value yields a fresh (different nonce) AES value, but the
prefix is what lets a migration skip already-migrated rows without decrypting.
"""
_use_aes(monkeypatch)
def test_unknown_algorithm_falls_back_to_the_aes_default(monkeypatch):
monkeypatch.setattr(proxy_server, "general_settings", {"encryption_algorithm": "rot13"})
ct = encrypt_value_helper("secret")
assert ct.startswith(_V2_GCM_PREFIX)
assert ct.startswith(_V3_GCM_PREFIX), ct
assert decrypt_value_helper(ct, key="t") == "secret"
def test_v3_value_is_aes_gcm_under_hkdf_sha256_not_raw_sha256():
ct = encrypt_value_helper("hkdf-secret")
raw = base64.urlsafe_b64decode(ct[len(_V3_GCM_PREFIX) :])
nonce, blob = raw[:12], raw[12:]
assert AESGCM(_hkdf_key()).decrypt(nonce, blob, None) == b"hkdf-secret"
with pytest.raises(InvalidTag):
AESGCM(_sha256_key()).decrypt(nonce, blob, None)
def test_each_v3_write_uses_a_fresh_nonce():
first, second = encrypt_value_helper("same-secret"), encrypt_value_helper("same-secret")
assert first != second
assert (
base64.urlsafe_b64decode(first[len(_V3_GCM_PREFIX) :])[:12]
!= base64.urlsafe_b64decode(second[len(_V3_GCM_PREFIX) :])[:12]
)
def test_v2_value_written_under_raw_sha256_still_decrypts():
legacy_v2 = _v2_ciphertext("v2-secret", _sha256_key())
assert decrypt_value_helper(legacy_v2, key="t") == "v2-secret"
assert decrypt_if_encrypted_with(legacy_v2, _SALT_KEY) == "v2-secret"
def test_v2_value_is_not_read_with_the_v3_derivation():
"""A v2 body relabelled as v3 must fail: the prefix, not the caller, picks the derivation."""
v2_body = _v2_ciphertext("v2-secret", _sha256_key())[len(_V2_GCM_PREFIX) :]
assert decrypt_if_encrypted_with(_V3_GCM_PREFIX + v2_body, _SALT_KEY) is None
assert decrypt_value_helper(_V3_GCM_PREFIX + v2_body, key="t", exception_type="debug") is None
def test_legacy_nacl_value_still_decrypts_under_the_aes_default():
legacy = _legacy_nacl_ciphertext("legacy-secret", _sha256_key())
assert not is_versioned_gcm(legacy)
assert decrypt_value_helper(legacy, key="t") == "legacy-secret"
assert decrypt_if_encrypted_with(legacy, _SALT_KEY) == "legacy-secret"
assert encrypt_value_helper("fresh").startswith(_V3_GCM_PREFIX)
def test_legacy_opt_in_writes_unprefixed_nacl_that_still_reads_back(monkeypatch):
_use_legacy(monkeypatch)
ct = encrypt_value_helper("legacy-secret")
assert not is_versioned_gcm(ct), ct
assert decrypt_value_helper(ct, key="t") == "legacy-secret"
assert _get_encryption_algorithm() == "xsalsa20-poly1305"
def test_legacy_opt_in_is_refused_under_fips_mode(monkeypatch):
_use_legacy(monkeypatch)
monkeypatch.setenv("LITELLM_FIPS_MODE", "true")
with pytest.raises(FipsModeError, match="xsalsa20-poly1305") as refused:
encrypt_value_helper("secret")
assert "aes-256-gcm" in str(refused.value)
def test_fips_mode_with_the_default_setting_writes_v3_gcm(monkeypatch):
monkeypatch.setenv("LITELLM_FIPS_MODE", "true")
ct = encrypt_value_helper("fips-secret")
assert ct.startswith(_V3_GCM_PREFIX), ct
assert decrypt_value_helper(ct, key="t") == "fips-secret"
def test_versioned_gcm_values_never_import_nacl(monkeypatch):
legacy_v2 = _v2_ciphertext("v2-secret", _sha256_key())
monkeypatch.setitem(sys.modules, "nacl", None)
monkeypatch.setitem(sys.modules, "nacl.secret", None)
assert decrypt_value_helper(encrypt_value_helper("v3-secret"), key="t") == "v3-secret"
assert decrypt_value_helper(legacy_v2, key="t") == "v2-secret"
def test_legacy_ciphertext_without_pynacl_logs_the_reencrypt_path_and_never_returns_the_blob(monkeypatch, caplog):
legacy = _legacy_nacl_ciphertext("legacy-secret", _sha256_key())
assert legacy_encryption_available()
monkeypatch.setitem(sys.modules, "nacl", None)
monkeypatch.setitem(sys.modules, "nacl.secret", None)
assert not legacy_encryption_available()
with caplog.at_level(logging.ERROR, logger="LiteLLM Proxy"):
assert decrypt_value_helper(legacy, key="t", exception_type="debug", return_original_value=True) is None
assert decrypt_value_helper(legacy, key="t", exception_type="debug") is None
assert decrypt_if_encrypted_with(legacy, _SALT_KEY) is None
assert len(caplog.records) == 3, caplog.text
for record in caplog.records:
message = record.getMessage()
assert "PyNaCl is not installed" in message
assert "legacy-encryption" in message
assert "/credentials/migrate-encryption" in message
assert "legacy-secret" not in caplog.text
def test_require_legacy_reader_for_refuses_only_unprefixed_values_without_pynacl(monkeypatch):
legacy = _legacy_nacl_ciphertext("legacy-secret", _sha256_key())
v3 = encrypt_value_helper("v3-secret")
require_legacy_reader_for((legacy, v3, "plain"), "rotate the master key")
monkeypatch.setitem(sys.modules, "nacl", None)
monkeypatch.setitem(sys.modules, "nacl.secret", None)
require_legacy_reader_for((v3, "", None, 3), "rotate the master key")
for unreadable in (legacy, "plain-or-legacy"):
with pytest.raises(LegacyEncryptionUnavailableError, match=r"rotate the master key.*legacy-encryption"):
require_legacy_reader_for((v3, unreadable), "rotate the master key")
def test_legacy_opt_in_without_pynacl_fails_the_write_not_silently(monkeypatch):
_use_legacy(monkeypatch)
monkeypatch.setitem(sys.modules, "nacl", None)
monkeypatch.setitem(sys.modules, "nacl.secret", None)
with pytest.raises(LegacyEncryptionUnavailableError, match="encrypt"):
encrypt_value_helper("secret")
def test_v3_prefix_is_the_idempotent_migration_marker():
ct = encrypt_value_helper("secret")
assert is_versioned_gcm(ct)
# Round-tripping does not change the plaintext, and the marker is stable.
again = encrypt_value_helper(decrypt_value_helper(ct, key="t"))
assert again.startswith(_V2_GCM_PREFIX)
assert again.startswith(_V3_GCM_PREFIX)
assert decrypt_value_helper(again, key="t") == "secret"
def test_aes_decrypt_failure_returns_none_not_raise(monkeypatch):
"""Decrypt contract preserved: a garbled v2 value returns None, never raises."""
_use_aes(monkeypatch)
garbled = _V2_GCM_PREFIX + "not-valid-base64-or-ciphertext!!!"
# exception_type="debug" exercises the swallow path; must not raise.
@pytest.mark.parametrize("prefix", [_V3_GCM_PREFIX, _V2_GCM_PREFIX])
def test_aes_decrypt_failure_returns_none_not_raise(prefix: str):
garbled = prefix + "not-valid-base64-or-ciphertext!!!"
assert decrypt_value_helper(garbled, key="t", exception_type="debug") is None
def test_aes_decrypt_failure_returns_original_when_requested(monkeypatch):
"""With return_original_value=True a bad v2 value comes back as-is, not None."""
_use_aes(monkeypatch)
garbled = _V2_GCM_PREFIX + "###"
def test_aes_decrypt_failure_returns_original_when_requested():
garbled = _V3_GCM_PREFIX + "###"
assert decrypt_value_helper(garbled, key="t", exception_type="debug", return_original_value=True) == garbled
def test_empty_string_round_trips_under_aes(monkeypatch):
"""Empty string is preserved through the AES path (parity with legacy)."""
_use_aes(monkeypatch)
def test_empty_string_round_trips_under_aes():
ct = encrypt_value_helper("")
assert ct.startswith(_V2_GCM_PREFIX)
assert ct.startswith(_V3_GCM_PREFIX)
assert decrypt_value_helper(ct, key="t") == ""
def test_callback_prefix_composes_with_v2(monkeypatch):
"""litellm_enc:: + v2:gcm:... round-trips through the callback read path.
Callback vars are stored as ``litellm_enc::<helper output>``; the read path
strips ``litellm_enc::`` then calls the helper, so the value handed to the
helper is ``v2:gcm:...``. Ordering must work end to end.
"""
def test_callback_prefix_composes_with_v3():
"""litellm_enc:: + v3:gcm:... round-trips through the callback read path."""
from litellm.proxy.common_utils.callback_utils import (
_CALLBACK_VAR_ENCRYPTED_PREFIX,
_decrypt_or_passthrough,
_encrypt_if_plaintext,
)
_use_aes(monkeypatch)
# "gcs_path_service_account" is a known-sensitive callback key.
stored = _encrypt_if_plaintext("gcs_path_service_account", "my-sa-secret")
assert stored.startswith(_CALLBACK_VAR_ENCRYPTED_PREFIX)
inner = stored[len(_CALLBACK_VAR_ENCRYPTED_PREFIX) :]
assert inner.startswith(_V2_GCM_PREFIX)
assert inner.startswith(_V3_GCM_PREFIX)
assert _decrypt_or_passthrough("gcs_path_service_account", stored) == "my-sa-secret"
def test_unknown_algorithm_falls_back_to_legacy(monkeypatch):
"""An unrecognized encryption_algorithm value does not produce v2 writes."""
monkeypatch.setattr(proxy_server, "general_settings", {"encryption_algorithm": "rot13"})
ct = encrypt_value_helper("secret")
assert not ct.startswith(_V2_GCM_PREFIX)
assert decrypt_value_helper(ct, key="t") == "secret"
def test_decrypt_failure_debug_log_omits_raw_value(monkeypatch):
"""Regression for LIT-4152: the decrypt-failure debug breadcrumb must not
embed the raw value.
@ -194,10 +318,10 @@ def test_decrypt_failure_debug_log_omits_raw_value(monkeypatch):
assert result == secret
@pytest.mark.parametrize("use_aes", [False, True])
def test_explicit_key_decrypt_reads_only_values_written_under_that_key(monkeypatch, use_aes: bool):
if use_aes:
_use_aes(monkeypatch)
@pytest.mark.parametrize("use_legacy", [False, True])
def test_explicit_key_decrypt_reads_only_values_written_under_that_key(monkeypatch, use_legacy: bool):
if use_legacy:
_use_legacy(monkeypatch)
written_with_previous_key = encrypt_value_helper("stored-secret", new_encryption_key="sk-1234")
assert decrypt_if_encrypted_with(written_with_previous_key, "sk-1234") == "stored-secret"
@ -212,6 +336,7 @@ def test_explicit_key_decrypt_reads_only_values_written_under_that_key(monkeypat
"gpt-5.4-mini",
"https://example.invalid/v1",
"v2:gcm:",
"v3:gcm:",
"aGVsbG8=",
"*",
"-",
@ -227,10 +352,10 @@ def test_explicit_key_decrypt_rejects_values_that_are_not_ciphertexts(not_a_ciph
assert decrypt_if_encrypted_with(not_a_ciphertext, "sk-1234") is None
@pytest.mark.parametrize("use_aes", [False, True])
def test_explicit_key_decrypt_tells_an_encrypted_empty_string_from_no_ciphertext(monkeypatch, use_aes: bool):
if use_aes:
_use_aes(monkeypatch)
@pytest.mark.parametrize("use_legacy", [False, True])
def test_explicit_key_decrypt_tells_an_encrypted_empty_string_from_no_ciphertext(monkeypatch, use_legacy: bool):
if use_legacy:
_use_legacy(monkeypatch)
assert decrypt_if_encrypted_with(encrypt_value_helper("", new_encryption_key="sk-1234"), "sk-1234") == ""

View file

@ -8,6 +8,7 @@ proof-of-fix (real proxy + DB) is performed separately on the repro server.
import asyncio
import json
import sys
from types import SimpleNamespace
from typing import Final
from unittest.mock import AsyncMock, MagicMock
@ -17,7 +18,8 @@ import pytest
from litellm._service_logger import ServiceTypes
from litellm.proxy import proxy_server
from litellm.proxy.common_utils.encrypt_decrypt_utils import (
_V2_GCM_PREFIX,
_V3_GCM_PREFIX,
encrypt_secret_map,
encrypt_value_helper,
)
from litellm.proxy.management_endpoints import credential_migration as cm
@ -32,8 +34,8 @@ def salt_key(monkeypatch):
def _legacy_ct(value: str, monkeypatch) -> str:
"""Produce a legacy (nacl) ciphertext with the AES gate off."""
monkeypatch.setattr(proxy_server, "general_settings", {})
"""Produce a legacy (nacl) ciphertext through the explicit xsalsa20-poly1305 opt-in."""
monkeypatch.setattr(proxy_server, "general_settings", {"encryption_algorithm": "xsalsa20-poly1305"})
return encrypt_value_helper(value)
@ -82,7 +84,7 @@ def test_reencrypt_value_legacy_to_v2(salt_key, monkeypatch):
out = cm.reencrypt_value(legacy)
assert out != legacy
assert out.startswith(_V2_GCM_PREFIX)
assert out.startswith(_V3_GCM_PREFIX)
def test_reencrypt_value_is_idempotent(salt_key, monkeypatch):
@ -113,7 +115,7 @@ def test_reencrypt_selective_dict(salt_key, monkeypatch):
data = {"api_key": legacy_key, "base_url": "https://x", "integration_token": None}
out = cm.reencrypt_selective_dict(data, ["api_key", "integration_token"])
assert out["api_key"].startswith(_V2_GCM_PREFIX)
assert out["api_key"].startswith(_V3_GCM_PREFIX)
assert out["base_url"] == "https://x" # untouched non-sensitive
assert out["integration_token"] is None # null skipped
@ -123,13 +125,120 @@ def test_reencrypt_selective_dict(salt_key, monkeypatch):
@pytest.mark.asyncio
async def test_migrate_requires_aes_gate(salt_key, monkeypatch):
monkeypatch.setattr(proxy_server, "general_settings", {}) # gate off
monkeypatch.setattr(proxy_server, "general_settings", {"encryption_algorithm": "xsalsa20-poly1305"}) # legacy opt-in
with pytest.raises(RuntimeError, match="encryption_algorithm"):
await cm.migrate_encryption(
prisma_client=MagicMock(), user_api_key_dict=MagicMock()
)
@pytest.mark.asyncio
async def test_migrate_and_check_refuse_without_pynacl_instead_of_miscounting_legacy_rows(salt_key, monkeypatch):
legacy: Final = _legacy_ct("model-secret", monkeypatch)
_enable_aes(monkeypatch)
monkeypatch.setitem(sys.modules, "nacl", None)
monkeypatch.setitem(sys.modules, "nacl.secret", None)
client: Final = MagicMock()
_empty_covered_tables(client)
client.db.litellm_teamtable.find_many = AsyncMock(return_value=[])
client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[])
client.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=None)
client.db.litellm_config.find_unique = AsyncMock(return_value=None)
client.db.litellm_config.update = AsyncMock()
v3_only: Final = await cm.check_encryption(prisma_client=client)
assert v3_only.residual_legacy == 0
client.db.litellm_proxymodeltable.find_many = AsyncMock(
return_value=[SimpleNamespace(litellm_params={"api_key": legacy})]
)
client.db.litellm_proxymodeltable.update_many = AsyncMock()
with pytest.raises(RuntimeError, match="legacy-encryption"):
await cm.check_encryption(prisma_client=client)
with pytest.raises(RuntimeError, match="legacy-encryption"):
await cm.migrate_encryption(prisma_client=client, user_api_key_dict=MagicMock())
client.db.litellm_proxymodeltable.update_many.assert_not_awaited()
client.db.litellm_config.update.assert_not_awaited()
@pytest.mark.parametrize(
"db_attr, build_row",
[
("litellm_mcpservertable", lambda ct: SimpleNamespace(server_id="s1", static_headers=json.dumps(ct))),
("litellm_mcpservertable", lambda ct: SimpleNamespace(server_id="s1", env=ct)),
("litellm_mcpserveroauthclient", lambda ct: SimpleNamespace(server_id="s1", credentials={"client_secret": ct})),
("litellm_ssoidentityassertion", lambda ct: SimpleNamespace(user_id="u1", assertion_b64=ct)),
],
)
@pytest.mark.asyncio
async def test_check_refuses_without_pynacl_for_every_rotation_rewritten_location(
db_attr, build_row, salt_key, monkeypatch
):
monkeypatch.setattr(proxy_server, "general_settings", {"encryption_algorithm": "xsalsa20-poly1305"})
legacy_map: Final = json.loads(encrypt_secret_map({"Authorization": "Bearer legacy"}))
legacy_value: Final = encrypt_value_helper("legacy-secret")
_enable_aes(monkeypatch)
client: Final = MagicMock()
_empty_covered_tables(client)
client.db.litellm_teamtable.find_many = AsyncMock(return_value=[])
client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[])
client.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=None)
client.db.litellm_config.find_unique = AsyncMock(return_value=None)
ciphertext: Final = legacy_map if db_attr == "litellm_mcpservertable" else legacy_value
getattr(client.db, db_attr).find_many = AsyncMock(return_value=[build_row(ciphertext)])
with_pynacl: Final = await cm.check_encryption(prisma_client=client)
assert with_pynacl.residual_legacy == 1, with_pynacl.to_dict()
monkeypatch.setitem(sys.modules, "nacl", None)
monkeypatch.setitem(sys.modules, "nacl.secret", None)
with pytest.raises(RuntimeError, match="legacy-encryption"):
await cm.check_encryption(prisma_client=client)
@pytest.mark.asyncio
async def test_check_without_pynacl_ignores_plaintext_mcp_metadata_next_to_v3_secrets(salt_key, monkeypatch):
_enable_aes(monkeypatch)
v3_secret: Final = encrypt_value_helper("dcr-secret")
client: Final = MagicMock()
_empty_covered_tables(client)
client.db.litellm_teamtable.find_many = AsyncMock(return_value=[])
client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[])
client.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=None)
client.db.litellm_config.find_unique = AsyncMock(return_value=None)
client.db.litellm_mcpserveroauthclient.find_many = AsyncMock(
return_value=[
SimpleNamespace(
server_id="s1",
credentials={"client_id": v3_secret, "client_secret": v3_secret, "scopes": ["a"], "auth_type": "oauth2"},
)
]
)
client.db.litellm_mcpservertable.find_many = AsyncMock(
return_value=[
SimpleNamespace(
server_id="s1",
credentials={"auth_value": v3_secret, "auth_type": "api_key", "token_url": "https://idp/token"},
env_vars=[
{"name": "TOKEN", "scope": "global", "value": v3_secret},
{"name": "USER_TOKEN", "scope": "user", "value": "{{user.token}}"},
],
)
]
)
monkeypatch.setitem(sys.modules, "nacl", None)
monkeypatch.setitem(sys.modules, "nacl.secret", None)
report: Final = await cm.check_encryption(prisma_client=client)
by_location: Final = report.as_dict()["locations"]
assert report.residual_legacy == 0, by_location
assert by_location["mcp_oauth_client"]["already_v2"] == 2, by_location
assert by_location["mcp_oauth_client"]["scanned"] == 2, by_location
assert by_location["mcp_server"]["already_v2"] == 2, by_location
assert by_location["mcp_server"]["scanned"] == 2, by_location
# --------------------------- config-row walker ---------------------------
@ -164,7 +273,7 @@ async def test_vantage_walker_migrates_legacy_field(salt_key, monkeypatch):
written = json.loads(
client.db.litellm_config.update.call_args.kwargs["data"]["param_value"]
)
assert written["api_key"].startswith(_V2_GCM_PREFIX)
assert written["api_key"].startswith(_V3_GCM_PREFIX)
assert written["base_url"] == "https://api.vantage.sh" # non-sensitive untouched
@ -308,8 +417,8 @@ async def test_callback_vars_walker_migrates_team_metadata(salt_key, monkeypatch
"""A team row with a legacy-encrypted callback var is rewritten to v2."""
from litellm.proxy.common_utils.callback_utils import encrypt_callback_vars
# Legacy-encrypt a callback var via the real callback path (gate off).
monkeypatch.setattr(proxy_server, "general_settings", {})
# Legacy-encrypt a callback var via the real callback path (legacy opt-in).
monkeypatch.setattr(proxy_server, "general_settings", {"encryption_algorithm": "xsalsa20-poly1305"})
legacy_meta = encrypt_callback_vars(
{"logging": [{"callback_vars": {"gcs_path_service_account": "sa-secret"}}]}
)
@ -329,7 +438,7 @@ async def test_callback_vars_walker_migrates_team_metadata(salt_key, monkeypatch
client.db.litellm_teamtable.update.call_args.kwargs["data"]["metadata"]
)
inner = written["logging"][0]["callback_vars"]["gcs_path_service_account"]
assert "v2:gcm:" in inner
assert "v3:gcm:" in inner
@pytest.mark.asyncio
@ -337,7 +446,7 @@ async def test_callback_vars_walker_dry_run_reports_legacy(salt_key, monkeypatch
"""In --check (dry-run) mode, a legacy callback var counts as residual legacy."""
from litellm.proxy.common_utils.callback_utils import encrypt_callback_vars
monkeypatch.setattr(proxy_server, "general_settings", {})
monkeypatch.setattr(proxy_server, "general_settings", {"encryption_algorithm": "xsalsa20-poly1305"})
legacy_meta = encrypt_callback_vars(
{"logging": [{"callback_vars": {"gcs_path_service_account": "sa-secret"}}]}
)
@ -369,7 +478,7 @@ async def test_callback_vars_walker_migrates_callback_settings_shape(
"""
from litellm.proxy.common_utils.callback_utils import encrypt_callback_vars
monkeypatch.setattr(proxy_server, "general_settings", {})
monkeypatch.setattr(proxy_server, "general_settings", {"encryption_algorithm": "xsalsa20-poly1305"})
legacy_meta = encrypt_callback_vars(
{
"callback_settings": {
@ -394,7 +503,7 @@ async def test_callback_vars_walker_migrates_callback_settings_shape(
client.db.litellm_teamtable.update.call_args.kwargs["data"]["metadata"]
)
inner = written["callback_settings"]["callback_vars"]["gcs_path_service_account"]
assert "v2:gcm:" in inner
assert "v3:gcm:" in inner
@pytest.mark.asyncio
@ -404,13 +513,13 @@ async def test_check_reports_callback_var_legacy_with_gate_off(salt_key, monkeyp
Detection is decrypt-based, not a re-encrypt delta, so it does not depend on
the write gate. A heuristic that re-encrypts and counts new v2 values would
read zero here (gate off -> no v2 produced) and emit a false-clean
read zero here (legacy opt-in -> no versioned AES produced) and emit a false-clean
attestation -- exactly the compliance trap this guards against.
"""
from litellm.proxy.common_utils.callback_utils import encrypt_callback_vars
# Legacy-encrypt a callback var, and leave the gate OFF for the check itself.
monkeypatch.setattr(proxy_server, "general_settings", {})
# Legacy-encrypt a callback var, and keep the legacy opt-in for the check itself.
monkeypatch.setattr(proxy_server, "general_settings", {"encryption_algorithm": "xsalsa20-poly1305"})
legacy_meta = encrypt_callback_vars(
{"logging": [{"callback_vars": {"gcs_path_service_account": "sa-secret"}}]}
)
@ -453,7 +562,7 @@ async def test_scan_covered_tables_classifies_legacy_and_v2(salt_key, monkeypatc
)
client.db.litellm_config.find_unique = AsyncMock(return_value=None)
by_loc = {r.location: r for r in await cm._scan_covered_tables(client)}
by_loc = {r.location: r for r in await cm.scan_covered_tables(client)}
assert by_loc["model_table"].legacy == 1
assert by_loc["model_table"].plaintext == 1 # "gpt-4" model name, not ciphertext
@ -477,7 +586,7 @@ async def test_scan_covered_tables_classifies_search_tool_params(salt_key, monke
)
client.db.litellm_config.find_unique = AsyncMock(return_value=None)
by_loc = {r.location: r for r in await cm._scan_covered_tables(client)}
by_loc = {r.location: r for r in await cm.scan_covered_tables(client)}
assert (by_loc["search_tools"].legacy, by_loc["search_tools"].already_v2) == (1, 1)
assert by_loc["search_tools"].plaintext == 1

View file

@ -9430,6 +9430,110 @@ async def test_rotate_master_key_reencrypts_model_params_in_place(
), "api_key must be stored re-encrypted under the new master key, not in plaintext"
@pytest.mark.parametrize(
"legacy_table, build_row",
[
("litellm_proxymodeltable", lambda ct: SimpleNamespace(litellm_params={"api_key": ct})),
("litellm_mcpserveroauthclient", lambda ct: SimpleNamespace(server_id="s1", credentials={"client_secret": ct})),
("litellm_ssoidentityassertion", lambda ct: SimpleNamespace(user_id="u1", assertion_b64=ct)),
],
)
async def test_rotate_master_key_refuses_without_pynacl_before_touching_any_row(legacy_table, build_row, monkeypatch):
import sys
from unittest.mock import AsyncMock, MagicMock
from fastapi import HTTPException
from litellm.proxy import proxy_server
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_value_helper
from litellm.proxy.management_endpoints.key_management_endpoints import (
_rotate_master_key,
)
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-salt-rotate")
monkeypatch.setattr(proxy_server, "general_settings", {"encryption_algorithm": "xsalsa20-poly1305"})
legacy_row = build_row(encrypt_value_helper("legacy-secret"))
monkeypatch.setattr(proxy_server, "general_settings", {})
monkeypatch.setitem(sys.modules, "nacl", None)
monkeypatch.setitem(sys.modules, "nacl.secret", None)
mock_prisma_client = AsyncMock()
mock_prisma_client.db = MagicMock()
mock_prisma_client.db.litellm_config.find_unique = AsyncMock(return_value=None)
covered_tables = (
"litellm_proxymodeltable",
"litellm_credentialstable",
"litellm_mcpservertable",
"litellm_mcpusercredentials",
"litellm_mcpuserenvvars",
"litellm_mcpserveroauthclient",
"litellm_ssoidentityassertion",
)
for table in covered_tables:
rows = [legacy_row] if table == legacy_table else []
getattr(mock_prisma_client.db, table).find_many = AsyncMock(return_value=rows)
getattr(mock_prisma_client.db, table).update = AsyncMock()
getattr(mock_prisma_client.db, table).update_many = AsyncMock()
with pytest.raises(HTTPException) as exc_info:
await _rotate_master_key(
prisma_client=mock_prisma_client,
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="test-user"),
current_master_key="sk-old-master-key",
new_master_key="sk-new-master-key",
)
assert exc_info.value.status_code == 400
assert "legacy-encryption" in exc_info.value.detail["error"]
for table in covered_tables:
getattr(mock_prisma_client.db, table).update.assert_not_awaited()
getattr(mock_prisma_client.db, table).update_many.assert_not_awaited()
mock_prisma_client.db.tx.assert_not_called()
async def test_rotate_master_key_preflight_without_pynacl_passes_migrated_oauth_client_with_plaintext_scopes(
monkeypatch,
):
import sys
from unittest.mock import AsyncMock, MagicMock
from litellm.proxy import proxy_server
from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_value_helper
from litellm.proxy.management_endpoints.key_management_endpoints import (
_require_legacy_reader_for_stored_values,
)
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-salt-rotate")
monkeypatch.setattr(proxy_server, "general_settings", {})
v3_secret = encrypt_value_helper("dcr-secret")
monkeypatch.setitem(sys.modules, "nacl", None)
monkeypatch.setitem(sys.modules, "nacl.secret", None)
mock_prisma_client = AsyncMock()
mock_prisma_client.db = MagicMock()
mock_prisma_client.db.litellm_config.find_unique = AsyncMock(return_value=None)
for table in (
"litellm_proxymodeltable",
"litellm_credentialstable",
"litellm_mcpservertable",
"litellm_mcpusercredentials",
"litellm_mcpuserenvvars",
"litellm_ssoidentityassertion",
):
getattr(mock_prisma_client.db, table).find_many = AsyncMock(return_value=[])
mock_prisma_client.db.litellm_mcpserveroauthclient.find_many = AsyncMock(
return_value=[
SimpleNamespace(
server_id="s1",
credentials={"client_id": v3_secret, "client_secret": v3_secret, "scopes": ["a"], "auth_type": "oauth2"},
)
]
)
await _require_legacy_reader_for_stored_values(mock_prisma_client)
assert mock_prisma_client.db.litellm_mcpserveroauthclient.find_many.await_count == 1
async def test_default_key_generate_params_duration(monkeypatch):
"""
Test that default_key_generate_params with 'duration' is applied

View file

@ -13,6 +13,7 @@ import json
import logging
import os
import re
import sys
from collections.abc import Mapping
from contextlib import nullcontext
from dataclasses import dataclass
@ -27,7 +28,11 @@ from pydantic import JsonValue, TypeAdapter, ValidationError
import litellm
from litellm.proxy._types import CommonProxyErrors
from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_value_helper
from litellm.proxy.common_utils.encrypt_decrypt_utils import (
LegacyEncryptionUnavailableError,
decrypt_value_helper,
encrypt_value_helper,
)
from litellm.proxy.proxy_server import (
ProxyConfig,
_is_remote_module_url,
@ -3343,6 +3348,28 @@ def test_ProxyConfig_decrypt_credentials_returns_an_encrypted_empty_value_as_emp
assert decrypted.credential_values == {"api_base": "", "openai_service_account_id": "user-1"}
def test_ProxyConfig_decrypt_credentials_without_pynacl_drops_a_legacy_value_instead_of_serving_the_blob(monkeypatch):
import base64
import hashlib
import nacl.secret
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-decrypt-credentials-test-salt")
box = nacl.secret.SecretBox(hashlib.sha256(b"sk-decrypt-credentials-test-salt").digest())
legacy = base64.urlsafe_b64encode(bytes(box.encrypt(b"sk-legacy-upstream"))).decode()
monkeypatch.setitem(sys.modules, "nacl", None)
monkeypatch.setitem(sys.modules, "nacl.secret", None)
decrypted = ProxyConfig().decrypt_credentials(
{
"credential_name": "openai-legacy",
"credential_values": {"api_key": legacy, "api_base": encrypt_value_helper("https://api.example.test")},
"credential_info": {"custom_llm_provider": "openai"},
}
)
assert decrypted.credential_values == {"api_base": "https://api.example.test"}
def test_ProxyConfig_decrypt_model_list_from_db_returns_decrypted(monkeypatch):
monkeypatch.setattr(
"litellm.proxy.proxy_server.decrypt_value_helper",
@ -3688,6 +3715,22 @@ def test_ProxyConfig__encrypt_env_variables_for_db_idempotent(monkeypatch):
assert out == {"A": "ENC[1]", "B": "ENC[2]", "C": "ENC[3]"}
def test_ProxyConfig__encrypt_env_variables_for_db_refuses_without_pynacl_instead_of_dropping_legacy_values(
monkeypatch,
):
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-salt-config-save")
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {})
v3 = encrypt_value_helper("already-migrated")
monkeypatch.setitem(sys.modules, "nacl", None)
monkeypatch.setitem(sys.modules, "nacl.secret", None)
pc = ProxyConfig()
saved = pc._encrypt_env_variables_for_db({"A": v3})
assert decrypt_value_helper(saved["A"], key="A") == "already-migrated"
with pytest.raises(LegacyEncryptionUnavailableError, match=r"config save.*legacy-encryption"):
pc._encrypt_env_variables_for_db({"A": v3, "B": "plain-or-legacy"})
def test_ProxyConfig__encrypt_env_variables_for_db_invalid_raises():
pc = ProxyConfig()
with pytest.raises(AttributeError):

View file

@ -3751,9 +3751,9 @@ export interface paths {
put?: never;
/**
* Migrate Encryption Endpoint
* @description Re-encrypt all at-rest credentials into the AES-256-GCM (``v2:gcm:``) format.
* @description Re-encrypt all at-rest credentials into the versioned AES-256-GCM (``v3:gcm:``) format.
*
* Admin only. Requires ``general_settings.encryption_algorithm: aes-256-gcm``.
* Admin only. Requires the proxy to write ``aes-256-gcm`` (the default).
* Idempotent and resumable — re-running skips already-migrated values. Pass
* ``dry_run=true`` for a non-mutating scan (equivalent to ``--check``).
*/

392
uv.lock generated
View file

@ -10,7 +10,7 @@ resolution-markers = [
]
[options]
exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values.
exclude-newer = "2026-10-02T08:09:01.060139Z"
exclude-newer-span = "P3D"
[manifest]
@ -225,9 +225,9 @@ name = "aiologic"
version = "0.17.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "sniffio" },
{ name = "typing-extensions" },
{ name = "wrapt" },
{ name = "sniffio", marker = "python_full_version < '3.13'" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
{ name = "wrapt", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/53/a7/809482759f40079f4c4328c7318bf569ae25d457f5017aad30a1b9aafedc/aiologic-0.17.0.tar.gz", hash = "sha256:65aa058e858c94cd208badb188e7f00b54dcabb3ba85b34f794db98074d108b9", size = 251625, upload-time = "2026-06-14T12:24:35.367Z" }
wheels = [
@ -519,14 +519,14 @@ name = "aurelio-sdk"
version = "0.0.19"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiofiles" },
{ name = "aiohttp" },
{ name = "colorlog" },
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "requests" },
{ name = "requests-toolbelt" },
{ name = "tornado" },
{ name = "aiofiles", marker = "python_full_version < '3.14'" },
{ name = "aiohttp", marker = "python_full_version < '3.14'" },
{ name = "colorlog", marker = "python_full_version < '3.14'" },
{ name = "pydantic", marker = "python_full_version < '3.14'" },
{ name = "python-dotenv", marker = "python_full_version < '3.14'" },
{ name = "requests", marker = "python_full_version < '3.14'" },
{ name = "requests-toolbelt", marker = "python_full_version < '3.14'" },
{ name = "tornado", marker = "python_full_version < '3.14'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/27/0e/c2e369ad173fb3d76448e46d10beb3dcc53388318933ddf8169a3f21a810/aurelio_sdk-0.0.19.tar.gz", hash = "sha256:14107e7440ff2efd0b4a08c52fb595e7680bd4bc973a0ddfb3b64157c6666b91", size = 15258, upload-time = "2025-03-24T14:37:32.203Z" }
wheels = [
@ -538,9 +538,9 @@ name = "aws-sdk-bedrock-runtime"
version = "0.11.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "smithy-aws-core", extra = ["eventstream", "json"] },
{ name = "smithy-core" },
{ name = "smithy-http", extra = ["aiohttp"] },
{ name = "smithy-aws-core", extra = ["eventstream", "json"], marker = "python_full_version >= '3.12'" },
{ name = "smithy-core", marker = "python_full_version >= '3.12'" },
{ name = "smithy-http", extra = ["aiohttp"], marker = "python_full_version >= '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8e/b3/9c225cbfe9f17ea2e3d75a0fdd0b325ef79839b9c09a376bda63a7bf3bb3/aws_sdk_bedrock_runtime-0.11.0.tar.gz", hash = "sha256:f2c45d34625bf6a7b56375e29a53a16b376880bda771e4bbf7d84491622eb193", size = 173854, upload-time = "2026-08-24T21:17:16.304Z" }
wheels = [
@ -549,7 +549,7 @@ wheels = [
[package.optional-dependencies]
awscrt = [
{ name = "smithy-http", extra = ["awscrt"] },
{ name = "smithy-http", extra = ["awscrt"], marker = "python_full_version >= '3.12'" },
]
[[package]]
@ -1204,7 +1204,7 @@ name = "colorlog"
version = "6.10.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "colorama", marker = "python_full_version < '3.14' and sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a2/61/f083b5ac52e505dfc1c624eafbf8c7589a0d7f32daa398d2e7590efa5fda/colorlog-6.10.1.tar.gz", hash = "sha256:eb4ae5cb65fe7fec7773c2306061a8e63e02efc2c72eba9d27b0fa23c94f1321", size = 17162, upload-time = "2025-10-16T16:14:11.978Z" }
wheels = [
@ -1228,7 +1228,7 @@ resolution-markers = [
"python_full_version < '3.11'",
]
dependencies = [
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" }
wheels = [
@ -1301,7 +1301,7 @@ resolution-markers = [
"python_full_version == '3.11.*'",
]
dependencies = [
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
{ name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" }
@ -1571,8 +1571,8 @@ name = "culsans"
version = "0.11.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiologic" },
{ name = "typing-extensions" },
{ name = "aiologic", marker = "python_full_version < '3.13'" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" }
wheels = [
@ -1826,7 +1826,7 @@ name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [
@ -2409,11 +2409,11 @@ resolution-markers = [
"python_full_version >= '3.14'",
]
dependencies = [
{ name = "google-auth" },
{ name = "googleapis-common-protos" },
{ name = "proto-plus" },
{ name = "protobuf" },
{ name = "requests" },
{ name = "google-auth", marker = "python_full_version >= '3.14'" },
{ name = "googleapis-common-protos", marker = "python_full_version >= '3.14'" },
{ name = "proto-plus", marker = "python_full_version >= '3.14'" },
{ name = "protobuf", marker = "python_full_version >= '3.14'" },
{ name = "requests", marker = "python_full_version >= '3.14'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/09/cd/63f1557235c2440fe0577acdbc32577c5c002684c58c7f4d770a92366a24/google_api_core-2.25.2.tar.gz", hash = "sha256:1c63aa6af0d0d5e37966f157a77f9396d820fba59f9e43e9415bc3dc5baff300", size = 166266, upload-time = "2025-10-03T00:07:34.778Z" }
wheels = [
@ -2422,8 +2422,8 @@ wheels = [
[package.optional-dependencies]
grpc = [
{ name = "grpcio" },
{ name = "grpcio-status" },
{ name = "grpcio", marker = "python_full_version >= '3.14'" },
{ name = "grpcio-status", marker = "python_full_version >= '3.14'" },
]
[[package]]
@ -2437,11 +2437,11 @@ resolution-markers = [
"python_full_version < '3.11'",
]
dependencies = [
{ name = "google-auth" },
{ name = "googleapis-common-protos" },
{ name = "proto-plus" },
{ name = "protobuf" },
{ name = "requests" },
{ name = "google-auth", marker = "python_full_version < '3.14'" },
{ name = "googleapis-common-protos", marker = "python_full_version < '3.14'" },
{ name = "proto-plus", marker = "python_full_version < '3.14'" },
{ name = "protobuf", marker = "python_full_version < '3.14'" },
{ name = "requests", marker = "python_full_version < '3.14'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/16/ce/502a57fb0ec752026d24df1280b162294b22a0afb98a326084f9a979138b/google_api_core-2.30.3.tar.gz", hash = "sha256:e601a37f148585319b26db36e219df68c5d07b6382cff2d580e83404e44d641b", size = 177001, upload-time = "2026-04-10T00:41:28.035Z" }
wheels = [
@ -2450,8 +2450,8 @@ wheels = [
[package.optional-dependencies]
grpc = [
{ name = "grpcio" },
{ name = "grpcio-status" },
{ name = "grpcio", marker = "python_full_version < '3.14'" },
{ name = "grpcio-status", marker = "python_full_version < '3.14'" },
]
[[package]]
@ -2637,12 +2637,12 @@ resolution-markers = [
"python_full_version >= '3.14'",
]
dependencies = [
{ name = "google-api-core", version = "2.25.2", source = { registry = "https://pypi.org/simple" } },
{ name = "google-auth" },
{ name = "google-cloud-core" },
{ name = "google-crc32c" },
{ name = "google-resumable-media" },
{ name = "requests" },
{ name = "google-api-core", version = "2.25.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" },
{ name = "google-auth", marker = "python_full_version >= '3.14'" },
{ name = "google-cloud-core", marker = "python_full_version >= '3.14'" },
{ name = "google-crc32c", marker = "python_full_version >= '3.14'" },
{ name = "google-resumable-media", marker = "python_full_version >= '3.14'" },
{ name = "requests", marker = "python_full_version >= '3.14'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bd/ef/7cefdca67a6c8b3af0ec38612f9e78e5a9f6179dd91352772ae1a9849246/google_cloud_storage-3.4.1.tar.gz", hash = "sha256:6f041a297e23a4b485fad8c305a7a6e6831855c208bcbe74d00332a909f82268", size = 17238203, upload-time = "2025-10-08T18:43:39.665Z" }
wheels = [
@ -2660,12 +2660,12 @@ resolution-markers = [
"python_full_version < '3.11'",
]
dependencies = [
{ name = "google-api-core", version = "2.30.3", source = { registry = "https://pypi.org/simple" } },
{ name = "google-auth" },
{ name = "google-cloud-core" },
{ name = "google-crc32c" },
{ name = "google-resumable-media" },
{ name = "requests" },
{ name = "google-api-core", version = "2.30.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" },
{ name = "google-auth", marker = "python_full_version < '3.14'" },
{ name = "google-cloud-core", marker = "python_full_version < '3.14'" },
{ name = "google-crc32c", marker = "python_full_version < '3.14'" },
{ name = "google-resumable-media", marker = "python_full_version < '3.14'" },
{ name = "requests", marker = "python_full_version < '3.14'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4c/47/205eb8e9a1739b5345843e5a425775cbdc472cc38e7eda082ba5b8d02450/google_cloud_storage-3.10.1.tar.gz", hash = "sha256:97db9aa4460727982040edd2bd13ff3d5e2260b5331ad22895802da1fc2a5286", size = 17309950, upload-time = "2026-03-23T09:35:23.409Z" }
wheels = [
@ -4127,13 +4127,13 @@ name = "langchain-classic"
version = "1.0.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "langchain-core" },
{ name = "langchain-text-splitters" },
{ name = "langsmith" },
{ name = "pydantic" },
{ name = "pyyaml" },
{ name = "requests" },
{ name = "sqlalchemy" },
{ name = "langchain-core", marker = "python_full_version >= '3.11'" },
{ name = "langchain-text-splitters", marker = "python_full_version >= '3.11'" },
{ name = "langsmith", marker = "python_full_version >= '3.11'" },
{ name = "pydantic", marker = "python_full_version >= '3.11'" },
{ name = "pyyaml", marker = "python_full_version >= '3.11'" },
{ name = "requests", marker = "python_full_version >= '3.11'" },
{ name = "sqlalchemy", marker = "python_full_version >= '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9b/78/84b5065816f348c39fefa4316f209f0135e8410216340a953bec17d9e4e4/langchain_classic-1.0.7.tar.gz", hash = "sha256:debbec8065e69b95108d2652e8d5c44f4516e19aa8d716c02ed2211c3aee099d", size = 10554118, upload-time = "2026-05-07T15:46:56.8Z" }
wheels = [
@ -4148,18 +4148,18 @@ resolution-markers = [
"python_full_version < '3.11'",
]
dependencies = [
{ name = "aiohttp" },
{ name = "dataclasses-json" },
{ name = "httpx-sse" },
{ name = "langchain" },
{ name = "langchain-core" },
{ name = "langsmith" },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } },
{ name = "pydantic-settings" },
{ name = "pyyaml" },
{ name = "requests" },
{ name = "sqlalchemy" },
{ name = "tenacity" },
{ name = "aiohttp", marker = "python_full_version < '3.11'" },
{ name = "dataclasses-json", marker = "python_full_version < '3.11'" },
{ name = "httpx-sse", marker = "python_full_version < '3.11'" },
{ name = "langchain", marker = "python_full_version < '3.11'" },
{ name = "langchain-core", marker = "python_full_version < '3.11'" },
{ name = "langsmith", marker = "python_full_version < '3.11'" },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "pydantic-settings", marker = "python_full_version < '3.11'" },
{ name = "pyyaml", marker = "python_full_version < '3.11'" },
{ name = "requests", marker = "python_full_version < '3.11'" },
{ name = "sqlalchemy", marker = "python_full_version < '3.11'" },
{ name = "tenacity", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/83/49/2ff5354273809e9811392bc24bcffda545a196070666aef27bc6aacf1c21/langchain_community-0.3.31.tar.gz", hash = "sha256:250e4c1041539130f6d6ac6f9386cb018354eafccd917b01a4cff1950b80fd81", size = 33241237, upload-time = "2025-10-07T20:17:57.857Z" }
wheels = [
@ -4177,19 +4177,19 @@ resolution-markers = [
"python_full_version == '3.11.*'",
]
dependencies = [
{ name = "aiohttp" },
{ name = "dataclasses-json" },
{ name = "httpx-sse" },
{ name = "langchain-classic" },
{ name = "langchain-core" },
{ name = "langsmith" },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "aiohttp", marker = "python_full_version >= '3.11'" },
{ name = "dataclasses-json", marker = "python_full_version >= '3.11'" },
{ name = "httpx-sse", marker = "python_full_version >= '3.11'" },
{ name = "langchain-classic", marker = "python_full_version >= '3.11'" },
{ name = "langchain-core", marker = "python_full_version >= '3.11'" },
{ name = "langsmith", marker = "python_full_version >= '3.11'" },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
{ name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
{ name = "pydantic-settings" },
{ name = "pyyaml" },
{ name = "requests" },
{ name = "sqlalchemy" },
{ name = "tenacity" },
{ name = "pydantic-settings", marker = "python_full_version >= '3.11'" },
{ name = "pyyaml", marker = "python_full_version >= '3.11'" },
{ name = "requests", marker = "python_full_version >= '3.11'" },
{ name = "sqlalchemy", marker = "python_full_version >= '3.11'" },
{ name = "tenacity", marker = "python_full_version >= '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/53/97/a03585d42b9bdb6fbd935282d6e3348b10322a24e6ce12d0c99eb461d9af/langchain_community-0.4.1.tar.gz", hash = "sha256:f3b211832728ee89f169ddce8579b80a085222ddb4f4ed445a46e977d17b1e85", size = 33241144, upload-time = "2025-10-27T15:20:32.504Z" }
wheels = [
@ -4247,7 +4247,7 @@ name = "langchain-text-splitters"
version = "1.1.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "langchain-core" },
{ name = "langchain-core", marker = "python_full_version >= '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/26/9f/6c545900fefb7b00ddfa3f16b80d61338a0ec68c31c5451eeeab99082760/langchain_text_splitters-1.1.2.tar.gz", hash = "sha256:782a723db0a4746ac91e251c7c1d57fd23636e4f38ed733074e28d7a86f41627", size = 293580, upload-time = "2026-04-16T14:20:39.162Z" }
wheels = [
@ -4557,6 +4557,9 @@ google = [
grpc = [
{ name = "grpcio" },
]
legacy-encryption = [
{ name = "pynacl" },
]
mcp = [
{ name = "httpx2" },
{ name = "mcp" },
@ -4587,7 +4590,6 @@ proxy = [
{ name = "polars" },
{ name = "pydantic" },
{ name = "pyjwt" },
{ name = "pynacl" },
{ name = "pyroscope-io", marker = "sys_platform != 'win32'" },
{ name = "python-multipart" },
{ name = "pyyaml" },
@ -4744,6 +4746,7 @@ proxy-dev = [
{ name = "opentelemetry-sdk" },
{ name = "prisma" },
{ name = "prometheus-client" },
{ name = "pynacl" },
{ name = "sentry-sdk" },
]
@ -4826,7 +4829,7 @@ requires-dist = [
{ name = "pydantic", marker = "extra == 'proxy'", specifier = ">=2.12.0,<3" },
{ name = "pydantic-settings", specifier = ">=2.14.1,<3.0" },
{ name = "pyjwt", marker = "extra == 'proxy'", specifier = ">=2.13.0,<3.0" },
{ name = "pynacl", marker = "extra == 'proxy'", specifier = ">=1.6.2,<2.0" },
{ name = "pynacl", marker = "extra == 'legacy-encryption'", specifier = ">=1.6.2,<2.0" },
{ name = "pypdf", marker = "extra == 'proxy-runtime'", specifier = ">=6.16.1,<7.0" },
{ name = "pyroscope-io", marker = "sys_platform != 'win32' and extra == 'proxy'", specifier = ">=0.8.16,<1.0" },
{ name = "python-dotenv", specifier = ">=1.0.0,<2.0" },
@ -4857,7 +4860,7 @@ requires-dist = [
{ name = "uvloop", marker = "sys_platform != 'win32' and extra == 'proxy'", specifier = ">=0.22.1,<1.0" },
{ name = "websockets", marker = "extra == 'proxy'", specifier = ">=15.0.1,<16.0" },
]
provides-extras = ["proxy", "cli", "extra-proxy", "utils", "caching", "mcp", "saml", "semantic-router", "mlflow", "grpc", "stt-vertex-chirp", "stt-nvidia-riva", "google", "bedrock-realtime", "proxy-runtime"]
provides-extras = ["proxy", "cli", "extra-proxy", "utils", "caching", "legacy-encryption", "mcp", "saml", "semantic-router", "mlflow", "grpc", "stt-vertex-chirp", "stt-nvidia-riva", "google", "bedrock-realtime", "proxy-runtime"]
[package.metadata.requires-dev]
benchmarks = [
@ -4959,6 +4962,7 @@ proxy-dev = [
{ name = "opentelemetry-sdk", specifier = "==1.33.1" },
{ name = "prisma", specifier = "==0.11.0" },
{ name = "prometheus-client", specifier = "==0.20.0" },
{ name = "pynacl", specifier = ">=1.6.2,<2.0" },
{ name = "sentry-sdk", specifier = "==2.21.0" },
]
@ -5049,16 +5053,16 @@ resolution-markers = [
"python_full_version < '3.11'",
]
dependencies = [
{ name = "aiohttp" },
{ name = "chevron" },
{ name = "jsonpickle" },
{ name = "langchain-community", version = "0.3.31", source = { registry = "https://pypi.org/simple" } },
{ name = "packaging" },
{ name = "pydantic" },
{ name = "pyhumps" },
{ name = "requests" },
{ name = "setuptools" },
{ name = "tenacity" },
{ name = "aiohttp", marker = "python_full_version < '3.11'" },
{ name = "chevron", marker = "python_full_version < '3.11'" },
{ name = "jsonpickle", marker = "python_full_version < '3.11'" },
{ name = "langchain-community", version = "0.3.31", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "packaging", marker = "python_full_version < '3.11'" },
{ name = "pydantic", marker = "python_full_version < '3.11'" },
{ name = "pyhumps", marker = "python_full_version < '3.11'" },
{ name = "requests", marker = "python_full_version < '3.11'" },
{ name = "setuptools", marker = "python_full_version < '3.11'" },
{ name = "tenacity", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4a/6f/9ca1acf766848aaf5f0ac4140c34c91ad0dbfad2654359699644be3352c9/lunary-1.4.36.tar.gz", hash = "sha256:53f002f385c83d9c0e6368e7999923acffbde987f53c5205c2c249c38ee2d75c", size = 20253, upload-time = "2026-02-09T20:49:30.56Z" }
wheels = [
@ -5076,16 +5080,16 @@ resolution-markers = [
"python_full_version == '3.11.*'",
]
dependencies = [
{ name = "aiohttp" },
{ name = "chevron" },
{ name = "jsonpickle" },
{ name = "langchain-community", version = "0.4.1", source = { registry = "https://pypi.org/simple" } },
{ name = "packaging" },
{ name = "pydantic" },
{ name = "pyhumps" },
{ name = "requests" },
{ name = "setuptools" },
{ name = "tenacity" },
{ name = "aiohttp", marker = "python_full_version >= '3.11'" },
{ name = "chevron", marker = "python_full_version >= '3.11'" },
{ name = "jsonpickle", marker = "python_full_version >= '3.11'" },
{ name = "langchain-community", version = "0.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "packaging", marker = "python_full_version >= '3.11'" },
{ name = "pydantic", marker = "python_full_version >= '3.11'" },
{ name = "pyhumps", marker = "python_full_version >= '3.11'" },
{ name = "requests", marker = "python_full_version >= '3.11'" },
{ name = "setuptools", marker = "python_full_version >= '3.11'" },
{ name = "tenacity", marker = "python_full_version >= '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/37/ef/1acbc6957585cc0110e648d787663871717ced3df27fcd3cb5e18fa418f3/lunary-1.4.37.tar.gz", hash = "sha256:1781091e9dceffcc28ebc4be7e085c9fec4102d98d7ca945ed0021e9ce03c36f", size = 20248, upload-time = "2026-02-12T08:15:02.091Z" }
wheels = [
@ -8865,10 +8869,10 @@ resolution-markers = [
"python_full_version < '3.11'",
]
dependencies = [
{ name = "joblib" },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } },
{ name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" } },
{ name = "threadpoolctl" },
{ name = "joblib", marker = "python_full_version < '3.11'" },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "threadpoolctl", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/98/c2/a7855e41c9d285dfe86dc50b250978105dce513d6e459ea66a6aeb0e1e0c/scikit_learn-1.7.2.tar.gz", hash = "sha256:20e9e49ecd130598f1ca38a1d85090e1a600147b9c02fa6f15d69cb53d968fda", size = 7193136, upload-time = "2025-09-09T08:21:29.075Z" }
wheels = [
@ -8915,11 +8919,11 @@ resolution-markers = [
"python_full_version == '3.11.*'",
]
dependencies = [
{ name = "joblib" },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "joblib", marker = "python_full_version >= '3.11'" },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
{ name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" } },
{ name = "threadpoolctl" },
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "threadpoolctl", marker = "python_full_version >= '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" }
wheels = [
@ -8969,7 +8973,7 @@ resolution-markers = [
"python_full_version < '3.11'",
]
dependencies = [
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" }
wheels = [
@ -9031,7 +9035,7 @@ resolution-markers = [
"python_full_version == '3.11.*'",
]
dependencies = [
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
{ name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" }
@ -9116,20 +9120,20 @@ name = "semantic-router"
version = "0.1.15"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
{ name = "aurelio-sdk" },
{ name = "colorama" },
{ name = "colorlog" },
{ name = "litellm" },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' or python_full_version >= '3.14'" },
{ name = "aiohttp", marker = "python_full_version < '3.14'" },
{ name = "aurelio-sdk", marker = "python_full_version < '3.14'" },
{ name = "colorama", marker = "python_full_version < '3.14'" },
{ name = "colorlog", marker = "python_full_version < '3.14'" },
{ name = "litellm", marker = "python_full_version < '3.14'" },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and python_full_version < '3.14'" },
{ name = "openai" },
{ name = "pydantic" },
{ name = "pyyaml" },
{ name = "regex" },
{ name = "tiktoken" },
{ name = "tornado" },
{ name = "urllib3" },
{ name = "openai", marker = "python_full_version < '3.14'" },
{ name = "pydantic", marker = "python_full_version < '3.14'" },
{ name = "pyyaml", marker = "python_full_version < '3.14'" },
{ name = "regex", marker = "python_full_version < '3.14'" },
{ name = "tiktoken", marker = "python_full_version < '3.14'" },
{ name = "tornado", marker = "python_full_version < '3.14'" },
{ name = "urllib3", marker = "python_full_version < '3.14'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/dc/a9/1a689e916e8b280f1fd8fb335cc059be626a22fe4533baa045d32fcd6de5/semantic_router-0.1.15.tar.gz", hash = "sha256:328256ddc3c2b713101ec69561d6585aecbf1198ea3461e1486289d8c3a35288", size = 95605, upload-time = "2026-05-23T12:58:15.444Z" }
wheels = [
@ -9203,9 +9207,9 @@ name = "smithy-aws-core"
version = "0.11.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aws-sdk-signers" },
{ name = "smithy-core" },
{ name = "smithy-http" },
{ name = "aws-sdk-signers", marker = "python_full_version >= '3.12'" },
{ name = "smithy-core", marker = "python_full_version >= '3.12'" },
{ name = "smithy-http", marker = "python_full_version >= '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/d3/501c0023548173416109ac42298ca33b708469dc922005770811a597949f/smithy_aws_core-0.11.0.tar.gz", hash = "sha256:29ee89976a520a87e3db557e03e115fdc21a0a60b81161e95174395a1b064da1", size = 38791, upload-time = "2026-08-24T21:16:59.631Z" }
wheels = [
@ -9214,10 +9218,10 @@ wheels = [
[package.optional-dependencies]
eventstream = [
{ name = "smithy-aws-event-stream" },
{ name = "smithy-aws-event-stream", marker = "python_full_version >= '3.12'" },
]
json = [
{ name = "smithy-json" },
{ name = "smithy-json", marker = "python_full_version >= '3.12'" },
]
[[package]]
@ -9225,7 +9229,7 @@ name = "smithy-aws-event-stream"
version = "0.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "smithy-core" },
{ name = "smithy-core", marker = "python_full_version >= '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/38/0e/6efb3a4ed92c0f1ada6de060ac92e7115a1e34d0ab1fb99a6056734a88ea/smithy_aws_event_stream-0.3.0.tar.gz", hash = "sha256:a0e227367a973144e205a075d0a424f95c92f26656a1018d08900da2ae547c49", size = 12818, upload-time = "2026-05-05T18:04:14.317Z" }
wheels = [
@ -9246,7 +9250,7 @@ name = "smithy-http"
version = "0.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "smithy-core" },
{ name = "smithy-core", marker = "python_full_version >= '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/98/78/b5f3113d6c8f0bc1f9777a7f5ca84b892d29efac05850e14f7d4f7e645b5/smithy_http-0.5.0.tar.gz", hash = "sha256:bb4a19672f7c7eeb872a308f777eb505281a5bafb1ee3d1ea9c760c06c352510", size = 31122, upload-time = "2026-08-24T21:16:56.488Z" }
wheels = [
@ -9255,11 +9259,11 @@ wheels = [
[package.optional-dependencies]
aiohttp = [
{ name = "aiohttp" },
{ name = "yarl" },
{ name = "aiohttp", marker = "python_full_version >= '3.12'" },
{ name = "yarl", marker = "python_full_version >= '3.12'" },
]
awscrt = [
{ name = "awscrt" },
{ name = "awscrt", marker = "python_full_version >= '3.12'" },
]
[[package]]
@ -9267,8 +9271,8 @@ name = "smithy-json"
version = "0.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ijson" },
{ name = "smithy-core" },
{ name = "ijson", marker = "python_full_version >= '3.12'" },
{ name = "smithy-core", marker = "python_full_version >= '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c7/ac/04164eefb3da7479f52f6535b4b39cc8384c292cb2bb74279f2acc4f4b4d/smithy_json-0.3.0.tar.gz", hash = "sha256:c81c7034587e01bc64767cbbecb05a7d65ca9070612fd94e8a03e80540290a22", size = 7956, upload-time = "2026-08-20T17:55:32.177Z" }
wheels = [
@ -9346,23 +9350,23 @@ resolution-markers = [
"python_full_version < '3.11'",
]
dependencies = [
{ name = "alabaster" },
{ name = "babel" },
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" } },
{ name = "imagesize" },
{ name = "jinja2" },
{ name = "packaging" },
{ name = "pygments" },
{ name = "requests" },
{ name = "snowballstemmer" },
{ name = "sphinxcontrib-applehelp" },
{ name = "sphinxcontrib-devhelp" },
{ name = "sphinxcontrib-htmlhelp" },
{ name = "sphinxcontrib-jsmath" },
{ name = "sphinxcontrib-qthelp" },
{ name = "sphinxcontrib-serializinghtml" },
{ name = "tomli" },
{ name = "alabaster", marker = "python_full_version < '3.11'" },
{ name = "babel", marker = "python_full_version < '3.11'" },
{ name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" },
{ name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "imagesize", marker = "python_full_version < '3.11'" },
{ name = "jinja2", marker = "python_full_version < '3.11'" },
{ name = "packaging", marker = "python_full_version < '3.11'" },
{ name = "pygments", marker = "python_full_version < '3.11'" },
{ name = "requests", marker = "python_full_version < '3.11'" },
{ name = "snowballstemmer", marker = "python_full_version < '3.11'" },
{ name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.11'" },
{ name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.11'" },
{ name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.11'" },
{ name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.11'" },
{ name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.11'" },
{ name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.11'" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" }
wheels = [
@ -9377,23 +9381,23 @@ resolution-markers = [
"python_full_version == '3.11.*'",
]
dependencies = [
{ name = "alabaster" },
{ name = "babel" },
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" } },
{ name = "imagesize" },
{ name = "jinja2" },
{ name = "packaging" },
{ name = "pygments" },
{ name = "requests" },
{ name = "roman-numerals" },
{ name = "snowballstemmer" },
{ name = "sphinxcontrib-applehelp" },
{ name = "sphinxcontrib-devhelp" },
{ name = "sphinxcontrib-htmlhelp" },
{ name = "sphinxcontrib-jsmath" },
{ name = "sphinxcontrib-qthelp" },
{ name = "sphinxcontrib-serializinghtml" },
{ name = "alabaster", marker = "python_full_version == '3.11.*'" },
{ name = "babel", marker = "python_full_version == '3.11.*'" },
{ name = "colorama", marker = "python_full_version == '3.11.*' and sys_platform == 'win32'" },
{ name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
{ name = "imagesize", marker = "python_full_version == '3.11.*'" },
{ name = "jinja2", marker = "python_full_version == '3.11.*'" },
{ name = "packaging", marker = "python_full_version == '3.11.*'" },
{ name = "pygments", marker = "python_full_version == '3.11.*'" },
{ name = "requests", marker = "python_full_version == '3.11.*'" },
{ name = "roman-numerals", marker = "python_full_version == '3.11.*'" },
{ name = "snowballstemmer", marker = "python_full_version == '3.11.*'" },
{ name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.11.*'" },
{ name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.11.*'" },
{ name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.11.*'" },
{ name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.11.*'" },
{ name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.11.*'" },
{ name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.11.*'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" }
wheels = [
@ -9410,23 +9414,23 @@ resolution-markers = [
"python_full_version == '3.12.*'",
]
dependencies = [
{ name = "alabaster" },
{ name = "babel" },
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" } },
{ name = "imagesize" },
{ name = "jinja2" },
{ name = "packaging" },
{ name = "pygments" },
{ name = "requests" },
{ name = "roman-numerals" },
{ name = "snowballstemmer" },
{ name = "sphinxcontrib-applehelp" },
{ name = "sphinxcontrib-devhelp" },
{ name = "sphinxcontrib-htmlhelp" },
{ name = "sphinxcontrib-jsmath" },
{ name = "sphinxcontrib-qthelp" },
{ name = "sphinxcontrib-serializinghtml" },
{ name = "alabaster", marker = "python_full_version >= '3.12'" },
{ name = "babel", marker = "python_full_version >= '3.12'" },
{ name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" },
{ name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
{ name = "imagesize", marker = "python_full_version >= '3.12'" },
{ name = "jinja2", marker = "python_full_version >= '3.12'" },
{ name = "packaging", marker = "python_full_version >= '3.12'" },
{ name = "pygments", marker = "python_full_version >= '3.12'" },
{ name = "requests", marker = "python_full_version >= '3.12'" },
{ name = "roman-numerals", marker = "python_full_version >= '3.12'" },
{ name = "snowballstemmer", marker = "python_full_version >= '3.12'" },
{ name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.12'" },
{ name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.12'" },
{ name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.12'" },
{ name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.12'" },
{ name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.12'" },
{ name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" }
wheels = [
@ -9574,8 +9578,8 @@ name = "standard-aifc"
version = "3.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "audioop-lts" },
{ name = "standard-chunk" },
{ name = "audioop-lts", marker = "python_full_version >= '3.13'" },
{ name = "standard-chunk", marker = "python_full_version >= '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c4/53/6050dc3dde1671eb3db592c13b55a8005e5040131f7509cef0215212cb84/standard_aifc-3.13.0.tar.gz", hash = "sha256:64e249c7cb4b3daf2fdba4e95721f811bde8bdfc43ad9f936589b7bb2fae2e43", size = 15240, upload-time = "2024-10-30T16:01:31.772Z" }
wheels = [
@ -9596,7 +9600,7 @@ name = "standard-sunau"
version = "3.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "audioop-lts" },
{ name = "audioop-lts", marker = "python_full_version >= '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/e3/ce8d38cb2d70e05ffeddc28bb09bad77cfef979eb0a299c9117f7ed4e6a9/standard_sunau-3.13.0.tar.gz", hash = "sha256:b319a1ac95a09a2378a8442f403c66f4fd4b36616d6df6ae82b8e536ee790908", size = 9368, upload-time = "2024-10-30T16:01:41.626Z" }
wheels = [
@ -9630,8 +9634,8 @@ name = "taskgroup"
version = "0.2.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "exceptiongroup" },
{ name = "typing-extensions" },
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f0/8d/e218e0160cc1b692e6e0e5ba34e8865dbb171efeb5fc9a704544b3020605/taskgroup-0.2.2.tar.gz", hash = "sha256:078483ac3e78f2e3f973e2edbf6941374fbea81b9c5d0a96f51d297717f4752d", size = 11504, upload-time = "2025-01-03T09:24:13.761Z" }
wheels = [