From 8e67a33fc33f6d989d172e6a7e979321f4b6127d Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 18 Sep 2026 01:49:30 -0700 Subject: [PATCH] fix(proxy): refuse config-owned writes at one choke point and refresh the store Both write paths now go through the same refusal, so /config/field/update and /config/update answer identically instead of each phrasing its own rule. A successful write now applies to the SettingsStore, so the next read sees it. Without this, /config/field/info reported a key the dashboard had just stored as "not set" until the process reloaded from the database. resolve() no longer takes a KeyRule it never reads; the store picks the row. The matrix tests resolve through SettingsStore instead of calling resolve directly, so the section and key in each case actually route a lookup. ConfigFieldInfo and ConfigList type `source` as the FieldSource literal, and the dashboard API types are regenerated for the two new fields. --- litellm/proxy/_types.py | 4 +- .../proxy/config_resolvers/settings_rules.py | 16 +-- .../proxy/config_resolvers/settings_store.py | 9 +- litellm/proxy/proxy_server.py | 52 ++++--- .../test_config_misc_endpoints_e2e.py | 2 +- .../config_resolvers/test_settings_rules.py | 88 +++++++----- .../config_resolvers/test_settings_store.py | 1 - .../proxy/proxy_server/test_routes_config.py | 25 +++- tests/test_litellm/proxy/test_proxy_server.py | 134 ++++++++++++++++++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 22 +++ 10 files changed, 264 insertions(+), 89 deletions(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 2486d8eae2e..aacf318267a 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -2423,7 +2423,7 @@ 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: str = "unset" + source: Literal["config", "db", "env", "default", "unset"] = "unset" editable: bool = True @@ -3695,7 +3695,7 @@ class InvitationClaim(LiteLLMPydanticObjectBase): class ConfigFieldInfo(LiteLLMPydanticObjectBase): field_name: str field_value: Any - source: str = "unset" + source: Literal["config", "db", "env", "default", "unset"] = "unset" editable: bool = True diff --git a/litellm/proxy/config_resolvers/settings_rules.py b/litellm/proxy/config_resolvers/settings_rules.py index 30b8ae3ddb8..f346dd6198d 100644 --- a/litellm/proxy/config_resolvers/settings_rules.py +++ b/litellm/proxy/config_resolvers/settings_rules.py @@ -89,14 +89,12 @@ def coerce_bool(value: JsonValue) -> JsonValue: return bool(value) -def resolve(rule: KeyRule, yaml_value: SettingValue, db_value: SettingValue) -> Resolved: +def resolve(yaml_value: SettingValue, db_value: SettingValue) -> Resolved: """Config wins. A key the config file declares is config-owned, whatever the database holds. - ``rule`` only selects which stored row the database value came from; it no longer - varies the precedence. A stored ``null`` still counts as absent, so clearing a row - does not erase a value the file never declared. + A stored ``null`` still counts as absent, so clearing a row does not erase a value + the file never declared. """ - del rule if yaml_value is not ABSENT: return Resolved(value=yaml_value, source="config") if _db_is_present(db_value): @@ -104,17 +102,9 @@ def resolve(rule: KeyRule, yaml_value: SettingValue, db_value: SettingValue) -> 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 - - diff --git a/litellm/proxy/config_resolvers/settings_store.py b/litellm/proxy/config_resolvers/settings_store.py index 2326f84c150..079d262319f 100644 --- a/litellm/proxy/config_resolvers/settings_store.py +++ b/litellm/proxy/config_resolvers/settings_store.py @@ -39,15 +39,10 @@ class SettingsStore(MutableMapping[str, JsonValue]): def owned_by_config(self, key: str) -> bool: return key in self._yaml_values - def config_owned_keys(self) -> frozenset[str]: - return frozenset(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] + key for key, value in incoming.items() if self.owned_by_config(key) and value != self._yaml_values[key] ) ) @@ -130,4 +125,4 @@ class SettingsStore(MutableMapping[str, JsonValue]): 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(rule, yaml_value, db_value) + return resolve(yaml_value, db_value) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 00da2f3a82a..91be0ba7c39 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -439,12 +439,13 @@ from litellm.proxy.config_resolvers.alerting import ( ) from litellm.proxy.config_resolvers.changed_section_keys import changed_section_keys from litellm.proxy.config_resolvers.settings_rules import ( - JsonValue as SettingsJsonValue, -) -from litellm.proxy.config_resolvers.settings_rules import ( + DbRow, Section, coerce_bool, ) +from litellm.proxy.config_resolvers.settings_rules import ( + JsonValue as SettingsJsonValue, +) from litellm.proxy.container_endpoints.endpoints import router as container_router from litellm.proxy.credential_endpoints.endpoints import router as credential_router from litellm.proxy.db.db_transaction_queue.pod_lock_manager import PodLockManager @@ -5041,7 +5042,7 @@ class ProxyConfig: else MappingProxyType({}) ) changed_keys, removed_keys = changed_section_keys(baseline_section, new_section) - self._reject_config_owned_writes(section_name=section_name, changed_keys=changed_keys) + self.reject_config_owned_writes(section_name=section_name, changed_keys=changed_keys) if not changed_keys and not removed_keys: return wrote_section: Final = await self._upsert_changed_config_section( @@ -5050,11 +5051,14 @@ class ProxyConfig: removed_keys=removed_keys, prisma_client=prisma_client, ) - if not wrote_section: + if wrote_section is None: return + store: Final = self._settings_stores.get(cast(Section, section_name)) + if store is not None: + store.apply_db_row(cast(DbRow, section_name), wrote_section) await invalidate_config_param(section_name) - def _reject_config_owned_writes(self, *, section_name: str, changed_keys: Mapping[str, JsonValue]) -> None: + def reject_config_owned_writes(self, *, section_name: str, changed_keys: Mapping[str, JsonValue]) -> None: """Refuse a write to a setting the config file owns, rather than storing a value that never applies.""" store: Final = self._settings_stores.get(cast(Section, section_name)) if store is None: @@ -5062,15 +5066,19 @@ class ProxyConfig: rejected: Final = store.rejected_writes(changed_keys) if not rejected: return + subject: Final = ( + f"key '{rejected[0]}' is" if len(rejected) == 1 else f"keys {', '.join(repr(key) for key in rejected)} are" + ) + pronoun: Final = "it" if len(rejected) == 1 else "them" raise HTTPException( status_code=400, detail={ - "error": f"{section_name} keys {list(rejected)} are set in the config file and cannot be changed here", + "error": f"{section_name} {subject} set in the config file and cannot be changed here", "keys": list(rejected), "section": section_name, "resolution": ( - f"edit {user_config_file_path} to change them, " - "or remove them from it to let the database own them" + f"edit {user_config_file_path} to change {pronoun}, " + f"or remove {pronoun} from the file to let the database own {pronoun}" ), }, ) @@ -5082,7 +5090,7 @@ class ProxyConfig: changed_keys: Mapping[str, JsonValue], removed_keys: frozenset[str], prisma_client: PrismaClient, - ) -> bool: + ) -> Mapping[str, JsonValue] | None: async with prisma_client.tx() as tx: await tx.query_raw(_CONFIG_SECTION_LOCK_SQL, section_name) config_table: Final = cast("TableActions[_ConfigParamRow]", tx.litellm_config) @@ -5106,14 +5114,14 @@ class ProxyConfig: } ) if merged_section == existing_section: - return False + return None serialized_section: Final = json.dumps(dict(merged_section)) # mutable-ok: JSON encoder requires a dict config_data: Final[_ConfigParamUpsert] = { "create": {"param_name": section_name, "param_value": serialized_section}, "update": {"param_value": serialized_section}, } await config_table.upsert(where=config_where, data=config_data) - return True + return merged_section async def save_environment_variables(self, updates: dict[str, str | None]) -> None: """Persist specific environment variables to the DB config row. @@ -17229,19 +17237,10 @@ async def update_config_general_settings( ## update db - if proxy_config.settings.owned_by_config(data.field_name): - raise HTTPException( - status_code=400, - detail={ - "error": f"general_settings key '{data.field_name}' is set in the config file and cannot be changed here", - "keys": [data.field_name], - "section": "general_settings", - "resolution": ( - f"edit {user_config_file_path} to change it, " - "or remove it from the file to let the database own it" - ), - }, - ) + proxy_config.reject_config_owned_writes( + section_name="general_settings", + changed_keys={data.field_name: cast(JsonValue, data.field_value)}, # cast-ok: validated above + ) field_value = data.field_value if data.field_name == "plugins": @@ -17260,6 +17259,7 @@ async def update_config_general_settings( }, ) await invalidate_config_param("general_settings") + proxy_config.settings.apply_db_row("general_settings", general_settings) asyncio.create_task( create_config_audit_log( "general_settings", "updated", before_general_settings, general_settings, user_api_key_dict @@ -17448,8 +17448,6 @@ async def get_config_general_settings( detail={"error": f"Invalid field={field_name} passed in."}, ) - # Answer with the value the proxy resolved, not the stored row: the config file may - # own this key, in which case the row holds a value that never applies. settings: Final = proxy_config.settings if field_name not in settings: raise HTTPException( diff --git a/tests/e2e/management/test_config_misc_endpoints_e2e.py b/tests/e2e/management/test_config_misc_endpoints_e2e.py index 0906ab52fe9..85b578a181a 100644 --- a/tests/e2e/management/test_config_misc_endpoints_e2e.py +++ b/tests/e2e/management/test_config_misc_endpoints_e2e.py @@ -564,7 +564,7 @@ class TestConfigPersistence: ) match field_info: case UnknownApiError(status_code=400, body=body): - assert "not in DB" in body + assert "is not set" in body case _: pytest.fail(f"expected max_parallel_requests to remain absent from the DB row, got {field_info}") diff --git a/tests/test_litellm/proxy/config_resolvers/test_settings_rules.py b/tests/test_litellm/proxy/config_resolvers/test_settings_rules.py index 23d3fa0659c..ea5ebe6cf12 100644 --- a/tests/test_litellm/proxy/config_resolvers/test_settings_rules.py +++ b/tests/test_litellm/proxy/config_resolvers/test_settings_rules.py @@ -16,6 +16,7 @@ from litellm.proxy.config_resolvers.settings_rules import ( resolve, rule_for, ) +from litellm.proxy.config_resolvers.settings_store import SettingsStore _SECTIONS: Final[tuple[Section, ...]] = ( "general_settings", @@ -24,9 +25,6 @@ _SECTIONS: Final[tuple[Section, ...]] = ( "environment_variables", ) -# One route per shape the resolver has to serve: a key that used to be database-owned, -# one that was already config-owned, the collection keys that used to merge, a key -# carried by a different stored row, another section, and a key with no rule at all. _ROUTES: Final[tuple[tuple[Section, str], ...]] = ( ("general_settings", "max_parallel_requests"), ("general_settings", "max_file_size_mb"), @@ -66,17 +64,11 @@ _DB_VALUES: Final[tuple[SettingValue, ...]] = ( [{"path": "/shared", "target": "db"}], ) -_CONFIG_OWNED_MATRIX: Final = tuple( +_MATRIX: Final = tuple( (section, key, config_value, db_value) for (section, key), config_value, db_value in itertools.product(_ROUTES, _CONFIG_VALUES, _DB_VALUES) - if not is_absent(config_value) -) -_DB_FALLBACK_MATRIX: Final = tuple( - (section, key, db_value) for (section, key), db_value in itertools.product(_ROUTES, _DB_VALUES) ) -# Keys the database used to win outright. The flip is the breaking change this PR ships, -# so each one is named rather than generated: a revert has to fail here. _PREVIOUSLY_DB_WINS: Final[tuple[str, ...]] = ( "max_parallel_requests", "global_max_parallel_requests", @@ -99,46 +91,72 @@ _PREVIOUSLY_DB_WINS: Final[tuple[str, ...]] = ( ) -@pytest.mark.parametrize(("section", "key", "config_value", "db_value"), _CONFIG_OWNED_MATRIX) -def test_a_key_the_config_file_declares_always_resolves_to_the_config_value( +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: - resolved: Final = resolve(rule_for(section, key), config_value, db_value) + store: Final = _store_for(section, key, config_value, db_value) - assert resolved.value == config_value - assert resolved.source == "config" - - -@pytest.mark.parametrize(("section", "key", "db_value"), _DB_FALLBACK_MATRIX) -def test_a_key_the_config_file_omits_falls_back_to_the_stored_value( - section: Section, key: str, db_value: SettingValue -) -> None: - resolved: Final = resolve(rule_for(section, key), ABSENT, db_value) - - if is_absent(db_value) or db_value is None: - assert isinstance(resolved.value, Absent) - assert resolved.source == "unset" + 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 resolved.value == db_value - assert resolved.source == "db" + 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: - resolved: Final = resolve(rule_for("general_settings", key), "from-config", "from-db") + store: Final = _store_for("general_settings", key, "from-config", "from-db") - assert resolved.value == "from-config" - assert resolved.source == "config" + 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, "", [], {}) - resolved: Final = tuple(resolve(rule_for("general_settings", key), "from-config", value) for value in falsy) + stores: Final = tuple(_store_for("general_settings", key, "from-config", value) for value in falsy) - assert {entry.value for entry in resolved} == {"from-config"} - assert {entry.source for entry in resolved} == {"config"} + assert {store[key] for store in stores} == {"from-config"} + assert {store.source(key) for store in stores} == {"config"} @pytest.mark.parametrize( @@ -162,7 +180,7 @@ def test_every_registered_rule_routes_to_a_known_row() -> None: def test_a_config_value_of_none_is_still_config_owned() -> None: - resolved: Final = resolve(rule_for("general_settings", "ui_access_mode"), None, "from-db") + resolved: Final = resolve(None, "from-db") assert resolved.value is None assert resolved.source == "config" diff --git a/tests/test_litellm/proxy/config_resolvers/test_settings_store.py b/tests/test_litellm/proxy/config_resolvers/test_settings_store.py index 87d08c83ba6..aa410deac43 100644 --- a/tests/test_litellm/proxy/config_resolvers/test_settings_store.py +++ b/tests/test_litellm/proxy/config_resolvers/test_settings_store.py @@ -177,7 +177,6 @@ def test_settings_store_reports_the_config_owned_keys_a_write_would_change() -> ) assert rejected == ("max_parallel_requests",) - assert store.config_owned_keys() == frozenset({"max_parallel_requests", "ui_access_mode"}) def test_settings_store_resolved_view_is_read_only() -> None: diff --git a/tests/test_litellm/proxy/proxy_server/test_routes_config.py b/tests/test_litellm/proxy/proxy_server/test_routes_config.py index dd3914e3ad5..19b36b026f9 100644 --- a/tests/test_litellm/proxy/proxy_server/test_routes_config.py +++ b/tests/test_litellm/proxy/proxy_server/test_routes_config.py @@ -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"}) diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index f6fd7d7caa2..c69e8104f09 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -11542,6 +11542,140 @@ 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_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 diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index fd882937e79..58f1b93e544 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -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; };