mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
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:
commit
1f4b595072
9 changed files with 493 additions and 41 deletions
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
11
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
11
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue