Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py: 74%
618 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
1#### CRUD ENDPOINTS for UI Settings #####
2import asyncio
3import json
4import os
5from collections import Counter
6from collections.abc import Mapping, MutableMapping, Sequence
7from types import MappingProxyType
8from typing import (
9 Annotated,
10 Final,
11 NamedTuple,
12 Protocol,
13 cast, # noqa: TID251 # prisma types Json columns as fields.Json but de-serializes them to plain python on read
14)
15from urllib.parse import urlparse
17from fastapi import APIRouter, Body, Depends, File, HTTPException, UploadFile
18from pydantic import BaseModel, ConfigDict, JsonValue, TypeAdapter, ValidationError, create_model
19from pydantic.fields import FieldInfo, PydanticUndefined
20from typing_extensions import NotRequired, ReadOnly, TypedDict
22import litellm
23from litellm._logging import verbose_proxy_logger
24from litellm.litellm_core_utils.sensitive_data_masker import mask_sensitive_keys
25from litellm.proxy._experimental.mcp_server.tool_search import MCP_TOOL_SEARCH_SETTINGS_KEY
26from litellm.proxy._types import *
27from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
28from litellm.proxy.config_resolvers import FieldSource, SettingsStore, source_for
29from litellm.proxy.config_resolvers.settings_store import ConfigOwnedKeyError
30from litellm.proxy.config_resolvers.sso import (
31 SSO_FIELD_ENV_VARS,
32 SSO_SECRET_FIELDS,
33 resolve_sso_config,
34)
35from litellm.proxy.management_endpoints.team_admin_field_permissions import (
36 SUPPORTED_TEAM_ADMIN_PERMISSIONS,
37 TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING,
38)
39from litellm.proxy.spend_tracking.ptu_feature_flag import (
40 PTU_COST_ATTRIBUTION_ENV_VAR,
41 is_ptu_cost_attribution_enabled,
42)
43from litellm.proxy.utils import invalidate_config_param
44from litellm.repositories.config_repository import ConfigRepository
45from litellm.repositories.organization_repository import OrganizationRepository
46from litellm.repositories.prisma_protocols import TableActions
47from litellm.repositories.table_repositories import (
48 SSOConfigRepository,
49 UISettingsRepository,
50)
51from litellm.repositories.team_repository import TeamRepository
52from litellm.secret_managers.main import get_secret
53from litellm.types.mcp import MCPToolSearchSettings
54from litellm.types.proxy.management_endpoints.ui_sso import (
55 DefaultTeamSSOParams,
56 SSOConfig,
57)
59router: Final = APIRouter()
62JsonSchemaItems: Final = TypedDict(
63 "JsonSchemaItems",
64 {"$ref": ReadOnly[str], "enum": ReadOnly[Sequence[JsonValue]]},
65 total=False,
66)
69class JsonSchemaNode(TypedDict, total=False):
70 type: ReadOnly[str]
71 description: ReadOnly[str]
72 enum: ReadOnly[Sequence[JsonValue]]
73 anyOf: ReadOnly[Sequence["JsonSchemaNode"]]
74 items: ReadOnly["JsonSchemaItems"]
75 properties: ReadOnly[Mapping[str, "JsonSchemaNode"]]
78_EMPTY_SCHEMA_DEFS: Final[Mapping[str, "JsonSchemaNode"]] = MappingProxyType({})
81class JsonSchemaPropertyEntry(TypedDict):
82 description: ReadOnly[str]
83 type: ReadOnly[str]
84 items: NotRequired[ReadOnly["JsonSchemaItems"]]
87class _SsoSettingsMappingRow(Protocol):
88 @property
89 def sso_settings(self) -> Mapping[str, object] | None: ... 89 ↛ exitline 89 didn't return from function 'sso_settings' because
92def _sso_settings_mapping_db(repo: SSOConfigRepository) -> TableActions[_SsoSettingsMappingRow]:
93 return cast( # cast-ok: prisma types Json columns as str; the client hands back the deserialized value
94 "TableActions[_SsoSettingsMappingRow]", repo.table
95 )
98class _StoredSsoSettingsRow(Protocol):
99 @property
100 def sso_settings(self) -> object: ... 100 ↛ exitline 100 didn't return from function 'sso_settings' because
103def _stored_sso_settings_db(repo: SSOConfigRepository) -> TableActions[_StoredSsoSettingsRow]:
104 return repo.table
107class _UiSettingsRow(Protocol):
108 @property
109 def ui_settings(self) -> str | Mapping[str, JsonValue] | None: ... 109 ↛ exitline 109 didn't return from function 'ui_settings' because
112def _ui_settings_db(repo: UISettingsRepository) -> TableActions[_UiSettingsRow]:
113 return cast( # cast-ok: prisma types Json columns as str; the client hands back the deserialized value
114 "TableActions[_UiSettingsRow]", repo.table
115 )
118class _ConfigParamRow(Protocol):
119 @property
120 def param_value(self) -> str | Mapping[str, object] | None: ... 120 ↛ exitline 120 didn't return from function 'param_value' because
123def _config_param_db(repo: ConfigRepository) -> TableActions[_ConfigParamRow]:
124 return cast( # cast-ok: prisma's LiteLLM_Config actions object, whose Json column parses to a mapping
125 "TableActions[_ConfigParamRow]", repo.table
126 )
129# Maps each UIThemeConfig field to the env var the UI branding path reads it
130# from. /update/ui_theme_settings writes both the stored ui_theme_config and
131# these env vars, so /get/ui_theme_settings resolves the same env vars to
132# reflect a deployment branded purely through process env.
133_UI_THEME_FIELD_ENV_VARS: Final[dict[str, str]] = {
134 "logo_url": "UI_LOGO_PATH",
135 "logo_url_dark": "UI_LOGO_PATH_DARK",
136 "favicon_url": "LITELLM_FAVICON_URL",
137}
140def _is_public_http_url(value: str | None) -> bool:
141 """Whether a value is a plain http(s) URL with a host, safe to disclose publicly."""
142 if not isinstance(value, str) or not value.strip(): 142 ↛ 144line 142 didn't jump to line 144 because the condition on line 142 was always true
143 return False
144 parsed: Final = urlparse(value.strip())
145 return parsed.scheme in ("http", "https") and bool(parsed.netloc)
148def _resolve_ui_theme_field(stored_values: Mapping[str, object], field_name: str) -> str | None:
149 """Resolve one UI theme field to the value the branding path actually uses.
151 The stored ui_theme_config wins; a field absent or blank there falls back to
152 the process environment. The branding path reads the env var, and stored
153 settings reach it by being pushed into the environment on save, so a value
154 supplied only as a process env var is live even though no stored entry exists.
156 This endpoint is unauthenticated, so the env fallback only surfaces a public
157 http(s) URL: an operator can point UI_LOGO_PATH at a local filesystem path
158 (the branding path serves it server-side), and that path must not be
159 disclosed to anonymous callers. A stored value is already validated as a
160 public URL on write, so it passes through.
161 """
162 stored: Final = stored_values.get(field_name)
163 if isinstance(stored, str) and stored.strip(): 163 ↛ 164line 163 didn't jump to line 164 because the condition on line 163 was never true
164 return stored
165 env_value: Final = os.environ.get(_UI_THEME_FIELD_ENV_VARS[field_name])
166 return env_value if _is_public_http_url(env_value) else None
169class IPAddress(BaseModel):
170 ip: str
173class UIThemeConfig(BaseModel):
174 """Configuration for UI theme customization"""
176 # Logo configuration
177 logo_url: str | None = Field(
178 default=None,
179 description="URL or path to custom logo image. Can be a local file path or HTTP/HTTPS URL",
180 )
182 logo_url_dark: str | None = Field(
183 default=None,
184 description=(
185 "URL or path to a custom logo image for dark mode. Can be a local file path or HTTP/HTTPS URL. "
186 "Leave unset to reuse logo_url in dark mode"
187 ),
188 )
190 # Favicon configuration
191 favicon_url: str | None = Field(
192 default=None,
193 description="URL to custom favicon image. Must be an HTTP/HTTPS URL to a .ico, .png, or .svg file",
194 )
197class SettingsResponse(BaseModel):
198 """Base response model for settings with values and schema information"""
200 values: dict[str, object]
201 """The current configuration values"""
203 field_schema: dict[str, object]
204 """Schema information including descriptions and property types for UI display"""
207class _SettingsWithSchema(BaseModel):
208 values: dict[str, object]
209 field_schema: dict[str, object]
212class SSOSettingsResponse(SettingsResponse):
213 """Response model for SSO settings"""
215 provenance: dict[str, str] = Field(default_factory=dict)
216 """Per-field source of each value: 'db', 'env', 'default', or 'unset'."""
219class InternalUserSettingsResponse(SettingsResponse):
220 """Response model for internal user settings"""
223class DefaultTeamSettingsResponse(SettingsResponse):
224 """Response model for default team settings"""
227class UIThemeSettingsResponse(SettingsResponse):
228 """Response model for UI theme settings"""
231_TEAM_ADMIN_FIELD_ENUM: Final = tuple(sorted(SUPPORTED_TEAM_ADMIN_PERMISSIONS))
234class UISettings(BaseModel):
235 """Configuration for UI-specific flags"""
237 model_config = ConfigDict(extra="allow")
239 disable_model_add_for_internal_users: bool = Field(
240 default=False,
241 description="If true, internal users cannot add models from the UI",
242 )
244 disable_team_admin_delete_team_user: bool = Field(
245 default=False,
246 description="Prevents Team Admins from deleting users from the teams they manage. Useful for SCIM provisioning where team membership is defined externally.",
247 )
249 enabled_ui_pages_internal_users: list[str] | None = Field(
250 default=None,
251 description="List of page keys that internal users (non-admins) can see in the UI sidebar. If not set, all pages are visible based on role permissions.",
252 )
254 require_auth_for_public_ai_hub: bool = Field(
255 default=False,
256 description="If true, requires authentication for accessing the public AI Hub.",
257 )
259 allow_public_health_readiness_details: bool = Field(
260 default=False,
261 description="If true, returns the legacy detailed payload from the unauthenticated /health/readiness endpoint.",
262 )
264 forward_client_headers_to_llm_api: bool = Field(
265 default=False,
266 description=(
267 "Forwards client headers (Authorization, anthropic-beta, and x-* "
268 "custom headers) to the upstream LLM. Enable for Claude Code with a "
269 "Max subscription (forwards the OAuth token) or to pass custom/tracing "
270 "headers through to the provider. Independent of the BYOK toggle — "
271 "enable only the one(s) you need."
272 ),
273 )
275 forward_llm_provider_auth_headers: bool = Field(
276 default=False,
277 description=(
278 "Forwards provider auth headers (x-api-key, x-goog-api-key, api-key, "
279 "ocp-apim-subscription-key) to the upstream LLM, overriding any "
280 "deployment-configured key for that request. Enable for Claude Code "
281 "BYOK (clients bring their own API key). Independent of the "
282 "client-headers toggle — enable only the one(s) you need."
283 ),
284 )
286 disable_agents_for_internal_users: bool = Field(
287 default=False,
288 description="If true, internal users cannot access agent management endpoints or the Agents page in the UI.",
289 )
291 allow_agents_for_team_admins: bool = Field(
292 default=False,
293 description="If true, team admins are exempt from the agents disable restriction (only takes effect when disable_agents_for_internal_users is true).",
294 )
296 disable_vector_stores_for_internal_users: bool = Field(
297 default=False,
298 description="If true, internal users cannot access vector store management endpoints or the Vector Stores page in the UI.",
299 )
301 allow_vector_stores_for_team_admins: bool = Field(
302 default=False,
303 description="If true, team admins are exempt from the vector stores disable restriction (only takes effect when disable_vector_stores_for_internal_users is true).",
304 )
306 scope_user_search_to_org: bool = Field(
307 default=False,
308 description="If enabled, the user search endpoint (/user/filter/ui) restricts results by organization. When off, any authenticated user can search all users.",
309 )
311 disable_custom_api_keys: bool = Field(
312 default=False,
313 description="If true, users cannot specify custom key values. All keys must be auto-generated.",
314 )
316 disable_key_generate_for_org_admin: bool = Field(
317 default=False,
318 description="If true, org admins cannot generate API keys via /key/generate.",
319 )
321 enable_chat_ui: bool = Field(
322 default=False,
323 description="If true, shows the Chat page in the UI sidebar, letting users chat with an LLM and connect their own MCP server credentials via OAuth.",
324 )
326 team_admin_editable_team_fields: Sequence[str] = Field(
327 default=(),
328 description=(
329 "Team settings fields a team admin may change on the teams they administer. "
330 "Include 'projects' to let team admins create and update projects for those teams. "
331 "Include 'member_key_budgets' to let team admins update budget fields on keys owned by other members of those teams. "
332 "Empty means team admins cannot edit team settings or manage projects at all. "
333 "Proxy admins and org admins are not affected."
334 ),
335 json_schema_extra={ # mutable-ok: pydantic only merges json_schema_extra when it is a plain dict
336 "items": {"type": "string", "enum": [*_TEAM_ADMIN_FIELD_ENUM]}, # mutable-ok: nested in the dict above
337 },
338 )
341class UISettingsResponse(SettingsResponse):
342 """Response model for UI settings"""
344 source: dict[str, FieldSource]
347# Allowlist of UI settings that can be stored
348ALLOWED_UI_SETTINGS_FIELDS: Final = {
349 "disable_model_add_for_internal_users",
350 "disable_team_admin_delete_team_user",
351 "enabled_ui_pages_internal_users",
352 "require_auth_for_public_ai_hub",
353 "allow_public_health_readiness_details",
354 "forward_client_headers_to_llm_api",
355 "forward_llm_provider_auth_headers",
356 "disable_agents_for_internal_users",
357 "allow_agents_for_team_admins",
358 "disable_vector_stores_for_internal_users",
359 "allow_vector_stores_for_team_admins",
360 "scope_user_search_to_org",
361 "disable_custom_api_keys",
362 "disable_key_generate_for_org_admin",
363 "enable_chat_ui",
364 TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING,
365}
367ENABLE_PTU_COST_ATTRIBUTION_UI_SETTING: Final = "enable_ptu_cost_attribution"
368APPLY_USER_BUDGET_TO_TEAM_KEYS_UI_SETTING: Final = "apply_user_budget_to_team_keys"
370# UI settings derived from the deployment environment. Deliberately kept out of
371# ALLOWED_UI_SETTINGS_FIELDS: they are read-only, never persisted, and PATCH
372# rejects them so an admin cannot flip an env-gated feature at runtime.
373_DERIVED_UI_SETTINGS_FIELDS: Final[frozenset[str]] = frozenset(
374 {ENABLE_PTU_COST_ATTRIBUTION_UI_SETTING, APPLY_USER_BUDGET_TO_TEAM_KEYS_UI_SETTING}
375)
378def _apply_user_budget_to_team_keys_enabled(settings: Mapping[str, object]) -> bool:
379 return settings.get(APPLY_USER_BUDGET_TO_TEAM_KEYS_UI_SETTING) is True
382def _derived_ui_setting_value(key: str) -> object:
383 """The environment-derived value GET reports for ``key``.
385 PATCH compares against this rather than rejecting the key outright, so the body GET
386 hands back is still a valid PATCH body. Rejecting on presence broke read-modify-write:
387 a client that edited one setting and sent the rest back unchanged got a 400 and lost
388 the edit it actually wanted.
389 """
390 if key == ENABLE_PTU_COST_ATTRIBUTION_UI_SETTING: 390 ↛ 391line 390 didn't jump to line 391 because the condition on line 390 was never true
391 return is_ptu_cost_attribution_enabled()
392 if key == APPLY_USER_BUDGET_TO_TEAM_KEYS_UI_SETTING: 392 ↛ 398line 392 didn't jump to line 398 because the condition on line 392 was always true
393 from litellm.proxy.proxy_server import general_settings
395 return _apply_user_budget_to_team_keys_enabled(
396 cast(Mapping[str, object], general_settings) # cast-ok: proxy_server declares general_settings as bare dict
397 )
398 return None
401# Flags that must be synced from the persisted UISettings into
402# general_settings at runtime (on both read and write).
403_RUNTIME_GENERAL_SETTINGS_FLAGS: Final = [
404 "allow_public_health_readiness_details",
405 "forward_client_headers_to_llm_api",
406 "forward_llm_provider_auth_headers",
407 "disable_agents_for_internal_users",
408 "allow_agents_for_team_admins",
409 "disable_vector_stores_for_internal_users",
410 "allow_vector_stores_for_team_admins",
411 "disable_custom_api_keys",
412 "disable_key_generate_for_org_admin",
413 TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING,
414]
416# Extension point: packages outside OSS (e.g. litellm_enterprise) can
417# contribute additional UI settings fields at import time. Each entry
418# maps a field name to a (annotation, FieldInfo) tuple in pydantic
419# create_model's field-definitions format. Registering a field also
420# appends it to ALLOWED_UI_SETTINGS_FIELDS so GET/PATCH pass it through.
421#
422# The annotation is typed ``Any`` because pydantic field annotations
423# include generics like ``Optional[int]`` / ``List[str]`` that are not
424# instances of ``type`` — so tightening this to ``type`` would reject
425# valid inputs.
426_EXTRA_UI_SETTINGS_FIELDS: Final[dict[str, tuple[object, FieldInfo]]] = {}
428# Settings OSS knows about as enterprise-gated. If a caller sends one of
429# these keys and no extension package has registered it, the PATCH
430# endpoint returns 403 instead of silently dropping the value, so the
431# client gets a clear signal that the feature requires LiteLLM Enterprise.
432_ENTERPRISE_ONLY_UI_SETTINGS: Final[set[str]] = {"enable_projects_ui"}
434# Memoized effective class; invalidated on registration.
435_EFFECTIVE_UI_SETTINGS_CLASS: type[UISettings] | None = None
438def register_extra_ui_setting(name: str, annotation: object, field: FieldInfo) -> None:
439 """Register an additional UI settings field contributed by an extension package.
441 ``field`` must be a ``FieldInfo`` instance — construct it directly
442 (e.g. ``FieldInfo(default=..., description=...)``) rather than via
443 the ``pydantic.Field`` factory, whose stub reports the default's
444 type instead of ``FieldInfo`` and trips mypy at the call site.
445 """
446 global _EFFECTIVE_UI_SETTINGS_CLASS
447 _EXTRA_UI_SETTINGS_FIELDS[name] = (annotation, field)
448 ALLOWED_UI_SETTINGS_FIELDS.add(name)
449 _EFFECTIVE_UI_SETTINGS_CLASS = None
452def _get_effective_ui_settings_class() -> type[UISettings]:
453 """Return UISettings with any extension-registered fields merged in.
455 Memoized — pydantic ``create_model`` runs metaclass + schema work
456 each call, so we cache until a new registration invalidates it.
457 """
458 global _EFFECTIVE_UI_SETTINGS_CLASS
459 if _EFFECTIVE_UI_SETTINGS_CLASS is not None:
460 return _EFFECTIVE_UI_SETTINGS_CLASS
461 if not _EXTRA_UI_SETTINGS_FIELDS: 461 ↛ 462line 461 didn't jump to line 462 because the condition on line 461 was never true
462 return UISettings
463 _EFFECTIVE_UI_SETTINGS_CLASS = create_model(
464 "EffectiveUISettings",
465 __base__=UISettings,
466 __doc__=UISettings.__doc__,
467 **_EXTRA_UI_SETTINGS_FIELDS,
468 )
469 return _EFFECTIVE_UI_SETTINGS_CLASS
472class MCPSemanticFilterSettings(BaseModel):
473 """Configuration for MCP Semantic Tool Filter"""
475 enabled: bool = Field(
476 default=False,
477 description="Enable semantic filtering of MCP tools based on query relevance",
478 )
480 embedding_model: str = Field(
481 default="text-embedding-3-small",
482 description="Embedding model to use for semantic similarity (e.g., 'text-embedding-3-small', 'text-embedding-ada-002')",
483 )
485 top_k: int = Field(
486 default=10,
487 description="Number of most relevant tools to return",
488 ge=1,
489 le=100,
490 )
492 similarity_threshold: float = Field(
493 default=0.3,
494 description="Minimum similarity score for tool inclusion (0.0 to 1.0, where 1.0 = exact match)",
495 ge=0.0,
496 le=1.0,
497 )
500class MCPSemanticFilterSettingsResponse(SettingsResponse):
501 """Response model for MCP semantic filter settings"""
504class MCPToolSearchSettingsResponse(SettingsResponse):
505 """Response model for native MCP tool search settings"""
508class WebSearchInterceptionSettings(BaseModel):
509 """Configuration for server-side web search interception"""
511 enabled: bool = Field(
512 default=False,
513 description="Serve web search tool calls from a configured search tool instead of passing them upstream",
514 )
516 enabled_providers: list[str] = Field(
517 default_factory=list,
518 description="LLM providers to intercept for (e.g. 'bedrock', 'vertex_ai'). Empty intercepts Bedrock only.",
519 )
521 search_tool_name: str | None = Field(
522 default=None,
523 description="Name of the configured search tool to run searches through. Empty uses the first one available.",
524 )
526 max_agentic_loops: int | None = Field(
527 default=None,
528 ge=1,
529 description="How many follow-up model calls one intercepted request may chain. Empty applies the default of 3.",
530 )
533class WebSearchInterceptionSettingsResponse(SettingsResponse):
534 """Response model for web search interception settings"""
536 active_on_this_pod: bool = Field(
537 default=False,
538 description=(
539 "Whether the process answering this request has the interception callback "
540 "registered. Read-only: it reports what is running here, while values.enabled "
541 "is the cluster-wide setting, and the two disagree while a pod is still "
542 "applying a change or failed to apply it."
543 ),
544 )
547def _with_websearch_enabled_resolved(config: Mapping[str, object]) -> dict[str, object]:
548 """
549 Answer with the stored flag when there is one, and only otherwise with what
550 this process is running.
552 A stored flag is the cluster's own answer, so it is the same on every pod and
553 is safe for the page to send back on save. Deriving the answer from this
554 process instead would report off on a pod that has not polled yet, and the
555 next save would persist that as a cluster-wide off. Without a stored flag the
556 only available answer is local: litellm_settings.callbacks activates
557 interception without storing one, and a write through the generic config
558 endpoint can drop the flag from a block that is still live. Reporting the
559 field default there would claim the feature is off while it serves.
560 """
561 from litellm.integrations.websearch_interception.handler import (
562 WebSearchInterceptionLogger,
563 )
565 litellm_settings: Final[Mapping[str, object]] = _as_settings_section(config.get("litellm_settings"))
566 stored: Final[Mapping[str, object]] = _as_settings_section(litellm_settings.get("websearch_interception_params"))
567 if "enabled" in stored:
568 return dict(config)
570 resolved: Final = {
571 **stored,
572 "enabled": bool(litellm.logging_callback_manager.get_custom_loggers_for_type(WebSearchInterceptionLogger)),
573 }
574 return {
575 **config,
576 "litellm_settings": {**litellm_settings, "websearch_interception_params": resolved},
577 }
580def _as_settings_section(value: object) -> Mapping[str, object]:
581 return cast("Mapping[str, object]", value) if isinstance(value, Mapping) else MappingProxyType({})
584@router.get(
585 "/get/allowed_ips",
586 tags=["Budget & Spend Tracking"],
587 dependencies=[Depends(user_api_key_auth)],
588 include_in_schema=False,
589)
590async def get_allowed_ips():
591 from litellm.proxy.proxy_server import general_settings
593 _allowed_ip: Final = general_settings.get("allowed_ips")
594 return {"data": _allowed_ip}
597def _store_allowed_ips(general_settings: MutableMapping[str, object], allowed_ips: Sequence[str]) -> None:
598 try:
599 general_settings["allowed_ips"] = list(allowed_ips) # mutable-ok: compared against the file's own list
600 except ConfigOwnedKeyError as owned:
601 raise HTTPException(
602 status_code=400,
603 detail={ # mutable-ok: HTTPException serializes its detail as json
604 "error": str(owned),
605 "keys": (owned.key,),
606 "section": owned.section,
607 "stored_database_value_ignored": owned.shadows_db_value,
608 },
609 ) from owned
612@router.post(
613 "/add/allowed_ip",
614 tags=["Budget & Spend Tracking"],
615 dependencies=[Depends(user_api_key_auth)],
616)
617async def add_allowed_ip(
618 ip_address: IPAddress,
619 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
620):
621 from litellm.proxy.proxy_server import (
622 create_config_audit_log,
623 general_settings,
624 prisma_client,
625 proxy_config,
626 store_model_in_db,
627 )
629 if prisma_client is None:
630 raise Exception("No DB Connected")
632 _allowed_ips: Final[Sequence[str]] = general_settings.get("allowed_ips") or ()
633 if ip_address.ip in _allowed_ips:
634 raise HTTPException(status_code=400, detail="IP address already exists")
635 _store_allowed_ips(general_settings, (*_allowed_ips, ip_address.ip))
637 if store_model_in_db is not True:
638 raise HTTPException(
639 status_code=500,
640 detail={"error": "Set `'STORE_MODEL_IN_DB='True'` in your env to enable this feature."},
641 )
643 # Load existing config
644 config: Final = await proxy_config.get_config()
645 verbose_proxy_logger.debug("Loaded config: %s", config)
646 if "general_settings" not in config:
647 config["general_settings"] = {}
649 if "allowed_ips" not in config["general_settings"]:
650 config["general_settings"]["allowed_ips"] = []
652 before_allowed_ips: Final = list(config["general_settings"]["allowed_ips"])
653 if ip_address.ip not in config["general_settings"]["allowed_ips"]:
654 config["general_settings"]["allowed_ips"].append(ip_address.ip)
656 await proxy_config.save_config(new_config=config)
658 asyncio.create_task(
659 create_config_audit_log(
660 param_name="general_settings",
661 action="updated",
662 before_value={"allowed_ips": before_allowed_ips},
663 after_value={"allowed_ips": config["general_settings"]["allowed_ips"]},
664 user_api_key_dict=user_api_key_dict,
665 )
666 )
668 return {
669 "message": f"IP {ip_address.ip} address added successfully",
670 "status": "success",
671 }
674@router.post(
675 "/delete/allowed_ip",
676 tags=["Budget & Spend Tracking"],
677 dependencies=[Depends(user_api_key_auth)],
678)
679async def delete_allowed_ip(
680 ip_address: IPAddress,
681 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
682):
683 from litellm.proxy.proxy_server import (
684 create_config_audit_log,
685 general_settings,
686 proxy_config,
687 )
689 _allowed_ips: Final[Sequence[str]] = general_settings.get("allowed_ips") or ()
690 if ip_address.ip not in _allowed_ips: 690 ↛ 692line 690 didn't jump to line 692 because the condition on line 690 was always true
691 raise HTTPException(status_code=404, detail="IP address not found")
692 _store_allowed_ips(general_settings, tuple(ip for ip in _allowed_ips if ip != ip_address.ip))
694 # Load existing config
695 config: Final = await proxy_config.get_config()
696 verbose_proxy_logger.debug("Loaded config: %s", config)
697 if "general_settings" not in config:
698 config["general_settings"] = {}
700 if "allowed_ips" not in config["general_settings"]:
701 config["general_settings"]["allowed_ips"] = []
703 before_allowed_ips: Final = list(config["general_settings"]["allowed_ips"])
704 if ip_address.ip in config["general_settings"]["allowed_ips"]:
705 config["general_settings"]["allowed_ips"].remove(ip_address.ip)
707 await proxy_config.save_config(new_config=config)
709 asyncio.create_task(
710 create_config_audit_log(
711 param_name="general_settings",
712 action="deleted",
713 before_value={"allowed_ips": before_allowed_ips},
714 after_value={"allowed_ips": config["general_settings"]["allowed_ips"]},
715 user_api_key_dict=user_api_key_dict,
716 )
717 )
719 return {"message": f"IP {ip_address.ip} deleted successfully", "status": "success"}
722def _resolve_non_null_variant(field_info: JsonSchemaNode) -> JsonSchemaNode:
723 """Pydantic v2 renders Optional fields as ``anyOf: [actual_type, null]``."""
724 if "anyOf" not in field_info:
725 return field_info
726 return next((variant for variant in field_info["anyOf"] if variant.get("type") != "null"), field_info)
729def _schema_items_entry(resolved: JsonSchemaNode, defs: Mapping[str, JsonSchemaNode]) -> "JsonSchemaItems | None":
730 """Items info (including enum values) for array fields, so the UI can render a multi-select dropdown."""
731 if "items" not in resolved:
732 return None
733 items: Final = resolved["items"]
734 if "$ref" not in items:
735 return items
736 ref_def: Final = defs.get(items["$ref"].split("/")[-1])
737 if ref_def is None or "enum" not in ref_def: 737 ↛ 738line 737 didn't jump to line 738 because the condition on line 737 was never true
738 return None
739 enum_items: Final[JsonSchemaItems] = {"enum": ref_def["enum"]}
740 return enum_items
743def _schema_property_entry(field_info: JsonSchemaNode, defs: Mapping[str, JsonSchemaNode]) -> JsonSchemaPropertyEntry:
744 resolved: Final = _resolve_non_null_variant(field_info)
745 items_entry: Final = _schema_items_entry(resolved, defs)
746 description: Final = field_info.get("description", "")
747 type_name: Final = resolved.get("type", "string")
748 if items_entry is None:
749 entry: Final[JsonSchemaPropertyEntry] = {"description": description, "type": type_name}
750 return entry
751 entry_with_items: Final[JsonSchemaPropertyEntry] = {
752 "description": description,
753 "type": type_name,
754 "items": items_entry,
755 }
756 return entry_with_items
759class _RootSchema(NamedTuple):
760 description: str
761 properties: Mapping[str, JsonSchemaNode]
762 nested_defs: Mapping[str, JsonSchemaNode]
763 defs: Mapping[str, JsonSchemaNode]
766def _root_schema(settings_class: type[BaseModel]) -> _RootSchema:
767 from pydantic import TypeAdapter
769 raw_schema: Final = TypeAdapter(settings_class).json_schema(by_alias=True)
770 return _RootSchema(
771 description=raw_schema.get("description", ""),
772 properties=raw_schema["properties"],
773 nested_defs=raw_schema.get("definitions", _EMPTY_SCHEMA_DEFS),
774 defs=raw_schema["$defs"] if "$defs" in raw_schema else raw_schema.get("definitions", _EMPTY_SCHEMA_DEFS),
775 )
778def _model_field_default(settings_class: type[BaseModel], field_name: str) -> object:
779 field_info: Final = settings_class.model_fields.get(field_name)
780 if field_info is None or field_info.default is PydanticUndefined: 780 ↛ 781line 780 didn't jump to line 781 because the condition on line 780 was never true
781 return None
782 return cast(object, field_info.default) # cast-ok: Pydantic field defaults are untyped
785def _ui_setting_source(
786 key: str,
787 value: object,
788 settings: SettingsStore,
789 settings_class: type[BaseModel],
790) -> FieldSource:
791 if key == ENABLE_PTU_COST_ATTRIBUTION_UI_SETTING:
792 configured_value: Final = get_secret(PTU_COST_ATTRIBUTION_ENV_VAR, None)
793 return "config" if configured_value is not None or value is True else "default"
794 if key == APPLY_USER_BUDGET_TO_TEAM_KEYS_UI_SETTING:
795 return "config" if value is True else "default"
796 return source_for(settings, key, _model_field_default(settings_class, key))
799async def _get_settings_with_schema(
800 settings_key: str,
801 settings_class: type[BaseModel],
802 config: dict,
803) -> dict:
804 """
805 Common utility function to get settings with schema information.
807 Args:
808 settings_key: The key in litellm_settings to get
809 settings_class: The Pydantic class to use for schema
810 config: The config dictionary
811 """
812 litellm_settings: Final = config.get("litellm_settings", {}) or {}
813 settings_data: Final = litellm_settings.get(settings_key, {}) or {}
815 # Create the settings object
816 settings: Final = settings_class(**(settings_data))
817 # Get the schema
818 root_schema: Final = _root_schema(settings_class)
820 # Convert to dict for response
821 settings_dict: Final = settings.model_dump()
823 # Add descriptions to the response
824 schema_properties_out: Final[Mapping[str, JsonSchemaPropertyEntry]] = {
825 field_name: _schema_property_entry(field_info, root_schema.defs)
826 for field_name, field_info in root_schema.properties.items()
827 }
829 # Add nested object descriptions
830 nested_defs_out: Final[Mapping[str, Mapping[str, object]]] = {
831 def_name: {
832 "description": def_schema.get("description", ""),
833 "properties": {
834 prop_name: {"description": prop_info.get("description", "")}
835 for prop_name, prop_info in def_schema.get("properties", {}).items()
836 },
837 }
838 for def_name, def_schema in root_schema.nested_defs.items()
839 }
841 return {
842 "values": settings_dict,
843 "field_schema": {
844 "description": root_schema.description,
845 "properties": schema_properties_out,
846 **nested_defs_out,
847 },
848 }
851@router.get(
852 "/get/internal_user_settings",
853 tags=["SSO Settings"],
854 dependencies=[Depends(user_api_key_auth)],
855 response_model=InternalUserSettingsResponse,
856)
857async def get_internal_user_settings():
858 """
859 Get all SSO settings from the litellm_settings configuration.
860 Returns a structured object with values and descriptions for UI display.
861 """
862 from litellm.proxy.proxy_server import proxy_config
864 # Load existing config
865 config: Final = await proxy_config.get_config()
867 return await _get_settings_with_schema(
868 settings_key="default_internal_user_params",
869 settings_class=DefaultInternalUserParams,
870 config=config,
871 )
874@router.get(
875 "/get/default_team_settings",
876 tags=["SSO Settings"],
877 dependencies=[Depends(user_api_key_auth)],
878 response_model=DefaultTeamSettingsResponse,
879)
880async def get_default_team_settings():
881 """
882 Get the default team parameters (litellm_settings.default_team_params).
883 Returns a structured object with values and descriptions for UI display.
884 """
885 from litellm.proxy.proxy_server import proxy_config
887 # Load existing config
888 config: Final = await proxy_config.get_config()
890 return await _get_settings_with_schema(
891 settings_key="default_team_params",
892 settings_class=DefaultTeamSSOParams,
893 config=config,
894 )
897def _default_team_ids(teams: list[str] | list[NewUserRequestTeam]) -> tuple[str, ...]:
898 return tuple(team if isinstance(team, str) else team.team_id for team in teams)
901async def _validate_default_teams_exist(teams: list[str] | list[NewUserRequestTeam]) -> None:
902 """Reject default teams that cannot be assigned.
904 New users are added to these teams long after the settings are saved, and that
905 consume path swallows the resulting 404, so an unknown team id would silently
906 drop every future user's team assignment unless it is caught here.
907 """
908 team_ids: Final = _default_team_ids(teams)
909 if not team_ids:
910 return
912 duplicate_ids: Final = tuple(team_id for team_id, count in Counter(team_ids).items() if count > 1)
913 if duplicate_ids:
914 raise HTTPException(
915 status_code=400,
916 detail={
917 "error": f"Duplicate default team id(s): {', '.join(duplicate_ids)}. List each default team only once."
918 },
919 )
921 from litellm.proxy.proxy_server import prisma_client
923 if prisma_client is None: 923 ↛ 924line 923 didn't jump to line 924 because the condition on line 923 was never true
924 raise HTTPException(
925 status_code=500,
926 detail={"error": "Database not connected. Please connect a database."},
927 )
929 existing_teams: Final = await TeamRepository(prisma_client).find_many(where={"team_id": {"in": list(team_ids)}})
930 existing_team_ids: Final = {team.team_id for team in existing_teams}
931 missing_ids: Final = tuple(team_id for team_id in team_ids if team_id not in existing_team_ids)
932 if missing_ids: 932 ↛ exitline 932 didn't return from function '_validate_default_teams_exist' because the condition on line 932 was always true
933 raise HTTPException(
934 status_code=400,
935 detail={
936 "error": f"Team(s) not found: {', '.join(missing_ids)}. "
937 "A team must exist before it can be set as a default team for new users."
938 },
939 )
942async def _validate_default_organization_exists(organization_id: str) -> None:
943 """Reject a default organization that cannot be assigned.
945 Teams are created from these settings long after they are saved, and an unknown
946 organization id would fail every future team creation instead of here, where the
947 admin who typed it can still fix it.
948 """
949 from litellm.proxy.proxy_server import prisma_client
951 if prisma_client is None: 951 ↛ 952line 951 didn't jump to line 952 because the condition on line 951 was never true
952 raise HTTPException(
953 status_code=500,
954 detail={ # mutable-ok: HTTPException detail must be a plain dict for FastAPI JSON serialization
955 "error": "Database not connected. Please connect a database."
956 },
957 )
959 organization_exists: Final = await OrganizationRepository(prisma_client).exists(
960 organization_id, id_field="organization_id"
961 )
962 if not organization_exists: 962 ↛ exitline 962 didn't return from function '_validate_default_organization_exists' because the condition on line 962 was always true
963 raise HTTPException(
964 status_code=400,
965 detail={ # mutable-ok: HTTPException detail must be a plain dict for FastAPI JSON serialization
966 "error": f"Organization not found: {organization_id}. "
967 "An organization must exist before it can be set as the default organization for new teams."
968 },
969 )
972async def update_default_team_member_budget(teams: list[NewUserRequestTeam], user_api_key_dict: UserAPIKeyAuth):
973 """
974 1. Update the max member budget for the team
975 """
976 from fastapi import Request
978 from litellm.proxy.management_endpoints.team_endpoints import update_team
980 for team in teams: 980 ↛ 981line 980 didn't jump to line 981 because the loop on line 980 never started
981 team_id = team.team_id
982 max_budget_in_team = team.max_budget_in_team
983 try:
984 await update_team(
985 data=UpdateTeamRequest(
986 team_id=team_id,
987 team_member_budget=max_budget_in_team,
988 ),
989 user_api_key_dict=user_api_key_dict,
990 http_request=Request(scope={"type": "http"}),
991 )
992 except Exception as e:
993 verbose_proxy_logger.info(
994 "Error updating team %s with team member budget %s with error: %s, skipping..",
995 team_id,
996 max_budget_in_team,
997 e,
998 )
999 continue
1002async def _update_litellm_setting(
1003 settings: (
1004 DefaultInternalUserParams
1005 | DefaultTeamSSOParams
1006 | MCPSemanticFilterSettings
1007 | MCPToolSearchSettings
1008 | WebSearchInterceptionSettings
1009 ),
1010 settings_key: str,
1011 success_message: str,
1012 user_api_key_dict: UserAPIKeyAuth,
1013):
1014 """
1015 Common utility function to update `litellm_settings` in both memory and config.
1017 Args:
1018 settings: The settings object to update
1019 settings_key: The key in litellm_settings to update
1020 success_message: Message to return on success
1021 user_api_key_dict: The acting admin, recorded as the audit-log actor.
1022 """
1023 from litellm.proxy.proxy_server import (
1024 create_config_audit_log,
1025 proxy_config,
1026 store_model_in_db,
1027 )
1029 if store_model_in_db is not True: 1029 ↛ 1030line 1029 didn't jump to line 1030 because the condition on line 1029 was never true
1030 raise HTTPException(
1031 status_code=500,
1032 detail={"error": "Set `'STORE_MODEL_IN_DB='True'` in your env to enable this feature."},
1033 )
1035 in_memory_var: Final = settings.model_dump(mode="json", exclude_none=True)
1037 # Load existing config first, then set in-memory value after,
1038 # because get_config() may overwrite litellm.<key> with stale DB values
1039 # via LITELLM_SETTINGS_SAFE_DB_OVERRIDES.
1040 config: Final = await proxy_config.get_config()
1041 before_value: Final = config.get("litellm_settings", {}).get(settings_key)
1043 # Update the in-memory settings (after get_config to avoid stale override)
1044 setattr(litellm, settings_key, in_memory_var)
1046 # Update config with new settings
1047 if "litellm_settings" not in config: 1047 ↛ 1048line 1047 didn't jump to line 1048 because the condition on line 1047 was never true
1048 config["litellm_settings"] = {}
1050 config["litellm_settings"][settings_key] = in_memory_var
1052 # Save the updated config
1053 await proxy_config.save_config(new_config=config)
1055 # Fire-and-forget so an audit-log failure (transient DB blip, etc.)
1056 # never surfaces as a 500 after save_config has already committed,
1057 # matching the create_object_audit_log pattern used elsewhere
1058 # (e.g. model_management_endpoints).
1059 asyncio.create_task(
1060 create_config_audit_log(
1061 param_name=settings_key,
1062 action="updated",
1063 before_value=before_value,
1064 after_value=in_memory_var,
1065 user_api_key_dict=user_api_key_dict,
1066 )
1067 )
1069 return {
1070 "message": success_message,
1071 "status": "success",
1072 "settings": in_memory_var,
1073 }
1076@router.patch(
1077 "/update/internal_user_settings",
1078 tags=["SSO Settings"],
1079 dependencies=[Depends(user_api_key_auth)],
1080)
1081async def update_internal_user_settings(
1082 settings: DefaultInternalUserParams,
1083 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
1084):
1085 """
1086 Update the default internal user parameters for SSO users.
1087 These settings will be applied to new users who sign in via SSO.
1088 """
1089 if settings.teams is not None:
1090 await _validate_default_teams_exist(settings.teams)
1092 if settings.teams is not None and all(isinstance(team, NewUserRequestTeam) for team in settings.teams):
1093 await update_default_team_member_budget(
1094 settings.teams,
1095 user_api_key_dict=user_api_key_dict,
1096 )
1098 return await _update_litellm_setting(
1099 settings=settings,
1100 settings_key="default_internal_user_params",
1101 success_message="Internal user settings updated successfully",
1102 user_api_key_dict=user_api_key_dict,
1103 )
1106@router.patch(
1107 "/update/default_team_settings",
1108 tags=["SSO Settings"],
1109 dependencies=[Depends(user_api_key_auth)],
1110)
1111async def update_default_team_settings(
1112 settings: DefaultTeamSSOParams,
1113 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
1114):
1115 """
1116 Update the default team parameters (litellm_settings.default_team_params).
1117 Applied to every new team for fields not explicitly provided in the create request;
1118 `models` only applies to teams automatically created via SSO Groups.
1119 """
1120 if settings.organization_id is not None:
1121 await _validate_default_organization_exists(settings.organization_id)
1123 return await _update_litellm_setting(
1124 settings=settings,
1125 settings_key="default_team_params",
1126 success_message="Default team settings updated successfully",
1127 user_api_key_dict=user_api_key_dict,
1128 )
1131@router.get(
1132 "/get/sso_settings",
1133 tags=["SSO Settings"],
1134 dependencies=[Depends(user_api_key_auth)],
1135 response_model=SSOSettingsResponse,
1136)
1137async def get_sso_settings():
1138 """
1139 Get all SSO configuration settings from the dedicated SSO table.
1140 Returns a structured object with values and descriptions for UI display.
1141 """
1143 from litellm.proxy.proxy_server import prisma_client
1145 if prisma_client is None: 1145 ↛ 1146line 1145 didn't jump to line 1146 because the condition on line 1145 was never true
1146 raise HTTPException(
1147 status_code=500,
1148 detail={"error": "Database not connected. Please connect a database."},
1149 )
1151 # Resolve the effective SSO config: the stored row wins, else the process
1152 # environment, else each field's default. Unlike the legacy read path this
1153 # does not write os.environ; a GET has no business mutating the environment.
1154 sso_db_record: Final = await _sso_settings_mapping_db(SSOConfigRepository(prisma_client)).find_unique(
1155 where={"id": "sso_config"}
1156 )
1157 sso_db_settings: Final = dict(sso_db_record.sso_settings) if sso_db_record and sso_db_record.sso_settings else None
1158 resolved: Final = resolve_sso_config(sso_db_settings, os.environ)
1160 # Get the schema for UI display
1161 root_schema: Final = _root_schema(SSOConfig)
1163 # Convert to dict for response, masking OAuth client secrets so plaintext
1164 # is never sent to the UI.
1165 sso_dict: Final = mask_sensitive_keys(resolved.config.model_dump(), set(SSO_SECRET_FIELDS))
1167 # Add descriptions to the response
1168 schema_properties_out: Final[Mapping[str, Mapping[str, str]]] = {
1169 field_name: {
1170 "description": field_info.get("description", ""),
1171 "type": field_info.get("type", "string"),
1172 }
1173 for field_name, field_info in root_schema.properties.items()
1174 }
1176 return {
1177 "values": sso_dict,
1178 "provenance": resolved.provenance,
1179 "field_schema": {
1180 "description": root_schema.description,
1181 "properties": schema_properties_out,
1182 },
1183 }
1186@router.patch(
1187 "/update/sso_settings",
1188 tags=["SSO Settings"],
1189 dependencies=[Depends(user_api_key_auth)],
1190)
1191async def update_sso_settings(
1192 sso_config: SSOConfig,
1193 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
1194):
1195 """
1196 Update SSO configuration by saving to the dedicated SSO table.
1197 """
1198 import json
1199 import os
1201 from litellm.proxy.proxy_server import (
1202 create_config_audit_log,
1203 prisma_client,
1204 proxy_config,
1205 store_model_in_db,
1206 )
1208 if prisma_client is None: 1208 ↛ 1209line 1208 didn't jump to line 1209 because the condition on line 1208 was never true
1209 raise HTTPException(
1210 status_code=500,
1211 detail={"error": "Database not connected. Please connect a database."},
1212 )
1214 if store_model_in_db is not True: 1214 ↛ 1215line 1214 didn't jump to line 1215 because the condition on line 1214 was never true
1215 raise HTTPException(
1216 status_code=500,
1217 detail={"error": "Set `'STORE_MODEL_IN_DB='True'` in your env to enable this feature."},
1218 )
1220 # Read the existing SSO row first so the audit log captures a real
1221 # before/after diff. Stored values are encrypted; decrypt them so the
1222 # before-snapshot has the same shape as after_value, and rely on
1223 # create_config_audit_log's secret-name redaction to mask the
1224 # *_client_secret fields before the audit row is written.
1225 existing_sso_record: Final = await _stored_sso_settings_db(SSOConfigRepository(prisma_client)).find_unique(
1226 where={"id": "sso_config"}
1227 )
1228 before_sso_data: dict[str, JsonValue] | None = None
1229 if existing_sso_record and existing_sso_record.sso_settings:
1230 stored = existing_sso_record.sso_settings
1231 if isinstance(stored, str): 1231 ↛ 1232line 1231 didn't jump to line 1232 because the condition on line 1231 was never true
1232 stored = json.loads(stored)
1233 if isinstance(stored, dict): 1233 ↛ 1237line 1233 didn't jump to line 1237 because the condition on line 1233 was always true
1234 before_sso_data = proxy_config._decrypt_db_variables(stored)
1236 # Load existing config
1237 config: Final = await proxy_config.get_config()
1239 # Update config with new environment variables
1240 if "environment_variables" not in config: 1240 ↛ 1244line 1240 didn't jump to line 1244 because the condition on line 1240 was always true
1241 config["environment_variables"] = {}
1243 # Update general_settings for user_email (admin email)
1244 if "general_settings" not in config: 1244 ↛ 1245line 1244 didn't jump to line 1245 because the condition on line 1244 was never true
1245 config["general_settings"] = {}
1247 # Update environment variables in config and in memory
1248 sso_data: Final = sso_config.model_dump()
1249 for field_name, value in sso_data.items():
1250 if field_name in SSO_FIELD_ENV_VARS:
1251 env_var_name = SSO_FIELD_ENV_VARS[field_name]
1252 if value:
1253 os.environ[env_var_name] = value
1254 else:
1255 # Clear environment variable if value is null/empty
1256 os.environ.pop(env_var_name, None)
1258 encrypted_sso_data: Final = proxy_config._encrypt_env_variables(environment_variables=sso_data)
1260 # Save to dedicated SSO table
1261 await _stored_sso_settings_db(SSOConfigRepository(prisma_client)).upsert(
1262 where={"id": "sso_config"},
1263 data={
1264 "create": {
1265 "id": "sso_config",
1266 "sso_settings": json.dumps(encrypted_sso_data),
1267 },
1268 "update": {
1269 "sso_settings": json.dumps(encrypted_sso_data),
1270 },
1271 },
1272 )
1274 asyncio.create_task(
1275 create_config_audit_log(
1276 param_name="sso_config",
1277 action="updated",
1278 before_value=before_sso_data,
1279 after_value=sso_data,
1280 user_api_key_dict=user_api_key_dict,
1281 table_name=LitellmTableNames.SSO_CONFIG_TABLE_NAME,
1282 )
1283 )
1285 # Remove SSO-related env vars from config.environment_variables
1286 try:
1287 env_var_entry: Final = await _config_param_db(ConfigRepository(prisma_client)).find_unique(
1288 where={"param_name": "environment_variables"}
1289 )
1291 # If no environment_variables entry exists, nothing to clean up
1292 if env_var_entry is not None:
1293 if env_var_entry.param_value is not None: 1293 ↛ 1299line 1293 didn't jump to line 1299 because the condition on line 1293 was always true
1294 if isinstance(env_var_entry.param_value, str): 1294 ↛ 1295line 1294 didn't jump to line 1295 because the condition on line 1294 was never true
1295 environment_variables: Mapping[str, object] = json.loads(env_var_entry.param_value)
1296 else:
1297 environment_variables = dict(env_var_entry.param_value)
1298 else:
1299 environment_variables = {}
1301 env_vars_to_remove: Final = set(SSO_FIELD_ENV_VARS.values())
1302 filtered_env_vars: Final = {
1303 key: value for key, value in environment_variables.items() if key not in env_vars_to_remove
1304 }
1306 await _config_param_db(ConfigRepository(prisma_client)).update(
1307 where={"param_name": "environment_variables"},
1308 data={
1309 "param_value": json.dumps(filtered_env_vars, default=str),
1310 },
1311 )
1312 await invalidate_config_param("environment_variables")
1313 except Exception as e:
1314 raise HTTPException(
1315 status_code=500,
1316 detail={"error": f"Error updating environment_variables: {e}"},
1317 )
1319 return {
1320 "message": "SSO settings updated successfully",
1321 "status": "success",
1322 "settings": sso_data,
1323 }
1326@router.get(
1327 "/get/ui_theme_settings",
1328 tags=["UI Theme Settings"],
1329 response_model=UIThemeSettingsResponse,
1330)
1331async def get_ui_theme_settings():
1332 """
1333 Get UI theme configuration from the litellm_settings.
1334 Returns current logo settings for UI customization.
1336 Note: This endpoint is public (no authentication required) so all users can see custom branding.
1337 Only the /update/ui_theme_settings endpoint requires authentication for admins to change settings.
1338 """
1339 from litellm.proxy.proxy_server import proxy_config
1341 # Load existing config
1342 config: Final = await proxy_config.get_config()
1344 result: Final = await _get_settings_with_schema(
1345 settings_key="ui_theme_config",
1346 settings_class=UIThemeConfig,
1347 config=config,
1348 )
1350 stored_values: Final = result.get("values", {})
1351 result["values"] = {
1352 **stored_values,
1353 **{field: _resolve_ui_theme_field(stored_values, field) for field in _UI_THEME_FIELD_ENV_VARS},
1354 }
1355 return result
1358def _validate_public_image_url(value: str | None, field_name: str) -> None:
1359 """
1360 Reject anything that isn't a plain http(s) URL with a host. This value is
1361 later served via the unauthenticated /get_image endpoint, so local paths
1362 like "/etc/passwd" or "file://..." must not be accepted.
1363 """
1364 if value is None:
1365 return
1366 if not isinstance(value, str) or not value.strip():
1367 return
1368 parsed: Final = urlparse(value.strip())
1369 if parsed.scheme not in ("http", "https") or not parsed.netloc: 1369 ↛ exitline 1369 didn't return from function '_validate_public_image_url' because the condition on line 1369 was always true
1370 raise HTTPException(
1371 status_code=400,
1372 detail={
1373 "error": (
1374 f"Invalid {field_name}: must be an http(s) URL with a host. "
1375 "Local filesystem paths and non-http schemes are not allowed."
1376 )
1377 },
1378 )
1381@router.patch(
1382 "/update/ui_theme_settings",
1383 tags=["UI Theme Settings"],
1384 dependencies=[Depends(user_api_key_auth)],
1385)
1386async def update_ui_theme_settings(
1387 theme_config: UIThemeConfig,
1388 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
1389):
1390 """
1391 Update UI theme configuration.
1392 Updates logo settings for the admin UI.
1393 """
1394 import os
1396 from litellm.proxy.proxy_server import (
1397 create_config_audit_log,
1398 proxy_config,
1399 store_model_in_db,
1400 )
1402 _validate_public_image_url(theme_config.logo_url, "logo_url")
1403 _validate_public_image_url(theme_config.logo_url_dark, "logo_url_dark")
1404 _validate_public_image_url(theme_config.favicon_url, "favicon_url")
1406 if store_model_in_db is not True: 1406 ↛ 1407line 1406 didn't jump to line 1407 because the condition on line 1406 was never true
1407 raise HTTPException(
1408 status_code=500,
1409 detail={"error": "Set `'STORE_MODEL_IN_DB='True'` in your env to enable this feature."},
1410 )
1412 # Load existing config
1413 config: Final = await proxy_config.get_config()
1414 before_theme: Final = config.get("litellm_settings", {}).get("ui_theme_config")
1416 # Convert theme config to dict
1417 theme_data: Final = theme_config.model_dump(exclude_none=True)
1419 # Store UI theme config in litellm_settings (where it's retrieved from)
1420 if "litellm_settings" not in config: 1420 ↛ 1421line 1420 didn't jump to line 1421 because the condition on line 1420 was never true
1421 config["litellm_settings"] = {}
1422 config["litellm_settings"]["ui_theme_config"] = theme_data
1424 # The vars below are the only environment variables this endpoint owns, and
1425 # they must stay in step with _UI_THEME_FIELD_ENV_VARS. A non-empty value
1426 # sets the var; an empty or missing one clears it back to the default. Apply
1427 # to the live process immediately, then persist only those keys so an
1428 # unrelated env var (a YAML/OS value merged in by get_config) is never
1429 # snapshotted into the DB.
1430 def _clean(url: str | None) -> str | None:
1431 return url if url is not None and url.strip() else None
1433 env_updates: Final[dict[str, str | None]] = {
1434 "UI_LOGO_PATH": _clean(theme_config.logo_url),
1435 "UI_LOGO_PATH_DARK": _clean(theme_config.logo_url_dark),
1436 "LITELLM_FAVICON_URL": _clean(theme_config.favicon_url),
1437 }
1438 for env_key, env_value in env_updates.items():
1439 if env_value is not None: 1439 ↛ 1440line 1439 didn't jump to line 1440 because the condition on line 1439 was never true
1440 os.environ[env_key] = env_value
1441 else:
1442 os.environ.pop(env_key, None)
1444 # Persist the theme config (litellm_settings). save_config defaults to
1445 # include_env_vars=False, so it does not snapshot environment_variables.
1446 await proxy_config.save_config(new_config=config)
1447 # Persist only the two owned env vars, merged against the existing DB row.
1448 await proxy_config.save_environment_variables(env_updates)
1450 asyncio.create_task(
1451 create_config_audit_log(
1452 param_name="ui_theme_config",
1453 action="updated",
1454 before_value=before_theme,
1455 after_value=theme_data,
1456 user_api_key_dict=user_api_key_dict,
1457 )
1458 )
1460 return {
1461 "message": "UI theme settings updated successfully.",
1462 "status": "success",
1463 "theme_config": theme_data,
1464 }
1467@router.get(
1468 "/get/mcp_semantic_filter_settings",
1469 tags=["Settings"],
1470 dependencies=[Depends(user_api_key_auth)],
1471 response_model=MCPSemanticFilterSettingsResponse,
1472)
1473async def get_mcp_semantic_filter_settings(
1474 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
1475):
1476 """
1477 Get MCP semantic filter configuration.
1478 Returns current settings for semantic tool filtering.
1479 """
1480 from litellm.proxy.proxy_server import prisma_client, proxy_config
1482 if prisma_client is None: 1482 ↛ 1483line 1482 didn't jump to line 1483 because the condition on line 1482 was never true
1483 raise HTTPException(
1484 status_code=500,
1485 detail={"error": "Database not connected. Please connect a database."},
1486 )
1488 config: Final = await proxy_config.get_config()
1490 return await _get_settings_with_schema(
1491 settings_key="mcp_semantic_tool_filter",
1492 settings_class=MCPSemanticFilterSettings,
1493 config=config,
1494 )
1497@router.patch(
1498 "/update/mcp_semantic_filter_settings",
1499 tags=["Settings"],
1500 dependencies=[Depends(user_api_key_auth)],
1501)
1502async def update_mcp_semantic_filter_settings(
1503 settings: MCPSemanticFilterSettings,
1504 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
1505):
1506 """
1507 Update MCP semantic filter settings in database.
1508 Settings will be picked up by all pods within approximately 10 seconds via background polling.
1509 """
1510 if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: 1510 ↛ 1511line 1510 didn't jump to line 1511 because the condition on line 1510 was never true
1511 raise HTTPException(
1512 status_code=403,
1513 detail="Only proxy admins can update MCP semantic filter settings.",
1514 )
1516 result: Final = await _update_litellm_setting(
1517 settings=settings,
1518 settings_key="mcp_semantic_tool_filter",
1519 success_message="MCP Semantic Filter settings updated successfully. Changes will be applied across all pods within 10 seconds.",
1520 user_api_key_dict=user_api_key_dict,
1521 )
1522 try:
1523 from litellm.proxy.proxy_server import prisma_client, proxy_config
1525 if prisma_client is not None: 1525 ↛ 1530line 1525 didn't jump to line 1530 because the condition on line 1525 was always true
1526 await proxy_config._init_semantic_filter_settings_in_db(prisma_client=prisma_client)
1527 except Exception as e:
1528 verbose_proxy_logger.warning("Failed to reinitialize MCP semantic filter settings immediately: %s", e)
1530 return result
1533@router.get(
1534 "/get/websearch_interception_settings",
1535 tags=["Settings"],
1536 dependencies=[Depends(user_api_key_auth)],
1537 response_model=WebSearchInterceptionSettingsResponse,
1538)
1539async def get_websearch_interception_settings(
1540 user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
1541):
1542 """
1543 Get web search interception configuration.
1545 Returns the current settings plus their schema, for the Admin UI to render.
1546 """
1547 from litellm.proxy.proxy_server import prisma_client, proxy_config
1549 if prisma_client is None: 1549 ↛ 1550line 1549 didn't jump to line 1550 because the condition on line 1549 was never true
1550 raise HTTPException(
1551 status_code=500,
1552 detail={"error": "Database not connected. Please connect a database."},
1553 )
1555 config: Final = await proxy_config.get_config()
1557 from litellm.integrations.websearch_interception.handler import (
1558 WebSearchInterceptionLogger,
1559 )
1561 settings: Final = await _get_settings_with_schema(
1562 settings_key="websearch_interception_params",
1563 settings_class=WebSearchInterceptionSettings,
1564 config=_with_websearch_enabled_resolved(config),
1565 )
1566 return WebSearchInterceptionSettingsResponse(
1567 values=settings["values"],
1568 field_schema=settings["field_schema"],
1569 active_on_this_pod=bool(
1570 litellm.logging_callback_manager.get_custom_loggers_for_type(WebSearchInterceptionLogger)
1571 ),
1572 )
1575@router.patch(
1576 "/update/websearch_interception_settings",
1577 tags=["Settings"],
1578 dependencies=[Depends(user_api_key_auth)],
1579)
1580async def update_websearch_interception_settings(
1581 settings: WebSearchInterceptionSettings,
1582 user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
1583):
1584 """
1585 Update web search interception settings in database.
1587 Settings will be picked up by all pods within approximately 10 seconds via background polling.
1588 """
1589 if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: 1589 ↛ 1590line 1589 didn't jump to line 1590 because the condition on line 1589 was never true
1590 raise HTTPException(
1591 status_code=403,
1592 detail="Only proxy admins can update web search interception settings.",
1593 )
1595 result: Final = await _update_litellm_setting(
1596 settings=settings,
1597 settings_key="websearch_interception_params",
1598 success_message=(
1599 "Web search interception settings updated successfully. "
1600 "Changes will be applied across all pods within 10 seconds."
1601 ),
1602 user_api_key_dict=user_api_key_dict,
1603 )
1604 try:
1605 from litellm.proxy.proxy_server import prisma_client, proxy_config
1607 if prisma_client is not None: 1607 ↛ 1612line 1607 didn't jump to line 1612 because the condition on line 1607 was always true
1608 await proxy_config.init_websearch_interception_settings_in_db(prisma_client=prisma_client)
1609 except Exception as e:
1610 verbose_proxy_logger.warning("Failed to reinitialize web search interception settings immediately: %s", e)
1612 return result
1615@router.get(
1616 "/get/mcp_tool_search_settings",
1617 tags=["Settings"], # mutable-ok: FastAPI's route decorator only accepts a list
1618 dependencies=[Depends(user_api_key_auth)], # mutable-ok: FastAPI's route decorator only accepts a list
1619 response_model=MCPToolSearchSettingsResponse,
1620)
1621async def get_mcp_tool_search_settings(
1622 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
1623) -> Mapping[str, object]:
1624 """
1625 Get the `litellm_settings.mcp_tool_search` configuration used by the native `mcp_tool_search` virtual tool.
1626 """
1627 from litellm.proxy.proxy_server import prisma_client, proxy_config
1629 if prisma_client is None: 1629 ↛ 1630line 1629 didn't jump to line 1630 because the condition on line 1629 was never true
1630 raise HTTPException(status_code=500, detail="Database not connected. Please connect a database.")
1632 config: Final = await proxy_config.get_config()
1634 return await _get_settings_with_schema(
1635 settings_key=MCP_TOOL_SEARCH_SETTINGS_KEY,
1636 settings_class=MCPToolSearchSettings,
1637 config=config,
1638 )
1641@router.patch(
1642 "/update/mcp_tool_search_settings",
1643 tags=["Settings"], # mutable-ok: FastAPI's route decorator only accepts a list
1644 dependencies=[Depends(user_api_key_auth)], # mutable-ok: FastAPI's route decorator only accepts a list
1645)
1646async def update_mcp_tool_search_settings(
1647 settings: MCPToolSearchSettings,
1648 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
1649) -> Mapping[str, object]:
1650 """
1651 Update `litellm_settings.mcp_tool_search` in the database.
1652 Settings will be picked up by all pods within approximately 10 seconds via background polling.
1653 """
1654 if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: 1654 ↛ 1655line 1654 didn't jump to line 1655 because the condition on line 1654 was never true
1655 raise HTTPException(
1656 status_code=403,
1657 detail="Only proxy admins can update MCP tool search settings.",
1658 )
1660 return await _update_litellm_setting(
1661 settings=settings,
1662 settings_key=MCP_TOOL_SEARCH_SETTINGS_KEY,
1663 success_message="MCP tool search settings updated successfully. Changes will be applied across all pods within 10 seconds.",
1664 user_api_key_dict=user_api_key_dict,
1665 )
1668UI_SETTINGS_CACHE_KEY: Final = "ui_settings:settings_dict"
1669UI_SETTINGS_CACHE_TTL: Final = 600 # 10 minutes
1672async def get_ui_settings_cached() -> dict[str, JsonValue]:
1673 """
1674 Return the persisted UI settings dict, using DualCache for reads.
1676 Cache hit → return cached dict immediately.
1677 Cache miss → read from DB, populate cache, return dict.
1678 """
1679 from litellm.proxy.proxy_server import prisma_client, user_api_key_cache
1681 # 1. Try cache
1682 cached: Final = await user_api_key_cache.async_get_cache(key=UI_SETTINGS_CACHE_KEY)
1683 if cached is not None and isinstance(cached, dict):
1684 return cached
1686 # 2. Fallback to DB
1687 if prisma_client is None:
1688 return {}
1690 db_record: Final = await _ui_settings_db(UISettingsRepository(prisma_client)).find_unique(
1691 where={"id": "ui_settings"}
1692 )
1693 ui_settings: dict[str, JsonValue] = {}
1694 if db_record and db_record.ui_settings:
1695 raw: Final = db_record.ui_settings
1696 ui_settings = json.loads(raw) if isinstance(raw, str) else dict(raw)
1698 # Sanitize
1699 ui_settings = {k: v for k, v in ui_settings.items() if k in ALLOWED_UI_SETTINGS_FIELDS}
1701 # 3. Populate cache with TTL
1702 await user_api_key_cache.async_set_cache(key=UI_SETTINGS_CACHE_KEY, value=ui_settings, ttl=UI_SETTINGS_CACHE_TTL)
1704 return ui_settings
1707_UI_SETTINGS_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
1710def apply_runtime_general_settings_flags(ui_settings: Mapping[str, JsonValue]) -> Mapping[str, JsonValue]:
1711 """Copy the UI settings that gate runtime behavior into ``general_settings``. Returns what was applied."""
1712 from litellm.proxy.config_resolvers import SettingsStore
1713 from litellm.proxy.proxy_server import general_settings
1715 flags: Final = {k: ui_settings[k] for k in _RUNTIME_GENERAL_SETTINGS_FLAGS if k in ui_settings}
1716 if isinstance(general_settings, SettingsStore): 1716 ↛ 1718line 1716 didn't jump to line 1718 because the condition on line 1716 was always true
1717 general_settings.apply_db_row("ui_settings", flags)
1718 elif flags:
1719 general_settings.update(flags)
1720 return MappingProxyType(flags)
1723async def sync_ui_settings_to_general_settings(prisma_client: object) -> Mapping[str, JsonValue]:
1724 """Re-read the persisted UI settings and apply the runtime flags to ``general_settings``.
1726 Runs on startup and on every periodic config reload: the PATCH handler only updates the pod
1727 that served it, so every other pod needs its own read to pick up a change without a restart.
1728 Never raises. A read that fails leaves this pod on the flags it already had.
1729 """
1730 try:
1731 db_record: Final = await _ui_settings_db(UISettingsRepository(prisma_client)).find_unique(
1732 where={"id": "ui_settings"}
1733 )
1734 stored: Final = (db_record.ui_settings if db_record else None) or "{}"
1735 parsed: Final = (
1736 _UI_SETTINGS_OBJECT.validate_json(stored)
1737 if isinstance(stored, str)
1738 else _UI_SETTINGS_OBJECT.validate_python(stored)
1739 )
1740 except Exception as e:
1741 verbose_proxy_logger.warning("Could not refresh UI settings from the database: %s", e)
1742 return MappingProxyType({})
1743 return apply_runtime_general_settings_flags(parsed)
1746@router.get(
1747 "/get/ui_settings",
1748 tags=["UI Settings"],
1749 response_model=UISettingsResponse,
1750)
1751async def get_ui_settings():
1752 """
1753 Get UI-specific configuration flags.
1754 All authenticated users can fetch these settings for client-side behavior.
1755 """
1756 from litellm.proxy.proxy_server import prisma_client, proxy_config
1758 if prisma_client is None: 1758 ↛ 1759line 1758 didn't jump to line 1759 because the condition on line 1758 was never true
1759 raise HTTPException(
1760 status_code=500,
1761 detail={"error": "Database not connected. Please connect a database."},
1762 )
1764 db_record: Final = await _ui_settings_db(UISettingsRepository(prisma_client)).find_unique(
1765 where={"id": "ui_settings"}
1766 )
1768 stored: Final = (db_record.ui_settings if db_record else None) or "{}"
1769 parsed: Final = json.loads(stored) if isinstance(stored, str) else stored
1771 # Sanitize any unexpected keys from persisted config before returning
1772 ui_settings: Final = {k: v for k, v in parsed.items() if k in ALLOWED_UI_SETTINGS_FIELDS}
1774 apply_runtime_general_settings_flags(ui_settings)
1776 # Refresh DualCache so other code paths (e.g. /user/filter/ui) see fresh values
1777 from litellm.proxy.proxy_server import user_api_key_cache
1779 await user_api_key_cache.async_set_cache(key=UI_SETTINGS_CACHE_KEY, value=ui_settings, ttl=UI_SETTINGS_CACHE_TTL)
1781 effective_ui_settings: Final[Mapping[str, object]] = MappingProxyType(
1782 {
1783 **ui_settings,
1784 **{key: proxy_config.settings[key] for key in ALLOWED_UI_SETTINGS_FIELDS if key in proxy_config.settings},
1785 }
1786 )
1787 config: Final[Mapping[str, object]] = MappingProxyType(
1788 {"litellm_settings": MappingProxyType({"ui_settings": effective_ui_settings})}
1789 )
1790 settings_class: Final = _get_effective_ui_settings_class()
1791 resolved_settings: Final = _SettingsWithSchema.model_validate(
1792 await _get_settings_with_schema(
1793 settings_key="ui_settings",
1794 settings_class=settings_class,
1795 config=config,
1796 )
1797 )
1798 values: Final[Mapping[str, object]] = MappingProxyType(
1799 {
1800 **resolved_settings.values,
1801 ENABLE_PTU_COST_ATTRIBUTION_UI_SETTING: is_ptu_cost_attribution_enabled(),
1802 APPLY_USER_BUDGET_TO_TEAM_KEYS_UI_SETTING: _derived_ui_setting_value(
1803 APPLY_USER_BUDGET_TO_TEAM_KEYS_UI_SETTING
1804 ),
1805 }
1806 )
1807 source: Final[Mapping[str, FieldSource]] = MappingProxyType(
1808 {
1809 key: (
1810 _ui_setting_source(key, values[key], proxy_config.settings, settings_class)
1811 if key in proxy_config.settings or key not in ui_settings
1812 else "db"
1813 )
1814 for key in values
1815 }
1816 )
1817 return UISettingsResponse(
1818 values=values,
1819 field_schema=resolved_settings.field_schema,
1820 source=source,
1821 )
1824@router.patch(
1825 "/update/ui_settings",
1826 tags=["UI Settings"],
1827 dependencies=[Depends(user_api_key_auth)],
1828)
1829async def update_ui_settings(
1830 settings_body: dict[str, object] = Body(...),
1831 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
1832):
1833 """
1834 Update UI-specific configuration flags.
1835 Only proxy admins are allowed to modify these settings.
1836 """
1837 from litellm.proxy.proxy_server import (
1838 create_config_audit_log,
1839 prisma_client,
1840 store_model_in_db,
1841 )
1843 if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: 1843 ↛ 1844line 1843 didn't jump to line 1844 because the condition on line 1843 was never true
1844 raise HTTPException(status_code=403, detail="Only proxy admins can update UI settings.")
1846 if prisma_client is None: 1846 ↛ 1847line 1846 didn't jump to line 1847 because the condition on line 1846 was never true
1847 raise HTTPException(
1848 status_code=500,
1849 detail={"error": "Database not connected. Please connect a database."},
1850 )
1852 if store_model_in_db is not True: 1852 ↛ 1853line 1852 didn't jump to line 1853 because the condition on line 1852 was never true
1853 raise HTTPException(
1854 status_code=500,
1855 detail={"error": "Set `'STORE_MODEL_IN_DB='True'` in your env to enable this feature."},
1856 )
1858 conflicting_keys: Final = sorted(
1859 key
1860 for key, value in settings_body.items()
1861 if key in _DERIVED_UI_SETTINGS_FIELDS and value != _derived_ui_setting_value(key)
1862 )
1863 if conflicting_keys: 1863 ↛ 1864line 1863 didn't jump to line 1864 because the condition on line 1863 was never true
1864 raise HTTPException(
1865 status_code=400,
1866 detail=(
1867 f"Setting(s) {conflicting_keys} are derived from the deployment environment "
1868 "and cannot be changed from the UI."
1869 ),
1870 )
1872 # Validate against the same effective class GET advertises, so
1873 # enterprise-registered fields are typed consistently on both sides.
1874 effective_cls: Final = _get_effective_ui_settings_class()
1875 try:
1876 settings: Final = effective_cls.model_validate(settings_body)
1877 except ValidationError as e:
1878 raise HTTPException(status_code=422, detail=e.errors())
1880 unsupported_team_fields: Final = sorted(
1881 frozenset(settings.team_admin_editable_team_fields) - SUPPORTED_TEAM_ADMIN_PERMISSIONS
1882 )
1883 if unsupported_team_fields: 1883 ↛ 1884line 1883 didn't jump to line 1884 because the condition on line 1883 was never true
1884 raise HTTPException(
1885 status_code=400,
1886 detail={ # mutable-ok: HTTPException detail must be a plain dict for FastAPI JSON serialization
1887 "error": (
1888 f"{TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING} does not support {unsupported_team_fields}. "
1889 f"Supported fields: {sorted(SUPPORTED_TEAM_ADMIN_PERMISSIONS)}."
1890 )
1891 },
1892 )
1894 # Only include fields the caller actually sent (not Pydantic defaults).
1895 settings_dict: Final[Mapping[str, JsonValue]] = settings.model_dump(exclude_unset=True)
1897 # Reject enterprise-only settings up front so the caller gets a clear
1898 # signal instead of a silent drop.
1899 blocked_enterprise_keys = sorted((settings_dict.keys() & _ENTERPRISE_ONLY_UI_SETTINGS) - ALLOWED_UI_SETTINGS_FIELDS)
1900 if blocked_enterprise_keys: 1900 ↛ 1901line 1900 didn't jump to line 1901 because the condition on line 1900 was never true
1901 raise HTTPException(
1902 status_code=403,
1903 detail={
1904 "error": (
1905 f"Setting(s) {blocked_enterprise_keys} are a LiteLLM "
1906 "Enterprise feature and are not available on this build."
1907 )
1908 },
1909 )
1911 # Enforce allowlist and drop anything unexpected
1912 incoming: Final = {k: v for k, v in settings_dict.items() if k in ALLOWED_UI_SETTINGS_FIELDS}
1914 # Merge with existing persisted settings so a partial PATCH doesn't
1915 # overwrite fields the caller didn't send.
1916 existing: dict[str, JsonValue] = {}
1917 db_existing: Final = await _ui_settings_db(UISettingsRepository(prisma_client)).find_unique(
1918 where={"id": "ui_settings"}
1919 )
1920 if db_existing and db_existing.ui_settings: 1920 ↛ 1921line 1920 didn't jump to line 1921 because the condition on line 1920 was never true
1921 raw: Final = db_existing.ui_settings
1922 existing = json.loads(raw) if isinstance(raw, str) else dict(raw)
1924 ui_settings: Final = {**existing, **incoming}
1926 await _ui_settings_db(UISettingsRepository(prisma_client)).upsert(
1927 where={"id": "ui_settings"},
1928 data={
1929 "create": {
1930 "id": "ui_settings",
1931 "ui_settings": json.dumps(ui_settings),
1932 },
1933 "update": {
1934 "ui_settings": json.dumps(ui_settings),
1935 },
1936 },
1937 )
1939 apply_runtime_general_settings_flags(ui_settings)
1941 # Invalidate + set DualCache so subsequent reads see the new values immediately
1942 from litellm.proxy.proxy_server import user_api_key_cache
1944 sanitized: Final = {k: v for k, v in ui_settings.items() if k in ALLOWED_UI_SETTINGS_FIELDS}
1945 await user_api_key_cache.async_set_cache(key=UI_SETTINGS_CACHE_KEY, value=sanitized, ttl=UI_SETTINGS_CACHE_TTL)
1947 asyncio.create_task(
1948 create_config_audit_log(
1949 param_name="ui_settings",
1950 action="updated",
1951 before_value=existing,
1952 after_value=ui_settings,
1953 user_api_key_dict=user_api_key_dict,
1954 table_name=LitellmTableNames.UI_SETTINGS_TABLE_NAME,
1955 )
1956 )
1958 return {
1959 "message": "UI settings updated successfully",
1960 "status": "success",
1961 "settings": ui_settings,
1962 }
1965@router.post(
1966 "/upload/logo",
1967 tags=["UI Theme Settings"],
1968 dependencies=[Depends(user_api_key_auth)],
1969)
1970async def upload_logo(
1971 file: UploadFile = File(...),
1972 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
1973):
1974 """
1975 Upload a custom logo for the admin UI.
1976 Accepts image files (PNG, JPG, JPEG, SVG) and stores them for use in the UI.
1977 """
1978 import os
1979 from pathlib import Path
1981 if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: 1981 ↛ 1982line 1981 didn't jump to line 1982 because the condition on line 1981 was never true
1982 raise HTTPException(
1983 status_code=403,
1984 detail="Only proxy admins can upload a UI logo.",
1985 )
1987 # Validate file type
1988 allowed_extensions: Final = {".png", ".jpg", ".jpeg", ".svg"}
1989 file_extension: Final = Path(file.filename or "").suffix.lower()
1991 if file_extension not in allowed_extensions: 1991 ↛ 1999line 1991 didn't jump to line 1999 because the condition on line 1991 was always true
1992 raise HTTPException(
1993 status_code=400,
1994 detail=f"Invalid file type. Allowed types: {', '.join(allowed_extensions)}",
1995 )
1997 # Read bounded to one byte past the limit, so an oversized upload is never
1998 # fully buffered in memory before being rejected.
1999 max_logo_size_bytes: Final = 5 * 1024 * 1024
2000 file_content: Final = await file.read(max_logo_size_bytes + 1)
2001 if len(file_content) > max_logo_size_bytes:
2002 raise HTTPException(status_code=400, detail="File size too large. Maximum size is 5MB.")
2004 # Create uploads directory if it doesn't exist
2005 current_dir: Final = os.path.dirname(os.path.abspath(__file__))
2006 upload_dir: Final = os.path.join(current_dir, "..", "uploads")
2007 os.makedirs(upload_dir, exist_ok=True)
2009 # Generate unique filename
2010 from litellm._uuid import uuid
2012 unique_filename: Final = f"logo_{uuid.uuid4().hex}{file_extension}"
2013 file_path: Final = os.path.join(upload_dir, unique_filename)
2015 # Save the file
2016 with open(file_path, "wb") as buffer:
2017 buffer.write(file_content)
2019 return {
2020 "message": "Logo uploaded successfully",
2021 "status": "success",
2022 "file_path": file_path,
2023 "filename": unique_filename,
2024 "file_size": len(file_content),
2025 }