From 38f6c7f83f4754144382ba6377d00cce5e63c738 Mon Sep 17 00:00:00 2001 From: Genmin Date: Thu, 30 Apr 2026 11:29:53 -0700 Subject: [PATCH] fix: preserve router deployment credentials on none overrides --- litellm/router.py | 20 +++++ .../clientside_credential_handler.py | 11 ++- tests/test_litellm/test_router.py | 83 +++++++++++++++++++ 3 files changed, 110 insertions(+), 4 deletions(-) diff --git a/litellm/router.py b/litellm/router.py index 50fd7eaed0b..54c920ad3d1 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -90,6 +90,7 @@ from litellm.router_utils.batch_utils import ( ) from litellm.router_utils.client_initalization_utils import InitalizeCachedClient from litellm.router_utils.clientside_credential_handler import ( + clientside_credential_keys, get_dynamic_litellm_params, is_clientside_credential, ) @@ -2365,6 +2366,22 @@ class Router: if "tool_choice" not in kwargs and dep_params.get("tool_choice") is not None: kwargs["tool_choice"] = dep_params["tool_choice"] + @staticmethod + def _drop_none_clientside_credentials_from_kwargs( + deployment: dict, kwargs: dict + ) -> None: + """ + Preserve deployment credentials when wrappers pass explicit None values. + + Some SDK wrappers include fields like api_base=None in every call. Treat + those as absent, otherwise the request-level merge would erase a + configured deployment endpoint/key. + """ + dep_params = deployment.get("litellm_params", {}) or {} + for key in clientside_credential_keys: + if kwargs.get(key) is None and dep_params.get(key) is not None: + kwargs.pop(key, None) + def _update_kwargs_with_deployment( self, deployment: dict, @@ -2378,6 +2395,9 @@ class Router: - Merges tools from deployment with request (proxy-configured tools + request tools). """ self._merge_tools_from_deployment(deployment=deployment, kwargs=kwargs) + self._drop_none_clientside_credentials_from_kwargs( + deployment=deployment, kwargs=kwargs + ) model_info = deployment.get("model_info", {}).copy() deployment_litellm_model_name = deployment["litellm_params"]["model"] diff --git a/litellm/router_utils/clientside_credential_handler.py b/litellm/router_utils/clientside_credential_handler.py index 45ade81b2dd..9d5eb7c9296 100644 --- a/litellm/router_utils/clientside_credential_handler.py +++ b/litellm/router_utils/clientside_credential_handler.py @@ -69,7 +69,7 @@ def is_clientside_credential(request_kwargs: dict) -> bool: """ Check if the credential is a clientside credential. """ - return any(key in request_kwargs for key in clientside_credential_keys) + return any(request_kwargs.get(key) is not None for key in clientside_credential_keys) def get_dynamic_litellm_params(litellm_params: dict, request_kwargs: dict) -> dict: @@ -83,7 +83,7 @@ def get_dynamic_litellm_params(litellm_params: dict, request_kwargs: dict) -> di """ # update litellm_params with clientside credentials for key in clientside_credential_keys: - if key in request_kwargs: + if request_kwargs.get(key) is not None: litellm_params[key] = request_kwargs[key] # If the caller redirected api_base/base_url to a client-controlled value, @@ -95,10 +95,13 @@ def get_dynamic_litellm_params(litellm_params: dict, request_kwargs: dict) -> di # field name (with any value, including an empty string) to keep the # admin's value in ``litellm_params`` and have it forwarded to the # redirected upstream. - if "api_base" in request_kwargs or "base_url" in request_kwargs: + if ( + request_kwargs.get("api_base") is not None + or request_kwargs.get("base_url") is not None + ): for field in _ADMIN_CONFIG_FIELDS_TO_CLEAR_ON_BASE_OVERRIDE: litellm_params.pop(field, None) - if field in request_kwargs: + if request_kwargs.get(field) is not None: litellm_params[field] = request_kwargs[field] return litellm_params diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index 4df8003338c..f8d29660169 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -54,6 +54,89 @@ def test_update_kwargs_does_not_mutate_defaults_and_merges_metadata(): assert kwargs["litellm_metadata"] == {"baz": 123} +@pytest.mark.asyncio +async def test_router_preserves_deployment_credentials_when_request_values_are_none(): + router = litellm.Router( + model_list=[ + { + "model_name": "GPT-5.4", + "litellm_params": { + "model": "azure/gpt-5.4", + "api_base": "https://example-resource.cognitiveservices.azure.com", + "api_key": "test-key", + "api_version": "2025-04-01-preview", + "base_model": "gpt-5.4", + }, + } + ], + ) + + with patch("litellm.acompletion", new_callable=AsyncMock) as mock_acompletion: + mock_acompletion.return_value = {"choices": []} + + await router.acompletion( + model="GPT-5.4", + messages=[{"role": "user", "content": "hi"}], + api_base=None, + api_key=None, + reasoning_effort="medium", + tools=[ + { + "type": "function", + "function": { + "name": "search", + "description": "Search for information.", + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string"}, + }, + "required": ["query"], + }, + }, + } + ], + ) + + call_kwargs = mock_acompletion.call_args.kwargs + assert ( + call_kwargs["api_base"] + == "https://example-resource.cognitiveservices.azure.com" + ) + assert call_kwargs["api_key"] == "test-key" + assert call_kwargs["api_version"] == "2025-04-01-preview" + + +def test_router_keeps_non_none_request_credentials_as_clientside_override(): + router = litellm.Router( + model_list=[ + { + "model_name": "GPT-5.4", + "litellm_params": { + "model": "azure/gpt-5.4", + "api_base": "https://deployment-resource.cognitiveservices.azure.com", + "api_key": "deployment-key", + }, + } + ], + ) + + with patch("litellm.completion", return_value={"choices": []}) as mock_completion: + router.completion( + model="GPT-5.4", + messages=[{"role": "user", "content": "hi"}], + api_base="https://request-resource.cognitiveservices.azure.com", + api_key="request-key", + ) + + call_kwargs = mock_completion.call_args.kwargs + assert ( + call_kwargs["api_base"] + == "https://request-resource.cognitiveservices.azure.com" + ) + assert call_kwargs["api_key"] == "request-key" + + def test_router_with_model_info_and_model_group(): """ Test edge case where user specifies model_group in model_info