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
This commit is contained in:
yuneng-jiang 2026-10-08 15:12:25 -07:00 • committed by GitHub
parent 406514fcaf
commit b1eae8855e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 102 additions and 13 deletions

View file

@ -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: () => <div data-testid="vector-store-selector" />,
}));
type Catalog = Record<string, { litellm_provider: string }>;
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();

View file

@ -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<string | null>(Providers.Anthropic);
const [providerModels, setProviderModels] = useState<string[]>([]);
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}

View file

@ -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,

View file

@ -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<AddModelFormProps> = ({
selectedProvider,
setSelectedProvider,
providerModels,
setProviderModelsFn,
getPlaceholder,
showAdvancedSettings,
setShowAdvancedSettings,
@ -166,7 +164,6 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
const applyProviderSelection = (provider: string | null) => {
setSelectedProvider(provider);
setProviderModelsFn(provider);
form.setValue("model", []);
form.setValue("model_name", undefined);
};

View file

@ -131,12 +131,12 @@ const LiteLLMModelNameField: React.FC<LiteLLMModelNameFieldProps> = ({
}
}}
/>
) : providerModels.length > 0 ? (
) : providerModels.length > 0 && !(typeof control.value === "string" && control.value !== "") ? (
<MultiSelect
id={control.id}
placeholder="Select models"
emptyText="No models found"
value={(control.value as string[] | undefined) ?? []}
value={Array.isArray(control.value) ? control.value : []}
onValueChange={(value: string[]) => {
control.onChange(value);
handleModelChange(value);