From 2ba4c3166d8cecb30e3a32a4feb03647a3238bdc Mon Sep 17 00:00:00 2001 From: Sina Date: Sun, 5 Apr 2026 22:45:16 +0000 Subject: [PATCH 01/28] feat(ocr): add olmocr profiles and plain-text parsing --- src/churro_ocr/prompts/__init__.py | 4 ++ src/churro_ocr/prompts/ocr.py | 104 +++++++++++++++++++++++++++ src/churro_ocr/providers/_shared.py | 13 +++- src/churro_ocr/providers/specs.py | 79 ++++++++++++++++++-- src/churro_ocr/templates/__init__.py | 6 ++ src/churro_ocr/templates/hf.py | 6 +- src/churro_ocr/templates/presets.py | 16 ++++- tests/test_hf_ocr.py | 63 +++++++++++++++- 8 files changed, 281 insertions(+), 10 deletions(-) diff --git a/src/churro_ocr/prompts/__init__.py b/src/churro_ocr/prompts/__init__.py index 578679d..41917aa 100644 --- a/src/churro_ocr/prompts/__init__.py +++ b/src/churro_ocr/prompts/__init__.py @@ -8,6 +8,8 @@ DEFAULT_OCR_OUTPUT_TAG, DEFAULT_OCR_SYSTEM_PROMPT, DEFAULT_OCR_USER_PROMPT, + OLMOCR_V4_YAML_PROMPT, + parse_olmocr_response, strip_ocr_output_tag, ) @@ -17,5 +19,7 @@ "DEFAULT_OCR_OUTPUT_TAG", "DEFAULT_OCR_SYSTEM_PROMPT", "DEFAULT_OCR_USER_PROMPT", + "OLMOCR_V4_YAML_PROMPT", + "parse_olmocr_response", "strip_ocr_output_tag", ] diff --git a/src/churro_ocr/prompts/ocr.py b/src/churro_ocr/prompts/ocr.py index b96db4e..0007850 100644 --- a/src/churro_ocr/prompts/ocr.py +++ b/src/churro_ocr/prompts/ocr.py @@ -2,7 +2,9 @@ from __future__ import annotations +import html import re +from typing import Any DEFAULT_OCR_OUTPUT_TAG = "output" @@ -39,6 +41,16 @@ "tables, and line breaks when they are visible." ) +OLMOCR_V4_YAML_PROMPT = ( + "Attached is one page of a document that you must process. " + "Just return the plain text representation of this document as if you were reading it naturally.\n" + "Convert equations to LateX and tables to HTML.\n" + "If there are any figures or charts, label them with the following markdown syntax " + "![Alt text describing the contents of the figure](page_startx_starty_width_height.png)\n" + "Return your output as markdown, with a front matter section on top specifying values for the " + "primary_language, is_rotation_valid, rotation_correction, is_table, and is_diagram parameters." +) + def strip_ocr_output_tag(text: str, *, output_tag: str = DEFAULT_OCR_OUTPUT_TAG) -> str: """Remove outer OCR output tags and any stray tag tokens when present. @@ -57,3 +69,95 @@ def strip_ocr_output_tag(text: str, *, output_tag: str = DEFAULT_OCR_OUTPUT_TAG) stray_tag_pattern = re.compile(rf"]*>", flags=re.IGNORECASE) return stray_tag_pattern.sub("", text).strip() + + +def _extract_yaml_front_matter(text: str) -> tuple[dict[str, object], str]: + """Return YAML front matter fields and the remaining markdown body.""" + stripped = text.strip() + if not stripped.startswith("---\n"): + return {}, stripped + + end_index = stripped.find("\n---", 4) + if end_index == -1: + return {}, stripped + + front_matter_block = stripped[4:end_index] + body = stripped[end_index + 4 :].strip() + front_matter: dict[str, object] = {} + for line in front_matter_block.splitlines(): + if ":" not in line: + continue + key, raw_value = line.split(":", 1) + key = key.strip() + value = raw_value.strip() + lower = value.lower() + if lower == "null": + parsed: object = None + elif lower == "true": + parsed = True + elif lower == "false": + parsed = False + elif re.fullmatch(r"-?\d+", value): + parsed = int(value) + else: + parsed = value + front_matter[key] = parsed + return front_matter, body + + +def _strip_olmocr_markdown_to_plain_text(text: str) -> str: + """Best-effort plain-text conversion for olmOCR markdown/HTML output.""" + cleaned = text.strip() + if not cleaned: + return "" + + cleaned = re.sub(r"!\[[^\]]*]\([^)]+\)", "", cleaned) + cleaned = re.sub(r"\[([^\]]+)]\([^)]+\)", r"\1", cleaned) + + html_replacements = ( + (r"(?i)<\s*br\s*/?\s*>", "\n"), + (r"(?i)", "\n"), + (r"(?i)", " | "), + (r"(?i)<\s*li\b[^>]*>", ""), + ( + r"(?i)]*>", + "", + ), + ) + for pattern, replacement in html_replacements: + cleaned = re.sub(pattern, replacement, cleaned) + + cleaned = html.unescape(cleaned) + cleaned = re.sub(r"(?m)^\s{0,3}#{1,6}\s*", "", cleaned) + cleaned = re.sub(r"(?m)^\s*[-+*]\s+", "", cleaned) + cleaned = re.sub(r"(?m)^\s*>\s?", "", cleaned) + cleaned = cleaned.replace("```", "") + cleaned = cleaned.replace("**", "") + cleaned = cleaned.replace("__", "") + cleaned = cleaned.replace("`", "") + cleaned = re.sub(r"[ \t]+\n", "\n", cleaned) + cleaned = re.sub(r"[ \t]{2,}", " ", cleaned) + + normalized_lines: list[str] = [] + saw_content = False + for raw_line in cleaned.splitlines(): + line = re.sub(r"\s*\|\s*$", "", raw_line.strip()) + line = re.sub(r"^\|\s*", "", line) + line = re.sub(r"\s*\|\s*", " | ", line) + if line: + normalized_lines.append(line) + saw_content = True + continue + if saw_content and normalized_lines and normalized_lines[-1] != "": + normalized_lines.append("") + return "\n".join(normalized_lines).strip() + + +def parse_olmocr_response(text: str) -> tuple[str, dict[str, Any]]: + """Extract plain text and metadata from an olmOCR YAML-front-matter response.""" + front_matter, markdown_body = _extract_yaml_front_matter(text) + return _strip_olmocr_markdown_to_plain_text(markdown_body), { + "front_matter": front_matter, + "raw_markdown": markdown_body, + } diff --git a/src/churro_ocr/providers/_shared.py b/src/churro_ocr/providers/_shared.py index 9d72254..42f7f01 100644 --- a/src/churro_ocr/providers/_shared.py +++ b/src/churro_ocr/providers/_shared.py @@ -77,11 +77,20 @@ def build_ocr_result( metadata: dict[str, Any] | None = None, ) -> OCRResult: """Build a normalized OCR result after postprocessing.""" + processed = text_postprocessor(text) + postprocessor_metadata: dict[str, Any] = {} + if isinstance(processed, tuple): + processed_text, postprocessor_metadata = processed + else: + processed_text = processed + + combined_metadata = dict(metadata or {}) + combined_metadata.update(postprocessor_metadata) return OCRResult( - text=text_postprocessor(text), + text=processed_text, provider_name=provider_name, model_name=model_name, - metadata=dict(metadata or {}), + metadata=combined_metadata, ) diff --git a/src/churro_ocr/providers/specs.py b/src/churro_ocr/providers/specs.py index 2ca709d..58a08ae 100644 --- a/src/churro_ocr/providers/specs.py +++ b/src/churro_ocr/providers/specs.py @@ -5,18 +5,25 @@ from collections.abc import Callable from dataclasses import dataclass, field from pathlib import Path -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING, Any, Literal from PIL import Image -from churro_ocr._internal.image import prepare_ocr_image -from churro_ocr.prompts import DEFAULT_OCR_OUTPUT_TAG, strip_ocr_output_tag +from churro_ocr._internal.image import ensure_rgb, prepare_ocr_image, resize_image_to_fit +from churro_ocr.prompts import ( + DEFAULT_OCR_OUTPUT_TAG, + parse_olmocr_response, + strip_ocr_output_tag, +) from churro_ocr.templates import ( CHURRO_3B_MODEL_ID, CHURRO_3B_XML_TEMPLATE, DEFAULT_OCR_TEMPLATE, DOTS_OCR_1_5_MODEL_ID, DOTS_OCR_1_5_OCR_TEMPLATE, + OLMOCR_2_7B_1025_FP8_MODEL_ID, + OLMOCR_2_7B_1025_MODEL_ID, + OLMOCR_2_7B_1025_OCR_TEMPLATE, OCRConversation, OCRPromptTemplateLike, ) @@ -27,9 +34,12 @@ OCRProvider = Literal["litellm", "openai-compatible", "azure", "mistral", "hf", "vllm"] ImagePreprocessor = Callable[[Image.Image], Image.Image] -TextPostprocessor = Callable[[str], str] +TextPostprocessorResult = str | tuple[str, dict[str, Any]] +TextPostprocessor = Callable[[str], TextPostprocessorResult] VisionInputBuilder = Callable[[OCRConversation], object] DEFAULT_OCR_MAX_TOKENS = 20_000 +OLMOCR_MAX_TOKENS = 8_000 +OLMOCR_TARGET_LONGEST_IMAGE_DIM = 1_288 def identity_text_postprocessor(text: str) -> str: @@ -59,6 +69,22 @@ def default_ocr_text_postprocessor(text: str) -> str: return strip_ocr_output_tag(text, output_tag=DEFAULT_OCR_OUTPUT_TAG) +def olmocr_image_preprocessor(image: Image.Image) -> Image.Image: + """Resize an image to olmOCR's expected 1288px longest side and normalize to RGB.""" + return ensure_rgb( + resize_image_to_fit( + image, + OLMOCR_TARGET_LONGEST_IMAGE_DIM, + OLMOCR_TARGET_LONGEST_IMAGE_DIM, + ) + ) + + +def olmocr_text_postprocessor(text: str) -> TextPostprocessorResult: + """Extract plain text and metadata from olmOCR YAML/markdown output.""" + return parse_olmocr_response(text) + + @dataclass(slots=True, frozen=True) class LiteLLMTransportConfig: """Shared transport config for LiteLLM-based multimodal requests. @@ -241,14 +267,57 @@ def dots_ocr_1_5_profile() -> OCRModelProfile: ) +def _olmocr_profile(*, profile_name: str, display_name: str) -> OCRModelProfile: + return OCRModelProfile( + profile_name=profile_name, + template=OLMOCR_2_7B_1025_OCR_TEMPLATE, + image_preprocessor=olmocr_image_preprocessor, + text_postprocessor=olmocr_text_postprocessor, + display_name=display_name, + transport=LiteLLMTransportConfig( + completion_kwargs={ + "max_tokens": OLMOCR_MAX_TOKENS, + "temperature": 0.1, + } + ), + huggingface=HuggingFaceOptions( + generation_kwargs={ + "max_new_tokens": OLMOCR_MAX_TOKENS, + "temperature": 0.1, + "do_sample": True, + }, + ), + ) + + +def olmocr_2_7b_1025_profile() -> OCRModelProfile: + """Return the built-in ``allenai/olmOCR-2-7B-1025`` OCR profile.""" + return _olmocr_profile( + profile_name=OLMOCR_2_7B_1025_MODEL_ID, + display_name="olmOCR-2-7B-1025", + ) + + +def olmocr_2_7b_1025_fp8_profile() -> OCRModelProfile: + """Return the built-in ``allenai/olmOCR-2-7B-1025-FP8`` OCR profile.""" + return _olmocr_profile( + profile_name=OLMOCR_2_7B_1025_FP8_MODEL_ID, + display_name="olmOCR-2-7B-1025-FP8", + ) + + def _profile_registry() -> dict[str, OCRModelProfile]: default_profile = default_ocr_profile() churro_profile = churro_3b_profile() dots_profile = dots_ocr_1_5_profile() + olmocr_profile = olmocr_2_7b_1025_profile() + olmocr_fp8_profile = olmocr_2_7b_1025_fp8_profile() return { default_profile.profile_name: default_profile, churro_profile.profile_name: churro_profile, dots_profile.profile_name: dots_profile, + olmocr_profile.profile_name: olmocr_profile, + olmocr_fp8_profile.profile_name: olmocr_fp8_profile, } @@ -290,6 +359,8 @@ def resolve_ocr_profile( "ImagePreprocessor", "LiteLLMTransportConfig", "MistralOptions", + "olmocr_image_preprocessor", + "olmocr_text_postprocessor", "OCRBackendSpec", "OCRModelProfile", "OCRProvider", diff --git a/src/churro_ocr/templates/__init__.py b/src/churro_ocr/templates/__init__.py index 16f76b0..a8d97c9 100644 --- a/src/churro_ocr/templates/__init__.py +++ b/src/churro_ocr/templates/__init__.py @@ -15,6 +15,9 @@ DOTS_OCR_1_5_MODEL_ID, DOTS_OCR_1_5_OCR_PROMPT, DOTS_OCR_1_5_OCR_TEMPLATE, + OLMOCR_2_7B_1025_FP8_MODEL_ID, + OLMOCR_2_7B_1025_MODEL_ID, + OLMOCR_2_7B_1025_OCR_TEMPLATE, ) __all__ = [ @@ -26,6 +29,9 @@ "DOTS_OCR_1_5_OCR_PROMPT", "DOTS_OCR_1_5_OCR_TEMPLATE", "HFChatTemplate", + "OLMOCR_2_7B_1025_FP8_MODEL_ID", + "OLMOCR_2_7B_1025_MODEL_ID", + "OLMOCR_2_7B_1025_OCR_TEMPLATE", "OCRConversation", "OCRPromptTemplate", "OCRPromptTemplateCallable", diff --git a/src/churro_ocr/templates/hf.py b/src/churro_ocr/templates/hf.py index 16a1987..9032873 100644 --- a/src/churro_ocr/templates/hf.py +++ b/src/churro_ocr/templates/hf.py @@ -15,11 +15,13 @@ class HFChatTemplate: :param system_message: Optional system message prepended to the conversation. :param user_prompt: Optional user-side text prompt appended with the image. :param include_image: Whether to include the page image in the user message. + :param user_prompt_first: Whether to place the user prompt before the image. """ system_message: str | None = None user_prompt: str | None = None include_image: bool = True + user_prompt_first: bool = False def build_conversation(self, page: DocumentPage) -> OCRConversation: """Build a structured multimodal conversation for one OCR page. @@ -37,9 +39,11 @@ def build_conversation(self, page: DocumentPage) -> OCRConversation: ) user_content: list[dict[str, object]] = [] + if self.user_prompt and self.user_prompt_first: + user_content.append({"type": "text", "text": self.user_prompt}) if self.include_image: user_content.append({"type": "image", "image": page.image.copy()}) - if self.user_prompt: + if self.user_prompt and not self.user_prompt_first: user_content.append({"type": "text", "text": self.user_prompt}) conversation.append({"role": "user", "content": user_content}) diff --git a/src/churro_ocr/templates/presets.py b/src/churro_ocr/templates/presets.py index 9909db8..8270444 100644 --- a/src/churro_ocr/templates/presets.py +++ b/src/churro_ocr/templates/presets.py @@ -2,11 +2,17 @@ from __future__ import annotations -from churro_ocr.prompts import DEFAULT_OCR_SYSTEM_PROMPT, DEFAULT_OCR_USER_PROMPT +from churro_ocr.prompts import ( + DEFAULT_OCR_SYSTEM_PROMPT, + DEFAULT_OCR_USER_PROMPT, + OLMOCR_V4_YAML_PROMPT, +) from churro_ocr.templates.hf import HFChatTemplate CHURRO_3B_MODEL_ID = "stanford-oval/churro-3B" DOTS_OCR_1_5_MODEL_ID = "kristaller486/dots.ocr-1.5" +OLMOCR_2_7B_1025_MODEL_ID = "allenai/olmOCR-2-7B-1025" +OLMOCR_2_7B_1025_FP8_MODEL_ID = "allenai/olmOCR-2-7B-1025-FP8" DEFAULT_OCR_TEMPLATE = HFChatTemplate( system_message=DEFAULT_OCR_SYSTEM_PROMPT, user_prompt=DEFAULT_OCR_USER_PROMPT, @@ -21,6 +27,11 @@ system_message=None, user_prompt=DOTS_OCR_1_5_OCR_PROMPT, ) +OLMOCR_2_7B_1025_OCR_TEMPLATE = HFChatTemplate( + system_message=None, + user_prompt=OLMOCR_V4_YAML_PROMPT, + user_prompt_first=True, +) __all__ = [ @@ -30,4 +41,7 @@ "DOTS_OCR_1_5_MODEL_ID", "DOTS_OCR_1_5_OCR_PROMPT", "DOTS_OCR_1_5_OCR_TEMPLATE", + "OLMOCR_2_7B_1025_FP8_MODEL_ID", + "OLMOCR_2_7B_1025_MODEL_ID", + "OLMOCR_2_7B_1025_OCR_TEMPLATE", ] diff --git a/tests/test_hf_ocr.py b/tests/test_hf_ocr.py index 8eabd3c..92b26ae 100644 --- a/tests/test_hf_ocr.py +++ b/tests/test_hf_ocr.py @@ -12,7 +12,7 @@ from churro_ocr.errors import ConfigurationError from churro_ocr.ocr import OCRClient from churro_ocr.page_detection import DocumentPage -from churro_ocr.prompts import DEFAULT_OCR_OUTPUT_TAG +from churro_ocr.prompts import DEFAULT_OCR_OUTPUT_TAG, OLMOCR_V4_YAML_PROMPT, parse_olmocr_response from churro_ocr.providers import OCRBackendSpec, build_ocr_backend from churro_ocr.providers.hf import ( Churro3BOCRBackend, @@ -25,6 +25,8 @@ DOTS_OCR_1_5_MODEL_ID, DOTS_OCR_1_5_OCR_PROMPT, DOTS_OCR_1_5_OCR_TEMPLATE, + OLMOCR_2_7B_1025_MODEL_ID, + OLMOCR_2_7B_1025_OCR_TEMPLATE, HFChatTemplate, ) @@ -45,6 +47,63 @@ def test_hf_chat_template_builds_expected_conversation() -> None: assert conversation[1]["content"][1]["text"] == "user text" +def test_olmocr_template_builds_prompt_before_image() -> None: + page = DocumentPage.from_image(Image.new("RGB", (20, 20), color="white")) + + conversation = OLMOCR_2_7B_1025_OCR_TEMPLATE.build_conversation(page) + + assert conversation[0]["role"] == "user" + assert conversation[0]["content"][0]["text"] == OLMOCR_V4_YAML_PROMPT + assert conversation[0]["content"][1]["type"] == "image" + + +def test_parse_olmocr_response_extracts_plain_text_and_metadata() -> None: + text, metadata = parse_olmocr_response( + "---\n" + "primary_language: en\n" + "is_rotation_valid: true\n" + "rotation_correction: 0\n" + "is_table: true\n" + "is_diagram: false\n" + "---\n" + "# Heading\n\n" + "
YearValue
190042
\n\n" + "![Figure alt text](page_0_0_100_100.png)\n" + "Paragraph with [reference](https://example.test)." + ) + + assert text == "Heading\n\nYear | Value\n1900 | 42\n\nParagraph with reference." + assert metadata["front_matter"] == { + "primary_language": "en", + "is_rotation_valid": True, + "rotation_correction": 0, + "is_table": True, + "is_diagram": False, + } + assert "Heading" in cast("str", metadata["raw_markdown"]) + + +def test_build_ocr_backend_uses_olmocr_profile_defaults_for_hf() -> None: + backend = cast( + "HuggingFaceVisionOCRBackend", + build_ocr_backend( + OCRBackendSpec( + provider="hf", + model=OLMOCR_2_7B_1025_MODEL_ID, + ) + ), + ) + + assert backend.template == OLMOCR_2_7B_1025_OCR_TEMPLATE + assert backend.model_name == "olmOCR-2-7B-1025" + assert backend.generation_kwargs == { + "max_new_tokens": 8_000, + "temperature": 0.1, + "do_sample": True, + } + assert backend.image_preprocessor(Image.new("RGB", (5_000, 3_000), color="white")).size == (1_288, 772) + + @pytest.mark.asyncio async def test_huggingface_vision_ocr_backend_uses_custom_template( monkeypatch: pytest.MonkeyPatch, @@ -345,7 +404,7 @@ def test_dots_ocr_15_backend_uses_expected_defaults() -> None: assert backend.trust_remote_code is True assert backend.processor_kwargs == {} assert backend.model_kwargs["dtype"] in {"auto", "float32"} - if backend.model_kwargs["dtype"] == "auto": + if backend.model_kwargs["dtype"] == "auto" and "device_map" in backend.model_kwargs: assert backend.model_kwargs["device_map"] == "auto" assert "max_memory" in backend.model_kwargs assert backend.generation_kwargs == {"max_new_tokens": DEFAULT_OCR_MAX_TOKENS} From d89bfb8c4aed2c7427005164fc440155a0ab0ab2 Mon Sep 17 00:00:00 2001 From: Sina Date: Sun, 5 Apr 2026 22:46:11 +0000 Subject: [PATCH 02/28] build(repo): slim runtime extras and pin ruff --- pixi.lock | 540 +----------------------------------- pyproject.toml | 13 +- ruff.toml | 2 +- scripts/package_check.py | 37 ++- tests/test_package_check.py | 17 ++ 5 files changed, 57 insertions(+), 552 deletions(-) diff --git a/pixi.lock b/pixi.lock index edf4dd3..3eb0c09 100644 --- a/pixi.lock +++ b/pixi.lock @@ -62,7 +62,6 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda - - pypi: https://files.pythonhosted.org/packages/7e/46/02ac5e262d4af18054b3e922b2baedbb2a03289ee792162de60a865defc5/accelerate-1.13.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl @@ -86,9 +85,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/ee/5e/c0fe77a73aaefd3fff25ffaccaac69c5a63eafdf8b9a4c476626ef0ac703/cuda_bindings-13.2.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/c4/74/8c66861b873d8eed51fde56d3091baa4906a56f0d4390cae991f2d41dda5/cuda_pathfinder-1.5.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/57/b2/453099f5f3b698d7d0eab38916aac44c7f76229f451709e2eb9db6615dcd/cuda_toolkit-13.0.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b0/e5/247d094108e42ac26363ab8dc57f168840cf7c05774b40ffeb0d78868fcc/datasets-4.8.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl @@ -134,30 +130,13 @@ environments: - pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c9/f9/98d825105c450b9c67c27026caa374112b7e466c18331601d02ca278a01b/mistralai-1.12.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d8/f4/5e52c7319b8087acef603ed6e50dc325c02eaa999355414830468611f13c/more_itertools-11.0.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/a0/61/af9115673a5870fd885247e2f1b68c4f1197737da315b520a91c757a861a/multiprocess-0.70.19-py314-none-any.whl - pypi: https://files.pythonhosted.org/packages/5f/df/76d0321c3797b54b60fef9ec3bd6f4cfd124b9e422182156a1dd418722cf/myst_parser-4.0.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d6/b7/ec1cbc6b297a808c513f59f501656389623fc09ad6a58c640851289c7854/nh3-0.3.4-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/9d/91/04e965f8e717ba0ab4bdca5c112deeab11c9e750d94c4d4602f050295d39/nltk-3.9.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/e7/44/423ac00af4dd95a5aeb27207e2c0d9b7118702149bf4704c3ddb55bb7429/nvidia_cublas-13.1.0.3-py3-none-manylinux_2_27_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/33/6d/737d164b4837a9bbd202f5ae3078975f0525a55730fe871d8ed4e3b952b0/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/c3/68/483a78f5e8f31b08fb1bb671559968c0ca3a065ac7acabfc7cee55214fd6/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/2e/24/d1558f3b68b1d26e706813b1d10aa1d785e4698c425af8db8edc3dced472/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/a3/22/0b4b932655d17a6da1b92fa92ab12844b053bb2ac2475e179ba6f043da1e/nvidia_cudnn_cu13-9.19.0.56-py3-none-manylinux_2_27_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/a8/2f/7b57e29836ea8714f81e9898409196f47d772d5ddedddf1592eadb8ab743/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/3f/70/4f193de89a48b71714e74602ee14d04e4019ad36a5a9f20c425776e72cd6/nvidia_cufile-1.15.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/a5/9f/be0a41ca4a4917abf5cb9ae0daff1a6060cc5de950aec0396de9f3b52bc5/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/5f/67/cba3777620cdacb99102da4042883709c41c709f4b6323c10781a9c3aa34/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/fa/18/623c77619c31d62efd55302939756966f3ecc8d724a14dab2b75f1508850/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/fd/53/43b0d71f4e702fa9733f8b4571fdca50a8813f1e450b656c239beff12315/nvidia_cusparselt_cu13-0.8.0-py3-none-manylinux2014_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/b0/b4/878fefaad5b2bcc6fcf8d474a25e3e3774bc5133e4b58adff4d0bca238bc/nvidia_nccl_cu13-2.28.9-py3-none-manylinux_2_18_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/56/7a/123e033aaff487c77107195fa5a2b8686795ca537935a24efae476c41f05/nvidia_nvjitlink-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/3c/35/a9bf80a609e74e3b000fef598933235c908fcefcef9026042b8e6dfde2a9/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/c2/f3/d86c845465a2723ad7e1e5c36dcd75ddb82898b3f53be47ebd429fb2fa5d/nvidia_nvtx-13.0.85-py3-none-manylinux1_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/2a/9e/5bfa2270f902d5b92ab7d41ce0475b8630572e71e349b2a4996d14bdda93/openai-2.30.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5f/bf/93795954016c522008da367da292adceed71cca6ee1717e1d64c83089099/opentelemetry_api-1.40.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8b/ca/8f122055c97a932311a3f640273f084e738008933503d0c2563cd5d591fc/opentelemetry_exporter_otlp_proto_common-1.40.0-py3-none-any.whl @@ -173,7 +152,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/d7/64/0ea5be39e6a6515804cae8c280226d771f42750a08182f9d2e5f3b822694/PyArabic-0.6.15-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/36/2e/c0f017c405fcdc252dbccafbe05e36b0d0eb1ea9a958f081e01c6972927f/pyarrow-23.0.1-cp314-cp314-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl @@ -207,7 +185,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/a0/60/429e9b1cb3fc651937727befe258ea24122d9663e4d5709a48c9cbfceecb/safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl @@ -224,14 +201,10 @@ environments: - pypi: https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f5/de/9341a6d7a8f1b448573bbf3425fa57669ac58258a667eb48a25dfe916d70/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/47/e8/b98ca2d39b2e0e4730c0ee52537e488e7008025bc77ca89552ff91021f7c/torch-2.11.0-cp314-cp314-manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/a4/21/a2266f7f1b0e58e624ff15fd6f01041f59182c49551ece0db9a183071329/torchvision-0.26.0-cp314-cp314-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/03/b8/e484ef633af3887baeeb4b6ad12743363af7cce68ae51e938e00aaa0529d/transformers-4.57.6-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/df/3d/9e7eee57b37c80cec63322c0231bb6da3cfe535a91d7a4d64896fcb89357/triton-3.6.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/3a/7a/882d99539b19b1490cac5d77c67338d126e4122c8276bf640e411650c830/twine-6.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/89/29/8ac0281fc44c3297f0e58699ebf993c13621e32a0fab1025439d3ea8a2f1/ty-0.0.28-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl @@ -282,7 +255,6 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda - - pypi: https://files.pythonhosted.org/packages/7e/46/02ac5e262d4af18054b3e922b2baedbb2a03289ee792162de60a865defc5/accelerate-1.13.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl @@ -306,9 +278,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/ee/5e/c0fe77a73aaefd3fff25ffaccaac69c5a63eafdf8b9a4c476626ef0ac703/cuda_bindings-13.2.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/c4/74/8c66861b873d8eed51fde56d3091baa4906a56f0d4390cae991f2d41dda5/cuda_pathfinder-1.5.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/57/b2/453099f5f3b698d7d0eab38916aac44c7f76229f451709e2eb9db6615dcd/cuda_toolkit-13.0.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b0/e5/247d094108e42ac26363ab8dc57f168840cf7c05774b40ffeb0d78868fcc/datasets-4.8.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl @@ -354,30 +323,13 @@ environments: - pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c9/f9/98d825105c450b9c67c27026caa374112b7e466c18331601d02ca278a01b/mistralai-1.12.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d8/f4/5e52c7319b8087acef603ed6e50dc325c02eaa999355414830468611f13c/more_itertools-11.0.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/a0/61/af9115673a5870fd885247e2f1b68c4f1197737da315b520a91c757a861a/multiprocess-0.70.19-py314-none-any.whl - pypi: https://files.pythonhosted.org/packages/5f/df/76d0321c3797b54b60fef9ec3bd6f4cfd124b9e422182156a1dd418722cf/myst_parser-4.0.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d6/b7/ec1cbc6b297a808c513f59f501656389623fc09ad6a58c640851289c7854/nh3-0.3.4-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/9d/91/04e965f8e717ba0ab4bdca5c112deeab11c9e750d94c4d4602f050295d39/nltk-3.9.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/e7/44/423ac00af4dd95a5aeb27207e2c0d9b7118702149bf4704c3ddb55bb7429/nvidia_cublas-13.1.0.3-py3-none-manylinux_2_27_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/33/6d/737d164b4837a9bbd202f5ae3078975f0525a55730fe871d8ed4e3b952b0/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/c3/68/483a78f5e8f31b08fb1bb671559968c0ca3a065ac7acabfc7cee55214fd6/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/2e/24/d1558f3b68b1d26e706813b1d10aa1d785e4698c425af8db8edc3dced472/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/a3/22/0b4b932655d17a6da1b92fa92ab12844b053bb2ac2475e179ba6f043da1e/nvidia_cudnn_cu13-9.19.0.56-py3-none-manylinux_2_27_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/a8/2f/7b57e29836ea8714f81e9898409196f47d772d5ddedddf1592eadb8ab743/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/3f/70/4f193de89a48b71714e74602ee14d04e4019ad36a5a9f20c425776e72cd6/nvidia_cufile-1.15.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/a5/9f/be0a41ca4a4917abf5cb9ae0daff1a6060cc5de950aec0396de9f3b52bc5/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/5f/67/cba3777620cdacb99102da4042883709c41c709f4b6323c10781a9c3aa34/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/fa/18/623c77619c31d62efd55302939756966f3ecc8d724a14dab2b75f1508850/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/fd/53/43b0d71f4e702fa9733f8b4571fdca50a8813f1e450b656c239beff12315/nvidia_cusparselt_cu13-0.8.0-py3-none-manylinux2014_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/b0/b4/878fefaad5b2bcc6fcf8d474a25e3e3774bc5133e4b58adff4d0bca238bc/nvidia_nccl_cu13-2.28.9-py3-none-manylinux_2_18_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/56/7a/123e033aaff487c77107195fa5a2b8686795ca537935a24efae476c41f05/nvidia_nvjitlink-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/3c/35/a9bf80a609e74e3b000fef598933235c908fcefcef9026042b8e6dfde2a9/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/c2/f3/d86c845465a2723ad7e1e5c36dcd75ddb82898b3f53be47ebd429fb2fa5d/nvidia_nvtx-13.0.85-py3-none-manylinux1_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/2a/9e/5bfa2270f902d5b92ab7d41ce0475b8630572e71e349b2a4996d14bdda93/openai-2.30.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5f/bf/93795954016c522008da367da292adceed71cca6ee1717e1d64c83089099/opentelemetry_api-1.40.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8b/ca/8f122055c97a932311a3f640273f084e738008933503d0c2563cd5d591fc/opentelemetry_exporter_otlp_proto_common-1.40.0-py3-none-any.whl @@ -393,7 +345,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/d7/64/0ea5be39e6a6515804cae8c280226d771f42750a08182f9d2e5f3b822694/PyArabic-0.6.15-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/36/2e/c0f017c405fcdc252dbccafbe05e36b0d0eb1ea9a958f081e01c6972927f/pyarrow-23.0.1-cp314-cp314-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl @@ -427,7 +378,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/a0/60/429e9b1cb3fc651937727befe258ea24122d9663e4d5709a48c9cbfceecb/safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl @@ -444,14 +394,10 @@ environments: - pypi: https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f5/de/9341a6d7a8f1b448573bbf3425fa57669ac58258a667eb48a25dfe916d70/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/47/e8/b98ca2d39b2e0e4730c0ee52537e488e7008025bc77ca89552ff91021f7c/torch-2.11.0-cp314-cp314-manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/a4/21/a2266f7f1b0e58e624ff15fd6f01041f59182c49551ece0db9a183071329/torchvision-0.26.0-cp314-cp314-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/03/b8/e484ef633af3887baeeb4b6ad12743363af7cce68ae51e938e00aaa0529d/transformers-4.57.6-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/df/3d/9e7eee57b37c80cec63322c0231bb6da3cfe535a91d7a4d64896fcb89357/triton-3.6.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/3a/7a/882d99539b19b1490cac5d77c67338d126e4122c8276bf640e411650c830/twine-6.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/89/29/8ac0281fc44c3297f0e58699ebf993c13621e32a0fab1025439d3ea8a2f1/ty-0.0.28-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl @@ -502,7 +448,6 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda - - pypi: https://files.pythonhosted.org/packages/7e/46/02ac5e262d4af18054b3e922b2baedbb2a03289ee792162de60a865defc5/accelerate-1.13.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl @@ -523,9 +468,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/ee/5e/c0fe77a73aaefd3fff25ffaccaac69c5a63eafdf8b9a4c476626ef0ac703/cuda_bindings-13.2.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/c4/74/8c66861b873d8eed51fde56d3091baa4906a56f0d4390cae991f2d41dda5/cuda_pathfinder-1.5.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/57/b2/453099f5f3b698d7d0eab38916aac44c7f76229f451709e2eb9db6615dcd/cuda_toolkit-13.0.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b0/e5/247d094108e42ac26363ab8dc57f168840cf7c05774b40ffeb0d78868fcc/datasets-4.8.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl @@ -558,30 +500,13 @@ environments: - pypi: https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d8/f4/5e52c7319b8087acef603ed6e50dc325c02eaa999355414830468611f13c/more_itertools-11.0.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/a0/61/af9115673a5870fd885247e2f1b68c4f1197737da315b520a91c757a861a/multiprocess-0.70.19-py314-none-any.whl - pypi: https://files.pythonhosted.org/packages/5f/df/76d0321c3797b54b60fef9ec3bd6f4cfd124b9e422182156a1dd418722cf/myst_parser-4.0.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d6/b7/ec1cbc6b297a808c513f59f501656389623fc09ad6a58c640851289c7854/nh3-0.3.4-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/9d/91/04e965f8e717ba0ab4bdca5c112deeab11c9e750d94c4d4602f050295d39/nltk-3.9.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/e7/44/423ac00af4dd95a5aeb27207e2c0d9b7118702149bf4704c3ddb55bb7429/nvidia_cublas-13.1.0.3-py3-none-manylinux_2_27_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/33/6d/737d164b4837a9bbd202f5ae3078975f0525a55730fe871d8ed4e3b952b0/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/c3/68/483a78f5e8f31b08fb1bb671559968c0ca3a065ac7acabfc7cee55214fd6/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/2e/24/d1558f3b68b1d26e706813b1d10aa1d785e4698c425af8db8edc3dced472/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/a3/22/0b4b932655d17a6da1b92fa92ab12844b053bb2ac2475e179ba6f043da1e/nvidia_cudnn_cu13-9.19.0.56-py3-none-manylinux_2_27_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/a8/2f/7b57e29836ea8714f81e9898409196f47d772d5ddedddf1592eadb8ab743/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/3f/70/4f193de89a48b71714e74602ee14d04e4019ad36a5a9f20c425776e72cd6/nvidia_cufile-1.15.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/a5/9f/be0a41ca4a4917abf5cb9ae0daff1a6060cc5de950aec0396de9f3b52bc5/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/5f/67/cba3777620cdacb99102da4042883709c41c709f4b6323c10781a9c3aa34/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/fa/18/623c77619c31d62efd55302939756966f3ecc8d724a14dab2b75f1508850/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/fd/53/43b0d71f4e702fa9733f8b4571fdca50a8813f1e450b656c239beff12315/nvidia_cusparselt_cu13-0.8.0-py3-none-manylinux2014_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/b0/b4/878fefaad5b2bcc6fcf8d474a25e3e3774bc5133e4b58adff4d0bca238bc/nvidia_nccl_cu13-2.28.9-py3-none-manylinux_2_18_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/56/7a/123e033aaff487c77107195fa5a2b8686795ca537935a24efae476c41f05/nvidia_nvjitlink-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/3c/35/a9bf80a609e74e3b000fef598933235c908fcefcef9026042b8e6dfde2a9/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/c2/f3/d86c845465a2723ad7e1e5c36dcd75ddb82898b3f53be47ebd429fb2fa5d/nvidia_nvtx-13.0.85-py3-none-manylinux1_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/15/88/3cdd54fa279341afa10acf8d2b503556b1375245dccc9315659f795dd2e9/pandas-3.0.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl @@ -589,7 +514,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/d7/64/0ea5be39e6a6515804cae8c280226d771f42750a08182f9d2e5f3b822694/PyArabic-0.6.15-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/36/2e/c0f017c405fcdc252dbccafbe05e36b0d0eb1ea9a958f081e01c6972927f/pyarrow-23.0.1-cp314-cp314-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl @@ -617,7 +541,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/a0/60/429e9b1cb3fc651937727befe258ea24122d9663e4d5709a48c9cbfceecb/safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl @@ -633,13 +556,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/47/e8/b98ca2d39b2e0e4730c0ee52537e488e7008025bc77ca89552ff91021f7c/torch-2.11.0-cp314-cp314-manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/a4/21/a2266f7f1b0e58e624ff15fd6f01041f59182c49551ece0db9a183071329/torchvision-0.26.0-cp314-cp314-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/03/b8/e484ef633af3887baeeb4b6ad12743363af7cce68ae51e938e00aaa0529d/transformers-4.57.6-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/df/3d/9e7eee57b37c80cec63322c0231bb6da3cfe535a91d7a4d64896fcb89357/triton-3.6.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/3a/7a/882d99539b19b1490cac5d77c67338d126e4122c8276bf640e411650c830/twine-6.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/89/29/8ac0281fc44c3297f0e58699ebf993c13621e32a0fab1025439d3ea8a2f1/ty-0.0.28-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl @@ -667,81 +586,6 @@ packages: purls: [] size: 28948 timestamp: 1770939786096 -- pypi: https://files.pythonhosted.org/packages/7e/46/02ac5e262d4af18054b3e922b2baedbb2a03289ee792162de60a865defc5/accelerate-1.13.0-py3-none-any.whl - name: accelerate - version: 1.13.0 - sha256: cf1a3efb96c18f7b152eb0fa7490f3710b19c3f395699358f08decca2b8b62e0 - requires_dist: - - numpy>=1.17 - - packaging>=20.0 - - psutil - - pyyaml - - torch>=2.0.0 - - huggingface-hub>=0.21.0 - - safetensors>=0.4.3 - - ruff==0.13.1 ; extra == 'quality' - - pytest>=7.2.0 ; extra == 'test-prod' - - pytest-xdist ; extra == 'test-prod' - - pytest-subtests ; extra == 'test-prod' - - parameterized ; extra == 'test-prod' - - pytest-order ; extra == 'test-prod' - - datasets ; extra == 'test-dev' - - diffusers ; extra == 'test-dev' - - evaluate ; extra == 'test-dev' - - torchdata>=0.8.0 ; extra == 'test-dev' - - torchpippy>=0.2.0 ; extra == 'test-dev' - - transformers ; extra == 'test-dev' - - scipy ; extra == 'test-dev' - - scikit-learn ; extra == 'test-dev' - - tqdm ; extra == 'test-dev' - - bitsandbytes ; extra == 'test-dev' - - timm ; extra == 'test-dev' - - pytest>=7.2.0 ; extra == 'testing' - - pytest-xdist ; extra == 'testing' - - pytest-subtests ; extra == 'testing' - - parameterized ; extra == 'testing' - - pytest-order ; extra == 'testing' - - datasets ; extra == 'testing' - - diffusers ; extra == 'testing' - - evaluate ; extra == 'testing' - - torchdata>=0.8.0 ; extra == 'testing' - - torchpippy>=0.2.0 ; extra == 'testing' - - transformers ; extra == 'testing' - - scipy ; extra == 'testing' - - scikit-learn ; extra == 'testing' - - tqdm ; extra == 'testing' - - bitsandbytes ; extra == 'testing' - - timm ; extra == 'testing' - - deepspeed ; extra == 'deepspeed' - - rich ; extra == 'rich' - - torchao ; extra == 'test-fp8' - - wandb ; extra == 'test-trackers' - - comet-ml ; extra == 'test-trackers' - - tensorboard ; extra == 'test-trackers' - - dvclive ; extra == 'test-trackers' - - matplotlib ; extra == 'test-trackers' - - swanlab[dashboard] ; extra == 'test-trackers' - - trackio ; extra == 'test-trackers' - - ruff==0.13.1 ; extra == 'dev' - - pytest>=7.2.0 ; extra == 'dev' - - pytest-xdist ; extra == 'dev' - - pytest-subtests ; extra == 'dev' - - parameterized ; extra == 'dev' - - pytest-order ; extra == 'dev' - - datasets ; extra == 'dev' - - diffusers ; extra == 'dev' - - evaluate ; extra == 'dev' - - torchdata>=0.8.0 ; extra == 'dev' - - torchpippy>=0.2.0 ; extra == 'dev' - - transformers ; extra == 'dev' - - scipy ; extra == 'dev' - - scikit-learn ; extra == 'dev' - - tqdm ; extra == 'dev' - - bitsandbytes ; extra == 'dev' - - timm ; extra == 'dev' - - rich ; extra == 'dev' - - sagemaker ; extra == 'sagemaker' - requires_python: '>=3.10.0' - pypi: https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl name: accessible-pygments version: 0.0.5 @@ -932,7 +776,7 @@ packages: - pypi: ./ name: churro-ocr version: 0.2.0 - sha256: 6a6c02deb47eda1382303d34056d6b79ca06f84f75e8739d162c1f06eed4563d + sha256: c9aab6c57f839ce8119b38b864926707e9f7b1e55548a4dfc0421eb8520d5ba3 requires_dist: - loguru>=0.7.2,<1 - pillow>=10.4.0,<12 @@ -942,10 +786,7 @@ packages: - litellm[caching]==1.82.3 ; extra == 'llm' - azure-ai-documentintelligence==1.0.2 ; extra == 'azure' - qwen-vl-utils ; extra == 'hf' - - transformers[torch]>=4.57.0,<5 ; extra == 'hf' - - torchvision ; extra == 'hf' - - transformers>=4.57.0,<5 ; extra == 'vllm' - - torchvision ; extra == 'vllm' + - transformers>=4.57.0,<5 ; extra == 'hf' - vllm>=0.18,<1 ; extra == 'vllm' - mistralai>=1.6.0,<2 ; extra == 'mistral' - litellm[caching]==1.82.3 ; extra == 'local' @@ -954,9 +795,7 @@ packages: - litellm[caching]==1.82.3 ; extra == 'all' - azure-ai-documentintelligence==1.0.2 ; extra == 'all' - qwen-vl-utils ; extra == 'all' - - transformers[torch]>=4.57.0,<5 ; extra == 'all' - - torchvision ; extra == 'all' - - vllm>=0.18,<1 ; extra == 'all' + - transformers>=4.57.0,<5 ; extra == 'all' - mistralai>=1.6.0,<2 ; extra == 'all' - pypdfium2>=5,<6 ; extra == 'all' requires_python: '>=3.12' @@ -1009,15 +848,6 @@ packages: - check-sdist ; extra == 'pep8test' - click>=8.0.1 ; extra == 'pep8test' requires_python: '>=3.8,!=3.9.0,!=3.9.1' -- pypi: https://files.pythonhosted.org/packages/ee/5e/c0fe77a73aaefd3fff25ffaccaac69c5a63eafdf8b9a4c476626ef0ac703/cuda_bindings-13.2.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl - name: cuda-bindings - version: 13.2.0 - sha256: f4af9f3e1be603fa12d5ad6cfca7844c9d230befa9792b5abdf7dd79979c3626 - requires_dist: - - cuda-pathfinder~=1.1 - - cuda-toolkit[nvfatbin,nvjitlink,nvrtc,nvvm]==13.* ; extra == 'all' - - cuda-toolkit[cufile]==13.* ; sys_platform == 'linux' and extra == 'all' - requires_python: '>=3.10' - conda: https://conda.anaconda.org/conda-forge/linux-64/cuda-cudart-12.9.79-h5888daf_0.conda sha256: 57d1294ecfaf9dc8cdb5fc4be3e63ebc7614538bddb5de53cfd9b1b7de43aed5 md5: cb15315d19b58bd9cd424084e58ad081 @@ -1086,66 +916,6 @@ packages: purls: [] size: 30747 timestamp: 1746192810479 -- pypi: https://files.pythonhosted.org/packages/c4/74/8c66861b873d8eed51fde56d3091baa4906a56f0d4390cae991f2d41dda5/cuda_pathfinder-1.5.1-py3-none-any.whl - name: cuda-pathfinder - version: 1.5.1 - sha256: b3718097fb57cf9e8a904dd072d806f2c9a27627e35c020b06ab9454bcec08c0 - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/57/b2/453099f5f3b698d7d0eab38916aac44c7f76229f451709e2eb9db6615dcd/cuda_toolkit-13.0.2-py2.py3-none-any.whl - name: cuda-toolkit - version: 13.0.2 - sha256: b198824cf2f54003f50d64ada3a0f184b42ca0846c1c94192fa269ecd97a66eb - requires_dist: - - nvidia-cublas==13.1.0.3.* ; (sys_platform == 'linux' and extra == 'all') or (sys_platform == 'win32' and extra == 'all') - - nvidia-cuda-cccl==13.0.85.* ; (sys_platform == 'linux' and extra == 'all') or (sys_platform == 'win32' and extra == 'all') - - nvidia-cuda-crt==13.0.88.* ; (sys_platform == 'linux' and extra == 'all') or (sys_platform == 'win32' and extra == 'all') - - nvidia-cuda-culibos==13.0.85.* ; sys_platform == 'linux' and extra == 'all' - - nvidia-cuda-cupti==13.0.85.* ; (sys_platform == 'linux' and extra == 'all') or (sys_platform == 'win32' and extra == 'all') - - nvidia-cuda-cuxxfilt==13.0.85.* ; (sys_platform == 'linux' and extra == 'all') or (sys_platform == 'win32' and extra == 'all') - - nvidia-cuda-nvcc==13.0.88.* ; (sys_platform == 'linux' and extra == 'all') or (sys_platform == 'win32' and extra == 'all') - - nvidia-cuda-nvrtc==13.0.88.* ; (sys_platform == 'linux' and extra == 'all') or (sys_platform == 'win32' and extra == 'all') - - nvidia-cuda-opencl==13.0.85.* ; (sys_platform == 'linux' and extra == 'all') or (sys_platform == 'win32' and extra == 'all') - - nvidia-cuda-profiler-api==13.0.85.* ; (sys_platform == 'linux' and extra == 'all') or (sys_platform == 'win32' and extra == 'all') - - nvidia-cuda-runtime==13.0.96.* ; (sys_platform == 'linux' and extra == 'all') or (sys_platform == 'win32' and extra == 'all') - - nvidia-cuda-sanitizer-api==13.0.85.* ; (sys_platform == 'linux' and extra == 'all') or (sys_platform == 'win32' and extra == 'all') - - nvidia-cufft==12.0.0.61.* ; (sys_platform == 'linux' and extra == 'all') or (sys_platform == 'win32' and extra == 'all') - - nvidia-cufile==1.15.1.6.* ; sys_platform == 'linux' and extra == 'all' - - nvidia-curand==10.4.0.35.* ; (sys_platform == 'linux' and extra == 'all') or (sys_platform == 'win32' and extra == 'all') - - nvidia-cusolver==12.0.4.66.* ; (sys_platform == 'linux' and extra == 'all') or (sys_platform == 'win32' and extra == 'all') - - nvidia-cusparse==12.6.3.3.* ; (sys_platform == 'linux' and extra == 'all') or (sys_platform == 'win32' and extra == 'all') - - nvidia-npp==13.0.1.2.* ; (sys_platform == 'linux' and extra == 'all') or (sys_platform == 'win32' and extra == 'all') - - nvidia-nvfatbin==13.0.85.* ; (sys_platform == 'linux' and extra == 'all') or (sys_platform == 'win32' and extra == 'all') - - nvidia-nvjitlink==13.0.88.* ; (sys_platform == 'linux' and extra == 'all') or (sys_platform == 'win32' and extra == 'all') - - nvidia-nvjpeg==13.0.1.86.* ; (sys_platform == 'linux' and extra == 'all') or (sys_platform == 'win32' and extra == 'all') - - nvidia-nvml-dev==13.0.87.* ; (sys_platform == 'linux' and extra == 'all') or (sys_platform == 'win32' and extra == 'all') - - nvidia-nvptxcompiler==13.0.88.* ; (sys_platform == 'linux' and extra == 'all') or (sys_platform == 'win32' and extra == 'all') - - nvidia-nvtx==13.0.85.* ; (sys_platform == 'linux' and extra == 'all') or (sys_platform == 'win32' and extra == 'all') - - nvidia-nvvm==13.0.88.* ; (sys_platform == 'linux' and extra == 'all') or (sys_platform == 'win32' and extra == 'all') - - nvidia-cuda-cccl==13.0.85.* ; (sys_platform == 'linux' and extra == 'cccl') or (sys_platform == 'win32' and extra == 'cccl') - - nvidia-cuda-crt==13.0.88.* ; (sys_platform == 'linux' and extra == 'crt') or (sys_platform == 'win32' and extra == 'crt') - - nvidia-cublas==13.1.0.3.* ; (sys_platform == 'linux' and extra == 'cublas') or (sys_platform == 'win32' and extra == 'cublas') - - nvidia-cuda-runtime==13.0.96.* ; (sys_platform == 'linux' and extra == 'cudart') or (sys_platform == 'win32' and extra == 'cudart') - - nvidia-cufft==12.0.0.61.* ; (sys_platform == 'linux' and extra == 'cufft') or (sys_platform == 'win32' and extra == 'cufft') - - nvidia-cufile==1.15.1.6.* ; sys_platform == 'linux' and extra == 'cufile' - - nvidia-cuda-culibos==13.0.85.* ; sys_platform == 'linux' and extra == 'culibos' - - nvidia-cuda-cupti==13.0.85.* ; (sys_platform == 'linux' and extra == 'cupti') or (sys_platform == 'win32' and extra == 'cupti') - - nvidia-curand==10.4.0.35.* ; (sys_platform == 'linux' and extra == 'curand') or (sys_platform == 'win32' and extra == 'curand') - - nvidia-cusolver==12.0.4.66.* ; (sys_platform == 'linux' and extra == 'cusolver') or (sys_platform == 'win32' and extra == 'cusolver') - - nvidia-cusparse==12.6.3.3.* ; (sys_platform == 'linux' and extra == 'cusparse') or (sys_platform == 'win32' and extra == 'cusparse') - - nvidia-cuda-cuxxfilt==13.0.85.* ; (sys_platform == 'linux' and extra == 'cuxxfilt') or (sys_platform == 'win32' and extra == 'cuxxfilt') - - nvidia-npp==13.0.1.2.* ; (sys_platform == 'linux' and extra == 'npp') or (sys_platform == 'win32' and extra == 'npp') - - nvidia-cuda-nvcc==13.0.88.* ; (sys_platform == 'linux' and extra == 'nvcc') or (sys_platform == 'win32' and extra == 'nvcc') - - nvidia-nvfatbin==13.0.85.* ; (sys_platform == 'linux' and extra == 'nvfatbin') or (sys_platform == 'win32' and extra == 'nvfatbin') - - nvidia-nvjitlink==13.0.88.* ; (sys_platform == 'linux' and extra == 'nvjitlink') or (sys_platform == 'win32' and extra == 'nvjitlink') - - nvidia-nvjpeg==13.0.1.86.* ; (sys_platform == 'linux' and extra == 'nvjpeg') or (sys_platform == 'win32' and extra == 'nvjpeg') - - nvidia-nvml-dev==13.0.87.* ; (sys_platform == 'linux' and extra == 'nvml') or (sys_platform == 'win32' and extra == 'nvml') - - nvidia-nvptxcompiler==13.0.88.* ; (sys_platform == 'linux' and extra == 'nvptxcompiler') or (sys_platform == 'win32' and extra == 'nvptxcompiler') - - nvidia-cuda-nvrtc==13.0.88.* ; (sys_platform == 'linux' and extra == 'nvrtc') or (sys_platform == 'win32' and extra == 'nvrtc') - - nvidia-nvtx==13.0.85.* ; (sys_platform == 'linux' and extra == 'nvtx') or (sys_platform == 'win32' and extra == 'nvtx') - - nvidia-nvvm==13.0.88.* ; (sys_platform == 'linux' and extra == 'nvvm') or (sys_platform == 'win32' and extra == 'nvvm') - - nvidia-cuda-opencl==13.0.85.* ; (sys_platform == 'linux' and extra == 'opencl') or (sys_platform == 'win32' and extra == 'opencl') - - nvidia-cuda-profiler-api==13.0.85.* ; (sys_platform == 'linux' and extra == 'profiler') or (sys_platform == 'win32' and extra == 'profiler') - - nvidia-cuda-sanitizer-api==13.0.85.* ; (sys_platform == 'linux' and extra == 'sanitizer') or (sys_platform == 'win32' and extra == 'sanitizer') - conda: https://conda.anaconda.org/conda-forge/noarch/cuda-version-12.9-h4f385c5_3.conda sha256: 5f5f428031933f117ff9f7fcc650e6ea1b3fef5936cf84aa24af79167513b656 md5: b6d5d7f1c171cbd228ea06b556cfa859 @@ -2539,19 +2309,6 @@ packages: version: 11.0.1 sha256: eaf287826069452a8f61026c597eae2428b2d1ba2859083abbf240b46842ce6d requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl - name: mpmath - version: 1.3.0 - sha256: a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c - requires_dist: - - pytest>=4.6 ; extra == 'develop' - - pycodestyle ; extra == 'develop' - - pytest-cov ; extra == 'develop' - - codecov ; extra == 'develop' - - wheel ; extra == 'develop' - - sphinx ; extra == 'docs' - - gmpy2>=2.1.0a4 ; platform_python_implementation != 'PyPy' and extra == 'gmpy' - - pytest>=4.6 ; extra == 'tests' - pypi: https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl name: multidict version: 6.7.1 @@ -2613,49 +2370,6 @@ packages: purls: [] size: 891641 timestamp: 1738195959188 -- pypi: https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl - name: networkx - version: 3.6.1 - sha256: d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762 - requires_dist: - - asv ; extra == 'benchmarking' - - virtualenv ; extra == 'benchmarking' - - numpy>=1.25 ; extra == 'default' - - scipy>=1.11.2 ; extra == 'default' - - matplotlib>=3.8 ; extra == 'default' - - pandas>=2.0 ; extra == 'default' - - pre-commit>=4.1 ; extra == 'developer' - - mypy>=1.15 ; extra == 'developer' - - sphinx>=8.0 ; extra == 'doc' - - pydata-sphinx-theme>=0.16 ; extra == 'doc' - - sphinx-gallery>=0.18 ; extra == 'doc' - - numpydoc>=1.8.0 ; extra == 'doc' - - pillow>=10 ; extra == 'doc' - - texext>=0.6.7 ; extra == 'doc' - - myst-nb>=1.1 ; extra == 'doc' - - intersphinx-registry ; extra == 'doc' - - osmnx>=2.0.0 ; extra == 'example' - - momepy>=0.7.2 ; extra == 'example' - - contextily>=1.6 ; extra == 'example' - - seaborn>=0.13 ; extra == 'example' - - cairocffi>=1.7 ; extra == 'example' - - igraph>=0.11 ; extra == 'example' - - scikit-learn>=1.5 ; extra == 'example' - - iplotx>=0.9.0 ; extra == 'example' - - lxml>=4.6 ; extra == 'extra' - - pygraphviz>=1.14 ; extra == 'extra' - - pydot>=3.0.1 ; extra == 'extra' - - sympy>=1.10 ; extra == 'extra' - - build>=0.10 ; extra == 'release' - - twine>=4.0 ; extra == 'release' - - wheel>=0.40 ; extra == 'release' - - changelist==0.5 ; extra == 'release' - - pytest>=7.2 ; extra == 'test' - - pytest-cov>=4.0 ; extra == 'test' - - pytest-xdist>=3.0 ; extra == 'test' - - pytest-mpl ; extra == 'test-extras' - - pytest-randomly ; extra == 'test-extras' - requires_python: '>=3.11,!=3.14.1' - pypi: https://files.pythonhosted.org/packages/d6/b7/ec1cbc6b297a808c513f59f501656389623fc09ad6a58c640851289c7854/nh3-0.3.4-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl name: nh3 version: 0.3.4 @@ -2697,90 +2411,6 @@ packages: version: 2.4.4 sha256: 27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d requires_python: '>=3.11' -- pypi: https://files.pythonhosted.org/packages/e7/44/423ac00af4dd95a5aeb27207e2c0d9b7118702149bf4704c3ddb55bb7429/nvidia_cublas-13.1.0.3-py3-none-manylinux_2_27_x86_64.whl - name: nvidia-cublas - version: 13.1.0.3 - sha256: ee8722c1f0145ab246bccb9e452153b5e0515fd094c3678df50b2a0888b8b171 - requires_python: '>=3' -- pypi: https://files.pythonhosted.org/packages/33/6d/737d164b4837a9bbd202f5ae3078975f0525a55730fe871d8ed4e3b952b0/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_x86_64.whl - name: nvidia-cuda-cupti - version: 13.0.85 - sha256: 4eb01c08e859bf924d222250d2e8f8b8ff6d3db4721288cf35d14252a4d933c8 - requires_python: '>=3' -- pypi: https://files.pythonhosted.org/packages/c3/68/483a78f5e8f31b08fb1bb671559968c0ca3a065ac7acabfc7cee55214fd6/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl - name: nvidia-cuda-nvrtc - version: 13.0.88 - sha256: ad9b6d2ead2435f11cbb6868809d2adeeee302e9bb94bcf0539c7a40d80e8575 - requires_python: '>=3' -- pypi: https://files.pythonhosted.org/packages/2e/24/d1558f3b68b1d26e706813b1d10aa1d785e4698c425af8db8edc3dced472/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - name: nvidia-cuda-runtime - version: 13.0.96 - sha256: 7f82250d7782aa23b6cfe765ecc7db554bd3c2870c43f3d1821f1d18aebf0548 - requires_python: '>=3' -- pypi: https://files.pythonhosted.org/packages/a3/22/0b4b932655d17a6da1b92fa92ab12844b053bb2ac2475e179ba6f043da1e/nvidia_cudnn_cu13-9.19.0.56-py3-none-manylinux_2_27_x86_64.whl - name: nvidia-cudnn-cu13 - version: 9.19.0.56 - sha256: d20e1734305e9d68889a96e3f35094d733ff1f83932ebe462753973e53a572bf - requires_dist: - - nvidia-cublas - requires_python: '>=3' -- pypi: https://files.pythonhosted.org/packages/a8/2f/7b57e29836ea8714f81e9898409196f47d772d5ddedddf1592eadb8ab743/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - name: nvidia-cufft - version: 12.0.0.61 - sha256: 6c44f692dce8fd5ffd3e3df134b6cdb9c2f72d99cf40b62c32dde45eea9ddad3 - requires_dist: - - nvidia-nvjitlink - requires_python: '>=3' -- pypi: https://files.pythonhosted.org/packages/3f/70/4f193de89a48b71714e74602ee14d04e4019ad36a5a9f20c425776e72cd6/nvidia_cufile-1.15.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - name: nvidia-cufile - version: 1.15.1.6 - sha256: 08a3ecefae5a01c7f5117351c64f17c7c62efa5fffdbe24fc7d298da19cd0b44 - requires_python: '>=3' -- pypi: https://files.pythonhosted.org/packages/a5/9f/be0a41ca4a4917abf5cb9ae0daff1a6060cc5de950aec0396de9f3b52bc5/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_x86_64.whl - name: nvidia-curand - version: 10.4.0.35 - sha256: 1aee33a5da6e1db083fe2b90082def8915f30f3248d5896bcec36a579d941bfc - requires_python: '>=3' -- pypi: https://files.pythonhosted.org/packages/5f/67/cba3777620cdacb99102da4042883709c41c709f4b6323c10781a9c3aa34/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_x86_64.whl - name: nvidia-cusolver - version: 12.0.4.66 - sha256: 0a759da5dea5c0ea10fd307de75cdeb59e7ea4fcb8add0924859b944babf1112 - requires_dist: - - nvidia-cublas - - nvidia-nvjitlink - - nvidia-cusparse - requires_python: '>=3' -- pypi: https://files.pythonhosted.org/packages/fa/18/623c77619c31d62efd55302939756966f3ecc8d724a14dab2b75f1508850/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - name: nvidia-cusparse - version: 12.6.3.3 - sha256: 2b3c89c88d01ee0e477cb7f82ef60a11a4bcd57b6b87c33f789350b59759360b - requires_dist: - - nvidia-nvjitlink - requires_python: '>=3' -- pypi: https://files.pythonhosted.org/packages/fd/53/43b0d71f4e702fa9733f8b4571fdca50a8813f1e450b656c239beff12315/nvidia_cusparselt_cu13-0.8.0-py3-none-manylinux2014_x86_64.whl - name: nvidia-cusparselt-cu13 - version: 0.8.0 - sha256: 25e30a8a7323935d4ad0340b95a0b69926eee755767e8e0b1cf8dd85b197d3fd -- pypi: https://files.pythonhosted.org/packages/b0/b4/878fefaad5b2bcc6fcf8d474a25e3e3774bc5133e4b58adff4d0bca238bc/nvidia_nccl_cu13-2.28.9-py3-none-manylinux_2_18_x86_64.whl - name: nvidia-nccl-cu13 - version: 2.28.9 - sha256: e4553a30f34195f3fa1da02a6da3d6337d28f2003943aa0a3d247bbc25fefc42 - requires_python: '>=3' -- pypi: https://files.pythonhosted.org/packages/56/7a/123e033aaff487c77107195fa5a2b8686795ca537935a24efae476c41f05/nvidia_nvjitlink-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl - name: nvidia-nvjitlink - version: 13.0.88 - sha256: 13a74f429e23b921c1109976abefacc69835f2f433ebd323d3946e11d804e47b - requires_python: '>=3' -- pypi: https://files.pythonhosted.org/packages/3c/35/a9bf80a609e74e3b000fef598933235c908fcefcef9026042b8e6dfde2a9/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - name: nvidia-nvshmem-cu13 - version: 3.4.5 - sha256: 290f0a2ee94c9f3687a02502f3b9299a9f9fe826e6d0287ee18482e78d495b80 - requires_python: '>=3' -- pypi: https://files.pythonhosted.org/packages/c2/f3/d86c845465a2723ad7e1e5c36dcd75ddb82898b3f53be47ebd429fb2fa5d/nvidia_nvtx-13.0.85-py3-none-manylinux1_x86_64.manylinux_2_5_x86_64.whl - name: nvidia-nvtx - version: 13.0.85 - sha256: 4936d1d6780fbe68db454f5e72a42ff64d1fd6397df9f363ae786930fd5c1cd4 - requires_python: '>=3' - conda: https://conda.anaconda.org/conda-forge/linux-64/ocl-icd-2.3.3-hb9d3cd8_0.conda sha256: 2254dae821b286fb57c61895f2b40e3571a070910fdab79a948ff703e1ea807b md5: 56f8947aa9d5cf37b0b3d43b83f34192 @@ -3066,50 +2696,6 @@ packages: version: 6.33.6 sha256: e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593 requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl - name: psutil - version: 7.2.2 - sha256: 076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9 - requires_dist: - - psleak ; extra == 'dev' - - pytest ; extra == 'dev' - - pytest-instafail ; extra == 'dev' - - pytest-xdist ; extra == 'dev' - - setuptools ; extra == 'dev' - - abi3audit ; extra == 'dev' - - black ; extra == 'dev' - - check-manifest ; extra == 'dev' - - coverage ; extra == 'dev' - - packaging ; extra == 'dev' - - pylint ; extra == 'dev' - - pyperf ; extra == 'dev' - - pypinfo ; extra == 'dev' - - pytest-cov ; extra == 'dev' - - requests ; extra == 'dev' - - rstcheck ; extra == 'dev' - - ruff ; extra == 'dev' - - sphinx ; extra == 'dev' - - sphinx-rtd-theme ; extra == 'dev' - - toml-sort ; extra == 'dev' - - twine ; extra == 'dev' - - validate-pyproject[all] ; extra == 'dev' - - virtualenv ; extra == 'dev' - - vulture ; extra == 'dev' - - wheel ; extra == 'dev' - - colorama ; os_name == 'nt' and extra == 'dev' - - pyreadline3 ; os_name == 'nt' and extra == 'dev' - - pywin32 ; implementation_name != 'pypy' and os_name == 'nt' and extra == 'dev' - - wheel ; implementation_name != 'pypy' and os_name == 'nt' and extra == 'dev' - - wmi ; implementation_name != 'pypy' and os_name == 'nt' and extra == 'dev' - - psleak ; extra == 'test' - - pytest ; extra == 'test' - - pytest-instafail ; extra == 'test' - - pytest-xdist ; extra == 'test' - - setuptools ; extra == 'test' - - pywin32 ; implementation_name != 'pypy' and os_name == 'nt' and extra == 'test' - - wheel ; implementation_name != 'pypy' and os_name == 'nt' and extra == 'test' - - wmi ; implementation_name != 'pypy' and os_name == 'nt' and extra == 'test' - requires_python: '>=3.6' - pypi: https://files.pythonhosted.org/packages/d7/64/0ea5be39e6a6515804cae8c280226d771f42750a08182f9d2e5f3b822694/PyArabic-0.6.15-py3-none-any.whl name: pyarabic version: 0.6.15 @@ -3527,63 +3113,6 @@ packages: - cryptography>=2.0 - jeepney>=0.6 requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl - name: setuptools - version: 81.0.0 - sha256: fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6 - requires_dist: - - pytest>=6,!=8.1.* ; extra == 'test' - - virtualenv>=13.0.0 ; extra == 'test' - - wheel>=0.44.0 ; extra == 'test' - - pip>=19.1 ; extra == 'test' - - packaging>=24.2 ; extra == 'test' - - jaraco-envs>=2.2 ; extra == 'test' - - pytest-xdist>=3 ; extra == 'test' - - jaraco-path>=3.7.2 ; extra == 'test' - - build[virtualenv]>=1.0.3 ; extra == 'test' - - filelock>=3.4.0 ; extra == 'test' - - ini2toml[lite]>=0.14 ; extra == 'test' - - tomli-w>=1.0.0 ; extra == 'test' - - pytest-timeout ; extra == 'test' - - pytest-perf ; sys_platform != 'cygwin' and extra == 'test' - - jaraco-develop>=7.21 ; python_full_version >= '3.9' and sys_platform != 'cygwin' and extra == 'test' - - pytest-home>=0.5 ; extra == 'test' - - pytest-subprocess ; extra == 'test' - - pyproject-hooks!=1.1 ; extra == 'test' - - jaraco-test>=5.5 ; extra == 'test' - - sphinx>=3.5 ; extra == 'doc' - - jaraco-packaging>=9.3 ; extra == 'doc' - - rst-linker>=1.9 ; extra == 'doc' - - furo ; extra == 'doc' - - sphinx-lint ; extra == 'doc' - - jaraco-tidelift>=1.4 ; extra == 'doc' - - pygments-github-lexers==0.0.5 ; extra == 'doc' - - sphinx-favicon ; extra == 'doc' - - sphinx-inline-tabs ; extra == 'doc' - - sphinx-reredirects ; extra == 'doc' - - sphinxcontrib-towncrier ; extra == 'doc' - - sphinx-notfound-page>=1,<2 ; extra == 'doc' - - pyproject-hooks!=1.1 ; extra == 'doc' - - towncrier<24.7 ; extra == 'doc' - - packaging>=24.2 ; extra == 'core' - - more-itertools>=8.8 ; extra == 'core' - - jaraco-text>=3.7 ; extra == 'core' - - importlib-metadata>=6 ; python_full_version < '3.10' and extra == 'core' - - tomli>=2.0.1 ; python_full_version < '3.11' and extra == 'core' - - wheel>=0.43.0 ; extra == 'core' - - platformdirs>=4.2.2 ; extra == 'core' - - jaraco-functools>=4 ; extra == 'core' - - more-itertools ; extra == 'core' - - pytest-checkdocs>=2.4 ; extra == 'check' - - pytest-ruff>=0.2.1 ; sys_platform != 'cygwin' and extra == 'check' - - ruff>=0.13.0 ; sys_platform != 'cygwin' and extra == 'check' - - pytest-cov ; extra == 'cover' - - pytest-enabler>=2.2 ; extra == 'enabler' - - pytest-mypy ; extra == 'type' - - mypy==1.18.* ; extra == 'type' - - importlib-metadata>=7.0.2 ; python_full_version < '3.10' and extra == 'type' - - jaraco-develop>=7.21 ; sys_platform != 'cygwin' and extra == 'type' - requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl name: shellingham version: 1.5.4 @@ -3782,15 +3311,6 @@ packages: - python-multipart>=0.0.18 ; extra == 'full' - pyyaml ; extra == 'full' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl - name: sympy - version: 1.14.0 - sha256: e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5 - requires_dist: - - mpmath>=1.1.0,<1.4 - - pytest>=7.1.0 ; extra == 'dev' - - hypothesis>=6.70.0 ; extra == 'dev' - requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/f5/de/9341a6d7a8f1b448573bbf3425fa57669ac58258a667eb48a25dfe916d70/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl name: tiktoken version: 0.12.0 @@ -3832,40 +3352,6 @@ packages: - setuptools-rust ; extra == 'docs' - tokenizers[testing] ; extra == 'dev' requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/47/e8/b98ca2d39b2e0e4730c0ee52537e488e7008025bc77ca89552ff91021f7c/torch-2.11.0-cp314-cp314-manylinux_2_28_x86_64.whl - name: torch - version: 2.11.0 - sha256: 4dc8b3809469b6c30b411bb8c4cad3828efd26236153d9beb6a3ec500f211a60 - requires_dist: - - filelock - - typing-extensions>=4.10.0 - - setuptools<82 - - sympy>=1.13.3 - - networkx>=2.5.1 - - jinja2 - - fsspec>=0.8.5 - - cuda-toolkit[cublas,cudart,cufft,cufile,cupti,curand,cusolver,cusparse,nvjitlink,nvrtc,nvtx]==13.0.2 ; sys_platform == 'linux' - - cuda-bindings>=13.0.3,<14 ; sys_platform == 'linux' - - nvidia-cudnn-cu13==9.19.0.56 ; sys_platform == 'linux' - - nvidia-cusparselt-cu13==0.8.0 ; sys_platform == 'linux' - - nvidia-nccl-cu13==2.28.9 ; sys_platform == 'linux' - - nvidia-nvshmem-cu13==3.4.5 ; sys_platform == 'linux' - - triton==3.6.0 ; sys_platform == 'linux' - - optree>=0.13.0 ; extra == 'optree' - - opt-einsum>=3.3 ; extra == 'opt-einsum' - - pyyaml ; extra == 'pyyaml' - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/a4/21/a2266f7f1b0e58e624ff15fd6f01041f59182c49551ece0db9a183071329/torchvision-0.26.0-cp314-cp314-manylinux_2_28_x86_64.whl - name: torchvision - version: 0.26.0 - sha256: 0f3e572efe62ad645017ea847e0b5e4f2f638d4e39f05bc011d1eb9ac68d4806 - requires_dist: - - numpy - - torch==2.11.0 - - pillow>=5.3.0,!=8.3.* - - gdown>=4.7.3 ; extra == 'gdown' - - scipy ; extra == 'scipy' - requires_python: '>=3.10,!=3.14.1' - pypi: https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl name: tqdm version: 4.67.3 @@ -4346,26 +3832,6 @@ packages: - opentelemetry-exporter-otlp ; extra == 'open-telemetry' - opentelemetry-sdk ; extra == 'open-telemetry' requires_python: '>=3.9.0' -- pypi: https://files.pythonhosted.org/packages/df/3d/9e7eee57b37c80cec63322c0231bb6da3cfe535a91d7a4d64896fcb89357/triton-3.6.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - name: triton - version: 3.6.0 - sha256: a17a5d5985f0ac494ed8a8e54568f092f7057ef60e1b0fa09d3fd1512064e803 - requires_dist: - - importlib-metadata ; python_full_version < '3.10' - - cmake>=3.20,<4.0 ; extra == 'build' - - lit ; extra == 'build' - - autopep8 ; extra == 'tests' - - isort ; extra == 'tests' - - numpy ; extra == 'tests' - - pytest ; extra == 'tests' - - pytest-forked ; extra == 'tests' - - pytest-xdist ; extra == 'tests' - - scipy>=1.7.1 ; extra == 'tests' - - llnl-hatchet ; extra == 'tests' - - matplotlib ; extra == 'tutorials' - - pandas ; extra == 'tutorials' - - tabulate ; extra == 'tutorials' - requires_python: '>=3.10,<3.15' - pypi: https://files.pythonhosted.org/packages/3a/7a/882d99539b19b1490cac5d77c67338d126e4122c8276bf640e411650c830/twine-6.2.0-py3-none-any.whl name: twine version: 6.2.0 diff --git a/pyproject.toml b/pyproject.toml index 013c748..568ef00 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "churro-ocr" version = "0.2.0" -description = "Library-first OCR and layout detection for historical documents" +description = "OCR for historical documents" readme = { file = "docs/pypi.md", content-type = "text/markdown" } requires-python = ">=3.12" license = "Apache-2.0" @@ -33,12 +33,9 @@ azure = [ ] hf = [ "qwen-vl-utils", - "transformers[torch]>=4.57.0,<5", - "torchvision", + "transformers>=4.57.0,<5", ] vllm = [ - "transformers>=4.57.0,<5", - "torchvision", "vllm>=0.18,<1", ] mistral = [ @@ -55,9 +52,7 @@ all = [ "litellm[caching]==1.82.3", "azure-ai-documentintelligence==1.0.2", "qwen-vl-utils", - "transformers[torch]>=4.57.0,<5", - "torchvision", - "vllm>=0.18,<1", + "transformers>=4.57.0,<5", "mistralai>=1.6.0,<2", "pypdfium2>=5,<6", ] @@ -89,7 +84,7 @@ dev-test = [ dev-tooling = [ "build", "pre-commit>=4,<5", - "ruff", + "ruff==0.15.9", "twine", "ty>=0.0.28,<0.0.29", ] diff --git a/ruff.toml b/ruff.toml index 3fb075f..869de7c 100644 --- a/ruff.toml +++ b/ruff.toml @@ -1,5 +1,5 @@ line-length = 110 -required-version = "==0.15.7" +required-version = "==0.15.9" [lint] select = ["E", "F", "I", "B", "UP", "ASYNC", "SIM", "D"] diff --git a/scripts/package_check.py b/scripts/package_check.py index 8e23c23..865ae77 100644 --- a/scripts/package_check.py +++ b/scripts/package_check.py @@ -125,11 +125,7 @@ def _assert_metadata(metadata_message: Message, entry_points_text: str) -> None: if provides_extra != EXPECTED_EXTRAS: raise RuntimeError(f"Unexpected extras set: {sorted(provides_extra)!r}.") - requires_dist = metadata_message.get_all("Requires-Dist", []) - if not any( - requirement.startswith("vllm") and 'extra == "all"' in requirement for requirement in requires_dist - ): - raise RuntimeError("The all extra does not include vllm.") + _assert_local_runtime_packaging_policy(metadata_message) if ( "[console_scripts]" not in entry_points_text @@ -138,6 +134,37 @@ def _assert_metadata(metadata_message: Message, entry_points_text: str) -> None: raise RuntimeError("Console script entry point is missing or incorrect.") +def _iter_requirements_for_extra(metadata_message: Message, extra: str) -> list[Requirement]: + requirements: list[Requirement] = [] + for requirement_text in metadata_message.get_all("Requires-Dist", []): + parsed = Requirement(requirement_text) + marker_text = str(parsed.marker) if parsed.marker is not None else "" + if f'extra == "{extra}"' in marker_text: + requirements.append(parsed) + return requirements + + +def _assert_local_runtime_packaging_policy(metadata_message: Message) -> None: + disallowed_runtime_reqs: list[str] = [] + for extra in ("hf", "all"): + for requirement in _iter_requirements_for_extra(metadata_message, extra): + normalized_name = requirement.name.replace("_", "-").lower() + if extra == "all" and normalized_name == "vllm": + disallowed_runtime_reqs.append(f"{extra}:{requirement}") + continue + if normalized_name in {"torch", "torchvision"}: + disallowed_runtime_reqs.append(f"{extra}:{requirement}") + continue + if normalized_name == "transformers" and "torch" in requirement.extras: + disallowed_runtime_reqs.append(f"{extra}:{requirement}") + if disallowed_runtime_reqs: + formatted = ", ".join(sorted(disallowed_runtime_reqs)) + raise RuntimeError( + "PyPI extras for active-environment runtimes must not pin a local PyTorch or vLLM runtime. " + f"Found disallowed requirements: {formatted}." + ) + + def _assert_runtime_only_artifacts(wheel: Path, sdist: Path) -> None: with zipfile.ZipFile(wheel) as zip_file: wheel_names = zip_file.namelist() diff --git a/tests/test_package_check.py b/tests/test_package_check.py index 4ee8953..e208efe 100644 --- a/tests/test_package_check.py +++ b/tests/test_package_check.py @@ -43,3 +43,20 @@ def _always_missing(_: str): with pytest.raises(RuntimeError, match="pillow \\(not installed in the Pixi audit environment\\)"): package_check._audit_dependency_licenses(_metadata_message("Pillow<12,>=10.4.0")) + + +def test_local_runtime_packaging_policy_rejects_direct_torch_runtime_pin() -> None: + metadata_message = _metadata_message( + 'transformers[torch]<5,>=4.57.0; extra == "hf"', + 'torchvision; extra == "all"', + ) + + with pytest.raises(RuntimeError, match="must not pin a local PyTorch or vLLM runtime"): + package_check._assert_local_runtime_packaging_policy(metadata_message) + + +def test_local_runtime_packaging_policy_rejects_vllm_in_all_extra() -> None: + metadata_message = _metadata_message('vllm<1,>=0.18; extra == "all"') + + with pytest.raises(RuntimeError, match="must not pin a local PyTorch or vLLM runtime"): + package_check._assert_local_runtime_packaging_policy(metadata_message) From cb4a52035d73bdd224efff9fee79271e41721ed6 Mon Sep 17 00:00:00 2001 From: Sina Date: Sun, 5 Apr 2026 22:47:53 +0000 Subject: [PATCH 03/28] feat(runtime): add uv-managed runtime installers --- src/churro_ocr/_internal/install.py | 284 +++++++++++++++++++++ src/churro_ocr/_internal/litellm.py | 4 +- src/churro_ocr/_internal/pdf.py | 3 +- src/churro_ocr/cli.py | 71 ++++++ src/churro_ocr/providers/hf.py | 26 +- src/churro_ocr/providers/ocr.py | 6 +- src/churro_ocr/providers/page_detection.py | 4 +- tests/test_cli.py | 105 ++++++++ tests/test_hf_ocr.py | 1 + tests/test_install.py | 214 ++++++++++++++++ tests/test_internal_helpers.py | 38 ++- 11 files changed, 735 insertions(+), 21 deletions(-) create mode 100644 src/churro_ocr/_internal/install.py create mode 100644 tests/test_install.py diff --git a/src/churro_ocr/_internal/install.py b/src/churro_ocr/_internal/install.py new file mode 100644 index 0000000..dff132f --- /dev/null +++ b/src/churro_ocr/_internal/install.py @@ -0,0 +1,284 @@ +"""UV-backed runtime installation helpers.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from dataclasses import dataclass +from importlib import metadata +from pathlib import Path +from typing import Final + +from churro_ocr.errors import ConfigurationError + +PROJECT_DISTRIBUTION_NAME: Final[str] = "churro-ocr" +VLLM_RUNTIME_DIR_ENV: Final[str] = "CHURRO_OCR_VLLM_RUNTIME_DIR" +DEFAULT_VLLM_RUNTIME_DIR: Final[Path] = Path.home() / ".cache" / "churro-ocr" / "runtimes" / "vllm" +INSTALL_TARGETS: Final[tuple[str, ...]] = ( + "llm", + "local", + "hf", + "vllm", + "azure", + "mistral", + "pdf", + "all", +) +_CURRENT_ENV_TARGET_EXTRAS: Final[dict[str, tuple[str, ...]]] = { + "llm": ("llm",), + "local": ("local",), + "hf": ("hf",), + "vllm": ("local",), + "azure": ("azure",), + "mistral": ("mistral",), + "pdf": ("pdf",), + "all": ("llm", "local", "hf", "azure", "mistral", "pdf"), +} +_PYTORCH_TARGETS: Final[frozenset[str]] = frozenset({"hf", "all"}) +_PYTORCH_PACKAGES: Final[tuple[str, ...]] = ("torch", "torchvision") + + +@dataclass(frozen=True, slots=True) +class RuntimeInstallResult: + """Summary of a completed runtime installation.""" + + target: str + executed_commands: tuple[tuple[str, ...], ...] + notes: tuple[str, ...] = () + vllm_runtime_dir: Path | None = None + + +def install_command_hint(target: str) -> str: + """Return a short user-facing install hint for a runtime target.""" + return f"Run `churro-ocr install {target}`." + + +def recommended_vllm_runtime_hint() -> str: + """Return the short user-facing hint for the dedicated vLLM runtime.""" + return "Run `churro-ocr install vllm`." + + +def resolve_vllm_runtime_dir(runtime_dir: Path | None = None) -> Path: + """Resolve the dedicated vLLM runtime directory.""" + if runtime_dir is not None: + return runtime_dir.expanduser().resolve() + override = os.environ.get(VLLM_RUNTIME_DIR_ENV) + if override: + return Path(override).expanduser().resolve() + return DEFAULT_VLLM_RUNTIME_DIR + + +def install_runtime_dependencies( + *, + target: str, + torch_backend: str = "auto", + vllm_runtime_dir: Path | None = None, +) -> RuntimeInstallResult: + """Install one of the supported optional runtime targets with UV.""" + normalized_target = target.strip().lower() + if normalized_target not in INSTALL_TARGETS: + supported = ", ".join(INSTALL_TARGETS) + raise ConfigurationError(f"Unknown install target '{target}'. Choose one of: {supported}.") + + uv_executable = _require_uv_executable() + executed_commands: list[tuple[str, ...]] = [] + notes: list[str] = [] + + current_env_extras = _CURRENT_ENV_TARGET_EXTRAS.get(normalized_target, ()) + requirements = _requirements_for_extra(current_env_extras) + if requirements: + executed_commands.append( + _run_command( + [ + uv_executable, + "pip", + "install", + "--python", + sys.executable, + "--upgrade", + *requirements, + ] + ) + ) + + if normalized_target in _PYTORCH_TARGETS: + executed_commands.append( + _run_command( + [ + uv_executable, + "pip", + "install", + "--python", + sys.executable, + "--upgrade", + f"--torch-backend={torch_backend}", + *_PYTORCH_PACKAGES, + ] + ) + ) + + resolved_runtime_dir: Path | None = None + if normalized_target in {"vllm", "all"}: + resolved_runtime_dir = resolve_vllm_runtime_dir(vllm_runtime_dir) + runtime_python = _ensure_runtime_python(resolved_runtime_dir) + executed_commands.append( + _run_command( + [ + uv_executable, + "pip", + "install", + "--python", + str(runtime_python), + "--upgrade", + f"--torch-backend={torch_backend}", + _requirement_for_extra("vllm", package_name="vllm"), + ] + ) + ) + notes.append( + "The dedicated vLLM runtime is ready. Start it with `churro-ocr serve-vllm --model `." + ) + notes.append( + "Use `--backend openai-compatible --base-url http://127.0.0.1:8000/v1` " + "to connect to the served runtime." + ) + + return RuntimeInstallResult( + target=normalized_target, + executed_commands=tuple(executed_commands), + notes=tuple(notes), + vllm_runtime_dir=resolved_runtime_dir, + ) + + +def serve_vllm_runtime( + *, + model: str, + host: str = "127.0.0.1", + port: int = 8000, + runtime_dir: Path | None = None, + extra_args: tuple[str, ...] = (), +) -> int: + """Run `python -m vllm serve` from the dedicated Churro vLLM runtime.""" + resolved_runtime_dir = resolve_vllm_runtime_dir(runtime_dir) + runtime_python = _venv_python(resolved_runtime_dir) + if not runtime_python.exists(): + raise ConfigurationError( + f"No dedicated vLLM runtime exists at {resolved_runtime_dir}. {recommended_vllm_runtime_hint()}" + ) + + command = [ + str(runtime_python), + "-m", + "vllm", + "serve", + model, + "--host", + host, + "--port", + str(port), + *extra_args, + ] + try: + completed = subprocess.run(command, check=False) + except OSError as exc: # pragma: no cover - depends on host process state + raise ConfigurationError( + f"Failed to launch the dedicated vLLM runtime at {resolved_runtime_dir}: {exc}" + ) from exc + return int(completed.returncode) + + +def _require_uv_executable() -> str: + uv_executable = shutil.which("uv") + if uv_executable is None: + raise ConfigurationError( + "`churro-ocr install` requires `uv` on PATH. Install uv and rerun the command." + ) + return uv_executable + + +def _distribution_requirements() -> list[str]: + try: + distribution = metadata.distribution(PROJECT_DISTRIBUTION_NAME) + except metadata.PackageNotFoundError as exc: # pragma: no cover - depends on install mode + raise ConfigurationError( + "The Churro installer must run from an installed `churro-ocr` environment." + ) from exc + return list(distribution.requires or []) + + +def _requirements_for_extra(extras: tuple[str, ...]) -> list[str]: + requirements = _distribution_requirements() + selected: list[str] = [] + for extra in extras: + for requirement_text in requirements: + requirement, _, marker = requirement_text.partition(";") + if f'extra == "{extra}"' not in marker: + continue + normalized_requirement = requirement.strip() + if normalized_requirement and normalized_requirement not in selected: + selected.append(normalized_requirement) + return selected + + +def _requirement_for_extra(extra: str, *, package_name: str) -> str: + normalized_package_name = package_name.replace("_", "-").lower() + for requirement in _requirements_for_extra((extra,)): + dependency_name = _requirement_name(requirement) + if dependency_name.replace("_", "-").lower() == normalized_package_name: + return requirement + raise ConfigurationError( + f"Could not resolve the `{package_name}` dependency from the `{extra}` extra metadata." + ) + + +def _ensure_runtime_python(runtime_dir: Path) -> Path: + runtime_python = _venv_python(runtime_dir) + if runtime_python.exists(): + return runtime_python + + runtime_dir.parent.mkdir(parents=True, exist_ok=True) + try: + subprocess.run( + [sys.executable, "-m", "venv", str(runtime_dir)], + check=True, + ) + except (OSError, subprocess.CalledProcessError) as exc: + raise ConfigurationError( + f"Failed to create the dedicated vLLM runtime at {runtime_dir}: {exc}" + ) from exc + + if not runtime_python.exists(): + raise ConfigurationError( + f"Created the dedicated vLLM runtime at {runtime_dir}, but no Python interpreter was found." + ) + return runtime_python + + +def _run_command(command: list[str]) -> tuple[str, ...]: + try: + subprocess.run(command, check=True) + except (OSError, subprocess.CalledProcessError) as exc: + rendered_command = " ".join(command) + raise ConfigurationError(f"Command failed: {rendered_command}") from exc + return tuple(command) + + +def _requirement_name(requirement: str) -> str: + name_chars: list[str] = [] + for character in requirement: + if character.isalnum() or character in {"-", "_", "."}: + name_chars.append(character) + continue + if character == "[": + break + break + return "".join(name_chars) + + +def _venv_python(venv_dir: Path) -> Path: + if sys.platform == "win32": + return venv_dir / "Scripts" / "python.exe" + return venv_dir / "bin" / "python" diff --git a/src/churro_ocr/_internal/litellm.py b/src/churro_ocr/_internal/litellm.py index 967d3c3..2daffc5 100644 --- a/src/churro_ocr/_internal/litellm.py +++ b/src/churro_ocr/_internal/litellm.py @@ -12,6 +12,7 @@ from PIL import Image from churro_ocr._internal.image import image_to_base64 +from churro_ocr._internal.install import install_command_hint from churro_ocr.errors import ConfigurationError, ProviderError from churro_ocr.providers.specs import LiteLLMTransportConfig from churro_ocr.templates import OCRConversation @@ -28,8 +29,7 @@ def _ensure_initialized() -> None: import litellm except ImportError as exc: # pragma: no cover - optional extra path raise ConfigurationError( - "LiteLLM-backed providers require the 'llm' extra. " - 'Install with `pip install "churro-ocr[llm]"`.' + f"LiteLLM-backed providers require the `llm` runtime. {install_command_hint('llm')}" ) from exc litellm_any = cast(Any, litellm) diff --git a/src/churro_ocr/_internal/pdf.py b/src/churro_ocr/_internal/pdf.py index 4f89194..770819d 100644 --- a/src/churro_ocr/_internal/pdf.py +++ b/src/churro_ocr/_internal/pdf.py @@ -6,6 +6,7 @@ from PIL import Image +from churro_ocr._internal.install import install_command_hint from churro_ocr.errors import ConfigurationError @@ -15,7 +16,7 @@ def rasterize_pdf(path: str | Path, *, dpi: int = 300) -> list[Image.Image]: import pypdfium2 except ImportError as exc: # pragma: no cover - depends on optional extra raise ConfigurationError( - "PDF support requires the 'pdf' extra. Install with `pip install \"churro-ocr[pdf]\"`." + f"PDF support requires the `pdf` runtime. {install_command_hint('pdf')}" ) from exc resolved = Path(path) diff --git a/src/churro_ocr/cli.py b/src/churro_ocr/cli.py index 8715bcd..5d2ff87 100644 --- a/src/churro_ocr/cli.py +++ b/src/churro_ocr/cli.py @@ -6,6 +6,12 @@ import typer +from churro_ocr._internal.install import ( + INSTALL_TARGETS, + install_runtime_dependencies, + serve_vllm_runtime, +) +from churro_ocr.errors import ConfigurationError from churro_ocr.ocr import OCRClient from churro_ocr.page_detection import DocumentPage, DocumentPageDetector, PageDetectionRequest from churro_ocr.providers import ( @@ -23,6 +29,8 @@ app = typer.Typer(help="churro-ocr library-first CLI") +_INSTALL_TARGET_METAVAR = "{" + "|".join(INSTALL_TARGETS) + "}" + def _build_ocr_backend( *, @@ -230,6 +238,69 @@ def extract_pages_command( typer.echo(str(output_path)) +@app.command("install") +def install_command( + target: str = typer.Argument( + ..., + metavar=_INSTALL_TARGET_METAVAR, + help="Runtime target to install into the active environment with uv.", + ), + torch_backend: str = typer.Option( + "auto", + help="PyTorch backend passed through to uv when a local runtime needs torch.", + ), + vllm_runtime_dir: Path | None = typer.Option( + None, + help="Override the dedicated vLLM runtime directory for `install vllm`.", + ), +) -> None: + """Install optional runtime dependencies with uv.""" + try: + result = install_runtime_dependencies( + target=target, + torch_backend=torch_backend, + vllm_runtime_dir=vllm_runtime_dir, + ) + except ConfigurationError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(code=1) from exc + + typer.echo(f"Installed runtime target: {result.target}") + if result.vllm_runtime_dir is not None: + typer.echo(f"Dedicated vLLM runtime: {result.vllm_runtime_dir}") + for note in result.notes: + typer.echo(note) + + +@app.command( + "serve-vllm", + context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, +) +def serve_vllm_command( + ctx: typer.Context, + model: str = typer.Option(..., help="Model identifier to serve from the dedicated vLLM runtime."), + host: str = typer.Option("127.0.0.1", help="Bind address passed to `vllm serve`."), + port: int = typer.Option(8000, help="Port passed to `vllm serve`."), + vllm_runtime_dir: Path | None = typer.Option( + None, + help="Override the dedicated vLLM runtime directory.", + ), +) -> None: + """Start the dedicated vLLM runtime managed by `churro-ocr install vllm`.""" + try: + exit_code = serve_vllm_runtime( + model=model, + host=host, + port=port, + runtime_dir=vllm_runtime_dir, + extra_args=tuple(ctx.args), + ) + except ConfigurationError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(code=1) from exc + raise typer.Exit(code=exit_code) + + def main() -> None: """Console-script entrypoint.""" app() diff --git a/src/churro_ocr/providers/hf.py b/src/churro_ocr/providers/hf.py index 7832b29..54e62a3 100644 --- a/src/churro_ocr/providers/hf.py +++ b/src/churro_ocr/providers/hf.py @@ -5,9 +5,11 @@ import asyncio import threading from dataclasses import dataclass, field +from importlib import import_module from pathlib import Path from typing import Any +from churro_ocr._internal.install import install_command_hint from churro_ocr._internal.prompt_logging import log_prompt_payload_once from churro_ocr.errors import ConfigurationError from churro_ocr.ocr import OCRBackend, OCRResult @@ -35,6 +37,11 @@ OCRPromptTemplateLike, ) +_HF_EXTRA_INSTALL_HINT = install_command_hint("hf") +_HF_TORCH_INSTALL_HINT = ( + f"Hugging Face OCR requires a separately installed PyTorch runtime. {_HF_EXTRA_INSTALL_HINT}" +) + @dataclass(slots=True) class _HFRuntime: @@ -43,13 +50,21 @@ class _HFRuntime: process_vision_info: Any +def _ensure_hf_torch_runtime() -> None: + try: + import_module("torch") + except ImportError as exc: # pragma: no cover - optional extra path + raise ConfigurationError(_HF_TORCH_INSTALL_HINT) from exc + + def _load_hf_runtime() -> _HFRuntime: + _ensure_hf_torch_runtime() try: from qwen_vl_utils import process_vision_info from transformers import AutoModelForImageTextToText, AutoProcessor except ImportError as exc: # pragma: no cover - optional extra path raise ConfigurationError( - "Hugging Face OCR requires the 'hf' extra. Install with `pip install \"churro-ocr[hf]\"`." + f"Hugging Face OCR requires the `hf` runtime. {_HF_EXTRA_INSTALL_HINT}" ) from exc return _HFRuntime( @@ -60,12 +75,13 @@ def _load_hf_runtime() -> _HFRuntime: def _load_hf_causal_runtime() -> _HFRuntime: + _ensure_hf_torch_runtime() try: from qwen_vl_utils import process_vision_info from transformers import AutoModelForCausalLM, AutoProcessor except ImportError as exc: # pragma: no cover - optional extra path raise ConfigurationError( - "Hugging Face OCR requires the 'hf' extra. Install with `pip install \"churro-ocr[hf]\"`." + f"Hugging Face OCR requires the `hf` runtime. {_HF_EXTRA_INSTALL_HINT}" ) from exc return _HFRuntime( @@ -129,7 +145,7 @@ def _prepare_dots_ocr_model_dir(model_id: str) -> str: from huggingface_hub import snapshot_download except ImportError as exc: # pragma: no cover - transitively provided by transformers raise ConfigurationError( - 'Hugging Face OCR requires huggingface_hub. Install with `pip install "churro-ocr[hf]"`.' + f"Hugging Face OCR requires the `hf` runtime. {_HF_EXTRA_INSTALL_HINT}" ) from exc model_dir = ( @@ -148,8 +164,8 @@ def _prepare_dots_ocr_model_dir(model_id: str) -> str: def _default_dots_ocr_1_5_model_kwargs() -> dict[str, object]: model_kwargs: dict[str, object] = {"dtype": "auto"} try: - import torch - except ImportError: # pragma: no cover - torch comes from the hf extra + torch = import_module("torch") + except ImportError: # pragma: no cover - torch is installed separately for local HF use return model_kwargs if not torch.cuda.is_available(): diff --git a/src/churro_ocr/providers/ocr.py b/src/churro_ocr/providers/ocr.py index 8f58f98..87836ed 100644 --- a/src/churro_ocr/providers/ocr.py +++ b/src/churro_ocr/providers/ocr.py @@ -10,6 +10,7 @@ from typing import Any from churro_ocr._internal.image import image_to_base64 +from churro_ocr._internal.install import install_command_hint from churro_ocr._internal.litellm import LiteLLMTransport from churro_ocr._internal.prompt_logging import log_prompt_payload_once from churro_ocr.errors import ConfigurationError, ProviderError @@ -183,7 +184,7 @@ async def _get_client(self) -> Any: from azure.core.credentials import AzureKeyCredential except ImportError as exc: # pragma: no cover - optional extra path raise ConfigurationError( - "Azure OCR requires the 'azure' extra. Install with `pip install \"churro-ocr[azure]\"`." + f"Azure OCR requires the `azure` runtime. {install_command_hint('azure')}" ) from exc client = DocumentIntelligenceClient( @@ -269,8 +270,7 @@ async def _get_client(self) -> Any: from mistralai import Mistral except ImportError as exc: # pragma: no cover - optional extra path raise ConfigurationError( - "Mistral OCR requires the 'mistral' extra. " - 'Install with `pip install "churro-ocr[mistral]"`.' + f"Mistral OCR requires the `mistral` runtime. {install_command_hint('mistral')}" ) from exc client = Mistral(api_key=self.api_key) diff --git a/src/churro_ocr/providers/page_detection.py b/src/churro_ocr/providers/page_detection.py index b967e4f..cee234d 100644 --- a/src/churro_ocr/providers/page_detection.py +++ b/src/churro_ocr/providers/page_detection.py @@ -10,6 +10,7 @@ from PIL import Image, ImageDraw, ImageOps +from churro_ocr._internal.install import install_command_hint from churro_ocr._internal.litellm import LiteLLMTransport from churro_ocr._internal.logging import logger from churro_ocr._internal.runtime import run_sync @@ -1202,8 +1203,7 @@ async def detect(self, image: Image.Image) -> list[PageCandidate]: from azure.core.credentials import AzureKeyCredential except ImportError as exc: # pragma: no cover - optional extra path raise ConfigurationError( - "Azure page detection requires the 'azure' extra. " - 'Install with `pip install "churro-ocr[azure]"`.' + f"Azure page detection requires the `azure` runtime. {install_command_hint('azure')}" ) from exc buffer = BytesIO() diff --git a/tests/test_cli.py b/tests/test_cli.py index a6e6715..684071a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -4,12 +4,14 @@ import subprocess import sys from pathlib import Path +from types import SimpleNamespace import pytest from PIL import Image import churro_ocr.cli as cli_module from churro_ocr.cli import app +from churro_ocr.errors import ConfigurationError from churro_ocr.ocr import OCRResult from churro_ocr.page_detection import DocumentPage, PageDetectionResult from churro_ocr.prompts import DEFAULT_OCR_OUTPUT_TAG @@ -198,6 +200,105 @@ def test_build_ocr_backend_uses_generic_defaults_for_qwen_3_5_0_8b() -> None: assert vllm_backend.llm_kwargs == {} +def test_install_command_invokes_runtime_installer( + monkeypatch: pytest.MonkeyPatch, + cli_runner, + tmp_path: Path, +) -> None: + captured: dict[str, object] = {} + + def _fake_install_runtime_dependencies(**kwargs: object) -> SimpleNamespace: + captured.update(kwargs) + return SimpleNamespace( + target="vllm", + notes=("runtime ready",), + vllm_runtime_dir=tmp_path / "vllm-runtime", + ) + + monkeypatch.setattr( + "churro_ocr.cli.install_runtime_dependencies", + _fake_install_runtime_dependencies, + ) + + result = cli_runner.invoke( + app, + [ + "install", + "vllm", + "--torch-backend", + "cu126", + ], + ) + + assert result.exit_code == 0 + assert captured == { + "target": "vllm", + "torch_backend": "cu126", + "vllm_runtime_dir": None, + } + assert "Installed runtime target: vllm" in result.output + assert "runtime ready" in result.output + + +def test_install_command_surfaces_configuration_errors( + monkeypatch: pytest.MonkeyPatch, + cli_runner, +) -> None: + def _raise_configuration_error(**_: object) -> SimpleNamespace: + raise ConfigurationError("missing uv") + + monkeypatch.setattr( + "churro_ocr.cli.install_runtime_dependencies", + _raise_configuration_error, + ) + + result = cli_runner.invoke(app, ["install", "hf"]) + + assert result.exit_code == 1 + assert "missing uv" in result.output + + +def test_serve_vllm_command_forwards_extra_args( + monkeypatch: pytest.MonkeyPatch, + cli_runner, + tmp_path: Path, +) -> None: + captured: dict[str, object] = {} + + def _fake_serve_vllm_runtime(**kwargs: object) -> int: + captured.update(kwargs) + return 0 + + monkeypatch.setattr( + "churro_ocr.cli.serve_vllm_runtime", + _fake_serve_vllm_runtime, + ) + + result = cli_runner.invoke( + app, + [ + "serve-vllm", + "--model", + "stanford-oval/churro-3B", + "--host", + "0.0.0.0", + "--port", + "9000", + "--tensor-parallel-size", + "2", + ], + ) + + assert result.exit_code == 0 + assert captured == { + "model": "stanford-oval/churro-3B", + "host": "0.0.0.0", + "port": 9000, + "runtime_dir": None, + "extra_args": ("--tensor-parallel-size", "2"), + } + + def test_module_entrypoint_help() -> None: result = subprocess.run( [sys.executable, "-m", "churro_ocr", "--help"], @@ -209,6 +310,8 @@ def test_module_entrypoint_help() -> None: assert result.returncode == 0 assert "transcribe" in result.stdout assert "extract-pages" in result.stdout + assert "install" in result.stdout + assert "serve-vllm" in result.stdout def test_console_script_help() -> None: @@ -225,3 +328,5 @@ def test_console_script_help() -> None: assert result.returncode == 0 assert "transcribe" in result.stdout assert "extract-pages" in result.stdout + assert "install" in result.stdout + assert "serve-vllm" in result.stdout diff --git a/tests/test_hf_ocr.py b/tests/test_hf_ocr.py index 92b26ae..78ece49 100644 --- a/tests/test_hf_ocr.py +++ b/tests/test_hf_ocr.py @@ -518,6 +518,7 @@ def test_hf_runtime_loaders_use_installed_modules( cast(Any, transformers_module).AutoModelForImageTextToText = image_text_model_cls cast(Any, transformers_module).AutoModelForCausalLM = causal_model_cls + monkeypatch.setitem(sys.modules, "torch", ModuleType("torch")) monkeypatch.setitem(sys.modules, "qwen_vl_utils", qwen_module) monkeypatch.setitem(sys.modules, "transformers", transformers_module) diff --git a/tests/test_install.py b/tests/test_install.py new file mode 100644 index 0000000..a779656 --- /dev/null +++ b/tests/test_install.py @@ -0,0 +1,214 @@ +from __future__ import annotations + +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +import churro_ocr._internal.install as install_module +from churro_ocr.errors import ConfigurationError + + +class _FakeDistribution: + def __init__(self, *, requires: list[str] | None) -> None: + self.requires = requires + + +def test_install_runtime_dependencies_installs_hf_and_torch_with_uv( + monkeypatch: pytest.MonkeyPatch, +) -> None: + commands: list[list[str]] = [] + + monkeypatch.setattr( + install_module.metadata, + "distribution", + lambda _: _FakeDistribution( + requires=[ + 'qwen-vl-utils; extra == "hf"', + 'transformers>=4.57.0,<5; extra == "hf"', + ] + ), + ) + monkeypatch.setattr(install_module.shutil, "which", lambda name: "/usr/bin/uv" if name == "uv" else None) + monkeypatch.setattr( + install_module.subprocess, + "run", + lambda command, check=True: commands.append(list(command)) or SimpleNamespace(returncode=0), + ) + + result = install_module.install_runtime_dependencies( + target="hf", + torch_backend="cu126", + ) + + assert result.target == "hf" + assert result.vllm_runtime_dir is None + assert commands == [ + [ + "/usr/bin/uv", + "pip", + "install", + "--python", + sys.executable, + "--upgrade", + "qwen-vl-utils", + "transformers>=4.57.0,<5", + ], + [ + "/usr/bin/uv", + "pip", + "install", + "--python", + sys.executable, + "--upgrade", + "--torch-backend=cu126", + "torch", + "torchvision", + ], + ] + + +def test_install_runtime_dependencies_installs_local_client_and_dedicated_vllm_runtime( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + commands: list[list[str]] = [] + runtime_dir = tmp_path / "vllm-runtime" + runtime_python = runtime_dir / "bin" / "python" + + monkeypatch.setattr( + install_module.metadata, + "distribution", + lambda _: _FakeDistribution( + requires=[ + 'litellm[caching]==1.82.3; extra == "local"', + 'vllm>=0.18,<1; extra == "vllm"', + ] + ), + ) + monkeypatch.setattr(install_module.shutil, "which", lambda name: "/usr/bin/uv" if name == "uv" else None) + + def _fake_ensure_runtime_python(path: Path) -> Path: + assert path == runtime_dir.resolve() + return runtime_python + + monkeypatch.setattr( + install_module, + "_ensure_runtime_python", + _fake_ensure_runtime_python, + ) + monkeypatch.setattr( + install_module.subprocess, + "run", + lambda command, check=True: commands.append(list(command)) or SimpleNamespace(returncode=0), + ) + + result = install_module.install_runtime_dependencies( + target="vllm", + vllm_runtime_dir=runtime_dir, + ) + + assert result.target == "vllm" + assert result.vllm_runtime_dir == runtime_dir.resolve() + assert commands == [ + [ + "/usr/bin/uv", + "pip", + "install", + "--python", + sys.executable, + "--upgrade", + "litellm[caching]==1.82.3", + ], + [ + "/usr/bin/uv", + "pip", + "install", + "--python", + str(runtime_python), + "--upgrade", + "--torch-backend=auto", + "vllm>=0.18,<1", + ], + ] + assert result.notes + + +def test_install_runtime_dependencies_requires_uv(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(install_module.shutil, "which", lambda _: None) + + with pytest.raises(ConfigurationError, match="requires `uv` on PATH"): + install_module.install_runtime_dependencies(target="hf") + + +def test_resolve_vllm_runtime_dir_prefers_explicit_path( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv(install_module.VLLM_RUNTIME_DIR_ENV, str(tmp_path / "from-env")) + + resolved = install_module.resolve_vllm_runtime_dir(tmp_path / "from-arg") + + assert resolved == (tmp_path / "from-arg").resolve() + + +def test_resolve_vllm_runtime_dir_uses_environment_override( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv(install_module.VLLM_RUNTIME_DIR_ENV, str(tmp_path / "from-env")) + + resolved = install_module.resolve_vllm_runtime_dir() + + assert resolved == (tmp_path / "from-env").resolve() + + +def test_serve_vllm_runtime_runs_module_from_dedicated_env( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + runtime_dir = tmp_path / "runtime" + runtime_python = runtime_dir / "bin" / "python" + runtime_python.parent.mkdir(parents=True, exist_ok=True) + runtime_python.write_text("") + + commands: list[list[str]] = [] + monkeypatch.setattr( + install_module.subprocess, + "run", + lambda command, check=False: commands.append(list(command)) or SimpleNamespace(returncode=0), + ) + + exit_code = install_module.serve_vllm_runtime( + model="stanford-oval/churro-3B", + host="0.0.0.0", + port=9000, + runtime_dir=runtime_dir, + extra_args=("--tensor-parallel-size", "2"), + ) + + assert exit_code == 0 + assert commands == [ + [ + str(runtime_python), + "-m", + "vllm", + "serve", + "stanford-oval/churro-3B", + "--host", + "0.0.0.0", + "--port", + "9000", + "--tensor-parallel-size", + "2", + ] + ] + + +def test_serve_vllm_runtime_requires_installed_runtime(tmp_path: Path) -> None: + with pytest.raises(ConfigurationError, match="install vllm"): + install_module.serve_vllm_runtime( + model="stanford-oval/churro-3B", + runtime_dir=tmp_path / "missing-runtime", + ) diff --git a/tests/test_internal_helpers.py b/tests/test_internal_helpers.py index 7f33cfe..72a78de 100644 --- a/tests/test_internal_helpers.py +++ b/tests/test_internal_helpers.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re import sys from base64 import b64encode from threading import Lock @@ -351,20 +352,41 @@ def debug(self, message: str, *args: object) -> None: @pytest.mark.parametrize( - ("loader", "dependency_name"), + ("loader", "dependency_name", "message"), [ - (_load_vllm_processor_cls, "transformers"), - (_load_vllm_runtime, "vllm"), - (_load_hf_runtime, "qwen_vl_utils"), - (_load_hf_causal_runtime, "qwen_vl_utils"), + (_load_vllm_processor_cls, "transformers", None), + (_load_vllm_runtime, "vllm", None), + (_load_hf_runtime, "torch", "PyTorch runtime"), + (_load_hf_runtime, "qwen_vl_utils", "install hf"), + (_load_hf_causal_runtime, "torch", "PyTorch runtime"), + (_load_hf_causal_runtime, "qwen_vl_utils", "install hf"), ], ) def test_optional_dependency_loaders_raise_configuration_error( loader: Any, dependency_name: str, + message: str | None, patch_import_failure, + monkeypatch: pytest.MonkeyPatch, ) -> None: - patch_import_failure(failing_name=dependency_name) - - with pytest.raises(ConfigurationError): + if dependency_name == "torch": + + def _fake_import_module(name: str) -> object: + if name == "torch": + raise ImportError("missing torch") + return __import__(name) + + monkeypatch.setattr("churro_ocr.providers.hf.import_module", _fake_import_module) + elif dependency_name == "qwen_vl_utils": + monkeypatch.setitem(sys.modules, "torch", ModuleType("torch")) + patch_import_failure(failing_name=dependency_name) + else: + patch_import_failure(failing_name=dependency_name) + + if message is None: + with pytest.raises(ConfigurationError): + loader() + return + + with pytest.raises(ConfigurationError, match=re.escape(message)): loader() From b3d267bae9a4e0479e139f8710f987ad7d5926a4 Mon Sep 17 00:00:00 2001 From: Sina Date: Sun, 5 Apr 2026 22:48:59 +0000 Subject: [PATCH 04/28] refactor(providers)!: remove in-process vllm backend BREAKING CHANGE: The `vllm` OCR provider and `VLLMOptions` are removed. Use a served vLLM runtime through `provider="openai-compatible"` instead. --- src/churro_ocr/cli.py | 17 +- src/churro_ocr/providers/__init__.py | 3 - src/churro_ocr/providers/builder.py | 57 +----- src/churro_ocr/providers/specs.py | 32 +-- src/churro_ocr/providers/vllm.py | 209 ------------------- tests/test_cli.py | 75 +++++-- tests/test_cli_contract.py | 59 +++++- tests/test_internal_helpers.py | 10 +- tests/test_provider_api_contracts.py | 15 +- tests/test_providers.py | 290 ++++++++++++--------------- tests/test_tooling_benchmark.py | 158 ++++++--------- tooling/benchmarking/benchmark.py | 41 +--- 12 files changed, 320 insertions(+), 646 deletions(-) delete mode 100644 src/churro_ocr/providers/vllm.py diff --git a/src/churro_ocr/cli.py b/src/churro_ocr/cli.py index 5d2ff87..8c846b3 100644 --- a/src/churro_ocr/cli.py +++ b/src/churro_ocr/cli.py @@ -23,7 +23,6 @@ MistralOptions, OCRBackendSpec, OpenAICompatibleOptions, - VLLMOptions, build_ocr_backend, ) @@ -56,10 +55,8 @@ def _build_ocr_backend( ) ) if backend == "openai-compatible": - if not model or not base_url or not api_key: - raise typer.BadParameter( - "--model, --base-url, and --api-key are required for backend=openai-compatible" - ) + if not model or not base_url: + raise typer.BadParameter("--model and --base-url are required for backend=openai-compatible") return build_ocr_backend( OCRBackendSpec( provider="openai-compatible", @@ -105,16 +102,6 @@ def _build_ocr_backend( options=HuggingFaceOptions(model_kwargs={"device_map": "auto", "torch_dtype": "auto"}), ) ) - if backend == "vllm": - if not model: - raise typer.BadParameter("--model is required for backend=vllm") - return build_ocr_backend( - OCRBackendSpec( - provider="vllm", - model=model, - options=VLLMOptions(), - ) - ) raise typer.BadParameter(f"Unsupported backend: {backend}") diff --git a/src/churro_ocr/providers/__init__.py b/src/churro_ocr/providers/__init__.py index ecab2bd..0a96528 100644 --- a/src/churro_ocr/providers/__init__.py +++ b/src/churro_ocr/providers/__init__.py @@ -24,7 +24,6 @@ OCRBackendSpec, OCRModelProfile, OpenAICompatibleOptions, - VLLMOptions, resolve_ocr_profile, ) @@ -53,7 +52,6 @@ "OCRModelProfile": ("churro_ocr.providers.specs", "OCRModelProfile"), "OpenAICompatibleOptions": ("churro_ocr.providers.specs", "OpenAICompatibleOptions"), "resolve_ocr_profile": ("churro_ocr.providers.specs", "resolve_ocr_profile"), - "VLLMOptions": ("churro_ocr.providers.specs", "VLLMOptions"), } __all__ = [ @@ -72,7 +70,6 @@ "OCRModelProfile", "OpenAICompatibleOptions", "resolve_ocr_profile", - "VLLMOptions", ] diff --git a/src/churro_ocr/providers/builder.py b/src/churro_ocr/providers/builder.py index 7f7550b..bc27461 100644 --- a/src/churro_ocr/providers/builder.py +++ b/src/churro_ocr/providers/builder.py @@ -24,10 +24,8 @@ OCRBackendSpec, OCRModelProfile, OpenAICompatibleOptions, - VLLMOptions, resolve_ocr_profile, ) -from churro_ocr.providers.vllm import VLLMVisionOCRBackend def _merge_mapping( @@ -92,32 +90,6 @@ def _merge_huggingface_options( ) -def _merge_vllm_options( - base: VLLMOptions, - override: VLLMOptions | None, -) -> VLLMOptions: - if override is None: - return VLLMOptions( - trust_remote_code=base.trust_remote_code, - processor_kwargs=dict(base.processor_kwargs), - llm_kwargs=dict(base.llm_kwargs), - sampling_kwargs=dict(base.sampling_kwargs), - limit_mm_per_prompt=dict(base.limit_mm_per_prompt), - ) - return VLLMOptions( - trust_remote_code=( - override.trust_remote_code if override.trust_remote_code is not None else base.trust_remote_code - ), - processor_kwargs=_merge_mapping(base.processor_kwargs, override.processor_kwargs), - llm_kwargs=_merge_mapping(base.llm_kwargs, override.llm_kwargs), - sampling_kwargs=_merge_mapping(base.sampling_kwargs, override.sampling_kwargs), - limit_mm_per_prompt={ - **base.limit_mm_per_prompt, - **override.limit_mm_per_prompt, - }, - ) - - def _merge_openai_options( override: OpenAICompatibleOptions | None, ) -> OpenAICompatibleOptions: @@ -166,10 +138,8 @@ def _build_openai_compatible_backend(spec: OCRBackendSpec, profile: OCRModelProf _ensure_options_type(spec.options, OpenAICompatibleOptions, provider=spec.provider) ) transport_config = _merge_transport_config(profile.transport, spec.transport) - if not transport_config.api_base or not transport_config.api_key: - raise ConfigurationError( - "OCR provider 'openai-compatible' requires `transport.api_base` and `transport.api_key`." - ) + if not transport_config.api_base: + raise ConfigurationError("OCR provider 'openai-compatible' requires `transport.api_base`.") return OpenAICompatibleOCRBackend( model=spec.model, model_prefix=options.model_prefix or "openai", @@ -207,27 +177,6 @@ def _build_huggingface_backend(spec: OCRBackendSpec, profile: OCRModelProfile) - ) -def _build_vllm_backend(spec: OCRBackendSpec, profile: OCRModelProfile) -> OCRBackend: - if spec.model is None: - raise ConfigurationError("OCR provider 'vllm' requires `model`.") - options = _merge_vllm_options( - profile.vllm, - _ensure_options_type(spec.options, VLLMOptions, provider=spec.provider), - ) - return VLLMVisionOCRBackend( - model_id=spec.model, - template=profile.template, - model_name=_resolve_model_name(profile, spec.model, fallback=spec.model), - trust_remote_code=bool(options.trust_remote_code), - processor_kwargs=dict(options.processor_kwargs), - llm_kwargs=dict(options.llm_kwargs), - sampling_kwargs=dict(options.sampling_kwargs), - limit_mm_per_prompt=dict(options.limit_mm_per_prompt) or {"image": 1}, - image_preprocessor=profile.image_preprocessor, - text_postprocessor=profile.text_postprocessor, - ) - - def _build_azure_backend(spec: OCRBackendSpec, profile: OCRModelProfile) -> OCRBackend: options = _ensure_options_type(spec.options, AzureDocumentIntelligenceOptions, provider=spec.provider) if options is None or not options.endpoint or not options.api_key: @@ -275,8 +224,6 @@ def build_ocr_backend(spec: OCRBackendSpec) -> OCRBackend: return _build_openai_compatible_backend(spec, profile) if spec.provider == "hf": return _build_huggingface_backend(spec, profile) - if spec.provider == "vllm": - return _build_vllm_backend(spec, profile) if spec.provider == "azure": return _build_azure_backend(spec, profile) if spec.provider == "mistral": diff --git a/src/churro_ocr/providers/specs.py b/src/churro_ocr/providers/specs.py index 58a08ae..b366617 100644 --- a/src/churro_ocr/providers/specs.py +++ b/src/churro_ocr/providers/specs.py @@ -32,7 +32,7 @@ pass -OCRProvider = Literal["litellm", "openai-compatible", "azure", "mistral", "hf", "vllm"] +OCRProvider = Literal["litellm", "openai-compatible", "azure", "mistral", "hf"] ImagePreprocessor = Callable[[Image.Image], Image.Image] TextPostprocessorResult = str | tuple[str, dict[str, Any]] TextPostprocessor = Callable[[str], TextPostprocessorResult] @@ -135,24 +135,6 @@ class HuggingFaceOptions: backend_variant: str | None = None -@dataclass(slots=True, frozen=True) -class VLLMOptions: - """Provider options for local vLLM OCR backends. - - :param trust_remote_code: Whether to allow remote model code execution. - :param processor_kwargs: Extra kwargs passed to ``AutoProcessor.from_pretrained``. - :param llm_kwargs: Extra kwargs passed to the vLLM ``LLM`` constructor. - :param sampling_kwargs: Extra kwargs passed to vLLM sampling params. - :param limit_mm_per_prompt: Per-request multimodal limits passed to vLLM. - """ - - trust_remote_code: bool | None = None - processor_kwargs: dict[str, object] = field(default_factory=dict) - llm_kwargs: dict[str, object] = field(default_factory=dict) - sampling_kwargs: dict[str, object] = field(default_factory=dict) - limit_mm_per_prompt: dict[str, int] = field(default_factory=dict) - - @dataclass(slots=True, frozen=True) class AzureDocumentIntelligenceOptions: """Provider options for Azure Document Intelligence OCR. @@ -176,11 +158,7 @@ class MistralOptions: OCRProviderOptions = ( - OpenAICompatibleOptions - | HuggingFaceOptions - | VLLMOptions - | AzureDocumentIntelligenceOptions - | MistralOptions + OpenAICompatibleOptions | HuggingFaceOptions | AzureDocumentIntelligenceOptions | MistralOptions ) @@ -195,7 +173,6 @@ class OCRModelProfile: :param display_name: Optional human-readable model name. :param transport: Default LiteLLM transport settings for this profile. :param huggingface: Default Hugging Face backend options for this profile. - :param vllm: Default vLLM backend options for this profile. """ profile_name: str @@ -205,7 +182,6 @@ class OCRModelProfile: display_name: str | None = None transport: LiteLLMTransportConfig = field(default_factory=LiteLLMTransportConfig) huggingface: HuggingFaceOptions = field(default_factory=HuggingFaceOptions) - vllm: VLLMOptions = field(default_factory=VLLMOptions) @dataclass(slots=True, frozen=True) @@ -261,9 +237,6 @@ def dots_ocr_1_5_profile() -> OCRModelProfile: trust_remote_code=True, backend_variant="dots-ocr-1.5", ), - vllm=VLLMOptions( - trust_remote_code=True, - ), ) @@ -368,5 +341,4 @@ def resolve_ocr_profile( "resolve_ocr_profile", "TextPostprocessor", "VisionInputBuilder", - "VLLMOptions", ] diff --git a/src/churro_ocr/providers/vllm.py b/src/churro_ocr/providers/vllm.py deleted file mode 100644 index 1c05283..0000000 --- a/src/churro_ocr/providers/vllm.py +++ /dev/null @@ -1,209 +0,0 @@ -"""vLLM OCR backends.""" - -from __future__ import annotations - -import asyncio -import threading -from dataclasses import dataclass, field -from importlib import import_module -from typing import Any, cast - -from churro_ocr._internal.prompt_logging import log_prompt_payload_once -from churro_ocr.errors import ConfigurationError, ProviderError -from churro_ocr.ocr import OCRBackend, OCRResult -from churro_ocr.page_detection import DocumentPage -from churro_ocr.providers._shared import build_ocr_result, preprocess_backend_page, render_ocr_prompt -from churro_ocr.providers.specs import ( - DEFAULT_OCR_MAX_TOKENS, - ImagePreprocessor, - TextPostprocessor, - default_ocr_image_preprocessor, - identity_text_postprocessor, -) -from churro_ocr.templates import ( - DOTS_OCR_1_5_MODEL_ID, - DOTS_OCR_1_5_OCR_TEMPLATE, - OCRPromptTemplateLike, -) - - -def _load_vllm_processor_cls() -> Any: - try: - from transformers import AutoProcessor - except ImportError as exc: # pragma: no cover - optional extra path - raise ConfigurationError( - 'vLLM OCR requires transformers. Install with `pip install "churro-ocr[vllm]"`.' - ) from exc - - return AutoProcessor - - -def _load_vllm_runtime() -> tuple[Any, Any]: - try: - vllm = import_module("vllm") - except ImportError as exc: # pragma: no cover - optional extra path - raise ConfigurationError( - 'vLLM OCR requires the "vllm" extra. Install with `pip install "churro-ocr[vllm]"`.' - ) from exc - - vllm_any = cast(Any, vllm) - return vllm_any.LLM, vllm_any.SamplingParams - - -@dataclass(slots=True) -class VLLMVisionOCRBackend(OCRBackend): - """OCR backend for local multimodal models served by vLLM. - - :param model_id: Model identifier served by vLLM. - :param template: Prompt template used to render OCR input. - :param model_name: Optional human-readable model name for result metadata. - :param trust_remote_code: Whether to allow remote model code execution. - :param processor_kwargs: Extra kwargs passed to processor loading. - :param llm_kwargs: Extra kwargs passed to the vLLM ``LLM`` constructor. - :param sampling_kwargs: Extra sampling kwargs passed at inference time. - :param limit_mm_per_prompt: Multimodal limits passed to vLLM. - :param image_preprocessor: Image preprocessor applied before OCR. - :param text_postprocessor: Text postprocessor applied after OCR. - :param provider_name: Provider identifier written into OCR results. - """ - - model_id: str - template: OCRPromptTemplateLike - model_name: str | None = None - trust_remote_code: bool = False - processor_kwargs: dict[str, object] = field(default_factory=dict) - llm_kwargs: dict[str, object] = field(default_factory=dict) - sampling_kwargs: dict[str, object] = field(default_factory=dict) - limit_mm_per_prompt: dict[str, int] = field(default_factory=lambda: {"image": 1}) - image_preprocessor: ImagePreprocessor = default_ocr_image_preprocessor - text_postprocessor: TextPostprocessor = identity_text_postprocessor - provider_name: str = "vllm" - _processor: object | None = field(default=None, init=False, repr=False) - _llm: object | None = field(default=None, init=False, repr=False) - _init_lock: threading.RLock = field(default_factory=threading.RLock, init=False, repr=False) - _has_logged_prompt: bool = field(default=False, init=False, repr=False) - _prompt_log_lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False) - - def __post_init__(self) -> None: - """Apply default sampling settings after dataclass initialization.""" - self.sampling_kwargs = { - "max_tokens": DEFAULT_OCR_MAX_TOKENS, - **self.sampling_kwargs, - } - - async def ocr(self, page: DocumentPage) -> OCRResult: - """Run OCR for one page. - - :param page: Page to transcribe. - :returns: Provider-agnostic OCR result. - """ - return (await self.ocr_batch([page]))[0] - - async def ocr_batch(self, pages: list[DocumentPage]) -> list[OCRResult]: - """Run OCR for multiple pages in one batch. - - :param pages: Pages to transcribe in batch order. - :returns: OCR results in the same order as ``pages``. - """ - return await asyncio.to_thread(self._ocr_batch_sync, pages) - - def _ocr_batch_sync(self, pages: list[DocumentPage]) -> list[OCRResult]: - if not pages: - return [] - - processor = self._get_processor() - llm = self._get_llm() - _, sampling_params_cls = _load_vllm_runtime() - prompts: list[dict[str, object]] = [] - - for page in pages: - prepared_page = preprocess_backend_page( - page, - image_preprocessor=self.image_preprocessor, - ) - rendered, _ = render_ocr_prompt( - processor, - self.template, - prepared_page, - add_generation_prompt=True, - ) - prompt_payload: dict[str, object] = { - "prompt": rendered, - "multi_modal_data": {"image": prepared_page.image}, - } - prompts.append(prompt_payload) - if not self._has_logged_prompt: - log_prompt_payload_once( - payload={ - "batch_size": len(pages), - "prompt": prompt_payload, - }, - provider_name=self.provider_name, - has_logged=lambda: self._has_logged_prompt, - lock=self._prompt_log_lock, - set_logged=lambda: setattr(self, "_has_logged_prompt", True), - ) - - request_outputs = llm.generate( - prompts, - sampling_params_cls(**self.sampling_kwargs), - use_tqdm=False, - ) - results: list[OCRResult] = [] - for request_output in request_outputs: - outputs = getattr(request_output, "outputs", None) - if not outputs: - raise ProviderError("vLLM OCR returned no outputs.") - text = getattr(outputs[0], "text", None) - if not isinstance(text, str): - raise ProviderError("vLLM OCR returned a non-text response.") - results.append( - build_ocr_result( - text, - provider_name=self.provider_name, - model_name=self.model_name or self.model_id, - text_postprocessor=self.text_postprocessor, - ) - ) - return results - - def _get_processor(self) -> Any: - if self._processor is None: - with self._init_lock: - if self._processor is None: - processor_cls = _load_vllm_processor_cls() - self._processor = processor_cls.from_pretrained( - self.model_id, - trust_remote_code=self.trust_remote_code, - **self.processor_kwargs, - ) - return self._processor - - def _get_llm(self) -> Any: - if self._llm is None: - with self._init_lock: - if self._llm is None: - llm_cls, _ = _load_vllm_runtime() - self._llm = llm_cls( - model=self.model_id, - trust_remote_code=self.trust_remote_code, - limit_mm_per_prompt=self.limit_mm_per_prompt, - **self.llm_kwargs, - ) - return self._llm - - -@dataclass(slots=True) -class DotsOCR15VLLMOCRBackend(VLLMVisionOCRBackend): - """Preset vLLM OCR backend for ``kristaller486/dots.ocr-1.5``.""" - - model_id: str = DOTS_OCR_1_5_MODEL_ID - template: OCRPromptTemplateLike = DOTS_OCR_1_5_OCR_TEMPLATE - model_name: str | None = "dots.ocr-1.5" - trust_remote_code: bool = True - - -__all__ = [ - "DotsOCR15VLLMOCRBackend", - "VLLMVisionOCRBackend", -] diff --git a/tests/test_cli.py b/tests/test_cli.py index 684071a..0df4647 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -15,7 +15,12 @@ from churro_ocr.ocr import OCRResult from churro_ocr.page_detection import DocumentPage, PageDetectionResult from churro_ocr.prompts import DEFAULT_OCR_OUTPUT_TAG -from churro_ocr.templates import DEFAULT_OCR_TEMPLATE, DOTS_OCR_1_5_OCR_TEMPLATE +from churro_ocr.templates import ( + DEFAULT_OCR_TEMPLATE, + DOTS_OCR_1_5_OCR_TEMPLATE, + OLMOCR_2_7B_1025_MODEL_ID, + OLMOCR_2_7B_1025_OCR_TEMPLATE, +) def test_transcribe_cli_writes_output( @@ -113,20 +118,20 @@ def test_build_ocr_backend_aligns_templates_for_generic_models() -> None: base_url=None, api_version=None, ) - vllm_backend = cli_module._build_ocr_backend( - backend="vllm", + openai_backend = cli_module._build_ocr_backend( + backend="openai-compatible", model="example/model", endpoint=None, api_key=None, - base_url=None, + base_url="http://127.0.0.1:8000/v1", api_version=None, ) assert litellm_backend.template == DEFAULT_OCR_TEMPLATE - assert litellm_backend.template == hf_backend.template == vllm_backend.template + assert litellm_backend.template == hf_backend.template == openai_backend.template assert litellm_backend.model_name == "example/model" assert hf_backend.model_name == "example/model" - assert vllm_backend.model_name == "example/model" + assert openai_backend.model_name == "example/model" assert f"<{DEFAULT_OCR_OUTPUT_TAG}>" in litellm_backend.template.system_message assert f"" in litellm_backend.template.system_message assert f"<{DEFAULT_OCR_OUTPUT_TAG}>" in litellm_backend.template.user_prompt @@ -150,20 +155,20 @@ def test_build_ocr_backend_aligns_templates_for_dots() -> None: base_url=None, api_version=None, ) - vllm_backend = cli_module._build_ocr_backend( - backend="vllm", + openai_backend = cli_module._build_ocr_backend( + backend="openai-compatible", model="kristaller486/dots.ocr-1.5", endpoint=None, api_key=None, - base_url=None, + base_url="http://127.0.0.1:8000/v1", api_version=None, ) assert litellm_backend.template == DOTS_OCR_1_5_OCR_TEMPLATE - assert litellm_backend.template == hf_backend.template == vllm_backend.template + assert litellm_backend.template == hf_backend.template == openai_backend.template assert litellm_backend.model_name == "dots.ocr-1.5" assert hf_backend.model_name == "dots.ocr-1.5" - assert vllm_backend.model_name == "dots.ocr-1.5" + assert openai_backend.model_name == "dots.ocr-1.5" def test_build_ocr_backend_uses_generic_defaults_for_qwen_3_5_0_8b() -> None: @@ -183,21 +188,57 @@ def test_build_ocr_backend_uses_generic_defaults_for_qwen_3_5_0_8b() -> None: base_url=None, api_version=None, ) - vllm_backend = cli_module._build_ocr_backend( - backend="vllm", + openai_backend = cli_module._build_ocr_backend( + backend="openai-compatible", model="Qwen/Qwen3.5-0.8B", endpoint=None, api_key=None, - base_url=None, + base_url="http://127.0.0.1:8000/v1", api_version=None, ) assert litellm_backend.template == DEFAULT_OCR_TEMPLATE - assert litellm_backend.template == hf_backend.template == vllm_backend.template + assert litellm_backend.template == hf_backend.template == openai_backend.template assert litellm_backend.model_name == "Qwen/Qwen3.5-0.8B" assert hf_backend.model_name == "Qwen/Qwen3.5-0.8B" - assert vllm_backend.model_name == "Qwen/Qwen3.5-0.8B" - assert vllm_backend.llm_kwargs == {} + assert openai_backend.model_name == "Qwen/Qwen3.5-0.8B" + + +def test_build_ocr_backend_aligns_templates_for_olmocr() -> None: + litellm_backend = cli_module._build_ocr_backend( + backend="litellm", + model=OLMOCR_2_7B_1025_MODEL_ID, + endpoint=None, + api_key=None, + base_url=None, + api_version=None, + ) + hf_backend = cli_module._build_ocr_backend( + backend="hf", + model=OLMOCR_2_7B_1025_MODEL_ID, + endpoint=None, + api_key=None, + base_url=None, + api_version=None, + ) + openai_backend = cli_module._build_ocr_backend( + backend="openai-compatible", + model=OLMOCR_2_7B_1025_MODEL_ID, + endpoint=None, + api_key=None, + base_url="http://127.0.0.1:8000/v1", + api_version=None, + ) + + assert litellm_backend.template == OLMOCR_2_7B_1025_OCR_TEMPLATE + assert litellm_backend.template == hf_backend.template == openai_backend.template + assert litellm_backend.model_name == "olmOCR-2-7B-1025" + assert hf_backend.model_name == "olmOCR-2-7B-1025" + assert openai_backend.model_name == "olmOCR-2-7B-1025" + assert openai_backend.transport.config.completion_kwargs == { + "max_tokens": 8_000, + "temperature": 0.1, + } def test_install_command_invokes_runtime_installer( diff --git a/tests/test_cli_contract.py b/tests/test_cli_contract.py index 0bffed9..f932706 100644 --- a/tests/test_cli_contract.py +++ b/tests/test_cli_contract.py @@ -23,12 +23,11 @@ def sample_image_path(write_image_file) -> Path: (["--backend", "litellm"], ("--model is required for backend=litellm",)), ( ["--backend", "openai-compatible", "--model", "local-model"], - ("required for", "backend=openai-compatible"), + ("--model and --base-url are required for", "backend=openai-compatible"), ), (["--backend", "azure"], ("--endpoint and --api-key are required for backend=azure",)), (["--backend", "mistral"], ("--api-key is required for backend=mistral",)), (["--backend", "hf"], ("--model is required for backend=hf",)), - (["--backend", "vllm"], ("--model is required for backend=vllm",)), ], ) def test_transcribe_cli_validates_backend_requirements( @@ -48,7 +47,57 @@ def test_transcribe_cli_validates_backend_requirements( assert expected_part in output -def test_transcribe_cli_rejects_unsupported_backend(sample_image_path: Path, cli_runner) -> None: +def test_transcribe_cli_allows_openai_compatible_backend_without_api_key( + monkeypatch: pytest.MonkeyPatch, + sample_image_path: Path, + cli_runner, +) -> None: + captured: dict[str, object] = {} + + class _FakeBackend: + async def ocr(self, page: DocumentPage) -> OCRResult: + captured["size"] = (page.image.width, page.image.height) + return OCRResult(text="ok", provider_name="fake", model_name="fake-model") + + def _fake_build_ocr_backend(spec: cli_module.OCRBackendSpec) -> _FakeBackend: + captured["spec"] = spec + return _FakeBackend() + + monkeypatch.setattr( + "churro_ocr.cli.build_ocr_backend", + _fake_build_ocr_backend, + ) + + result = cli_runner.invoke( + app, + [ + "transcribe", + "--image", + str(sample_image_path), + "--backend", + "openai-compatible", + "--model", + "local-model", + "--base-url", + "http://127.0.0.1:8000/v1", + ], + ) + + assert result.exit_code == 0 + assert result.output.strip() == "ok" + spec = captured["spec"] + assert isinstance(spec, cli_module.OCRBackendSpec) + assert spec.transport is not None + assert spec.transport.api_base == "http://127.0.0.1:8000/v1" + assert spec.transport.api_key is None + + +@pytest.mark.parametrize("backend", ["unsupported", "vllm"]) +def test_transcribe_cli_rejects_unsupported_backend( + sample_image_path: Path, + backend: str, + cli_runner, +) -> None: result = cli_runner.invoke( app, [ @@ -56,14 +105,14 @@ def test_transcribe_cli_rejects_unsupported_backend(sample_image_path: Path, cli "--image", str(sample_image_path), "--backend", - "unsupported", + backend, "--model", "example/model", ], ) assert result.exit_code != 0 - assert "Unsupported backend: unsupported" in result.output + assert f"Unsupported backend: {backend}" in result.output def test_transcribe_cli_echoes_text_without_output( diff --git a/tests/test_internal_helpers.py b/tests/test_internal_helpers.py index 72a78de..e37af3e 100644 --- a/tests/test_internal_helpers.py +++ b/tests/test_internal_helpers.py @@ -18,7 +18,6 @@ from churro_ocr.errors import ConfigurationError, ProviderError from churro_ocr.providers.hf import _load_hf_causal_runtime, _load_hf_runtime from churro_ocr.providers.specs import LiteLLMTransportConfig -from churro_ocr.providers.vllm import _load_vllm_processor_cls, _load_vllm_runtime def _make_fake_litellm_module(*, acompletion: object, completion_cost: object | None = None) -> ModuleType: @@ -354,8 +353,6 @@ def debug(self, message: str, *args: object) -> None: @pytest.mark.parametrize( ("loader", "dependency_name", "message"), [ - (_load_vllm_processor_cls, "transformers", None), - (_load_vllm_runtime, "vllm", None), (_load_hf_runtime, "torch", "PyTorch runtime"), (_load_hf_runtime, "qwen_vl_utils", "install hf"), (_load_hf_causal_runtime, "torch", "PyTorch runtime"), @@ -365,7 +362,7 @@ def debug(self, message: str, *args: object) -> None: def test_optional_dependency_loaders_raise_configuration_error( loader: Any, dependency_name: str, - message: str | None, + message: str, patch_import_failure, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -383,10 +380,5 @@ def _fake_import_module(name: str) -> object: else: patch_import_failure(failing_name=dependency_name) - if message is None: - with pytest.raises(ConfigurationError): - loader() - return - with pytest.raises(ConfigurationError, match=re.escape(message)): loader() diff --git a/tests/test_provider_api_contracts.py b/tests/test_provider_api_contracts.py index bde918f..d44f021 100644 --- a/tests/test_provider_api_contracts.py +++ b/tests/test_provider_api_contracts.py @@ -14,7 +14,6 @@ MistralOptions, OCRBackendSpec, OpenAICompatibleOptions, - VLLMOptions, build_ocr_backend, resolve_ocr_profile, ) @@ -59,7 +58,7 @@ def test_provider_dir_lists_lazy_exports() -> None: (OCRBackendSpec(provider="litellm"), "OCR provider 'litellm' requires `model`."), ( OCRBackendSpec(provider="openai-compatible", model="local-model"), - "OCR provider 'openai-compatible' requires `transport.api_base` and `transport.api_key`.", + "OCR provider 'openai-compatible' requires `transport.api_base`.", ), ( OCRBackendSpec(provider="azure"), @@ -73,17 +72,20 @@ def test_provider_dir_lists_lazy_exports() -> None: OCRBackendSpec( provider="hf", model="example/model", - options=cast("Any", VLLMOptions()), + options=cast("Any", OpenAICompatibleOptions()), ), - "OCR provider 'hf' requires options of type HuggingFaceOptions, got VLLMOptions.", + "OCR provider 'hf' requires options of type HuggingFaceOptions, got OpenAICompatibleOptions.", ), ( OCRBackendSpec( - provider="vllm", + provider="openai-compatible", model="example/model", options=cast("Any", HuggingFaceOptions()), ), - "OCR provider 'vllm' requires options of type VLLMOptions, got HuggingFaceOptions.", + ( + "OCR provider 'openai-compatible' requires options of type " + "OpenAICompatibleOptions, got HuggingFaceOptions." + ), ), ], ) @@ -99,7 +101,6 @@ def test_build_ocr_backend_supports_custom_openai_model_prefix() -> None: model="local-model", transport=LiteLLMTransportConfig( api_base="http://127.0.0.1:8000/v1", - api_key="dummy", ), options=OpenAICompatibleOptions(model_prefix="custom"), ) diff --git a/tests/test_providers.py b/tests/test_providers.py index 065d08b..1c70467 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -12,7 +12,11 @@ from churro_ocr._internal.litellm import LiteLLMTransport from churro_ocr.errors import ProviderError from churro_ocr.page_detection import DocumentPage -from churro_ocr.prompts import DEFAULT_BOUNDARY_DETECTION_PROMPT, DEFAULT_OCR_OUTPUT_TAG +from churro_ocr.prompts import ( + DEFAULT_BOUNDARY_DETECTION_PROMPT, + DEFAULT_OCR_OUTPUT_TAG, + OLMOCR_V4_YAML_PROMPT, +) from churro_ocr.providers import ( AzureDocumentIntelligenceOptions, AzurePageDetector, @@ -22,7 +26,6 @@ MistralOptions, OCRBackendSpec, OpenAICompatibleOptions, - VLLMOptions, build_ocr_backend, locate_text_block_bbox_with_llm, resolve_ocr_profile, @@ -32,6 +35,7 @@ AzureDocumentIntelligenceOCRBackend, LiteLLMVisionOCRBackend, MistralOCRBackend, + OpenAICompatibleOCRBackend, ) from churro_ocr.providers.page_detection import ( _normalize_azure_page_polygon, @@ -39,8 +43,12 @@ locate_text_block_bbox_with_llm_sync, ) from churro_ocr.providers.specs import DEFAULT_OCR_MAX_TOKENS -from churro_ocr.providers.vllm import VLLMVisionOCRBackend -from churro_ocr.templates import DEFAULT_OCR_TEMPLATE +from churro_ocr.templates import ( + DEFAULT_OCR_TEMPLATE, + OLMOCR_2_7B_1025_FP8_MODEL_ID, + OLMOCR_2_7B_1025_MODEL_ID, + OLMOCR_2_7B_1025_OCR_TEMPLATE, +) def _extract_user_text_parts(messages: list[dict[str, Any]]) -> list[str]: @@ -348,183 +356,142 @@ def __init__(self, *, api_key: str) -> None: assert calls == {"client_inits": 1, "requests": 2} -@pytest.mark.asyncio -async def test_vllm_ocr_backend_reuses_engine_and_batches(monkeypatch: pytest.MonkeyPatch) -> None: - calls = {"processor_inits": 0, "engine_inits": 0} - captured: dict[str, object] = {} - - class FakeProcessor: - tokenizer = None - - def apply_chat_template( - self, - conversation: list[dict[str, object]], - *, - add_generation_prompt: bool, - tokenize: bool, - ) -> str: - assert add_generation_prompt is True - assert tokenize is False - content = cast("list[dict[str, object]]", conversation[0]["content"]) - return f"prompt:{content[1]['text']}" - - class FakeProcessorCls: - @staticmethod - def from_pretrained(model_id: str, **kwargs: object) -> FakeProcessor: - calls["processor_inits"] += 1 - captured["processor_model_id"] = model_id - captured["processor_kwargs"] = kwargs - return FakeProcessor() - - class FakeSamplingParams: - def __init__(self, **kwargs: object) -> None: - captured["sampling_kwargs"] = kwargs - - class FakeLLM: - def __init__(self, **kwargs: object) -> None: - calls["engine_inits"] += 1 - captured["llm_kwargs"] = kwargs - - def generate( - self, - prompts: list[dict[str, object]], - sampling_params: FakeSamplingParams, - *, - use_tqdm: bool, - ) -> list[SimpleNamespace]: - del sampling_params - captured["prompts"] = prompts - captured["use_tqdm"] = use_tqdm - return [ - SimpleNamespace(outputs=[SimpleNamespace(text=f"text:{index}")]) - for index, _prompt in enumerate(prompts) - ] - - vllm_module = ModuleType("vllm") - cast("Any", vllm_module).LLM = FakeLLM - cast("Any", vllm_module).SamplingParams = FakeSamplingParams - monkeypatch.setitem(sys.modules, "vllm", vllm_module) - monkeypatch.setattr("churro_ocr.providers.vllm._load_vllm_processor_cls", lambda: FakeProcessorCls) - +def test_build_ocr_backend_uses_olmocr_profile_defaults_for_openai_compatible() -> None: backend = cast( - "VLLMVisionOCRBackend", + "OpenAICompatibleOCRBackend", build_ocr_backend( OCRBackendSpec( - provider="vllm", - model="kristaller486/dots.ocr-1.5", - options=VLLMOptions(), + provider="openai-compatible", + model=OLMOCR_2_7B_1025_MODEL_ID, + transport=LiteLLMTransportConfig(api_base="http://127.0.0.1:8000/v1"), ) ), ) - pages = [ - DocumentPage.from_image(Image.new("RGBA", (5_000, 3_000), color=(255, 255, 255, 255))), - DocumentPage.from_image(Image.new("RGB", (12, 12), color="white")), - ] - first = await backend.ocr(pages[0]) - second_batch = await backend.ocr_batch(pages) - - assert first.text == "text:0" - assert [result.text for result in second_batch] == ["text:0", "text:1"] - assert calls == {"processor_inits": 1, "engine_inits": 1} - assert captured["processor_model_id"] == "kristaller486/dots.ocr-1.5" - assert captured["processor_kwargs"] == {"trust_remote_code": True} - assert captured["llm_kwargs"] == { - "model": "kristaller486/dots.ocr-1.5", - "trust_remote_code": True, - "limit_mm_per_prompt": {"image": 1}, + assert backend.template == OLMOCR_2_7B_1025_OCR_TEMPLATE + assert backend.model_name == "olmOCR-2-7B-1025" + assert backend.transport.config.completion_kwargs == { + "max_tokens": 8_000, + "temperature": 0.1, } - assert captured["sampling_kwargs"] == {"max_tokens": DEFAULT_OCR_MAX_TOKENS} - assert captured["use_tqdm"] is False - prompt_batch = cast("list[dict[str, object]]", captured["prompts"]) - assert isinstance(prompt_batch, list) - assert len(prompt_batch) == 2 - assert prompt_batch[0]["prompt"] == "prompt:Extract the text content from this image." - prompt_media = cast("dict[str, object]", prompt_batch[0]["multi_modal_data"]) - prompt_image = cast("Image.Image", prompt_media["image"]) - assert isinstance(prompt_image, Image.Image) - assert prompt_image.size == (2_500, 1_500) - assert prompt_image.mode == "RGB" + assert backend.image_preprocessor(Image.new("RGB", (5_000, 3_000), color="white")).size == (1_288, 772) -def test_vllm_backend_defaults_are_public() -> None: +def test_build_ocr_backend_resolves_olmocr_fp8_profile_defaults_for_openai_compatible() -> None: backend = cast( - "VLLMVisionOCRBackend", + "OpenAICompatibleOCRBackend", build_ocr_backend( OCRBackendSpec( - provider="vllm", - model="kristaller486/dots.ocr-1.5", - options=VLLMOptions(), + provider="openai-compatible", + model=OLMOCR_2_7B_1025_FP8_MODEL_ID, + transport=LiteLLMTransportConfig(api_base="http://127.0.0.1:8000/v1"), ) ), ) - assert backend.model_id == "kristaller486/dots.ocr-1.5" - assert backend.model_name == "dots.ocr-1.5" - assert backend.provider_name == "vllm" - assert backend.processor_kwargs == {} + assert backend.template == OLMOCR_2_7B_1025_OCR_TEMPLATE + assert backend.model_name == "olmOCR-2-7B-1025-FP8" + assert backend.transport.config.completion_kwargs == { + "max_tokens": 8_000, + "temperature": 0.1, + } @pytest.mark.asyncio -async def test_vllm_ocr_backend_strips_default_output_tags(monkeypatch: pytest.MonkeyPatch) -> None: - class FakeProcessor: - tokenizer = None +async def test_openai_compatible_backend_uses_olmocr_prompt_and_plain_text_postprocessing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} - def apply_chat_template( - self, - conversation: list[dict[str, object]], - *, - add_generation_prompt: bool, - tokenize: bool, - ) -> str: - del conversation, add_generation_prompt, tokenize - return "prompt" - - class FakeProcessorCls: - @staticmethod - def from_pretrained(model_id: str, **kwargs: object) -> FakeProcessor: - del model_id, kwargs - return FakeProcessor() - - class FakeSamplingParams: - def __init__(self, **kwargs: object) -> None: - del kwargs - - class FakeLLM: - def __init__(self, **kwargs: object) -> None: - del kwargs - - def generate( - self, - prompts: list[dict[str, object]], - sampling_params: FakeSamplingParams, - *, - use_tqdm: bool, - ) -> list[SimpleNamespace]: - del prompts, sampling_params, use_tqdm - return [ - SimpleNamespace( - outputs=[ - SimpleNamespace( - text=(f"<{DEFAULT_OCR_OUTPUT_TAG}>\npage text\n") - ) - ] - ) - ] + def _fake_prepare_messages_from_conversation( + self: LiteLLMTransport, + conversation: list[dict[str, object]], + ) -> list[dict[str, object]]: + captured["conversation"] = conversation + captured["completion_kwargs"] = dict(self.config.completion_kwargs) + return [{"role": "user", "content": [{"type": "text", "text": "prompt"}]}] - vllm_module = ModuleType("vllm") - cast("Any", vllm_module).LLM = FakeLLM - cast("Any", vllm_module).SamplingParams = FakeSamplingParams - monkeypatch.setitem(sys.modules, "vllm", vllm_module) - monkeypatch.setattr("churro_ocr.providers.vllm._load_vllm_processor_cls", lambda: FakeProcessorCls) + async def _fake_complete_text( + self: LiteLLMTransport, + *, + model: str, + messages: list[dict[str, object]], + timeout_seconds: int = 600, + output_json: bool = False, + ) -> str: + captured["model"] = model + captured["messages"] = messages + captured["timeout_seconds"] = timeout_seconds + captured["output_json"] = output_json + captured["completion_kwargs"] = dict(self.config.completion_kwargs) + return ( + "---\n" + "primary_language: en\n" + "is_rotation_valid: true\n" + "rotation_correction: 0\n" + "is_table: true\n" + "is_diagram: false\n" + "---\n" + "# Ledger\n\n" + "" + "
YearValue
190042
\n\n" + "Paragraph with [note](https://example.test).\n" + "![Figure alt text](page_0_0_100_100.png)\n" + ) + + monkeypatch.setattr( + LiteLLMTransport, + "prepare_messages_from_conversation", + _fake_prepare_messages_from_conversation, + ) + monkeypatch.setattr(LiteLLMTransport, "complete_text", _fake_complete_text) backend = cast( - "VLLMVisionOCRBackend", - build_ocr_backend(OCRBackendSpec(provider="vllm", model="example/model")), + "OpenAICompatibleOCRBackend", + build_ocr_backend( + OCRBackendSpec( + provider="openai-compatible", + model=OLMOCR_2_7B_1025_MODEL_ID, + transport=LiteLLMTransportConfig(api_base="http://127.0.0.1:8000/v1"), + ) + ), + ) + result = await backend.ocr( + DocumentPage.from_image(Image.new("RGBA", (5_000, 3_000), color=(255, 255, 255, 255))) ) - result = await backend.ocr(DocumentPage.from_image(Image.new("RGB", (10, 10), color="white"))) - assert result.text == "page text" + assert result.text == "Ledger\n\nYear | Value\n1900 | 42\n\nParagraph with note." + assert result.metadata == { + "front_matter": { + "primary_language": "en", + "is_rotation_valid": True, + "rotation_correction": 0, + "is_table": True, + "is_diagram": False, + }, + "raw_markdown": ( + "# Ledger\n\n" + "
YearValue
190042
\n\n" + "Paragraph with [note](https://example.test).\n" + "![Figure alt text](page_0_0_100_100.png)" + ), + } + assert captured["model"] == f"openai/{OLMOCR_2_7B_1025_MODEL_ID}" + assert captured["messages"] == [{"role": "user", "content": [{"type": "text", "text": "prompt"}]}] + assert captured["timeout_seconds"] == 600 + assert captured["output_json"] is False + assert captured["completion_kwargs"] == { + "max_tokens": 8_000, + "temperature": 0.1, + } + conversation = cast("list[dict[str, object]]", captured["conversation"]) + assert conversation[0]["role"] == "user" + user_content = cast("list[dict[str, object]]", conversation[0]["content"]) + assert user_content[0] == {"type": "text", "text": OLMOCR_V4_YAML_PROMPT} + assert user_content[1]["type"] == "image" + prompt_image = cast("Image.Image", user_content[1]["image"]) + assert prompt_image.size == (1_288, 772) + assert prompt_image.mode == "RGB" @pytest.mark.asyncio @@ -1017,11 +984,12 @@ def test_azure_page_detector_type_is_public() -> None: def test_build_ocr_backend_resolves_profile_defaults() -> None: backend = cast( - "VLLMVisionOCRBackend", + "OpenAICompatibleOCRBackend", build_ocr_backend( OCRBackendSpec( - provider="vllm", + provider="openai-compatible", model="stanford-oval/churro-3B", + transport=LiteLLMTransportConfig(api_base="http://127.0.0.1:8000/v1"), ) ), ) @@ -1032,19 +1000,19 @@ def test_build_ocr_backend_resolves_profile_defaults() -> None: def test_build_ocr_backend_uses_generic_defaults_for_qwen_model() -> None: backend = cast( - "VLLMVisionOCRBackend", + "OpenAICompatibleOCRBackend", build_ocr_backend( OCRBackendSpec( - provider="vllm", + provider="openai-compatible", model="Qwen/Qwen3.5-0.8B", + transport=LiteLLMTransportConfig(api_base="http://127.0.0.1:8000/v1"), ) ), ) assert backend.model_name == "Qwen/Qwen3.5-0.8B" assert backend.template == DEFAULT_OCR_TEMPLATE - assert backend.llm_kwargs == {} - assert backend.sampling_kwargs == {"max_tokens": DEFAULT_OCR_MAX_TOKENS} + assert backend.transport.config.completion_kwargs == {"max_tokens": DEFAULT_OCR_MAX_TOKENS} def test_build_ocr_backend_merges_hf_overrides_with_profile_defaults() -> None: diff --git a/tests/test_tooling_benchmark.py b/tests/test_tooling_benchmark.py index e672903..04f1825 100644 --- a/tests/test_tooling_benchmark.py +++ b/tests/test_tooling_benchmark.py @@ -11,8 +11,7 @@ from churro_ocr.providers.hf import HuggingFaceVisionOCRBackend from churro_ocr.providers.ocr import LiteLLMVisionOCRBackend from churro_ocr.providers.specs import DEFAULT_OCR_MAX_TOKENS -from churro_ocr.providers.vllm import VLLMVisionOCRBackend -from churro_ocr.templates import CHURRO_3B_XML_TEMPLATE +from churro_ocr.templates import CHURRO_3B_XML_TEMPLATE, DOTS_OCR_1_5_OCR_TEMPLATE from tooling.benchmarking import benchmark from tooling.evaluation.types import BenchmarkDatasetExample @@ -104,23 +103,14 @@ def test_validate_options_requires_model_for_hf() -> None: assert benchmark._validate_options(options) == 1 -def test_validate_options_requires_model_for_vllm() -> None: +def test_validate_options_allows_openai_compatible_without_api_key() -> None: options = benchmark.BenchmarkOptions( - backend="vllm", - model=None, - dataset_split="dev", - ) - assert benchmark._validate_options(options) == 1 - - -def test_validate_options_rejects_invalid_vllm_gpu_memory_utilization() -> None: - options = benchmark.BenchmarkOptions( - backend="vllm", - model="Qwen/Qwen3.5-0.8B", + backend="openai-compatible", dataset_split="dev", - vllm_gpu_memory_utilization=1.5, + model="local-model", + base_url="http://127.0.0.1:8000/v1", ) - assert benchmark._validate_options(options) == 1 + assert benchmark._validate_options(options) == 0 def test_validate_options_rejects_invalid_split() -> None: @@ -155,24 +145,18 @@ def test_parse_args_accepts_subset_filters() -> None: assert options.document_type == "print" -def test_parse_args_accepts_vllm_resource_overrides() -> None: - options = benchmark.parse_args( - [ - "--backend", - "vllm", - "--dataset-split", - "dev", - "--model", - "Qwen/Qwen3.5-0.8B", - "--vllm-gpu-memory-utilization", - "0.25", - "--vllm-cpu-offload-gb", - "8", - ] - ) - - assert options.vllm_gpu_memory_utilization == pytest.approx(0.25) - assert options.vllm_cpu_offload_gb == pytest.approx(8.0) +def test_parse_args_rejects_removed_vllm_backend() -> None: + with pytest.raises(SystemExit): + benchmark.parse_args( + [ + "--backend", + "vllm", + "--dataset-split", + "dev", + "--model", + "Qwen/Qwen3.5-0.8B", + ] + ) def test_build_ocr_backend_enables_disk_cache_for_litellm( @@ -198,6 +182,24 @@ def test_build_ocr_backend_enables_disk_cache_for_litellm( assert backend.transport.config.completion_kwargs == {"max_tokens": DEFAULT_OCR_MAX_TOKENS} +def test_build_ocr_backend_allows_openai_compatible_without_api_key() -> None: + backend = cast( + "LiteLLMVisionOCRBackend", + benchmark._build_ocr_backend( + benchmark.BenchmarkOptions( + backend="openai-compatible", + dataset_split="dev", + model="local-model", + base_url="http://127.0.0.1:8000/v1", + ) + ), + ) + + assert backend.provider_name == "openai-compatible" + assert backend.transport.config.api_base == "http://127.0.0.1:8000/v1" + assert backend.transport.config.api_key is None + + def test_build_ocr_backend_uses_dots_preset_for_hf() -> None: backend = cast( "HuggingFaceVisionOCRBackend", @@ -214,38 +216,41 @@ def test_build_ocr_backend_uses_dots_preset_for_hf() -> None: assert backend.processor_kwargs == {} assert backend.trust_remote_code is True assert backend.model_kwargs["dtype"] in {"auto", "float32"} - if backend.model_kwargs["dtype"] == "auto": + if backend.model_kwargs["dtype"] == "auto" and "device_map" in backend.model_kwargs: assert backend.model_kwargs["device_map"] == "auto" - assert "max_memory" in backend.model_kwargs + if "max_memory" in backend.model_kwargs: + assert backend.model_kwargs["max_memory"] assert backend.generation_kwargs == {"max_new_tokens": DEFAULT_OCR_MAX_TOKENS} -def test_build_ocr_backend_uses_dots_preset_for_vllm() -> None: +def test_build_ocr_backend_uses_dots_preset_for_openai_compatible() -> None: backend = cast( - "VLLMVisionOCRBackend", + "LiteLLMVisionOCRBackend", benchmark._build_ocr_backend( benchmark.BenchmarkOptions( - backend="vllm", + backend="openai-compatible", dataset_split="dev", model="kristaller486/dots.ocr-1.5", + base_url="http://127.0.0.1:8000/v1", ) ), ) + assert backend.provider_name == "openai-compatible" assert backend.model_name == "dots.ocr-1.5" - assert backend.processor_kwargs == {} - assert backend.trust_remote_code is True - assert backend.sampling_kwargs == {"max_tokens": DEFAULT_OCR_MAX_TOKENS} + assert backend.template == DOTS_OCR_1_5_OCR_TEMPLATE + assert backend.transport.config.completion_kwargs == {"max_tokens": DEFAULT_OCR_MAX_TOKENS} -def test_build_ocr_backend_uses_churro_preset_template_for_vllm() -> None: +def test_build_ocr_backend_uses_churro_preset_template_for_openai_compatible() -> None: backend = cast( - "VLLMVisionOCRBackend", + "LiteLLMVisionOCRBackend", benchmark._build_ocr_backend( benchmark.BenchmarkOptions( - backend="vllm", + backend="openai-compatible", dataset_split="dev", model="stanford-oval/churro-3B", + base_url="http://127.0.0.1:8000/v1", ) ), ) @@ -254,29 +259,24 @@ def test_build_ocr_backend_uses_churro_preset_template_for_vllm() -> None: assert backend.model_name == "churro-3B" -def test_build_ocr_backend_uses_generic_qwen_model_name_for_vllm() -> None: +def test_build_ocr_backend_uses_generic_qwen_model_name_for_openai_compatible() -> None: backend = cast( - "VLLMVisionOCRBackend", + "LiteLLMVisionOCRBackend", benchmark._build_ocr_backend( benchmark.BenchmarkOptions( - backend="vllm", + backend="openai-compatible", dataset_split="dev", model="Qwen/Qwen3.5-0.8B", - vllm_gpu_memory_utilization=0.25, - vllm_cpu_offload_gb=8.0, + base_url="http://127.0.0.1:8000/v1", ) ), ) assert backend.model_name == "Qwen/Qwen3.5-0.8B" - assert backend.llm_kwargs == { - "gpu_memory_utilization": 0.25, - "cpu_offload_gb": 8.0, - } - assert backend.sampling_kwargs == {"max_tokens": DEFAULT_OCR_MAX_TOKENS} + assert backend.transport.config.completion_kwargs == {"max_tokens": DEFAULT_OCR_MAX_TOKENS} -def test_build_ocr_backend_aligns_hf_and_vllm_templates_for_generic_models() -> None: +def test_build_ocr_backend_aligns_hf_and_openai_compatible_templates_for_generic_models() -> None: hf_backend = cast( "HuggingFaceVisionOCRBackend", benchmark._build_ocr_backend( @@ -287,18 +287,19 @@ def test_build_ocr_backend_aligns_hf_and_vllm_templates_for_generic_models() -> ) ), ) - vllm_backend = cast( - "VLLMVisionOCRBackend", + openai_backend = cast( + "LiteLLMVisionOCRBackend", benchmark._build_ocr_backend( benchmark.BenchmarkOptions( - backend="vllm", + backend="openai-compatible", dataset_split="dev", model="example/model", + base_url="http://127.0.0.1:8000/v1", ) ), ) - assert hf_backend.template == vllm_backend.template + assert hf_backend.template == openai_backend.template @pytest.mark.asyncio @@ -678,43 +679,6 @@ async def ocr_batch(self, pages): # noqa: ANN001 ] -@pytest.mark.asyncio -async def test_predict_texts_uses_max_concurrency_for_vllm_batch_backend(monkeypatch) -> None: - dataset = [ - _benchmark_example(str(index), size=(index + 1, index + 1), transcription=f"text-{index}") - for index in range(10) - ] - captured_batch_sizes: list[int] = [] - - class FakeOCRResult: - def __init__(self, text: str) -> None: - self.text = text - - class FakeBatchBackend: - async def ocr_batch(self, pages): # noqa: ANN001 - captured_batch_sizes.append(len(pages)) - return [FakeOCRResult(text=f"page-{page.width}") for page in pages] - - monkeypatch.setattr(benchmark, "_build_ocr_backend", lambda _: FakeBatchBackend()) - - options = benchmark.BenchmarkOptions( - backend="vllm", - dataset_split="dev", - model="Qwen/Qwen3.5-0.8B", - max_concurrency=2, - ) - - evaluation_examples, predictions = await benchmark._predict_texts( - dataset, - options, - total_pages=10, - ) - - assert captured_batch_sizes == [2, 2, 2, 2, 2] - assert predictions == [f"page-{index + 1}" for index in range(10)] - assert evaluation_examples == [benchmark._build_evaluation_example(example) for example in dataset] - - @pytest.mark.asyncio async def test_predict_texts_logs_first_submitted_output_once_for_non_batch_backend(monkeypatch) -> None: dataset: list[BenchmarkDatasetExample] = [ diff --git a/tooling/benchmarking/benchmark.py b/tooling/benchmarking/benchmark.py index 2ca1622..f5bebb3 100644 --- a/tooling/benchmarking/benchmark.py +++ b/tooling/benchmarking/benchmark.py @@ -32,7 +32,6 @@ MistralOptions, OCRBackendSpec, OpenAICompatibleOptions, - VLLMOptions, ) from tooling.benchmarking.dataset import ( DatasetSelection, @@ -44,7 +43,7 @@ CHURRO_DATASET_ID = "stanford-oval/churro-dataset" VALID_DATASET_SPLITS = {"dev", "test"} -VALID_OCR_BACKENDS = {"litellm", "openai-compatible", "azure", "mistral", "hf", "vllm"} +VALID_OCR_BACKENDS = {"litellm", "openai-compatible", "azure", "mistral", "hf"} BENCHMARK_DATASET_COLUMNS = ( "image", "cleaned_transcription", @@ -73,8 +72,6 @@ class BenchmarkOptions: api_key: str | None = None base_url: str | None = None api_version: str | None = None - vllm_gpu_memory_utilization: float | None = None - vllm_cpu_offload_gb: float | None = None def dataset_subset(self) -> DatasetSubset: """Return the normalized subset filters for this benchmark run.""" @@ -108,8 +105,6 @@ def build_parser(*, add_help: bool = True) -> argparse.ArgumentParser: parser.add_argument("--api-key", default=None) parser.add_argument("--base-url", default=None) parser.add_argument("--api-version", default=None) - parser.add_argument("--vllm-gpu-memory-utilization", type=float, default=None) - parser.add_argument("--vllm-cpu-offload-gb", type=float, default=None) return parser @@ -130,8 +125,6 @@ def parse_args(argv: list[str] | None = None) -> BenchmarkOptions: api_key=namespace.api_key, base_url=namespace.base_url, api_version=namespace.api_version, - vllm_gpu_memory_utilization=namespace.vllm_gpu_memory_utilization, - vllm_cpu_offload_gb=namespace.vllm_cpu_offload_gb, ) @@ -148,25 +141,12 @@ def _validate_options(options: BenchmarkOptions) -> int: if options.backend == "litellm" and not options.model: logger.error("--model is required for backend=litellm.") return 1 - if options.backend == "openai-compatible" and ( - not options.model or not options.base_url or not options.api_key - ): - logger.error("--model, --base-url, and --api-key are required for backend=openai-compatible.") + if options.backend == "openai-compatible" and (not options.model or not options.base_url): + logger.error("--model and --base-url are required for backend=openai-compatible.") return 1 if options.backend == "hf" and not options.model: logger.error("--model is required for backend=hf.") return 1 - if options.backend == "vllm" and not options.model: - logger.error("--model is required for backend=vllm.") - return 1 - if options.vllm_gpu_memory_utilization is not None and not ( - 0.0 < options.vllm_gpu_memory_utilization <= 1.0 - ): - logger.error("--vllm-gpu-memory-utilization must be in the range (0, 1].") - return 1 - if options.vllm_cpu_offload_gb is not None and options.vllm_cpu_offload_gb < 0.0: - logger.error("--vllm-cpu-offload-gb must be non-negative.") - return 1 if options.backend == "azure" and (not options.endpoint or not options.api_key): logger.error("--endpoint and --api-key are required for backend=azure.") return 1 @@ -232,7 +212,6 @@ def _build_ocr_backend(options: BenchmarkOptions) -> OCRBackendLike: if options.backend == "openai-compatible": assert options.model is not None assert options.base_url is not None - assert options.api_key is not None return build_ocr_backend( OCRBackendSpec( provider="openai-compatible", @@ -267,20 +246,6 @@ def _build_ocr_backend(options: BenchmarkOptions) -> OCRBackendLike: options=HuggingFaceOptions(model_kwargs={"device_map": "auto", "torch_dtype": "auto"}), ) ) - if options.backend == "vllm": - assert options.model is not None - llm_kwargs: dict[str, object] = {} - if options.vllm_gpu_memory_utilization is not None: - llm_kwargs["gpu_memory_utilization"] = options.vllm_gpu_memory_utilization - if options.vllm_cpu_offload_gb is not None: - llm_kwargs["cpu_offload_gb"] = options.vllm_cpu_offload_gb - return build_ocr_backend( - OCRBackendSpec( - provider="vllm", - model=options.model, - options=VLLMOptions(llm_kwargs=llm_kwargs), - ) - ) assert options.api_key is not None return build_ocr_backend( OCRBackendSpec( From 63e3ca02ee3768704a3324313eeba7c57d19934d Mon Sep 17 00:00:00 2001 From: Sina Date: Sun, 5 Apr 2026 22:49:17 +0000 Subject: [PATCH 05/28] docs(repo): consolidate install and provider guidance --- README.md | 5 ++-- docs/benchmarking.md | 5 ++-- docs/cli.md | 28 +++++++++++++++++++---- docs/getting-started.md | 39 +++++++++++++++----------------- docs/guides/providers.md | 49 ++++++++++++++++++---------------------- docs/pypi.md | 21 +++++++++-------- 6 files changed, 81 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index f133067..bcb8ada 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ CHURRO is an OCR toolkit for historical document transcription, built to make handwritten and printed sources readable at high accuracy and lower cost. -It works with all major OCR proividers and vision-language models, and provides first-party support for the CHURRO 3B model and CHURRO-DS dataset. +It works with all major OCR providers and vision-language models, and provides first-party support for the CHURRO 3B model and CHURRO-DS dataset. [![Model](https://img.shields.io/badge/Model-CHURRO%203B-8A4FFF)](https://huggingface.co/stanford-oval/churro-3B) [![Dataset](https://img.shields.io/badge/Dataset-CHURRO--DS-0A7BBB)](https://huggingface.co/datasets/stanford-oval/churro-dataset) @@ -24,7 +24,8 @@ It works with all major OCR proividers and vision-language models, and provides ## Quick Try ```bash -pip install "churro-ocr[hf]" +uv tool install churro-ocr +churro-ocr install hf churro-ocr transcribe --image scan.png --backend hf --model stanford-oval/churro-3B ``` diff --git a/docs/benchmarking.md b/docs/benchmarking.md index f6a8ea8..e4da07f 100644 --- a/docs/benchmarking.md +++ b/docs/benchmarking.md @@ -27,7 +27,6 @@ By default, results are written under `workdir/results//`. - `--language` and `--document-type`: filter the benchmark subset before slicing - `--output-dir PATH`: override the default results directory - `--max-concurrency N`: cap the number of in-flight OCR requests -- `--vllm-gpu-memory-utilization` and `--vllm-cpu-offload-gb`: pass through selected vLLM runtime knobs ## Output Files @@ -51,7 +50,9 @@ That means `--language Arabic --offset 100 --input-size 50` selects rows 101 to ## Example Commands +Start a dedicated local vLLM server separately with `churro-ocr install vllm` and `churro-ocr serve-vllm --model ...`, then point the benchmark runner at `http://127.0.0.1:8000/v1`. + | Model | Model ID | Backend | Full command | | --- | --- | --- | --- | | Gemini 2.5 Pro | `vertex_ai/gemini-2.5-pro` | `litellm` | `pixi run python -m tooling.benchmarking.benchmark --backend litellm --dataset-split test --model vertex_ai/gemini-2.5-pro --output-dir workdir/results/test/litellm_vertex_ai_gemini-2.5-pro` | -| Qwen 3.5-0.8B | `Qwen/Qwen3.5-0.8B` | `vllm` | `pixi run python -m tooling.benchmarking.benchmark --backend vllm --dataset-split test --model Qwen/Qwen3.5-0.8B --output-dir workdir/results/test/vllm_Qwen_Qwen3.5-0.8B` | +| Qwen 3.5-0.8B | `Qwen/Qwen3.5-0.8B` | `openai-compatible` | `pixi run python -m tooling.benchmarking.benchmark --backend openai-compatible --dataset-split test --model Qwen/Qwen3.5-0.8B --base-url http://127.0.0.1:8000/v1 --output-dir workdir/results/test/openai-compatible_Qwen_Qwen3.5-0.8B` | diff --git a/docs/cli.md b/docs/cli.md index d47f119..d81e0aa 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -3,14 +3,36 @@ Use the CLI when you want a quick sanity check before writing Python code. Use `churro-ocr --help` or `python -m churro_ocr --help` to inspect the top-level commands. +Install Churro in [Getting Started](getting-started.md), and use +[Providers And Configuration](guides/providers.md) +for backend-specific runtime setup. ## Command Summary | Command | Use it when | | --- | --- | +| `install` | you want Churro to install an optional runtime into the active UV environment | +| `serve-vllm` | you want to start the dedicated Churro-managed vLLM runtime | | `transcribe` | you want OCR text for one image | | `extract-pages` | you want page crops from an image or PDF | +## `install` Examples + +### Install Local Transformers OCR + +```bash +churro-ocr install hf +``` + +### Install And Serve The Recommended vLLM Runtime + +```bash +churro-ocr install vllm +churro-ocr serve-vllm --model stanford-oval/churro-3B +``` + +Any extra flags after the command are forwarded to `vllm serve`. + ## `transcribe` Examples ### OCR One Image @@ -29,8 +51,7 @@ churro-ocr transcribe \ --image scan.png \ --backend openai-compatible \ --model local-model \ - --base-url http://127.0.0.1:8000/v1 \ - --api-key dummy + --base-url http://127.0.0.1:8000/v1 ``` ## `extract-pages` Examples @@ -73,11 +94,10 @@ churro-ocr extract-pages \ | `--backend` value | Required flags | Notes | | --- | --- | --- | | `litellm` | `--model` | Uses LiteLLM credentials and routing. `--base-url`, `--api-key`, and `--api-version` are optional transport overrides. | -| `openai-compatible` | `--model`, `--base-url`, `--api-key` | For local or self-hosted OpenAI-style servers. | +| `openai-compatible` | `--model`, `--base-url` | For local or self-hosted OpenAI-style servers. `--api-key` is optional. | | `azure` | `--endpoint`, `--api-key` | `--model` is optional. | | `mistral` | `--api-key` | `--model` defaults to `mistral-ocr-latest`. | | `hf` | `--model` | Local Transformers OCR. | -| `vllm` | `--model` | Local vLLM OCR. | ### `extract-pages` Detectors diff --git a/docs/getting-started.md b/docs/getting-started.md index 1dbf321..edbc304 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -11,36 +11,31 @@ | Run an end-to-end image or PDF OCR workflow | `DocumentOCRPipeline` | | Tune provider options directly | `build_ocr_backend(...)` + `OCRBackendSpec` | -## Install Only What You Need +## Install + +Use UV as the supported install path. ```bash -pip install churro-ocr -pip install "churro-ocr[llm]" -pip install "churro-ocr[local]" -pip install "churro-ocr[hf]" -pip install "churro-ocr[vllm]" -pip install "churro-ocr[azure]" -pip install "churro-ocr[mistral]" -pip install "churro-ocr[pdf]" -pip install "churro-ocr[all]" +uv tool install churro-ocr +# or, in a project: +uv add churro-ocr ``` -## Provider Extras +Then install the runtime for the backend you plan to use: -| Extra | Use it when | -| --- | --- | -| `llm` | you want hosted multimodal OCR and LLM-based page detection through LiteLLM | -| `local` | you have a local or self-hosted OpenAI-compatible server | -| `hf` | you want local Transformers inference in-process | -| `vllm` | you want higher-throughput local serving | -| `azure` | you want Azure Document Intelligence OCR or layout detection | -| `mistral` | you want Mistral OCR | -| `pdf` | you want PDF rasterization through `pypdfium2` | -| `all` | you want every supported backend and utility extra | +```bash +uv run churro-ocr install llm +uv run churro-ocr install hf +uv run churro-ocr install vllm +``` + +If you installed the CLI with `uv tool install churro-ocr`, drop the `uv run` prefix. +For the full provider/runtime matrix, use [Providers And Configuration](guides/providers.md). ## First OCR Example Use `OCRClient` when your input is already one page per image. +This example uses `provider="litellm"`, so install the `llm` runtime first. ```python from churro_ocr.ocr import OCRClient @@ -67,6 +62,8 @@ When an API accepts both `image` and `image_path`, pass exactly one of them. Use the CLI when you want to confirm a model or backend before writing Python code. ```bash +uv tool install churro-ocr +churro-ocr install hf churro-ocr transcribe \ --image scan.png \ --backend hf \ diff --git a/docs/guides/providers.md b/docs/guides/providers.md index 08e5b6f..f0adbc8 100644 --- a/docs/guides/providers.md +++ b/docs/guides/providers.md @@ -15,14 +15,16 @@ backend = build_ocr_backend( ## Which OCR Backend Should You Use? -| Provider | Install extra | Good default when | +Install the base package first as shown in [Getting Started](../getting-started.md). +This page is the source of truth for matching providers to runtime targets. + +| Provider | Install command | Good default when | | --- | --- | --- | -| `litellm` | `llm` | you want hosted multimodal models routed through LiteLLM | -| `openai-compatible` | `local` | you have a local or self-hosted OpenAI-style server | -| `hf` | `hf` | you want local Transformers inference in-process | -| `vllm` | `vllm` | you want higher-throughput local serving | -| `azure` | `azure` | you want Azure Document Intelligence OCR | -| `mistral` | `mistral` | you want Mistral OCR | +| `litellm` | `churro-ocr install llm` | you want hosted multimodal models routed through LiteLLM | +| `openai-compatible` | `churro-ocr install local` | you have a local or self-hosted OpenAI-style server | +| `hf` | `churro-ocr install hf` | you want local Transformers inference in-process | +| `azure` | `churro-ocr install azure` | you want Azure Document Intelligence OCR | +| `mistral` | `churro-ocr install mistral` | you want Mistral OCR | ## Recommended Starting Points @@ -30,7 +32,7 @@ backend = build_ocr_backend( | --- | --- | --- | | hosted OCR | `litellm` + `vertex_ai/gemini-2.5-flash` | easiest hosted path with the standard builder interface | | local OCR | `hf` + `stanford-oval/churro-3B` | first-party local model support in-process | -| higher-throughput local serving | `vllm` + `stanford-oval/churro-3B` | better fit when you want a served local backend | +| higher-throughput local serving | `openai-compatible` + `serve-vllm` + `stanford-oval/churro-3B` | recommended path when you want a dedicated served local backend | ## Hosted Providers @@ -102,6 +104,8 @@ backend = build_ocr_backend( ## Local And Self-Hosted Providers +Before using a local or self-hosted provider, install the matching runtime from the table above. + ### OpenAI-compatible ```python @@ -117,12 +121,18 @@ backend = build_ocr_backend( model="local-model", transport=LiteLLMTransportConfig( api_base="http://127.0.0.1:8000/v1", - api_key="dummy", ), ) ) ``` +This is also the recommended way to talk to a dedicated vLLM server: + +```bash +uv run churro-ocr install vllm +uv run churro-ocr serve-vllm --model stanford-oval/churro-3B +``` + ### Hugging Face ```python @@ -139,26 +149,12 @@ backend = build_ocr_backend( ) ``` -### vLLM - -```python -from churro_ocr.providers import OCRBackendSpec, VLLMOptions, build_ocr_backend - -backend = build_ocr_backend( - OCRBackendSpec( - provider="vllm", - model="stanford-oval/churro-3B", - options=VLLMOptions(), - ) -) -``` - ## `OCRBackendSpec` Reference | Field | Meaning | | --- | --- | -| `provider` | One of `litellm`, `openai-compatible`, `azure`, `mistral`, `hf`, or `vllm`. | -| `model` | Required for `litellm`, `openai-compatible`, `hf`, and `vllm`. Optional for `azure`. Defaults to `mistral-ocr-latest` when omitted for `mistral`. | +| `provider` | One of `litellm`, `openai-compatible`, `azure`, `mistral`, or `hf`. | +| `model` | Required for `litellm`, `openai-compatible`, and `hf`. Optional for `azure`. Defaults to `mistral-ocr-latest` when omitted for `mistral`. | | `profile` | `None`, a built-in profile name, or a custom `OCRModelProfile`. | | `transport` | Shared request transport config for LiteLLM-based providers. | | `options` | Provider-specific dataclass matching `provider`. | @@ -167,10 +163,9 @@ backend = build_ocr_backend( | Type | Used by | Required fields | Notes | | --- | --- | --- | --- | -| `LiteLLMTransportConfig` | `litellm`, `openai-compatible`, `LLMPageDetector` | None at the dataclass level | Use this for transport, credentials, and completion settings. | +| `LiteLLMTransportConfig` | `litellm`, `openai-compatible`, `LLMPageDetector` | None at the dataclass level | Use this for transport, credentials, and completion settings. `api_base` is required for `openai-compatible`; `api_key` is optional. | | `OpenAICompatibleOptions` | `openai-compatible` | None | Use `model_prefix` when your local server expects a provider prefix. | | `HuggingFaceOptions` | `hf` | None | Carries runtime, processor, generation, and template options. | -| `VLLMOptions` | `vllm` | None | Carries runtime and sampling settings for vLLM. | | `AzureDocumentIntelligenceOptions` | `azure` | `endpoint`, `api_key` | `model` is optional for Azure OCR in `OCRBackendSpec`. | | `MistralOptions` | `mistral` | `api_key` | `model` defaults to `mistral-ocr-latest` when omitted. | diff --git a/docs/pypi.md b/docs/pypi.md index 6d55bf2..28e1359 100644 --- a/docs/pypi.md +++ b/docs/pypi.md @@ -6,20 +6,19 @@ Full documentation and project overview live at https://stanford-oval.github.io/ ## Install -Install only the pieces you need: +Use UV as the supported install path. ```bash -pip install churro-ocr -pip install "churro-ocr[llm]" -pip install "churro-ocr[local]" -pip install "churro-ocr[hf]" -pip install "churro-ocr[vllm]" -pip install "churro-ocr[azure]" -pip install "churro-ocr[mistral]" -pip install "churro-ocr[pdf]" -pip install "churro-ocr[all]" +uv tool install churro-ocr +# or, in a project: +uv add churro-ocr ``` +Runtime setup and provider-specific install commands are in +[Getting Started](https://stanford-oval.github.io/Churro/getting-started.html) +and +[Providers And Configuration](https://stanford-oval.github.io/Churro/guides/providers.html). + ## Which API Should You Use? | Goal | API | @@ -31,6 +30,8 @@ pip install "churro-ocr[all]" ## Quick Start +This example assumes you already installed the runtime for the provider you want to use. + ```python from churro_ocr.ocr import OCRClient from churro_ocr.providers import OCRBackendSpec, build_ocr_backend From 7cfcc1e1b595719d196dd1af1fd5730d67d85da6 Mon Sep 17 00:00:00 2001 From: Sina Date: Sun, 5 Apr 2026 23:18:58 +0000 Subject: [PATCH 06/28] fix(docs): move badges to the top of the README file --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index bcb8ada..77f2fde 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,5 @@ # CHURRO logo CHURRO -CHURRO is an OCR toolkit for historical document transcription, built to make handwritten and printed sources readable at high accuracy and lower cost. - -It works with all major OCR providers and vision-language models, and provides first-party support for the CHURRO 3B model and CHURRO-DS dataset. - [![Model](https://img.shields.io/badge/Model-CHURRO%203B-8A4FFF)](https://huggingface.co/stanford-oval/churro-3B) [![Dataset](https://img.shields.io/badge/Dataset-CHURRO--DS-0A7BBB)](https://huggingface.co/datasets/stanford-oval/churro-dataset) [![Paper](https://img.shields.io/badge/Paper-arXiv-B31B1B)](https://arxiv.org/abs/2509.19768) @@ -11,6 +7,10 @@ It works with all major OCR providers and vision-language models, and provides f [![Leaderboard](https://img.shields.io/badge/Leaderboard-Benchmark%20Snapshot-6B7280)](https://stanford-oval.github.io/Churro/leaderboard.html) [![GitHub Stars](https://img.shields.io/github/stars/stanford-oval/churro?style=social)](https://github.com/stanford-oval/churro/stargazers) +CHURRO is an OCR toolkit for historical document transcription, built to make handwritten and printed sources readable at high accuracy and lower cost. + +It works with all major OCR providers and vision-language models, and provides first-party support for the CHURRO 3B model and CHURRO-DS dataset. + - CHURRO 3B exceeds the accuracy of Gemini 2.5 Pro at 15.5x lower cost. - CHURRO-DS contains ~100K pages from 155 historical collections spanning 22 centuries and 46 language clusters. From aa2ba0e3757144b821c2c3e6aced69b91843cdd8 Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 6 Apr 2026 00:07:38 +0000 Subject: [PATCH 07/28] refactor(runtime)!: remove managed vllm runtime support Remove the repo-managed vLLM install and serve path in favor of external OpenAI-compatible servers. BREAKING CHANGE: The `vllm` extra, `churro-ocr install vllm`, and `churro-ocr serve-vllm` were removed. Use an external vLLM OpenAI-compatible server with the `openai-compatible` backend instead. --- docs/benchmarking.md | 2 +- docs/cli.md | 12 +-- docs/getting-started.md | 2 +- docs/guides/providers.md | 9 +- pixi.lock | 3 +- pyproject.toml | 3 - scripts/package_check.py | 10 +- src/churro_ocr/_internal/install.py | 139 ---------------------------- src/churro_ocr/cli.py | 37 -------- tests/test_cli.py | 54 +---------- tests/test_cli_contract.py | 2 +- tests/test_install.py | 118 ++--------------------- tests/test_package_check.py | 11 +-- tests/test_tooling_benchmark.py | 4 +- 14 files changed, 25 insertions(+), 381 deletions(-) diff --git a/docs/benchmarking.md b/docs/benchmarking.md index e4da07f..d1e8263 100644 --- a/docs/benchmarking.md +++ b/docs/benchmarking.md @@ -50,7 +50,7 @@ That means `--language Arabic --offset 100 --input-size 50` selects rows 101 to ## Example Commands -Start a dedicated local vLLM server separately with `churro-ocr install vllm` and `churro-ocr serve-vllm --model ...`, then point the benchmark runner at `http://127.0.0.1:8000/v1`. +If you want to benchmark a vLLM-served model, run vLLM separately and point `--backend openai-compatible` at its OpenAI-compatible endpoint. See the [official vLLM serving docs](https://docs.vllm.ai/en/stable/serving/openai_compatible_server.html). | Model | Model ID | Backend | Full command | | --- | --- | --- | --- | diff --git a/docs/cli.md b/docs/cli.md index d81e0aa..f7a1a95 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -12,7 +12,6 @@ for backend-specific runtime setup. | Command | Use it when | | --- | --- | | `install` | you want Churro to install an optional runtime into the active UV environment | -| `serve-vllm` | you want to start the dedicated Churro-managed vLLM runtime | | `transcribe` | you want OCR text for one image | | `extract-pages` | you want page crops from an image or PDF | @@ -24,15 +23,6 @@ for backend-specific runtime setup. churro-ocr install hf ``` -### Install And Serve The Recommended vLLM Runtime - -```bash -churro-ocr install vllm -churro-ocr serve-vllm --model stanford-oval/churro-3B -``` - -Any extra flags after the command are forwarded to `vllm serve`. - ## `transcribe` Examples ### OCR One Image @@ -54,6 +44,8 @@ churro-ocr transcribe \ --base-url http://127.0.0.1:8000/v1 ``` +For vLLM, serve the model separately with its OpenAI-compatible server and then use this same `openai-compatible` route. See the [official vLLM serving docs](https://docs.vllm.ai/en/stable/serving/openai_compatible_server.html). + ## `extract-pages` Examples ### Extract Pages From An Image diff --git a/docs/getting-started.md b/docs/getting-started.md index edbc304..92cef8b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -26,7 +26,7 @@ Then install the runtime for the backend you plan to use: ```bash uv run churro-ocr install llm uv run churro-ocr install hf -uv run churro-ocr install vllm +uv run churro-ocr install local ``` If you installed the CLI with `uv tool install churro-ocr`, drop the `uv run` prefix. diff --git a/docs/guides/providers.md b/docs/guides/providers.md index f0adbc8..3b1640a 100644 --- a/docs/guides/providers.md +++ b/docs/guides/providers.md @@ -32,7 +32,7 @@ This page is the source of truth for matching providers to runtime targets. | --- | --- | --- | | hosted OCR | `litellm` + `vertex_ai/gemini-2.5-flash` | easiest hosted path with the standard builder interface | | local OCR | `hf` + `stanford-oval/churro-3B` | first-party local model support in-process | -| higher-throughput local serving | `openai-compatible` + `serve-vllm` + `stanford-oval/churro-3B` | recommended path when you want a dedicated served local backend | +| higher-throughput local serving | `openai-compatible` + your own OpenAI-style server | good when you already run a served local backend such as vLLM | ## Hosted Providers @@ -126,12 +126,7 @@ backend = build_ocr_backend( ) ``` -This is also the recommended way to talk to a dedicated vLLM server: - -```bash -uv run churro-ocr install vllm -uv run churro-ocr serve-vllm --model stanford-oval/churro-3B -``` +If you want to use vLLM, serve it separately and point this backend at that server's OpenAI-compatible endpoint. See the [official vLLM serving docs](https://docs.vllm.ai/en/stable/serving/openai_compatible_server.html). ### Hugging Face diff --git a/pixi.lock b/pixi.lock index 3eb0c09..f4542de 100644 --- a/pixi.lock +++ b/pixi.lock @@ -776,7 +776,7 @@ packages: - pypi: ./ name: churro-ocr version: 0.2.0 - sha256: c9aab6c57f839ce8119b38b864926707e9f7b1e55548a4dfc0421eb8520d5ba3 + sha256: f05d8a5f234f0cdb08bcdbf205bea3cdcfecd53b1ac058686bb9341e92719637 requires_dist: - loguru>=0.7.2,<1 - pillow>=10.4.0,<12 @@ -787,7 +787,6 @@ packages: - azure-ai-documentintelligence==1.0.2 ; extra == 'azure' - qwen-vl-utils ; extra == 'hf' - transformers>=4.57.0,<5 ; extra == 'hf' - - vllm>=0.18,<1 ; extra == 'vllm' - mistralai>=1.6.0,<2 ; extra == 'mistral' - litellm[caching]==1.82.3 ; extra == 'local' - pypdfium2>=5,<6 ; extra == 'pdf' diff --git a/pyproject.toml b/pyproject.toml index 568ef00..367c298 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,9 +35,6 @@ hf = [ "qwen-vl-utils", "transformers>=4.57.0,<5", ] -vllm = [ - "vllm>=0.18,<1", -] mistral = [ "mistralai>=1.6.0,<2", ] diff --git a/scripts/package_check.py b/scripts/package_check.py index 865ae77..6dd41cd 100644 --- a/scripts/package_check.py +++ b/scripts/package_check.py @@ -35,7 +35,6 @@ "local", "mistral", "pdf", - "vllm", } FORBIDDEN_ARTIFACT_SEGMENTS = ("/tests/", "/tooling/", "/scripts/") FORBIDDEN_ARTIFACT_SUFFIXES = ("PYPI_AUDIT.md",) @@ -146,13 +145,10 @@ def _iter_requirements_for_extra(metadata_message: Message, extra: str) -> list[ def _assert_local_runtime_packaging_policy(metadata_message: Message) -> None: disallowed_runtime_reqs: list[str] = [] - for extra in ("hf", "all"): + for extra in EXPECTED_EXTRAS: for requirement in _iter_requirements_for_extra(metadata_message, extra): normalized_name = requirement.name.replace("_", "-").lower() - if extra == "all" and normalized_name == "vllm": - disallowed_runtime_reqs.append(f"{extra}:{requirement}") - continue - if normalized_name in {"torch", "torchvision"}: + if normalized_name in {"torch", "torchvision", "vllm"}: disallowed_runtime_reqs.append(f"{extra}:{requirement}") continue if normalized_name == "transformers" and "torch" in requirement.extras: @@ -160,7 +156,7 @@ def _assert_local_runtime_packaging_policy(metadata_message: Message) -> None: if disallowed_runtime_reqs: formatted = ", ".join(sorted(disallowed_runtime_reqs)) raise RuntimeError( - "PyPI extras for active-environment runtimes must not pin a local PyTorch or vLLM runtime. " + "PyPI extras for active-environment runtimes must not pin local PyTorch or vLLM runtimes. " f"Found disallowed requirements: {formatted}." ) diff --git a/src/churro_ocr/_internal/install.py b/src/churro_ocr/_internal/install.py index dff132f..e1e7ff0 100644 --- a/src/churro_ocr/_internal/install.py +++ b/src/churro_ocr/_internal/install.py @@ -2,25 +2,20 @@ from __future__ import annotations -import os import shutil import subprocess import sys from dataclasses import dataclass from importlib import metadata -from pathlib import Path from typing import Final from churro_ocr.errors import ConfigurationError PROJECT_DISTRIBUTION_NAME: Final[str] = "churro-ocr" -VLLM_RUNTIME_DIR_ENV: Final[str] = "CHURRO_OCR_VLLM_RUNTIME_DIR" -DEFAULT_VLLM_RUNTIME_DIR: Final[Path] = Path.home() / ".cache" / "churro-ocr" / "runtimes" / "vllm" INSTALL_TARGETS: Final[tuple[str, ...]] = ( "llm", "local", "hf", - "vllm", "azure", "mistral", "pdf", @@ -30,7 +25,6 @@ "llm": ("llm",), "local": ("local",), "hf": ("hf",), - "vllm": ("local",), "azure": ("azure",), "mistral": ("mistral",), "pdf": ("pdf",), @@ -47,7 +41,6 @@ class RuntimeInstallResult: target: str executed_commands: tuple[tuple[str, ...], ...] notes: tuple[str, ...] = () - vllm_runtime_dir: Path | None = None def install_command_hint(target: str) -> str: @@ -55,26 +48,10 @@ def install_command_hint(target: str) -> str: return f"Run `churro-ocr install {target}`." -def recommended_vllm_runtime_hint() -> str: - """Return the short user-facing hint for the dedicated vLLM runtime.""" - return "Run `churro-ocr install vllm`." - - -def resolve_vllm_runtime_dir(runtime_dir: Path | None = None) -> Path: - """Resolve the dedicated vLLM runtime directory.""" - if runtime_dir is not None: - return runtime_dir.expanduser().resolve() - override = os.environ.get(VLLM_RUNTIME_DIR_ENV) - if override: - return Path(override).expanduser().resolve() - return DEFAULT_VLLM_RUNTIME_DIR - - def install_runtime_dependencies( *, target: str, torch_backend: str = "auto", - vllm_runtime_dir: Path | None = None, ) -> RuntimeInstallResult: """Install one of the supported optional runtime targets with UV.""" normalized_target = target.strip().lower() @@ -119,77 +96,13 @@ def install_runtime_dependencies( ) ) - resolved_runtime_dir: Path | None = None - if normalized_target in {"vllm", "all"}: - resolved_runtime_dir = resolve_vllm_runtime_dir(vllm_runtime_dir) - runtime_python = _ensure_runtime_python(resolved_runtime_dir) - executed_commands.append( - _run_command( - [ - uv_executable, - "pip", - "install", - "--python", - str(runtime_python), - "--upgrade", - f"--torch-backend={torch_backend}", - _requirement_for_extra("vllm", package_name="vllm"), - ] - ) - ) - notes.append( - "The dedicated vLLM runtime is ready. Start it with `churro-ocr serve-vllm --model `." - ) - notes.append( - "Use `--backend openai-compatible --base-url http://127.0.0.1:8000/v1` " - "to connect to the served runtime." - ) - return RuntimeInstallResult( target=normalized_target, executed_commands=tuple(executed_commands), notes=tuple(notes), - vllm_runtime_dir=resolved_runtime_dir, ) -def serve_vllm_runtime( - *, - model: str, - host: str = "127.0.0.1", - port: int = 8000, - runtime_dir: Path | None = None, - extra_args: tuple[str, ...] = (), -) -> int: - """Run `python -m vllm serve` from the dedicated Churro vLLM runtime.""" - resolved_runtime_dir = resolve_vllm_runtime_dir(runtime_dir) - runtime_python = _venv_python(resolved_runtime_dir) - if not runtime_python.exists(): - raise ConfigurationError( - f"No dedicated vLLM runtime exists at {resolved_runtime_dir}. {recommended_vllm_runtime_hint()}" - ) - - command = [ - str(runtime_python), - "-m", - "vllm", - "serve", - model, - "--host", - host, - "--port", - str(port), - *extra_args, - ] - try: - completed = subprocess.run(command, check=False) - except OSError as exc: # pragma: no cover - depends on host process state - raise ConfigurationError( - f"Failed to launch the dedicated vLLM runtime at {resolved_runtime_dir}: {exc}" - ) from exc - return int(completed.returncode) - - def _require_uv_executable() -> str: uv_executable = shutil.which("uv") if uv_executable is None: @@ -223,40 +136,6 @@ def _requirements_for_extra(extras: tuple[str, ...]) -> list[str]: return selected -def _requirement_for_extra(extra: str, *, package_name: str) -> str: - normalized_package_name = package_name.replace("_", "-").lower() - for requirement in _requirements_for_extra((extra,)): - dependency_name = _requirement_name(requirement) - if dependency_name.replace("_", "-").lower() == normalized_package_name: - return requirement - raise ConfigurationError( - f"Could not resolve the `{package_name}` dependency from the `{extra}` extra metadata." - ) - - -def _ensure_runtime_python(runtime_dir: Path) -> Path: - runtime_python = _venv_python(runtime_dir) - if runtime_python.exists(): - return runtime_python - - runtime_dir.parent.mkdir(parents=True, exist_ok=True) - try: - subprocess.run( - [sys.executable, "-m", "venv", str(runtime_dir)], - check=True, - ) - except (OSError, subprocess.CalledProcessError) as exc: - raise ConfigurationError( - f"Failed to create the dedicated vLLM runtime at {runtime_dir}: {exc}" - ) from exc - - if not runtime_python.exists(): - raise ConfigurationError( - f"Created the dedicated vLLM runtime at {runtime_dir}, but no Python interpreter was found." - ) - return runtime_python - - def _run_command(command: list[str]) -> tuple[str, ...]: try: subprocess.run(command, check=True) @@ -264,21 +143,3 @@ def _run_command(command: list[str]) -> tuple[str, ...]: rendered_command = " ".join(command) raise ConfigurationError(f"Command failed: {rendered_command}") from exc return tuple(command) - - -def _requirement_name(requirement: str) -> str: - name_chars: list[str] = [] - for character in requirement: - if character.isalnum() or character in {"-", "_", "."}: - name_chars.append(character) - continue - if character == "[": - break - break - return "".join(name_chars) - - -def _venv_python(venv_dir: Path) -> Path: - if sys.platform == "win32": - return venv_dir / "Scripts" / "python.exe" - return venv_dir / "bin" / "python" diff --git a/src/churro_ocr/cli.py b/src/churro_ocr/cli.py index 8c846b3..5c4d41f 100644 --- a/src/churro_ocr/cli.py +++ b/src/churro_ocr/cli.py @@ -9,7 +9,6 @@ from churro_ocr._internal.install import ( INSTALL_TARGETS, install_runtime_dependencies, - serve_vllm_runtime, ) from churro_ocr.errors import ConfigurationError from churro_ocr.ocr import OCRClient @@ -236,58 +235,22 @@ def install_command( "auto", help="PyTorch backend passed through to uv when a local runtime needs torch.", ), - vllm_runtime_dir: Path | None = typer.Option( - None, - help="Override the dedicated vLLM runtime directory for `install vllm`.", - ), ) -> None: """Install optional runtime dependencies with uv.""" try: result = install_runtime_dependencies( target=target, torch_backend=torch_backend, - vllm_runtime_dir=vllm_runtime_dir, ) except ConfigurationError as exc: typer.echo(str(exc), err=True) raise typer.Exit(code=1) from exc typer.echo(f"Installed runtime target: {result.target}") - if result.vllm_runtime_dir is not None: - typer.echo(f"Dedicated vLLM runtime: {result.vllm_runtime_dir}") for note in result.notes: typer.echo(note) -@app.command( - "serve-vllm", - context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, -) -def serve_vllm_command( - ctx: typer.Context, - model: str = typer.Option(..., help="Model identifier to serve from the dedicated vLLM runtime."), - host: str = typer.Option("127.0.0.1", help="Bind address passed to `vllm serve`."), - port: int = typer.Option(8000, help="Port passed to `vllm serve`."), - vllm_runtime_dir: Path | None = typer.Option( - None, - help="Override the dedicated vLLM runtime directory.", - ), -) -> None: - """Start the dedicated vLLM runtime managed by `churro-ocr install vllm`.""" - try: - exit_code = serve_vllm_runtime( - model=model, - host=host, - port=port, - runtime_dir=vllm_runtime_dir, - extra_args=tuple(ctx.args), - ) - except ConfigurationError as exc: - typer.echo(str(exc), err=True) - raise typer.Exit(code=1) from exc - raise typer.Exit(code=exit_code) - - def main() -> None: """Console-script entrypoint.""" app() diff --git a/tests/test_cli.py b/tests/test_cli.py index 0df4647..b26be7e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -244,16 +244,14 @@ def test_build_ocr_backend_aligns_templates_for_olmocr() -> None: def test_install_command_invokes_runtime_installer( monkeypatch: pytest.MonkeyPatch, cli_runner, - tmp_path: Path, ) -> None: captured: dict[str, object] = {} def _fake_install_runtime_dependencies(**kwargs: object) -> SimpleNamespace: captured.update(kwargs) return SimpleNamespace( - target="vllm", + target="local", notes=("runtime ready",), - vllm_runtime_dir=tmp_path / "vllm-runtime", ) monkeypatch.setattr( @@ -265,7 +263,7 @@ def _fake_install_runtime_dependencies(**kwargs: object) -> SimpleNamespace: app, [ "install", - "vllm", + "local", "--torch-backend", "cu126", ], @@ -273,11 +271,10 @@ def _fake_install_runtime_dependencies(**kwargs: object) -> SimpleNamespace: assert result.exit_code == 0 assert captured == { - "target": "vllm", + "target": "local", "torch_backend": "cu126", - "vllm_runtime_dir": None, } - assert "Installed runtime target: vllm" in result.output + assert "Installed runtime target: local" in result.output assert "runtime ready" in result.output @@ -299,47 +296,6 @@ def _raise_configuration_error(**_: object) -> SimpleNamespace: assert "missing uv" in result.output -def test_serve_vllm_command_forwards_extra_args( - monkeypatch: pytest.MonkeyPatch, - cli_runner, - tmp_path: Path, -) -> None: - captured: dict[str, object] = {} - - def _fake_serve_vllm_runtime(**kwargs: object) -> int: - captured.update(kwargs) - return 0 - - monkeypatch.setattr( - "churro_ocr.cli.serve_vllm_runtime", - _fake_serve_vllm_runtime, - ) - - result = cli_runner.invoke( - app, - [ - "serve-vllm", - "--model", - "stanford-oval/churro-3B", - "--host", - "0.0.0.0", - "--port", - "9000", - "--tensor-parallel-size", - "2", - ], - ) - - assert result.exit_code == 0 - assert captured == { - "model": "stanford-oval/churro-3B", - "host": "0.0.0.0", - "port": 9000, - "runtime_dir": None, - "extra_args": ("--tensor-parallel-size", "2"), - } - - def test_module_entrypoint_help() -> None: result = subprocess.run( [sys.executable, "-m", "churro_ocr", "--help"], @@ -352,7 +308,6 @@ def test_module_entrypoint_help() -> None: assert "transcribe" in result.stdout assert "extract-pages" in result.stdout assert "install" in result.stdout - assert "serve-vllm" in result.stdout def test_console_script_help() -> None: @@ -370,4 +325,3 @@ def test_console_script_help() -> None: assert "transcribe" in result.stdout assert "extract-pages" in result.stdout assert "install" in result.stdout - assert "serve-vllm" in result.stdout diff --git a/tests/test_cli_contract.py b/tests/test_cli_contract.py index f932706..4ff9856 100644 --- a/tests/test_cli_contract.py +++ b/tests/test_cli_contract.py @@ -92,7 +92,7 @@ def _fake_build_ocr_backend(spec: cli_module.OCRBackendSpec) -> _FakeBackend: assert spec.transport.api_key is None -@pytest.mark.parametrize("backend", ["unsupported", "vllm"]) +@pytest.mark.parametrize("backend", ["unsupported"]) def test_transcribe_cli_rejects_unsupported_backend( sample_image_path: Path, backend: str, diff --git a/tests/test_install.py b/tests/test_install.py index a779656..ab06e95 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -1,7 +1,6 @@ from __future__ import annotations import sys -from pathlib import Path from types import SimpleNamespace import pytest @@ -43,7 +42,6 @@ def test_install_runtime_dependencies_installs_hf_and_torch_with_uv( ) assert result.target == "hf" - assert result.vllm_runtime_dir is None assert commands == [ [ "/usr/bin/uv", @@ -69,48 +67,26 @@ def test_install_runtime_dependencies_installs_hf_and_torch_with_uv( ] -def test_install_runtime_dependencies_installs_local_client_and_dedicated_vllm_runtime( +def test_install_runtime_dependencies_installs_local_client_with_uv( monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, ) -> None: commands: list[list[str]] = [] - runtime_dir = tmp_path / "vllm-runtime" - runtime_python = runtime_dir / "bin" / "python" monkeypatch.setattr( install_module.metadata, "distribution", - lambda _: _FakeDistribution( - requires=[ - 'litellm[caching]==1.82.3; extra == "local"', - 'vllm>=0.18,<1; extra == "vllm"', - ] - ), + lambda _: _FakeDistribution(requires=['litellm[caching]==1.82.3; extra == "local"']), ) monkeypatch.setattr(install_module.shutil, "which", lambda name: "/usr/bin/uv" if name == "uv" else None) - - def _fake_ensure_runtime_python(path: Path) -> Path: - assert path == runtime_dir.resolve() - return runtime_python - - monkeypatch.setattr( - install_module, - "_ensure_runtime_python", - _fake_ensure_runtime_python, - ) monkeypatch.setattr( install_module.subprocess, "run", lambda command, check=True: commands.append(list(command)) or SimpleNamespace(returncode=0), ) - result = install_module.install_runtime_dependencies( - target="vllm", - vllm_runtime_dir=runtime_dir, - ) + result = install_module.install_runtime_dependencies(target="local") - assert result.target == "vllm" - assert result.vllm_runtime_dir == runtime_dir.resolve() + assert result.target == "local" assert commands == [ [ "/usr/bin/uv", @@ -120,19 +96,9 @@ def _fake_ensure_runtime_python(path: Path) -> Path: sys.executable, "--upgrade", "litellm[caching]==1.82.3", - ], - [ - "/usr/bin/uv", - "pip", - "install", - "--python", - str(runtime_python), - "--upgrade", - "--torch-backend=auto", - "vllm>=0.18,<1", - ], + ] ] - assert result.notes + assert result.notes == () def test_install_runtime_dependencies_requires_uv(monkeypatch: pytest.MonkeyPatch) -> None: @@ -140,75 +106,3 @@ def test_install_runtime_dependencies_requires_uv(monkeypatch: pytest.MonkeyPatc with pytest.raises(ConfigurationError, match="requires `uv` on PATH"): install_module.install_runtime_dependencies(target="hf") - - -def test_resolve_vllm_runtime_dir_prefers_explicit_path( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - monkeypatch.setenv(install_module.VLLM_RUNTIME_DIR_ENV, str(tmp_path / "from-env")) - - resolved = install_module.resolve_vllm_runtime_dir(tmp_path / "from-arg") - - assert resolved == (tmp_path / "from-arg").resolve() - - -def test_resolve_vllm_runtime_dir_uses_environment_override( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - monkeypatch.setenv(install_module.VLLM_RUNTIME_DIR_ENV, str(tmp_path / "from-env")) - - resolved = install_module.resolve_vllm_runtime_dir() - - assert resolved == (tmp_path / "from-env").resolve() - - -def test_serve_vllm_runtime_runs_module_from_dedicated_env( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - runtime_dir = tmp_path / "runtime" - runtime_python = runtime_dir / "bin" / "python" - runtime_python.parent.mkdir(parents=True, exist_ok=True) - runtime_python.write_text("") - - commands: list[list[str]] = [] - monkeypatch.setattr( - install_module.subprocess, - "run", - lambda command, check=False: commands.append(list(command)) or SimpleNamespace(returncode=0), - ) - - exit_code = install_module.serve_vllm_runtime( - model="stanford-oval/churro-3B", - host="0.0.0.0", - port=9000, - runtime_dir=runtime_dir, - extra_args=("--tensor-parallel-size", "2"), - ) - - assert exit_code == 0 - assert commands == [ - [ - str(runtime_python), - "-m", - "vllm", - "serve", - "stanford-oval/churro-3B", - "--host", - "0.0.0.0", - "--port", - "9000", - "--tensor-parallel-size", - "2", - ] - ] - - -def test_serve_vllm_runtime_requires_installed_runtime(tmp_path: Path) -> None: - with pytest.raises(ConfigurationError, match="install vllm"): - install_module.serve_vllm_runtime( - model="stanford-oval/churro-3B", - runtime_dir=tmp_path / "missing-runtime", - ) diff --git a/tests/test_package_check.py b/tests/test_package_check.py index e208efe..84ef26f 100644 --- a/tests/test_package_check.py +++ b/tests/test_package_check.py @@ -32,7 +32,7 @@ def _always_missing(_: str): monkeypatch.setattr(package_check.metadata, "distribution", _always_missing) - package_check._audit_dependency_licenses(_metadata_message('vllm<1,>=0.18; extra == "vllm"')) + package_check._audit_dependency_licenses(_metadata_message('mistralai<2,>=1.6.0; extra == "mistral"')) def test_license_audit_fails_for_missing_base_dependency(monkeypatch: pytest.MonkeyPatch) -> None: @@ -51,12 +51,5 @@ def test_local_runtime_packaging_policy_rejects_direct_torch_runtime_pin() -> No 'torchvision; extra == "all"', ) - with pytest.raises(RuntimeError, match="must not pin a local PyTorch or vLLM runtime"): - package_check._assert_local_runtime_packaging_policy(metadata_message) - - -def test_local_runtime_packaging_policy_rejects_vllm_in_all_extra() -> None: - metadata_message = _metadata_message('vllm<1,>=0.18; extra == "all"') - - with pytest.raises(RuntimeError, match="must not pin a local PyTorch or vLLM runtime"): + with pytest.raises(RuntimeError, match="must not pin local PyTorch"): package_check._assert_local_runtime_packaging_policy(metadata_message) diff --git a/tests/test_tooling_benchmark.py b/tests/test_tooling_benchmark.py index 04f1825..48c733e 100644 --- a/tests/test_tooling_benchmark.py +++ b/tests/test_tooling_benchmark.py @@ -145,12 +145,12 @@ def test_parse_args_accepts_subset_filters() -> None: assert options.document_type == "print" -def test_parse_args_rejects_removed_vllm_backend() -> None: +def test_parse_args_rejects_unsupported_backend() -> None: with pytest.raises(SystemExit): benchmark.parse_args( [ "--backend", - "vllm", + "unsupported", "--dataset-split", "dev", "--model", From be5249ce01243dfbcdb224960b9ee6b129d5b545 Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 6 Apr 2026 00:22:54 +0000 Subject: [PATCH 08/28] build(hf): support transformers 5.x Update the published HF dependency range to `transformers>=5,<6` and refresh the lockfile accordingly. Add and align test coverage for the Transformers v5 chat-template contract and package metadata expectations. --- pixi.lock | 571 ++++++++++----------------------- pyproject.toml | 4 +- tests/test_install.py | 4 +- tests/test_internal_helpers.py | 39 +++ tests/test_package_check.py | 2 +- 5 files changed, 218 insertions(+), 402 deletions(-) diff --git a/pixi.lock b/pixi.lock index f4542de..1b12f56 100644 --- a/pixi.lock +++ b/pixi.lock @@ -103,7 +103,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/df/9a/a24b26dc8a65f0ecc0fe5be981a19e61e7ca963b85e062c083f3a9100529/hf_xet-1.4.3-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - pypi: https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/a8/af/48ac8483240de756d2438c380746e7130d1c6f75802ef22f3c6d49982787/huggingface_hub-0.36.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/73/37/0d15d16150e1829f3e90962c99f28257f6de9e526a680b4c6f5acdb54fd2/huggingface_hub-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/42/77/de194443bf38daed9452139e960c632b0ef9f9a5dd9ce605fdf18ca9f1b1/id-1.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/46/33/92ef41c6fad0233e41d3d84ba8e8ad18d1780f1e5d99b3c683e6d7f98b63/identify-2.6.18-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl @@ -204,7 +204,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/f5/de/9341a6d7a8f1b448573bbf3425fa57669ac58258a667eb48a25dfe916d70/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/03/b8/e484ef633af3887baeeb4b6ad12743363af7cce68ae51e938e00aaa0529d/transformers-4.57.6-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e7/28/35f7411ff80a3640c1f4fc907dcbb6a65061ebb82f66950e38bfc9f7f740/transformers-5.5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3a/7a/882d99539b19b1490cac5d77c67338d126e4122c8276bf640e411650c830/twine-6.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/89/29/8ac0281fc44c3297f0e58699ebf993c13621e32a0fab1025439d3ea8a2f1/ty-0.0.28-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl @@ -296,7 +296,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/df/9a/a24b26dc8a65f0ecc0fe5be981a19e61e7ca963b85e062c083f3a9100529/hf_xet-1.4.3-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - pypi: https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/a8/af/48ac8483240de756d2438c380746e7130d1c6f75802ef22f3c6d49982787/huggingface_hub-0.36.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/73/37/0d15d16150e1829f3e90962c99f28257f6de9e526a680b4c6f5acdb54fd2/huggingface_hub-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/42/77/de194443bf38daed9452139e960c632b0ef9f9a5dd9ce605fdf18ca9f1b1/id-1.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/46/33/92ef41c6fad0233e41d3d84ba8e8ad18d1780f1e5d99b3c683e6d7f98b63/identify-2.6.18-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl @@ -397,7 +397,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/f5/de/9341a6d7a8f1b448573bbf3425fa57669ac58258a667eb48a25dfe916d70/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/03/b8/e484ef633af3887baeeb4b6ad12743363af7cce68ae51e938e00aaa0529d/transformers-4.57.6-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e7/28/35f7411ff80a3640c1f4fc907dcbb6a65061ebb82f66950e38bfc9f7f740/transformers-5.5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3a/7a/882d99539b19b1490cac5d77c67338d126e4122c8276bf640e411650c830/twine-6.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/89/29/8ac0281fc44c3297f0e58699ebf993c13621e32a0fab1025439d3ea8a2f1/ty-0.0.28-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl @@ -481,7 +481,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/df/9a/a24b26dc8a65f0ecc0fe5be981a19e61e7ca963b85e062c083f3a9100529/hf_xet-1.4.3-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - pypi: https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/a8/af/48ac8483240de756d2438c380746e7130d1c6f75802ef22f3c6d49982787/huggingface_hub-0.36.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/73/37/0d15d16150e1829f3e90962c99f28257f6de9e526a680b4c6f5acdb54fd2/huggingface_hub-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/42/77/de194443bf38daed9452139e960c632b0ef9f9a5dd9ce605fdf18ca9f1b1/id-1.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/46/33/92ef41c6fad0233e41d3d84ba8e8ad18d1780f1e5d99b3c683e6d7f98b63/identify-2.6.18-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl @@ -558,7 +558,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/03/b8/e484ef633af3887baeeb4b6ad12743363af7cce68ae51e938e00aaa0529d/transformers-4.57.6-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e7/28/35f7411ff80a3640c1f4fc907dcbb6a65061ebb82f66950e38bfc9f7f740/transformers-5.5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3a/7a/882d99539b19b1490cac5d77c67338d126e4122c8276bf640e411650c830/twine-6.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/89/29/8ac0281fc44c3297f0e58699ebf993c13621e32a0fab1025439d3ea8a2f1/ty-0.0.28-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl @@ -776,7 +776,7 @@ packages: - pypi: ./ name: churro-ocr version: 0.2.0 - sha256: f05d8a5f234f0cdb08bcdbf205bea3cdcfecd53b1ac058686bb9341e92719637 + sha256: 4c7ab2bad968d566228a29c231a0cc7d8e594542a8632a5afa64841d05b307df requires_dist: - loguru>=0.7.2,<1 - pillow>=10.4.0,<12 @@ -786,7 +786,7 @@ packages: - litellm[caching]==1.82.3 ; extra == 'llm' - azure-ai-documentintelligence==1.0.2 ; extra == 'azure' - qwen-vl-utils ; extra == 'hf' - - transformers>=4.57.0,<5 ; extra == 'hf' + - transformers>=5,<6 ; extra == 'hf' - mistralai>=1.6.0,<2 ; extra == 'mistral' - litellm[caching]==1.82.3 ; extra == 'local' - pypdfium2>=5,<6 ; extra == 'pdf' @@ -794,7 +794,7 @@ packages: - litellm[caching]==1.82.3 ; extra == 'all' - azure-ai-documentintelligence==1.0.2 ; extra == 'all' - qwen-vl-utils ; extra == 'all' - - transformers>=4.57.0,<5 ; extra == 'all' + - transformers>=5,<6 ; extra == 'all' - mistralai>=1.6.0,<2 ; extra == 'all' - pypdfium2>=5,<6 ; extra == 'all' requires_python: '>=3.12' @@ -1416,49 +1416,38 @@ packages: - socksio==1.* ; extra == 'socks' - zstandard>=0.18.0 ; extra == 'zstd' requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/a8/af/48ac8483240de756d2438c380746e7130d1c6f75802ef22f3c6d49982787/huggingface_hub-0.36.2-py3-none-any.whl +- pypi: https://files.pythonhosted.org/packages/73/37/0d15d16150e1829f3e90962c99f28257f6de9e526a680b4c6f5acdb54fd2/huggingface_hub-1.9.0-py3-none-any.whl name: huggingface-hub - version: 0.36.2 - sha256: 48f0c8eac16145dfce371e9d2d7772854a4f591bcb56c9cf548accf531d54270 + version: 1.9.0 + sha256: 2999328c058d39fd19ab748dd09bd4da2fbaa4f4c1ddea823eab103051e14a1f requires_dist: - - filelock + - filelock>=3.10.0 - fsspec>=2023.5.0 - - hf-xet>=1.1.3,<2.0.0 ; platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64' + - hf-xet>=1.4.3,<2.0.0 ; platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64' + - httpx>=0.23.0,<1 - packaging>=20.9 - pyyaml>=5.1 - - requests - tqdm>=4.42.1 - - typing-extensions>=3.7.4.3 - - inquirerpy==0.3.4 ; extra == 'cli' - - aiohttp ; extra == 'inference' + - typer + - typing-extensions>=4.1.0 - authlib>=1.3.2 ; extra == 'oauth' - fastapi ; extra == 'oauth' - httpx ; extra == 'oauth' - itsdangerous ; extra == 'oauth' - torch ; extra == 'torch' - safetensors[torch] ; extra == 'torch' - - hf-transfer>=0.1.4 ; extra == 'hf-transfer' - toml ; extra == 'fastai' - fastai>=2.4 ; extra == 'fastai' - fastcore>=1.3.27 ; extra == 'fastai' - - tensorflow ; extra == 'tensorflow' - - pydot ; extra == 'tensorflow' - - graphviz ; extra == 'tensorflow' - - tensorflow ; extra == 'tensorflow-testing' - - keras<3.0 ; extra == 'tensorflow-testing' - - hf-xet>=1.1.2,<2.0.0 ; extra == 'hf-xet' + - hf-xet>=1.4.3,<2.0.0 ; extra == 'hf-xet' - mcp>=1.8.0 ; extra == 'mcp' - - typer ; extra == 'mcp' - - aiohttp ; extra == 'mcp' - - inquirerpy==0.3.4 ; extra == 'testing' - - aiohttp ; extra == 'testing' - authlib>=1.3.2 ; extra == 'testing' - fastapi ; extra == 'testing' - httpx ; extra == 'testing' - itsdangerous ; extra == 'testing' - jedi ; extra == 'testing' - jinja2 ; extra == 'testing' - - pytest>=8.1.1,<8.2.2 ; extra == 'testing' + - pytest>=8.4.2 ; extra == 'testing' - pytest-cov ; extra == 'testing' - pytest-env ; extra == 'testing' - pytest-xdist ; extra == 'testing' @@ -1469,30 +1458,28 @@ packages: - urllib3<2.0 ; extra == 'testing' - soundfile ; extra == 'testing' - pillow ; extra == 'testing' - - gradio>=4.0.0 ; extra == 'testing' - numpy ; extra == 'testing' + - duckdb ; extra == 'testing' - fastapi ; extra == 'testing' + - gradio>=5.0.0 ; extra == 'gradio' + - requests ; extra == 'gradio' - typing-extensions>=4.8.0 ; extra == 'typing' - types-pyyaml ; extra == 'typing' - - types-requests ; extra == 'typing' - types-simplejson ; extra == 'typing' - types-toml ; extra == 'typing' - types-tqdm ; extra == 'typing' - types-urllib3 ; extra == 'typing' - ruff>=0.9.0 ; extra == 'quality' - - mypy>=1.14.1,<1.15.0 ; python_full_version == '3.8.*' and extra == 'quality' - - mypy==1.15.0 ; python_full_version >= '3.9' and extra == 'quality' + - mypy==1.15.0 ; extra == 'quality' - libcst>=1.4.0 ; extra == 'quality' - ty ; extra == 'quality' - - inquirerpy==0.3.4 ; extra == 'all' - - aiohttp ; extra == 'all' - authlib>=1.3.2 ; extra == 'all' - fastapi ; extra == 'all' - httpx ; extra == 'all' - itsdangerous ; extra == 'all' - jedi ; extra == 'all' - jinja2 ; extra == 'all' - - pytest>=8.1.1,<8.2.2 ; extra == 'all' + - pytest>=8.4.2 ; extra == 'all' - pytest-cov ; extra == 'all' - pytest-env ; extra == 'all' - pytest-xdist ; extra == 'all' @@ -1503,30 +1490,26 @@ packages: - urllib3<2.0 ; extra == 'all' - soundfile ; extra == 'all' - pillow ; extra == 'all' - - gradio>=4.0.0 ; extra == 'all' - numpy ; extra == 'all' + - duckdb ; extra == 'all' - fastapi ; extra == 'all' - ruff>=0.9.0 ; extra == 'all' - - mypy>=1.14.1,<1.15.0 ; python_full_version == '3.8.*' and extra == 'all' - - mypy==1.15.0 ; python_full_version >= '3.9' and extra == 'all' + - mypy==1.15.0 ; extra == 'all' - libcst>=1.4.0 ; extra == 'all' - ty ; extra == 'all' - typing-extensions>=4.8.0 ; extra == 'all' - types-pyyaml ; extra == 'all' - - types-requests ; extra == 'all' - types-simplejson ; extra == 'all' - types-toml ; extra == 'all' - types-tqdm ; extra == 'all' - types-urllib3 ; extra == 'all' - - inquirerpy==0.3.4 ; extra == 'dev' - - aiohttp ; extra == 'dev' - authlib>=1.3.2 ; extra == 'dev' - fastapi ; extra == 'dev' - httpx ; extra == 'dev' - itsdangerous ; extra == 'dev' - jedi ; extra == 'dev' - jinja2 ; extra == 'dev' - - pytest>=8.1.1,<8.2.2 ; extra == 'dev' + - pytest>=8.4.2 ; extra == 'dev' - pytest-cov ; extra == 'dev' - pytest-env ; extra == 'dev' - pytest-xdist ; extra == 'dev' @@ -1537,22 +1520,20 @@ packages: - urllib3<2.0 ; extra == 'dev' - soundfile ; extra == 'dev' - pillow ; extra == 'dev' - - gradio>=4.0.0 ; extra == 'dev' - numpy ; extra == 'dev' + - duckdb ; extra == 'dev' - fastapi ; extra == 'dev' - ruff>=0.9.0 ; extra == 'dev' - - mypy>=1.14.1,<1.15.0 ; python_full_version == '3.8.*' and extra == 'dev' - - mypy==1.15.0 ; python_full_version >= '3.9' and extra == 'dev' + - mypy==1.15.0 ; extra == 'dev' - libcst>=1.4.0 ; extra == 'dev' - ty ; extra == 'dev' - typing-extensions>=4.8.0 ; extra == 'dev' - types-pyyaml ; extra == 'dev' - - types-requests ; extra == 'dev' - types-simplejson ; extra == 'dev' - types-toml ; extra == 'dev' - types-tqdm ; extra == 'dev' - types-urllib3 ; extra == 'dev' - requires_python: '>=3.8.0' + requires_python: '>=3.10.0' - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h33c6efd_0.conda sha256: fbf86c4a59c2ed05bbffb2ba25c7ed94f6185ec30ecb691615d42342baa1a16a md5: c80d8a3b84358cb967fa81e7075fbc8a @@ -3368,469 +3349,265 @@ packages: - requests ; extra == 'telegram' - ipywidgets>=6 ; extra == 'notebook' requires_python: '>=3.7' -- pypi: https://files.pythonhosted.org/packages/03/b8/e484ef633af3887baeeb4b6ad12743363af7cce68ae51e938e00aaa0529d/transformers-4.57.6-py3-none-any.whl +- pypi: https://files.pythonhosted.org/packages/e7/28/35f7411ff80a3640c1f4fc907dcbb6a65061ebb82f66950e38bfc9f7f740/transformers-5.5.0-py3-none-any.whl name: transformers - version: 4.57.6 - sha256: 4c9e9de11333ddfe5114bc872c9f370509198acf0b87a832a0ab9458e2bd0550 + version: 5.5.0 + sha256: 821a9ff0961abbb29eb1eb686d78df1c85929fdf213a3fe49dc6bd94f9efa944 requires_dist: - - filelock - - huggingface-hub>=0.34.0,<1.0 + - huggingface-hub>=1.5.0,<2.0 - numpy>=1.17 - packaging>=20.0 - pyyaml>=5.1 - - regex!=2019.12.17 - - requests + - regex>=2025.10.22 - tokenizers>=0.22.0,<=0.23.0 + - typer - safetensors>=0.4.3 - tqdm>=4.27 - - fugashi>=1.0 ; extra == 'ja' - - ipadic>=1.0.0,<2.0 ; extra == 'ja' - - unidic-lite>=1.0.7 ; extra == 'ja' - - unidic>=1.0.2 ; extra == 'ja' - - sudachipy>=0.6.6 ; extra == 'ja' - - sudachidict-core>=20220729 ; extra == 'ja' - - rhoknp>=1.1.0,<1.3.1 ; extra == 'ja' + - torch>=2.4 ; extra == 'torch' + - accelerate>=1.1.0 ; extra == 'torch' + - torchvision ; extra == 'vision' + - pillow>=10.0.1,<=15.0 ; extra == 'vision' + - torchaudio ; extra == 'audio' + - librosa ; extra == 'audio' + - pyctcdecode>=0.4.0 ; extra == 'audio' + - phonemizer ; extra == 'audio' + - av ; extra == 'video' + - timm>=1.0.23 ; extra == 'timm' + - datasets>=2.15.0 ; extra == 'quality' + - ruff==0.14.10 ; extra == 'quality' + - gitpython<3.1.19 ; extra == 'quality' + - urllib3<2.0.0 ; extra == 'quality' + - libcst ; extra == 'quality' + - rich ; extra == 'quality' + - ty==0.0.20 ; extra == 'quality' + - tomli ; extra == 'quality' + - hf-doc-builder ; extra == 'docs' + - kernels>=0.12.0,<0.13 ; extra == 'kernels' + - sentencepiece>=0.1.91,!=0.1.92 ; extra == 'sentencepiece' + - protobuf ; extra == 'sentencepiece' + - tiktoken ; extra == 'tiktoken' + - blobfile ; extra == 'tiktoken' + - mistral-common[image]>=1.10.0 ; extra == 'mistral-common' + - jinja2>=3.1.0 ; extra == 'chat-template' + - jmespath>=1.0.1 ; extra == 'chat-template' - scikit-learn ; extra == 'sklearn' - - tensorflow>2.9,<2.16 ; extra == 'tf' - - onnxconverter-common ; extra == 'tf' - - tf2onnx ; extra == 'tf' - - tensorflow-text<2.16 ; extra == 'tf' - - keras-nlp>=0.3.1,<0.14.0 ; extra == 'tf' - - keras>2.9,<2.16 ; extra == 'tf-cpu' - - tensorflow-cpu>2.9,<2.16 ; extra == 'tf-cpu' - - onnxconverter-common ; extra == 'tf-cpu' - - tf2onnx ; extra == 'tf-cpu' - - tensorflow-text<2.16 ; extra == 'tf-cpu' - - keras-nlp>=0.3.1,<0.14.0 ; extra == 'tf-cpu' - - tensorflow-probability<0.24 ; extra == 'tf-cpu' - - torch>=2.2 ; extra == 'torch' - - accelerate>=0.26.0 ; extra == 'torch' - - accelerate>=0.26.0 ; extra == 'accelerate' - - hf-xet ; extra == 'hf-xet' + - accelerate>=1.1.0 ; extra == 'accelerate' - faiss-cpu ; extra == 'retrieval' - datasets>=2.15.0 ; extra == 'retrieval' - - jax>=0.4.1,<=0.4.13 ; extra == 'flax' - - jaxlib>=0.4.1,<=0.4.13 ; extra == 'flax' - - flax>=0.4.1,<=0.7.0 ; extra == 'flax' - - optax>=0.0.8,<=0.1.4 ; extra == 'flax' - - scipy<1.13.0 ; extra == 'flax' - - tokenizers>=0.22.0,<=0.23.0 ; extra == 'tokenizers' - - ftfy ; extra == 'ftfy' - - onnxruntime>=1.4.0 ; extra == 'onnxruntime' - - onnxruntime-tools>=1.4.2 ; extra == 'onnxruntime' - - onnxconverter-common ; extra == 'onnx' - - tf2onnx ; extra == 'onnx' - - onnxruntime>=1.4.0 ; extra == 'onnx' - - onnxruntime-tools>=1.4.2 ; extra == 'onnx' - - cookiecutter==1.7.3 ; extra == 'modelcreation' - sagemaker>=2.31.0 ; extra == 'sagemaker' - deepspeed>=0.9.3 ; extra == 'deepspeed' - - accelerate>=0.26.0 ; extra == 'deepspeed' + - accelerate>=1.1.0 ; extra == 'deepspeed' - optuna ; extra == 'optuna' - - ray[tune]>=2.7.0 ; extra == 'ray' - - sigopt ; extra == 'sigopt' - - kernels>=0.6.1,<=0.9 ; extra == 'hub-kernels' - - kernels>=0.6.1,<=0.9 ; extra == 'integrations' + - kernels>=0.12.0,<0.13 ; extra == 'integrations' - optuna ; extra == 'integrations' + - codecarbon>=2.8.1 ; extra == 'integrations' - ray[tune]>=2.7.0 ; extra == 'integrations' + - ray[tune]>=2.7.0 ; extra == 'ray' + - codecarbon>=2.8.1 ; extra == 'codecarbon' - openai>=1.98.0 ; extra == 'serving' - pydantic>=2 ; extra == 'serving' - uvicorn ; extra == 'serving' - fastapi ; extra == 'serving' - starlette ; extra == 'serving' - - torch>=2.2 ; extra == 'serving' - - accelerate>=0.26.0 ; extra == 'serving' - - librosa ; extra == 'audio' - - pyctcdecode>=0.4.0 ; extra == 'audio' - - phonemizer ; extra == 'audio' - - kenlm ; extra == 'audio' - - torchaudio ; extra == 'speech' - - librosa ; extra == 'speech' - - pyctcdecode>=0.4.0 ; extra == 'speech' - - phonemizer ; extra == 'speech' - - kenlm ; extra == 'speech' - - torchaudio ; extra == 'torch-speech' - - librosa ; extra == 'torch-speech' - - pyctcdecode>=0.4.0 ; extra == 'torch-speech' - - phonemizer ; extra == 'torch-speech' - - kenlm ; extra == 'torch-speech' - - librosa ; extra == 'tf-speech' - - pyctcdecode>=0.4.0 ; extra == 'tf-speech' - - phonemizer ; extra == 'tf-speech' - - kenlm ; extra == 'tf-speech' - - librosa ; extra == 'flax-speech' - - pyctcdecode>=0.4.0 ; extra == 'flax-speech' - - phonemizer ; extra == 'flax-speech' - - kenlm ; extra == 'flax-speech' - - pillow>=10.0.1,<=15.0 ; extra == 'vision' - - timm!=1.0.18,<=1.0.19 ; extra == 'timm' - - torchvision ; extra == 'torch-vision' - - pillow>=10.0.1,<=15.0 ; extra == 'torch-vision' - - natten>=0.14.6,<0.15.0 ; extra == 'natten' - - codecarbon>=2.8.1 ; extra == 'codecarbon' - - av ; extra == 'video' + - rich ; extra == 'serving' + - torch>=2.4 ; extra == 'serving' + - accelerate>=1.1.0 ; extra == 'serving' - num2words ; extra == 'num2words' - - sentencepiece>=0.1.91,!=0.1.92 ; extra == 'sentencepiece' - - protobuf ; extra == 'sentencepiece' - - tiktoken ; extra == 'tiktoken' - - blobfile ; extra == 'tiktoken' - - mistral-common[opencv]>=1.6.3 ; extra == 'mistral-common' - - jinja2>=3.1.0 ; extra == 'chat-template' - - pytest>=7.2.0 ; extra == 'testing' - - pytest-asyncio ; extra == 'testing' + - optimum-benchmark>=0.3.0 ; extra == 'benchmark' + - fugashi>=1.0 ; extra == 'ja' + - ipadic>=1.0.0,<2.0 ; extra == 'ja' + - unidic-lite>=1.0.7 ; extra == 'ja' + - unidic>=1.0.2 ; extra == 'ja' + - rhoknp>=1.1.0,<1.3.1 ; extra == 'ja' + - sudachipy>=0.6.6 ; extra == 'ja' + - sudachidict-core>=20220729 ; extra == 'ja' + - opentelemetry-api ; extra == 'open-telemetry' + - opentelemetry-exporter-otlp ; extra == 'open-telemetry' + - opentelemetry-sdk ; extra == 'open-telemetry' + - pytest>=7.2.0,<9.0.0 ; extra == 'testing' + - pytest-asyncio>=1.2.0 ; extra == 'testing' + - pytest-random-order ; extra == 'testing' - pytest-rich ; extra == 'testing' - pytest-xdist ; extra == 'testing' - pytest-order ; extra == 'testing' - pytest-rerunfailures<16.0 ; extra == 'testing' + - pytest-timeout ; extra == 'testing' + - pytest-env ; extra == 'testing' - timeout-decorator ; extra == 'testing' - parameterized>=0.9 ; extra == 'testing' - psutil ; extra == 'testing' - - datasets>=2.15.0 ; extra == 'testing' - dill<0.3.5 ; extra == 'testing' - - evaluate>=0.2.0 ; extra == 'testing' - - pytest-timeout ; extra == 'testing' - - ruff==0.13.1 ; extra == 'testing' + - evaluate>=0.4.6 ; extra == 'testing' - rouge-score!=0.0.7,!=0.0.8,!=0.1,!=0.1.1 ; extra == 'testing' - nltk<=3.8.1 ; extra == 'testing' - - gitpython<3.1.19 ; extra == 'testing' - sacremoses ; extra == 'testing' - rjieba ; extra == 'testing' - beautifulsoup4 ; extra == 'testing' - tensorboard ; extra == 'testing' - - pydantic>=2 ; extra == 'testing' - - sentencepiece>=0.1.91,!=0.1.92 ; extra == 'testing' - sacrebleu>=1.4.12,<2.0.0 ; extra == 'testing' + - filelock ; extra == 'testing' + - hf-doc-builder ; extra == 'testing' + - datasets>=2.15.0 ; extra == 'testing' + - ruff==0.14.10 ; extra == 'testing' + - gitpython<3.1.19 ; extra == 'testing' + - urllib3<2.0.0 ; extra == 'testing' - libcst ; extra == 'testing' + - rich ; extra == 'testing' + - ty==0.0.20 ; extra == 'testing' + - tomli ; extra == 'testing' - faiss-cpu ; extra == 'testing' - datasets>=2.15.0 ; extra == 'testing' - - cookiecutter==1.7.3 ; extra == 'testing' - - mistral-common[opencv]>=1.6.3 ; extra == 'testing' + - sentencepiece>=0.1.91,!=0.1.92 ; extra == 'testing' + - protobuf ; extra == 'testing' - openai>=1.98.0 ; extra == 'testing' - pydantic>=2 ; extra == 'testing' - uvicorn ; extra == 'testing' - fastapi ; extra == 'testing' - starlette ; extra == 'testing' - - torch>=2.2 ; extra == 'testing' - - accelerate>=0.26.0 ; extra == 'testing' + - rich ; extra == 'testing' + - torch>=2.4 ; extra == 'testing' + - accelerate>=1.1.0 ; extra == 'testing' + - mistral-common[image]>=1.10.0 ; extra == 'testing' - deepspeed>=0.9.3 ; extra == 'deepspeed-testing' - - accelerate>=0.26.0 ; extra == 'deepspeed-testing' - - pytest>=7.2.0 ; extra == 'deepspeed-testing' - - pytest-asyncio ; extra == 'deepspeed-testing' + - accelerate>=1.1.0 ; extra == 'deepspeed-testing' + - pytest>=7.2.0,<9.0.0 ; extra == 'deepspeed-testing' + - pytest-asyncio>=1.2.0 ; extra == 'deepspeed-testing' + - pytest-random-order ; extra == 'deepspeed-testing' - pytest-rich ; extra == 'deepspeed-testing' - pytest-xdist ; extra == 'deepspeed-testing' - pytest-order ; extra == 'deepspeed-testing' - pytest-rerunfailures<16.0 ; extra == 'deepspeed-testing' + - pytest-timeout ; extra == 'deepspeed-testing' + - pytest-env ; extra == 'deepspeed-testing' - timeout-decorator ; extra == 'deepspeed-testing' - parameterized>=0.9 ; extra == 'deepspeed-testing' - psutil ; extra == 'deepspeed-testing' - - datasets>=2.15.0 ; extra == 'deepspeed-testing' - dill<0.3.5 ; extra == 'deepspeed-testing' - - evaluate>=0.2.0 ; extra == 'deepspeed-testing' - - pytest-timeout ; extra == 'deepspeed-testing' - - ruff==0.13.1 ; extra == 'deepspeed-testing' + - evaluate>=0.4.6 ; extra == 'deepspeed-testing' - rouge-score!=0.0.7,!=0.0.8,!=0.1,!=0.1.1 ; extra == 'deepspeed-testing' - nltk<=3.8.1 ; extra == 'deepspeed-testing' - - gitpython<3.1.19 ; extra == 'deepspeed-testing' - sacremoses ; extra == 'deepspeed-testing' - rjieba ; extra == 'deepspeed-testing' - beautifulsoup4 ; extra == 'deepspeed-testing' - tensorboard ; extra == 'deepspeed-testing' - - pydantic>=2 ; extra == 'deepspeed-testing' - - sentencepiece>=0.1.91,!=0.1.92 ; extra == 'deepspeed-testing' - sacrebleu>=1.4.12,<2.0.0 ; extra == 'deepspeed-testing' + - filelock ; extra == 'deepspeed-testing' + - hf-doc-builder ; extra == 'deepspeed-testing' + - datasets>=2.15.0 ; extra == 'deepspeed-testing' + - ruff==0.14.10 ; extra == 'deepspeed-testing' + - gitpython<3.1.19 ; extra == 'deepspeed-testing' + - urllib3<2.0.0 ; extra == 'deepspeed-testing' - libcst ; extra == 'deepspeed-testing' + - rich ; extra == 'deepspeed-testing' + - ty==0.0.20 ; extra == 'deepspeed-testing' + - tomli ; extra == 'deepspeed-testing' - faiss-cpu ; extra == 'deepspeed-testing' - datasets>=2.15.0 ; extra == 'deepspeed-testing' - - cookiecutter==1.7.3 ; extra == 'deepspeed-testing' - - mistral-common[opencv]>=1.6.3 ; extra == 'deepspeed-testing' + - sentencepiece>=0.1.91,!=0.1.92 ; extra == 'deepspeed-testing' + - protobuf ; extra == 'deepspeed-testing' - openai>=1.98.0 ; extra == 'deepspeed-testing' - pydantic>=2 ; extra == 'deepspeed-testing' - uvicorn ; extra == 'deepspeed-testing' - fastapi ; extra == 'deepspeed-testing' - starlette ; extra == 'deepspeed-testing' - - torch>=2.2 ; extra == 'deepspeed-testing' - - accelerate>=0.26.0 ; extra == 'deepspeed-testing' + - rich ; extra == 'deepspeed-testing' + - torch>=2.4 ; extra == 'deepspeed-testing' + - accelerate>=1.1.0 ; extra == 'deepspeed-testing' + - mistral-common[image]>=1.10.0 ; extra == 'deepspeed-testing' - optuna ; extra == 'deepspeed-testing' - sentencepiece>=0.1.91,!=0.1.92 ; extra == 'deepspeed-testing' - protobuf ; extra == 'deepspeed-testing' - - ruff==0.13.1 ; extra == 'ruff' - - datasets>=2.15.0 ; extra == 'quality' - - ruff==0.13.1 ; extra == 'quality' - - gitpython<3.1.19 ; extra == 'quality' - - urllib3<2.0.0 ; extra == 'quality' - - libcst ; extra == 'quality' - - rich ; extra == 'quality' - - pandas<2.3.0 ; extra == 'quality' - - tensorflow>2.9,<2.16 ; extra == 'all' - - onnxconverter-common ; extra == 'all' - - tf2onnx ; extra == 'all' - - tensorflow-text<2.16 ; extra == 'all' - - keras-nlp>=0.3.1,<0.14.0 ; extra == 'all' - - torch>=2.2 ; extra == 'all' - - accelerate>=0.26.0 ; extra == 'all' - - jax>=0.4.1,<=0.4.13 ; extra == 'all' - - jaxlib>=0.4.1,<=0.4.13 ; extra == 'all' - - flax>=0.4.1,<=0.7.0 ; extra == 'all' - - optax>=0.0.8,<=0.1.4 ; extra == 'all' - - scipy<1.13.0 ; extra == 'all' - - sentencepiece>=0.1.91,!=0.1.92 ; extra == 'all' - - protobuf ; extra == 'all' - - tokenizers>=0.22.0,<=0.23.0 ; extra == 'all' + - torch>=2.4 ; extra == 'all' + - accelerate>=1.1.0 ; extra == 'all' + - torchvision ; extra == 'all' + - pillow>=10.0.1,<=15.0 ; extra == 'all' - torchaudio ; extra == 'all' - librosa ; extra == 'all' - pyctcdecode>=0.4.0 ; extra == 'all' - phonemizer ; extra == 'all' - - kenlm ; extra == 'all' - - pillow>=10.0.1,<=15.0 ; extra == 'all' - - kernels>=0.6.1,<=0.9 ; extra == 'all' - - optuna ; extra == 'all' - - ray[tune]>=2.7.0 ; extra == 'all' - - timm!=1.0.18,<=1.0.19 ; extra == 'all' - - torchvision ; extra == 'all' - - pillow>=10.0.1,<=15.0 ; extra == 'all' - - codecarbon>=2.8.1 ; extra == 'all' - - accelerate>=0.26.0 ; extra == 'all' - av ; extra == 'all' - - num2words ; extra == 'all' - - mistral-common[opencv]>=1.6.3 ; extra == 'all' + - kernels>=0.12.0,<0.13 ; extra == 'all' + - timm>=1.0.23 ; extra == 'all' + - sentencepiece>=0.1.91,!=0.1.92 ; extra == 'all' + - protobuf ; extra == 'all' + - tiktoken ; extra == 'all' + - blobfile ; extra == 'all' - jinja2>=3.1.0 ; extra == 'all' - - pytest>=7.2.0 ; extra == 'dev-torch' - - pytest-asyncio ; extra == 'dev-torch' - - pytest-rich ; extra == 'dev-torch' - - pytest-xdist ; extra == 'dev-torch' - - pytest-order ; extra == 'dev-torch' - - pytest-rerunfailures<16.0 ; extra == 'dev-torch' - - timeout-decorator ; extra == 'dev-torch' - - parameterized>=0.9 ; extra == 'dev-torch' - - psutil ; extra == 'dev-torch' - - datasets>=2.15.0 ; extra == 'dev-torch' - - dill<0.3.5 ; extra == 'dev-torch' - - evaluate>=0.2.0 ; extra == 'dev-torch' - - pytest-timeout ; extra == 'dev-torch' - - ruff==0.13.1 ; extra == 'dev-torch' - - rouge-score!=0.0.7,!=0.0.8,!=0.1,!=0.1.1 ; extra == 'dev-torch' - - nltk<=3.8.1 ; extra == 'dev-torch' - - gitpython<3.1.19 ; extra == 'dev-torch' - - sacremoses ; extra == 'dev-torch' - - rjieba ; extra == 'dev-torch' - - beautifulsoup4 ; extra == 'dev-torch' - - tensorboard ; extra == 'dev-torch' - - pydantic>=2 ; extra == 'dev-torch' - - sentencepiece>=0.1.91,!=0.1.92 ; extra == 'dev-torch' - - sacrebleu>=1.4.12,<2.0.0 ; extra == 'dev-torch' - - libcst ; extra == 'dev-torch' - - faiss-cpu ; extra == 'dev-torch' - - datasets>=2.15.0 ; extra == 'dev-torch' - - cookiecutter==1.7.3 ; extra == 'dev-torch' - - mistral-common[opencv]>=1.6.3 ; extra == 'dev-torch' - - openai>=1.98.0 ; extra == 'dev-torch' - - pydantic>=2 ; extra == 'dev-torch' - - uvicorn ; extra == 'dev-torch' - - fastapi ; extra == 'dev-torch' - - starlette ; extra == 'dev-torch' - - torch>=2.2 ; extra == 'dev-torch' - - accelerate>=0.26.0 ; extra == 'dev-torch' - - torch>=2.2 ; extra == 'dev-torch' - - accelerate>=0.26.0 ; extra == 'dev-torch' - - sentencepiece>=0.1.91,!=0.1.92 ; extra == 'dev-torch' - - protobuf ; extra == 'dev-torch' - - tokenizers>=0.22.0,<=0.23.0 ; extra == 'dev-torch' - - torchaudio ; extra == 'dev-torch' - - librosa ; extra == 'dev-torch' - - pyctcdecode>=0.4.0 ; extra == 'dev-torch' - - phonemizer ; extra == 'dev-torch' - - kenlm ; extra == 'dev-torch' - - pillow>=10.0.1,<=15.0 ; extra == 'dev-torch' - - kernels>=0.6.1,<=0.9 ; extra == 'dev-torch' - - optuna ; extra == 'dev-torch' - - ray[tune]>=2.7.0 ; extra == 'dev-torch' - - timm!=1.0.18,<=1.0.19 ; extra == 'dev-torch' - - torchvision ; extra == 'dev-torch' - - pillow>=10.0.1,<=15.0 ; extra == 'dev-torch' - - codecarbon>=2.8.1 ; extra == 'dev-torch' - - datasets>=2.15.0 ; extra == 'dev-torch' - - ruff==0.13.1 ; extra == 'dev-torch' - - gitpython<3.1.19 ; extra == 'dev-torch' - - urllib3<2.0.0 ; extra == 'dev-torch' - - libcst ; extra == 'dev-torch' - - rich ; extra == 'dev-torch' - - pandas<2.3.0 ; extra == 'dev-torch' - - fugashi>=1.0 ; extra == 'dev-torch' - - ipadic>=1.0.0,<2.0 ; extra == 'dev-torch' - - unidic-lite>=1.0.7 ; extra == 'dev-torch' - - unidic>=1.0.2 ; extra == 'dev-torch' - - sudachipy>=0.6.6 ; extra == 'dev-torch' - - sudachidict-core>=20220729 ; extra == 'dev-torch' - - rhoknp>=1.1.0,<1.3.1 ; extra == 'dev-torch' - - scikit-learn ; extra == 'dev-torch' - - cookiecutter==1.7.3 ; extra == 'dev-torch' - - onnxruntime>=1.4.0 ; extra == 'dev-torch' - - onnxruntime-tools>=1.4.2 ; extra == 'dev-torch' - - num2words ; extra == 'dev-torch' - - pytest>=7.2.0 ; extra == 'dev-tensorflow' - - pytest-asyncio ; extra == 'dev-tensorflow' - - pytest-rich ; extra == 'dev-tensorflow' - - pytest-xdist ; extra == 'dev-tensorflow' - - pytest-order ; extra == 'dev-tensorflow' - - pytest-rerunfailures<16.0 ; extra == 'dev-tensorflow' - - timeout-decorator ; extra == 'dev-tensorflow' - - parameterized>=0.9 ; extra == 'dev-tensorflow' - - psutil ; extra == 'dev-tensorflow' - - datasets>=2.15.0 ; extra == 'dev-tensorflow' - - dill<0.3.5 ; extra == 'dev-tensorflow' - - evaluate>=0.2.0 ; extra == 'dev-tensorflow' - - pytest-timeout ; extra == 'dev-tensorflow' - - ruff==0.13.1 ; extra == 'dev-tensorflow' - - rouge-score!=0.0.7,!=0.0.8,!=0.1,!=0.1.1 ; extra == 'dev-tensorflow' - - nltk<=3.8.1 ; extra == 'dev-tensorflow' - - gitpython<3.1.19 ; extra == 'dev-tensorflow' - - sacremoses ; extra == 'dev-tensorflow' - - rjieba ; extra == 'dev-tensorflow' - - beautifulsoup4 ; extra == 'dev-tensorflow' - - tensorboard ; extra == 'dev-tensorflow' - - pydantic>=2 ; extra == 'dev-tensorflow' - - sentencepiece>=0.1.91,!=0.1.92 ; extra == 'dev-tensorflow' - - sacrebleu>=1.4.12,<2.0.0 ; extra == 'dev-tensorflow' - - libcst ; extra == 'dev-tensorflow' - - faiss-cpu ; extra == 'dev-tensorflow' - - datasets>=2.15.0 ; extra == 'dev-tensorflow' - - cookiecutter==1.7.3 ; extra == 'dev-tensorflow' - - mistral-common[opencv]>=1.6.3 ; extra == 'dev-tensorflow' - - openai>=1.98.0 ; extra == 'dev-tensorflow' - - pydantic>=2 ; extra == 'dev-tensorflow' - - uvicorn ; extra == 'dev-tensorflow' - - fastapi ; extra == 'dev-tensorflow' - - starlette ; extra == 'dev-tensorflow' - - torch>=2.2 ; extra == 'dev-tensorflow' - - accelerate>=0.26.0 ; extra == 'dev-tensorflow' - - tensorflow>2.9,<2.16 ; extra == 'dev-tensorflow' - - onnxconverter-common ; extra == 'dev-tensorflow' - - tf2onnx ; extra == 'dev-tensorflow' - - tensorflow-text<2.16 ; extra == 'dev-tensorflow' - - keras-nlp>=0.3.1,<0.14.0 ; extra == 'dev-tensorflow' - - sentencepiece>=0.1.91,!=0.1.92 ; extra == 'dev-tensorflow' - - protobuf ; extra == 'dev-tensorflow' - - tokenizers>=0.22.0,<=0.23.0 ; extra == 'dev-tensorflow' - - pillow>=10.0.1,<=15.0 ; extra == 'dev-tensorflow' - - datasets>=2.15.0 ; extra == 'dev-tensorflow' - - ruff==0.13.1 ; extra == 'dev-tensorflow' - - gitpython<3.1.19 ; extra == 'dev-tensorflow' - - urllib3<2.0.0 ; extra == 'dev-tensorflow' - - libcst ; extra == 'dev-tensorflow' - - rich ; extra == 'dev-tensorflow' - - pandas<2.3.0 ; extra == 'dev-tensorflow' - - scikit-learn ; extra == 'dev-tensorflow' - - cookiecutter==1.7.3 ; extra == 'dev-tensorflow' - - onnxconverter-common ; extra == 'dev-tensorflow' - - tf2onnx ; extra == 'dev-tensorflow' - - onnxruntime>=1.4.0 ; extra == 'dev-tensorflow' - - onnxruntime-tools>=1.4.2 ; extra == 'dev-tensorflow' - - librosa ; extra == 'dev-tensorflow' - - pyctcdecode>=0.4.0 ; extra == 'dev-tensorflow' - - phonemizer ; extra == 'dev-tensorflow' - - kenlm ; extra == 'dev-tensorflow' - - tensorflow>2.9,<2.16 ; extra == 'dev' - - onnxconverter-common ; extra == 'dev' - - tf2onnx ; extra == 'dev' - - tensorflow-text<2.16 ; extra == 'dev' - - keras-nlp>=0.3.1,<0.14.0 ; extra == 'dev' - - torch>=2.2 ; extra == 'dev' - - accelerate>=0.26.0 ; extra == 'dev' - - jax>=0.4.1,<=0.4.13 ; extra == 'dev' - - jaxlib>=0.4.1,<=0.4.13 ; extra == 'dev' - - flax>=0.4.1,<=0.7.0 ; extra == 'dev' - - optax>=0.0.8,<=0.1.4 ; extra == 'dev' - - scipy<1.13.0 ; extra == 'dev' - - sentencepiece>=0.1.91,!=0.1.92 ; extra == 'dev' - - protobuf ; extra == 'dev' - - tokenizers>=0.22.0,<=0.23.0 ; extra == 'dev' + - jmespath>=1.0.1 ; extra == 'all' + - num2words ; extra == 'all' + - mistral-common[image]>=1.10.0 ; extra == 'all' + - torch>=2.4 ; extra == 'dev' + - accelerate>=1.1.0 ; extra == 'dev' + - torchvision ; extra == 'dev' + - pillow>=10.0.1,<=15.0 ; extra == 'dev' - torchaudio ; extra == 'dev' - librosa ; extra == 'dev' - pyctcdecode>=0.4.0 ; extra == 'dev' - phonemizer ; extra == 'dev' - - kenlm ; extra == 'dev' - - pillow>=10.0.1,<=15.0 ; extra == 'dev' - - kernels>=0.6.1,<=0.9 ; extra == 'dev' - - optuna ; extra == 'dev' - - ray[tune]>=2.7.0 ; extra == 'dev' - - timm!=1.0.18,<=1.0.19 ; extra == 'dev' - - torchvision ; extra == 'dev' - - pillow>=10.0.1,<=15.0 ; extra == 'dev' - - codecarbon>=2.8.1 ; extra == 'dev' - - accelerate>=0.26.0 ; extra == 'dev' - av ; extra == 'dev' - - num2words ; extra == 'dev' - - mistral-common[opencv]>=1.6.3 ; extra == 'dev' + - kernels>=0.12.0,<0.13 ; extra == 'dev' + - timm>=1.0.23 ; extra == 'dev' + - sentencepiece>=0.1.91,!=0.1.92 ; extra == 'dev' + - protobuf ; extra == 'dev' + - tiktoken ; extra == 'dev' + - blobfile ; extra == 'dev' - jinja2>=3.1.0 ; extra == 'dev' - - pytest>=7.2.0 ; extra == 'dev' - - pytest-asyncio ; extra == 'dev' + - jmespath>=1.0.1 ; extra == 'dev' + - num2words ; extra == 'dev' + - mistral-common[image]>=1.10.0 ; extra == 'dev' + - pytest>=7.2.0,<9.0.0 ; extra == 'dev' + - pytest-asyncio>=1.2.0 ; extra == 'dev' + - pytest-random-order ; extra == 'dev' - pytest-rich ; extra == 'dev' - pytest-xdist ; extra == 'dev' - pytest-order ; extra == 'dev' - pytest-rerunfailures<16.0 ; extra == 'dev' + - pytest-timeout ; extra == 'dev' + - pytest-env ; extra == 'dev' - timeout-decorator ; extra == 'dev' - parameterized>=0.9 ; extra == 'dev' - psutil ; extra == 'dev' - - datasets>=2.15.0 ; extra == 'dev' - dill<0.3.5 ; extra == 'dev' - - evaluate>=0.2.0 ; extra == 'dev' - - pytest-timeout ; extra == 'dev' - - ruff==0.13.1 ; extra == 'dev' + - evaluate>=0.4.6 ; extra == 'dev' - rouge-score!=0.0.7,!=0.0.8,!=0.1,!=0.1.1 ; extra == 'dev' - nltk<=3.8.1 ; extra == 'dev' - - gitpython<3.1.19 ; extra == 'dev' - sacremoses ; extra == 'dev' - rjieba ; extra == 'dev' - beautifulsoup4 ; extra == 'dev' - tensorboard ; extra == 'dev' - - pydantic>=2 ; extra == 'dev' - - sentencepiece>=0.1.91,!=0.1.92 ; extra == 'dev' - sacrebleu>=1.4.12,<2.0.0 ; extra == 'dev' + - filelock ; extra == 'dev' + - hf-doc-builder ; extra == 'dev' + - datasets>=2.15.0 ; extra == 'dev' + - ruff==0.14.10 ; extra == 'dev' + - gitpython<3.1.19 ; extra == 'dev' + - urllib3<2.0.0 ; extra == 'dev' - libcst ; extra == 'dev' + - rich ; extra == 'dev' + - ty==0.0.20 ; extra == 'dev' + - tomli ; extra == 'dev' - faiss-cpu ; extra == 'dev' - datasets>=2.15.0 ; extra == 'dev' - - cookiecutter==1.7.3 ; extra == 'dev' - - mistral-common[opencv]>=1.6.3 ; extra == 'dev' + - sentencepiece>=0.1.91,!=0.1.92 ; extra == 'dev' + - protobuf ; extra == 'dev' - openai>=1.98.0 ; extra == 'dev' - pydantic>=2 ; extra == 'dev' - uvicorn ; extra == 'dev' - fastapi ; extra == 'dev' - starlette ; extra == 'dev' - - torch>=2.2 ; extra == 'dev' - - accelerate>=0.26.0 ; extra == 'dev' - - datasets>=2.15.0 ; extra == 'dev' - - ruff==0.13.1 ; extra == 'dev' - - gitpython<3.1.19 ; extra == 'dev' - - urllib3<2.0.0 ; extra == 'dev' - - libcst ; extra == 'dev' - rich ; extra == 'dev' - - pandas<2.3.0 ; extra == 'dev' + - torch>=2.4 ; extra == 'dev' + - accelerate>=1.1.0 ; extra == 'dev' + - mistral-common[image]>=1.10.0 ; extra == 'dev' - fugashi>=1.0 ; extra == 'dev' - ipadic>=1.0.0,<2.0 ; extra == 'dev' - unidic-lite>=1.0.7 ; extra == 'dev' - unidic>=1.0.2 ; extra == 'dev' + - rhoknp>=1.1.0,<1.3.1 ; extra == 'dev' - sudachipy>=0.6.6 ; extra == 'dev' - sudachidict-core>=20220729 ; extra == 'dev' - - rhoknp>=1.1.0,<1.3.1 ; extra == 'dev' - scikit-learn ; extra == 'dev' - - cookiecutter==1.7.3 ; extra == 'dev' - - filelock ; extra == 'torchhub' - - huggingface-hub>=0.34.0,<1.0 ; extra == 'torchhub' - - importlib-metadata ; extra == 'torchhub' - - numpy>=1.17 ; extra == 'torchhub' - - packaging>=20.0 ; extra == 'torchhub' - - protobuf ; extra == 'torchhub' - - regex!=2019.12.17 ; extra == 'torchhub' - - requests ; extra == 'torchhub' - - sentencepiece>=0.1.91,!=0.1.92 ; extra == 'torchhub' - - torch>=2.2 ; extra == 'torchhub' - - tokenizers>=0.22.0,<=0.23.0 ; extra == 'torchhub' - - tqdm>=4.27 ; extra == 'torchhub' - - optimum-benchmark>=0.3.0 ; extra == 'benchmark' - - opentelemetry-api ; extra == 'open-telemetry' - - opentelemetry-exporter-otlp ; extra == 'open-telemetry' - - opentelemetry-sdk ; extra == 'open-telemetry' - requires_python: '>=3.9.0' + requires_python: '>=3.10.0' - pypi: https://files.pythonhosted.org/packages/3a/7a/882d99539b19b1490cac5d77c67338d126e4122c8276bf640e411650c830/twine-6.2.0-py3-none-any.whl name: twine version: 6.2.0 diff --git a/pyproject.toml b/pyproject.toml index 367c298..0db092f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ azure = [ ] hf = [ "qwen-vl-utils", - "transformers>=4.57.0,<5", + "transformers>=5,<6", ] mistral = [ "mistralai>=1.6.0,<2", @@ -49,7 +49,7 @@ all = [ "litellm[caching]==1.82.3", "azure-ai-documentintelligence==1.0.2", "qwen-vl-utils", - "transformers>=4.57.0,<5", + "transformers>=5,<6", "mistralai>=1.6.0,<2", "pypdfium2>=5,<6", ] diff --git a/tests/test_install.py b/tests/test_install.py index ab06e95..7748202 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -25,7 +25,7 @@ def test_install_runtime_dependencies_installs_hf_and_torch_with_uv( lambda _: _FakeDistribution( requires=[ 'qwen-vl-utils; extra == "hf"', - 'transformers>=4.57.0,<5; extra == "hf"', + 'transformers>=5,<6; extra == "hf"', ] ), ) @@ -51,7 +51,7 @@ def test_install_runtime_dependencies_installs_hf_and_torch_with_uv( sys.executable, "--upgrade", "qwen-vl-utils", - "transformers>=4.57.0,<5", + "transformers>=5,<6", ], [ "/usr/bin/uv", diff --git a/tests/test_internal_helpers.py b/tests/test_internal_helpers.py index e37af3e..240581a 100644 --- a/tests/test_internal_helpers.py +++ b/tests/test_internal_helpers.py @@ -16,8 +16,11 @@ from churro_ocr._internal.image import image_to_base64, load_image from churro_ocr._internal.litellm import LiteLLMTransport, complete_text from churro_ocr.errors import ConfigurationError, ProviderError +from churro_ocr.page_detection import DocumentPage +from churro_ocr.providers._shared import render_ocr_prompt from churro_ocr.providers.hf import _load_hf_causal_runtime, _load_hf_runtime from churro_ocr.providers.specs import LiteLLMTransportConfig +from churro_ocr.templates import HFChatTemplate def _make_fake_litellm_module(*, acompletion: object, completion_cost: object | None = None) -> ModuleType: @@ -93,6 +96,42 @@ def test_prepare_messages_from_conversation_converts_images_and_preserves_unknow ] +def test_render_ocr_prompt_supports_transformers_v5_chat_template_contract() -> None: + captured: dict[str, object] = {} + + class FakeProcessor: + def apply_chat_template( + self, + conversation: list[dict[str, object]], + *, + add_generation_prompt: bool, + tokenize: bool = True, + return_dict: bool = True, + ) -> object: + captured["conversation"] = conversation + captured["add_generation_prompt"] = add_generation_prompt + captured["tokenize"] = tokenize + captured["return_dict"] = return_dict + if not tokenize: + return "" + return {"input_ids": [1, 2, 3]} if return_dict else [1, 2, 3] + + page = DocumentPage.from_image(Image.new("RGB", (16, 16), color="white")) + + rendered, conversation = render_ocr_prompt( + FakeProcessor(), + HFChatTemplate(user_prompt="prompt"), + page, + add_generation_prompt=True, + ) + + assert rendered == "" + assert conversation == captured["conversation"] + assert captured["add_generation_prompt"] is True + assert captured["tokenize"] is False + assert captured["return_dict"] is True + + @pytest.mark.asyncio async def test_complete_text_wrapper_passes_transport_config(monkeypatch: pytest.MonkeyPatch) -> None: captured: dict[str, object] = {} diff --git a/tests/test_package_check.py b/tests/test_package_check.py index 84ef26f..a4dfcfa 100644 --- a/tests/test_package_check.py +++ b/tests/test_package_check.py @@ -47,7 +47,7 @@ def _always_missing(_: str): def test_local_runtime_packaging_policy_rejects_direct_torch_runtime_pin() -> None: metadata_message = _metadata_message( - 'transformers[torch]<5,>=4.57.0; extra == "hf"', + 'transformers[torch]>=5,<6; extra == "hf"', 'torchvision; extra == "all"', ) From 6dafe58bdaa0c0b07380ebba34726688182bd8ec Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 6 Apr 2026 01:56:14 +0000 Subject: [PATCH 09/28] feat(ocr): add chandra-ocr-2 support --- docs/guides/providers.md | 6 + src/churro_ocr/prompts/__init__.py | 4 + src/churro_ocr/prompts/ocr.py | 71 +++++++- src/churro_ocr/providers/builder.py | 3 + src/churro_ocr/providers/hf.py | 235 +++++++++++++++++++++++---- src/churro_ocr/providers/specs.py | 86 ++++++++++ src/churro_ocr/templates/__init__.py | 4 + src/churro_ocr/templates/presets.py | 8 + tests/test_cli.py | 40 +++++ tests/test_hf_ocr.py | 219 ++++++++++++++++++++++++- tests/test_providers.py | 110 +++++++++++++ 11 files changed, 746 insertions(+), 40 deletions(-) diff --git a/docs/guides/providers.md b/docs/guides/providers.md index 3b1640a..f12565d 100644 --- a/docs/guides/providers.md +++ b/docs/guides/providers.md @@ -32,6 +32,7 @@ This page is the source of truth for matching providers to runtime targets. | --- | --- | --- | | hosted OCR | `litellm` + `vertex_ai/gemini-2.5-flash` | easiest hosted path with the standard builder interface | | local OCR | `hf` + `stanford-oval/churro-3B` | first-party local model support in-process | +| layout-heavy local OCR | `hf` + `datalab-to/chandra-ocr-2` | built-in profile matches Chandra's layout-oriented prompt, scaling, and generation defaults | | higher-throughput local serving | `openai-compatible` + your own OpenAI-style server | good when you already run a served local backend such as vLLM | ## Hosted Providers @@ -144,6 +145,8 @@ backend = build_ocr_backend( ) ``` +Built-in model-specific profiles are resolved automatically for known models such as `stanford-oval/churro-3B`, `datalab-to/chandra-ocr-2`, `kristaller486/dots.ocr-1.5`, and the supported `olmOCR` checkpoints. + ## `OCRBackendSpec` Reference | Field | Meaning | @@ -204,6 +207,7 @@ Useful public template exports: | `HFChatTemplate` | `churro_ocr.templates` | Build a Hugging Face chat-style multimodal prompt. | | `DEFAULT_OCR_TEMPLATE` | `churro_ocr.templates` | Generic OCR prompt template used by the default model profile. | | `CHURRO_3B_XML_TEMPLATE` | `churro_ocr.templates` | Built-in template for `stanford-oval/churro-3B`. | +| `CHANDRA_OCR_2_OCR_TEMPLATE` | `churro_ocr.templates` | Built-in template for `datalab-to/chandra-ocr-2`. | | `DOTS_OCR_1_5_OCR_TEMPLATE` | `churro_ocr.templates` | Built-in template for `kristaller486/dots.ocr-1.5`. | | `OCRPromptTemplate` | `churro_ocr.templates` | Base protocol for custom profile integration. | @@ -214,6 +218,8 @@ Useful public prompt exports: | `DEFAULT_OCR_SYSTEM_PROMPT` | `churro_ocr.prompts` | Default system instruction for generic OCR prompting. | | `DEFAULT_OCR_USER_PROMPT` | `churro_ocr.prompts` | Default user prompt for plain OCR output. | | `DEFAULT_MARKDOWN_OCR_USER_PROMPT` | `churro_ocr.prompts` | Default user prompt when markdown-style OCR output is preferred. | +| `CHANDRA_OCR_LAYOUT_PROMPT` | `churro_ocr.prompts` | Upstream Chandra OCR 2 layout-block HTML prompt. | | `DEFAULT_OCR_OUTPUT_TAG` | `churro_ocr.prompts` | Shared tag name used by the default OCR postprocessor. | | `DEFAULT_BOUNDARY_DETECTION_PROMPT` | `churro_ocr.prompts` | Default prompt used by LLM-based page and text-block boundary detection helpers. | +| `parse_chandra_response(...)` | `churro_ocr.prompts` | Convert Chandra HTML-layout output to plain text and preserve raw HTML metadata. | | `strip_ocr_output_tag(...)` | `churro_ocr.prompts` | Remove the default OCR wrapper tag from model output. | diff --git a/src/churro_ocr/prompts/__init__.py b/src/churro_ocr/prompts/__init__.py index 41917aa..19db07e 100644 --- a/src/churro_ocr/prompts/__init__.py +++ b/src/churro_ocr/prompts/__init__.py @@ -4,22 +4,26 @@ DEFAULT_BOUNDARY_DETECTION_PROMPT, ) from churro_ocr.prompts.ocr import ( + CHANDRA_OCR_LAYOUT_PROMPT, DEFAULT_MARKDOWN_OCR_USER_PROMPT, DEFAULT_OCR_OUTPUT_TAG, DEFAULT_OCR_SYSTEM_PROMPT, DEFAULT_OCR_USER_PROMPT, OLMOCR_V4_YAML_PROMPT, + parse_chandra_response, parse_olmocr_response, strip_ocr_output_tag, ) __all__ = [ + "CHANDRA_OCR_LAYOUT_PROMPT", "DEFAULT_BOUNDARY_DETECTION_PROMPT", "DEFAULT_MARKDOWN_OCR_USER_PROMPT", "DEFAULT_OCR_OUTPUT_TAG", "DEFAULT_OCR_SYSTEM_PROMPT", "DEFAULT_OCR_USER_PROMPT", "OLMOCR_V4_YAML_PROMPT", + "parse_chandra_response", "parse_olmocr_response", "strip_ocr_output_tag", ] diff --git a/src/churro_ocr/prompts/ocr.py b/src/churro_ocr/prompts/ocr.py index 0007850..a888d4a 100644 --- a/src/churro_ocr/prompts/ocr.py +++ b/src/churro_ocr/prompts/ocr.py @@ -41,6 +41,54 @@ "tables, and line breaks when they are visible." ) +CHANDRA_OCR_LAYOUT_PROMPT = ( + "OCR this image to HTML, arranged as layout blocks. Each layout block should be a div " + "with the data-bbox attribute representing the bounding box of the block in x0 y0 x1 y1 " + "format. Bboxes are normalized 0-1000. The data-label attribute is the label for the block.\n\n" + "Use the following labels:\n" + "- Caption\n" + "- Footnote\n" + "- Equation-Block\n" + "- List-Group\n" + "- Page-Header\n" + "- Page-Footer\n" + "- Image\n" + "- Section-Header\n" + "- Table\n" + "- Text\n" + "- Complex-Block\n" + "- Code-Block\n" + "- Form\n" + "- Table-Of-Contents\n" + "- Figure\n" + "- Chemical-Block\n" + "- Diagram\n" + "- Bibliography\n" + "- Blank-Page\n\n" + "Only use these tags ['math', 'br', 'i', 'b', 'u', 'del', 'sup', 'sub', 'table', 'tr', " + "'td', 'p', 'th', 'div', 'pre', 'h1', 'h2', 'h3', 'h4', 'h5', 'ul', 'ol', 'li', 'input', " + "'a', 'span', 'img', 'hr', 'tbody', 'small', 'caption', 'strong', 'thead', 'big', 'code', " + "'chem'], and these attributes ['class', 'colspan', 'rowspan', 'display', 'checked', " + "'type', 'border', 'value', 'style', 'href', 'alt', 'align', 'data-bbox', 'data-label'].\n\n" + "Guidelines:\n" + "* Inline math: Surround math with ... tags. Math expressions should be rendered " + "in KaTeX-compatible LaTeX. Use display for block math.\n" + "* Tables: Use colspan and rowspan attributes to match table structure.\n" + "* Formatting: Maintain consistent formatting with the image, including spacing, indentation, " + "subscripts/superscripts, and special characters.\n" + "* Images: Include a description of any images in the alt attribute of an tag. Do not " + "fill out the src property. Describe in detail inside the div tag. Also convert charts to high " + "fidelity data, and convert diagrams to mermaid.\n" + "* Forms: Mark checkboxes and radio buttons properly.\n" + "* Text: join lines together properly into paragraphs using

...

tags. Use
tags for " + "line breaks within paragraphs, but only when absolutely necessary to maintain meaning.\n" + "* Chemistry: Use ... tags for chemical formulas with reactive SMILES.\n" + "* Lists: Preserve indents and proper list markers.\n" + "* Use the simplest possible HTML structure that accurately represents the content of the block.\n" + "* Make sure the text is accurate and easy for a human to read and interpret. Reading order " + "should be correct and natural." +) + OLMOCR_V4_YAML_PROMPT = ( "Attached is one page of a document that you must process. " "Just return the plain text representation of this document as if you were reading it naturally.\n" @@ -105,8 +153,8 @@ def _extract_yaml_front_matter(text: str) -> tuple[dict[str, object], str]: return front_matter, body -def _strip_olmocr_markdown_to_plain_text(text: str) -> str: - """Best-effort plain-text conversion for olmOCR markdown/HTML output.""" +def _strip_rich_ocr_markup_to_plain_text(text: str) -> str: + """Best-effort plain-text conversion for OCR markdown/HTML output.""" cleaned = text.strip() if not cleaned: return "" @@ -115,20 +163,25 @@ def _strip_olmocr_markdown_to_plain_text(text: str) -> str: cleaned = re.sub(r"\[([^\]]+)]\([^)]+\)", r"\1", cleaned) html_replacements = ( + (r"(?is)<\s*input\b[^>]*\bchecked(?:=(?:\"[^\"]*\"|'[^']*'|[^\s>]+))?[^>]*>", "[x]"), + (r"(?is)<\s*input\b[^>]*>", "[ ]"), (r"(?i)<\s*br\s*/?\s*>", "\n"), - (r"(?i)", "\n"), + (r"(?i)<\s*hr\s*/?\s*>", "\n"), + (r"(?i)", "\n"), (r"(?i)", " | "), (r"(?i)<\s*li\b[^>]*>", ""), ( r"(?i)]*>", + r"strong|em|b|i|u|sup|sub|code|pre|a|math|chem|caption|small|big)\b[^>]*>", "", ), + (r"(?i)<\s*img\b[^>]*>", ""), ) for pattern, replacement in html_replacements: cleaned = re.sub(pattern, replacement, cleaned) cleaned = html.unescape(cleaned) + cleaned = re.sub(r"(?is)]*>", "", cleaned) cleaned = re.sub(r"(?m)^\s{0,3}#{1,6}\s*", "", cleaned) cleaned = re.sub(r"(?m)^\s*[-+*]\s+", "", cleaned) cleaned = re.sub(r"(?m)^\s*>\s?", "", cleaned) @@ -157,7 +210,15 @@ def _strip_olmocr_markdown_to_plain_text(text: str) -> str: def parse_olmocr_response(text: str) -> tuple[str, dict[str, Any]]: """Extract plain text and metadata from an olmOCR YAML-front-matter response.""" front_matter, markdown_body = _extract_yaml_front_matter(text) - return _strip_olmocr_markdown_to_plain_text(markdown_body), { + return _strip_rich_ocr_markup_to_plain_text(markdown_body), { "front_matter": front_matter, "raw_markdown": markdown_body, } + + +def parse_chandra_response(text: str) -> tuple[str, dict[str, Any]]: + """Extract plain text and metadata from a Chandra HTML-layout response.""" + raw_html = text.strip() + return _strip_rich_ocr_markup_to_plain_text(raw_html), { + "raw_html": raw_html, + } diff --git a/src/churro_ocr/providers/builder.py b/src/churro_ocr/providers/builder.py index bc27461..216ed12 100644 --- a/src/churro_ocr/providers/builder.py +++ b/src/churro_ocr/providers/builder.py @@ -6,6 +6,7 @@ from churro_ocr.errors import ConfigurationError from churro_ocr.ocr import OCRBackend from churro_ocr.providers.hf import ( + ChandraOCR2OCRBackend, DotsOCR15OCRBackend, HuggingFaceVisionOCRBackend, _default_dots_ocr_1_5_model_kwargs, @@ -163,6 +164,8 @@ def _build_huggingface_backend(spec: OCRBackendSpec, profile: OCRModelProfile) - if options.backend_variant == "dots-ocr-1.5": backend_cls = DotsOCR15OCRBackend model_kwargs = _merge_mapping(_default_dots_ocr_1_5_model_kwargs(), model_kwargs) + elif options.backend_variant == "chandra-ocr-2": + backend_cls = ChandraOCR2OCRBackend return backend_cls( model_id=spec.model, template=profile.template, diff --git a/src/churro_ocr/providers/hf.py b/src/churro_ocr/providers/hf.py index 54e62a3..f3ad5a0 100644 --- a/src/churro_ocr/providers/hf.py +++ b/src/churro_ocr/providers/hf.py @@ -7,7 +7,7 @@ from dataclasses import dataclass, field from importlib import import_module from pathlib import Path -from typing import Any +from typing import Any, cast from churro_ocr._internal.install import install_command_hint from churro_ocr._internal.prompt_logging import log_prompt_payload_once @@ -29,6 +29,8 @@ identity_text_postprocessor, ) from churro_ocr.templates import ( + CHANDRA_OCR_2_MODEL_ID, + CHANDRA_OCR_2_OCR_TEMPLATE, CHURRO_3B_MODEL_ID, CHURRO_3B_XML_TEMPLATE, DOTS_OCR_1_5_MODEL_ID, @@ -181,6 +183,53 @@ def _default_dots_ocr_1_5_model_kwargs() -> dict[str, object]: return model_kwargs +def _default_chandra_ocr_2_model_kwargs() -> dict[str, object]: + model_kwargs: dict[str, object] = { + "device_map": "auto", + "dtype": "auto", + } + try: + torch = import_module("torch") + except ImportError: # pragma: no cover - torch is installed separately for local HF use + return model_kwargs + + if torch.cuda.is_available(): + model_kwargs["dtype"] = torch.bfloat16 + return model_kwargs + + +def _move_batch_to_model(batch: Any, model: Any) -> Any: + model_device = getattr(model, "device", None) + if hasattr(batch, "to") and model_device is not None: + batch = batch.to(model_device) + model_dtype = getattr(model, "dtype", None) + if model_dtype is not None: + batch_mapping = cast(dict[str, object], batch) + for key, value in batch_mapping.items(): + if hasattr(value, "dtype") and getattr(value.dtype, "is_floating_point", False): + batch_mapping[key] = cast(Any, value).to(dtype=model_dtype) + return batch + + +def _decode_completion_texts(processor: Any, batch: Any, generated_ids: Any) -> list[str]: + batch_mapping = cast(dict[str, object], batch) + attention_mask = batch_mapping.get("attention_mask") + if attention_mask is not None and hasattr(attention_mask, "sum"): + prompt_lengths = cast(Any, attention_mask).sum(dim=1).tolist() + completion_ids = [ + output_ids[int(prompt_length) :] + for prompt_length, output_ids in zip(prompt_lengths, generated_ids, strict=True) + ] + else: + prompt_length = cast(Any, batch_mapping["input_ids"]).shape[1] + completion_ids = generated_ids[:, prompt_length:] + return processor.batch_decode( + completion_ids, + skip_special_tokens=True, + clean_up_tokenization_spaces=False, + ) + + @dataclass(slots=True) class HuggingFaceVisionOCRBackend(OCRBackend): """OCR backend for local Hugging Face multimodal models with custom templates. @@ -270,23 +319,10 @@ def _ocr_sync(self, page: DocumentPage) -> OCRResult: if normalized_video_inputs is not None: batch_kwargs["videos"] = normalized_video_inputs batch = processor(**batch_kwargs) - model_device = getattr(model, "device", None) - if hasattr(batch, "to") and model_device is not None: - batch = batch.to(model_device) - model_dtype = getattr(model, "dtype", None) - if model_dtype is not None: - for key, value in batch.items(): - if hasattr(value, "dtype") and getattr(value.dtype, "is_floating_point", False): - batch[key] = value.to(dtype=model_dtype) + batch = _move_batch_to_model(batch, model) generated_ids = model.generate(**batch, **self.generation_kwargs) - prompt_length = batch["input_ids"].shape[1] - completion_ids = generated_ids[:, prompt_length:] - text = processor.batch_decode( - completion_ids, - skip_special_tokens=True, - clean_up_tokenization_spaces=False, - )[0] + text = _decode_completion_texts(processor, batch, generated_ids)[0] return build_ocr_result( text, provider_name=self.provider_name, @@ -340,26 +376,10 @@ def _ocr_batch_sync(self, pages: list[DocumentPage]) -> list[OCRResult]: if has_videos: batch_kwargs["videos"] = video_batch batch = processor(**batch_kwargs) - model_device = getattr(model, "device", None) - if hasattr(batch, "to") and model_device is not None: - batch = batch.to(model_device) - model_dtype = getattr(model, "dtype", None) - if model_dtype is not None: - for key, value in batch.items(): - if hasattr(value, "dtype") and getattr(value.dtype, "is_floating_point", False): - batch[key] = value.to(dtype=model_dtype) + batch = _move_batch_to_model(batch, model) generated_ids = model.generate(**batch, **self.generation_kwargs) - prompt_lengths = batch["attention_mask"].sum(dim=1).tolist() - completion_ids = [ - output_ids[int(prompt_length) :] - for prompt_length, output_ids in zip(prompt_lengths, generated_ids, strict=True) - ] - texts = processor.batch_decode( - completion_ids, - skip_special_tokens=True, - clean_up_tokenization_spaces=False, - ) + texts = _decode_completion_texts(processor, batch, generated_ids) return [ build_ocr_result( text, @@ -442,6 +462,152 @@ def _log_prompt_payload( ) +@dataclass(slots=True) +class ChandraOCR2OCRBackend(HuggingFaceVisionOCRBackend): + """Preset OCR backend for ``datalab-to/chandra-ocr-2``.""" + + model_id: str = CHANDRA_OCR_2_MODEL_ID + template: OCRPromptTemplateLike = CHANDRA_OCR_2_OCR_TEMPLATE + model_name: str | None = "chandra-ocr-2" + + def _get_processor(self, runtime: _HFRuntime) -> Any: + processor = super()._get_processor(runtime) + tokenizer = getattr(processor, "tokenizer", None) + if tokenizer is not None and getattr(tokenizer, "padding_side", None) != "left": + tokenizer.padding_side = "left" + return processor + + def _get_model(self, runtime: _HFRuntime) -> Any: + if self._model is None: + with self._init_lock: + if self._model is None: + model_kwargs = { + **_default_chandra_ocr_2_model_kwargs(), + **self.model_kwargs, + } + self._model = runtime.model_cls.from_pretrained( + self._get_model_source(), + trust_remote_code=self.trust_remote_code, + **model_kwargs, + ) + eval_method = getattr(self._model, "eval", None) + if callable(eval_method): + eval_method() + return self._model + + def _build_chandra_batch(self, processor: Any, conversations: list[OCRConversation]) -> Any: + processor_apply = getattr(processor, "apply_chat_template", None) + if not callable(processor_apply): + raise ConfigurationError("Chandra OCR 2 requires `processor.apply_chat_template(...)` support.") + return processor_apply( + conversations, + add_generation_prompt=True, + tokenize=True, + return_dict=True, + return_tensors="pt", + padding=True, + ) + + def _resolve_chandra_generation_kwargs(self, processor: Any, model: Any) -> dict[str, object]: + generation_kwargs = dict(self.generation_kwargs) + eos_token_ids: list[int] = [] + eos_token_id = getattr(getattr(model, "generation_config", None), "eos_token_id", None) + if isinstance(eos_token_id, int): + eos_token_ids.append(eos_token_id) + elif isinstance(eos_token_id, list): + eos_token_ids.extend(token_id for token_id in eos_token_id if isinstance(token_id, int)) + + tokenizer = getattr(processor, "tokenizer", None) + convert_tokens_to_ids = getattr(tokenizer, "convert_tokens_to_ids", None) + if callable(convert_tokens_to_ids): + im_end_id = convert_tokens_to_ids("<|im_end|>") + if isinstance(im_end_id, int) and im_end_id >= 0 and im_end_id not in eos_token_ids: + eos_token_ids.append(im_end_id) + + if eos_token_ids: + generation_kwargs["eos_token_id"] = eos_token_ids + return generation_kwargs + + def _ocr_sync(self, page: DocumentPage) -> OCRResult: + prepared_page = preprocess_backend_page( + page, + image_preprocessor=self.image_preprocessor, + ) + runtime = self._load_runtime() + processor = self._get_processor(runtime) + model = self._get_model(runtime) + + rendered, conversation = render_ocr_prompt( + processor, + self.template, + prepared_page, + add_generation_prompt=True, + ) + self._log_prompt_payload( + rendered_prompt=rendered, + conversation=conversation, + batch_size=1, + ) + batch = self._build_chandra_batch(processor, [conversation]) + batch = _move_batch_to_model(batch, model) + generated_ids = model.generate( + **batch, + **self._resolve_chandra_generation_kwargs(processor, model), + ) + text = _decode_completion_texts(processor, batch, generated_ids)[0] + return build_ocr_result( + text, + provider_name=self.provider_name, + model_name=self.model_name or self.model_id, + text_postprocessor=self.text_postprocessor, + ) + + def _ocr_batch_sync(self, pages: list[DocumentPage]) -> list[OCRResult]: + if not pages: + return [] + + runtime = self._load_runtime() + processor = self._get_processor(runtime) + model = self._get_model(runtime) + conversations: list[OCRConversation] = [] + + for page in pages: + prepared_page = preprocess_backend_page( + page, + image_preprocessor=self.image_preprocessor, + ) + rendered, conversation = render_ocr_prompt( + processor, + self.template, + prepared_page, + add_generation_prompt=True, + ) + conversations.append(conversation) + if not self._has_logged_prompt: + self._log_prompt_payload( + rendered_prompt=rendered, + conversation=conversation, + batch_size=len(pages), + ) + + batch = self._build_chandra_batch(processor, conversations) + batch = _move_batch_to_model(batch, model) + generated_ids = model.generate( + **batch, + **self._resolve_chandra_generation_kwargs(processor, model), + ) + texts = _decode_completion_texts(processor, batch, generated_ids) + return [ + build_ocr_result( + text, + provider_name=self.provider_name, + model_name=self.model_name or self.model_id, + text_postprocessor=self.text_postprocessor, + ) + for text in texts + ] + + @dataclass(slots=True) class Churro3BOCRBackend(HuggingFaceVisionOCRBackend): """Preset OCR backend for ``stanford-oval/churro-3B``.""" @@ -497,6 +663,7 @@ def _get_model(self, runtime: _HFRuntime) -> Any: __all__ = [ + "ChandraOCR2OCRBackend", "Churro3BOCRBackend", "DotsOCR15OCRBackend", "HuggingFaceVisionOCRBackend", diff --git a/src/churro_ocr/providers/specs.py b/src/churro_ocr/providers/specs.py index b366617..43f46c9 100644 --- a/src/churro_ocr/providers/specs.py +++ b/src/churro_ocr/providers/specs.py @@ -12,10 +12,13 @@ from churro_ocr._internal.image import ensure_rgb, prepare_ocr_image, resize_image_to_fit from churro_ocr.prompts import ( DEFAULT_OCR_OUTPUT_TAG, + parse_chandra_response, parse_olmocr_response, strip_ocr_output_tag, ) from churro_ocr.templates import ( + CHANDRA_OCR_2_MODEL_ID, + CHANDRA_OCR_2_OCR_TEMPLATE, CHURRO_3B_MODEL_ID, CHURRO_3B_XML_TEMPLATE, DEFAULT_OCR_TEMPLATE, @@ -38,7 +41,11 @@ TextPostprocessor = Callable[[str], TextPostprocessorResult] VisionInputBuilder = Callable[[OCRConversation], object] DEFAULT_OCR_MAX_TOKENS = 20_000 +CHANDRA_OCR_MAX_TOKENS = 12_384 OLMOCR_MAX_TOKENS = 8_000 +CHANDRA_MAX_IMAGE_SIZE = (3_072, 2_048) +CHANDRA_MIN_IMAGE_SIZE = (1_792, 28) +CHANDRA_IMAGE_GRID_SIZE = 28 OLMOCR_TARGET_LONGEST_IMAGE_DIM = 1_288 @@ -85,6 +92,56 @@ def olmocr_text_postprocessor(text: str) -> TextPostprocessorResult: return parse_olmocr_response(text) +def chandra_image_preprocessor(image: Image.Image) -> Image.Image: + """Resize an image using Chandra OCR 2's pixel-budget and 28px-grid scaling.""" + width, height = image.size + if width <= 0 or height <= 0: + return ensure_rgb(image) + + max_pixels = CHANDRA_MAX_IMAGE_SIZE[0] * CHANDRA_MAX_IMAGE_SIZE[1] + min_pixels = CHANDRA_MIN_IMAGE_SIZE[0] * CHANDRA_MIN_IMAGE_SIZE[1] + current_pixels = width * height + scale = 1.0 + if current_pixels > max_pixels: + scale = (max_pixels / current_pixels) ** 0.5 + elif current_pixels < min_pixels: + scale = (min_pixels / current_pixels) ** 0.5 + + original_aspect_ratio = width / height + width_blocks = max(1, round((width * scale) / CHANDRA_IMAGE_GRID_SIZE)) + height_blocks = max(1, round((height * scale) / CHANDRA_IMAGE_GRID_SIZE)) + + while (width_blocks * height_blocks * CHANDRA_IMAGE_GRID_SIZE**2) > max_pixels: + if width_blocks == 1 and height_blocks == 1: + break + if width_blocks == 1: + height_blocks -= 1 + continue + if height_blocks == 1: + width_blocks -= 1 + continue + + width_loss = abs(((width_blocks - 1) / height_blocks) - original_aspect_ratio) + height_loss = abs((width_blocks / (height_blocks - 1)) - original_aspect_ratio) + if width_loss < height_loss: + width_blocks -= 1 + else: + height_blocks -= 1 + + new_size = ( + width_blocks * CHANDRA_IMAGE_GRID_SIZE, + height_blocks * CHANDRA_IMAGE_GRID_SIZE, + ) + if new_size == (width, height): + return ensure_rgb(image) + return ensure_rgb(image.resize(new_size, resample=Image.Resampling.LANCZOS)) + + +def chandra_text_postprocessor(text: str) -> TextPostprocessorResult: + """Extract plain text and metadata from Chandra OCR 2 HTML-layout output.""" + return parse_chandra_response(text) + + @dataclass(slots=True, frozen=True) class LiteLLMTransportConfig: """Shared transport config for LiteLLM-based multimodal requests. @@ -223,6 +280,30 @@ def churro_3b_profile() -> OCRModelProfile: ) +def chandra_ocr_2_profile() -> OCRModelProfile: + """Return the built-in ``datalab-to/chandra-ocr-2`` OCR profile.""" + return OCRModelProfile( + profile_name=CHANDRA_OCR_2_MODEL_ID, + template=CHANDRA_OCR_2_OCR_TEMPLATE, + image_preprocessor=chandra_image_preprocessor, + text_postprocessor=chandra_text_postprocessor, + display_name="chandra-ocr-2", + transport=LiteLLMTransportConfig( + completion_kwargs={ + "max_tokens": CHANDRA_OCR_MAX_TOKENS, + "temperature": 0.0, + "top_p": 0.1, + } + ), + huggingface=HuggingFaceOptions( + generation_kwargs={ + "max_new_tokens": CHANDRA_OCR_MAX_TOKENS, + }, + backend_variant="chandra-ocr-2", + ), + ) + + def dots_ocr_1_5_profile() -> OCRModelProfile: """Return the built-in ``kristaller486/dots.ocr-1.5`` OCR profile. @@ -282,12 +363,14 @@ def olmocr_2_7b_1025_fp8_profile() -> OCRModelProfile: def _profile_registry() -> dict[str, OCRModelProfile]: default_profile = default_ocr_profile() churro_profile = churro_3b_profile() + chandra_profile = chandra_ocr_2_profile() dots_profile = dots_ocr_1_5_profile() olmocr_profile = olmocr_2_7b_1025_profile() olmocr_fp8_profile = olmocr_2_7b_1025_fp8_profile() return { default_profile.profile_name: default_profile, churro_profile.profile_name: churro_profile, + chandra_profile.profile_name: chandra_profile, dots_profile.profile_name: dots_profile, olmocr_profile.profile_name: olmocr_profile, olmocr_fp8_profile.profile_name: olmocr_fp8_profile, @@ -324,6 +407,9 @@ def resolve_ocr_profile( __all__ = [ "AzureDocumentIntelligenceOptions", "DEFAULT_OCR_MAX_TOKENS", + "chandra_image_preprocessor", + "chandra_ocr_2_profile", + "chandra_text_postprocessor", "default_ocr_image_preprocessor", "default_ocr_profile", "default_ocr_text_postprocessor", diff --git a/src/churro_ocr/templates/__init__.py b/src/churro_ocr/templates/__init__.py index a8d97c9..d8a1f30 100644 --- a/src/churro_ocr/templates/__init__.py +++ b/src/churro_ocr/templates/__init__.py @@ -9,6 +9,8 @@ ) from churro_ocr.templates.hf import HFChatTemplate from churro_ocr.templates.presets import ( + CHANDRA_OCR_2_MODEL_ID, + CHANDRA_OCR_2_OCR_TEMPLATE, CHURRO_3B_MODEL_ID, CHURRO_3B_XML_TEMPLATE, DEFAULT_OCR_TEMPLATE, @@ -24,6 +26,8 @@ "build_ocr_conversation", "CHURRO_3B_MODEL_ID", "CHURRO_3B_XML_TEMPLATE", + "CHANDRA_OCR_2_MODEL_ID", + "CHANDRA_OCR_2_OCR_TEMPLATE", "DEFAULT_OCR_TEMPLATE", "DOTS_OCR_1_5_MODEL_ID", "DOTS_OCR_1_5_OCR_PROMPT", diff --git a/src/churro_ocr/templates/presets.py b/src/churro_ocr/templates/presets.py index 8270444..ffd6009 100644 --- a/src/churro_ocr/templates/presets.py +++ b/src/churro_ocr/templates/presets.py @@ -3,6 +3,7 @@ from __future__ import annotations from churro_ocr.prompts import ( + CHANDRA_OCR_LAYOUT_PROMPT, DEFAULT_OCR_SYSTEM_PROMPT, DEFAULT_OCR_USER_PROMPT, OLMOCR_V4_YAML_PROMPT, @@ -10,6 +11,7 @@ from churro_ocr.templates.hf import HFChatTemplate CHURRO_3B_MODEL_ID = "stanford-oval/churro-3B" +CHANDRA_OCR_2_MODEL_ID = "datalab-to/chandra-ocr-2" DOTS_OCR_1_5_MODEL_ID = "kristaller486/dots.ocr-1.5" OLMOCR_2_7B_1025_MODEL_ID = "allenai/olmOCR-2-7B-1025" OLMOCR_2_7B_1025_FP8_MODEL_ID = "allenai/olmOCR-2-7B-1025-FP8" @@ -22,6 +24,10 @@ system_message="Transcribe the entirety of this historical document to XML format.", user_prompt=None, ) +CHANDRA_OCR_2_OCR_TEMPLATE = HFChatTemplate( + system_message=None, + user_prompt=CHANDRA_OCR_LAYOUT_PROMPT, +) DOTS_OCR_1_5_OCR_PROMPT = "Extract the text content from this image." DOTS_OCR_1_5_OCR_TEMPLATE = HFChatTemplate( system_message=None, @@ -37,6 +43,8 @@ __all__ = [ "CHURRO_3B_MODEL_ID", "CHURRO_3B_XML_TEMPLATE", + "CHANDRA_OCR_2_MODEL_ID", + "CHANDRA_OCR_2_OCR_TEMPLATE", "DEFAULT_OCR_TEMPLATE", "DOTS_OCR_1_5_MODEL_ID", "DOTS_OCR_1_5_OCR_PROMPT", diff --git a/tests/test_cli.py b/tests/test_cli.py index b26be7e..8410b8b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -16,6 +16,8 @@ from churro_ocr.page_detection import DocumentPage, PageDetectionResult from churro_ocr.prompts import DEFAULT_OCR_OUTPUT_TAG from churro_ocr.templates import ( + CHANDRA_OCR_2_MODEL_ID, + CHANDRA_OCR_2_OCR_TEMPLATE, DEFAULT_OCR_TEMPLATE, DOTS_OCR_1_5_OCR_TEMPLATE, OLMOCR_2_7B_1025_MODEL_ID, @@ -241,6 +243,44 @@ def test_build_ocr_backend_aligns_templates_for_olmocr() -> None: } +def test_build_ocr_backend_aligns_templates_for_chandra() -> None: + litellm_backend = cli_module._build_ocr_backend( + backend="litellm", + model=CHANDRA_OCR_2_MODEL_ID, + endpoint=None, + api_key=None, + base_url=None, + api_version=None, + ) + hf_backend = cli_module._build_ocr_backend( + backend="hf", + model=CHANDRA_OCR_2_MODEL_ID, + endpoint=None, + api_key=None, + base_url=None, + api_version=None, + ) + openai_backend = cli_module._build_ocr_backend( + backend="openai-compatible", + model=CHANDRA_OCR_2_MODEL_ID, + endpoint=None, + api_key=None, + base_url="http://127.0.0.1:8000/v1", + api_version=None, + ) + + assert litellm_backend.template == CHANDRA_OCR_2_OCR_TEMPLATE + assert litellm_backend.template == hf_backend.template == openai_backend.template + assert litellm_backend.model_name == "chandra-ocr-2" + assert hf_backend.model_name == "chandra-ocr-2" + assert openai_backend.model_name == "chandra-ocr-2" + assert openai_backend.transport.config.completion_kwargs == { + "max_tokens": 12_384, + "temperature": 0.0, + "top_p": 0.1, + } + + def test_install_command_invokes_runtime_installer( monkeypatch: pytest.MonkeyPatch, cli_runner, diff --git a/tests/test_hf_ocr.py b/tests/test_hf_ocr.py index 78ece49..6373da5 100644 --- a/tests/test_hf_ocr.py +++ b/tests/test_hf_ocr.py @@ -12,15 +12,24 @@ from churro_ocr.errors import ConfigurationError from churro_ocr.ocr import OCRClient from churro_ocr.page_detection import DocumentPage -from churro_ocr.prompts import DEFAULT_OCR_OUTPUT_TAG, OLMOCR_V4_YAML_PROMPT, parse_olmocr_response +from churro_ocr.prompts import ( + CHANDRA_OCR_LAYOUT_PROMPT, + DEFAULT_OCR_OUTPUT_TAG, + OLMOCR_V4_YAML_PROMPT, + parse_chandra_response, + parse_olmocr_response, +) from churro_ocr.providers import OCRBackendSpec, build_ocr_backend from churro_ocr.providers.hf import ( + ChandraOCR2OCRBackend, Churro3BOCRBackend, DotsOCR15OCRBackend, HuggingFaceVisionOCRBackend, ) from churro_ocr.providers.specs import DEFAULT_OCR_MAX_TOKENS from churro_ocr.templates import ( + CHANDRA_OCR_2_MODEL_ID, + CHANDRA_OCR_2_OCR_TEMPLATE, CHURRO_3B_XML_TEMPLATE, DOTS_OCR_1_5_MODEL_ID, DOTS_OCR_1_5_OCR_PROMPT, @@ -28,6 +37,7 @@ OLMOCR_2_7B_1025_MODEL_ID, OLMOCR_2_7B_1025_OCR_TEMPLATE, HFChatTemplate, + OCRConversation, ) @@ -57,6 +67,16 @@ def test_olmocr_template_builds_prompt_before_image() -> None: assert conversation[0]["content"][1]["type"] == "image" +def test_chandra_template_builds_image_before_prompt() -> None: + page = DocumentPage.from_image(Image.new("RGB", (20, 20), color="white")) + + conversation = CHANDRA_OCR_2_OCR_TEMPLATE.build_conversation(page) + + assert conversation[0]["role"] == "user" + assert conversation[0]["content"][0]["type"] == "image" + assert conversation[0]["content"][1]["text"] == CHANDRA_OCR_LAYOUT_PROMPT + + def test_parse_olmocr_response_extracts_plain_text_and_metadata() -> None: text, metadata = parse_olmocr_response( "---\n" @@ -83,6 +103,51 @@ def test_parse_olmocr_response_extracts_plain_text_and_metadata() -> None: assert "Heading" in cast("str", metadata["raw_markdown"]) +def test_parse_chandra_response_extracts_plain_text_and_metadata() -> None: + text, metadata = parse_chandra_response( + '

Title

\n' + '

Paragraph with reference.

\n' + '

' + "Checked item

\n" + '
' + "
YearValue
190042
" + ) + + assert text == "Title\n\nParagraph with reference.\n\n[x] Checked item\n\nYear | Value\n1900 | 42" + assert metadata == { + "raw_html": ( + '

Title

\n' + '

Paragraph with reference.

\n' + '

' + "Checked item

\n" + '
' + "
YearValue
190042
" + ), + } + + +def test_build_ocr_backend_uses_chandra_profile_defaults_for_hf() -> None: + backend = cast( + "ChandraOCR2OCRBackend", + build_ocr_backend( + OCRBackendSpec( + provider="hf", + model=CHANDRA_OCR_2_MODEL_ID, + ) + ), + ) + + assert isinstance(backend, ChandraOCR2OCRBackend) + assert backend.template == CHANDRA_OCR_2_OCR_TEMPLATE + assert backend.model_name == "chandra-ocr-2" + assert backend.generation_kwargs == { + "max_new_tokens": 12_384, + } + assert backend.image_preprocessor(Image.new("RGB", (5_000, 3_000), color="white")).size == (3_248, 1_932) + + def test_build_ocr_backend_uses_olmocr_profile_defaults_for_hf() -> None: backend = cast( "HuggingFaceVisionOCRBackend", @@ -104,6 +169,158 @@ def test_build_ocr_backend_uses_olmocr_profile_defaults_for_hf() -> None: assert backend.image_preprocessor(Image.new("RGB", (5_000, 3_000), color="white")).size == (1_288, 772) +@pytest.mark.asyncio +async def test_chandra_huggingface_backend_matches_upstream_chat_template_and_eos_behavior( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, Any] = {} + + class FakeAttentionMask: + def sum(self, dim: int) -> SimpleNamespace: + captured["attention_mask_sum_dim"] = dim + return SimpleNamespace(tolist=lambda: [3]) + + class FakeBatch(dict[str, object]): + def to(self, device: object) -> FakeBatch: + captured["device"] = device + return self + + class FakeTokenizer: + def __init__(self) -> None: + self.padding_side = "right" + + def convert_tokens_to_ids(self, token: str) -> int: + captured["stop_token_lookup"] = token + return 77 + + class FakeProcessor: + def __init__(self) -> None: + self.tokenizer = FakeTokenizer() + + def apply_chat_template( + self, + conversation: object, + *, + add_generation_prompt: bool, + tokenize: bool = True, + return_dict: bool = True, + return_tensors: str | None = None, + padding: bool | None = None, + ) -> object: + captured["add_generation_prompt"] = add_generation_prompt + captured["tokenize"] = tokenize + captured["return_dict"] = return_dict + if not tokenize: + captured["render_conversation"] = conversation + return "" + captured["tokenized_conversations"] = conversation + captured["return_tensors"] = return_tensors + captured["padding"] = padding + return FakeBatch( + { + "input_ids": SimpleNamespace(shape=(1, 3)), + "attention_mask": FakeAttentionMask(), + } + ) + + def batch_decode( + self, + generated_ids: object, + *, + skip_special_tokens: bool, + clean_up_tokenization_spaces: bool, + ) -> list[str]: + captured["generated_ids"] = generated_ids + captured["skip_special_tokens"] = skip_special_tokens + captured["clean_up_tokenization_spaces"] = clean_up_tokenization_spaces + return [ + '

Decoded ' + 'output.

' + ] + + class FakeProcessorCls: + @staticmethod + def from_pretrained(model_id: str, **kwargs: object) -> FakeProcessor: + captured["processor_model_id"] = model_id + captured["processor_from_pretrained_kwargs"] = kwargs + return FakeProcessor() + + class FakeModel: + device = "fake-device" + dtype = None + generation_config = SimpleNamespace(eos_token_id=11) + + def eval(self) -> FakeModel: + captured["eval_called"] = True + return self + + def generate(self, **kwargs: object) -> list[list[int | str]]: + captured["generate_kwargs"] = kwargs + return [[0, 1, 2, "completion"]] + + class FakeModelCls: + @staticmethod + def from_pretrained(model_id: str, **kwargs: object) -> FakeModel: + captured["model_model_id"] = model_id + captured["model_from_pretrained_kwargs"] = kwargs + return FakeModel() + + monkeypatch.setattr( + "churro_ocr.providers.hf._load_hf_runtime", + lambda: SimpleNamespace( + processor_cls=FakeProcessorCls, + model_cls=FakeModelCls, + process_vision_info=None, + ), + ) + + backend = cast( + "ChandraOCR2OCRBackend", + build_ocr_backend( + OCRBackendSpec( + provider="hf", + model=CHANDRA_OCR_2_MODEL_ID, + ) + ), + ) + result = await backend.ocr( + DocumentPage.from_image(Image.new("RGBA", (5_000, 3_000), color=(255, 255, 255, 255))) + ) + + assert result.text == "Decoded output." + assert result.metadata == { + "raw_html": ( + '

Decoded ' + 'output.

' + ), + } + assert captured["processor_model_id"] == CHANDRA_OCR_2_MODEL_ID + assert captured["model_model_id"] == CHANDRA_OCR_2_MODEL_ID + assert captured["eval_called"] is True + assert captured["render_conversation"][0]["role"] == "user" + render_content = cast("list[dict[str, object]]", captured["render_conversation"][0]["content"]) + assert render_content[0]["type"] == "image" + render_image = cast("Image.Image", render_content[0]["image"]) + assert render_image.size == (3_248, 1_932) + assert render_image.mode == "RGB" + assert render_content[1] == {"type": "text", "text": CHANDRA_OCR_LAYOUT_PROMPT} + tokenized_conversation = cast("list[OCRConversation]", captured["tokenized_conversations"]) + assert tokenized_conversation[0][0]["role"] == "user" + assert captured["tokenize"] is True + assert captured["return_dict"] is True + assert captured["return_tensors"] == "pt" + assert captured["padding"] is True + assert captured["device"] == "fake-device" + assert captured["attention_mask_sum_dim"] == 1 + assert captured["stop_token_lookup"] == "<|im_end|>" + assert captured["generate_kwargs"]["max_new_tokens"] == 12_384 + assert captured["generate_kwargs"]["eos_token_id"] == [11, 77] + assert captured["model_from_pretrained_kwargs"]["device_map"] == "auto" + assert "dtype" in cast("dict[str, object]", captured["model_from_pretrained_kwargs"]) + assert captured["skip_special_tokens"] is True + assert captured["clean_up_tokenization_spaces"] is False + + @pytest.mark.asyncio async def test_huggingface_vision_ocr_backend_uses_custom_template( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_providers.py b/tests/test_providers.py index 1c70467..ca1a73e 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -13,6 +13,7 @@ from churro_ocr.errors import ProviderError from churro_ocr.page_detection import DocumentPage from churro_ocr.prompts import ( + CHANDRA_OCR_LAYOUT_PROMPT, DEFAULT_BOUNDARY_DETECTION_PROMPT, DEFAULT_OCR_OUTPUT_TAG, OLMOCR_V4_YAML_PROMPT, @@ -44,6 +45,8 @@ ) from churro_ocr.providers.specs import DEFAULT_OCR_MAX_TOKENS from churro_ocr.templates import ( + CHANDRA_OCR_2_MODEL_ID, + CHANDRA_OCR_2_OCR_TEMPLATE, DEFAULT_OCR_TEMPLATE, OLMOCR_2_7B_1025_FP8_MODEL_ID, OLMOCR_2_7B_1025_MODEL_ID, @@ -377,6 +380,28 @@ def test_build_ocr_backend_uses_olmocr_profile_defaults_for_openai_compatible() assert backend.image_preprocessor(Image.new("RGB", (5_000, 3_000), color="white")).size == (1_288, 772) +def test_build_ocr_backend_uses_chandra_profile_defaults_for_openai_compatible() -> None: + backend = cast( + "OpenAICompatibleOCRBackend", + build_ocr_backend( + OCRBackendSpec( + provider="openai-compatible", + model=CHANDRA_OCR_2_MODEL_ID, + transport=LiteLLMTransportConfig(api_base="http://127.0.0.1:8000/v1"), + ) + ), + ) + + assert backend.template == CHANDRA_OCR_2_OCR_TEMPLATE + assert backend.model_name == "chandra-ocr-2" + assert backend.transport.config.completion_kwargs == { + "max_tokens": 12_384, + "temperature": 0.0, + "top_p": 0.1, + } + assert backend.image_preprocessor(Image.new("RGB", (5_000, 3_000), color="white")).size == (3_248, 1_932) + + def test_build_ocr_backend_resolves_olmocr_fp8_profile_defaults_for_openai_compatible() -> None: backend = cast( "OpenAICompatibleOCRBackend", @@ -494,6 +519,91 @@ async def _fake_complete_text( assert prompt_image.mode == "RGB" +@pytest.mark.asyncio +async def test_openai_compatible_backend_uses_chandra_prompt_and_plain_text_postprocessing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + + def _fake_prepare_messages_from_conversation( + self: LiteLLMTransport, + conversation: list[dict[str, object]], + ) -> list[dict[str, object]]: + captured["conversation"] = conversation + captured["completion_kwargs"] = dict(self.config.completion_kwargs) + return [{"role": "user", "content": [{"type": "text", "text": "prompt"}]}] + + async def _fake_complete_text( + self: LiteLLMTransport, + *, + model: str, + messages: list[dict[str, object]], + timeout_seconds: int = 600, + output_json: bool = False, + ) -> str: + captured["model"] = model + captured["messages"] = messages + captured["timeout_seconds"] = timeout_seconds + captured["output_json"] = output_json + captured["completion_kwargs"] = dict(self.config.completion_kwargs) + return ( + '

Ledger

\n' + '

Paragraph with note.

\n' + '
' + "
YearValue
190042
" + ) + + monkeypatch.setattr( + LiteLLMTransport, + "prepare_messages_from_conversation", + _fake_prepare_messages_from_conversation, + ) + monkeypatch.setattr(LiteLLMTransport, "complete_text", _fake_complete_text) + + backend = cast( + "OpenAICompatibleOCRBackend", + build_ocr_backend( + OCRBackendSpec( + provider="openai-compatible", + model=CHANDRA_OCR_2_MODEL_ID, + transport=LiteLLMTransportConfig(api_base="http://127.0.0.1:8000/v1"), + ) + ), + ) + result = await backend.ocr( + DocumentPage.from_image(Image.new("RGBA", (5_000, 3_000), color=(255, 255, 255, 255))) + ) + + assert result.text == "Ledger\n\nParagraph with note.\n\nYear | Value\n1900 | 42" + assert result.metadata == { + "raw_html": ( + '

Ledger

\n' + '

Paragraph with note.

\n' + '
' + "
YearValue
190042
" + ), + } + assert captured["model"] == f"openai/{CHANDRA_OCR_2_MODEL_ID}" + assert captured["messages"] == [{"role": "user", "content": [{"type": "text", "text": "prompt"}]}] + assert captured["timeout_seconds"] == 600 + assert captured["output_json"] is False + assert captured["completion_kwargs"] == { + "max_tokens": 12_384, + "temperature": 0.0, + "top_p": 0.1, + } + conversation = cast("list[dict[str, object]]", captured["conversation"]) + assert conversation[0]["role"] == "user" + user_content = cast("list[dict[str, object]]", conversation[0]["content"]) + assert user_content[0]["type"] == "image" + prompt_image = cast("Image.Image", user_content[0]["image"]) + assert prompt_image.size == (3_248, 1_932) + assert prompt_image.mode == "RGB" + assert user_content[1] == {"type": "text", "text": CHANDRA_OCR_LAYOUT_PROMPT} + + @pytest.mark.asyncio async def test_llm_page_detector_uses_prompt_transport( monkeypatch: pytest.MonkeyPatch, From 98a3684b59e15d42137384aab523cc8ca2eb09c2 Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 6 Apr 2026 01:56:17 +0000 Subject: [PATCH 10/28] fix(evaluation): initialize metrics in worker processes --- tests/test_tooling_evaluate_page.py | 18 +++++++ tests/test_tooling_support.py | 79 +++++++++++++++++++++++++++++ tooling/evaluation/evaluate_page.py | 4 +- 3 files changed, 100 insertions(+), 1 deletion(-) diff --git a/tests/test_tooling_evaluate_page.py b/tests/test_tooling_evaluate_page.py index 1963af4..d0c0293 100644 --- a/tests/test_tooling_evaluate_page.py +++ b/tests/test_tooling_evaluate_page.py @@ -97,3 +97,21 @@ def test_calculate_metrics_strips_output_tags_before_normalization( result = evaluate_page_module.calculate_metrics((example, predicted_text)) assert result["normalized_predicted_text"] == expected + + +def test_calculate_metrics_from_text_lazily_initializes_bleu_metric(monkeypatch) -> None: + init_calls = 0 + fake_metric = SimpleNamespace(compute=lambda *_args, **_kwargs: {"bleu": 0.25}) + + def fake_initialize_metrics() -> None: + nonlocal init_calls + init_calls += 1 + monkeypatch.setattr(evaluate_page_module, "bleu_metric", fake_metric) + + monkeypatch.setattr(evaluate_page_module, "bleu_metric", None) + monkeypatch.setattr(evaluate_page_module, "initialize_metrics", fake_initialize_metrics) + + result = evaluate_page_module.calculate_metrics_from_text("pred", "gold", "English", "Latin") + + assert init_calls == 1 + assert result["bleu"] == 0.25 diff --git a/tests/test_tooling_support.py b/tests/test_tooling_support.py index af2dabe..32b3ad6 100644 --- a/tests/test_tooling_support.py +++ b/tests/test_tooling_support.py @@ -270,6 +270,85 @@ def test_evaluate_page_metric_helpers_cover_initialization_and_single_batch_path assert rows[0]["example_id"] == "row-1" +def test_batch_evaluate_initializes_worker_metrics_for_multi_example( + monkeypatch: pytest.MonkeyPatch, +) -> None: + init_calls = 0 + captured_initializer = None + + def fake_initialize_metrics() -> None: + nonlocal init_calls + init_calls += 1 + + class _FakePool: + def __init__(self, *, processes: int, initializer) -> None: # noqa: ANN001 + nonlocal captured_initializer + assert processes == 2 + captured_initializer = initializer + + def __enter__(self) -> _FakePool: + assert captured_initializer is not None + captured_initializer() + return self + + def __exit__(self, exc_type, exc, tb) -> bool: # noqa: ANN001 + return False + + def imap(self, func, iterable): # noqa: ANN001 + return map(func, iterable) + + monkeypatch.setattr(evaluate_page_module, "initialize_metrics", fake_initialize_metrics) + monkeypatch.setattr(evaluate_page_module.multiprocessing, "cpu_count", lambda: 2) + monkeypatch.setattr(evaluate_page_module.multiprocessing, "Pool", _FakePool) + monkeypatch.setattr( + evaluate_page_module, + "evaluate_page", + lambda inputs: cast( + "PageEvaluationResult", + { + "example_id": inputs[0]["example_id"], + "normalized_levenshtein_similarity": 1.0, + "is_empty": 0.0, + }, + ), + ) + + aggregate, rows = evaluate_page_module.batch_evaluate( + dataset=[ + cast( + "BenchmarkDatasetExample", + { + "image": "image", + "cleaned_transcription": "", + "dataset_id": "dataset-1", + "document_type": "print", + "example_id": "row-1", + "main_language": "English", + "main_script": "Latin", + }, + ), + cast( + "BenchmarkDatasetExample", + { + "image": "image", + "cleaned_transcription": "", + "dataset_id": "dataset-2", + "document_type": "print", + "example_id": "row-2", + "main_language": "English", + "main_script": "Latin", + }, + ), + ], + predicted_texts=["predicted-1", "predicted-2"], + ) + + assert captured_initializer is fake_initialize_metrics + assert init_calls == 2 + assert aggregate == {"normalized_levenshtein_similarity": 1.0, "is_empty": 0.0} + assert [row["example_id"] for row in rows] == ["row-1", "row-2"] + + def test_calculate_metrics_from_text_and_internal_error_fallback(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(evaluate_page_module, "initialize_metrics", lambda: None) monkeypatch.setattr( diff --git a/tooling/evaluation/evaluate_page.py b/tooling/evaluation/evaluate_page.py index 587fd16..7c9038d 100644 --- a/tooling/evaluation/evaluate_page.py +++ b/tooling/evaluation/evaluate_page.py @@ -91,6 +91,8 @@ def _compute_text_metrics_core( has_repetition_flag = has_long_repetition(predicted_text) if is_empty != 1.0: + if bleu_metric is None: + initialize_metrics() assert bleu_metric is not None bleu_result = bleu_metric.compute( predictions=[predicted_text], @@ -216,7 +218,7 @@ def batch_evaluate( return aggregate_results(results) processes = min(8, max(1, multiprocessing.cpu_count())) - with multiprocessing.Pool(processes=processes) as pool: + with multiprocessing.Pool(processes=processes, initializer=initialize_metrics) as pool: results = list( tqdm( pool.imap(evaluate_page, zip(dataset, predicted_texts, strict=False)), From 71d0509119605fcc2cb32de9fb96655c87358108 Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 6 Apr 2026 08:03:05 +0000 Subject: [PATCH 11/28] feat(hf): add Liquid LFM2.5-VL OCR backend --- src/churro_ocr/providers/builder.py | 3 + src/churro_ocr/providers/hf.py | 134 +++++++++++ src/churro_ocr/providers/specs.py | 84 ++++++- src/churro_ocr/templates/__init__.py | 4 + src/churro_ocr/templates/presets.py | 7 + tests/test_hf_ocr.py | 321 ++++++++++++++++++++++++++- 6 files changed, 551 insertions(+), 2 deletions(-) diff --git a/src/churro_ocr/providers/builder.py b/src/churro_ocr/providers/builder.py index 216ed12..706f591 100644 --- a/src/churro_ocr/providers/builder.py +++ b/src/churro_ocr/providers/builder.py @@ -9,6 +9,7 @@ ChandraOCR2OCRBackend, DotsOCR15OCRBackend, HuggingFaceVisionOCRBackend, + LFM25VLOCRBackend, _default_dots_ocr_1_5_model_kwargs, ) from churro_ocr.providers.ocr import ( @@ -166,6 +167,8 @@ def _build_huggingface_backend(spec: OCRBackendSpec, profile: OCRModelProfile) - model_kwargs = _merge_mapping(_default_dots_ocr_1_5_model_kwargs(), model_kwargs) elif options.backend_variant == "chandra-ocr-2": backend_cls = ChandraOCR2OCRBackend + elif options.backend_variant == "lfm2.5-vl": + backend_cls = LFM25VLOCRBackend return backend_cls( model_id=spec.model, template=profile.template, diff --git a/src/churro_ocr/providers/hf.py b/src/churro_ocr/providers/hf.py index f3ad5a0..f0b73b0 100644 --- a/src/churro_ocr/providers/hf.py +++ b/src/churro_ocr/providers/hf.py @@ -35,6 +35,8 @@ CHURRO_3B_XML_TEMPLATE, DOTS_OCR_1_5_MODEL_ID, DOTS_OCR_1_5_OCR_TEMPLATE, + LFM2_5_VL_1_6B_MODEL_ID, + LFM2_5_VL_1_6B_OCR_TEMPLATE, OCRConversation, OCRPromptTemplateLike, ) @@ -662,9 +664,141 @@ def _get_model(self, runtime: _HFRuntime) -> Any: return self._model +@dataclass(slots=True) +class LFM25VLOCRBackend(HuggingFaceVisionOCRBackend): + """Preset OCR backend for ``LiquidAI/LFM2.5-VL-1.6B``.""" + + model_id: str = LFM2_5_VL_1_6B_MODEL_ID + template: OCRPromptTemplateLike = LFM2_5_VL_1_6B_OCR_TEMPLATE + model_name: str | None = "LFM2.5-VL-1.6B" + _has_tied_lm_head: bool = field(default=False, init=False, repr=False) + + def _get_processor(self, runtime: _HFRuntime) -> Any: + processor = super()._get_processor(runtime) + tokenizer = getattr(processor, "tokenizer", None) + if tokenizer is not None and getattr(tokenizer, "padding_side", None) != "left": + tokenizer.padding_side = "left" + return processor + + def _get_model(self, runtime: _HFRuntime) -> Any: + model = super()._get_model(runtime) + if self._has_tied_lm_head: + return model + + with self._init_lock: + if self._has_tied_lm_head: + return model + lm_head = getattr(model, "lm_head", None) + get_input_embeddings = getattr(model, "get_input_embeddings", None) + if lm_head is None or not callable(get_input_embeddings): + self._has_tied_lm_head = True + return model + input_embeddings = get_input_embeddings() + weight = getattr(input_embeddings, "weight", None) + if weight is not None: + lm_head.weight = weight + self._has_tied_lm_head = True + return model + + def _build_lfm_batch( + self, + processor: Any, + conversations: OCRConversation | list[OCRConversation], + *, + padding: bool, + ) -> Any: + processor_apply = getattr(processor, "apply_chat_template", None) + if not callable(processor_apply): + raise ConfigurationError( + "Liquid LFM2.5-VL requires `processor.apply_chat_template(...)` support." + ) + return processor_apply( + conversations, + add_generation_prompt=True, + tokenize=True, + return_dict=True, + return_tensors="pt", + padding=padding, + ) + + def _ocr_sync(self, page: DocumentPage) -> OCRResult: + prepared_page = preprocess_backend_page( + page, + image_preprocessor=self.image_preprocessor, + ) + runtime = self._load_runtime() + processor = self._get_processor(runtime) + model = self._get_model(runtime) + + rendered, conversation = render_ocr_prompt( + processor, + self.template, + prepared_page, + add_generation_prompt=True, + ) + self._log_prompt_payload( + rendered_prompt=rendered, + conversation=conversation, + batch_size=1, + ) + batch = self._build_lfm_batch(processor, conversation, padding=False) + batch = _move_batch_to_model(batch, model) + generated_ids = model.generate(**batch, **self.generation_kwargs) + text = _decode_completion_texts(processor, batch, generated_ids)[0] + return build_ocr_result( + text, + provider_name=self.provider_name, + model_name=self.model_name or self.model_id, + text_postprocessor=self.text_postprocessor, + ) + + def _ocr_batch_sync(self, pages: list[DocumentPage]) -> list[OCRResult]: + if not pages: + return [] + + runtime = self._load_runtime() + processor = self._get_processor(runtime) + model = self._get_model(runtime) + conversations: list[OCRConversation] = [] + + for page in pages: + prepared_page = preprocess_backend_page( + page, + image_preprocessor=self.image_preprocessor, + ) + rendered, conversation = render_ocr_prompt( + processor, + self.template, + prepared_page, + add_generation_prompt=True, + ) + conversations.append(conversation) + if not self._has_logged_prompt: + self._log_prompt_payload( + rendered_prompt=rendered, + conversation=conversation, + batch_size=len(pages), + ) + + batch = self._build_lfm_batch(processor, conversations, padding=True) + batch = _move_batch_to_model(batch, model) + generated_ids = model.generate(**batch, **self.generation_kwargs) + texts = _decode_completion_texts(processor, batch, generated_ids) + return [ + build_ocr_result( + text, + provider_name=self.provider_name, + model_name=self.model_name or self.model_id, + text_postprocessor=self.text_postprocessor, + ) + for text in texts + ] + + __all__ = [ "ChandraOCR2OCRBackend", "Churro3BOCRBackend", "DotsOCR15OCRBackend", "HuggingFaceVisionOCRBackend", + "LFM25VLOCRBackend", ] diff --git a/src/churro_ocr/providers/specs.py b/src/churro_ocr/providers/specs.py index 43f46c9..1b753c0 100644 --- a/src/churro_ocr/providers/specs.py +++ b/src/churro_ocr/providers/specs.py @@ -2,9 +2,10 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Sequence from dataclasses import dataclass, field from pathlib import Path +import re from typing import TYPE_CHECKING, Any, Literal from PIL import Image @@ -24,6 +25,8 @@ DEFAULT_OCR_TEMPLATE, DOTS_OCR_1_5_MODEL_ID, DOTS_OCR_1_5_OCR_TEMPLATE, + LFM2_5_VL_1_6B_MODEL_ID, + LFM2_5_VL_1_6B_OCR_TEMPLATE, OLMOCR_2_7B_1025_FP8_MODEL_ID, OLMOCR_2_7B_1025_MODEL_ID, OLMOCR_2_7B_1025_OCR_TEMPLATE, @@ -76,6 +79,56 @@ def default_ocr_text_postprocessor(text: str) -> str: return strip_ocr_output_tag(text, output_tag=DEFAULT_OCR_OUTPUT_TAG) +_CHAT_ROLE_PREFIXES = { + "assistant", + "assistant:", + "user", + "user:", + "system", + "system:", + "", + "", + "", + "<|assistant|>", + "<|user|>", + "<|system|>", +} + + +def _strip_leading_chat_scaffold(text: str, *, prompts: Sequence[str]) -> str: + """Remove echoed prompts and leading chat role markers from model output.""" + cleaned = text.strip() + if not cleaned: + return "" + + normalized_prompts = tuple(prompt.strip() for prompt in prompts if prompt and prompt.strip()) + for _ in range(8): + previous = cleaned + lowered = cleaned.casefold() + stripped_prompt = False + for prompt in normalized_prompts: + if lowered.startswith(prompt.casefold()): + cleaned = cleaned[len(prompt) :].lstrip() + stripped_prompt = True + break + if stripped_prompt: + continue + + lines = cleaned.splitlines() + if not lines: + return "" + first_line = lines[0].strip() + if first_line.casefold() in _CHAT_ROLE_PREFIXES: + cleaned = "\n".join(lines[1:]).lstrip() + continue + if re.fullmatch(r"<\|?(?:assistant|user|system)\|?>", first_line, flags=re.IGNORECASE): + cleaned = "\n".join(lines[1:]).lstrip() + continue + if cleaned == previous: + break + return cleaned.strip() + + def olmocr_image_preprocessor(image: Image.Image) -> Image.Image: """Resize an image to olmOCR's expected 1288px longest side and normalize to RGB.""" return ensure_rgb( @@ -92,6 +145,13 @@ def olmocr_text_postprocessor(text: str) -> TextPostprocessorResult: return parse_olmocr_response(text) +def lfm2_5_vl_text_postprocessor(text: str) -> str: + """Strip Liquid LFM2.5-VL chat scaffold and OCR wrapper tags.""" + prompt = getattr(LFM2_5_VL_1_6B_OCR_TEMPLATE, "user_prompt", None) + cleaned = _strip_leading_chat_scaffold(text, prompts=[prompt] if isinstance(prompt, str) else []) + return strip_ocr_output_tag(cleaned, output_tag=DEFAULT_OCR_OUTPUT_TAG) + + def chandra_image_preprocessor(image: Image.Image) -> Image.Image: """Resize an image using Chandra OCR 2's pixel-budget and 28px-grid scaling.""" width, height = image.size @@ -344,6 +404,24 @@ def _olmocr_profile(*, profile_name: str, display_name: str) -> OCRModelProfile: ) +def lfm2_5_vl_1_6b_profile() -> OCRModelProfile: + """Return the built-in ``LiquidAI/LFM2.5-VL-1.6B`` OCR profile.""" + return OCRModelProfile( + profile_name=LFM2_5_VL_1_6B_MODEL_ID, + template=LFM2_5_VL_1_6B_OCR_TEMPLATE, + text_postprocessor=lfm2_5_vl_text_postprocessor, + display_name="LFM2.5-VL-1.6B", + huggingface=HuggingFaceOptions( + generation_kwargs={ + "max_new_tokens": 512, + "do_sample": False, + "repetition_penalty": 1.05, + }, + backend_variant="lfm2.5-vl", + ), + ) + + def olmocr_2_7b_1025_profile() -> OCRModelProfile: """Return the built-in ``allenai/olmOCR-2-7B-1025`` OCR profile.""" return _olmocr_profile( @@ -365,6 +443,7 @@ def _profile_registry() -> dict[str, OCRModelProfile]: churro_profile = churro_3b_profile() chandra_profile = chandra_ocr_2_profile() dots_profile = dots_ocr_1_5_profile() + lfm2_5_vl_profile = lfm2_5_vl_1_6b_profile() olmocr_profile = olmocr_2_7b_1025_profile() olmocr_fp8_profile = olmocr_2_7b_1025_fp8_profile() return { @@ -372,6 +451,7 @@ def _profile_registry() -> dict[str, OCRModelProfile]: churro_profile.profile_name: churro_profile, chandra_profile.profile_name: chandra_profile, dots_profile.profile_name: dots_profile, + lfm2_5_vl_profile.profile_name: lfm2_5_vl_profile, olmocr_profile.profile_name: olmocr_profile, olmocr_fp8_profile.profile_name: olmocr_fp8_profile, } @@ -415,6 +495,8 @@ def resolve_ocr_profile( "default_ocr_text_postprocessor", "HuggingFaceOptions", "identity_text_postprocessor", + "lfm2_5_vl_text_postprocessor", + "lfm2_5_vl_1_6b_profile", "ImagePreprocessor", "LiteLLMTransportConfig", "MistralOptions", diff --git a/src/churro_ocr/templates/__init__.py b/src/churro_ocr/templates/__init__.py index d8a1f30..64c264c 100644 --- a/src/churro_ocr/templates/__init__.py +++ b/src/churro_ocr/templates/__init__.py @@ -17,6 +17,8 @@ DOTS_OCR_1_5_MODEL_ID, DOTS_OCR_1_5_OCR_PROMPT, DOTS_OCR_1_5_OCR_TEMPLATE, + LFM2_5_VL_1_6B_MODEL_ID, + LFM2_5_VL_1_6B_OCR_TEMPLATE, OLMOCR_2_7B_1025_FP8_MODEL_ID, OLMOCR_2_7B_1025_MODEL_ID, OLMOCR_2_7B_1025_OCR_TEMPLATE, @@ -32,6 +34,8 @@ "DOTS_OCR_1_5_MODEL_ID", "DOTS_OCR_1_5_OCR_PROMPT", "DOTS_OCR_1_5_OCR_TEMPLATE", + "LFM2_5_VL_1_6B_MODEL_ID", + "LFM2_5_VL_1_6B_OCR_TEMPLATE", "HFChatTemplate", "OLMOCR_2_7B_1025_FP8_MODEL_ID", "OLMOCR_2_7B_1025_MODEL_ID", diff --git a/src/churro_ocr/templates/presets.py b/src/churro_ocr/templates/presets.py index ffd6009..e9c6464 100644 --- a/src/churro_ocr/templates/presets.py +++ b/src/churro_ocr/templates/presets.py @@ -15,6 +15,7 @@ DOTS_OCR_1_5_MODEL_ID = "kristaller486/dots.ocr-1.5" OLMOCR_2_7B_1025_MODEL_ID = "allenai/olmOCR-2-7B-1025" OLMOCR_2_7B_1025_FP8_MODEL_ID = "allenai/olmOCR-2-7B-1025-FP8" +LFM2_5_VL_1_6B_MODEL_ID = "LiquidAI/LFM2.5-VL-1.6B" DEFAULT_OCR_TEMPLATE = HFChatTemplate( system_message=DEFAULT_OCR_SYSTEM_PROMPT, user_prompt=DEFAULT_OCR_USER_PROMPT, @@ -38,6 +39,10 @@ user_prompt=OLMOCR_V4_YAML_PROMPT, user_prompt_first=True, ) +LFM2_5_VL_1_6B_OCR_TEMPLATE = HFChatTemplate( + system_message=None, + user_prompt="Transcribe all visible text from this historical document page in reading order.", +) __all__ = [ @@ -52,4 +57,6 @@ "OLMOCR_2_7B_1025_FP8_MODEL_ID", "OLMOCR_2_7B_1025_MODEL_ID", "OLMOCR_2_7B_1025_OCR_TEMPLATE", + "LFM2_5_VL_1_6B_MODEL_ID", + "LFM2_5_VL_1_6B_OCR_TEMPLATE", ] diff --git a/tests/test_hf_ocr.py b/tests/test_hf_ocr.py index 6373da5..e7a4e2c 100644 --- a/tests/test_hf_ocr.py +++ b/tests/test_hf_ocr.py @@ -25,8 +25,9 @@ Churro3BOCRBackend, DotsOCR15OCRBackend, HuggingFaceVisionOCRBackend, + LFM25VLOCRBackend, ) -from churro_ocr.providers.specs import DEFAULT_OCR_MAX_TOKENS +from churro_ocr.providers.specs import DEFAULT_OCR_MAX_TOKENS, lfm2_5_vl_text_postprocessor from churro_ocr.templates import ( CHANDRA_OCR_2_MODEL_ID, CHANDRA_OCR_2_OCR_TEMPLATE, @@ -34,6 +35,8 @@ DOTS_OCR_1_5_MODEL_ID, DOTS_OCR_1_5_OCR_PROMPT, DOTS_OCR_1_5_OCR_TEMPLATE, + LFM2_5_VL_1_6B_MODEL_ID, + LFM2_5_VL_1_6B_OCR_TEMPLATE, OLMOCR_2_7B_1025_MODEL_ID, OLMOCR_2_7B_1025_OCR_TEMPLATE, HFChatTemplate, @@ -128,6 +131,22 @@ def test_parse_chandra_response_extracts_plain_text_and_metadata() -> None: } +def test_lfm25_text_postprocessor_strips_prompt_and_role_scaffold() -> None: + text = ( + "Transcribe all visible text from this historical document page in reading order.\n" + "assistant\n" + f"<{DEFAULT_OCR_OUTPUT_TAG}>\n" + "decoded text\n" + f"" + ) + + assert lfm2_5_vl_text_postprocessor(text) == "decoded text" + + +def test_lfm25_text_postprocessor_strips_role_only_prefix() -> None: + assert lfm2_5_vl_text_postprocessor("assistant:\nplain text") == "plain text" + + def test_build_ocr_backend_uses_chandra_profile_defaults_for_hf() -> None: backend = cast( "ChandraOCR2OCRBackend", @@ -169,6 +188,27 @@ def test_build_ocr_backend_uses_olmocr_profile_defaults_for_hf() -> None: assert backend.image_preprocessor(Image.new("RGB", (5_000, 3_000), color="white")).size == (1_288, 772) +def test_build_ocr_backend_uses_lfm25_profile_defaults_for_hf() -> None: + backend = cast( + "LFM25VLOCRBackend", + build_ocr_backend( + OCRBackendSpec( + provider="hf", + model=LFM2_5_VL_1_6B_MODEL_ID, + ) + ), + ) + + assert isinstance(backend, LFM25VLOCRBackend) + assert backend.template == LFM2_5_VL_1_6B_OCR_TEMPLATE + assert backend.model_name == "LFM2.5-VL-1.6B" + assert backend.generation_kwargs == { + "max_new_tokens": 512, + "do_sample": False, + "repetition_penalty": 1.05, + } + + @pytest.mark.asyncio async def test_chandra_huggingface_backend_matches_upstream_chat_template_and_eos_behavior( monkeypatch: pytest.MonkeyPatch, @@ -321,6 +361,285 @@ def from_pretrained(model_id: str, **kwargs: object) -> FakeModel: assert captured["clean_up_tokenization_spaces"] is False +@pytest.mark.asyncio +async def test_lfm25_huggingface_backend_uses_tokenized_chat_template_and_ties_lm_head( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, Any] = {} + + class FakeAttentionMask: + def sum(self, dim: int) -> SimpleNamespace: + captured["attention_mask_sum_dim"] = dim + return SimpleNamespace(tolist=lambda: [4]) + + class FakeBatch(dict[str, object]): + def to(self, device: object) -> FakeBatch: + captured["device"] = device + return self + + class FakeProcessor: + def __init__(self) -> None: + self.tokenizer = SimpleNamespace(padding_side="right") + + def apply_chat_template( + self, + conversation: object, + *, + add_generation_prompt: bool, + tokenize: bool, + return_dict: bool | None = None, + return_tensors: str | None = None, + padding: bool | None = None, + ) -> object: + captured.setdefault("chat_calls", []).append( + { + "conversation": conversation, + "add_generation_prompt": add_generation_prompt, + "tokenize": tokenize, + "return_dict": return_dict, + "return_tensors": return_tensors, + "padding": padding, + } + ) + if not tokenize: + return "" + return FakeBatch( + { + "input_ids": SimpleNamespace(shape=(1, 4)), + "attention_mask": FakeAttentionMask(), + } + ) + + def __call__(self, **kwargs: object) -> object: + raise AssertionError("processor(...) should not be used for LFM2.5-VL") + + def batch_decode( + self, + generated_ids: object, + *, + skip_special_tokens: bool, + clean_up_tokenization_spaces: bool, + ) -> list[str]: + captured["generated_ids"] = generated_ids + captured["skip_special_tokens"] = skip_special_tokens + captured["clean_up_tokenization_spaces"] = clean_up_tokenization_spaces + return ["lfm transcription"] + + class FakeProcessorCls: + @staticmethod + def from_pretrained(model_id: str, **kwargs: object) -> FakeProcessor: + captured["processor_model_id"] = model_id + captured["processor_from_pretrained_kwargs"] = kwargs + return FakeProcessor() + + class FakeLmHead: + weight = "original-weight" + + class FakeModel: + device = "fake-device" + dtype = None + + def __init__(self) -> None: + self.lm_head = FakeLmHead() + + def get_input_embeddings(self) -> SimpleNamespace: + captured["get_input_embeddings_called"] = True + return SimpleNamespace(weight="tied-weight") + + def generate(self, **kwargs: object) -> list[list[int | str]]: + captured["generate_kwargs"] = kwargs + return [[0, 1, 2, 3, "completion"]] + + class FakeModelCls: + @staticmethod + def from_pretrained(model_id: str, **kwargs: object) -> FakeModel: + captured["model_model_id"] = model_id + captured["model_from_pretrained_kwargs"] = kwargs + model = FakeModel() + captured["model"] = model + return model + + monkeypatch.setattr( + "churro_ocr.providers.hf._load_hf_runtime", + lambda: SimpleNamespace( + processor_cls=FakeProcessorCls, + model_cls=FakeModelCls, + process_vision_info=None, + ), + ) + + backend = cast( + "LFM25VLOCRBackend", + build_ocr_backend( + OCRBackendSpec( + provider="hf", + model=LFM2_5_VL_1_6B_MODEL_ID, + ) + ), + ) + result = await backend.ocr(DocumentPage.from_image(Image.new("RGB", (32, 32), color="white"))) + + assert result.text == "lfm transcription" + assert captured["processor_model_id"] == LFM2_5_VL_1_6B_MODEL_ID + assert captured["model_model_id"] == LFM2_5_VL_1_6B_MODEL_ID + assert captured["get_input_embeddings_called"] is True + assert cast("FakeModel", captured["model"]).lm_head.weight == "tied-weight" + assert cast("FakeProcessor", backend._processor).tokenizer.padding_side == "left" + assert len(cast("list[dict[str, object]]", captured["chat_calls"])) == 2 + assert cast("list[dict[str, object]]", captured["chat_calls"])[0]["tokenize"] is False + assert cast("list[dict[str, object]]", captured["chat_calls"])[1]["tokenize"] is True + assert cast("list[dict[str, object]]", captured["chat_calls"])[1]["return_dict"] is True + assert cast("list[dict[str, object]]", captured["chat_calls"])[1]["return_tensors"] == "pt" + assert cast("list[dict[str, object]]", captured["chat_calls"])[1]["padding"] is False + assert captured["device"] == "fake-device" + assert captured["attention_mask_sum_dim"] == 1 + assert captured["generate_kwargs"] == { + "input_ids": SimpleNamespace(shape=(1, 4)), + "attention_mask": cast("object", captured["generate_kwargs"]["attention_mask"]), + "max_new_tokens": 512, + "do_sample": False, + "repetition_penalty": 1.05, + } + assert captured["generated_ids"] == [["completion"]] + assert captured["skip_special_tokens"] is True + assert captured["clean_up_tokenization_spaces"] is False + + +@pytest.mark.asyncio +async def test_lfm25_huggingface_backend_batches_pages_with_tokenized_chat_template( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, Any] = {} + + class FakeAttentionMask: + def sum(self, dim: int) -> SimpleNamespace: + captured["attention_mask_sum_dim"] = dim + return SimpleNamespace(tolist=lambda: [4, 4]) + + class FakeBatch(dict[str, object]): + def to(self, device: object) -> FakeBatch: + captured["device"] = device + return self + + class FakeProcessor: + def __init__(self) -> None: + self.tokenizer = SimpleNamespace(padding_side="right") + + def apply_chat_template( + self, + conversation: object, + *, + add_generation_prompt: bool, + tokenize: bool, + return_dict: bool | None = None, + return_tensors: str | None = None, + padding: bool | None = None, + ) -> object: + captured.setdefault("chat_calls", []).append( + { + "conversation": conversation, + "add_generation_prompt": add_generation_prompt, + "tokenize": tokenize, + "return_dict": return_dict, + "return_tensors": return_tensors, + "padding": padding, + } + ) + if not tokenize: + return "" + return FakeBatch( + { + "input_ids": SimpleNamespace(shape=(2, 4)), + "attention_mask": FakeAttentionMask(), + } + ) + + def __call__(self, **kwargs: object) -> object: + raise AssertionError("processor(...) should not be used for LFM2.5-VL batches") + + def batch_decode( + self, + generated_ids: object, + *, + skip_special_tokens: bool, + clean_up_tokenization_spaces: bool, + ) -> list[str]: + captured["generated_ids"] = generated_ids + captured["skip_special_tokens"] = skip_special_tokens + captured["clean_up_tokenization_spaces"] = clean_up_tokenization_spaces + return ["first transcription", "second transcription"] + + class FakeProcessorCls: + @staticmethod + def from_pretrained(model_id: str, **kwargs: object) -> FakeProcessor: + captured["processor_model_id"] = model_id + captured["processor_from_pretrained_kwargs"] = kwargs + return FakeProcessor() + + class FakeLmHead: + weight = "original-weight" + + class FakeModel: + device = "fake-device" + dtype = None + + def __init__(self) -> None: + self.lm_head = FakeLmHead() + + def get_input_embeddings(self) -> SimpleNamespace: + return SimpleNamespace(weight="tied-weight") + + def generate(self, **kwargs: object) -> list[list[int | str]]: + captured["generate_kwargs"] = kwargs + return [ + [0, 1, 2, 3, "first"], + [0, 1, 2, 3, "second"], + ] + + class FakeModelCls: + @staticmethod + def from_pretrained(model_id: str, **kwargs: object) -> FakeModel: + captured["model_model_id"] = model_id + captured["model_from_pretrained_kwargs"] = kwargs + return FakeModel() + + monkeypatch.setattr( + "churro_ocr.providers.hf._load_hf_runtime", + lambda: SimpleNamespace( + processor_cls=FakeProcessorCls, + model_cls=FakeModelCls, + process_vision_info=None, + ), + ) + + backend = cast( + "LFM25VLOCRBackend", + build_ocr_backend( + OCRBackendSpec( + provider="hf", + model=LFM2_5_VL_1_6B_MODEL_ID, + ) + ), + ) + results = await backend.ocr_batch( + [ + DocumentPage.from_image(Image.new("RGB", (32, 32), color="white")), + DocumentPage.from_image(Image.new("RGB", (32, 32), color="white")), + ] + ) + + assert [result.text for result in results] == ["first transcription", "second transcription"] + assert cast("FakeProcessor", backend._processor).tokenizer.padding_side == "left" + assert len(cast("list[dict[str, object]]", captured["chat_calls"])) == 3 + assert cast("list[dict[str, object]]", captured["chat_calls"])[2]["tokenize"] is True + assert cast("list[dict[str, object]]", captured["chat_calls"])[2]["padding"] is True + assert captured["device"] == "fake-device" + assert captured["attention_mask_sum_dim"] == 1 + assert captured["generated_ids"] == [["first"], ["second"]] + assert captured["skip_special_tokens"] is True + assert captured["clean_up_tokenization_spaces"] is False + + @pytest.mark.asyncio async def test_huggingface_vision_ocr_backend_uses_custom_template( monkeypatch: pytest.MonkeyPatch, From aaac23ac5cce0fe6a742f02c0e9bdf175c963e6c Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 6 Apr 2026 08:04:00 +0000 Subject: [PATCH 12/28] fix(mistral): require pinned OCR models and retry transient failures --- docs/cli.md | 2 +- docs/guides/providers.md | 6 +- src/churro_ocr/cli.py | 11 +- src/churro_ocr/providers/builder.py | 3 +- src/churro_ocr/providers/ocr.py | 73 ++++++++- src/churro_ocr/providers/specs.py | 27 +++- tests/test_cli.py | 26 ++++ tests/test_cli_contract.py | 8 + tests/test_provider_api_contracts.py | 21 ++- tests/test_providers.py | 217 ++++++++++++++++++++++++++- tests/test_tooling_benchmark.py | 25 +++ tooling/benchmarking/benchmark.py | 21 ++- 12 files changed, 421 insertions(+), 19 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index f7a1a95..4c85737 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -88,7 +88,7 @@ churro-ocr extract-pages \ | `litellm` | `--model` | Uses LiteLLM credentials and routing. `--base-url`, `--api-key`, and `--api-version` are optional transport overrides. | | `openai-compatible` | `--model`, `--base-url` | For local or self-hosted OpenAI-style servers. `--api-key` is optional. | | `azure` | `--endpoint`, `--api-key` | `--model` is optional. | -| `mistral` | `--api-key` | `--model` defaults to `mistral-ocr-latest`. | +| `mistral` | `--api-key`, `--model` | `--model` must be either `mistral-ocr-2505` or `mistral-ocr-2512`. | | `hf` | `--model` | Local Transformers OCR. | ### `extract-pages` Detectors diff --git a/docs/guides/providers.md b/docs/guides/providers.md index f12565d..90afda1 100644 --- a/docs/guides/providers.md +++ b/docs/guides/providers.md @@ -97,7 +97,7 @@ from churro_ocr.providers import MistralOptions, OCRBackendSpec, build_ocr_backe backend = build_ocr_backend( OCRBackendSpec( provider="mistral", - model="mistral-ocr-latest", + model="mistral-ocr-2512", options=MistralOptions(api_key=""), ) ) @@ -152,7 +152,7 @@ Built-in model-specific profiles are resolved automatically for known models suc | Field | Meaning | | --- | --- | | `provider` | One of `litellm`, `openai-compatible`, `azure`, `mistral`, or `hf`. | -| `model` | Required for `litellm`, `openai-compatible`, and `hf`. Optional for `azure`. Defaults to `mistral-ocr-latest` when omitted for `mistral`. | +| `model` | Required for `litellm`, `openai-compatible`, `mistral`, and `hf`. Optional for `azure`. For `mistral`, use one of `mistral-ocr-2505` or `mistral-ocr-2512`. | | `profile` | `None`, a built-in profile name, or a custom `OCRModelProfile`. | | `transport` | Shared request transport config for LiteLLM-based providers. | | `options` | Provider-specific dataclass matching `provider`. | @@ -165,7 +165,7 @@ Built-in model-specific profiles are resolved automatically for known models suc | `OpenAICompatibleOptions` | `openai-compatible` | None | Use `model_prefix` when your local server expects a provider prefix. | | `HuggingFaceOptions` | `hf` | None | Carries runtime, processor, generation, and template options. | | `AzureDocumentIntelligenceOptions` | `azure` | `endpoint`, `api_key` | `model` is optional for Azure OCR in `OCRBackendSpec`. | -| `MistralOptions` | `mistral` | `api_key` | `model` defaults to `mistral-ocr-latest` when omitted. | +| `MistralOptions` | `mistral` | `api_key` | `model` is required and must be `mistral-ocr-2505` or `mistral-ocr-2512`. | ## Advanced Customization diff --git a/src/churro_ocr/cli.py b/src/churro_ocr/cli.py index 5c4d41f..9a00b21 100644 --- a/src/churro_ocr/cli.py +++ b/src/churro_ocr/cli.py @@ -24,10 +24,15 @@ OpenAICompatibleOptions, build_ocr_backend, ) +from churro_ocr.providers.specs import MISTRAL_OCR_MODEL_IDS, validate_mistral_ocr_model app = typer.Typer(help="churro-ocr library-first CLI") _INSTALL_TARGET_METAVAR = "{" + "|".join(INSTALL_TARGETS) + "}" +_MISTRAL_MODEL_OPTION_ERROR = ( + "--model is required for backend=mistral and must be one of: " + + ", ".join(MISTRAL_OCR_MODEL_IDS) +) def _build_ocr_backend( @@ -84,10 +89,14 @@ def _build_ocr_backend( if backend == "mistral": if not api_key: raise typer.BadParameter("--api-key is required for backend=mistral") + try: + mistral_model = validate_mistral_ocr_model(model) + except ConfigurationError as exc: + raise typer.BadParameter(_MISTRAL_MODEL_OPTION_ERROR) from exc return build_ocr_backend( OCRBackendSpec( provider="mistral", - model=model or "mistral-ocr-latest", + model=mistral_model, options=MistralOptions(api_key=api_key), ) ) diff --git a/src/churro_ocr/providers/builder.py b/src/churro_ocr/providers/builder.py index 706f591..8845f46 100644 --- a/src/churro_ocr/providers/builder.py +++ b/src/churro_ocr/providers/builder.py @@ -27,6 +27,7 @@ OCRModelProfile, OpenAICompatibleOptions, resolve_ocr_profile, + validate_mistral_ocr_model, ) @@ -204,7 +205,7 @@ def _build_mistral_backend(spec: OCRBackendSpec, profile: OCRModelProfile) -> OC options = _ensure_options_type(spec.options, MistralOptions, provider=spec.provider) if options is None or not options.api_key: raise ConfigurationError("OCR provider 'mistral' requires MistralOptions(api_key=...).") - model = spec.model or "mistral-ocr-latest" + model = validate_mistral_ocr_model(spec.model) return MistralOCRBackend( api_key=options.api_key, model=model, diff --git a/src/churro_ocr/providers/ocr.py b/src/churro_ocr/providers/ocr.py index 87836ed..9a80e39 100644 --- a/src/churro_ocr/providers/ocr.py +++ b/src/churro_ocr/providers/ocr.py @@ -4,6 +4,7 @@ import asyncio import base64 +import logging from dataclasses import dataclass, field from io import BytesIO from threading import Lock @@ -24,6 +25,7 @@ TextPostprocessor, default_ocr_image_preprocessor, identity_text_postprocessor, + validate_mistral_ocr_model, ) from churro_ocr.templates import ( DEFAULT_OCR_TEMPLATE, @@ -31,6 +33,13 @@ build_ocr_conversation, ) +logger = logging.getLogger(__name__) +_MISTRAL_TRANSIENT_STATUS_CODES = frozenset({408, 429, 500, 502, 503, 504, 520, 521, 522, 524}) +_MISTRAL_MAX_ATTEMPTS = 6 +_MISTRAL_INITIAL_BACKOFF_SECONDS = 1.0 +_MISTRAL_MAX_BACKOFF_SECONDS = 16.0 +_MISTRAL_REQUEST_TIMEOUT_SECONDS = 60.0 + def _with_default_ocr_completion_kwargs(config: LiteLLMTransportConfig) -> LiteLLMTransportConfig: completion_kwargs: dict[str, object] = {"max_tokens": DEFAULT_OCR_MAX_TOKENS} @@ -47,6 +56,30 @@ def _with_default_ocr_completion_kwargs(config: LiteLLMTransportConfig) -> LiteL ) +def _is_retryable_mistral_error(exc: Exception) -> bool: + status_code = getattr(exc, "status_code", None) + if isinstance(status_code, int): + return status_code in _MISTRAL_TRANSIENT_STATUS_CODES + return exc.__class__.__module__.startswith("httpx") or isinstance(exc, TimeoutError) + + +def _get_mistral_retry_delay_seconds(exc: Exception, attempt: int) -> float: + headers = getattr(exc, "headers", None) + if headers is None: + raw_response = getattr(exc, "raw_response", None) + headers = getattr(raw_response, "headers", None) + retry_after = headers.get("retry-after") if hasattr(headers, "get") else None + if retry_after is not None: + try: + return max(0.0, float(retry_after)) + except (TypeError, ValueError): + pass + return min( + _MISTRAL_INITIAL_BACKOFF_SECONDS * (2 ** (attempt - 1)), + _MISTRAL_MAX_BACKOFF_SECONDS, + ) + + @dataclass(slots=True) class LiteLLMVisionOCRBackend(OCRBackend): """OCR backend for any LiteLLM-supported multimodal provider. @@ -241,14 +274,14 @@ class MistralOCRBackend(OCRBackend): """Mistral OCR backend. :param api_key: Mistral API key used for OCR requests. - :param model: Mistral OCR model identifier. + :param model: Pinned Mistral OCR model identifier. :param model_name: Optional human-readable model name for result metadata. :param image_preprocessor: Image preprocessor applied before OCR. :param text_postprocessor: Text postprocessor applied after OCR. """ api_key: str - model: str = "mistral-ocr-latest" + model: str model_name: str | None = None image_preprocessor: ImagePreprocessor = default_ocr_image_preprocessor text_postprocessor: TextPostprocessor = identity_text_postprocessor @@ -257,6 +290,10 @@ class MistralOCRBackend(OCRBackend): _has_logged_prompt: bool = field(default=False, init=False, repr=False) _prompt_log_lock: Lock = field(default_factory=Lock, init=False, repr=False) + def __post_init__(self) -> None: + """Reject unsupported Mistral OCR aliases and unpinned model ids.""" + validate_mistral_ocr_model(self.model, context="Mistral OCR backend") + async def _get_client(self) -> Any: client = self._client if client is not None: @@ -302,10 +339,34 @@ async def ocr(self, page: DocumentPage) -> OCRResult: set_logged=lambda: setattr(self, "_has_logged_prompt", True), ) client = await self._get_client() - response = await client.ocr.process_async( - model=self.model, - document={"type": "image_url", "image_url": image_url}, - ) + document = {"type": "image_url", "image_url": image_url} + response: Any | None = None + for attempt in range(1, _MISTRAL_MAX_ATTEMPTS + 1): + try: + response = await asyncio.wait_for( + client.ocr.process_async( + model=self.model, + document=document, + ), + timeout=_MISTRAL_REQUEST_TIMEOUT_SECONDS, + ) + break + except Exception as exc: + if attempt >= _MISTRAL_MAX_ATTEMPTS or not _is_retryable_mistral_error(exc): + raise + delay_seconds = _get_mistral_retry_delay_seconds(exc, attempt) + logger.warning( + "Transient Mistral OCR failure for model %s " + "(status=%s, attempt=%s/%s); retrying in %.1fs.", + self.model, + getattr(exc, "status_code", "unknown"), + attempt, + _MISTRAL_MAX_ATTEMPTS, + delay_seconds, + ) + await asyncio.sleep(delay_seconds) + if response is None: # pragma: no cover - loop exits early via exception + raise ProviderError("Mistral OCR request failed before a response was returned.") if not response.pages: raise ProviderError("Mistral OCR returned no pages.") return build_ocr_result( diff --git a/src/churro_ocr/providers/specs.py b/src/churro_ocr/providers/specs.py index 1b753c0..fbbcd8f 100644 --- a/src/churro_ocr/providers/specs.py +++ b/src/churro_ocr/providers/specs.py @@ -6,11 +6,12 @@ from dataclasses import dataclass, field from pathlib import Path import re -from typing import TYPE_CHECKING, Any, Literal +from typing import TYPE_CHECKING, Any, Literal, cast from PIL import Image from churro_ocr._internal.image import ensure_rgb, prepare_ocr_image, resize_image_to_fit +from churro_ocr.errors import ConfigurationError from churro_ocr.prompts import ( DEFAULT_OCR_OUTPUT_TAG, parse_chandra_response, @@ -39,6 +40,7 @@ OCRProvider = Literal["litellm", "openai-compatible", "azure", "mistral", "hf"] +MistralOCRModel = Literal["mistral-ocr-2505", "mistral-ocr-2512"] ImagePreprocessor = Callable[[Image.Image], Image.Image] TextPostprocessorResult = str | tuple[str, dict[str, Any]] TextPostprocessor = Callable[[str], TextPostprocessorResult] @@ -50,6 +52,26 @@ CHANDRA_MIN_IMAGE_SIZE = (1_792, 28) CHANDRA_IMAGE_GRID_SIZE = 28 OLMOCR_TARGET_LONGEST_IMAGE_DIM = 1_288 +MISTRAL_OCR_MODEL_IDS: tuple[MistralOCRModel, ...] = ( + "mistral-ocr-2505", + "mistral-ocr-2512", +) + + +def validate_mistral_ocr_model( + model: str | None, + *, + context: str = "OCR provider 'mistral'", +) -> MistralOCRModel: + """Return a supported pinned Mistral OCR model id or raise a configuration error.""" + supported_models = ", ".join(MISTRAL_OCR_MODEL_IDS) + if model is None: + raise ConfigurationError(f"{context} requires `model` to be one of: {supported_models}.") + if model not in MISTRAL_OCR_MODEL_IDS: + raise ConfigurationError( + f"{context} only supports `model` values {supported_models}; got {model!r}." + ) + return cast(MistralOCRModel, model) def identity_text_postprocessor(text: str) -> str: @@ -499,6 +521,8 @@ def resolve_ocr_profile( "lfm2_5_vl_1_6b_profile", "ImagePreprocessor", "LiteLLMTransportConfig", + "MistralOCRModel", + "MISTRAL_OCR_MODEL_IDS", "MistralOptions", "olmocr_image_preprocessor", "olmocr_text_postprocessor", @@ -508,5 +532,6 @@ def resolve_ocr_profile( "OpenAICompatibleOptions", "resolve_ocr_profile", "TextPostprocessor", + "validate_mistral_ocr_model", "VisionInputBuilder", ] diff --git a/tests/test_cli.py b/tests/test_cli.py index 8410b8b..09ca454 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -7,6 +7,7 @@ from types import SimpleNamespace import pytest +import typer from PIL import Image import churro_ocr.cli as cli_module @@ -140,6 +141,31 @@ def test_build_ocr_backend_aligns_templates_for_generic_models() -> None: assert f"" in litellm_backend.template.user_prompt +def test_build_ocr_backend_requires_pinned_mistral_model() -> None: + with pytest.raises(typer.BadParameter, match="mistral-ocr-2505, mistral-ocr-2512"): + cli_module._build_ocr_backend( + backend="mistral", + model="mistral-ocr-latest", + endpoint=None, + api_key="secret", + base_url=None, + api_version=None, + ) + + +def test_build_ocr_backend_accepts_pinned_mistral_model() -> None: + mistral_backend = cli_module._build_ocr_backend( + backend="mistral", + model="mistral-ocr-2512", + endpoint=None, + api_key="secret", + base_url=None, + api_version=None, + ) + + assert mistral_backend.model == "mistral-ocr-2512" + + def test_build_ocr_backend_aligns_templates_for_dots() -> None: litellm_backend = cli_module._build_ocr_backend( backend="litellm", diff --git a/tests/test_cli_contract.py b/tests/test_cli_contract.py index 4ff9856..d269117 100644 --- a/tests/test_cli_contract.py +++ b/tests/test_cli_contract.py @@ -27,6 +27,14 @@ def sample_image_path(write_image_file) -> Path: ), (["--backend", "azure"], ("--endpoint and --api-key are required for backend=azure",)), (["--backend", "mistral"], ("--api-key is required for backend=mistral",)), + ( + ["--backend", "mistral", "--api-key", "secret"], + ("--model is required for backend=mistral", "mistral-ocr-2505", "mistral-ocr-2512"), + ), + ( + ["--backend", "mistral", "--api-key", "secret", "--model", "mistral-ocr-latest"], + ("must be one of", "mistral-ocr-2505", "mistral-ocr-2512"), + ), (["--backend", "hf"], ("--model is required for backend=hf",)), ], ) diff --git a/tests/test_provider_api_contracts.py b/tests/test_provider_api_contracts.py index d44f021..71aa64c 100644 --- a/tests/test_provider_api_contracts.py +++ b/tests/test_provider_api_contracts.py @@ -68,6 +68,24 @@ def test_provider_dir_lists_lazy_exports() -> None: OCRBackendSpec(provider="mistral"), "OCR provider 'mistral' requires MistralOptions(api_key=...).", ), + ( + OCRBackendSpec( + provider="mistral", + options=MistralOptions(api_key="secret"), + ), + "OCR provider 'mistral' requires `model` to be one of: mistral-ocr-2505, mistral-ocr-2512.", + ), + ( + OCRBackendSpec( + provider="mistral", + model="mistral-ocr-latest", + options=MistralOptions(api_key="secret"), + ), + ( + "OCR provider 'mistral' only supports `model` values mistral-ocr-2505, " + "mistral-ocr-2512; got 'mistral-ocr-latest'." + ), + ), ( OCRBackendSpec( provider="hf", @@ -135,6 +153,7 @@ def test_build_ocr_backend_accepts_provider_specific_options() -> None: mistral_backend = build_ocr_backend( OCRBackendSpec( provider="mistral", + model="mistral-ocr-2512", options=MistralOptions(api_key="secret"), ) ) @@ -143,4 +162,4 @@ def test_build_ocr_backend_accepts_provider_specific_options() -> None: assert isinstance(mistral_backend, MistralOCRBackend) assert azure_backend.model_id == "layout-model" assert azure_backend.model_name == "layout-model" - assert mistral_backend.model == "mistral-ocr-latest" + assert mistral_backend.model == "mistral-ocr-2512" diff --git a/tests/test_providers.py b/tests/test_providers.py index ca1a73e..3302c7d 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -312,7 +312,7 @@ async def test_mistral_ocr_backend_reuses_client(monkeypatch: pytest.MonkeyPatch class FakeOCRNamespace: async def process_async(self, *, model: str, document: dict[str, str]) -> SimpleNamespace: calls["requests"] += 1 - assert model == "mistral-ocr-latest" + assert model == "mistral-ocr-2512" assert document == { "type": "image_url", "image_url": "data:image/jpeg;base64,encoded-image", @@ -345,6 +345,7 @@ def __init__(self, *, api_key: str) -> None: build_ocr_backend( OCRBackendSpec( provider="mistral", + model="mistral-ocr-2512", options=MistralOptions(api_key="secret"), ) ), @@ -359,6 +360,220 @@ def __init__(self, *, api_key: str) -> None: assert calls == {"client_inits": 1, "requests": 2} +@pytest.mark.asyncio +async def test_mistral_ocr_backend_retries_transient_errors( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls = {"client_inits": 0, "requests": 0} + image = Image.new("RGB", (10, 10), color="white") + sleep_calls: list[float] = [] + + class FakeMistralError(Exception): + def __init__(self, status_code: int, headers: dict[str, str] | None = None) -> None: + self.status_code = status_code + self.headers = headers or {} + self.raw_response = SimpleNamespace(headers=self.headers) + super().__init__(f"Status {status_code}") + + class FakeOCRNamespace: + async def process_async(self, *, model: str, document: dict[str, str]) -> SimpleNamespace: + calls["requests"] += 1 + assert model == "mistral-ocr-2512" + assert document == { + "type": "image_url", + "image_url": "data:image/jpeg;base64,encoded-image", + } + if calls["requests"] < 3: + raise FakeMistralError(520) + return SimpleNamespace(pages=[SimpleNamespace(markdown="mistral text")]) + + class FakeMistralClient: + def __init__(self, *, api_key: str) -> None: + calls["client_inits"] += 1 + assert api_key == "secret" + self.ocr = FakeOCRNamespace() + + async def _fake_sleep(delay: float) -> None: + sleep_calls.append(delay) + + mistral_module = ModuleType("mistralai") + cast("Any", mistral_module).Mistral = FakeMistralClient + monkeypatch.setitem(sys.modules, "mistralai", mistral_module) + monkeypatch.setattr( + "churro_ocr.providers.ocr.image_to_base64", + lambda actual_image, format_name: ( + ( + "image/jpeg", + "encoded-image", + ) + if actual_image.size == image.size and actual_image.mode == "RGB" and format_name == "JPEG" + else ("", "") + ), + ) + monkeypatch.setattr("churro_ocr.providers.ocr.asyncio.sleep", _fake_sleep) + + backend = cast( + "MistralOCRBackend", + build_ocr_backend( + OCRBackendSpec( + provider="mistral", + model="mistral-ocr-2512", + options=MistralOptions(api_key="secret"), + ) + ), + ) + page = DocumentPage(page_index=0, image=image, source_index=0) + + result = await backend.ocr(page) + + assert result.text == "mistral text" + assert calls == {"client_inits": 1, "requests": 3} + assert sleep_calls == [1.0, 2.0] + + +@pytest.mark.asyncio +async def test_mistral_ocr_backend_retries_request_timeouts( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls = {"client_inits": 0, "requests": 0, "wait_for": 0} + image = Image.new("RGB", (10, 10), color="white") + sleep_calls: list[float] = [] + + class FakeOCRNamespace: + async def process_async(self, *, model: str, document: dict[str, str]) -> SimpleNamespace: + calls["requests"] += 1 + assert model == "mistral-ocr-2512" + assert document == { + "type": "image_url", + "image_url": "data:image/jpeg;base64,encoded-image", + } + return SimpleNamespace(pages=[SimpleNamespace(markdown="mistral text")]) + + class FakeMistralClient: + def __init__(self, *, api_key: str) -> None: + calls["client_inits"] += 1 + assert api_key == "secret" + self.ocr = FakeOCRNamespace() + + async def _fake_sleep(delay: float) -> None: + sleep_calls.append(delay) + + async def _fake_wait_for(awaitable: Any, **kwargs: float) -> Any: + calls["wait_for"] += 1 + timeout = kwargs["timeout"] + assert timeout == 60.0 + if calls["wait_for"] == 1: + close = getattr(awaitable, "close", None) + if callable(close): + close() + raise TimeoutError + return await awaitable + + mistral_module = ModuleType("mistralai") + cast("Any", mistral_module).Mistral = FakeMistralClient + monkeypatch.setitem(sys.modules, "mistralai", mistral_module) + monkeypatch.setattr( + "churro_ocr.providers.ocr.image_to_base64", + lambda actual_image, format_name: ( + ( + "image/jpeg", + "encoded-image", + ) + if actual_image.size == image.size and actual_image.mode == "RGB" and format_name == "JPEG" + else ("", "") + ), + ) + monkeypatch.setattr("churro_ocr.providers.ocr.asyncio.sleep", _fake_sleep) + monkeypatch.setattr("churro_ocr.providers.ocr.asyncio.wait_for", _fake_wait_for) + + backend = cast( + "MistralOCRBackend", + build_ocr_backend( + OCRBackendSpec( + provider="mistral", + model="mistral-ocr-2512", + options=MistralOptions(api_key="secret"), + ) + ), + ) + page = DocumentPage(page_index=0, image=image, source_index=0) + + result = await backend.ocr(page) + + assert result.text == "mistral text" + assert calls == {"client_inits": 1, "requests": 1, "wait_for": 2} + assert sleep_calls == [1.0] + + +@pytest.mark.asyncio +async def test_mistral_ocr_backend_does_not_retry_non_transient_errors( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls = {"client_inits": 0, "requests": 0} + image = Image.new("RGB", (10, 10), color="white") + sleep_calls: list[float] = [] + + class FakeMistralError(Exception): + def __init__(self, status_code: int) -> None: + self.status_code = status_code + self.headers: dict[str, str] = {} + self.raw_response = SimpleNamespace(headers=self.headers) + super().__init__(f"Status {status_code}") + + class FakeOCRNamespace: + async def process_async(self, *, model: str, document: dict[str, str]) -> SimpleNamespace: + calls["requests"] += 1 + assert model == "mistral-ocr-2505" + assert document == { + "type": "image_url", + "image_url": "data:image/jpeg;base64,encoded-image", + } + raise FakeMistralError(400) + + class FakeMistralClient: + def __init__(self, *, api_key: str) -> None: + calls["client_inits"] += 1 + assert api_key == "secret" + self.ocr = FakeOCRNamespace() + + async def _fake_sleep(delay: float) -> None: + sleep_calls.append(delay) + + mistral_module = ModuleType("mistralai") + cast("Any", mistral_module).Mistral = FakeMistralClient + monkeypatch.setitem(sys.modules, "mistralai", mistral_module) + monkeypatch.setattr( + "churro_ocr.providers.ocr.image_to_base64", + lambda actual_image, format_name: ( + ( + "image/jpeg", + "encoded-image", + ) + if actual_image.size == image.size and actual_image.mode == "RGB" and format_name == "JPEG" + else ("", "") + ), + ) + monkeypatch.setattr("churro_ocr.providers.ocr.asyncio.sleep", _fake_sleep) + + backend = cast( + "MistralOCRBackend", + build_ocr_backend( + OCRBackendSpec( + provider="mistral", + model="mistral-ocr-2505", + options=MistralOptions(api_key="secret"), + ) + ), + ) + page = DocumentPage(page_index=0, image=image, source_index=0) + + with pytest.raises(FakeMistralError, match="Status 400"): + await backend.ocr(page) + + assert calls == {"client_inits": 1, "requests": 1} + assert sleep_calls == [] + + def test_build_ocr_backend_uses_olmocr_profile_defaults_for_openai_compatible() -> None: backend = cast( "OpenAICompatibleOCRBackend", diff --git a/tests/test_tooling_benchmark.py b/tests/test_tooling_benchmark.py index 48c733e..6406a6e 100644 --- a/tests/test_tooling_benchmark.py +++ b/tests/test_tooling_benchmark.py @@ -103,6 +103,31 @@ def test_validate_options_requires_model_for_hf() -> None: assert benchmark._validate_options(options) == 1 +def test_validate_options_requires_pinned_model_for_mistral() -> None: + missing_model = benchmark.BenchmarkOptions( + backend="mistral", + model=None, + dataset_split="dev", + api_key="secret", + ) + alias_model = benchmark.BenchmarkOptions( + backend="mistral", + model="mistral-ocr-latest", + dataset_split="dev", + api_key="secret", + ) + pinned_model = benchmark.BenchmarkOptions( + backend="mistral", + model="mistral-ocr-2512", + dataset_split="dev", + api_key="secret", + ) + + assert benchmark._validate_options(missing_model) == 1 + assert benchmark._validate_options(alias_model) == 1 + assert benchmark._validate_options(pinned_model) == 0 + + def test_validate_options_allows_openai_compatible_without_api_key() -> None: options = benchmark.BenchmarkOptions( backend="openai-compatible", diff --git a/tooling/benchmarking/benchmark.py b/tooling/benchmarking/benchmark.py index f5bebb3..059ef79 100644 --- a/tooling/benchmarking/benchmark.py +++ b/tooling/benchmarking/benchmark.py @@ -22,6 +22,7 @@ sys.path.insert(0, _REPO_SRC_PATH_STR) from churro_ocr._internal.logging import logger +from churro_ocr.errors import ConfigurationError from churro_ocr.ocr import BatchOCRBackend, OCRBackend, OCRBackendLike from churro_ocr.page_detection import DocumentPage from churro_ocr.providers import ( @@ -33,6 +34,7 @@ OCRBackendSpec, OpenAICompatibleOptions, ) +from churro_ocr.providers.specs import MISTRAL_OCR_MODEL_IDS, validate_mistral_ocr_model from tooling.benchmarking.dataset import ( DatasetSelection, DatasetSubset, @@ -150,9 +152,18 @@ def _validate_options(options: BenchmarkOptions) -> int: if options.backend == "azure" and (not options.endpoint or not options.api_key): logger.error("--endpoint and --api-key are required for backend=azure.") return 1 - if options.backend == "mistral" and not options.api_key: - logger.error("--api-key is required for backend=mistral.") - return 1 + if options.backend == "mistral": + if not options.api_key: + logger.error("--api-key is required for backend=mistral.") + return 1 + try: + validate_mistral_ocr_model(options.model) + except ConfigurationError: + logger.error( + "--model is required for backend=mistral and must be one of: %s.", + ", ".join(MISTRAL_OCR_MODEL_IDS), + ) + return 1 return 0 @@ -247,10 +258,12 @@ def _build_ocr_backend(options: BenchmarkOptions) -> OCRBackendLike: ) ) assert options.api_key is not None + assert options.model is not None + mistral_model = validate_mistral_ocr_model(options.model) return build_ocr_backend( OCRBackendSpec( provider="mistral", - model=options.model or "mistral-ocr-latest", + model=mistral_model, options=MistralOptions(api_key=options.api_key), ) ) From 29b8d065535b51ac52d6997b3eb462748892f3b6 Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 6 Apr 2026 08:04:18 +0000 Subject: [PATCH 13/28] feat(benchmark): retain OCR metadata in benchmark outputs --- tests/test_tooling_benchmark.py | 80 +++++++++++++++++++++---------- tests/test_tooling_metrics.py | 7 +-- tooling/benchmarking/benchmark.py | 50 ++++++++++++------- tooling/evaluation/metrics.py | 47 ++++++++++++++---- tooling/evaluation/types.py | 17 ++++++- 5 files changed, 142 insertions(+), 59 deletions(-) diff --git a/tests/test_tooling_benchmark.py b/tests/test_tooling_benchmark.py index 6406a6e..8abe82a 100644 --- a/tests/test_tooling_benchmark.py +++ b/tests/test_tooling_benchmark.py @@ -8,6 +8,7 @@ from datasets import Dataset from PIL import Image +from churro_ocr.ocr import OCRResult from churro_ocr.providers.hf import HuggingFaceVisionOCRBackend from churro_ocr.providers.ocr import LiteLLMVisionOCRBackend from churro_ocr.providers.specs import DEFAULT_OCR_MAX_TOKENS @@ -360,7 +361,9 @@ async def fake_predict(ds, options, *, total_pages): # noqa: ANN001 assert selected[0]["example_id"] == "1" assert options.max_concurrency == 2 assert total_pages is None - return [benchmark._build_evaluation_example(selected[0])], ["prediction"] + return [ + benchmark._build_evaluation_example(selected[0]) + ], [{"text": "prediction", "metadata": {"raw_html": "

prediction

"}}] monkeypatch.setattr(benchmark, "_predict_texts", fake_predict) @@ -392,7 +395,7 @@ def fake_compute_metrics(ds, predictions, output_prefix, elapsed_time): # noqa: assert result == 0 assert captured["dataset"] == [benchmark._build_evaluation_example(dataset[1])] - assert captured["predictions"] == ["prediction"] + assert captured["predictions"] == [{"text": "prediction", "metadata": {"raw_html": "

prediction

"}}] assert captured["output_prefix"] == str(tmp_path / "outputs") assert captured["elapsed_time"] == pytest.approx(3.5) @@ -569,14 +572,15 @@ def fake_tqdm(*, total: int | None, desc: str, unit: str) -> FakeProgressBar: progress_bars.append(progress_bar) return progress_bar - class FakeOCRResult: - def __init__(self, text: str) -> None: - self.text = text - class FakeOCRBackend: async def ocr(self, page): # noqa: ANN001 await asyncio.sleep(page.width / 1000) - return FakeOCRResult(text=f"page-{page.width}") + return OCRResult( + text=f"page-{page.width}", + provider_name="fake", + model_name="fake-model", + metadata={"page_width": page.width}, + ) monkeypatch.setattr(benchmark, "tqdm", fake_tqdm) monkeypatch.setattr(benchmark, "_build_ocr_backend", lambda _: FakeOCRBackend()) @@ -595,7 +599,11 @@ async def ocr(self, page): # noqa: ANN001 total_pages=3, ) - assert predictions == ["page-3", "page-1", "page-2"] + assert predictions == [ + {"text": "page-3", "metadata": {"page_width": 3}}, + {"text": "page-1", "metadata": {"page_width": 1}}, + {"text": "page-2", "metadata": {"page_width": 2}}, + ] assert evaluation_examples == [benchmark._build_evaluation_example(example) for example in dataset] assert len(progress_bars) == 1 assert progress_bars[0].total == 3 @@ -633,14 +641,18 @@ async def test_predict_texts_uses_batch_backend_with_max_concurrency_as_batch_si ] captured_batch_sizes: list[int] = [] - class FakeOCRResult: - def __init__(self, text: str) -> None: - self.text = text - class FakeBatchBackend: async def ocr_batch(self, pages): # noqa: ANN001 captured_batch_sizes.append(len(pages)) - return [FakeOCRResult(text=f"page-{page.width}") for page in pages] + return [ + OCRResult( + text=f"page-{page.width}", + provider_name="fake", + model_name="fake-model", + metadata={"page_width": page.width}, + ) + for page in pages + ] monkeypatch.setattr(benchmark, "_build_ocr_backend", lambda _: FakeBatchBackend()) @@ -658,7 +670,11 @@ async def ocr_batch(self, pages): # noqa: ANN001 ) assert captured_batch_sizes == [2, 1] - assert predictions == ["page-3", "page-1", "page-2"] + assert predictions == [ + {"text": "page-3", "metadata": {"page_width": 3}}, + {"text": "page-1", "metadata": {"page_width": 1}}, + {"text": "page-2", "metadata": {"page_width": 2}}, + ] assert evaluation_examples == [benchmark._build_evaluation_example(example) for example in dataset] @@ -674,13 +690,17 @@ class FakeLogger: def info(self, message: str, *args: object) -> None: logged_messages.append(message % args if args else message) - class FakeOCRResult: - def __init__(self, text: str) -> None: - self.text = text - class FakeBatchBackend: async def ocr_batch(self, pages): # noqa: ANN001 - return [FakeOCRResult(text=f"page-{page.width}") for page in pages] + return [ + OCRResult( + text=f"page-{page.width}", + provider_name="fake", + model_name="fake-model", + metadata={"page_width": page.width}, + ) + for page in pages + ] monkeypatch.setattr(benchmark, "logger", FakeLogger()) monkeypatch.setattr(benchmark, "_build_ocr_backend", lambda _: FakeBatchBackend()) @@ -698,7 +718,10 @@ async def ocr_batch(self, pages): # noqa: ANN001 total_pages=2, ) - assert predictions == ["page-3", "page-1"] + assert predictions == [ + {"text": "page-3", "metadata": {"page_width": 3}}, + {"text": "page-1", "metadata": {"page_width": 1}}, + ] assert logged_messages == [ "First benchmark OCR output for backend=hf model=kristaller486/dots.ocr-1.5:\npage-3" ] @@ -717,14 +740,15 @@ class FakeLogger: def info(self, message: str, *args: object) -> None: logged_messages.append(message % args if args else message) - class FakeOCRResult: - def __init__(self, text: str) -> None: - self.text = text - class FakeOCRBackend: async def ocr(self, page): # noqa: ANN001 await asyncio.sleep(page.width / 1000) - return FakeOCRResult(text=f"page-{page.width}") + return OCRResult( + text=f"page-{page.width}", + provider_name="fake", + model_name="fake-model", + metadata={"page_width": page.width}, + ) monkeypatch.setattr(benchmark, "logger", FakeLogger()) monkeypatch.setattr(benchmark, "_build_ocr_backend", lambda _: FakeOCRBackend()) @@ -743,5 +767,9 @@ async def ocr(self, page): # noqa: ANN001 total_pages=3, ) - assert predictions == ["page-3", "page-1", "page-2"] + assert predictions == [ + {"text": "page-3", "metadata": {"page_width": 3}}, + {"text": "page-1", "metadata": {"page_width": 1}}, + {"text": "page-2", "metadata": {"page_width": 2}}, + ] assert logged_messages == ["First benchmark OCR output for backend=azure model=:\npage-3"] diff --git a/tests/test_tooling_metrics.py b/tests/test_tooling_metrics.py index f328c13..0493f52 100644 --- a/tests/test_tooling_metrics.py +++ b/tests/test_tooling_metrics.py @@ -5,7 +5,7 @@ from typing import cast from tooling.evaluation import metrics -from tooling.evaluation.types import EvaluationExample, PageEvaluationResult +from tooling.evaluation.types import BenchmarkPrediction, EvaluationExample, PageEvaluationResult def test_calculate_language_and_type_metrics_handles_missing_categories() -> None: @@ -55,7 +55,7 @@ def test_compute_metrics_writes_expected_outputs( "document_type": "print", }, ] - predicted_texts = [""] + predictions: list[BenchmarkPrediction] = [{"text": "", "metadata": {"raw_html": "

"}}] def fake_batch_evaluate(ds, preds): # noqa: ANN001 assert ds == dataset @@ -81,7 +81,7 @@ def fake_batch_evaluate(ds, preds): # noqa: ANN001 output_prefix = tmp_path / "results" combined = metrics.compute_metrics( dataset=dataset, - predicted_texts=predicted_texts, + predictions=predictions, output_prefix=output_prefix, elapsed_time=4.567, ) @@ -90,6 +90,7 @@ def fake_batch_evaluate(ds, preds): # noqa: ANN001 assert outputs == [ { "example_id": "file1", + "metadata": {"raw_html": "

"}, "main_language": "english", "document_type": "print", "normalized_levenshtein_similarity": 0.9, diff --git a/tooling/benchmarking/benchmark.py b/tooling/benchmarking/benchmark.py index 059ef79..55f78c5 100644 --- a/tooling/benchmarking/benchmark.py +++ b/tooling/benchmarking/benchmark.py @@ -41,7 +41,12 @@ load_dataset_split, ) from tooling.evaluation.metrics import compute_metrics -from tooling.evaluation.types import BenchmarkDatasetExample, EvaluationExample, to_evaluation_example +from tooling.evaluation.types import ( + BenchmarkDatasetExample, + BenchmarkPrediction, + EvaluationExample, + to_evaluation_example, +) CHURRO_DATASET_ID = "stanford-oval/churro-dataset" VALID_DATASET_SPLITS = {"dev", "test"} @@ -284,14 +289,14 @@ async def _predict_texts( options: BenchmarkOptions, *, total_pages: int | None = None, -) -> tuple[list[EvaluationExample], list[str]]: +) -> tuple[list[EvaluationExample], list[BenchmarkPrediction]]: ocr_backend = _build_ocr_backend(options) max_in_flight = max(1, options.max_concurrency) has_logged_first_output = False if isinstance(ocr_backend, BatchOCRBackend): dataset_iterator = iter(dataset) evaluation_examples: list[EvaluationExample] = [] - predicted_texts: list[str] = [] + predictions: list[BenchmarkPrediction] = [] submitted_pages = 0 with tqdm(total=total_pages, desc="OCR", unit="page") as progress: @@ -324,25 +329,34 @@ async def _predict_texts( text=batch_results[0].text or "", ) has_logged_first_output = True - predicted_texts.extend((result.text or "") for result in batch_results) + predictions.extend( + { + "text": result.text or "", + "metadata": dict(result.metadata), + } + for result in batch_results + ) progress.update(len(batch_results)) progress.set_postfix(submitted=submitted_pages, in_flight=0, refresh=False) - return evaluation_examples, predicted_texts + return evaluation_examples, predictions - async def _predict(index: int, image: Image.Image) -> tuple[int, str]: + async def _predict(index: int, image: Image.Image) -> tuple[int, BenchmarkPrediction]: page = DocumentPage(page_index=index, source_index=0, image=image) if callable(ocr_backend) and not isinstance(ocr_backend, OCRBackend): result = await ocr_backend(page) else: assert isinstance(ocr_backend, OCRBackend) result = await ocr_backend.ocr(page) - return index, (result.text or "") + return index, { + "text": result.text or "", + "metadata": dict(result.metadata), + } dataset_iterator = iter(dataset) evaluation_examples: list[EvaluationExample] = [] - pending_tasks: set[asyncio.Task[tuple[int, str]]] = set() - predicted_texts: list[str] = [] + pending_tasks: set[asyncio.Task[tuple[int, BenchmarkPrediction]]] = set() + predictions: list[BenchmarkPrediction] = [] next_index = 0 wait_poll_seconds = 1.0 @@ -378,7 +392,7 @@ def _progress_heartbeat(progress: tqdm[object], stop_event: threading.Event) -> image = example["image"] assert isinstance(image, Image.Image) evaluation_examples.append(_build_evaluation_example(example)) - predicted_texts.append("") + predictions.append({"text": "", "metadata": {}}) pending_tasks.add(asyncio.create_task(_predict(next_index, image))) next_index += 1 _update_progress_status(progress) @@ -391,10 +405,10 @@ def _progress_heartbeat(progress: tqdm[object], stop_event: threading.Event) -> return_when=asyncio.FIRST_COMPLETED, ) for task in done_tasks: - index, text = await task - predicted_texts[index] = text + index, prediction = await task + predictions[index] = prediction if not has_logged_first_output and index == 0: - _log_first_benchmark_output(options=options, text=text) + _log_first_benchmark_output(options=options, text=prediction["text"]) has_logged_first_output = True progress.update(1) _update_progress_status(progress) @@ -408,7 +422,7 @@ def _progress_heartbeat(progress: tqdm[object], stop_event: threading.Event) -> heartbeat_thread.join(timeout=wait_poll_seconds * 2) _update_progress_status(progress, force_refresh=True) - return evaluation_examples, predicted_texts + return evaluation_examples, predictions async def run(options: BenchmarkOptions) -> int: @@ -425,17 +439,17 @@ async def run(options: BenchmarkOptions) -> int: output_prefix = create_output_prefix(options) start_time = time() - evaluation_examples, predicted_texts = await _predict_texts( + evaluation_examples, predictions = await _predict_texts( dataset, options, total_pages=total_pages, ) elapsed_time = time() - start_time - assert len(evaluation_examples) == len(predicted_texts), ( - f"Mismatch in dataset size ({len(evaluation_examples)}) and predictions ({len(predicted_texts)})." + assert len(evaluation_examples) == len(predictions), ( + f"Mismatch in dataset size ({len(evaluation_examples)}) and predictions ({len(predictions)})." ) - compute_metrics(evaluation_examples, predicted_texts, output_prefix, elapsed_time) + compute_metrics(evaluation_examples, predictions, output_prefix, elapsed_time) return 0 diff --git a/tooling/evaluation/metrics.py b/tooling/evaluation/metrics.py index e4f016b..ba1ac3c 100644 --- a/tooling/evaluation/metrics.py +++ b/tooling/evaluation/metrics.py @@ -3,13 +3,19 @@ from __future__ import annotations from collections import defaultdict +from collections.abc import Sequence import json from pathlib import Path from typing import Any from churro_ocr._internal.logging import logger from tooling.evaluation.evaluate_page import batch_evaluate -from tooling.evaluation.types import EvaluationExample, PageEvaluationResult +from tooling.evaluation.types import ( + BenchmarkOutputRow, + BenchmarkPrediction, + EvaluationExample, + PageEvaluationResult, +) def _get_llm_total_cost() -> float: @@ -39,7 +45,7 @@ def to_rounded_percentage(metrics: dict[str, Any]) -> dict[str, Any]: def calculate_language_and_type_metrics( - outputs: list[PageEvaluationResult], + outputs: Sequence[PageEvaluationResult], main_metric: str = "normalized_levenshtein_similarity", ) -> tuple[dict[str, float], dict[str, float], dict[str, float]]: """Compute averages grouped by language, document type, and both.""" @@ -70,28 +76,49 @@ def calculate_language_and_type_metrics( return averaged_language, averaged_type, averaged_language_type +def _normalize_prediction(prediction: str | BenchmarkPrediction) -> BenchmarkPrediction: + """Coerce legacy string predictions into the structured benchmark format.""" + if isinstance(prediction, str): + return {"text": prediction, "metadata": {}} + return { + "text": str(prediction["text"]), + "metadata": dict(prediction["metadata"]), + } + + def _build_output_rows( - dataset: list[EvaluationExample], - per_example_outputs: list[PageEvaluationResult], -) -> list[PageEvaluationResult]: + dataset: Sequence[EvaluationExample], + per_example_outputs: Sequence[PageEvaluationResult], + predictions: Sequence[BenchmarkPrediction], +) -> list[BenchmarkOutputRow]: """Attach stable example ids to per-example outputs before writing them.""" return [ - {"example_id": str(example["example_id"]), **evaluation_output} - for evaluation_output, example in zip(per_example_outputs, dataset, strict=False) + { + "example_id": str(example["example_id"]), + "metadata": dict(prediction["metadata"]), + **evaluation_output, + } + for evaluation_output, example, prediction in zip( + per_example_outputs, + dataset, + predictions, + strict=False, + ) ] def compute_metrics( dataset: list[EvaluationExample], - predicted_texts: list[str], + predictions: list[str] | list[BenchmarkPrediction], output_prefix: str | Path, elapsed_time: float, main_metric: str = "normalized_levenshtein_similarity", ) -> dict[str, Any]: """Compute aggregate metrics and write benchmark output files.""" - sanitized_predictions = [prediction or "" for prediction in predicted_texts] + normalized_predictions = [_normalize_prediction(prediction) for prediction in predictions] + sanitized_predictions = [prediction["text"] or "" for prediction in normalized_predictions] aggregate_metrics, per_example_outputs = batch_evaluate(dataset, sanitized_predictions) - outputs = _build_output_rows(dataset, per_example_outputs) + outputs = _build_output_rows(dataset, per_example_outputs, normalized_predictions) output_dir = Path(output_prefix) output_dir.mkdir(parents=True, exist_ok=True) diff --git a/tooling/evaluation/types.py b/tooling/evaluation/types.py index f650679..ede76f1 100644 --- a/tooling/evaluation/types.py +++ b/tooling/evaluation/types.py @@ -1,8 +1,8 @@ -"""Shared dataset and result types for CHURRO tooling evaluation flows.""" +"""Shared dataset, prediction, and result types for CHURRO tooling evaluation flows.""" from __future__ import annotations -from typing import TypedDict +from typing import Any, TypedDict from PIL import Image @@ -61,6 +61,19 @@ class PageEvaluationResult(PageEvaluationMetrics): document_type: str +class BenchmarkPrediction(TypedDict): + """OCR output retained during benchmarking before metrics are computed.""" + + text: str + metadata: dict[str, Any] + + +class BenchmarkOutputRow(PageEvaluationResult): + """Serialized benchmark output row written to ``outputs.json``.""" + + metadata: dict[str, Any] + + def to_evaluation_example(example: BenchmarkDatasetExample) -> EvaluationExample: """Keep only the dataset fields needed after OCR completes.""" return {field_name: example[field_name] for field_name in EVALUATION_EXAMPLE_FIELDS} From 36d0c7ec1d9149dd48341125df9ffa0de515c048 Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 6 Apr 2026 14:27:56 +0000 Subject: [PATCH 14/28] fix(providers): retry transient provider API errors --- pixi.lock | 17 +- pyproject.toml | 1 + src/churro_ocr/_internal/litellm.py | 7 +- src/churro_ocr/_internal/retry.py | 202 +++++++++++++++++++++ src/churro_ocr/providers/ocr.py | 91 +++------- src/churro_ocr/providers/page_detection.py | 20 +- tests/test_internal_helpers.py | 47 +++++ tests/test_providers.py | 171 ++++++++++++++++- 8 files changed, 484 insertions(+), 72 deletions(-) create mode 100644 src/churro_ocr/_internal/retry.py diff --git a/pixi.lock b/pixi.lock index 1b12f56..3beddc1 100644 --- a/pixi.lock +++ b/pixi.lock @@ -201,6 +201,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f5/de/9341a6d7a8f1b448573bbf3425fa57669ac58258a667eb48a25dfe916d70/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl @@ -394,6 +395,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f5/de/9341a6d7a8f1b448573bbf3425fa57669ac58258a667eb48a25dfe916d70/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl @@ -556,6 +558,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e7/28/35f7411ff80a3640c1f4fc907dcbb6a65061ebb82f66950e38bfc9f7f740/transformers-5.5.0-py3-none-any.whl @@ -776,11 +779,12 @@ packages: - pypi: ./ name: churro-ocr version: 0.2.0 - sha256: 4c7ab2bad968d566228a29c231a0cc7d8e594542a8632a5afa64841d05b307df + sha256: 4aab922d00e91acd3427b8ddbb59341f7f5232ccc72fbc120a3754e7e05d0dbc requires_dist: - loguru>=0.7.2,<1 - pillow>=10.4.0,<12 - rich>=13.9.2,<14 + - tenacity>=9.1.2,<10 - typer>=0.12.3,<1 - google-auth>=2.41.1,<3 ; extra == 'llm' - litellm[caching]==1.82.3 ; extra == 'llm' @@ -3291,6 +3295,17 @@ packages: - python-multipart>=0.0.18 ; extra == 'full' - pyyaml ; extra == 'full' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl + name: tenacity + version: 9.1.4 + sha256: 6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55 + requires_dist: + - reno ; extra == 'doc' + - sphinx ; extra == 'doc' + - pytest ; extra == 'test' + - tornado>=4.5 ; extra == 'test' + - typeguard ; extra == 'test' + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/f5/de/9341a6d7a8f1b448573bbf3425fa57669ac58258a667eb48a25dfe916d70/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl name: tiktoken version: 0.12.0 diff --git a/pyproject.toml b/pyproject.toml index 0db092f..3ef6d83 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "loguru>=0.7.2,<1", "Pillow>=10.4.0,<12", "rich>=13.9.2,<14", + "tenacity>=9.1.2,<10", "typer>=0.12.3,<1", ] diff --git a/src/churro_ocr/_internal/litellm.py b/src/churro_ocr/_internal/litellm.py index 2daffc5..23fb318 100644 --- a/src/churro_ocr/_internal/litellm.py +++ b/src/churro_ocr/_internal/litellm.py @@ -13,6 +13,7 @@ from churro_ocr._internal.image import image_to_base64 from churro_ocr._internal.install import install_command_hint +from churro_ocr._internal.retry import retry_api_call from churro_ocr.errors import ConfigurationError, ProviderError from churro_ocr.providers.specs import LiteLLMTransportConfig from churro_ocr.templates import OCRConversation @@ -186,7 +187,11 @@ async def complete_text( kwargs.update(self._config.completion_kwargs) try: - response = await acompletion(**kwargs) + response = await retry_api_call( + lambda: acompletion(**kwargs), + operation_name="LiteLLM request", + context=f"for model '{model}'", + ) except Exception as exc: # pragma: no cover - provider-specific failure path raise ProviderError(f"LiteLLM request failed for model '{model}': {exc}") from exc self._record_response_cost(model=model, response=response) diff --git a/src/churro_ocr/_internal/retry.py b/src/churro_ocr/_internal/retry.py new file mode 100644 index 0000000..4595461 --- /dev/null +++ b/src/churro_ocr/_internal/retry.py @@ -0,0 +1,202 @@ +"""Shared retry helpers for provider API calls.""" + +from __future__ import annotations + +import asyncio +import logging +from collections.abc import Awaitable, Callable, Mapping +from typing import cast + +from tenacity import AsyncRetrying, RetryCallState, retry_if_exception, stop_after_attempt + +logger = logging.getLogger(__name__) + +DEFAULT_MAX_ATTEMPTS = 6 +DEFAULT_INITIAL_BACKOFF_SECONDS = 1.0 +DEFAULT_MAX_BACKOFF_SECONDS = 16.0 +TRANSIENT_STATUS_CODES = frozenset({408, 429, 500, 502, 503, 504, 520, 521, 522, 524}) +RETRYABLE_EXCEPTION_CLASS_NAMES = frozenset( + { + "APIConnectionError", + "APITimeoutError", + "ConnectError", + "ConnectTimeout", + "PoolTimeout", + "RateLimitError", + "ReadTimeout", + "RemoteProtocolError", + "ServiceRequestError", + "ServiceResponseError", + "WriteTimeout", + } +) +RETRYABLE_EXCEPTION_MODULE_PREFIXES = ("httpcore", "httpx") + +retry_sleep = asyncio.sleep + +type RetryPredicate = Callable[[BaseException], bool] + + +def _coerce_status_code(value: object) -> int | None: + if isinstance(value, int): + return value + if isinstance(value, float) and value.is_integer(): + return int(value) + return None + + +def _headers_from_candidate(value: object) -> Mapping[str, object] | None: + if isinstance(value, Mapping): + return cast("Mapping[str, object]", value) + return None + + +def get_error_status_code(exc: BaseException) -> int | None: + """Extract an HTTP-like status code from a provider exception when available.""" + status_code = _coerce_status_code(getattr(exc, "status_code", None)) + if status_code is not None: + return status_code + + for attribute_name in ("response", "raw_response"): + response = getattr(exc, attribute_name, None) + status_code = _coerce_status_code(getattr(response, "status_code", None)) + if status_code is not None: + return status_code + return None + + +def get_error_retry_after_seconds(exc: BaseException) -> float | None: + """Extract a retry delay from response headers when one is available.""" + headers = _headers_from_candidate(getattr(exc, "headers", None)) + if headers is None: + for attribute_name in ("response", "raw_response"): + response = getattr(exc, attribute_name, None) + headers = _headers_from_candidate(getattr(response, "headers", None)) + if headers is not None: + break + if headers is None: + return None + + retry_after = headers.get("retry-after") + if retry_after is None: + retry_after = headers.get("Retry-After") + if retry_after is None: + return None + if not isinstance(retry_after, str | int | float): + return None + try: + return max(0.0, float(retry_after)) + except ValueError: + return None + + +def compute_retry_delay_seconds( + exc: BaseException, + *, + attempt_number: int, + initial_backoff_seconds: float = DEFAULT_INITIAL_BACKOFF_SECONDS, + max_backoff_seconds: float = DEFAULT_MAX_BACKOFF_SECONDS, +) -> float: + """Compute the retry delay for a failed provider request.""" + retry_after = get_error_retry_after_seconds(exc) + if retry_after is not None: + return retry_after + return min( + initial_backoff_seconds * (2 ** max(0, attempt_number - 1)), + max_backoff_seconds, + ) + + +def is_retryable_api_error(exc: BaseException) -> bool: + """Return whether a provider exception should be retried.""" + if isinstance(exc, TimeoutError): + return True + + status_code = get_error_status_code(exc) + if status_code is not None: + return status_code in TRANSIENT_STATUS_CODES + + exc_type = exc.__class__ + if exc_type.__name__ in RETRYABLE_EXCEPTION_CLASS_NAMES: + return True + + module_name = exc_type.__module__ + return any(module_name.startswith(prefix) for prefix in RETRYABLE_EXCEPTION_MODULE_PREFIXES) + + +def _build_before_sleep_callback( + *, + operation_name: str, + context: str | None, + max_attempts: int, +) -> Callable[[RetryCallState], None]: + message_context = f" {context}" if context else "" + + def _before_sleep(retry_state: RetryCallState) -> None: + outcome = retry_state.outcome + exc = outcome.exception() if outcome is not None and outcome.failed else None + if exc is None: + return + delay_seconds = retry_state.next_action.sleep if retry_state.next_action is not None else 0.0 + logger.warning( + "Transient %s failure%s (status=%s, attempt=%s/%s); retrying in %.1fs.", + operation_name, + message_context, + get_error_status_code(exc) or "unknown", + retry_state.attempt_number, + max_attempts, + delay_seconds, + ) + + return _before_sleep + + +async def retry_api_call[T]( + fn: Callable[[], Awaitable[T]], + *, + operation_name: str, + context: str | None = None, + max_attempts: int = DEFAULT_MAX_ATTEMPTS, + retry_filter: RetryPredicate = is_retryable_api_error, + initial_backoff_seconds: float = DEFAULT_INITIAL_BACKOFF_SECONDS, + max_backoff_seconds: float = DEFAULT_MAX_BACKOFF_SECONDS, +) -> T: + """Execute an async provider request with shared retry behavior.""" + retrying = AsyncRetrying( + reraise=True, + stop=stop_after_attempt(max_attempts), + retry=retry_if_exception(retry_filter), + wait=lambda retry_state: ( + 0.0 + if retry_state.outcome is None or not retry_state.outcome.failed + else compute_retry_delay_seconds( + retry_state.outcome.exception(), + attempt_number=retry_state.attempt_number, + initial_backoff_seconds=initial_backoff_seconds, + max_backoff_seconds=max_backoff_seconds, + ) + ), + before_sleep=_build_before_sleep_callback( + operation_name=operation_name, + context=context, + max_attempts=max_attempts, + ), + sleep=retry_sleep, + ) + + async for attempt in retrying: + with attempt: + return await fn() + + raise AssertionError("AsyncRetrying exited without returning or raising.") + + +__all__ = [ + "DEFAULT_MAX_ATTEMPTS", + "compute_retry_delay_seconds", + "get_error_retry_after_seconds", + "get_error_status_code", + "is_retryable_api_error", + "retry_api_call", + "retry_sleep", +] diff --git a/src/churro_ocr/providers/ocr.py b/src/churro_ocr/providers/ocr.py index 9a80e39..4c40a49 100644 --- a/src/churro_ocr/providers/ocr.py +++ b/src/churro_ocr/providers/ocr.py @@ -4,7 +4,6 @@ import asyncio import base64 -import logging from dataclasses import dataclass, field from io import BytesIO from threading import Lock @@ -14,6 +13,7 @@ from churro_ocr._internal.install import install_command_hint from churro_ocr._internal.litellm import LiteLLMTransport from churro_ocr._internal.prompt_logging import log_prompt_payload_once +from churro_ocr._internal.retry import retry_api_call from churro_ocr.errors import ConfigurationError, ProviderError from churro_ocr.ocr import OCRBackend, OCRResult from churro_ocr.page_detection import DocumentPage @@ -33,11 +33,6 @@ build_ocr_conversation, ) -logger = logging.getLogger(__name__) -_MISTRAL_TRANSIENT_STATUS_CODES = frozenset({408, 429, 500, 502, 503, 504, 520, 521, 522, 524}) -_MISTRAL_MAX_ATTEMPTS = 6 -_MISTRAL_INITIAL_BACKOFF_SECONDS = 1.0 -_MISTRAL_MAX_BACKOFF_SECONDS = 16.0 _MISTRAL_REQUEST_TIMEOUT_SECONDS = 60.0 @@ -56,30 +51,6 @@ def _with_default_ocr_completion_kwargs(config: LiteLLMTransportConfig) -> LiteL ) -def _is_retryable_mistral_error(exc: Exception) -> bool: - status_code = getattr(exc, "status_code", None) - if isinstance(status_code, int): - return status_code in _MISTRAL_TRANSIENT_STATUS_CODES - return exc.__class__.__module__.startswith("httpx") or isinstance(exc, TimeoutError) - - -def _get_mistral_retry_delay_seconds(exc: Exception, attempt: int) -> float: - headers = getattr(exc, "headers", None) - if headers is None: - raw_response = getattr(exc, "raw_response", None) - headers = getattr(raw_response, "headers", None) - retry_after = headers.get("retry-after") if hasattr(headers, "get") else None - if retry_after is not None: - try: - return max(0.0, float(retry_after)) - except (TypeError, ValueError): - pass - return min( - _MISTRAL_INITIAL_BACKOFF_SECONDS * (2 ** (attempt - 1)), - _MISTRAL_MAX_BACKOFF_SECONDS, - ) - - @dataclass(slots=True) class LiteLLMVisionOCRBackend(OCRBackend): """OCR backend for any LiteLLM-supported multimodal provider. @@ -253,12 +224,20 @@ async def ocr(self, page: DocumentPage) -> OCRResult: set_logged=lambda: setattr(self, "_has_logged_prompt", True), ) client = await self._get_client() - poller = await client.begin_analyze_document( - model_id=self.model_id, - body=BytesIO(image_bytes), - content_type="application/octet-stream", + + async def _analyze_document() -> Any: + poller = await client.begin_analyze_document( + model_id=self.model_id, + body=BytesIO(image_bytes), + content_type="application/octet-stream", + ) + return await poller.result() + + result = await retry_api_call( + _analyze_document, + operation_name="Azure OCR request", + context=f"for model {self.model_id}", ) - result = await poller.result() if not isinstance(result.content, str): raise ProviderError("Azure Document Intelligence returned no OCR text.") return build_ocr_result( @@ -340,33 +319,21 @@ async def ocr(self, page: DocumentPage) -> OCRResult: ) client = await self._get_client() document = {"type": "image_url", "image_url": image_url} - response: Any | None = None - for attempt in range(1, _MISTRAL_MAX_ATTEMPTS + 1): - try: - response = await asyncio.wait_for( - client.ocr.process_async( - model=self.model, - document=document, - ), - timeout=_MISTRAL_REQUEST_TIMEOUT_SECONDS, - ) - break - except Exception as exc: - if attempt >= _MISTRAL_MAX_ATTEMPTS or not _is_retryable_mistral_error(exc): - raise - delay_seconds = _get_mistral_retry_delay_seconds(exc, attempt) - logger.warning( - "Transient Mistral OCR failure for model %s " - "(status=%s, attempt=%s/%s); retrying in %.1fs.", - self.model, - getattr(exc, "status_code", "unknown"), - attempt, - _MISTRAL_MAX_ATTEMPTS, - delay_seconds, - ) - await asyncio.sleep(delay_seconds) - if response is None: # pragma: no cover - loop exits early via exception - raise ProviderError("Mistral OCR request failed before a response was returned.") + + async def _process_ocr() -> Any: + return await asyncio.wait_for( + client.ocr.process_async( + model=self.model, + document=document, + ), + timeout=_MISTRAL_REQUEST_TIMEOUT_SECONDS, + ) + + response = await retry_api_call( + _process_ocr, + operation_name="Mistral OCR request", + context=f"for model {self.model}", + ) if not response.pages: raise ProviderError("Mistral OCR returned no pages.") return build_ocr_result( diff --git a/src/churro_ocr/providers/page_detection.py b/src/churro_ocr/providers/page_detection.py index cee234d..3ff6362 100644 --- a/src/churro_ocr/providers/page_detection.py +++ b/src/churro_ocr/providers/page_detection.py @@ -13,6 +13,7 @@ from churro_ocr._internal.install import install_command_hint from churro_ocr._internal.litellm import LiteLLMTransport from churro_ocr._internal.logging import logger +from churro_ocr._internal.retry import retry_api_call from churro_ocr._internal.runtime import run_sync from churro_ocr.errors import ConfigurationError, ProviderError from churro_ocr.page_detection import PageCandidate, PageDetectionBackend @@ -1213,12 +1214,21 @@ async def detect(self, image: Image.Image) -> list[PageCandidate]: credential=AzureKeyCredential(self.api_key), ) try: - poller = await client.begin_analyze_document( - model_id=self.model_id, - body=BytesIO(buffer.getvalue()), - content_type="application/octet-stream", + image_bytes = buffer.getvalue() + + async def _analyze_document() -> Any: + poller = await client.begin_analyze_document( + model_id=self.model_id, + body=BytesIO(image_bytes), + content_type="application/octet-stream", + ) + return await poller.result() + + result = await retry_api_call( + _analyze_document, + operation_name="Azure page detection request", + context=f"for model {self.model_id}", ) - result = await poller.result() finally: await client.close() diff --git a/tests/test_internal_helpers.py b/tests/test_internal_helpers.py index 240581a..42edae9 100644 --- a/tests/test_internal_helpers.py +++ b/tests/test_internal_helpers.py @@ -12,6 +12,7 @@ import churro_ocr._internal.litellm as litellm_module import churro_ocr._internal.prompt_logging as prompt_logging_module +import churro_ocr._internal.retry as retry_module from churro_ocr._internal import logging as logging_module from churro_ocr._internal.image import image_to_base64, load_image from churro_ocr._internal.litellm import LiteLLMTransport, complete_text @@ -262,7 +263,10 @@ def test_configure_disk_cache_enables_and_updates_cache(monkeypatch: pytest.Monk async def test_transport_complete_text_raises_provider_error_on_failure( monkeypatch: pytest.MonkeyPatch, ) -> None: + calls = {"acompletion": 0} + async def _failing_acompletion(**_: object) -> object: + calls["acompletion"] += 1 raise RuntimeError("boom") fake_module = _make_fake_litellm_module(acompletion=_failing_acompletion) @@ -275,6 +279,49 @@ async def _failing_acompletion(**_: object) -> object: model="example/model", messages=[{"role": "user", "content": [{"type": "text", "text": "hello"}]}], ) + assert calls == {"acompletion": 1} + + +@pytest.mark.asyncio +async def test_transport_complete_text_retries_transient_errors( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls = {"acompletion": 0} + sleep_calls: list[float] = [] + + class FakeLiteLLMError(Exception): + def __init__(self, status_code: int, headers: dict[str, str] | None = None) -> None: + self.status_code = status_code + self.headers = headers or {} + self.raw_response = SimpleNamespace(headers=self.headers) + super().__init__(f"Status {status_code}") + + async def _fake_sleep(delay: float) -> None: + sleep_calls.append(delay) + + async def _flaky_acompletion(**_: object) -> object: + calls["acompletion"] += 1 + if calls["acompletion"] == 1: + raise FakeLiteLLMError(429, headers={"retry-after": "3"}) + return SimpleNamespace( + choices=[SimpleNamespace(message=SimpleNamespace(content="ok"))], + _hidden_params={}, + ) + + fake_module = _make_fake_litellm_module(acompletion=_flaky_acompletion) + monkeypatch.setitem(sys.modules, "litellm", fake_module) + monkeypatch.setattr(litellm_module, "_INITIALIZED", False) + monkeypatch.setattr(retry_module, "retry_sleep", _fake_sleep) + + transport = LiteLLMTransport() + result = await transport.complete_text( + model="example/model", + messages=[{"role": "user", "content": [{"type": "text", "text": "hello"}]}], + ) + + assert result == "ok" + assert calls == {"acompletion": 2} + assert sleep_calls == [3.0] @pytest.mark.asyncio diff --git a/tests/test_providers.py b/tests/test_providers.py index 3302c7d..ca50a88 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -9,6 +9,7 @@ import pytest from PIL import Image +import churro_ocr._internal.retry as retry_module from churro_ocr._internal.litellm import LiteLLMTransport from churro_ocr.errors import ProviderError from churro_ocr.page_detection import DocumentPage @@ -304,6 +305,93 @@ def __init__(self, key: str) -> None: assert calls == {"client_inits": 1, "requests": 2} +@pytest.mark.asyncio +async def test_azure_ocr_backend_retries_transient_errors( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls = {"client_inits": 0, "requests": 0} + sleep_calls: list[float] = [] + image = Image.new("RGB", (10, 10), color="white") + encoded = base64.b64encode(b"image-bytes").decode("ascii") + + class FakeAzureError(Exception): + def __init__(self, status_code: int, headers: dict[str, str] | None = None) -> None: + self.status_code = status_code + self.headers = headers or {} + self.response = SimpleNamespace(headers=self.headers) + super().__init__(f"Status {status_code}") + + class FakePoller: + async def result(self) -> SimpleNamespace: + return SimpleNamespace(content="azure text") + + class FakeClient: + def __init__(self, *, endpoint: str, credential: Any) -> None: + calls["client_inits"] += 1 + assert endpoint == "https://example.test" + assert credential.key == "secret" + + async def begin_analyze_document( + self, + *, + model_id: str, + body: Any, + content_type: str, + ) -> FakePoller: + calls["requests"] += 1 + assert model_id == "prebuilt-layout" + assert body.read() == b"image-bytes" + assert content_type == "application/octet-stream" + if calls["requests"] < 3: + raise FakeAzureError(503) + return FakePoller() + + class FakeAzureKeyCredential: + def __init__(self, key: str) -> None: + self.key = key + + async def _fake_sleep(delay: float) -> None: + sleep_calls.append(delay) + + azure_document_module = ModuleType("azure.ai.documentintelligence.aio") + cast("Any", azure_document_module).DocumentIntelligenceClient = FakeClient + azure_credentials_module = ModuleType("azure.core.credentials") + cast("Any", azure_credentials_module).AzureKeyCredential = FakeAzureKeyCredential + monkeypatch.setitem(sys.modules, "azure.ai.documentintelligence.aio", azure_document_module) + monkeypatch.setitem(sys.modules, "azure.core.credentials", azure_credentials_module) + monkeypatch.setattr(retry_module, "retry_sleep", _fake_sleep) + monkeypatch.setattr( + "churro_ocr.providers.ocr.image_to_base64", + lambda actual_image, format_name: ( + ( + "image/jpeg", + encoded, + ) + if actual_image.size == image.size and actual_image.mode == "RGB" and format_name == "JPEG" + else ("", "") + ), + ) + + backend = cast( + "AzureDocumentIntelligenceOCRBackend", + build_ocr_backend( + OCRBackendSpec( + provider="azure", + options=AzureDocumentIntelligenceOptions( + endpoint="https://example.test", + api_key="secret", + ), + ) + ), + ) + + result = await backend.ocr(DocumentPage(page_index=0, image=image, source_index=0)) + + assert result.text == "azure text" + assert calls == {"client_inits": 1, "requests": 3} + assert sleep_calls == [1.0, 2.0] + + @pytest.mark.asyncio async def test_mistral_ocr_backend_reuses_client(monkeypatch: pytest.MonkeyPatch) -> None: calls = {"client_inits": 0, "requests": 0} @@ -410,7 +498,7 @@ async def _fake_sleep(delay: float) -> None: else ("", "") ), ) - monkeypatch.setattr("churro_ocr.providers.ocr.asyncio.sleep", _fake_sleep) + monkeypatch.setattr(retry_module, "retry_sleep", _fake_sleep) backend = cast( "MistralOCRBackend", @@ -483,7 +571,7 @@ async def _fake_wait_for(awaitable: Any, **kwargs: float) -> Any: else ("", "") ), ) - monkeypatch.setattr("churro_ocr.providers.ocr.asyncio.sleep", _fake_sleep) + monkeypatch.setattr(retry_module, "retry_sleep", _fake_sleep) monkeypatch.setattr("churro_ocr.providers.ocr.asyncio.wait_for", _fake_wait_for) backend = cast( @@ -553,7 +641,7 @@ async def _fake_sleep(delay: float) -> None: else ("", "") ), ) - monkeypatch.setattr("churro_ocr.providers.ocr.asyncio.sleep", _fake_sleep) + monkeypatch.setattr(retry_module, "retry_sleep", _fake_sleep) backend = cast( "MistralOCRBackend", @@ -1302,6 +1390,83 @@ def __init__(self, key: str) -> None: assert calls["closes"] == 1 +@pytest.mark.asyncio +async def test_azure_page_detector_retries_transient_errors( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls = {"client_inits": 0, "requests": 0, "closes": 0} + sleep_calls: list[float] = [] + + class FakeAzureError(Exception): + def __init__(self, status_code: int, headers: dict[str, str] | None = None) -> None: + self.status_code = status_code + self.headers = headers or {} + self.response = SimpleNamespace(headers=self.headers) + super().__init__(f"Status {status_code}") + + class FakePoller: + async def result(self) -> SimpleNamespace: + return SimpleNamespace( + pages=[ + SimpleNamespace( + polygon=[0, 0, 100, 0, 100, 200, 0, 200, 0, 0], + width=100, + height=200, + page_number=1, + unit="pixel", + angle=None, + ) + ] + ) + + class FakeClient: + def __init__(self, *, endpoint: str, credential: Any) -> None: + calls["client_inits"] += 1 + assert endpoint == "https://example.test" + assert credential.key == "secret" + + async def begin_analyze_document( + self, + *, + model_id: str, + body: Any, + content_type: str, + ) -> FakePoller: + calls["requests"] += 1 + assert model_id == "prebuilt-layout" + assert body.read() + assert content_type == "application/octet-stream" + if calls["requests"] == 1: + raise FakeAzureError(429, headers={"retry-after": "4"}) + return FakePoller() + + async def close(self) -> None: + calls["closes"] += 1 + + class FakeAzureKeyCredential: + def __init__(self, key: str) -> None: + self.key = key + + async def _fake_sleep(delay: float) -> None: + sleep_calls.append(delay) + + azure_document_module = ModuleType("azure.ai.documentintelligence.aio") + cast(Any, azure_document_module).DocumentIntelligenceClient = FakeClient + azure_credentials_module = ModuleType("azure.core.credentials") + cast(Any, azure_credentials_module).AzureKeyCredential = FakeAzureKeyCredential + monkeypatch.setitem(sys.modules, "azure.ai.documentintelligence.aio", azure_document_module) + monkeypatch.setitem(sys.modules, "azure.core.credentials", azure_credentials_module) + monkeypatch.setattr(retry_module, "retry_sleep", _fake_sleep) + + detector = AzurePageDetector(endpoint="https://example.test", api_key="secret") + candidates = await detector.detect(Image.new("RGB", (100, 200), color="white")) + + assert len(candidates) == 1 + assert candidates[0].bbox == (0.0, 0.0, 100.0, 200.0) + assert calls == {"client_inits": 1, "requests": 2, "closes": 1} + assert sleep_calls == [4.0] + + def test_azure_page_detector_type_is_public() -> None: detector = AzurePageDetector(endpoint="https://example.test", api_key="secret") assert detector.model_id == "prebuilt-layout" From a0377ac56bc3ae7cd05e3e3bfc400489fd03a45d Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 6 Apr 2026 14:28:03 +0000 Subject: [PATCH 15/28] style(providers): format specs module --- src/churro_ocr/providers/specs.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/churro_ocr/providers/specs.py b/src/churro_ocr/providers/specs.py index fbbcd8f..ee0b953 100644 --- a/src/churro_ocr/providers/specs.py +++ b/src/churro_ocr/providers/specs.py @@ -2,10 +2,10 @@ from __future__ import annotations +import re from collections.abc import Callable, Sequence from dataclasses import dataclass, field from pathlib import Path -import re from typing import TYPE_CHECKING, Any, Literal, cast from PIL import Image @@ -68,9 +68,7 @@ def validate_mistral_ocr_model( if model is None: raise ConfigurationError(f"{context} requires `model` to be one of: {supported_models}.") if model not in MISTRAL_OCR_MODEL_IDS: - raise ConfigurationError( - f"{context} only supports `model` values {supported_models}; got {model!r}." - ) + raise ConfigurationError(f"{context} only supports `model` values {supported_models}; got {model!r}.") return cast(MistralOCRModel, model) From 9aecd0f878c640e5c02954dbfc28ae838eb2478e Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 6 Apr 2026 14:28:09 +0000 Subject: [PATCH 16/28] chore(benchmarks): refresh benchmark results --- benchmark_results.json | 37 ++++++++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/benchmark_results.json b/benchmark_results.json index 0fc66a2..0946d93 100644 --- a/benchmark_results.json +++ b/benchmark_results.json @@ -160,6 +160,24 @@ "printed": 69.766, "handwritten": 41.5165, "total": 54.5547 + }, + { + "modelName": "olmOCR 2", + "modelId": "allenai/olmOCR-2-7B-1025", + "modelUrl": "https://huggingface.co/allenai/olmOCR-2-7B-1025", + "hasIcon": false, + "printed": 71.1, + "handwritten": 44.6, + "total": 56.8 + }, + { + "modelName": "LiquidAI LFM2.5 VL (1.6B)", + "modelId": "LiquidAI/LFM2.5-VL-1.6B", + "modelUrl": "https://huggingface.co/LiquidAI/LFM2.5-VL-1.6B", + "hasIcon": false, + "printed": 40.2, + "handwritten": 25.5, + "total": 32.3 }, { "modelName": "Qwen 2.5 VL (3B)", @@ -225,13 +243,22 @@ "total": 45.5566 }, { - "modelName": "Mistral OCR", - "modelId": null, + "modelName": "Mistral OCR 2", + "modelId": "mistral-ocr-2505", + "modelUrl": null, + "hasIcon": false, + "printed": 64.3, + "handwritten": 30.4, + "total": 46.0 + }, + { + "modelName": "Mistral OCR 3", + "modelId": "mistral-ocr-2512", "modelUrl": null, "hasIcon": false, - "printed": 64.0418, - "handwritten": 29.4388, - "total": 45.4094 + "printed": 71.5, + "handwritten": 47.6, + "total": 58.6 }, { "modelName": "GPT-5", From 3b5101f717c93ecb5fa3f88ae3304f41c0d917c0 Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 6 Apr 2026 15:08:25 +0000 Subject: [PATCH 17/28] docs(benchmarking): update benchmarking instructions and clarify output file details --- docs/benchmarking.md | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/docs/benchmarking.md b/docs/benchmarking.md index d1e8263..f21609a 100644 --- a/docs/benchmarking.md +++ b/docs/benchmarking.md @@ -1,10 +1,9 @@ # Benchmarking -This page is for reproducing CHURRO-DS benchmark runs from a repo checkout. +For the official leaderboard results, see the [Benchmark Leaderboard](leaderboard.md). -Run these commands from the repo root after `pixi install`. +This page describes how to benchmark your own model on [CHURRO-DS](https://huggingface.co/datasets/stanford-oval/churro-dataset). Please open a pull request if you would like to add your model to the official leaderboard. -For the current committed benchmark snapshot, see the [Benchmark Leaderboard](leaderboard.md). Contributor setup, test commands, and package checks live in [Contributing](contributing.md). ## Smallest Useful Run @@ -18,6 +17,7 @@ pixi run python -m tooling.benchmarking.benchmark \ ``` By default, results are written under `workdir/results//`. +The evaluation pipeline strips the default OCR wrapper tag, flattens supported XML-like OCR output, normalizes whitespace and punctuation, and applies additional Arabic normalization for languages with Arabic script (Arabic and Persian). ## Common Flags @@ -30,27 +30,26 @@ By default, results are written under `workdir/results//`. ## Output Files -Each benchmark run writes one result directory. The important files are: +Each benchmark run writes one result directory. The directory contains two JSON files: - `outputs.json`: one row per evaluated page with the raw predicted text, gold text, and page-level metrics - `all_metrics.json`: aggregate metrics grouped across the full run, by main language, by document type, and by the language/type combination -`outputs.json` stores page-level metric values as raw fractions. `all_metrics.json` converts aggregate values to percentages and rounds them to one decimal place. The evaluation pipeline strips the default OCR wrapper tag, flattens supported XML-like OCR output, normalizes whitespace and punctuation, and applies additional Arabic normalization for Arabic and Persian examples. ## Filtering And Slicing -Subset filters are applied before offset and limit: +You can run benchmarks on subsets of the data by combining `--language`, `--document-type`, `--offset`, and `--input-size`. The filters are applied in the following order: - `--language` filters on `main_language` - `--document-type` filters on `document_type` - `--offset` skips rows after filtering - `--input-size` limits rows after filtering and offset -That means `--language Arabic --offset 100 --input-size 50` selects rows 101 to 150 from the Arabic-only subset, not from the full split. +That means for example `--language Arabic --offset 100 --input-size 50` selects rows 101 to 150 from the Arabic-only subset, not from the full split. ## Example Commands -If you want to benchmark a vLLM-served model, run vLLM separately and point `--backend openai-compatible` at its OpenAI-compatible endpoint. See the [official vLLM serving docs](https://docs.vllm.ai/en/stable/serving/openai_compatible_server.html). +If you want to benchmark a model using vLLM, run a vLLM server separately and point `--backend openai-compatible` at its OpenAI-compatible endpoint. See the [official vLLM serving docs](https://docs.vllm.ai/en/stable/serving/openai_compatible_server.html). | Model | Model ID | Backend | Full command | | --- | --- | --- | --- | From 9dc80f79e010aa8492822abba74c1aa6905e7dc0 Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 6 Apr 2026 15:30:16 +0000 Subject: [PATCH 18/28] docs: add logos to the benchmark table --- benchmark_results.json | 116 ++++++++++++++--------- docs/_static/css/custom.css | 71 +++++++++++++- docs/_static/img/ai2-symbol.svg | 4 + docs/_static/img/azure-logo.png | Bin 0 -> 93963 bytes docs/_static/img/claude-symbol.svg | 7 ++ docs/_static/img/gemini-symbol.png | Bin 0 -> 8233 bytes docs/_static/img/gemma-icon.svg | 1 + docs/_static/img/mistral-logo.svg | 1 + docs/_static/img/nvidia-logo.svg | 32 +++++++ docs/_static/img/openai-symbol.svg | 5 + docs/_static/img/qwen-logo.svg | 15 +++ docs/_static/img/reducto-logomark.svg | 3 + docs/_static/js/benchmark-leaderboard.js | 61 ++++++++---- 13 files changed, 250 insertions(+), 66 deletions(-) create mode 100644 docs/_static/img/ai2-symbol.svg create mode 100644 docs/_static/img/azure-logo.png create mode 100644 docs/_static/img/claude-symbol.svg create mode 100644 docs/_static/img/gemini-symbol.png create mode 100644 docs/_static/img/gemma-icon.svg create mode 100644 docs/_static/img/mistral-logo.svg create mode 100644 docs/_static/img/nvidia-logo.svg create mode 100644 docs/_static/img/openai-symbol.svg create mode 100644 docs/_static/img/qwen-logo.svg create mode 100644 docs/_static/img/reducto-logomark.svg diff --git a/benchmark_results.json b/benchmark_results.json index 0946d93..21183cc 100644 --- a/benchmark_results.json +++ b/benchmark_results.json @@ -3,7 +3,7 @@ "modelName": "Churro", "modelId": "stanford-oval/churro-3B", "modelUrl": "https://huggingface.co/stanford-oval/churro-3B", - "hasIcon": true, + "iconPath": "_static/img/churro.png", "printed": 82.3309, "handwritten": 70.0965, "total": 75.7431 @@ -12,7 +12,7 @@ "modelName": "Gemini 3 Flash", "modelId": "gemini-3-flash", "modelUrl": "https://ai.google.dev/gemini-api/docs/models", - "hasIcon": false, + "iconPath": "_static/img/gemini-symbol.png", "printed": 82.6, "handwritten": 66.5, "total": 73.9 @@ -21,7 +21,7 @@ "modelName": "Gemini 3 Pro", "modelId": "gemini-3-pro", "modelUrl": "https://ai.google.dev/gemini-api/docs/models", - "hasIcon": false, + "iconPath": "_static/img/gemini-symbol.png", "printed": 78.2, "handwritten": 66.7, "total": 72.0 @@ -30,7 +30,7 @@ "modelName": "Gemini 2.5 Pro", "modelId": "gemini-2.5-pro", "modelUrl": "https://ai.google.dev/gemini-api/docs/models", - "hasIcon": false, + "iconPath": "_static/img/gemini-symbol.png", "printed": 80.8523, "handwritten": 63.6329, "total": 71.5803 @@ -39,7 +39,7 @@ "modelName": "Gemini 2.5 Flash", "modelId": "gemini-2.5-flash", "modelUrl": "https://ai.google.dev/gemini-api/docs/models", - "hasIcon": false, + "iconPath": "_static/img/gemini-symbol.png", "printed": 73.719, "handwritten": 58.7283, "total": 65.6471 @@ -48,7 +48,7 @@ "modelName": "Qwen 3 VL (8B)", "modelId": "Qwen/Qwen3-VL-8B-Instruct", "modelUrl": "https://huggingface.co/Qwen/Qwen3-VL-8B-Instruct", - "hasIcon": false, + "iconPath": "_static/img/qwen-logo.svg", "printed": 76.6136, "handwritten": 48.4963, "total": 61.4735 @@ -57,7 +57,7 @@ "modelName": "Qwen 3 VL (30B-A3B)", "modelId": "Qwen/Qwen3-VL-30B-A3B-Instruct", "modelUrl": "https://huggingface.co/Qwen/Qwen3-VL-30B-A3B-Instruct", - "hasIcon": false, + "iconPath": "_static/img/qwen-logo.svg", "printed": 74.8873, "handwritten": 49.558, "total": 61.2485 @@ -66,7 +66,7 @@ "modelName": "NuMarkdown", "modelId": "numind/NuMarkdown-8B-Thinking", "modelUrl": "https://huggingface.co/numind/NuMarkdown-8B-Thinking", - "hasIcon": false, + "iconPath": null, "printed": 72.7318, "handwritten": 51.2224, "total": 61.1498 @@ -75,7 +75,8 @@ "modelName": "GPT-4.1 Mini", "modelId": "gpt-4.1-mini-2025-04-14", "modelUrl": "https://platform.openai.com/docs/models/gpt-4.1-mini", - "hasIcon": false, + "iconPath": "_static/img/openai-symbol.svg", + "iconInvertDark": true, "printed": 73.1042, "handwritten": 50.2487, "total": 60.7974 @@ -84,7 +85,7 @@ "modelName": "Qwen 2.5 VL (72B)", "modelId": "Qwen/Qwen2.5-VL-72B-Instruct", "modelUrl": "https://huggingface.co/Qwen/Qwen2.5-VL-72B-Instruct", - "hasIcon": false, + "iconPath": "_static/img/qwen-logo.svg", "printed": 66.2783, "handwritten": 54.4717, "total": 59.9209 @@ -93,7 +94,7 @@ "modelName": "Azure OCR", "modelId": null, "modelUrl": null, - "hasIcon": false, + "iconPath": "_static/img/azure-logo.png", "printed": 71.8617, "handwritten": 47.7443, "total": 58.8754 @@ -102,7 +103,8 @@ "modelName": "GPT-5.2", "modelId": "gpt-5.2-2025-12-11", "modelUrl": "https://platform.openai.com/docs/models/gpt-5.2", - "hasIcon": false, + "iconPath": "_static/img/openai-symbol.svg", + "iconInvertDark": true, "printed": 71.4, "handwritten": 46.8, "total": 58.2 @@ -111,7 +113,8 @@ "modelName": "GPT-5 Mini", "modelId": "gpt-5-mini-2025-08-07", "modelUrl": "https://platform.openai.com/docs/models/gpt-5-mini", - "hasIcon": false, + "iconPath": "_static/img/openai-symbol.svg", + "iconInvertDark": true, "printed": 69.8107, "handwritten": 47.5121, "total": 57.8038 @@ -120,7 +123,7 @@ "modelName": "Claude Sonnet 3.7", "modelId": "claude-3-7-sonnet-20250219", "modelUrl": "https://docs.claude.com/en/docs/about-claude/models/overview", - "hasIcon": false, + "iconPath": "_static/img/claude-symbol.svg", "printed": 70.1866, "handwritten": 46.5904, "total": 57.481 @@ -129,7 +132,7 @@ "modelName": "RolmOCR", "modelId": "reducto/RolmOCR", "modelUrl": "https://huggingface.co/reducto/RolmOCR", - "hasIcon": false, + "iconPath": "_static/img/reducto-logomark.svg", "printed": 67.2281, "handwritten": 49.0075, "total": 57.417 @@ -138,7 +141,7 @@ "modelName": "Qwen 3 VL (4B)", "modelId": "Qwen/Qwen3-VL-4B-Instruct", "modelUrl": "https://huggingface.co/Qwen/Qwen3-VL-4B-Instruct", - "hasIcon": false, + "iconPath": "_static/img/qwen-logo.svg", "printed": 74.9451, "handwritten": 40.6463, "total": 56.4765 @@ -147,7 +150,7 @@ "modelName": "Nanonets OCR", "modelId": "nanonets/Nanonets-OCR-s", "modelUrl": "https://huggingface.co/nanonets/Nanonets-OCR-s", - "hasIcon": false, + "iconPath": null, "printed": 69.7033, "handwritten": 43.1781, "total": 55.4205 @@ -156,16 +159,16 @@ "modelName": "olmOCR", "modelId": "allenai/olmOCR-7B-0825", "modelUrl": "https://huggingface.co/allenai/olmOCR-7B-0825", - "hasIcon": false, + "iconPath": "_static/img/ai2-symbol.svg", "printed": 69.766, "handwritten": 41.5165, "total": 54.5547 }, - { + { "modelName": "olmOCR 2", "modelId": "allenai/olmOCR-2-7B-1025", "modelUrl": "https://huggingface.co/allenai/olmOCR-2-7B-1025", - "hasIcon": false, + "iconPath": "_static/img/ai2-symbol.svg", "printed": 71.1, "handwritten": 44.6, "total": 56.8 @@ -174,7 +177,7 @@ "modelName": "LiquidAI LFM2.5 VL (1.6B)", "modelId": "LiquidAI/LFM2.5-VL-1.6B", "modelUrl": "https://huggingface.co/LiquidAI/LFM2.5-VL-1.6B", - "hasIcon": false, + "iconPath": null, "printed": 40.2, "handwritten": 25.5, "total": 32.3 @@ -183,7 +186,7 @@ "modelName": "Qwen 2.5 VL (3B)", "modelId": "Qwen/Qwen2.5-VL-3B-Instruct", "modelUrl": "https://huggingface.co/Qwen/Qwen2.5-VL-3B-Instruct", - "hasIcon": false, + "iconPath": "_static/img/qwen-logo.svg", "printed": 67.8366, "handwritten": 42.8576, "total": 54.3864 @@ -192,7 +195,8 @@ "modelName": "O4 Mini", "modelId": "o4-mini-2025-04-16", "modelUrl": "https://platform.openai.com/docs/models/o4-mini", - "hasIcon": false, + "iconPath": "_static/img/openai-symbol.svg", + "iconInvertDark": true, "printed": 65.5032, "handwritten": 41.4801, "total": 52.5677 @@ -201,7 +205,7 @@ "modelName": "Claude Opus 4.1", "modelId": "claude-opus-4-1-20250805", "modelUrl": "https://docs.claude.com/en/docs/about-claude/models/overview", - "hasIcon": false, + "iconPath": "_static/img/claude-symbol.svg", "printed": 66.6052, "handwritten": 40.1808, "total": 52.3767 @@ -210,7 +214,8 @@ "modelName": "GPT-4.1", "modelId": "gpt-4.1-2025-04-14", "modelUrl": "https://platform.openai.com/docs/models/gpt-4.1", - "hasIcon": false, + "iconPath": "_static/img/openai-symbol.svg", + "iconInvertDark": true, "printed": 64.4017, "handwritten": 41.4127, "total": 52.023 @@ -219,7 +224,7 @@ "modelName": "Claude Sonnet 4", "modelId": "claude-sonnet-4-20250514", "modelUrl": "https://docs.claude.com/en/docs/about-claude/models/overview", - "hasIcon": false, + "iconPath": "_static/img/claude-symbol.svg", "printed": 62.2358, "handwritten": 37.0701, "total": 48.685 @@ -228,7 +233,8 @@ "modelName": "O1", "modelId": "o1-2024-12-17", "modelUrl": "https://platform.openai.com/docs/models/o1", - "hasIcon": false, + "iconPath": "_static/img/openai-symbol.svg", + "iconInvertDark": true, "printed": 62.4692, "handwritten": 35.0806, "total": 47.7215 @@ -237,7 +243,8 @@ "modelName": "O3", "modelId": "o3-2025-04-16", "modelUrl": "https://platform.openai.com/docs/models/o3", - "hasIcon": false, + "iconPath": "_static/img/openai-symbol.svg", + "iconInvertDark": true, "printed": 62.65, "handwritten": 30.9051, "total": 45.5566 @@ -246,7 +253,7 @@ "modelName": "Mistral OCR 2", "modelId": "mistral-ocr-2505", "modelUrl": null, - "hasIcon": false, + "iconPath": "_static/img/mistral-logo.svg", "printed": 64.3, "handwritten": 30.4, "total": 46.0 @@ -255,7 +262,7 @@ "modelName": "Mistral OCR 3", "modelId": "mistral-ocr-2512", "modelUrl": null, - "hasIcon": false, + "iconPath": "_static/img/mistral-logo.svg", "printed": 71.5, "handwritten": 47.6, "total": 58.6 @@ -264,7 +271,8 @@ "modelName": "GPT-5", "modelId": "gpt-5-2025-08-07", "modelUrl": "https://platform.openai.com/docs/models/gpt-5", - "hasIcon": false, + "iconPath": "_static/img/openai-symbol.svg", + "iconInvertDark": true, "printed": 60.7442, "handwritten": 30.4073, "total": 44.409 @@ -273,7 +281,8 @@ "modelName": "GPT-4o", "modelId": "gpt-4o-2024-11-20", "modelUrl": "https://platform.openai.com/docs/models/gpt-4o", - "hasIcon": false, + "iconPath": "_static/img/openai-symbol.svg", + "iconInvertDark": true, "printed": 56.2807, "handwritten": 34.1932, "total": 44.3875 @@ -282,7 +291,7 @@ "modelName": "MiMo VL", "modelId": "XiaomiMiMo/MiMo-VL-7B-RL-2508", "modelUrl": "https://huggingface.co/XiaomiMiMo/MiMo-VL-7B-RL-2508", - "hasIcon": false, + "iconPath": null, "printed": 54.8469, "handwritten": 34.6299, "total": 43.9608 @@ -291,16 +300,26 @@ "modelName": "Gemma 3 (27B)", "modelId": "google/gemma-3-27b-it", "modelUrl": "https://huggingface.co/google/gemma-3-27b-it", - "hasIcon": false, + "iconPath": "_static/img/gemma-icon.svg", "printed": 55.3292, "handwritten": 34.1056, "total": 43.9011 }, + { + "modelName": "Qwen 3.5 (2B)", + "modelId": "Qwen/Qwen3.5-2B", + "modelUrl": "https://huggingface.co/Qwen/Qwen3.5-2B", + "iconPath": "_static/img/qwen-logo.svg", + "printed": 57.6, + "handwritten": 31.6, + "total": 43.6 + }, { "modelName": "GPT-4o Mini", "modelId": "gpt-4o-mini-2024-07-18", "modelUrl": "https://platform.openai.com/docs/models/gpt-4o-mini", - "hasIcon": false, + "iconPath": "_static/img/openai-symbol.svg", + "iconInvertDark": true, "printed": 52.8335, "handwritten": 29.7653, "total": 40.4121 @@ -309,7 +328,7 @@ "modelName": "MiniCPM-V 4.5", "modelId": "openbmb/MiniCPM-V-4_5", "modelUrl": "https://huggingface.co/openbmb/MiniCPM-V-4_5", - "hasIcon": false, + "iconPath": null, "printed": 49.7495, "handwritten": 32.0729, "total": 40.2314 @@ -318,7 +337,8 @@ "modelName": "GPT-4.1 Nano", "modelId": "gpt-4.1-nano-2025-04-14", "modelUrl": "https://platform.openai.com/docs/models/gpt-4.1-nano", - "hasIcon": false, + "iconPath": "_static/img/openai-symbol.svg", + "iconInvertDark": true, "printed": 51.8564, "handwritten": 28.3488, "total": 39.1984 @@ -327,7 +347,7 @@ "modelName": "Skywork R1V3", "modelId": "Skywork/Skywork-R1V3-38B", "modelUrl": "https://huggingface.co/Skywork/Skywork-R1V3-38B", - "hasIcon": false, + "iconPath": null, "printed": 42.4283, "handwritten": 25.619, "total": 33.3771 @@ -336,16 +356,25 @@ "modelName": "InternVL 3.5 (30B-A3B)", "modelId": "OpenGVLab/InternVL3_5-30B-A3B", "modelUrl": "https://huggingface.co/OpenGVLab/InternVL3_5-30B-A3B", - "hasIcon": false, + "iconPath": null, "printed": 35.8059, "handwritten": 26.3691, "total": 30.7246 }, + { + "modelName": "Qwen 3.5 (0.8B)", + "modelId": "Qwen/Qwen3.5-0.8B", + "modelUrl": "https://huggingface.co/Qwen/Qwen3.5-0.8B", + "iconPath": "_static/img/qwen-logo.svg", + "printed": 41.7, + "handwritten": 16.9, + "total": 28.4 + }, { "modelName": "R", "modelId": "YannQi/R-4B", "modelUrl": "https://huggingface.co/YannQi/R-4B", - "hasIcon": false, + "iconPath": null, "printed": 32.6874, "handwritten": 21.735, "total": 26.79 @@ -354,7 +383,8 @@ "modelName": "GPT-5 Nano", "modelId": "gpt-5-nano-2025-08-07", "modelUrl": "https://platform.openai.com/docs/models/gpt-5-nano", - "hasIcon": false, + "iconPath": "_static/img/openai-symbol.svg", + "iconInvertDark": true, "printed": 38.7162, "handwritten": 14.165, "total": 25.4963 @@ -363,7 +393,7 @@ "modelName": "Nemotron Nano VL", "modelId": "nvidia/Llama-3.1-Nemotron-Nano-VL-8B-V1", "modelUrl": "https://huggingface.co/nvidia/Llama-3.1-Nemotron-Nano-VL-8B-V1", - "hasIcon": false, + "iconPath": "_static/img/nvidia-logo.svg", "printed": 29.3383, "handwritten": 15.2615, "total": 21.7585 @@ -372,7 +402,7 @@ "modelName": "Phi 4 Multimodal", "modelId": "microsoft/Phi-4-multimodal-instruct", "modelUrl": "https://huggingface.co/microsoft/Phi-4-multimodal-instruct", - "hasIcon": false, + "iconPath": null, "printed": 9.0269, "handwritten": 5.3393, "total": 7.0413 diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index b750fbf..df6d205 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -112,6 +112,10 @@ html[data-theme="dark"] { z-index: 1; } +.benchmark-table tbody tr:nth-child(even) { + background: rgba(139, 69, 31, 0.03); +} + .benchmark-sort-button { align-items: center; appearance: none; @@ -150,11 +154,46 @@ html[data-theme="dark"] { gap: 0.8rem; } -.benchmark-model-icon { - border-radius: 0.4rem; - height: 1.65rem; +.benchmark-model-icon-frame { + align-items: center; + display: inline-flex; + flex: 0 0 auto; + height: 1.9rem; + justify-content: center; margin-top: 0.1rem; - width: 1.65rem; + width: auto; +} + +.benchmark-model-icon { + background: transparent; + border-radius: 0; + display: block; + height: 100%; + max-height: 100%; + max-width: 3rem; + width: auto; +} + +.benchmark-model-icon-placeholder { + align-items: center; + background: rgba(139, 69, 31, 0.06); + border: 1px dashed rgba(139, 69, 31, 0.22); + border-radius: 999px; + box-sizing: border-box; + color: var(--pst-color-text-muted); + display: inline-flex; + height: 1.9rem; + justify-content: center; + width: 1.9rem; +} + +.benchmark-model-icon-placeholder::before { + background: currentColor; + border-radius: 999px; + content: ""; + height: 0.34rem; + opacity: 0.45; + width: 0.34rem; } .benchmark-model-text { @@ -190,6 +229,30 @@ html[data-theme="dark"] .benchmark-table th { background: rgba(244, 178, 94, 0.06); } +html[data-theme="dark"] .benchmark-table tbody tr:nth-child(even) { + background: rgba(244, 178, 94, 0.05); +} + +html[data-theme="dark"] .benchmark-model-icon-placeholder, +[data-bs-theme="dark"] .benchmark-model-icon-placeholder { + background: rgba(244, 178, 94, 0.08); + border-color: rgba(244, 178, 94, 0.24); +} + +html[data-theme="dark"] .bd-content .benchmark-model-icon, +html[data-theme="dark"] .bd-content .benchmark-model-icon-frame, +[data-bs-theme="dark"] .bd-content .benchmark-model-icon, +[data-bs-theme="dark"] .bd-content .benchmark-model-icon-frame { + background: transparent; + box-shadow: none; + border-radius: 0; +} + +html[data-theme="dark"] .bd-content .benchmark-model-icon--invert-dark, +[data-bs-theme="dark"] .bd-content .benchmark-model-icon--invert-dark { + filter: invert(1); +} + @media (min-width: 1200px) { .bd-page-width { max-width: 96rem; diff --git a/docs/_static/img/ai2-symbol.svg b/docs/_static/img/ai2-symbol.svg new file mode 100644 index 0000000..cf2af97 --- /dev/null +++ b/docs/_static/img/ai2-symbol.svg @@ -0,0 +1,4 @@ + + + + diff --git a/docs/_static/img/azure-logo.png b/docs/_static/img/azure-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..23f6803bbdfa6be1adc5a1af27c79b74eb8cfa23 GIT binary patch literal 93963 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
!Oj7-8|!t|jFkeTqzXHCoweovNk4 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 U53HF+tngc?Dr+jeQ!owqKhe}3?EnA( literal 0 HcmV?d00001 diff --git a/docs/_static/img/claude-symbol.svg b/docs/_static/img/claude-symbol.svg new file mode 100644 index 0000000..879ad81 --- /dev/null +++ b/docs/_static/img/claude-symbol.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/docs/_static/img/gemini-symbol.png b/docs/_static/img/gemini-symbol.png new file mode 100644 index 0000000000000000000000000000000000000000..5141463841ef9990a4b4f169f908f3eb52f34a08 GIT binary patch literal 8233 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 \ No newline at end of file diff --git a/docs/_static/img/mistral-logo.svg b/docs/_static/img/mistral-logo.svg new file mode 100644 index 0000000..9ea7556 --- /dev/null +++ b/docs/_static/img/mistral-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/_static/img/nvidia-logo.svg b/docs/_static/img/nvidia-logo.svg new file mode 100644 index 0000000..9393002 --- /dev/null +++ b/docs/_static/img/nvidia-logo.svg @@ -0,0 +1,32 @@ + + + + +generated by pstoedit version:3.44 from NVBadge_2D.eps + + + + diff --git a/docs/_static/img/openai-symbol.svg b/docs/_static/img/openai-symbol.svg new file mode 100644 index 0000000..74d9b1b --- /dev/null +++ b/docs/_static/img/openai-symbol.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/docs/_static/img/qwen-logo.svg b/docs/_static/img/qwen-logo.svg new file mode 100644 index 0000000..64be10b --- /dev/null +++ b/docs/_static/img/qwen-logo.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/docs/_static/img/reducto-logomark.svg b/docs/_static/img/reducto-logomark.svg new file mode 100644 index 0000000..642c73e --- /dev/null +++ b/docs/_static/img/reducto-logomark.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/_static/js/benchmark-leaderboard.js b/docs/_static/js/benchmark-leaderboard.js index f0beee2..240a202 100644 --- a/docs/_static/js/benchmark-leaderboard.js +++ b/docs/_static/js/benchmark-leaderboard.js @@ -10,6 +10,10 @@ { key: "handwritten", label: "Handwritten", numeric: true }, { key: "total", label: "Total", numeric: true }, ]; + const NUMERIC_COLUMNS = new Set( + COLUMN_DEFINITIONS.filter((column) => column.numeric).map((column) => column.key), + ); + const DEFAULT_SORT_STATE = { key: "total", direction: "desc" }; function contentRoot() { return document.documentElement.dataset.content_root || ""; @@ -32,15 +36,42 @@ return SCORE_FORMATTER.format(value); } + function createIconFrame(row) { + const iconFrame = document.createElement("span"); + iconFrame.className = "benchmark-model-icon-frame"; + + if (!row.iconPath) { + const placeholder = document.createElement("span"); + placeholder.className = "benchmark-model-icon-placeholder"; + placeholder.setAttribute("aria-hidden", "true"); + iconFrame.append(placeholder); + return iconFrame; + } + + const icon = document.createElement("img"); + icon.className = "benchmark-model-icon"; + if (row.iconInvertDark) { + icon.classList.add("benchmark-model-icon--invert-dark"); + } + icon.alt = `${row.modelName} icon`; + icon.src = resolvePath(row.iconPath); + iconFrame.append(icon); + + return iconFrame; + } + function compareRows(left, right, sortState) { const { key, direction } = sortState; const multiplier = direction === "asc" ? 1 : -1; const leftValue = left[key]; const rightValue = right[key]; - if (typeof leftValue === "number" && typeof rightValue === "number") { - if (leftValue !== rightValue) { - return (leftValue - rightValue) * multiplier; + if (NUMERIC_COLUMNS.has(key)) { + const leftNumber = Number(leftValue); + const rightNumber = Number(rightValue); + + if (!Number.isNaN(leftNumber) && !Number.isNaN(rightNumber) && leftNumber !== rightNumber) { + return (leftNumber - rightNumber) * multiplier; } return left.modelName.localeCompare(right.modelName); } @@ -48,17 +79,11 @@ return String(leftValue || "").localeCompare(String(rightValue || "")) * multiplier; } - function createModelCell(row, logoPath) { + function createModelCell(row) { const wrapper = document.createElement("div"); wrapper.className = "benchmark-model"; - if (row.hasIcon) { - const icon = document.createElement("img"); - icon.className = "benchmark-model-icon"; - icon.alt = `${row.modelName} icon`; - icon.src = logoPath; - wrapper.append(icon); - } + wrapper.append(createIconFrame(row)); const textBlock = document.createElement("div"); textBlock.className = "benchmark-model-text"; @@ -104,7 +129,7 @@ return button; } - function renderLeaderboard(container, rows, sortState, logoPath) { + function renderLeaderboard(container, rows, sortState) { container.replaceChildren(); const sortedRows = [...rows].sort((left, right) => compareRows(left, right, sortState)); @@ -130,7 +155,7 @@ sortState.key = key; sortState.direction = key === "modelName" ? "asc" : "desc"; } - renderLeaderboard(container, rows, sortState, logoPath); + renderLeaderboard(container, rows, sortState); }; for (const column of COLUMN_DEFINITIONS) { @@ -147,7 +172,7 @@ sortedRows.forEach((row, index) => { const tr = document.createElement("tr"); - if (row.hasIcon) { + if (row.iconPath) { tr.classList.add("is-featured"); } @@ -157,7 +182,7 @@ tr.append(rankCell); const modelCell = document.createElement("td"); - modelCell.append(createModelCell(row, logoPath)); + modelCell.append(createModelCell(row)); tr.append(modelCell); for (const key of ["printed", "handwritten", "total"]) { @@ -184,8 +209,6 @@ } async function initializeLeaderboard(container) { - const logoPath = resolvePath("_static/img/churro.png"); - try { const response = await fetch(resolvePath("_static/data/benchmark_results.json")); if (!response.ok) { @@ -193,8 +216,8 @@ } const rows = await response.json(); - const sortState = { key: "total", direction: "desc" }; - renderLeaderboard(container, rows, sortState, logoPath); + const sortState = { ...DEFAULT_SORT_STATE }; + renderLeaderboard(container, rows, sortState); } catch (error) { renderError(container, "Unable to load the benchmark leaderboard data."); console.error("[benchmark-leaderboard] failed to initialize", error); From b989362259318b94930c6fe11fd41f9af74e109e Mon Sep 17 00:00:00 2001 From: Sina Date: Tue, 7 Apr 2026 01:36:50 +0000 Subject: [PATCH 19/28] fix(ocr): allow empty model responses --- src/churro_ocr/_internal/litellm.py | 14 ++++++-- src/churro_ocr/providers/ocr.py | 1 + tests/test_internal_helpers.py | 25 +++++++++++++ tests/test_providers.py | 54 ++++++++++++++++++++++++++++- 4 files changed, 91 insertions(+), 3 deletions(-) diff --git a/src/churro_ocr/_internal/litellm.py b/src/churro_ocr/_internal/litellm.py index 23fb318..eaed37c 100644 --- a/src/churro_ocr/_internal/litellm.py +++ b/src/churro_ocr/_internal/litellm.py @@ -162,6 +162,7 @@ async def complete_text( messages: list[dict[str, Any]], timeout_seconds: int = 600, output_json: bool = False, + allow_empty: bool = False, ) -> str: """Run a LiteLLM completion and return the text content.""" if self._config.cache_dir is not None: @@ -197,9 +198,16 @@ async def complete_text( self._record_response_cost(model=model, response=response) answer = response.choices[0].message.content - if not isinstance(answer, str) or not answer.strip(): + if isinstance(answer, str): + if answer.strip(): + return answer + if allow_empty: + return "" + elif answer is None and allow_empty: + return "" + if not isinstance(answer, str): raise ProviderError(f"LiteLLM returned empty output for model '{model}'.") - return answer + raise ProviderError(f"LiteLLM returned empty output for model '{model}'.") def _resolved_image_detail(self) -> str | None: return "high" if self._config.image_detail is None else self._config.image_detail @@ -333,6 +341,7 @@ async def complete_text( api_version: str | None = None, timeout_seconds: int = 600, output_json: bool = False, + allow_empty: bool = False, completion_kwargs: dict[str, object] | None = None, ) -> str: """Run a LiteLLM completion and return the text content.""" @@ -349,6 +358,7 @@ async def complete_text( messages=messages, timeout_seconds=timeout_seconds, output_json=output_json, + allow_empty=allow_empty, ) diff --git a/src/churro_ocr/providers/ocr.py b/src/churro_ocr/providers/ocr.py index 4c40a49..452b47d 100644 --- a/src/churro_ocr/providers/ocr.py +++ b/src/churro_ocr/providers/ocr.py @@ -105,6 +105,7 @@ async def ocr(self, page: DocumentPage) -> OCRResult: text = await self.transport.complete_text( model=self.model, messages=messages, + allow_empty=True, ) return build_ocr_result( text, diff --git a/tests/test_internal_helpers.py b/tests/test_internal_helpers.py index 42edae9..427bdb4 100644 --- a/tests/test_internal_helpers.py +++ b/tests/test_internal_helpers.py @@ -144,12 +144,14 @@ async def _fake_complete_text( messages: list[dict[str, object]], timeout_seconds: int = 600, output_json: bool = False, + allow_empty: bool = False, ) -> str: captured["config"] = self.config captured["model"] = model captured["messages"] = messages captured["timeout_seconds"] = timeout_seconds captured["output_json"] = output_json + captured["allow_empty"] = allow_empty return "ok" monkeypatch.setattr( @@ -177,6 +179,7 @@ async def _fake_complete_text( assert config.completion_kwargs == {"temperature": 0} assert captured["timeout_seconds"] == 42 assert captured["output_json"] is True + assert captured["allow_empty"] is False def test_extract_response_cost_uses_completion_cost_fallback(monkeypatch: pytest.MonkeyPatch) -> None: @@ -344,6 +347,28 @@ async def _empty_acompletion(**_: object) -> object: ) +@pytest.mark.asyncio +async def test_transport_complete_text_allows_empty_output(monkeypatch: pytest.MonkeyPatch) -> None: + async def _empty_acompletion(**_: object) -> object: + return SimpleNamespace( + choices=[SimpleNamespace(message=SimpleNamespace(content=" "))], + _hidden_params={}, + ) + + fake_module = _make_fake_litellm_module(acompletion=_empty_acompletion) + monkeypatch.setitem(sys.modules, "litellm", fake_module) + monkeypatch.setattr(litellm_module, "_INITIALIZED", False) + + transport = LiteLLMTransport() + result = await transport.complete_text( + model="example/model", + messages=[{"role": "user", "content": [{"type": "text", "text": "hello"}]}], + allow_empty=True, + ) + + assert result == "" + + def test_logger_adapter_supports_success_fallback_and_other_levels() -> None: records: list[tuple[str, str]] = [] diff --git a/tests/test_providers.py b/tests/test_providers.py index ca50a88..7efba90 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -71,6 +71,7 @@ async def test_litellm_ocr_backend_uses_transport(monkeypatch: pytest.MonkeyPatc image = Image.new("RGB", (10, 10), color="white") page = DocumentPage(page_index=0, image=image, source_index=0) prompt_logs: list[str] = [] + captured: dict[str, object] = {} class FakeLogger: def debug(self, message: str, *args: object) -> None: @@ -86,7 +87,21 @@ def _fake_prepare_messages( assert image_detail == "high" return [{"role": "user", "content": [{"type": "text", "text": "prompt"}]}] - async def _fake_complete_text(self, **_: object) -> str: # noqa: ANN001 + async def _fake_complete_text( + self: LiteLLMTransport, + *, + model: str, + messages: list[dict[str, object]], + timeout_seconds: int = 600, + output_json: bool = False, + allow_empty: bool = False, + ) -> str: + captured["transport"] = self + captured["model"] = model + captured["messages"] = messages + captured["timeout_seconds"] = timeout_seconds + captured["output_json"] = output_json + captured["allow_empty"] = allow_empty return "transcribed text" monkeypatch.setattr( @@ -107,6 +122,12 @@ async def _fake_complete_text(self, **_: object) -> str: # noqa: ANN001 assert result.text == "transcribed text" assert result.model_name == "gpt-4.1-mini" assert backend.transport.config.completion_kwargs == {"max_tokens": DEFAULT_OCR_MAX_TOKENS} + assert captured["transport"] is backend.transport + assert captured["model"] == "gpt-4.1-mini" + assert captured["messages"] == [{"role": "user", "content": [{"type": "text", "text": "prompt"}]}] + assert captured["timeout_seconds"] == 600 + assert captured["output_json"] is False + assert captured["allow_empty"] is True assert len(prompt_logs) == 1 assert "First OCR prompt payload for litellm" in prompt_logs[0] @@ -202,6 +223,31 @@ async def _fake_complete_text(self, **_: object) -> str: # noqa: ANN001 assert result.text == "transcribed text" +@pytest.mark.asyncio +async def test_litellm_ocr_backend_accepts_empty_transport_output( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def _fake_complete_text(self, **_: object) -> str: # noqa: ANN001 + return "" + + monkeypatch.setattr( + "churro_ocr._internal.litellm._prepare_messages_from_conversation", + lambda *_args, **_kwargs: [], + ) + monkeypatch.setattr("churro_ocr._internal.litellm.LiteLLMTransport.complete_text", _fake_complete_text) + + backend = build_ocr_backend( + OCRBackendSpec( + provider="litellm", + model="gpt-4.1-mini", + profile=resolve_ocr_profile(model_id="gpt-4.1-mini"), + ) + ) + result = await backend.ocr(DocumentPage.from_image(Image.new("RGB", (10, 10), color="white"))) + + assert result.text == "" + + @pytest.mark.asyncio async def test_openai_compatible_backend_reports_display_model( monkeypatch: pytest.MonkeyPatch, @@ -746,11 +792,13 @@ async def _fake_complete_text( messages: list[dict[str, object]], timeout_seconds: int = 600, output_json: bool = False, + allow_empty: bool = False, ) -> str: captured["model"] = model captured["messages"] = messages captured["timeout_seconds"] = timeout_seconds captured["output_json"] = output_json + captured["allow_empty"] = allow_empty captured["completion_kwargs"] = dict(self.config.completion_kwargs) return ( "---\n" @@ -808,6 +856,7 @@ async def _fake_complete_text( assert captured["messages"] == [{"role": "user", "content": [{"type": "text", "text": "prompt"}]}] assert captured["timeout_seconds"] == 600 assert captured["output_json"] is False + assert captured["allow_empty"] is True assert captured["completion_kwargs"] == { "max_tokens": 8_000, "temperature": 0.1, @@ -843,11 +892,13 @@ async def _fake_complete_text( messages: list[dict[str, object]], timeout_seconds: int = 600, output_json: bool = False, + allow_empty: bool = False, ) -> str: captured["model"] = model captured["messages"] = messages captured["timeout_seconds"] = timeout_seconds captured["output_json"] = output_json + captured["allow_empty"] = allow_empty captured["completion_kwargs"] = dict(self.config.completion_kwargs) return ( '

Ledger

\n' @@ -892,6 +943,7 @@ async def _fake_complete_text( assert captured["messages"] == [{"role": "user", "content": [{"type": "text", "text": "prompt"}]}] assert captured["timeout_seconds"] == 600 assert captured["output_json"] is False + assert captured["allow_empty"] is True assert captured["completion_kwargs"] == { "max_tokens": 12_384, "temperature": 0.0, From a56e1bca3691f1c3011301578c5ea206b0b64543 Mon Sep 17 00:00:00 2001 From: Sina Date: Tue, 7 Apr 2026 01:40:00 +0000 Subject: [PATCH 20/28] fix(ocr): harden dots.ocr generation handling --- src/churro_ocr/providers/hf.py | 80 +++++++++++++++++++++++++++++-- src/churro_ocr/providers/specs.py | 6 +++ tests/test_cli.py | 8 ++++ tests/test_hf_ocr.py | 7 ++- tests/test_tooling_benchmark.py | 5 +- 5 files changed, 100 insertions(+), 6 deletions(-) diff --git a/src/churro_ocr/providers/hf.py b/src/churro_ocr/providers/hf.py index f0b73b0..90fa499 100644 --- a/src/churro_ocr/providers/hf.py +++ b/src/churro_ocr/providers/hf.py @@ -7,6 +7,7 @@ from dataclasses import dataclass, field from importlib import import_module from pathlib import Path +from types import MethodType from typing import Any, cast from churro_ocr._internal.install import install_command_hint @@ -165,6 +166,64 @@ def _prepare_dots_ocr_model_dir(model_id: str) -> str: return str(model_dir) +def _patch_dots_ocr_prepare_inputs_for_generation(model: Any) -> None: + prepare_inputs_for_generation = getattr(model, "prepare_inputs_for_generation", None) + if not callable(prepare_inputs_for_generation): + return + if getattr(model, "_churro_dots_prepare_inputs_patched", False): + return + + original_prepare_inputs = getattr(prepare_inputs_for_generation, "__func__", None) + base_prepare_inputs_for_generation = prepare_inputs_for_generation + if original_prepare_inputs is not None: + for candidate in type(model).__mro__[1:]: + candidate_prepare_inputs = candidate.__dict__.get("prepare_inputs_for_generation") + if candidate_prepare_inputs is None or candidate_prepare_inputs is original_prepare_inputs: + continue + base_prepare_inputs_for_generation = cast("Any", candidate_prepare_inputs).__get__( + model, + type(model), + ) + break + if not callable(base_prepare_inputs_for_generation): + return + + def _patched_prepare_inputs_for_generation( + self: Any, + input_ids: object, + past_key_values: object = None, + inputs_embeds: object = None, + pixel_values: object = None, + attention_mask: object = None, + cache_position: object = None, + num_logits_to_keep: object = None, + **kwargs: object, + ) -> Any: + model_inputs = base_prepare_inputs_for_generation( + input_ids, + past_key_values=past_key_values, + inputs_embeds=inputs_embeds, + attention_mask=attention_mask, + cache_position=cache_position, + num_logits_to_keep=num_logits_to_keep, + **kwargs, + ) + + first_cache_position: int | None = None + if cache_position is not None: + try: + first_cache_position = int(cast("Any", cache_position)[0]) + except Exception: + first_cache_position = None + if first_cache_position in (None, 0): + model_inputs["pixel_values"] = pixel_values + + return model_inputs + + model.prepare_inputs_for_generation = MethodType(_patched_prepare_inputs_for_generation, model) + model._churro_dots_prepare_inputs_patched = True + + def _default_dots_ocr_1_5_model_kwargs() -> dict[str, object]: model_kwargs: dict[str, object] = {"dtype": "auto"} try: @@ -322,8 +381,10 @@ def _ocr_sync(self, page: DocumentPage) -> OCRResult: batch_kwargs["videos"] = normalized_video_inputs batch = processor(**batch_kwargs) batch = _move_batch_to_model(batch, model) - - generated_ids = model.generate(**batch, **self.generation_kwargs) + generated_ids = model.generate( + **self._generation_inputs(batch), + **self.generation_kwargs, + ) text = _decode_completion_texts(processor, batch, generated_ids)[0] return build_ocr_result( text, @@ -379,8 +440,10 @@ def _ocr_batch_sync(self, pages: list[DocumentPage]) -> list[OCRResult]: batch_kwargs["videos"] = video_batch batch = processor(**batch_kwargs) batch = _move_batch_to_model(batch, model) - - generated_ids = model.generate(**batch, **self.generation_kwargs) + generated_ids = model.generate( + **self._generation_inputs(batch), + **self.generation_kwargs, + ) texts = _decode_completion_texts(processor, batch, generated_ids) return [ build_ocr_result( @@ -395,6 +458,9 @@ def _ocr_batch_sync(self, pages: list[DocumentPage]) -> list[OCRResult]: def _load_runtime(self) -> _HFRuntime: return _load_hf_runtime() + def _generation_inputs(self, batch: Any) -> dict[str, object]: + return dict(cast("dict[str, object]", batch)) + def _resolve_model_source(self) -> str: return self.model_id @@ -636,6 +702,11 @@ class DotsOCR15OCRBackend(HuggingFaceVisionOCRBackend): def _load_runtime(self) -> _HFRuntime: return _load_hf_causal_runtime() + def _generation_inputs(self, batch: Any) -> dict[str, object]: + generation_inputs = super()._generation_inputs(batch) + generation_inputs.pop("mm_token_type_ids", None) + return generation_inputs + def _resolve_model_source(self) -> str: return _prepare_dots_ocr_model_dir(self.model_id) @@ -661,6 +732,7 @@ def _get_model(self, runtime: _HFRuntime) -> Any: trust_remote_code=self.trust_remote_code, **self.model_kwargs, ) + _patch_dots_ocr_prepare_inputs_for_generation(self._model) return self._model diff --git a/src/churro_ocr/providers/specs.py b/src/churro_ocr/providers/specs.py index ee0b953..7405da9 100644 --- a/src/churro_ocr/providers/specs.py +++ b/src/churro_ocr/providers/specs.py @@ -394,6 +394,12 @@ def dots_ocr_1_5_profile() -> OCRModelProfile: template=DOTS_OCR_1_5_OCR_TEMPLATE, text_postprocessor=identity_text_postprocessor, display_name="dots.ocr-1.5", + transport=LiteLLMTransportConfig( + completion_kwargs={ + "max_tokens": 2_048, + "temperature": 0.0, + } + ), huggingface=HuggingFaceOptions( trust_remote_code=True, backend_variant="dots-ocr-1.5", diff --git a/tests/test_cli.py b/tests/test_cli.py index 09ca454..97f6b9b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -197,6 +197,14 @@ def test_build_ocr_backend_aligns_templates_for_dots() -> None: assert litellm_backend.model_name == "dots.ocr-1.5" assert hf_backend.model_name == "dots.ocr-1.5" assert openai_backend.model_name == "dots.ocr-1.5" + assert litellm_backend.transport.config.completion_kwargs == { + "max_tokens": 2_048, + "temperature": 0.0, + } + assert openai_backend.transport.config.completion_kwargs == { + "max_tokens": 2_048, + "temperature": 0.0, + } def test_build_ocr_backend_uses_generic_defaults_for_qwen_3_5_0_8b() -> None: diff --git a/tests/test_hf_ocr.py b/tests/test_hf_ocr.py index e7a4e2c..1e7aedb 100644 --- a/tests/test_hf_ocr.py +++ b/tests/test_hf_ocr.py @@ -847,7 +847,12 @@ def apply_chat_template( def __call__(self, **kwargs: object) -> FakeBatch: captured["processor_kwargs"] = kwargs - return FakeBatch({"input_ids": SimpleNamespace(shape=(1, 4))}) + return FakeBatch( + { + "input_ids": SimpleNamespace(shape=(1, 4)), + "mm_token_type_ids": "ignored-mm-token-type-ids", + } + ) def batch_decode( self, diff --git a/tests/test_tooling_benchmark.py b/tests/test_tooling_benchmark.py index 8abe82a..71ca46d 100644 --- a/tests/test_tooling_benchmark.py +++ b/tests/test_tooling_benchmark.py @@ -265,7 +265,10 @@ def test_build_ocr_backend_uses_dots_preset_for_openai_compatible() -> None: assert backend.provider_name == "openai-compatible" assert backend.model_name == "dots.ocr-1.5" assert backend.template == DOTS_OCR_1_5_OCR_TEMPLATE - assert backend.transport.config.completion_kwargs == {"max_tokens": DEFAULT_OCR_MAX_TOKENS} + assert backend.transport.config.completion_kwargs == { + "max_tokens": 2_048, + "temperature": 0.0, + } def test_build_ocr_backend_uses_churro_preset_template_for_openai_compatible() -> None: From a58e24422aac879c6128212e25c0d42945dc7961 Mon Sep 17 00:00:00 2001 From: Sina Date: Tue, 7 Apr 2026 01:40:24 +0000 Subject: [PATCH 21/28] feat(ocr): add PaddleOCR-VL 1.5 presets --- src/churro_ocr/providers/builder.py | 3 + src/churro_ocr/providers/hf.py | 144 +++++++++++++++++++++++++++ src/churro_ocr/providers/specs.py | 36 +++++++ src/churro_ocr/templates/__init__.py | 6 ++ src/churro_ocr/templates/presets.py | 9 ++ tests/test_cli.py | 43 ++++++++ tests/test_providers.py | 95 ++++++++++++++++++ 7 files changed, 336 insertions(+) diff --git a/src/churro_ocr/providers/builder.py b/src/churro_ocr/providers/builder.py index 8845f46..afd70f6 100644 --- a/src/churro_ocr/providers/builder.py +++ b/src/churro_ocr/providers/builder.py @@ -10,6 +10,7 @@ DotsOCR15OCRBackend, HuggingFaceVisionOCRBackend, LFM25VLOCRBackend, + PaddleOCRVL15OCRBackend, _default_dots_ocr_1_5_model_kwargs, ) from churro_ocr.providers.ocr import ( @@ -168,6 +169,8 @@ def _build_huggingface_backend(spec: OCRBackendSpec, profile: OCRModelProfile) - model_kwargs = _merge_mapping(_default_dots_ocr_1_5_model_kwargs(), model_kwargs) elif options.backend_variant == "chandra-ocr-2": backend_cls = ChandraOCR2OCRBackend + elif options.backend_variant == "paddleocr-vl-1.5": + backend_cls = PaddleOCRVL15OCRBackend elif options.backend_variant == "lfm2.5-vl": backend_cls = LFM25VLOCRBackend return backend_cls( diff --git a/src/churro_ocr/providers/hf.py b/src/churro_ocr/providers/hf.py index 90fa499..98f9b91 100644 --- a/src/churro_ocr/providers/hf.py +++ b/src/churro_ocr/providers/hf.py @@ -38,6 +38,8 @@ DOTS_OCR_1_5_OCR_TEMPLATE, LFM2_5_VL_1_6B_MODEL_ID, LFM2_5_VL_1_6B_OCR_TEMPLATE, + PADDLEOCR_VL_1_5_MODEL_ID, + PADDLEOCR_VL_1_5_OCR_TEMPLATE, OCRConversation, OCRPromptTemplateLike, ) @@ -291,6 +293,24 @@ def _decode_completion_texts(processor: Any, batch: Any, generated_ids: Any) -> ) +def _paddleocr_vl_processor_kwargs(*, processor: Any, padding: bool) -> dict[str, object]: + processor_kwargs: dict[str, object] = { + "text_kwargs": { + "padding": padding, + "return_mm_token_type_ids": True, + } + } + image_processor = getattr(processor, "image_processor", None) + images_kwargs: dict[str, int] = {} + for key in ("min_pixels", "max_pixels"): + value = getattr(image_processor, key, None) + if isinstance(value, int): + images_kwargs[key] = value + if images_kwargs: + processor_kwargs["images_kwargs"] = images_kwargs + return processor_kwargs + + @dataclass(slots=True) class HuggingFaceVisionOCRBackend(OCRBackend): """OCR backend for local Hugging Face multimodal models with custom templates. @@ -736,6 +756,129 @@ def _get_model(self, runtime: _HFRuntime) -> Any: return self._model +@dataclass(slots=True) +class PaddleOCRVL15OCRBackend(HuggingFaceVisionOCRBackend): + """Preset OCR backend for ``PaddlePaddle/PaddleOCR-VL-1.5``.""" + + model_id: str = PADDLEOCR_VL_1_5_MODEL_ID + template: OCRPromptTemplateLike = PADDLEOCR_VL_1_5_OCR_TEMPLATE + model_name: str | None = "PaddleOCR-VL-1.5" + generation_kwargs: dict[str, object] = field( + default_factory=lambda: {"max_new_tokens": 4_096, "do_sample": False} + ) + + def _get_processor(self, runtime: _HFRuntime) -> Any: + processor = super()._get_processor(runtime) + tokenizer = getattr(processor, "tokenizer", None) + if tokenizer is not None and getattr(tokenizer, "padding_side", None) != "left": + tokenizer.padding_side = "left" + return processor + + def _get_model(self, runtime: _HFRuntime) -> Any: + model = super()._get_model(runtime) + eval_method = getattr(model, "eval", None) + if callable(eval_method): + eval_method() + return model + + def _build_paddleocr_vl_batch( + self, + processor: Any, + conversations: OCRConversation | list[OCRConversation], + *, + padding: bool, + ) -> Any: + processor_apply = getattr(processor, "apply_chat_template", None) + if not callable(processor_apply): + raise ConfigurationError( + "PaddleOCR-VL-1.5 requires `processor.apply_chat_template(...)` support." + ) + return processor_apply( + conversations, + add_generation_prompt=True, + tokenize=True, + return_dict=True, + return_tensors="pt", + processor_kwargs=_paddleocr_vl_processor_kwargs( + processor=processor, + padding=padding, + ), + ) + + def _ocr_sync(self, page: DocumentPage) -> OCRResult: + prepared_page = preprocess_backend_page( + page, + image_preprocessor=self.image_preprocessor, + ) + runtime = self._load_runtime() + processor = self._get_processor(runtime) + model = self._get_model(runtime) + + rendered, conversation = render_ocr_prompt( + processor, + self.template, + prepared_page, + add_generation_prompt=True, + ) + self._log_prompt_payload( + rendered_prompt=rendered, + conversation=conversation, + batch_size=1, + ) + batch = self._build_paddleocr_vl_batch(processor, conversation, padding=False) + batch = _move_batch_to_model(batch, model) + generated_ids = model.generate(**batch, **self.generation_kwargs) + text = _decode_completion_texts(processor, batch, generated_ids)[0] + return build_ocr_result( + text, + provider_name=self.provider_name, + model_name=self.model_name or self.model_id, + text_postprocessor=self.text_postprocessor, + ) + + def _ocr_batch_sync(self, pages: list[DocumentPage]) -> list[OCRResult]: + if not pages: + return [] + + runtime = self._load_runtime() + processor = self._get_processor(runtime) + model = self._get_model(runtime) + conversations: list[OCRConversation] = [] + + for page in pages: + prepared_page = preprocess_backend_page( + page, + image_preprocessor=self.image_preprocessor, + ) + rendered, conversation = render_ocr_prompt( + processor, + self.template, + prepared_page, + add_generation_prompt=True, + ) + conversations.append(conversation) + if not self._has_logged_prompt: + self._log_prompt_payload( + rendered_prompt=rendered, + conversation=conversation, + batch_size=len(pages), + ) + + batch = self._build_paddleocr_vl_batch(processor, conversations, padding=True) + batch = _move_batch_to_model(batch, model) + generated_ids = model.generate(**batch, **self.generation_kwargs) + texts = _decode_completion_texts(processor, batch, generated_ids) + return [ + build_ocr_result( + text, + provider_name=self.provider_name, + model_name=self.model_name or self.model_id, + text_postprocessor=self.text_postprocessor, + ) + for text in texts + ] + + @dataclass(slots=True) class LFM25VLOCRBackend(HuggingFaceVisionOCRBackend): """Preset OCR backend for ``LiquidAI/LFM2.5-VL-1.6B``.""" @@ -873,4 +1016,5 @@ def _ocr_batch_sync(self, pages: list[DocumentPage]) -> list[OCRResult]: "DotsOCR15OCRBackend", "HuggingFaceVisionOCRBackend", "LFM25VLOCRBackend", + "PaddleOCRVL15OCRBackend", ] diff --git a/src/churro_ocr/providers/specs.py b/src/churro_ocr/providers/specs.py index 7405da9..ab0f699 100644 --- a/src/churro_ocr/providers/specs.py +++ b/src/churro_ocr/providers/specs.py @@ -31,6 +31,9 @@ OLMOCR_2_7B_1025_FP8_MODEL_ID, OLMOCR_2_7B_1025_MODEL_ID, OLMOCR_2_7B_1025_OCR_TEMPLATE, + PADDLEOCR_VL_1_5_MODEL_ID, + PADDLEOCR_VL_1_5_OCR_PROMPT, + PADDLEOCR_VL_1_5_OCR_TEMPLATE, OCRConversation, OCRPromptTemplateLike, ) @@ -48,6 +51,7 @@ DEFAULT_OCR_MAX_TOKENS = 20_000 CHANDRA_OCR_MAX_TOKENS = 12_384 OLMOCR_MAX_TOKENS = 8_000 +PADDLEOCR_VL_MAX_TOKENS = 4_096 CHANDRA_MAX_IMAGE_SIZE = (3_072, 2_048) CHANDRA_MIN_IMAGE_SIZE = (1_792, 28) CHANDRA_IMAGE_GRID_SIZE = 28 @@ -172,6 +176,11 @@ def lfm2_5_vl_text_postprocessor(text: str) -> str: return strip_ocr_output_tag(cleaned, output_tag=DEFAULT_OCR_OUTPUT_TAG) +def paddleocr_vl_text_postprocessor(text: str) -> str: + """Strip PaddleOCR-VL prompt echoes and leading chat scaffold from OCR output.""" + return _strip_leading_chat_scaffold(text, prompts=[PADDLEOCR_VL_1_5_OCR_PROMPT]) + + def chandra_image_preprocessor(image: Image.Image) -> Image.Image: """Resize an image using Chandra OCR 2's pixel-budget and 28px-grid scaling.""" width, height = image.size @@ -407,6 +416,29 @@ def dots_ocr_1_5_profile() -> OCRModelProfile: ) +def paddleocr_vl_1_5_profile() -> OCRModelProfile: + """Return the built-in ``PaddlePaddle/PaddleOCR-VL-1.5`` OCR profile.""" + return OCRModelProfile( + profile_name=PADDLEOCR_VL_1_5_MODEL_ID, + template=PADDLEOCR_VL_1_5_OCR_TEMPLATE, + text_postprocessor=paddleocr_vl_text_postprocessor, + display_name="PaddleOCR-VL-1.5", + transport=LiteLLMTransportConfig( + completion_kwargs={ + "max_tokens": PADDLEOCR_VL_MAX_TOKENS, + "temperature": 0.0, + } + ), + huggingface=HuggingFaceOptions( + generation_kwargs={ + "max_new_tokens": PADDLEOCR_VL_MAX_TOKENS, + "do_sample": False, + }, + backend_variant="paddleocr-vl-1.5", + ), + ) + + def _olmocr_profile(*, profile_name: str, display_name: str) -> OCRModelProfile: return OCRModelProfile( profile_name=profile_name, @@ -472,6 +504,7 @@ def _profile_registry() -> dict[str, OCRModelProfile]: lfm2_5_vl_profile = lfm2_5_vl_1_6b_profile() olmocr_profile = olmocr_2_7b_1025_profile() olmocr_fp8_profile = olmocr_2_7b_1025_fp8_profile() + paddleocr_vl_profile = paddleocr_vl_1_5_profile() return { default_profile.profile_name: default_profile, churro_profile.profile_name: churro_profile, @@ -480,6 +513,7 @@ def _profile_registry() -> dict[str, OCRModelProfile]: lfm2_5_vl_profile.profile_name: lfm2_5_vl_profile, olmocr_profile.profile_name: olmocr_profile, olmocr_fp8_profile.profile_name: olmocr_fp8_profile, + paddleocr_vl_profile.profile_name: paddleocr_vl_profile, } @@ -530,6 +564,8 @@ def resolve_ocr_profile( "MistralOptions", "olmocr_image_preprocessor", "olmocr_text_postprocessor", + "paddleocr_vl_1_5_profile", + "paddleocr_vl_text_postprocessor", "OCRBackendSpec", "OCRModelProfile", "OCRProvider", diff --git a/src/churro_ocr/templates/__init__.py b/src/churro_ocr/templates/__init__.py index 64c264c..4324315 100644 --- a/src/churro_ocr/templates/__init__.py +++ b/src/churro_ocr/templates/__init__.py @@ -22,6 +22,9 @@ OLMOCR_2_7B_1025_FP8_MODEL_ID, OLMOCR_2_7B_1025_MODEL_ID, OLMOCR_2_7B_1025_OCR_TEMPLATE, + PADDLEOCR_VL_1_5_MODEL_ID, + PADDLEOCR_VL_1_5_OCR_PROMPT, + PADDLEOCR_VL_1_5_OCR_TEMPLATE, ) __all__ = [ @@ -36,6 +39,9 @@ "DOTS_OCR_1_5_OCR_TEMPLATE", "LFM2_5_VL_1_6B_MODEL_ID", "LFM2_5_VL_1_6B_OCR_TEMPLATE", + "PADDLEOCR_VL_1_5_MODEL_ID", + "PADDLEOCR_VL_1_5_OCR_PROMPT", + "PADDLEOCR_VL_1_5_OCR_TEMPLATE", "HFChatTemplate", "OLMOCR_2_7B_1025_FP8_MODEL_ID", "OLMOCR_2_7B_1025_MODEL_ID", diff --git a/src/churro_ocr/templates/presets.py b/src/churro_ocr/templates/presets.py index e9c6464..3a549af 100644 --- a/src/churro_ocr/templates/presets.py +++ b/src/churro_ocr/templates/presets.py @@ -13,6 +13,7 @@ CHURRO_3B_MODEL_ID = "stanford-oval/churro-3B" CHANDRA_OCR_2_MODEL_ID = "datalab-to/chandra-ocr-2" DOTS_OCR_1_5_MODEL_ID = "kristaller486/dots.ocr-1.5" +PADDLEOCR_VL_1_5_MODEL_ID = "PaddlePaddle/PaddleOCR-VL-1.5" OLMOCR_2_7B_1025_MODEL_ID = "allenai/olmOCR-2-7B-1025" OLMOCR_2_7B_1025_FP8_MODEL_ID = "allenai/olmOCR-2-7B-1025-FP8" LFM2_5_VL_1_6B_MODEL_ID = "LiquidAI/LFM2.5-VL-1.6B" @@ -34,6 +35,11 @@ system_message=None, user_prompt=DOTS_OCR_1_5_OCR_PROMPT, ) +PADDLEOCR_VL_1_5_OCR_PROMPT = "OCR:" +PADDLEOCR_VL_1_5_OCR_TEMPLATE = HFChatTemplate( + system_message=None, + user_prompt=PADDLEOCR_VL_1_5_OCR_PROMPT, +) OLMOCR_2_7B_1025_OCR_TEMPLATE = HFChatTemplate( system_message=None, user_prompt=OLMOCR_V4_YAML_PROMPT, @@ -54,6 +60,9 @@ "DOTS_OCR_1_5_MODEL_ID", "DOTS_OCR_1_5_OCR_PROMPT", "DOTS_OCR_1_5_OCR_TEMPLATE", + "PADDLEOCR_VL_1_5_MODEL_ID", + "PADDLEOCR_VL_1_5_OCR_PROMPT", + "PADDLEOCR_VL_1_5_OCR_TEMPLATE", "OLMOCR_2_7B_1025_FP8_MODEL_ID", "OLMOCR_2_7B_1025_MODEL_ID", "OLMOCR_2_7B_1025_OCR_TEMPLATE", diff --git a/tests/test_cli.py b/tests/test_cli.py index 97f6b9b..237c32d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -23,6 +23,8 @@ DOTS_OCR_1_5_OCR_TEMPLATE, OLMOCR_2_7B_1025_MODEL_ID, OLMOCR_2_7B_1025_OCR_TEMPLATE, + PADDLEOCR_VL_1_5_MODEL_ID, + PADDLEOCR_VL_1_5_OCR_TEMPLATE, ) @@ -207,6 +209,47 @@ def test_build_ocr_backend_aligns_templates_for_dots() -> None: } +def test_build_ocr_backend_aligns_templates_for_paddleocr_vl() -> None: + litellm_backend = cli_module._build_ocr_backend( + backend="litellm", + model=PADDLEOCR_VL_1_5_MODEL_ID, + endpoint=None, + api_key=None, + base_url=None, + api_version=None, + ) + hf_backend = cli_module._build_ocr_backend( + backend="hf", + model=PADDLEOCR_VL_1_5_MODEL_ID, + endpoint=None, + api_key=None, + base_url=None, + api_version=None, + ) + openai_backend = cli_module._build_ocr_backend( + backend="openai-compatible", + model=PADDLEOCR_VL_1_5_MODEL_ID, + endpoint=None, + api_key=None, + base_url="http://127.0.0.1:8000/v1", + api_version=None, + ) + + assert litellm_backend.template == PADDLEOCR_VL_1_5_OCR_TEMPLATE + assert litellm_backend.template == hf_backend.template == openai_backend.template + assert litellm_backend.model_name == "PaddleOCR-VL-1.5" + assert hf_backend.model_name == "PaddleOCR-VL-1.5" + assert openai_backend.model_name == "PaddleOCR-VL-1.5" + assert litellm_backend.transport.config.completion_kwargs == { + "max_tokens": 4_096, + "temperature": 0.0, + } + assert openai_backend.transport.config.completion_kwargs == { + "max_tokens": 4_096, + "temperature": 0.0, + } + + def test_build_ocr_backend_uses_generic_defaults_for_qwen_3_5_0_8b() -> None: litellm_backend = cli_module._build_ocr_backend( backend="litellm", diff --git a/tests/test_providers.py b/tests/test_providers.py index 7efba90..fc959a0 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -52,6 +52,9 @@ OLMOCR_2_7B_1025_FP8_MODEL_ID, OLMOCR_2_7B_1025_MODEL_ID, OLMOCR_2_7B_1025_OCR_TEMPLATE, + PADDLEOCR_VL_1_5_MODEL_ID, + PADDLEOCR_VL_1_5_OCR_PROMPT, + PADDLEOCR_VL_1_5_OCR_TEMPLATE, ) @@ -771,6 +774,26 @@ def test_build_ocr_backend_resolves_olmocr_fp8_profile_defaults_for_openai_compa } +def test_build_ocr_backend_uses_paddleocr_vl_profile_defaults_for_openai_compatible() -> None: + backend = cast( + "OpenAICompatibleOCRBackend", + build_ocr_backend( + OCRBackendSpec( + provider="openai-compatible", + model=PADDLEOCR_VL_1_5_MODEL_ID, + transport=LiteLLMTransportConfig(api_base="http://127.0.0.1:8000/v1"), + ) + ), + ) + + assert backend.template == PADDLEOCR_VL_1_5_OCR_TEMPLATE + assert backend.model_name == "PaddleOCR-VL-1.5" + assert backend.transport.config.completion_kwargs == { + "max_tokens": 4_096, + "temperature": 0.0, + } + + @pytest.mark.asyncio async def test_openai_compatible_backend_uses_olmocr_prompt_and_plain_text_postprocessing( monkeypatch: pytest.MonkeyPatch, @@ -959,6 +982,78 @@ async def _fake_complete_text( assert user_content[1] == {"type": "text", "text": CHANDRA_OCR_LAYOUT_PROMPT} +@pytest.mark.asyncio +async def test_openai_compatible_backend_uses_paddleocr_vl_prompt_and_strips_prompt_echo( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + + def _fake_prepare_messages_from_conversation( + self: LiteLLMTransport, + conversation: list[dict[str, object]], + ) -> list[dict[str, object]]: + captured["conversation"] = conversation + captured["completion_kwargs"] = dict(self.config.completion_kwargs) + return [{"role": "user", "content": [{"type": "text", "text": "prompt"}]}] + + async def _fake_complete_text( + self: LiteLLMTransport, + *, + model: str, + messages: list[dict[str, object]], + timeout_seconds: int = 600, + output_json: bool = False, + allow_empty: bool = False, + ) -> str: + captured["model"] = model + captured["messages"] = messages + captured["timeout_seconds"] = timeout_seconds + captured["output_json"] = output_json + captured["allow_empty"] = allow_empty + captured["completion_kwargs"] = dict(self.config.completion_kwargs) + return "OCR:\nassistant\npaddle transcription" + + monkeypatch.setattr( + LiteLLMTransport, + "prepare_messages_from_conversation", + _fake_prepare_messages_from_conversation, + ) + monkeypatch.setattr(LiteLLMTransport, "complete_text", _fake_complete_text) + + backend = cast( + "OpenAICompatibleOCRBackend", + build_ocr_backend( + OCRBackendSpec( + provider="openai-compatible", + model=PADDLEOCR_VL_1_5_MODEL_ID, + transport=LiteLLMTransportConfig(api_base="http://127.0.0.1:8000/v1"), + ) + ), + ) + result = await backend.ocr( + DocumentPage.from_image(Image.new("RGBA", (5_000, 3_000), color=(255, 255, 255, 255))) + ) + + assert result.text == "paddle transcription" + assert captured["model"] == f"openai/{PADDLEOCR_VL_1_5_MODEL_ID}" + assert captured["messages"] == [{"role": "user", "content": [{"type": "text", "text": "prompt"}]}] + assert captured["timeout_seconds"] == 600 + assert captured["output_json"] is False + assert captured["allow_empty"] is True + assert captured["completion_kwargs"] == { + "max_tokens": 4_096, + "temperature": 0.0, + } + conversation = cast("list[dict[str, object]]", captured["conversation"]) + assert conversation[0]["role"] == "user" + user_content = cast("list[dict[str, object]]", conversation[0]["content"]) + assert user_content[0]["type"] == "image" + prompt_image = cast("Image.Image", user_content[0]["image"]) + assert prompt_image.size == (2_500, 1_500) + assert prompt_image.mode == "RGB" + assert user_content[1] == {"type": "text", "text": PADDLEOCR_VL_1_5_OCR_PROMPT} + + @pytest.mark.asyncio async def test_llm_page_detector_uses_prompt_transport( monkeypatch: pytest.MonkeyPatch, From f837911809ca2756eefa7955439019eef9b55da5 Mon Sep 17 00:00:00 2001 From: Sina Date: Tue, 7 Apr 2026 01:40:44 +0000 Subject: [PATCH 22/28] test(hf): cover dots and Paddle OCR backends --- tests/test_hf_ocr.py | 548 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 548 insertions(+) diff --git a/tests/test_hf_ocr.py b/tests/test_hf_ocr.py index 1e7aedb..cfe5057 100644 --- a/tests/test_hf_ocr.py +++ b/tests/test_hf_ocr.py @@ -26,6 +26,7 @@ DotsOCR15OCRBackend, HuggingFaceVisionOCRBackend, LFM25VLOCRBackend, + PaddleOCRVL15OCRBackend, ) from churro_ocr.providers.specs import DEFAULT_OCR_MAX_TOKENS, lfm2_5_vl_text_postprocessor from churro_ocr.templates import ( @@ -39,6 +40,9 @@ LFM2_5_VL_1_6B_OCR_TEMPLATE, OLMOCR_2_7B_1025_MODEL_ID, OLMOCR_2_7B_1025_OCR_TEMPLATE, + PADDLEOCR_VL_1_5_MODEL_ID, + PADDLEOCR_VL_1_5_OCR_PROMPT, + PADDLEOCR_VL_1_5_OCR_TEMPLATE, HFChatTemplate, OCRConversation, ) @@ -209,6 +213,26 @@ def test_build_ocr_backend_uses_lfm25_profile_defaults_for_hf() -> None: } +def test_build_ocr_backend_uses_paddleocr_vl_profile_defaults_for_hf() -> None: + backend = cast( + "PaddleOCRVL15OCRBackend", + build_ocr_backend( + OCRBackendSpec( + provider="hf", + model=PADDLEOCR_VL_1_5_MODEL_ID, + ) + ), + ) + + assert isinstance(backend, PaddleOCRVL15OCRBackend) + assert backend.template == PADDLEOCR_VL_1_5_OCR_TEMPLATE + assert backend.model_name == "PaddleOCR-VL-1.5" + assert backend.generation_kwargs == { + "max_new_tokens": 4_096, + "do_sample": False, + } + + @pytest.mark.asyncio async def test_chandra_huggingface_backend_matches_upstream_chat_template_and_eos_behavior( monkeypatch: pytest.MonkeyPatch, @@ -951,6 +975,530 @@ def test_dots_ocr_15_backend_uses_expected_defaults() -> None: assert backend.generation_kwargs == {"max_new_tokens": DEFAULT_OCR_MAX_TOKENS} +@pytest.mark.asyncio +async def test_paddleocr_vl_15_backend_uses_tokenized_chat_template_and_profile_defaults( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, Any] = {} + + class FakeAttentionMask: + def sum(self, dim: int) -> SimpleNamespace: + captured["attention_mask_sum_dim"] = dim + return SimpleNamespace(tolist=lambda: [4]) + + class FakeBatch(dict[str, object]): + def to(self, device: object) -> FakeBatch: + captured["device"] = device + return self + + class FakeTokenizer: + def __init__(self) -> None: + self.padding_side = "right" + + class FakeProcessor: + def __init__(self) -> None: + self.tokenizer = FakeTokenizer() + self.image_processor = SimpleNamespace(min_pixels=112_896, max_pixels=1_003_520) + + def apply_chat_template( + self, + conversation: object, + *, + add_generation_prompt: bool, + tokenize: bool, + return_dict: bool | None = None, + return_tensors: str | None = None, + processor_kwargs: dict[str, object] | None = None, + ) -> object: + captured.setdefault("chat_calls", []).append( + { + "conversation": conversation, + "add_generation_prompt": add_generation_prompt, + "tokenize": tokenize, + "return_dict": return_dict, + "return_tensors": return_tensors, + "processor_kwargs": processor_kwargs, + } + ) + if not tokenize: + return "" + return FakeBatch( + { + "input_ids": SimpleNamespace(shape=(1, 4)), + "attention_mask": FakeAttentionMask(), + } + ) + + def __call__(self, **kwargs: object) -> object: + raise AssertionError("processor(...) should not be used for PaddleOCR-VL") + + def batch_decode( + self, + generated_ids: object, + *, + skip_special_tokens: bool, + clean_up_tokenization_spaces: bool, + ) -> list[str]: + captured["generated_ids"] = generated_ids + captured["skip_special_tokens"] = skip_special_tokens + captured["clean_up_tokenization_spaces"] = clean_up_tokenization_spaces + return ["OCR:\nassistant\npaddle transcription"] + + class FakeProcessorCls: + @staticmethod + def from_pretrained(model_id: str, **kwargs: object) -> FakeProcessor: + captured["processor_model_id"] = model_id + captured["processor_from_pretrained_kwargs"] = kwargs + return FakeProcessor() + + class FakeModel: + device = "fake-device" + dtype = None + + def eval(self) -> FakeModel: + captured["eval_called"] = True + return self + + def generate(self, **kwargs: object) -> list[list[int | str]]: + captured["generate_kwargs"] = kwargs + return [[0, 1, 2, 3, "completion"]] + + class FakeModelCls: + @staticmethod + def from_pretrained(model_id: str, **kwargs: object) -> FakeModel: + captured["model_model_id"] = model_id + captured["model_from_pretrained_kwargs"] = kwargs + return FakeModel() + + monkeypatch.setattr( + "churro_ocr.providers.hf._load_hf_runtime", + lambda: SimpleNamespace( + processor_cls=FakeProcessorCls, + model_cls=FakeModelCls, + process_vision_info=None, + ), + ) + + backend = cast( + "PaddleOCRVL15OCRBackend", + build_ocr_backend( + OCRBackendSpec( + provider="hf", + model=PADDLEOCR_VL_1_5_MODEL_ID, + ) + ), + ) + result = await backend.ocr(DocumentPage.from_image(Image.new("RGB", (32, 32), color="white"))) + + assert result.text == "paddle transcription" + assert captured["processor_model_id"] == PADDLEOCR_VL_1_5_MODEL_ID + assert captured["model_model_id"] == PADDLEOCR_VL_1_5_MODEL_ID + assert captured["eval_called"] is True + assert cast("FakeProcessor", backend._processor).tokenizer.padding_side == "left" + assert len(cast("list[dict[str, object]]", captured["chat_calls"])) == 2 + assert cast("list[dict[str, object]]", captured["chat_calls"])[0]["tokenize"] is False + assert cast("list[dict[str, object]]", captured["chat_calls"])[1]["tokenize"] is True + assert cast("list[dict[str, object]]", captured["chat_calls"])[1]["return_dict"] is True + assert cast("list[dict[str, object]]", captured["chat_calls"])[1]["return_tensors"] == "pt" + assert cast("list[dict[str, object]]", captured["chat_calls"])[1]["processor_kwargs"] == { + "text_kwargs": { + "padding": False, + "return_mm_token_type_ids": True, + }, + "images_kwargs": { + "min_pixels": 112_896, + "max_pixels": 1_003_520, + }, + } + render_conversation = cast("list[dict[str, object]]", captured["chat_calls"])[0]["conversation"] + assert cast("list[dict[str, object]]", render_conversation)[0]["role"] == "user" + render_content = cast( + "list[dict[str, object]]", + cast("list[dict[str, object]]", render_conversation)[0]["content"], + ) + assert render_content[0]["type"] == "image" + assert render_content[1] == {"type": "text", "text": PADDLEOCR_VL_1_5_OCR_PROMPT} + assert captured["device"] == "fake-device" + assert captured["attention_mask_sum_dim"] == 1 + assert captured["generate_kwargs"] == { + "input_ids": SimpleNamespace(shape=(1, 4)), + "attention_mask": cast("object", captured["generate_kwargs"]["attention_mask"]), + "max_new_tokens": 4_096, + "do_sample": False, + } + assert captured["generated_ids"] == [["completion"]] + assert captured["skip_special_tokens"] is True + assert captured["clean_up_tokenization_spaces"] is False + + +def test_paddleocr_vl_15_backend_uses_expected_defaults() -> None: + backend = PaddleOCRVL15OCRBackend() + + assert backend.model_id == PADDLEOCR_VL_1_5_MODEL_ID + assert backend.template == PADDLEOCR_VL_1_5_OCR_TEMPLATE + assert backend.trust_remote_code is False + assert backend.processor_kwargs == {} + assert backend.model_kwargs == {} + assert backend.generation_kwargs == { + "max_new_tokens": 4_096, + "do_sample": False, + } + + +def test_patch_dots_ocr_prepare_inputs_for_generation_handles_missing_cache_position() -> None: + captured: dict[str, object] = {} + + class BaseModel: + def prepare_inputs_for_generation( + self, + input_ids: object, + *, + past_key_values: object = None, + inputs_embeds: object = None, + pixel_values: object = None, + attention_mask: object = None, + cache_position: object = None, + num_logits_to_keep: object = None, + **kwargs: object, + ) -> dict[str, object]: + del pixel_values + captured["base_call"] = { + "input_ids": input_ids, + "past_key_values": past_key_values, + "inputs_embeds": inputs_embeds, + "attention_mask": attention_mask, + "cache_position": cache_position, + "num_logits_to_keep": num_logits_to_keep, + "kwargs": kwargs, + } + return { + "input_ids": input_ids, + "attention_mask": attention_mask, + } + + class FakeDotsModel(BaseModel): + pass + + model = FakeDotsModel() + + hf_module._patch_dots_ocr_prepare_inputs_for_generation(model) + first_inputs = model.prepare_inputs_for_generation( + "tokens", + pixel_values="pixels", + attention_mask="mask", + cache_position=None, + ) + later_inputs = model.prepare_inputs_for_generation( + "tokens", + pixel_values="pixels", + attention_mask="mask", + cache_position=[3], + ) + + assert first_inputs == { + "input_ids": "tokens", + "attention_mask": "mask", + "pixel_values": "pixels", + } + assert later_inputs == { + "input_ids": "tokens", + "attention_mask": "mask", + } + assert captured["base_call"] == { + "input_ids": "tokens", + "past_key_values": None, + "inputs_embeds": None, + "attention_mask": "mask", + "cache_position": [3], + "num_logits_to_keep": None, + "kwargs": {}, + } + + hf_module._patch_dots_ocr_prepare_inputs_for_generation(model) + assert cast("Any", model)._churro_dots_prepare_inputs_patched is True + + +def test_patch_dots_ocr_prepare_inputs_for_generation_skips_wrapped_dots_method() -> None: + captured: dict[str, object] = {} + + class BaseModel: + def prepare_inputs_for_generation( + self, + input_ids: object, + *, + past_key_values: object = None, + inputs_embeds: object = None, + pixel_values: object = None, + attention_mask: object = None, + cache_position: object = None, + num_logits_to_keep: object = None, + **kwargs: object, + ) -> dict[str, object]: + del pixel_values + captured["base_call"] = { + "input_ids": input_ids, + "past_key_values": past_key_values, + "inputs_embeds": inputs_embeds, + "attention_mask": attention_mask, + "cache_position": cache_position, + "num_logits_to_keep": num_logits_to_keep, + "kwargs": kwargs, + } + return { + "input_ids": input_ids, + "attention_mask": attention_mask, + } + + class FakeDotsModel(BaseModel): + def prepare_inputs_for_generation( + self, + input_ids: object, + *, + past_key_values: object = None, + inputs_embeds: object = None, + pixel_values: object = None, + attention_mask: object = None, + cache_position: object = None, + num_logits_to_keep: object = None, + **kwargs: object, + ) -> dict[str, object]: + if cast("Any", cache_position)[0] == 0: + return {"pixel_values": pixel_values} + return { + "input_ids": input_ids, + "attention_mask": attention_mask, + } + + class WrappedFakeDotsModel(FakeDotsModel): + pass + + model = WrappedFakeDotsModel() + + hf_module._patch_dots_ocr_prepare_inputs_for_generation(model) + inputs = model.prepare_inputs_for_generation( + "tokens", + pixel_values="pixels", + attention_mask="mask", + cache_position=None, + ) + + assert inputs == { + "input_ids": "tokens", + "attention_mask": "mask", + "pixel_values": "pixels", + } + assert captured["base_call"] == { + "input_ids": "tokens", + "past_key_values": None, + "inputs_embeds": None, + "attention_mask": "mask", + "cache_position": None, + "num_logits_to_keep": None, + "kwargs": {}, + } + + +def test_patch_dots_ocr_prepare_inputs_for_generation_skips_duplicate_wrapped_dots_methods() -> None: + captured: dict[str, object] = {} + + class BaseModel: + def prepare_inputs_for_generation( + self, + input_ids: object, + *, + past_key_values: object = None, + inputs_embeds: object = None, + pixel_values: object = None, + attention_mask: object = None, + cache_position: object = None, + num_logits_to_keep: object = None, + **kwargs: object, + ) -> dict[str, object]: + del pixel_values + captured["base_call"] = { + "input_ids": input_ids, + "past_key_values": past_key_values, + "inputs_embeds": inputs_embeds, + "attention_mask": attention_mask, + "cache_position": cache_position, + "num_logits_to_keep": num_logits_to_keep, + "kwargs": kwargs, + } + return { + "input_ids": input_ids, + "attention_mask": attention_mask, + } + + def shared_prepare_inputs_for_generation( + self: object, + input_ids: object, + *, + past_key_values: object = None, + inputs_embeds: object = None, + pixel_values: object = None, + attention_mask: object = None, + cache_position: object = None, + num_logits_to_keep: object = None, + **kwargs: object, + ) -> dict[str, object]: + del self, input_ids, past_key_values, inputs_embeds, pixel_values, attention_mask + del num_logits_to_keep, kwargs + if cast("Any", cache_position)[0] == 0: + return {"unexpected": True} + return {"unexpected": False} + + class FakeDotsOwner(BaseModel): + pass + + class WrappedFakeDotsModel(FakeDotsOwner): + pass + + cast("Any", FakeDotsOwner).prepare_inputs_for_generation = shared_prepare_inputs_for_generation + cast("Any", WrappedFakeDotsModel).prepare_inputs_for_generation = ( + shared_prepare_inputs_for_generation + ) + + model = WrappedFakeDotsModel() + + hf_module._patch_dots_ocr_prepare_inputs_for_generation(model) + inputs = model.prepare_inputs_for_generation( + "tokens", + pixel_values="pixels", + attention_mask="mask", + cache_position=None, + ) + + assert inputs == { + "input_ids": "tokens", + "attention_mask": "mask", + "pixel_values": "pixels", + } + assert captured["base_call"] == { + "input_ids": "tokens", + "past_key_values": None, + "inputs_embeds": None, + "attention_mask": "mask", + "cache_position": None, + "num_logits_to_keep": None, + "kwargs": {}, + } + + +def test_dots_ocr_15_backend_batch_strips_unused_mm_token_type_ids(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, Any] = {} + + class FakeAttentionMask: + def sum(self, dim: int) -> SimpleNamespace: + captured["attention_mask_sum_dim"] = dim + return SimpleNamespace(tolist=lambda: [4, 4]) + + class FakeBatch(dict[str, object]): + def to(self, device: object) -> FakeBatch: + captured["device"] = device + return self + + class FakeProcessor: + tokenizer = object() + + def apply_chat_template( + self, + conversation: list[dict[str, object]], + *, + add_generation_prompt: bool, + tokenize: bool, + ) -> str: + del add_generation_prompt, tokenize + image = cast(Image.Image, cast(list[dict[str, object]], conversation[0]["content"])[0]["image"]) + return f"" + + def __call__(self, **kwargs: object) -> FakeBatch: + captured["processor_kwargs"] = kwargs + return FakeBatch( + { + "input_ids": SimpleNamespace(shape=(2, 4)), + "attention_mask": FakeAttentionMask(), + "mm_token_type_ids": "ignored-mm-token-type-ids", + } + ) + + def batch_decode( + self, + generated_ids: object, + *, + skip_special_tokens: bool, + clean_up_tokenization_spaces: bool, + ) -> list[str]: + captured["generated_ids"] = generated_ids + captured["decode_kwargs"] = (skip_special_tokens, clean_up_tokenization_spaces) + return ["dots batch 1", "dots batch 2"] + + class FakeProcessorCls: + @staticmethod + def from_pretrained(model_id: str, **kwargs: object) -> FakeProcessor: + captured["processor_model_id"] = model_id + captured["processor_from_pretrained_kwargs"] = kwargs + return FakeProcessor() + + class FakeModel: + device = "fake-device" + + def generate(self, **kwargs: object) -> list[list[int]]: + captured["generate_kwargs"] = kwargs + return [ + [100, 101, 102, 103, 104, 105], + [200, 201, 202, 203, 204, 205], + ] + + class FakeModelCls: + @staticmethod + def from_pretrained(model_id: str, **kwargs: object) -> FakeModel: + captured["model_model_id"] = model_id + captured["model_from_pretrained_kwargs"] = kwargs + return FakeModel() + + def fake_process_vision_info( + conversation: list[dict[str, object]], + *, + return_video_kwargs: bool, + return_video_metadata: bool, + ) -> tuple[object, None, None]: + del conversation, return_video_kwargs, return_video_metadata + return "fake-image-inputs", None, None + + monkeypatch.setattr( + "churro_ocr.providers.hf._load_hf_causal_runtime", + lambda: SimpleNamespace( + processor_cls=FakeProcessorCls, + model_cls=FakeModelCls, + process_vision_info=fake_process_vision_info, + ), + ) + monkeypatch.setattr( + "churro_ocr.providers.hf._prepare_dots_ocr_model_dir", + lambda model_id: model_id, + ) + + backend = DotsOCR15OCRBackend() + results = backend._ocr_batch_sync( + [ + DocumentPage.from_image(Image.new("RGB", (32, 32), color="white")), + DocumentPage.from_image(Image.new("RGB", (64, 64), color="white")), + ] + ) + + assert [result.text for result in results] == ["dots batch 1", "dots batch 2"] + assert captured["processor_kwargs"]["text"] == ["", ""] + assert captured["processor_kwargs"]["images"] == [["fake-image-inputs"], ["fake-image-inputs"]] + assert captured["generate_kwargs"] == { + "input_ids": SimpleNamespace(shape=(2, 4)), + "attention_mask": cast("object", captured["generate_kwargs"]["attention_mask"]), + "max_new_tokens": DEFAULT_OCR_MAX_TOKENS, + } + assert captured["attention_mask_sum_dim"] == 1 + assert captured["generated_ids"] == [[104, 105], [204, 205]] + assert captured["decode_kwargs"] == (True, False) + + @pytest.mark.asyncio async def test_huggingface_vision_ocr_backend_strips_default_output_tags( monkeypatch: pytest.MonkeyPatch, From 89f6ae10d3859c3b6be34367a9e4d752eca0518f Mon Sep 17 00:00:00 2001 From: Sina Date: Tue, 7 Apr 2026 01:40:54 +0000 Subject: [PATCH 23/28] chore(benchmarking): tune benchmark runs and refresh scores --- benchmark_results.json | 18 +++++++ tests/test_tooling_benchmark.py | 81 +++++++++++++++++++++++++++++-- tooling/benchmarking/benchmark.py | 20 ++++++-- 3 files changed, 112 insertions(+), 7 deletions(-) diff --git a/benchmark_results.json b/benchmark_results.json index 21183cc..76278b2 100644 --- a/benchmark_results.json +++ b/benchmark_results.json @@ -239,6 +239,15 @@ "handwritten": 35.0806, "total": 47.7215 }, + { + "modelName": "PaddleOCR-VL 1.5", + "modelId": "PaddlePaddle/PaddleOCR-VL-1.5", + "modelUrl": "https://huggingface.co/PaddlePaddle/PaddleOCR-VL-1.5", + "iconPath": null, + "printed": 62.6, + "handwritten": 33.6, + "total": 47.0 + }, { "modelName": "O3", "modelId": "o3-2025-04-16", @@ -398,6 +407,15 @@ "handwritten": 15.2615, "total": 21.7585 }, + { + "modelName": "dots.ocr 1.5", + "modelId": "kristaller486/dots.ocr-1.5", + "modelUrl": "https://huggingface.co/kristaller486/dots.ocr-1.5", + "iconPath": null, + "printed": 22.2, + "handwritten": 10.4, + "total": 15.9 + }, { "modelName": "Phi 4 Multimodal", "modelId": "microsoft/Phi-4-multimodal-instruct", diff --git a/tests/test_tooling_benchmark.py b/tests/test_tooling_benchmark.py index 71ca46d..c6ed151 100644 --- a/tests/test_tooling_benchmark.py +++ b/tests/test_tooling_benchmark.py @@ -12,7 +12,11 @@ from churro_ocr.providers.hf import HuggingFaceVisionOCRBackend from churro_ocr.providers.ocr import LiteLLMVisionOCRBackend from churro_ocr.providers.specs import DEFAULT_OCR_MAX_TOKENS -from churro_ocr.templates import CHURRO_3B_XML_TEMPLATE, DOTS_OCR_1_5_OCR_TEMPLATE +from churro_ocr.templates import ( + CHURRO_3B_XML_TEMPLATE, + DOTS_OCR_1_5_OCR_TEMPLATE, + PADDLEOCR_VL_1_5_OCR_TEMPLATE, +) from tooling.benchmarking import benchmark from tooling.evaluation.types import BenchmarkDatasetExample @@ -271,6 +275,50 @@ def test_build_ocr_backend_uses_dots_preset_for_openai_compatible() -> None: } +def test_build_ocr_backend_uses_paddleocr_vl_preset_for_hf() -> None: + backend = cast( + "HuggingFaceVisionOCRBackend", + benchmark._build_ocr_backend( + benchmark.BenchmarkOptions( + backend="hf", + dataset_split="dev", + model="PaddlePaddle/PaddleOCR-VL-1.5", + ) + ), + ) + + assert backend.model_name == "PaddleOCR-VL-1.5" + assert backend.processor_kwargs == {} + assert backend.trust_remote_code is False + assert backend.model_kwargs == {"device_map": "auto", "torch_dtype": "auto"} + assert backend.generation_kwargs == { + "max_new_tokens": 4_096, + "do_sample": False, + } + + +def test_build_ocr_backend_uses_paddleocr_vl_preset_for_openai_compatible() -> None: + backend = cast( + "LiteLLMVisionOCRBackend", + benchmark._build_ocr_backend( + benchmark.BenchmarkOptions( + backend="openai-compatible", + dataset_split="dev", + model="PaddlePaddle/PaddleOCR-VL-1.5", + base_url="http://127.0.0.1:8000/v1", + ) + ), + ) + + assert backend.provider_name == "openai-compatible" + assert backend.model_name == "PaddleOCR-VL-1.5" + assert backend.template == PADDLEOCR_VL_1_5_OCR_TEMPLATE + assert backend.transport.config.completion_kwargs == { + "max_tokens": 4_096, + "temperature": 0.0, + } + + def test_build_ocr_backend_uses_churro_preset_template_for_openai_compatible() -> None: backend = cast( "LiteLLMVisionOCRBackend", @@ -539,10 +587,20 @@ async def test_predict_texts_updates_progress_and_preserves_order(monkeypatch) - ] class FakeProgressBar: - def __init__(self, *, total: int | None, desc: str, unit: str) -> None: + def __init__( + self, + *, + total: int | None, + desc: str, + unit: str, + mininterval: float, + smoothing: float, + ) -> None: self.total = total self.desc = desc self.unit = unit + self.mininterval = mininterval + self.smoothing = smoothing self.updates: list[int] = [] self.postfixes: list[dict[str, int]] = [] self.refresh_count = 0 @@ -570,8 +628,21 @@ def refresh(self) -> None: progress_bars: list[FakeProgressBar] = [] - def fake_tqdm(*, total: int | None, desc: str, unit: str) -> FakeProgressBar: - progress_bar = FakeProgressBar(total=total, desc=desc, unit=unit) + def fake_tqdm( + *, + total: int | None, + desc: str, + unit: str, + mininterval: float, + smoothing: float, + ) -> FakeProgressBar: + progress_bar = FakeProgressBar( + total=total, + desc=desc, + unit=unit, + mininterval=mininterval, + smoothing=smoothing, + ) progress_bars.append(progress_bar) return progress_bar @@ -612,6 +683,8 @@ async def ocr(self, page): # noqa: ANN001 assert progress_bars[0].total == 3 assert progress_bars[0].desc == "OCR" assert progress_bars[0].unit == "page" + assert progress_bars[0].mininterval == benchmark.PROGRESS_BAR_MININTERVAL_SECONDS + assert progress_bars[0].smoothing == benchmark.PROGRESS_BAR_SMOOTHING assert progress_bars[0].updates == [1, 1, 1] assert progress_bars[0].postfixes[-1] == { "submitted": 3, diff --git a/tooling/benchmarking/benchmark.py b/tooling/benchmarking/benchmark.py index 55f78c5..88ae9e5 100644 --- a/tooling/benchmarking/benchmark.py +++ b/tooling/benchmarking/benchmark.py @@ -51,6 +51,8 @@ CHURRO_DATASET_ID = "stanford-oval/churro-dataset" VALID_DATASET_SPLITS = {"dev", "test"} VALID_OCR_BACKENDS = {"litellm", "openai-compatible", "azure", "mistral", "hf"} +PROGRESS_BAR_SMOOTHING = 0.05 +PROGRESS_BAR_MININTERVAL_SECONDS = 1.0 BENCHMARK_DATASET_COLUMNS = ( "image", "cleaned_transcription", @@ -107,7 +109,7 @@ def build_parser(*, add_help: bool = True) -> argparse.ArgumentParser: parser.add_argument("--input-size", type=int, default=0) parser.add_argument("--offset", type=int, default=0) parser.add_argument("--output-dir", type=Path, default=None) - parser.add_argument("--max-concurrency", type=int, default=32) + parser.add_argument("--max-concurrency", type=int, default=16) parser.add_argument("--endpoint", default=None) parser.add_argument("--api-key", default=None) parser.add_argument("--base-url", default=None) @@ -198,10 +200,22 @@ def _default_litellm_cache_dir() -> Path: return Path(__file__).resolve().parents[2] / "workdir" / "cache" / "litellm" +def _create_progress_bar(*, total: int | None, desc: str, unit: str) -> tqdm[object]: + """Return a tqdm progress bar tuned for steadier ETA updates.""" + return tqdm( + total=total, + desc=desc, + unit=unit, + mininterval=PROGRESS_BAR_MININTERVAL_SECONDS, + smoothing=PROGRESS_BAR_SMOOTHING, + ) + + def _build_evaluation_example(example: BenchmarkDatasetExample) -> EvaluationExample: """Keep only the fields needed for evaluation after OCR completes.""" return to_evaluation_example(example) + def _selected_dataset_examples( dataset_stream: Iterable[BenchmarkDatasetExample], options: BenchmarkOptions, @@ -299,7 +313,7 @@ async def _predict_texts( predictions: list[BenchmarkPrediction] = [] submitted_pages = 0 - with tqdm(total=total_pages, desc="OCR", unit="page") as progress: + with _create_progress_bar(total=total_pages, desc="OCR", unit="page") as progress: while True: batch_examples: list[BenchmarkDatasetExample] = [] pages: list[DocumentPage] = [] @@ -373,7 +387,7 @@ def _progress_heartbeat(progress: tqdm[object], stop_event: threading.Event) -> while not stop_event.wait(wait_poll_seconds): _update_progress_status(progress, force_refresh=True) - with tqdm(total=total_pages, desc="OCR", unit="page") as progress: + with _create_progress_bar(total=total_pages, desc="OCR", unit="page") as progress: heartbeat_stop_event = threading.Event() heartbeat_thread = threading.Thread( target=_progress_heartbeat, From eb1fd19cc0041e3c4c6bb067780fcbf1798a6c54 Mon Sep 17 00:00:00 2001 From: Sina Date: Tue, 7 Apr 2026 03:31:54 +0000 Subject: [PATCH 24/28] feat(providers): add dots.mocr OCR support Register the built-in rednote-hilab/dots.mocr profile for both Hugging Face and openai-compatible backends, export the template preset, and cover the integration with provider, CLI, benchmark, and documentation updates. --- docs/guides/providers.md | 3 +- src/churro_ocr/providers/builder.py | 5 ++- src/churro_ocr/providers/hf.py | 12 +++++++ src/churro_ocr/providers/specs.py | 24 +++++++++++++ src/churro_ocr/templates/__init__.py | 6 ++++ src/churro_ocr/templates/presets.py | 9 +++++ tests/test_cli.py | 43 +++++++++++++++++++++++ tests/test_hf_ocr.py | 52 ++++++++++++++++++++++++++++ tests/test_providers.py | 21 +++++++++++ tests/test_tooling_benchmark.py | 46 ++++++++++++++++++++++++ 10 files changed, 219 insertions(+), 2 deletions(-) diff --git a/docs/guides/providers.md b/docs/guides/providers.md index 90afda1..7b8b555 100644 --- a/docs/guides/providers.md +++ b/docs/guides/providers.md @@ -145,7 +145,7 @@ backend = build_ocr_backend( ) ``` -Built-in model-specific profiles are resolved automatically for known models such as `stanford-oval/churro-3B`, `datalab-to/chandra-ocr-2`, `kristaller486/dots.ocr-1.5`, and the supported `olmOCR` checkpoints. +Built-in model-specific profiles are resolved automatically for known models such as `stanford-oval/churro-3B`, `datalab-to/chandra-ocr-2`, `kristaller486/dots.ocr-1.5`, `rednote-hilab/dots.mocr`, and the supported `olmOCR` checkpoints. ## `OCRBackendSpec` Reference @@ -209,6 +209,7 @@ Useful public template exports: | `CHURRO_3B_XML_TEMPLATE` | `churro_ocr.templates` | Built-in template for `stanford-oval/churro-3B`. | | `CHANDRA_OCR_2_OCR_TEMPLATE` | `churro_ocr.templates` | Built-in template for `datalab-to/chandra-ocr-2`. | | `DOTS_OCR_1_5_OCR_TEMPLATE` | `churro_ocr.templates` | Built-in template for `kristaller486/dots.ocr-1.5`. | +| `DOTS_MOCR_OCR_TEMPLATE` | `churro_ocr.templates` | Built-in template for `rednote-hilab/dots.mocr`. | | `OCRPromptTemplate` | `churro_ocr.templates` | Base protocol for custom profile integration. | Useful public prompt exports: diff --git a/src/churro_ocr/providers/builder.py b/src/churro_ocr/providers/builder.py index afd70f6..b670364 100644 --- a/src/churro_ocr/providers/builder.py +++ b/src/churro_ocr/providers/builder.py @@ -7,6 +7,7 @@ from churro_ocr.ocr import OCRBackend from churro_ocr.providers.hf import ( ChandraOCR2OCRBackend, + DotsMOCROCRBackend, DotsOCR15OCRBackend, HuggingFaceVisionOCRBackend, LFM25VLOCRBackend, @@ -164,8 +165,10 @@ def _build_huggingface_backend(spec: OCRBackendSpec, profile: OCRModelProfile) - ) backend_cls: type[HuggingFaceVisionOCRBackend] = HuggingFaceVisionOCRBackend model_kwargs = dict(options.model_kwargs) - if options.backend_variant == "dots-ocr-1.5": + if options.backend_variant in {"dots-ocr-1.5", "dots-mocr"}: backend_cls = DotsOCR15OCRBackend + if options.backend_variant == "dots-mocr": + backend_cls = DotsMOCROCRBackend model_kwargs = _merge_mapping(_default_dots_ocr_1_5_model_kwargs(), model_kwargs) elif options.backend_variant == "chandra-ocr-2": backend_cls = ChandraOCR2OCRBackend diff --git a/src/churro_ocr/providers/hf.py b/src/churro_ocr/providers/hf.py index 98f9b91..c4fdf1f 100644 --- a/src/churro_ocr/providers/hf.py +++ b/src/churro_ocr/providers/hf.py @@ -34,6 +34,8 @@ CHANDRA_OCR_2_OCR_TEMPLATE, CHURRO_3B_MODEL_ID, CHURRO_3B_XML_TEMPLATE, + DOTS_MOCR_MODEL_ID, + DOTS_MOCR_OCR_TEMPLATE, DOTS_OCR_1_5_MODEL_ID, DOTS_OCR_1_5_OCR_TEMPLATE, LFM2_5_VL_1_6B_MODEL_ID, @@ -756,6 +758,15 @@ def _get_model(self, runtime: _HFRuntime) -> Any: return self._model +@dataclass(slots=True) +class DotsMOCROCRBackend(DotsOCR15OCRBackend): + """Preset OCR backend for ``rednote-hilab/dots.mocr``.""" + + model_id: str = DOTS_MOCR_MODEL_ID + template: OCRPromptTemplateLike = DOTS_MOCR_OCR_TEMPLATE + model_name: str | None = "dots.mocr" + + @dataclass(slots=True) class PaddleOCRVL15OCRBackend(HuggingFaceVisionOCRBackend): """Preset OCR backend for ``PaddlePaddle/PaddleOCR-VL-1.5``.""" @@ -1013,6 +1024,7 @@ def _ocr_batch_sync(self, pages: list[DocumentPage]) -> list[OCRResult]: __all__ = [ "ChandraOCR2OCRBackend", "Churro3BOCRBackend", + "DotsMOCROCRBackend", "DotsOCR15OCRBackend", "HuggingFaceVisionOCRBackend", "LFM25VLOCRBackend", diff --git a/src/churro_ocr/providers/specs.py b/src/churro_ocr/providers/specs.py index ab0f699..27f64b4 100644 --- a/src/churro_ocr/providers/specs.py +++ b/src/churro_ocr/providers/specs.py @@ -24,6 +24,8 @@ CHURRO_3B_MODEL_ID, CHURRO_3B_XML_TEMPLATE, DEFAULT_OCR_TEMPLATE, + DOTS_MOCR_MODEL_ID, + DOTS_MOCR_OCR_TEMPLATE, DOTS_OCR_1_5_MODEL_ID, DOTS_OCR_1_5_OCR_TEMPLATE, LFM2_5_VL_1_6B_MODEL_ID, @@ -416,6 +418,26 @@ def dots_ocr_1_5_profile() -> OCRModelProfile: ) +def dots_mocr_profile() -> OCRModelProfile: + """Return the built-in ``rednote-hilab/dots.mocr`` OCR profile.""" + return OCRModelProfile( + profile_name=DOTS_MOCR_MODEL_ID, + template=DOTS_MOCR_OCR_TEMPLATE, + text_postprocessor=identity_text_postprocessor, + display_name="dots.mocr", + transport=LiteLLMTransportConfig( + completion_kwargs={ + "max_tokens": DEFAULT_OCR_MAX_TOKENS, + "temperature": 0.0, + } + ), + huggingface=HuggingFaceOptions( + trust_remote_code=True, + backend_variant="dots-mocr", + ), + ) + + def paddleocr_vl_1_5_profile() -> OCRModelProfile: """Return the built-in ``PaddlePaddle/PaddleOCR-VL-1.5`` OCR profile.""" return OCRModelProfile( @@ -500,6 +522,7 @@ def _profile_registry() -> dict[str, OCRModelProfile]: default_profile = default_ocr_profile() churro_profile = churro_3b_profile() chandra_profile = chandra_ocr_2_profile() + dots_mocr = dots_mocr_profile() dots_profile = dots_ocr_1_5_profile() lfm2_5_vl_profile = lfm2_5_vl_1_6b_profile() olmocr_profile = olmocr_2_7b_1025_profile() @@ -509,6 +532,7 @@ def _profile_registry() -> dict[str, OCRModelProfile]: default_profile.profile_name: default_profile, churro_profile.profile_name: churro_profile, chandra_profile.profile_name: chandra_profile, + dots_mocr.profile_name: dots_mocr, dots_profile.profile_name: dots_profile, lfm2_5_vl_profile.profile_name: lfm2_5_vl_profile, olmocr_profile.profile_name: olmocr_profile, diff --git a/src/churro_ocr/templates/__init__.py b/src/churro_ocr/templates/__init__.py index 4324315..b9e9e18 100644 --- a/src/churro_ocr/templates/__init__.py +++ b/src/churro_ocr/templates/__init__.py @@ -14,6 +14,9 @@ CHURRO_3B_MODEL_ID, CHURRO_3B_XML_TEMPLATE, DEFAULT_OCR_TEMPLATE, + DOTS_MOCR_MODEL_ID, + DOTS_MOCR_OCR_PROMPT, + DOTS_MOCR_OCR_TEMPLATE, DOTS_OCR_1_5_MODEL_ID, DOTS_OCR_1_5_OCR_PROMPT, DOTS_OCR_1_5_OCR_TEMPLATE, @@ -34,6 +37,9 @@ "CHANDRA_OCR_2_MODEL_ID", "CHANDRA_OCR_2_OCR_TEMPLATE", "DEFAULT_OCR_TEMPLATE", + "DOTS_MOCR_MODEL_ID", + "DOTS_MOCR_OCR_PROMPT", + "DOTS_MOCR_OCR_TEMPLATE", "DOTS_OCR_1_5_MODEL_ID", "DOTS_OCR_1_5_OCR_PROMPT", "DOTS_OCR_1_5_OCR_TEMPLATE", diff --git a/src/churro_ocr/templates/presets.py b/src/churro_ocr/templates/presets.py index 3a549af..300a65f 100644 --- a/src/churro_ocr/templates/presets.py +++ b/src/churro_ocr/templates/presets.py @@ -13,6 +13,7 @@ CHURRO_3B_MODEL_ID = "stanford-oval/churro-3B" CHANDRA_OCR_2_MODEL_ID = "datalab-to/chandra-ocr-2" DOTS_OCR_1_5_MODEL_ID = "kristaller486/dots.ocr-1.5" +DOTS_MOCR_MODEL_ID = "rednote-hilab/dots.mocr" PADDLEOCR_VL_1_5_MODEL_ID = "PaddlePaddle/PaddleOCR-VL-1.5" OLMOCR_2_7B_1025_MODEL_ID = "allenai/olmOCR-2-7B-1025" OLMOCR_2_7B_1025_FP8_MODEL_ID = "allenai/olmOCR-2-7B-1025-FP8" @@ -35,6 +36,11 @@ system_message=None, user_prompt=DOTS_OCR_1_5_OCR_PROMPT, ) +DOTS_MOCR_OCR_PROMPT = DOTS_OCR_1_5_OCR_PROMPT +DOTS_MOCR_OCR_TEMPLATE = HFChatTemplate( + system_message=None, + user_prompt=DOTS_MOCR_OCR_PROMPT, +) PADDLEOCR_VL_1_5_OCR_PROMPT = "OCR:" PADDLEOCR_VL_1_5_OCR_TEMPLATE = HFChatTemplate( system_message=None, @@ -57,6 +63,9 @@ "CHANDRA_OCR_2_MODEL_ID", "CHANDRA_OCR_2_OCR_TEMPLATE", "DEFAULT_OCR_TEMPLATE", + "DOTS_MOCR_MODEL_ID", + "DOTS_MOCR_OCR_PROMPT", + "DOTS_MOCR_OCR_TEMPLATE", "DOTS_OCR_1_5_MODEL_ID", "DOTS_OCR_1_5_OCR_PROMPT", "DOTS_OCR_1_5_OCR_TEMPLATE", diff --git a/tests/test_cli.py b/tests/test_cli.py index 237c32d..875b8ad 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -16,10 +16,12 @@ from churro_ocr.ocr import OCRResult from churro_ocr.page_detection import DocumentPage, PageDetectionResult from churro_ocr.prompts import DEFAULT_OCR_OUTPUT_TAG +from churro_ocr.providers.specs import DEFAULT_OCR_MAX_TOKENS from churro_ocr.templates import ( CHANDRA_OCR_2_MODEL_ID, CHANDRA_OCR_2_OCR_TEMPLATE, DEFAULT_OCR_TEMPLATE, + DOTS_MOCR_OCR_TEMPLATE, DOTS_OCR_1_5_OCR_TEMPLATE, OLMOCR_2_7B_1025_MODEL_ID, OLMOCR_2_7B_1025_OCR_TEMPLATE, @@ -209,6 +211,47 @@ def test_build_ocr_backend_aligns_templates_for_dots() -> None: } +def test_build_ocr_backend_aligns_templates_for_dots_mocr() -> None: + litellm_backend = cli_module._build_ocr_backend( + backend="litellm", + model="rednote-hilab/dots.mocr", + endpoint=None, + api_key=None, + base_url=None, + api_version=None, + ) + hf_backend = cli_module._build_ocr_backend( + backend="hf", + model="rednote-hilab/dots.mocr", + endpoint=None, + api_key=None, + base_url=None, + api_version=None, + ) + openai_backend = cli_module._build_ocr_backend( + backend="openai-compatible", + model="rednote-hilab/dots.mocr", + endpoint=None, + api_key=None, + base_url="http://127.0.0.1:8000/v1", + api_version=None, + ) + + assert litellm_backend.template == DOTS_MOCR_OCR_TEMPLATE + assert litellm_backend.template == hf_backend.template == openai_backend.template + assert litellm_backend.model_name == "dots.mocr" + assert hf_backend.model_name == "dots.mocr" + assert openai_backend.model_name == "dots.mocr" + assert litellm_backend.transport.config.completion_kwargs == { + "max_tokens": DEFAULT_OCR_MAX_TOKENS, + "temperature": 0.0, + } + assert openai_backend.transport.config.completion_kwargs == { + "max_tokens": DEFAULT_OCR_MAX_TOKENS, + "temperature": 0.0, + } + + def test_build_ocr_backend_aligns_templates_for_paddleocr_vl() -> None: litellm_backend = cli_module._build_ocr_backend( backend="litellm", diff --git a/tests/test_hf_ocr.py b/tests/test_hf_ocr.py index cfe5057..855e0c3 100644 --- a/tests/test_hf_ocr.py +++ b/tests/test_hf_ocr.py @@ -23,6 +23,7 @@ from churro_ocr.providers.hf import ( ChandraOCR2OCRBackend, Churro3BOCRBackend, + DotsMOCROCRBackend, DotsOCR15OCRBackend, HuggingFaceVisionOCRBackend, LFM25VLOCRBackend, @@ -33,6 +34,9 @@ CHANDRA_OCR_2_MODEL_ID, CHANDRA_OCR_2_OCR_TEMPLATE, CHURRO_3B_XML_TEMPLATE, + DOTS_MOCR_MODEL_ID, + DOTS_MOCR_OCR_PROMPT, + DOTS_MOCR_OCR_TEMPLATE, DOTS_OCR_1_5_MODEL_ID, DOTS_OCR_1_5_OCR_PROMPT, DOTS_OCR_1_5_OCR_TEMPLATE, @@ -233,6 +237,29 @@ def test_build_ocr_backend_uses_paddleocr_vl_profile_defaults_for_hf() -> None: } +def test_build_ocr_backend_uses_dots_mocr_profile_defaults_for_hf() -> None: + backend = cast( + "DotsMOCROCRBackend", + build_ocr_backend( + OCRBackendSpec( + provider="hf", + model=DOTS_MOCR_MODEL_ID, + ) + ), + ) + + assert isinstance(backend, DotsMOCROCRBackend) + assert backend.template == DOTS_MOCR_OCR_TEMPLATE + assert backend.model_name == "dots.mocr" + assert backend.generation_kwargs == {"max_new_tokens": DEFAULT_OCR_MAX_TOKENS} + assert backend.trust_remote_code is True + assert backend.processor_kwargs == {} + assert backend.model_kwargs["dtype"] in {"auto", "float32"} + if backend.model_kwargs["dtype"] == "auto" and "device_map" in backend.model_kwargs: + assert backend.model_kwargs["device_map"] == "auto" + assert "max_memory" in backend.model_kwargs + + @pytest.mark.asyncio async def test_chandra_huggingface_backend_matches_upstream_chat_template_and_eos_behavior( monkeypatch: pytest.MonkeyPatch, @@ -975,6 +1002,31 @@ def test_dots_ocr_15_backend_uses_expected_defaults() -> None: assert backend.generation_kwargs == {"max_new_tokens": DEFAULT_OCR_MAX_TOKENS} +def test_dots_mocr_backend_uses_expected_defaults() -> None: + backend = DotsMOCROCRBackend() + + assert backend.model_id == DOTS_MOCR_MODEL_ID + assert backend.template == DOTS_MOCR_OCR_TEMPLATE + assert backend.model_name == "dots.mocr" + assert backend.trust_remote_code is True + assert backend.processor_kwargs == {} + assert backend.model_kwargs["dtype"] in {"auto", "float32"} + if backend.model_kwargs["dtype"] == "auto" and "device_map" in backend.model_kwargs: + assert backend.model_kwargs["device_map"] == "auto" + assert "max_memory" in backend.model_kwargs + assert backend.generation_kwargs == {"max_new_tokens": DEFAULT_OCR_MAX_TOKENS} + + +def test_dots_mocr_template_matches_upstream_prompt() -> None: + page = DocumentPage.from_image(Image.new("RGB", (20, 20), color="white")) + + conversation = DOTS_MOCR_OCR_TEMPLATE.build_conversation(page) + + assert conversation[0]["role"] == "user" + assert conversation[0]["content"][0]["type"] == "image" + assert conversation[0]["content"][1]["text"] == DOTS_MOCR_OCR_PROMPT + + @pytest.mark.asyncio async def test_paddleocr_vl_15_backend_uses_tokenized_chat_template_and_profile_defaults( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_providers.py b/tests/test_providers.py index fc959a0..c123543 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -49,6 +49,7 @@ CHANDRA_OCR_2_MODEL_ID, CHANDRA_OCR_2_OCR_TEMPLATE, DEFAULT_OCR_TEMPLATE, + DOTS_MOCR_OCR_TEMPLATE, OLMOCR_2_7B_1025_FP8_MODEL_ID, OLMOCR_2_7B_1025_MODEL_ID, OLMOCR_2_7B_1025_OCR_TEMPLATE, @@ -794,6 +795,26 @@ def test_build_ocr_backend_uses_paddleocr_vl_profile_defaults_for_openai_compati } +def test_build_ocr_backend_uses_dots_mocr_profile_defaults_for_openai_compatible() -> None: + backend = cast( + "OpenAICompatibleOCRBackend", + build_ocr_backend( + OCRBackendSpec( + provider="openai-compatible", + model="rednote-hilab/dots.mocr", + transport=LiteLLMTransportConfig(api_base="http://127.0.0.1:8000/v1"), + ) + ), + ) + + assert backend.template == DOTS_MOCR_OCR_TEMPLATE + assert backend.model_name == "dots.mocr" + assert backend.transport.config.completion_kwargs == { + "max_tokens": DEFAULT_OCR_MAX_TOKENS, + "temperature": 0.0, + } + + @pytest.mark.asyncio async def test_openai_compatible_backend_uses_olmocr_prompt_and_plain_text_postprocessing( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_tooling_benchmark.py b/tests/test_tooling_benchmark.py index c6ed151..c5817b5 100644 --- a/tests/test_tooling_benchmark.py +++ b/tests/test_tooling_benchmark.py @@ -14,6 +14,7 @@ from churro_ocr.providers.specs import DEFAULT_OCR_MAX_TOKENS from churro_ocr.templates import ( CHURRO_3B_XML_TEMPLATE, + DOTS_MOCR_OCR_TEMPLATE, DOTS_OCR_1_5_OCR_TEMPLATE, PADDLEOCR_VL_1_5_OCR_TEMPLATE, ) @@ -275,6 +276,51 @@ def test_build_ocr_backend_uses_dots_preset_for_openai_compatible() -> None: } +def test_build_ocr_backend_uses_dots_mocr_preset_for_hf() -> None: + backend = cast( + "HuggingFaceVisionOCRBackend", + benchmark._build_ocr_backend( + benchmark.BenchmarkOptions( + backend="hf", + dataset_split="dev", + model="rednote-hilab/dots.mocr", + ) + ), + ) + + assert backend.model_name == "dots.mocr" + assert backend.processor_kwargs == {} + assert backend.trust_remote_code is True + assert backend.model_kwargs["dtype"] in {"auto", "float32"} + if backend.model_kwargs["dtype"] == "auto" and "device_map" in backend.model_kwargs: + assert backend.model_kwargs["device_map"] == "auto" + if "max_memory" in backend.model_kwargs: + assert backend.model_kwargs["max_memory"] + assert backend.generation_kwargs == {"max_new_tokens": DEFAULT_OCR_MAX_TOKENS} + + +def test_build_ocr_backend_uses_dots_mocr_preset_for_openai_compatible() -> None: + backend = cast( + "LiteLLMVisionOCRBackend", + benchmark._build_ocr_backend( + benchmark.BenchmarkOptions( + backend="openai-compatible", + dataset_split="dev", + model="rednote-hilab/dots.mocr", + base_url="http://127.0.0.1:8000/v1", + ) + ), + ) + + assert backend.provider_name == "openai-compatible" + assert backend.model_name == "dots.mocr" + assert backend.template == DOTS_MOCR_OCR_TEMPLATE + assert backend.transport.config.completion_kwargs == { + "max_tokens": DEFAULT_OCR_MAX_TOKENS, + "temperature": 0.0, + } + + def test_build_ocr_backend_uses_paddleocr_vl_preset_for_hf() -> None: backend = cast( "HuggingFaceVisionOCRBackend", From 03682e8cbe4c96470fd3c1aec90d34b1e52c751a Mon Sep 17 00:00:00 2001 From: Sina Date: Tue, 7 Apr 2026 03:32:00 +0000 Subject: [PATCH 25/28] chore(benchmark): add dots.mocr test results Record the CHURRO test-set vLLM benchmark entry for rednote-hilab/dots.mocr in benchmark_results.json. --- benchmark_results.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/benchmark_results.json b/benchmark_results.json index 76278b2..dd35a45 100644 --- a/benchmark_results.json +++ b/benchmark_results.json @@ -35,6 +35,15 @@ "handwritten": 63.6329, "total": 71.5803 }, + { + "modelName": "dots.mocr", + "modelId": "rednote-hilab/dots.mocr", + "modelUrl": "https://huggingface.co/rednote-hilab/dots.mocr", + "iconPath": null, + "printed": 81.2, + "handwritten": 55.0, + "total": 67.1 + }, { "modelName": "Gemini 2.5 Flash", "modelId": "gemini-2.5-flash", From cdf37de231fa6112725705c8fa86d65674f32323 Mon Sep 17 00:00:00 2001 From: Sina Date: Tue, 7 Apr 2026 06:25:27 +0000 Subject: [PATCH 26/28] feat(providers): add DeepSeek OCR 2 support --- docs/guides/providers.md | 3 +- src/churro_ocr/providers/builder.py | 3 + src/churro_ocr/providers/hf.py | 193 ++++++++++++++++++++++++++- src/churro_ocr/providers/specs.py | 59 ++++++++ src/churro_ocr/templates/__init__.py | 6 + src/churro_ocr/templates/presets.py | 9 ++ tests/test_cli.py | 43 ++++++ tests/test_hf_ocr.py | 167 ++++++++++++++++++++++- tests/test_providers.py | 84 ++++++++++++ tests/test_tooling_benchmark.py | 46 +++++++ 10 files changed, 610 insertions(+), 3 deletions(-) diff --git a/docs/guides/providers.md b/docs/guides/providers.md index 7b8b555..1b061bb 100644 --- a/docs/guides/providers.md +++ b/docs/guides/providers.md @@ -145,7 +145,7 @@ backend = build_ocr_backend( ) ``` -Built-in model-specific profiles are resolved automatically for known models such as `stanford-oval/churro-3B`, `datalab-to/chandra-ocr-2`, `kristaller486/dots.ocr-1.5`, `rednote-hilab/dots.mocr`, and the supported `olmOCR` checkpoints. +Built-in model-specific profiles are resolved automatically for known models such as `stanford-oval/churro-3B`, `datalab-to/chandra-ocr-2`, `deepseek-ai/DeepSeek-OCR-2`, `kristaller486/dots.ocr-1.5`, `rednote-hilab/dots.mocr`, and the supported `olmOCR` checkpoints. ## `OCRBackendSpec` Reference @@ -208,6 +208,7 @@ Useful public template exports: | `DEFAULT_OCR_TEMPLATE` | `churro_ocr.templates` | Generic OCR prompt template used by the default model profile. | | `CHURRO_3B_XML_TEMPLATE` | `churro_ocr.templates` | Built-in template for `stanford-oval/churro-3B`. | | `CHANDRA_OCR_2_OCR_TEMPLATE` | `churro_ocr.templates` | Built-in template for `datalab-to/chandra-ocr-2`. | +| `DEEPSEEK_OCR_2_OCR_TEMPLATE` | `churro_ocr.templates` | Built-in template for `deepseek-ai/DeepSeek-OCR-2`. | | `DOTS_OCR_1_5_OCR_TEMPLATE` | `churro_ocr.templates` | Built-in template for `kristaller486/dots.ocr-1.5`. | | `DOTS_MOCR_OCR_TEMPLATE` | `churro_ocr.templates` | Built-in template for `rednote-hilab/dots.mocr`. | | `OCRPromptTemplate` | `churro_ocr.templates` | Base protocol for custom profile integration. | diff --git a/src/churro_ocr/providers/builder.py b/src/churro_ocr/providers/builder.py index b670364..09975ed 100644 --- a/src/churro_ocr/providers/builder.py +++ b/src/churro_ocr/providers/builder.py @@ -7,6 +7,7 @@ from churro_ocr.ocr import OCRBackend from churro_ocr.providers.hf import ( ChandraOCR2OCRBackend, + DeepSeekOCR2OCRBackend, DotsMOCROCRBackend, DotsOCR15OCRBackend, HuggingFaceVisionOCRBackend, @@ -170,6 +171,8 @@ def _build_huggingface_backend(spec: OCRBackendSpec, profile: OCRModelProfile) - if options.backend_variant == "dots-mocr": backend_cls = DotsMOCROCRBackend model_kwargs = _merge_mapping(_default_dots_ocr_1_5_model_kwargs(), model_kwargs) + elif options.backend_variant == "deepseek-ocr-2": + backend_cls = DeepSeekOCR2OCRBackend elif options.backend_variant == "chandra-ocr-2": backend_cls = ChandraOCR2OCRBackend elif options.backend_variant == "paddleocr-vl-1.5": diff --git a/src/churro_ocr/providers/hf.py b/src/churro_ocr/providers/hf.py index c4fdf1f..567f291 100644 --- a/src/churro_ocr/providers/hf.py +++ b/src/churro_ocr/providers/hf.py @@ -7,12 +7,13 @@ from dataclasses import dataclass, field from importlib import import_module from pathlib import Path +from tempfile import TemporaryDirectory from types import MethodType from typing import Any, cast from churro_ocr._internal.install import install_command_hint from churro_ocr._internal.prompt_logging import log_prompt_payload_once -from churro_ocr.errors import ConfigurationError +from churro_ocr.errors import ConfigurationError, ProviderError from churro_ocr.ocr import OCRBackend, OCRResult from churro_ocr.page_detection import DocumentPage from churro_ocr.providers._shared import ( @@ -34,6 +35,8 @@ CHANDRA_OCR_2_OCR_TEMPLATE, CHURRO_3B_MODEL_ID, CHURRO_3B_XML_TEMPLATE, + DEEPSEEK_OCR_2_MODEL_ID, + DEEPSEEK_OCR_2_OCR_TEMPLATE, DOTS_MOCR_MODEL_ID, DOTS_MOCR_OCR_TEMPLATE, DOTS_OCR_1_5_MODEL_ID, @@ -44,6 +47,7 @@ PADDLEOCR_VL_1_5_OCR_TEMPLATE, OCRConversation, OCRPromptTemplateLike, + build_ocr_conversation, ) _HF_EXTRA_INSTALL_HINT = install_command_hint("hf") @@ -100,6 +104,33 @@ def _load_hf_causal_runtime() -> _HFRuntime: ) +def _load_hf_auto_model_runtime() -> _HFRuntime: + _ensure_hf_torch_runtime() + try: + from transformers import AutoModel, AutoTokenizer + except ImportError as exc: # pragma: no cover - optional extra path + raise ConfigurationError( + f"Hugging Face OCR requires the `hf` runtime. {_HF_EXTRA_INSTALL_HINT}" + ) from exc + + return _HFRuntime( + processor_cls=AutoTokenizer, + model_cls=AutoModel, + process_vision_info=None, + ) + + +def _ensure_deepseek_ocr_2_cuda_runtime() -> Any: + _ensure_hf_torch_runtime() + torch = import_module("torch") + if not torch.cuda.is_available(): + raise ConfigurationError( + "DeepSeek-OCR-2 HF backend requires a CUDA-capable PyTorch runtime because " + "the upstream `infer(...)` implementation moves inputs to CUDA." + ) + return torch + + _DOTS_OCR_1_5_LOCAL_DIRNAME = "DotsOCR_1_5" _DOTS_FLASH_ATTN_IMPORT = "from flash_attn import flash_attn_varlen_func" _DOTS_FLASH_ATTN_FALLBACK = """try: @@ -263,6 +294,39 @@ def _default_chandra_ocr_2_model_kwargs() -> dict[str, object]: return model_kwargs +def _deepseek_ocr_2_prompt_from_conversation(conversation: OCRConversation) -> str: + prompt_lines: list[str] = [] + has_image = False + for message in conversation: + if message.get("role") == "system": + content_items = cast("list[dict[str, object]]", message["content"]) + system_text = "\n".join( + cast("str", item["text"]).strip() + for item in content_items + if item.get("type") == "text" and isinstance(item.get("text"), str) + ).strip() + if system_text: + raise ConfigurationError("DeepSeek-OCR-2 does not support system prompts in the HF backend.") + continue + if message.get("role") != "user": + continue + content_items = cast("list[dict[str, object]]", message["content"]) + for item in content_items: + if item.get("type") == "image": + has_image = True + continue + if item.get("type") == "text" and isinstance(item.get("text"), str): + text = cast("str", item["text"]).strip() + if text: + prompt_lines.append(text) + prompt_text = "\n".join(prompt_lines).strip() + if not prompt_text: + raise ConfigurationError("DeepSeek-OCR-2 requires a non-empty OCR prompt.") + if has_image: + return f"\n{prompt_text}" + return prompt_text + + def _move_batch_to_model(batch: Any, model: Any) -> Any: model_device = getattr(model, "device", None) if hasattr(batch, "to") and model_device is not None: @@ -707,6 +771,132 @@ class Churro3BOCRBackend(HuggingFaceVisionOCRBackend): model_name: str | None = "churro-3B" +@dataclass(slots=True) +class DeepSeekOCR2OCRBackend(HuggingFaceVisionOCRBackend): + """Preset OCR backend for ``deepseek-ai/DeepSeek-OCR-2``.""" + + model_id: str = DEEPSEEK_OCR_2_MODEL_ID + template: OCRPromptTemplateLike = DEEPSEEK_OCR_2_OCR_TEMPLATE + model_name: str | None = "DeepSeek-OCR-2" + trust_remote_code: bool = True + model_kwargs: dict[str, object] = field(default_factory=lambda: {"use_safetensors": True}) + generation_kwargs: dict[str, object] = field(default_factory=lambda: {"max_new_tokens": 8_192}) + base_size: int = 1_024 + image_size: int = 768 + crop_mode: bool = True + + def _load_runtime(self) -> _HFRuntime: + return _load_hf_auto_model_runtime() + + def _get_model(self, runtime: _HFRuntime) -> Any: + torch = _ensure_deepseek_ocr_2_cuda_runtime() + if self._model is None: + with self._init_lock: + if self._model is None: + model_kwargs = dict(self.model_kwargs) + model = runtime.model_cls.from_pretrained( + self._get_model_source(), + trust_remote_code=self.trust_remote_code, + **model_kwargs, + ) + eval_method = getattr(model, "eval", None) + if callable(eval_method): + model = eval_method() + cuda_method = getattr(model, "cuda", None) + if callable(cuda_method) and "device_map" not in model_kwargs: + model = cuda_method() + to_method = getattr(model, "to", None) + if ( + callable(to_method) + and "torch_dtype" not in model_kwargs + and "dtype" not in model_kwargs + ): + model = to_method(torch.bfloat16) + self._model = model + return self._model + + def _infer_deepseek_page( + self, + *, + tokenizer: Any, + model: Any, + page: DocumentPage, + batch_size: int, + ) -> OCRResult: + conversation = build_ocr_conversation(self.template, page) + rendered_prompt = _deepseek_ocr_2_prompt_from_conversation(conversation) + self._log_prompt_payload( + rendered_prompt=rendered_prompt, + conversation=conversation, + batch_size=batch_size, + ) + + infer_method = getattr(model, "infer", None) + if not callable(infer_method): + raise ConfigurationError("DeepSeek-OCR-2 requires a model object with `infer(...)` support.") + + with TemporaryDirectory(prefix="churro-deepseek-ocr-2-") as output_dir: + image_path = Path(output_dir) / "page.png" + page.image.save(image_path) + text = infer_method( + tokenizer, + prompt=rendered_prompt, + image_file=str(image_path), + output_path=output_dir, + base_size=self.base_size, + image_size=self.image_size, + crop_mode=self.crop_mode, + save_results=False, + eval_mode=True, + ) + if not isinstance(text, str): + raise ProviderError("DeepSeek-OCR-2 returned no OCR text.") + return build_ocr_result( + text, + provider_name=self.provider_name, + model_name=self.model_name or self.model_id, + text_postprocessor=self.text_postprocessor, + ) + + def _ocr_sync(self, page: DocumentPage) -> OCRResult: + prepared_page = preprocess_backend_page( + page, + image_preprocessor=self.image_preprocessor, + ) + runtime = self._load_runtime() + tokenizer = self._get_processor(runtime) + model = self._get_model(runtime) + return self._infer_deepseek_page( + tokenizer=tokenizer, + model=model, + page=prepared_page, + batch_size=1, + ) + + def _ocr_batch_sync(self, pages: list[DocumentPage]) -> list[OCRResult]: + if not pages: + return [] + + runtime = self._load_runtime() + tokenizer = self._get_processor(runtime) + model = self._get_model(runtime) + results: list[OCRResult] = [] + for page in pages: + prepared_page = preprocess_backend_page( + page, + image_preprocessor=self.image_preprocessor, + ) + results.append( + self._infer_deepseek_page( + tokenizer=tokenizer, + model=model, + page=prepared_page, + batch_size=len(pages), + ) + ) + return results + + @dataclass(slots=True) class DotsOCR15OCRBackend(HuggingFaceVisionOCRBackend): """Preset OCR backend for ``kristaller486/dots.ocr-1.5``. @@ -1024,6 +1214,7 @@ def _ocr_batch_sync(self, pages: list[DocumentPage]) -> list[OCRResult]: __all__ = [ "ChandraOCR2OCRBackend", "Churro3BOCRBackend", + "DeepSeekOCR2OCRBackend", "DotsMOCROCRBackend", "DotsOCR15OCRBackend", "HuggingFaceVisionOCRBackend", diff --git a/src/churro_ocr/providers/specs.py b/src/churro_ocr/providers/specs.py index 27f64b4..cc6fec8 100644 --- a/src/churro_ocr/providers/specs.py +++ b/src/churro_ocr/providers/specs.py @@ -23,6 +23,9 @@ CHANDRA_OCR_2_OCR_TEMPLATE, CHURRO_3B_MODEL_ID, CHURRO_3B_XML_TEMPLATE, + DEEPSEEK_OCR_2_MODEL_ID, + DEEPSEEK_OCR_2_OCR_PROMPT, + DEEPSEEK_OCR_2_OCR_TEMPLATE, DEFAULT_OCR_TEMPLATE, DOTS_MOCR_MODEL_ID, DOTS_MOCR_OCR_TEMPLATE, @@ -52,6 +55,7 @@ VisionInputBuilder = Callable[[OCRConversation], object] DEFAULT_OCR_MAX_TOKENS = 20_000 CHANDRA_OCR_MAX_TOKENS = 12_384 +DEEPSEEK_OCR_2_MAX_TOKENS = 8_192 OLMOCR_MAX_TOKENS = 8_000 PADDLEOCR_VL_MAX_TOKENS = 4_096 CHANDRA_MAX_IMAGE_SIZE = (3_072, 2_048) @@ -116,8 +120,17 @@ def default_ocr_text_postprocessor(text: str) -> str: "", "", "<|assistant|>", + "<|assistant|>:", "<|user|>", + "<|user|>:", "<|system|>", + "<|system|>:", + "<|assistant|>", + "<|assistant|>:", + "<|user|>", + "<|user|>:", + "<|system|>", + "<|system|>:", } @@ -178,6 +191,22 @@ def lfm2_5_vl_text_postprocessor(text: str) -> str: return strip_ocr_output_tag(cleaned, output_tag=DEFAULT_OCR_OUTPUT_TAG) +def deepseek_ocr_2_text_postprocessor(text: str) -> str: + """Strip DeepSeek OCR 2 prompt echoes, chat scaffold, and trailing stop tokens.""" + cleaned = text.strip() + stop_token = "<|end▁of▁sentence|>" + while cleaned.endswith(stop_token): + cleaned = cleaned[: -len(stop_token)].rstrip() + cleaned = _strip_leading_chat_scaffold( + cleaned, + prompts=[ + f"\n{DEEPSEEK_OCR_2_OCR_PROMPT}", + DEEPSEEK_OCR_2_OCR_PROMPT, + ], + ) + return cleaned.strip() + + def paddleocr_vl_text_postprocessor(text: str) -> str: """Strip PaddleOCR-VL prompt echoes and leading chat scaffold from OCR output.""" return _strip_leading_chat_scaffold(text, prompts=[PADDLEOCR_VL_1_5_OCR_PROMPT]) @@ -395,6 +424,32 @@ def chandra_ocr_2_profile() -> OCRModelProfile: ) +def deepseek_ocr_2_profile() -> OCRModelProfile: + """Return the built-in ``deepseek-ai/DeepSeek-OCR-2`` OCR profile.""" + return OCRModelProfile( + profile_name=DEEPSEEK_OCR_2_MODEL_ID, + template=DEEPSEEK_OCR_2_OCR_TEMPLATE, + text_postprocessor=deepseek_ocr_2_text_postprocessor, + display_name="DeepSeek-OCR-2", + transport=LiteLLMTransportConfig( + completion_kwargs={ + "max_tokens": DEEPSEEK_OCR_2_MAX_TOKENS, + "temperature": 0.0, + } + ), + huggingface=HuggingFaceOptions( + model_kwargs={ + "use_safetensors": True, + }, + generation_kwargs={ + "max_new_tokens": DEEPSEEK_OCR_2_MAX_TOKENS, + }, + trust_remote_code=True, + backend_variant="deepseek-ocr-2", + ), + ) + + def dots_ocr_1_5_profile() -> OCRModelProfile: """Return the built-in ``kristaller486/dots.ocr-1.5`` OCR profile. @@ -522,6 +577,7 @@ def _profile_registry() -> dict[str, OCRModelProfile]: default_profile = default_ocr_profile() churro_profile = churro_3b_profile() chandra_profile = chandra_ocr_2_profile() + deepseek_profile = deepseek_ocr_2_profile() dots_mocr = dots_mocr_profile() dots_profile = dots_ocr_1_5_profile() lfm2_5_vl_profile = lfm2_5_vl_1_6b_profile() @@ -532,6 +588,7 @@ def _profile_registry() -> dict[str, OCRModelProfile]: default_profile.profile_name: default_profile, churro_profile.profile_name: churro_profile, chandra_profile.profile_name: chandra_profile, + deepseek_profile.profile_name: deepseek_profile, dots_mocr.profile_name: dots_mocr, dots_profile.profile_name: dots_profile, lfm2_5_vl_profile.profile_name: lfm2_5_vl_profile, @@ -574,6 +631,8 @@ def resolve_ocr_profile( "chandra_image_preprocessor", "chandra_ocr_2_profile", "chandra_text_postprocessor", + "deepseek_ocr_2_profile", + "deepseek_ocr_2_text_postprocessor", "default_ocr_image_preprocessor", "default_ocr_profile", "default_ocr_text_postprocessor", diff --git a/src/churro_ocr/templates/__init__.py b/src/churro_ocr/templates/__init__.py index b9e9e18..755da05 100644 --- a/src/churro_ocr/templates/__init__.py +++ b/src/churro_ocr/templates/__init__.py @@ -13,6 +13,9 @@ CHANDRA_OCR_2_OCR_TEMPLATE, CHURRO_3B_MODEL_ID, CHURRO_3B_XML_TEMPLATE, + DEEPSEEK_OCR_2_MODEL_ID, + DEEPSEEK_OCR_2_OCR_PROMPT, + DEEPSEEK_OCR_2_OCR_TEMPLATE, DEFAULT_OCR_TEMPLATE, DOTS_MOCR_MODEL_ID, DOTS_MOCR_OCR_PROMPT, @@ -36,6 +39,9 @@ "CHURRO_3B_XML_TEMPLATE", "CHANDRA_OCR_2_MODEL_ID", "CHANDRA_OCR_2_OCR_TEMPLATE", + "DEEPSEEK_OCR_2_MODEL_ID", + "DEEPSEEK_OCR_2_OCR_PROMPT", + "DEEPSEEK_OCR_2_OCR_TEMPLATE", "DEFAULT_OCR_TEMPLATE", "DOTS_MOCR_MODEL_ID", "DOTS_MOCR_OCR_PROMPT", diff --git a/src/churro_ocr/templates/presets.py b/src/churro_ocr/templates/presets.py index 300a65f..6e31226 100644 --- a/src/churro_ocr/templates/presets.py +++ b/src/churro_ocr/templates/presets.py @@ -12,6 +12,7 @@ CHURRO_3B_MODEL_ID = "stanford-oval/churro-3B" CHANDRA_OCR_2_MODEL_ID = "datalab-to/chandra-ocr-2" +DEEPSEEK_OCR_2_MODEL_ID = "deepseek-ai/DeepSeek-OCR-2" DOTS_OCR_1_5_MODEL_ID = "kristaller486/dots.ocr-1.5" DOTS_MOCR_MODEL_ID = "rednote-hilab/dots.mocr" PADDLEOCR_VL_1_5_MODEL_ID = "PaddlePaddle/PaddleOCR-VL-1.5" @@ -31,6 +32,11 @@ system_message=None, user_prompt=CHANDRA_OCR_LAYOUT_PROMPT, ) +DEEPSEEK_OCR_2_OCR_PROMPT = "Free OCR." +DEEPSEEK_OCR_2_OCR_TEMPLATE = HFChatTemplate( + system_message=None, + user_prompt=DEEPSEEK_OCR_2_OCR_PROMPT, +) DOTS_OCR_1_5_OCR_PROMPT = "Extract the text content from this image." DOTS_OCR_1_5_OCR_TEMPLATE = HFChatTemplate( system_message=None, @@ -62,6 +68,9 @@ "CHURRO_3B_XML_TEMPLATE", "CHANDRA_OCR_2_MODEL_ID", "CHANDRA_OCR_2_OCR_TEMPLATE", + "DEEPSEEK_OCR_2_MODEL_ID", + "DEEPSEEK_OCR_2_OCR_PROMPT", + "DEEPSEEK_OCR_2_OCR_TEMPLATE", "DEFAULT_OCR_TEMPLATE", "DOTS_MOCR_MODEL_ID", "DOTS_MOCR_OCR_PROMPT", diff --git a/tests/test_cli.py b/tests/test_cli.py index 875b8ad..c564195 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -20,6 +20,8 @@ from churro_ocr.templates import ( CHANDRA_OCR_2_MODEL_ID, CHANDRA_OCR_2_OCR_TEMPLATE, + DEEPSEEK_OCR_2_MODEL_ID, + DEEPSEEK_OCR_2_OCR_TEMPLATE, DEFAULT_OCR_TEMPLATE, DOTS_MOCR_OCR_TEMPLATE, DOTS_OCR_1_5_OCR_TEMPLATE, @@ -252,6 +254,47 @@ def test_build_ocr_backend_aligns_templates_for_dots_mocr() -> None: } +def test_build_ocr_backend_aligns_templates_for_deepseek_ocr_2() -> None: + litellm_backend = cli_module._build_ocr_backend( + backend="litellm", + model=DEEPSEEK_OCR_2_MODEL_ID, + endpoint=None, + api_key=None, + base_url=None, + api_version=None, + ) + hf_backend = cli_module._build_ocr_backend( + backend="hf", + model=DEEPSEEK_OCR_2_MODEL_ID, + endpoint=None, + api_key=None, + base_url=None, + api_version=None, + ) + openai_backend = cli_module._build_ocr_backend( + backend="openai-compatible", + model=DEEPSEEK_OCR_2_MODEL_ID, + endpoint=None, + api_key=None, + base_url="http://127.0.0.1:8000/v1", + api_version=None, + ) + + assert litellm_backend.template == DEEPSEEK_OCR_2_OCR_TEMPLATE + assert litellm_backend.template == hf_backend.template == openai_backend.template + assert litellm_backend.model_name == "DeepSeek-OCR-2" + assert hf_backend.model_name == "DeepSeek-OCR-2" + assert openai_backend.model_name == "DeepSeek-OCR-2" + assert litellm_backend.transport.config.completion_kwargs == { + "max_tokens": 8_192, + "temperature": 0.0, + } + assert openai_backend.transport.config.completion_kwargs == { + "max_tokens": 8_192, + "temperature": 0.0, + } + + def test_build_ocr_backend_aligns_templates_for_paddleocr_vl() -> None: litellm_backend = cli_module._build_ocr_backend( backend="litellm", diff --git a/tests/test_hf_ocr.py b/tests/test_hf_ocr.py index 855e0c3..70bdb71 100644 --- a/tests/test_hf_ocr.py +++ b/tests/test_hf_ocr.py @@ -23,17 +23,25 @@ from churro_ocr.providers.hf import ( ChandraOCR2OCRBackend, Churro3BOCRBackend, + DeepSeekOCR2OCRBackend, DotsMOCROCRBackend, DotsOCR15OCRBackend, HuggingFaceVisionOCRBackend, LFM25VLOCRBackend, PaddleOCRVL15OCRBackend, ) -from churro_ocr.providers.specs import DEFAULT_OCR_MAX_TOKENS, lfm2_5_vl_text_postprocessor +from churro_ocr.providers.specs import ( + DEFAULT_OCR_MAX_TOKENS, + deepseek_ocr_2_text_postprocessor, + lfm2_5_vl_text_postprocessor, +) from churro_ocr.templates import ( CHANDRA_OCR_2_MODEL_ID, CHANDRA_OCR_2_OCR_TEMPLATE, CHURRO_3B_XML_TEMPLATE, + DEEPSEEK_OCR_2_MODEL_ID, + DEEPSEEK_OCR_2_OCR_PROMPT, + DEEPSEEK_OCR_2_OCR_TEMPLATE, DOTS_MOCR_MODEL_ID, DOTS_MOCR_OCR_PROMPT, DOTS_MOCR_OCR_TEMPLATE, @@ -88,6 +96,16 @@ def test_chandra_template_builds_image_before_prompt() -> None: assert conversation[0]["content"][1]["text"] == CHANDRA_OCR_LAYOUT_PROMPT +def test_deepseek_ocr_2_template_builds_image_before_prompt() -> None: + page = DocumentPage.from_image(Image.new("RGB", (20, 20), color="white")) + + conversation = DEEPSEEK_OCR_2_OCR_TEMPLATE.build_conversation(page) + + assert conversation[0]["role"] == "user" + assert conversation[0]["content"][0]["type"] == "image" + assert conversation[0]["content"][1]["text"] == DEEPSEEK_OCR_2_OCR_PROMPT + + def test_parse_olmocr_response_extracts_plain_text_and_metadata() -> None: text, metadata = parse_olmocr_response( "---\n" @@ -155,6 +173,15 @@ def test_lfm25_text_postprocessor_strips_role_only_prefix() -> None: assert lfm2_5_vl_text_postprocessor("assistant:\nplain text") == "plain text" +def test_deepseek_ocr_2_text_postprocessor_strips_prompt_echo_and_stop_token() -> None: + assert ( + deepseek_ocr_2_text_postprocessor( + "\nFree OCR.\n<|Assistant|>\nplain text<|end▁of▁sentence|>" + ) + == "plain text" + ) + + def test_build_ocr_backend_uses_chandra_profile_defaults_for_hf() -> None: backend = cast( "ChandraOCR2OCRBackend", @@ -175,6 +202,29 @@ def test_build_ocr_backend_uses_chandra_profile_defaults_for_hf() -> None: assert backend.image_preprocessor(Image.new("RGB", (5_000, 3_000), color="white")).size == (3_248, 1_932) +def test_build_ocr_backend_uses_deepseek_ocr_2_profile_defaults_for_hf() -> None: + backend = cast( + "DeepSeekOCR2OCRBackend", + build_ocr_backend( + OCRBackendSpec( + provider="hf", + model=DEEPSEEK_OCR_2_MODEL_ID, + ) + ), + ) + + assert isinstance(backend, DeepSeekOCR2OCRBackend) + assert backend.template == DEEPSEEK_OCR_2_OCR_TEMPLATE + assert backend.model_name == "DeepSeek-OCR-2" + assert backend.generation_kwargs == {"max_new_tokens": 8_192} + assert backend.trust_remote_code is True + assert backend.processor_kwargs == {} + assert backend.model_kwargs == {"use_safetensors": True} + assert backend.base_size == 1_024 + assert backend.image_size == 768 + assert backend.crop_mode is True + + def test_build_ocr_backend_uses_olmocr_profile_defaults_for_hf() -> None: backend = cast( "HuggingFaceVisionOCRBackend", @@ -412,6 +462,106 @@ def from_pretrained(model_id: str, **kwargs: object) -> FakeModel: assert captured["clean_up_tokenization_spaces"] is False +@pytest.mark.asyncio +async def test_deepseek_ocr_2_huggingface_backend_uses_upstream_infer_contract( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, Any] = {} + + class FakeTokenizer: + pass + + class FakeTokenizerCls: + @staticmethod + def from_pretrained(model_id: str, **kwargs: object) -> FakeTokenizer: + captured["tokenizer_model_id"] = model_id + captured["tokenizer_from_pretrained_kwargs"] = kwargs + return FakeTokenizer() + + class FakeModel: + def eval(self) -> FakeModel: + captured["eval_called"] = True + return self + + def cuda(self) -> FakeModel: + captured["cuda_called"] = True + return self + + def to(self, dtype: object) -> FakeModel: + captured["to_dtype"] = dtype + return self + + def infer(self, tokenizer: object, **kwargs: object) -> str: + captured["infer_tokenizer"] = tokenizer + captured["infer_kwargs"] = kwargs + image = Image.open(cast("str", kwargs["image_file"])) + captured["saved_image_mode"] = image.mode + captured["saved_image_size"] = image.size + captured["output_dir_exists"] = Path(cast("str", kwargs["output_path"])).exists() + return "\nFree OCR.\n<|Assistant|>\nDecoded text<|end▁of▁sentence|>" + + class FakeModelCls: + @staticmethod + def from_pretrained(model_id: str, **kwargs: object) -> FakeModel: + captured["model_model_id"] = model_id + captured["model_from_pretrained_kwargs"] = kwargs + return FakeModel() + + monkeypatch.setattr( + "churro_ocr.providers.hf._load_hf_auto_model_runtime", + lambda: SimpleNamespace( + processor_cls=FakeTokenizerCls, + model_cls=FakeModelCls, + process_vision_info=None, + ), + ) + monkeypatch.setattr( + "churro_ocr.providers.hf._ensure_deepseek_ocr_2_cuda_runtime", + lambda: SimpleNamespace(bfloat16="fake-bfloat16"), + ) + + backend = cast( + "DeepSeekOCR2OCRBackend", + build_ocr_backend( + OCRBackendSpec( + provider="hf", + model=DEEPSEEK_OCR_2_MODEL_ID, + ) + ), + ) + result = await backend.ocr( + DocumentPage.from_image(Image.new("RGBA", (32, 16), color=(255, 255, 255, 255))) + ) + + assert result.text == "Decoded text" + assert result.metadata == {} + assert captured["tokenizer_model_id"] == DEEPSEEK_OCR_2_MODEL_ID + assert captured["model_model_id"] == DEEPSEEK_OCR_2_MODEL_ID + assert captured["tokenizer_from_pretrained_kwargs"] == {"trust_remote_code": True} + assert captured["model_from_pretrained_kwargs"] == { + "trust_remote_code": True, + "use_safetensors": True, + } + assert captured["eval_called"] is True + assert captured["cuda_called"] is True + assert captured["to_dtype"] == "fake-bfloat16" + assert captured["infer_tokenizer"].__class__ is FakeTokenizer + infer_kwargs = cast("dict[str, object]", captured["infer_kwargs"]) + assert infer_kwargs == { + "prompt": "\nFree OCR.", + "image_file": infer_kwargs["image_file"], + "output_path": infer_kwargs["output_path"], + "base_size": 1_024, + "image_size": 768, + "crop_mode": True, + "save_results": False, + "eval_mode": True, + } + assert captured["saved_image_mode"] == "RGB" + assert captured["saved_image_size"] == (32, 16) + assert captured["output_dir_exists"] is True + + @pytest.mark.asyncio async def test_lfm25_huggingface_backend_uses_tokenized_chat_template_and_ties_lm_head( monkeypatch: pytest.MonkeyPatch, @@ -1002,6 +1152,21 @@ def test_dots_ocr_15_backend_uses_expected_defaults() -> None: assert backend.generation_kwargs == {"max_new_tokens": DEFAULT_OCR_MAX_TOKENS} +def test_deepseek_ocr_2_backend_uses_expected_defaults() -> None: + backend = DeepSeekOCR2OCRBackend() + + assert backend.model_id == DEEPSEEK_OCR_2_MODEL_ID + assert backend.template == DEEPSEEK_OCR_2_OCR_TEMPLATE + assert backend.model_name == "DeepSeek-OCR-2" + assert backend.trust_remote_code is True + assert backend.processor_kwargs == {} + assert backend.model_kwargs == {"use_safetensors": True} + assert backend.generation_kwargs == {"max_new_tokens": 8_192} + assert backend.base_size == 1_024 + assert backend.image_size == 768 + assert backend.crop_mode is True + + def test_dots_mocr_backend_uses_expected_defaults() -> None: backend = DotsMOCROCRBackend() diff --git a/tests/test_providers.py b/tests/test_providers.py index c123543..c7c9828 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -48,6 +48,9 @@ from churro_ocr.templates import ( CHANDRA_OCR_2_MODEL_ID, CHANDRA_OCR_2_OCR_TEMPLATE, + DEEPSEEK_OCR_2_MODEL_ID, + DEEPSEEK_OCR_2_OCR_PROMPT, + DEEPSEEK_OCR_2_OCR_TEMPLATE, DEFAULT_OCR_TEMPLATE, DOTS_MOCR_OCR_TEMPLATE, OLMOCR_2_7B_1025_FP8_MODEL_ID, @@ -815,6 +818,87 @@ def test_build_ocr_backend_uses_dots_mocr_profile_defaults_for_openai_compatible } +def test_build_ocr_backend_uses_deepseek_ocr_2_profile_defaults_for_openai_compatible() -> None: + backend = cast( + "OpenAICompatibleOCRBackend", + build_ocr_backend( + OCRBackendSpec( + provider="openai-compatible", + model=DEEPSEEK_OCR_2_MODEL_ID, + transport=LiteLLMTransportConfig(api_base="http://127.0.0.1:8000/v1"), + ) + ), + ) + + assert backend.template == DEEPSEEK_OCR_2_OCR_TEMPLATE + assert backend.model_name == "DeepSeek-OCR-2" + assert backend.transport.config.completion_kwargs == { + "max_tokens": 8_192, + "temperature": 0.0, + } + + +@pytest.mark.asyncio +async def test_openai_compatible_backend_uses_deepseek_ocr_2_prompt_and_postprocessing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + + def _fake_prepare_messages_from_conversation( + self: LiteLLMTransport, + conversation: list[dict[str, object]], + ) -> list[dict[str, object]]: + captured["conversation"] = conversation + captured["completion_kwargs"] = dict(self.config.completion_kwargs) + return conversation + + async def _fake_complete_text( + self: LiteLLMTransport, + *, + model: str, + messages: list[dict[str, object]], + timeout_seconds: int = 600, + output_json: bool = False, + allow_empty: bool = False, + ) -> str: + captured["model"] = model + captured["messages"] = messages + captured["timeout_seconds"] = timeout_seconds + captured["output_json"] = output_json + captured["allow_empty"] = allow_empty + return "\nFree OCR.\n<|Assistant|>\nDecoded text<|end▁of▁sentence|>" + + monkeypatch.setattr( + "churro_ocr._internal.litellm.LiteLLMTransport.prepare_messages_from_conversation", + _fake_prepare_messages_from_conversation, + ) + monkeypatch.setattr("churro_ocr._internal.litellm.LiteLLMTransport.complete_text", _fake_complete_text) + + backend = cast( + "OpenAICompatibleOCRBackend", + build_ocr_backend( + OCRBackendSpec( + provider="openai-compatible", + model=DEEPSEEK_OCR_2_MODEL_ID, + transport=LiteLLMTransportConfig(api_base="http://127.0.0.1:8000/v1"), + ) + ), + ) + result = await backend.ocr(DocumentPage.from_image(Image.new("RGB", (10, 10), color="white"))) + + assert result.text == "Decoded text" + assert result.model_name == "DeepSeek-OCR-2" + assert captured["model"] == f"openai/{DEEPSEEK_OCR_2_MODEL_ID}" + assert captured["completion_kwargs"] == { + "max_tokens": 8_192, + "temperature": 0.0, + } + conversation = cast("list[dict[str, object]]", captured["conversation"]) + content = cast("list[dict[str, object]]", conversation[0]["content"]) + assert content[0]["type"] == "image" + assert content[1] == {"type": "text", "text": DEEPSEEK_OCR_2_OCR_PROMPT} + + @pytest.mark.asyncio async def test_openai_compatible_backend_uses_olmocr_prompt_and_plain_text_postprocessing( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_tooling_benchmark.py b/tests/test_tooling_benchmark.py index c5817b5..98bd750 100644 --- a/tests/test_tooling_benchmark.py +++ b/tests/test_tooling_benchmark.py @@ -14,6 +14,7 @@ from churro_ocr.providers.specs import DEFAULT_OCR_MAX_TOKENS from churro_ocr.templates import ( CHURRO_3B_XML_TEMPLATE, + DEEPSEEK_OCR_2_OCR_TEMPLATE, DOTS_MOCR_OCR_TEMPLATE, DOTS_OCR_1_5_OCR_TEMPLATE, PADDLEOCR_VL_1_5_OCR_TEMPLATE, @@ -321,6 +322,51 @@ def test_build_ocr_backend_uses_dots_mocr_preset_for_openai_compatible() -> None } +def test_build_ocr_backend_uses_deepseek_ocr_2_preset_for_hf() -> None: + backend = cast( + "HuggingFaceVisionOCRBackend", + benchmark._build_ocr_backend( + benchmark.BenchmarkOptions( + backend="hf", + dataset_split="dev", + model="deepseek-ai/DeepSeek-OCR-2", + ) + ), + ) + + assert backend.model_name == "DeepSeek-OCR-2" + assert backend.processor_kwargs == {} + assert backend.trust_remote_code is True + assert backend.model_kwargs == { + "device_map": "auto", + "torch_dtype": "auto", + "use_safetensors": True, + } + assert backend.generation_kwargs == {"max_new_tokens": 8_192} + + +def test_build_ocr_backend_uses_deepseek_ocr_2_preset_for_openai_compatible() -> None: + backend = cast( + "LiteLLMVisionOCRBackend", + benchmark._build_ocr_backend( + benchmark.BenchmarkOptions( + backend="openai-compatible", + dataset_split="dev", + model="deepseek-ai/DeepSeek-OCR-2", + base_url="http://127.0.0.1:8000/v1", + ) + ), + ) + + assert backend.provider_name == "openai-compatible" + assert backend.model_name == "DeepSeek-OCR-2" + assert backend.template == DEEPSEEK_OCR_2_OCR_TEMPLATE + assert backend.transport.config.completion_kwargs == { + "max_tokens": 8_192, + "temperature": 0.0, + } + + def test_build_ocr_backend_uses_paddleocr_vl_preset_for_hf() -> None: backend = cast( "HuggingFaceVisionOCRBackend", From d4cf2a5e4ef3f6f990cc49516a660b03d5946140 Mon Sep 17 00:00:00 2001 From: Sina Date: Tue, 7 Apr 2026 06:25:33 +0000 Subject: [PATCH 27/28] chore(benchmark): add DeepSeek OCR 2 leaderboard result --- benchmark_results.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/benchmark_results.json b/benchmark_results.json index dd35a45..037514c 100644 --- a/benchmark_results.json +++ b/benchmark_results.json @@ -361,6 +361,15 @@ "handwritten": 28.3488, "total": 39.1984 }, + { + "modelName": "DeepSeek OCR 2", + "modelId": "deepseek-ai/DeepSeek-OCR-2", + "modelUrl": "https://huggingface.co/deepseek-ai/DeepSeek-OCR-2", + "iconPath": null, + "printed": 56.1, + "handwritten": 20.0, + "total": 36.7 + }, { "modelName": "Skywork R1V3", "modelId": "Skywork/Skywork-R1V3-38B", From 4086aaa3dd699702dfe22e26f8f66efae5051045 Mon Sep 17 00:00:00 2001 From: Sina Date: Tue, 7 Apr 2026 06:31:39 +0000 Subject: [PATCH 28/28] feat(providers): add Gemini 3.1 Pro Preview model to benchmark results --- benchmark_results.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/benchmark_results.json b/benchmark_results.json index 037514c..0ccccbe 100644 --- a/benchmark_results.json +++ b/benchmark_results.json @@ -16,6 +16,15 @@ "printed": 82.6, "handwritten": 66.5, "total": 73.9 + }, + { + "modelName": "Gemini 3.1 Pro Preview", + "modelId": "gemini-3.1-pro-preview", + "modelUrl": "https://ai.google.dev/gemini-api/docs/models", + "iconPath": "_static/img/gemini-symbol.png", + "printed": 80.3, + "handwritten": 68.3, + "total": 73.8 }, { "modelName": "Gemini 3 Pro",