fix(cloud): Mark cloud-hostile components and consolidate cloud metadata utils

Mark git, google, and homeassistant components as cloud_compatible=False
since they require local filesystem, credentials files, or local network
access. Consolidate duplicated cloud metadata helpers into cloudMetadataUtils
and fix the nodeType resolution to use the explicit prop instead of falling
back to nodeClass.type. Fix model input empty state to distinguish disabled
models from cloud-filtered models.
This commit is contained in:
Jordan Frazier
2026-03-27 10:08:59 -04:00
parent fa9118742d
commit 79eb29946b
17 changed files with 314 additions and 169 deletions

View File

@ -201,10 +201,10 @@ The **Write File** component's "Local" storage option is hidden from the Sortabl
| Directory | Component | Notes |
|-----------|-----------|-------|
| `google` | Gmail Loader, Google Drive Search, Google Drive, Google OAuth Token | Legacy; require local credentials files |
| `google` | Gmail Loader, Google Drive Loader, Google Drive Search, Google OAuth Token | Legacy; require local credentials files |
| `deactivated` | Various | Deprecated components; should not be used |
| `homeassistant` | Home Assistant Control, List States | Requires local network Home Assistant instance (`192.168.x.x`) |
| `git` | Git Loader, Git Extractor | Clones repos to local temp dirs; requires `git` CLI |
| `homeassistant` | Home Assistant Control, List Home Assistant States | Requires local network Home Assistant instance (`192.168.x.x`) |
| `git` | Git, GitExtractor | Clones repos to local temp dirs; requires `git` CLI |
> **Note on legacy Google components:** These are marked `legacy = True` with replacements suggested. They require local credentials JSON files which won't work on ephemeral pods. They're already hidden when "Show Legacy" is off.

View File

@ -75,9 +75,12 @@ jest.mock("@/controllers/API/queries/models/use-get-model-providers", () => ({
})),
}));
let mockEnabledModelsData: { enabled_models: Record<string, Record<string, boolean>> } =
{ enabled_models: {} };
jest.mock("@/controllers/API/queries/models/use-get-enabled-models", () => ({
useGetEnabledModels: jest.fn(() => ({
data: { enabled_models: {} },
data: mockEnabledModelsData,
isLoading: false,
})),
}));
@ -185,6 +188,8 @@ const renderWithQueryClient = (component: React.ReactElement) => {
describe("ModelInputComponent", () => {
beforeEach(() => {
jest.clearAllMocks();
mockCloudOnly = false;
mockEnabledModelsData = { enabled_models: {} };
});
describe("Rendering", () => {
@ -560,6 +565,39 @@ describe("ModelInputComponent", () => {
mockCloudOnly = false;
});
it("should keep the generic empty state when models are disabled instead of cloud-filtered", () => {
mockCloudOnly = true;
mockEnabledModelsData = {
enabled_models: {
OpenAI: {
"gpt-4": false,
},
},
};
renderWithQueryClient(
<ModelInputComponent
{...defaultProps}
options={[
{
id: "gpt-4",
name: "gpt-4",
icon: "Bot",
provider: "OpenAI",
metadata: {},
},
]}
value={[]}
showEmptyState={true}
/>,
);
expect(screen.getByText("No models enabled")).toBeInTheDocument();
expect(
screen.queryByText("No cloud-compatible models"),
).not.toBeInTheDocument();
});
it("should show all providers when cloud mode is inactive", () => {
mockCloudOnly = false;

View File

@ -76,29 +76,32 @@ export default function ModelInputComponent({
const cloudOnly = useCloudModeStore((state) => state.cloudOnly);
const enabledOptions = useMemo(
() =>
options.filter((option) => {
if (option.metadata?.is_disabled_provider) {
return false;
}
const provider = option.provider || "Unknown";
const providerModels = enabledModelsData?.enabled_models?.[provider];
return providerModels?.[option.name] !== false;
}),
[enabledModelsData?.enabled_models, options],
);
// Groups models by their provider name for sectioned display in dropdown.
// Filters out models from disabled providers, disabled models,
// and cloud-incompatible providers when cloud mode is active.
// Filters out cloud-incompatible providers when cloud mode is active.
const groupedOptions = useMemo(() => {
const grouped: Record<string, ModelOption[]> = {};
for (const option of options) {
if (option.metadata?.is_disabled_provider) continue;
for (const option of enabledOptions) {
const provider = option.provider || "Unknown";
// Filter out cloud-incompatible providers when cloud mode is active
if (cloudOnly && CLOUD_INCOMPATIBLE_PROVIDERS.has(provider)) {
continue;
}
// 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
}
}
if (!grouped[provider]) {
grouped[provider] = [];
}
@ -106,7 +109,7 @@ export default function ModelInputComponent({
grouped[provider].push(option);
}
return grouped;
}, [options, enabledModelsData, cloudOnly]);
}, [enabledOptions, cloudOnly]);
// Flattened array of all enabled options for efficient lookups by name
const flatOptions = useMemo(
@ -162,13 +165,30 @@ export default function ModelInputComponent({
[cloudOnly, selectedModel?.provider],
);
const cloudFilteredOptionCount = useMemo(
() =>
cloudOnly
? enabledOptions.filter((option) =>
CLOUD_INCOMPATIBLE_PROVIDERS.has(option.provider || "Unknown"),
).length
: 0,
[cloudOnly, enabledOptions],
);
const showNoCompatibleCloudModels = useMemo(
() =>
cloudOnly &&
options.length > 0 &&
enabledOptions.length > 0 &&
flatOptions.length === 0 &&
cloudFilteredOptionCount > 0 &&
!selectedModel,
[cloudOnly, options.length, flatOptions.length, selectedModel],
[
cloudFilteredOptionCount,
cloudOnly,
enabledOptions.length,
flatOptions.length,
selectedModel,
],
);
const effectiveShowEmptyState = showEmptyState || showNoCompatibleCloudModels;

View File

@ -4,6 +4,7 @@ import { ReactSortable } from "react-sortablejs";
import ListSelectionComponent from "@/CustomNodes/GenericNode/components/ListSelectionComponent";
import ForwardedIconComponent from "@/components/common/genericIconComponent";
import { Button } from "@/components/ui/button";
import { isCloudIncompatibleOption as isCloudIncompatibleListOption } from "@/utils/cloudMetadataUtils";
import { cn } from "@/utils/utils";
import type { InputProps } from "../../types";
import HelperTextComponent from "../helperTextComponent";
@ -113,14 +114,9 @@ const SortableListComponent = ({
// Convert value to an array if it exists, otherwise use empty array
const listData = useMemo(() => (Array.isArray(value) ? value : []), [value]);
const isCloudIncompatibleOption = useCallback(
const isCloudIncompatibleSelection = useCallback(
(item: unknown) => {
const itemName =
typeof item === "object" && item !== null && "name" in item
? ((item as { name?: unknown }).name ?? item)
: item;
return cloudIncompatibleOptions.some((option) => option === itemName);
return isCloudIncompatibleListOption(item, cloudIncompatibleOptions);
},
[cloudIncompatibleOptions],
);
@ -224,7 +220,7 @@ const SortableListComponent = ({
index={index}
onRemove={createRemoveHandler(index)}
limit={limit}
showCloudIncompatibleWarning={isCloudIncompatibleOption(data)}
showCloudIncompatibleWarning={isCloudIncompatibleSelection(data)}
/>
))}
</ReactSortable>

View File

@ -12,7 +12,13 @@ import { ENABLE_INSPECTION_PANEL } from "@/customization/feature-flags";
import { useCloudModeStore } from "@/stores/cloudModeStore";
import { useTypesStore } from "@/stores/typesStore";
import type { APIClassType, InputFieldType } from "@/types/api";
import { withCurrentCloudMetadata } from "@/utils/cloudMetadataUtils";
import {
filterCloudCompatibleOptions,
getCloudFieldOverride,
getCloudIncompatibleOptions,
getCloudUiMetadata,
withCurrentCloudMetadata,
} from "@/utils/cloudMetadataUtils";
import AccordionPromptComponent from "./components/accordionPromptComponent";
import DictComponent from "./components/dictComponent";
import { EmptyParameterComponent } from "./components/emptyParameterComponent";
@ -69,13 +75,12 @@ export function ParameterRenderComponent({
const cloudOnly = useCloudModeStore((state) => state.cloudOnly);
const templates = useTypesStore((state) => state.templates);
const resolvedNodeType =
nodeType ??
(typeof nodeClass?.type === "string" ? nodeClass.type : undefined);
const effectiveNodeClass =
cloudOnly && resolvedNodeType
? (withCurrentCloudMetadata(nodeClass, templates[resolvedNodeType]) ??
nodeClass)
cloudOnly && nodeType
? (withCurrentCloudMetadata(
nodeClass,
templates[nodeType] as APIClassType | undefined,
) ?? nodeClass)
: nodeClass;
const id = (
@ -85,15 +90,7 @@ export function ParameterRenderComponent({
templateData.name
).toLowerCase();
const nodeMetadata = effectiveNodeClass?.metadata as
| {
cloud_default_overrides?: Record<
string,
{ value?: unknown; placeholder?: string }
>;
cloud_incompatible_options?: Record<string, unknown[]>;
}
| undefined;
const nodeMetadata = getCloudUiMetadata(effectiveNodeClass?.metadata);
const shouldUseCloudPlaceholder =
cloudOnly &&
@ -102,7 +99,7 @@ export function ParameterRenderComponent({
templateValue === null);
const cloudOverride = shouldUseCloudPlaceholder
? nodeMetadata?.cloud_default_overrides?.[name]
? getCloudFieldOverride(nodeMetadata, name)
: undefined;
const renderComponent = (): React.ReactElement<InputProps> => {
@ -311,21 +308,16 @@ export function ParameterRenderComponent({
}
case "sortableList": {
// Filter out cloud-incompatible options when cloud mode is active
let sortableOptions = templateData?.options;
let cloudIncompatibleOptions: unknown[] = [];
if (cloudOnly && nodeMetadata?.cloud_incompatible_options) {
const incompatible = nodeMetadata.cloud_incompatible_options[name];
if (incompatible && Array.isArray(incompatible)) {
cloudIncompatibleOptions = incompatible;
sortableOptions = sortableOptions?.filter((opt: unknown) => {
const optionName =
typeof opt === "object" && opt !== null && "name" in opt
? ((opt as { name?: unknown }).name ?? opt)
: opt;
return !incompatible.includes(optionName);
});
}
}
const cloudIncompatibleOptions = cloudOnly
? getCloudIncompatibleOptions(nodeMetadata, name)
: [];
const sortableOptions =
cloudOnly && cloudIncompatibleOptions.length > 0
? filterCloudCompatibleOptions(
templateData?.options,
cloudIncompatibleOptions,
)
: templateData?.options;
return (
<SortableListComponent
{...baseInputProps}

View File

@ -7,77 +7,14 @@ import { useCloudModeStore } from "@/stores/cloudModeStore";
import useFlowStore from "@/stores/flowStore";
import type { APIClassType } from "@/types/api";
import type { AllNodeType } from "@/types/flow";
import {
applyCloudDefaultOverrides,
getCloudUiMetadata,
sanitizeCloudIncompatibleDefaults,
} from "@/utils/cloudMetadataUtils";
import { getNodeId } from "@/utils/reactflowUtils";
import { getNodeRenderType } from "@/utils/utils";
type CloudFieldOverride = {
value?: unknown;
placeholder?: string;
};
type CloudComponentMetadata = {
cloud_default_overrides?: Record<string, CloudFieldOverride>;
cloud_incompatible_options?: Record<string, unknown[]>;
};
const getOptionName = (option: unknown) => {
if (typeof option === "object" && option !== null && "name" in option) {
return (option as { name?: unknown }).name;
}
return option;
};
const sanitizeCloudIncompatibleDefaults = (
component: APIClassType,
cloudIncompatibleOptions?: Record<string, unknown[]>,
) => {
if (!cloudIncompatibleOptions) {
return;
}
Object.entries(cloudIncompatibleOptions).forEach(
([fieldName, incompatibleOptions]) => {
if (!Array.isArray(incompatibleOptions)) {
return;
}
const templateField = component.template?.[fieldName];
if (!templateField) {
return;
}
const selectedOptions = Array.isArray(templateField.value)
? templateField.value
: templateField.value
? [templateField.value]
: [];
const filteredSelections = selectedOptions.filter(
(selection) => !incompatibleOptions.includes(getOptionName(selection)),
);
if (filteredSelections.length > 0) {
templateField.value = filteredSelections;
return;
}
if (templateField.limit !== 1 || !Array.isArray(templateField.options)) {
templateField.value = filteredSelections;
return;
}
const firstCompatibleOption = templateField.options.find(
(option) => !incompatibleOptions.includes(getOptionName(option)),
);
templateField.value = firstCompatibleOption
? [cloneDeep(firstCompatibleOption)]
: [];
},
);
};
export function useAddComponent() {
const store = useStoreApi();
const paste = useFlowStore((state) => state.paste);
@ -129,9 +66,7 @@ export function useAddComponent() {
(output) => outputType && output.types.includes(outputType),
);
const componentMetadata = component.metadata as
| CloudComponentMetadata
| undefined;
const componentMetadata = getCloudUiMetadata(component.metadata);
const cloudDefaultOverrides = componentMetadata?.cloud_default_overrides;
const cloudIncompatibleOptions =
@ -142,26 +77,10 @@ export function useAddComponent() {
? (() => {
const clonedComponent = cloneDeep(component);
if (cloudDefaultOverrides) {
Object.entries(cloudDefaultOverrides).forEach(
([fieldName, override]) => {
if (!clonedComponent.template?.[fieldName]) {
return;
}
if (Object.hasOwn(override, "value")) {
clonedComponent.template[fieldName].value =
override.value;
}
if (override.placeholder !== undefined) {
clonedComponent.template[fieldName].placeholder =
override.placeholder;
}
},
);
}
applyCloudDefaultOverrides(
clonedComponent,
cloudDefaultOverrides,
);
sanitizeCloudIncompatibleDefaults(
clonedComponent,
cloudIncompatibleOptions,

View File

@ -1,11 +1,12 @@
import { cloneDeep } from "lodash";
import type { APIClassType } from "@/types/api";
type CloudFieldOverride = {
export type CloudFieldOverride = {
value?: unknown;
placeholder?: string;
};
type CloudUiMetadata = Record<string, unknown> & {
export type CloudUiMetadata = Record<string, unknown> & {
cloud_default_overrides?: Record<string, CloudFieldOverride>;
cloud_incompatible_options?: Record<string, unknown[]>;
};
@ -14,6 +15,132 @@ function isCloudUiMetadata(value: unknown): value is CloudUiMetadata {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function getCloudUiMetadata(value: unknown): CloudUiMetadata | undefined {
return isCloudUiMetadata(value) ? value : undefined;
}
export function getCloudOptionName(option: unknown): unknown {
if (typeof option === "object" && option !== null && "name" in option) {
return (option as { name?: unknown }).name ?? option;
}
return option;
}
export function isCloudIncompatibleOption(
option: unknown,
incompatibleOptions: readonly unknown[] = [],
): boolean {
return incompatibleOptions.some(
(incompatibleOption) =>
incompatibleOption === getCloudOptionName(option),
);
}
export function filterCloudCompatibleOptions<T>(
options: T[] | undefined,
incompatibleOptions: readonly unknown[] = [],
): T[] | undefined {
if (!options || incompatibleOptions.length === 0) {
return options;
}
return options.filter(
(option) => !isCloudIncompatibleOption(option, incompatibleOptions),
);
}
export function getCloudFieldOverride(
metadata: CloudUiMetadata | undefined,
fieldName: string,
): CloudFieldOverride | undefined {
return metadata?.cloud_default_overrides?.[fieldName];
}
export function getCloudIncompatibleOptions(
metadata: CloudUiMetadata | undefined,
fieldName: string,
): unknown[] {
const incompatibleOptions = metadata?.cloud_incompatible_options?.[fieldName];
return Array.isArray(incompatibleOptions) ? incompatibleOptions : [];
}
export function applyCloudDefaultOverrides(
component: APIClassType,
cloudDefaultOverrides?: Record<string, CloudFieldOverride>,
): void {
if (!cloudDefaultOverrides) {
return;
}
Object.entries(cloudDefaultOverrides).forEach(([fieldName, override]) => {
if (!component.template?.[fieldName]) {
return;
}
if (Object.hasOwn(override, "value")) {
component.template[fieldName].value = override.value;
}
if (override.placeholder !== undefined) {
component.template[fieldName].placeholder = override.placeholder;
}
});
}
export function sanitizeCloudIncompatibleDefaults(
component: APIClassType,
cloudIncompatibleOptions?: Record<string, unknown[]>,
): void {
if (!cloudIncompatibleOptions) {
return;
}
Object.entries(cloudIncompatibleOptions).forEach(
([fieldName, incompatibleOptions]) => {
if (!Array.isArray(incompatibleOptions)) {
return;
}
const templateField = component.template?.[fieldName];
if (!templateField) {
return;
}
const selectedOptions = Array.isArray(templateField.value)
? templateField.value
: templateField.value
? [templateField.value]
: [];
const filteredSelections = selectedOptions.filter(
(selection) =>
!isCloudIncompatibleOption(selection, incompatibleOptions),
);
if (filteredSelections.length > 0) {
templateField.value = filteredSelections;
return;
}
if (templateField.limit !== 1 || !Array.isArray(templateField.options)) {
templateField.value = filteredSelections;
return;
}
const compatibleOptions = filterCloudCompatibleOptions(
templateField.options,
incompatibleOptions,
);
const firstCompatibleOption = compatibleOptions?.[0];
templateField.value = firstCompatibleOption
? [cloneDeep(firstCompatibleOption)]
: [];
},
);
}
export function withCurrentCloudMetadata(
savedNode: APIClassType | undefined,
currentCatalogNode: APIClassType | undefined,
@ -22,12 +149,8 @@ export function withCurrentCloudMetadata(
return savedNode;
}
const savedMetadata = isCloudUiMetadata(savedNode.metadata)
? savedNode.metadata
: undefined;
const currentMetadata = isCloudUiMetadata(currentCatalogNode.metadata)
? currentCatalogNode.metadata
: undefined;
const savedMetadata = getCloudUiMetadata(savedNode.metadata);
const currentMetadata = getCloudUiMetadata(currentCatalogNode.metadata);
const shouldOverlayCloudCompatible =
savedNode.cloud_compatible === undefined &&

View File

@ -73016,7 +73016,7 @@
"Message"
],
"beta": false,
"cloud_compatible": true,
"cloud_compatible": false,
"conditional_paths": [],
"custom_fields": {},
"description": "Analyzes a Git repository and returns file contents and complete repository information",
@ -73178,7 +73178,7 @@
"JSON"
],
"beta": false,
"cloud_compatible": true,
"cloud_compatible": false,
"conditional_paths": [],
"custom_fields": {},
"description": "Load and filter documents from a local or remote Git repository. Use a local repo path or clone from a remote URL.",
@ -73775,7 +73775,7 @@
"JSON"
],
"beta": false,
"cloud_compatible": true,
"cloud_compatible": false,
"conditional_paths": [],
"custom_fields": {},
"description": "Loads emails from Gmail using provided credentials.",
@ -74068,7 +74068,7 @@
"JSON"
],
"beta": false,
"cloud_compatible": true,
"cloud_compatible": false,
"conditional_paths": [],
"custom_fields": {},
"description": "Loads documents from Google Drive using provided credentials.",
@ -74195,7 +74195,7 @@
"Text"
],
"beta": false,
"cloud_compatible": true,
"cloud_compatible": false,
"conditional_paths": [],
"custom_fields": {},
"description": "Searches Google Drive files using provided credentials and query parameters.",
@ -74850,7 +74850,7 @@
"JSON"
],
"beta": false,
"cloud_compatible": true,
"cloud_compatible": false,
"conditional_paths": [],
"custom_fields": {},
"description": "Generates a JSON string with your Google OAuth token.",
@ -75661,7 +75661,7 @@
"Tool"
],
"beta": false,
"cloud_compatible": true,
"cloud_compatible": false,
"conditional_paths": [],
"custom_fields": {},
"description": "A very simple tool to control Home Assistant devices. Only action (turn_on, turn_off, toggle) and entity_id need to be provided.",
@ -75846,7 +75846,7 @@
"Tool"
],
"beta": false,
"cloud_compatible": true,
"cloud_compatible": false,
"conditional_paths": [],
"custom_fields": {},
"description": "Retrieve states from Home Assistant. The agent only needs to specify 'filter_domain' (optional). Token and base_url are not exposed to the agent.",
@ -118918,4 +118918,4 @@
},
"sha256": "ea05564a639d360e38fa4f323deadb21b41a6b44535408c501ca16048fb54afe",
"version": "0.3.1"
}
}

View File

@ -20,6 +20,7 @@ class GitLoaderComponent(Component):
)
trace_type = "tool"
icon = "GitLoader"
cloud_compatible = False
inputs = [
DropdownInput(

View File

@ -17,6 +17,7 @@ class GitExtractorComponent(Component):
display_name = "GitExtractor"
description = "Analyzes a Git repository and returns file contents and complete repository information"
icon = "GitLoader"
cloud_compatible = False
inputs = [
MessageTextInput(

View File

@ -26,6 +26,7 @@ class GmailLoaderComponent(Component):
icon = "Google"
legacy: bool = True
replacement = ["composio.ComposioGmailAPIComponent"]
cloud_compatible = False
inputs = [
SecretStrInput(

View File

@ -18,6 +18,7 @@ class GoogleDriveComponent(Component):
description = "Loads documents from Google Drive using provided credentials."
icon = "Google"
legacy: bool = True
cloud_compatible = False
inputs = [
SecretStrInput(

View File

@ -15,6 +15,7 @@ class GoogleDriveSearchComponent(Component):
description = "Searches Google Drive files using provided credentials and query parameters."
icon = "Google"
legacy: bool = True
cloud_compatible = False
inputs = [
SecretStrInput(

View File

@ -18,6 +18,7 @@ class GoogleOAuthToken(Component):
icon = "Google"
name = "GoogleOAuthToken"
legacy: bool = True
cloud_compatible = False
inputs = [
MultilineInput(
name="scopes",

View File

@ -26,6 +26,7 @@ class HomeAssistantControl(LCToolComponent):
)
documentation: str = "https://developers.home-assistant.io/docs/api/rest/"
icon: str = "HomeAssistant"
cloud_compatible = False
# --- Input fields for LangFlow UI (token, URL) ---
inputs = [

View File

@ -20,6 +20,7 @@ class ListHomeAssistantStates(LCToolComponent):
)
documentation: str = "https://developers.home-assistant.io/docs/api/rest/"
icon = "HomeAssistant"
cloud_compatible = False
# 1) Define fields to be received in LangFlow UI
inputs = [

View File

@ -1,5 +1,9 @@
"""Tests for cloud_compatible component attribute."""
import json
from pathlib import Path
import pytest
from lfx.custom.attributes import ATTR_FUNC_MAPPING, getattr_return_bool
from lfx.template.frontend_node.base import FrontendNode
@ -55,3 +59,48 @@ class TestCloudCompatibleAttribute:
assert getattr_return_bool(cloud_false) is False
assert getattr_return_bool(None) is None
assert getattr_return_bool("not a bool") is None
@pytest.mark.parametrize(
"display_name",
[
"Git",
"GitExtractor",
"Gmail Loader",
"Google Drive Loader",
"Google Drive Search",
"Google OAuth Token",
"Home Assistant Control",
"List Home Assistant States",
],
)
def test_audited_components_are_cloud_incompatible_in_component_index(
self,
display_name,
):
"""Audited cloud-hostile components should ship as incompatible in the production index."""
component_index_path = (
Path(__file__).resolve().parents[3]
/ "src"
/ "lfx"
/ "_assets"
/ "component_index.json"
)
with component_index_path.open(encoding="utf-8") as index_file:
component_index = json.load(index_file)
matching_component = None
for _entry_category, components in component_index["entries"]:
matching_component = next(
(
component
for component in components.values()
if component.get("display_name") == display_name
),
None,
)
if matching_component:
break
assert matching_component is not None
assert matching_component["cloud_compatible"] is False