/** * Routing Rule Dialog (Sheet) * Create/Edit form for routing rules */ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ModelMultiselect } from "@/components/ui/modelMultiselect"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { ProviderIconType, RenderProviderIcon } from "@/lib/constants/icons"; import { getProviderLabel } from "@/lib/constants/logs"; import { getErrorMessage } from "@/lib/store"; import { useGetCustomersQuery, useGetTeamsQuery, useGetVirtualKeysQuery } from "@/lib/store/apis/governanceApi"; import { useGetAllKeysQuery, useGetProvidersQuery } from "@/lib/store/apis/providersApi"; import { useCreateRoutingRuleMutation, useGetRoutingRulesQuery, useUpdateRoutingRuleMutation } from "@/lib/store/apis/routingRulesApi"; import { DEFAULT_ROUTING_RULE_FORM_DATA, DEFAULT_ROUTING_TARGET, ROUTING_RULE_SCOPES, RoutingRule, RoutingRuleFormData, RoutingTargetFormData, } from "@/lib/types/routingRules"; import { validateRateLimitAndBudgetRules, validateRoutingRules } from "@/lib/utils/celConverterRouting"; import { Plus, Save, Trash2, X } from "lucide-react"; import { lazy, Suspense, useCallback, useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { RuleGroupType } from "react-querybuilder"; import { toast } from "sonner"; interface RoutingRuleDialogProps { open: boolean; onOpenChange: (open: boolean) => void; editingRule?: RoutingRule | null; onSuccess?: () => void; } const defaultQuery: RuleGroupType = { combinator: "and", rules: [], }; // Lazy-load CEL builder (heavy dependency tree). const CELRuleBuilderLazy = lazy(() => import("@/app/workspace/routing-rules/components/celBuilder/celRuleBuilder").then((mod) => ({ default: mod.CELRuleBuilder, })), ); const CELRuleBuilder = (props: React.ComponentProps) => ( Loading CEL builder...}> ); export function RoutingRuleSheet({ open, onOpenChange, editingRule, onSuccess }: RoutingRuleDialogProps) { const { data: rulesData } = useGetRoutingRulesQuery(); const rules = rulesData?.rules || []; const { data: providersData = [] } = useGetProvidersQuery(); const { data: allKeysData = [] } = useGetAllKeysQuery(); const { data: vksData = { virtual_keys: [] } } = useGetVirtualKeysQuery(); const { data: teamsData = { teams: [], count: 0, total_count: 0, limit: 0, offset: 0 } } = useGetTeamsQuery(); const { data: customersData = { customers: [] } } = useGetCustomersQuery(); const [createRoutingRule, { isLoading: isCreating }] = useCreateRoutingRuleMutation(); const [updateRoutingRule, { isLoading: isUpdating }] = useUpdateRoutingRuleMutation(); // State for targets and query (managed outside react-hook-form for complex nested structures) const [targets, setTargets] = useState([{ ...DEFAULT_ROUTING_TARGET }]); const [query, setQuery] = useState(defaultQuery); const [builderKey, setBuilderKey] = useState(0); const { register, handleSubmit, setValue, watch, reset, formState: { errors }, } = useForm({ defaultValues: DEFAULT_ROUTING_RULE_FORM_DATA, }); const isEditing = !!editingRule; const isLoading = isCreating || isUpdating; const enabled = watch("enabled"); const chainRule = watch("chain_rule"); const scope = watch("scope"); const scopeId = watch("scope_id"); const fallbacks = watch("fallbacks"); // Get available providers from configured providers, plus any provider already // referenced by the current targets, existing rules' targets, or rules' fallbacks // so edited/removed providers are still visible in the dropdown. const availableProviders = Array.from( new Set([ ...providersData.map((p) => p.name), ...(targets.map((t) => t.provider).filter(Boolean) as string[]), ...(rules.flatMap((r) => r.targets?.map((t) => t.provider).filter(Boolean) ?? []) as string[]), ...rules.flatMap((r) => (r.fallbacks ?? []).map((f) => f.split("/")[0]?.trim()).filter(Boolean)), ]), ); // Initialize form data when editing rule changes useEffect(() => { if (editingRule) { setValue("id", editingRule.id); setValue("name", editingRule.name); setValue("description", editingRule.description); setValue("cel_expression", editingRule.cel_expression); setValue("fallbacks", editingRule.fallbacks || []); setValue("scope", editingRule.scope); setValue("scope_id", editingRule.scope_id || ""); setValue("priority", editingRule.priority); setValue("enabled", editingRule.enabled); setValue("chain_rule", editingRule.chain_rule ?? false); if (editingRule.targets && editingRule.targets.length > 0) { setTargets( editingRule.targets.map((t) => ({ ...DEFAULT_ROUTING_TARGET, provider: t.provider || "", model: t.model || "", key_id: t.key_id || "", weight: t.weight, })), ); } else { setTargets([{ ...DEFAULT_ROUTING_TARGET }]); } // Restore the query object if it exists, otherwise use default if (editingRule.query) { setQuery(editingRule.query); } else { setQuery(defaultQuery); } setBuilderKey((prev) => prev + 1); } else { reset(); setTargets([{ ...DEFAULT_ROUTING_TARGET }]); setQuery(defaultQuery); setBuilderKey((prev) => prev + 1); } }, [editingRule, open, setValue, reset]); const handleQueryChange = useCallback( (expression: string, newQuery: RuleGroupType) => { setValue("cel_expression", expression); setQuery(newQuery); }, [setValue], ); const addTarget = () => { const remaining = 1 - targets.reduce((sum, t) => sum + (t.weight || 0), 0); setTargets((prev) => [...prev, { ...DEFAULT_ROUTING_TARGET, weight: Math.max(0, parseFloat(remaining.toFixed(4))) }]); }; const removeTarget = (index: number) => { setTargets((prev) => prev.filter((_, i) => i !== index)); }; const updateTarget = (index: number, field: keyof RoutingTargetFormData, value: string | number) => { setTargets((prev) => prev.map((t, i) => (i === index ? { ...t, [field]: value } : t))); }; const totalWeight = targets.reduce((sum, t) => sum + (t.weight || 0), 0); const onSubmit = (data: RoutingRuleFormData) => { // Validate scope_id is required when scope is not global if (data.scope !== "global" && !data.scope_id?.trim()) { toast.error(`${data.scope === "team" ? "Team" : data.scope === "customer" ? "Customer" : "Virtual Key"} is required`); return; } // Validate targets if (targets.length === 0) { toast.error("At least one routing target is required"); return; } for (const t of targets) { if (t.weight <= 0) { toast.error("Each target weight must be greater than 0"); return; } } if (Math.abs(totalWeight - 1) > 0.001) { toast.error(`Target weights must sum to 1, current total: ${totalWeight.toFixed(4)}`); return; } // Validate regex patterns in routing rules const regexErrors = validateRoutingRules(query); if (regexErrors.length > 0) { toast.error(`Invalid regex pattern:\n${regexErrors.join("\n")}`); return; } // Validate rate limit and budget rules const rateLimitErrors = validateRateLimitAndBudgetRules(query); if (rateLimitErrors.length > 0) { toast.error(`Invalid rule configuration:\n${rateLimitErrors.join("\n")}`); return; } // Filter out incomplete fallbacks (empty provider) const validFallbacks = (data.fallbacks || []).filter((fb) => { const provider = fb.split("/")[0]?.trim(); return provider && provider.length > 0; }); const payload = { name: data.name, description: data.description, cel_expression: data.cel_expression, targets: targets.map(({ provider, model, key_id, weight }) => ({ provider: provider || undefined, model: model || undefined, key_id: key_id || undefined, weight, })), fallbacks: validFallbacks, scope: data.scope, scope_id: data.scope === "global" ? undefined : data.scope_id || undefined, priority: data.priority, enabled: data.enabled, chain_rule: data.chain_rule, query: query, }; const submitPromise = isEditing && editingRule ? updateRoutingRule({ id: editingRule.id, data: payload, }).unwrap() : createRoutingRule(payload).unwrap(); submitPromise .then(() => { toast.success(isEditing ? "Routing rule updated successfully" : "Routing rule created successfully"); reset(); setTargets([{ ...DEFAULT_ROUTING_TARGET }]); setQuery(defaultQuery); setBuilderKey((prev) => prev + 1); onOpenChange(false); onSuccess?.(); }) .catch((error: any) => { toast.error(getErrorMessage(error)); }); }; const handleCancel = () => { reset(); setTargets([{ ...DEFAULT_ROUTING_TARGET }]); setQuery(defaultQuery); setBuilderKey((prev) => prev + 1); onOpenChange(false); }; return ( {isEditing ? "Edit Routing Rule" : "Create New Routing Rule"} {isEditing ? "Update the routing rule configuration" : "Create a new CEL-based routing rule for intelligent request routing"}
{/* Rule Name */}
{errors.name &&

{errors.name.message}

}
{/* Description */}