diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index b0d31df92ce..8574f2d8bf4 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -2410,6 +2410,7 @@ class FieldDetail(BaseModel): field_description: str field_default_value: Any = None stored_in_db: bool | None + source: Literal["config", "db", "default", "unset"] = "unset" class ConfigList(LiteLLMPydanticObjectBase): @@ -2418,6 +2419,7 @@ class ConfigList(LiteLLMPydanticObjectBase): field_description: str field_value: Any stored_in_db: bool | None + source: Literal["config", "db", "default", "unset"] = "unset" field_default_value: Any premium_field: bool = False nested_fields: list[FieldDetail] | None = None # For nested dictionary or Pydantic fields @@ -3693,6 +3695,7 @@ class InvitationClaim(LiteLLMPydanticObjectBase): class ConfigFieldInfo(LiteLLMPydanticObjectBase): field_name: str field_value: Any + source: Literal["config", "db", "default", "unset"] = "unset" class CallbackOnUI(LiteLLMPydanticObjectBase): diff --git a/litellm/proxy/config_resolvers/__init__.py b/litellm/proxy/config_resolvers/__init__.py index ebd339b34c3..77da2c413c2 100644 --- a/litellm/proxy/config_resolvers/__init__.py +++ b/litellm/proxy/config_resolvers/__init__.py @@ -5,6 +5,13 @@ from litellm.proxy.config_resolvers._descriptors import ( FieldSource, resolve_fields, ) -from litellm.proxy.config_resolvers.settings_store import SettingsStore +from litellm.proxy.config_resolvers.settings_store import SettingsSource, SettingsStore, source_for -__all__ = ("FieldDescriptor", "FieldSource", "SettingsStore", "resolve_fields") +__all__ = ( + "FieldDescriptor", + "FieldSource", + "SettingsSource", + "SettingsStore", + "resolve_fields", + "source_for", +) diff --git a/litellm/proxy/config_resolvers/settings_store.py b/litellm/proxy/config_resolvers/settings_store.py index 3fe869ee2ce..8f400853fa9 100644 --- a/litellm/proxy/config_resolvers/settings_store.py +++ b/litellm/proxy/config_resolvers/settings_store.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Iterator, Mapping, MutableMapping from types import MappingProxyType -from typing import Final +from typing import Final, Literal, TypeAlias from litellm.proxy.config_resolvers._descriptors import FieldSource from litellm.proxy.config_resolvers.settings_rules import ( @@ -19,6 +19,7 @@ from litellm.proxy.config_resolvers.settings_rules import ( _EMPTY_VALUES: Final[Mapping[str, JsonValue]] = MappingProxyType({}) _EMPTY_ROWS: Final[Mapping[DbRow, Mapping[str, JsonValue]]] = MappingProxyType({}) +SettingsSource: TypeAlias = Literal["config", "db", "default", "unset"] class SettingsStore(MutableMapping[str, JsonValue]): @@ -109,3 +110,12 @@ class SettingsStore(MutableMapping[str, JsonValue]): yaml_value: Final[SettingValue] = self._yaml_values.get(key, ABSENT) db_value: Final[SettingValue] = self._database_rows.get(rule.db_row, _EMPTY_VALUES).get(key, ABSENT) return resolve(rule, yaml_value, db_value) + + +def source_for(settings: SettingsStore, key: str, default: object = None) -> SettingsSource: + source: Final = settings.source(key) + if source == "unset": + return "default" if default is not None else "unset" + if source in ("config", "db", "default"): + return source + return "unset" diff --git a/litellm/proxy/management_endpoints/router_settings_endpoints.py b/litellm/proxy/management_endpoints/router_settings_endpoints.py index fc000b1638b..5d3b6d40601 100644 --- a/litellm/proxy/management_endpoints/router_settings_endpoints.py +++ b/litellm/proxy/management_endpoints/router_settings_endpoints.py @@ -8,7 +8,7 @@ GET /router/fields - Get router settings field definitions without values (for U """ import inspect -from typing import Any, Final, get_args +from typing import Any, Final, cast, get_args from fastapi import APIRouter, Depends from pydantic import BaseModel, Field @@ -16,6 +16,7 @@ from pydantic import BaseModel, Field from litellm._logging import verbose_proxy_logger from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.config_resolvers import SettingsSource, source_for from litellm.router import Router from litellm.types.management_endpoints import ( ROUTER_SETTINGS_FIELDS, @@ -30,6 +31,7 @@ class RouterSettingsResponse(BaseModel): fields: list[RouterSettingsField] = Field(description="List of all configurable router settings with metadata") current_values: dict[str, Any] = Field(description="Current values of router settings") routing_strategy_descriptions: dict[str, str] = Field(description="Descriptions for each routing strategy option") + source: dict[str, SettingsSource] = Field(description="Source of each current router setting") class RouterFieldsResponse(BaseModel): @@ -109,15 +111,21 @@ async def get_router_settings( # Merge with config values (config takes precedence) current_values.update(router_settings_from_config) - # Update field values with current values for field in router_fields: if field.field_name in current_values: field.field_value = current_values[field.field_name] + field_defaults: Final[dict[str, object]] = { + field.field_name: cast(object, field.field_default) for field in router_fields + } + source: Final[dict[str, SettingsSource]] = { + key: source_for(proxy_config.router_settings, key, field_defaults.get(key)) for key in current_values + } return RouterSettingsResponse( fields=router_fields, current_values=current_values, routing_strategy_descriptions=ROUTING_STRATEGY_DESCRIPTIONS, + source=source, ) except Exception as e: verbose_proxy_logger.error("Error fetching router settings: %s", e) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 6a720a066b4..1131d7bea86 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -50,6 +50,7 @@ import anyio import websockets import websockets.exceptions from pydantic import BaseModel, Json, JsonValue, TypeAdapter, ValidationError +from pydantic.fields import FieldInfo, PydanticUndefined from typing_extensions import NotRequired, ReadOnly, assert_never from litellm._uuid import uuid @@ -431,7 +432,7 @@ from litellm.proxy.common_utils.user_api_key_cache import ( model_access_group_spend_counter_key, tag_cache_key, ) -from litellm.proxy.config_resolvers import SettingsStore, resolve_fields +from litellm.proxy.config_resolvers import SettingsStore, resolve_fields, source_for from litellm.proxy.config_resolvers.alerting import ( EMAIL_DESCRIPTORS, MS_TEAMS_DESCRIPTORS, @@ -4816,6 +4817,12 @@ def _as_settings_mapping(value: object) -> Mapping[str, SettingsJsonValue]: return _SETTINGS_MAPPING.validate_python(value) +def _get_field_default(field_info: FieldInfo) -> JsonValue: + if field_info.default is PydanticUndefined: + return None + return cast(JsonValue, field_info.default) + + def _bind_general_settings_store(settings: SettingsStore) -> None: global general_settings general_settings = settings # pyright: ignore[reportAssignmentType] # legacy global accepts mappings @@ -15635,17 +15642,16 @@ async def alerting_settings( where={"param_name": "general_settings"} ) - if db_general_settings is not None and db_general_settings.param_value is not None: - db_general_settings_dict: Final = dict(db_general_settings.param_value) - alerting_args_dict: dict = cast( # cast-ok: ConfigGeneralSettings validates alerting_args as a dict on write - dict[str, JsonValue], db_general_settings_dict.get("alerting_args", {}) - ) - alerting_values: list | None = cast( # cast-ok: ConfigGeneralSettings validates alerting as a list on write - list[JsonValue] | None, db_general_settings_dict.get("alerting") - ) - else: - alerting_args_dict = {} - alerting_values = None + db_general_settings_dict: Final[Mapping[str, JsonValue]] = ( + dict(db_general_settings.param_value) + if db_general_settings is not None and db_general_settings.param_value is not None + else {} + ) + alerting_args_dict: Final = cast(dict[str, JsonValue], db_general_settings_dict.get("alerting_args", {})) + alerting_values: Final = cast(list[JsonValue] | None, db_general_settings_dict.get("alerting")) + + settings: Final = proxy_config.settings + settings.apply_db_row("general_settings", db_general_settings_dict) allowed_args: Final = MappingProxyType( { @@ -15674,9 +15680,9 @@ async def alerting_settings( is_slack_enabled = False - if general_settings.get("alerting") and isinstance(general_settings["alerting"], list): - if "slack" in general_settings["alerting"]: - is_slack_enabled = True + alerting: Final = settings.get("alerting") + if isinstance(alerting, list) and "slack" in alerting: + is_slack_enabled = True _response_obj = ConfigList( field_name="slack_alerting", @@ -15684,6 +15690,7 @@ async def alerting_settings( field_description="Enable slack alerting for monitoring proxy in production: llm outages, budgets, spend tracking failures.", field_value=is_slack_enabled, stored_in_db=True if alerting_values is not None else False, + source=source_for(settings, "alerting"), field_default_value=None, premium_field=False, ) @@ -15691,6 +15698,7 @@ async def alerting_settings( for field_name, field_info in SlackAlertingArgs.model_fields.items(): if field_name in allowed_args: + field_default: JsonValue = _get_field_default(field_info) _stored_in_db: bool | None = None if field_name in alerting_args_dict: _stored_in_db = True @@ -15701,9 +15709,10 @@ async def alerting_settings( field_name=field_name, field_type=allowed_args[field_name], field_description=field_info.description or "", - field_value=_slack_alerting_args_dict.get(field_name, None), + field_value=_slack_alerting_args_dict.get(field_name, field_default), stored_in_db=_stored_in_db, - field_default_value=field_info.default, + source=source_for(settings, "alerting_args", field_default), + field_default_value=field_default, premium_field=(True if field_name == "region_outage_alert_ttl" else False), ) return_val.append(_response_obj) @@ -17390,20 +17399,6 @@ async def get_config_general_settings( field_name: str, user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ): - global prisma_client - - ## VALIDATION ## - """ - - Check if prisma_client is None - - Check if user allowed to call this endpoint (admin-only) - - Check if param in general settings - """ - if prisma_client is None: - raise HTTPException( - status_code=400, - detail={"error": CommonProxyErrors.db_not_connected_error.value}, - ) - if not _user_has_admin_view(user_api_key_dict): raise HTTPException( status_code=400, @@ -17416,37 +17411,47 @@ async def get_config_general_settings( detail={"error": f"Invalid field={field_name} passed in."}, ) - ## get general settings from db - db_general_settings: Final[_ConfigParamRow | None] = await _config_param_table(prisma_client).find_first( - where={"param_name": "general_settings"} - ) - ### pop the value + field_info: Final = ConfigGeneralSettings.model_fields[field_name] + field_default: JsonValue = _get_field_default(field_info) + settings: Final = proxy_config.settings + db_values: Mapping[str, JsonValue] + if prisma_client is None: + db_values = {} + else: + db_general_settings: Final[_ConfigParamRow | None] = await _config_param_table(prisma_client).find_first( + where={"param_name": "general_settings"} + ) + db_values = ( + dict(db_general_settings.param_value) + if db_general_settings is not None and db_general_settings.param_value is not None + else {} + ) + settings.apply_db_row("general_settings", db_values) - if db_general_settings is None or db_general_settings.param_value is None: + if field_name not in settings and field_default is None: raise HTTPException( status_code=400, detail={"error": f"Field name={field_name} not in DB"}, ) - else: - general_settings = dict(db_general_settings.param_value) - if field_name in general_settings: - field_value = _redact_general_setting_value( - field_name, - general_settings[field_name], - user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN, - ) - if field_name == "plugins" and isinstance(field_value, list): - field_value = [ - ({k: ("***" if k == "plugin_key" else v) for k, v in p.items()} if isinstance(p, dict) else p) - for p in field_value - ] - return ConfigFieldInfo(field_name=field_name, field_value=field_value) - else: - raise HTTPException( - status_code=400, - detail={"error": f"Field name={field_name} not in DB"}, - ) + redacted_field_value: Final = _redact_general_setting_value( + field_name, + settings.get(field_name, field_default), + user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN, + ) + field_value: Final = ( + [ + ({k: ("***" if k == "plugin_key" else v) for k, v in p.items()} if isinstance(p, dict) else p) + for p in redacted_field_value + ] + if field_name == "plugins" and isinstance(redacted_field_value, list) + else redacted_field_value + ) + return ConfigFieldInfo( + field_name=field_name, + field_value=field_value, + source=source_for(settings, field_name, field_default), + ) GeneralSettingsUILiteLLMValue = float | bool | str | None @@ -17600,7 +17605,7 @@ async def get_config_list( """ List the available fields + current values for a given type of setting (currently just 'general_settings'user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),) """ - global prisma_client, general_settings + global prisma_client ## VALIDATION ## """ @@ -17627,10 +17632,16 @@ async def get_config_list( where={"param_name": "general_settings"} ) - if db_general_settings is not None and db_general_settings.param_value is not None: - db_general_settings_dict: Mapping[str, JsonValue] = dict(db_general_settings.param_value) - else: - db_general_settings_dict = {} + db_general_settings_dict: Final[Mapping[str, JsonValue]] = ( + dict(db_general_settings.param_value) + if db_general_settings is not None and db_general_settings.param_value is not None + else {} + ) + settings: Final = proxy_config.settings + settings.apply_db_row("general_settings", db_general_settings_dict) + runtime_settings: Final[Mapping[str, JsonValue]] = ( + cast(Mapping[str, JsonValue], general_settings) if not isinstance(general_settings, SettingsStore) else settings + ) allowed_args: Final = _GENERAL_SETTINGS_CONFIG_LIST_FIELD_TYPES @@ -17638,6 +17649,7 @@ async def get_config_list( for field_name, field_info in ConfigGeneralSettings.model_fields.items(): if field_name in allowed_args: + field_default: JsonValue = _get_field_default(field_info) ## HANDLE TYPED DICT typed_dict_type = allowed_args[field_name] @@ -17657,10 +17669,11 @@ async def get_config_list( field_description="", # Add custom logic if descriptions are available field_default_value=_redact_general_setting_value( sub_field, - general_settings.get(sub_field, None), + runtime_settings.get(sub_field, None), is_full_admin, ), stored_in_db=None, + source=source_for(settings, field_name), ) for sub_field, sub_field_type in pydantic_class.__annotations__.items() ] @@ -17677,7 +17690,7 @@ async def get_config_list( _stored_in_db = None if field_name in db_general_settings_dict: _stored_in_db = True - elif field_name in general_settings: + elif field_name in runtime_settings: _stored_in_db = False _response_obj = ConfigList( @@ -17686,11 +17699,12 @@ async def get_config_list( field_description=field_info.description or "", field_value=_redact_general_setting_value( field_name, - general_settings.get(field_name, None), + runtime_settings.get(field_name, field_default), is_full_admin, ), stored_in_db=_stored_in_db, - field_default_value=field_info.default, + source=source_for(settings, field_name, field_default), + field_default_value=field_default, nested_fields=nested_fields, ) return_val.append(_response_obj) @@ -17701,12 +17715,10 @@ async def get_config_list( _stored_in_db = None if field_name in db_general_settings_dict: _stored_in_db = True - elif field_name in general_settings: + elif field_name in runtime_settings: _stored_in_db = False - _field_value = general_settings.get(field_name, None) - if _field_value is None and field_name in db_general_settings_dict: - _field_value = db_general_settings_dict[field_name] + _field_value: JsonValue = runtime_settings.get(field_name, field_default) _response_obj = ConfigList( field_name=field_name, @@ -17714,7 +17726,8 @@ async def get_config_list( field_description=field_info.description or "", field_value=_redact_general_setting_value(field_name, _field_value, is_full_admin), stored_in_db=_stored_in_db, - field_default_value=field_info.default, + source=source_for(settings, field_name, field_default), + field_default_value=field_default, nested_fields=nested_fields, ) return_val.append(_response_obj) @@ -17722,18 +17735,24 @@ async def get_config_list( db_litellm_settings_row: Final[_ConfigParamRow | None] = await _config_param_table(prisma_client).find_first( where={"param_name": "litellm_settings"} ) - db_litellm_settings: Final[dict] = ( + db_litellm_settings: Final[Mapping[str, JsonValue]] = ( dict(db_litellm_settings_row.param_value) if db_litellm_settings_row is not None and db_litellm_settings_row.param_value is not None else {} ) + litellm_settings_store: Final = proxy_config.litellm_settings + litellm_settings_store.apply_db_row("litellm_settings", db_litellm_settings) for litellm_field_name, spec in _GENERAL_SETTINGS_UI_LITELLM_FIELDS.items(): - current_value: GeneralSettingsUILiteLLMValue = getattr(litellm, litellm_field_name, None) - default_value = _general_settings_ui_litellm_default(spec) + default_value: GeneralSettingsUILiteLLMValue = _general_settings_ui_litellm_default(spec) + current_value: GeneralSettingsUILiteLLMValue = cast( + GeneralSettingsUILiteLLMValue, + litellm_settings_store.get(litellm_field_name, default_value), + ) + source = source_for(litellm_settings_store, litellm_field_name, default_value) stored_in_db_litellm: bool | None if litellm_field_name in db_litellm_settings: stored_in_db_litellm = True - elif current_value != default_value: + elif source == "config": stored_in_db_litellm = False else: stored_in_db_litellm = None @@ -17744,6 +17763,7 @@ async def get_config_list( field_description=spec["description"], field_value=current_value, stored_in_db=stored_in_db_litellm, + source=source, field_default_value=default_value, field_options=list(spec.get("options", ())) or None, field_tab=spec.get("tab"), diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index fd160636d46..4cd031b8780 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -14,8 +14,8 @@ from typing import ( from urllib.parse import urlparse from fastapi import APIRouter, Body, Depends, File, HTTPException, UploadFile -from pydantic import ConfigDict, JsonValue, TypeAdapter, ValidationError, create_model -from pydantic.fields import FieldInfo +from pydantic import BaseModel, ConfigDict, JsonValue, TypeAdapter, ValidationError, create_model +from pydantic.fields import FieldInfo, PydanticUndefined from typing_extensions import NotRequired, ReadOnly, TypedDict import litellm @@ -24,6 +24,7 @@ from litellm.litellm_core_utils.sensitive_data_masker import mask_sensitive_keys from litellm.proxy._experimental.mcp_server.tool_search import MCP_TOOL_SEARCH_SETTINGS_KEY from litellm.proxy._types import * from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.config_resolvers import SettingsSource, source_for from litellm.proxy.config_resolvers.sso import ( SSO_FIELD_ENV_VARS, SSO_SECRET_FIELDS, @@ -197,6 +198,11 @@ class SettingsResponse(BaseModel): """Schema information including descriptions and property types for UI display""" +class _SettingsWithSchema(BaseModel): + values: dict[str, object] + field_schema: dict[str, object] + + class SSOSettingsResponse(SettingsResponse): """Response model for SSO settings""" @@ -327,6 +333,8 @@ class UISettings(BaseModel): class UISettingsResponse(SettingsResponse): """Response model for UI settings""" + source: dict[str, SettingsSource] + # Allowlist of UI settings that can be stored ALLOWED_UI_SETTINGS_FIELDS: Final = { @@ -658,6 +666,13 @@ def _root_schema(settings_class: type[BaseModel]) -> _RootSchema: ) +def _model_field_default(settings_class: type[BaseModel], field_name: str) -> object: + field_info: Final = settings_class.model_fields.get(field_name) + if field_info is None or field_info.default is PydanticUndefined: + return None + return cast(object, field_info.default) + + async def _get_settings_with_schema( settings_key: str, settings_class: type[BaseModel], @@ -1527,7 +1542,7 @@ async def get_ui_settings(): Get UI-specific configuration flags. All authenticated users can fetch these settings for client-side behavior. """ - from litellm.proxy.proxy_server import prisma_client + from litellm.proxy.proxy_server import prisma_client, proxy_config if prisma_client is None: raise HTTPException( @@ -1546,26 +1561,46 @@ async def get_ui_settings(): ui_settings: Final = {k: v for k, v in parsed.items() if k in ALLOWED_UI_SETTINGS_FIELDS} apply_runtime_general_settings_flags(ui_settings) + proxy_config.settings.apply_db_row("ui_settings", ui_settings) # Refresh DualCache so other code paths (e.g. /user/filter/ui) see fresh values from litellm.proxy.proxy_server import user_api_key_cache await user_api_key_cache.async_set_cache(key=UI_SETTINGS_CACHE_KEY, value=ui_settings, ttl=UI_SETTINGS_CACHE_TTL) - # Build config-like object for schema helper - config: Final[dict[str, object]] = {"litellm_settings": {"ui_settings": ui_settings}} - - settings: Final = await _get_settings_with_schema( - settings_key="ui_settings", - settings_class=_get_effective_ui_settings_class(), - config=config, + effective_ui_settings: Final = { + **{key: proxy_config.settings[key] for key in ALLOWED_UI_SETTINGS_FIELDS if key in proxy_config.settings}, + **ui_settings, + } + config: Final[dict[str, object]] = {"litellm_settings": {"ui_settings": effective_ui_settings}} + settings_class: Final = _get_effective_ui_settings_class() + resolved_settings: Final = _SettingsWithSchema.model_validate( + await _get_settings_with_schema( + settings_key="ui_settings", + settings_class=settings_class, + config=config, + ) ) + values: Final = { + **resolved_settings.values, + ENABLE_PTU_COST_ATTRIBUTION_UI_SETTING: is_ptu_cost_attribution_enabled(), + } + source: Final[dict[str, SettingsSource]] = { + key: ( + "db" + if key in ui_settings + else source_for( + proxy_config.settings, + key, + _model_field_default(settings_class, key), + ) + ) + for key in values + } return UISettingsResponse( - values={ - **settings["values"], - ENABLE_PTU_COST_ATTRIBUTION_UI_SETTING: is_ptu_cost_attribution_enabled(), - }, - field_schema=settings["field_schema"], + values=values, + field_schema=resolved_settings.field_schema, + source=source, ) diff --git a/tests/test_litellm/proxy/management_endpoints/test_router_settings_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_router_settings_endpoints.py index 308f4d88f02..148f30f517b 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_router_settings_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_router_settings_endpoints.py @@ -75,6 +75,36 @@ class TestRouterSettingsEndpoints: assert isinstance(routing_strategy_field["options"], list) assert len(routing_strategy_field["options"]) > 0 + @pytest.mark.asyncio + async def test_get_router_settings_reports_sources(self, monkeypatch): + from litellm.proxy.config_resolvers import SettingsStore + + store = SettingsStore("router_settings") + store.load_yaml({"routing_strategy": "simple-shuffle"}) + store.apply_db_row("router_settings", {"num_retries": 3}) + monkeypatch.setattr(proxy_server.proxy_config, "router_settings", store) + monkeypatch.setattr(proxy_server, "llm_router", None) + + async def fake_get_config(self, config_file_path=None): + return { + "router_settings": { + "routing_strategy": "simple-shuffle", + "num_retries": 3, + } + } + + monkeypatch.setattr( + proxy_server.ProxyConfig, "get_config", fake_get_config, raising=True + ) + + admin_user = UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-x" + ) + response = await get_router_settings(user_api_key_dict=admin_user) + + assert response.source["routing_strategy"] == "config" + assert response.source["num_retries"] == "db" + @pytest.mark.asyncio async def test_get_router_settings_includes_routing_groups_from_live_router( self, monkeypatch diff --git a/tests/test_litellm/proxy/proxy_server/test_routes_config.py b/tests/test_litellm/proxy/proxy_server/test_routes_config.py index dd3914e3ad5..d6c63ec9a78 100644 --- a/tests/test_litellm/proxy/proxy_server/test_routes_config.py +++ b/tests/test_litellm/proxy/proxy_server/test_routes_config.py @@ -15,10 +15,15 @@ from __future__ import annotations import asyncio import json +from collections.abc import Mapping +from typing import Final from unittest.mock import AsyncMock, MagicMock import pytest +from litellm.proxy.config_resolvers import SettingsStore +from litellm.proxy.config_resolvers.settings_rules import JsonValue + from .conftest import VOLATILE_KEYS, normalize @@ -37,6 +42,21 @@ def _install_litellm_config(mock_prisma: MagicMock) -> MagicMock: return table +def _install_settings_store( + monkeypatch: pytest.MonkeyPatch, + config_values: Mapping[str, JsonValue], + db_values: Mapping[str, JsonValue], +) -> SettingsStore: + from litellm.proxy import proxy_server + + store: Final = SettingsStore("general_settings") + store.load_yaml(config_values) + store.apply_db_row("general_settings", db_values) + monkeypatch.setattr(proxy_server.proxy_config, "settings", store) + monkeypatch.setattr(proxy_server, "general_settings", store) + return store + + # --------------------------------------------------------------------------- # POST /config/update # --------------------------------------------------------------------------- @@ -338,6 +358,7 @@ def test_config_field_info_happy_admin(client, auth_as, mock_prisma, monkeypatch assert normalize(response.json()) == { "field_name": "max_parallel_requests", "field_value": 7, + "source": "db", } @@ -566,6 +587,122 @@ def test_config_list_happy_admin(client, auth_as, mock_prisma, monkeypatch): } +def test_config_read_routes_report_effective_values_and_sources(client, auth_as, mock_prisma, monkeypatch): + from litellm.proxy import proxy_server as ps + from litellm.proxy._types import LitellmUserRoles + + table = _install_litellm_config(mock_prisma) + row = MagicMock() + row.param_value = {"max_parallel_requests": 7, "max_file_size_mb": 222} + table.find_first = AsyncMock(return_value=row) + monkeypatch.setattr(ps, "prisma_client", mock_prisma) + _install_settings_store( + monkeypatch, + { + "max_parallel_requests": 5, + "max_file_size_mb": 111, + "pass_through_endpoints": [{"path": "/synthetic"}], + }, + {"max_parallel_requests": 7, "max_file_size_mb": 222}, + ) + + with auth_as(LitellmUserRoles.PROXY_ADMIN): + list_response = client.get("/config/list", params={"config_type": "general_settings"}) + config_only_response = client.get( + "/config/field/info", params={"field_name": "max_file_size_mb"} + ) + db_wins_response = client.get( + "/config/field/info", params={"field_name": "max_parallel_requests"} + ) + + assert list_response.status_code == 200 + by_name: Final = {entry["field_name"]: entry for entry in list_response.json()} + assert by_name["max_file_size_mb"]["field_value"] == 111 + assert by_name["max_file_size_mb"]["source"] == "config" + assert by_name["pass_through_endpoints"]["source"] == "config" + assert by_name["pass_through_endpoints"]["nested_fields"][0]["source"] == "config" + assert by_name["max_parallel_requests"]["field_value"] == 7 + assert by_name["max_parallel_requests"]["source"] == "db" + + assert config_only_response.status_code == 200 + assert config_only_response.json() == { + "field_name": "max_file_size_mb", + "field_value": 111, + "source": "config", + } + assert db_wins_response.status_code == 200 + assert db_wins_response.json() == { + "field_name": "max_parallel_requests", + "field_value": 7, + "source": "db", + } + + +def test_config_read_routes_report_default_source(client, auth_as, mock_prisma, monkeypatch): + from litellm.proxy import proxy_server as ps + from litellm.proxy._types import LitellmUserRoles + + table = _install_litellm_config(mock_prisma) + row = MagicMock() + row.param_value = {} + table.find_first = AsyncMock(return_value=row) + monkeypatch.setattr(ps, "prisma_client", mock_prisma) + _install_settings_store(monkeypatch, {}, {}) + + with auth_as(LitellmUserRoles.PROXY_ADMIN): + list_response = client.get("/config/list", params={"config_type": "general_settings"}) + field_response = client.get( + "/config/field/info", params={"field_name": "proxy_config_reload_interval_seconds"} + ) + + assert list_response.status_code == 200 + by_name: Final = {entry["field_name"]: entry for entry in list_response.json()} + assert by_name["proxy_config_reload_interval_seconds"]["field_value"] == 30 + assert by_name["proxy_config_reload_interval_seconds"]["source"] == "default" + assert field_response.status_code == 200 + assert field_response.json() == { + "field_name": "proxy_config_reload_interval_seconds", + "field_value": 30, + "source": "default", + } + + +def test_config_field_info_uses_store_without_db(client, auth_as, monkeypatch): + from litellm.proxy import proxy_server as ps + from litellm.proxy._types import LitellmUserRoles + + monkeypatch.setattr(ps, "prisma_client", None) + _install_settings_store(monkeypatch, {"max_file_size_mb": 111}, {}) + + with auth_as(LitellmUserRoles.PROXY_ADMIN): + response = client.get("/config/field/info", params={"field_name": "max_file_size_mb"}) + + assert response.status_code == 200 + assert response.json() == { + "field_name": "max_file_size_mb", + "field_value": 111, + "source": "config", + } + + +def test_config_field_info_unset_source_remains_an_error(client, auth_as, mock_prisma, monkeypatch): + from litellm.proxy import proxy_server as ps + from litellm.proxy._types import LitellmUserRoles + + table = _install_litellm_config(mock_prisma) + row = MagicMock() + row.param_value = {} + table.find_first = AsyncMock(return_value=row) + monkeypatch.setattr(ps, "prisma_client", mock_prisma) + _install_settings_store(monkeypatch, {}, {}) + + with auth_as(LitellmUserRoles.PROXY_ADMIN): + response = client.get("/config/field/info", params={"field_name": "max_parallel_requests"}) + + assert response.status_code == 400 + assert "not in" in response.json()["detail"]["error"] + + def test_config_list_exposes_config_reload_interval(client, auth_as, mock_prisma, monkeypatch): """proxy_config_reload_interval_seconds must surface in the admin UI general-settings list as an Integer field defaulting to 30, so operators can tune multi-pod convergence diff --git a/tests/test_litellm/proxy/proxy_server/test_routes_model_metrics.py b/tests/test_litellm/proxy/proxy_server/test_routes_model_metrics.py index 246e2cbba54..6a57ad1636d 100644 --- a/tests/test_litellm/proxy/proxy_server/test_routes_model_metrics.py +++ b/tests/test_litellm/proxy/proxy_server/test_routes_model_metrics.py @@ -179,6 +179,44 @@ def test_model_settings_method_not_allowed(client, auth_as): # --------------------------------------------------------------------------- +def test_alerting_settings_reports_sources(client, auth_as, monkeypatch): + from litellm.proxy.config_resolvers import SettingsStore + + pc = MagicMock() + row = MagicMock() + row.param_value = {"alerting_args": {"daily_report_frequency": 7}} + pc.db.litellm_config.find_first = AsyncMock(return_value=row) + monkeypatch.setattr(proxy_server, "prisma_client", pc) + + logging_obj = MagicMock() + args_model = MagicMock() + args_model.model_dump = MagicMock(return_value={"daily_report_frequency": 7}) + logging_obj.slack_alerting_instance.alerting_args = args_model + monkeypatch.setattr(proxy_server, "proxy_logging_obj", logging_obj) + + store = SettingsStore("general_settings") + store.load_yaml( + { + "alerting": ["slack"], + "alerting_args": {"daily_report_frequency": 3}, + } + ) + store.apply_db_row( + "general_settings", + {"alerting_args": {"daily_report_frequency": 7}}, + ) + monkeypatch.setattr(proxy_server.proxy_config, "settings", store) + monkeypatch.setattr(proxy_server, "general_settings", store) + + with auth_as(LitellmUserRoles.PROXY_ADMIN): + response = client.get("/alerting/settings") + + assert response.status_code == 200 + by_name = {entry["field_name"]: entry for entry in response.json()} + assert by_name["slack_alerting"]["source"] == "config" + assert by_name["daily_report_frequency"]["source"] == "db" + + def test_alerting_settings_no_db_error(client, auth_as, no_prisma): """Pins ``GET /alerting/settings`` (error: db not connected).""" with auth_as(LitellmUserRoles.PROXY_ADMIN): diff --git a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py index 93fb54f84eb..faf5b336410 100644 --- a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py +++ b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py @@ -1342,6 +1342,45 @@ class TestProxySettingEndpoints: where={"id": "ui_settings"} ) + def test_get_ui_settings_reports_sources(self, monkeypatch): + from unittest.mock import AsyncMock, MagicMock + + from litellm.proxy import proxy_server + from litellm.proxy.config_resolvers import SettingsStore + + mock_prisma = MagicMock() + mock_db_record = MagicMock() + mock_db_record.ui_settings = { + "disable_model_add_for_internal_users": True, + } + mock_prisma.db.litellm_uisettings.find_unique = AsyncMock( + return_value=mock_db_record + ) + monkeypatch.setattr(proxy_server, "prisma_client", mock_prisma) + + store = SettingsStore("general_settings") + store.load_yaml( + { + "disable_model_add_for_internal_users": False, + "forward_client_headers_to_llm_api": True, + } + ) + store.apply_db_row( + "ui_settings", + {"disable_model_add_for_internal_users": True}, + ) + monkeypatch.setattr(proxy_server.proxy_config, "settings", store) + monkeypatch.setattr(proxy_server, "general_settings", store) + + response = client.get("/get/ui_settings") + + assert response.status_code == 200 + data = response.json() + assert data["values"]["disable_model_add_for_internal_users"] is True + assert data["values"]["forward_client_headers_to_llm_api"] is True + assert data["source"]["disable_model_add_for_internal_users"] == "db" + assert data["source"]["forward_client_headers_to_llm_api"] == "config" + def test_get_ui_settings_schema_description_preserved_with_extensions( self, mock_auth, monkeypatch ): diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index fd882937e79..790ddb93546 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -26393,6 +26393,12 @@ export interface components { field_name: string; /** Field Value */ field_value: unknown; + /** + * Source + * @default unset + * @enum {string} + */ + source: "config" | "db" | "default" | "unset"; }; /** ConfigFieldUpdate */ ConfigFieldUpdate: { @@ -26895,6 +26901,12 @@ export interface components { * @default false */ premium_field: boolean; + /** + * Source + * @default unset + * @enum {string} + */ + source: "config" | "db" | "default" | "unset"; /** Stored In Db */ stored_in_db: boolean | null; }; @@ -28286,6 +28298,12 @@ export interface components { field_name: string; /** Field Type */ field_type: string; + /** + * Source + * @default unset + * @enum {string} + */ + source: "config" | "db" | "default" | "unset"; /** Stored In Db */ stored_in_db: boolean | null; }; @@ -36439,6 +36457,13 @@ export interface components { routing_strategy_descriptions: { [key: string]: string; }; + /** + * Source + * @description Source of each current router setting + */ + source: { + [key: string]: "config" | "db" | "default" | "unset"; + }; }; /** * RoutingGroup @@ -38856,6 +38881,10 @@ export interface components { field_schema: { [key: string]: unknown; }; + /** Source */ + source: { + [key: string]: "config" | "db" | "default" | "unset"; + }; /** Values */ values: { [key: string]: unknown;