From 108f699294810c5dfd9fdb5e00ad9de8264716a1 Mon Sep 17 00:00:00 2001 From: mateo Date: Mon, 21 Sep 2026 23:11:58 +0000 Subject: [PATCH] test(proxy): cover frozen runtime config and section-based save_config Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../proxy/agent_endpoints/test_endpoints.py | 13 +- .../config_resolvers/test_settings_store.py | 4 +- .../scim/test_scim_v2_endpoints.py | 49 +-- .../test_search_tool_management.py | 13 +- .../test_coordination_redis_endpoints.py | 6 +- .../test_cost_tracking_settings.py | 14 +- .../test_model_management_endpoints.py | 5 +- .../test_router_settings_endpoints.py | 4 +- .../test_team_default_params.py | 9 +- .../proxy/proxy_server/test_proxy_config.py | 351 +++++++++++------- .../proxy/proxy_server/test_routes_config.py | 49 +-- .../proxy_server/test_routes_model_info.py | 9 +- .../test_team_model_name_translation.py | 5 +- tests/test_litellm/proxy/test__types.py | 49 +++ .../test_fallback_management_endpoints.py | 7 +- tests/test_litellm/proxy/test_proxy_cli.py | 17 +- tests/test_litellm/proxy/test_proxy_server.py | 49 +-- .../test_update_llm_router_resilience.py | 9 +- .../test_proxy_setting_endpoints.py | 51 +-- .../test_router_retry_policy_update.py | 3 +- 20 files changed, 428 insertions(+), 288 deletions(-) diff --git a/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py b/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py index 482294e7b92..52ea7634ac8 100644 --- a/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py +++ b/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py @@ -7,7 +7,7 @@ from fastapi import FastAPI from fastapi.testclient import TestClient from litellm.constants import REDACTED_BY_LITELM_STRING -from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth +from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth, ProxyRuntimeConfig from litellm.proxy.agent_endpoints import endpoints as agent_endpoints from litellm.proxy.agent_endpoints.auth.agent_permission_handler import ( RestrictedAgentAccess, @@ -1075,20 +1075,19 @@ class _DbBackedProxyConfig: def __init__(self, stored_litellm_settings: dict[str, object] | None = None) -> None: self.stored_litellm_settings_json: str = json.dumps(stored_litellm_settings or {}) - async def get_config(self) -> dict[str, dict[str, object]]: + async def get_config(self): from litellm.proxy.proxy_server import ProxyConfig - config: Final[dict[str, dict[str, object]]] = {"litellm_settings": {}} db_param_value: Final[dict[str, object]] = json.loads(self.stored_litellm_settings_json) if not db_param_value: - return config + return ProxyRuntimeConfig() proxy_config: Final = ProxyConfig() db_values: Final = proxy_config._prepared_db_settings_values("litellm_settings", db_param_value) proxy_config._apply_litellm_settings_db_values(db_values) - return {"litellm_settings": dict(proxy_config.litellm_settings.resolved())} + return ProxyRuntimeConfig.from_resolved({"litellm_settings": dict(proxy_config.litellm_settings.resolved())}) - async def save_config(self, new_config: dict[str, dict[str, object]]) -> None: - self.stored_litellm_settings_json = json.dumps(new_config.get("litellm_settings") or {}) + async def save_config(self, new_config: ProxyRuntimeConfig) -> None: + self.stored_litellm_settings_json = json.dumps(new_config.litellm_settings) def test_make_agent_public_twice_keeps_both_agents_public(monkeypatch: pytest.MonkeyPatch) -> None: 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 806b2d5e5aa..7fbb3d96129 100644 --- a/tests/test_litellm/proxy/config_resolvers/test_settings_store.py +++ b/tests/test_litellm/proxy/config_resolvers/test_settings_store.py @@ -298,8 +298,8 @@ async def test_load_config_returns_and_binds_the_general_settings_store(tmp_path assert returned_store is proxy_config.settings assert proxy_server.general_settings is proxy_config.settings - assert isinstance(config_state["general_settings"], dict) - assert config_state["general_settings"]["max_file_size_mb"] == 5 + assert isinstance(config_state.general_settings, dict) + assert config_state.general_settings["max_file_size_mb"] == 5 def test_settings_store_starts_with_an_unset_source() -> None: diff --git a/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py b/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py index dbcf622bbb1..2e5aa542942 100644 --- a/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py @@ -21,6 +21,7 @@ from litellm.proxy._types import ( NewUserResponse, ProxyErrorTypes, ProxyException, + ProxyRuntimeConfig, UserAPIKeyAuth, ) from litellm.proxy.management_endpoints.scim.scim_v2 import ( @@ -417,7 +418,7 @@ async def test_scim_create_user_respects_default_role_set_via_ui(mocker, monkeyp # Step 2: Simulate the UI saving "Internal User (Create/Delete/View)" as default role # Mock the proxy_config and store_model_in_db that _update_litellm_setting needs mock_proxy_config = mocker.MagicMock() - mock_proxy_config.get_config = AsyncMock(return_value={"litellm_settings": {}}) + mock_proxy_config.get_config = AsyncMock(return_value=ProxyRuntimeConfig.from_resolved({"litellm_settings": {}})) mock_proxy_config.save_config = AsyncMock() mocker.patch( @@ -1380,7 +1381,7 @@ async def test_update_user_without_groups_preserves_memberships_and_role(mocker, from litellm.proxy.proxy_server import proxy_config async def mock_get_config(): - return {"litellm_settings": {"scim_admin_group": "litellm-admins"}} + return ProxyRuntimeConfig.from_resolved({"litellm_settings": {"scim_admin_group": "litellm-admins"}}) monkeypatch.setattr(proxy_config, "get_config", mock_get_config) monkeypatch.setattr("litellm.default_internal_user_params", None, raising=False) @@ -1939,7 +1940,7 @@ async def test_create_group_with_nonexistent_users_rejects(mocker, monkeypatch): # Mock the feature flag to False (SCIM 2.0 strict mode) async def mock_get_config(): - return {"litellm_settings": {"scim_upsert_user": False}} + return ProxyRuntimeConfig.from_resolved({"litellm_settings": {"scim_upsert_user": False}}) from litellm.proxy.proxy_server import proxy_config @@ -2008,7 +2009,7 @@ async def test_update_group_with_nonexistent_users_rejects(mocker, monkeypatch): # Mock the feature flag to False (SCIM 2.0 strict mode) async def mock_get_config(): - return {"litellm_settings": {"scim_upsert_user": False}} + return ProxyRuntimeConfig.from_resolved({"litellm_settings": {"scim_upsert_user": False}}) from litellm.proxy.proxy_server import proxy_config @@ -2098,7 +2099,7 @@ async def test_create_group_with_nonexistent_users_creates_when_flag_true(mocker # Mock the feature flag to True (backward compatible mode) async def mock_get_config(): - return {"litellm_settings": {"scim_upsert_user": True}} + return ProxyRuntimeConfig.from_resolved({"litellm_settings": {"scim_upsert_user": True}}) from litellm.proxy.proxy_server import proxy_config @@ -2192,7 +2193,7 @@ async def test_extract_group_member_ids_with_flag_true_creates_users(mocker, mon # Mock the feature flag to True (backward compatible mode) async def mock_get_config(): - return {"litellm_settings": {"scim_upsert_user": True}} + return ProxyRuntimeConfig.from_resolved({"litellm_settings": {"scim_upsert_user": True}}) from litellm.proxy.proxy_server import proxy_config @@ -2261,7 +2262,7 @@ async def test_extract_group_member_ids_with_flag_false_rejects(mocker, monkeypa # Mock the feature flag to False (SCIM 2.0 strict mode) async def mock_get_config(): - return {"litellm_settings": {"scim_upsert_user": False}} + return ProxyRuntimeConfig.from_resolved({"litellm_settings": {"scim_upsert_user": False}}) from litellm.proxy.proxy_server import proxy_config @@ -2320,7 +2321,7 @@ async def test_process_group_patch_operations_with_flag_true_creates_users(mocke # Mock the feature flag to True (backward compatible mode) async def mock_get_config(): - return {"litellm_settings": {"scim_upsert_user": True}} + return ProxyRuntimeConfig.from_resolved({"litellm_settings": {"scim_upsert_user": True}}) from litellm.proxy.proxy_server import proxy_config @@ -2377,7 +2378,7 @@ async def test_process_group_patch_operations_with_flag_false_rejects(mocker, mo # Mock the feature flag to False (SCIM 2.0 strict mode) async def mock_get_config(): - return {"litellm_settings": {"scim_upsert_user": False}} + return ProxyRuntimeConfig.from_resolved({"litellm_settings": {"scim_upsert_user": False}}) from litellm.proxy.proxy_server import proxy_config @@ -2426,7 +2427,7 @@ async def test_create_user_grants_admin_when_in_scim_admin_group(mocker, monkeyp from litellm.proxy.proxy_server import proxy_config async def mock_get_config(): - return {"litellm_settings": {"scim_admin_group": "litellm-admins"}} + return ProxyRuntimeConfig.from_resolved({"litellm_settings": {"scim_admin_group": "litellm-admins"}}) monkeypatch.setattr(proxy_config, "get_config", mock_get_config) monkeypatch.setattr("litellm.default_internal_user_params", None, raising=False) @@ -2471,7 +2472,7 @@ async def test_create_user_keeps_default_when_not_in_scim_admin_group(mocker, mo from litellm.proxy.proxy_server import proxy_config async def mock_get_config(): - return {"litellm_settings": {"scim_admin_group": "litellm-admins"}} + return ProxyRuntimeConfig.from_resolved({"litellm_settings": {"scim_admin_group": "litellm-admins"}}) monkeypatch.setattr(proxy_config, "get_config", mock_get_config) monkeypatch.setattr("litellm.default_internal_user_params", None, raising=False) @@ -2517,7 +2518,7 @@ async def test_update_user_demotes_admin_when_removed_from_scim_admin_group(mock from litellm.proxy.proxy_server import proxy_config async def mock_get_config(): - return {"litellm_settings": {"scim_admin_group": "litellm-admins"}} + return ProxyRuntimeConfig.from_resolved({"litellm_settings": {"scim_admin_group": "litellm-admins"}}) monkeypatch.setattr(proxy_config, "get_config", mock_get_config) monkeypatch.setattr("litellm.default_internal_user_params", None, raising=False) @@ -2576,7 +2577,7 @@ async def test_update_user_does_not_force_role_when_scim_admin_group_unset(mocke from litellm.proxy.proxy_server import proxy_config async def mock_get_config(): - return {"litellm_settings": {}} + return ProxyRuntimeConfig.from_resolved({"litellm_settings": {}}) monkeypatch.setattr(proxy_config, "get_config", mock_get_config) monkeypatch.setattr("litellm.default_internal_user_params", None, raising=False) @@ -2636,7 +2637,7 @@ async def test_update_user_demotes_when_default_params_lack_user_role(mocker, mo from litellm.proxy.proxy_server import proxy_config async def mock_get_config(): - return {"litellm_settings": {"scim_admin_group": "litellm-admins"}} + return ProxyRuntimeConfig.from_resolved({"litellm_settings": {"scim_admin_group": "litellm-admins"}}) monkeypatch.setattr(proxy_config, "get_config", mock_get_config) monkeypatch.setattr("litellm.default_internal_user_params", {"max_budget": 10}, raising=False) @@ -2695,7 +2696,7 @@ async def test_patch_user_demotes_admin_when_removed_from_scim_admin_group(mocke from litellm.proxy.proxy_server import proxy_config async def mock_get_config(): - return {"litellm_settings": {"scim_admin_group": "litellm-admins"}} + return ProxyRuntimeConfig.from_resolved({"litellm_settings": {"scim_admin_group": "litellm-admins"}}) monkeypatch.setattr(proxy_config, "get_config", mock_get_config) monkeypatch.setattr("litellm.default_internal_user_params", None, raising=False) @@ -2762,7 +2763,7 @@ async def test_patch_user_grants_admin_by_team_display_name(mocker, monkeypatch) from litellm.proxy.proxy_server import proxy_config async def mock_get_config(): - return {"litellm_settings": {"scim_admin_group": "LiteLLM Admins"}} + return ProxyRuntimeConfig.from_resolved({"litellm_settings": {"scim_admin_group": "LiteLLM Admins"}}) monkeypatch.setattr(proxy_config, "get_config", mock_get_config) monkeypatch.setattr("litellm.default_internal_user_params", None, raising=False) @@ -2851,7 +2852,7 @@ async def test_recompute_scim_member_roles_demotes_when_not_in_admin_group(mocke from litellm.proxy.proxy_server import proxy_config async def mock_get_config(): - return {"litellm_settings": {"scim_admin_group": "litellm-admins"}} + return ProxyRuntimeConfig.from_resolved({"litellm_settings": {"scim_admin_group": "litellm-admins"}}) monkeypatch.setattr(proxy_config, "get_config", mock_get_config) monkeypatch.setattr("litellm.default_internal_user_params", None, raising=False) @@ -2871,7 +2872,7 @@ async def test_recompute_scim_member_roles_grants_when_in_admin_group(mocker, mo from litellm.proxy.proxy_server import proxy_config async def mock_get_config(): - return {"litellm_settings": {"scim_admin_group": "litellm-admins"}} + return ProxyRuntimeConfig.from_resolved({"litellm_settings": {"scim_admin_group": "litellm-admins"}}) monkeypatch.setattr(proxy_config, "get_config", mock_get_config) monkeypatch.setattr("litellm.default_internal_user_params", None, raising=False) @@ -2891,7 +2892,7 @@ async def test_recompute_scim_member_roles_noop_when_admin_group_unset(mocker, m from litellm.proxy.proxy_server import proxy_config async def mock_get_config(): - return {"litellm_settings": {}} + return ProxyRuntimeConfig.from_resolved({"litellm_settings": {}}) monkeypatch.setattr(proxy_config, "get_config", mock_get_config) @@ -3153,7 +3154,7 @@ async def test_create_user_existing_email_upsert_demotes_when_admin_group_set(mo from litellm.proxy.proxy_server import proxy_config async def mock_get_config(): - return {"litellm_settings": {"scim_admin_group": "litellm-admins"}} + return ProxyRuntimeConfig.from_resolved({"litellm_settings": {"scim_admin_group": "litellm-admins"}}) monkeypatch.setattr(proxy_config, "get_config", mock_get_config) monkeypatch.setattr("litellm.default_internal_user_params", None, raising=False) @@ -3379,7 +3380,7 @@ async def test_process_group_patch_operations_add_retains_existing_members(mocke """ async def mock_get_config(): - return {"litellm_settings": {"scim_upsert_user": True}} + return ProxyRuntimeConfig.from_resolved({"litellm_settings": {"scim_upsert_user": True}}) from litellm.proxy.proxy_server import proxy_config @@ -3417,7 +3418,7 @@ async def test_process_group_patch_operations_remove_uses_members_with_roles(moc member leaves the rest of the team intact rather than emptying it.""" async def mock_get_config(): - return {"litellm_settings": {"scim_upsert_user": True}} + return ProxyRuntimeConfig.from_resolved({"litellm_settings": {"scim_upsert_user": True}}) from litellm.proxy.proxy_server import proxy_config @@ -4072,7 +4073,7 @@ def scim_upsert_user_enabled(monkeypatch): from litellm.proxy.proxy_server import proxy_config async def mock_get_config(): - return {"litellm_settings": {"scim_upsert_user": True}} + return ProxyRuntimeConfig.from_resolved({"litellm_settings": {"scim_upsert_user": True}}) monkeypatch.setattr(proxy_config, "get_config", mock_get_config) @@ -4082,7 +4083,7 @@ def scim_upsert_user_disabled(monkeypatch): from litellm.proxy.proxy_server import proxy_config async def mock_get_config(): - return {"litellm_settings": {"scim_upsert_user": False}} + return ProxyRuntimeConfig.from_resolved({"litellm_settings": {"scim_upsert_user": False}}) monkeypatch.setattr(proxy_config, "get_config", mock_get_config) diff --git a/tests/test_litellm/proxy/management_endpoints/search_endpoints/test_search_tool_management.py b/tests/test_litellm/proxy/management_endpoints/search_endpoints/test_search_tool_management.py index 70e9a96b316..a30c6ca002e 100644 --- a/tests/test_litellm/proxy/management_endpoints/search_endpoints/test_search_tool_management.py +++ b/tests/test_litellm/proxy/management_endpoints/search_endpoints/test_search_tool_management.py @@ -11,6 +11,7 @@ from litellm.proxy._types import ( LiteLLM_ObjectPermissionTable, LiteLLM_TeamTable, LitellmUserRoles, + ProxyRuntimeConfig, UserAPIKeyAuth, ) @@ -51,7 +52,7 @@ async def test_list_search_tools_db_only(monkeypatch): with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma): # Mock proxy_config mock_proxy_config = MagicMock() - mock_proxy_config.get_config = AsyncMock(return_value={}) + mock_proxy_config.get_config = AsyncMock(return_value=ProxyRuntimeConfig.from_resolved({})) mock_proxy_config.parse_search_tools = MagicMock(return_value=None) with patch("litellm.proxy.proxy_server.proxy_config", mock_proxy_config): # Mock auth @@ -115,7 +116,7 @@ async def test_list_search_tools_config_only(monkeypatch): # Mock proxy_config mock_proxy_config = MagicMock() mock_proxy_config.get_config = AsyncMock( - return_value={"search_tools": config_tools} + return_value=ProxyRuntimeConfig.from_resolved({"search_tools": config_tools}) ) mock_proxy_config.parse_search_tools = MagicMock(return_value=config_tools) with patch("litellm.proxy.proxy_server.proxy_config", mock_proxy_config): @@ -191,7 +192,7 @@ async def test_list_search_tools_filters_duplicate_config_tools(monkeypatch): # Mock proxy_config mock_proxy_config = MagicMock() mock_proxy_config.get_config = AsyncMock( - return_value={"search_tools": config_tools} + return_value=ProxyRuntimeConfig.from_resolved({"search_tools": config_tools}) ) mock_proxy_config.parse_search_tools = MagicMock(return_value=config_tools) with patch("litellm.proxy.proxy_server.proxy_config", mock_proxy_config): @@ -301,7 +302,7 @@ async def test_list_search_tools_datetime_conversion(monkeypatch): with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma): # Mock proxy_config mock_proxy_config = MagicMock() - mock_proxy_config.get_config = AsyncMock(return_value={}) + mock_proxy_config.get_config = AsyncMock(return_value=ProxyRuntimeConfig.from_resolved({})) mock_proxy_config.parse_search_tools = MagicMock(return_value=None) with patch("litellm.proxy.proxy_server.proxy_config", mock_proxy_config): # Mock auth @@ -521,7 +522,7 @@ async def test_list_search_tools_db_masking_sensitive_values(monkeypatch): with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma): # Mock proxy_config mock_proxy_config = MagicMock() - mock_proxy_config.get_config = AsyncMock(return_value={}) + mock_proxy_config.get_config = AsyncMock(return_value=ProxyRuntimeConfig.from_resolved({})) mock_proxy_config.parse_search_tools = MagicMock(return_value=None) with patch("litellm.proxy.proxy_server.proxy_config", mock_proxy_config): # Mock auth @@ -654,7 +655,7 @@ def _mock_search_tool_backend(db_tools): mock_registry = MagicMock() mock_registry.get_all_search_tools_from_db = AsyncMock(return_value=db_tools) mock_proxy_config = MagicMock() - mock_proxy_config.get_config = AsyncMock(return_value={}) + mock_proxy_config.get_config = AsyncMock(return_value=ProxyRuntimeConfig.from_resolved({})) mock_proxy_config.parse_search_tools = MagicMock(return_value=None) with ( patch( diff --git a/tests/test_litellm/proxy/management_endpoints/test_coordination_redis_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_coordination_redis_endpoints.py index 7c6e8154107..c5d0e7d0225 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_coordination_redis_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_coordination_redis_endpoints.py @@ -13,7 +13,7 @@ from fastapi import HTTPException import litellm from litellm.caching.caching import RedisCache from litellm.caching.redis_cluster_cache import RedisClusterCache -from litellm.proxy._types import LitellmTableNames, LitellmUserRoles +from litellm.proxy._types import LitellmTableNames, LitellmUserRoles, ProxyRuntimeConfig from litellm.proxy.auth.user_api_key_auth import UserAPIKeyAuth from litellm.proxy.management_endpoints.coordination_redis_endpoints import ( _REDACTED_VALUE, @@ -59,7 +59,7 @@ def _prisma_with_general_settings(general_settings: dict | None) -> MagicMock: def _proxy_config(file_general_settings: dict | None = None) -> MagicMock: proxy_config = MagicMock() proxy_config.get_config_state = MagicMock( - return_value={"general_settings": file_general_settings or {}}, + return_value=ProxyRuntimeConfig.from_resolved({"general_settings": file_general_settings or {}}), ) return proxy_config @@ -624,7 +624,7 @@ def _real_proxy_config(file_general_settings: dict) -> "object": proxy_config = ProxyConfig() proxy_config._load_yaml_settings_stores({"general_settings": file_general_settings}) proxy_config.get_config_state = MagicMock( - return_value={"general_settings": file_general_settings} + return_value=ProxyRuntimeConfig.from_resolved({"general_settings": file_general_settings}) ) return proxy_config diff --git a/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py b/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py index 94d388fce60..763057db5b7 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py +++ b/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py @@ -14,7 +14,7 @@ from pydantic import ValidationError import litellm from litellm._internal_context import pinned_billing_time -from litellm.proxy._types import CostEstimateRequest +from litellm.proxy._types import CostEstimateRequest, ProxyRuntimeConfig from litellm.proxy.management_endpoints.cost_tracking_settings import router from litellm.proxy.proxy_server import app @@ -32,7 +32,7 @@ class TestCostTrackingSettings: # Mock the proxy_config to return a config with cost_discount_config mock_proxy_config = AsyncMock() mock_proxy_config.get_config = AsyncMock( - return_value={ + return_value=ProxyRuntimeConfig.from_resolved({ "litellm_settings": { "cost_discount_config": { "vertex_ai": 0.05, @@ -40,7 +40,7 @@ class TestCostTrackingSettings: "openai": 0.01, } } - } + }) ) mock_prisma_client = MagicMock() @@ -80,7 +80,7 @@ class TestCostTrackingSettings: """ # Mock the proxy_config to return a config without cost_discount_config mock_proxy_config = AsyncMock() - mock_proxy_config.get_config = AsyncMock(return_value={"litellm_settings": {}}) + mock_proxy_config.get_config = AsyncMock(return_value=ProxyRuntimeConfig.from_resolved({"litellm_settings": {}})) mock_prisma_client = MagicMock() @@ -114,7 +114,7 @@ class TestCostTrackingSettings: """ # Mock the proxy_config mock_proxy_config = AsyncMock() - mock_proxy_config.get_config = AsyncMock(return_value={"litellm_settings": {}}) + mock_proxy_config.get_config = AsyncMock(return_value=ProxyRuntimeConfig.from_resolved({"litellm_settings": {}})) mock_proxy_config.save_config = AsyncMock() mock_prisma_client = MagicMock() @@ -705,7 +705,7 @@ class TestBlockRequestsForModelsWithoutPricing: @pytest.mark.asyncio async def test_patch_persists_and_updates_flag(self): mock_proxy_config = AsyncMock() - mock_proxy_config.get_config = AsyncMock(return_value={"litellm_settings": {}}) + mock_proxy_config.get_config = AsyncMock(return_value=ProxyRuntimeConfig.from_resolved({"litellm_settings": {}})) mock_proxy_config.save_config = AsyncMock() with ( @@ -725,7 +725,7 @@ class TestBlockRequestsForModelsWithoutPricing: assert litellm.block_requests_for_models_without_pricing is True saved_config = mock_proxy_config.save_config.call_args.kwargs["new_config"] - assert saved_config["litellm_settings"]["block_requests_for_models_without_pricing"] is True + assert saved_config.litellm_settings["block_requests_for_models_without_pricing"] is True def test_peer_workers_pick_up_persisted_flag_on_config_reload(self): """A PATCH only mutates the flag on the worker that served it; peer workers must pick the diff --git a/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py index e6b5fb25c3e..1d7f44b2099 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py @@ -19,6 +19,7 @@ from litellm.proxy._types import ( LitellmUserRoles, Member, ProxyException, + ProxyRuntimeConfig, ReconcileOutcome, UserAPIKeyAuth, ) @@ -1385,7 +1386,7 @@ class TestUpdatePublicModelGroups: async def mock_get_config(*args, **kwargs): # This simulates _update_config_from_db calling setattr(litellm, "public_model_groups", old_value) litellm.public_model_groups = old_db_models - return {"litellm_settings": {"public_model_groups": old_db_models}} + return ProxyRuntimeConfig.from_resolved({"litellm_settings": {"public_model_groups": old_db_models}}) mock_proxy_config = MagicMock() mock_proxy_config.get_config = mock_get_config @@ -1443,7 +1444,7 @@ class TestUpdatePublicModelGroups: async def mock_get_config(*args, **kwargs): litellm.public_model_groups_links = old_links - return {"litellm_settings": {"public_model_groups_links": old_links}} + return ProxyRuntimeConfig.from_resolved({"litellm_settings": {"public_model_groups_links": old_links}}) mock_proxy_config = MagicMock() mock_proxy_config.get_config = mock_get_config diff --git a/tests/test_litellm/proxy/management_endpoints/test_router_settings_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_router_settings_endpoints.py index 308f4d88f02..bdfaa00b5e5 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_router_settings_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_router_settings_endpoints.py @@ -11,7 +11,7 @@ from fastapi.testclient import TestClient from litellm.proxy import proxy_server -from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth +from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth, ProxyRuntimeConfig from litellm.proxy.management_endpoints.router_settings_endpoints import ( get_router_settings, ) @@ -104,7 +104,7 @@ class TestRouterSettingsEndpoints: monkeypatch.setattr(proxy_server, "llm_router", llm_router) async def fake_get_config(self, config_file_path=None): - return {} + return ProxyRuntimeConfig.from_resolved({}) monkeypatch.setattr( proxy_server.ProxyConfig, "get_config", fake_get_config, raising=True diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_default_params.py b/tests/test_litellm/proxy/management_endpoints/test_team_default_params.py index 17cb30dd07d..2f0b3881a4b 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_default_params.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_default_params.py @@ -13,10 +13,11 @@ import litellm from litellm.proxy._types import ( LiteLLM_BudgetTable, LiteLLM_OrganizationTable, + LitellmUserRoles, NewTeamRequest, ProxyException, + ProxyRuntimeConfig, UserAPIKeyAuth, - LitellmUserRoles, ) from litellm.proxy.management_endpoints.team_endpoints import ( _get_default_team_param, @@ -543,13 +544,11 @@ class TestUpdateLitellmSettingOrdering: async def mock_get_config(): # Simulate what _update_config_from_db does for safe overrides litellm.default_team_params = stale_value - return { + return ProxyRuntimeConfig.from_resolved({ "litellm_settings": { "default_team_params": stale_value, } - } - - saved_configs = [] + }) async def mock_save_config(new_config=None): saved_configs.append(new_config) diff --git a/tests/test_litellm/proxy/proxy_server/test_proxy_config.py b/tests/test_litellm/proxy/proxy_server/test_proxy_config.py index 462489f48b0..9dae8ac6644 100644 --- a/tests/test_litellm/proxy/proxy_server/test_proxy_config.py +++ b/tests/test_litellm/proxy/proxy_server/test_proxy_config.py @@ -23,7 +23,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest import litellm -from litellm.proxy._types import CommonProxyErrors +from litellm.proxy._types import CommonProxyErrors, ProxyRuntimeConfig from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_value_helper from litellm.proxy.proxy_server import ( ProxyConfig, @@ -641,7 +641,7 @@ def test_ProxyConfig___init___sets_defaults(): "worker_registry": pc.worker_registry, } assert snapshot == { - "config": {}, + "config": ProxyRuntimeConfig(), "last_semantic_filter_config": None, "worker_registry": [], } @@ -943,11 +943,10 @@ async def test_ProxyConfig_save_config_merges_changed_keys_without_copying_file_ "router_settings": {"num_retries": 1}, "litellm_settings": {"drop_params": True}, } - proxy_config.update_config_state(config=baseline) - changed: Final = { - **baseline, - "general_settings": {**baseline["general_settings"], "allowed_ips": ["127.0.0.1"]}, - } + proxy_config.update_config_state(config=ProxyRuntimeConfig.from_resolved(baseline)) + changed: Final = proxy_config.get_config_state().with_section( + "general_settings", {**baseline["general_settings"], "allowed_ips": ["127.0.0.1"]} + ) await proxy_config.save_config(changed) @@ -964,9 +963,9 @@ async def test_ProxyConfig_save_config_skips_unchanged_config(monkeypatch): "router_settings": {"num_retries": 1}, "litellm_settings": {"drop_params": True}, } - proxy_config.update_config_state(config=baseline) + proxy_config.update_config_state(config=ProxyRuntimeConfig.from_resolved(baseline)) - await proxy_config.save_config(baseline) + await proxy_config.save_config(proxy_config.get_config_state()) assert table.rows == {"general_settings": {"db_only": "stored"}} assert table.upserted_param_names == [] @@ -975,10 +974,10 @@ async def test_ProxyConfig_save_config_skips_unchanged_config(monkeypatch): @pytest.mark.asyncio async def test_ProxyConfig_save_config_skips_unchanged_unmanaged_values(monkeypatch): proxy_config, table = _db_backed_proxy_config(monkeypatch, {}) - baseline: Final = {"general_settings": {}, "guardrails": {"enabled": True}} - proxy_config.update_config_state(config=baseline) + baseline: Final = {"general_settings": {}, "guardrails": [{"enabled": True}]} + proxy_config.update_config_state(config=ProxyRuntimeConfig.from_resolved(baseline)) - await proxy_config.save_config(baseline) + await proxy_config.save_config(proxy_config.get_config_state()) assert table.rows == {} assert table.upserted_param_names == [] @@ -991,10 +990,14 @@ async def test_ProxyConfig_save_config_leaves_omitted_sections_unchanged(monkeyp {"general_settings": {"allowed_ips": ["10.0.0.1"], "db_only": "stored"}}, ) proxy_config.update_config_state( - config={"general_settings": {"allowed_ips": ["10.0.0.1"]}, "router_settings": {"num_retries": 1}} + config=ProxyRuntimeConfig.from_resolved( + {"general_settings": {"allowed_ips": ["10.0.0.1"]}, "router_settings": {"num_retries": 1}} + ) ) - await proxy_config.save_config({"router_settings": {"num_retries": 2}}) + await proxy_config.save_config( + proxy_config.get_config_state().with_section("router_settings", {"num_retries": 2}) + ) assert table.rows == { "general_settings": {"allowed_ips": ["10.0.0.1"], "db_only": "stored"}, @@ -1006,9 +1009,13 @@ async def test_ProxyConfig_save_config_leaves_omitted_sections_unchanged(monkeyp @pytest.mark.asyncio async def test_ProxyConfig_save_config_decodes_a_serialized_config_row(monkeypatch): proxy_config, table = _db_backed_proxy_config(monkeypatch, {"general_settings": '{"db_only":"stored"}'}) - proxy_config.update_config_state(config={"general_settings": {"allowed_ips": []}}) + proxy_config.update_config_state( + config=ProxyRuntimeConfig.from_resolved({"general_settings": {"allowed_ips": []}}) + ) - await proxy_config.save_config({"general_settings": {"allowed_ips": ["127.0.0.1"]}}) + await proxy_config.save_config( + proxy_config.get_config_state().with_section("general_settings", {"allowed_ips": ["127.0.0.1"]}) + ) assert table.rows == {"general_settings": {"db_only": "stored", "allowed_ips": ["127.0.0.1"]}} assert table.upserted_param_names == ["general_settings"] @@ -1018,13 +1025,13 @@ async def test_ProxyConfig_save_config_decodes_a_serialized_config_row(monkeypat async def test_ProxyConfig_save_config_serializes_concurrent_changes_to_one_section(monkeypatch): first, table = _db_backed_proxy_config(monkeypatch, {"general_settings": {"a": 0, "b": 0}}) second: Final = ProxyConfig() - baseline: Final = {"general_settings": {"a": 0, "b": 0}} - first.update_config_state(config=baseline) - second.update_config_state(config=baseline) + baseline: Final = ProxyRuntimeConfig.from_resolved({"general_settings": {"a": 0, "b": 0}}) + first.update_config_state(config=ProxyRuntimeConfig.from_resolved(baseline)) + second.update_config_state(config=ProxyRuntimeConfig.from_resolved(baseline)) await asyncio.gather( - first.save_config({"general_settings": {"a": 1, "b": 0}}), - second.save_config({"general_settings": {"a": 0, "b": 1}}), + first.save_config(first.get_config_state().with_section("general_settings", {"a": 1, "b": 0})), + second.save_config(second.get_config_state().with_section("general_settings", {"a": 0, "b": 1})), ) assert table.rows == {"general_settings": {"a": 1, "b": 1}} @@ -1033,10 +1040,14 @@ async def test_ProxyConfig_save_config_serializes_concurrent_changes_to_one_sect @pytest.mark.asyncio async def test_ProxyConfig_save_config_updates_the_baseline_after_a_save(monkeypatch): proxy_config, table = _db_backed_proxy_config(monkeypatch, {}) - proxy_config.update_config_state(config={"general_settings": {}}) + proxy_config.update_config_state( + config=ProxyRuntimeConfig.from_resolved({"general_settings": {}}) + ) - await proxy_config.save_config({"general_settings": {"removed_key": True}}) - await proxy_config.save_config({"general_settings": {}}) + await proxy_config.save_config( + proxy_config.get_config_state().with_section("general_settings", {"removed_key": True}) + ) + await proxy_config.save_config(proxy_config.get_config_state().with_section("general_settings", {})) assert table.rows == {"general_settings": {}} @@ -1045,11 +1056,15 @@ async def test_ProxyConfig_save_config_updates_the_baseline_after_a_save(monkeyp async def test_ProxyConfig_save_config_keeps_omitted_sections_in_its_next_baseline(monkeypatch): proxy_config, table = _db_backed_proxy_config(monkeypatch, {"general_settings": {"allowed_ips": ["10.0.0.1"]}}) proxy_config.update_config_state( - config={"general_settings": {"allowed_ips": ["10.0.0.1"]}, "router_settings": {"num_retries": 1}} + config=ProxyRuntimeConfig.from_resolved( + {"general_settings": {"allowed_ips": ["10.0.0.1"]}, "router_settings": {"num_retries": 1}} + ) ) - await proxy_config.save_config({"router_settings": {"num_retries": 2}}) - await proxy_config.save_config({"general_settings": {}}) + await proxy_config.save_config( + proxy_config.get_config_state().with_section("router_settings", {"num_retries": 2}) + ) + await proxy_config.save_config(proxy_config.get_config_state().with_section("general_settings", {})) assert table.rows == {"general_settings": {}, "router_settings": {"num_retries": 2}} @@ -1068,8 +1083,8 @@ async def test_ProxyConfig_save_config_uses_the_baseline_from_the_loaded_config( monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True) monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {"store_model_in_db": True}) monkeypatch.setattr("litellm.proxy.proxy_server.invalidate_config_param", AsyncMock()) - first["general_settings"]["first"] = True - second["general_settings"]["second"] = True + first = first.with_section("general_settings", {**first.general_settings, "first": True}) + second = second.with_section("general_settings", {**second.general_settings, "second": True}) await proxy_config.save_config(second) await proxy_config.save_config(first) @@ -1080,11 +1095,17 @@ async def test_ProxyConfig_save_config_uses_the_baseline_from_the_loaded_config( @pytest.mark.asyncio async def test_ProxyConfig_save_config_accepts_non_json_model_metadata(monkeypatch): proxy_config, table = _db_backed_proxy_config(monkeypatch, {}) - proxy_config.update_config_state(config={"general_settings": {"allowed_ips": []}}) - config: Final = { - "model_list": [{"model_name": "date-model", "model_info": {"created_at": datetime(2026, 1, 1)}}], - "general_settings": {"allowed_ips": ["127.0.0.1"]}, - } + proxy_config.update_config_state( + config=ProxyRuntimeConfig.from_resolved({"general_settings": {"allowed_ips": []}}) + ) + config: Final = ( + proxy_config.get_config_state() + .with_section( + "model_list", + ({"model_name": "date-model", "model_info": {"created_at": datetime(2026, 1, 1)}},), + ) + .with_section("general_settings", {"allowed_ips": ["127.0.0.1"]}) + ) await proxy_config.save_config(config) @@ -1100,8 +1121,8 @@ async def test_ProxyConfig_save_config_writes_only_changed_router_settings(monke "router_settings": {"num_retries": 1}, "litellm_settings": {"drop_params": True}, } - proxy_config.update_config_state(config=baseline) - changed: Final = {**baseline, "router_settings": {"num_retries": 2}} + proxy_config.update_config_state(config=ProxyRuntimeConfig.from_resolved(baseline)) + changed: Final = proxy_config.get_config_state().with_section("router_settings", {"num_retries": 2}) await proxy_config.save_config(changed) @@ -1115,8 +1136,10 @@ async def test_ProxyConfig_save_config_removes_a_key_only_when_the_db_has_it(mon monkeypatch, {"general_settings": {"removed_key": "db", "db_only": "stored"}} ) baseline: Final = {"general_settings": {"removed_key": "yaml", "file_only": "yaml"}} - proxy_config.update_config_state(config=baseline) - changed: Final = {"general_settings": {"file_only": "yaml"}} + proxy_config.update_config_state(config=ProxyRuntimeConfig.from_resolved(baseline)) + changed: Final = proxy_config.get_config_state().with_section( + "general_settings", {"file_only": "yaml"} + ) await proxy_config.save_config(changed) @@ -1128,9 +1151,9 @@ async def test_ProxyConfig_save_config_removes_a_key_only_when_the_db_has_it(mon async def test_ProxyConfig_save_config_keeps_an_unstored_removed_key_as_a_noop(monkeypatch): proxy_config, table = _db_backed_proxy_config(monkeypatch, {"general_settings": {"db_only": "stored"}}) baseline: Final = {"general_settings": {"file_only": "yaml"}} - proxy_config.update_config_state(config=baseline) + proxy_config.update_config_state(config=ProxyRuntimeConfig.from_resolved(baseline)) - await proxy_config.save_config({"general_settings": {}}) + await proxy_config.save_config(proxy_config.get_config_state().with_section("general_settings", {})) assert table.rows == {"general_settings": {"db_only": "stored"}} assert table.upserted_param_names == [] @@ -1146,18 +1169,19 @@ async def test_ProxyConfig_get_config_keeps_state_separate_from_returned_config( proxy_config: Final = ProxyConfig() loaded: Final = await proxy_config.get_config(config_file_path=str(config_file)) - loaded["general_settings"]["max_parallel_requests"] = 6 + mutated: Final = loaded.with_section("general_settings", {"max_parallel_requests": 6}) - assert proxy_config.get_config_state()["general_settings"]["max_parallel_requests"] == 5 + assert proxy_config.get_config_state().general_settings["max_parallel_requests"] == 5 + assert mutated.general_settings["max_parallel_requests"] == 6 def test_ProxyConfig_update_config_state_keeps_a_copy_of_its_input(): source: Final = {"general_settings": {"max_parallel_requests": 5}} proxy_config: Final = ProxyConfig() - proxy_config.update_config_state(config=source) + proxy_config.update_config_state(config=ProxyRuntimeConfig.from_resolved(source)) source["general_settings"]["max_parallel_requests"] = 6 - assert proxy_config.get_config_state()["general_settings"]["max_parallel_requests"] == 5 + assert proxy_config.get_config_state().general_settings["max_parallel_requests"] == 5 @pytest.mark.asyncio @@ -1169,7 +1193,7 @@ async def test_ProxyConfig_save_config_writes_yaml_when_no_db(tmp_path, monkeypa monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {}) pc = ProxyConfig() cfg = {"model_list": [], "general_settings": {"a": 1}, "litellm_settings": {}} - await pc.save_config(cfg) + await pc.save_config(ProxyRuntimeConfig.from_resolved(cfg)) import yaml as _yaml loaded = _yaml.safe_load(target.read_text()) @@ -1186,7 +1210,7 @@ async def test_ProxyConfig_save_config_writes_a_loadable_yaml_for_a_loaded_confi monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {}) proxy_config: Final = ProxyConfig() loaded_config: Final = await proxy_config.get_config(config_file_path=str(config_file)) - loaded_config["general_settings"]["max_parallel_requests"] = 6 + loaded_config = loaded_config.with_section("general_settings", {"max_parallel_requests": 6}) await proxy_config.save_config(loaded_config) @@ -1206,33 +1230,38 @@ async def test_ProxyConfig_save_config_invalid_path_raises(monkeypatch): monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {}) pc = ProxyConfig() with pytest.raises(FileNotFoundError): - await pc.save_config({"x": 1}) + await pc.save_config(ProxyRuntimeConfig.from_resolved({"x": 1})) @pytest.mark.asyncio async def test_ProxyConfig_save_config_db_omits_environment_variables_by_default(monkeypatch): proxy_config, table = _db_backed_proxy_config(monkeypatch, {}) baseline: Final = {"model_list": [], "litellm_settings": {}} - proxy_config.update_config_state(config=baseline) - config: Final = { - "model_list": [{"model_name": "gpt-4o"}], - "litellm_settings": {"success_callback": ["langfuse"]}, - "environment_variables": {"OPENAI_API_KEY": "sk-from-yaml"}, - } + proxy_config.update_config_state(config=ProxyRuntimeConfig.from_resolved(baseline)) + config: Final = ( + proxy_config.get_config_state() + .with_section("model_list", ({"model_name": "gpt-4o"},)) + .with_section("litellm_settings", {"success_callback": ["langfuse"]}) + .with_section("environment_variables", {"OPENAI_API_KEY": "sk-from-yaml"}) + ) await proxy_config.save_config(config) assert table.rows == {"litellm_settings": {"success_callback": ["langfuse"]}} assert table.upserted_param_names == ["litellm_settings"] - assert config["environment_variables"] == {"OPENAI_API_KEY": "sk-from-yaml"} + assert config.environment_variables == {"OPENAI_API_KEY": "sk-from-yaml"} @pytest.mark.asyncio async def test_ProxyConfig_save_config_db_persists_environment_variables_when_opted_in(monkeypatch): proxy_config, table = _db_backed_proxy_config(monkeypatch, {}) - proxy_config.update_config_state(config={"litellm_settings": {}}) + proxy_config.update_config_state( + config=ProxyRuntimeConfig.from_resolved({"litellm_settings": {}}) + ) monkeypatch.setattr("litellm.proxy.proxy_server.master_key", "sk-test-salt-key") - config: Final = {"litellm_settings": {}, "environment_variables": {"OPENAI_API_KEY": "sk-explicit"}} + config: Final = proxy_config.get_config_state().with_section( + "environment_variables", {"OPENAI_API_KEY": "sk-explicit"} + ) await proxy_config.save_config(config, include_env_vars=True) @@ -1244,10 +1273,12 @@ async def test_ProxyConfig_save_config_db_persists_environment_variables_when_op @pytest.mark.asyncio async def test_ProxyConfig_save_config_persists_unchanged_environment_variables_when_opted_in(monkeypatch): proxy_config, table = _db_backed_proxy_config(monkeypatch, {}) - config: Final = { - "litellm_settings": {}, - "environment_variables": {"OPENAI_API_KEY": "sk-explicit"}, - } + proxy_config.update_config_state( + config=ProxyRuntimeConfig.from_resolved( + {"litellm_settings": {}, "environment_variables": {"OPENAI_API_KEY": "sk-explicit"}} + ) + ) + config: Final = proxy_config.get_config_state() monkeypatch.setattr("litellm.proxy.proxy_server.master_key", "sk-test-salt-key") await proxy_config.save_config(config) @@ -1377,25 +1408,25 @@ def test_ProxyConfig__get_team_config_missing_team_id_raises(): def test_ProxyConfig_load_team_config_returns_team_dict(): pc = ProxyConfig() - pc.config = { + pc.config = ProxyRuntimeConfig.from_resolved({ "litellm_settings": { "default_team_settings": [ {"team_id": "ta", "max_budget": 99, "drop_params": True}, ] } - } + }) out = pc.load_team_config(team_id="ta") assert out == {"team_id": "ta", "max_budget": 99, "drop_params": True} def test_ProxyConfig_load_team_config_no_settings_returns_empty(): pc = ProxyConfig() - pc.config = {"litellm_settings": {}} + pc.config = ProxyRuntimeConfig.from_resolved({"litellm_settings": {}}) # Missing entry — happy path returns {} (no default_team_settings). out = pc.load_team_config(team_id="missing") assert out == {} # Error-style: a misconfigured team list without team_id raises. - pc.config = {"litellm_settings": {"default_team_settings": [{"no_id": True}]}} + pc.config = ProxyRuntimeConfig.from_resolved({"litellm_settings": {"default_team_settings": [{"no_id": True}]}}) with pytest.raises(Exception, match="team_id missing from team"): pc.load_team_config(team_id="anything") @@ -1477,11 +1508,9 @@ async def test_ProxyConfig_get_config_loads_from_file(tmp_path, monkeypatch): monkeypatch.delenv("LITELLM_CONFIG_BUCKET_NAME", raising=False) pc = ProxyConfig() cfg = await pc.get_config(config_file_path=str(f)) - assert cfg == { - "model_list": [], - "general_settings": {}, - "litellm_settings": {}, - } + assert cfg.model_list == () + assert cfg.general_settings == {} + assert cfg.litellm_settings == {} @pytest.mark.asyncio @@ -1505,8 +1534,8 @@ async def test_ProxyConfig_get_config_from_a_bucket_merges_includes(monkeypatch) cfg = await ProxyConfig().get_config() - assert cfg["model_list"] == [{"model_name": "included-model"}] - assert "include" not in cfg + assert [dict(model) for model in cfg.model_list] == [{"model_name": "included-model"}] + assert "include" not in (cfg.model_extra or {}) @pytest.mark.asyncio @@ -1597,8 +1626,8 @@ async def test_ProxyConfig_get_config_resolves_keys_held_only_by_the_secret_mana cfg = await ProxyConfig().get_config(config_file_path=config_file_path) assert { - "master_key": cfg["general_settings"]["master_key"], - "api_key": cfg["model_list"][0]["litellm_params"]["api_key"], + "master_key": cfg.general_settings["master_key"], + "api_key": cfg.model_list[0]["litellm_params"]["api_key"], "hosted_keys": litellm._key_management_settings.hosted_keys, } == { "master_key": "master-from-vault", @@ -1640,7 +1669,7 @@ async def test_ProxyConfig_get_config_reuses_an_already_initialized_secret_manag assert { "client_reused": litellm.secret_manager_client is first_client, - "master_key": second["general_settings"]["master_key"], + "master_key": second.general_settings["master_key"], } == {"client_reused": True, "master_key": "master-from-vault"} @@ -1656,8 +1685,8 @@ async def test_ProxyConfig_get_config_without_key_management_system_leaves_secre cfg = await ProxyConfig().get_config(config_file_path=config_file_path) assert { - "master_key": cfg["general_settings"]["master_key"], - "api_key": cfg["model_list"][0]["litellm_params"]["api_key"], + "master_key": cfg.general_settings["master_key"], + "api_key": cfg.model_list[0]["litellm_params"]["api_key"], "client": litellm.secret_manager_client, "warned_about": [call.args[1] for call in warn.call_args_list], } == {"master_key": None, "api_key": None, "client": None, "warned_about": []} @@ -1674,7 +1703,7 @@ async def test_ProxyConfig_get_config_warns_when_a_reference_is_missing_from_the cfg = await ProxyConfig().get_config(config_file_path=config_file_path) assert { - "api_key": cfg["model_list"][0]["litellm_params"]["api_key"], + "api_key": cfg.model_list[0]["litellm_params"]["api_key"], "warned_about": [call.args[1] for call in warn.call_args_list], } == {"api_key": None, "warned_about": ["os.environ/NOT_IN_VAULT"]} @@ -1694,7 +1723,7 @@ async def test_ProxyConfig_get_config_does_not_warn_for_a_name_outside_hosted_ke cfg = await ProxyConfig().get_config(config_file_path=config_file_path) assert { - "api_key": cfg["model_list"][0]["litellm_params"]["api_key"], + "api_key": cfg.model_list[0]["litellm_params"]["api_key"], "client_is_up": litellm.secret_manager_client is not None, "warned_about": [call.args[1] for call in warn.call_args_list], } == {"api_key": None, "client_is_up": True, "warned_about": []} @@ -1717,7 +1746,7 @@ async def test_ProxyConfig_get_config_does_not_warn_under_write_only_access_mode cfg = await ProxyConfig().get_config(config_file_path=config_file_path) assert { - "master_key": cfg["general_settings"]["master_key"], + "master_key": cfg.general_settings["master_key"], "client_is_up": litellm.secret_manager_client is not None, "warned_about": [call.args[1] for call in warn.call_args_list], } == {"master_key": None, "client_is_up": True, "warned_about": []} @@ -1730,13 +1759,13 @@ async def test_ProxyConfig_get_config_does_not_warn_under_write_only_access_mode def test_ProxyConfig_update_config_state_and_get_config_state_roundtrip(): pc = ProxyConfig() - cfg = {"model_list": [], "general_settings": {"x": 1}, "litellm_settings": {}} - pc.update_config_state(config=cfg) + cfg = ProxyRuntimeConfig.from_resolved({"model_list": [], "general_settings": {"x": 1}, "litellm_settings": {}}) + pc.update_config_state(config=ProxyRuntimeConfig.from_resolved(cfg)) out = pc.get_config_state() assert out == cfg - # Mutating the returned dict must not affect internal state. - out["model_list"].append({"new": True}) - assert pc.get_config_state() == cfg + # The returned model is frozen, so it cannot be mutated to corrupt internal state. + with pytest.raises(ValidationError): + out.model_list = () def test_ProxyConfig_update_config_state_with_bad_arg_raises(): @@ -1745,17 +1774,12 @@ def test_ProxyConfig_update_config_state_with_bad_arg_raises(): pc.update_config_state() # type: ignore[call-arg] -def test_ProxyConfig_get_config_state_handles_undeepcopyable(monkeypatch): - # Pins ProxyConfig.get_config_state — see source for behavior. +def test_ProxyConfig_get_config_state_returns_the_stored_model(monkeypatch): + # get_config_state returns the stored model as is (no copy needed: it is frozen). pc = ProxyConfig() - - class NoCopy: - def __deepcopy__(self, memo): - raise RuntimeError("nope") - - pc.config = {"x": NoCopy()} # type: ignore[assignment] - # Exception is caught internally and an empty dict returned. - assert pc.get_config_state() == {} + cfg = ProxyRuntimeConfig.from_resolved({"x": 1}) + pc.config = cfg + assert pc.get_config_state() is cfg # --------------------------------------------------------------------------- @@ -1766,15 +1790,17 @@ def test_ProxyConfig_get_config_state_handles_undeepcopyable(monkeypatch): def test_ProxyConfig_load_credential_list_returns_items(): pc = ProxyConfig() creds = pc.load_credential_list( - { - "credential_list": [ + ProxyRuntimeConfig.from_resolved( + { + "credential_list": [ { "credential_name": "openai-key", "credential_info": {"provider": "openai"}, "credential_values": {"api_key": "sk-x"}, } ] - } + } + ) ) assert len(creds) == 1 dumped = creds[0].model_dump() @@ -1788,7 +1814,9 @@ def test_ProxyConfig_load_credential_list_returns_items(): def test_ProxyConfig_load_credential_list_invalid_entry_raises(): pc = ProxyConfig() with pytest.raises(ValidationError): - pc.load_credential_list({"credential_list": [{"missing_required": True}]}) + pc.load_credential_list( + ProxyRuntimeConfig.from_resolved({"credential_list": [{"missing_required": True}]}) + ) # --------------------------------------------------------------------------- @@ -1798,14 +1826,16 @@ def test_ProxyConfig_load_credential_list_invalid_entry_raises(): def test_ProxyConfig_parse_search_tools_returns_parsed(): pc = ProxyConfig() - cfg = { - "search_tools": [ - { - "search_tool_name": "web", - "litellm_params": {"search_provider": "google"}, - } - ] - } + cfg = ProxyRuntimeConfig.from_resolved( + { + "search_tools": [ + { + "search_tool_name": "web", + "litellm_params": {"search_provider": "google"}, + } + ] + } + ) out = pc.parse_search_tools(cfg) assert out is not None assert len(out) == 1 @@ -1817,7 +1847,7 @@ def test_ProxyConfig_parse_search_tools_returns_parsed(): def test_ProxyConfig_parse_search_tools_missing_returns_none(): pc = ProxyConfig() - assert pc.parse_search_tools({}) is None + assert pc.parse_search_tools(ProxyRuntimeConfig()) is None def test_ProxyConfig_merge_config_and_db_search_tools_returns_superset(): @@ -1884,7 +1914,7 @@ async def test_ProxyConfig__init_search_tools_in_db_loads_merged_tools(monkeypat pc = ProxyConfig() pc.update_config_state( - { + ProxyRuntimeConfig.from_resolved({ "search_tools": [ { "search_tool_name": "shared-search", @@ -1895,7 +1925,7 @@ async def test_ProxyConfig__init_search_tools_in_db_loads_merged_tools(monkeypat "litellm_params": {"search_provider": "perplexity"}, }, ] - } + }) ) db_tools = [ { @@ -1936,7 +1966,7 @@ async def test_ProxyConfig__init_search_tools_in_db_clears_router_when_last_tool from litellm.proxy import proxy_server pc = ProxyConfig() - pc.update_config_state({}) + pc.update_config_state(ProxyRuntimeConfig.from_resolved({})) fake_router = MagicMock() fake_router.search_tools = [{"search_tool_name": "deleted-search", "litellm_params": {}}] mock_get_db_tools = AsyncMock(return_value=[]) @@ -1990,7 +2020,7 @@ async def test_ProxyConfig_reload_search_tools_from_db_serializes_overlapping_re from litellm.proxy import proxy_server pc = ProxyConfig() - pc.update_config_state({}) + pc.update_config_state(ProxyRuntimeConfig.from_resolved({})) fake_router = MagicMock() fake_router.search_tools = [] @@ -2049,7 +2079,7 @@ async def test_ProxyConfig_reload_search_tools_from_db_noops_without_prisma(monk def test_ProxyConfig__load_environment_variables_sets_env(monkeypatch): monkeypatch.delenv("TEST_LOAD_ENV_X", raising=False) pc = ProxyConfig() - pc._load_environment_variables({"environment_variables": {"TEST_LOAD_ENV_X": "hello"}}) + pc._load_environment_variables(ProxyRuntimeConfig.from_resolved({"environment_variables": {"TEST_LOAD_ENV_X": "hello"}})) result = { "TEST_LOAD_ENV_X": os.environ.get("TEST_LOAD_ENV_X"), "set": True, @@ -2061,7 +2091,7 @@ def test_ProxyConfig__load_environment_variables_sets_env(monkeypatch): def test_ProxyConfig__load_environment_variables_blocks_dangerous_keys(monkeypatch): original_path = os.environ.get("PATH", "") pc = ProxyConfig() - pc._load_environment_variables({"environment_variables": {"PATH": "/evil/bin"}}) + pc._load_environment_variables(ProxyRuntimeConfig.from_resolved({"environment_variables": {"PATH": "/evil/bin"}})) # PATH must be unchanged — it's a blocked key. assert os.environ.get("PATH", "") == original_path @@ -2087,7 +2117,7 @@ async def test_ProxyConfig_load_config_minimal_yaml(tmp_path, monkeypatch): snapshot = { "raised": raised, "config_loaded": pc.config is not None, - "model_list_key_present": "model_list" in pc.config, + "model_list_key_present": "model_list" in pc.config.model_fields_set, } assert snapshot == { "raised": False, @@ -2321,7 +2351,7 @@ async def test_ProxyConfig_load_config_blank_callback_settings_does_not_crash(tm async def test_ProxyConfig__init_non_llm_configs_empty_config(): pc = ProxyConfig() try: - await pc._init_non_llm_configs(config={}, config_file_path=None) + await pc._init_non_llm_configs(config=ProxyRuntimeConfig.from_resolved({}), config_file_path=None) raised = False except Exception: raised = True @@ -2339,7 +2369,7 @@ async def test_ProxyConfig__init_non_llm_configs_premium_invalid_worker_registry pc = ProxyConfig() with pytest.raises(ValidationError): await pc._init_non_llm_configs( - config={"worker_registry": [{"totally": "invalid"}]}, + config=ProxyRuntimeConfig.from_resolved({"worker_registry": [{"totally": "invalid"}]}), config_file_path=None, ) @@ -2350,7 +2380,7 @@ async def test_ProxyConfig__init_non_llm_configs_worker_registry_requires_premiu pc = ProxyConfig() with pytest.raises(ValueError, match="Trying to use `worker_registry`You must be a LiteLLM") as exc_info: await pc._init_non_llm_configs( - config={"worker_registry": [{"worker_id": "worker-a", "name": "Worker A", "url": "http://localhost:4001"}]}, + config=ProxyRuntimeConfig.from_resolved({"worker_registry": [{"worker_id": "worker-a", "name": "Worker A", "url": "http://localhost:4001"}]}), config_file_path=None, ) message = str(exc_info.value) @@ -2364,12 +2394,12 @@ async def test_ProxyConfig__init_non_llm_configs_worker_registry_loads_for_premi monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", True) pc = ProxyConfig() await pc._init_non_llm_configs( - config={ + config=ProxyRuntimeConfig.from_resolved({ "worker_registry": [ {"worker_id": "worker-a", "name": "Worker A", "url": "http://localhost:4001"}, {"worker_id": "worker-b", "name": "Worker B", "url": "https://worker-b.example.com"}, ] - }, + }), config_file_path=None, ) assert [(w.worker_id, w.name, w.url) for w in pc.worker_registry] == [ @@ -2383,7 +2413,7 @@ async def test_ProxyConfig__init_non_llm_configs_worker_registry_loads_for_premi async def test_ProxyConfig__init_non_llm_configs_no_worker_registry_is_never_gated(monkeypatch, premium): monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", premium) pc = ProxyConfig() - await pc._init_non_llm_configs(config={}, config_file_path=None) + await pc._init_non_llm_configs(config=ProxyRuntimeConfig.from_resolved({}), config_file_path=None) assert pc.worker_registry == [] @@ -2396,7 +2426,7 @@ async def test_ProxyConfig__init_non_llm_configs_no_worker_registry_is_never_gat async def test_ProxyConfig__init_policy_engine_no_policies_noop(): pc = ProxyConfig() try: - await pc._init_policy_engine(config={}, prisma_client=None, llm_router=None) + await pc._init_policy_engine(config=ProxyRuntimeConfig.from_resolved({}), prisma_client=None, llm_router=None) raised = False except Exception: raised = True @@ -2412,13 +2442,9 @@ async def test_ProxyConfig__init_policy_engine_none_config_noop(): pc = ProxyConfig() # None config returns early without raising. await pc._init_policy_engine(config=None, prisma_client=None, llm_router=None) - # Error-style: invalid policies value should raise. - with pytest.raises(AttributeError): - await pc._init_policy_engine( - config={"policies": "not-a-list"}, - prisma_client=None, - llm_router=None, - ) + # Error-style: invalid policies value is rejected at model construction. + with pytest.raises(ValidationError): + ProxyRuntimeConfig.from_resolved({"policies": "not-a-list"}) # --------------------------------------------------------------------------- @@ -2540,7 +2566,7 @@ def _capture_proxy_warnings(config: dict) -> tuple[tuple[str, ...], list[str]]: verbose_proxy_logger.setLevel(logging.WARNING) verbose_proxy_logger.addHandler(handler) try: - result = ProxyConfig()._warn_on_misplaced_jwt_keys(config=config) + result = ProxyConfig()._warn_on_misplaced_jwt_keys(config=ProxyRuntimeConfig.from_resolved(config)) finally: verbose_proxy_logger.removeHandler(handler) verbose_proxy_logger.setLevel(original_level) @@ -3203,7 +3229,7 @@ async def test_ProxyConfig__update_llm_router_no_models_smoke(monkeypatch): pc = ProxyConfig() async def fake_get_config(*args, **kwargs): - return {} + return ProxyRuntimeConfig.from_resolved({}) monkeypatch.setattr(pc, "get_config", fake_get_config) monkeypatch.setattr( @@ -3273,7 +3299,7 @@ def test_ProxyConfig__add_callbacks_from_db_config_processes_lists(monkeypatch): "failure_callback": ["f_a"], } } - pc._add_callbacks_from_db_config(cfg) + pc._add_callbacks_from_db_config(ProxyRuntimeConfig.from_resolved(cfg)) snapshot = { "cb_added": "cb_a" in litellm.callbacks, "success_added": "s_a" in litellm.success_callback, @@ -3551,7 +3577,7 @@ async def test_ProxyConfig_add_deployment_applies_db_router_settings(monkeypatch ) async def fake_get_config(*args, **kwargs): - return {} + return ProxyRuntimeConfig.from_resolved({}) monkeypatch.setattr(pc, "get_config", fake_get_config) monkeypatch.setattr(pc, "_get_models_from_db", AsyncMock(return_value=[])) @@ -3578,7 +3604,7 @@ def _stub_add_deployment_collaborators( fake_router.get_model_list = MagicMock(return_value=[]) async def fake_get_config(*args: object, **kwargs: object) -> dict[str, object]: - return {} + return ProxyRuntimeConfig.from_resolved({}) monkeypatch.setattr(litellm, "credential_list", []) monkeypatch.setattr(pc, "get_config", fake_get_config) @@ -4286,7 +4312,7 @@ class _FakeAgentRow: async def test_ProxyConfig__init_non_llm_configs_registers_agents_from_config(clean_agent_registry, config_key): """The documented ``agents:`` key must register agents, as must the legacy ``agent_list:``.""" await ProxyConfig()._init_non_llm_configs( - config={config_key: [_config_agent("config-agent")]}, + config=ProxyRuntimeConfig.from_resolved({config_key: [_config_agent("config-agent")]}), config_file_path=None, ) @@ -4297,7 +4323,7 @@ async def test_ProxyConfig__init_non_llm_configs_registers_agents_from_config(cl async def test_ProxyConfig__init_agents_in_db_keeps_config_defined_agents(clean_agent_registry): """A DB reload rebuilds the registry; config-defined agents must survive it alongside DB rows.""" await ProxyConfig()._init_non_llm_configs( - config={"agents": [_config_agent("config-agent")]}, + config=ProxyRuntimeConfig.from_resolved({"agents": [_config_agent("config-agent")]}), config_file_path=None, ) @@ -4333,7 +4359,7 @@ async def test_ProxyStartupEvent_jwt_auth_resolves_agent_claims_against_live_reg ) if agents_source == "config": await ProxyConfig()._init_non_llm_configs( - config={"agents": [_config_agent("loaded-agent")]}, + config=ProxyRuntimeConfig.from_resolved({"agents": [_config_agent("loaded-agent")]}), config_file_path=None, ) elif agents_source == "db": @@ -4386,7 +4412,7 @@ async def test_ProxyConfig__init_non_llm_configs_prefers_agents_key_by_presence( Selecting on truthiness instead would silently register the legacy entries for a config that spells out ``agents: []``. """ - await ProxyConfig()._init_non_llm_configs(config=config, config_file_path=None) + await ProxyConfig()._init_non_llm_configs(config=ProxyRuntimeConfig.from_resolved(config), config_file_path=None) assert [agent.agent_name for agent in clean_agent_registry.get_agent_list()] == expected_agent_names @@ -4402,7 +4428,7 @@ async def test_ProxyConfig__init_non_llm_configs_empty_agents_key_clears_remembe clean_agent_registry.load_agents_from_config([_config_agent("stale-agent")]) assert clean_agent_registry.config_agents != () - await ProxyConfig()._init_non_llm_configs(config={"agents": []}, config_file_path=None) + await ProxyConfig()._init_non_llm_configs(config=ProxyRuntimeConfig.from_resolved({"agents": []}), config_file_path=None) assert clean_agent_registry.config_agents == () clean_agent_registry.load_agents_from_db_and_config(db_agents=None) @@ -4714,3 +4740,52 @@ def test_websearch_interception_settings_can_be_named_in_supported_db_objects(mo monkeypatch.setattr(proxy_server, "general_settings", {"supported_db_objects": ["models"]}) assert proxy_server.should_load_db_object(object_type="websearch_interception_settings") is False + + +@pytest.mark.asyncio +async def test_ProxyConfig_save_config_writes_yaml_with_only_the_section_that_changed(tmp_path, monkeypatch): + """A file-backed save replaces the file with the updated model's content; the + untouched section round-trips verbatim while the edited one holds the new value.""" + config_file: Final = tmp_path / "config.yaml" + config_file.write_text("general_settings:\n max_parallel_requests: 5\nrouter_settings:\n num_retries: 3\n") + monkeypatch.setattr("litellm.proxy.proxy_server.user_config_file_path", str(config_file)) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) + monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", False) + monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {}) + proxy_config: Final = ProxyConfig() + + loaded: Final = await proxy_config.get_config(config_file_path=str(config_file)) + updated: Final = loaded.with_section( + "general_settings", {**loaded.general_settings, "allowed_ips": ["127.0.0.1"]} + ) + await proxy_config.save_config(updated) + + import yaml as _yaml + + assert _yaml.safe_load(config_file.read_text()) == { + "general_settings": {"max_parallel_requests": 5, "allowed_ips": ["127.0.0.1"]}, + "router_settings": {"num_retries": 3}, + } + + +@pytest.mark.asyncio +async def test_ProxyConfig_save_config_db_writes_exactly_the_differing_section(monkeypatch): + proxy_config, table = _db_backed_proxy_config( + monkeypatch, + {"general_settings": {"db_only": "stored"}, "router_settings": {"db_only": "stored"}}, + ) + proxy_config.update_config_state( + config=ProxyRuntimeConfig.from_resolved( + {"general_settings": {"a": 1}, "router_settings": {"num_retries": 1}} + ) + ) + + await proxy_config.save_config( + proxy_config.get_config_state().with_section("general_settings", {"a": 2}) + ) + + assert table.rows == { + "general_settings": {"db_only": "stored", "a": 2}, + "router_settings": {"db_only": "stored"}, + } + assert table.upserted_param_names == ["general_settings"] 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 4234cdad23d..536565a7ecf 100644 --- a/tests/test_litellm/proxy/proxy_server/test_routes_config.py +++ b/tests/test_litellm/proxy/proxy_server/test_routes_config.py @@ -20,6 +20,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest from .conftest import VOLATILE_KEYS, normalize +from litellm.proxy._types import ProxyRuntimeConfig def _seed_settings_store(monkeypatch, db_row: dict, yaml_values: dict | None = None) -> None: @@ -888,7 +889,7 @@ def test_config_callback_delete_happy_admin(client, auth_as, mock_prisma, monkey fake_proxy_config = MagicMock() fake_proxy_config.get_config = AsyncMock( - return_value={"litellm_settings": {"success_callback": ["langfuse", "slack"]}} + return_value=ProxyRuntimeConfig.from_resolved({"litellm_settings": {"success_callback": ["langfuse", "slack"]}}) ) fake_proxy_config.save_config = AsyncMock() fake_proxy_config.add_deployment = AsyncMock() @@ -933,7 +934,7 @@ def test_config_callback_delete_not_found(client, auth_as, mock_prisma, monkeypa monkeypatch.setattr(ps, "store_model_in_db", True) fake_proxy_config = MagicMock() - fake_proxy_config.get_config = AsyncMock(return_value={"litellm_settings": {"success_callback": ["slack"]}}) + fake_proxy_config.get_config = AsyncMock(return_value=ProxyRuntimeConfig.from_resolved({"litellm_settings": {"success_callback": ["slack"]}})) monkeypatch.setattr(ps, "proxy_config", fake_proxy_config) with auth_as(LitellmUserRoles.PROXY_ADMIN): @@ -961,11 +962,11 @@ def test_get_config_callbacks_happy(client, auth_as, mock_prisma, monkeypatch): fake_proxy_config = MagicMock() fake_proxy_config.get_config = AsyncMock( - return_value={ + return_value=ProxyRuntimeConfig.from_resolved({ "litellm_settings": {"success_callback": []}, "general_settings": {}, "environment_variables": {}, - } + }) ) monkeypatch.setattr(ps, "proxy_config", fake_proxy_config) @@ -1029,11 +1030,11 @@ def _install_callbacks_config(monkeypatch, mock_prisma): fake_proxy_config = MagicMock() fake_proxy_config.get_config = AsyncMock( - return_value={ + return_value=ProxyRuntimeConfig.from_resolved({ "litellm_settings": {"success_callback": ["langfuse", "datadog", "otel"]}, "general_settings": {"alerting": ["slack"]}, "environment_variables": dict(_CALLBACK_ENV_FIXTURE), - } + }) ) monkeypatch.setattr(ps, "proxy_config", fake_proxy_config) @@ -1175,7 +1176,7 @@ def test_get_config_callbacks_redacts_email_alerting_vars_for_view_only_admin( fake_proxy_config = MagicMock() fake_proxy_config.get_config = AsyncMock( - return_value={ + return_value=ProxyRuntimeConfig.from_resolved({ "litellm_settings": {"success_callback": []}, "general_settings": {"alerting": ["email"]}, "environment_variables": { @@ -1188,7 +1189,7 @@ def test_get_config_callbacks_redacts_email_alerting_vars_for_view_only_admin( "EMAIL_LOGO_URL": "https://example.com/logo.png", "EMAIL_SUPPORT_CONTACT": "support@example.com", }, - } + }) ) monkeypatch.setattr(ps, "proxy_config", fake_proxy_config) @@ -1228,11 +1229,11 @@ def test_get_config_callbacks_appends_runtime_only_callbacks(client, auth_as, mo fake_proxy_config = MagicMock() fake_proxy_config.get_config = AsyncMock( - return_value={ + return_value=ProxyRuntimeConfig.from_resolved({ "litellm_settings": {"success_callback": ["langfuse"]}, "general_settings": {}, "environment_variables": dict(_CALLBACK_ENV_FIXTURE), - } + }) ) monkeypatch.setattr(ps, "proxy_config", fake_proxy_config) @@ -1269,11 +1270,11 @@ def test_get_config_callbacks_accepts_scalar_and_null_yaml_callbacks(client, aut fake_proxy_config = MagicMock() fake_proxy_config.get_config = AsyncMock( - return_value={ + return_value=ProxyRuntimeConfig.from_resolved({ "litellm_settings": {"success_callback": "langfuse", "failure_callback": None, "callbacks": None}, "general_settings": {}, "environment_variables": dict(_CALLBACK_ENV_FIXTURE), - } + }) ) monkeypatch.setattr(ps, "proxy_config", fake_proxy_config) @@ -1308,11 +1309,11 @@ def test_get_config_callbacks_deduplicates_configured_and_runtime(client, auth_a fake_proxy_config = MagicMock() fake_proxy_config.get_config = AsyncMock( - return_value={ + return_value=ProxyRuntimeConfig.from_resolved({ "litellm_settings": {"success_callback": ["langfuse", "arize", "logfire"]}, "general_settings": {}, "environment_variables": dict(_CALLBACK_ENV_FIXTURE), - } + }) ) monkeypatch.setattr(ps, "proxy_config", fake_proxy_config) @@ -1353,11 +1354,11 @@ def test_get_config_callbacks_keeps_yaml_otel_family_callbacks_next_to_configure fake_proxy_config = MagicMock() fake_proxy_config.get_config = AsyncMock( - return_value={ + return_value=ProxyRuntimeConfig.from_resolved({ "litellm_settings": {"callbacks": ["langfuse_otel"]}, "general_settings": {}, "environment_variables": dict(_CALLBACK_ENV_FIXTURE), - } + }) ) monkeypatch.setattr(ps, "proxy_config", fake_proxy_config) @@ -1430,11 +1431,11 @@ def test_get_config_callbacks_deduplicates_dotted_path_callback( fake_proxy_config = MagicMock() fake_proxy_config.get_config = AsyncMock( - return_value={ + return_value=ProxyRuntimeConfig.from_resolved({ "litellm_settings": {config_key: [dotted_path]}, "general_settings": {}, "environment_variables": dict(_CALLBACK_ENV_FIXTURE), - } + }) ) monkeypatch.setattr(ps, "proxy_config", fake_proxy_config) @@ -1465,11 +1466,11 @@ def test_get_config_callbacks_lists_dict_shaped_config_callbacks(client, auth_as fake_proxy_config = MagicMock() fake_proxy_config.get_config = AsyncMock( - return_value={ + return_value=ProxyRuntimeConfig.from_resolved({ "litellm_settings": {"success_callback": {"langsmith": {"batch_size": 1}}}, "general_settings": {}, "environment_variables": dict(_CALLBACK_ENV_FIXTURE), - } + }) ) monkeypatch.setattr(ps, "proxy_config", fake_proxy_config) @@ -1500,11 +1501,11 @@ def test_get_config_callbacks_excludes_internal_runtime_callbacks(client, auth_a fake_proxy_config = MagicMock() fake_proxy_config.get_config = AsyncMock( - return_value={ + return_value=ProxyRuntimeConfig.from_resolved({ "litellm_settings": {"success_callback": []}, "general_settings": {}, "environment_variables": dict(_CALLBACK_ENV_FIXTURE), - } + }) ) monkeypatch.setattr(ps, "proxy_config", fake_proxy_config) @@ -1585,11 +1586,11 @@ def test_get_config_callbacks_redacts_runtime_only_row_secrets_for_view_only_adm fake_proxy_config = MagicMock() fake_proxy_config.get_config = AsyncMock( - return_value={ + return_value=ProxyRuntimeConfig.from_resolved({ "litellm_settings": {"success_callback": []}, "general_settings": {}, "environment_variables": dict(_CALLBACK_ENV_FIXTURE), - } + }) ) monkeypatch.setattr(ps, "proxy_config", fake_proxy_config) diff --git a/tests/test_litellm/proxy/proxy_server/test_routes_model_info.py b/tests/test_litellm/proxy/proxy_server/test_routes_model_info.py index 636dc0f4d77..15e256827b7 100644 --- a/tests/test_litellm/proxy/proxy_server/test_routes_model_info.py +++ b/tests/test_litellm/proxy/proxy_server/test_routes_model_info.py @@ -26,6 +26,7 @@ from litellm.proxy import proxy_server from litellm.utils import _invalidate_model_cost_lowercase_map from .conftest import normalize # type: ignore[import-not-found] +from litellm.proxy._types import ProxyRuntimeConfig @pytest.mark.parametrize( @@ -350,7 +351,7 @@ def test_v2_model_info_reports_pricing_overrides_to_the_admin_ui(client, auth_as monkeypatch.setattr(proxy_server, "llm_model_list", model_list) monkeypatch.setattr(proxy_server, "prisma_client", MagicMock()) monkeypatch.setattr(proxy_server, "user_model", None) - monkeypatch.setattr(proxy_server.proxy_config, "get_config", AsyncMock(return_value={})) + monkeypatch.setattr(proxy_server.proxy_config, "get_config", AsyncMock(return_value=ProxyRuntimeConfig.from_resolved({}))) monkeypatch.setattr( proxy_server, "_apply_search_filter_to_models", @@ -739,7 +740,7 @@ def mixed_auto_router_router(monkeypatch): monkeypatch.setattr(proxy_server, "llm_model_list", model_list) monkeypatch.setattr(proxy_server, "prisma_client", MagicMock()) monkeypatch.setattr(proxy_server, "user_model", None) - monkeypatch.setattr(proxy_server.proxy_config, "get_config", AsyncMock(return_value={})) + monkeypatch.setattr(proxy_server.proxy_config, "get_config", AsyncMock(return_value=ProxyRuntimeConfig.from_resolved({}))) monkeypatch.setattr( proxy_server, "_apply_search_filter_to_models", @@ -806,7 +807,7 @@ async def test_model_info_v2_query_sentinel_does_not_filter(monkeypatch, mixed_a from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth monkeypatch.setattr(proxy_server, "prisma_client", MagicMock()) - monkeypatch.setattr(proxy_server.proxy_config, "get_config", AsyncMock(return_value={})) + monkeypatch.setattr(proxy_server.proxy_config, "get_config", AsyncMock(return_value=ProxyRuntimeConfig.from_resolved({}))) monkeypatch.setattr( proxy_server, "_apply_search_filter_to_models", @@ -871,7 +872,7 @@ def access_group_router(monkeypatch): monkeypatch.setattr(proxy_server, "llm_model_list", model_list) monkeypatch.setattr(proxy_server, "prisma_client", MagicMock()) monkeypatch.setattr(proxy_server, "user_model", None) - monkeypatch.setattr(proxy_server.proxy_config, "get_config", AsyncMock(return_value={})) + monkeypatch.setattr(proxy_server.proxy_config, "get_config", AsyncMock(return_value=ProxyRuntimeConfig.from_resolved({}))) monkeypatch.setattr( proxy_server, "_apply_search_filter_to_models", diff --git a/tests/test_litellm/proxy/proxy_server/test_team_model_name_translation.py b/tests/test_litellm/proxy/proxy_server/test_team_model_name_translation.py index baa032f75e6..fd95f97bb8d 100644 --- a/tests/test_litellm/proxy/proxy_server/test_team_model_name_translation.py +++ b/tests/test_litellm/proxy/proxy_server/test_team_model_name_translation.py @@ -18,6 +18,7 @@ import litellm.proxy.proxy_server as ps from litellm.proxy._types import ( LiteLLM_UserTable, LitellmUserRoles, + ProxyRuntimeConfig, UserAPIKeyAuth, ) from litellm.proxy.common_utils.model_listing_utils import ( @@ -119,7 +120,7 @@ async def test_model_info_v2_translates_team_model_name(monkeypatch): monkeypatch.setattr(ps, "llm_router", router) monkeypatch.setattr(ps, "prisma_client", MagicMock()) - monkeypatch.setattr(ps.proxy_config, "get_config", AsyncMock(return_value={})) + monkeypatch.setattr(ps.proxy_config, "get_config", AsyncMock(return_value=ProxyRuntimeConfig.from_resolved({}))) monkeypatch.setattr( ps, "_apply_search_filter_to_models", @@ -175,7 +176,7 @@ async def test_model_info_v2_exact_model_filter_matches_team_public_name(monkeyp monkeypatch.setattr(ps, "llm_router", router) monkeypatch.setattr(ps, "user_model", None) monkeypatch.setattr(ps, "prisma_client", MagicMock()) - monkeypatch.setattr(ps.proxy_config, "get_config", AsyncMock(return_value={})) + monkeypatch.setattr(ps.proxy_config, "get_config", AsyncMock(return_value=ProxyRuntimeConfig.from_resolved({}))) monkeypatch.setattr( ps, "_apply_search_filter_to_models", diff --git a/tests/test_litellm/proxy/test__types.py b/tests/test_litellm/proxy/test__types.py index 9e1486ce90f..e64022f0c92 100644 --- a/tests/test_litellm/proxy/test__types.py +++ b/tests/test_litellm/proxy/test__types.py @@ -335,3 +335,52 @@ def test_virtual_key_mapping_counts_as_configured_when_any_issuer_sets_the_claim ) assert jwt_auth.is_virtual_key_mapping_configured() is is_configured + + +def test_proxy_runtime_config_rejects_field_assignment(): + from litellm.proxy._types import ProxyRuntimeConfig + + config = ProxyRuntimeConfig.from_resolved({"litellm_settings": {"drop_params": True}}) + + with pytest.raises(ValidationError): + config.litellm_settings = {} + + +def test_proxy_runtime_config_loads_a_null_section_as_empty(): + from litellm.proxy._types import ProxyRuntimeConfig + + config = ProxyRuntimeConfig.from_resolved({"litellm_settings": None, "general_settings": {"a": 1}}) + + assert config.litellm_settings == {} + assert config.general_settings == {"a": 1} + + +def test_proxy_runtime_config_to_mapping_keeps_unknown_sections(): + from litellm.proxy._types import ProxyRuntimeConfig + + config = ProxyRuntimeConfig.from_resolved({"my_custom_section": {"k": "v"}, "general_settings": {"a": 1}}) + + assert config.to_mapping()["my_custom_section"] == {"k": "v"} + + +def test_proxy_runtime_config_with_section_keeps_the_loaded_baseline(): + from litellm.proxy._types import ProxyRuntimeConfig + + config = ProxyRuntimeConfig.from_resolved({"general_settings": {"a": 1}}) + updated = config.with_section("general_settings", {"a": 2}) + + assert updated.baseline["general_settings"] == {"a": 1} + assert updated.to_mapping()["general_settings"] == {"a": 2} + assert config.general_settings == {"a": 1} + + +def test_proxy_runtime_config_to_mapping_yaml_dumps_lists_not_tuples(): + import yaml + + from litellm.proxy._types import ProxyRuntimeConfig + + config = ProxyRuntimeConfig.from_resolved({"model_list": [{"model_name": "m"}], "guardrails": [{"g": 1}]}) + + dumped = yaml.safe_dump(dict(config.to_mapping())) + assert "!!python/tuple" not in dumped + assert yaml.safe_load(dumped) == {"model_list": [{"model_name": "m"}], "guardrails": [{"g": 1}]} diff --git a/tests/test_litellm/proxy/test_fallback_management_endpoints.py b/tests/test_litellm/proxy/test_fallback_management_endpoints.py index 054dafbf5a7..529928004e1 100644 --- a/tests/test_litellm/proxy/test_fallback_management_endpoints.py +++ b/tests/test_litellm/proxy/test_fallback_management_endpoints.py @@ -19,6 +19,7 @@ from litellm.proxy.management_endpoints.fallback_management_endpoints import ( delete_fallback, get_fallback, ) +from litellm.proxy._types import ProxyRuntimeConfig class TestFallbackCreateRequest: @@ -130,7 +131,7 @@ class TestCreateFallback: def mock_proxy_config(self): """Create a mock proxy config""" config = MagicMock() - config.get_config = AsyncMock(return_value={"router_settings": {}}) + config.get_config = AsyncMock(return_value=ProxyRuntimeConfig.from_resolved({"router_settings": {}})) return config @pytest.fixture @@ -438,11 +439,11 @@ class TestDeleteFallback: """Create a mock proxy config""" config = MagicMock() config.get_config = AsyncMock( - return_value={ + return_value=ProxyRuntimeConfig.from_resolved({ "router_settings": { "fallbacks": [{"gpt-3.5-turbo": ["gpt-4", "claude-3-haiku"]}] } - } + }) ) return config diff --git a/tests/test_litellm/proxy/test_proxy_cli.py b/tests/test_litellm/proxy/test_proxy_cli.py index a38470d1fdf..4d0e138e4db 100644 --- a/tests/test_litellm/proxy/test_proxy_cli.py +++ b/tests/test_litellm/proxy/test_proxy_cli.py @@ -19,6 +19,7 @@ from uvicorn.config import LOOP_FACTORIES from uvicorn.importer import import_from_string from litellm.proxy.proxy_cli import ProxyInitializationHelpers, run_server +from litellm.proxy._types import ProxyRuntimeConfig @pytest.fixture(autouse=True) @@ -870,13 +871,13 @@ class TestProxyInitializationHelpers: save_worker_config=MagicMock(), ) mock_proxy_module.ProxyConfig.return_value.get_config = AsyncMock( - return_value={ + return_value=ProxyRuntimeConfig.from_resolved({ "general_settings": { "database_url": "postgresql://test:test@localhost:5432/test", "database_connection_pool_limit": 5, **timeout_config, } - } + }) ) clean_env = { @@ -996,7 +997,7 @@ class TestProxyInitializationHelpers: save_worker_config=MagicMock(), ) mock_proxy_module.ProxyConfig.return_value.get_config = AsyncMock( - return_value={ + return_value=ProxyRuntimeConfig.from_resolved({ "general_settings": { "database_url": "postgresql://test:test@localhost:5432/test", "database_connect_timeout": 15, @@ -1006,7 +1007,7 @@ class TestProxyInitializationHelpers: "statement_cache_size": 0, }, } - } + }) ) clean_env = { @@ -1122,12 +1123,12 @@ class TestProxyInitializationHelpers: save_worker_config=MagicMock(), ) mock_proxy_module.ProxyConfig.return_value.get_config = AsyncMock( - return_value={ + return_value=ProxyRuntimeConfig.from_resolved({ "general_settings": { "database_url": "postgresql://test:test@localhost:5432/test", "database_disable_prepared_statements": config_value, } - } + }) ) clean_env = { @@ -1773,7 +1774,7 @@ class TestProxyInitializationHelpers: # Mock the ProxyConfig.get_config method to return a proper async config async def mock_get_config(config_file_path=None): - return {"general_settings": {}, "litellm_settings": {}} + return ProxyRuntimeConfig.from_resolved({"general_settings": {}, "litellm_settings": {}}) mock_proxy_config_instance = MagicMock() mock_proxy_config_instance.get_config = mock_get_config @@ -2664,7 +2665,7 @@ def _run_server_and_capture_urls( ) -> dict: loaded_config = yaml.safe_load(Path(config_path).read_text()) mock_proxy_config = MagicMock() - mock_proxy_config.return_value.get_config = AsyncMock(return_value=loaded_config) + mock_proxy_config.return_value.get_config = AsyncMock(return_value=ProxyRuntimeConfig.from_resolved(loaded_config)) mock_proxy_module = MagicMock( app=MagicMock(), ProxyConfig=mock_proxy_config, diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 950a6cc3c40..f71194e89d3 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -37,6 +37,7 @@ from litellm.proxy._types import ( ModelAccessDeniedProxyException, ProxyErrorTypes, ProxyException, + ProxyRuntimeConfig, TokenCountRequest, UserAPIKeyAuth, ) @@ -1163,7 +1164,7 @@ def test_get_config_custom_callback_api_env_vars(monkeypatch): mock_router = MagicMock() mock_router.get_settings.return_value = {} monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", mock_router) - monkeypatch.setattr(proxy_config, "get_config", AsyncMock(return_value=config_data)) + monkeypatch.setattr(proxy_config, "get_config", AsyncMock(return_value=ProxyRuntimeConfig.from_resolved(config_data))) # Bypass auth dependency original_overrides = app.dependency_overrides.copy() @@ -1213,7 +1214,7 @@ def test_get_config_callbacks_fall_back_to_process_env(mock_env_vars, monkeypatc mock_router = MagicMock() mock_router.get_settings.return_value = {} monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", mock_router) - monkeypatch.setattr(proxy_config, "get_config", AsyncMock(return_value=config_data)) + monkeypatch.setattr(proxy_config, "get_config", AsyncMock(return_value=ProxyRuntimeConfig.from_resolved(config_data))) original_overrides = app.dependency_overrides.copy() app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( @@ -1260,7 +1261,7 @@ def test_get_config_callback_env_secrets_redacted_for_non_admin(mock_env_vars, m mock_router = MagicMock() mock_router.get_settings.return_value = {} monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", mock_router) - monkeypatch.setattr(proxy_config, "get_config", AsyncMock(return_value=config_data)) + monkeypatch.setattr(proxy_config, "get_config", AsyncMock(return_value=ProxyRuntimeConfig.from_resolved(config_data))) original_overrides = app.dependency_overrides.copy() app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( @@ -1310,7 +1311,7 @@ def test_get_config_returns_email_settings(monkeypatch): mock_router = MagicMock() mock_router.get_settings.return_value = {} monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", mock_router) - monkeypatch.setattr(proxy_config, "get_config", AsyncMock(return_value=config_data)) + monkeypatch.setattr(proxy_config, "get_config", AsyncMock(return_value=ProxyRuntimeConfig.from_resolved(config_data))) original_overrides = app.dependency_overrides.copy() app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( @@ -1347,7 +1348,7 @@ def _get_email_alert_variables(monkeypatch, config_data): mock_router = MagicMock() mock_router.get_settings.return_value = {} monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", mock_router) - monkeypatch.setattr(proxy_config, "get_config", AsyncMock(return_value=config_data)) + monkeypatch.setattr(proxy_config, "get_config", AsyncMock(return_value=ProxyRuntimeConfig.from_resolved(config_data))) original_overrides = app.dependency_overrides.copy() app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( @@ -1472,7 +1473,7 @@ def test_get_config_returns_slack_webhook(monkeypatch): mock_logging.slack_alerting_instance._all_possible_alert_types.return_value = ["budget_alerts"] mock_logging.slack_alerting_instance.alert_to_webhook_url = {} monkeypatch.setattr("litellm.proxy.proxy_server.proxy_logging_obj", mock_logging) - monkeypatch.setattr(proxy_config, "get_config", AsyncMock(return_value=config_data)) + monkeypatch.setattr(proxy_config, "get_config", AsyncMock(return_value=ProxyRuntimeConfig.from_resolved(config_data))) original_overrides = app.dependency_overrides.copy() app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( @@ -1522,7 +1523,7 @@ def test_get_config_cleared_slack_webhook_not_overridden_by_os_env(monkeypatch): mock_logging.slack_alerting_instance._all_possible_alert_types.return_value = ["budget_alerts"] mock_logging.slack_alerting_instance.alert_to_webhook_url = {} monkeypatch.setattr("litellm.proxy.proxy_server.proxy_logging_obj", mock_logging) - monkeypatch.setattr(proxy_config, "get_config", AsyncMock(return_value=config_data)) + monkeypatch.setattr(proxy_config, "get_config", AsyncMock(return_value=ProxyRuntimeConfig.from_resolved(config_data))) original_overrides = app.dependency_overrides.copy() app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( @@ -3008,7 +3009,7 @@ async def test_delete_deployment_type_mismatch(): mock_llm_router.delete_deployment = MagicMock(side_effect=mock_delete_deployment) async def mock_get_config(config_file_path): - return { + return ProxyRuntimeConfig.from_resolved({ "model_list": [ { "model_name": "openai-gpt-4o", @@ -3021,7 +3022,7 @@ async def test_delete_deployment_type_mismatch(): "model_info": {"id": 12345679}, }, ] - } + }) pc.get_config = AsyncMock(side_effect=mock_get_config) @@ -4088,8 +4089,10 @@ async def test_write_config_to_file(monkeypatch): with patch("builtins.open", mock_file_open), patch("yaml.dump") as mock_yaml_dump: # Call save_config with test data - test_config = {"key": "value", "model_list": ["model1", "model2"]} - await proxy_config.save_config(new_config=test_config) + new_config = ProxyRuntimeConfig().model_copy( + update={"key": "value", "model_list": [{"model_name": "model1"}, {"model_name": "model2"}]} + ) + await proxy_config.save_config(new_config=new_config) # Verify that file was NOT opened for writing (since store_model_in_db=True) mock_file_open.assert_not_called() @@ -4135,7 +4138,7 @@ async def test_write_config_to_file_when_store_model_in_db_false(monkeypatch): with patch("builtins.open", mock_file_open), patch("yaml.dump") as mock_yaml_dump: # Call save_config with test data test_config = {"key": "value", "other_key": "other_value"} - await proxy_config.save_config(new_config=test_config) + await proxy_config.save_config(new_config=ProxyRuntimeConfig.from_resolved(test_config)) # Verify that file WAS opened for writing (since store_model_in_db=False) mock_file_open.assert_called_once_with(f"{test_config_path}", "w") @@ -6993,7 +6996,7 @@ def test_get_config_normalizes_string_callbacks(monkeypatch): mock_router = MagicMock() mock_router.get_settings.return_value = {} monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", mock_router) - monkeypatch.setattr(proxy_config, "get_config", AsyncMock(return_value=config_data)) + monkeypatch.setattr(proxy_config, "get_config", AsyncMock(return_value=ProxyRuntimeConfig.from_resolved(config_data))) original_overrides = app.dependency_overrides.copy() app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( @@ -11237,7 +11240,7 @@ class TestDeleteDeploymentSync: mock_router.delete_deployment.return_value = MagicMock() with patch("litellm.proxy.proxy_server.llm_router", mock_router): - with patch.object(proxy_config, "get_config", AsyncMock(return_value={"model_list": []})): + with patch.object(proxy_config, "get_config", AsyncMock(return_value=ProxyRuntimeConfig.from_resolved({"model_list": []}))): still_desired = await proxy_config._delete_deployment(db_models=[]) mock_router.delete_deployment.assert_called_once_with(id="model-id-to-evict") @@ -11260,7 +11263,7 @@ class TestDeleteDeploymentSync: mock_router = MagicMock() with patch("litellm.proxy.proxy_server.llm_router", mock_router): - with patch.object(proxy_config, "get_config", AsyncMock(return_value={})): + with patch.object(proxy_config, "get_config", AsyncMock(return_value=ProxyRuntimeConfig.from_resolved({}))): await proxy_config._update_llm_router(new_models=None, proxy_logging_obj=MagicMock()) mock_router.delete_deployment.assert_not_called() @@ -11492,7 +11495,7 @@ async def test_update_config_field_throttle_persists_to_litellm_settings(monkeyp saved: dict = {} async def fake_get_config(): - return {"litellm_settings": {}} + return ProxyRuntimeConfig.from_resolved({"litellm_settings": {}}) async def fake_save_config(new_config=None): saved.update(new_config or {}) @@ -11604,7 +11607,7 @@ async def test_update_config_field_max_ui_session_budget_sets_live_value(monkeyp saved: dict = {} async def fake_get_config(): - return {"litellm_settings": {}} + return ProxyRuntimeConfig.from_resolved({"litellm_settings": {}}) async def fake_save_config(new_config=None): saved.update(new_config or {}) @@ -11824,7 +11827,7 @@ async def test_update_config_field_prompt_caching_persists_to_litellm_settings(m saved: dict = {} async def fake_get_config(): - return {"litellm_settings": {}} + return ProxyRuntimeConfig.from_resolved({"litellm_settings": {}}) async def fake_save_config(new_config=None): saved.update(new_config or {}) @@ -11873,7 +11876,7 @@ async def test_update_config_field_prompt_caching_rejects_invalid(monkeypatch, f from litellm.proxy.proxy_server import update_config_general_settings async def fake_get_config(): - return {"litellm_settings": {}} + return ProxyRuntimeConfig.from_resolved({"litellm_settings": {}}) monkeypatch.setattr(ps.proxy_config, "get_config", fake_get_config) monkeypatch.setattr(ps, "prisma_client", MagicMock()) @@ -11915,7 +11918,7 @@ async def test_reset_config_field_restores_type_default(monkeypatch, field_name, saved: dict = {} async def fake_get_config(): - return {"litellm_settings": {field_name: "stale"}} + return ProxyRuntimeConfig.from_resolved({"litellm_settings": {field_name: "stale"}}) async def fake_save_config(new_config=None): saved.update(new_config or {}) @@ -11952,7 +11955,7 @@ async def test_update_config_field_throttle_rejects_invalid(monkeypatch, bad_val from litellm.proxy.proxy_server import update_config_general_settings async def fake_get_config(): - return {"litellm_settings": {}} + return ProxyRuntimeConfig.from_resolved({"litellm_settings": {}}) monkeypatch.setattr(ps.proxy_config, "get_config", fake_get_config) monkeypatch.setattr(ps, "prisma_client", MagicMock()) @@ -12703,7 +12706,7 @@ def test_delete_callback_audits_litellm_settings_deletion(_update_config_setup, monkeypatch.setattr( real_proxy_config, "get_config", - AsyncMock(return_value={"litellm_settings": {"success_callback": ["langfuse", "datadog"]}}), + AsyncMock(return_value=ProxyRuntimeConfig.from_resolved({"litellm_settings": {"success_callback": ["langfuse", "datadog"]}})), ) monkeypatch.setattr(real_proxy_config, "save_config", AsyncMock(return_value=None)) try: @@ -12736,7 +12739,7 @@ def test_delete_callback_audits_before_reload_failure(_update_config_setup, monk monkeypatch.setattr( real_proxy_config, "get_config", - AsyncMock(return_value={"litellm_settings": {"success_callback": ["langfuse", "datadog"]}}), + AsyncMock(return_value=ProxyRuntimeConfig.from_resolved({"litellm_settings": {"success_callback": ["langfuse", "datadog"]}})), ) monkeypatch.setattr(real_proxy_config, "save_config", AsyncMock(return_value=None)) monkeypatch.setattr( diff --git a/tests/test_litellm/proxy/test_update_llm_router_resilience.py b/tests/test_litellm/proxy/test_update_llm_router_resilience.py index aaa9d144d4e..177a3435f5c 100644 --- a/tests/test_litellm/proxy/test_update_llm_router_resilience.py +++ b/tests/test_litellm/proxy/test_update_llm_router_resilience.py @@ -11,6 +11,7 @@ catch-all handler in _update_llm_router. import pytest from unittest.mock import AsyncMock, MagicMock, patch +from litellm.proxy._types import ProxyRuntimeConfig from litellm.proxy.proxy_server import ProxyConfig @@ -89,7 +90,7 @@ class TestUpdateLlmRouterResilience: proxy_config, "get_config", new_callable=AsyncMock, - return_value={"model_list": []}, + return_value=ProxyRuntimeConfig.from_resolved({"model_list": []}), ), patch.object(proxy_config, "_add_deployment", return_value=1) as mock_add, patch.object( @@ -161,7 +162,7 @@ class TestDeleteDeploymentResilience: proxy_config, "get_config", new_callable=AsyncMock, - return_value={ + return_value=ProxyRuntimeConfig.from_resolved({ "model_list": [ { "model_name": "gpt-4", @@ -169,7 +170,7 @@ class TestDeleteDeploymentResilience: "model_info": {"id": "config-id-1"}, } ] - }, + }), ), patch("litellm.proxy.proxy_server.llm_router", mock_router), patch("litellm.proxy.proxy_server.premium_user", False), @@ -265,7 +266,7 @@ class TestDeleteDeploymentKeepsPluginConfigModels: } proxy_config = ProxyConfig() with ( - patch.object(proxy_config, "get_config", new_callable=AsyncMock, return_value=raw_config), + patch.object(proxy_config, "get_config", new_callable=AsyncMock, return_value=ProxyRuntimeConfig.from_resolved(raw_config)), patch("litellm.proxy.proxy_server.llm_router", router), patch("litellm.proxy.proxy_server.user_config_file_path", config_file_path), patch("litellm.proxy.proxy_server.premium_user", False), diff --git a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py index 0f57af7f82c..c562646a881 100644 --- a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py +++ b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py @@ -5,7 +5,7 @@ import pytest from fastapi.testclient import TestClient -from litellm.proxy._types import DefaultInternalUserParams, LitellmUserRoles +from litellm.proxy._types import DefaultInternalUserParams, LitellmUserRoles, ProxyRuntimeConfig from litellm.proxy.proxy_server import app from litellm.types.proxy.management_endpoints.ui_sso import ( DefaultTeamSSOParams, @@ -18,7 +18,7 @@ client = TestClient(app) @pytest.fixture def mock_proxy_config(monkeypatch): """Mock the proxy_config to avoid actual file operations during tests""" - mock_config = { + mock_config = ProxyRuntimeConfig.from_resolved({ "litellm_settings": { "default_internal_user_params": { "user_role": LitellmUserRoles.INTERNAL_USER, @@ -42,7 +42,7 @@ def mock_proxy_config(monkeypatch): "MICROSOFT_CLIENT_SECRET": "test_microsoft_client_secret", "PROXY_BASE_URL": "https://example.com", }, - } + }) async def mock_get_config(): return mock_config @@ -70,7 +70,7 @@ def mock_proxy_config(monkeypatch): # Return the config, the save_config call counter, and any env-var updates # the endpoint routed through the dedicated save_environment_variables path return { - "config": mock_config, + "config": lambda: mock_config, "save_call_count": lambda: save_config_call_count, "env_updates": lambda: saved_env_updates, } @@ -104,7 +104,7 @@ class TestProxySettingEndpoints: # Check values match our mock config values = data["values"] - mock_params = mock_proxy_config["config"]["litellm_settings"][ + mock_params = mock_proxy_config["config"]().litellm_settings[ "default_internal_user_params" ] assert values["user_role"] == mock_params["user_role"] @@ -185,7 +185,7 @@ class TestProxySettingEndpoints: assert settings["models"] == new_settings["models"] # Verify the config was updated - updated_config = mock_proxy_config["config"]["litellm_settings"][ + updated_config = mock_proxy_config["config"]().litellm_settings[ "default_internal_user_params" ] assert updated_config["user_role"] == new_settings["user_role"] @@ -207,7 +207,7 @@ class TestProxySettingEndpoints: # Check values match our mock config values = data["values"] - mock_params = mock_proxy_config["config"]["litellm_settings"][ + mock_params = mock_proxy_config["config"]().litellm_settings[ "default_team_params" ] assert values["models"] == mock_params["models"] @@ -258,7 +258,7 @@ class TestProxySettingEndpoints: assert settings["rpm_limit"] == new_settings["rpm_limit"] # Verify the config was updated - updated_config = mock_proxy_config["config"]["litellm_settings"][ + updated_config = mock_proxy_config["config"]().litellm_settings[ "default_team_params" ] assert updated_config["models"] == new_settings["models"] @@ -1216,7 +1216,7 @@ class TestProxySettingEndpoints: def test_get_ui_theme_settings_with_favicon_configured(self, mock_proxy_config): """Test getting UI theme settings when favicon is configured""" - mock_proxy_config["config"]["litellm_settings"]["ui_theme_config"] = { + mock_proxy_config["config"]().litellm_settings["ui_theme_config"] = { "logo_url": "https://example.com/logo.png", "favicon_url": "https://example.com/favicon.ico", } @@ -2634,7 +2634,7 @@ def test_add_allowed_ip_hands_save_config_only_the_changed_general_setting(monke save_config: Final = AsyncMock(side_effect=lambda new_config: new_config) async def _get_config(): - return {"general_settings": dict(file_settings)} + return ProxyRuntimeConfig.from_resolved({"general_settings": dict(file_settings)}) monkeypatch.setattr(proxy_server_module, "prisma_client", fake_prisma) monkeypatch.setattr(proxy_server_module, "store_model_in_db", True) @@ -2656,10 +2656,13 @@ def test_add_allowed_ip_hands_save_config_only_the_changed_general_setting(monke assert resp.status_code == 200, resp.text save_config.assert_awaited_once() - persisted: Final = save_config.await_args.kwargs["new_config"]["general_settings"] - changed, removed = changed_section_keys(file_settings, persisted) + persisted: Final = save_config.await_args.kwargs["new_config"] + changed, removed = changed_section_keys(file_settings, persisted.general_settings) assert dict(changed) == {"allowed_ips": ["203.0.113.77"]} assert removed == frozenset() + # the frozen model save_config was handed still carries the loaded + # baseline, so the write is provably an add of just the new ip + assert "allowed_ips" not in persisted.baseline["general_settings"] assert store["allowed_ips"] == ["203.0.113.77"] finally: app.dependency_overrides.pop(user_api_key_auth, None) @@ -2679,7 +2682,9 @@ def test_delete_allowed_ip_writes_deleted_audit_log(monkeypatch): fake_prisma = MagicMock() fake_prisma.db.litellm_auditlog.create = audit_create - config = {"general_settings": {"allowed_ips": ["203.0.113.77", "198.51.100.1"]}} + config = ProxyRuntimeConfig.from_resolved( + {"general_settings": {"allowed_ips": ["203.0.113.77", "198.51.100.1"]}} + ) async def _get_config(): return config @@ -2740,7 +2745,7 @@ def test_allowed_ip_routes_refuse_a_config_owned_list_with_a_clear_400(route, mo fake_prisma.db.litellm_auditlog.create = AsyncMock() async def _get_config(): - return {"general_settings": {"allowed_ips": ["203.0.113.77"]}} + return ProxyRuntimeConfig.from_resolved({"general_settings": {"allowed_ips": ["203.0.113.77"]}}) async def _save_config(new_config=None): saved.append(new_config) @@ -3130,7 +3135,7 @@ class TestMcpToolSearchSettingsEndpoints: def test_get_returns_stored_values_and_field_schema(self, mock_proxy_config, mock_auth, monkeypatch): monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", object()) - mock_proxy_config["config"]["litellm_settings"]["mcp_tool_search"] = { + mock_proxy_config["config"]().litellm_settings["mcp_tool_search"] = { "embedding_model": "text-embedding-3-small", "core_tools": ["treasury-get_rates"], } @@ -3175,7 +3180,7 @@ class TestMcpToolSearchSettingsEndpoints: assert resp.status_code == 200, resp.text assert mock_proxy_config["save_call_count"]() == 1 assert litellm.mcp_tool_search == payload - assert mock_proxy_config["config"]["litellm_settings"]["mcp_tool_search"] == payload + assert mock_proxy_config["config"]().litellm_settings["mcp_tool_search"] == payload def test_update_rejects_out_of_range_top_k(self, mock_proxy_config, monkeypatch): monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True) @@ -3206,7 +3211,7 @@ class TestWebSearchInterceptionSettingsEndpoints: monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", object()) monkeypatch.setattr(litellm, "callbacks", [WebSearchInterceptionLogger(search_tool_name="running")]) - mock_proxy_config["config"]["litellm_settings"]["websearch_interception_params"] = { + mock_proxy_config["config"]().litellm_settings["websearch_interception_params"] = { "enabled": True, "enabled_providers": ["bedrock", "vertex_ai"], "search_tool_name": "my-perplexity-search", @@ -3249,7 +3254,7 @@ class TestWebSearchInterceptionSettingsEndpoints: assert resp.status_code == 200, resp.text assert mock_proxy_config["save_call_count"]() == 1 - assert mock_proxy_config["config"]["litellm_settings"]["websearch_interception_params"] == payload + assert mock_proxy_config["config"]().litellm_settings["websearch_interception_params"] == payload def test_get_reports_enabled_while_the_callback_is_running_without_a_stored_flag( self, mock_proxy_config, mock_auth, monkeypatch @@ -3261,7 +3266,7 @@ class TestWebSearchInterceptionSettingsEndpoints: monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", object()) monkeypatch.setattr(litellm, "callbacks", [WebSearchInterceptionLogger(search_tool_name="from-config")]) - mock_proxy_config["config"]["litellm_settings"]["websearch_interception_params"] = { + mock_proxy_config["config"]().litellm_settings["websearch_interception_params"] = { "enabled_providers": ["bedrock"], "search_tool_name": "my-perplexity-search", } @@ -3278,7 +3283,7 @@ class TestWebSearchInterceptionSettingsEndpoints: monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", object()) monkeypatch.setattr(litellm, "callbacks", []) - mock_proxy_config["config"]["litellm_settings"]["websearch_interception_params"] = { + mock_proxy_config["config"]().litellm_settings["websearch_interception_params"] = { "enabled_providers": ["bedrock"], } @@ -3314,7 +3319,7 @@ class TestWebSearchInterceptionSettingsEndpoints: monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", object()) monkeypatch.setattr(litellm, "callbacks", []) - mock_proxy_config["config"]["litellm_settings"]["websearch_interception_params"] = { + mock_proxy_config["config"]().litellm_settings["websearch_interception_params"] = { "enabled": True, "search_tool_name": "cluster-search", } @@ -3331,7 +3336,7 @@ class TestWebSearchInterceptionSettingsEndpoints: monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", object()) monkeypatch.setattr(litellm, "callbacks", []) - mock_proxy_config["config"]["litellm_settings"]["websearch_interception_params"] = {"enabled": True} + mock_proxy_config["config"]().litellm_settings["websearch_interception_params"] = {"enabled": True} resp = client.get("/get/websearch_interception_settings") @@ -3349,7 +3354,7 @@ class TestWebSearchInterceptionSettingsEndpoints: monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", object()) monkeypatch.setattr(litellm, "callbacks", [WebSearchInterceptionLogger(search_tool_name="running")]) - mock_proxy_config["config"]["litellm_settings"]["websearch_interception_params"] = {"enabled": True} + mock_proxy_config["config"]().litellm_settings["websearch_interception_params"] = {"enabled": True} resp = client.get("/get/websearch_interception_settings") diff --git a/tests/test_litellm/test_router_retry_policy_update.py b/tests/test_litellm/test_router_retry_policy_update.py index 0a3dcba325a..158bcea6c36 100644 --- a/tests/test_litellm/test_router_retry_policy_update.py +++ b/tests/test_litellm/test_router_retry_policy_update.py @@ -33,6 +33,7 @@ from litellm.router_strategy.budget_limiter import RouterBudgetLimiting from litellm.router_utils.pre_call_checks.model_rate_limit_check import ModelRateLimitingCheck from litellm.router_utils.pre_call_checks.prompt_caching_deployment_check import PromptCachingDeploymentCheck from litellm.types.router import RetryPolicy, UpdateRouterConfig +from litellm.proxy._types import ProxyRuntimeConfig @pytest.fixture(autouse=True) @@ -366,7 +367,7 @@ async def test_config_update_persists_and_reads_back_retry_policy(monkeypatch): monkeypatch.setattr(proxy_server, "prisma_client", prisma_client) monkeypatch.setattr(proxy_server, "llm_router", router) monkeypatch.setattr(proxy_server.proxy_config, "add_deployment", _apply_router_settings) - monkeypatch.setattr(proxy_server.proxy_config, "get_config", AsyncMock(return_value={})) + monkeypatch.setattr(proxy_server.proxy_config, "get_config", AsyncMock(return_value=ProxyRuntimeConfig.from_resolved({}))) posted = UpdateRouterConfig( retry_policy=RetryPolicy(