mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-11 03:38:38 +00:00
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:
parent
f3e2167730
commit
b8edaf62d9
17 changed files with 315 additions and 416 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" />,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue