From 2cf2e037c679c040fb251256d9bff994a8b36f77 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 5 Aug 2026 15:48:16 -0700 Subject: [PATCH] fix(health): stop inheriting configured credentials when a connection test sets its own A request that supplies its own connection fields describes a connection of its own, so the configured deployment's credentials are no longer merged underneath it. Anything the request leaves unset still comes from the configuration, so naming a configured model and testing it as configured is unchanged, and adding a second deployment for an already-configured name works as before. Replaces the earlier outright rejection, which also refused requests that supplied a complete connection of their own. (cherry picked from commit b468acb31c68b63584736f57a6f07f4fde2f8d6f) --- .../health_endpoints/_health_endpoints.py | 46 +++++++----- .../health_endpoints/test_health_endpoints.py | 73 ++++++++++++++----- 2 files changed, 79 insertions(+), 40 deletions(-) diff --git a/litellm/proxy/health_endpoints/_health_endpoints.py b/litellm/proxy/health_endpoints/_health_endpoints.py index 64a47d5fe81..4d7e164136d 100644 --- a/litellm/proxy/health_endpoints/_health_endpoints.py +++ b/litellm/proxy/health_endpoints/_health_endpoints.py @@ -9,6 +9,7 @@ from datetime import datetime, timedelta from typing import ( Any, Dict, + Final, Iterable, Literal, Mapping, @@ -55,6 +56,10 @@ from litellm.proxy.middleware.in_flight_requests_middleware import ( get_in_flight_requests, ) from litellm.proxy.shutdown.graceful_shutdown_manager import GracefulShutdownManager +from litellm.router_utils.clientside_credential_handler import ( + _ADMIN_CONFIG_FIELDS_TO_CLEAR_ON_BASE_OVERRIDE, # pyright: ignore[reportPrivateUsage] # one canonical list, shared with the router path + clientside_credential_keys, +) #### Health ENDPOINTS #### @@ -92,25 +97,25 @@ def _reject_os_environ_references(params: dict) -> None: stack.append(value) -def _reject_banned_param_overrides(request_params: Mapping[str, object]) -> None: - """Reject request params that would replace a configured deployment's routing or credentials. +_CONFIG_CONNECTION_FIELDS: Final[frozenset[str]] = frozenset( + (*_ADMIN_CONFIG_FIELDS_TO_CLEAR_ON_BASE_OVERRIDE, *clientside_credential_keys) +) - Applied only when a configured deployment supplies the base parameters. The - request may still adjust benign fields; routing and credential fields come - from the configuration. A caller who wants a fully custom connection supplies - the complete parameter set instead of naming a configured model. + +def _config_base_for_health_check( + config_params: Mapping[str, object], request_params: Mapping[str, object] +) -> dict[str, object]: + """Return the configured parameters to merge under a connection-test request. + + A request that sets its own connection fields describes a connection of its + own, so the configuration's credentials are not carried into it: they belong + to the endpoint the configuration names. Anything the request does not set + still comes from the configuration, which is what lets a request name a + configured model and test it as configured. """ - for param in _BANNED_REQUEST_BODY_PARAMS: - if param in request_params: - raise HTTPException( - status_code=400, - detail={ - "error": ( - f"{param} cannot be overridden when testing a configured model. " - "Provide the full connection parameters instead of naming a configured model." - ) - }, - ) + if not any(param in request_params for param in _BANNED_REQUEST_BODY_PARAMS): + return dict(config_params) + return {key: value for key, value in config_params.items() if key not in _CONFIG_CONNECTION_FIELDS} def get_callback_identifier(callback): @@ -1894,7 +1899,10 @@ async def test_model_connection( ) # Merge: config params (from proxy config) as base, request params override - litellm_params = {**config_litellm_params, **request_litellm_params} + litellm_params = { + **_config_base_for_health_check(config_litellm_params, request_litellm_params), + **request_litellm_params, + } ## Auth check auth_model_info = loaded_model_info if loaded_model_info is not None else model_info @@ -1908,8 +1916,6 @@ async def test_model_connection( prisma_client=prisma_client, premium_user=premium_user, ) - if config_litellm_params: - _reject_banned_param_overrides(request_litellm_params) # Include health_check_params if provided litellm_params = _update_litellm_params_for_health_check( model_info={}, diff --git a/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py b/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py index 7280abd8ea5..21a3e80de02 100644 --- a/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py +++ b/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py @@ -2366,30 +2366,63 @@ def test_clean_endpoint_data_strips_credentials_keeps_routing_fields(): assert cleaned.get("api_version") == "2024-10-21" -class TestRejectBannedParamOverrides: - """Routing and credential fields come from the deployment configuration when a - request names a configured model; a request that wants its own connection - supplies the whole parameter set instead.""" +class TestConfigBaseForHealthCheck: + """A request that sets its own connection fields gets a base without the + configuration's credentials; anything it leaves unset still comes from + the configuration.""" - def test_banned_param_is_refused(self): - from fastapi import HTTPException + CONFIG = { + "model": "openai/gpt-4o", + "api_key": "sk-configured", + "api_base": "https://configured.example/v1", + "vertex_credentials": "configured-creds", + "rpm": 100, + } + def _base(self, config, request): from litellm.proxy.health_endpoints._health_endpoints import ( - _reject_banned_param_overrides, + _config_base_for_health_check, ) - for param in ("api_base", "base_url", "vertex_credentials", "aws_web_identity_token"): - with pytest.raises(HTTPException) as exc_info: - _reject_banned_param_overrides({"model": "gpt-4o", param: "caller-supplied"}) - assert exc_info.value.status_code == 400 - assert param in str(exc_info.value.detail) + return _config_base_for_health_check(config, request) - def test_benign_params_are_allowed(self): - from litellm.proxy.health_endpoints._health_endpoints import ( - _reject_banned_param_overrides, + def test_request_without_connection_fields_inherits_config(self): + base = self._base(self.CONFIG, {"model": "openai/gpt-4o"}) + assert base["api_key"] == "sk-configured" + assert base["api_base"] == "https://configured.example/v1" + + def test_request_setting_api_base_does_not_inherit_config_credentials(self): + base = self._base(self.CONFIG, {"api_base": "https://caller.example/v1"}) + assert "api_key" not in base + assert "api_base" not in base + assert "vertex_credentials" not in base + assert base["rpm"] == 100 + + def test_add_model_flow_keeps_its_own_credentials(self): + """Adding a second deployment for an already-configured name sends a + complete connection; it is tested as sent, not as configured.""" + request = { + "model": "openai/gpt-4o", + "api_base": "https://new-deployment.example/v1", + "api_key": "sk-new-deployment", + } + merged = {**self._base(self.CONFIG, request), **request} + assert merged["api_base"] == "https://new-deployment.example/v1" + assert merged["api_key"] == "sk-new-deployment" + assert "sk-configured" not in str(merged) + + def test_destination_override_without_own_key_inherits_no_credential(self): + """A request that redirects the destination but supplies no credential + of its own gets none from the configuration.""" + request = {"api_base": "https://elsewhere.example"} + merged = {**self._base(self.CONFIG, request), **request} + assert "api_key" not in merged + assert "sk-configured" not in str(merged) + + def test_non_api_base_destination_field_also_drops_credentials(self): + base = self._base( + {**self.CONFIG, "aws_secret_access_key": "configured-secret"}, + {"aws_bedrock_runtime_endpoint": "https://caller.example"}, ) - - _reject_banned_param_overrides({}) - _reject_banned_param_overrides({"model": "gpt-4o"}) - _reject_banned_param_overrides({"model": "gpt-4o", "api_key": "sk-caller-owned"}) - _reject_banned_param_overrides({"mode": "chat", "timeout": 30}) + assert "api_key" not in base + assert "aws_secret_access_key" not in base