mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-11 03:38:38 +00:00
Merge 0c2553cf6d into f0415ee033
This commit is contained in:
commit
4dcbf83d31
26 changed files with 1280 additions and 478 deletions
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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``).
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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]):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
263
tests/integration/management/test_at_rest_encryption_format.py
Normal file
263
tests/integration/management/test_at_rest_encryption_format.py
Normal 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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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") == ""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
4
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
4
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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
392
uv.lock
generated
|
|
@ -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 = [
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue