mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-05 08:07:05 +00:00
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:
commit
2151dcbd73
13 changed files with 850 additions and 212 deletions
|
|
@ -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"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
61
litellm/proxy/management_helpers/resource_display_names.py
Normal file
61
litellm/proxy/management_helpers/resource_display_names.py
Normal 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})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) ────────────────────────────
|
||||
|
||||
|
|
|
|||
18
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
18
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue