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
This commit is contained in:
ryan-crabbe-berri 2026-06-03 14:04:26 -07:00
parent f3e2167730
commit b8edaf62d9
17 changed files with 315 additions and 416 deletions

View file

@ -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

View file

@ -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,
)

View file

@ -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

View file

@ -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"}
)

View file

@ -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():
"""

View file

@ -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", () => {

View file

@ -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,

View file

@ -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: () => <div data-testid="add-agent-form" />,
}));
vi.mock("./agents/agent_card_grid", () => ({
default: ({ isAdmin }: { isAdmin: boolean }) => (
<div data-testid="agent-card-grid" data-is-admin={String(isAdmin)} />
),
}));
// Note: agents.tsx no longer uses AgentCardGrid — it renders a Table directly.
vi.mock("./agents/agent_info", () => ({
default: () => <div data-testid="agent-info" />,
}));

View file

@ -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<AgentsPanelProps> = ({ accessToken, userRole, teams }) => {
const [agentsList, setAgentsList] = useState<Agent[]>([]);
const [keyInfoMap, setKeyInfoMap] = useState<Record<string, AgentKeyInfo>>({});
const [isAddModalVisible, setIsAddModalVisible] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
@ -61,50 +60,10 @@ const AgentsPanel: React.FC<AgentsPanelProps> = ({ 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<string, AgentKeyInfo> = {};
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<AgentsPanelProps> = ({ accessToken, userRole, teams
</Text>
</TableCell>
<TableCell>
{keyInfoMap[agent.agent_id]?.has_key ? (
{(agent.keys?.length ?? 0) > 0 ? (
<Badge color="green">Active</Badge>
) : (
<Badge color="yellow">Needs Setup</Badge>

View file

@ -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(<AgentCard {...defaultProps} />);
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(<AgentCard {...defaultProps} agent={agent} />);
expect(screen.getByText("No description")).toBeInTheDocument();
});
it("should show the agent URL when provided", () => {
renderWithProviders(<AgentCard {...defaultProps} />);
expect(screen.getByText("https://agent.example.com")).toBeInTheDocument();
});
it("should show 'Needs Setup' badge when agent has no key", () => {
renderWithProviders(<AgentCard {...defaultProps} />);
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(<AgentCard {...defaultProps} keyInfo={keyInfo} />);
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(<AgentCard {...defaultProps} onAgentClick={onAgentClick} />);
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(
<AgentCard {...defaultProps} isAdmin={false} onDeleteClick={onDeleteClick} />
);
expect(screen.queryByRole("button", { name: /delete/i })).not.toBeInTheDocument();
unmount();
renderWithProviders(
<AgentCard {...defaultProps} isAdmin={true} onDeleteClick={onDeleteClick} />
);
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(
<AgentCard {...defaultProps} isAdmin={true} onDeleteClick={onDeleteClick} />
);
await user.click(screen.getByRole("button", { name: /delete/i }));
expect(onDeleteClick).toHaveBeenCalledWith("agent-123", "Test Agent");
});
});

View file

@ -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<AgentCardProps> = ({
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 ? (
<Badge status="success" text="Active" />
) : (
<Badge status="warning" text="Needs Setup" />
);
const copyToClipboard = (e: React.MouseEvent, text: string) => {
e.stopPropagation();
navigator.clipboard.writeText(text);
};
return (
<Card
hoverable
className="h-full flex flex-col"
styles={{
body: { flex: 1, display: "flex", flexDirection: "column" },
}}
onClick={() => onAgentClick(agent.agent_id)}
>
<div className="flex items-start justify-between gap-2 mb-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-gray-900 truncate">
{agent.agent_name}
</span>
<Tooltip title="Copy Agent ID">
<CopyOutlined
onClick={(e) => copyToClipboard(e, agent.agent_id)}
className="cursor-pointer text-gray-400 hover:text-blue-500 text-xs shrink-0"
/>
</Tooltip>
</div>
<div className="mt-1">{statusBadge}</div>
</div>
{isAdmin && onDeleteClick && (
<Tooltip title="Delete agent">
<Button
type="text"
size="small"
danger
icon={<DeleteOutlined />}
onClick={(e) => {
e.stopPropagation();
onDeleteClick(agent.agent_id, agent.agent_name);
}}
className="shrink-0 -mr-1"
/>
</Tooltip>
)}
</div>
<p className="text-sm text-gray-600 line-clamp-2 flex-1 mb-3">
{description}
</p>
{url && (
<p className="text-xs text-gray-500 truncate mb-2" title={url}>
{url}
</p>
)}
<div className="mt-auto pt-3 border-t border-gray-100 text-xs">
{hasKey ? (
<div className="flex items-center gap-1.5 text-gray-600">
<KeyOutlined />
<span>{keyInfo?.key_alias || keyInfo?.token_prefix || "Key assigned"}</span>
</div>
) : (
<div className="flex items-center gap-1.5 text-amber-600">
<WarningOutlined />
<span>No key assigned</span>
</div>
)}
</div>
</Card>
);
};
export default AgentCard;

View file

@ -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) => (
<div data-testid={`agent-card-${agent.agent_id}`} onClick={() => onAgentClick(agent.agent_id)}>
{agent.agent_name}
</div>
),
}));
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<string, AgentKeyInfo> = {
"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(<AgentCardGrid {...defaultProps} />);
expect(screen.getByText("Test Agent 1")).toBeInTheDocument();
});
it("should render all agent cards", () => {
renderWithProviders(<AgentCardGrid {...defaultProps} />);
expect(screen.getByText("Test Agent 1")).toBeInTheDocument();
expect(screen.getByText("Test Agent 2")).toBeInTheDocument();
});
it("should show loading skeletons when isLoading is true", () => {
renderWithProviders(<AgentCardGrid {...defaultProps} isLoading={true} />);
expect(screen.queryByText("Test Agent 1")).not.toBeInTheDocument();
});
it("should show admin empty state message when no agents and isAdmin", () => {
renderWithProviders(
<AgentCardGrid {...defaultProps} agentsList={[]} isAdmin={true} />
);
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(
<AgentCardGrid {...defaultProps} agentsList={[]} isAdmin={false} />
);
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(
<AgentCardGrid {...defaultProps} onAgentClick={onAgentClick} />
);
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");
});
});

View file

@ -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<string, AgentKeyInfo>;
isLoading: boolean;
onDeleteClick: (agentId: string, agentName: string) => void;
accessToken: string | null;
onAgentUpdated: () => void;
isAdmin: boolean;
onAgentClick: (agentId: string) => void;
}
const AgentCardGrid: React.FC<AgentCardGridProps> = ({
agentsList,
keyInfoMap,
isLoading,
onDeleteClick,
accessToken,
onAgentUpdated,
isAdmin,
onAgentClick,
}) => {
if (isLoading) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3].map((i) => (
<Skeleton key={i} active paragraph={{ rows: 3 }} />
))}
</div>
);
}
if (!agentsList || agentsList.length === 0) {
return (
<div className="rounded-lg border border-gray-200 bg-gray-50/50 py-12 text-center">
<p className="text-gray-500">
{isAdmin
? "No agents found. Create one to get started."
: "No agents found. Contact an admin to create agents."}
</p>
</div>
);
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{agentsList.map((agent) => (
<AgentCard
key={agent.agent_id}
agent={agent}
keyInfo={keyInfoMap[agent.agent_id]}
onAgentClick={onAgentClick}
onDeleteClick={isAdmin ? onDeleteClick : undefined}
accessToken={accessToken}
isAdmin={isAdmin}
onAgentUpdated={onAgentUpdated}
/>
))}
</div>
);
};
export default AgentCardGrid;

View file

@ -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<AgentInfoViewProps> = ({
isAdmin,
}) => {
const [agent, setAgent] = useState<Agent | null>(null);
const [selectedKey, setSelectedKey] = useState<KeyResponse | null>(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<AgentInfoViewProps> = ({
return date.toLocaleString();
};
if (selectedKey) {
return (
<KeyInfoView
keyId={selectedKey.token}
keyData={selectedKey}
onClose={() => setSelectedKey(null)}
onDelete={() => {
setSelectedKey(null);
refetchAgentKeys();
}}
teams={null}
backButtonText="Back to Agent"
/>
);
}
return (
<div className="p-4">
<div>
@ -270,6 +297,12 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({
<Descriptions.Item label="Updated At">{formatDate(agent.updated_at)}</Descriptions.Item>
</Descriptions>
<AgentVirtualKeys
keys={agentKeys}
isLoading={keysLoading}
onKeyClick={setSelectedKey}
/>
{agent.object_permission &&
(agent.object_permission.mcp_servers?.length ||
agent.object_permission.mcp_access_groups?.length ||

View file

@ -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>): 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(<AgentVirtualKeys keys={[]} isLoading={false} onKeyClick={vi.fn()} />);
expect(screen.getByText("No virtual key assigned to this agent.")).toBeInTheDocument();
});
it("shows a loading state while keys are fetching", () => {
renderWithProviders(<AgentVirtualKeys keys={[]} isLoading={true} onKeyClick={vi.fn()} />);
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(<AgentVirtualKeys keys={keys} isLoading={false} onKeyClick={vi.fn()} />);
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(<AgentVirtualKeys keys={[key]} isLoading={false} onKeyClick={onKeyClick} />);
await userEvent.click(screen.getByRole("button"));
expect(onKeyClick).toHaveBeenCalledTimes(1);
expect(onKeyClick).toHaveBeenCalledWith(key);
});
});

View file

@ -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<AgentVirtualKeysProps> = ({ keys, isLoading, onKeyClick }) => {
return (
<div style={{ marginTop: 24 }}>
<Title>Virtual Keys</Title>
{isLoading ? (
<Text className="mt-2">Loading keys...</Text>
) : keys.length === 0 ? (
<Text className="mt-2 text-gray-500">No virtual key assigned to this agent.</Text>
) : (
<div className="mt-3 flex flex-col gap-2">
{keys.map((key) => (
<div key={key.token} className="flex items-center gap-3 border border-gray-100 rounded px-3 py-2">
<KeyOutlined className="text-gray-400" />
<span className="font-medium">{key.key_alias || "Unnamed key"}</span>
{key.key_name && <span className="font-mono text-xs text-gray-500">{key.key_name}</span>}
<Tooltip title={key.token}>
<Button
size="small"
type="link"
className="font-mono text-blue-500 ml-auto"
onClick={() => onKeyClick(key)}
>
{key.token?.slice(0, 12)}...
</Button>
</Tooltip>
</div>
))}
</div>
)}
</div>
);
};
export default AgentVirtualKeys;

View file

@ -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;