From fa4a28eca081a20689ee66c36bda67828b715551 Mon Sep 17 00:00:00 2001 From: Mihidum Hettiyahandi <55163074+mihidumh@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:03:04 +1000 Subject: [PATCH 1/3] fix(health): don't treat a custom-pricing field as a connection override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /health/test_connection returns "Missing credentials. Please pass one of api_key, azure_ad_token, ..." for a perfectly healthy configured model, as soon as the request also carries any custom-pricing field. _config_base_for_health_check strips the configured connection fields when the request sets one of _BANNED_REQUEST_BODY_PARAMS, on the reasoning that such a request describes a connection of its own. That tuple ends with every CustomPricingLiteLLMParams field, which is correct for the request-body check it was built for — those fields are banned because they poison the shared model-cost registry — but they are not connection parameters. A test that names a configured model and its price therefore has the configuration emptied out from under it, litellm_credential_name included, and the probe fails with no credential at all. It lands on the Admin UI's Add Model wizard, where "Test Connection" sits next to the pricing fields that a model missing from the cost map has to have, so the two are filled in together and the button reports a working deployment as broken. Splits the connection-relevant subset out as _CONNECTION_OVERRIDE_REQUEST_PARAMS — the same tuple minus the pricing fields — and gates the health-check merge on that. The request-body check is unchanged, so pricing fields stay banned there. Measured against a configured azure deployment: {"model": ...} succeeds, {"model": ..., "input_cost_per_token": 1e-9} failed and now succeeds, and {"api_base": ..., "input_cost_per_token": 1e-9} still drops the configured credentials. Co-Authored-By: Claude Opus 5 --- litellm/proxy/auth/auth_utils.py | 11 +++++ .../health_endpoints/_health_endpoints.py | 12 ++++- .../health_endpoints/test_health_endpoints.py | 44 +++++++++++++++++++ 3 files changed, 65 insertions(+), 2 deletions(-) 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.""" From cee783d247624aa5bf2e10ec84ff467182900332 Mon Sep 17 00:00:00 2001 From: Mihidum Hettiyahandi <55163074+mihidumh@users.noreply.github.com> Date: Thu, 17 Sep 2026 08:30:34 +1000 Subject: [PATCH 2/3] refactor(health): derive the connection-override list next to its only reader Move _CONNECTION_OVERRIDE_REQUEST_PARAMS from auth_utils into _health_endpoints, which is its only consumer. The health module keeps importing _BANNED_REQUEST_BODY_PARAMS exactly as the base does, so the PR adds no new cross-module private import and auth_utils carries no unused global (both flagged by CodeQL on the previous head). --- litellm/proxy/auth/auth_utils.py | 11 ----------- litellm/proxy/health_endpoints/_health_endpoints.py | 12 +++++++++++- .../proxy/health_endpoints/test_health_endpoints.py | 4 ++-- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/litellm/proxy/auth/auth_utils.py b/litellm/proxy/auth/auth_utils.py index cbae3bdaa8b..3372145e66c 100644 --- a/litellm/proxy/auth/auth_utils.py +++ b/litellm/proxy/auth/auth_utils.py @@ -371,17 +371,6 @@ _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 b03af69176e..b7952256962 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 ( - _CONNECTION_OVERRIDE_REQUEST_PARAMS, # pyright: ignore[reportPrivateUsage] # one canonical list, shared with the request-body check + _BANNED_REQUEST_BODY_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 @@ -72,6 +72,7 @@ from litellm.router_utils.clientside_credential_handler import ( clientside_credential_keys, ) from litellm.secret_managers.main import get_secret_bool +from litellm.types.utils import CustomPricingLiteLLMParams #### Health ENDPOINTS #### @@ -124,6 +125,15 @@ _CONFIG_CONNECTION_FIELDS: Final[frozenset[str]] = frozenset( ) ) +# The banned request-body params that actually describe a CONNECTION — the +# banned list 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, so the full list 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 _request_inherits_config_credentials( config_params: Mapping[str, object], 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 e330d628ac7..6f8c2022455 100644 --- a/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py +++ b/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py @@ -3707,7 +3707,7 @@ class TestConfigBaseForHealthCheck: 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.proxy.health_endpoints._health_endpoints import _CONNECTION_OVERRIDE_REQUEST_PARAMS from litellm.types.utils import CustomPricingLiteLLMParams connection_params = set(_CONNECTION_OVERRIDE_REQUEST_PARAMS) @@ -3718,7 +3718,7 @@ class TestConfigBaseForHealthCheck: ) def test_connection_list_keeps_the_real_credential_and_endpoint_fields(self): - from litellm.proxy.auth.auth_utils import _CONNECTION_OVERRIDE_REQUEST_PARAMS + from litellm.proxy.health_endpoints._health_endpoints 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"): From 9e36c2752f7dd979752b1a8b75e5826b39a3679c Mon Sep 17 00:00:00 2001 From: Mihidum Hettiyahandi <55163074+mihidumh@users.noreply.github.com> Date: Thu, 17 Sep 2026 09:57:00 +1000 Subject: [PATCH 3/3] fix(health): probe test_connection without custom-pricing fields completion() registers request pricing under the shared {provider}/{model} cost-map key when the call carries no router deployment id, which a connection test never does. A probe that carried input/output_cost_per_token (from the request, or from the configured deployment) therefore re-priced every sibling deployment of the same backend model. A connection test needs no prices, so strip CustomPricingLiteLLMParams fields from the probe params. Two end-to-end tests fail without this change. --- .../health_endpoints/_health_endpoints.py | 8 ++- .../health_endpoints/test_health_endpoints.py | 72 +++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/health_endpoints/_health_endpoints.py b/litellm/proxy/health_endpoints/_health_endpoints.py index b7952256962..60640d286de 100644 --- a/litellm/proxy/health_endpoints/_health_endpoints.py +++ b/litellm/proxy/health_endpoints/_health_endpoints.py @@ -2210,10 +2210,16 @@ async def test_model_connection( premium_user=premium_user, ) mode = mode or litellm_params.pop("mode", None) + # A connection test needs no prices, and ``completion`` registers any + # request pricing under the shared ``{provider}/{model}`` cost-map key + # (a probe carries no router deployment id), which would re-price every + # sibling deployment of the same backend model. Probe without them, + # whether they came from the request or from the configuration. + probe_params: Final = CustomPricingLiteLLMParams.strip_custom_pricing_fields(litellm_params) result: Final = await run_with_timeout( litellm.ahealth_check( - model_params=litellm_params, + model_params=probe_params, mode=mode, prompt="test from litellm", input=["test from litellm"], 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 6f8c2022455..25785173943 100644 --- a/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py +++ b/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py @@ -3902,6 +3902,78 @@ class TestTestConnectionUsesTheNamedCredential: assert probe.headers["authorization"] == f"Bearer {self.CREDENTIAL_KEY}" +class TestTestConnectionLeavesSharedPricingAlone: + """A connection test must not write prices into ``litellm.model_cost``: a probe + carries no router deployment id, so ``completion`` would register them under + the shared ``{provider}/{model}`` key that sibling deployments price from.""" + + MODEL = "openai/test-connection-pricing-probe" + SHARED_KEY = MODEL + CONFIG_KEY = "sk-configured" + COMPLETION = TestTestConnectionUsesTheNamedCredential.COMPLETION + + def _probe(self, monkeypatch, config_litellm_params: dict, request_litellm_params: dict) -> httpx.Request: + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + monkeypatch.setattr(litellm, "model_cost", dict(litellm.model_cost)) + litellm.in_memory_llm_clients_cache.flush_cache() + + app = FastAPI() + app.include_router(_health_endpoints_module.router) + app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN) + + router = MagicMock() + router.get_deployment.return_value = None + router.get_model_list.return_value = [ + {"model_name": "pricing-probe", "litellm_params": config_litellm_params, "model_info": {}} + ] + + with ( + patch( # test-quality-ok: the endpoint reads the proxy-global DB client and 500s when it is None; it has no injection seam + "litellm.proxy.proxy_server.prisma_client", MagicMock() + ), + patch( # test-quality-ok: the deployment the probe is matched against is a proxy global; it has no injection seam + "litellm.proxy.proxy_server.llm_router", router + ), + respx.mock(assert_all_called=True) as respx_mock, + ): + respx_mock.post(path__regex=r".*/chat/completions").respond(json=self.COMPLETION) + response = TestClient(app).post( + "/health/test_connection", + json={"mode": "chat", "litellm_params": request_litellm_params, "model_info": {"mode": "chat"}}, + ) + probe = respx_mock.calls.last.request + + assert response.status_code == 200, response.text + assert response.json()["status"] == "success", response.text + return probe + + def test_request_pricing_is_not_registered_under_the_shared_key(self, monkeypatch): + probe = self._probe( + monkeypatch, + {"model": self.MODEL, "api_key": self.CONFIG_KEY, "api_base": "https://configured.example/v1"}, + {"model": self.MODEL, "input_cost_per_token": 1.0, "output_cost_per_token": 2.0}, + ) + + assert probe.headers["authorization"] == f"Bearer {self.CONFIG_KEY}" + assert self.SHARED_KEY not in litellm.model_cost + + def test_configured_pricing_is_not_registered_under_the_shared_key(self, monkeypatch): + probe = self._probe( + monkeypatch, + { + "model": self.MODEL, + "api_key": self.CONFIG_KEY, + "api_base": "https://configured.example/v1", + "input_cost_per_token": 3.0, + "output_cost_per_token": 4.0, + }, + {"model": self.MODEL}, + ) + + assert probe.url.host == "configured.example" + assert self.SHARED_KEY not in litellm.model_cost + + class TestNoRedisWarning: """`show_no_redis_warning` drives the Admin UI's default-on "no Redis" banner."""