feat(agents): assign virtual keys to agents (#22045)

* feat(agents): assign virtual keys to agents

- Add agent_id field to LiteLLM_VerificationToken (schema.prisma + _types.py)
- Pass agent_id through key generation endpoint so keys can be scoped to an agent
- Refactor Add Agent wizard to 3-step flow (Configure → Assign Key → Ready)
  - Configure: all agent fields, custom/other type with just name+description
  - Assign Key: create new key or reassign existing key to agent
  - URL is now optional for easy discovery
- Add "Agent" ownership option to Create Key modal on Virtual Keys page
  with agent selector dropdown
- Extract CreatedKeyDisplay into shared component, reused in both flows
- Add keyCreateForAgentCall networking helper
- Add test for agent_id key generation

* fix(agents): code quality fixes from self-review

- Fix test_generate_key_helper_fn_agent_id: remove bare except clause,
  use explicit assert mock_insert.called, use .kwargs for clean arg access
- Remove no-op conditional in handleNext (both branches were identical)
- Validate selectedExistingKey before calling keyUpdateCall
- Validate selectedAgentId before setting on formValues in create_key_button

* fix(ui): replace deprecated Tremor Button with Ant Design Button in CreatedKeyDisplay
This commit is contained in:
Ishaan Jaff
2026-02-24 18:28:16 -08:00
committed by GitHub
parent 4e84e4c607
commit 60bcb26dc8
10 changed files with 700 additions and 168 deletions
+1
View File
@@ -851,6 +851,7 @@ class GenerateRequestBase(LiteLLMPydanticObjectBase):
max_budget: Optional[float] = None
user_id: Optional[str] = None
team_id: Optional[str] = None
agent_id: Optional[str] = None
max_parallel_requests: Optional[int] = None
metadata: Optional[dict] = {}
tpm_limit: Optional[int] = None
@@ -2535,6 +2535,7 @@ async def generate_key_helper_fn( # noqa: PLR0915
user_id: Optional[str] = None,
user_alias: Optional[str] = None,
team_id: Optional[str] = None,
agent_id: Optional[str] = None,
user_email: Optional[str] = None,
user_role: Optional[str] = None,
max_parallel_requests: Optional[int] = None,
@@ -2668,6 +2669,7 @@ async def generate_key_helper_fn( # noqa: PLR0915
"max_budget": key_max_budget,
"user_id": user_id,
"team_id": team_id,
"agent_id": agent_id,
"project_id": project_id,
"max_parallel_requests": max_parallel_requests,
"metadata": metadata_json,
+1
View File
@@ -314,6 +314,7 @@ model LiteLLM_VerificationToken {
router_settings Json? @default("{}")
user_id String?
team_id String?
agent_id String?
project_id String?
permissions Json @default("{}")
max_parallel_requests Int?
@@ -5623,6 +5623,7 @@ async def test_rotate_master_key_model_data_valid_for_prisma(
litellm_params/model_info are JSON strings (create_many expects dicts).
"""
from unittest.mock import AsyncMock, MagicMock
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.management_endpoints.key_management_endpoints import (
_rotate_master_key,
@@ -6157,3 +6158,56 @@ async def test_get_member_team_ids():
# Should return team-A and team-B (user is a member of both)
# Should NOT return team-C (user is not in members list)
assert sorted(result) == ["team-A", "team-B"]
@pytest.mark.asyncio
async def test_generate_key_with_agent_id():
"""Test that agent_id is accepted in GenerateKeyRequest and passed to generate_key_helper_fn."""
from litellm.proxy._types import GenerateKeyRequest
# Verify GenerateKeyRequest accepts agent_id
request = GenerateKeyRequest(
key_alias="agent-test-key",
agent_id="test-agent-123",
models=[],
)
assert request.agent_id == "test-agent-123"
data_json = request.model_dump(exclude_unset=True, exclude_none=True)
assert data_json["agent_id"] == "test-agent-123"
@pytest.mark.asyncio
async def test_generate_key_helper_fn_agent_id():
"""Test that generate_key_helper_fn passes agent_id into the insert_data call."""
from unittest.mock import AsyncMock, MagicMock, call, patch
import litellm.proxy.management_endpoints.key_management_endpoints as km
mock_prisma_client = AsyncMock()
mock_insert = AsyncMock(
return_value=MagicMock(
token="sk-test",
created_at=None,
updated_at=None,
litellm_budget_table=None,
)
)
mock_prisma_client.insert_data = mock_insert
with patch.object(km, "prisma_client", mock_prisma_client):
with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client):
await generate_key_helper_fn(
request_type="key",
agent_id="test-agent-456",
key_alias="test-agent-key",
models=[],
table_name="key",
)
assert mock_insert.called, "insert_data was never called"
# insert_data is called as insert_data(data=key_data, ...)
call_kwargs = mock_insert.call_args.kwargs
key_data = call_kwargs.get("data", {})
assert key_data.get("agent_id") == "test-agent-456", (
f"Expected agent_id='test-agent-456' in key_data, got: {key_data.get('agent_id')}"
)
@@ -1,11 +1,24 @@
import React, { useState, useEffect } from "react";
import { Modal, Form, message, Select, Input } from "antd";
import { Modal, Form, message, Select, Input, Steps, Radio, Tag, Divider } from "antd";
import { Button } from "@tremor/react";
import { createAgentCall, getAgentCreateMetadata, AgentCreateInfo } from "../networking";
import { CheckCircleFilled, KeyOutlined, RobotOutlined, AppstoreOutlined } from "@ant-design/icons";
import CreatedKeyDisplay from "../shared/CreatedKeyDisplay";
import {
createAgentCall,
getAgentCreateMetadata,
keyCreateForAgentCall,
keyListCall,
keyUpdateCall,
AgentCreateInfo,
} from "../networking";
import AgentFormFields from "./agent_form_fields";
import DynamicAgentFormFields, { buildDynamicAgentData } from "./dynamic_agent_form_fields";
import { getDefaultFormValues, buildAgentDataFromForm } from "./agent_config";
const { Step } = Steps;
const CUSTOM_AGENT_TYPE = "custom";
interface AddAgentFormProps {
visible: boolean;
onClose: () => void;
@@ -20,11 +33,25 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({
onSuccess,
}) => {
const [form] = Form.useForm();
const [currentStep, setCurrentStep] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const [agentType, setAgentType] = useState<string>("a2a");
const [agentTypeMetadata, setAgentTypeMetadata] = useState<AgentCreateInfo[]>([]);
const [loadingMetadata, setLoadingMetadata] = useState(false);
// Step 1: key assignment state
const [keyAssignOption, setKeyAssignOption] = useState<"create_new" | "existing_key" | "skip">("create_new");
const [newKeyName, setNewKeyName] = useState<string>("");
const [newKeyModels, setNewKeyModels] = useState<string[]>([]);
const [existingKeys, setExistingKeys] = useState<any[]>([]);
const [selectedExistingKey, setSelectedExistingKey] = useState<string | null>(null);
const [loadingKeys, setLoadingKeys] = useState(false);
// Step 2: results
const [createdAgentName, setCreatedAgentName] = useState<string>("");
const [createdKeyValue, setCreatedKeyValue] = useState<string | null>(null);
const [assignedKeyAlias, setAssignedKeyAlias] = useState<string | null>(null);
// Fetch agent type metadata on mount
useEffect(() => {
const fetchMetadata = async () => {
@@ -41,11 +68,87 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({
fetchMetadata();
}, []);
// Fetch existing keys when assign key step becomes active
useEffect(() => {
if (currentStep === 1 && accessToken && existingKeys.length === 0) {
const fetchKeys = async () => {
setLoadingKeys(true);
try {
const result = await keyListCall(accessToken, null, null, null, null, null, 1, 100);
setExistingKeys(result?.keys || []);
} catch (error) {
console.error("Error fetching keys:", error);
} finally {
setLoadingKeys(false);
}
};
fetchKeys();
}
}, [currentStep, accessToken]);
const selectedAgentTypeInfo = agentTypeMetadata.find(
(info) => info.agent_type === agentType
);
const handleSubmit = async (values: any) => {
const handleNext = async () => {
try {
if (currentStep === 0) {
await form.validateFields(["agent_name"]);
const agentName = form.getFieldValue("agent_name");
if (agentName && !newKeyName) {
setNewKeyName(`${agentName}-key`);
}
}
setCurrentStep((s) => s + 1);
} catch {
// validation failed — stay on current step
}
};
const handleBack = () => {
setCurrentStep((s) => Math.max(0, s - 1));
};
const buildAgentData = (values: any) => {
if (agentType === CUSTOM_AGENT_TYPE) {
return {
agent_name: values.agent_name,
agent_card_params: {
protocolVersion: "1.0",
name: values.agent_name,
description: values.description || "",
url: "",
version: "1.0.0",
defaultInputModes: ["text"],
defaultOutputModes: ["text"],
capabilities: { streaming: false },
skills: [],
},
};
} else if (agentType === "a2a") {
return buildAgentDataFromForm(values);
} else if (selectedAgentTypeInfo?.use_a2a_form_fields) {
const agentData = buildAgentDataFromForm(values);
if (selectedAgentTypeInfo.litellm_params_template) {
agentData.litellm_params = {
...agentData.litellm_params,
...selectedAgentTypeInfo.litellm_params_template,
};
}
for (const field of selectedAgentTypeInfo.credential_fields) {
const value = values[field.key];
if (value && field.include_in_litellm_params !== false) {
agentData.litellm_params[field.key] = value;
}
}
return agentData;
} else if (selectedAgentTypeInfo) {
return buildDynamicAgentData(values, selectedAgentTypeInfo);
}
return null;
};
const handleCreateAgent = async () => {
if (!accessToken) {
message.error("No access token available");
return;
@@ -53,40 +156,46 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({
setIsSubmitting(true);
try {
let agentData: any;
if (agentType === "a2a") {
agentData = buildAgentDataFromForm(values);
} else if (selectedAgentTypeInfo?.use_a2a_form_fields) {
// A2A-compatible agents use the standard A2A form builder
// but need to add litellm_params from the agent type config
agentData = buildAgentDataFromForm(values);
// Merge litellm_params_template
if (selectedAgentTypeInfo.litellm_params_template) {
agentData.litellm_params = {
...agentData.litellm_params,
...selectedAgentTypeInfo.litellm_params_template,
};
}
// Add credential fields to litellm_params
for (const field of selectedAgentTypeInfo.credential_fields) {
const value = values[field.key];
if (value && field.include_in_litellm_params !== false) {
agentData.litellm_params[field.key] = value;
}
}
} else if (selectedAgentTypeInfo) {
agentData = buildDynamicAgentData(values, selectedAgentTypeInfo);
// getFieldsValue(true) returns ALL preserved values including fields from
// unmounted steps; merge with any currently-mounted validated fields.
await form.validateFields();
const values = { ...form.getFieldsValue(true) };
const agentData = buildAgentData(values);
if (!agentData) {
message.error("Failed to build agent data");
setIsSubmitting(false);
return;
}
await createAgentCall(accessToken, agentData);
message.success("Agent created successfully");
form.resetFields();
setAgentType("a2a");
const agentResponse = await createAgentCall(accessToken, agentData);
const agentId: string = agentResponse.agent_id;
const agentName: string = agentResponse.agent_name || values.agent_name || agentId;
setCreatedAgentName(agentName);
if (keyAssignOption === "create_new" && newKeyName) {
const keyResponse = await keyCreateForAgentCall(
accessToken,
agentId,
newKeyName,
newKeyModels,
);
setCreatedKeyValue(keyResponse.key || null);
} else if (keyAssignOption === "existing_key") {
if (!selectedExistingKey) {
message.error("Please select an existing key to assign");
setIsSubmitting(false);
return;
}
await keyUpdateCall(accessToken, {
key: selectedExistingKey,
agent_id: agentId,
});
const keyInfo = existingKeys.find((k) => k.token === selectedExistingKey);
setAssignedKeyAlias(keyInfo?.key_alias || selectedExistingKey.slice(0, 12) + "…");
}
setCurrentStep(2);
onSuccess();
onClose();
} catch (error) {
console.error("Error creating agent:", error);
message.error("Failed to create agent");
@@ -95,9 +204,17 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({
}
};
const handleCancel = () => {
const handleClose = () => {
form.resetFields();
setAgentType("a2a");
setCurrentStep(0);
setKeyAssignOption("create_new");
setNewKeyName("");
setNewKeyModels([]);
setSelectedExistingKey(null);
setCreatedAgentName("");
setCreatedKeyValue(null);
setAssignedKeyAlias(null);
onClose();
};
@@ -106,25 +223,308 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({
form.resetFields();
};
// Get the logo for the selected agent type for the header
const selectedLogo = selectedAgentTypeInfo?.logo_url || agentTypeMetadata.find(a => a.agent_type === "a2a")?.logo_url;
const isCustomAgent = agentType === CUSTOM_AGENT_TYPE;
const selectedLogo = isCustomAgent
? null
: selectedAgentTypeInfo?.logo_url ||
agentTypeMetadata.find((a) => a.agent_type === "a2a")?.logo_url;
const renderConfigureStep = () => (
<>
<Form.Item
label={<span className="text-sm font-medium text-gray-700">Agent Type</span>}
required
tooltip="Select the type of agent you want to create"
>
<Select
value={agentType}
onChange={handleAgentTypeChange}
size="large"
style={{ width: "100%" }}
optionLabelProp="label"
dropdownRender={(menu) => (
<>
{menu}
<Divider style={{ margin: "4px 0" }} />
<div className="px-2 py-1">
<div className="text-xs text-gray-400 font-medium mb-1 uppercase tracking-wide px-2">
Not listed?
</div>
<div
className={`flex items-center gap-3 px-2 py-2 rounded cursor-pointer transition-colors ${
agentType === CUSTOM_AGENT_TYPE
? "bg-amber-50"
: "hover:bg-amber-50"
}`}
onClick={() => handleAgentTypeChange(CUSTOM_AGENT_TYPE)}
>
<AppstoreOutlined className="text-amber-600 text-lg" />
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-amber-700">Custom / Other</span>
<Tag color="orange" style={{ fontSize: 10, padding: "0 4px" }}>GENERIC</Tag>
</div>
<div className="text-xs text-amber-600">
For agents that don&apos;t follow a standard protocol just needs a virtual key
</div>
</div>
</div>
</div>
</>
)}
>
{agentTypeMetadata.map((info) => (
<Select.Option
key={info.agent_type}
value={info.agent_type}
label={
<div className="flex items-center gap-2">
<img src={info.logo_url || ""} alt="" className="w-4 h-4 object-contain" />
<span>{info.agent_type_display_name}</span>
</div>
}
>
<div className="flex items-center gap-3 py-1">
<img
src={info.logo_url || ""}
alt={info.agent_type_display_name}
className="w-5 h-5 object-contain"
/>
<div>
<div className="font-medium">{info.agent_type_display_name}</div>
{info.description && (
<div className="text-xs text-gray-500">{info.description}</div>
)}
</div>
</div>
</Select.Option>
))}
</Select>
</Form.Item>
<div className="mt-4">
{agentType === CUSTOM_AGENT_TYPE ? (
<div className="space-y-4">
<Form.Item
label="Agent Name"
name="agent_name"
rules={[{ required: true, message: "Please enter an agent name" }]}
>
<Input placeholder="e.g. my-custom-agent" />
</Form.Item>
<Form.Item
label="Description"
name="description"
>
<Input.TextArea placeholder="Describe what this agent does…" rows={3} />
</Form.Item>
</div>
) : agentType === "a2a" ? (
<AgentFormFields showAgentName={true} />
) : selectedAgentTypeInfo?.use_a2a_form_fields ? (
<>
<AgentFormFields showAgentName={true} />
{selectedAgentTypeInfo.credential_fields.length > 0 && (
<div className="mt-4 p-4 border border-gray-200 rounded-lg">
<h4 className="text-sm font-medium text-gray-700 mb-3">
{selectedAgentTypeInfo.agent_type_display_name} Settings
</h4>
{selectedAgentTypeInfo.credential_fields.map((field) => (
<Form.Item
key={field.key}
label={field.label}
name={field.key}
rules={
field.required
? [{ required: true, message: `Please enter ${field.label}` }]
: undefined
}
tooltip={field.tooltip}
initialValue={field.default_value}
>
{field.field_type === "password" ? (
<Input.Password placeholder={field.placeholder || ""} />
) : (
<Input placeholder={field.placeholder || ""} />
)}
</Form.Item>
))}
</div>
)}
</>
) : selectedAgentTypeInfo ? (
<DynamicAgentFormFields agentTypeInfo={selectedAgentTypeInfo} />
) : null}
</div>
</>
);
const renderAssignKeyStep = () => {
const agentName = form.getFieldValue("agent_name") || "your-agent";
return (
<div>
{/* Agent name chip */}
<div className="flex justify-center mb-6">
<Tag icon={<RobotOutlined />} color="purple" className="px-3 py-1 text-sm">
{agentName}
</Tag>
</div>
<div className="space-y-3">
{/* Option: Create new key */}
<div
className={`p-4 border-2 rounded-lg cursor-pointer transition-colors ${
keyAssignOption === "create_new"
? "border-indigo-600 bg-indigo-50"
: "border-gray-200 bg-white hover:border-gray-300"
}`}
onClick={() => setKeyAssignOption("create_new")}
>
<div className="flex items-start justify-between">
<div className="flex items-start gap-3 flex-1">
<Radio
value="create_new"
checked={keyAssignOption === "create_new"}
onChange={() => setKeyAssignOption("create_new")}
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<KeyOutlined className="text-indigo-600" />
<span className="font-medium text-gray-900">Create a new key for this agent</span>
</div>
<p className="text-sm text-gray-500 mt-1">
A dedicated key scoped to this agent.
</p>
{keyAssignOption === "create_new" && (
<div className="mt-3 space-y-3" onClick={(e) => e.stopPropagation()}>
<div>
<label className="text-sm text-gray-600 block mb-1">Key Name</label>
<Input
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder="e.g. my-agent-key"
/>
</div>
<div>
<label className="text-sm text-gray-600 block mb-1">
Allowed Models <span className="text-gray-400">(optional leave empty for all models)</span>
</label>
<Select
mode="tags"
style={{ width: "100%" }}
placeholder="e.g. gpt-4o, claude-3-5-sonnet"
value={newKeyModels}
onChange={setNewKeyModels}
tokenSeparators={[","]}
/>
</div>
</div>
)}
</div>
</div>
<Tag color="green">Recommended</Tag>
</div>
</div>
{/* Option: Assign existing key */}
<div
className={`p-4 border-2 rounded-lg cursor-pointer transition-colors ${
keyAssignOption === "existing_key"
? "border-indigo-600 bg-indigo-50"
: "border-gray-200 bg-white hover:border-gray-300"
}`}
onClick={() => setKeyAssignOption("existing_key")}
>
<div className="flex items-start gap-3">
<Radio
value="existing_key"
checked={keyAssignOption === "existing_key"}
onChange={() => setKeyAssignOption("existing_key")}
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<KeyOutlined className="text-gray-500" />
<span className="font-medium text-gray-900">Assign an existing key</span>
</div>
<p className="text-sm text-gray-500 mt-1">
Re-assign a key you already have to this agent.
</p>
{keyAssignOption === "existing_key" && (
<div className="mt-3" onClick={(e) => e.stopPropagation()}>
<Select
showSearch
style={{ width: "100%" }}
placeholder="Search by key name…"
loading={loadingKeys}
value={selectedExistingKey}
onChange={(value) => setSelectedExistingKey(value)}
filterOption={(input, option) =>
(option?.label as string ?? "").toLowerCase().includes(input.toLowerCase())
}
options={existingKeys.map((k) => ({
label: k.key_alias || k.token?.slice(0, 12) + "…",
value: k.token,
}))}
/>
</div>
)}
</div>
</div>
</div>
</div>
<div className="text-center mt-4">
<button
type="button"
className="text-sm text-gray-500 underline hover:text-gray-700"
onClick={() => setKeyAssignOption("skip")}
>
Skip for now I&apos;ll assign a key later
</button>
</div>
</div>
);
};
const renderReadyStep = () => (
<div className="text-center py-6">
<CheckCircleFilled className="text-5xl text-green-500 mb-4" style={{ fontSize: 48 }} />
<h3 className="text-xl font-semibold text-gray-900 mb-2">Agent Created!</h3>
<div className="flex justify-center mb-4">
<Tag icon={<RobotOutlined />} color="purple" className="px-3 py-1 text-sm">
{createdAgentName}
</Tag>
</div>
{createdKeyValue && (
<div className="mt-4 text-left max-w-md mx-auto">
<CreatedKeyDisplay apiKey={createdKeyValue} />
</div>
)}
{assignedKeyAlias && (
<p className="text-sm text-gray-600 mt-2">
Key <span className="font-medium">{assignedKeyAlias}</span> has been assigned to this agent.
</p>
)}
{!createdKeyValue && !assignedKeyAlias && keyAssignOption === "skip" && (
<p className="text-sm text-gray-500 mt-2">
No key assigned. You can create one from the Virtual Keys page.
</p>
)}
</div>
);
return (
<Modal
title={
<div className="flex items-center space-x-3 pb-4 border-b border-gray-100">
{selectedLogo && (
<img
src={selectedLogo}
alt="Agent"
className="w-6 h-6 object-contain"
/>
{selectedLogo && currentStep < 1 && (
<img src={selectedLogo} alt="Agent" className="w-6 h-6 object-contain" />
)}
<h2 className="text-xl font-semibold text-gray-900">Add New Agent</h2>
</div>
}
open={visible}
onCancel={handleCancel}
onCancel={handleClose}
footer={null}
width={900}
className="top-8"
@@ -134,103 +534,60 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({
}}
>
<div className="mt-4">
{/* Step indicator */}
<Steps current={currentStep} size="small" className="mb-8">
<Step title="Configure" />
<Step title="Assign Key" />
<Step title="Ready" />
</Steps>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
initialValues={agentType === "a2a" ? getDefaultFormValues() : {}}
className="space-y-4"
>
{/* Agent Type Selection */}
<Form.Item
label={<span className="text-sm font-medium text-gray-700">Agent Type</span>}
required
tooltip="Select the type of agent you want to create"
>
<Select
value={agentType}
onChange={handleAgentTypeChange}
size="large"
style={{ width: "100%" }}
optionLabelProp="label"
>
{agentTypeMetadata.map((info) => (
<Select.Option
key={info.agent_type}
value={info.agent_type}
label={
<div className="flex items-center gap-2">
<img src={info.logo_url || ""} alt="" className="w-4 h-4 object-contain" />
<span>{info.agent_type_display_name}</span>
</div>
}
>
<div className="flex items-center gap-3 py-1">
<img
src={info.logo_url || ""}
alt={info.agent_type_display_name}
className="w-5 h-5 object-contain"
/>
<div>
<div className="font-medium">{info.agent_type_display_name}</div>
{info.description && (
<div className="text-xs text-gray-500">{info.description}</div>
)}
</div>
</div>
</Select.Option>
))}
</Select>
</Form.Item>
{/* Conditional Form Fields */}
<div className="mt-6">
{agentType === "a2a" ? (
<AgentFormFields showAgentName={true} />
) : selectedAgentTypeInfo?.use_a2a_form_fields ? (
// A2A-compatible agents (like Pydantic AI) use full A2A form fields
// plus any additional credential fields
<>
<AgentFormFields showAgentName={true} />
{selectedAgentTypeInfo.credential_fields.length > 0 && (
<div className="mt-4 p-4 border border-gray-200 rounded-lg">
<h4 className="text-sm font-medium text-gray-700 mb-3">
{selectedAgentTypeInfo.agent_type_display_name} Settings
</h4>
{selectedAgentTypeInfo.credential_fields.map((field) => (
<Form.Item
key={field.key}
label={field.label}
name={field.key}
rules={field.required ? [{ required: true, message: `Please enter ${field.label}` }] : undefined}
tooltip={field.tooltip}
initialValue={field.default_value}
>
{field.field_type === "password" ? (
<Input.Password placeholder={field.placeholder || ""} />
) : (
<Input placeholder={field.placeholder || ""} />
)}
</Form.Item>
))}
</div>
)}
</>
) : selectedAgentTypeInfo ? (
<DynamicAgentFormFields agentTypeInfo={selectedAgentTypeInfo} />
) : null}
</div>
{/* Footer Buttons */}
<div className="flex items-center justify-end space-x-3 pt-6 border-t border-gray-100 mt-6">
<Button variant="secondary" onClick={handleCancel}>
Cancel
</Button>
<Button variant="primary" loading={isSubmitting}>
{isSubmitting ? "Creating..." : "Create Agent"}
</Button>
</div>
{currentStep === 0 && renderConfigureStep()}
{currentStep === 1 && renderAssignKeyStep()}
{currentStep === 2 && renderReadyStep()}
</Form>
{/* Footer navigation */}
<div className="flex items-center justify-between pt-6 border-t border-gray-100 mt-6">
<div>
{currentStep > 0 && currentStep < 2 && (
<button
type="button"
onClick={handleBack}
className="text-sm text-gray-600 border border-gray-300 rounded px-4 py-2 hover:bg-gray-50"
>
Back
</button>
)}
</div>
<div className="flex gap-3">
{currentStep < 2 && (
<Button variant="secondary" onClick={handleClose}>
Cancel
</Button>
)}
{currentStep === 0 && (
<Button variant="primary" onClick={handleNext}>
Next
</Button>
)}
{currentStep === 1 && (
<Button variant="primary" loading={isSubmitting} onClick={handleCreateAgent}>
{isSubmitting ? "Creating..." : "Create Agent →"}
</Button>
)}
{currentStep === 2 && (
<Button variant="primary" onClick={handleClose}>
Done
</Button>
)}
</div>
</div>
</div>
</Modal>
);
@@ -54,9 +54,9 @@ export const AGENT_FORM_CONFIG: {
name: "url",
label: "URL",
type: "url",
required: true,
required: false,
placeholder: "http://localhost:9999/",
tooltip: "Base URL where the agent is hosted",
tooltip: "Base URL where the agent is hosted (optional)",
},
{
name: "version",
@@ -237,9 +237,9 @@ export const buildAgentDataFromForm = (values: any, existingAgent?: any) => {
agent_name: values.agent_name,
agent_card_params: {
protocolVersion: values.protocolVersion || "1.0",
name: values.name,
description: values.description,
url: values.url,
name: values.name || values.agent_name,
description: values.description || "",
url: values.url || "",
version: values.version || "1.0.0",
defaultInputModes: existingAgent?.agent_card_params?.defaultInputModes || ["text"],
defaultOutputModes: existingAgent?.agent_card_params?.defaultOutputModes || ["text"],
@@ -10,13 +10,15 @@ const { Panel } = Collapse;
interface AgentFormFieldsProps {
showAgentName?: boolean;
visiblePanels?: string[];
}
/**
* Reusable form fields component for agent forms
* Uses shared configuration from agent_config.ts
*/
const AgentFormFields: React.FC<AgentFormFieldsProps> = ({ showAgentName = true }) => {
const AgentFormFields: React.FC<AgentFormFieldsProps> = ({ showAgentName = true, visiblePanels }) => {
const shouldShow = (key: string) => !visiblePanels || visiblePanels.includes(key);
return (
<>
{showAgentName && (
@@ -32,6 +34,7 @@ const AgentFormFields: React.FC<AgentFormFieldsProps> = ({ showAgentName = true
<Collapse defaultActiveKey={['basic']} style={{ marginBottom: 16 }}>
{/* Basic Information */}
{shouldShow(AGENT_FORM_CONFIG.basic.key) && (
<Panel header={`${AGENT_FORM_CONFIG.basic.title} (Required)`} key={AGENT_FORM_CONFIG.basic.key}>
{AGENT_FORM_CONFIG.basic.fields.map((field) => (
<Form.Item
@@ -49,8 +52,10 @@ const AgentFormFields: React.FC<AgentFormFieldsProps> = ({ showAgentName = true
</Form.Item>
))}
</Panel>
)}
{/* Skills */}
{shouldShow(AGENT_FORM_CONFIG.skills.key) && (
<Panel header={`${AGENT_FORM_CONFIG.skills.title} (Required)`} key={AGENT_FORM_CONFIG.skills.key}>
<Form.List name="skills">
{(fields, { add, remove }) => (
@@ -127,8 +132,10 @@ const AgentFormFields: React.FC<AgentFormFieldsProps> = ({ showAgentName = true
)}
</Form.List>
</Panel>
)}
{/* Capabilities */}
{shouldShow(AGENT_FORM_CONFIG.capabilities.key) && (
<Panel header={AGENT_FORM_CONFIG.capabilities.title} key={AGENT_FORM_CONFIG.capabilities.key}>
{AGENT_FORM_CONFIG.capabilities.fields.map((field) => (
<Form.Item
@@ -141,8 +148,10 @@ const AgentFormFields: React.FC<AgentFormFieldsProps> = ({ showAgentName = true
</Form.Item>
))}
</Panel>
)}
{/* Optional Settings */}
{shouldShow(AGENT_FORM_CONFIG.optional.key) && (
<Panel header={AGENT_FORM_CONFIG.optional.title} key={AGENT_FORM_CONFIG.optional.key}>
{AGENT_FORM_CONFIG.optional.fields.map((field) => (
<Form.Item
@@ -155,13 +164,17 @@ const AgentFormFields: React.FC<AgentFormFieldsProps> = ({ showAgentName = true
</Form.Item>
))}
</Panel>
)}
{/* Cost Configuration */}
{shouldShow(AGENT_FORM_CONFIG.cost.key) && (
<Panel header={AGENT_FORM_CONFIG.cost.title} key={AGENT_FORM_CONFIG.cost.key}>
<CostConfigFields />
</Panel>
)}
{/* LiteLLM Parameters */}
{shouldShow(AGENT_FORM_CONFIG.litellm.key) && (
<Panel header={AGENT_FORM_CONFIG.litellm.title} key={AGENT_FORM_CONFIG.litellm.key}>
{AGENT_FORM_CONFIG.litellm.fields.map((field) => (
<Form.Item
@@ -174,6 +187,7 @@ const AgentFormFields: React.FC<AgentFormFieldsProps> = ({ showAgentName = true
</Form.Item>
))}
</Panel>
)}
</Collapse>
</>
);
@@ -930,6 +930,35 @@ export const keyCreateCall = async (
}
};
export const keyCreateForAgentCall = async (
accessToken: string,
agentId: string,
keyAlias: string,
models: string[],
) => {
const url = proxyBaseUrl ? `${proxyBaseUrl}/key/generate` : `/key/generate`;
const response = await fetch(url, {
method: "POST",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
agent_id: agentId,
key_alias: keyAlias,
models: models.length > 0 ? models : [],
}),
});
if (!response.ok) {
const errorData = await response.text();
handleError(errorData);
throw new Error("Failed to create key for agent");
}
return response.json();
};
export const userCreateCall = async (
accessToken: string,
userID: string | null,
@@ -5,7 +5,7 @@ import { formatNumberWithCommas } from "@/utils/dataUtils";
import { InfoCircleOutlined } from "@ant-design/icons";
import { useQueryClient } from "@tanstack/react-query";
import { Accordion, AccordionBody, AccordionHeader, Button, Col, Grid, Text, TextInput, Title } from "@tremor/react";
import { Button as Button2, Form, Input, Modal, Radio, Select, Switch, Tooltip } from "antd";
import { Button as Button2, Form, Input, Modal, Radio, Select, Switch, Tag, Tooltip } from "antd";
import debounce from "lodash/debounce";
import React, { useCallback, useEffect, useState } from "react";
import { CopyToClipboard } from "react-copy-to-clipboard";
@@ -29,6 +29,7 @@ import MCPServerSelector from "../mcp_server_management/MCPServerSelector";
import MCPToolPermissions from "../mcp_server_management/MCPToolPermissions";
import NotificationsManager from "../molecules/notifications_manager";
import {
getAgentsList,
getGuardrailsList,
getPoliciesList,
getPossibleUserRoles,
@@ -42,6 +43,7 @@ import {
import NumericalInput from "../shared/numerical_input";
import VectorStoreSelector from "../vector_store_management/VectorStoreSelector";
import { simplifyKeyGenerateError } from "./utils";
import CreatedKeyDisplay from "../shared/CreatedKeyDisplay";
const { Option } = Select;
@@ -169,6 +171,8 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey }) => {
const [rotationInterval, setRotationInterval] = useState<string>("30d");
const [routerSettings, setRouterSettings] = useState<RouterSettingsAccordionValue | null>(null);
const [routerSettingsKey, setRouterSettingsKey] = useState<number>(0);
const [agentsList, setAgentsList] = useState<{ agent_id: string; agent_name: string }[]>([]);
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const handleOk = () => {
setIsModalVisible(false);
form.resetFields();
@@ -180,6 +184,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey }) => {
setRotationInterval("30d");
setRouterSettings(null);
setRouterSettingsKey((prev) => prev + 1);
setSelectedAgentId(null);
};
const handleCancel = () => {
@@ -195,6 +200,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey }) => {
setRotationInterval("30d");
setRouterSettings(null);
setRouterSettingsKey((prev) => prev + 1);
setSelectedAgentId(null);
};
useEffect(() => {
@@ -203,6 +209,14 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey }) => {
}
}, [accessToken, userID, userRole]);
useEffect(() => {
if (accessToken) {
getAgentsList(accessToken)
.then((res) => setAgentsList(res?.agents || []))
.catch(() => setAgentsList([]));
}
}, [accessToken]);
useEffect(() => {
const fetchGuardrails = async () => {
try {
@@ -283,6 +297,12 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey }) => {
if (keyOwner === "you") {
formValues.user_id = userID;
} else if (keyOwner === "agent") {
if (!selectedAgentId) {
message.error("Please select an agent");
return;
}
formValues.agent_id = selectedAgentId;
}
// Handle metadata for all key types
@@ -539,6 +559,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey }) => {
<Radio value="you">You</Radio>
<Radio value="service_account">Service Account</Radio>
{userRole === "Admin" && <Radio value="another_user">Another User</Radio>}
<Radio value="agent">Agent <Tag color="purple">New</Tag></Radio>
</Radio.Group>
</Form.Item>
@@ -583,6 +604,32 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey }) => {
</div>
</Form.Item>
)}
{keyOwner === "agent" && (
<div className="mt-4 p-4 bg-purple-50 border border-purple-200 rounded-md">
<div className="mb-3">
<span className="text-sm font-medium text-gray-700">
Select Agent <span className="text-red-500">*</span>
</span>
</div>
<Select
showSearch
placeholder="Select an agent"
style={{ width: "100%" }}
value={selectedAgentId}
onChange={(value) => setSelectedAgentId(value)}
filterOption={(input, option) =>
(option?.label as string)?.toLowerCase().includes(input.toLowerCase())
}
options={agentsList.map((a) => ({
label: a.agent_name || a.agent_id,
value: a.agent_id,
}))}
/>
<div className="text-xs text-gray-500 mt-2">
This key will be used by the selected agent to make requests to LiteLLM
</div>
</div>
)}
<Form.Item
label={
<span>
@@ -1380,35 +1427,9 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey }) => {
<Modal open={isModalVisible} onOk={handleOk} onCancel={handleCancel} footer={null}>
<Grid numItems={1} className="gap-2 w-full">
<Title>Save your Key</Title>
<Col numColSpan={1}>
<p>
Please save this secret key somewhere safe and accessible. For security reasons,{" "}
<b>you will not be able to view it again</b> through your LiteLLM account. If you lose this secret key,
you will need to generate a new one.
</p>
</Col>
<Col numColSpan={1}>
{apiKey != null ? (
<div>
<Text className="mt-3">Virtual Key:</Text>
<div
style={{
background: "#f8f8f8",
padding: "10px",
borderRadius: "5px",
marginBottom: "10px",
}}
>
<pre style={{ wordWrap: "break-word", whiteSpace: "normal" }}>{apiKey}</pre>
</div>
<CopyToClipboard text={apiKey} onCopy={handleCopy}>
<Button className="mt-3">Copy Virtual Key</Button>
</CopyToClipboard>
{/* <Button className="mt-3" onClick={sendSlackAlert}>
Test Key
</Button> */}
</div>
<CreatedKeyDisplay apiKey={apiKey} />
) : (
<Text>Key being created, this might take 30s</Text>
)}
@@ -0,0 +1,53 @@
import React, { useState } from "react";
import { CopyToClipboard } from "react-copy-to-clipboard";
import { Button, message } from "antd";
interface CreatedKeyDisplayProps {
apiKey: string;
}
/**
* Shared component for displaying a newly-created virtual key.
* Used on the Virtual Keys page and in the Add Agent wizard.
*/
const CreatedKeyDisplay: React.FC<CreatedKeyDisplayProps> = ({ apiKey }) => {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
setCopied(true);
message.success("Key copied to clipboard");
setTimeout(() => setCopied(false), 2000);
};
return (
<div>
<p className="mb-2">
Please save this secret key somewhere safe and accessible. For security reasons,{" "}
<b>you will not be able to view it again</b> through your LiteLLM account. If you
lose this secret key, you will need to generate a new one.
</p>
<p className="text-sm text-gray-600 mt-3 mb-1">Virtual Key:</p>
<div
style={{
background: "#f8f8f8",
padding: "10px",
borderRadius: "5px",
marginBottom: "10px",
}}
>
<pre style={{ wordWrap: "break-word", whiteSpace: "normal", margin: 0 }}>
{apiKey}
</pre>
</div>
<CopyToClipboard text={apiKey} onCopy={handleCopy}>
<Button type="primary" style={{ marginTop: 12 }}>
{copied ? "Copied!" : "Copy Virtual Key"}
</Button>
</CopyToClipboard>
</div>
);
};
export default CreatedKeyDisplay;