diff --git a/litellm/proxy/auth/auth_utils.py b/litellm/proxy/auth/auth_utils.py index 3372145e66c..cbae3bdaa8b 100644 --- a/litellm/proxy/auth/auth_utils.py +++ b/litellm/proxy/auth/auth_utils.py @@ -371,6 +371,17 @@ _BANNED_REQUEST_BODY_PARAMS: Final[tuple[str, ...]] = ( ) +# The banned params that actually describe a CONNECTION — the tuple above minus +# the custom-pricing fields. Pricing fields are banned from a request body +# because they poison the shared model-cost registry, not because they retarget +# or re-authenticate the outbound call. A caller that needs to reason about +# "did this request bring its own connection?" must use this list; the full +# tuple would treat `input_cost_per_token` as a credential. +_CONNECTION_OVERRIDE_REQUEST_PARAMS: Final[tuple[str, ...]] = tuple( + param for param in _BANNED_REQUEST_BODY_PARAMS if param not in CustomPricingLiteLLMParams.model_fields +) + + def _check_banned_params( body: dict, general_settings: dict, diff --git a/litellm/proxy/health_endpoints/_health_endpoints.py b/litellm/proxy/health_endpoints/_health_endpoints.py index 64fd59bbe44..b03af69176e 100644 --- a/litellm/proxy/health_endpoints/_health_endpoints.py +++ b/litellm/proxy/health_endpoints/_health_endpoints.py @@ -40,7 +40,7 @@ from litellm.proxy.auth.auth_checks import ( _resolve_key_models_for_auth_check, # pyright: ignore[reportPrivateUsage] # the auth layer's sentinel resolution, reused so /health scopes exactly like a request ) from litellm.proxy.auth.auth_utils import ( - _BANNED_REQUEST_BODY_PARAMS, # pyright: ignore[reportPrivateUsage] # one canonical list, shared with the request-body check + _CONNECTION_OVERRIDE_REQUEST_PARAMS, # pyright: ignore[reportPrivateUsage] # one canonical list, shared with the request-body check ) from litellm.proxy.auth.model_checks import get_key_models from litellm.proxy.auth.user_api_key_auth import user_api_key_auth @@ -139,13 +139,21 @@ def _request_inherits_config_credentials( name is no name: ``load_credentials_from_list`` resolves nothing from it, so it must not cost the request the credentials it would otherwise be probed with. + + The trigger is ``_CONNECTION_OVERRIDE_REQUEST_PARAMS``, not the full banned + list: the custom-pricing fields are banned from a request body for a + different reason (they poison the shared model-cost registry) and say + nothing about which connection a test describes. Treating them as a + connection override empties the configuration under a request that only + named a model and its price, which reports a healthy deployment as + "Missing credentials". """ requested_credential: Final = request_params.get("litellm_credential_name") if requested_credential and requested_credential != config_params.get("litellm_credential_name"): return False if allow_client_side_credentials: return True - return not any(param in request_params for param in _BANNED_REQUEST_BODY_PARAMS) + return not any(param in request_params for param in _CONNECTION_OVERRIDE_REQUEST_PARAMS) def _config_base_for_health_check( 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 761cd0685f2..e330d628ac7 100644 --- a/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py +++ b/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py @@ -3680,6 +3680,50 @@ class TestConfigBaseForHealthCheck: assert "litellm_credential_name" not in base assert "api_key" not in base + @pytest.mark.parametrize( + "pricing_field,value", + [ + ("input_cost_per_token", 1e-9), + ("output_cost_per_token", 2e-9), + ("cache_read_input_token_cost", 5e-10), + ], + ) + def test_pricing_field_is_not_a_connection_override(self, pricing_field, value): + """Pricing fields are banned from a request body because they poison the + shared model-cost registry, not because they describe a connection. A + connection test that carries one still gets the configured credentials — + otherwise a healthy deployment reports "Missing credentials".""" + base = self._base(self.CONFIG, {"model": "openai/gpt-4o", pricing_field: value}) + assert base["api_key"] == "sk-configured" + assert base["api_base"] == "https://configured.example/v1" + + def test_pricing_field_alongside_a_real_override_still_drops_credentials(self): + """The pricing field is neutral, so the api_base beside it still decides.""" + base = self._base( + self.CONFIG, + {"api_base": "https://caller.example/v1", "input_cost_per_token": 1e-9}, + ) + assert "api_key" not in base + assert "sk-configured" not in str(base) + + def test_every_custom_pricing_field_is_excluded_from_the_connection_list(self): + from litellm.proxy.auth.auth_utils import _CONNECTION_OVERRIDE_REQUEST_PARAMS + from litellm.types.utils import CustomPricingLiteLLMParams + + connection_params = set(_CONNECTION_OVERRIDE_REQUEST_PARAMS) + for field in CustomPricingLiteLLMParams.model_fields: + assert field not in connection_params, ( + f"CustomPricingLiteLLMParams.{field} is treated as a connection override, " + "so a connection test that sets it loses the configured credentials." + ) + + def test_connection_list_keeps_the_real_credential_and_endpoint_fields(self): + from litellm.proxy.auth.auth_utils import _CONNECTION_OVERRIDE_REQUEST_PARAMS + + connection_params = set(_CONNECTION_OVERRIDE_REQUEST_PARAMS) + for field in ("api_base", "base_url", "azure_ad_token", "vertex_credentials", "user_config"): + assert field in connection_params + def test_stored_credential_reference_kept_when_request_sets_no_connection(self): """The Admin UI tests a configured model by naming it plus its stored credential and nothing else; that keeps working."""