Merge pull request #41779 from BerriAI/litellm_settings_store_precedence

refactor(proxy): make the config file win over the database
This commit is contained in:
yuneng-jiang 2026-09-18 09:52:09 -07:00 • committed by GitHub
commit c4ab1d98e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1794 additions and 1149 deletions

View file

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

View file

@ -2423,6 +2423,8 @@ class ConfigList(LiteLLMPydanticObjectBase):
nested_fields: list[FieldDetail] | None = None # For nested dictionary or Pydantic fields
field_options: list[str] | None = None # Allowed values, for field_type == "Select"
field_tab: str | None = None # Admin UI sub-tab this field renders under; None groups it with the rest
source: 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):

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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