diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index cb71de06ca..6db6d3aa79 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/Dockerfile
@@ -1,4 +1,4 @@
-FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
+FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim
# Set timezone
ENV TZ=UTC
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7482586272..ae7f95d2f7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -11,7 +11,7 @@ on:
description: "Python Versions"
required: false
type: string
- default: "['3.10']"
+ default: "['3.10', '3.14']"
frontend-tests-folder:
description: "Frontend Tests Folder"
required: false
@@ -50,7 +50,7 @@ on:
description: "Python Versions"
required: false
type: string
- default: "['3.10']"
+ default: "['3.10', '3.14']"
runs-on:
description: "Runner to use for the tests"
required: false
@@ -261,7 +261,7 @@ jobs:
)
uses: ./.github/workflows/python_test.yml
with:
- python-versions: ${{ inputs.python-versions || '["3.10"]' }}
+ python-versions: ${{ inputs.python-versions || '["3.10", "3.14"]' }}
runs-on: ${{ inputs['runs-on'] || github.event.inputs['runs-on'] || 'ubuntu-latest' }}
ref: ${{ inputs.ref || github.ref }}
secrets:
diff --git a/.github/workflows/cross-platform-test.yml b/.github/workflows/cross-platform-test.yml
index 01d39bb40f..46aa680c8f 100644
--- a/.github/workflows/cross-platform-test.yml
+++ b/.github/workflows/cross-platform-test.yml
@@ -584,9 +584,26 @@ jobs:
uv venv test-env --seed
shell: bash
- # Wheel installation steps — install langflow-sdk before LFX (sdk is not yet on PyPI)
- - name: Install langflow-sdk from wheel (Windows)
- if: steps.install-method.outputs.method == 'wheel' && matrix.os == 'windows' && (inputs.sdk-artifact-name != '' || needs.build-if-needed.outputs.sdk-artifact-name != '')
+ # Wheel installation steps — install SDK and LFX together when both are present.
+ - name: Install SDK and LFX packages from wheel (Windows)
+ if: steps.install-method.outputs.method == 'wheel' && matrix.os == 'windows' && (inputs.sdk-artifact-name != '' || needs.build-if-needed.outputs.sdk-artifact-name != '') && (inputs.lfx-artifact-name != '' || needs.build-if-needed.outputs.lfx-artifact-name != '')
+ run: |
+ ls -la ./sdk-dist/
+ find ./sdk-dist -name "*.whl" -type f
+ ls -la ./lfx-dist/
+ find ./lfx-dist -name "*.whl" -type f
+ SDK_WHEEL_FILE=$(find ./sdk-dist -name "*.whl" -type f | head -1)
+ LFX_WHEEL_FILE=$(find ./lfx-dist -name "*.whl" -type f | head -1)
+ if [ -n "$SDK_WHEEL_FILE" ] && [ -n "$LFX_WHEEL_FILE" ]; then
+ uv pip install --prerelease=allow --python ./test-env/Scripts/python.exe "$SDK_WHEEL_FILE" "$LFX_WHEEL_FILE"
+ else
+ echo "Missing wheel file in ./sdk-dist/ or ./lfx-dist/"
+ exit 1
+ fi
+ shell: bash
+
+ - name: Install SDK package from wheel (Windows)
+ if: steps.install-method.outputs.method == 'wheel' && matrix.os == 'windows' && (inputs.sdk-artifact-name != '' || needs.build-if-needed.outputs.sdk-artifact-name != '') && (inputs.lfx-artifact-name == '' && needs.build-if-needed.outputs.lfx-artifact-name == '')
run: |
WHEEL_FILE=$(find ./sdk-dist -name "*.whl" -type f | head -1)
if [ -n "$WHEEL_FILE" ]; then
@@ -597,8 +614,25 @@ jobs:
fi
shell: bash
- - name: Install langflow-sdk from wheel (Unix)
- if: steps.install-method.outputs.method == 'wheel' && matrix.os != 'windows' && (inputs.sdk-artifact-name != '' || needs.build-if-needed.outputs.sdk-artifact-name != '')
+ - name: Install SDK and LFX packages from wheel (Unix)
+ if: steps.install-method.outputs.method == 'wheel' && matrix.os != 'windows' && (inputs.sdk-artifact-name != '' || needs.build-if-needed.outputs.sdk-artifact-name != '') && (inputs.lfx-artifact-name != '' || needs.build-if-needed.outputs.lfx-artifact-name != '')
+ run: |
+ ls -la ./sdk-dist/
+ find ./sdk-dist -name "*.whl" -type f
+ ls -la ./lfx-dist/
+ find ./lfx-dist -name "*.whl" -type f
+ SDK_WHEEL_FILE=$(find ./sdk-dist -name "*.whl" -type f | head -1)
+ LFX_WHEEL_FILE=$(find ./lfx-dist -name "*.whl" -type f | head -1)
+ if [ -n "$SDK_WHEEL_FILE" ] && [ -n "$LFX_WHEEL_FILE" ]; then
+ uv pip install --prerelease=allow --python ./test-env/bin/python "$SDK_WHEEL_FILE" "$LFX_WHEEL_FILE"
+ else
+ echo "Missing wheel file in ./sdk-dist/ or ./lfx-dist/"
+ exit 1
+ fi
+ shell: bash
+
+ - name: Install SDK package from wheel (Unix)
+ if: steps.install-method.outputs.method == 'wheel' && matrix.os != 'windows' && (inputs.sdk-artifact-name != '' || needs.build-if-needed.outputs.sdk-artifact-name != '') && (inputs.lfx-artifact-name == '' && needs.build-if-needed.outputs.lfx-artifact-name == '')
run: |
WHEEL_FILE=$(find ./sdk-dist -name "*.whl" -type f | head -1)
if [ -n "$WHEEL_FILE" ]; then
@@ -610,7 +644,7 @@ jobs:
shell: bash
- name: Install LFX package from wheel (Windows)
- if: steps.install-method.outputs.method == 'wheel' && matrix.os == 'windows' && (inputs.lfx-artifact-name != '' || needs.build-if-needed.outputs.lfx-artifact-name != '')
+ if: steps.install-method.outputs.method == 'wheel' && matrix.os == 'windows' && (inputs.lfx-artifact-name != '' || needs.build-if-needed.outputs.lfx-artifact-name != '') && (inputs.sdk-artifact-name == '' && needs.build-if-needed.outputs.sdk-artifact-name == '')
run: |
ls -la ./lfx-dist/
find ./lfx-dist -name "*.whl" -type f
@@ -652,7 +686,7 @@ jobs:
shell: bash
- name: Install LFX package from wheel (Unix)
- if: steps.install-method.outputs.method == 'wheel' && matrix.os != 'windows' && (inputs.lfx-artifact-name != '' || needs.build-if-needed.outputs.lfx-artifact-name != '')
+ if: steps.install-method.outputs.method == 'wheel' && matrix.os != 'windows' && (inputs.lfx-artifact-name != '' || needs.build-if-needed.outputs.lfx-artifact-name != '') && (inputs.sdk-artifact-name == '' && needs.build-if-needed.outputs.sdk-artifact-name == '')
run: |
ls -la ./lfx-dist/
find ./lfx-dist -name "*.whl" -type f
@@ -701,11 +735,20 @@ jobs:
LFX_WHEEL=$(find ./lfx-dist -name "*.whl" -type f | head -1)
if [ -n "$LFX_WHEEL" ]; then
echo "Force reinstalling LFX: $LFX_WHEEL"
+ INSTALL_WHEELS=("$LFX_WHEEL")
+ SDK_WHEEL=""
+ if [ -d ./sdk-dist ]; then
+ SDK_WHEEL=$(find ./sdk-dist -name "*.whl" -type f | head -1)
+ fi
+ if [ -n "$SDK_WHEEL" ]; then
+ echo "Force reinstalling langflow-sdk with LFX: $SDK_WHEEL"
+ INSTALL_WHEELS=("$SDK_WHEEL" "$LFX_WHEEL")
+ fi
NO_DEPS="--no-deps"
if [ "${{ inputs.pre_release }}" = "true" ]; then
NO_DEPS=""
fi
- uv pip install --force-reinstall $NO_DEPS --prerelease=allow --python ./test-env/Scripts/python.exe "$LFX_WHEEL"
+ uv pip install --force-reinstall $NO_DEPS --prerelease=allow --python ./test-env/Scripts/python.exe "${INSTALL_WHEELS[@]}"
fi
fi
@@ -729,11 +772,20 @@ jobs:
LFX_WHEEL=$(find ./lfx-dist -name "*.whl" -type f | head -1)
if [ -n "$LFX_WHEEL" ]; then
echo "Force reinstalling LFX: $LFX_WHEEL"
+ INSTALL_WHEELS=("$LFX_WHEEL")
+ SDK_WHEEL=""
+ if [ -d ./sdk-dist ]; then
+ SDK_WHEEL=$(find ./sdk-dist -name "*.whl" -type f | head -1)
+ fi
+ if [ -n "$SDK_WHEEL" ]; then
+ echo "Force reinstalling langflow-sdk with LFX: $SDK_WHEEL"
+ INSTALL_WHEELS=("$SDK_WHEEL" "$LFX_WHEEL")
+ fi
NO_DEPS="--no-deps"
if [ "${{ inputs.pre_release }}" = "true" ]; then
NO_DEPS=""
fi
- uv pip install --force-reinstall $NO_DEPS --prerelease=allow --python ./test-env/bin/python "$LFX_WHEEL"
+ uv pip install --force-reinstall $NO_DEPS --prerelease=allow --python ./test-env/bin/python "${INSTALL_WHEELS[@]}"
fi
fi
diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml
index 0c322cc731..5139d105ad 100644
--- a/.github/workflows/integration_tests.yml
+++ b/.github/workflows/integration_tests.yml
@@ -13,7 +13,7 @@ on:
description: "(Optional) Python versions to test"
required: true
type: string
- default: "['3.10', '3.11', '3.12', '3.13']"
+ default: "['3.10', '3.11', '3.12', '3.13', '3.14']"
ref:
description: "(Optional) ref to checkout"
required: false
diff --git a/.github/workflows/lint-py.yml b/.github/workflows/lint-py.yml
index 1bbd96c50b..b5f004595f 100644
--- a/.github/workflows/lint-py.yml
+++ b/.github/workflows/lint-py.yml
@@ -19,6 +19,7 @@ jobs:
strategy:
matrix:
python-version:
+ - "3.14"
- "3.13"
- "3.12"
- "3.11"
diff --git a/.github/workflows/nightly_build.yml b/.github/workflows/nightly_build.yml
index cb1939a92b..e85ec6d40b 100644
--- a/.github/workflows/nightly_build.yml
+++ b/.github/workflows/nightly_build.yml
@@ -258,7 +258,7 @@ jobs:
needs: [resolve-release-branch, create-nightly-tag]
uses: ./.github/workflows/python_test.yml
with:
- python-versions: '["3.10", "3.11", "3.12", "3.13"]'
+ python-versions: '["3.10", "3.11", "3.12", "3.13", "3.14"]'
runs-on: ${{ inputs['runs_on'] || github.event.inputs['runs_on'] || 'ubuntu-latest' }}
ref: ${{ needs.resolve-release-branch.outputs.branch }}
secrets:
diff --git a/.github/workflows/python_test.yml b/.github/workflows/python_test.yml
index df5b997ee3..4063fec810 100644
--- a/.github/workflows/python_test.yml
+++ b/.github/workflows/python_test.yml
@@ -14,7 +14,7 @@ on:
description: "(Optional) Python versions to test"
required: true
type: string
- default: "['3.10', '3.11', '3.12', '3.13']"
+ default: "['3.10', '3.11', '3.12', '3.13', '3.14']"
ref:
description: "(Optional) ref to checkout"
required: false
@@ -35,7 +35,7 @@ on:
description: "(Optional) Python versions to test"
required: true
type: string
- default: "['3.10', '3.11', '3.12', '3.13']"
+ default: "['3.10', '3.11', '3.12', '3.13', '3.14']"
runs-on:
description: "Runner to use for the tests"
required: false
@@ -58,7 +58,7 @@ jobs:
runs-on: ${{ (inputs['runs-on'] && startsWith(format('{0}', inputs['runs-on']), '[') && fromJSON(inputs['runs-on'])) || inputs['runs-on'] || github.event.inputs['runs-on'] || 'ubuntu-latest' }}
strategy:
matrix:
- python-version: ${{ fromJson(inputs.python-versions || '["3.10", "3.11", "3.12", "3.13"]' ) }}
+ python-version: ${{ fromJson(inputs.python-versions || '["3.10", "3.11", "3.12", "3.13", "3.14"]') }}
splitCount: [5]
group: [1, 2, 3, 4, 5]
steps:
@@ -123,7 +123,7 @@ jobs:
runs-on: ${{ (inputs['runs-on'] && startsWith(format('{0}', inputs['runs-on']), '[') && fromJSON(inputs['runs-on'])) || inputs['runs-on'] || github.event.inputs['runs-on'] || 'ubuntu-latest' }}
strategy:
matrix:
- python-version: ${{ fromJson(inputs.python-versions || '["3.10", "3.11", "3.12", "3.13"]' ) }}
+ python-version: ${{ fromJson(inputs.python-versions || '["3.10", "3.11", "3.12", "3.13", "3.14"]') }}
steps:
- uses: actions/checkout@v6
with:
@@ -148,7 +148,7 @@ jobs:
runs-on: ${{ (inputs['runs-on'] && startsWith(format('{0}', inputs['runs-on']), '[') && fromJSON(inputs['runs-on'])) || inputs['runs-on'] || github.event.inputs['runs-on'] || 'ubuntu-latest' }}
strategy:
matrix:
- python-version: ${{ fromJson(inputs.python-versions || '["3.10", "3.11", "3.12", "3.13"]' ) }}
+ python-version: ${{ fromJson(inputs.python-versions || '["3.10", "3.11", "3.12", "3.13", "3.14"]') }}
steps:
- uses: actions/checkout@v6
with:
@@ -191,7 +191,7 @@ jobs:
runs-on: ${{ (inputs['runs-on'] && startsWith(format('{0}', inputs['runs-on']), '[') && fromJSON(inputs['runs-on'])) || inputs['runs-on'] || github.event.inputs['runs-on'] || 'ubuntu-latest' }}
strategy:
matrix:
- python-version: ${{ fromJson(inputs.python-versions || '["3.10", "3.11", "3.12", "3.13"]') }}
+ python-version: ${{ fromJson(inputs.python-versions || '["3.10", "3.11", "3.12", "3.13", "3.14"]') }}
steps:
- name: Check out the code at a specific ref
uses: actions/checkout@v6
diff --git a/.github/workflows/release-lfx.yml b/.github/workflows/release-lfx.yml
index 143434acf7..fd7c11b8bf 100644
--- a/.github/workflows/release-lfx.yml
+++ b/.github/workflows/release-lfx.yml
@@ -92,7 +92,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- python-version: ["3.10", "3.11", "3.12", "3.13"]
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- name: Checkout code
uses: actions/checkout@v6
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 2c52f0b9df..504103ac4c 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -383,7 +383,7 @@ jobs:
uses: ./.github/workflows/ci.yml
with:
ref: ${{ inputs.release_tag }}
- python-versions: "['3.10', '3.11', '3.12', '3.13']"
+ python-versions: "['3.10', '3.11', '3.12', '3.13', '3.14']"
frontend-tests-folder: "tests"
release: true
run-all-tests: true
@@ -445,7 +445,10 @@ jobs:
uv venv test-env --seed
uv pip install --python ./test-env/bin/python dist/*.whl
EXPECTED_VERSION="${{ needs.determine-sdk-version.outputs.version }}"
- ./test-env/bin/python -c "from importlib.metadata import version; import langflow_sdk; installed_version = version('langflow-sdk'); print(f'langflow-sdk {installed_version} imported successfully'); assert installed_version == '$EXPECTED_VERSION', f'Installed version {installed_version} does not match expected version $EXPECTED_VERSION'"
+ # importlib.metadata returns the PEP 440 canonical form, so normalize
+ # the expected version the same way the wheel filename check does.
+ NORMALIZED_VERSION=$(echo "$EXPECTED_VERSION" | sed 's/\.rc/rc/g; s/\.a/a/g; s/\.b/b/g; s/\.dev/dev/g')
+ ./test-env/bin/python -c "from importlib.metadata import version; import langflow_sdk; installed_version = version('langflow-sdk'); print(f'langflow-sdk {installed_version} imported successfully'); assert installed_version == '$NORMALIZED_VERSION', f'Installed version {installed_version} does not match expected version $NORMALIZED_VERSION'"
- name: Upload Artifact
uses: actions/upload-artifact@v6
with:
diff --git a/.secrets.baseline b/.secrets.baseline
index ddfe3d0285..5f03c909df 100644
--- a/.secrets.baseline
+++ b/.secrets.baseline
@@ -237,7 +237,7 @@
"filename": "docker_example/docker-compose.yml",
"hashed_secret": "e80c4f90316c87b6b24d03890493c8d1c7c1c99d",
"is_verified": false,
- "line_number": 20,
+ "line_number": 24,
"is_secret": false
}
],
@@ -247,7 +247,7 @@
"filename": "docker_example/pre.docker-compose.yml",
"hashed_secret": "e80c4f90316c87b6b24d03890493c8d1c7c1c99d",
"is_verified": false,
- "line_number": 21,
+ "line_number": 25,
"is_secret": false
}
],
@@ -2326,6 +2326,14 @@
"hashed_secret": "d6e6d7b4b115cd3b9d172623199f8c403055fecc",
"is_verified": false,
"line_number": 403
+ },
+ {
+ "type": "Hex High Entropy String",
+ "filename": "src/backend/base/langflow/initial_setup/starter_projects/SaaS Pricing.json",
+ "hashed_secret": "de5b1f2ea12440f0a19893e82535166ea24c7ce3",
+ "is_verified": false,
+ "line_number": 720,
+ "is_secret": false
}
],
"src/backend/base/langflow/initial_setup/starter_projects/Search agent.json": [
@@ -2362,6 +2370,14 @@
"line_number": 2970,
"is_secret": false
},
+ {
+ "type": "Hex High Entropy String",
+ "filename": "src/backend/base/langflow/initial_setup/starter_projects/Sequential Tasks Agents.json",
+ "hashed_secret": "de5b1f2ea12440f0a19893e82535166ea24c7ce3",
+ "is_verified": false,
+ "line_number": 3206,
+ "is_secret": false
+ },
{
"type": "Hex High Entropy String",
"filename": "src/backend/base/langflow/initial_setup/starter_projects/Sequential Tasks Agents.json",
@@ -2371,6 +2387,14 @@
}
],
"src/backend/base/langflow/initial_setup/starter_projects/Simple Agent.json": [
+ {
+ "type": "Hex High Entropy String",
+ "filename": "src/backend/base/langflow/initial_setup/starter_projects/Simple Agent.json",
+ "hashed_secret": "de5b1f2ea12440f0a19893e82535166ea24c7ce3",
+ "is_verified": false,
+ "line_number": 203,
+ "is_secret": false
+ },
{
"type": "Hex High Entropy String",
"filename": "src/backend/base/langflow/initial_setup/starter_projects/Simple Agent.json",
@@ -2444,6 +2468,14 @@
"hashed_secret": "d6e6d7b4b115cd3b9d172623199f8c403055fecc",
"is_verified": false,
"line_number": 501
+ },
+ {
+ "type": "Hex High Entropy String",
+ "filename": "src/backend/base/langflow/initial_setup/starter_projects/Travel Planning Agents.json",
+ "hashed_secret": "de5b1f2ea12440f0a19893e82535166ea24c7ce3",
+ "is_verified": false,
+ "line_number": 1263,
+ "is_secret": false
}
],
"src/backend/base/langflow/initial_setup/starter_projects/Twitter Thread Generator.json": [
@@ -3103,6 +3135,16 @@
"is_secret": false
}
],
+ "src/backend/tests/unit/base/models/test_model_utils.py": [
+ {
+ "type": "Secret Keyword",
+ "filename": "src/backend/tests/unit/base/models/test_model_utils.py",
+ "hashed_secret": "c053ecf9ed41df0311b9df13cc6c3b6078d2d3c2",
+ "is_verified": false,
+ "line_number": 96,
+ "is_secret": false
+ }
+ ],
"src/backend/tests/unit/components/bundles/agentics/test_llm_factory.py": [
{
"type": "Secret Keyword",
@@ -3211,7 +3253,7 @@
"filename": "src/backend/tests/unit/components/files_and_knowledge/test_retrieval.py",
"hashed_secret": "3658a95db0b214e73694335448df084f979c526f",
"is_verified": false,
- "line_number": 350,
+ "line_number": 358,
"is_secret": false
},
{
@@ -3219,7 +3261,7 @@
"filename": "src/backend/tests/unit/components/files_and_knowledge/test_retrieval.py",
"hashed_secret": "3d3c09dc9101fcd18909dd4071d6429141ec1bef",
"is_verified": false,
- "line_number": 418,
+ "line_number": 427,
"is_secret": false
},
{
@@ -3227,7 +3269,7 @@
"filename": "src/backend/tests/unit/components/files_and_knowledge/test_retrieval.py",
"hashed_secret": "b871b1863882c37735c7754f2cb49d38356e7f1e",
"is_verified": false,
- "line_number": 799,
+ "line_number": 810,
"is_secret": false
}
],
@@ -8245,5 +8287,5 @@
}
]
},
- "generated_at": "2026-04-24T19:11:48Z"
+ "generated_at": "2026-05-15T00:15:30Z"
}
diff --git a/docker/build_and_push.Dockerfile b/docker/build_and_push.Dockerfile
index ce9405e314..62d28c8198 100644
--- a/docker/build_and_push.Dockerfile
+++ b/docker/build_and_push.Dockerfile
@@ -9,7 +9,7 @@
# 1. use python:3.12.3-slim as the base image until https://github.com/pydantic/pydantic-core/issues/1292 gets resolved
# 2. do not add --platform=$BUILDPLATFORM because the pydantic binaries must be resolved for the final architecture
# Use a Python image with uv pre-installed
-FROM ghcr.io/astral-sh/uv:python3.12-trixie-slim AS builder
+FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim AS builder
# Install the project into `/app`
WORKDIR /app
@@ -75,7 +75,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
# RUNTIME
# Setup user, utilities and copy the virtual environment only
################################
-FROM python:3.12-slim-trixie AS runtime
+FROM python:3.14-slim-trixie AS runtime
RUN apt-get update \
@@ -113,4 +113,3 @@ ENV LANGFLOW_HOST=0.0.0.0
ENV LANGFLOW_PORT=7860
CMD ["langflow", "run"]
-
diff --git a/docker/build_and_push_backend.Dockerfile b/docker/build_and_push_backend.Dockerfile
index 2fae03032d..0fe2a39ec5 100644
--- a/docker/build_and_push_backend.Dockerfile
+++ b/docker/build_and_push_backend.Dockerfile
@@ -8,7 +8,7 @@
################################
# BUILDER
################################
-FROM ghcr.io/astral-sh/uv:python3.12-trixie-slim AS builder
+FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim AS builder
WORKDIR /app
@@ -44,7 +44,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
################################
# RUNTIME
################################
-FROM python:3.12-slim-trixie AS runtime
+FROM python:3.14-slim-trixie AS runtime
# Install minimal runtime dependencies
RUN apt-get update \
diff --git a/docker/build_and_push_base.Dockerfile b/docker/build_and_push_base.Dockerfile
index 228c86fdd8..4ab6369b2b 100644
--- a/docker/build_and_push_base.Dockerfile
+++ b/docker/build_and_push_base.Dockerfile
@@ -10,7 +10,7 @@
# 1. use python:3.12.3-slim as the base image until https://github.com/pydantic/pydantic-core/issues/1292 gets resolved
# 2. do not add --platform=$BUILDPLATFORM because the pydantic binaries must be resolved for the final architecture
# Use a Python image with uv pre-installed
-FROM ghcr.io/astral-sh/uv:python3.12-trixie-slim AS builder
+FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim AS builder
# Install the project into `/app`
WORKDIR /app
@@ -77,7 +77,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
# RUNTIME
# Setup user, utilities and copy the virtual environment only
################################
-FROM python:3.12-slim-trixie AS runtime
+FROM python:3.14-slim-trixie AS runtime
RUN apt-get update \
diff --git a/docker/build_and_push_ep.Dockerfile b/docker/build_and_push_ep.Dockerfile
index fd0c053669..b88a14fae5 100644
--- a/docker/build_and_push_ep.Dockerfile
+++ b/docker/build_and_push_ep.Dockerfile
@@ -9,7 +9,7 @@
# 1. use python:3.12.3-slim as the base image until https://github.com/pydantic/pydantic-core/issues/1292 gets resolved
# 2. do not add --platform=$BUILDPLATFORM because the pydantic binaries must be resolved for the final architecture
# Use a Python image with uv pre-installed
-FROM ghcr.io/astral-sh/uv:python3.12-trixie-slim AS builder
+FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim AS builder
# Install the project into `/app`
WORKDIR /app
@@ -72,7 +72,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
# RUNTIME
# Setup user, utilities and copy the virtual environment only
################################
-FROM python:3.12-slim-trixie AS runtime
+FROM python:3.14-slim-trixie AS runtime
RUN apt-get update \
&& apt-get upgrade -y \
diff --git a/docker/build_and_push_with_extras.Dockerfile b/docker/build_and_push_with_extras.Dockerfile
index 84af837d63..48e016b796 100644
--- a/docker/build_and_push_with_extras.Dockerfile
+++ b/docker/build_and_push_with_extras.Dockerfile
@@ -9,7 +9,7 @@
# 1. use python:3.12.3-slim as the base image until https://github.com/pydantic/pydantic-core/issues/1292 gets resolved
# 2. do not add --platform=$BUILDPLATFORM because the pydantic binaries must be resolved for the final architecture
# Use a Python image with uv pre-installed
-FROM ghcr.io/astral-sh/uv:python3.12-trixie-slim AS builder
+FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim AS builder
# Install the project into `/app`
WORKDIR /app
@@ -72,7 +72,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
# RUNTIME
# Setup user, utilities and copy the virtual environment only
################################
-FROM python:3.12-slim-trixie AS runtime
+FROM python:3.14-slim-trixie AS runtime
RUN apt-get update \
diff --git a/docker/dev.Dockerfile b/docker/dev.Dockerfile
index a14e73b7d4..f92368ae3f 100644
--- a/docker/dev.Dockerfile
+++ b/docker/dev.Dockerfile
@@ -1,4 +1,4 @@
-FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
+FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim
ENV TZ=UTC
WORKDIR /app
diff --git a/docker_example/README.md b/docker_example/README.md
index 622f7afd63..f91d2acfe5 100644
--- a/docker_example/README.md
+++ b/docker_example/README.md
@@ -48,7 +48,7 @@ Volumes:
### PostgreSQL Service
-The `postgres` service uses the `postgres:16` Docker image and exposes port 5432.
+The `postgres` service uses the `postgres:16-trixie` Docker image and exposes port 5432. The image is pinned to a specific Debian base (`trixie`, Debian 13) so the `postgres:16` tag cannot silently roll its underlying OS, which would otherwise produce a glibc collation version mismatch warning on existing data volumes.
Environment variables:
@@ -60,6 +60,26 @@ Volumes:
- `langflow-postgres`: This volume is mapped to `/var/lib/postgresql/data` in the container.
+### Upgrading from a `bookworm`-initialized volume
+
+Earlier versions of this example used `postgres:16`, which initially shipped on Debian Bookworm (glibc 2.36). The pinned image now uses Trixie (glibc 2.41). On the first start against a volume that was initialized under Bookworm, PostgreSQL logs a one-time warning:
+
+```
+WARNING: database "langflow" has a collation version mismatch
+DETAIL: The database was created using collation version 2.36, but the operating system provides version 2.41.
+```
+
+To clear it, refresh the collation version against the running database (one-off, takes seconds on a typical Langflow database):
+
+```sh
+docker compose exec postgres \
+ psql -U langflow -d langflow \
+ -c "REINDEX DATABASE langflow;" \
+ -c "ALTER DATABASE langflow REFRESH COLLATION VERSION;"
+```
+
+Fresh installs are unaffected.
+
## Switching to a Specific LangFlow Version
If you want to use a specific version of LangFlow, you can modify the `image` field under the `langflow` service in the Docker Compose file. For example, to use version 1.0-alpha, change `langflowai/langflow:latest` to `langflowai/langflow:1.0-alpha`.
diff --git a/docker_example/docker-compose.yml b/docker_example/docker-compose.yml
index 342e430a91..9ab0471209 100644
--- a/docker_example/docker-compose.yml
+++ b/docker_example/docker-compose.yml
@@ -14,7 +14,11 @@ services:
- langflow-data:/app/langflow
postgres:
- image: postgres:16
+ # Pinned to a specific Debian base (trixie) so the postgres:16 tag does not
+ # silently roll its OS underneath us — that roll causes a glibc collation
+ # version mismatch warning on existing volumes. Matches the langflow image
+ # base. See https://github.com/langflow-ai/langflow/issues/9608
+ image: postgres:16-trixie
environment:
POSTGRES_USER: langflow
POSTGRES_PASSWORD: langflow
diff --git a/docker_example/pre.docker-compose.yml b/docker_example/pre.docker-compose.yml
index 3df573df53..14d5bb3368 100644
--- a/docker_example/pre.docker-compose.yml
+++ b/docker_example/pre.docker-compose.yml
@@ -15,7 +15,11 @@ services:
- langflow-data:/app/langflow
postgres:
- image: postgres:16
+ # Pinned to a specific Debian base (trixie) so the postgres:16 tag does not
+ # silently roll its OS underneath us — that roll causes a glibc collation
+ # version mismatch warning on existing volumes. Matches the langflow image
+ # base. See https://github.com/langflow-ai/langflow/issues/9608
+ image: postgres:16-trixie
environment:
POSTGRES_USER: langflow
POSTGRES_PASSWORD: langflow
diff --git a/docs/docs/Deployment/deployment-docker.mdx b/docs/docs/Deployment/deployment-docker.mdx
index 6a58e5ea48..44b6a59d0e 100644
--- a/docs/docs/Deployment/deployment-docker.mdx
+++ b/docs/docs/Deployment/deployment-docker.mdx
@@ -232,7 +232,10 @@ For example, this Docker Compose file uses a bind mount for Langflow data (`./la
volumes:
- ./langflow-data:/app/langflow
postgres:
- image: postgres:16
+ # Pinned to a specific Debian base (trixie) so the postgres:16 tag does
+ # not silently roll its OS, which triggers a glibc collation mismatch
+ # warning on existing volumes. See https://github.com/langflow-ai/langflow/issues/9608
+ image: postgres:16-trixie
volumes:
- langflow-postgres:/var/lib/postgresql/data
diff --git a/docs/docs/Develop/configuration-custom-database.mdx b/docs/docs/Develop/configuration-custom-database.mdx
index 5ac46ccad0..2b00e19933 100644
--- a/docs/docs/Develop/configuration-custom-database.mdx
+++ b/docs/docs/Develop/configuration-custom-database.mdx
@@ -124,7 +124,10 @@ For example:
```yaml
services:
postgres:
- image: postgres:16
+ # Pinned to a specific Debian base (trixie) so the postgres:16 tag does
+ # not silently roll its OS, which triggers a glibc collation mismatch
+ # warning on existing volumes. See https://github.com/langflow-ai/langflow/issues/9608
+ image: postgres:16-trixie
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
diff --git a/docs/docs/Develop/enterprise-database-guide.mdx b/docs/docs/Develop/enterprise-database-guide.mdx
index 071063b945..03e971ffc2 100644
--- a/docs/docs/Develop/enterprise-database-guide.mdx
+++ b/docs/docs/Develop/enterprise-database-guide.mdx
@@ -67,7 +67,10 @@ For more information, see [Configure an external PostgreSQL database](/configura
environment:
- LANGFLOW_DATABASE_URL=postgresql://langflow:langflow@postgres:5432/langflow
postgres:
- image: postgres:16
+ # Pinned to a specific Debian base (trixie) so the postgres:16 tag does
+ # not silently roll its OS, which triggers a glibc collation mismatch
+ # warning on existing volumes. See https://github.com/langflow-ai/langflow/issues/9608
+ image: postgres:16-trixie
ports:
- "5432:5432"
environment:
diff --git a/docs/docs/Develop/integrations-langfuse.mdx b/docs/docs/Develop/integrations-langfuse.mdx
index 2a4e3f52ce..58de67dd22 100644
--- a/docs/docs/Develop/integrations-langfuse.mdx
+++ b/docs/docs/Develop/integrations-langfuse.mdx
@@ -107,7 +107,10 @@ As an alternative to the previous setup, particularly for self-hosted Langfuse,
- langflow-data:/app/langflow
postgres:
- image: postgres:16
+ # Pinned to a specific Debian base (trixie) so the postgres:16 tag does
+ # not silently roll its OS, which triggers a glibc collation mismatch
+ # warning on existing volumes. See https://github.com/langflow-ai/langflow/issues/9608
+ image: postgres:16-trixie
environment:
POSTGRES_USER: langflow
POSTGRES_PASSWORD: langflow
diff --git a/docs/package-lock.json b/docs/package-lock.json
index 24111f0742..d03a3c3b01 100644
--- a/docs/package-lock.json
+++ b/docs/package-lock.json
@@ -3662,6 +3662,12 @@
"url": "https://opencollective.com/core-js"
}
},
+ "node_modules/@docusaurus/core/node_modules/lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "license": "MIT"
+ },
"node_modules/@docusaurus/cssnano-preset": {
"version": "3.9.2",
"resolved": "https://registry.npmjs.org/@docusaurus/cssnano-preset/-/cssnano-preset-3.9.2.tgz",
@@ -3772,6 +3778,12 @@
"react-dom": "^18.0.0 || ^19.0.0"
}
},
+ "node_modules/@docusaurus/plugin-client-redirects/node_modules/lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "license": "MIT"
+ },
"node_modules/@docusaurus/plugin-content-blog": {
"version": "3.9.2",
"resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.9.2.tgz",
@@ -3846,6 +3858,12 @@
"entities": "^4.4.0"
}
},
+ "node_modules/@docusaurus/plugin-content-blog/node_modules/lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "license": "MIT"
+ },
"node_modules/@docusaurus/plugin-content-docs": {
"version": "3.9.2",
"resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.9.2.tgz",
@@ -3880,6 +3898,12 @@
"react-dom": "^18.0.0 || ^19.0.0"
}
},
+ "node_modules/@docusaurus/plugin-content-docs/node_modules/lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "license": "MIT"
+ },
"node_modules/@docusaurus/plugin-content-pages": {
"version": "3.9.2",
"resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.9.2.tgz",
@@ -4124,6 +4148,12 @@
"node": ">=6"
}
},
+ "node_modules/@docusaurus/theme-classic/node_modules/lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "license": "MIT"
+ },
"node_modules/@docusaurus/theme-classic/node_modules/prism-react-renderer": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-2.4.1.tgz",
@@ -4228,6 +4258,12 @@
"node": ">=6"
}
},
+ "node_modules/@docusaurus/theme-search-algolia/node_modules/lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "license": "MIT"
+ },
"node_modules/@docusaurus/theme-translations": {
"version": "3.9.2",
"resolved": "https://registry.npmjs.org/@docusaurus/theme-translations/-/theme-translations-3.9.2.tgz",
@@ -4351,6 +4387,12 @@
"node": ">=20.0"
}
},
+ "node_modules/@docusaurus/utils-validation/node_modules/lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "license": "MIT"
+ },
"node_modules/@docusaurus/utils/node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@@ -4363,6 +4405,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/@docusaurus/utils/node_modules/lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "license": "MIT"
+ },
"node_modules/@easyops-cn/autocomplete.js": {
"version": "0.38.1",
"resolved": "https://registry.npmjs.org/@easyops-cn/autocomplete.js/-/autocomplete.js-0.38.1.tgz",
@@ -6703,10 +6751,13 @@
}
},
"node_modules/balanced-match": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
- "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
- "license": "MIT"
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
},
"node_modules/base64-js": {
"version": "1.5.1",
@@ -6910,12 +6961,15 @@
}
},
"node_modules/brace-expansion": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
- "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
+ "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"license": "MIT",
"dependencies": {
- "balanced-match": "^1.0.0"
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
}
},
"node_modules/braces": {
@@ -7734,12 +7788,6 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
- "node_modules/concat-map": {
- "version": "0.0.1",
- "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
- "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
- "license": "MIT"
- },
"node_modules/config-chain": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
@@ -9038,6 +9086,12 @@
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT"
},
+ "node_modules/docusaurus-theme-redoc/node_modules/lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "license": "MIT"
+ },
"node_modules/docusaurus-theme-redoc/node_modules/marked": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz",
@@ -11022,6 +11076,12 @@
}
}
},
+ "node_modules/html-webpack-plugin/node_modules/lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "license": "MIT"
+ },
"node_modules/htmlparser2": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz",
@@ -12070,12 +12130,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/lodash": {
- "version": "4.17.21",
- "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
- "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
- "license": "MIT"
- },
"node_modules/lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
@@ -13624,16 +13678,6 @@
"node": "*"
}
},
- "node_modules/minimatch/node_modules/brace-expansion": {
- "version": "1.1.12",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
- "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
- "license": "MIT",
- "dependencies": {
- "balanced-match": "^1.0.0",
- "concat-map": "0.0.1"
- }
- },
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
@@ -14818,12 +14862,12 @@
"license": "ISC"
},
"node_modules/picomatch": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
- "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"engines": {
- "node": ">=8.6"
+ "node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
@@ -16529,6 +16573,12 @@
"renderkid": "^3.0.0"
}
},
+ "node_modules/pretty-error/node_modules/lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "license": "MIT"
+ },
"node_modules/pretty-time": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/pretty-time/-/pretty-time-1.1.0.tgz",
@@ -17502,6 +17552,12 @@
"entities": "^2.0.0"
}
},
+ "node_modules/renderkid/node_modules/lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "license": "MIT"
+ },
"node_modules/repeat-string": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz",
diff --git a/docs/package.json b/docs/package.json
index 5185fc19ab..15e691f666 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -54,19 +54,21 @@
"node": "22.22.0"
},
"overrides": {
- "lodash": "4.18.0",
+ "brace-expansion": "^5.0.5",
+ "picomatch": "^4.0.4",
+ "lodash": "4.17.21",
"fast-xml-parser": "4.5.4",
"openapi-to-postmanv2": {
- "lodash": "4.18.0"
+ "lodash": "4.17.21"
},
"postman-collection": {
- "lodash": "4.18.0"
+ "lodash": "4.17.21"
},
"json-schema-resolve-allof": {
- "lodash": "4.18.0"
+ "lodash": "4.17.21"
},
"json-refs": {
- "lodash": "4.18.0"
+ "lodash": "4.17.21"
}
}
}
diff --git a/docs/versioned_docs/version-1.9.0/Develop/api-keys-and-authentication.mdx b/docs/versioned_docs/version-1.9.0/Develop/api-keys-and-authentication.mdx
index 826ac3d0f9..4a5f16ac05 100644
--- a/docs/versioned_docs/version-1.9.0/Develop/api-keys-and-authentication.mdx
+++ b/docs/versioned_docs/version-1.9.0/Develop/api-keys-and-authentication.mdx
@@ -454,9 +454,15 @@ LANGFLOW_CORS_ALLOW_METHODS=["GET","POST","PUT"]
The following environment variables configure Server-Side Request Forgery (SSRF) protection for the [**API Request** component](/api-request).
SSRF protection prevents requests to internal or private network resources, such as private IP ranges, loopback addresses, and cloud metadata endpoints.
+:::warning
+As of Langflow 1.9.3, SSRF protection is enabled by default and includes DNS rebinding prevention through IP pinning.
+
+If you are upgrading to Langflow 1.9.3 and your flows use the [**API Request** component](/api-request) to call resources that would be blocked by SSRF protection, add those hosts to `LANGFLOW_SSRF_ALLOWED_HOSTS` before upgrading to avoid disruption.
+:::
+
| Variable | Format | Default | Description |
|----------|--------|---------|-------------|
-| `LANGFLOW_SSRF_PROTECTION_ENABLED` | Boolean | `False` | Enable SSRF protection for the **API Request** component. When enabled, the component blocks requests to private IP addresses. When disabled, requests are not blocked. |
+| `LANGFLOW_SSRF_PROTECTION_ENABLED` | Boolean | `True` | Enable SSRF protection for the **API Request** component. When enabled, the component blocks requests to private IP addresses. When disabled, requests are not blocked. |
| `LANGFLOW_SSRF_ALLOWED_HOSTS` | List[String] | Not set | A comma-separated list of allowed hosts, IP addresses, or CIDR ranges that can bypass SSRF protection checks. For example: `192.168.1.0/24,10.0.0.5,*.internal.company.local`.|
### LANGFLOW_WEBHOOK_AUTH_ENABLE {#langflow-webhook-auth-enable}
diff --git a/pyproject.toml b/pyproject.toml
index 2bd669db72..b81f4cfe08 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,8 +1,8 @@
[project]
name = "langflow"
-version = "1.9.2"
+version = "1.9.3"
description = "A Python package with a built-in web application"
-requires-python = ">=3.10,<3.14"
+requires-python = ">=3.10,<3.15"
license = "MIT"
keywords = ["nlp", "langchain", "openai", "gpt", "gui"]
readme = "README.md"
@@ -17,7 +17,7 @@ maintainers = [
]
# Define your main dependencies here
dependencies = [
- "langflow-base[complete]>=0.9.2",
+ "langflow-base[complete]>=0.9.3",
]
@@ -27,7 +27,7 @@ dev = [
"ipykernel>=6.29.0",
"ruff~=0.13.1",
"httpx>=0.28.1",
- "pytest>=8.2.0",
+ "pytest>=9.0.3",
"requests>=2.33.0",
"pytest-cov>=5.0.0",
"pytest-mock>=3.14.0",
@@ -48,7 +48,7 @@ dev = [
"types-aiofiles>=24.1.0.20240626",
"codeflash>=0.8.4",
"hypothesis>=6.123.17",
- "locust~=2.40.5",
+ "locust~=2.43.4",
"pytest-rerunfailures>=15.0",
"scrapegraph-py>=1.10.2",
'elevenlabs==1.58.1; python_version == "3.12"',
@@ -58,7 +58,8 @@ dev = [
"pyyaml>=6.0.2",
"pyleak>=0.1.14",
"mcp-server-fetch>=2025.1.17",
- "onnxruntime>=1.20,<1.24" # >=1.24 does not support Python 3.10; <1.24 allows 1.23.x for agent-lifecycle-toolkit
+ "onnxruntime>=1.20,<1.24; python_version<'3.14'", # >=1.24 does not support Python 3.10; <1.24 allows 1.23.x for agent-lifecycle-toolkit
+ "onnxruntime>=1.26; python_version>='3.14'",
]
[[tool.uv.index]]
@@ -147,6 +148,11 @@ override-dependencies = [
"dynaconf>=3.2.13",
"pillow>=12.1.1", # Force Pillow 12.1.1+ to prevent CVE-vulnerable versions
"playwright>=1.59.0", # Latest available on PyPI; ensures updated Chromium with CVE fixes
+ # Transitive dependency CVE fixes
+ "lxml>=6.1.0,<7.0.0", # CVE-2026-41066
+ "mako>=1.3.12,<2.0.0", # CVE-2026-44307
+ "urllib3>=2.7.0,<3.0.0", # CVE-2026-44431, CVE-2026-44432
+ "python-liquid>=2.2.0,<3.0.0", # CVE-2026-45017
]
[project.scripts]
diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Document Q&A.json b/src/backend/base/langflow/initial_setup/starter_projects/Document Q&A.json
index 347f54eb47..2503356a6c 100644
--- a/src/backend/base/langflow/initial_setup/starter_projects/Document Q&A.json
+++ b/src/backend/base/langflow/initial_setup/starter_projects/Document Q&A.json
@@ -1327,7 +1327,7 @@
},
{
"name": "googleapiclient",
- "version": "2.194.0"
+ "version": "2.195.0"
}
],
"total_dependencies": 4
diff --git a/src/backend/base/langflow/initial_setup/starter_projects/News Aggregator.json b/src/backend/base/langflow/initial_setup/starter_projects/News Aggregator.json
index 34bcff1da8..7ae3584863 100644
--- a/src/backend/base/langflow/initial_setup/starter_projects/News Aggregator.json
+++ b/src/backend/base/langflow/initial_setup/starter_projects/News Aggregator.json
@@ -1781,7 +1781,7 @@
},
{
"name": "googleapiclient",
- "version": "2.194.0"
+ "version": "2.195.0"
}
],
"total_dependencies": 7
diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Pokédex Agent.json b/src/backend/base/langflow/initial_setup/starter_projects/Pokédex Agent.json
index a591bc20f3..07b7363350 100644
--- a/src/backend/base/langflow/initial_setup/starter_projects/Pokédex Agent.json
+++ b/src/backend/base/langflow/initial_setup/starter_projects/Pokédex Agent.json
@@ -768,7 +768,7 @@
"key": "APIRequest",
"legacy": false,
"metadata": {
- "code_hash": "2af407885294",
+ "code_hash": "4f329dec9a39",
"dependencies": {
"dependencies": [
{
@@ -886,7 +886,7 @@
"show": true,
"title_case": false,
"type": "code",
- "value": "import json\nimport re\nimport tempfile\nfrom datetime import datetime, timezone\nfrom pathlib import Path\nfrom typing import Any\nfrom urllib.parse import parse_qsl, urlencode, urlparse, urlunparse\n\nimport aiofiles\nimport aiofiles.os as aiofiles_os\nimport httpx\nimport validators\n\nfrom lfx.base.curl.parse import parse_context\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.inputs.inputs import TabInput\nfrom lfx.io import (\n BoolInput,\n DataInput,\n DropdownInput,\n IntInput,\n MessageTextInput,\n MultilineInput,\n Output,\n TableInput,\n)\nfrom lfx.schema.data import Data\nfrom lfx.schema.dotdict import dotdict\nfrom lfx.utils.component_utils import set_current_fields, set_field_advanced, set_field_display\nfrom lfx.utils.ssrf_protection import SSRFProtectionError, validate_url_for_ssrf\n\n# Define fields for each mode\nMODE_FIELDS = {\n \"URL\": [\n \"url_input\",\n \"method\",\n ],\n \"cURL\": [\"curl_input\"],\n}\n\n# Fields that should always be visible\nDEFAULT_FIELDS = [\"mode\"]\n\n\nclass APIRequestComponent(Component):\n display_name = \"API Request\"\n description = \"Make HTTP requests using URL or cURL commands.\"\n documentation: str = \"https://docs.langflow.org/api-request\"\n icon = \"Globe\"\n name = \"APIRequest\"\n\n inputs = [\n MessageTextInput(\n name=\"url_input\",\n display_name=\"URL\",\n info=\"Enter the URL for the request.\",\n advanced=False,\n tool_mode=True,\n ),\n MultilineInput(\n name=\"curl_input\",\n display_name=\"cURL\",\n info=(\n \"Paste a curl command to populate the fields. \"\n \"This will fill in the dictionary fields for headers and body.\"\n ),\n real_time_refresh=True,\n tool_mode=True,\n advanced=True,\n show=False,\n ),\n DropdownInput(\n name=\"method\",\n display_name=\"Method\",\n options=[\"GET\", \"POST\", \"PATCH\", \"PUT\", \"DELETE\"],\n value=\"GET\",\n info=\"The HTTP method to use.\",\n real_time_refresh=True,\n ),\n TabInput(\n name=\"mode\",\n display_name=\"Mode\",\n options=[\"URL\", \"cURL\"],\n value=\"URL\",\n info=\"Enable cURL mode to populate fields from a cURL command.\",\n real_time_refresh=True,\n ),\n DataInput(\n name=\"query_params\",\n display_name=\"Query Parameters\",\n info=\"The query parameters to append to the URL.\",\n advanced=True,\n ),\n TableInput(\n name=\"body\",\n display_name=\"Body\",\n info=\"The body to send with the request as a dictionary (for POST, PATCH, PUT).\",\n table_schema=[\n {\n \"name\": \"key\",\n \"display_name\": \"Key\",\n \"type\": \"str\",\n \"description\": \"Parameter name\",\n },\n {\n \"name\": \"value\",\n \"display_name\": \"Value\",\n \"description\": \"Parameter value\",\n },\n ],\n value=[],\n input_types=[\"Data\", \"JSON\"],\n advanced=True,\n real_time_refresh=True,\n ),\n TableInput(\n name=\"headers\",\n display_name=\"Headers\",\n info=\"The headers to send with the request\",\n table_schema=[\n {\n \"name\": \"key\",\n \"display_name\": \"Header\",\n \"type\": \"str\",\n \"description\": \"Header name\",\n },\n {\n \"name\": \"value\",\n \"display_name\": \"Value\",\n \"type\": \"str\",\n \"description\": \"Header value\",\n },\n ],\n value=[{\"key\": \"User-Agent\", \"value\": \"Langflow/1.0\"}],\n advanced=True,\n input_types=[\"Data\", \"JSON\"],\n real_time_refresh=True,\n ),\n IntInput(\n name=\"timeout\",\n display_name=\"Timeout\",\n value=30,\n info=\"The timeout to use for the request.\",\n advanced=True,\n ),\n BoolInput(\n name=\"follow_redirects\",\n display_name=\"Follow Redirects\",\n value=False,\n info=(\n \"Whether to follow HTTP redirects. \"\n \"WARNING: Enabling redirects may allow SSRF bypass attacks where a public URL \"\n \"redirects to internal resources. Only enable if you trust the target server. \"\n \"See OWASP SSRF Prevention Cheat Sheet for details.\"\n ),\n advanced=True,\n ),\n BoolInput(\n name=\"save_to_file\",\n display_name=\"Save to File\",\n value=False,\n info=\"Save the API response to a temporary file\",\n advanced=True,\n ),\n BoolInput(\n name=\"include_httpx_metadata\",\n display_name=\"Include HTTPx Metadata\",\n value=False,\n info=(\n \"Include properties such as headers, status_code, response_headers, \"\n \"and redirection_history in the output.\"\n ),\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"API Response\", name=\"data\", method=\"make_api_request\"),\n ]\n\n def _parse_json_value(self, value: Any) -> Any:\n \"\"\"Parse a value that might be a JSON string.\"\"\"\n if not isinstance(value, str):\n return value\n\n try:\n parsed = json.loads(value)\n except json.JSONDecodeError:\n return value\n else:\n return parsed\n\n def _process_body(self, body: Any) -> dict:\n \"\"\"Process the body input into a valid dictionary.\"\"\"\n if body is None:\n return {}\n if hasattr(body, \"data\"):\n body = body.data\n if isinstance(body, dict):\n return self._process_dict_body(body)\n if isinstance(body, str):\n return self._process_string_body(body)\n if isinstance(body, list):\n return self._process_list_body(body)\n return {}\n\n def _process_dict_body(self, body: dict) -> dict:\n \"\"\"Process dictionary body by parsing JSON values.\"\"\"\n return {k: self._parse_json_value(v) for k, v in body.items()}\n\n def _process_string_body(self, body: str) -> dict:\n \"\"\"Process string body by attempting JSON parse.\"\"\"\n try:\n return self._process_body(json.loads(body))\n except json.JSONDecodeError:\n return {\"data\": body}\n\n def _process_list_body(self, body: list) -> dict:\n \"\"\"Process list body by converting to key-value dictionary.\"\"\"\n processed_dict = {}\n try:\n for item in body:\n # Unwrap Data objects\n current_item = item\n if hasattr(item, \"data\"):\n unwrapped_data = item.data\n # If the unwrapped data is a dict but not key-value format, use it directly\n if isinstance(unwrapped_data, dict) and not self._is_valid_key_value_item(unwrapped_data):\n return unwrapped_data\n current_item = unwrapped_data\n if not self._is_valid_key_value_item(current_item):\n continue\n key = current_item[\"key\"]\n value = self._parse_json_value(current_item[\"value\"])\n processed_dict[key] = value\n except (KeyError, TypeError, ValueError) as e:\n self.log(f\"Failed to process body list: {e}\")\n return {}\n return processed_dict\n\n def _is_valid_key_value_item(self, item: Any) -> bool:\n \"\"\"Check if an item is a valid key-value dictionary.\"\"\"\n return isinstance(item, dict) and \"key\" in item and \"value\" in item\n\n def parse_curl(self, curl: str, build_config: dotdict) -> dotdict:\n \"\"\"Parse a cURL command and update build configuration.\"\"\"\n try:\n parsed = parse_context(curl)\n\n # Update basic configuration\n url = parsed.url\n # Normalize URL before setting it\n url = self._normalize_url(url)\n\n build_config[\"url_input\"][\"value\"] = url\n build_config[\"method\"][\"value\"] = parsed.method.upper()\n\n # Process headers\n headers_list = [{\"key\": k, \"value\": v} for k, v in parsed.headers.items()]\n build_config[\"headers\"][\"value\"] = headers_list\n\n # Process body data\n if not parsed.data:\n build_config[\"body\"][\"value\"] = []\n elif parsed.data:\n try:\n json_data = json.loads(parsed.data)\n if isinstance(json_data, dict):\n body_list = [\n {\"key\": k, \"value\": json.dumps(v) if isinstance(v, dict | list) else str(v)}\n for k, v in json_data.items()\n ]\n build_config[\"body\"][\"value\"] = body_list\n else:\n build_config[\"body\"][\"value\"] = [{\"key\": \"data\", \"value\": json.dumps(json_data)}]\n except json.JSONDecodeError:\n build_config[\"body\"][\"value\"] = [{\"key\": \"data\", \"value\": parsed.data}]\n\n except Exception as exc:\n msg = f\"Error parsing curl: {exc}\"\n self.log(msg)\n raise ValueError(msg) from exc\n\n return build_config\n\n def _normalize_url(self, url: str) -> str:\n \"\"\"Normalize URL by adding https:// if no protocol is specified.\"\"\"\n if not url or not isinstance(url, str):\n msg = \"URL cannot be empty\"\n raise ValueError(msg)\n\n url = url.strip()\n if url.startswith((\"http://\", \"https://\")):\n return url\n return f\"https://{url}\"\n\n async def make_request(\n self,\n client: httpx.AsyncClient,\n method: str,\n url: str,\n headers: dict | None = None,\n body: Any = None,\n timeout: int = 5,\n *,\n follow_redirects: bool = True,\n save_to_file: bool = False,\n include_httpx_metadata: bool = False,\n ) -> Data:\n method = method.upper()\n if method not in {\"GET\", \"POST\", \"PATCH\", \"PUT\", \"DELETE\"}:\n msg = f\"Unsupported method: {method}\"\n raise ValueError(msg)\n\n processed_body = self._process_body(body)\n redirection_history = []\n\n try:\n # Prepare request parameters\n request_params = {\n \"method\": method,\n \"url\": url,\n \"headers\": headers,\n \"timeout\": timeout,\n \"follow_redirects\": follow_redirects,\n }\n # Only include body for methods that support it (GET must not have a body per HTTP spec)\n if method in {\"POST\", \"PATCH\", \"PUT\", \"DELETE\"} and processed_body is not None:\n request_params[\"json\"] = processed_body\n response = await client.request(**request_params)\n\n redirection_history = [\n {\n \"url\": redirect.headers.get(\"Location\", str(redirect.url)),\n \"status_code\": redirect.status_code,\n }\n for redirect in response.history\n ]\n\n is_binary, file_path = await self._response_info(response, with_file_path=save_to_file)\n response_headers = self._headers_to_dict(response.headers)\n\n # Base metadata\n metadata = {\n \"source\": url,\n \"status_code\": response.status_code,\n \"response_headers\": response_headers,\n }\n\n if redirection_history:\n metadata[\"redirection_history\"] = redirection_history\n\n if save_to_file:\n mode = \"wb\" if is_binary else \"w\"\n encoding = response.encoding if mode == \"w\" else None\n if file_path:\n await aiofiles_os.makedirs(file_path.parent, exist_ok=True)\n if is_binary:\n async with aiofiles.open(file_path, \"wb\") as f:\n await f.write(response.content)\n await f.flush()\n else:\n async with aiofiles.open(file_path, \"w\", encoding=encoding) as f:\n await f.write(response.text)\n await f.flush()\n metadata[\"file_path\"] = str(file_path)\n\n if include_httpx_metadata:\n metadata.update({\"headers\": headers})\n return Data(data=metadata)\n\n # Handle response content\n if is_binary:\n result = response.content\n else:\n try:\n result = response.json()\n except json.JSONDecodeError:\n self.log(\"Failed to decode JSON response\")\n result = response.text.encode(\"utf-8\")\n\n metadata[\"result\"] = result\n\n if include_httpx_metadata:\n metadata.update({\"headers\": headers})\n\n return Data(data=metadata)\n except (httpx.HTTPError, httpx.RequestError, httpx.TimeoutException) as exc:\n self.log(f\"Error making request to {url}\")\n return Data(\n data={\n \"source\": url,\n \"headers\": headers,\n \"status_code\": 500,\n \"error\": str(exc),\n **({\"redirection_history\": redirection_history} if redirection_history else {}),\n },\n )\n\n def add_query_params(self, url: str, params: dict) -> str:\n \"\"\"Add query parameters to URL efficiently.\"\"\"\n if not params:\n return url\n url_parts = list(urlparse(url))\n query = dict(parse_qsl(url_parts[4]))\n query.update(params)\n url_parts[4] = urlencode(query)\n return urlunparse(url_parts)\n\n def _headers_to_dict(self, headers: httpx.Headers) -> dict[str, str]:\n \"\"\"Convert HTTP headers to a dictionary with lowercased keys.\"\"\"\n return {k.lower(): v for k, v in headers.items()}\n\n def _process_headers(self, headers: Any) -> dict:\n \"\"\"Process the headers input into a valid dictionary.\"\"\"\n if headers is None:\n return {}\n if isinstance(headers, dict):\n return headers\n if isinstance(headers, list):\n return {item[\"key\"]: item[\"value\"] for item in headers if self._is_valid_key_value_item(item)}\n return {}\n\n async def make_api_request(self) -> Data:\n \"\"\"Make HTTP request with optimized parameter handling.\"\"\"\n method = self.method\n url = self.url_input.strip() if isinstance(self.url_input, str) else \"\"\n headers = self.headers or {}\n body = self.body or {}\n timeout = self.timeout\n follow_redirects = self.follow_redirects\n save_to_file = self.save_to_file\n include_httpx_metadata = self.include_httpx_metadata\n\n # Security warning when redirects are enabled\n if follow_redirects:\n self.log(\n \"Security Warning: HTTP redirects are enabled. This may allow SSRF bypass attacks \"\n \"where a public URL redirects to internal resources (e.g., cloud metadata endpoints). \"\n \"Only enable this if you trust the target server.\"\n )\n\n # if self.mode == \"cURL\" and self.curl_input:\n # self._build_config = self.parse_curl(self.curl_input, dotdict())\n # # After parsing curl, get the normalized URL\n # url = self._build_config[\"url_input\"][\"value\"]\n\n # Normalize URL before validation\n url = self._normalize_url(url)\n\n # Validate URL\n if not validators.url(url):\n msg = f\"Invalid URL provided: {url}\"\n raise ValueError(msg)\n\n # SSRF Protection: Validate URL to prevent access to internal resources\n # TODO: In next major version (2.0), remove warn_only=True to enforce blocking\n try:\n validate_url_for_ssrf(url, warn_only=True)\n except SSRFProtectionError as e:\n # This will only raise if SSRF protection is enabled and warn_only=False\n msg = f\"SSRF Protection: {e}\"\n raise ValueError(msg) from e\n\n # Process query parameters\n if isinstance(self.query_params, str):\n query_params = dict(parse_qsl(self.query_params))\n else:\n query_params = self.query_params.data if self.query_params else {}\n\n # Process headers and body\n headers = self._process_headers(headers)\n body = self._process_body(body)\n url = self.add_query_params(url, query_params)\n\n async with httpx.AsyncClient() as client:\n result = await self.make_request(\n client,\n method,\n url,\n headers,\n body,\n timeout,\n follow_redirects=follow_redirects,\n save_to_file=save_to_file,\n include_httpx_metadata=include_httpx_metadata,\n )\n self.status = result\n return result\n\n def update_build_config(self, build_config: dotdict, field_value: Any, field_name: str | None = None) -> dotdict:\n \"\"\"Update the build config based on the selected mode.\"\"\"\n if field_name != \"mode\":\n if field_name == \"curl_input\" and self.mode == \"cURL\" and self.curl_input:\n return self.parse_curl(self.curl_input, build_config)\n return build_config\n\n if field_value == \"cURL\":\n set_field_display(build_config, \"curl_input\", value=True)\n if build_config[\"curl_input\"][\"value\"]:\n try:\n build_config = self.parse_curl(build_config[\"curl_input\"][\"value\"], build_config)\n except ValueError as e:\n self.log(f\"Failed to parse cURL input: {e}\")\n else:\n set_field_display(build_config, \"curl_input\", value=False)\n\n return set_current_fields(\n build_config=build_config,\n action_fields=MODE_FIELDS,\n selected_action=field_value,\n default_fields=DEFAULT_FIELDS,\n func=set_field_advanced,\n default_value=True,\n )\n\n async def _response_info(\n self, response: httpx.Response, *, with_file_path: bool = False\n ) -> tuple[bool, Path | None]:\n \"\"\"Determine the file path and whether the response content is binary.\n\n Args:\n response (Response): The HTTP response object.\n with_file_path (bool): Whether to save the response content to a file.\n\n Returns:\n Tuple[bool, Path | None]:\n A tuple containing a boolean indicating if the content is binary and the full file path (if applicable).\n \"\"\"\n content_type = response.headers.get(\"Content-Type\", \"\")\n is_binary = \"application/octet-stream\" in content_type or \"application/binary\" in content_type\n\n if not with_file_path:\n return is_binary, None\n\n component_temp_dir = Path(tempfile.gettempdir()) / self.__class__.__name__\n\n # Create directory asynchronously\n await aiofiles_os.makedirs(component_temp_dir, exist_ok=True)\n\n filename = None\n if \"Content-Disposition\" in response.headers:\n content_disposition = response.headers[\"Content-Disposition\"]\n filename_match = re.search(r'filename=\"(.+?)\"', content_disposition)\n if filename_match:\n extracted_filename = filename_match.group(1)\n filename = extracted_filename\n\n # Step 3: Infer file extension or use part of the request URL if no filename\n if not filename:\n # Extract the last segment of the URL path\n url_path = urlparse(str(response.request.url) if response.request else \"\").path\n base_name = Path(url_path).name # Get the last segment of the path\n if not base_name: # If the path ends with a slash or is empty\n base_name = \"response\"\n\n # Infer file extension\n content_type_to_extension = {\n \"text/plain\": \".txt\",\n \"application/json\": \".json\",\n \"image/jpeg\": \".jpg\",\n \"image/png\": \".png\",\n \"application/octet-stream\": \".bin\",\n }\n extension = content_type_to_extension.get(content_type, \".bin\" if is_binary else \".txt\")\n filename = f\"{base_name}{extension}\"\n\n # Step 4: Define the full file path\n file_path = component_temp_dir / filename\n\n # Step 5: Check if file exists asynchronously and handle accordingly\n try:\n # Try to create the file exclusively (x mode) to check existence\n async with aiofiles.open(file_path, \"x\") as _:\n pass # File created successfully, we can use this path\n except FileExistsError:\n # If file exists, append a timestamp to the filename\n timestamp = datetime.now(timezone.utc).strftime(\"%Y%m%d%H%M%S%f\")\n file_path = component_temp_dir / f\"{timestamp}-{filename}\"\n\n return is_binary, file_path\n"
+ "value": "import json\nimport re\nimport tempfile\nfrom datetime import datetime, timezone\nfrom pathlib import Path\nfrom typing import Any\nfrom urllib.parse import parse_qsl, urlencode, urlparse, urlunparse\n\nimport aiofiles\nimport aiofiles.os as aiofiles_os\nimport httpx\nimport validators\n\nfrom lfx.base.curl.parse import parse_context\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.inputs.inputs import TabInput\nfrom lfx.io import (\n BoolInput,\n DataInput,\n DropdownInput,\n IntInput,\n MessageTextInput,\n MultilineInput,\n Output,\n TableInput,\n)\nfrom lfx.schema.data import Data\nfrom lfx.schema.dotdict import dotdict\nfrom lfx.utils.component_utils import set_current_fields, set_field_advanced, set_field_display\n\n# SSRF Protection imports - for preventing Server-Side Request Forgery attacks\nfrom lfx.utils.ssrf_protection import SSRFProtectionError, validate_and_resolve_url\nfrom lfx.utils.ssrf_transport import create_ssrf_protected_client\n\n# Define fields for each mode\nMODE_FIELDS = {\n \"URL\": [\n \"url_input\",\n \"method\",\n ],\n \"cURL\": [\"curl_input\"],\n}\n\n# Fields that should always be visible\nDEFAULT_FIELDS = [\"mode\"]\n\n\nclass APIRequestComponent(Component):\n display_name = \"API Request\"\n description = \"Make HTTP requests using URL or cURL commands.\"\n documentation: str = \"https://docs.langflow.org/api-request\"\n icon = \"Globe\"\n name = \"APIRequest\"\n\n inputs = [\n MessageTextInput(\n name=\"url_input\",\n display_name=\"URL\",\n info=\"Enter the URL for the request.\",\n advanced=False,\n tool_mode=True,\n ),\n MultilineInput(\n name=\"curl_input\",\n display_name=\"cURL\",\n info=(\n \"Paste a curl command to populate the fields. \"\n \"This will fill in the dictionary fields for headers and body.\"\n ),\n real_time_refresh=True,\n tool_mode=True,\n advanced=True,\n show=False,\n ),\n DropdownInput(\n name=\"method\",\n display_name=\"Method\",\n options=[\"GET\", \"POST\", \"PATCH\", \"PUT\", \"DELETE\"],\n value=\"GET\",\n info=\"The HTTP method to use.\",\n real_time_refresh=True,\n ),\n TabInput(\n name=\"mode\",\n display_name=\"Mode\",\n options=[\"URL\", \"cURL\"],\n value=\"URL\",\n info=\"Enable cURL mode to populate fields from a cURL command.\",\n real_time_refresh=True,\n ),\n DataInput(\n name=\"query_params\",\n display_name=\"Query Parameters\",\n info=\"The query parameters to append to the URL.\",\n advanced=True,\n ),\n TableInput(\n name=\"body\",\n display_name=\"Body\",\n info=\"The body to send with the request as a dictionary (for POST, PATCH, PUT).\",\n table_schema=[\n {\n \"name\": \"key\",\n \"display_name\": \"Key\",\n \"type\": \"str\",\n \"description\": \"Parameter name\",\n },\n {\n \"name\": \"value\",\n \"display_name\": \"Value\",\n \"description\": \"Parameter value\",\n },\n ],\n value=[],\n input_types=[\"Data\", \"JSON\"],\n advanced=True,\n real_time_refresh=True,\n ),\n TableInput(\n name=\"headers\",\n display_name=\"Headers\",\n info=\"The headers to send with the request\",\n table_schema=[\n {\n \"name\": \"key\",\n \"display_name\": \"Header\",\n \"type\": \"str\",\n \"description\": \"Header name\",\n },\n {\n \"name\": \"value\",\n \"display_name\": \"Value\",\n \"type\": \"str\",\n \"description\": \"Header value\",\n },\n ],\n value=[{\"key\": \"User-Agent\", \"value\": \"Langflow/1.0\"}],\n advanced=True,\n input_types=[\"Data\", \"JSON\"],\n real_time_refresh=True,\n ),\n IntInput(\n name=\"timeout\",\n display_name=\"Timeout\",\n value=30,\n info=\"The timeout to use for the request.\",\n advanced=True,\n ),\n BoolInput(\n name=\"follow_redirects\",\n display_name=\"Follow Redirects\",\n value=False,\n info=(\n \"Whether to follow HTTP redirects. \"\n \"WARNING: Enabling redirects may allow SSRF bypass attacks where a public URL \"\n \"redirects to internal resources. Only enable if you trust the target server. \"\n \"See OWASP SSRF Prevention Cheat Sheet for details.\"\n ),\n advanced=True,\n ),\n BoolInput(\n name=\"save_to_file\",\n display_name=\"Save to File\",\n value=False,\n info=\"Save the API response to a temporary file\",\n advanced=True,\n ),\n BoolInput(\n name=\"include_httpx_metadata\",\n display_name=\"Include HTTPx Metadata\",\n value=False,\n info=(\n \"Include properties such as headers, status_code, response_headers, \"\n \"and redirection_history in the output.\"\n ),\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"API Response\", name=\"data\", method=\"make_api_request\"),\n ]\n\n def _parse_json_value(self, value: Any) -> Any:\n \"\"\"Parse a value that might be a JSON string.\"\"\"\n if not isinstance(value, str):\n return value\n\n try:\n parsed = json.loads(value)\n except json.JSONDecodeError:\n return value\n else:\n return parsed\n\n def _process_body(self, body: Any) -> dict:\n \"\"\"Process the body input into a valid dictionary.\"\"\"\n if body is None:\n return {}\n if hasattr(body, \"data\"):\n body = body.data\n if isinstance(body, dict):\n return self._process_dict_body(body)\n if isinstance(body, str):\n return self._process_string_body(body)\n if isinstance(body, list):\n return self._process_list_body(body)\n return {}\n\n def _process_dict_body(self, body: dict) -> dict:\n \"\"\"Process dictionary body by parsing JSON values.\"\"\"\n return {k: self._parse_json_value(v) for k, v in body.items()}\n\n def _process_string_body(self, body: str) -> dict:\n \"\"\"Process string body by attempting JSON parse.\"\"\"\n try:\n return self._process_body(json.loads(body))\n except json.JSONDecodeError:\n return {\"data\": body}\n\n def _process_list_body(self, body: list) -> dict:\n \"\"\"Process list body by converting to key-value dictionary.\"\"\"\n processed_dict = {}\n try:\n for item in body:\n # Unwrap Data objects\n current_item = item\n if hasattr(item, \"data\"):\n unwrapped_data = item.data\n # If the unwrapped data is a dict but not key-value format, use it directly\n if isinstance(unwrapped_data, dict) and not self._is_valid_key_value_item(unwrapped_data):\n return unwrapped_data\n current_item = unwrapped_data\n if not self._is_valid_key_value_item(current_item):\n continue\n key = current_item[\"key\"]\n value = self._parse_json_value(current_item[\"value\"])\n processed_dict[key] = value\n except (KeyError, TypeError, ValueError) as e:\n self.log(f\"Failed to process body list: {e}\")\n return {}\n return processed_dict\n\n def _is_valid_key_value_item(self, item: Any) -> bool:\n \"\"\"Check if an item is a valid key-value dictionary.\"\"\"\n return isinstance(item, dict) and \"key\" in item and \"value\" in item\n\n def parse_curl(self, curl: str, build_config: dotdict) -> dotdict:\n \"\"\"Parse a cURL command and update build configuration.\"\"\"\n try:\n parsed = parse_context(curl)\n\n # Update basic configuration\n url = parsed.url\n # Normalize URL before setting it\n url = self._normalize_url(url)\n\n build_config[\"url_input\"][\"value\"] = url\n build_config[\"method\"][\"value\"] = parsed.method.upper()\n\n # Process headers\n headers_list = [{\"key\": k, \"value\": v} for k, v in parsed.headers.items()]\n build_config[\"headers\"][\"value\"] = headers_list\n\n # Process body data\n if not parsed.data:\n build_config[\"body\"][\"value\"] = []\n elif parsed.data:\n try:\n json_data = json.loads(parsed.data)\n if isinstance(json_data, dict):\n body_list = [\n {\"key\": k, \"value\": json.dumps(v) if isinstance(v, dict | list) else str(v)}\n for k, v in json_data.items()\n ]\n build_config[\"body\"][\"value\"] = body_list\n else:\n build_config[\"body\"][\"value\"] = [{\"key\": \"data\", \"value\": json.dumps(json_data)}]\n except json.JSONDecodeError:\n build_config[\"body\"][\"value\"] = [{\"key\": \"data\", \"value\": parsed.data}]\n\n except Exception as exc:\n msg = f\"Error parsing curl: {exc}\"\n self.log(msg)\n raise ValueError(msg) from exc\n\n return build_config\n\n def _normalize_url(self, url: str) -> str:\n \"\"\"Normalize URL by adding https:// if no protocol is specified.\"\"\"\n if not url or not isinstance(url, str):\n msg = \"URL cannot be empty\"\n raise ValueError(msg)\n\n url = url.strip()\n if url.startswith((\"http://\", \"https://\")):\n return url\n return f\"https://{url}\"\n\n async def make_request(\n self,\n client: httpx.AsyncClient,\n method: str,\n url: str,\n headers: dict | None = None,\n body: Any = None,\n timeout: int = 5,\n *,\n follow_redirects: bool = True,\n save_to_file: bool = False,\n include_httpx_metadata: bool = False,\n ) -> Data:\n method = method.upper()\n if method not in {\"GET\", \"POST\", \"PATCH\", \"PUT\", \"DELETE\"}:\n msg = f\"Unsupported method: {method}\"\n raise ValueError(msg)\n\n processed_body = self._process_body(body)\n redirection_history = []\n\n try:\n # Prepare request parameters\n request_params = {\n \"method\": method,\n \"url\": url,\n \"headers\": headers,\n \"timeout\": timeout,\n \"follow_redirects\": follow_redirects,\n }\n # Only include body for methods that support it (GET must not have a body per HTTP spec)\n if method in {\"POST\", \"PATCH\", \"PUT\", \"DELETE\"} and processed_body is not None:\n request_params[\"json\"] = processed_body\n response = await client.request(**request_params)\n\n redirection_history = [\n {\n \"url\": redirect.headers.get(\"Location\", str(redirect.url)),\n \"status_code\": redirect.status_code,\n }\n for redirect in response.history\n ]\n\n is_binary, file_path = await self._response_info(response, with_file_path=save_to_file)\n response_headers = self._headers_to_dict(response.headers)\n\n # Base metadata\n metadata = {\n \"source\": url,\n \"status_code\": response.status_code,\n \"response_headers\": response_headers,\n }\n\n if redirection_history:\n metadata[\"redirection_history\"] = redirection_history\n\n if save_to_file:\n mode = \"wb\" if is_binary else \"w\"\n encoding = response.encoding if mode == \"w\" else None\n if file_path:\n await aiofiles_os.makedirs(file_path.parent, exist_ok=True)\n if is_binary:\n async with aiofiles.open(file_path, \"wb\") as f:\n await f.write(response.content)\n await f.flush()\n else:\n async with aiofiles.open(file_path, \"w\", encoding=encoding) as f:\n await f.write(response.text)\n await f.flush()\n metadata[\"file_path\"] = str(file_path)\n\n if include_httpx_metadata:\n metadata.update({\"headers\": headers})\n return Data(data=metadata)\n\n # Handle response content\n if is_binary:\n result = response.content\n else:\n try:\n result = response.json()\n except json.JSONDecodeError:\n self.log(\"Failed to decode JSON response\")\n result = response.text.encode(\"utf-8\")\n\n metadata[\"result\"] = result\n\n if include_httpx_metadata:\n metadata.update({\"headers\": headers})\n\n return Data(data=metadata)\n except (httpx.HTTPError, httpx.RequestError, httpx.TimeoutException) as exc:\n self.log(f\"Error making request to {url}\")\n return Data(\n data={\n \"source\": url,\n \"headers\": headers,\n \"status_code\": 500,\n \"error\": str(exc),\n **({\"redirection_history\": redirection_history} if redirection_history else {}),\n },\n )\n\n def add_query_params(self, url: str, params: dict) -> str:\n \"\"\"Add query parameters to URL efficiently.\"\"\"\n if not params:\n return url\n url_parts = list(urlparse(url))\n query = dict(parse_qsl(url_parts[4]))\n query.update(params)\n url_parts[4] = urlencode(query)\n return urlunparse(url_parts)\n\n def _headers_to_dict(self, headers: httpx.Headers) -> dict[str, str]:\n \"\"\"Convert HTTP headers to a dictionary with lowercased keys.\"\"\"\n return {k.lower(): v for k, v in headers.items()}\n\n def _process_headers(self, headers: Any) -> dict:\n \"\"\"Process the headers input into a valid dictionary.\"\"\"\n if headers is None:\n return {}\n if isinstance(headers, dict):\n return headers\n if isinstance(headers, list):\n return {item[\"key\"]: item[\"value\"] for item in headers if self._is_valid_key_value_item(item)}\n return {}\n\n async def make_api_request(self) -> Data:\n \"\"\"Make HTTP request with SSRF protection and DNS pinning.\n\n This method implements comprehensive SSRF (Server-Side Request Forgery) protection\n using DNS pinning to prevent DNS rebinding attacks. The protection works by:\n 1. Validating the URL and resolving DNS during security check\n 2. Pinning the validated IP address\n 3. Forcing the HTTP client to use the pinned IP for the actual request\n 4. Ignoring any subsequent DNS changes (prevents rebinding attacks)\n\n Returns:\n Data: Response data from the HTTP request\n\n Raises:\n ValueError: If URL is invalid or blocked by SSRF protection\n \"\"\"\n # Extract request parameters\n method = self.method\n url = self.url_input.strip() if isinstance(self.url_input, str) else \"\"\n headers = self.headers or {}\n body = self.body or {}\n timeout = self.timeout\n follow_redirects = self.follow_redirects\n save_to_file = self.save_to_file\n include_httpx_metadata = self.include_httpx_metadata\n\n # Security warning: HTTP redirects can bypass SSRF protection\n # A public URL could redirect to an internal resource\n if follow_redirects:\n self.log(\n \"Security Warning: HTTP redirects are enabled. This may allow SSRF bypass attacks \"\n \"where a public URL redirects to internal resources (e.g., cloud metadata endpoints). \"\n \"Only enable this if you trust the target server.\"\n )\n\n # Normalize URL (add https:// if no protocol specified)\n url = self._normalize_url(url)\n\n # Basic URL format validation\n if not validators.url(url):\n msg = f\"Invalid URL provided: {url}\"\n raise ValueError(msg)\n\n # ============================================================================\n # SSRF Protection with DNS Pinning\n # ============================================================================\n # This prevents DNS rebinding attacks by:\n # 1. Resolving DNS and validating IPs during security check\n # 2. Pinning the validated IP address\n # 3. Using a custom HTTP transport that forces use of the pinned IP\n # 4. Ignoring any new DNS resolutions (prevents rebinding)\n #\n # Without DNS pinning, an attacker could:\n # - First DNS lookup: returns public IP (passes validation)\n # - Second DNS lookup: returns internal IP (bypasses protection)\n # - Attack succeeds: accesses internal services\n #\n # With DNS pinning:\n # - First DNS lookup: returns public IP (passes validation)\n # - IP is pinned: \"example.com = 93.184.216.34\"\n # - HTTP request: uses pinned IP directly (no new DNS lookup)\n # - Attack fails: even if DNS changes, we use the validated IP\n # ============================================================================\n\n try:\n # Validate URL and get validated IPs for DNS pinning\n _validated_url, validated_ips = validate_and_resolve_url(url)\n\n # Log DNS pinning information for security auditing\n if validated_ips:\n self.log(f\"SSRF Protection: Using DNS pinning with {len(validated_ips)} validated IP(s)\")\n\n except SSRFProtectionError as e:\n # SSRF protection blocked the request (private IP, internal network, etc.)\n msg = f\"SSRF Protection: {e}\"\n raise ValueError(msg) from e\n\n # Process query parameters (from string or Data object)\n if isinstance(self.query_params, str):\n query_params = dict(parse_qsl(self.query_params))\n else:\n query_params = self.query_params.data if self.query_params else {}\n\n # Process headers and body into proper format\n headers = self._process_headers(headers)\n body = self._process_body(body)\n url = self.add_query_params(url, query_params)\n\n # ============================================================================\n # Create HTTP Client with DNS Pinning (if SSRF protection enabled)\n # ============================================================================\n from lfx.utils.ssrf_protection import is_ssrf_protection_enabled\n\n if is_ssrf_protection_enabled() and validated_ips:\n # SSRF protection is enabled and DNS pinning is needed\n # Extract hostname from the final URL (after query params added)\n hostname = urlparse(url).hostname\n\n if hostname:\n # Create client with DNS pinning to prevent rebinding attacks\n # The custom transport will try validated IPs in order (supports dual-stack/load balancing)\n # while preserving the Host header for virtual hosting/SNI\n async with create_ssrf_protected_client(\n hostname=hostname,\n validated_ips=validated_ips, # Pass all validated IPs\n ) as client:\n result = await self.make_request(\n client,\n method,\n url,\n headers,\n body,\n timeout,\n follow_redirects=follow_redirects,\n save_to_file=save_to_file,\n include_httpx_metadata=include_httpx_metadata,\n )\n else:\n # Hostname extraction failed - fallback to normal client\n # This should rarely happen as URL was already validated\n async with httpx.AsyncClient() as client:\n result = await self.make_request(\n client,\n method,\n url,\n headers,\n body,\n timeout,\n follow_redirects=follow_redirects,\n save_to_file=save_to_file,\n include_httpx_metadata=include_httpx_metadata,\n )\n else:\n # No DNS pinning needed - use normal client\n # This happens when SSRF protection is disabled or host is allowlisted\n # - SSRF protection is disabled\n # - Host is in the allowlist (e.g., localhost for Ollama)\n # - Direct IP address was used (no DNS to pin)\n async with httpx.AsyncClient() as client:\n result = await self.make_request(\n client,\n method,\n url,\n headers,\n body,\n timeout,\n follow_redirects=follow_redirects,\n save_to_file=save_to_file,\n include_httpx_metadata=include_httpx_metadata,\n )\n\n self.status = result\n return result\n\n def update_build_config(self, build_config: dotdict, field_value: Any, field_name: str | None = None) -> dotdict:\n \"\"\"Update the build config based on the selected mode.\"\"\"\n if field_name != \"mode\":\n if field_name == \"curl_input\" and self.mode == \"cURL\" and self.curl_input:\n return self.parse_curl(self.curl_input, build_config)\n return build_config\n\n if field_value == \"cURL\":\n set_field_display(build_config, \"curl_input\", value=True)\n if build_config[\"curl_input\"][\"value\"]:\n try:\n build_config = self.parse_curl(build_config[\"curl_input\"][\"value\"], build_config)\n except ValueError as e:\n self.log(f\"Failed to parse cURL input: {e}\")\n else:\n set_field_display(build_config, \"curl_input\", value=False)\n\n return set_current_fields(\n build_config=build_config,\n action_fields=MODE_FIELDS,\n selected_action=field_value,\n default_fields=DEFAULT_FIELDS,\n func=set_field_advanced,\n default_value=True,\n )\n\n async def _response_info(\n self, response: httpx.Response, *, with_file_path: bool = False\n ) -> tuple[bool, Path | None]:\n \"\"\"Determine the file path and whether the response content is binary.\n\n Args:\n response (Response): The HTTP response object.\n with_file_path (bool): Whether to save the response content to a file.\n\n Returns:\n Tuple[bool, Path | None]:\n A tuple containing a boolean indicating if the content is binary and the full file path (if applicable).\n \"\"\"\n content_type = response.headers.get(\"Content-Type\", \"\")\n is_binary = \"application/octet-stream\" in content_type or \"application/binary\" in content_type\n\n if not with_file_path:\n return is_binary, None\n\n component_temp_dir = Path(tempfile.gettempdir()) / self.__class__.__name__\n\n # Create directory asynchronously\n await aiofiles_os.makedirs(component_temp_dir, exist_ok=True)\n\n filename = None\n if \"Content-Disposition\" in response.headers:\n content_disposition = response.headers[\"Content-Disposition\"]\n filename_match = re.search(r'filename=\"(.+?)\"', content_disposition)\n if filename_match:\n extracted_filename = filename_match.group(1)\n filename = extracted_filename\n\n # Step 3: Infer file extension or use part of the request URL if no filename\n if not filename:\n # Extract the last segment of the URL path\n url_path = urlparse(str(response.request.url) if response.request else \"\").path\n base_name = Path(url_path).name # Get the last segment of the path\n if not base_name: # If the path ends with a slash or is empty\n base_name = \"response\"\n\n # Infer file extension\n content_type_to_extension = {\n \"text/plain\": \".txt\",\n \"application/json\": \".json\",\n \"image/jpeg\": \".jpg\",\n \"image/png\": \".png\",\n \"application/octet-stream\": \".bin\",\n }\n extension = content_type_to_extension.get(content_type, \".bin\" if is_binary else \".txt\")\n filename = f\"{base_name}{extension}\"\n\n # Step 4: Define the full file path\n file_path = component_temp_dir / filename\n\n # Step 5: Check if file exists asynchronously and handle accordingly\n try:\n # Try to create the file exclusively (x mode) to check existence\n async with aiofiles.open(file_path, \"x\") as _:\n pass # File created successfully, we can use this path\n except FileExistsError:\n # If file exists, append a timestamp to the filename\n timestamp = datetime.now(timezone.utc).strftime(\"%Y%m%d%H%M%S%f\")\n file_path = component_temp_dir / f\"{timestamp}-{filename}\"\n\n return is_binary, file_path\n"
},
"curl_input": {
"_input_type": "MultilineInput",
diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Portfolio Website Code Generator.json b/src/backend/base/langflow/initial_setup/starter_projects/Portfolio Website Code Generator.json
index f66cee1ec5..bbf8fe2f5c 100644
--- a/src/backend/base/langflow/initial_setup/starter_projects/Portfolio Website Code Generator.json
+++ b/src/backend/base/langflow/initial_setup/starter_projects/Portfolio Website Code Generator.json
@@ -949,7 +949,7 @@
},
{
"name": "googleapiclient",
- "version": "2.194.0"
+ "version": "2.195.0"
}
],
"total_dependencies": 4
diff --git a/src/backend/base/langflow/initial_setup/starter_projects/SaaS Pricing.json b/src/backend/base/langflow/initial_setup/starter_projects/SaaS Pricing.json
index 153e7f4088..ae15e88168 100644
--- a/src/backend/base/langflow/initial_setup/starter_projects/SaaS Pricing.json
+++ b/src/backend/base/langflow/initial_setup/starter_projects/SaaS Pricing.json
@@ -717,7 +717,7 @@
"legacy": false,
"lf_version": "1.4.2",
"metadata": {
- "code_hash": "37caa1aba62c",
+ "code_hash": "d0b2936e74fa",
"dependencies": {
"dependencies": [
{
@@ -770,7 +770,7 @@
"show": true,
"title_case": false,
"type": "code",
- "value": "import ast\nimport operator\nfrom collections.abc import Callable\n\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.inputs.inputs import MessageTextInput\nfrom lfx.io import Output\nfrom lfx.schema.data import Data\n\n\nclass CalculatorComponent(Component):\n display_name = \"Calculator\"\n description = \"Perform basic arithmetic operations on a given expression.\"\n documentation: str = \"https://docs.langflow.org/calculator\"\n icon = \"calculator\"\n\n # Cache operators dictionary as a class variable\n OPERATORS: dict[type[ast.operator], Callable] = {\n ast.Add: operator.add,\n ast.Sub: operator.sub,\n ast.Mult: operator.mul,\n ast.Div: operator.truediv,\n ast.Pow: operator.pow,\n }\n\n inputs = [\n MessageTextInput(\n name=\"expression\",\n display_name=\"Expression\",\n info=\"The arithmetic expression to evaluate (e.g., '4*4*(33/22)+12-20').\",\n tool_mode=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"JSON\", name=\"result\", type_=Data, method=\"evaluate_expression\"),\n ]\n\n def _eval_expr(self, node: ast.AST) -> float:\n \"\"\"Evaluate an AST node recursively.\"\"\"\n if isinstance(node, ast.Constant):\n if isinstance(node.value, int | float):\n return float(node.value)\n error_msg = f\"Unsupported constant type: {type(node.value).__name__}\"\n raise TypeError(error_msg)\n if isinstance(node, ast.Num): # For backwards compatibility\n if isinstance(node.n, int | float):\n return float(node.n)\n error_msg = f\"Unsupported number type: {type(node.n).__name__}\"\n raise TypeError(error_msg)\n\n if isinstance(node, ast.BinOp):\n op_type = type(node.op)\n if op_type not in self.OPERATORS:\n error_msg = f\"Unsupported binary operator: {op_type.__name__}\"\n raise TypeError(error_msg)\n\n left = self._eval_expr(node.left)\n right = self._eval_expr(node.right)\n return self.OPERATORS[op_type](left, right)\n\n error_msg = f\"Unsupported operation or expression type: {type(node).__name__}\"\n raise TypeError(error_msg)\n\n def evaluate_expression(self) -> Data:\n \"\"\"Evaluate the mathematical expression and return the result.\"\"\"\n try:\n tree = ast.parse(self.expression, mode=\"eval\")\n result = self._eval_expr(tree.body)\n\n formatted_result = f\"{float(result):.6f}\".rstrip(\"0\").rstrip(\".\")\n self.log(f\"Calculation result: {formatted_result}\")\n\n self.status = formatted_result\n return Data(data={\"result\": formatted_result})\n\n except ZeroDivisionError:\n error_message = \"Error: Division by zero\"\n self.status = error_message\n return Data(data={\"error\": error_message, \"input\": self.expression})\n\n except (SyntaxError, TypeError, KeyError, ValueError, AttributeError, OverflowError) as e:\n error_message = f\"Invalid expression: {e!s}\"\n self.status = error_message\n return Data(data={\"error\": error_message, \"input\": self.expression})\n\n def build(self):\n \"\"\"Return the main evaluation function.\"\"\"\n return self.evaluate_expression\n"
+ "value": "import ast\nimport operator\nfrom collections.abc import Callable\n\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.inputs.inputs import MessageTextInput\nfrom lfx.io import Output\nfrom lfx.schema.data import Data\n\n\nclass CalculatorComponent(Component):\n display_name = \"Calculator\"\n description = \"Perform basic arithmetic operations on a given expression.\"\n documentation: str = \"https://docs.langflow.org/calculator\"\n icon = \"calculator\"\n\n # Cache operators dictionary as a class variable\n OPERATORS: dict[type[ast.operator], Callable] = {\n ast.Add: operator.add,\n ast.Sub: operator.sub,\n ast.Mult: operator.mul,\n ast.Div: operator.truediv,\n ast.Pow: operator.pow,\n }\n\n inputs = [\n MessageTextInput(\n name=\"expression\",\n display_name=\"Expression\",\n info=\"The arithmetic expression to evaluate (e.g., '4*4*(33/22)+12-20').\",\n tool_mode=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"JSON\", name=\"result\", type_=Data, method=\"evaluate_expression\"),\n ]\n\n def _eval_expr(self, node: ast.AST) -> float:\n \"\"\"Evaluate an AST node recursively.\"\"\"\n if isinstance(node, ast.Constant):\n if isinstance(node.value, int | float):\n return float(node.value)\n error_msg = f\"Unsupported constant type: {type(node.value).__name__}\"\n raise TypeError(error_msg)\n\n if isinstance(node, ast.BinOp):\n op_type = type(node.op)\n if op_type not in self.OPERATORS:\n error_msg = f\"Unsupported binary operator: {op_type.__name__}\"\n raise TypeError(error_msg)\n\n left = self._eval_expr(node.left)\n right = self._eval_expr(node.right)\n return self.OPERATORS[op_type](left, right)\n\n error_msg = f\"Unsupported operation or expression type: {type(node).__name__}\"\n raise TypeError(error_msg)\n\n def evaluate_expression(self) -> Data:\n \"\"\"Evaluate the mathematical expression and return the result.\"\"\"\n try:\n tree = ast.parse(self.expression, mode=\"eval\")\n result = self._eval_expr(tree.body)\n\n formatted_result = f\"{float(result):.6f}\".rstrip(\"0\").rstrip(\".\")\n self.log(f\"Calculation result: {formatted_result}\")\n\n self.status = formatted_result\n return Data(data={\"result\": formatted_result})\n\n except ZeroDivisionError:\n error_message = \"Error: Division by zero\"\n self.status = error_message\n return Data(data={\"error\": error_message, \"input\": self.expression})\n\n except (SyntaxError, TypeError, KeyError, ValueError, AttributeError, OverflowError) as e:\n error_message = f\"Invalid expression: {e!s}\"\n self.status = error_message\n return Data(data={\"error\": error_message, \"input\": self.expression})\n\n def build(self):\n \"\"\"Return the main evaluation function.\"\"\"\n return self.evaluate_expression\n"
},
"expression": {
"_input_type": "MessageTextInput",
diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Sequential Tasks Agents.json b/src/backend/base/langflow/initial_setup/starter_projects/Sequential Tasks Agents.json
index 72bbddcb34..1284b8654d 100644
--- a/src/backend/base/langflow/initial_setup/starter_projects/Sequential Tasks Agents.json
+++ b/src/backend/base/langflow/initial_setup/starter_projects/Sequential Tasks Agents.json
@@ -3203,7 +3203,7 @@
"key": "CalculatorComponent",
"legacy": false,
"metadata": {
- "code_hash": "37caa1aba62c",
+ "code_hash": "d0b2936e74fa",
"dependencies": {
"dependencies": [
{
@@ -3256,7 +3256,7 @@
"show": true,
"title_case": false,
"type": "code",
- "value": "import ast\nimport operator\nfrom collections.abc import Callable\n\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.inputs.inputs import MessageTextInput\nfrom lfx.io import Output\nfrom lfx.schema.data import Data\n\n\nclass CalculatorComponent(Component):\n display_name = \"Calculator\"\n description = \"Perform basic arithmetic operations on a given expression.\"\n documentation: str = \"https://docs.langflow.org/calculator\"\n icon = \"calculator\"\n\n # Cache operators dictionary as a class variable\n OPERATORS: dict[type[ast.operator], Callable] = {\n ast.Add: operator.add,\n ast.Sub: operator.sub,\n ast.Mult: operator.mul,\n ast.Div: operator.truediv,\n ast.Pow: operator.pow,\n }\n\n inputs = [\n MessageTextInput(\n name=\"expression\",\n display_name=\"Expression\",\n info=\"The arithmetic expression to evaluate (e.g., '4*4*(33/22)+12-20').\",\n tool_mode=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"JSON\", name=\"result\", type_=Data, method=\"evaluate_expression\"),\n ]\n\n def _eval_expr(self, node: ast.AST) -> float:\n \"\"\"Evaluate an AST node recursively.\"\"\"\n if isinstance(node, ast.Constant):\n if isinstance(node.value, int | float):\n return float(node.value)\n error_msg = f\"Unsupported constant type: {type(node.value).__name__}\"\n raise TypeError(error_msg)\n if isinstance(node, ast.Num): # For backwards compatibility\n if isinstance(node.n, int | float):\n return float(node.n)\n error_msg = f\"Unsupported number type: {type(node.n).__name__}\"\n raise TypeError(error_msg)\n\n if isinstance(node, ast.BinOp):\n op_type = type(node.op)\n if op_type not in self.OPERATORS:\n error_msg = f\"Unsupported binary operator: {op_type.__name__}\"\n raise TypeError(error_msg)\n\n left = self._eval_expr(node.left)\n right = self._eval_expr(node.right)\n return self.OPERATORS[op_type](left, right)\n\n error_msg = f\"Unsupported operation or expression type: {type(node).__name__}\"\n raise TypeError(error_msg)\n\n def evaluate_expression(self) -> Data:\n \"\"\"Evaluate the mathematical expression and return the result.\"\"\"\n try:\n tree = ast.parse(self.expression, mode=\"eval\")\n result = self._eval_expr(tree.body)\n\n formatted_result = f\"{float(result):.6f}\".rstrip(\"0\").rstrip(\".\")\n self.log(f\"Calculation result: {formatted_result}\")\n\n self.status = formatted_result\n return Data(data={\"result\": formatted_result})\n\n except ZeroDivisionError:\n error_message = \"Error: Division by zero\"\n self.status = error_message\n return Data(data={\"error\": error_message, \"input\": self.expression})\n\n except (SyntaxError, TypeError, KeyError, ValueError, AttributeError, OverflowError) as e:\n error_message = f\"Invalid expression: {e!s}\"\n self.status = error_message\n return Data(data={\"error\": error_message, \"input\": self.expression})\n\n def build(self):\n \"\"\"Return the main evaluation function.\"\"\"\n return self.evaluate_expression\n"
+ "value": "import ast\nimport operator\nfrom collections.abc import Callable\n\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.inputs.inputs import MessageTextInput\nfrom lfx.io import Output\nfrom lfx.schema.data import Data\n\n\nclass CalculatorComponent(Component):\n display_name = \"Calculator\"\n description = \"Perform basic arithmetic operations on a given expression.\"\n documentation: str = \"https://docs.langflow.org/calculator\"\n icon = \"calculator\"\n\n # Cache operators dictionary as a class variable\n OPERATORS: dict[type[ast.operator], Callable] = {\n ast.Add: operator.add,\n ast.Sub: operator.sub,\n ast.Mult: operator.mul,\n ast.Div: operator.truediv,\n ast.Pow: operator.pow,\n }\n\n inputs = [\n MessageTextInput(\n name=\"expression\",\n display_name=\"Expression\",\n info=\"The arithmetic expression to evaluate (e.g., '4*4*(33/22)+12-20').\",\n tool_mode=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"JSON\", name=\"result\", type_=Data, method=\"evaluate_expression\"),\n ]\n\n def _eval_expr(self, node: ast.AST) -> float:\n \"\"\"Evaluate an AST node recursively.\"\"\"\n if isinstance(node, ast.Constant):\n if isinstance(node.value, int | float):\n return float(node.value)\n error_msg = f\"Unsupported constant type: {type(node.value).__name__}\"\n raise TypeError(error_msg)\n\n if isinstance(node, ast.BinOp):\n op_type = type(node.op)\n if op_type not in self.OPERATORS:\n error_msg = f\"Unsupported binary operator: {op_type.__name__}\"\n raise TypeError(error_msg)\n\n left = self._eval_expr(node.left)\n right = self._eval_expr(node.right)\n return self.OPERATORS[op_type](left, right)\n\n error_msg = f\"Unsupported operation or expression type: {type(node).__name__}\"\n raise TypeError(error_msg)\n\n def evaluate_expression(self) -> Data:\n \"\"\"Evaluate the mathematical expression and return the result.\"\"\"\n try:\n tree = ast.parse(self.expression, mode=\"eval\")\n result = self._eval_expr(tree.body)\n\n formatted_result = f\"{float(result):.6f}\".rstrip(\"0\").rstrip(\".\")\n self.log(f\"Calculation result: {formatted_result}\")\n\n self.status = formatted_result\n return Data(data={\"result\": formatted_result})\n\n except ZeroDivisionError:\n error_message = \"Error: Division by zero\"\n self.status = error_message\n return Data(data={\"error\": error_message, \"input\": self.expression})\n\n except (SyntaxError, TypeError, KeyError, ValueError, AttributeError, OverflowError) as e:\n error_message = f\"Invalid expression: {e!s}\"\n self.status = error_message\n return Data(data={\"error\": error_message, \"input\": self.expression})\n\n def build(self):\n \"\"\"Return the main evaluation function.\"\"\"\n return self.evaluate_expression\n"
},
"expression": {
"_input_type": "MessageTextInput",
diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Simple Agent.json b/src/backend/base/langflow/initial_setup/starter_projects/Simple Agent.json
index 268efe45e7..69ca825bc8 100644
--- a/src/backend/base/langflow/initial_setup/starter_projects/Simple Agent.json
+++ b/src/backend/base/langflow/initial_setup/starter_projects/Simple Agent.json
@@ -200,7 +200,7 @@
"legacy": false,
"lf_version": "1.2.0",
"metadata": {
- "code_hash": "37caa1aba62c",
+ "code_hash": "d0b2936e74fa",
"dependencies": {
"dependencies": [
{
@@ -254,7 +254,7 @@
"show": true,
"title_case": false,
"type": "code",
- "value": "import ast\nimport operator\nfrom collections.abc import Callable\n\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.inputs.inputs import MessageTextInput\nfrom lfx.io import Output\nfrom lfx.schema.data import Data\n\n\nclass CalculatorComponent(Component):\n display_name = \"Calculator\"\n description = \"Perform basic arithmetic operations on a given expression.\"\n documentation: str = \"https://docs.langflow.org/calculator\"\n icon = \"calculator\"\n\n # Cache operators dictionary as a class variable\n OPERATORS: dict[type[ast.operator], Callable] = {\n ast.Add: operator.add,\n ast.Sub: operator.sub,\n ast.Mult: operator.mul,\n ast.Div: operator.truediv,\n ast.Pow: operator.pow,\n }\n\n inputs = [\n MessageTextInput(\n name=\"expression\",\n display_name=\"Expression\",\n info=\"The arithmetic expression to evaluate (e.g., '4*4*(33/22)+12-20').\",\n tool_mode=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"JSON\", name=\"result\", type_=Data, method=\"evaluate_expression\"),\n ]\n\n def _eval_expr(self, node: ast.AST) -> float:\n \"\"\"Evaluate an AST node recursively.\"\"\"\n if isinstance(node, ast.Constant):\n if isinstance(node.value, int | float):\n return float(node.value)\n error_msg = f\"Unsupported constant type: {type(node.value).__name__}\"\n raise TypeError(error_msg)\n if isinstance(node, ast.Num): # For backwards compatibility\n if isinstance(node.n, int | float):\n return float(node.n)\n error_msg = f\"Unsupported number type: {type(node.n).__name__}\"\n raise TypeError(error_msg)\n\n if isinstance(node, ast.BinOp):\n op_type = type(node.op)\n if op_type not in self.OPERATORS:\n error_msg = f\"Unsupported binary operator: {op_type.__name__}\"\n raise TypeError(error_msg)\n\n left = self._eval_expr(node.left)\n right = self._eval_expr(node.right)\n return self.OPERATORS[op_type](left, right)\n\n error_msg = f\"Unsupported operation or expression type: {type(node).__name__}\"\n raise TypeError(error_msg)\n\n def evaluate_expression(self) -> Data:\n \"\"\"Evaluate the mathematical expression and return the result.\"\"\"\n try:\n tree = ast.parse(self.expression, mode=\"eval\")\n result = self._eval_expr(tree.body)\n\n formatted_result = f\"{float(result):.6f}\".rstrip(\"0\").rstrip(\".\")\n self.log(f\"Calculation result: {formatted_result}\")\n\n self.status = formatted_result\n return Data(data={\"result\": formatted_result})\n\n except ZeroDivisionError:\n error_message = \"Error: Division by zero\"\n self.status = error_message\n return Data(data={\"error\": error_message, \"input\": self.expression})\n\n except (SyntaxError, TypeError, KeyError, ValueError, AttributeError, OverflowError) as e:\n error_message = f\"Invalid expression: {e!s}\"\n self.status = error_message\n return Data(data={\"error\": error_message, \"input\": self.expression})\n\n def build(self):\n \"\"\"Return the main evaluation function.\"\"\"\n return self.evaluate_expression\n"
+ "value": "import ast\nimport operator\nfrom collections.abc import Callable\n\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.inputs.inputs import MessageTextInput\nfrom lfx.io import Output\nfrom lfx.schema.data import Data\n\n\nclass CalculatorComponent(Component):\n display_name = \"Calculator\"\n description = \"Perform basic arithmetic operations on a given expression.\"\n documentation: str = \"https://docs.langflow.org/calculator\"\n icon = \"calculator\"\n\n # Cache operators dictionary as a class variable\n OPERATORS: dict[type[ast.operator], Callable] = {\n ast.Add: operator.add,\n ast.Sub: operator.sub,\n ast.Mult: operator.mul,\n ast.Div: operator.truediv,\n ast.Pow: operator.pow,\n }\n\n inputs = [\n MessageTextInput(\n name=\"expression\",\n display_name=\"Expression\",\n info=\"The arithmetic expression to evaluate (e.g., '4*4*(33/22)+12-20').\",\n tool_mode=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"JSON\", name=\"result\", type_=Data, method=\"evaluate_expression\"),\n ]\n\n def _eval_expr(self, node: ast.AST) -> float:\n \"\"\"Evaluate an AST node recursively.\"\"\"\n if isinstance(node, ast.Constant):\n if isinstance(node.value, int | float):\n return float(node.value)\n error_msg = f\"Unsupported constant type: {type(node.value).__name__}\"\n raise TypeError(error_msg)\n\n if isinstance(node, ast.BinOp):\n op_type = type(node.op)\n if op_type not in self.OPERATORS:\n error_msg = f\"Unsupported binary operator: {op_type.__name__}\"\n raise TypeError(error_msg)\n\n left = self._eval_expr(node.left)\n right = self._eval_expr(node.right)\n return self.OPERATORS[op_type](left, right)\n\n error_msg = f\"Unsupported operation or expression type: {type(node).__name__}\"\n raise TypeError(error_msg)\n\n def evaluate_expression(self) -> Data:\n \"\"\"Evaluate the mathematical expression and return the result.\"\"\"\n try:\n tree = ast.parse(self.expression, mode=\"eval\")\n result = self._eval_expr(tree.body)\n\n formatted_result = f\"{float(result):.6f}\".rstrip(\"0\").rstrip(\".\")\n self.log(f\"Calculation result: {formatted_result}\")\n\n self.status = formatted_result\n return Data(data={\"result\": formatted_result})\n\n except ZeroDivisionError:\n error_message = \"Error: Division by zero\"\n self.status = error_message\n return Data(data={\"error\": error_message, \"input\": self.expression})\n\n except (SyntaxError, TypeError, KeyError, ValueError, AttributeError, OverflowError) as e:\n error_message = f\"Invalid expression: {e!s}\"\n self.status = error_message\n return Data(data={\"error\": error_message, \"input\": self.expression})\n\n def build(self):\n \"\"\"Return the main evaluation function.\"\"\"\n return self.evaluate_expression\n"
},
"expression": {
"_input_type": "MessageTextInput",
diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Text Sentiment Analysis.json b/src/backend/base/langflow/initial_setup/starter_projects/Text Sentiment Analysis.json
index e6abf4c19f..64b84fa493 100644
--- a/src/backend/base/langflow/initial_setup/starter_projects/Text Sentiment Analysis.json
+++ b/src/backend/base/langflow/initial_setup/starter_projects/Text Sentiment Analysis.json
@@ -2641,7 +2641,7 @@
},
{
"name": "googleapiclient",
- "version": "2.194.0"
+ "version": "2.195.0"
}
],
"total_dependencies": 4
diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Travel Planning Agents.json b/src/backend/base/langflow/initial_setup/starter_projects/Travel Planning Agents.json
index 22575d16e7..4fbc085a25 100644
--- a/src/backend/base/langflow/initial_setup/starter_projects/Travel Planning Agents.json
+++ b/src/backend/base/langflow/initial_setup/starter_projects/Travel Planning Agents.json
@@ -1260,7 +1260,7 @@
"legacy": false,
"lf_version": "1.2.0",
"metadata": {
- "code_hash": "37caa1aba62c",
+ "code_hash": "d0b2936e74fa",
"dependencies": {
"dependencies": [
{
@@ -1313,7 +1313,7 @@
"show": true,
"title_case": false,
"type": "code",
- "value": "import ast\nimport operator\nfrom collections.abc import Callable\n\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.inputs.inputs import MessageTextInput\nfrom lfx.io import Output\nfrom lfx.schema.data import Data\n\n\nclass CalculatorComponent(Component):\n display_name = \"Calculator\"\n description = \"Perform basic arithmetic operations on a given expression.\"\n documentation: str = \"https://docs.langflow.org/calculator\"\n icon = \"calculator\"\n\n # Cache operators dictionary as a class variable\n OPERATORS: dict[type[ast.operator], Callable] = {\n ast.Add: operator.add,\n ast.Sub: operator.sub,\n ast.Mult: operator.mul,\n ast.Div: operator.truediv,\n ast.Pow: operator.pow,\n }\n\n inputs = [\n MessageTextInput(\n name=\"expression\",\n display_name=\"Expression\",\n info=\"The arithmetic expression to evaluate (e.g., '4*4*(33/22)+12-20').\",\n tool_mode=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"JSON\", name=\"result\", type_=Data, method=\"evaluate_expression\"),\n ]\n\n def _eval_expr(self, node: ast.AST) -> float:\n \"\"\"Evaluate an AST node recursively.\"\"\"\n if isinstance(node, ast.Constant):\n if isinstance(node.value, int | float):\n return float(node.value)\n error_msg = f\"Unsupported constant type: {type(node.value).__name__}\"\n raise TypeError(error_msg)\n if isinstance(node, ast.Num): # For backwards compatibility\n if isinstance(node.n, int | float):\n return float(node.n)\n error_msg = f\"Unsupported number type: {type(node.n).__name__}\"\n raise TypeError(error_msg)\n\n if isinstance(node, ast.BinOp):\n op_type = type(node.op)\n if op_type not in self.OPERATORS:\n error_msg = f\"Unsupported binary operator: {op_type.__name__}\"\n raise TypeError(error_msg)\n\n left = self._eval_expr(node.left)\n right = self._eval_expr(node.right)\n return self.OPERATORS[op_type](left, right)\n\n error_msg = f\"Unsupported operation or expression type: {type(node).__name__}\"\n raise TypeError(error_msg)\n\n def evaluate_expression(self) -> Data:\n \"\"\"Evaluate the mathematical expression and return the result.\"\"\"\n try:\n tree = ast.parse(self.expression, mode=\"eval\")\n result = self._eval_expr(tree.body)\n\n formatted_result = f\"{float(result):.6f}\".rstrip(\"0\").rstrip(\".\")\n self.log(f\"Calculation result: {formatted_result}\")\n\n self.status = formatted_result\n return Data(data={\"result\": formatted_result})\n\n except ZeroDivisionError:\n error_message = \"Error: Division by zero\"\n self.status = error_message\n return Data(data={\"error\": error_message, \"input\": self.expression})\n\n except (SyntaxError, TypeError, KeyError, ValueError, AttributeError, OverflowError) as e:\n error_message = f\"Invalid expression: {e!s}\"\n self.status = error_message\n return Data(data={\"error\": error_message, \"input\": self.expression})\n\n def build(self):\n \"\"\"Return the main evaluation function.\"\"\"\n return self.evaluate_expression\n"
+ "value": "import ast\nimport operator\nfrom collections.abc import Callable\n\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.inputs.inputs import MessageTextInput\nfrom lfx.io import Output\nfrom lfx.schema.data import Data\n\n\nclass CalculatorComponent(Component):\n display_name = \"Calculator\"\n description = \"Perform basic arithmetic operations on a given expression.\"\n documentation: str = \"https://docs.langflow.org/calculator\"\n icon = \"calculator\"\n\n # Cache operators dictionary as a class variable\n OPERATORS: dict[type[ast.operator], Callable] = {\n ast.Add: operator.add,\n ast.Sub: operator.sub,\n ast.Mult: operator.mul,\n ast.Div: operator.truediv,\n ast.Pow: operator.pow,\n }\n\n inputs = [\n MessageTextInput(\n name=\"expression\",\n display_name=\"Expression\",\n info=\"The arithmetic expression to evaluate (e.g., '4*4*(33/22)+12-20').\",\n tool_mode=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"JSON\", name=\"result\", type_=Data, method=\"evaluate_expression\"),\n ]\n\n def _eval_expr(self, node: ast.AST) -> float:\n \"\"\"Evaluate an AST node recursively.\"\"\"\n if isinstance(node, ast.Constant):\n if isinstance(node.value, int | float):\n return float(node.value)\n error_msg = f\"Unsupported constant type: {type(node.value).__name__}\"\n raise TypeError(error_msg)\n\n if isinstance(node, ast.BinOp):\n op_type = type(node.op)\n if op_type not in self.OPERATORS:\n error_msg = f\"Unsupported binary operator: {op_type.__name__}\"\n raise TypeError(error_msg)\n\n left = self._eval_expr(node.left)\n right = self._eval_expr(node.right)\n return self.OPERATORS[op_type](left, right)\n\n error_msg = f\"Unsupported operation or expression type: {type(node).__name__}\"\n raise TypeError(error_msg)\n\n def evaluate_expression(self) -> Data:\n \"\"\"Evaluate the mathematical expression and return the result.\"\"\"\n try:\n tree = ast.parse(self.expression, mode=\"eval\")\n result = self._eval_expr(tree.body)\n\n formatted_result = f\"{float(result):.6f}\".rstrip(\"0\").rstrip(\".\")\n self.log(f\"Calculation result: {formatted_result}\")\n\n self.status = formatted_result\n return Data(data={\"result\": formatted_result})\n\n except ZeroDivisionError:\n error_message = \"Error: Division by zero\"\n self.status = error_message\n return Data(data={\"error\": error_message, \"input\": self.expression})\n\n except (SyntaxError, TypeError, KeyError, ValueError, AttributeError, OverflowError) as e:\n error_message = f\"Invalid expression: {e!s}\"\n self.status = error_message\n return Data(data={\"error\": error_message, \"input\": self.expression})\n\n def build(self):\n \"\"\"Return the main evaluation function.\"\"\"\n return self.evaluate_expression\n"
},
"expression": {
"_input_type": "MessageTextInput",
diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Vector Store RAG.json b/src/backend/base/langflow/initial_setup/starter_projects/Vector Store RAG.json
index 13ee4dee83..7967fed23e 100644
--- a/src/backend/base/langflow/initial_setup/starter_projects/Vector Store RAG.json
+++ b/src/backend/base/langflow/initial_setup/starter_projects/Vector Store RAG.json
@@ -1643,7 +1643,7 @@
},
{
"name": "googleapiclient",
- "version": "2.194.0"
+ "version": "2.195.0"
}
],
"total_dependencies": 4
diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Youtube Analysis.json b/src/backend/base/langflow/initial_setup/starter_projects/Youtube Analysis.json
index c373dc7eb9..5441bdbe3e 100644
--- a/src/backend/base/langflow/initial_setup/starter_projects/Youtube Analysis.json
+++ b/src/backend/base/langflow/initial_setup/starter_projects/Youtube Analysis.json
@@ -274,7 +274,7 @@
},
{
"name": "googleapiclient",
- "version": "2.194.0"
+ "version": "2.195.0"
},
{
"name": "lfx",
diff --git a/src/backend/base/pyproject.toml b/src/backend/base/pyproject.toml
index adb925773c..d67cfa5c1c 100644
--- a/src/backend/base/pyproject.toml
+++ b/src/backend/base/pyproject.toml
@@ -1,8 +1,8 @@
[project]
name = "langflow-base"
-version = "0.9.2"
+version = "0.9.3"
description = "A Python package with a built-in web application"
-requires-python = ">=3.10,<3.14"
+requires-python = ">=3.10,<3.15"
license = "MIT"
keywords = ["nlp", "langchain", "openai", "gpt", "gui"]
readme = "README.md"
@@ -17,15 +17,15 @@ maintainers = [
]
dependencies = [
- "lfx~=0.4.2",
+ "lfx~=0.4.3",
"fastapi>=0.135.0,<1.0.0",
"httpx[http2]>=0.27,<1.0.0",
"aiofile>=3.9.0,<4.0.0",
"uvicorn>=0.30.0,<1.0.0",
- "gunicorn>=25.3.0,<26.0.0",
+ "gunicorn>=25.3.0,<27.0.0",
"langchain~=1.2.0",
"langchain-community~=0.4.1",
- "langchain-core>=1.2.28,<2.0.0",
+ "langchain-core>=1.3.3,<2.0.0",
"langchainhub~=0.1.15",
"loguru>=0.7.1,<1.0.0",
"structlog>=25.4.0,<26.0.0",
@@ -90,13 +90,15 @@ dependencies = [
'elevenlabs==1.58.1; python_version == "3.12"',
'elevenlabs>=1.52.0,<2.0.0; python_version != "3.12"',
"scipy>=1.15.2,<2.0.0",
- "ibm-watsonx-ai>=1.3.1,<2.0.0",
- "langchain-ibm~=1.0.2",
+ "ibm-watsonx-ai>=1.3.1,<2.0.0; python_version < '3.14'",
+ "langchain-ibm~=1.0.2; python_version < '3.14'",
"trustcall>=0.0.38,<1.0.0",
"langchain-chroma~=0.2.6",
"jaraco-context>=6.1.0",
"wheel>=0.46.2,<1.0.0",
- "onnxruntime>=1.20,<1.24", # >=1.24 does not support Python 3.10; allow 1.23.x for agent-lifecycle-toolkit compatibility
+ "onnxruntime>=1.20,<1.24; python_version<'3.14'", # >=1.24 does not support Python 3.10; allow 1.23.x for agent-lifecycle-toolkit compatibility
+ "onnxruntime>=1.26; python_version>='3.14'",
+ "protobuf>=6.33.6,<7.0.0",
"dynaconf>=3.2.13,<4.0.0",
"pyasn1>=0.6.3,<0.7.0",
"langgraph-checkpoint>4.0.0,<5.0.0",
@@ -110,7 +112,7 @@ dev = [
"ipykernel>=6.29.0",
"ruff>=0.12.7",
"httpx[http2]>=0.27",
- "pytest>=8.2.0",
+ "pytest>=9.0.3",
"requests>=2.33.0",
"pytest-cov>=5.0.0",
"pytest-mock>=3.14.0",
@@ -193,7 +195,10 @@ redis = ["redis>=7.4.0,<8.0.0"]
elasticsearch = ["elasticsearch~=8.19", "langchain-elasticsearch~=1.0.0"]
# Individual vector store providers
-faiss = ["faiss-cpu==1.9.0.post1"]
+faiss = [
+ "faiss-cpu==1.9.0.post1; python_version<'3.14'",
+ "faiss-cpu>=1.13.2; python_version>='3.14'",
+]
qdrant = ["qdrant-client==1.9.2"]
weaviate = ["weaviate-client>=4.10.2,<5.0.0"]
chroma = [
@@ -201,7 +206,7 @@ chroma = [
"langchain-chroma~=0.2.6"
]
upstash = ["upstash-vector==0.6.0"]
-pinecone = ["langchain-pinecone~=0.2.13"]
+pinecone = ["langchain-pinecone~=0.2.13; python_version < '3.14'"]
milvus = ["langchain-milvus~=0.3.2"]
mongodb-vectors = ["langchain-mongodb==0.7.0"]
astradb = ["langchain-astradb~=1.0.0"]
@@ -229,8 +234,8 @@ sentence-transformers = ["sentence-transformers>=2.3.1"]
ctransformers = ["ctransformers>=0.2.10"]
# Individual monitoring providers
-langfuse = ["langfuse~=3.8"]
-langwatch = ["langwatch~=0.10.0"]
+langfuse = ["langfuse~=3.8; python_version < '3.14'"]
+langwatch = ["langwatch~=0.10.0; python_version < '3.14'"]
langsmith = ["langsmith>=0.3.42,<1.0.0"]
arize = ["arize-phoenix-otel>=0.6.1"]
@@ -256,12 +261,12 @@ yfinance = ["yfinance==0.2.50"]
wolframalpha = ["wolframalpha==5.1.3"]
# Individual utilities
-pyarrow = ["pyarrow>=19.0.1,!=22.0.0"]
+pyarrow = ["pyarrow>=23.0.1,<24.0.0"]
fastavro = [
'fastavro==1.9.7; python_version < "3.13"',
'fastavro>=1.9.8,<2.0.0; python_version >= "3.13"',
]
-gitpython = ["GitPython==3.1.47"]
+gitpython = ["GitPython>=3.1.50"]
nltk = ["nltk>=3.9.4"]
lark = ["lark==1.2.2"]
@@ -284,7 +289,7 @@ composio = [
"composio==0.9.2",
"composio-langchain==0.9.2"
]
-ragstack = ["ragstack-ai-knowledge-store==0.2.1"]
+ragstack = ["ragstack-ai-knowledge-store==0.2.1; python_version < '3.13'"]
opensearch = ["opensearch-py==2.8.0"]
atlassian = ["atlassian-python-api==3.41.16"]
mem0 = ["mem0ai>=0.1.34"]
@@ -301,7 +306,7 @@ spider = ["spider-client>=0.0.27,<1.0.0"]
# Updated to 0.10.1+ for PyTorch 2.6.0 compatibility (addresses torch.load RCE vulnerability)
# Upper bound prevents breaking changes in future major versions
# Excluded on macOS x86_64: PyTorch dropped Intel Mac wheel builds after v2.2.2 (see LE-172)
-altk = ["agent-lifecycle-toolkit>=0.10.1,<1.0; sys_platform != 'darwin' or platform_machine != 'x86_64'"]
+altk = ["agent-lifecycle-toolkit>=0.10.1,<1.0; (sys_platform != 'darwin' or platform_machine != 'x86_64') and python_version < '3.14'"]
# Toolguard Integration
toolguard = ["toolguard>=0.2.16,<1.0.0"]
@@ -338,8 +343,8 @@ fastparquet = ["fastparquet>=2024.11.0,<2025.0.0"]
# Vision / ML
vlmrun = ["vlmrun[all]>=0.2.0"]
cuga = [
- "cuga>=0.2.20,<0.3.0; sys_platform != 'darwin'",
- "cuga>=0.2.20,<0.3.0; sys_platform == 'darwin' and platform_machine == 'arm64'",
+ "cuga>=0.2.20,<0.3.0; sys_platform != 'darwin' and python_version < '3.14'",
+ "cuga>=0.2.20,<0.3.0; sys_platform == 'darwin' and platform_machine == 'arm64' and python_version < '3.14'",
]
mlx = [
"mlx>=0.29.0; sys_platform == 'darwin' and platform_machine == 'arm64' and python_version >= '3.12'",
@@ -361,8 +366,8 @@ gassist = ["gassist>=0.0.1; sys_platform == 'win32'"]
# SDKs for IBM watsonx Orchestrate deployment adapter.
ibm-watsonx-clients = [
"ibm-cloud-sdk-core~=3.24.4",
- "ibm-watsonx-orchestrate-core~=2.8.0; python_version >= '3.11'",
- "ibm-watsonx-orchestrate-clients~=2.8.0; python_version >= '3.11'",
+ "ibm-watsonx-orchestrate-core~=2.8.0; python_version >= '3.11' and python_version < '3.14'",
+ "ibm-watsonx-orchestrate-clients~=2.8.0; python_version >= '3.11' and python_version < '3.14'",
]
complete = [
diff --git a/src/backend/tests/unit/base/models/test_model_utils.py b/src/backend/tests/unit/base/models/test_model_utils.py
index b18e24d6be..a2ba396915 100644
--- a/src/backend/tests/unit/base/models/test_model_utils.py
+++ b/src/backend/tests/unit/base/models/test_model_utils.py
@@ -6,7 +6,15 @@ by Akash Joshi / Anderson Filho: ``get_model_name`` returns ``"Custom"`` for
is set on a different attribute than the one ``next()`` happens to find first.
"""
-from langchain_ibm import ChatWatsonx
+import pytest
+
+try:
+ from langchain_ibm import ChatWatsonx
+except (ImportError, TypeError):
+ # langchain-ibm is gated to python_version<'3.14' because ibm-watsonx-ai
+ # has not yet adapted to Python 3.14's StrEnum initialization changes.
+ pytest.skip("langchain-ibm not available on Python 3.14+", allow_module_level=True)
+
from langchain_openai import AzureChatOpenAI, ChatOpenAI
from lfx.base.models.model_utils import get_model_name
diff --git a/src/backend/tests/unit/components/data_source/test_api_request_component.py b/src/backend/tests/unit/components/data_source/test_api_request_component.py
index 64834643e5..37d21819c6 100644
--- a/src/backend/tests/unit/components/data_source/test_api_request_component.py
+++ b/src/backend/tests/unit/components/data_source/test_api_request_component.py
@@ -363,7 +363,15 @@ class TestAPIRequestComponent(ComponentTestBaseWithoutClient):
class TestAPIRequestSSRFProtection:
- """Test SSRF protection in API Request component."""
+ """Rewritten SSRF Protection Tests for API Request Component.
+
+ These tests properly test the actual SSRF protection implementation without mocking
+ the core security functions. They verify:
+ 1. Real SSRF blocking with actual private IPs
+ 2. DNS pinning actually prevents rebinding
+ 3. Allowlist functionality works correctly
+ 4. Custom transport is used when protection is enabled.
+ """
@pytest.fixture
def component_class(self):
@@ -376,10 +384,10 @@ class TestAPIRequestSSRFProtection:
return {
"url_input": "https://example.com/api/test",
"method": "GET",
- "headers": [{"key": "User-Agent", "value": "test-agent"}],
+ "headers": [],
"body": [],
"timeout": 30,
- "follow_redirects": False, # Changed default for SSRF security
+ "follow_redirects": False,
"save_to_file": False,
"include_httpx_metadata": False,
"mode": "URL",
@@ -392,73 +400,93 @@ class TestAPIRequestSSRFProtection:
"""Return a component instance."""
return component_class(**default_kwargs)
- async def test_ssrf_protection_disabled_by_default(self, component):
- """Test that SSRF protection is disabled by default (warn-only mode)."""
- # Even with protection disabled, this should not raise
+ async def test_ssrf_protection_disabled_allows_all_urls(self, component):
+ """Test that when SSRF protection is disabled, all URLs are allowed."""
component.url_input = "http://127.0.0.1:8080"
- with respx.mock:
+ with (
+ patch.dict(os.environ, {"LANGFLOW_SSRF_PROTECTION_ENABLED": "false"}),
+ respx.mock,
+ ):
respx.get("http://127.0.0.1:8080").mock(return_value=Response(200, json={"status": "ok"}))
- # Should not raise (protection is off by default)
result = await component.make_api_request()
assert isinstance(result, Data)
+ assert result.data["result"]["status"] == "ok"
- async def test_ssrf_protection_enabled_blocks_localhost(self, component):
- """Test that SSRF protection blocks localhost when enabled."""
+ async def test_ssrf_protection_blocks_localhost_127_0_0_1(self, component):
+ """Test that SSRF protection blocks 127.0.0.1 (localhost)."""
component.url_input = "http://127.0.0.1:8080/admin"
- # Enable SSRF protection in enforcement mode
with (
patch.dict(os.environ, {"LANGFLOW_SSRF_PROTECTION_ENABLED": "true"}),
- patch("lfx.components.data_source.api_request.validate_url_for_ssrf") as mock_validate,
+ pytest.raises(ValueError, match="SSRF Protection"),
):
- from lfx.utils.ssrf_protection import SSRFProtectionError
+ await component.make_api_request()
- # Make it raise in enforcement mode
- mock_validate.side_effect = SSRFProtectionError("Access to 127.0.0.1 blocked")
+ async def test_ssrf_protection_blocks_localhost_0_0_0_0(self, component):
+ """Test that SSRF protection blocks 0.0.0.0."""
+ component.url_input = "http://0.0.0.0:8080/admin"
- with pytest.raises(ValueError, match="SSRF Protection"):
- await component.make_api_request()
+ with (
+ patch.dict(os.environ, {"LANGFLOW_SSRF_PROTECTION_ENABLED": "true"}),
+ pytest.raises(ValueError, match="SSRF Protection"),
+ ):
+ await component.make_api_request()
- async def test_ssrf_protection_enabled_blocks_private_networks(self, component):
- """Test that SSRF protection blocks private network IPs when enabled."""
- private_ips = [
- "http://192.168.1.1/config",
- "http://10.0.0.1/admin",
- "http://172.16.0.1/internal",
- ]
+ async def test_ssrf_protection_blocks_private_network_192_168(self, component):
+ """Test that SSRF protection blocks 192.168.x.x private network."""
+ component.url_input = "http://192.168.1.1/config"
- with patch.dict(os.environ, {"LANGFLOW_SSRF_PROTECTION_ENABLED": "true"}):
- for url in private_ips:
- component.url_input = url
+ with (
+ patch.dict(os.environ, {"LANGFLOW_SSRF_PROTECTION_ENABLED": "true"}),
+ pytest.raises(ValueError, match="SSRF Protection"),
+ ):
+ await component.make_api_request()
- with patch("lfx.components.data_source.api_request.validate_url_for_ssrf") as mock_validate:
- from lfx.utils.ssrf_protection import SSRFProtectionError
+ async def test_ssrf_protection_blocks_private_network_10_0(self, component):
+ """Test that SSRF protection blocks 10.x.x.x private network."""
+ component.url_input = "http://10.0.0.1/admin"
- mock_validate.side_effect = SSRFProtectionError(f"Access to {url} blocked")
+ with (
+ patch.dict(os.environ, {"LANGFLOW_SSRF_PROTECTION_ENABLED": "true"}),
+ pytest.raises(ValueError, match="SSRF Protection"),
+ ):
+ await component.make_api_request()
- with pytest.raises(ValueError, match="SSRF Protection"):
- await component.make_api_request()
+ async def test_ssrf_protection_blocks_private_network_172_16(self, component):
+ """Test that SSRF protection blocks 172.16.x.x private network."""
+ component.url_input = "http://172.16.0.1/internal"
- async def test_ssrf_protection_enabled_blocks_metadata_endpoint(self, component):
- """Test that SSRF protection blocks cloud metadata endpoints when enabled."""
+ with (
+ patch.dict(os.environ, {"LANGFLOW_SSRF_PROTECTION_ENABLED": "true"}),
+ pytest.raises(ValueError, match="SSRF Protection"),
+ ):
+ await component.make_api_request()
+
+ async def test_ssrf_protection_blocks_cloud_metadata_endpoint(self, component):
+ """Test that SSRF protection blocks AWS/GCP metadata endpoint."""
component.url_input = "http://169.254.169.254/latest/meta-data/"
with (
patch.dict(os.environ, {"LANGFLOW_SSRF_PROTECTION_ENABLED": "true"}),
- patch("lfx.components.data_source.api_request.validate_url_for_ssrf") as mock_validate,
+ pytest.raises(ValueError, match="SSRF Protection"),
):
- from lfx.utils.ssrf_protection import SSRFProtectionError
+ await component.make_api_request()
- mock_validate.side_effect = SSRFProtectionError("Access to 169.254.169.254 blocked")
+ async def test_ssrf_protection_blocks_link_local_169_254(self, component):
+ """Test that SSRF protection blocks link-local addresses."""
+ component.url_input = "http://169.254.1.1/api"
- with pytest.raises(ValueError, match="SSRF Protection"):
- await component.make_api_request()
+ with (
+ patch.dict(os.environ, {"LANGFLOW_SSRF_PROTECTION_ENABLED": "true"}),
+ pytest.raises(ValueError, match="SSRF Protection"),
+ ):
+ await component.make_api_request()
@respx.mock
async def test_ssrf_protection_allows_public_urls(self, component):
- """Test that SSRF protection allows public URLs."""
+ """Test that SSRF protection allows legitimate public URLs."""
public_urls = [
"https://api.openai.com/v1/chat/completions",
"https://api.github.com/repos/langflow-ai/langflow",
@@ -470,56 +498,133 @@ class TestAPIRequestSSRFProtection:
component.url_input = url
respx.get(url).mock(return_value=Response(200, json={"status": "ok"}))
- # Should not raise - these are public URLs
result = await component.make_api_request()
assert isinstance(result, Data)
+ assert result.data["result"]["status"] == "ok"
- async def test_ssrf_protection_allowlist_bypass(self, component):
- """Test that allowlisted hosts bypass SSRF protection."""
+ async def test_ssrf_allowlist_hostname(self, component):
+ """Test that allowlisted hostnames bypass SSRF protection."""
component.url_input = "http://internal.company.local/api"
with (
patch.dict(
os.environ,
- {"LANGFLOW_SSRF_PROTECTION_ENABLED": "true", "LANGFLOW_SSRF_ALLOWED_HOSTS": "internal.company.local"},
+ {
+ "LANGFLOW_SSRF_PROTECTION_ENABLED": "true",
+ "LANGFLOW_SSRF_ALLOWED_HOSTS": "internal.company.local",
+ },
),
respx.mock,
):
respx.get("http://internal.company.local/api").mock(return_value=Response(200, json={"status": "ok"}))
- # Should not raise - host is in allowlist
result = await component.make_api_request()
assert isinstance(result, Data)
+ assert result.data["result"]["status"] == "ok"
- async def test_ssrf_protection_allowlist_cidr(self, component):
+ async def test_ssrf_allowlist_ip_address(self, component):
+ """Test that allowlisted IP addresses bypass SSRF protection."""
+ component.url_input = "http://192.168.1.100/api"
+
+ with (
+ patch.dict(
+ os.environ,
+ {
+ "LANGFLOW_SSRF_PROTECTION_ENABLED": "true",
+ "LANGFLOW_SSRF_ALLOWED_HOSTS": "192.168.1.100",
+ },
+ ),
+ respx.mock,
+ ):
+ respx.get("http://192.168.1.100/api").mock(return_value=Response(200, json={"status": "ok"}))
+
+ result = await component.make_api_request()
+ assert isinstance(result, Data)
+ assert result.data["result"]["status"] == "ok"
+
+ async def test_ssrf_allowlist_cidr_range(self, component):
"""Test that CIDR ranges in allowlist work correctly."""
component.url_input = "http://192.168.1.5/api"
with (
patch.dict(
os.environ,
- {"LANGFLOW_SSRF_PROTECTION_ENABLED": "true", "LANGFLOW_SSRF_ALLOWED_HOSTS": "192.168.1.0/24"},
+ {
+ "LANGFLOW_SSRF_PROTECTION_ENABLED": "true",
+ "LANGFLOW_SSRF_ALLOWED_HOSTS": "192.168.1.0/24",
+ },
+ ),
+ respx.mock,
+ ):
+ respx.get("http://192.168.1.5/api").mock(return_value=Response(200, json={"status": "ok"}))
+
+ result = await component.make_api_request()
+ assert isinstance(result, Data)
+ assert result.data["result"]["status"] == "ok"
+
+ async def test_ssrf_allowlist_multiple_entries(self, component):
+ """Test that multiple allowlist entries work correctly."""
+ component.url_input = "http://192.168.1.5/api"
+
+ with (
+ patch.dict(
+ os.environ,
+ {
+ "LANGFLOW_SSRF_PROTECTION_ENABLED": "true",
+ "LANGFLOW_SSRF_ALLOWED_HOSTS": "localhost,192.168.1.0/24,internal.local",
+ },
),
respx.mock,
):
respx.get("http://192.168.1.5/api").mock(return_value=Response(200, json={"status": "ok"}))
- # Should not raise - IP is in allowlisted CIDR range
result = await component.make_api_request()
assert isinstance(result, Data)
- async def test_ssrf_protection_warn_only_mode(self, component):
- """Test that warn_only mode logs warnings instead of blocking."""
- component.url_input = "http://127.0.0.1:8080/admin"
+ async def test_dns_pinning_is_used_when_protection_enabled(self, component):
+ """Test that DNS pinning (custom transport) is used when SSRF protection is enabled."""
+ from unittest.mock import AsyncMock
- with patch.dict(os.environ, {"LANGFLOW_SSRF_PROTECTION_ENABLED": "true"}), respx.mock:
- respx.get("http://127.0.0.1:8080/admin").mock(return_value=Response(200, json={"status": "ok"}))
+ component.url_input = "https://example.com/api"
+
+ with (
+ patch.dict(os.environ, {"LANGFLOW_SSRF_PROTECTION_ENABLED": "true"}),
+ patch("lfx.components.data_source.api_request.create_ssrf_protected_client") as mock_create_client,
+ respx.mock,
+ ):
+ # Mock the context manager returned by create_ssrf_protected_client
+ mock_client = AsyncMock()
+ mock_client.__aenter__.return_value = mock_client
+ mock_client.__aexit__.return_value = None
+ mock_create_client.return_value = mock_client
+
+ # Mock the make_request to return a Data object
+ component.make_request = AsyncMock(return_value=Data(data={"status": "ok"}))
+
+ await component.make_api_request()
+
+ # Verify that create_ssrf_protected_client was called (DNS pinning is used)
+ mock_create_client.assert_called_once()
+ call_kwargs = mock_create_client.call_args[1]
+ assert call_kwargs["hostname"] == "example.com"
+ assert len(call_kwargs["validated_ips"]) > 0 # Should have validated IPs
+
+ async def test_normal_client_used_when_protection_disabled(self, component):
+ """Test that normal httpx client is used when SSRF protection is disabled."""
+ component.url_input = "https://example.com/api"
+
+ with (
+ patch.dict(os.environ, {"LANGFLOW_SSRF_PROTECTION_ENABLED": "false"}),
+ patch("lfx.components.data_source.api_request.create_ssrf_protected_client") as mock_create_client,
+ respx.mock,
+ ):
+ respx.get("https://example.com/api").mock(return_value=Response(200, json={"status": "ok"}))
- # In warn_only mode (default), should not raise but should log
result = await component.make_api_request()
- assert isinstance(result, Data)
- # TODO: In next major version, this should raise instead of just warning
+ # Verify that create_ssrf_protected_client was NOT called
+ mock_create_client.assert_not_called()
+ assert isinstance(result, Data)
async def test_follow_redirects_security_warning(self, component):
"""Test that enabling follow_redirects logs a security warning."""
@@ -527,58 +632,55 @@ class TestAPIRequestSSRFProtection:
component.url_input = "https://example.com/api"
component.follow_redirects = True
-
- # Mock the log method to capture what's being logged
component.log = MagicMock()
- with respx.mock:
+ with (
+ patch.dict(os.environ, {"LANGFLOW_SSRF_PROTECTION_ENABLED": "false"}),
+ respx.mock,
+ ):
respx.get("https://example.com/api").mock(return_value=Response(200, json={"status": "ok"}))
- result = await component.make_api_request()
- assert isinstance(result, Data)
+ await component.make_api_request()
- # Verify log was called with security warning
+ # Verify security warning was logged
component.log.assert_called()
- log_call_args = component.log.call_args[0][0]
- assert "Security Warning" in log_call_args
- assert "SSRF bypass" in log_call_args
- assert "redirects are enabled" in log_call_args
+ all_log_messages = [call[0][0] for call in component.log.call_args_list]
+ security_warning_found = any("Security Warning" in msg and "SSRF bypass" in msg for msg in all_log_messages)
+ assert security_warning_found, f"Security warning not found in: {all_log_messages}"
- async def test_follow_redirects_disabled_by_default(self, component):
- """Test that follow_redirects is disabled by default."""
- # Verify the default value is False
- assert component.follow_redirects is False
-
- async def test_url_normalization(self, component):
+ async def test_url_normalization_adds_https(self, component):
"""Test that URLs without protocol get normalized to https://."""
- # Test URL without protocol
component.url_input = "example.com"
- with respx.mock:
+ with (
+ patch.dict(os.environ, {"LANGFLOW_SSRF_PROTECTION_ENABLED": "false"}),
+ respx.mock,
+ ):
respx.get("https://example.com").mock(return_value=Response(200, json={"status": "ok"}))
result = await component.make_api_request()
- assert isinstance(result, Data)
assert result.data["source"] == "https://example.com"
- async def test_url_normalization_preserves_protocol(self, component):
- """Test that URLs with protocol are not modified."""
- # Test http:// is preserved
+ async def test_url_normalization_preserves_http(self, component):
+ """Test that http:// protocol is preserved."""
component.url_input = "http://example.com"
- with respx.mock:
+ with (
+ patch.dict(os.environ, {"LANGFLOW_SSRF_PROTECTION_ENABLED": "false"}),
+ respx.mock,
+ ):
respx.get("http://example.com").mock(return_value=Response(200, json={"status": "ok"}))
result = await component.make_api_request()
- assert isinstance(result, Data)
assert result.data["source"] == "http://example.com"
- # Test https:// is preserved
- component.url_input = "https://example.com"
+ async def test_invalid_url_raises_error(self, component):
+ """Test that invalid URLs raise ValueError."""
+ component.url_input = "not_a_valid_url"
- with respx.mock:
- respx.get("https://example.com").mock(return_value=Response(200, json={"status": "ok"}))
+ with pytest.raises(ValueError, match="Invalid URL provided"):
+ await component.make_api_request()
- result = await component.make_api_request()
- assert isinstance(result, Data)
- assert result.data["source"] == "https://example.com"
+ async def test_follow_redirects_disabled_by_default(self, component):
+ """Test that follow_redirects is disabled by default for security."""
+ assert component.follow_redirects is False
diff --git a/src/backend/tests/unit/components/data_source/test_dns_rebinding.py b/src/backend/tests/unit/components/data_source/test_dns_rebinding.py
new file mode 100644
index 0000000000..5471b685f1
--- /dev/null
+++ b/src/backend/tests/unit/components/data_source/test_dns_rebinding.py
@@ -0,0 +1,374 @@
+"""Test DNS rebinding protection in API Request component.
+
+This test suite verifies that the DNS pinning implementation prevents
+DNS rebinding attacks that could bypass SSRF protection.
+"""
+# ruff: noqa: ARG001, SIM117
+
+import os
+import socket
+from unittest.mock import patch
+
+import httpcore
+import httpx
+import pytest
+from lfx.components.data_source.api_request import APIRequestComponent
+from lfx.schema import Data
+
+
+class TestDNSRebindingProtection:
+ """Test DNS rebinding attack prevention through DNS pinning."""
+
+ @pytest.fixture
+ def component(self):
+ """Create a basic API request component."""
+ return APIRequestComponent(
+ url_input="http://rebinding.test:8080/api",
+ method="GET",
+ headers=[],
+ body=[],
+ timeout=30,
+ follow_redirects=False,
+ save_to_file=False,
+ include_httpx_metadata=False,
+ mode="URL",
+ curl_input="",
+ query_params={},
+ )
+
+ @pytest.mark.asyncio
+ async def test_dns_pinning_prevents_rebinding_attack(self, component):
+ """Test that DNS pinning prevents DNS rebinding attacks.
+
+ This test simulates a DNS rebinding attack where:
+ 1. First DNS lookup (validation): returns public IP (8.8.8.8)
+ 2. Second DNS lookup (httpx): would return localhost (127.0.0.1)
+
+ With DNS pinning, the second lookup should NOT happen - the validated
+ IP from the first lookup should be used directly at the network layer.
+ """
+ call_count = 0
+ connected_to_ip = None
+
+ def mock_getaddrinfo(_hostname, _port, *_args, **_kwargs):
+ """Mock DNS resolution to simulate rebinding attack."""
+ nonlocal call_count
+ call_count += 1
+
+ if call_count == 1:
+ # First call (during validation): return public IP
+ return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("8.8.8.8", 0))]
+ # Second call (during httpx request): return localhost
+ # This simulates the DNS rebinding attack
+ # With DNS pinning, this should NOT be called
+ return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", 0))]
+
+ # Mock the network backend's connect_tcp to capture the actual IP being connected to
+ async def mock_connect_tcp(self, host, port, **kwargs):
+ """Capture the IP that's actually being connected to."""
+ nonlocal connected_to_ip
+ connected_to_ip = host
+ # Return a mock stream with proper format (list of bytes)
+ return httpcore.AsyncMockStream(
+ [
+ b"HTTP/1.1 200 OK\r\n",
+ b"Content-Type: application/json\r\n",
+ b"Content-Length: 15\r\n",
+ b"\r\n",
+ b'{"status":"ok"}',
+ ]
+ )
+
+ with (
+ patch("socket.getaddrinfo", side_effect=mock_getaddrinfo),
+ patch.dict(os.environ, {"LANGFLOW_SSRF_PROTECTION_ENABLED": "true"}),
+ patch.object(httpcore.AnyIOBackend, "connect_tcp", mock_connect_tcp),
+ ):
+ # Execute the request
+ result = await component.make_api_request()
+
+ # Verify the request succeeded
+ assert result is not None
+
+ # CRITICAL CHECK: DNS should only be called once (during validation)
+ # If called twice, DNS pinning failed and the attack succeeded
+ assert call_count == 1, (
+ f"DNS was called {call_count} times. Expected 1 (validation only). "
+ "DNS pinning failed - the component is vulnerable to DNS rebinding!"
+ )
+
+ # Verify the connection was made to the pinned IP
+ assert connected_to_ip is not None, "No TCP connection was made"
+ assert connected_to_ip == "8.8.8.8", (
+ f"Connection should be to pinned IP 8.8.8.8, but was to {connected_to_ip}"
+ )
+
+ @pytest.mark.asyncio
+ async def test_dns_pinning_preserves_hostname_in_header(self, component):
+ """Test that DNS pinning connects to pinned IP while preserving hostname for TLS.
+
+ With network-level DNS pinning:
+ - TCP connection goes to the pinned IP (93.184.216.34)
+ - URL preserves the original hostname (rebinding.test)
+ - This allows TLS SNI and certificate verification to work correctly
+
+ This is important for:
+ - Virtual hosting (multiple sites on same IP)
+ - SNI (Server Name Indication) for HTTPS
+ - Certificate verification (cert is for hostname, not IP)
+ """
+ connected_to_ip = None
+
+ def mock_getaddrinfo(hostname, port, *args, **kwargs):
+ """Mock DNS resolution."""
+ return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 0))]
+
+ # Mock network backend to capture TCP connection
+ async def mock_connect_tcp(self, host, port, **kwargs):
+ """Capture the IP that TCP connects to."""
+ nonlocal connected_to_ip
+ connected_to_ip = host
+ return httpcore.AsyncMockStream(
+ [
+ b"HTTP/1.1 200 OK\r\n",
+ b"Content-Type: application/json\r\n",
+ b"Content-Length: 15\r\n",
+ b"\r\n",
+ b'{"status":"ok"}',
+ ]
+ )
+
+ with (
+ patch("socket.getaddrinfo", side_effect=mock_getaddrinfo),
+ patch.dict(os.environ, {"LANGFLOW_SSRF_PROTECTION_ENABLED": "true"}),
+ patch.object(httpcore.AnyIOBackend, "connect_tcp", mock_connect_tcp),
+ ):
+ result = await component.make_api_request()
+
+ # Verify the request succeeded
+ assert result is not None
+
+ # Verify TCP connection was made to the pinned IP
+ assert connected_to_ip is not None, "No TCP connection was made"
+ assert connected_to_ip == "93.184.216.34", (
+ f"TCP connection should be to pinned IP 93.184.216.34, but was to {connected_to_ip}"
+ )
+
+ @pytest.mark.asyncio
+ async def test_dns_pinning_with_direct_ip_address(self, component):
+ """Test that direct IP addresses work correctly (no DNS pinning needed)."""
+ component.url_input = "http://93.184.216.34:8080/api"
+
+ def mock_getaddrinfo(hostname, port, *args, **kwargs):
+ """Mock DNS resolution - should not be called for direct IPs."""
+ # For direct IPs, socket.getaddrinfo is still called but returns the same IP
+ return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 0))]
+
+ mock_response = httpx.Response(200, json={"status": "ok"})
+
+ with (
+ patch("socket.getaddrinfo", side_effect=mock_getaddrinfo),
+ patch.dict(os.environ, {"LANGFLOW_SSRF_PROTECTION_ENABLED": "true"}),
+ patch("httpx.AsyncClient.request", return_value=mock_response) as mock_request,
+ ):
+ result = await component.make_api_request()
+
+ # Verify the request succeeded
+ assert isinstance(result, Data)
+ assert mock_request.called
+
+ @pytest.mark.asyncio
+ async def test_dns_pinning_disabled_when_protection_disabled(self, component):
+ """Test that DNS pinning is skipped when SSRF protection is disabled."""
+ call_count = 0
+
+ def mock_getaddrinfo(hostname, port, *args, **kwargs):
+ """Mock DNS resolution."""
+ nonlocal call_count
+ call_count += 1
+ return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("8.8.8.8", 0))]
+
+ mock_response = httpx.Response(200, json={"status": "ok"})
+
+ with (
+ patch("socket.getaddrinfo", side_effect=mock_getaddrinfo),
+ patch.dict(os.environ, {"LANGFLOW_SSRF_PROTECTION_ENABLED": "false"}),
+ patch("httpx.AsyncClient.request", return_value=mock_response) as mock_request,
+ ):
+ result = await component.make_api_request()
+
+ # Verify the request succeeded
+ assert isinstance(result, Data)
+ assert mock_request.called
+
+ # When protection is disabled, DNS pinning is not used
+ # So DNS might be called multiple times (once by httpx)
+ # This is expected behavior when protection is off
+
+ @pytest.mark.asyncio
+ async def test_dns_pinning_blocks_private_ip_resolution(self, component):
+ """Test that DNS pinning blocks hostnames that resolve to private IPs."""
+ component.url_input = "http://internal.example.com/api"
+
+ def mock_getaddrinfo(hostname, port, *args, **kwargs):
+ """Mock DNS resolution to return private IP."""
+ return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("192.168.1.1", 0))]
+
+ with (
+ patch("socket.getaddrinfo", side_effect=mock_getaddrinfo),
+ patch.dict(os.environ, {"LANGFLOW_SSRF_PROTECTION_ENABLED": "true"}),
+ ):
+ # Should raise ValueError due to SSRF protection
+ with pytest.raises(ValueError, match="SSRF Protection"):
+ await component.make_api_request()
+
+ @pytest.mark.asyncio
+ async def test_dns_pinning_with_ipv6(self, component):
+ """Test that DNS pinning works with IPv6 addresses.
+
+ With network-level DNS pinning:
+ - TCP connection goes to the IPv6 address
+ - URL preserves the original hostname
+ - IPv6 addresses don't need brackets in connect_tcp (only in URLs)
+ """
+ component.url_input = "http://ipv6.example.com/api"
+ connected_to_ip = None
+
+ def mock_getaddrinfo(hostname, port, *args, **kwargs):
+ """Mock DNS resolution to return IPv6."""
+ return [(socket.AF_INET6, socket.SOCK_STREAM, 6, "", ("2001:4860:4860::8888", 0))]
+
+ # Mock network backend to capture TCP connection
+ async def mock_connect_tcp(self, host, port, **kwargs):
+ """Capture the IP that TCP connects to."""
+ nonlocal connected_to_ip
+ connected_to_ip = host
+ return httpcore.AsyncMockStream(
+ [
+ b"HTTP/1.1 200 OK\r\n",
+ b"Content-Type: application/json\r\n",
+ b"Content-Length: 15\r\n",
+ b"\r\n",
+ b'{"status":"ok"}',
+ ]
+ )
+
+ with (
+ patch("socket.getaddrinfo", side_effect=mock_getaddrinfo),
+ patch.dict(os.environ, {"LANGFLOW_SSRF_PROTECTION_ENABLED": "true"}),
+ patch.object(httpcore.AnyIOBackend, "connect_tcp", mock_connect_tcp),
+ ):
+ result = await component.make_api_request()
+
+ # Verify the request succeeded
+ assert isinstance(result, Data)
+
+ # Verify TCP connection was made to the IPv6 address
+ assert connected_to_ip is not None, "No TCP connection was made"
+ assert connected_to_ip == "2001:4860:4860::8888", (
+ f"TCP connection should be to IPv6 2001:4860:4860::8888, but was to {connected_to_ip}"
+ )
+
+ @pytest.mark.asyncio
+ async def test_dns_pinning_with_allowlisted_host(self, component):
+ """Test that allowlisted hosts bypass DNS pinning and preserve original hostname."""
+ component.url_input = "http://internal.example.com:8080/api" # Use a valid hostname format
+ captured_request = None
+
+ def mock_getaddrinfo(hostname, port, *args, **kwargs):
+ """Mock DNS resolution."""
+ return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("10.0.0.1", 0))]
+
+ # Mock the transport's handle_async_request to capture the rewritten request
+ async def mock_handle_async_request(_self, request):
+ """Capture the request after transport rewrite."""
+ nonlocal captured_request
+ captured_request = request
+ return httpx.Response(200, json={"status": "ok"}, request=request)
+
+ with (
+ patch("socket.getaddrinfo", side_effect=mock_getaddrinfo),
+ patch.dict(
+ os.environ,
+ {
+ "LANGFLOW_SSRF_PROTECTION_ENABLED": "true",
+ "LANGFLOW_SSRF_ALLOWED_HOSTS": "internal.example.com,10.0.0.1",
+ },
+ ),
+ # Patch get_allowed_hosts to return the allowlist directly
+ patch(
+ "lfx.utils.ssrf_protection.get_allowed_hosts",
+ return_value=["internal.example.com", "10.0.0.1"],
+ ),
+ patch.object(httpx.AsyncHTTPTransport, "handle_async_request", mock_handle_async_request),
+ ):
+ result = await component.make_api_request()
+
+ # Verify the request succeeded
+ assert isinstance(result, Data)
+ assert captured_request is not None, "Transport did not capture request"
+
+ # Verify the original hostname is preserved (no DNS pinning for allowlisted hosts)
+ url_str = str(captured_request.url)
+ assert "internal.example.com" in url_str, f"Allowlisted host should preserve hostname: {url_str}"
+ assert "10.0.0.1" not in url_str, f"Allowlisted host should not use IP: {url_str}"
+
+ @pytest.mark.asyncio
+ async def test_dns_pinning_with_multiple_ips_fallback(self, component):
+ """Test that DNS pinning tries multiple IPs when first one fails (dual-stack/load balancing)."""
+ component.url_input = "http://dual-stack.example.com/api"
+
+ # Track which IPs were attempted
+ attempted_ips = []
+
+ def mock_getaddrinfo(host, port, family=0, type_=0, proto=0, flags=0):
+ """Mock DNS resolution to return multiple IPs (IPv4 and IPv6)."""
+ if host == "dual-stack.example.com":
+ # Return both IPv4 and IPv6 addresses (dual-stack)
+ return [
+ (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 0)), # IPv4
+ (socket.AF_INET6, socket.SOCK_STREAM, 6, "", ("2606:2800:220:1:248:1893:25c8:1946", 0)), # IPv6
+ ]
+ return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (host, 0))]
+
+ async def mock_connect_tcp(self, host, port, **kwargs):
+ """Mock connection that fails on first IP, succeeds on second."""
+ nonlocal attempted_ips
+ attempted_ips.append(host)
+
+ # First IP fails (simulating IPv4 unreachable)
+ if host == "93.184.216.34":
+ msg = "Connection refused"
+ raise OSError(msg)
+
+ # Second IP succeeds (IPv6 works)
+ if host == "2606:2800:220:1:248:1893:25c8:1946":
+ return httpcore.AsyncMockStream(
+ [
+ b"HTTP/1.1 200 OK\r\n",
+ b"Content-Type: application/json\r\n",
+ b"Content-Length: 15\r\n",
+ b"\r\n",
+ b'{"status":"ok"}',
+ ]
+ )
+
+ msg = f"Unexpected host: {host}"
+ raise OSError(msg)
+
+ with (
+ patch("socket.getaddrinfo", side_effect=mock_getaddrinfo),
+ patch.dict(os.environ, {"LANGFLOW_SSRF_PROTECTION_ENABLED": "true"}),
+ patch.object(httpcore.AnyIOBackend, "connect_tcp", mock_connect_tcp),
+ ):
+ # Execute the request
+ result = await component.make_api_request()
+
+ # Verify both IPs were attempted in order
+ assert len(attempted_ips) == 2
+ assert attempted_ips[0] == "93.184.216.34" # IPv4 tried first
+ assert attempted_ips[1] == "2606:2800:220:1:248:1893:25c8:1946" # IPv6 tried second
+
+ # Verify the result (should succeed with second IP)
+ assert isinstance(result, Data)
+ assert result.data is not None
diff --git a/src/backend/tests/unit/components/files_and_knowledge/test_retrieval.py b/src/backend/tests/unit/components/files_and_knowledge/test_retrieval.py
index 8b24ddaa24..c392b50669 100644
--- a/src/backend/tests/unit/components/files_and_knowledge/test_retrieval.py
+++ b/src/backend/tests/unit/components/files_and_knowledge/test_retrieval.py
@@ -1,3 +1,4 @@
+import importlib.util
import json
import uuid
from pathlib import Path
@@ -9,6 +10,12 @@ from lfx.components.files_and_knowledge.retrieval import KnowledgeBaseComponent
from tests.base import ComponentTestBaseWithClient
+# langchain-ibm is gated to python_version<'3.14' in pyproject.toml because
+# upstream pins exclude 3.14. Tests that patch langchain_ibm.* directly must
+# be skipped when the package is unavailable.
+_HAS_LANGCHAIN_IBM = importlib.util.find_spec("langchain_ibm") is not None
+_NO_LANGCHAIN_IBM_REASON = "langchain-ibm not available (gated to python_version<'3.14')"
+
class TestKnowledgeBaseComponent(ComponentTestBaseWithClient):
@pytest.fixture
@@ -331,6 +338,7 @@ class TestKnowledgeBaseComponent(ComponentTestBaseWithClient):
)
assert result == mock_embeddings
+ @pytest.mark.skipif(not _HAS_LANGCHAIN_IBM, reason=_NO_LANGCHAIN_IBM_REASON)
@patch("langchain_ibm.WatsonxEmbeddings")
def test_build_embeddings_watsonx(self, mock_watsonx_embeddings, component_class, default_kwargs):
"""Test building IBM WatsonX embeddings."""
@@ -363,6 +371,7 @@ class TestKnowledgeBaseComponent(ComponentTestBaseWithClient):
)
assert result == mock_embeddings
+ @pytest.mark.skipif(not _HAS_LANGCHAIN_IBM, reason=_NO_LANGCHAIN_IBM_REASON)
def test_build_embeddings_watsonx_no_key(self, component_class, default_kwargs):
"""Test building IBM WatsonX embeddings without API key raises error."""
component = component_class(**default_kwargs)
@@ -748,6 +757,7 @@ class TestKnowledgeBaseComponent(ComponentTestBaseWithClient):
mock_ollama_embeddings.assert_called_once_with(model="nomic-embed-text")
assert result == mock_embeddings
+ @pytest.mark.skipif(not _HAS_LANGCHAIN_IBM, reason=_NO_LANGCHAIN_IBM_REASON)
@patch("langchain_ibm.WatsonxEmbeddings")
def test_build_embeddings_watsonx_api_key_from_provider_vars(
self, mock_watsonx_embeddings, component_class, default_kwargs
@@ -781,6 +791,7 @@ class TestKnowledgeBaseComponent(ComponentTestBaseWithClient):
)
assert result == mock_embeddings
+ @pytest.mark.skipif(not _HAS_LANGCHAIN_IBM, reason=_NO_LANGCHAIN_IBM_REASON)
@patch("langchain_ibm.WatsonxEmbeddings")
def test_build_embeddings_watsonx_partial_vars(self, mock_watsonx_embeddings, component_class, default_kwargs):
"""Test WatsonX with only apikey, no project_id or url."""
diff --git a/src/backend/tests/unit/components/languagemodels/test_baidu_qianfan.py b/src/backend/tests/unit/components/languagemodels/test_baidu_qianfan.py
index e72693cb08..0697585eee 100644
--- a/src/backend/tests/unit/components/languagemodels/test_baidu_qianfan.py
+++ b/src/backend/tests/unit/components/languagemodels/test_baidu_qianfan.py
@@ -1,10 +1,28 @@
import os
import pytest
-from langchain_community.chat_models.baidu_qianfan_endpoint import QianfanChatEndpoint
from langchain_core.messages import HumanMessage
-from lfx.components.baidu.baidu_qianfan_chat import QianfanChatEndpointComponent
-from qianfan.errors import APIError
+
+# Skip the entire module if the optional Qianfan stack cannot be imported.
+# qianfan's GlobalConfig is a pydantic.v1 BaseSettings model and has been
+# observed to fail at class-construction time in some environments
+# (`pydantic.v1.errors.ConfigError: unable to infer type for attribute "AK"`),
+# which crashes collection. These tests are all `api_key_required` and are
+# skipped in normal CI anyway — guard the imports so collection never crashes.
+# NOTE: pytest.importorskip only catches ImportError; qianfan can raise
+# pydantic.v1.errors.ConfigError on Python 3.14+, so we catch broadly.
+try:
+ import qianfan
+ from langchain_community.chat_models.baidu_qianfan_endpoint import QianfanChatEndpoint
+
+ APIError = qianfan.errors.APIError
+
+ from lfx.components.baidu.baidu_qianfan_chat import QianfanChatEndpointComponent
+except Exception:
+ pytest.skip(
+ "qianfan stack is not importable (likely pydantic v1 incompatibility)",
+ allow_module_level=True,
+ )
@pytest.fixture
diff --git a/src/backend/tests/unit/components/models_and_agents/test_altk_agent.py b/src/backend/tests/unit/components/models_and_agents/test_altk_agent.py
index dabfd0dc8d..0784983a8d 100644
--- a/src/backend/tests/unit/components/models_and_agents/test_altk_agent.py
+++ b/src/backend/tests/unit/components/models_and_agents/test_altk_agent.py
@@ -3,6 +3,13 @@ from typing import Any
from uuid import uuid4
import pytest
+
+try:
+ import altk # noqa: F401
+except ImportError:
+ # agent-lifecycle-toolkit is gated to python_version<'3.14' upstream.
+ pytest.skip("altk (agent-lifecycle-toolkit) not available", allow_module_level=True)
+
from langflow.custom import Component
from lfx.base.models.anthropic_constants import ANTHROPIC_MODELS
from lfx.components.altk.altk_agent import ALTKAgentComponent
diff --git a/src/backend/tests/unit/components/models_and_agents/test_altk_agent_logic.py b/src/backend/tests/unit/components/models_and_agents/test_altk_agent_logic.py
index a1007db091..e4196517c5 100644
--- a/src/backend/tests/unit/components/models_and_agents/test_altk_agent_logic.py
+++ b/src/backend/tests/unit/components/models_and_agents/test_altk_agent_logic.py
@@ -8,6 +8,13 @@ from unittest.mock import MagicMock
from uuid import uuid4
import pytest
+
+try:
+ import altk # noqa: F401
+except ImportError:
+ # agent-lifecycle-toolkit is gated to python_version<'3.14' upstream.
+ pytest.skip("altk (agent-lifecycle-toolkit) not available", allow_module_level=True)
+
from langchain_core.messages import HumanMessage
from langchain_core.tools import BaseTool
from lfx.base.agents.altk_base_agent import (
diff --git a/src/backend/tests/unit/components/models_and_agents/test_altk_agent_tool_conversion.py b/src/backend/tests/unit/components/models_and_agents/test_altk_agent_tool_conversion.py
index afe2fe5356..2370c8289c 100644
--- a/src/backend/tests/unit/components/models_and_agents/test_altk_agent_tool_conversion.py
+++ b/src/backend/tests/unit/components/models_and_agents/test_altk_agent_tool_conversion.py
@@ -1,3 +1,11 @@
+import pytest
+
+try:
+ import altk # noqa: F401
+except ImportError:
+ # agent-lifecycle-toolkit is gated to python_version<'3.14' upstream.
+ pytest.skip("altk (agent-lifecycle-toolkit) not available", allow_module_level=True)
+
from langchain_core.tools import BaseTool
from lfx.base.agents.altk_tool_wrappers import PreToolValidationWrapper
from lfx.log.logger import logger
diff --git a/src/backend/tests/unit/components/models_and_agents/test_conversation_context_ordering.py b/src/backend/tests/unit/components/models_and_agents/test_conversation_context_ordering.py
index fedcb481ee..bbd4972894 100644
--- a/src/backend/tests/unit/components/models_and_agents/test_conversation_context_ordering.py
+++ b/src/backend/tests/unit/components/models_and_agents/test_conversation_context_ordering.py
@@ -5,6 +5,14 @@ This test ensures that conversation context maintains proper chronological order
SPARC tool validation and conversation flow understanding.
"""
+import pytest
+
+try:
+ import altk # noqa: F401
+except ImportError:
+ # agent-lifecycle-toolkit is gated to python_version<'3.14' upstream.
+ pytest.skip("altk (agent-lifecycle-toolkit) not available", allow_module_level=True)
+
from langchain_core.messages import AIMessage, HumanMessage
from lfx.base.agents.altk_base_agent import ALTKBaseAgentComponent
from lfx.schema.message import Message
diff --git a/src/backend/tests/unit/components/test_all_modules_importable.py b/src/backend/tests/unit/components/test_all_modules_importable.py
index 32fa955ed9..53688e5e76 100644
--- a/src/backend/tests/unit/components/test_all_modules_importable.py
+++ b/src/backend/tests/unit/components/test_all_modules_importable.py
@@ -44,6 +44,18 @@ class TestAllModulesImportable:
def test_all_components_in_categories_importable(self):
"""Test that all components in each category's __all__ can be imported."""
+ import sys
+
+ # Components whose underlying packages are gated to python_version<'3.14'
+ # in pyproject.toml because upstream pins exclude 3.14. These are expected
+ # to fail import on 3.14 until the upstreams adapt.
+ gated_on_py314 = {
+ "altk.ALTKAgentComponent",
+ "ibm.WatsonxAIComponent",
+ "ibm.WatsonxEmbeddingsComponent",
+ }
+ on_py314 = sys.version_info >= (3, 14)
+
failed_imports = []
successful_imports = 0
@@ -58,6 +70,7 @@ class TestAllModulesImportable:
print(f"Testing {category_components} components in {category_name}") # noqa: T201
for component_name in category_module.__all__:
+ qualified = f"{category_name}.{component_name}"
try:
component = getattr(category_module, component_name)
assert component is not None, f"Component {component_name} is None"
@@ -65,8 +78,11 @@ class TestAllModulesImportable:
successful_imports += 1
except Exception as e:
- failed_imports.append(f"{category_name}.{component_name}: {e!s}")
- print(f"FAILED: {category_name}.{component_name}: {e!s}") # noqa: T201
+ if on_py314 and qualified in gated_on_py314:
+ print(f"SKIPPED on 3.14: {qualified}: {e!s}") # noqa: T201
+ continue
+ failed_imports.append(f"{qualified}: {e!s}")
+ print(f"FAILED: {qualified}: {e!s}") # noqa: T201
else:
# Category doesn't have __all__, skip
print(f"Skipping {category_name} (no __all__ attribute)") # noqa: T201
@@ -387,6 +403,11 @@ class TestDirectModuleImports:
"redis",
"elasticsearch",
"langchain_community",
+ # Gated to python_version<'3.14' in pyproject.toml until
+ # upstream caps lift.
+ "altk",
+ "langchain_ibm",
+ "ibm_watsonx_ai",
]
):
return ("skipped", modname, "missing optional dependency")
diff --git a/src/backend/tests/unit/serialization/test_serialization.py b/src/backend/tests/unit/serialization/test_serialization.py
index 8e2f1cc654..6e03845be3 100644
--- a/src/backend/tests/unit/serialization/test_serialization.py
+++ b/src/backend/tests/unit/serialization/test_serialization.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
import math
from datetime import datetime, timezone
from typing import Any
diff --git a/src/backend/tests/unit/services/tracing/test_langfuse_v3_compatibility.py b/src/backend/tests/unit/services/tracing/test_langfuse_v3_compatibility.py
index d9e134396b..8312383e71 100644
--- a/src/backend/tests/unit/services/tracing/test_langfuse_v3_compatibility.py
+++ b/src/backend/tests/unit/services/tracing/test_langfuse_v3_compatibility.py
@@ -8,6 +8,8 @@ See: https://langfuse.com/docs/observability/sdk/upgrade-path
"""
import os
+import sys
+import types
import uuid
from unittest.mock import MagicMock, patch
@@ -28,41 +30,66 @@ def langfuse_env_vars():
yield
+def _clear_failed_langfuse_import() -> None:
+ """Remove partially imported Langfuse modules after SDK import failures."""
+ for module_name in list(sys.modules):
+ if module_name == "langfuse" or module_name.startswith("langfuse."):
+ sys.modules.pop(module_name, None)
+
+
+def _import_langfuse_or_skip():
+ try:
+ from langfuse import Langfuse
+ except Exception as exc:
+ _clear_failed_langfuse_import()
+ pytest.skip(f"langfuse SDK is not importable: {exc}")
+ return Langfuse
+
+
+def _import_callback_handler_or_skip():
+ try:
+ from langfuse.langchain import CallbackHandler
+ except Exception as exc:
+ _clear_failed_langfuse_import()
+ pytest.skip(f"langfuse LangChain callback handler is not importable: {exc}")
+ return CallbackHandler
+
+
class TestLangfuseV3ApiExists:
"""Verify that the langfuse v3 API methods we need actually exist."""
def test_langfuse_client_has_start_span(self):
"""Verify start_span method exists (v3 API)."""
- from langfuse import Langfuse
+ langfuse_class = _import_langfuse_or_skip()
- assert hasattr(Langfuse, "start_span"), "Langfuse.start_span() should exist in v3"
+ assert hasattr(langfuse_class, "start_span"), "Langfuse.start_span() should exist in v3"
def test_langfuse_client_has_start_as_current_span(self):
"""Verify start_as_current_span method exists (v3 API)."""
- from langfuse import Langfuse
+ langfuse_class = _import_langfuse_or_skip()
- assert hasattr(Langfuse, "start_as_current_span"), "Langfuse.start_as_current_span() should exist in v3"
+ assert hasattr(langfuse_class, "start_as_current_span"), "Langfuse.start_as_current_span() should exist in v3"
def test_langfuse_client_has_create_trace_id(self):
"""Verify create_trace_id method exists (v3 API)."""
- from langfuse import Langfuse
+ langfuse_class = _import_langfuse_or_skip()
- assert hasattr(Langfuse, "create_trace_id"), "Langfuse.create_trace_id() should exist in v3"
+ assert hasattr(langfuse_class, "create_trace_id"), "Langfuse.create_trace_id() should exist in v3"
def test_langfuse_client_does_not_have_trace(self):
"""Verify trace() method was removed in v3."""
- from langfuse import Langfuse
+ langfuse_class = _import_langfuse_or_skip()
# This test documents that trace() no longer exists
# If this fails, langfuse may have restored backward compatibility
- assert not hasattr(Langfuse, "trace"), "Langfuse.trace() should NOT exist in v3 (removed)"
+ assert not hasattr(langfuse_class, "trace"), "Langfuse.trace() should NOT exist in v3 (removed)"
def test_callback_handler_import_path(self):
"""Verify the v3 callback handler import path works."""
# v3 path
- from langfuse.langchain import CallbackHandler
+ callback_handler = _import_callback_handler_or_skip()
- assert CallbackHandler is not None
+ assert callback_handler is not None
class TestLangfuseTracerV3Compatibility:
@@ -91,7 +118,29 @@ class TestLangfuseTracerFunctionality:
@pytest.fixture
def mock_langfuse(self):
"""Create a mock langfuse client that simulates v3 API."""
- with patch("langfuse.Langfuse") as mock_langfuse_class:
+
+ class TraceContext(dict):
+ pass
+
+ mock_langfuse_module = types.ModuleType("langfuse")
+ mock_langfuse_types_module = types.ModuleType("langfuse.types")
+ mock_langfuse_langchain_module = types.ModuleType("langfuse.langchain")
+
+ mock_langfuse_class = MagicMock()
+ mock_langfuse_types_module.TraceContext = TraceContext
+ mock_langfuse_langchain_module.CallbackHandler = MagicMock()
+ mock_langfuse_module.Langfuse = mock_langfuse_class
+ mock_langfuse_module.types = mock_langfuse_types_module
+ mock_langfuse_module.langchain = mock_langfuse_langchain_module
+
+ with patch.dict(
+ sys.modules,
+ {
+ "langfuse": mock_langfuse_module,
+ "langfuse.types": mock_langfuse_types_module,
+ "langfuse.langchain": mock_langfuse_langchain_module,
+ },
+ ):
mock_client = MagicMock()
mock_langfuse_class.return_value = mock_client
mock_langfuse_class.create_trace_id = MagicMock(return_value="a" * 32)
diff --git a/src/backend/tests/unit/test_custom_component.py b/src/backend/tests/unit/test_custom_component.py
index 1bcdcd15fa..506abc1ed6 100644
--- a/src/backend/tests/unit/test_custom_component.py
+++ b/src/backend/tests/unit/test_custom_component.py
@@ -241,7 +241,7 @@ def test_code_parser_parse_callable_details_no_args():
def test_code_parser_parse_assign():
"""Test the parse_assign method of the CodeParser class."""
parser = CodeParser("")
- stmt = ast.Assign(targets=[ast.Name(id="x", ctx=ast.Store())], value=ast.Num(n=1))
+ stmt = ast.Assign(targets=[ast.Name(id="x", ctx=ast.Store())], value=ast.Constant(value=1))
result = parser.parse_assign(stmt)
assert result["name"] == "x"
assert result["value"] == "1"
@@ -253,7 +253,7 @@ def test_code_parser_parse_ann_assign():
stmt = ast.AnnAssign(
target=ast.Name(id="x", ctx=ast.Store()),
annotation=ast.Name(id="int", ctx=ast.Load()),
- value=ast.Num(n=1),
+ value=ast.Constant(value=1),
simple=1,
)
result = parser.parse_ann_assign(stmt)
diff --git a/src/backend/tests/unit/test_database.py b/src/backend/tests/unit/test_database.py
index fb620d4253..d85b40b61c 100644
--- a/src/backend/tests/unit/test_database.py
+++ b/src/backend/tests/unit/test_database.py
@@ -981,9 +981,11 @@ async def test_download_project_zip_sanitizes_flow_names(client: AsyncClient, js
@pytest.mark.usefixtures("session")
async def test_upload_bad_zip_file_returns_400(client: AsyncClient, logged_in_headers):
"""Uploading a corrupt/invalid ZIP file returns 400 with a descriptive error."""
- # Build a payload that passes zipfile.is_zipfile() but fails ZipFile() construction.
- # We keep only the end-of-central-directory record (last 22 bytes of a real ZIP)
- # prepended with garbage, so the EOCD signature is found but the central directory is invalid.
+ # Payload with an EOCD signature prepended by garbage. Python 3.10-3.13 treat
+ # this as a ZIP and fail in ZipFile() construction ("not a valid ZIP archive");
+ # Python 3.14's stricter zipfile.is_zipfile() rejects it and the route falls
+ # through to the JSON branch ("Invalid JSON file"). Both paths return 400 with
+ # a descriptive detail, which is the user-facing contract under test.
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("dummy.json", '{"name":"x"}')
@@ -997,7 +999,8 @@ async def test_upload_bad_zip_file_returns_400(client: AsyncClient, logged_in_he
headers=logged_in_headers,
)
assert response.status_code == 400
- assert "not a valid ZIP" in response.json()["detail"]
+ detail = response.json()["detail"]
+ assert "not a valid ZIP" in detail or "Invalid JSON file" in detail
@pytest.mark.usefixtures("session")
diff --git a/src/backend/tests/unit/utils/test_concurrency.py b/src/backend/tests/unit/utils/test_concurrency.py
index 2252131623..0b0409e32a 100644
--- a/src/backend/tests/unit/utils/test_concurrency.py
+++ b/src/backend/tests/unit/utils/test_concurrency.py
@@ -177,7 +177,7 @@ class TestKeyedWorkerLockManager:
manager = KeyedWorkerLockManager()
- mock_cache_dir.assert_called_once_with("langflow")
+ mock_cache_dir.assert_called_once_with("langflow", ensure_exists=True)
assert manager.locks_dir == mock_path_instance.__truediv__.return_value
def test_validate_key_valid_keys(self):
@@ -376,6 +376,8 @@ class TestKeyedWorkerLockManager:
KeyedWorkerLockManager()
+ # Verify user_cache_dir was called with ensure_exists=True
+ mock_cache_dir.assert_called_once_with("langflow", ensure_exists=True)
# Verify Path was called correctly
- mock_path.assert_called_once_with("/test/cache", ensure_exists=True)
+ mock_path.assert_called_once_with("/test/cache")
mock_path_instance.__truediv__.assert_called_once_with("worker_locks")
diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json
index ad86cadb93..7d80a96912 100644
--- a/src/frontend/package-lock.json
+++ b/src/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "langflow",
- "version": "1.9.2",
+ "version": "1.9.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "langflow",
- "version": "1.9.2",
+ "version": "1.9.3",
"dependencies": {
"@chakra-ui/number-input": "^2.1.2",
"@chakra-ui/system": "^2.6.2",
@@ -57,7 +57,7 @@
"framer-motion": "^11.2.10",
"fuse.js": "^7.0.0",
"i18next": "^25.8.18",
- "lodash": "^4.18.0",
+ "lodash": "^4.17.21",
"lucide-react": "^0.575.0",
"moment": "^2.30.1",
"moment-timezone": "^0.5.48",
@@ -217,6 +217,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1082,6 +1083,7 @@
"resolved": "https://registry.npmjs.org/@chakra-ui/styled-system/-/styled-system-2.9.2.tgz",
"integrity": "sha512-To/Z92oHpIE+4nk11uVMWqo2GGRS86coeMmjxtpnErmWRdLcp1WVCVRAvn+ZwpLiNR+reWFr2FFqJRsREuZdAg==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@chakra-ui/shared-utils": "2.0.5",
"csstype": "^3.1.2",
@@ -1093,6 +1095,7 @@
"resolved": "https://registry.npmjs.org/@chakra-ui/system/-/system-2.6.2.tgz",
"integrity": "sha512-EGtpoEjLrUu4W1fHD+a62XR+hzC5YfsWm+6lO0Kybcga3yYEij9beegO0jZgug27V+Rf7vns95VPVP6mFd/DEQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@chakra-ui/color-mode": "2.2.0",
"@chakra-ui/object-utils": "2.1.0",
@@ -1199,6 +1202,7 @@
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
@@ -1235,6 +1239,7 @@
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
@@ -1244,6 +1249,7 @@
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.41.1.tgz",
"integrity": "sha512-ToDnWKbBnke+ZLrP6vgTTDScGi5H37YYuZGniQaBzxMVdtCxMrslsmtnOvbPZk4RX9bvkQqnWR/WS/35tJA0qg==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@codemirror/state": "^6.6.0",
"crelt": "^1.0.6",
@@ -1339,6 +1345,7 @@
}
],
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=18"
},
@@ -1362,6 +1369,7 @@
}
],
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -6007,6 +6015,7 @@
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@swc/counter": "^0.1.3",
"@swc/types": "^0.1.26"
@@ -6413,6 +6422,7 @@
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -7124,6 +7134,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -7134,6 +7145,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -7149,7 +7161,8 @@
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.9.tgz",
"integrity": "sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/@types/stack-utils": {
"version": "2.0.3",
@@ -7781,6 +7794,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -8281,6 +8295,7 @@
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"peerDependencies": {
"bare-abort-controller": "*"
},
@@ -8549,6 +8564,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -8941,6 +8957,7 @@
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
@@ -9381,6 +9398,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
+ "peer": true,
"engines": {
"node": ">=12"
}
@@ -9957,6 +9975,7 @@
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"hasInstallScript": true,
"license": "MIT",
+ "peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@@ -10235,6 +10254,7 @@
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -11367,6 +11387,7 @@
"integrity": "sha512-jN0ZewiNAWSe5khM3EyCmBb250+b40wWbwNILNfEvq84VREWwOIkuUsFONk/3i3nqkz7Oe1PcpM2mwQEK2L9Kg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -11519,6 +11540,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/runtime": "^7.29.2"
},
@@ -11669,9 +11691,9 @@
}
},
"node_modules/ip-address": {
- "version": "10.1.0",
- "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
- "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
+ "version": "10.2.0",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
+ "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -12078,6 +12100,7 @@
"integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@jest/core": "30.3.0",
"@jest/types": "30.3.0",
@@ -13664,6 +13687,7 @@
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"license": "MIT",
+ "peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -13713,6 +13737,7 @@
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
@@ -13752,6 +13777,7 @@
"resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz",
"integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 10.16.0"
}
@@ -16247,6 +16273,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -16669,6 +16696,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -16762,6 +16790,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -16792,6 +16821,7 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.74.0.tgz",
"integrity": "sha512-yR6wHr99p9wFv686jhRWVSFhUvDvNbdUf2dKlbno8/VKOCuoNobDGC6S+M2dua9A9Yo8vpcrp8assIYbsZCQ9g==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -17485,6 +17515,7 @@
"integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -18003,7 +18034,8 @@
"version": "1.15.7",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz",
"integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/source-map": {
"version": "0.5.7",
@@ -18182,6 +18214,7 @@
"integrity": "sha512-vbSz7g/1rGMC1uAULqMZjALkIuLu2QABqfhRYhyr/11kzyesi+vAmwyJLukZP1FfecxGOgMwOh6GS0YsGpHAvQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@storybook/global": "^5.0.0",
"@storybook/icons": "^2.0.1",
@@ -18556,6 +18589,7 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
@@ -19152,6 +19186,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -19665,6 +19700,7 @@
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -20649,6 +20685,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
+ "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -20702,4 +20739,4 @@
}
}
}
-}
+}
\ No newline at end of file
diff --git a/src/frontend/package.json b/src/frontend/package.json
index 74de2d2a75..e793617e38 100644
--- a/src/frontend/package.json
+++ b/src/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "langflow",
- "version": "1.9.2",
+ "version": "1.9.3",
"private": true,
"engines": {
"node": ">=20.19.0"
@@ -11,6 +11,7 @@
"glob": "^11.1.0",
"test-exclude": "^7.0.0",
"picomatch": "^4.0.4",
+ "ip-address": "^10.1.1",
"jest-util": {
"picomatch": "^4.0.4"
},
@@ -72,7 +73,7 @@
"framer-motion": "^11.2.10",
"fuse.js": "^7.0.0",
"i18next": "^25.8.18",
- "lodash": "^4.18.0",
+ "lodash": "^4.17.21",
"lucide-react": "^0.575.0",
"moment": "^2.30.1",
"moment-timezone": "^0.5.48",
diff --git a/src/frontend/src/components/core/parameterRenderComponent/components/modelInputComponent/__tests__/ModelInputComponent.test.tsx b/src/frontend/src/components/core/parameterRenderComponent/components/modelInputComponent/__tests__/ModelInputComponent.test.tsx
index 220abfeca5..07f75486cc 100644
--- a/src/frontend/src/components/core/parameterRenderComponent/components/modelInputComponent/__tests__/ModelInputComponent.test.tsx
+++ b/src/frontend/src/components/core/parameterRenderComponent/components/modelInputComponent/__tests__/ModelInputComponent.test.tsx
@@ -171,7 +171,10 @@ const defaultProps: BaseInputProps & ModelInputComponentType = {
editNode: false,
};
-// Helper to render with QueryClientProvider
+// Helper to render with QueryClientProvider. Returns the raw RTL handle plus
+// a ``rerenderWithProvider`` wrapper so callers can rerender without losing
+// the surrounding ``QueryClientProvider`` (rerender replaces the root JSX —
+// dropping the wrapper would force a remount and reset component state).
const renderWithQueryClient = (component: React.ReactElement) => {
const queryClient = new QueryClient({
defaultOptions: {
@@ -179,9 +182,13 @@ const renderWithQueryClient = (component: React.ReactElement) => {
mutations: { retry: false },
},
});
- return render(
- {component},
+ const wrap = (node: React.ReactElement) => (
+ {node}
);
+ const result = render(wrap(component));
+ const rerenderWithProvider = (node: React.ReactElement) =>
+ result.rerender(wrap(node));
+ return { ...result, rerenderWithProvider };
};
describe("ModelInputComponent", () => {
@@ -190,14 +197,15 @@ describe("ModelInputComponent", () => {
});
describe("Rendering", () => {
- it("should render disabled combobox when no options are provided", () => {
+ it("renders the Setup Provider CTA when no models are enabled", () => {
renderWithQueryClient(
,
);
- const combobox = screen.getByRole("combobox");
- expect(combobox).toBeInTheDocument();
- expect(combobox).toBeDisabled();
+ // No combobox — the trigger swaps for the Setup Provider button when
+ // there's nothing to pick from.
+ expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
+ expect(screen.getByText("Setup Provider")).toBeInTheDocument();
});
it("should render the model selector when options are available", () => {
@@ -323,6 +331,80 @@ describe("ModelInputComponent", () => {
expect(screen.getByTestId("model-provider-modal")).toBeInTheDocument();
});
});
+
+ it("keeps loading state until both providers and enabled-models refetches settle", async () => {
+ // The post-close refresh invalidates both ``useGetModelProviders`` AND
+ // ``useGetEnabledModels``. The component must wait for BOTH to finish
+ // before clearing the loading state — otherwise ``groupedOptions``
+ // renders against a stale ``enabledModelsData`` cache and disabled
+ // models briefly leak back into the dropdown after the user closes
+ // the provider modal.
+ let providersFetching = true;
+ let enabledFetching = true;
+
+ const mockedProviders = useGetModelProviders as jest.MockedFunction<
+ typeof useGetModelProviders
+ >;
+ const mockedEnabled = useGetEnabledModels as jest.MockedFunction<
+ typeof useGetEnabledModels
+ >;
+
+ mockedProviders.mockImplementation(
+ () =>
+ ({
+ data: mockProvidersData,
+ isLoading: false,
+ isFetching: providersFetching,
+ }) as unknown as ReturnType,
+ );
+ mockedEnabled.mockImplementation(
+ () =>
+ ({
+ data: { enabled_models: {} },
+ isLoading: false,
+ isFetching: enabledFetching,
+ }) as unknown as ReturnType,
+ );
+
+ const user = userEvent.setup();
+ const { rerenderWithProvider } = renderWithQueryClient(
+ ,
+ );
+
+ // Open the dropdown then the provider manager dialog
+ const trigger = screen.getByRole("combobox");
+ await user.click(trigger);
+ await waitFor(() => {
+ expect(
+ screen.getByTestId("manage-model-providers"),
+ ).toBeInTheDocument();
+ });
+ await user.click(screen.getByTestId("manage-model-providers"));
+ await waitFor(() => {
+ expect(screen.getByTestId("model-provider-modal")).toBeInTheDocument();
+ });
+
+ // Close the modal — this sets isRefreshingAfterClose=true and the
+ // loading button replaces the dropdown.
+ await user.click(screen.getByTestId("close-provider-modal"));
+ await waitFor(() => {
+ expect(screen.getByText("Loading models")).toBeInTheDocument();
+ });
+
+ // Providers refetch completes FIRST. With the fix, loading persists
+ // because the enabled-models refetch is still in flight.
+ providersFetching = false;
+ rerenderWithProvider();
+ await new Promise((r) => setTimeout(r, 30));
+ expect(screen.getByText("Loading models")).toBeInTheDocument();
+
+ // Enabled-models refetch completes. Loading state clears.
+ enabledFetching = false;
+ rerenderWithProvider();
+ await waitFor(() => {
+ expect(screen.queryByText("Loading models")).not.toBeInTheDocument();
+ });
+ });
});
describe("Footer Buttons", () => {
@@ -602,117 +684,88 @@ describe("ModelInputComponent", () => {
expect(screen.getByRole("combobox")).not.toBeDisabled();
});
- it("keeps a saved value whose model isn't enabled locally and renders the Configure wrench", async () => {
- // The backend's update_model_options_in_build_config injects the saved
- // value into options tagged with `not_enabled_locally: true` whenever
- // it isn't in the user's enabled list. The frontend must:
- // 1. NOT auto-reset the saved value.
- // 2. Keep the option visible/selectable in the dropdown.
- // 3. Render the Configure wrench next to the trigger.
- mockedUseGetEnabledModels.mockReturnValue({
- data: {
- enabled_models: {
- OpenAI: { "gpt-4": true, "gpt-3.5-turbo": true },
- },
- },
- isLoading: false,
- });
-
- const handleOnNewValue = jest.fn();
- const savedValue = [
- {
- id: "ibm/granite-3",
- name: "ibm/granite-3",
- icon: "IBMWatsonx",
- provider: "IBM watsonx.ai",
- metadata: { not_enabled_locally: true },
- },
- ];
- // Backend-style options: the saved value is injected into options with
- // the sticky flag so the client can render it.
- const optionsWithSticky = [
- ...mockOptions,
- {
- id: "ibm/granite-3",
- name: "ibm/granite-3",
- icon: "IBMWatsonx",
- provider: "IBM watsonx.ai",
- metadata: { not_enabled_locally: true },
- },
- ];
-
- renderWithQueryClient(
- ,
- );
-
- // Value must not be reset by the auto-select effect.
- expect(handleOnNewValue).not.toHaveBeenCalled();
-
- // Saved model remains visible in the trigger label.
- await waitFor(() => {
- expect(screen.getByText("ibm/granite-3")).toBeInTheDocument();
- });
-
- // Configure wrench is rendered next to the trigger.
- expect(
- screen.getByTestId(`${defaultProps.id}-configure`),
- ).toBeInTheDocument();
- });
-
- it("opens the provider manager when the Configure wrench is clicked", async () => {
+ it("auto-selects an available model when the saved value is globally disabled", async () => {
+ // The user previously chose ibm/granite-3 but that provider's models
+ // are no longer enabled. Some other models (gpt-4) ARE enabled, so the
+ // component must drop the stale selection and align the stored value
+ // with the first available option rather than visually showing a model
+ // the dropdown doesn't contain.
mockedUseGetEnabledModels.mockReturnValue({
data: { enabled_models: { OpenAI: { "gpt-4": true } } },
isLoading: false,
});
+ const handleOnNewValue = jest.fn();
const savedValue = [
{
id: "ibm/granite-3",
name: "ibm/granite-3",
icon: "IBMWatsonx",
provider: "IBM watsonx.ai",
- metadata: { not_enabled_locally: true },
- },
- ];
- const optionsWithSticky = [
- ...mockOptions,
- {
- id: "ibm/granite-3",
- name: "ibm/granite-3",
- icon: "IBMWatsonx",
- provider: "IBM watsonx.ai",
- metadata: { not_enabled_locally: true },
+ metadata: {},
},
];
- const handleOnNewValue = jest.fn();
- const user = userEvent.setup();
renderWithQueryClient(
,
);
- const wrench = await screen.findByTestId(`${defaultProps.id}-configure`);
- await user.click(wrench);
-
+ // Auto-select fires because the saved name isn't in flatOptions.
await waitFor(() => {
- expect(screen.getByTestId("model-provider-modal")).toBeInTheDocument();
+ expect(handleOnNewValue).toHaveBeenCalled();
});
- // Clicking the wrench must not mutate the saved value.
- expect(handleOnNewValue).not.toHaveBeenCalled();
+ const newValue = handleOnNewValue.mock.calls[0][0].value;
+ expect(newValue[0].name).toBe("gpt-4");
+
+ // The stale model name must NOT be rendered anywhere.
+ expect(screen.queryByText("ibm/granite-3")).not.toBeInTheDocument();
});
- it("does not render Configure when the selected model isn't flagged", () => {
- // Baseline: a normal enabled model must not surface the wrench.
+ it("renders the Setup Provider CTA when a provider is configured but all its models are disabled", () => {
+ // OpenAI is configured (is_configured/is_enabled true on the provider)
+ // but every model is explicitly disabled in enabled_models. There's
+ // nothing for the user to pick from, so we must show the Setup
+ // Provider CTA — same UX as the never-configured case.
+ mockedUseGetModelProviders.mockReturnValue({
+ data: [
+ {
+ provider: "OpenAI",
+ is_enabled: true,
+ is_configured: true,
+ icon: "OpenAI",
+ models: [
+ { model_name: "gpt-4", metadata: { model_type: "llm" } },
+ { model_name: "gpt-3.5-turbo", metadata: { model_type: "llm" } },
+ ],
+ },
+ ],
+ isLoading: false,
+ });
+ mockedUseGetEnabledModels.mockReturnValue({
+ data: {
+ enabled_models: {
+ OpenAI: { "gpt-4": false, "gpt-3.5-turbo": false },
+ },
+ },
+ isLoading: false,
+ });
+
+ renderWithQueryClient(
+ ,
+ );
+
+ expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
+ expect(screen.getByText("Setup Provider")).toBeInTheDocument();
+ });
+
+ it("never renders the Configure wrench affordance", () => {
+ // The sticky-default UX was removed: there's no per-trigger affordance
+ // for a disabled-but-saved model. The dropdown is the single source
+ // of truth.
mockedUseGetEnabledModels.mockReturnValue({
data: { enabled_models: { OpenAI: { "gpt-4": true } } },
isLoading: false,
diff --git a/src/frontend/src/components/core/parameterRenderComponent/components/modelInputComponent/index.tsx b/src/frontend/src/components/core/parameterRenderComponent/components/modelInputComponent/index.tsx
index 9a3c4de061..b19be72281 100644
--- a/src/frontend/src/components/core/parameterRenderComponent/components/modelInputComponent/index.tsx
+++ b/src/frontend/src/components/core/parameterRenderComponent/components/modelInputComponent/index.tsx
@@ -80,10 +80,6 @@ export default function ModelInputComponent({
const { refreshAllModelInputs } = useRefreshModelInputs();
- // Ref to track if we've already processed the empty options state
- // prevents infinite loop when no models are available
- const hasProcessedEmptyRef = useRef(false);
-
const postTemplateValue = usePostTemplateValue({
parameterId: "model",
nodeId: nodeId || "",
@@ -113,17 +109,14 @@ export default function ModelInputComponent({
isLoading: isLoadingProviders,
isFetching: isFetchingProviders,
} = useGetModelProviders({});
- const { data: enabledModelsData, isLoading: isLoadingEnabledModels } =
- useGetEnabledModels();
+ const {
+ data: enabledModelsData,
+ isLoading: isLoadingEnabledModels,
+ isFetching: isFetchingEnabledModels,
+ } = useGetEnabledModels();
const isLoading = isLoadingProviders || isLoadingEnabledModels;
- const hasEnabledProviders = useMemo(() => {
- return providersData?.some(
- (provider) => provider.is_enabled || provider.is_configured,
- );
- }, [providersData]);
-
// Groups models by their provider name for sectioned display in dropdown.
// Filters out models from disabled providers AND disabled models, then
// augments with any enabled models from `providersData` that weren't in the
@@ -137,20 +130,12 @@ export default function ModelInputComponent({
if (option.metadata?.is_disabled_provider) continue;
const provider = option.provider || "Unknown";
- // Backend sticky-default: options tagged with `not_enabled_locally` are
- // the user's saved selection injected into the options list by the
- // unified-models build_config helper even though they aren't in the
- // enabled list. They must always pass the client-side filter so the
- // selection stays visible and selectable in the dropdown.
- const isStickyNotEnabled = option.metadata?.not_enabled_locally === true;
-
- // Filter against client-side enabled models data. This is the source of
- // truth for what the current user has enabled — stale `options` saved in
- // an imported flow may include models from providers the current user
- // hasn't enabled (e.g. WatsonX). When the provider is tracked in
- // enabled_models, the model must be explicitly enabled (=== true); a
- // `false` or missing entry means the model should be hidden.
- if (!isStickyNotEnabled && enabledModelsData?.enabled_models) {
+ // Filter against client-side enabled models data. The user's
+ // current enabled list is the single source of truth: a model that
+ // isn't explicitly enabled (=== true) is hidden, even if it's the
+ // node's saved selection. There is no sticky-default carve-out —
+ // a globally-disabled model never appears in the dropdown.
+ if (enabledModelsData?.enabled_models) {
const providerModels = enabledModelsData.enabled_models[provider];
if (providerModels && providerModels[option.name] !== true) {
continue;
@@ -216,33 +201,17 @@ export default function ModelInputComponent({
}
}
- // Zero-provider import fallback: if the user has no providers configured
- // locally but an imported flow carries a saved model, inject that saved
- // selection as the only dropdown entry so the trigger renders the model
- // (with the Configure wrench) instead of the "Setup Provider" button.
- // Without this, ``flatOptions`` would be empty and ``ModelTrigger`` would
- // swap the dropdown for the setup CTA, visually losing the imported
- // selection.
- const hasAnyGrouped = Object.keys(grouped).length > 0;
- const savedValue = value?.[0];
- if (!hasAnyGrouped && savedValue?.name) {
- const providerName = savedValue.provider || "Unknown";
- grouped[providerName] = [
- {
- ...(savedValue.id && { id: savedValue.id }),
- name: savedValue.name,
- icon: savedValue.icon || "Bot",
- provider: providerName,
- metadata: {
- ...(savedValue.metadata ?? {}),
- not_enabled_locally: true,
- },
- } as ModelOption,
- ];
- }
-
return grouped;
- }, [options, enabledModelsData, providersData, modelType, value]);
+ }, [options, enabledModelsData, providersData, modelType]);
+
+ // True iff at least one model of this component's type is enabled across
+ // any provider. Drives the Setup Provider CTA — a provider that is
+ // configured but has all its models disabled is treated the same as a
+ // provider that hasn't been configured yet.
+ const hasEnabledProviders = useMemo(
+ () => Object.values(groupedOptions).some((models) => models.length > 0),
+ [groupedOptions],
+ );
// Flattened array of all enabled options for efficient lookups by name
const flatOptions = useMemo(
@@ -250,9 +219,12 @@ export default function ModelInputComponent({
[groupedOptions],
);
- // Derive the currently selected model from the value prop
+ // Derive the currently selected model from the value prop. If the saved
+ // value isn't in the current ``flatOptions`` (typically because the
+ // model was globally disabled), it's treated as if no model is selected
+ // — the trigger renders the first available option as the visual
+ // selection and the effect below realigns the stored ``value`` to match.
const selectedModel = useMemo(() => {
- // If we're in connection mode, show the connection option as selected
if (isConnectionMode) {
return {
name:
@@ -264,56 +236,26 @@ export default function ModelInputComponent({
}
const currentName = value?.[0]?.name;
- if (!currentName) {
- // Logic to auto-select the first model if none is selected
- // We only do this check if we have options available
- if (flatOptions.length > 0 && !hasProcessedEmptyRef.current) {
- // If we haven't processed empty state yet, we render the first one
- return flatOptions[0];
- }
- return null;
- }
-
- const match = flatOptions.find((option) => option.name === currentName);
- if (match) return match;
-
- // Saved name isn't in the filtered options list — typically because the
- // flow was imported via drag-drop (no backend sticky-default round-trip)
- // or because an outdated component was upgraded and the fresh template
- // lacks the sticky-default metadata. Preserve the saved selection in the
- // trigger so it doesn't visually "snap" to the user's first enabled
- // model. The wrench affordance in the dropdown handles configuration.
- const saved = value?.[0];
- if (saved) {
- return {
- ...(saved.id && { id: saved.id }),
- name: saved.name,
- icon: saved.icon || "Bot",
- provider: saved.provider || "Unknown",
- metadata: {
- ...(saved.metadata ?? {}),
- not_enabled_locally: true,
- },
- } as SelectedModel;
+ if (currentName) {
+ const match = flatOptions.find((option) => option.name === currentName);
+ if (match) return match;
}
return flatOptions.length > 0 ? flatOptions[0] : null;
}, [value, flatOptions, isConnectionMode, externalOptions]);
+ // Auto-select the first available option whenever the stored ``value``
+ // doesn't point at a currently-available model — including the case
+ // where the previously-selected model was globally disabled. Without
+ // this, the trigger would visually show one model while the node's
+ // saved value pointed at a different (or removed) one.
useEffect(() => {
if (flatOptions.length === 0 || isConnectionMode) return;
- if (hasProcessedEmptyRef.current) return;
- const isEmpty = !value || value.length === 0;
- // Sticky-default: if the component has a saved value, keep it as-is. The
- // backend injects any selection that isn't in the user's enabled list
- // back into `options` tagged with `not_enabled_locally`, so the saved
- // value remains visible and runnable. Only auto-select the first option
- // when there's no saved value at all.
- if (!isEmpty) return;
+ const savedName = value?.[0]?.name;
+ if (savedName && flatOptions.some((o) => o.name === savedName)) return;
const firstOption = flatOptions[0];
- // Construct the new value object
const newValue = [
{
...(firstOption.id && { id: firstOption.id }),
@@ -324,7 +266,6 @@ export default function ModelInputComponent({
},
];
handleOnNewValue({ value: newValue });
- hasProcessedEmptyRef.current = true;
}, [flatOptions, value, handleOnNewValue, isConnectionMode]);
/**
@@ -402,22 +343,27 @@ export default function ModelInputComponent({
setIsRefreshingAfterClose(true);
}, []);
- // Clear the refreshing indicator after the providers query completes a full
- // refetch cycle (isFetchingProviders: false → true → false). We track whether
- // we've seen the fetch start so we don't clear prematurely before the
- // invalidation has even been triggered by refreshAllModelInputs.
+ // Clear the refreshing indicator only after BOTH the providers and the
+ // enabled-models queries have completed a full refetch cycle (isFetching:
+ // false → true → false). Watching only ``isFetchingProviders`` clears the
+ // loading state too early when the enabled-models refetch is slower,
+ // letting ``groupedOptions`` render against a stale ``enabledModelsData``
+ // cache — disabled models would briefly leak back into the dropdown after
+ // the user closes the provider modal. We track whether we've seen the
+ // fetch start so we don't clear prematurely before the invalidation has
+ // even been triggered by refreshAllModelInputs.
const hasSeenFetchStartRef = useRef(false);
useEffect(() => {
if (!isRefreshingAfterClose) {
hasSeenFetchStartRef.current = false;
return;
}
- if (isFetchingProviders) {
+ if (isFetchingProviders || isFetchingEnabledModels) {
hasSeenFetchStartRef.current = true;
} else if (hasSeenFetchStartRef.current) {
setIsRefreshingAfterClose(false);
}
- }, [isRefreshingAfterClose, isFetchingProviders]);
+ }, [isRefreshingAfterClose, isFetchingProviders, isFetchingEnabledModels]);
// Safety timeout: clear loading even if no refetch cycle is detected
// (e.g. no model nodes on canvas, or the refresh was a no-op)
@@ -521,49 +467,22 @@ export default function ModelInputComponent({
return
{renderLoadingButton()}
;
}
- // Show a small "Configure" wrench next to the trigger when the currently
- // selected model was injected by the backend as a sticky default — i.e.
- // the user's saved selection isn't in their current enabled_models list.
- // Clicking it jumps straight to the provider manager so the user can
- // enable the provider without losing their selection.
- const showConfigureAffordance =
- selectedModel?.metadata?.not_enabled_locally === true;
-
// Main render
return (
<>
-