Skip to content

feat(ocr): async-native, typed Rust OCR foundation (ocr/aocr bridge) - #31128

Open
ishaan-berri wants to merge 16 commits into
litellm_internal_stagingfrom
litellm_async_ocr_foundation
Open

feat(ocr): async-native, typed Rust OCR foundation (ocr/aocr bridge)#31128
ishaan-berri wants to merge 16 commits into
litellm_internal_stagingfrom
litellm_async_ocr_foundation

Conversation

@ishaan-berri

Copy link
Copy Markdown
Contributor

Relevant issues

Follow-up to #31033 (now merged). Builds the async-native, typed OCR foundation on top of the opt-in Mistral beachhead.

Pre-Submission checklist

  • Added unit tests — tests/test_litellm/ocr/test_rust_bridge.py (sync ocr + async aocr routing, fallback) plus Rust unit tests across core/providers.
  • OCR suite passes; cargo fmt / clippy -D warnings / cargo test --workspace --locked pass.

Type

🆕 New Feature
🧹 Refactor

Changes

The proxy calls aocr(), so the Rust OCR path is now async-first instead of blocking-in-a-thread-pool.

  • One async core: litellm_providers::ocr(OcrRequest) -> OcrResponse (renamed from run_ocr) on a shared async reqwest client. aocr() returns a Python awaitable (pyo3-async-runtimes + Tokio); ocr() block_ons the same future with the GIL released. Drops run_in_executor, so the HTTP wait no longer pins a thread-pool worker per request.
  • Fully typed: core gets real types (OcrRequest, OcrResponse, OcrProvider, OcrDocument, OcrParams, page/usage types) instead of serde_json::Value. Serde derives produce the exact wire JSON. The only Values left are the three leaves where Python itself is Any / Dict[str, Any].
  • Dispatch by provider: match request.provider (Mistral wired; others return a typed UnsupportedProvider). The Python shell gates with rust_supports() and falls back to the Python path otherwise.
  • litellm-rust/AGENTS.md: explains the bridge — crate layout, async model, request lifecycle, error mapping, and the recipe to add a provider.

Names mirror Python end to end: litellm.ocr/aocr → bridge ocr/aocrlitellm_providers::ocr. Not a UI change — no screenshots.

@codecov

codecov Bot commented Jun 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.95918% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
litellm/ocr/rust_bridge.py 91.66% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@greptile-apps

greptile-apps Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR builds an async-native Rust OCR foundation on top of the Mistral beachhead from #31033. The Rust core is converted from blocking to async (reqwest::Client, async fn ocr), a new aocr PyO3 entry point is added via pyo3-async-runtimes, and the entire OCR domain model is replaced with proper Rust types instead of free-form serde_json::Value.

  • Async core: litellm_providers::ocr is now async fn; the bridge exposes aocr (returns a Python awaitable driven by Tokio) and ocr (block_on with GIL released), so the proxy's await aocr() no longer pins a thread-pool worker per request.
  • Typed domain model: OcrRequest, OcrResponse, OcrDocument, OcrParams, and page/usage types replace unstructured Value; serde produces the exact wire JSON with only three Value leaves where Python's own type is Any.
  • Provider dispatch: match request.provider in ocr.rs with RUST_SUPPORTED_PROVIDERS gating in Python; UnsupportedProvider propagates as ValueError across the bridge.

Confidence Score: 3/5

The Rust core and type model are well-structured, but two specific regression-guard tests were removed and the async secret-resolution path can block the event loop for remote backends.

Two targeted tests that previously guarded distinct behaviors were removed without replacement: the test verifying the explicit API key takes priority over the resolver (security-relevant, since a silent fallback to a secret-manager key would be wrong), and the test verifying that load_rust_bridge() returns the module object rather than the old callable attribute (a behavior that changed in this PR). Without these guards, regressions on both paths are invisible in CI. Additionally, _rust_ocr_pre_call — which can perform synchronous network I/O via get_secret_str for remote secret backends — now runs on the event-loop thread for the aocr path, contradicting the stated goal of never blocking the event loop during an OCR call.

tests/test_litellm/ocr/test_rust_bridge.py (two tests deleted) and litellm/ocr/main.py (_arun_rust_ocr — pre-call runs on the event loop).

Important Files Changed

Filename Overview
litellm/ocr/main.py Adds async Rust path (_arun_rust_ocr) and provider-aware routing; _rust_ocr_pre_call runs synchronously on the event loop in the async path, which can block for remote secret backends
litellm/ocr/rust_bridge.py Renames bridge abstraction from callable RustOcr to object RustBridge with ocr/aocr methods; adds RUST_SUPPORTED_PROVIDERS frozenset and rust_supports() guard
tests/test_litellm/ocr/test_rust_bridge.py Adds async aocr coverage and RecordingBridge; removes two regression-guard tests (test_run_rust_ocr_prefers_explicit_key_over_resolver, test_load_rust_ocr_uses_compiled_extension) without equivalent replacements
litellm-rust/crates/python-bridge/src/lib.rs Adds aocr PyO3 function using pyo3-async-runtimes; note_offload() increments before future_into_py completes, potentially overcounting GIL-release stats
litellm-rust/crates/providers/src/ocr.rs Converts run_ocr from blocking to async, dispatches on OcrProvider, switches to shared async reqwest::Client; clean provider dispatch with typed errors
litellm-rust/crates/core/src/ocr/types.rs Replaces serde_json::Value-heavy types with full typed OCR domain model; serde derives match wire JSON; only three Value leaves where Python itself is Any
litellm-rust/crates/providers/src/mistral/ocr/transformation.rs Replaces imperative JSON construction with typed MistralRequestBody struct and parse_response; cleaner and testable pure functions

Comments Outside Diff (1)

  1. tests/test_litellm/ocr/test_rust_bridge.py, line 144-145 (link)

    P1 Two regression-guard tests removed without replacement

    test_run_rust_ocr_prefers_explicit_key_over_resolver verified that when an explicit api_key is supplied, resolve_api_key (i.e. get_secret_str) is never called. The new code preserves this via call.api_key or (call.resolve_api_key(secret_env) if secret_env else None), but without the test a future change to key-resolution order would go undetected — this is security-relevant, as it prevents accidentally fetching a wrong key from a vault backend.

    test_load_rust_ocr_uses_compiled_extension was the only test covering the non-injection path in load_rust_bridge(). Critically, the behaviour changed: the old code returned litellm_python_bridge.ocr (the callable), while the new code returns litellm_python_bridge (the module). That difference is now untested — a regression to the old return value would silently make every call fail with an AttributeError at runtime.

    Rule Used: What: Flag any modifications to existing tests and... (source)

Reviews (1): Last reviewed commit: "docs(rust-ocr): AGENTS.md explaining the..." | Re-trigger Greptile

Comment thread litellm/ocr/main.py
Comment on lines +137 to +155
async def _arun_rust_ocr(bridge: RustBridge, call: _RustOcrCall) -> OCRResponse:
"""Async Rust OCR: pre_call, then await the bridge awaitable.

The HTTP call runs on the Rust Tokio runtime with the GIL released, so no
event-loop thread is held during the request — unlike the ``run_in_executor``
path, this scales to many concurrent OCR calls on a handful of threads.
"""
resolved_api_key = _rust_ocr_pre_call(call)
raw = await bridge.aocr(
provider=call.provider,
model=call.model,
document=call.document,
api_key=resolved_api_key,
api_base=call.api_base,
extra_headers=call.extra_headers,
timeout_seconds=call.timeout_seconds,
params=call.optional_params,
)
return OCRResponse.model_validate(raw)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Blocking secret-resolution on the event-loop thread in the async path

_rust_ocr_pre_call is called synchronously inside this coroutine, which runs on the asyncio event-loop thread when aocr awaits it. The call.resolve_api_key(secret_env) call is get_secret_str, which for remote backends (AWS Secrets Manager, GCP Secret Manager, Azure Key Vault) performs a synchronous network request. That call will block the event loop for the duration of the secret lookup.

For the sync _run_rust_ocr path this is fine — it runs inside run_in_executor. The _arun_rust_ocr path was intended to never hold a thread, but it does block the event loop for the secret resolution step when a remote backend is configured. Moving _rust_ocr_pre_call into the executor step (before returning the coroutine) or wrapping it in asyncio.to_thread would preserve the non-blocking guarantee.

Comment on lines +145 to +151
gil::note_offload();
pyo3_async_runtimes::tokio::future_into_py(py, async move {
match core_ocr(request).await {
Ok(response) => Python::with_gil(|py| response_to_py(py, response)),
Err(err) => Err(core_error_to_pyerr(err)),
}
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 GIL-release counter incremented before future_into_py succeeds

note_offload() fires unconditionally before pyo3_async_runtimes::tokio::future_into_py(...). If future_into_py returns an Err (e.g. no active Tokio runtime, Python GIL state issue), the error is propagated to the caller but the offload counter has already been bumped. The gil_stats surface would then overstate the number of times the GIL was genuinely released, making the diagnostic less reliable for profiling and load testing. Moving note_offload() inside the async closure — after core_ocr(request).await is scheduled — would keep the count accurate.

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.

2 participants