diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 5030b1512a6..3e0245db178 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -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( 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"