diff --git a/src/backend/base/langflow/api/v1/models.py b/src/backend/base/langflow/api/v1/models.py index 4d7a6e87c8..f51890f499 100644 --- a/src/backend/base/langflow/api/v1/models.py +++ b/src/backend/base/langflow/api/v1/models.py @@ -197,7 +197,11 @@ async def get_enabled_providers( current_user: CurrentActiveUser, providers: Annotated[list[str] | None, Query()] = None, ): - """Get enabled providers for the current user.""" + """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. + """ variable_service = get_variable_service() try: if not isinstance(variable_service, DatabaseVariableService): @@ -205,14 +209,14 @@ async def get_enabled_providers( status_code=500, detail="Variable service is not an instance of DatabaseVariableService", ) - # Get all credential variables for the user + # Get all variables to check which credential variables exist all_variables = await variable_service.get_all(user_id=current_user.id, session=session) # Get all credential variable names (regardless of default_fields) # This includes both env variables and explicitly created model provider credentials - credential_names = {var.name for var in all_variables if var.type == CREDENTIAL_TYPE} + credential_variable_names = {var.name for var in all_variables if var.type == CREDENTIAL_TYPE} - if not credential_names: + if not credential_variable_names: return { "enabled_providers": [], "provider_status": {}, @@ -221,14 +225,39 @@ async def get_enabled_providers( # Get the provider-variable mapping provider_variable_map = get_model_provider_variable_mapping() - enabled_providers = [] - provider_status = {} + # 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 = {} - for provider, var_name in provider_variable_map.items(): - is_enabled = var_name in credential_names - provider_status[provider] = is_enabled - if is_enabled: - enabled_providers.append(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 + provider_status = {provider: provider in enabled_providers_set for provider in provider_variable_map} result = { "enabled_providers": enabled_providers, diff --git a/src/backend/base/langflow/api/v1/variable.py b/src/backend/base/langflow/api/v1/variable.py index e62b01f1f8..ddbf3f52d9 100644 --- a/src/backend/base/langflow/api/v1/variable.py +++ b/src/backend/base/langflow/api/v1/variable.py @@ -14,6 +14,7 @@ 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 @@ -148,19 +149,141 @@ async def read_variables( session: DbSession, current_user: CurrentActiveUser, ): - """Read all 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. + + 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. + """ variable_service = get_variable_service() if not isinstance(variable_service, DatabaseVariableService): msg = "Variable service is not an instance of DatabaseVariableService" raise TypeError(msg) try: all_variables = await variable_service.get_all(user_id=current_user.id, session=session) + # Filter out internal variables (those starting and ending with __) - return [ + filtered_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 + 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" + else: + # Not a model provider credential, validation fields remain None + var.is_valid = None + var.validation_error = None except Exception as e: raise HTTPException(status_code=500, detail=str(e)) from e + else: + return filtered_variables @router.patch("/{variable_id}", response_model=VariableRead, status_code=200) diff --git a/src/backend/base/langflow/services/database/models/variable/model.py b/src/backend/base/langflow/services/database/models/variable/model.py index c52e0c94cb..6d3f6f9292 100644 --- a/src/backend/base/langflow/services/database/models/variable/model.py +++ b/src/backend/base/langflow/services/database/models/variable/model.py @@ -55,6 +55,12 @@ class VariableRead(SQLModel): type: str | None = Field(None, description="Type of the variable") value: str | None = Field(None, description="Encrypted value of the variable") default_fields: list[str] | None = Field(None, description="Default fields for the variable") + validation_error: str | None = Field( + None, description="Validation error message if this is a model provider credential with an invalid key" + ) + is_valid: bool | None = Field( + None, description="Whether this model provider credential has a valid key (None if not a provider credential)" + ) @field_validator("value") @classmethod diff --git a/src/backend/base/langflow/services/variable/service.py b/src/backend/base/langflow/services/variable/service.py index dd8bd3874d..f872e9aa23 100644 --- a/src/backend/base/langflow/services/variable/service.py +++ b/src/backend/base/langflow/services/variable/service.py @@ -68,7 +68,22 @@ class DatabaseVariableService(VariableService, Service): try: if is_api_key_variable: provider_name = var_to_provider[var_name] - default_fields = [provider_name, "api_key"] + # Validate the API key before setting default_fields + # This prevents invalid keys from enabling providers during migration + try: + from lfx.base.models.unified_models import validate_model_provider_key + + validate_model_provider_key(var_name, value) + # Only set default_fields if validation passes + default_fields = [provider_name, "api_key"] + await logger.adebug(f"Validated {var_name} - provider will be enabled") + except (ValueError, Exception) as validation_error: # noqa: BLE001 + # Validation failed - don't set default_fields + # This prevents the provider from appearing as "Enabled" + default_fields = [] + await logger.adebug( + f"Skipping default_fields for {var_name} - validation failed: {validation_error!s}" + ) existing = (await session.exec(query)).first() except Exception as e: # noqa: BLE001 await logger.aexception(f"Error querying {var_name} variable: {e!s}") diff --git a/src/backend/tests/unit/api/v1/test_models_enabled_providers.py b/src/backend/tests/unit/api/v1/test_models_enabled_providers.py index 2caeb8d465..d037782274 100644 --- a/src/backend/tests/unit/api/v1/test_models_enabled_providers.py +++ b/src/backend/tests/unit/api/v1/test_models_enabled_providers.py @@ -98,7 +98,10 @@ async def test_enabled_providers_after_credential_creation(client: AsyncClient, assert create_response.status_code == status.HTTP_201_CREATED # Check status after credential creation - after_response = await client.get("api/v1/models/enabled_providers", headers=logged_in_headers) + # Mock validation for enabled_providers endpoint as well + with mock.patch("lfx.base.models.unified_models.validate_model_provider_key") as mock_validate: + mock_validate.return_value = None + after_response = await client.get("api/v1/models/enabled_providers", headers=logged_in_headers) after_result = after_response.json() assert after_response.status_code == status.HTTP_200_OK @@ -137,8 +140,10 @@ async def test_enabled_providers_multiple_credentials( await client.post("api/v1/variables/", json=anthropic_var, headers=logged_in_headers) await client.post("api/v1/variables/", json=google_var, headers=logged_in_headers) - # Check enabled providers - response = await client.get("api/v1/models/enabled_providers", headers=logged_in_headers) + # Check enabled providers - mock validation for enabled_providers endpoint + with mock.patch("lfx.base.models.unified_models.validate_model_provider_key") as mock_validate: + mock_validate.return_value = None + response = await client.get("api/v1/models/enabled_providers", headers=logged_in_headers) result = response.json() assert response.status_code == status.HTTP_200_OK @@ -170,8 +175,10 @@ async def test_enabled_providers_after_credential_deletion(client: AsyncClient, created_credential = create_response.json() credential_id = created_credential["id"] - # Verify enabled - enabled_response = await client.get("api/v1/models/enabled_providers", headers=logged_in_headers) + # Verify enabled - mock validation for enabled_providers endpoint as well + with mock.patch("lfx.base.models.unified_models.validate_model_provider_key") as mock_validate: + mock_validate.return_value = None + enabled_response = await client.get("api/v1/models/enabled_providers", headers=logged_in_headers) enabled_result = enabled_response.json() assert "OpenAI" in enabled_result["enabled_providers"] assert enabled_result["provider_status"]["OpenAI"] is True @@ -213,10 +220,12 @@ async def test_enabled_providers_filter_by_specific_providers( await client.post("api/v1/variables/", json=openai_var, headers=logged_in_headers) await client.post("api/v1/variables/", json=anthropic_var, headers=logged_in_headers) - # Request specific providers (only providers that are in the mapping) - response = await client.get( - "api/v1/models/enabled_providers?providers=OpenAI&providers=Anthropic", headers=logged_in_headers - ) + # Request specific providers (only providers that are in the mapping) - mock validation + with mock.patch("lfx.base.models.unified_models.validate_model_provider_key") as mock_validate: + mock_validate.return_value = None + response = await client.get( + "api/v1/models/enabled_providers?providers=OpenAI&providers=Anthropic", headers=logged_in_headers + ) result = response.json() assert response.status_code == status.HTTP_200_OK @@ -333,13 +342,16 @@ async def test_enabled_providers_reflects_models_endpoint(client: AsyncClient, o mock_validate.return_value = None await client.post("api/v1/variables/", json=variable_payload, headers=logged_in_headers) - # Get enabled providers - enabled_response = await client.get("api/v1/models/enabled_providers", headers=logged_in_headers) - enabled_result = enabled_response.json() + # Get enabled providers and models - mock validation in unified_models so providers are marked enabled + with mock.patch("lfx.base.models.unified_models.validate_model_provider_key") as mock_validate: + mock_validate.return_value = None - # Get models (which should include provider information) - models_response = await client.get("api/v1/models", headers=logged_in_headers) - models_result = models_response.json() + enabled_response = await client.get("api/v1/models/enabled_providers", headers=logged_in_headers) + enabled_result = enabled_response.json() + + # Get models (which should include provider information) + models_response = await client.get("api/v1/models", headers=logged_in_headers) + models_result = models_response.json() assert models_response.status_code == status.HTTP_200_OK diff --git a/src/frontend/src/pages/SettingsPage/pages/GlobalVariablesPage/index.tsx b/src/frontend/src/pages/SettingsPage/pages/GlobalVariablesPage/index.tsx index 318b966904..a30a3b2336 100644 --- a/src/frontend/src/pages/SettingsPage/pages/GlobalVariablesPage/index.tsx +++ b/src/frontend/src/pages/SettingsPage/pages/GlobalVariablesPage/index.tsx @@ -4,11 +4,12 @@ import type { SelectionChangedEvent, ValueFormatterParams, } from "ag-grid-community"; -import { useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import Dropdown from "@/components/core/dropdownComponent"; import GlobalVariableModal from "@/components/core/GlobalVariableModal/GlobalVariableModal"; import TableComponent from "@/components/core/parameterRenderComponent/components/tableComponent"; +import { PROVIDER_VARIABLE_MAPPING } from "@/constants/providerConstants"; import { useDeleteGlobalVariables, useGetGlobalVariables, @@ -88,6 +89,59 @@ export default function GlobalVariablesPage() { const { data: globalVariables } = useGetGlobalVariables(); const { mutate: mutateDeleteGlobalVariable } = useDeleteGlobalVariables(); + // Get list of provider variable names to identify provider credentials + const providerVariableNames = useMemo( + () => new Set(Object.values(PROVIDER_VARIABLE_MAPPING)), + [], + ); + + // Filter out invalid provider credentials + const validGlobalVariables = useMemo(() => { + if (!globalVariables) return []; + + return globalVariables.filter((variable) => { + // Check if this is a provider credential variable + const isProviderCredential = + variable.type === "Credential" && + providerVariableNames.has(variable.name); + + if (isProviderCredential) { + // If validation failed (is_valid === false), filter it out + if (variable.is_valid === false) { + return false; // Filter out invalid provider credentials + } + } + + return true; // Keep all other variables and valid provider credentials + }); + }, [globalVariables, providerVariableNames]); + + // Show validation errors for invalid provider credentials (only once when detected) + useEffect(() => { + if (!globalVariables) return; + + const invalidProviderVars = globalVariables.filter( + (variable) => + variable.type === "Credential" && + providerVariableNames.has(variable.name) && + variable.is_valid === false, + ); + + if (invalidProviderVars.length > 0) { + const errorMessages = invalidProviderVars.map( + (variable) => + `${variable.name}: ${variable.validation_error || "Invalid API key"}`, + ); + setErrorData({ + title: "Invalid Provider Credentials Detected", + list: [ + `${invalidProviderVars.length} provider credential(s) with invalid keys have been hidden from the list.`, + ...errorMessages, + ], + }); + } + }, [globalVariables, providerVariableNames, setErrorData]); + async function removeVariables() { selectedRows.map(async (row) => { const id = globalVariables?.find((variable) => variable.name === row)?.id; @@ -150,7 +204,7 @@ export default function GlobalVariablesPage() { suppressRowClickSelection={true} pagination={true} columnDefs={colDefs} - rowData={globalVariables ?? []} + rowData={validGlobalVariables ?? []} onDelete={removeVariables} /> {initialData.current && ( diff --git a/src/frontend/src/types/global_variables/index.ts b/src/frontend/src/types/global_variables/index.ts index d6652806c3..b54067d669 100644 --- a/src/frontend/src/types/global_variables/index.ts +++ b/src/frontend/src/types/global_variables/index.ts @@ -7,4 +7,6 @@ export type GlobalVariable = { name: string; value?: string; category?: string; + is_valid?: boolean | null; + validation_error?: string | null; }; diff --git a/src/lfx/src/lfx/base/models/unified_models.py b/src/lfx/src/lfx/base/models/unified_models.py index d51be9d49a..2b3f5b312d 100644 --- a/src/lfx/src/lfx/base/models/unified_models.py +++ b/src/lfx/src/lfx/base/models/unified_models.py @@ -275,6 +275,46 @@ def get_api_key_for_provider(user_id: UUID | str | None, provider: str, api_key: return run_until_complete(_get_variable()) +def _validate_and_get_enabled_providers( + credential_variables: dict[str, Any], + provider_variable_map: dict[str, str], +) -> set[str]: + """Validate API keys and return set of enabled providers. + + 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. + + Args: + credential_variables: Dictionary mapping variable names to VariableRead objects + provider_variable_map: Dictionary mapping provider names to variable names + + Returns: + Set of provider names that have valid API keys + """ + 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) + + return enabled + + def validate_model_provider_key(variable_name: str, api_key: str) -> None: """Validate a model provider API key by making a minimal test call. @@ -425,7 +465,7 @@ def get_language_model_options( # If we can't get model status, continue without filtering pass - # Get enabled providers (those with credentials configured) + # Get enabled providers (those with credentials configured and validated) enabled_providers = set() if user_id: try: @@ -435,6 +475,7 @@ def get_language_model_options( variable_service = get_variable_service() if variable_service is None: return set() + from langflow.services.variable.constants import CREDENTIAL_TYPE from langflow.services.variable.service import DatabaseVariableService @@ -444,11 +485,11 @@ def get_language_model_options( user_id=UUID(user_id) if isinstance(user_id, str) else user_id, session=session, ) - credential_names = {var.name for var in all_vars if var.type == CREDENTIAL_TYPE} + credential_vars = {var.name: var for var in all_vars if var.type == CREDENTIAL_TYPE} provider_variable_map = get_model_provider_variable_mapping() - return { - provider for provider, var_name in provider_variable_map.items() if var_name in credential_names - } + + # Use shared helper to validate and get enabled providers + return _validate_and_get_enabled_providers(credential_vars, provider_variable_map) enabled_providers = run_until_complete(_get_enabled_providers()) except Exception: # noqa: BLE001, S110 @@ -611,7 +652,7 @@ def get_embedding_model_options(user_id: UUID | str | None = None) -> list[dict[ # If we can't get model status, continue without filtering pass - # Get enabled providers (those with credentials configured) + # Get enabled providers (those with credentials configured and validated) enabled_providers = set() if user_id: try: @@ -621,6 +662,7 @@ def get_embedding_model_options(user_id: UUID | str | None = None) -> list[dict[ variable_service = get_variable_service() if variable_service is None: return set() + from langflow.services.variable.constants import CREDENTIAL_TYPE from langflow.services.variable.service import DatabaseVariableService @@ -630,11 +672,11 @@ def get_embedding_model_options(user_id: UUID | str | None = None) -> list[dict[ user_id=UUID(user_id) if isinstance(user_id, str) else user_id, session=session, ) - credential_names = {var.name for var in all_vars if var.type == CREDENTIAL_TYPE} + credential_vars = {var.name: var for var in all_vars if var.type == CREDENTIAL_TYPE} provider_variable_map = get_model_provider_variable_mapping() - return { - provider for provider, var_name in provider_variable_map.items() if var_name in credential_names - } + + # Use shared helper to validate and get enabled providers + return _validate_and_get_enabled_providers(credential_vars, provider_variable_map) enabled_providers = run_until_complete(_get_enabled_providers()) except Exception: # noqa: BLE001, S110