feat: Model Provider Input Patches for Design (#11218)

* feat: add IBM icon and reorganize IBM WatsonX icon structure

- Add new IBM logo icon (IBM.jsx, ibm.svg) for generic IBM provider branding
- Reorganize icon directory structure: move IBMWatsonx to IBM parent folder with watsonx subfolder
- Export both WatsonxAiIcon and IBMIcon from consolidated IBM index
- Update icon mapping in use-get-model-providers to support both 'IBM WatsonX' and 'IBM watsonx.ai' provider names
- Standardize quote style from double to single quotes across use-get-model-providers.

* added doc link support

* refactor: standardize IBM WatsonX icon references and quote style

- Update all WatsonX model metadata to use generic "IBM" icon instead of "WatsonxAI"
- Standardize quote style from double to single quotes in ModelProvidersPage imports

* refactor: standardize quote style and update LLM section label in ModelSelection

- Change all double quotes to single quotes in ModelSelection component and tests
- Update "LLM Models" label to "Language Models" for consistency
- Standardize arrow function formatting across component

* fixes the model provider streching the page

* fixes border not being fully hidden

* updated api doc url

* fix: improve model field default value logic and provider field visibility

- Update default value setting logic to check current model value from build_config instead of field_value parameter
- Only set default when field_name is None (initial load) or when model field is being set and is empty
- Fix provider-specific field visibility to use current model value from build_config when field_name is not model
- Ensure provider fields are shown/hidden correctly regardless of which field triggere

* fix: improve model validation and refresh logic in model provider modal

- Add client-side filtering of disabled models in ModelInputComponent using enabled models data
- Implement validateModelValue function to ensure selected models are available and valid
- Move refreshAllModelInputs call from ModelProvidersContent cleanup to ModelProviderModal handleClose with 1000ms delay to ensure database transactions complete
- Remove race condition between cleanup effect and debounced mutateTemplate by not

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes

* revery lru cache

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

* updated component_index

* updated templates

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

* refactor: convert use-refresh-model-inputs and api types to single quotes and add ModelOptionType

- Convert double quotes to single quotes throughout use-refresh-model-inputs.ts and api/index.ts
- Add ModelOptionType interface with name, id, icon, provider, and metadata properties
- Update validateModelValue to use typed ModelOptionType instead of any for option filtering

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

* fixed tests

* removed sticky property

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Deon Sanchez
2026-01-16 11:54:17 -07:00
committed by GitHub
parent 0689810bf3
commit 6b4f946818
42 changed files with 472 additions and 111 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -2028,6 +2028,10 @@
"limit": 1,
"name": "storage_location",
"options": [
{
"icon": "hard-drive",
"name": "Local"
},
{
"icon": "Amazon",
"name": "AWS"

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -47,6 +47,13 @@ jest.mock("@/controllers/API/queries/models/use-get-model-providers", () => ({
})),
}));
jest.mock("@/controllers/API/queries/models/use-get-enabled-models", () => ({
useGetEnabledModels: jest.fn(() => ({
data: { enabled_models: {} },
isLoading: false,
})),
}));
jest.mock("@/controllers/API/queries/nodes/use-post-template-value", () => ({
usePostTemplateValue: jest.fn(() => ({
mutateAsync: jest.fn(),
@ -267,18 +274,6 @@ describe("ModelInputComponent", () => {
});
describe("Footer Buttons", () => {
it("should render Refresh List button in dropdown", async () => {
const user = userEvent.setup();
render(<ModelInputComponent {...defaultProps} />);
const trigger = screen.getByRole("combobox");
await user.click(trigger);
await waitFor(() => {
expect(screen.getByText("Refresh List")).toBeInTheDocument();
});
});
it("should render Manage Model Providers button", async () => {
const user = userEvent.setup();
render(<ModelInputComponent {...defaultProps} />);

View File

@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { mutateTemplate } from "@/CustomNodes/helpers/mutate-template";
import LoadingTextComponent from "@/components/common/loadingTextComponent";
import { RECEIVING_INPUT_VALUE } from "@/constants/constants";
import { useGetEnabledModels } from "@/controllers/API/queries/models/use-get-enabled-models";
import { useGetModelProviders } from "@/controllers/API/queries/models/use-get-model-providers";
import { usePostTemplateValue } from "@/controllers/API/queries/nodes/use-post-template-value";
import ModelProviderModal from "@/modals/modelProviderModal";
@ -82,6 +83,7 @@ export default function ModelInputComponent({
: "embeddings";
const { data: providersData = [] } = useGetModelProviders({});
const { data: enabledModelsData } = useGetEnabledModels();
// Determines if we should show the model selector or the "Setup Provider" button
const hasEnabledProviders = useMemo(() => {
@ -89,16 +91,26 @@ export default function ModelInputComponent({
}, [providersData]);
// Groups models by their provider name for sectioned display in dropdown.
// Filters out models from disabled providers.
// Filters out models from disabled providers AND disabled models.
const groupedOptions = useMemo(() => {
const grouped: Record<string, ModelOption[]> = {};
for (const option of options) {
if (option.metadata?.is_disabled_provider) continue;
const provider = option.provider || "Unknown";
// Filter out disabled models using client-side enabled models data
// This provides a reliable fallback when backend filtering fails
if (enabledModelsData?.enabled_models) {
const providerModels = enabledModelsData.enabled_models[provider];
if (providerModels && providerModels[option.name] === false) {
continue; // Skip disabled models
}
}
(grouped[provider] ??= []).push(option);
}
return grouped;
}, [options]);
}, [options, enabledModelsData]);
// Flattened array of all enabled options for efficient lookups by name
const flatOptions = useMemo(
@ -221,8 +233,11 @@ export default function ModelInputComponent({
const handleManageProvidersDialogClose = useCallback(() => {
setOpenManageProvidersDialog(false);
handleRefreshButtonPress();
}, [handleRefreshButtonPress]);
// Note: Don't call handleRefreshButtonPress here - the cleanup effect in
// ModelProvidersContent triggers refreshAllModelInputs which properly validates
// model values against available options. Calling both causes a race condition
// where the debounced mutateTemplate overwrites the validated value.
}, []);
const handleExternalOptions = useCallback(
async (optionValue: string) => {
@ -476,13 +491,13 @@ export default function ModelInputComponent({
);
const renderManageProvidersButton = () => (
<div className="sticky bottom-0 bg-background">
{renderFooterButton(
<div className="bottom-0 bg-background">
{/* {renderFooterButton(
"Refresh List",
"RefreshCw",
handleRefreshButtonPress,
"external-option-button",
)}
)} */}
{renderFooterButton(
"Manage Model Providers",

View File

@ -10,6 +10,7 @@ export interface ModelProviderInfo {
metadata: Record<string, any>;
}>;
is_enabled: boolean;
api_docs_url?: string;
}
export interface ModelProviderWithStatus extends ModelProviderInfo {
@ -78,7 +79,7 @@ const getProviderIcon = (providerName: string): string => {
const iconMap: Record<string, string> = {
OpenAI: "OpenAI",
Anthropic: "Anthropic",
"Google Generative AI": "Google",
"Google Generative AI": "GoogleGenerativeAI",
Groq: "Groq",
"Amazon Bedrock": "Bedrock",
NVIDIA: "NVIDIA",
@ -86,6 +87,8 @@ const getProviderIcon = (providerName: string): string => {
"Azure OpenAI": "AzureOpenAI",
SambaNova: "SambaNova",
Ollama: "Ollama",
"IBM WatsonX": "IBM",
"IBM watsonx.ai": "IBM",
};
return iconMap[providerName] || "Bot";

View File

@ -383,7 +383,7 @@ describe("refreshAllModelInputs", () => {
});
});
it("should skip node update when API returns empty options", async () => {
it("should clear model value when API returns empty options", async () => {
mockNodes = [createMockModelNode("node-1")];
(api.post as jest.Mock).mockResolvedValue({
@ -404,7 +404,8 @@ describe("refreshAllModelInputs", () => {
await refreshAllModelInputs(mockQueryClient as any);
expect(mockSetNode).not.toHaveBeenCalled();
// Should still call setNode to clear the invalid value
expect(mockSetNode).toHaveBeenCalled();
});
it("should handle API errors gracefully", async () => {

View File

@ -5,7 +5,11 @@ import { getURL } from "@/controllers/API/helpers/constants";
import useAlertStore from "@/stores/alertStore";
import useFlowStore from "@/stores/flowStore";
import useFlowsManagerStore from "@/stores/flowsManagerStore";
import type { APIClassType, APITemplateType } from "@/types/api";
import type {
APIClassType,
APITemplateType,
ModelOptionType,
} from "@/types/api";
import type { AllNodeType } from "@/types/flow";
export interface RefreshOptions {
@ -120,6 +124,71 @@ export async function refreshAllModelInputs(
}
}
/** Validates and corrects model value against available options */
function validateModelValue(
template: APITemplateType,
modelFieldKey: string,
): APITemplateType {
const modelField = template[modelFieldKey];
if (!modelField) return template;
const options = modelField.options || [];
const currentValue = modelField.value;
// Filter out disabled provider placeholders to get actual available models
const availableOptions = options.filter(
(opt: ModelOptionType) => !opt?.metadata?.is_disabled_provider,
);
// Get current model name from value
const currentModelName = Array.isArray(currentValue)
? currentValue[0]?.name
: currentValue?.name;
// Check if current model is still available
const isCurrentModelValid =
currentModelName &&
availableOptions.some(
(opt: ModelOptionType) => opt.name === currentModelName,
);
if (isCurrentModelValid) {
// Current value is valid, no changes needed
return template;
}
// Current value is invalid - need to update it
if (availableOptions.length > 0) {
// Select the first available model
const firstOption = availableOptions[0];
const newValue = [
{
...(firstOption.id && { id: firstOption.id }),
name: firstOption.name,
icon: firstOption.icon || "Bot",
provider: firstOption.provider || "Unknown",
metadata: firstOption.metadata ?? {},
},
];
return {
...template,
[modelFieldKey]: {
...modelField,
value: newValue,
},
};
}
// No available options - clear the value
return {
...template,
[modelFieldKey]: {
...modelField,
value: [],
},
};
}
/** Refreshes a single node's model field via API */
async function refreshSingleNode(
node: AllNodeType,
@ -156,18 +225,16 @@ async function refreshSingleNode(
const responseData = response.data;
if (!responseData?.template) return;
// Skip if no options (prevents infinite loops)
const newModelOptions = responseData.template[modelFieldKey]?.options || [];
if (newModelOptions.length === 0) return;
// Validate and correct the model value against available options
const validatedTemplate = validateModelValue(
responseData.template,
modelFieldKey,
);
setNode(
node.id,
(currentNode) =>
createUpdatedNode(
currentNode,
responseData.template,
responseData.outputs,
),
createUpdatedNode(currentNode, validatedTemplate, responseData.outputs),
false,
);
} catch (error) {

View File

@ -0,0 +1,162 @@
const SvgIBM = (props) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={16}
height={7}
viewBox="0 0 16 7"
fill="none"
{...props}
>
<g clipPath="url(#clip0_1341_6038)">
<path
d="M3.11111 5.93939H0V6.34343H3.11111V5.93939Z"
fill="currentColor"
/>
<path
d="M3.11111 5.49495H0V5.09091H3.11111V5.49495Z"
fill="currentColor"
/>
<path
d="M2.22222 4.64647H0.888889V4.24242H2.22222V4.64647Z"
fill="currentColor"
/>
<path
d="M2.22222 3.79798H0.888889V3.39394H2.22222V3.79798Z"
fill="currentColor"
/>
<path
d="M2.22222 2.9495H0.888889V2.54545H2.22222V2.9495Z"
fill="currentColor"
/>
<path
d="M0.888889 1.69697H2.22222V2.10101H0.888889V1.69697Z"
fill="currentColor"
/>
<path
d="M3.11111 1.25253H0V0.848485H3.11111V1.25253Z"
fill="currentColor"
/>
<path d="M3.11111 0.40404H0V0H3.11111V0.40404Z" fill="currentColor" />
<path
d="M6.82828 6.34343H3.55556V5.93939H7.92687C7.63071 6.19111 7.24727 6.34343 6.82828 6.34343Z"
fill="currentColor"
/>
<path
d="M8.29778 5.49495H3.55556V5.09091H8.46626C8.42747 5.23394 8.37051 5.3697 8.29778 5.49495Z"
fill="currentColor"
/>
<path
d="M5.77778 4.64647H4.44444V4.24242H5.77778V4.64647Z"
fill="currentColor"
/>
<path
d="M4.44444 2.9495V2.54545H8.29737C8.2097 2.69657 8.09939 2.83232 7.97131 2.9495H4.44444Z"
fill="currentColor"
/>
<path
d="M8.29778 0.848485C8.37051 0.973737 8.42747 1.1095 8.46626 1.25253H3.55556V0.848485H8.29778Z"
fill="currentColor"
/>
<path
d="M5.77778 2.10101H4.44444V1.69697H5.77778V2.10101Z"
fill="currentColor"
/>
<path
d="M8.47596 2.10101H7.0303V1.69697H8.52525C8.52525 1.83636 8.50747 1.97131 8.47596 2.10101Z"
fill="currentColor"
/>
<path
d="M7.0303 4.64647V4.24242H8.47596C8.50747 4.37212 8.52525 4.50707 8.52525 4.64647H7.0303Z"
fill="currentColor"
/>
<path
d="M4.44444 3.39394H7.97131C8.09939 3.51111 8.2097 3.64687 8.29737 3.79798H4.44444V3.39394Z"
fill="currentColor"
/>
<path
d="M6.82828 0C7.24727 0 7.63071 0.152323 7.92687 0.40404H3.55556V0H6.82828Z"
fill="currentColor"
/>
<path
d="M11.1111 6.34343H8.88889V5.93939H11.1111V6.34343Z"
fill="currentColor"
/>
<path
d="M11.1111 5.49495H8.88889V5.09091H11.1111V5.49495Z"
fill="currentColor"
/>
<path
d="M11.1111 4.64647H9.77778V4.24242H11.1111V4.64647Z"
fill="currentColor"
/>
<path
d="M11.1111 3.79798H9.77778V3.39394H11.1111V3.79798Z"
fill="currentColor"
/>
<path d="M16 6.34343H13.7778V5.93939H16V6.34343Z" fill="currentColor" />
<path d="M16 5.49495H13.7778V5.09091H16V5.49495Z" fill="currentColor" />
<path
d="M15.1111 4.64647H13.7778V4.24242H15.1111V4.64647Z"
fill="currentColor"
/>
<path
d="M13.7778 3.39394H15.1111V3.79798H13.7778V3.39394Z"
fill="currentColor"
/>
<path
d="M15.1111 2.9495H13.7774V2.56485L13.6416 2.9495H12.4715L12.6117 2.54545H15.1111V2.9495Z"
fill="currentColor"
/>
<path
d="M12.9063 1.69697H15.1111V2.10101H12.7661L12.9063 1.69697Z"
fill="currentColor"
/>
<path
d="M13.3547 0.40404L13.4949 0H16V0.40404H13.3547Z"
fill="currentColor"
/>
<path
d="M12.5863 5.93939L12.4444 6.34343L12.3026 5.93939H12.5863Z"
fill="currentColor"
/>
<path
d="M12.7434 5.49495H12.1459L12.0032 5.09091H12.8861L12.7434 5.49495Z"
fill="currentColor"
/>
<path
d="M13.0428 4.64647H11.8461L11.7034 4.24242H13.1855L13.0428 4.64647Z"
fill="currentColor"
/>
<path
d="M13.3426 3.79798H11.5467L11.404 3.39394H13.4853L13.3426 3.79798Z"
fill="currentColor"
/>
<path
d="M12.1228 2.10101H9.77778V1.69697H11.9826L12.1228 2.10101Z"
fill="currentColor"
/>
<path
d="M16 1.25253H13.0602L13.2004 0.848485H16V1.25253Z"
fill="currentColor"
/>
<path
d="M8.88889 0.848485H11.6881L11.8283 1.25253H8.88889V0.848485Z"
fill="currentColor"
/>
<path
d="M8.88889 0H11.3939L11.5341 0.40404H8.88889V0Z"
fill="currentColor"
/>
<path
d="M12.2772 2.54545L12.4174 2.9495H11.2469L11.1111 2.56606V2.9495H9.77778V2.54545H12.2772Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_1341_6038">
<rect width={16} height={6.38384} fill="white" />
</clipPath>
</defs>
</svg>
);
export default SvgIBM;

View File

@ -0,0 +1,47 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="7" viewBox="0 0 16 7" fill="none">
<g clip-path="url(#clip0_1341_6038)">
<path d="M3.11111 5.93939H0V6.34343H3.11111V5.93939Z" fill="white"/>
<path d="M3.11111 5.49495H0V5.09091H3.11111V5.49495Z" fill="white"/>
<path d="M2.22222 4.64647H0.888889V4.24242H2.22222V4.64647Z" fill="white"/>
<path d="M2.22222 3.79798H0.888889V3.39394H2.22222V3.79798Z" fill="white"/>
<path d="M2.22222 2.9495H0.888889V2.54545H2.22222V2.9495Z" fill="white"/>
<path d="M0.888889 1.69697H2.22222V2.10101H0.888889V1.69697Z" fill="white"/>
<path d="M3.11111 1.25253H0V0.848485H3.11111V1.25253Z" fill="white"/>
<path d="M3.11111 0.40404H0V0H3.11111V0.40404Z" fill="white"/>
<path d="M6.82828 6.34343H3.55556V5.93939H7.92687C7.63071 6.19111 7.24727 6.34343 6.82828 6.34343Z" fill="white"/>
<path d="M8.29778 5.49495H3.55556V5.09091H8.46626C8.42747 5.23394 8.37051 5.3697 8.29778 5.49495Z" fill="white"/>
<path d="M5.77778 4.64647H4.44444V4.24242H5.77778V4.64647Z" fill="white"/>
<path d="M4.44444 2.9495V2.54545H8.29737C8.2097 2.69657 8.09939 2.83232 7.97131 2.9495H4.44444Z" fill="white"/>
<path d="M8.29778 0.848485C8.37051 0.973737 8.42747 1.1095 8.46626 1.25253H3.55556V0.848485H8.29778Z" fill="white"/>
<path d="M5.77778 2.10101H4.44444V1.69697H5.77778V2.10101Z" fill="white"/>
<path d="M8.47596 2.10101H7.0303V1.69697H8.52525C8.52525 1.83636 8.50747 1.97131 8.47596 2.10101Z" fill="white"/>
<path d="M7.0303 4.64647V4.24242H8.47596C8.50747 4.37212 8.52525 4.50707 8.52525 4.64647H7.0303Z" fill="white"/>
<path d="M4.44444 3.39394H7.97131C8.09939 3.51111 8.2097 3.64687 8.29737 3.79798H4.44444V3.39394Z" fill="white"/>
<path d="M6.82828 0C7.24727 0 7.63071 0.152323 7.92687 0.40404H3.55556V0H6.82828Z" fill="white"/>
<path d="M11.1111 6.34343H8.88889V5.93939H11.1111V6.34343Z" fill="white"/>
<path d="M11.1111 5.49495H8.88889V5.09091H11.1111V5.49495Z" fill="white"/>
<path d="M11.1111 4.64647H9.77778V4.24242H11.1111V4.64647Z" fill="white"/>
<path d="M11.1111 3.79798H9.77778V3.39394H11.1111V3.79798Z" fill="white"/>
<path d="M16 6.34343H13.7778V5.93939H16V6.34343Z" fill="white"/>
<path d="M16 5.49495H13.7778V5.09091H16V5.49495Z" fill="white"/>
<path d="M15.1111 4.64647H13.7778V4.24242H15.1111V4.64647Z" fill="white"/>
<path d="M13.7778 3.39394H15.1111V3.79798H13.7778V3.39394Z" fill="white"/>
<path d="M15.1111 2.9495H13.7774V2.56485L13.6416 2.9495H12.4715L12.6117 2.54545H15.1111V2.9495Z" fill="white"/>
<path d="M12.9063 1.69697H15.1111V2.10101H12.7661L12.9063 1.69697Z" fill="white"/>
<path d="M13.3547 0.40404L13.4949 0H16V0.40404H13.3547Z" fill="white"/>
<path d="M12.5863 5.93939L12.4444 6.34343L12.3026 5.93939H12.5863Z" fill="white"/>
<path d="M12.7434 5.49495H12.1459L12.0032 5.09091H12.8861L12.7434 5.49495Z" fill="white"/>
<path d="M13.0428 4.64647H11.8461L11.7034 4.24242H13.1855L13.0428 4.64647Z" fill="white"/>
<path d="M13.3426 3.79798H11.5467L11.404 3.39394H13.4853L13.3426 3.79798Z" fill="white"/>
<path d="M12.1228 2.10101H9.77778V1.69697H11.9826L12.1228 2.10101Z" fill="white"/>
<path d="M16 1.25253H13.0602L13.2004 0.848485H16V1.25253Z" fill="white"/>
<path d="M8.88889 0.848485H11.6881L11.8283 1.25253H8.88889V0.848485Z" fill="white"/>
<path d="M8.88889 0H11.3939L11.5341 0.40404H8.88889V0Z" fill="white"/>
<path d="M12.2772 2.54545L12.4174 2.9495H11.2469L11.1111 2.56606V2.9495H9.77778V2.54545H12.2772Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_1341_6038">
<rect width="16" height="6.38384" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@ -0,0 +1,16 @@
import React, { forwardRef } from "react";
import SvgIBM from "./ibm/IBM";
import SvgWatsonxAI from "./watsonx/WatsonxAI";
export const WatsonxAiIcon = forwardRef<
SVGSVGElement,
React.PropsWithChildren<{}>
>((props, ref) => {
return <SvgWatsonxAI ref={ref} {...props} />;
});
export const IBMIcon = forwardRef<SVGSVGElement, React.PropsWithChildren<{}>>(
(props, ref) => {
return <SvgIBM ref={ref} {...props} />;
},
);

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,9 +0,0 @@
import React, { forwardRef } from "react";
import SvgWatsonxAI from "./WatsonxAI";
export const WatsonxAiIcon = forwardRef<
SVGSVGElement,
React.PropsWithChildren<{}>
>((props, ref) => {
return <SvgWatsonxAI ref={ref} {...props} />;
});

View File

@ -52,7 +52,7 @@ import { HCDIcon } from "@/icons/HCD";
import { HomeAssistantIcon } from "@/icons/HomeAssistant";
import { HuggingFaceIcon } from "@/icons/HuggingFace";
import { HackerNewsIcon } from "@/icons/hackerNews";
import { WatsonxAiIcon } from "@/icons/IBMWatsonx";
import { IBMIcon, WatsonxAiIcon } from "@/icons/IBM";
import { IcosaIcon } from "@/icons/Icosa";
import { IFixIcon } from "@/icons/IFixIt";
import { JSIcon } from "@/icons/JSicon";
@ -106,8 +106,8 @@ import { UnstructuredIcon } from "@/icons/Unstructured";
import { UpstashSvgIcon } from "@/icons/Upstash";
import { VectaraIcon } from "@/icons/VectaraIcon";
import { VertexAIIcon } from "@/icons/VertexAI";
import { VllmIcon } from "@/icons/vLLM";
import { VLMRunIcon } from "@/icons/VLMRun";
import { VllmIcon } from "@/icons/vLLM";
import { WeaviateIcon } from "@/icons/Weaviate";
import { WikipediaIcon } from "@/icons/Wikipedia";
import { WolframIcon } from "@/icons/Wolfram";
@ -172,6 +172,7 @@ export const eagerIconsMapping = {
HCD: HCDIcon,
HomeAssistant: HomeAssistantIcon,
HuggingFace: HuggingFaceIcon,
IBM: IBMIcon,
Icosa: IcosaIcon,
IFixIt: IFixIcon,
javascript: JSIcon,

View File

@ -278,6 +278,14 @@ export const lazyIconsMapping = {
import("@/icons/HuggingFace").then((mod) => ({
default: mod.HuggingFaceIcon,
})),
IBM: () =>
import("@/icons/IBM").then((mod) => ({
default: mod.IBMIcon,
})),
WatsonxAI: () =>
import("@/icons/IBM").then((mod) => ({
default: mod.WatsonxAiIcon,
})),
Icosa: () =>
import("@/icons/Icosa").then((mod) => ({ default: mod.IcosaIcon })),
IFixIt: () =>
@ -467,19 +475,6 @@ export const lazyIconsMapping = {
import("@/icons/vectorstores").then((mod) => ({
default: mod.VectorStoresIcon,
})),
VertexAI: () =>
import("@/icons/VertexAI").then((mod) => ({ default: mod.VertexAIIcon })),
vLLM: () => import("@/icons/vLLM").then((mod) => ({ default: mod.VllmIcon })),
WatsonxAI: () =>
import("@/icons/IBMWatsonx").then((mod) => ({
default: mod.WatsonxAiIcon,
})),
Weaviate: () =>
import("@/icons/Weaviate").then((mod) => ({ default: mod.WeaviateIcon })),
Wikipedia: () =>
import("@/icons/Wikipedia/Wikipedia").then((mod) => ({
default: mod.default,
})),
Windsurf: () =>
import("@/icons/Windsurf").then((mod) => ({ default: mod.WindsurfIcon })),
Wolfram: () =>

View File

@ -74,7 +74,7 @@ describe("ModelSelection", () => {
render(<ModelSelection {...defaultProps} />);
expect(screen.getByTestId("llm-models-section")).toBeInTheDocument();
expect(screen.getByText("LLM Models")).toBeInTheDocument();
expect(screen.getByText("Language Models")).toBeInTheDocument();
});
it("should render Embeddings section when modelType is all", () => {

View File

@ -14,7 +14,6 @@ import {
usePostGlobalVariables,
} from "@/controllers/API/queries/variables";
import { useDebounce } from "@/hooks/use-debounce";
import { useRefreshModelInputs } from "@/hooks/use-refresh-model-inputs";
import ProviderList from "@/modals/modelProviderModal/components/ProviderList";
import { Provider } from "@/modals/modelProviderModal/components/types";
import useAlertStore from "@/stores/alertStore";
@ -49,7 +48,6 @@ const ModelProvidersContent = ({
usePatchGlobalVariables();
const { data: globalVariables = [] } = useGetGlobalVariables();
const { mutate: updateEnabledModels } = useUpdateEnabledModels();
const { refreshAllModelInputs } = useRefreshModelInputs();
const isPending = isCreating || isUpdating;
@ -239,20 +237,15 @@ const ModelProvidersContent = ({
}
};
useEffect(() => {
return () => {
if (onClose) {
refreshAllModelInputs({ silent: true });
}
};
}, [onClose, refreshAllModelInputs]);
// Note: refreshAllModelInputs is now called in ModelProviderModal's handleClose
// to ensure reliable execution when the modal closes
return (
<div className="flex flex-row w-full overflow-hidden">
<div className="flex flex-row w-full h-full overflow-hidden">
<div
className={cn(
"flex border-r p-2 flex-col transition-all duration-300 ease-in-out",
selectedProvider ? "w-1/3" : "w-full",
"flex p-2 flex-col transition-all duration-300 ease-in-out",
selectedProvider ? "w-1/3 border-r" : "w-full",
)}
>
<ProviderList
@ -282,7 +275,18 @@ const ModelProvidersContent = ({
{requiresApiKey ? (
<>
Add your{" "}
<span className="underline cursor-pointer hover:text-primary">
<span
className="underline cursor-pointer hover:text-primary"
onClick={() => {
if (selectedProvider?.api_docs_url) {
window.open(
selectedProvider.api_docs_url,
"_blank",
"noopener,noreferrer",
);
}
}}
>
{selectedProvider?.provider} API key
</span>{" "}
to enable these models

View File

@ -3,6 +3,7 @@ import { Switch } from "@/components/ui/switch";
import { useGetEnabledModels } from "@/controllers/API/queries/models/use-get-enabled-models";
import { Model } from "@/modals/modelProviderModal/components/types";
import { cn } from "@/utils/utils";
export interface ModelProviderSelectionProps {
availableModels: Model[];
@ -32,9 +33,13 @@ const ModelRow = ({
<div className="flex flex-row items-center gap-2">
<ForwardedIconComponent
name={model.metadata?.icon || "Bot"}
className="w-5 h-5"
className={cn("w-5 h-5", { grayscale: !isEnabledModel })}
/>
<span className="text-sm">{model.model_name}</span>
<span
className={cn("text-sm", { "text-muted-foreground": !isEnabledModel })}
>
{model.model_name}
</span>
</div>
{isEnabledModel && (
<Switch
@ -102,7 +107,7 @@ const ModelSelection = ({
<div data-testid="model-provider-selection" className="flex flex-col gap-6">
{modelType === "all" ? (
<>
{renderModelSection("LLM Models", llmModels, "llm")}
{renderModelSection("Language Models", llmModels, "llm")}
{renderModelSection(
"Embedding Models",
embeddingModels,
@ -110,7 +115,7 @@ const ModelSelection = ({
)}
</>
) : modelType === "llm" ? (
renderModelSection("LLM Models", llmModels, "llm")
renderModelSection("Language Models", llmModels, "llm")
) : (
renderModelSection("Embedding Models", embeddingModels, "embeddings")
)}

View File

@ -40,6 +40,7 @@ const ProviderList = ({
is_enabled: provider.is_enabled,
model_count: matchingModels.length,
models: matchingModels,
api_docs_url: provider.api_docs_url,
};
})
.filter((provider) => provider.model_count > 0);

View File

@ -12,6 +12,7 @@ export type Provider = {
is_enabled: boolean;
model_count?: number;
models?: Model[];
api_docs_url?: string;
};
/** Map of provider -> model_name -> enabled status */

View File

@ -1,4 +1,5 @@
import { Dialog, DialogContent, DialogHeader } from "@/components/ui/dialog";
import { useRefreshModelInputs } from "@/hooks/use-refresh-model-inputs";
import ModelProvidersContent from "./components/ModelProvidersContent";
interface ModelProviderModalProps {
@ -12,8 +13,16 @@ const ModelProviderModal = ({
onClose,
modelType,
}: ModelProviderModalProps) => {
const { refreshAllModelInputs } = useRefreshModelInputs();
const handleClose = () => {
onClose();
// Refresh after a delay to allow pending API operations to complete
// This ensures model toggles are persisted before we fetch updated options
// Using 1000ms to ensure database transactions complete for both LLM and embedding models
setTimeout(() => {
refreshAllModelInputs({ silent: true });
}, 1000);
};
return (

View File

@ -3,7 +3,7 @@ import ModelProvidersContent from "@/modals/modelProviderModal/components/ModelP
export default function ModelProvidersPage() {
return (
<div className="flex h-full w-full flex-col gap-6">
<div className="flex w-full flex-col gap-6">
<div className="flex w-full items-start justify-between gap-6">
<div className="flex flex-col">
<h2
@ -21,7 +21,7 @@ export default function ModelProvidersPage() {
</p>
</div>
</div>
<div className="flex h-full w-full border rounded-lg overflow-hidden">
<div className="flex w-full h-[calc(100vh-305px)] border rounded-lg overflow-hidden">
<ModelProvidersContent modelType="all" />
</div>
</div>

View File

@ -67,6 +67,17 @@ export type APIClassType = {
| Array<{ types: Array<string>; selected?: string }>;
};
export type ModelOptionType = {
name: string;
id?: string;
icon?: string;
provider?: string;
metadata?: {
is_disabled_provider?: boolean;
[key: string]: unknown;
};
};
export type InputFieldType = {
type: string;
required: boolean;

View File

@ -68,7 +68,9 @@ test(
await page.waitForTimeout(300);
try {
// [cmdk-item] targets dropdown options, not the search input
const optionItem = page.locator(`[cmdk-item]:has-text("${fieldName}")`);
const optionItem = page.locator(
`[cmdk-item]:has-text("${fieldName}")`,
);
await optionItem.waitFor({ state: "visible", timeout: 2000 });
await optionItem.click();
return true;

File diff suppressed because one or more lines are too long

View File

@ -62,22 +62,27 @@ def get_model_provider_metadata():
"OpenAI": {
"icon": "OpenAI",
"variable_name": "OPENAI_API_KEY",
"api_docs_url": "https://platform.openai.com/docs/overview",
},
"Anthropic": {
"icon": "Anthropic",
"variable_name": "ANTHROPIC_API_KEY",
"api_docs_url": "https://console.anthropic.com/docs",
},
"Google Generative AI": {
"icon": "GoogleGenerativeAI",
"variable_name": "GOOGLE_API_KEY",
"api_docs_url": "https://aistudio.google.com/app/apikey",
},
"Ollama": {
"icon": "Ollama",
"variable_name": "OLLAMA_BASE_URL", # Ollama is local but can have custom URL
"variable_name": "OLLAMA_BASE_URL",
"api_docs_url": "https://ollama.com/",
},
"IBM WatsonX": {
"icon": "WatsonxAI",
"icon": "IBM",
"variable_name": "WATSONX_APIKEY",
"api_docs_url": "https://www.ibm.com/products/watsonx",
},
}
@ -1019,6 +1024,9 @@ def update_model_options_in_build_config(
time_since_cache = time.time() - component.cache[cache_timestamp_key]
cache_expired = time_since_cache > cache_ttl
# Check if is_refresh flag is set in build_config (from frontend refresh request)
is_refresh_request = build_config.get("is_refresh", False)
# Check if we need to refresh
should_refresh = (
field_name == "api_key" # API key changed
@ -1026,6 +1034,7 @@ def update_model_options_in_build_config(
or field_name == "model" # Model field refresh button clicked
or cache_key not in component.cache # Cache miss
or cache_expired # Cache expired
or is_refresh_request # Frontend requested a refresh
)
if should_refresh:
@ -1045,9 +1054,13 @@ def update_model_options_in_build_config(
cached = component.cache.get(cache_key, {"options": []})
build_config["model"]["options"] = cached["options"]
# Set default value on initial load when field is empty
# Fetch from user's default model setting in the database
if not field_value or field_value == "":
# Set default value on initial load when model field is empty
# Only set default when: initial load (field_name is None) or model field is being set and is empty
# Get the current model value to check if it's empty
current_model_value = build_config.get("model", {}).get("value")
model_is_empty = not current_model_value or current_model_value == "" or current_model_value == []
should_set_default = field_name is None or (field_name == "model" and model_is_empty)
if should_set_default:
options = cached.get("options", [])
if options:
# Determine model type based on cache_key_prefix

View File

@ -4,7 +4,7 @@ WATSONX_DEFAULT_LLM_MODELS = [
create_model_metadata(
provider="IBM WatsonX",
name="ibm/granite-3-2b-instruct",
icon="WatsonxAI",
icon="IBM",
model_type="llm",
tool_calling=True,
default=True,
@ -12,7 +12,7 @@ WATSONX_DEFAULT_LLM_MODELS = [
create_model_metadata(
provider="IBM WatsonX",
name="ibm/granite-3-8b-instruct",
icon="WatsonxAI",
icon="IBM",
model_type="llm",
tool_calling=True,
default=True,
@ -20,7 +20,7 @@ WATSONX_DEFAULT_LLM_MODELS = [
create_model_metadata(
provider="IBM WatsonX",
name="ibm/granite-13b-instruct-v2",
icon="WatsonxAI",
icon="IBM",
model_type="llm",
tool_calling=True,
default=True,
@ -31,7 +31,7 @@ WATSONX_DEFAULT_EMBEDDING_MODELS = [
create_model_metadata(
provider="IBM WatsonX",
name="sentence-transformers/all-minilm-l12-v2",
icon="WatsonxAI",
icon="IBM",
model_type="embeddings",
tool_calling=True,
default=True,
@ -39,7 +39,7 @@ WATSONX_DEFAULT_EMBEDDING_MODELS = [
create_model_metadata(
provider="IBM WatsonX",
name="ibm/slate-125m-english-rtrvr-v2",
icon="WatsonxAI",
icon="IBM",
model_type="embeddings",
tool_calling=True,
default=True,
@ -47,7 +47,7 @@ WATSONX_DEFAULT_EMBEDDING_MODELS = [
create_model_metadata(
provider="IBM WatsonX",
name="ibm/slate-30m-english-rtrvr-v2",
icon="WatsonxAI",
icon="IBM",
model_type="embeddings",
tool_calling=True,
default=True,
@ -55,7 +55,7 @@ WATSONX_DEFAULT_EMBEDDING_MODELS = [
create_model_metadata(
provider="IBM WatsonX",
name="intfloat/multilingual-e5-large",
icon="WatsonxAI",
icon="IBM",
model_type="embeddings",
tool_calling=True,
default=True,

View File

@ -116,8 +116,10 @@ class LanguageModelComponent(LCModelComponent):
)
# Show/hide provider-specific fields based on selected model
if field_name == "model" and isinstance(field_value, list) and len(field_value) > 0:
selected_model = field_value[0]
# Get current model value - from field_value if model is being changed, otherwise from build_config
current_model_value = field_value if field_name == "model" else build_config.get("model", {}).get("value")
if isinstance(current_model_value, list) and len(current_model_value) > 0:
selected_model = current_model_value[0]
provider = selected_model.get("provider", "")
# Show/hide watsonx fields