diff --git a/src/backend/base/langflow/api/v1/models.py b/src/backend/base/langflow/api/v1/models.py index f51890f499..05c0997ab0 100644 --- a/src/backend/base/langflow/api/v1/models.py +++ b/src/backend/base/langflow/api/v1/models.py @@ -199,8 +199,9 @@ async def get_enabled_providers( ): """Get enabled providers for the current user. - Only providers with valid API keys are marked as enabled. This prevents - providers from appearing enabled when they have invalid credentials. + Providers are considered enabled if they have a credential variable stored. + API key validation is performed when credentials are saved, not on every read, + to avoid latency from external API calls. """ variable_service = get_variable_service() try: @@ -225,35 +226,12 @@ async def get_enabled_providers( # Get the provider-variable mapping provider_variable_map = get_model_provider_variable_mapping() - # Build credential_variables dict with objects that have encrypted values - # VariableRead sets value=None for CREDENTIAL_TYPE (via validator), but _validate_and_get_enabled_providers - # needs the encrypted value to decrypt and validate. So we create simple objects with the encrypted value. - credential_variables = {} + # Check which providers have credentials stored (no validation - that happens on save) + enabled_providers_set = set() + for provider, var_name in provider_variable_map.items(): + if var_name in credential_variable_names: + enabled_providers_set.add(provider) - for var_name in credential_variable_names: - if var_name and var_name in provider_variable_map.values(): - try: - # Get the raw Variable object to access the encrypted value - variable_obj = await variable_service.get_variable_object( - user_id=current_user.id, name=var_name, session=session - ) - if variable_obj and variable_obj.value: - # Create a simple object with the encrypted value - # _validate_and_get_enabled_providers only needs .value attribute - class VarWithValue: - def __init__(self, value): - self.value = value - - credential_variables[var_name] = VarWithValue(variable_obj.value) - except (ValueError, Exception) as e: # noqa: BLE001 - # Variable not found or error accessing it - skip - logger.debug("Skipping variable %s due to error: %s", var_name, e) - continue - - # Use shared helper to validate and get enabled providers - from lfx.base.models.unified_models import _validate_and_get_enabled_providers - - enabled_providers_set = _validate_and_get_enabled_providers(credential_variables, provider_variable_map) enabled_providers = list(enabled_providers_set) # Build provider_status dict for all providers diff --git a/src/backend/base/langflow/api/v1/variable.py b/src/backend/base/langflow/api/v1/variable.py index ddbf3f52d9..942f423fed 100644 --- a/src/backend/base/langflow/api/v1/variable.py +++ b/src/backend/base/langflow/api/v1/variable.py @@ -14,7 +14,6 @@ from langflow.api.v1.models import ( get_model_names_for_provider, get_provider_from_variable_name, ) -from langflow.services.auth import utils as auth_utils from langflow.services.database.models.variable.model import VariableCreate, VariableRead, VariableUpdate from langflow.services.deps import get_variable_service from langflow.services.variable.constants import CREDENTIAL_TYPE, GENERIC_TYPE @@ -151,15 +150,10 @@ async def read_variables( ): """Read all variables. - Model provider credentials are validated when reading from the database. - If a provider key is invalid, its default_fields are cleared to prevent - the provider from appearing enabled. + Model provider credentials are validated when they are created or updated, + not on every read. This avoids latency from external API calls on read operations. - Each variable in the response includes: - - is_valid: bool | None - True if valid, False if invalid, None if not a provider credential - - validation_error: str | None - Error message if validation failed - - Returns a list of variables with validation status for model provider credentials. + Returns a list of variables. """ variable_service = get_variable_service() if not isinstance(variable_service, DatabaseVariableService): @@ -173,113 +167,18 @@ async def read_variables( var for var in all_variables if not (var.name and var.name.startswith("__") and var.name.endswith("__")) ] - # Validate model provider credentials and clear default_fields if invalid - # Build dict of credential variables for validation - credential_variables = {var.name: var for var in filtered_variables if var.type == CREDENTIAL_TYPE} - provider_variable_map = get_model_provider_variable_mapping() - - # Create reverse mapping: variable_name -> provider - var_to_provider = {var_name: provider for provider, var_name in provider_variable_map.items()} - - # Validate each provider credential once and capture both enabled status and error messages - validation_results: dict[ - str, tuple[bool, str | None, list[str] | None] - ] = {} # var_name -> (is_valid, error, default_fields) - - for var_name in provider_variable_map.values(): - if var_name in credential_variables: - is_valid = False - error_message = None - variable_obj = None - - try: - # Get the raw Variable object to access the encrypted value - variable_obj = await variable_service.get_variable_object( - user_id=current_user.id, name=var_name, session=session - ) - if variable_obj and variable_obj.value: - # Decrypt the API key value - from langflow.services.deps import get_settings_service - - settings_service = get_settings_service() - decrypted_value = auth_utils.decrypt_api_key( - variable_obj.value, settings_service=settings_service - ) - if decrypted_value and decrypted_value.strip(): - # Validate the key (this will raise ValueError if invalid) - await asyncio.to_thread(validate_model_provider_key, var_name, decrypted_value) - # Validation passed - is_valid = True - error_message = None - else: - error_message = "API key is empty" - else: - error_message = "Variable value is empty" - except ValueError as e: - # Validation failed - get the error message - error_message = str(e) - except Exception as e: # noqa: BLE001 - error_message = f"Validation error: {e!s}" - - # Update default_fields based on validation result - updated_default_fields = None - if variable_obj and variable_obj.id: - try: - if is_valid: - # Key is valid - ensure default_fields are set (important for migration) - provider_name = var_to_provider.get(var_name) - expected_default_fields = [provider_name, "api_key"] if provider_name else [] - if variable_obj.default_fields != expected_default_fields: - await variable_service.update_variable_fields( - user_id=current_user.id, - variable_id=variable_obj.id, - variable=VariableUpdate( - id=variable_obj.id, - default_fields=expected_default_fields, - ), - session=session, - ) - updated_default_fields = expected_default_fields - else: - # Key is invalid - clear default_fields - if variable_obj.default_fields: - await variable_service.update_variable_fields( - user_id=current_user.id, - variable_id=variable_obj.id, - variable=VariableUpdate( - id=variable_obj.id, - default_fields=[], - ), - session=session, - ) - updated_default_fields = [] - except Exception: # noqa: BLE001 - # Log but don't fail if we can't update - # Use current default_fields if update failed - updated_default_fields = variable_obj.default_fields if variable_obj else None - - validation_results[var_name] = (is_valid, error_message, updated_default_fields) - - # Set validation status on each variable and update default_fields in response + # Mark model provider credentials - validation status is based on existence + # (actual validation happens on create/update) for var in filtered_variables: if var.name and var.name in model_provider_variable_mapping.values() and var.type == CREDENTIAL_TYPE: - result = validation_results.get(var.name) - if result: - is_valid, error_message, updated_default_fields = result - var.is_valid = is_valid - var.validation_error = error_message - # Update default_fields in response to reflect what we set in database - # This is important for migration - valid keys will have default_fields set - if updated_default_fields is not None: - var.default_fields = updated_default_fields - else: - # Variable not found in validation results - var.is_valid = False - var.validation_error = "Variable not found" + # Credential exists and was validated on save + var.is_valid = True + var.validation_error = None else: - # Not a model provider credential, validation fields remain None + # Not a model provider credential var.is_valid = None var.validation_error = None + except Exception as e: raise HTTPException(status_code=500, detail=str(e)) from e else: diff --git a/src/backend/tests/unit/api/v1/test_variable.py b/src/backend/tests/unit/api/v1/test_variable.py index 2598282c5b..00d5c068d3 100644 --- a/src/backend/tests/unit/api/v1/test_variable.py +++ b/src/backend/tests/unit/api/v1/test_variable.py @@ -431,3 +431,129 @@ async def test_create_variable__model_provider_network_error_allows_creation(cli # Should succeed despite network error assert response.status_code == status.HTTP_201_CREATED + + +@pytest.mark.usefixtures("active_user") +async def test_delete_provider_credential_cleans_up_disabled_models(client: AsyncClient, logged_in_headers): + """Test that deleting a provider credential cleans up disabled models for that provider.""" + # Clean up any existing OPENAI_API_KEY variables + all_vars = await client.get("api/v1/variables/", headers=logged_in_headers) + for var in all_vars.json(): + if var.get("name") == "OPENAI_API_KEY": + await client.delete(f"api/v1/variables/{var['id']}", headers=logged_in_headers) + + openai_variable = { + "name": "OPENAI_API_KEY", + "value": "sk-test-key", + "type": CREDENTIAL_TYPE, + "default_fields": [], + } + + # Mock successful OpenAI API call to create credential + with mock.patch("langchain_openai.ChatOpenAI.invoke") as mock_invoke: + mock_invoke.return_value = "test response" + create_response = await client.post("api/v1/variables/", json=openai_variable, headers=logged_in_headers) + assert create_response.status_code == status.HTTP_201_CREATED + created_var = create_response.json() + + # Disable some OpenAI models + with mock.patch("lfx.base.models.unified_models.validate_model_provider_key"): + disable_response = await client.post( + "api/v1/models/enabled_models", + json=[ + {"provider": "OpenAI", "model_id": "gpt-4", "enabled": False}, + {"provider": "OpenAI", "model_id": "gpt-3.5-turbo", "enabled": False}, + ], + headers=logged_in_headers, + ) + assert disable_response.status_code == status.HTTP_200_OK + + # Delete the credential - should clean up disabled models + delete_response = await client.delete(f"api/v1/variables/{created_var['id']}", headers=logged_in_headers) + assert delete_response.status_code == status.HTTP_204_NO_CONTENT + + # Verify disabled models are cleaned up - check that the disabled models variable is gone or cleared + all_vars_after = await client.get("api/v1/variables/", headers=logged_in_headers) + disabled_models_var = next( + (v for v in all_vars_after.json() if v.get("name") == "__disabled_models__"), + None, + ) + # Either the variable should be gone, or it should not contain OpenAI models + if disabled_models_var and disabled_models_var.get("value"): + import json + + disabled_models = json.loads(disabled_models_var["value"]) + assert "gpt-4" not in disabled_models + assert "gpt-3.5-turbo" not in disabled_models + + +@pytest.mark.usefixtures("active_user") +async def test_delete_provider_credential_cleans_up_enabled_models(client: AsyncClient, logged_in_headers): + """Test that deleting a provider credential cleans up explicitly enabled models for that provider.""" + # Clean up any existing OPENAI_API_KEY variables + all_vars = await client.get("api/v1/variables/", headers=logged_in_headers) + for var in all_vars.json(): + if var.get("name") == "OPENAI_API_KEY": + await client.delete(f"api/v1/variables/{var['id']}", headers=logged_in_headers) + + openai_variable = { + "name": "OPENAI_API_KEY", + "value": "sk-test-key", + "type": CREDENTIAL_TYPE, + "default_fields": [], + } + + # Mock successful OpenAI API call to create credential + with mock.patch("langchain_openai.ChatOpenAI.invoke") as mock_invoke: + mock_invoke.return_value = "test response" + create_response = await client.post("api/v1/variables/", json=openai_variable, headers=logged_in_headers) + assert create_response.status_code == status.HTTP_201_CREATED + created_var = create_response.json() + + # Enable some non-default OpenAI models (explicitly enable models that aren't default) + with mock.patch("lfx.base.models.unified_models.validate_model_provider_key"): + enable_response = await client.post( + "api/v1/models/enabled_models", + json=[ + {"provider": "OpenAI", "model_id": "gpt-4-turbo-preview", "enabled": True}, + ], + headers=logged_in_headers, + ) + assert enable_response.status_code == status.HTTP_200_OK + + # Delete the credential - should clean up enabled models + delete_response = await client.delete(f"api/v1/variables/{created_var['id']}", headers=logged_in_headers) + assert delete_response.status_code == status.HTTP_204_NO_CONTENT + + # Verify enabled models are cleaned up + all_vars_after = await client.get("api/v1/variables/", headers=logged_in_headers) + enabled_models_var = next( + (v for v in all_vars_after.json() if v.get("name") == "__enabled_models__"), + None, + ) + # Either the variable should be gone, or it should not contain OpenAI models + if enabled_models_var and enabled_models_var.get("value"): + import json + + enabled_models = json.loads(enabled_models_var["value"]) + assert "gpt-4-turbo-preview" not in enabled_models + + +@pytest.mark.usefixtures("active_user") +async def test_delete_non_provider_credential_does_not_cleanup_models(client: AsyncClient, logged_in_headers): + """Test that deleting a non-provider credential does not affect model lists.""" + # Create a generic variable (not a provider credential) + generic_variable = { + "name": "MY_CUSTOM_VAR", + "value": "custom_value", + "type": GENERIC_TYPE, + "default_fields": [], + } + + create_response = await client.post("api/v1/variables/", json=generic_variable, headers=logged_in_headers) + assert create_response.status_code == status.HTTP_201_CREATED + created_var = create_response.json() + + # Delete the variable - should not trigger any cleanup + delete_response = await client.delete(f"api/v1/variables/{created_var['id']}", headers=logged_in_headers) + assert delete_response.status_code == status.HTTP_204_NO_CONTENT 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 611ae72b25..0dc4aa3273 100644 --- a/src/frontend/src/components/core/parameterRenderComponent/components/modelInputComponent/index.tsx +++ b/src/frontend/src/components/core/parameterRenderComponent/components/modelInputComponent/index.tsx @@ -82,8 +82,12 @@ export default function ModelInputComponent({ ? "llm" : "embeddings"; - const { data: providersData = [] } = useGetModelProviders({}); - const { data: enabledModelsData } = useGetEnabledModels(); + const { data: providersData = [], isLoading: isLoadingProviders } = + useGetModelProviders({}); + const { data: enabledModelsData, isLoading: isLoadingEnabledModels } = + useGetEnabledModels(); + + const isLoading = isLoadingProviders || isLoadingEnabledModels; // Determines if we should show the model selector or the "Setup Provider" button const hasEnabledProviders = useMemo(() => { @@ -203,34 +207,6 @@ export default function ModelInputComponent({ [flatOptions, handleOnNewValue], ); - /** - * Triggers a refresh of available model options from the backend. - * Shows loading state for 2 seconds to provide visual feedback. - */ - const handleRefreshButtonPress = useCallback(async () => { - setRefreshOptions(true); - setOpen(false); - - // mutateTemplate triggers a backend call to refresh the template options - await mutateTemplate( - value, - nodeId!, - nodeClass!, - handleNodeClass!, - postTemplateValue, - setErrorData, - ); - // Brief delay before hiding loading state for better UX - setTimeout(() => setRefreshOptions(false), 2000); - }, [ - value, - nodeId, - nodeClass, - handleNodeClass, - postTemplateValue, - setErrorData, - ]); - const handleManageProvidersDialogClose = useCallback(() => { setOpenManageProvidersDialog(false); // Note: Don't call handleRefreshButtonPress here - the cleanup effect in @@ -365,6 +341,7 @@ export default function ModelInputComponent({ !hasEnabledProviders ? ( + + + + +); + +export default DisconnectWarning; diff --git a/src/frontend/src/modals/modelProviderModal/components/ModelProvidersContent.tsx b/src/frontend/src/modals/modelProviderModal/components/ModelProvidersContent.tsx index 04fe8842fa..ed91ea20fc 100644 --- a/src/frontend/src/modals/modelProviderModal/components/ModelProvidersContent.tsx +++ b/src/frontend/src/modals/modelProviderModal/components/ModelProvidersContent.tsx @@ -9,34 +9,47 @@ import { } from "@/constants/providerConstants"; import { useUpdateEnabledModels } from "@/controllers/API/queries/models/use-update-enabled-models"; import { + useDeleteGlobalVariables, useGetGlobalVariables, usePatchGlobalVariables, usePostGlobalVariables, } from "@/controllers/API/queries/variables"; -import { useDebounce } from "@/hooks/use-debounce"; import ProviderList from "@/modals/modelProviderModal/components/ProviderList"; import { Provider } from "@/modals/modelProviderModal/components/types"; import useAlertStore from "@/stores/alertStore"; import { cn } from "@/utils/utils"; +import DisconnectWarning from "./DisconnectWarning"; import ModelSelection from "./ModelSelection"; interface ModelProvidersContentProps { modelType: "llm" | "embeddings" | "all"; - onClose?: () => void; } -const ModelProvidersContent = ({ - modelType, - onClose, -}: ModelProvidersContentProps) => { +const ModelProvidersContent = ({ modelType }: ModelProvidersContentProps) => { const [selectedProvider, setSelectedProvider] = useState( null, ); const [apiKey, setApiKey] = useState(""); const [validationFailed, setValidationFailed] = useState(false); - // Track if API key change came from user typing (vs programmatic reset) - // Used to prevent auto-save from triggering when we clear the input after success - const isUserInputRef = useRef(false); + const [showReplaceWarning, setShowReplaceWarning] = useState(false); + const [isEditingKey, setIsEditingKey] = useState(false); + const inputRef = useRef(null); + + // Generate a masked preview of the API key based on provider + const getMaskedKeyPreview = (providerName: string): string => { + const keyConfig: Record = { + OpenAI: { prefix: "sk-proj-", totalLength: 164 }, + Anthropic: { prefix: "sk-ant-", totalLength: 108 }, + "Google Generative AI": { prefix: "AIza", totalLength: 39 }, + "IBM watsonx": { prefix: "", totalLength: 44 }, + }; + const config = keyConfig[providerName] || { + prefix: "", + totalLength: 40, + }; + const maskedLength = config.totalLength - config.prefix.length; + return `${config.prefix}${"•".repeat(maskedLength)}`; + }; const queryClient = useQueryClient(); const setSuccessData = useAlertStore((state) => state.setSuccessData); @@ -48,41 +61,71 @@ const ModelProvidersContent = ({ usePatchGlobalVariables(); const { data: globalVariables = [] } = useGetGlobalVariables(); const { mutate: updateEnabledModels } = useUpdateEnabledModels(); + const { mutate: deleteGlobalVariable, isPending: isDeleting } = + useDeleteGlobalVariables(); - const isPending = isCreating || isUpdating; + const isPending = isCreating || isUpdating || isDeleting; - // Invalidate all provider-related caches after successful create/update - // This ensures the UI reflects the latest state across all components const invalidateProviderQueries = () => { queryClient.invalidateQueries({ queryKey: ["useGetModelProviders"] }); queryClient.invalidateQueries({ queryKey: ["useGetEnabledModels"] }); queryClient.invalidateQueries({ queryKey: ["useGetGlobalVariables"] }); - queryClient.refetchQueries({ queryKey: ["flows"] }); }; - // Reset form when provider changes + const placeholderValue = "http://localhost:11434"; + useEffect(() => { setApiKey(""); setValidationFailed(false); + setShowReplaceWarning(false); + setIsEditingKey(false); }, [selectedProvider?.provider]); - // Auto-save API key after user stops typing for 800ms - // The debounce prevents API calls on every keystroke - const debouncedConfigureProvider = useDebounce(() => { - if (apiKey.trim() && selectedProvider && isUserInputRef.current) { - handleConfigureProvider(); - isUserInputRef.current = false; - } - }, 800); + const handleApiKey = () => { + setShowReplaceWarning(true); + }; - // Trigger debounced save when apiKey changes from user input - useEffect(() => { - if (apiKey.trim() && isUserInputRef.current) { - debouncedConfigureProvider(); - } - }, [apiKey, debouncedConfigureProvider]); + const handleDisconnect = () => { + setShowReplaceWarning(true); + }; + + const handleConfirmDisconnect = () => { + if (!selectedProvider) return; + + const variableName = PROVIDER_VARIABLE_MAPPING[selectedProvider.provider]; + if (!variableName) return; + + const existingVariable = globalVariables.find( + (v) => v.name === variableName, + ); + if (!existingVariable) return; + + deleteGlobalVariable( + { id: existingVariable.id }, + { + onSuccess: () => { + setSuccessData({ + title: `${selectedProvider.provider} Disconnected`, + }); + invalidateProviderQueries(); + setShowReplaceWarning(false); + setSelectedProvider((prev) => + prev ? { ...prev, is_enabled: false } : null, + ); + }, + onError: (error: any) => { + setErrorData({ + title: "Error Disconnecting Provider", + list: [ + error?.response?.data?.detail || + "An unexpected error occurred. Please try again.", + ], + }); + }, + }, + ); + }; - // Update enabled models when toggled const handleModelToggle = (modelName: string, enabled: boolean) => { if (!selectedProvider?.provider) return; @@ -104,26 +147,22 @@ const ModelProvidersContent = ({ ); }; - // Toggle provider selection - clicking same provider deselects it const handleProviderSelect = (provider: Provider) => { setSelectedProvider((prev) => prev?.provider === provider.provider ? null : provider, ); }; - // Some providers (e.g., Ollama) don't require API keys - they just need activation const requiresApiKey = useMemo(() => { if (!selectedProvider) return true; return !NO_API_KEY_PROVIDERS.includes(selectedProvider.provider); }, [selectedProvider]); - // Activate providers that don't need API keys (e.g., Ollama) - // Creates/updates a global variable with a placeholder URL to mark provider as enabled const handleActivateNoApiKeyProvider = () => { if (!selectedProvider) return; - // Map provider name to its corresponding global variable name const variableName = PROVIDER_VARIABLE_MAPPING[selectedProvider.provider]; + if (!variableName) { setErrorData({ title: "Invalid Provider", @@ -132,17 +171,13 @@ const ModelProvidersContent = ({ return; } - // Check if provider was previously configured (variable exists) const existingVariable = globalVariables.find( (v) => v.name === variableName, ); - // Ollama default endpoint - used as placeholder to mark provider as active - const placeholderValue = "http://localhost:11434"; - const onSuccess = () => { - setSuccessData({ title: `${selectedProvider.provider} Activated` }); invalidateProviderQueries(); + setSuccessData({ title: `${selectedProvider.provider} Activated` }); setSelectedProvider((prev) => prev ? { ...prev, is_enabled: true } : null, ); @@ -237,9 +272,6 @@ const ModelProvidersContent = ({ } }; - // Note: refreshAllModelInputs is now called in ModelProviderModal's handleClose - // to ensure reliable execution when the modal closes - return (
-
-
- - {selectedProvider?.provider || "Unknown Provider"} - {requiresApiKey && " API Key"} - {requiresApiKey && *} - -
- - {requiresApiKey ? ( - <> - Add your{" "} - { - if (selectedProvider?.api_docs_url) { - window.open( - selectedProvider.api_docs_url, - "_blank", - "noopener,noreferrer", - ); +
+ {requiresApiKey ? ( + <> +
+
+ + {selectedProvider?.provider || "Unknown Provider"} + {requiresApiKey && " API Key"} + {requiresApiKey && ( + * + )} + + + Add your{" "} + { + if (selectedProvider?.api_docs_url) { + window.open( + selectedProvider.api_docs_url, + "_blank", + "noopener,noreferrer", + ); + } + }} + > + {selectedProvider?.provider} API key + {" "} + to enable these models + +
+ { + if (selectedProvider?.is_enabled) { + setIsEditingKey(true); } }} - > - {selectedProvider?.provider} API key - {" "} - to enable these models - - ) : ( - <>Activate {selectedProvider?.provider} to enable these models - )} - - {requiresApiKey ? ( - { - isUserInputRef.current = true; - setValidationFailed(false); - setApiKey(e.target.value); - }} - // Show loading spinner while saving, X on error, checkmark when configured - endIcon={ - isPending - ? "LoaderCircle" - : validationFailed - ? "X" - : selectedProvider?.is_enabled - ? "Check" - : undefined - } - endIconClassName={cn( - isPending && "animate-spin text-muted-foreground top-2.5", - validationFailed && "text-red-500", - !isPending && - !validationFailed && - selectedProvider?.is_enabled && - "text-green-500", - )} - /> + onBlur={() => { + if (!apiKey) { + setIsEditingKey(false); + } + }} + onChange={(e) => { + setValidationFailed(false); + setApiKey(e.target.value); + }} + endIcon={ + isPending + ? "LoaderCircle" + : validationFailed + ? "X" + : selectedProvider?.is_enabled && !isEditingKey + ? "Check" + : undefined + } + endIconClassName={cn( + isPending && "animate-spin text-muted-foreground top-2.5", + validationFailed && "text-red-500", + !isPending && + !validationFailed && + selectedProvider?.is_enabled && + "text-green-500", + )} + /> +
+ {selectedProvider?.is_enabled && ( + + )} + +
+
+ setShowReplaceWarning(false)} + onConfirm={handleConfirmDisconnect} + isLoading={isDeleting} + className="absolute inset-0 m-4" + /> + ) : ( - + <> +
+
+ + {selectedProvider?.provider || "Unknown Provider"} + + + Activate {selectedProvider?.provider} to enable these models + +
+ +
+ {selectedProvider?.is_enabled && ( + + )} + {!selectedProvider?.is_enabled && ( + + )} +
+
+ setShowReplaceWarning(false)} + onConfirm={handleConfirmDisconnect} + isLoading={isDeleting} + className="absolute inset-0 m-4" + /> + )}
diff --git a/src/frontend/src/modals/modelProviderModal/index.tsx b/src/frontend/src/modals/modelProviderModal/index.tsx index e71c887bc6..a01057e30d 100644 --- a/src/frontend/src/modals/modelProviderModal/index.tsx +++ b/src/frontend/src/modals/modelProviderModal/index.tsx @@ -17,9 +17,6 @@ const ModelProviderModal = ({ 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); @@ -35,7 +32,7 @@ const ModelProviderModal = ({
- +
diff --git a/src/lfx/src/lfx/base/models/unified_models.py b/src/lfx/src/lfx/base/models/unified_models.py index f0ccbc5ac4..155ad4243e 100644 --- a/src/lfx/src/lfx/base/models/unified_models.py +++ b/src/lfx/src/lfx/base/models/unified_models.py @@ -322,39 +322,45 @@ def get_api_key_for_provider(user_id: UUID | str | None, provider: str, api_key: def _validate_and_get_enabled_providers( credential_variables: dict[str, Any], provider_variable_map: dict[str, str], + *, + skip_validation: bool = True, ) -> set[str]: - """Validate API keys and return set of enabled providers. + """Return set of enabled providers based on credential existence. - This helper function validates API keys for credential variables and returns - only providers with valid keys. Used by get_enabled_providers and model options functions. + This helper function checks which providers have credentials stored. + API key validation is performed when credentials are saved, not on every read, + to avoid latency from external API calls. Args: credential_variables: Dictionary mapping variable names to VariableRead objects provider_variable_map: Dictionary mapping provider names to variable names + skip_validation: If True (default), skip API validation and just check existence. + If False, validate each API key (slower, makes external calls). Returns: - Set of provider names that have valid API keys + Set of provider names that have credentials stored """ - from langflow.services.auth import utils as auth_utils - from langflow.services.deps import get_settings_service - - settings_service = get_settings_service() enabled = set() for provider, var_name in provider_variable_map.items(): if var_name in credential_variables: - # Validate the API key before marking as enabled - credential_var = credential_variables[var_name] - try: - # Decrypt the API key value - api_key = auth_utils.decrypt_api_key(credential_var.value, settings_service=settings_service) - # Validate the key (this will raise ValueError if invalid) - if api_key and api_key.strip(): - validate_model_provider_key(var_name, api_key) - enabled.add(provider) - except (ValueError, Exception) as e: # noqa: BLE001 - # Key validation failed or decryption failed - don't enable provider - logger.debug("Provider %s validation failed for variable %s: %s", provider, var_name, e) + if skip_validation: + # Just check existence - validation was done on save + enabled.add(provider) + else: + # Legacy path: validate the API key (slow - makes external calls) + from langflow.services.auth import utils as auth_utils + from langflow.services.deps import get_settings_service + + credential_var = credential_variables[var_name] + try: + settings_service = get_settings_service() + api_key = auth_utils.decrypt_api_key(credential_var.value, settings_service=settings_service) + if api_key and api_key.strip(): + validate_model_provider_key(var_name, api_key) + enabled.add(provider) + except (ValueError, Exception) as e: # noqa: BLE001 + logger.debug("Provider %s validation failed for variable %s: %s", provider, var_name, e) return enabled