feat: Missing 'Disconnect' Option in Model Providers Settings (#11373)

* added disconnect styles from design

* added motion to model provider

* revert input to make sense

* styles improved

* backend fix

* removed non used import

* fixed disconnect (design suggestion)

* added tests

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

* updated templates

* updated templates

* re-run ci

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Deon Sanchez
2026-02-03 14:25:32 -07:00
committed by GitHub
parent 84958cf99a
commit f849047695
10 changed files with 628 additions and 312 deletions

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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 ? (
<Button
variant="default"
loading={isLoading}
size="sm"
className="w-full"
onClick={() => setOpenManageProvidersDialog(true)}
@ -492,13 +469,6 @@ export default function ModelInputComponent({
const renderManageProvidersButton = () => (
<div className="bottom-0 bg-background">
{/* {renderFooterButton(
"Refresh List",
"RefreshCw",
handleRefreshButtonPress,
"external-option-button",
)} */}
{renderFooterButton(
"Manage Model Providers",
"Settings",

View File

@ -7,6 +7,7 @@ export interface InputProps
icon?: string;
inputClassName?: string;
placeholder?: string;
placeholderClassName?: string;
endIcon?: string;
endIconClassName?: string;
}

View File

@ -0,0 +1,139 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import DisconnectWarning from "../components/DisconnectWarning";
// Mock ForwardedIconComponent
jest.mock("@/components/common/genericIconComponent", () => ({
__esModule: true,
default: ({ name, className }: { name: string; className?: string }) => (
<span data-testid={`icon-${name}`} className={className}>
{name}
</span>
),
}));
const defaultProps = {
show: true,
message: "Test warning message",
onCancel: jest.fn(),
onConfirm: jest.fn(),
isLoading: false,
};
describe("DisconnectWarning", () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe("Rendering", () => {
it("should render the warning message when show is true", () => {
render(<DisconnectWarning {...defaultProps} />);
expect(screen.getByText("Warning")).toBeInTheDocument();
expect(screen.getByText("Test warning message")).toBeInTheDocument();
});
it("should render Cancel and Confirm buttons", () => {
render(<DisconnectWarning {...defaultProps} />);
expect(
screen.getByRole("button", { name: /cancel/i }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /confirm/i }),
).toBeInTheDocument();
});
it("should render the warning icon", () => {
render(<DisconnectWarning {...defaultProps} />);
expect(screen.getByTestId("icon-Circle")).toBeInTheDocument();
});
it("should apply opacity-0 class when show is false", () => {
const { container } = render(
<DisconnectWarning {...defaultProps} show={false} />,
);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper).toHaveClass("opacity-0");
expect(wrapper).toHaveClass("pointer-events-none");
});
it("should apply opacity-100 class when show is true", () => {
const { container } = render(<DisconnectWarning {...defaultProps} />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper).toHaveClass("opacity-100");
});
it("should apply custom className when provided", () => {
const { container } = render(
<DisconnectWarning {...defaultProps} className="custom-class" />,
);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper).toHaveClass("custom-class");
});
});
describe("Button Interactions", () => {
it("should call onCancel when Cancel button is clicked", async () => {
const onCancel = jest.fn();
const user = userEvent.setup();
render(<DisconnectWarning {...defaultProps} onCancel={onCancel} />);
const cancelButton = screen.getByRole("button", { name: /cancel/i });
await user.click(cancelButton);
expect(onCancel).toHaveBeenCalledTimes(1);
});
it("should call onConfirm when Confirm button is clicked", async () => {
const onConfirm = jest.fn();
const user = userEvent.setup();
render(<DisconnectWarning {...defaultProps} onConfirm={onConfirm} />);
const confirmButton = screen.getByRole("button", { name: /confirm/i });
await user.click(confirmButton);
expect(onConfirm).toHaveBeenCalledTimes(1);
});
});
describe("Loading State", () => {
it("should show loading state on Confirm button when isLoading is true", () => {
render(<DisconnectWarning {...defaultProps} isLoading={true} />);
const confirmButton = screen.getByRole("button", { name: /confirm/i });
expect(confirmButton).toBeInTheDocument();
});
it("should not show loading state when isLoading is false", () => {
render(<DisconnectWarning {...defaultProps} isLoading={false} />);
const confirmButton = screen.getByRole("button", { name: /confirm/i });
expect(confirmButton).toBeInTheDocument();
});
});
describe("Different Messages", () => {
it("should display provider-specific disconnect message", () => {
const message =
"Disconnecting an API key will disable all of the provider's models being used in a flow.";
render(<DisconnectWarning {...defaultProps} message={message} />);
expect(screen.getByText(message)).toBeInTheDocument();
});
it("should display provider-specific deactivate message", () => {
const message =
"Deactivating Ollama will disable all of the provider's models being used in a flow.";
render(<DisconnectWarning {...defaultProps} message={message} />);
expect(screen.getByText(message)).toBeInTheDocument();
});
});
});

View File

@ -0,0 +1,57 @@
import ForwardedIconComponent from "@/components/common/genericIconComponent";
import { Button } from "@/components/ui/button";
import { cn } from "@/utils/utils";
interface DisconnectWarningProps {
show: boolean;
message: string;
onCancel: () => void;
onConfirm: () => void;
isLoading: boolean;
className?: string;
}
const DisconnectWarning = ({
show,
message,
onCancel,
onConfirm,
isLoading,
className,
}: DisconnectWarningProps) => (
<div
className={cn(
"border border-border border-destructive rounded-md transition-all h-fit duration-300 ease-in-out",
show ? "opacity-100 p-4" : "opacity-0 pointer-events-none",
className,
)}
>
<div className="flex flex-col gap-2">
<div className="text-destructive flex items-center gap-1 text-sm">
<ForwardedIconComponent
name="Circle"
className="text-destructive w-2 h-2 fill-destructive mr-1"
/>
Warning
</div>
<p className="flex flex-col text-sm ">{message}</p>
<div className="flex gap-2 justify-end ">
<Button size="sm" variant="ghost" onClick={onCancel}>
Cancel
</Button>
<Button
size="sm"
variant="destructive"
onClick={onConfirm}
loading={isLoading}
>
Confirm
</Button>
</div>
</div>
</div>
);
export default DisconnectWarning;

View File

@ -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<Provider | null>(
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<HTMLInputElement>(null);
// Generate a masked preview of the API key based on provider
const getMaskedKeyPreview = (providerName: string): string => {
const keyConfig: Record<string, { prefix: string; totalLength: number }> = {
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 (
<div className="flex flex-row w-full h-full overflow-hidden">
<div
@ -263,77 +295,188 @@ const ModelProvidersContent = ({
: "w-0 opacity-0 translate-x-full",
)}
>
<div className="flex flex-col gap-1 p-4">
<div className="flex flex-row gap-1 min-w-[300px]">
<span className="text-[13px] font-semibold mr-auto">
{selectedProvider?.provider || "Unknown Provider"}
{requiresApiKey && " API Key"}
{requiresApiKey && <span className="text-red-500 ml-1">*</span>}
</span>
</div>
<span className="text-[13px] text-muted-foreground pt-1 pb-2">
{requiresApiKey ? (
<>
Add your{" "}
<span
className="underline cursor-pointer hover:text-primary"
onClick={() => {
if (selectedProvider?.api_docs_url) {
window.open(
selectedProvider.api_docs_url,
"_blank",
"noopener,noreferrer",
);
<div className={cn("flex flex-col gap-1 relative p-4")}>
{requiresApiKey ? (
<>
<div
className={cn(
"flex flex-col gap-3 transition-all duration-300 ease-in-out relative",
showReplaceWarning
? "opacity-0 pointer-events-none"
: "opacity-100",
)}
>
<div className="flex flex-col gap-1">
<span className="text-sm font-semibold">
{selectedProvider?.provider || "Unknown Provider"}
{requiresApiKey && " API Key"}
{requiresApiKey && (
<span className="text-red-500 ml-1">*</span>
)}
</span>
<span className="text-sm text-muted-foreground">
Add your{" "}
<span
className="underline cursor-pointer hover:text-primary"
onClick={() => {
if (selectedProvider?.api_docs_url) {
window.open(
selectedProvider.api_docs_url,
"_blank",
"noopener,noreferrer",
);
}
}}
>
{selectedProvider?.provider} API key
</span>{" "}
to enable these models
</span>
</div>
<Input
ref={inputRef}
placeholder={"Enter API key"}
value={
selectedProvider?.is_enabled && !isEditingKey
? getMaskedKeyPreview(selectedProvider.provider)
: apiKey
}
className="group"
type={
selectedProvider?.is_enabled && !isEditingKey
? "text"
: "password"
}
onFocus={() => {
if (selectedProvider?.is_enabled) {
setIsEditingKey(true);
}
}}
>
{selectedProvider?.provider} API key
</span>{" "}
to enable these models
</>
) : (
<>Activate {selectedProvider?.provider} to enable these models</>
)}
</span>
{requiresApiKey ? (
<Input
placeholder="Add API key"
value={apiKey}
type="password"
onChange={(e) => {
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",
)}
/>
<div className="flex gap-2 justify-end">
{selectedProvider?.is_enabled && (
<Button
size="sm"
variant="destructive"
onClick={() => {
handleDisconnect();
}}
>
Disconnect
</Button>
)}
<Button size="sm" onClick={handleConfigureProvider}>
<span>
{selectedProvider?.is_enabled ? "Replace" : "Validate"}{" "}
API Key
</span>
</Button>
</div>
</div>
<DisconnectWarning
show={showReplaceWarning}
message="Disconnecting an API key will disable all of the provider's models being used in a flow."
onCancel={() => setShowReplaceWarning(false)}
onConfirm={handleConfirmDisconnect}
isLoading={isDeleting}
className="absolute inset-0 m-4"
/>
</>
) : (
<Button
onClick={handleActivateNoApiKeyProvider}
loading={isPending}
disabled={selectedProvider?.is_enabled}
>
{selectedProvider?.is_enabled
? `${selectedProvider?.provider} Activated`
: `Activate ${selectedProvider?.provider}`}
</Button>
<>
<div
className={cn(
"flex flex-col gap-3 transition-all duration-300 ease-in-out relative",
showReplaceWarning
? "opacity-0 pointer-events-none"
: "opacity-100",
)}
>
<div className="flex flex-col gap-1">
<span className="text-sm font-semibold">
{selectedProvider?.provider || "Unknown Provider"}
</span>
<span className="text-sm text-muted-foreground">
Activate {selectedProvider?.provider} to enable these models
</span>
</div>
<Input
disabled
value={"No key required"}
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",
)}
/>
<div className="flex gap-2 justify-end">
{selectedProvider?.is_enabled && (
<Button
size="sm"
variant="destructive"
onClick={handleDisconnect}
>
Deactivate {selectedProvider?.provider}
</Button>
)}
{!selectedProvider?.is_enabled && (
<Button
size="sm"
onClick={handleActivateNoApiKeyProvider}
loading={isPending && !showReplaceWarning}
>
Activate {selectedProvider?.provider}
</Button>
)}
</div>
</div>
<DisconnectWarning
show={showReplaceWarning}
message={`Deactivating ${selectedProvider?.provider} will disable all of the provider's models being used in a flow.`}
onCancel={() => setShowReplaceWarning(false)}
onConfirm={handleConfirmDisconnect}
isLoading={isDeleting}
className="absolute inset-0 m-4"
/>
</>
)}
</div>

View File

@ -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 = ({
</DialogHeader>
<div className="h-[513px] overflow-hidden">
<ModelProvidersContent modelType={modelType} onClose={handleClose} />
<ModelProvidersContent modelType={modelType} />
</div>
</DialogContent>
</Dialog>

View File

@ -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