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

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 @@ -11822,6 +11822,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 @@ -12256,6 +12259,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 @@ -12296,7 +12326,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)
Comment thread
veria-ai[bot] marked this conversation as resolved.


@router.get(
Expand Down Expand Up @@ -12436,8 +12466,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