From b1eae8855e476158822bf6e486955505048cda9e Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Thu, 8 Oct 2026 15:12:25 -0700 Subject: [PATCH] fix(ui): show the Add Model picker once the model catalog loads after a provider is picked (#45426) * fix(ui): show the Add Model picker once the model catalog loads after a provider is picked * fix(ui): show the Add Model picker when a name typed before the catalog loaded was cleared --- .../panels/AddModelPanel.integration.test.tsx | 94 ++++++++++++++++++- .../panels/AddModelPanel.tsx | 13 +-- .../AddModelForm.integration.test.tsx | 1 - .../src/components/add_model/AddModelForm.tsx | 3 - .../add_model/litellm_model_name.tsx | 4 +- 5 files changed, 102 insertions(+), 13 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.integration.test.tsx index 410758491fe..844ac9f44e2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.integration.test.tsx @@ -1,4 +1,5 @@ -import { renderWithProviders, screen, waitFor } from "../../../../../tests/test-utils"; +import { useSyncExternalStore } from "react"; +import { act, renderWithProviders, screen, waitFor } from "../../../../../tests/test-utils"; import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import AddModelPanel from "./AddModelPanel"; @@ -64,6 +65,13 @@ vi.mock("@/app/(dashboard)/hooks/providers/useProviderFields", () => ({ { key: "api_base", label: "API Base", field_type: "text", required: false }, ], }, + { + provider: "Anthropic", + provider_display_name: "Anthropic", + litellm_provider: "anthropic", + default_model_placeholder: "claude-3-opus", + credential_fields: [{ key: "api_key", label: "API Key", field_type: "password", required: false }], + }, ], isLoading: false, error: null, @@ -74,6 +82,26 @@ vi.mock("@/components/vector_store_management/VectorStoreSelector", () => ({ default: () =>
, })); +type Catalog = Record; + +const createCatalogFeed = () => { + let current: Catalog | undefined; + const listeners = new Set<() => void>(); + const subscribe = (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }; + return { + useData: () => ({ data: useSyncExternalStore(subscribe, () => current) }), + publish: (next: Catalog) => { + current = next; + listeners.forEach((listener) => listener()); + }, + }; +}; + const lastCreatedModel = () => modelCreateCall.mock.calls.at(-1)?.[1]; const PROXY_ADMIN = { @@ -165,6 +193,70 @@ describe("AddModelPanel submit payload contract", () => { expect(screen.queryByText("openai-gpt-4o-deployment-id")).not.toBeInTheDocument(); }); + it("offers the catalog models once the catalog arrives after the provider was picked", async () => { + const catalog = createCatalogFeed(); + mockUseModelCostMap.mockImplementation(() => catalog.useData()); + const { user } = await setup(); + await user.click(screen.getByRole("combobox", { name: /provider/i })); + await user.click(await screen.findByText("OpenAI")); + expect(await screen.findByPlaceholderText("gpt-3.5-turbo")).toBeInTheDocument(); + + act(() => catalog.publish({ "gpt-4o-2024-08-06": { litellm_provider: "openai" } })); + await user.click(await screen.findByPlaceholderText("Select models")); + + expect(await screen.findByText("gpt-4o-2024-08-06")).toBeInTheDocument(); + }); + + it("keeps a model name typed before the catalog arrives instead of swapping the field under the user", async () => { + const catalog = createCatalogFeed(); + mockUseModelCostMap.mockImplementation(() => catalog.useData()); + const { user } = await setup(); + await user.click(screen.getByRole("combobox", { name: /provider/i })); + await user.click(await screen.findByText("OpenAI")); + await user.type(await screen.findByPlaceholderText("gpt-3.5-turbo"), "my-fine-tune"); + + act(() => catalog.publish({ "gpt-4o-2024-08-06": { litellm_provider: "openai" } })); + + await waitFor(() => expect(screen.getByPlaceholderText("gpt-3.5-turbo")).toHaveValue("my-fine-tune")); + expect(screen.queryByPlaceholderText("Select models")).not.toBeInTheDocument(); + }); + + it("offers the catalog models when a name typed before the catalog arrived was cleared again", async () => { + const catalog = createCatalogFeed(); + mockUseModelCostMap.mockImplementation(() => catalog.useData()); + const { user } = await setup(); + await user.click(screen.getByRole("combobox", { name: /provider/i })); + await user.click(await screen.findByText("OpenAI")); + const typed = await screen.findByPlaceholderText("gpt-3.5-turbo"); + await user.type(typed, "my-fine-tune"); + await user.clear(typed); + + act(() => catalog.publish({ "gpt-4o-2024-08-06": { litellm_provider: "openai" } })); + await user.click(await screen.findByPlaceholderText("Select models")); + + expect(await screen.findByText("gpt-4o-2024-08-06")).toBeInTheDocument(); + }); + + it("swaps the offered models when the provider changes while the catalog stays the same", async () => { + const catalog = createCatalogFeed(); + catalog.publish({ + "gpt-4o-2024-08-06": { litellm_provider: "openai" }, + "claude-sonnet-4-5": { litellm_provider: "anthropic" }, + }); + mockUseModelCostMap.mockImplementation(() => catalog.useData()); + const { user } = await setup(); + const provider = screen.getByRole("combobox", { name: /provider/i }); + await user.click(provider); + await user.click(await screen.findByText("OpenAI")); + await user.clear(provider); + await user.type(provider, "Anthropic"); + await user.click(await screen.findByText("Anthropic")); + await user.click(await screen.findByPlaceholderText("Select models")); + + expect(await screen.findByText("claude-sonnet-4-5")).toBeInTheDocument(); + expect(screen.queryByText("gpt-4o-2024-08-06")).not.toBeInTheDocument(); + }); + it("sends only the always-mounted fields while Advanced Settings stays closed", async () => { const { fillRequired, submit } = await setup(); await fillRequired(); 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 9dacabe506b..9fbd138bda4 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 @@ -1,7 +1,7 @@ "use client"; -import { useState } from "react"; -import { useForm } from "react-hook-form"; +import { useMemo, useState } from "react"; +import { useForm, useWatch } from "react-hook-form"; import { useQueryClient } from "@tanstack/react-query"; import AddModelForm from "@/components/add_model/AddModelForm"; import { handleAddModelSubmit } from "@/components/add_model/handle_add_model_submit"; @@ -27,7 +27,11 @@ export default function AddModelPanel() { const { data: credentialsResponse } = useCredentials(); const { data: teams } = useTeams(); const [selectedProvider, setSelectedProvider] = useState(Providers.Anthropic); - const [providerModels, setProviderModels] = useState([]); + const pickedProvider = useWatch({ control: form.control, name: "custom_llm_provider" }); + const providerModels = useMemo( + () => (typeof pickedProvider === "string" ? getProviderModels(pickedProvider, modelCostMapData) : []), + [pickedProvider, modelCostMapData], + ); const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); const refresh = () => queryClient.invalidateQueries({ queryKey: ["models", "list"] }); @@ -57,9 +61,6 @@ export default function AddModelPanel() { selectedProvider={selectedProvider} setSelectedProvider={setSelectedProvider} providerModels={providerModels} - setProviderModelsFn={(provider) => - setProviderModels(provider === null ? [] : getProviderModels(provider, modelCostMapData)) - } getPlaceholder={getPlaceholder} showAdvancedSettings={showAdvancedSettings} setShowAdvancedSettings={setShowAdvancedSettings} diff --git a/ui/litellm-dashboard/src/components/add_model/AddModelForm.integration.test.tsx b/ui/litellm-dashboard/src/components/add_model/AddModelForm.integration.test.tsx index 12efc5beb7c..220d46647f6 100644 --- a/ui/litellm-dashboard/src/components/add_model/AddModelForm.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/AddModelForm.integration.test.tsx @@ -164,7 +164,6 @@ const createTestProps = (userRole = "proxy_admin", userId = "user-1", isTeamAdmi mountedValues: () => projectMountedValues(registry, form.getValues), handleOk: vi.fn().mockResolvedValue(true), setSelectedProvider: vi.fn(), - setProviderModelsFn: vi.fn(), getPlaceholder: vi.fn((provider: string) => `Enter ${provider} model name`), setShowAdvancedSettings: vi.fn(), selectedProvider: Providers.OpenAI, diff --git a/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx b/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx index a416bf87602..b5b6c439dc2 100644 --- a/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx +++ b/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx @@ -52,7 +52,6 @@ interface AddModelFormProps { selectedProvider: string | null; setSelectedProvider: (provider: string | null) => void; providerModels: string[]; - setProviderModelsFn: (provider: string | null) => void; getPlaceholder: (provider: string) => string; showAdvancedSettings: boolean; setShowAdvancedSettings: (show: boolean) => void; @@ -76,7 +75,6 @@ const AddModelForm: React.FC = ({ selectedProvider, setSelectedProvider, providerModels, - setProviderModelsFn, getPlaceholder, showAdvancedSettings, setShowAdvancedSettings, @@ -166,7 +164,6 @@ const AddModelForm: React.FC = ({ const applyProviderSelection = (provider: string | null) => { setSelectedProvider(provider); - setProviderModelsFn(provider); form.setValue("model", []); form.setValue("model_name", 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 8cf4b077f39..529c0e2a63e 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 @@ -131,12 +131,12 @@ const LiteLLMModelNameField: React.FC = ({ } }} /> - ) : providerModels.length > 0 ? ( + ) : providerModels.length > 0 && !(typeof control.value === "string" && control.value !== "") ? ( { control.onChange(value); handleModelChange(value);