mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-19 00:01:29 +00:00
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:
parent
a7d4f7c521
commit
afa4a6fe78
11 changed files with 384 additions and 17045 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue