diff --git a/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.51-py3-none-any.whl b/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.51-py3-none-any.whl new file mode 100644 index 0000000000..f3b69199c8 Binary files /dev/null and b/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.51-py3-none-any.whl differ diff --git a/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.51.tar.gz b/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.51.tar.gz new file mode 100644 index 0000000000..a1ea473b8b Binary files /dev/null and b/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.51.tar.gz differ diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260306175056_add_configs_override_table/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260306175056_add_configs_override_table/migration.sql new file mode 100644 index 0000000000..aad5e2b388 --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260306175056_add_configs_override_table/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN "spec_path" TEXT; + diff --git a/litellm-proxy-extras/pyproject.toml b/litellm-proxy-extras/pyproject.toml index 45c8856441..634d5ba6d4 100644 --- a/litellm-proxy-extras/pyproject.toml +++ b/litellm-proxy-extras/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "litellm-proxy-extras" -version = "0.4.50" +version = "0.4.51" description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package." authors = ["BerriAI"] readme = "README.md" @@ -22,7 +22,7 @@ requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" [tool.commitizen] -version = "0.4.50" +version = "0.4.51" version_files = [ "pyproject.toml:version", "../requirements.txt:litellm-proxy-extras==", diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 95dabd8bfd..da7e1f5a04 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -78,6 +78,7 @@ class SupportedDBObjectType(str, enum.Enum): PROMPTS = "prompts" MODEL_COST_MAP = "model_cost_map" TOOLS = "tools" + CONFIG_OVERRIDES = "config_overrides" def __str__(self): return str(self.value) @@ -2167,7 +2168,7 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase): user_header_mappings: Optional[List[UserHeaderMapping]] = None supported_db_objects: Optional[List[SupportedDBObjectType]] = Field( None, - description="Fine-grained control over which object types to load from the database when store_model_in_db is True. Available types: 'models', 'mcp', 'guardrails', 'vector_stores', 'pass_through_endpoints', 'prompts', 'model_cost_map', 'tools'. If not set, all objects are loaded (default behavior).", + description="Fine-grained control over which object types to load from the database when store_model_in_db is True. Available types: 'models', 'mcp', 'guardrails', 'vector_stores', 'pass_through_endpoints', 'prompts', 'model_cost_map', 'tools', 'config_overrides'. If not set, all objects are loaded (default behavior).", ) user_mcp_management_mode: Optional[UserMCPManagementMode] = Field( None, diff --git a/litellm/proxy/management_endpoints/config_override_endpoints.py b/litellm/proxy/management_endpoints/config_override_endpoints.py new file mode 100644 index 0000000000..d17c4e9a7b --- /dev/null +++ b/litellm/proxy/management_endpoints/config_override_endpoints.py @@ -0,0 +1,409 @@ +import asyncio +import json +import os +from typing import Any, Dict, Set + +from litellm.litellm_core_utils.safe_json_dumps import safe_dumps +from litellm.litellm_core_utils.safe_json_loads import safe_json_loads + +from fastapi import APIRouter, Depends, HTTPException +from prisma.errors import RecordNotFoundError +from pydantic import TypeAdapter + +import litellm +from litellm._logging import verbose_proxy_logger +from litellm.llms.custom_httpx.http_handler import get_async_httpx_client +from litellm.types.llms.custom_http import httpxSpecialProvider +from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker +from litellm.proxy._types import CommonProxyErrors, KeyManagementSystem, LitellmUserRoles, UserAPIKeyAuth +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.types.proxy.management_endpoints.config_overrides import ( + ConfigOverrideSettingsResponse, + HashicorpVaultConfig, +) + +router = APIRouter() + +# --- Hashicorp Vault constants --- + +HASHICORP_ENV_VAR_MAPPING: Dict[str, str] = { + "vault_addr": "HCP_VAULT_ADDR", + "vault_token": "HCP_VAULT_TOKEN", + "approle_role_id": "HCP_VAULT_APPROLE_ROLE_ID", + "approle_secret_id": "HCP_VAULT_APPROLE_SECRET_ID", + "approle_mount_path": "HCP_VAULT_APPROLE_MOUNT_PATH", + "client_cert": "HCP_VAULT_CLIENT_CERT", + "client_key": "HCP_VAULT_CLIENT_KEY", + "vault_cert_role": "HCP_VAULT_CERT_ROLE", + "vault_namespace": "HCP_VAULT_NAMESPACE", + "vault_mount_name": "HCP_VAULT_MOUNT_NAME", + "vault_path_prefix": "HCP_VAULT_PATH_PREFIX", +} + +HASHICORP_SENSITIVE_FIELDS: Set[str] = { + "vault_token", + "approle_secret_id", + "client_key", +} + +_sensitive_masker = SensitiveDataMasker() + + +# --- Shared helpers --- + + +def _mask_sensitive_fields( + data: Dict[str, Any], sensitive_fields: Set[str] +) -> Dict[str, Any]: + """Mask sensitive fields for API responses. Non-sensitive fields are left as-is.""" + masked = {} + for key, value in data.items(): + if value is not None and key in sensitive_fields and isinstance(value, str): + masked[key] = _sensitive_masker._mask_value(value) + else: + masked[key] = value + return masked + + +def _get_current_env_values(env_var_mapping: Dict[str, str]) -> Dict[str, Any]: + """Read current env var values as fallback when no DB record exists.""" + values = {} + for field_name, env_var_name in env_var_mapping.items(): + env_value = os.environ.get(env_var_name) + values[field_name] = env_value + return values + + +def _extract_field_type(field_info: Dict[str, Any]) -> str: + """Extract the non-null type from a Pydantic v2 JSON schema field.""" + if "type" in field_info: + return field_info["type"] + for option in field_info.get("anyOf", []): + if option.get("type") != "null": + return option.get("type", "string") + return "string" + + +def _build_field_schema(model_class: type) -> Dict[str, Any]: + """Build field_schema dict from a Pydantic model for UI rendering.""" + schema = TypeAdapter(model_class).json_schema(by_alias=True) + properties = {} + for field_name, field_info in schema.get("properties", {}).items(): + properties[field_name] = { + "description": field_info.get("description", ""), + "type": _extract_field_type(field_info), + } + return { + "description": schema.get("description", ""), + "properties": properties, + } + + +def _parse_config_value(raw: Any) -> Dict[str, Any]: + """Parse a config_value from DB (may be JSON string or dict).""" + if isinstance(raw, str): + return safe_json_loads(raw, default={}) + return dict(raw) + + +def _set_env_vars(config_data: Dict[str, Any]) -> None: + """Set HCP_VAULT_* env vars from config data. Unsets vars for missing/None/empty fields.""" + for field_name, env_var_name in HASHICORP_ENV_VAR_MAPPING.items(): + value = config_data.get(field_name) + if value is not None and value != "": + os.environ[env_var_name] = str(value) + else: + os.environ.pop(env_var_name, None) + + +def _clear_hashicorp_vault_state(proxy_config: Any) -> None: + """Clear all Hashicorp Vault state: env vars, secret manager, and change-detection cache.""" + _set_env_vars({}) + if litellm._key_management_system == KeyManagementSystem.HASHICORP_VAULT: + litellm.secret_manager_client = None + litellm._key_management_system = None + proxy_config._last_hashicorp_vault_config = None + + +# --- Hashicorp Vault endpoints --- + + +@router.post( + "/config_overrides/hashicorp_vault", + tags=["Config Overrides"], + dependencies=[Depends(user_api_key_auth)], +) +async def update_hashicorp_vault_config( + config: HashicorpVaultConfig, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """ + Update Hashicorp Vault secret manager configuration. + Sets environment variables, encrypts sensitive fields, and stores in DB. + Reinitializes the secret manager on this pod. + """ + from litellm.proxy.proxy_server import prisma_client, proxy_config + + if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: + raise HTTPException( + status_code=403, + detail="Only admin users can update config overrides", + ) + + if prisma_client is None: + raise HTTPException( + status_code=500, + detail=CommonProxyErrors.db_not_connected_error.value, + ) + + config_data = config.model_dump(exclude_none=True) + + # Merge ALL fields the user didn't send: try DB first, fall back to env vars. + # Omitted field = keep existing; empty string = clear/remove the field. + existing_record = await prisma_client.db.litellm_configoverrides.find_unique( + where={"config_type": "hashicorp_vault"} + ) + if existing_record is not None and existing_record.config_value is not None: + existing_data = _parse_config_value(existing_record.config_value) + existing_decrypted = proxy_config._decrypt_db_variables(existing_data) + for field in HASHICORP_ENV_VAR_MAPPING: + if field not in config_data and existing_decrypted.get(field): + config_data[field] = existing_decrypted[field] + else: + # No DB record yet — merge from current env vars + env_values = _get_current_env_values(HASHICORP_ENV_VAR_MAPPING) + for field in HASHICORP_ENV_VAR_MAPPING: + if field not in config_data and env_values.get(field): + config_data[field] = env_values[field] + + # Strip empty strings — they signal "clear this field" + config_data = {k: v for k, v in config_data.items() if v != ""} + + # Validate that the config has enough fields to initialize + has_vault_addr = bool(config_data.get("vault_addr")) + has_token_auth = bool(config_data.get("vault_token")) + has_approle_auth = bool( + config_data.get("approle_role_id") and config_data.get("approle_secret_id") + ) + has_tls_cert_auth = bool( + config_data.get("client_cert") and config_data.get("client_key") + ) + + if not has_vault_addr: + raise HTTPException( + status_code=400, + detail="Vault Address is required", + ) + + if not has_token_auth and not has_approle_auth and not has_tls_cert_auth: + raise HTTPException( + status_code=400, + detail="At least one authentication method is required: " + "provide a Token, both AppRole Role ID and Secret ID, " + "or both Client Certificate and Client Key", + ) + + # Snapshot current env vars so we can restore on failure + previous_env = _get_current_env_values(HASHICORP_ENV_VAR_MAPPING) + + # Set env vars and verify the secret manager can initialize before persisting + _set_env_vars(config_data) + + try: + proxy_config.initialize_secret_manager( + key_management_system="hashicorp_vault" + ) + except Exception as e: + _set_env_vars(previous_env) + verbose_proxy_logger.exception( + "Error reinitializing Hashicorp Vault secret manager: %s", str(e) + ) + raise HTTPException( + status_code=500, + detail=f"Failed to initialize secret manager: {e}", + ) + + # Only persist to DB after successful init + encrypted_data = proxy_config._encrypt_env_variables(config_data) + config_value = safe_dumps(encrypted_data) + await prisma_client.db.litellm_configoverrides.upsert( + where={"config_type": "hashicorp_vault"}, + data={ + "create": { + "config_type": "hashicorp_vault", + "config_value": config_value, + }, + "update": { + "config_value": config_value, + }, + }, + ) + + # Update change-detection cache so the background reload doesn't redundantly re-init + proxy_config._last_hashicorp_vault_config = safe_json_loads(config_value) + + return { + "message": "Hashicorp Vault configuration updated successfully", + "status": "success", + } + + +@router.get( + "/config_overrides/hashicorp_vault", + tags=["Config Overrides"], + dependencies=[Depends(user_api_key_auth)], + response_model=ConfigOverrideSettingsResponse, +) +async def get_hashicorp_vault_config( + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """ + Get current Hashicorp Vault configuration. + Returns decrypted values from DB, or falls back to current env vars. + """ + from litellm.proxy.proxy_server import prisma_client, proxy_config + + if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: + raise HTTPException( + status_code=403, + detail="Only admin users can view config overrides", + ) + + if prisma_client is None: + raise HTTPException( + status_code=500, + detail=CommonProxyErrors.db_not_connected_error.value, + ) + + field_schema = _build_field_schema(HashicorpVaultConfig) + + # Try to load from DB + db_record = await prisma_client.db.litellm_configoverrides.find_unique( + where={"config_type": "hashicorp_vault"} + ) + + if db_record is not None and db_record.config_value is not None: + config_data = _parse_config_value(db_record.config_value) + + # Decrypt then mask sensitive fields so plaintext secrets are never sent to the UI + decrypted_data = proxy_config._decrypt_db_variables(config_data) + masked_data = _mask_sensitive_fields( + decrypted_data, HASHICORP_SENSITIVE_FIELDS + ) + + return ConfigOverrideSettingsResponse( + config_type="hashicorp_vault", + values=masked_data, + field_schema=field_schema, + ) + + # Fallback to env vars — also mask sensitive values + env_values = _get_current_env_values(HASHICORP_ENV_VAR_MAPPING) + masked_env_values = _mask_sensitive_fields( + env_values, HASHICORP_SENSITIVE_FIELDS + ) + + return ConfigOverrideSettingsResponse( + config_type="hashicorp_vault", + values=masked_env_values, + field_schema=field_schema, + ) + + +@router.delete( + "/config_overrides/hashicorp_vault", + tags=["Config Overrides"], + dependencies=[Depends(user_api_key_auth)], +) +async def delete_hashicorp_vault_config( + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """Delete Hashicorp Vault configuration. Idempotent.""" + from litellm.proxy.proxy_server import prisma_client, proxy_config + + if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: + raise HTTPException( + status_code=403, + detail="Only admin users can delete config overrides", + ) + + if prisma_client is None: + raise HTTPException( + status_code=500, + detail=CommonProxyErrors.db_not_connected_error.value, + ) + + # Delete DB record if it exists — ignore if not found + try: + await prisma_client.db.litellm_configoverrides.delete( + where={"config_type": "hashicorp_vault"} + ) + except RecordNotFoundError: + verbose_proxy_logger.debug( + "No existing Hashicorp Vault config record to delete" + ) + + _clear_hashicorp_vault_state(proxy_config) + + return { + "message": "Hashicorp Vault configuration deleted successfully", + "status": "success", + } + + +@router.post( + "/config_overrides/hashicorp_vault/test_connection", + tags=["Config Overrides"], + dependencies=[Depends(user_api_key_auth)], +) +async def test_hashicorp_vault_connection( + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """ + Test the connection to the currently configured Hashicorp Vault. + Uses the already-initialized secret manager client. Does not modify any state. + """ + from litellm.secret_managers.hashicorp_secret_manager import ( + HashicorpSecretManager, + ) + + if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: + raise HTTPException( + status_code=403, + detail="Only admin users can test Vault connection", + ) + + client = litellm.secret_manager_client + if not isinstance(client, HashicorpSecretManager): + raise HTTPException( + status_code=400, + detail="Hashicorp Vault is not configured. Save a configuration first.", + ) + + # Step 1: Authenticate (exercises AppRole login, TLS cert login, or direct token) + try: + headers = await asyncio.to_thread(client._get_request_headers) + except Exception as e: + raise HTTPException( + status_code=502, + detail=f"Vault authentication failed: {e}", + ) + + # Step 2: Verify the token is valid via token/lookup-self + try: + async_client = get_async_httpx_client(llm_provider=httpxSpecialProvider.SecretManager) + lookup_url = f"{client.vault_addr}/v1/auth/token/lookup-self" + if client.vault_namespace: + headers["X-Vault-Namespace"] = client.vault_namespace + response = await async_client.get(lookup_url, headers=headers) + response.raise_for_status() + except Exception as e: + raise HTTPException( + status_code=502, + detail=f"Vault token validation failed: {e}", + ) + + return { + "status": "success", + "message": f"Successfully connected to Vault at {client.vault_addr}", + } diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 7fe0ce6d6f..120f872b35 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -350,6 +350,9 @@ from litellm.proxy.management_endpoints.cache_settings_endpoints import ( from litellm.proxy.management_endpoints.callback_management_endpoints import ( router as callback_management_endpoints_router, ) +from litellm.proxy.management_endpoints.config_override_endpoints import ( + router as config_override_router, +) from litellm.proxy.management_endpoints.common_utils import ( _user_has_admin_privileges, admin_can_invite_user, @@ -2248,6 +2251,7 @@ class ProxyConfig: def __init__(self) -> None: self.config: Dict[str, Any] = {} self._last_semantic_filter_config: Optional[Dict[str, Any]] = None + self._last_hashicorp_vault_config: Optional[Dict[str, Any]] = None def is_yaml(self, config_file_path: str) -> bool: if not os.path.isfile(config_file_path): @@ -4448,6 +4452,11 @@ class ProxyConfig: if self._should_load_db_object(object_type="semantic_filter_settings"): await self._init_semantic_filter_settings_in_db(prisma_client=prisma_client) + if self._should_load_db_object(object_type="config_overrides"): + await self._init_hashicorp_vault_config_override( + prisma_client=prisma_client + ) + async def _init_semantic_filter_settings_in_db(self, prisma_client: PrismaClient): """ Initialize MCP semantic filter settings from database. @@ -4557,6 +4566,65 @@ class ProxyConfig: ) ) + async def _init_hashicorp_vault_config_override( + self, prisma_client: PrismaClient + ): + """ + Load Hashicorp Vault config override from DB. + Decrypts sensitive fields, sets HCP_VAULT_* env vars, and reinitializes the secret manager. + Called periodically via _init_non_llm_objects_in_db to sync config across pods. + """ + from litellm.proxy.management_endpoints.config_override_endpoints import ( + HASHICORP_ENV_VAR_MAPPING, + _clear_hashicorp_vault_state, + _get_current_env_values, + _parse_config_value, + _set_env_vars, + ) + + try: + db_record = await prisma_client.db.litellm_configoverrides.find_unique( + where={"config_type": "hashicorp_vault"} + ) + + if db_record is None or db_record.config_value is None: + if self._last_hashicorp_vault_config is not None: + _clear_hashicorp_vault_state(self) + return + + config_data = _parse_config_value(db_record.config_value) + + # Skip reinit if config hasn't changed since last poll + if self._last_hashicorp_vault_config == config_data: + return + + # Decrypt all fields and set env vars + decrypted_data = self._decrypt_db_variables(config_data) + + # Snapshot current env vars so we can restore on failure + previous_env = _get_current_env_values(HASHICORP_ENV_VAR_MAPPING) + _set_env_vars(decrypted_data) + + # Reinitialize the secret manager + try: + self.initialize_secret_manager( + key_management_system="hashicorp_vault" + ) + except Exception: + # Restore previous working env vars instead of wiping all + _set_env_vars(previous_env) + raise + + self._last_hashicorp_vault_config = config_data.copy() + verbose_proxy_logger.debug( + "Hashicorp Vault config override loaded from DB" + ) + except Exception as e: + verbose_proxy_logger.exception( + "Error loading Hashicorp Vault config override from DB: %s", + str(e), + ) + async def _check_and_reload_model_cost_map(self, prisma_client: PrismaClient): """ Check if model cost map needs to be reloaded based on database configuration. @@ -13062,6 +13130,7 @@ app.include_router(cost_tracking_settings_router) app.include_router(router_settings_router) app.include_router(fallback_management_router) app.include_router(cache_settings_router) +app.include_router(config_override_router) app.include_router(user_agent_analytics_router) app.include_router(enterprise_router) app.include_router(ui_discovery_endpoints_router) diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index 6f4ef0c24b..4210afc11d 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -1057,6 +1057,14 @@ model LiteLLM_UISettings { updated_at DateTime @updatedAt } +// Generic config overrides table - one row per config_type +model LiteLLM_ConfigOverrides { + config_type String @id + config_value Json + created_at DateTime @default(now()) + updated_at DateTime @updatedAt +} + // Skills table for storing LiteLLM-managed skills model LiteLLM_SkillsTable { skill_id String @id @default(uuid()) diff --git a/litellm/secret_managers/hashicorp_secret_manager.py b/litellm/secret_managers/hashicorp_secret_manager.py index c59f2ef638..ccee5018ee 100644 --- a/litellm/secret_managers/hashicorp_secret_manager.py +++ b/litellm/secret_managers/hashicorp_secret_manager.py @@ -44,6 +44,11 @@ class HashicorpSecretManager(BaseSecretManager): self._verify_required_credentials_exist() + if premium_user is not True: + raise ValueError( + f"Hashicorp secret manager is only available for premium users. {CommonProxyErrors.not_premium_user.value}" + ) + litellm.secret_manager_client = self litellm._key_management_system = KeyManagementSystem.HASHICORP_VAULT _refresh_interval = os.environ.get( @@ -58,11 +63,6 @@ class HashicorpSecretManager(BaseSecretManager): default_ttl=_refresh_interval ) # store in memory for 1 day - if premium_user is not True: - raise ValueError( - f"Hashicorp secret manager is only available for premium users. {CommonProxyErrors.not_premium_user.value}" - ) - def _verify_required_credentials_exist(self) -> None: """ Validate that at least one authentication method is configured. @@ -70,13 +70,16 @@ class HashicorpSecretManager(BaseSecretManager): Raises: ValueError: If no valid authentication credentials are provided """ - if not self.vault_token and not ( - self.approle_role_id and self.approle_secret_id - ): + has_token = bool(self.vault_token) + has_approle = bool(self.approle_role_id and self.approle_secret_id) + has_tls_cert = bool(self.tls_cert_path and self.tls_key_path) + + if not has_token and not has_approle and not has_tls_cert: raise ValueError( "Missing Vault authentication credentials. Please set either:\n" " - HCP_VAULT_TOKEN for token-based auth, or\n" - " - HCP_VAULT_APPROLE_ROLE_ID and HCP_VAULT_APPROLE_SECRET_ID for AppRole auth" + " - HCP_VAULT_APPROLE_ROLE_ID and HCP_VAULT_APPROLE_SECRET_ID for AppRole auth, or\n" + " - HCP_VAULT_CLIENT_CERT and HCP_VAULT_CLIENT_KEY for TLS certificate auth" ) def _auth_via_approle(self) -> str: diff --git a/litellm/types/proxy/management_endpoints/config_overrides.py b/litellm/types/proxy/management_endpoints/config_overrides.py new file mode 100644 index 0000000000..6f5d661f57 --- /dev/null +++ b/litellm/types/proxy/management_endpoints/config_overrides.py @@ -0,0 +1,64 @@ +from typing import Any, Dict, Optional + +from pydantic import BaseModel, Field + + +class HashicorpVaultConfig(BaseModel): + """Configuration for Hashicorp Vault secret manager integration.""" + + vault_addr: Optional[str] = Field( + default=None, + description="The address of the Vault server (e.g., https://vault.example.com:8200)", + ) + vault_token: Optional[str] = Field( + default=None, + description="Token for Vault token-based authentication", + ) + approle_role_id: Optional[str] = Field( + default=None, + description="Role ID for Vault AppRole authentication", + ) + approle_secret_id: Optional[str] = Field( + default=None, + description="Secret ID for Vault AppRole authentication", + ) + approle_mount_path: Optional[str] = Field( + default=None, + description="Mount path for the AppRole auth method (default: approle)", + ) + client_cert: Optional[str] = Field( + default=None, + description="Path to the client TLS certificate for Vault", + ) + client_key: Optional[str] = Field( + default=None, + description="Path to the client TLS private key for Vault", + ) + vault_cert_role: Optional[str] = Field( + default=None, + description="Certificate role name for TLS cert authentication", + ) + vault_namespace: Optional[str] = Field( + default=None, + description="Vault namespace (for multi-tenant Vault, sent as X-Vault-Namespace header)", + ) + vault_mount_name: Optional[str] = Field( + default=None, + description="KV engine mount name (default: secret)", + ) + vault_path_prefix: Optional[str] = Field( + default=None, + description="Optional path prefix for secrets (e.g., myapp -> secret/data/myapp/{secret_name})", + ) + + +class ConfigOverrideSettingsResponse(BaseModel): + """Response model for config override settings GET endpoints.""" + + config_type: str = Field(description="The type of config override") + values: Dict[str, Any] = Field( + description="Current configuration values (sensitive fields decrypted)" + ) + field_schema: Dict[str, Any] = Field( + description="Schema information for UI rendering" + ) diff --git a/pyproject.toml b/pyproject.toml index a432c1ac83..96f800d9e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,7 +61,7 @@ boto3 = { version = "1.40.76", optional = true } redisvl = {version = "^0.4.1", optional = true, markers = "python_version >= '3.9' and python_version < '3.14'"} mcp = {version = ">=1.25.0,<2.0.0", optional = true, python = ">=3.10"} a2a-sdk = {version = "^0.3.22", optional = true, python = ">=3.10"} -litellm-proxy-extras = {version = "0.4.50", optional = true} +litellm-proxy-extras = {version = "0.4.51", optional = true} rich = {version = "13.7.1", optional = true} litellm-enterprise = {version = "0.1.33", optional = true} diskcache = {version = "^5.6.1", optional = true} diff --git a/requirements.txt b/requirements.txt index aef0e1d271..c44c97ac6c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -57,7 +57,7 @@ grpcio>=1.75.0; python_version >= "3.14" sentry_sdk==2.21.0 # for sentry error handling detect-secrets==1.5.0 # Enterprise - secret detection / masking in LLM requests tzdata==2025.1 # IANA time zone database -litellm-proxy-extras==0.4.50 # for proxy extras - e.g. prisma migrations +litellm-proxy-extras==0.4.51 # for proxy extras - e.g. prisma migrations llm-sandbox==0.3.31 # for skill execution in sandbox ### LITELLM PACKAGE DEPENDENCIES python-dotenv==1.0.1 # for env diff --git a/tests/test_litellm/proxy/management_endpoints/test_config_override_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_config_override_endpoints.py new file mode 100644 index 0000000000..22258dc80c --- /dev/null +++ b/tests/test_litellm/proxy/management_endpoints/test_config_override_endpoints.py @@ -0,0 +1,251 @@ +import json +import os +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi.testclient import TestClient +from prisma.errors import RecordNotFoundError + +import litellm +import litellm.proxy.proxy_server as ps +from litellm.proxy._types import KeyManagementSystem, LitellmUserRoles, UserAPIKeyAuth +from litellm.proxy.management_endpoints.config_override_endpoints import ( + HASHICORP_ENV_VAR_MAPPING, + _build_field_schema, + _set_env_vars, +) +from litellm.proxy.proxy_server import app +from litellm.types.proxy.management_endpoints.config_overrides import ( + HashicorpVaultConfig, +) + +VAULT_URL = "/config_overrides/hashicorp_vault" + + +@pytest.fixture +def client(): + return TestClient(app) + + +def _make_mock_db(): + mock = MagicMock() + mock.find_unique = AsyncMock(return_value=None) + mock.upsert = AsyncMock(return_value=None) + mock.delete = AsyncMock(return_value=None) + prisma = MagicMock() + prisma.db.litellm_configoverrides = mock + return prisma, mock + + +def _make_mock_proxy_config(): + cfg = MagicMock() + cfg.initialize_secret_manager = MagicMock() + cfg._last_hashicorp_vault_config = None + cfg._encrypt_env_variables = MagicMock( + side_effect=lambda d: {k: f"enc_{v}" for k, v in d.items()} + ) + cfg._decrypt_db_variables = MagicMock( + side_effect=lambda d: { + k: v.replace("enc_", "") if isinstance(v, str) else v + for k, v in d.items() + } + ) + return cfg + + +def _upserted_data(mock_db): + return json.loads(mock_db.upsert.call_args.kwargs["data"]["create"]["config_value"]) + + +def _db_record(data): + rec = MagicMock() + rec.config_value = json.dumps(data) + return rec + + +def _cleanup(): + app.dependency_overrides.pop(ps.user_api_key_auth, None) + for env_var in HASHICORP_ENV_VAR_MAPPING.values(): + os.environ.pop(env_var, None) + + +def _set_admin(): + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user" + ) + + +@pytest.mark.asyncio +async def test_hashicorp_vault_crud_lifecycle(client, monkeypatch): + """Create → read (masked) → partial update (merge from DB) → clear field → + only-provided fields → delete → idempotent delete → env fallback → + merge from env → helpers → encrypt/decrypt roundtrip.""" + mock_prisma, mock_db = _make_mock_db() + mock_cfg = _make_mock_proxy_config() + monkeypatch.setattr(ps, "prisma_client", mock_prisma) + monkeypatch.setattr(ps, "proxy_config", mock_cfg) + old_client, old_kms = litellm.secret_manager_client, litellm._key_management_system + _set_admin() + + try: + # 1. POST: create + r = client.post(VAULT_URL, json={ + "vault_addr": "https://vault.example.com", + "vault_token": "my-secret-vault-token", + "vault_namespace": "admin", + "vault_mount_name": "secret", + }) + assert r.status_code == 200 + assert os.environ["HCP_VAULT_ADDR"] == "https://vault.example.com" + data = _upserted_data(mock_db) + assert data["vault_token"] == "enc_my-secret-vault-token" + mock_cfg.initialize_secret_manager.assert_called_with(key_management_system="hashicorp_vault") + assert mock_cfg._last_hashicorp_vault_config is not None + + # 2. GET: sensitive fields masked + mock_db.find_unique = AsyncMock(return_value=_db_record(data)) + r = client.get(VAULT_URL) + assert r.status_code == 200 + vals = r.json()["values"] + assert vals["vault_addr"] == "https://vault.example.com" + assert "*" in vals["vault_token"] + assert "properties" in r.json()["field_schema"] + + # 3. POST partial: omitted fields merge from DB + r = client.post(VAULT_URL, json={"vault_addr": "https://vault.new.com"}) + assert r.status_code == 200 + data = _upserted_data(mock_db) + assert data["vault_addr"] == "enc_https://vault.new.com" + assert data["vault_token"] == "enc_my-secret-vault-token" + assert data["vault_namespace"] == "enc_admin" + + # 4. POST empty string: clears field, preserves others + step3 = {**data, "approle_role_id": "enc_role", "approle_secret_id": "enc_secret"} + mock_db.find_unique = AsyncMock(return_value=_db_record(step3)) + mock_db.upsert = AsyncMock(return_value=None) + r = client.post(VAULT_URL, json={"vault_token": ""}) + assert r.status_code == 200 + data = _upserted_data(mock_db) + assert "vault_token" not in data + assert data["approle_role_id"] == "enc_role" + + # 5. POST only provided fields (clean slate) + for v in HASHICORP_ENV_VAR_MAPPING.values(): + os.environ.pop(v, None) + mock_db.find_unique = AsyncMock(return_value=None) + mock_db.upsert = AsyncMock(return_value=None) + r = client.post(VAULT_URL, json={"vault_addr": "https://v.com", "vault_token": "tok"}) + assert r.status_code == 200 + assert _upserted_data(mock_db) == {"vault_addr": "enc_https://v.com", "vault_token": "enc_tok"} + + # 6. DELETE: clears everything + litellm.secret_manager_client = MagicMock() + litellm._key_management_system = KeyManagementSystem.HASHICORP_VAULT + r = client.delete(VAULT_URL) + assert r.status_code == 200 + assert os.environ.get("HCP_VAULT_ADDR") is None + assert litellm.secret_manager_client is None + + # 7. DELETE idempotent + mock_db.delete = AsyncMock( + side_effect=RecordNotFoundError(data={"clientVersion": "0.0.0"}, message="Not found") + ) + assert client.delete(VAULT_URL).status_code == 200 + + # 8. GET: env var fallback + mock_db.find_unique = AsyncMock(return_value=None) + monkeypatch.setenv("HCP_VAULT_ADDR", "https://vault.env.com") + monkeypatch.setenv("HCP_VAULT_NAMESPACE", "env-ns") + r = client.get(VAULT_URL) + assert r.json()["values"]["vault_addr"] == "https://vault.env.com" + + # 9. POST: merge from env vars + monkeypatch.setenv("HCP_VAULT_TOKEN", "env-token") + monkeypatch.setenv("HCP_VAULT_MOUNT_NAME", "env-mount") + mock_cfg.initialize_secret_manager = MagicMock() + mock_db.upsert = AsyncMock(return_value=None) + r = client.post(VAULT_URL, json={"vault_addr": "https://vault.merged.com"}) + assert r.status_code == 200 + data = _upserted_data(mock_db) + assert data["vault_token"] == "enc_env-token" + assert data["vault_mount_name"] == "enc_env-mount" + + # 10. _set_env_vars: empty string unsets + monkeypatch.setenv("HCP_VAULT_TOKEN", "existing") + _set_env_vars({"vault_token": "", "vault_addr": "https://v.com"}) + assert os.environ.get("HCP_VAULT_TOKEN") is None + assert os.environ["HCP_VAULT_ADDR"] == "https://v.com" + + # 11. _build_field_schema + schema = _build_field_schema(HashicorpVaultConfig) + assert "vault_addr" in schema["properties"] + assert len(schema["properties"]["vault_addr"]["description"]) > 0 + + # 12. encrypt/decrypt roundtrip + from litellm.proxy.proxy_server import ProxyConfig + monkeypatch.setenv("LITELLM_SALT_KEY", "sk-test-salt-key") + pc = ProxyConfig() + orig = {"vault_addr": "https://v.com", "vault_token": "secret"} + encrypted = pc._encrypt_env_variables(orig) + assert all(encrypted[k] != orig[k] for k in orig) + decrypted = pc._decrypt_db_variables(encrypted) + assert all(decrypted[k] == orig[k] for k in orig) + + finally: + litellm.secret_manager_client = old_client + litellm._key_management_system = old_kms + _cleanup() + + +@pytest.mark.asyncio +async def test_hashicorp_vault_validation_errors_and_access_control(client, monkeypatch): + """Validation (missing fields, init failure rollback), DELETE preserves + non-Vault secret managers, non-admin 403 on all endpoints.""" + mock_prisma, mock_db = _make_mock_db() + mock_cfg = MagicMock() + mock_cfg._last_hashicorp_vault_config = {"vault_addr": "old"} + monkeypatch.setattr(ps, "prisma_client", mock_prisma) + monkeypatch.setattr(ps, "proxy_config", mock_cfg) + old_client, old_kms = litellm.secret_manager_client, litellm._key_management_system + _set_admin() + + try: + # 1. Missing vault_addr → 400 + r = client.post(VAULT_URL, json={"vault_token": "tok"}) + assert r.status_code == 400 + assert "Vault Address" in r.json()["detail"] + + # 2. Missing auth → 400 + r = client.post(VAULT_URL, json={"vault_addr": "https://v.com"}) + assert r.status_code == 400 + assert "authentication" in r.json()["detail"].lower() + + # 3. Init failure → 500, env vars restored + mock_cfg.initialize_secret_manager = MagicMock(side_effect=Exception("fail")) + monkeypatch.setenv("HCP_VAULT_ADDR", "https://vault.old.com") + monkeypatch.setenv("HCP_VAULT_TOKEN", "old-token") + r = client.post(VAULT_URL, json={"vault_addr": "https://bad.com", "vault_token": "bad"}) + assert r.status_code == 500 + assert os.environ["HCP_VAULT_ADDR"] == "https://vault.old.com" + mock_db.upsert.assert_not_awaited() + + # 4. DELETE preserves non-Vault secret manager + aws = MagicMock() + litellm.secret_manager_client = aws + litellm._key_management_system = KeyManagementSystem.AWS_SECRET_MANAGER + assert client.delete(VAULT_URL).status_code == 200 + assert litellm.secret_manager_client is aws + assert litellm._key_management_system == KeyManagementSystem.AWS_SECRET_MANAGER + + # 5. Non-admin → 403 + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, user_id="user" + ) + assert client.get(VAULT_URL).status_code == 403 + assert client.post(VAULT_URL, json={"vault_addr": "https://v.com"}).status_code == 403 + assert client.delete(VAULT_URL).status_code == 403 + + finally: + litellm.secret_manager_client = old_client + litellm._key_management_system = old_kms + _cleanup()