diff --git a/src/frontend/src/components/core/parameterRenderComponent/components/modelInputComponent/__tests__/ModelInputComponent.test.tsx b/src/frontend/src/components/core/parameterRenderComponent/components/modelInputComponent/__tests__/ModelInputComponent.test.tsx
index 220abfeca5..07f75486cc 100644
--- a/src/frontend/src/components/core/parameterRenderComponent/components/modelInputComponent/__tests__/ModelInputComponent.test.tsx
+++ b/src/frontend/src/components/core/parameterRenderComponent/components/modelInputComponent/__tests__/ModelInputComponent.test.tsx
@@ -171,7 +171,10 @@ const defaultProps: BaseInputProps & ModelInputComponentType = {
editNode: false,
};
-// Helper to render with QueryClientProvider
+// Helper to render with QueryClientProvider. Returns the raw RTL handle plus
+// a ``rerenderWithProvider`` wrapper so callers can rerender without losing
+// the surrounding ``QueryClientProvider`` (rerender replaces the root JSX —
+// dropping the wrapper would force a remount and reset component state).
const renderWithQueryClient = (component: React.ReactElement) => {
const queryClient = new QueryClient({
defaultOptions: {
@@ -179,9 +182,13 @@ const renderWithQueryClient = (component: React.ReactElement) => {
mutations: { retry: false },
},
});
- return render(
- {component},
+ const wrap = (node: React.ReactElement) => (
+ {node}
);
+ const result = render(wrap(component));
+ const rerenderWithProvider = (node: React.ReactElement) =>
+ result.rerender(wrap(node));
+ return { ...result, rerenderWithProvider };
};
describe("ModelInputComponent", () => {
@@ -190,14 +197,15 @@ describe("ModelInputComponent", () => {
});
describe("Rendering", () => {
- it("should render disabled combobox when no options are provided", () => {
+ it("renders the Setup Provider CTA when no models are enabled", () => {
renderWithQueryClient(
,
);
- const combobox = screen.getByRole("combobox");
- expect(combobox).toBeInTheDocument();
- expect(combobox).toBeDisabled();
+ // No combobox — the trigger swaps for the Setup Provider button when
+ // there's nothing to pick from.
+ expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
+ expect(screen.getByText("Setup Provider")).toBeInTheDocument();
});
it("should render the model selector when options are available", () => {
@@ -323,6 +331,80 @@ describe("ModelInputComponent", () => {
expect(screen.getByTestId("model-provider-modal")).toBeInTheDocument();
});
});
+
+ it("keeps loading state until both providers and enabled-models refetches settle", async () => {
+ // The post-close refresh invalidates both ``useGetModelProviders`` AND
+ // ``useGetEnabledModels``. The component must wait for BOTH to finish
+ // before clearing the loading state — otherwise ``groupedOptions``
+ // renders against a stale ``enabledModelsData`` cache and disabled
+ // models briefly leak back into the dropdown after the user closes
+ // the provider modal.
+ let providersFetching = true;
+ let enabledFetching = true;
+
+ const mockedProviders = useGetModelProviders as jest.MockedFunction<
+ typeof useGetModelProviders
+ >;
+ const mockedEnabled = useGetEnabledModels as jest.MockedFunction<
+ typeof useGetEnabledModels
+ >;
+
+ mockedProviders.mockImplementation(
+ () =>
+ ({
+ data: mockProvidersData,
+ isLoading: false,
+ isFetching: providersFetching,
+ }) as unknown as ReturnType,
+ );
+ mockedEnabled.mockImplementation(
+ () =>
+ ({
+ data: { enabled_models: {} },
+ isLoading: false,
+ isFetching: enabledFetching,
+ }) as unknown as ReturnType,
+ );
+
+ const user = userEvent.setup();
+ const { rerenderWithProvider } = renderWithQueryClient(
+ ,
+ );
+
+ // Open the dropdown then the provider manager dialog
+ const trigger = screen.getByRole("combobox");
+ await user.click(trigger);
+ await waitFor(() => {
+ expect(
+ screen.getByTestId("manage-model-providers"),
+ ).toBeInTheDocument();
+ });
+ await user.click(screen.getByTestId("manage-model-providers"));
+ await waitFor(() => {
+ expect(screen.getByTestId("model-provider-modal")).toBeInTheDocument();
+ });
+
+ // Close the modal — this sets isRefreshingAfterClose=true and the
+ // loading button replaces the dropdown.
+ await user.click(screen.getByTestId("close-provider-modal"));
+ await waitFor(() => {
+ expect(screen.getByText("Loading models")).toBeInTheDocument();
+ });
+
+ // Providers refetch completes FIRST. With the fix, loading persists
+ // because the enabled-models refetch is still in flight.
+ providersFetching = false;
+ rerenderWithProvider();
+ await new Promise((r) => setTimeout(r, 30));
+ expect(screen.getByText("Loading models")).toBeInTheDocument();
+
+ // Enabled-models refetch completes. Loading state clears.
+ enabledFetching = false;
+ rerenderWithProvider();
+ await waitFor(() => {
+ expect(screen.queryByText("Loading models")).not.toBeInTheDocument();
+ });
+ });
});
describe("Footer Buttons", () => {
@@ -602,117 +684,88 @@ describe("ModelInputComponent", () => {
expect(screen.getByRole("combobox")).not.toBeDisabled();
});
- it("keeps a saved value whose model isn't enabled locally and renders the Configure wrench", async () => {
- // The backend's update_model_options_in_build_config injects the saved
- // value into options tagged with `not_enabled_locally: true` whenever
- // it isn't in the user's enabled list. The frontend must:
- // 1. NOT auto-reset the saved value.
- // 2. Keep the option visible/selectable in the dropdown.
- // 3. Render the Configure wrench next to the trigger.
- mockedUseGetEnabledModels.mockReturnValue({
- data: {
- enabled_models: {
- OpenAI: { "gpt-4": true, "gpt-3.5-turbo": true },
- },
- },
- isLoading: false,
- });
-
- const handleOnNewValue = jest.fn();
- const savedValue = [
- {
- id: "ibm/granite-3",
- name: "ibm/granite-3",
- icon: "IBMWatsonx",
- provider: "IBM watsonx.ai",
- metadata: { not_enabled_locally: true },
- },
- ];
- // Backend-style options: the saved value is injected into options with
- // the sticky flag so the client can render it.
- const optionsWithSticky = [
- ...mockOptions,
- {
- id: "ibm/granite-3",
- name: "ibm/granite-3",
- icon: "IBMWatsonx",
- provider: "IBM watsonx.ai",
- metadata: { not_enabled_locally: true },
- },
- ];
-
- renderWithQueryClient(
- ,
- );
-
- // Value must not be reset by the auto-select effect.
- expect(handleOnNewValue).not.toHaveBeenCalled();
-
- // Saved model remains visible in the trigger label.
- await waitFor(() => {
- expect(screen.getByText("ibm/granite-3")).toBeInTheDocument();
- });
-
- // Configure wrench is rendered next to the trigger.
- expect(
- screen.getByTestId(`${defaultProps.id}-configure`),
- ).toBeInTheDocument();
- });
-
- it("opens the provider manager when the Configure wrench is clicked", async () => {
+ it("auto-selects an available model when the saved value is globally disabled", async () => {
+ // The user previously chose ibm/granite-3 but that provider's models
+ // are no longer enabled. Some other models (gpt-4) ARE enabled, so the
+ // component must drop the stale selection and align the stored value
+ // with the first available option rather than visually showing a model
+ // the dropdown doesn't contain.
mockedUseGetEnabledModels.mockReturnValue({
data: { enabled_models: { OpenAI: { "gpt-4": true } } },
isLoading: false,
});
+ const handleOnNewValue = jest.fn();
const savedValue = [
{
id: "ibm/granite-3",
name: "ibm/granite-3",
icon: "IBMWatsonx",
provider: "IBM watsonx.ai",
- metadata: { not_enabled_locally: true },
- },
- ];
- const optionsWithSticky = [
- ...mockOptions,
- {
- id: "ibm/granite-3",
- name: "ibm/granite-3",
- icon: "IBMWatsonx",
- provider: "IBM watsonx.ai",
- metadata: { not_enabled_locally: true },
+ metadata: {},
},
];
- const handleOnNewValue = jest.fn();
- const user = userEvent.setup();
renderWithQueryClient(
,
);
- const wrench = await screen.findByTestId(`${defaultProps.id}-configure`);
- await user.click(wrench);
-
+ // Auto-select fires because the saved name isn't in flatOptions.
await waitFor(() => {
- expect(screen.getByTestId("model-provider-modal")).toBeInTheDocument();
+ expect(handleOnNewValue).toHaveBeenCalled();
});
- // Clicking the wrench must not mutate the saved value.
- expect(handleOnNewValue).not.toHaveBeenCalled();
+ const newValue = handleOnNewValue.mock.calls[0][0].value;
+ expect(newValue[0].name).toBe("gpt-4");
+
+ // The stale model name must NOT be rendered anywhere.
+ expect(screen.queryByText("ibm/granite-3")).not.toBeInTheDocument();
});
- it("does not render Configure when the selected model isn't flagged", () => {
- // Baseline: a normal enabled model must not surface the wrench.
+ it("renders the Setup Provider CTA when a provider is configured but all its models are disabled", () => {
+ // OpenAI is configured (is_configured/is_enabled true on the provider)
+ // but every model is explicitly disabled in enabled_models. There's
+ // nothing for the user to pick from, so we must show the Setup
+ // Provider CTA — same UX as the never-configured case.
+ mockedUseGetModelProviders.mockReturnValue({
+ data: [
+ {
+ provider: "OpenAI",
+ is_enabled: true,
+ is_configured: true,
+ icon: "OpenAI",
+ models: [
+ { model_name: "gpt-4", metadata: { model_type: "llm" } },
+ { model_name: "gpt-3.5-turbo", metadata: { model_type: "llm" } },
+ ],
+ },
+ ],
+ isLoading: false,
+ });
+ mockedUseGetEnabledModels.mockReturnValue({
+ data: {
+ enabled_models: {
+ OpenAI: { "gpt-4": false, "gpt-3.5-turbo": false },
+ },
+ },
+ isLoading: false,
+ });
+
+ renderWithQueryClient(
+ ,
+ );
+
+ expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
+ expect(screen.getByText("Setup Provider")).toBeInTheDocument();
+ });
+
+ it("never renders the Configure wrench affordance", () => {
+ // The sticky-default UX was removed: there's no per-trigger affordance
+ // for a disabled-but-saved model. The dropdown is the single source
+ // of truth.
mockedUseGetEnabledModels.mockReturnValue({
data: { enabled_models: { OpenAI: { "gpt-4": true } } },
isLoading: false,
diff --git a/src/frontend/src/components/core/parameterRenderComponent/components/modelInputComponent/index.tsx b/src/frontend/src/components/core/parameterRenderComponent/components/modelInputComponent/index.tsx
index 9a3c4de061..b19be72281 100644
--- a/src/frontend/src/components/core/parameterRenderComponent/components/modelInputComponent/index.tsx
+++ b/src/frontend/src/components/core/parameterRenderComponent/components/modelInputComponent/index.tsx
@@ -80,10 +80,6 @@ export default function ModelInputComponent({
const { refreshAllModelInputs } = useRefreshModelInputs();
- // Ref to track if we've already processed the empty options state
- // prevents infinite loop when no models are available
- const hasProcessedEmptyRef = useRef(false);
-
const postTemplateValue = usePostTemplateValue({
parameterId: "model",
nodeId: nodeId || "",
@@ -113,17 +109,14 @@ export default function ModelInputComponent({
isLoading: isLoadingProviders,
isFetching: isFetchingProviders,
} = useGetModelProviders({});
- const { data: enabledModelsData, isLoading: isLoadingEnabledModels } =
- useGetEnabledModels();
+ const {
+ data: enabledModelsData,
+ isLoading: isLoadingEnabledModels,
+ isFetching: isFetchingEnabledModels,
+ } = useGetEnabledModels();
const isLoading = isLoadingProviders || isLoadingEnabledModels;
- const hasEnabledProviders = useMemo(() => {
- return providersData?.some(
- (provider) => provider.is_enabled || provider.is_configured,
- );
- }, [providersData]);
-
// Groups models by their provider name for sectioned display in dropdown.
// Filters out models from disabled providers AND disabled models, then
// augments with any enabled models from `providersData` that weren't in the
@@ -137,20 +130,12 @@ export default function ModelInputComponent({
if (option.metadata?.is_disabled_provider) continue;
const provider = option.provider || "Unknown";
- // Backend sticky-default: options tagged with `not_enabled_locally` are
- // the user's saved selection injected into the options list by the
- // unified-models build_config helper even though they aren't in the
- // enabled list. They must always pass the client-side filter so the
- // selection stays visible and selectable in the dropdown.
- const isStickyNotEnabled = option.metadata?.not_enabled_locally === true;
-
- // Filter against client-side enabled models data. This is the source of
- // truth for what the current user has enabled — stale `options` saved in
- // an imported flow may include models from providers the current user
- // hasn't enabled (e.g. WatsonX). When the provider is tracked in
- // enabled_models, the model must be explicitly enabled (=== true); a
- // `false` or missing entry means the model should be hidden.
- if (!isStickyNotEnabled && enabledModelsData?.enabled_models) {
+ // Filter against client-side enabled models data. The user's
+ // current enabled list is the single source of truth: a model that
+ // isn't explicitly enabled (=== true) is hidden, even if it's the
+ // node's saved selection. There is no sticky-default carve-out —
+ // a globally-disabled model never appears in the dropdown.
+ if (enabledModelsData?.enabled_models) {
const providerModels = enabledModelsData.enabled_models[provider];
if (providerModels && providerModels[option.name] !== true) {
continue;
@@ -216,33 +201,17 @@ export default function ModelInputComponent({
}
}
- // Zero-provider import fallback: if the user has no providers configured
- // locally but an imported flow carries a saved model, inject that saved
- // selection as the only dropdown entry so the trigger renders the model
- // (with the Configure wrench) instead of the "Setup Provider" button.
- // Without this, ``flatOptions`` would be empty and ``ModelTrigger`` would
- // swap the dropdown for the setup CTA, visually losing the imported
- // selection.
- const hasAnyGrouped = Object.keys(grouped).length > 0;
- const savedValue = value?.[0];
- if (!hasAnyGrouped && savedValue?.name) {
- const providerName = savedValue.provider || "Unknown";
- grouped[providerName] = [
- {
- ...(savedValue.id && { id: savedValue.id }),
- name: savedValue.name,
- icon: savedValue.icon || "Bot",
- provider: providerName,
- metadata: {
- ...(savedValue.metadata ?? {}),
- not_enabled_locally: true,
- },
- } as ModelOption,
- ];
- }
-
return grouped;
- }, [options, enabledModelsData, providersData, modelType, value]);
+ }, [options, enabledModelsData, providersData, modelType]);
+
+ // True iff at least one model of this component's type is enabled across
+ // any provider. Drives the Setup Provider CTA — a provider that is
+ // configured but has all its models disabled is treated the same as a
+ // provider that hasn't been configured yet.
+ const hasEnabledProviders = useMemo(
+ () => Object.values(groupedOptions).some((models) => models.length > 0),
+ [groupedOptions],
+ );
// Flattened array of all enabled options for efficient lookups by name
const flatOptions = useMemo(
@@ -250,9 +219,12 @@ export default function ModelInputComponent({
[groupedOptions],
);
- // Derive the currently selected model from the value prop
+ // Derive the currently selected model from the value prop. If the saved
+ // value isn't in the current ``flatOptions`` (typically because the
+ // model was globally disabled), it's treated as if no model is selected
+ // — the trigger renders the first available option as the visual
+ // selection and the effect below realigns the stored ``value`` to match.
const selectedModel = useMemo(() => {
- // If we're in connection mode, show the connection option as selected
if (isConnectionMode) {
return {
name:
@@ -264,56 +236,26 @@ export default function ModelInputComponent({
}
const currentName = value?.[0]?.name;
- if (!currentName) {
- // Logic to auto-select the first model if none is selected
- // We only do this check if we have options available
- if (flatOptions.length > 0 && !hasProcessedEmptyRef.current) {
- // If we haven't processed empty state yet, we render the first one
- return flatOptions[0];
- }
- return null;
- }
-
- const match = flatOptions.find((option) => option.name === currentName);
- if (match) return match;
-
- // Saved name isn't in the filtered options list — typically because the
- // flow was imported via drag-drop (no backend sticky-default round-trip)
- // or because an outdated component was upgraded and the fresh template
- // lacks the sticky-default metadata. Preserve the saved selection in the
- // trigger so it doesn't visually "snap" to the user's first enabled
- // model. The wrench affordance in the dropdown handles configuration.
- const saved = value?.[0];
- if (saved) {
- return {
- ...(saved.id && { id: saved.id }),
- name: saved.name,
- icon: saved.icon || "Bot",
- provider: saved.provider || "Unknown",
- metadata: {
- ...(saved.metadata ?? {}),
- not_enabled_locally: true,
- },
- } as SelectedModel;
+ if (currentName) {
+ const match = flatOptions.find((option) => option.name === currentName);
+ if (match) return match;
}
return flatOptions.length > 0 ? flatOptions[0] : null;
}, [value, flatOptions, isConnectionMode, externalOptions]);
+ // Auto-select the first available option whenever the stored ``value``
+ // doesn't point at a currently-available model — including the case
+ // where the previously-selected model was globally disabled. Without
+ // this, the trigger would visually show one model while the node's
+ // saved value pointed at a different (or removed) one.
useEffect(() => {
if (flatOptions.length === 0 || isConnectionMode) return;
- if (hasProcessedEmptyRef.current) return;
- const isEmpty = !value || value.length === 0;
- // Sticky-default: if the component has a saved value, keep it as-is. The
- // backend injects any selection that isn't in the user's enabled list
- // back into `options` tagged with `not_enabled_locally`, so the saved
- // value remains visible and runnable. Only auto-select the first option
- // when there's no saved value at all.
- if (!isEmpty) return;
+ const savedName = value?.[0]?.name;
+ if (savedName && flatOptions.some((o) => o.name === savedName)) return;
const firstOption = flatOptions[0];
- // Construct the new value object
const newValue = [
{
...(firstOption.id && { id: firstOption.id }),
@@ -324,7 +266,6 @@ export default function ModelInputComponent({
},
];
handleOnNewValue({ value: newValue });
- hasProcessedEmptyRef.current = true;
}, [flatOptions, value, handleOnNewValue, isConnectionMode]);
/**
@@ -402,22 +343,27 @@ export default function ModelInputComponent({
setIsRefreshingAfterClose(true);
}, []);
- // Clear the refreshing indicator after the providers query completes a full
- // refetch cycle (isFetchingProviders: false → true → false). We track whether
- // we've seen the fetch start so we don't clear prematurely before the
- // invalidation has even been triggered by refreshAllModelInputs.
+ // Clear the refreshing indicator only after BOTH the providers and the
+ // enabled-models queries have completed a full refetch cycle (isFetching:
+ // false → true → false). Watching only ``isFetchingProviders`` clears the
+ // loading state too early when the enabled-models refetch is slower,
+ // letting ``groupedOptions`` render against a stale ``enabledModelsData``
+ // cache — disabled models would briefly leak back into the dropdown after
+ // the user closes the provider modal. We track whether we've seen the
+ // fetch start so we don't clear prematurely before the invalidation has
+ // even been triggered by refreshAllModelInputs.
const hasSeenFetchStartRef = useRef(false);
useEffect(() => {
if (!isRefreshingAfterClose) {
hasSeenFetchStartRef.current = false;
return;
}
- if (isFetchingProviders) {
+ if (isFetchingProviders || isFetchingEnabledModels) {
hasSeenFetchStartRef.current = true;
} else if (hasSeenFetchStartRef.current) {
setIsRefreshingAfterClose(false);
}
- }, [isRefreshingAfterClose, isFetchingProviders]);
+ }, [isRefreshingAfterClose, isFetchingProviders, isFetchingEnabledModels]);
// Safety timeout: clear loading even if no refetch cycle is detected
// (e.g. no model nodes on canvas, or the refresh was a no-op)
@@ -521,49 +467,22 @@ export default function ModelInputComponent({
return
{renderLoadingButton()}
;
}
- // Show a small "Configure" wrench next to the trigger when the currently
- // selected model was injected by the backend as a sticky default — i.e.
- // the user's saved selection isn't in their current enabled_models list.
- // Clicking it jumps straight to the provider manager so the user can
- // enable the provider without losing their selection.
- const showConfigureAffordance =
- selectedModel?.metadata?.not_enabled_locally === true;
-
// Main render
return (
<>
-