mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-25 01:02:15 +00:00
fix(proxy): correct settings source provenance
This commit is contained in:
parent
5a8d1f5eca
commit
8dcf9e8b78
10 changed files with 280 additions and 50 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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, ...]:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue