fix(proxy): correct settings source provenance

This commit is contained in:
Yuneng Jiang 2026-09-18 01:26:10 -07:00
parent 5a8d1f5eca
commit 8dcf9e8b78
No known key found for this signature in database
10 changed files with 280 additions and 50 deletions

View file

@ -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": {

View file

@ -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, ...]:

View file

@ -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,

View file

@ -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

View file

@ -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
}

View file

@ -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"})

View file

@ -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

View file

@ -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()}

View file

@ -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):

View file

@ -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."""