diff --git a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py index ffb27d5f92e..7733ad1c522 100644 --- a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py @@ -2579,7 +2579,7 @@ def _jwt_auth_issuers() -> list: if env_issuer: issuers.append(env_issuer) - jwtauth: Final = general_settings.get("litellm_jwtauth") if isinstance(general_settings, dict) else None + jwtauth: Final = general_settings.get("litellm_jwtauth") if isinstance(general_settings, Mapping) else None raw_issuers: Final = jwtauth.get("issuers") if isinstance(jwtauth, dict) else getattr(jwtauth, "issuers", None) for cfg in raw_issuers or []: issuer = cfg.get("issuer") if isinstance(cfg, dict) else getattr(cfg, "issuer", None) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index b0d31df92ce..aacf318267a 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -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: Literal["config", "db", "env", "default", "unset"] = "unset" + editable: bool = True class UserHeaderMapping(LiteLLMPydanticObjectBase): @@ -3693,6 +3695,8 @@ class InvitationClaim(LiteLLMPydanticObjectBase): class ConfigFieldInfo(LiteLLMPydanticObjectBase): field_name: str field_value: Any + source: Literal["config", "db", "env", "default", "unset"] = "unset" + editable: bool = True class CallbackOnUI(LiteLLMPydanticObjectBase): diff --git a/litellm/proxy/client/cli/commands/autoroute/commands.py b/litellm/proxy/client/cli/commands/autoroute/commands.py index 05c21875f84..d9fc3ae9f77 100644 --- a/litellm/proxy/client/cli/commands/autoroute/commands.py +++ b/litellm/proxy/client/cli/commands/autoroute/commands.py @@ -2,6 +2,7 @@ import atexit import secrets import signal import threading +from collections.abc import Mapping from types import FrameType from typing import Final @@ -67,7 +68,7 @@ def _ensure_master_key() -> str: master_key: Final = secrets.token_urlsafe(32) general_settings: Final = generated.get("general_settings") updated_settings: Final[dict[str, JsonValue]] = { - **(general_settings if isinstance(general_settings, dict) else {}), + **(general_settings if isinstance(general_settings, Mapping) else {}), "master_key": master_key, } updated: Final[dict[str, JsonValue]] = {**generated, "general_settings": updated_settings} diff --git a/litellm/proxy/client/cli/commands/autoroute/config.py b/litellm/proxy/client/cli/commands/autoroute/config.py index 1bfcdf444bf..f8738af221e 100644 --- a/litellm/proxy/client/cli/commands/autoroute/config.py +++ b/litellm/proxy/client/cli/commands/autoroute/config.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from typing import Final, Literal from pydantic import BaseModel, ConfigDict, Field, JsonValue, TypeAdapter @@ -221,7 +222,7 @@ def master_key_from_config(config: dict[str, JsonValue]) -> str | None: normalized copy here would diverge from what the proxy expects. """ general_settings: Final = config.get("general_settings") - if not isinstance(general_settings, dict): + if not isinstance(general_settings, Mapping): return None master_key: Final = general_settings.get("master_key") if isinstance(master_key, str) and master_key.strip(): diff --git a/litellm/proxy/config_resolvers/__init__.py b/litellm/proxy/config_resolvers/__init__.py index 88b4c3961f0..ebd339b34c3 100644 --- a/litellm/proxy/config_resolvers/__init__.py +++ b/litellm/proxy/config_resolvers/__init__.py @@ -5,5 +5,6 @@ from litellm.proxy.config_resolvers._descriptors import ( FieldSource, resolve_fields, ) +from litellm.proxy.config_resolvers.settings_store import SettingsStore -__all__ = ["FieldDescriptor", "FieldSource", "resolve_fields"] +__all__ = ("FieldDescriptor", "FieldSource", "SettingsStore", "resolve_fields") diff --git a/litellm/proxy/config_resolvers/_descriptors.py b/litellm/proxy/config_resolvers/_descriptors.py index edc0eeb1cf6..e2e0534bf7b 100644 --- a/litellm/proxy/config_resolvers/_descriptors.py +++ b/litellm/proxy/config_resolvers/_descriptors.py @@ -13,7 +13,7 @@ from collections.abc import Mapping, Sequence from dataclasses import dataclass from typing import Final, Literal -FieldSource = Literal["db", "env", "default", "unset"] +FieldSource = Literal["config", "db", "env", "default", "unset"] @dataclass(frozen=True, slots=True) @@ -69,5 +69,7 @@ def resolve_fields( """ resolved: Final = tuple(_resolve_one(descriptor, db_values, env, empty_db_is_set) for descriptor in descriptors) values: Final = {field_name: value for field_name, value, _ in resolved} - provenance: Final = {field_name: source for field_name, _, source in resolved} + provenance: Final[dict[str, FieldSource]] = dict( # mutable-ok: public resolver contract returns a plain dict + (field_name, source) for field_name, _, source in resolved + ) return values, provenance diff --git a/litellm/proxy/config_resolvers/settings_rules.py b/litellm/proxy/config_resolvers/settings_rules.py new file mode 100644 index 00000000000..f346dd6198d --- /dev/null +++ b/litellm/proxy/config_resolvers/settings_rules.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from types import MappingProxyType +from typing import Final, Literal, TypeAlias + +from litellm.proxy.config_resolvers._descriptors import FieldSource + +JsonValue: TypeAlias = None | bool | int | float | str | list["JsonValue"] | dict[str, "JsonValue"] +Section: TypeAlias = Literal[ + "general_settings", + "router_settings", + "litellm_settings", + "environment_variables", + "ui_settings", +] +DbRow: TypeAlias = Section + + +@dataclass(frozen=True, slots=True) +class Absent: + pass + + +ABSENT: Final = Absent() +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 + + +@dataclass(frozen=True, slots=True) +class Resolved: + value: SettingValue + source: FieldSource + + +_UI_SETTINGS_FIELDS: Final[tuple[str, ...]] = ( + "allow_public_health_readiness_details", + "forward_client_headers_to_llm_api", + "forward_llm_provider_auth_headers", + "disable_agents_for_internal_users", + "allow_agents_for_team_admins", + "disable_vector_stores_for_internal_users", + "allow_vector_stores_for_team_admins", + "disable_key_generate_for_org_admin", + "team_admin_editable_team_fields", +) + + +def _rules_for( + section: Section, keys: tuple[str, ...], db_row: DbRow +) -> tuple[tuple[tuple[Section, str], KeyRule], ...]: + 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", _UI_SETTINGS_FIELDS, "ui_settings"), + *( + ((section, "*"), KeyRule(db_row=section)) + for section in ("general_settings", "router_settings", "litellm_settings", "environment_variables") + ), + ) + ) + ) + + +DUAL_SOURCE_KEYS: Final[Mapping[tuple[Section, str], KeyRule]] = _build_dual_source_keys() + + +def rule_for(section: Section, key: str) -> KeyRule: + return DUAL_SOURCE_KEYS.get((section, key), DUAL_SOURCE_KEYS[(section, "*")]) + + +def coerce_bool(value: JsonValue) -> JsonValue: + if value is None or isinstance(value, bool): + return value + if isinstance(value, str): + return value.lower() == "true" + return bool(value) + + +def resolve(yaml_value: SettingValue, db_value: SettingValue) -> Resolved: + """Config wins. A key the config file declares is config-owned, whatever the database holds. + + A stored ``null`` still counts as absent, so clearing a row does not erase a value + the file never declared. + """ + if yaml_value is not ABSENT: + return Resolved(value=yaml_value, source="config") + if _db_is_present(db_value): + return Resolved(value=db_value, source="db") + return Resolved(value=ABSENT, source="unset") + + +def is_absent(value: SettingValue) -> bool: + return value is ABSENT + + +def _db_is_present(value: SettingValue) -> bool: + return not is_absent(value) and value is not None diff --git a/litellm/proxy/config_resolvers/settings_store.py b/litellm/proxy/config_resolvers/settings_store.py new file mode 100644 index 00000000000..079d262319f --- /dev/null +++ b/litellm/proxy/config_resolvers/settings_store.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +from collections.abc import Iterator, Mapping, MutableMapping +from types import MappingProxyType +from typing import Final + +from litellm.proxy.config_resolvers._descriptors import FieldSource +from litellm.proxy.config_resolvers.settings_rules import ( + ABSENT, + Absent, + DbRow, + JsonValue, + Resolved, + Section, + SettingValue, + resolve, + rule_for, +) + +_EMPTY_VALUES: Final[Mapping[str, JsonValue]] = MappingProxyType({}) +_EMPTY_ROWS: Final[Mapping[DbRow, Mapping[str, JsonValue]]] = MappingProxyType({}) + + +class SettingsStore(MutableMapping[str, JsonValue]): + def __init__(self, section: Section) -> None: + self._section: Final = section + self._yaml_values: Mapping[str, JsonValue] = _EMPTY_VALUES + self._database_rows: Mapping[DbRow, Mapping[str, JsonValue]] = _EMPTY_ROWS + self._runtime_values: Mapping[str, JsonValue] = _EMPTY_VALUES + self._deleted_runtime_keys: frozenset[str] = frozenset() + + def load_yaml(self, mapping: Mapping[str, JsonValue]) -> None: + self._yaml_values = MappingProxyType(dict(mapping)) + self._clear_runtime() + + 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 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))}) + self._clear_runtime_keys(frozenset((*previous_row, *db_row))) + + def resolved(self) -> Mapping[str, JsonValue]: + return MappingProxyType(dict(self)) + + def apply_runtime_values(self, values: Mapping[str, JsonValue]) -> None: + self._runtime_values = MappingProxyType(dict(values)) + self._deleted_runtime_keys = frozenset() + + def source(self, key: str) -> FieldSource: + return self._resolution_for(key).source + + def __getitem__(self, key: str) -> JsonValue: + if key in self._deleted_runtime_keys: + raise KeyError(key) + if key in self._runtime_values: + return self._runtime_values[key] + resolved: Final = self._resolution_for(key) + if isinstance(resolved.value, Absent): + raise KeyError(key) + 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} + ) + self._deleted_runtime_keys = self._deleted_runtime_keys | frozenset((key,)) + + def __iter__(self) -> Iterator[str]: + return iter( + key + for key in self._keys() + if key not in self._deleted_runtime_keys + and (key in self._runtime_values or not isinstance(self._resolution_for(key).value, Absent)) + ) + + def __len__(self) -> int: + return sum(1 for _ in self) + + def _clear_runtime(self) -> None: + self._runtime_values = _EMPTY_VALUES + self._deleted_runtime_keys = frozenset() + + def _clear_runtime_keys(self, keys: frozenset[str]) -> None: + if not keys: + return + self._runtime_values = MappingProxyType( + {key: value for key, value in self._runtime_values.items() if key not in keys} + ) + self._deleted_runtime_keys = self._deleted_runtime_keys - keys + + def _keys(self) -> tuple[str, ...]: + return tuple( + dict.fromkeys( + ( + *self._yaml_values, + *(key for row in self._database_rows.values() for key in row), + *self._runtime_values, + ) + ) + ) + + def _resolution_for(self, key: str) -> Resolved: + rule: Final = rule_for(self._section, key) + yaml_value: Final[SettingValue] = self._yaml_values.get(key, ABSENT) + db_value: Final[SettingValue] = self._database_rows.get(rule.db_row, _EMPTY_VALUES).get(key, ABSENT) + return resolve(yaml_value, db_value) diff --git a/litellm/proxy/management_endpoints/coordination_redis_endpoints.py b/litellm/proxy/management_endpoints/coordination_redis_endpoints.py index 88dc09ab001..8e64e1ea651 100644 --- a/litellm/proxy/management_endpoints/coordination_redis_endpoints.py +++ b/litellm/proxy/management_endpoints/coordination_redis_endpoints.py @@ -198,7 +198,7 @@ async def _current_coordination_redis_settings() -> dict[str, object] | None: config_state: Final = _SETTINGS_ADAPTER.validate_python(proxy_config.get_config_state()) general_settings: Final = config_state.get(_GENERAL_SETTINGS_PARAM_NAME) - if not isinstance(general_settings, dict): + if not isinstance(general_settings, Mapping): return None from_file: Final = general_settings.get(_COORDINATION_REDIS_KEY) if isinstance(from_file, dict): diff --git a/litellm/proxy/plugin_routes.py b/litellm/proxy/plugin_routes.py index a72ecd4b0f2..eb6fe7dd177 100644 --- a/litellm/proxy/plugin_routes.py +++ b/litellm/proxy/plugin_routes.py @@ -67,7 +67,7 @@ def _configured_key_header_names() -> frozenset[str]: except Exception: return frozenset() general_settings: Final = getattr(proxy_server, "general_settings", None) - if not isinstance(general_settings, dict): + if not isinstance(general_settings, Mapping): return frozenset() name: Final[object] = general_settings.get("litellm_key_header_name") return frozenset({name.lower()}) if isinstance(name, str) and name else frozenset() diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 721e9992e92..bb0e432af51 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -431,20 +431,25 @@ from litellm.proxy.common_utils.user_api_key_cache import ( model_access_group_spend_counter_key, tag_cache_key, ) -from litellm.proxy.config_resolvers import resolve_fields +from litellm.proxy.config_resolvers import SettingsStore, resolve_fields from litellm.proxy.config_resolvers.alerting import ( EMAIL_DESCRIPTORS, MS_TEAMS_DESCRIPTORS, SLACK_DESCRIPTORS, ) from litellm.proxy.config_resolvers.changed_section_keys import changed_section_keys +from litellm.proxy.config_resolvers.settings_rules import ( + DbRow, + Section, + coerce_bool, +) +from litellm.proxy.config_resolvers.settings_rules import ( + JsonValue as SettingsJsonValue, +) from litellm.proxy.container_endpoints.endpoints import router as container_router from litellm.proxy.credential_endpoints.endpoints import router as credential_router from litellm.proxy.db.db_transaction_queue.pod_lock_manager import PodLockManager -from litellm.proxy.db.db_transaction_queue.spend_log_cleanup import ( - SPEND_LOG_CLEANUP_BOUND_SETTINGS, - SpendLogCleanup, -) +from litellm.proxy.db.db_transaction_queue.spend_log_cleanup import SpendLogCleanup from litellm.proxy.db.db_transaction_queue.window_spend_update_queue import ( build_window_spend_transaction, ) @@ -4322,7 +4327,7 @@ def _scrub_guardrail_inner(inner: dict[str, JsonValue]) -> None: inner["guardrail"] = None -def _scrub_db_overlay_remote_module_loads(section: str, db_value: JsonValue) -> JsonValue: +def _scrub_db_overlay_remote_module_loads(section: str, db_value: object) -> object: """Strip ``s3://`` / ``gcs://`` entries from the DB-overlay value for fields whose contents reach ``get_instance_fn``. The same scheme is allowed from a YAML config (the documented operator flow) but a @@ -4802,6 +4807,21 @@ class _ConfigWithBaseline(dict[str, object]): self._baseline = MappingProxyType({key: copy.deepcopy(value) for key, value in config.items()}) +_EMPTY_SETTINGS_MAPPING: Final[Mapping[str, SettingsJsonValue]] = MappingProxyType({}) +_SETTINGS_MAPPING: Final = TypeAdapter(dict[str, SettingsJsonValue]) + + +def _as_settings_mapping(value: object) -> Mapping[str, SettingsJsonValue]: + if not isinstance(value, Mapping): + return _EMPTY_SETTINGS_MAPPING + return _SETTINGS_MAPPING.validate_python(value) + + +def _bind_general_settings_store(settings: SettingsStore) -> None: + global general_settings + general_settings = settings # pyright: ignore[reportAssignmentType] # legacy global accepts mappings + + class ProxyConfig: """ Abstraction class on top of config loading/updating logic. Gives us one place to control all config updating logic. @@ -4825,11 +4845,45 @@ class ProxyConfig: # whether an existing request predates the prices it just fetched, and re-serving one # costs a single fetch where skipping one leaves it priced wrong indefinitely self.model_cost_map_applied_revision: int = 0 - # Keys explicitly set in the YAML config file. Used to give YAML - # precedence over stale DB-cached values for these specific keys - # during periodic config reloads (_update_general_settings). - self._yaml_general_settings_keys: set[str] = set() # mutable-ok: populated once at startup, read-only thereafter # fmt: skip - self._yaml_spend_log_cleanup_bounds: dict[str, object] = {} # mutable-ok: snapshot of YAML bounds at load time # fmt: skip + self.settings: Final[SettingsStore] = SettingsStore("general_settings") + self.router_settings: Final[SettingsStore] = SettingsStore("router_settings") + self.litellm_settings: Final[SettingsStore] = SettingsStore("litellm_settings") + self.environment_variables: Final[SettingsStore] = SettingsStore("environment_variables") + self._settings_stores: Final[Mapping[Section, SettingsStore]] = MappingProxyType( + { + "general_settings": self.settings, + "router_settings": self.router_settings, + "litellm_settings": self.litellm_settings, + "environment_variables": self.environment_variables, + } + ) + + def _load_yaml_settings_stores(self, config: Mapping[str, object]) -> None: + global config_passthrough_endpoints + for section, store in self._settings_stores.items(): + store.load_yaml(_as_settings_mapping(config.get(section))) + store.apply_db_row(section, _EMPTY_SETTINGS_MAPPING) + yaml_endpoints: Final = self.settings.config_value("pass_through_endpoints") + config_passthrough_endpoints = ( + [dict(endpoint) for endpoint in yaml_endpoints if isinstance(endpoint, dict)] + if isinstance(yaml_endpoints, list) + else None + ) + + def _config_with_resolved_settings(self, config: Mapping[str, object]) -> dict[str, object]: + return { # mutable-ok: get_config preserves the mutable mapping contract used by existing loaders + **config, + **{ + section: dict(store.resolved()) + for section, store in self._settings_stores.items() + if isinstance(config.get(section), Mapping) or len(store) > 0 + }, + } + + def _apply_resolved_runtime_settings(self, config: Mapping[str, object]) -> None: + for section, store in self._settings_stores.items(): + if isinstance(config.get(section), Mapping): + store.apply_runtime_values(_as_settings_mapping(config[section])) def is_yaml(self, config_file_path: str) -> bool: if not os.path.isfile(config_file_path): @@ -4988,6 +5042,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( @@ -4996,10 +5051,38 @@ class ProxyConfig: removed_keys=removed_keys, prisma_client=prisma_client, ) - if not wrote_section: + if wrote_section is None: return + store: Final = self._settings_stores.get(cast(Section, section_name)) + if store is not None: + store.apply_db_row(cast(DbRow, section_name), wrote_section) 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 + subject: Final = ( + f"key '{rejected[0]}' is" if len(rejected) == 1 else f"keys {', '.join(repr(key) for key in rejected)} are" + ) + pronoun: Final = "it" if len(rejected) == 1 else "them" + raise HTTPException( + status_code=400, + detail={ + "error": f"{section_name} {subject} 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 {pronoun}, " + f"or remove {pronoun} from the file to let the database own {pronoun}" + ), + }, + ) + async def _upsert_changed_config_section( self, *, @@ -5007,7 +5090,7 @@ class ProxyConfig: changed_keys: Mapping[str, JsonValue], removed_keys: frozenset[str], prisma_client: PrismaClient, - ) -> bool: + ) -> Mapping[str, JsonValue] | None: async with prisma_client.tx() as tx: await tx.query_raw(_CONFIG_SECTION_LOCK_SQL, section_name) config_table: Final = cast("TableActions[_ConfigParamRow]", tx.litellm_config) @@ -5031,14 +5114,14 @@ class ProxyConfig: } ) if merged_section == existing_section: - return False + return None serialized_section: Final = json.dumps(dict(merged_section)) # mutable-ok: JSON encoder requires a dict config_data: Final[_ConfigParamUpsert] = { "create": {"param_name": section_name, "param_value": serialized_section}, "update": {"param_value": serialized_section}, } await config_table.upsert(where=config_where, data=config_data) - return True + return merged_section async def save_environment_variables(self, updates: dict[str, str | None]) -> None: """Persist specific environment variables to the DB config row. @@ -5372,6 +5455,8 @@ class ProxyConfig: config = await self._get_config_from_file(config_file_path=config_file_path) + self._load_yaml_settings_stores(config) + ## UPDATE CONFIG WITH DB if prisma_client is not None and store_model_in_db is True: config = await self._update_config_from_db( @@ -5380,6 +5465,8 @@ class ProxyConfig: store_model_in_db=store_model_in_db, ) + config = self._config_with_resolved_settings(config) + ## PRINT YAML FOR CONFIRMING IT WORKS printed_yaml: Final = copy.deepcopy(config) printed_yaml.pop("environment_variables", None) @@ -5387,6 +5474,7 @@ class ProxyConfig: self._initialize_secret_manager_from_raw_config(config=config, config_file_path=config_file_path) config = self._check_for_os_environ_vars(config=config) + self._apply_resolved_runtime_settings(config) self.update_config_state(config=config) @@ -5965,17 +6053,6 @@ class ProxyConfig: _hc_staleness = None _hc_ignore_transient = False if general_settings: - # Record which keys were explicitly set in the YAML config file. - # These keys take precedence over DB-cached values during periodic - # reloads (see _update_general_settings). - self._yaml_general_settings_keys = set(general_settings.keys()) # mutable-ok: snapshot of YAML keys at load time # fmt: skip - # The VALUES matter for the cleanup bounds, not just which keys were - # set: clearing one from the dashboard has to fall back to what the - # YAML declared, and a set of names cannot answer that. - self._yaml_spend_log_cleanup_bounds = { # mutable-ok: snapshot of YAML bounds at load time # fmt: skip - key: general_settings[key] for key in SPEND_LOG_CLEANUP_BOUND_SETTINGS if key in general_settings - } - ### LOAD KEY MANAGEMENT SETTINGS ### # The secret manager itself is brought up by get_config(), which runs before the # `os.environ/` references in this config were resolved. Re-reading the settings here @@ -6121,7 +6198,6 @@ class ProxyConfig: ## pass through endpoints if general_settings.get("pass_through_endpoints", None) is not None: - config_passthrough_endpoints = general_settings["pass_through_endpoints"] await initialize_pass_through_endpoints( pass_through_endpoints=general_settings["pass_through_endpoints"], config_file_path=config_file_path, @@ -6348,7 +6424,8 @@ class ProxyConfig: ## NON-LLM CONFIGS eg. MCP tools, vector stores, etc. await self._init_non_llm_configs(config=config, config_file_path=config_file_path) - return router, router.get_model_list(), general_settings + _bind_general_settings_store(self.settings) + return router, router.get_model_list(), self.settings async def _init_non_llm_configs(self, config: dict, config_file_path: str | None = None): """ @@ -6796,13 +6873,6 @@ class ProxyConfig: config_data=config_data, llm_router=llm_router, prisma_client=prisma_client ) - # general settings - self._add_general_settings_from_db_config( - config_data=config_data, - general_settings=general_settings, - proxy_logging_obj=proxy_logging_obj, - ) - return still_desired_ids def _add_callback_from_db_to_in_memory_litellm_callbacks( @@ -7009,121 +7079,25 @@ class ProxyConfig: async def _add_router_settings_from_db_config( self, - config_data: dict, + config_data: Mapping[str, object], llm_router: Router | None, prisma_client: PrismaClient | None, ) -> None: - """ - Adds router settings from DB config to litellm proxy - - 1. Get router settings from DB - 2. Get router settings from config - 3. Combine both - 4. Update router settings - """ - if llm_router is not None and prisma_client is not None: - db_router_settings: Final[_ConfigParamRow | None] = await _config_param_table(prisma_client).find_first( - where={"param_name": "router_settings"} - ) - - config_router_settings: Final = config_data.get("router_settings", {}) - - combined_router_settings = {} - if ( - config_router_settings is not None - and isinstance(config_router_settings, dict) - and db_router_settings is not None - and isinstance(db_router_settings.param_value, dict) - ): - from litellm.utils import _update_dictionary - - db_overlay_deferring_empty_lists_to_config: Final = { - k: v - for k, v in db_router_settings.param_value.items() - if not (k in config_router_settings and isinstance(v, list) and len(v) == 0) - } - combined_router_settings = _update_dictionary( - config_router_settings, db_overlay_deferring_empty_lists_to_config - ) - elif config_router_settings is not None and isinstance(config_router_settings, dict): - combined_router_settings = config_router_settings - elif db_router_settings is not None and isinstance(db_router_settings.param_value, dict): - combined_router_settings = db_router_settings.param_value - - if combined_router_settings: - llm_router.update_settings(**combined_router_settings) - - def _add_general_settings_from_db_config( - self, config_data: dict, general_settings: dict, proxy_logging_obj: ProxyLogging - ) -> None: - """ - Adds general settings from DB config to litellm proxy - - Args: - config_data: dict - general_settings: dict - global general_settings currently in use - proxy_logging_obj: ProxyLogging - """ - _general_settings: Final = config_data.get("general_settings", {}) - - if _general_settings is not None and "alerting" in _general_settings: - if ( - general_settings is not None - and general_settings.get("alerting", None) is not None - and isinstance(general_settings["alerting"], list) - and _general_settings.get("alerting", None) is not None - and isinstance(_general_settings["alerting"], list) - ): - # Merge DB and YAML/config alerting values instead of overriding - _yaml_alerting: Final = set(general_settings["alerting"]) - _db_alerting: Final = set(_general_settings["alerting"]) - _merged_alerting = list(_yaml_alerting.union(_db_alerting)) - # Preserve order: YAML values first, then DB values - _merged_alerting = list(general_settings["alerting"]) + [ - item for item in _general_settings["alerting"] if item not in general_settings["alerting"] - ] - verbose_proxy_logger.debug( - "Merging alerting values: YAML=%s, DB=%s, Merged=%s", - general_settings["alerting"], - _general_settings["alerting"], - _merged_alerting, - ) - general_settings["alerting"] = _merged_alerting - # Use update_values to properly set alerting for both slack and email - proxy_logging_obj.update_values( - alerting=general_settings["alerting"], - ) - elif general_settings is None: - general_settings = {} - general_settings["alerting"] = _general_settings["alerting"] - # Use update_values to properly set alerting for both slack and email - proxy_logging_obj.update_values( - alerting=general_settings["alerting"], - ) - elif isinstance(general_settings, dict): - general_settings["alerting"] = _general_settings["alerting"] - # Use update_values to properly set alerting for both slack and email - proxy_logging_obj.update_values( - alerting=general_settings["alerting"], - ) - - if _general_settings is not None and "alert_types" in _general_settings: - general_settings["alert_types"] = _general_settings["alert_types"] - proxy_logging_obj.alert_types = general_settings["alert_types"] - proxy_logging_obj.slack_alerting_instance.update_values( - alert_types=general_settings["alert_types"], llm_router=llm_router - ) - - if _general_settings is not None and "alert_to_webhook_url" in _general_settings: - general_settings["alert_to_webhook_url"] = _general_settings["alert_to_webhook_url"] - proxy_logging_obj.slack_alerting_instance.update_values( - alert_to_webhook_url=general_settings["alert_to_webhook_url"], - llm_router=llm_router, - ) - - if _general_settings is not None and "plugins" in _general_settings: - general_settings["plugins"] = _general_settings["plugins"] - register_plugins_from_config(general_settings) + if llm_router is None or prisma_client is None: + return + self.router_settings.load_yaml(_as_settings_mapping(config_data.get("router_settings"))) + db_router_settings: Final[_ConfigParamRow | None] = await _config_param_table(prisma_client).find_first( + where={"param_name": "router_settings"} + ) + db_values: Final = ( + _as_settings_mapping(db_router_settings.param_value) + if db_router_settings is not None and db_router_settings.param_value is not None + else _EMPTY_SETTINGS_MAPPING + ) + self.router_settings.apply_db_row("router_settings", db_values) + combined_router_settings: Final = self.router_settings.resolved() + if combined_router_settings: + llm_router.update_settings(**combined_router_settings) async def _reschedule_spend_log_cleanup_job(self): """ @@ -7199,260 +7173,143 @@ class ProxyConfig: except ValueError: verbose_proxy_logger.error("Invalid maximum_spend_logs_retention_interval value") - async def _update_general_settings(self, db_general_settings: Json | None): - """ - Pull from DB, read general settings value - """ - global general_settings, store_model_in_db + async def _update_general_settings(self, db_general_settings: Mapping[str, SettingsJsonValue] | None) -> None: + global general_settings if db_general_settings is None: return - _general_settings: Final = dict(db_general_settings) - ## MAX PARALLEL REQUESTS ## - if "max_parallel_requests" in _general_settings: - general_settings["max_parallel_requests"] = _general_settings["max_parallel_requests"] + if not isinstance(general_settings, SettingsStore): + self.settings.load_yaml(_as_settings_mapping(general_settings)) + cache_size_was_db: Final = self.settings.source("user_api_key_cache_max_size") == "db" + previous_retention_values: Final = self._resolved_retention_values() + previous_pass_through_endpoints: Final = self.settings.get("pass_through_endpoints") + self.settings.apply_db_row("general_settings", db_general_settings) + _bind_general_settings_store(self.settings) + await self._apply_general_settings_side_effects( + db_general_settings, + cache_size_was_db, + previous_retention_values, + previous_pass_through_endpoints, + ) - if "global_max_parallel_requests" in _general_settings: - general_settings["global_max_parallel_requests"] = _general_settings["global_max_parallel_requests"] - - if "max_batch_file_size_mb" not in self._yaml_general_settings_keys: - general_settings["max_batch_file_size_mb"] = _general_settings.get("max_batch_file_size_mb") - - if "max_file_size_mb" not in self._yaml_general_settings_keys: - general_settings["max_file_size_mb"] = _general_settings.get("max_file_size_mb") - - if "allowed_file_extensions" not in self._yaml_general_settings_keys: - general_settings["allowed_file_extensions"] = _general_settings.get("allowed_file_extensions") - - if "blocked_file_extensions" not in self._yaml_general_settings_keys: - general_settings["blocked_file_extensions"] = _general_settings.get("blocked_file_extensions") - - ## ALERTING ARGS ## - if "alerting_args" in _general_settings: - general_settings["alerting_args"] = _general_settings["alerting_args"] - proxy_logging_obj.slack_alerting_instance.update_values( - alerting_args=general_settings["alerting_args"], + def _resolved_retention_values(self) -> tuple[SettingsJsonValue | None, ...]: + return tuple( + self.settings.get(key) + for key in ( + "maximum_spend_logs_retention_period", + "maximum_autorouter_session_retention_period", + "maximum_health_check_retention_period", ) + ) - ## PASS-THROUGH ENDPOINTS ## - if "pass_through_endpoints" in _general_settings: - db_pass_through_endpoints: Final = _general_settings["pass_through_endpoints"] - db_pass_through_paths: Final = frozenset( - endpoint.get("path") for endpoint in db_pass_through_endpoints if isinstance(endpoint, dict) - ) - general_settings["pass_through_endpoints"] = [ - *db_pass_through_endpoints, - *( - endpoint - for endpoint in config_passthrough_endpoints or () - if endpoint.get("path") not in db_pass_through_paths - ), - ] - await initialize_pass_through_endpoints(pass_through_endpoints=db_pass_through_endpoints) - - ## UI ACCESS MODE ## - if "ui_access_mode" in _general_settings: - general_settings["ui_access_mode"] = _general_settings["ui_access_mode"] - - ## STORE PROMPTS IN SPEND LOGS ## - if "store_prompts_in_spend_logs" in _general_settings: - # If the YAML config explicitly set this key, prefer the YAML value - # over the DB-cached value. This ensures config changes deployed via - # CI/CD take effect without requiring a manual /config/update call. - # When YAML does not set this key, the DB value is used (preserving - # admin UI runtime changes). - if "store_prompts_in_spend_logs" in self._yaml_general_settings_keys: - value = general_settings.get("store_prompts_in_spend_logs") - else: - value = _general_settings["store_prompts_in_spend_logs"] - # Normalize case: handle True/true/TRUE, False/false/FALSE, None/null - if value is None: - general_settings["store_prompts_in_spend_logs"] = None - elif isinstance(value, bool): - general_settings["store_prompts_in_spend_logs"] = value - elif isinstance(value, str): - # Case-insensitive string comparison - general_settings["store_prompts_in_spend_logs"] = value.lower() == "true" - else: - # For other types, convert to bool - general_settings["store_prompts_in_spend_logs"] = bool(value) - - if "disable_auto_add_proxy_admin_to_teams" in _general_settings: - value = _general_settings["disable_auto_add_proxy_admin_to_teams"] - if isinstance(value, str): - general_settings["disable_auto_add_proxy_admin_to_teams"] = value.lower() == "true" - else: - general_settings["disable_auto_add_proxy_admin_to_teams"] = value if value is None else bool(value) - - if "apply_user_budget_to_team_keys" in _general_settings and ( - "apply_user_budget_to_team_keys" not in self._yaml_general_settings_keys - ): - db_value: Final = _general_settings["apply_user_budget_to_team_keys"] - if isinstance(db_value, str): - general_settings["apply_user_budget_to_team_keys"] = db_value.lower() == "true" - else: - general_settings["apply_user_budget_to_team_keys"] = db_value if db_value is None else bool(db_value) - - if "enable_openai_websocket_passthrough" not in self._yaml_general_settings_keys: - general_settings["enable_openai_websocket_passthrough"] = _general_settings.get( - "enable_openai_websocket_passthrough" - ) - - if "user_api_key_cache_max_size" not in self._yaml_general_settings_keys: - db_cache_max_size: Final = _general_settings.get("user_api_key_cache_max_size") - try: - cache_max_size: Final = ConfigGeneralSettings.model_validate( - MappingProxyType({"user_api_key_cache_max_size": db_cache_max_size}) - ).user_api_key_cache_max_size - except ValidationError: - verbose_proxy_logger.warning( - "Ignoring invalid general_settings.user_api_key_cache_max_size=%r from the DB", db_cache_max_size - ) - else: - if cache_max_size is None: - general_settings.pop("user_api_key_cache_max_size", None) - else: - general_settings["user_api_key_cache_max_size"] = cache_max_size - user_api_key_cache.update_in_memory_max_size(cache_max_size) - - ## STORE MODEL IN DB ## - if "store_model_in_db" in _general_settings: - value = _general_settings["store_model_in_db"] - if value is None: - pass # Don't change store_model_in_db to None; keep current value - elif isinstance(value, bool): - store_model_in_db = value - elif isinstance(value, str): - store_model_in_db = value.lower() == "true" - else: - store_model_in_db = bool(value) - general_settings["store_model_in_db"] = store_model_in_db - - ## MAXIMUM SPEND LOGS RETENTION PERIOD ## - if "maximum_spend_logs_retention_period" in _general_settings: - old_value: Final = general_settings.get("maximum_spend_logs_retention_period") - new_value: Final = _general_settings["maximum_spend_logs_retention_period"] - general_settings["maximum_spend_logs_retention_period"] = new_value - # Reschedule cleanup job if value changed (including when set to None) - if old_value != new_value: - await self._reschedule_spend_log_cleanup_job() - - if "maximum_autorouter_session_retention_period" in _general_settings: - old_session_value: Final = general_settings.get("maximum_autorouter_session_retention_period") - new_session_value: Final = _general_settings["maximum_autorouter_session_retention_period"] - general_settings["maximum_autorouter_session_retention_period"] = new_session_value - if old_session_value != new_session_value: - await self._reschedule_spend_log_cleanup_job() - - if "maximum_health_check_retention_period" in _general_settings: - old_health_check_value: Final = general_settings.get("maximum_health_check_retention_period") - new_health_check_value: Final = _general_settings["maximum_health_check_retention_period"] - general_settings["maximum_health_check_retention_period"] = new_health_check_value - if old_health_check_value != new_health_check_value: - await self._reschedule_spend_log_cleanup_job() - - ## SPEND LOG CLEANUP BOUNDS ## - # The dashboard writes these straight to the DB, so without copying them - # here the running cleanup job never sees them. A key the DB no longer - # carries was cleared from the dashboard, and falls back to whatever - # config.yaml declared, or to None (the shipped default) when it declared - # nothing. Leaving the deleted DB value in memory would keep enforcing the - # bound the operator just removed. - for cleanup_key in SPEND_LOG_CLEANUP_BOUND_SETTINGS: - general_settings[cleanup_key] = _general_settings.get( - cleanup_key, self._yaml_spend_log_cleanup_bounds.get(cleanup_key) - ) - - for key in ( - "user_url_allowed_hosts", - "user_url_validation", - "provider_url_destination_allowed_hosts", - ): - if key in _general_settings: - general_settings[key] = _general_settings[key] - _apply_ssrf_general_settings(_general_settings) - - def _update_config_fields( + async def _apply_general_settings_side_effects( self, - current_config: dict, - param_name: Literal[ - "general_settings", - "router_settings", - "litellm_settings", - "environment_variables", - ], - db_param_value: Any, - ) -> dict: - """ - Updates the config fields with the new values from the DB + db_values: Mapping[str, SettingsJsonValue], + cache_size_was_db: bool, + previous_retention_values: tuple[SettingsJsonValue | None, ...], + previous_pass_through_endpoints: SettingsJsonValue | None, + ) -> None: + effects: Final = ( + self._apply_alerting_settings, + partial(self._apply_pass_through_settings, previous_endpoints=previous_pass_through_endpoints), + self._apply_boolean_settings, + partial(self._apply_cache_size_setting, cache_size_was_db=cache_size_was_db), + self._apply_store_model_in_db_setting, + partial(self._apply_retention_settings, previous_retention_values=previous_retention_values), + self._apply_ssrf_settings, + ) + for effect in effects: + await effect(db_values) - Args: - current_config (dict): Current configuration dictionary to update - param_name (Literal): Name of the parameter to update - db_param_value (Any): New value from the database + async def _apply_alerting_settings(self, db_values: Mapping[str, SettingsJsonValue]) -> None: + alerting: Final = self.settings.get("alerting") + if "alerting" in db_values and isinstance(alerting, list): + proxy_logging_obj.update_values(alerting=alerting) - Returns: - dict: Updated configuration dictionary - """ + alerting_args: Final = self.settings.get("alerting_args") + if "alerting_args" in db_values and self.settings.source("alerting_args") == "db": + proxy_logging_obj.slack_alerting_instance.update_values(alerting_args=alerting_args) - def _deep_merge_dicts(dst: dict, src: dict) -> None: - """ - Deep-merge src into dst, skipping None values and empty lists from src. - On conflicts, src (DB) wins, but empty lists are treated as "no value" and don't overwrite. - """ - stack: Final = [(dst, src)] - while stack: - d, s = stack.pop() - for k, v in s.items(): - if v is None: - # Preserve existing config when DB value is None (matches prior behavior) - continue - # Skip empty lists - treat them as "no value" to preserve file config - if isinstance(v, list) and len(v) == 0: - continue - if isinstance(v, dict) and isinstance(d.get(k), dict): - stack.append((d[k], v)) - else: - d[k] = v + alert_types: Final = self.settings.get("alert_types") + if "alert_types" in db_values and self.settings.source("alert_types") == "db": + proxy_logging_obj.alert_types = alert_types + proxy_logging_obj.slack_alerting_instance.update_values(alert_types=alert_types, llm_router=llm_router) - # Strip remote-URL module loads from the DB-overlay before merge — - # the YAML-load callsites have ``config_file_path`` set, so a - # DB-sourced ``s3://`` value would otherwise reach - # ``_load_instance_from_remote_storage`` without going through - # the runtime gate. - db_param_value = _scrub_db_overlay_remote_module_loads(section=param_name, db_value=db_param_value) + webhook_url: Final = self.settings.get("alert_to_webhook_url") + if "alert_to_webhook_url" in db_values and self.settings.source("alert_to_webhook_url") == "db": + proxy_logging_obj.slack_alerting_instance.update_values( + alert_to_webhook_url=webhook_url, llm_router=llm_router + ) - if param_name == "environment_variables": - decrypted_env_vars = self._decrypt_and_set_db_env_variables(db_param_value, return_original_value=True) - # Normalize keys when loading from DB so services expecting uppercase - # (e.g. Datadog) can read them even if stored in lowercase. - merged_env_vars: Final[dict] = {} - for key, value in decrypted_env_vars.items(): - merged_env_vars[key] = value - upper_key = key.upper() - merged_env_vars[upper_key] = value - os.environ[upper_key] = value + if "plugins" in db_values and self.settings.source("plugins") == "db": + register_plugins_from_config(self.settings) - current_config.setdefault("environment_variables", {}).update(merged_env_vars) - return current_config - elif param_name == "litellm_settings" and isinstance(db_param_value, dict): - for key, value in db_param_value.items(): - if key in LITELLM_SETTINGS_SAFE_DB_OVERRIDES: # params that are safe to override with db values - setattr(litellm, key, value) + async def _apply_pass_through_settings( + self, + db_values: Mapping[str, SettingsJsonValue], + previous_endpoints: SettingsJsonValue | None, + ) -> None: + del db_values + resolved_endpoints: Final = self.settings.get("pass_through_endpoints") + if resolved_endpoints == previous_endpoints: + return + await initialize_pass_through_endpoints( + pass_through_endpoints=resolved_endpoints if isinstance(resolved_endpoints, list) else [] + ) - # If param doesn't exist in config, add it - if param_name not in current_config: - current_config[param_name] = db_param_value + async def _apply_boolean_settings(self, db_values: Mapping[str, SettingsJsonValue]) -> None: + for key in ( + "store_prompts_in_spend_logs", + "disable_auto_add_proxy_admin_to_teams", + "apply_user_budget_to_team_keys", + ): + if key in db_values and (value := self.settings.get(key)) is not None: + self.settings[key] = coerce_bool(value) - return current_config - - # For dictionary values, update only non-none values - if isinstance(current_config[param_name], dict) and isinstance(db_param_value, dict): - _deep_merge_dicts(current_config[param_name], db_param_value) + async def _apply_cache_size_setting( + self, + db_values: Mapping[str, SettingsJsonValue], + cache_size_was_db: bool, + ) -> None: + if "user_api_key_cache_max_size" not in db_values and not cache_size_was_db: + return + cache_value: Final = self.settings.get("user_api_key_cache_max_size") + try: + cache_max_size: Final = ConfigGeneralSettings.model_validate( + MappingProxyType({"user_api_key_cache_max_size": cache_value}) + ).user_api_key_cache_max_size + except ValidationError: + self.settings.pop("user_api_key_cache_max_size", None) + verbose_proxy_logger.warning( + "Ignoring invalid general_settings.user_api_key_cache_max_size=%r from the DB", cache_value + ) + return + if cache_max_size is None: + self.settings.pop("user_api_key_cache_max_size", None) else: - # Non-dict or mismatched types: DB value replaces config (unchanged behavior) - current_config[param_name] = db_param_value + self.settings["user_api_key_cache_max_size"] = cache_max_size + user_api_key_cache.update_in_memory_max_size(cache_max_size) - return current_config + async def _apply_store_model_in_db_setting(self, db_values: Mapping[str, SettingsJsonValue]) -> None: + global store_model_in_db + if "store_model_in_db" not in db_values: + return + value: Final = self.settings.get("store_model_in_db") + if value is None: + return + normalized: Final = coerce_bool(value) + store_model_in_db = normalized if isinstance(normalized, bool) else bool(normalized) + self.settings["store_model_in_db"] = store_model_in_db + + async def _apply_retention_settings( + self, + db_values: Mapping[str, SettingsJsonValue], + previous_retention_values: tuple[SettingsJsonValue | None, ...], + ) -> None: + if previous_retention_values != self._resolved_retention_values(): + await self._reschedule_spend_log_cleanup_job() + + async def _apply_ssrf_settings(self, db_values: Mapping[str, SettingsJsonValue]) -> None: + _apply_ssrf_general_settings(db_values) async def _update_config_from_db( self, @@ -7464,37 +7321,48 @@ class ProxyConfig: verbose_proxy_logger.info("'store_model_in_db' is not True, skipping db updates") return config - _tasks: Final = [] - keys: Final = [ - "general_settings", - "router_settings", - "litellm_settings", - "environment_variables", - ] - for k in keys: - _tasks.append(get_config_param(prisma_client, k)) - - responses: Final = await asyncio.gather(*_tasks) - for response in responses: - if response is None: + sections: Final = tuple(self._settings_stores) + responses: Final = await asyncio.gather(*(get_config_param(prisma_client, section) for section in sections)) + for section, response in zip(sections, responses): + if response is None or (param_value := getattr(response, "param_value", None)) is None: continue - param_name = getattr(response, "param_name", None) - param_value = getattr(response, "param_value", None) verbose_proxy_logger.debug( "param_name=%s, param_value=%s", - param_name, - _redact_config_param_value_for_logging(param_name, param_value), + section, + _redact_config_param_value_for_logging(section, param_value), ) - - if param_name is not None and param_value is not None: - config = self._update_config_fields( - current_config=config, - param_name=param_name, - db_param_value=param_value, + if section == "litellm_settings": + self._apply_litellm_settings_db_values(self._prepared_db_settings_values(section, param_value)) + else: + self._settings_stores[section].apply_db_row( + section, + self._prepared_db_settings_values(section, param_value), ) - return config + return self._config_with_resolved_settings(config) + + def _prepared_db_settings_values(self, section: Section, value: object) -> Mapping[str, SettingsJsonValue]: + if section == "environment_variables": + decrypted: Final = self._decrypt_and_set_db_env_variables( + dict(_as_settings_mapping(value)), return_original_value=True + ) + normalized: Final = { + **decrypted, + **{key.upper(): decrypted_value for key, decrypted_value in decrypted.items()}, + } + for key, decrypted_value in normalized.items(): + os.environ[key] = decrypted_value + return _as_settings_mapping(normalized) + + scrubbed: Final = _scrub_db_overlay_remote_module_loads(section=section, db_value=value) + return _as_settings_mapping(scrubbed) + + def _apply_litellm_settings_db_values(self, db_values: Mapping[str, SettingsJsonValue]) -> None: + self.litellm_settings.apply_db_row("litellm_settings", db_values) + for key in LITELLM_SETTINGS_SAFE_DB_OVERRIDES: + if key in db_values and (value := self.litellm_settings.get(key)) is not None: + setattr(litellm, key, value) def _should_load_db_object(self, object_type: str | SupportedDBObjectType) -> bool: return should_load_db_object(object_type=object_type) @@ -7730,12 +7598,8 @@ class ProxyConfig: if config_record is None or config_record.param_value is None: return raw_settings: Final = config_record.param_value - litellm_settings: Final = json.loads(raw_settings) if isinstance(raw_settings, str) else raw_settings - if not isinstance(litellm_settings, dict): - return - for key, value in litellm_settings.items(): - if key in LITELLM_SETTINGS_SAFE_DB_OVERRIDES: - setattr(litellm, key, value) + db_values: Final = self._prepared_db_settings_values("litellm_settings", raw_settings) + self._apply_litellm_settings_db_values(db_values) async def _init_semantic_filter_settings_in_db(self, prisma_client: PrismaClient): """ @@ -17377,6 +17241,11 @@ async def update_config_general_settings( ## update db + proxy_config.reject_config_owned_writes( + section_name="general_settings", + changed_keys={data.field_name: cast(JsonValue, data.field_value)}, # cast-ok: validated above + ) + field_value = data.field_value if data.field_name == "plugins": field_value = _preserve_redacted_plugin_keys(field_value, general_settings.get("plugins")) @@ -17394,6 +17263,7 @@ async def update_config_general_settings( }, ) await invalidate_config_param("general_settings") + proxy_config.settings.apply_db_row("general_settings", general_settings) asyncio.create_task( create_config_audit_log( "general_settings", "updated", before_general_settings, general_settings, user_api_key_dict @@ -17582,37 +17452,30 @@ 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: + 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 @@ -17730,6 +17593,7 @@ async def _persist_general_settings_ui_litellm_field( field_name: str, value: object, user_api_key_dict: UserAPIKeyAuth ) -> dict: validated: Final = _validate_general_settings_ui_litellm_value(field_name, value) + proxy_config.reject_config_owned_writes(section_name="litellm_settings", changed_keys={field_name: validated}) config: Final = await proxy_config.get_config() before_value: Final = config.get("litellm_settings", {}).get(field_name) setattr(litellm, field_name, validated) @@ -17742,9 +17606,10 @@ async def _persist_general_settings_ui_litellm_field( async def _reset_general_settings_ui_litellm_field(field_name: str, user_api_key_dict: UserAPIKeyAuth) -> dict: + default_value: Final = _general_settings_ui_litellm_default(_GENERAL_SETTINGS_UI_LITELLM_FIELDS[field_name]) + proxy_config.reject_config_owned_writes(section_name="litellm_settings", changed_keys={field_name: default_value}) config: Final = await proxy_config.get_config() before_value: Final = config.get("litellm_settings", {}).get(field_name) - default_value: Final = _general_settings_ui_litellm_default(_GENERAL_SETTINGS_UI_LITELLM_FIELDS[field_name]) setattr(litellm, field_name, default_value) if "litellm_settings" in config: config["litellm_settings"].pop(field_name, None) @@ -17845,6 +17710,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, @@ -17858,6 +17724,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) @@ -17870,8 +17738,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( @@ -17882,6 +17751,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) @@ -17903,6 +17774,7 @@ async def get_config_list( stored_in_db_litellm = False else: stored_in_db_litellm = None + _litellm_source = proxy_config.litellm_settings.source(litellm_field_name) return_val.append( ConfigList( field_name=litellm_field_name, @@ -17914,6 +17786,8 @@ async def get_config_list( field_options=list(spec.get("options", ())) or None, field_tab=spec.get("tab"), nested_fields=None, + source=_litellm_source, + editable=_litellm_source != "config", ) ) @@ -17992,6 +17866,7 @@ async def delete_config_general_settings( }, ) await invalidate_config_param("general_settings") + proxy_config.settings.apply_db_row("general_settings", general_settings) asyncio.create_task( create_config_audit_log( "general_settings", "deleted", before_general_settings, general_settings, user_api_key_dict diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index de965aff889..fd160636d46 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -1483,10 +1483,13 @@ _UI_SETTINGS_OBJECT: Final = TypeAdapter(dict[str, JsonValue]) def apply_runtime_general_settings_flags(ui_settings: Mapping[str, JsonValue]) -> Mapping[str, JsonValue]: """Copy the UI settings that gate runtime behavior into ``general_settings``. Returns what was applied.""" + from litellm.proxy.config_resolvers import SettingsStore from litellm.proxy.proxy_server import general_settings flags: Final = {k: ui_settings[k] for k in _RUNTIME_GENERAL_SETTINGS_FLAGS if k in ui_settings} - if flags: + if isinstance(general_settings, SettingsStore): + general_settings.apply_db_row("ui_settings", flags) + elif flags: general_settings.update(flags) return MappingProxyType(flags) diff --git a/litellm/repositories/config_repository.py b/litellm/repositories/config_repository.py index 2e8e760db07..8b8280622fd 100644 --- a/litellm/repositories/config_repository.py +++ b/litellm/repositories/config_repository.py @@ -1,25 +1,18 @@ -""" -Config repository for database operations on LiteLLM_Config. +"""Config repository for database operations on LiteLLM_Config.""" -This repository handles config reconciliation between database values and -YAML configmap values. DB values override configmap values except for -None values and empty lists. -""" +from __future__ import annotations -import asyncio -import copy import json -import os from collections.abc import Mapping, Sequence -from typing import Any, Final, Literal, Protocol, cast +from typing import TYPE_CHECKING, Final, Protocol, cast -from litellm._logging import verbose_proxy_logger -from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper +if TYPE_CHECKING: + from litellm.proxy.utils import PrismaClient def _decoded_json(raw: str) -> object: """Decode a JSON-encoded config row value into an opaque object.""" - return json.loads(raw) + return cast(object, json.loads(raw)) class _ConfigRow(Protocol): @@ -40,16 +33,6 @@ class _ConfigTable(Protocol): async def delete(self, *, where: Mapping[str, str]) -> _ConfigRow | None: ... -class _ConfigDb(Protocol): - @property - def litellm_config(self) -> _ConfigTable: ... - - -class _PrismaHandle(Protocol): - @property - def db(self) -> _ConfigDb: ... - - class ConfigParam: """Simple wrapper for config parameter from DB.""" @@ -59,27 +42,20 @@ class ConfigParam: class ConfigRepository: - """Repository for config database operations with reconciliation support.""" + """Repository for config database operations.""" - CONFIG_PARAMS = [ - "general_settings", - "router_settings", - "litellm_settings", - "environment_variables", - ] - - def __init__(self, prisma_client: Any): - self._prisma_client = prisma_client + def __init__(self, prisma_client: PrismaClient | None): + self._prisma_client: Final = prisma_client @property - def prisma_client(self) -> _PrismaHandle: + def prisma_client(self) -> PrismaClient: if self._prisma_client is None: raise RuntimeError("No DB Connected. See - https://docs.litellm.ai/docs/proxy/virtual_keys") return self._prisma_client @property def _config_table(self) -> _ConfigTable: - return self.prisma_client.db.litellm_config + return cast(_ConfigTable, self.prisma_client.db.litellm_config) @property def table(self) -> _ConfigTable: @@ -125,141 +101,3 @@ class ConfigRepository: param_value = _decoded_json(param_value) result[record.param_name] = param_value return result - - def _deep_merge_dicts(self, dst: dict, src: dict) -> None: - """Deep-merge src into dst, skipping None values and empty lists from src. - - On conflicts, src (DB) wins, but empty lists are treated as "no value" - and don't overwrite the destination. - """ - stack: Final = [(dst, src)] - while stack: - d, s = stack.pop() - for k, v in s.items(): - if v is None: - continue - if isinstance(v, list) and len(v) == 0: - continue - if isinstance(v, dict) and isinstance(d.get(k), dict): - stack.append((d[k], v)) - else: - d[k] = v - - def _decrypt_env_variables( - self, env_vars: Mapping[str, object], return_original_value: bool = True - ) -> dict[str, str]: - """Decrypt environment variables from database.""" - decrypted: Final[dict[str, str]] = {} - for key, value in env_vars.items(): - if isinstance(value, str): - decrypted_value = decrypt_value_helper( - value=value, - key=key, - exception_type="debug", - return_original_value=return_original_value, - ) - if decrypted_value is not None: - decrypted[key] = decrypted_value - else: - decrypted[key] = str(value) - return decrypted - - def _normalize_env_variable_keys(self, env_vars: dict[str, str]) -> dict[str, str]: - """Normalize env variable keys to include both original and uppercase versions.""" - normalized: Final[dict[str, str]] = {} - for key, value in env_vars.items(): - normalized[key] = value - upper_key = key.upper() - normalized[upper_key] = value - return normalized - - def _update_config_fields( - self, - current_config: dict, - param_name: Literal[ - "general_settings", - "router_settings", - "litellm_settings", - "environment_variables", - ], - db_param_value: Any, - ) -> dict: - """Update config fields with DB values, handling the merge strategy.""" - if param_name == "environment_variables": - decrypted_env_vars: Final = self._decrypt_env_variables(db_param_value, return_original_value=True) - merged_env_vars: Final = self._normalize_env_variable_keys(decrypted_env_vars) - for env_key, value in merged_env_vars.items(): - os.environ[env_key] = value - - current_config.setdefault("environment_variables", {}).update(merged_env_vars) - return current_config - - if param_name not in current_config: - current_config[param_name] = db_param_value - return current_config - - if isinstance(current_config[param_name], dict) and isinstance(db_param_value, dict): - self._deep_merge_dicts(current_config[param_name], db_param_value) - else: - current_config[param_name] = db_param_value - - return current_config - - async def reconcile_config( - self, - yaml_config: dict, - store_model_in_db: bool | None = None, - ) -> dict: - """Reconcile config from YAML with database overrides. - - This is the main config reconciliation method that loads config params - from the database and merges them with the YAML config. DB values - override YAML values except for None values and empty lists. - - Args: - yaml_config: The configuration loaded from YAML file - store_model_in_db: Whether to load config from DB - - Returns: - The merged configuration with DB overrides applied - """ - if store_model_in_db is not True: - verbose_proxy_logger.info("'store_model_in_db' is not True, skipping db config reconciliation") - return yaml_config - - tasks: Final = [self.get_param(k) for k in self.CONFIG_PARAMS] - responses: Final = await asyncio.gather(*tasks) - - config = copy.deepcopy(yaml_config) - for response in responses: - if response is None: - continue - - param_name = response.param_name - param_value = response.param_value - verbose_proxy_logger.debug("param_name=%s, param_value=%s", param_name, param_value) - - if param_name is not None and param_value is not None: - config = self._update_config_fields( - current_config=config, - param_name=cast( - Literal[ - "general_settings", - "router_settings", - "litellm_settings", - "environment_variables", - ], - param_name, - ), - db_param_value=param_value, - ) - - return config - - async def prefetch_params(self, param_names: list[str]) -> None: - """Prefetch config params to warm the cache. - - This can be called before reconcile_config to ensure all needed - params are loaded in a single batch. - """ - await asyncio.gather(*[self.get_param(k) for k in param_names]) diff --git a/tests/e2e/management/test_config_misc_endpoints_e2e.py b/tests/e2e/management/test_config_misc_endpoints_e2e.py index 0906ab52fe9..20e98e993d4 100644 --- a/tests/e2e/management/test_config_misc_endpoints_e2e.py +++ b/tests/e2e/management/test_config_misc_endpoints_e2e.py @@ -24,10 +24,10 @@ from collections.abc import Callable from typing import Final import pytest -from pydantic import BaseModel, JsonValue +from pydantic import BaseModel, JsonValue, RootModel from e2e_config import unique_marker -from e2e_http import NoBody, Success, UnknownApiError, unwrap, unwrap_status +from e2e_http import NoBody, Success, unwrap, unwrap_status from lifecycle import ResourceManager from management_client import ManagementClient from models import KeyGenerateBody, LiteLLMParamsBody, TeamNewBody @@ -210,6 +210,24 @@ class ConfigFieldInfoParams(BaseModel): class ConfigFieldInfoResponse(BaseModel): field_name: str field_value: JsonValue + source: str + editable: bool + + +class ConfigListParams(BaseModel): + config_type: str + + +class ConfigListEntry(BaseModel): + field_name: str + field_value: JsonValue + stored_in_db: bool | None + source: str + editable: bool + + +class ConfigListResponse(RootModel[list[ConfigListEntry]]): + pass class RouterCurrentValues(BaseModel): @@ -556,17 +574,30 @@ class TestConfigPersistence: ) assert added.message == f"IP {allowed_ip} address added successfully" - field_info: Final = client.proxy.transport.get( - "/config/field/info", - headers=client.proxy.transport.master, - params=ConfigFieldInfoParams(field_name="max_parallel_requests"), - response_type=ConfigFieldInfoResponse, + listed: Final = unwrap( + client.proxy.transport.get( + "/config/list", + headers=client.proxy.transport.master, + params=ConfigListParams(config_type="general_settings"), + response_type=ConfigListResponse, + ) ) - match field_info: - case UnknownApiError(status_code=400, body=body): - assert "not in DB" in body - case _: - pytest.fail(f"expected max_parallel_requests to remain absent from the DB row, got {field_info}") + unrelated: Final = next(entry for entry in listed.root if entry.field_name == "max_parallel_requests") + assert unrelated.stored_in_db is not True + assert unrelated.source == "config" + assert unrelated.editable is False + + field_info: Final = unwrap( + client.proxy.transport.get( + "/config/field/info", + headers=client.proxy.transport.master, + params=ConfigFieldInfoParams(field_name="max_parallel_requests"), + response_type=ConfigFieldInfoResponse, + ) + ) + assert field_info.source == "config" + assert field_info.editable is False + assert field_info.field_value == unrelated.field_value class TestMcpServerSubmission: diff --git a/tests/proxy_unit_tests/test_proxy_config_unit_test.py b/tests/proxy_unit_tests/test_proxy_config_unit_test.py index 81648dc1158..5f236806685 100644 --- a/tests/proxy_unit_tests/test_proxy_config_unit_test.py +++ b/tests/proxy_unit_tests/test_proxy_config_unit_test.py @@ -288,68 +288,55 @@ async def test_json_logs_calls_turn_on_json(): class TestYamlStorePromptsDbOverride: - """ - Test that YAML store_prompts_in_spend_logs takes precedence over DB-cached value. - - When store_model_in_db=true, LiteLLM persists general_settings to the DB. - On periodic reloads, _update_general_settings() must NOT override - YAML-explicit values with stale DB values. - """ - - def _make_proxy_config_with_yaml_keys(self, yaml_keys: set) -> "ProxyConfig": - """Helper: create ProxyConfig with pre-populated _yaml_general_settings_keys.""" - proxy_config = ProxyConfig() - proxy_config._yaml_general_settings_keys = yaml_keys - return proxy_config - @pytest.mark.asyncio async def test_yaml_value_takes_precedence_over_db(self): - """When YAML sets store_prompts_in_spend_logs=false, DB value (true) should be ignored.""" - proxy_config = self._make_proxy_config_with_yaml_keys({"store_prompts_in_spend_logs"}) + proxy_config = ProxyConfig() + proxy_config.settings.load_yaml({"store_prompts_in_spend_logs": False}) - test_general_settings = {"store_prompts_in_spend_logs": False} - - with mock.patch("litellm.proxy.proxy_server.general_settings", test_general_settings): + with mock.patch("litellm.proxy.proxy_server.general_settings", proxy_config.settings): await proxy_config._update_general_settings( db_general_settings={"store_prompts_in_spend_logs": True}, ) - assert test_general_settings["store_prompts_in_spend_logs"] is False + from litellm.proxy import proxy_server + + assert proxy_server.general_settings["store_prompts_in_spend_logs"] is False + assert proxy_server.general_settings.source("store_prompts_in_spend_logs") == "config" @pytest.mark.asyncio async def test_db_value_used_when_yaml_does_not_set_key(self): - """When YAML does NOT set store_prompts_in_spend_logs, DB value should be used.""" - proxy_config = self._make_proxy_config_with_yaml_keys({"master_key", "database_url"}) + proxy_config = ProxyConfig() + proxy_config.settings.load_yaml({"master_key": "sk-test"}) - test_general_settings = {"master_key": "sk-test"} - - with mock.patch("litellm.proxy.proxy_server.general_settings", test_general_settings): + with mock.patch("litellm.proxy.proxy_server.general_settings", proxy_config.settings): await proxy_config._update_general_settings( db_general_settings={"store_prompts_in_spend_logs": True}, ) - assert test_general_settings["store_prompts_in_spend_logs"] is True + from litellm.proxy import proxy_server + + assert proxy_server.general_settings["store_prompts_in_spend_logs"] is True + assert proxy_server.general_settings.source("store_prompts_in_spend_logs") == "db" @pytest.mark.asyncio async def test_admin_ui_change_works_when_yaml_omits_key(self): - """Admin UI change (DB update) should work when YAML doesn't set the key.""" - proxy_config = self._make_proxy_config_with_yaml_keys({"master_key"}) + proxy_config = ProxyConfig() + proxy_config.settings.load_yaml({"master_key": "sk-test"}) - test_general_settings = {"master_key": "sk-test"} - - with mock.patch("litellm.proxy.proxy_server.general_settings", test_general_settings): + with mock.patch("litellm.proxy.proxy_server.general_settings", proxy_config.settings): await proxy_config._update_general_settings( db_general_settings={"store_prompts_in_spend_logs": True}, ) - assert test_general_settings["store_prompts_in_spend_logs"] is True - await proxy_config._update_general_settings( db_general_settings={"store_prompts_in_spend_logs": False}, ) - assert test_general_settings["store_prompts_in_spend_logs"] is False + from litellm.proxy import proxy_server - def test_yaml_general_settings_keys_populated_on_load(self): - """_yaml_general_settings_keys should be empty on init.""" + assert proxy_server.general_settings["store_prompts_in_spend_logs"] is False + assert proxy_server.general_settings.source("store_prompts_in_spend_logs") == "db" + + def test_proxy_config_settings_start_unset(self): proxy_config = ProxyConfig() - assert proxy_config._yaml_general_settings_keys == set() + + assert proxy_config.settings.source("store_prompts_in_spend_logs") == "unset" diff --git a/tests/proxy_unit_tests/test_proxy_utils.py b/tests/proxy_unit_tests/test_proxy_utils.py index 35de9961054..160753e3442 100644 --- a/tests/proxy_unit_tests/test_proxy_utils.py +++ b/tests/proxy_unit_tests/test_proxy_utils.py @@ -699,18 +699,19 @@ async def test_proxy_config_update_from_db(): param_name: str param_value: dict - with patch.object( - pc, - "get_generic_data", - new=AsyncMock( - return_value=ReturnValue( - param_name="litellm_settings", - param_value={ - "success_callback": "langfuse", - }, - ) - ), - ): + async def get_litellm_settings(_: object, section: str) -> ReturnValue | None: + if section != "litellm_settings": + return None + return ReturnValue( + param_name="litellm_settings", + param_value={ + "success_callback": "langfuse", + }, + ) + + proxy_config._load_yaml_settings_stores(test_config) + + with patch("litellm.proxy.proxy_server.get_config_param", side_effect=get_litellm_settings): new_config = await proxy_config._update_config_from_db( prisma_client=pc, config=test_config, @@ -1090,7 +1091,7 @@ def test_get_team_models(): assert result == ["gpt-4o", "gpt-3.5-turbo", "gpt-4o-mini"] -def test_update_config_fields(): +def test_settings_store_preserves_yaml_team_configuration_when_db_value_is_null(): from litellm.proxy.proxy_server import ProxyConfig proxy_config = ProxyConfig() @@ -1120,13 +1121,10 @@ def test_update_config_fields(): "context_window_fallbacks": [{"gpt-3.5-turbo": ["gpt-3.5-turbo-large"]}], }, } - updated_config = proxy_config._update_config_fields(**args) + proxy_config.litellm_settings.load_yaml(args["current_config"]["litellm_settings"]) + proxy_config.litellm_settings.apply_db_row("litellm_settings", args["db_param_value"]) + all_team_config = proxy_config.litellm_settings["default_team_settings"] - print("updated_config", updated_config) - all_team_config = updated_config["litellm_settings"]["default_team_settings"] - - # check if team id config returned - print("all_team_config", all_team_config) team_config = proxy_config._get_team_config( team_id="c91e32bb-0f2a-4aa1-86c4-307ca2e03ea3", all_teams_config=all_team_config ) @@ -1135,7 +1133,7 @@ def test_update_config_fields(): assert team_config["langfuse_secret"] == "my-fake-secret" -def test_update_config_fields_default_internal_user_params(monkeypatch): +def test_settings_store_applies_default_internal_user_params_from_db(monkeypatch): from litellm.proxy.proxy_server import ProxyConfig proxy_config = ProxyConfig() @@ -1153,7 +1151,8 @@ def test_update_config_fields_default_internal_user_params(monkeypatch): }, }, } - proxy_config._update_config_fields(**args) + db_values = proxy_config._prepared_db_settings_values("litellm_settings", args["db_param_value"]) + proxy_config._apply_litellm_settings_db_values(db_values) assert litellm.default_internal_user_params == { "user_role": "proxy_admin", diff --git a/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py b/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py index 13d6cd8a68c..482294e7b92 100644 --- a/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py +++ b/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py @@ -1082,11 +1082,10 @@ class _DbBackedProxyConfig: db_param_value: Final[dict[str, object]] = json.loads(self.stored_litellm_settings_json) if not db_param_value: return config - return ProxyConfig()._update_config_fields( - current_config=config, - param_name="litellm_settings", - db_param_value=db_param_value, - ) + proxy_config: Final = ProxyConfig() + db_values: Final = proxy_config._prepared_db_settings_values("litellm_settings", db_param_value) + proxy_config._apply_litellm_settings_db_values(db_values) + return {"litellm_settings": dict(proxy_config.litellm_settings.resolved())} async def save_config(self, new_config: dict[str, dict[str, object]]) -> None: self.stored_litellm_settings_json = json.dumps(new_config.get("litellm_settings") or {}) diff --git a/tests/test_litellm/proxy/config_resolvers/test_settings_rules.py b/tests/test_litellm/proxy/config_resolvers/test_settings_rules.py new file mode 100644 index 00000000000..ea5ebe6cf12 --- /dev/null +++ b/tests/test_litellm/proxy/config_resolvers/test_settings_rules.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +import itertools +from typing import Final + +import pytest + +from litellm.proxy.config_resolvers.settings_rules import ( + ABSENT, + DUAL_SOURCE_KEYS, + Absent, + JsonValue, + Section, + SettingValue, + is_absent, + resolve, + rule_for, +) +from litellm.proxy.config_resolvers.settings_store import SettingsStore + +_SECTIONS: Final[tuple[Section, ...]] = ( + "general_settings", + "router_settings", + "litellm_settings", + "environment_variables", +) + +_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"}], +) + +_MATRIX: Final = tuple( + (section, key, config_value, db_value) + for (section, key), config_value, db_value in itertools.product(_ROUTES, _CONFIG_VALUES, _DB_VALUES) +) + +_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", +) + + +def _store_for(section: Section, key: str, config_value: SettingValue, db_value: SettingValue) -> SettingsStore: + store: Final = SettingsStore(section) + store.load_yaml({} if is_absent(config_value) else {key: config_value}) + if not is_absent(db_value): + store.apply_db_row(rule_for(section, key).db_row, {key: db_value}) + return store + + +@pytest.mark.parametrize(("section", "key", "config_value", "db_value"), _MATRIX) +def test_the_store_resolves_every_config_and_stored_value_combination( + section: Section, key: str, config_value: SettingValue, db_value: SettingValue +) -> None: + store: Final = _store_for(section, key, config_value, db_value) + + if not is_absent(config_value): + assert store[key] == config_value + assert store.source(key) == "config" + elif is_absent(db_value) or db_value is None: + assert key not in store + assert store.source(key) == "unset" + else: + assert store[key] == db_value + assert store.source(key) == "db" + + +@pytest.mark.parametrize(("section", "key", "config_value", "db_value"), _MATRIX) +def test_the_store_and_the_resolver_never_disagree( + section: Section, key: str, config_value: SettingValue, db_value: SettingValue +) -> None: + resolved: Final = resolve(config_value, db_value) + store: Final = _store_for(section, key, config_value, db_value) + + assert store.source(key) == resolved.source + if isinstance(resolved.value, Absent): + assert key not in store + else: + assert store[key] == resolved.value + + +@pytest.mark.parametrize(("section", "key"), _ROUTES) +def test_a_stored_row_the_key_does_not_belong_to_never_reaches_it(section: Section, key: str) -> None: + other_row: Final = "ui_settings" if rule_for(section, key).db_row != "ui_settings" else "general_settings" + store: Final = SettingsStore(section) + store.load_yaml({}) + store.apply_db_row(other_row, {key: "from-the-wrong-row"}) + + assert key not in store + assert store.source(key) == "unset" + + +@pytest.mark.parametrize("key", _PREVIOUSLY_DB_WINS) +def test_keys_the_database_used_to_win_now_resolve_to_the_config_value(key: str) -> None: + store: Final = _store_for("general_settings", key, "from-config", "from-db") + + assert store[key] == "from-config" + assert store.source(key) == "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, "", [], {}) + + stores: Final = tuple(_store_for("general_settings", key, "from-config", value) for value in falsy) + + assert {store[key] for store in stores} == {"from-config"} + assert {store.source(key) for store in stores} == {"config"} + + +@pytest.mark.parametrize( + ("key", "expected_row"), + ( + ("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_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 + + +def test_every_registered_rule_routes_to_a_known_row() -> None: + rows: Final = {rule.db_row for rule in DUAL_SOURCE_KEYS.values()} + + assert rows <= {*_SECTIONS, "ui_settings"} + + +def test_a_config_value_of_none_is_still_config_owned() -> None: + resolved: Final = resolve(None, "from-db") + + assert resolved.value is None + assert resolved.source == "config" diff --git a/tests/test_litellm/proxy/config_resolvers/test_settings_store.py b/tests/test_litellm/proxy/config_resolvers/test_settings_store.py new file mode 100644 index 00000000000..aa410deac43 --- /dev/null +++ b/tests/test_litellm/proxy/config_resolvers/test_settings_store.py @@ -0,0 +1,235 @@ +from __future__ import annotations + +from typing import Final + +import pytest + +from litellm.proxy.config_resolvers.settings_rules import JsonValue +from litellm.proxy.config_resolvers.settings_store import SettingsStore + + +def test_settings_store_matches_plain_dict_mapping_operations() -> None: + store: Final = SettingsStore("general_settings") + + store["none"] = None + store["false"] = False + store["zero"] = 0 + store["empty_list"] = [] + store["empty_string"] = "" + store.update({"updated": "value"}) + defaulted: Final = store.setdefault("defaulted", "default") + existing: Final = store.setdefault("updated", "other") + popped: Final = store.pop("updated") + + assert defaulted == "default" + assert existing == "value" + assert popped == "value" + assert store.get("missing") is None + assert store["none"] is None + assert "false" in store + assert tuple(store) == ("none", "false", "zero", "empty_list", "empty_string", "defaulted") + assert len(store) == 6 + assert dict(store) == { + "none": None, + "false": False, + "zero": 0, + "empty_list": [], + "empty_string": "", + "defaulted": "default", + } + + +@pytest.mark.parametrize("operation", ("set", "update", "setdefault", "pop", "delete")) +@pytest.mark.parametrize("initial_value", (None, False, 0, [], "")) +def test_settings_store_mapping_operations_match_a_plain_dict(operation: str, initial_value: JsonValue) -> None: + expected: dict[str, JsonValue] = {"value": initial_value} + store: Final = SettingsStore("general_settings") + store["value"] = initial_value + + match operation: + case "set": + expected["value"] = "replacement" + store["value"] = "replacement" + case "update": + expected.update({"value": "replacement", "other": initial_value}) + store.update({"value": "replacement", "other": initial_value}) + case "setdefault": + assert store.setdefault("value", "replacement") == expected.setdefault("value", "replacement") + assert store.setdefault("other", initial_value) == expected.setdefault("other", initial_value) + case "pop": + assert store.pop("value") == expected.pop("value") + case "delete": + del expected["value"] + del store["value"] + case _: + raise AssertionError(f"unexpected operation: {operation}") + + assert dict(store) == expected + assert tuple(store) == tuple(expected) + assert len(store) == len(expected) + assert ("value" in store) is ("value" in expected) + + +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"}) + store.apply_runtime_values({"template": "resolved", "changed": "resolved-runtime"}) + + store.apply_db_row("general_settings", {"changed": "database"}) + + assert store["template"] == "resolved" + assert store["changed"] == "database" + 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"}) + store.apply_db_row("ui_settings", {"allow_public_health_readiness_details": True}) + store.apply_runtime_values({"template": "resolved", "allow_public_health_readiness_details": True}) + + store.apply_db_row("ui_settings", {}) + + assert store["template"] == "resolved" + assert "allow_public_health_readiness_details" not in store + + +def test_settings_store_preserves_falsy_config_values_and_provenance() -> None: + store: Final = SettingsStore("general_settings") + yaml_values: Final = {"none": None, "false": False, "zero": 0, "empty_list": [], "empty_string": ""} + + store.load_yaml(yaml_values) + + assert dict(store) == yaml_values + assert tuple(store.source(key) for key in yaml_values) == ("config",) * len(yaml_values) + + +@pytest.mark.parametrize( + ("yaml_value", "db_value", "expected_value", "expected_source"), + ( + ("from-config", "from-db", "from-config", "config"), + ("from-config", None, "from-config", "config"), + (None, "from-db", None, "config"), + (None, None, None, "config"), + ), +) +def test_settings_store_resolves_a_db_row_with_provenance( + yaml_value: object, + db_value: object, + expected_value: object, + expected_source: str, +) -> None: + store: Final = SettingsStore("general_settings") + store.load_yaml({"ordinary": yaml_value}) + store.apply_db_row("general_settings", {"ordinary": db_value}) + + assert store["ordinary"] == expected_value + assert store.source("ordinary") == expected_source + + +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": 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",) + + +def test_settings_store_resolved_view_is_read_only() -> None: + store: Final = SettingsStore("general_settings") + store.load_yaml({"configured": "value"}) + resolved: Final = store.resolved() + + with pytest.raises(TypeError): + resolved["configured"] = "changed" + + assert store["configured"] == "value" + + +def test_settings_store_omits_a_null_database_overlay_value() -> None: + store: Final = SettingsStore("router_settings") + store.apply_db_row("router_settings", {"fallbacks": None}) + + assert "fallbacks" not in store + assert dict(store) == {} + assert store.source("fallbacks") == "unset" + + +def test_settings_store_keeps_an_empty_database_list_without_a_config_value() -> None: + store: Final = SettingsStore("router_settings") + store.apply_db_row("router_settings", {"fallbacks": []}) + + assert store["fallbacks"] == [] + assert store.source("fallbacks") == "db" + + +@pytest.mark.asyncio +async def test_load_config_returns_and_binds_the_general_settings_store(tmp_path, monkeypatch) -> None: + from litellm.proxy import proxy_server + from litellm.proxy.proxy_server import ProxyConfig + + config_path = tmp_path / "config.yaml" + config_path.write_text("model_list: []\ngeneral_settings:\n max_file_size_mb: 5\n") + monkeypatch.setattr(proxy_server, "prisma_client", None) + monkeypatch.setattr(proxy_server, "store_model_in_db", False) + monkeypatch.delenv("LITELLM_CONFIG_BUCKET_NAME", raising=False) + + proxy_config: Final = ProxyConfig() + _router, _models, returned_store = await proxy_config.load_config(router=None, config_file_path=str(config_path)) + + config_state: Final = proxy_config.get_config_state() + + assert returned_store is proxy_config.settings + assert proxy_server.general_settings is proxy_config.settings + assert isinstance(config_state["general_settings"], dict) + assert config_state["general_settings"]["max_file_size_mb"] == 5 + + +def test_settings_store_starts_with_an_unset_source() -> None: + store: Final = SettingsStore("general_settings") + + assert store.source("unknown") == "unset" diff --git a/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py b/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py index c73d29e78b2..8c3e88b864a 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py +++ b/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py @@ -733,11 +733,11 @@ class TestBlockRequestsForModelsWithoutPricing: from litellm.proxy.proxy_server import ProxyConfig with patch.object(litellm, "block_requests_for_models_without_pricing", False): - ProxyConfig()._update_config_fields( - current_config={}, - param_name="litellm_settings", - db_param_value={"block_requests_for_models_without_pricing": True}, + proxy_config = ProxyConfig() + db_values = proxy_config._prepared_db_settings_values( + "litellm_settings", {"block_requests_for_models_without_pricing": True} ) + proxy_config._apply_litellm_settings_db_values(db_values) assert litellm.block_requests_for_models_without_pricing is True diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_default_params.py b/tests/test_litellm/proxy/management_endpoints/test_team_default_params.py index 265437f97e9..17cb30dd07d 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_default_params.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_default_params.py @@ -24,13 +24,7 @@ from litellm.proxy.management_endpoints.team_endpoints import ( from litellm.proxy.proxy_server import ProxyConfig -# --------------------------------------------------------------------------- -# _update_config_fields: default_team_params loaded from DB on startup -# --------------------------------------------------------------------------- - - -class TestConfigFieldsDefaultTeamParams: - """Tests that _update_config_fields applies default_team_params from DB.""" +class TestDefaultTeamParamsFromSettingsStore: def _make_proxy_config(self) -> ProxyConfig: return ProxyConfig() @@ -50,11 +44,8 @@ class TestConfigFieldsDefaultTeamParams: } } - pc._update_config_fields( - current_config={}, - param_name="litellm_settings", - db_param_value=db_settings, - ) + db_values = pc._prepared_db_settings_values("litellm_settings", db_settings) + pc._apply_litellm_settings_db_values(db_values) assert litellm.default_team_params == db_settings["default_team_params"] @@ -68,11 +59,9 @@ class TestConfigFieldsDefaultTeamParams: } } - result = pc._update_config_fields( - current_config=config, - param_name="litellm_settings", - db_param_value=db_settings, - ) + pc.litellm_settings.load_yaml(config["litellm_settings"]) + pc.litellm_settings.apply_db_row("litellm_settings", db_settings) + result = {"litellm_settings": dict(pc.litellm_settings.resolved())} assert result["litellm_settings"]["default_team_params"] == {"max_budget": 100.0} # Existing keys preserved @@ -83,16 +72,14 @@ class TestConfigFieldsDefaultTeamParams: monkeypatch.setattr(litellm, "default_team_params", None) pc = self._make_proxy_config() - pc._update_config_fields( - current_config={}, - param_name="litellm_settings", - db_param_value={"cache": True}, - ) + db_values = pc._prepared_db_settings_values("litellm_settings", {"cache": True}) + pc._apply_litellm_settings_db_values(db_values) 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() @@ -111,22 +98,29 @@ class TestConfigFieldsDefaultTeamParams: } } - result = pc._update_config_fields( - current_config=config, - param_name="litellm_settings", - db_param_value=db_settings, - ) + pc.litellm_settings.load_yaml(config["litellm_settings"]) + db_values = pc._prepared_db_settings_values("litellm_settings", db_settings) + pc._apply_litellm_settings_db_values(db_values) - merged = result["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 - # setattr should have applied the DB value - assert litellm.default_team_params == db_settings["default_team_params"] + 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 # --------------------------------------------------------------------------- diff --git a/tests/test_litellm/proxy/proxy_server/test_proxy_config.py b/tests/test_litellm/proxy/proxy_server/test_proxy_config.py index 48eeb39fecf..42cd6e4ed78 100644 --- a/tests/test_litellm/proxy/proxy_server/test_proxy_config.py +++ b/tests/test_litellm/proxy/proxy_server/test_proxy_config.py @@ -134,20 +134,14 @@ def test__scrub_db_overlay_remote_module_loads_invalid_non_dict_returns_input(): def test_resolve_complexity_router_plugins_no_plugins_key_is_a_noop(): config: Dict[str, Any] = {"tiers": {"SIMPLE": "gpt-4o-mini"}} - resolve_complexity_router_plugins( - model_name="smart-router", complexity_router_config=config, config_file_path=None - ) + resolve_complexity_router_plugins(model_name="smart-router", complexity_router_config=config, config_file_path=None) assert config == {"tiers": {"SIMPLE": "gpt-4o-mini"}} def test_resolve_complexity_router_plugins_resolves_dotted_path_to_live_instance(tmp_path): plugin_file = tmp_path / "my_plugin.py" plugin_file.write_text( - "class _Plugin:\n" - " async def run(self, context):\n" - " return context\n" - "\n" - "my_plugin_instance = _Plugin()\n" + "class _Plugin:\n async def run(self, context):\n return context\n\nmy_plugin_instance = _Plugin()\n" ) config: Dict[str, Any] = {"plugins": ["my_plugin.my_plugin_instance"]} @@ -262,9 +256,18 @@ def _custom_prompt_row(model_name: str) -> dict[str, object]: [ ([_heuristic_v2_row("a"), _heuristic_v2_row("b"), _heuristic_v2_row("c", "heuristic")], "heuristic_v2"), ([_custom_tier_row("a"), _custom_tier_row("b"), _heuristic_v2_row("c", "heuristic")], "tier_definitions"), - ([_custom_prompt_row("a"), _custom_prompt_row("b"), _heuristic_v2_row("c", "heuristic")], "operator-written classifier prompt"), - ([_custom_tier_row("a"), _custom_prompt_row("b"), _heuristic_v2_row("c", "heuristic")], "operator-written classifier prompt"), - ([_operator_examples_row("a"), _custom_tier_row("b"), _heuristic_v2_row("c", "heuristic")], "operator-written classifier prompt"), + ( + [_custom_prompt_row("a"), _custom_prompt_row("b"), _heuristic_v2_row("c", "heuristic")], + "operator-written classifier prompt", + ), + ( + [_custom_tier_row("a"), _custom_prompt_row("b"), _heuristic_v2_row("c", "heuristic")], + "operator-written classifier prompt", + ), + ( + [_operator_examples_row("a"), _custom_tier_row("b"), _heuristic_v2_row("c", "heuristic")], + "operator-written classifier prompt", + ), ], ) def test_validate_auto_router_capability_limits_refuses_to_start_over_the_limit( @@ -339,20 +342,17 @@ async def test_ProxyConfig_load_config_takes_the_classifier_limit_from_the_licen ), } config_yaml = _TWO_HEURISTIC_V2_ROUTERS_YAML.replace( - "classifier_type: heuristic_v2\n", f"classifier_type: {classifier_type}\n{forecast_settings.get(classifier_type, '')}" + "classifier_type: heuristic_v2\n", + f"classifier_type: {classifier_type}\n{forecast_settings.get(classifier_type, '')}", ).replace("tiers: {SIMPLE: gpt-4o-mini}", "tiers: {SIMPLE: gpt-4o-mini, REASONING: gpt-4o}") f.write_text(config_yaml) monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", False) monkeypatch.delenv("LITELLM_CONFIG_BUCKET_NAME", raising=False) - monkeypatch.setattr( - "litellm.proxy.proxy_server._license_check.auto_router_capability_limit", lambda: license_limit - ) + monkeypatch.setattr("litellm.proxy.proxy_server._license_check.auto_router_capability_limit", lambda: license_limit) if license_limit is None: - router, _model_list, _general_settings = await ProxyConfig().load_config( - router=None, config_file_path=str(f) - ) + router, _model_list, _general_settings = await ProxyConfig().load_config(router=None, config_file_path=str(f)) assert router.auto_router_capability_limit is not None assert router.auto_router_capability_limit() is None assert sorted(router.complexity_routers) == ["v2-a", "v2-b"] @@ -371,10 +371,12 @@ async def test_ProxyConfig_load_config_router_refuses_a_db_heuristic_v2_router_b from litellm.types.router import Deployment f = tmp_path / "c.yaml" - f.write_text(_TWO_HEURISTIC_V2_ROUTERS_YAML.replace(" - model_name: v2-b\n", " - model_name: v1-b\n", 1).replace( - "classifier_type: heuristic_v2\n tiers: {SIMPLE: gpt-4o-mini}\nrouter_settings", - "classifier_type: heuristic\n tiers: {SIMPLE: gpt-4o-mini}\nrouter_settings", - )) + f.write_text( + _TWO_HEURISTIC_V2_ROUTERS_YAML.replace(" - model_name: v2-b\n", " - model_name: v1-b\n", 1).replace( + "classifier_type: heuristic_v2\n tiers: {SIMPLE: gpt-4o-mini}\nrouter_settings", + "classifier_type: heuristic\n tiers: {SIMPLE: gpt-4o-mini}\nrouter_settings", + ) + ) monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", False) monkeypatch.delenv("LITELLM_CONFIG_BUCKET_NAME", raising=False) @@ -557,9 +559,7 @@ def test_resolve_complexity_router_plugins_leaves_live_classifier_instance_alone instance = _Classifier() config: dict[str, Any] = {"classifier_plugin": instance} - resolve_complexity_router_plugins( - model_name="smart-router", complexity_router_config=config, config_file_path=None - ) + resolve_complexity_router_plugins(model_name="smart-router", complexity_router_config=config, config_file_path=None) assert config["classifier_plugin"] is instance @@ -571,11 +571,7 @@ def test_resolve_complexity_router_plugins_leaves_live_classifier_instance_alone def test_resolve_routing_plugins_resolves_dotted_paths(tmp_path): plugin_file = tmp_path / "rs_plugin.py" plugin_file.write_text( - "class _Plugin:\n" - " async def run(self, context):\n" - " return context\n" - "\n" - "rs_plugin_instance = _Plugin()\n" + "class _Plugin:\n async def run(self, context):\n return context\n\nrs_plugin_instance = _Plugin()\n" ) resolved = resolve_routing_plugins( @@ -1527,7 +1523,7 @@ async def test_ProxyConfig_get_config_missing_file_raises(monkeypatch): # ProxyConfig._initialize_secret_manager_from_raw_config # --------------------------------------------------------------------------- -VAULT_SECRET_MANAGER_MODULE = ''' +VAULT_SECRET_MANAGER_MODULE = """ import os from litellm.integrations.custom_secret_manager import CustomSecretManager @@ -1548,7 +1544,7 @@ class VaultSecretManager(CustomSecretManager): async def async_read_secret(self, secret_name, optional_params=None, timeout=None, **kwargs): return VAULT.get(secret_name) -''' +""" VAULT_BACKED_CONFIG = """ model_list: @@ -1649,9 +1645,7 @@ async def test_ProxyConfig_get_config_reuses_an_already_initialized_secret_manag @pytest.mark.asyncio -async def test_ProxyConfig_get_config_without_key_management_system_leaves_secret_manager_unset( - tmp_path, monkeypatch -): +async def test_ProxyConfig_get_config_without_key_management_system_leaves_secret_manager_unset(tmp_path, monkeypatch): """No ``key_management_system`` means no manager, an unresolvable reference stays None, and nothing is warned about: with no manager there is nothing to have been absent from.""" config_yaml = VAULT_BACKED_CONFIG.replace(" key_management_system: custom\n", "") @@ -1670,9 +1664,7 @@ async def test_ProxyConfig_get_config_without_key_management_system_leaves_secre @pytest.mark.asyncio -async def test_ProxyConfig_get_config_warns_when_a_reference_is_missing_from_the_secret_manager( - tmp_path, monkeypatch -): +async def test_ProxyConfig_get_config_warns_when_a_reference_is_missing_from_the_secret_manager(tmp_path, monkeypatch): """A reference the manager cannot resolve is logged, instead of silently becoming None.""" config_yaml = VAULT_BACKED_CONFIG.replace("MY_PROVIDER_KEY", "NOT_IN_VAULT") config_file_path = _write_vault_backed_config(tmp_path, monkeypatch, config_yaml) @@ -2109,10 +2101,7 @@ async def test_ProxyConfig_load_config_minimal_yaml(tmp_path, monkeypatch): async def test_load_config_logs_disabled_budget_reservation_once(tmp_path, monkeypatch, caplog, setting): config_file = tmp_path / "budget.yaml" flag = f" disable_budget_reservation: {setting}\n" if setting is not None else "" - config_file.write_text( - "model_list: []\nlitellm_settings: {}\ngeneral_settings:\n" - " master_key: null\n" + flag - ) + config_file.write_text("model_list: []\nlitellm_settings: {}\ngeneral_settings:\n master_key: null\n" + flag) monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", False) monkeypatch.setattr("litellm.constants.budget_reservation_disabled_info_emitted", False) @@ -2123,10 +2112,7 @@ async def test_load_config_logs_disabled_budget_reservation_once(tmp_path, monke for _ in range(3): await config.load_config(router=None, config_file_path=str(config_file)) - records = [ - record for record in caplog.records - if "disable_budget_reservation is enabled" in record.message - ] + records = [record for record in caplog.records if "disable_budget_reservation is enabled" in record.message] assert [record.levelno for record in records] == ([logging.INFO] if setting == "true" else []) @@ -2138,11 +2124,7 @@ async def test_ProxyConfig_load_config_resolves_router_settings_plugins(tmp_path to `await "some.string".run(context)`.""" plugin_file = tmp_path / "rs_plugin.py" plugin_file.write_text( - "class _Plugin:\n" - " async def run(self, context):\n" - " return context\n" - "\n" - "rs_plugin_instance = _Plugin()\n" + "class _Plugin:\n async def run(self, context):\n return context\n\nrs_plugin_instance = _Plugin()\n" ) f = tmp_path / "c.yaml" f.write_text( @@ -2157,9 +2139,7 @@ async def test_ProxyConfig_load_config_resolves_router_settings_plugins(tmp_path monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", False) monkeypatch.delenv("LITELLM_CONFIG_BUCKET_NAME", raising=False) - router, _model_list, _general_settings = await ProxyConfig().load_config( - router=None, config_file_path=str(f) - ) + router, _model_list, _general_settings = await ProxyConfig().load_config(router=None, config_file_path=str(f)) assert len(router.routing_plugins) == 1 assert type(router.routing_plugins[0]).__name__ == "_Plugin" @@ -2226,10 +2206,7 @@ async def test_ProxyConfig_load_config_wires_config_reload_interval(tmp_path, mo f = tmp_path / "c.yaml" f.write_text( - "model_list: []\n" - "general_settings:\n" - " proxy_config_reload_interval_seconds: 47\n" - "litellm_settings: {}\n" + "model_list: []\ngeneral_settings:\n proxy_config_reload_interval_seconds: 47\nlitellm_settings: {}\n" ) monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", False) @@ -2371,13 +2348,9 @@ async def test_ProxyConfig__init_non_llm_configs_premium_invalid_worker_registry async def test_ProxyConfig__init_non_llm_configs_worker_registry_requires_premium(monkeypatch): monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", False) pc = ProxyConfig() - with pytest.raises(ValueError, match='Trying to use `worker_registry`You must be a LiteLLM') as exc_info: + with pytest.raises(ValueError, match="Trying to use `worker_registry`You must be a LiteLLM") as exc_info: await pc._init_non_llm_configs( - config={ - "worker_registry": [ - {"worker_id": "worker-a", "name": "Worker A", "url": "http://localhost:4001"} - ] - }, + config={"worker_registry": [{"worker_id": "worker-a", "name": "Worker A", "url": "http://localhost:4001"}]}, config_file_path=None, ) message = str(exc_info.value) @@ -2607,9 +2580,7 @@ def test_ProxyConfig__warn_on_misplaced_jwt_keys_warns_even_when_also_under_gene def test_ProxyConfig__warn_on_misplaced_jwt_keys_silent_when_correctly_placed(): """Keys living only under general_settings are valid, so no warning fires.""" - result, warnings = _capture_proxy_warnings( - {"general_settings": {"enable_jwt_auth": True, "litellm_jwtauth": {}}} - ) + result, warnings = _capture_proxy_warnings({"general_settings": {"enable_jwt_auth": True, "litellm_jwtauth": {}}}) assert result == () assert warnings == [] @@ -2636,7 +2607,7 @@ def test_ProxyConfig_initialize_secret_manager_none_noop(): def test_ProxyConfig_initialize_secret_manager_invalid_kms_raises(): pc = ProxyConfig() - with pytest.raises(ValueError, match='Invalid Key Management System selected'): + with pytest.raises(ValueError, match="Invalid Key Management System selected"): pc.initialize_secret_manager(key_management_system="not-a-real-kms") @@ -3141,28 +3112,6 @@ async def test_ProxyConfig__update_llm_router_no_models_smoke(monkeypatch): assert snapshot == {"raised": False, "called": True, "models": "empty"} -@pytest.mark.asyncio -async def test_ProxyConfig__update_llm_router_bad_proxy_logging_raises(monkeypatch): - pc = ProxyConfig() - - async def fake_get_config(): - # alerting present + non-list general_settings to trigger the alerting branch. - return {"general_settings": {"alerting": ["slack"]}} - - fake_router = MagicMock() - fake_router.update_settings = MagicMock() - monkeypatch.setattr(pc, "get_config", fake_get_config) - monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", fake_router) - monkeypatch.setattr("litellm.proxy.proxy_server.master_key", "sk-x") - monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) - monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {"alerting": ["email"]}) - monkeypatch.setattr("litellm.proxy.proxy_server.proxy_config", pc) - # Passing None for proxy_logging_obj triggers AttributeError in _add_general_settings_from_db_config - # when it calls proxy_logging_obj.update_values. - with pytest.raises(AttributeError): - await pc._update_llm_router(new_models=[], proxy_logging_obj=None) # type: ignore[arg-type] - - # --------------------------------------------------------------------------- # ProxyConfig._add_callback_from_db_to_in_memory_litellm_callbacks # --------------------------------------------------------------------------- @@ -3637,43 +3586,6 @@ async def test_ProxyConfig_get_credentials_reads_from_writer_not_replica(monkeyp reader_inner.litellm_credentialstable.find_many.assert_not_awaited() -# --------------------------------------------------------------------------- -# ProxyConfig._add_general_settings_from_db_config -# --------------------------------------------------------------------------- - - -def test_ProxyConfig__add_general_settings_from_db_config_merges_alerting(): - pc = ProxyConfig() - proxy_logging = MagicMock() - general = {"alerting": ["slack"]} - config_data = {"general_settings": {"alerting": ["email", "slack"]}} - pc._add_general_settings_from_db_config( - config_data=config_data, - general_settings=general, - proxy_logging_obj=proxy_logging, - ) - snapshot = { - "alerting": sorted(general["alerting"]), - "logging_called": proxy_logging.update_values.called, - "merged_count": len(general["alerting"]), - } - assert snapshot == { - "alerting": ["email", "slack"], - "logging_called": True, - "merged_count": 2, - } - - -def test_ProxyConfig__add_general_settings_from_db_config_bad_config_raises(): - pc = ProxyConfig() - with pytest.raises(AttributeError): - pc._add_general_settings_from_db_config( - config_data=None, # type: ignore[arg-type] - general_settings={}, - proxy_logging_obj=MagicMock(), - ) - - # --------------------------------------------------------------------------- # ProxyConfig._reschedule_spend_log_cleanup_job # --------------------------------------------------------------------------- @@ -3736,7 +3648,9 @@ async def test_ProxyConfig__update_general_settings_updates_health_check_retenti reschedule = AsyncMock() monkeypatch.setattr(pc, "_reschedule_spend_log_cleanup_job", reschedule) await pc._update_general_settings({"maximum_health_check_retention_period": "30d"}) - assert settings["maximum_health_check_retention_period"] == "30d" + from litellm.proxy import proxy_server + + assert proxy_server.general_settings["maximum_health_check_retention_period"] == "30d" reschedule.assert_awaited_once() @@ -3790,7 +3704,6 @@ async def test_ProxyConfig__update_general_settings_yaml_max_batch_file_size_mb_ {"max_batch_file_size_mb": 3}, ) pc = ProxyConfig() - pc._yaml_general_settings_keys = {"max_batch_file_size_mb"} await pc._update_general_settings({"max_batch_file_size_mb": 5}) from litellm.proxy import proxy_server as ps @@ -3807,7 +3720,7 @@ async def test_ProxyConfig__update_general_settings_cleared_db_max_batch_file_si await pc._update_general_settings({"max_parallel_requests": 1}) from litellm.proxy import proxy_server as ps - assert ps.general_settings.get("max_batch_file_size_mb") is None + assert ps.general_settings.get("max_batch_file_size_mb") == 8 @pytest.mark.asyncio @@ -3827,7 +3740,6 @@ async def test_ProxyConfig__update_general_settings_yaml_allowed_file_extensions {"allowed_file_extensions": [".pdf"]}, ) pc = ProxyConfig() - pc._yaml_general_settings_keys = {"allowed_file_extensions"} await pc._update_general_settings({"allowed_file_extensions": [".jsonl"]}) from litellm.proxy import proxy_server as ps @@ -3845,27 +3757,195 @@ async def test_ProxyConfig__update_general_settings_none_input_noop(): await pc._update_general_settings(db_general_settings=12345) # type: ignore[arg-type] -# --------------------------------------------------------------------------- -# ProxyConfig._update_config_fields -# --------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_ProxyConfig__update_general_settings_skips_redundant_retention_reschedule(monkeypatch): + from litellm.proxy import proxy_server - -def test_ProxyConfig__update_config_fields_merges_dict(): pc = ProxyConfig() - current = {"general_settings": {"a": 1, "b": 2}} - out = pc._update_config_fields( - current_config=current, - param_name="general_settings", - db_param_value={"b": 3, "c": 4, "d": 5}, + reschedule: Final = AsyncMock() + monkeypatch.setattr(proxy_server, "general_settings", {}) + monkeypatch.setattr(pc, "_reschedule_spend_log_cleanup_job", reschedule) + + await pc._update_general_settings({"maximum_health_check_retention_period": "30d"}) + reschedule.assert_awaited_once() + reschedule.reset_mock() + + await pc._update_general_settings({"maximum_health_check_retention_period": "30d"}) + + reschedule.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_ProxyConfig__update_general_settings_reschedules_after_retention_key_deletion(monkeypatch): + from litellm.proxy import proxy_server + + pc = ProxyConfig() + reschedule: Final = AsyncMock() + monkeypatch.setattr(proxy_server, "general_settings", {}) + monkeypatch.setattr(pc, "_reschedule_spend_log_cleanup_job", reschedule) + + await pc._update_general_settings({"maximum_health_check_retention_period": "30d"}) + reschedule.reset_mock() + + await pc._update_general_settings({}) + + reschedule.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_ProxyConfig__update_general_settings_dispatches_every_side_effect_handler(monkeypatch): + pc = ProxyConfig() + handlers: Final = ( + ("_apply_alerting_settings", AsyncMock()), + ("_apply_pass_through_settings", AsyncMock()), + ("_apply_boolean_settings", AsyncMock()), + ("_apply_store_model_in_db_setting", AsyncMock()), + ("_apply_retention_settings", AsyncMock()), + ("_apply_ssrf_settings", AsyncMock()), + ("_apply_cache_size_setting", AsyncMock()), ) - assert out == {"general_settings": {"a": 1, "b": 3, "c": 4, "d": 5}} + for name, handler in handlers: + monkeypatch.setattr(pc, name, handler) + + await pc._apply_general_settings_side_effects({}, False, (), None) + + for name, handler in handlers: + if name == "_apply_cache_size_setting": + handler.assert_awaited_once_with({}, cache_size_was_db=False) + elif name == "_apply_retention_settings": + handler.assert_awaited_once_with({}, previous_retention_values=()) + elif name == "_apply_pass_through_settings": + handler.assert_awaited_once_with({}, previous_endpoints=None) + else: + handler.assert_awaited_once_with({}) -def test_ProxyConfig__update_config_fields_invalid_param_raises(): +@pytest.mark.asyncio +async def test_ProxyConfig__update_general_settings_unrelated_value_fires_no_runtime_effect(monkeypatch): + from litellm.proxy import proxy_server + pc = ProxyConfig() - with pytest.raises(TypeError): - # Missing required arg. - pc._update_config_fields(current_config={}, param_name="general_settings") # type: ignore[call-arg] + initialize_endpoints: Final = AsyncMock() + reschedule: Final = AsyncMock() + cache: Final = MagicMock() + proxy_logging: Final = MagicMock() + monkeypatch.setattr(proxy_server, "general_settings", {}) + monkeypatch.setattr(proxy_server, "initialize_pass_through_endpoints", initialize_endpoints) + monkeypatch.setattr(proxy_server, "user_api_key_cache", cache) + monkeypatch.setattr(proxy_server, "proxy_logging_obj", proxy_logging) + monkeypatch.setattr(pc, "_reschedule_spend_log_cleanup_job", reschedule) + + await pc._update_general_settings({"unrelated": "value"}) + + initialize_endpoints.assert_not_awaited() + reschedule.assert_not_awaited() + cache.update_in_memory_max_size.assert_not_called() + proxy_logging.update_values.assert_not_called() + proxy_logging.slack_alerting_instance.update_values.assert_not_called() + + +@pytest.mark.asyncio +async def test_ProxyConfig__update_config_from_db_resolves_through_settings_stores(monkeypatch): + pc = ProxyConfig() + config = { + "general_settings": { + "max_file_size_mb": 7, + "max_parallel_requests": 3, + "alerting": ["config"], + "pass_through_endpoints": [{"path": "/config"}], + "maximum_spend_logs_cleanup_batch_size": 10, + }, + "router_settings": {"fallbacks": ["config"], "num_retries": 1}, + } + db_values = { + "general_settings": { + "max_file_size_mb": 9, + "max_parallel_requests": 11, + "alerting": ["db"], + "pass_through_endpoints": [{"path": "/db"}], + "maximum_spend_logs_cleanup_batch_size": None, + }, + "router_settings": {"fallbacks": [], "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": 3, + "alerting": ["config"], + "pass_through_endpoints": [{"path": "/config"}], + "maximum_spend_logs_cleanup_batch_size": 10, + } + 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" + + +def test_ProxyConfig_load_yaml_settings_stores_keeps_db_endpoints_out_of_config_baseline(): + from litellm.proxy import proxy_server + + pc = ProxyConfig() + config_endpoint: Final = {"path": "/config", "target": "https://config.example"} + db_endpoint: Final = {"id": "db-endpoint", "path": "/db", "target": "https://db.example"} + + pc._load_yaml_settings_stores({"general_settings": {"pass_through_endpoints": [config_endpoint]}}) + pc.settings.apply_db_row("general_settings", {"pass_through_endpoints": [db_endpoint]}) + + assert proxy_server.config_passthrough_endpoints == [config_endpoint] + + +@pytest.mark.asyncio +async def test_ProxyConfig_add_deployment_continues_after_null_pass_through_endpoints(monkeypatch): + from litellm.proxy import proxy_server + + pc = ProxyConfig() + non_llm_initialization = AsyncMock() + monkeypatch.setattr(proxy_server, "general_settings", {}) + monkeypatch.setattr(proxy_server, "prefetch_config_params", AsyncMock()) + monkeypatch.setattr( + proxy_server, + "get_config_param", + AsyncMock(return_value=SimpleNamespace(param_value={"pass_through_endpoints": None})), + ) + monkeypatch.setattr(proxy_server, "sync_ui_settings_to_general_settings", AsyncMock()) + monkeypatch.setattr(pc, "_should_load_db_object", lambda *, object_type: False) + monkeypatch.setattr(pc, "get_credentials", AsyncMock()) + monkeypatch.setattr(pc, "_init_non_llm_objects_in_db", non_llm_initialization) + + await pc.add_deployment(prisma_client=MagicMock(), proxy_logging_obj=MagicMock()) + + non_llm_initialization.assert_awaited_once() # --------------------------------------------------------------------------- diff --git a/tests/test_litellm/proxy/proxy_server/test_routes_config.py b/tests/test_litellm/proxy/proxy_server/test_routes_config.py index dd3914e3ad5..19b36b026f9 100644 --- a/tests/test_litellm/proxy/proxy_server/test_routes_config.py +++ b/tests/test_litellm/proxy/proxy_server/test_routes_config.py @@ -22,6 +22,18 @@ import pytest from .conftest import VOLATILE_KEYS, normalize +def _seed_settings_store(monkeypatch, db_row: dict, yaml_values: dict | None = None) -> None: + """Point proxy_config.settings at a store holding the same row the mocked table returns, + the way a booted proxy does, so the read routes resolve against it.""" + from litellm.proxy import proxy_server as ps + from litellm.proxy.config_resolvers import SettingsStore + + store = SettingsStore("general_settings") + store.load_yaml(yaml_values or {}) + store.apply_db_row("general_settings", db_row) + monkeypatch.setattr(ps.proxy_config, "settings", store) + + def _install_litellm_config(mock_prisma: MagicMock) -> MagicMock: """Ensure mock_prisma.db.litellm_config exists with async methods (the conftest only stubs ``litellm_configtable`` — this is a different table).""" @@ -322,7 +334,7 @@ def test_config_field_update_invalid_field(client, auth_as, mock_prisma, monkeyp def test_config_field_info_happy_admin(client, auth_as, mock_prisma, monkeypatch): - """Admin gets back ConfigFieldInfo with the stored value pulled from DB.""" + """Admin gets back ConfigFieldInfo with the value the proxy resolved, tagged with where it came from.""" from litellm.proxy import proxy_server as ps from litellm.proxy._types import LitellmUserRoles @@ -331,6 +343,7 @@ def test_config_field_info_happy_admin(client, auth_as, mock_prisma, monkeypatch row.param_value = {"max_parallel_requests": 7} table.find_first = AsyncMock(return_value=row) monkeypatch.setattr(ps, "prisma_client", mock_prisma) + _seed_settings_store(monkeypatch, row.param_value) with auth_as(LitellmUserRoles.PROXY_ADMIN): response = client.get("/config/field/info", params={"field_name": "max_parallel_requests"}) @@ -338,6 +351,8 @@ def test_config_field_info_happy_admin(client, auth_as, mock_prisma, monkeypatch assert normalize(response.json()) == { "field_name": "max_parallel_requests", "field_value": 7, + "source": "db", + "editable": True, } @@ -356,7 +371,7 @@ def test_config_field_info_non_admin_rejected(client, auth_as, mock_prisma, monk def test_config_field_info_field_not_in_db(client, auth_as, mock_prisma, monkeypatch): - """When the field is missing from the DB row, returns 400 'not in DB'.""" + """When nothing sets the field, neither the config file nor the DB row, it 400s.""" from litellm.proxy import proxy_server as ps from litellm.proxy._types import LitellmUserRoles @@ -365,11 +380,12 @@ def test_config_field_info_field_not_in_db(client, auth_as, mock_prisma, monkeyp row.param_value = {"some_other_field": "value"} table.find_first = AsyncMock(return_value=row) monkeypatch.setattr(ps, "prisma_client", mock_prisma) + _seed_settings_store(monkeypatch, row.param_value) with auth_as(LitellmUserRoles.PROXY_ADMIN): response = client.get("/config/field/info", params={"field_name": "max_parallel_requests"}) assert response.status_code == 400 - assert "not in DB" in response.json().get("detail", {}).get("error", "") + assert "is not set" in response.json().get("detail", {}).get("error", "") def test_config_field_info_redacts_nested_secret_for_view_only_admin(client, auth_as, mock_prisma, monkeypatch): @@ -391,6 +407,7 @@ def test_config_field_info_redacts_nested_secret_for_view_only_admin(client, aut } table.find_first = AsyncMock(return_value=row) monkeypatch.setattr(ps, "prisma_client", mock_prisma) + _seed_settings_store(monkeypatch, row.param_value) with auth_as(LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY): response = client.get("/config/field/info", params={"field_name": "database_args"}) @@ -417,6 +434,7 @@ def test_config_field_info_full_admin_sees_nested_secret(client, auth_as, mock_p } table.find_first = AsyncMock(return_value=row) monkeypatch.setattr(ps, "prisma_client", mock_prisma) + _seed_settings_store(monkeypatch, row.param_value) with auth_as(LitellmUserRoles.PROXY_ADMIN): response = client.get("/config/field/info", params={"field_name": "database_args"}) @@ -438,6 +456,7 @@ def test_config_field_info_redacts_top_level_scalar_for_view_only(client, auth_a row.param_value = {"database_url": "postgresql://admin:p4ss@db:5432/litellm"} table.find_first = AsyncMock(return_value=row) monkeypatch.setattr(ps, "prisma_client", mock_prisma) + _seed_settings_store(monkeypatch, row.param_value) with auth_as(LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY): response = client.get("/config/field/info", params={"field_name": "database_url"}) diff --git a/tests/test_litellm/proxy/test_plugin_routes.py b/tests/test_litellm/proxy/test_plugin_routes.py index 52999447179..c8d2939385d 100644 --- a/tests/test_litellm/proxy/test_plugin_routes.py +++ b/tests/test_litellm/proxy/test_plugin_routes.py @@ -14,6 +14,8 @@ Covers three bugs: import asyncio from unittest.mock import MagicMock +import pytest + from litellm.proxy._types import ( ConfigGeneralSettings, LitellmUserRoles, @@ -131,16 +133,16 @@ def test_plugin_key_is_never_returned_to_the_browser() -> None: register_plugins_from_config({}) -def test_db_persisted_plugins_load_on_startup() -> None: - """Plugins saved to DB general_settings must register when the DB config is - merged at startup, not just when present in the YAML file.""" +def test_db_persisted_plugins_load_on_startup(monkeypatch: pytest.MonkeyPatch) -> None: + from litellm.proxy import proxy_server from litellm.proxy.proxy_server import ProxyConfig - register_plugins_from_config({}) # start empty (as if YAML had no plugins) + register_plugins_from_config({}) + monkeypatch.setattr(proxy_server, "general_settings", {}) - ProxyConfig()._add_general_settings_from_db_config( - config_data={ - "general_settings": { + asyncio.run( + ProxyConfig()._update_general_settings( + { "plugins": [ { "name": "db-plugin", @@ -149,9 +151,7 @@ def test_db_persisted_plugins_load_on_startup() -> None: } ] } - }, - general_settings={}, - proxy_logging_obj=MagicMock(), + ) ) names = [p["name"] for p in asyncio.run(list_plugins(user_api_key_dict=_admin()))] diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 41c4956dba6..bc7ae556e7b 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -9,6 +9,7 @@ import socket import subprocess import time import types +import uuid from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Final @@ -1087,7 +1088,9 @@ async def test_init_mcp_servers_from_db_respects_supported_db_objects(monkeypatc mock_init.assert_not_awaited() -def test_update_config_fields_deep_merge_db_wins(): +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() @@ -1127,29 +1130,15 @@ def test_update_config_fields_deep_merge_db_wins(): } } - updated = proxy_config._update_config_fields( - current_config=current_config, - param_name="router_settings", - db_param_value=db_param_value, - ) + proxy_config.router_settings.load_yaml(current_config["router_settings"]) + proxy_config.router_settings.apply_db_row("router_settings", db_param_value) - rs = updated["router_settings"] + 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" @@ -4946,26 +4935,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(): @@ -5012,7 +4989,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 @@ -5199,8 +5176,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 @@ -5990,7 +5967,7 @@ async def test_init_hashicorp_vault_config_override_retries_on_transport_error() assert reconnect_kwargs["reason"] == "init_hashicorp_vault_config_override_lookup_failure" -def test_update_config_fields_uppercases_env_vars(monkeypatch): +def test_settings_store_uppercases_db_env_vars(monkeypatch): """ Ensure environment variables pulled from DB are uppercased when applied so integrations like Datadog that expect uppercase env keys can read them. @@ -6001,13 +5978,12 @@ def test_update_config_fields_uppercases_env_vars(monkeypatch): monkeypatch.delenv(key, raising=False) proxy_config = ProxyConfig() - updated_config = proxy_config._update_config_fields( - current_config={}, - param_name="environment_variables", - db_param_value={"dd_api_key": "test-api-key", "dd_site": "us5.datadoghq.com"}, + db_values = proxy_config._prepared_db_settings_values( + "environment_variables", {"dd_api_key": "test-api-key", "dd_site": "us5.datadoghq.com"} ) + proxy_config.environment_variables.apply_db_row("environment_variables", db_values) - env_vars = updated_config.get("environment_variables", {}) + env_vars = proxy_config.environment_variables.resolved() assert env_vars["DD_API_KEY"] == "test-api-key" assert env_vars["DD_SITE"] == "us5.datadoghq.com" assert os.environ.get("DD_API_KEY") == "test-api-key" @@ -6464,9 +6440,8 @@ def test_get_config_normalizes_string_callbacks(monkeypatch): def test_deep_merge_dicts_skips_none_and_empty_lists(monkeypatch): - """ - Test that _update_config_fields 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() @@ -6492,14 +6467,14 @@ def test_deep_merge_dicts_skips_none_and_empty_lists(monkeypatch): }, } - result = proxy_config._update_config_fields(current_config, "general_settings", db_param_value) + proxy_config.settings.load_yaml(current_config["general_settings"]) + proxy_config.settings.apply_db_row("general_settings", db_param_value) + result = proxy_config.settings.resolved() - assert result["general_settings"]["max_parallel_requests"] == 10 - assert result["general_settings"]["allowed_models"] == ["gpt-3.5-turbo", "gpt-4"] - assert result["general_settings"]["new_key"] == "new_value" - assert result["general_settings"]["nested"]["key1"] == "updated_value1" - assert result["general_settings"]["nested"]["key2"] == "value2" - assert result["general_settings"]["nested"]["key3"] == "value3" + 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": "value1", "key2": "value2"} class TestInvitationEndpoints: @@ -7343,17 +7318,20 @@ async def test_update_general_settings_clears_a_spend_log_cleanup_bound_dropped_ proxy_config = ProxyConfig() - with patch( - "litellm.proxy.proxy_server.general_settings", - {"maximum_spend_logs_cleanup_run_budget": "90s", "maximum_spend_logs_cleanup_batch_timeout": "10s"}, - ): + with patch("litellm.proxy.proxy_server.general_settings", proxy_config.settings): + await proxy_config._update_general_settings( + db_general_settings={ + "maximum_spend_logs_cleanup_run_budget": "90s", + "maximum_spend_logs_cleanup_batch_timeout": "10s", + } + ) await proxy_config._update_general_settings( db_general_settings={"maximum_spend_logs_cleanup_batch_timeout": "10s"} ) import litellm.proxy.proxy_server as ps - assert ps.general_settings["maximum_spend_logs_cleanup_run_budget"] is None + assert "maximum_spend_logs_cleanup_run_budget" not in ps.general_settings assert ps.general_settings["maximum_spend_logs_cleanup_batch_timeout"] == "10s" @@ -7364,9 +7342,9 @@ async def test_update_general_settings_keeps_a_yaml_set_spend_log_cleanup_bound( from litellm.proxy.proxy_server import ProxyConfig proxy_config = ProxyConfig() - proxy_config._yaml_spend_log_cleanup_bounds = {"maximum_spend_logs_cleanup_run_budget": "90s"} + proxy_config.settings.load_yaml({"maximum_spend_logs_cleanup_run_budget": "90s"}) - with patch("litellm.proxy.proxy_server.general_settings", {"maximum_spend_logs_cleanup_run_budget": "90s"}): + with patch("litellm.proxy.proxy_server.general_settings", proxy_config.settings): await proxy_config._update_general_settings(db_general_settings={"store_model_in_db": True}) import litellm.proxy.proxy_server as ps @@ -7382,10 +7360,10 @@ async def test_update_general_settings_clearing_a_db_override_falls_back_to_the_ from litellm.proxy.proxy_server import ProxyConfig proxy_config = ProxyConfig() - proxy_config._yaml_spend_log_cleanup_bounds = {"maximum_spend_logs_cleanup_run_budget": "90s"} + proxy_config.settings.load_yaml({"maximum_spend_logs_cleanup_run_budget": "90s"}) - # Memory currently holds the dashboard override, and the DB no longer carries it. - with patch("litellm.proxy.proxy_server.general_settings", {"maximum_spend_logs_cleanup_run_budget": "30s"}): + with patch("litellm.proxy.proxy_server.general_settings", proxy_config.settings): + await proxy_config._update_general_settings(db_general_settings={"maximum_spend_logs_cleanup_run_budget": "30s"}) await proxy_config._update_general_settings(db_general_settings={"store_model_in_db": True}) import litellm.proxy.proxy_server as ps @@ -7399,9 +7377,9 @@ async def test_update_general_settings_apply_user_budget_to_team_keys_yaml_wins( from litellm.proxy.proxy_server import ProxyConfig proxy_config = ProxyConfig() - proxy_config._yaml_general_settings_keys = {"apply_user_budget_to_team_keys"} + proxy_config.settings.load_yaml({"apply_user_budget_to_team_keys": True}) - with patch("litellm.proxy.proxy_server.general_settings", {"apply_user_budget_to_team_keys": True}): + with patch("litellm.proxy.proxy_server.general_settings", proxy_config.settings): await proxy_config._update_general_settings(db_general_settings={"apply_user_budget_to_team_keys": False}) import litellm.proxy.proxy_server as ps @@ -7449,14 +7427,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 = { @@ -7486,9 +7463,71 @@ 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 + + +@pytest.mark.asyncio +async def test_deleting_the_stored_pass_through_row_takes_the_route_out_of_service(): + """A pass-through route the database declared has to stop serving when that row is + deleted. The proxy's own registry of live pass-through routes is what decides whether + a request is routed upstream or falls through to the auth error, so it has to lose the + entry on the reload rather than at the next process restart.""" + from litellm.proxy.pass_through_endpoints.pass_through_endpoints import InitPassThroughEndpointHelpers + from litellm.proxy.proxy_server import ProxyConfig + + path: Final = f"/v1/deleted-{uuid.uuid4().hex[:8]}" + db_endpoint: Final = {"id": "db-1", "path": path, "target": "https://example.com/post"} + + def live_routes() -> set[str]: + return {route for route in InitPassThroughEndpointHelpers.get_all_registered_pass_through_routes() if path in route} + + settings: Final = patch("litellm.proxy.proxy_server.general_settings", {}) # test-quality-ok: the method reads this module global; no injection seam + yaml_endpoints: Final = patch("litellm.proxy.proxy_server.config_passthrough_endpoints", None) # test-quality-ok: module global holding the YAML endpoints; this case has none + with settings, yaml_endpoints: + pc = ProxyConfig() + await pc._update_general_settings(db_general_settings={"pass_through_endpoints": [db_endpoint]}) + assert live_routes(), "the stored endpoint should be serving before the row is deleted" + + await pc._update_general_settings(db_general_settings={}) + + assert live_routes() == set() + + +@pytest.mark.asyncio +async def test_a_stored_pass_through_row_never_disturbs_the_config_declared_routes(): + """``pass_through_endpoints`` is config-owned once the file declares it, so writing and then + deleting a stored row resolves to the same list both times and the config file's routes keep + serving untouched. The stored entry never gets a route of its own.""" + from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( + InitPassThroughEndpointHelpers, + initialize_pass_through_endpoints, + ) + from litellm.proxy.proxy_server import ProxyConfig + + marker: Final = uuid.uuid4().hex[:8] + config_path: Final = f"/v1/kept-{marker}" + db_path: Final = f"/v1/ignored-{marker}" + config_endpoint: Final = {"id": f"cfg-{marker}", "path": config_path, "target": "https://example.com/post"} + db_endpoint: Final = {"id": f"db-{marker}", "path": db_path, "target": "https://example.com/post"} + + def live_paths() -> set[str]: + registered: Final = InitPassThroughEndpointHelpers.get_all_registered_pass_through_routes() + return {path for path in (config_path, db_path) if any(path in route for route in registered)} + + settings: Final = patch("litellm.proxy.proxy_server.general_settings", {"pass_through_endpoints": [config_endpoint]}) # test-quality-ok: the method reads this module global; no injection seam + yaml_endpoints: Final = patch("litellm.proxy.proxy_server.config_passthrough_endpoints", [config_endpoint]) # test-quality-ok: module global holding the YAML endpoints the reload merges in + with settings, yaml_endpoints: + await initialize_pass_through_endpoints(pass_through_endpoints=[config_endpoint]) + assert live_paths() == {config_path} + + pc = ProxyConfig() + await pc._update_general_settings(db_general_settings={"pass_through_endpoints": [db_endpoint]}) + assert live_paths() == {config_path} + + await pc._update_general_settings(db_general_settings={}) + + assert live_paths() == {config_path} def _fill_user_api_key_cache(cache: DualCache, count: int) -> None: @@ -7522,10 +7561,11 @@ async def test_update_general_settings_clearing_user_api_key_cache_max_size_rest from litellm.proxy.proxy_server import ProxyConfig cache = UserApiKeyCache() - cache.update_in_memory_max_size(5000) - monkeypatch.setattr(proxy_server_module, "general_settings", {"user_api_key_cache_max_size": 5000}) + proxy_config = ProxyConfig() + monkeypatch.setattr(proxy_server_module, "general_settings", proxy_config.settings) monkeypatch.setattr(proxy_server_module, "user_api_key_cache", cache) - await ProxyConfig()._update_general_settings(db_general_settings={"store_model_in_db": True}) + await proxy_config._update_general_settings(db_general_settings={"user_api_key_cache_max_size": 5000}) + await proxy_config._update_general_settings(db_general_settings={"store_model_in_db": True}) assert "user_api_key_cache_max_size" not in proxy_server_module.general_settings @@ -7560,10 +7600,10 @@ async def test_update_general_settings_user_api_key_cache_max_size_yaml_wins(mon from litellm.proxy.proxy_server import ProxyConfig proxy_config = ProxyConfig() - proxy_config._yaml_general_settings_keys = {"user_api_key_cache_max_size"} + proxy_config.settings.load_yaml({"user_api_key_cache_max_size": 300}) cache = UserApiKeyCache() cache.update_in_memory_max_size(300) - monkeypatch.setattr(proxy_server_module, "general_settings", {"user_api_key_cache_max_size": 300}) + monkeypatch.setattr(proxy_server_module, "general_settings", proxy_config.settings) monkeypatch.setattr(proxy_server_module, "user_api_key_cache", cache) await proxy_config._update_general_settings(db_general_settings={"user_api_key_cache_max_size": 10}) @@ -7596,7 +7636,10 @@ async def test_update_general_settings_disable_auto_add_proxy_admin_to_teams(db_ import litellm.proxy.proxy_server as ps - assert ps.general_settings["disable_auto_add_proxy_admin_to_teams"] is expected + if expected is None: + assert "disable_auto_add_proxy_admin_to_teams" not in ps.general_settings + else: + assert ps.general_settings["disable_auto_add_proxy_admin_to_teams"] is expected @pytest.mark.asyncio @@ -11064,11 +11107,8 @@ def test_prompt_caching_settings_propagate_on_config_reload(monkeypatch, field_n monkeypatch.setattr(litellm, field_name, False if isinstance(db_value, bool) else None) pc = ps.ProxyConfig() - pc._update_config_fields( - current_config={"litellm_settings": {}}, - param_name="litellm_settings", - db_param_value={field_name: db_value}, - ) + resolved_db_values = pc._prepared_db_settings_values("litellm_settings", {field_name: db_value}) + pc._apply_litellm_settings_db_values(resolved_db_values) assert getattr(litellm, field_name) == db_value @@ -11342,6 +11382,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 @@ -11364,6 +11405,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) @@ -11559,6 +11606,217 @@ async def test_update_config_general_settings_emits_audit_log(monkeypatch): assert before["some_api_key"] != "sk-stored-secret" +@pytest.mark.asyncio +async def test_delete_config_general_settings_is_visible_to_the_next_read(monkeypatch): + import litellm.proxy.proxy_server as proxy_server_module + from litellm.proxy._types import ConfigFieldDelete + from litellm.proxy.config_resolvers import SettingsStore + from litellm.proxy.proxy_server import delete_config_general_settings, get_config_general_settings + + fake = _fake_prisma_with_config({"max_request_size_mb": 42}) + monkeypatch.setattr(proxy_server_module, "prisma_client", fake) + + settings = SettingsStore("general_settings") + settings.load_yaml({}) + settings.apply_db_row("general_settings", {"max_request_size_mb": 42}) + monkeypatch.setattr(proxy_server_module.proxy_config, "settings", settings) + + admin = UserAPIKeyAuth(api_key="hashed-admin", user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN) + await delete_config_general_settings( + data=ConfigFieldDelete(field_name="max_request_size_mb", config_type="general_settings"), + user_api_key_dict=admin, + ) + + with pytest.raises(HTTPException) as excinfo: + await get_config_general_settings(field_name="max_request_size_mb", user_api_key_dict=admin) + assert excinfo.value.status_code == 400 + assert "is not set" in excinfo.value.detail["error"] + + +@pytest.mark.asyncio +async def test_ui_litellm_field_write_refuses_a_key_the_config_file_declares(monkeypatch): + import litellm.proxy.proxy_server as proxy_server_module + from litellm.proxy._types import ConfigFieldUpdate + from litellm.proxy.proxy_server import ProxyConfig, update_config_general_settings + + pc = ProxyConfig() + pc._load_yaml_settings_stores({"litellm_settings": {"enable_anthropic_prompt_caching": True}}) + monkeypatch.setattr(proxy_server_module, "proxy_config", pc) + monkeypatch.setattr(litellm, "enable_anthropic_prompt_caching", True) + + fake = _fake_prisma_with_config({}) + monkeypatch.setattr(proxy_server_module, "prisma_client", fake) + + admin = UserAPIKeyAuth(api_key="hashed-admin", user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN) + with pytest.raises(HTTPException) as excinfo: + await update_config_general_settings( + data=ConfigFieldUpdate( + field_name="enable_anthropic_prompt_caching", field_value=False, config_type="general_settings" + ), + user_api_key_dict=admin, + ) + + assert excinfo.value.status_code == 400 + assert excinfo.value.detail["keys"] == ["enable_anthropic_prompt_caching"] + assert litellm.enable_anthropic_prompt_caching is True + fake.db.litellm_config.upsert.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_ui_litellm_field_reset_refuses_a_key_the_config_file_declares(monkeypatch): + import litellm.proxy.proxy_server as proxy_server_module + from litellm.proxy.proxy_server import ProxyConfig, _reset_general_settings_ui_litellm_field + + pc = ProxyConfig() + pc._load_yaml_settings_stores({"litellm_settings": {"enable_anthropic_prompt_caching": True}}) + monkeypatch.setattr(proxy_server_module, "proxy_config", pc) + monkeypatch.setattr(litellm, "enable_anthropic_prompt_caching", True) + + fake = _fake_prisma_with_config({}) + monkeypatch.setattr(proxy_server_module, "prisma_client", fake) + + admin = UserAPIKeyAuth(api_key="hashed-admin", user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN) + with pytest.raises(HTTPException) as excinfo: + await _reset_general_settings_ui_litellm_field("enable_anthropic_prompt_caching", admin) + + assert excinfo.value.status_code == 400 + assert litellm.enable_anthropic_prompt_caching is True + + +@pytest.mark.asyncio +async def test_update_config_general_settings_refuses_a_key_the_config_file_declares(monkeypatch): + import litellm.proxy.proxy_server as proxy_server_module + from litellm.proxy._types import ConfigFieldUpdate + from litellm.proxy.proxy_server import ProxyConfig, update_config_general_settings + + pc = ProxyConfig() + pc._load_yaml_settings_stores({"general_settings": {"max_parallel_requests": 111}}) + monkeypatch.setattr(proxy_server_module, "proxy_config", pc) + monkeypatch.setattr(proxy_server_module, "user_config_file_path", "/etc/litellm/config.yaml") + + fake = _fake_prisma_with_config({}) + monkeypatch.setattr(proxy_server_module, "prisma_client", fake) + + admin = UserAPIKeyAuth(api_key="hashed-admin", user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN) + with pytest.raises(HTTPException) as excinfo: + await update_config_general_settings( + data=ConfigFieldUpdate( + field_name="max_parallel_requests", field_value=999, config_type="general_settings" + ), + user_api_key_dict=admin, + ) + + assert excinfo.value.status_code == 400 + detail = excinfo.value.detail + assert detail["keys"] == ["max_parallel_requests"] + assert "max_parallel_requests" in detail["error"] + assert "/etc/litellm/config.yaml" in detail["resolution"] + fake.db.litellm_config.upsert.assert_not_awaited() + assert pc.settings["max_parallel_requests"] == 111 + + +@pytest.mark.asyncio +async def test_save_config_refuses_a_key_the_config_file_declares(monkeypatch): + import litellm.proxy.proxy_server as proxy_server_module + from litellm.proxy.proxy_server import ProxyConfig + + pc = ProxyConfig() + pc._load_yaml_settings_stores({"general_settings": {"max_parallel_requests": 111}}) + monkeypatch.setattr(proxy_server_module, "proxy_config", pc) + + fake = _fake_prisma_with_config({}) + monkeypatch.setattr(proxy_server_module, "prisma_client", fake) + + with pytest.raises(HTTPException) as excinfo: + await pc._save_changed_config_section( + section_name="general_settings", + baseline={"general_settings": {"max_parallel_requests": 111}}, + new_config={"general_settings": {"max_parallel_requests": 999}}, + prisma_client=fake, + ) + + assert excinfo.value.status_code == 400 + assert excinfo.value.detail["keys"] == ["max_parallel_requests"] + fake.db.litellm_config.upsert.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_save_config_allows_a_write_that_matches_the_config_file(monkeypatch): + import litellm.proxy.proxy_server as proxy_server_module + from litellm.proxy.proxy_server import ProxyConfig + + pc = ProxyConfig() + pc._load_yaml_settings_stores({"general_settings": {"max_parallel_requests": 111}}) + monkeypatch.setattr(proxy_server_module, "proxy_config", pc) + + fake = _fake_prisma_with_config({}) + monkeypatch.setattr(proxy_server_module, "prisma_client", fake) + + await pc._save_changed_config_section( + section_name="general_settings", + baseline={"general_settings": {}}, + new_config={"general_settings": {"max_parallel_requests": 111, "max_request_size_mb": 42}}, + prisma_client=fake, + ) + + assert pc.settings["max_request_size_mb"] == 42 + assert pc.settings["max_parallel_requests"] == 111 + + +@pytest.mark.asyncio +async def test_update_config_general_settings_is_visible_to_the_next_read(monkeypatch): + import litellm.proxy.proxy_server as proxy_server_module + from litellm.proxy._types import ConfigFieldUpdate + from litellm.proxy.config_resolvers import SettingsStore + from litellm.proxy.proxy_server import ( + get_config_general_settings, + update_config_general_settings, + ) + + fake = _fake_prisma_with_config({}) + monkeypatch.setattr(proxy_server_module, "prisma_client", fake) + + settings = SettingsStore("general_settings") + settings.load_yaml({}) + monkeypatch.setattr(proxy_server_module.proxy_config, "settings", settings) + + admin = UserAPIKeyAuth(api_key="hashed-admin", user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN) + await update_config_general_settings( + data=ConfigFieldUpdate(field_name="max_request_size_mb", field_value=42, config_type="general_settings"), + user_api_key_dict=admin, + ) + + read_back = await get_config_general_settings(field_name="max_request_size_mb", user_api_key_dict=admin) + assert read_back.field_value == 42 + assert read_back.source == "db" + assert read_back.editable is True + + +@pytest.mark.asyncio +async def test_save_config_makes_a_db_owned_write_visible_to_the_next_read(monkeypatch): + import litellm.proxy.proxy_server as proxy_server_module + from litellm.proxy.proxy_server import ProxyConfig + + pc = ProxyConfig() + pc._load_yaml_settings_stores({"general_settings": {"max_parallel_requests": 111}}) + monkeypatch.setattr(proxy_server_module, "proxy_config", pc) + + fake = _fake_prisma_with_config({}) + monkeypatch.setattr(proxy_server_module, "prisma_client", fake) + + await pc._save_changed_config_section( + section_name="general_settings", + baseline={"general_settings": {}}, + new_config={"general_settings": {"max_request_size_mb": 42}}, + prisma_client=fake, + ) + + assert pc.settings["max_request_size_mb"] == 42 + assert pc.settings.source("max_request_size_mb") == "db" + assert pc.settings["max_parallel_requests"] == 111 + assert pc.settings.source("max_parallel_requests") == "config" + + @pytest.mark.asyncio async def test_update_config_field_rejects_out_of_range_alerting_args(monkeypatch): """Out-of-range alerting_args must be rejected at save time. If they land in the @@ -13782,8 +14040,8 @@ def test_disabling_docs_does_not_disable_other_routes(monkeypatch): "db_general_settings, expected", [ ({"enable_openai_websocket_passthrough": True}, True), - ({"enable_openai_websocket_passthrough": False}, False), - ({}, None), + ({"enable_openai_websocket_passthrough": False}, True), + ({}, True), ], ) async def test_update_general_settings_propagates_openai_websocket_passthrough(db_general_settings, expected): @@ -13804,9 +14062,9 @@ async def test_update_general_settings_keeps_yaml_openai_websocket_passthrough() from litellm.proxy.proxy_server import ProxyConfig proxy_config = ProxyConfig() - proxy_config._yaml_general_settings_keys = {"enable_openai_websocket_passthrough"} + proxy_config.settings.load_yaml({"enable_openai_websocket_passthrough": False}) - with patch("litellm.proxy.proxy_server.general_settings", {"enable_openai_websocket_passthrough": False}): + with patch("litellm.proxy.proxy_server.general_settings", proxy_config.settings): await proxy_config._update_general_settings(db_general_settings={"enable_openai_websocket_passthrough": True}) import litellm.proxy.proxy_server as ps diff --git a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py index 8f17a1e45de..fc5733b1a82 100644 --- a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py +++ b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py @@ -3439,3 +3439,31 @@ class TestSyncUiSettingsToGeneralSettings: assert dict(applied) == {} assert general_settings == {"allow_agents_for_team_admins": True} + + def test_applied_runtime_flags_keep_the_ui_row_as_the_source(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({}) + 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" diff --git a/tests/test_litellm/repositories/test_repositories.py b/tests/test_litellm/repositories/test_repositories.py index c7f6b8ff83a..b6b4a8072fa 100644 --- a/tests/test_litellm/repositories/test_repositories.py +++ b/tests/test_litellm/repositories/test_repositories.py @@ -1447,27 +1447,6 @@ class TestConfigRepository: client = MockPrismaClient() return ConfigRepository(client) - def test_deep_merge_dicts_db_wins(self, repo): - dst = {"a": 1, "b": {"c": 2}} - src = {"a": 10, "b": {"d": 3}} - repo._deep_merge_dicts(dst, src) - assert dst["a"] == 10 - assert dst["b"]["c"] == 2 - assert dst["b"]["d"] == 3 - - def test_deep_merge_dicts_skips_none(self, repo): - dst = {"a": 1} - src = {"a": None, "b": 2} - repo._deep_merge_dicts(dst, src) - assert dst["a"] == 1 - assert dst["b"] == 2 - - def test_deep_merge_dicts_skips_empty_list(self, repo): - dst = {"models": ["gpt-4"]} - src = {"models": []} - repo._deep_merge_dicts(dst, src) - assert dst["models"] == ["gpt-4"] - @pytest.mark.asyncio async def test_get_param(self, repo): repo._prisma_client.db.litellm_config._records["general_settings"] = { @@ -1512,99 +1491,6 @@ class TestConfigRepository: params = await repo.get_all_params() assert len(params) == 2 - @pytest.mark.asyncio - async def test_reconcile_config_skips_when_store_model_false(self, repo): - yaml_config = {"general_settings": {"key": "value"}} - result = await repo.reconcile_config(yaml_config, store_model_in_db=False) - assert result == yaml_config - - @pytest.mark.asyncio - async def test_prefetch_params(self, repo): - repo._prisma_client.db.litellm_config._records["general_settings"] = { - "param_name": "general_settings", - "param_value": "{}", - } - await repo.prefetch_params(["general_settings"]) - - @pytest.mark.asyncio - async def test_reconcile_config_with_db_values(self, repo): - repo._prisma_client.db.litellm_config._records["general_settings"] = { - "param_name": "general_settings", - "param_value": '{"master_key": "db-key", "db_only": "from_db"}', - } - repo._prisma_client.db.litellm_config._records["router_settings"] = { - "param_name": "router_settings", - "param_value": '{"timeout": 60}', - } - yaml_config = { - "general_settings": {"master_key": "yaml-key", "yaml_only": "from_yaml"}, - } - result = await repo.reconcile_config(yaml_config, store_model_in_db=True) - assert result["general_settings"]["master_key"] == "db-key" - assert result["general_settings"]["yaml_only"] == "from_yaml" - assert result["general_settings"]["db_only"] == "from_db" - assert result["router_settings"]["timeout"] == 60 - - @pytest.mark.asyncio - @patch("litellm.repositories.config_repository.decrypt_value_helper") - async def test_reconcile_config_with_environment_variables( - self, mock_decrypt, repo - ): - mock_decrypt.side_effect = lambda value, **kw: f"decrypted_{value}" - repo._prisma_client.db.litellm_config._records["environment_variables"] = { - "param_name": "environment_variables", - "param_value": '{"api_key": "encrypted_key", "secret": "encrypted_secret"}', - } - yaml_config = {} - result = await repo.reconcile_config(yaml_config, store_model_in_db=True) - assert "environment_variables" in result - assert "api_key" in result["environment_variables"] - assert "API_KEY" in result["environment_variables"] - - @pytest.mark.asyncio - async def test_reconcile_config_none_values_preserved(self, repo): - repo._prisma_client.db.litellm_config._records["general_settings"] = { - "param_name": "general_settings", - "param_value": '{"new_key": "value", "null_key": null}', - } - yaml_config = {"general_settings": {"existing": "keep"}} - result = await repo.reconcile_config(yaml_config, store_model_in_db=True) - assert result["general_settings"]["existing"] == "keep" - assert result["general_settings"]["new_key"] == "value" - - def test_update_config_fields_non_dict(self, repo): - config = {"litellm_settings": "old_value"} - result = repo._update_config_fields( - current_config=config, - param_name="litellm_settings", - db_param_value="new_value", - ) - assert result["litellm_settings"] == "new_value" - - def test_update_config_fields_new_param(self, repo): - config = {} - result = repo._update_config_fields( - current_config=config, - param_name="router_settings", - db_param_value={"timeout": 30}, - ) - assert result["router_settings"] == {"timeout": 30} - - @patch("litellm.repositories.config_repository.decrypt_value_helper") - def test_decrypt_env_variables_non_string(self, mock_decrypt, repo): - mock_decrypt.side_effect = lambda value, **kw: value - env_vars = {"string_val": "encrypted", "int_val": 123, "bool_val": True} - result = repo._decrypt_env_variables(env_vars) - assert result["int_val"] == "123" - assert result["bool_val"] == "True" - - @patch("litellm.repositories.config_repository.decrypt_value_helper") - def test_decrypt_env_variables_none_value(self, mock_decrypt, repo): - mock_decrypt.return_value = None - env_vars = {"key": "value"} - result = repo._decrypt_env_variables(env_vars) - assert "key" not in result - class TestVerificationTokenRepositoryExtended: @pytest.fixture @@ -2213,48 +2099,6 @@ class TestTeamRepositoryArchiveData: assert "router_settings" in archive_data -class TestConfigRepositoryDeepCopy: - @pytest.fixture - def repo(self): - client = MockPrismaClient() - return ConfigRepository(client) - - @pytest.mark.asyncio - async def test_reconcile_config_does_not_mutate_original(self, repo): - import copy - - repo._prisma_client.db.litellm_config._records["general_settings"] = { - "param_name": "general_settings", - "param_value": '{"db_key": "db_value", "nested": {"db_nested": "from_db"}}', - } - original_config = { - "general_settings": { - "yaml_key": "yaml_value", - "nested": {"yaml_nested": "from_yaml"}, - } - } - original_copy = copy.deepcopy(original_config) - result = await repo.reconcile_config(original_config, store_model_in_db=True) - assert original_config == original_copy - assert result["general_settings"]["db_key"] == "db_value" - assert result["general_settings"]["yaml_key"] == "yaml_value" - assert result["general_settings"]["nested"]["db_nested"] == "from_db" - assert result["general_settings"]["nested"]["yaml_nested"] == "from_yaml" - - @pytest.mark.asyncio - async def test_reconcile_config_repeated_calls_independent(self, repo): - repo._prisma_client.db.litellm_config._records["general_settings"] = { - "param_name": "general_settings", - "param_value": '{"db_key": "db_value"}', - } - yaml_config = {"general_settings": {"yaml_key": "yaml_value"}} - result1 = await repo.reconcile_config(yaml_config, store_model_in_db=True) - result1["general_settings"]["modified"] = "in_result1" - result2 = await repo.reconcile_config(yaml_config, store_model_in_db=True) - assert "modified" not in yaml_config.get("general_settings", {}) - assert "modified" not in result2.get("general_settings", {}) - - class TestPrismaTableRepository: def test_table_property_returns_named_delegate(self): from litellm.proxy.common_utils.config_sync_pubsub import ( diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index fd882937e79..58f1b93e544 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -26389,10 +26389,21 @@ export interface components { }; /** ConfigFieldInfo */ ConfigFieldInfo: { + /** + * Editable + * @default true + */ + editable: boolean; /** Field Name */ field_name: string; /** Field Value */ field_value: unknown; + /** + * Source + * @default unset + * @enum {string} + */ + source: "config" | "db" | "env" | "default" | "unset"; }; /** ConfigFieldUpdate */ ConfigFieldUpdate: { @@ -26874,6 +26885,11 @@ export interface components { }; /** ConfigList */ ConfigList: { + /** + * Editable + * @default true + */ + editable: boolean; /** Field Default Value */ field_default_value: unknown; /** Field Description */ @@ -26895,6 +26911,12 @@ export interface components { * @default false */ premium_field: boolean; + /** + * Source + * @default unset + * @enum {string} + */ + source: "config" | "db" | "env" | "default" | "unset"; /** Stored In Db */ stored_in_db: boolean | null; };