refactor(proxy): make the config file win over the database

The precedence used to vary per key: some keys let a stored row win, some
let the file win, some merged the two. That meant an operator could not
answer "which value is live?" without knowing the key.

Now file presence decides ownership. A key the config file declares is
config-owned, whatever the database holds, and a key the file omits falls
back to the stored row. KeyRule no longer carries a RuleKind, only which
row the stored value lives in.

Writes to a config-owned key are refused at the two surfaces that reach
the database instead of being stored and silently ignored: save_config
and /config/field/update both 400 naming the key and the config file path.

Both read endpoints now report source and editable off the same
SettingsStore, so /config/field/info and /config/list can no longer
disagree inside one process.

Replaces the 786-case checked-in JSON fixture with cases generated from
the rule table, so the matrix tests no longer assert that resolve() agrees
with a snapshot of resolve().

BREAKING CHANGE: a dashboard or /config/field/update write to a setting
the config file declares now returns 400 instead of being stored. Remove
the key from the config file to let the database own it.
This commit is contained in:
Yuneng Jiang 2026-09-18 01:28:21 -07:00
parent a7d4f7c521
commit afa4a6fe78
No known key found for this signature in database
11 changed files with 384 additions and 17045 deletions

View file

@ -2423,6 +2423,8 @@ class ConfigList(LiteLLMPydanticObjectBase):
nested_fields: list[FieldDetail] | None = None # For nested dictionary or Pydantic fields
field_options: list[str] | None = None # Allowed values, for field_type == "Select"
field_tab: str | None = None # Admin UI sub-tab this field renders under; None groups it with the rest
source: str = "unset"
editable: bool = True
class UserHeaderMapping(LiteLLMPydanticObjectBase):
@ -3693,6 +3695,8 @@ class InvitationClaim(LiteLLMPydanticObjectBase):
class ConfigFieldInfo(LiteLLMPydanticObjectBase):
field_name: str
field_value: Any
source: str = "unset"
editable: bool = True
class CallbackOnUI(LiteLLMPydanticObjectBase):

View file

@ -5,10 +5,6 @@ from dataclasses import dataclass
from types import MappingProxyType
from typing import Final, Literal, TypeAlias
from typing_extensions import (
assert_never,
)
from litellm.proxy.config_resolvers._descriptors import FieldSource
JsonValue: TypeAlias = None | bool | int | float | str | list["JsonValue"] | dict[str, "JsonValue"]
@ -20,14 +16,6 @@ Section: TypeAlias = Literal[
"ui_settings",
]
DbRow: TypeAlias = Section
RuleKind: TypeAlias = Literal[
"db_wins",
"config_wins",
"db_fallback_to_config",
"list_union",
"merge_by_path",
"db_overlay",
]
@dataclass(frozen=True, slots=True)
@ -41,8 +29,9 @@ SettingValue: TypeAlias = JsonValue | Absent
@dataclass(frozen=True, slots=True)
class KeyRule:
"""Which stored row carries this key. Precedence no longer varies per key."""
db_row: DbRow
kind: RuleKind
@dataclass(frozen=True, slots=True)
@ -51,36 +40,6 @@ class Resolved:
source: FieldSource
_DB_GENERAL_SETTINGS: Final[tuple[str, ...]] = (
"max_parallel_requests",
"global_max_parallel_requests",
"alerting_args",
"ui_access_mode",
"disable_auto_add_proxy_admin_to_teams",
"store_model_in_db",
"maximum_spend_logs_retention_period",
"maximum_autorouter_session_retention_period",
"maximum_health_check_retention_period",
"user_url_validation",
"user_url_allowed_hosts",
"provider_url_destination_allowed_hosts",
)
_CONFIG_GENERAL_SETTINGS: Final[tuple[str, ...]] = (
"max_batch_file_size_mb",
"max_file_size_mb",
"allowed_file_extensions",
"blocked_file_extensions",
"store_prompts_in_spend_logs",
"apply_user_budget_to_team_keys",
"enable_openai_websocket_passthrough",
"user_api_key_cache_max_size",
)
_CLEANUP_BOUNDS: Final[tuple[str, ...]] = (
"maximum_spend_logs_cleanup_batch_size",
"maximum_spend_logs_cleanup_max_batches",
"maximum_spend_logs_cleanup_run_budget",
"maximum_spend_logs_cleanup_batch_timeout",
)
_UI_SETTINGS_FIELDS: Final[tuple[str, ...]] = (
"allow_public_health_readiness_details",
"forward_client_headers_to_llm_api",
@ -95,28 +54,21 @@ _UI_SETTINGS_FIELDS: Final[tuple[str, ...]] = (
def _rules_for(
section: Section, keys: tuple[str, ...], db_row: DbRow, kind: RuleKind
section: Section, keys: tuple[str, ...], db_row: DbRow
) -> tuple[tuple[tuple[Section, str], KeyRule], ...]:
return tuple(((section, key), KeyRule(db_row=db_row, kind=kind)) for key in keys)
return tuple(((section, key), KeyRule(db_row=db_row)) for key in keys)
def _build_dual_source_keys() -> Mapping[tuple[Section, str], KeyRule]:
"""Maps a key to the stored row that carries it, for the keys whose row is not their own section."""
return MappingProxyType(
dict(
(
*_rules_for("general_settings", _DB_GENERAL_SETTINGS, "general_settings", "db_wins"),
*_rules_for("general_settings", _CONFIG_GENERAL_SETTINGS, "general_settings", "config_wins"),
*_rules_for("general_settings", _CLEANUP_BOUNDS, "general_settings", "db_fallback_to_config"),
(("general_settings", "alerting"), KeyRule(db_row="general_settings", kind="list_union")),
(
("general_settings", "pass_through_endpoints"),
KeyRule(db_row="general_settings", kind="merge_by_path"),
*_rules_for("general_settings", _UI_SETTINGS_FIELDS, "ui_settings"),
*(
((section, "*"), KeyRule(db_row=section))
for section in ("general_settings", "router_settings", "litellm_settings", "environment_variables")
),
(("general_settings", "*"), KeyRule(db_row="general_settings", kind="db_overlay")),
(("router_settings", "*"), KeyRule(db_row="router_settings", kind="db_overlay")),
(("litellm_settings", "*"), KeyRule(db_row="litellm_settings", kind="db_overlay")),
(("environment_variables", "*"), KeyRule(db_row="environment_variables", kind="db_overlay")),
*_rules_for("general_settings", _UI_SETTINGS_FIELDS, "ui_settings", "db_wins"),
)
)
)
@ -138,30 +90,13 @@ def coerce_bool(value: JsonValue) -> JsonValue:
def resolve(rule: KeyRule, yaml_value: SettingValue, db_value: SettingValue) -> Resolved:
match rule.kind:
case "db_wins" | "db_fallback_to_config":
return _db_wins(yaml_value, db_value)
case "config_wins":
return _config_wins(yaml_value, db_value)
case "list_union":
return _list_union(yaml_value, db_value)
case "merge_by_path":
return _merge_by_path(yaml_value, db_value)
case "db_overlay":
return _db_overlay(yaml_value, db_value)
case _:
assert_never(rule.kind)
"""Config wins. A key the config file declares is config-owned, whatever the database holds.
def _db_wins(yaml_value: SettingValue, db_value: SettingValue) -> Resolved:
if _db_is_present(db_value):
return Resolved(value=db_value, source="db")
if yaml_value is not ABSENT:
return Resolved(value=yaml_value, source="config")
return Resolved(value=ABSENT, source="unset")
def _config_wins(yaml_value: SettingValue, db_value: SettingValue) -> Resolved:
``rule`` only selects which stored row the database value came from; it no longer
varies the precedence. A stored ``null`` still counts as absent, so clearing a row
does not erase a value the file never declared.
"""
del rule
if yaml_value is not ABSENT:
return Resolved(value=yaml_value, source="config")
if _db_is_present(db_value):
@ -169,67 +104,10 @@ def _config_wins(yaml_value: SettingValue, db_value: SettingValue) -> Resolved:
return Resolved(value=ABSENT, source="unset")
def _list_union(yaml_value: SettingValue, db_value: SettingValue) -> Resolved:
if not _db_is_present(db_value):
return _db_wins(yaml_value, db_value)
if not isinstance(yaml_value, list) or not isinstance(db_value, list):
return _db_wins(yaml_value, db_value)
merged: Final[list[JsonValue]] = [ # mutable-ok: resolved config values retain the legacy JSON-list contract
*yaml_value,
*(value for value in db_value if value not in yaml_value),
]
return Resolved(value=merged, source="db")
def _merge_by_path(yaml_value: SettingValue, db_value: SettingValue) -> Resolved:
if not _db_is_present(db_value):
return _db_wins(yaml_value, db_value)
if not isinstance(yaml_value, list) or not isinstance(db_value, list):
return _db_wins(yaml_value, db_value)
db_paths: Final = frozenset(_endpoint_path(value) for value in db_value if _endpoint_path(value) is not None)
merged: Final[list[JsonValue]] = [ # mutable-ok: resolved config values retain the legacy JSON-list contract
*db_value,
*(value for value in yaml_value if _endpoint_path(value) not in db_paths),
]
return Resolved(value=merged, source="db")
def _db_overlay(yaml_value: SettingValue, db_value: SettingValue) -> Resolved:
if not _db_is_present(db_value):
return _db_wins(yaml_value, db_value)
if isinstance(db_value, list) and not db_value and yaml_value is not ABSENT:
return Resolved(value=yaml_value, source="config")
if not isinstance(yaml_value, dict) or not isinstance(db_value, dict):
return _db_wins(yaml_value, db_value)
overlay: Final = _overlay_mapping(yaml_value, db_value)
source: Final[FieldSource] = "db" if overlay != yaml_value else "config"
return Resolved(value=overlay, source=source)
def _overlay_mapping(yaml_value: dict[str, JsonValue], db_value: dict[str, JsonValue]) -> dict[str, JsonValue]:
return dict( # mutable-ok: resolved config values retain the legacy JSON-object contract
(
*(
(key, _overlay_value(value, db_value[key]) if key in db_value else value)
for key, value in yaml_value.items()
),
*(
(key, value)
for key, value in db_value.items()
if key not in yaml_value and not _db_overlay_defers(value)
),
)
)
def _overlay_value(yaml_value: JsonValue, db_value: JsonValue) -> JsonValue:
if isinstance(yaml_value, dict) and isinstance(db_value, dict):
return _overlay_mapping(yaml_value, db_value)
return yaml_value if _db_overlay_defers(db_value) else db_value
def _db_overlay_defers(value: JsonValue) -> bool:
return value is None or (isinstance(value, list) and not value)
def is_absent(value: SettingValue) -> bool:
@ -240,8 +118,3 @@ def _db_is_present(value: SettingValue) -> bool:
return not is_absent(value) and value is not None
def _endpoint_path(value: JsonValue) -> str | None:
if not isinstance(value, dict):
return None
path: Final = value.get("path")
return path if isinstance(path, str) else None

View file

@ -36,6 +36,21 @@ class SettingsStore(MutableMapping[str, JsonValue]):
def config_value(self, key: str) -> JsonValue:
return self._yaml_values.get(key)
def owned_by_config(self, key: str) -> bool:
return key in self._yaml_values
def config_owned_keys(self) -> frozenset[str]:
return frozenset(self._yaml_values)
def rejected_writes(self, incoming: Mapping[str, JsonValue]) -> tuple[str, ...]:
return tuple(
sorted(
key
for key, value in incoming.items()
if self.owned_by_config(key) and value != self._yaml_values[key]
)
)
def apply_db_row(self, row: DbRow, db_row: Mapping[str, JsonValue]) -> None:
previous_row: Final = self._database_rows.get(row, _EMPTY_VALUES)
self._database_rows = MappingProxyType({**self._database_rows, row: MappingProxyType(dict(db_row))})
@ -62,12 +77,16 @@ class SettingsStore(MutableMapping[str, JsonValue]):
return resolved.value
def __setitem__(self, key: str, value: JsonValue) -> None:
if self.owned_by_config(key):
return
self._runtime_values = MappingProxyType({**self._runtime_values, key: value})
self._deleted_runtime_keys = self._deleted_runtime_keys - frozenset((key,))
def __delitem__(self, key: str) -> None:
if key not in self:
raise KeyError(key)
if self.owned_by_config(key):
return
self._runtime_values = MappingProxyType(
{key_: value for key_, value in self._runtime_values.items() if key_ != key}
)

View file

@ -5041,6 +5041,7 @@ class ProxyConfig:
else MappingProxyType({})
)
changed_keys, removed_keys = changed_section_keys(baseline_section, new_section)
self._reject_config_owned_writes(section_name=section_name, changed_keys=changed_keys)
if not changed_keys and not removed_keys:
return
wrote_section: Final = await self._upsert_changed_config_section(
@ -5053,6 +5054,27 @@ class ProxyConfig:
return
await invalidate_config_param(section_name)
def _reject_config_owned_writes(self, *, section_name: str, changed_keys: Mapping[str, JsonValue]) -> None:
"""Refuse a write to a setting the config file owns, rather than storing a value that never applies."""
store: Final = self._settings_stores.get(cast(Section, section_name))
if store is None:
return
rejected: Final = store.rejected_writes(changed_keys)
if not rejected:
return
raise HTTPException(
status_code=400,
detail={
"error": f"{section_name} keys {list(rejected)} are set in the config file and cannot be changed here",
"keys": list(rejected),
"section": section_name,
"resolution": (
f"edit {user_config_file_path} to change them, "
"or remove them from it to let the database own them"
),
},
)
async def _upsert_changed_config_section(
self,
*,
@ -17207,6 +17229,20 @@ async def update_config_general_settings(
## update db
if proxy_config.settings.owned_by_config(data.field_name):
raise HTTPException(
status_code=400,
detail={
"error": f"general_settings key '{data.field_name}' is set in the config file and cannot be changed here",
"keys": [data.field_name],
"section": "general_settings",
"resolution": (
f"edit {user_config_file_path} to change it, "
"or remove it from the file to let the database own it"
),
},
)
field_value = data.field_value
if data.field_name == "plugins":
field_value = _preserve_redacted_plugin_keys(field_value, general_settings.get("plugins"))
@ -17412,37 +17448,32 @@ 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
if db_general_settings is None or db_general_settings.param_value is None:
# Answer with the value the proxy resolved, not the stored row: the config file may
# own this key, in which case the row holds a value that never applies.
settings: Final = proxy_config.settings
if field_name not in settings:
raise HTTPException(
status_code=400,
detail={"error": f"Field name={field_name} not in DB"},
detail={"error": f"Field name={field_name} is not set"},
)
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"},
)
field_value = _redact_general_setting_value(
field_name,
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
]
source: Final = settings.source(field_name)
return ConfigFieldInfo(
field_name=field_name,
field_value=field_value,
source=source,
editable=source != "config",
)
GeneralSettingsUILiteLLMValue = float | bool | str | None
@ -17675,6 +17706,7 @@ async def get_config_list(
_stored_in_db = True
elif field_name in general_settings:
_stored_in_db = False
_source = proxy_config.settings.source(field_name)
_response_obj = ConfigList(
field_name=field_name,
@ -17688,6 +17720,8 @@ async def get_config_list(
stored_in_db=_stored_in_db,
field_default_value=field_info.default,
nested_fields=nested_fields,
source=_source,
editable=_source != "config",
)
return_val.append(_response_obj)
@ -17700,8 +17734,9 @@ async def get_config_list(
elif field_name in general_settings:
_stored_in_db = False
_source = proxy_config.settings.source(field_name)
_field_value = general_settings.get(field_name, None)
if _field_value is None and field_name in db_general_settings_dict:
if _field_value is None and _source != "config" and field_name in db_general_settings_dict:
_field_value = db_general_settings_dict[field_name]
_response_obj = ConfigList(
@ -17712,6 +17747,8 @@ async def get_config_list(
stored_in_db=_stored_in_db,
field_default_value=field_info.default,
nested_fields=nested_fields,
source=_source,
editable=_source != "config",
)
return_val.append(_response_obj)

View file

@ -1,179 +1,168 @@
from __future__ import annotations
import json
from collections.abc import Mapping
from pathlib import Path
from typing import Final, Literal, cast
import itertools
from typing import Final
import pytest
from litellm.proxy.config_resolvers._descriptors import FieldSource
from litellm.proxy.config_resolvers.settings_rules import (
ABSENT,
DUAL_SOURCE_KEYS,
Absent,
JsonValue,
KeyRule,
Resolved,
Section,
SettingValue,
_build_dual_source_keys,
is_absent,
resolve,
rule_for,
)
_SECTIONS: Final[tuple[Section, ...]] = (
"general_settings",
"router_settings",
"litellm_settings",
"environment_variables",
)
# One route per shape the resolver has to serve: a key that used to be database-owned,
# one that was already config-owned, the collection keys that used to merge, a key
# carried by a different stored row, another section, and a key with no rule at all.
_ROUTES: Final[tuple[tuple[Section, str], ...]] = (
("general_settings", "max_parallel_requests"),
("general_settings", "max_file_size_mb"),
("general_settings", "alerting"),
("general_settings", "pass_through_endpoints"),
("general_settings", "forward_client_headers_to_llm_api"),
("router_settings", "fallbacks"),
("litellm_settings", "drop_params"),
("general_settings", "an_unregistered_key"),
)
_CONFIG_VALUES: Final[tuple[SettingValue, ...]] = (
ABSENT,
None,
False,
0,
"",
[],
{},
"config-value",
["config-value"],
{"config": "value"},
[{"path": "/shared", "target": "config"}],
)
_DB_VALUES: Final[tuple[SettingValue, ...]] = (
ABSENT,
None,
False,
0,
"",
[],
{},
"db-value",
["db-value"],
{"db": "value"},
[{"path": "/shared", "target": "db"}],
)
_CONFIG_OWNED_MATRIX: Final = tuple(
(section, key, config_value, db_value)
for (section, key), config_value, db_value in itertools.product(_ROUTES, _CONFIG_VALUES, _DB_VALUES)
if not is_absent(config_value)
)
_DB_FALLBACK_MATRIX: Final = tuple(
(section, key, db_value) for (section, key), db_value in itertools.product(_ROUTES, _DB_VALUES)
)
# Keys the database used to win outright. The flip is the breaking change this PR ships,
# so each one is named rather than generated: a revert has to fail here.
_PREVIOUSLY_DB_WINS: Final[tuple[str, ...]] = (
"max_parallel_requests",
"global_max_parallel_requests",
"alerting_args",
"ui_access_mode",
"disable_auto_add_proxy_admin_to_teams",
"store_model_in_db",
"maximum_spend_logs_retention_period",
"maximum_autorouter_session_retention_period",
"maximum_health_check_retention_period",
"maximum_spend_logs_cleanup_batch_size",
"maximum_spend_logs_cleanup_max_batches",
"maximum_spend_logs_cleanup_run_budget",
"maximum_spend_logs_cleanup_batch_timeout",
"user_url_validation",
"user_url_allowed_hosts",
"provider_url_destination_allowed_hosts",
"alerting",
"pass_through_endpoints",
)
@pytest.mark.parametrize(("section", "key", "config_value", "db_value"), _CONFIG_OWNED_MATRIX)
def test_a_key_the_config_file_declares_always_resolves_to_the_config_value(
section: Section, key: str, config_value: SettingValue, db_value: SettingValue
) -> None:
resolved: Final = resolve(rule_for(section, key), config_value, db_value)
assert resolved.value == config_value
assert resolved.source == "config"
@pytest.mark.parametrize(("section", "key", "db_value"), _DB_FALLBACK_MATRIX)
def test_a_key_the_config_file_omits_falls_back_to_the_stored_value(
section: Section, key: str, db_value: SettingValue
) -> None:
resolved: Final = resolve(rule_for(section, key), ABSENT, db_value)
if is_absent(db_value) or db_value is None:
assert isinstance(resolved.value, Absent)
assert resolved.source == "unset"
else:
assert resolved.value == db_value
assert resolved.source == "db"
@pytest.mark.parametrize("key", _PREVIOUSLY_DB_WINS)
def test_keys_the_database_used_to_win_now_resolve_to_the_config_value(key: str) -> None:
resolved: Final = resolve(rule_for("general_settings", key), "from-config", "from-db")
assert resolved.value == "from-config"
assert resolved.source == "config"
@pytest.mark.parametrize("key", _PREVIOUSLY_DB_WINS)
def test_a_falsy_stored_value_cannot_erase_a_config_value(key: str) -> None:
falsy: Final[tuple[JsonValue, ...]] = (None, False, 0, "", [], {})
resolved: Final = tuple(resolve(rule_for("general_settings", key), "from-config", value) for value in falsy)
assert {entry.value for entry in resolved} == {"from-config"}
assert {entry.source for entry in resolved} == {"config"}
@pytest.mark.parametrize(
("rule", "yaml_value", "db_value", "expected"),
("key", "expected_row"),
(
(
KeyRule(db_row="general_settings", kind="db_wins"),
"from-config",
"from-db",
Resolved(value="from-db", source="db"),
),
(
KeyRule(db_row="general_settings", kind="config_wins"),
"from-config",
"from-db",
Resolved(value="from-config", source="config"),
),
(
KeyRule(db_row="general_settings", kind="db_fallback_to_config"),
"from-config",
None,
Resolved(value="from-config", source="config"),
),
(
KeyRule(db_row="general_settings", kind="list_union"),
["config", "shared"],
["db", "shared"],
Resolved(value=["config", "shared", "db"], source="db"),
),
(
KeyRule(db_row="general_settings", kind="merge_by_path"),
[{"path": "/config"}, {"path": "/shared", "source": "config"}],
[{"path": "/db"}, {"path": "/shared", "source": "db"}],
Resolved(
value=[
{"path": "/db"},
{"path": "/shared", "source": "db"},
{"path": "/config"},
],
source="db",
),
),
(
KeyRule(db_row="router_settings", kind="db_overlay"),
{"config": 1, "nested": {"config": True, "shared": "config"}, "fallbacks": ["config"]},
{"db": 2, "nested": {"shared": "db", "db": True}, "fallbacks": []},
Resolved(
value={
"config": 1,
"db": 2,
"nested": {"config": True, "shared": "db", "db": True},
"fallbacks": ["config"],
},
source="db",
),
),
("forward_client_headers_to_llm_api", "ui_settings"),
("team_admin_editable_team_fields", "ui_settings"),
("disable_key_generate_for_org_admin", "ui_settings"),
("max_parallel_requests", "general_settings"),
("an_unregistered_key", "general_settings"),
),
)
def test_resolve_matches_the_config_and_db_precedence_rules(
rule: KeyRule,
yaml_value: object,
db_value: object,
expected: Resolved,
) -> None:
assert resolve(rule, yaml_value, db_value) == expected
def test_a_key_reads_from_the_row_that_carries_it(key: str, expected_row: str) -> None:
assert rule_for("general_settings", key).db_row == expected_row
@pytest.mark.parametrize("rule", tuple(DUAL_SOURCE_KEYS.values()))
def test_resolve_treats_none_from_the_database_as_absent(rule: KeyRule) -> None:
resolved: Final = resolve(rule, "from-config", None)
def test_every_registered_rule_routes_to_a_known_row() -> None:
rows: Final = {rule.db_row for rule in DUAL_SOURCE_KEYS.values()}
assert resolved == Resolved(value="from-config", source="config")
assert rows <= {*_SECTIONS, "ui_settings"}
def test_resolve_distinguishes_an_absent_config_value_from_a_configured_null() -> None:
absent: Final = resolve(KeyRule(db_row="general_settings", kind="db_wins"), ABSENT, None)
configured_null: Final = resolve(KeyRule(db_row="general_settings", kind="db_wins"), None, None)
def test_a_config_value_of_none_is_still_config_owned() -> None:
resolved: Final = resolve(rule_for("general_settings", "ui_access_mode"), None, "from-db")
assert absent == Resolved(value=ABSENT, source="unset")
assert configured_null == Resolved(value=None, source="config")
def test_resolve_reports_config_db_and_unset_sources() -> None:
rule: Final = KeyRule(db_row="general_settings", kind="db_wins")
sources: Final[tuple[FieldSource, ...]] = (
resolve(rule, "from-config", None).source,
resolve(rule, "from-config", "from-db").source,
resolve(rule, ABSENT, None).source,
)
assert sources == ("config", "db", "unset")
_PRECEDENCE_MATRIX_PATH: Final = Path(__file__).parent / "fixtures" / "precedence_matrix.json"
def _load_precedence_matrix() -> tuple[dict[str, object], ...]:
raw: Final[object] = json.loads(_PRECEDENCE_MATRIX_PATH.read_text())
assert isinstance(raw, dict)
cases: Final[object] = raw.get("cases")
assert isinstance(cases, list)
assert all(isinstance(case, dict) for case in cases)
return tuple(cast(dict[str, object], case) for case in cases)
def _matrix_value(case: Mapping[str, object], source: Literal["config", "db"]) -> SettingValue:
raw_value: Final[object] = case[source]
assert isinstance(raw_value, Mapping)
present: Final[object] = raw_value.get("present")
assert isinstance(present, bool)
if not present:
return ABSENT
return cast(JsonValue, raw_value["value"])
def test_dual_source_key_registry_matches_the_golden_precedence_matrix() -> None:
registry: Final = _build_dual_source_keys()
for case in _load_precedence_matrix():
section: Final[object] = case["section"]
key: Final[object] = case["key"]
rule_kind: Final[object] = case["rule"]
db_row: Final[object] = case["db_row"]
assert isinstance(section, str)
assert isinstance(key, str)
assert isinstance(rule_kind, str)
assert isinstance(db_row, str)
resolved_rule: Final = registry.get((cast(Section, section), key), registry[(cast(Section, section), "*")])
assert resolved_rule.kind == rule_kind
assert resolved_rule.db_row == db_row
@pytest.mark.parametrize("case", _load_precedence_matrix())
def test_resolve_matches_the_golden_precedence_matrix(case: dict[str, object]) -> None:
section: Final[object] = case["section"]
key: Final[object] = case["key"]
rule_kind: Final[object] = case["rule"]
expected: Final[object] = case["expected"]
assert isinstance(section, str)
assert isinstance(key, str)
assert isinstance(rule_kind, str)
assert isinstance(expected, Mapping)
resolved: Final = resolve(
rule_for(cast(Section, section), key),
_matrix_value(case, "config"),
_matrix_value(case, "db"),
)
expected_present: Final[object] = expected["present"]
assert isinstance(expected_present, bool)
assert rule_for(cast(Section, section), key).kind == rule_kind
assert not isinstance(resolved.value, Absent) is expected_present
if expected_present:
assert resolved.value == expected["value"]
assert resolved.source == expected["source"]
assert resolved.value is None
assert resolved.source == "config"

View file

@ -72,8 +72,8 @@ def test_settings_store_mapping_operations_match_a_plain_dict(operation: str, in
def test_settings_store_keeps_unaffected_runtime_values_on_a_db_row_refresh() -> None:
store: Final = SettingsStore("general_settings")
store.load_yaml({"template": "os.environ/SETTING", "changed": "config"})
store.apply_runtime_values({"template": "resolved", "changed": "resolved-config"})
store.load_yaml({"template": "os.environ/SETTING"})
store.apply_runtime_values({"template": "resolved", "changed": "resolved-runtime"})
store.apply_db_row("general_settings", {"changed": "database"})
@ -82,6 +82,17 @@ def test_settings_store_keeps_unaffected_runtime_values_on_a_db_row_refresh() ->
assert store.source("changed") == "db"
def test_settings_store_keeps_a_config_owned_key_when_a_db_row_disagrees() -> None:
store: Final = SettingsStore("general_settings")
store.load_yaml({"changed": "config"})
store.apply_runtime_values({"changed": "resolved-config"})
store.apply_db_row("general_settings", {"changed": "database"})
assert store["changed"] == "config"
assert store.source("changed") == "config"
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"})
@ -107,9 +118,9 @@ def test_settings_store_preserves_falsy_config_values_and_provenance() -> None:
@pytest.mark.parametrize(
("yaml_value", "db_value", "expected_value", "expected_source"),
(
("from-config", "from-db", "from-db", "db"),
("from-config", "from-db", "from-config", "config"),
("from-config", None, "from-config", "config"),
(None, "from-db", "from-db", "db"),
(None, "from-db", None, "config"),
(None, None, None, "config"),
),
)
@ -127,16 +138,48 @@ def test_settings_store_resolves_a_db_row_with_provenance(
assert store.source("ordinary") == expected_source
def test_settings_store_applies_the_registered_config_precedence_rule() -> None:
def test_settings_store_gives_every_config_declared_key_to_the_config_file() -> None:
store: Final = SettingsStore("general_settings")
store.load_yaml({"max_file_size_mb": 7, "max_parallel_requests": 3})
store.apply_db_row("general_settings", {"max_file_size_mb": 9, "max_parallel_requests": 11})
assert dict(store) == {"max_file_size_mb": 7, "max_parallel_requests": 11}
assert dict(store) == {"max_file_size_mb": 7, "max_parallel_requests": 3}
assert store.source("max_file_size_mb") == "config"
assert store.source("max_parallel_requests") == "config"
def test_settings_store_gives_a_key_the_config_file_omits_to_the_database() -> None:
store: Final = SettingsStore("general_settings")
store.load_yaml({"max_file_size_mb": 7})
store.apply_db_row("general_settings", {"max_file_size_mb": 9, "max_parallel_requests": 11})
assert dict(store) == {"max_file_size_mb": 7, "max_parallel_requests": 11}
assert store.source("max_parallel_requests") == "db"
def test_settings_store_refuses_a_runtime_write_to_a_config_owned_key() -> None:
store: Final = SettingsStore("general_settings")
store.load_yaml({"max_parallel_requests": 3})
store["max_parallel_requests"] = 11
del store["max_parallel_requests"]
assert store["max_parallel_requests"] == 3
assert store.source("max_parallel_requests") == "config"
def test_settings_store_reports_the_config_owned_keys_a_write_would_change() -> None:
store: Final = SettingsStore("general_settings")
store.load_yaml({"max_parallel_requests": 3, "ui_access_mode": "admin_only"})
rejected: Final = store.rejected_writes(
{"max_parallel_requests": 11, "ui_access_mode": "admin_only", "global_max_parallel_requests": 5}
)
assert rejected == ("max_parallel_requests",)
assert store.config_owned_keys() == frozenset({"max_parallel_requests", "ui_access_mode"})
def test_settings_store_resolved_view_is_read_only() -> None:
store: Final = SettingsStore("general_settings")
store.load_yaml({"configured": "value"})

View file

@ -77,8 +77,9 @@ class TestDefaultTeamParamsFromSettingsStore:
assert litellm.default_team_params is None
def test_default_team_params_overrides_yaml_value(self, monkeypatch):
"""DB value for default_team_params overrides YAML value via deep merge."""
def test_default_team_params_keeps_the_yaml_value(self, monkeypatch):
"""``default_team_params`` is config-owned once the file declares it, so a stored
value no longer merges into or replaces any part of it."""
monkeypatch.setattr(litellm, "default_team_params", None)
pc = self._make_proxy_config()
@ -101,15 +102,25 @@ class TestDefaultTeamParamsFromSettingsStore:
db_values = pc._prepared_db_settings_values("litellm_settings", db_settings)
pc._apply_litellm_settings_db_values(db_values)
merged = pc.litellm_settings["default_team_params"]
# DB value wins for max_budget
assert merged["max_budget"] == 200.0
# DB adds rpm_limit
assert merged["rpm_limit"] == 500
# YAML tpm_limit preserved (not in DB)
assert merged["tpm_limit"] == 100
resolved = pc.litellm_settings["default_team_params"]
assert resolved == {"max_budget": 50.0, "tpm_limit": 100}
assert pc.litellm_settings.source("default_team_params") == "config"
assert litellm.default_team_params == resolved
assert litellm.default_team_params == merged
def test_default_team_params_comes_from_the_database_when_the_yaml_omits_it(self, monkeypatch):
monkeypatch.setattr(litellm, "default_team_params", None)
pc = self._make_proxy_config()
db_settings = {"default_team_params": {"max_budget": 200.0, "rpm_limit": 500}}
pc.litellm_settings.load_yaml({})
db_values = pc._prepared_db_settings_values("litellm_settings", db_settings)
pc._apply_litellm_settings_db_values(db_values)
resolved = pc.litellm_settings["default_team_params"]
assert resolved == {"max_budget": 200.0, "rpm_limit": 500}
assert pc.litellm_settings.source("default_team_params") == "db"
assert litellm.default_team_params == resolved
# ---------------------------------------------------------------------------

View file

@ -3877,13 +3877,36 @@ async def test_ProxyConfig__update_config_from_db_resolves_through_settings_stor
assert resolved["general_settings"] == {
"max_file_size_mb": 7,
"max_parallel_requests": 11,
"alerting": ["config", "db"],
"pass_through_endpoints": [{"path": "/db"}, {"path": "/config"}],
"max_parallel_requests": 3,
"alerting": ["config"],
"pass_through_endpoints": [{"path": "/config"}],
"maximum_spend_logs_cleanup_batch_size": 10,
}
assert resolved["router_settings"] == {"fallbacks": ["config"], "num_retries": 2}
assert resolved["router_settings"] == {"fallbacks": ["config"], "num_retries": 1}
assert pc.settings.source("max_file_size_mb") == "config"
assert pc.settings.source("max_parallel_requests") == "config"
@pytest.mark.asyncio
async def test_ProxyConfig__update_config_from_db_keeps_keys_the_config_file_omits(monkeypatch):
pc = ProxyConfig()
config = {"general_settings": {"max_file_size_mb": 7}, "router_settings": {"num_retries": 1}}
db_values = {
"general_settings": {"max_file_size_mb": 9, "max_parallel_requests": 11},
"router_settings": {"fallbacks": ["db"], "num_retries": 2},
}
async def get_config_param(_, param_name):
value = db_values.get(param_name)
return SimpleNamespace(param_name=param_name, param_value=value) if value is not None else None
monkeypatch.setattr("litellm.proxy.proxy_server.get_config_param", get_config_param)
pc._load_yaml_settings_stores(config)
resolved = await pc._update_config_from_db(MagicMock(), config, store_model_in_db=True)
assert resolved["general_settings"] == {"max_file_size_mb": 7, "max_parallel_requests": 11}
assert resolved["router_settings"] == {"num_retries": 1, "fallbacks": ["db"]}
assert pc.settings.source("max_parallel_requests") == "db"

View file

@ -1088,6 +1088,8 @@ async def test_init_mcp_servers_from_db_respects_supported_db_objects(monkeypatc
def test_settings_store_deep_merge_db_wins():
"""The config file owns model_group_alias outright once it declares it, so a stored
row can no longer add, replace or partially update entries inside it."""
from litellm.proxy.proxy_server import ProxyConfig
proxy_config = ProxyConfig()
@ -1133,20 +1135,9 @@ def test_settings_store_deep_merge_db_wins():
rs = proxy_config.router_settings.resolved()
aliases = rs["model_group_alias"]
# DB wins on conflicts (deep) for existing alias
assert aliases["claude-sonnet-4"]["model"] == "claude-sonnet-4-20250514"
assert aliases["claude-sonnet-4"]["hidden"] is False
# New alias introduced by DB is present with its values
assert "claude-sonnet-latest" in aliases
assert aliases["claude-sonnet-latest"]["model"] == "claude-sonnet-4-20250514"
assert aliases["claude-sonnet-latest"]["hidden"] is True
# None in DB does not overwrite existing values
assert aliases["legacy-sonnet"]["model"] == "claude-2.1"
assert aliases["legacy-sonnet"]["hidden"] is True
# Unrelated router_settings keys are preserved
assert aliases == current_config["router_settings"]["model_group_alias"]
assert "claude-sonnet-latest" not in aliases
assert proxy_config.router_settings.source("model_group_alias") == "config"
assert rs["routing_mode"] == "cost_optimized"
@ -4943,26 +4934,14 @@ async def test_add_router_settings_from_db_config_merge_logic():
call_args = mock_router.update_settings.call_args
combined_settings = call_args[1] # kwargs
# Verify the merge results
# DB values should override config values
assert combined_settings["routing_strategy"] == "least-busy"
# Config-only values should be preserved
assert combined_settings["routing_strategy"] == "usage-based-routing"
assert combined_settings["model_group_alias"] == {"gpt-4": "openai-gpt-4"}
assert combined_settings["enable_pre_call_checks"] == True
assert combined_settings["enable_pre_call_checks"] is True
assert combined_settings["timeout"] == 30
assert combined_settings["nested_config"] == {"setting1": "config_value1", "setting2": "config_value2"}
# DB-only values should be added
assert combined_settings["retry_delay"] == 2
# Nested dictionaries should be merged (but this is shallow merge)
expected_nested = {
"setting1": "config_value1",
"setting2": "db_value2",
"setting3": "db_value3",
}
assert combined_settings["nested_config"] == expected_nested
@pytest.mark.asyncio
async def test_add_router_settings_from_db_config_empty_db_lists_do_not_clobber_config_fallbacks():
@ -5009,7 +4988,7 @@ async def test_add_router_settings_from_db_config_empty_db_lists_do_not_clobber_
combined_settings = mock_router.update_settings.call_args.kwargs
assert combined_settings["fallbacks"] == [{"gpt-oss-120b": ["granite-4-h-small"]}]
assert combined_settings["context_window_fallbacks"] == [{"gpt-oss-120b": ["granite-4-h-small"]}]
assert combined_settings["content_policy_fallbacks"] == [{"gpt-oss-120b": ["other-model"]}]
assert combined_settings["content_policy_fallbacks"] == [{"gpt-oss-120b": ["granite-4-h-small"]}]
assert combined_settings["num_retries"] == 3
@ -5196,8 +5175,8 @@ async def test_add_router_settings_shallow_merge_behavior():
"key4": "db_value4",
}
assert merged_settings["nested_setting"] == expected_nested
assert merged_settings["top_level"] == "db_top"
assert merged_settings["nested_setting"] == config_data["router_settings"]["nested_setting"]
assert merged_settings["top_level"] == "config_top"
@pytest.mark.asyncio
@ -6460,9 +6439,8 @@ def test_get_config_normalizes_string_callbacks(monkeypatch):
def test_deep_merge_dicts_skips_none_and_empty_lists(monkeypatch):
"""
Test that SettingsStore deep merge skips None values and empty lists.
"""
"""A key the config file declares is config-owned, so the stored row cannot
reshape it. Keys the file omits still come from the row."""
from litellm.proxy.proxy_server import ProxyConfig
proxy_config = ProxyConfig()
@ -6495,9 +6473,7 @@ def test_deep_merge_dicts_skips_none_and_empty_lists(monkeypatch):
assert result["max_parallel_requests"] == 10
assert result["allowed_models"] == ["gpt-3.5-turbo", "gpt-4"]
assert result["new_key"] == "new_value"
assert result["nested"]["key1"] == "updated_value1"
assert result["nested"]["key2"] == "value2"
assert result["nested"]["key3"] == "value3"
assert result["nested"] == {"key1": "value1", "key2": "value2"}
class TestInvitationEndpoints:
@ -7450,14 +7426,13 @@ async def test_update_general_settings_keeps_yaml_pass_through_endpoints_next_to
[(None, None), (["POST"], ["GET"])],
ids=["all-methods", "disjoint-methods"],
)
async def test_update_general_settings_db_pass_through_endpoint_overrides_yaml_entry_on_the_same_path(
async def test_update_general_settings_db_pass_through_endpoint_cannot_override_a_yaml_declared_path(
db_methods: list[str] | None, yaml_methods: list[str] | None
):
"""The auth check matches pass-through entries by path only and lets any
matching ``auth: false`` entry through, so a DB ``auth: true`` entry can only
lock down a YAML-declared path if the YAML entry is dropped from the merged
list, whatever ``methods`` either entry declares."""
from litellm.proxy._types import ProxyException
"""``pass_through_endpoints`` is config-owned once the file declares it, so a stored
``auth: true`` entry on a path the YAML already declares ``auth: false`` no longer
locks that path down. Changing it means editing the config file. A path the YAML
does not declare is still governed by the stored row, which the sibling test covers."""
from litellm.proxy.proxy_server import ProxyConfig
yaml_endpoint: Final = {
@ -7487,9 +7462,8 @@ async def test_update_general_settings_db_pass_through_endpoint_overrides_yaml_e
with settings, yaml_endpoints, initialize, master_key:
await ProxyConfig()._update_general_settings(db_general_settings={"pass_through_endpoints": [db_endpoint]})
with pytest.raises(ProxyException) as locked_down:
await user_api_key_auth(request=request, api_key=None)
assert locked_down.value.code == "401"
still_open: Final = await user_api_key_auth(request=request, api_key=None)
assert still_open.api_key is None
def _fill_user_api_key_cache(cache: DualCache, count: int) -> None:
@ -11344,6 +11318,7 @@ def _config_field_info_client(monkeypatch, user_role):
from fastapi.testclient import TestClient
import litellm.proxy.proxy_server as ps
from litellm.proxy.config_resolvers import SettingsStore
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.proxy_server import app
@ -11366,6 +11341,12 @@ def _config_field_info_client(monkeypatch, user_role):
mock_prisma = MagicMock()
mock_prisma.db = types.SimpleNamespace(litellm_config=mock_config_table)
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
settings = SettingsStore("general_settings")
settings.load_yaml({})
settings.apply_db_row("general_settings", db_record.param_value)
monkeypatch.setattr(ps.proxy_config, "settings", settings)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(user_id="u", user_role=user_role)
return TestClient(app)

View file

@ -3446,10 +3446,24 @@ class TestSyncUiSettingsToGeneralSettings:
from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import apply_runtime_general_settings_flags
general_settings = SettingsStore("general_settings")
general_settings.load_yaml({"forward_client_headers_to_llm_api": False})
general_settings.load_yaml({})
monkeypatch.setattr(proxy_server, "general_settings", general_settings)
apply_runtime_general_settings_flags({"forward_client_headers_to_llm_api": True})
assert general_settings["forward_client_headers_to_llm_api"] is True
assert general_settings.source("forward_client_headers_to_llm_api") == "db"
def test_applied_runtime_flags_cannot_override_the_config_file(self, monkeypatch):
from litellm.proxy import proxy_server
from litellm.proxy.config_resolvers import SettingsStore
from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import apply_runtime_general_settings_flags
general_settings = SettingsStore("general_settings")
general_settings.load_yaml({"forward_client_headers_to_llm_api": False})
monkeypatch.setattr(proxy_server, "general_settings", general_settings)
apply_runtime_general_settings_flags({"forward_client_headers_to_llm_api": True})
assert general_settings["forward_client_headers_to_llm_api"] is False
assert general_settings.source("forward_client_headers_to_llm_api") == "config"