mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-26 06:03:19 +00:00
Merge 1cd9e5a6f4 into 3fa1587f43
This commit is contained in:
commit
772a0fb8eb
2 changed files with 156 additions and 2 deletions
|
|
@ -2964,6 +2964,34 @@ disable_spend_logs = False
|
|||
jwt_handler: Final = JWTHandler()
|
||||
prompt_injection_detection_obj: Final[OPTIONAL_PromptInjectionDetection | None] = None
|
||||
store_model_in_db: bool = False
|
||||
|
||||
|
||||
def _publish_store_model_in_db(value: bool, settings_store: SettingsStore | None = None) -> None:
|
||||
"""Mirror the effective ``store_model_in_db`` flag into the surfaces the Admin UI reads.
|
||||
|
||||
``/config/list`` reads the in-memory ``general_settings`` mapping and
|
||||
``/config/field/info`` reads the settings store — neither sees the
|
||||
``STORE_MODEL_IN_DB`` env var, so without this an env-enabled deployment
|
||||
reported the flag as false and the UI toggle rendered OFF while the feature
|
||||
was active (the runtime global is resolved from YAML OR env OR DB at
|
||||
startup, and from the DB row on reloads). Callers holding the live store
|
||||
pass it explicitly; keys owned by the config file are never written to the
|
||||
store — the file owns the value and shadowed writes are rejected.
|
||||
"""
|
||||
try:
|
||||
if settings_store is None:
|
||||
settings_store = proxy_config.settings
|
||||
if not settings_store.owned_by_config("store_model_in_db"):
|
||||
settings_store["store_model_in_db"] = value
|
||||
except AttributeError:
|
||||
# No live settings store (e.g. a mocked ProxyConfig in unit tests).
|
||||
pass
|
||||
# ``general_settings`` is usually a plain dict, but some code paths point it
|
||||
# at the settings store itself, which the branch above already updated.
|
||||
if general_settings is not settings_store:
|
||||
general_settings["store_model_in_db"] = value
|
||||
|
||||
|
||||
open_telemetry_logger: OpenTelemetry | None = None
|
||||
### GATEWAY REQUEST COUNTS (SGR) ###
|
||||
# Folded in memory by BillableRequestMetricsMiddleware, drained to
|
||||
|
|
@ -8404,8 +8432,7 @@ class ProxyConfig:
|
|||
return
|
||||
normalized: Final = coerce_bool(value)
|
||||
store_model_in_db = normalized if isinstance(normalized, bool) else bool(normalized)
|
||||
if not self.settings.owned_by_config("store_model_in_db"):
|
||||
self.settings["store_model_in_db"] = store_model_in_db
|
||||
_publish_store_model_in_db(store_model_in_db, self.settings)
|
||||
|
||||
async def _apply_retention_settings(
|
||||
self,
|
||||
|
|
@ -11421,6 +11448,10 @@ class ProxyStartupEvent:
|
|||
except Exception as e:
|
||||
verbose_proxy_logger.debug("Failed to check DB for store_model_in_db: %s", str(e))
|
||||
|
||||
# Keep the reporting surfaces (/config/list, /config/field/info) in sync with the
|
||||
# effective flag — they only see YAML + DB, not the STORE_MODEL_IN_DB env var.
|
||||
_publish_store_model_in_db(store_model_in_db)
|
||||
|
||||
config_reload_interval_seconds = proxy_config_reload_interval_seconds
|
||||
if not isinstance(config_reload_interval_seconds, int) or config_reload_interval_seconds <= 0:
|
||||
verbose_proxy_logger.warning(
|
||||
|
|
|
|||
|
|
@ -1882,3 +1882,126 @@ def test_config_yaml_invalid_method(client):
|
|||
# Method-not-allowed responses still return a JSON-ish body via the
|
||||
# FastAPI default handler — assert the body is not the success payload.
|
||||
assert response.content != b'{"hello":"world"}'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# store_model_in_db reporting surfaces (/config/list + /config/field/info)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_config_list_reflects_env_resolved_store_model_in_db(client, auth_as, mock_prisma, monkeypatch):
|
||||
"""store_model_in_db enabled only via the STORE_MODEL_IN_DB env var (absent from
|
||||
config.yaml and the DB row) must surface as field_value=True in /config/list with
|
||||
stored_in_db=False — the Admin UI "Store Model in DB" toggle reads this endpoint
|
||||
and rendered OFF while the feature was active (issue #47238)."""
|
||||
from litellm.proxy import proxy_server as ps
|
||||
from litellm.proxy._types import LitellmUserRoles
|
||||
|
||||
_seed_settings_store(monkeypatch, db_row={})
|
||||
table = _install_litellm_config(mock_prisma)
|
||||
row = MagicMock()
|
||||
row.param_value = {}
|
||||
table.find_first = AsyncMock(return_value=row)
|
||||
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
|
||||
monkeypatch.setattr(ps, "store_model_in_db", True)
|
||||
# pre-publish state: the config-load normalization wrote the YAML-resolved false
|
||||
monkeypatch.setitem(ps.general_settings, "store_model_in_db", False)
|
||||
|
||||
ps._publish_store_model_in_db(True, ps.proxy_config.settings) # what startup_event does after resolving the flag
|
||||
|
||||
with auth_as(LitellmUserRoles.PROXY_ADMIN):
|
||||
response = client.get("/config/list", params={"config_type": "general_settings"})
|
||||
assert response.status_code == 200
|
||||
entry = {item["field_name"]: item for item in response.json()}["store_model_in_db"]
|
||||
assert entry["field_value"] is True
|
||||
assert entry["stored_in_db"] is False
|
||||
assert entry["editable"] is True
|
||||
|
||||
|
||||
def test_config_field_info_reflects_env_resolved_store_model_in_db(client, auth_as, mock_prisma, monkeypatch):
|
||||
"""/config/field/info reads the settings store, which does not see the env var either;
|
||||
after startup publishes the effective flag the endpoint must return it."""
|
||||
from litellm.proxy import proxy_server as ps
|
||||
from litellm.proxy._types import LitellmUserRoles
|
||||
|
||||
_seed_settings_store(monkeypatch, db_row={})
|
||||
table = _install_litellm_config(mock_prisma)
|
||||
row = MagicMock()
|
||||
row.param_value = {}
|
||||
table.find_first = AsyncMock(return_value=row)
|
||||
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
|
||||
monkeypatch.setattr(ps, "store_model_in_db", True)
|
||||
|
||||
ps._publish_store_model_in_db(True, ps.proxy_config.settings)
|
||||
|
||||
with auth_as(LitellmUserRoles.PROXY_ADMIN):
|
||||
response = client.get("/config/field/info", params={"field_name": "store_model_in_db"})
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["field_name"] == "store_model_in_db"
|
||||
assert body["field_value"] is True
|
||||
assert body["editable"] is True
|
||||
|
||||
|
||||
def test_publish_store_model_in_db_skips_store_write_for_config_owned_key(client, auth_as, mock_prisma, monkeypatch):
|
||||
"""When general_settings.store_model_in_db is declared in config.yaml the file owns
|
||||
it: /config/list still reports the effective (env-resolved) value, but the row is
|
||||
non-editable and the settings store keeps the config-owned value untouched."""
|
||||
from litellm.proxy import proxy_server as ps
|
||||
from litellm.proxy._types import LitellmUserRoles
|
||||
|
||||
_seed_settings_store(monkeypatch, db_row={}, yaml_values={"store_model_in_db": True})
|
||||
table = _install_litellm_config(mock_prisma)
|
||||
row = MagicMock()
|
||||
row.param_value = {}
|
||||
table.find_first = AsyncMock(return_value=row)
|
||||
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
|
||||
monkeypatch.setattr(ps, "store_model_in_db", True)
|
||||
monkeypatch.setitem(ps.general_settings, "store_model_in_db", True)
|
||||
|
||||
ps._publish_store_model_in_db(True, ps.proxy_config.settings)
|
||||
|
||||
with auth_as(LitellmUserRoles.PROXY_ADMIN):
|
||||
response = client.get("/config/list", params={"config_type": "general_settings"})
|
||||
assert response.status_code == 200
|
||||
entry = {item["field_name"]: item for item in response.json()}["store_model_in_db"]
|
||||
assert entry["field_value"] is True
|
||||
assert entry["source"] == "config"
|
||||
assert entry["editable"] is False
|
||||
|
||||
|
||||
def test_store_model_in_db_reload_updates_reporting_surfaces(client, auth_as, mock_prisma, monkeypatch):
|
||||
"""A DB general_settings row flip applied by the periodic reload must update the
|
||||
module global AND both reporting surfaces, not just the settings store."""
|
||||
from litellm.proxy import proxy_server as ps
|
||||
from litellm.proxy.config_resolvers import SettingsStore
|
||||
|
||||
store = SettingsStore("general_settings")
|
||||
store.load_yaml({})
|
||||
store.apply_db_row("general_settings", {"store_model_in_db": True})
|
||||
monkeypatch.setattr(ps.proxy_config, "settings", store)
|
||||
monkeypatch.setattr(ps, "store_model_in_db", False)
|
||||
monkeypatch.setitem(ps.general_settings, "store_model_in_db", False)
|
||||
|
||||
asyncio.run(ps.proxy_config._apply_store_model_in_db_setting({"store_model_in_db": True}))
|
||||
|
||||
assert ps.store_model_in_db is True
|
||||
assert ps.general_settings["store_model_in_db"] is True
|
||||
assert store["store_model_in_db"] is True
|
||||
|
||||
|
||||
def test_store_model_in_db_publish_skips_config_owned_store_when_general_settings_aliases_it(monkeypatch):
|
||||
"""Code paths may point general_settings at the settings store itself; publishing the
|
||||
effective flag must never raise ConfigOwnedKeyError for a config-owned key, and the
|
||||
file-owned value must stay untouched."""
|
||||
from litellm.proxy import proxy_server as ps
|
||||
from litellm.proxy.config_resolvers import SettingsStore
|
||||
|
||||
store = SettingsStore("general_settings")
|
||||
store.load_yaml({"store_model_in_db": "os.environ/PROOF_FLAG"})
|
||||
monkeypatch.setattr(ps.proxy_config, "settings", store)
|
||||
monkeypatch.setattr(ps, "general_settings", store, raising=False)
|
||||
|
||||
ps._publish_store_model_in_db(False, store) # the DB row said false; the config file owns the key
|
||||
|
||||
assert store["store_model_in_db"] == "os.environ/PROOF_FLAG"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue