mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-27 06:13:07 +00:00
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:
parent
f81e657903
commit
49a2f2f663
2 changed files with 142 additions and 4 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue