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