Merge pull request #25677 from BerriAI/litellm_migration_projects

[Refactor] Proxy: move projects management to enterprise package
This commit is contained in:
yuneng-jiang 2026-04-24 17:40:33 -07:00 committed by GitHub
commit 7723a54478
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 198 additions and 40 deletions

View File

@ -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

View File

@ -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"

View File

@ -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)

View File

@ -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"]

View File

@ -0,0 +1,3 @@
from . import ui_settings_extensions # side-effect: registers extra UI settings fields
__all__ = ["ui_settings_extensions"]

View File

@ -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."
),
),
)

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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",
[

View File

@ -320,22 +320,24 @@ export default function UISettings() {
</Space>
</Space>
<Space align="start" size="middle">
<Switch
checked={Boolean(values.enable_projects_ui)}
disabled={isUpdating}
loading={isUpdating}
onChange={handleToggleEnableProjectsUI}
aria-label={enableProjectsUIProperty?.description ?? "Enable Projects UI"}
/>
<Space direction="vertical" size={4}>
<Typography.Text strong>[BETA] Enable Projects (page will refresh)</Typography.Text>
<Typography.Text type="secondary">
{enableProjectsUIProperty?.description ??
"If enabled, shows the Projects feature in the UI sidebar and the project field in key management."}
</Typography.Text>
{enableProjectsUIProperty && (
<Space align="start" size="middle">
<Switch
checked={Boolean(values.enable_projects_ui)}
disabled={isUpdating}
loading={isUpdating}
onChange={handleToggleEnableProjectsUI}
aria-label={enableProjectsUIProperty.description ?? "Enable Projects UI"}
/>
<Space direction="vertical" size={4}>
<Typography.Text strong>[BETA] Enable Projects (page will refresh)</Typography.Text>
<Typography.Text type="secondary">
{enableProjectsUIProperty.description ??
"If enabled, shows the Projects feature in the UI sidebar and the project field in key management."}
</Typography.Text>
</Space>
</Space>
</Space>
)}
<Divider />