mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-24 02:05:11 +08:00
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:
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -7,6 +7,7 @@ export interface InputProps
|
||||
icon?: string;
|
||||
inputClassName?: string;
|
||||
placeholder?: string;
|
||||
placeholderClassName?: string;
|
||||
endIcon?: string;
|
||||
endIconClassName?: string;
|
||||
}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user