From e19640e49688f2bca41b152d1fb29211176aaaa2 Mon Sep 17 00:00:00 2001 From: tin Date: Sat, 29 Aug 2026 18:17:16 +0000 Subject: [PATCH] fix(ui): let number fields be cleared instead of refilling their current value Adds a NumberInput that keeps the field empty while editing, wired into the auto-router classifier, adaptive and semantic-matching number fields. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../add_model/AdaptiveRoutingConfig.tsx | 9 +-- .../add_model/ClassificationMethodConfig.tsx | 23 ++---- .../add_model/ComplexityRouterConfig.test.tsx | 24 ++++++ .../add_model/SemanticKeywordMatching.tsx | 9 +-- .../components/shared/NumberInput.test.tsx | 80 +++++++++++++++++++ .../src/components/shared/NumberInput.tsx | 38 +++++++++ 6 files changed, 155 insertions(+), 28 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/shared/NumberInput.test.tsx create mode 100644 ui/litellm-dashboard/src/components/shared/NumberInput.tsx diff --git a/ui/litellm-dashboard/src/components/add_model/AdaptiveRoutingConfig.tsx b/ui/litellm-dashboard/src/components/add_model/AdaptiveRoutingConfig.tsx index 84421cd5acb..13a7d392063 100644 --- a/ui/litellm-dashboard/src/components/add_model/AdaptiveRoutingConfig.tsx +++ b/ui/litellm-dashboard/src/components/add_model/AdaptiveRoutingConfig.tsx @@ -1,5 +1,5 @@ import { Card, CardContent } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; +import { NumberInput } from "@/components/shared/NumberInput"; import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Slider } from "@/components/ui/slider"; @@ -120,12 +120,9 @@ const AdaptiveRoutingConfig: React.FC = ({ value, on {adaptiveEligible === "all" && (
Tier Distance Penalty - - handleTierDistancePenaltyChange(event.target.value === "" ? null : event.target.valueAsNumber) - } + onValueChange={handleTierDistancePenaltyChange} min={0} step={0.1} className="w-full" diff --git a/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx b/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx index c48d15adecb..02d975d7164 100644 --- a/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx @@ -4,7 +4,7 @@ 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 { NumberInput } from "@/components/shared/NumberInput"; import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Switch } from "@/components/ui/switch"; @@ -367,12 +367,9 @@ const ClassificationMethodConfig: React.FC = ({
Timeout (ms) - - handleClassifierTimeoutChange(event.target.value === "" ? null : event.target.valueAsNumber) - } + onValueChange={handleClassifierTimeoutChange} min={1} className="w-full" /> @@ -481,12 +478,9 @@ const ClassificationMethodConfig: React.FC = ({
Context Window Size - - handleClassifierContextWindowSizeChange(event.target.value === "" ? null : event.target.valueAsNumber) - } + onValueChange={handleClassifierContextWindowSizeChange} min={0} className="w-full" /> @@ -498,12 +492,9 @@ const ClassificationMethodConfig: React.FC = ({
Context Character Budget - - handleClassifierContextBudgetCharsChange(event.target.value === "" ? null : event.target.valueAsNumber) - } + onValueChange={handleClassifierContextBudgetCharsChange} min={0} className="w-full" /> diff --git a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx index 91201b51663..ec140a8a88c 100644 --- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { fireEvent, renderWithProviders, screen, within } from "../../../tests/test-utils"; import userEvent from "@testing-library/user-event"; import { vi } from "vitest"; @@ -267,6 +268,29 @@ describe("ComplexityRouterConfig", () => { }); }); + it("should let the context window size field be cleared instead of refilling the default", () => { + const StatefulConfig = () => { + const [value, setValue] = useState({ + ...defaultValue, + classifier_type: "llm", + classifier_llm_config: { model: "gpt-3.5-turbo", timeout_ms: 3000 }, + }); + return ; + }; + renderWithProviders(); + fireEvent.click(screen.getByText("Advanced: Classification Method")); + + const windowSizeSection = screen.getByText("Context Window Size").closest("div") as HTMLElement; + const input = within(windowSizeSection).getByRole("spinbutton"); + fireEvent.change(input, { target: { value: "" } }); + + expect(input).toHaveValue(null); + + fireEvent.change(input, { target: { value: "9" } }); + + expect(input).toHaveValue(9); + }); + it("should render the custom technical keywords field", () => { renderWithProviders(); fireEvent.click(screen.getByText("Advanced: Classification Method")); diff --git a/ui/litellm-dashboard/src/components/add_model/SemanticKeywordMatching.tsx b/ui/litellm-dashboard/src/components/add_model/SemanticKeywordMatching.tsx index 5684d32a319..0f4e0fb3c78 100644 --- a/ui/litellm-dashboard/src/components/add_model/SemanticKeywordMatching.tsx +++ b/ui/litellm-dashboard/src/components/add_model/SemanticKeywordMatching.tsx @@ -1,7 +1,7 @@ import { Info } from "lucide-react"; import { SimpleTooltip } from "@/components/ui/tooltip"; import { SearchSelect } from "@/components/shared/SearchSelect"; -import { Input } from "@/components/ui/input"; +import { NumberInput } from "@/components/shared/NumberInput"; import { Switch } from "@/components/ui/switch"; import React from "react"; import { ModelGroup } from "@/components/llm_calls/fetch_models"; @@ -72,12 +72,9 @@ const SemanticKeywordMatching: React.FC = ({
Minimum match score - - onMatchThresholdChange(event.target.value === "" ? DEFAULT_MATCH_THRESHOLD : event.target.valueAsNumber) - } + onValueChange={(threshold) => onMatchThresholdChange(threshold ?? DEFAULT_MATCH_THRESHOLD)} min={0} max={1} step={0.05} diff --git a/ui/litellm-dashboard/src/components/shared/NumberInput.test.tsx b/ui/litellm-dashboard/src/components/shared/NumberInput.test.tsx new file mode 100644 index 00000000000..e8611dda655 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/NumberInput.test.tsx @@ -0,0 +1,80 @@ +import { useState } from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { vi } from "vitest"; +import { NumberInput } from "./NumberInput"; + +const DEFAULT_VALUE = 3; + +const DefaultingHarness = () => { + const [value, setValue] = useState(DEFAULT_VALUE); + return setValue(next ?? DEFAULT_VALUE)} min={0} />; +}; + +describe("NumberInput", () => { + it("should report the typed number", () => { + const onValueChange = vi.fn(); + render(); + + fireEvent.change(screen.getByRole("spinbutton"), { target: { value: "7" } }); + + expect(onValueChange).toHaveBeenCalledWith(7); + }); + + it("should report null when the field is cleared", () => { + const onValueChange = vi.fn(); + render(); + + fireEvent.change(screen.getByRole("spinbutton"), { target: { value: "" } }); + + expect(onValueChange).toHaveBeenCalledWith(null); + }); + + it("should stay empty after a clear that sends the parent back to its default", () => { + render(); + const input = screen.getByRole("spinbutton"); + + fireEvent.change(input, { target: { value: "" } }); + + expect(input).toHaveValue(null); + }); + + it("should accept a fresh number typed into the cleared field", () => { + render(); + const input = screen.getByRole("spinbutton"); + + fireEvent.change(input, { target: { value: "" } }); + fireEvent.change(input, { target: { value: "5" } }); + + expect(input).toHaveValue(5); + }); + + it("should show the parent value again once the cleared field is blurred", () => { + render(); + const input = screen.getByRole("spinbutton"); + + fireEvent.change(input, { target: { value: "" } }); + fireEvent.blur(input); + + expect(input).toHaveValue(DEFAULT_VALUE); + }); + + it("should follow the parent value after the draft is committed", () => { + const { rerender } = render(); + const input = screen.getByRole("spinbutton"); + + fireEvent.change(input, { target: { value: "" } }); + fireEvent.blur(input); + rerender(); + + expect(input).toHaveValue(9); + }); + + it("should call a caller-supplied blur handler", () => { + const onBlur = vi.fn(); + render(); + + fireEvent.blur(screen.getByRole("spinbutton")); + + expect(onBlur).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/NumberInput.tsx b/ui/litellm-dashboard/src/components/shared/NumberInput.tsx new file mode 100644 index 00000000000..f4960ec621b --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/NumberInput.tsx @@ -0,0 +1,38 @@ +import * as React from "react"; + +import { Input } from "@/components/ui/input"; + +type NumberInputProps = Omit, "type" | "value" | "onChange"> & { + value: number; + onValueChange: (value: number | null) => void; +}; + +/** + * Number input whose field can be emptied while editing: the parent's value is only re-displayed on blur, so + * backspacing the last digit leaves the field empty instead of snapping straight back to the current value. + */ +const NumberInput = React.forwardRef( + ({ value, onValueChange, onBlur, ...props }, ref) => { + const [draft, setDraft] = React.useState(null); + + return ( + { + setDraft(event.target.value); + onValueChange(Number.isNaN(event.target.valueAsNumber) ? null : event.target.valueAsNumber); + }} + onBlur={(event) => { + setDraft(null); + onBlur?.(event); + }} + /> + ); + }, +); +NumberInput.displayName = "NumberInput"; + +export { NumberInput };