[Fix] Team-based model name corruption on PATCH

For team-scoped ("Team-BYOK") models, the backend stores an internal
routing key `model_name_{team_id}_{uuid}` in the `model_name` column and
the user-facing name in `model_info.team_public_model_name`. The
internal name was leaking into `/v1/model/info` and `/v2/model/info`
responses. The UI bound the model edit form to that field, so any
non-rename edit (e.g. changing TPM) would PATCH the model with the
internal name as `model_name`. The PATCH endpoint then treated it as
a new public name, overwriting `team_public_model_name` and rewriting
the team's `models[]` list with the mangled string.

Translate `model_name` to the public name in API responses for
team-scoped rows; the DB column and router index continue to use the
internal name as the routing key. Harden `_get_public_model_name` to
detect a no-op or internal-shape `model_name` in the patch and fall
through to the existing `team_public_model_name` instead of treating
it as a rename. Standardize `AgentBuilderView` selection on
`model_info.id` so cross-team agents with the same public name don't
collide.
This commit is contained in:
Yuneng Jiang 2026-05-06 23:51:38 -07:00
parent a67b7a7e87
commit c96d47192b
No known key found for this signature in database
3 changed files with 95 additions and 31 deletions

View file

@ -446,9 +446,28 @@ 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.
Defends against a stale client round-tripping the internal mangled
`model_name` (the routing key `model_name_{team_id}_{uuid}`) instead
of the actual public name. A no-op (incoming equals current DB column)
or an internal-shape match is treated as "no rename intent" and falls
through to the existing public name.
"""
incoming = patch_data.model_name
if incoming:
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
)
is_internal_shape = (
team_id is not None
and incoming.startswith(f"model_name_{team_id}_")
)
is_no_op = incoming == db_model.model_name
if not (is_internal_shape or is_no_op):
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

View file

@ -11081,6 +11081,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,
@ -11515,6 +11518,28 @@ 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.
"""
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", {})
@ -11555,7 +11580,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(

View file

@ -191,22 +191,31 @@ export default function AgentBuilderView({
const [deleting, setDeleting] = useState(false);
const effectiveApiKey = apiKey || accessToken || "";
const selectedAgent = selectedId === NEW_AGENT_ID ? null : agentModels.find((a) => a.model_name === selectedId) ?? null;
const selectedAgent =
selectedId === NEW_AGENT_ID
? null
: agentModels.find((a) => getAgentModelId(a) === selectedId) ?? null;
const isNewAgent = selectedId === NEW_AGENT_ID;
const selectedAgentModelId = selectedAgent ? getAgentModelId(selectedAgent) : null;
const loadAgents = useCallback(async () => {
if (!accessToken || !userID || !userRole) return;
const loadAgents = useCallback(async (): Promise<AgentModel[]> => {
if (!accessToken || !userID || !userRole) return [];
setLoadingAgents(true);
try {
const list = await fetchAvailableAgentModels(accessToken, userID, userRole);
setAgentModels(list);
if (!selectedId || (selectedId !== NEW_AGENT_ID && !list.some((a) => a.model_name === selectedId))) {
setSelectedId(list.length > 0 ? list[0].model_name : null);
if (
!selectedId ||
(selectedId !== NEW_AGENT_ID &&
!list.some((a) => getAgentModelId(a) === selectedId))
) {
setSelectedId(list.length > 0 ? getAgentModelId(list[0]) : null);
}
return list;
} catch (e) {
console.error(e);
NotificationsManager.fromBackend("Failed to load agents");
return [];
} finally {
setLoadingAgents(false);
}
@ -309,8 +318,9 @@ export default function AgentBuilderView({
model_info: {},
});
const newName = draftName.trim();
await loadAgents();
setSelectedId(newName);
const list = await loadAgents();
const created = list.find((a) => a.model_name === newName);
setSelectedId(getAgentModelId(created ?? list[0]) ?? null);
setActiveTab("chat");
} catch (e) {
NotificationsManager.fromBackend("Failed to save agent");
@ -342,8 +352,11 @@ export default function AgentBuilderView({
selectedAgentModelId,
);
NotificationsManager.success("Agent updated successfully");
await loadAgents();
setSelectedId(draftName.trim());
const list = await loadAgents();
const stillSelected = list.find(
(a) => getAgentModelId(a) === selectedAgentModelId,
);
setSelectedId(getAgentModelId(stillSelected ?? list[0]) ?? null);
} catch (e) {
NotificationsManager.fromBackend("Failed to update agent");
} finally {
@ -387,9 +400,13 @@ export default function AgentBuilderView({
try {
await modelDeleteCall(accessToken, selectedAgentModelId);
NotificationsManager.success("Agent deleted");
await loadAgents();
const remaining = agentModels.filter((a) => a.model_name !== selectedAgent.model_name);
setSelectedId(remaining.length > 0 ? remaining[0].model_name : null);
const list = await loadAgents();
const remaining = list.filter(
(a) => getAgentModelId(a) !== selectedAgentModelId,
);
setSelectedId(
remaining.length > 0 ? getAgentModelId(remaining[0]) : null,
);
} catch (e) {
NotificationsManager.fromBackend("Failed to delete agent");
} finally {
@ -452,21 +469,24 @@ export default function AgentBuilderView({
</div>
) : (
<>
{agentModels.map((agent) => (
<button
key={agent.model_name}
type="button"
onClick={() => setSelectedId(agent.model_name)}
className={`mb-1 w-full rounded-md border-l-2 px-3 py-2 text-left text-sm transition-colors ${
selectedId === agent.model_name
? "border-blue-500 bg-blue-50 text-blue-800"
: "border-transparent hover:bg-gray-50"
}`}
>
<div className="font-medium truncate">{agent.model_name}</div>
<div className="text-[10px] text-gray-500 truncate">litellm_agent</div>
</button>
))}
{agentModels.map((agent) => {
const id = getAgentModelId(agent);
return (
<button
key={id ?? agent.model_name}
type="button"
onClick={() => id && setSelectedId(id)}
className={`mb-1 w-full rounded-md border-l-2 px-3 py-2 text-left text-sm transition-colors ${
selectedId === id
? "border-blue-500 bg-blue-50 text-blue-800"
: "border-transparent hover:bg-gray-50"
}`}
>
<div className="font-medium truncate">{agent.model_name}</div>
<div className="text-[10px] text-gray-500 truncate">litellm_agent</div>
</button>
);
})}
<button
type="button"
onClick={handleAddAgent}