Merge pull request #23429 from BerriAI/litellm_dev_03_10_2026_p1

Litellm dev 03 10 2026 p1
This commit is contained in:
Sameer Kankute 2026-03-12 18:04:48 +05:30 committed by GitHub
commit 7aa5bd3ff3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 676 additions and 207 deletions

View File

@ -267,6 +267,7 @@ model LiteLLM_ObjectPermissionTable {
vector_stores String[] @default([])
agents String[] @default([])
agent_access_groups String[] @default([])
models String[] @default([])
blocked_tools String[] @default([]) // Tool names blocked for any key/team/user with this permission
teams LiteLLM_TeamTable[]
projects LiteLLM_ProjectTable[]

View File

@ -1,59 +1,40 @@
import enum
import json
from datetime import datetime
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Literal, Optional, Union
from typing import (TYPE_CHECKING, Any, Callable, Dict, List, Literal,
Optional, Union)
import httpx
from pydantic import (
BaseModel,
ConfigDict,
Field,
Json,
field_validator,
model_validator,
)
from pydantic import (BaseModel, ConfigDict, Field, Json, field_validator,
model_validator)
from typing_extensions import Required, TypedDict
from litellm._uuid import uuid
from litellm.types.integrations.slack_alerting import AlertType
from litellm.types.llms.openai import (
AllMessageValues,
OpenAIFileObject,
ResponsesAPIResponse,
)
from litellm.types.mcp import (
MCPAuthType,
MCPCredentials,
MCPTransport,
MCPTransportType,
)
from litellm.types.llms.openai import (AllMessageValues, OpenAIFileObject,
ResponsesAPIResponse)
from litellm.types.mcp import (MCPAuthType, MCPCredentials, MCPTransport,
MCPTransportType)
from litellm.types.mcp_server.mcp_server_manager import MCPInfo
from litellm.types.router import RouterErrors, UpdateRouterConfig
from litellm.types.secret_managers.main import KeyManagementSystem
from litellm.types.utils import (
CallTypes,
CostBreakdown,
EmbeddingResponse,
GenericBudgetConfigType,
ImageResponse,
LiteLLMBatch,
LiteLLMFineTuningJob,
LiteLLMPydanticObjectBase,
ModelResponse,
ProviderField,
StandardCallbackDynamicParams,
StandardLoggingGuardrailInformation,
StandardLoggingMCPToolCall,
StandardLoggingModelInformation,
StandardLoggingPayloadErrorInformation,
StandardLoggingPayloadStatus,
StandardLoggingVectorStoreRequest,
StandardPassThroughResponseObject,
TextCompletionResponse,
)
from litellm.types.utils import (CallTypes, CostBreakdown, EmbeddingResponse,
GenericBudgetConfigType, ImageResponse,
LiteLLMBatch, LiteLLMFineTuningJob,
LiteLLMPydanticObjectBase, ModelResponse,
ProviderField, StandardCallbackDynamicParams,
StandardLoggingGuardrailInformation,
StandardLoggingMCPToolCall,
StandardLoggingModelInformation,
StandardLoggingPayloadErrorInformation,
StandardLoggingPayloadStatus,
StandardLoggingVectorStoreRequest,
StandardPassThroughResponseObject,
TextCompletionResponse)
from litellm.types.videos.main import VideoObject
from .types_utils.utils import get_instance_fn, validate_custom_validate_return_type
from .types_utils.utils import (get_instance_fn,
validate_custom_validate_return_type)
if TYPE_CHECKING:
from opentelemetry.trace import Span as _Span
@ -857,6 +838,7 @@ class LiteLLM_ObjectPermissionBase(LiteLLMPydanticObjectBase):
vector_stores: Optional[List[str]] = None
agents: Optional[List[str]] = None
agent_access_groups: Optional[List[str]] = None
models: Optional[List[str]] = None
class GenerateRequestBase(LiteLLMPydanticObjectBase):
@ -2504,7 +2486,8 @@ class UserAPIKeyAuth(
This is used to track number of requests/spend for health check calls.
"""
from litellm.constants import LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME
from litellm.constants import \
LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME
return cls(
api_key=LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME,
@ -2536,7 +2519,8 @@ class UserAPIKeyAuth(
This is used to track actions performed by automated system jobs.
"""
from litellm.constants import LITELLM_INTERNAL_JOBS_SERVICE_ACCOUNT_NAME
from litellm.constants import \
LITELLM_INTERNAL_JOBS_SERVICE_ACCOUNT_NAME
return cls(
api_key=LITELLM_INTERNAL_JOBS_SERVICE_ACCOUNT_NAME,
@ -2942,7 +2926,8 @@ class LiteLLM_AuditLogs(LiteLLMPydanticObjectBase):
@model_validator(mode="after")
def mask_api_keys(self):
from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker
from litellm.litellm_core_utils.sensitive_data_masker import \
SensitiveDataMasker
masker = SensitiveDataMasker(sensitive_patterns={"key"})

View File

@ -267,6 +267,7 @@ model LiteLLM_ObjectPermissionTable {
vector_stores String[] @default([])
agents String[] @default([])
agent_access_groups String[] @default([])
models String[] @default([])
blocked_tools String[] @default([]) // Tool names blocked for any key/team/user with this permission
teams LiteLLM_TeamTable[]
projects LiteLLM_ProjectTable[]

View File

@ -172,6 +172,8 @@ class AgentObjectPermission(TypedDict, total=False):
mcp_servers: Optional[List[str]]
mcp_access_groups: Optional[List[str]]
mcp_tool_permissions: Optional[Dict[str, List[str]]]
models: Optional[List[str]]
agents: Optional[List[str]]
class AgentConfig(TypedDict, total=False):

View File

@ -267,6 +267,7 @@ model LiteLLM_ObjectPermissionTable {
vector_stores String[] @default([])
agents String[] @default([])
agent_access_groups String[] @default([])
models String[] @default([])
blocked_tools String[] @default([]) // Tool names blocked for any key/team/user with this permission
teams LiteLLM_TeamTable[]
projects LiteLLM_ProjectTable[]

View File

@ -547,7 +547,7 @@ function CreateKeyPageContent() {
) : page == "policies" ? (
<PoliciesPanel accessToken={accessToken} userRole={userRole} />
) : page == "agents" ? (
<AgentsPanel accessToken={accessToken} userRole={userRole} />
<AgentsPanel accessToken={accessToken} userRole={userRole} teams={teams} />
) : page == "prompts" ? (
<PromptsPanel accessToken={accessToken} userRole={userRole} />
) : page == "transform-request" ? (

View File

@ -125,6 +125,208 @@ describe("EntityUsage", () => {
},
};
const mockAgentSpendData = {
results: [
{
date: "2025-01-01",
metrics: {
spend: 245.8,
api_requests: 3200,
successful_requests: 3100,
failed_requests: 100,
total_tokens: 1250000,
prompt_tokens: 850000,
completion_tokens: 400000,
cache_read_input_tokens: 50000,
cache_creation_input_tokens: 10000,
},
breakdown: {
entities: {
"agent-code-review": {
metrics: {
spend: 120.4,
api_requests: 1500,
successful_requests: 1450,
failed_requests: 50,
total_tokens: 620000,
prompt_tokens: 420000,
completion_tokens: 200000,
cache_read_input_tokens: 30000,
cache_creation_input_tokens: 5000,
},
metadata: { agent_name: "Code Review Agent" },
api_key_breakdown: {},
},
"agent-customer-support": {
metrics: {
spend: 85.2,
api_requests: 1200,
successful_requests: 1170,
failed_requests: 30,
total_tokens: 430000,
prompt_tokens: 290000,
completion_tokens: 140000,
cache_read_input_tokens: 15000,
cache_creation_input_tokens: 3000,
},
metadata: { agent_name: "Customer Support Agent" },
api_key_breakdown: {},
},
"agent-data-analyst": {
metrics: {
spend: 40.2,
api_requests: 500,
successful_requests: 480,
failed_requests: 20,
total_tokens: 200000,
prompt_tokens: 140000,
completion_tokens: 60000,
cache_read_input_tokens: 5000,
cache_creation_input_tokens: 2000,
},
metadata: { agent_name: "Data Analyst Agent" },
api_key_breakdown: {},
},
},
models: {
"gpt-4o": {
metrics: {
spend: 180.0,
api_requests: 2000,
successful_requests: 1950,
failed_requests: 50,
total_tokens: 900000,
prompt_tokens: 600000,
completion_tokens: 300000,
cache_read_input_tokens: 40000,
cache_creation_input_tokens: 8000,
},
metadata: {},
api_key_breakdown: {},
},
"claude-sonnet-4-20250514": {
metrics: {
spend: 65.8,
api_requests: 1200,
successful_requests: 1150,
failed_requests: 50,
total_tokens: 350000,
prompt_tokens: 250000,
completion_tokens: 100000,
cache_read_input_tokens: 10000,
cache_creation_input_tokens: 2000,
},
metadata: {},
api_key_breakdown: {},
},
},
api_keys: {},
providers: {
openai: {
metrics: {
spend: 180.0,
api_requests: 2000,
successful_requests: 1950,
failed_requests: 50,
total_tokens: 900000,
prompt_tokens: 600000,
completion_tokens: 300000,
cache_read_input_tokens: 40000,
cache_creation_input_tokens: 8000,
},
},
anthropic: {
metrics: {
spend: 65.8,
api_requests: 1200,
successful_requests: 1150,
failed_requests: 50,
total_tokens: 350000,
prompt_tokens: 250000,
completion_tokens: 100000,
cache_read_input_tokens: 10000,
cache_creation_input_tokens: 2000,
},
},
},
},
},
{
date: "2025-01-02",
metrics: {
spend: 198.5,
api_requests: 2800,
successful_requests: 2720,
failed_requests: 80,
total_tokens: 980000,
prompt_tokens: 670000,
completion_tokens: 310000,
cache_read_input_tokens: 42000,
cache_creation_input_tokens: 9000,
},
breakdown: {
entities: {
"agent-code-review": {
metrics: {
spend: 95.3,
api_requests: 1300,
successful_requests: 1270,
failed_requests: 30,
total_tokens: 510000,
prompt_tokens: 350000,
completion_tokens: 160000,
cache_read_input_tokens: 25000,
cache_creation_input_tokens: 4000,
},
metadata: { agent_name: "Code Review Agent" },
api_key_breakdown: {},
},
"agent-customer-support": {
metrics: {
spend: 68.7,
api_requests: 1000,
successful_requests: 970,
failed_requests: 30,
total_tokens: 320000,
prompt_tokens: 220000,
completion_tokens: 100000,
cache_read_input_tokens: 12000,
cache_creation_input_tokens: 3000,
},
metadata: { agent_name: "Customer Support Agent" },
api_key_breakdown: {},
},
"agent-data-analyst": {
metrics: {
spend: 34.5,
api_requests: 500,
successful_requests: 480,
failed_requests: 20,
total_tokens: 150000,
prompt_tokens: 100000,
completion_tokens: 50000,
cache_read_input_tokens: 5000,
cache_creation_input_tokens: 2000,
},
metadata: { agent_name: "Data Analyst Agent" },
api_key_breakdown: {},
},
},
models: {},
api_keys: {},
providers: {},
},
},
],
metadata: {
total_spend: 444.3,
total_api_requests: 6000,
total_successful_requests: 5820,
total_failed_requests: 180,
total_tokens: 2230000,
},
};
const defaultProps = {
accessToken: "test-token",
entityType: "tag" as const,
@ -153,7 +355,7 @@ describe("EntityUsage", () => {
mockTeamDailyActivityCall.mockResolvedValue(mockSpendData);
mockOrganizationDailyActivityCall.mockResolvedValue(mockSpendData);
mockCustomerDailyActivityCall.mockResolvedValue(mockSpendData);
mockAgentDailyActivityCall.mockResolvedValue(mockSpendData);
mockAgentDailyActivityCall.mockResolvedValue(mockAgentSpendData);
mockUserDailyActivityCall.mockResolvedValue(mockSpendData);
});
@ -231,7 +433,7 @@ describe("EntityUsage", () => {
expect(screen.getByText("Agent Spend Overview")).toBeInTheDocument();
await waitFor(() => {
const spendElements = screen.getAllByText("$100.50");
const spendElements = screen.getAllByText("$444.30");
expect(spendElements.length).toBeGreaterThan(0);
});
});
@ -385,6 +587,87 @@ describe("EntityUsage", () => {
});
});
it("should display Agent Activity tab for team entity type", async () => {
render(<EntityUsage {...defaultProps} entityType="team" />);
await waitFor(() => {
expect(mockTeamDailyActivityCall).toHaveBeenCalled();
});
expect(screen.getByText("Agent Activity")).toBeInTheDocument();
});
it("should not display Agent Activity tab for non-team entity types", async () => {
render(<EntityUsage {...defaultProps} entityType="tag" />);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
});
expect(screen.queryByText("Agent Activity")).not.toBeInTheDocument();
});
it("should display Top Agents Driving Spend card for team entity type", async () => {
render(<EntityUsage {...defaultProps} entityType="team" />);
await waitFor(() => {
expect(mockTeamDailyActivityCall).toHaveBeenCalled();
});
expect(screen.getByText("Top Agents Driving Spend")).toBeInTheDocument();
});
it("should not display Top Agents Driving Spend card for non-team entity types", async () => {
render(<EntityUsage {...defaultProps} entityType="tag" />);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
});
expect(screen.queryByText("Top Agents Driving Spend")).not.toBeInTheDocument();
});
it("should fetch agent activity data when entity type is team", async () => {
render(<EntityUsage {...defaultProps} entityType="team" />);
await waitFor(() => {
expect(mockAgentDailyActivityCall).toHaveBeenCalledWith(
"test-token",
expect.any(Date),
expect.any(Date),
1,
null,
);
});
});
it("should not fetch agent activity data for non-team entity types", async () => {
render(<EntityUsage {...defaultProps} entityType="tag" />);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
});
expect(mockAgentDailyActivityCall).not.toHaveBeenCalled();
});
it("should switch to Agent Activity tab for team entity type", async () => {
render(<EntityUsage {...defaultProps} entityType="team" />);
await waitFor(() => {
expect(mockTeamDailyActivityCall).toHaveBeenCalled();
});
const agentActivityTab = screen.getByText("Agent Activity");
act(() => {
fireEvent.click(agentActivityTab);
});
await waitFor(() => {
expect(screen.getAllByText("Activity Metrics").length).toBeGreaterThan(0);
});
});
it("should fallback to entity value when no entityList and no team_alias", async () => {
const spendDataWithoutAlias = {
...mockSpendData,

View File

@ -100,11 +100,24 @@ const EntityUsage: React.FC<EntityUsageProps> = ({ accessToken, entityType, enti
});
const { teams } = useTeams();
const [agentSpendData, setAgentSpendData] = useState<EntitySpendData>({
results: [],
metadata: {
total_spend: 0,
total_api_requests: 0,
total_successful_requests: 0,
total_failed_requests: 0,
total_tokens: 0,
},
});
const modelMetrics = processActivityData(spendData, "models", teams || []);
const keyMetrics = processActivityData(spendData, "api_keys", teams || []);
const agentMetrics = entityType === "team" ? processActivityData(agentSpendData, "entities", teams || []) : {};
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [topKeysLimit, setTopKeysLimit] = useState<number>(5);
const [topModelsLimit, setTopModelsLimit] = useState<number>(5);
const [topAgentsLimit, setTopAgentsLimit] = useState<number>(5);
const fetchSpendData = async () => {
if (!accessToken || !dateValue.from || !dateValue.to) return;
@ -171,8 +184,21 @@ const EntityUsage: React.FC<EntityUsageProps> = ({ accessToken, entityType, enti
}
};
const fetchAgentSpendData = async () => {
if (!accessToken || !dateValue.from || !dateValue.to || entityType !== "team") return;
const startTime = new Date(dateValue.from);
const endTime = new Date(dateValue.to);
try {
const data = await agentDailyActivityCall(accessToken, startTime, endTime, 1, null);
setAgentSpendData(data);
} catch (e) {
console.error("Failed to fetch agent activity data:", e);
}
};
useEffect(() => {
fetchSpendData();
fetchAgentSpendData();
}, [accessToken, dateValue, entityId, selectedTags]);
const getTopModels = () => {
@ -209,6 +235,37 @@ const EntityUsage: React.FC<EntityUsageProps> = ({ accessToken, entityType, enti
.slice(0, topModelsLimit);
};
const getTopAgents = () => {
const agentSpend: { [key: string]: any } = {};
agentSpendData.results.forEach((day) => {
Object.entries(day.breakdown.entities || {}).forEach(([agentId, data]) => {
if (!agentSpend[agentId]) {
agentSpend[agentId] = {
spend: 0,
requests: 0,
successful_requests: 0,
failed_requests: 0,
tokens: 0,
agent_name: (data.metadata as any)?.agent_name || agentId,
};
}
agentSpend[agentId].spend += data.metrics.spend;
agentSpend[agentId].requests += data.metrics.api_requests;
agentSpend[agentId].successful_requests += data.metrics.successful_requests;
agentSpend[agentId].failed_requests += data.metrics.failed_requests;
agentSpend[agentId].tokens += data.metrics.total_tokens;
});
});
return Object.entries(agentSpend)
.map(([agentId, metrics]) => ({
key: metrics.agent_name,
...metrics,
}))
.sort((a, b) => b.spend - a.spend)
.slice(0, topAgentsLimit);
};
const getTopAPIKeys = () => {
console.log("debugTags", { spendData });
const keySpend: { [key: string]: KeyMetricWithMetadata } = {};
@ -408,6 +465,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({ accessToken, entityType, enti
<TabList variant="solid" className="mt-1">
<Tab>Cost</Tab>
<Tab>{entityType === "agent" ? "Request / Token Consumption" : "Model Activity"}</Tab>
{entityType === "team" && <Tab>Agent Activity</Tab>}
<Tab>Key Activity</Tab>
<Tab>Endpoint Activity</Tab>
</TabList>
@ -621,6 +679,20 @@ const EntityUsage: React.FC<EntityUsageProps> = ({ accessToken, entityType, enti
</Card>
</Col>
{/* Top Agents - only for team entity type */}
{entityType === "team" && (
<Col numColSpan={2}>
<Card>
<Title>Top Agents Driving Spend</Title>
<TopModelView
topModels={getTopAgents()}
topModelsLimit={topAgentsLimit}
setTopModelsLimit={setTopAgentsLimit}
/>
</Card>
</Col>
)}
{/* Spend by Provider */}
<Col numColSpan={2}>
<Card>
@ -696,6 +768,11 @@ const EntityUsage: React.FC<EntityUsageProps> = ({ accessToken, entityType, enti
<TabPanel>
<ActivityMetrics modelMetrics={modelMetrics} hidePromptCachingMetrics={entityType === "agent"} />
</TabPanel>
{entityType === "team" && (
<TabPanel>
<ActivityMetrics modelMetrics={agentMetrics} />
</TabPanel>
)}
<TabPanel>
<ActivityMetrics modelMetrics={keyMetrics} hidePromptCachingMetrics={entityType === "agent"} />
</TabPanel>

View File

@ -362,7 +362,7 @@ export const formatKeyLabel = (modelData: KeyMetricWithMetadata, model: string,
// Process data function
export const processActivityData = (
dailyActivity: { results: DailyData[] },
key: "models" | "api_keys" | "mcp_servers",
key: "models" | "api_keys" | "mcp_servers" | "entities",
teams: Team[] = [],
): Record<string, ModelActivityData> => {
const modelMetrics: Record<string, ModelActivityData> = {};
@ -371,7 +371,11 @@ export const processActivityData = (
Object.entries(day.breakdown[key] || {}).forEach(([model, modelData]) => {
if (!modelMetrics[model]) {
modelMetrics[model] = {
label: key === "api_keys" ? formatKeyLabel(modelData as KeyMetricWithMetadata, model, teams) : model,
label: key === "api_keys"
? formatKeyLabel(modelData as KeyMetricWithMetadata, model, teams)
: key === "entities"
? ((modelData as any).metadata?.agent_name || (modelData as any).metadata?.team_alias || model)
: model,
total_requests: 0,
total_successful_requests: 0,
total_failed_requests: 0,

View File

@ -19,19 +19,21 @@ import { isAdminRole } from "@/utils/roles";
import AgentInfoView from "./agents/agent_info";
import NotificationsManager from "./molecules/notifications_manager";
import { Agent, AgentKeyInfo } from "./agents/types";
import { Team } from "./key_team_helpers/key_list";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import TableIconActionButton from "./common_components/IconActionButton/TableIconActionButtons/TableIconActionButton";
interface AgentsPanelProps {
accessToken: string | null;
userRole?: string;
teams?: Team[] | null;
}
interface AgentsResponse {
agents: Agent[];
}
const AgentsPanel: React.FC<AgentsPanelProps> = ({ accessToken, userRole }) => {
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);
@ -282,6 +284,7 @@ const AgentsPanel: React.FC<AgentsPanelProps> = ({ accessToken, userRole }) => {
onClose={handleCloseModal}
accessToken={accessToken}
onSuccess={handleSuccess}
teams={teams}
/>
{agentToDelete && (

View File

@ -6,6 +6,7 @@ import CreatedKeyDisplay from "../shared/CreatedKeyDisplay";
import {
createAgentCall,
getAgentCreateMetadata,
getAgentsList,
keyCreateForAgentCall,
keyListCall,
keyUpdateCall,
@ -14,11 +15,14 @@ import {
} from "../networking";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key";
import { Team } from "../key_team_helpers/key_list";
import TeamDropdown from "../common_components/team_dropdown";
import AgentFormFields from "./agent_form_fields";
import DynamicAgentFormFields, { buildDynamicAgentData } from "./dynamic_agent_form_fields";
import { getDefaultFormValues, buildAgentDataFromForm } from "./agent_config";
import MCPServerSelector from "../mcp_server_management/MCPServerSelector";
import MCPToolPermissions from "../mcp_server_management/MCPToolPermissions";
import GuardrailSelector from "../guardrails/GuardrailSelector";
const { Step } = Steps;
@ -29,6 +33,7 @@ interface AddAgentFormProps {
onClose: () => void;
accessToken: string | null;
onSuccess: () => void;
teams?: Team[] | null;
}
const AddAgentForm: React.FC<AddAgentFormProps> = ({
@ -36,6 +41,7 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({
onClose,
accessToken,
onSuccess,
teams,
}) => {
const { userId, userRole } = useAuthorized();
const [form] = Form.useForm();
@ -45,7 +51,7 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({
const [agentTypeMetadata, setAgentTypeMetadata] = useState<AgentCreateInfo[]>([]);
const [loadingMetadata, setLoadingMetadata] = useState(false);
// Step 1: key assignment state
// Step 3: key assignment state
const [keyAssignOption, setKeyAssignOption] = useState<"create_new" | "existing_key" | "skip">("create_new");
const [newKeyName, setNewKeyName] = useState<string>("");
const [newKeyModels, setNewKeyModels] = useState<string[]>([]);
@ -54,8 +60,10 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({
const [loadingKeys, setLoadingKeys] = useState(false);
const [availableModels, setAvailableModels] = useState<string[]>([]);
const [loadingModels, setLoadingModels] = useState(false);
const [availableAgents, setAvailableAgents] = useState<{agent_id: string; agent_name: string}[]>([]);
const [loadingAgents, setLoadingAgents] = useState(false);
// Step 2: results
// Step 4: results
const [createdAgentName, setCreatedAgentName] = useState<string>("");
const [createdKeyValue, setCreatedKeyValue] = useState<string | null>(null);
const [assignedKeyAlias, setAssignedKeyAlias] = useState<string | null>(null);
@ -82,9 +90,9 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({
fetchMetadata();
}, []);
// Fetch existing keys when assign key step becomes active (step 2)
// Fetch existing keys when Agent Management step becomes active (step 3)
useEffect(() => {
if (currentStep === 2 && accessToken && existingKeys.length === 0) {
if (currentStep === 3 && accessToken && existingKeys.length === 0) {
const fetchKeys = async () => {
setLoadingKeys(true);
try {
@ -100,9 +108,9 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({
}
}, [currentStep, accessToken]);
// Fetch available models when Assign Key step is active (same list as key generation)
// Fetch available models when Agent Management step is active (same list as key generation)
useEffect(() => {
if (currentStep !== 2 || !accessToken || !userId || !userRole) return;
if ((currentStep !== 1 && currentStep !== 3) || !accessToken || !userId || !userRole) return;
let cancelled = false;
setLoadingModels(true);
modelAvailableCall(accessToken, userId, userRole)
@ -125,6 +133,25 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({
};
}, [currentStep, accessToken, userId, userRole]);
useEffect(() => {
if (currentStep !== 1 || !accessToken) return;
let cancelled = false;
setLoadingAgents(true);
getAgentsList(accessToken)
.then((response) => {
if (cancelled) return;
const agents = response?.agents ?? [];
setAvailableAgents(agents.map((a: any) => ({ agent_id: a.agent_id, agent_name: a.agent_name })));
})
.catch((error) => {
if (!cancelled) console.error("Error fetching agents:", error);
})
.finally(() => {
if (!cancelled) setLoadingAgents(false);
});
return () => { cancelled = true; };
}, [currentStep, accessToken]);
const selectedAgentTypeInfo = agentTypeMetadata.find(
(info) => info.agent_type === agentType
);
@ -207,11 +234,14 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({
// Build object_permission from MCP Tools step (allowed_mcp_servers_and_groups, mcp_tool_permissions)
const mcpServersAndGroups = values.allowed_mcp_servers_and_groups;
const mcpToolPermissions = values.mcp_tool_permissions || {};
if (
mcpServersAndGroups &&
(mcpServersAndGroups.servers?.length > 0 || mcpServersAndGroups.accessGroups?.length > 0) ||
Object.keys(mcpToolPermissions).length > 0
) {
const entitlementModels = values.entitlement_models || [];
const entitlementAgents = values.entitlement_agents || [];
const hasObjectPermission =
(mcpServersAndGroups?.servers?.length > 0 || mcpServersAndGroups?.accessGroups?.length > 0) ||
Object.keys(mcpToolPermissions).length > 0 ||
entitlementModels.length > 0 ||
entitlementAgents.length > 0;
if (hasObjectPermission) {
agentData.object_permission = {};
if (mcpServersAndGroups?.servers?.length > 0) {
agentData.object_permission.mcp_servers = mcpServersAndGroups.servers;
@ -222,6 +252,12 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({
if (Object.keys(mcpToolPermissions).length > 0) {
agentData.object_permission.mcp_tool_permissions = mcpToolPermissions;
}
if (entitlementModels.length > 0) {
agentData.object_permission.models = entitlementModels;
}
if (entitlementAgents.length > 0) {
agentData.object_permission.agents = entitlementAgents;
}
}
// Wire trace-id flags and budget controls into agent litellm_params (before create call)
@ -237,6 +273,17 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({
}
}
const selectedGuardrails = values.guardrails || [];
if (selectedGuardrails.length > 0) {
if (!agentData.litellm_params) agentData.litellm_params = {};
agentData.litellm_params.guardrails = selectedGuardrails;
}
const selectedTeamId = values.team_id || null;
if (selectedTeamId) {
agentData.team_id = selectedTeamId;
}
const agentResponse = await createAgentCall(accessToken, agentData);
const agentId: string = agentResponse.agent_id;
const agentName: string = agentResponse.agent_name || values.agent_name || agentId;
@ -248,6 +295,8 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({
agentId,
newKeyName,
newKeyModels,
undefined,
selectedTeamId,
);
setCreatedKeyValue(keyResponse.key || null);
} else if (keyAssignOption === "existing_key") {
@ -264,7 +313,7 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({
setAssignedKeyAlias(keyInfo?.key_alias || selectedExistingKey.slice(0, 12) + "…");
}
setCurrentStep(3);
setCurrentStep(4);
onSuccess();
} catch (error) {
console.error("Error creating agent:", error);
@ -293,11 +342,54 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({
onClose();
};
const renderMCPToolsStep = () => (
const renderEntitlementsStep = () => (
<div className="space-y-4">
<p className="text-sm text-gray-600">
Optionally restrict which MCP servers and tools this agent can use. Leave empty to allow all (subject to key/team permissions).
Configure which models, agents, and MCP tools this agent is allowed to use. Leave fields empty to allow all (subject to key/team permissions).
</p>
<Form.Item
label={<span className="text-sm font-medium text-gray-700">Allowed Models</span>}
name="entitlement_models"
tooltip="Restrict which models this agent can call. Leave empty to allow all."
>
<Select
mode="tags"
style={{ width: "100%" }}
placeholder={loadingModels ? "Loading models..." : "Select models (leave empty for all)"}
tokenSeparators={[","]}
loading={loadingModels}
showSearch
options={availableModels.map((m) => ({
label: getModelDisplayName(m),
value: m,
}))}
/>
</Form.Item>
<Form.Item
label={<span className="text-sm font-medium text-gray-700">Allowed Agents (Sub-Agents)</span>}
name="entitlement_agents"
tooltip="Restrict which other agents this agent can invoke as sub-agents. Leave empty to allow all."
>
<Select
mode="multiple"
style={{ width: "100%" }}
placeholder={loadingAgents ? "Loading agents..." : "Select agents (leave empty for all)"}
loading={loadingAgents}
showSearch
filterOption={(input, option) =>
(option?.label as string ?? "").toLowerCase().includes(input.toLowerCase())
}
options={availableAgents.map((a) => ({
label: a.agent_name,
value: a.agent_id,
}))}
/>
</Form.Item>
<Divider className="my-2" />
<Form.Item
label={
<span>
@ -338,122 +430,137 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({
</div>
)}
</Form.Item>
</div>
);
<Collapse ghost className="mt-6" items={[
{
key: "tracing",
label: <span className="text-sm font-medium text-gray-700">Tracing</span>,
children: (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-medium text-gray-700">
Require x-litellm-trace-id on calls TO this agent
</span>
<p className="text-xs text-gray-500 mt-1">
Only accept this agent being invoked with a trace-id (e.g. when used as a sub-agent).
</p>
</div>
<Switch
checked={requireTraceIdInbound}
onChange={setRequireTraceIdInbound}
/>
</div>
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-medium text-gray-700">
Require x-litellm-trace-id on calls BY this agent
</span>
<p className="text-xs text-gray-500 mt-1">
Requires LLM/MCP calls made by this agent to include x-litellm-trace-id for session tracking.
</p>
</div>
<Switch
checked={requireTraceIdOutbound}
onChange={(checked) => {
setRequireTraceIdOutbound(checked);
if (!checked) {
setMaxIterations(null);
setMaxBudgetPerSession(null);
}
}}
/>
</div>
</div>
),
},
{
key: "budgets_and_rate_limits",
label: <span className="text-sm font-medium text-gray-700">Budgets &amp; Rate Limits</span>,
children: (
<div className="space-y-4">
{!requireTraceIdOutbound && (
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg text-sm text-yellow-800">
Enable &quot;Require x-litellm-trace-id on calls BY this agent&quot; in Tracing to configure budgets and rate limits.
</div>
)}
<div className="text-sm font-medium text-gray-700">Session Budgets</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-gray-600 block mb-1">Max Iterations</label>
<InputNumber
className="w-full"
min={1}
placeholder="e.g. 25"
disabled={!requireTraceIdOutbound}
value={maxIterations}
onChange={(val) => setMaxIterations(val)}
/>
<p className="text-xs text-gray-400 mt-1">Hard cap on LLM calls per session</p>
</div>
<div>
<label className="text-sm text-gray-600 block mb-1">Max Budget Per Session ($)</label>
<InputNumber
className="w-full"
min={0.01}
step={0.5}
placeholder="e.g. 5.00"
disabled={!requireTraceIdOutbound}
value={maxBudgetPerSession}
onChange={(val) => setMaxBudgetPerSession(val)}
/>
<p className="text-xs text-gray-400 mt-1">Max spend per trace before returning 429</p>
</div>
</div>
<Divider className="my-2" />
<div className="text-sm font-medium text-gray-700">Agent Rate Limits</div>
<p className="text-xs text-gray-500">
Global rate limits applied across all callers of this agent.
const renderObservabilityStep = () => (
<div className="space-y-6">
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3">Tracing</h4>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-medium text-gray-700">
Require x-litellm-trace-id on calls TO this agent
</span>
<p className="text-xs text-gray-500 mt-1">
Only accept this agent being invoked with a trace-id (e.g. when used as a sub-agent).
</p>
<div className="grid grid-cols-2 gap-4">
<Form.Item label="TPM Limit" name="tpm_limit" className="mb-0">
<InputNumber className="w-full" min={0} placeholder="e.g. 100000" disabled={!requireTraceIdOutbound} />
</Form.Item>
<Form.Item label="RPM Limit" name="rpm_limit" className="mb-0">
<InputNumber className="w-full" min={0} placeholder="e.g. 100" disabled={!requireTraceIdOutbound} />
</Form.Item>
</div>
<div className="text-sm font-medium text-gray-700 mt-4">Per-Session Rate Limits</div>
<p className="text-xs text-gray-500">
Rate limits per session (x-litellm-trace-id). Each session gets its own counters.
</p>
<div className="grid grid-cols-2 gap-4">
<Form.Item label="Session TPM Limit" name="session_tpm_limit" className="mb-0">
<InputNumber className="w-full" min={0} placeholder="e.g. 10000" disabled={!requireTraceIdOutbound} />
</Form.Item>
<Form.Item label="Session RPM Limit" name="session_rpm_limit" className="mb-0">
<InputNumber className="w-full" min={0} placeholder="e.g. 20" disabled={!requireTraceIdOutbound} />
</Form.Item>
</div>
</div>
),
},
]} />
<Switch
checked={requireTraceIdInbound}
onChange={setRequireTraceIdInbound}
/>
</div>
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-medium text-gray-700">
Require x-litellm-trace-id on calls BY this agent
</span>
<p className="text-xs text-gray-500 mt-1">
Requires LLM/MCP calls made by this agent to include x-litellm-trace-id for session tracking.
</p>
</div>
<Switch
checked={requireTraceIdOutbound}
onChange={(checked) => {
setRequireTraceIdOutbound(checked);
if (!checked) {
setMaxIterations(null);
setMaxBudgetPerSession(null);
}
}}
/>
</div>
</div>
</div>
<Divider className="my-0" />
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3">Budgets &amp; Rate Limits</h4>
<div className="space-y-4">
{!requireTraceIdOutbound && (
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg text-sm text-yellow-800">
Enable &quot;Require x-litellm-trace-id on calls BY this agent&quot; in Tracing to configure budgets and rate limits.
</div>
)}
<div className="text-sm font-medium text-gray-700">Session Budgets</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-gray-600 block mb-1">Max Iterations</label>
<InputNumber
className="w-full"
min={1}
placeholder="e.g. 25"
disabled={!requireTraceIdOutbound}
value={maxIterations}
onChange={(val) => setMaxIterations(val)}
/>
<p className="text-xs text-gray-400 mt-1">Hard cap on LLM calls per session</p>
</div>
<div>
<label className="text-sm text-gray-600 block mb-1">Max Budget Per Session ($)</label>
<InputNumber
className="w-full"
min={0.01}
step={0.5}
placeholder="e.g. 5.00"
disabled={!requireTraceIdOutbound}
value={maxBudgetPerSession}
onChange={(val) => setMaxBudgetPerSession(val)}
/>
<p className="text-xs text-gray-400 mt-1">Max spend per trace before returning 429</p>
</div>
</div>
<Divider className="my-2" />
<div className="text-sm font-medium text-gray-700">Agent Rate Limits</div>
<p className="text-xs text-gray-500">
Global rate limits applied across all callers of this agent.
</p>
<div className="grid grid-cols-2 gap-4">
<Form.Item label="TPM Limit" name="tpm_limit" className="mb-0">
<InputNumber className="w-full" min={0} placeholder="e.g. 100000" disabled={!requireTraceIdOutbound} />
</Form.Item>
<Form.Item label="RPM Limit" name="rpm_limit" className="mb-0">
<InputNumber className="w-full" min={0} placeholder="e.g. 100" disabled={!requireTraceIdOutbound} />
</Form.Item>
</div>
<div className="text-sm font-medium text-gray-700 mt-4">Per-Session Rate Limits</div>
<p className="text-xs text-gray-500">
Rate limits per session (x-litellm-trace-id). Each session gets its own counters.
</p>
<div className="grid grid-cols-2 gap-4">
<Form.Item label="Session TPM Limit" name="session_tpm_limit" className="mb-0">
<InputNumber className="w-full" min={0} placeholder="e.g. 10000" disabled={!requireTraceIdOutbound} />
</Form.Item>
<Form.Item label="Session RPM Limit" name="session_rpm_limit" className="mb-0">
<InputNumber className="w-full" min={0} placeholder="e.g. 20" disabled={!requireTraceIdOutbound} />
</Form.Item>
</div>
</div>
</div>
<Divider className="my-0" />
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3">Guardrails</h4>
<p className="text-xs text-gray-500 mb-3">
Apply guardrails to this agent. Selected guardrails will run on all calls made by this agent.
</p>
<Form.Item name="guardrails" initialValue={[]}>
<GuardrailSelector
accessToken={accessToken ?? ""}
value={form.getFieldValue("guardrails") ?? []}
onChange={(selected: string[]) => form.setFieldsValue({ guardrails: selected })}
/>
</Form.Item>
</div>
</div>
);
@ -610,6 +717,19 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({
</Tag>
</div>
<Form.Item
label={<span className="text-sm font-medium text-gray-700">Assign to Team</span>}
name="team_id"
tooltip="Optionally assign this agent to a team. The agent and its key will belong to the selected team."
>
<TeamDropdown
teams={teams}
loading={!teams}
/>
</Form.Item>
<Divider className="my-4" />
<div className="space-y-3">
{/* Option: Create new key */}
<div
@ -645,25 +765,6 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({
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={loadingModels ? "Loading models..." : "e.g. gpt-4o, claude-3-5-sonnet"}
value={newKeyModels}
onChange={setNewKeyModels}
tokenSeparators={[","]}
loading={loadingModels}
showSearch
options={availableModels.map((m) => ({
label: getModelDisplayName(m),
value: m,
}))}
/>
</div>
</div>
)}
</div>
@ -783,8 +884,9 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({
{/* Step indicator */}
<Steps current={currentStep} size="small" className="mb-8">
<Step title="Configure" />
<Step title="Agent Settings" />
<Step title="Assign Key" />
<Step title="Entitlements" />
<Step title="Governance" />
<Step title="Agent Management" />
<Step title="Ready" />
</Steps>
@ -793,21 +895,22 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({
layout="vertical"
initialValues={
agentType === "a2a"
? { ...getDefaultFormValues(), allowed_mcp_servers_and_groups: { servers: [], accessGroups: [] }, mcp_tool_permissions: {} }
: { allowed_mcp_servers_and_groups: { servers: [], accessGroups: [] }, mcp_tool_permissions: {} }
? { ...getDefaultFormValues(), allowed_mcp_servers_and_groups: { servers: [], accessGroups: [] }, mcp_tool_permissions: {}, entitlement_models: [], entitlement_agents: [], guardrails: [] }
: { allowed_mcp_servers_and_groups: { servers: [], accessGroups: [] }, mcp_tool_permissions: {}, entitlement_models: [], entitlement_agents: [], guardrails: [] }
}
className="space-y-4"
>
{currentStep === 0 && renderConfigureStep()}
{currentStep === 1 && renderMCPToolsStep()}
{currentStep === 2 && renderAssignKeyStep()}
{currentStep === 3 && renderReadyStep()}
{currentStep === 1 && renderEntitlementsStep()}
{currentStep === 2 && renderObservabilityStep()}
{currentStep === 3 && renderAssignKeyStep()}
{currentStep === 4 && renderReadyStep()}
</Form>
{/* Footer navigation */}
<div className="flex items-center justify-between pt-6 border-t border-gray-100 mt-6">
<div>
{currentStep > 0 && currentStep < 3 && (
{currentStep > 0 && currentStep < 4 && (
<button
type="button"
onClick={handleBack}
@ -818,7 +921,7 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({
)}
</div>
<div className="flex gap-3">
{currentStep < 3 && (
{currentStep < 4 && (
<Button variant="secondary" onClick={handleClose}>
Cancel
</Button>
@ -834,11 +937,16 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({
</Button>
)}
{currentStep === 2 && (
<Button variant="primary" onClick={handleNext}>
Next
</Button>
)}
{currentStep === 3 && (
<Button variant="primary" loading={isSubmitting} onClick={handleCreateAgent}>
{isSubmitting ? "Creating..." : "Create Agent →"}
</Button>
)}
{currentStep === 3 && (
{currentStep === 4 && (
<Button variant="primary" onClick={handleClose}>
Done
</Button>

View File

@ -907,6 +907,7 @@ export const keyCreateForAgentCall = async (
keyAlias: string,
models: string[],
metadata?: Record<string, any>,
teamId?: string | null,
) => {
const url = proxyBaseUrl ? `${proxyBaseUrl}/key/generate` : `/key/generate`;
const body: Record<string, any> = {
@ -914,6 +915,9 @@ export const keyCreateForAgentCall = async (
key_alias: keyAlias,
models: models.length > 0 ? models : [],
};
if (teamId) {
body.team_id = teamId;
}
if (metadata && Object.keys(metadata).length > 0) {
body.metadata = metadata;
}