From e19640e49688f2bca41b152d1fb29211176aaaa2 Mon Sep 17 00:00:00 2001 From: tin Date: Sat, 29 Aug 2026 18:17:16 +0000 Subject: [PATCH 1/2] 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 }; From 63f1ae03615f1539820e0daf7359a8161661d6a8 Mon Sep 17 00:00:00 2001 From: tin Date: Sat, 29 Aug 2026 18:25:36 +0000 Subject: [PATCH 2/2] fix(ui): keep an emptied number field local instead of committing a default Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../add_model/AdaptiveRoutingConfig.tsx | 4 +-- .../add_model/ClassificationMethodConfig.tsx | 18 ++++------- .../add_model/ComplexityRouterConfig.test.tsx | 17 ++++++++++ .../add_model/SemanticKeywordMatching.tsx | 2 +- .../components/shared/NumberInput.test.tsx | 31 ++++++++++++------- .../src/components/shared/NumberInput.tsx | 10 +++--- 6 files changed, 49 insertions(+), 33 deletions(-) diff --git a/ui/litellm-dashboard/src/components/add_model/AdaptiveRoutingConfig.tsx b/ui/litellm-dashboard/src/components/add_model/AdaptiveRoutingConfig.tsx index 13a7d392063..b452e62dff1 100644 --- a/ui/litellm-dashboard/src/components/add_model/AdaptiveRoutingConfig.tsx +++ b/ui/litellm-dashboard/src/components/add_model/AdaptiveRoutingConfig.tsx @@ -42,8 +42,8 @@ const AdaptiveRoutingConfig: React.FC = ({ value, on onChange({ ...value, adaptive_eligible: eligible }); }; - const handleTierDistancePenaltyChange = (penalty: number | null) => { - onChange({ ...value, tier_distance_penalty: penalty ?? DEFAULT_TIER_DISTANCE_PENALTY }); + const handleTierDistancePenaltyChange = (penalty: number) => { + onChange({ ...value, tier_distance_penalty: penalty }); }; return ( diff --git a/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx b/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx index 02d975d7164..a70c13ebf28 100644 --- a/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx @@ -261,13 +261,13 @@ const ClassificationMethodConfig: React.FC = ({ }); }; - const handleClassifierTimeoutChange = (timeoutMs: number | null) => { + const handleClassifierTimeoutChange = (timeoutMs: number) => { onChange({ ...value, classifier_llm_config: { ...value.classifier_llm_config, model: value.classifier_llm_config?.model ?? "", - timeout_ms: timeoutMs ?? DEFAULT_CLASSIFIER_TIMEOUT_MS, + timeout_ms: timeoutMs, }, }); }; @@ -300,18 +300,12 @@ const ClassificationMethodConfig: React.FC = ({ onChange({ ...value, classifier_fallback: fallback }); }; - const handleClassifierContextWindowSizeChange = (windowSize: number | null) => { - onChange({ - ...value, - classifier_context_window_size: windowSize ?? DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE, - }); + const handleClassifierContextWindowSizeChange = (windowSize: number) => { + onChange({ ...value, classifier_context_window_size: windowSize }); }; - const handleClassifierContextBudgetCharsChange = (budgetChars: number | null) => { - onChange({ - ...value, - classifier_context_budget_chars: budgetChars ?? DEFAULT_CLASSIFIER_CONTEXT_BUDGET_CHARS, - }); + const handleClassifierContextBudgetCharsChange = (budgetChars: number) => { + onChange({ ...value, classifier_context_budget_chars: budgetChars }); }; const handleClassifierContextIncludeAssistantTurnsChange = (includeAssistantTurns: boolean) => { 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 ec140a8a88c..5383986e5ce 100644 --- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx @@ -291,6 +291,23 @@ describe("ComplexityRouterConfig", () => { expect(input).toHaveValue(9); }); + it("should not save a value for a context window size field left empty", () => { + const onChange = vi.fn(); + const llmValue: ComplexityRouterConfigValue = { + ...defaultValue, + classifier_type: "llm", + classifier_llm_config: { model: "gpt-3.5-turbo", timeout_ms: 3000 }, + classifier_context_window_size: 8, + }; + renderWithProviders(); + fireEvent.click(screen.getByText("Advanced: Classification Method")); + + const windowSizeSection = screen.getByText("Context Window Size").closest("div") as HTMLElement; + fireEvent.change(within(windowSizeSection).getByRole("spinbutton"), { target: { value: "" } }); + + expect(onChange).not.toHaveBeenCalled(); + }); + 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 0f4e0fb3c78..30c3f2c8a2a 100644 --- a/ui/litellm-dashboard/src/components/add_model/SemanticKeywordMatching.tsx +++ b/ui/litellm-dashboard/src/components/add_model/SemanticKeywordMatching.tsx @@ -74,7 +74,7 @@ const SemanticKeywordMatching: React.FC = ({ Minimum match score onMatchThresholdChange(threshold ?? DEFAULT_MATCH_THRESHOLD)} + onValueChange={onMatchThresholdChange} 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 index e8611dda655..063c507868f 100644 --- a/ui/litellm-dashboard/src/components/shared/NumberInput.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/NumberInput.test.tsx @@ -3,11 +3,16 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { vi } from "vitest"; import { NumberInput } from "./NumberInput"; -const DEFAULT_VALUE = 3; +const STORED_VALUE = 3; -const DefaultingHarness = () => { - const [value, setValue] = useState(DEFAULT_VALUE); - return setValue(next ?? DEFAULT_VALUE)} min={0} />; +const StatefulHarness = () => { + const [value, setValue] = useState(STORED_VALUE); + return ( + <> + + {value} + + ); }; describe("NumberInput", () => { @@ -20,42 +25,44 @@ describe("NumberInput", () => { expect(onValueChange).toHaveBeenCalledWith(7); }); - it("should report null when the field is cleared", () => { + it("should commit nothing while the field holds no number", () => { const onValueChange = vi.fn(); render(); fireEvent.change(screen.getByRole("spinbutton"), { target: { value: "" } }); - expect(onValueChange).toHaveBeenCalledWith(null); + expect(onValueChange).not.toHaveBeenCalled(); }); - it("should stay empty after a clear that sends the parent back to its default", () => { - render(); + it("should stay empty once cleared, keeping the stored value untouched", () => { + render(); const input = screen.getByRole("spinbutton"); fireEvent.change(input, { target: { value: "" } }); expect(input).toHaveValue(null); + expect(screen.getByRole("status")).toHaveTextContent(String(STORED_VALUE)); }); it("should accept a fresh number typed into the cleared field", () => { - render(); + render(); const input = screen.getByRole("spinbutton"); fireEvent.change(input, { target: { value: "" } }); fireEvent.change(input, { target: { value: "5" } }); expect(input).toHaveValue(5); + expect(screen.getByRole("status")).toHaveTextContent("5"); }); - it("should show the parent value again once the cleared field is blurred", () => { - render(); + it("should show the stored 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); + expect(input).toHaveValue(STORED_VALUE); }); it("should follow the parent value after the draft is committed", () => { diff --git a/ui/litellm-dashboard/src/components/shared/NumberInput.tsx b/ui/litellm-dashboard/src/components/shared/NumberInput.tsx index f4960ec621b..0de5d7beb5b 100644 --- a/ui/litellm-dashboard/src/components/shared/NumberInput.tsx +++ b/ui/litellm-dashboard/src/components/shared/NumberInput.tsx @@ -4,13 +4,9 @@ import { Input } from "@/components/ui/input"; type NumberInputProps = Omit, "type" | "value" | "onChange"> & { value: number; - onValueChange: (value: number | null) => void; + onValueChange: (value: number) => 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); @@ -23,7 +19,9 @@ const NumberInput = React.forwardRef( value={draft ?? String(value)} onChange={(event) => { setDraft(event.target.value); - onValueChange(Number.isNaN(event.target.valueAsNumber) ? null : event.target.valueAsNumber); + if (!Number.isNaN(event.target.valueAsNumber)) { + onValueChange(event.target.valueAsNumber); + } }} onBlur={(event) => { setDraft(null);