From 1cd9e5a6f4316dc911367c7ae08529baea792c62 Mon Sep 17 00:00:00 2001 From: Delweng Date: Sun, 18 Oct 2026 14:27:10 +0800 Subject: [PATCH] fix(proxy): report the effective store_model_in_db flag to the Admin UI config endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /config/list and /config/field/info only saw the YAML general_settings value and the DB-stored row — neither knows about the STORE_MODEL_IN_DB env var, so a deployment that enabled the feature via env reported the flag as false and the Admin UI 'Store Model in DB' toggle rendered OFF while model persistence was actually active. The runtime global is resolved at startup from YAML general_settings OR the env var OR a DB override, and reapplied from the DB row on periodic reloads. Publish the resolved value back into both reporting surfaces at each of those points via a new _publish_store_model_in_db helper. Config-file-owned keys are never written to the store, and the general_settings write is skipped when it aliases the store itself. Fixes #47238 Signed-off-by: Delweng --- litellm/proxy/proxy_server.py | 35 ++++- .../proxy/proxy_server/test_routes_config.py | 123 ++++++++++++++++++ 2 files changed, 156 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index e3ea9dabe50..b7707a6bf88 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -2947,6 +2947,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 @@ -8392,8 +8420,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, @@ -11409,6 +11436,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( diff --git a/tests/unit/proxy/proxy_server/test_routes_config.py b/tests/unit/proxy/proxy_server/test_routes_config.py index a92986392e9..8b8a39dcfdb 100644 --- a/tests/unit/proxy/proxy_server/test_routes_config.py +++ b/tests/unit/proxy/proxy_server/test_routes_config.py @@ -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"