refactor(ui): migrate the model and router settings pages off antd (#37523)

* refactor(ui): migrate the model and router settings pages off antd

Converts the add model flow, credential panels, model settings and router
settings onto the shadcn primitives, moves the mapping table onto the
shared DataTable, and drops the dead uploadProps prop chain that only
existed to carry antd's UploadProps type.

* fix(ui): split comma-separated custom technical keywords into one term each
This commit is contained in:
ryan-crabbe-berri 2026-08-19 18:19:09 -07:00 • committed by GitHub
parent 26841dae43
commit 0ab1725757
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 1153 additions and 1318 deletions

View file

@ -57,9 +57,6 @@
"local/no-complex-jsx-arrow": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/immutability": {
"count": 1
}
@ -199,11 +196,6 @@
"count": 1
}
},
"src/app/(dashboard)/guardrails-monitor/_components/GuardrailConfig.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/guardrails-monitor/_components/GuardrailDetail.tsx": {
"no-nested-ternary": {
"count": 3
@ -550,11 +542,6 @@
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.test.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.tsx": {
"no-nested-ternary": {
"count": 1
@ -570,11 +557,6 @@
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/PassthroughAuthorizeSection.test.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/ToolTestPanel.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
@ -782,11 +764,6 @@
"count": 1
}
},
"src/app/(dashboard)/playground/components/compareUI/components/ModelSelector.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/playground/components/complianceUI/ComplianceUI.tsx": {
"local/no-complex-jsx-arrow": {
"count": 2
@ -987,9 +964,6 @@
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/immutability": {
"count": 1
}
@ -1257,11 +1231,6 @@
"count": 1
}
},
"src/app/(dashboard)/vector-stores/_components/CreateVectorStore.tsx": {
"no-restricted-imports": {
"count": 2
}
},
"src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx": {
"no-nested-ternary": {
"count": 2
@ -1400,9 +1369,6 @@
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
@ -1437,18 +1403,7 @@
"count": 1
}
},
"src/components/Settings/RouterSettings/Fallbacks/FallbackGroupConfig.tsx": {
"local/no-complex-jsx-arrow": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/components/Settings/RouterSettings/Fallbacks/FallbackSelectionForm.tsx": {
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
@ -1488,11 +1443,6 @@
"count": 1
}
},
"src/components/UsagePage/components/EntityUsage/TopKeyView.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/UsagePage/utils/value_formatters.tsx": {
"local/filename-pascal-case": {
"count": 1
@ -1511,11 +1461,6 @@
"count": 1
}
},
"src/components/add_model/AddModelForm.test.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/add_model/AddModelForm.tsx": {
"local/no-complex-jsx-arrow": {
"count": 1
@ -1548,9 +1493,6 @@
"src/components/add_model/advanced_settings.tsx": {
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/components/add_model/auto_router_connection_test.tsx": {
@ -1570,9 +1512,6 @@
"local/no-complex-jsx-arrow": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 2
}
@ -1593,9 +1532,6 @@
},
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 2
}
},
"src/components/add_model/model_connection_test.tsx": {
@ -1613,9 +1549,6 @@
"no-nested-ternary": {
"count": 3
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/immutability": {
"count": 3
}
@ -1625,15 +1558,7 @@
"count": 1
}
},
"src/components/agent_management/AgentSelector.test.tsx": {
"react/display-name": {
"count": 1
}
},
"src/components/agent_management/AgentSelector.tsx": {
"no-restricted-imports": {
"count": 1
},
"prefer-const": {
"count": 1
}
@ -1724,11 +1649,6 @@
"count": 1
}
},
"src/components/common_components/AccessGroupSelector.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/common_components/DeleteResourceModal.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
@ -1739,16 +1659,6 @@
"count": 1
}
},
"src/components/common_components/MetadataKeyValueFields.test.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/common_components/MetadataKeyValueFields.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/common_components/ModelAliasManager.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
@ -1759,11 +1669,6 @@
"count": 1
}
},
"src/components/common_components/RateLimitTypeFormItem.test.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/common_components/budget_duration_dropdown.tsx": {
"local/filename-pascal-case": {
"count": 1
@ -1772,9 +1677,6 @@
"src/components/common_components/check_openapi_schema.tsx": {
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
"count": 2
}
},
"src/components/common_components/fetch_teams.tsx": {
@ -1845,16 +1747,6 @@
"count": 1
}
},
"src/components/key_team_helpers/BudgetFallbacksEditor.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/key_team_helpers/BudgetWindowsEditor.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/key_team_helpers/fetch_available_models_team_key.tsx": {
"local/filename-pascal-case": {
"count": 1
@ -1953,11 +1845,6 @@
"count": 1
}
},
"src/components/model_dashboard/ModelSettingsModal/ModelSettingsModal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/model_filters.tsx": {
"local/filename-pascal-case": {
"count": 1
@ -2111,11 +1998,6 @@
"count": 1
}
},
"src/components/router_settings/RoutingStrategySelector.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/router_settings/index.tsx": {
"local/filename-pascal-case": {
"count": 1
@ -2264,11 +2146,6 @@
"count": 1
}
},
"src/components/team/LoggingSettings.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/team/TeamInfo.tsx": {
"max-lines": {
"count": 1
@ -2276,9 +2153,6 @@
"no-nested-ternary": {
"count": 3
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
@ -2614,11 +2488,6 @@
"count": 2
}
},
"src/contexts/AntdGlobalProvider.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/contexts/AuthContext.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1

View file

@ -219,7 +219,7 @@ describe("AddModelPanel submit payload contract", () => {
const { user, openAdvanced, fillRequired, submit } = await setup();
await fillRequired();
await openAdvanced();
await user.click(screen.getByLabelText("Custom Pricing"));
await user.click(screen.getByRole("switch", { name: "Custom Pricing" }));
await user.type(await screen.findByLabelText("Input Cost (per 1M tokens)"), "3");
await user.type(screen.getByLabelText("Output Cost (per 1M tokens)"), "9");
await submit();
@ -241,7 +241,7 @@ describe("AddModelPanel submit payload contract", () => {
const { user, openAdvanced, fillRequired, submit } = await setup();
await fillRequired();
await openAdvanced();
await user.click(screen.getByLabelText("Cache Control Injection Points"));
await user.click(screen.getByRole("switch", { name: "Cache Control Injection Points" }));
await screen.findByText("Add Injection Point");
await submit();
@ -260,7 +260,7 @@ describe("AddModelPanel submit payload contract", () => {
const { user, openAdvanced, fillRequired, submit } = await setup();
await fillRequired();
await openAdvanced();
await user.click(screen.getByLabelText("Cache Control Injection Points"));
await user.click(screen.getByRole("switch", { name: "Cache Control Injection Points" }));
await screen.findByText("Add Injection Point");
await user.click(screen.getByText("Select a role"));
await user.click(await screen.findByText("System"));
@ -385,7 +385,7 @@ describe("AddModelPanel behaviours the removed Advanced Settings form instance n
const { user, openAdvanced, fillRequired, submit } = await setup();
await fillRequired();
await openAdvanced();
await user.click(screen.getByLabelText("Use in pass through routes"));
await user.click(screen.getByRole("switch", { name: "Use in pass through routes" }));
expect(screen.getByLabelText("LiteLLM Params")).toHaveValue("");
await submit();
@ -401,11 +401,11 @@ describe("AddModelPanel behaviours the removed Advanced Settings form instance n
const { user, openAdvanced, fillRequired, submit } = await setup();
await fillRequired();
await openAdvanced();
await user.click(screen.getByLabelText("Custom Pricing"));
await user.click(screen.getByRole("switch", { name: "Custom Pricing" }));
await user.type(await screen.findByLabelText("Input Cost (per 1M tokens)"), "3");
await user.click(screen.getByLabelText("Custom Pricing"));
await user.click(screen.getByRole("switch", { name: "Custom Pricing" }));
await waitFor(() => expect(screen.queryByLabelText("Input Cost (per 1M tokens)")).not.toBeInTheDocument());
await user.click(screen.getByLabelText("Custom Pricing"));
await user.click(screen.getByRole("switch", { name: "Custom Pricing" }));
expect(await screen.findByLabelText("Input Cost (per 1M tokens)")).toHaveValue("3");
await submit();

View file

@ -15,7 +15,6 @@ import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap"
import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { vertexCredentialsUploadProps } from "@/app/(dashboard)/models-and-endpoints/vertexCredentialsUpload";
const INITIAL_VALUES: MountedFormValues = { litellm_credential_name: null };
@ -60,9 +59,6 @@ export default function AddModelPanel() {
providerModels={providerModels}
setProviderModelsFn={(provider) => setProviderModels(getProviderModels(provider, modelCostMapData))}
getPlaceholder={getPlaceholder}
uploadProps={vertexCredentialsUploadProps({
setFieldsValue: (values) => form.setValue("vertex_credentials", values.vertex_credentials),
})}
showAdvancedSettings={showAdvancedSettings}
setShowAdvancedSettings={setShowAdvancedSettings}
teams={teams ?? null}

View file

@ -1,17 +1,7 @@
"use client";
import { useForm } from "react-hook-form";
import CredentialsPanel from "@/components/model_add/CredentialsPanel";
import type { MountedFormValues } from "@/components/common_components/MountedFormField";
import { vertexCredentialsUploadProps } from "@/app/(dashboard)/models-and-endpoints/vertexCredentialsUpload";
export default function LlmCredentialsPanel() {
const form = useForm<MountedFormValues>();
return (
<CredentialsPanel
uploadProps={vertexCredentialsUploadProps({
setFieldsValue: (values) => form.setValue("vertex_credentials", values.vertex_credentials),
})}
/>
);
return <CredentialsPanel />;
}

View file

@ -1,47 +0,0 @@
import { waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { toast } from "@/lib/toast";
import { vertexCredentialsUploadProps } from "./vertexCredentialsUpload";
const makeForm = () => ({ setFieldsValue: vi.fn() });
describe("vertexCredentialsUploadProps", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("reads a JSON credential file into the vertex_credentials field without uploading it", async () => {
const form = makeForm();
const props = vertexCredentialsUploadProps(form as never);
const file = new File(['{"project_id":"example"}'], "vertex.json", { type: "application/json" });
expect(props.beforeUpload?.(file as never, [file] as never)).toBe(false);
await waitFor(() => {
expect(form.setFieldsValue).toHaveBeenCalledWith({ vertex_credentials: '{"project_id":"example"}' });
});
});
it("ignores non-JSON files", async () => {
const form = makeForm();
const props = vertexCredentialsUploadProps(form as never);
const file = new File(["not json"], "vertex.txt", { type: "text/plain" });
expect(props.beforeUpload?.(file as never, [file] as never)).toBe(false);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(form.setFieldsValue).not.toHaveBeenCalled();
});
it("reports completed and failed upload states", () => {
const props = vertexCredentialsUploadProps(makeForm() as never);
props.onChange?.({ file: { name: "vertex.json", status: "done" } } as never);
props.onChange?.({ file: { name: "vertex.json", status: "error" } } as never);
expect(toast.success).toHaveBeenCalledWith("vertex.json file uploaded successfully");
expect(toast.fromError).toHaveBeenCalledWith("vertex.json file upload failed.");
});
});

View file

@ -1,37 +0,0 @@
import type { ComponentProps } from "react";
import { toast } from "@/lib/toast";
import type CredentialsPanel from "@/components/model_add/CredentialsPanel";
interface VertexCredentialsForm {
setFieldsValue: (values: { vertex_credentials: string }) => void;
}
type UploadProps = ComponentProps<typeof CredentialsPanel>["uploadProps"];
export function vertexCredentialsUploadProps(form: VertexCredentialsForm): UploadProps {
return {
name: "file",
accept: ".json",
pastable: false,
beforeUpload: (file) => {
if (file.type === "application/json") {
const reader = new FileReader();
reader.onload = (event) => {
if (event.target) {
form.setFieldsValue({ vertex_credentials: event.target.result as string });
}
};
reader.readAsText(file);
}
return false;
},
onChange(info) {
if (info.file.status === "done") {
toast.success(`${info.file.name} file uploaded successfully`);
} else if (info.file.status === "error") {
toast.fromError(`${info.file.name} file upload failed.`);
}
},
};
}

View file

@ -1,8 +1,6 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
// eslint-disable-next-line no-restricted-imports -- the dashboard has no shadcn date-time picker; the PTU window fields need one
import { DatePicker } from "antd";
import { CircleHelp } from "lucide-react";
import type { Dayjs } from "dayjs";
import * as React from "react";
@ -11,6 +9,7 @@ import { z } from "zod/v4";
import { TagsInput } from "@/app/(dashboard)/guardrails/_components/content_filter/TagsInput";
import { FormField } from "@/components/shared/form/FormField";
import { UtcDateTimeInput } from "@/components/shared/form/UtcDateTimeInput";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -292,9 +291,16 @@ const Display: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<div className="mt-1 rounded-sm bg-muted p-2">{children}</div>
);
const FieldLabel: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<p className="text-sm font-medium text-foreground">{children}</p>
);
const FIELD_LABEL_CLASS = "text-sm font-medium text-foreground";
const FieldLabel: React.FC<{ htmlFor?: string; children: React.ReactNode }> = ({ htmlFor, children }) =>
htmlFor === undefined ? (
<p className={FIELD_LABEL_CLASS}>{children}</p>
) : (
<label htmlFor={htmlFor} className={FIELD_LABEL_CLASS}>
{children}
</label>
);
const Hint: React.FC<{ text: string }> = ({ text }) => (
<Tooltip>
@ -463,13 +469,14 @@ const ModelInfoEditForm: React.FC<ModelInfoEditFormProps> = ({
{ptuCostAttributionEnabled &&
PTU_EDIT_FIELDS.map((ptuField) => (
<div key={ptuField.name}>
<FieldLabel>{ptuField.label}</FieldLabel>
<FieldLabel htmlFor={ptuField.name}>{ptuField.label}</FieldLabel>
{isEditing ? (
<FormField control={form.control} name={ptuField.name as ModelEditFieldName}>
{({ value, onChange, ...control }) =>
ptuField.input === "number" ? (
<NumericalInput
{...control}
id={ptuField.name}
onChange={onChange}
value={value ?? ""}
placeholder={ptuField.placeholder}
@ -477,10 +484,10 @@ const ModelInfoEditForm: React.FC<ModelInfoEditFormProps> = ({
min={ptuField.isCount ? 1 : 0}
/>
) : (
<DatePicker
showTime
style={{ width: "100%" }}
value={(value as Dayjs | null) ?? null}
<UtcDateTimeInput
{...control}
id={ptuField.name}
value={value as Dayjs | null}
onChange={onChange}
/>
)

View file

@ -1,5 +1,5 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import EditFallbacks, { Fallbacks } from "./EditFallbacks";
@ -49,10 +49,9 @@ describe("EditFallbacks", () => {
it("prefills the existing fallback chain for the primary model", async () => {
setup();
await waitFor(() => {
expect(screen.getByText("gpt-3.5-turbo")).toBeInTheDocument();
expect(screen.getByText("claude-3-opus")).toBeInTheDocument();
});
const chain = await screen.findByRole("list", { name: "Fallback chain" });
expect(within(chain).getByText("gpt-3.5-turbo")).toBeInTheDocument();
expect(within(chain).getByText("claude-3-opus")).toBeInTheDocument();
});
it("removes a fallback model and saves only the edited entry", async () => {
@ -61,8 +60,8 @@ describe("EditFallbacks", () => {
const onClose = vi.fn();
setup({ onChange, onClose });
await screen.findByText("gpt-3.5-turbo");
await user.click(screen.getByTestId("remove-fallback-gpt-3.5-turbo"));
const chain = await screen.findByRole("list", { name: "Fallback chain" });
await user.click(within(chain).getByRole("button", { name: "Remove gpt-3.5-turbo" }));
await user.click(screen.getByRole("button", { name: /save changes/i }));
@ -77,8 +76,8 @@ describe("EditFallbacks", () => {
const onChange = vi.fn().mockResolvedValue(undefined);
setup({ fallbackEntry: { "gpt-4": ["gpt-3.5-turbo"] }, onChange });
await screen.findByText("gpt-3.5-turbo");
await user.click(screen.getByTestId("remove-fallback-gpt-3.5-turbo"));
const chain = await screen.findByRole("list", { name: "Fallback chain" });
await user.click(within(chain).getByRole("button", { name: "Remove gpt-3.5-turbo" }));
const saveButton = screen.getByRole("button", { name: /save changes/i });
expect(saveButton).toBeDisabled();

View file

@ -3,10 +3,10 @@
* Handles primary model selection and fallback chain configuration
*/
import { SimpleTooltip } from "@/components/ui/tooltip";
import { Select } from "antd";
import { MultiSelect } from "@/components/shared/MultiSelect";
import { SearchSelect } from "@/components/shared/SearchSelect";
import { AlertCircle, ArrowDown, X } from "lucide-react";
import React from "react";
import React, { useId } from "react";
export interface FallbackGroup {
id: string;
@ -64,25 +64,24 @@ export function FallbackGroupConfig({
};
const canAddMoreFallbacks = group.fallbackModels.length < maxFallbacks;
const primaryModelInputId = useId();
return (
<div className="flex flex-col gap-8 py-4">
{/* Primary Model Section */}
<div className="relative">
<label className="block text-sm font-semibold text-gray-700 mb-2">
<label htmlFor={primaryModelInputId} className="block text-sm font-semibold text-gray-700 mb-2">
Primary Model <span className="text-red-500">*</span>
</label>
<Select
className="w-full h-12"
size="large"
placeholder="Select primary model"
value={group.primaryModel}
onChange={handlePrimaryChange}
disabled={disablePrimaryModel}
showSearch
getPopupContainer={(trigger) => trigger.parentElement || document.body}
filterOption={(input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase())}
<SearchSelect
inputId={primaryModelInputId}
options={availableModels.map((m) => ({ label: m, value: m }))}
value={group.primaryModel ?? ""}
onValueChange={handlePrimaryChange}
placeholder="Select primary model"
emptyText="No models found"
disabled={disablePrimaryModel}
className="h-12"
/>
{!disablePrimaryModel && !group.primaryModel && (
<div className="mt-2 flex items-center gap-2 text-amber-600 text-xs bg-amber-50 p-2 rounded-sm">
@ -112,43 +111,16 @@ export function FallbackGroupConfig({
<div className="bg-gray-50 rounded-xl p-4 border border-gray-200">
{/* Add Fallback Input */}
<div className="mb-4">
<Select
mode="multiple"
className="w-full"
size="large"
<MultiSelect
options={availableFallbackOptions.map((m) => ({ label: m, value: m }))}
value={group.fallbackModels}
onValueChange={handleFallbackSelect}
placeholder={
canAddMoreFallbacks ? "Select fallback models to add..." : `Maximum ${maxFallbacks} fallbacks reached`
}
value={group.fallbackModels}
onChange={handleFallbackSelect}
emptyText="No models found"
disabled={!group.primaryModel}
getPopupContainer={(trigger) => trigger.parentElement || document.body}
options={availableFallbackOptions.map((m) => ({
label: m,
value: m,
}))}
optionRender={(option, info) => {
const isSelected = group.fallbackModels.includes(option.value as string);
const orderIndex = isSelected ? group.fallbackModels.indexOf(option.value as string) + 1 : null;
return (
<div className="flex items-center gap-2">
{isSelected && orderIndex !== null && (
<span className="flex items-center justify-center w-5 h-5 rounded-sm bg-indigo-100 text-indigo-600 text-xs font-bold">
{orderIndex}
</span>
)}
<span>{option.label}</span>
</div>
);
}}
maxTagCount="responsive"
maxTagPlaceholder={(omittedValues) => (
<SimpleTooltip content={omittedValues.map(({ value }) => value).join(", ")}>
<span>+{omittedValues.length} more</span>
</SimpleTooltip>
)}
showSearch
filterOption={(input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase())}
className="w-full"
/>
<p className="text-xs text-gray-500 mt-1 ml-1">
{canAddMoreFallbacks
@ -165,9 +137,9 @@ export function FallbackGroupConfig({
<span className="text-xs mt-1">Add models from the dropdown above</span>
</div>
) : (
group.fallbackModels.map((modelValue, index) => {
return (
<div
<ol aria-label="Fallback chain" className="space-y-2">
{group.fallbackModels.map((modelValue, index) => (
<li
key={`${modelValue}-${index}`}
className="group flex items-center justify-between p-3 bg-white rounded-lg border border-gray-200 hover:border-indigo-300 hover:shadow-xs transition-all"
>
@ -182,15 +154,15 @@ export function FallbackGroupConfig({
<button
type="button"
data-testid={`remove-fallback-${modelValue}`}
aria-label={`Remove ${modelValue}`}
onClick={() => removeFallback(index)}
className="opacity-0 group-hover:opacity-100 transition-opacity text-gray-400 hover:text-red-500 p-1"
>
<X className="w-4 h-4" />
</button>
</div>
);
})
</li>
))}
</ol>
)}
</div>
</div>

View file

@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { FallbackSelectionForm } from "./FallbackSelectionForm";
@ -69,7 +69,7 @@ describe("FallbackSelectionForm", () => {
<FallbackSelectionForm groups={groups} onGroupsChange={mockOnGroupsChange} availableModels={AVAILABLE_MODELS} />,
);
const addTabButton = screen.getByRole("button", { name: /add tab/i });
const addTabButton = screen.getByRole("button", { name: /add fallback group/i });
await user.click(addTabButton);
expect(mockOnGroupsChange).toHaveBeenCalledTimes(1);
@ -98,7 +98,7 @@ describe("FallbackSelectionForm", () => {
maxGroups={5}
/>,
);
expect(screen.queryByRole("button", { name: /add tab/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /add fallback group/i })).not.toBeInTheDocument();
});
it("should show add tab button when below maxGroups with custom maxGroups", () => {
@ -111,7 +111,7 @@ describe("FallbackSelectionForm", () => {
maxGroups={3}
/>,
);
expect(screen.getByRole("button", { name: /add tab/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /add fallback group/i })).toBeInTheDocument();
});
it("should call onGroupsChange when a group is removed", async () => {
@ -124,7 +124,8 @@ describe("FallbackSelectionForm", () => {
<FallbackSelectionForm groups={groups} onGroupsChange={mockOnGroupsChange} availableModels={AVAILABLE_MODELS} />,
);
const removeButtons = screen.getAllByRole("tab", { name: "remove" });
const removeButtons = screen.getAllByRole("button", { name: /^remove /i });
expect(removeButtons).toHaveLength(2);
await user.click(removeButtons[0]);
expect(mockOnGroupsChange).toHaveBeenCalledTimes(1);
@ -139,7 +140,7 @@ describe("FallbackSelectionForm", () => {
render(
<FallbackSelectionForm groups={groups} onGroupsChange={mockOnGroupsChange} availableModels={AVAILABLE_MODELS} />,
);
expect(screen.getByText("Select primary model")).toBeInTheDocument();
expect(screen.getByRole("combobox", { name: /primary model/i })).toHaveValue("");
expect(screen.getByText("Primary Model")).toBeInTheDocument();
});
@ -150,7 +151,8 @@ describe("FallbackSelectionForm", () => {
);
expect(screen.getByRole("tab", { name: "gpt-4" })).toBeInTheDocument();
expect(screen.getAllByText("gpt-4").length).toBeGreaterThan(0);
expect(screen.getByText("gpt-3.5-turbo")).toBeInTheDocument();
const chain = screen.getByRole("list", { name: "Fallback chain" });
expect(within(chain).getByText("gpt-3.5-turbo")).toBeInTheDocument();
});
it("should not add group when add button clicked at maxGroups", () => {
@ -168,7 +170,7 @@ describe("FallbackSelectionForm", () => {
/>,
);
expect(screen.queryByRole("button", { name: /add tab/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /add fallback group/i })).not.toBeInTheDocument();
expect(mockOnGroupsChange).not.toHaveBeenCalled();
});
});

View file

@ -5,8 +5,8 @@
*/
import { Button } from "@/components/ui/button";
import { Tabs } from "antd";
import { Plus } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Plus, X } from "lucide-react";
import React, { useEffect, useState } from "react";
import { toast } from "@/lib/toast";
import { FallbackGroup, FallbackGroupConfig } from "./FallbackGroupConfig";
@ -76,23 +76,8 @@ export function FallbackSelectionForm({
onGroupsChange(newGroups);
};
// Generate tab items
const items = groups.map((group, index) => {
const label = group.primaryModel ? group.primaryModel : `Group ${index + 1}`;
return {
key: group.id,
label: label,
closable: groups.length > 1, // Only allow closing if there's more than 1 group
children: (
<FallbackGroupConfig
group={group}
onChange={handleGroupUpdate}
availableModels={availableModels}
maxFallbacks={maxFallbacks}
/>
),
};
});
const groupLabel = (group: FallbackGroup, index: number) =>
group.primaryModel ? group.primaryModel : `Group ${index + 1}`;
if (groups.length === 0) {
return (
@ -107,22 +92,47 @@ export function FallbackSelectionForm({
}
return (
<Tabs
type="editable-card"
activeKey={activeKey}
onChange={setActiveKey}
onEdit={(targetKey, action) => {
if (action === "add") handleAddGroup();
else if (action === "remove" && groups.length > 1) {
handleRemoveGroup(targetKey as string);
}
}}
items={items}
className="fallback-tabs"
tabBarStyle={{
marginBottom: 0,
}}
hideAdd={groups.length >= maxGroups}
/>
<Tabs value={activeKey} onValueChange={setActiveKey}>
<div className="flex items-center border-b">
<TabsList variant="line" className="h-auto justify-start rounded-none p-0">
{groups.map((group, index) => (
<div key={group.id} className="relative flex items-center">
<TabsTrigger
value={group.id}
className={`flex-none rounded-none py-2 pl-4 ${groups.length > 1 ? "pr-9" : "pr-4"}`}
>
{groupLabel(group, index)}
</TabsTrigger>
{groups.length > 1 && (
<Button
variant="ghost"
size="icon-xs"
className="absolute right-1"
aria-label={`Remove ${groupLabel(group, index)}`}
onClick={() => handleRemoveGroup(group.id)}
>
<X />
</Button>
)}
</div>
))}
</TabsList>
{groups.length < maxGroups && (
<Button variant="ghost" size="icon-sm" aria-label="Add fallback group" onClick={handleAddGroup}>
<Plus />
</Button>
)}
</div>
{groups.map((group) => (
<TabsContent key={group.id} value={group.id} className="pt-4">
<FallbackGroupConfig
group={group}
onChange={handleGroupUpdate}
availableModels={availableModels}
maxFallbacks={maxFallbacks}
/>
</TabsContent>
))}
</Tabs>
);
}

View file

@ -1,4 +1,9 @@
import { Card, InputNumber, Radio, Slider, Space, Switch, Typography } from "antd";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import React from "react";
import {
AdaptiveEligible,
@ -7,8 +12,6 @@ import {
DEFAULT_TIER_DISTANCE_PENALTY,
} from "./ComplexityRouterConfig";
const { Text } = Typography;
interface AdaptiveRoutingConfigProps {
value: ComplexityRouterConfigValue;
onChange: (value: ComplexityRouterConfigValue) => void;
@ -45,84 +48,91 @@ const AdaptiveRoutingConfig: React.FC<AdaptiveRoutingConfigProps> = ({ value, on
return (
<>
<div className="flex items-center gap-2 mb-2">
<Switch checked={value.adaptive ?? false} onChange={handleAdaptiveToggle} />
<Text strong>Enable adaptive bandit selection</Text>
</div>
<Text type="secondary" style={{ display: "block", fontSize: 12 }}>
<Label className="mb-2">
<Switch checked={value.adaptive ?? false} onCheckedChange={handleAdaptiveToggle} />
<strong className="font-semibold">Enable adaptive bandit selection</strong>
</Label>
<span className="block text-xs text-muted-foreground">
When disabled, each request always uses the model assigned to its classified tier.
</Text>
</span>
<Card className="bg-gray-50 mt-4">
<Text strong style={{ display: "block", marginBottom: 8 }}>
How Adaptive Routing Works
</Text>
<Text type="secondary" style={{ fontSize: 13 }}>
It learns from how each conversation actually goes: does the user have to rephrase or correct the model, does
it get stuck repeating itself, does it run out of tool calls, does the user seem satisfied. Combined with
cost, this live feedback shifts future routing toward the models that are actually working well, and improves
as more conversations come in. Until there&apos;s enough feedback, it defaults to the classified tier&apos;s
model.
</Text>
<CardContent>
<strong className="mb-2 block font-semibold">How Adaptive Routing Works</strong>
<span className="text-[13px] text-muted-foreground">
It learns from how each conversation actually goes: does the user have to rephrase or correct the model,
does it get stuck repeating itself, does it run out of tool calls, does the user seem satisfied. Combined
with cost, this live feedback shifts future routing toward the models that are actually working well, and
improves as more conversations come in. Until there&apos;s enough feedback, it defaults to the classified
tier&apos;s model.
</span>
</CardContent>
</Card>
{value.adaptive && (
<div className="mt-4 space-y-4">
<div>
<Text strong style={{ display: "block", marginBottom: 4 }}>
<strong className="mb-1 block font-semibold">
Quality vs. Cost ({Math.round(adaptiveWeights.quality * 100)}% quality /{" "}
{Math.round(adaptiveWeights.cost * 100)}% cost)
</Text>
</strong>
<Slider
aria-label="Quality vs. Cost"
min={0}
max={100}
value={Math.round(adaptiveWeights.quality * 100)}
onChange={handleQualityWeightChange}
tooltip={{ formatter: (v) => `${v}% quality / ${100 - (v ?? 0)}% cost` }}
value={[Math.round(adaptiveWeights.quality * 100)]}
onValueChange={(next) => handleQualityWeightChange(Array.isArray(next) ? next[0] : next)}
/>
<Text type="secondary" style={{ fontSize: 12 }}>
<span className="text-xs text-muted-foreground">
Higher quality weight favors more capable (pricier) models; higher cost weight favors cheaper models when
the bandit has feedback to act on. Recommended: 30% quality / 70% cost split.
</Text>
</span>
</div>
<div>
<Text strong style={{ display: "block", marginBottom: 4 }}>
Eligible Model Pool
</Text>
<Radio.Group
<strong className="mb-1 block font-semibold">Eligible Model Pool</strong>
<RadioGroup
value={adaptiveEligible}
onChange={(e) => handleAdaptiveEligibleChange(e.target.value)}
onValueChange={(eligible: unknown) => handleAdaptiveEligibleChange(eligible as AdaptiveEligible)}
className="w-full"
>
<Space direction="vertical" className="w-full">
<Radio value="all">
<Text strong>All tiers (soft floor)</Text>{" "}
<Text type="secondary">— router can pick across tiers, depending on the best fit for the prompt</Text>
</Radio>
<Radio value="classified_tier">
<Text strong>Classified tier only</Text>{" "}
<Text type="secondary">— router can only pick models within tier</Text>
</Radio>
</Space>
</Radio.Group>
<div className="flex w-full flex-col items-start gap-2">
<Label className="items-start font-normal leading-normal">
<RadioGroupItem value="all" className="mt-0.5" />
<span>
<strong className="font-semibold">All tiers (soft floor)</strong>{" "}
<span className="text-muted-foreground">
— router can pick across tiers, depending on the best fit for the prompt
</span>
</span>
</Label>
<Label className="items-start font-normal leading-normal">
<RadioGroupItem value="classified_tier" className="mt-0.5" />
<span>
<strong className="font-semibold">Classified tier only</strong>{" "}
<span className="text-muted-foreground">— router can only pick models within tier</span>
</span>
</Label>
</div>
</RadioGroup>
</div>
{adaptiveEligible === "all" && (
<div>
<Text strong style={{ display: "block", marginBottom: 4 }}>
Tier Distance Penalty
</Text>
<InputNumber
<strong className="mb-1 block font-semibold">Tier Distance Penalty</strong>
<Input
type="number"
value={tierDistancePenalty}
onChange={handleTierDistancePenaltyChange}
onChange={(event) =>
handleTierDistancePenaltyChange(event.target.value === "" ? null : event.target.valueAsNumber)
}
min={0}
step={0.1}
style={{ width: "100%" }}
className="w-full"
/>
<Text type="secondary" style={{ fontSize: 12 }}>
<span className="text-xs text-muted-foreground">
Score penalty applied per tier-step away from the classified tier.
</Text>
</span>
</div>
)}
</div>

View file

@ -1,6 +1,5 @@
import { renderHook, screen, waitFor, renderWithProviders } from "../../../tests/test-utils";
import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event";
import type { UploadProps } from "antd/es/upload";
import { describe, expect, it, vi } from "vitest";
import type { Team } from "../key_team_helpers/key_list";
import type { CredentialItem } from "../networking";
@ -157,11 +156,6 @@ const createTestProps = (userRole = "proxy_admin", userId = "user-1", isTeamAdmi
},
];
const uploadProps: UploadProps = {
beforeUpload: () => false,
showUploadList: false,
};
return {
form,
registry,
@ -176,7 +170,6 @@ const createTestProps = (userRole = "proxy_admin", userId = "user-1", isTeamAdmi
showAdvancedSettings: false,
teams,
credentials,
uploadProps,
userRole,
userId,
};
@ -318,6 +311,35 @@ describe("AddModelForm", () => {
expect(await screen.findByRole("button", { name: "Add Model" })).toBeInTheDocument();
});
describe("the enterprise gate on the Team-BYOK switch", () => {
const renderForm = async (premiumUser: boolean) => {
const mockUseAuthorized = vi.mocked(await import("@/app/(dashboard)/hooks/useAuthorized"));
mockUseAuthorized.default.mockReturnValue(mockAuthorizedUser("proxy_admin", "user-1", premiumUser));
renderWithProviders(<AddModelForm {...createTestProps()} />);
return screen.findByRole("switch", { name: "Team-BYOK Model" });
};
it("explains the gate on hover even though the switch it sits on is disabled", async () => {
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
const teamOnlySwitch = await renderForm(false);
expect(teamOnlySwitch).toHaveAttribute("aria-disabled", "true");
await user.hover(teamOnlySwitch);
expect(await screen.findByText(/enterprise-only feature/)).toBeInTheDocument();
});
it("says nothing on hover once the user is premium", async () => {
const user = userEvent.setup();
const teamOnlySwitch = await renderForm(true);
expect(teamOnlySwitch).not.toHaveAttribute("aria-disabled", "true");
await user.hover(teamOnlySwitch);
expect(screen.queryByText(/enterprise-only feature/)).not.toBeInTheDocument();
});
});
describe("cache control bindings reach the parent form store", () => {
const renderWithForm = async () => {
const mockUseAuthorized = vi.mocked(await import("@/app/(dashboard)/hooks/useAuthorized"));
@ -331,11 +353,11 @@ describe("AddModelForm", () => {
user,
openCacheControl: async () => {
await user.click(await screen.findByText("Advanced Settings"));
await user.click(screen.getByLabelText("Cache Control Injection Points"));
await user.click(screen.getByRole("switch", { name: "Cache Control Injection Points" }));
await screen.findByText("Add Injection Point");
},
closeCacheControl: async () => {
await user.click(screen.getByLabelText("Cache Control Injection Points"));
await user.click(screen.getByRole("switch", { name: "Cache Control Injection Points" }));
await waitFor(() => expect(screen.queryByText("Add Injection Point")).not.toBeInTheDocument());
},
mountedValues: async (): Promise<Record<string, unknown>> => props.mountedValues(),

View file

@ -9,7 +9,6 @@ import { Select as AntdSelect, Card, Col, Row, Tooltip, Typography } from "antd"
import { Info } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
import { Button } from "@/components/ui/button";
import type { UploadProps } from "antd/es/upload";
import React, { useEffect, useMemo, useState } from "react";
import { FormProvider, useWatch, type UseFormReturn } from "react-hook-form";
import TeamDropdown from "../common_components/team_dropdown";
@ -44,7 +43,6 @@ interface AddModelFormProps {
providerModels: string[];
setProviderModelsFn: (provider: Providers) => void;
getPlaceholder: (provider: Providers) => string;
uploadProps: UploadProps;
showAdvancedSettings: boolean;
setShowAdvancedSettings: (show: boolean) => void;
teams: Team[] | null;
@ -71,7 +69,6 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
providerModels,
setProviderModelsFn,
getPlaceholder,
uploadProps,
showAdvancedSettings,
setShowAdvancedSettings,
teams,
@ -311,7 +308,7 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
<span className="px-4 text-muted-foreground text-sm">OR</span>
<div className="grow border-t border-border"></div>
</div>
<ProviderSpecificFields selectedProvider={selectedProvider} uploadProps={uploadProps} />
<ProviderSpecificFields selectedProvider={selectedProvider} />
</>
)}
<div className="flex items-center my-4">

View file

@ -1,6 +1,13 @@
import { Info } from "lucide-react";
import { SimpleTooltip } from "@/components/ui/tooltip";
import { Select as AntdSelect, Card, InputNumber, Radio, Space, Switch, Typography } from "antd";
import { MultiSelect } from "@/components/shared/MultiSelect";
import { SearchSelect } from "@/components/shared/SearchSelect";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Switch } from "@/components/ui/switch";
import React from "react";
import ClassifierPromptEditor from "./ClassifierPromptEditor";
import HeuristicScoringConfig from "./HeuristicScoringConfig";
@ -21,8 +28,6 @@ import {
effectiveTierLabel,
} from "./ComplexityRouterConfig";
const { Text } = Typography;
const DEFAULT_SCORING_EXPLANATION =
"The router scores each request across 7 dimensions: token count, code presence, reasoning markers, technical " +
"terms, simple indicators, multi-step patterns, and question complexity. The weighted score determines the tier:";
@ -87,36 +92,35 @@ const HowClassificationWorks: React.FC<{ value: ComplexityRouterConfigValue }> =
return (
<Card className="bg-gray-50 mt-4">
<Text strong style={{ display: "block", marginBottom: 8 }}>
How Classification Works
</Text>
<Text type="secondary" style={{ fontSize: 13 }}>
{scoringExplanation(value)}
</Text>
{ranges && (
<ul style={{ marginTop: 8, marginBottom: 0, paddingLeft: 20, fontSize: 13, color: "rgba(0, 0, 0, 0.45)" }}>
<li>
<strong>{effectiveTierLabel("SIMPLE", value.tier_labels)}</strong>: Score &lt; {ranges.simpleMedium}
</li>
<li>
<strong>{effectiveTierLabel("MEDIUM", value.tier_labels)}</strong>: Score {ranges.simpleMedium} -{" "}
{ranges.mediumComplex}
</li>
<li>
<strong>{effectiveTierLabel("COMPLEX", value.tier_labels)}</strong>: Score {ranges.mediumComplex} -{" "}
{ranges.complexReasoning}
</li>
<li>
<strong>{effectiveTierLabel("REASONING", value.tier_labels)}</strong>: Score &gt; {ranges.complexReasoning}{" "}
(or 2+ reasoning markers with a score of at least {ranges.reasoningOverrideFloor})
</li>
</ul>
)}
{!ranges && isError && (
<Text type="secondary" style={{ fontSize: 13, display: "block", marginTop: 8 }}>
The tier score ranges could not be loaded from the proxy.
</Text>
)}
<CardContent>
<strong className="block mb-2 font-semibold">How Classification Works</strong>
<span className="text-[13px] text-muted-foreground">{scoringExplanation(value)}</span>
{ranges && (
<ul style={{ marginTop: 8, marginBottom: 0, paddingLeft: 20, fontSize: 13, color: "rgba(0, 0, 0, 0.45)" }}>
<li>
<strong>{effectiveTierLabel("SIMPLE", value.tier_labels)}</strong>: Score &lt; {ranges.simpleMedium}
</li>
<li>
<strong>{effectiveTierLabel("MEDIUM", value.tier_labels)}</strong>: Score {ranges.simpleMedium} -{" "}
{ranges.mediumComplex}
</li>
<li>
<strong>{effectiveTierLabel("COMPLEX", value.tier_labels)}</strong>: Score {ranges.mediumComplex} -{" "}
{ranges.complexReasoning}
</li>
<li>
<strong>{effectiveTierLabel("REASONING", value.tier_labels)}</strong>: Score &gt;{" "}
{ranges.complexReasoning} (or 2+ reasoning markers with a score of at least{" "}
{ranges.reasoningOverrideFloor})
</li>
</ul>
)}
{!ranges && isError && (
<span className="text-[13px] block mt-2 text-muted-foreground">
The tier score ranges could not be loaded from the proxy.
</span>
)}
</CardContent>
</Card>
);
};
@ -247,61 +251,64 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
return (
<>
<Radio.Group
<RadioGroup
value={value.classifier_type}
onChange={(e) => handleClassifierTypeChange(e.target.value)}
onValueChange={(classifierType: unknown) => handleClassifierTypeChange(classifierType as ClassifierType)}
className="w-full"
>
<Space direction="vertical" className="w-full">
<Radio value="heuristic">
<Text strong>Heuristic</Text>{" "}
<Text type="secondary">(default) — rule-based scoring, no API calls, &lt;1ms latency</Text>
</Radio>
<Radio value="llm">
<Text strong>LLM Classifier</Text>{" "}
<Text type="secondary">— use a model to decide the tier (e.g. a small/fast model)</Text>
</Radio>
</Space>
</Radio.Group>
<div className="flex w-full flex-col items-start gap-2">
<Label className="items-start font-normal leading-normal">
<RadioGroupItem value="heuristic" className="mt-0.5" />
<span>
<strong className="font-semibold">Heuristic</strong>{" "}
<span className="text-muted-foreground">
(default) — rule-based scoring, no API calls, &lt;1ms latency
</span>
</span>
</Label>
<Label className="items-start font-normal leading-normal">
<RadioGroupItem value="llm" className="mt-0.5" />
<span>
<strong className="font-semibold">LLM Classifier</strong>{" "}
<span className="text-muted-foreground">— use a model to decide the tier (e.g. a small/fast model)</span>
</span>
</Label>
</div>
</RadioGroup>
{value.classifier_type === "llm" && (
<div className="mt-4 space-y-3">
<div>
<Text strong style={{ display: "block", marginBottom: 4 }}>
Classifier Model
</Text>
<AntdSelect
value={value.classifier_llm_config?.model || undefined}
onChange={handleClassifierModelChange}
placeholder="Select the model that will classify request complexity"
showSearch
style={{ width: "100%" }}
<strong className="block mb-1 font-semibold">Classifier Model</strong>
<SearchSelect
options={modelOptions}
status={classifierModelMissing ? "error" : undefined}
value={value.classifier_llm_config?.model ?? ""}
onValueChange={handleClassifierModelChange}
placeholder="Select the model that will classify request complexity"
emptyText="No models found"
allowClear={false}
className={classifierModelMissing ? "border-destructive" : undefined}
/>
{classifierModelMissing && (
<Text type="danger" style={{ fontSize: 12 }}>
A classifier model is required
</Text>
)}
{classifierModelMissing && <span className="text-xs text-destructive">A classifier model is required</span>}
</div>
<div>
<Text strong style={{ display: "block", marginBottom: 4 }}>
Timeout (ms)
</Text>
<InputNumber
<strong className="block mb-1 font-semibold">Timeout (ms)</strong>
<Input
type="number"
value={value.classifier_llm_config?.timeout_ms ?? DEFAULT_CLASSIFIER_TIMEOUT_MS}
onChange={handleClassifierTimeoutChange}
onChange={(event) =>
handleClassifierTimeoutChange(event.target.value === "" ? null : event.target.valueAsNumber)
}
min={1}
style={{ width: "100%" }}
className="w-full"
/>
<Text type="secondary" style={{ fontSize: 12 }}>
<span className="text-xs text-muted-foreground">
How long the classifier call has before it fails and the fallback below takes over.
</Text>
</span>
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<Text strong>Classification Rubric</Text>
<strong className="font-semibold">Classification Rubric</strong>
<SimpleTooltip content="Every rubric uses the same four tiers and the same tier definitions. They differ only in the worked examples that show the classifier where the boundary between tiers sits.">
<Info className="size-4 text-gray-400" />
</SimpleTooltip>
@ -310,28 +317,37 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
content={usesCustomPrompt ? "Your custom prompt replaces the built-in rubric entirely" : undefined}
className="w-full"
>
<AntdSelect
value={classificationRubric}
onChange={handleClassificationRubricChange}
disabled={usesCustomPrompt}
style={{ width: "100%" }}
aria-label="Classification Rubric"
options={CLASSIFICATION_RUBRIC_KEYS.map((preset) => ({
<Select
items={CLASSIFICATION_RUBRIC_KEYS.map((preset) => ({
value: preset,
label: CLASSIFICATION_RUBRIC_DESCRIPTIONS[preset].label,
}))}
/>
value={classificationRubric}
onValueChange={(preset: ClassificationRubric | null) =>
preset && handleClassificationRubricChange(preset)
}
disabled={usesCustomPrompt}
>
<SelectTrigger aria-label="Classification Rubric" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CLASSIFICATION_RUBRIC_KEYS.map((preset) => (
<SelectItem key={preset} value={preset}>
{CLASSIFICATION_RUBRIC_DESCRIPTIONS[preset].label}
</SelectItem>
))}
</SelectContent>
</Select>
</SimpleTooltip>
<Text type="secondary" style={{ display: "block", fontSize: 12 }}>
<span className="block text-xs text-muted-foreground">
{usesCustomPrompt
? "Not in use: the custom prompt below is the classifier's entire rubric."
: CLASSIFICATION_RUBRIC_DESCRIPTIONS[classificationRubric].description}
</Text>
</span>
</div>
<div>
<Text strong style={{ display: "block", marginBottom: 4 }}>
Classifier Prompt
</Text>
<strong className="block mb-1 font-semibold">Classifier Prompt</strong>
<ClassifierPromptEditor
systemPrompt={value.classifier_llm_config?.system_prompt}
onChange={handleClassifierSystemPromptChange}
@ -341,19 +357,21 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
/>
</div>
<div>
<Text strong style={{ display: "block", marginBottom: 4 }}>
If the classifier fails
</Text>
<Radio.Group
<strong className="block mb-1 font-semibold">If the classifier fails</strong>
<RadioGroup
value={value.classifier_fallback ?? DEFAULT_CLASSIFIER_FALLBACK}
onChange={(e) => handleClassifierFallbackChange(e.target.value)}
onValueChange={(fallback: unknown) => handleClassifierFallbackChange(fallback as ClassifierFallback)}
>
<Space direction="vertical">
<Radio value="heuristic">
<Text>Score with the heuristic</Text>{" "}
<Text type="secondary">— right when the classifier grades complexity too</Text>
</Radio>
<Radio value="default_model" disabled={!hasDefaultModel}>
<div className="inline-flex flex-col gap-2">
<Label className="items-start font-normal leading-normal">
<RadioGroupItem value="heuristic" className="mt-0.5" />
<span>
<span>Score with the heuristic</span>{" "}
<span className="text-muted-foreground">— right when the classifier grades complexity too</span>
</span>
</Label>
<Label className="items-start font-normal leading-normal has-data-disabled:cursor-not-allowed has-data-disabled:opacity-50">
<RadioGroupItem value="default_model" disabled={!hasDefaultModel} className="mt-0.5" />
<SimpleTooltip
content={
hasDefaultModel
@ -362,66 +380,68 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
}
>
<span>
<Text>Route to the default model{defaultModel ? ` (${defaultModel})` : ""}</Text>{" "}
<Text type="secondary">— right when your prompt grades something other than complexity</Text>
<span>Route to the default model{defaultModel ? ` (${defaultModel})` : ""}</span>{" "}
<span className="text-muted-foreground">
— right when your prompt grades something other than complexity
</span>
</span>
</SimpleTooltip>
</Radio>
</Space>
</Radio.Group>
<Text type="secondary" style={{ display: "block", fontSize: 12 }}>
</Label>
</div>
</RadioGroup>
<span className="block text-xs text-muted-foreground">
Applies when the classifier call errors, times out, or returns an unparseable response.
</Text>
</span>
</div>
<div>
<Text strong style={{ display: "block", marginBottom: 4 }}>
Context Window Size
</Text>
<InputNumber
<strong className="block mb-1 font-semibold">Context Window Size</strong>
<Input
type="number"
value={value.classifier_context_window_size ?? DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE}
onChange={handleClassifierContextWindowSizeChange}
onChange={(event) =>
handleClassifierContextWindowSizeChange(event.target.value === "" ? null : event.target.valueAsNumber)
}
min={0}
style={{ width: "100%" }}
className="w-full"
/>
<Text type="secondary" style={{ fontSize: 12 }}>
<span className="text-xs text-muted-foreground">
Number of prior user turns (tool output and harness reminders excluded) sent to the classifier as context,
so a referring follow-up like &quot;now do the same for the streaming path&quot; is classified against
what it refers to. Set to 0 to send only the current message.
</Text>
</span>
</div>
<div>
<Text strong style={{ display: "block", marginBottom: 4 }}>
Context Per-Turn Character Limit
</Text>
<InputNumber
<strong className="block mb-1 font-semibold">Context Per-Turn Character Limit</strong>
<Input
type="number"
value={value.classifier_context_per_turn_chars ?? DEFAULT_CLASSIFIER_CONTEXT_PER_TURN_CHARS}
onChange={handleClassifierContextPerTurnCharsChange}
onChange={(event) =>
handleClassifierContextPerTurnCharsChange(event.target.value === "" ? null : event.target.valueAsNumber)
}
min={1}
style={{ width: "100%" }}
className="w-full"
/>
<Text type="secondary" style={{ fontSize: 12 }}>
Prior turns longer than this are truncated.
</Text>
<span className="text-xs text-muted-foreground">Prior turns longer than this are truncated.</span>
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<Switch
checked={value.classifier_context_include_assistant_turns ?? false}
onChange={handleClassifierContextIncludeAssistantTurnsChange}
size="small"
onCheckedChange={handleClassifierContextIncludeAssistantTurnsChange}
size="sm"
aria-label="Include Assistant Turns"
/>
<Text strong>Include Assistant Turns</Text>
<strong className="font-semibold">Include Assistant Turns</strong>
<SimpleTooltip content="Off by default. Enabling it changes tier decisions, and therefore spend, for an existing router, and sends assistant text to the classifier model, which may be a different provider than the routed model.">
<Info className="size-4 text-gray-400" />
</SimpleTooltip>
</div>
<Text type="secondary" style={{ fontSize: 12 }}>
<span className="text-xs text-muted-foreground">
Let the classifier read the assistant&apos;s replies, so difficulty the model stated rather than the user
stays visible: a plan the assistant calls complex, approved with &quot;yes&quot;, is classified on the
work being approved. Context Window Size then counts the last N turns across both roles rather than the
last N user turns.
</Text>
</span>
</div>
</div>
)}
@ -429,25 +449,29 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
{value.classifier_type === "heuristic" && (
<div className="mt-4">
<div className="flex items-center gap-2 mb-1">
<Text strong>Custom Technical Keywords</Text>
<strong className="font-semibold">Custom Technical Keywords</strong>
<SimpleTooltip content="Domain-specific terms appended to the built-in technical keyword list. Prompts containing these terms score higher on the technical dimension and route to more capable models.">
<Info className="size-4 text-gray-400" />
</SimpleTooltip>
</div>
<Text type="secondary" style={{ display: "block", marginBottom: 8, fontSize: 12 }}>
<span className="block mb-2 text-xs text-muted-foreground">
Optional: Add terms to the built-in list to improve classification accuracy on the technical dimension.
(e.g., udp, kafka, terraform).
</Text>
<AntdSelect
mode="tags"
</span>
<MultiSelect
options={(customTechnicalKeywords ?? []).map((keyword) => ({ label: keyword, value: keyword }))}
value={customTechnicalKeywords ?? []}
onChange={(keywords: string[]) => onCustomTechnicalKeywordsChange?.(keywords)}
placeholder="Type a keyword and press Enter, or paste a comma-separated list"
tokenSeparators={[","]}
open={false}
suffixIcon={null}
style={{ width: "100%" }}
allowClear
onValueChange={(keywords: string[]) =>
onCustomTechnicalKeywordsChange?.(
Array.from(
new Set(keywords.flatMap((keyword) => keyword.split(",").map((part) => part.trim())).filter(Boolean)),
),
)
}
placeholder="Type a keyword and press Enter"
emptyText="Type to add a keyword"
allowCustomValues
className="w-full"
/>
</div>
)}

View file

@ -280,11 +280,28 @@ describe("ComplexityRouterConfig", () => {
);
fireEvent.click(screen.getByText("Advanced: Classification Method"));
const keywordsSection = screen.getByText("Custom Technical Keywords").closest("div")?.parentElement as HTMLElement;
const input = within(keywordsSection).getByRole("combobox");
fireEvent.change(input, { target: { value: "udp," } });
await user.type(within(keywordsSection).getByRole("combobox"), "udp");
await user.click(await screen.findByText('Create "udp"'));
expect(onCustomTechnicalKeywordsChange).toHaveBeenCalledWith(["udp"]);
});
it("splits a comma-separated keyword entry into one keyword per token", async () => {
const user = userEvent.setup();
const onCustomTechnicalKeywordsChange = vi.fn();
renderWithProviders(
<ComplexityRouterConfig
{...baseProps}
customTechnicalKeywords={[]}
onCustomTechnicalKeywordsChange={onCustomTechnicalKeywordsChange}
/>,
);
fireEvent.click(screen.getByText("Advanced: Classification Method"));
const keywordsSection = screen.getByText("Custom Technical Keywords").closest("div")?.parentElement as HTMLElement;
await user.type(within(keywordsSection).getByRole("combobox"), "udp, kafka ,terraform");
await user.click(await screen.findByText('Create "udp, kafka ,terraform"'));
expect(onCustomTechnicalKeywordsChange).toHaveBeenCalledWith(["udp", "kafka", "terraform"]);
});
it("should render an empty state when no keyword tier rules exist", () => {
renderWithProviders(<ComplexityRouterConfig {...baseProps} />);
fireEvent.click(screen.getByText("Advanced: Keyword/Semantic Matching"));
@ -314,9 +331,7 @@ describe("ComplexityRouterConfig", () => {
expect(newRules[0]).toMatchObject({ keywords: [], tier: "COMPLEX" });
});
// The dropdown is closed, so antd has nothing for Enter to select and the word would only land
// on blur. Submitting used to provide that blur; it no longer can while the row reads as empty.
it("commits a typed keyword on Enter, with the dropdown closed", async () => {
it("commits a typed keyword to the rule it was typed into", async () => {
const user = userEvent.setup();
const onKeywordTierRulesChange = vi.fn();
renderWithProviders(
@ -329,7 +344,8 @@ describe("ComplexityRouterConfig", () => {
fireEvent.click(screen.getByText("Advanced: Keyword/Semantic Matching"));
const field = screen.getByText("Keywords 1").closest("div") as HTMLElement;
await user.type(within(field).getByRole("combobox"), "invoice{enter}");
await user.type(within(field).getByRole("combobox"), "invoice");
await user.click(await screen.findByText('Create "invoice"'));
expect(onKeywordTierRulesChange).toHaveBeenCalledWith([{ id: "rule-1", keywords: ["invoice"], tier: "COMPLEX" }]);
});
@ -482,7 +498,7 @@ describe("ComplexityRouterConfig classifier fallback", () => {
};
renderWithProviders(<ComplexityRouterConfig modelInfo={mockModelInfo} value={noTiers} onChange={vi.fn()} />);
fireEvent.click(screen.getByText("Advanced: Classification Method"));
expect(screen.getByRole("radio", { name: /Route to the default model/ })).toBeDisabled();
expect(screen.getByRole("radio", { name: /Route to the default model/ })).toHaveAttribute("aria-disabled", "true");
});
it("hides the fallback choice for the heuristic classifier, which has nothing to fall back from", () => {
@ -584,8 +600,8 @@ describe("ComplexityRouterConfig classifier rubric", () => {
it("records the chat preset the operator picks", async () => {
const onChange = openClassificationPanel(llmValue);
fireEvent.mouseDown(screen.getByRole("combobox", { name: "Classification Rubric" }));
await userEvent.click(await screen.findByTitle("Chat"));
await userEvent.click(screen.getByRole("combobox", { name: "Classification Rubric" }));
await userEvent.click(await screen.findByRole("option", { name: "Chat" }));
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({ classifier_llm_config: expect.objectContaining({ classification_rubric: "chat" }) }),
);
@ -678,7 +694,7 @@ describe("ComplexityRouterConfig tier labels", () => {
/>,
);
fireEvent.click(screen.getByText("Advanced: Keyword/Semantic Matching"));
expect(screen.getByTitle("Deep")).toBeInTheDocument();
expect(screen.getByRole("combobox", { name: "Route keyword rule 1 to tier" })).toHaveTextContent("Deep");
});
});
@ -716,7 +732,7 @@ describe("ComplexityRouterConfig default model", () => {
it("shows what the tiers currently imply, so an untouched router still names its default", () => {
renderWithProviders(<ComplexityRouterConfig {...baseProps} />);
expect(screen.getByText("Derived from tiers: gpt-3.5-turbo")).toBeInTheDocument();
expect(getDefaultModelSelect()).toHaveAttribute("placeholder", "Derived from tiers: gpt-3.5-turbo");
});
it("asks for a model rather than naming a derived one when no tier holds one", () => {
@ -725,7 +741,7 @@ describe("ComplexityRouterConfig default model", () => {
tiers: { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] },
};
renderWithProviders(<ComplexityRouterConfig {...baseProps} value={noTiers} />);
expect(screen.getByText("Add a model to the Simple or Medium tier")).toBeInTheDocument();
expect(getDefaultModelSelect()).toHaveAttribute("placeholder", "Add a model to the Simple or Medium tier");
});
it("records a pinned model", async () => {
@ -734,7 +750,7 @@ describe("ComplexityRouterConfig default model", () => {
renderWithProviders(<ComplexityRouterConfig {...baseProps} onChange={onChange} />);
await user.click(getDefaultModelSelect());
await user.click((await screen.findAllByTitle("claude-3-opus")).slice(-1)[0]);
await user.click(await screen.findByRole("option", { name: "claude-3-opus" }));
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ default_model: "claude-3-opus" }));
});
@ -745,8 +761,7 @@ describe("ComplexityRouterConfig default model", () => {
const pinned: ComplexityRouterConfigValue = { ...defaultValue, default_model: "claude-3-opus" };
renderWithProviders(<ComplexityRouterConfig {...baseProps} value={pinned} onChange={onChange} />);
// eslint-disable-next-line local/no-antd-class-selectors -- antd marks the clear affordance aria-hidden, so no accessible query reaches it
await user.click(document.querySelector(".ant-select-clear") as HTMLElement);
await user.click(screen.getByRole("button", { name: "Clear" }));
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ default_model: undefined }));
});
@ -754,10 +769,7 @@ describe("ComplexityRouterConfig default model", () => {
it("shows a pinned model as the selection instead of the tier-derived one", () => {
const pinned: ComplexityRouterConfigValue = { ...defaultValue, default_model: "claude-3-opus" };
renderWithProviders(<ComplexityRouterConfig {...baseProps} value={pinned} />);
expect(
// eslint-disable-next-line local/no-antd-class-selectors -- the tier selects show the same model as a tag, so the assertion has to scope to this select's root, which antd exposes only as a class
within(getDefaultModelSelect().closest(".ant-select") as HTMLElement).getByTitle("claude-3-opus"),
).toBeInTheDocument();
expect(getDefaultModelSelect()).toHaveValue("claude-3-opus");
});
it("unlocks the default model fallback on a pin alone, with no tier to derive from", () => {
@ -770,7 +782,7 @@ describe("ComplexityRouterConfig default model", () => {
};
renderWithProviders(<ComplexityRouterConfig {...baseProps} value={pinnedNoTiers} />);
fireEvent.click(screen.getByText("Advanced: Classification Method"));
expect(screen.getByRole("radio", { name: /Route to the default model/ })).toBeEnabled();
expect(screen.getByRole("radio", { name: /Route to the default model/ })).not.toHaveAttribute("aria-disabled");
});
it("names the resolved default on the fallback option, so the destination is not a guess", () => {
@ -827,9 +839,9 @@ describe("plan-mode override", () => {
/>,
);
openPanel();
fireEvent.mouseDown(await screen.findByRole("combobox", { name: "Plan-mode minimum tier" }));
expect(await screen.findByTitle("Medium")).toBeInTheDocument();
expect(screen.queryByTitle("Reasoning")).not.toBeInTheDocument();
await userEvent.click(await screen.findByRole("combobox", { name: "Plan-mode minimum tier" }));
expect(await screen.findByRole("option", { name: "Medium" })).toBeInTheDocument();
expect(screen.queryByRole("option", { name: "Reasoning" })).not.toBeInTheDocument();
});
it("disables the toggle until some tier has models", async () => {
@ -840,6 +852,6 @@ describe("plan-mode override", () => {
/>,
);
openPanel();
expect(await screen.findByRole("switch", { name: switchName })).toBeDisabled();
expect(await screen.findByRole("switch", { name: switchName })).toHaveAttribute("aria-disabled", "true");
});
});

View file

@ -1,6 +1,13 @@
import { Info } from "lucide-react";
import { SimpleTooltip } from "@/components/ui/tooltip";
import { Select as AntdSelect, Card, Collapse, Divider, Input, Space, Switch, Typography } from "antd";
import { MultiSelect } from "@/components/shared/MultiSelect";
import { SearchSelect } from "@/components/shared/SearchSelect";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ChevronRight, Info, X } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { Card, CardContent } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
import { Separator } from "@/components/ui/separator";
import React from "react";
import { ModelGroup } from "@/components/llm_calls/fetch_models";
import AdaptiveRoutingConfig from "./AdaptiveRoutingConfig";
@ -13,8 +20,6 @@ import { type DimensionWeights, type TierBoundaries, type TokenThresholds } from
export type { DimensionWeights, TierBoundaries, TokenThresholds };
const { Text } = Typography;
export const DEFAULT_CLASSIFIER_TIMEOUT_MS = 3000;
export const DEFAULT_TIER_DISTANCE_PENALTY = 0.5;
export const DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE = 3;
@ -218,6 +223,9 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
showValidationErrors = false,
}) => {
const planModeTiers = planModeEligibleTiers(value.tiers);
const planModeTierOptions = tierOptions(value.tier_labels).filter((option) =>
(planModeTiers as string[]).includes(option.value),
);
const derivedDefaultModel = resolveComplexityDefaultModel(value.tiers);
const defaultModel = resolveComplexityDefaultModel(value.tiers, value.default_model);
@ -251,128 +259,119 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
return (
<div className="w-full max-w-none">
<Space align="center" style={{ marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>
Complexity Tier Configuration
</Typography.Title>
<div className="inline-flex items-center gap-2 mb-4">
<h4 className="m-0 text-xl font-semibold text-foreground">Complexity Tier Configuration</h4>
<SimpleTooltip content="Map each complexity tier to one or more models. Simple queries use cheaper/faster models, complex queries use more capable models.">
<Info className="size-4 text-gray-400" />
</SimpleTooltip>
</Space>
</div>
<Text type="secondary" style={{ display: "block", marginBottom: 24 }}>
<span className="block mb-6 text-muted-foreground">
The complexity router automatically classifies requests by complexity using rule-based scoring (no API calls,
&lt;1ms latency). Configure which model(s) handle each tier.
</Text>
</span>
<Text type="secondary" style={{ display: "block", marginBottom: 16, fontSize: 12 }}>
<span className="block mb-4 text-xs text-muted-foreground">
Rename a tier to use your own vocabulary in the dashboard and your spend logs. Renaming doesn&apos;t change how
requests are classified, and callers never see these names.
{value.classifier_type === "llm" &&
" Your classifier model reads these names, so clearer ones can sharpen its choices."}
</Text>
</span>
<Card>
{TIER_KEYS.map((tier, index) => {
const tierInfo = TIER_DESCRIPTIONS[tier];
const label = effectiveTierLabel(tier, value.tier_labels);
const tierMissing = showValidationErrors && value.tiers[tier].length === 0;
return (
<div key={tier}>
{index > 0 && <Divider style={{ margin: "16px 0" }} />}
<div className="mb-4">
<div className="flex items-center gap-2 mb-2">
<Text strong style={{ fontSize: 16 }}>
{label} Tier
</Text>
<SimpleTooltip content={tierInfo.description}>
<Info className="size-4 text-gray-400" />
</SimpleTooltip>
<Text type="secondary" style={{ fontSize: 12 }}>
Tier {index + 1} of {TIER_KEYS.length} &middot; {tier}
</Text>
<CardContent>
{TIER_KEYS.map((tier, index) => {
const tierInfo = TIER_DESCRIPTIONS[tier];
const label = effectiveTierLabel(tier, value.tier_labels);
const tierMissing = showValidationErrors && value.tiers[tier].length === 0;
return (
<div key={tier}>
{index > 0 && <Separator className="my-4" />}
<div className="mb-4">
<div className="flex items-center gap-2 mb-2">
<strong className="text-base font-semibold">{label} Tier</strong>
<SimpleTooltip content={tierInfo.description}>
<Info className="size-4 text-gray-400" />
</SimpleTooltip>
<span className="text-xs text-muted-foreground">
Tier {index + 1} of {TIER_KEYS.length} &middot; {tier}
</span>
</div>
<span className="block mb-2 text-xs text-muted-foreground">Examples: {tierInfo.examples}</span>
<InputGroup className="mb-2">
<InputGroupInput
value={value.tier_labels?.[tier] ?? ""}
onChange={(event) => handleTierLabelChange(tier, event.target.value)}
placeholder={`Display name (default: ${tierInfo.label})`}
aria-label={`Display name for the ${tierInfo.label} tier`}
/>
{value.tier_labels?.[tier] && (
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
aria-label={`Clear display name for the ${tierInfo.label} tier`}
onClick={() => handleTierLabelChange(tier, "")}
>
<X />
</InputGroupButton>
</InputGroupAddon>
)}
</InputGroup>
<MultiSelect
options={modelOptions}
value={value.tiers[tier]}
onValueChange={(models: string[]) => handleTierChange(tier, models)}
placeholder={`Select model(s) for ${label.toLowerCase()} queries`}
emptyText="No models found"
className={tierMissing ? "w-full border-destructive" : "w-full"}
/>
{value.tiers[tier].length > 1 && (
<span className="text-xs text-muted-foreground">
Multiple models selected — the router randomly picks among them per request (or Thompson-samples
within the pool when adaptive routing is on).
</span>
)}
{tierMissing && <span className="text-xs text-destructive">The {label} tier is required</span>}
</div>
<Text type="secondary" style={{ display: "block", marginBottom: 8, fontSize: 12 }}>
Examples: {tierInfo.examples}
</Text>
<Input
value={value.tier_labels?.[tier] ?? ""}
onChange={(event) => handleTierLabelChange(tier, event.target.value)}
placeholder={`Display name (default: ${tierInfo.label})`}
aria-label={`Display name for the ${tierInfo.label} tier`}
style={{ marginBottom: 8 }}
allowClear
/>
<AntdSelect
mode="multiple"
value={value.tiers[tier]}
onChange={(models) => handleTierChange(tier, models)}
placeholder={`Select model(s) for ${label.toLowerCase()} queries`}
showSearch
style={{ width: "100%" }}
options={modelOptions}
status={tierMissing ? "error" : undefined}
/>
{value.tiers[tier].length > 1 && (
<Text type="secondary" style={{ fontSize: 12 }}>
Multiple models selected — the router randomly picks among them per request (or Thompson-samples
within the pool when adaptive routing is on).
</Text>
)}
{tierMissing && (
<Text type="danger" style={{ fontSize: 12 }}>
The {label} tier is required
</Text>
)}
</div>
</div>
);
})}
<Divider style={{ margin: "16px 0" }} />
);
})}
<Separator className="my-4" />
<div className="mb-2">
<div className="flex items-center gap-2 mb-2">
<Text strong style={{ fontSize: 16 }}>
Default Model
</Text>
<SimpleTooltip content="Leave empty to follow the tiers. A model chosen here is pinned: it stays the default however the tiers change.">
<Info className="size-4 text-gray-400" />
</SimpleTooltip>
<div className="mb-2">
<div className="flex items-center gap-2 mb-2">
<strong className="text-base font-semibold">Default Model</strong>
<SimpleTooltip content="Leave empty to follow the tiers. A model chosen here is pinned: it stays the default however the tiers change.">
<Info className="size-4 text-gray-400" />
</SimpleTooltip>
</div>
<SearchSelect
options={modelOptions}
value={value.default_model ?? ""}
onValueChange={handleDefaultModelChange}
placeholder={
derivedDefaultModel
? `Derived from tiers: ${derivedDefaultModel}`
: "Add a model to the Simple or Medium tier"
}
emptyText="No models found"
aria-label="Default model"
/>
<span className="block mt-1 text-xs text-muted-foreground">
Used when the tier the request lands in has no model, and when the classifier fails with &quot;Route to
the default model&quot; selected.
</span>
</div>
<AntdSelect
value={value.default_model || undefined}
onChange={handleDefaultModelChange}
placeholder={
derivedDefaultModel
? `Derived from tiers: ${derivedDefaultModel}`
: "Add a model to the Simple or Medium tier"
}
aria-label="Default model"
showSearch
allowClear
style={{ width: "100%" }}
options={modelOptions}
/>
<Text type="secondary" style={{ display: "block", marginTop: 4, fontSize: 12 }}>
Used when the tier the request lands in has no model, and when the classifier fails with &quot;Route to the
default model&quot; selected.
</Text>
</div>
</CardContent>
</Card>
<Divider />
<Separator className="my-6" />
<Collapse
ghost
style={{ background: "#f9fafb", borderRadius: 8, border: "1px solid #e5e7eb" }}
items={[
<div className="rounded-lg border border-gray-200 bg-gray-50">
{[
{
key: "classifier",
label: (
<Text strong style={{ color: "#374151" }}>
Advanced: Classification Method
</Text>
),
label: <strong className="text-gray-700 font-semibold">Advanced: Classification Method</strong>,
children: (
<ClassificationMethodConfig
value={value}
@ -387,85 +386,82 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
},
{
key: "adaptive",
label: (
<Text strong style={{ color: "#374151" }}>
Advanced: Adaptive Routing
</Text>
),
label: <strong className="text-gray-700 font-semibold">Advanced: Adaptive Routing</strong>,
children: <AdaptiveRoutingConfig value={value} onChange={onChange} />,
},
{
key: "affinity",
label: (
<Text strong style={{ color: "#374151" }}>
Advanced: Affinity
</Text>
),
label: <strong className="text-gray-700 font-semibold">Advanced: Affinity</strong>,
children: (
<>
<div className="flex items-center gap-2 mb-2">
<Switch
checked={value.deployment_affinity ?? DEFAULT_DEPLOYMENT_AFFINITY}
onChange={(deploymentAffinity) => onChange({ ...value, deployment_affinity: deploymentAffinity })}
onCheckedChange={(deploymentAffinity) =>
onChange({ ...value, deployment_affinity: deploymentAffinity })
}
aria-label="Pin a session to one deployment per model group"
/>
<Text strong>Pin a session to one deployment per model group</Text>
<strong className="font-semibold">Pin a session to one deployment per model group</strong>
</div>
<Text type="secondary" style={{ display: "block", fontSize: 12, marginBottom: 12 }}>
<span className="block text-xs mb-3 text-muted-foreground">
Keeps a session on the same deployment within a group, so provider prompt caches stay warm. Turn off
to load-balance every turn.
</Text>
</span>
<div className="flex items-center gap-2 mb-2">
<Switch
checked={value.session_affinity ?? DEFAULT_SESSION_AFFINITY}
onChange={(sessionAffinity) => onChange({ ...value, session_affinity: sessionAffinity })}
onCheckedChange={(sessionAffinity) => onChange({ ...value, session_affinity: sessionAffinity })}
aria-label="Pin a session to its first model"
/>
<Text strong>Pin a session to its first model</Text>
<strong className="font-semibold">Pin a session to its first model</strong>
</div>
<Text type="secondary" style={{ display: "block", fontSize: 12 }}>
<span className="block text-xs text-muted-foreground">
Keeps a session on its first turn&apos;s model instead of re-classifying each turn. Also pins the
deployment.
</Text>
</span>
</>
),
},
{
key: "plan-mode",
label: (
<Text strong style={{ color: "#374151" }}>
Advanced: Plan-Mode Override
</Text>
),
label: <strong className="text-gray-700 font-semibold">Advanced: Plan-Mode Override</strong>,
children: (
<>
<div className="flex items-center gap-2 mb-2">
<Switch
checked={value.plan_mode_min_tier !== undefined}
disabled={planModeTiers.length === 0}
onChange={(enabled) =>
onCheckedChange={(enabled) =>
onChange({ ...value, plan_mode_min_tier: enabled ? planModeTiers.at(-1) : undefined })
}
aria-label="Route plan-mode requests to a minimum tier"
/>
<Text strong>Route plan-mode requests to a minimum tier</Text>
<strong className="font-semibold">Route plan-mode requests to a minimum tier</strong>
</div>
<Text type="secondary" style={{ display: "block", fontSize: 12, marginBottom: 12 }}>
<span className="block text-xs mb-3 text-muted-foreground">
Requests from coding agents in plan mode (Claude Code, GitHub Copilot) route to at least this tier.
The classifier still wins when it picks higher, and the override only lasts while plan mode is active.
{planModeTiers.length === 0 && " Add models to a tier to enable this."}
</Text>
</span>
{value.plan_mode_min_tier !== undefined && (
<div style={{ maxWidth: 320 }}>
<AntdSelect
aria-label="Plan-mode minimum tier"
style={{ width: "100%" }}
<Select
items={planModeTierOptions}
value={value.plan_mode_min_tier}
options={tierOptions(value.tier_labels).filter((option) =>
(planModeTiers as string[]).includes(option.value),
)}
onChange={(tier: string) => onChange({ ...value, plan_mode_min_tier: tier })}
/>
onValueChange={(tier: string | null) => tier && onChange({ ...value, plan_mode_min_tier: tier })}
>
<SelectTrigger aria-label="Plan-mode minimum tier" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{planModeTierOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</>
@ -473,23 +469,22 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
},
{
key: "response",
label: (
<Text strong style={{ color: "#374151" }}>
Advanced: Response Format
</Text>
),
label: <strong className="text-gray-700 font-semibold">Advanced: Response Format</strong>,
children: (
<>
<div className="flex items-center gap-2 mb-2">
<Switch
checked={value.return_raw_model_name ?? false}
onChange={(returnRawModelName) => onChange({ ...value, return_raw_model_name: returnRawModelName })}
onCheckedChange={(returnRawModelName) =>
onChange({ ...value, return_raw_model_name: returnRawModelName })
}
aria-label="Return raw model name"
/>
<Text strong>Return raw model name</Text>
<strong className="font-semibold">Return raw model name</strong>
</div>
<Text type="secondary" style={{ display: "block", fontSize: 12 }}>
<span className="block text-xs text-muted-foreground">
Return the resolved underlying model name in responses instead of the autorouter alias.
</Text>
</span>
</>
),
},
@ -497,11 +492,7 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
? [
{
key: "escalation",
label: (
<Text strong style={{ color: "#374151" }}>
Advanced: Escalation Keywords
</Text>
),
label: <strong className="text-gray-700 font-semibold">Advanced: Escalation Keywords</strong>,
children: <EscalationKeywords keywords={escalationKeywords} onChange={onEscalationKeywordsChange} />,
},
]
@ -510,11 +501,7 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
? [
{
key: "keyword-semantic",
label: (
<Text strong style={{ color: "#374151" }}>
Advanced: Keyword/Semantic Matching
</Text>
),
label: <strong className="text-gray-700 font-semibold">Advanced: Keyword/Semantic Matching</strong>,
children: (
<>
{onKeywordTierRulesChange && (
@ -524,9 +511,7 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
tierLabels={value.tier_labels}
/>
)}
{onKeywordTierRulesChange && onSemanticMatchingEnabledChange && (
<Divider style={{ margin: "16px 0" }} />
)}
{onKeywordTierRulesChange && onSemanticMatchingEnabledChange && <Separator className="my-4" />}
{onSemanticMatchingEnabledChange && (
<SemanticKeywordMatching
enabled={semanticMatchingEnabled}
@ -544,8 +529,16 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
},
]
: []),
]}
/>
].map(({ key, label, children }) => (
<Collapsible key={key} className="border-b border-gray-200 last:border-b-0">
<CollapsibleTrigger className="group flex w-full items-center gap-2 px-4 py-3 text-left">
<ChevronRight className="size-4 shrink-0 text-muted-foreground transition-transform group-data-panel-open:rotate-90" />
{label}
</CollapsibleTrigger>
<CollapsibleContent className="px-4 pb-4">{children}</CollapsibleContent>
</Collapsible>
))}
</div>
</div>
);
};

View file

@ -1,10 +1,8 @@
import { Info } from "lucide-react";
import { SimpleTooltip } from "@/components/ui/tooltip";
import { Select as AntdSelect, Typography } from "antd";
import { MultiSelect } from "@/components/shared/MultiSelect";
import React from "react";
const { Text } = Typography;
export const DEFAULT_ESCALATION_KEYWORDS = ["LITELLM ESCALATE"];
interface EscalationKeywordsProps {
@ -16,28 +14,24 @@ const EscalationKeywords: React.FC<EscalationKeywordsProps> = ({ keywords, onCha
return (
<div className="w-full max-w-none">
<div className="flex items-center gap-2 mb-1">
<Typography.Title level={4} style={{ margin: 0 }}>
Escalation Keywords
</Typography.Title>
<h4 className="m-0 text-xl font-semibold text-foreground">Escalation Keywords</h4>
<SimpleTooltip content="Case-sensitive phrases a user can include in their message to force a bump to the next-higher complexity tier when they aren't happy with results. They can force a stronger model, but not choose which one.">
<Info className="size-4 text-gray-400" />
</SimpleTooltip>
</div>
<Text type="secondary" style={{ display: "block", marginBottom: 8, fontSize: 12 }}>
<span className="mb-2 block text-xs text-muted-foreground">
Optional: when a user message contains one of these phrases, the request is bumped one tier higher than it would
otherwise route to. Matching is case-sensitive, so &quot;LITELLM ESCALATE&quot; only fires on the exact, shouted
form. Leave empty to disable.
</Text>
<AntdSelect
mode="tags"
</span>
<MultiSelect
options={keywords.map((keyword) => ({ label: keyword, value: keyword }))}
value={keywords}
onChange={onChange}
onValueChange={onChange}
placeholder="e.g., LITELLM ESCALATE"
tokenSeparators={[","]}
open={false}
suffixIcon={null}
style={{ width: "100%" }}
allowClear
emptyText="Type to add a phrase"
allowCustomValues
className="w-full"
/>
</div>
);

View file

@ -1,14 +1,14 @@
import { Info, Plus, Trash2 } from "lucide-react";
import { Inbox, Info, Plus, Trash2 } from "lucide-react";
import { SimpleTooltip } from "@/components/ui/tooltip";
import { Card, Empty, Select as AntdSelect, Typography } from "antd";
import { MultiSelect } from "@/components/shared/MultiSelect";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import React from "react";
import { emptyKeywordTierRuleIndexes } from "./complexity_router_keywords";
import { tierOptions } from "./complexity_router_tiers";
const { Text } = Typography;
export type ComplexityTier = "SIMPLE" | "MEDIUM" | "COMPLEX" | "REASONING";
export interface KeywordTierRule {
@ -28,29 +28,9 @@ interface KeywordTierRulesProps {
// there is no failed attempt left to surface it.
const KeywordTierRules: React.FC<KeywordTierRulesProps> = ({ rules, onChange, tierLabels }) => {
const emptyRuleIndexes = new Set(emptyKeywordTierRuleIndexes(rules));
const [drafts, setDrafts] = React.useState<Record<string, string>>({});
const setDraft = (id: string, text: string) => setDrafts((current) => ({ ...current, [id]: text }));
// The dropdown is kept closed, which leaves antd nothing for Enter to select, so a typed keyword
// would only become a tag on blur. Submitting used to supply that blur; the button is disabled
// while the row reads as empty, so Enter has to commit the word itself or the row cannot be filled.
const commitDraft = (rule: KeywordTierRule) => {
const keyword = (drafts[rule.id] ?? "").trim();
if (!keyword) return;
updateRule(rule.id, { keywords: [...rule.keywords, keyword] });
setDraft(rule.id, "");
};
const commitDraftOnEnter = (rule: KeywordTierRule) => (event: React.KeyboardEvent<HTMLElement>) => {
if (event.key !== "Enter") return;
event.preventDefault();
commitDraft(rule);
};
const replaceKeywords = (rule: KeywordTierRule) => (keywords: string[]) => {
updateRule(rule.id, { keywords });
setDraft(rule.id, "");
};
const addRule = () => {
@ -69,9 +49,7 @@ const KeywordTierRules: React.FC<KeywordTierRulesProps> = ({ rules, onChange, ti
<div className="w-full max-w-none">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Typography.Title level={4} style={{ margin: 0 }}>
Keyword Tier Overrides
</Typography.Title>
<h4 className="m-0 text-xl font-semibold text-foreground">Keyword Tier Overrides</h4>
<SimpleTooltip content="Match known terms and force the request straight to a chosen complexity tier, bypassing rule-based scoring.">
<Info className="size-4 text-gray-400" />
</SimpleTooltip>
@ -81,67 +59,71 @@ const KeywordTierRules: React.FC<KeywordTierRulesProps> = ({ rules, onChange, ti
Add keyword rule
</Button>
</div>
<Text type="secondary" style={{ display: "block", marginBottom: 16 }}>
<span className="mb-4 block text-muted-foreground">
Optional: route requests containing specific keywords directly to a tier, e.g. route &quot;invoice, refund,
billing&quot; to the medium tier.
</Text>
</span>
{rules.length === 0 ? (
<Card className="bg-gray-50">
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="No keyword tier overrides configured" />
<CardContent>
<div className="py-2 text-center">
<Inbox className="mx-auto mb-2 size-6 text-muted-foreground" aria-hidden="true" />
<p className="text-sm text-muted-foreground">No keyword tier overrides configured</p>
</div>
</CardContent>
</Card>
) : (
<div className="flex flex-col gap-3">
{rules.map((rule, index) => (
<Card key={rule.id} size="small">
<div className="flex items-end gap-3">
<div className="flex-1">
<Text strong style={{ display: "block", marginBottom: 8 }}>
Keywords {index + 1}
</Text>
<AntdSelect
mode="tags"
value={rule.keywords}
onChange={replaceKeywords(rule)}
searchValue={drafts[rule.id] ?? ""}
onSearch={(text) => setDraft(rule.id, text)}
onInputKeyDown={commitDraftOnEnter(rule)}
onBlur={() => commitDraft(rule)}
placeholder="e.g., invoice, refund, billing"
tokenSeparators={[","]}
open={false}
suffixIcon={null}
style={{ width: "100%" }}
allowClear
status={emptyRuleIndexes.has(index) ? "error" : undefined}
/>
{emptyRuleIndexes.has(index) && (
<Text type="danger" style={{ fontSize: 12 }}>
At least one keyword is required
</Text>
)}
<Card key={rule.id} size="sm">
<CardContent>
<div className="flex items-end gap-3">
<div className="flex-1">
<strong className="mb-2 block font-semibold">Keywords {index + 1}</strong>
<MultiSelect
options={rule.keywords.map((keyword) => ({ label: keyword, value: keyword }))}
value={rule.keywords}
onValueChange={replaceKeywords(rule)}
placeholder="e.g., invoice, refund, billing"
emptyText="Type to add a keyword"
allowCustomValues
className={emptyRuleIndexes.has(index) ? "w-full border-destructive" : "w-full"}
/>
{emptyRuleIndexes.has(index) && (
<span className="text-xs text-destructive">At least one keyword is required</span>
)}
</div>
<div style={{ width: 220 }}>
<strong className="mb-2 block font-semibold">Route to tier</strong>
<Select
items={tierOptions(tierLabels)}
value={rule.tier}
onValueChange={(tier: ComplexityTier | null) => tier && updateRule(rule.id, { tier })}
>
<SelectTrigger aria-label={`Route keyword rule ${index + 1} to tier`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{tierOptions(tierLabels).map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive"
aria-label={`Remove keyword rule ${index + 1}`}
onClick={() => removeRule(rule.id)}
>
<Trash2 />
</Button>
</div>
<div style={{ width: 220 }}>
<Text strong style={{ display: "block", marginBottom: 8 }}>
Route to tier
</Text>
<AntdSelect
value={rule.tier}
onChange={(tier: ComplexityTier) => updateRule(rule.id, { tier })}
options={tierOptions(tierLabels)}
style={{ width: "100%" }}
/>
</div>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive"
aria-label={`Remove keyword rule ${index + 1}`}
onClick={() => removeRule(rule.id)}
>
<Trash2 />
</Button>
</div>
</CardContent>
</Card>
))}
</div>

View file

@ -1,11 +1,11 @@
import { Info } from "lucide-react";
import { SimpleTooltip } from "@/components/ui/tooltip";
import { InputNumber, Select as AntdSelect, Switch, Typography } from "antd";
import { SearchSelect } from "@/components/shared/SearchSelect";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import React from "react";
import { ModelGroup } from "@/components/llm_calls/fetch_models";
const { Text } = Typography;
const DEFAULT_MATCH_THRESHOLD = 0.5;
interface SemanticKeywordMatchingProps {
@ -41,49 +41,49 @@ const SemanticKeywordMatching: React.FC<SemanticKeywordMatchingProps> = ({
<div className="flex items-center justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<Text className="font-medium">Semantic keyword matching</Text>
<span className="font-medium">Semantic keyword matching</span>
<SimpleTooltip content="Recognize related phrasing beyond exact keyword matches by comparing embeddings instead of plain text. Overrides direct keyword matching">
<Info className="size-4 text-gray-400" />
</SimpleTooltip>
</div>
<Text className="text-gray-500 text-sm">
<span className="text-gray-500 text-sm">
Uses same keyword-tier pairs as above and overrides direct keyword matching. Adds latency based on embedding
model network request.
</Text>
</span>
</div>
<Switch checked={enabled} onChange={onEnabledChange} aria-label="Semantic keyword matching" />
<Switch checked={enabled} onCheckedChange={onEnabledChange} aria-label="Semantic keyword matching" />
</div>
{enabled && (
<div className="grid gap-4 md:grid-cols-2 mt-4 pt-4 border-t border-gray-200">
<div>
<Text className="text-sm font-medium mb-1 block">Embedding model</Text>
<AntdSelect
value={embeddingModel}
onChange={onEmbeddingModelChange}
placeholder="Select an embedding model"
showSearch
style={{ width: "100%" }}
<span className="mb-1 block text-sm font-medium">Embedding model</span>
<SearchSelect
options={modelOptions}
status={embeddingModelMissing ? "error" : undefined}
value={embeddingModel ?? ""}
onValueChange={onEmbeddingModelChange}
placeholder="Select an embedding model"
emptyText="No embedding models found"
aria-label="Embedding model"
allowClear={false}
className={embeddingModelMissing ? "border-destructive" : undefined}
/>
{embeddingModelMissing && (
<Text type="danger" style={{ fontSize: 12 }}>
An embedding model is required
</Text>
)}
{embeddingModelMissing && <span className="text-xs text-destructive">An embedding model is required</span>}
</div>
<div>
<Text className="text-sm font-medium mb-1 block">Minimum match score</Text>
<InputNumber
<span className="mb-1 block text-sm font-medium">Minimum match score</span>
<Input
type="number"
value={matchThreshold}
onChange={(value) => onMatchThresholdChange(value ?? DEFAULT_MATCH_THRESHOLD)}
onChange={(event) =>
onMatchThresholdChange(event.target.value === "" ? DEFAULT_MATCH_THRESHOLD : event.target.valueAsNumber)
}
min={0}
max={1}
step={0.05}
style={{ width: "100%" }}
className="w-full"
/>
<Text className="text-gray-500 text-xs mt-1 block">Match only at or above this similarity score.</Text>
<span className="mt-1 block text-xs text-gray-500">Match only at or above this similarity score.</span>
</div>
</div>
)}

View file

@ -27,7 +27,7 @@ const ALL_FAMILY_MODELS: ModelGroup[] = [
const ANTHROPIC_ONLY_MODEL = ANTHROPIC_TIERS.COMPLEX[0];
const openTemplateDropdown = (): void => {
fireEvent.mouseDown(within(screen.getByTestId("template-selector")).getByRole("combobox"));
fireEvent.click(screen.getByTestId("template-selector"));
};
// Detailed Configuration is collapsed by default, so any test reaching into it (a tier select, an
@ -36,15 +36,32 @@ const expandDetailedConfiguration = (): void => {
fireEvent.click(screen.getByTestId("detailed-configuration-toggle"));
};
const visibleOptions = (): HTMLElement[] =>
// eslint-disable-next-line local/no-antd-class-selectors -- antd puts role="option" only on a hidden mirror list of raw values; the visible options carry no role, no aria-disabled, and only a tooltip in title
Array.from(document.querySelectorAll<HTMLElement>(".ant-select-item-option"));
const visibleOptions = (): HTMLElement[] => screen.queryAllByRole("option");
const optionByLabel = (label: string): HTMLElement | undefined =>
visibleOptions().find((el) => el.textContent?.startsWith(label));
// eslint-disable-next-line local/no-antd-class-selectors -- antd signals option disabled state only through this class
const isOptionDisabled = (option: HTMLElement): boolean => option.classList.contains("ant-select-item-option-disabled");
const isOptionDisabled = (option: HTMLElement): boolean => option.getAttribute("aria-disabled") === "true";
const selectTemplate = async (label: string): Promise<void> => {
await userEvent.click(optionByLabel(label)!);
};
// Opens the dropdown only when it is closed, since openTemplateDropdown toggles: waiting on a
// second preset in the same test would otherwise close the list out from under the poll.
const waitForPresetEnabled = async (label: string) => {
if (visibleOptions().length === 0) openTemplateDropdown();
await waitFor(() => {
expect(isOptionDisabled(optionByLabel(label)!)).toBe(false);
});
};
// The keyword field is a combobox that offers whatever is typed as a "Create ..." entry, so a
// keyword only lands on the rule once that entry is picked.
const addKeyword = async (user: ReturnType<typeof userEvent.setup>, field: HTMLElement, keyword: string) => {
await user.type(within(field).getByRole("combobox"), keyword);
await user.click(await screen.findByText(`Create "${keyword}"`));
};
const { mockFetchAvailableModels, mockFetchAllModelDeployments } = vi.hoisted(() => ({
mockFetchAvailableModels: vi.fn(),
@ -202,10 +219,7 @@ describe("AddAutoRouterTab", () => {
await user.click(screen.getByRole("button", { name: /add keyword rule/i }));
expect(screen.getByRole("button", { name: /add auto router/i })).toBeDisabled();
await user.type(
within(screen.getByText("Keywords 1").closest("div") as HTMLElement).getByRole("combobox"),
"invoice{enter}",
);
await addKeyword(user, screen.getByText("Keywords 1").closest("div") as HTMLElement, "invoice");
expect(screen.getByRole("button", { name: /add auto router/i })).toBeEnabled();
expect(screen.queryByText("At least one keyword is required")).not.toBeInTheDocument();
@ -221,10 +235,7 @@ describe("AddAutoRouterTab", () => {
expandDetailedConfiguration();
await user.click(screen.getByText("Advanced: Keyword/Semantic Matching"));
await user.click(screen.getByRole("button", { name: /add keyword rule/i }));
await user.type(
within(screen.getByText("Keywords 1").closest("div") as HTMLElement).getByRole("combobox"),
"invoice{enter}",
);
await addKeyword(user, screen.getByText("Keywords 1").closest("div") as HTMLElement, "invoice");
await user.click(screen.getByRole("button", { name: /add keyword rule/i }));
expect(await screen.findAllByText("At least one keyword is required")).toHaveLength(1);
@ -242,7 +253,7 @@ describe("AddAutoRouterTab", () => {
await user.click(screen.getByText("Advanced: Keyword/Semantic Matching"));
await user.click(screen.getByRole("button", { name: /add keyword rule/i }));
const keywordsField = screen.getByText("Keywords 1").closest("div") as HTMLElement;
await user.type(within(keywordsField).getByRole("combobox"), "invoice{enter}");
await addKeyword(user, keywordsField, "invoice");
await user.click(screen.getByRole("button", { name: /add auto router/i }));
await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalled());
@ -407,7 +418,7 @@ describe("AddAutoRouterTab", () => {
await user.click(screen.getByText("Advanced: Keyword/Semantic Matching"));
await user.click(screen.getByRole("button", { name: /add keyword rule/i }));
const keywordsField = screen.getByText("Keywords 1").closest("div") as HTMLElement;
await user.type(within(keywordsField).getByRole("combobox"), "invoice{enter}");
await addKeyword(user, keywordsField, "invoice");
await user.click(screen.getByTestId("auto-router-test-routing-btn"));
await user.type(await screen.findByTestId("auto-router-routing-test-prompt"), "reconcile this invoice");
@ -452,17 +463,6 @@ describe("AddAutoRouterTab", () => {
});
describe("template presets", () => {
// Opens the dropdown once, then waits out the useQuery load: an open antd Select re-renders its
// already-mounted options in place as state changes, so polling only re-reads the DOM here.
// Re-firing the open/close mousedown on every poll (calling openTemplateDropdown inside the
// waitFor callback) fights the dropdown's own open/close animation and hangs the test.
const waitForPresetEnabled = async (label: string) => {
openTemplateDropdown();
await waitFor(() => {
expect(isOptionDisabled(optionByLabel(label)!)).toBe(false);
});
};
it("disables every preset while the model list is loading", async () => {
let resolveModels: (models: ModelGroup[]) => void = () => {};
mockFetchAvailableModels.mockImplementation(
@ -537,7 +537,7 @@ describe("AddAutoRouterTab", () => {
renderWithProviders(<Harness />);
await waitForPresetEnabled("Anthropic Family");
fireEvent.click(optionByLabel("Anthropic Family")!);
await selectTemplate("Anthropic Family");
expect(screen.queryByText("Advanced: Keyword/Semantic Matching")).not.toBeInTheDocument();
expect(
@ -548,11 +548,11 @@ describe("AddAutoRouterTab", () => {
).toBeInTheDocument();
});
it("expands detailed configuration when Custom Configuration is chosen", () => {
it("expands detailed configuration when Custom Configuration is chosen", async () => {
renderWithProviders(<Harness />);
openTemplateDropdown();
fireEvent.click(optionByLabel("Custom Configuration")!);
await selectTemplate("Custom Configuration");
expect(screen.getByText("Advanced: Keyword/Semantic Matching")).toBeInTheDocument();
});
@ -561,7 +561,7 @@ describe("AddAutoRouterTab", () => {
mockFetchAvailableModels.mockResolvedValue(ALL_FAMILY_MODELS);
renderWithProviders(<Harness />);
await waitForPresetEnabled("Anthropic Family");
fireEvent.click(optionByLabel("Anthropic Family")!);
await selectTemplate("Anthropic Family");
expect(screen.queryByText("Advanced: Keyword/Semantic Matching")).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId("detailed-configuration-toggle"));
@ -578,7 +578,7 @@ describe("AddAutoRouterTab", () => {
renderWithProviders(<Harness />);
await waitForPresetEnabled("Anthropic Family");
fireEvent.click(optionByLabel("Anthropic Family")!);
await selectTemplate("Anthropic Family");
await user.type(screen.getByPlaceholderText(/smart_router/i), "anthropic-router");
await user.click(screen.getByRole("button", { name: /add auto router/i }));
@ -600,7 +600,7 @@ describe("AddAutoRouterTab", () => {
const { container } = renderWithProviders(<Harness />);
await waitForPresetEnabled("Anthropic Family");
fireEvent.click(optionByLabel("Anthropic Family")!);
await selectTemplate("Anthropic Family");
fireEvent.change(screen.getByPlaceholderText(/smart_router/i), { target: { value: "stale-model-router" } });
expect(screen.getByRole("button", { name: /add auto router/i })).toBeEnabled();
@ -622,26 +622,16 @@ describe("AddAutoRouterTab", () => {
describe("default model pin", () => {
const PINNED_MODEL = "pinned-default-model";
const waitForPresetEnabled = async (label: string) => {
openTemplateDropdown();
await waitFor(() => {
expect(isOptionDisabled(optionByLabel(label)!)).toBe(false);
});
};
const applyPresetAndPin = async (user: ReturnType<typeof userEvent.setup>) => {
await waitForPresetEnabled("Anthropic Family");
fireEvent.click(optionByLabel("Anthropic Family")!);
await selectTemplate("Anthropic Family");
// Applying a preset collapses Detailed Configuration, so the default model row is behind it.
expandDetailedConfiguration();
const defaultModel = screen.getByRole("combobox", { name: "Default model" });
await user.click(defaultModel);
// antd virtualizes the option list and jsdom gives every row zero height, so options past
// the first window never render. Typing filters the list down to the pin instead of relying
// on its index, which adding a preset to the bundled JSON shifts.
await user.type(defaultModel, PINNED_MODEL);
await user.click((await screen.findAllByTitle(PINNED_MODEL)).slice(-1)[0]);
await user.click(await screen.findByRole("option", { name: PINNED_MODEL }));
};
beforeEach(() => {
@ -692,19 +682,12 @@ describe("AddAutoRouterTab", () => {
mockFetchAvailableModels.mockResolvedValue(ALL_FAMILY_MODELS);
});
const waitForPresetEnabled = async (label: string) => {
openTemplateDropdown();
await waitFor(() => {
expect(isOptionDisabled(optionByLabel(label)!)).toBe(false);
});
};
it("omits plan_mode_min_tier from the payload when never touched", async () => {
const user = userEvent.setup();
renderWithProviders(<Harness />);
await waitForPresetEnabled("Anthropic Family");
fireEvent.click(optionByLabel("Anthropic Family")!);
await selectTemplate("Anthropic Family");
await user.type(screen.getByPlaceholderText(/smart_router/i), "no-plan-router");
await user.click(screen.getByRole("button", { name: /add auto router/i }));
@ -719,7 +702,7 @@ describe("AddAutoRouterTab", () => {
renderWithProviders(<Harness />);
await waitForPresetEnabled("Anthropic Family");
fireEvent.click(optionByLabel("Anthropic Family")!);
await selectTemplate("Anthropic Family");
expandDetailedConfiguration();
await user.click(screen.getByText("Advanced: Plan-Mode Override"));
await user.click(await screen.findByRole("switch", { name: "Route plan-mode requests to a minimum tier" }));
@ -773,7 +756,7 @@ describe("AddAutoRouterTab", () => {
await waitFor(() => {
expect(isOptionDisabled(optionByLabel("Anthropic Family")!)).toBe(false);
});
fireEvent.click(optionByLabel("Anthropic Family")!);
await selectTemplate("Anthropic Family");
expect(screen.getByText("Advanced: Keyword/Semantic Matching")).toBeInTheDocument();
@ -862,7 +845,7 @@ describe("AddAutoRouterTab", () => {
await waitFor(() => {
expect(isOptionDisabled(optionByLabel("Anthropic Family")!)).toBe(false);
});
fireEvent.click(optionByLabel("Anthropic Family")!);
await selectTemplate("Anthropic Family");
await user.type(screen.getByPlaceholderText(/smart_router/i), "wildcard-router");
await user.click(screen.getByRole("button", { name: /add auto router/i }));

View file

@ -1,13 +1,14 @@
import React, { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useWatch } from "react-hook-form";
import { Card, Select as AntdSelect } from "antd";
import { Card } 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { useZodForm } from "@/lib/forms/useZodForm";
@ -289,6 +290,14 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
[presetAvailability],
);
const templateItems = React.useMemo(
() => [
...sortedPresetOptions.map(({ preset }) => ({ value: preset.key, label: preset.label })),
{ value: "custom", label: "Custom Configuration" },
],
[sortedPresetOptions],
);
const applyPrefill = (prefill: PresetPrefill) => {
setComplexityRouterConfig(prefill.complexityRouterConfig);
setCustomTechnicalKeywords(prefill.customTechnicalKeywords);
@ -486,49 +495,52 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
<div>
<label className="block text-sm font-medium text-foreground 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"
<Select
items={templateItems}
value={selectedPreset ?? null}
onValueChange={(presetKey: string | null) => handlePresetChange(presetKey ?? undefined)}
>
{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;
<SelectTrigger data-testid="template-selector" className="w-full">
<SelectValue placeholder="Choose a template or select Custom to define your own" />
</SelectTrigger>
<SelectContent>
{sortedPresetOptions.map(({ preset, availability: presetState }) => {
const disabledHint = presetDisabledHint(presetState);
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;
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>
return (
<SelectItem
key={preset.key}
value={preset.key}
label={preset.label}
disabled={disabledHint !== null}
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>
</SelectItem>
);
})}
<SelectItem 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>
</SelectItem>
</SelectContent>
</Select>
{modelsUnverifiable && (
<div className="text-xs mt-1 text-red-500 dark:text-red-400">
Could not load available models.{" "}

View file

@ -1,14 +1,18 @@
import React from "react";
import { Switch, Select, Tooltip, DatePicker } from "antd";
import { MultiSelect } from "@/components/shared/MultiSelect";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { SimpleTooltip } from "@/components/ui/tooltip";
import type { Dayjs } from "dayjs";
import { ChevronDown, Info } from "lucide-react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input";
import { Row, Col, Typography } from "antd";
import TextArea from "antd/es/input/TextArea";
import { Textarea } from "@/components/ui/textarea";
import { Team } from "../key_team_helpers/key_list";
import { antdRules } from "../common_components/antdFormRules";
import { labelWithHint } from "@/components/shared/form/LabelWithHint";
import { MountedFormField } from "../common_components/MountedFormField";
import { UtcDateTimeInput } from "@/components/shared/form/UtcDateTimeInput";
import CacheControlInjectionPoints, {
CACHE_CONTROL_LABEL,
CACHE_CONTROL_TOOLTIP,
@ -30,7 +34,6 @@ import {
PTU_END_FIELD,
} from "../../utils/ptuValidation";
import { usePtuCostAttributionEnabled } from "@/app/(dashboard)/hooks/uiSettings/usePtuCostAttributionEnabled";
const { Link } = Typography;
interface AdvancedSettingsProps {
showAdvancedSettings: boolean;
@ -51,6 +54,11 @@ const USAGE_COST_FIELDS = [
const REVALIDATED_WHEN_PTU_COUNT_CHANGES = [PTU_RATE_FIELD, PTU_START_FIELD, ...USAGE_COST_FIELDS];
const PRICING_MODEL_ITEMS = [
{ value: "per_token", label: "Per Million Tokens" },
{ value: "per_second", label: "Per Second" },
] as const;
const validateNumber = (_: unknown, value: unknown) => {
if (!value) {
return Promise.resolve();
@ -79,6 +87,14 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
const [showCacheControl, setShowCacheControl] = React.useState(false);
const ptuCostAttributionEnabled = usePtuCostAttributionEnabled();
const handlePricingModelChange =
(onChange: (value: string) => void) =>
(value: "per_token" | "per_second" | null): void => {
if (value === null) return;
onChange(value);
setPricingModel(value);
};
return (
<>
<Collapsible className="mt-2 mb-4 overflow-hidden rounded-lg border">
@ -93,11 +109,10 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
<Switch
id={control.id}
checked={control.value === true}
onChange={(checked) => {
onCheckedChange={(checked) => {
control.onChange(checked);
setCustomPricing(checked);
}}
className="bg-gray-600"
/>
)}
</MountedFormField>
@ -107,7 +122,7 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
label={
<span>
Attached Knowledge Bases (RAG){" "}
<Tooltip title="Vector stores to use for RAG. Every request to this model will automatically retrieve context from these knowledge bases.">
<SimpleTooltip content="Vector stores to use for RAG. Every request to this model will automatically retrieve context from these knowledge bases.">
<a
href="https://docs.litellm.ai/docs/completion/knowledgebase"
target="_blank"
@ -116,7 +131,7 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
>
<Info className="ml-1 inline size-3.5 align-text-bottom" />
</a>
</Tooltip>
</SimpleTooltip>
</span>
}
className="mt-4"
@ -137,7 +152,7 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
label={
<span>
Guardrails{" "}
<Tooltip title="Apply safety guardrails to this key to filter content or enforce policies">
<SimpleTooltip content="Apply safety guardrails to this key to filter content or enforce policies">
<a
href="https://docs.litellm.ai/docs/proxy/guardrails/quick_start"
target="_blank"
@ -146,41 +161,39 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
>
<Info className="ml-1 inline size-3.5 align-text-bottom" />
</a>
</Tooltip>
</SimpleTooltip>
</span>
}
className="mt-4"
help="Select existing guardrails. Go to 'Guardrails' tab to create new guardrails."
>
{(control) => (
<Select
<MultiSelect
id={control.id}
mode="tags"
style={{ width: "100%" }}
placeholder="Select or enter guardrails"
value={control.value as string[] | undefined}
onChange={control.onChange}
onBlur={control.onBlur}
emptyText="Type to add a guardrail"
value={(control.value as string[] | undefined) ?? []}
onValueChange={control.onChange}
options={guardrailsList.map((name) => ({ value: name, label: name }))}
allowCustomValues
/>
)}
</MountedFormField>
<MountedFormField name="tags" label="Tags" className="mb-4">
{(control) => (
<Select
<MultiSelect
id={control.id}
mode="tags"
style={{ width: "100%" }}
placeholder="Select or enter tags"
value={control.value as string[] | undefined}
onChange={control.onChange}
onBlur={control.onBlur}
emptyText="Type to add a tag"
value={(control.value as string[] | undefined) ?? []}
onValueChange={control.onChange}
options={Object.values(tagsList).map((tag) => ({
value: tag.name,
label: tag.name,
title: tag.description || tag.name,
description: tag.description || undefined,
}))}
allowCustomValues
/>
)}
</MountedFormField>
@ -249,11 +262,9 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
className="mb-4"
>
{(control) => (
<DatePicker
<UtcDateTimeInput
id={control.id}
showTime
style={{ width: "100%" }}
value={control.value as never}
value={control.value as Dayjs | null}
onChange={control.onChange}
onBlur={control.onBlur}
/>
@ -273,11 +284,9 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
className="mb-4"
>
{(control) => (
<DatePicker
<UtcDateTimeInput
id={control.id}
showTime
style={{ width: "100%" }}
value={control.value as never}
value={control.value as Dayjs | null}
onChange={control.onChange}
onBlur={control.onBlur}
/>
@ -291,19 +300,21 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
<MountedFormField name="pricing_model" label="Pricing Model" className="mb-4">
{(control) => (
<Select
id={control.id}
defaultValue="per_token"
value={control.value as "per_token" | "per_second" | undefined}
onBlur={control.onBlur}
onChange={(value: "per_token" | "per_second") => {
control.onChange(value);
setPricingModel(value);
}}
options={[
{ value: "per_token", label: "Per Million Tokens" },
{ value: "per_second", label: "Per Second" },
]}
/>
items={PRICING_MODEL_ITEMS}
value={(control.value as "per_token" | "per_second" | undefined) ?? "per_token"}
onValueChange={handlePricingModelChange(control.onChange)}
>
<SelectTrigger id={control.id} onBlur={control.onBlur} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PRICING_MODEL_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</MountedFormField>
@ -401,20 +412,20 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
"Use in pass through routes",
<span>
Allow using these credentials in pass through routes.{" "}
<Link href="https://docs.litellm.ai/docs/pass_through/vertex_ai" target="_blank">
<a
href="https://docs.litellm.ai/docs/pass_through/vertex_ai"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline-offset-4 hover:underline"
>
Learn more
</Link>
</a>
</span>,
)}
className="mb-4 mt-4"
>
{(control) => (
<Switch
id={control.id}
checked={control.value === true}
onChange={control.onChange}
className="bg-gray-600"
/>
<Switch id={control.id} checked={control.value === true} onCheckedChange={control.onChange} />
)}
</MountedFormField>
@ -427,11 +438,10 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
<Switch
id={control.id}
checked={control.value === true}
onChange={(checked) => {
onCheckedChange={(checked) => {
control.onChange(checked);
setShowCacheControl(checked);
}}
className="bg-gray-600"
/>
)}
</MountedFormField>
@ -456,7 +466,7 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
rules={{ validate: antdRules({ validator: formItemValidateJSON }) }}
>
{(control) => (
<TextArea
<Textarea
id={control.id}
value={(control.value as string | undefined) ?? ""}
onChange={control.onChange}
@ -470,17 +480,19 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
/>
)}
</MountedFormField>
<Row className="mb-4">
<Col span={10}></Col>
<Col span={10}>
<p className="text-muted-foreground text-sm">
Pass JSON of litellm supported params{" "}
<Link href="https://docs.litellm.ai/docs/completion/input" target="_blank">
litellm.completion() call
</Link>
</p>
</Col>
</Row>
<div className="grid grid-cols-24 mb-4">
<p className="col-start-11 col-span-10 text-muted-foreground text-sm">
Pass JSON of litellm supported params{" "}
<a
href="https://docs.litellm.ai/docs/completion/input"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline-offset-4 hover:underline"
>
litellm.completion() call
</a>
</p>
</div>
<MountedFormField
name="model_info_params"
label={labelWithHint(
@ -491,7 +503,7 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
rules={{ validate: antdRules({ validator: formItemValidateJSON }) }}
>
{(control) => (
<TextArea
<Textarea
id={control.id}
value={(control.value as string | undefined) ?? ""}
onChange={control.onChange}

View file

@ -1,6 +1,7 @@
import React, { useEffect, useState } from "react";
import { Table } from "antd";
import type { ColumnDef } from "@tanstack/react-table";
import { useFormContext, useWatch } from "react-hook-form";
import { DataTable } from "@/components/shared/DataTable";
import { Input } from "@/components/ui/input";
import { SimpleTooltip } from "@/components/ui/tooltip";
import { antdRules } from "../common_components/antdFormRules";
@ -136,20 +137,20 @@ const ConditionalPublicModelName: React.FC = () => {
const liteLLMModelTooltipContent = <div>The model name LiteLLM will send to the LLM API</div>;
const columns = [
const columns: ColumnDef<ModelMapping>[] = [
{
title: (
id: "public_name",
accessorKey: "public_name",
header: () => (
<span className="flex items-center">
Public Model Name
<SimpleTooltip content={publicNameTooltipContent} width="500px" />
</span>
),
dataIndex: "public_name",
key: "public_name",
render: (text: string, record: any, index: number) => {
cell: ({ row }) => {
return (
<Input
value={text}
value={row.original.public_name}
onChange={(e) => {
const newValue = e.target.value;
const newMappings = [...((form.getValues("model_mappings") as ModelMapping[]) ?? [])];
@ -175,7 +176,7 @@ const ConditionalPublicModelName: React.FC = () => {
finalPublicName = newValue.slice(0, -3); // Remove "-1m" (3 characters)
}
newMappings[index].public_name = finalPublicName;
newMappings[row.index].public_name = finalPublicName;
form.setValue("model_mappings", newMappings);
}}
/>
@ -183,14 +184,14 @@ const ConditionalPublicModelName: React.FC = () => {
},
},
{
title: (
id: "litellm_model",
accessorKey: "litellm_model",
header: () => (
<span className="flex items-center">
LiteLLM Model Name
<SimpleTooltip content={liteLLMModelTooltipContent} width="360px" />
</span>
),
dataIndex: "litellm_model",
key: "litellm_model",
},
];
@ -208,12 +209,12 @@ const ConditionalPublicModelName: React.FC = () => {
className="mb-4"
>
{(control) => (
<Table
<DataTable
key={tableKey} // Add key to force re-render
dataSource={control.value as ModelMapping[] | undefined}
data={(control.value as ModelMapping[] | undefined) ?? []}
columns={columns}
pagination={false}
size="small"
getRowId={(row) => row.litellm_model}
size="compact"
/>
)}
</MountedFormField>

View file

@ -1,8 +1,7 @@
import React from "react";
import { Select as AntSelect } from "antd";
import { MultiSelect } from "@/components/shared/MultiSelect";
import { useFormContext, useWatch } from "react-hook-form";
import { Input } from "@/components/ui/input";
import { Row, Col } from "antd";
import { antdRequired } from "../common_components/antdFormRules";
import { labelWithHint } from "@/components/shared/form/LabelWithHint";
import { MountedFormField, type MountedFormValues } from "../common_components/MountedFormField";
@ -133,21 +132,15 @@ const LiteLLMModelNameField: React.FC<LiteLLMModelNameFieldProps> = ({
}}
/>
) : providerModels.length > 0 ? (
<AntSelect
<MultiSelect
id={control.id}
data-testid="model-name-select"
mode="multiple"
allowClear
showSearch
placeholder="Select models"
value={control.value as string[] | undefined}
onBlur={control.onBlur}
onChange={(value) => {
emptyText="No models found"
value={(control.value as string[] | undefined) ?? []}
onValueChange={(value: string[]) => {
control.onChange(value);
handleModelChange(value);
}}
optionFilterProp="children"
filterOption={(input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase())}
options={[
{
label: "Custom Model Name (Enter below)",
@ -162,7 +155,7 @@ const LiteLLMModelNameField: React.FC<LiteLLMModelNameFieldProps> = ({
value: model,
})),
]}
style={{ width: "100%" }}
className="w-full"
/>
) : (
<Input
@ -199,16 +192,13 @@ const LiteLLMModelNameField: React.FC<LiteLLMModelNameFieldProps> = ({
)}
</MountedFormField>
)}
<Row>
<Col span={10}></Col>
<Col span={14}>
<p className="text-sm mb-3 mt-1">
{selectedProvider === Providers.Azure
? "Your deployment name will be saved as the public model name, and LiteLLM will use 'azure/deployment-name' internally"
: "The model name LiteLLM will send to the LLM API"}
</p>
</Col>
</Row>
<div className="grid grid-cols-24">
<p className="col-start-11 col-span-14 text-sm mb-3 mt-1">
{selectedProvider === Providers.Azure
? "Your deployment name will be saved as the public model name, and LiteLLM will use 'azure/deployment-name' internally"
: "The model name LiteLLM will send to the LLM API"}
</p>
</div>
</>
);
};

View file

@ -1,7 +1,9 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { useFormContext } from "react-hook-form";
import { Providers } from "../provider_info_helpers";
import type { MountedFormValues } from "../common_components/MountedFormField";
import { MountedFormHost } from "../../../tests/mounted-form-host";
import ProviderSpecificFields from "./provider_specific_fields";
@ -38,6 +40,19 @@ vi.mock("../networking", async () => {
},
],
},
{
provider: "Vertex_AI",
provider_display_name: Providers.Vertex_AI,
litellm_provider: "vertex_ai",
default_model_placeholder: "gemini-pro",
credential_fields: [
{
key: "vertex_credentials",
label: "Vertex Credentials",
field_type: "upload",
},
],
},
{
provider: "Hosted_Vllm",
provider_display_name: Providers.Hosted_Vllm,
@ -124,7 +139,52 @@ const createQueryClient = () =>
},
});
const VertexCredentialsProbe = () => {
const { watch } = useFormContext<MountedFormValues>();
return <output data-testid="vertex-credentials">{String(watch("vertex_credentials") ?? "")}</output>;
};
describe("ProviderSpecificFields", () => {
it("reads a picked service-account file into the vertex credentials field", async () => {
const queryClient = createQueryClient();
render(
<QueryClientProvider client={queryClient}>
<MountedFormHost>
<ProviderSpecificFields selectedProvider={"Vertex_AI" as Providers} />
<VertexCredentialsProbe />
</MountedFormHost>
</QueryClientProvider>,
);
const fileInput = await screen.findByLabelText("Vertex Credentials");
const serviceAccount = '{"project_id":"example"}';
fireEvent.change(fileInput, {
target: { files: [new File([serviceAccount], "vertex.json", { type: "application/json" })] },
});
await waitFor(() => expect(screen.getByTestId("vertex-credentials")).toHaveTextContent(serviceAccount));
});
it("ignores a picked file that is not JSON", async () => {
const queryClient = createQueryClient();
render(
<QueryClientProvider client={queryClient}>
<MountedFormHost>
<ProviderSpecificFields selectedProvider={"Vertex_AI" as Providers} />
<VertexCredentialsProbe />
</MountedFormHost>
</QueryClientProvider>,
);
const fileInput = await screen.findByLabelText("Vertex Credentials");
fireEvent.change(fileInput, {
target: { files: [new File(["not json"], "vertex.txt", { type: "text/plain" })] },
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(screen.getByTestId("vertex-credentials")).toBeEmptyDOMElement();
});
it("should render", async () => {
const queryClient = createQueryClient();
render(
@ -174,8 +234,11 @@ describe("ProviderSpecificFields", () => {
const apiKeyInput = await screen.findByLabelText("OpenAI API Key");
expect(apiKeyInput).toHaveAttribute("type", "password");
fireEvent.click(screen.getByLabelText("eye-invisible"));
fireEvent.click(screen.getByRole("button", { name: "Show password" }));
expect(await screen.findByLabelText("OpenAI API Key")).toHaveAttribute("type", "text");
fireEvent.click(screen.getByRole("button", { name: "Hide password" }));
expect(await screen.findByLabelText("OpenAI API Key")).toHaveAttribute("type", "password");
});
it("should render the provider specific fields for vLLM", async () => {

View file

@ -1,6 +1,8 @@
import { useProviderFields } from "@/app/(dashboard)/hooks/providers/useProviderFields";
import { PasswordInput } from "@/components/shared/PasswordInput";
import { Input } from "@/components/ui/input";
import { Col, Input as AntdInput, Row, Select, Typography, Upload, UploadProps } from "antd";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Upload as UploadIcon } from "lucide-react";
import React from "react";
@ -14,13 +16,21 @@ import {
import { CredentialItem, ProviderCredentialFieldMetadata } from "../networking";
import { provider_map, Providers } from "../provider_info_helpers";
import { labelWithHint } from "@/components/shared/form/LabelWithHint";
const { Link } = Typography;
interface ProviderSpecificFieldsProps {
selectedProvider: Providers;
uploadProps?: UploadProps;
}
const readTextFile = (file: File, onLoaded: (contents: string) => void) => {
const reader = new FileReader();
reader.onload = (event) => {
if (event.target) {
onLoaded(event.target.result as string);
}
};
reader.readAsText(file);
};
interface ProviderCredentialField {
key: string;
label: string;
@ -107,9 +117,18 @@ export const createCredentialFromModel = (provider: string, modelData: any): Cre
return credential;
};
const ProviderSpecificFields: React.FC<ProviderSpecificFieldsProps> = ({ selectedProvider, uploadProps }) => {
const ProviderSpecificFields: React.FC<ProviderSpecificFieldsProps> = ({ selectedProvider }) => {
const selectedProviderEnum = Providers[selectedProvider as keyof typeof Providers] as Providers;
const form = useFormContext<MountedFormValues>();
const credentialsFileRef = React.useRef<HTMLInputElement>(null);
const pickCredentialsFile =
(onLoaded: (contents: string) => void) => (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file?.type === "application/json") {
readTextFile(file, onLoaded);
}
};
const { data: providerMetadata, isLoading, error: loadError } = useProviderFields();
@ -206,67 +225,51 @@ const ProviderSpecificFields: React.FC<ProviderSpecificFieldsProps> = ({ selecte
[form, hasApiVersionField],
);
const handleUpload = {
name: "file",
accept: ".json",
beforeUpload: (file: any) => {
if (file.type === "application/json") {
const reader = new FileReader();
reader.onload = (e) => {
if (e.target) {
const jsonStr = e.target.result as string;
form.setValue("vertex_credentials", jsonStr);
}
};
reader.readAsText(file);
}
// Prevent upload
return false;
},
};
const renderFieldControl = (field: ProviderCredentialField, control: MountedFieldControlProps) => {
if (field.type === "select") {
return (
<Select
id={control.id}
value={control.value as string | undefined}
onChange={control.onChange}
onBlur={control.onBlur}
placeholder={field.placeholder}
defaultValue={field.defaultValue}
items={(field.options ?? []).map((option) => ({ value: option, label: option }))}
value={(control.value as string | undefined) ?? field.defaultValue ?? null}
onValueChange={control.onChange}
>
{field.options?.map((option) => (
<Select.Option key={option} value={option}>
{option}
</Select.Option>
))}
<SelectTrigger id={control.id} onBlur={control.onBlur} className="w-full">
<SelectValue placeholder={field.placeholder} />
</SelectTrigger>
<SelectContent>
{field.options?.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
if (field.type === "upload") {
return (
<Upload
{...handleUpload}
onChange={(info) => {
control.onChange(info);
if (uploadProps?.onChange) {
uploadProps.onChange(info);
}
}}
>
<Button variant="outline">
<>
<Button type="button" variant="outline" className="w-fit" onClick={() => credentialsFileRef.current?.click()}>
<UploadIcon />
Click to Upload
</Button>
</Upload>
<input
ref={credentialsFileRef}
id={control.id}
type="file"
accept=".json"
className="sr-only"
onBlur={control.onBlur}
onChange={pickCredentialsFile(control.onChange)}
/>
</>
);
}
if (field.type === "textarea") {
return (
<AntdInput.TextArea
<Textarea
id={control.id}
value={control.value as string | undefined}
onChange={control.onChange}
@ -274,14 +277,14 @@ const ProviderSpecificFields: React.FC<ProviderSpecificFieldsProps> = ({ selecte
placeholder={field.placeholder}
defaultValue={field.defaultValue}
rows={6}
style={{ fontFamily: "monospace", fontSize: "12px" }}
className="font-mono text-xs"
/>
);
}
if (field.type === "password") {
return (
<AntdInput.Password
<PasswordInput
id={control.id}
value={control.value as string | undefined}
onChange={control.onChange}
@ -312,21 +315,11 @@ const ProviderSpecificFields: React.FC<ProviderSpecificFieldsProps> = ({ selecte
return (
<>
{isLoading && allFields.length === 0 && (
<Row>
<Col span={24}>
<p className="text-sm mb-2">Loading provider fields...</p>
</Col>
</Row>
)}
{isLoading && allFields.length === 0 && <p className="text-sm mb-2">Loading provider fields...</p>}
{loadError && allFields.length === 0 && (
<Row>
<Col span={24}>
<p className="text-sm mb-2 text-destructive">
{loadError instanceof Error ? loadError.message : "Failed to load provider credential fields"}
</p>
</Col>
</Row>
<p className="text-sm mb-2 text-destructive">
{loadError instanceof Error ? loadError.message : "Failed to load provider credential fields"}
</p>
)}
{allFields.map((field) => (
<React.Fragment key={field.key}>
@ -342,29 +335,24 @@ const ProviderSpecificFields: React.FC<ProviderSpecificFieldsProps> = ({ selecte
{/* Special case for Vertex Credentials help text */}
{field.key === "vertex_credentials" && (
<Row>
<Col>
<p className="text-sm mb-3 mt-1">Give a gcp service account(.json file)</p>
</Col>
</Row>
<p className="text-sm mb-3 mt-1">Give a gcp service account(.json file)</p>
)}
{/* Special case for Azure Base Model help text */}
{field.key === "base_model" && (
<Row>
<Col span={10}></Col>
<Col span={10}>
<p className="text-sm mb-2">
The actual model your azure deployment uses. Used for accurate cost tracking. Select name from{" "}
<Link
href="https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json"
target="_blank"
>
here
</Link>
</p>
</Col>
</Row>
<div className="grid grid-cols-24">
<p className="col-start-11 col-span-10 text-sm mb-2">
The actual model your azure deployment uses. Used for accurate cost tracking. Select name from{" "}
<a
href="https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline-offset-4 hover:underline"
>
here
</a>
</p>
</div>
)}
</React.Fragment>
))}

View file

@ -64,10 +64,6 @@ const savedConfig = () => {
return payload?.litellm_params?.complexity_router_config;
};
const selectedValueIn = (combobox: HTMLElement): string | null =>
// eslint-disable-next-line local/no-antd-class-selectors -- antd keeps the rendered selection in a sibling of the combobox, reachable only through these classes; the tier selects show the same models, so an unscoped title query is ambiguous
combobox.closest(".ant-select")?.querySelector(".ant-select-selection-item")?.getAttribute("title") ?? null;
describe("EditAutoRouterModal keyword matching", () => {
beforeEach(() => {
modelPatchUpdateCall.mockClear();
@ -201,8 +197,9 @@ describe("EditAutoRouterModal keyword matching", () => {
await user.type(
within(screen.getByText("Keywords 2").closest("div") as HTMLElement).getByRole("combobox"),
"chargeback{enter}",
"chargeback",
);
await user.click(await screen.findByText('Create "chargeback"'));
expect(screen.getByRole("button", { name: /save changes/i })).toBeEnabled();
expect(screen.queryByText("At least one keyword is required")).not.toBeInTheDocument();
@ -523,8 +520,7 @@ describe("EditAutoRouterModal custom classifier prompt and fallback", () => {
await user.click(await screen.findByText("Advanced: Classification Method"));
expect(await screen.findByRole("button", { name: "Edit custom prompt" })).toBeInTheDocument();
// eslint-disable-next-line jest-dom/prefer-checked -- antd sets the checked attribute without the DOM property, so toBeChecked reads false
expect(screen.getByRole("radio", { name: /Route to the default model/ })).toHaveAttribute("checked");
expect(screen.getByRole("radio", { name: /Route to the default model/ })).toBeChecked();
await user.click(screen.getByRole("button", { name: /save changes/i }));
@ -623,7 +619,7 @@ describe("EditAutoRouterModal default model", () => {
renderWithStoredPin("out-of-band-default");
const select = await screen.findByRole("combobox", { name: "Default model" });
expect(selectedValueIn(select)).toBe("out-of-band-default");
expect(select).toHaveValue("out-of-band-default");
});
// The pin is recorded in the config rather than inferred by comparing the stored default to a
@ -633,7 +629,7 @@ describe("EditAutoRouterModal default model", () => {
renderWithStoredPin(STORED_CONFIG.tiers.MEDIUM[0]);
const select = await screen.findByRole("combobox", { name: "Default model" });
expect(selectedValueIn(select)).toBe(STORED_CONFIG.tiers.MEDIUM[0]);
expect(select).toHaveValue(STORED_CONFIG.tiers.MEDIUM[0]);
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
@ -649,7 +645,7 @@ describe("EditAutoRouterModal default model", () => {
renderWithLitellmParamsDefaultOnly(STORED_CONFIG.tiers.MEDIUM[0]);
const select = await screen.findByRole("combobox", { name: "Default model" });
expect(selectedValueIn(select)).toBeNull();
expect(select).toHaveValue("");
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
@ -666,7 +662,7 @@ describe("EditAutoRouterModal default model", () => {
renderWithLitellmParamsDefaultOnly("claude-sonnet-4");
const select = await screen.findByRole("combobox", { name: "Default model" });
expect(selectedValueIn(select)).toBe("claude-sonnet-4");
expect(select).toHaveValue("claude-sonnet-4");
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
@ -698,7 +694,7 @@ describe("EditAutoRouterModal default model", () => {
);
const select = await screen.findByRole("combobox", { name: "Default model" });
expect(selectedValueIn(select)).toBe("blob-pin");
expect(select).toHaveValue("blob-pin");
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
@ -740,7 +736,7 @@ describe("EditAutoRouterModal default model", () => {
renderWithStoredPin();
const select = await screen.findByRole("combobox", { name: "Default model" });
expect(selectedValueIn(select)).toBeNull();
expect(select).toHaveValue("");
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());

View file

@ -58,11 +58,6 @@ const createQueryClient = () =>
},
});
const mockUploadProps = {
beforeUpload: vi.fn(),
onChange: vi.fn(),
};
const mockCredential: CredentialItem = {
credential_name: "test-credential",
credential_values: {
@ -77,14 +72,7 @@ const mockCredential: CredentialItem = {
const renderModal = (props: Partial<React.ComponentProps<typeof CredentialModal>> = {}) =>
render(
<QueryClientProvider client={createQueryClient()}>
<CredentialModal
open={true}
mode="add"
onCancel={vi.fn()}
onSubmit={vi.fn()}
uploadProps={mockUploadProps}
{...props}
/>
<CredentialModal open={true} mode="add" onCancel={vi.fn()} onSubmit={vi.fn()} {...props} />
</QueryClientProvider>,
);

View file

@ -1,7 +1,6 @@
import { Input } from "@/components/ui/input";
import { Select as AntdSelect, Tooltip, Typography } from "antd";
import { Button } from "@/components/ui/button";
import type { UploadProps } from "antd/es/upload";
import { useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import ProviderSpecificFields from "../add_model/provider_specific_fields";
@ -26,7 +25,6 @@ interface CredentialModalProps {
open: boolean;
onCancel: () => void;
onSubmit: (values: any) => void;
uploadProps: UploadProps;
mode: "add" | "edit";
existingCredential?: CredentialItem | null;
}
@ -35,7 +33,6 @@ export default function CredentialModal({
open,
onCancel,
onSubmit,
uploadProps,
mode,
existingCredential = null,
}: CredentialModalProps) {
@ -147,7 +144,7 @@ export default function CredentialModal({
)}
</MountedFormField>
<ProviderSpecificFields selectedProvider={selectedProvider} uploadProps={uploadProps} />
<ProviderSpecificFields selectedProvider={selectedProvider} />
<div className="flex justify-between items-center">
<Tooltip title="Get help on our github">

View file

@ -1,7 +1,6 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ComponentProps } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CredentialItem, credentialCreateCall, credentialUpdateCall } from "@/components/networking";
@ -9,8 +8,6 @@ import { toast } from "@/lib/toast";
import CredentialsPanel from "./CredentialsPanel";
const DEFAULT_UPLOAD_PROPS = {} as ComponentProps<typeof CredentialsPanel>["uploadProps"];
const mockUseAuthorized = vi.fn();
const mockUseCredentials = vi.fn();
@ -85,7 +82,7 @@ const createQueryClient = () =>
const renderPanel = () =>
render(
<QueryClientProvider client={createQueryClient()}>
<CredentialsPanel uploadProps={DEFAULT_UPLOAD_PROPS} />
<CredentialsPanel />
</QueryClientProvider>,
);

View file

@ -1,7 +1,7 @@
"use client";
import { Plus } from "lucide-react";
import { type ComponentProps, useState } from "react";
import { useState } from "react";
import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
@ -20,10 +20,6 @@ import { toast } from "@/lib/toast";
import CredentialModal from "./CredentialModal";
import CredentialsTable from "./CredentialsTable";
interface CredentialsPanelProps {
uploadProps: ComponentProps<typeof CredentialModal>["uploadProps"];
}
const restrictedFields = ["credential_name", "custom_llm_provider"];
const buildCredential = (values: Record<string, unknown>, credentialValues: Record<string, unknown>) => ({
@ -37,7 +33,7 @@ const buildCredential = (values: Record<string, unknown>, credentialValues: Reco
const withoutRestrictedFields = (values: Record<string, unknown>): Record<string, unknown> =>
Object.fromEntries(Object.entries(values).filter(([key]) => !restrictedFields.includes(key)));
export default function CredentialsPanel({ uploadProps }: CredentialsPanelProps) {
export default function CredentialsPanel() {
const { accessToken, userRole } = useAuthorized();
// Admin Viewer follows the read-parity rule: see credentials, do not modify.
const canModifyCredentials = isProxyAdminRole(userRole ?? "");
@ -142,7 +138,6 @@ export default function CredentialsPanel({ uploadProps }: CredentialsPanelProps)
onSubmit={handleAddCredential}
open={isAddModalOpen}
onCancel={() => setIsAddModalOpen(false)}
uploadProps={uploadProps}
/>
)}
{isUpdateModalOpen && (
@ -151,7 +146,6 @@ export default function CredentialsPanel({ uploadProps }: CredentialsPanelProps)
open={isUpdateModalOpen}
existingCredential={selectedCredential}
onSubmit={handleUpdateCredential}
uploadProps={uploadProps}
onCancel={() => setIsUpdateModalOpen(false)}
/>
)}

View file

@ -271,9 +271,7 @@ describe("ModelSettingsModal", () => {
renderWithProviders(<ModelSettingsModal {...defaultProps} />);
expect(screen.queryByRole("switch")).not.toBeInTheDocument();
// eslint-disable-next-line local/no-antd-class-selectors -- antd Skeleton exposes no role, label or aria-busy to query the loading affordance by
const skeletons = document.querySelectorAll(".ant-skeleton");
expect(skeletons.length).toBeGreaterThan(0);
expect(screen.getByRole("status", { name: "Loading model settings" })).toBeInTheDocument();
});
it("should not call onSuccess when it is not provided", async () => {

View file

@ -8,7 +8,7 @@ import { FieldGroup } from "@/components/shared/form/field";
import { FormField } from "@/components/shared/form/FormField";
import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Skeleton, Space } from "antd";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { CircleHelp } from "lucide-react";
import React, { useEffect, useMemo } from "react";
@ -101,7 +101,11 @@ const ModelSettingsModal: React.FC<ModelSettingsModalProps> = ({ isVisible, onCa
>
{({ id, value, onChange, onBlur }) =>
isLoadingConfig ? (
<Skeleton.Input active block />
<Skeleton
role="status"
aria-label="Loading model settings"
className="h-[18.4px] w-8 rounded-full"
/>
) : (
<Switch
id={id}
@ -117,18 +121,16 @@ const ModelSettingsModal: React.FC<ModelSettingsModalProps> = ({ isVisible, onCa
</form>
</TooltipProvider>
<DialogFooter>
<Space>
<Button variant="outline" onClick={handleCancel} disabled={isPending || isLoadingConfig}>
Cancel
</Button>
<Button
disabled={isPending || isLoadingConfig}
aria-busy={isPending}
onClick={() => void form.handleSubmit(handleFormSubmit)()}
>
{isPending ? "Saving..." : "Save Settings"}
</Button>
</Space>
<Button variant="outline" onClick={handleCancel} disabled={isPending || isLoadingConfig}>
Cancel
</Button>
<Button
disabled={isPending || isLoadingConfig}
aria-busy={isPending}
onClick={() => void form.handleSubmit(handleFormSubmit)()}
>
{isPending ? "Saving..." : "Save Settings"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View file

@ -861,10 +861,9 @@ describe("ModelInfoView", () => {
const user = userEvent.setup();
await enterPtuEdit(user);
const to = screen.getAllByPlaceholderText("Select date")[1];
await user.clear(to);
await user.type(to, "2026-06-01 00:00:00");
await user.tab();
fireEvent.change(screen.getByLabelText("PTU Effective To (UTC)"), {
target: { value: "2026-06-01T00:00:00" },
});
await expectBlocked(user, /PTU Effective To must be after PTU Effective From/i);
});
@ -907,17 +906,13 @@ describe("ModelInfoView", () => {
await user.clear(screen.getByPlaceholderText("e.g. 2.00"));
await user.type(screen.getByPlaceholderText("e.g. 2.00"), "3.5");
const dates = () => screen.getAllByPlaceholderText("Select date");
expect(dates()[0]).toHaveValue("2026-07-01 00:00:00");
expect(dates()[1]).toHaveValue("2026-08-01 00:00:00");
const from = screen.getByLabelText("PTU Effective From (UTC)");
const to = screen.getByLabelText("PTU Effective To (UTC)");
expect(from).toHaveValue("2026-07-01T00:00");
expect(to).toHaveValue("2026-08-01T00:00");
const setDate = async (index: number, value: string) => {
await user.clear(dates()[index]);
await user.type(dates()[index], value);
await user.tab();
};
await setDate(1, "2026-10-03 02:00:00");
await setDate(0, "2026-09-02 01:00:00");
fireEvent.change(to, { target: { value: "2026-10-03T02:00:00" } });
fireEvent.change(from, { target: { value: "2026-09-02T01:00:00" } });
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(mockModelPatchUpdateCall).toHaveBeenCalled());

View file

@ -4,25 +4,6 @@ import userEvent from "@testing-library/user-event";
import RouterSettingsForm from "./RouterSettingsForm";
import type { RouterSettingsFormValue } from "./RouterSettingsForm";
// Override antd Select (complex to drive in JSDOM) while preserving the rest
// of antd (Switch, Button, etc.) so nested components render normally.
vi.mock("antd", async (importOriginal) => {
const actual = await importOriginal<typeof import("antd")>();
return {
...actual,
Select: Object.assign(
({ value, onChange, children }: any) => (
<select data-testid="strategy-select" value={value ?? ""} onChange={(e) => onChange(e.target.value)}>
{children}
</select>
),
{
Option: ({ value, children }: any) => <option value={value}>{children}</option>,
},
),
};
});
const defaultValue: RouterSettingsFormValue = {
routerSettings: {},
selectedStrategy: null,
@ -45,7 +26,7 @@ describe("RouterSettingsForm", () => {
it("should not show the strategy selector when no strategies are provided", () => {
render(<RouterSettingsForm {...baseProps} />);
expect(screen.queryByTestId("strategy-select")).not.toBeInTheDocument();
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
});
it("should show the strategy selector when strategies are available", () => {
@ -54,7 +35,7 @@ describe("RouterSettingsForm", () => {
availableRoutingStrategies: ["simple-shuffle", "latency-based-routing"],
};
render(<RouterSettingsForm {...props} />);
expect(screen.getByTestId("strategy-select")).toBeInTheDocument();
expect(screen.getByRole("combobox")).toBeInTheDocument();
});
it("should not render LatencyBasedConfiguration for non-latency strategies", () => {
@ -91,7 +72,8 @@ describe("RouterSettingsForm", () => {
};
render(<RouterSettingsForm {...props} />);
await user.selectOptions(screen.getByTestId("strategy-select"), "latency-based-routing");
await user.click(screen.getByRole("combobox"));
await user.click(await screen.findByRole("option", { name: /latency-based-routing/ }));
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ selectedStrategy: "latency-based-routing" }));
});

View file

@ -3,22 +3,8 @@ import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import RoutingStrategySelector from "./RoutingStrategySelector";
// Ant Design's Select is complex to drive in JSDOM; swap it for a plain
// <select> so we can assert options and fire change events normally.
vi.mock("antd", () => ({
Select: Object.assign(
({ value, onChange, children }: any) => (
<div data-testid="ant-select">
<select data-testid="strategy-select" value={value ?? ""} onChange={(e) => onChange(e.target.value)}>
{children}
</select>
</div>
),
{
Option: ({ value, children }: any) => <option value={value}>{children}</option>,
},
),
}));
const openStrategyDropdown = async (user: ReturnType<typeof userEvent.setup>) =>
user.click(screen.getByRole("combobox"));
const baseProps = {
selectedStrategy: null,
@ -34,7 +20,7 @@ const baseProps = {
describe("RoutingStrategySelector", () => {
it("should render", () => {
render(<RoutingStrategySelector {...baseProps} />);
expect(screen.getByTestId("ant-select")).toBeInTheDocument();
expect(screen.getByRole("combobox")).toBeInTheDocument();
});
it("should display default label when no metadata is provided", () => {
@ -57,23 +43,35 @@ describe("RoutingStrategySelector", () => {
expect(screen.getByText("How to pick a deployment")).toBeInTheDocument();
});
it("should render all available strategies as options", () => {
it("should render all available strategies as options", async () => {
const user = userEvent.setup();
render(<RoutingStrategySelector {...baseProps} />);
expect(screen.getByText("simple-shuffle")).toBeInTheDocument();
expect(screen.getByText("latency-based-routing")).toBeInTheDocument();
expect(screen.getByText("least-busy")).toBeInTheDocument();
await openStrategyDropdown(user);
expect(await screen.findByRole("option", { name: /simple-shuffle/ })).toBeInTheDocument();
expect(screen.getByRole("option", { name: /latency-based-routing/ })).toBeInTheDocument();
expect(screen.getByRole("option", { name: /least-busy/ })).toBeInTheDocument();
});
it("should display strategy descriptions alongside option labels", () => {
it("should display strategy descriptions alongside option labels", async () => {
const user = userEvent.setup();
render(<RoutingStrategySelector {...baseProps} />);
expect(screen.getByText("Randomly pick a deployment")).toBeInTheDocument();
await openStrategyDropdown(user);
expect(await screen.findByText("Randomly pick a deployment")).toBeInTheDocument();
expect(screen.getByText("Pick the lowest-latency deployment")).toBeInTheDocument();
});
it("should not render a description for a strategy that has none", () => {
it("should not render a description for a strategy that has none", async () => {
const user = userEvent.setup();
render(<RoutingStrategySelector {...baseProps} />);
// "least-busy" has no entry in routingStrategyDescriptions — it still renders without crashing
expect(screen.getByText("least-busy")).toBeInTheDocument();
await openStrategyDropdown(user);
// "least-busy" has no entry in routingStrategyDescriptions - it still offers the bare option
expect(await screen.findByRole("option", { name: "least-busy" })).toBeInTheDocument();
});
it("should call onStrategyChange with the selected strategy value", async () => {
@ -81,7 +79,8 @@ describe("RoutingStrategySelector", () => {
const user = userEvent.setup();
render(<RoutingStrategySelector {...baseProps} onStrategyChange={onStrategyChange} />);
await user.selectOptions(screen.getByTestId("strategy-select"), "latency-based-routing");
await openStrategyDropdown(user);
await user.click(await screen.findByRole("option", { name: /latency-based-routing/ }));
expect(onStrategyChange).toHaveBeenCalledWith("latency-based-routing");
});

View file

@ -1,5 +1,5 @@
import React from "react";
import { Select } from "antd";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
interface RoutingStrategySelectorProps {
selectedStrategy: string | null;
@ -27,17 +27,27 @@ const RoutingStrategySelector: React.FC<RoutingStrategySelectorProps> = ({
</p>
</div>
<div className="routing-strategy-select max-w-3xl">
<Select value={selectedStrategy} onChange={onStrategyChange} style={{ width: "100%" }} size="large">
{availableStrategies.map((strategy) => (
<Select.Option key={strategy} value={strategy} label={strategy}>
<div className="flex flex-col gap-0.5 py-1">
<span className="font-mono text-sm font-medium">{strategy}</span>
{routingStrategyDescriptions[strategy] && (
<span className="text-xs text-gray-500 font-normal">{routingStrategyDescriptions[strategy]}</span>
)}
</div>
</Select.Option>
))}
<Select
value={selectedStrategy}
onValueChange={(strategy: string | null) => strategy && onStrategyChange(strategy)}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableStrategies.map((strategy) => (
<SelectItem key={strategy} value={strategy}>
<div className="flex flex-col gap-0.5 py-1">
<span className="font-mono text-sm font-medium">{strategy}</span>
{routingStrategyDescriptions[strategy] && (
<span className="text-xs font-normal text-muted-foreground">
{routingStrategyDescriptions[strategy]}
</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>

View file

@ -3,22 +3,9 @@ import { fireEvent, renderWithProviders, screen, waitFor } from "../../../tests/
import userEvent from "@testing-library/user-event";
import RouterSettings from "./index";
vi.mock("antd", async (importOriginal) => {
const actual = await importOriginal<typeof import("antd")>();
return {
...actual,
Select: Object.assign(
({ value, onChange, children }: any) => (
<select data-testid="strategy-select" value={value ?? ""} onChange={(e) => onChange(e.target.value)}>
{children}
</select>
),
{
Option: ({ value, children }: any) => <option value={value}>{children}</option>,
},
),
};
});
// The strategy select only renders once getRouterSettingsCall resolves, so awaiting it is how a
// test knows the loaded settings are on screen.
const findStrategySelect = () => screen.findByRole("combobox");
vi.mock("@/components/networking", () => ({
getCallbacksCall: vi.fn(),
@ -101,26 +88,20 @@ describe("RouterSettings", () => {
});
it("should render routing strategies loaded from the API", async () => {
const user = userEvent.setup();
renderWithProviders(<RouterSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("strategy-select")).toBeInTheDocument();
});
await user.click(await findStrategySelect());
const select = screen.getByTestId("strategy-select") as HTMLSelectElement;
const optionValues = Array.from(select.options).map((o) => o.value);
expect(optionValues).toContain("simple-shuffle");
expect(optionValues).toContain("latency-based-routing");
expect(await screen.findByRole("option", { name: /simple-shuffle/ })).toBeInTheDocument();
expect(screen.getByRole("option", { name: /latency-based-routing/ })).toBeInTheDocument();
});
it("should call setCallbacksCall with updated settings on Save Changes", async () => {
const user = userEvent.setup();
renderWithProviders(<RouterSettings {...defaultProps} />);
// Wait for the strategy select to appear — it only renders after getRouterSettingsCall resolves
await waitFor(() => {
expect(screen.getByTestId("strategy-select")).toBeInTheDocument();
});
await findStrategySelect();
await user.click(screen.getByRole("button", { name: /save changes/i }));
@ -138,9 +119,7 @@ describe("RouterSettings", () => {
const user = userEvent.setup();
renderWithProviders(<RouterSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("strategy-select")).toBeInTheDocument();
});
await findStrategySelect();
const numRetries = await screen.findByRole("textbox", { name: /num_retries/i });
await user.clear(numRetries);
@ -162,10 +141,7 @@ describe("RouterSettings", () => {
const user = userEvent.setup();
renderWithProviders(<RouterSettings {...defaultProps} />);
// Wait for data to load before interacting
await waitFor(() => {
expect(screen.getByTestId("strategy-select")).toBeInTheDocument();
});
await findStrategySelect();
await user.click(screen.getByRole("button", { name: /save changes/i }));
expect(toast.success).toHaveBeenCalledWith("router settings updated successfully");
@ -182,9 +158,7 @@ describe("RouterSettings", () => {
});
renderWithProviders(<RouterSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("strategy-select")).toBeInTheDocument();
});
await findStrategySelect();
expect(document.querySelector('input[name="routing_groups"]')).toBeNull();
await user.click(screen.getByRole("button", { name: /save changes/i }));
@ -201,9 +175,7 @@ describe("RouterSettings", () => {
vi.mocked(setCallbacksCall).mockRejectedValue(new Error("422 Unprocessable Entity"));
renderWithProviders(<RouterSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("strategy-select")).toBeInTheDocument();
});
await findStrategySelect();
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {

View file

@ -16,6 +16,13 @@ describe("SearchSelect", () => {
expect(screen.getByPlaceholderText("Select Team…")).toBeInTheDocument();
});
it("names the field from aria-label so callers can label it independently of the placeholder", () => {
render(
<SearchSelect options={OPTIONS} onValueChange={vi.fn()} placeholder="Select Team…" aria-label="Default model" />,
);
expect(screen.getByRole("combobox", { name: "Default model" })).toBeInTheDocument();
});
it("shows the selected option's label in the field", () => {
render(<SearchSelect options={OPTIONS} value="team-2" onValueChange={vi.fn()} />);
expect(screen.getByRole("combobox")).toHaveValue("Growth");

View file

@ -0,0 +1,53 @@
"use client";
import * as React from "react";
import dayjs, { type Dayjs } from "dayjs";
import utc from "dayjs/plugin/utc";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/cva.config";
dayjs.extend(utc);
const MINUTE_FORMAT = "YYYY-MM-DDTHH:mm";
const SECOND_FORMAT = "YYYY-MM-DDTHH:mm:ss";
export interface UtcDateTimeInputProps
extends Omit<React.ComponentProps<"input">, "value" | "onChange" | "type" | "step"> {
value: Dayjs | null | undefined;
onChange: (value: Dayjs | null) => void;
}
/**
* The wall clock the operator sees is the wall clock that gets stored, so the value stays in UTC
* mode end to end instead of being converted through the browser zone - see utils/ptuDatetime.
*/
const toInputValue = (value: Dayjs | null | undefined): string => {
if (!value || typeof value.format !== "function" || !value.isValid()) {
return "";
}
return value.second() === 0 && value.millisecond() === 0 ? value.format(MINUTE_FORMAT) : value.format(SECOND_FORMAT);
};
const toUtcValue = (raw: string): Dayjs | null => {
if (!raw) {
return null;
}
const parsed = dayjs.utc(raw);
return parsed.isValid() ? parsed : null;
};
export const UtcDateTimeInput = React.forwardRef<HTMLInputElement, UtcDateTimeInputProps>(
({ value, onChange, className, ...props }, ref) => (
<Input
{...props}
ref={ref}
type="datetime-local"
step={1}
className={cn("w-full", className)}
value={toInputValue(value)}
onChange={(event) => onChange(toUtcValue(event.target.value))}
/>
),
);
UtcDateTimeInput.displayName = "UtcDateTimeInput";

View file

@ -32,12 +32,13 @@ const ComboboxTrigger = React.forwardRef<
});
ComboboxTrigger.displayName = "ComboboxTrigger";
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
function ComboboxClear({ className, "aria-label": ariaLabel = "Clear", ...props }: ComboboxPrimitive.Clear.Props) {
return (
<ComboboxPrimitive.Clear
data-slot="combobox-clear"
render={<InputGroupButton variant="ghost" size="icon-xs" />}
className={cn(className)}
aria-label={ariaLabel}
{...props}
>
<XIcon className="pointer-events-none" />