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 && (
-
- }
- onClick={(e) => {
- e.stopPropagation();
- onDeleteClick(agent.agent_id, agent.agent_name);
- }}
- className="shrink-0 -mr-1"
- />
-
- )}
-
-
- {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;