mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-19 00:01:29 +00:00
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:
parent
afa4a6fe78
commit
8e67a33fc3
10 changed files with 264 additions and 89 deletions
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
22
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
22
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue