fix: preserve router deployment credentials on none overrides

This commit is contained in:
Genmin 2026-04-30 11:29:53 -07:00
parent 3583ac1159
commit 38f6c7f83f
3 changed files with 110 additions and 4 deletions

View file

@ -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"]

View file

@ -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

View file

@ -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