Skip to content
Merged
7 changes: 7 additions & 0 deletions litellm/proxy/config_resolvers/settings_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@ def _build_dual_source_keys() -> Mapping[tuple[Section, str], KeyRule]:
DUAL_SOURCE_KEYS: Final[Mapping[tuple[Section, str], KeyRule]] = _build_dual_source_keys()


RESOURCE_LIST_KEYS: Final[frozenset[tuple[Section, str]]] = frozenset({("general_settings", "pass_through_endpoints")})


def is_resource_list(section: Section, key: str) -> bool:
return (section, key) in RESOURCE_LIST_KEYS


def rule_for(section: Section, key: str) -> KeyRule:
return DUAL_SOURCE_KEYS.get((section, key), DUAL_SOURCE_KEYS[(section, "*")])

Expand Down
17 changes: 11 additions & 6 deletions litellm/proxy/config_resolvers/settings_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
Resolved,
Section,
SettingValue,
is_resource_list,
resolve,
rule_for,
)
Expand Down Expand Up @@ -49,8 +50,13 @@ def __init__(self, section: Section) -> None:
self._deleted_runtime_keys: frozenset[str] = frozenset()

def load_yaml(self, mapping: Mapping[str, JsonValue]) -> None:
self._yaml_values = MappingProxyType(dict(mapping))
self._clear_runtime()
self._yaml_values = MappingProxyType(
{key: value for key, value in mapping.items() if not is_resource_list(self._section, key)}
)
self._runtime_values = MappingProxyType(
{key: value for key, value in self._runtime_values.items() if is_resource_list(self._section, key)}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Comment thread
veria-ai[bot] marked this conversation as resolved.
)
self._deleted_runtime_keys = frozenset()
Comment thread
cursor[bot] marked this conversation as resolved.

def config_value(self, key: str) -> JsonValue:
return self._yaml_values.get(key)
Expand Down Expand Up @@ -136,10 +142,6 @@ def __len__(self) -> int:
def __bool__(self) -> bool:
return any(True for _ in self)

def _clear_runtime(self) -> None:
self._runtime_values = _EMPTY_VALUES
self._deleted_runtime_keys = frozenset()

def _clear_runtime_keys(self, keys: frozenset[str]) -> None:
stale: Final = frozenset(key for key in keys if not self.owned_by_config(key))
if not stale:
Expand All @@ -160,6 +162,9 @@ def _keys(self) -> tuple[str, ...]:
)
)

def db_value(self, key: str) -> SettingValue:
return self._db_value(key) if is_resource_list(self._section, key) else ABSENT

def _db_value(self, key: str) -> SettingValue:
rule: Final = rule_for(self._section, key)
return self._database_rows.get(rule.db_row, _EMPTY_VALUES).get(key, ABSENT)
Expand Down
113 changes: 84 additions & 29 deletions litellm/proxy/proxy_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -500,9 +500,13 @@ def generate_feedback_box():
)
from litellm.proxy.config_resolvers.changed_section_keys import changed_section_keys
from litellm.proxy.config_resolvers.settings_rules import (
ABSENT,
DbRow,
Section,
SettingValue,
coerce_bool,
is_absent,
is_resource_list,
)
from litellm.proxy.config_resolvers.settings_rules import (
JsonValue as SettingsJsonValue,
Expand Down Expand Up @@ -5218,6 +5222,8 @@ def update_baseline(self, config: Mapping[str, object]) -> None:

_EMPTY_SETTINGS_MAPPING: Final[Mapping[str, SettingsJsonValue]] = MappingProxyType({})
_SETTINGS_MAPPING: Final = TypeAdapter(dict[str, SettingsJsonValue])
_SETTINGS_LIST: Final = TypeAdapter(list[SettingsJsonValue])
_ENDPOINT_DICTS: Final = TypeAdapter(list[dict[str, object]])


def _as_settings_mapping(value: object) -> Mapping[str, SettingsJsonValue]:
Expand All @@ -5232,6 +5238,40 @@ def _get_field_default(field_info: FieldInfo) -> JsonValue:
return cast(JsonValue, field_info.default) # cast-ok: Pydantic field defaults are JSON values at runtime


def _pass_through_endpoints_beside_db(db_endpoints: object, config_endpoints: object) -> list[SettingsJsonValue]:
stored: Final = db_endpoints if isinstance(db_endpoints, list) else ()
declared: Final = config_endpoints if isinstance(config_endpoints, list) else ()
db_paths: Final = frozenset(endpoint.get("path") for endpoint in stored if isinstance(endpoint, dict))
beside_db: Final = (
endpoint for endpoint in declared if not isinstance(endpoint, dict) or endpoint.get("path") not in db_paths
)
return _SETTINGS_LIST.validate_python((*stored, *beside_db))


def _with_config_file_pass_through_endpoints(
section_config: object, resolved: Mapping[str, SettingsJsonValue], db_endpoints: SettingValue
) -> Mapping[str, object]:
config_endpoints: Final = (
section_config.get("pass_through_endpoints") if isinstance(section_config, Mapping) else None
)
if config_endpoints is None and not isinstance(db_endpoints, list) and "pass_through_endpoints" not in resolved:
return resolved
return MappingProxyType(
{
**resolved,
"pass_through_endpoints": _pass_through_endpoints_beside_db(db_endpoints, config_endpoints),
}
)


def _reload_settings_store(section: Section, store: SettingsStore, section_config: object) -> None:
serving_pass_throughs: Final = store.get("pass_through_endpoints")
store.load_yaml(_as_settings_mapping(section_config))
store.apply_db_row(section, _EMPTY_SETTINGS_MAPPING)
if is_resource_list(section, "pass_through_endpoints") and serving_pass_throughs is not None:
store["pass_through_endpoints"] = serving_pass_throughs


def _bind_general_settings_store(settings: SettingsStore) -> None:
global general_settings
general_settings = settings # pyright: ignore[reportAssignmentType] # legacy global accepts mappings
Expand Down Expand Up @@ -5364,22 +5404,18 @@ def __init__(self) -> None:
)

def _load_yaml_settings_stores(self, config: Mapping[str, object]) -> None:
global config_passthrough_endpoints
for section, store in self._settings_stores.items():
store.load_yaml(_as_settings_mapping(config.get(section)))
store.apply_db_row(section, _EMPTY_SETTINGS_MAPPING)
yaml_endpoints: Final = self.settings.config_value("pass_through_endpoints")
config_passthrough_endpoints = (
[dict(endpoint) for endpoint in yaml_endpoints if isinstance(endpoint, dict)]
if isinstance(yaml_endpoints, list)
else None
)
_reload_settings_store(section, store, config.get(section))

def _config_with_resolved_settings(self, config: Mapping[str, object]) -> dict[str, object]:
return { # mutable-ok: get_config preserves the mutable mapping contract used by existing loaders
**config,
**{
section: dict(store.resolved())
section: dict(
_with_config_file_pass_through_endpoints(
config.get(section), store.resolved(), store.db_value("pass_through_endpoints")
)
)
for section, store in self._settings_stores.items()
if isinstance(config.get(section), Mapping) or len(store) > 0
},
Expand Down Expand Up @@ -6743,6 +6779,7 @@ async def load_config(self, router: litellm.Router | None, config_file_path: str

## pass through endpoints
if general_settings.get("pass_through_endpoints", None) is not None:
config_passthrough_endpoints = general_settings["pass_through_endpoints"]
Comment thread
greptile-apps[bot] marked this conversation as resolved.
await initialize_pass_through_endpoints(
pass_through_endpoints=general_settings["pass_through_endpoints"],
config_file_path=config_file_path,
Expand Down Expand Up @@ -7758,14 +7795,12 @@ async def _update_general_settings(self, db_general_settings: Mapping[str, Setti
self.settings.load_yaml(_as_settings_mapping(general_settings))
cache_size_was_db: Final = self.settings.source("user_api_key_cache_max_size") == "db"
previous_cleanup_schedule: Final = self._resolved_cleanup_schedule()
previous_pass_through_endpoints: Final = self.settings.get("pass_through_endpoints")
self.settings.apply_db_row("general_settings", db_general_settings)
_bind_general_settings_store(self.settings)
await self._apply_general_settings_side_effects(
db_general_settings,
cache_size_was_db,
previous_cleanup_schedule,
previous_pass_through_endpoints,
)

def _resolved_cleanup_schedule(self) -> tuple[object, ...]:
Expand All @@ -7779,11 +7814,10 @@ async def _apply_general_settings_side_effects(
db_values: Mapping[str, SettingsJsonValue],
cache_size_was_db: bool,
previous_cleanup_schedule: tuple[object, ...],
previous_pass_through_endpoints: SettingsJsonValue | None,
) -> None:
effects: Final = (
self._apply_alerting_settings,
partial(self._apply_pass_through_settings, previous_endpoints=previous_pass_through_endpoints),
self._apply_pass_through_settings,
self._apply_boolean_settings,
partial(self._apply_cache_size_setting, cache_size_was_db=cache_size_was_db),
self._apply_store_model_in_db_setting,
Expand Down Expand Up @@ -7816,19 +7850,23 @@ async def _apply_alerting_settings(self, db_values: Mapping[str, SettingsJsonVal
if "plugins" in db_values and self.settings.source("plugins") == "db":
register_plugins_from_config(self.settings)

async def _apply_pass_through_settings(
self,
db_values: Mapping[str, SettingsJsonValue],
previous_endpoints: SettingsJsonValue | None,
) -> None:
del db_values
resolved_endpoints: Final = self.settings.get("pass_through_endpoints")
if resolved_endpoints == previous_endpoints:
async def _apply_pass_through_settings(self, db_values: Mapping[str, SettingsJsonValue]) -> None:
db_endpoints: Final = db_values.get("pass_through_endpoints")
if isinstance(db_endpoints, list):
await self._serve_pass_through_endpoints(db_endpoints)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
return
await initialize_pass_through_endpoints(
pass_through_endpoints=resolved_endpoints if isinstance(resolved_endpoints, list) else []
if "pass_through_endpoints" not in self.settings:
self._publish_pass_through_endpoints(())

def _publish_pass_through_endpoints(self, db_endpoints: Sequence[SettingsJsonValue]) -> None:
self.settings["pass_through_endpoints"] = _pass_through_endpoints_beside_db(
list(db_endpoints), config_passthrough_endpoints
Comment thread
cursor[bot] marked this conversation as resolved.
)

async def _serve_pass_through_endpoints(self, db_endpoints: Sequence[SettingsJsonValue]) -> None:
self._publish_pass_through_endpoints(db_endpoints)
await initialize_pass_through_endpoints(pass_through_endpoints=_ENDPOINT_DICTS.validate_python(db_endpoints))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High: Pass-through overrides authorize the wrong upstream

For a YAML POST /shared targeting a private upstream (auth: true) and a DB replacement POST /shared targeting a public upstream (auth: false), the merged auth list permits anonymous requests, but initialize_pass_through_endpoints() still registers both entries and the first-match registry selects the YAML entry registered at startup. An unauthenticated attacker can now reach the private upstream with its configured credentials; apply the same DB-wins filtering to route registration and remove shadowed YAML registry entries before publishing the replacement authentication policy.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pre-existing: pre-#41779 and v1.102 behave the same way. This PR reverts to that handling to fix a regression, so fixing route registration is a follow-up


async def _apply_boolean_settings(self, db_values: Mapping[str, SettingsJsonValue]) -> None:
for key in (
"store_prompts_in_spend_logs",
Expand Down Expand Up @@ -18312,6 +18350,9 @@ async def update_config_general_settings(
)
await invalidate_config_param("general_settings")
proxy_config.settings.apply_db_row("general_settings", general_settings)
if is_resource_list("general_settings", data.field_name):
stored_endpoints: Final = general_settings.get("pass_through_endpoints")
await proxy_config._serve_pass_through_endpoints(stored_endpoints if isinstance(stored_endpoints, list) else ())
asyncio.create_task(
create_config_audit_log(
"general_settings", "updated", before_general_settings, general_settings, user_api_key_dict
Expand Down Expand Up @@ -18463,6 +18504,20 @@ def _apply_webhook_role_gate(webhook_map, is_full_admin: bool):
return {alert_type: "REDACTED" for alert_type in webhook_map}


async def _declared_general_setting(
settings: SettingsStore, field_name: str, prisma_client: PrismaClient
) -> SettingValue:
if is_resource_list("general_settings", field_name):
row: Final = await ConfigRepository(prisma_client, use_writer=True).table.find_first(
where={"param_name": "general_settings"}
)
stored: Final = row.param_value if row is not None and isinstance(row.param_value, Mapping) else {}
return stored.get(field_name, ABSENT) if stored.get(field_name) is not None else ABSENT
if field_name not in settings:
return ABSENT
return settings.config_value(field_name) if settings.owned_by_config(field_name) else settings[field_name]


@router.get(
"/config/field/info",
tags=["config.yaml"],
Expand Down Expand Up @@ -18501,15 +18556,12 @@ async def get_config_general_settings(
)

settings: Final = proxy_config.settings
if field_name not in settings:
declared: Final = await _declared_general_setting(settings, field_name, prisma_client)
if is_absent(declared):
raise HTTPException(
status_code=400,
detail={"error": f"Field name={field_name} is not set"},
)

declared: Final = (
settings.config_value(field_name) if settings.owned_by_config(field_name) else settings[field_name]
)
field_value = _redact_general_setting_value(
field_name,
declared,
Expand Down Expand Up @@ -18920,6 +18972,9 @@ async def delete_config_general_settings(
)
await invalidate_config_param("general_settings")
proxy_config.settings.apply_db_row("general_settings", general_settings)
if is_resource_list("general_settings", data.field_name):
stored_endpoints: Final = general_settings.get("pass_through_endpoints")
await proxy_config._serve_pass_through_endpoints(stored_endpoints if isinstance(stored_endpoints, list) else ())
asyncio.create_task(
create_config_audit_log(
"general_settings", "deleted", before_general_settings, general_settings, user_api_key_dict
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
Section,
SettingValue,
is_absent,
is_resource_list,
resolve,
rule_for,
)
Expand Down Expand Up @@ -88,7 +89,6 @@
"user_url_allowed_hosts",
"provider_url_destination_allowed_hosts",
"alerting",
"pass_through_endpoints",
)


Expand All @@ -105,8 +105,9 @@ def test_the_store_resolves_every_config_and_stored_value_combination(
section: Section, key: str, config_value: SettingValue, db_value: SettingValue
) -> None:
store: Final = _store_for(section, key, config_value, db_value)
owned_config_value: Final = ABSENT if is_resource_list(section, key) else config_value

if not is_absent(config_value):
if not is_absent(owned_config_value):
assert store[key] == config_value
assert store.source(key) == "config"
elif is_absent(db_value) or db_value is None:
Expand All @@ -121,7 +122,7 @@ def test_the_store_resolves_every_config_and_stored_value_combination(
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)
resolved: Final = resolve(ABSENT if is_resource_list(section, key) else config_value, db_value)
store: Final = _store_for(section, key, config_value, db_value)

assert store.source(key) == resolved.source
Expand Down
22 changes: 22 additions & 0 deletions tests/test_litellm/proxy/config_resolvers/test_settings_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,28 @@ async def test_load_config_returns_and_binds_the_general_settings_store(tmp_path
assert config_state["general_settings"]["max_file_size_mb"] == 5


def test_settings_store_leaves_pass_through_endpoints_to_the_database() -> None:
store: Final = SettingsStore("general_settings")
store.load_yaml({"pass_through_endpoints": [{"path": "/config"}]})
store.apply_db_row("general_settings", {"pass_through_endpoints": [{"path": "/db"}]})

assert store["pass_through_endpoints"] == [{"path": "/db"}]
assert store.source("pass_through_endpoints") == "db"
assert store.rejected_writes({"pass_through_endpoints": [{"path": "/ui"}]}) == ()


def test_settings_store_keeps_serving_pass_through_endpoints_while_the_config_file_reloads() -> None:
store: Final = SettingsStore("general_settings")
store.load_yaml({"pass_through_endpoints": [{"path": "/config"}], "max_parallel_requests": 1})
store["pass_through_endpoints"] = [{"path": "/config", "auth": False}]
store["allowed_ips"] = ["1.2.3.4"]

store.load_yaml({"pass_through_endpoints": [{"path": "/config"}], "max_parallel_requests": 1})

assert store["pass_through_endpoints"] == [{"path": "/config", "auth": False}]
assert "allowed_ips" not in store


def test_settings_store_starts_with_an_unset_source() -> None:
store: Final = SettingsStore("general_settings")

Expand Down
Loading
Loading