1537 lines
62 KiB
TypeScript
1537 lines
62 KiB
TypeScript
import { useVirtualKeyUsage } from "@/app/workspace/virtual-keys/hooks/useVirtualKeyUsage";
|
|
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,
|
|
} from "@/components/ui/alertDialog";
|
|
import { AsyncMultiSelect } from "@/components/ui/asyncMultiselect";
|
|
import { Button } from "@/components/ui/button";
|
|
import { ConfigSyncAlert } from "@/components/ui/configSyncAlert";
|
|
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 { DottedSeparator } from "@/components/ui/separator";
|
|
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 { Textarea } from "@/components/ui/textarea";
|
|
import Toggle from "@/components/ui/toggle";
|
|
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 { ProviderIconType, RenderProviderIcon } from "@/lib/constants/icons";
|
|
import { ProviderLabels, ProviderName } from "@/lib/constants/logs";
|
|
import {
|
|
getErrorMessage,
|
|
useCreateVirtualKeyMutation,
|
|
useGetAllKeysQuery,
|
|
useGetMCPClientsQuery,
|
|
useGetProvidersQuery,
|
|
useUpdateVirtualKeyMutation,
|
|
} from "@/lib/store";
|
|
import { KnownProvider } from "@/lib/types/config";
|
|
import { CreateVirtualKeyRequest, Customer, Team, UpdateVirtualKeyRequest, VirtualKey } from "@/lib/types/governance";
|
|
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import { useNavigate } from "@tanstack/react-router";
|
|
import { Building, Info, Lock, RotateCcw, Trash2, Users, X } from "lucide-react";
|
|
import { useEffect, useState } from "react";
|
|
import { useForm } from "react-hook-form";
|
|
import { components, MultiValueProps, OptionProps } from "react-select";
|
|
import { toast } from "sonner";
|
|
import { z } from "zod";
|
|
|
|
interface VirtualKeySheetProps {
|
|
virtualKey?: VirtualKey | null;
|
|
teams: Team[];
|
|
customers: Customer[];
|
|
// When set and not editing, the new VK is created owned by this team and the sheet locks
|
|
// all fields except name/description (same treatment as access-profile-managed keys).
|
|
defaultTeamId?: string;
|
|
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(),
|
|
key_ids: z.array(z.string()).optional(), // Keys associated with this provider config
|
|
// Provider-level budget
|
|
budgets: z
|
|
.array(
|
|
z.object({
|
|
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(),
|
|
});
|
|
|
|
// 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", "none"]),
|
|
teamId: z.string().optional(),
|
|
customerId: z.string().optional(),
|
|
isActive: z.boolean(),
|
|
// Budget
|
|
budgetCalendarAligned: z.boolean(),
|
|
budgets: z
|
|
.array(
|
|
z.object({
|
|
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() !== "";
|
|
}
|
|
return true;
|
|
},
|
|
{
|
|
message: "Please select a valid team or customer when assignment type is chosen",
|
|
path: ["entityType"], // This will show the error on the entityType field
|
|
},
|
|
);
|
|
|
|
type FormData = z.infer<typeof formSchema>;
|
|
|
|
type VirtualKeyType = {
|
|
label: string;
|
|
value: string;
|
|
description: string;
|
|
provider: string;
|
|
};
|
|
|
|
export default function VirtualKeySheet({ virtualKey, teams, customers, defaultTeamId, 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 a VK already belongs to a team (edit) or will be created for one
|
|
// (create from team detail sheet via defaultTeamId), the team assignment is fixed — users
|
|
// can still edit providers/budgets/rate limits/MCP, but not reparent the VK.
|
|
const attachedTeamId = isEditing ? (virtualKey?.team_id || "") : (defaultTeamId || "");
|
|
const attachedTeam = attachedTeamId ? teams.find((t) => t.id === attachedTeamId) : undefined;
|
|
const isTeamLocked = !!attachedTeamId;
|
|
|
|
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 { data: mcpClientsResponse, error: mcpClientsError } = useGetMCPClientsQuery();
|
|
const mcpClientsData = mcpClientsResponse?.clients || [];
|
|
const isLoading = isCreating || isUpdating;
|
|
|
|
const availableKeys = keysData || [];
|
|
const availableProviders = providersData || [];
|
|
|
|
// Form setup
|
|
const form = useForm<z.input<typeof formSchema>, 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,
|
|
key_ids: config.allow_all_keys ? ["*"] : config.keys?.map((key) => key.key_id) || [],
|
|
budgets: config.budgets?.map((b) => ({
|
|
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"
|
|
: !isEditing && defaultTeamId
|
|
? "team"
|
|
: "none",
|
|
teamId: virtualKey?.team_id || (!isEditing ? defaultTeamId || "" : ""),
|
|
customerId: virtualKey?.customer_id || "",
|
|
isActive: virtualKey?.is_active ?? true,
|
|
budgets:
|
|
virtualKey?.budgets && virtualKey.budgets.length > 0
|
|
? virtualKey.budgets.map((b) => ({ 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 team/customer IDs when entityType changes to "none"
|
|
useEffect(() => {
|
|
const entityType = form.watch("entityType");
|
|
if (entityType === "none") {
|
|
form.setValue("teamId", "", { shouldDirty: true });
|
|
form.setValue("customerId", "", { shouldDirty: true });
|
|
} else if (entityType === "team") {
|
|
form.setValue("customerId", "", { shouldDirty: true });
|
|
} else if (entityType === "customer") {
|
|
form.setValue("teamId", "", { shouldDirty: true });
|
|
}
|
|
}, [form.watch("entityType"), form]);
|
|
|
|
// Provider configuration state
|
|
const [selectedProvider, setSelectedProvider] = useState<string>("");
|
|
|
|
// MCP client configuration state
|
|
const [selectedMCPClient, setSelectedMCPClient] = useState<string>("");
|
|
|
|
// 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 watchedBudgetCalendarAligned = form.watch("budgetCalendarAligned");
|
|
|
|
// Calendar alignment is VK-wide: show toggle if any budget has a max_limit and supports alignment
|
|
const hasAnyAlignableBudget =
|
|
watchedBudgets &&
|
|
watchedBudgets.length > 0 &&
|
|
watchedBudgets.some((b) => b.max_limit !== undefined && b.max_limit !== null && supportsCalendarAlignment(b.reset_duration || "1M"));
|
|
|
|
// 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: ["*"],
|
|
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 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 { 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;
|
|
})(),
|
|
}));
|
|
};
|
|
|
|
// Handle form submission
|
|
const onSubmit = async (data: FormData) => {
|
|
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,
|
|
is_active: data.isActive,
|
|
};
|
|
|
|
// Add budgets if enabled
|
|
const validBudgets = (data.budgets || []).filter(
|
|
(b): b is { 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;
|
|
updateData.calendar_aligned = data.budgetCalendarAligned;
|
|
} else if (hadBudget) {
|
|
updateData.budgets = [];
|
|
updateData.calendar_aligned = false;
|
|
}
|
|
|
|
// 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,
|
|
is_active: data.isActive,
|
|
};
|
|
|
|
// Add budgets if enabled
|
|
const validBudgets = (data.budgets || []).filter(
|
|
(b): b is { max_limit: number; reset_duration: string } => b.max_limit !== undefined,
|
|
);
|
|
if (validBudgets.length > 0) {
|
|
createData.budgets = validBudgets;
|
|
createData.calendar_aligned = data.budgetCalendarAligned;
|
|
}
|
|
|
|
// 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));
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Sheet open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
|
<SheetContent
|
|
className="flex w-full flex-col overflow-x-hidden px-4 pb-8"
|
|
data-testid="vk-sheet-content"
|
|
onInteractOutside={(e) => e.preventDefault()}
|
|
onEscapeKeyDown={() => handleClose()}
|
|
>
|
|
<SheetHeader className="flex flex-col items-start px-3 pt-8">
|
|
<SheetTitle className="flex items-center gap-2">{isEditing ? virtualKey?.name : "Create Virtual Key"}</SheetTitle>
|
|
<SheetDescription>
|
|
{isEditing
|
|
? "Update the virtual key configuration and permissions."
|
|
: "Create a new virtual key with specific permissions, budgets, and rate limits."}
|
|
</SheetDescription>
|
|
</SheetHeader>
|
|
|
|
<Form {...form}>
|
|
<form onSubmit={form.handleSubmit(onSubmit)} className="flex h-full flex-col gap-6 px-4">
|
|
<div className="space-y-4">
|
|
{isManagedByProfile && (
|
|
<Alert variant="info">
|
|
<Lock className="h-4 w-4" />
|
|
<AlertDescription>
|
|
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.
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{isTeamLocked && !isManagedByProfile && (
|
|
<Alert variant="info">
|
|
<Users className="h-4 w-4" />
|
|
<AlertDescription>
|
|
<p>This virtual key is attached to team{" "}
|
|
<a
|
|
data-testid="vk-team-link"
|
|
href={`/workspace/governance/teams?team=${encodeURIComponent(attachedTeamId)}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="font-medium underline underline-offset-2 hover:no-underline"
|
|
>
|
|
{attachedTeam?.name ?? virtualKey?.team?.name ?? attachedTeamId}
|
|
</a>
|
|
. The team assignment can't be changed here — all other fields remain editable.</p>
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{/* Assigned User */}
|
|
{assignedUsers.length > 0 && (
|
|
<div className="space-y-1">
|
|
<Label className="text-sm font-medium">Assigned To</Label>
|
|
<div className="flex items-center gap-2">
|
|
<Users className="text-muted-foreground h-4 w-4" />
|
|
<span className="text-sm">{assignedUsers.map((u) => u.name || u.email).join(", ")}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Basic Information */}
|
|
<div className="space-y-4">
|
|
<FormField
|
|
control={form.control}
|
|
name="name"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Name *</FormLabel>
|
|
<FormControl>
|
|
<Input placeholder="e.g., Production API Key" data-testid="vk-name-input" {...field} />
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="description"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Description</FormLabel>
|
|
<FormControl>
|
|
<Textarea placeholder="This key is used for..." data-testid="vk-description-input" {...field} rows={3} />
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
</div>
|
|
<fieldset
|
|
disabled={isManagedByProfile}
|
|
aria-disabled={isManagedByProfile}
|
|
inert={isManagedByProfile ? true : undefined}
|
|
className={isManagedByProfile ? "pointer-events-none space-y-4 opacity-50" : "space-y-4"}
|
|
>
|
|
<div className="space-y-4">
|
|
<FormField
|
|
control={form.control}
|
|
name="isActive"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<Toggle label="Is this key active?" val={field.value} setVal={field.onChange} data-testid="vk-is-active-toggle" />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
{/* Provider Configurations */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<Label className="text-sm font-medium">Provider Configurations</Label>
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span>
|
|
<Info className="text-muted-foreground h-3 w-3" />
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>
|
|
Configure which providers this virtual key can use and their specific settings. Leave empty to block all
|
|
providers. Add providers to allow them.
|
|
</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</div>
|
|
|
|
{/* Add Provider Dropdown */}
|
|
<div className="flex gap-2">
|
|
<Select
|
|
value={selectedProvider}
|
|
onValueChange={(provider) => {
|
|
if (provider === "__manage_providers__") {
|
|
navigate({ to: "/workspace/providers" });
|
|
setSelectedProvider("");
|
|
return;
|
|
}
|
|
handleAddProvider(provider);
|
|
setSelectedProvider(""); // Reset to placeholder state
|
|
}}
|
|
>
|
|
<SelectTrigger className="flex-1" data-testid="vk-provider-select">
|
|
<SelectValue placeholder="Select a provider to add" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{(() => {
|
|
// Filter out already configured providers
|
|
const unconfiguredProviders = availableProviders.filter(
|
|
(provider) => !providerConfigs.some((config) => config.provider === provider.name),
|
|
);
|
|
|
|
if (unconfiguredProviders.length === 0) {
|
|
return (
|
|
<SelectItem
|
|
value="__manage_providers__"
|
|
className="text-muted-foreground hover:text-foreground"
|
|
data-testid="vk-provider-config-link"
|
|
>
|
|
<span>
|
|
No providers left to configure. <span className="text-primary font-medium underline">Click to add</span>
|
|
</span>
|
|
</SelectItem>
|
|
);
|
|
}
|
|
|
|
// Separate base providers and custom providers
|
|
const baseProviders = unconfiguredProviders.filter((provider) => !provider.custom_provider_config);
|
|
const customProviders = unconfiguredProviders.filter((provider) => provider.custom_provider_config);
|
|
|
|
return (
|
|
<>
|
|
{/* Base providers first */}
|
|
{baseProviders
|
|
.filter((p) => p.name)
|
|
.map((provider, index) => (
|
|
<SelectItem key={`base-${index}`} value={provider.name}>
|
|
<RenderProviderIcon provider={provider.name as KnownProvider} size="sm" className="h-4 w-4" />
|
|
{ProviderLabels[provider.name as ProviderName]}
|
|
</SelectItem>
|
|
))}
|
|
|
|
{/* Custom providers second */}
|
|
{customProviders
|
|
.filter((p) => p.name)
|
|
.map((provider, index) => (
|
|
<SelectItem key={`custom-${index}`} value={provider.name}>
|
|
<RenderProviderIcon
|
|
provider={provider.custom_provider_config?.base_provider_type || (provider.name as KnownProvider)}
|
|
size="sm"
|
|
className="h-4 w-4"
|
|
/>
|
|
{provider.name}
|
|
</SelectItem>
|
|
))}
|
|
</>
|
|
);
|
|
})()}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Provider Configurations Table */}
|
|
{providerConfigs.length > 0 && (
|
|
<div className="rounded-md border px-2">
|
|
<Accordion type="multiple" className="w-full">
|
|
{providerConfigs.map((config, index) => {
|
|
const providerConfig = availableProviders.find((provider) => provider.name === config.provider);
|
|
return (
|
|
<AccordionItem key={index} className="w-full" value={`${config.provider}-${index}`}>
|
|
<AccordionTrigger className="flex h-12 items-center gap-0 px-1">
|
|
<div className="flex w-full items-center justify-between">
|
|
<div className="flex w-fit items-center gap-2">
|
|
<RenderProviderIcon
|
|
provider={
|
|
providerConfig?.custom_provider_config?.base_provider_type || (config.provider as ProviderIconType)
|
|
}
|
|
size="sm"
|
|
className="h-4 w-4"
|
|
/>
|
|
{providerConfig?.custom_provider_config
|
|
? providerConfig.name
|
|
: ProviderLabels[config.provider as ProviderName]}
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
aria-label={`Remove ${config.provider} provider`}
|
|
className="hover:bg-accent/50 h-8 w-8 rounded-sm p-2"
|
|
data-testid={`vk-delete-provider-${index}`}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleRemoveProvider(index);
|
|
}}
|
|
>
|
|
<Trash2 className="h-4 w-4 opacity-75" />
|
|
</Button>
|
|
</div>
|
|
</AccordionTrigger>
|
|
<AccordionContent className="flex flex-col gap-4 px-1 text-balance">
|
|
<div className="flex w-full items-start gap-2">
|
|
<div className="w-1/4">
|
|
<NumberAndSelect
|
|
id={`vk-weight-${index}`}
|
|
label="Weight"
|
|
labelClassName="text-sm font-medium"
|
|
placeholder="Exclude from routing"
|
|
inputClassName="h-[38px] w-full"
|
|
dataTestId={`vk-weight-input-${index}`}
|
|
value={config.weight}
|
|
onChangeNumber={(value) => handleUpdateProviderConfig(index, "weight", value)}
|
|
/>
|
|
</div>
|
|
<div className="w-3/4 space-y-2">
|
|
<Label className="text-sm font-medium">
|
|
Allowed Models <span className="text-muted-foreground ml-auto text-xs italic">type to search</span>
|
|
</Label>
|
|
{(() => {
|
|
const hasWildcardModels = (config.allowed_models || []).includes("*");
|
|
return (
|
|
<ModelMultiselect
|
|
data-testid={`vk-models-multiselect-${index}`}
|
|
provider={config.provider}
|
|
keys={(() => {
|
|
const providerKeys = availableKeys.filter((key) => key.provider === config.provider);
|
|
const configKeyIds = config.key_ids || [];
|
|
return configKeyIds.includes("*")
|
|
? providerKeys.map((key) => key.key_id)
|
|
: providerKeys.filter((key) => configKeyIds.includes(key.key_id)).map((key) => key.key_id);
|
|
})()}
|
|
allowAllOption={true}
|
|
value={hasWildcardModels ? ["*"] : config.allowed_models || []}
|
|
onChange={(models: string[]) => {
|
|
const hadStar = (config.allowed_models || []).includes("*");
|
|
const hasStar = models.includes("*");
|
|
if (!hadStar && hasStar) {
|
|
handleUpdateProviderConfig(index, "allowed_models", ["*"]);
|
|
} else if (hadStar && hasStar && models.length > 1) {
|
|
handleUpdateProviderConfig(
|
|
index,
|
|
"allowed_models",
|
|
models.filter((m) => m !== "*"),
|
|
);
|
|
} else {
|
|
handleUpdateProviderConfig(index, "allowed_models", models);
|
|
}
|
|
}}
|
|
placeholder={
|
|
hasWildcardModels
|
|
? "All models allowed"
|
|
: (config.allowed_models || []).length === 0
|
|
? "No models (deny all)"
|
|
: config.provider
|
|
? ModelPlaceholders[config.provider as keyof typeof ModelPlaceholders] ||
|
|
ModelPlaceholders.default
|
|
: ModelPlaceholders.default
|
|
}
|
|
className="min-h-10 max-w-[500px] min-w-[200px]"
|
|
/>
|
|
);
|
|
})()}
|
|
<p className="text-muted-foreground text-xs">
|
|
Select specific models or choose “Allow All Models” to allow all. Leave empty to deny all.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Allowed Keys for this provider */}
|
|
{(() => {
|
|
const providerKeys = availableKeys.filter((key) => key.provider === config.provider);
|
|
const configKeyIds = config.key_ids || [];
|
|
const hasWildcard = configKeyIds.includes("*");
|
|
const allKeyOptions = [
|
|
{
|
|
label: "Allow All Keys",
|
|
value: "*",
|
|
description: "Allow all current and future keys for this provider",
|
|
provider: "",
|
|
},
|
|
...providerKeys.map((key) => ({
|
|
label: key.name,
|
|
value: key.key_id,
|
|
description:
|
|
key.models == null || key.models.includes("*")
|
|
? "All models"
|
|
: key.models.filter((m) => m !== "*").join(", ") || "No models (deny all)",
|
|
provider: key.provider,
|
|
})),
|
|
];
|
|
const selectedProviderKeys = hasWildcard
|
|
? [allKeyOptions[0]]
|
|
: providerKeys
|
|
.filter((key) => configKeyIds.includes(key.key_id))
|
|
.map((key) => ({
|
|
label: key.name,
|
|
value: key.key_id,
|
|
description:
|
|
key.models == null || key.models.includes("*")
|
|
? "All models"
|
|
: key.models.filter((m) => m !== "*").join(", ") || "No models (deny all)",
|
|
provider: key.provider,
|
|
}));
|
|
|
|
return (
|
|
<div className="mx-0.5 space-y-2">
|
|
<Label className="text-sm font-medium">Allowed Keys</Label>
|
|
<p className="text-muted-foreground text-xs">
|
|
Select specific keys or allow all. Leave empty to block all keys for this provider.
|
|
</p>
|
|
<AsyncMultiSelect
|
|
hideSelectedOptions
|
|
isNonAsync
|
|
closeMenuOnSelect={false}
|
|
menuPlacement="auto"
|
|
defaultOptions={allKeyOptions}
|
|
views={{
|
|
multiValue: (multiValueProps: MultiValueProps<VirtualKeyType>) => {
|
|
return (
|
|
<div
|
|
{...multiValueProps.innerProps}
|
|
className="bg-accent dark:!bg-card flex cursor-pointer items-center gap-1 rounded-sm px-1 py-0.5 text-sm"
|
|
>
|
|
{multiValueProps.data.label}{" "}
|
|
<X
|
|
className="hover:text-foreground text-muted-foreground h-4 w-4 cursor-pointer"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
multiValueProps.removeProps.onClick?.(e as any);
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
},
|
|
option: (optionProps: OptionProps<VirtualKeyType>) => {
|
|
const { Option } = components;
|
|
return (
|
|
<Option
|
|
{...optionProps}
|
|
className={cn(
|
|
"flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-2 text-sm",
|
|
optionProps.isFocused && "bg-accent dark:!bg-card",
|
|
"hover:bg-accent",
|
|
optionProps.isSelected && "bg-accent dark:!bg-card",
|
|
)}
|
|
>
|
|
<span className="text-content-primary grow truncate text-sm">{optionProps.data.label}</span>
|
|
{optionProps.data.description && (
|
|
<span className="text-content-tertiary max-w-[70%] text-sm">
|
|
{optionProps.data.description}
|
|
</span>
|
|
)}
|
|
</Option>
|
|
);
|
|
},
|
|
}}
|
|
value={selectedProviderKeys}
|
|
onChange={(keys) => {
|
|
const hadStar = hasWildcard;
|
|
const hasStar = keys.some((k) => k.value === "*");
|
|
if (!hadStar && hasStar) {
|
|
// Just selected "Allow All Keys" — set to ["*"] only
|
|
handleUpdateProviderConfig(index, "key_ids", ["*"]);
|
|
} else if (hadStar && hasStar && keys.length > 1) {
|
|
// Had "*", still has "*", but user also selected a specific key — drop "*"
|
|
handleUpdateProviderConfig(
|
|
index,
|
|
"key_ids",
|
|
keys.filter((k) => k.value !== "*").map((k) => k.value as string),
|
|
);
|
|
} else {
|
|
handleUpdateProviderConfig(
|
|
index,
|
|
"key_ids",
|
|
keys.map((k) => k.value as string),
|
|
);
|
|
}
|
|
}}
|
|
placeholder={
|
|
hasWildcard ? "All keys allowed" : configKeyIds.length === 0 ? "No keys selected" : "Select keys..."
|
|
}
|
|
className="hover:bg-accent w-full"
|
|
menuClassName="z-[60] max-h-[300px] overflow-y-auto w-full cursor-pointer custom-scrollbar"
|
|
/>
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
<DottedSeparator />
|
|
|
|
{/* Provider Budget Configuration */}
|
|
<MultiBudgetLines
|
|
id={`providerBudget-${index}`}
|
|
data-testid={`vk-provider-budget-${index}`}
|
|
label="Provider Budget"
|
|
lines={
|
|
config.budgets && config.budgets.length > 0
|
|
? config.budgets.map((b) => ({
|
|
max_limit: b.max_limit,
|
|
reset_duration: b.reset_duration || "1M",
|
|
}))
|
|
: []
|
|
}
|
|
onChange={(lines) => {
|
|
const updatedConfigs = [...providerConfigs];
|
|
updatedConfigs[index] = {
|
|
...updatedConfigs[index],
|
|
budgets: lines.map((l) => ({
|
|
max_limit: l.max_limit,
|
|
reset_duration: l.reset_duration,
|
|
})),
|
|
};
|
|
form.setValue("providerConfigs", updatedConfigs, { shouldDirty: true });
|
|
}}
|
|
/>
|
|
|
|
<DottedSeparator />
|
|
|
|
{/* Provider Rate Limit Configuration */}
|
|
<div className="space-y-4">
|
|
<Label className="text-sm font-medium">Provider Rate Limits</Label>
|
|
|
|
<NumberAndSelect
|
|
id={`providerTokenLimit-${index}`}
|
|
labelClassName="font-normal"
|
|
label="Maximum Tokens"
|
|
value={config.rate_limit?.token_max_limit}
|
|
selectValue={config.rate_limit?.token_reset_duration || "1h"}
|
|
onChangeNumber={(value) => {
|
|
const currentRateLimit = config.rate_limit || {};
|
|
handleUpdateProviderConfig(index, "rate_limit", {
|
|
...currentRateLimit,
|
|
token_max_limit: value,
|
|
});
|
|
}}
|
|
onChangeSelect={(value) => {
|
|
const currentRateLimit = config.rate_limit || {};
|
|
handleUpdateProviderConfig(index, "rate_limit", {
|
|
...currentRateLimit,
|
|
token_reset_duration: value,
|
|
});
|
|
}}
|
|
options={resetDurationOptions}
|
|
/>
|
|
|
|
<NumberAndSelect
|
|
id={`providerRequestLimit-${index}`}
|
|
labelClassName="font-normal"
|
|
label="Maximum Requests"
|
|
value={config.rate_limit?.request_max_limit}
|
|
selectValue={config.rate_limit?.request_reset_duration || "1h"}
|
|
onChangeNumber={(value) => {
|
|
const currentRateLimit = config.rate_limit || {};
|
|
handleUpdateProviderConfig(index, "rate_limit", {
|
|
...currentRateLimit,
|
|
request_max_limit: value,
|
|
});
|
|
}}
|
|
onChangeSelect={(value) => {
|
|
const currentRateLimit = config.rate_limit || {};
|
|
handleUpdateProviderConfig(index, "rate_limit", {
|
|
...currentRateLimit,
|
|
request_reset_duration: value,
|
|
});
|
|
}}
|
|
options={resetDurationOptions}
|
|
/>
|
|
</div>
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
);
|
|
})}
|
|
</Accordion>
|
|
</div>
|
|
)}
|
|
{/* Display validation errors for provider configurations */}
|
|
{form.formState.errors.providerConfigs && (
|
|
<div className="text-destructive text-sm">{form.formState.errors.providerConfigs.message}</div>
|
|
)}
|
|
</div>
|
|
{/* MCP Client Configurations */}
|
|
{((mcpClientsData && mcpClientsData.length > 0) || (mcpConfigs && mcpConfigs.length > 0)) && (
|
|
<div className="mt-6 space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<Label className="text-sm font-medium">MCP Client Configurations</Label>
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span>
|
|
<Info className="text-muted-foreground h-3 w-3" />
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>
|
|
Configure which MCP clients this virtual key can use and their allowed tools. Leaving this section empty blocks
|
|
all MCP tools. After adding an MCP client, you must select specific tools or choose{" "}
|
|
<span className="font-medium">Allow All Tools</span> to grant tool access.
|
|
</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</div>
|
|
|
|
{/* MCP servers available on all virtual keys by default, excluding explicitly overridden ones */}
|
|
{(() => {
|
|
const defaultMCPClients = mcpClientsData.filter(
|
|
(client) =>
|
|
client.config.allow_on_all_virtual_keys &&
|
|
!mcpConfigs.some((config) => config.mcp_client_name === client.config.name),
|
|
);
|
|
return defaultMCPClients.length > 0 ? (
|
|
<div className="text-muted-foreground rounded-md border p-3 text-xs">
|
|
<div className="flex items-start gap-1.5">
|
|
<Info className="mt-0.5 h-3 w-3 shrink-0" />
|
|
<span>
|
|
The following MCP servers are available to this key by default with all tools enabled on that client:{" "}
|
|
<span className="text-foreground font-medium">{defaultMCPClients.map((c) => c.config.name).join(", ")}</span>.
|
|
Adding an explicit config for any of them below will override the all-tools default for this key.
|
|
</span>
|
|
</div>
|
|
</div>
|
|
) : null;
|
|
})()}
|
|
|
|
{/* Add MCP Client Dropdown */}
|
|
{mcpClientsData && mcpClientsData.length > 0 && (
|
|
<div className="flex gap-2">
|
|
<Select
|
|
value={selectedMCPClient}
|
|
onValueChange={(mcpClientId) => {
|
|
handleAddMCPClient(mcpClientId);
|
|
setSelectedMCPClient(""); // Reset to placeholder state
|
|
}}
|
|
>
|
|
<SelectTrigger className="flex-1">
|
|
<SelectValue placeholder="Select an MCP client to add" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{mcpClientsData.filter((client) => !mcpConfigs.some((config) => config.mcp_client_name === client.config.name))
|
|
.length > 0 ? (
|
|
mcpClientsData
|
|
.filter(
|
|
(client) =>
|
|
client.config.name && !mcpConfigs.some((config) => config.mcp_client_name === client.config.name),
|
|
)
|
|
.map((client, index) => {
|
|
const client_tools = client.tools || [];
|
|
const totalTools = client.config.tools_to_execute?.includes("*")
|
|
? client_tools.length
|
|
: client_tools.filter((tool) => client.config.tools_to_execute?.includes(tool.name)).length;
|
|
return (
|
|
<SelectItem key={index} value={client.config.name}>
|
|
<div className="flex items-center gap-2">
|
|
{client.config.name}
|
|
<span className="text-muted-foreground text-xs">
|
|
({totalTools} {totalTools === 1 ? "enabled tool" : "enabled tools"})
|
|
</span>
|
|
</div>
|
|
</SelectItem>
|
|
);
|
|
})
|
|
) : (
|
|
<div className="text-muted-foreground px-2 py-1.5 text-sm">All MCP clients configured</div>
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
|
|
{/* MCP Configurations Table */}
|
|
{mcpConfigs.length > 0 && (
|
|
<div className="rounded-md border">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>MCP Client</TableHead>
|
|
<TableHead>Allowed Tools</TableHead>
|
|
<TableHead className="w-[50px]"></TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{mcpConfigs.map((config, index) => {
|
|
const mcpClient = mcpClientsData?.find((client) => client.config.name === config.mcp_client_name);
|
|
|
|
// Handle new wildcard semantics for client-level filtering
|
|
const clientToolsToExecute = mcpClient?.config?.tools_to_execute;
|
|
let availableTools: any[] = [];
|
|
|
|
if (!clientToolsToExecute || clientToolsToExecute.length === 0) {
|
|
// nil/undefined or empty array - no tools available from client config
|
|
availableTools = [];
|
|
} else if (clientToolsToExecute.includes("*")) {
|
|
// Wildcard - all tools available
|
|
availableTools = mcpClient?.tools || [];
|
|
} else {
|
|
// Specific tools listed
|
|
availableTools = (mcpClient?.tools || []).filter((tool) => clientToolsToExecute.includes(tool.name)) || [];
|
|
}
|
|
|
|
const enabledToolsByConfig =
|
|
(mcpClient?.tools || []).filter((tool) => config.tools_to_execute?.includes(tool.name)) || [];
|
|
const selectedTools = config.tools_to_execute || [];
|
|
|
|
return (
|
|
<TableRow key={`${config.mcp_client_name}-${index}`}>
|
|
<TableCell className="w-[150px]">{config.mcp_client_name}</TableCell>
|
|
<TableCell>
|
|
<MultiSelect
|
|
options={[
|
|
{
|
|
label: "Allow All Tools",
|
|
value: "*",
|
|
description: "Allow all current and future tools",
|
|
},
|
|
...[...availableTools, ...enabledToolsByConfig]
|
|
.filter((tool, index, arr) => arr.findIndex((t) => t.name === tool.name) === index)
|
|
.map((tool) => ({
|
|
label: tool.name,
|
|
value: tool.name,
|
|
description: tool.description,
|
|
})),
|
|
]}
|
|
defaultValue={selectedTools}
|
|
onValueChange={(tools: string[]) => {
|
|
const hadStar = selectedTools.includes("*");
|
|
const hasStar = tools.includes("*");
|
|
if (!hadStar && hasStar) {
|
|
// Just selected "Allow All Tools" — set to ["*"] only
|
|
handleUpdateMCPConfig(index, "tools_to_execute", ["*"]);
|
|
} else if (hadStar && hasStar && tools.length > 1) {
|
|
// Had "*", still has "*", but user also selected a specific tool — drop "*"
|
|
handleUpdateMCPConfig(
|
|
index,
|
|
"tools_to_execute",
|
|
tools.filter((t) => t !== "*"),
|
|
);
|
|
} else {
|
|
handleUpdateMCPConfig(index, "tools_to_execute", tools);
|
|
}
|
|
}}
|
|
placeholder={
|
|
selectedTools.length === 0
|
|
? "No tools selected"
|
|
: selectedTools.includes("*")
|
|
? "All tools allowed"
|
|
: "Select tools..."
|
|
}
|
|
variant="inverted"
|
|
className="hover:bg-accent w-full bg-white dark:bg-zinc-800"
|
|
commandClassName="w-full max-w-96"
|
|
modalPopover={true}
|
|
animation={0}
|
|
/>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleRemoveMCPClient(index)}
|
|
data-testid={`vk-delete-mcp-${index}`}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
<DottedSeparator className="mt-6 mb-5" />
|
|
{/* Budget Configuration */}
|
|
<div className="space-y-4">
|
|
<MultiBudgetLines
|
|
id="vkBudget"
|
|
data-testid="vk-budget-lines"
|
|
label="Budget Configuration"
|
|
lines={form.watch("budgets") ?? []}
|
|
onChange={(lines) => {
|
|
form.setValue("budgets", lines, { shouldDirty: true });
|
|
}}
|
|
onReset={clearVirtualKeyBudget}
|
|
showReset={isEditing && !!(virtualKey?.budgets?.length || (watchedBudgets && watchedBudgets.length > 0))}
|
|
/>
|
|
|
|
{/* Calendar alignment toggle — shown when any budget supports alignment */}
|
|
{hasAnyAlignableBudget && (
|
|
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2">
|
|
<div className="space-y-0.5">
|
|
<Label htmlFor="vk-budget-calendar-aligned-toggle" className="text-sm font-normal">
|
|
Align to calendar cycle
|
|
</Label>
|
|
<p id="vk-budget-calendar-aligned-description" className="text-muted-foreground text-xs">
|
|
Reset at the start of each period (e.g. 1st of month) instead of rolling from creation date
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
id="vk-budget-calendar-aligned-toggle"
|
|
aria-describedby="vk-budget-calendar-aligned-description"
|
|
checked={watchedBudgetCalendarAligned}
|
|
onCheckedChange={handleCalendarAlignedChange}
|
|
data-testid="vk-budget-calendar-aligned-toggle"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Warning dialog shown when enabling calendar alignment on an existing budget */}
|
|
<AlertDialog open={showCalendarAlignWarning} onOpenChange={setShowCalendarAlignWarning}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Reset budget usage?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
Enabling calendar alignment will reset all budget usage for this virtual key to{" "}
|
|
<span className="font-semibold">$0.00</span> and snap each budget's reset date to the start of its current
|
|
period (e.g. start of day, week, month, or year). The usage reset to $0.00 cannot be undone, but calendar alignment
|
|
can be turned off later. This will take effect when you save.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel data-testid="vk-calendar-align-cancel-btn">Cancel</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
data-testid="vk-calendar-align-enable-btn"
|
|
onClick={() => {
|
|
form.setValue("budgetCalendarAligned", true, { shouldDirty: true });
|
|
setShowCalendarAlignWarning(false);
|
|
}}
|
|
>
|
|
Enable Calendar Alignment
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
{/* Rate Limiting Configuration */}
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<Label className="text-sm font-medium">Rate Limiting Configuration</Label>
|
|
{isEditing && (virtualKey?.rate_limit || watchedTokenMaxLimit || watchedRequestMaxLimit) && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={clearVirtualKeyRateLimits}
|
|
data-testid="vk-rate-limit-reset-button"
|
|
>
|
|
<RotateCcw className="h-4 w-4" />
|
|
Reset
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="tokenMaxLimit"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<NumberAndSelect
|
|
id="tokenMaxLimit"
|
|
labelClassName="font-normal"
|
|
label="Maximum Tokens"
|
|
value={field.value}
|
|
selectValue={form.watch("tokenResetDuration") || "1h"}
|
|
onChangeNumber={(value) => {
|
|
field.onChange(value);
|
|
}}
|
|
onChangeSelect={(value) => form.setValue("tokenResetDuration", value, { shouldDirty: true })}
|
|
options={resetDurationOptions}
|
|
/>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="requestMaxLimit"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<NumberAndSelect
|
|
id="requestMaxLimit"
|
|
labelClassName="font-normal"
|
|
label="Maximum Requests"
|
|
value={field.value}
|
|
selectValue={form.watch("requestResetDuration") || "1h"}
|
|
onChangeNumber={(value) => {
|
|
field.onChange(value);
|
|
}}
|
|
onChangeSelect={(value) => form.setValue("requestResetDuration", value, { shouldDirty: true })}
|
|
options={resetDurationOptions}
|
|
/>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
{(teams?.length > 0 || customers?.length > 0) && (
|
|
<>
|
|
<DottedSeparator className="my-6" />
|
|
|
|
{/* Entity Assignment */}
|
|
<div className="space-y-4">
|
|
<Label className="text-sm font-medium">Entity Assignment</Label>
|
|
|
|
<div className="grid grid-cols-1 items-center gap-2 md:grid-cols-2">
|
|
<FormField
|
|
control={form.control}
|
|
name="entityType"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel className="font-normal">Assignment Type</FormLabel>
|
|
<Select
|
|
onValueChange={async (value) => {
|
|
field.onChange(value);
|
|
// Auto-select first entry when switching to team or customer
|
|
if (value === "team" && teams && teams.length > 0) {
|
|
form.setValue("teamId", teams[0].id, { shouldDirty: true, shouldValidate: true });
|
|
form.setValue("customerId", "", { shouldDirty: true, shouldValidate: true });
|
|
// Trigger validation after state updates
|
|
await form.trigger(["teamId", "customerId", "entityType"]);
|
|
} else if (value === "customer" && customers && customers.length > 0) {
|
|
form.setValue("customerId", customers[0].id, { shouldDirty: true, shouldValidate: true });
|
|
form.setValue("teamId", "", { shouldDirty: true, shouldValidate: true });
|
|
// Trigger validation after state updates
|
|
await form.trigger(["teamId", "customerId", "entityType"]);
|
|
} else if (value === "none") {
|
|
form.setValue("teamId", "", { shouldDirty: true, shouldValidate: true });
|
|
form.setValue("customerId", "", { shouldDirty: true, shouldValidate: true });
|
|
// Trigger validation after state updates
|
|
await form.trigger(["teamId", "customerId", "entityType"]);
|
|
}
|
|
}}
|
|
defaultValue={field.value}
|
|
disabled={isTeamLocked}
|
|
>
|
|
<FormControl className="w-full">
|
|
<SelectTrigger data-testid="vk-entity-type-select">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
<SelectContent>
|
|
<SelectItem value="none">No Assignment</SelectItem>
|
|
{teams?.length > 0 && <SelectItem value="team">Assign to Team</SelectItem>}
|
|
{customers?.length > 0 && <SelectItem value="customer">Assign to Customer</SelectItem>}
|
|
</SelectContent>
|
|
</Select>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
{form.watch("entityType") === "team" && teams?.length > 0 && (
|
|
<FormField
|
|
control={form.control}
|
|
name="teamId"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel className="font-normal">Select Team</FormLabel>
|
|
<Select onValueChange={field.onChange} defaultValue={field.value} disabled={isTeamLocked}>
|
|
<FormControl className="w-full">
|
|
<SelectTrigger data-testid="vk-team-select">
|
|
<SelectValue placeholder="Select a team" />
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
<SelectContent>
|
|
{teams.map((team) => (
|
|
<SelectItem key={team.id} value={team.id}>
|
|
<div className="flex items-center gap-2">
|
|
<Users className="h-4 w-4" />
|
|
{team.name}
|
|
{team.customer && (
|
|
<span className="text-muted-foreground flex items-center gap-1">
|
|
<Building className="h-2 w-2" />
|
|
{team.customer.name}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
)}
|
|
|
|
{form.watch("entityType") === "customer" && customers?.length > 0 && (
|
|
<FormField
|
|
control={form.control}
|
|
name="customerId"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel className="font-normal">Select Customer</FormLabel>
|
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
|
<FormControl className="w-full">
|
|
<SelectTrigger data-testid="vk-customer-select">
|
|
<SelectValue placeholder="Select a customer" />
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
<SelectContent>
|
|
{customers.map((customer) => (
|
|
<SelectItem key={customer.id} value={customer.id}>
|
|
<div className="flex items-center gap-2">
|
|
<Building className="h-4 w-4" />
|
|
{customer.name}
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</fieldset>
|
|
</div>
|
|
{isEditing && virtualKey?.config_hash && <ConfigSyncAlert className="mt-2" />}
|
|
{/* Form Footer */}
|
|
<div className="dark:bg-card border-border bg-white py-6">
|
|
<div className="flex justify-end gap-2">
|
|
<Button type="button" variant="outline" onClick={handleClose} data-testid="vk-cancel-btn">
|
|
Cancel
|
|
</Button>
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span className="inline-block">
|
|
<Button
|
|
type="submit"
|
|
disabled={isLoading || !form.formState.isDirty || !form.formState.isValid || !canSubmit}
|
|
data-testid="vk-save-btn"
|
|
>
|
|
{isLoading ? "Saving..." : isEditing ? "Update" : "Create"}
|
|
</Button>
|
|
</span>
|
|
</TooltipTrigger>
|
|
{(isLoading || !form.formState.isDirty || !form.formState.isValid || !canSubmit) && (
|
|
<TooltipContent>
|
|
<p>
|
|
{!canSubmit
|
|
? "You don't have permission to perform this action"
|
|
: isLoading
|
|
? "Saving..."
|
|
: !form.formState.isDirty && !form.formState.isValid
|
|
? "No changes made and validation errors present"
|
|
: !form.formState.isDirty
|
|
? "No changes made"
|
|
: "Please fix validation errors"}
|
|
</p>
|
|
</TooltipContent>
|
|
)}
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</Form>
|
|
</SheetContent>
|
|
</Sheet>
|
|
);
|
|
} |