mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-24 02:05:11 +08:00
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:
@ -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
|
||||
|
||||
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
@ -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:
|
||||
|
||||
70
.github/workflows/cross-platform-test.yml
vendored
70
.github/workflows/cross-platform-test.yml
vendored
@ -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
|
||||
|
||||
|
||||
2
.github/workflows/integration_tests.yml
vendored
2
.github/workflows/integration_tests.yml
vendored
@ -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
|
||||
|
||||
1
.github/workflows/lint-py.yml
vendored
1
.github/workflows/lint-py.yml
vendored
@ -19,6 +19,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
python-version:
|
||||
- "3.14"
|
||||
- "3.13"
|
||||
- "3.12"
|
||||
- "3.11"
|
||||
|
||||
2
.github/workflows/nightly_build.yml
vendored
2
.github/workflows/nightly_build.yml
vendored
@ -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:
|
||||
|
||||
12
.github/workflows/python_test.yml
vendored
12
.github/workflows/python_test.yml
vendored
@ -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
|
||||
|
||||
2
.github/workflows/release-lfx.yml
vendored
2
.github/workflows/release-lfx.yml
vendored
@ -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
|
||||
|
||||
7
.github/workflows/release.yml
vendored
7
.github/workflows/release.yml
vendored
@ -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:
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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"]
|
||||
|
||||
|
||||
@ -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 \
|
||||
|
||||
@ -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 \
|
||||
|
||||
@ -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 \
|
||||
|
||||
@ -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 \
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
124
docs/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -1327,7 +1327,7 @@
|
||||
},
|
||||
{
|
||||
"name": "googleapiclient",
|
||||
"version": "2.194.0"
|
||||
"version": "2.195.0"
|
||||
}
|
||||
],
|
||||
"total_dependencies": 4
|
||||
|
||||
@ -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
@ -949,7 +949,7 @@
|
||||
},
|
||||
{
|
||||
"name": "googleapiclient",
|
||||
"version": "2.194.0"
|
||||
"version": "2.195.0"
|
||||
}
|
||||
],
|
||||
"total_dependencies": 4
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -2641,7 +2641,7 @@
|
||||
},
|
||||
{
|
||||
"name": "googleapiclient",
|
||||
"version": "2.194.0"
|
||||
"version": "2.195.0"
|
||||
}
|
||||
],
|
||||
"total_dependencies": 4
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -1643,7 +1643,7 @@
|
||||
},
|
||||
{
|
||||
"name": "googleapiclient",
|
||||
"version": "2.194.0"
|
||||
"version": "2.195.0"
|
||||
}
|
||||
],
|
||||
"total_dependencies": 4
|
||||
|
||||
@ -274,7 +274,7 @@
|
||||
},
|
||||
{
|
||||
"name": "googleapiclient",
|
||||
"version": "2.194.0"
|
||||
"version": "2.195.0"
|
||||
},
|
||||
{
|
||||
"name": "lfx",
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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."""
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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")
|
||||
|
||||
55
src/frontend/package-lock.json
generated
55
src/frontend/package-lock.json
generated
@ -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 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
242
src/lfx/src/lfx/utils/ssrf_transport.py
Normal file
242
src/lfx/src/lfx/utils/ssrf_transport.py
Normal 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)
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
Reference in New Issue
Block a user