diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index 0349f289b4..dd2b5aea7c 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -100,6 +100,15 @@ class UISettings(BaseModel): description="If enabled, forwards client headers (e.g. Authorization) to the LLM API. Required for Claude Code with Max subscription.", ) + forward_llm_provider_auth_headers: bool = Field( + default=False, + description=( + "If enabled, forwards LLM provider auth headers (x-api-key, " + "x-goog-api-key, api-key, ocp-apim-subscription-key) to the upstream " + "provider. Required for Claude Code BYOK (bring-your-own-key) flows." + ), + ) + enable_projects_ui: bool = Field( default=False, description="If enabled, shows the Projects feature in the UI sidebar and the project field in key management.", @@ -149,6 +158,7 @@ ALLOWED_UI_SETTINGS_FIELDS = { "enabled_ui_pages_internal_users", "require_auth_for_public_ai_hub", "forward_client_headers_to_llm_api", + "forward_llm_provider_auth_headers", "enable_projects_ui", "disable_agents_for_internal_users", "allow_agents_for_team_admins", @@ -162,6 +172,7 @@ ALLOWED_UI_SETTINGS_FIELDS = { # general_settings at runtime (on both read and write). _RUNTIME_GENERAL_SETTINGS_FLAGS = [ "forward_client_headers_to_llm_api", + "forward_llm_provider_auth_headers", "disable_agents_for_internal_users", "allow_agents_for_team_admins", "disable_vector_stores_for_internal_users", 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 bd9968ae93..f82017b14a 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 @@ -1016,6 +1016,83 @@ class TestProxySettingEndpoints: assert "unsupported_flag" not in stored_settings assert stored_settings["disable_model_add_for_internal_users"] is False + def test_update_ui_settings_persists_forward_llm_provider_auth_headers( + self, mock_auth, monkeypatch + ): + """BYOK flag must be allowlisted and persisted to litellm_uisettings.""" + from unittest.mock import AsyncMock, MagicMock + + from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + + mock_user_auth = UserAPIKeyAuth( + user_id="test-user-123", + user_role=LitellmUserRoles.PROXY_ADMIN, + ) + app.dependency_overrides[user_api_key_auth] = lambda: mock_user_auth + + monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True) + mock_prisma = MagicMock() + mock_prisma.db.litellm_uisettings.upsert = AsyncMock() + mock_prisma.db.litellm_uisettings.find_unique = AsyncMock(return_value=None) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma) + + payload = {"forward_llm_provider_auth_headers": True} + + try: + response = client.patch("/update/ui_settings", json=payload) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + assert data["settings"]["forward_llm_provider_auth_headers"] is True + + assert mock_prisma.db.litellm_uisettings.upsert.called + call_args = mock_prisma.db.litellm_uisettings.upsert.call_args + create_data = call_args.kwargs["data"]["create"] + stored_settings = json.loads(create_data["ui_settings"]) + assert stored_settings["forward_llm_provider_auth_headers"] is True + + def test_update_ui_settings_syncs_forward_llm_provider_auth_headers_to_general_settings( + self, mock_auth, monkeypatch + ): + """BYOK flag must be synced into general_settings dict so the request path sees it.""" + from unittest.mock import AsyncMock, MagicMock + + from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + + mock_user_auth = UserAPIKeyAuth( + user_id="test-user-123", + user_role=LitellmUserRoles.PROXY_ADMIN, + ) + app.dependency_overrides[user_api_key_auth] = lambda: mock_user_auth + + monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True) + + # Reset general_settings so the test is hermetic + general_settings: dict = {} + monkeypatch.setattr( + "litellm.proxy.proxy_server.general_settings", general_settings + ) + + mock_prisma = MagicMock() + mock_prisma.db.litellm_uisettings.upsert = AsyncMock() + mock_prisma.db.litellm_uisettings.find_unique = AsyncMock(return_value=None) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma) + + payload = {"forward_llm_provider_auth_headers": True} + + try: + response = client.patch("/update/ui_settings", json=payload) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 200 + assert general_settings.get("forward_llm_provider_auth_headers") is True + def test_get_sso_settings_from_database(self, mock_proxy_config, mock_auth, monkeypatch): """Test getting SSO settings from the dedicated database table""" import json