mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
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>
This commit is contained in:
parent
2a5d09ee87
commit
e19640e496
6 changed files with 155 additions and 28 deletions
|
|
@ -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<AdaptiveRoutingConfigProps> = ({ value, on
|
|||
{adaptiveEligible === "all" && (
|
||||
<div>
|
||||
<strong className="mb-1 block font-semibold">Tier Distance Penalty</strong>
|
||||
<Input
|
||||
type="number"
|
||||
<NumberInput
|
||||
value={tierDistancePenalty}
|
||||
onChange={(event) =>
|
||||
handleTierDistancePenaltyChange(event.target.value === "" ? null : event.target.valueAsNumber)
|
||||
}
|
||||
onValueChange={handleTierDistancePenaltyChange}
|
||||
min={0}
|
||||
step={0.1}
|
||||
className="w-full"
|
||||
|
|
|
|||
|
|
@ -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<ClassificationMethodConfigProps> = ({
|
|||
</div>
|
||||
<div>
|
||||
<strong className="block mb-1 font-semibold">Timeout (ms)</strong>
|
||||
<Input
|
||||
type="number"
|
||||
<NumberInput
|
||||
value={value.classifier_llm_config?.timeout_ms ?? DEFAULT_CLASSIFIER_TIMEOUT_MS}
|
||||
onChange={(event) =>
|
||||
handleClassifierTimeoutChange(event.target.value === "" ? null : event.target.valueAsNumber)
|
||||
}
|
||||
onValueChange={handleClassifierTimeoutChange}
|
||||
min={1}
|
||||
className="w-full"
|
||||
/>
|
||||
|
|
@ -481,12 +478,9 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
</RestrictedSection>
|
||||
<div>
|
||||
<strong className="block mb-1 font-semibold">Context Window Size</strong>
|
||||
<Input
|
||||
type="number"
|
||||
<NumberInput
|
||||
value={value.classifier_context_window_size ?? DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE}
|
||||
onChange={(event) =>
|
||||
handleClassifierContextWindowSizeChange(event.target.value === "" ? null : event.target.valueAsNumber)
|
||||
}
|
||||
onValueChange={handleClassifierContextWindowSizeChange}
|
||||
min={0}
|
||||
className="w-full"
|
||||
/>
|
||||
|
|
@ -498,12 +492,9 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
</div>
|
||||
<div>
|
||||
<strong className="block mb-1 font-semibold">Context Character Budget</strong>
|
||||
<Input
|
||||
type="number"
|
||||
<NumberInput
|
||||
value={value.classifier_context_budget_chars ?? DEFAULT_CLASSIFIER_CONTEXT_BUDGET_CHARS}
|
||||
onChange={(event) =>
|
||||
handleClassifierContextBudgetCharsChange(event.target.value === "" ? null : event.target.valueAsNumber)
|
||||
}
|
||||
onValueChange={handleClassifierContextBudgetCharsChange}
|
||||
min={0}
|
||||
className="w-full"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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<ComplexityRouterConfigValue>({
|
||||
...defaultValue,
|
||||
classifier_type: "llm",
|
||||
classifier_llm_config: { model: "gpt-3.5-turbo", timeout_ms: 3000 },
|
||||
});
|
||||
return <ComplexityRouterConfig modelInfo={mockModelInfo} value={value} onChange={setValue} />;
|
||||
};
|
||||
renderWithProviders(<StatefulConfig />);
|
||||
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(<ComplexityRouterConfig {...baseProps} />);
|
||||
fireEvent.click(screen.getByText("Advanced: Classification Method"));
|
||||
|
|
|
|||
|
|
@ -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<SemanticKeywordMatchingProps> = ({
|
|||
</div>
|
||||
<div>
|
||||
<span className="mb-1 block text-sm font-medium">Minimum match score</span>
|
||||
<Input
|
||||
type="number"
|
||||
<NumberInput
|
||||
value={matchThreshold}
|
||||
onChange={(event) =>
|
||||
onMatchThresholdChange(event.target.value === "" ? DEFAULT_MATCH_THRESHOLD : event.target.valueAsNumber)
|
||||
}
|
||||
onValueChange={(threshold) => onMatchThresholdChange(threshold ?? DEFAULT_MATCH_THRESHOLD)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
|
|
|
|||
|
|
@ -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 <NumberInput value={value} onValueChange={(next) => setValue(next ?? DEFAULT_VALUE)} min={0} />;
|
||||
};
|
||||
|
||||
describe("NumberInput", () => {
|
||||
it("should report the typed number", () => {
|
||||
const onValueChange = vi.fn();
|
||||
render(<NumberInput value={3} onValueChange={onValueChange} />);
|
||||
|
||||
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(<NumberInput value={3} onValueChange={onValueChange} />);
|
||||
|
||||
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(<DefaultingHarness />);
|
||||
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(<DefaultingHarness />);
|
||||
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(<DefaultingHarness />);
|
||||
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(<NumberInput value={3} onValueChange={vi.fn()} />);
|
||||
const input = screen.getByRole("spinbutton");
|
||||
|
||||
fireEvent.change(input, { target: { value: "" } });
|
||||
fireEvent.blur(input);
|
||||
rerender(<NumberInput value={9} onValueChange={vi.fn()} />);
|
||||
|
||||
expect(input).toHaveValue(9);
|
||||
});
|
||||
|
||||
it("should call a caller-supplied blur handler", () => {
|
||||
const onBlur = vi.fn();
|
||||
render(<NumberInput value={3} onValueChange={vi.fn()} onBlur={onBlur} />);
|
||||
|
||||
fireEvent.blur(screen.getByRole("spinbutton"));
|
||||
|
||||
expect(onBlur).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
38
ui/litellm-dashboard/src/components/shared/NumberInput.tsx
Normal file
38
ui/litellm-dashboard/src/components/shared/NumberInput.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
type NumberInputProps = Omit<React.ComponentProps<"input">, "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<HTMLInputElement, NumberInputProps>(
|
||||
({ value, onValueChange, onBlur, ...props }, ref) => {
|
||||
const [draft, setDraft] = React.useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<Input
|
||||
{...props}
|
||||
ref={ref}
|
||||
type="number"
|
||||
value={draft ?? String(value)}
|
||||
onChange={(event) => {
|
||||
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 };
|
||||
Loading…
Add table
Reference in a new issue