mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-14 22:21:29 +00:00
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:
@@ -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):
|
||||
|
||||
+74
-68
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user