feat(teams): resolve access group models/MCPs/agents in team endpoints

Add access_group_models, access_group_mcp_server_ids, and
access_group_agent_ids to /team/info and /v2/team/list responses.
These fields contain resources inherited from access groups, kept
separate from direct assignments so the UI can distinguish the source.

Backend: _resolve_access_group_resources() helper resolves access
group resources via existing _get_*_from_access_groups() functions.

UI: Teams table and detail view show direct models as blue badges
and access-group-sourced models as green badges.
This commit is contained in:
Ryan Crabbe
2026-04-02 12:25:48 -07:00
parent 50a52f62e3
commit f62658795a
6 changed files with 161 additions and 74 deletions
+4
View File
@@ -3805,6 +3805,10 @@ class OrganizationMemberUpdateResponse(MemberUpdateResponse):
class TeamInfoResponseObjectTeamTable(LiteLLM_TeamTable):
team_member_budget_table: Optional[LiteLLM_BudgetTable] = None
# Resources inherited from access groups (separate from direct assignments)
access_group_models: Optional[List[str]] = None
access_group_mcp_server_ids: Optional[List[str]] = None
access_group_agent_ids: Optional[List[str]] = None
class TeamInfoResponseObject(TypedDict):
@@ -62,6 +62,9 @@ from litellm.proxy._types import (
UserAPIKeyAuth,
)
from litellm.proxy.auth.auth_checks import (
_get_agent_ids_from_access_groups,
_get_mcp_server_ids_from_access_groups,
_get_models_from_access_groups,
allowed_route_check_inside_route,
can_org_access_model,
get_org_object,
@@ -3042,6 +3045,14 @@ async def team_info(
team_info_response_object=_team_info,
)
# Resolve resources inherited from access groups
resolved = await _resolve_access_group_resources(
access_group_ids=_team_info.access_group_ids,
)
_team_info.access_group_models = resolved["access_group_models"]
_team_info.access_group_mcp_server_ids = resolved["access_group_mcp_server_ids"]
_team_info.access_group_agent_ids = resolved["access_group_agent_ids"]
response_object = TeamInfoResponseObject(
team_id=team_id,
team_info=_team_info,
@@ -3332,6 +3343,36 @@ async def _build_team_list_where_conditions(
return where_conditions
async def _resolve_access_group_resources(
access_group_ids: Optional[List[str]],
) -> Dict[str, List[str]]:
"""
Resolve resources inherited from access groups.
Returns only the access-group-sourced resources (not direct assignments).
Keeps them separate so callers can distinguish where each resource comes from.
"""
empty: Dict[str, List[str]] = {
"access_group_models": [],
"access_group_mcp_server_ids": [],
"access_group_agent_ids": [],
}
if not access_group_ids:
return empty
return {
"access_group_models": await _get_models_from_access_groups(
access_group_ids=access_group_ids,
),
"access_group_mcp_server_ids": await _get_mcp_server_ids_from_access_groups(
access_group_ids=access_group_ids,
),
"access_group_agent_ids": await _get_agent_ids_from_access_groups(
access_group_ids=access_group_ids,
),
}
def _convert_teams_to_response_models(
teams: list,
use_deleted_table: bool,
@@ -3558,6 +3599,19 @@ async def list_team_v2(
# Convert Prisma models to response models with members_count
team_list = _convert_teams_to_response_models(teams, use_deleted_table)
# Resolve resources inherited from access groups for each team
if not use_deleted_table:
for team_item in team_list:
if isinstance(team_item, TeamListItem):
resolved = await _resolve_access_group_resources(
access_group_ids=team_item.access_group_ids,
)
team_item.access_group_models = resolved["access_group_models"]
team_item.access_group_mcp_server_ids = resolved[
"access_group_mcp_server_ids"
]
team_item.access_group_agent_ids = resolved["access_group_agent_ids"]
return {
"teams": team_list,
"total": total_count,
@@ -47,6 +47,10 @@ class TeamListItem(LiteLLM_TeamTable):
"""A team item in the paginated list response, enriched with computed fields."""
members_count: int = 0
# Resources inherited from access groups (separate from direct assignments)
access_group_models: Optional[List[str]] = None
access_group_mcp_server_ids: Optional[List[str]] = None
access_group_agent_ids: Optional[List[str]] = None
class TeamListResponse(BaseModel):
@@ -1,16 +1,54 @@
import { Badge, Icon, TableCell, Text } from "@tremor/react";
import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/outline";
import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key";
import React, { useState } from "react";
import React, { useMemo, useState } from "react";
import { Team } from "@/components/key_team_helpers/key_list";
interface ModelsCellProps {
team: Team;
}
interface ModelEntry {
name: string;
source: "direct" | "access_group";
}
const ModelsCell = ({ team }: ModelsCellProps) => {
const [expandedAccordion, setExpandedAccordion] = useState<boolean>(false);
const modelEntries: ModelEntry[] = useMemo(() => {
const entries: ModelEntry[] = (team.models || []).map((m) => ({
name: m,
source: "direct" as const,
}));
for (const m of team.access_group_models || []) {
entries.push({ name: m, source: "access_group" });
}
return entries;
}, [team.models, team.access_group_models]);
const renderBadge = (entry: ModelEntry, index: number) => {
if (entry.name === "all-proxy-models") {
return (
<Badge key={index} size={"xs"} color="red">
<Text>All Proxy Models</Text>
</Badge>
);
}
const displayName = getModelDisplayName(entry.name);
const truncated = displayName.length > 30 ? `${displayName.slice(0, 30)}...` : displayName;
return (
<Badge
key={index}
size={"xs"}
color={entry.source === "access_group" ? "green" : "blue"}
title={entry.source === "access_group" ? "From access group" : "Direct assignment"}
>
<Text>{truncated}</Text>
</Badge>
);
};
return (
<TableCell
style={{
@@ -18,78 +56,46 @@ const ModelsCell = ({ team }: ModelsCellProps) => {
whiteSpace: "pre-wrap",
overflow: "hidden",
}}
className={team.models.length > 3 ? "px-0" : ""}
className={modelEntries.length > 3 ? "px-0" : ""}
>
<div className="flex flex-col">
{Array.isArray(team.models) ? (
{modelEntries.length === 0 ? (
<Badge size={"xs"} className="mb-1" color="red">
<Text>All Proxy Models</Text>
</Badge>
) : (
<div className="flex flex-col">
{team.models.length === 0 ? (
<Badge size={"xs"} className="mb-1" color="red">
<Text>All Proxy Models</Text>
</Badge>
) : (
<>
<div className="flex items-start">
{team.models.length > 3 && (
<div>
<Icon
icon={expandedAccordion ? ChevronDownIcon : ChevronRightIcon}
className="cursor-pointer"
size="xs"
onClick={() => {
setExpandedAccordion((prev) => !prev);
}}
/>
</div>
)}
<div className="flex flex-wrap gap-1">
{team.models.slice(0, 3).map((model: string, index: number) =>
model === "all-proxy-models" ? (
<Badge key={index} size={"xs"} color="red">
<Text>All Proxy Models</Text>
</Badge>
) : (
<Badge key={index} size={"xs"} color="blue">
<Text>
{model.length > 30
? `${getModelDisplayName(model).slice(0, 30)}...`
: getModelDisplayName(model)}
</Text>
</Badge>
),
)}
{team.models.length > 3 && !expandedAccordion && (
<Badge size={"xs"} color="gray" className="cursor-pointer">
<Text>
+{team.models.length - 3} {team.models.length - 3 === 1 ? "more model" : "more models"}
</Text>
</Badge>
)}
{expandedAccordion && (
<div className="flex flex-wrap gap-1">
{team.models.slice(3).map((model: string, index: number) =>
model === "all-proxy-models" ? (
<Badge key={index + 3} size={"xs"} color="red">
<Text>All Proxy Models</Text>
</Badge>
) : (
<Badge key={index + 3} size={"xs"} color="blue">
<Text>
{model.length > 30
? `${getModelDisplayName(model).slice(0, 30)}...`
: getModelDisplayName(model)}
</Text>
</Badge>
),
)}
</div>
)}
</div>
<div className="flex items-start">
{modelEntries.length > 3 && (
<div>
<Icon
icon={expandedAccordion ? ChevronDownIcon : ChevronRightIcon}
className="cursor-pointer"
size="xs"
onClick={() => {
setExpandedAccordion((prev) => !prev);
}}
/>
</div>
</>
)}
)}
<div className="flex flex-wrap gap-1">
{modelEntries.slice(0, 3).map((entry, index) => renderBadge(entry, index))}
{modelEntries.length > 3 && !expandedAccordion && (
<Badge size={"xs"} color="gray" className="cursor-pointer">
<Text>
+{modelEntries.length - 3} {modelEntries.length - 3 === 1 ? "more model" : "more models"}
</Text>
</Badge>
)}
{expandedAccordion && (
<div className="flex flex-wrap gap-1">
{modelEntries.slice(3).map((entry, index) => renderBadge(entry, index + 3))}
</div>
)}
</div>
</div>
</div>
) : null}
)}
</div>
</TableCell>
);
@@ -15,6 +15,10 @@ export interface Team {
keys: KeyResponse[];
members_with_roles: Member[];
spend: number;
access_group_ids?: string[];
access_group_models?: string[];
access_group_mcp_server_ids?: string[];
access_group_agent_ids?: string[];
}
export interface KeyResponse {
@@ -655,16 +655,31 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
<Card>
<Text>Models</Text>
<div className="mt-2 flex flex-wrap gap-2">
{info.models.length === 0 ? (
{info.models.length === 0 && !(info.access_group_models?.length) ? (
<Badge color="red">All proxy models</Badge>
) : (
info.models.map((model, index) => (
<Badge key={index} color="red">
{model}
</Badge>
))
<>
{info.models.map((model: string, index: number) => (
<Badge key={`direct-${index}`} color="blue">
{model}
</Badge>
))}
{(info.access_group_models || []).map((model: string, index: number) => (
<Badge key={`ag-${index}`} color="green" title="From access group">
{model}
</Badge>
))}
</>
)}
</div>
{info.access_group_models && info.access_group_models.length > 0 && (
<div className="mt-2">
<Text className="text-xs text-gray-500">
<span className="inline-block w-2 h-2 rounded-full bg-blue-500 mr-1" />Direct
<span className="inline-block w-2 h-2 rounded-full bg-green-500 ml-3 mr-1" />From access group
</Text>
</div>
)}
</Card>
<Card>