From d3a1f63af2e3762590207b428f866a7dd491aeab Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Mon, 13 Apr 2026 21:41:12 -0700 Subject: [PATCH 1/7] [Refactor] Proxy: move projects management to enterprise package Remove the /project/* management endpoints and the enable_projects_ui admin-settings flag from the OSS litellm package. Project endpoints now live under litellm_enterprise and are wired through the existing enterprise router; OSS builds return 404 for every /project/* route. The enable_projects_ui UI flag is registered back onto UISettings via a small extension registry when the enterprise package is imported, so the admin toggle and downstream key/sidebar gating continue to work in enterprise builds. On OSS, explicit PATCH attempts with the flag return 403 with a clear enterprise-only message instead of being silently dropped. Pydantic request/response types (NewProjectRequest, UpdateProjectRequest, DeleteProjectRequest, NewProjectResponse) stay in litellm/proxy/_types.py because management_endpoints/common_utils.py and pydantic-shape tests import them. LiteLLM_ProjectTable and all FK columns in schema.prisma are unchanged. --- .../proxy/enterprise_routes.py | 3 + .../proxy/management_endpoints/__init__.py | 2 + .../management_endpoints/project_endpoints.py | 0 .../proxy/ui_crud_endpoints/__init__.py | 3 + .../ui_settings_extensions.py | 24 ++++++++ litellm/proxy/proxy_server.py | 4 -- .../proxy_setting_endpoints.py | 59 ++++++++++++++++--- .../test_project_endpoints_prisma.py | 2 +- .../AdminSettings/UISettings/UISettings.tsx | 32 +++++----- 9 files changed, 102 insertions(+), 27 deletions(-) rename {litellm => enterprise/litellm_enterprise}/proxy/management_endpoints/project_endpoints.py (100%) create mode 100644 enterprise/litellm_enterprise/proxy/ui_crud_endpoints/__init__.py create mode 100644 enterprise/litellm_enterprise/proxy/ui_crud_endpoints/ui_settings_extensions.py rename tests/{proxy_unit_tests => enterprise/litellm_enterprise/proxy/management_endpoints}/test_project_endpoints_prisma.py (99%) 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..20289cecac --- /dev/null +++ b/enterprise/litellm_enterprise/proxy/ui_crud_endpoints/ui_settings_extensions.py @@ -0,0 +1,24 @@ +"""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 import Field + +from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import ( + register_extra_ui_setting, +) + +register_extra_ui_setting( + "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." + ), + ), +) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 85a12f70f5..df003f1894 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, ) @@ -13911,7 +13908,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 0349f289b4..5cf7c215cf 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -4,6 +4,8 @@ from typing import Any, Dict, List, Optional, Union from urllib.parse import urlparse from fastapi import APIRouter, Depends, File, HTTPException, UploadFile +from pydantic import ConfigDict, 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", @@ -100,11 +104,6 @@ class UISettings(BaseModel): description="If enabled, forwards client headers (e.g. Authorization) to the LLM API. Required for Claude Code with Max subscription.", ) - 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.", @@ -149,7 +148,6 @@ ALLOWED_UI_SETTINGS_FIELDS = { "enabled_ui_pages_internal_users", "require_auth_for_public_ai_hub", "forward_client_headers_to_llm_api", - "enable_projects_ui", "disable_agents_for_internal_users", "allow_agents_for_team_admins", "disable_vector_stores_for_internal_users", @@ -168,6 +166,36 @@ _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 (type, FieldInfo) tuple suitable for pydantic's +# create_model. Registering a field also appends it to +# ALLOWED_UI_SETTINGS_FIELDS so GET/PATCH pass it through. +_EXTRA_UI_SETTINGS_FIELDS: Dict[str, tuple] = {} + +# 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"} + + +def register_extra_ui_setting(name: str, type_: Any, field: FieldInfo) -> None: + """Register an additional UI settings field contributed by an extension package.""" + _EXTRA_UI_SETTINGS_FIELDS[name] = (type_, field) + ALLOWED_UI_SETTINGS_FIELDS.add(name) + + +def _get_effective_ui_settings_class() -> type: + """Return UISettings with any extension-registered fields merged in.""" + if not _EXTRA_UI_SETTINGS_FIELDS: + return UISettings + return create_model( + "EffectiveUISettings", + __base__=UISettings, + **_EXTRA_UI_SETTINGS_FIELDS, + ) + class MCPSemanticFilterSettings(BaseModel): """Configuration for MCP Semantic Tool Filter""" @@ -1136,7 +1164,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, ) @@ -1177,6 +1205,23 @@ async def update_ui_settings( # 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 99% 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 77ed09a40f..8d72885e76 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, 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 fb7c38449b..e1d3c1609b 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx @@ -284,22 +284,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."} + + - + )} From 084dc710b50b99e93f38004bec68656028ff1282 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Mon, 13 Apr 2026 21:49:30 -0700 Subject: [PATCH 2/7] [Fix] Proxy: resolve CI fallout from projects migration - workflow proxy-config matrix: drop test_project*.py glob now that the test lives under tests/enterprise/ - update uv.lock to match bumped litellm version - fix mypy: loosen FieldInfo annotation on register_extra_ui_setting (pydantic.Field stubs report the default's type) and silence create_model overload resolution when passing **tuple_dict - fix inline imports in moved test_project_endpoints_prisma.py to target litellm_enterprise.proxy.management_endpoints.project_endpoints --- .github/workflows/test-unit-proxy-legacy.yml | 2 +- .../ui_crud_endpoints/proxy_setting_endpoints.py | 11 ++++++++--- .../test_project_endpoints_prisma.py | 16 ++++++++-------- uv.lock | 4 ++-- 4 files changed, 19 insertions(+), 14 deletions(-) diff --git a/.github/workflows/test-unit-proxy-legacy.yml b/.github/workflows/test-unit-proxy-legacy.yml index d4f5c38a61..1263bcea6a 100644 --- a/.github/workflows/test-unit-proxy-legacy.yml +++ b/.github/workflows/test-unit-proxy-legacy.yml @@ -24,7 +24,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/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index 5cf7c215cf..355161964d 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -180,8 +180,13 @@ _EXTRA_UI_SETTINGS_FIELDS: Dict[str, tuple] = {} _ENTERPRISE_ONLY_UI_SETTINGS: set[str] = {"enable_projects_ui"} -def register_extra_ui_setting(name: str, type_: Any, field: FieldInfo) -> None: - """Register an additional UI settings field contributed by an extension package.""" +def register_extra_ui_setting(name: str, type_: Any, field: Any) -> None: + """Register an additional UI settings field contributed by an extension package. + + ``field`` should be a pydantic ``Field(...)`` result (``FieldInfo`` at + runtime); typed as ``Any`` because pydantic's ``Field`` stub reports the + default value's type instead of ``FieldInfo``. + """ _EXTRA_UI_SETTINGS_FIELDS[name] = (type_, field) ALLOWED_UI_SETTINGS_FIELDS.add(name) @@ -190,7 +195,7 @@ def _get_effective_ui_settings_class() -> type: """Return UISettings with any extension-registered fields merged in.""" if not _EXTRA_UI_SETTINGS_FIELDS: return UISettings - return create_model( + return create_model( # type: ignore[call-overload] "EffectiveUISettings", __base__=UISettings, **_EXTRA_UI_SETTINGS_FIELDS, diff --git a/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py b/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py index 8d72885e76..6678acca87 100644 --- a/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py +++ b/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py @@ -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/uv.lock b/uv.lock index 04224dc537..ca4f0f766d 100644 --- a/uv.lock +++ b/uv.lock @@ -11,7 +11,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-04-08T16:01:27.663665Z" +exclude-newer = "2026-04-11T04:48:04.282864Z" exclude-newer-span = "P3D" [manifest] @@ -3602,7 +3602,7 @@ wheels = [ [[package]] name = "litellm" -version = "1.83.6" +version = "1.83.7" source = { editable = "." } dependencies = [ { name = "aiohttp" }, From 937d81331f776fa68a6a072f518831e4d28db5af Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Mon, 13 Apr 2026 21:58:02 -0700 Subject: [PATCH 3/7] [Refactor] Proxy: tighten UI settings extras registry - drop unused Any annotation on register_extra_ui_setting's field param; type it as FieldInfo and have enterprise callers construct FieldInfo directly (pydantic.Field's stub reports the default's type, which doesn't match FieldInfo) - cache the effective UISettings class and invalidate it inside register_extra_ui_setting so GET /get/ui_settings does not rebuild a pydantic model on every request - annotate _EXTRA_UI_SETTINGS_FIELDS with a concrete Dict[str, Tuple[Any, FieldInfo]] instead of bare Dict[str, tuple]; the annotation remains Any because pydantic field annotations include generics (Optional[X], List[X]) that are not instances of type --- .../ui_settings_extensions.py | 5 ++- .../proxy_setting_endpoints.py | 45 +++++++++++++------ 2 files changed, 35 insertions(+), 15 deletions(-) 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 index 20289cecac..e61611ae59 100644 --- a/enterprise/litellm_enterprise/proxy/ui_crud_endpoints/ui_settings_extensions.py +++ b/enterprise/litellm_enterprise/proxy/ui_crud_endpoints/ui_settings_extensions.py @@ -5,7 +5,8 @@ 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 import Field + +from pydantic.fields import FieldInfo from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import ( register_extra_ui_setting, @@ -14,7 +15,7 @@ from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import ( register_extra_ui_setting( "enable_projects_ui", bool, - Field( + FieldInfo( default=False, description=( "If enabled, shows the Projects feature in the UI sidebar and " diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index 355161964d..c4d89ea7a4 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -1,6 +1,6 @@ #### CRUD ENDPOINTS for UI Settings ##### import json -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Set, Tuple, Union from urllib.parse import urlparse from fastapi import APIRouter, Depends, File, HTTPException, UploadFile @@ -168,38 +168,57 @@ _RUNTIME_GENERAL_SETTINGS_FLAGS = [ # 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 (type, FieldInfo) tuple suitable for pydantic's -# create_model. Registering a field also appends it to -# ALLOWED_UI_SETTINGS_FIELDS so GET/PATCH pass it through. -_EXTRA_UI_SETTINGS_FIELDS: Dict[str, tuple] = {} +# 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"} +_ENTERPRISE_ONLY_UI_SETTINGS: Set[str] = {"enable_projects_ui"} + +# Memoized effective class; invalidated on registration. +_EFFECTIVE_UI_SETTINGS_CLASS: Optional[type] = None -def register_extra_ui_setting(name: str, type_: Any, field: Any) -> 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`` should be a pydantic ``Field(...)`` result (``FieldInfo`` at - runtime); typed as ``Any`` because pydantic's ``Field`` stub reports the - default value's type instead of ``FieldInfo``. + ``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. """ - _EXTRA_UI_SETTINGS_FIELDS[name] = (type_, field) + 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: - """Return UISettings with any extension-registered fields merged in.""" + """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 - return create_model( # type: ignore[call-overload] + _EFFECTIVE_UI_SETTINGS_CLASS = create_model( # type: ignore[call-overload] "EffectiveUISettings", __base__=UISettings, **_EXTRA_UI_SETTINGS_FIELDS, ) + return _EFFECTIVE_UI_SETTINGS_CLASS class MCPSemanticFilterSettings(BaseModel): From d747e4c2481086c4f8caac01fe0d6a0b5a4e6e51 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 24 Apr 2026 12:52:51 -0700 Subject: [PATCH 4/7] Remove stale test_project_endpoints_prisma.py path from proxy-db workflow The file was moved to tests/enterprise/litellm_enterprise/proxy/management_endpoints/ and is covered by the CircleCI litellm_mapped_enterprise_tests job. The stale path was causing pytest to error with 'file or directory not found'. --- .github/workflows/test-unit-proxy-db.yml | 1 - 1 file changed, 1 deletion(-) 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 From 32d4ae79f60226ef8ef7cb14f37f5fe17918062d Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 24 Apr 2026 13:09:16 -0700 Subject: [PATCH 5/7] Validate UI settings PATCH body against effective class GET /get/ui_settings returns a schema built from the effective UISettings class (base + enterprise-registered fields), but PATCH /update/ui_settings declared its body as the base UISettings. Enterprise fields still worked via extra="allow", but the OpenAPI schema was asymmetric between GET and PATCH. Accept the body as a dict and validate with the effective class so both sides are in sync and enterprise-registered fields are type-checked. --- .../ui_crud_endpoints/proxy_setting_endpoints.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index 7b71d468e9..cc284da024 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -3,8 +3,8 @@ import json from typing import Any, Dict, List, Optional, Set, Tuple, Union from urllib.parse import urlparse -from fastapi import APIRouter, Depends, File, HTTPException, UploadFile -from pydantic import ConfigDict, create_model +from fastapi import APIRouter, Body, Depends, File, HTTPException, UploadFile +from pydantic import ConfigDict, ValidationError, create_model from pydantic.fields import FieldInfo import litellm @@ -1218,7 +1218,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. @@ -1245,6 +1246,14 @@ 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) From 85b6cae508bf2d2d7b410247e9ee33791e058969 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 24 Apr 2026 13:19:02 -0700 Subject: [PATCH 6/7] Type _get_effective_ui_settings_class as Type[UISettings] for mypy --- litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index cc284da024..8670face60 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -1,6 +1,6 @@ #### CRUD ENDPOINTS for UI Settings ##### import json -from typing import Any, Dict, List, Optional, Set, Tuple, Union +from typing import Any, Dict, List, Optional, Set, Tuple, Type, Union from urllib.parse import urlparse from fastapi import APIRouter, Body, Depends, File, HTTPException, UploadFile @@ -204,7 +204,7 @@ _EXTRA_UI_SETTINGS_FIELDS: Dict[str, Tuple[Any, FieldInfo]] = {} _ENTERPRISE_ONLY_UI_SETTINGS: Set[str] = {"enable_projects_ui"} # Memoized effective class; invalidated on registration. -_EFFECTIVE_UI_SETTINGS_CLASS: Optional[type] = None +_EFFECTIVE_UI_SETTINGS_CLASS: Optional[Type[UISettings]] = None def register_extra_ui_setting(name: str, annotation: Any, field: FieldInfo) -> None: @@ -221,7 +221,7 @@ def register_extra_ui_setting(name: str, annotation: Any, field: FieldInfo) -> N _EFFECTIVE_UI_SETTINGS_CLASS = None -def _get_effective_ui_settings_class() -> type: +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 From b300dc3e54f78bef397c767a6841bb62d57b60ba Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 24 Apr 2026 13:56:59 -0700 Subject: [PATCH 7/7] Preserve UISettings docstring on effective class for GET schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit create_model does not inherit the base class docstring, so once an extension registered a field the effective class had no description. The UI renders schema.description as a header paragraph — losing it broke the 'Configuration for UI-specific flags' text. Pass __doc__ through explicitly and add a regression test. --- .../proxy_setting_endpoints.py | 1 + .../test_proxy_setting_endpoints.py | 49 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index 8670face60..caf8a00f10 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -235,6 +235,7 @@ def _get_effective_ui_settings_class() -> Type[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 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", [