From 79eb29946b40920a36d3bb0e005e83a8500b7462 Mon Sep 17 00:00:00 2001 From: Jordan Frazier Date: Fri, 27 Mar 2026 10:08:59 -0400 Subject: [PATCH] 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. --- docs/cloud-compatibility-analysis.md | 6 +- .../__tests__/ModelInputComponent.test.tsx | 40 ++++- .../components/modelInputComponent/index.tsx | 54 ++++--- .../sortableListComponent/index.tsx | 12 +- .../core/parameterRenderComponent/index.tsx | 56 +++---- src/frontend/src/hooks/use-add-component.ts | 101 ++----------- src/frontend/src/utils/cloudMetadataUtils.ts | 139 +++++++++++++++++- src/lfx/src/lfx/_assets/component_index.json | 18 +-- src/lfx/src/lfx/components/git/git.py | 1 + .../src/lfx/components/git/gitextractor.py | 1 + src/lfx/src/lfx/components/google/gmail.py | 1 + .../src/lfx/components/google/google_drive.py | 1 + .../components/google/google_drive_search.py | 1 + .../components/google/google_oauth_token.py | 1 + .../homeassistant/home_assistant_control.py | 1 + .../list_home_assistant_states.py | 1 + .../custom/test_cloud_compatible_attribute.py | 49 ++++++ 17 files changed, 314 insertions(+), 169 deletions(-) diff --git a/docs/cloud-compatibility-analysis.md b/docs/cloud-compatibility-analysis.md index 3ac3a7d315..32ec5a4445 100644 --- a/docs/cloud-compatibility-analysis.md +++ b/docs/cloud-compatibility-analysis.md @@ -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. 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 6e6d276145..4a1445245f 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 @@ -75,9 +75,12 @@ jest.mock("@/controllers/API/queries/models/use-get-model-providers", () => ({ })), })); +let mockEnabledModelsData: { enabled_models: Record> } = + { 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( + , + ); + + 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; 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 d2781e07ba..63e9e47745 100644 --- a/src/frontend/src/components/core/parameterRenderComponent/components/modelInputComponent/index.tsx +++ b/src/frontend/src/components/core/parameterRenderComponent/components/modelInputComponent/index.tsx @@ -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 = {}; - 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; diff --git a/src/frontend/src/components/core/parameterRenderComponent/components/sortableListComponent/index.tsx b/src/frontend/src/components/core/parameterRenderComponent/components/sortableListComponent/index.tsx index 99fd6722f4..292793d486 100644 --- a/src/frontend/src/components/core/parameterRenderComponent/components/sortableListComponent/index.tsx +++ b/src/frontend/src/components/core/parameterRenderComponent/components/sortableListComponent/index.tsx @@ -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)} /> ))} diff --git a/src/frontend/src/components/core/parameterRenderComponent/index.tsx b/src/frontend/src/components/core/parameterRenderComponent/index.tsx index 68d6d26916..81b5a3cb9e 100644 --- a/src/frontend/src/components/core/parameterRenderComponent/index.tsx +++ b/src/frontend/src/components/core/parameterRenderComponent/index.tsx @@ -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; - } - | 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 => { @@ -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 ( ; - cloud_incompatible_options?: Record; -}; - -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, -) => { - 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, diff --git a/src/frontend/src/utils/cloudMetadataUtils.ts b/src/frontend/src/utils/cloudMetadataUtils.ts index 91b2ead9d3..cad9a600ef 100644 --- a/src/frontend/src/utils/cloudMetadataUtils.ts +++ b/src/frontend/src/utils/cloudMetadataUtils.ts @@ -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 & { +export type CloudUiMetadata = Record & { cloud_default_overrides?: Record; cloud_incompatible_options?: Record; }; @@ -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( + 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, +): 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, +): 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 && diff --git a/src/lfx/src/lfx/_assets/component_index.json b/src/lfx/src/lfx/_assets/component_index.json index a399f80f11..681145ff66 100644 --- a/src/lfx/src/lfx/_assets/component_index.json +++ b/src/lfx/src/lfx/_assets/component_index.json @@ -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" -} \ No newline at end of file +} diff --git a/src/lfx/src/lfx/components/git/git.py b/src/lfx/src/lfx/components/git/git.py index 778be7ea6b..021d83266b 100644 --- a/src/lfx/src/lfx/components/git/git.py +++ b/src/lfx/src/lfx/components/git/git.py @@ -20,6 +20,7 @@ class GitLoaderComponent(Component): ) trace_type = "tool" icon = "GitLoader" + cloud_compatible = False inputs = [ DropdownInput( diff --git a/src/lfx/src/lfx/components/git/gitextractor.py b/src/lfx/src/lfx/components/git/gitextractor.py index 830257daa7..13cec485c3 100644 --- a/src/lfx/src/lfx/components/git/gitextractor.py +++ b/src/lfx/src/lfx/components/git/gitextractor.py @@ -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( diff --git a/src/lfx/src/lfx/components/google/gmail.py b/src/lfx/src/lfx/components/google/gmail.py index adc9708bf4..d6fec1469f 100644 --- a/src/lfx/src/lfx/components/google/gmail.py +++ b/src/lfx/src/lfx/components/google/gmail.py @@ -26,6 +26,7 @@ class GmailLoaderComponent(Component): icon = "Google" legacy: bool = True replacement = ["composio.ComposioGmailAPIComponent"] + cloud_compatible = False inputs = [ SecretStrInput( diff --git a/src/lfx/src/lfx/components/google/google_drive.py b/src/lfx/src/lfx/components/google/google_drive.py index 339e436be4..4bea24148a 100644 --- a/src/lfx/src/lfx/components/google/google_drive.py +++ b/src/lfx/src/lfx/components/google/google_drive.py @@ -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( diff --git a/src/lfx/src/lfx/components/google/google_drive_search.py b/src/lfx/src/lfx/components/google/google_drive_search.py index a622223c35..a9ee6eb96a 100644 --- a/src/lfx/src/lfx/components/google/google_drive_search.py +++ b/src/lfx/src/lfx/components/google/google_drive_search.py @@ -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( diff --git a/src/lfx/src/lfx/components/google/google_oauth_token.py b/src/lfx/src/lfx/components/google/google_oauth_token.py index 33578f030b..a0407a4812 100644 --- a/src/lfx/src/lfx/components/google/google_oauth_token.py +++ b/src/lfx/src/lfx/components/google/google_oauth_token.py @@ -18,6 +18,7 @@ class GoogleOAuthToken(Component): icon = "Google" name = "GoogleOAuthToken" legacy: bool = True + cloud_compatible = False inputs = [ MultilineInput( name="scopes", diff --git a/src/lfx/src/lfx/components/homeassistant/home_assistant_control.py b/src/lfx/src/lfx/components/homeassistant/home_assistant_control.py index f8505f6b22..596830c8f3 100644 --- a/src/lfx/src/lfx/components/homeassistant/home_assistant_control.py +++ b/src/lfx/src/lfx/components/homeassistant/home_assistant_control.py @@ -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 = [ diff --git a/src/lfx/src/lfx/components/homeassistant/list_home_assistant_states.py b/src/lfx/src/lfx/components/homeassistant/list_home_assistant_states.py index 814f953ba5..34119882d9 100644 --- a/src/lfx/src/lfx/components/homeassistant/list_home_assistant_states.py +++ b/src/lfx/src/lfx/components/homeassistant/list_home_assistant_states.py @@ -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 = [ diff --git a/src/lfx/tests/unit/custom/test_cloud_compatible_attribute.py b/src/lfx/tests/unit/custom/test_cloud_compatible_attribute.py index 323a0b7943..fe7f94acdd 100644 --- a/src/lfx/tests/unit/custom/test_cloud_compatible_attribute.py +++ b/src/lfx/tests/unit/custom/test_cloud_compatible_attribute.py @@ -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