diff --git a/.github/workflows/test-unit-proxy-db.yml b/.github/workflows/test-unit-proxy-db.yml index 49795ad4e8..d5781f767f 100644 --- a/.github/workflows/test-unit-proxy-db.yml +++ b/.github/workflows/test-unit-proxy-db.yml @@ -183,7 +183,6 @@ jobs: tests/proxy_unit_tests/test_skills_db.py tests/proxy_unit_tests/test_update_daily_tag_spend.py tests/proxy_unit_tests/test_update_spend.py - tests/proxy_unit_tests/test_project_endpoints_prisma.py tests/proxy_unit_tests/test_proxy_encrypt_decrypt.py workers: 4 dist: loadscope diff --git a/.github/workflows/test-unit-proxy-legacy.yml b/.github/workflows/test-unit-proxy-legacy.yml index e078d1d45f..5768551f9b 100644 --- a/.github/workflows/test-unit-proxy-legacy.yml +++ b/.github/workflows/test-unit-proxy-legacy.yml @@ -28,7 +28,7 @@ jobs: - name: "key-generation" path: "tests/proxy_unit_tests/test_[k-o]*.py" - name: "proxy-config" - path: "tests/proxy_unit_tests/test_prisma*.py tests/proxy_unit_tests/test_project*.py tests/proxy_unit_tests/test_prompt*.py tests/proxy_unit_tests/test_proxy_[c-r]*.py" + path: "tests/proxy_unit_tests/test_prisma*.py tests/proxy_unit_tests/test_prompt*.py tests/proxy_unit_tests/test_proxy_[c-r]*.py" - name: "proxy-server" path: "tests/proxy_unit_tests/test_proxy_server.py" - name: "proxy-server-extras" diff --git a/enterprise/litellm_enterprise/proxy/enterprise_routes.py b/enterprise/litellm_enterprise/proxy/enterprise_routes.py index e28d8b8a4c..ec37c04980 100644 --- a/enterprise/litellm_enterprise/proxy/enterprise_routes.py +++ b/enterprise/litellm_enterprise/proxy/enterprise_routes.py @@ -4,10 +4,13 @@ from litellm_enterprise.enterprise_callbacks.send_emails.endpoints import ( router as email_events_router, ) +from . import ui_crud_endpoints # side-effect: registers extra UI settings from .audit_logging_endpoints import router as audit_logging_router from .management_endpoints import management_endpoints_router from .utils import _should_block_robots +__all__ = ["router", "ui_crud_endpoints"] + router = APIRouter() router.include_router(email_events_router) router.include_router(audit_logging_router) diff --git a/enterprise/litellm_enterprise/proxy/management_endpoints/__init__.py b/enterprise/litellm_enterprise/proxy/management_endpoints/__init__.py index 7042dae53a..0791a061b4 100644 --- a/enterprise/litellm_enterprise/proxy/management_endpoints/__init__.py +++ b/enterprise/litellm_enterprise/proxy/management_endpoints/__init__.py @@ -1,8 +1,10 @@ from fastapi import APIRouter from .internal_user_endpoints import router as internal_user_endpoints_router +from .project_endpoints import router as project_endpoints_router management_endpoints_router = APIRouter() management_endpoints_router.include_router(internal_user_endpoints_router) +management_endpoints_router.include_router(project_endpoints_router) __all__ = ["management_endpoints_router"] diff --git a/litellm/proxy/management_endpoints/project_endpoints.py b/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py similarity index 100% rename from litellm/proxy/management_endpoints/project_endpoints.py rename to enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py diff --git a/enterprise/litellm_enterprise/proxy/ui_crud_endpoints/__init__.py b/enterprise/litellm_enterprise/proxy/ui_crud_endpoints/__init__.py new file mode 100644 index 0000000000..296d964f85 --- /dev/null +++ b/enterprise/litellm_enterprise/proxy/ui_crud_endpoints/__init__.py @@ -0,0 +1,3 @@ +from . import ui_settings_extensions # side-effect: registers extra UI settings fields + +__all__ = ["ui_settings_extensions"] diff --git a/enterprise/litellm_enterprise/proxy/ui_crud_endpoints/ui_settings_extensions.py b/enterprise/litellm_enterprise/proxy/ui_crud_endpoints/ui_settings_extensions.py new file mode 100644 index 0000000000..e61611ae59 --- /dev/null +++ b/enterprise/litellm_enterprise/proxy/ui_crud_endpoints/ui_settings_extensions.py @@ -0,0 +1,25 @@ +"""Enterprise-only UI settings fields. + +Registers additional fields onto the OSS ``UISettings`` model at import time. +Importing this module has the side effect of extending both the GET schema +and the PATCH allowlist served by ``/get/ui_settings`` and +``/update/ui_settings``. +""" + +from pydantic.fields import FieldInfo + +from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import ( + register_extra_ui_setting, +) + +register_extra_ui_setting( + "enable_projects_ui", + bool, + FieldInfo( + default=False, + description=( + "If enabled, shows the Projects feature in the UI sidebar and " + "the project field in key management." + ), + ), +) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 00c2cf9e3d..8e9baec736 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -407,9 +407,6 @@ from litellm.proxy.management_endpoints.organization_endpoints import ( router as organization_router, ) from litellm.proxy.management_endpoints.policy_endpoints import router as policy_router -from litellm.proxy.management_endpoints.project_endpoints import ( - router as project_router, -) from litellm.proxy.management_endpoints.router_settings_endpoints import ( router as router_settings_router, ) @@ -14195,7 +14192,6 @@ app.include_router(team_router) app.include_router(ui_sso_router) app.include_router(scim_router) app.include_router(organization_router) -app.include_router(project_router) app.include_router(customer_router) app.include_router(spend_management_router) app.include_router(cloudzero_router) diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index 1523e1cb9b..caf8a00f10 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -1,9 +1,11 @@ #### CRUD ENDPOINTS for UI Settings ##### import json -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Set, Tuple, Type, Union from urllib.parse import urlparse -from fastapi import APIRouter, Depends, File, HTTPException, UploadFile +from fastapi import APIRouter, Body, Depends, File, HTTPException, UploadFile +from pydantic import ConfigDict, ValidationError, create_model +from pydantic.fields import FieldInfo import litellm from litellm._logging import verbose_proxy_logger @@ -75,6 +77,8 @@ class UIThemeSettingsResponse(SettingsResponse): class UISettings(BaseModel): """Configuration for UI-specific flags""" + model_config = ConfigDict(extra="allow") + disable_model_add_for_internal_users: bool = Field( default=False, description="If true, internal users cannot add models from the UI", @@ -117,11 +121,6 @@ class UISettings(BaseModel): ), ) - enable_projects_ui: bool = Field( - default=False, - description="If enabled, shows the Projects feature in the UI sidebar and the project field in key management.", - ) - disable_agents_for_internal_users: bool = Field( default=False, description="If true, internal users cannot access agent management endpoints or the Agents page in the UI.", @@ -167,7 +166,6 @@ ALLOWED_UI_SETTINGS_FIELDS = { "require_auth_for_public_ai_hub", "forward_client_headers_to_llm_api", "forward_llm_provider_auth_headers", - "enable_projects_ui", "disable_agents_for_internal_users", "allow_agents_for_team_admins", "disable_vector_stores_for_internal_users", @@ -187,6 +185,61 @@ _RUNTIME_GENERAL_SETTINGS_FLAGS = [ "allow_vector_stores_for_team_admins", ] +# Extension point: packages outside OSS (e.g. litellm_enterprise) can +# contribute additional UI settings fields at import time. Each entry +# maps a field name to a (annotation, FieldInfo) tuple in pydantic +# create_model's field-definitions format. Registering a field also +# appends it to ALLOWED_UI_SETTINGS_FIELDS so GET/PATCH pass it through. +# +# The annotation is typed ``Any`` because pydantic field annotations +# include generics like ``Optional[int]`` / ``List[str]`` that are not +# instances of ``type`` — so tightening this to ``type`` would reject +# valid inputs. +_EXTRA_UI_SETTINGS_FIELDS: Dict[str, Tuple[Any, FieldInfo]] = {} + +# Settings OSS knows about as enterprise-gated. If a caller sends one of +# these keys and no extension package has registered it, the PATCH +# endpoint returns 403 instead of silently dropping the value, so the +# client gets a clear signal that the feature requires LiteLLM Enterprise. +_ENTERPRISE_ONLY_UI_SETTINGS: Set[str] = {"enable_projects_ui"} + +# Memoized effective class; invalidated on registration. +_EFFECTIVE_UI_SETTINGS_CLASS: Optional[Type[UISettings]] = None + + +def register_extra_ui_setting(name: str, annotation: Any, field: FieldInfo) -> None: + """Register an additional UI settings field contributed by an extension package. + + ``field`` must be a ``FieldInfo`` instance — construct it directly + (e.g. ``FieldInfo(default=..., description=...)``) rather than via + the ``pydantic.Field`` factory, whose stub reports the default's + type instead of ``FieldInfo`` and trips mypy at the call site. + """ + global _EFFECTIVE_UI_SETTINGS_CLASS + _EXTRA_UI_SETTINGS_FIELDS[name] = (annotation, field) + ALLOWED_UI_SETTINGS_FIELDS.add(name) + _EFFECTIVE_UI_SETTINGS_CLASS = None + + +def _get_effective_ui_settings_class() -> Type[UISettings]: + """Return UISettings with any extension-registered fields merged in. + + Memoized — pydantic ``create_model`` runs metaclass + schema work + each call, so we cache until a new registration invalidates it. + """ + global _EFFECTIVE_UI_SETTINGS_CLASS + if _EFFECTIVE_UI_SETTINGS_CLASS is not None: + return _EFFECTIVE_UI_SETTINGS_CLASS + if not _EXTRA_UI_SETTINGS_FIELDS: + return UISettings + _EFFECTIVE_UI_SETTINGS_CLASS = create_model( # type: ignore[call-overload] + "EffectiveUISettings", + __base__=UISettings, + __doc__=UISettings.__doc__, + **_EXTRA_UI_SETTINGS_FIELDS, + ) + return _EFFECTIVE_UI_SETTINGS_CLASS + class MCPSemanticFilterSettings(BaseModel): """Configuration for MCP Semantic Tool Filter""" @@ -1155,7 +1208,7 @@ async def get_ui_settings(): return await _get_settings_with_schema( settings_key="ui_settings", - settings_class=UISettings, + settings_class=_get_effective_ui_settings_class(), config=config, ) @@ -1166,7 +1219,8 @@ async def get_ui_settings(): dependencies=[Depends(user_api_key_auth)], ) async def update_ui_settings( - settings: UISettings, user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth) + settings_body: Dict[str, Any] = Body(...), + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ): """ Update UI-specific configuration flags. @@ -1193,9 +1247,34 @@ async def update_ui_settings( }, ) + # Validate against the same effective class GET advertises, so + # enterprise-registered fields are typed consistently on both sides. + effective_cls = _get_effective_ui_settings_class() + try: + settings = effective_cls.model_validate(settings_body) + except ValidationError as e: + raise HTTPException(status_code=422, detail=e.errors()) + # Only include fields the caller actually sent (not Pydantic defaults). settings_dict = settings.model_dump(exclude_unset=True) + # Reject enterprise-only settings up front so the caller gets a clear + # signal instead of a silent drop. + blocked_enterprise_keys = sorted( + (settings_dict.keys() & _ENTERPRISE_ONLY_UI_SETTINGS) + - ALLOWED_UI_SETTINGS_FIELDS + ) + if blocked_enterprise_keys: + raise HTTPException( + status_code=403, + detail={ + "error": ( + f"Setting(s) {blocked_enterprise_keys} are a LiteLLM " + "Enterprise feature and are not available on this build." + ) + }, + ) + # Enforce allowlist and drop anything unexpected incoming = { k: v for k, v in settings_dict.items() if k in ALLOWED_UI_SETTINGS_FIELDS diff --git a/tests/proxy_unit_tests/test_project_endpoints_prisma.py b/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py similarity index 97% rename from tests/proxy_unit_tests/test_project_endpoints_prisma.py rename to tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py index 19401fd5cc..52cb94ff34 100644 --- a/tests/proxy_unit_tests/test_project_endpoints_prisma.py +++ b/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py @@ -20,7 +20,7 @@ from litellm._logging import verbose_proxy_logger from litellm.proxy.management_endpoints.team_endpoints import ( new_team, ) -from litellm.proxy.management_endpoints.project_endpoints import ( +from litellm_enterprise.proxy.management_endpoints.project_endpoints import ( new_project, update_project, delete_project, @@ -432,7 +432,7 @@ def test_check_team_project_limits_models_not_in_team(): """ Test that creating a project with models not in the team raises an error. """ - from litellm.proxy.management_endpoints.project_endpoints import ( + from litellm_enterprise.proxy.management_endpoints.project_endpoints import ( _check_team_project_limits, ) from litellm.proxy._types import LiteLLM_TeamTable @@ -458,7 +458,7 @@ def test_check_team_project_limits_budget_exceeds_team(): """ Test that creating a project with budget > team budget raises an error. """ - from litellm.proxy.management_endpoints.project_endpoints import ( + from litellm_enterprise.proxy.management_endpoints.project_endpoints import ( _check_team_project_limits, ) from litellm.proxy._types import LiteLLM_TeamTable @@ -485,7 +485,7 @@ def test_check_team_project_limits_valid_subset(): """ Test that a valid project (models subset, budget within limit) passes. """ - from litellm.proxy.management_endpoints.project_endpoints import ( + from litellm_enterprise.proxy.management_endpoints.project_endpoints import ( _check_team_project_limits, ) from litellm.proxy._types import LiteLLM_TeamTable @@ -510,7 +510,7 @@ def test_check_team_project_limits_all_proxy_models(): """ Test that team with 'all-proxy-models' allows any project models. """ - from litellm.proxy.management_endpoints.project_endpoints import ( + from litellm_enterprise.proxy.management_endpoints.project_endpoints import ( _check_team_project_limits, ) from litellm.proxy._types import LiteLLM_TeamTable @@ -533,7 +533,7 @@ def test_check_team_project_limits_tpm_exceeds_team(): """ Test that project tpm_limit exceeding team tpm_limit raises an error. """ - from litellm.proxy.management_endpoints.project_endpoints import ( + from litellm_enterprise.proxy.management_endpoints.project_endpoints import ( _check_team_project_limits, ) from litellm.proxy._types import LiteLLM_TeamTable @@ -560,7 +560,7 @@ def test_check_team_project_limits_negative_budget(): """ Test that negative budget values raise an error. """ - from litellm.proxy.management_endpoints.project_endpoints import ( + from litellm_enterprise.proxy.management_endpoints.project_endpoints import ( _check_team_project_limits, ) from litellm.proxy._types import LiteLLM_TeamTable @@ -586,7 +586,7 @@ def test_check_team_project_limits_soft_budget_gte_max(): """ Test that soft_budget >= max_budget raises an error. """ - from litellm.proxy.management_endpoints.project_endpoints import ( + from litellm_enterprise.proxy.management_endpoints.project_endpoints import ( _check_team_project_limits, ) from litellm.proxy._types import LiteLLM_TeamTable @@ -801,7 +801,7 @@ async def test_list_projects_returns_timestamps(): from datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock, patch - from litellm.proxy.management_endpoints.project_endpoints import list_projects + from litellm_enterprise.proxy.management_endpoints.project_endpoints import list_projects from litellm.proxy._types import LiteLLM_ProjectTable now = datetime(2024, 1, 15, 12, 0, 0, tzinfo=timezone.utc) diff --git a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py index d7ce66f1d7..475b20921b 100644 --- a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py +++ b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py @@ -888,6 +888,55 @@ class TestProxySettingEndpoints: where={"id": "ui_settings"} ) + def test_get_ui_settings_schema_description_preserved_with_extensions( + self, mock_auth, monkeypatch + ): + """The UI renders ``schema.description`` as a header paragraph. + When an extension package registers extra fields, the effective + class is built via ``create_model`` — which drops the base + class docstring unless we pass ``__doc__`` explicitly.""" + from unittest.mock import AsyncMock, MagicMock + + from pydantic.fields import FieldInfo + + from litellm.proxy.ui_crud_endpoints import proxy_setting_endpoints + from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import ( + _EXTRA_UI_SETTINGS_FIELDS, + ALLOWED_UI_SETTINGS_FIELDS, + register_extra_ui_setting, + ) + + # Snapshot + restore extension registry so the test doesn't leak. + original_fields = dict(_EXTRA_UI_SETTINGS_FIELDS) + original_allowed = set(ALLOWED_UI_SETTINGS_FIELDS) + monkeypatch.setattr( + proxy_setting_endpoints, "_EFFECTIVE_UI_SETTINGS_CLASS", None + ) + + try: + register_extra_ui_setting( + "test_extension_flag", bool, FieldInfo(default=False) + ) + + mock_prisma = MagicMock() + mock_prisma.db.litellm_uisettings.find_unique = AsyncMock(return_value=None) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma) + + response = client.get("/get/ui_settings") + + assert response.status_code == 200 + data = response.json() + assert ( + data["field_schema"]["description"] + == "Configuration for UI-specific flags" + ) + finally: + _EXTRA_UI_SETTINGS_FIELDS.clear() + _EXTRA_UI_SETTINGS_FIELDS.update(original_fields) + ALLOWED_UI_SETTINGS_FIELDS.clear() + ALLOWED_UI_SETTINGS_FIELDS.update(original_allowed) + proxy_setting_endpoints._EFFECTIVE_UI_SETTINGS_CLASS = None + @pytest.mark.parametrize( "user_role", [ diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx index c1042e3cd3..51b9895e9c 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx @@ -320,22 +320,24 @@ export default function UISettings() { - - - - [BETA] Enable Projects (page will refresh) - - {enableProjectsUIProperty?.description ?? - "If enabled, shows the Projects feature in the UI sidebar and the project field in key management."} - + {enableProjectsUIProperty && ( + + + + [BETA] Enable Projects (page will refresh) + + {enableProjectsUIProperty.description ?? + "If enabled, shows the Projects feature in the UI sidebar and the project field in key management."} + + - + )}