Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 39 additions & 3 deletions litellm/proxy/management_endpoints/model_management_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -490,9 +490,45 @@ def _get_public_model_name(
patch_data: updateDeployment,
db_model: Deployment,
) -> str:
"""Determine the public model name from patch or existing model."""
if patch_data.model_name:
return patch_data.model_name
"""Determine the public model name from patch or existing model.

The top-level ``model_name`` is the rename channel. For team-scoped rows
the DB ``model_name`` column holds an internal routing key
(``model_name_{team_id}_{uuid}``), and ``/model/info`` historically leaked
it into the dashboard edit form, so a non-rename save (e.g. a TPM tweak)
would PATCH the internal name and the update path would treat it as a
rename -- overwriting ``team_public_model_name`` and rewriting the team ACL
(see issue #28382).

Guard against that by ignoring an incoming ``model_name`` that matches the
internal shape, or is a no-op against the current DB column. Anything else
is a genuine rename and wins. We deliberately do NOT read
``patch_data.model_info.team_public_model_name``: the dashboard passes the
existing ``model_info`` blob through untouched on a rename, so honoring it
would return the OLD public name and silently drop the rename.

Precedence (highest first):
1. patch_data.model_name -- a genuine rename: not internal-shape and not a
no-op against db_model.model_name.
2. db_model.model_info.team_public_model_name -- existing public name.
3. db_model.model_name -- last-resort fallback for legacy rows.
"""
team_id = (patch_data.model_info.team_id if patch_data.model_info else None) or (
db_model.model_info.team_id if db_model.model_info else None
)

def _is_internal_shape(name: Optional[str]) -> bool:
if team_id is None or not name:
return False
return name.startswith(f"model_name_{team_id}_")

incoming = patch_data.model_name
if (
incoming
and not _is_internal_shape(incoming)
and incoming != db_model.model_name
):
return incoming
Comment on lines +525 to +531

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 No-op guard misses the primary post-fix scenario

The condition incoming != db_model.model_name is designed to catch the case where the dashboard echoes back the internal routing key (before the read-path fix). After the read-path fix is deployed, the dashboard will instead send the public name (e.g. "team-claude-sonnet"), which is always != the internal key "model_name_{team_id}_{uuid}". The guard therefore never fires on the new client and falls through to return the incoming public name. This is still safe because _update_existing_team_model_assignment later detects old_public_name == public_model_name and skips the ACL update, then always sets patch_data.model_name = None at line 685.

However, there is no test that covers this scenario end-to-end (UI sends public name on a non-rename edit), so the correctness of the combined path relies solely on a reader tracing through two functions.


if db_model.model_info and db_model.model_info.team_public_model_name:
return db_model.model_info.team_public_model_name
Expand Down
39 changes: 36 additions & 3 deletions litellm/proxy/proxy_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -11888,6 +11888,9 @@ async def model_info_v2(
# Update total count to include agents
search_total_count = len(all_models)

# Translate `model_name` to the public name for team-scoped rows.
all_models = [_translate_model_name_for_response(m) for m in all_models]

return _paginate_models_response(
all_models=all_models,
page=page,
Expand Down Expand Up @@ -12322,6 +12325,33 @@ async def model_metrics_exceptions(
return {"data": response, "exception_types": list(exception_types)}


def _translate_model_name_for_response(model: dict) -> dict:
"""For team-scoped DB rows, replace `model_name` with the public name
in `model_info.team_public_model_name` before returning. The DB column
and the in-memory router index keep the internal mangled name
(`model_name_{team_id}_{uuid}`) as the routing key -- this swap is a
presentation-layer concern. Returns a shallow copy; never mutates.

Without this swap the internal name leaks into `/v1/model/info` and
`/v2/model/info`, the dashboard binds its edit form to it, and a
non-rename save round-trips the internal name back -- corrupting
`team_public_model_name` and the team ACL (see issue #28382).
"""
if not isinstance(model, dict):
return model
model_info = model.get("model_info") or {}
if not isinstance(model_info, dict):
return model
team_public = model_info.get("team_public_model_name")
team_id = model_info.get("team_id")
if not team_public or not team_id:
return model
current = model.get("model_name") or ""
if not current.startswith(f"model_name_{team_id}_"):
return model
return {**model, "model_name": team_public}


def _get_proxy_model_info(model: dict) -> dict:
# provided model_info in config.yaml
model_info = model.get("model_info", {})
Expand Down Expand Up @@ -12362,7 +12392,7 @@ def _get_proxy_model_info(model: dict) -> dict:
deployment_dict=model, excluded_keys={"litellm_credential_name"}
)

return model
return _translate_model_name_for_response(model)


@router.get(
Expand Down Expand Up @@ -12502,8 +12532,11 @@ async def model_info_v1( # noqa: PLR0915
else:
all_models = []

for in_place_model in all_models:
in_place_model = _get_proxy_model_info(model=in_place_model)
# Reassign each entry: _get_proxy_model_info returns a (possibly new)
# dict via _translate_model_name_for_response, which does NOT mutate in
# place. Binding only the loop variable would drop the public-name swap
# for team-scoped rows and leak the internal routing key (#28382).
all_models = [_get_proxy_model_info(model=model) for model in all_models]

verbose_proxy_logger.debug("all_models: %s", all_models)
return {"data": all_models}
Expand Down
Loading
Loading