mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
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:
parent
44ce8bb1ef
commit
cf97b757a4
78 changed files with 492 additions and 263 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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-.*)"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
||||
|
|
|
|||
|
|
@ -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", []);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export interface Tool {
|
|||
|
||||
export interface PromptType {
|
||||
name: string;
|
||||
model: string;
|
||||
model: string | null;
|
||||
config: {
|
||||
temperature?: number;
|
||||
max_tokens?: number;
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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", []);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ describe("RouterConfigBuilder", () => {
|
|||
expect(onChange).toHaveBeenCalledWith({
|
||||
routes: [
|
||||
expect.objectContaining({
|
||||
name: "",
|
||||
name: null,
|
||||
utterances: [],
|
||||
description: "",
|
||||
score_threshold: 0.5,
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
||||
|
|
|
|||
|
|
@ -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))}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 || "",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -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})`]),
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue