mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
refactor(ui): migrate auto router and credential forms to react-hook-form and shadcn (#37304)
* refactor(ui): migrate auto router and credential forms to react-hook-form and shadcn Moves four antd Form surfaces onto useZodForm plus the shared FormField and FieldGroup primitives: the routing group modal, the auto router edit modal, the add auto router tab, and the reuse credentials modal. Field labels that carried an antd tooltip= keep it as a hover Tooltip on a help icon, and hardcoded greys give way to semantic color tokens. Two Base UI combobox wrappers come out of the two auto router surfaces that shared the same antd controls: AccessGroupTagsCombobox replaces mode="tags" for model access groups, and ModelChoiceCombobox replaces the searchable single select for default and embedding models. Payload building for the routing group modal moves to routingGroupPayload.ts so the JSON args parsing and the four bound fields can be asserted directly instead of through a render. handle_add_auto_router_submit now takes a resetForm callback rather than an antd form instance, which drops one any from its signature. No change to what any of these forms submit. The reuse credentials payload keeps the same key set, with the stored credential values rendered read-only and merged back in at submit rather than copied into form state. * refactor(ui): type the auto router create payload boundary handleAddAutoRouterSubmit took its values as any, so a change to either side of the auto router create payload passed static checking. It now takes an exported AddAutoRouterValues, and add_auto_router_tab annotates the object it builds with that same type, so the producer and the consumer cannot drift. Its model_info is built in one shot rather than assigned into after the fact, which drops the second any and keeps the two conditional keys exactly as they were. Adds a routing group case that saves an untouched edit of a group whose stored arguments are null, which is the shape the proxy returns for an unset field.
This commit is contained in:
parent
e20e31e985
commit
7d97bab405
12 changed files with 1629 additions and 669 deletions
|
|
@ -1934,7 +1934,7 @@
|
|||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/add_model/add_model_modes.tsx": {
|
||||
|
|
@ -2284,9 +2284,6 @@
|
|||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
},
|
||||
"react-hooks/immutability": {
|
||||
"count": 1
|
||||
}
|
||||
|
|
@ -2431,7 +2428,7 @@
|
|||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/model_dashboard/ModelSettingsModal/ModelSettingsModal.tsx": {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxChip,
|
||||
ComboboxChips,
|
||||
ComboboxChipsInput,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
} from "@/components/ui/combobox";
|
||||
|
||||
interface AccessGroupTagsComboboxProps {
|
||||
id: string;
|
||||
value: string[] | undefined;
|
||||
onChange: (value: string[]) => void;
|
||||
options: string[];
|
||||
ariaInvalid: true | undefined;
|
||||
ariaDescribedBy: string | undefined;
|
||||
}
|
||||
|
||||
const AccessGroupTagsCombobox: React.FC<AccessGroupTagsComboboxProps> = ({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
ariaInvalid,
|
||||
ariaDescribedBy,
|
||||
}) => {
|
||||
const anchor = useComboboxAnchor();
|
||||
const [query, setQuery] = useState("");
|
||||
const selected = value ?? [];
|
||||
const trimmedQuery = query.trim();
|
||||
const items = trimmedQuery && !options.includes(trimmedQuery) ? [...options, trimmedQuery] : options;
|
||||
|
||||
const commit = (next: string[]) => {
|
||||
onChange(Array.from(new Set(next)));
|
||||
setQuery("");
|
||||
};
|
||||
|
||||
const handleInputValueChange = (next: string) => {
|
||||
if (!next.includes(",")) {
|
||||
setQuery(next);
|
||||
return;
|
||||
}
|
||||
commit([
|
||||
...selected,
|
||||
...next
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean),
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
multiple
|
||||
autoHighlight
|
||||
items={items}
|
||||
value={selected}
|
||||
onValueChange={commit}
|
||||
inputValue={query}
|
||||
onInputValueChange={handleInputValueChange}
|
||||
>
|
||||
<ComboboxChips render={<div ref={anchor} />}>
|
||||
<ComboboxValue>
|
||||
{(groups: string[]) => (
|
||||
<>
|
||||
{groups.map((group) => (
|
||||
<ComboboxChip key={group} aria-label={group}>
|
||||
{group}
|
||||
</ComboboxChip>
|
||||
))}
|
||||
<ComboboxChipsInput
|
||||
id={id}
|
||||
aria-invalid={ariaInvalid}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
placeholder="Select existing groups or type to create new ones"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ComboboxValue>
|
||||
</ComboboxChips>
|
||||
<ComboboxContent anchor={anchor}>
|
||||
<ComboboxEmpty>No access groups found</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(group: string) => (
|
||||
<ComboboxItem key={group} value={group}>
|
||||
{group}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccessGroupTagsCombobox;
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from "@/components/ui/combobox";
|
||||
|
||||
export interface ModelChoice {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ModelChoiceComboboxProps {
|
||||
id: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
choices: ModelChoice[];
|
||||
placeholder: string;
|
||||
ariaInvalid: true | undefined;
|
||||
ariaDescribedBy: string | undefined;
|
||||
}
|
||||
|
||||
const ModelChoiceCombobox: React.FC<ModelChoiceComboboxProps> = ({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
choices,
|
||||
placeholder,
|
||||
ariaInvalid,
|
||||
ariaDescribedBy,
|
||||
}) => {
|
||||
const selected = value ? choices.find((choice) => choice.value === value) ?? { value, label: value } : null;
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
items={choices}
|
||||
value={selected}
|
||||
onValueChange={(choice: ModelChoice | null) => onChange(choice?.value ?? "")}
|
||||
itemToStringLabel={(choice: ModelChoice) => choice.label}
|
||||
isItemEqualToValue={(choice: ModelChoice, current: ModelChoice) => choice.value === current.value}
|
||||
>
|
||||
<ComboboxInput
|
||||
id={id}
|
||||
aria-invalid={ariaInvalid}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
placeholder={placeholder}
|
||||
className="w-full"
|
||||
showClear={value !== ""}
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>No models found</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(choice: ModelChoice) => (
|
||||
<ComboboxItem key={choice.value} value={choice}>
|
||||
{choice.label}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelChoiceCombobox;
|
||||
|
|
@ -1,13 +1,22 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, Form, Button, Tooltip, Typography, Select as AntdSelect, Modal } from "antd";
|
||||
import { DownOutlined, RightOutlined } from "@ant-design/icons";
|
||||
import { TextInput } from "@tremor/react";
|
||||
import { useWatch } from "react-hook-form";
|
||||
import { Card, Select as AntdSelect, Modal } from "antd";
|
||||
import { ChevronDown, ChevronRight, CircleHelp } from "lucide-react";
|
||||
import { z } from "zod/v4";
|
||||
import { FieldGroup } from "@/components/shared/form/field";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
import AccessGroupTagsCombobox from "./AccessGroupTagsCombobox";
|
||||
import { modelAvailableCall } from "../networking";
|
||||
import { all_admin_roles } from "@/utils/roles";
|
||||
import { type ModelWriteScope } from "@/utils/modelPermissions";
|
||||
import TeamDropdown from "../common_components/team_dropdown";
|
||||
import { handleAddAutoRouterSubmit } from "./handle_add_auto_router_submit";
|
||||
import { type AddAutoRouterValues, handleAddAutoRouterSubmit } from "./handle_add_auto_router_submit";
|
||||
import { fetchAvailableModels } from "@/components/llm_calls/fetch_models";
|
||||
import { autoRouterListKey, fetchAllModelDeployments } from "@/app/(dashboard)/hooks/models/useModels";
|
||||
import ComplexityRouterConfig, {
|
||||
|
|
@ -121,6 +130,47 @@ const getSubmitBlockedReason = (
|
|||
getKeywordTierRulesError(keywordTierRules) ??
|
||||
getReferencedModelsError(referencedModelsParams, availability);
|
||||
|
||||
const autoRouterSchema = (requiresTeamScope: boolean) =>
|
||||
z.object({
|
||||
auto_router_name: z.string().min(1, "Auto router name is required"),
|
||||
team_id: requiresTeamScope ? z.string().min(1, "Please select a team to continue") : z.string(),
|
||||
model_access_group: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
type AddAutoRouterFormValues = z.infer<ReturnType<typeof autoRouterSchema>>;
|
||||
|
||||
const EMPTY_FORM_VALUES: AddAutoRouterFormValues = {
|
||||
auto_router_name: "",
|
||||
team_id: "",
|
||||
model_access_group: undefined,
|
||||
};
|
||||
|
||||
const labelWithHint = (label: string, hint: string): React.ReactNode => (
|
||||
<>
|
||||
{label}
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<CircleHelp className="size-3.5 shrink-0 cursor-help text-muted-foreground" />} />
|
||||
<TooltipContent>{hint}</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
|
||||
const teamScopePayload = (requiresTeamScope: boolean, teamId: string): { team_id?: string } =>
|
||||
requiresTeamScope ? { team_id: teamId } : {};
|
||||
|
||||
const BlockedReasonTooltip: React.FC<{ reason: string | null; children: React.ReactElement }> = ({
|
||||
reason,
|
||||
children,
|
||||
}) =>
|
||||
reason === null ? (
|
||||
children
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={children} />
|
||||
<TooltipContent>{reason}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
||||
handleOk,
|
||||
accessToken,
|
||||
|
|
@ -129,7 +179,9 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
createScope = "unscoped-ok",
|
||||
}) => {
|
||||
const requiresTeamScope = createScope === "team-required";
|
||||
const [form] = Form.useForm();
|
||||
const form = useZodForm(autoRouterSchema(requiresTeamScope), { defaultValues: EMPTY_FORM_VALUES });
|
||||
const watchedName = useWatch({ control: form.control, name: "auto_router_name" });
|
||||
const watchedTeamId = useWatch({ control: form.control, name: "team_id" });
|
||||
const [modelAccessGroups, setModelAccessGroups] = useState<string[]>([]);
|
||||
|
||||
const [complexityRouterConfig, setComplexityRouterConfig] = useState<ComplexityRouterConfigValue>({
|
||||
|
|
@ -308,7 +360,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
dimensionWeights: complexityRouterConfig.dimension_weights,
|
||||
};
|
||||
|
||||
const submitRecommendedRouter = (name: string) => {
|
||||
const submitRecommendedRouter = async (name: string) => {
|
||||
const { tiers, tierLabels, classifierType, classifierLlmConfig } = complexityRouterConfigParams;
|
||||
|
||||
const missingTiersError = getMissingTiersError(tiers);
|
||||
|
|
@ -345,8 +397,8 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
// submitBlockedReason already disables the button for this, but Form's onFinish (wired to this
|
||||
// same handler) fires on Enter regardless of the button's disabled state - without this check,
|
||||
// submitBlockedReason already disables the button for this, but the form's submit handler (wired to
|
||||
// this same function) fires on Enter regardless of the button's disabled state - without this check,
|
||||
// Enter in the name field could still create a router referencing a model that disappeared from
|
||||
// availableModelSet after the tiers were filled in.
|
||||
const referencedModelsError = getReferencedModelsError(referencedModelsParams, groupsOnlyAvailability);
|
||||
|
|
@ -357,48 +409,41 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
}
|
||||
|
||||
const defaultModel = resolveComplexityDefaultModel(tiers, complexityRouterConfig.default_model);
|
||||
const validatedFields = requiresTeamScope
|
||||
? (["auto_router_name", "team_id"] as const)
|
||||
: (["auto_router_name"] as const);
|
||||
|
||||
form.setFieldsValue({
|
||||
custom_llm_provider: "auto_router",
|
||||
model: name,
|
||||
api_key: "not_required_for_auto_router",
|
||||
if (!(await form.trigger(validatedFields))) {
|
||||
toast.fromError("Please fill in all required fields");
|
||||
return;
|
||||
}
|
||||
|
||||
// auto_router_default_model (-> litellm_params, read by the backend at init) and
|
||||
// complexity_router_config.default_model (-> the pin marker read back on edit, see
|
||||
// hydratePinnedDefaultModel in edit_auto_router_modal.tsx) must both come from the same
|
||||
// `defaultModel`, or the two fields diverge and hydration's divergence check misfires.
|
||||
const submitValues: AddAutoRouterValues = {
|
||||
auto_router_name: name,
|
||||
...teamScopePayload(requiresTeamScope, form.getValues("team_id")),
|
||||
auto_router_default_model: defaultModel,
|
||||
});
|
||||
model_type: "complexity_router",
|
||||
complexity_router_config: buildComplexityRouterConfig(complexityRouterConfigParams),
|
||||
model_access_group: form.getValues("model_access_group"),
|
||||
};
|
||||
|
||||
form
|
||||
.validateFields(requiresTeamScope ? ["auto_router_name", "team_id"] : ["auto_router_name"])
|
||||
.then((values) => {
|
||||
// auto_router_default_model (-> litellm_params, read by the backend at init) and
|
||||
// complexity_router_config.default_model (-> the pin marker read back on edit, see
|
||||
// hydratePinnedDefaultModel in edit_auto_router_modal.tsx) must both come from the same
|
||||
// `defaultModel`, or the two fields diverge and hydration's divergence check misfires.
|
||||
const submitValues = {
|
||||
...values,
|
||||
auto_router_name: name,
|
||||
auto_router_default_model: defaultModel,
|
||||
model_type: "complexity_router",
|
||||
complexity_router_config: buildComplexityRouterConfig(complexityRouterConfigParams),
|
||||
model_access_group: form.getFieldValue("model_access_group"),
|
||||
};
|
||||
|
||||
handleAddAutoRouterSubmit(submitValues, accessToken, form, handleOk);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Validation failed:", error);
|
||||
toast.fromError("Please fill in all required fields");
|
||||
});
|
||||
handleAddAutoRouterSubmit(submitValues, accessToken, () => form.reset(EMPTY_FORM_VALUES), handleOk);
|
||||
};
|
||||
|
||||
const handleAutoRouterSubmit = () => {
|
||||
const name = form.getFieldValue("auto_router_name");
|
||||
const handleAutoRouterSubmit = async () => {
|
||||
const name = form.getValues("auto_router_name");
|
||||
if (!name) {
|
||||
setShowValidationErrors(true);
|
||||
form.validateFields(["auto_router_name"]).catch(() => undefined);
|
||||
void form.trigger("auto_router_name");
|
||||
toast.fromError("Please enter an Auto Router Name");
|
||||
return;
|
||||
}
|
||||
|
||||
submitRecommendedRouter(name);
|
||||
await submitRecommendedRouter(name);
|
||||
};
|
||||
|
||||
const handleTestConnection = () => {
|
||||
|
|
@ -422,196 +467,204 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TooltipProvider>
|
||||
<Card>
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={handleAutoRouterSubmit}
|
||||
labelCol={{ span: 10 }}
|
||||
wrapperCol={{ span: 16 }}
|
||||
labelAlign="left"
|
||||
>
|
||||
<Form.Item
|
||||
rules={[{ required: true, message: "Auto router name is required" }]}
|
||||
label="Auto Router Name"
|
||||
name="auto_router_name"
|
||||
tooltip="Unique name for this auto router configuration"
|
||||
labelCol={{ span: 10 }}
|
||||
labelAlign="left"
|
||||
>
|
||||
<TextInput placeholder="e.g., smart_router, auto_router_1" />
|
||||
</Form.Item>
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-900 mb-2">Template</label>
|
||||
<AntdSelect
|
||||
value={selectedPreset}
|
||||
onChange={handlePresetChange}
|
||||
placeholder="Choose a template or select Custom to define your own"
|
||||
className="w-full"
|
||||
optionLabelProp="label"
|
||||
data-testid="template-selector"
|
||||
<form onSubmit={form.handleSubmit(() => handleAutoRouterSubmit())} noValidate>
|
||||
<FieldGroup>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="auto_router_name"
|
||||
label={labelWithHint("Auto Router Name", "Unique name for this auto router configuration")}
|
||||
>
|
||||
{sortedPresetOptions.map(({ preset, availability: presetState }) => {
|
||||
const disabledHint = presetDisabledHint(presetState);
|
||||
const isDisabled = disabledHint !== null;
|
||||
const hintClass = isPresetHintAlarming(presetState) ? "text-red-500" : "text-gray-400";
|
||||
const matchedHint =
|
||||
presetState.kind === "available" && presetState.viaDeployments ? "Matches your deployments" : null;
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="e.g., smart_router, auto_router_1" />}
|
||||
</FormField>
|
||||
|
||||
return (
|
||||
<AntdSelect.Option
|
||||
key={preset.key}
|
||||
value={preset.key}
|
||||
label={preset.label}
|
||||
disabled={isDisabled}
|
||||
title={disabledHint ?? preset.description}
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">{preset.label}</div>
|
||||
<div className="text-xs text-gray-500">{preset.description}</div>
|
||||
{disabledHint && <div className={`text-xs mt-1 ${hintClass}`}>{disabledHint}</div>}
|
||||
{matchedHint && <div className="text-xs mt-1 text-green-600">{matchedHint}</div>}
|
||||
</div>
|
||||
</AntdSelect.Option>
|
||||
);
|
||||
})}
|
||||
<AntdSelect.Option value="custom" label="Custom Configuration">
|
||||
<div>
|
||||
<div className="font-medium">Custom Configuration</div>
|
||||
<div className="text-xs text-gray-500">Define your auto router from scratch</div>
|
||||
</div>
|
||||
</AntdSelect.Option>
|
||||
</AntdSelect>
|
||||
{modelsUnverifiable && (
|
||||
<div className="text-xs mt-1 text-red-500">
|
||||
Could not load available models.{" "}
|
||||
<button type="button" className="underline" onClick={() => refetchModels()}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{requiresTeamScope && (
|
||||
<Form.Item
|
||||
label="Select Team"
|
||||
name="team_id"
|
||||
rules={[{ required: true, message: "Please select a team to continue" }]}
|
||||
tooltip="Select the team this auto router belongs to. Only keys for this team will be able to call it."
|
||||
labelCol={{ span: 10 }}
|
||||
labelAlign="left"
|
||||
>
|
||||
<TeamDropdown />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<div className="border border-gray-200 rounded-lg mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetailsExpanded((expanded) => !expanded)}
|
||||
className="w-full flex flex-col gap-1 px-4 py-3 text-left hover:bg-gray-50"
|
||||
data-testid="detailed-configuration-toggle"
|
||||
>
|
||||
<span className="flex items-center gap-2 font-medium text-gray-900">
|
||||
{detailsExpanded ? (
|
||||
<DownOutlined className="text-xs text-gray-500" />
|
||||
) : (
|
||||
<RightOutlined className="text-xs text-gray-500" />
|
||||
)}
|
||||
Detailed Configuration
|
||||
</span>
|
||||
{!detailsExpanded && (
|
||||
<span className="text-xs text-gray-500 line-clamp-2">
|
||||
{tierConfigSummary(complexityRouterConfig.tiers)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{detailsExpanded && (
|
||||
<div className="px-4 pb-4">
|
||||
<ComplexityRouterConfig
|
||||
modelInfo={modelInfo}
|
||||
value={complexityRouterConfig}
|
||||
onChange={setComplexityRouterConfig}
|
||||
customTechnicalKeywords={customTechnicalKeywords}
|
||||
onCustomTechnicalKeywordsChange={setCustomTechnicalKeywords}
|
||||
keywordTierRules={keywordTierRules}
|
||||
onKeywordTierRulesChange={setKeywordTierRules}
|
||||
semanticMatchingEnabled={semanticMatchingEnabled}
|
||||
onSemanticMatchingEnabledChange={setSemanticMatchingEnabled}
|
||||
embeddingModel={embeddingModel}
|
||||
onEmbeddingModelChange={setEmbeddingModel}
|
||||
matchThreshold={matchThreshold}
|
||||
onMatchThresholdChange={setMatchThreshold}
|
||||
escalationKeywords={escalationKeywords}
|
||||
onEscalationKeywordsChange={setEscalationKeywords}
|
||||
showValidationErrors={showValidationErrors}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Model Access Groups - Admin only */}
|
||||
{isAdmin && (
|
||||
<Form.Item
|
||||
label="Model Access Group"
|
||||
name="model_access_group"
|
||||
className="mb-4"
|
||||
tooltip="Use model access groups to control who can access this auto router"
|
||||
>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">Template</label>
|
||||
<AntdSelect
|
||||
mode="tags"
|
||||
showSearch
|
||||
placeholder="Select existing groups or type to create new ones"
|
||||
optionFilterProp="children"
|
||||
tokenSeparators={[","]}
|
||||
options={modelAccessGroups.map((group) => ({
|
||||
value: group,
|
||||
label: group,
|
||||
}))}
|
||||
maxTagCount="responsive"
|
||||
allowClear
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
value={selectedPreset}
|
||||
onChange={handlePresetChange}
|
||||
placeholder="Choose a template or select Custom to define your own"
|
||||
className="w-full"
|
||||
optionLabelProp="label"
|
||||
data-testid="template-selector"
|
||||
>
|
||||
{sortedPresetOptions.map(({ preset, availability: presetState }) => {
|
||||
const disabledHint = presetDisabledHint(presetState);
|
||||
const isDisabled = disabledHint !== null;
|
||||
const hintClass = isPresetHintAlarming(presetState)
|
||||
? "text-red-500 dark:text-red-400"
|
||||
: "text-muted-foreground";
|
||||
const matchedHint =
|
||||
presetState.kind === "available" && presetState.viaDeployments ? "Matches your deployments" : null;
|
||||
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Tooltip title="Get help on our github">
|
||||
<Typography.Link href="https://github.com/BerriAI/litellm/issues">Need Help?</Typography.Link>
|
||||
</Tooltip>
|
||||
<div className="space-x-2">
|
||||
<Tooltip title={submitBlockedReason}>
|
||||
<Button
|
||||
data-testid="auto-router-test-routing-btn"
|
||||
disabled={submitBlockedReason !== null}
|
||||
onClick={() => setIsRoutingTestVisible(true)}
|
||||
>
|
||||
Test Routing
|
||||
</Button>
|
||||
return (
|
||||
<AntdSelect.Option
|
||||
key={preset.key}
|
||||
value={preset.key}
|
||||
label={preset.label}
|
||||
disabled={isDisabled}
|
||||
title={disabledHint ?? preset.description}
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">{preset.label}</div>
|
||||
<div className="text-xs text-muted-foreground">{preset.description}</div>
|
||||
{disabledHint && <div className={`text-xs mt-1 ${hintClass}`}>{disabledHint}</div>}
|
||||
{matchedHint && (
|
||||
<div className="text-xs mt-1 text-green-600 dark:text-green-400">{matchedHint}</div>
|
||||
)}
|
||||
</div>
|
||||
</AntdSelect.Option>
|
||||
);
|
||||
})}
|
||||
<AntdSelect.Option value="custom" label="Custom Configuration">
|
||||
<div>
|
||||
<div className="font-medium">Custom Configuration</div>
|
||||
<div className="text-xs text-muted-foreground">Define your auto router from scratch</div>
|
||||
</div>
|
||||
</AntdSelect.Option>
|
||||
</AntdSelect>
|
||||
{modelsUnverifiable && (
|
||||
<div className="text-xs mt-1 text-red-500 dark:text-red-400">
|
||||
Could not load available models.{" "}
|
||||
<button type="button" className="underline" onClick={() => refetchModels()}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{requiresTeamScope && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="team_id"
|
||||
label={labelWithHint(
|
||||
"Select Team",
|
||||
"Select the team this auto router belongs to. Only keys for this team will be able to call it.",
|
||||
)}
|
||||
>
|
||||
{({ id, value, onChange }) => <TeamDropdown id={id} value={value} onChange={onChange} />}
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetailsExpanded((expanded) => !expanded)}
|
||||
className="w-full flex flex-col gap-1 px-4 py-3 text-left hover:bg-muted"
|
||||
data-testid="detailed-configuration-toggle"
|
||||
>
|
||||
<span className="flex items-center gap-2 font-medium text-foreground">
|
||||
{detailsExpanded ? (
|
||||
<ChevronDown className="size-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="size-3 text-muted-foreground" />
|
||||
)}
|
||||
Detailed Configuration
|
||||
</span>
|
||||
{!detailsExpanded && (
|
||||
<span className="text-xs text-muted-foreground line-clamp-2">
|
||||
{tierConfigSummary(complexityRouterConfig.tiers)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{detailsExpanded && (
|
||||
<div className="px-4 pb-4">
|
||||
<ComplexityRouterConfig
|
||||
modelInfo={modelInfo}
|
||||
value={complexityRouterConfig}
|
||||
onChange={setComplexityRouterConfig}
|
||||
customTechnicalKeywords={customTechnicalKeywords}
|
||||
onCustomTechnicalKeywordsChange={setCustomTechnicalKeywords}
|
||||
keywordTierRules={keywordTierRules}
|
||||
onKeywordTierRulesChange={setKeywordTierRules}
|
||||
semanticMatchingEnabled={semanticMatchingEnabled}
|
||||
onSemanticMatchingEnabledChange={setSemanticMatchingEnabled}
|
||||
embeddingModel={embeddingModel}
|
||||
onEmbeddingModelChange={setEmbeddingModel}
|
||||
matchThreshold={matchThreshold}
|
||||
onMatchThresholdChange={setMatchThreshold}
|
||||
escalationKeywords={escalationKeywords}
|
||||
onEscalationKeywordsChange={setEscalationKeywords}
|
||||
showValidationErrors={showValidationErrors}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="model_access_group"
|
||||
label={labelWithHint(
|
||||
"Model Access Group",
|
||||
"Use model access groups to control who can access this auto router",
|
||||
)}
|
||||
>
|
||||
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
|
||||
<AccessGroupTagsCombobox
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={modelAccessGroups}
|
||||
ariaInvalid={ariaInvalid}
|
||||
ariaDescribedBy={ariaDescribedBy}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<a
|
||||
href="https://github.com/BerriAI/litellm/issues"
|
||||
className="text-sm text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
Need Help?
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>Get help on our github</TooltipContent>
|
||||
</Tooltip>
|
||||
{
|
||||
<div className="flex gap-2">
|
||||
<BlockedReasonTooltip reason={submitBlockedReason}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
data-testid="auto-router-test-routing-btn"
|
||||
disabled={submitBlockedReason !== null}
|
||||
onClick={() => setIsRoutingTestVisible(true)}
|
||||
>
|
||||
Test Routing
|
||||
</Button>
|
||||
</BlockedReasonTooltip>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
data-testid="auto-router-test-connect-btn"
|
||||
onClick={handleTestConnection}
|
||||
loading={isTestingConnection}
|
||||
disabled={isTestingConnection}
|
||||
>
|
||||
{isTestingConnection && <UiLoadingSpinner className="size-4" />}
|
||||
Test Connection
|
||||
</Button>
|
||||
}
|
||||
<Tooltip title={submitBlockedReason}>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={submitBlockedReason !== null}
|
||||
onClick={() => {
|
||||
handleAutoRouterSubmit();
|
||||
}}
|
||||
>
|
||||
Add Auto Router
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<BlockedReasonTooltip reason={submitBlockedReason}>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={submitBlockedReason !== null}
|
||||
onClick={() => {
|
||||
void handleAutoRouterSubmit();
|
||||
}}
|
||||
>
|
||||
Add Auto Router
|
||||
</Button>
|
||||
</BlockedReasonTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
|
|
@ -620,7 +673,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
destroyOnHidden
|
||||
onCancel={() => setIsRoutingTestVisible(false)}
|
||||
footer={[
|
||||
<Button key="close" onClick={() => setIsRoutingTestVisible(false)}>
|
||||
<Button key="close" variant="outline" onClick={() => setIsRoutingTestVisible(false)}>
|
||||
Close
|
||||
</Button>,
|
||||
]}
|
||||
|
|
@ -634,8 +687,8 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
complexityRouterConfig.tiers,
|
||||
complexityRouterConfig.default_model,
|
||||
)}
|
||||
routerName={form.getFieldValue("auto_router_name")}
|
||||
teamId={requiresTeamScope ? form.getFieldValue("team_id") : undefined}
|
||||
routerName={watchedName}
|
||||
teamId={requiresTeamScope ? watchedTeamId : undefined}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
|
|
@ -650,6 +703,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
footer={[
|
||||
<Button
|
||||
key="close"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsTestModalVisible(false);
|
||||
setIsTestingConnection(false);
|
||||
|
|
@ -669,7 +723,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,50 +1,41 @@
|
|||
import { modelCreateCall, Model } from "../networking";
|
||||
import { modelCreateCall } from "../networking";
|
||||
import { toast } from "@/lib/toast";
|
||||
import type { ComplexityRouterConfigPayload } from "./build_complexity_router_config";
|
||||
|
||||
export const handleAddAutoRouterSubmit = async (values: any, accessToken: string, form: any, callback?: () => void) => {
|
||||
export interface AddAutoRouterValues {
|
||||
auto_router_name: string;
|
||||
auto_router_default_model: string | undefined;
|
||||
model_type: "complexity_router";
|
||||
complexity_router_config: ComplexityRouterConfigPayload;
|
||||
team_id?: string;
|
||||
model_access_group?: string[];
|
||||
}
|
||||
|
||||
export const handleAddAutoRouterSubmit = async (
|
||||
values: AddAutoRouterValues,
|
||||
accessToken: string,
|
||||
resetForm: () => void,
|
||||
callback?: () => void,
|
||||
) => {
|
||||
try {
|
||||
let autoRouterConfig: any;
|
||||
const autoRouterConfig = {
|
||||
model_name: values.auto_router_name,
|
||||
litellm_params: {
|
||||
model: "auto_router/complexity_router",
|
||||
complexity_router_config: values.complexity_router_config,
|
||||
complexity_router_default_model: values.auto_router_default_model,
|
||||
},
|
||||
model_info: {
|
||||
...(values.team_id ? { team_id: values.team_id } : {}),
|
||||
...(values.model_access_group?.length ? { access_groups: values.model_access_group } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
if (values.model_type === "complexity_router") {
|
||||
autoRouterConfig = {
|
||||
model_name: values.auto_router_name,
|
||||
litellm_params: {
|
||||
model: `auto_router/complexity_router`,
|
||||
complexity_router_config: values.complexity_router_config,
|
||||
complexity_router_default_model: values.auto_router_default_model,
|
||||
},
|
||||
model_info: {},
|
||||
};
|
||||
} else {
|
||||
autoRouterConfig = {
|
||||
model_name: values.auto_router_name,
|
||||
litellm_params: {
|
||||
model: `auto_router/${values.auto_router_name}`,
|
||||
auto_router_config: JSON.stringify(values.auto_router_config),
|
||||
auto_router_default_model: values.auto_router_default_model,
|
||||
},
|
||||
model_info: {},
|
||||
};
|
||||
await modelCreateCall(accessToken, autoRouterConfig);
|
||||
|
||||
if (values.auto_router_embedding_model) {
|
||||
autoRouterConfig.litellm_params.auto_router_embedding_model = values.auto_router_embedding_model;
|
||||
}
|
||||
}
|
||||
toast.success(`Successfully created Auto Router: ${values.auto_router_name}`);
|
||||
|
||||
if (values.team_id) {
|
||||
autoRouterConfig.model_info.team_id = values.team_id;
|
||||
}
|
||||
|
||||
if (values.model_access_group && values.model_access_group.length > 0) {
|
||||
autoRouterConfig.model_info.access_groups = values.model_access_group;
|
||||
}
|
||||
|
||||
await modelCreateCall(accessToken, autoRouterConfig as Model);
|
||||
|
||||
const routerTypeName = values.model_type === "complexity_router" ? "Auto Router" : "Semantic Router";
|
||||
toast.success(`Successfully created ${routerTypeName}: ${values.auto_router_name}`);
|
||||
|
||||
form.resetFields();
|
||||
resetForm();
|
||||
|
||||
if (callback) {
|
||||
callback();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,16 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { Form, Button, Select as AntdSelect, Tooltip } from "antd";
|
||||
import { TextInput } from "@tremor/react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { z } from "zod/v4";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { CircleHelp } from "lucide-react";
|
||||
import { FieldGroup } from "@/components/shared/form/field";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
import AccessGroupTagsCombobox from "../add_model/AccessGroupTagsCombobox";
|
||||
import ModelChoiceCombobox, { type ModelChoice } from "../add_model/ModelChoiceCombobox";
|
||||
import { modelAvailableCall, modelPatchUpdateCall } from "../networking";
|
||||
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
import RouterConfigBuilder from "../add_model/RouterConfigBuilder";
|
||||
|
|
@ -31,7 +41,6 @@ import ComplexityRouterConfig, {
|
|||
DEFAULT_TIER_DISTANCE_PENALTY,
|
||||
heuristicScoringRole,
|
||||
} from "../add_model/ComplexityRouterConfig";
|
||||
import { toast } from "@/lib/toast";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -191,6 +200,45 @@ export const buildUpdatedComplexityRouterConfig = (
|
|||
};
|
||||
};
|
||||
|
||||
const sharedShape = {
|
||||
auto_router_name: z.string().min(1, "Auto router name is required"),
|
||||
model_access_group: z.array(z.string()),
|
||||
};
|
||||
|
||||
const complexityRouterShape = {
|
||||
...sharedShape,
|
||||
auto_router_default_model: z.string(),
|
||||
auto_router_embedding_model: z.string(),
|
||||
};
|
||||
|
||||
const semanticRouterShape = {
|
||||
...sharedShape,
|
||||
auto_router_default_model: z.string().min(1, "Default model is required"),
|
||||
auto_router_embedding_model: z.string().min(1, "Embedding model is required"),
|
||||
};
|
||||
|
||||
const complexityRouterSchema = z.object(complexityRouterShape);
|
||||
const semanticRouterSchema = z.object(semanticRouterShape);
|
||||
|
||||
type EditAutoRouterFormValues = z.infer<typeof semanticRouterSchema>;
|
||||
|
||||
const EMPTY_FORM_VALUES: EditAutoRouterFormValues = {
|
||||
auto_router_name: "",
|
||||
auto_router_default_model: "",
|
||||
auto_router_embedding_model: "",
|
||||
model_access_group: [],
|
||||
};
|
||||
|
||||
const labelWithHint = (label: string, hint: string): React.ReactNode => (
|
||||
<>
|
||||
{label}
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<CircleHelp className="size-3.5 shrink-0 cursor-help text-muted-foreground" />} />
|
||||
<TooltipContent>{hint}</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
|
||||
const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
||||
isVisible,
|
||||
onCancel,
|
||||
|
|
@ -199,7 +247,6 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
accessToken,
|
||||
userRole,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modelAccessGroups, setModelAccessGroups] = useState<string[]>([]);
|
||||
const [modelInfo, setModelInfo] = useState<ModelGroup[]>([]);
|
||||
|
|
@ -217,6 +264,12 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
});
|
||||
const isComplexityRouterModel = isComplexityRouter(modelData?.litellm_params);
|
||||
|
||||
const schema = useMemo(
|
||||
() => (isComplexityRouterModel ? complexityRouterSchema : semanticRouterSchema),
|
||||
[isComplexityRouterModel],
|
||||
);
|
||||
const form = useZodForm(schema, { defaultValues: EMPTY_FORM_VALUES });
|
||||
|
||||
// Mirrors the create form: the button says why it is unavailable and disables on the same
|
||||
// answer. Tiers use this modal's own rule, which allows a partly filled router, so an edit that
|
||||
// is legal today stays legal.
|
||||
|
|
@ -339,7 +392,8 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
typeof parsedConfig.match_threshold === "number" ? parsedConfig.match_threshold : DEFAULT_MATCH_THRESHOLD,
|
||||
);
|
||||
|
||||
form.setFieldsValue({
|
||||
form.reset({
|
||||
...EMPTY_FORM_VALUES,
|
||||
auto_router_name: modelData.model_name,
|
||||
model_access_group: modelData.model_info?.access_groups || [],
|
||||
});
|
||||
|
|
@ -359,7 +413,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
setRouterConfig(parsedConfig);
|
||||
|
||||
// Set form values
|
||||
form.setFieldsValue({
|
||||
form.reset({
|
||||
auto_router_name: modelData.model_name,
|
||||
auto_router_default_model: modelData.litellm_params?.auto_router_default_model || "",
|
||||
auto_router_embedding_model: modelData.litellm_params?.auto_router_embedding_model || "",
|
||||
|
|
@ -371,128 +425,132 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const values = await form.validateFields();
|
||||
|
||||
if (isComplexityRouterModel) {
|
||||
const { tiers, classifier_type, classifier_llm_config } = complexityRouterConfig;
|
||||
if (Object.values(tiers).every((models) => models.length === 0)) {
|
||||
setShowValidationErrors(true);
|
||||
toast.fromError("Please select at least one model for a complexity tier");
|
||||
return;
|
||||
}
|
||||
if (classifier_type === "llm" && !classifier_llm_config?.model) {
|
||||
setShowValidationErrors(true);
|
||||
toast.fromError("Please select a classifier model, or switch back to Heuristic");
|
||||
return;
|
||||
}
|
||||
// Same guards the create form applies (add_auto_router_tab.tsx). The backend rejects a
|
||||
// keyword rule with no keyword, and semantic_keyword_matching without an embedding model
|
||||
// or keyword rules (complexity_router/config.py), so without these a save fails as a raw
|
||||
// 400 instead of an inline message.
|
||||
const keywordRulesError = getKeywordTierRulesError(keywordTierRules);
|
||||
if (keywordRulesError) {
|
||||
setShowValidationErrors(true);
|
||||
toast.fromError(keywordRulesError);
|
||||
return;
|
||||
}
|
||||
|
||||
const semanticError = getSemanticConfigError({ semanticMatchingEnabled, embeddingModel, keywordTierRules });
|
||||
if (semanticError) {
|
||||
setShowValidationErrors(true);
|
||||
toast.fromError(semanticError);
|
||||
return;
|
||||
}
|
||||
|
||||
// Unlike the create form, this modal only requires one non-empty tier, so a router can reach
|
||||
// here with nothing the backend would pick as a default (see getMissingTiersError in
|
||||
// build_complexity_router_config.ts for why create never can). init_complexity_router_deployment
|
||||
// raises in that case (litellm/router.py), so block it rather than saving a router that
|
||||
// fails at init.
|
||||
const defaultModel = resolveComplexityDefaultModel(tiers, complexityRouterConfig.default_model);
|
||||
if (!defaultModel) {
|
||||
setShowValidationErrors(true);
|
||||
toast.fromError(
|
||||
"Add a model to the Simple or Medium tier, or pin a default model, so requests have somewhere to route.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Dual write: complexity_router_config.default_model (the pin marker hydratePinnedDefaultModel
|
||||
// reads back) and complexity_router_default_model (what the backend routes on) must always be
|
||||
// written together from the same value. Same pairing in add_auto_router_tab.tsx.
|
||||
const updatedLitellmParams = {
|
||||
...modelData.litellm_params,
|
||||
complexity_router_config: buildUpdatedComplexityRouterConfig(
|
||||
modelData.litellm_params?.complexity_router_config,
|
||||
complexityRouterConfig,
|
||||
customTechnicalKeywords,
|
||||
{
|
||||
keywordTierRules,
|
||||
escalationKeywords,
|
||||
semanticMatchingEnabled,
|
||||
embeddingModel,
|
||||
matchThreshold,
|
||||
},
|
||||
),
|
||||
complexity_router_default_model: defaultModel,
|
||||
};
|
||||
const updatedModelInfo = {
|
||||
...modelData.model_info,
|
||||
access_groups: values.model_access_group || [],
|
||||
};
|
||||
|
||||
await modelPatchUpdateCall(
|
||||
accessToken,
|
||||
{ model_name: values.auto_router_name, litellm_params: updatedLitellmParams, model_info: updatedModelInfo },
|
||||
modelData.model_info.id,
|
||||
);
|
||||
|
||||
toast.success("Auto router configuration updated successfully");
|
||||
onSuccess({
|
||||
...modelData,
|
||||
model_name: values.auto_router_name,
|
||||
litellm_params: updatedLitellmParams,
|
||||
model_info: updatedModelInfo,
|
||||
});
|
||||
onCancel();
|
||||
const saveValues = async (values: EditAutoRouterFormValues) => {
|
||||
if (isComplexityRouterModel) {
|
||||
const { tiers, classifier_type, classifier_llm_config } = complexityRouterConfig;
|
||||
if (Object.values(tiers).every((models) => models.length === 0)) {
|
||||
setShowValidationErrors(true);
|
||||
toast.fromError("Please select at least one model for a complexity tier");
|
||||
return;
|
||||
}
|
||||
if (classifier_type === "llm" && !classifier_llm_config?.model) {
|
||||
setShowValidationErrors(true);
|
||||
toast.fromError("Please select a classifier model, or switch back to Heuristic");
|
||||
return;
|
||||
}
|
||||
// Same guards the create form applies (add_auto_router_tab.tsx). The backend rejects a
|
||||
// keyword rule with no keyword, and semantic_keyword_matching without an embedding model
|
||||
// or keyword rules (complexity_router/config.py), so without these a save fails as a raw
|
||||
// 400 instead of an inline message.
|
||||
const keywordRulesError = getKeywordTierRulesError(keywordTierRules);
|
||||
if (keywordRulesError) {
|
||||
setShowValidationErrors(true);
|
||||
toast.fromError(keywordRulesError);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare the updated litellm_params
|
||||
const semanticError = getSemanticConfigError({ semanticMatchingEnabled, embeddingModel, keywordTierRules });
|
||||
if (semanticError) {
|
||||
setShowValidationErrors(true);
|
||||
toast.fromError(semanticError);
|
||||
return;
|
||||
}
|
||||
|
||||
// Unlike the create form, this modal only requires one non-empty tier, so a router can reach
|
||||
// here with nothing the backend would pick as a default (see getMissingTiersError in
|
||||
// build_complexity_router_config.ts for why create never can). init_complexity_router_deployment
|
||||
// raises in that case (litellm/router.py), so block it rather than saving a router that
|
||||
// fails at init.
|
||||
const defaultModel = resolveComplexityDefaultModel(tiers, complexityRouterConfig.default_model);
|
||||
if (!defaultModel) {
|
||||
setShowValidationErrors(true);
|
||||
toast.fromError(
|
||||
"Add a model to the Simple or Medium tier, or pin a default model, so requests have somewhere to route.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Dual write: complexity_router_config.default_model (the pin marker hydratePinnedDefaultModel
|
||||
// reads back) and complexity_router_default_model (what the backend routes on) must always be
|
||||
// written together from the same value. Same pairing in add_auto_router_tab.tsx.
|
||||
const updatedLitellmParams = {
|
||||
...modelData.litellm_params,
|
||||
auto_router_config: JSON.stringify(routerConfig),
|
||||
auto_router_default_model: values.auto_router_default_model,
|
||||
auto_router_embedding_model: values.auto_router_embedding_model || undefined,
|
||||
complexity_router_config: buildUpdatedComplexityRouterConfig(
|
||||
modelData.litellm_params?.complexity_router_config,
|
||||
complexityRouterConfig,
|
||||
customTechnicalKeywords,
|
||||
{
|
||||
keywordTierRules,
|
||||
escalationKeywords,
|
||||
semanticMatchingEnabled,
|
||||
embeddingModel,
|
||||
matchThreshold,
|
||||
},
|
||||
),
|
||||
complexity_router_default_model: defaultModel,
|
||||
};
|
||||
|
||||
// Prepare updated model_info
|
||||
const updatedModelInfo = {
|
||||
...modelData.model_info,
|
||||
access_groups: values.model_access_group || [],
|
||||
};
|
||||
|
||||
const updateData = {
|
||||
model_name: values.auto_router_name,
|
||||
litellm_params: updatedLitellmParams,
|
||||
model_info: updatedModelInfo,
|
||||
};
|
||||
await modelPatchUpdateCall(
|
||||
accessToken,
|
||||
{ model_name: values.auto_router_name, litellm_params: updatedLitellmParams, model_info: updatedModelInfo },
|
||||
modelData.model_info.id,
|
||||
);
|
||||
|
||||
await modelPatchUpdateCall(accessToken, updateData, modelData.model_info.id);
|
||||
|
||||
const updatedModelData = {
|
||||
toast.success("Auto router configuration updated successfully");
|
||||
onSuccess({
|
||||
...modelData,
|
||||
model_name: values.auto_router_name,
|
||||
litellm_params: updatedLitellmParams,
|
||||
model_info: updatedModelInfo,
|
||||
};
|
||||
|
||||
toast.success("Auto router configuration updated successfully");
|
||||
onSuccess(updatedModelData);
|
||||
});
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare the updated litellm_params
|
||||
const updatedLitellmParams = {
|
||||
...modelData.litellm_params,
|
||||
auto_router_config: JSON.stringify(routerConfig),
|
||||
auto_router_default_model: values.auto_router_default_model,
|
||||
auto_router_embedding_model: values.auto_router_embedding_model || undefined,
|
||||
};
|
||||
|
||||
// Prepare updated model_info
|
||||
const updatedModelInfo = {
|
||||
...modelData.model_info,
|
||||
access_groups: values.model_access_group || [],
|
||||
};
|
||||
|
||||
const updateData = {
|
||||
model_name: values.auto_router_name,
|
||||
litellm_params: updatedLitellmParams,
|
||||
model_info: updatedModelInfo,
|
||||
};
|
||||
|
||||
await modelPatchUpdateCall(accessToken, updateData, modelData.model_info.id);
|
||||
|
||||
const updatedModelData = {
|
||||
...modelData,
|
||||
model_name: values.auto_router_name,
|
||||
litellm_params: updatedLitellmParams,
|
||||
model_info: updatedModelInfo,
|
||||
};
|
||||
|
||||
toast.success("Auto router configuration updated successfully");
|
||||
onSuccess(updatedModelData);
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await form.handleSubmit(saveValues, () => {
|
||||
toast.fromError("Failed to update auto router configuration");
|
||||
})();
|
||||
} catch (error) {
|
||||
console.error("Error updating auto router:", error);
|
||||
toast.fromError("Failed to update auto router configuration");
|
||||
|
|
@ -501,128 +559,139 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
const modelOptions = modelInfo.map((model) => ({
|
||||
value: model.model_group,
|
||||
label: model.model_group,
|
||||
}));
|
||||
const modelChoices: ModelChoice[] = [
|
||||
...modelInfo.map((model) => ({ value: model.model_group, label: model.model_group })),
|
||||
{ value: "custom", label: "Enter custom model name" },
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open={isVisible} onOpenChange={(open) => !open && onCancel()}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Auto Router Configuration</DialogTitle>
|
||||
<DialogDescription>
|
||||
Edit the auto router configuration including routing logic, default models, and access settings.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<TooltipProvider>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Auto Router Configuration</DialogTitle>
|
||||
<DialogDescription>
|
||||
Edit the auto router configuration including routing logic, default models, and access settings.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form form={form} layout="vertical" className="space-y-4">
|
||||
{/* Auto Router Name */}
|
||||
<Form.Item
|
||||
label="Auto Router Name"
|
||||
name="auto_router_name"
|
||||
rules={[{ required: true, message: "Auto router name is required" }]}
|
||||
>
|
||||
<TextInput placeholder="e.g., auto_router_1, smart_routing" />
|
||||
</Form.Item>
|
||||
<form onSubmit={(event) => event.preventDefault()} noValidate>
|
||||
<FieldGroup>
|
||||
<FormField control={form.control} name="auto_router_name" label="Auto Router Name">
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="e.g., auto_router_1, smart_routing" />}
|
||||
</FormField>
|
||||
|
||||
{isComplexityRouterModel ? (
|
||||
/* Complexity Router Configuration */
|
||||
<div className="w-full">
|
||||
<ComplexityRouterConfig
|
||||
showValidationErrors={showValidationErrors}
|
||||
modelInfo={modelInfo}
|
||||
value={complexityRouterConfig}
|
||||
onChange={(config) => {
|
||||
setComplexityRouterConfig(config);
|
||||
}}
|
||||
customTechnicalKeywords={customTechnicalKeywords}
|
||||
onCustomTechnicalKeywordsChange={setCustomTechnicalKeywords}
|
||||
keywordTierRules={keywordTierRules}
|
||||
onKeywordTierRulesChange={setKeywordTierRules}
|
||||
semanticMatchingEnabled={semanticMatchingEnabled}
|
||||
onSemanticMatchingEnabledChange={setSemanticMatchingEnabled}
|
||||
embeddingModel={embeddingModel}
|
||||
onEmbeddingModelChange={setEmbeddingModel}
|
||||
matchThreshold={matchThreshold}
|
||||
onMatchThresholdChange={setMatchThreshold}
|
||||
escalationKeywords={escalationKeywords}
|
||||
onEscalationKeywordsChange={setEscalationKeywords}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Router Configuration Builder */}
|
||||
<div className="w-full">
|
||||
<RouterConfigBuilder
|
||||
modelInfo={modelInfo}
|
||||
value={routerConfig}
|
||||
onChange={(config) => {
|
||||
setRouterConfig(config);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{isComplexityRouterModel ? (
|
||||
/* Complexity Router Configuration */
|
||||
<div className="w-full">
|
||||
<ComplexityRouterConfig
|
||||
showValidationErrors={showValidationErrors}
|
||||
modelInfo={modelInfo}
|
||||
value={complexityRouterConfig}
|
||||
onChange={(config) => {
|
||||
setComplexityRouterConfig(config);
|
||||
}}
|
||||
customTechnicalKeywords={customTechnicalKeywords}
|
||||
onCustomTechnicalKeywordsChange={setCustomTechnicalKeywords}
|
||||
keywordTierRules={keywordTierRules}
|
||||
onKeywordTierRulesChange={setKeywordTierRules}
|
||||
semanticMatchingEnabled={semanticMatchingEnabled}
|
||||
onSemanticMatchingEnabledChange={setSemanticMatchingEnabled}
|
||||
embeddingModel={embeddingModel}
|
||||
onEmbeddingModelChange={setEmbeddingModel}
|
||||
matchThreshold={matchThreshold}
|
||||
onMatchThresholdChange={setMatchThreshold}
|
||||
escalationKeywords={escalationKeywords}
|
||||
onEscalationKeywordsChange={setEscalationKeywords}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Router Configuration Builder */}
|
||||
<div className="w-full">
|
||||
<RouterConfigBuilder
|
||||
modelInfo={modelInfo}
|
||||
value={routerConfig}
|
||||
onChange={(config) => {
|
||||
setRouterConfig(config);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Default Model */}
|
||||
<Form.Item
|
||||
label="Default Model"
|
||||
name="auto_router_default_model"
|
||||
rules={[{ required: true, message: "Default model is required" }]}
|
||||
>
|
||||
<AntdSelect
|
||||
placeholder="Select a default model"
|
||||
options={[...modelOptions, { value: "custom", label: "Enter custom model name" }]}
|
||||
showSearch={true}
|
||||
/>
|
||||
</Form.Item>
|
||||
<FormField control={form.control} name="auto_router_default_model" label="Default Model">
|
||||
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
|
||||
<ModelChoiceCombobox
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
choices={modelChoices}
|
||||
placeholder="Select a default model"
|
||||
ariaInvalid={ariaInvalid}
|
||||
ariaDescribedBy={ariaDescribedBy}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
{/* Embedding Model */}
|
||||
<Form.Item
|
||||
label="Embedding Model"
|
||||
name="auto_router_embedding_model"
|
||||
rules={[{ required: true, message: "Embedding model is required" }]}
|
||||
>
|
||||
<AntdSelect
|
||||
placeholder="Select an embedding model"
|
||||
options={[...modelOptions, { value: "custom", label: "Enter custom model name" }]}
|
||||
showSearch={true}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
<FormField control={form.control} name="auto_router_embedding_model" label="Embedding Model">
|
||||
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
|
||||
<ModelChoiceCombobox
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
choices={modelChoices}
|
||||
placeholder="Select an embedding model"
|
||||
ariaInvalid={ariaInvalid}
|
||||
ariaDescribedBy={ariaDescribedBy}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Model Access Groups - Admin only */}
|
||||
{userRole === "Admin" && (
|
||||
<Form.Item
|
||||
label="Model Access Groups"
|
||||
name="model_access_group"
|
||||
tooltip="Control who can access this auto router"
|
||||
>
|
||||
<AntdSelect
|
||||
mode="tags"
|
||||
showSearch
|
||||
placeholder="Select existing groups or type to create new ones"
|
||||
optionFilterProp="children"
|
||||
tokenSeparators={[","]}
|
||||
options={modelAccessGroups.map((group) => ({
|
||||
value: group,
|
||||
label: group,
|
||||
}))}
|
||||
maxTagCount="responsive"
|
||||
allowClear
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
{userRole === "Admin" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="model_access_group"
|
||||
label={labelWithHint("Model Access Groups", "Control who can access this auto router")}
|
||||
>
|
||||
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
|
||||
<AccessGroupTagsCombobox
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={modelAccessGroups}
|
||||
ariaInvalid={ariaInvalid}
|
||||
ariaDescribedBy={ariaDescribedBy}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
)}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={onCancel}>Cancel</Button>
|
||||
<Tooltip title={submitBlockedReason}>
|
||||
<Button loading={loading} disabled={submitBlockedReason !== null} onClick={handleSubmit}>
|
||||
Save Changes
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</DialogFooter>
|
||||
{submitBlockedReason === null ? (
|
||||
<Button disabled={loading} onClick={handleSubmit}>
|
||||
{loading && <UiLoadingSpinner className="size-4" />}
|
||||
Save Changes
|
||||
</Button>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button disabled onClick={handleSubmit}>
|
||||
Save Changes
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>{submitBlockedReason}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</TooltipProvider>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,117 @@
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders, screen } from "@/../tests/test-utils";
|
||||
|
||||
import type { CredentialItem } from "../networking";
|
||||
import ReuseCredentialsModal from "./reuse_credentials";
|
||||
|
||||
const EXISTING_CREDENTIAL: CredentialItem = {
|
||||
credential_name: "openai-prod",
|
||||
credential_values: { api_key: "sk-stored-value", api_base: "https://api.example.com" },
|
||||
credential_info: { custom_llm_provider: "openai" },
|
||||
};
|
||||
|
||||
const renderModal = (existingCredential: CredentialItem | null = EXISTING_CREDENTIAL) => {
|
||||
const onAddCredential = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
const setIsCredentialModalOpen = vi.fn();
|
||||
renderWithProviders(
|
||||
<ReuseCredentialsModal
|
||||
isVisible
|
||||
onCancel={onCancel}
|
||||
onAddCredential={onAddCredential}
|
||||
existingCredential={existingCredential}
|
||||
setIsCredentialModalOpen={setIsCredentialModalOpen}
|
||||
/>,
|
||||
);
|
||||
return { onAddCredential, onCancel, setIsCredentialModalOpen };
|
||||
};
|
||||
|
||||
const submit = async (user: ReturnType<typeof userEvent.setup>) =>
|
||||
await user.click(screen.getByRole("button", { name: "Reuse Credentials" }));
|
||||
|
||||
describe("ReuseCredentialsModal", () => {
|
||||
it("submits the typed name alongside every stored credential value", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onAddCredential, setIsCredentialModalOpen } = renderModal();
|
||||
|
||||
const nameInput = screen.getByLabelText("Credential Name:");
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, "reused-openai");
|
||||
await submit(user);
|
||||
|
||||
expect(onAddCredential).toHaveBeenCalledTimes(1);
|
||||
expect(onAddCredential).toHaveBeenCalledWith({
|
||||
credential_name: "reused-openai",
|
||||
api_key: "sk-stored-value",
|
||||
api_base: "https://api.example.com",
|
||||
});
|
||||
expect(setIsCredentialModalOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("seeds the name from the existing credential and submits it untouched", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onAddCredential } = renderModal();
|
||||
|
||||
expect(screen.getByLabelText("Credential Name:")).toHaveValue("openai-prod");
|
||||
await submit(user);
|
||||
|
||||
expect(onAddCredential).toHaveBeenCalledWith({
|
||||
credential_name: "openai-prod",
|
||||
api_key: "sk-stored-value",
|
||||
api_base: "https://api.example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the stored values as read-only inputs", () => {
|
||||
renderModal();
|
||||
|
||||
expect(screen.getByLabelText("api_key")).toBeDisabled();
|
||||
expect(screen.getByLabelText("api_key")).toHaveValue("sk-stored-value");
|
||||
expect(screen.getByLabelText("api_base")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("blocks the submit and shows the required message when the name is cleared", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onAddCredential } = renderModal();
|
||||
|
||||
await user.clear(screen.getByLabelText("Credential Name:"));
|
||||
await submit(user);
|
||||
|
||||
expect(await screen.findByText("Credential name is required")).toBeInTheDocument();
|
||||
expect(onAddCredential).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("submits only the name when the credential carries no stored values", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onAddCredential } = renderModal({
|
||||
credential_name: "bare",
|
||||
credential_values: {},
|
||||
credential_info: {},
|
||||
});
|
||||
|
||||
await submit(user);
|
||||
|
||||
expect(onAddCredential).toHaveBeenCalledWith({ credential_name: "bare" });
|
||||
});
|
||||
|
||||
it("closes without submitting when Cancel is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onAddCredential, onCancel } = renderModal();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
expect(onAddCredential).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("submits on Enter from the name field", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onAddCredential } = renderModal();
|
||||
|
||||
await user.type(screen.getByLabelText("Credential Name:"), "{Enter}");
|
||||
|
||||
expect(onAddCredential).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,17 +1,33 @@
|
|||
import React from "react";
|
||||
import { Form, Button, Tooltip, Typography, Modal } from "antd";
|
||||
import { TextInput } from "@tremor/react";
|
||||
import { Modal } from "antd";
|
||||
import { z } from "zod/v4";
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/shared/form/field";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
import { CredentialItem } from "../networking";
|
||||
const { Link } = Typography;
|
||||
|
||||
interface ReuseCredentialsModalProps {
|
||||
isVisible: boolean;
|
||||
onCancel: () => void;
|
||||
onAddCredential: (values: any) => void;
|
||||
onAddCredential: (values: Record<string, unknown>) => void;
|
||||
existingCredential: CredentialItem | null;
|
||||
setIsCredentialModalOpen: (isVisible: boolean) => void;
|
||||
}
|
||||
|
||||
const reuseCredentialsSchema = z.object({
|
||||
credential_name: z.string().min(1, "Credential name is required"),
|
||||
});
|
||||
|
||||
type ReuseCredentialsFormValues = z.infer<typeof reuseCredentialsSchema>;
|
||||
|
||||
const storedValuesOf = (existingCredential: CredentialItem | null): Record<string, unknown> => {
|
||||
const values: unknown = existingCredential?.credential_values;
|
||||
return typeof values === "object" && values !== null ? (values as Record<string, unknown>) : {};
|
||||
};
|
||||
|
||||
const ReuseCredentialsModal: React.FC<ReuseCredentialsModalProps> = ({
|
||||
isVisible,
|
||||
onCancel,
|
||||
|
|
@ -19,63 +35,72 @@ const ReuseCredentialsModal: React.FC<ReuseCredentialsModalProps> = ({
|
|||
existingCredential,
|
||||
setIsCredentialModalOpen,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const fieldIdPrefix = React.useId();
|
||||
const storedValues = storedValuesOf(existingCredential);
|
||||
const form = useZodForm(reuseCredentialsSchema, {
|
||||
defaultValues: { credential_name: existingCredential?.credential_name ?? "" },
|
||||
});
|
||||
|
||||
const handleSubmit = (values: any) => {
|
||||
onAddCredential(values);
|
||||
form.resetFields();
|
||||
const handleSubmit = (values: ReuseCredentialsFormValues) => {
|
||||
onAddCredential({ ...storedValues, ...values });
|
||||
form.reset();
|
||||
setIsCredentialModalOpen(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
onCancel();
|
||||
form.reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Reuse Credentials"
|
||||
open={isVisible}
|
||||
onCancel={() => {
|
||||
onCancel();
|
||||
form.resetFields();
|
||||
}}
|
||||
footer={null}
|
||||
width={600}
|
||||
>
|
||||
<Form form={form} onFinish={handleSubmit} layout="vertical">
|
||||
{/* Credential Name */}
|
||||
<Form.Item
|
||||
label="Credential Name:"
|
||||
name="credential_name"
|
||||
rules={[{ required: true, message: "Credential name is required" }]}
|
||||
initialValue={existingCredential?.credential_name}
|
||||
>
|
||||
<TextInput placeholder="Enter a friendly name for these credentials" />
|
||||
</Form.Item>
|
||||
<Modal title="Reuse Credentials" open={isVisible} onCancel={handleCancel} footer={null} width={600}>
|
||||
<TooltipProvider>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} noValidate>
|
||||
<FieldGroup>
|
||||
<FormField control={form.control} name="credential_name" label="Credential Name:">
|
||||
{({ ref, ...field }) => (
|
||||
<Input {...field} ref={ref} placeholder="Enter a friendly name for these credentials" />
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
{/* Display Credential Values of existingCredential, don't allow user to edit. Credential values is a dictionary */}
|
||||
{Object.entries(existingCredential?.credential_values || {}).map(([key, value]) => (
|
||||
<Form.Item key={key} label={key} name={key} initialValue={value}>
|
||||
<TextInput placeholder={`Enter ${key}`} disabled={true} />
|
||||
</Form.Item>
|
||||
))}
|
||||
{Object.entries(storedValues).map(([key, value]) => (
|
||||
<Field key={key}>
|
||||
<FieldLabel htmlFor={`${fieldIdPrefix}-${key}`}>{key}</FieldLabel>
|
||||
<Input
|
||||
id={`${fieldIdPrefix}-${key}`}
|
||||
value={String(value)}
|
||||
placeholder={`Enter ${key}`}
|
||||
disabled
|
||||
readOnly
|
||||
/>
|
||||
</Field>
|
||||
))}
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="flex justify-between items-center">
|
||||
<Tooltip title="Get help on our github">
|
||||
<Link href="https://github.com/BerriAI/litellm/issues">Need Help?</Link>
|
||||
</Tooltip>
|
||||
<div className="flex items-center justify-between">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<a
|
||||
href="https://github.com/BerriAI/litellm/issues"
|
||||
className="text-sm text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
Need Help?
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>Get help on our github</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
onCancel();
|
||||
form.resetFields();
|
||||
}}
|
||||
style={{ marginRight: 10 }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button htmlType="submit">Reuse Credentials</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
<div className="flex gap-2.5">
|
||||
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">Reuse Credentials</Button>
|
||||
</div>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</TooltipProvider>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,296 @@
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders, screen } from "@/../tests/test-utils";
|
||||
|
||||
import RoutingGroupModal from "./RoutingGroupModal";
|
||||
import type { RoutingGroup } from "./types";
|
||||
|
||||
const STRATEGIES = ["simple-shuffle", "latency-based-routing", "usage-based-routing"];
|
||||
const MODEL_OPTIONS = ["gpt-4o", "claude-sonnet", "gemini-pro"];
|
||||
const STRATEGY_DESCRIPTIONS = { "simple-shuffle": "Spreads requests evenly across the group." };
|
||||
|
||||
const EXPECTED_STORED_PAYLOAD: RoutingGroup = {
|
||||
group_name: "already-taken",
|
||||
models: ["gpt-4o", "claude-sonnet"],
|
||||
routing_strategy: "latency-based-routing",
|
||||
routing_strategy_args: { ttl: 3600 },
|
||||
};
|
||||
|
||||
const SEEDED_CREATE: RoutingGroup = { group_name: "", models: ["gemini-pro"], routing_strategy: "simple-shuffle" };
|
||||
|
||||
const STORED_GROUP: RoutingGroup = {
|
||||
group_name: "already-taken",
|
||||
models: ["gpt-4o", "claude-sonnet"],
|
||||
routing_strategy: "latency-based-routing",
|
||||
routing_strategy_args: { ttl: 3600 },
|
||||
};
|
||||
|
||||
const STORED_GROUP_NULL_ARGS: RoutingGroup = {
|
||||
group_name: "already-taken",
|
||||
models: ["gpt-4o"],
|
||||
routing_strategy: "latency-based-routing",
|
||||
routing_strategy_args: null,
|
||||
};
|
||||
|
||||
const EXPECTED_NULL_ARGS_PAYLOAD: RoutingGroup = {
|
||||
group_name: "already-taken",
|
||||
models: ["gpt-4o"],
|
||||
routing_strategy: "latency-based-routing",
|
||||
routing_strategy_args: null,
|
||||
};
|
||||
|
||||
const renderModal = (overrides: Partial<React.ComponentProps<typeof RoutingGroupModal>> = {}) => {
|
||||
const onSubmit = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
renderWithProviders(
|
||||
<RoutingGroupModal
|
||||
open
|
||||
mode="create"
|
||||
initialValue={null}
|
||||
availableStrategies={STRATEGIES}
|
||||
strategyDescriptions={STRATEGY_DESCRIPTIONS}
|
||||
modelOptions={MODEL_OPTIONS}
|
||||
existingGroupNames={["already-taken", "other-group"]}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
return { onSubmit, onClose };
|
||||
};
|
||||
|
||||
const typeName = async (user: ReturnType<typeof userEvent.setup>, name: string) => {
|
||||
const input = screen.getByLabelText("Group Name");
|
||||
await user.clear(input);
|
||||
await user.type(input, name);
|
||||
};
|
||||
|
||||
const setArgs = async (user: ReturnType<typeof userEvent.setup>, json: string) => {
|
||||
const textarea = screen.getByLabelText("Strategy Arguments (JSON)");
|
||||
await user.clear(textarea);
|
||||
if (json) {
|
||||
await user.type(textarea, json);
|
||||
}
|
||||
};
|
||||
|
||||
const pickModels = async (user: ReturnType<typeof userEvent.setup>, ...models: string[]) => {
|
||||
await user.click(screen.getByLabelText("Models"));
|
||||
for (const model of models) {
|
||||
await user.click(await screen.findByRole("option", { name: model }));
|
||||
}
|
||||
};
|
||||
|
||||
const pickStrategy = async (user: ReturnType<typeof userEvent.setup>, strategy: string) => {
|
||||
await user.click(screen.getByLabelText("Routing Strategy"));
|
||||
await user.click(await screen.findByRole("option", { name: strategy }));
|
||||
};
|
||||
|
||||
const save = async (user: ReturnType<typeof userEvent.setup>, name: string) =>
|
||||
await user.click(screen.getByRole("button", { name }));
|
||||
|
||||
describe("RoutingGroupModal", () => {
|
||||
it("submits an untouched edit of a group whose stored arguments are null", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSubmit } = renderModal({ mode: "edit", initialValue: STORED_GROUP_NULL_ARGS });
|
||||
|
||||
await save(user, "Save Changes");
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith(EXPECTED_NULL_ARGS_PAYLOAD);
|
||||
});
|
||||
|
||||
it("submits an untouched edit with the stored models, strategy and parsed arguments", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSubmit } = renderModal({ mode: "edit", initialValue: STORED_GROUP });
|
||||
|
||||
await save(user, "Save Changes");
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
expect(onSubmit.mock.calls[0][0]).toStrictEqual(EXPECTED_STORED_PAYLOAD);
|
||||
});
|
||||
|
||||
it("carries a typed group name into the payload", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSubmit } = renderModal({ initialValue: SEEDED_CREATE });
|
||||
|
||||
await typeName(user, "fast-chat");
|
||||
await save(user, "Create Group");
|
||||
|
||||
const expected: RoutingGroup = {
|
||||
group_name: "fast-chat",
|
||||
models: ["gemini-pro"],
|
||||
routing_strategy: "simple-shuffle",
|
||||
routing_strategy_args: null,
|
||||
};
|
||||
expect(onSubmit.mock.calls[0][0]).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it("sends null arguments when the selected strategy does not take them", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSubmit } = renderModal({
|
||||
mode: "edit",
|
||||
initialValue: { ...STORED_GROUP, routing_strategy: "simple-shuffle" },
|
||||
});
|
||||
|
||||
expect(screen.queryByLabelText("Strategy Arguments (JSON)")).not.toBeInTheDocument();
|
||||
await save(user, "Save Changes");
|
||||
|
||||
const expected: RoutingGroup = {
|
||||
group_name: "already-taken",
|
||||
models: ["gpt-4o", "claude-sonnet"],
|
||||
routing_strategy: "simple-shuffle",
|
||||
routing_strategy_args: null,
|
||||
};
|
||||
expect(onSubmit.mock.calls[0][0]).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it("sends null arguments when the argument box is emptied", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSubmit } = renderModal({ mode: "edit", initialValue: STORED_GROUP });
|
||||
|
||||
await setArgs(user, "");
|
||||
await save(user, "Save Changes");
|
||||
|
||||
expect(onSubmit.mock.calls[0][0]?.routing_strategy_args).toBeNull();
|
||||
});
|
||||
|
||||
it("edits the arguments into the payload", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSubmit } = renderModal({ mode: "edit", initialValue: STORED_GROUP });
|
||||
|
||||
await setArgs(user, '{{"ttl": 60, "lowest_latency_buffer": 0}');
|
||||
await save(user, "Save Changes");
|
||||
|
||||
expect(onSubmit.mock.calls[0][0]?.routing_strategy_args).toStrictEqual({ ttl: 60, lowest_latency_buffer: 0 });
|
||||
});
|
||||
|
||||
it("blocks the save and flags the field when the arguments are not valid JSON", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSubmit } = renderModal({ mode: "edit", initialValue: STORED_GROUP });
|
||||
|
||||
await setArgs(user, "not json");
|
||||
await save(user, "Save Changes");
|
||||
|
||||
expect(await screen.findByText("Must be valid JSON")).toBeInTheDocument();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires a group name", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSubmit } = renderModal({
|
||||
initialValue: { group_name: "", models: ["gemini-pro"], routing_strategy: "simple-shuffle" },
|
||||
});
|
||||
|
||||
await save(user, "Create Group");
|
||||
|
||||
expect(await screen.findByText("Group name is required")).toBeInTheDocument();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires at least one model", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSubmit } = renderModal();
|
||||
|
||||
await typeName(user, "no-models");
|
||||
await save(user, "Create Group");
|
||||
|
||||
expect(await screen.findByText("Select at least one model")).toBeInTheDocument();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a name longer than 64 characters", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSubmit } = renderModal({
|
||||
initialValue: { group_name: "", models: ["gemini-pro"], routing_strategy: "simple-shuffle" },
|
||||
});
|
||||
|
||||
await typeName(user, "a".repeat(65));
|
||||
await save(user, "Create Group");
|
||||
|
||||
expect(await screen.findByText("Must be 64 characters or fewer")).toBeInTheDocument();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a name with characters outside the allowed set", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSubmit } = renderModal({
|
||||
initialValue: { group_name: "", models: ["gemini-pro"], routing_strategy: "simple-shuffle" },
|
||||
});
|
||||
|
||||
await typeName(user, "bad name");
|
||||
await save(user, "Create Group");
|
||||
|
||||
expect(await screen.findByText("Only letters, numbers, dot, underscore, and dash are allowed")).toBeInTheDocument();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a name another group already uses, ignoring case", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSubmit } = renderModal({
|
||||
initialValue: { group_name: "", models: ["gemini-pro"], routing_strategy: "simple-shuffle" },
|
||||
});
|
||||
|
||||
await typeName(user, "Other-Group");
|
||||
await save(user, "Create Group");
|
||||
|
||||
expect(await screen.findByText("A group with this name already exists")).toBeInTheDocument();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("locks the name in edit mode and pretty-prints the stored arguments", () => {
|
||||
renderModal({ mode: "edit", initialValue: STORED_GROUP });
|
||||
|
||||
expect(screen.getByLabelText("Group Name")).toHaveValue("already-taken");
|
||||
expect(screen.getByLabelText("Group Name")).toBeDisabled();
|
||||
expect(screen.getByLabelText("Strategy Arguments (JSON)")).toHaveValue('{\n "ttl": 3600\n}');
|
||||
});
|
||||
|
||||
it("carries picked models and a picked strategy into the payload", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSubmit } = renderModal();
|
||||
|
||||
await typeName(user, "probe-group");
|
||||
await pickModels(user, "gpt-4o", "claude-sonnet");
|
||||
await pickStrategy(user, "latency-based-routing");
|
||||
await setArgs(user, '{{"ttl": 99}');
|
||||
await save(user, "Create Group");
|
||||
|
||||
const expected: RoutingGroup = {
|
||||
group_name: "probe-group",
|
||||
models: ["gpt-4o", "claude-sonnet"],
|
||||
routing_strategy: "latency-based-routing",
|
||||
routing_strategy_args: { ttl: 99 },
|
||||
};
|
||||
expect(onSubmit.mock.calls[0][0]).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it("forgets arguments typed before the strategy stopped taking them", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSubmit } = renderModal();
|
||||
|
||||
await typeName(user, "probe-group");
|
||||
await pickModels(user, "gpt-4o");
|
||||
await pickStrategy(user, "latency-based-routing");
|
||||
await setArgs(user, '{{"ttl": 99}');
|
||||
await pickStrategy(user, "simple-shuffle");
|
||||
expect(screen.queryByLabelText("Strategy Arguments (JSON)")).not.toBeInTheDocument();
|
||||
await pickStrategy(user, "latency-based-routing");
|
||||
|
||||
expect(screen.getByLabelText("Strategy Arguments (JSON)")).toHaveValue("");
|
||||
|
||||
await save(user, "Create Group");
|
||||
const expected: RoutingGroup = {
|
||||
group_name: "probe-group",
|
||||
models: ["gpt-4o"],
|
||||
routing_strategy: "latency-based-routing",
|
||||
routing_strategy_args: null,
|
||||
};
|
||||
expect(onSubmit.mock.calls[0][0]).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it("describes the selected strategy", async () => {
|
||||
renderModal();
|
||||
|
||||
expect(await screen.findByText("Spreads requests evenly across the group.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,10 +1,36 @@
|
|||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { Form, Input, Modal, Select, Space, Typography } from "antd";
|
||||
import type { RoutingGroup, RoutingStrategy } from "./types";
|
||||
|
||||
const { Text, Paragraph } = Typography;
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import { Modal } from "antd";
|
||||
import { useWatch } from "react-hook-form";
|
||||
import { z } from "zod/v4";
|
||||
import { FieldGroup } from "@/components/shared/form/field";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxChip,
|
||||
ComboboxChips,
|
||||
ComboboxChipsInput,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
} from "@/components/ui/combobox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
import {
|
||||
GROUP_NAME_MAX_LENGTH,
|
||||
GROUP_NAME_PATTERN,
|
||||
STRATEGIES_WITH_ARGS,
|
||||
argsForStrategy,
|
||||
buildRoutingGroupPayload,
|
||||
toRoutingGroupFormValues,
|
||||
} from "./routingGroupPayload";
|
||||
import type { RoutingGroup } from "./types";
|
||||
|
||||
interface RoutingGroupModalProps {
|
||||
open: boolean;
|
||||
|
|
@ -19,17 +45,9 @@ interface RoutingGroupModalProps {
|
|||
saving?: boolean;
|
||||
}
|
||||
|
||||
interface FormValues {
|
||||
group_name: string;
|
||||
models: string[];
|
||||
routing_strategy: RoutingStrategy | string;
|
||||
routing_strategy_args?: string;
|
||||
}
|
||||
|
||||
const STRATEGIES_WITH_ARGS = new Set<string>(["latency-based-routing", "usage-based-routing"]);
|
||||
|
||||
const GROUP_NAME_PATTERN = /^[A-Za-z0-9._-]+$/;
|
||||
const GROUP_NAME_MAX_LENGTH = 64;
|
||||
const ARGS_EXAMPLES: Record<string, string> = {
|
||||
"latency-based-routing": 'Example: { "ttl": 3600, "lowest_latency_buffer": 0 }',
|
||||
};
|
||||
|
||||
const RoutingGroupModal: React.FC<RoutingGroupModalProps> = ({
|
||||
open,
|
||||
|
|
@ -43,47 +61,44 @@ const RoutingGroupModal: React.FC<RoutingGroupModalProps> = ({
|
|||
onSubmit,
|
||||
saving,
|
||||
}) => {
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const selectedStrategy = Form.useWatch("routing_strategy", form);
|
||||
|
||||
const initialValues: FormValues = {
|
||||
group_name: initialValue?.group_name ?? "",
|
||||
models: initialValue?.models ?? [],
|
||||
routing_strategy: initialValue?.routing_strategy ?? availableStrategies[0] ?? "simple-shuffle",
|
||||
routing_strategy_args: initialValue?.routing_strategy_args
|
||||
? JSON.stringify(initialValue.routing_strategy_args, null, 2)
|
||||
: "",
|
||||
};
|
||||
const modelsAnchor = useComboboxAnchor();
|
||||
const strategyItems = availableStrategies.map((strategy) => ({ label: strategy, value: strategy }));
|
||||
|
||||
const reservedNames = useMemo(() => {
|
||||
const others = existingGroupNames.filter((n) => n !== initialValue?.group_name);
|
||||
return new Set(others.map((n) => n.toLowerCase()));
|
||||
}, [existingGroupNames, initialValue]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const values = await form.validateFields();
|
||||
const strategySupportsArgs = STRATEGIES_WITH_ARGS.has(String(values.routing_strategy));
|
||||
let parsedArgs: Record<string, unknown> | null = null;
|
||||
if (strategySupportsArgs && values.routing_strategy_args && values.routing_strategy_args.trim()) {
|
||||
try {
|
||||
parsedArgs = JSON.parse(values.routing_strategy_args);
|
||||
} catch {
|
||||
form.setFields([
|
||||
{
|
||||
name: "routing_strategy_args",
|
||||
errors: ["Must be valid JSON"],
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const schema = useMemo(() => {
|
||||
const shape = {
|
||||
group_name: z
|
||||
.string()
|
||||
.min(1, "Group name is required")
|
||||
.max(GROUP_NAME_MAX_LENGTH, `Must be ${GROUP_NAME_MAX_LENGTH} characters or fewer`)
|
||||
.regex(GROUP_NAME_PATTERN, "Only letters, numbers, dot, underscore, and dash are allowed")
|
||||
.refine((value) => !reservedNames.has(value.trim().toLowerCase()), "A group with this name already exists"),
|
||||
models: z.array(z.string()).min(1, "Select at least one model"),
|
||||
routing_strategy: z.string().min(1, "Strategy is required"),
|
||||
routing_strategy_args: z.string(),
|
||||
};
|
||||
return z.object(shape);
|
||||
}, [reservedNames]);
|
||||
|
||||
await onSubmit({
|
||||
group_name: values.group_name.trim(),
|
||||
models: values.models,
|
||||
routing_strategy: values.routing_strategy,
|
||||
routing_strategy_args: parsedArgs,
|
||||
});
|
||||
const form = useZodForm(schema, { defaultValues: toRoutingGroupFormValues(initialValue, availableStrategies) });
|
||||
|
||||
useEffect(() => {
|
||||
form.reset(toRoutingGroupFormValues(initialValue, availableStrategies));
|
||||
}, [open, initialValue, availableStrategies, form]);
|
||||
|
||||
const selectedStrategy = useWatch({ control: form.control, name: "routing_strategy" });
|
||||
|
||||
const handleSubmit = async (values: z.infer<typeof schema>) => {
|
||||
const payload = buildRoutingGroupPayload(values);
|
||||
if (!payload.ok) {
|
||||
form.setError("routing_strategy_args", { message: payload.argsError });
|
||||
return;
|
||||
}
|
||||
await onSubmit(payload.group);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -91,92 +106,115 @@ const RoutingGroupModal: React.FC<RoutingGroupModalProps> = ({
|
|||
title={mode === "create" ? "Create Routing Group" : `Edit ${initialValue?.group_name ?? ""}`}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
onOk={handleSubmit}
|
||||
onOk={() => void form.handleSubmit(handleSubmit)()}
|
||||
okText={mode === "create" ? "Create Group" : "Save Changes"}
|
||||
cancelText="Cancel"
|
||||
confirmLoading={saving}
|
||||
destroyOnClose
|
||||
width={560}
|
||||
>
|
||||
<Form<FormValues>
|
||||
key={mode === "edit" ? `edit-${initialValue?.group_name ?? ""}` : "create"}
|
||||
form={form}
|
||||
layout="vertical"
|
||||
preserve={false}
|
||||
initialValues={initialValues}
|
||||
>
|
||||
<Form.Item
|
||||
label="Group Name"
|
||||
name="group_name"
|
||||
rules={[
|
||||
{ required: true, message: "Group name is required" },
|
||||
{ max: GROUP_NAME_MAX_LENGTH, message: `Must be ${GROUP_NAME_MAX_LENGTH} characters or fewer` },
|
||||
{
|
||||
pattern: GROUP_NAME_PATTERN,
|
||||
message: "Only letters, numbers, dot, underscore, and dash are allowed",
|
||||
},
|
||||
{
|
||||
validator: (_, value: string) => {
|
||||
if (!value) return Promise.resolve();
|
||||
if (reservedNames.has(value.trim().toLowerCase())) {
|
||||
return Promise.reject(new Error("A group with this name already exists"));
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
]}
|
||||
extra="Use this name as the model in API calls — LiteLLM routes the request to one of the group's models."
|
||||
>
|
||||
<Input placeholder="fast-chat" disabled={mode === "edit"} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Models"
|
||||
name="models"
|
||||
rules={[{ required: true, message: "Select at least one model" }]}
|
||||
extra="Models from your model list that this group routes between."
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
placeholder="Select models"
|
||||
options={modelOptions.map((m) => ({ label: m, value: m }))}
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Routing Strategy"
|
||||
name="routing_strategy"
|
||||
rules={[{ required: true, message: "Strategy is required" }]}
|
||||
>
|
||||
<Select options={availableStrategies.map((s) => ({ label: s, value: s }))} placeholder="Select strategy" />
|
||||
</Form.Item>
|
||||
|
||||
{selectedStrategy && strategyDescriptions[selectedStrategy] && (
|
||||
<Paragraph className="text-xs text-gray-500 -mt-2 mb-4">{strategyDescriptions[selectedStrategy]}</Paragraph>
|
||||
)}
|
||||
|
||||
{STRATEGIES_WITH_ARGS.has(String(selectedStrategy)) && (
|
||||
<Form.Item
|
||||
label="Strategy Arguments (JSON)"
|
||||
name="routing_strategy_args"
|
||||
extra={
|
||||
selectedStrategy === "latency-based-routing"
|
||||
? 'Example: { "ttl": 3600, "lowest_latency_buffer": 0 }'
|
||||
: 'Example: { "ttl": 60 }'
|
||||
}
|
||||
<form onSubmit={(event) => event.preventDefault()} noValidate>
|
||||
<FieldGroup>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="group_name"
|
||||
label="Group Name"
|
||||
description="Use this name as the model in API calls — LiteLLM routes the request to one of the group's models."
|
||||
>
|
||||
<Input.TextArea rows={4} placeholder='{ "ttl": 3600 }' className="font-mono text-xs" />
|
||||
</Form.Item>
|
||||
)}
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="fast-chat" disabled={mode === "edit"} />}
|
||||
</FormField>
|
||||
|
||||
<Space direction="vertical" className="w-full mt-2">
|
||||
<Text type="secondary" className="text-xs">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="models"
|
||||
label="Models"
|
||||
description="Models from your model list that this group routes between."
|
||||
>
|
||||
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
|
||||
<Combobox multiple items={modelOptions} value={value} onValueChange={onChange}>
|
||||
<ComboboxChips render={<div ref={modelsAnchor} />}>
|
||||
<ComboboxValue>
|
||||
{(selected: string[]) => (
|
||||
<>
|
||||
{selected.map((model) => (
|
||||
<ComboboxChip key={model} aria-label={model}>
|
||||
{model}
|
||||
</ComboboxChip>
|
||||
))}
|
||||
<ComboboxChipsInput
|
||||
id={id}
|
||||
aria-invalid={ariaInvalid}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
placeholder="Select models"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ComboboxValue>
|
||||
</ComboboxChips>
|
||||
<ComboboxContent anchor={modelsAnchor}>
|
||||
<ComboboxEmpty>No models found</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(model: string) => (
|
||||
<ComboboxItem key={model} value={model}>
|
||||
{model}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="routing_strategy"
|
||||
label="Routing Strategy"
|
||||
description={strategyDescriptions[selectedStrategy]}
|
||||
>
|
||||
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
|
||||
<Select
|
||||
items={strategyItems}
|
||||
value={value}
|
||||
onValueChange={(next: string | null) => {
|
||||
onChange(next ?? "");
|
||||
form.setValue(
|
||||
"routing_strategy_args",
|
||||
argsForStrategy(next ?? "", form.getValues("routing_strategy_args")),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id={id} aria-invalid={ariaInvalid} aria-describedby={ariaDescribedBy}>
|
||||
<SelectValue placeholder="Select strategy" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableStrategies.map((strategy) => (
|
||||
<SelectItem key={strategy} value={strategy}>
|
||||
{strategy}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
{STRATEGIES_WITH_ARGS.has(selectedStrategy) && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="routing_strategy_args"
|
||||
label="Strategy Arguments (JSON)"
|
||||
description={ARGS_EXAMPLES[selectedStrategy] ?? 'Example: { "ttl": 60 }'}
|
||||
>
|
||||
{({ ref, ...field }) => (
|
||||
<Textarea {...field} ref={ref} rows={4} placeholder='{ "ttl": 3600 }' className="font-mono text-xs" />
|
||||
)}
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Models not claimed by an explicit group fall through to the proxy's top-level routing strategy.
|
||||
</Text>
|
||||
</Space>
|
||||
</Form>
|
||||
</p>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,153 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { RoutingGroup } from "./types";
|
||||
import {
|
||||
argsForStrategy,
|
||||
buildRoutingGroupPayload,
|
||||
toRoutingGroupFormValues,
|
||||
type RoutingGroupFormValues,
|
||||
} from "./routingGroupPayload";
|
||||
|
||||
const values = (overrides: Partial<RoutingGroupFormValues> = {}): RoutingGroupFormValues => ({
|
||||
group_name: "fast-chat",
|
||||
models: ["gpt-4o"],
|
||||
routing_strategy: "simple-shuffle",
|
||||
routing_strategy_args: "",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("buildRoutingGroupPayload", () => {
|
||||
it("sends a null args key for a strategy that takes no arguments", () => {
|
||||
expect(buildRoutingGroupPayload(values())).toStrictEqual({
|
||||
ok: true,
|
||||
group: {
|
||||
group_name: "fast-chat",
|
||||
models: ["gpt-4o"],
|
||||
routing_strategy: "simple-shuffle",
|
||||
routing_strategy_args: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("parses the arguments for latency based routing", () => {
|
||||
const result = buildRoutingGroupPayload(
|
||||
values({ routing_strategy: "latency-based-routing", routing_strategy_args: '{"ttl": 3600}' }),
|
||||
);
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
ok: true,
|
||||
group: {
|
||||
group_name: "fast-chat",
|
||||
models: ["gpt-4o"],
|
||||
routing_strategy: "latency-based-routing",
|
||||
routing_strategy_args: { ttl: 3600 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("parses the arguments for usage based routing", () => {
|
||||
const result = buildRoutingGroupPayload(
|
||||
values({ routing_strategy: "usage-based-routing", routing_strategy_args: '{"ttl": 60}' }),
|
||||
);
|
||||
|
||||
expect(result.ok && result.group.routing_strategy_args).toStrictEqual({ ttl: 60 });
|
||||
});
|
||||
|
||||
it("drops arguments belonging to a strategy that does not take them", () => {
|
||||
const result = buildRoutingGroupPayload(
|
||||
values({ routing_strategy: "least-busy", routing_strategy_args: '{"ttl": 3600}' }),
|
||||
);
|
||||
|
||||
expect(result.ok && result.group.routing_strategy_args).toBeNull();
|
||||
});
|
||||
|
||||
it("treats whitespace-only arguments as absent", () => {
|
||||
const result = buildRoutingGroupPayload(
|
||||
values({ routing_strategy: "latency-based-routing", routing_strategy_args: " \n " }),
|
||||
);
|
||||
|
||||
expect(result.ok && result.group.routing_strategy_args).toBeNull();
|
||||
});
|
||||
|
||||
it("reports invalid JSON instead of a payload", () => {
|
||||
expect(
|
||||
buildRoutingGroupPayload(values({ routing_strategy: "latency-based-routing", routing_strategy_args: "{ttl:}" })),
|
||||
).toStrictEqual({ ok: false, argsError: "Must be valid JSON" });
|
||||
});
|
||||
|
||||
it("trims the group name", () => {
|
||||
const result = buildRoutingGroupPayload(values({ group_name: " fast-chat " }));
|
||||
|
||||
expect(result.ok && result.group.group_name).toBe("fast-chat");
|
||||
});
|
||||
|
||||
it("passes the selected models through untouched", () => {
|
||||
const models = ["gpt-4o", "claude-sonnet", "gemini-pro"];
|
||||
const result = buildRoutingGroupPayload(values({ models }));
|
||||
|
||||
expect(result.ok && result.group.models).toStrictEqual(models);
|
||||
});
|
||||
});
|
||||
|
||||
describe("argsForStrategy", () => {
|
||||
it("keeps the arguments when the new strategy still takes them", () => {
|
||||
expect(argsForStrategy("usage-based-routing", '{"ttl": 60}')).toBe('{"ttl": 60}');
|
||||
});
|
||||
|
||||
it("clears the arguments when the new strategy takes none", () => {
|
||||
expect(argsForStrategy("simple-shuffle", '{"ttl": 60}')).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toRoutingGroupFormValues", () => {
|
||||
it("falls back to empty values and the first available strategy when creating", () => {
|
||||
const expected: RoutingGroupFormValues = {
|
||||
group_name: "",
|
||||
models: [],
|
||||
routing_strategy: "least-busy",
|
||||
routing_strategy_args: "",
|
||||
};
|
||||
|
||||
expect(toRoutingGroupFormValues(null, ["least-busy", "simple-shuffle"])).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it("falls back to simple-shuffle when no strategy is available", () => {
|
||||
expect(toRoutingGroupFormValues(null, []).routing_strategy).toBe("simple-shuffle");
|
||||
});
|
||||
|
||||
it("pretty-prints the stored arguments", () => {
|
||||
const stored: RoutingGroup = {
|
||||
group_name: "latency-group",
|
||||
models: ["gpt-4o"],
|
||||
routing_strategy: "latency-based-routing",
|
||||
routing_strategy_args: { ttl: 3600 },
|
||||
};
|
||||
const expected: RoutingGroupFormValues = {
|
||||
group_name: "latency-group",
|
||||
models: ["gpt-4o"],
|
||||
routing_strategy: "latency-based-routing",
|
||||
routing_strategy_args: '{\n "ttl": 3600\n}',
|
||||
};
|
||||
|
||||
expect(toRoutingGroupFormValues(stored, [])).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it("leaves the arguments blank when the stored group has none", () => {
|
||||
const stored: RoutingGroup = {
|
||||
group_name: "g",
|
||||
models: [],
|
||||
routing_strategy: "simple-shuffle",
|
||||
routing_strategy_args: null,
|
||||
};
|
||||
|
||||
expect(toRoutingGroupFormValues(stored, []).routing_strategy_args).toBe("");
|
||||
});
|
||||
|
||||
it("carries only the four bound fields, never the rest of the record", () => {
|
||||
expect(
|
||||
Object.keys(
|
||||
toRoutingGroupFormValues({ group_name: "g", models: [], routing_strategy: "simple-shuffle" }, []),
|
||||
).sort(),
|
||||
).toStrictEqual(["group_name", "models", "routing_strategy", "routing_strategy_args"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import type { RoutingGroup } from "./types";
|
||||
|
||||
export const STRATEGIES_WITH_ARGS = new Set<string>(["latency-based-routing", "usage-based-routing"]);
|
||||
|
||||
export const GROUP_NAME_PATTERN = /^[A-Za-z0-9._-]+$/;
|
||||
export const GROUP_NAME_MAX_LENGTH = 64;
|
||||
|
||||
export interface RoutingGroupFormValues {
|
||||
group_name: string;
|
||||
models: string[];
|
||||
routing_strategy: string;
|
||||
routing_strategy_args: string;
|
||||
}
|
||||
|
||||
export type RoutingGroupPayload =
|
||||
| { readonly ok: true; readonly group: RoutingGroup }
|
||||
| { readonly ok: false; readonly argsError: string };
|
||||
|
||||
export const toRoutingGroupFormValues = (
|
||||
group: RoutingGroup | null,
|
||||
availableStrategies: string[],
|
||||
): RoutingGroupFormValues => ({
|
||||
group_name: group?.group_name ?? "",
|
||||
models: group?.models ?? [],
|
||||
routing_strategy: group?.routing_strategy ?? availableStrategies[0] ?? "simple-shuffle",
|
||||
routing_strategy_args: group?.routing_strategy_args ? JSON.stringify(group.routing_strategy_args, null, 2) : "",
|
||||
});
|
||||
|
||||
export const argsForStrategy = (routingStrategy: string, routingStrategyArgs: string): string =>
|
||||
STRATEGIES_WITH_ARGS.has(routingStrategy) ? routingStrategyArgs : "";
|
||||
|
||||
export const buildRoutingGroupPayload = (values: RoutingGroupFormValues): RoutingGroupPayload => {
|
||||
const base = {
|
||||
group_name: values.group_name.trim(),
|
||||
models: values.models,
|
||||
routing_strategy: values.routing_strategy,
|
||||
};
|
||||
const args = argsForStrategy(values.routing_strategy, values.routing_strategy_args);
|
||||
|
||||
if (!args.trim()) {
|
||||
return { ok: true, group: { ...base, routing_strategy_args: null } };
|
||||
}
|
||||
|
||||
try {
|
||||
return { ok: true, group: { ...base, routing_strategy_args: JSON.parse(args) as Record<string, unknown> } };
|
||||
} catch {
|
||||
return { ok: false, argsError: "Must be valid JSON" };
|
||||
}
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue