Merge branch 'release-1.9.3'

# Conflicts:
#	docker/build_and_push.Dockerfile
#	docker/build_and_push_backend.Dockerfile
#	docker/build_and_push_base.Dockerfile
#	docker/build_and_push_ep.Dockerfile
#	docker/build_and_push_with_extras.Dockerfile
This commit is contained in:
Eric Hare
2026-05-14 21:01:03 -07:00
75 changed files with 4871 additions and 1255 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -19,6 +19,7 @@ jobs:
strategy:
matrix:
python-version:
- "3.14"
- "3.13"
- "3.12"
- "3.11"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

124
docs/package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

@ -1327,7 +1327,7 @@
},
{
"name": "googleapiclient",
"version": "2.194.0"
"version": "2.195.0"
}
],
"total_dependencies": 4

View File

@ -1781,7 +1781,7 @@
},
{
"name": "googleapiclient",
"version": "2.194.0"
"version": "2.195.0"
}
],
"total_dependencies": 7

File diff suppressed because one or more lines are too long

View File

@ -949,7 +949,7 @@
},
{
"name": "googleapiclient",
"version": "2.194.0"
"version": "2.195.0"
}
],
"total_dependencies": 4

View File

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

View File

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

View File

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

View File

@ -2641,7 +2641,7 @@
},
{
"name": "googleapiclient",
"version": "2.194.0"
"version": "2.195.0"
}
],
"total_dependencies": 4

View File

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

View File

@ -1643,7 +1643,7 @@
},
{
"name": "googleapiclient",
"version": "2.194.0"
"version": "2.195.0"
}
],
"total_dependencies": 4

View File

@ -274,7 +274,7 @@
},
{
"name": "googleapiclient",
"version": "2.194.0"
"version": "2.195.0"
},
{
"name": "lfx",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,5 @@
from __future__ import annotations
import math
from datetime import datetime, timezone
from typing import Any

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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(
<QueryClientProvider client={queryClient}>{component}</QueryClientProvider>,
const wrap = (node: React.ReactElement) => (
<QueryClientProvider client={queryClient}>{node}</QueryClientProvider>
);
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(
<ModelInputComponent {...defaultProps} options={[]} />,
);
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<typeof useGetModelProviders>,
);
mockedEnabled.mockImplementation(
() =>
({
data: { enabled_models: {} },
isLoading: false,
isFetching: enabledFetching,
}) as unknown as ReturnType<typeof useGetEnabledModels>,
);
const user = userEvent.setup();
const { rerenderWithProvider } = renderWithQueryClient(
<ModelInputComponent {...defaultProps} />,
);
// 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(<ModelInputComponent {...defaultProps} />);
await new Promise((r) => setTimeout(r, 30));
expect(screen.getByText("Loading models")).toBeInTheDocument();
// Enabled-models refetch completes. Loading state clears.
enabledFetching = false;
rerenderWithProvider(<ModelInputComponent {...defaultProps} />);
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(
<ModelInputComponent
{...defaultProps}
options={optionsWithSticky}
value={savedValue}
handleOnNewValue={handleOnNewValue}
/>,
);
// 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(
<ModelInputComponent
{...defaultProps}
options={optionsWithSticky}
value={savedValue}
handleOnNewValue={handleOnNewValue}
/>,
);
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(
<ModelInputComponent {...defaultProps} options={[]} />,
);
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,

View File

@ -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 <div className="w-full">{renderLoadingButton()}</div>;
}
// 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 (
<>
<Popover open={open} onOpenChange={setOpen}>
<div className="flex w-full items-center gap-2">
<div className="min-w-0 flex-1 truncate">
<ModelTrigger
open={open}
disabled={disabled}
options={flatOptions}
selectedModel={selectedModel}
placeholder={placeholder}
hasEnabledProviders={hasEnabledProviders ?? false}
onOpenManageProviders={() => setOpenManageProvidersDialog(true)}
id={id}
refButton={refButton}
showEmptyState={showEmptyState}
/>
</div>
{showConfigureAffordance && (
<button
type="button"
onClick={() => {
setOpen(false);
setOpenManageProvidersDialog(true);
}}
data-testid={`${id}-configure`}
aria-label="Configure this model's provider"
title="This model isn't enabled for your user. Click to configure its provider."
className="shrink-0 inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-primary"
>
<ForwardedIconComponent name="Wrench" className="h-3.5 w-3.5" />
</button>
)}
</div>
<ModelTrigger
open={open}
disabled={disabled}
options={flatOptions}
selectedModel={selectedModel}
placeholder={placeholder}
hasEnabledProviders={hasEnabledProviders}
onOpenManageProviders={() => setOpenManageProvidersDialog(true)}
id={id}
refButton={refButton}
showEmptyState={showEmptyState}
/>
{renderPopoverContent()}
</Popover>

View File

@ -0,0 +1,452 @@
import { renderHook } from "@testing-library/react";
import { act } from "react";
import { useGetEnabledModels } from "@/controllers/API/queries/models/use-get-enabled-models";
import { useModelToggleQueue } from "../hooks/useModelToggleQueue";
// ---------------------------------------------------------------------------
// React Query — capture invocation order on the shared mock so the test can
// assert that ``cancelQueries`` is called BEFORE ``setQueryData`` whenever a
// user toggles a model. The two are wired to a single Jest mock function so
// their relative call order is preserved.
// ---------------------------------------------------------------------------
const recordedCalls: Array<{ method: string; args: unknown[] }> = [];
const trackingQueryClient = {
cancelQueries: jest.fn((...args: unknown[]) => {
recordedCalls.push({ method: "cancelQueries", args });
return Promise.resolve();
}),
setQueryData: jest.fn((...args: unknown[]) => {
recordedCalls.push({ method: "setQueryData", args });
const updater = args[1] as
| ((prev: unknown) => unknown)
| Record<string, unknown>;
if (typeof updater === "function") {
updater({ enabled_models: { OpenAI: { "gpt-4": true } } });
}
return undefined;
}),
getQueryData: jest.fn(() => ({
enabled_models: { OpenAI: { "gpt-4": true } },
})),
invalidateQueries: jest.fn((...args: unknown[]) => {
recordedCalls.push({ method: "invalidateQueries", args });
return Promise.resolve();
}),
};
jest.mock("@tanstack/react-query", () => ({
useQueryClient: () => trackingQueryClient,
}));
// The hook subscribes to ``useGetEnabledModels`` so the re-overlay effect can
// react when a refetch lands. The shared mock starts with the same baseline
// the trackingQueryClient holds.
jest.mock("@/controllers/API/queries/models/use-get-enabled-models", () => ({
useGetEnabledModels: jest.fn(() => ({
data: { enabled_models: { OpenAI: { "gpt-4": true } } },
})),
}));
// Mutation mock — tests can read the recorded payloads to assert that each
// flush sends ONLY the unsent slice, never re-sending an in-flight overlay,
// and can drive ``onError`` / ``onSettled`` via the captured callbacks.
const mutationCalls: Array<{
updates: { provider: string; model_id: string; enabled: boolean }[];
}> = [];
const mutationCallbacks: Array<{
onError?: (error: unknown) => void;
onSettled?: () => void;
}> = [];
jest.mock("@/controllers/API/queries/models/use-update-enabled-models", () => ({
useUpdateEnabledModels: () => ({
mutate: jest.fn((vars, callbacks) => {
mutationCalls.push(vars);
mutationCallbacks.push(callbacks);
}),
mutateAsync: jest.fn((vars) => {
mutationCalls.push(vars);
return Promise.resolve({ disabled_models: [] });
}),
}),
}));
// Debounce stub: capturing the pending function instead of running it
// synchronously lets each test pick when (or whether) the debounced flush
// fires. Tests that exercise the debounced path call ``runDebounced()``;
// tests that exercise the awaitable ``flushPendingChanges`` path don't, so
// ``unsentToggles`` is still populated when the explicit flush runs.
let pendingDebouncedFns: Array<() => void> = [];
const runDebounced = () => {
const fns = pendingDebouncedFns;
pendingDebouncedFns = [];
for (const fn of fns) fn();
};
jest.mock("@/hooks/use-debounce", () => ({
useDebounce: (fn: (...args: unknown[]) => unknown) => {
const wrapped = (..._args: unknown[]) => {
pendingDebouncedFns.push(() => fn());
};
(wrapped as unknown as { cancel: () => void }).cancel = jest.fn(() => {
pendingDebouncedFns = [];
});
return wrapped;
},
}));
const mockRefreshAllModelInputs = jest.fn(() => Promise.resolve());
jest.mock("@/hooks/use-refresh-model-inputs", () => ({
useRefreshModelInputs: () => ({
refreshAllModelInputs: mockRefreshAllModelInputs,
}),
}));
const mockSetErrorData = jest.fn();
jest.mock("@/stores/alertStore", () => ({
__esModule: true,
default: (selector: (state: unknown) => unknown) =>
selector({
setSuccessData: jest.fn(),
setErrorData: mockSetErrorData,
}),
}));
describe("useModelToggleQueue", () => {
beforeEach(() => {
recordedCalls.length = 0;
mutationCalls.length = 0;
mutationCallbacks.length = 0;
pendingDebouncedFns = [];
trackingQueryClient.cancelQueries.mockClear();
trackingQueryClient.setQueryData.mockClear();
trackingQueryClient.getQueryData.mockClear();
trackingQueryClient.invalidateQueries.mockClear();
mockRefreshAllModelInputs.mockClear();
mockSetErrorData.mockClear();
});
describe("optimistic update", () => {
it("cancels in-flight useGetEnabledModels refetches before the optimistic cache update", () => {
const { result } = renderHook(() =>
useModelToggleQueue({ providerName: "OpenAI" }),
);
act(() => {
result.current.handleModelToggle("gpt-4", false);
});
expect(trackingQueryClient.cancelQueries).toHaveBeenCalledWith({
queryKey: ["useGetEnabledModels"],
});
expect(trackingQueryClient.setQueryData).toHaveBeenCalledWith(
["useGetEnabledModels"],
expect.any(Function),
);
const cancelIdx = recordedCalls.findIndex(
(call) => call.method === "cancelQueries",
);
const setIdx = recordedCalls.findIndex(
(call) => call.method === "setQueryData",
);
expect(cancelIdx).toBeGreaterThanOrEqual(0);
expect(setIdx).toBeGreaterThanOrEqual(0);
expect(cancelIdx).toBeLessThan(setIdx);
});
it("no-ops when no provider is selected", () => {
const { result } = renderHook(() =>
useModelToggleQueue({ providerName: null }),
);
act(() => {
result.current.handleModelToggle("gpt-4", false);
});
expect(trackingQueryClient.cancelQueries).not.toHaveBeenCalled();
expect(trackingQueryClient.setQueryData).not.toHaveBeenCalled();
});
});
describe("re-overlay effect", () => {
it("re-applies the pending overlay when a refetch surfaces stale data", () => {
const mockedEnabled = useGetEnabledModels as jest.MockedFunction<
typeof useGetEnabledModels
>;
// Initial render: gpt-4 enabled on the server.
mockedEnabled.mockReturnValue({
data: { enabled_models: { OpenAI: { "gpt-4": true } } },
} as unknown as ReturnType<typeof useGetEnabledModels>);
const { result, rerender } = renderHook(() =>
useModelToggleQueue({ providerName: "OpenAI" }),
);
// User toggles gpt-4 off — overlay now holds {gpt-4: false}.
act(() => {
result.current.handleModelToggle("gpt-4", false);
});
expect(trackingQueryClient.setQueryData).toHaveBeenCalled();
// Drain the call log so the re-overlay can be detected specifically.
recordedCalls.length = 0;
trackingQueryClient.setQueryData.mockClear();
// Simulate a refetch that lands inside the debounce window: the cache
// reports the still-stale server state (gpt-4: true).
mockedEnabled.mockReturnValue({
data: { enabled_models: { OpenAI: { "gpt-4": true } } },
} as unknown as ReturnType<typeof useGetEnabledModels>);
rerender();
// The effect detects drift and re-applies the overlay.
expect(trackingQueryClient.setQueryData).toHaveBeenCalledWith(
["useGetEnabledModels"],
expect.any(Function),
);
const updater = trackingQueryClient.setQueryData.mock.calls[0][1] as (
old: unknown,
) => unknown;
const result2 = updater({
enabled_models: { OpenAI: { "gpt-4": true, "gpt-3.5-turbo": true } },
}) as { enabled_models: { OpenAI: Record<string, boolean> } };
expect(result2.enabled_models.OpenAI["gpt-4"]).toBe(false);
expect(result2.enabled_models.OpenAI["gpt-3.5-turbo"]).toBe(true);
});
it("does not re-overlay when no toggles are pending", () => {
const mockedEnabled = useGetEnabledModels as jest.MockedFunction<
typeof useGetEnabledModels
>;
mockedEnabled.mockReturnValue({
data: { enabled_models: { OpenAI: { "gpt-4": true } } },
} as unknown as ReturnType<typeof useGetEnabledModels>);
renderHook(() => useModelToggleQueue({ providerName: "OpenAI" }));
// No toggle was performed — the mount effect must NOT call setQueryData.
expect(trackingQueryClient.setQueryData).not.toHaveBeenCalled();
});
});
describe("send buffer", () => {
it("does not resend in-flight toggles when a new toggle is flushed", () => {
const { result } = renderHook(() =>
useModelToggleQueue({ providerName: "OpenAI" }),
);
// Toggle A → debounce schedules a flush; drive it to send mutation A.
act(() => {
result.current.handleModelToggle("gpt-4", false);
runDebounced();
});
expect(mutationCalls).toHaveLength(1);
expect(mutationCalls[0].updates).toEqual([
{ provider: "OpenAI", model_id: "gpt-4", enabled: false },
]);
// Toggle B while A's mutation is still in flight (onSettled hasn't
// fired). The next flush MUST send ONLY B — re-sending A would be a
// duplicate request with non-deterministic ordering vs the original.
act(() => {
result.current.handleModelToggle("gpt-3.5-turbo", false);
runDebounced();
});
expect(mutationCalls).toHaveLength(2);
expect(mutationCalls[1].updates).toEqual([
{ provider: "OpenAI", model_id: "gpt-3.5-turbo", enabled: false },
]);
});
it("re-sends a model when the user re-toggles it after the previous flush fired", () => {
const { result } = renderHook(() =>
useModelToggleQueue({ providerName: "OpenAI" }),
);
// Toggle A → false. Drive the debounce.
act(() => {
result.current.handleModelToggle("gpt-4", false);
runDebounced();
});
expect(mutationCalls).toHaveLength(1);
expect(mutationCalls[0].updates).toEqual([
{ provider: "OpenAI", model_id: "gpt-4", enabled: false },
]);
// User re-toggles A → true before the first mutation settles. The
// re-toggle is a fresh intent and must be sent.
act(() => {
result.current.handleModelToggle("gpt-4", true);
runDebounced();
});
expect(mutationCalls).toHaveLength(2);
expect(mutationCalls[1].updates).toEqual([
{ provider: "OpenAI", model_id: "gpt-4", enabled: true },
]);
});
});
describe("error path", () => {
it("rolls back to previousData when the toggle mutation fails", () => {
const { result } = renderHook(() =>
useModelToggleQueue({ providerName: "OpenAI" }),
);
act(() => {
result.current.handleModelToggle("gpt-4", false);
runDebounced();
});
expect(mutationCallbacks).toHaveLength(1);
// The first setQueryData is the optimistic update from handleModelToggle.
// The second will be the rollback we expect on error.
const setQueryDataCallsBefore =
trackingQueryClient.setQueryData.mock.calls.length;
act(() => {
mutationCallbacks[0].onError?.(new Error("backend down"));
});
// Rollback restored ``previousData`` via setQueryData with the snapshot
// (NOT a function updater).
const newCalls = trackingQueryClient.setQueryData.mock.calls.slice(
setQueryDataCallsBefore,
);
const rollbackCall = newCalls.find(
([_, arg]) => typeof arg !== "function",
);
expect(rollbackCall).toBeDefined();
expect(rollbackCall?.[0]).toEqual(["useGetEnabledModels"]);
expect(rollbackCall?.[1]).toEqual({
enabled_models: { OpenAI: { "gpt-4": true } },
});
// User sees an error toast.
expect(mockSetErrorData).toHaveBeenCalledWith(
expect.objectContaining({ title: "Error updating model status" }),
);
});
it("does not re-apply the overlay after a failed mutation drains it", () => {
// The drain-before-rollback ordering inside ``rollbackToggleBatch`` is
// load-bearing. Without it, the re-overlay effect (triggered by the
// setQueryData rollback) would re-apply the stale overlay onto the
// just-rolled-back cache and silently undo the rollback.
const mockedEnabled = useGetEnabledModels as jest.MockedFunction<
typeof useGetEnabledModels
>;
mockedEnabled.mockReturnValue({
data: { enabled_models: { OpenAI: { "gpt-4": true } } },
} as unknown as ReturnType<typeof useGetEnabledModels>);
const { result, rerender } = renderHook(() =>
useModelToggleQueue({ providerName: "OpenAI" }),
);
act(() => {
result.current.handleModelToggle("gpt-4", false);
runDebounced();
});
// Mutation fails — overlay is drained, cache reverts.
act(() => {
mutationCallbacks[0].onError?.(new Error("backend down"));
});
// Subsequent refetch surfaces the (now-correct) server state.
trackingQueryClient.setQueryData.mockClear();
mockedEnabled.mockReturnValue({
data: { enabled_models: { OpenAI: { "gpt-4": true } } },
} as unknown as ReturnType<typeof useGetEnabledModels>);
rerender();
// The re-overlay effect must NOT re-apply the failed toggle. The
// overlay has been drained, so there's nothing to re-apply.
expect(trackingQueryClient.setQueryData).not.toHaveBeenCalled();
});
it("preserves a mid-flight re-toggle when the original mutation fails", () => {
// Scenario: user toggles A→false, then re-toggles A→true while the
// first mutation is in flight. The first mutation then fails. The
// user's latest intent (A=true) must survive — both in the overlay
// (so the re-overlay effect protects it) and in the unsent buffer
// (so the next flush sends it).
const { result } = renderHook(() =>
useModelToggleQueue({ providerName: "OpenAI" }),
);
act(() => {
result.current.handleModelToggle("gpt-4", false);
runDebounced();
});
expect(mutationCalls).toHaveLength(1);
expect(mutationCalls[0].updates[0].enabled).toBe(false);
// Re-toggle while first mutation is in flight.
act(() => {
result.current.handleModelToggle("gpt-4", true);
runDebounced();
});
expect(mutationCalls).toHaveLength(2);
expect(mutationCalls[1].updates[0].enabled).toBe(true);
// First mutation (A=false) fails. ``clearSentOverlay`` must NOT drop
// the A entry from the overlay, because the current overlay value
// (true) doesn't match what we sent (false) — the user re-toggled.
// The second mutation's overlay entry stays protected.
mutationCalls.length = 0;
act(() => {
mutationCallbacks[0].onError?.(new Error("backend down"));
});
// Trigger a fresh flush by toggling another key — if the overlay had
// been cleared in error, this flush would NOT include any prior
// intent. We verify the overlay survived by re-toggling A back to
// its in-flight value: if the overlay still has A=true, this is a
// no-op intent that doesn't appear in unsent. If the overlay was
// cleared, this re-toggle would be a fresh intent.
//
// Simpler check: the second mutation (A=true) is still in flight
// and must complete cleanly. The user's last intent is preserved
// by the overlay until that second mutation settles.
expect(mutationCallbacks).toHaveLength(2);
act(() => {
mutationCallbacks[1].onSettled?.();
});
// No additional rollback calls happened — the second mutation's
// overlay entry was preserved through the first failure.
expect(mockSetErrorData).toHaveBeenCalledTimes(1);
});
});
describe("flushPendingChanges", () => {
it("invalidates useGetEnabledModels on success without relying on the caller", async () => {
const { result } = renderHook(() =>
useModelToggleQueue({ providerName: "OpenAI" }),
);
// Toggle but DON'T run the debounced flush — leave the entry in
// unsentToggles for the awaitable close handler to consume.
act(() => {
result.current.handleModelToggle("gpt-4", false);
});
await act(async () => {
await result.current.flushPendingChanges();
});
// The async flush must invalidate the enabled-models cache itself —
// callers should not have to remember to invalidate downstream.
expect(trackingQueryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: ["useGetEnabledModels"],
});
expect(trackingQueryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: ["useGetModelProviders"],
});
});
});
});

View File

@ -0,0 +1,292 @@
import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useRef } from "react";
import {
EnabledModelsResponse,
useGetEnabledModels,
} from "@/controllers/API/queries/models/use-get-enabled-models";
import { useUpdateEnabledModels } from "@/controllers/API/queries/models/use-update-enabled-models";
import { useDebounce } from "@/hooks/use-debounce";
import { useRefreshModelInputs } from "@/hooks/use-refresh-model-inputs";
import useAlertStore from "@/stores/alertStore";
// Extracted from useProviderConfiguration.ts to keep the toggle-queue
// concerns (overlay buffer, unsent buffer, debounced flush, awaitable
// flush, re-overlay effect, optimistic cache management) in a single
// focused module. The parent hook now owns only variable CRUD and
// provider lifecycle — the two responsibilities no longer share a file.
const getErrorMessage = (error: unknown): string | undefined => {
const e = error as {
response?: { data?: { detail?: string } };
message?: string;
};
return e?.response?.data?.detail || e?.message;
};
export interface UseModelToggleQueueOptions {
/**
* Provider whose models the user is toggling. ``null`` short-circuits all
* handlers — useful while the modal is still resolving the selection.
*/
providerName: string | null | undefined;
}
export interface UseModelToggleQueueReturn {
handleModelToggle: (modelName: string, enabled: boolean) => void;
flushPendingChanges: () => Promise<void>;
}
interface ToggleBatch {
updates: { provider: string; model_id: string; enabled: boolean }[];
previousData: EnabledModelsResponse | undefined;
togglesToSend: Record<string, boolean>;
}
/**
* Coordinated optimistic-update queue for model enable/disable toggles.
*
* Two refs back the queue, each with a single responsibility:
*
* - ``overlayToggles`` — the union of every toggle the user has made
* whose change has not yet been confirmed by the server. The
* re-overlay effect re-applies it whenever ``useGetEnabledModels``
* emits new data, so any refetch which lands inside the debounce or
* in-flight-mutation window can't overwrite the optimistic cache
* with stale server state. Entries are drained per-key on
* ``onSettled``/``onError`` — but only when the entry still matches
* the value we sent (a user re-toggle mid-flight becomes a fresh
* intent and must survive the clear).
*
* - ``unsentToggles`` — the strict subset that has NOT been sent in a
* mutation yet (or was re-toggled since the last send). It's drained
* immediately at flush time so subsequent flushes don't resend the
* same payload — without this split, mutation A's in-flight overlay
* entries would be snapshotted into mutation B's payload, producing
* duplicate requests with non-deterministic success/failure ordering.
*/
export const useModelToggleQueue = ({
providerName,
}: UseModelToggleQueueOptions): UseModelToggleQueueReturn => {
const queryClient = useQueryClient();
const setErrorData = useAlertStore((state) => state.setErrorData);
const { mutate: updateEnabledModels, mutateAsync: updateEnabledModelsAsync } =
useUpdateEnabledModels({ retry: 0 });
const { refreshAllModelInputs } = useRefreshModelInputs();
const { data: enabledModelsData } = useGetEnabledModels();
const overlayToggles = useRef<Record<string, boolean>>({});
const unsentToggles = useRef<Record<string, boolean>>({});
const fallbackModelData = useRef<EnabledModelsResponse | undefined>(
undefined,
);
// After a mutation settles, remove its entries from the overlay — but
// only when the current overlay value still matches what we sent. A
// mismatch means the user re-toggled the same model mid-flight; that
// entry already sits in ``unsentToggles`` for the next flush and must
// not be dropped from the overlay until its own mutation settles.
const clearSentOverlay = useCallback((sent: Record<string, boolean>) => {
for (const [key, value] of Object.entries(sent)) {
if (overlayToggles.current[key] === value) {
delete overlayToggles.current[key];
}
}
if (Object.keys(overlayToggles.current).length === 0) {
fallbackModelData.current = undefined;
}
}, []);
// Shared flush prelude: builds the mutation payload, snapshots the
// pre-toggle cache for rollback, and drains ``unsentToggles`` so a
// follow-up flush triggered by a new user toggle cannot resend what we
// just sent. Returns null when there's nothing to do so callers can
// bail symmetrically.
const buildAndConsumeToggleBatch = useCallback((): ToggleBatch | null => {
if (!providerName) return null;
const togglesToSend = { ...unsentToggles.current };
if (Object.keys(togglesToSend).length === 0) return null;
const updates = Object.entries(togglesToSend).map(
([modelName, enabled]) => ({
provider: providerName,
model_id: modelName,
enabled,
}),
);
const previousData = fallbackModelData.current;
unsentToggles.current = {};
return { updates, previousData, togglesToSend };
}, [providerName]);
// Shared error-path: drain the overlay BEFORE restoring previousData so
// the re-overlay effect (triggered by setQueryData below) can't re-apply
// a stale overlay over the rollback we just performed.
const rollbackToggleBatch = useCallback(
(
togglesToSend: Record<string, boolean>,
previousData: EnabledModelsResponse | undefined,
error: unknown,
) => {
clearSentOverlay(togglesToSend);
if (previousData) {
queryClient.setQueryData(["useGetEnabledModels"], previousData);
}
setErrorData({
title: "Error updating model status",
list: [getErrorMessage(error) || "Failed to update model status"],
});
},
[clearSentOverlay, queryClient, setErrorData],
);
const flushModelToggles = useDebounce(() => {
const batch = buildAndConsumeToggleBatch();
if (!batch) return;
const { updates, previousData, togglesToSend } = batch;
updateEnabledModels(
{ updates },
{
onError: (error: unknown) => {
rollbackToggleBatch(togglesToSend, previousData, error);
},
onSettled: () => {
clearSentOverlay(togglesToSend);
queryClient.invalidateQueries({
queryKey: ["useGetEnabledModels"],
});
queryClient.invalidateQueries({
queryKey: ["useGetModelProviders"],
});
refreshAllModelInputs({ silent: true });
},
},
);
}, 1000);
const flushPendingChanges = useCallback(async () => {
// Cancel the pending debounce timer — we'll send the toggles directly
flushModelToggles.cancel();
const batch = buildAndConsumeToggleBatch();
if (!batch) return;
const { updates, previousData, togglesToSend } = batch;
try {
await updateEnabledModelsAsync({ updates });
clearSentOverlay(togglesToSend);
// Invalidate the affected queries inline so callers don't need to
// bolt this on. The modal's onClose still triggers
// ``refreshAllModelInputs`` afterwards to repopulate per-node
// template options, but ``useGetEnabledModels`` consumers no longer
// depend on the caller for cache freshness.
queryClient.invalidateQueries({ queryKey: ["useGetEnabledModels"] });
queryClient.invalidateQueries({ queryKey: ["useGetModelProviders"] });
} catch (error: unknown) {
rollbackToggleBatch(togglesToSend, previousData, error);
}
}, [
flushModelToggles,
buildAndConsumeToggleBatch,
updateEnabledModelsAsync,
clearSentOverlay,
rollbackToggleBatch,
queryClient,
]);
const handleModelToggle = useCallback(
(modelName: string, enabled: boolean) => {
if (!providerName) return;
// Cancel any in-flight refetch of useGetEnabledModels so its (stale)
// result cannot overwrite the optimistic cache update below. The
// re-overlay effect handles refetches that start AFTER this point;
// ``cancelQueries`` covers the ones already in flight at click time.
void queryClient.cancelQueries({ queryKey: ["useGetEnabledModels"] });
if (Object.keys(overlayToggles.current).length === 0) {
fallbackModelData.current =
queryClient.getQueryData<EnabledModelsResponse>([
"useGetEnabledModels",
]);
}
queryClient.setQueryData<EnabledModelsResponse>(
["useGetEnabledModels"],
(old) => {
if (!old) return old;
return {
...old,
enabled_models: {
...old.enabled_models,
[providerName]: {
...old.enabled_models[providerName],
[modelName]: enabled,
},
},
};
},
);
// Track in BOTH buffers: overlay for UI protection across refetches,
// unsent for the next flush's payload.
overlayToggles.current[modelName] = enabled;
unsentToggles.current[modelName] = enabled;
flushModelToggles();
},
[providerName, queryClient, flushModelToggles],
);
// Re-overlay effect — protects the pending-toggle window in its entirety,
// not just the instant of the click. Any refetch (window focus, remount,
// reconnect, or a stale-time expiry) that lands while ``overlayToggles``
// has entries will surface the server's pre-toggle state into
// ``enabledModelsData``; this effect detects the drift and re-applies the
// pending overlay so the Switch tracks the user's intent through the
// entire debounce + in-flight window. Once ``clearSentOverlay`` drains
// the entry on settle, the next data emission is a no-op.
useEffect(() => {
if (!providerName) return;
if (!enabledModelsData) return;
const overlay = overlayToggles.current;
if (Object.keys(overlay).length === 0) return;
const current = enabledModelsData.enabled_models[providerName] ?? {};
// Loop guard: the ``setQueryData`` below re-emits ``enabledModelsData``
// and re-runs this effect; ``drifted`` must return false on the second
// pass for the recursion to terminate. Don't replace this with an
// unconditional re-apply — the second invocation finds the overlay
// already applied, ``current[model] === enabled`` for every entry, and
// bails. Any future refactor that removes the drift check must
// introduce an equivalent termination condition.
const drifted = Object.entries(overlay).some(
([model, enabled]) => current[model] !== enabled,
);
if (!drifted) return;
queryClient.setQueryData<EnabledModelsResponse>(
["useGetEnabledModels"],
(old) => {
if (!old) return old;
return {
...old,
enabled_models: {
...old.enabled_models,
[providerName]: {
...(old.enabled_models[providerName] ?? {}),
...overlay,
},
},
};
},
);
}, [enabledModelsData, providerName, queryClient]);
return {
handleModelToggle,
flushPendingChanges,
};
};

View File

@ -5,10 +5,8 @@ import {
ProviderVariable,
VARIABLE_CATEGORY,
} from "@/constants/providerConstants";
import { EnabledModelsResponse } from "@/controllers/API/queries/models/use-get-enabled-models";
import { useGetModelProviders } from "@/controllers/API/queries/models/use-get-model-providers";
import { useGetProviderVariables } from "@/controllers/API/queries/models/use-get-provider-variables";
import { useUpdateEnabledModels } from "@/controllers/API/queries/models/use-update-enabled-models";
import { useValidateProvider } from "@/controllers/API/queries/models/use-validate-provider";
import {
useDeleteGlobalVariables,
@ -16,14 +14,25 @@ import {
usePatchGlobalVariables,
usePostGlobalVariables,
} from "@/controllers/API/queries/variables";
import { useDebounce } from "@/hooks/use-debounce";
import { useRefreshModelInputs } from "@/hooks/use-refresh-model-inputs";
import useAlertStore from "@/stores/alertStore";
import { Provider } from "../components/types";
import { useModelToggleQueue } from "./useModelToggleQueue";
// Masked value shown for configured secret fields
const MASKED_VALUE = "••••••••";
// Extract a user-facing message from an error caught from an API call.
// Handles Axios-shaped errors (``response.data.detail``) and standard
// ``Error.message`` without resorting to ``any``.
const getErrorMessage = (error: unknown): string | undefined => {
const e = error as {
response?: { data?: { detail?: string } };
message?: string;
};
return e?.response?.data?.detail || e?.message;
};
interface UseProviderConfigurationOptions {
selectedProvider: Provider | null;
}
@ -100,8 +109,6 @@ export const useProviderConfiguration = ({
const { data: globalVariables = [] } = useGetGlobalVariables();
const { mutateAsync: validateProvider } = useValidateProvider();
const { data: providerVariablesMapping = {} } = useGetProviderVariables();
const { mutate: updateEnabledModels, mutateAsync: updateEnabledModelsAsync } =
useUpdateEnabledModels({ retry: 0 });
const { refreshAllModelInputs } = useRefreshModelInputs();
const { data: modelProviders = [], isFetching: isFetchingModels } =
useGetModelProviders(
@ -342,7 +349,7 @@ export const useProviderConfiguration = ({
setValidationError(result.error || "Validation failed");
return false;
}
} catch (error: any) {
} catch (error: unknown) {
// Ensure minimum 500ms duration even on error
const elapsedTime = Date.now() - startTime;
if (elapsedTime < 500) {
@ -350,7 +357,7 @@ export const useProviderConfiguration = ({
}
setValidationState("invalid");
setValidationError(error?.message || "Validation failed");
setValidationError(getErrorMessage(error) || "Validation failed");
return false;
}
}, [selectedProvider, getVariablesForValidation, validateProvider]);
@ -420,12 +427,12 @@ export const useProviderConfiguration = ({
setIsFetchingAfterSave(true);
clearValuesAfterFetchRef.current = true;
invalidateProviderQueries();
} catch (error: any) {
} catch (error: unknown) {
setValidationFailed(true);
setErrorData({
title: "Error Saving Configuration",
list: [
error?.response?.data?.detail ||
getErrorMessage(error) ||
"An unexpected error occurred. Please try again.",
],
});
@ -488,11 +495,11 @@ export const useProviderConfiguration = ({
setSuccessData({ title: `${syncedSelectedProvider.provider} Activated` });
invalidateProviderQueries();
} catch (error: any) {
} catch (error: unknown) {
setErrorData({
title: "Error Activating Provider",
list: [
error?.response?.data?.detail ||
getErrorMessage(error) ||
"An unexpected error occurred. Please try again.",
],
});
@ -529,11 +536,11 @@ export const useProviderConfiguration = ({
});
setIsFetchingAfterDisconnect(true);
invalidateProviderQueries();
} catch (error: any) {
} catch (error: unknown) {
setErrorData({
title: "Error Disconnecting Provider",
list: [
error?.response?.data?.detail ||
getErrorMessage(error) ||
"An unexpected error occurred. Please try again.",
],
});
@ -547,144 +554,14 @@ export const useProviderConfiguration = ({
invalidateProviderQueries,
]);
const pendingModelToggles = useRef<Record<string, boolean>>({});
const fallbackModelData = useRef<EnabledModelsResponse | undefined>(
undefined,
);
const flushModelToggles = useDebounce(() => {
if (!syncedSelectedProvider?.provider) return;
const providerName = syncedSelectedProvider.provider;
const updates = Object.entries(pendingModelToggles.current).map(
([modelName, enabled]) => ({
provider: providerName,
model_id: modelName,
enabled,
}),
);
if (updates.length === 0) return;
// Capture the fallback data
const previousData = fallbackModelData.current;
// Clear buffer
pendingModelToggles.current = {};
fallbackModelData.current = undefined;
updateEnabledModels(
{ updates },
{
onError: (error: any) => {
if (previousData) {
queryClient.setQueryData(["useGetEnabledModels"], previousData);
}
const errorMessage =
error?.response?.data?.detail ||
error?.message ||
"Failed to update model status";
setErrorData({
title: "Error updating model status",
list: [errorMessage],
});
},
onSettled: () => {
queryClient.invalidateQueries({
queryKey: ["useGetEnabledModels"],
});
queryClient.invalidateQueries({
queryKey: ["useGetModelProviders"],
});
refreshAllModelInputs({ silent: true });
},
},
);
}, 1000);
const flushPendingChanges = useCallback(async () => {
// Cancel the pending debounce timer — we'll send the toggles directly
flushModelToggles.cancel();
if (!syncedSelectedProvider?.provider) return;
const providerName = syncedSelectedProvider.provider;
const toggles = { ...pendingModelToggles.current };
if (Object.keys(toggles).length === 0) return;
const updates = Object.entries(toggles).map(([modelName, enabled]) => ({
provider: providerName,
model_id: modelName,
enabled,
}));
const previousData = fallbackModelData.current;
// Clear buffer
pendingModelToggles.current = {};
fallbackModelData.current = undefined;
try {
await updateEnabledModelsAsync({ updates });
// Mutation succeeded — query invalidation is handled by
// refreshAllModelInputs which runs after this promise resolves.
} catch (error: any) {
// Revert optimistic update on failure
if (previousData) {
queryClient.setQueryData(["useGetEnabledModels"], previousData);
}
const errorMessage =
error?.response?.data?.detail ||
error?.message ||
"Failed to update model status";
setErrorData({
title: "Error updating model status",
list: [errorMessage],
});
}
}, [
flushModelToggles,
syncedSelectedProvider,
queryClient,
updateEnabledModelsAsync,
setErrorData,
]);
const handleModelToggle = useCallback(
(modelName: string, enabled: boolean) => {
if (!syncedSelectedProvider?.provider) return;
const providerName = syncedSelectedProvider.provider;
if (Object.keys(pendingModelToggles.current).length === 0) {
fallbackModelData.current =
queryClient.getQueryData<EnabledModelsResponse>([
"useGetEnabledModels",
]);
}
queryClient.setQueryData<EnabledModelsResponse>(
["useGetEnabledModels"],
(old) => {
if (!old) return old;
return {
...old,
enabled_models: {
...old.enabled_models,
[providerName]: {
...old.enabled_models[providerName],
[modelName]: enabled,
},
},
};
},
);
pendingModelToggles.current[modelName] = enabled;
flushModelToggles();
},
[syncedSelectedProvider, queryClient, flushModelToggles],
);
// Model-toggle queue — overlay buffer, unsent buffer, debounced flush,
// awaitable close-time flush, re-overlay effect. Extracted into its own
// hook so this file stays focused on variable CRUD + provider lifecycle.
// See ``useModelToggleQueue`` for the full design rationale (split
// buffers, drain-before-rollback ordering, re-overlay loop guard).
const { handleModelToggle, flushPendingChanges } = useModelToggleQueue({
providerName: syncedSelectedProvider?.provider,
});
return {
variableValues,

View File

@ -7,7 +7,7 @@
################################
# Use an Alpine-based Python image with uv pre-installed
FROM ghcr.io/astral-sh/uv:python3.12-alpine AS builder
FROM ghcr.io/astral-sh/uv:python3.14-alpine AS builder
# Install build dependencies needed for some Python packages on Alpine
RUN apk add --no-cache build-base libaio-dev linux-headers
@ -47,7 +47,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
# RUNTIME
# Setup user, utilities and copy the virtual environment only
################################
FROM python:3.12-alpine AS runtime
FROM python:3.14-alpine AS runtime
# Create a non-root user
# -D: Don't assign a password

View File

@ -1,12 +1,12 @@
[project]
name = "lfx"
version = "0.4.2"
version = "0.4.3"
description = "Langflow Executor - A lightweight CLI tool for executing and serving Langflow AI flows"
readme = "README.md"
authors = [
{ name = "Gabriel Luiz Freitas Almeida", email = "gabriel@langflow.org" }
]
requires-python = ">=3.10,<3.14"
requires-python = ">=3.10,<3.15"
dependencies = [
"langchain-core>=1.2.28,<2.0.0",
"pandas>=2.0.0,<3.0.0",
@ -39,7 +39,7 @@ dependencies = [
"structlog>=25.4.0,<26.0.0",
"loguru>=0.7.3,<1.0.0",
"langchain~=1.2.0",
"langchain-classic~=1.0.0",
"langchain-classic~=1.0.7",
"validators>=0.34.0,<1.0.0",
"filelock>=3.20.1,<4.0.0",
"pypdf>=6.10.0,<7.0.0",
@ -88,7 +88,7 @@ dev = [
"blockbuster>=1.5.25",
"coverage>=7.9.2",
"hypothesis>=6.136.3",
"pytest>=8.4.1",
"pytest>=9.0.3",
"pytest-asyncio>=0.26.0",
"pytest-cov>=7.0.0",
"ruff>=0.9.10",

File diff suppressed because one or more lines are too long

View File

@ -27,7 +27,10 @@ from lfx.io import (
from lfx.schema.data import Data
from lfx.schema.dotdict import dotdict
from lfx.utils.component_utils import set_current_fields, set_field_advanced, set_field_display
from lfx.utils.ssrf_protection import SSRFProtectionError, validate_url_for_ssrf
# SSRF Protection imports - for preventing Server-Side Request Forgery attacks
from lfx.utils.ssrf_protection import SSRFProtectionError, validate_and_resolve_url
from lfx.utils.ssrf_transport import create_ssrf_protected_client
# Define fields for each mode
MODE_FIELDS = {
@ -422,7 +425,22 @@ class APIRequestComponent(Component):
return {}
async def make_api_request(self) -> Data:
"""Make HTTP request with optimized parameter handling."""
"""Make HTTP request with SSRF protection and DNS pinning.
This method implements comprehensive SSRF (Server-Side Request Forgery) protection
using DNS pinning to prevent DNS rebinding attacks. The protection works by:
1. Validating the URL and resolving DNS during security check
2. Pinning the validated IP address
3. Forcing the HTTP client to use the pinned IP for the actual request
4. Ignoring any subsequent DNS changes (prevents rebinding attacks)
Returns:
Data: Response data from the HTTP request
Raises:
ValueError: If URL is invalid or blocked by SSRF protection
"""
# Extract request parameters
method = self.method
url = self.url_input.strip() if isinstance(self.url_input, str) else ""
headers = self.headers or {}
@ -432,7 +450,8 @@ class APIRequestComponent(Component):
save_to_file = self.save_to_file
include_httpx_metadata = self.include_httpx_metadata
# Security warning when redirects are enabled
# Security warning: HTTP redirects can bypass SSRF protection
# A public URL could redirect to an internal resource
if follow_redirects:
self.log(
"Security Warning: HTTP redirects are enabled. This may allow SSRF bypass attacks "
@ -440,51 +459,122 @@ class APIRequestComponent(Component):
"Only enable this if you trust the target server."
)
# if self.mode == "cURL" and self.curl_input:
# self._build_config = self.parse_curl(self.curl_input, dotdict())
# # After parsing curl, get the normalized URL
# url = self._build_config["url_input"]["value"]
# Normalize URL before validation
# Normalize URL (add https:// if no protocol specified)
url = self._normalize_url(url)
# Validate URL
# Basic URL format validation
if not validators.url(url):
msg = f"Invalid URL provided: {url}"
raise ValueError(msg)
# SSRF Protection: Validate URL to prevent access to internal resources
# TODO: In next major version (2.0), remove warn_only=True to enforce blocking
# ============================================================================
# SSRF Protection with DNS Pinning
# ============================================================================
# This prevents DNS rebinding attacks by:
# 1. Resolving DNS and validating IPs during security check
# 2. Pinning the validated IP address
# 3. Using a custom HTTP transport that forces use of the pinned IP
# 4. Ignoring any new DNS resolutions (prevents rebinding)
#
# Without DNS pinning, an attacker could:
# - First DNS lookup: returns public IP (passes validation)
# - Second DNS lookup: returns internal IP (bypasses protection)
# - Attack succeeds: accesses internal services
#
# With DNS pinning:
# - First DNS lookup: returns public IP (passes validation)
# - IP is pinned: "example.com = 93.184.216.34"
# - HTTP request: uses pinned IP directly (no new DNS lookup)
# - Attack fails: even if DNS changes, we use the validated IP
# ============================================================================
try:
validate_url_for_ssrf(url, warn_only=True)
# Validate URL and get validated IPs for DNS pinning
_validated_url, validated_ips = validate_and_resolve_url(url)
# Log DNS pinning information for security auditing
if validated_ips:
self.log(f"SSRF Protection: Using DNS pinning with {len(validated_ips)} validated IP(s)")
except SSRFProtectionError as e:
# This will only raise if SSRF protection is enabled and warn_only=False
# SSRF protection blocked the request (private IP, internal network, etc.)
msg = f"SSRF Protection: {e}"
raise ValueError(msg) from e
# Process query parameters
# Process query parameters (from string or Data object)
if isinstance(self.query_params, str):
query_params = dict(parse_qsl(self.query_params))
else:
query_params = self.query_params.data if self.query_params else {}
# Process headers and body
# Process headers and body into proper format
headers = self._process_headers(headers)
body = self._process_body(body)
url = self.add_query_params(url, query_params)
async with httpx.AsyncClient() as client:
result = await self.make_request(
client,
method,
url,
headers,
body,
timeout,
follow_redirects=follow_redirects,
save_to_file=save_to_file,
include_httpx_metadata=include_httpx_metadata,
)
# ============================================================================
# Create HTTP Client with DNS Pinning (if SSRF protection enabled)
# ============================================================================
from lfx.utils.ssrf_protection import is_ssrf_protection_enabled
if is_ssrf_protection_enabled() and validated_ips:
# SSRF protection is enabled and DNS pinning is needed
# Extract hostname from the final URL (after query params added)
hostname = urlparse(url).hostname
if hostname:
# Create client with DNS pinning to prevent rebinding attacks
# The custom transport will try validated IPs in order (supports dual-stack/load balancing)
# while preserving the Host header for virtual hosting/SNI
async with create_ssrf_protected_client(
hostname=hostname,
validated_ips=validated_ips, # Pass all validated IPs
) as client:
result = await self.make_request(
client,
method,
url,
headers,
body,
timeout,
follow_redirects=follow_redirects,
save_to_file=save_to_file,
include_httpx_metadata=include_httpx_metadata,
)
else:
# Hostname extraction failed - fallback to normal client
# This should rarely happen as URL was already validated
async with httpx.AsyncClient() as client:
result = await self.make_request(
client,
method,
url,
headers,
body,
timeout,
follow_redirects=follow_redirects,
save_to_file=save_to_file,
include_httpx_metadata=include_httpx_metadata,
)
else:
# No DNS pinning needed - use normal client
# This happens when SSRF protection is disabled or host is allowlisted
# - SSRF protection is disabled
# - Host is in the allowlist (e.g., localhost for Ollama)
# - Direct IP address was used (no DNS to pin)
async with httpx.AsyncClient() as client:
result = await self.make_request(
client,
method,
url,
headers,
body,
timeout,
follow_redirects=follow_redirects,
save_to_file=save_to_file,
include_httpx_metadata=include_httpx_metadata,
)
self.status = result
return result

View File

@ -48,8 +48,9 @@ class CalculatorToolComponent(LCToolComponent):
)
def _eval_expr(self, node):
if isinstance(node, ast.Num):
return node.n
# ast.Num was removed in Python 3.14; ast.Constant covers numeric literals since 3.8.
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return node.value
if isinstance(node, ast.BinOp):
left_val = self._eval_expr(node.left)
right_val = self._eval_expr(node.right)

View File

@ -43,11 +43,6 @@ class CalculatorComponent(Component):
return float(node.value)
error_msg = f"Unsupported constant type: {type(node.value).__name__}"
raise TypeError(error_msg)
if isinstance(node, ast.Num): # For backwards compatibility
if isinstance(node.n, int | float):
return float(node.n)
error_msg = f"Unsupported number type: {type(node.n).__name__}"
raise TypeError(error_msg)
if isinstance(node, ast.BinOp):
op_type = type(node.op)

View File

@ -398,12 +398,12 @@ class Settings(BaseSettings):
use hardware-level isolation to restrict access."""
# SSRF Protection
ssrf_protection_enabled: bool = False
ssrf_protection_enabled: bool = True
"""If set to True, Langflow will enable SSRF (Server-Side Request Forgery) protection.
When enabled, blocks requests to private IP ranges, localhost, and cloud metadata endpoints.
When False (default), no URL validation is performed, allowing requests to any destination
When False, no URL validation is performed, allowing requests to any destination
including internal services, private networks, and cloud metadata endpoints.
Default is False for backward compatibility. In v2.0, this will be changed to True.
Default is True to protect against SSRF attacks including DNS rebinding.
Note: When ssrf_protection_enabled is disabled, the ssrf_allowed_hosts setting is ignored and has no effect."""
ssrf_allowed_hosts: list[str] = []
@ -444,7 +444,15 @@ class Settings(BaseSettings):
@field_validator("cors_origins", mode="before")
@classmethod
def validate_cors_origins(cls, value):
"""Convert comma-separated string to list if needed."""
"""Convert comma-separated string to list if needed.
Pydantic-settings on Python 3.14 parses the env var "*" into ["*"]
before this validator runs (the union list[str] | str resolves
differently). Collapse that back to the bare-string wildcard so
downstream consumers see the same shape on every Python version.
"""
if isinstance(value, list) and value == ["*"]:
return "*"
if isinstance(value, str) and value != "*":
if "," in value:
# Convert comma-separated string to list

View File

@ -34,7 +34,7 @@ class KeyedWorkerLockManager:
"""A manager for acquiring locks between workers based on a key."""
def __init__(self) -> None:
self.locks_dir = Path(user_cache_dir("langflow"), ensure_exists=True) / "worker_locks"
self.locks_dir = Path(user_cache_dir("langflow", ensure_exists=True)) / "worker_locks"
@staticmethod
def _validate_key(key: str) -> bool:

View File

@ -13,15 +13,9 @@ IMPORTANT: HTTP Redirects
See: https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html
Configuration:
LANGFLOW_SSRF_PROTECTION_ENABLED: Enable/disable SSRF protection (default: false)
TODO: Change default to true in next major version (2.0)
LANGFLOW_SSRF_PROTECTION_ENABLED: Enable/disable SSRF protection (default: true)
LANGFLOW_SSRF_ALLOWED_HOSTS: Comma-separated list of allowed hosts/CIDR ranges
Examples: "192.168.1.0/24,internal-api.company.local,10.0.0.5"
TODO: In next major version (2.0):
- Change LANGFLOW_SSRF_PROTECTION_ENABLED default to "true"
- Remove warning-only mode and enforce blocking
- Update documentation to reflect breaking change
"""
import functools
@ -83,6 +77,16 @@ def is_ssrf_protection_enabled() -> bool:
Returns:
bool: True if SSRF protection is enabled, False otherwise.
"""
# Read directly from environment variable to support test mocking with patch.dict()
# This ensures tests can override the protection state without settings service caching issues
import os
env_value = os.getenv("LANGFLOW_SSRF_PROTECTION_ENABLED")
if env_value is not None:
# Environment variable is set - use it (supports test mocking)
return env_value.lower() in ("true", "1", "yes", "on")
# Fall back to settings service for non-test scenarios
return get_settings_service().settings.ssrf_protection_enabled
@ -92,11 +96,23 @@ def get_allowed_hosts() -> list[str]:
Returns:
list[str]: Stripped hostnames or CIDR blocks from settings, or empty list if unset.
"""
allowed_hosts = get_settings_service().settings.ssrf_allowed_hosts
if not allowed_hosts:
return []
# ssrf_allowed_hosts is already a list[str], just clean and filter entries
return [host.strip() for host in allowed_hosts if host and host.strip()]
# Read directly from environment variable to support test mocking with patch.dict()
# This ensures tests can override the allowlist without settings service caching issues
import os
env_value = os.getenv("LANGFLOW_SSRF_ALLOWED_HOSTS", "")
if env_value:
# Parse comma-separated list from environment variable
return [host.strip() for host in env_value.split(",") if host.strip()]
# Fall back to settings service for non-test scenarios
settings_service = get_settings_service()
if settings_service:
allowed_hosts = settings_service.settings.ssrf_allowed_hosts
if allowed_hosts:
return [host.strip() for host in allowed_hosts if host and host.strip()]
return []
def is_host_allowed(hostname: str, ip: str | None = None) -> bool:
@ -271,7 +287,6 @@ def _validate_direct_ip_address(hostname: str) -> bool:
if is_ip_blocked(ip_obj):
msg = (
f"Access to IP address {hostname} is blocked by SSRF protection. "
"Requests to private/internal IP ranges are not allowed for security reasons. "
"To allow this IP, add it to LANGFLOW_SSRF_ALLOWED_HOSTS environment variable."
)
raise SSRFProtectionError(msg)
@ -313,15 +328,12 @@ def _validate_hostname_resolution(hostname: str) -> None:
if blocked_ips:
msg = (
f"Hostname {hostname} resolves to blocked IP address(es): {', '.join(blocked_ips)}. "
"Requests to private/internal IP ranges are not allowed for security reasons. "
"This protection prevents access to internal services, cloud metadata endpoints "
"(e.g., AWS 169.254.169.254), and other sensitive internal resources. "
"To allow this hostname, add it to LANGFLOW_SSRF_ALLOWED_HOSTS environment variable."
)
raise SSRFProtectionError(msg)
def validate_url_for_ssrf(url: str, *, warn_only: bool = True) -> None:
def validate_url_for_ssrf(url: str, *, warn_only: bool = False) -> None:
"""Validate a URL to prevent SSRF attacks.
This function performs the following checks:
@ -333,8 +345,7 @@ def validate_url_for_ssrf(url: str, *, warn_only: bool = True) -> None:
Args:
url: URL to validate
warn_only: If True, only log warnings instead of raising errors (default: True)
TODO: Change default to False in next major version (2.0)
warn_only: If True, only log warnings instead of raising errors (default: False)
Raises:
SSRFProtectionError: If the URL is blocked due to SSRF protection (only if warn_only=False)
@ -382,3 +393,187 @@ def validate_url_for_ssrf(url: str, *, warn_only: bool = True) -> None:
)
return
raise
def validate_and_resolve_url(url: str) -> tuple[str, list[str]]:
"""Validate URL for SSRF and return validated IP addresses for DNS pinning.
This function is the core of DNS pinning-based SSRF protection. It performs
comprehensive validation and returns the validated IP addresses that should
be used for the actual HTTP request, preventing DNS rebinding attacks.
DNS Rebinding Attack Prevention:
Without DNS pinning, an attacker can exploit the time gap between validation
and the actual HTTP request:
1. Validation: DNS returns public IP (8.8.8.8) → passes security check
2. [Attacker changes DNS with TTL=0]
3. HTTP request: DNS returns internal IP (127.0.0.1) → bypasses protection
With DNS pinning (this function):
1. Validation: DNS returns public IP (8.8.8.8) → passes security check
2. Function returns: (url, ['8.8.8.8']) → IP is pinned
3. HTTP request: Uses pinned IP directly → no new DNS lookup → secure
Args:
url: URL to validate (e.g., "http://example.com/api")
Returns:
Tuple of (original_url, list_of_validated_ips):
- original_url: The input URL unchanged
- list_of_validated_ips: List of validated IP addresses to use for DNS pinning
Returns empty list if:
- SSRF protection is disabled
- Host is in the allowlist (e.g., localhost for Ollama)
- URL scheme is not http/https
Raises:
SSRFProtectionError: If URL is blocked by SSRF protection
ValueError: If URL format is invalid
Example:
>>> # Public domain - returns validated IPs for pinning
>>> url, ips = validate_and_resolve_url("http://example.com")
>>> print(ips) # ['93.184.216.34']
>>> # Localhost (if in allowlist) - returns empty list (no pinning needed)
>>> url, ips = validate_and_resolve_url("http://localhost:8080")
>>> print(ips) # []
>>> # Private IP - raises SSRFProtectionError
>>> url, ips = validate_and_resolve_url("http://192.168.1.1")
# Raises: SSRFProtectionError("Access to IP address 192.168.1.1 is blocked...")
"""
# ============================================================================
# Step 1: Check if SSRF protection is enabled
# ============================================================================
if not is_ssrf_protection_enabled():
# Protection is disabled - return empty list (no DNS pinning)
return url, []
# ============================================================================
# Step 2: Parse and validate URL format
# ============================================================================
try:
parsed = urlparse(url)
except Exception as e:
msg = f"Invalid URL format: {e}"
raise ValueError(msg) from e
try:
# ============================================================================
# Step 3: Validate URL scheme (only http/https allowed)
# ============================================================================
_validate_url_scheme(parsed.scheme)
if parsed.scheme not in ("http", "https"):
# Non-HTTP schemes (ftp, file, etc.) are not subject to SSRF protection
return url, []
# ============================================================================
# Step 4: Extract and validate hostname
# ============================================================================
hostname = _validate_hostname_exists(parsed.hostname)
# ============================================================================
# Step 5: Check allowlist (early return for trusted hosts)
# ============================================================================
# Allowlisted hosts bypass all SSRF checks and DNS pinning
# This is used for legitimate internal services like Ollama (localhost)
if is_host_allowed(hostname):
logger.debug(f"Hostname {hostname} is in allowlist, bypassing SSRF checks and DNS pinning")
return url, []
# ============================================================================
# Step 6: Handle direct IP addresses
# ============================================================================
# Check if the hostname is already an IP address (no DNS resolution needed)
try:
ip_obj = ipaddress.ip_address(hostname)
# Check if this specific IP is in the allowlist
if is_host_allowed(hostname, str(ip_obj)):
logger.debug(f"IP {hostname} is in allowlist")
return url, []
# Check if IP is in blocked ranges (private IPs, localhost, etc.)
if is_ip_blocked(ip_obj):
msg = (
f"Access to IP address {hostname} is blocked by SSRF protection. "
"To allow this IP, add it to LANGFLOW_SSRF_ALLOWED_HOSTS environment variable."
)
raise SSRFProtectionError(msg)
# Direct IP is public and allowed - return it for DNS pinning
# (Even though it's already an IP, we return it for consistency)
logger.debug(f"Direct IP {hostname} validated, will use for DNS pinning")
return url, [hostname] # noqa: TRY300
except ValueError:
# Not an IP address, it's a hostname - continue to DNS resolution
pass
# ============================================================================
# Step 7: Resolve hostname to IP addresses
# ============================================================================
# This is the critical step for DNS pinning - we resolve DNS here during
# validation, and the returned IPs will be used for the actual HTTP request
resolved_ips = resolve_hostname(hostname)
blocked_ips = []
# ============================================================================
# Step 8: Validate all resolved IPs
# ============================================================================
# Security: We must check ALL resolved IPs before making any decisions.
# A hostname might resolve to multiple IPs (e.g., [8.8.8.8, 192.168.1.1]).
# If we return early on the first allowlisted IP, we skip validation of
# remaining IPs, which could include blocked/internal addresses.
#
# Strategy:
# 1. Collect all allowlisted IPs and all blocked IPs
# 2. If ANY IP is blocked → block the entire hostname (security first)
# 3. If some IPs are allowlisted but others are not → use only allowlisted IPs for pinning
# 4. If all IPs are public (none blocked, none allowlisted) → use all for pinning
allowed_ips = []
for ip in resolved_ips:
# Check if this resolved IP is in the allowlist
if is_host_allowed(hostname, ip):
allowed_ips.append(ip)
# Check if IP is in blocked ranges
elif is_ip_blocked(ip):
blocked_ips.append(ip)
# ============================================================================
# Step 9: Block if any resolved IPs are private/internal
# ============================================================================
# Security: If ANY resolved IP is blocked, we block the entire hostname.
# This prevents attacks where a hostname resolves to both safe and unsafe IPs.
if blocked_ips:
msg = (
f"Hostname {hostname} resolves to blocked IP address(es): {', '.join(blocked_ips)}. "
"To allow this hostname, add it to LANGFLOW_SSRF_ALLOWED_HOSTS environment variable."
)
raise SSRFProtectionError(msg)
# ============================================================================
# Step 9b: Handle partially allowlisted IPs
# ============================================================================
# If some (but not all) IPs are allowlisted, use only the allowlisted ones for pinning
if allowed_ips:
logger.debug(
f"Hostname {hostname} has {len(allowed_ips)} allowlisted IP(s) out of {len(resolved_ips)} total. "
f"Using allowlisted IPs for DNS pinning: {allowed_ips}"
)
return url, allowed_ips
# ============================================================================
# Step 10: Return validated IPs for DNS pinning
# ============================================================================
# All IPs are public and safe - return them for DNS pinning
# The HTTP client will use these IPs directly, preventing DNS rebinding
logger.debug(f"Hostname {hostname} validated, resolved to {resolved_ips}, will use for DNS pinning")
return url, resolved_ips # noqa: TRY300
except SSRFProtectionError:
# Re-raise SSRF errors as-is
raise
except Exception as e:
# Wrap unexpected errors in SSRFProtectionError
msg = f"Error validating URL: {e}"
raise SSRFProtectionError(msg) from e

View File

@ -0,0 +1,242 @@
"""Custom httpx transport with DNS pinning for SSRF protection.
This module provides a custom httpx transport that pins DNS resolution to validated
IP addresses, preventing DNS rebinding attacks that could bypass SSRF protection.
The implementation uses a custom AsyncNetworkBackend to intercept TCP connections
and connect to the pinned IP address while preserving the original hostname for
TLS SNI (Server Name Indication) and certificate verification.
"""
import ssl
from collections.abc import Iterable
import httpcore
import httpx
from httpx import URL, Proxy
from httpx._config import create_ssl_context
from lfx.logging import logger
class DNSPinningNetworkBackend(httpcore.AsyncNetworkBackend):
"""Network backend that pins DNS resolution to validated IP addresses.
This backend intercepts TCP connection attempts and redirects them to pinned
IP addresses while preserving the original hostname for TLS SNI and certificate
verification. This prevents DNS rebinding attacks without breaking HTTPS.
How it works:
1. When httpcore tries to connect to a hostname, we intercept the connect_tcp() call
2. If the hostname has a pinned IP, we connect to that IP instead
3. The original hostname is preserved for TLS handshake (SNI) and cert verification
4. This prevents DNS rebinding while maintaining full HTTPS compatibility
"""
def __init__(self, pinned_ips: dict[str, list[str]], backend: httpcore.AsyncNetworkBackend | None = None):
"""Initialize the DNS pinning backend.
Args:
pinned_ips: Dictionary mapping hostnames to list of validated IP addresses
backend: Underlying network backend (defaults to AnyIOBackend for asyncio)
"""
self.pinned_ips = pinned_ips
# Use httpcore's default async backend (AnyIOBackend) if none provided
# This is the public API recommended in httpcore documentation
if backend is None:
backend = httpcore.AnyIOBackend()
self._backend = backend
logger.debug(f"Created DNS pinning network backend with pinned IPs: {pinned_ips}")
async def connect_tcp(
self,
host: str,
port: int,
timeout: float | None = None,
local_address: str | None = None,
socket_options: Iterable[httpcore.SOCKET_OPTION] | None = None,
) -> httpcore.AsyncNetworkStream:
"""Connect to TCP socket, using pinned IP if available.
This method intercepts connection attempts and redirects to pinned IPs
while preserving the original hostname for TLS.
Args:
host: Hostname to connect to (may be replaced with pinned IP)
port: Port number
timeout: Connection timeout
local_address: Local address to bind to
socket_options: Socket options
Returns:
Network stream for the connection
"""
# Check if this hostname has pinned IPs
if host in self.pinned_ips:
pinned_ips = self.pinned_ips[host]
# Security: If host is in pinned_ips but list is empty, fail rather than bypass
if not pinned_ips:
msg = f"DNS pinning: Host {host} is marked for pinning but has no pinned IPs"
logger.error(msg)
raise RuntimeError(msg)
logger.debug(f"DNS pinning: Connecting to pinned IPs {pinned_ips} for hostname {host}")
# Try each pinned IP in order (supports dual-stack and load balancing)
# The TLS layer will still use the original hostname for SNI and cert verification
last_error = None
for pinned_ip in pinned_ips:
try:
logger.debug(f"DNS pinning: Attempting connection to {pinned_ip}")
return await self._backend.connect_tcp(
host=pinned_ip,
port=port,
timeout=timeout,
local_address=local_address,
socket_options=socket_options,
)
except (OSError, TimeoutError) as e:
last_error = e
logger.debug(f"DNS pinning: Failed to connect to {pinned_ip}: {e}")
continue
# All pinned IPs failed, raise the last error
# This should never be None since we checked for empty list above
if last_error is None:
msg = f"DNS pinning: All pinned IPs failed for {host} but no error was captured"
raise RuntimeError(msg)
raise last_error
# No pinned IP, use normal connection
return await self._backend.connect_tcp(
host=host,
port=port,
timeout=timeout,
local_address=local_address,
socket_options=socket_options,
)
async def connect_unix_socket(
self,
path: str,
timeout: float | None = None,
socket_options: Iterable[httpcore.SOCKET_OPTION] | None = None,
) -> httpcore.AsyncNetworkStream:
"""Connect to Unix socket (pass through to underlying backend)."""
return await self._backend.connect_unix_socket(
path=path,
timeout=timeout,
socket_options=socket_options,
)
async def sleep(self, seconds: float) -> None:
"""Sleep for specified duration (pass through to underlying backend)."""
await self._backend.sleep(seconds)
class SSRFProtectedTransport(httpx.AsyncHTTPTransport):
"""HTTP transport that pins DNS resolution to validated IPs.
This transport prevents DNS rebinding attacks by using a custom network backend
that connects to pinned IP addresses while preserving the original hostname for
TLS SNI and certificate verification.
Unlike the naive approach of rewriting URLs (which breaks HTTPS), this implementation
works at the network layer to ensure both security and compatibility.
Example:
>>> pinned_ips = {"example.com": "93.184.216.34"}
>>> transport = SSRFProtectedTransport(pinned_ips=pinned_ips)
>>> async with httpx.AsyncClient(transport=transport) as client:
... # Request to example.com will connect to 93.184.216.34
... # But TLS will still verify against example.com certificate
... response = await client.get("https://example.com/path")
"""
def __init__(
self,
pinned_ips: dict[str, list[str]],
verify: bool | str | ssl.SSLContext = True, # noqa: FBT001, FBT002
cert: tuple[str, str] | tuple[str, str, str] | str | None = None,
trust_env: bool = True, # noqa: FBT001, FBT002
http1: bool = True, # noqa: FBT001, FBT002
http2: bool = False, # noqa: FBT001, FBT002
limits: httpx.Limits = httpx.Limits(), # noqa: B008
proxy: httpx._types.ProxyTypes | None = None,
uds: str | None = None,
local_address: str | None = None,
retries: int = 0,
socket_options: Iterable[httpcore.SOCKET_OPTION] | None = None,
):
"""Initialize transport with pinned DNS mappings.
Args:
pinned_ips: Dictionary mapping hostnames to list of validated IP addresses.
Example: {"example.com": ["93.184.216.34", "2606:2800:220:1:248:1893:25c8:1946"]}
verify: SSL verification settings
cert: Client certificate
trust_env: Whether to trust environment variables for proxy config
http1: Enable HTTP/1.1
http2: Enable HTTP/2
limits: Connection pool limits
proxy: Proxy configuration
uds: Unix domain socket path
local_address: Local address to bind to
retries: Number of retries
socket_options: Socket options
"""
# Create custom network backend with DNS pinning
network_backend = DNSPinningNetworkBackend(pinned_ips=pinned_ips)
# Create SSL context (same as parent class)
ssl_context = create_ssl_context(verify=verify, cert=cert, trust_env=trust_env)
# Handle proxy (same as parent class)
if proxy is not None:
proxy = Proxy(url=proxy) if isinstance(proxy, (str, URL)) else proxy
# Create pool with our custom network backend
# We replicate the parent's logic but add network_backend parameter
if proxy is None:
self._pool = httpcore.AsyncConnectionPool(
ssl_context=ssl_context,
max_connections=limits.max_connections,
max_keepalive_connections=limits.max_keepalive_connections,
keepalive_expiry=limits.keepalive_expiry,
http1=http1,
http2=http2,
uds=uds,
local_address=local_address,
retries=retries,
socket_options=socket_options,
network_backend=network_backend, # Our custom backend!
)
else:
# For proxy scenarios, we'd need to handle HTTPProxy/SOCKSProxy
# For now, raise an error as DNS pinning with proxies needs special handling
msg = "DNS pinning with proxies is not currently supported"
raise NotImplementedError(msg)
self.pinned_ips = pinned_ips
logger.debug(f"Created SSRF protected transport with pinned IPs: {pinned_ips}")
def create_ssrf_protected_client(
hostname: str, validated_ips: list[str] | tuple[str, ...], **client_kwargs
) -> httpx.AsyncClient:
"""Create an httpx client with DNS pinning for SSRF protection.
Args:
hostname: The hostname to pin
validated_ips: List of validated IP addresses to use for this hostname.
IPs will be tried in order for dual-stack/load-balanced hosts.
**client_kwargs: Additional arguments for AsyncClient (e.g., timeout, headers)
Returns:
Configured AsyncClient with DNS pinning
"""
# Convert to list if tuple
ip_list = list(validated_ips) if isinstance(validated_ips, tuple) else validated_ips
transport = SSRFProtectedTransport(pinned_ips={hostname: ip_list})
return httpx.AsyncClient(transport=transport, **client_kwargs)

View File

@ -4,6 +4,14 @@ import sys
from unittest.mock import MagicMock, Mock, patch
import pytest
try:
import langchain_ibm # noqa: F401
except ImportError:
# langchain-ibm is gated to python_version<'3.14' in pyproject.toml because
# upstream pins exclude 3.14. Skip these tests on 3.14 until upstream adapts.
pytest.skip("langchain-ibm not available", allow_module_level=True)
from lfx.schema.dotdict import dotdict
# Mock the langchain_ibm module before importing the component

View File

@ -4,6 +4,16 @@ import sys
from unittest.mock import MagicMock, Mock, patch
import pytest
try:
import ibm_watsonx_ai # noqa: F401
import langchain_ibm # noqa: F401
except ImportError:
# langchain-ibm and ibm-watsonx-ai are gated to python_version<'3.14' in
# pyproject.toml because upstream pins exclude 3.14. Skip these tests on
# 3.14 until upstream adapts.
pytest.skip("langchain-ibm / ibm-watsonx-ai not available", allow_module_level=True)
from lfx.schema.dotdict import dotdict
# Mock the required modules before importing the component

View File

@ -1,9 +1,9 @@
[project]
name = "langflow-sdk"
version = "0.1.2"
version = "0.1.3"
description = "Python SDK for the Langflow REST API"
readme = "README.md"
requires-python = ">=3.10,<3.14"
requires-python = ">=3.10,<3.15"
license = "MIT"
keywords = ["langflow", "sdk", "ai", "workflow"]
@ -17,7 +17,7 @@ dependencies = [
[project.optional-dependencies]
testing = [
"pytest>=8.0",
"pytest>=9.0.3",
"pytest-asyncio>=0.26.0",
]
@ -45,7 +45,7 @@ markers = [
[dependency-groups]
dev = [
"pytest>=8.4.1",
"pytest>=9.0.3",
"pytest-asyncio>=0.26.0",
"pytest-cov>=7.0.0",
"respx>=0.21.0",

2642
uv.lock generated

File diff suppressed because it is too large Load Diff