From de7bb21887a9ac9f0de11243901f163c7db75ef0 Mon Sep 17 00:00:00 2001 From: Mateo Wang <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:15:09 +0000 Subject: [PATCH] fix(credentials): preserve stored info when patching credentials (#49919) * fix(credentials): preserve stored info when patching credentials Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(credentials): type patch test parameters Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../proxy/credential_endpoints/endpoints.py | 2 -- .../test_credential_display_name.py | 3 +++ .../credential_endpoints/test_endpoints.py | 20 +++++++++++++++++-- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/credential_endpoints/endpoints.py b/litellm/proxy/credential_endpoints/endpoints.py index 5b3507f9b6d..86e716f19e3 100644 --- a/litellm/proxy/credential_endpoints/endpoints.py +++ b/litellm/proxy/credential_endpoints/endpoints.py @@ -920,8 +920,6 @@ def update_db_credential( # update model info if encrypted_credential.credential_info: """Update credential info""" - if "credential_info" not in merged_credential.credential_info: - merged_credential.credential_info = {} merged_credential.credential_info.update(encrypted_credential.credential_info) return merged_credential diff --git a/tests/integration/management/test_credential_display_name.py b/tests/integration/management/test_credential_display_name.py index 0e72dcf8b2f..f605be1d059 100644 --- a/tests/integration/management/test_credential_display_name.py +++ b/tests/integration/management/test_credential_display_name.py @@ -122,6 +122,9 @@ def test_patch_rename_is_rejected_and_model_keeps_working(gateway: Gateway) -> N "PATCH", f"/credentials/{name}", {"credential_name": name, "credential_info": {"description": "y"}} ) assert same_name.status_code == 200, same_name.text + assert read_rows( + 'SELECT credential_info FROM "LiteLLM_CredentialsTable" WHERE credential_name = %s', (name,) + ) == [{"credential_info": {"custom_llm_provider": "openai", "description": "y"}}] assert _served_credential(gateway, name)["credential_info"] == { "custom_llm_provider": "openai", "description": "y", diff --git a/tests/unit/proxy/credential_endpoints/test_endpoints.py b/tests/unit/proxy/credential_endpoints/test_endpoints.py index 3d8e92988d9..cdd18fd4eef 100644 --- a/tests/unit/proxy/credential_endpoints/test_endpoints.py +++ b/tests/unit/proxy/credential_endpoints/test_endpoints.py @@ -3166,18 +3166,34 @@ class TestCredentialDisplayName: repository.update_by_name.assert_not_awaited() assert litellm.credential_list == [_labeled_credential()] - def test_patch_accepts_the_same_credential_name_echoed_back(self, restore_credential_list, monkeypatch): + @pytest.mark.parametrize( + ("patch_info", "expected_info"), + [ + ({}, {"custom_llm_provider": "openai"}), + ({"description": "y"}, {"custom_llm_provider": "openai", "description": "y"}), + ({"custom_llm_provider": "anthropic"}, {"custom_llm_provider": "anthropic"}), + ], + ) + def test_patch_accepts_the_same_credential_name_echoed_back( + self, restore_credential_list, monkeypatch, patch_info: dict[str, str], expected_info: dict[str, str] + ): monkeypatch.setattr(litellm, "credential_list", [_labeled_credential()]) with _repository_holding(_labeled_credential()) as repository: response = _patch_credential( "openai-prod", - {"credential_name": "openai-prod", "credential_values": {"api_key": "sk-new"}, "credential_info": {}}, + { + "credential_name": "openai-prod", + "credential_values": {"api_key": "sk-new"}, + "credential_info": patch_info, + }, ) assert response.status_code == 200, response.text assert [(c.credential_name, c.credential_values) for c in litellm.credential_list] == [ ("openai-prod", {"api_key": "sk-new"}) ] + assert json.loads(repository.update_by_name.await_args.kwargs["data"]["credential_info"]) == expected_info + assert litellm.credential_list[0].credential_info == expected_info def test_patch_on_a_config_credential_is_rejected_as_config_owned(self, restore_credential_list, monkeypatch): config_credential = _labeled_credential(display_name=None).model_copy(update={"source": "config"})