From cf97b757a4356b02baef6b88b5b216ed01dcb515 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Fri, 11 Sep 2026 18:37:08 -0700 Subject: [PATCH] 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. --- .../add_agent_form.integration.test.tsx | 33 ++++++++++++++- .../agents/_components/add_agent_form.tsx | 16 ++++---- .../_components/ShadowEvalStartForm.tsx | 17 ++++---- .../_components/pricing_calculator/index.tsx | 4 +- .../_components/pricing_calculator/types.ts | 4 +- .../_components/TeamGuardrailsTab.tsx | 5 ++- .../components/AllModelsTable.tsx | 4 +- .../panels/AddModelPanel.tsx | 6 ++- ...I.test.tsx => ChatUI.integration.test.tsx} | 16 ++++++++ .../playground/components/chat_ui/ChatUI.tsx | 30 +++++++++----- .../components/chat_ui/EndpointSelector.tsx | 4 +- .../components/chat_ui/SessionManagement.tsx | 2 +- .../compareUI/components/ModelSelector.tsx | 3 +- ...x => add_policy_form.integration.test.tsx} | 0 .../policies/_components/add_policy_form.tsx | 14 +++---- .../_components/ai_suggestion_modal.tsx | 6 +-- .../_components/pipeline_flow_builder.tsx | 2 +- .../_components/template_parameter_modal.tsx | 6 +-- .../ProjectModals/EditProjectModal.tsx | 6 +-- .../ProjectModals/ProjectBaseForm.tsx | 6 +-- .../ProjectModals/projectFormSchema.ts | 10 +++-- .../prompt_editor_view/ModelConfigCard.tsx | 4 +- .../prompt_editor_view/PromptEditorHeader.tsx | 4 +- .../_components/prompt_editor_view/types.ts | 2 +- .../prompt_editor_view/utils.test.ts | 16 ++++++++ .../_components/prompt_editor_view/utils.ts | 4 +- .../DefaultUserSettingsForm.tsx | 11 +++-- .../default-user-settings/mapper.test.ts | 8 ++-- .../default-user-settings/mapper.ts | 11 +++-- .../default-user-settings/schema.ts | 12 ++++-- .../_components/view_users/UsersTable.tsx | 4 +- .../src/components/CreateUserButton.tsx | 2 +- .../MCPSemanticFilterSettings.tsx | 2 +- .../MCPSemanticFilterTestPanel.tsx | 4 +- .../semanticFilterTestUtils.ts | 6 +-- .../toolSearchForm.test.ts | 1 + .../MCPToolSearchSettings/toolSearchForm.ts | 6 +-- .../Fallbacks/FallbackGroupConfig.tsx | 10 ++--- ui/litellm-dashboard/src/components/Teams.tsx | 4 +- .../src/components/TeamsPage/TeamsTable.tsx | 2 +- .../VirtualKeysPage/VirtualKeysTable.tsx | 4 +- ....tsx => AddModelForm.integration.test.tsx} | 2 +- .../src/components/add_model/AddModelForm.tsx | 15 ++++--- .../add_model/ClassificationMethodConfig.tsx | 3 +- .../add_model/ComplexityRouterConfig.tsx | 2 +- .../add_model/CompressionControls.tsx | 8 ++-- ... RouterConfigBuilder.integration.test.tsx} | 2 +- .../add_model/RouterConfigBuilder.test.ts | 10 +++++ .../add_model/RouterConfigBuilder.tsx | 23 +++++++---- .../add_model/SemanticKeywordMatching.tsx | 4 +- .../add_model/add_auto_router_tab.tsx | 19 +++++---- .../add_model/litellm_model_name.tsx | 10 ++--- .../add_model/provider_specific_fields.tsx | 3 +- .../common_components/ModelAliasManager.tsx | 14 +++++-- .../common_components/ModelSelector.tsx | 16 ++++---- .../OrganizationDropdown.tsx | 4 +- .../common_components/ProjectDropdown.tsx | 4 +- .../common_components/UserDropdown.tsx | 4 +- .../common_components/team_dropdown.tsx | 8 ++-- ...=> user_search_modal.integration.test.tsx} | 18 +++++--- .../common_components/user_search_modal.tsx | 28 +++++++++---- .../edit_auto_router_modal.tsx | 8 ++-- .../BudgetFallbacksEditor.tsx | 4 +- .../key_team_helpers/ModelMaxBudgetEditor.tsx | 4 +- .../components/model_add/CredentialModal.tsx | 8 ++-- .../model_add/credential_form_helpers.ts | 6 +-- .../components/organisms/createKeyPayload.ts | 2 + .../create_key_button.integration.test.tsx | 32 ++++++++++++++- .../organisms/create_key_button.tsx | 24 +++++------ .../src/components/provider_info_helpers.tsx | 2 +- ...aginatedSearchSelect.integration.test.tsx} | 41 ++++++++++++++----- .../shared/PaginatedSearchSelect.tsx | 10 ++--- ....tsx => SearchSelect.integration.test.tsx} | 32 ++++++++++++--- .../src/components/shared/SearchSelect.tsx | 10 ++--- .../src/components/team/TeamInfo.tsx | 7 +++- ...tsx => key_edit_view.integration.test.tsx} | 32 +++++++++++++-- .../components/templates/key_edit_view.tsx | 15 ++++--- .../view_logs/RequestLogsFilters.tsx | 10 ++--- 78 files changed, 492 insertions(+), 263 deletions(-) rename ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/{ChatUI.test.tsx => ChatUI.integration.test.tsx} (95%) rename ui/litellm-dashboard/src/app/(dashboard)/policies/_components/{add_policy_form.test.tsx => add_policy_form.integration.test.tsx} (100%) rename ui/litellm-dashboard/src/components/add_model/{AddModelForm.test.tsx => AddModelForm.integration.test.tsx} (99%) rename ui/litellm-dashboard/src/components/add_model/{RouterConfigBuilder.test.tsx => RouterConfigBuilder.integration.test.tsx} (99%) create mode 100644 ui/litellm-dashboard/src/components/add_model/RouterConfigBuilder.test.ts rename ui/litellm-dashboard/src/components/common_components/{user_search_modal.test.tsx => user_search_modal.integration.test.tsx} (94%) rename ui/litellm-dashboard/src/components/shared/{PaginatedSearchSelect.test.tsx => PaginatedSearchSelect.integration.test.tsx} (89%) rename ui/litellm-dashboard/src/components/shared/{SearchSelect.test.tsx => SearchSelect.integration.test.tsx} (76%) rename ui/litellm-dashboard/src/components/templates/{key_edit_view.test.tsx => key_edit_view.integration.test.tsx} (98%) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.integration.test.tsx index dad8599e967..ebc97891744 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.integration.test.tsx @@ -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); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx index 108bae977e1..e71fed40209 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx @@ -338,6 +338,11 @@ const AddAgentForm: React.FC = ({ 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 = ({ 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 = ({ visible, onClose, accessTok setSelectedExistingKey(value || null)} + value={selectedExistingKey} + onValueChange={setSelectedExistingKey} options={existingKeys.map((k) => ({ label: k.key_alias || k.token?.slice(0, 12) + "…", value: k.token, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalStartForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalStartForm.tsx index d85d26a21a8..06e332d2cfc 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalStartForm.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalStartForm.tsx @@ -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([]); const [routerNames, setRouterNames] = useState([]); const [direction, setDirection] = useState("forward"); - const [baselineModel, setBaselineModel] = useState(""); + const [baselineModel, setBaselineModel] = useState(null); const [percentage, setPercentage] = useState("10"); const [durationDays, setDurationDays] = useState("7"); - const [judgeModel, setJudgeModel] = useState(""); + const [judgeModel, setJudgeModel] = useState(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, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/index.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/index.tsx index f3bd74260ad..b0612412059 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/index.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/index.tsx @@ -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 = ({ 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); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/types.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/types.ts index 250857a74f5..859d2f3e8d9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/types.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/types.ts @@ -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; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.tsx index 1de4e697f64..d45cfc3fe7d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.tsx @@ -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"), diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.tsx index 5c7dbb18428..8482d0832c3 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.tsx @@ -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" diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.tsx index 1443c065d9b..1cdce04d07c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.tsx @@ -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.Anthropic); + const [selectedProvider, setSelectedProvider] = useState(Providers.Anthropic); const [providerModels, setProviderModels] = useState([]); 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} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.integration.test.tsx similarity index 95% rename from ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.integration.test.tsx index bf6b092a2b0..984996351df 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.integration.test.tsx @@ -153,6 +153,7 @@ describe("ChatUI", () => { }); it("should allow the user to select a model", async () => { + const user = userEvent.setup(); render( { 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 () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx index eca9e2323ae..ed8679cfdc1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx @@ -196,17 +196,17 @@ const ChatUI: React.FC = ({ () => sessionStorage.getItem("customProxyBaseUrl") || "", ); const [inputMessage, setInputMessage] = useState(""); - const [selectedModel, setSelectedModel] = useState(simplified ? fixedModel : undefined); + const [selectedModel, setSelectedModel] = useState(simplified ? fixedModel : null); const [showCustomModelInput, setShowCustomModelInput] = useState(false); const [modelInfo, setModelInfo] = useState([]); const [isLoadingModels, setIsLoadingModels] = useState(false); const [modelLoadError, setModelLoadError] = useState(false); const [agentInfo, setAgentInfo] = useState([]); - const [selectedAgent, setSelectedAgent] = useState(undefined); + const [selectedAgent, setSelectedAgent] = useState(null); const debouncedSetSelectedModel = useDebouncedCallback((value: string) => setSelectedModel(value), { wait: CUSTOM_MODEL_DEBOUNCE_WAIT_MS, }); - const [endpointType, setEndpointType] = useState( + const [endpointType, setEndpointType] = useState( () => sessionStorage.getItem("endpointType") || EndpointType.CHAT, ); const [isLoading, setIsLoading] = useState(false); @@ -327,7 +327,7 @@ const ChatUI: React.FC = ({ }; useEffect(() => { - if (isGetCodeModalVisible) { + if (isGetCodeModalVisible && endpointType !== null) { const code = generateCodeSnippet({ apiKeySource, accessToken, @@ -342,7 +342,7 @@ const ChatUI: React.FC = ({ mcpServers, mcpServerToolRestrictions, endpointType, - selectedModel, + selectedModel: selectedModel ?? undefined, selectedSdk, selectedVoice, proxySettings, @@ -376,7 +376,8 @@ const ChatUI: React.FC = ({ } 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 = ({ 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 = ({ 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 = ({ }; 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 = ({ 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 = ({ : "Describe the image you want to generate..."; const sendDisabled = + endpointType === null || isLoading || (endpointType === EndpointType.MCP ? !(selectedMCPServers.length === 1 && selectedMCPServers[0] !== "__all__" && selectedMCPDirectTool) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/EndpointSelector.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/EndpointSelector.tsx index 644bab5b5a4..6b286c403a8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/EndpointSelector.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/EndpointSelector.tsx @@ -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; } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/SessionManagement.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/SessionManagement.tsx index 35c8e839159..4dbf9168897 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/SessionManagement.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/SessionManagement.tsx @@ -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; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/components/ModelSelector.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/components/ModelSelector.tsx index 5659875cf82..8ecc0e0c8bb 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/components/ModelSelector.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/components/ModelSelector.tsx @@ -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)) { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_policy_form.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_policy_form.integration.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_policy_form.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_policy_form.integration.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_policy_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_policy_form.tsx index 2830b0fda12..d14165dd849 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_policy_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_policy_form.tsx @@ -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; 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 = ({ {...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-.*)" /> diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/ai_suggestion_modal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/ai_suggestion_modal.tsx index 32e777949d0..c4f76b07a4e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/ai_suggestion_modal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/ai_suggestion_modal.tsx @@ -68,7 +68,7 @@ const AiSuggestionModal: React.FC = ({ const [suggestions, setSuggestions] = useState(null); const [explanation, setExplanation] = useState(null); const [selectedIds, setSelectedIds] = useState>(new Set()); - const [selectedModel, setSelectedModel] = useState(undefined); + const [selectedModel, setSelectedModel] = useState(null); const [availableModels, setAvailableModels] = useState([]); const [isLoadingModels, setIsLoadingModels] = useState(false); // Test panel state @@ -114,7 +114,7 @@ const AiSuggestionModal: React.FC = ({ setSuggestions(null); setExplanation(null); setSelectedIds(new Set()); - setSelectedModel(undefined); + setSelectedModel(null); setShowTestPanel(false); setTestInputText(""); setIsTestLoading(false); @@ -837,7 +837,7 @@ const AiSuggestionModal: React.FC = ({ ({ 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} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/pipeline_flow_builder.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/pipeline_flow_builder.tsx index 8651be39f0d..da77b348028 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/pipeline_flow_builder.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/pipeline_flow_builder.tsx @@ -343,7 +343,7 @@ const StepCard: React.FC = ({ onChange({ guardrail: value })} + onValueChange={(value) => onChange({ guardrail: value ?? undefined })} placeholder="Select a guardrail" emptyText="No guardrails found" /> diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/template_parameter_modal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/template_parameter_modal.tsx index 410d4ea8467..b90379b149d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/template_parameter_modal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/template_parameter_modal.tsx @@ -46,7 +46,7 @@ const TemplateParameterModal: React.FC = ({ }) => { const [parameterValues, setParameterValues] = useState>({}); const [competitorMode, setCompetitorMode] = useState<"ai" | "manual">("ai"); - const [selectedModel, setSelectedModel] = useState(undefined); + const [selectedModel, setSelectedModel] = useState(null); const [availableModels, setAvailableModels] = useState([]); const [isLoadingModels, setIsLoadingModels] = useState(false); const [competitorTags, setCompetitorTags] = useState([]); @@ -72,7 +72,7 @@ const TemplateParameterModal: React.FC = ({ }); setParameterValues(initial); setCompetitorMode("ai"); - setSelectedModel(undefined); + setSelectedModel(null); setCompetitorTags([]); setVariationsMap({}); setIsGenerating(false); @@ -297,7 +297,7 @@ const TemplateParameterModal: React.FC = ({ ({ 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} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/EditProjectModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/EditProjectModal.tsx index 5c1a443d6c5..77f28b05ea5 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/EditProjectModal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/EditProjectModal.tsx @@ -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 { - const submitted: ProjectFormValues = advancedEverOpened + const submitted: ProjectSubmitValues = advancedEverOpened ? values : { ...values, guardrails: undefined, modelLimits: undefined, metadata: undefined }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.tsx index fed1a88fe98..1ad8a1e4953 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.tsx @@ -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; + form: UseFormReturn; 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", []); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/projectFormSchema.ts b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/projectFormSchema.ts index 6bfaa831bba..d4c85d89616 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/projectFormSchema.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/projectFormSchema.ts @@ -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; +export type ProjectFormValues = z.input; +export type ProjectSubmitValues = z.output; export const emptyProjectFormValues: ProjectFormValues = { project_alias: "", - team_id: "", + team_id: null, description: undefined, models: [], max_budget: undefined, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/ModelConfigCard.tsx b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/ModelConfigCard.tsx index a14e45de99a..e9b526c9703 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/ModelConfigCard.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/ModelConfigCard.tsx @@ -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; } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/PromptEditorHeader.tsx b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/PromptEditorHeader.tsx index 04cac01365a..0f001979c28 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/PromptEditorHeader.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/PromptEditorHeader.tsx @@ -21,7 +21,7 @@ interface PromptEditorHeaderProps { editMode?: boolean; onShowHistory?: () => void; version?: string | null; - promptModel?: string; + promptModel?: string | null; promptVariables?: Record; accessToken: string | null; proxySettings?: { @@ -85,7 +85,7 @@ const PromptEditorHeader: React.FC = ({
{ 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: { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/utils.ts b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/utils.ts index ef327d93495..3d768e930a9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/utils.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/utils.ts @@ -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, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/default-user-settings/DefaultUserSettingsForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/default-user-settings/DefaultUserSettingsForm.tsx index 269f3b0af39..0239844851f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/default-user-settings/DefaultUserSettingsForm.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/default-user-settings/DefaultUserSettingsForm.tsx @@ -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; +type SettingsControl = Control; 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 }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/default-user-settings/mapper.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/default-user-settings/mapper.test.ts index e8b350332c8..f4a7f001480 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/default-user-settings/mapper.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/default-user-settings/mapper.test.ts @@ -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 => ({ +const formValues = (overrides: Partial = {}): DefaultUserSettingsSubmitValues => ({ user_role: "internal_user", max_budget: "100", budget_duration: "30d", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/default-user-settings/mapper.ts b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/default-user-settings/mapper.ts index 50365081afb..ac528542ae2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/default-user-settings/mapper.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/default-user-settings/mapper.ts @@ -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 = (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), diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/default-user-settings/schema.ts b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/default-user-settings/schema.ts index 7309e3745da..cd66c3addc8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/default-user-settings/schema.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/default-user-settings/schema.ts @@ -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; +export type DefaultTeamRowValues = z.input; -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; +export type DefaultUserSettingsFormValues = z.input; +export type DefaultUserSettingsSubmitValues = z.output; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.tsx index 875df74652c..8fa76a64af6 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.tsx @@ -192,7 +192,7 @@ export function UsersTable({ 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({ set("team", value)} + onValueChange={(value) => set("team", value ?? undefined)} placeholder="Select a team…" emptyText="No teams found" /> diff --git a/ui/litellm-dashboard/src/components/CreateUserButton.tsx b/ui/litellm-dashboard/src/components/CreateUserButton.tsx index 0f7c356b8cc..83a72e50f5c 100644 --- a/ui/litellm-dashboard/src/components/CreateUserButton.tsx +++ b/ui/litellm-dashboard/src/components/CreateUserButton.tsx @@ -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; diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterSettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterSettings.tsx index 390684d3739..f78790dea88 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterSettings.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterSettings.tsx @@ -115,7 +115,7 @@ export default function MCPSemanticFilterSettings({ accessToken }: MCPSemanticFi // Test section state const [testQuery, setTestQuery] = useState(""); - const [testModel, setTestModel] = useState("gpt-4o"); + const [testModel, setTestModel] = useState("gpt-4o"); const [testResult, setTestResult] = useState(null); const [testError, setTestError] = useState(null); const [isTesting, setIsTesting] = useState(false); diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.tsx index 5dc661391ac..82c699d9983 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.tsx @@ -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; diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/semanticFilterTestUtils.ts b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/semanticFilterTestUtils.ts index d3e585e8052..c41b081da88 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/semanticFilterTestUtils.ts +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/semanticFilterTestUtils.ts @@ -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", diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPToolSearchSettings/toolSearchForm.test.ts b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPToolSearchSettings/toolSearchForm.test.ts index 2f9032537c1..6d19c17c577 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPToolSearchSettings/toolSearchForm.test.ts +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPToolSearchSettings/toolSearchForm.test.ts @@ -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", () => { diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPToolSearchSettings/toolSearchForm.ts b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPToolSearchSettings/toolSearchForm.ts index f10bfd249d7..d3535fe91a3 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPToolSearchSettings/toolSearchForm.ts +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPToolSearchSettings/toolSearchForm.ts @@ -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): 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), diff --git a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/FallbackGroupConfig.tsx b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/FallbackGroupConfig.tsx index a82e0fdfa88..3897a9d9fa2 100644 --- a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/FallbackGroupConfig.tsx +++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/FallbackGroupConfig.tsx @@ -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({ ({ label: m, value: m }))} - value={group.primaryModel ?? ""} + value={group.primaryModel} onValueChange={handlePrimaryChange} placeholder="Select primary model" emptyText="No models found" diff --git a/ui/litellm-dashboard/src/components/Teams.tsx b/ui/litellm-dashboard/src/components/Teams.tsx index d2d18139893..f0d21cc5350 100644 --- a/ui/litellm-dashboard/src/components/Teams.tsx +++ b/ui/litellm-dashboard/src/components/Teams.tsx @@ -328,11 +328,11 @@ const Teams: React.FC = ({ 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", []); diff --git a/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.tsx b/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.tsx index 3fb19e522f3..bde1c7724c3 100644 --- a/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.tsx +++ b/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.tsx @@ -203,7 +203,7 @@ export function TeamsTable({ userRole, userID, onSelectTeam, onEditTeam, onDelet set("org_id", value)} + onValueChange={(value) => set("org_id", value ?? undefined)} placeholder="Select an organization…" emptyText="No organizations found" /> diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx index f28ae27b6bc..d0367cb4d5b 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx @@ -311,7 +311,7 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) { 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) { set("org_id", value)} + onValueChange={(value) => set("org_id", value ?? undefined)} placeholder="Select an organization…" emptyText="No organizations found" /> diff --git a/ui/litellm-dashboard/src/components/add_model/AddModelForm.test.tsx b/ui/litellm-dashboard/src/components/add_model/AddModelForm.integration.test.tsx similarity index 99% rename from ui/litellm-dashboard/src/components/add_model/AddModelForm.test.tsx rename to ui/litellm-dashboard/src/components/add_model/AddModelForm.integration.test.tsx index 3b879da2d68..536bc231008 100644 --- a/ui/litellm-dashboard/src/components/add_model/AddModelForm.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/AddModelForm.integration.test.tsx @@ -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"], diff --git a/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx b/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx index 92951b68fd5..30b4911da3e 100644 --- a/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx +++ b/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx @@ -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; - 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 = ({ [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 = ({ 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); }} /> )} diff --git a/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx b/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx index 594c6d022ce..93ccf561387 100644 --- a/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx @@ -328,7 +328,8 @@ const ClassificationMethodConfig: React.FC = ({ 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: "", diff --git a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx index 97d6739aa22..febcde269f7 100644 --- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx @@ -632,7 +632,7 @@ const ComplexityRouterConfig: React.FC = ({ // 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 }); }; diff --git a/ui/litellm-dashboard/src/components/add_model/CompressionControls.tsx b/ui/litellm-dashboard/src/components/add_model/CompressionControls.tsx index c1817918f60..e67583febf3 100644 --- a/ui/litellm-dashboard/src/components/add_model/CompressionControls.tsx +++ b/ui/litellm-dashboard/src/components/add_model/CompressionControls.tsx @@ -42,8 +42,8 @@ const CompressionControls: React.FC = ({ value, onChan
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 = ({ value, onChan
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" diff --git a/ui/litellm-dashboard/src/components/add_model/RouterConfigBuilder.test.tsx b/ui/litellm-dashboard/src/components/add_model/RouterConfigBuilder.integration.test.tsx similarity index 99% rename from ui/litellm-dashboard/src/components/add_model/RouterConfigBuilder.test.tsx rename to ui/litellm-dashboard/src/components/add_model/RouterConfigBuilder.integration.test.tsx index ecd5abfa451..495fd207266 100644 --- a/ui/litellm-dashboard/src/components/add_model/RouterConfigBuilder.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/RouterConfigBuilder.integration.test.tsx @@ -47,7 +47,7 @@ describe("RouterConfigBuilder", () => { expect(onChange).toHaveBeenCalledWith({ routes: [ expect.objectContaining({ - name: "", + name: null, utterances: [], description: "", score_threshold: 0.5, diff --git a/ui/litellm-dashboard/src/components/add_model/RouterConfigBuilder.test.ts b/ui/litellm-dashboard/src/components/add_model/RouterConfigBuilder.test.ts new file mode 100644 index 00000000000..d681c911c46 --- /dev/null +++ b/ui/litellm-dashboard/src/components/add_model/RouterConfigBuilder.test.ts @@ -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); + }); +}); diff --git a/ui/litellm-dashboard/src/components/add_model/RouterConfigBuilder.tsx b/ui/litellm-dashboard/src/components/add_model/RouterConfigBuilder.tsx index 6dfb9f32091..9713d18872b 100644 --- a/ui/litellm-dashboard/src/components/add_model/RouterConfigBuilder.tsx +++ b/ui/litellm-dashboard/src/components/add_model/RouterConfigBuilder.tsx @@ -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 = ({ 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 = ({ 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); diff --git a/ui/litellm-dashboard/src/components/add_model/SemanticKeywordMatching.tsx b/ui/litellm-dashboard/src/components/add_model/SemanticKeywordMatching.tsx index 5684d32a319..0a5a9a5bfb6 100644 --- a/ui/litellm-dashboard/src/components/add_model/SemanticKeywordMatching.tsx +++ b/ui/litellm-dashboard/src/components/add_model/SemanticKeywordMatching.tsx @@ -61,7 +61,9 @@ const SemanticKeywordMatching: React.FC = ({ { + if (model !== null) onEmbeddingModelChange(model); + }} placeholder="Select an embedding model" emptyText="No embedding models found" aria-label="Embedding model" diff --git a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx index d7c4a671317..632f4427a82 100644 --- a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx +++ b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx @@ -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>; 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 = ({ const serverVerdict = await validateAutoRouterConfig( accessToken, complexityRouterConfigPayload as unknown as Record, - 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 = ({ "Select the team this auto router belongs to. Only keys for this team will be able to call it.", )} > - {({ id, value, onChange }) => ( - onChange(next ?? "")} /> - )} + {({ id, value, onChange }) => } )} @@ -776,7 +777,7 @@ const AddAutoRouterTab: React.FC = ({ config={buildComplexityRouterConfig(complexityRouterConfigParams)} defaultModel={resolveComplexityDefaultModel(complexityRouterConfig, complexityRouterConfig.default_model)} routerName={watchedName} - teamId={requiresTeamScope ? watchedTeamId : undefined} + teamId={requiresTeamScope ? watchedTeamId ?? undefined : undefined} /> )} diff --git a/ui/litellm-dashboard/src/components/add_model/litellm_model_name.tsx b/ui/litellm-dashboard/src/components/add_model/litellm_model_name.tsx index bfa2df8f54d..8cf4b077f39 100644 --- a/ui/litellm-dashboard/src/components/add_model/litellm_model_name.tsx +++ b/ui/litellm-dashboard/src/components/add_model/litellm_model_name.tsx @@ -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 = ({ @@ -123,7 +123,7 @@ const LiteLLMModelNameField: React.FC = ({ 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 = ({ 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 = ({ value={(control.value as string | undefined) ?? ""} onChange={control.onChange} onBlur={control.onBlur} - placeholder={getPlaceholder(selectedProvider)} + placeholder={selectedProvider === null ? "Select a provider first" : getPlaceholder(selectedProvider)} /> ) } diff --git a/ui/litellm-dashboard/src/components/add_model/provider_specific_fields.tsx b/ui/litellm-dashboard/src/components/add_model/provider_specific_fields.tsx index e1a47e9792d..4add7b918f7 100644 --- a/ui/litellm-dashboard/src/components/add_model/provider_specific_fields.tsx +++ b/ui/litellm-dashboard/src/components/add_model/provider_specific_fields.tsx @@ -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 = ({ 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 = diff --git a/ui/litellm-dashboard/src/components/common_components/ModelAliasManager.tsx b/ui/litellm-dashboard/src/components/common_components/ModelAliasManager.tsx index c6e24d3b470..384fa44cfde 100644 --- a/ui/litellm-dashboard/src/components/common_components/ModelAliasManager.tsx +++ b/ui/litellm-dashboard/src/components/common_components/ModelAliasManager.tsx @@ -27,8 +27,13 @@ const ModelAliasManager: React.FC = ({ showExampleConfig = true, }) => { const [aliases, setAliases] = useState([]); - const [newAlias, setNewAlias] = useState({ aliasName: "", targetModel: "" }); - const [editingAlias, setEditingAlias] = useState(null); + const [newAlias, setNewAlias] = useState<{ aliasName: string; targetModel: string | null }>({ + aliasName: "", + targetModel: null, + }); + const [editingAlias, setEditingAlias] = useState< + (Omit & { targetModel: string | null }) | null + >(null); const aliasNameId = useId(); useEffect(() => { @@ -61,7 +66,7 @@ const ModelAliasManager: React.FC = ({ 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 = ({ 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); diff --git a/ui/litellm-dashboard/src/components/common_components/ModelSelector.tsx b/ui/litellm-dashboard/src/components/common_components/ModelSelector.tsx index 6a6807de5c0..15b6b1644b6 100644 --- a/ui/litellm-dashboard/src/components/common_components/ModelSelector.tsx +++ b/ui/litellm-dashboard/src/components/common_components/ModelSelector.tsx @@ -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 = ({ showLabel = true, labelText = "Select Model", }) => { - const [selectedModel, setSelectedModel] = useState(value); + const [selectedModel, setSelectedModel] = useState(value ?? null); const [showCustomModelInput, setShowCustomModelInput] = useState(false); const [modelInfo, setModelInfo] = useState([]); useEffect(() => { - setSelectedModel(value); + setSelectedModel(value ?? null); }, [value]); useEffect(() => { @@ -56,13 +56,13 @@ const ModelSelector: React.FC = ({ 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 = ({ const debouncedSelect = useDebouncedCallback( (value: string) => { - setSelectedModel(value); + setSelectedModel(value ?? null); onChange?.(value); }, { wait: MODEL_SELECT_DEBOUNCE_MS }, diff --git a/ui/litellm-dashboard/src/components/common_components/OrganizationDropdown.tsx b/ui/litellm-dashboard/src/components/common_components/OrganizationDropdown.tsx index 663028b2d92..f2f1504fed5 100644 --- a/ui/litellm-dashboard/src/components/common_components/OrganizationDropdown.tsx +++ b/ui/litellm-dashboard/src/components/common_components/OrganizationDropdown.tsx @@ -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 = ({ 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} diff --git a/ui/litellm-dashboard/src/components/common_components/ProjectDropdown.tsx b/ui/litellm-dashboard/src/components/common_components/ProjectDropdown.tsx index 0b5a84f9589..f27e1a683dd 100644 --- a/ui/litellm-dashboard/src/components/common_components/ProjectDropdown.tsx +++ b/ui/litellm-dashboard/src/components/common_components/ProjectDropdown.tsx @@ -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 */ diff --git a/ui/litellm-dashboard/src/components/common_components/UserDropdown.tsx b/ui/litellm-dashboard/src/components/common_components/UserDropdown.tsx index eef1a8233bc..c80f654bbd1 100644 --- a/ui/litellm-dashboard/src/components/common_components/UserDropdown.tsx +++ b/ui/litellm-dashboard/src/components/common_components/UserDropdown.tsx @@ -47,8 +47,8 @@ const UserDropdown: React.FC = ({ value, onChange, disabled,
onChange(next === "" ? null : next)} + value={value} + onValueChange={onChange} onSearchChange={setSearch} onLoadMore={fetchNextPage} hasNextPage={hasNextPage} diff --git a/ui/litellm-dashboard/src/components/common_components/team_dropdown.tsx b/ui/litellm-dashboard/src/components/common_components/team_dropdown.tsx index 7d385c2a3f7..e10a8d1e562 100644 --- a/ui/litellm-dashboard/src/components/common_components/team_dropdown.tsx +++ b/ui/litellm-dashboard/src/components/common_components/team_dropdown.tsx @@ -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 = ({ 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 = ({ value: team.team_id, sublabel: team.team_id, }))} - value={value || undefined} + value={value} onValueChange={handleChange} onSearchChange={setSearch} onLoadMore={fetchNextPage} diff --git a/ui/litellm-dashboard/src/components/common_components/user_search_modal.test.tsx b/ui/litellm-dashboard/src/components/common_components/user_search_modal.integration.test.tsx similarity index 94% rename from ui/litellm-dashboard/src/components/common_components/user_search_modal.test.tsx rename to ui/litellm-dashboard/src/components/common_components/user_search_modal.integration.test.tsx index 7ca2b530235..5769d8c2f88 100644 --- a/ui/litellm-dashboard/src/components/common_components/user_search_modal.test.tsx +++ b/ui/litellm-dashboard/src/components/common_components/user_search_modal.integration.test.tsx @@ -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")); diff --git a/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx b/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx index 20c45cd0d2e..04ac412397f 100644 --- a/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx +++ b/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx @@ -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 = ({ }) => { const emptyValues: FormValues = { user_email: undefined, user_id: undefined, role: defaultRole }; const form = useForm({ defaultValues: emptyValues }); + const selectedUserId = form.watch("user_id"); + const selectedUserEmail = form.watch("user_email"); const [userOptions, setUserOptions] = useState([]); const [loading, setLoading] = useState(false); const [selectedField, setSelectedField] = useState<"user_email" | "user_id">("user_email"); @@ -143,19 +145,29 @@ const UserSearchModal: React.FC = ({ 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 (
{ - 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 = ({
- diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx index 8a62e86e842..86f18ee9b12 100644 --- a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx +++ b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx @@ -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 = ({ const [modelInfo, setModelInfo] = useState([]); const [showValidationErrors, setShowValidationErrors] = useState(false); const [editingTiers, setEditingTiers] = useState(false); - const [routerConfig, setRouterConfig] = useState(null); + const [routerConfig, setRouterConfig] = useState(null); const [customTechnicalKeywords, setCustomTechnicalKeywords] = useState([]); const [keywordTierRules, setKeywordTierRules] = useState([]); const [escalationKeywords, setEscalationKeywords] = useState([]); @@ -706,7 +706,7 @@ const EditAutoRouterModal: React.FC = ({ // 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 = ({ })(); } catch (error) { console.error("Error updating auto router:", error); - toast.fromError("Failed to update auto router configuration"); + toast.fromError(error); } finally { setLoading(false); } diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/BudgetFallbacksEditor.tsx b/ui/litellm-dashboard/src/components/key_team_helpers/BudgetFallbacksEditor.tsx index c0c12356c6f..dbc270fa62e 100644 --- a/ui/litellm-dashboard/src/components/key_team_helpers/BudgetFallbacksEditor.tsx +++ b/ui/litellm-dashboard/src/components/key_team_helpers/BudgetFallbacksEditor.tsx @@ -96,10 +96,10 @@ export function BudgetFallbacksEditor({ value, onChange, availableModels }: Budg ({ 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" diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/ModelMaxBudgetEditor.tsx b/ui/litellm-dashboard/src/components/key_team_helpers/ModelMaxBudgetEditor.tsx index ef5a3e15b52..0fab5555343 100644 --- a/ui/litellm-dashboard/src/components/key_team_helpers/ModelMaxBudgetEditor.tsx +++ b/ui/litellm-dashboard/src/components/key_team_helpers/ModelMaxBudgetEditor.tsx @@ -153,8 +153,8 @@ export function ModelMaxBudgetEditor({ ({ 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} diff --git a/ui/litellm-dashboard/src/components/model_add/CredentialModal.tsx b/ui/litellm-dashboard/src/components/model_add/CredentialModal.tsx index 8108cc1c20e..fdc02b4fc41 100644 --- a/ui/litellm-dashboard/src/components/model_add/CredentialModal.tsx +++ b/ui/litellm-dashboard/src/components/model_add/CredentialModal.tsx @@ -42,7 +42,7 @@ export default function CredentialModal({ existingCredential = null, }: CredentialModalProps) { const isEdit = mode === "edit"; - const [selectedProvider, setSelectedProvider] = useState( + const [selectedProvider, setSelectedProvider] = useState( (existingCredential?.credential_info.custom_llm_provider as Providers) ?? Providers.OpenAI, ); @@ -110,7 +110,7 @@ export default function CredentialModal({ {(control) => ( { control.onChange(value); - resetCredentialFormOnProviderChange(formAdapter, value as Providers, setSelectedProvider); + resetCredentialFormOnProviderChange(formAdapter, value, setSelectedProvider); }} /> )} diff --git a/ui/litellm-dashboard/src/components/model_add/credential_form_helpers.ts b/ui/litellm-dashboard/src/components/model_add/credential_form_helpers.ts index 7190c539c81..e7b1e861811 100644 --- a/ui/litellm-dashboard/src/components/model_add/credential_form_helpers.ts +++ b/ui/litellm-dashboard/src/components/model_add/credential_form_helpers.ts @@ -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(); diff --git a/ui/litellm-dashboard/src/components/organisms/createKeyPayload.ts b/ui/litellm-dashboard/src/components/organisms/createKeyPayload.ts index 37a973d5def..311e9357825 100644 --- a/ui/litellm-dashboard/src/components/organisms/createKeyPayload.ts +++ b/ui/litellm-dashboard/src/components/organisms/createKeyPayload.ts @@ -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 }), diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.integration.test.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.integration.test.tsx index 2bf39bf4cd7..0d5d9f5ec8d 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.integration.test.tsx @@ -16,7 +16,7 @@ const state = vi.hoisted(() => ({ can: {} as Record, uiSettings: {} as Record, tags: {} as Record, - 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(); diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx index 831cb0cf6a6..82580fd4667 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx @@ -592,35 +592,35 @@ const CreateKey: React.FC = ({ 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 = ({ 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 = ({ team, teams, data, addKey, autoOp {(control) => ( = ({ team, teams, data, addKey, autoOp {(control) => ( = ({ team, teams, data, addKey, autoOp {(control) => ( { return providerPlaceholderMap[resolvedProvider] ?? "gpt-3.5-turbo"; }; -export const getProviderModels = (provider: Providers, modelMap: any): Array => { +export const getProviderModels = (provider: string, modelMap: any): Array => { let providerKey = provider; let custom_llm_provider = provider_map[providerKey]; diff --git a/ui/litellm-dashboard/src/components/shared/PaginatedSearchSelect.test.tsx b/ui/litellm-dashboard/src/components/shared/PaginatedSearchSelect.integration.test.tsx similarity index 89% rename from ui/litellm-dashboard/src/components/shared/PaginatedSearchSelect.test.tsx rename to ui/litellm-dashboard/src/components/shared/PaginatedSearchSelect.integration.test.tsx index 04c8d3e9012..2b948ca8420 100644 --- a/ui/litellm-dashboard/src/components/shared/PaginatedSearchSelect.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/PaginatedSearchSelect.integration.test.tsx @@ -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(null); return ( { 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("alias-alpha"); + return ( + { + setValue(next); + onValueChange(next); + }} + /> + ); + } + const { rerender } = render(); + await user.click(screen.getByRole("button", { name: "Clear" })); + expect(onValueChange).toHaveBeenLastCalledWith(null); + rerender( ({ ...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("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([{ label: "Beta Team", value: "team-2" }]); - const [value, setValue] = useState(""); + const [value, setValue] = useState(null); return ( <> { function ServerBacked() { const [search, setSearch] = useState(""); - const [value, setValue] = useState(""); + const [value, setValue] = useState(null); return ( option.label.includes(search))} diff --git a/ui/litellm-dashboard/src/components/shared/PaginatedSearchSelect.tsx b/ui/litellm-dashboard/src/components/shared/PaginatedSearchSelect.tsx index 4b7ef7401c7..1314afdf2bf 100644 --- a/ui/litellm-dashboard/src/components/shared/PaginatedSearchSelect.tsx +++ b/ui/litellm-dashboard/src/components/shared/PaginatedSearchSelect.tsx @@ -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(() => { - 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 ?? ""}`} /> diff --git a/ui/litellm-dashboard/src/components/shared/SearchSelect.test.tsx b/ui/litellm-dashboard/src/components/shared/SearchSelect.integration.test.tsx similarity index 76% rename from ui/litellm-dashboard/src/components/shared/SearchSelect.test.tsx rename to ui/litellm-dashboard/src/components/shared/SearchSelect.integration.test.tsx index 62a510268dd..c981010dff9 100644 --- a/ui/litellm-dashboard/src/components/shared/SearchSelect.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/SearchSelect.integration.test.tsx @@ -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(); - expect(document.querySelector('[data-slot="combobox-clear"]')).toBeNull(); - rerender(); - 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(null); + return ( + { + setValue(next); + onValueChange(next); + }} + /> + ); + } + render(); + 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 () => { diff --git a/ui/litellm-dashboard/src/components/shared/SearchSelect.tsx b/ui/litellm-dashboard/src/components/shared/SearchSelect.tsx index 53a5371d72d..21d38e9458c 100644 --- a/ui/litellm-dashboard/src/components/shared/SearchSelect.tsx +++ b/ui/litellm-dashboard/src/components/shared/SearchSelect.tsx @@ -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({ 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} diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index 988e2e082aa..8e3a17c2622 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -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 = ({ onChange(next === "" ? null : next)} + onValueChange={onChange} options={userOrganizations.map((org) => ({ value: org.organization_id ?? "", label: org.organization_alias || org.organization_id || "", diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.integration.test.tsx similarity index 98% rename from ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx rename to ui/litellm-dashboard/src/components/templates/key_edit_view.integration.test.tsx index b2c2a381b42..7c2226f0369 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.integration.test.tsx @@ -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( {}} 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( + {}} + 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"); }); }); diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx index e464dfe5008..f0d63f9671d 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx @@ -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 }) => ( 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 }) => (