mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
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:
parent
26841dae43
commit
0ab1725757
42 changed files with 1153 additions and 1318 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
});
|
||||
});
|
||||
|
|
@ -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.`);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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's enough feedback, it defaults to the classified tier'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's enough feedback, it defaults to the classified
|
||||
tier'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>
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 < {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 > {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 < {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 >{" "}
|
||||
{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, <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, <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 "now do the same for the streaming path" 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's replies, so difficulty the model stated rather than the user
|
||||
stays visible: a plan the assistant calls complex, approved with "yes", 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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
<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'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} · {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} · {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 "Route to
|
||||
the default model" 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 "Route to the
|
||||
default model" 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'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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 "LITELLM ESCALATE" 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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 "invoice, refund,
|
||||
billing" 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
|
|
|
|||
|
|
@ -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.{" "}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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" }));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue