Merge remote-tracking branch 'origin/release-1.8.3'

# Conflicts:
#	src/backend/tests/unit/test_unified_models.py
This commit is contained in:
vijay kumar katuri
2026-04-06 15:22:06 -04:00
17 changed files with 1605 additions and 451 deletions

View File

@ -6041,56 +6041,21 @@
"filename": "src/lfx/src/lfx/base/models/unified_models.py",
"hashed_secret": "665b1e3851eefefa3fb878654292f16597d25155",
"is_verified": false,
"line_number": 69
"line_number": 1171
},
{
"type": "Secret Keyword",
"filename": "src/lfx/src/lfx/base/models/unified_models.py",
"hashed_secret": "3f2df46921dd8e2c36e2ce85238705ac0774c74a",
"is_verified": false,
"line_number": 87
},
{
"type": "Secret Keyword",
"filename": "src/lfx/src/lfx/base/models/unified_models.py",
"hashed_secret": "d3d6fe3f7d33d0f4aa28c49544a865982a48a00a",
"is_verified": false,
"line_number": 96
"line_number": 1182
},
{
"type": "Secret Keyword",
"filename": "src/lfx/src/lfx/base/models/unified_models.py",
"hashed_secret": "d4c3d66fd0c38547a3c7a4c6bdc29c36911bc030",
"is_verified": false,
"line_number": 106
},
{
"type": "Secret Keyword",
"filename": "src/lfx/src/lfx/base/models/unified_models.py",
"hashed_secret": "a19ee5a9fdc191092796cd9bfa97c55fe5631695",
"is_verified": false,
"line_number": 380
},
{
"type": "Secret Keyword",
"filename": "src/lfx/src/lfx/base/models/unified_models.py",
"hashed_secret": "b780a23b530ef6bb5c3b722a0c9c5e3abf222e8b",
"is_verified": false,
"line_number": 381
},
{
"type": "Secret Keyword",
"filename": "src/lfx/src/lfx/base/models/unified_models.py",
"hashed_secret": "ed96761b106155514463a9f82d02b14042e75b61",
"is_verified": false,
"line_number": 382
},
{
"type": "Secret Keyword",
"filename": "src/lfx/src/lfx/base/models/unified_models.py",
"hashed_secret": "ac579e82cacca3f9c1a8b18bdfdad78e8b6140f8",
"is_verified": false,
"line_number": 383
"line_number": 1196
}
],
"src/lfx/src/lfx/cli/serve_app.py": [
@ -6373,5 +6338,5 @@
}
]
},
"generated_at": "2026-03-04T23:52:18Z"
"generated_at": "2026-03-25T17:32:51Z"
}

View File

@ -1,6 +1,6 @@
[project]
name = "langflow"
version = "1.8.2"
version = "1.8.3"
description = "A Python package with a built-in web application"
requires-python = ">=3.10,<3.14"
license = "MIT"
@ -17,7 +17,7 @@ maintainers = [
]
# Define your main dependencies here
dependencies = [
"langflow-base[complete]~=0.8.2",
"langflow-base[complete]~=0.8.3",
]

View File

@ -137,6 +137,7 @@ async def upload_user_file(
settings_service: Annotated[SettingsService, Depends(get_settings_service)],
*,
append: bool = False,
ephemeral: bool = False,
) -> UploadFileResponse:
"""Upload a file for the current user and track it in the database."""
# Get the max allowed file size from settings (in MB)
@ -232,6 +233,12 @@ async def upload_user_file(
# General error saving file or getting file size
raise HTTPException(status_code=500, detail=f"Error accessing file: {e}") from e
if ephemeral:
# Ephemeral uploads: file is saved to storage (servable for chat history)
# but no UserFile record is created (won't appear in "My Files")
file_path = f"{current_user.id}/{stored_file_name}"
return UploadFileResponse(id=file_id, name=root_filename, path=file_path, size=file_size)
if append and existing_file:
existing_file.size = file_size
session.add(existing_file)

View File

@ -2123,7 +2123,7 @@
"legacy": false,
"lf_version": "1.4.2",
"metadata": {
"code_hash": "2bd7a064d724",
"code_hash": "3e55e36d0692",
"dependencies": {
"dependencies": [
{
@ -2167,7 +2167,7 @@
"advanced": true,
"display_name": "Allow Dangerous Deserialization",
"dynamic": false,
"info": "Set to True to allow loading pickle files from untrusted sources. Only enable this if you trust the source of the data.",
"info": "Set to True to allow loading pickle files. WARNING: Only enable this if you trust the source of the data. Malicious pickle files can execute arbitrary code on your system.",
"list": false,
"list_add_label": "Add More",
"name": "allow_dangerous_deserialization",
@ -2178,7 +2178,7 @@
"tool_mode": false,
"trace_as_metadata": true,
"type": "bool",
"value": true
"value": false
},
"code": {
"advanced": true,
@ -2196,7 +2196,7 @@
"show": true,
"title_case": false,
"type": "code",
"value": "from pathlib import Path\n\nfrom langchain_community.vectorstores import FAISS\n\nfrom lfx.base.vectorstores.model import LCVectorStoreComponent, check_cached_vector_store\nfrom lfx.helpers.data import docs_to_data\nfrom lfx.io import BoolInput, HandleInput, IntInput, StrInput\nfrom lfx.schema.data import Data\n\n\nclass FaissVectorStoreComponent(LCVectorStoreComponent):\n \"\"\"FAISS Vector Store with search capabilities.\"\"\"\n\n display_name: str = \"FAISS\"\n description: str = \"FAISS Vector Store with search capabilities\"\n name = \"FAISS\"\n icon = \"FAISS\"\n\n inputs = [\n StrInput(\n name=\"index_name\",\n display_name=\"Index Name\",\n value=\"langflow_index\",\n ),\n StrInput(\n name=\"persist_directory\",\n display_name=\"Persist Directory\",\n info=\"Path to save the FAISS index. It will be relative to where Langflow is running.\",\n ),\n *LCVectorStoreComponent.inputs,\n BoolInput(\n name=\"allow_dangerous_deserialization\",\n display_name=\"Allow Dangerous Deserialization\",\n info=\"Set to True to allow loading pickle files from untrusted sources. \"\n \"Only enable this if you trust the source of the data.\",\n advanced=True,\n value=True,\n ),\n HandleInput(name=\"embedding\", display_name=\"Embedding\", input_types=[\"Embeddings\"]),\n IntInput(\n name=\"number_of_results\",\n display_name=\"Number of Results\",\n info=\"Number of results to return.\",\n advanced=True,\n value=4,\n ),\n ]\n\n @staticmethod\n def resolve_path(path: str) -> str:\n \"\"\"Resolve the path relative to the Langflow root.\n\n Args:\n path: The path to resolve\n Returns:\n str: The resolved path as a string\n \"\"\"\n return str(Path(path).resolve())\n\n def get_persist_directory(self) -> Path:\n \"\"\"Returns the resolved persist directory path or the current directory if not set.\"\"\"\n if self.persist_directory:\n return Path(self.resolve_path(self.persist_directory))\n return Path()\n\n @check_cached_vector_store\n def build_vector_store(self) -> FAISS:\n \"\"\"Builds the FAISS object.\"\"\"\n path = self.get_persist_directory()\n path.mkdir(parents=True, exist_ok=True)\n\n # Convert DataFrame to Data if needed using parent's method\n self.ingest_data = self._prepare_ingest_data()\n\n documents = []\n for _input in self.ingest_data or []:\n if isinstance(_input, Data):\n documents.append(_input.to_lc_document())\n else:\n documents.append(_input)\n\n faiss = FAISS.from_documents(documents=documents, embedding=self.embedding)\n faiss.save_local(str(path), self.index_name)\n return faiss\n\n def search_documents(self) -> list[Data]:\n \"\"\"Search for documents in the FAISS vector store.\"\"\"\n path = self.get_persist_directory()\n index_path = path / f\"{self.index_name}.faiss\"\n\n if not index_path.exists():\n vector_store = self.build_vector_store()\n else:\n vector_store = FAISS.load_local(\n folder_path=str(path),\n embeddings=self.embedding,\n index_name=self.index_name,\n allow_dangerous_deserialization=self.allow_dangerous_deserialization,\n )\n\n if not vector_store:\n msg = \"Failed to load the FAISS index.\"\n raise ValueError(msg)\n\n if self.search_query and isinstance(self.search_query, str) and self.search_query.strip():\n docs = vector_store.similarity_search(\n query=self.search_query,\n k=self.number_of_results,\n )\n return docs_to_data(docs)\n return []\n"
"value": "from pathlib import Path\n\nfrom langchain_community.vectorstores import FAISS\n\nfrom lfx.base.vectorstores.model import LCVectorStoreComponent, check_cached_vector_store\nfrom lfx.helpers.data import docs_to_data\nfrom lfx.io import BoolInput, HandleInput, IntInput, StrInput\nfrom lfx.schema.data import Data\n\n\nclass FaissVectorStoreComponent(LCVectorStoreComponent):\n \"\"\"FAISS Vector Store with search capabilities.\"\"\"\n\n display_name: str = \"FAISS\"\n description: str = \"FAISS Vector Store with search capabilities\"\n name = \"FAISS\"\n icon = \"FAISS\"\n\n inputs = [\n StrInput(\n name=\"index_name\",\n display_name=\"Index Name\",\n value=\"langflow_index\",\n ),\n StrInput(\n name=\"persist_directory\",\n display_name=\"Persist Directory\",\n info=\"Path to save the FAISS index. It will be relative to where Langflow is running.\",\n ),\n *LCVectorStoreComponent.inputs,\n BoolInput(\n name=\"allow_dangerous_deserialization\",\n display_name=\"Allow Dangerous Deserialization\",\n info=\"Set to True to allow loading pickle files. WARNING: Only enable this if you trust the source \"\n \"of the data. Malicious pickle files can execute arbitrary code on your system.\",\n advanced=True,\n value=False,\n ),\n HandleInput(name=\"embedding\", display_name=\"Embedding\", input_types=[\"Embeddings\"]),\n IntInput(\n name=\"number_of_results\",\n display_name=\"Number of Results\",\n info=\"Number of results to return.\",\n advanced=True,\n value=4,\n ),\n ]\n\n @staticmethod\n def resolve_path(path: str) -> str:\n \"\"\"Resolve the path relative to the Langflow root.\n\n Args:\n path: The path to resolve\n Returns:\n str: The resolved path as a string\n \"\"\"\n return str(Path(path).resolve())\n\n def get_persist_directory(self) -> Path:\n \"\"\"Returns the resolved persist directory path or the current directory if not set.\"\"\"\n if self.persist_directory:\n return Path(self.resolve_path(self.persist_directory))\n return Path()\n\n @check_cached_vector_store\n def build_vector_store(self) -> FAISS:\n \"\"\"Builds the FAISS object.\"\"\"\n path = self.get_persist_directory()\n path.mkdir(parents=True, exist_ok=True)\n\n # Convert DataFrame to Data if needed using parent's method\n self.ingest_data = self._prepare_ingest_data()\n\n documents = []\n for _input in self.ingest_data or []:\n if isinstance(_input, Data):\n documents.append(_input.to_lc_document())\n else:\n documents.append(_input)\n\n faiss = FAISS.from_documents(documents=documents, embedding=self.embedding)\n faiss.save_local(str(path), self.index_name)\n return faiss\n\n def search_documents(self) -> list[Data]:\n \"\"\"Search for documents in the FAISS vector store.\"\"\"\n path = self.get_persist_directory()\n index_path = path / f\"{self.index_name}.faiss\"\n\n if not index_path.exists():\n vector_store = self.build_vector_store()\n else:\n vector_store = FAISS.load_local(\n folder_path=str(path),\n embeddings=self.embedding,\n index_name=self.index_name,\n allow_dangerous_deserialization=self.allow_dangerous_deserialization,\n )\n\n if not vector_store:\n msg = \"Failed to load the FAISS index.\"\n raise ValueError(msg)\n\n if self.search_query and isinstance(self.search_query, str) and self.search_query.strip():\n docs = vector_store.similarity_search(\n query=self.search_query,\n k=self.number_of_results,\n )\n return docs_to_data(docs)\n return []\n"
},
"embedding": {
"_input_type": "HandleInput",

View File

@ -1,6 +1,6 @@
[project]
name = "langflow-base"
version = "0.8.2"
version = "0.8.3"
description = "A Python package with a built-in web application"
requires-python = ">=3.10,<3.14"
license = "MIT"
@ -17,7 +17,7 @@ maintainers = [
]
dependencies = [
"lfx~=0.3.2",
"lfx~=0.3.3",
"fastapi>=0.135.0,<1.0.0",
"httpx[http2]>=0.27,<1.0.0",
"aiofile>=3.9.0,<4.0.0",

View File

@ -149,6 +149,77 @@ async def test_upload_file(files_client, files_created_api_key):
assert "id" in response_json
async def test_should_not_persist_in_my_files_when_upload_is_ephemeral(files_client, files_created_api_key):
"""Ephemeral uploads save the file to storage but do NOT create a UserFile DB record.
This is the expected behavior for chat playground uploads in Desktop,
where the file must be servable (for chat history) but should not
appear in the user's 'My Files' list.
"""
headers = {"x-api-key": files_created_api_key.api_key}
# Upload with ephemeral=true
response = await files_client.post(
"api/v2/files",
files={"file": ("playground_image.png", b"fake image content")},
params={"ephemeral": "true"},
headers=headers,
)
assert response.status_code == 201, f"Expected 201, got {response.status_code}: {response.text}"
upload_response = response.json()
assert "path" in upload_response
# The file must NOT appear in the user's file list
list_response = await files_client.get("api/v2/files", headers=headers)
assert list_response.status_code == 200
file_names = [f["name"] for f in list_response.json()]
assert "playground_image" not in file_names, (
f"Ephemeral file should not appear in My Files, but found: {file_names}"
)
# The file is saved in storage and the response includes a valid path
file_path = upload_response["path"]
assert file_path, "Ephemeral upload should return a non-empty path"
# Path format: {user_id}/{stored_file_name}
parts = file_path.split("/")
assert len(parts) == 2, f"Expected path format 'user_id/filename', got: {file_path}"
async def test_should_return_path_with_forward_slashes_when_uploading_file(files_client, files_created_api_key):
"""Upload response path must use forward slashes on all platforms.
On Windows, pathlib.Path serializes with backslashes, but the GET list
endpoint returns the raw DB string with forward slashes. If the POST
response uses backslashes, the frontend cannot match them with
selectedFiles.includes(file.path), leaving checkboxes unchecked.
"""
headers = {"x-api-key": files_created_api_key.api_key}
response = await files_client.post(
"api/v2/files",
files={"file": ("test_path.txt", b"path test content")},
headers=headers,
)
assert response.status_code == 201
upload_path = response.json()["path"]
assert "\\" not in upload_path, (
f"Upload response path contains backslashes: '{upload_path}'. "
"Path must use forward slashes on all platforms for frontend compatibility."
)
# Verify the upload path matches what GET /files returns
list_response = await files_client.get("api/v2/files", headers=headers)
assert list_response.status_code == 200
listed_paths = [f["path"] for f in list_response.json()]
assert upload_path in listed_paths, (
f"Upload path '{upload_path}' not found in listed paths {listed_paths}. "
"POST and GET must return identical path strings."
)
async def test_download_file(files_client, files_created_api_key):
headers = {"x-api-key": files_created_api_key.api_key}

View File

@ -0,0 +1,102 @@
"""Tests for credential resolution in the unified models system.
Bug: Desktop Global Variable OPENAI_API_KEY not injected at runtime.
When api_key parameter is None (Agent component default), get_api_key_for_provider
only attempts DB lookup but has no os.getenv() fallback and no error handling
for ValueError from get_variable(). This causes failures in Desktop where the
env var is not set and the DB lookup is the only path.
"""
from unittest.mock import patch
from uuid import uuid4
class TestGetApiKeyForProviderDbFallback:
"""Tests for get_api_key_for_provider when api_key param is None (second path)."""
@patch("lfx.base.models.unified_models.get_model_provider_variable_mapping")
@patch("lfx.base.models.unified_models.run_until_complete")
def test_should_fallback_to_env_when_db_lookup_raises_value_error(self, mock_run, mock_mapping, monkeypatch):
"""When variable_service.get_variable raises ValueError (variable not found in DB).
get_api_key_for_provider should fall back to os.getenv() instead of returning None.
"""
from lfx.base.models.unified_models import get_api_key_for_provider
user_id = str(uuid4())
mock_mapping.return_value = {"OpenAI": "OPENAI_API_KEY"}
mock_run.side_effect = ValueError("OPENAI_API_KEY variable not found.")
monkeypatch.setenv("OPENAI_API_KEY", "sk-test-env-key")
result = get_api_key_for_provider(user_id, "OpenAI", None)
assert result == "sk-test-env-key"
@patch("lfx.base.models.unified_models.get_model_provider_variable_mapping")
@patch("lfx.base.models.unified_models.run_until_complete")
def test_should_fallback_to_env_when_db_lookup_returns_empty_string(self, mock_run, mock_mapping, monkeypatch):
"""When decryption fails, get_variable returns empty string.
get_api_key_for_provider should fall back to os.getenv().
"""
from lfx.base.models.unified_models import get_api_key_for_provider
user_id = str(uuid4())
mock_mapping.return_value = {"OpenAI": "OPENAI_API_KEY"}
mock_run.return_value = ""
monkeypatch.setenv("OPENAI_API_KEY", "sk-test-env-key")
result = get_api_key_for_provider(user_id, "OpenAI", None)
assert result == "sk-test-env-key"
@patch("lfx.base.models.unified_models.get_model_provider_variable_mapping")
@patch("lfx.base.models.unified_models.run_until_complete")
def test_should_fallback_to_env_when_variable_service_is_none(self, mock_run, mock_mapping, monkeypatch):
"""When variable_service is None (service not available in thread context).
get_api_key_for_provider should fall back to os.getenv().
"""
from lfx.base.models.unified_models import get_api_key_for_provider
user_id = str(uuid4())
mock_mapping.return_value = {"OpenAI": "OPENAI_API_KEY"}
mock_run.return_value = None
monkeypatch.setenv("OPENAI_API_KEY", "sk-test-env-key")
result = get_api_key_for_provider(user_id, "OpenAI", None)
assert result == "sk-test-env-key"
@patch("lfx.base.models.unified_models.get_model_provider_variable_mapping")
@patch("lfx.base.models.unified_models.run_until_complete")
def test_should_return_none_when_both_db_and_env_unavailable(self, mock_run, mock_mapping, monkeypatch):
"""When both DB lookup and env var are unavailable, should return None."""
from lfx.base.models.unified_models import get_api_key_for_provider
user_id = str(uuid4())
mock_mapping.return_value = {"OpenAI": "OPENAI_API_KEY"}
mock_run.return_value = None
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
result = get_api_key_for_provider(user_id, "OpenAI", None)
assert result is None
@patch("lfx.base.models.unified_models.get_model_provider_variable_mapping")
@patch("lfx.base.models.unified_models.run_until_complete")
def test_should_return_db_value_when_db_lookup_succeeds(self, mock_run, mock_mapping):
"""When DB lookup succeeds, should return the DB value (no env fallback needed)."""
from lfx.base.models.unified_models import get_api_key_for_provider
user_id = str(uuid4())
mock_mapping.return_value = {"OpenAI": "OPENAI_API_KEY"}
mock_run.return_value = "sk-from-database"
result = get_api_key_for_provider(user_id, "OpenAI", None)
assert result == "sk-from-database"

View File

@ -1,6 +1,11 @@
from unittest.mock import MagicMock
import os
from unittest.mock import MagicMock, patch
from lfx.base.models.unified_models import get_unified_models_detailed, update_model_options_in_build_config
from lfx.base.models.unified_models import (
apply_provider_variable_config_to_build_config,
get_unified_models_detailed,
update_model_options_in_build_config,
)
def _flatten_models(result):
@ -81,16 +86,16 @@ def test_filter_by_model_type_embeddings():
def test_update_model_options_with_custom_field_name():
"""Test that update_model_options_in_build_config works with custom field names."""
"""Test that update_model_options_in_build_config populates the 'model' field with embedding options."""
# Create mock component
mock_component = MagicMock()
mock_component.user_id = "test-user-123"
mock_component.cache = {}
mock_component.log = MagicMock()
# Create build_config with custom field name
# Create build_config using the standard 'model' field (used for embeddings too)
build_config = {
"embedding_model": {
"model": {
"options": [],
"value": "",
"input_types": ["Embeddings"],
@ -104,25 +109,24 @@ def test_update_model_options_with_custom_field_name():
{"name": "embed-english-v3.0", "provider": "Cohere"},
]
# Call with custom field name
# Call with embedding cache key prefix
result = update_model_options_in_build_config(
component=mock_component,
build_config=build_config,
cache_key_prefix="test_embedding_options",
cache_key_prefix="embedding_model_options",
get_options_func=mock_get_options,
field_name=None,
field_value="",
model_field_name="embedding_model",
)
# Verify options were populated in the custom field
assert "embedding_model" in result
assert len(result["embedding_model"]["options"]) == 2
assert result["embedding_model"]["options"][0]["name"] == "text-embedding-ada-002"
assert result["embedding_model"]["options"][1]["provider"] == "Cohere"
# Verify options were populated in the model field
assert "model" in result
assert len(result["model"]["options"]) == 2
assert result["model"]["options"][0]["name"] == "text-embedding-ada-002"
assert result["model"]["options"][1]["provider"] == "Cohere"
# Verify default value was set
assert result["embedding_model"]["value"] == [result["embedding_model"]["options"][0]]
assert result["model"]["value"] == [result["model"]["options"][0]]
def test_update_model_options_default_field_name():
@ -160,3 +164,227 @@ def test_update_model_options_default_field_name():
assert "model" in result
assert len(result["model"]["options"]) == 1
assert result["model"]["options"][0]["name"] == "gpt-4"
# Tests for apply_provider_variable_config_to_build_config
def test_apply_provider_config_auto_sets_env_var_for_empty_field():
"""Test that env var is auto-set when field is empty."""
mock_provider_vars = [
{
"variable_key": "OPENAI_API_KEY",
"component_metadata": {
"mapping_field": "openai_api_key",
"required": True,
"advanced": False,
"info": "OpenAI API Key",
},
}
]
build_config = {
"openai_api_key": {
"value": "",
"show": False,
"required": False,
"advanced": False,
"load_from_db": False,
}
}
with (
patch("lfx.base.models.unified_models.get_provider_all_variables", return_value=mock_provider_vars),
patch("lfx.base.models.unified_models._get_all_provider_specific_field_names", return_value=["openai_api_key"]),
patch.dict(os.environ, {"OPENAI_API_KEY": "test-key-123"}), # pragma: allowlist secret
):
result = apply_provider_variable_config_to_build_config(build_config, "OpenAI")
# Should auto-set the env var key for empty field
assert result["openai_api_key"]["value"] == "OPENAI_API_KEY"
assert result["openai_api_key"]["load_from_db"] is True
assert result["openai_api_key"]["show"] is True
assert result["openai_api_key"]["required"] is True
def test_apply_provider_config_preserves_user_selected_value():
"""Test that user-selected values are NOT overwritten."""
mock_provider_vars = [
{
"variable_key": "OPENAI_API_KEY",
"component_metadata": {
"mapping_field": "openai_api_key",
"required": True,
},
}
]
# User has already selected a different global variable
build_config = {
"openai_api_key": {
"value": "MY_CUSTOM_API_KEY",
"load_from_db": True,
"show": False,
"required": False,
}
}
with (
patch("lfx.base.models.unified_models.get_provider_all_variables", return_value=mock_provider_vars),
patch("lfx.base.models.unified_models._get_all_provider_specific_field_names", return_value=["openai_api_key"]),
patch.dict(os.environ, {"OPENAI_API_KEY": "test-key-123"}), # pragma: allowlist secret
):
result = apply_provider_variable_config_to_build_config(build_config, "OpenAI")
# Should preserve user's selection
assert result["openai_api_key"]["value"] == "MY_CUSTOM_API_KEY"
assert result["openai_api_key"]["load_from_db"] is True
def test_apply_provider_config_replaces_hardcoded_with_env_var():
"""Test that hardcoded values are replaced with env var key for global variable usage.
Expected behavior: When load_from_db=False (hardcoded value), the function
replaces it with the env var key and sets load_from_db=True to enable
global variable functionality.
"""
mock_provider_vars = [
{
"variable_key": "OPENAI_API_KEY",
"component_metadata": {
"mapping_field": "openai_api_key",
"required": True,
},
}
]
# User has hardcoded a value (not loading from db)
build_config = {
"openai_api_key": {
"value": "sk-hardcoded-key",
"load_from_db": False,
"show": False,
"required": False,
}
}
with (
patch("lfx.base.models.unified_models.get_provider_all_variables", return_value=mock_provider_vars),
patch("lfx.base.models.unified_models._get_all_provider_specific_field_names", return_value=["openai_api_key"]),
patch.dict(os.environ, {"OPENAI_API_KEY": "test-key-123"}), # pragma: allowlist secret
):
result = apply_provider_variable_config_to_build_config(build_config, "OpenAI")
# Should overwrite with env var key since load_from_db was False
assert result["openai_api_key"]["value"] == "OPENAI_API_KEY"
assert result["openai_api_key"]["load_from_db"] is True
def test_apply_provider_config_skips_when_env_var_not_set():
"""Test that nothing is set when env var doesn't exist."""
mock_provider_vars = [
{
"variable_key": "OPENAI_API_KEY",
"component_metadata": {
"mapping_field": "openai_api_key",
"required": True,
},
}
]
build_config = {
"openai_api_key": {
"value": "",
"show": False,
"required": False,
"load_from_db": False,
}
}
with (
patch("lfx.base.models.unified_models.get_provider_all_variables", return_value=mock_provider_vars),
patch("lfx.base.models.unified_models._get_all_provider_specific_field_names", return_value=["openai_api_key"]),
patch.dict(os.environ, {}, clear=True),
):
result = apply_provider_variable_config_to_build_config(build_config, "OpenAI")
# Should not set anything when env var doesn't exist
assert result["openai_api_key"]["value"] == ""
assert result["openai_api_key"]["load_from_db"] is False
def test_apply_provider_config_applies_component_metadata():
"""Test that component metadata (required, advanced, info) is applied."""
mock_provider_vars = [
{
"variable_key": "OPENAI_API_KEY",
"component_metadata": {
"mapping_field": "openai_api_key",
"required": True,
"advanced": False,
"info": "OpenAI API Key",
},
}
]
build_config = {
"openai_api_key": {
"value": "",
"show": False,
"required": False,
"advanced": False,
"load_from_db": False,
}
}
with (
patch("lfx.base.models.unified_models.get_provider_all_variables", return_value=mock_provider_vars),
patch("lfx.base.models.unified_models._get_all_provider_specific_field_names", return_value=["openai_api_key"]),
patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}), # pragma: allowlist secret
):
result = apply_provider_variable_config_to_build_config(build_config, "OpenAI")
# Check metadata was applied
assert result["openai_api_key"]["required"] is True
assert result["openai_api_key"]["advanced"] is False
assert result["openai_api_key"]["info"] == "OpenAI API Key"
assert result["openai_api_key"]["show"] is True
def test_apply_provider_config_idempotent_when_already_set():
"""Test idempotent behavior when value already matches env var key.
When a field already has the env var key as its value and load_from_db=True,
the function should be a no-op (no changes made). This documents the
idempotent behavior of the function.
"""
mock_provider_vars = [
{
"variable_key": "OPENAI_API_KEY",
"component_metadata": {
"mapping_field": "openai_api_key",
"required": True,
},
}
]
# Field already configured with env var key
build_config = {
"openai_api_key": {
"value": "OPENAI_API_KEY",
"load_from_db": True,
"show": False,
"required": False,
}
}
with (
patch("lfx.base.models.unified_models.get_provider_all_variables", return_value=mock_provider_vars),
patch("lfx.base.models.unified_models._get_all_provider_specific_field_names", return_value=["openai_api_key"]),
patch.dict(os.environ, {"OPENAI_API_KEY": "test-key-123"}), # pragma: allowlist secret
):
result = apply_provider_variable_config_to_build_config(build_config, "OpenAI")
# Should remain unchanged (idempotent)
assert result["openai_api_key"]["value"] == "OPENAI_API_KEY"
assert result["openai_api_key"]["load_from_db"] is True

View File

@ -1,12 +1,12 @@
{
"name": "langflow",
"version": "1.8.2",
"version": "1.8.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "langflow",
"version": "1.8.2",
"version": "1.8.3",
"dependencies": {
"@chakra-ui/number-input": "^2.1.2",
"@chakra-ui/system": "^2.6.2",

View File

@ -1,6 +1,6 @@
{
"name": "langflow",
"version": "1.8.2",
"version": "1.8.3",
"private": true,
"engines": {
"node": ">=20.19.0"

View File

@ -1,6 +1,6 @@
[project]
name = "lfx"
version = "0.3.2"
version = "0.3.3"
description = "Langflow Executor - A lightweight CLI tool for executing and serving Langflow AI flows"
readme = "README.md"
authors = [

View File

@ -29,7 +29,7 @@
"icon": "FAISS",
"legacy": false,
"metadata": {
"code_hash": "2bd7a064d724",
"code_hash": "3e55e36d0692",
"dependencies": {
"dependencies": [
{
@ -85,7 +85,7 @@
"advanced": true,
"display_name": "Allow Dangerous Deserialization",
"dynamic": false,
"info": "Set to True to allow loading pickle files from untrusted sources. Only enable this if you trust the source of the data.",
"info": "Set to True to allow loading pickle files. WARNING: Only enable this if you trust the source of the data. Malicious pickle files can execute arbitrary code on your system.",
"list": false,
"list_add_label": "Add More",
"name": "allow_dangerous_deserialization",
@ -98,7 +98,7 @@
"trace_as_metadata": true,
"track_in_telemetry": true,
"type": "bool",
"value": true
"value": false
},
"code": {
"advanced": true,
@ -116,7 +116,7 @@
"show": true,
"title_case": false,
"type": "code",
"value": "from pathlib import Path\n\nfrom langchain_community.vectorstores import FAISS\n\nfrom lfx.base.vectorstores.model import LCVectorStoreComponent, check_cached_vector_store\nfrom lfx.helpers.data import docs_to_data\nfrom lfx.io import BoolInput, HandleInput, IntInput, StrInput\nfrom lfx.schema.data import Data\n\n\nclass FaissVectorStoreComponent(LCVectorStoreComponent):\n \"\"\"FAISS Vector Store with search capabilities.\"\"\"\n\n display_name: str = \"FAISS\"\n description: str = \"FAISS Vector Store with search capabilities\"\n name = \"FAISS\"\n icon = \"FAISS\"\n\n inputs = [\n StrInput(\n name=\"index_name\",\n display_name=\"Index Name\",\n value=\"langflow_index\",\n ),\n StrInput(\n name=\"persist_directory\",\n display_name=\"Persist Directory\",\n info=\"Path to save the FAISS index. It will be relative to where Langflow is running.\",\n ),\n *LCVectorStoreComponent.inputs,\n BoolInput(\n name=\"allow_dangerous_deserialization\",\n display_name=\"Allow Dangerous Deserialization\",\n info=\"Set to True to allow loading pickle files from untrusted sources. \"\n \"Only enable this if you trust the source of the data.\",\n advanced=True,\n value=True,\n ),\n HandleInput(name=\"embedding\", display_name=\"Embedding\", input_types=[\"Embeddings\"]),\n IntInput(\n name=\"number_of_results\",\n display_name=\"Number of Results\",\n info=\"Number of results to return.\",\n advanced=True,\n value=4,\n ),\n ]\n\n @staticmethod\n def resolve_path(path: str) -> str:\n \"\"\"Resolve the path relative to the Langflow root.\n\n Args:\n path: The path to resolve\n Returns:\n str: The resolved path as a string\n \"\"\"\n return str(Path(path).resolve())\n\n def get_persist_directory(self) -> Path:\n \"\"\"Returns the resolved persist directory path or the current directory if not set.\"\"\"\n if self.persist_directory:\n return Path(self.resolve_path(self.persist_directory))\n return Path()\n\n @check_cached_vector_store\n def build_vector_store(self) -> FAISS:\n \"\"\"Builds the FAISS object.\"\"\"\n path = self.get_persist_directory()\n path.mkdir(parents=True, exist_ok=True)\n\n # Convert DataFrame to Data if needed using parent's method\n self.ingest_data = self._prepare_ingest_data()\n\n documents = []\n for _input in self.ingest_data or []:\n if isinstance(_input, Data):\n documents.append(_input.to_lc_document())\n else:\n documents.append(_input)\n\n faiss = FAISS.from_documents(documents=documents, embedding=self.embedding)\n faiss.save_local(str(path), self.index_name)\n return faiss\n\n def search_documents(self) -> list[Data]:\n \"\"\"Search for documents in the FAISS vector store.\"\"\"\n path = self.get_persist_directory()\n index_path = path / f\"{self.index_name}.faiss\"\n\n if not index_path.exists():\n vector_store = self.build_vector_store()\n else:\n vector_store = FAISS.load_local(\n folder_path=str(path),\n embeddings=self.embedding,\n index_name=self.index_name,\n allow_dangerous_deserialization=self.allow_dangerous_deserialization,\n )\n\n if not vector_store:\n msg = \"Failed to load the FAISS index.\"\n raise ValueError(msg)\n\n if self.search_query and isinstance(self.search_query, str) and self.search_query.strip():\n docs = vector_store.similarity_search(\n query=self.search_query,\n k=self.number_of_results,\n )\n return docs_to_data(docs)\n return []\n"
"value": "from pathlib import Path\n\nfrom langchain_community.vectorstores import FAISS\n\nfrom lfx.base.vectorstores.model import LCVectorStoreComponent, check_cached_vector_store\nfrom lfx.helpers.data import docs_to_data\nfrom lfx.io import BoolInput, HandleInput, IntInput, StrInput\nfrom lfx.schema.data import Data\n\n\nclass FaissVectorStoreComponent(LCVectorStoreComponent):\n \"\"\"FAISS Vector Store with search capabilities.\"\"\"\n\n display_name: str = \"FAISS\"\n description: str = \"FAISS Vector Store with search capabilities\"\n name = \"FAISS\"\n icon = \"FAISS\"\n\n inputs = [\n StrInput(\n name=\"index_name\",\n display_name=\"Index Name\",\n value=\"langflow_index\",\n ),\n StrInput(\n name=\"persist_directory\",\n display_name=\"Persist Directory\",\n info=\"Path to save the FAISS index. It will be relative to where Langflow is running.\",\n ),\n *LCVectorStoreComponent.inputs,\n BoolInput(\n name=\"allow_dangerous_deserialization\",\n display_name=\"Allow Dangerous Deserialization\",\n info=\"Set to True to allow loading pickle files. WARNING: Only enable this if you trust the source \"\n \"of the data. Malicious pickle files can execute arbitrary code on your system.\",\n advanced=True,\n value=False,\n ),\n HandleInput(name=\"embedding\", display_name=\"Embedding\", input_types=[\"Embeddings\"]),\n IntInput(\n name=\"number_of_results\",\n display_name=\"Number of Results\",\n info=\"Number of results to return.\",\n advanced=True,\n value=4,\n ),\n ]\n\n @staticmethod\n def resolve_path(path: str) -> str:\n \"\"\"Resolve the path relative to the Langflow root.\n\n Args:\n path: The path to resolve\n Returns:\n str: The resolved path as a string\n \"\"\"\n return str(Path(path).resolve())\n\n def get_persist_directory(self) -> Path:\n \"\"\"Returns the resolved persist directory path or the current directory if not set.\"\"\"\n if self.persist_directory:\n return Path(self.resolve_path(self.persist_directory))\n return Path()\n\n @check_cached_vector_store\n def build_vector_store(self) -> FAISS:\n \"\"\"Builds the FAISS object.\"\"\"\n path = self.get_persist_directory()\n path.mkdir(parents=True, exist_ok=True)\n\n # Convert DataFrame to Data if needed using parent's method\n self.ingest_data = self._prepare_ingest_data()\n\n documents = []\n for _input in self.ingest_data or []:\n if isinstance(_input, Data):\n documents.append(_input.to_lc_document())\n else:\n documents.append(_input)\n\n faiss = FAISS.from_documents(documents=documents, embedding=self.embedding)\n faiss.save_local(str(path), self.index_name)\n return faiss\n\n def search_documents(self) -> list[Data]:\n \"\"\"Search for documents in the FAISS vector store.\"\"\"\n path = self.get_persist_directory()\n index_path = path / f\"{self.index_name}.faiss\"\n\n if not index_path.exists():\n vector_store = self.build_vector_store()\n else:\n vector_store = FAISS.load_local(\n folder_path=str(path),\n embeddings=self.embedding,\n index_name=self.index_name,\n allow_dangerous_deserialization=self.allow_dangerous_deserialization,\n )\n\n if not vector_store:\n msg = \"Failed to load the FAISS index.\"\n raise ValueError(msg)\n\n if self.search_query and isinstance(self.search_query, str) and self.search_query.strip():\n docs = vector_store.similarity_search(\n query=self.search_query,\n k=self.number_of_results,\n )\n return docs_to_data(docs)\n return []\n"
},
"embedding": {
"_input_type": "HandleInput",
@ -118191,6 +118191,6 @@
"num_components": 359,
"num_modules": 97
},
"sha256": "c2c714d9bec52ae89ed53341db3be1653da036cb8259013f204df4707bdbf757",
"version": "0.3.1"
"sha256": "8fc3c7cb1bdf0873d2374de24e6e6e9a0bed598adbd0ef95964db455b2c937b6",
"version": "0.3.3"
}

File diff suppressed because it is too large Load Diff

View File

@ -280,14 +280,26 @@ def apply_provider_variable_config_to_build_config(
env_var_key = var_info.get("variable_key")
if env_var_key:
env_value = os.environ.get(env_var_key)
# Only set the value if the field is currently empty or not already set to load from db
# This prevents overwriting user-selected global variables
current_value = field_config.get("value")
current_load_from_db = field_config.get("load_from_db", False)
if env_value and str(env_value).strip():
field_config["value"] = env_var_key
field_config["load_from_db"] = True
logger.debug(
"Set field %s to env var name %s (value resolved at runtime)",
field_name,
env_var_key,
)
# Only auto-set if field is empty or not already loading from db
if not current_value or (not current_load_from_db):
field_config["value"] = env_var_key
field_config["load_from_db"] = True
logger.debug(
"Set field %s to env var name %s (value resolved at runtime)",
field_name,
env_var_key,
)
else:
logger.debug(
"Skipping auto-set for field %s - user has already selected a value (load_from_db=True)",
field_name,
)
return build_config
@ -503,20 +515,31 @@ def get_api_key_for_provider(user_id: UUID | str | None, provider: str, api_key:
if not variable_name:
return None
# Try to get from global variables
# Try to get from global variables, fall back to environment
async def _get_variable():
async with session_scope() as session:
variable_service = get_variable_service()
if variable_service is None:
return None
return await variable_service.get_variable(
user_id=UUID(user_id) if isinstance(user_id, str) else user_id,
name=variable_name,
field="",
session=session,
)
try:
return await variable_service.get_variable(
user_id=UUID(user_id) if isinstance(user_id, str) else user_id,
name=variable_name,
field="",
session=session,
)
except ValueError:
return None
return run_until_complete(_get_variable())
try:
api_key = run_until_complete(_get_variable())
except (ValueError, Exception): # noqa: BLE001
api_key = None
if api_key:
return api_key
return os.getenv(variable_name)
def get_all_variables_for_provider(user_id: UUID | str | None, provider: str) -> dict[str, str]:

View File

@ -31,10 +31,10 @@ class FaissVectorStoreComponent(LCVectorStoreComponent):
BoolInput(
name="allow_dangerous_deserialization",
display_name="Allow Dangerous Deserialization",
info="Set to True to allow loading pickle files from untrusted sources. "
"Only enable this if you trust the source of the data.",
info="Set to True to allow loading pickle files. WARNING: Only enable this if you trust the source "
"of the data. Malicious pickle files can execute arbitrary code on your system.",
advanced=True,
value=True,
value=False,
),
HandleInput(name="embedding", display_name="Embedding", input_types=["Embeddings"]),
IntInput(

View File

@ -0,0 +1,37 @@
"""Regression tests for FaissVectorStoreComponent security defaults."""
import pytest
pytest.importorskip("langchain_community")
from lfx.components.FAISS.faiss import FaissVectorStoreComponent
from lfx.io import BoolInput
class TestFaissVectorStoreComponentDefaults:
"""Regression tests ensuring FAISS component security defaults do not regress."""
def test_allow_dangerous_deserialization_defaults_to_false(self):
"""Regression test: allow_dangerous_deserialization must default to False.
This guards against accidentally re-enabling unsafe pickle deserialization
(RCE vulnerability PVR0699083). Do not change this default to True.
"""
input_def = next(
inp
for inp in FaissVectorStoreComponent.inputs
if isinstance(inp, BoolInput) and inp.name == "allow_dangerous_deserialization"
)
assert input_def.value is False, (
"allow_dangerous_deserialization must default to False to prevent RCE via malicious pickle files. "
"See security issue PVR0699083."
)
def test_allow_dangerous_deserialization_is_advanced(self):
"""allow_dangerous_deserialization should be an advanced field to reduce accidental enabling."""
input_def = next(
inp
for inp in FaissVectorStoreComponent.inputs
if isinstance(inp, BoolInput) and inp.name == "allow_dangerous_deserialization"
)
assert input_def.advanced is True

9
uv.lock generated
View File

@ -3945,6 +3945,7 @@ dependencies = [
{ name = "griffecli" },
{ name = "griffelib" },
]
sdist = { url = "https://files.pythonhosted.org/packages/04/56/28a0accac339c164b52a92c6cfc45a903acc0c174caa5c1713803467b533/griffe-2.0.0.tar.gz", hash = "sha256:c68979cd8395422083a51ea7cf02f9c119d889646d99b7b656ee43725de1b80f", size = 293906, upload-time = "2026-03-23T21:06:53.402Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/94/ee21d41e7eb4f823b94603b9d40f86d3c7fde80eacc2c3c71845476dddaa/griffe-2.0.0-py3-none-any.whl", hash = "sha256:5418081135a391c3e6e757a7f3f156f1a1a746cc7b4023868ff7d5e2f9a980aa", size = 5214, upload-time = "2026-02-09T19:09:44.105Z" },
]
@ -3957,6 +3958,7 @@ dependencies = [
{ name = "colorama" },
{ name = "griffelib" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a4/f8/2e129fd4a86e52e58eefe664de05e7d502decf766e7316cc9e70fdec3e18/griffecli-2.0.0.tar.gz", hash = "sha256:312fa5ebb4ce6afc786356e2d0ce85b06c1c20d45abc42d74f0cda65e159f6ef", size = 56213, upload-time = "2026-03-23T21:06:54.8Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/ed/d93f7a447bbf7a935d8868e9617cbe1cadf9ee9ee6bd275d3040fbf93d60/griffecli-2.0.0-py3-none-any.whl", hash = "sha256:9f7cd9ee9b21d55e91689358978d2385ae65c22f307a63fb3269acf3f21e643d", size = 9345, upload-time = "2026-02-09T19:09:42.554Z" },
]
@ -3965,6 +3967,7 @@ wheels = [
name = "griffelib"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ad/06/eccbd311c9e2b3ca45dbc063b93134c57a1ccc7607c5e545264ad092c4a9/griffelib-2.0.0.tar.gz", hash = "sha256:e504d637a089f5cab9b5daf18f7645970509bf4f53eda8d79ed71cce8bd97934", size = 166312, upload-time = "2026-03-23T21:06:55.954Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4d/51/c936033e16d12b627ea334aaaaf42229c37620d0f15593456ab69ab48161/griffelib-2.0.0-py3-none-any.whl", hash = "sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f", size = 142004, upload-time = "2026-02-09T19:09:40.561Z" },
]
@ -5738,7 +5741,7 @@ wheels = [
[[package]]
name = "langflow"
version = "1.8.2"
version = "1.8.3"
source = { editable = "." }
dependencies = [
{ name = "langflow-base", extra = ["complete"] },
@ -5916,7 +5919,7 @@ dev = [
[[package]]
name = "langflow-base"
version = "0.8.2"
version = "0.8.3"
source = { editable = "src/backend/base" }
dependencies = [
{ name = "aiofile" },
@ -7096,7 +7099,7 @@ wheels = [
[[package]]
name = "lfx"
version = "0.3.2"
version = "0.3.3"
source = { editable = "src/lfx" }
dependencies = [
{ name = "ag-ui-protocol" },