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.
This commit is contained in:
Yuneng Jiang 2026-09-18 01:49:30 -07:00
parent afa4a6fe78
commit 8e67a33fc3
No known key found for this signature in database
10 changed files with 264 additions and 89 deletions

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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(

View file

@ -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}")

View file

@ -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"

View file

@ -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:

View file

@ -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"})

View file

@ -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

View file

@ -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;
};