├── .dockerignore ├── .env.example ├── .github ├── PULL_REQUEST_TEMPLATE.md ├── SECURITY.md ├── mergify.yml ├── scripts │ └── release.sh └── workflows │ ├── actionlint.yml │ ├── cd.yml │ ├── ci-images-dryrun.yml │ ├── ci.yml │ ├── images.yml │ ├── job-build.yml │ ├── job-checks.yml │ ├── job-image.yml │ └── pypi.yml ├── .gitignore ├── .markdownlint-cli2.yaml ├── .pre-commit-config.yaml ├── .python-version ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Containerfile ├── LICENSE ├── MAINTAINERS.md ├── Makefile ├── README.md ├── docling_serve ├── __init__.py ├── __main__.py ├── app.py ├── datamodel │ ├── __init__.py │ ├── callback.py │ ├── convert.py │ ├── engines.py │ ├── kfp.py │ ├── requests.py │ ├── responses.py │ ├── task.py │ └── task_meta.py ├── docling_conversion.py ├── engines │ ├── __init__.py │ ├── async_kfp │ │ ├── __init__.py │ │ ├── kfp_pipeline.py │ │ ├── notify.py │ │ └── orchestrator.py │ ├── async_local │ │ ├── __init__.py │ │ ├── orchestrator.py │ │ └── worker.py │ ├── async_orchestrator.py │ ├── async_orchestrator_factory.py │ ├── base_orchestrator.py │ └── block_local │ │ └── __init__.py ├── gradio_ui.py ├── helper_functions.py ├── py.typed ├── response_preparation.py ├── settings.py └── storage.py ├── docs ├── README.md ├── assets │ └── docling-serve-pic.png ├── configuration.md ├── deploy-examples │ ├── compose-gpu.yaml │ ├── docling-model-cache-deployment.yaml │ ├── docling-model-cache-job.yaml │ ├── docling-model-cache-pvc.yaml │ ├── docling-serve-oauth.yaml │ ├── docling-serve-replicas-w-sticky-sessions.yaml │ └── docling-serve-simple.yaml ├── deployment.md ├── development.md ├── pre-loading-models.md └── usage.md ├── img ├── swagger.png ├── ui-input.png └── ui-output.png ├── os-packages.txt ├── pyproject.toml ├── tests ├── 2206.01062v1.pdf ├── 2408.09869v5.pdf ├── __init__.py ├── test_1-file-all-outputs.py ├── test_1-file-async.py ├── test_1-url-all-outputs.py ├── test_1-url-async-ws.py ├── test_1-url-async.py ├── test_2-files-all-outputs.py ├── test_2-urls-all-outputs.py ├── test_2-urls-async-all-outputs.py ├── test_fastapi_endpoints.py ├── test_file_opts.py ├── test_options_serialization.py └── test_results_clear.py └── uv.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | # Ignore Python cache files 2 | __pycache__/ 3 | **/__pycache__/ 4 | *.pyc 5 | *.pyo 6 | *.pyd 7 | 8 | # Ignore virtual environments 9 | env/ 10 | venv/ 11 | 12 | # Ignore development artifacts 13 | *.log 14 | *.db 15 | *.sqlite3 16 | 17 | # Ignore configuration and sensitive files 18 | **/.env 19 | *.env 20 | *.ini 21 | *.cfg 22 | 23 | # Ignore IDE and editor settings 24 | .vscode/ 25 | .idea/ 26 | *.swp 27 | *.swo 28 | 29 | # Ignore Git files 30 | .git/ 31 | .gitignore 32 | 33 | # Ignore Docker files themselves (optional if not needed in the image) 34 | .dockerignore 35 | Dockerfile* 36 | 37 | # Ignore build artifacts (if applicable) 38 | build/ 39 | dist/ 40 | *.egg-info 41 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | TESSDATA_PREFIX=/usr/share/tesseract/tessdata/ 2 | UVICORN_WORKERS=2 3 | UVICORN_RELOAD=True -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | 13 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security and Disclosure Information Policy for the Docling Project 2 | 3 | The Docling team and community take security bugs seriously. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions. 4 | 5 | ## Reporting a Vulnerability 6 | 7 | If you think you've identified a security issue in an Docling project repository, please DO NOT report the issue publicly via the GitHub issue tracker, etc. 8 | 9 | Instead, send an email with as many details as possible to [deepsearch-core@zurich.ibm.com](mailto:deepsearch-core@zurich.ibm.com). This is a private mailing list for the maintainers team. 10 | 11 | Please do not create a public issue. 12 | 13 | ## Security Vulnerability Response 14 | 15 | Each report is acknowledged and analyzed by the core maintainers within 3 working days. 16 | 17 | Any vulnerability information shared with core maintainers stays within the Docling project and will not be disseminated to other projects unless it is necessary to get the issue fixed. 18 | 19 | After the initial reply to your report, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance. 20 | 21 | ## Security Alerts 22 | 23 | We will send announcements of security vulnerabilities and steps to remediate on the [Docling announcements](https://github.com/docling-project/docling/discussions/categories/announcements). 24 | -------------------------------------------------------------------------------- /.github/mergify.yml: -------------------------------------------------------------------------------- 1 | merge_protections: 2 | - name: Enforce conventional commit 3 | description: Make sure that we follow https://www.conventionalcommits.org/en/v1.0.0/ 4 | if: 5 | - base = main 6 | success_conditions: 7 | - "title ~= 8 | ^(fix|feat|docs|style|refactor|perf|test|build|ci|chore|revert)(?:\\(.+\ 9 | \\))?(!)?:" 10 | -------------------------------------------------------------------------------- /.github/scripts/release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e # trigger failure on error - do not remove! 4 | set -x # display command on output 5 | 6 | if [ -z "${TARGET_VERSION}" ]; then 7 | >&2 echo "No TARGET_VERSION specified" 8 | exit 1 9 | fi 10 | CHGLOG_FILE="${CHGLOG_FILE:-CHANGELOG.md}" 11 | 12 | # update package version 13 | uvx --from=toml-cli toml set --toml-path=pyproject.toml project.version "${TARGET_VERSION}" 14 | uv lock --upgrade-package docling-serve 15 | 16 | # collect release notes 17 | REL_NOTES=$(mktemp) 18 | uv run --no-sync semantic-release changelog --unreleased >> "${REL_NOTES}" 19 | 20 | # update changelog 21 | TMP_CHGLOG=$(mktemp) 22 | TARGET_TAG_NAME="v${TARGET_VERSION}" 23 | RELEASE_URL="$(gh repo view --json url -q ".url")/releases/tag/${TARGET_TAG_NAME}" 24 | printf "## [${TARGET_TAG_NAME}](${RELEASE_URL}) - $(date -Idate)\n\n" >> "${TMP_CHGLOG}" 25 | cat "${REL_NOTES}" >> "${TMP_CHGLOG}" 26 | if [ -f "${CHGLOG_FILE}" ]; then 27 | printf "\n" | cat - "${CHGLOG_FILE}" >> "${TMP_CHGLOG}" 28 | fi 29 | mv "${TMP_CHGLOG}" "${CHGLOG_FILE}" 30 | 31 | # push changes 32 | git config --global user.name 'github-actions[bot]' 33 | git config --global user.email 'github-actions[bot]@users.noreply.github.com' 34 | git add pyproject.toml uv.lock "${CHGLOG_FILE}" 35 | COMMIT_MSG="chore: bump version to ${TARGET_VERSION} [skip ci]" 36 | git commit -m "${COMMIT_MSG}" 37 | git push origin main 38 | 39 | # create GitHub release (incl. Git tag) 40 | gh release create "${TARGET_TAG_NAME}" -F "${REL_NOTES}" 41 | -------------------------------------------------------------------------------- /.github/workflows/actionlint.yml: -------------------------------------------------------------------------------- 1 | name: Lint GitHub Actions workflows 2 | on: 3 | push: 4 | branches: ["main"] 5 | paths: 6 | - '.github/**' 7 | pull_request: 8 | branches: ["main"] 9 | paths: 10 | - '.github/**' 11 | 12 | jobs: 13 | actionlint: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Download actionlint 18 | id: get_actionlint 19 | run: bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) 20 | shell: bash 21 | - name: Check workflow files 22 | run: PATH=".:$PATH" make action-lint 23 | shell: bash 24 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: "Run CD" 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | code-checks: 8 | uses: ./.github/workflows/job-checks.yml 9 | pre-release-check: 10 | runs-on: ubuntu-latest 11 | outputs: 12 | TARGET_TAG_V: ${{ steps.version_check.outputs.TRGT_VERSION }} 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 # for fetching tags, required for semantic-release 17 | - name: Install uv and set the python version 18 | uses: astral-sh/setup-uv@v5 19 | with: 20 | enable-cache: true 21 | - name: Install dependencies 22 | run: uv sync --only-dev 23 | - name: Check version of potential release 24 | id: version_check 25 | run: | 26 | TRGT_VERSION=$(uv run --no-sync semantic-release print-version) 27 | echo "TRGT_VERSION=${TRGT_VERSION}" >> "$GITHUB_OUTPUT" 28 | echo "${TRGT_VERSION}" 29 | - name: Check notes of potential release 30 | run: uv run --no-sync semantic-release changelog --unreleased 31 | release: 32 | needs: [code-checks, pre-release-check] 33 | if: needs.pre-release-check.outputs.TARGET_TAG_V != '' 34 | environment: auto-release 35 | runs-on: ubuntu-latest 36 | concurrency: release 37 | steps: 38 | - uses: actions/create-github-app-token@v1 39 | id: app-token 40 | with: 41 | app-id: ${{ vars.CI_APP_ID }} 42 | private-key: ${{ secrets.CI_PRIVATE_KEY }} 43 | - uses: actions/checkout@v4 44 | with: 45 | token: ${{ steps.app-token.outputs.token }} 46 | fetch-depth: 0 # for fetching tags, required for semantic-release 47 | - name: Install uv and set the python version 48 | uses: astral-sh/setup-uv@v5 49 | with: 50 | enable-cache: true 51 | - name: Install dependencies 52 | run: uv sync --only-dev 53 | - name: Run release script 54 | env: 55 | GH_TOKEN: ${{ steps.app-token.outputs.token }} 56 | TARGET_VERSION: ${{ needs.pre-release-check.outputs.TARGET_TAG_V }} 57 | CHGLOG_FILE: CHANGELOG.md 58 | run: ./.github/scripts/release.sh 59 | shell: bash 60 | -------------------------------------------------------------------------------- /.github/workflows/ci-images-dryrun.yml: -------------------------------------------------------------------------------- 1 | name: Dry run docling-serve image building 2 | 3 | on: 4 | workflow_call: 5 | 6 | concurrency: 7 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 8 | cancel-in-progress: true 9 | 10 | jobs: 11 | build_image: 12 | name: Build ${{ matrix.spec.name }} container image 13 | strategy: 14 | matrix: 15 | spec: 16 | - name: docling-project/docling-serve 17 | build_args: | 18 | UV_SYNC_EXTRA_ARGS=--no-extra cu124 --no-extra cpu 19 | platforms: linux/amd64, linux/arm64 20 | - name: docling-project/docling-serve-cpu 21 | build_args: | 22 | UV_SYNC_EXTRA_ARGS=--no-extra cu124 --no-extra flash-attn 23 | platforms: linux/amd64, linux/arm64 24 | - name: docling-project/docling-serve-cu124 25 | build_args: | 26 | UV_SYNC_EXTRA_ARGS=--no-extra cpu 27 | platforms: linux/amd64 28 | 29 | permissions: 30 | packages: write 31 | contents: read 32 | attestations: write 33 | id-token: write 34 | 35 | uses: ./.github/workflows/job-image.yml 36 | with: 37 | publish: false 38 | build_args: ${{ matrix.spec.build_args }} 39 | ghcr_image_name: ${{ matrix.spec.name }} 40 | quay_image_name: "" 41 | platforms: ${{ matrix.spec.platforms }} 42 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: "Run CI" 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["main"] 8 | 9 | jobs: 10 | code-checks: 11 | # if: ${{ github.event_name == 'push' || (github.event.pull_request.head.repo.full_name != 'docling-project/docling-serve' && github.event.pull_request.head.repo.full_name != 'docling-project/docling-serve') }} 12 | uses: ./.github/workflows/job-checks.yml 13 | permissions: 14 | packages: write 15 | contents: read 16 | attestations: write 17 | id-token: write 18 | 19 | build-images: 20 | uses: ./.github/workflows/ci-images-dryrun.yml 21 | permissions: 22 | packages: write 23 | contents: read 24 | attestations: write 25 | id-token: write 26 | -------------------------------------------------------------------------------- /.github/workflows/images.yml: -------------------------------------------------------------------------------- 1 | name: Publish docling-serve images 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | release: 8 | types: [published] 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | build_and_publish_images: 16 | name: Build and push ${{ matrix.spec.name }} container image to GHCR and QUAY 17 | strategy: 18 | matrix: 19 | spec: 20 | - name: docling-project/docling-serve 21 | build_args: | 22 | UV_SYNC_EXTRA_ARGS=--no-extra cu124 --no-extra cpu 23 | platforms: linux/amd64, linux/arm64 24 | - name: docling-project/docling-serve-cpu 25 | build_args: | 26 | UV_SYNC_EXTRA_ARGS=--no-extra cu124 --no-extra flash-attn 27 | platforms: linux/amd64, linux/arm64 28 | - name: docling-project/docling-serve-cu124 29 | build_args: | 30 | UV_SYNC_EXTRA_ARGS=--no-extra cpu 31 | platforms: linux/amd64 32 | 33 | permissions: 34 | packages: write 35 | contents: read 36 | attestations: write 37 | id-token: write 38 | secrets: inherit 39 | 40 | uses: ./.github/workflows/job-image.yml 41 | with: 42 | publish: true 43 | environment: registry-creds 44 | build_args: ${{ matrix.spec.build_args }} 45 | ghcr_image_name: ${{ matrix.spec.name }} 46 | quay_image_name: ${{ matrix.spec.name }} 47 | platforms: ${{ matrix.spec.platforms }} 48 | -------------------------------------------------------------------------------- /.github/workflows/job-build.yml: -------------------------------------------------------------------------------- 1 | name: Run checks 2 | 3 | on: 4 | workflow_call: 5 | 6 | jobs: 7 | build-package: 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: ['3.12'] 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Install uv and set the python version 15 | uses: astral-sh/setup-uv@v5 16 | with: 17 | python-version: ${{ matrix.python-version }} 18 | enable-cache: true 19 | - name: Install dependencies 20 | run: uv sync --all-extras --no-extra cu124 --no-extra flash-attn 21 | - name: Build package 22 | run: uv build 23 | - name: Check content of wheel 24 | run: unzip -l dist/*.whl 25 | - name: Store the distribution packages 26 | uses: actions/upload-artifact@v4 27 | with: 28 | name: python-package-distributions 29 | path: dist/ 30 | -------------------------------------------------------------------------------- /.github/workflows/job-checks.yml: -------------------------------------------------------------------------------- 1 | name: Run checks 2 | 3 | on: 4 | workflow_call: 5 | 6 | jobs: 7 | py-lint: 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: ['3.12'] 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Install uv and set the python version 15 | uses: astral-sh/setup-uv@v5 16 | with: 17 | python-version: ${{ matrix.python-version }} 18 | enable-cache: true 19 | 20 | - name: pre-commit cache key 21 | run: echo "PY=$(python -VV | sha256sum | cut -d' ' -f1)" >> "$GITHUB_ENV" 22 | - uses: actions/cache@v4 23 | with: 24 | path: ~/.cache/pre-commit 25 | key: pre-commit|${{ env.PY }}|${{ hashFiles('.pre-commit-config.yaml') }} 26 | 27 | - name: Install dependencies 28 | run: uv sync --frozen --all-extras --no-extra cu124 --no-extra flash-attn 29 | 30 | - name: Run styling check 31 | run: pre-commit run --all-files 32 | 33 | build-package: 34 | uses: ./.github/workflows/job-build.yml 35 | 36 | test-package: 37 | needs: 38 | - build-package 39 | runs-on: ubuntu-latest 40 | strategy: 41 | matrix: 42 | python-version: ['3.12'] 43 | steps: 44 | - name: Download all the dists 45 | uses: actions/download-artifact@v4 46 | with: 47 | name: python-package-distributions 48 | path: dist/ 49 | - name: Install uv and set the python version 50 | uses: astral-sh/setup-uv@v5 51 | with: 52 | python-version: ${{ matrix.python-version }} 53 | enable-cache: true 54 | - name: Install package 55 | run: uv pip install dist/*.whl 56 | - name: Create the server 57 | run: python -c 'from docling_serve.app import create_app; create_app()' 58 | 59 | markdown-lint: 60 | runs-on: ubuntu-latest 61 | steps: 62 | - uses: actions/checkout@v4 63 | - name: markdownlint-cli2-action 64 | uses: DavidAnson/markdownlint-cli2-action@v16 65 | with: 66 | globs: "**/*.md" 67 | 68 | -------------------------------------------------------------------------------- /.github/workflows/job-image.yml: -------------------------------------------------------------------------------- 1 | name: Build docling-serve container image 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | build_args: 7 | type: string 8 | description: "Extra build arguments for the build." 9 | default: "" 10 | ghcr_image_name: 11 | type: string 12 | description: "Name of the image for GHCR." 13 | quay_image_name: 14 | type: string 15 | description: "Name of the image Quay." 16 | platforms: 17 | type: string 18 | description: "Platform argument for building images." 19 | default: linux/amd64, linux/arm64 20 | publish: 21 | type: boolean 22 | description: "If true, the images will be published." 23 | default: false 24 | environment: 25 | type: string 26 | description: "GH Action environment" 27 | default: "" 28 | 29 | env: 30 | GHCR_REGISTRY: ghcr.io 31 | QUAY_REGISTRY: quay.io 32 | 33 | jobs: 34 | image: 35 | runs-on: ubuntu-latest 36 | permissions: 37 | packages: write 38 | contents: read 39 | attestations: write 40 | id-token: write 41 | environment: ${{ inputs.environment }} 42 | 43 | steps: 44 | - name: Free up space in github runner 45 | # Free space as indicated here : https://github.com/actions/runner-images/issues/2840#issuecomment-790492173 46 | run: | 47 | df -h 48 | sudo rm -rf "/usr/local/share/boost" 49 | sudo rm -rf "$AGENT_TOOLSDIRECTORY" 50 | sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android /usr/local/share/powershell /usr/share/swift /usr/local/.ghcup 51 | # shellcheck disable=SC2046 52 | sudo docker rmi "$(docker image ls -aq)" >/dev/null 2>&1 || true 53 | df -h 54 | 55 | - name: Check out the repo 56 | uses: actions/checkout@v4 57 | 58 | - name: Log in to the GHCR container image registry 59 | if: ${{ inputs.publish }} 60 | uses: docker/login-action@v3 61 | with: 62 | registry: ${{ env.GHCR_REGISTRY }} 63 | username: ${{ github.actor }} 64 | password: ${{ secrets.GITHUB_TOKEN }} 65 | 66 | - name: Log in to the Quay container image registry 67 | if: ${{ inputs.publish }} 68 | uses: docker/login-action@v3 69 | with: 70 | registry: ${{ env.QUAY_REGISTRY }} 71 | username: ${{ secrets.QUAY_USERNAME }} 72 | password: ${{ secrets.QUAY_TOKEN }} 73 | 74 | - name: Set up Docker Buildx 75 | uses: docker/setup-buildx-action@v3 76 | 77 | - name: Cache Docker layers 78 | uses: actions/cache@v4 79 | with: 80 | path: /tmp/.buildx-cache 81 | key: ${{ runner.os }}-buildx-${{ github.sha }} 82 | restore-keys: | 83 | ${{ runner.os }}-buildx- 84 | 85 | - name: Extract metadata (tags, labels) for docling-serve ghcr image 86 | id: ghcr_meta 87 | uses: docker/metadata-action@v5 88 | with: 89 | images: ${{ env.GHCR_REGISTRY }}/${{ inputs.ghcr_image_name }} 90 | 91 | - name: Build and push image to ghcr.io 92 | id: ghcr_push 93 | uses: docker/build-push-action@v5 94 | with: 95 | context: . 96 | push: ${{ inputs.publish }} 97 | tags: ${{ steps.ghcr_meta.outputs.tags }} 98 | labels: ${{ steps.ghcr_meta.outputs.labels }} 99 | platforms: ${{ inputs.platforms}} 100 | cache-from: type=gha 101 | cache-to: type=gha,mode=max 102 | file: Containerfile 103 | build-args: ${{ inputs.build_args }} 104 | 105 | - name: Generate artifact attestation 106 | if: ${{ inputs.publish }} 107 | uses: actions/attest-build-provenance@v1 108 | with: 109 | subject-name: ${{ env.GHCR_REGISTRY }}/${{ inputs.ghcr_image_name }} 110 | subject-digest: ${{ steps.ghcr_push.outputs.digest }} 111 | push-to-registry: true 112 | 113 | - name: Extract metadata (tags, labels) for docling-serve quay image 114 | if: ${{ inputs.publish }} 115 | id: quay_meta 116 | uses: docker/metadata-action@v5 117 | with: 118 | images: ${{ env.QUAY_REGISTRY }}/${{ inputs.quay_image_name }} 119 | 120 | - name: Build and push image to quay.io 121 | if: ${{ inputs.publish }} 122 | # id: push-serve-cpu-quay 123 | uses: docker/build-push-action@v5 124 | with: 125 | context: . 126 | push: ${{ inputs.publish }} 127 | tags: ${{ steps.quay_meta.outputs.tags }} 128 | labels: ${{ steps.quay_meta.outputs.labels }} 129 | platforms: ${{ inputs.platforms}} 130 | cache-from: type=gha 131 | cache-to: type=gha,mode=max 132 | file: Containerfile 133 | build-args: ${{ inputs.build_args }} 134 | 135 | # - name: Inspect the image details 136 | # run: | 137 | # echo "${{ steps.ghcr_push.outputs.metadata }}" 138 | 139 | - name: Remove Local Docker Images 140 | run: | 141 | docker image prune -af 142 | -------------------------------------------------------------------------------- /.github/workflows/pypi.yml: -------------------------------------------------------------------------------- 1 | name: "Build and publish package" 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | 12 | build-package: 13 | uses: ./.github/workflows/job-build.yml 14 | 15 | build-and-publish: 16 | needs: 17 | - build-package 18 | runs-on: ubuntu-latest 19 | environment: 20 | name: pypi 21 | url: https://pypi.org/p/docling-serve # Replace with your PyPI project name 22 | permissions: 23 | id-token: write # IMPORTANT: mandatory for trusted publishing 24 | steps: 25 | - name: Download all the dists 26 | uses: actions/download-artifact@v4 27 | with: 28 | name: python-package-distributions 29 | path: dist/ 30 | - name: Publish distribution 📦 to PyPI 31 | uses: pypa/gh-action-pypi-publish@release/v1 32 | with: 33 | # currently not working with reusable workflows 34 | attestations: false 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | model_artifacts/ 2 | scratch/ 3 | .md-lint 4 | actionlint 5 | 6 | # Created by https://www.toptal.com/developers/gitignore/api/python,macos,virtualenv,pycharm,visualstudiocode,emacs,vim,jupyternotebooks 7 | # Edit at https://www.toptal.com/developers/gitignore?templates=python,macos,virtualenv,pycharm,visualstudiocode,emacs,vim,jupyternotebooks 8 | 9 | ### Emacs ### 10 | # -*- mode: gitignore; -*- 11 | *~ 12 | \#*\# 13 | /.emacs.desktop 14 | /.emacs.desktop.lock 15 | *.elc 16 | auto-save-list 17 | tramp 18 | .\#* 19 | 20 | # Org-mode 21 | .org-id-locations 22 | *_archive 23 | 24 | # flymake-mode 25 | *_flymake.* 26 | 27 | # eshell files 28 | /eshell/history 29 | /eshell/lastdir 30 | 31 | # elpa packages 32 | /elpa/ 33 | 34 | # reftex files 35 | *.rel 36 | 37 | # AUCTeX auto folder 38 | /auto/ 39 | 40 | # cask packages 41 | .cask/ 42 | dist/ 43 | 44 | # Flycheck 45 | flycheck_*.el 46 | 47 | # server auth directory 48 | /server/ 49 | 50 | # projectiles files 51 | .projectile 52 | 53 | # directory configuration 54 | .dir-locals.el 55 | 56 | # network security 57 | /network-security.data 58 | 59 | 60 | ### JupyterNotebooks ### 61 | # gitignore template for Jupyter Notebooks 62 | # website: http://jupyter.org/ 63 | 64 | .ipynb_checkpoints 65 | */.ipynb_checkpoints/* 66 | 67 | # IPython 68 | profile_default/ 69 | ipython_config.py 70 | 71 | # Remove previous ipynb_checkpoints 72 | # git rm -r .ipynb_checkpoints/ 73 | 74 | ### macOS ### 75 | # General 76 | .DS_Store 77 | .AppleDouble 78 | .LSOverride 79 | 80 | # Icon must end with two \r 81 | Icon 82 | 83 | 84 | # Thumbnails 85 | ._* 86 | 87 | # Files that might appear in the root of a volume 88 | .DocumentRevisions-V100 89 | .fseventsd 90 | .Spotlight-V100 91 | .TemporaryItems 92 | .Trashes 93 | .VolumeIcon.icns 94 | .com.apple.timemachine.donotpresent 95 | 96 | # Directories potentially created on remote AFP share 97 | .AppleDB 98 | .AppleDesktop 99 | Network Trash Folder 100 | Temporary Items 101 | .apdisk 102 | 103 | ### macOS Patch ### 104 | # iCloud generated files 105 | *.icloud 106 | 107 | ### PyCharm ### 108 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 109 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 110 | 111 | # User-specific stuff 112 | .idea/**/workspace.xml 113 | .idea/**/tasks.xml 114 | .idea/**/usage.statistics.xml 115 | .idea/**/dictionaries 116 | .idea/**/shelf 117 | 118 | # AWS User-specific 119 | .idea/**/aws.xml 120 | 121 | # Generated files 122 | .idea/**/contentModel.xml 123 | 124 | # Sensitive or high-churn files 125 | .idea/**/dataSources/ 126 | .idea/**/dataSources.ids 127 | .idea/**/dataSources.local.xml 128 | .idea/**/sqlDataSources.xml 129 | .idea/**/dynamic.xml 130 | .idea/**/uiDesigner.xml 131 | .idea/**/dbnavigator.xml 132 | 133 | # Gradle 134 | .idea/**/gradle.xml 135 | .idea/**/libraries 136 | 137 | # Gradle and Maven with auto-import 138 | # When using Gradle or Maven with auto-import, you should exclude module files, 139 | # since they will be recreated, and may cause churn. Uncomment if using 140 | # auto-import. 141 | # .idea/artifacts 142 | # .idea/compiler.xml 143 | # .idea/jarRepositories.xml 144 | # .idea/modules.xml 145 | # .idea/*.iml 146 | # .idea/modules 147 | # *.iml 148 | # *.ipr 149 | 150 | # CMake 151 | cmake-build-*/ 152 | 153 | # Mongo Explorer plugin 154 | .idea/**/mongoSettings.xml 155 | 156 | # File-based project format 157 | *.iws 158 | 159 | # IntelliJ 160 | out/ 161 | 162 | # mpeltonen/sbt-idea plugin 163 | .idea_modules/ 164 | 165 | # JIRA plugin 166 | atlassian-ide-plugin.xml 167 | 168 | # Cursive Clojure plugin 169 | .idea/replstate.xml 170 | 171 | # SonarLint plugin 172 | .idea/sonarlint/ 173 | 174 | # Crashlytics plugin (for Android Studio and IntelliJ) 175 | com_crashlytics_export_strings.xml 176 | crashlytics.properties 177 | crashlytics-build.properties 178 | fabric.properties 179 | 180 | # Editor-based Rest Client 181 | .idea/httpRequests 182 | 183 | # Android studio 3.1+ serialized cache file 184 | .idea/caches/build_file_checksums.ser 185 | 186 | ### PyCharm Patch ### 187 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 188 | 189 | # *.iml 190 | # modules.xml 191 | # .idea/misc.xml 192 | # *.ipr 193 | 194 | # Sonarlint plugin 195 | # https://plugins.jetbrains.com/plugin/7973-sonarlint 196 | .idea/**/sonarlint/ 197 | 198 | # SonarQube Plugin 199 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin 200 | .idea/**/sonarIssues.xml 201 | 202 | # Markdown Navigator plugin 203 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced 204 | .idea/**/markdown-navigator.xml 205 | .idea/**/markdown-navigator-enh.xml 206 | .idea/**/markdown-navigator/ 207 | 208 | # Cache file creation bug 209 | # See https://youtrack.jetbrains.com/issue/JBR-2257 210 | .idea/$CACHE_FILE$ 211 | 212 | # CodeStream plugin 213 | # https://plugins.jetbrains.com/plugin/12206-codestream 214 | .idea/codestream.xml 215 | 216 | # Azure Toolkit for IntelliJ plugin 217 | # https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij 218 | .idea/**/azureSettings.xml 219 | 220 | ### Python ### 221 | # Byte-compiled / optimized / DLL files 222 | __pycache__/ 223 | *.py[cod] 224 | *$py.class 225 | 226 | # C extensions 227 | *.so 228 | 229 | # Distribution / packaging 230 | .Python 231 | build/ 232 | develop-eggs/ 233 | downloads/ 234 | eggs/ 235 | .eggs/ 236 | lib/ 237 | lib64/ 238 | parts/ 239 | sdist/ 240 | var/ 241 | wheels/ 242 | share/python-wheels/ 243 | *.egg-info/ 244 | .installed.cfg 245 | *.egg 246 | MANIFEST 247 | 248 | # PyInstaller 249 | # Usually these files are written by a python script from a template 250 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 251 | *.manifest 252 | *.spec 253 | 254 | # Installer logs 255 | pip-log.txt 256 | pip-delete-this-directory.txt 257 | 258 | # Unit test / coverage reports 259 | htmlcov/ 260 | .tox/ 261 | .nox/ 262 | .coverage 263 | .coverage.* 264 | .cache 265 | nosetests.xml 266 | coverage.xml 267 | *.cover 268 | *.py,cover 269 | .hypothesis/ 270 | .pytest_cache/ 271 | cover/ 272 | 273 | # Translations 274 | *.mo 275 | *.pot 276 | 277 | # Django stuff: 278 | *.log 279 | local_settings.py 280 | db.sqlite3 281 | db.sqlite3-journal 282 | 283 | # Flask stuff: 284 | instance/ 285 | .webassets-cache 286 | 287 | # Scrapy stuff: 288 | .scrapy 289 | 290 | # Sphinx documentation 291 | docs/_build/ 292 | 293 | # PyBuilder 294 | .pybuilder/ 295 | target/ 296 | 297 | # Jupyter Notebook 298 | 299 | # IPython 300 | 301 | # pyenv 302 | # For a library or package, you might want to ignore these files since the code is 303 | # intended to run in multiple environments; otherwise, check them in: 304 | # .python-version 305 | 306 | # pipenv 307 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 308 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 309 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 310 | # install all needed dependencies. 311 | #Pipfile.lock 312 | 313 | # poetry 314 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 315 | # This is especially recommended for binary packages to ensure reproducibility, and is more 316 | # commonly ignored for libraries. 317 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 318 | #poetry.lock 319 | 320 | # pdm 321 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 322 | #pdm.lock 323 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 324 | # in version control. 325 | # https://pdm.fming.dev/#use-with-ide 326 | .pdm.toml 327 | 328 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 329 | __pypackages__/ 330 | 331 | # Celery stuff 332 | celerybeat-schedule 333 | celerybeat.pid 334 | 335 | # SageMath parsed files 336 | *.sage.py 337 | 338 | # Environments 339 | .env 340 | .venv 341 | env/ 342 | venv/ 343 | ENV/ 344 | env.bak/ 345 | venv.bak/ 346 | 347 | # Spyder project settings 348 | .spyderproject 349 | .spyproject 350 | 351 | # Rope project settings 352 | .ropeproject 353 | 354 | # mkdocs documentation 355 | /site 356 | 357 | # mypy 358 | .mypy_cache/ 359 | .dmypy.json 360 | dmypy.json 361 | 362 | # Pyre type checker 363 | .pyre/ 364 | 365 | # pytype static type analyzer 366 | .pytype/ 367 | 368 | # Cython debug symbols 369 | cython_debug/ 370 | 371 | # PyCharm 372 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 373 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 374 | # and can be added to the global gitignore or merged into this file. For a more nuclear 375 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 376 | .idea/ 377 | 378 | ### Python Patch ### 379 | # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration 380 | poetry.toml 381 | 382 | # ruff 383 | .ruff_cache/ 384 | 385 | ### Vim ### 386 | # Swap 387 | [._]*.s[a-v][a-z] 388 | !*.svg # comment out if you don't need vector files 389 | [._]*.sw[a-p] 390 | [._]s[a-rt-v][a-z] 391 | [._]ss[a-gi-z] 392 | [._]sw[a-p] 393 | 394 | # Session 395 | Session.vim 396 | Sessionx.vim 397 | 398 | # Temporary 399 | .netrwhist 400 | # Auto-generated tag files 401 | tags 402 | # Persistent undo 403 | [._]*.un~ 404 | 405 | 406 | ### Visual Studio Code ### 407 | .vscode/ 408 | 409 | ### VirtualEnv ### 410 | # Virtualenv 411 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 412 | [Bb]in 413 | [Ii]nclude 414 | [Ll]ib 415 | [Ll]ib64 416 | [Ll]ocal 417 | pyvenv.cfg 418 | pip-selfcheck.json 419 | 420 | ### VisualStudioCode ### 421 | .vscode/* 422 | !.vscode/settings.json 423 | !.vscode/tasks.json 424 | !.vscode/launch.json 425 | !.vscode/extensions.json 426 | !.vscode/*.code-snippets 427 | 428 | # Local History for Visual Studio Code 429 | .history/ 430 | 431 | # Built Visual Studio Code Extensions 432 | *.vsix 433 | 434 | ### VisualStudioCode Patch ### 435 | # Ignore all local history of files 436 | .history 437 | .ionide 438 | 439 | 440 | # Docs 441 | # docs/**/*.png 442 | # docs/**/*.svg 443 | 444 | # Makefile 445 | .action-lint 446 | .markdown-lint 447 | 448 | cookies.txt -------------------------------------------------------------------------------- /.markdownlint-cli2.yaml: -------------------------------------------------------------------------------- 1 | config: 2 | line-length: false 3 | no-emphasis-as-header: false 4 | first-line-heading: false 5 | MD033: 6 | allowed_elements: ["details", "summary", "br", "a", "b", "p", "img"] 7 | MD024: 8 | siblings_only: true 9 | globs: 10 | - "**/*.md" 11 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | fail_fast: true 2 | repos: 3 | - repo: https://github.com/astral-sh/ruff-pre-commit 4 | rev: v0.9.6 5 | hooks: 6 | # Run the Ruff formatter. 7 | - id: ruff-format 8 | name: "Ruff formatter" 9 | args: [--config=pyproject.toml] 10 | files: '^(docling_serve|tests).*\.(py|ipynb)$' 11 | # Run the Ruff linter. 12 | - id: ruff 13 | name: "Ruff linter" 14 | args: [--exit-non-zero-on-fix, --fix, --config=pyproject.toml] 15 | files: '^(docling_serve|tests).*\.(py|ipynb)$' 16 | - repo: local 17 | hooks: 18 | - id: system 19 | name: MyPy 20 | entry: uv run --no-sync mypy docling_serve 21 | pass_filenames: false 22 | language: system 23 | files: '\.py$' 24 | - repo: https://github.com/astral-sh/uv-pre-commit 25 | # uv version. 26 | rev: 0.6.1 27 | hooks: 28 | - id: uv-lock 29 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.12 2 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement using 63 | [deepsearch-core@zurich.ibm.com](mailto:deepsearch-core@zurich.ibm.com). 64 | 65 | All complaints will be reviewed and investigated promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series 87 | of actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or 94 | permanent ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within 114 | the community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org)], 119 | version 2.0, available at 120 | [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html](https://www.contributor-covenant.org/version/2/0/code_of_conduct.html). 121 | 122 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 123 | enforcement ladder](https://github.com/mozilla/diversity). 124 | 125 | Homepage: [https://www.contributor-covenant.org](https://www.contributor-covenant.org) 126 | 127 | For answers to common questions about this code of conduct, see the FAQ at 128 | [https://www.contributor-covenant.org/faq](https://www.contributor-covenant.org/faq). Translations are available at 129 | [https://www.contributor-covenant.org/translations](https://www.contributor-covenant.org/translations). 130 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing In General 2 | 3 | Our project welcomes external contributions. If you have an itch, please feel 4 | free to scratch it. 5 | 6 | To contribute code or documentation, please submit a [pull request](https://github.com/docling-project/docling-serve/pulls). 7 | 8 | A good way to familiarize yourself with the codebase and contribution process is 9 | to look for and tackle low-hanging fruit in the [issue tracker](https://github.com/docling-project/docling-serve/issues). 10 | Before embarking on a more ambitious contribution, please quickly [get in touch](#communication) with us. 11 | 12 | For general questions or support requests, please refer to the [discussion section](https://github.com/docling-project/docling-serve/discussions). 13 | 14 | **Note: We appreciate your effort, and want to avoid a situation where a contribution 15 | requires extensive rework (by you or by us), sits in backlog for a long time, or 16 | cannot be accepted at all!** 17 | 18 | ### Proposing new features 19 | 20 | If you would like to implement a new feature, please [raise an issue](https://github.com/docling-project/docling-serve/issues) 21 | before sending a pull request so the feature can be discussed. This is to avoid 22 | you wasting your valuable time working on a feature that the project developers 23 | are not interested in accepting into the code base. 24 | 25 | ### Fixing bugs 26 | 27 | If you would like to fix a bug, please [raise an issue](https://github.com/docling-project/docling-serve/issues) before sending a 28 | pull request so it can be tracked. 29 | 30 | ### Merge approval 31 | 32 | The project maintainers use LGTM (Looks Good To Me) in comments on the code 33 | review to indicate acceptance. A change requires LGTMs from two of the 34 | maintainers of each component affected. 35 | 36 | For a list of the maintainers, see the [MAINTAINERS.md](MAINTAINERS.md) page. 37 | 38 | ## Legal 39 | 40 | Each source file must include a license header for the MIT 41 | Software. Using the SPDX format is the simplest approach. 42 | e.g. 43 | 44 | ```text 45 | /* 46 | Copyright IBM Inc. All rights reserved. 47 | 48 | SPDX-License-Identifier: MIT 49 | */ 50 | ``` 51 | 52 | We have tried to make it as easy as possible to make contributions. This 53 | applies to how we handle the legal aspects of contribution. We use the 54 | same approach - the [Developer's Certificate of Origin 1.1 (DCO)](https://github.com/hyperledger/fabric/blob/master/docs/source/DCO1.1.txt) - that the Linux® Kernel [community](https://elinux.org/Developer_Certificate_Of_Origin) 55 | uses to manage code contributions. 56 | 57 | We simply ask that when submitting a patch for review, the developer 58 | must include a sign-off statement in the commit message. 59 | 60 | Here is an example Signed-off-by line, which indicates that the 61 | submitter accepts the DCO: 62 | 63 | ```text 64 | Signed-off-by: John Doe 65 | ``` 66 | 67 | You can include this automatically when you commit a change to your 68 | local git repository using the following command: 69 | 70 | ```text 71 | git commit -s 72 | ``` 73 | 74 | ## Communication 75 | 76 | Please feel free to connect with us using the [discussion section](https://github.com/docling-project/docling-serve/discussions). 77 | 78 | ## Developing 79 | 80 | ### Usage of Poetry 81 | 82 | We use Poetry to manage dependencies. 83 | 84 | #### Install 85 | 86 | To install, see the documentation here: 87 | 88 | 1. Install the Poetry globally in your machine 89 | 90 | ```bash 91 | curl -sSL https://install.python-poetry.org | python3 - 92 | ``` 93 | 94 | The installation script will print the installation bin folder `POETRY_BIN` which you need in the next steps. 95 | 96 | 2. Make sure Poetry is in your `$PATH` 97 | - for `zsh` 98 | 99 | ```sh 100 | echo 'export PATH="POETRY_BIN:$PATH"' >> ~/.zshrc 101 | ``` 102 | 103 | - for `bash` 104 | 105 | ```sh 106 | echo 'export PATH="POETRY_BIN:$PATH"' >> ~/.bashrc 107 | ``` 108 | 109 | 3. The official guidelines linked above include useful details on the configuration of autocomplete for most shell environments, e.g. Bash and Zsh. 110 | 111 | #### Create a Virtual Environment and Install Dependencies 112 | 113 | To activate the Virtual Environment, run: 114 | 115 | ```bash 116 | poetry shell 117 | ``` 118 | 119 | To spawn a shell with the Virtual Environment activated. If the Virtual Environment doesn't exist, Poetry will create one for you. Then, to install dependencies, run: 120 | 121 | ```bash 122 | poetry install 123 | ``` 124 | 125 | #### (Advanced) Use a Specific Python Version 126 | 127 | If for whatever reason you need to work in a specific (older) version of Python, run: 128 | 129 | ```bash 130 | poetry env use $(which python3.10) 131 | ``` 132 | 133 | This creates a Virtual Environment with Python 3.10. For other versions, replace `$(which python3.10)` by the path to the interpreter (e.g., `/usr/bin/python3.8`) or use `$(which pythonX.Y)`. 134 | 135 | #### Add a new dependency 136 | 137 | ```bash 138 | poetry add NAME 139 | ``` 140 | 141 | ## Coding style guidelines 142 | 143 | We use the following tools to enforce code style: 144 | 145 | - ruff, to sort imports and format code 146 | 147 | We run a series of checks on the code base on every commit, using `pre-commit`. To install the hooks, run: 148 | 149 | ```bash 150 | pre-commit install 151 | ``` 152 | 153 | To run the checks on-demand, run: 154 | 155 | ```shell 156 | pre-commit run --all-files 157 | ``` 158 | 159 | Note: Formatting checks like `ruff` will "fail" if they modify files. This is because `pre-commit` doesn't like to see files modified by their Hooks. In these cases, `git add` the modified files and `git commit` again. 160 | -------------------------------------------------------------------------------- /Containerfile: -------------------------------------------------------------------------------- 1 | ARG BASE_IMAGE=quay.io/sclorg/python-312-c9s:c9s 2 | 3 | FROM ${BASE_IMAGE} 4 | 5 | USER 0 6 | 7 | ################################################################################################### 8 | # OS Layer # 9 | ################################################################################################### 10 | 11 | RUN --mount=type=bind,source=os-packages.txt,target=/tmp/os-packages.txt \ 12 | dnf -y install --best --nodocs --setopt=install_weak_deps=False dnf-plugins-core && \ 13 | dnf config-manager --best --nodocs --setopt=install_weak_deps=False --save && \ 14 | dnf config-manager --enable crb && \ 15 | dnf -y update && \ 16 | dnf install -y $(cat /tmp/os-packages.txt) && \ 17 | dnf -y clean all && \ 18 | rm -rf /var/cache/dnf 19 | 20 | RUN /usr/bin/fix-permissions /opt/app-root/src/.cache 21 | 22 | ENV TESSDATA_PREFIX=/usr/share/tesseract/tessdata/ 23 | 24 | ################################################################################################### 25 | # Docling layer # 26 | ################################################################################################### 27 | 28 | USER 1001 29 | 30 | WORKDIR /opt/app-root/src 31 | 32 | ENV \ 33 | # On container environments, always set a thread budget to avoid undesired thread congestion. 34 | OMP_NUM_THREADS=4 \ 35 | LANG=en_US.UTF-8 \ 36 | LC_ALL=en_US.UTF-8 \ 37 | PYTHONIOENCODING=utf-8 \ 38 | UV_COMPILE_BYTECODE=1 \ 39 | UV_LINK_MODE=copy \ 40 | UV_PROJECT_ENVIRONMENT=/opt/app-root \ 41 | DOCLING_SERVE_ARTIFACTS_PATH=/opt/app-root/src/.cache/docling/models 42 | 43 | ARG UV_SYNC_EXTRA_ARGS="" 44 | 45 | RUN --mount=from=ghcr.io/astral-sh/uv:0.6.1,source=/uv,target=/bin/uv \ 46 | --mount=type=cache,target=/opt/app-root/src/.cache/uv,uid=1001 \ 47 | --mount=type=bind,source=uv.lock,target=uv.lock \ 48 | --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ 49 | umask 002 && \ 50 | UV_SYNC_ARGS="--frozen --no-install-project --no-dev --all-extras" && \ 51 | uv sync ${UV_SYNC_ARGS} ${UV_SYNC_EXTRA_ARGS} --no-extra flash-attn && \ 52 | FLASH_ATTENTION_SKIP_CUDA_BUILD=TRUE uv sync ${UV_SYNC_ARGS} ${UV_SYNC_EXTRA_ARGS} --no-build-isolation-package=flash-attn 53 | 54 | ARG MODELS_LIST="layout tableformer picture_classifier easyocr" 55 | 56 | RUN echo "Downloading models..." && \ 57 | HF_HUB_DOWNLOAD_TIMEOUT="90" \ 58 | HF_HUB_ETAG_TIMEOUT="90" \ 59 | docling-tools models download -o "${DOCLING_SERVE_ARTIFACTS_PATH}" ${MODELS_LIST} && \ 60 | chown -R 1001:0 ${DOCLING_SERVE_ARTIFACTS_PATH} && \ 61 | chmod -R g=u ${DOCLING_SERVE_ARTIFACTS_PATH} 62 | 63 | COPY --chown=1001:0 ./docling_serve ./docling_serve 64 | RUN --mount=from=ghcr.io/astral-sh/uv:0.6.1,source=/uv,target=/bin/uv \ 65 | --mount=type=cache,target=/opt/app-root/src/.cache/uv,uid=1001 \ 66 | --mount=type=bind,source=uv.lock,target=uv.lock \ 67 | --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ 68 | umask 002 && uv sync --frozen --no-dev --all-extras ${UV_SYNC_EXTRA_ARGS} 69 | 70 | EXPOSE 5001 71 | 72 | CMD ["docling-serve", "run"] 73 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 International Business Machines 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MAINTAINERS.md: -------------------------------------------------------------------------------- 1 | # MAINTAINERS 2 | 3 | - Christoph Auer - [@cau-git](https://github.com/cau-git) 4 | - Michele Dolfi - [@dolfim-ibm](https://github.com/dolfim-ibm) 5 | - Maxim Lysak - [@maxmnemonic](https://github.com/maxmnemonic) 6 | - Nikos Livathinos - [@nikos-livathinos](https://github.com/nikos-livathinos) 7 | - Ahmed Nassar - [@nassarofficial](https://github.com/nassarofficial) 8 | - Panos Vagenas - [@vagenas](https://github.com/vagenas) 9 | - Peter Staar - [@PeterStaar-IBM](https://github.com/PeterStaar-IBM) 10 | 11 | Maintainers can be contacted at [deepsearch-core@zurich.ibm.com](mailto:deepsearch-core@zurich.ibm.com). 12 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: help 2 | help: 3 | @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-18s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) 4 | 5 | # 6 | # If you want to see the full commands, run: 7 | # NOISY_BUILD=y make 8 | # 9 | ifeq ($(NOISY_BUILD),) 10 | ECHO_PREFIX=@ 11 | CMD_PREFIX=@ 12 | PIPE_DEV_NULL=> /dev/null 2> /dev/null 13 | else 14 | ECHO_PREFIX=@\# 15 | CMD_PREFIX= 16 | PIPE_DEV_NULL= 17 | endif 18 | 19 | TAG=$(shell git rev-parse HEAD) 20 | BRANCH_TAG=$(shell git rev-parse --abbrev-ref HEAD) 21 | 22 | action-lint-file: 23 | $(CMD_PREFIX) touch .action-lint 24 | 25 | md-lint-file: 26 | $(CMD_PREFIX) touch .markdown-lint 27 | 28 | .PHONY: docling-serve-image 29 | docling-serve-image: Containerfile 30 | $(ECHO_PREFIX) printf " %-12s Containerfile\n" "[docling-serve]" 31 | $(CMD_PREFIX) docker build --load --build-arg "UV_SYNC_EXTRA_ARGS=--no-extra cu124 --no-extra cpu" -f Containerfile -t ghcr.io/docling-project/docling-serve:$(TAG) . 32 | $(CMD_PREFIX) docker tag ghcr.io/docling-project/docling-serve:$(TAG) ghcr.io/docling-project/docling-serve:$(BRANCH_TAG) 33 | $(CMD_PREFIX) docker tag ghcr.io/docling-project/docling-serve:$(TAG) quay.io/docling-project/docling-serve:$(BRANCH_TAG) 34 | 35 | .PHONY: docling-serve-cpu-image 36 | docling-serve-cpu-image: Containerfile ## Build docling-serve "cpu only" container image 37 | $(ECHO_PREFIX) printf " %-12s Containerfile\n" "[docling-serve CPU]" 38 | $(CMD_PREFIX) docker build --load --build-arg "UV_SYNC_EXTRA_ARGS=--no-extra cu124 --no-extra flash-attn" -f Containerfile -t ghcr.io/docling-project/docling-serve-cpu:$(TAG) . 39 | $(CMD_PREFIX) docker tag ghcr.io/docling-project/docling-serve-cpu:$(TAG) ghcr.io/docling-project/docling-serve-cpu:$(BRANCH_TAG) 40 | $(CMD_PREFIX) docker tag ghcr.io/docling-project/docling-serve-cpu:$(TAG) quay.io/docling-project/docling-serve-cpu:$(BRANCH_TAG) 41 | 42 | .PHONY: docling-serve-cu124-image 43 | docling-serve-cu124-image: Containerfile ## Build docling-serve container image with GPU support 44 | $(ECHO_PREFIX) printf " %-12s Containerfile\n" "[docling-serve with Cuda 12.4]" 45 | $(CMD_PREFIX) docker build --load --build-arg "UV_SYNC_EXTRA_ARGS=--no-extra cpu" -f Containerfile --platform linux/amd64 -t ghcr.io/docling-project/docling-serve-cu124:$(TAG) . 46 | $(CMD_PREFIX) docker tag ghcr.io/docling-project/docling-serve-cu124:$(TAG) ghcr.io/docling-project/docling-serve-cu124:$(BRANCH_TAG) 47 | $(CMD_PREFIX) docker tag ghcr.io/docling-project/docling-serve-cu124:$(TAG) quay.io/docling-project/docling-serve-cu124:$(BRANCH_TAG) 48 | 49 | .PHONY: action-lint 50 | action-lint: .action-lint ## Lint GitHub Action workflows 51 | .action-lint: $(shell find .github -type f) | action-lint-file 52 | $(ECHO_PREFIX) printf " %-12s .github/...\n" "[ACTION LINT]" 53 | $(CMD_PREFIX) if ! which actionlint $(PIPE_DEV_NULL) ; then \ 54 | echo "Please install actionlint." ; \ 55 | echo "go install github.com/rhysd/actionlint/cmd/actionlint@latest" ; \ 56 | exit 1 ; \ 57 | fi 58 | $(CMD_PREFIX) if ! which shellcheck $(PIPE_DEV_NULL) ; then \ 59 | echo "Please install shellcheck." ; \ 60 | echo "https://github.com/koalaman/shellcheck#user-content-installing" ; \ 61 | exit 1 ; \ 62 | fi 63 | $(CMD_PREFIX) actionlint -color 64 | $(CMD_PREFIX) touch $@ 65 | 66 | .PHONY: md-lint 67 | md-lint: .md-lint ## Lint markdown files 68 | .md-lint: $(wildcard */**/*.md) | md-lint-file 69 | $(ECHO_PREFIX) printf " %-12s ./...\n" "[MD LINT]" 70 | $(CMD_PREFIX) docker run --rm -v $$(pwd):/workdir davidanson/markdownlint-cli2:v0.16.0 "**/*.md" "#.venv" 71 | $(CMD_PREFIX) touch $@ 72 | 73 | .PHONY: py-Lint 74 | py-lint: ## Lint Python files 75 | $(ECHO_PREFIX) printf " %-12s ./...\n" "[PY LINT]" 76 | $(CMD_PREFIX) if ! which uv $(PIPE_DEV_NULL) ; then \ 77 | echo "Please install uv." ; \ 78 | exit 1 ; \ 79 | fi 80 | $(CMD_PREFIX) uv sync --extra ui 81 | $(CMD_PREFIX) uv run pre-commit run --all-files 82 | 83 | .PHONY: run-docling-cpu 84 | run-docling-cpu: ## Run the docling-serve container with CPU support and assign a container name 85 | $(ECHO_PREFIX) printf " %-12s Removing existing container if it exists...\n" "[CLEANUP]" 86 | $(CMD_PREFIX) docker rm -f docling-serve-cpu 2>/dev/null || true 87 | $(ECHO_PREFIX) printf " %-12s Running docling-serve container with CPU support on port 5001...\n" "[RUN CPU]" 88 | $(CMD_PREFIX) docker run -it --name docling-serve-cpu -p 5001:5001 ghcr.io/docling-project/docling-serve-cpu:main 89 | 90 | .PHONY: run-docling-gpu 91 | run-docling-gpu: ## Run the docling-serve container with GPU support and assign a container name 92 | $(ECHO_PREFIX) printf " %-12s Removing existing container if it exists...\n" "[CLEANUP]" 93 | $(CMD_PREFIX) docker rm -f docling-serve-gpu 2>/dev/null || true 94 | $(ECHO_PREFIX) printf " %-12s Running docling-serve container with GPU support on port 5001...\n" "[RUN GPU]" 95 | $(CMD_PREFIX) docker run -it --name docling-serve-gpu -p 5001:5001 ghcr.io/docling-project/docling-serve:main 96 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Docling 4 | 5 |

6 | 7 | # Docling Serve 8 | 9 | Running [Docling](https://github.com/docling-project/docling) as an API service. 10 | 11 | ## Getting started 12 | 13 | Install the `docling-serve` package and run the server. 14 | 15 | ```bash 16 | # Using the python package 17 | pip install "docling-serve" 18 | docling-serve run 19 | 20 | # Using container images, e.g. with Podman 21 | podman run -p 5001:5001 quay.io/docling-project/docling-serve 22 | ``` 23 | 24 | The server is available at 25 | 26 | - API 27 | - API documentation 28 | ![swagger.png](img/swagger.png) 29 | 30 | Try it out with a simple conversion: 31 | 32 | ```bash 33 | curl -X 'POST' \ 34 | 'http://localhost:5001/v1alpha/convert/source' \ 35 | -H 'accept: application/json' \ 36 | -H 'Content-Type: application/json' \ 37 | -d '{ 38 | "http_sources": [{"url": "https://arxiv.org/pdf/2501.17887"}] 39 | }' 40 | ``` 41 | 42 | ### Container images 43 | 44 | Available container images: 45 | 46 | | Name | Description | Arch | Size | 47 | | -----|-------------|------|------| 48 | | [`ghcr.io/docling-project/docling-serve`](https://github.com/docling-project/docling-serve/pkgs/container/docling-serve)
[`quay.io/docling-project/docling-serve`](https://quay.io/repository/docling-project/docling-serve) | Simple image for Docling Serve, installing all packages from the official pypi.org index. | `linux/amd64`, `linux/arm64` | 3.6 GB | 49 | | [`ghcr.io/docling-project/docling-serve-cpu`](https://github.com/docling-project/docling-serve/pkgs/container/docling-serve-cpu)
[`quay.io/docling-project/docling-serve-cpu`](https://quay.io/repository/docling-project/docling-serve-cpu) | Cpu-only image which installs `torch` from the pytorch cpu index. | `linux/amd64`, `linux/arm64` | 3.6 GB | 50 | | [`ghcr.io/docling-project/docling-serve-cu124`](https://github.com/docling-project/docling-serve/pkgs/container/docling-serve-cu124)
[`quay.io/docling-project/docling-serve-cu124`](https://quay.io/repository/docling-project/docling-serve-cu124) | Cuda 12.4 image which installs `torch` from the pytorch cu124 index. | `linux/amd64` | 8.7 GB | 51 | 52 | Coming soon: `docling-serve-slim` images will reduce the size by skipping the model weights download. 53 | 54 | ### Demonstration UI 55 | 56 | ```bash 57 | # Install the Python package with the extra dependencies 58 | pip install "docling-serve[ui]" 59 | docling-serve run --enable-ui 60 | 61 | # Run the container image with the extra env parameters 62 | podman run -p 5001:5001 -e DOCLING_SERVE_ENABLE_UI=true quay.io/docling-project/docling-serve 63 | ``` 64 | 65 | An easy to use UI is available at the `/ui` endpoint. 66 | 67 | ![ui-input.png](img/ui-input.png) 68 | 69 | ![ui-output.png](img/ui-output.png) 70 | 71 | ## Documentation and advance usages 72 | 73 | Visit the [Docling Serve documentation](./docs/README.md) for learning how to [configure the webserver](./docs/configuration.md), use all the [runtime options](./docs/usage.md) of the API and [deployment examples](./docs/deployment.md), pre-load model weights into a persistent volume [model weights on persistent volume](./docs/pre-loading-models.md) 74 | 75 | ## Get help and support 76 | 77 | Please feel free to connect with us using the [discussion section](https://github.com/docling-project/docling/discussions). 78 | 79 | ## Contributing 80 | 81 | Please read [Contributing to Docling Serve](https://github.com/docling-project/docling-serve/blob/main/CONTRIBUTING.md) for details. 82 | 83 | ## References 84 | 85 | If you use Docling in your projects, please consider citing the following: 86 | 87 | ```bib 88 | @techreport{Docling, 89 | author = {Docling Contributors}, 90 | month = {1}, 91 | title = {Docling: An Efficient Open-Source Toolkit for AI-driven Document Conversion}, 92 | url = {https://arxiv.org/abs/2501.17887}, 93 | eprint = {2501.17887}, 94 | doi = {10.48550/arXiv.2501.17887}, 95 | version = {2.0.0}, 96 | year = {2025} 97 | } 98 | ``` 99 | 100 | ## License 101 | 102 | The Docling Serve codebase is under MIT license. 103 | 104 | ## IBM ❤️ Open Source AI 105 | 106 | Docling has been brought to you by IBM. 107 | -------------------------------------------------------------------------------- /docling_serve/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docling-project/docling-serve/524f6a8997b86d2f869ca491ec8fb40585b42ca4/docling_serve/__init__.py -------------------------------------------------------------------------------- /docling_serve/__main__.py: -------------------------------------------------------------------------------- 1 | import importlib.metadata 2 | import logging 3 | import platform 4 | import sys 5 | import warnings 6 | from pathlib import Path 7 | from typing import Annotated, Any, Optional, Union 8 | 9 | import typer 10 | import uvicorn 11 | from rich.console import Console 12 | 13 | from docling_serve.settings import docling_serve_settings, uvicorn_settings 14 | 15 | warnings.filterwarnings(action="ignore", category=UserWarning, module="pydantic|torch") 16 | warnings.filterwarnings(action="ignore", category=FutureWarning, module="easyocr") 17 | 18 | 19 | err_console = Console(stderr=True) 20 | console = Console() 21 | 22 | app = typer.Typer( 23 | no_args_is_help=True, 24 | rich_markup_mode="rich", 25 | ) 26 | 27 | logger = logging.getLogger(__name__) 28 | 29 | 30 | def version_callback(value: bool) -> None: 31 | if value: 32 | docling_serve_version = importlib.metadata.version("docling_serve") 33 | docling_version = importlib.metadata.version("docling") 34 | docling_core_version = importlib.metadata.version("docling-core") 35 | docling_ibm_models_version = importlib.metadata.version("docling-ibm-models") 36 | docling_parse_version = importlib.metadata.version("docling-parse") 37 | platform_str = platform.platform() 38 | py_impl_version = sys.implementation.cache_tag 39 | py_lang_version = platform.python_version() 40 | console.print(f"Docling Serve version: {docling_serve_version}") 41 | console.print(f"Docling version: {docling_version}") 42 | console.print(f"Docling Core version: {docling_core_version}") 43 | console.print(f"Docling IBM Models version: {docling_ibm_models_version}") 44 | console.print(f"Docling Parse version: {docling_parse_version}") 45 | console.print(f"Python: {py_impl_version} ({py_lang_version})") 46 | console.print(f"Platform: {platform_str}") 47 | raise typer.Exit() 48 | 49 | 50 | @app.callback() 51 | def callback( 52 | version: Annotated[ 53 | Union[bool, None], 54 | typer.Option(help="Show the version and exit.", callback=version_callback), 55 | ] = None, 56 | verbose: Annotated[ 57 | int, 58 | typer.Option( 59 | "--verbose", 60 | "-v", 61 | count=True, 62 | help="Set the verbosity level. -v for info logging, -vv for debug logging.", 63 | ), 64 | ] = 0, 65 | ) -> None: 66 | if verbose == 0: 67 | logging.basicConfig(level=logging.WARNING) 68 | elif verbose == 1: 69 | logging.basicConfig(level=logging.INFO) 70 | elif verbose == 2: 71 | logging.basicConfig(level=logging.DEBUG) 72 | 73 | 74 | def _run( 75 | *, 76 | command: str, 77 | # Docling serve parameters 78 | artifacts_path: Path | None, 79 | enable_ui: bool, 80 | ) -> None: 81 | server_type = "development" if command == "dev" else "production" 82 | 83 | console.print(f"Starting {server_type} server 🚀") 84 | 85 | run_subprocess = ( 86 | uvicorn_settings.workers is not None and uvicorn_settings.workers > 1 87 | ) or uvicorn_settings.reload 88 | 89 | run_ssl = ( 90 | uvicorn_settings.ssl_certfile is not None 91 | and uvicorn_settings.ssl_keyfile is not None 92 | ) 93 | 94 | if run_subprocess and docling_serve_settings.artifacts_path != artifacts_path: 95 | err_console.print( 96 | "\n[yellow]:warning: The server will run with reload or multiple workers. \n" 97 | "The argument [bold]--artifacts-path[/bold] will be ignored, please set the value \n" 98 | "using the environment variable [bold]DOCLING_SERVE_ARTIFACTS_PATH[/bold].[/yellow]" 99 | ) 100 | 101 | if run_subprocess and docling_serve_settings.enable_ui != enable_ui: 102 | err_console.print( 103 | "\n[yellow]:warning: The server will run with reload or multiple workers. \n" 104 | "The argument [bold]--enable-ui[/bold] will be ignored, please set the value \n" 105 | "using the environment variable [bold]DOCLING_SERVE_ENABLE_UI[/bold].[/yellow]" 106 | ) 107 | 108 | # Propagate the settings to the app settings 109 | docling_serve_settings.artifacts_path = artifacts_path 110 | docling_serve_settings.enable_ui = enable_ui 111 | 112 | # Print documentation 113 | protocol = "https" if run_ssl else "http" 114 | url = f"{protocol}://{uvicorn_settings.host}:{uvicorn_settings.port}" 115 | url_docs = f"{url}/docs" 116 | url_ui = f"{url}/ui" 117 | 118 | console.print("") 119 | console.print(f"Server started at [link={url}]{url}[/]") 120 | console.print(f"Documentation at [link={url_docs}]{url_docs}[/]") 121 | if docling_serve_settings.enable_ui: 122 | console.print(f"UI at [link={url_ui}]{url_ui}[/]") 123 | 124 | if command == "dev": 125 | console.print("") 126 | console.print( 127 | "Running in development mode, for production use: " 128 | "[bold]docling-serve run[/]", 129 | ) 130 | 131 | console.print("") 132 | console.print("Logs:") 133 | 134 | # Launch the server 135 | uvicorn.run( 136 | app="docling_serve.app:create_app", 137 | factory=True, 138 | host=uvicorn_settings.host, 139 | port=uvicorn_settings.port, 140 | reload=uvicorn_settings.reload, 141 | workers=uvicorn_settings.workers, 142 | root_path=uvicorn_settings.root_path, 143 | proxy_headers=uvicorn_settings.proxy_headers, 144 | timeout_keep_alive=uvicorn_settings.timeout_keep_alive, 145 | ssl_certfile=uvicorn_settings.ssl_certfile, 146 | ssl_keyfile=uvicorn_settings.ssl_keyfile, 147 | ssl_keyfile_password=uvicorn_settings.ssl_keyfile_password, 148 | ) 149 | 150 | 151 | @app.command() 152 | def dev( 153 | *, 154 | # uvicorn options 155 | host: Annotated[ 156 | str, 157 | typer.Option( 158 | help=( 159 | "The host to serve on. For local development in localhost " 160 | "use [blue]127.0.0.1[/blue]. To enable public access, " 161 | "e.g. in a container, use all the IP addresses " 162 | "available with [blue]0.0.0.0[/blue]." 163 | ) 164 | ), 165 | ] = "127.0.0.1", 166 | port: Annotated[ 167 | int, 168 | typer.Option(help="The port to serve on."), 169 | ] = uvicorn_settings.port, 170 | reload: Annotated[ 171 | bool, 172 | typer.Option( 173 | help=( 174 | "Enable auto-reload of the server when (code) files change. " 175 | "This is [bold]resource intensive[/bold], " 176 | "use it only during development." 177 | ) 178 | ), 179 | ] = True, 180 | root_path: Annotated[ 181 | str, 182 | typer.Option( 183 | help=( 184 | "The root path is used to tell your app that it is being served " 185 | "to the outside world with some [bold]path prefix[/bold] " 186 | "set up in some termination proxy or similar." 187 | ) 188 | ), 189 | ] = uvicorn_settings.root_path, 190 | proxy_headers: Annotated[ 191 | bool, 192 | typer.Option( 193 | help=( 194 | "Enable/Disable X-Forwarded-Proto, X-Forwarded-For, " 195 | "X-Forwarded-Port to populate remote address info." 196 | ) 197 | ), 198 | ] = uvicorn_settings.proxy_headers, 199 | timeout_keep_alive: Annotated[ 200 | int, typer.Option(help="Timeout for the server response.") 201 | ] = uvicorn_settings.timeout_keep_alive, 202 | ssl_certfile: Annotated[ 203 | Optional[Path], typer.Option(help="SSL certificate file") 204 | ] = uvicorn_settings.ssl_certfile, 205 | ssl_keyfile: Annotated[ 206 | Optional[Path], typer.Option(help="SSL key file") 207 | ] = uvicorn_settings.ssl_keyfile, 208 | ssl_keyfile_password: Annotated[ 209 | Optional[str], typer.Option(help="SSL keyfile password") 210 | ] = uvicorn_settings.ssl_keyfile_password, 211 | # docling options 212 | artifacts_path: Annotated[ 213 | Optional[Path], 214 | typer.Option( 215 | help=( 216 | "If set to a valid directory, " 217 | "the model weights will be loaded from this path." 218 | ) 219 | ), 220 | ] = docling_serve_settings.artifacts_path, 221 | enable_ui: Annotated[bool, typer.Option(help="Enable the development UI.")] = True, 222 | ) -> Any: 223 | """ 224 | Run a [bold]Docling Serve[/bold] app in [yellow]development[/yellow] mode. 🧪 225 | 226 | This is equivalent to [bold]docling-serve run[/bold] but with [bold]reload[/bold] 227 | enabled and listening on the [blue]127.0.0.1[/blue] address. 228 | 229 | Options can be set also with the corresponding ENV variable, with the exception 230 | of --enable-ui, --host and --reload. 231 | """ 232 | 233 | uvicorn_settings.host = host 234 | uvicorn_settings.port = port 235 | uvicorn_settings.reload = reload 236 | uvicorn_settings.root_path = root_path 237 | uvicorn_settings.proxy_headers = proxy_headers 238 | uvicorn_settings.timeout_keep_alive = timeout_keep_alive 239 | uvicorn_settings.ssl_certfile = ssl_certfile 240 | uvicorn_settings.ssl_keyfile = ssl_keyfile 241 | uvicorn_settings.ssl_keyfile_password = ssl_keyfile_password 242 | 243 | _run( 244 | command="dev", 245 | artifacts_path=artifacts_path, 246 | enable_ui=enable_ui, 247 | ) 248 | 249 | 250 | @app.command() 251 | def run( 252 | *, 253 | host: Annotated[ 254 | str, 255 | typer.Option( 256 | help=( 257 | "The host to serve on. For local development in localhost " 258 | "use [blue]127.0.0.1[/blue]. To enable public access, " 259 | "e.g. in a container, use all the IP addresses " 260 | "available with [blue]0.0.0.0[/blue]." 261 | ) 262 | ), 263 | ] = uvicorn_settings.host, 264 | port: Annotated[ 265 | int, 266 | typer.Option(help="The port to serve on."), 267 | ] = uvicorn_settings.port, 268 | reload: Annotated[ 269 | bool, 270 | typer.Option( 271 | help=( 272 | "Enable auto-reload of the server when (code) files change. " 273 | "This is [bold]resource intensive[/bold], " 274 | "use it only during development." 275 | ) 276 | ), 277 | ] = uvicorn_settings.reload, 278 | workers: Annotated[ 279 | Union[int, None], 280 | typer.Option( 281 | help=( 282 | "Use multiple worker processes. " 283 | "Mutually exclusive with the --reload flag." 284 | ) 285 | ), 286 | ] = uvicorn_settings.workers, 287 | root_path: Annotated[ 288 | str, 289 | typer.Option( 290 | help=( 291 | "The root path is used to tell your app that it is being served " 292 | "to the outside world with some [bold]path prefix[/bold] " 293 | "set up in some termination proxy or similar." 294 | ) 295 | ), 296 | ] = uvicorn_settings.root_path, 297 | proxy_headers: Annotated[ 298 | bool, 299 | typer.Option( 300 | help=( 301 | "Enable/Disable X-Forwarded-Proto, X-Forwarded-For, " 302 | "X-Forwarded-Port to populate remote address info." 303 | ) 304 | ), 305 | ] = uvicorn_settings.proxy_headers, 306 | timeout_keep_alive: Annotated[ 307 | int, typer.Option(help="Timeout for the server response.") 308 | ] = uvicorn_settings.timeout_keep_alive, 309 | ssl_certfile: Annotated[ 310 | Optional[Path], typer.Option(help="SSL certificate file") 311 | ] = uvicorn_settings.ssl_certfile, 312 | ssl_keyfile: Annotated[ 313 | Optional[Path], typer.Option(help="SSL key file") 314 | ] = uvicorn_settings.ssl_keyfile, 315 | ssl_keyfile_password: Annotated[ 316 | Optional[str], typer.Option(help="SSL keyfile password") 317 | ] = uvicorn_settings.ssl_keyfile_password, 318 | # docling options 319 | artifacts_path: Annotated[ 320 | Optional[Path], 321 | typer.Option( 322 | help=( 323 | "If set to a valid directory, " 324 | "the model weights will be loaded from this path." 325 | ) 326 | ), 327 | ] = docling_serve_settings.artifacts_path, 328 | enable_ui: Annotated[ 329 | bool, typer.Option(help="Enable the development UI.") 330 | ] = docling_serve_settings.enable_ui, 331 | ) -> Any: 332 | """ 333 | Run a [bold]Docling Serve[/bold] app in [green]production[/green] mode. 🚀 334 | 335 | This is equivalent to [bold]docling-serve dev[/bold] but with [bold]reload[/bold] 336 | disabled and listening on the [blue]0.0.0.0[/blue] address. 337 | 338 | Options can be set also with the corresponding ENV variable, e.g. UVICORN_PORT 339 | or DOCLING_SERVE_ENABLE_UI. 340 | """ 341 | 342 | uvicorn_settings.host = host 343 | uvicorn_settings.port = port 344 | uvicorn_settings.reload = reload 345 | uvicorn_settings.workers = workers 346 | uvicorn_settings.root_path = root_path 347 | uvicorn_settings.proxy_headers = proxy_headers 348 | uvicorn_settings.timeout_keep_alive = timeout_keep_alive 349 | uvicorn_settings.ssl_certfile = ssl_certfile 350 | uvicorn_settings.ssl_keyfile = ssl_keyfile 351 | uvicorn_settings.ssl_keyfile_password = ssl_keyfile_password 352 | 353 | _run( 354 | command="run", 355 | artifacts_path=artifacts_path, 356 | enable_ui=enable_ui, 357 | ) 358 | 359 | 360 | def main() -> None: 361 | app() 362 | 363 | 364 | # Launch the CLI when calling python -m docling_serve 365 | if __name__ == "__main__": 366 | main() 367 | -------------------------------------------------------------------------------- /docling_serve/datamodel/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docling-project/docling-serve/524f6a8997b86d2f869ca491ec8fb40585b42ca4/docling_serve/datamodel/__init__.py -------------------------------------------------------------------------------- /docling_serve/datamodel/callback.py: -------------------------------------------------------------------------------- 1 | import enum 2 | from typing import Annotated, Literal 3 | 4 | from pydantic import BaseModel, Field 5 | 6 | 7 | class ProgressKind(str, enum.Enum): 8 | SET_NUM_DOCS = "set_num_docs" 9 | UPDATE_PROCESSED = "update_processed" 10 | 11 | 12 | class BaseProgress(BaseModel): 13 | kind: ProgressKind 14 | 15 | 16 | class ProgressSetNumDocs(BaseProgress): 17 | kind: Literal[ProgressKind.SET_NUM_DOCS] = ProgressKind.SET_NUM_DOCS 18 | 19 | num_docs: int 20 | 21 | 22 | class SucceededDocsItem(BaseModel): 23 | source: str 24 | 25 | 26 | class FailedDocsItem(BaseModel): 27 | source: str 28 | error: str 29 | 30 | 31 | class ProgressUpdateProcessed(BaseProgress): 32 | kind: Literal[ProgressKind.UPDATE_PROCESSED] = ProgressKind.UPDATE_PROCESSED 33 | 34 | num_processed: int 35 | num_succeeded: int 36 | num_failed: int 37 | 38 | docs_succeeded: list[SucceededDocsItem] 39 | docs_failed: list[FailedDocsItem] 40 | 41 | 42 | class ProgressCallbackRequest(BaseModel): 43 | task_id: str 44 | progress: Annotated[ 45 | ProgressSetNumDocs | ProgressUpdateProcessed, Field(discriminator="kind") 46 | ] 47 | 48 | 49 | class ProgressCallbackResponse(BaseModel): 50 | status: Literal["ack"] = "ack" 51 | -------------------------------------------------------------------------------- /docling_serve/datamodel/engines.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | 4 | class TaskStatus(str, enum.Enum): 5 | SUCCESS = "success" 6 | PENDING = "pending" 7 | STARTED = "started" 8 | FAILURE = "failure" 9 | 10 | 11 | class AsyncEngine(str, enum.Enum): 12 | LOCAL = "local" 13 | KFP = "kfp" 14 | -------------------------------------------------------------------------------- /docling_serve/datamodel/kfp.py: -------------------------------------------------------------------------------- 1 | from pydantic import AnyUrl, BaseModel 2 | 3 | 4 | class CallbackSpec(BaseModel): 5 | url: AnyUrl 6 | headers: dict[str, str] = {} 7 | ca_cert: str = "" 8 | -------------------------------------------------------------------------------- /docling_serve/datamodel/requests.py: -------------------------------------------------------------------------------- 1 | import base64 2 | from io import BytesIO 3 | from typing import Annotated, Any, Union 4 | 5 | from pydantic import AnyHttpUrl, BaseModel, Field 6 | 7 | from docling.datamodel.base_models import DocumentStream 8 | 9 | from docling_serve.datamodel.convert import ConvertDocumentsOptions 10 | 11 | 12 | class DocumentsConvertBase(BaseModel): 13 | options: ConvertDocumentsOptions = ConvertDocumentsOptions() 14 | 15 | 16 | class HttpSource(BaseModel): 17 | url: Annotated[ 18 | AnyHttpUrl, 19 | Field( 20 | description="HTTP url to process", 21 | examples=["https://arxiv.org/pdf/2206.01062"], 22 | ), 23 | ] 24 | headers: Annotated[ 25 | dict[str, Any], 26 | Field( 27 | description="Additional headers used to fetch the urls, " 28 | "e.g. authorization, agent, etc" 29 | ), 30 | ] = {} 31 | 32 | 33 | class FileSource(BaseModel): 34 | base64_string: Annotated[ 35 | str, 36 | Field( 37 | description="Content of the file serialized in base64. " 38 | "For example it can be obtained via " 39 | "`base64 -w 0 /path/to/file/pdf-to-convert.pdf`." 40 | ), 41 | ] 42 | filename: Annotated[ 43 | str, 44 | Field(description="Filename of the uploaded document", examples=["file.pdf"]), 45 | ] 46 | 47 | def to_document_stream(self) -> DocumentStream: 48 | buf = BytesIO(base64.b64decode(self.base64_string)) 49 | return DocumentStream(stream=buf, name=self.filename) 50 | 51 | 52 | class ConvertDocumentHttpSourcesRequest(DocumentsConvertBase): 53 | http_sources: list[HttpSource] 54 | 55 | 56 | class ConvertDocumentFileSourcesRequest(DocumentsConvertBase): 57 | file_sources: list[FileSource] 58 | 59 | 60 | ConvertDocumentsRequest = Union[ 61 | ConvertDocumentFileSourcesRequest, ConvertDocumentHttpSourcesRequest 62 | ] 63 | -------------------------------------------------------------------------------- /docling_serve/datamodel/responses.py: -------------------------------------------------------------------------------- 1 | import enum 2 | from typing import Optional 3 | 4 | from pydantic import BaseModel 5 | 6 | from docling.datamodel.document import ConversionStatus, ErrorItem 7 | from docling.utils.profiling import ProfilingItem 8 | from docling_core.types.doc import DoclingDocument 9 | 10 | from docling_serve.datamodel.task_meta import TaskProcessingMeta 11 | 12 | 13 | # Status 14 | class HealthCheckResponse(BaseModel): 15 | status: str = "ok" 16 | 17 | 18 | class ClearResponse(BaseModel): 19 | status: str = "ok" 20 | 21 | 22 | class DocumentResponse(BaseModel): 23 | filename: str 24 | md_content: Optional[str] = None 25 | json_content: Optional[DoclingDocument] = None 26 | html_content: Optional[str] = None 27 | text_content: Optional[str] = None 28 | doctags_content: Optional[str] = None 29 | 30 | 31 | class ConvertDocumentResponse(BaseModel): 32 | document: DocumentResponse 33 | status: ConversionStatus 34 | errors: list[ErrorItem] = [] 35 | processing_time: float 36 | timings: dict[str, ProfilingItem] = {} 37 | 38 | 39 | class ConvertDocumentErrorResponse(BaseModel): 40 | status: ConversionStatus 41 | 42 | 43 | class TaskStatusResponse(BaseModel): 44 | task_id: str 45 | task_status: str 46 | task_position: Optional[int] = None 47 | task_meta: Optional[TaskProcessingMeta] = None 48 | 49 | 50 | class MessageKind(str, enum.Enum): 51 | CONNECTION = "connection" 52 | UPDATE = "update" 53 | ERROR = "error" 54 | 55 | 56 | class WebsocketMessage(BaseModel): 57 | message: MessageKind 58 | task: Optional[TaskStatusResponse] = None 59 | error: Optional[str] = None 60 | -------------------------------------------------------------------------------- /docling_serve/datamodel/task.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from functools import partial 3 | from pathlib import Path 4 | from typing import Optional, Union 5 | 6 | from fastapi.responses import FileResponse 7 | from pydantic import BaseModel, ConfigDict, Field 8 | 9 | from docling.datamodel.base_models import DocumentStream 10 | 11 | from docling_serve.datamodel.convert import ConvertDocumentsOptions 12 | from docling_serve.datamodel.engines import TaskStatus 13 | from docling_serve.datamodel.requests import FileSource, HttpSource 14 | from docling_serve.datamodel.responses import ConvertDocumentResponse 15 | from docling_serve.datamodel.task_meta import TaskProcessingMeta 16 | 17 | TaskSource = Union[HttpSource, FileSource, DocumentStream] 18 | 19 | 20 | class Task(BaseModel): 21 | model_config = ConfigDict(arbitrary_types_allowed=True) 22 | 23 | task_id: str 24 | task_status: TaskStatus = TaskStatus.PENDING 25 | sources: list[TaskSource] = [] 26 | options: Optional[ConvertDocumentsOptions] 27 | result: Optional[Union[ConvertDocumentResponse, FileResponse]] = None 28 | scratch_dir: Optional[Path] = None 29 | processing_meta: Optional[TaskProcessingMeta] = None 30 | created_at: datetime.datetime = Field( 31 | default_factory=partial(datetime.datetime.now, datetime.timezone.utc) 32 | ) 33 | started_at: Optional[datetime.datetime] = None 34 | finished_at: Optional[datetime.datetime] = None 35 | last_update_at: datetime.datetime = Field( 36 | default_factory=partial(datetime.datetime.now, datetime.timezone.utc) 37 | ) 38 | 39 | def set_status(self, status: TaskStatus): 40 | now = datetime.datetime.now(datetime.timezone.utc) 41 | if status == TaskStatus.STARTED and self.started_at is None: 42 | self.started_at = now 43 | if ( 44 | status in [TaskStatus.SUCCESS, TaskStatus.FAILURE] 45 | and self.finished_at is None 46 | ): 47 | self.finished_at = now 48 | 49 | self.last_update_at = now 50 | self.task_status = status 51 | 52 | def is_completed(self) -> bool: 53 | if self.task_status in [TaskStatus.SUCCESS, TaskStatus.FAILURE]: 54 | return True 55 | return False 56 | -------------------------------------------------------------------------------- /docling_serve/datamodel/task_meta.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class TaskProcessingMeta(BaseModel): 5 | num_docs: int 6 | num_processed: int = 0 7 | num_succeeded: int = 0 8 | num_failed: int = 0 9 | -------------------------------------------------------------------------------- /docling_serve/docling_conversion.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import json 3 | import logging 4 | import sys 5 | from collections.abc import Iterable, Iterator 6 | from functools import lru_cache 7 | from pathlib import Path 8 | from typing import Any, Optional, Union 9 | 10 | from fastapi import HTTPException 11 | 12 | from docling.backend.docling_parse_backend import DoclingParseDocumentBackend 13 | from docling.backend.docling_parse_v2_backend import DoclingParseV2DocumentBackend 14 | from docling.backend.docling_parse_v4_backend import DoclingParseV4DocumentBackend 15 | from docling.backend.pdf_backend import PdfDocumentBackend 16 | from docling.backend.pypdfium2_backend import PyPdfiumDocumentBackend 17 | from docling.datamodel.base_models import DocumentStream, InputFormat 18 | from docling.datamodel.document import ConversionResult 19 | from docling.datamodel.pipeline_options import ( 20 | OcrOptions, 21 | PdfBackend, 22 | PdfPipeline, 23 | PdfPipelineOptions, 24 | PictureDescriptionApiOptions, 25 | PictureDescriptionVlmOptions, 26 | TableFormerMode, 27 | VlmPipelineOptions, 28 | smoldocling_vlm_conversion_options, 29 | smoldocling_vlm_mlx_conversion_options, 30 | ) 31 | from docling.document_converter import DocumentConverter, FormatOption, PdfFormatOption 32 | from docling.pipeline.vlm_pipeline import VlmPipeline 33 | from docling_core.types.doc import ImageRefMode 34 | 35 | from docling_serve.datamodel.convert import ConvertDocumentsOptions, ocr_factory 36 | from docling_serve.helper_functions import _to_list_of_strings 37 | from docling_serve.settings import docling_serve_settings 38 | 39 | _log = logging.getLogger(__name__) 40 | 41 | 42 | # Custom serializer for PdfFormatOption 43 | # (model_dump_json does not work with some classes) 44 | def _hash_pdf_format_option(pdf_format_option: PdfFormatOption) -> bytes: 45 | data = pdf_format_option.model_dump(serialize_as_any=True) 46 | 47 | # pipeline_options are not fully serialized by model_dump, dedicated pass 48 | if pdf_format_option.pipeline_options: 49 | data["pipeline_options"] = pdf_format_option.pipeline_options.model_dump( 50 | serialize_as_any=True, mode="json" 51 | ) 52 | 53 | # Replace `pipeline_cls` with a string representation 54 | data["pipeline_cls"] = repr(data["pipeline_cls"]) 55 | 56 | # Replace `backend` with a string representation 57 | data["backend"] = repr(data["backend"]) 58 | 59 | # Serialize the dictionary to JSON with sorted keys to have consistent hashes 60 | serialized_data = json.dumps(data, sort_keys=True) 61 | options_hash = hashlib.sha1( 62 | serialized_data.encode(), usedforsecurity=False 63 | ).digest() 64 | return options_hash 65 | 66 | 67 | # Cache of DocumentConverter objects 68 | _options_map: dict[bytes, PdfFormatOption] = {} 69 | 70 | 71 | @lru_cache(maxsize=docling_serve_settings.options_cache_size) 72 | def _get_converter_from_hash(options_hash: bytes) -> DocumentConverter: 73 | pdf_format_option = _options_map[options_hash] 74 | format_options: dict[InputFormat, FormatOption] = { 75 | InputFormat.PDF: pdf_format_option, 76 | InputFormat.IMAGE: pdf_format_option, 77 | } 78 | 79 | return DocumentConverter(format_options=format_options) 80 | 81 | 82 | def get_converter(pdf_format_option: PdfFormatOption) -> DocumentConverter: 83 | options_hash = _hash_pdf_format_option(pdf_format_option) 84 | _options_map[options_hash] = pdf_format_option 85 | return _get_converter_from_hash(options_hash) 86 | 87 | 88 | def _parse_standard_pdf_opts( 89 | request: ConvertDocumentsOptions, artifacts_path: Optional[Path] 90 | ) -> PdfPipelineOptions: 91 | try: 92 | ocr_options: OcrOptions = ocr_factory.create_options( 93 | kind=request.ocr_engine.value, # type: ignore 94 | force_full_page_ocr=request.force_ocr, 95 | ) 96 | except ImportError as err: 97 | raise HTTPException( 98 | status_code=400, 99 | detail="The requested OCR engine" 100 | f" (ocr_engine={request.ocr_engine.value})" # type: ignore 101 | " is not available on this system. Please choose another OCR engine " 102 | "or contact your system administrator.\n" 103 | f"{err}", 104 | ) 105 | 106 | if request.ocr_lang is not None: 107 | if isinstance(request.ocr_lang, str): 108 | ocr_options.lang = _to_list_of_strings(request.ocr_lang) 109 | else: 110 | ocr_options.lang = request.ocr_lang 111 | 112 | pipeline_options = PdfPipelineOptions( 113 | artifacts_path=artifacts_path, 114 | enable_remote_services=docling_serve_settings.enable_remote_services, 115 | document_timeout=request.document_timeout, 116 | do_ocr=request.do_ocr, 117 | ocr_options=ocr_options, 118 | do_table_structure=request.do_table_structure, 119 | do_code_enrichment=request.do_code_enrichment, 120 | do_formula_enrichment=request.do_formula_enrichment, 121 | do_picture_classification=request.do_picture_classification, 122 | do_picture_description=request.do_picture_description, 123 | ) 124 | pipeline_options.table_structure_options.mode = TableFormerMode(request.table_mode) 125 | 126 | if request.image_export_mode != ImageRefMode.PLACEHOLDER: 127 | pipeline_options.generate_page_images = True 128 | if request.image_export_mode == ImageRefMode.REFERENCED: 129 | pipeline_options.generate_picture_images = True 130 | if request.images_scale: 131 | pipeline_options.images_scale = request.images_scale 132 | 133 | if request.picture_description_local is not None: 134 | pipeline_options.picture_description_options = ( 135 | PictureDescriptionVlmOptions.model_validate( 136 | request.picture_description_local.model_dump() 137 | ) 138 | ) 139 | 140 | if request.picture_description_api is not None: 141 | pipeline_options.picture_description_options = ( 142 | PictureDescriptionApiOptions.model_validate( 143 | request.picture_description_api.model_dump() 144 | ) 145 | ) 146 | pipeline_options.picture_description_options.picture_area_threshold = ( 147 | request.picture_description_area_threshold 148 | ) 149 | 150 | return pipeline_options 151 | 152 | 153 | def _parse_backend(request: ConvertDocumentsOptions) -> type[PdfDocumentBackend]: 154 | if request.pdf_backend == PdfBackend.DLPARSE_V1: 155 | backend: type[PdfDocumentBackend] = DoclingParseDocumentBackend 156 | elif request.pdf_backend == PdfBackend.DLPARSE_V2: 157 | backend = DoclingParseV2DocumentBackend 158 | elif request.pdf_backend == PdfBackend.DLPARSE_V4: 159 | backend = DoclingParseV4DocumentBackend 160 | elif request.pdf_backend == PdfBackend.PYPDFIUM2: 161 | backend = PyPdfiumDocumentBackend 162 | else: 163 | raise RuntimeError(f"Unexpected PDF backend type {request.pdf_backend}") 164 | 165 | return backend 166 | 167 | 168 | def _parse_vlm_pdf_opts( 169 | request: ConvertDocumentsOptions, artifacts_path: Optional[Path] 170 | ) -> VlmPipelineOptions: 171 | pipeline_options = VlmPipelineOptions( 172 | artifacts_path=artifacts_path, 173 | document_timeout=request.document_timeout, 174 | ) 175 | pipeline_options.vlm_options = smoldocling_vlm_conversion_options 176 | if sys.platform == "darwin": 177 | try: 178 | import mlx_vlm # noqa: F401 179 | 180 | pipeline_options.vlm_options = smoldocling_vlm_mlx_conversion_options 181 | except ImportError: 182 | _log.warning( 183 | "To run SmolDocling faster, please install mlx-vlm:\n" 184 | "pip install mlx-vlm" 185 | ) 186 | return pipeline_options 187 | 188 | 189 | # Computes the PDF pipeline options and returns the PdfFormatOption and its hash 190 | def get_pdf_pipeline_opts( 191 | request: ConvertDocumentsOptions, 192 | ) -> PdfFormatOption: 193 | artifacts_path: Optional[Path] = None 194 | if docling_serve_settings.artifacts_path is not None: 195 | if str(docling_serve_settings.artifacts_path.absolute()) == "": 196 | _log.info( 197 | "artifacts_path is an empty path, model weights will be downloaded " 198 | "at runtime." 199 | ) 200 | artifacts_path = None 201 | elif docling_serve_settings.artifacts_path.is_dir(): 202 | _log.info( 203 | "artifacts_path is set to a valid directory. " 204 | "No model weights will be downloaded at runtime." 205 | ) 206 | artifacts_path = docling_serve_settings.artifacts_path 207 | else: 208 | _log.warning( 209 | "artifacts_path is set to an invalid directory. " 210 | "The system will download the model weights at runtime." 211 | ) 212 | artifacts_path = None 213 | else: 214 | _log.info( 215 | "artifacts_path is unset. " 216 | "The system will download the model weights at runtime." 217 | ) 218 | 219 | pipeline_options: Union[PdfPipelineOptions, VlmPipelineOptions] 220 | if request.pipeline == PdfPipeline.STANDARD: 221 | pipeline_options = _parse_standard_pdf_opts(request, artifacts_path) 222 | backend = _parse_backend(request) 223 | pdf_format_option = PdfFormatOption( 224 | pipeline_options=pipeline_options, 225 | backend=backend, 226 | ) 227 | 228 | elif request.pipeline == PdfPipeline.VLM: 229 | pipeline_options = _parse_vlm_pdf_opts(request, artifacts_path) 230 | pdf_format_option = PdfFormatOption( 231 | pipeline_cls=VlmPipeline, pipeline_options=pipeline_options 232 | ) 233 | else: 234 | raise NotImplementedError( 235 | f"The pipeline {request.pipeline} is not implemented." 236 | ) 237 | 238 | return pdf_format_option 239 | 240 | 241 | def convert_documents( 242 | sources: Iterable[Union[Path, str, DocumentStream]], 243 | options: ConvertDocumentsOptions, 244 | headers: Optional[dict[str, Any]] = None, 245 | ): 246 | pdf_format_option = get_pdf_pipeline_opts(options) 247 | converter = get_converter(pdf_format_option) 248 | results: Iterator[ConversionResult] = converter.convert_all( 249 | sources, 250 | headers=headers, 251 | page_range=options.page_range, 252 | max_file_size=docling_serve_settings.max_file_size, 253 | max_num_pages=docling_serve_settings.max_num_pages, 254 | ) 255 | 256 | return results 257 | -------------------------------------------------------------------------------- /docling_serve/engines/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docling-project/docling-serve/524f6a8997b86d2f869ca491ec8fb40585b42ca4/docling_serve/engines/__init__.py -------------------------------------------------------------------------------- /docling_serve/engines/async_kfp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docling-project/docling-serve/524f6a8997b86d2f869ca491ec8fb40585b42ca4/docling_serve/engines/async_kfp/__init__.py -------------------------------------------------------------------------------- /docling_serve/engines/async_kfp/kfp_pipeline.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: E402, UP006, UP035 2 | 3 | from typing import Any, Dict, List 4 | 5 | from kfp import dsl 6 | 7 | PYTHON_BASE_IMAGE = "python:3.12" 8 | 9 | 10 | @dsl.component( 11 | base_image=PYTHON_BASE_IMAGE, 12 | packages_to_install=[ 13 | "pydantic", 14 | "docling-serve @ git+https://github.com/docling-project/docling-serve@feat-kfp-engine", 15 | ], 16 | pip_index_urls=["https://download.pytorch.org/whl/cpu", "https://pypi.org/simple"], 17 | ) 18 | def generate_chunks( 19 | run_name: str, 20 | request: Dict[str, Any], 21 | batch_size: int, 22 | callbacks: List[Dict[str, Any]], 23 | ) -> List[List[Dict[str, Any]]]: 24 | from pydantic import TypeAdapter 25 | 26 | from docling_serve.datamodel.callback import ( 27 | ProgressCallbackRequest, 28 | ProgressSetNumDocs, 29 | ) 30 | from docling_serve.datamodel.kfp import CallbackSpec 31 | from docling_serve.engines.async_kfp.notify import notify_callbacks 32 | 33 | CallbacksListType = TypeAdapter(list[CallbackSpec]) 34 | 35 | sources = request["http_sources"] 36 | splits = [sources[i : i + batch_size] for i in range(0, len(sources), batch_size)] 37 | 38 | total = sum(len(chunk) for chunk in splits) 39 | payload = ProgressCallbackRequest( 40 | task_id=run_name, progress=ProgressSetNumDocs(num_docs=total) 41 | ) 42 | notify_callbacks( 43 | payload=payload, 44 | callbacks=CallbacksListType.validate_python(callbacks), 45 | ) 46 | 47 | return splits 48 | 49 | 50 | @dsl.component( 51 | base_image=PYTHON_BASE_IMAGE, 52 | packages_to_install=[ 53 | "pydantic", 54 | "docling-serve @ git+https://github.com/docling-project/docling-serve@feat-kfp-engine", 55 | ], 56 | pip_index_urls=["https://download.pytorch.org/whl/cpu", "https://pypi.org/simple"], 57 | ) 58 | def convert_batch( 59 | run_name: str, 60 | data_splits: List[Dict[str, Any]], 61 | request: Dict[str, Any], 62 | callbacks: List[Dict[str, Any]], 63 | output_path: dsl.OutputPath("Directory"), # type: ignore 64 | ): 65 | from pathlib import Path 66 | 67 | from pydantic import AnyUrl, TypeAdapter 68 | 69 | from docling_serve.datamodel.callback import ( 70 | FailedDocsItem, 71 | ProgressCallbackRequest, 72 | ProgressUpdateProcessed, 73 | SucceededDocsItem, 74 | ) 75 | from docling_serve.datamodel.convert import ConvertDocumentsOptions 76 | from docling_serve.datamodel.kfp import CallbackSpec 77 | from docling_serve.datamodel.requests import HttpSource 78 | from docling_serve.engines.async_kfp.notify import notify_callbacks 79 | 80 | CallbacksListType = TypeAdapter(list[CallbackSpec]) 81 | 82 | convert_options = ConvertDocumentsOptions.model_validate(request["options"]) 83 | print(convert_options) 84 | 85 | output_dir = Path(output_path) 86 | output_dir.mkdir(exist_ok=True, parents=True) 87 | docs_succeeded: list[SucceededDocsItem] = [] 88 | docs_failed: list[FailedDocsItem] = [] 89 | for source_dict in data_splits: 90 | source = HttpSource.model_validate(source_dict) 91 | filename = Path(str(AnyUrl(source.url).path)).name 92 | output_filename = output_dir / filename 93 | print(f"Writing {output_filename}") 94 | with output_filename.open("w") as f: 95 | f.write(source.model_dump_json()) 96 | docs_succeeded.append(SucceededDocsItem(source=source.url)) 97 | 98 | payload = ProgressCallbackRequest( 99 | task_id=run_name, 100 | progress=ProgressUpdateProcessed( 101 | num_failed=len(docs_failed), 102 | num_processed=len(docs_succeeded) + len(docs_failed), 103 | num_succeeded=len(docs_succeeded), 104 | docs_succeeded=docs_succeeded, 105 | docs_failed=docs_failed, 106 | ), 107 | ) 108 | 109 | print(payload) 110 | notify_callbacks( 111 | payload=payload, 112 | callbacks=CallbacksListType.validate_python(callbacks), 113 | ) 114 | 115 | 116 | @dsl.pipeline() 117 | def process( 118 | batch_size: int, 119 | request: Dict[str, Any], 120 | callbacks: List[Dict[str, Any]] = [], 121 | run_name: str = "", 122 | ): 123 | chunks_task = generate_chunks( 124 | run_name=run_name, 125 | request=request, 126 | batch_size=batch_size, 127 | callbacks=callbacks, 128 | ) 129 | chunks_task.set_caching_options(False) 130 | 131 | with dsl.ParallelFor(chunks_task.output, parallelism=4) as data_splits: 132 | convert_batch( 133 | run_name=run_name, 134 | data_splits=data_splits, 135 | request=request, 136 | callbacks=callbacks, 137 | ) 138 | -------------------------------------------------------------------------------- /docling_serve/engines/async_kfp/notify.py: -------------------------------------------------------------------------------- 1 | import ssl 2 | 3 | import certifi 4 | import httpx 5 | 6 | from docling_serve.datamodel.callback import ProgressCallbackRequest 7 | from docling_serve.datamodel.kfp import CallbackSpec 8 | 9 | 10 | def notify_callbacks( 11 | payload: ProgressCallbackRequest, 12 | callbacks: list[CallbackSpec], 13 | ): 14 | if len(callbacks) == 0: 15 | return 16 | 17 | for callback in callbacks: 18 | # https://www.python-httpx.org/advanced/ssl/#configuring-client-instances 19 | if callback.ca_cert: 20 | ctx = ssl.create_default_context(cadata=callback.ca_cert) 21 | else: 22 | ctx = ssl.create_default_context(cafile=certifi.where()) 23 | 24 | try: 25 | httpx.post( 26 | str(callback.url), 27 | headers=callback.headers, 28 | json=payload.model_dump(mode="json"), 29 | verify=ctx, 30 | ) 31 | except httpx.HTTPError as err: 32 | print(f"Error notifying callback {callback.url}: {err}") 33 | -------------------------------------------------------------------------------- /docling_serve/engines/async_kfp/orchestrator.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | import logging 4 | import uuid 5 | from pathlib import Path 6 | from typing import Optional 7 | 8 | from kfp_server_api.models import V2beta1RuntimeState 9 | from pydantic import BaseModel, TypeAdapter 10 | from pydantic_settings import SettingsConfigDict 11 | 12 | from docling_serve.datamodel.callback import ( 13 | ProgressCallbackRequest, 14 | ProgressSetNumDocs, 15 | ProgressUpdateProcessed, 16 | ) 17 | from docling_serve.datamodel.convert import ConvertDocumentsOptions 18 | from docling_serve.datamodel.engines import TaskStatus 19 | from docling_serve.datamodel.kfp import CallbackSpec 20 | from docling_serve.datamodel.requests import HttpSource 21 | from docling_serve.datamodel.task import Task, TaskSource 22 | from docling_serve.datamodel.task_meta import TaskProcessingMeta 23 | from docling_serve.engines.async_kfp.kfp_pipeline import process 24 | from docling_serve.engines.async_orchestrator import ( 25 | BaseAsyncOrchestrator, 26 | ProgressInvalid, 27 | ) 28 | from docling_serve.settings import docling_serve_settings 29 | 30 | _log = logging.getLogger(__name__) 31 | 32 | 33 | class _RunItem(BaseModel): 34 | model_config = SettingsConfigDict(arbitrary_types_allowed=True) 35 | 36 | run_id: str 37 | state: str 38 | created_at: datetime.datetime 39 | scheduled_at: datetime.datetime 40 | finished_at: datetime.datetime 41 | 42 | 43 | class AsyncKfpOrchestrator(BaseAsyncOrchestrator): 44 | def __init__(self): 45 | super().__init__() 46 | import kfp 47 | 48 | kfp_endpoint = docling_serve_settings.eng_kfp_endpoint 49 | if kfp_endpoint is None: 50 | raise ValueError("KFP endpoint is required when using the KFP engine.") 51 | 52 | kube_sa_token_path = Path("/run/secrets/kubernetes.io/serviceaccount/token") 53 | kube_sa_ca_cert_path = Path( 54 | "/run/secrets/kubernetes.io/serviceaccount/service-ca.crt" 55 | ) 56 | 57 | ssl_ca_cert = docling_serve_settings.eng_kfp_ca_cert_path 58 | token = docling_serve_settings.eng_kfp_token 59 | if ( 60 | ssl_ca_cert is None 61 | and ".svc" in kfp_endpoint.host 62 | and kube_sa_ca_cert_path.exists() 63 | ): 64 | ssl_ca_cert = str(kube_sa_ca_cert_path) 65 | if token is None and kube_sa_token_path.exists(): 66 | token = kube_sa_token_path.read_text() 67 | 68 | self._client = kfp.Client( 69 | host=str(kfp_endpoint), 70 | existing_token=token, 71 | ssl_ca_cert=ssl_ca_cert, 72 | # verify_ssl=False, 73 | ) 74 | 75 | async def enqueue( 76 | self, sources: list[TaskSource], options: ConvertDocumentsOptions 77 | ) -> Task: 78 | callbacks = [] 79 | if docling_serve_settings.eng_kfp_self_callback_endpoint is not None: 80 | headers = {} 81 | if docling_serve_settings.eng_kfp_self_callback_token_path is not None: 82 | token = ( 83 | docling_serve_settings.eng_kfp_self_callback_token_path.read_text() 84 | ) 85 | headers["Authorization"] = f"Bearer {token}" 86 | ca_cert = "" 87 | if docling_serve_settings.eng_kfp_self_callback_ca_cert_path is not None: 88 | ca_cert = docling_serve_settings.eng_kfp_self_callback_ca_cert_path.read_text() 89 | callbacks.append( 90 | CallbackSpec( 91 | url=docling_serve_settings.eng_kfp_self_callback_endpoint, 92 | headers=headers, 93 | ca_cert=ca_cert, 94 | ) 95 | ) 96 | 97 | CallbacksType = TypeAdapter(list[CallbackSpec]) 98 | SourcesListType = TypeAdapter(list[HttpSource]) 99 | http_sources = [s for s in sources if isinstance(s, HttpSource)] 100 | # hack: since the current kfp backend is not resolving the job_id placeholder, 101 | # we set the run_name and pass it as argument to the job itself. 102 | run_name = f"docling-job-{uuid.uuid4()}" 103 | kfp_run = self._client.create_run_from_pipeline_func( 104 | process, 105 | arguments={ 106 | "batch_size": 10, 107 | "sources": SourcesListType.dump_python(http_sources, mode="json"), 108 | "options": options.model_dump(mode="json"), 109 | "callbacks": CallbacksType.dump_python(callbacks, mode="json"), 110 | "run_name": run_name, 111 | }, 112 | run_name=run_name, 113 | ) 114 | task_id = kfp_run.run_id 115 | 116 | task = Task(task_id=task_id, sources=sources, options=options) 117 | await self.init_task_tracking(task) 118 | return task 119 | 120 | async def _update_task_from_run(self, task_id: str, wait: float = 0.0): 121 | run_info = self._client.get_run(run_id=task_id) 122 | task = await self.get_raw_task(task_id=task_id) 123 | # RUNTIME_STATE_UNSPECIFIED = "RUNTIME_STATE_UNSPECIFIED" 124 | # PENDING = "PENDING" 125 | # RUNNING = "RUNNING" 126 | # SUCCEEDED = "SUCCEEDED" 127 | # SKIPPED = "SKIPPED" 128 | # FAILED = "FAILED" 129 | # CANCELING = "CANCELING" 130 | # CANCELED = "CANCELED" 131 | # PAUSED = "PAUSED" 132 | if run_info.state == V2beta1RuntimeState.SUCCEEDED: 133 | task.set_status(TaskStatus.SUCCESS) 134 | elif run_info.state == V2beta1RuntimeState.PENDING: 135 | task.set_status(TaskStatus.PENDING) 136 | elif run_info.state == V2beta1RuntimeState.RUNNING: 137 | task.set_status(TaskStatus.STARTED) 138 | else: 139 | task.set_status(TaskStatus.FAILURE) 140 | 141 | async def task_status(self, task_id: str, wait: float = 0.0) -> Task: 142 | await self._update_task_from_run(task_id=task_id, wait=wait) 143 | return await self.get_raw_task(task_id=task_id) 144 | 145 | async def _get_pending(self) -> list[_RunItem]: 146 | runs: list[_RunItem] = [] 147 | next_page: Optional[str] = None 148 | while True: 149 | res = self._client.list_runs( 150 | page_token=next_page, 151 | page_size=20, 152 | filter=json.dumps( 153 | { 154 | "predicates": [ 155 | { 156 | "operation": "EQUALS", 157 | "key": "state", 158 | "stringValue": "PENDING", 159 | } 160 | ] 161 | } 162 | ), 163 | ) 164 | if res.runs is not None: 165 | for run in res.runs: 166 | runs.append( 167 | _RunItem( 168 | run_id=run.run_id, 169 | state=run.state, 170 | created_at=run.created_at, 171 | scheduled_at=run.scheduled_at, 172 | finished_at=run.finished_at, 173 | ) 174 | ) 175 | if res.next_page_token is None: 176 | break 177 | next_page = res.next_page_token 178 | return runs 179 | 180 | async def queue_size(self) -> int: 181 | runs = await self._get_pending() 182 | return len(runs) 183 | 184 | async def get_queue_position(self, task_id: str) -> Optional[int]: 185 | runs = await self._get_pending() 186 | for pos, run in enumerate(runs, start=1): 187 | if run.run_id == task_id: 188 | return pos 189 | return None 190 | 191 | async def process_queue(self): 192 | return 193 | 194 | async def warm_up_caches(self): 195 | return 196 | 197 | async def _get_run_id(self, run_name: str) -> str: 198 | res = self._client.list_runs( 199 | filter=json.dumps( 200 | { 201 | "predicates": [ 202 | { 203 | "operation": "EQUALS", 204 | "key": "name", 205 | "stringValue": run_name, 206 | } 207 | ] 208 | } 209 | ), 210 | ) 211 | if res.runs is not None and len(res.runs) > 0: 212 | return res.runs[0].run_id 213 | raise RuntimeError(f"Run with {run_name=} not found.") 214 | 215 | async def receive_task_progress(self, request: ProgressCallbackRequest): 216 | task_id = await self._get_run_id(run_name=request.task_id) 217 | progress = request.progress 218 | task = await self.get_raw_task(task_id=task_id) 219 | 220 | if isinstance(progress, ProgressSetNumDocs): 221 | task.processing_meta = TaskProcessingMeta(num_docs=progress.num_docs) 222 | task.task_status = TaskStatus.STARTED 223 | 224 | elif isinstance(progress, ProgressUpdateProcessed): 225 | if task.processing_meta is None: 226 | raise ProgressInvalid( 227 | "UpdateProcessed was called before setting the expected number of documents." 228 | ) 229 | task.processing_meta.num_processed += progress.num_processed 230 | task.processing_meta.num_succeeded += progress.num_succeeded 231 | task.processing_meta.num_failed += progress.num_failed 232 | task.task_status = TaskStatus.STARTED 233 | 234 | # TODO: could be moved to BackgroundTask 235 | await self.notify_task_subscribers(task_id=task_id) 236 | -------------------------------------------------------------------------------- /docling_serve/engines/async_local/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docling-project/docling-serve/524f6a8997b86d2f869ca491ec8fb40585b42ca4/docling_serve/engines/async_local/__init__.py -------------------------------------------------------------------------------- /docling_serve/engines/async_local/orchestrator.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import uuid 4 | from typing import Optional 5 | 6 | from docling_serve.datamodel.convert import ConvertDocumentsOptions 7 | from docling_serve.datamodel.task import Task, TaskSource 8 | from docling_serve.docling_conversion import get_converter, get_pdf_pipeline_opts 9 | from docling_serve.engines.async_local.worker import AsyncLocalWorker 10 | from docling_serve.engines.async_orchestrator import BaseAsyncOrchestrator 11 | from docling_serve.settings import docling_serve_settings 12 | 13 | _log = logging.getLogger(__name__) 14 | 15 | 16 | class AsyncLocalOrchestrator(BaseAsyncOrchestrator): 17 | def __init__(self): 18 | super().__init__() 19 | self.task_queue = asyncio.Queue() 20 | self.queue_list: list[str] = [] 21 | 22 | async def enqueue( 23 | self, sources: list[TaskSource], options: ConvertDocumentsOptions 24 | ) -> Task: 25 | task_id = str(uuid.uuid4()) 26 | task = Task(task_id=task_id, sources=sources, options=options) 27 | await self.init_task_tracking(task) 28 | 29 | self.queue_list.append(task_id) 30 | await self.task_queue.put(task_id) 31 | return task 32 | 33 | async def queue_size(self) -> int: 34 | return self.task_queue.qsize() 35 | 36 | async def get_queue_position(self, task_id: str) -> Optional[int]: 37 | return ( 38 | self.queue_list.index(task_id) + 1 if task_id in self.queue_list else None 39 | ) 40 | 41 | async def process_queue(self): 42 | # Create a pool of workers 43 | workers = [] 44 | for i in range(docling_serve_settings.eng_loc_num_workers): 45 | _log.debug(f"Starting worker {i}") 46 | w = AsyncLocalWorker(i, self) 47 | worker_task = asyncio.create_task(w.loop()) 48 | workers.append(worker_task) 49 | 50 | # Wait for all workers to complete (they won't, as they run indefinitely) 51 | await asyncio.gather(*workers) 52 | _log.debug("All workers completed.") 53 | 54 | async def warm_up_caches(self): 55 | # Converter with default options 56 | pdf_format_option = get_pdf_pipeline_opts(ConvertDocumentsOptions()) 57 | get_converter(pdf_format_option) 58 | -------------------------------------------------------------------------------- /docling_serve/engines/async_local/worker.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import shutil 4 | import time 5 | from typing import TYPE_CHECKING, Any, Optional, Union 6 | 7 | from fastapi.responses import FileResponse 8 | 9 | from docling.datamodel.base_models import DocumentStream 10 | 11 | from docling_serve.datamodel.engines import TaskStatus 12 | from docling_serve.datamodel.requests import FileSource, HttpSource 13 | from docling_serve.docling_conversion import convert_documents 14 | from docling_serve.response_preparation import process_results 15 | from docling_serve.storage import get_scratch 16 | 17 | if TYPE_CHECKING: 18 | from docling_serve.engines.async_local.orchestrator import AsyncLocalOrchestrator 19 | 20 | _log = logging.getLogger(__name__) 21 | 22 | 23 | class AsyncLocalWorker: 24 | def __init__(self, worker_id: int, orchestrator: "AsyncLocalOrchestrator"): 25 | self.worker_id = worker_id 26 | self.orchestrator = orchestrator 27 | 28 | async def loop(self): 29 | _log.debug(f"Starting loop for worker {self.worker_id}") 30 | while True: 31 | task_id: str = await self.orchestrator.task_queue.get() 32 | self.orchestrator.queue_list.remove(task_id) 33 | 34 | if task_id not in self.orchestrator.tasks: 35 | raise RuntimeError(f"Task {task_id} not found.") 36 | task = self.orchestrator.tasks[task_id] 37 | 38 | try: 39 | task.set_status(TaskStatus.STARTED) 40 | _log.info(f"Worker {self.worker_id} processing task {task_id}") 41 | 42 | # Notify clients about task updates 43 | await self.orchestrator.notify_task_subscribers(task_id) 44 | 45 | # Notify clients about queue updates 46 | await self.orchestrator.notify_queue_positions() 47 | 48 | # Define a callback function to send progress updates to the client. 49 | # TODO: send partial updates, e.g. when a document in the batch is done 50 | def run_conversion(): 51 | convert_sources: list[Union[str, DocumentStream]] = [] 52 | headers: Optional[dict[str, Any]] = None 53 | for source in task.sources: 54 | if isinstance(source, DocumentStream): 55 | convert_sources.append(source) 56 | elif isinstance(source, FileSource): 57 | convert_sources.append(source.to_document_stream()) 58 | elif isinstance(source, HttpSource): 59 | convert_sources.append(str(source.url)) 60 | if headers is None and source.headers: 61 | headers = source.headers 62 | 63 | # Note: results are only an iterator->lazy evaluation 64 | results = convert_documents( 65 | sources=convert_sources, 66 | options=task.options, 67 | headers=headers, 68 | ) 69 | 70 | # The real processing will happen here 71 | work_dir = get_scratch() / task_id 72 | response = process_results( 73 | conversion_options=task.options, 74 | conv_results=results, 75 | work_dir=work_dir, 76 | ) 77 | 78 | if work_dir.exists(): 79 | task.scratch_dir = work_dir 80 | if not isinstance(response, FileResponse): 81 | _log.warning( 82 | f"Task {task_id=} produced content in {work_dir=} but the response is not a file." 83 | ) 84 | shutil.rmtree(work_dir, ignore_errors=True) 85 | 86 | return response 87 | 88 | start_time = time.monotonic() 89 | 90 | # Run the prediction in a thread to avoid blocking the event loop. 91 | # Get the current event loop 92 | # loop = asyncio.get_event_loop() 93 | # future = asyncio.run_coroutine_threadsafe( 94 | # run_conversion(), 95 | # loop=loop 96 | # ) 97 | # response = future.result() 98 | 99 | # Run in a thread 100 | response = await asyncio.to_thread( 101 | run_conversion, 102 | ) 103 | processing_time = time.monotonic() - start_time 104 | 105 | task.result = response 106 | task.sources = [] 107 | task.options = None 108 | 109 | task.set_status(TaskStatus.SUCCESS) 110 | _log.info( 111 | f"Worker {self.worker_id} completed job {task_id} " 112 | f"in {processing_time:.2f} seconds" 113 | ) 114 | 115 | except Exception as e: 116 | _log.error( 117 | f"Worker {self.worker_id} failed to process job {task_id}: {e}" 118 | ) 119 | task.set_status(TaskStatus.FAILURE) 120 | 121 | finally: 122 | await self.orchestrator.notify_task_subscribers(task_id) 123 | self.orchestrator.task_queue.task_done() 124 | _log.debug(f"Worker {self.worker_id} completely done with {task_id}") 125 | -------------------------------------------------------------------------------- /docling_serve/engines/async_orchestrator.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import datetime 3 | import logging 4 | import shutil 5 | from typing import Union 6 | 7 | from fastapi import BackgroundTasks, WebSocket 8 | from fastapi.responses import FileResponse 9 | 10 | from docling_serve.datamodel.callback import ProgressCallbackRequest 11 | from docling_serve.datamodel.engines import TaskStatus 12 | from docling_serve.datamodel.responses import ( 13 | ConvertDocumentResponse, 14 | MessageKind, 15 | TaskStatusResponse, 16 | WebsocketMessage, 17 | ) 18 | from docling_serve.datamodel.task import Task 19 | from docling_serve.engines.base_orchestrator import ( 20 | BaseOrchestrator, 21 | OrchestratorError, 22 | TaskNotFoundError, 23 | ) 24 | from docling_serve.settings import docling_serve_settings 25 | 26 | _log = logging.getLogger(__name__) 27 | 28 | 29 | class ProgressInvalid(OrchestratorError): 30 | pass 31 | 32 | 33 | class BaseAsyncOrchestrator(BaseOrchestrator): 34 | def __init__(self): 35 | self.tasks: dict[str, Task] = {} 36 | self.task_subscribers: dict[str, set[WebSocket]] = {} 37 | 38 | async def init_task_tracking(self, task: Task): 39 | task_id = task.task_id 40 | self.tasks[task.task_id] = task 41 | self.task_subscribers[task_id] = set() 42 | 43 | async def get_raw_task(self, task_id: str) -> Task: 44 | if task_id not in self.tasks: 45 | raise TaskNotFoundError() 46 | return self.tasks[task_id] 47 | 48 | async def task_status(self, task_id: str, wait: float = 0.0) -> Task: 49 | return await self.get_raw_task(task_id=task_id) 50 | 51 | async def task_result( 52 | self, task_id: str, background_tasks: BackgroundTasks 53 | ) -> Union[ConvertDocumentResponse, FileResponse, None]: 54 | try: 55 | task = await self.get_raw_task(task_id=task_id) 56 | if task.is_completed() and docling_serve_settings.single_use_results: 57 | if task.scratch_dir is not None: 58 | background_tasks.add_task( 59 | shutil.rmtree, task.scratch_dir, ignore_errors=True 60 | ) 61 | 62 | async def _remove_task_impl(): 63 | await asyncio.sleep(docling_serve_settings.result_removal_delay) 64 | await self.delete_task(task_id=task.task_id) 65 | 66 | async def _remove_task(): 67 | asyncio.create_task(_remove_task_impl()) # noqa: RUF006 68 | 69 | background_tasks.add_task(_remove_task) 70 | 71 | return task.result 72 | except TaskNotFoundError: 73 | return None 74 | 75 | async def delete_task(self, task_id: str): 76 | _log.info(f"Deleting {task_id=}") 77 | if task_id in self.task_subscribers: 78 | for websocket in self.task_subscribers[task_id]: 79 | await websocket.close() 80 | 81 | del self.task_subscribers[task_id] 82 | 83 | if task_id in self.tasks: 84 | del self.tasks[task_id] 85 | 86 | async def clear_results(self, older_than: float = 0.0): 87 | cutoff_time = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta( 88 | seconds=older_than 89 | ) 90 | 91 | tasks_to_delete = [ 92 | task_id 93 | for task_id, task in self.tasks.items() 94 | if task.finished_at is not None and task.finished_at < cutoff_time 95 | ] 96 | for task_id in tasks_to_delete: 97 | await self.delete_task(task_id=task_id) 98 | 99 | async def notify_task_subscribers(self, task_id: str): 100 | if task_id not in self.task_subscribers: 101 | raise RuntimeError(f"Task {task_id} does not have a subscribers list.") 102 | 103 | task = await self.get_raw_task(task_id=task_id) 104 | task_queue_position = await self.get_queue_position(task_id) 105 | msg = TaskStatusResponse( 106 | task_id=task.task_id, 107 | task_status=task.task_status, 108 | task_position=task_queue_position, 109 | task_meta=task.processing_meta, 110 | ) 111 | for websocket in self.task_subscribers[task_id]: 112 | await websocket.send_text( 113 | WebsocketMessage(message=MessageKind.UPDATE, task=msg).model_dump_json() 114 | ) 115 | if task.is_completed(): 116 | await websocket.close() 117 | 118 | async def notify_queue_positions(self): 119 | for task_id in self.task_subscribers.keys(): 120 | # notify only pending tasks 121 | if self.tasks[task_id].task_status != TaskStatus.PENDING: 122 | continue 123 | 124 | await self.notify_task_subscribers(task_id) 125 | 126 | async def receive_task_progress(self, request: ProgressCallbackRequest): 127 | raise NotImplementedError() 128 | -------------------------------------------------------------------------------- /docling_serve/engines/async_orchestrator_factory.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | 3 | from docling_serve.datamodel.engines import AsyncEngine 4 | from docling_serve.engines.async_orchestrator import BaseAsyncOrchestrator 5 | from docling_serve.settings import docling_serve_settings 6 | 7 | 8 | @lru_cache 9 | def get_async_orchestrator() -> BaseAsyncOrchestrator: 10 | if docling_serve_settings.eng_kind == AsyncEngine.LOCAL: 11 | from docling_serve.engines.async_local.orchestrator import ( 12 | AsyncLocalOrchestrator, 13 | ) 14 | 15 | return AsyncLocalOrchestrator() 16 | elif docling_serve_settings.eng_kind == AsyncEngine.KFP: 17 | from docling_serve.engines.async_kfp.orchestrator import AsyncKfpOrchestrator 18 | 19 | return AsyncKfpOrchestrator() 20 | 21 | raise RuntimeError(f"Engine {docling_serve_settings.eng_kind} not recognized.") 22 | -------------------------------------------------------------------------------- /docling_serve/engines/base_orchestrator.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Optional, Union 3 | 4 | from fastapi import BackgroundTasks 5 | from fastapi.responses import FileResponse 6 | 7 | from docling_serve.datamodel.convert import ConvertDocumentsOptions 8 | from docling_serve.datamodel.responses import ConvertDocumentResponse 9 | from docling_serve.datamodel.task import Task, TaskSource 10 | 11 | 12 | class OrchestratorError(Exception): 13 | pass 14 | 15 | 16 | class TaskNotFoundError(OrchestratorError): 17 | pass 18 | 19 | 20 | class BaseOrchestrator(ABC): 21 | @abstractmethod 22 | async def enqueue( 23 | self, sources: list[TaskSource], options: ConvertDocumentsOptions 24 | ) -> Task: 25 | pass 26 | 27 | @abstractmethod 28 | async def queue_size(self) -> int: 29 | pass 30 | 31 | @abstractmethod 32 | async def get_queue_position(self, task_id: str) -> Optional[int]: 33 | pass 34 | 35 | @abstractmethod 36 | async def task_status(self, task_id: str, wait: float = 0.0) -> Task: 37 | pass 38 | 39 | @abstractmethod 40 | async def task_result( 41 | self, task_id: str, background_tasks: BackgroundTasks 42 | ) -> Union[ConvertDocumentResponse, FileResponse, None]: 43 | pass 44 | 45 | @abstractmethod 46 | async def clear_results(self, older_than: float = 0.0): 47 | pass 48 | 49 | @abstractmethod 50 | async def process_queue(self): 51 | pass 52 | 53 | @abstractmethod 54 | async def warm_up_caches(self): 55 | pass 56 | -------------------------------------------------------------------------------- /docling_serve/engines/block_local/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docling-project/docling-serve/524f6a8997b86d2f869ca491ec8fb40585b42ca4/docling_serve/engines/block_local/__init__.py -------------------------------------------------------------------------------- /docling_serve/helper_functions.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import json 3 | import re 4 | from typing import Union, get_args, get_origin 5 | 6 | from fastapi import Depends, Form 7 | from pydantic import BaseModel, TypeAdapter 8 | 9 | 10 | def is_pydantic_model(type_): 11 | try: 12 | if inspect.isclass(type_) and issubclass(type_, BaseModel): 13 | return True 14 | 15 | origin = get_origin(type_) 16 | if origin is Union: 17 | args = get_args(type_) 18 | return any( 19 | inspect.isclass(arg) and issubclass(arg, BaseModel) 20 | for arg in args 21 | if arg is not type(None) 22 | ) 23 | 24 | except Exception: 25 | pass 26 | 27 | return False 28 | 29 | 30 | # Adapted from 31 | # https://github.com/fastapi/fastapi/discussions/8971#discussioncomment-7892972 32 | def FormDepends(cls: type[BaseModel]): 33 | new_parameters = [] 34 | 35 | for field_name, model_field in cls.model_fields.items(): 36 | annotation = model_field.annotation 37 | description = model_field.description 38 | default = ( 39 | Form(..., description=description) 40 | if model_field.is_required() 41 | else Form( 42 | model_field.default, 43 | examples=model_field.examples, 44 | description=description, 45 | ) 46 | ) 47 | 48 | # Flatten nested Pydantic models by accepting them as JSON strings 49 | if is_pydantic_model(annotation): 50 | annotation = str 51 | default = Form( 52 | None 53 | if model_field.default is None 54 | else json.dumps(model_field.default.model_dump(mode="json")), 55 | description=description, 56 | examples=None 57 | if not model_field.examples 58 | else [ 59 | json.dumps(ex.model_dump(mode="json")) 60 | for ex in model_field.examples 61 | ], 62 | ) 63 | 64 | new_parameters.append( 65 | inspect.Parameter( 66 | name=field_name, 67 | kind=inspect.Parameter.POSITIONAL_ONLY, 68 | default=default, 69 | annotation=annotation, 70 | ) 71 | ) 72 | 73 | async def as_form_func(**data): 74 | for field_name, model_field in cls.model_fields.items(): 75 | value = data.get(field_name) 76 | annotation = model_field.annotation 77 | 78 | # Parse nested models from JSON string 79 | if value is not None and is_pydantic_model(annotation): 80 | try: 81 | validator = TypeAdapter(annotation) 82 | data[field_name] = validator.validate_json(value) 83 | except Exception as e: 84 | raise ValueError(f"Invalid JSON for field '{field_name}': {e}") 85 | 86 | return cls(**data) 87 | 88 | sig = inspect.signature(as_form_func) 89 | sig = sig.replace(parameters=new_parameters) 90 | as_form_func.__signature__ = sig # type: ignore 91 | 92 | return Depends(as_form_func) 93 | 94 | 95 | def _to_list_of_strings(input_value: Union[str, list[str]]) -> list[str]: 96 | def split_and_strip(value: str) -> list[str]: 97 | if re.search(r"[;,]", value): 98 | return [item.strip() for item in re.split(r"[;,]", value)] 99 | else: 100 | return [value.strip()] 101 | 102 | if isinstance(input_value, str): 103 | return split_and_strip(input_value) 104 | elif isinstance(input_value, list): 105 | result = [] 106 | for item in input_value: 107 | result.extend(split_and_strip(str(item))) 108 | return result 109 | else: 110 | raise ValueError("Invalid input: must be a string or a list of strings.") 111 | 112 | 113 | # Helper functions to parse inputs coming as Form objects 114 | def _str_to_bool(value: Union[str, bool]) -> bool: 115 | if isinstance(value, bool): 116 | return value # Already a boolean, return as-is 117 | if isinstance(value, str): 118 | value = value.strip().lower() # Normalize input 119 | return value in ("true", "1", "yes") 120 | return False # Default to False if none of the above matches 121 | -------------------------------------------------------------------------------- /docling_serve/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docling-project/docling-serve/524f6a8997b86d2f869ca491ec8fb40585b42ca4/docling_serve/py.typed -------------------------------------------------------------------------------- /docling_serve/response_preparation.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import shutil 4 | import time 5 | from collections.abc import Iterable 6 | from pathlib import Path 7 | from typing import Union 8 | 9 | from fastapi import HTTPException 10 | from fastapi.responses import FileResponse 11 | 12 | from docling.datamodel.base_models import OutputFormat 13 | from docling.datamodel.document import ConversionResult, ConversionStatus 14 | from docling_core.types.doc import ImageRefMode 15 | 16 | from docling_serve.datamodel.convert import ConvertDocumentsOptions 17 | from docling_serve.datamodel.responses import ConvertDocumentResponse, DocumentResponse 18 | 19 | _log = logging.getLogger(__name__) 20 | 21 | 22 | def _export_document_as_content( 23 | conv_res: ConversionResult, 24 | export_json: bool, 25 | export_html: bool, 26 | export_md: bool, 27 | export_txt: bool, 28 | export_doctags: bool, 29 | image_mode: ImageRefMode, 30 | md_page_break_placeholder: str, 31 | ): 32 | document = DocumentResponse(filename=conv_res.input.file.name) 33 | 34 | if conv_res.status == ConversionStatus.SUCCESS: 35 | new_doc = conv_res.document._make_copy_with_refmode(Path(), image_mode) 36 | 37 | # Create the different formats 38 | if export_json: 39 | document.json_content = new_doc 40 | if export_html: 41 | document.html_content = new_doc.export_to_html(image_mode=image_mode) 42 | if export_txt: 43 | document.text_content = new_doc.export_to_markdown( 44 | strict_text=True, 45 | image_mode=image_mode, 46 | ) 47 | if export_md: 48 | document.md_content = new_doc.export_to_markdown( 49 | image_mode=image_mode, 50 | page_break_placeholder=md_page_break_placeholder or None, 51 | ) 52 | if export_doctags: 53 | document.doctags_content = new_doc.export_to_doctags() 54 | elif conv_res.status == ConversionStatus.SKIPPED: 55 | raise HTTPException(status_code=400, detail=conv_res.errors) 56 | else: 57 | raise HTTPException(status_code=500, detail=conv_res.errors) 58 | 59 | return document 60 | 61 | 62 | def _export_documents_as_files( 63 | conv_results: Iterable[ConversionResult], 64 | output_dir: Path, 65 | export_json: bool, 66 | export_html: bool, 67 | export_md: bool, 68 | export_txt: bool, 69 | export_doctags: bool, 70 | image_export_mode: ImageRefMode, 71 | md_page_break_placeholder: str, 72 | ): 73 | success_count = 0 74 | failure_count = 0 75 | 76 | for conv_res in conv_results: 77 | if conv_res.status == ConversionStatus.SUCCESS: 78 | success_count += 1 79 | doc_filename = conv_res.input.file.stem 80 | 81 | # Export JSON format: 82 | if export_json: 83 | fname = output_dir / f"{doc_filename}.json" 84 | _log.info(f"writing JSON output to {fname}") 85 | conv_res.document.save_as_json( 86 | filename=fname, image_mode=image_export_mode 87 | ) 88 | 89 | # Export HTML format: 90 | if export_html: 91 | fname = output_dir / f"{doc_filename}.html" 92 | _log.info(f"writing HTML output to {fname}") 93 | conv_res.document.save_as_html( 94 | filename=fname, image_mode=image_export_mode 95 | ) 96 | 97 | # Export Text format: 98 | if export_txt: 99 | fname = output_dir / f"{doc_filename}.txt" 100 | _log.info(f"writing TXT output to {fname}") 101 | conv_res.document.save_as_markdown( 102 | filename=fname, 103 | strict_text=True, 104 | image_mode=ImageRefMode.PLACEHOLDER, 105 | ) 106 | 107 | # Export Markdown format: 108 | if export_md: 109 | fname = output_dir / f"{doc_filename}.md" 110 | _log.info(f"writing Markdown output to {fname}") 111 | conv_res.document.save_as_markdown( 112 | filename=fname, 113 | image_mode=image_export_mode, 114 | page_break_placeholder=md_page_break_placeholder or None, 115 | ) 116 | 117 | # Export Document Tags format: 118 | if export_doctags: 119 | fname = output_dir / f"{doc_filename}.doctags" 120 | _log.info(f"writing Doc Tags output to {fname}") 121 | conv_res.document.save_as_document_tokens(filename=fname) 122 | 123 | else: 124 | _log.warning(f"Document {conv_res.input.file} failed to convert.") 125 | failure_count += 1 126 | 127 | _log.info( 128 | f"Processed {success_count + failure_count} docs, " 129 | f"of which {failure_count} failed" 130 | ) 131 | 132 | 133 | def process_results( 134 | conversion_options: ConvertDocumentsOptions, 135 | conv_results: Iterable[ConversionResult], 136 | work_dir: Path, 137 | ) -> Union[ConvertDocumentResponse, FileResponse]: 138 | # Let's start by processing the documents 139 | try: 140 | start_time = time.monotonic() 141 | 142 | # Convert the iterator to a list to count the number of results and get timings 143 | # As it's an iterator (lazy evaluation), it will also start the conversion 144 | conv_results = list(conv_results) 145 | 146 | processing_time = time.monotonic() - start_time 147 | 148 | _log.info( 149 | f"Processed {len(conv_results)} docs in {processing_time:.2f} seconds." 150 | ) 151 | 152 | except Exception as e: 153 | raise HTTPException(status_code=500, detail=str(e)) 154 | 155 | if len(conv_results) == 0: 156 | raise HTTPException( 157 | status_code=500, detail="No documents were generated by Docling." 158 | ) 159 | 160 | # We have some results, let's prepare the response 161 | response: Union[FileResponse, ConvertDocumentResponse] 162 | 163 | # Booleans to know what to export 164 | export_json = OutputFormat.JSON in conversion_options.to_formats 165 | export_html = OutputFormat.HTML in conversion_options.to_formats 166 | export_md = OutputFormat.MARKDOWN in conversion_options.to_formats 167 | export_txt = OutputFormat.TEXT in conversion_options.to_formats 168 | export_doctags = OutputFormat.DOCTAGS in conversion_options.to_formats 169 | 170 | # Only 1 document was processed, and we are not returning it as a file 171 | if len(conv_results) == 1 and not conversion_options.return_as_file: 172 | conv_res = conv_results[0] 173 | document = _export_document_as_content( 174 | conv_res, 175 | export_json=export_json, 176 | export_html=export_html, 177 | export_md=export_md, 178 | export_txt=export_txt, 179 | export_doctags=export_doctags, 180 | image_mode=conversion_options.image_export_mode, 181 | md_page_break_placeholder=conversion_options.md_page_break_placeholder, 182 | ) 183 | 184 | response = ConvertDocumentResponse( 185 | document=document, 186 | status=conv_res.status, 187 | processing_time=processing_time, 188 | timings=conv_res.timings, 189 | ) 190 | 191 | # Multiple documents were processed, or we are forced returning as a file 192 | else: 193 | # Temporary directory to store the outputs 194 | output_dir = work_dir / "output" 195 | output_dir.mkdir(parents=True, exist_ok=True) 196 | 197 | # Worker pid to use in archive identification as we may have multiple workers 198 | os.getpid() 199 | 200 | # Export the documents 201 | _export_documents_as_files( 202 | conv_results=conv_results, 203 | output_dir=output_dir, 204 | export_json=export_json, 205 | export_html=export_html, 206 | export_md=export_md, 207 | export_txt=export_txt, 208 | export_doctags=export_doctags, 209 | image_export_mode=conversion_options.image_export_mode, 210 | md_page_break_placeholder=conversion_options.md_page_break_placeholder, 211 | ) 212 | 213 | files = os.listdir(output_dir) 214 | if len(files) == 0: 215 | raise HTTPException(status_code=500, detail="No documents were exported.") 216 | 217 | file_path = work_dir / "converted_docs.zip" 218 | shutil.make_archive( 219 | base_name=str(file_path.with_suffix("")), 220 | format="zip", 221 | root_dir=output_dir, 222 | ) 223 | 224 | # Other cleanups after the response is sent 225 | # Output directory 226 | # background_tasks.add_task(shutil.rmtree, work_dir, ignore_errors=True) 227 | 228 | response = FileResponse( 229 | file_path, filename=file_path.name, media_type="application/zip" 230 | ) 231 | 232 | return response 233 | -------------------------------------------------------------------------------- /docling_serve/settings.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pathlib import Path 3 | from typing import Optional, Union 4 | 5 | from pydantic import AnyUrl, model_validator 6 | from pydantic_settings import BaseSettings, SettingsConfigDict 7 | from typing_extensions import Self 8 | 9 | from docling_serve.datamodel.engines import AsyncEngine 10 | 11 | 12 | class UvicornSettings(BaseSettings): 13 | model_config = SettingsConfigDict( 14 | env_prefix="UVICORN_", env_file=".env", extra="allow" 15 | ) 16 | 17 | host: str = "0.0.0.0" 18 | port: int = 5001 19 | reload: bool = False 20 | root_path: str = "" 21 | proxy_headers: bool = True 22 | timeout_keep_alive: int = 60 23 | ssl_certfile: Optional[Path] = None 24 | ssl_keyfile: Optional[Path] = None 25 | ssl_keyfile_password: Optional[str] = None 26 | workers: Union[int, None] = None 27 | 28 | 29 | class DoclingServeSettings(BaseSettings): 30 | model_config = SettingsConfigDict( 31 | env_prefix="DOCLING_SERVE_", 32 | env_file=".env", 33 | env_parse_none_str="", 34 | extra="allow", 35 | ) 36 | 37 | enable_ui: bool = False 38 | api_host: str = "localhost" 39 | artifacts_path: Optional[Path] = None 40 | static_path: Optional[Path] = None 41 | scratch_path: Optional[Path] = None 42 | single_use_results: bool = True 43 | result_removal_delay: float = 300 # 5 minutes 44 | options_cache_size: int = 2 45 | enable_remote_services: bool = False 46 | allow_external_plugins: bool = False 47 | 48 | max_document_timeout: float = 3_600 * 24 * 7 # 7 days 49 | max_num_pages: int = sys.maxsize 50 | max_file_size: int = sys.maxsize 51 | 52 | max_sync_wait: int = 120 # 2 minutes 53 | 54 | cors_origins: list[str] = ["*"] 55 | cors_methods: list[str] = ["*"] 56 | cors_headers: list[str] = ["*"] 57 | 58 | eng_kind: AsyncEngine = AsyncEngine.LOCAL 59 | # Local engine 60 | eng_loc_num_workers: int = 2 61 | # KFP engine 62 | eng_kfp_endpoint: Optional[AnyUrl] = None 63 | eng_kfp_token: Optional[str] = None 64 | eng_kfp_ca_cert_path: Optional[str] = None 65 | eng_kfp_self_callback_endpoint: Optional[str] = None 66 | eng_kfp_self_callback_token_path: Optional[Path] = None 67 | eng_kfp_self_callback_ca_cert_path: Optional[Path] = None 68 | 69 | eng_kfp_experimental: bool = False 70 | 71 | @model_validator(mode="after") 72 | def engine_settings(self) -> Self: 73 | # Validate KFP engine settings 74 | if self.eng_kind == AsyncEngine.KFP: 75 | if self.eng_kfp_endpoint is None: 76 | raise ValueError("KFP endpoint is required when using the KFP engine.") 77 | 78 | if self.eng_kind == AsyncEngine.KFP: 79 | if not self.eng_kfp_experimental: 80 | raise ValueError( 81 | "KFP is not yet working. To enable the development version, you must set DOCLING_SERVE_ENG_KFP_EXPERIMENTAL=true." 82 | ) 83 | 84 | return self 85 | 86 | 87 | uvicorn_settings = UvicornSettings() 88 | docling_serve_settings = DoclingServeSettings() 89 | -------------------------------------------------------------------------------- /docling_serve/storage.py: -------------------------------------------------------------------------------- 1 | import tempfile 2 | from functools import lru_cache 3 | from pathlib import Path 4 | 5 | from docling_serve.settings import docling_serve_settings 6 | 7 | 8 | @lru_cache 9 | def get_scratch() -> Path: 10 | scratch_dir = ( 11 | docling_serve_settings.scratch_path 12 | if docling_serve_settings.scratch_path is not None 13 | else Path(tempfile.mkdtemp(prefix="docling_")) 14 | ) 15 | scratch_dir.mkdir(exist_ok=True, parents=True) 16 | return scratch_dir 17 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Dolcing Serve documentation 2 | 3 | This documentation pages explore the webserver configurations, runtime options, deployment examples as well as development best practices. 4 | 5 | - [Configuration](./configuration.md) 6 | - [Advance usage](./usage.md) 7 | - [Deployment](./deployment.md) 8 | - [Development](./development.md) 9 | -------------------------------------------------------------------------------- /docs/assets/docling-serve-pic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docling-project/docling-serve/524f6a8997b86d2f869ca491ec8fb40585b42ca4/docs/assets/docling-serve-pic.png -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | The `docling-serve` executable allows to configure the server via command line 4 | options as well as environment variables. 5 | Configurations are divided between the settings used for the `uvicorn` asgi 6 | server and the actual app-specific configurations. 7 | 8 | > [!WARNING] 9 | > When the server is running with `reload` or with multiple `workers`, uvicorn 10 | > will spawn multiple subprocessed. This invalides all the values configured 11 | > via the CLI command line options. Please use environment variables in this 12 | > type of deployments. 13 | 14 | ## Webserver configuration 15 | 16 | The following table shows the options which are propagated directly to the 17 | `uvicorn` webserver runtime. 18 | 19 | | CLI option | ENV | Default | Description | 20 | | -----------|-----|---------|-------------| 21 | | `--host` | `UVICORN_HOST` | `0.0.0.0` for `run`, `localhost` for `dev` | THe host to serve on. | 22 | | `--port` | `UVICORN_PORT` | `5001` | The port to serve on. | 23 | | `--reload` | `UVICORN_RELOAD` | `false` for `run`, `true` for `dev` | Enable auto-reload of the server when (code) files change. | 24 | | `--workers` | `UVICORN_WORKERS` | `1` | Use multiple worker processes. | 25 | | `--root-path` | `UVICORN_ROOT_PATH` | `""` | The root path is used to tell your app that it is being served to the outside world with some | 26 | | `--proxy-headers` | `UVICORN_PROXY_HEADERS` | `true` | Enable/Disable X-Forwarded-Proto, X-Forwarded-For, X-Forwarded-Port to populate remote address info. | 27 | | `--timeout-keep-alive` | `UVICORN_TIMEOUT_KEEP_ALIVE` | `60` | Timeout for the server response. | 28 | | `--ssl-certfile` | `UVICORN_SSL_CERTFILE` | | SSL certificate file. | 29 | | `--ssl-keyfile` | `UVICORN_SSL_KEYFILE` | | SSL key file. | 30 | | `--ssl-keyfile-password` | `UVICORN_SSL_KEYFILE_PASSWORD` | | SSL keyfile password. | 31 | 32 | ## Docling Serve configuration 33 | 34 | THe following table describes the options to configure the Docling Serve app. 35 | 36 | | CLI option | ENV | Default | Description | 37 | | -----------|-----|---------|-------------| 38 | | `--artifacts-path` | `DOCLING_SERVE_ARTIFACTS_PATH` | unset | If set to a valid directory, the model weights will be loaded from this path | 39 | | | `DOCLING_SERVE_STATIC_PATH` | unset | If set to a valid directory, the static assets for the docs and ui will be loaded from this path | 40 | | | `DOCLING_SERVE_SCRATCH_PATH` | | If set, this directory will be used as scratch workspace, e.g. storing the results before they get requested. If unset, a temporary created is created for this purpose. | 41 | | `--enable-ui` | `DOCLING_SERVE_ENABLE_UI` | `false` | Enable the demonstrator UI. | 42 | | | `DOCLING_SERVE_ENABLE_REMOTE_SERVICES` | `false` | Allow pipeline components making remote connections. For example, this is needed when using a vision-language model via APIs. | 43 | | | `DOCLING_SERVE_ALLOW_EXTERNAL_PLUGINS` | `false` | Allow the selection of third-party plugins. | 44 | | | `DOCLING_SERVE_SINGLE_USE_RESULTS` | `true` | If true, results can be accessed only once. If false, the results accumulate in the scratch directory. | 45 | | | `DOCLING_SERVE_RESULT_REMOVAL_DELAY` | `300` | When `DOCLING_SERVE_SINGLE_USE_RESULTS` is active, this is the delay before results are removed from the task registry. | 46 | | | `DOCLING_SERVE_MAX_DOCUMENT_TIMEOUT` | `604800` (7 days) | The maximum time for processing a document. | 47 | | | `DOCLING_SERVE_MAX_NUM_PAGES` | | The maximum number of pages for a document to be processed. | 48 | | | `DOCLING_SERVE_MAX_FILE_SIZE` | | The maximum file size for a document to be processed. | 49 | | | `DOCLING_SERVE_MAX_SYNC_WAIT` | `120` | Max number of seconds a synchronous endpoint is waiting for the task completion. | 50 | | | `DOCLING_SERVE_OPTIONS_CACHE_SIZE` | `2` | How many DocumentConveter objects (including their loaded models) to keep in the cache. | 51 | | | `DOCLING_SERVE_CORS_ORIGINS` | `["*"]` | A list of origins that should be permitted to make cross-origin requests. | 52 | | | `DOCLING_SERVE_CORS_METHODS` | `["*"]` | A list of HTTP methods that should be allowed for cross-origin requests. | 53 | | | `DOCLING_SERVE_CORS_HEADERS` | `["*"]` | A list of HTTP request headers that should be supported for cross-origin requests. | 54 | | | `DOCLING_SERVE_ENG_KIND` | `local` | The compute engine to use for the async tasks. Possible values are `local` and `kfp`. See below for more configurations of the engines. | 55 | 56 | ### Compute engine 57 | 58 | Docling Serve can be deployed with several possible of compute engine. 59 | The selected compute engine will be running all the async jobs. 60 | 61 | #### Local engine 62 | 63 | The following table describes the options to configure the Docling Serve KFP engine. 64 | 65 | | ENV | Default | Description | 66 | |-----|---------|-------------| 67 | | `DOCLING_SERVE_ENG_LOC_NUM_WORKERS` | 2 | Number of workers/threads processing the incoming tasks. | 68 | 69 | #### KFP engine 70 | 71 | The following table describes the options to configure the Docling Serve KFP engine. 72 | 73 | | ENV | Default | Description | 74 | |-----|---------|-------------| 75 | | `DOCLING_SERVE_ENG_KFP_ENDPOINT` | | Must be set to the Kubeflow Pipeline endpoint. When using the in-cluster deployment, make sure to use the cluster endpoint, e.g. `https://NAME.NAMESPACE.svc.cluster.local:8888` | 76 | | `DOCLING_SERVE_ENG_KFP_TOKEN` | | The authentication token for KFP. For in-cluster deployment, the app will load automatically the token of the ServiceAccount. | 77 | | `DOCLING_SERVE_ENG_KFP_CA_CERT_PATH` | | Path to the CA certificates for the KFP endpoint. For in-cluster deployment, the app will load automatically the internal CA. | 78 | | `DOCLING_SERVE_ENG_KFP_SELF_CALLBACK_ENDPOINT` | | If set, it enables internal callbacks providing status update of the KFP job. Usually something like `https://NAME.NAMESPACE.svc.cluster.local:5001/v1alpha/callback/task/progress`. | 79 | | `DOCLING_SERVE_ENG_KFP_SELF_CALLBACK_TOKEN_PATH` | | The token used for authenticating the progress callback. For cluster-internal workloads, use `/run/secrets/kubernetes.io/serviceaccount/token`. | 80 | | `DOCLING_SERVE_ENG_KFP_SELF_CALLBACK_CA_CERT_PATH` | | The CA certificate for the progress callback. For cluster-inetrnal workloads, use `/var/run/secrets/kubernetes.io/serviceaccount/service-ca.crt`. | 81 | -------------------------------------------------------------------------------- /docs/deploy-examples/compose-gpu.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | docling: 3 | image: ghcr.io/docling-project/docling-serve-cu124 4 | container_name: docling-serve 5 | ports: 6 | - 5001:5001 7 | environment: 8 | - DOCLING_SERVE_ENABLE_UI=true 9 | deploy: 10 | resources: 11 | reservations: 12 | devices: 13 | - driver: nvidia 14 | count: all # nvidia-smi 15 | capabilities: [gpu] 16 | -------------------------------------------------------------------------------- /docs/deploy-examples/docling-model-cache-deployment.yaml: -------------------------------------------------------------------------------- 1 | kind: Deployment 2 | apiVersion: apps/v1 3 | metadata: 4 | name: docling-serve 5 | labels: 6 | app: docling-serve 7 | component: docling-serve-api 8 | spec: 9 | replicas: 1 10 | selector: 11 | matchLabels: 12 | app: docling-serve 13 | component: docling-serve-api 14 | template: 15 | metadata: 16 | labels: 17 | app: docling-serve 18 | component: docling-serve-api 19 | spec: 20 | restartPolicy: Always 21 | containers: 22 | - name: api 23 | resources: 24 | limits: 25 | cpu: 500m 26 | memory: 2Gi 27 | requests: 28 | cpu: 250m 29 | memory: 1Gi 30 | env: 31 | - name: DOCLING_SERVE_ENABLE_UI 32 | value: 'true' 33 | - name: DOCLING_SERVE_ARTIFACTS_PATH 34 | value: '/modelcache' 35 | ports: 36 | - name: http 37 | containerPort: 5001 38 | protocol: TCP 39 | imagePullPolicy: Always 40 | image: 'ghcr.io/docling-project/docling-serve-cpu' 41 | volumeMounts: 42 | - name: docling-model-cache 43 | mountPath: /modelcache 44 | volumes: 45 | - name: docling-model-cache 46 | persistentVolumeClaim: 47 | claimName: docling-model-cache-pvc -------------------------------------------------------------------------------- /docs/deploy-examples/docling-model-cache-job.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: batch/v1 2 | kind: Job 3 | metadata: 4 | name: docling-model-cache-load 5 | spec: 6 | selector: {} 7 | template: 8 | metadata: 9 | name: docling-model-load 10 | spec: 11 | containers: 12 | - name: loader 13 | image: ghcr.io/docling-project/docling-serve-cpu:main 14 | command: 15 | - docling-tools 16 | - models 17 | - download 18 | - '--output-dir=/modelcache' 19 | - 'layout' 20 | - 'tableformer' 21 | - 'code_formula' 22 | - 'picture_classifier' 23 | - 'smolvlm' 24 | - 'granite_vision' 25 | - 'easyocr' 26 | volumeMounts: 27 | - name: docling-model-cache 28 | mountPath: /modelcache 29 | volumes: 30 | - name: docling-model-cache 31 | persistentVolumeClaim: 32 | claimName: docling-model-cache-pvc 33 | restartPolicy: Never -------------------------------------------------------------------------------- /docs/deploy-examples/docling-model-cache-pvc.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: PersistentVolumeClaim 3 | metadata: 4 | name: docling-model-cache-pvc 5 | spec: 6 | accessModes: 7 | - ReadWriteOnce 8 | volumeMode: Filesystem 9 | resources: 10 | requests: 11 | storage: 10Gi -------------------------------------------------------------------------------- /docs/deploy-examples/docling-serve-oauth.yaml: -------------------------------------------------------------------------------- 1 | # This example deployment configures Docling Serve with a OAuth-Proxy sidecar and TLS termination 2 | --- 3 | apiVersion: v1 4 | kind: ServiceAccount 5 | metadata: 6 | name: docling-serve 7 | labels: 8 | app: docling-serve 9 | annotations: 10 | serviceaccounts.openshift.io/oauth-redirectreference.primary: '{"kind":"OAuthRedirectReference","apiVersion":"v1","reference":{"kind":"Route","name":"docling-serve"}}' 11 | --- 12 | apiVersion: rbac.authorization.k8s.io/v1 13 | kind: ClusterRoleBinding 14 | metadata: 15 | name: docling-serve-oauth 16 | roleRef: 17 | apiGroup: rbac.authorization.k8s.io 18 | kind: ClusterRole 19 | name: system:auth-delegator 20 | subjects: 21 | - kind: ServiceAccount 22 | name: docling-serve 23 | namespace: docling 24 | --- 25 | apiVersion: route.openshift.io/v1 26 | kind: Route 27 | metadata: 28 | name: docling-serve 29 | labels: 30 | app: docling-serve 31 | component: docling-serve-api 32 | spec: 33 | to: 34 | kind: Service 35 | name: docling-serve 36 | port: 37 | targetPort: oauth 38 | tls: 39 | termination: Reencrypt 40 | --- 41 | apiVersion: v1 42 | kind: Service 43 | metadata: 44 | name: docling-serve 45 | labels: 46 | app: docling-serve 47 | component: docling-serve-api 48 | annotations: 49 | service.alpha.openshift.io/serving-cert-secret-name: docling-serve-tls 50 | spec: 51 | ports: 52 | - name: oauth 53 | port: 8443 54 | targetPort: oauth 55 | - name: http 56 | port: 5001 57 | targetPort: http 58 | selector: 59 | app: docling-serve 60 | component: docling-serve-api 61 | --- 62 | kind: Deployment 63 | apiVersion: apps/v1 64 | metadata: 65 | name: docling-serve 66 | labels: 67 | app: docling-serve 68 | component: docling-serve-api 69 | spec: 70 | replicas: 1 71 | selector: 72 | matchLabels: 73 | app: docling-serve 74 | component: docling-serve-api 75 | template: 76 | metadata: 77 | labels: 78 | app: docling-serve 79 | component: docling-serve-api 80 | spec: 81 | restartPolicy: Always 82 | serviceAccountName: docling-serve 83 | containers: 84 | - name: api 85 | resources: 86 | limits: 87 | cpu: 2000m 88 | memory: 2Gi 89 | requests: 90 | cpu: 800m 91 | memory: 1Gi 92 | readinessProbe: 93 | httpGet: 94 | path: /health 95 | port: http 96 | scheme: HTTPS 97 | initialDelaySeconds: 10 98 | timeoutSeconds: 2 99 | periodSeconds: 5 100 | successThreshold: 1 101 | failureThreshold: 3 102 | livenessProbe: 103 | httpGet: 104 | path: /health 105 | port: http 106 | scheme: HTTPS 107 | initialDelaySeconds: 3 108 | timeoutSeconds: 4 109 | periodSeconds: 10 110 | successThreshold: 1 111 | failureThreshold: 5 112 | env: 113 | - name: NAMESPACE 114 | valueFrom: 115 | fieldRef: 116 | fieldPath: metadata.namespace 117 | - name: DOCLING_SERVE_ENABLE_UI 118 | value: 'true' 119 | - name: DOCLING_SERVE_API_HOST 120 | value: 'docling-serve.$(NAMESPACE).svc.cluster.local' 121 | - name: UVICORN_SSL_CERTFILE 122 | value: '/etc/tls/private/tls.crt' 123 | - name: UVICORN_SSL_KEYFILE 124 | value: '/etc/tls/private/tls.key' 125 | ports: 126 | - name: http 127 | containerPort: 5001 128 | protocol: TCP 129 | volumeMounts: 130 | - name: proxy-tls 131 | mountPath: /etc/tls/private 132 | imagePullPolicy: Always 133 | image: 'ghcr.io/docling-project/docling-serve-cpu:fix-ui-with-https' 134 | - name: oauth-proxy 135 | resources: 136 | limits: 137 | cpu: 100m 138 | memory: 256Mi 139 | requests: 140 | cpu: 100m 141 | memory: 256Mi 142 | readinessProbe: 143 | httpGet: 144 | path: /oauth/healthz 145 | port: oauth 146 | scheme: HTTPS 147 | initialDelaySeconds: 5 148 | timeoutSeconds: 1 149 | periodSeconds: 5 150 | successThreshold: 1 151 | failureThreshold: 3 152 | livenessProbe: 153 | httpGet: 154 | path: /oauth/healthz 155 | port: oauth 156 | scheme: HTTPS 157 | initialDelaySeconds: 30 158 | timeoutSeconds: 1 159 | periodSeconds: 5 160 | successThreshold: 1 161 | failureThreshold: 3 162 | ports: 163 | - name: oauth 164 | containerPort: 8443 165 | protocol: TCP 166 | imagePullPolicy: IfNotPresent 167 | volumeMounts: 168 | - name: proxy-tls 169 | mountPath: /etc/tls/private 170 | env: 171 | - name: NAMESPACE 172 | valueFrom: 173 | fieldRef: 174 | fieldPath: metadata.namespace 175 | image: 'registry.redhat.io/openshift4/ose-oauth-proxy:v4.13' 176 | args: 177 | - '--https-address=:8443' 178 | - '--provider=openshift' 179 | - '--openshift-service-account=docling-serve' 180 | - '--upstream=https://docling-serve.$(NAMESPACE).svc.cluster.local:5001' 181 | - '--upstream-ca=/var/run/secrets/kubernetes.io/serviceaccount/service-ca.crt' 182 | - '--tls-cert=/etc/tls/private/tls.crt' 183 | - '--tls-key=/etc/tls/private/tls.key' 184 | - '--cookie-secret=SECRET' 185 | - '--openshift-delegate-urls={"/": {"group":"route.openshift.io","resource":"routes","verb":"get","name":"docling-serve","namespace":"$(NAMESPACE)"}}' 186 | - '--openshift-sar={"namespace":"$(NAMESPACE)","resource":"routes","resourceName":"docling-serve","verb":"get","resourceAPIGroup":"route.openshift.io"}' 187 | - '--skip-auth-regex=''(^/health|^/docs)''' 188 | volumes: 189 | - name: proxy-tls 190 | secret: 191 | secretName: docling-serve-tls 192 | defaultMode: 420 193 | -------------------------------------------------------------------------------- /docs/deploy-examples/docling-serve-replicas-w-sticky-sessions.yaml: -------------------------------------------------------------------------------- 1 | # This example deployment configures Docling Serve with a Route + Sticky sessions, a Service and cpu image 2 | --- 3 | kind: Route 4 | apiVersion: route.openshift.io/v1 5 | metadata: 6 | name: docling-serve 7 | labels: 8 | app: docling-serve 9 | component: docling-serve-api 10 | annotations: 11 | haproxy.router.openshift.io/disable_cookies: "false" # this annotation enables the sticky sessions 12 | spec: 13 | path: / 14 | to: 15 | kind: Service 16 | name: docling-serve 17 | port: 18 | targetPort: http 19 | tls: 20 | termination: edge 21 | insecureEdgeTerminationPolicy: Redirect 22 | --- 23 | apiVersion: v1 24 | kind: Service 25 | metadata: 26 | name: docling-serve 27 | labels: 28 | app: docling-serve 29 | component: docling-serve-api 30 | spec: 31 | ports: 32 | - name: http 33 | port: 5001 34 | targetPort: http 35 | selector: 36 | app: docling-serve 37 | component: docling-serve-api 38 | --- 39 | kind: Deployment 40 | apiVersion: apps/v1 41 | metadata: 42 | name: docling-serve 43 | labels: 44 | app: docling-serve 45 | component: docling-serve-api 46 | spec: 47 | replicas: 3 48 | selector: 49 | matchLabels: 50 | app: docling-serve 51 | component: docling-serve-api 52 | template: 53 | metadata: 54 | labels: 55 | app: docling-serve 56 | component: docling-serve-api 57 | spec: 58 | restartPolicy: Always 59 | containers: 60 | - name: api 61 | resources: 62 | limits: 63 | cpu: 500m 64 | memory: 2Gi 65 | requests: 66 | cpu: 250m 67 | memory: 1Gi 68 | env: 69 | - name: DOCLING_SERVE_ENABLE_UI 70 | value: 'true' 71 | ports: 72 | - name: http 73 | containerPort: 5001 74 | protocol: TCP 75 | imagePullPolicy: Always 76 | image: 'ghcr.io/docling-project/docling-serve' 77 | -------------------------------------------------------------------------------- /docs/deploy-examples/docling-serve-simple.yaml: -------------------------------------------------------------------------------- 1 | # This example deployment configures Docling Serve with a Service and cuda image 2 | --- 3 | apiVersion: v1 4 | kind: Service 5 | metadata: 6 | name: docling-serve 7 | labels: 8 | app: docling-serve 9 | component: docling-serve-api 10 | spec: 11 | ports: 12 | - name: http 13 | port: 5001 14 | targetPort: http 15 | selector: 16 | app: docling-serve 17 | component: docling-serve-api 18 | --- 19 | kind: Deployment 20 | apiVersion: apps/v1 21 | metadata: 22 | name: docling-serve 23 | labels: 24 | app: docling-serve 25 | component: docling-serve-api 26 | spec: 27 | replicas: 1 28 | selector: 29 | matchLabels: 30 | app: docling-serve 31 | component: docling-serve-api 32 | template: 33 | metadata: 34 | labels: 35 | app: docling-serve 36 | component: docling-serve-api 37 | spec: 38 | restartPolicy: Always 39 | containers: 40 | - name: api 41 | resources: 42 | limits: 43 | cpu: 500m 44 | memory: 2Gi 45 | nvidia.com/gpu: 1 # Limit to one GPU 46 | requests: 47 | cpu: 250m 48 | memory: 1Gi 49 | nvidia.com/gpu: 1 # Limit to one GPU 50 | env: 51 | - name: DOCLING_SERVE_ENABLE_UI 52 | value: 'true' 53 | ports: 54 | - name: http 55 | containerPort: 5001 56 | protocol: TCP 57 | imagePullPolicy: Always 58 | image: 'ghcr.io/docling-project/docling-serve-cu124' 59 | -------------------------------------------------------------------------------- /docs/deployment.md: -------------------------------------------------------------------------------- 1 | # Deployment Examples 2 | 3 | This document provides deployment examples for running the application in different environments. 4 | 5 | Choose the deployment option that best fits your setup. 6 | 7 | - **[Local GPU](#local-gpu)**: For deploying the application locally on a machine with a NVIDIA GPU (using Docker Compose). 8 | - **[OpenShift](#openshift)**: For deploying the application on an OpenShift cluster, designed for cloud-native environments. 9 | 10 | --- 11 | 12 | ## Local GPU 13 | 14 | ### Docker compose 15 | 16 | Manifest example: [compose-gpu.yaml](./deploy-examples/compose-gpu.yaml) 17 | 18 | This deployment has the following features: 19 | 20 | - NVIDIA cuda enabled 21 | 22 | Install the app with: 23 | 24 | ```sh 25 | docker compose -f docs/deploy-examples/compose-gpu.yaml up -d 26 | ``` 27 | 28 | For using the API: 29 | 30 | ```sh 31 | # Make a test query 32 | curl -X 'POST' \ 33 | "localhost:5001/v1alpha/convert/source/async" \ 34 | -H "accept: application/json" \ 35 | -H "Content-Type: application/json" \ 36 | -d '{ 37 | "http_sources": [{"url": "https://arxiv.org/pdf/2501.17887"}] 38 | }' 39 | ``` 40 | 41 |
42 | Requirements 43 | 44 | - debian/ubuntu/rhel/fedora/opensuse 45 | - docker 46 | - nvidia drivers >=550.54.14 47 | - nvidia-container-toolkit 48 | 49 | Docs: 50 | 51 | - [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/supported-platforms.html) 52 | - [CUDA Toolkit Release Notes](https://docs.nvidia.com/cuda/cuda-toolkit-release-notes/index.html#id6) 53 | 54 |
55 | 56 |
57 | Steps 58 | 59 | 1. Check driver version and which GPU you want to use (0/1/2/3.. and update [compose-gpu.yaml](./deploy-examples/compose-gpu.yaml) file or use `count: all`) 60 | 61 | ```sh 62 | nvidia-smi 63 | ``` 64 | 65 | 2. Check if the NVIDIA Container Toolkit is installed/updated 66 | 67 | ```sh 68 | # debian 69 | dpkg -l | grep nvidia-container-toolkit 70 | ``` 71 | 72 | ```sh 73 | # rhel 74 | rpm -q nvidia-container-toolkit 75 | ``` 76 | 77 | NVIDIA Container Toolkit install steps can be found here: 78 | 79 | 80 | 81 | 3. Check which runtime is being used by Docker 82 | 83 | ```sh 84 | # docker 85 | docker info | grep -i runtime 86 | ``` 87 | 88 | 4. If the default Docker runtime changes back from 'nvidia' to 'default' after restarting the Docker service (optional): 89 | 90 | Backup the daemon.json file: 91 | 92 | ```sh 93 | sudo cp /etc/docker/daemon.json /etc/docker/daemon.json.bak 94 | ``` 95 | 96 | Update the daemon.json file: 97 | 98 | ```sh 99 | echo '{ 100 | "runtimes": { 101 | "nvidia": { 102 | "path": "nvidia-container-runtime" 103 | } 104 | }, 105 | "default-runtime": "nvidia" 106 | }' | sudo tee /etc/docker/daemon.json > /dev/null 107 | ``` 108 | 109 | Restart the Docker service: 110 | 111 | ```sh 112 | sudo systemctl restart docker 113 | ``` 114 | 115 | Confirm 'nvidia' is the default runtime used by Docker by repeating step 3. 116 | 117 | 5. Run the container: 118 | 119 | ```sh 120 | docker compose -f docs/deploy-examples/compose-gpu.yaml up -d 121 | ``` 122 | 123 |
124 | 125 | ## OpenShift 126 | 127 | ### Simple deployment 128 | 129 | Manifest example: [docling-serve-simple.yaml](./deploy-examples/docling-serve-simple.yaml) 130 | 131 | This deployment example has the following features: 132 | 133 | - Deployment configuration 134 | - Service configuration 135 | - NVIDIA cuda enabled 136 | 137 | Install the app with: 138 | 139 | ```sh 140 | oc apply -f docs/deploy-examples/docling-serve-simple.yaml 141 | ``` 142 | 143 | For using the API: 144 | 145 | ```sh 146 | # Port-forward the service 147 | oc port-forward svc/docling-serve 5001:5001 148 | 149 | # Make a test query 150 | curl -X 'POST' \ 151 | "localhost:5001/v1alpha/convert/source/async" \ 152 | -H "accept: application/json" \ 153 | -H "Content-Type: application/json" \ 154 | -d '{ 155 | "http_sources": [{"url": "https://arxiv.org/pdf/2501.17887"}] 156 | }' 157 | ``` 158 | 159 | ### Secure deployment with `oauth-proxy` 160 | 161 | Manifest example: [docling-serve-oauth.yaml](./deploy-examples/docling-serve-oauth.yaml) 162 | 163 | This deployment has the following features: 164 | 165 | - TLS encryption between all components (using the cluster-internal CA authority). 166 | - Authentication via a secure `oauth-proxy` sidecar. 167 | - Expose the service using a secure OpenShift `Route` 168 | 169 | Install the app with: 170 | 171 | ```sh 172 | oc apply -f docs/deploy-examples/docling-serve-oauth.yaml 173 | ``` 174 | 175 | For using the API: 176 | 177 | ```sh 178 | # Retrieve the endpoint 179 | DOCLING_NAME=docling-serve 180 | DOCLING_ROUTE="https://$(oc get routes ${DOCLING_NAME} --template={{.spec.host}})" 181 | 182 | # Retrieve the authentication token 183 | OCP_AUTH_TOKEN=$(oc whoami --show-token) 184 | 185 | # Make a test query 186 | curl -X 'POST' \ 187 | "${DOCLING_ROUTE}/v1alpha/convert/source/async" \ 188 | -H "Authorization: Bearer ${OCP_AUTH_TOKEN}" \ 189 | -H "accept: application/json" \ 190 | -H "Content-Type: application/json" \ 191 | -d '{ 192 | "http_sources": [{"url": "https://arxiv.org/pdf/2501.17887"}] 193 | }' 194 | ``` 195 | 196 | ### ReplicaSets with `sticky sessions` 197 | 198 | Manifest example: [docling-serve-replicas-w-sticky-sessions.yaml](./deploy-examples/docling-serve-replicas-w-sticky-sessions.yaml) 199 | 200 | This deployment has the following features: 201 | 202 | - Deployment configuration with 3 replicas 203 | - Service configuration 204 | - Expose the service using a OpenShift `Route` and enables sticky sessions 205 | 206 | Install the app with: 207 | 208 | ```sh 209 | oc apply -f docs/deploy-examples/docling-serve-replicas-w-sticky-sessions.yaml 210 | ``` 211 | 212 | For using the API: 213 | 214 | ```sh 215 | # Retrieve the endpoint 216 | DOCLING_NAME=docling-serve 217 | DOCLING_ROUTE="https://$(oc get routes $DOCLING_NAME --template={{.spec.host}})" 218 | 219 | # Make a test query, store the cookie and taskid 220 | task_id=$(curl -s -X 'POST' \ 221 | "${DOCLING_ROUTE}/v1alpha/convert/source/async" \ 222 | -H "accept: application/json" \ 223 | -H "Content-Type: application/json" \ 224 | -d '{ 225 | "http_sources": [{"url": "https://arxiv.org/pdf/2501.17887"}] 226 | }' \ 227 | -c cookies.txt | grep -oP '"task_id":"\K[^"]+') 228 | ``` 229 | 230 | ```sh 231 | # Grab the taskid and cookie to check the task status 232 | curl -v -X 'GET' \ 233 | "${DOCLING_ROUTE}/v1alpha/status/poll/$task_id?wait=0" \ 234 | -H "accept: application/json" \ 235 | -b "cookies.txt" 236 | ``` 237 | -------------------------------------------------------------------------------- /docs/development.md: -------------------------------------------------------------------------------- 1 | # Development 2 | 3 | ## Install dependencies 4 | 5 | ### CPU only 6 | 7 | ```sh 8 | # Install uv if not already available 9 | curl -LsSf https://astral.sh/uv/install.sh | sh 10 | 11 | # Install dependencies 12 | uv sync --extra cpu 13 | ``` 14 | 15 | ### Cuda GPU 16 | 17 | For GPU support use the following command: 18 | 19 | ```sh 20 | # Install dependencies 21 | uv sync 22 | ``` 23 | 24 | ### Gradio UI and different OCR backends 25 | 26 | `/ui` endpoint using `gradio` and different OCR backends can be enabled via package extras: 27 | 28 | ```sh 29 | # Enable ui and rapidocr 30 | uv sync --extra ui --extra rapidocr 31 | ``` 32 | 33 | ```sh 34 | # Enable tesserocr 35 | uv sync --extra tesserocr 36 | ``` 37 | 38 | See `[project.optional-dependencies]` section in `pyproject.toml` for full list of options and runtime options with `uv run docling-serve --help`. 39 | 40 | ### Run the server 41 | 42 | The `docling-serve` executable is a convenient script for launching the webserver both in 43 | development and production mode. 44 | 45 | ```sh 46 | # Run the server in development mode 47 | # - reload is enabled by default 48 | # - listening on the 127.0.0.1 address 49 | # - ui is enabled by default 50 | docling-serve dev 51 | 52 | # Run the server in production mode 53 | # - reload is disabled by default 54 | # - listening on the 0.0.0.0 address 55 | # - ui is disabled by default 56 | docling-serve run 57 | ``` 58 | -------------------------------------------------------------------------------- /docs/pre-loading-models.md: -------------------------------------------------------------------------------- 1 | # Pre-loading models for docling 2 | 3 | This document provides examples for pre-loading docling models to a persistent volume and re-using it for docling-serve deployments. 4 | 5 | 1. We need to create a persistent volume that will store models weights: 6 | 7 | ```yaml 8 | apiVersion: v1 9 | kind: PersistentVolumeClaim 10 | metadata: 11 | name: docling-model-cache-pvc 12 | spec: 13 | accessModes: 14 | - ReadWriteOnce 15 | volumeMode: Filesystem 16 | resources: 17 | requests: 18 | storage: 10Gi 19 | ``` 20 | 21 | If you don't want to use default storage class, set your custom storage class with following: 22 | 23 | ```yaml 24 | spec: 25 | ... 26 | storageClassName: 27 | ``` 28 | 29 | Manifest example: [docling-model-cache-pvc.yaml](./deploy-examples/docling-model-cache-pvc.yaml) 30 | 31 | 2. In order to load model weights, we can use docling-toolkit to download them, as this is a one time operation we can use kubernetes job for this: 32 | 33 | ```yaml 34 | apiVersion: batch/v1 35 | kind: Job 36 | metadata: 37 | name: docling-model-cache-load 38 | spec: 39 | selector: {} 40 | template: 41 | metadata: 42 | name: docling-model-load 43 | spec: 44 | containers: 45 | - name: loader 46 | image: ghcr.io/docling-project/docling-serve-cpu:main 47 | command: 48 | - docling-tools 49 | - models 50 | - download 51 | - '--output-dir=/modelcache' 52 | - 'layout' 53 | - 'tableformer' 54 | - 'code_formula' 55 | - 'picture_classifier' 56 | - 'smolvlm' 57 | - 'granite_vision' 58 | - 'easyocr' 59 | volumeMounts: 60 | - name: docling-model-cache 61 | mountPath: /modelcache 62 | volumes: 63 | - name: docling-model-cache 64 | persistentVolumeClaim: 65 | claimName: docling-model-cache-pvc 66 | restartPolicy: Never 67 | ``` 68 | 69 | The job will mount previously created persistent volume and execute command similar to how we would load models locally: 70 | `docling-tools models download --output-dir [LIST_OF_MODELS]` 71 | 72 | In manifest, we specify desired models individually, or we can use `--all` parameter to download all models. 73 | 74 | Manifest example: [docling-model-cache-job.yaml](./deploy-examples/docling-model-cache-job.yaml) 75 | 76 | 3. Now we can mount volume in the docling-serve deployment and set env `DOCLING_SERVE_ARTIFACTS_PATH` to point to it. 77 | Following additions to deploymeny should be made: 78 | 79 | ```yaml 80 | spec: 81 | template: 82 | spec: 83 | containers: 84 | - name: api 85 | env: 86 | ... 87 | - name: DOCLING_SERVE_ARTIFACTS_PATH 88 | value: '/modelcache' 89 | volumeMounts: 90 | - name: docling-model-cache 91 | mountPath: /modelcache 92 | ... 93 | volumes: 94 | - name: docling-model-cache 95 | persistentVolumeClaim: 96 | claimName: docling-model-cache-pvc 97 | ``` 98 | 99 | Make sure that value of `DOCLING_SERVE_ARTIFACTS_PATH` is the same as where models were downloaded and where volume is mounted. 100 | 101 | Now when docling-serve is executing tasks, the underlying docling installation will load model weights from mouted volume. 102 | 103 | Manifest example: [docling-model-cache-deployment.yaml](./deploy-examples/docling-model-cache-deployment.yaml) 104 | -------------------------------------------------------------------------------- /img/swagger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docling-project/docling-serve/524f6a8997b86d2f869ca491ec8fb40585b42ca4/img/swagger.png -------------------------------------------------------------------------------- /img/ui-input.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docling-project/docling-serve/524f6a8997b86d2f869ca491ec8fb40585b42ca4/img/ui-input.png -------------------------------------------------------------------------------- /img/ui-output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docling-project/docling-serve/524f6a8997b86d2f869ca491ec8fb40585b42ca4/img/ui-output.png -------------------------------------------------------------------------------- /os-packages.txt: -------------------------------------------------------------------------------- 1 | tesseract 2 | tesseract-devel 3 | tesseract-langpack-eng 4 | leptonica-devel 5 | libglvnd-glx 6 | glib2 7 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "docling-serve" 3 | version = "0.13.0" # DO NOT EDIT, updated automatically 4 | description = "Running Docling as a service" 5 | license = {text = "MIT"} 6 | authors = [ 7 | {name="Michele Dolfi", email="dol@zurich.ibm.com"}, 8 | {name="Guillaume Moutier", email="gmoutier@redhat.com"}, 9 | {name="Anil Vishnoi", email="avishnoi@redhat.com"}, 10 | {name="Panos Vagenas", email="pva@zurich.ibm.com"}, 11 | {name="Panos Vagenas", email="pva@zurich.ibm.com"}, 12 | {name="Christoph Auer", email="cau@zurich.ibm.com"}, 13 | {name="Peter Staar", email="taa@zurich.ibm.com"}, 14 | ] 15 | maintainers = [ 16 | {name="Michele Dolfi", email="dol@zurich.ibm.com"}, 17 | {name="Anil Vishnoi", email="avishnoi@redhat.com"}, 18 | {name="Panos Vagenas", email="pva@zurich.ibm.com"}, 19 | {name="Christoph Auer", email="cau@zurich.ibm.com"}, 20 | {name="Peter Staar", email="taa@zurich.ibm.com"}, 21 | ] 22 | readme = "README.md" 23 | classifiers = [ 24 | "License :: OSI Approved :: MIT License", 25 | "Operating System :: OS Independent", 26 | # "Development Status :: 5 - Production/Stable", 27 | "Intended Audience :: Developers", 28 | "Typing :: Typed", 29 | "Programming Language :: Python :: 3" 30 | ] 31 | requires-python = ">=3.10" 32 | dependencies = [ 33 | "docling[vlm]~=2.28", 34 | "docling-core>=2.32.0", 35 | "mlx-vlm~=0.1.12; sys_platform == 'darwin' and platform_machine == 'arm64'", 36 | "fastapi[standard]~=0.115", 37 | "httpx~=0.28", 38 | "kfp[kubernetes]>=2.10.0", 39 | "pydantic~=2.10", 40 | "pydantic-settings~=2.4", 41 | "python-multipart>=0.0.14,<0.1.0", 42 | "typer~=0.12", 43 | "uvicorn[standard]>=0.29.0,<1.0.0", 44 | "websockets~=14.0", 45 | ] 46 | 47 | [project.optional-dependencies] 48 | ui = [ 49 | "gradio~=5.9", 50 | "pydantic<2.11.0", # fix compatibility between gradio and new pydantic 2.11 51 | ] 52 | tesserocr = [ 53 | "tesserocr~=2.7" 54 | ] 55 | rapidocr = [ 56 | "rapidocr-onnxruntime~=1.4; python_version<'3.13'", 57 | "onnxruntime~=1.7", 58 | ] 59 | cpu = [ 60 | "torch>=2.6.0", 61 | "torchvision>=0.21.0", 62 | ] 63 | cu124 = [ 64 | "torch>=2.6.0", 65 | "torchvision>=0.21.0", 66 | ] 67 | flash-attn = [ 68 | "flash-attn~=2.7.0; sys_platform == 'linux' and platform_machine == 'x86_64'" 69 | ] 70 | 71 | [dependency-groups] 72 | dev = [ 73 | "asgi-lifespan~=2.0", 74 | "mypy~=1.11", 75 | "pre-commit-uv~=4.1", 76 | "pytest~=8.3", 77 | "pytest-asyncio~=0.24", 78 | "pytest-check~=2.4", 79 | "python-semantic-release~=7.32", 80 | "ruff>=0.9.6", 81 | ] 82 | 83 | [tool.uv] 84 | package = true 85 | conflicts = [ 86 | [ 87 | { extra = "cpu" }, 88 | { extra = "cu124" }, 89 | ], 90 | [ 91 | { extra = "cpu" }, 92 | { extra = "flash-attn" }, 93 | ],] 94 | environments = ["sys_platform != 'darwin' or platform_machine != 'x86_64'"] 95 | override-dependencies = [ 96 | "urllib3~=2.0" 97 | ] 98 | 99 | [tool.uv.sources] 100 | torch = [ 101 | { index = "pytorch-cpu", extra = "cpu" }, 102 | { index = "pytorch-cu124", extra = "cu124" }, 103 | ] 104 | torchvision = [ 105 | { index = "pytorch-cpu", extra = "cpu" }, 106 | { index = "pytorch-cu124", extra = "cu124" }, 107 | ] 108 | 109 | [[tool.uv.index]] 110 | name = "pytorch-cpu" 111 | url = "https://download.pytorch.org/whl/cpu" 112 | explicit = true 113 | 114 | [[tool.uv.index]] 115 | name = "pytorch-cu124" 116 | url = "https://download.pytorch.org/whl/cu124" 117 | explicit = true 118 | 119 | [tool.setuptools.packages.find] 120 | include = ["docling_serve*"] 121 | namespaces = true 122 | 123 | [project.scripts] 124 | docling-serve = "docling_serve.__main__:main" 125 | 126 | [project.urls] 127 | Homepage = "https://github.com/docling-project/docling-serve" 128 | # Documentation = "https://ds4sd.github.io/docling" 129 | Repository = "https://github.com/docling-project/docling-serve" 130 | Issues = "https://github.com/docling-project/docling-serve/issues" 131 | Changelog = "https://github.com/docling-project/docling-serve/blob/main/CHANGELOG.md" 132 | 133 | [tool.ruff] 134 | target-version = "py310" 135 | line-length = 88 136 | respect-gitignore = true 137 | 138 | # extend-exclude = [ 139 | # "tests", 140 | # ] 141 | 142 | [tool.ruff.format] 143 | skip-magic-trailing-comma = false 144 | 145 | [tool.ruff.lint] 146 | select = [ 147 | # "B", # flake8-bugbear 148 | "C", # flake8-comprehensions 149 | "C9", # mccabe 150 | # "D", # flake8-docstrings 151 | "E", # pycodestyle errors (default) 152 | "F", # pyflakes (default) 153 | "I", # isort 154 | "PD", # pandas-vet 155 | "PIE", # pie 156 | # "PTH", # pathlib 157 | "Q", # flake8-quotes 158 | # "RET", # return 159 | "RUF", # Enable all ruff-specific checks 160 | # "SIM", # simplify 161 | "S307", # eval 162 | # "T20", # (disallow print statements) keep debugging statements out of the codebase 163 | "W", # pycodestyle warnings 164 | "ASYNC", # async 165 | "UP", # pyupgrade 166 | ] 167 | 168 | ignore = [ 169 | "E501", # Line too long, handled by ruff formatter 170 | "D107", # "Missing docstring in __init__", 171 | "F811", # "redefinition of the same function" 172 | "PL", # Pylint 173 | "RUF012", # Mutable Class Attributes 174 | "UP007", # Option and Union 175 | ] 176 | 177 | #extend-select = [] 178 | 179 | [tool.ruff.lint.per-file-ignores] 180 | "__init__.py" = ["E402", "F401"] 181 | "tests/*.py" = ["ASYNC"] # Disable ASYNC check for tests 182 | 183 | [tool.ruff.lint.mccabe] 184 | max-complexity = 15 185 | 186 | [tool.ruff.lint.isort.sections] 187 | "docling" = ["docling", "docling_core"] 188 | 189 | [tool.ruff.lint.isort] 190 | combine-as-imports = true 191 | section-order = [ 192 | "future", 193 | "standard-library", 194 | "third-party", 195 | "docling", 196 | "first-party", 197 | "local-folder", 198 | ] 199 | 200 | [tool.mypy] 201 | pretty = true 202 | # strict = true 203 | no_implicit_optional = true 204 | plugins = "pydantic.mypy" 205 | python_version = "3.10" 206 | 207 | [[tool.mypy.overrides]] 208 | module = [ 209 | "easyocr.*", 210 | "tesserocr.*", 211 | "rapidocr_onnxruntime.*", 212 | "requests.*", 213 | "kfp.*", 214 | "kfp_server_api.*", 215 | "mlx_vlm.*", 216 | ] 217 | ignore_missing_imports = true 218 | 219 | [tool.pytest.ini_options] 220 | asyncio_mode = "auto" 221 | asyncio_default_fixture_loop_scope = "function" 222 | minversion = "8.2" 223 | testpaths = [ 224 | "tests", 225 | ] 226 | addopts = "-rA --color=yes --tb=short --maxfail=5" 227 | markers = [ 228 | "asyncio", 229 | ] 230 | 231 | [tool.semantic_release] 232 | # for default values check: 233 | # https://github.com/python-semantic-release/python-semantic-release/blob/v7.32.2/semantic_release/defaults.cfg 234 | 235 | version_source = "tag_only" 236 | branch = "main" 237 | 238 | # configure types which should trigger minor and patch version bumps respectively 239 | # (note that they must be a subset of the configured allowed types): 240 | parser_angular_allowed_types = "build,chore,ci,docs,feat,fix,perf,style,refactor,test" 241 | parser_angular_minor_types = "feat" 242 | parser_angular_patch_types = "fix,perf" 243 | -------------------------------------------------------------------------------- /tests/2206.01062v1.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docling-project/docling-serve/524f6a8997b86d2f869ca491ec8fb40585b42ca4/tests/2206.01062v1.pdf -------------------------------------------------------------------------------- /tests/2408.09869v5.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docling-project/docling-serve/524f6a8997b86d2f869ca491ec8fb40585b42ca4/tests/2408.09869v5.pdf -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docling-project/docling-serve/524f6a8997b86d2f869ca491ec8fb40585b42ca4/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_1-file-all-outputs.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | import httpx 5 | import pytest 6 | import pytest_asyncio 7 | from pytest_check import check 8 | 9 | 10 | @pytest_asyncio.fixture 11 | async def async_client(): 12 | async with httpx.AsyncClient(timeout=60.0) as client: 13 | yield client 14 | 15 | 16 | @pytest.mark.asyncio 17 | async def test_convert_file(async_client): 18 | """Test convert single file to all outputs""" 19 | url = "http://localhost:5001/v1alpha/convert/file" 20 | options = { 21 | "from_formats": [ 22 | "docx", 23 | "pptx", 24 | "html", 25 | "image", 26 | "pdf", 27 | "asciidoc", 28 | "md", 29 | "xlsx", 30 | ], 31 | "to_formats": ["md", "json", "html", "text", "doctags"], 32 | "image_export_mode": "placeholder", 33 | "ocr": True, 34 | "force_ocr": False, 35 | "ocr_engine": "easyocr", 36 | "ocr_lang": ["en"], 37 | "pdf_backend": "dlparse_v2", 38 | "table_mode": "fast", 39 | "abort_on_error": False, 40 | "return_as_file": False, 41 | } 42 | 43 | current_dir = os.path.dirname(__file__) 44 | file_path = os.path.join(current_dir, "2206.01062v1.pdf") 45 | 46 | files = { 47 | "files": ("2206.01062v1.pdf", open(file_path, "rb"), "application/pdf"), 48 | } 49 | 50 | response = await async_client.post(url, files=files, data=options) 51 | assert response.status_code == 200, "Response should be 200 OK" 52 | 53 | data = response.json() 54 | 55 | # Response content checks 56 | # Helper function to safely slice strings 57 | def safe_slice(value, length=100): 58 | if isinstance(value, str): 59 | return value[:length] 60 | return str(value) # Convert non-string values to string for debug purposes 61 | 62 | # Document check 63 | check.is_in( 64 | "document", 65 | data, 66 | msg=f"Response should contain 'document' key. Received keys: {list(data.keys())}", 67 | ) 68 | # MD check 69 | check.is_in( 70 | "md_content", 71 | data.get("document", {}), 72 | msg=f"Response should contain 'md_content' key. Received keys: {list(data.get('document', {}).keys())}", 73 | ) 74 | if data.get("document", {}).get("md_content") is not None: 75 | check.is_in( 76 | "## DocLayNet: ", 77 | data["document"]["md_content"], 78 | msg=f"Markdown document should contain 'DocLayNet: '. Received: {safe_slice(data['document']['md_content'])}", 79 | ) 80 | # JSON check 81 | check.is_in( 82 | "json_content", 83 | data.get("document", {}), 84 | msg=f"Response should contain 'json_content' key. Received keys: {list(data.get('document', {}).keys())}", 85 | ) 86 | if data.get("document", {}).get("json_content") is not None: 87 | check.is_in( 88 | '{"schema_name": "DoclingDocument"', 89 | json.dumps(data["document"]["json_content"]), 90 | msg=f'JSON document should contain \'{{\\n "schema_name": "DoclingDocument\'". Received: {safe_slice(data["document"]["json_content"])}', 91 | ) 92 | # HTML check 93 | if data.get("document", {}).get("html_content") is not None: 94 | check.is_in( 95 | "\n\n", 96 | data["document"]["html_content"], 97 | msg=f"HTML document should contain '\\n'. Received: {safe_slice(data['document']['html_content'])}", 98 | ) 99 | # Text check 100 | check.is_in( 101 | "text_content", 102 | data.get("document", {}), 103 | msg=f"Response should contain 'text_content' key. Received keys: {list(data.get('document', {}).keys())}", 104 | ) 105 | if data.get("document", {}).get("text_content") is not None: 106 | check.is_in( 107 | "DocLayNet: A Large Human-Annotated Dataset", 108 | data["document"]["text_content"], 109 | msg=f"Text document should contain 'DocLayNet: A Large Human-Annotated Dataset'. Received: {safe_slice(data['document']['text_content'])}", 110 | ) 111 | # DocTags check 112 | check.is_in( 113 | "doctags_content", 114 | data.get("document", {}), 115 | msg=f"Response should contain 'doctags_content' key. Received keys: {list(data.get('document', {}).keys())}", 116 | ) 117 | if data.get("document", {}).get("doctags_content") is not None: 118 | check.is_in( 119 | " 10 64 | 65 | assert "html_content" in result["document"] 66 | assert result["document"]["html_content"] is not None 67 | assert len(result["document"]["html_content"]) > 10 68 | 69 | assert "json_content" in result["document"] 70 | assert result["document"]["json_content"] is not None 71 | assert result["document"]["json_content"]["schema_name"] == "DoclingDocument" 72 | -------------------------------------------------------------------------------- /tests/test_1-url-all-outputs.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import httpx 4 | import pytest 5 | import pytest_asyncio 6 | from pytest_check import check 7 | 8 | 9 | @pytest_asyncio.fixture 10 | async def async_client(): 11 | async with httpx.AsyncClient(timeout=60.0) as client: 12 | yield client 13 | 14 | 15 | @pytest.mark.asyncio 16 | async def test_convert_url(async_client): 17 | """Test convert URL to all outputs""" 18 | url = "http://localhost:5001/v1alpha/convert/source" 19 | payload = { 20 | "options": { 21 | "from_formats": [ 22 | "docx", 23 | "pptx", 24 | "html", 25 | "image", 26 | "pdf", 27 | "asciidoc", 28 | "md", 29 | "xlsx", 30 | ], 31 | "to_formats": ["md", "json", "html", "text", "doctags"], 32 | "image_export_mode": "placeholder", 33 | "ocr": True, 34 | "force_ocr": False, 35 | "ocr_engine": "easyocr", 36 | "ocr_lang": ["en"], 37 | "pdf_backend": "dlparse_v2", 38 | "table_mode": "fast", 39 | "abort_on_error": False, 40 | "return_as_file": False, 41 | }, 42 | "http_sources": [{"url": "https://arxiv.org/pdf/2206.01062"}], 43 | } 44 | print(json.dumps(payload, indent=2)) 45 | 46 | response = await async_client.post(url, json=payload) 47 | assert response.status_code == 200, "Response should be 200 OK" 48 | 49 | data = response.json() 50 | 51 | # Response content checks 52 | # Helper function to safely slice strings 53 | def safe_slice(value, length=100): 54 | if isinstance(value, str): 55 | return value[:length] 56 | return str(value) # Convert non-string values to string for debug purposes 57 | 58 | # Document check 59 | check.is_in( 60 | "document", 61 | data, 62 | msg=f"Response should contain 'document' key. Received keys: {list(data.keys())}", 63 | ) 64 | # MD check 65 | check.is_in( 66 | "md_content", 67 | data.get("document", {}), 68 | msg=f"Response should contain 'md_content' key. Received keys: {list(data.get('document', {}).keys())}", 69 | ) 70 | if data.get("document", {}).get("md_content") is not None: 71 | check.is_in( 72 | "## DocLayNet: ", 73 | data["document"]["md_content"], 74 | msg=f"Markdown document should contain 'DocLayNet: '. Received: {safe_slice(data['document']['md_content'])}", 75 | ) 76 | # JSON check 77 | check.is_in( 78 | "json_content", 79 | data.get("document", {}), 80 | msg=f"Response should contain 'json_content' key. Received keys: {list(data.get('document', {}).keys())}", 81 | ) 82 | if data.get("document", {}).get("json_content") is not None: 83 | check.is_in( 84 | '{"schema_name": "DoclingDocument"', 85 | json.dumps(data["document"]["json_content"]), 86 | msg=f'JSON document should contain \'{{\\n "schema_name": "DoclingDocument\'". Received: {safe_slice(data["document"]["json_content"])}', 87 | ) 88 | # HTML check 89 | check.is_in( 90 | "html_content", 91 | data.get("document", {}), 92 | msg=f"Response should contain 'html_content' key. Received keys: {list(data.get('document', {}).keys())}", 93 | ) 94 | if data.get("document", {}).get("html_content") is not None: 95 | check.is_in( 96 | "\n\n", 97 | data["document"]["html_content"], 98 | msg=f"HTML document should contain '\\n'. Received: {safe_slice(data['document']['html_content'])}", 99 | ) 100 | # Text check 101 | check.is_in( 102 | "text_content", 103 | data.get("document", {}), 104 | msg=f"Response should contain 'text_content' key. Received keys: {list(data.get('document', {}).keys())}", 105 | ) 106 | if data.get("document", {}).get("text_content") is not None: 107 | check.is_in( 108 | "DocLayNet: A Large Human-Annotated Dataset", 109 | data["document"]["text_content"], 110 | msg=f"Text document should contain 'DocLayNet: A Large Human-Annotated Dataset'. Received: {safe_slice(data['document']['text_content'])}", 111 | ) 112 | # DocTags check 113 | check.is_in( 114 | "doctags_content", 115 | data.get("document", {}), 116 | msg=f"Response should contain 'doctags_content' key. Received keys: {list(data.get('document', {}).keys())}", 117 | ) 118 | if data.get("document", {}).get("doctags_content") is not None: 119 | check.is_in( 120 | "\n\n", 130 | data["document"]["html_content"], 131 | msg=f"HTML document should contain '\n\n'. Received: {safe_slice(data['document']['html_content'])}", 132 | ) 133 | # Text check 134 | check.is_in( 135 | "text_content", 136 | data.get("document", {}), 137 | msg=f"Response should contain 'text_content' key. Received keys: {list(data.get('document', {}).keys())}", 138 | ) 139 | if data.get("document", {}).get("text_content") is not None: 140 | check.is_in( 141 | "DocLayNet: A Large Human-Annotated Dataset", 142 | data["document"]["text_content"], 143 | msg=f"Text document should contain 'DocLayNet: A Large Human-Annotated Dataset'. Received: {safe_slice(data['document']['text_content'])}", 144 | ) 145 | # DocTags check 146 | check.is_in( 147 | "doctags_content", 148 | data.get("document", {}), 149 | msg=f"Response should contain 'doctags_content' key. Received keys: {list(data.get('document', {}).keys())}", 150 | ) 151 | if data.get("document", {}).get("doctags_content") is not None: 152 | check.is_in( 153 | "", 154 | data["document"]["doctags_content"], 155 | msg=f"DocTags document should contain ''. Received: {safe_slice(data['document']['doctags_content'])}", 156 | ) 157 | -------------------------------------------------------------------------------- /tests/test_file_opts.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import os 4 | 5 | import pytest 6 | import pytest_asyncio 7 | from asgi_lifespan import LifespanManager 8 | from httpx import ASGITransport, AsyncClient 9 | 10 | from docling_core.types import DoclingDocument 11 | from docling_core.types.doc.document import PictureDescriptionData 12 | 13 | from docling_serve.app import create_app 14 | 15 | 16 | @pytest.fixture(scope="session") 17 | def event_loop(): 18 | return asyncio.get_event_loop() 19 | 20 | 21 | @pytest_asyncio.fixture(scope="session") 22 | async def app(): 23 | app = create_app() 24 | 25 | async with LifespanManager(app) as manager: 26 | print("Launching lifespan of app.") 27 | yield manager.app 28 | 29 | 30 | @pytest_asyncio.fixture(scope="session") 31 | async def client(app): 32 | async with AsyncClient( 33 | transport=ASGITransport(app=app), base_url="http://app.io" 34 | ) as client: 35 | print("Client is ready") 36 | yield client 37 | 38 | 39 | @pytest.mark.asyncio 40 | async def test_convert_file(client: AsyncClient): 41 | """Test convert single file to all outputs""" 42 | 43 | endpoint = "/v1alpha/convert/file" 44 | options = { 45 | "to_formats": ["md", "json"], 46 | "image_export_mode": "placeholder", 47 | "ocr": False, 48 | "do_picture_description": True, 49 | "picture_description_api": json.dumps( 50 | { 51 | "url": "http://localhost:11434/v1/chat/completions", # ollama 52 | "params": {"model": "granite3.2-vision:2b"}, 53 | "timeout": 60, 54 | "prompt": "Describe this image in a few sentences. ", 55 | } 56 | ), 57 | } 58 | 59 | current_dir = os.path.dirname(__file__) 60 | file_path = os.path.join(current_dir, "2206.01062v1.pdf") 61 | 62 | files = { 63 | "files": ("2206.01062v1.pdf", open(file_path, "rb"), "application/pdf"), 64 | } 65 | 66 | response = await client.post(endpoint, files=files, data=options) 67 | assert response.status_code == 200, "Response should be 200 OK" 68 | 69 | data = response.json() 70 | 71 | doc = DoclingDocument.model_validate(data["document"]["json_content"]) 72 | 73 | for pic in doc.pictures: 74 | for ann in pic.annotations: 75 | if isinstance(ann, PictureDescriptionData): 76 | print(f"{pic.self_ref}") 77 | print(ann.text) 78 | -------------------------------------------------------------------------------- /tests/test_options_serialization.py: -------------------------------------------------------------------------------- 1 | from docling_serve.datamodel.convert import ( 2 | ConvertDocumentsOptions, 3 | PictureDescriptionApi, 4 | ) 5 | from docling_serve.docling_conversion import ( 6 | _hash_pdf_format_option, 7 | get_pdf_pipeline_opts, 8 | ) 9 | 10 | 11 | def test_options_cache_key(): 12 | hashes = set() 13 | 14 | opts = ConvertDocumentsOptions() 15 | pipeline_opts = get_pdf_pipeline_opts(opts) 16 | hash = _hash_pdf_format_option(pipeline_opts) 17 | assert hash not in hashes 18 | hashes.add(hash) 19 | 20 | opts.do_picture_description = True 21 | pipeline_opts = get_pdf_pipeline_opts(opts) 22 | hash = _hash_pdf_format_option(pipeline_opts) 23 | # pprint(pipeline_opts.pipeline_options.model_dump(serialize_as_any=True)) 24 | assert hash not in hashes 25 | hashes.add(hash) 26 | 27 | opts.picture_description_api = PictureDescriptionApi( 28 | url="http://localhost", 29 | params={"model": "mymodel"}, 30 | prompt="Hello 1", 31 | ) 32 | pipeline_opts = get_pdf_pipeline_opts(opts) 33 | hash = _hash_pdf_format_option(pipeline_opts) 34 | # pprint(pipeline_opts.pipeline_options.model_dump(serialize_as_any=True)) 35 | assert hash not in hashes 36 | hashes.add(hash) 37 | 38 | opts.picture_description_api = PictureDescriptionApi( 39 | url="http://localhost", 40 | params={"model": "your-model"}, 41 | prompt="Hello 1", 42 | ) 43 | pipeline_opts = get_pdf_pipeline_opts(opts) 44 | hash = _hash_pdf_format_option(pipeline_opts) 45 | # pprint(pipeline_opts.pipeline_options.model_dump(serialize_as_any=True)) 46 | assert hash not in hashes 47 | hashes.add(hash) 48 | 49 | opts.picture_description_api.prompt = "World" 50 | pipeline_opts = get_pdf_pipeline_opts(opts) 51 | hash = _hash_pdf_format_option(pipeline_opts) 52 | # pprint(pipeline_opts.pipeline_options.model_dump(serialize_as_any=True)) 53 | assert hash not in hashes 54 | hashes.add(hash) 55 | -------------------------------------------------------------------------------- /tests/test_results_clear.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import base64 3 | import json 4 | from pathlib import Path 5 | 6 | import pytest 7 | import pytest_asyncio 8 | from asgi_lifespan import LifespanManager 9 | from httpx import ASGITransport, AsyncClient 10 | 11 | from docling_serve.app import create_app 12 | from docling_serve.settings import docling_serve_settings 13 | 14 | 15 | @pytest.fixture(scope="session") 16 | def event_loop(): 17 | return asyncio.get_event_loop() 18 | 19 | 20 | @pytest_asyncio.fixture(scope="session") 21 | async def app(): 22 | app = create_app() 23 | 24 | async with LifespanManager(app) as manager: 25 | print("Launching lifespan of app.") 26 | yield manager.app 27 | 28 | 29 | @pytest_asyncio.fixture(scope="session") 30 | async def client(app): 31 | async with AsyncClient( 32 | transport=ASGITransport(app=app), base_url="http://app.io" 33 | ) as client: 34 | print("Client is ready") 35 | yield client 36 | 37 | 38 | async def convert_file(client: AsyncClient): 39 | doc_filename = Path("tests/2408.09869v5.pdf") 40 | encoded_doc = base64.b64encode(doc_filename.read_bytes()).decode() 41 | 42 | payload = { 43 | "options": { 44 | "to_formats": ["json"], 45 | }, 46 | "file_sources": [{"base64_string": encoded_doc, "filename": doc_filename.name}], 47 | } 48 | 49 | response = await client.post("/v1alpha/convert/source/async", json=payload) 50 | assert response.status_code == 200, "Response should be 200 OK" 51 | 52 | task = response.json() 53 | 54 | print(json.dumps(task, indent=2)) 55 | 56 | while task["task_status"] not in ("success", "failure"): 57 | response = await client.get(f"/v1alpha/status/poll/{task['task_id']}") 58 | assert response.status_code == 200, "Response should be 200 OK" 59 | task = response.json() 60 | print(f"{task['task_status']=}") 61 | print(f"{task['task_position']=}") 62 | 63 | await asyncio.sleep(2) 64 | 65 | assert task["task_status"] == "success" 66 | 67 | return task 68 | 69 | 70 | @pytest.mark.asyncio 71 | async def test_clear_results(client: AsyncClient): 72 | """Test removal of task.""" 73 | 74 | # Set long delay deletion 75 | docling_serve_settings.result_removal_delay = 100 76 | 77 | # Convert and wait for completion 78 | task = await convert_file(client) 79 | 80 | # Get result once 81 | result_response = await client.get(f"/v1alpha/result/{task['task_id']}") 82 | assert result_response.status_code == 200, "Response should be 200 OK" 83 | print("Result 1 ok.") 84 | result = result_response.json() 85 | assert result["document"]["json_content"]["schema_name"] == "DoclingDocument" 86 | 87 | # Get result twice 88 | result_response = await client.get(f"/v1alpha/result/{task['task_id']}") 89 | assert result_response.status_code == 200, "Response should be 200 OK" 90 | print("Result 2 ok.") 91 | result = result_response.json() 92 | assert result["document"]["json_content"]["schema_name"] == "DoclingDocument" 93 | 94 | # Clear 95 | clear_response = await client.get("/v1alpha/clear/results?older_then=0") 96 | assert clear_response.status_code == 200, "Response should be 200 OK" 97 | print("Clear ok.") 98 | 99 | # Get deleted result 100 | result_response = await client.get(f"/v1alpha/result/{task['task_id']}") 101 | assert result_response.status_code == 404, "Response should be removed" 102 | print("Result was no longer found.") 103 | 104 | 105 | @pytest.mark.asyncio 106 | async def test_delay_remove(client: AsyncClient): 107 | """Test automatic removal of task with delay.""" 108 | 109 | # Set short delay deletion 110 | docling_serve_settings.result_removal_delay = 5 111 | 112 | # Convert and wait for completion 113 | task = await convert_file(client) 114 | 115 | # Get result once 116 | result_response = await client.get(f"/v1alpha/result/{task['task_id']}") 117 | assert result_response.status_code == 200, "Response should be 200 OK" 118 | print("Result ok.") 119 | result = result_response.json() 120 | assert result["document"]["json_content"]["schema_name"] == "DoclingDocument" 121 | 122 | print("Sleeping to wait the automatic task deletion.") 123 | await asyncio.sleep(10) 124 | 125 | # Get deleted result 126 | result_response = await client.get(f"/v1alpha/result/{task['task_id']}") 127 | assert result_response.status_code == 404, "Response should be removed" 128 | --------------------------------------------------------------------------------