diff --git a/ui/app/workspace/governance/virtual-keys/page.tsx b/ui/app/workspace/governance/virtual-keys/page.tsx index 7934d0a2071..3e9b1d8cd6c 100644 --- a/ui/app/workspace/governance/virtual-keys/page.tsx +++ b/ui/app/workspace/governance/virtual-keys/page.tsx @@ -148,7 +148,7 @@ export default function GovernanceVirtualKeysPage() { }; return ( -
+
{ if (virtualKey.team) { @@ -480,4 +480,4 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe ); -} \ No newline at end of file +} diff --git a/ui/app/workspace/virtual-keys/views/virtualKeySheet.tsx b/ui/app/workspace/virtual-keys/views/virtualKeySheet.tsx index 3bfabf131c5..465eaedf4ab 100644 --- a/ui/app/workspace/virtual-keys/views/virtualKeySheet.tsx +++ b/ui/app/workspace/virtual-keys/views/virtualKeySheet.tsx @@ -1,51 +1,96 @@ import { useVirtualKeyUsage } from "@/app/workspace/virtual-keys/hooks/useVirtualKeyUsage"; -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, } from "@/components/ui/alertDialog"; import { AsyncMultiSelect } from "@/components/ui/asyncMultiselect"; import { Button } from "@/components/ui/button"; import { ComboboxSelect } from "@/components/ui/combobox"; import { ConfigSyncAlert } from "@/components/ui/configSyncAlert"; -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ModelMultiselect } from "@/components/ui/modelMultiselect"; import MultiBudgetLines from "@/components/ui/multibudgets"; import { MultiSelect } from "@/components/ui/multiSelect"; import NumberAndSelect from "@/components/ui/numberAndSelect"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { DottedSeparator } from "@/components/ui/separator"; -import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; import { Switch } from "@/components/ui/switch"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; import { Textarea } from "@/components/ui/textarea"; import Toggle from "@/components/ui/toggle"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { cn } from "@/components/ui/utils"; import { ModelPlaceholders } from "@/lib/constants/config"; -import { resetDurationOptions, supportsCalendarAlignment } from "@/lib/constants/governance"; +import { + resetDurationOptions, + supportsCalendarAlignment, +} from "@/lib/constants/governance"; import { ProviderIconType, RenderProviderIcon } from "@/lib/constants/icons"; import { ProviderLabels, ProviderName } from "@/lib/constants/logs"; import { - getErrorMessage, - useCreateVirtualKeyMutation, - useGetAllKeysQuery, - useGetMCPClientsQuery, - useGetProvidersQuery, - useRotateVirtualKeyMutation, - useUpdateVirtualKeyMutation, + getErrorMessage, + useCreateVirtualKeyMutation, + useGetAllKeysQuery, + useGetMCPClientsQuery, + useGetProvidersQuery, + useRotateVirtualKeyMutation, + useUpdateVirtualKeyMutation, } from "@/lib/store"; import { KnownProvider } from "@/lib/types/config"; -import { CreateVirtualKeyRequest, Customer, Team, UpdateVirtualKeyRequest, VirtualKey } from "@/lib/types/governance"; +import { + CreateVirtualKeyRequest, + Customer, + Team, + UpdateVirtualKeyRequest, + VirtualKey, +} from "@/lib/types/governance"; import { useGetAccessProfilesQuery } from "@enterprise/lib/store/apis/accessProfileApi"; import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -58,2058 +103,2887 @@ import { toast } from "sonner"; import { z } from "zod"; interface VirtualKeySheetProps { - virtualKey?: VirtualKey | null; - teams: Team[]; - customers: Customer[]; - // When set, the new VK is created under this team. The entity assignment is pre-set - // and cannot be changed (but all other fields remain editable). - defaultTeamId?: string; - // When set, the new VK is created under this access profile. The entity assignment is - // pre-set and cannot be changed. - defaultAccessProfileId?: number; - onSave: () => void; - onCancel: () => void; + virtualKey?: VirtualKey | null; + teams: Team[]; + customers: Customer[]; + // When set, the new VK is created under this team. The entity assignment is pre-set + // and cannot be changed (but all other fields remain editable). + defaultTeamId?: string; + // When set, the new VK is created under this access profile. The entity assignment is + // pre-set and cannot be changed. + defaultAccessProfileId?: number; + onSave: () => void; + onCancel: () => void; } // Provider configuration schema const providerConfigSchema = z.object({ - id: z.number().optional(), - provider: z.string().min(1, "Provider is required"), - weight: z.number().min(0, "Weight must be at least 0").max(1, "Weight must be at most 1").optional(), - allowed_models: z.array(z.string()).optional(), - blacklisted_models: z.array(z.string()).optional(), - key_ids: z.array(z.string()).optional(), // Keys associated with this provider config - // Provider-level budget - budgets: z - .array( - z.object({ - id: z.string().optional(), - max_limit: z.number().nonnegative().optional(), - reset_duration: z.string().optional(), - }), - ) - .optional(), - // Provider-level rate limits - rate_limit: z - .object({ - token_max_limit: z.number().int().nonnegative().optional(), - token_reset_duration: z.string().optional(), - request_max_limit: z.number().int().nonnegative().optional(), - request_reset_duration: z.string().optional(), - }) - .optional(), + id: z.number().optional(), + provider: z.string().min(1, "Provider is required"), + weight: z + .number() + .min(0, "Weight must be at least 0") + .max(1, "Weight must be at most 1") + .optional(), + allowed_models: z.array(z.string()).optional(), + blacklisted_models: z.array(z.string()).optional(), + key_ids: z.array(z.string()).optional(), // Keys associated with this provider config + // Provider-level budget + budgets: z + .array( + z.object({ + id: z.string().optional(), + max_limit: z.number().nonnegative().optional(), + reset_duration: z.string().optional(), + }), + ) + .optional(), + // Provider-level rate limits + rate_limit: z + .object({ + token_max_limit: z.number().int().nonnegative().optional(), + token_reset_duration: z.string().optional(), + request_max_limit: z.number().int().nonnegative().optional(), + request_reset_duration: z.string().optional(), + }) + .optional(), }); const mcpConfigSchema = z.object({ - id: z.number().optional(), - mcp_client_name: z.string().min(1, "MCP client name is required"), - tools_to_execute: z.array(z.string()).optional(), + id: z.number().optional(), + mcp_client_name: z.string().min(1, "MCP client name is required"), + tools_to_execute: z.array(z.string()).optional(), }); // Main form schema const formSchema = z - .object({ - name: z.string().min(1, "Virtual key name is required"), - description: z.string().optional(), - providerConfigs: z.array(providerConfigSchema).optional(), - mcpConfigs: z.array(mcpConfigSchema).optional(), - entityType: z.enum(["team", "customer", "access_profile", "none"]), - teamId: z.string().optional(), - customerId: z.string().optional(), - accessProfileId: z.string().optional(), - isActive: z.boolean(), - // Budget - budgetCalendarAligned: z.boolean(), - budgets: z - .array( - z.object({ - id: z.string().optional(), - max_limit: z.number().nonnegative().optional(), - reset_duration: z.string(), - }), - ) - .optional(), - // Token limits - tokenMaxLimit: z.number().int().nonnegative().optional(), - tokenResetDuration: z.string().optional(), - // Request limits - requestMaxLimit: z.number().int().nonnegative().optional(), - requestResetDuration: z.string().optional(), - }) - .refine( - (data) => { - // If entityType is "team", teamId must be provided and not empty - if (data.entityType === "team") { - return data.teamId && data.teamId.trim() !== ""; - } - // If entityType is "customer", customerId must be provided and not empty - if (data.entityType === "customer") { - return data.customerId && data.customerId.trim() !== ""; - } - // If entityType is "access_profile", accessProfileId must be provided and not empty - if (data.entityType === "access_profile") { - return data.accessProfileId && data.accessProfileId.trim() !== ""; - } - return true; - }, - { - message: "Please select a valid team, customer, or access profile when assignment type is chosen", - path: ["entityType"], - }, - ); + .object({ + name: z.string().min(1, "Virtual key name is required"), + description: z.string().optional(), + providerConfigs: z.array(providerConfigSchema).optional(), + mcpConfigs: z.array(mcpConfigSchema).optional(), + entityType: z.enum(["team", "customer", "access_profile", "none"]), + teamId: z.string().optional(), + customerId: z.string().optional(), + accessProfileId: z.string().optional(), + isActive: z.boolean(), + // Budget + budgetCalendarAligned: z.boolean(), + budgets: z + .array( + z.object({ + id: z.string().optional(), + max_limit: z.number().nonnegative().optional(), + reset_duration: z.string(), + }), + ) + .optional(), + // Token limits + tokenMaxLimit: z.number().int().nonnegative().optional(), + tokenResetDuration: z.string().optional(), + // Request limits + requestMaxLimit: z.number().int().nonnegative().optional(), + requestResetDuration: z.string().optional(), + }) + .refine( + (data) => { + // If entityType is "team", teamId must be provided and not empty + if (data.entityType === "team") { + return data.teamId && data.teamId.trim() !== ""; + } + // If entityType is "customer", customerId must be provided and not empty + if (data.entityType === "customer") { + return data.customerId && data.customerId.trim() !== ""; + } + // If entityType is "access_profile", accessProfileId must be provided and not empty + if (data.entityType === "access_profile") { + return data.accessProfileId && data.accessProfileId.trim() !== ""; + } + return true; + }, + { + message: + "Please select a valid team, customer, or access profile when assignment type is chosen", + path: ["entityType"], + }, + ); type FormData = z.infer; type BudgetComparisonEntry = { - id?: string; - max_limit?: number; - reset_duration?: string; - current_usage?: number; + id?: string; + max_limit?: number; + reset_duration?: string; + current_usage?: number; }; type VirtualKeyType = { - label: string; - value: string; - description: string; - provider: string; + label: string; + value: string; + description: string; + provider: string; }; export default function VirtualKeySheet({ - virtualKey, - teams, - customers, - defaultTeamId, - defaultAccessProfileId, - onSave, - onCancel, + virtualKey, + teams, + customers, + defaultTeamId, + defaultAccessProfileId, + onSave, + onCancel, }: VirtualKeySheetProps) { - const [isOpen, setIsOpen] = useState(true); - const navigate = useNavigate(); - const isEditing = !!virtualKey; - - const hasCreateAccess = useRbac(RbacResource.VirtualKeys, RbacOperation.Create); - const hasUpdateAccess = useRbac(RbacResource.VirtualKeys, RbacOperation.Update); - const canSubmit = isEditing ? hasUpdateAccess : hasCreateAccess; - - // Detect AP-managed status via the managing profile's virtual_key_ids, not just by the presence - // of assignees — directly-attached users don't imply an access-profile relation. - const { assignedUsers, isManagedByProfile: isManagedByProfileHook } = useVirtualKeyUsage(virtualKey); - const isManagedByProfile = isEditing && isManagedByProfileHook; - // Team attachment: when creating from a team context (defaultTeamId provided), the entity - // assignment is pre-set and locked. When editing an existing VK the assignment can be changed. - const attachedTeamId = isEditing ? virtualKey?.team_id || "" : defaultTeamId || ""; - const attachedTeam = attachedTeamId ? teams.find((t) => t.id === attachedTeamId) : undefined; - const isTeamLocked = !isEditing && !!defaultTeamId; - const isAPLocked = !isEditing && !!defaultAccessProfileId; - - const handleClose = () => { - setIsOpen(false); - setTimeout(() => { - onCancel(); - }, 150); // Slightly longer than the 100ms animation duration - }; - - // RTK Query hooks - const { data: providersData, error: providersError } = useGetProvidersQuery(); - const { data: keysData, error: keysError } = useGetAllKeysQuery(); - const [createVirtualKey, { isLoading: isCreating }] = useCreateVirtualKeyMutation(); - const [updateVirtualKey, { isLoading: isUpdating }] = useUpdateVirtualKeyMutation(); - const [rotateVirtualKey, { isLoading: isRotating }] = useRotateVirtualKeyMutation(); - const { data: mcpClientsResponse, error: mcpClientsError } = useGetMCPClientsQuery(); - const { data: accessProfilesData } = useGetAccessProfilesQuery({ limit: 100 }); - const accessProfiles = accessProfilesData?.access_profiles ?? []; - const mcpClientsData = mcpClientsResponse?.clients || []; - const isLoading = isCreating || isUpdating || isRotating; - - const availableKeys = keysData || []; - const availableProviders = providersData || []; - - // Form setup - const form = useForm, unknown, FormData>({ - resolver: zodResolver(formSchema), - defaultValues: { - name: virtualKey?.name || "", - description: virtualKey?.description || "", - providerConfigs: - virtualKey?.provider_configs?.map((config) => ({ - id: config.id, - provider: config.provider, - weight: config.weight ?? undefined, - allowed_models: config.allowed_models, - blacklisted_models: config.blacklisted_models, - key_ids: config.allow_all_keys ? ["*"] : config.keys?.map((key) => key.key_id) || [], - budgets: config.budgets?.map((b) => ({ - id: b.id, - max_limit: b.max_limit, - reset_duration: b.reset_duration, - })), - rate_limit: config.rate_limit - ? { - token_max_limit: config.rate_limit.token_max_limit ?? undefined, - token_reset_duration: config.rate_limit.token_reset_duration, - request_max_limit: config.rate_limit.request_max_limit ?? undefined, - request_reset_duration: config.rate_limit.request_reset_duration, - } - : undefined, - })) || [], - mcpConfigs: - virtualKey?.mcp_configs?.map((config) => ({ - id: config.id, - mcp_client_name: config.mcp_client?.name || "", - tools_to_execute: config.tools_to_execute || [], - })) || [], - entityType: virtualKey?.team_id - ? "team" - : virtualKey?.customer_id - ? "customer" - : virtualKey?.access_profile_id - ? "access_profile" - : !isEditing && defaultTeamId - ? "team" - : !isEditing && defaultAccessProfileId - ? "access_profile" - : "none", - teamId: virtualKey?.team_id || (!isEditing ? defaultTeamId || "" : ""), - customerId: virtualKey?.customer_id || "", - accessProfileId: virtualKey?.access_profile_id - ? String(virtualKey.access_profile_id) - : !isEditing && defaultAccessProfileId - ? String(defaultAccessProfileId) - : "", - isActive: virtualKey?.is_active ?? true, - budgets: - virtualKey?.budgets && virtualKey.budgets.length > 0 - ? virtualKey.budgets.map((b) => ({ id: b.id, max_limit: b.max_limit, reset_duration: b.reset_duration ?? "1M" })) - : [], - budgetCalendarAligned: virtualKey?.calendar_aligned ?? false, - tokenMaxLimit: virtualKey?.rate_limit?.token_max_limit ?? undefined, - tokenResetDuration: virtualKey?.rate_limit?.token_reset_duration || "1h", - requestMaxLimit: virtualKey?.rate_limit?.request_max_limit ?? undefined, - requestResetDuration: virtualKey?.rate_limit?.request_reset_duration || "1h", - }, - }); - - // Handle keys loading error - useEffect(() => { - if (keysError) { - toast.error(`Failed to load available keys: ${getErrorMessage(keysError)}`); - } - }, [keysError]); - - // Handle providers loading error - useEffect(() => { - if (providersError) { - toast.error(`Failed to load available providers: ${getErrorMessage(providersError)}`); - } - }, [providersError]); - - // Handle mcp clients loading error - useEffect(() => { - if (mcpClientsError) { - toast.error(`Failed to load available MCP clients: ${getErrorMessage(mcpClientsError)}`); - } - }, [mcpClientsError]); - - // Clear entity ID fields when entityType changes - useEffect(() => { - const entityType = form.watch("entityType"); - if (entityType === "none") { - form.setValue("teamId", "", { shouldDirty: true }); - form.setValue("customerId", "", { shouldDirty: true }); - form.setValue("accessProfileId", "", { shouldDirty: true }); - } else if (entityType === "team") { - form.setValue("customerId", "", { shouldDirty: true }); - form.setValue("accessProfileId", "", { shouldDirty: true }); - } else if (entityType === "customer") { - form.setValue("teamId", "", { shouldDirty: true }); - form.setValue("accessProfileId", "", { shouldDirty: true }); - } else if (entityType === "access_profile") { - form.setValue("teamId", "", { shouldDirty: true }); - form.setValue("customerId", "", { shouldDirty: true }); - } - }, [form.watch("entityType"), form]); - - // Provider configuration state - const [selectedProvider, setSelectedProvider] = useState(""); - - // MCP client configuration state - const [selectedMCPClient, setSelectedMCPClient] = useState(""); - - // Get current provider configs from form - const providerConfigs = form.watch("providerConfigs") || []; - - // Get current MCP configs from form - const mcpConfigs = form.watch("mcpConfigs") || []; - - // Watch budget/rate-limit fields for conditional rendering of reset buttons - const watchedBudgets = form.watch("budgets"); - const watchedTokenMaxLimit = form.watch("tokenMaxLimit"); - const watchedRequestMaxLimit = form.watch("requestMaxLimit"); - const watchedTokenResetDuration = form.watch("tokenResetDuration"); - const watchedRequestResetDuration = form.watch("requestResetDuration"); - const watchedBudgetCalendarAligned = form.watch("budgetCalendarAligned"); - - // Calendar alignment is VK-wide and applies to both budgets and rate limits: show the - // toggle when any configured budget or rate-limit uses a calendar-alignable duration. - const hasAnyAlignableBudget = - watchedBudgets && - watchedBudgets.length > 0 && - watchedBudgets.some((b) => b.max_limit !== undefined && b.max_limit !== null && supportsCalendarAlignment(b.reset_duration || "1M")); - const hasAnyAlignableRateLimit = - (watchedTokenMaxLimit !== undefined && watchedTokenMaxLimit !== null && supportsCalendarAlignment(watchedTokenResetDuration || "1h")) || - (watchedRequestMaxLimit !== undefined && - watchedRequestMaxLimit !== null && - supportsCalendarAlignment(watchedRequestResetDuration || "1h")); - const showCalendarAlignToggle = hasAnyAlignableBudget || hasAnyAlignableRateLimit; - - // Handle adding a new provider configuration - const handleAddProvider = (provider: string) => { - const existingConfig = providerConfigs.find((config) => config.provider === provider); - if (existingConfig) { - toast.error("This provider is already configured"); - return; - } - - const newConfig = { - provider: provider, - weight: undefined as number | undefined, // undefined = excluded from weighted routing until user sets a weight - allowed_models: ["*"], - blacklisted_models: [], - key_ids: ["*"], - }; - - form.setValue("providerConfigs", [...providerConfigs, newConfig], { shouldDirty: true }); - }; - - // Handle removing a provider configuration - const handleRemoveProvider = (index: number) => { - const updatedConfigs = providerConfigs.filter((_, i) => i !== index); - form.setValue("providerConfigs", updatedConfigs, { shouldDirty: true }); - }; - - // Handle updating provider configuration - const handleUpdateProviderConfig = (index: number, field: string, value: any) => { - const updatedConfigs = [...providerConfigs]; - updatedConfigs[index] = { ...updatedConfigs[index], [field]: value }; - form.setValue("providerConfigs", updatedConfigs, { shouldDirty: true }); - }; - - // Handle adding a new MCP client configuration - const handleAddMCPClient = (mcpClientName: string) => { - const existingConfig = mcpConfigs.find((config) => config.mcp_client_name === mcpClientName); - if (existingConfig) { - toast.error("This MCP client is already configured"); - return; - } - - const newConfig = { - mcp_client_name: mcpClientName, - tools_to_execute: ["*"], - }; - - form.setValue("mcpConfigs", [...mcpConfigs, newConfig], { shouldDirty: true }); - }; - - // Handle removing an MCP client configuration - const handleRemoveMCPClient = (index: number) => { - const updatedConfigs = mcpConfigs.filter((_, i) => i !== index); - form.setValue("mcpConfigs", updatedConfigs, { shouldDirty: true }); - }; - - // Handle updating MCP client configuration - const handleUpdateMCPConfig = (index: number, field: keyof (typeof mcpConfigs)[0], value: any) => { - const updatedConfigs = [...mcpConfigs]; - updatedConfigs[index] = { ...updatedConfigs[index], [field]: value }; - form.setValue("mcpConfigs", updatedConfigs, { shouldDirty: true }); - }; - - const [showCalendarAlignWarning, setShowCalendarAlignWarning] = useState(false); - const [showReassignTeamWarning, setShowReassignTeamWarning] = useState(false); - const [pendingTeamId, setPendingTeamId] = useState(null); - const [showReassignAPWarning, setShowReassignAPWarning] = useState(false); - const [pendingAccessProfileId, setPendingAccessProfileId] = useState(null); - const [showReassignTypeWarning, setShowReassignTypeWarning] = useState(false); - const [pendingEntityType, setPendingEntityType] = useState<"team" | "customer" | "access_profile" | "none" | null>(null); - const [showRotateWarning, setShowRotateWarning] = useState(false); - const [showBudgetResetPrompt, setShowBudgetResetPrompt] = useState(false); - const [pendingBudgetResetData, setPendingBudgetResetData] = useState(null); - const [pendingBudgetUsageWarning, setPendingBudgetUsageWarning] = useState(null); - - const currentAssignmentLabel = useMemo(() => { - if (!isEditing) return null; - if (virtualKey?.team_id) { - const team = teams?.find((t) => t.id === virtualKey.team_id); - return team ? `Team: ${team.name}` : "a team"; - } - if (virtualKey?.customer_id) { - const customer = customers?.find((c) => c.id === virtualKey.customer_id); - return customer ? `Customer: ${customer.name}` : "a customer"; - } - if (virtualKey?.access_profile_id) { - const ap = accessProfiles?.find((p) => p.id === virtualKey.access_profile_id); - return ap ? `Access Profile: ${ap.name}` : "an access profile"; - } - return null; - }, [isEditing, virtualKey, teams, customers, accessProfiles]); - - const handleCalendarAlignedChange = (checked: boolean) => { - if (checked && isEditing) { - // Show warning when enabling on an existing VK - setShowCalendarAlignWarning(true); - } else { - form.setValue("budgetCalendarAligned", checked, { shouldDirty: true }); - } - }; - - const clearVirtualKeyBudget = () => { - form.setValue("budgets", [], { shouldDirty: true }); - form.setValue("budgetCalendarAligned", false, { shouldDirty: true }); - }; - - const clearVirtualKeyRateLimits = () => { - form.setValue("tokenMaxLimit", undefined, { shouldDirty: true }); - form.setValue("tokenResetDuration", "1h", { shouldDirty: true }); - form.setValue("requestMaxLimit", undefined, { shouldDirty: true }); - form.setValue("requestResetDuration", "1h", { shouldDirty: true }); - }; - - const normalizeProviderConfigs = (configs: typeof providerConfigs, existingConfigs?: VirtualKey["provider_configs"]): any[] => { - return configs.map((config) => ({ - ...config, - budgets: config.budgets?.filter((b): b is { id?: string; max_limit: number; reset_duration: string } => b.max_limit !== undefined), - weight: config.weight ?? null, - rate_limit: (() => { - const hasTokenMaxLimit = config.rate_limit?.token_max_limit !== undefined; - const hasRequestMaxLimit = config.rate_limit?.request_max_limit !== undefined; - if (hasTokenMaxLimit || hasRequestMaxLimit) { - return { - token_max_limit: config.rate_limit?.token_max_limit ?? null, - token_reset_duration: hasTokenMaxLimit ? config.rate_limit?.token_reset_duration || "1h" : null, - request_max_limit: config.rate_limit?.request_max_limit ?? null, - request_reset_duration: hasRequestMaxLimit ? config.rate_limit?.request_reset_duration || "1h" : null, - }; - } - - const existingConfig = existingConfigs?.find((item) => (config.id ? item.id === config.id : item.provider === config.provider)); - if (existingConfig?.rate_limit) { - return {}; - } - - return undefined; - })(), - })); - }; - - const budgetSignature = (budgets?: BudgetComparisonEntry[]) => - (budgets || []) - .filter((budget) => budget.max_limit !== undefined) - .map((budget) => `${budget.id ?? ""}:${budget.max_limit}:${budget.reset_duration ?? ""}`) - .sort() - .join("|"); - - const parseResetDurationMs = (duration?: string) => { - if (!duration) return null; - const match = duration.match(/^(\d+(?:\.\d+)?)(ms|s|m|h|d|w|M|Y)$/); - if (!match) return null; - const amount = Number(match[1]); - const unit = match[2]; - const multipliers: Record = { - ms: 1, - s: 1000, - m: 60 * 1000, - h: 60 * 60 * 1000, - d: 24 * 60 * 60 * 1000, - w: 7 * 24 * 60 * 60 * 1000, - M: 30 * 24 * 60 * 60 * 1000, - Y: 365 * 24 * 60 * 60 * 1000, - }; - return amount * multipliers[unit]; - }; - - const formatBudgetAmount = (value: number) => - new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 2 }).format(value); - - const findBudgetUsageWarning = ( - currentBudgets: BudgetComparisonEntry[] | undefined, - existingBudgets: BudgetComparisonEntry[] | undefined, - scopeLabel: string, - ) => { - const current = (currentBudgets || []) - .filter((budget): budget is BudgetComparisonEntry & { max_limit: number; reset_duration: string } => { - return budget.max_limit !== undefined && !!budget.reset_duration; - }) - .sort((left, right) => (parseResetDurationMs(left.reset_duration) ?? 0) - (parseResetDurationMs(right.reset_duration) ?? 0)); - const existingByID = new Map((existingBudgets || []).filter((budget) => budget.id).map((budget) => [budget.id, budget])); - const existingByDuration = new Map((existingBudgets || []).map((budget) => [budget.reset_duration, budget])); - const reconciled: BudgetComparisonEntry[] = []; - - for (const budget of current) { - const existing = budget.id ? existingByID.get(budget.id) : existingByDuration.get(budget.reset_duration); - if (existing) { - const configChanged = existing.max_limit !== budget.max_limit || existing.reset_duration !== budget.reset_duration; - const usage = existing.current_usage ?? 0; - if (configChanged && usage >= budget.max_limit) { - return `${scopeLabel} ${budget.reset_duration} budget has ${formatBudgetAmount(usage)} usage, which meets or exceeds the new ${formatBudgetAmount(budget.max_limit)} limit.`; - } - reconciled.push({ ...budget, current_usage: usage }); - continue; - } - - const targetDuration = parseResetDurationMs(budget.reset_duration); - const closestShorter = reconciled.reduce((closest, candidate) => { - const candidateDuration = parseResetDurationMs(candidate.reset_duration); - const closestDuration = parseResetDurationMs(closest?.reset_duration); - if (targetDuration === null || candidateDuration === null || candidateDuration >= targetDuration) { - return closest; - } - if (closest === null || closestDuration === null || candidateDuration > closestDuration) { - return candidate; - } - return closest; - }, null); - const inheritedUsage = closestShorter?.current_usage ?? 0; - if (inheritedUsage >= budget.max_limit) { - return `${scopeLabel} ${budget.reset_duration} budget will inherit ${formatBudgetAmount(inheritedUsage)} from the ${closestShorter?.reset_duration} budget, which meets or exceeds the new ${formatBudgetAmount(budget.max_limit)} limit.`; - } - reconciled.push({ ...budget, current_usage: inheritedUsage }); - } - - return null; - }; - - const getBudgetUsageWarning = (data: FormData) => { - if (!isEditing || !virtualKey || isManagedByProfile) { - return null; - } - - const vkWarning = findBudgetUsageWarning(data.budgets, virtualKey.budgets, "Virtual key"); - if (vkWarning) { - return vkWarning; - } - - const existingProviderConfigs = new Map[number]>(); - (virtualKey.provider_configs || []).forEach((config) => { - existingProviderConfigs.set(String(config.id ?? config.provider), config); - }); - for (const config of data.providerConfigs || []) { - const existingConfig = existingProviderConfigs.get(String(config.id ?? config.provider)); - const providerLabel = ProviderLabels[config.provider as ProviderName] ?? config.provider; - const warning = findBudgetUsageWarning(config.budgets, existingConfig?.budgets, `${providerLabel} provider`); - if (warning) { - return warning; - } - } - - return null; - }; - - const hasBudgetResetRelevantChanges = (data: FormData) => { - if (!isEditing || !virtualKey || isManagedByProfile) { - return false; - } - - const currentBudgets = (data.budgets || []).filter( - (budget): budget is { id?: string; max_limit: number; reset_duration: string } => budget.max_limit !== undefined, - ); - const existingBudgets = virtualKey.budgets || []; - const hasBudgetFields = - currentBudgets.length > 0 || - existingBudgets.length > 0 || - (data.providerConfigs || []).some((config) => (config.budgets || []).some((budget) => budget.max_limit !== undefined)) || - (virtualKey.provider_configs || []).some((config) => (config.budgets || []).length > 0); - - if (budgetSignature(currentBudgets) !== budgetSignature(existingBudgets)) { - return true; - } - - if (hasBudgetFields && data.budgetCalendarAligned !== (virtualKey.calendar_aligned ?? false)) { - return true; - } - - const existingProviderConfigs = new Map[number]>(); - (virtualKey.provider_configs || []).forEach((config) => { - existingProviderConfigs.set(String(config.id ?? config.provider), config); - }); - - const currentProviderConfigs = new Map[number]>(); - (data.providerConfigs || []).forEach((config) => { - currentProviderConfigs.set(String(config.id ?? config.provider), config); - }); - - const providerConfigKeys = new Set([...existingProviderConfigs.keys(), ...currentProviderConfigs.keys()]); - for (const key of providerConfigKeys) { - const currentSignature = budgetSignature(currentProviderConfigs.get(key)?.budgets); - const existingSignature = budgetSignature(existingProviderConfigs.get(key)?.budgets); - if (currentSignature !== existingSignature) { - return true; - } - } - - return false; - }; - - const handleRotateVirtualKey = async () => { - if (!virtualKey) return; - if (!hasUpdateAccess) { - toast.error("You don't have permission to perform this action"); - return; - } - try { - await rotateVirtualKey(virtualKey.id).unwrap(); - toast.success("Virtual key rotated successfully"); - setShowRotateWarning(false); - onSave(); - } catch (error) { - toast.error(getErrorMessage(error)); - } - }; - - const submitVirtualKeyForm = async (data: FormData, resetBudgetUsage = false) => { - if (!canSubmit) { - toast.error("You don't have permission to perform this action"); - return; - } - try { - // Managed VKs only allow name + description updates; all other fields are owned by the access profile. - if (isManagedByProfile && virtualKey) { - await updateVirtualKey({ - vkId: virtualKey.id, - data: { - name: data.name, - description: data.description, - }, - }).unwrap(); - toast.success("Virtual key updated"); - onSave(); - return; - } - - // Normalize provider configs to ensure weights are numbers and handle budget/rate limits - const normalizedProviderConfigs = data.providerConfigs - ? normalizeProviderConfigs(data.providerConfigs, virtualKey?.provider_configs) - : []; - if (isEditing && virtualKey) { - // Update existing virtual key - const updateData: UpdateVirtualKeyRequest = { - name: data.name, - description: data.description, - provider_configs: normalizedProviderConfigs, - mcp_configs: data.mcpConfigs, - team_id: data.entityType === "team" && data.teamId && data.teamId.trim() !== "" ? data.teamId : undefined, - customer_id: data.entityType === "customer" && data.customerId && data.customerId.trim() !== "" ? data.customerId : undefined, - access_profile_id: - data.entityType === "access_profile" && data.accessProfileId && data.accessProfileId.trim() !== "" - ? Number(data.accessProfileId) - : undefined, - is_active: data.isActive, - calendar_aligned: data.budgetCalendarAligned, - reset_budget_usage: resetBudgetUsage, - }; - - // Add budgets if enabled - const validBudgets = (data.budgets || []).filter( - (b): b is { id?: string; max_limit: number; reset_duration: string } => b.max_limit !== undefined, - ); - const hadBudget = virtualKey.budgets && virtualKey.budgets.length > 0; - if (validBudgets.length > 0) { - updateData.budgets = validBudgets; - } else if (hadBudget) { - updateData.budgets = []; - } - - // Add rate limit if enabled - const hadRateLimit = !!virtualKey.rate_limit; - const hasTokenMaxLimit = data.tokenMaxLimit !== undefined; - const hasRequestMaxLimit = data.requestMaxLimit !== undefined; - const hasRateLimit = hasTokenMaxLimit || hasRequestMaxLimit; - if (hasRateLimit) { - updateData.rate_limit = { - token_max_limit: data.tokenMaxLimit ?? null, - token_reset_duration: hasTokenMaxLimit ? data.tokenResetDuration || "1h" : null, - request_max_limit: data.requestMaxLimit ?? null, - request_reset_duration: hasRequestMaxLimit ? data.requestResetDuration || "1h" : null, - }; - } else if (hadRateLimit) { - updateData.rate_limit = {}; - } - - await updateVirtualKey({ vkId: virtualKey.id, data: updateData }).unwrap(); - toast.success("Virtual key updated successfully"); - } else { - // Create new virtual key - const createData: CreateVirtualKeyRequest = { - name: data.name, - description: data.description || undefined, - provider_configs: normalizedProviderConfigs, - mcp_configs: data.mcpConfigs, - team_id: data.entityType === "team" && data.teamId && data.teamId.trim() !== "" ? data.teamId : undefined, - customer_id: data.entityType === "customer" && data.customerId && data.customerId.trim() !== "" ? data.customerId : undefined, - access_profile_id: - data.entityType === "access_profile" && data.accessProfileId && data.accessProfileId.trim() !== "" - ? Number(data.accessProfileId) - : undefined, - is_active: data.isActive, - // VK-level setting that governs both budget and rate-limit calendar alignment. - calendar_aligned: data.budgetCalendarAligned, - }; - - // Add budgets if enabled - const validBudgets = (data.budgets || []).filter( - (b): b is { id?: string; max_limit: number; reset_duration: string } => b.max_limit !== undefined, - ); - if (validBudgets.length > 0) { - createData.budgets = validBudgets; - } - - // Add rate limit if enabled - const hasTokenMaxLimit = data.tokenMaxLimit !== undefined; - const hasRequestMaxLimit = data.requestMaxLimit !== undefined; - if (hasTokenMaxLimit || hasRequestMaxLimit) { - createData.rate_limit = { - token_max_limit: data.tokenMaxLimit, - token_reset_duration: hasTokenMaxLimit ? data.tokenResetDuration || "1h" : undefined, - request_max_limit: data.requestMaxLimit, - request_reset_duration: hasRequestMaxLimit ? data.requestResetDuration || "1h" : undefined, - }; - } - - await createVirtualKey(createData).unwrap(); - toast.success("Virtual key created successfully"); - } - - onSave(); - } catch (error) { - toast.error(getErrorMessage(error)); - } - }; - - // Handle form submission - const onSubmit = async (data: FormData) => { - if (hasBudgetResetRelevantChanges(data)) { - setPendingBudgetResetData(data); - setPendingBudgetUsageWarning(getBudgetUsageWarning(data)); - setShowBudgetResetPrompt(true); - return; - } - - await submitVirtualKeyForm(data, false); - }; - - const handleBudgetResetChoice = async (resetBudgetUsage: boolean) => { - if (!pendingBudgetResetData) return; - const data = pendingBudgetResetData; - setPendingBudgetResetData(null); - setPendingBudgetUsageWarning(null); - setShowBudgetResetPrompt(false); - await submitVirtualKeyForm(data, resetBudgetUsage); - }; - - return ( - !open && handleClose()}> - e.preventDefault()} - onEscapeKeyDown={() => handleClose()} - > - - {isEditing ? virtualKey?.name : "Create Virtual Key"} - - {isEditing - ? "Update the virtual key configuration and permissions." - : "Create a new virtual key with specific permissions, budgets, and rate limits."} - - - -
- -
- {isManagedByProfile && ( - - - - This virtual key is managed by an access profile. Only the name and description can be modified — providers, budgets, - rate limits, and MCP access are controlled by the profile. - - - )} - - {isTeamLocked && !isManagedByProfile && ( - - - - Creating this virtual key under team {attachedTeam?.name ?? attachedTeamId}. Team - assignment is pre-set — all other fields are editable. - - - )} - - {/* Assigned User */} - {assignedUsers.length > 0 && ( -
- -
- - {assignedUsers.map((u) => u.name || u.email).join(", ")} -
-
- )} - - {/* Basic Information */} -
- ( - - Name * - - - - - - )} - /> - - ( - - Description - -