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