diff --git a/ui/litellm-dashboard/src/components/add_model/AdaptiveRoutingConfig.test.tsx b/ui/litellm-dashboard/src/components/add_model/AdaptiveRoutingConfig.test.tsx new file mode 100644 index 00000000000..4b3edaeb3d2 --- /dev/null +++ b/ui/litellm-dashboard/src/components/add_model/AdaptiveRoutingConfig.test.tsx @@ -0,0 +1,37 @@ +import { fireEvent, renderWithProviders, screen } from "../../../tests/test-utils"; +import { vi } from "vitest"; +import AdaptiveRoutingConfig from "./AdaptiveRoutingConfig"; +import { ComplexityRouterConfigValue } from "./ComplexityRouterConfig"; + +const adaptiveValue: ComplexityRouterConfigValue = { + adaptive: true, + adaptive_eligible: "all", + tier_distance_penalty: 0.2, +}; + +describe("AdaptiveRoutingConfig", () => { + it("keeps the tier distance penalty empty while it is being edited, then commits the new penalty", () => { + const onChange = vi.fn(); + renderWithProviders(); + + const input = screen.getByRole("spinbutton"); + fireEvent.change(input, { target: { value: "" } }); + + expect(input).toHaveValue(null); + expect(onChange).not.toHaveBeenCalled(); + + fireEvent.change(input, { target: { value: "0.5" } }); + + expect(onChange).toHaveBeenCalledWith({ ...adaptiveValue, tier_distance_penalty: 0.5 }); + }); + + it("restores the committed tier distance penalty after an empty field loses focus", () => { + renderWithProviders(); + + const input = screen.getByRole("spinbutton"); + fireEvent.change(input, { target: { value: "" } }); + fireEvent.blur(input); + + expect(input).toHaveValue(0.2); + }); +}); diff --git a/ui/litellm-dashboard/src/components/add_model/AdaptiveRoutingConfig.tsx b/ui/litellm-dashboard/src/components/add_model/AdaptiveRoutingConfig.tsx index 84421cd5acb..b452e62dff1 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"; @@ -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 ( @@ -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/SemanticKeywordMatching.test.tsx b/ui/litellm-dashboard/src/components/add_model/SemanticKeywordMatching.test.tsx index 2336e6faf43..f3c6806c056 100644 --- a/ui/litellm-dashboard/src/components/add_model/SemanticKeywordMatching.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/SemanticKeywordMatching.test.tsx @@ -1,4 +1,4 @@ -import { renderWithProviders, screen } from "../../../tests/test-utils"; +import { fireEvent, renderWithProviders, screen } from "../../../tests/test-utils"; import userEvent from "@testing-library/user-event"; import { vi } from "vitest"; import SemanticKeywordMatching from "./SemanticKeywordMatching"; @@ -44,6 +44,23 @@ describe("SemanticKeywordMatching", () => { expect(screen.getByText("An embedding model is required")).toBeInTheDocument(); }); + it("keeps the minimum match score empty while it is being edited, then commits the new score", () => { + const onMatchThresholdChange = vi.fn(); + renderWithProviders( + , + ); + + const input = screen.getByRole("spinbutton"); + fireEvent.change(input, { target: { value: "" } }); + + expect(input).toHaveValue(null); + expect(onMatchThresholdChange).not.toHaveBeenCalled(); + + fireEvent.change(input, { target: { value: "0.8" } }); + + expect(onMatchThresholdChange).toHaveBeenCalledWith(0.8); + }); + it("hides the validation error once an embedding model is set", () => { renderWithProviders( , diff --git a/ui/litellm-dashboard/src/components/add_model/SemanticKeywordMatching.tsx b/ui/litellm-dashboard/src/components/add_model/SemanticKeywordMatching.tsx index 0a5a9a5bfb6..d2094596534 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"; @@ -74,12 +74,9 @@ const SemanticKeywordMatching: React.FC = ({
Minimum match score - - onMatchThresholdChange(event.target.value === "" ? DEFAULT_MATCH_THRESHOLD : event.target.valueAsNumber) - } + 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 new file mode 100644 index 00000000000..063c507868f --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/NumberInput.test.tsx @@ -0,0 +1,87 @@ +import { useState } from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { vi } from "vitest"; +import { NumberInput } from "./NumberInput"; + +const STORED_VALUE = 3; + +const StatefulHarness = () => { + const [value, setValue] = useState(STORED_VALUE); + return ( + <> + + {value} + + ); +}; + +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 commit nothing while the field holds no number", () => { + const onValueChange = vi.fn(); + render(); + + fireEvent.change(screen.getByRole("spinbutton"), { target: { value: "" } }); + + expect(onValueChange).not.toHaveBeenCalled(); + }); + + 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(); + 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 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(STORED_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..0de5d7beb5b --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/NumberInput.tsx @@ -0,0 +1,36 @@ +import * as React from "react"; + +import { Input } from "@/components/ui/input"; + +type NumberInputProps = Omit, "type" | "value" | "onChange"> & { + value: number; + onValueChange: (value: number) => void; +}; + +const NumberInput = React.forwardRef( + ({ value, onValueChange, onBlur, ...props }, ref) => { + const [draft, setDraft] = React.useState(null); + + return ( + { + setDraft(event.target.value); + if (!Number.isNaN(event.target.valueAsNumber)) { + onValueChange(event.target.valueAsNumber); + } + }} + onBlur={(event) => { + setDraft(null); + onBlur?.(event); + }} + /> + ); + }, +); +NumberInput.displayName = "NumberInput"; + +export { NumberInput };