794 lines
27 KiB
TypeScript
794 lines
27 KiB
TypeScript
/**
|
|
* 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<typeof CELRuleBuilderLazy>) => (
|
|
<Suspense fallback={<div className="text-sm text-gray-500">Loading CEL builder...</div>}>
|
|
<CELRuleBuilderLazy {...props} />
|
|
</Suspense>
|
|
);
|
|
|
|
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<RoutingTargetFormData[]>([{ ...DEFAULT_ROUTING_TARGET }]);
|
|
const [query, setQuery] = useState<RuleGroupType>(defaultQuery);
|
|
const [builderKey, setBuilderKey] = useState(0);
|
|
|
|
const {
|
|
register,
|
|
handleSubmit,
|
|
setValue,
|
|
watch,
|
|
reset,
|
|
formState: { errors },
|
|
} = useForm<RoutingRuleFormData>({
|
|
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 (
|
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
<SheetContent className="flex w-full min-w-1/2 flex-col gap-4 overflow-x-hidden p-8">
|
|
<SheetHeader className="flex flex-col items-start">
|
|
<SheetTitle>{isEditing ? "Edit Routing Rule" : "Create New Routing Rule"}</SheetTitle>
|
|
<SheetDescription>
|
|
{isEditing ? "Update the routing rule configuration" : "Create a new CEL-based routing rule for intelligent request routing"}
|
|
</SheetDescription>
|
|
</SheetHeader>
|
|
|
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
|
{/* Rule Name */}
|
|
<div className="space-y-3">
|
|
<Label htmlFor="name">
|
|
Rule Name <span className="text-red-500">*</span>
|
|
</Label>
|
|
<Input
|
|
id="name"
|
|
placeholder="e.g., Route GPT-4 to Azure"
|
|
{...register("name", { required: "Rule name is required", maxLength: 255 })}
|
|
/>
|
|
{errors.name && <p className="text-destructive text-sm">{errors.name.message}</p>}
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<div className="space-y-3">
|
|
<Label htmlFor="description">Description</Label>
|
|
<Textarea id="description" placeholder="Describe what this rule does..." rows={2} {...register("description")} />
|
|
</div>
|
|
|
|
{/* Enabled Switch */}
|
|
<div className="flex items-center justify-between rounded-lg border p-4">
|
|
<div className="space-y-0.5">
|
|
<Label htmlFor="enabled">Enable Rule</Label>
|
|
<p className="text-muted-foreground text-sm">Rule will be active and applied to matching requests</p>
|
|
</div>
|
|
<Switch id="enabled" checked={enabled} onCheckedChange={(checked) => setValue("enabled", checked)} />
|
|
</div>
|
|
|
|
{/* Chain Rule Switch */}
|
|
<div className="flex items-center justify-between rounded-lg border p-4">
|
|
<div className="space-y-0.5">
|
|
<Label htmlFor="chain_rule">Chain Rule</Label>
|
|
<p className="text-muted-foreground text-sm">
|
|
After this rule matches, re-evaluate routing rules using the resolved provider/model as the new context. Useful for
|
|
composing rules — e.g. normalize a model alias first, then route based on the canonical name.
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
id="chain_rule"
|
|
checked={chainRule}
|
|
onCheckedChange={(checked) => setValue("chain_rule", checked)}
|
|
data-testid="routing-rule-chain-rule-switch"
|
|
/>
|
|
</div>
|
|
|
|
{/* Scope and Priority - Side by Side */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-3">
|
|
<Label htmlFor="scope">Scope</Label>
|
|
<Select
|
|
value={scope}
|
|
onValueChange={(value) => {
|
|
setValue("scope", value as any);
|
|
// Clear scope_id when scope changes
|
|
setValue("scope_id", "");
|
|
}}
|
|
>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue placeholder="Select scope..." />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{ROUTING_RULE_SCOPES.map((scopeOption) => (
|
|
<SelectItem key={scopeOption.value} value={scopeOption.value}>
|
|
{scopeOption.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
<Label htmlFor="priority">
|
|
Priority <span className="text-red-500">*</span>
|
|
</Label>
|
|
<Input
|
|
id="priority"
|
|
type="number"
|
|
min={0}
|
|
max={1000}
|
|
{...register("priority", {
|
|
required: "Priority is required",
|
|
min: { value: 0, message: "Priority must be ≥ 0" },
|
|
max: { value: 1000, message: "Priority must be ≤ 1000" },
|
|
valueAsNumber: true,
|
|
})}
|
|
/>
|
|
<p className="text-muted-foreground text-xs">Lower numbers = higher priority (0 is highest)</p>
|
|
{errors.priority && <p className="text-destructive text-sm">{errors.priority.message}</p>}
|
|
</div>
|
|
</div>
|
|
|
|
{scope !== "global" && (
|
|
<div className="space-y-2">
|
|
<Label htmlFor="scope_id">
|
|
{scope === "team" ? "Team" : scope === "customer" ? "Customer" : "Virtual Key"} <span className="text-red-500">*</span>
|
|
</Label>
|
|
{scope === "team" && teamsData.teams.length > 0 && (
|
|
<Select value={scopeId || ""} onValueChange={(value) => setValue("scope_id", value)}>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue placeholder="Select a team..." />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{teamsData.teams.map((team) => (
|
|
<SelectItem key={team.id} value={team.id}>
|
|
{team.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
{scope === "customer" && customersData.customers.length > 0 && (
|
|
<Select value={scopeId || ""} onValueChange={(value) => setValue("scope_id", value)}>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue placeholder="Select a customer..." />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{customersData.customers.map((customer) => (
|
|
<SelectItem key={customer.id} value={customer.id}>
|
|
{customer.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
{scope === "virtual_key" && vksData.virtual_keys.length > 0 && (
|
|
<Select value={scopeId || ""} onValueChange={(value) => setValue("scope_id", value)}>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue placeholder="Select a virtual key..." />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{vksData.virtual_keys.map((vk) => (
|
|
<SelectItem key={vk.id} value={vk.id}>
|
|
{vk.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
{((scope === "team" && teamsData.teams.length === 0) ||
|
|
(scope === "customer" && customersData.customers.length === 0) ||
|
|
(scope === "virtual_key" && vksData.virtual_keys.length === 0)) && (
|
|
<p className="text-muted-foreground text-sm">
|
|
No {scope === "team" ? "teams" : scope === "customer" ? "customers" : "virtual keys"} available
|
|
</p>
|
|
)}
|
|
{errors.scope_id && <p className="text-destructive text-sm">{errors.scope_id.message}</p>}
|
|
</div>
|
|
)}
|
|
|
|
<Separator />
|
|
|
|
{/* CEL Rule Builder */}
|
|
<div className="space-y-3">
|
|
<Label>Rule Builder</Label>
|
|
<p className="text-muted-foreground text-sm">
|
|
Build conditions to determine when this rule should apply. Leave empty to apply this rule to all requests.
|
|
</p>
|
|
<CELRuleBuilder
|
|
key={builderKey}
|
|
initialQuery={query}
|
|
onChange={handleQueryChange}
|
|
providers={availableProviders}
|
|
models={[]}
|
|
allowCustomModels={true}
|
|
/>
|
|
</div>
|
|
|
|
{/* Note about Token/Request Limits and Budget Configuration */}
|
|
<p className="text-muted-foreground text-xs">
|
|
Note: Ensure token limits, request limits, and budget are configured in{" "}
|
|
<strong>Model Providers → Configurations → {"{provider}"} → Governance</strong> (provider-level) or{" "}
|
|
<strong>Model Providers → Budgets & Limits</strong> section (model-level) before using them in routing rules.
|
|
</p>
|
|
|
|
<Separator />
|
|
|
|
{/* Routing Targets */}
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<Label>Routing Targets</Label>
|
|
<p className="text-muted-foreground mt-0.5 text-xs">
|
|
Weights must sum to 1. Leave provider or model empty to use the incoming request value.
|
|
</p>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={addTarget}
|
|
className="shrink-0 gap-2"
|
|
data-testid="routing-rule-target-add"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
Add Target
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
{targets.map((target, index) => (
|
|
<TargetRow
|
|
key={index}
|
|
target={target}
|
|
index={index}
|
|
availableProviders={availableProviders}
|
|
allKeys={allKeysData}
|
|
showRemove={targets.length > 1}
|
|
onUpdate={updateTarget}
|
|
onRemove={removeTarget}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* Weight sum indicator */}
|
|
<div
|
|
className={`flex items-center justify-end gap-2 text-xs font-medium ${Math.abs(totalWeight - 1) > 0.001 ? "text-destructive" : "text-muted-foreground"}`}
|
|
>
|
|
Total weight: {totalWeight.toFixed(4)}
|
|
{Math.abs(totalWeight - 1) > 0.001 && <span className="text-destructive">(must equal 1)</span>}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Fallbacks */}
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<Label>Fallbacks</Label>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setValue("fallbacks", [...(fallbacks || []), ""])}
|
|
className="gap-2"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
Add Fallback
|
|
</Button>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{(fallbacks || []).length === 0 ? (
|
|
<p className="text-muted-foreground text-sm">No fallbacks configured</p>
|
|
) : (
|
|
(fallbacks || []).map((fallback, index) => {
|
|
// Parse provider/model from fallback string
|
|
const parts = fallback.split("/");
|
|
const fbProvider = parts[0] || "";
|
|
const fbModel = parts[1] || "";
|
|
|
|
const handleProviderChange = (newProvider: string) => {
|
|
const model = fbModel || "";
|
|
const newFallback = `${newProvider}/${model}`;
|
|
const newFallbacks = [...fallbacks];
|
|
newFallbacks[index] = newFallback;
|
|
setValue("fallbacks", newFallbacks);
|
|
};
|
|
|
|
const handleModelChange = (newModel: string) => {
|
|
const prov = fbProvider || "";
|
|
const newFallback = `${prov}/${newModel}`;
|
|
const newFallbacks = [...fallbacks];
|
|
newFallbacks[index] = newFallback;
|
|
setValue("fallbacks", newFallbacks);
|
|
};
|
|
|
|
const handleRemove = () => {
|
|
const newFallbacks = fallbacks.filter((_: string, i: number) => i !== index);
|
|
setValue("fallbacks", newFallbacks);
|
|
};
|
|
|
|
return (
|
|
<div key={index} className="flex items-center gap-2">
|
|
<div className="flex-1">
|
|
<Select value={fbProvider} onValueChange={handleProviderChange}>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue placeholder="Select provider..." />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{availableProviders.map((prov) => (
|
|
<SelectItem key={prov} value={prov}>
|
|
<div className="flex items-center gap-2">
|
|
<RenderProviderIcon provider={prov as ProviderIconType} size="sm" className="h-4 w-4" />
|
|
<span>{getProviderLabel(prov)}</span>
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="flex-1">
|
|
<ModelMultiselect
|
|
provider={fbProvider || undefined}
|
|
value={fbModel}
|
|
onChange={handleModelChange}
|
|
placeholder="Select model..."
|
|
isSingleSelect
|
|
disabled={!fbProvider}
|
|
className="!h-9 !min-h-9 w-full"
|
|
/>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleRemove}
|
|
className="h-9 px-2"
|
|
aria-label={`Remove fallback ${index + 1}`}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
<p className="text-muted-foreground text-xs">Fallbacks will be used in the order they are defined</p>
|
|
</div>
|
|
|
|
{/* Action Buttons */}
|
|
<div className="flex justify-end gap-3">
|
|
<Button type="button" variant="outline" onClick={handleCancel} disabled={isLoading}>
|
|
<X className="h-4 w-4" />
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit" disabled={isLoading}>
|
|
<Save className="h-4 w-4" />
|
|
{isEditing ? "Update Rule" : "Save Rule"}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</SheetContent>
|
|
</Sheet>
|
|
);
|
|
}
|
|
|
|
interface TargetRowProps {
|
|
target: RoutingTargetFormData;
|
|
index: number;
|
|
availableProviders: string[];
|
|
allKeys: Array<{ key_id: string; name: string; provider: string }>;
|
|
showRemove: boolean;
|
|
onUpdate: (index: number, field: keyof RoutingTargetFormData, value: string | number) => void;
|
|
onRemove: (index: number) => void;
|
|
}
|
|
|
|
function TargetRow({ target, index, availableProviders, allKeys, showRemove, onUpdate, onRemove }: TargetRowProps) {
|
|
const availableKeys = target.provider
|
|
? allKeys.filter((k) => k.provider === target.provider).map((k) => ({ id: k.key_id, name: k.name }))
|
|
: [];
|
|
|
|
return (
|
|
<div className="space-y-3 rounded-lg border p-3" data-testid={`routing-target-${index}`}>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-muted-foreground text-sm font-medium">Target {index + 1}</span>
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex items-center gap-1.5">
|
|
<Label htmlFor={`routing-target-${index}-weight-input`} className="text-muted-foreground shrink-0 text-xs">
|
|
Weight
|
|
</Label>
|
|
<Input
|
|
id={`routing-target-${index}-weight-input`}
|
|
type="number"
|
|
min={0.001}
|
|
max={1}
|
|
step={0.001}
|
|
value={target.weight}
|
|
onChange={(e) => onUpdate(index, "weight", parseFloat(e.target.value) || 0)}
|
|
className="h-8 w-24 text-sm"
|
|
data-testid={`routing-target-${index}-weight-input`}
|
|
/>
|
|
</div>
|
|
{showRemove && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => onRemove(index)}
|
|
className="h-8 w-8 p-0"
|
|
aria-label={`Remove target ${index + 1}`}
|
|
data-testid={`routing-target-${index}-remove-button`}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1.5">
|
|
<Label id={`routing-target-${index}-provider-label`} className="text-xs">
|
|
Provider
|
|
</Label>
|
|
<div className="flex gap-1.5">
|
|
<Select
|
|
value={target.provider}
|
|
onValueChange={(value) => {
|
|
onUpdate(index, "provider", value);
|
|
onUpdate(index, "model", "");
|
|
onUpdate(index, "key_id", "");
|
|
}}
|
|
>
|
|
<SelectTrigger
|
|
id={`routing-target-${index}-provider-select`}
|
|
aria-labelledby={`routing-target-${index}-provider-label`}
|
|
className="h-9 flex-1 text-sm"
|
|
data-testid={`routing-target-${index}-provider-select`}
|
|
>
|
|
<SelectValue placeholder="Incoming (optional)" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{availableProviders.map((prov) => (
|
|
<SelectItem key={prov} value={prov}>
|
|
<div className="flex items-center gap-2">
|
|
<RenderProviderIcon provider={prov as ProviderIconType} size="sm" className="h-4 w-4" />
|
|
<span>{getProviderLabel(prov)}</span>
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
{target.provider && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
onUpdate(index, "provider", "");
|
|
onUpdate(index, "model", "");
|
|
onUpdate(index, "key_id", "");
|
|
}}
|
|
className="h-9 w-9 p-0"
|
|
aria-label={`Clear provider for target ${index + 1}`}
|
|
data-testid={`routing-target-${index}-provider-clear`}
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label id={`routing-target-${index}-model-label`} className="text-xs">
|
|
Model
|
|
</Label>
|
|
<div className="flex gap-1.5">
|
|
<div className="flex-1" data-testid={`routing-target-${index}-model-select`}>
|
|
<ModelMultiselect
|
|
provider={target.provider || undefined}
|
|
value={target.model}
|
|
onChange={(value) => onUpdate(index, "model", value)}
|
|
placeholder="Incoming (optional)"
|
|
isSingleSelect
|
|
loadModelsOnEmptyProvider
|
|
className="!h-9 !min-h-9"
|
|
inputId={`routing-target-${index}-model-input`}
|
|
ariaLabelledBy={`routing-target-${index}-model-label`}
|
|
/>
|
|
</div>
|
|
{target.model && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => onUpdate(index, "model", "")}
|
|
className="h-9 w-9 p-0"
|
|
aria-label={`Clear model for target ${index + 1}`}
|
|
data-testid={`routing-target-${index}-model-clear`}
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{target.provider && (availableKeys.length > 0 || target.key_id) && (
|
|
<div className="space-y-1.5">
|
|
<Label id={`routing-target-${index}-apikey-label`} className="text-xs">
|
|
API Key <span className="text-muted-foreground">(optional — leave unset for load-balanced selection)</span>
|
|
</Label>
|
|
<div className="flex gap-1.5">
|
|
<Select value={target.key_id || ""} onValueChange={(value) => onUpdate(index, "key_id", value)}>
|
|
<SelectTrigger
|
|
id={`routing-target-${index}-apikey-select`}
|
|
aria-labelledby={`routing-target-${index}-apikey-label`}
|
|
className="h-9 flex-1 text-sm"
|
|
data-testid={`routing-target-${index}-apikey-select`}
|
|
>
|
|
<SelectValue placeholder="Select key (optional)" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{availableKeys.map((key) => (
|
|
<SelectItem key={key.id} value={key.id}>
|
|
{key.name}
|
|
</SelectItem>
|
|
))}
|
|
{target.key_id && !availableKeys.some((k) => k.id === target.key_id) && (
|
|
<SelectItem key={`pinned-${target.key_id}`} value={target.key_id}>
|
|
(pinned) {target.key_id}
|
|
</SelectItem>
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
{target.key_id && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => onUpdate(index, "key_id", "")}
|
|
className="h-9 w-9 p-0"
|
|
aria-label={`Clear API key for target ${index + 1}`}
|
|
data-testid={`routing-target-${index}-apikey-clear`}
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
} |