feat(proxy): report sources in config read endpoints

This commit is contained in:
Yuneng Jiang 2026-09-18 00:05:43 -07:00
parent d1cd869012
commit 5a8d1f5eca
No known key found for this signature in database
11 changed files with 450 additions and 94 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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