From 49a2f2f66323766cd0dd3006d75dfae2da3e1c02 Mon Sep 17 00:00:00 2001 From: Maxime Boudier Date: Fri, 16 Oct 2026 17:10:47 +0200 Subject: [PATCH] fix(proxy): persist sent model_info fields on POST /model/update POST /model/update rebuilt litellm_params from the request but dropped the request's model_info, so changing base_model (or mode, tier, etc.) on an existing DB model was silently ignored. Clients that only use this endpoint, like the Terraform provider, could never fix a deployment's base_model without deleting and recreating it. Persist the model_info fields the caller actually sent, on top of the stored ones. ModelInfo defaults never overwrite stored values, server-owned, team, PTU and pricing fields stay on their dedicated write paths, and values echoed from a /model/info response are not pinned. --- .../model_management_endpoints.py | 60 ++++++++++++- .../test_model_management_endpoints.py | 86 +++++++++++++++++++ 2 files changed, 142 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/management_endpoints/model_management_endpoints.py b/litellm/proxy/management_endpoints/model_management_endpoints.py index 36071bfe51a..e73856708d4 100644 --- a/litellm/proxy/management_endpoints/model_management_endpoints.py +++ b/litellm/proxy/management_endpoints/model_management_endpoints.py @@ -1055,6 +1055,52 @@ def _loaded_catalog_entry( return loaded_catalog().get(catalog_key, MappingProxyType({})) +_LEGACY_UPDATE_IGNORED_MODEL_INFO_FIELDS: Final = frozenset( + { + "id", + "db_model", + "created_at", + "created_by", + "updated_at", + "updated_by", + "blocked", + "member_auto_router", + "team_id", + "team_public_model_name", + COST_MAP_LOOKUP_KEY, + *PTU_MODEL_INFO_FIELDS, + } +) + + +def legacy_update_model_info_changes( + db_model: Deployment, + incoming_model_info: ModelInfo, + loaded_catalog: LoadedCatalog = GetModelCostMap.loaded_model_cost_map, +) -> Mapping[str, object]: + """The ``model_info`` fields ``POST /model/update`` persists from its request. + + Only fields the caller sent count, so ``ModelInfo`` defaults never overwrite stored + values. Server-owned, team, PTU and pricing fields keep their dedicated write paths, + and values echoed from a ``/model/info`` response stay display-only. + """ + sent: Final = { + k: v + for k, v in incoming_model_info.model_dump(exclude_unset=True, exclude_none=True).items() + if k not in _LEGACY_UPDATE_IGNORED_MODEL_INFO_FIELDS + } + echoed: Final = frozenset( + echoed_cost_map_fields( + incoming_model_info.model_dump(exclude_none=True), + _cost_map_entry(db_model, sent), + _loaded_catalog_entry(incoming_model_info.model_dump(exclude_none=True), loaded_catalog), + ) + ) + return MappingProxyType( + {k: getattr(incoming_model_info, k) for k in without_server_derived_pricing(sent) if k not in echoed} + ) + + def update_db_model( db_model: Deployment, updated_patch: updateDeployment, @@ -2857,14 +2903,20 @@ async def update_model( renamed_update: Final[PrismaCompatibleUpdateDBModel] = ( {**base_update, "model_name": renamed_to} if renamed_to is not None else base_update ) + model_info_changes: Final = MappingProxyType( + { + **legacy_update_model_info_changes(deployment, _model_info), + **({"member_auto_router": member_marker} if member_marker is not None else {}), + } + ) _data: Final[PrismaCompatibleUpdateDBModel] = ( { **renamed_update, - "model_info": deployment.model_info.model_copy( - update=MappingProxyType({"member_auto_router": member_marker}) - ).model_dump_json(exclude_none=True), + "model_info": deployment.model_info.model_copy(update=model_info_changes).model_dump_json( + exclude_none=True + ), } - if member_marker is not None + if model_info_changes else renamed_update ) async with _auto_router_capability_slot( diff --git a/tests/unit/proxy/management_endpoints/test_model_management_endpoints.py b/tests/unit/proxy/management_endpoints/test_model_management_endpoints.py index beffe454b8d..a37a0638557 100644 --- a/tests/unit/proxy/management_endpoints/test_model_management_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_model_management_endpoints.py @@ -1384,6 +1384,92 @@ class TestUpdateModel: Tests for the update_model (POST /model/update) handler. """ + @staticmethod + async def _persisted_model_info(stored_model_info: Mapping[str, object], model_info: ModelInfo) -> object: + from litellm.proxy.management_endpoints.model_management_endpoints import update_model + + existing_row = MagicMock() + existing_row.litellm_params = {"model": "azure/my-deployment", "api_key": "sk-existing"} + existing_row.model_dump.return_value = { + "model_name": "my-model", + "litellm_params": existing_row.litellm_params, + "model_info": dict(stored_model_info), + } + existing_row.model_dump_json.return_value = "{}" + 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 = [stored_model_info["id"]] + + 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( # test-quality-ok: [TQ008] isolate persistence from encryption implementation + "litellm.proxy.management_endpoints.model_management_endpoints.encrypt_value_helper", + side_effect=lambda value: value, + ), + patch( # test-quality-ok: [TQ008] isolate persistence from router reload implementation + "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_info=model_info), + user_api_key_dict=UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN), + ) + + written = mock_prisma.db.litellm_proxymodeltable.update.await_args.kwargs["data"] + return json.loads(written["model_info"]) if "model_info" in written else None + + @pytest.mark.asyncio + async def test_update_model_persists_sent_model_info_over_stored_fields(self): + stored = {"id": "m1", "db_model": True, "base_model": "my-deployment", "tier": "paid", "team_id": "team-a"} + + persisted = await self._persisted_model_info( + stored, ModelInfo(id="m1", base_model="azure/gpt-4o", mode="chat", db_model=False, team_id="team-b") + ) + + assert persisted is not None + assert persisted["base_model"] == "azure/gpt-4o" + assert persisted["mode"] == "chat" + assert {k: persisted[k] for k in ("db_model", "tier", "team_id")} == { + "db_model": True, + "tier": "paid", + "team_id": "team-a", + } + + @pytest.mark.asyncio + async def test_update_model_with_only_an_id_leaves_model_info_untouched(self): + assert await self._persisted_model_info({"id": "m1", "base_model": "my-deployment"}, ModelInfo(id="m1")) is None + + @pytest.mark.asyncio + async def test_update_model_does_not_pin_values_echoed_from_model_info(self): + import litellm + + catalog_entry = litellm.get_model_info(model="azure/gpt-4o") + echoed = ModelInfo( + id="m1", + base_model="azure/gpt-4o", + key=catalog_entry["key"], + max_tokens=catalog_entry["max_tokens"], + input_cost_per_token=catalog_entry["input_cost_per_token"], + ) + + persisted = await self._persisted_model_info({"id": "m1"}, echoed) + + assert persisted is not None + assert persisted["base_model"] == "azure/gpt-4o" + assert not {"key", "max_tokens", "input_cost_per_token"} & persisted.keys() + @pytest.mark.asyncio async def test_update_model_clears_cache_after_db_write(self): """