diff --git a/litellm/proxy/management_endpoints/model_management_endpoints.py b/litellm/proxy/management_endpoints/model_management_endpoints.py index c71381f2402..0399d8a9b8b 100644 --- a/litellm/proxy/management_endpoints/model_management_endpoints.py +++ b/litellm/proxy/management_endpoints/model_management_endpoints.py @@ -386,14 +386,16 @@ def update_db_model(db_model: Deployment, updated_patch: updateDeployment) -> Pr # # A litellm_params field the caller explicitly sets to null is removed from the # stored params, so the UI can drop a param (e.g. reasoning_effort) instead of - # only overwriting it. The cross-blob mirror stays scoped to SPECIAL_MODEL_INFO_PARAMS - # (input/output cost per token/character and cache read/write costs), which - # Deployment.__init__ mirrors between litellm_params and model_info. The model_info - # loop below stays restricted to SPECIAL params so this path cannot null out - # privileged model_info fields like team_id or access groups. + # only overwriting it. `model` is exempt: it is required to reconstruct the + # deployment, so a null there is ignored rather than persisting a modelless row. + # The cross-blob mirror stays scoped to SPECIAL_MODEL_INFO_PARAMS (input/output + # cost per token/character and cache read/write costs), which Deployment.__init__ + # mirrors between litellm_params and model_info. The model_info loop below stays + # restricted to SPECIAL params so this path cannot null out privileged model_info + # fields like team_id or access groups. if updated_patch.litellm_params: for field in updated_patch.litellm_params.model_fields_set: - if getattr(updated_patch.litellm_params, field) is None: + if field != "model" and getattr(updated_patch.litellm_params, field) is None: merged_litellm_params.pop(field, None) if field in SPECIAL_MODEL_INFO_PARAMS: merged_model_info.pop(field, None) 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 8836e0d4cfc..670b583416e 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 @@ -3296,6 +3296,25 @@ class TestUpdateDBModelClearParam: info = json.loads(result["model_info"]) assert info.get("team_id") == "team-keep-me" + def test_null_model_field_is_not_cleared(self): + """`model` is required to rebuild the deployment, so an explicit null for it + must be ignored, not persisted as a modelless (invalid) row. Other nulled + params in the same patch are still cleared.""" + from litellm.proxy.management_endpoints.model_management_endpoints import ( + update_db_model, + ) + from litellm.types.router import updateLiteLLMParams + + result = update_db_model( + db_model=self._db_model(), + updated_patch=updateDeployment( + litellm_params=updateLiteLLMParams(model=None, reasoning_effort=None) + ), + ) + params = json.loads(result["litellm_params"]) + assert params.get("model") == "gpt-5.6-sol" + assert "reasoning_effort" not in params + class TestGetModelInfoWithIdBlocked: """`ProxyConfig.get_model_info_with_id` must propagate the DB-level `blocked` diff --git a/ui/litellm-dashboard/src/components/model_info_view.tsx b/ui/litellm-dashboard/src/components/model_info_view.tsx index 33be0b590f5..c9beec4be26 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.tsx @@ -504,9 +504,8 @@ export default function ModelInfoView({ return; } - // A param the user deleted from the editor is absent from updatedLitellmParams; - // send it as an explicit null so the backend clears it, since the PATCH merge is - // additive and would otherwise keep the stored value. + // The additive PATCH merge keeps omitted keys, so a deleted param needs an + // explicit null to actually clear on the backend. for (const removedKey of computeRemovedLitellmParamKeys( localModelData.litellm_params ?? {}, updatedLitellmParams,