From b8edaf62d9190fb0c2a38e71e0f10684ec7e69a8 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Wed, 3 Jun 2026 14:04:26 -0700 Subject: [PATCH] fix(agents): show an agent's attached virtual key in the UI The A2A agent detail view never surfaced which virtual key was attached to an agent, so after assigning a key during agent creation there was no way to see it again. Surface the attached key(s) in the agent detail view, derived from the key table's agent_id foreign key the same way spend is already joined into the agent response. Backend adds an agent_id filter to /key/list (mirrors team_id) and enriches GET /v1/agents and GET /v1/agents/{id} with a non-secret key summary (alias, masked key_name, hashed token id). The frontend renders a Virtual Keys section in the agent detail view that lists the agent's keys and links through to the key detail, and the list view drops its fetch-500-keys-and- filter-client-side workaround in favor of the enriched response. The orphaned AgentCard and AgentCardGrid components, left behind when the agent list switched from a card grid to a table, are removed --- litellm/proxy/agent_endpoints/endpoints.py | 28 +++++ .../key_management_endpoints.py | 7 ++ litellm/types/agents.py | 7 ++ .../proxy/agent_endpoints/test_endpoints.py | 50 +++++++++ .../test_key_management_endpoints.py | 45 ++++++++ .../(dashboard)/hooks/keys/useKeys.test.ts | 38 +++++++ .../src/app/(dashboard)/hooks/keys/useKeys.ts | 2 + .../src/components/agents.test.tsx | 9 -- .../src/components/agents.tsx | 47 +------- .../src/components/agents/agent_card.test.tsx | 99 ----------------- .../src/components/agents/agent_card.tsx | 103 ------------------ .../agents/agent_card_grid.test.tsx | 90 --------------- .../src/components/agents/agent_card_grid.tsx | 67 ------------ .../src/components/agents/agent_info.tsx | 33 ++++++ .../agents/agent_virtual_keys.test.tsx | 51 +++++++++ .../components/agents/agent_virtual_keys.tsx | 46 ++++++++ .../src/components/agents/types.ts | 9 +- 17 files changed, 315 insertions(+), 416 deletions(-) delete mode 100644 ui/litellm-dashboard/src/components/agents/agent_card.test.tsx delete mode 100644 ui/litellm-dashboard/src/components/agents/agent_card.tsx delete mode 100644 ui/litellm-dashboard/src/components/agents/agent_card_grid.test.tsx delete mode 100644 ui/litellm-dashboard/src/components/agents/agent_card_grid.tsx create mode 100644 ui/litellm-dashboard/src/components/agents/agent_virtual_keys.test.tsx create mode 100644 ui/litellm-dashboard/src/components/agents/agent_virtual_keys.tsx diff --git a/litellm/proxy/agent_endpoints/endpoints.py b/litellm/proxy/agent_endpoints/endpoints.py index 082e314b08d..ad415d4fb0b 100644 --- a/litellm/proxy/agent_endpoints/endpoints.py +++ b/litellm/proxy/agent_endpoints/endpoints.py @@ -25,6 +25,7 @@ from litellm.proxy.common_utils.rbac_utils import check_feature_access_for_user from litellm.proxy.management_endpoints.common_daily_activity import get_daily_activity from litellm.types.agents import ( AgentConfig, + AgentKeySummary, AgentMakePublicResponse, AgentResponse, MakeAgentsPublicRequest, @@ -68,6 +69,30 @@ def _build_merged_agent_card( router = APIRouter() +async def _attach_keys_to_agents(agents: List[AgentResponse], prisma_client) -> None: + """Attach each agent's virtual keys, derived from the key table's agent_id + foreign key. Mirrors how spend is joined into the agent response so the UI + never has to cross-reference a full key dump client-side. Only non-secret + fields are exposed (alias, masked key_name, hashed token).""" + agent_ids = [agent.agent_id for agent in agents] + if not agent_ids: + return + key_rows = await prisma_client.db.litellm_verificationtoken.find_many( + where={"agent_id": {"in": agent_ids}}, + ) + keys_by_agent: Dict[str, List[AgentKeySummary]] = {} + for row in key_rows: + keys_by_agent.setdefault(row.agent_id, []).append( + AgentKeySummary( + token=row.token, + key_alias=row.key_alias, + key_name=row.key_name, + ) + ) + for agent in agents: + agent.keys = keys_by_agent.get(agent.agent_id) + + def _redact_sensitive_agent_fields( agents: List[AgentResponse], ) -> List[AgentResponse]: @@ -226,6 +251,7 @@ async def get_agents( for agent in returned_agents: if agent.agent_id in spend_map: agent.spend = spend_map[agent.agent_id] + await _attach_keys_to_agents(returned_agents, prisma_client) # add is_public field to each agent - we do it this way, to allow setting config agents as public for agent in returned_agents: @@ -502,6 +528,8 @@ async def get_agent_by_id( status_code=404, detail=f"Agent with ID {agent_id} not found" ) + await _attach_keys_to_agents([agent], prisma_client) + # Redact sensitive fields for non-admin users is_admin = ( user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index cf90f0661b3..3ebca0ce845 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -4987,6 +4987,7 @@ async def list_keys( access_group_id: Optional[str] = Query( None, description="Filter keys by access group ID" ), + agent_id: Optional[str] = Query(None, description="Filter keys by agent ID"), ) -> KeyListResponseObject: """ List all keys for a given user / team / organization. @@ -5095,6 +5096,7 @@ async def list_keys( status=status, project_id=project_id, access_group_id=access_group_id, + agent_id=agent_id, use_substring_matching=use_substring_matching, ) @@ -5333,6 +5335,7 @@ def _build_key_filter_conditions( include_created_by_keys: bool = False, project_id: Optional[str] = None, access_group_id: Optional[str] = None, + agent_id: Optional[str] = None, use_substring_matching: bool = False, ) -> Dict[str, Union[str, Dict[str, Any], List[Dict[str, Any]]]]: """Build filter conditions for key listing. @@ -5443,6 +5446,8 @@ def _build_key_filter_conditions( where = {"AND": [where, {"project_id": project_id}]} if access_group_id: where = {"AND": [where, {"access_group_ids": {"hasSome": [access_group_id]}}]} + if agent_id and isinstance(agent_id, str): + where = {"AND": [where, {"agent_id": agent_id}]} verbose_proxy_logger.debug(f"Filter conditions: {where}") return where @@ -5472,6 +5477,7 @@ async def _list_key_helper( status: Optional[str] = None, project_id: Optional[str] = None, access_group_id: Optional[str] = None, + agent_id: Optional[str] = None, use_substring_matching: bool = False, ) -> KeyListResponseObject: """ @@ -5508,6 +5514,7 @@ async def _list_key_helper( include_created_by_keys=include_created_by_keys, project_id=project_id, access_group_id=access_group_id, + agent_id=agent_id, use_substring_matching=use_substring_matching, ) diff --git a/litellm/types/agents.py b/litellm/types/agents.py index 8556b6bac93..d87cd78578e 100644 --- a/litellm/types/agents.py +++ b/litellm/types/agents.py @@ -205,6 +205,12 @@ class PatchAgentRequest(TypedDict, total=False): # Request/Response models for CRUD endpoints +class AgentKeySummary(BaseModel): + token: str + key_alias: Optional[str] = None + key_name: Optional[str] = None + + class AgentResponse(BaseModel): agent_id: str agent_name: str @@ -218,6 +224,7 @@ class AgentResponse(BaseModel): session_rpm_limit: Optional[int] = None static_headers: Optional[Dict[str, str]] = None extra_headers: Optional[List[str]] = None + keys: Optional[List[AgentKeySummary]] = None created_at: Optional[datetime] = None updated_at: Optional[datetime] = None created_by: Optional[str] = None diff --git a/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py b/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py index 68df365ff37..9fcf4a71d39 100644 --- a/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py +++ b/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py @@ -7,6 +7,7 @@ from fastapi.testclient import TestClient from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth from litellm.proxy.agent_endpoints import endpoints as agent_endpoints from litellm.proxy.agent_endpoints.endpoints import ( + _attach_keys_to_agents, _check_agent_management_permission, get_agent_daily_activity, router, @@ -275,6 +276,52 @@ async def test_get_agent_daily_activity_with_agent_names(monkeypatch): } +@pytest.mark.asyncio +async def test_attach_keys_to_agents_groups_by_agent_and_omits_secret(): + """ + The agents response must carry each agent's attached virtual keys (derived + from the key table's agent_id FK), grouped per agent, exposing only + non-secret summary fields. Agents with no key get None so the UI renders + "Needs Setup" rather than a stale badge. + """ + + class _Row: + def __init__(self, token, agent_id, key_alias, key_name): + self.token = token + self.agent_id = agent_id + self.key_alias = key_alias + self.key_name = key_name + self.user_id = "secret-owner" # extra field that must NOT leak + + agent_with_keys = _sample_agent_response(agent_id="agent-1") + agent_without_keys = _sample_agent_response(agent_id="agent-2") + + mock_prisma = MagicMock() + mock_prisma.db.litellm_verificationtoken.find_many = AsyncMock( + return_value=[ + _Row("hash-aaa", "agent-1", "primary", "sk-...aaa"), + _Row("hash-bbb", "agent-1", "backup", "sk-...bbb"), + ] + ) + + await _attach_keys_to_agents([agent_with_keys, agent_without_keys], mock_prisma) + + # Query is scoped to the agents being returned, not the whole key table. + where = mock_prisma.db.litellm_verificationtoken.find_many.call_args.kwargs["where"] + assert where == {"agent_id": {"in": ["agent-1", "agent-2"]}} + + # agent-1 gets both of its keys; agent-2 gets None. + assert agent_without_keys.keys is None + assert agent_with_keys.keys is not None + assert {k.token for k in agent_with_keys.keys} == {"hash-aaa", "hash-bbb"} + assert {k.key_alias for k in agent_with_keys.keys} == {"primary", "backup"} + + # Only summary fields are exposed; the row's user_id must not be carried. + summary = agent_with_keys.keys[0] + assert not hasattr(summary, "user_id") + assert set(summary.model_dump().keys()) == {"token", "key_alias", "key_name"} + + # ---------- RBAC enforcement tests ---------- @@ -302,6 +349,9 @@ class TestAgentRBACInternalUser: mock_prisma.db.litellm_agentstable.find_unique = AsyncMock( return_value=None ) + mock_prisma.db.litellm_verificationtoken.find_many = AsyncMock( + return_value=[] + ) resp = self.internal_client.get( "/v1/agents/agent-123", headers={"Authorization": "Bearer k"} ) diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index cda22da6ebd..83e863d83ca 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -5652,6 +5652,51 @@ def test_build_key_filter_conditions_member_only_team_restricts_to_service_accou ), f"member-only team must be restricted to user_id=NULL keys, got: {serialized}" +def test_build_key_filter_conditions_agent_id_narrows_visibility(): + """ + Filtering /key/list by agent_id must AND the agent_id on top of the + caller's visibility conditions (it can only narrow results, never widen + them). Without this the UI cannot list an agent's keys server-side and + falls back to fetching every key and filtering client-side. + """ + from litellm.proxy.management_endpoints.key_management_endpoints import ( + _build_key_filter_conditions, + ) + + agent_id = "agent-123" + where = _build_key_filter_conditions( + user_id="some-user", + team_id=None, + organization_id=None, + key_alias=None, + key_hash=None, + exclude_team_id=None, + admin_team_ids=["team-a"], + member_team_ids=None, + include_created_by_keys=False, + agent_id=agent_id, + ) + + # agent_id must be applied as a top-level AND so it intersects (narrows) the + # visibility OR-conditions rather than being added as another OR branch. + assert where.get("AND"), f"expected top-level AND, got: {where}" + assert {"agent_id": agent_id} in where["AND"], f"agent_id not ANDed: {where}" + + # Omitting agent_id must not introduce any agent_id constraint. + where_without = _build_key_filter_conditions( + user_id="some-user", + team_id=None, + organization_id=None, + key_alias=None, + key_hash=None, + exclude_team_id=None, + admin_team_ids=["team-a"], + member_team_ids=None, + include_created_by_keys=False, + ) + assert "agent_id" not in json.dumps(where_without) + + @pytest.mark.asyncio async def test_generate_key_negative_max_budget(): """ diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.test.ts index 80cb69495da..cb05eb11e25 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.test.ts @@ -468,6 +468,44 @@ describe("useKeys", () => { const callUrl = mockFetch.mock.calls[0][0]; expect(callUrl).not.toContain("project_id"); }); + + it("should pass agentID filter to the API", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockKeysResponse, + }); + + const { result } = renderHook( + () => useKeys(1, 10, { agentID: "agent-123" }), + { wrapper }, + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + const callUrl = mockFetch.mock.calls[0][0]; + expect(callUrl).toContain("agent_id=agent-123"); + }); + + it("should not include agent_id param when agentID is null", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockKeysResponse, + }); + + const { result } = renderHook( + () => useKeys(1, 10, { agentID: null }), + { wrapper }, + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + const callUrl = mockFetch.mock.calls[0][0]; + expect(callUrl).not.toContain("agent_id"); + }); }); describe("useDeletedKeys", () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts index fbe5eccb75a..7f999e68231 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts @@ -34,6 +34,7 @@ export interface KeyListCallOptions { organizationID?: string | null; teamID?: string | null; projectID?: string | null; + agentID?: string | null; selectedKeyAlias?: string | null; userID?: string | null; keyHash?: string | null; @@ -59,6 +60,7 @@ const keyListCall = async ( Object.entries({ team_id: options.teamID, project_id: options.projectID, + agent_id: options.agentID, organization_id: options.organizationID, key_alias: options.selectedKeyAlias, key_hash: options.keyHash, diff --git a/ui/litellm-dashboard/src/components/agents.test.tsx b/ui/litellm-dashboard/src/components/agents.test.tsx index 3b0f4341da8..7a4b0b5f8b3 100644 --- a/ui/litellm-dashboard/src/components/agents.test.tsx +++ b/ui/litellm-dashboard/src/components/agents.test.tsx @@ -7,21 +7,12 @@ import * as networking from "./networking"; vi.mock("./networking", () => ({ getAgentsList: vi.fn().mockResolvedValue({ agents: [] }), deleteAgentCall: vi.fn(), - keyListCall: vi.fn().mockResolvedValue({ keys: [] }), })); vi.mock("./agents/add_agent_form", () => ({ default: () =>
, })); -vi.mock("./agents/agent_card_grid", () => ({ - default: ({ isAdmin }: { isAdmin: boolean }) => ( -
- ), -})); - -// Note: agents.tsx no longer uses AgentCardGrid — it renders a Table directly. - vi.mock("./agents/agent_info", () => ({ default: () =>
, })); diff --git a/ui/litellm-dashboard/src/components/agents.tsx b/ui/litellm-dashboard/src/components/agents.tsx index 542d890e12f..0d8646c6934 100644 --- a/ui/litellm-dashboard/src/components/agents.tsx +++ b/ui/litellm-dashboard/src/components/agents.tsx @@ -13,12 +13,12 @@ import { } from "@tremor/react"; import { Modal, Alert, Tooltip, Skeleton, Switch } from "antd"; import { CheckCircleOutlined } from "@ant-design/icons"; -import { getAgentsList, deleteAgentCall, keyListCall } from "./networking"; +import { getAgentsList, deleteAgentCall } from "./networking"; import AddAgentForm from "./agents/add_agent_form"; import { isAdminRole } from "@/utils/roles"; import AgentInfoView from "./agents/agent_info"; import NotificationsManager from "./molecules/notifications_manager"; -import { Agent, AgentKeyInfo } from "./agents/types"; +import { Agent } from "./agents/types"; import { Team } from "./key_team_helpers/key_list"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import TableIconActionButton from "./common_components/IconActionButton/TableIconActionButtons/TableIconActionButton"; @@ -35,7 +35,6 @@ interface AgentsResponse { const AgentsPanel: React.FC = ({ accessToken, userRole, teams }) => { const [agentsList, setAgentsList] = useState([]); - const [keyInfoMap, setKeyInfoMap] = useState>({}); const [isAddModalVisible, setIsAddModalVisible] = useState(false); const [isLoading, setIsLoading] = useState(false); const [isDeleting, setIsDeleting] = useState(false); @@ -61,50 +60,10 @@ const AgentsPanel: React.FC = ({ accessToken, userRole, teams } }; - const fetchKeysForAgents = async () => { - if (!accessToken) return; - try { - const { keys = [] } = await keyListCall( - accessToken, - null, - null, - null, - null, - null, - 1, - 500 - ); - const map: Record = {}; - for (const key of keys) { - const agentId = (key as { agent_id?: string }).agent_id; - if (agentId && !map[agentId]) { - map[agentId] = { - has_key: true, - key_alias: (key as { key_alias?: string }).key_alias, - token_prefix: (key as { token?: string }).token - ? `${(key as { token: string }).token.slice(0, 8)}…` - : undefined, - }; - } - } - setKeyInfoMap(map); - } catch (error) { - console.error("Error fetching keys for agents:", error); - } - }; - useEffect(() => { fetchAgents(); }, [accessToken]); - useEffect(() => { - if (accessToken && agentsList.length > 0) { - fetchKeysForAgents(); - } else if (agentsList.length === 0) { - setKeyInfoMap({}); - } - }, [accessToken, agentsList.length]); - const handleHealthCheckToggle = (checked: boolean) => { setHealthCheckEnabled(checked); fetchAgents(checked); @@ -256,7 +215,7 @@ const AgentsPanel: React.FC = ({ accessToken, userRole, teams - {keyInfoMap[agent.agent_id]?.has_key ? ( + {(agent.keys?.length ?? 0) > 0 ? ( Active ) : ( Needs Setup diff --git a/ui/litellm-dashboard/src/components/agents/agent_card.test.tsx b/ui/litellm-dashboard/src/components/agents/agent_card.test.tsx deleted file mode 100644 index 0f928866a0d..00000000000 --- a/ui/litellm-dashboard/src/components/agents/agent_card.test.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import React from "react"; -import { describe, it, expect, vi } from "vitest"; -import { screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { renderWithProviders } from "../../../tests/test-utils"; -import AgentCard from "./agent_card"; -import type { Agent } from "./types"; - -const baseAgent: Agent = { - agent_id: "agent-123", - agent_name: "Test Agent", - litellm_params: { model: "gpt-4" }, - agent_card_params: { - description: "A test agent for unit testing", - url: "https://agent.example.com", - }, -}; - -const defaultProps = { - agent: baseAgent, - onAgentClick: vi.fn(), - accessToken: "token-123", - isAdmin: false, - onAgentUpdated: vi.fn(), -}; - -describe("AgentCard", () => { - it("should render the agent name and description", () => { - renderWithProviders(); - - expect(screen.getByText("Test Agent")).toBeInTheDocument(); - expect(screen.getByText("A test agent for unit testing")).toBeInTheDocument(); - }); - - it("should show 'No description' when agent has no description", () => { - const agent = { ...baseAgent, agent_card_params: {} }; - renderWithProviders(); - - expect(screen.getByText("No description")).toBeInTheDocument(); - }); - - it("should show the agent URL when provided", () => { - renderWithProviders(); - - expect(screen.getByText("https://agent.example.com")).toBeInTheDocument(); - }); - - it("should show 'Needs Setup' badge when agent has no key", () => { - renderWithProviders(); - - expect(screen.getByText("Needs Setup")).toBeInTheDocument(); - expect(screen.getByText("No key assigned")).toBeInTheDocument(); - }); - - it("should show 'Active' badge and key info when agent has a key", () => { - const keyInfo = { has_key: true, key_alias: "my-key" }; - renderWithProviders(); - - expect(screen.getByText("Active")).toBeInTheDocument(); - expect(screen.getByText("my-key")).toBeInTheDocument(); - }); - - it("should call onAgentClick when card is clicked", async () => { - const user = userEvent.setup(); - const onAgentClick = vi.fn(); - renderWithProviders(); - - await user.click(screen.getByText("Test Agent")); - - expect(onAgentClick).toHaveBeenCalledWith("agent-123"); - }); - - it("should show delete button only for admins", () => { - const onDeleteClick = vi.fn(); - const { unmount } = renderWithProviders( - - ); - expect(screen.queryByRole("button", { name: /delete/i })).not.toBeInTheDocument(); - - unmount(); - - renderWithProviders( - - ); - expect(screen.getByRole("button", { name: /delete/i })).toBeInTheDocument(); - }); - - it("should call onDeleteClick with agent id and name when delete is clicked", async () => { - const user = userEvent.setup(); - const onDeleteClick = vi.fn(); - renderWithProviders( - - ); - - await user.click(screen.getByRole("button", { name: /delete/i })); - - expect(onDeleteClick).toHaveBeenCalledWith("agent-123", "Test Agent"); - }); -}); diff --git a/ui/litellm-dashboard/src/components/agents/agent_card.tsx b/ui/litellm-dashboard/src/components/agents/agent_card.tsx deleted file mode 100644 index dc684386edf..00000000000 --- a/ui/litellm-dashboard/src/components/agents/agent_card.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import React from "react"; -import { Card, Badge, Tooltip, Button } from "antd"; -import { CopyOutlined, KeyOutlined, WarningOutlined, DeleteOutlined } from "@ant-design/icons"; -import { Agent, AgentKeyInfo } from "./types"; - -interface AgentCardProps { - agent: Agent; - keyInfo?: AgentKeyInfo; - onAgentClick: (agentId: string) => void; - onDeleteClick?: (agentId: string, agentName: string) => void; - accessToken: string | null; - isAdmin: boolean; - onAgentUpdated: () => void; -} - -const AgentCard: React.FC = ({ - agent, - keyInfo, - onAgentClick, - onDeleteClick, - isAdmin, -}) => { - const description = - agent.agent_card_params?.description || "No description"; - const url = agent.agent_card_params?.url; - const hasKey = keyInfo?.has_key ?? false; - const statusBadge = hasKey ? ( - - ) : ( - - ); - - const copyToClipboard = (e: React.MouseEvent, text: string) => { - e.stopPropagation(); - navigator.clipboard.writeText(text); - }; - - return ( - onAgentClick(agent.agent_id)} - > -
-
-
- - {agent.agent_name} - - - copyToClipboard(e, agent.agent_id)} - className="cursor-pointer text-gray-400 hover:text-blue-500 text-xs shrink-0" - /> - -
-
{statusBadge}
-
- {isAdmin && onDeleteClick && ( - -
-

- {description} -

- {url && ( -

- {url} -

- )} -
- {hasKey ? ( -
- - {keyInfo?.key_alias || keyInfo?.token_prefix || "Key assigned"} -
- ) : ( -
- - No key assigned -
- )} -
-
- ); -}; - -export default AgentCard; diff --git a/ui/litellm-dashboard/src/components/agents/agent_card_grid.test.tsx b/ui/litellm-dashboard/src/components/agents/agent_card_grid.test.tsx deleted file mode 100644 index c8a21d1b6e8..00000000000 --- a/ui/litellm-dashboard/src/components/agents/agent_card_grid.test.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { renderWithProviders, screen } from "../../../tests/test-utils"; -import { vi } from "vitest"; -import AgentCardGrid from "./agent_card_grid"; -import type { Agent, AgentKeyInfo } from "./types"; - -vi.mock("./agent_card", () => ({ - default: ({ agent, onAgentClick }: any) => ( -
onAgentClick(agent.agent_id)}> - {agent.agent_name} -
- ), -})); - -const mockAgents: Agent[] = [ - { - agent_id: "agent-1", - agent_name: "Test Agent 1", - litellm_params: { model: "gpt-4" }, - agent_card_params: { description: "First agent" }, - }, - { - agent_id: "agent-2", - agent_name: "Test Agent 2", - litellm_params: { model: "claude-3" }, - agent_card_params: { description: "Second agent" }, - }, -]; - -const mockKeyInfoMap: Record = { - "agent-1": { has_key: true, key_alias: "key-1" }, - "agent-2": { has_key: false }, -}; - -const defaultProps = { - agentsList: mockAgents, - keyInfoMap: mockKeyInfoMap, - isLoading: false, - onDeleteClick: vi.fn(), - accessToken: "test-token", - onAgentUpdated: vi.fn(), - isAdmin: true, - onAgentClick: vi.fn(), -}; - -describe("AgentCardGrid", () => { - it("should render", () => { - renderWithProviders(); - expect(screen.getByText("Test Agent 1")).toBeInTheDocument(); - }); - - it("should render all agent cards", () => { - renderWithProviders(); - expect(screen.getByText("Test Agent 1")).toBeInTheDocument(); - expect(screen.getByText("Test Agent 2")).toBeInTheDocument(); - }); - - it("should show loading skeletons when isLoading is true", () => { - renderWithProviders(); - expect(screen.queryByText("Test Agent 1")).not.toBeInTheDocument(); - }); - - it("should show admin empty state message when no agents and isAdmin", () => { - renderWithProviders( - - ); - expect( - screen.getByText("No agents found. Create one to get started.") - ).toBeInTheDocument(); - }); - - it("should show non-admin empty state message when no agents and not admin", () => { - renderWithProviders( - - ); - expect( - screen.getByText("No agents found. Contact an admin to create agents.") - ).toBeInTheDocument(); - }); - - it("should call onAgentClick when a card is clicked", async () => { - const onAgentClick = vi.fn(); - renderWithProviders( - - ); - const { default: userEvent } = await import("@testing-library/user-event"); - const user = userEvent.setup(); - await user.click(screen.getByTestId("agent-card-agent-1")); - expect(onAgentClick).toHaveBeenCalledWith("agent-1"); - }); -}); diff --git a/ui/litellm-dashboard/src/components/agents/agent_card_grid.tsx b/ui/litellm-dashboard/src/components/agents/agent_card_grid.tsx deleted file mode 100644 index 5e984cf220d..00000000000 --- a/ui/litellm-dashboard/src/components/agents/agent_card_grid.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import React from "react"; -import { Skeleton } from "antd"; -import AgentCard from "./agent_card"; -import { Agent, AgentKeyInfo } from "./types"; - -interface AgentCardGridProps { - agentsList: Agent[]; - keyInfoMap: Record; - isLoading: boolean; - onDeleteClick: (agentId: string, agentName: string) => void; - accessToken: string | null; - onAgentUpdated: () => void; - isAdmin: boolean; - onAgentClick: (agentId: string) => void; -} - -const AgentCardGrid: React.FC = ({ - agentsList, - keyInfoMap, - isLoading, - onDeleteClick, - accessToken, - onAgentUpdated, - isAdmin, - onAgentClick, -}) => { - if (isLoading) { - return ( -
- {[1, 2, 3].map((i) => ( - - ))} -
- ); - } - - if (!agentsList || agentsList.length === 0) { - return ( -
-

- {isAdmin - ? "No agents found. Create one to get started." - : "No agents found. Contact an admin to create agents."} -

-
- ); - } - - return ( -
- {agentsList.map((agent) => ( - - ))} -
- ); -}; - -export default AgentCardGrid; diff --git a/ui/litellm-dashboard/src/components/agents/agent_info.tsx b/ui/litellm-dashboard/src/components/agents/agent_info.tsx index 1e4280d3613..0e9226ccd18 100644 --- a/ui/litellm-dashboard/src/components/agents/agent_info.tsx +++ b/ui/litellm-dashboard/src/components/agents/agent_info.tsx @@ -5,6 +5,10 @@ import MessageManager from "@/components/molecules/message_manager"; import { ArrowLeftIcon } from "@heroicons/react/outline"; import { getAgentInfo, patchAgentCall, getAgentCreateMetadata, AgentCreateInfo } from "../networking"; import { Agent } from "./types"; +import { KeyResponse } from "../key_team_helpers/key_list"; +import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys"; +import KeyInfoView from "../templates/key_info_view"; +import AgentVirtualKeys from "./agent_virtual_keys"; import AgentFormFields from "./agent_form_fields"; import DynamicAgentFormFields, { buildDynamicAgentData } from "./dynamic_agent_form_fields"; import { buildAgentDataFromForm, parseAgentForForm } from "./agent_config"; @@ -32,6 +36,13 @@ const AgentInfoView: React.FC = ({ isAdmin, }) => { const [agent, setAgent] = useState(null); + const [selectedKey, setSelectedKey] = useState(null); + const { + data: keysData, + isLoading: keysLoading, + refetch: refetchAgentKeys, + } = useKeys(1, 100, { agentID: agentId }); + const agentKeys = keysData?.keys ?? []; const [isLoading, setIsLoading] = useState(true); const [isEditing, setIsEditing] = useState(false); const [isSaving, setIsSaving] = useState(false); @@ -211,6 +222,22 @@ const AgentInfoView: React.FC = ({ return date.toLocaleString(); }; + if (selectedKey) { + return ( + setSelectedKey(null)} + onDelete={() => { + setSelectedKey(null); + refetchAgentKeys(); + }} + teams={null} + backButtonText="Back to Agent" + /> + ); + } + return (
@@ -270,6 +297,12 @@ const AgentInfoView: React.FC = ({ {formatDate(agent.updated_at)} + + {agent.object_permission && (agent.object_permission.mcp_servers?.length || agent.object_permission.mcp_access_groups?.length || diff --git a/ui/litellm-dashboard/src/components/agents/agent_virtual_keys.test.tsx b/ui/litellm-dashboard/src/components/agents/agent_virtual_keys.test.tsx new file mode 100644 index 00000000000..9a58ad557a2 --- /dev/null +++ b/ui/litellm-dashboard/src/components/agents/agent_virtual_keys.test.tsx @@ -0,0 +1,51 @@ +import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../../../tests/test-utils"; +import AgentVirtualKeys from "./agent_virtual_keys"; +import type { KeyResponse } from "../key_team_helpers/key_list"; + +const makeKey = (overrides: Partial): KeyResponse => + ({ + token: "hash-abc123def456", + key_alias: "agent-primary", + key_name: "sk-...abcd", + ...overrides, + }) as unknown as KeyResponse; + +describe("AgentVirtualKeys", () => { + it("shows the empty state when the agent has no keys", () => { + renderWithProviders(); + expect(screen.getByText("No virtual key assigned to this agent.")).toBeInTheDocument(); + }); + + it("shows a loading state while keys are fetching", () => { + renderWithProviders(); + expect(screen.getByText("Loading keys...")).toBeInTheDocument(); + }); + + it("renders each attached key's alias and masked name", () => { + const keys = [ + makeKey({ token: "hash-aaa", key_alias: "primary", key_name: "sk-...aaa" }), + makeKey({ token: "hash-bbb", key_alias: "backup", key_name: "sk-...bbb" }), + ]; + renderWithProviders(); + + expect(screen.getByText("primary")).toBeInTheDocument(); + expect(screen.getByText("backup")).toBeInTheDocument(); + expect(screen.getByText("sk-...aaa")).toBeInTheDocument(); + expect(screen.getByText("sk-...bbb")).toBeInTheDocument(); + }); + + it("fires onKeyClick with the full key when its Key ID is clicked", async () => { + const onKeyClick = vi.fn(); + const key = makeKey({ token: "hash-clickme", key_alias: "clickable" }); + renderWithProviders(); + + await userEvent.click(screen.getByRole("button")); + + expect(onKeyClick).toHaveBeenCalledTimes(1); + expect(onKeyClick).toHaveBeenCalledWith(key); + }); +}); diff --git a/ui/litellm-dashboard/src/components/agents/agent_virtual_keys.tsx b/ui/litellm-dashboard/src/components/agents/agent_virtual_keys.tsx new file mode 100644 index 00000000000..4f16b0b3e62 --- /dev/null +++ b/ui/litellm-dashboard/src/components/agents/agent_virtual_keys.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { Title, Text } from "@tremor/react"; +import { Button, Tooltip } from "antd"; +import { KeyOutlined } from "@ant-design/icons"; +import { KeyResponse } from "../key_team_helpers/key_list"; + +interface AgentVirtualKeysProps { + keys: KeyResponse[]; + isLoading: boolean; + onKeyClick: (key: KeyResponse) => void; +} + +const AgentVirtualKeys: React.FC = ({ keys, isLoading, onKeyClick }) => { + return ( +
+ Virtual Keys + {isLoading ? ( + Loading keys... + ) : keys.length === 0 ? ( + No virtual key assigned to this agent. + ) : ( +
+ {keys.map((key) => ( +
+ + {key.key_alias || "Unnamed key"} + {key.key_name && {key.key_name}} + + + +
+ ))} +
+ )} +
+ ); +}; + +export default AgentVirtualKeys; diff --git a/ui/litellm-dashboard/src/components/agents/types.ts b/ui/litellm-dashboard/src/components/agents/types.ts index 3e27177c815..c29c566a5fe 100644 --- a/ui/litellm-dashboard/src/components/agents/types.ts +++ b/ui/litellm-dashboard/src/components/agents/types.ts @@ -1,7 +1,7 @@ -export interface AgentKeyInfo { - key_alias?: string; - token_prefix?: string; - has_key: boolean; +export interface AgentAttachedKey { + token: string; + key_alias?: string | null; + key_name?: string | null; } export interface AgentObjectPermission { @@ -23,6 +23,7 @@ export interface Agent { [key: string]: any; }; object_permission?: AgentObjectPermission; + keys?: AgentAttachedKey[] | null; spend?: number; tpm_limit?: number | null; rpm_limit?: number | null;