Skip to content

feat: OCR scanned PDFs with a vision model via LiteLLM - #161

Open
sandeep-selvaraj wants to merge 1 commit into
firecrawl:mainfrom
sandeep-selvaraj:feat/llm-ocr-for-scanned-pdfs
Open

feat: OCR scanned PDFs with a vision model via LiteLLM#161
sandeep-selvaraj wants to merge 1 commit into
firecrawl:mainfrom
sandeep-selvaraj:feat/llm-ocr-for-scanned-pdfs

Conversation

@sandeep-selvaraj

@sandeep-selvaraj sandeep-selvaraj commented Sep 3, 2026

Copy link
Copy Markdown

Follow-up to #141 — a provider-agnostic ocr="llm" beside ocr="hosted".

Scope, and why Python only

#141 added ocr="hosted": a PDF that needs OCR goes to Firecrawl Parse. This adds a second opt-in for people who would rather the document go to a model of their own choosing — OpenAI, Anthropic, Gemini, Bedrock, a local Ollama, a LiteLLM proxy — and not to Firecrawl.

LiteLLM's SDK is Python, so this is the Python binding only. Node and wasm keep hosted as their sole OCR path; the Rust core is untouched. to_markdown / to_markdown_bytes gain one Ocr literal and one keyword — there is no new Format, no .pyi change, no breaking change to exhaustive matches.

Approach

It sits exactly where _parse_hosted sits. Native conversion runs first; on NeedsOcrError, if ocr="llm", the new python/anydoc/_llm_ocr.py takes over:

  • rasterise every page with pypdfium2 at ANYDOC_OCR_DPI (200) to PNG
  • one litellm.completion per page — a text instruction plus the page as a base64 image_url data URI
  • pages run through a bounded ThreadPoolExecutor (ANYDOC_OCR_PAGE_CONCURRENCY, 4); pool.map keeps them in order
  • join transcriptions with a blank line, one trailing newline — same normalisation as _parse_hosted

litellm, pypdfium2 and pydantic-settings are an optional extra — pip install "firecrawl-anydoc[llm]" — imported lazily, so the base wheel keeps its zero-dependency install.

Configuration

Environment only, read through a pydantic-settings model with env_prefix="ANYDOC_OCR_". The API key is a SecretStr, unwrapped only at the litellm.completion call.

var default
ANYDOC_OCR_MODEL required LiteLLM model string, e.g. openai/gpt-4o-mini, ollama/llama3.2-vision
ANYDOC_OCR_API_BASE for proxies / local servers
ANYDOC_OCR_API_KEY optional here — LiteLLM also reads the provider's own var (OPENAI_API_KEY, …)
ANYDOC_OCR_PROMPT overrides the default transcription instruction
ANYDOC_OCR_DPI 200
ANYDOC_OCR_MAX_PAGES 100 refuses rather than spends
ANYDOC_OCR_PAGE_CONCURRENCY 4
ANYDOC_OCR_TIMEOUT 120 seconds, per page

to_markdown(..., model=...) overrides ANYDOC_OCR_MODEL per call — the one non-secret knob convenient to pass inline.

Details worth flagging

Whole document, not only the flagged pages. NeedsOcrError.pages names which pages need OCR, but a mixed PDF's text pages would be dropped if only the flagged ones went to the model, and the model reads better with the whole document. Same call hosted makes. The alternative — transcribe only the scanned pages and splice them into locally extracted text — is a real option I did not take; noted below.

New error, still a ConvertError. LlmOcrError(ConvertError), so except anydoc.ConvertError keeps catching everything. It covers three setup failures — extra not installed (message is the pip install line), ANYDOC_OCR_MODEL unset (message names the var), page count over MAX_PAGES — and wraps any litellm exception as llm OCR: <detail>. An internal LlmOcrConfigError carries the setup cases out of _llm_ocr.py; callers only ever see LlmOcrError.

No async. litellm has acompletion, but the binding is synchronous and the GIL is released only in the Rust layer. Page fan-out is threads.

.pyi untouched. LlmOcrError, Ocr and the settings live in the Python wrapper, not the compiled module — the same split as HostedError today. test_the_stubs_cover_the_module asserts that split and is updated for the new name.

Not wired into Node / wasm / CLI. Deliberate — LiteLLM is Python. An OpenAI-compatible HTTP path in node/anydoc.js pointed at a LiteLLM proxy would be a separate PR and a different shape.

Output

anydoc.to_markdown("scan.pdf", ocr="llm") returns the model's transcription verbatim, per-page results joined by a blank line and normalised to one trailing newline. Illustrative:

# Q3 planning notes

Notes from today:

- budget signed off
- hiring on hold

Tests

Three added to python/tests/test_anydoc.py, each skipUnless the extra is importable. litellm.completion is mocked; pypdfium2 rasterisation and the pydantic-settings load run for real against the committed handmade-mixed.pdf fixture:

  • two pages in → two completion calls out, each carrying an image/png base64 data URI; a .docx with ocr="llm" converts locally and never calls the model
  • ANYDOC_OCR_MODEL unset → LlmOcrError naming it, and it is a ConvertError
  • completion raising → LlmOcrError carrying the cause

A llm_env context manager saves, clears and restores ANYDOC_OCR_* around each, the way hosted_stub does for FIRECRAWL_*.

No integration test against a real model or a container — matching how hosted is tested (loopback stub, never real api.firecrawl.dev).

python -m unittest discover -s tests — 14 passed (3 new, 0 skipped with the extra installed). cargo test and node --test not run: the Rust core, Node and wasm sources are untouched.

Registered in

Ocr literal, __all__, python/pyproject.toml optional-dependencies, python/README.md (Scanned pages + error table), root README.md OCR section. No Format variant, so no binding-stub edits and no breaking change.

Open questions

  • Whole document vs. scanned-pages-only. Followed hosted. Keeping local extraction for the text pages and sending only the flagged ones would change _parse_llm and the assembly test.
  • Env-only config. model= is the only kwarg. Happy to surface api_base / prompt inline too; kept the surface minimal and the secret out of the signature.
  • Default prompt. Currently "transcribe to clean GFM, preserve headings/lists/tables/reading order, output only Markdown". Open to wording you'd prefer to ship.
  • LlmOcrSettings visibility. Internal right now. Export it if callers should build or introspect it.
  • Downscaling. Pages go to the model at full raster size; a max-dimension clamp before encoding would cut token cost. Left out pending a view on defaults.

Summary by cubic

Add ocr="llm" to the Python bindings so scanned PDFs can be transcribed by a vision model of your choice via LiteLLM, alongside the existing ocr="hosted".

  • When a PDF needs OCR, ocr="llm" rasterizes every page and sends each to a vision model through LiteLLM, returning the model's Markdown transcription. The whole document is sent, not just the flagged pages.
  • Requires the optional llm extra (pip install "firecrawl-anydoc[llm]") and configuration via ANYDOC_OCR_* environment variables (ANYDOC_OCR_MODEL is required). model= overrides ANYDOC_OCR_MODEL per call.
  • Failures raise LlmOcrError (a ConvertError), covering missing extra, missing config, and model call failures.
  • Only Python bindings change; Node, wasm, and the Rust core are untouched. No breaking changes to existing APIs.

Written for commit fdc7f3f. Summary will update on new commits.

Review in cubic

Add ocr="llm" to the Python bindings, alongside ocr="hosted". When a PDF needs OCR, rasterise every page with pypdfium2 and transcribe it to Markdown through litellm.completion, so the document goes only to the provider the user configures.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

6 issues found across 6 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="python/anydoc/_llm_ocr.py">

<violation number="1" location="python/anydoc/_llm_ocr.py:82">
P1: When callers provide `model=` without `ANYDOC_OCR_MODEL`, the override cannot work because settings validation fails before the override is applied. Pass the override into the settings constructor so it satisfies the required model field.</violation>

<violation number="2" location="python/anydoc/_llm_ocr.py:111">
P2: A large PDF page or `ANYDOC_OCR_DPI` value can exhaust memory during rasterization because rendering has no pixel/byte bound and all PNGs are retained. Enforce a maximum rendered size and use bounded/streamed page processing.</violation>

<violation number="3" location="python/anydoc/_llm_ocr.py:163">
P1: When any page completion is empty while another page has content, this filter silently returns a partial document. Treat an empty per-page response as an OCR failure, or preserve the page boundary, instead of checking only whether the entire document is empty.</violation>
</file>

<file name="python/anydoc/__init__.py">

<violation number="1" location="python/anydoc/__init__.py:54">
P2: The `ocr="llm"` path rasterizes pages and sends them to LiteLLM, so this sentence can falsely promise local privacy. Clarify that only local conversions stay on-device and OCR modes transmit content to their configured services.</violation>
</file>

<file name="python/pyproject.toml">

<violation number="1" location="python/pyproject.toml:27">
P2: The `llm` extra does not declare Pillow, but `_rasterise()` relies on `pypdfium2.PdfBitmap.to_pil()` to build the PNGs sent to the model. pypdfium2's `to_pil()` requires Pillow and does not install it transitively, so a clean `pip install firecrawl-anydoc[llm]` without a pre-existing Pillow fails during rasterisation (and `test_ocr_llm_transcribes_every_scanned_page_and_nothing_else` depends on Pillow being present). Add Pillow to the extra so the OCR path works on a fresh install.</violation>
</file>

<file name="python/tests/test_anydoc.py">

<violation number="1" location="python/tests/test_anydoc.py:187">
P3: The side_effect reads `completion.call_count` inside the threaded page pool, so both pages can observe the same value and the assertion `assertIn("# Page 1", markdown)` passes even if both pages came back as "# Page 1". Make the reply independent of thread timing (e.g. a per-call counter passed through the message content, or reply with a fixed distinct string per page) so the test actually verifies two distinct pages were transcribed.</violation>
</file>

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

Comment thread python/anydoc/_llm_ocr.py
pool.map(lambda png: _transcribe_page(litellm, settings, png), pages)
)

markdown = "\n\n".join(part.strip() for part in transcribed if part.strip())

@cubic-dev-ai cubic-dev-ai Bot Sep 3, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When any page completion is empty while another page has content, this filter silently returns a partial document. Treat an empty per-page response as an OCR failure, or preserve the page boundary, instead of checking only whether the entire document is empty.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At python/anydoc/_llm_ocr.py, line 163:

<comment>When any page completion is empty while another page has content, this filter silently returns a partial document. Treat an empty per-page response as an OCR failure, or preserve the page boundary, instead of checking only whether the entire document is empty.</comment>

<file context>
@@ -0,0 +1,166 @@
+                pool.map(lambda png: _transcribe_page(litellm, settings, png), pages)
+            )
+
+    markdown = "\n\n".join(part.strip() for part in transcribed if part.strip())
+    if not markdown:
+        raise LlmOcrConfigError("the model returned no Markdown")
</file context>
Fix with cubic

Comment thread python/anydoc/_llm_ocr.py

build = _build_settings_class()
try:
settings = build()

@cubic-dev-ai cubic-dev-ai Bot Sep 3, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When callers provide model= without ANYDOC_OCR_MODEL, the override cannot work because settings validation fails before the override is applied. Pass the override into the settings constructor so it satisfies the required model field.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At python/anydoc/_llm_ocr.py, line 82:

<comment>When callers provide `model=` without `ANYDOC_OCR_MODEL`, the override cannot work because settings validation fails before the override is applied. Pass the override into the settings constructor so it satisfies the required model field.</comment>

<file context>
@@ -0,0 +1,166 @@
+
+    build = _build_settings_class()
+    try:
+        settings = build()
+    except ValidationError as error:
+        missing = [
</file context>
Suggested change
settings = build()
settings = build(model=model_override) if model_override is not None else build()
Fix with cubic

Comment thread python/anydoc/_llm_ocr.py
)
pages = []
for page in pdf:
bitmap = page.render(scale=dpi / 72)

@cubic-dev-ai cubic-dev-ai Bot Sep 3, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A large PDF page or ANYDOC_OCR_DPI value can exhaust memory during rasterization because rendering has no pixel/byte bound and all PNGs are retained. Enforce a maximum rendered size and use bounded/streamed page processing.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At python/anydoc/_llm_ocr.py, line 111:

<comment>A large PDF page or `ANYDOC_OCR_DPI` value can exhaust memory during rasterization because rendering has no pixel/byte bound and all PNGs are retained. Enforce a maximum rendered size and use bounded/streamed page processing.</comment>

<file context>
@@ -0,0 +1,166 @@
+            )
+        pages = []
+        for page in pdf:
+            bitmap = page.render(scale=dpi / 72)
+            image = bitmap.to_pil()
+            buffer = io.BytesIO()
</file context>
Fix with cubic

Comment thread python/anydoc/__init__.py
Firecrawl Parse. `llm` rasterises the pages and transcribes them with a
vision model through LiteLLM, configured from `ANYDOC_OCR_*` environment
variables and needing the `llm` extra (`pip install firecrawl-anydoc[llm]`).
Documents anydoc converts itself never leave the machine."""

@cubic-dev-ai cubic-dev-ai Bot Sep 3, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The ocr="llm" path rasterizes pages and sends them to LiteLLM, so this sentence can falsely promise local privacy. Clarify that only local conversions stay on-device and OCR modes transmit content to their configured services.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At python/anydoc/__init__.py, line 54:

<comment>The `ocr="llm"` path rasterizes pages and sends them to LiteLLM, so this sentence can falsely promise local privacy. Clarify that only local conversions stay on-device and OCR modes transmit content to their configured services.</comment>

<file context>
@@ -45,38 +45,51 @@
+Firecrawl Parse. `llm` rasterises the pages and transcribes them with a
+vision model through LiteLLM, configured from `ANYDOC_OCR_*` environment
+variables and needing the `llm` extra (`pip install firecrawl-anydoc[llm]`).
+Documents anydoc converts itself never leave the machine."""
 
 
</file context>
Suggested change
Documents anydoc converts itself never leave the machine."""
Documents converted locally by anydoc never leave the machine; `ocr="hosted"`
and `ocr="llm"` send document content to their configured service."""
Fix with cubic

Comment thread python/pyproject.toml
# `llm` powers `to_markdown(..., ocr="llm")`: LiteLLM calls the vision model,
# pypdfium2 rasterises the pages, pydantic-settings reads `ANYDOC_OCR_*`.
[project.optional-dependencies]
llm = ["litellm>=1.0", "pypdfium2>=4", "pydantic-settings>=2"]

@cubic-dev-ai cubic-dev-ai Bot Sep 3, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The llm extra does not declare Pillow, but _rasterise() relies on pypdfium2.PdfBitmap.to_pil() to build the PNGs sent to the model. pypdfium2's to_pil() requires Pillow and does not install it transitively, so a clean pip install firecrawl-anydoc[llm] without a pre-existing Pillow fails during rasterisation (and test_ocr_llm_transcribes_every_scanned_page_and_nothing_else depends on Pillow being present). Add Pillow to the extra so the OCR path works on a fresh install.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At python/pyproject.toml, line 27:

<comment>The `llm` extra does not declare Pillow, but `_rasterise()` relies on `pypdfium2.PdfBitmap.to_pil()` to build the PNGs sent to the model. pypdfium2's `to_pil()` requires Pillow and does not install it transitively, so a clean `pip install firecrawl-anydoc[llm]` without a pre-existing Pillow fails during rasterisation (and `test_ocr_llm_transcribes_every_scanned_page_and_nothing_else` depends on Pillow being present). Add Pillow to the extra so the OCR path works on a fresh install.</comment>

<file context>
@@ -21,6 +21,11 @@ classifiers = [
+# `llm` powers `to_markdown(..., ocr="llm")`: LiteLLM calls the vision model,
+# pypdfium2 rasterises the pages, pydantic-settings reads `ANYDOC_OCR_*`.
+[project.optional-dependencies]
+llm = ["litellm>=1.0", "pypdfium2>=4", "pydantic-settings>=2"]
+
 [project.urls]
</file context>
Suggested change
llm = ["litellm>=1.0", "pypdfium2>=4", "pydantic-settings>=2"]
llm = ["litellm>=1.0", "pypdfium2>=4", "pydantic-settings>=2", "Pillow>=9"]
Fix with cubic

@unittest.skipUnless(_LLM_EXTRA, "the 'llm' extra is not installed")
def test_ocr_llm_transcribes_every_scanned_page_and_nothing_else(self):
with mock.patch("litellm.completion") as completion, llm_env(model="test/model"):
completion.side_effect = lambda **kw: _completion_reply(

@cubic-dev-ai cubic-dev-ai Bot Sep 3, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The side_effect reads completion.call_count inside the threaded page pool, so both pages can observe the same value and the assertion assertIn("# Page 1", markdown) passes even if both pages came back as "# Page 1". Make the reply independent of thread timing (e.g. a per-call counter passed through the message content, or reply with a fixed distinct string per page) so the test actually verifies two distinct pages were transcribed.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At python/tests/test_anydoc.py, line 187:

<comment>The side_effect reads `completion.call_count` inside the threaded page pool, so both pages can observe the same value and the assertion `assertIn("# Page 1", markdown)` passes even if both pages came back as "# Page 1". Make the reply independent of thread timing (e.g. a per-call counter passed through the message content, or reply with a fixed distinct string per page) so the test actually verifies two distinct pages were transcribed.</comment>

<file context>
@@ -146,6 +181,41 @@ def test_the_keyless_limit_says_to_set_an_api_key(self):
+    @unittest.skipUnless(_LLM_EXTRA, "the 'llm' extra is not installed")
+    def test_ocr_llm_transcribes_every_scanned_page_and_nothing_else(self):
+        with mock.patch("litellm.completion") as completion, llm_env(model="test/model"):
+            completion.side_effect = lambda **kw: _completion_reply(
+                f"# Page {completion.call_count}"
+            )
</file context>
Fix with cubic

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant