fix(ui): preserve cleared shared select values (#40795)

Preserve explicit null when shared selectors clear and adapt affected forms, validation, and request payloads. Clear stale dependent relationships and retain required-selection checks. Document project detachment, user model-budget clearing, and routing-compression clearing as deferred follow-ups.
This commit is contained in:
yuneng-jiang 2026-09-11 18:37:08 -07:00 committed by GitHub
parent 44ce8bb1ef
commit cf97b757a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
78 changed files with 492 additions and 263 deletions

View file

@ -1,11 +1,11 @@
import React from "react";
import { render, screen, waitFor, within } from "@testing-library/react";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import AddAgentForm from "./add_agent_form";
import * as networking from "@/components/networking";
import type { AgentCreateInfo } from "@/components/networking";
import { chooseSelectOption } from "../../../../../tests/test-utils";
import { chooseSelectOption, renderWithProviders as render } from "../../../../../tests/test-utils";
vi.mock("@/components/networking", () => ({
createAgentCall: vi.fn(),
@ -351,4 +351,33 @@ describe("AddAgentForm submit payload", () => {
expect(await screen.findByText("Agent Created!")).toBeInTheDocument();
expect(within(screen.getByText("Agent Created!").parentElement!).getByText("created-agent")).toBeInTheDocument();
});
it("blocks creation after clearing the existing key and assigns the reselected key", async () => {
vi.mocked(networking.keyListCall).mockResolvedValue({
keys: [{ token: "key-maple", key_alias: "Maple key" }],
});
const user = userEvent.setup();
renderForm();
await user.type(await screen.findByLabelText("Agent Name"), "key-selection-agent");
await user.type(screen.getByLabelText("Display Name"), "Key selection");
await user.type(screen.getByPlaceholderText("Describe what this agent does..."), "d");
for (let step = 0; step < 3; step++) {
await user.click(screen.getByRole("button", { name: /^Next/ }));
}
await user.click(screen.getByRole("radio", { name: "Assign an existing key" }));
const keySelector = await screen.findByPlaceholderText("Search by key name…");
await chooseSelectOption(user, keySelector, "Maple key");
await user.click(screen.getByRole("button", { name: "Clear" }));
await user.click(screen.getByRole("button", { name: /Create Agent/ }));
expect(networking.createAgentCall).not.toHaveBeenCalled();
expect(networking.keyUpdateCall).not.toHaveBeenCalled();
await chooseSelectOption(user, keySelector, "Maple key");
await user.click(screen.getByRole("button", { name: /Create Agent/ }));
await waitFor(() =>
expect(networking.keyUpdateCall).toHaveBeenCalledWith("tok", {
key: "key-maple",
agent_id: "agent-1",
}),
);
expect(networking.createAgentCall).toHaveBeenCalledTimes(1);
});
});

View file

@ -338,6 +338,11 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
return;
}
if (keyAssignOption === "existing_key" && !selectedExistingKey) {
toast.error("Please select an existing key to assign");
return;
}
setIsSubmitting(true);
try {
const isValid = await form.trigger();
@ -406,12 +411,7 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
selectedTeamId,
);
setCreatedKeyValue(keyResponse.key || null);
} else if (keyAssignOption === "existing_key") {
if (!selectedExistingKey) {
toast.error("Please select an existing key to assign");
setIsSubmitting(false);
return;
}
} else if (keyAssignOption === "existing_key" && selectedExistingKey) {
await keyUpdateCall(accessToken, {
key: selectedExistingKey,
agent_id: agentId,
@ -963,8 +963,8 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
<SearchSelect
inputId="agent-existing-key"
placeholder={loadingKeys ? "Loading keys…" : "Search by key name…"}
value={selectedExistingKey ?? ""}
onValueChange={(value) => setSelectedExistingKey(value || null)}
value={selectedExistingKey}
onValueChange={setSelectedExistingKey}
options={existingKeys.map((k) => ({
label: k.key_alias || k.token?.slice(0, 12) + "…",
value: k.token,

View file

@ -170,8 +170,8 @@ interface StartFormValidityInputs {
models: string[];
routerNames: string[];
direction: ShadowEvalDirection;
baselineModel: string;
judgeModel: string;
baselineModel: string | null;
judgeModel: string | null;
percentage: string;
maxBudget: string;
}
@ -181,13 +181,13 @@ const startFormValidity = (inputs: StartFormValidityInputs) => {
const percentageValid = parsedPct >= 0.1 && parsedPct <= 100;
const parsedMaxBudget = Number.parseFloat(inputs.maxBudget);
const maxBudgetValid = parsedMaxBudget >= 0.01 && parsedMaxBudget <= 10000;
const baselinePicked = inputs.direction === "forward" || inputs.baselineModel !== "";
const baselinePicked = inputs.direction === "forward" || Boolean(inputs.baselineModel);
const targetsPicked = inputs.apiKeyIds.length + inputs.teamIds.length + inputs.userIds.length > 0;
const routerCountValid = inputs.routerNames.length >= 1 && inputs.routerNames.length <= MAX_ROUTERS;
const routersMatchDirection = inputs.direction === "forward" || inputs.routerNames.length === 1;
const routersValid = routerCountValid && routersMatchDirection;
const scopeValid = routersValid && (inputs.direction === "reverse" || inputs.models.length <= MAX_MODELS);
const modelsPicked = scopeValid && inputs.judgeModel !== "" && baselinePicked;
const modelsPicked = scopeValid && Boolean(inputs.judgeModel) && baselinePicked;
const filled = targetsPicked && modelsPicked;
const boundsValid = percentageValid && maxBudgetValid;
const valid = Boolean(inputs.accessToken) && filled && boundsValid;
@ -201,7 +201,7 @@ interface StartBodyInputs {
models: string[];
routerNames: string[];
direction: ShadowEvalDirection;
baselineModel: string;
baselineModel: string | null;
shadowPercentage: number;
durationDays: number;
maxBudget: number;
@ -215,7 +215,7 @@ const buildStartBody = (inputs: StartBodyInputs) => ({
models: inputs.direction === "forward" ? inputs.models : [],
router_names: inputs.routerNames,
direction: inputs.direction,
...(inputs.direction === "reverse" ? { baseline_model: inputs.baselineModel } : {}),
...(inputs.direction === "reverse" ? { baseline_model: inputs.baselineModel ?? undefined } : {}),
shadow_percentage: inputs.shadowPercentage,
duration_days: inputs.durationDays,
max_budget: inputs.maxBudget,
@ -230,10 +230,10 @@ export const StartForm: React.FC = () => {
const [models, setModels] = useState<string[]>([]);
const [routerNames, setRouterNames] = useState<string[]>([]);
const [direction, setDirection] = useState<ShadowEvalDirection>("forward");
const [baselineModel, setBaselineModel] = useState("");
const [baselineModel, setBaselineModel] = useState<string | null>(null);
const [percentage, setPercentage] = useState("10");
const [durationDays, setDurationDays] = useState("7");
const [judgeModel, setJudgeModel] = useState("");
const [judgeModel, setJudgeModel] = useState<string | null>(null);
const [maxBudget, setMaxBudget] = useState("10");
const { data: autoRouters } = useAutoRouters();
const configuredGroups = usePlainModelGroups();
@ -286,6 +286,7 @@ export const StartForm: React.FC = () => {
};
const { parsedPct, parsedMaxBudget, percentageValid, maxBudgetValid, valid } = startFormValidity(validityInputs);
const handleStart = () => {
if (!valid || !judgeModel) return;
const bodyInputs: StartBodyInputs = {
apiKeyIds,
teamIds,

View file

@ -15,7 +15,7 @@ const generateId = () => `entry-${Date.now()}-${Math.random().toString(36).subst
const createDefaultEntry = (): ModelEntry => ({
id: generateId(),
model: "",
model: null,
input_tokens: 1000,
output_tokens: 500,
num_requests_per_day: undefined,
@ -28,7 +28,7 @@ const PricingCalculator: React.FC<PricingCalculatorProps> = ({ accessToken, mode
const { debouncedFetchForEntry, removeEntry, getMultiModelResult } = useMultiCostEstimate(accessToken);
const handleEntryChange = useCallback(
(id: string, field: keyof ModelEntry, value: string | number | undefined) => {
(id: string, field: keyof ModelEntry, value: string | number | null | undefined) => {
setEntries((prev) => {
const updated = prev.map((entry) => (entry.id === id ? { ...entry, [field]: value } : entry));
const changedEntry = updated.find((e) => e.id === id);

View file

@ -4,7 +4,7 @@ export interface PricingCalculatorProps {
}
export interface PricingFormValues {
model: string;
model: string | null;
input_tokens: number;
output_tokens: number;
num_requests_per_day?: number;
@ -13,7 +13,7 @@ export interface PricingFormValues {
export interface ModelEntry {
id: string;
model: string;
model: string | null;
input_tokens: number;
output_tokens: number;
num_requests_per_day?: number;

View file

@ -48,7 +48,10 @@ const GUARDRAIL_MODES = [
] as const;
const submitGuardrailSchema = z.object({
team_id: z.string().min(1, "Select a team"),
team_id: z
.string()
.nullable()
.pipe(z.string({ error: "Select a team" }).min(1, "Select a team")),
guardrail_name: z.string().min(1, "Enter a guardrail name"),
mode: z.string().min(1, "Select a mode"),
api_base: z.string().min(1, "Enter the API base URL").refine(isValidUrl, "Must be a valid URL"),

View file

@ -278,7 +278,7 @@ export function AllModelsTable({
options={modelGroupOptions}
value={(get(MODEL_NAME_COLUMN_ID) as string) ?? ALL_MODEL_GROUPS_VALUE}
onValueChange={(value) =>
set(MODEL_NAME_COLUMN_ID, value === ALL_MODEL_GROUPS_VALUE ? undefined : value)
set(MODEL_NAME_COLUMN_ID, value === ALL_MODEL_GROUPS_VALUE ? undefined : value ?? undefined)
}
placeholder="Filter by Public Model Name"
emptyText="No models found"
@ -289,7 +289,7 @@ export function AllModelsTable({
options={accessGroupOptions}
value={(get(ACCESS_GROUPS_COLUMN_ID) as string) ?? ALL_MODEL_GROUPS_VALUE}
onValueChange={(value) =>
set(ACCESS_GROUPS_COLUMN_ID, value === ALL_MODEL_GROUPS_VALUE ? undefined : value)
set(ACCESS_GROUPS_COLUMN_ID, value === ALL_MODEL_GROUPS_VALUE ? undefined : value ?? undefined)
}
placeholder="Filter by Model Access Group"
emptyText="No model access groups found"

View file

@ -26,7 +26,7 @@ export default function AddModelPanel() {
const { data: modelCostMapData } = useModelCostMap();
const { data: credentialsResponse } = useCredentials();
const { data: teams } = useTeams();
const [selectedProvider, setSelectedProvider] = useState<Providers>(Providers.Anthropic);
const [selectedProvider, setSelectedProvider] = useState<string | null>(Providers.Anthropic);
const [providerModels, setProviderModels] = useState<string[]>([]);
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
@ -57,7 +57,9 @@ export default function AddModelPanel() {
selectedProvider={selectedProvider}
setSelectedProvider={setSelectedProvider}
providerModels={providerModels}
setProviderModelsFn={(provider) => setProviderModels(getProviderModels(provider, modelCostMapData))}
setProviderModelsFn={(provider) =>
setProviderModels(provider === null ? [] : getProviderModels(provider, modelCostMapData))
}
getPlaceholder={getPlaceholder}
showAdvancedSettings={showAdvancedSettings}
setShowAdvancedSettings={setShowAdvancedSettings}

View file

@ -153,6 +153,7 @@ describe("ChatUI", () => {
});
it("should allow the user to select a model", async () => {
const user = userEvent.setup();
render(
<ChatUI
accessToken="1234567890"
@ -172,6 +173,21 @@ describe("ChatUI", () => {
await waitFor(() => {
expect(screen.getAllByText("Model 1").length).toBeGreaterThan(0);
});
await user.click(screen.getByRole("option", { name: "Model 1Mode: chat" }));
expect(screen.getByPlaceholderText("Select a Model")).toHaveValue("Model 1");
await user.click(screen.getAllByRole("button", { name: "Clear" })[0]);
const input = screen.getByPlaceholderText("Describe the image you want to generate...");
fireEvent.change(input, { target: { value: "Contract endpoint check" } });
expect(screen.getByRole("button", { name: "Send message" })).toBeDisabled();
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
expect(input).toHaveValue("Contract endpoint check");
expect(makeOpenAIChatCompletionRequest).not.toHaveBeenCalled();
expect(sessionStorage.getItem("endpointType")).toBeNull();
await selectComboboxOption("Select an endpoint", "/v1/chat/completions");
await selectComboboxOption("Select a Model", "Model 1");
expect(screen.getByRole("button", { name: "Send message" })).toBeEnabled();
});
it("shows only endpoint-compatible models when chat endpoint is selected", async () => {

View file

@ -196,17 +196,17 @@ const ChatUI: React.FC<ChatUIProps> = ({
() => sessionStorage.getItem("customProxyBaseUrl") || "",
);
const [inputMessage, setInputMessage] = useState("");
const [selectedModel, setSelectedModel] = useState<string | undefined>(simplified ? fixedModel : undefined);
const [selectedModel, setSelectedModel] = useState<string | null | undefined>(simplified ? fixedModel : null);
const [showCustomModelInput, setShowCustomModelInput] = useState<boolean>(false);
const [modelInfo, setModelInfo] = useState<ModelGroup[]>([]);
const [isLoadingModels, setIsLoadingModels] = useState(false);
const [modelLoadError, setModelLoadError] = useState(false);
const [agentInfo, setAgentInfo] = useState<Agent[]>([]);
const [selectedAgent, setSelectedAgent] = useState<string | undefined>(undefined);
const [selectedAgent, setSelectedAgent] = useState<string | null>(null);
const debouncedSetSelectedModel = useDebouncedCallback((value: string) => setSelectedModel(value), {
wait: CUSTOM_MODEL_DEBOUNCE_WAIT_MS,
});
const [endpointType, setEndpointType] = useState<string>(
const [endpointType, setEndpointType] = useState<string | null>(
() => sessionStorage.getItem("endpointType") || EndpointType.CHAT,
);
const [isLoading, setIsLoading] = useState<boolean>(false);
@ -327,7 +327,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
};
useEffect(() => {
if (isGetCodeModalVisible) {
if (isGetCodeModalVisible && endpointType !== null) {
const code = generateCodeSnippet({
apiKeySource,
accessToken,
@ -342,7 +342,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
mcpServers,
mcpServerToolRestrictions,
endpointType,
selectedModel,
selectedModel: selectedModel ?? undefined,
selectedSdk,
selectedVoice,
proxySettings,
@ -376,7 +376,8 @@ const ChatUI: React.FC<ChatUIProps> = ({
} catch {
// Storage full or unavailable — non-critical, skip persisting.
}
sessionStorage.setItem("endpointType", endpointType);
if (endpointType === null) sessionStorage.removeItem("endpointType");
else sessionStorage.setItem("endpointType", endpointType);
sessionStorage.setItem("selectedTags", JSON.stringify(selectedTags));
sessionStorage.setItem("selectedVectorStores", JSON.stringify(selectedVectorStores));
sessionStorage.setItem("selectedGuardrails", JSON.stringify(selectedGuardrails));
@ -493,7 +494,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
setAgentInfo(agents);
// Clear selection if current agent not in list
if (selectedAgent && !agents.some((a) => a.agent_name === selectedAgent)) {
setSelectedAgent(undefined);
setSelectedAgent(null);
}
} catch (error) {
console.error("Error fetching agents:", error);
@ -616,10 +617,11 @@ const ChatUI: React.FC<ChatUIProps> = ({
setUploadedAudio(file);
};
const handleEndpointChange = (value: string) => {
const handleEndpointChange = (value: string | null) => {
setEndpointType(value);
setSelectedModel(undefined);
setSelectedAgent(undefined);
setGeneratedCode("");
setSelectedModel(null);
setSelectedAgent(null);
setShowCustomModelInput(false);
setSelectedMCPDirectTool(undefined);
if (value === EndpointType.MCP) {
@ -710,6 +712,11 @@ const ChatUI: React.FC<ChatUIProps> = ({
};
const handleSendMessage = async () => {
if (endpointType === null) {
toast.fromError("Please select an endpoint before sending a request");
return;
}
if (inputMessage.trim() === "" && endpointType !== EndpointType.TRANSCRIPTION && endpointType !== EndpointType.MCP)
return;
@ -1152,7 +1159,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
toast.success("Chat history cleared.");
};
const onModelChange = (value: string) => {
const onModelChange = (value: string | null) => {
setSelectedModel(value);
setShowCustomModelInput(value === "custom");
@ -1210,6 +1217,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
: "Describe the image you want to generate...";
const sendDisabled =
endpointType === null ||
isLoading ||
(endpointType === EndpointType.MCP
? !(selectedMCPServers.length === 1 && selectedMCPServers[0] !== "__all__" && selectedMCPDirectTool)

View file

@ -3,8 +3,8 @@ import React from "react";
import { ENDPOINT_OPTIONS } from "./chatConstants";
interface EndpointSelectorProps {
endpointType: string; // Accept string to avoid type conflicts
onEndpointChange: (value: string) => void;
endpointType: string | null;
onEndpointChange: (value: string | null) => void;
className?: string;
}

View file

@ -7,7 +7,7 @@ import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
interface SessionManagementProps {
endpointType: string;
endpointType: string | null;
responsesSessionId: string | null;
useApiSessionManagement: boolean;
onToggleSessionManagement: (useApi: boolean) => void;

View file

@ -22,7 +22,8 @@ export function ModelSelector({ value, onChange, models, loading, disabled }: Mo
const selectValue = isAddingCustom ? "__custom__" : value || undefined;
const handleSelectChange = (selected: string) => {
const handleSelectChange = (selected: string | null) => {
if (selected === null) return;
if (selected === "__custom__") {
setIsAddingCustom(true);
if (value && !options.includes(value)) {

View file

@ -44,10 +44,10 @@ const policyShape = {
.min(1, "Please enter a policy name")
.regex(/^[a-zA-Z0-9_-]+$/, "Policy name can only contain letters, numbers, hyphens, and underscores"),
description: z.string(),
inherit: z.string(),
inherit: z.string().nullable(),
guardrails_add: z.array(z.string()),
guardrails_remove: z.array(z.string()),
model_condition: z.string(),
model_condition: z.string().nullable(),
};
const policySchema = z.object(policyShape);
@ -57,19 +57,19 @@ type PolicyFormValues = z.infer<typeof policySchema>;
const EMPTY_VALUES: PolicyFormValues = {
policy_name: "",
description: "",
inherit: "",
inherit: null,
guardrails_add: [],
guardrails_remove: [],
model_condition: "",
model_condition: null,
};
const toFormValues = (policy: Policy): PolicyFormValues => ({
policy_name: policy.policy_name,
description: policy.description ?? "",
inherit: policy.inherit ?? "",
inherit: policy.inherit ?? null,
guardrails_add: policy.guardrails_add || [],
guardrails_remove: policy.guardrails_remove || [],
model_condition: policy.condition?.model ?? "",
model_condition: policy.condition?.model ?? null,
});
const buildPolicyRequest = (values: PolicyFormValues): PolicyCreateRequest | PolicyUpdateRequest => ({
@ -529,7 +529,7 @@ const AddPolicyForm: React.FC<AddPolicyFormProps> = ({
{...control}
id={id}
ref={ref}
value={value}
value={value ?? ""}
onChange={onChange}
placeholder="Leave empty to apply to all models (e.g., gpt-4.* or bedrock/claude-.*)"
/>

View file

@ -68,7 +68,7 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
const [suggestions, setSuggestions] = useState<SuggestedTemplate[] | null>(null);
const [explanation, setExplanation] = useState<string | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [selectedModel, setSelectedModel] = useState<string | undefined>(undefined);
const [selectedModel, setSelectedModel] = useState<string | null>(null);
const [availableModels, setAvailableModels] = useState<string[]>([]);
const [isLoadingModels, setIsLoadingModels] = useState(false);
// Test panel state
@ -114,7 +114,7 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
setSuggestions(null);
setExplanation(null);
setSelectedIds(new Set());
setSelectedModel(undefined);
setSelectedModel(null);
setShowTestPanel(false);
setTestInputText("");
setIsTestLoading(false);
@ -837,7 +837,7 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
<SearchSelect
options={availableModels.map((m) => ({ label: m, value: m }))}
value={selectedModel}
onValueChange={(value) => setSelectedModel(value || undefined)}
onValueChange={setSelectedModel}
placeholder={isLoadingModels ? "Loading models..." : "Select a model to analyze your requirements"}
emptyText="No models found"
disabled={isLoadingModels}

View file

@ -343,7 +343,7 @@ const StepCard: React.FC<StepCardProps> = ({
<SearchSelect
options={guardrailOptions}
value={step.guardrail || undefined}
onValueChange={(value) => onChange({ guardrail: value })}
onValueChange={(value) => onChange({ guardrail: value ?? undefined })}
placeholder="Select a guardrail"
emptyText="No guardrails found"
/>

View file

@ -46,7 +46,7 @@ const TemplateParameterModal: React.FC<TemplateParameterModalProps> = ({
}) => {
const [parameterValues, setParameterValues] = useState<Record<string, string>>({});
const [competitorMode, setCompetitorMode] = useState<"ai" | "manual">("ai");
const [selectedModel, setSelectedModel] = useState<string | undefined>(undefined);
const [selectedModel, setSelectedModel] = useState<string | null>(null);
const [availableModels, setAvailableModels] = useState<string[]>([]);
const [isLoadingModels, setIsLoadingModels] = useState(false);
const [competitorTags, setCompetitorTags] = useState<string[]>([]);
@ -72,7 +72,7 @@ const TemplateParameterModal: React.FC<TemplateParameterModalProps> = ({
});
setParameterValues(initial);
setCompetitorMode("ai");
setSelectedModel(undefined);
setSelectedModel(null);
setCompetitorTags([]);
setVariationsMap({});
setIsGenerating(false);
@ -297,7 +297,7 @@ const TemplateParameterModal: React.FC<TemplateParameterModalProps> = ({
<SearchSelect
options={availableModels.map((m) => ({ label: m, value: m }))}
value={selectedModel}
onValueChange={(value) => setSelectedModel(value || undefined)}
onValueChange={setSelectedModel}
placeholder={isLoadingModels ? "Loading models..." : "Select a model to generate names"}
emptyText="No models found"
disabled={isLoadingModels}

View file

@ -10,7 +10,7 @@ import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects";
import { useUpdateProject, ProjectUpdateParams } from "@/app/(dashboard)/hooks/projects/useUpdateProject";
import { ProjectBaseForm } from "./ProjectBaseForm";
import { projectFormSchema, type ProjectFormValues } from "./projectFormSchema";
import { projectFormSchema, type ProjectFormValues, type ProjectSubmitValues } from "./projectFormSchema";
import { buildProjectUpdateParams } from "./projectFormUtils";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
@ -58,7 +58,7 @@ export const toFormValues = (project: ProjectResponse): ProjectFormValues => {
return {
project_alias: project.project_alias ?? "",
team_id: project.team_id ?? "",
team_id: project.team_id ?? null,
description: project.description ?? "",
models: project.models ?? [],
max_budget: project.litellm_budget_table?.max_budget ?? undefined,
@ -81,7 +81,7 @@ function EditProjectForm({ project, onClose, onSuccess }: Omit<EditProjectModalP
};
const handleSubmit = form.handleSubmit((values) => {
const submitted: ProjectFormValues = advancedEverOpened
const submitted: ProjectSubmitValues = advancedEverOpened
? values
: { ...values, guardrails: undefined, modelLimits: undefined, metadata: undefined };

View file

@ -5,7 +5,7 @@ import { useFieldArray, useWatch, type UseFormReturn } from "react-hook-form";
import { ChevronDown, CircleAlert, Minus, Plus } from "lucide-react";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { ALL_TEAM_MODELS, type ProjectFormValues } from "./projectFormSchema";
import { ALL_TEAM_MODELS, type ProjectFormValues, type ProjectSubmitValues } from "./projectFormSchema";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { Team } from "@/components/key_team_helpers/key_list";
import { fetchTeamModels } from "@/components/organisms/create_key_button";
@ -32,7 +32,7 @@ const toOptionalNumber = (raw: string): number | undefined => {
};
interface ProjectBaseFormProps {
form: UseFormReturn<ProjectFormValues>;
form: UseFormReturn<ProjectFormValues, unknown, ProjectSubmitValues>;
advancedOpen: boolean;
onAdvancedOpenChange: (open: boolean) => void;
}
@ -94,7 +94,7 @@ export function ProjectBaseForm({ form, advancedOpen, onAdvancedOpenChange }: Pr
}
}, [selectedTeam, accessToken, userId, userRole]);
const handleTeamChange = (teamId: string) => {
const handleTeamChange = (teamId: string | null) => {
const team = teams?.find((t) => t.team_id === teamId) ?? null;
setSelectedTeam(team);
form.setValue("models", []);

View file

@ -16,7 +16,10 @@ const modelLimitSchema = z.object({
export const projectFormSchema = z
.object({
project_alias: z.string().min(1, "Please enter a project name"),
team_id: z.string().min(1, "Please select a team"),
team_id: z
.string()
.nullable()
.pipe(z.string({ error: "Please select a team" }).min(1, "Please select a team")),
description: z.string().optional(),
models: z.array(z.string()),
max_budget: z.number().optional(),
@ -48,11 +51,12 @@ export const projectFormSchema = z
});
});
export type ProjectFormValues = z.output<typeof projectFormSchema>;
export type ProjectFormValues = z.input<typeof projectFormSchema>;
export type ProjectSubmitValues = z.output<typeof projectFormSchema>;
export const emptyProjectFormValues: ProjectFormValues = {
project_alias: "",
team_id: "",
team_id: null,
description: undefined,
models: [],
max_budget: undefined,

View file

@ -6,11 +6,11 @@ import { SettingsIcon } from "lucide-react";
import ModelSelector from "@/components/common_components/ModelSelector";
interface ModelConfigCardProps {
model: string;
model: string | null;
temperature?: number;
maxTokens?: number;
accessToken: string | null;
onModelChange: (model: string) => void;
onModelChange: (model: string | null) => void;
onTemperatureChange: (temp: number) => void;
onMaxTokensChange: (tokens: number) => void;
}

View file

@ -21,7 +21,7 @@ interface PromptEditorHeaderProps {
editMode?: boolean;
onShowHistory?: () => void;
version?: string | null;
promptModel?: string;
promptModel?: string | null;
promptVariables?: Record<string, string>;
accessToken: string | null;
proxySettings?: {
@ -85,7 +85,7 @@ const PromptEditorHeader: React.FC<PromptEditorHeaderProps> = ({
<div className="flex items-center space-x-2">
<PromptCodeSnippets
promptId={promptName}
model={promptModel}
model={promptModel ?? "YOUR_MODEL"}
promptVariables={promptVariables}
accessToken={accessToken}
version={version?.replace("v", "") || "1"}

View file

@ -11,7 +11,7 @@ export interface Tool {
export interface PromptType {
name: string;
model: string;
model: string | null;
config: {
temperature?: number;
max_tokens?: number;

View file

@ -108,6 +108,8 @@ describe("convertToDotPrompt", () => {
expect(result).toContain("output:");
expect(result).toContain("format: text");
expect(result).toContain("User: Hello world");
const cleared = convertToDotPrompt({ ...prompt, model: null });
expect(cleared).toBe(result.replace("model: gpt-4\n", ""));
});
it("should include config parameters when set", () => {
@ -203,6 +205,20 @@ describe("convertToDotPrompt", () => {
});
describe("parseExistingPrompt", () => {
it("should keep saved prompts with missing or blank models unassigned", () => {
for (const modelLine of ["", "model: \n"]) {
const prompt = parseExistingPrompt({
prompt_spec: {
prompt_id: "unassigned-prompt",
litellm_params: { dotprompt_content: `---\n${modelLine}temperature: 0\n---\nUser: Keep this message` },
},
});
expect(prompt.model).toBeNull();
expect(convertToDotPrompt(prompt)).not.toMatch(/^model:/m);
}
});
it("should parse basic dotprompt content", () => {
const apiResponse = {
prompt_spec: {

View file

@ -23,7 +23,7 @@ export const extractVariables = (prompt: PromptType): string[] => {
export const convertToDotPrompt = (prompt: PromptType): string => {
const variables = extractVariables(prompt);
let result = `---\nmodel: ${prompt.model}\n`;
let result = prompt.model ? `---\nmodel: ${prompt.model}\n` : "---\n";
// Add temperature if set
if (prompt.config.temperature !== undefined) {
@ -237,7 +237,7 @@ export const parseExistingPrompt = (apiResponse: any): PromptType => {
return {
name: baseName,
model: parsedFrontmatter.model || "gpt-4o",
model: parsedFrontmatter.model || null,
config: parsedFrontmatter.config,
tools: parsedFrontmatter.tools,
developerMessage: parsedBody.developerMessage,

View file

@ -20,7 +20,12 @@ import { useZodForm } from "@/lib/forms/useZodForm";
import { fetchClient } from "@/lib/http/api";
import { buildBody, settingsToForm, type DefaultInternalUserParams, type InternalUserSettings } from "./mapper";
import { defaultUserSettingsSchema, EMPTY_TEAM_ROW, type DefaultUserSettingsFormValues } from "./schema";
import {
defaultUserSettingsSchema,
EMPTY_TEAM_ROW,
type DefaultUserSettingsFormValues,
type DefaultUserSettingsSubmitValues,
} from "./schema";
const NO_RESET = "never";
@ -63,7 +68,7 @@ interface RoleOption {
description: string;
}
type SettingsControl = Control<DefaultUserSettingsFormValues, unknown, DefaultUserSettingsFormValues>;
type SettingsControl = Control<DefaultUserSettingsFormValues, unknown, DefaultUserSettingsSubmitValues>;
const TeamPickerField = ({ control, index }: { control: SettingsControl; index: number }) => {
const [search, setSearch] = React.useState("");
@ -233,7 +238,7 @@ const SettingsForm = ({ initialValues, roleOptions, updateSettings, onCancel, on
const { isDirty } = form.formState;
const mutation = useMutation({
mutationFn: (values: DefaultUserSettingsFormValues) => updateSettings(buildBody(values)),
mutationFn: (values: DefaultUserSettingsSubmitValues) => updateSettings(buildBody(values)),
onSuccess: (_result, values) => {
toast.success("Default user settings updated successfully");
queryClient.invalidateQueries({ queryKey: SETTINGS_QUERY_KEY });

View file

@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { buildBody, settingsToForm } from "./mapper";
import type { DefaultUserSettingsFormValues } from "./schema";
import type { DefaultUserSettingsSubmitValues } from "./schema";
const CONFIGURED_SETTINGS = {
user_role: "internal_user",
@ -59,13 +59,13 @@ describe("settingsToForm", () => {
it("degrades an unrecognisable team entry to a blank row instead of throwing", () => {
expect(settingsToForm({ teams: [{ max_budget_in_team: 5 }, 7] }).teams).toStrictEqual([
{ team_id: "", max_budget_in_team: "", user_role: "user" },
{ team_id: "", max_budget_in_team: "", user_role: "user" },
{ team_id: null, max_budget_in_team: "", user_role: "user" },
{ team_id: null, max_budget_in_team: "", user_role: "user" },
]);
});
});
const formValues = (overrides: Partial<DefaultUserSettingsFormValues> = {}): DefaultUserSettingsFormValues => ({
const formValues = (overrides: Partial<DefaultUserSettingsSubmitValues> = {}): DefaultUserSettingsSubmitValues => ({
user_role: "internal_user",
max_budget: "100",
budget_duration: "30d",

View file

@ -2,7 +2,12 @@ import { z } from "zod/v4";
import type { components } from "@/lib/http/schema";
import { EMPTY_TEAM_ROW, type DefaultTeamRowValues, type DefaultUserSettingsFormValues } from "./schema";
import {
EMPTY_TEAM_ROW,
type DefaultTeamRowValues,
type DefaultUserSettingsFormValues,
type DefaultUserSettingsSubmitValues,
} from "./schema";
export type InternalUserSettings = components["schemas"]["InternalUserSettingsResponse"];
export type DefaultInternalUserParams = components["schemas"]["DefaultInternalUserParams"];
@ -60,13 +65,13 @@ const textOrNull = (raw: string): string | null => (raw.trim() === "" ? null : r
const listOrNull = <T>(items: readonly T[]): T[] | null => (items.length === 0 ? null : [...items]);
const toTeamBody = (team: DefaultTeamRowValues): DefaultTeamBody => ({
const toTeamBody = (team: DefaultUserSettingsSubmitValues["teams"][number]): DefaultTeamBody => ({
team_id: team.team_id,
max_budget_in_team: numberOrNull(team.max_budget_in_team),
user_role: team.user_role,
});
export const buildBody = (values: DefaultUserSettingsFormValues): DefaultInternalUserParams => ({
export const buildBody = (values: DefaultUserSettingsSubmitValues): DefaultInternalUserParams => ({
user_role: asDefaultUserRole(values.user_role),
max_budget: numberOrNull(values.max_budget),
budget_duration: textOrNull(values.budget_duration),

View file

@ -10,14 +10,17 @@ const amountOrEmpty = z
);
const defaultTeamRowSchema = z.object({
team_id: z.string().min(1, "Select a team"),
team_id: z
.string()
.nullable()
.pipe(z.string({ error: "Select a team" }).min(1, "Select a team")),
max_budget_in_team: amountOrEmpty,
user_role: z.enum(["user", "admin"]),
});
export type DefaultTeamRowValues = z.output<typeof defaultTeamRowSchema>;
export type DefaultTeamRowValues = z.input<typeof defaultTeamRowSchema>;
export const EMPTY_TEAM_ROW: DefaultTeamRowValues = { team_id: "", max_budget_in_team: "", user_role: "user" };
export const EMPTY_TEAM_ROW: DefaultTeamRowValues = { team_id: null, max_budget_in_team: "", user_role: "user" };
const defaultUserSettingsShape = {
user_role: z.string(),
@ -41,4 +44,5 @@ export const defaultUserSettingsSchema = z.object(defaultUserSettingsShape).supe
);
});
export type DefaultUserSettingsFormValues = z.output<typeof defaultUserSettingsSchema>;
export type DefaultUserSettingsFormValues = z.input<typeof defaultUserSettingsSchema>;
export type DefaultUserSettingsSubmitValues = z.output<typeof defaultUserSettingsSchema>;

View file

@ -192,7 +192,7 @@ export function UsersTable({
<SearchSelect
options={roleOptions}
value={(get("user_role") as string) || undefined}
onValueChange={(value) => set("user_role", value)}
onValueChange={(value) => set("user_role", value ?? undefined)}
placeholder="Select a role…"
emptyText="No roles found"
/>
@ -201,7 +201,7 @@ export function UsersTable({
<SearchSelect
options={teamOptions}
value={(get("team") as string) || undefined}
onValueChange={(value) => set("team", value)}
onValueChange={(value) => set("team", value ?? undefined)}
placeholder="Select a team…"
emptyText="No teams found"
/>

View file

@ -59,7 +59,7 @@ interface UISettings {
interface CreateUserFormValues {
user_email?: string;
user_role: string;
team_id?: string;
team_id?: string | null;
organization_ids?: string[];
metadata?: string;
send_invite_email: boolean;

View file

@ -115,7 +115,7 @@ export default function MCPSemanticFilterSettings({ accessToken }: MCPSemanticFi
// Test section state
const [testQuery, setTestQuery] = useState("");
const [testModel, setTestModel] = useState<string>("gpt-4o");
const [testModel, setTestModel] = useState<string | null>("gpt-4o");
const [testResult, setTestResult] = useState<TestResult | null>(null);
const [testError, setTestError] = useState<string | null>(null);
const [isTesting, setIsTesting] = useState(false);

View file

@ -11,8 +11,8 @@ interface MCPSemanticFilterTestPanelProps {
accessToken: string | null;
testQuery: string;
setTestQuery: (value: string) => void;
testModel: string;
setTestModel: (value: string) => void;
testModel: string | null;
setTestModel: (value: string | null) => void;
isTesting: boolean;
onTest: () => void;
filterEnabled: boolean;

View file

@ -32,7 +32,7 @@ export const runSemanticFilterTest = async ({
setTestError,
}: {
accessToken: string;
testModel: string;
testModel: string | null;
testQuery: string;
setIsTesting: (value: boolean) => void;
setTestResult: (result: TestResult | null) => void;
@ -68,12 +68,12 @@ export const runSemanticFilterTest = async ({
}
};
export const getCurlCommand = (testModel: string, testQuery: string) =>
export const getCurlCommand = (testModel: string | null, testQuery: string) =>
`curl --location 'http://localhost:4000/v1/responses' \\
--header 'Content-Type: application/json' \\
--header 'Authorization: Bearer sk-1234' \\
--data '{
"model": "${testModel}",
"model": "${testModel ?? "YOUR_MODEL"}",
"input": [
{
"role": "user",

View file

@ -51,6 +51,7 @@ describe("parseCoreTools", () => {
describe("formToPayload", () => {
it("sends null for a cleared embedding model so the proxy returns to keyword matching", () => {
expect(formToPayload({ ...DEFAULT_FORM_VALUES, embedding_model: " " })).toEqual(KEYWORD_PAYLOAD);
expect(formToPayload({ ...DEFAULT_FORM_VALUES, embedding_model: null })).toEqual(KEYWORD_PAYLOAD);
});
it("clamps top_k into the range the proxy accepts and lists core tools", () => {

View file

@ -1,7 +1,7 @@
import type { MCPToolSearchSettings } from "@/app/(dashboard)/hooks/mcpToolSearchSettings/useMCPToolSearchSettings";
export interface ToolSearchFormValues {
embedding_model: string;
embedding_model: string | null;
top_k: number;
similarity_threshold: number;
core_tools_text: string;
@ -11,7 +11,7 @@ export const TOP_K_MIN = 1;
export const TOP_K_MAX = 100;
export const DEFAULT_FORM_VALUES: ToolSearchFormValues = {
embedding_model: "",
embedding_model: null,
top_k: 5,
similarity_threshold: 0,
core_tools_text: "",
@ -42,7 +42,7 @@ export const storedValuesToForm = (values: Record<string, unknown>): ToolSearchF
});
export const formToPayload = (form: ToolSearchFormValues): MCPToolSearchSettings => ({
embedding_model: form.embedding_model.trim() === "" ? null : form.embedding_model.trim(),
embedding_model: form.embedding_model?.trim() || null,
top_k: clampTopK(form.top_k),
similarity_threshold: form.similarity_threshold,
core_tools: parseCoreTools(form.core_tools_text),

View file

@ -32,12 +32,8 @@ export function FallbackGroupConfig({
// Filter available options for fallbacks (exclude primary only, allow already selected to be shown for deselection)
const availableFallbackOptions = availableModels.filter((m) => m !== group.primaryModel);
const handlePrimaryChange = (value: string) => {
let newFallbacks = [...group.fallbackModels];
// Remove from fallbacks if it was there
if (newFallbacks.includes(value)) {
newFallbacks = newFallbacks.filter((m) => m !== value);
}
const handlePrimaryChange = (value: string | null) => {
const newFallbacks = group.fallbackModels.filter((model) => model !== value);
onChange({
...group,
primaryModel: value,
@ -76,7 +72,7 @@ export function FallbackGroupConfig({
<SearchSelect
inputId={primaryModelInputId}
options={availableModels.map((m) => ({ label: m, value: m }))}
value={group.primaryModel ?? ""}
value={group.primaryModel}
onValueChange={handlePrimaryChange}
placeholder="Select primary model"
emptyText="No models found"

View file

@ -328,11 +328,11 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
};
const selectCreateTeamOrganization = (
next: string,
next: string | null,
currentOrganizationId: string | null,
onChange: (organizationId: string | null) => void,
) => {
const nextOrganizationId = next === "" ? null : next;
const nextOrganizationId = next;
if (nextOrganizationId === currentOrganizationId) return;
onChange(nextOrganizationId);
form.setValue("models", []);

View file

@ -203,7 +203,7 @@ export function TeamsTable({ userRole, userID, onSelectTeam, onEditTeam, onDelet
<SearchSelect
options={orgOptions}
value={(get("org_id") as string) || undefined}
onValueChange={(value) => set("org_id", value)}
onValueChange={(value) => set("org_id", value ?? undefined)}
placeholder="Select an organization…"
emptyText="No organizations found"
/>

View file

@ -311,7 +311,7 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
<SearchSelect
options={teamOptions}
value={(get("team_id") as string) || undefined}
onValueChange={(value) => set("team_id", value)}
onValueChange={(value) => set("team_id", value ?? undefined)}
placeholder="Select a team…"
emptyText="No teams found"
/>
@ -320,7 +320,7 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
<SearchSelect
options={orgOptions}
value={(get("org_id") as string) || undefined}
onValueChange={(value) => set("org_id", value)}
onValueChange={(value) => set("org_id", value ?? undefined)}
placeholder="Select an organization…"
emptyText="No organizations found"
/>

View file

@ -164,7 +164,7 @@ const createTestProps = (userRole = "proxy_admin", userId = "user-1", isTeamAdmi
handleOk: vi.fn().mockResolvedValue(true),
setSelectedProvider: vi.fn(),
setProviderModelsFn: vi.fn(),
getPlaceholder: vi.fn((provider: Providers) => `Enter ${provider} model name`),
getPlaceholder: vi.fn((provider: string) => `Enter ${provider} model name`),
setShowAdvancedSettings: vi.fn(),
selectedProvider: Providers.OpenAI,
providerModels: ["gpt-4", "gpt-3.5-turbo"],

View file

@ -25,7 +25,6 @@ import {
} from "../common_components/MountedFormField";
import type { Team } from "../key_team_helpers/key_list";
import { type CredentialItem, type ProviderCreateInfo, modelAvailableCall } from "../networking";
import { Providers } from "../provider_info_helpers";
import { ProviderLogo } from "../molecules/models/ProviderLogo";
import AccessGroupTagsCombobox from "./AccessGroupTagsCombobox";
import AdvancedSettings from "./advanced_settings";
@ -42,11 +41,11 @@ interface AddModelFormProps {
registry: MountRegistry;
mountedValues: () => MountedFormValues;
handleOk: () => Promise<boolean>;
selectedProvider: Providers;
setSelectedProvider: (provider: Providers) => void;
selectedProvider: string | null;
setSelectedProvider: (provider: string | null) => void;
providerModels: string[];
setProviderModelsFn: (provider: Providers) => void;
getPlaceholder: (provider: Providers) => string;
setProviderModelsFn: (provider: string | null) => void;
getPlaceholder: (provider: string) => string;
showAdvancedSettings: boolean;
setShowAdvancedSettings: (show: boolean) => void;
teams: Team[] | null;
@ -140,7 +139,7 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
[credentials],
);
const applyProviderSelection = (provider: Providers) => {
const applyProviderSelection = (provider: string | null) => {
setSelectedProvider(provider);
setProviderModelsFn(provider);
form.setValue("model", []);
@ -227,10 +226,10 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
options={providerOptions}
emptyText={providerMetadataErrorText ?? "No providers found"}
placeholder={isProviderMetadataLoading ? "Loading providers..." : "Select a provider"}
value={(control.value as string | undefined) ?? ""}
value={typeof control.value === "string" ? control.value : null}
onValueChange={(value) => {
control.onChange(value);
applyProviderSelection(value as Providers);
applyProviderSelection(value);
}}
/>
)}

View file

@ -328,7 +328,8 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
onChange(nextValue);
};
const handleClassifierModelChange = (model: string) => {
const handleClassifierModelChange = (model: string | null) => {
if (model === null) return;
if (model === value.classifier_llm_config?.model) return;
const { reasoning_effort: _reasoningEffort, ...classifierLlmConfig } = value.classifier_llm_config ?? {
model: "",

View file

@ -632,7 +632,7 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
// Clearing the select drops the key entirely rather than storing "", so an emptied pin reads as
// "track the tiers" everywhere downstream instead of as a blank model name.
const handleDefaultModelChange = (model: string | undefined) => {
const handleDefaultModelChange = (model: string | null | undefined) => {
onChange({ ...value, default_model: model || undefined });
};

View file

@ -42,8 +42,8 @@ const CompressionControls: React.FC<CompressionControlsProps> = ({ value, onChan
</div>
<SearchSelect
options={options}
value={routing ?? ""}
onValueChange={(value) => onRoutingChange(value === "" ? undefined : value)}
value={routing}
onValueChange={(value) => onRoutingChange(value ?? undefined)}
placeholder="Inherit from the request's own compression guardrails"
emptyText="No compression guardrails found"
aria-label="Routing decision compression"
@ -74,8 +74,8 @@ const CompressionControls: React.FC<CompressionControlsProps> = ({ value, onChan
<div className="mt-3">
<SearchSelect
options={options}
value={model ?? ""}
onValueChange={(value) => onModelChange(value === "" ? undefined : value)}
value={model}
onValueChange={(value) => onModelChange(value ?? undefined)}
placeholder="None (no compression)"
emptyText="No compression guardrails found"
aria-label="Model call compression"

View file

@ -47,7 +47,7 @@ describe("RouterConfigBuilder", () => {
expect(onChange).toHaveBeenCalledWith({
routes: [
expect.objectContaining({
name: "",
name: null,
utterances: [],
description: "",
score_threshold: 0.5,

View file

@ -0,0 +1,10 @@
import { describe, expect, it } from "vitest";
import { serializeRouterConfig } from "./RouterConfigBuilder";
describe("serializeRouterConfig", () => {
it("rejects a cleared route model and preserves selected route settings", () => {
expect(() => serializeRouterConfig({ routes: [{ name: null }] })).toThrow("Please select a model for every route");
const config = { routes: [{ name: "model-silver", utterances: [], description: "", score_threshold: 0 }] };
expect(JSON.parse(serializeRouterConfig(config))).toEqual(config);
});
});

View file

@ -15,7 +15,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
interface Route {
id: string;
model: string;
model: string | null;
utterances: string[];
description: string;
score_threshold: number;
@ -23,21 +23,28 @@ interface Route {
interface SavedRoute {
id?: string;
name?: string;
model?: string;
name?: string | null;
model?: string | null;
utterances?: string[];
description?: string;
score_threshold?: number;
}
interface RouterConfig {
export interface RouterConfig {
routes?: SavedRoute[];
}
export function serializeRouterConfig(config: RouterConfig | null): string {
if (config?.routes?.some((route) => !(route.name ?? route.model))) {
throw new Error("Please select a model for every route");
}
return JSON.stringify(config);
}
interface RouterConfigBuilderProps {
modelInfo: ModelGroup[];
value?: RouterConfig;
onChange?: (config: any) => void;
value?: RouterConfig | null;
onChange?: (config: RouterConfig) => void;
}
interface UtteranceInputProps {
@ -136,7 +143,7 @@ const RouterConfigBuilder: React.FC<RouterConfigBuilderProps> = ({ modelInfo, va
routeIds.push(id);
return {
id,
model: route.name || route.model || "",
model: route.name || route.model || null,
utterances: route.utterances || [],
description: route.description || "",
score_threshold: route.score_threshold ?? 0.5,
@ -165,7 +172,7 @@ const RouterConfigBuilder: React.FC<RouterConfigBuilderProps> = ({ modelInfo, va
const newRouteId = `route-${Date.now()}`;
const updatedRoutes = [
...routes,
{ id: newRouteId, model: "", utterances: [], description: "", score_threshold: 0.5 },
{ id: newRouteId, model: null, utterances: [], description: "", score_threshold: 0.5 },
];
setRoutes(updatedRoutes);
updateConfig(updatedRoutes);

View file

@ -61,7 +61,9 @@ const SemanticKeywordMatching: React.FC<SemanticKeywordMatchingProps> = ({
<SearchSelect
options={modelOptions}
value={embeddingModel ?? ""}
onValueChange={onEmbeddingModelChange}
onValueChange={(model) => {
if (model !== null) onEmbeddingModelChange(model);
}}
placeholder="Select an embedding model"
emptyText="No embedding models found"
aria-label="Embedding model"

View file

@ -150,7 +150,10 @@ export const getSubmitBlockedReason = (
const autoRouterSchema = (requiresTeamScope: boolean) =>
z.object({
auto_router_name: z.string().min(1, "Auto router name is required"),
team_id: requiresTeamScope ? z.string().min(1, "Please select a team to continue") : z.string(),
team_id: z
.string()
.nullable()
.refine((teamId) => !requiresTeamScope || Boolean(teamId), "Please select a team to continue"),
model_access_group: z.array(z.string()).optional(),
});
@ -158,12 +161,12 @@ type AddAutoRouterFormValues = z.infer<ReturnType<typeof autoRouterSchema>>;
const EMPTY_FORM_VALUES: AddAutoRouterFormValues = {
auto_router_name: "",
team_id: "",
team_id: null,
model_access_group: undefined,
};
const teamScopePayload = (requiresTeamScope: boolean, teamId: string): { team_id?: string } =>
requiresTeamScope ? { team_id: teamId } : {};
const teamScopePayload = (requiresTeamScope: boolean, teamId: string | null): { team_id?: string } =>
requiresTeamScope && teamId ? { team_id: teamId } : {};
const BlockedReasonTooltip: React.FC<{ reason: string | null; children: React.ReactElement }> = ({
reason,
@ -458,7 +461,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
const serverVerdict = await validateAutoRouterConfig(
accessToken,
complexityRouterConfigPayload as unknown as Record<string, unknown>,
requiresTeamScope ? form.getValues("team_id") : undefined,
requiresTeamScope ? form.getValues("team_id") ?? undefined : undefined,
);
const dryRunError = dryRunRejection(serverVerdict);
if (dryRunError) {
@ -630,9 +633,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
"Select the team this auto router belongs to. Only keys for this team will be able to call it.",
)}
>
{({ id, value, onChange }) => (
<TeamDropdown id={id} value={value} onChange={(next) => onChange(next ?? "")} />
)}
{({ id, value, onChange }) => <TeamDropdown id={id} value={value} onChange={onChange} />}
</FormField>
)}
@ -776,7 +777,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
config={buildComplexityRouterConfig(complexityRouterConfigParams)}
defaultModel={resolveComplexityDefaultModel(complexityRouterConfig, complexityRouterConfig.default_model)}
routerName={watchedName}
teamId={requiresTeamScope ? watchedTeamId : undefined}
teamId={requiresTeamScope ? watchedTeamId ?? undefined : undefined}
/>
)}
<DialogFooter>

View file

@ -8,9 +8,9 @@ import { MountedFormField, type MountedFormValues } from "../common_components/M
import { Providers } from "../provider_info_helpers";
interface LiteLLMModelNameFieldProps {
selectedProvider: Providers;
selectedProvider: string | null;
providerModels: string[];
getPlaceholder: (provider: Providers) => string;
getPlaceholder: (provider: string) => string;
}
const LiteLLMModelNameField: React.FC<LiteLLMModelNameFieldProps> = ({
@ -123,7 +123,7 @@ const LiteLLMModelNameField: React.FC<LiteLLMModelNameFieldProps> = ({
id={control.id}
value={(control.value as string | undefined) ?? ""}
onBlur={control.onBlur}
placeholder={getPlaceholder(selectedProvider)}
placeholder={selectedProvider === null ? "Select a provider first" : getPlaceholder(selectedProvider)}
onChange={(event) => {
control.onChange(event);
if (selectedProvider === Providers.Azure) {
@ -147,7 +147,7 @@ const LiteLLMModelNameField: React.FC<LiteLLMModelNameFieldProps> = ({
value: "custom",
},
{
label: `All ${selectedProvider} Models (Wildcard)`,
label: `All ${selectedProvider ?? "provider"} Models (Wildcard)`,
value: "all-wildcard",
},
...providerModels.map((model) => ({
@ -163,7 +163,7 @@ const LiteLLMModelNameField: React.FC<LiteLLMModelNameFieldProps> = ({
value={(control.value as string | undefined) ?? ""}
onChange={control.onChange}
onBlur={control.onBlur}
placeholder={getPlaceholder(selectedProvider)}
placeholder={selectedProvider === null ? "Select a provider first" : getPlaceholder(selectedProvider)}
/>
)
}

View file

@ -18,7 +18,7 @@ import { provider_map, Providers } from "../provider_info_helpers";
import { labelWithHint } from "@/components/shared/form/LabelWithHint";
interface ProviderSpecificFieldsProps {
selectedProvider: Providers;
selectedProvider: string | null;
}
const readTextFile = (file: File, onLoaded: (contents: string) => void) => {
@ -168,6 +168,7 @@ const ProviderSpecificFields: React.FC<ProviderSpecificFieldsProps> = ({ selecte
}, [cacheEntries]);
const allFields = React.useMemo(() => {
if (selectedProvider === null) return [];
// First try to resolve from the in-memory cache. We support both the
// enum/display-name form and the raw provider slug (e.g. "petals").
const cachedFields =

View file

@ -27,8 +27,13 @@ const ModelAliasManager: React.FC<ModelAliasManagerProps> = ({
showExampleConfig = true,
}) => {
const [aliases, setAliases] = useState<AliasItem[]>([]);
const [newAlias, setNewAlias] = useState({ aliasName: "", targetModel: "" });
const [editingAlias, setEditingAlias] = useState<AliasItem | null>(null);
const [newAlias, setNewAlias] = useState<{ aliasName: string; targetModel: string | null }>({
aliasName: "",
targetModel: null,
});
const [editingAlias, setEditingAlias] = useState<
(Omit<AliasItem, "targetModel"> & { targetModel: string | null }) | null
>(null);
const aliasNameId = useId();
useEffect(() => {
@ -61,7 +66,7 @@ const ModelAliasManager: React.FC<ModelAliasManagerProps> = ({
const updatedAliases = [...aliases, newAliasObj];
setAliases(updatedAliases);
setNewAlias({ aliasName: "", targetModel: "" });
setNewAlias({ aliasName: "", targetModel: null });
// Convert array back to object format and notify parent
const aliasObject: { [key: string]: string } = {};
@ -94,7 +99,8 @@ const ModelAliasManager: React.FC<ModelAliasManagerProps> = ({
return;
}
const updatedAliases = aliases.map((alias) => (alias.id === editingAlias.id ? editingAlias : alias));
const savedAlias: AliasItem = { ...editingAlias, targetModel: editingAlias.targetModel };
const updatedAliases = aliases.map((alias) => (alias.id === savedAlias.id ? savedAlias : alias));
setAliases(updatedAliases);
setEditingAlias(null);

View file

@ -9,9 +9,9 @@ const MODEL_SELECT_DEBOUNCE_MS = 500;
interface ModelSelectorProps {
accessToken: string;
value?: string;
value?: string | null;
placeholder?: string;
onChange?: (value: string) => void;
onChange?: (value: string | null) => void;
disabled?: boolean;
style?: React.CSSProperties;
className?: string;
@ -30,12 +30,12 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({
showLabel = true,
labelText = "Select Model",
}) => {
const [selectedModel, setSelectedModel] = useState<string | undefined>(value);
const [selectedModel, setSelectedModel] = useState<string | null>(value ?? null);
const [showCustomModelInput, setShowCustomModelInput] = useState<boolean>(false);
const [modelInfo, setModelInfo] = useState<ModelGroup[]>([]);
useEffect(() => {
setSelectedModel(value);
setSelectedModel(value ?? null);
}, [value]);
useEffect(() => {
@ -56,13 +56,13 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({
loadModels();
}, [accessToken]);
const onModelChange = (value: string) => {
const onModelChange = (value: string | null) => {
if (value === "custom") {
setShowCustomModelInput(true);
setSelectedModel(undefined);
setSelectedModel(null);
} else {
setShowCustomModelInput(false);
setSelectedModel(value);
setSelectedModel(value ?? null);
if (onChange) {
onChange(value);
}
@ -71,7 +71,7 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({
const debouncedSelect = useDebouncedCallback(
(value: string) => {
setSelectedModel(value);
setSelectedModel(value ?? null);
onChange?.(value);
},
{ wait: MODEL_SELECT_DEBOUNCE_MS },

View file

@ -4,7 +4,7 @@ import { Organization } from "../networking";
interface OrganizationDropdownProps {
organizations?: Organization[] | null;
value?: string;
value?: string | null;
onChange?: (value: string | null) => void;
disabled?: boolean;
loading?: boolean;
@ -32,7 +32,7 @@ const OrganizationDropdown: React.FC<OrganizationDropdownProps> = ({
sublabel: org.organization_id,
}))}
value={value}
onValueChange={(organizationId) => onChange?.(organizationId || null)}
onValueChange={(organizationId) => onChange?.(organizationId)}
placeholder={placeholder}
emptyText={loading ? "Loading organizations…" : "No organizations found"}
disabled={disabled}

View file

@ -4,8 +4,8 @@ import { ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects";
interface ProjectDropdownProps {
projects?: ProjectResponse[] | null;
value?: string;
onChange?: (value: string) => void;
value?: string | null;
onChange?: (value: string | null) => void;
disabled?: boolean;
loading?: boolean;
/** When set, only show projects belonging to this team */

View file

@ -47,8 +47,8 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ value, onChange, disabled,
<div data-testid="user-dropdown">
<PaginatedSearchSelect
options={options}
value={value ?? undefined}
onValueChange={(next) => onChange(next === "" ? null : next)}
value={value}
onValueChange={onChange}
onSearchChange={setSearch}
onLoadMore={fetchNextPage}
hasNextPage={hasNextPage}

View file

@ -4,7 +4,7 @@ import { useInfiniteTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { Team } from "../key_team_helpers/key_list";
interface TeamDropdownProps {
value?: string;
value?: string | null;
onChange?: (value: string | null) => void;
/** Callback with the full Team object (or null on clear). */
onTeamSelect?: (team: Team | null) => void;
@ -46,8 +46,8 @@ const TeamDropdown: React.FC<TeamDropdownProps> = ({
return result;
}, [data]);
const handleChange = (teamId: string) => {
onChange?.(teamId || null);
const handleChange = (teamId: string | null) => {
onChange?.(teamId);
if (onTeamSelect) {
onTeamSelect(teamId ? teams.find((t) => t.team_id === teamId) ?? null : null);
}
@ -61,7 +61,7 @@ const TeamDropdown: React.FC<TeamDropdownProps> = ({
value: team.team_id,
sublabel: team.team_id,
}))}
value={value || undefined}
value={value}
onValueChange={handleChange}
onSearchChange={setSearch}
onLoadMore={fetchNextPage}

View file

@ -1,4 +1,5 @@
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
import { renderWithProviders as render } from "../../../tests/test-utils";
import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import UserSearchModal from "./user_search_modal";
@ -101,15 +102,18 @@ describe("UserSearchModal submit payload", () => {
await user.click(await screen.findByRole("option", { name: "picked@example.com" }));
};
it("submits every registered field, with the untouched identity fields undefined", async () => {
it("should block submission without a selected user and after clearing the paired identity", async () => {
const { user, onSubmit } = setup();
expect(save()).toBeDisabled();
await searchByEmail(user, "pick");
await user.click(screen.getAllByRole("button", { name: "Clear" })[0]);
expect(screen.getByLabelText("Email")).toHaveValue("");
expect(screen.getByLabelText("User ID")).toHaveValue("");
expect(save()).toBeDisabled();
await user.click(save());
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
const values = onSubmit.mock.calls[0][0];
expect(Object.keys(values).sort()).toEqual(["role", "user_email", "user_id"]);
expect(values).toStrictEqual({ user_email: undefined, user_id: undefined, role: "user" });
expect(onSubmit).not.toHaveBeenCalled();
});
it("carries the picked user's email and id into the payload", async () => {
@ -130,6 +134,7 @@ describe("UserSearchModal submit payload", () => {
const { onSubmit } = setup();
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
await searchByEmail(user, "pick");
await user.click(screen.getByLabelText("Member Role"));
await user.click(await screen.findByRole("option", { name: /^admin/ }));
await user.click(save());
@ -184,6 +189,7 @@ describe("UserSearchModal submit payload", () => {
it("does not submit on Enter in any field, while the button still does", async () => {
const { user, onSubmit } = setup();
await searchByEmail(user, "pick");
await user.click(getEmailSearchInput());
await user.keyboard("{Enter}");
await user.click(screen.getByLabelText("User ID"));

View file

@ -31,8 +31,8 @@ interface Role {
}
interface FormValues {
user_email: string | undefined;
user_id: string | undefined;
user_email: string | null | undefined;
user_id: string | null | undefined;
role: string;
}
@ -66,6 +66,8 @@ const UserSearchModal: React.FC<UserSearchModalProps> = ({
}) => {
const emptyValues: FormValues = { user_email: undefined, user_id: undefined, role: defaultRole };
const form = useForm<FormValues>({ defaultValues: emptyValues });
const selectedUserId = form.watch("user_id");
const selectedUserEmail = form.watch("user_email");
const [userOptions, setUserOptions] = useState<UserOption[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [selectedField, setSelectedField] = useState<"user_email" | "user_id">("user_email");
@ -143,19 +145,29 @@ const UserSearchModal: React.FC<UserSearchModalProps> = ({
const renderUserSearch = (
fieldName: "user_email" | "user_id",
placeholder: string,
controlProps: { id: string; value: string | undefined; onChange: (value: string | undefined) => void },
controlProps: {
id: string;
value: string | null | undefined;
onChange: (value: string | null | undefined) => void;
},
testId?: string,
) => {
const items = selectedField === fieldName ? userOptions : [];
const handleValueChange = (value: string | null) => {
if (value === null) {
form.setValue("user_email", null);
form.setValue("user_id", null);
return;
}
controlProps.onChange(value);
handleSelect(items.find((option) => option.value === value) ?? null);
};
return (
<div data-testid={testId} onKeyDown={swallowEnter}>
<PaginatedSearchSelect
options={items}
value={controlProps.value}
onValueChange={(value: string) => {
controlProps.onChange(value === "" ? undefined : value);
handleSelect(items.find((option) => option.value === value) ?? null);
}}
onValueChange={handleValueChange}
onSearchChange={(query: string) => handleSearch(query, fieldName)}
autoHighlight="always"
isLoading={loading}
@ -226,7 +238,7 @@ const UserSearchModal: React.FC<UserSearchModalProps> = ({
</FieldGroup>
<div className="mt-4 text-right">
<Button type="submit" disabled={isSubmitting}>
<Button type="submit" disabled={isSubmitting || (!selectedUserId && !selectedUserEmail)}>
{isSubmitting ? <UiLoadingSpinner className="size-4" /> : <UserPlus />}
{isSubmitting ? "Adding..." : "Add Member"}
</Button>

View file

@ -13,7 +13,7 @@ import AccessGroupTagsCombobox from "../add_model/AccessGroupTagsCombobox";
import ModelChoiceCombobox, { type ModelChoice } from "../add_model/ModelChoiceCombobox";
import { modelAvailableCall, modelPatchUpdateCall, validateAutoRouterConfig } from "../networking";
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
import RouterConfigBuilder from "../add_model/RouterConfigBuilder";
import RouterConfigBuilder, { type RouterConfig, serializeRouterConfig } from "../add_model/RouterConfigBuilder";
import { hydrateTierModelParams } from "../add_model/complexity_router_tiers";
import {
type ActiveTierSet,
@ -452,7 +452,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
const [modelInfo, setModelInfo] = useState<ModelGroup[]>([]);
const [showValidationErrors, setShowValidationErrors] = useState<boolean>(false);
const [editingTiers, setEditingTiers] = useState(false);
const [routerConfig, setRouterConfig] = useState<any>(null);
const [routerConfig, setRouterConfig] = useState<RouterConfig | null>(null);
const [customTechnicalKeywords, setCustomTechnicalKeywords] = useState<string[]>([]);
const [keywordTierRules, setKeywordTierRules] = useState<KeywordTierRule[]>([]);
const [escalationKeywords, setEscalationKeywords] = useState<string[]>([]);
@ -706,7 +706,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
// Prepare the updated litellm_params
const updatedLitellmParams = {
...modelData.litellm_params,
auto_router_config: JSON.stringify(routerConfig),
auto_router_config: serializeRouterConfig(routerConfig),
auto_router_default_model: values.auto_router_default_model,
auto_router_embedding_model: values.auto_router_embedding_model || undefined,
};
@ -745,7 +745,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
})();
} catch (error) {
console.error("Error updating auto router:", error);
toast.fromError("Failed to update auto router configuration");
toast.fromError(error);
} finally {
setLoading(false);
}

View file

@ -96,10 +96,10 @@ export function BudgetFallbacksEditor({ value, onChange, availableModels }: Budg
<label className="block text-xs font-medium text-muted-foreground mb-1">Primary Model</label>
<SearchSelect
options={availablePrimaryOptions.map((m) => ({ label: m, value: m }))}
value={entry.primaryModel ?? ""}
value={entry.primaryModel}
onValueChange={(v) => {
const newFallbacks = entry.fallbackModels.filter((m) => m !== v);
updateEntry(entry.id, { primaryModel: v === "" ? null : v, fallbackModels: newFallbacks });
updateEntry(entry.id, { primaryModel: v, fallbackModels: newFallbacks });
}}
placeholder="Select model"
emptyText="No models found"

View file

@ -153,8 +153,8 @@ export function ModelMaxBudgetEditor({
<label className="block text-xs font-medium text-muted-foreground mb-1">Model</label>
<SearchSelect
options={modelOptions.map((model) => ({ label: model, value: model }))}
value={entry.model ?? ""}
onValueChange={(model) => updateEntry(entry.id, { model: model === "" ? null : model })}
value={entry.model}
onValueChange={(model) => updateEntry(entry.id, { model })}
placeholder="Select model"
emptyText="No models found"
disabled={!premiumUser}

View file

@ -42,7 +42,7 @@ export default function CredentialModal({
existingCredential = null,
}: CredentialModalProps) {
const isEdit = mode === "edit";
const [selectedProvider, setSelectedProvider] = useState<Providers>(
const [selectedProvider, setSelectedProvider] = useState<string | null>(
(existingCredential?.credential_info.custom_llm_provider as Providers) ?? Providers.OpenAI,
);
@ -110,7 +110,7 @@ export default function CredentialModal({
{(control) => (
<Input
id={control.id}
value={(control.value as string | undefined) ?? ""}
value={typeof control.value === "string" ? control.value : ""}
onChange={control.onChange}
onBlur={control.onBlur}
placeholder="Enter a friendly name for these credentials"
@ -131,10 +131,10 @@ export default function CredentialModal({
inputId={control.id}
placeholder="Select a provider"
options={providerOptions}
value={(control.value as string | undefined) ?? ""}
value={typeof control.value === "string" ? control.value : null}
onValueChange={(value) => {
control.onChange(value);
resetCredentialFormOnProviderChange(formAdapter, value as Providers, setSelectedProvider);
resetCredentialFormOnProviderChange(formAdapter, value, setSelectedProvider);
}}
/>
)}

View file

@ -1,5 +1,3 @@
import { Providers } from "../provider_info_helpers";
interface CredentialFormAdapter {
getFieldValue: (field: string) => unknown;
resetFields: () => void;
@ -25,8 +23,8 @@ interface CredentialFormAdapter {
*/
export function resetCredentialFormOnProviderChange(
form: CredentialFormAdapter,
newProvider: Providers,
setSelectedProvider: (p: Providers) => void,
newProvider: string | null,
setSelectedProvider: (p: string | null) => void,
): void {
const preservedName = form.getFieldValue("credential_name");
form.resetFields();

View file

@ -202,6 +202,8 @@ export const buildKeyCreatePayload = (input: KeyCreateInput): KeyPayloadResult =
endpoint: input.keyOwner === "service_account" ? "service_account" : "standard",
payload: {
...withoutKeys(values, dropped),
...(values.organization_id === null && { organization_id: undefined }),
...(values.project_id === null && { project_id: undefined }),
...(input.keyOwner === "you" && { user_id: input.userID }),
...(input.keyOwner === "agent" && { agent_id: input.selectedAgentId }),
...(input.autoRotationEnabled && { auto_rotate: true, rotation_interval: input.rotationInterval }),

View file

@ -16,7 +16,7 @@ const state = vi.hoisted(() => ({
can: {} as Record<string, boolean>,
uiSettings: {} as Record<string, unknown>,
tags: {} as Record<string, { name: string }>,
teams: [] as { team_id: string; team_alias: string; models: string[] }[],
teams: [] as { team_id: string; team_alias: string; models: string[]; organization_id?: string }[],
organizations: [] as { organization_id: string; organization_alias: string }[],
accessGroups: [] as { access_group_id: string; access_group_name: string }[],
projects: [] as { project_id: string; project_alias: string; team_id?: string; models?: string[] }[],
@ -806,6 +806,36 @@ describe("CreateKey", () => {
expect((await createdPayload()).organization_id).toBe("org-1");
});
it("discards the old project and team when the organization changes", async () => {
state.uiSettings = { enable_projects_ui: true };
state.organizations = [
{ organization_id: "scope-silver", organization_alias: "Silver" },
{ organization_id: "scope-copper", organization_alias: "Copper" },
];
state.teams = [{ team_id: "group-maple", team_alias: "Maple", organization_id: "scope-silver", models: [] }];
state.projects = [{ project_id: "project-orbit", project_alias: "Orbit", team_id: "group-maple", models: [] }];
await openModal({ teams: state.teams as Team[] });
await nameTheKey();
await userEvent.click(await screen.findByLabelText("Organization"));
await userEvent.click(await screen.findByRole("option", { name: /Silver/ }));
await userEvent.click(await screen.findByLabelText("Project"));
await userEvent.click(await screen.findByRole("option", { name: /Orbit/ }));
await waitFor(() => expect(screen.getByLabelText("Team")).toHaveValue("Maple"));
expect(screen.getByLabelText("Team")).toBeDisabled();
await userEvent.click(screen.getByLabelText("Organization"));
await userEvent.click(await screen.findByRole("option", { name: /Copper/ }));
expect(screen.getByLabelText("Project")).toHaveValue("");
expect(screen.getByLabelText("Team")).toHaveValue("");
expect(screen.getByLabelText("Team")).toBeEnabled();
await submit();
const payload = JSON.parse(JSON.stringify(await createdPayload()));
expect(payload.organization_id).toBe("scope-copper");
expect(payload.team_id).toBeNull();
expect(payload).not.toHaveProperty("project_id");
});
it("drops organization_id when the chosen organization is cleared again", async () => {
state.organizations = [{ organization_id: "org-1", organization_alias: "Engineering" }];
await openModal();

View file

@ -592,35 +592,35 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
};
const changeOrganization = (write: FieldWrite) => (orgId: string | null) => {
write(orgId ?? undefined);
write(orgId);
setSelectedOrganizationId(orgId);
// Clear team and project when org changes
setSelectedCreateKeyTeam(null);
setSelectedProjectId(null);
form.setValue("team_id", undefined);
form.setValue("project_id", undefined);
form.setValue("team_id", null);
form.setValue("project_id", null);
};
const selectTeam = (team: Team | null) => {
setSelectedCreateKeyTeam(team);
setSelectedProjectId(null);
form.setValue("project_id", undefined);
form.setValue("project_id", null);
// Auto-populate org from team for non-admin users
if (team?.organization_id) {
setSelectedOrganizationId(team.organization_id);
form.setValue("organization_id", team.organization_id);
} else if (!team) {
setSelectedOrganizationId(null);
form.setValue("organization_id", undefined);
form.setValue("organization_id", null);
}
};
const changeProject = (write: FieldWrite) => (projectId: string) => {
const changeProject = (write: FieldWrite) => (projectId: string | null) => {
write(projectId);
if (!projectId) {
setSelectedProjectId(null);
setSelectedCreateKeyTeam(null);
form.setValue("team_id", undefined);
form.setValue("team_id", null);
return;
}
setSelectedProjectId(projectId);
@ -756,8 +756,8 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
inputId="create-key-agent"
placeholder="Select an agent"
emptyText="No agents found"
value={selectedAgentId ?? undefined}
onValueChange={(value) => setSelectedAgentId(value === "" ? null : value)}
value={selectedAgentId}
onValueChange={setSelectedAgentId}
options={agentsList.map((a) => ({
label: a.agent_name || a.agent_id,
value: a.agent_id,
@ -783,7 +783,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
{(control) => (
<OrganizationDropdown
id={control.id}
value={control.value as string | undefined}
value={typeof control.value === "string" ? control.value : null}
organizations={organizations}
loading={isOrganizationsLoading}
disabled={userRole !== "Admin"}
@ -809,7 +809,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
{(control) => (
<TeamDropdown
id={control.id}
value={control.value as string | undefined}
value={typeof control.value === "string" ? control.value : null}
onChange={control.onChange}
disabled={selectedProjectId !== null}
organizationId={selectedOrganizationId}
@ -833,7 +833,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
{(control) => (
<ProjectDropdown
id={control.id}
value={control.value as string | undefined}
value={typeof control.value === "string" ? control.value : null}
projects={projects}
teamId={selectedCreateKeyTeam?.team_id}
loading={isProjectsLoading || !teams}

View file

@ -453,7 +453,7 @@ export const getPlaceholder = (selectedProvider: string): string => {
return providerPlaceholderMap[resolvedProvider] ?? "gpt-3.5-turbo";
};
export const getProviderModels = (provider: Providers, modelMap: any): Array<string> => {
export const getProviderModels = (provider: string, modelMap: any): Array<string> => {
let providerKey = provider;
let custom_llm_provider = provider_map[providerKey];

View file

@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { fireEvent, renderWithProviders as render, screen, waitFor } from "../../../tests/test-utils";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { describe, expect, it, vi } from "vitest";
@ -51,7 +51,7 @@ describe("PaginatedSearchSelect", () => {
const onSearchChange = vi.fn();
function Controlled() {
const [value, setValue] = useState("");
const [value, setValue] = useState<string | null>(null);
return (
<PaginatedSearchSelect
options={OPTIONS}
@ -71,14 +71,33 @@ describe("PaginatedSearchSelect", () => {
expect(onSearchChange).not.toHaveBeenCalled();
});
it("still reports a cleared input so the unfiltered page comes back", async () => {
it("should keep a cleared selection empty after a late page arrives and reset the query", async () => {
const user = userEvent.setup();
const onSearchChange = vi.fn();
renderSelect({ onSearchChange, value: "alias-alpha" });
await user.click(document.querySelector('[data-slot="combobox-clear"]') as HTMLElement);
await waitFor(() => expect(onSearchChange).toHaveBeenCalledWith(""));
const onValueChange = vi.fn();
function Controlled({ options }: { options: SearchSelectOption[] }) {
const [value, setValue] = useState<string | null>("alias-alpha");
return (
<PaginatedSearchSelect
options={options}
value={value}
onSearchChange={onSearchChange}
onValueChange={(next) => {
setValue(next);
onValueChange(next);
}}
/>
);
}
const { rerender } = render(<Controlled options={OPTIONS} />);
await user.click(screen.getByRole("button", { name: "Clear" }));
expect(onValueChange).toHaveBeenLastCalledWith(null);
rerender(<Controlled options={OPTIONS.map((option) => ({ ...option }))} />);
await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith(""));
expect(screen.getByRole("combobox")).toHaveValue("");
expect(screen.queryByRole("button", { name: "Clear" })).not.toBeInTheDocument();
await chooseSelectOption(user, screen.getByRole("combobox"), "alias-beta");
expect(onValueChange).toHaveBeenLastCalledWith("alias-beta");
});
it("requests the next page once the list is scrolled near the bottom", async () => {
@ -147,7 +166,7 @@ describe("PaginatedSearchSelect", () => {
function ServerBacked() {
const [search, setSearch] = useState("");
const [value, setValue] = useState("alias-alpha");
const [value, setValue] = useState<string | null>("alias-alpha");
const freshlyBuiltOptions = OPTIONS.filter((option) => option.label.includes(search)).map((option) => ({
...option,
}));
@ -236,7 +255,7 @@ describe("PaginatedSearchSelect", () => {
function Refetching() {
const [options, setOptions] = useState<SearchSelectOption[]>([{ label: "Beta Team", value: "team-2" }]);
const [value, setValue] = useState("");
const [value, setValue] = useState<string | null>(null);
return (
<>
<PaginatedSearchSelect
@ -279,7 +298,7 @@ describe("PaginatedSearchSelect", () => {
function ServerBacked() {
const [search, setSearch] = useState("");
const [value, setValue] = useState("");
const [value, setValue] = useState<string | null>(null);
return (
<PaginatedSearchSelect
options={OPTIONS.filter((option) => option.label.includes(search))}

View file

@ -17,8 +17,8 @@ import { usePaginatedCombobox } from "./usePaginatedCombobox";
interface PaginatedSearchSelectProps {
options: SearchSelectOption[];
value?: string;
onValueChange: (value: string) => void;
value?: string | null;
onValueChange: (value: string | null) => void;
onSearchChange: (query: string) => void;
onLoadMore?: () => void;
hasNextPage?: boolean;
@ -86,7 +86,7 @@ export function PaginatedSearchSelect({
};
const selected = useMemo<SearchSelectOption | null>(() => {
if (value === undefined || value === "") return null;
if (value == null || value === "") return null;
return (
options.find((option) => option.value === value) ??
(pickedOption?.value === value ? pickedOption : { label: value, value })
@ -118,7 +118,7 @@ export function PaginatedSearchSelect({
inputValue={typedQuery ?? selected?.label ?? ""}
onValueChange={(item: SearchSelectOption | null) => {
setPickedOption(item);
onValueChange(item?.value ?? "");
onValueChange(item?.value ?? null);
}}
onInputValueChange={(next, eventDetails) => handleTypedInput(next, eventDetails.reason)}
onOpenChange={(nextOpen, eventDetails) => handleOpenChange(nextOpen, eventDetails.reason)}
@ -139,7 +139,7 @@ export function PaginatedSearchSelect({
onKeyDown={snapshotWholeSelection}
onPaste={snapshotWholeSelection}
placeholder={placeholder}
showClear={value !== undefined && value !== ""}
showClear={value != null && value !== ""}
className={`w-full ${className ?? ""}`}
/>
<ComboboxContent>

View file

@ -1,4 +1,5 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, renderWithProviders as render, screen } from "../../../tests/test-utils";
import { useState } from "react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
@ -36,11 +37,30 @@ describe("SearchSelect", () => {
expect(screen.getByRole("combobox")).toHaveValue("Growth");
});
it("shows a clear control only when a value is selected", () => {
const { rerender } = render(<SearchSelect options={OPTIONS} onValueChange={vi.fn()} />);
expect(document.querySelector('[data-slot="combobox-clear"]')).toBeNull();
rerender(<SearchSelect options={OPTIONS} value="team-1" onValueChange={vi.fn()} />);
expect(document.querySelector('[data-slot="combobox-clear"]')).not.toBeNull();
it("should clear to null and allow selecting again through the real control", async () => {
const onValueChange = vi.fn();
const user = userEvent.setup();
function Controlled() {
const [value, setValue] = useState<string | null>(null);
return (
<SearchSelect
options={OPTIONS}
value={value}
onValueChange={(next) => {
setValue(next);
onValueChange(next);
}}
/>
);
}
render(<Controlled />);
expect(screen.queryByRole("button", { name: "Clear" })).not.toBeInTheDocument();
await chooseSelectOption(user, screen.getByRole("combobox"), "Growth");
await user.click(screen.getByRole("button", { name: "Clear" }));
expect(onValueChange).toHaveBeenLastCalledWith(null);
expect(screen.getByRole("combobox")).toHaveValue("");
await chooseSelectOption(user, screen.getByRole("combobox"), "Data Team");
expect(onValueChange).toHaveBeenLastCalledWith("team-3");
});
it("filters the options client-side as you type", async () => {

View file

@ -21,8 +21,8 @@ export interface SearchSelectOption {
interface SearchSelectProps {
options: SearchSelectOption[];
value?: string;
onValueChange: (value: string) => void;
value?: string | null;
onValueChange: (value: string | null) => void;
placeholder?: string;
emptyText?: string;
disabled?: boolean;
@ -51,9 +51,7 @@ export function SearchSelect({
"aria-label": ariaLabel,
}: SearchSelectProps) {
const selected =
value === undefined || value === ""
? null
: options.find((option) => option.value === value) ?? { label: value, value };
value == null || value === "" ? null : options.find((option) => option.value === value) ?? { label: value, value };
const items =
selected !== null && !options.some((option) => option.value === selected.value) ? [selected, ...options] : options;
@ -61,7 +59,7 @@ export function SearchSelect({
<Combobox
items={items}
value={selected}
onValueChange={(item: SearchSelectOption | null) => onValueChange(item?.value ?? "")}
onValueChange={(item: SearchSelectOption | null) => onValueChange(item?.value ?? null)}
isItemEqualToValue={(a: SearchSelectOption, b: SearchSelectOption) => a.value === b.value}
itemToStringLabel={(item: SearchSelectOption) => item.label}
filter={matchesQuery}

View file

@ -333,7 +333,10 @@ const teamUpdateFieldsSchema = z.object({
modelLimits: z
.array(
z.object({
model: z.string().min(1, "Missing model"),
model: z
.string()
.nullable()
.refine((model) => Boolean(model), "Missing model"),
tpm: z.number().nullish(),
rpm: z.number().nullish(),
}),
@ -1879,7 +1882,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
<SearchSelect
inputId={id}
value={value ?? ""}
onValueChange={(next) => onChange(next === "" ? null : next)}
onValueChange={onChange}
options={userOrganizations.map((org) => ({
value: org.organization_id ?? "",
label: org.organization_alias || org.organization_id || "",

View file

@ -1483,11 +1483,11 @@ describe("KeyEditView", () => {
});
});
it("submits organization_id as null after the organization is cleared", async () => {
it("clears the organization and its dependent team in the update payload", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
renderWithProviders(
<KeyEditView
keyData={{ ...MOCK_KEY_DATA, organization_id: "org-1" }}
keyData={{ ...MOCK_KEY_DATA, organization_id: "org-1", team_id: "group-maple" }}
onCancel={() => {}}
onSubmit={onSubmit}
accessToken=""
@ -1504,9 +1504,33 @@ describe("KeyEditView", () => {
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ organization_id: null }));
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ organization_id: null, team_id: null }));
});
expect(JSON.parse(JSON.stringify(onSubmit.mock.calls[0][0]))).toHaveProperty("organization_id", null);
expect(JSON.parse(JSON.stringify(onSubmit.mock.calls[0][0]))).toMatchObject({
organization_id: null,
team_id: null,
});
});
it("keeps project key relationships locked and omits unsupported project updates", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
renderWithProviders(
<KeyEditView
keyData={{ ...MOCK_KEY_DATA, organization_id: "org-1", team_id: "group-maple", project_id: "project-orbit" }}
onCancel={() => {}}
onSubmit={onSubmit}
accessToken=""
userID=""
userRole="Admin"
premiumUser={false}
/>,
);
expect(await screen.findByRole("combobox", { name: "Organization" })).toBeDisabled();
expect(screen.getByRole("combobox", { name: "Team ID" })).toBeDisabled();
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(onSubmit.mock.calls[0][0]).toMatchObject({ organization_id: "org-1", team_id: "group-maple" });
expect(onSubmit.mock.calls[0][0]).not.toHaveProperty("project_id");
});
});

View file

@ -305,7 +305,7 @@ export function KeyEditView({
const handleOrganizationChange = (setField: (value: string | null) => void, orgId: string | null) => {
setField(orgId);
setSelectedOrganizationId(orgId);
form.setValue("team_id", undefined);
form.setValue("team_id", null);
};
const handleTeamChange = (setField: (value: string | null) => void, teamId: string | null) => {
@ -316,7 +316,7 @@ export function KeyEditView({
form.setValue("organization_id", selectedTeam.organization_id);
} else if (!teamId) {
setSelectedOrganizationId(null);
form.setValue("organization_id", undefined);
form.setValue("organization_id", null);
}
};
@ -769,14 +769,15 @@ export function KeyEditView({
"Organization",
"The organization this key belongs to. Selecting an organization filters the available teams.",
)}
description={hasProject ? "Organization is locked because this key belongs to a project" : undefined}
>
{({ value, onChange, id }) => (
<OrganizationDropdown
id={id}
value={(value as string | undefined) ?? undefined}
value={value}
organizations={organizations}
loading={isOrganizationsLoading}
disabled={userRole !== "Admin"}
disabled={userRole !== "Admin" || hasProject}
onChange={(orgId) => handleOrganizationChange(onChange, orgId)}
/>
)}
@ -786,15 +787,13 @@ export function KeyEditView({
control={form.control}
name="team_id"
label="Team ID"
description={
enableProjectsUI && hasProject ? "Team is locked because this key belongs to a project" : undefined
}
description={hasProject ? "Team is locked because this key belongs to a project" : undefined}
>
{({ value, onChange, id }) => (
<Select
value={(value as string | null) ?? null}
onValueChange={(teamId: string | null) => handleTeamChange(onChange, teamId)}
disabled={enableProjectsUI && hasProject}
disabled={hasProject}
items={Object.fromEntries(
(visibleTeams ?? []).map((t) => [t.team_id, `${t.team_alias} (${t.team_id})`]),
)}

View file

@ -68,7 +68,7 @@ function TeamFilterField({
<SearchSelect
options={options}
value={value}
onValueChange={(next) => onChange(emptyToUndefined(next))}
onValueChange={(next) => onChange(next ?? undefined)}
placeholder="Search or select a team"
emptyText="No teams found"
/>
@ -108,7 +108,7 @@ function KeyAliasFilterField({
<PaginatedSearchSelect
options={options}
value={value}
onValueChange={(next) => onChange(emptyToUndefined(next))}
onValueChange={(next) => onChange(next ?? undefined)}
onSearchChange={setSearch}
onLoadMore={() => void fetchNextPage()}
hasNextPage={hasNextPage}
@ -146,7 +146,7 @@ function ModelFilterField({ value, onChange }: { value: string; onChange: (value
<PaginatedSearchSelect
options={options}
value={value}
onValueChange={(next) => onChange(emptyToUndefined(next))}
onValueChange={(next) => onChange(next ?? undefined)}
onSearchChange={setSearch}
onLoadMore={() => void fetchNextPage()}
hasNextPage={hasNextPage}
@ -191,7 +191,7 @@ function UserIdFilterField({
<PaginatedSearchSelect
options={options}
value={value}
onValueChange={(next) => onChange(emptyToUndefined(next))}
onValueChange={(next) => onChange(next ?? undefined)}
onSearchChange={setSearch}
onLoadMore={() => void fetchNextPage()}
hasNextPage={hasNextPage}
@ -236,7 +236,7 @@ function EndUserFilterField({
<PaginatedSearchSelect
options={options}
value={value}
onValueChange={(next) => onChange(emptyToUndefined(next))}
onValueChange={(next) => onChange(next ?? undefined)}
onSearchChange={setSearch}
onLoadMore={() => void fetchNextPage()}
hasNextPage={hasNextPage}