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.
This commit is contained in:
Maxime Boudier 2026-10-16 17:10:47 +02:00
parent f81e657903
commit 49a2f2f663
No known key found for this signature in database
2 changed files with 142 additions and 4 deletions

View file

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

View file

@ -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):
"""