= ({ 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 (
+ <>
+
+
+ >
+ );
+};
+
+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 };