From 8dcf9e8b78509e0392eaef34abb43d2754e2a43f Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 18 Sep 2026 01:26:10 -0700 Subject: [PATCH] fix(proxy): correct settings source provenance --- litellm/proxy/_lazy_openapi_snapshot.json | 2 +- .../proxy/config_resolvers/settings_store.py | 20 ++++++ .../router_settings_endpoints.py | 25 ++++++- litellm/proxy/proxy_server.py | 65 +++++++++++++----- .../proxy_setting_endpoints.py | 30 ++++++--- .../config_resolvers/test_settings_store.py | 33 +++++++++ .../test_router_settings_endpoints.py | 2 + .../proxy/proxy_server/test_routes_config.py | 44 +++++++++--- .../proxy_server/test_routes_model_metrics.py | 67 ++++++++++++++++--- .../test_proxy_setting_endpoints.py | 42 ++++++++++++ 10 files changed, 280 insertions(+), 50 deletions(-) diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index b244678e201..fa046ef0a72 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -19346,7 +19346,7 @@ } } }, - "description": "\n Unified rate-limit error.\n\n Every rate-limit condition surfaced by litellm \u2014 whether it originated from\n an upstream LLM provider, a vendor batch endpoint, or one of litellm's own\n proxy-side limiters (parallel-requests, dynamic-rate, batch-rate, budget,\n max-iterations, etc.) \u2014 is raised as an instance of this class.\n\n The :attr:`category` attribute lets callers distinguish the source. See\n :class:`RateLimitErrorCategory` for the available values.\n " + "description": "\nUnified rate-limit error.\n\nEvery rate-limit condition surfaced by litellm \u2014 whether it originated from\nan upstream LLM provider, a vendor batch endpoint, or one of litellm's own\nproxy-side limiters (parallel-requests, dynamic-rate, batch-rate, budget,\nmax-iterations, etc.) \u2014 is raised as an instance of this class.\n\nThe :attr:`category` attribute lets callers distinguish the source. See\n:class:`RateLimitErrorCategory` for the available values.\n" }, "500": { "content": { diff --git a/litellm/proxy/config_resolvers/settings_store.py b/litellm/proxy/config_resolvers/settings_store.py index 8f400853fa9..73bca3222ea 100644 --- a/litellm/proxy/config_resolvers/settings_store.py +++ b/litellm/proxy/config_resolvers/settings_store.py @@ -28,6 +28,7 @@ class SettingsStore(MutableMapping[str, JsonValue]): self._yaml_values: Mapping[str, JsonValue] = _EMPTY_VALUES self._database_rows: Mapping[DbRow, Mapping[str, JsonValue]] = _EMPTY_ROWS self._runtime_values: Mapping[str, JsonValue] = _EMPTY_VALUES + self._runtime_sources: Mapping[str, FieldSource] = MappingProxyType({}) self._deleted_runtime_keys: frozenset[str] = frozenset() def load_yaml(self, mapping: Mapping[str, JsonValue]) -> None: @@ -39,11 +40,22 @@ class SettingsStore(MutableMapping[str, JsonValue]): self._database_rows = MappingProxyType({**self._database_rows, row: MappingProxyType(dict(db_row))}) self._clear_runtime_keys(frozenset((*previous_row, *db_row))) + def without_db(self) -> SettingsStore: + copy: Final = SettingsStore(self._section) + copy.load_yaml(self._yaml_values) + runtime_values: Final = { + key: value for key, value in self._runtime_values.items() if self._runtime_sources.get(key) != "db" + } + copy.apply_runtime_values(runtime_values) + copy._deleted_runtime_keys = self._deleted_runtime_keys + return copy + def resolved(self) -> Mapping[str, JsonValue]: return MappingProxyType(dict(self)) def apply_runtime_values(self, values: Mapping[str, JsonValue]) -> None: self._runtime_values = MappingProxyType(dict(values)) + self._runtime_sources = MappingProxyType({key: self.source(key) for key in values}) self._deleted_runtime_keys = frozenset() def source(self, key: str) -> FieldSource: @@ -61,6 +73,7 @@ class SettingsStore(MutableMapping[str, JsonValue]): def __setitem__(self, key: str, value: JsonValue) -> None: self._runtime_values = MappingProxyType({**self._runtime_values, key: value}) + self._runtime_sources = MappingProxyType({**self._runtime_sources, key: self.source(key)}) self._deleted_runtime_keys = self._deleted_runtime_keys - frozenset((key,)) def __delitem__(self, key: str) -> None: @@ -69,6 +82,9 @@ class SettingsStore(MutableMapping[str, JsonValue]): self._runtime_values = MappingProxyType( {key_: value for key_, value in self._runtime_values.items() if key_ != key} ) + self._runtime_sources = MappingProxyType( + {key_: source for key_, source in self._runtime_sources.items() if key_ != key} + ) self._deleted_runtime_keys = self._deleted_runtime_keys | frozenset((key,)) def __iter__(self) -> Iterator[str]: @@ -84,6 +100,7 @@ class SettingsStore(MutableMapping[str, JsonValue]): def _clear_runtime(self) -> None: self._runtime_values = _EMPTY_VALUES + self._runtime_sources = MappingProxyType({}) self._deleted_runtime_keys = frozenset() def _clear_runtime_keys(self, keys: frozenset[str]) -> None: @@ -92,6 +109,9 @@ class SettingsStore(MutableMapping[str, JsonValue]): self._runtime_values = MappingProxyType( {key: value for key, value in self._runtime_values.items() if key not in keys} ) + self._runtime_sources = MappingProxyType( + {key: source for key, source in self._runtime_sources.items() if key not in keys} + ) self._deleted_runtime_keys = self._deleted_runtime_keys - keys def _keys(self) -> tuple[str, ...]: diff --git a/litellm/proxy/management_endpoints/router_settings_endpoints.py b/litellm/proxy/management_endpoints/router_settings_endpoints.py index 5d3b6d40601..1869be88d1b 100644 --- a/litellm/proxy/management_endpoints/router_settings_endpoints.py +++ b/litellm/proxy/management_endpoints/router_settings_endpoints.py @@ -16,7 +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.proxy.config_resolvers import SettingsSource, SettingsStore, source_for from litellm.router import Router from litellm.types.management_endpoints import ( ROUTER_SETTINGS_FIELDS, @@ -41,6 +41,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, +) -> SettingsSource: + 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. @@ -116,10 +128,17 @@ async def get_router_settings( 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 + field.field_name: cast(object, field.field_default) # cast-ok: Pydantic field defaults are untyped + 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 + key: _router_setting_source( + proxy_config.router_settings, + key, + cast(object, current_values[key]), # cast-ok: current values are stored in a typed response map + field_defaults.get(key), + ) + for key in current_values } return RouterSettingsResponse( fields=router_fields, diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 1131d7bea86..3898f522feb 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -432,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, source_for +from litellm.proxy.config_resolvers import SettingsSource, SettingsStore, resolve_fields, source_for from litellm.proxy.config_resolvers.alerting import ( EMAIL_DESCRIPTORS, MS_TEAMS_DESCRIPTORS, @@ -4820,7 +4820,7 @@ def _as_settings_mapping(value: object) -> Mapping[str, SettingsJsonValue]: def _get_field_default(field_info: FieldInfo) -> JsonValue: if field_info.default is PydanticUndefined: return None - return cast(JsonValue, field_info.default) + return cast(JsonValue, field_info.default) # cast-ok: Pydantic field defaults are JSON values at runtime def _bind_general_settings_store(settings: SettingsStore) -> None: @@ -15605,6 +15605,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, +) -> SettingsSource: + db_value: Final = db_values.get(field_name) + if db_value is not None and db_value != []: + return "db" + parent_value: Final = settings.without_db().get(parent_key) + if isinstance(parent_value, Mapping) and field_name in parent_value: + return "config" + return "default" if field_default is not None else "unset" + + @router.get( "/alerting/settings", description="Return the configurable alerting param, description, and current value", @@ -15647,8 +15663,13 @@ async def alerting_settings( 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")) + alerting_args_value: Final = db_general_settings_dict.get("alerting_args") + alerting_args_dict: Final[Mapping[str, JsonValue]] = ( + 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 settings.apply_db_row("general_settings", db_general_settings_dict) @@ -15711,7 +15732,13 @@ async def alerting_settings( field_description=field_info.description or "", field_value=_slack_alerting_args_dict.get(field_name, field_default), stored_in_db=_stored_in_db, - source=source_for(settings, "alerting_args", field_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), ) @@ -17414,21 +17441,19 @@ async def get_config_general_settings( 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: + if prisma_client is not None: db_general_settings: Final[_ConfigParamRow | None] = await _config_param_table(prisma_client).find_first( where={"param_name": "general_settings"} ) - db_values = ( + db_values: 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.apply_db_row("general_settings", db_values) + effective_settings: Final = settings.without_db() if prisma_client is None else settings - if field_name not in settings and field_default is None: + if field_name not in effective_settings and field_default is None: raise HTTPException( status_code=400, detail={"error": f"Field name={field_name} not in DB"}, @@ -17436,7 +17461,7 @@ async def get_config_general_settings( redacted_field_value: Final = _redact_general_setting_value( field_name, - settings.get(field_name, field_default), + effective_settings.get(field_name, field_default), user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN, ) field_value: Final = ( @@ -17450,7 +17475,7 @@ async def get_config_general_settings( return ConfigFieldInfo( field_name=field_name, field_value=field_value, - source=source_for(settings, field_name, field_default), + source=source_for(effective_settings, field_name, field_default), ) @@ -17640,7 +17665,11 @@ async def get_config_list( 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 + cast( # cast-ok: legacy general_settings remains a mapping at this route boundary + Mapping[str, JsonValue], general_settings + ) + if not isinstance(general_settings, SettingsStore) + else settings ) allowed_args: Final = _GENERAL_SETTINGS_CONFIG_LIST_FIELD_TYPES @@ -17744,9 +17773,11 @@ async def get_config_list( litellm_settings_store.apply_db_row("litellm_settings", db_litellm_settings) for litellm_field_name, spec in _GENERAL_SETTINGS_UI_LITELLM_FIELDS.items(): default_value: GeneralSettingsUILiteLLMValue = _general_settings_ui_litellm_default(spec) - current_value: GeneralSettingsUILiteLLMValue = cast( - GeneralSettingsUILiteLLMValue, - litellm_settings_store.get(litellm_field_name, default_value), + current_value: GeneralSettingsUILiteLLMValue = ( + cast( # cast-ok: UI field defaults are validated by the field spec + 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 diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index 4cd031b8780..aba65719f9b 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -24,7 +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 import SettingsSource, SettingsStore, source_for from litellm.proxy.config_resolvers.sso import ( SSO_FIELD_ENV_VARS, SSO_SECRET_FIELDS, @@ -34,7 +34,10 @@ from litellm.proxy.management_endpoints.team_admin_field_permissions import ( SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS, 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 @@ -44,6 +47,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, @@ -670,7 +674,19 @@ def _model_field_default(settings_class: type[BaseModel], field_name: str) -> ob 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) + 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], +) -> SettingsSource: + 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( @@ -1587,13 +1603,7 @@ async def get_ui_settings(): } 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), - ) + "db" if key in ui_settings else _ui_setting_source(key, values[key], proxy_config.settings, settings_class) ) for key in values } diff --git a/tests/test_litellm/proxy/config_resolvers/test_settings_store.py b/tests/test_litellm/proxy/config_resolvers/test_settings_store.py index e41c852eacb..efff9ad24c8 100644 --- a/tests/test_litellm/proxy/config_resolvers/test_settings_store.py +++ b/tests/test_litellm/proxy/config_resolvers/test_settings_store.py @@ -82,6 +82,39 @@ def test_settings_store_keeps_unaffected_runtime_values_on_a_db_row_refresh() -> assert store.source("changed") == "db" +def test_settings_store_without_db_uses_yaml_without_mutating_runtime_values() -> None: + store: Final = SettingsStore("general_settings") + store.load_yaml({"max_parallel_requests": 5}) + store.apply_db_row("general_settings", {"max_parallel_requests": 7}) + store.apply_runtime_values({"max_parallel_requests": 7}) + + without_db: Final = store.without_db() + + assert without_db["max_parallel_requests"] == 5 + assert without_db.source("max_parallel_requests") == "config" + assert store["max_parallel_requests"] == 7 + assert store.source("max_parallel_requests") == "db" + + +def test_settings_store_without_db_preserves_non_db_runtime_values() -> None: + store: Final = SettingsStore("general_settings") + store.load_yaml({"max_parallel_requests": "os.environ/MAX_PARALLEL_REQUESTS"}) + store.apply_runtime_values({"max_parallel_requests": 7}) + + without_db: Final = store.without_db() + + assert without_db["max_parallel_requests"] == 7 + assert without_db.source("max_parallel_requests") == "config" + + +def test_settings_store_without_db_preserves_runtime_deletions() -> None: + store: Final = SettingsStore("general_settings") + store.load_yaml({"deleted": 1}) + del store["deleted"] + + assert "deleted" not in store.without_db() + + def test_settings_store_removes_only_runtime_values_affected_by_a_cleared_db_row() -> None: store: Final = SettingsStore("general_settings") store.load_yaml({"template": "os.environ/SETTING"}) 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 148f30f517b..3af7de62abe 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 @@ -146,6 +146,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 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 d6c63ec9a78..fdaa2476219 100644 --- a/tests/test_litellm/proxy/proxy_server/test_routes_config.py +++ b/tests/test_litellm/proxy/proxy_server/test_routes_config.py @@ -15,8 +15,11 @@ from __future__ import annotations import asyncio import json -from collections.abc import Mapping +from collections.abc import Callable, Mapping +from contextlib import AbstractContextManager from typing import Final + +from fastapi.testclient import TestClient from unittest.mock import AsyncMock, MagicMock import pytest @@ -362,6 +365,33 @@ def test_config_field_info_happy_admin(client, auth_as, mock_prisma, monkeypatch } +def test_config_field_info_clears_stale_db_source_without_connection( + client: TestClient, + auth_as: Callable[..., AbstractContextManager[None]], + monkeypatch: pytest.MonkeyPatch, +): + from litellm.proxy import proxy_server as ps + from litellm.proxy._types import LitellmUserRoles + + store = SettingsStore("general_settings") + store.load_yaml({"max_parallel_requests": 5}) + store.apply_db_row("general_settings", {"max_parallel_requests": 7}) + store.apply_runtime_values({"max_parallel_requests": 7}) + monkeypatch.setattr(ps.proxy_config, "settings", store) + monkeypatch.setattr(ps, "prisma_client", None) + + with auth_as(LitellmUserRoles.PROXY_ADMIN): + response = client.get("/config/field/info", params={"field_name": "max_parallel_requests"}) + + assert response.status_code == 200 + assert normalize(response.json()) == { + "field_name": "max_parallel_requests", + "field_value": 5, + "source": "config", + } + assert store["max_parallel_requests"] == 7 + + def test_config_field_info_non_admin_rejected(client, auth_as, mock_prisma, monkeypatch): """Non-admin (INTERNAL_USER) is denied — admin-view gate fires.""" from litellm.proxy import proxy_server as ps @@ -608,12 +638,8 @@ def test_config_read_routes_report_effective_values_and_sources(client, auth_as, 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"} - ) + 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()} @@ -651,9 +677,7 @@ def test_config_read_routes_report_default_source(client, auth_as, mock_prisma, 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"} - ) + 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()} 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 6a57ad1636d..97ca3b3dbbe 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 @@ -11,13 +11,17 @@ Pins (PR2): from __future__ import annotations +from collections.abc import Callable +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 .conftest import normalize # type: ignore[import-not-found] @@ -53,9 +57,7 @@ def test_model_streaming_metrics_happy(client, auth_as, prisma_with_query_raw): pin can rely on the exact response shape. """ with auth_as(): - response = client.get( - "/model/streaming_metrics", params={"_selected_model_group": "gpt-4"} - ) + response = client.get("/model/streaming_metrics", params={"_selected_model_group": "gpt-4"}) assert response.status_code == 200 assert normalize(response.json()) == {"data": [], "all_api_bases": []} @@ -94,9 +96,7 @@ def test_model_metrics_no_prisma_error(client, auth_as, no_prisma): # --------------------------------------------------------------------------- -def test_model_metrics_slow_responses_happy( - client, auth_as, prisma_with_query_raw, monkeypatch -): +def test_model_metrics_slow_responses_happy(client, auth_as, prisma_with_query_raw, monkeypatch): """Pins ``GET /model/metrics/slow_responses`` (happy: empty list).""" logging_obj = MagicMock() logging_obj.slack_alerting_instance.alerting_threshold = 30 @@ -184,7 +184,12 @@ def test_alerting_settings_reports_sources(client, auth_as, monkeypatch): pc = MagicMock() row = MagicMock() - row.param_value = {"alerting_args": {"daily_report_frequency": 7}} + row.param_value = { + "alerting_args": { + "daily_report_frequency": 7, + "report_check_interval": None, + } + } pc.db.litellm_config.find_first = AsyncMock(return_value=row) monkeypatch.setattr(proxy_server, "prisma_client", pc) @@ -198,12 +203,20 @@ def test_alerting_settings_reports_sources(client, auth_as, monkeypatch): store.load_yaml( { "alerting": ["slack"], - "alerting_args": {"daily_report_frequency": 3}, + "alerting_args": { + "daily_report_frequency": 3, + "report_check_interval": 300, + }, } ) store.apply_db_row( "general_settings", - {"alerting_args": {"daily_report_frequency": 7}}, + { + "alerting_args": { + "daily_report_frequency": 7, + "report_check_interval": None, + } + }, ) monkeypatch.setattr(proxy_server.proxy_config, "settings", store) monkeypatch.setattr(proxy_server, "general_settings", store) @@ -215,6 +228,42 @@ def test_alerting_settings_reports_sources(client, auth_as, monkeypatch): 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" + assert by_name["report_check_interval"]["source"] == "config" + assert by_name["budget_alert_ttl"]["source"] == "default" + + +@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, +): + 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" def test_alerting_settings_no_db_error(client, auth_as, no_prisma): 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 faf5b336410..d8308d7831f 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 @@ -3198,6 +3198,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 @@ -3209,6 +3210,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 + ): + 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 + ): + 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."""