From 177021b2acc3283a1d3d530011e48b082d4146fc Mon Sep 17 00:00:00 2001 From: yuneng Date: Mon, 21 Sep 2026 19:19:14 +0000 Subject: [PATCH] fix(proxy): gate explicit credential detach only on PATCH /model/{id}/update Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../model_management_endpoints.py | 5 ++ .../test_model_management_endpoints.py | 70 +++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/litellm/proxy/management_endpoints/model_management_endpoints.py b/litellm/proxy/management_endpoints/model_management_endpoints.py index a8242904636..47e617a20af 100644 --- a/litellm/proxy/management_endpoints/model_management_endpoints.py +++ b/litellm/proxy/management_endpoints/model_management_endpoints.py @@ -1133,6 +1133,7 @@ async def patch_model( litellm_params=patch_data.litellm_params, user_api_key_dict=user_api_key_dict, existing_litellm_params=db_model.litellm_params, + null_detaches=True, ) _raise_on_invalid_credential_name(patch_data.litellm_params) @@ -1944,11 +1945,15 @@ class ModelManagementAuthChecks: litellm_params: GenericLiteLLMParams | None, user_api_key_dict: UserAPIKeyAuth, existing_litellm_params: GenericLiteLLMParams | None = None, + *, + null_detaches: bool = False, ) -> Literal[True]: if litellm_params is None: return True if "litellm_credential_name" not in litellm_params.model_fields_set: return True + if litellm_params.litellm_credential_name is None and not null_detaches: + return True existing_credential_name: Final = ( decrypt_value_helper( value=existing_litellm_params.litellm_credential_name, diff --git a/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py index 72db04dfbaa..b6b68d28880 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py @@ -319,6 +319,7 @@ class TestModelManagementAuthChecks: existing_litellm_params=LiteLLM_Params( model="test_model", litellm_credential_name="shared-credential" ), + null_detaches=True, ) assert exc_info.value.code == "403" @@ -333,6 +334,7 @@ class TestModelManagementAuthChecks: existing_litellm_params=LiteLLM_Params( model="test_model", litellm_credential_name="shared-credential" ), + null_detaches=True, ) assert result is True @@ -344,6 +346,20 @@ class TestModelManagementAuthChecks: litellm_params=litellm_params(litellm_credential_name=None), user_api_key_dict=self.team_admin_user, existing_litellm_params=LiteLLM_Params(model="test_model"), + null_detaches=True, + ) + + assert result is True + + def test_can_user_attach_credential_null_is_noop_when_null_does_not_detach(self): + from litellm.types.router import updateLiteLLMParams as litellm_params + + result = ModelManagementAuthChecks.can_user_attach_credential( + litellm_params=litellm_params(litellm_credential_name=None), + user_api_key_dict=self.team_admin_user, + existing_litellm_params=LiteLLM_Params( + model="test_model", litellm_credential_name="shared-credential" + ), ) assert result is True @@ -1289,6 +1305,60 @@ class TestUpdateModel: mock_prisma.db.litellm_proxymodeltable.update.assert_awaited_once() mock_clear_cache.assert_awaited_once_with() + @pytest.mark.asyncio + async def test_update_model_legacy_null_credential_name_is_not_a_detach_for_non_admin(self): + from litellm.proxy.management_endpoints.model_management_endpoints import update_model + + model_id = "legacy-null-credential" + existing = Deployment( + model_name="legacy-model", + litellm_params=LiteLLM_Params(model="openai/gpt-4o-mini", litellm_credential_name="shared-credential"), + model_info={"id": model_id}, + ) + existing_row = MagicMock() + existing_row.litellm_params = existing.litellm_params.model_dump() + existing_row.model_dump.return_value = existing.model_dump() + updated_row = MagicMock() + updated_row.model_dump_json.return_value = "{}" + mock_prisma = MagicMock() + mock_prisma.db.litellm_proxymodeltable.find_unique = AsyncMock(return_value=existing_row) + mock_prisma.db.litellm_proxymodeltable.update = AsyncMock(return_value=updated_row) + mock_router = MagicMock() + mock_router.get_model_ids.return_value = [model_id] + team_admin = UserAPIKeyAuth(user_id="team-admin", user_role=LitellmUserRoles.INTERNAL_USER) + + with ( + patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), + patch("litellm.proxy.proxy_server.llm_router", mock_router), + patch("litellm.proxy.proxy_server.store_model_in_db", True), + patch("litellm.proxy.proxy_server.premium_user", True), + patch( + "litellm.proxy.management_endpoints.model_management_endpoints.ModelManagementAuthChecks.can_user_make_model_call", + new=AsyncMock(return_value=None), + ), + patch( + "litellm.proxy.management_endpoints.model_management_endpoints.encrypt_value_helper", + side_effect=lambda value: value, + ), + patch( + "litellm.proxy.management_endpoints.model_management_endpoints.clear_cache", + new=AsyncMock(return_value=ReconcileOutcome(still_desired=None, live_after=None)), + ), + ): + await update_model( + model_params=updateDeployment( + litellm_params=updateLiteLLMParams( + model="openai/gpt-4o-mini", litellm_credential_name=None + ), + model_info=ModelInfo(id=model_id), + ), + user_api_key_dict=team_admin, + ) + + mock_prisma.db.litellm_proxymodeltable.update.assert_awaited_once() + persisted = json.loads(mock_prisma.db.litellm_proxymodeltable.update.await_args.kwargs["data"]["litellm_params"]) + assert persisted["litellm_credential_name"] == "shared-credential" + class TestUpdatePublicModelGroups: """Test that update_public_model_groups correctly sets litellm.public_model_groups