Merge pull request #41788 from BerriAI/litellm_config_read_source

feat(proxy): report the source of alerting, UI and router settings on read
This commit is contained in:
yuneng-jiang 2026-09-21 17:02:18 -07:00 • committed by GitHub
commit 1f4b595072
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 493 additions and 41 deletions

View file

@ -5,6 +5,17 @@ from litellm.proxy.config_resolvers._descriptors import (
FieldSource,
resolve_fields,
)
from litellm.proxy.config_resolvers.settings_store import SettingsStore, config_ownership_message
from litellm.proxy.config_resolvers.settings_store import (
SettingsStore,
config_ownership_message,
source_for,
)
__all__ = ("FieldDescriptor", "FieldSource", "SettingsStore", "config_ownership_message", "resolve_fields")
__all__ = (
"FieldDescriptor",
"FieldSource",
"SettingsStore",
"config_ownership_message",
"resolve_fields",
"source_for",
)

View file

@ -176,3 +176,10 @@ class SettingsStore(MutableMapping[str, JsonValue]):
def _resolution_for(self, key: str) -> Resolved:
yaml_value: Final[SettingValue] = self._yaml_values.get(key, ABSENT)
return resolve(yaml_value, self._db_value(key))
def source_for(settings: SettingsStore, key: str, default: object = None) -> FieldSource:
source: Final = settings.source(key)
if source == "unset":
return "default" if default is not None else "unset"
return source

View file

@ -8,6 +8,8 @@ GET /router/fields - Get router settings field definitions without values (for U
"""
import inspect
from collections.abc import Mapping
from types import MappingProxyType
from typing import Any, Final, get_args
from fastapi import APIRouter, Depends
@ -16,6 +18,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 FieldSource, SettingsStore, source_for
from litellm.router import Router
from litellm.types.management_endpoints import (
ROUTER_SETTINGS_FIELDS,
@ -30,6 +33,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, FieldSource] = Field(description="Source of each current router setting")
class RouterFieldsResponse(BaseModel):
@ -39,6 +43,18 @@ class RouterFieldsResponse(BaseModel):
routing_strategy_descriptions: dict[str, str] = Field(description="Descriptions for each routing strategy option")
def _router_setting_source(
settings: SettingsStore,
key: str,
current_value: object,
field_default: object,
) -> FieldSource:
source: Final = source_for(settings, key, field_default)
if source != "unset":
return source
return "default" if current_value is not None else "unset"
def _get_routing_strategies_from_router_class() -> list[str]:
"""
Dynamically extract routing strategies from the Router class __init__ method.
@ -109,15 +125,29 @@ 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[Mapping[str, object]] = MappingProxyType(
{field.field_name: field.field_default for field in router_fields}
)
source: Final[Mapping[str, FieldSource]] = MappingProxyType(
{
key: _router_setting_source(
proxy_config.router_settings,
key,
current_values[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
@ -456,7 +457,13 @@ from litellm.proxy.common_utils.user_api_key_cache import (
project_spend_counter_key,
tag_cache_key,
)
from litellm.proxy.config_resolvers import SettingsStore, config_ownership_message, resolve_fields
from litellm.proxy.config_resolvers import (
FieldSource,
SettingsStore,
config_ownership_message,
resolve_fields,
source_for,
)
from litellm.proxy.config_resolvers.alerting import (
EMAIL_DESCRIPTORS,
MS_TEAMS_DESCRIPTORS,
@ -4951,6 +4958,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) # cast-ok: Pydantic field defaults are JSON values at runtime
def _bind_general_settings_store(settings: SettingsStore) -> None:
global general_settings
general_settings = settings # pyright: ignore[reportAssignmentType] # legacy global accepts mappings
@ -16006,6 +16019,22 @@ async def model_settings():
#### ALERTING MANAGEMENT ENDPOINTS ####
def _nested_setting_source(
settings: SettingsStore,
db_values: Mapping[str, JsonValue],
parent_key: str,
field_name: str,
field_default: JsonValue,
) -> FieldSource:
unset_source: Final[FieldSource] = "default" if field_default is not None else "unset"
parent_value: Final = settings.config_value(parent_key)
if isinstance(parent_value, Mapping) and field_name in parent_value:
return "config"
if settings.owned_by_config(parent_key):
return unset_source
return "db" if field_name in db_values else unset_source
@router.get(
"/alerting/settings",
description="Return the configurable alerting param, description, and current value",
@ -16043,17 +16072,20 @@ 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]] = MappingProxyType(
dict(db_general_settings.param_value) # mutable-ok: Prisma returns the JSON column as a plain dict
if db_general_settings is not None and db_general_settings.param_value is not None
else {}
)
alerting_args_value: Final = db_general_settings_dict.get("alerting_args")
alerting_args_dict: Final[Mapping[str, JsonValue]] = MappingProxyType(
alerting_args_value if isinstance(alerting_args_value, dict) else {}
)
alerting_values: Final = cast( # cast-ok: alerting is stored as a JSON list when present
list[JsonValue] | None, db_general_settings_dict.get("alerting")
)
settings: Final = proxy_config.settings
allowed_args: Final = MappingProxyType(
{
@ -16082,9 +16114,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",
@ -16092,6 +16124,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,
)
@ -16099,6 +16132,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
@ -16109,9 +16143,16 @@ 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=_nested_setting_source(
settings,
alerting_args_dict,
"alerting_args",
field_name,
field_default,
),
field_default_value=field_default,
premium_field=(True if field_name == "region_outage_alert_ttl" else False),
)
return_val.append(_response_obj)

View file

@ -15,8 +15,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
@ -25,6 +25,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 FieldSource, SettingsStore, source_for
from litellm.proxy.config_resolvers.settings_store import ConfigOwnedKeyError
from litellm.proxy.config_resolvers.sso import (
SSO_FIELD_ENV_VARS,
@ -35,7 +36,10 @@ from litellm.proxy.management_endpoints.team_admin_field_permissions import (
SUPPORTED_TEAM_ADMIN_PERMISSIONS,
TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING,
)
from litellm.proxy.spend_tracking.ptu_feature_flag import is_ptu_cost_attribution_enabled
from litellm.proxy.spend_tracking.ptu_feature_flag import (
PTU_COST_ATTRIBUTION_ENV_VAR,
is_ptu_cost_attribution_enabled,
)
from litellm.proxy.utils import invalidate_config_param
from litellm.repositories.config_repository import ConfigRepository
from litellm.repositories.organization_repository import OrganizationRepository
@ -45,6 +49,7 @@ from litellm.repositories.table_repositories import (
UISettingsRepository,
)
from litellm.repositories.team_repository import TeamRepository
from litellm.secret_managers.main import get_secret
from litellm.types.mcp import MCPToolSearchSettings
from litellm.types.proxy.management_endpoints.ui_sso import (
DefaultTeamSSOParams,
@ -199,6 +204,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"""
@ -330,6 +340,8 @@ class UISettings(BaseModel):
class UISettingsResponse(SettingsResponse):
"""Response model for UI settings"""
source: dict[str, FieldSource]
# Allowlist of UI settings that can be stored
ALLOWED_UI_SETTINGS_FIELDS: Final = {
@ -748,6 +760,25 @@ 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) # cast-ok: Pydantic field defaults are untyped
def _ui_setting_source(
key: str,
value: object,
settings: SettingsStore,
settings_class: type[BaseModel],
) -> FieldSource:
if key == ENABLE_PTU_COST_ATTRIBUTION_UI_SETTING:
configured_value: Final = get_secret(PTU_COST_ATTRIBUTION_ENV_VAR, None)
return "config" if configured_value is not None or value is True else "default"
return source_for(settings, key, _model_field_default(settings_class, key))
async def _get_settings_with_schema(
settings_key: str,
settings_class: type[BaseModel],
@ -1705,7 +1736,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(
@ -1730,20 +1761,43 @@ async def get_ui_settings():
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[Mapping[str, object]] = MappingProxyType(
{
**ui_settings,
**{key: proxy_config.settings[key] for key in ALLOWED_UI_SETTINGS_FIELDS if key in proxy_config.settings},
}
)
config: Final[Mapping[str, object]] = MappingProxyType(
{"litellm_settings": MappingProxyType({"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[Mapping[str, object]] = MappingProxyType(
{
**resolved_settings.values,
ENABLE_PTU_COST_ATTRIBUTION_UI_SETTING: is_ptu_cost_attribution_enabled(),
}
)
source: Final[Mapping[str, FieldSource]] = MappingProxyType(
{
key: (
_ui_setting_source(key, values[key], proxy_config.settings, settings_class)
if key in proxy_config.settings or key not in ui_settings
else "db"
)
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

@ -4,6 +4,8 @@ Tests for router settings management endpoints.
Tests the GET endpoints for router settings and router fields.
"""
from collections.abc import Mapping
from typing import Any, Final
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@ -15,12 +17,23 @@ from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.management_endpoints.router_settings_endpoints import (
get_router_settings,
)
from litellm.proxy.config_resolvers import SettingsStore
from litellm.proxy.proxy_server import app
from litellm.router import Router
client = TestClient(app)
class _StubProxyConfig:
def __init__(self, router_settings: SettingsStore, config_router_settings: Mapping[str, Any]) -> None:
self.router_settings: Final = router_settings
self._config_router_settings: Final = dict(config_router_settings)
async def get_config(self, config_file_path: str | None = None) -> dict[str, Any]:
del config_file_path
return {"router_settings": dict(self._config_router_settings)}
class TestRouterSettingsEndpoints:
"""Test suite for router settings endpoints"""
@ -75,6 +88,31 @@ 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: pytest.MonkeyPatch
) -> None:
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",
_StubProxyConfig(
store,
{"routing_strategy": "simple-shuffle", "num_retries": 3},
),
)
monkeypatch.setattr(proxy_server, "llm_router", None)
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
@ -102,12 +140,10 @@ class TestRouterSettingsEndpoints:
)
monkeypatch.setattr(proxy_server, "llm_router", llm_router)
async def fake_get_config(self, config_file_path=None):
return {}
monkeypatch.setattr(
proxy_server.ProxyConfig, "get_config", fake_get_config, raising=True
proxy_server,
"proxy_config",
_StubProxyConfig(SettingsStore("router_settings"), {}),
)
admin_user = UserAPIKeyAuth(
@ -116,6 +152,8 @@ class TestRouterSettingsEndpoints:
response = await get_router_settings(user_api_key_dict=admin_user)
assert response.current_values.get("routing_groups") == groups
assert response.current_values["timeout"] is not None
assert response.source["timeout"] == "default"
rg_field = next(f for f in response.fields if f.field_name == "routing_groups")
assert rg_field.field_value == groups

View file

@ -11,13 +11,18 @@ Pins (PR2):
from __future__ import annotations
from collections.abc import Callable, Mapping
from contextlib import AbstractContextManager
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi.testclient import TestClient
import litellm
from litellm.proxy import proxy_server
from litellm.proxy._types import LitellmUserRoles
from litellm.proxy.config_resolvers.settings_rules import JsonValue
from litellm.proxy.config_resolvers.settings_store import SettingsStore
from .conftest import normalize # type: ignore[import-not-found]
@ -179,6 +184,177 @@ def test_model_settings_method_not_allowed(client, auth_as):
# ---------------------------------------------------------------------------
def _alerting_client(
monkeypatch: pytest.MonkeyPatch,
*,
yaml_values: Mapping[str, JsonValue],
db_row: Mapping[str, JsonValue],
live_args: Mapping[str, JsonValue],
) -> "SettingsStore":
pc = MagicMock()
row = MagicMock()
row.param_value = db_row
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=live_args)
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(yaml_values)
store.apply_db_row("general_settings", db_row)
monkeypatch.setattr(proxy_server.proxy_config, "settings", store)
monkeypatch.setattr(proxy_server, "general_settings", store)
return store
def test_alerting_settings_reports_sources(
client: TestClient,
auth_as: Callable[..., AbstractContextManager[None]],
monkeypatch: pytest.MonkeyPatch,
) -> None:
_alerting_client(
monkeypatch,
yaml_values={
"alerting": ["slack"],
"alerting_args": {"daily_report_frequency": 3, "report_check_interval": 300},
},
db_row={"alerting_args": {"daily_report_frequency": 7, "outage_alert_ttl": 4242}},
live_args={"daily_report_frequency": 3},
)
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"] == "config"
assert by_name["report_check_interval"]["source"] == "config"
assert by_name["outage_alert_ttl"]["source"] == "default"
assert by_name["budget_alert_ttl"]["source"] == "default"
def test_alerting_settings_reports_db_source_when_the_file_omits_alerting_args(
client: TestClient,
auth_as: Callable[..., AbstractContextManager[None]],
monkeypatch: pytest.MonkeyPatch,
) -> None:
store = _alerting_client(
monkeypatch,
yaml_values={"alerting": ["slack"]},
db_row={
"alerting_args": {
"outage_alert_ttl": 4242,
"region_outage_alert_ttl": [],
"report_check_interval": None,
}
},
live_args={"outage_alert_ttl": 4242},
)
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 store.source("alerting_args") == "db"
assert by_name["outage_alert_ttl"]["source"] == "db"
assert by_name["region_outage_alert_ttl"]["source"] == "db"
assert by_name["report_check_interval"]["source"] == "db"
assert by_name["budget_alert_ttl"]["source"] == "default"
def test_alerting_settings_reports_config_source_when_db_disagrees(
client: TestClient,
auth_as: Callable[..., AbstractContextManager[None]],
monkeypatch: pytest.MonkeyPatch,
) -> None:
from litellm.proxy.config_resolvers import SettingsStore
db_alerting_args = {"daily_report_frequency": 7}
pc = MagicMock()
row = MagicMock()
row.param_value = {"alerting_args": db_alerting_args}
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": 3})
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_args": {"daily_report_frequency": 3}})
store.apply_db_row("general_settings", {"alerting_args": db_alerting_args})
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 store.source("alerting_args") == "config"
assert by_name["daily_report_frequency"]["field_value"] == 3
assert by_name["daily_report_frequency"]["source"] == "config"
@pytest.mark.parametrize("db_alerting_args", [None, []])
def test_alerting_settings_handles_empty_db_args(
client: TestClient,
auth_as: Callable[..., AbstractContextManager[None]],
monkeypatch: pytest.MonkeyPatch,
db_alerting_args: JsonValue,
) -> None:
from litellm.proxy.config_resolvers import SettingsStore
pc = MagicMock()
row = MagicMock()
row.param_value = {"alerting_args": db_alerting_args}
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={})
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_args": {"report_check_interval": 300}})
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["report_check_interval"]["source"] == "config"
assert by_name["budget_alert_ttl"]["source"] == "default"
@pytest.mark.parametrize(
("field_default", "expected"),
[(43200, "default"), (None, "unset")],
)
def test_nested_setting_source_without_a_config_or_db_value(field_default: JsonValue, expected: str) -> None:
store = SettingsStore("general_settings")
store.load_yaml({})
assert (
proxy_server._nested_setting_source(store, {}, "alerting_args", "budget_alert_ttl", field_default) == expected
)
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,48 @@ class TestProxySettingEndpoints:
where={"id": "ui_settings"}
)
def test_get_ui_settings_reports_sources(self, monkeypatch: pytest.MonkeyPatch) -> None:
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,
"require_auth_for_public_ai_hub": 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 False
assert data["values"]["forward_client_headers_to_llm_api"] is True
assert data["values"]["require_auth_for_public_ai_hub"] is True
assert data["source"]["disable_model_add_for_internal_users"] == "config"
assert data["source"]["forward_client_headers_to_llm_api"] == "config"
assert data["source"]["require_auth_for_public_ai_hub"] == "db"
def test_get_ui_settings_schema_description_preserved_with_extensions(
self, mock_auth, monkeypatch
):
@ -3477,6 +3519,7 @@ class TestPtuCostAttributionUISetting:
assert response.status_code == 200
assert response.json()["values"]["enable_ptu_cost_attribution"] is False
assert response.json()["source"]["enable_ptu_cost_attribution"] == "default"
def test_reported_true_once_the_env_var_is_set(self, mock_auth, monkeypatch):
from litellm.proxy.spend_tracking.ptu_feature_flag import PTU_COST_ATTRIBUTION_ENV_VAR
@ -3488,6 +3531,47 @@ class TestPtuCostAttributionUISetting:
assert response.status_code == 200
assert response.json()["values"]["enable_ptu_cost_attribution"] is True
assert response.json()["source"]["enable_ptu_cost_attribution"] == "config"
def test_reported_config_when_secret_manager_enables_the_flag(
self, mock_auth: None, monkeypatch: pytest.MonkeyPatch
) -> None:
from litellm.proxy.spend_tracking.ptu_feature_flag import PTU_COST_ATTRIBUTION_ENV_VAR
monkeypatch.delenv(PTU_COST_ATTRIBUTION_ENV_VAR, raising=False)
monkeypatch.setattr(
"litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints.is_ptu_cost_attribution_enabled",
lambda: True,
)
self._mock_prisma(monkeypatch)
response = client.get("/get/ui_settings")
assert response.status_code == 200
assert response.json()["values"]["enable_ptu_cost_attribution"] is True
assert response.json()["source"]["enable_ptu_cost_attribution"] == "config"
def test_reported_config_when_secret_manager_disables_the_flag(
self, mock_auth: None, monkeypatch: pytest.MonkeyPatch
) -> None:
from litellm.proxy.spend_tracking.ptu_feature_flag import PTU_COST_ATTRIBUTION_ENV_VAR
monkeypatch.delenv(PTU_COST_ATTRIBUTION_ENV_VAR, raising=False)
monkeypatch.setattr(
"litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints.is_ptu_cost_attribution_enabled",
lambda: False,
)
monkeypatch.setattr(
"litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints.get_secret",
lambda *_args: False,
)
self._mock_prisma(monkeypatch)
response = client.get("/get/ui_settings")
assert response.status_code == 200
assert response.json()["values"]["enable_ptu_cost_attribution"] is False
assert response.json()["source"]["enable_ptu_cost_attribution"] == "config"
def test_a_persisted_true_cannot_forge_the_derived_value(self, mock_auth, monkeypatch):
"""A row written before the allowlist existed must not be able to turn the feature on."""

View file

@ -37245,6 +37245,13 @@ export interface components {
routing_strategy_descriptions: {
[key: string]: string;
};
/**
* Source
* @description Source of each current router setting
*/
source: {
[key: string]: "config" | "db" | "env" | "default" | "unset";
};
};
/**
* RoutingGroup
@ -39708,6 +39715,10 @@ export interface components {
field_schema: {
[key: string]: unknown;
};
/** Source */
source: {
[key: string]: "config" | "db" | "env" | "default" | "unset";
};
/** Values */
values: {
[key: string]: unknown;