Merge pull request #39822 from BerriAI/litellm_lit_6594_access_group_resource_names

feat(access-groups): resolve resource names on access group responses
This commit is contained in:
ryan-crabbe-berri 2026-09-04 19:03:20 -07:00 committed by GitHub
commit 2151dcbd73
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 850 additions and 212 deletions

View file

@ -741,6 +741,32 @@
"title": "AccessGroupInfo",
"type": "object"
},
"AccessGroupResource": {
"description": "A resource referenced by an access group. `name` is null when the id no longer resolves or has no alias.",
"properties": {
"id": {
"title": "Id",
"type": "string"
},
"name": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Name"
}
},
"required": [
"id",
"name"
],
"title": "AccessGroupResource",
"type": "object"
},
"AccessGroupResponse": {
"properties": {
"access_agent_ids": {
@ -750,6 +776,13 @@
"title": "Access Agent Ids",
"type": "array"
},
"access_agents": {
"items": {
"$ref": "#/components/schemas/AccessGroupResource"
},
"title": "Access Agents",
"type": "array"
},
"access_group_id": {
"title": "Access Group Id",
"type": "string"
@ -765,6 +798,13 @@
"title": "Access Mcp Server Ids",
"type": "array"
},
"access_mcp_servers": {
"items": {
"$ref": "#/components/schemas/AccessGroupResource"
},
"title": "Access Mcp Servers",
"type": "array"
},
"access_model_names": {
"items": {
"type": "string"
@ -779,6 +819,13 @@
"title": "Assigned Key Ids",
"type": "array"
},
"assigned_keys": {
"items": {
"$ref": "#/components/schemas/AccessGroupResource"
},
"title": "Assigned Keys",
"type": "array"
},
"assigned_team_ids": {
"items": {
"type": "string"
@ -786,6 +833,13 @@
"title": "Assigned Team Ids",
"type": "array"
},
"assigned_teams": {
"items": {
"$ref": "#/components/schemas/AccessGroupResource"
},
"title": "Assigned Teams",
"type": "array"
},
"created_at": {
"format": "date-time",
"title": "Created At",
@ -838,6 +892,10 @@
"access_agent_ids",
"assigned_team_ids",
"assigned_key_ids",
"access_mcp_servers",
"access_agents",
"assigned_teams",
"assigned_keys",
"created_at",
"updated_at"
],

View file

@ -1,16 +1,20 @@
from collections.abc import Mapping, Sequence
import asyncio
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from types import MappingProxyType
from typing import Final, Protocol
from fastapi import APIRouter, Depends, HTTPException, status
from litellm._logging import verbose_proxy_logger
from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager
from litellm.proxy._types import (
CommonProxyErrors,
LiteLLM_AccessGroupTable,
LitellmUserRoles,
UserAPIKeyAuth,
)
from litellm.proxy.agent_endpoints.agent_registry import global_agent_registry
from litellm.proxy.auth.auth_checks import (
_cache_access_object,
_cache_key_object,
@ -20,10 +24,16 @@ from litellm.proxy.auth.auth_checks import (
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler
from litellm.proxy.management_helpers.access_group_team_sync import invalidate_access_group_cache
from litellm.proxy.utils import get_prisma_client_or_throw
from litellm.proxy.management_helpers.resource_display_names import (
agent_display_names,
key_display_names,
mcp_server_display_names,
)
from litellm.proxy.utils import PrismaClient, get_prisma_client_or_throw
from litellm.repositories.table_repositories import AccessGroupRepository, TeamRepository
from litellm.types.access_group import (
AccessGroupCreateRequest,
AccessGroupResource,
AccessGroupResponse,
AccessGroupUpdateRequest,
)
@ -37,6 +47,12 @@ class _AccessGroupRecord(Protocol):
@property
def access_group_id(self) -> str: ...
@property
def access_mcp_server_ids(self) -> Sequence[str] | None: ...
@property
def access_agent_ids(self) -> Sequence[str] | None: ...
@property
def assigned_team_ids(self) -> Sequence[str] | None: ...
@ -50,6 +66,9 @@ class _TeamRecord(Protocol):
@property
def team_id(self) -> str: ...
@property
def team_alias(self) -> str | None: ...
@property
def access_group_ids(self) -> Sequence[str] | None: ...
@ -120,16 +139,75 @@ def _require_admin_view(user_api_key_dict: UserAPIKeyAuth) -> None:
)
@dataclass(frozen=True, slots=True)
class _ResourceNames:
mcp_servers: Mapping[str, str]
agents: Mapping[str, str]
teams: Mapping[str, str | None]
keys: Mapping[str, str]
def _label(ids: Sequence[str], names: Mapping[str, str | None]) -> tuple[AccessGroupResource, ...]:
return tuple(AccessGroupResource(id=resource_id, name=names.get(resource_id)) for resource_id in ids)
def _record_to_response(
record: _AccessGroupRecord, *, assigned_team_ids: Sequence[str] | None = None
record: _AccessGroupRecord, *, assigned_team_ids: Sequence[str], names: _ResourceNames
) -> AccessGroupResponse:
stored: Final = record.dict()
payload: Final = (
stored if assigned_team_ids is None else MappingProxyType({**stored, "assigned_team_ids": assigned_team_ids})
payload: Final = MappingProxyType(
{
**record.dict(),
"assigned_team_ids": assigned_team_ids,
"access_mcp_servers": _label(record.access_mcp_server_ids or (), names.mcp_servers),
"access_agents": _label(record.access_agent_ids or (), names.agents),
"assigned_teams": _label(assigned_team_ids, names.teams),
"assigned_keys": _label(record.assigned_key_ids or (), names.keys),
}
)
return AccessGroupResponse.model_validate(payload)
def _ids_across(
records: Sequence[_AccessGroupRecord], pick: Callable[[_AccessGroupRecord], Sequence[str] | None]
) -> tuple[str, ...]:
return tuple(dict.fromkeys(resource_id for record in records for resource_id in (pick(record) or ())))
async def _responses_for(
prisma_client: PrismaClient, records: Sequence[_AccessGroupRecord]
) -> tuple[AccessGroupResponse, ...]:
if not records:
return ()
teams: Final = await _teams_touching(TeamRepository(prisma_client).table, records)
mcp_servers, agents, keys = await asyncio.gather(
mcp_server_display_names(
prisma_client,
_ids_across(records, lambda record: record.access_mcp_server_ids),
global_mcp_server_manager.config_mcp_servers,
),
agent_display_names(
prisma_client, _ids_across(records, lambda record: record.access_agent_ids), global_agent_registry
),
key_display_names(prisma_client, _ids_across(records, lambda record: record.assigned_key_ids)),
)
names: Final = _ResourceNames(
mcp_servers=mcp_servers,
agents=agents,
teams=MappingProxyType({team.team_id: team.team_alias for team in teams}),
keys=keys,
)
attached: Final = _attached_team_ids_by_group(records, teams)
return tuple(
_record_to_response(record, assigned_team_ids=attached[record.access_group_id], names=names)
for record in records
)
async def _response_for(prisma_client: PrismaClient, record: _AccessGroupRecord) -> AccessGroupResponse:
(response,) = await _responses_for(prisma_client, (record,))
return response
def _attached_team_ids_by_group(
records: Sequence[_AccessGroupRecord], teams: Sequence[_TeamRecord]
) -> Mapping[str, tuple[str, ...]]:
@ -144,19 +222,21 @@ def _attached_team_ids_by_group(
return MappingProxyType({record.access_group_id: attached(record) for record in records})
async def _teams_touching(team_table: _TeamTable, records: Sequence[_AccessGroupRecord]) -> Sequence[_TeamRecord]:
"""Team rows listed on any of the groups or carrying any of them in access_group_ids."""
group_ids: Final = tuple(record.access_group_id for record in records)
stored_team_ids: Final = _ids_across(records, lambda record: record.assigned_team_ids)
carrying: Final = {"access_group_ids": {"hasSome": group_ids}} # mutable-ok: prisma where is a dict
listed: Final = {"team_id": {"in": stored_team_ids}} # mutable-ok: prisma where is a dict
return await team_table.find_many(where={"OR": (carrying, listed)}) # mutable-ok: prisma where is a dict
async def _attached_team_ids_for(
team_table: _TeamTable, records: Sequence[_AccessGroupRecord]
) -> Mapping[str, tuple[str, ...]]:
if not records:
return MappingProxyType({})
group_ids: Final = tuple(record.access_group_id for record in records)
stored_team_ids: Final = tuple(
dict.fromkeys(team_id for record in records for team_id in (record.assigned_team_ids or ()))
)
carrying: Final = {"access_group_ids": {"hasSome": group_ids}} # mutable-ok: prisma where is a dict
listed: Final = {"team_id": {"in": stored_team_ids}} # mutable-ok: prisma where is a dict
teams: Final = await team_table.find_many(where={"OR": (carrying, listed)}) # mutable-ok: prisma where is a dict
return _attached_team_ids_by_group(records, teams)
return _attached_team_ids_by_group(records, await _teams_touching(team_table, records))
async def _require_teams_exist(tx: _AccessGroupTx, team_ids: Sequence[str]) -> None:
@ -425,7 +505,7 @@ async def create_access_group(
proxy_logging_obj,
)
return _record_to_response(record)
return await _response_for(prisma_client, record)
@router.get(
@ -434,14 +514,13 @@ async def create_access_group(
)
async def list_access_groups(
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
) -> list[AccessGroupResponse]:
) -> Sequence[AccessGroupResponse]:
_require_admin_view(user_api_key_dict)
prisma_client: Final = get_prisma_client_or_throw(CommonProxyErrors.db_not_connected_error.value)
table: Final = AccessGroupRepository(prisma_client).table
records: Final = await table.find_many(order={"created_at": "desc"})
attached: Final = await _attached_team_ids_for(TeamRepository(prisma_client).table, records)
return [_record_to_response(r, assigned_team_ids=attached[r.access_group_id]) for r in records]
return await _responses_for(prisma_client, records)
@router.get(
@ -462,8 +541,7 @@ async def get_access_group(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Access group '{access_group_id}' not found",
)
attached: Final = await _attached_team_ids_for(TeamRepository(prisma_client).table, (record,))
return _record_to_response(record, assigned_team_ids=attached[record.access_group_id])
return await _response_for(prisma_client, record)
@router.put(
@ -560,7 +638,7 @@ async def update_access_group(
await _patch_key_caches_add_access_group(keys_to_add, access_group_id, user_api_key_cache, proxy_logging_obj)
await _patch_key_caches_remove_access_group(keys_to_remove, access_group_id, user_api_key_cache, proxy_logging_obj)
return _record_to_response(record)
return await _response_for(prisma_client, record)
@router.delete(

View file

@ -0,0 +1,61 @@
"""Display names for ids stored on management objects. DB rows win; config-declared servers and agents fill the gaps."""
from collections.abc import Mapping, Sequence
from types import MappingProxyType
from typing import Final
from litellm.proxy.agent_endpoints.agent_registry import AgentRegistry
from litellm.proxy.utils import PrismaClient
from litellm.repositories.table_repositories import AgentsRepository, MCPServerRepository
from litellm.repositories.verification_token_repository import VerificationTokenRepository
from litellm.types.mcp_server.mcp_server_manager import MCPServer
async def mcp_server_display_names(
prisma_client: PrismaClient,
server_ids: Sequence[str],
config_servers: Mapping[str, MCPServer],
) -> Mapping[str, str]:
"""server_id -> alias, falling back to server_name; config-only servers also fall back to their registry name."""
if not server_ids:
return MappingProxyType({})
wanted: Final = frozenset(server_ids)
where: Final = {"server_id": {"in": tuple(wanted)}} # mutable-ok: prisma where is a dict
rows: Final = await MCPServerRepository(prisma_client).table.find_many(where=where)
from_config: Final = {
server_id: server.alias or server.server_name or server.name
for server_id, server in config_servers.items()
if server_id in wanted
}
from_db: Final = {row.server_id: name for row in rows if (name := row.alias or row.server_name)}
return MappingProxyType({**from_config, **from_db})
async def agent_display_names(
prisma_client: PrismaClient,
agent_ids: Sequence[str],
registry: AgentRegistry,
) -> Mapping[str, str]:
"""agent_id -> agent_name. The registry covers config-declared agents and their legacy ids."""
if not agent_ids:
return MappingProxyType({})
wanted: Final = frozenset(agent_ids)
where: Final = {"agent_id": {"in": tuple(wanted)}} # mutable-ok: prisma where is a dict
rows: Final = await AgentsRepository(prisma_client).table.find_many(where=where)
from_registry: Final = {
alias_id: agent.agent_name
for agent in registry.get_agent_list()
for alias_id in registry.ids_for_agent(agent.agent_id)
if alias_id in wanted
}
from_db: Final = {row.agent_id: row.agent_name for row in rows}
return MappingProxyType({**from_registry, **from_db})
async def key_display_names(prisma_client: PrismaClient, tokens: Sequence[str]) -> Mapping[str, str]:
"""token hash -> key_alias for the keys that have one."""
if not tokens:
return MappingProxyType({})
where: Final = {"token": {"in": tuple(frozenset(tokens))}} # mutable-ok: prisma where is a dict
rows: Final = await VerificationTokenRepository(prisma_client).table.find_many(where=where)
return MappingProxyType({row.token: row.key_alias for row in rows if row.key_alias})

View file

@ -23,6 +23,13 @@ class AccessGroupUpdateRequest(BaseModel):
assigned_key_ids: list[str] | None = None
class AccessGroupResource(BaseModel):
"""A resource referenced by an access group. `name` is null when the id no longer resolves or has no alias."""
id: str
name: str | None
class AccessGroupResponse(BaseModel):
access_group_id: str
access_group_name: str
@ -32,6 +39,10 @@ class AccessGroupResponse(BaseModel):
access_agent_ids: list[str]
assigned_team_ids: list[str]
assigned_key_ids: list[str]
access_mcp_servers: tuple[AccessGroupResource, ...]
access_agents: tuple[AccessGroupResource, ...]
assigned_teams: tuple[AccessGroupResource, ...]
assigned_keys: tuple[AccessGroupResource, ...]
created_at: datetime
created_by: str | None = None
updated_at: datetime

View file

@ -57,8 +57,20 @@ def _make_access_group_record(
return record
def _make_team_record(team_id: str, access_group_ids: list[str] | None = None):
return types.SimpleNamespace(team_id=team_id, access_group_ids=access_group_ids or [])
def _make_team_record(team_id: str, access_group_ids: list[str] | None = None, team_alias: str | None = None):
return types.SimpleNamespace(team_id=team_id, access_group_ids=access_group_ids or [], team_alias=team_alias)
def _make_mcp_server_record(server_id: str, alias: str | None = None, server_name: str | None = None):
return types.SimpleNamespace(server_id=server_id, alias=alias, server_name=server_name)
def _make_agent_record(agent_id: str, agent_name: str):
return types.SimpleNamespace(agent_id=agent_id, agent_name=agent_name)
def _make_key_record(token: str, key_alias: str | None = None):
return types.SimpleNamespace(token=token, key_alias=key_alias)
@pytest.fixture
@ -109,6 +121,12 @@ def client_and_mocks(monkeypatch):
mock_key_table.find_unique = AsyncMock(return_value=None)
mock_key_table.update = AsyncMock(return_value=None)
mock_mcp_server_table = MagicMock()
mock_mcp_server_table.find_many = AsyncMock(return_value=[])
mock_agents_table = MagicMock()
mock_agents_table.find_many = AsyncMock(return_value=[])
@asynccontextmanager
async def mock_tx():
tx = types.SimpleNamespace(
@ -122,6 +140,8 @@ def client_and_mocks(monkeypatch):
litellm_accessgrouptable=mock_access_group_table,
litellm_teamtable=mock_team_table,
litellm_verificationtoken=mock_key_table,
litellm_mcpservertable=mock_mcp_server_table,
litellm_agentstable=mock_agents_table,
tx=mock_tx,
)
mock_prisma.db = mock_db
@ -1447,3 +1467,169 @@ def test_update_access_group_null_assigned_ids_treated_as_empty(client_and_mocks
update_call_kwargs = mock_table.update.call_args.kwargs
assert update_call_kwargs["data"]["assigned_team_ids"] == []
assert update_call_kwargs["data"]["assigned_key_ids"] == []
# ---------------------------------------------------------------------------
# Resolved resource names (LIT-6594)
# ---------------------------------------------------------------------------
def _mock_resource_tables(mock_prisma, *, mcp_servers=(), agents=(), teams=(), keys=()):
mock_prisma.db.litellm_mcpservertable.find_many = AsyncMock(return_value=list(mcp_servers))
mock_prisma.db.litellm_agentstable.find_many = AsyncMock(return_value=list(agents))
mock_prisma.db.litellm_teamtable.find_many = AsyncMock(return_value=list(teams))
mock_prisma.db.litellm_verificationtoken.find_many = AsyncMock(return_value=list(keys))
@pytest.mark.parametrize("base_path", ACCESS_GROUP_PATHS)
def test_get_access_group_resolves_resource_names(client_and_mocks, base_path):
"""Every id list gets a sibling list of {id, name}; name is null when the id has no alias or no longer resolves."""
client, mock_prisma, mock_table, *_ = client_and_mocks
mock_table.find_unique = AsyncMock(
return_value=_make_access_group_record(
access_group_id="ag-123",
access_mcp_server_ids=["mcp-a", "mcp-b", "mcp-ghost"],
access_agent_ids=["agent-a", "agent-ghost"],
assigned_team_ids=["team-a", "team-b"],
assigned_key_ids=["key-a", "key-b"],
)
)
_mock_resource_tables(
mock_prisma,
mcp_servers=[
_make_mcp_server_record("mcp-a", alias="GitHub"),
_make_mcp_server_record("mcp-b", server_name="jira_tools"),
],
agents=[_make_agent_record("agent-a", "support-bot")],
teams=[
_make_team_record("team-a", ["ag-123"], team_alias="Platform"),
_make_team_record("team-b", ["ag-123"]),
],
keys=[_make_key_record("key-a", key_alias="ci-key"), _make_key_record("key-b")],
)
resp = client.get(f"{base_path}/ag-123")
assert resp.status_code == 200
body = resp.json()
assert body["access_mcp_servers"] == [
{"id": "mcp-a", "name": "GitHub"},
{"id": "mcp-b", "name": "jira_tools"},
{"id": "mcp-ghost", "name": None},
]
assert body["access_agents"] == [{"id": "agent-a", "name": "support-bot"}, {"id": "agent-ghost", "name": None}]
assert body["assigned_teams"] == [{"id": "team-a", "name": "Platform"}, {"id": "team-b", "name": None}]
assert body["assigned_keys"] == [{"id": "key-a", "name": "ci-key"}, {"id": "key-b", "name": None}]
assert body["access_mcp_server_ids"] == ["mcp-a", "mcp-b", "mcp-ghost"]
assert body["assigned_team_ids"] == ["team-a", "team-b"]
mcp_where = mock_prisma.db.litellm_mcpservertable.find_many.call_args.kwargs["where"]
assert sorted(mcp_where["server_id"]["in"]) == ["mcp-a", "mcp-b", "mcp-ghost"]
agent_where = mock_prisma.db.litellm_agentstable.find_many.call_args.kwargs["where"]
assert sorted(agent_where["agent_id"]["in"]) == ["agent-a", "agent-ghost"]
key_where = mock_prisma.db.litellm_verificationtoken.find_many.call_args.kwargs["where"]
assert sorted(key_where["token"]["in"]) == ["key-a", "key-b"]
def test_list_access_groups_resolves_names_with_one_query_per_table(client_and_mocks):
"""List batches every group's ids into one lookup per table and attributes names back to the right group."""
client, mock_prisma, mock_table, *_ = client_and_mocks
mock_table.find_many = AsyncMock(
return_value=[
_make_access_group_record(
access_group_id="ag-1", access_mcp_server_ids=["mcp-a"], access_agent_ids=["agent-a"], assigned_key_ids=["key-a"]
),
_make_access_group_record(
access_group_id="ag-2", access_mcp_server_ids=["mcp-b"], access_agent_ids=["agent-b"], assigned_key_ids=["key-b"]
),
]
)
_mock_resource_tables(
mock_prisma,
mcp_servers=[_make_mcp_server_record("mcp-a", alias="A"), _make_mcp_server_record("mcp-b", alias="B")],
agents=[_make_agent_record("agent-a", "Agent A"), _make_agent_record("agent-b", "Agent B")],
keys=[_make_key_record("key-a", key_alias="Key A"), _make_key_record("key-b", key_alias="Key B")],
)
resp = client.get("/v1/access_group")
assert resp.status_code == 200
first, second = resp.json()
assert first["access_mcp_servers"] == [{"id": "mcp-a", "name": "A"}]
assert first["access_agents"] == [{"id": "agent-a", "name": "Agent A"}]
assert first["assigned_keys"] == [{"id": "key-a", "name": "Key A"}]
assert second["access_mcp_servers"] == [{"id": "mcp-b", "name": "B"}]
assert second["access_agents"] == [{"id": "agent-b", "name": "Agent B"}]
assert second["assigned_keys"] == [{"id": "key-b", "name": "Key B"}]
for table, column in (
(mock_prisma.db.litellm_mcpservertable, "server_id"),
(mock_prisma.db.litellm_agentstable, "agent_id"),
(mock_prisma.db.litellm_verificationtoken, "token"),
):
table.find_many.assert_awaited_once()
assert len(table.find_many.call_args.kwargs["where"][column]["in"]) == 2
def test_list_access_groups_skips_lookups_when_nothing_to_resolve(client_and_mocks):
"""Groups with no MCP servers, agents or keys must not trigger an empty IN () query per table."""
client, mock_prisma, mock_table, *_ = client_and_mocks
mock_table.find_many = AsyncMock(
return_value=[_make_access_group_record(access_group_id="ag-1"), _make_access_group_record(access_group_id="ag-2")]
)
resp = client.get("/v1/access_group")
assert resp.status_code == 200
assert all(group["access_mcp_servers"] == [] and group["assigned_keys"] == [] for group in resp.json())
mock_prisma.db.litellm_mcpservertable.find_many.assert_not_awaited()
mock_prisma.db.litellm_agentstable.find_many.assert_not_awaited()
mock_prisma.db.litellm_verificationtoken.find_many.assert_not_awaited()
def test_create_access_group_response_carries_resolved_names(client_and_mocks):
"""The create response already shows names so the UI never has to refetch to label what it just saved."""
client, mock_prisma, *_ = client_and_mocks
team_record = _make_team_record("team-1", team_alias="Platform")
mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=team_record)
_mock_resource_tables(
mock_prisma,
mcp_servers=[_make_mcp_server_record("mcp-a", alias="GitHub")],
agents=[_make_agent_record("agent-a", "support-bot")],
teams=[team_record],
)
resp = client.post(
"/v1/access_group",
json={
"access_group_name": "new-group",
"access_mcp_server_ids": ["mcp-a"],
"access_agent_ids": ["agent-a"],
"assigned_team_ids": ["team-1"],
},
)
assert resp.status_code == 201
body = resp.json()
assert body["access_mcp_servers"] == [{"id": "mcp-a", "name": "GitHub"}]
assert body["access_agents"] == [{"id": "agent-a", "name": "support-bot"}]
assert body["assigned_teams"] == [{"id": "team-1", "name": "Platform"}]
def test_update_access_group_response_carries_resolved_names(client_and_mocks):
"""The update response reflects the new ids with their names, not the pre-update state."""
client, mock_prisma, mock_table, *_ = client_and_mocks
mock_table.find_unique = AsyncMock(
return_value=_make_access_group_record(access_group_id="ag-update", access_mcp_server_ids=["mcp-old"])
)
_mock_resource_tables(
mock_prisma,
mcp_servers=[_make_mcp_server_record("mcp-new", alias="Linear")],
agents=[_make_agent_record("agent-a", "support-bot")],
)
resp = client.put(
"/v1/access_group/ag-update", json={"access_mcp_server_ids": ["mcp-new"], "access_agent_ids": ["agent-a"]}
)
assert resp.status_code == 200
body = resp.json()
assert body["access_mcp_servers"] == [{"id": "mcp-new", "name": "Linear"}]
assert body["access_agents"] == [{"id": "agent-a", "name": "support-bot"}]
assert body["access_mcp_server_ids"] == ["mcp-new"]

View file

@ -0,0 +1,130 @@
import types
from types import MappingProxyType
from unittest.mock import AsyncMock
import pytest
from litellm.proxy.agent_endpoints.agent_registry import AgentRegistry
from litellm.proxy.management_helpers.resource_display_names import (
agent_display_names,
key_display_names,
mcp_server_display_names,
)
from litellm.types.agents import AgentResponse
from litellm.types.mcp_server.mcp_server_manager import MCPServer
def _table(rows=()):
return types.SimpleNamespace(find_many=AsyncMock(return_value=list(rows)))
def _prisma(**tables):
return types.SimpleNamespace(db=types.SimpleNamespace(**tables))
def _config_server(server_id: str, name: str, alias: str | None = None, server_name: str | None = None) -> MCPServer:
return MCPServer(server_id=server_id, name=name, alias=alias, server_name=server_name, transport="http")
def _registry_with(*agents: AgentResponse, legacy_ids: dict[str, str] | None = None) -> AgentRegistry:
registry = AgentRegistry()
for agent in agents:
registry.register_agent(agent)
registry.config_agent_legacy_ids = MappingProxyType(legacy_ids or {})
return registry
def _agent(agent_id: str, agent_name: str) -> AgentResponse:
return AgentResponse(agent_id=agent_id, agent_name=agent_name, agent_card_params={})
@pytest.mark.asyncio
async def test_mcp_db_row_beats_config_entry_for_the_same_server():
"""The DB is authoritative when both sources know a server; the registry may lag behind a rename on another pod."""
prisma = _prisma(
litellm_mcpservertable=_table([types.SimpleNamespace(server_id="s1", alias="db-alias", server_name=None)])
)
names = await mcp_server_display_names(prisma, ("s1",), {"s1": _config_server("s1", "config-name")})
assert dict(names) == {"s1": "db-alias"}
@pytest.mark.asyncio
@pytest.mark.parametrize(
("alias", "server_name", "expected"),
[("Alias", "server_name", "Alias"), (None, "server_name", "server_name"), (None, None, "config-name")],
)
async def test_mcp_config_only_server_falls_back_alias_then_server_name_then_name(alias, server_name, expected):
"""Config-declared servers have no DB row, so their registry entry supplies the label."""
prisma = _prisma(litellm_mcpservertable=_table())
config = {"s1": _config_server("s1", "config-name", alias=alias, server_name=server_name)}
names = await mcp_server_display_names(prisma, ("s1",), config)
assert dict(names) == {"s1": expected}
@pytest.mark.asyncio
async def test_mcp_db_row_without_alias_or_server_name_yields_no_label():
"""A bare DB row must not produce an empty string label; the caller falls back to the id."""
prisma = _prisma(
litellm_mcpservertable=_table([types.SimpleNamespace(server_id="s1", alias=None, server_name=None)])
)
assert dict(await mcp_server_display_names(prisma, ("s1",), {})) == {}
@pytest.mark.asyncio
async def test_mcp_only_requested_ids_are_returned_and_the_query_is_deduped():
"""Unrequested config servers stay out of the result and repeated ids collapse to one IN filter entry."""
table = _table([types.SimpleNamespace(server_id="s1", alias="A", server_name=None)])
prisma = _prisma(litellm_mcpservertable=table)
config = {"other": _config_server("other", "not-requested")}
names = await mcp_server_display_names(prisma, ("s1", "s1", "missing"), config)
assert dict(names) == {"s1": "A"}
assert sorted(table.find_many.call_args.kwargs["where"]["server_id"]["in"]) == ["missing", "s1"]
@pytest.mark.asyncio
async def test_mcp_empty_ids_skip_the_db():
table = _table()
names = await mcp_server_display_names(_prisma(litellm_mcpservertable=table), (), {})
assert dict(names) == {}
table.find_many.assert_not_awaited()
@pytest.mark.asyncio
async def test_agent_db_name_beats_registry_name():
prisma = _prisma(litellm_agentstable=_table([types.SimpleNamespace(agent_id="a1", agent_name="from-db")]))
registry = _registry_with(_agent("a1", "from-registry"))
assert dict(await agent_display_names(prisma, ("a1",), registry)) == {"a1": "from-db"}
@pytest.mark.asyncio
async def test_agent_legacy_config_id_resolves_to_the_stable_agent_name():
"""Access groups saved before agent ids were stabilised still carry the legacy hash; it must still get a name."""
prisma = _prisma(litellm_agentstable=_table())
registry = _registry_with(_agent("stable-id", "config-agent"), legacy_ids={"legacy-id": "stable-id"})
names = await agent_display_names(prisma, ("legacy-id", "stable-id", "unknown"), registry)
assert dict(names) == {"legacy-id": "config-agent", "stable-id": "config-agent"}
@pytest.mark.asyncio
async def test_agent_empty_ids_skip_the_db():
table = _table()
names = await agent_display_names(_prisma(litellm_agentstable=table), (), _registry_with())
assert dict(names) == {}
table.find_many.assert_not_awaited()
@pytest.mark.asyncio
async def test_key_alias_only_for_keys_that_have_one():
table = _table(
[types.SimpleNamespace(token="k1", key_alias="ci-key"), types.SimpleNamespace(token="k2", key_alias=None)]
)
names = await key_display_names(_prisma(litellm_verificationtoken=table), ("k1", "k2", "k1"))
assert dict(names) == {"k1": "ci-key"}
assert sorted(table.find_many.call_args.kwargs["where"]["token"]["in"]) == ["k1", "k2"]
@pytest.mark.asyncio
async def test_key_empty_ids_skip_the_db():
table = _table()
assert dict(await key_display_names(_prisma(litellm_verificationtoken=table), ())) == {}
table.find_many.assert_not_awaited()

View file

@ -7,6 +7,7 @@ import { renderWithProviders } from "../../../../../tests/test-utils";
import { AccessGroupDetail } from "./AccessGroupsDetailsPage";
vi.mock("@/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails");
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
vi.mock("./AccessGroupsModal/AccessGroupEditModal", () => ({
AccessGroupEditModal: ({ visible, onCancel }: { visible: boolean; onCancel: () => void }) =>
visible ? (
@ -44,6 +45,8 @@ const baseMockReturnValue = {
refetch: vi.fn(),
} as unknown as ReturnType<typeof useAccessGroupDetails>;
const unnamed = (ids: readonly string[]) => ids.map((id) => ({ id, name: null }));
const createMockAccessGroup = (overrides: Partial<AccessGroupResponse> = {}): AccessGroupResponse => ({
access_group_id: "ag-1",
access_group_name: "Test Group",
@ -53,6 +56,13 @@ const createMockAccessGroup = (overrides: Partial<AccessGroupResponse> = {}): Ac
access_agent_ids: ["agent-1"],
assigned_team_ids: ["team-1"],
assigned_key_ids: ["key-1", "key-2"],
access_mcp_servers: [{ id: "mcp-1", name: "GitHub MCP" }],
access_agents: [{ id: "agent-1", name: "Support Agent" }],
assigned_teams: [{ id: "team-1", name: "Platform Team" }],
assigned_keys: [
{ id: "key-1", name: "ci-key" },
{ id: "key-2", name: null },
],
created_at: "2025-01-01T00:00:00Z",
created_by: null,
updated_at: "2025-01-02T00:00:00Z",
@ -60,6 +70,14 @@ const createMockAccessGroup = (overrides: Partial<AccessGroupResponse> = {}): Ac
...overrides,
});
const renderWith = (overrides: Partial<AccessGroupResponse> = {}) => {
mockUseAccessGroupDetails.mockReturnValue({
...baseMockReturnValue,
data: createMockAccessGroup(overrides),
} as ReturnType<typeof useAccessGroupDetails>);
return renderWithProviders(<AccessGroupDetail accessGroupId="ag-1" onBack={vi.fn()} />);
};
describe("AccessGroupDetail", () => {
const mockOnBack = vi.fn();
const accessGroupId = "ag-1";
@ -106,9 +124,7 @@ describe("AccessGroupDetail", () => {
const user = userEvent.setup();
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
const buttons = screen.getAllByRole("button");
const backButton = buttons.find((btn) => !btn.textContent?.includes("Edit"));
await user.click(backButton!);
await user.click(screen.getByRole("button", { name: "Back" }));
expect(mockOnBack).toHaveBeenCalledTimes(1);
});
@ -128,12 +144,7 @@ describe("AccessGroupDetail", () => {
});
it("should display em dash when description is empty", () => {
mockUseAccessGroupDetails.mockReturnValue({
...baseMockReturnValue,
data: createMockAccessGroup({ description: null }),
} as ReturnType<typeof useAccessGroupDetails>);
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
renderWith({ description: null });
expect(screen.getByText("—")).toBeInTheDocument();
});
@ -144,8 +155,7 @@ describe("AccessGroupDetail", () => {
expect(screen.queryByRole("dialog", { name: "Edit Access Group" })).not.toBeInTheDocument();
const editButton = screen.getByRole("button", { name: /Edit Access Group/i });
await user.click(editButton);
await user.click(screen.getByRole("button", { name: /Edit Access Group/i }));
expect(screen.getByRole("dialog", { name: "Edit Access Group" })).toBeInTheDocument();
});
@ -161,88 +171,126 @@ describe("AccessGroupDetail", () => {
expect(screen.queryByRole("dialog", { name: "Edit Access Group" })).not.toBeInTheDocument();
});
it("should display attached keys", () => {
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
describe("attached keys", () => {
it("should show the key alias and hide the token when the key has an alias", () => {
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
expect(screen.getByText("Attached Keys")).toBeInTheDocument();
expect(screen.getByText("key-1")).toBeInTheDocument();
expect(screen.getByText("key-2")).toBeInTheDocument();
expect(screen.getByText("Attached Keys")).toBeInTheDocument();
expect(screen.getByText("ci-key")).toBeInTheDocument();
expect(screen.queryByText("key-1")).not.toBeInTheDocument();
});
it("should fall back to the token when the key has no alias", () => {
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
expect(screen.getByText("key-2")).toBeInTheDocument();
});
it("should link each key to its detail page", () => {
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
expect(screen.getByRole("link", { name: "ci-key" })).toHaveAttribute(
"href",
expect.stringContaining("key=key-1"),
);
expect(screen.getByRole("link", { name: "key-2" })).toHaveAttribute("href", expect.stringContaining("key=key-2"));
});
it("should reveal the token in a tooltip when hovering an aliased key", async () => {
const user = userEvent.setup();
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
await user.hover(screen.getByText("ci-key"));
expect(await screen.findByText("key-1")).toBeInTheDocument();
});
it("should show View All button for keys when more than 5", () => {
renderWith({ assigned_keys: unnamed(["k1", "k2", "k3", "k4", "k5", "k6"]) });
expect(screen.getByRole("button", { name: "View All (6)" })).toBeInTheDocument();
expect(screen.queryByText("k6")).not.toBeInTheDocument();
});
it("should toggle between View All and Show Less for keys", async () => {
const user = userEvent.setup();
renderWith({ assigned_keys: unnamed(["k1", "k2", "k3", "k4", "k5", "k6"]) });
await user.click(screen.getByRole("button", { name: "View All (6)" }));
expect(screen.getByRole("button", { name: "Show Less" })).toBeInTheDocument();
expect(screen.getByText("k6")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Show Less" }));
expect(screen.getByRole("button", { name: "View All (6)" })).toBeInTheDocument();
});
it("should show empty state when no keys attached", () => {
renderWith({ assigned_keys: [] });
expect(screen.getByText("No keys attached")).toBeInTheDocument();
});
it("should truncate long unaliased tokens with ellipsis", () => {
renderWith({ assigned_keys: unnamed(["a".repeat(25)]) });
expect(screen.getByText(/^a{10}\.\.\.a{6}$/)).toBeInTheDocument();
});
it("should not truncate a long alias", () => {
const alias = "b".repeat(25);
renderWith({ assigned_keys: [{ id: "a".repeat(25), name: alias }] });
expect(screen.getByText(alias)).toBeInTheDocument();
});
});
it("should display attached teams", () => {
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
describe("attached teams", () => {
it("should show the team alias and hide the id when the team has an alias", () => {
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
expect(screen.getByText("Attached Teams")).toBeInTheDocument();
expect(screen.getByText("team-1")).toBeInTheDocument();
expect(screen.getByText("Attached Teams")).toBeInTheDocument();
expect(screen.getByText("Platform Team")).toBeInTheDocument();
expect(screen.queryByText("team-1")).not.toBeInTheDocument();
});
it("should link each team to its detail page", () => {
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
expect(screen.getByRole("link", { name: "Platform Team" })).toHaveAttribute(
"href",
expect.stringContaining("team=team-1"),
);
});
it("should reveal the team id in a tooltip when hovering an aliased team", async () => {
const user = userEvent.setup();
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
await user.hover(screen.getByText("Platform Team"));
expect(await screen.findByText("team-1")).toBeInTheDocument();
});
it("should fall back to the team id when the team has no alias", () => {
renderWith({ assigned_teams: unnamed(["team-ghost"]) });
expect(screen.getByText("team-ghost")).toBeInTheDocument();
});
it("should show View All button for teams when more than 5", () => {
renderWith({ assigned_teams: unnamed(["t1", "t2", "t3", "t4", "t5", "t6"]) });
expect(screen.getByRole("button", { name: "View All (6)" })).toBeInTheDocument();
});
it("should show empty state when no teams attached", () => {
renderWith({ assigned_teams: [] });
expect(screen.getByText("No teams attached")).toBeInTheDocument();
});
});
it("should show View All button for keys when more than 5", () => {
mockUseAccessGroupDetails.mockReturnValue({
...baseMockReturnValue,
data: createMockAccessGroup({
assigned_key_ids: ["k1", "k2", "k3", "k4", "k5", "k6"],
}),
} as ReturnType<typeof useAccessGroupDetails>);
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
expect(screen.getByRole("button", { name: "View All (6)" })).toBeInTheDocument();
});
it("should toggle between View All and Show Less for keys", async () => {
const user = userEvent.setup();
mockUseAccessGroupDetails.mockReturnValue({
...baseMockReturnValue,
data: createMockAccessGroup({
assigned_key_ids: ["k1", "k2", "k3", "k4", "k5", "k6"],
}),
} as ReturnType<typeof useAccessGroupDetails>);
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
await user.click(screen.getByRole("button", { name: "View All (6)" }));
expect(screen.getByRole("button", { name: "Show Less" })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Show Less" }));
expect(screen.getByRole("button", { name: "View All (6)" })).toBeInTheDocument();
});
it("should show View All button for teams when more than 5", () => {
mockUseAccessGroupDetails.mockReturnValue({
...baseMockReturnValue,
data: createMockAccessGroup({
assigned_team_ids: ["t1", "t2", "t3", "t4", "t5", "t6"],
}),
} as ReturnType<typeof useAccessGroupDetails>);
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
expect(screen.getByRole("button", { name: "View All (6)" })).toBeInTheDocument();
});
it("should show empty state when no keys attached", () => {
mockUseAccessGroupDetails.mockReturnValue({
...baseMockReturnValue,
data: createMockAccessGroup({ assigned_key_ids: [] }),
} as ReturnType<typeof useAccessGroupDetails>);
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
expect(screen.getByText("No keys attached")).toBeInTheDocument();
});
it("should show empty state when no teams attached", () => {
mockUseAccessGroupDetails.mockReturnValue({
...baseMockReturnValue,
data: createMockAccessGroup({ assigned_team_ids: [] }),
} as ReturnType<typeof useAccessGroupDetails>);
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
expect(screen.getByText("No teams attached")).toBeInTheDocument();
});
it("should display Models tab with model IDs", () => {
it("should display Models tab with model names", () => {
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
expect(screen.getByRole("tab", { name: /Models/i })).toBeInTheDocument();
@ -250,73 +298,90 @@ describe("AccessGroupDetail", () => {
expect(screen.getByText("model-2")).toBeInTheDocument();
});
it("should display MCP Servers tab with server IDs", async () => {
const user = userEvent.setup();
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
describe("MCP Servers tab", () => {
it("should show server names instead of ids", async () => {
const user = userEvent.setup();
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
const mcpTab = screen.getByRole("tab", { name: /MCP Servers/i });
expect(mcpTab).toBeInTheDocument();
await user.click(mcpTab);
expect(screen.getByText("mcp-1")).toBeInTheDocument();
await user.click(screen.getByRole("tab", { name: /MCP Servers/i }));
expect(screen.getByText("GitHub MCP")).toBeInTheDocument();
expect(screen.queryByText("mcp-1")).not.toBeInTheDocument();
});
it("should reveal the server id in a tooltip when hovering the name", async () => {
const user = userEvent.setup();
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
await user.click(screen.getByRole("tab", { name: /MCP Servers/i }));
await user.hover(screen.getByText("GitHub MCP"));
expect(await screen.findByText("mcp-1")).toBeInTheDocument();
});
it("should fall back to the id when the server has no name", async () => {
const user = userEvent.setup();
renderWith({ access_mcp_servers: unnamed(["mcp-deleted"]) });
await user.click(screen.getByRole("tab", { name: /MCP Servers/i }));
expect(screen.getByText("mcp-deleted")).toBeInTheDocument();
});
it("should show empty state when none assigned", async () => {
const user = userEvent.setup();
renderWith({ access_mcp_servers: [] });
await user.click(screen.getByRole("tab", { name: /MCP Servers/i }));
expect(screen.getByText("No MCP servers assigned to this group")).toBeInTheDocument();
});
});
it("should display Agents tab with agent IDs", async () => {
const user = userEvent.setup();
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
describe("Agents tab", () => {
it("should show agent names instead of ids", async () => {
const user = userEvent.setup();
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
const agentsTab = screen.getByRole("tab", { name: /Agents/i });
expect(agentsTab).toBeInTheDocument();
await user.click(agentsTab);
expect(screen.getByText("agent-1")).toBeInTheDocument();
await user.click(screen.getByRole("tab", { name: /Agents/i }));
expect(screen.getByText("Support Agent")).toBeInTheDocument();
expect(screen.queryByText("agent-1")).not.toBeInTheDocument();
});
it("should fall back to the id when the agent has no name", async () => {
const user = userEvent.setup();
renderWith({ access_agents: unnamed(["agent-deleted"]) });
await user.click(screen.getByRole("tab", { name: /Agents/i }));
expect(screen.getByText("agent-deleted")).toBeInTheDocument();
});
it("should show empty state when none assigned", async () => {
const user = userEvent.setup();
renderWith({ access_agents: [] });
await user.click(screen.getByRole("tab", { name: /Agents/i }));
expect(screen.getByText("No agents assigned to this group")).toBeInTheDocument();
});
});
it("should show empty state in Models tab when no models assigned", () => {
mockUseAccessGroupDetails.mockReturnValue({
...baseMockReturnValue,
data: createMockAccessGroup({ access_model_names: [] }),
} as ReturnType<typeof useAccessGroupDetails>);
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
renderWith({ access_model_names: [] });
expect(screen.getByText("No models assigned to this group")).toBeInTheDocument();
});
it("should show empty state in MCP Servers tab when none assigned", async () => {
const user = userEvent.setup();
mockUseAccessGroupDetails.mockReturnValue({
...baseMockReturnValue,
data: createMockAccessGroup({ access_mcp_server_ids: [] }),
} as ReturnType<typeof useAccessGroupDetails>);
it("should count resources from the resolved lists in the tab badges", () => {
renderWith({
access_mcp_servers: unnamed(["m1", "m2", "m3"]),
access_agents: unnamed(["a1", "a2"]),
});
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
await user.click(screen.getByRole("tab", { name: /MCP Servers/i }));
expect(screen.getByText("No MCP servers assigned to this group")).toBeInTheDocument();
});
it("should show empty state in Agents tab when none assigned", async () => {
const user = userEvent.setup();
mockUseAccessGroupDetails.mockReturnValue({
...baseMockReturnValue,
data: createMockAccessGroup({ access_agent_ids: [] }),
} as ReturnType<typeof useAccessGroupDetails>);
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
await user.click(screen.getByRole("tab", { name: /Agents/i }));
expect(screen.getByText("No agents assigned to this group")).toBeInTheDocument();
});
it("should truncate long key IDs with ellipsis", () => {
const longKeyId = "a".repeat(25);
mockUseAccessGroupDetails.mockReturnValue({
...baseMockReturnValue,
data: createMockAccessGroup({ assigned_key_ids: [longKeyId] }),
} as ReturnType<typeof useAccessGroupDetails>);
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
expect(screen.getByText(/a{10}\.\.\.a{6}/)).toBeInTheDocument();
expect(screen.getByRole("tab", { name: /MCP Servers/i })).toHaveTextContent("3");
expect(screen.getByRole("tab", { name: /Agents/i })).toHaveTextContent("2");
});
it("should display created and last updated timestamps", () => {

View file

@ -2,14 +2,20 @@ import { useAccessGroupDetails } from "@/app/(dashboard)/hooks/accessGroups/useA
import { ArrowLeftIcon, BotIcon, EditIcon, KeyIcon, LayersIcon, ServerIcon, UsersIcon } from "lucide-react";
import { useState } from "react";
import DefaultProxyAdminTag from "@/components/common_components/DefaultProxyAdminTag";
import { BadgeLink } from "@/components/shared/BadgeLink";
import CopyButton from "@/components/shared/CopyButton";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { SimpleTooltip } from "@/components/ui/tooltip";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import type { components } from "@/lib/http/schema";
import { keyDetailHref, teamDetailHref } from "@/utils/entityLinks";
import { AccessGroupEditModal } from "./AccessGroupsModal/AccessGroupEditModal";
type AccessGroupResource = components["schemas"]["AccessGroupResource"];
interface AccessGroupDetailProps {
accessGroupId: string;
onBack: () => void;
@ -17,16 +23,24 @@ interface AccessGroupDetailProps {
const MAX_PREVIEW = 5;
function ResourceList({ ids, emptyMessage }: { ids: string[]; emptyMessage: string }) {
if (ids.length === 0) {
const shortId = (id: string) => (id.length > 20 ? `${id.slice(0, 10)}...${id.slice(-6)}` : id);
function ResourceList({ items, emptyMessage }: { items: readonly AccessGroupResource[]; emptyMessage: string }) {
if (items.length === 0) {
return <p className="py-8 text-center text-sm text-muted-foreground">{emptyMessage}</p>;
}
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{ids.map((id) => (
{items.map(({ id, name }) => (
<Card key={id} size="sm">
<CardContent>
<code className="font-mono text-xs break-all text-foreground">{id}</code>
{name ? (
<SimpleTooltip content={id}>
<span className="text-sm font-medium break-all text-foreground">{name}</span>
</SimpleTooltip>
) : (
<code className="font-mono text-xs break-all text-foreground">{id}</code>
)}
</CardContent>
</Card>
))}
@ -34,6 +48,23 @@ function ResourceList({ ids, emptyMessage }: { ids: string[]; emptyMessage: stri
);
}
function ResourceBadge({
resource: { id, name },
href,
fallback,
}: {
resource: AccessGroupResource;
href: string;
fallback: (id: string) => string;
}) {
const badge = (
<BadgeLink href={href} className={name ? undefined : "font-mono"}>
{name ?? fallback(id)}
</BadgeLink>
);
return name ? <SimpleTooltip content={id}>{badge}</SimpleTooltip> : badge;
}
export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailProps) {
const { data: accessGroup, isLoading } = useAccessGroupDetails(accessGroupId);
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
@ -61,14 +92,14 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr
);
}
const modelIds = accessGroup.access_model_names ?? [];
const mcpServerIds = accessGroup.access_mcp_server_ids ?? [];
const agentIds = accessGroup.access_agent_ids ?? [];
const keyIds = accessGroup.assigned_key_ids ?? [];
const teamIds = accessGroup.assigned_team_ids ?? [];
const models = accessGroup.access_model_names.map((id) => ({ id, name: null }));
const mcpServers = accessGroup.access_mcp_servers;
const agents = accessGroup.access_agents;
const keys = accessGroup.assigned_keys;
const teams = accessGroup.assigned_teams;
const displayedKeys = showAllKeys ? keyIds : keyIds.slice(0, MAX_PREVIEW);
const displayedTeams = showAllTeams ? teamIds : teamIds.slice(0, MAX_PREVIEW);
const displayedKeys = showAllKeys ? keys : keys.slice(0, MAX_PREVIEW);
const displayedTeams = showAllTeams ? teams : teams.slice(0, MAX_PREVIEW);
return (
<div className="p-6 px-12">
@ -129,23 +160,21 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr
<CardTitle className="flex items-center gap-2">
<KeyIcon className="size-4" />
Attached Keys
<Badge variant="secondary">{keyIds.length}</Badge>
<Badge variant="secondary">{keys.length}</Badge>
</CardTitle>
{keyIds.length > MAX_PREVIEW && (
{keys.length > MAX_PREVIEW && (
<CardAction>
<Button variant="link" size="sm" onClick={() => setShowAllKeys(!showAllKeys)}>
{showAllKeys ? "Show Less" : `View All (${keyIds.length})`}
{showAllKeys ? "Show Less" : `View All (${keys.length})`}
</Button>
</CardAction>
)}
</CardHeader>
<CardContent>
{keyIds.length > 0 ? (
{keys.length > 0 ? (
<div className="flex flex-wrap gap-2">
{displayedKeys.map((id) => (
<Badge key={id} variant="secondary" className="font-mono">
{id.length > 20 ? `${id.slice(0, 10)}...${id.slice(-6)}` : id}
</Badge>
{displayedKeys.map((key) => (
<ResourceBadge key={key.id} resource={key} href={keyDetailHref(key.id)} fallback={shortId} />
))}
</div>
) : (
@ -159,23 +188,21 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr
<CardTitle className="flex items-center gap-2">
<UsersIcon className="size-4" />
Attached Teams
<Badge variant="secondary">{teamIds.length}</Badge>
<Badge variant="secondary">{teams.length}</Badge>
</CardTitle>
{teamIds.length > MAX_PREVIEW && (
{teams.length > MAX_PREVIEW && (
<CardAction>
<Button variant="link" size="sm" onClick={() => setShowAllTeams(!showAllTeams)}>
{showAllTeams ? "Show Less" : `View All (${teamIds.length})`}
{showAllTeams ? "Show Less" : `View All (${teams.length})`}
</Button>
</CardAction>
)}
</CardHeader>
<CardContent>
{teamIds.length > 0 ? (
{teams.length > 0 ? (
<div className="flex flex-wrap gap-2">
{displayedTeams.map((id) => (
<Badge key={id} variant="secondary" className="font-mono">
{id}
</Badge>
{displayedTeams.map((team) => (
<ResourceBadge key={team.id} resource={team} href={teamDetailHref(team.id)} fallback={(id) => id} />
))}
</div>
) : (
@ -192,27 +219,27 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr
<TabsTrigger value="models" className="flex-none gap-2 rounded-none px-4 py-2">
<LayersIcon className="size-4" />
Models
<Badge variant="secondary">{modelIds.length}</Badge>
<Badge variant="secondary">{models.length}</Badge>
</TabsTrigger>
<TabsTrigger value="mcp" className="flex-none gap-2 rounded-none px-4 py-2">
<ServerIcon className="size-4" />
MCP Servers
<Badge variant="secondary">{mcpServerIds.length}</Badge>
<Badge variant="secondary">{mcpServers.length}</Badge>
</TabsTrigger>
<TabsTrigger value="agents" className="flex-none gap-2 rounded-none px-4 py-2">
<BotIcon className="size-4" />
Agents
<Badge variant="secondary">{agentIds.length}</Badge>
<Badge variant="secondary">{agents.length}</Badge>
</TabsTrigger>
</TabsList>
<TabsContent value="models" className="pt-4">
<ResourceList ids={modelIds} emptyMessage="No models assigned to this group" />
<ResourceList items={models} emptyMessage="No models assigned to this group" />
</TabsContent>
<TabsContent value="mcp" className="pt-4">
<ResourceList ids={mcpServerIds} emptyMessage="No MCP servers assigned to this group" />
<ResourceList items={mcpServers} emptyMessage="No MCP servers assigned to this group" />
</TabsContent>
<TabsContent value="agents" className="pt-4">
<ResourceList ids={agentIds} emptyMessage="No agents assigned to this group" />
<ResourceList items={agents} emptyMessage="No agents assigned to this group" />
</TabsContent>
</Tabs>
</CardContent>

View file

@ -42,6 +42,10 @@ const accessGroup: AccessGroupResponse = {
access_agent_ids: ["agent-1"],
assigned_team_ids: [],
assigned_key_ids: [],
access_mcp_servers: [{ id: "srv-1", name: "Server One" }],
access_agents: [{ id: "agent-1", name: "Agent One" }],
assigned_teams: [],
assigned_keys: [],
created_at: "2024-01-01T00:00:00Z",
created_by: "user-1",
updated_at: "2024-01-02T00:00:00Z",

View file

@ -15,6 +15,10 @@ const mockAccessGroups: AccessGroupResponse[] = [
access_agent_ids: ["a1"],
assigned_team_ids: [],
assigned_key_ids: [],
access_mcp_servers: [{ id: "s1", name: "Server One" }],
access_agents: [{ id: "a1", name: "Agent One" }],
assigned_teams: [],
assigned_keys: [],
created_at: "2024-01-15T10:00:00Z",
created_by: "user-1",
updated_at: "2024-01-20T12:00:00Z",
@ -29,6 +33,10 @@ const mockAccessGroups: AccessGroupResponse[] = [
access_agent_ids: [],
assigned_team_ids: [],
assigned_key_ids: [],
access_mcp_servers: [],
access_agents: [],
assigned_teams: [],
assigned_keys: [],
created_at: "2024-01-10T09:00:00Z",
created_by: null,
updated_at: "2024-01-12T11:00:00Z",

View file

@ -46,6 +46,10 @@ const mockAccessGroups: AccessGroupResponse[] = [
access_agent_ids: [],
assigned_team_ids: [],
assigned_key_ids: [],
access_mcp_servers: [],
access_agents: [],
assigned_teams: [],
assigned_keys: [],
created_at: "2025-01-01T00:00:00Z",
created_by: "user-1",
updated_at: "2025-01-01T00:00:00Z",

View file

@ -3,23 +3,11 @@ import { createQueryKeys } from "../common/queryKeysFactory";
import { getProxyBaseUrl, getGlobalLitellmHeaderName, deriveErrorMessage, handleError } from "@/components/networking";
import { all_admin_roles } from "@/utils/roles";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import type { components } from "@/lib/http/schema";
// ── Types ────────────────────────────────────────────────────────────────────
export interface AccessGroupResponse {
access_group_id: string;
access_group_name: string;
description: string | null;
access_model_names: string[];
access_mcp_server_ids: string[];
access_agent_ids: string[];
assigned_team_ids: string[];
assigned_key_ids: string[];
created_at: string;
created_by: string | null;
updated_at: string;
updated_by: string | null;
}
export type AccessGroupResponse = components["schemas"]["AccessGroupResponse"];
// ── Query keys (shared across access-group hooks) ────────────────────────────

View file

@ -22764,22 +22764,40 @@ export interface components {
/** Spend */
spend?: number | null;
};
/**
* AccessGroupResource
* @description A resource referenced by an access group. `name` is null when the id no longer resolves or has no alias.
*/
AccessGroupResource: {
/** Id */
id: string;
/** Name */
name: string | null;
};
/** AccessGroupResponse */
AccessGroupResponse: {
/** Access Agent Ids */
access_agent_ids: string[];
/** Access Agents */
access_agents: components["schemas"]["AccessGroupResource"][];
/** Access Group Id */
access_group_id: string;
/** Access Group Name */
access_group_name: string;
/** Access Mcp Server Ids */
access_mcp_server_ids: string[];
/** Access Mcp Servers */
access_mcp_servers: components["schemas"]["AccessGroupResource"][];
/** Access Model Names */
access_model_names: string[];
/** Assigned Key Ids */
assigned_key_ids: string[];
/** Assigned Keys */
assigned_keys: components["schemas"]["AccessGroupResource"][];
/** Assigned Team Ids */
assigned_team_ids: string[];
/** Assigned Teams */
assigned_teams: components["schemas"]["AccessGroupResource"][];
/**
* Created At
* Format: date-time