mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
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:
commit
c4ab1d98e9
28 changed files with 1794 additions and 1149 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
110
litellm/proxy/config_resolvers/settings_rules.py
Normal file
110
litellm/proxy/config_resolvers/settings_rules.py
Normal 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
|
||||
128
litellm/proxy/config_resolvers/settings_store.py
Normal file
128
litellm/proxy/config_resolvers/settings_store.py
Normal 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)
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {})
|
||||
|
|
|
|||
186
tests/test_litellm/proxy/config_resolvers/test_settings_rules.py
Normal file
186
tests/test_litellm/proxy/config_resolvers/test_settings_rules.py
Normal 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"
|
||||
235
tests/test_litellm/proxy/config_resolvers/test_settings_store.py
Normal file
235
tests/test_litellm/proxy/config_resolvers/test_settings_store.py
Normal 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"
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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()))]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
22
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
22
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue