diff --git a/tests/test_litellm/ocr/fixtures/azure.py b/tests/test_litellm/ocr/fixtures/azure.py index 229cdc7205d..e662d839fa2 100644 --- a/tests/test_litellm/ocr/fixtures/azure.py +++ b/tests/test_litellm/ocr/fixtures/azure.py @@ -1,12 +1,14 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Final, cast +from typing import Final, Literal, cast from hypothesis import strategies as st from hypothesis.strategies import DrawFn, SearchStrategy +from pydantic import Field, field_validator from tests.route_parity.fixtures.recording import ProviderSpec +from tests.test_litellm.ocr.fixtures.base import OcrDocument, OcrSdkInputBase from tests.test_litellm.ocr.fixtures.common import ( OcrFixtureClient, OcrRecordingTarget, @@ -16,15 +18,38 @@ from tests.test_litellm.ocr.fixtures.common import ( ) from tests.test_litellm.ocr.fixtures.mistral import ( MISTRAL_MODEL, + MistralCompatibleOcrSdkInput, + MistralOcrSdkInput, mistral_input_strategy, required_mistral_inputs, ) -from tests.test_litellm.ocr.fixtures.models import ( - AzureDocumentIntelligenceOcrSdkInput, - AzureMistralOcrSdkInput, - MistralOcrSdkInput, - OcrSdkInputBase, -) + + +class AzureMistralOcrSdkInput(MistralCompatibleOcrSdkInput): + boundary: str = Field(default="azure_mistral", pattern=r"^azure_mistral$") + model: str + custom_llm_provider: Literal["azure_ai"] | None = None + + @field_validator("model") + @classmethod + def validate_model_namespace(cls, model: str) -> str: + if not model.startswith("azure_ai/"): + raise ValueError("Azure Mistral models must use the azure_ai/ LiteLLM namespace") + return model + + +class AzureDocumentIntelligenceOcrSdkInput(OcrSdkInputBase): + boundary: str = Field(default="azure_document_intelligence", pattern=r"^azure_document_intelligence$") + model: Literal[ + "azure_ai/doc-intelligence/prebuilt-read", + "azure_ai/doc-intelligence/prebuilt-layout", + "azure_ai/doc-intelligence/prebuilt-document", + ] + document: OcrDocument + custom_llm_provider: Literal["azure_ai"] | None = None + pages: str | list[int] | None = None + features: str | list[str] | None = None + req_format: Literal["litellm"] = "litellm" def _as_azure_mistral(case_input: MistralOcrSdkInput, model: str) -> AzureMistralOcrSdkInput: diff --git a/tests/test_litellm/ocr/fixtures/base.py b/tests/test_litellm/ocr/fixtures/base.py new file mode 100644 index 00000000000..b75b901faaf --- /dev/null +++ b/tests/test_litellm/ocr/fixtures/base.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from typing import Annotated, Literal + +from pydantic import Field + +from tests.route_parity.fixture_models import ( + FixtureModel, + JsonSchemaDefinition, + JsonSchemaResponseFormat, + SdkInputBase, +) + +__all__ = ( + "DocumentUrlDocument", + "ImageUrlDocument", + "ImageUrlValue", + "JsonSchemaDefinition", + "JsonSchemaResponseFormat", + "OcrDocument", + "OcrSdkInputBase", +) + +OcrSdkInputBase = SdkInputBase + + +class ImageUrlValue(FixtureModel): + url: str + detail: Literal["low", "auto", "high"] | None = None + + +class ImageUrlDocument(FixtureModel): + type: Literal["image_url"] + image_url: str | ImageUrlValue + + +class DocumentUrlDocument(FixtureModel): + type: Literal["document_url"] + document_url: str + document_name: str | None = None + + +OcrDocument = Annotated[ + ImageUrlDocument | DocumentUrlDocument, + Field(discriminator="type"), +] diff --git a/tests/test_litellm/ocr/fixtures/common.py b/tests/test_litellm/ocr/fixtures/common.py index a60712eff57..088d56c81ee 100644 --- a/tests/test_litellm/ocr/fixtures/common.py +++ b/tests/test_litellm/ocr/fixtures/common.py @@ -10,11 +10,11 @@ from hypothesis import strategies as st from hypothesis.strategies import SearchStrategy from tests.route_parity.fixtures.pipeline import RecordingTarget -from tests.test_litellm.ocr.fixtures.models import ( +from tests.test_litellm.ocr.fixtures.base import ( + DocumentUrlDocument, + ImageUrlDocument, JsonSchemaDefinition, JsonSchemaResponseFormat, - MistralDocumentUrlDocument, - MistralImageUrlDocument, OcrSdkInputBase, ) @@ -38,9 +38,9 @@ class ApiKeyOcrInvocation: self.client.execute(provider_url, self.api_key, case_input) -def image_document(text: str, font_size: int) -> MistralImageUrlDocument: +def image_document(text: str, font_size: int) -> ImageUrlDocument: url: Final = f"https://dummyjson.com/image/800x300/ffffff/000000?text={quote(text)}&fontSize={font_size}" - return MistralImageUrlDocument(type="image_url", image_url=url) + return ImageUrlDocument(type="image_url", image_url=url) def fixture_pdf_data_uri() -> str: @@ -49,11 +49,11 @@ def fixture_pdf_data_uri() -> str: return f"data:application/pdf;base64,{encoded}" -def pdf_document() -> MistralDocumentUrlDocument: - return MistralDocumentUrlDocument(type="document_url", document_url=fixture_pdf_data_uri()) +def pdf_document() -> DocumentUrlDocument: + return DocumentUrlDocument(type="document_url", document_url=fixture_pdf_data_uri()) -def public_document_strategy() -> SearchStrategy[MistralImageUrlDocument | MistralDocumentUrlDocument]: +def public_document_strategy() -> SearchStrategy[ImageUrlDocument | DocumentUrlDocument]: return st.sampled_from((image_document("invoice 123", 24), pdf_document())) diff --git a/tests/test_litellm/ocr/fixtures/mistral.py b/tests/test_litellm/ocr/fixtures/mistral.py index 4136c0585be..3bf1e82c3e5 100644 --- a/tests/test_litellm/ocr/fixtures/mistral.py +++ b/tests/test_litellm/ocr/fixtures/mistral.py @@ -1,12 +1,19 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Final, cast +from typing import Final, Literal, cast from hypothesis import strategies as st from hypothesis.strategies import DrawFn, SearchStrategy +from pydantic import Field, model_validator +from typing_extensions import Self from tests.route_parity.fixtures.recording import ProviderSpec +from tests.test_litellm.ocr.fixtures.base import ( + JsonSchemaResponseFormat, + OcrDocument, + OcrSdkInputBase, +) from tests.test_litellm.ocr.fixtures.common import ( OcrFixtureClient, OcrRecordingTarget, @@ -15,7 +22,55 @@ from tests.test_litellm.ocr.fixtures.common import ( invoke_with_api_key, public_document_strategy, ) -from tests.test_litellm.ocr.fixtures.models import MistralOcrSdkInput, OcrSdkInputBase + +MistralModel = Literal[ + "mistral/mistral-ocr-2512", + "mistral/mistral-ocr-4-0", + "mistral/mistral-ocr-4-1", + "mistral/mistral-ocr-4", + "mistral/mistral-ocr-latest", + "mistral-ocr-2512", + "mistral-ocr-4-0", + "mistral-ocr-4-1", + "mistral-ocr-4", + "mistral-ocr-latest", +] + + +class MistralCompatibleOcrSdkInput(OcrSdkInputBase): + document: OcrDocument + pages: str | list[int] | None = None + include_image_base64: bool | None = None + image_limit: int | None = None + image_min_size: int | None = None + bbox_annotation_format: JsonSchemaResponseFormat | None = None + document_annotation_format: JsonSchemaResponseFormat | None = None + document_annotation_prompt: str | None = None + extract_header: bool = False + extract_footer: bool = False + table_format: Literal["markdown", "html"] | None = None + confidence_scores_granularity: Literal["page", "word", "block"] | None = None + include_blocks: bool = True + id: str | None = None + + @model_validator(mode="after") + def validate_annotation_prompt(self) -> Self: + if self.document_annotation_prompt is not None and self.document_annotation_format is None: + raise ValueError("document_annotation_prompt requires document_annotation_format") + return self + + +class MistralOcrSdkInput(MistralCompatibleOcrSdkInput): + boundary: str = Field(default="mistral", pattern=r"^mistral$") + model: MistralModel + custom_llm_provider: Literal["mistral"] | None = None + + @model_validator(mode="after") + def validate_provider_routing(self) -> Self: + if not self.model.startswith("mistral/") and self.custom_llm_provider != "mistral": + raise ValueError("unqualified Mistral models require custom_llm_provider='mistral'") + return self + MISTRAL_MODEL: Final = "mistral/mistral-ocr-latest" _VALUE_TEXT: Final = st.just("case-1") diff --git a/tests/test_litellm/ocr/fixtures/models.py b/tests/test_litellm/ocr/fixtures/models.py index 95f020f104d..aecc97740bf 100644 --- a/tests/test_litellm/ocr/fixtures/models.py +++ b/tests/test_litellm/ocr/fixtures/models.py @@ -1,344 +1,42 @@ from __future__ import annotations -import base64 -import binascii -from typing import Annotated, Literal +from collections.abc import Mapping +from typing import Annotated, Final, cast -from pydantic import Field, field_validator, model_validator -from typing_extensions import Self +from pydantic import Discriminator, Tag -from tests.route_parity.fixture_models import ( - FixtureModel, - JsonObject, - JsonSchemaDefinition, - JsonSchemaResponseFormat, - ParityCase, - SdkInputBase, +from tests.route_parity.fixture_models import ParityCase +from tests.test_litellm.ocr.fixtures.azure import ( + AzureDocumentIntelligenceOcrSdkInput, + AzureMistralOcrSdkInput, ) - -__all__ = ( - "JsonSchemaDefinition", - "JsonSchemaResponseFormat", - "OcrParityCase", - "OcrSdkInput", - "OcrSdkInputBase", -) - -OcrSdkInputBase = SdkInputBase - - -class MistralImageUrlValue(FixtureModel): - url: str - detail: Literal["low", "auto", "high"] | None = None - - -class MistralImageUrlDocument(FixtureModel): - type: Literal["image_url"] - image_url: str | MistralImageUrlValue - - -class MistralDocumentUrlDocument(FixtureModel): - type: Literal["document_url"] - document_url: str - document_name: str | None = None - - -MistralDocument = Annotated[ - MistralImageUrlDocument | MistralDocumentUrlDocument, - Field(discriminator="type"), -] - - -MistralModel = Literal[ - "mistral/mistral-ocr-2512", - "mistral/mistral-ocr-4-0", - "mistral/mistral-ocr-4-1", - "mistral/mistral-ocr-4", - "mistral/mistral-ocr-latest", - "mistral-ocr-2512", - "mistral-ocr-4-0", - "mistral-ocr-4-1", - "mistral-ocr-4", - "mistral-ocr-latest", -] - - -class MistralCompatibleOcrSdkInput(OcrSdkInputBase): - document: MistralDocument - pages: str | list[int] | None = None - include_image_base64: bool | None = None - image_limit: int | None = None - image_min_size: int | None = None - bbox_annotation_format: JsonSchemaResponseFormat | None = None - document_annotation_format: JsonSchemaResponseFormat | None = None - document_annotation_prompt: str | None = None - extract_header: bool = False - extract_footer: bool = False - table_format: Literal["markdown", "html"] | None = None - confidence_scores_granularity: Literal["page", "word", "block"] | None = None - include_blocks: bool = True - id: str | None = None - - @model_validator(mode="after") - def validate_annotation_prompt(self) -> Self: - if self.document_annotation_prompt is not None and self.document_annotation_format is None: - raise ValueError("document_annotation_prompt requires document_annotation_format") - return self - - -class MistralOcrSdkInput(MistralCompatibleOcrSdkInput): - boundary: Literal["mistral"] = "mistral" - model: MistralModel - custom_llm_provider: Literal["mistral"] | None = None - - @model_validator(mode="after") - def validate_provider_routing(self) -> Self: - if not self.model.startswith("mistral/") and self.custom_llm_provider != "mistral": - raise ValueError("unqualified Mistral models require custom_llm_provider='mistral'") - return self - - -class AzureMistralOcrSdkInput(MistralCompatibleOcrSdkInput): - boundary: Literal["azure_mistral"] = "azure_mistral" - model: str - custom_llm_provider: Literal["azure_ai"] | None = None - - @field_validator("model") - @classmethod - def validate_model_namespace(cls, model: str) -> str: - if not model.startswith("azure_ai/"): - raise ValueError("Azure Mistral models must use the azure_ai/ LiteLLM namespace") - return model - - -class VertexMistralOcrSdkInput(MistralCompatibleOcrSdkInput): - boundary: Literal["vertex_mistral"] = "vertex_mistral" - model: Literal["vertex_ai/mistral-ocr-2505"] = "vertex_ai/mistral-ocr-2505" - custom_llm_provider: Literal["vertex_ai"] | None = None - vertex_project: str - vertex_location: str = "us-central1" - - -class AzureDocumentIntelligenceOcrSdkInput(OcrSdkInputBase): - boundary: Literal["azure_document_intelligence"] = "azure_document_intelligence" - model: Literal[ - "azure_ai/doc-intelligence/prebuilt-read", - "azure_ai/doc-intelligence/prebuilt-layout", - "azure_ai/doc-intelligence/prebuilt-document", - ] - document: MistralDocument - custom_llm_provider: Literal["azure_ai"] | None = None - pages: str | list[int] | None = None - features: str | list[str] | None = None - req_format: Literal["litellm"] = "litellm" - - -class VertexDeepSeekOcrSdkInput(OcrSdkInputBase): - boundary: Literal["vertex_deepseek"] = "vertex_deepseek" - model: Literal["vertex_ai/deepseek-ocr-maas"] = "vertex_ai/deepseek-ocr-maas" - document: MistralDocument - custom_llm_provider: Literal["vertex_ai"] | None = None - vertex_project: str - vertex_location: str = "us-central1" - - -def _validate_reducto_source(source: str) -> str: - if source.startswith("reducto://"): - return source - if not source.startswith("data:"): - raise ValueError("Reducto documents require a reducto:// id or base64 data URI") - try: - header, encoded = source.split(",", 1) - except ValueError as error: - raise ValueError("invalid Reducto data URI") from error - if ";base64" not in header: - raise ValueError("Reducto data URIs must be base64 encoded") - try: - base64.b64decode(encoded, validate=True) - except (binascii.Error, ValueError) as error: - raise ValueError("invalid Reducto base64 payload") from error - return source - - -class ReductoImageUrlDocument(FixtureModel): - type: Literal["image_url"] - image_url: str - - @field_validator("image_url") - @classmethod - def validate_image_url(cls, value: str) -> str: - return _validate_reducto_source(value) - - -class ReductoDocumentUrlDocument(FixtureModel): - type: Literal["document_url"] - document_url: str - - @field_validator("document_url") - @classmethod - def validate_document_url(cls, value: str) -> str: - return _validate_reducto_source(value) - - -ReductoDocument = Annotated[ - ReductoImageUrlDocument | ReductoDocumentUrlDocument, - Field(discriminator="type"), -] - -ReductoTableOutputFormat = Literal["html", "json", "md", "jsonbbox", "dynamic", "csv"] -ReductoFormattingInclude = Literal[ - "change_tracking", - "highlight", - "comments", - "hyperlinks", - "signatures", - "ignore_watermarks", -] -ReductoBlockType = Literal[ - "Header", - "Footer", - "Title", - "Section Header", - "Page Number", - "List Item", - "Figure", - "Table", - "Key Value", - "Text", - "Comment", - "Signature", -] - - -class ReductoFormatting(FixtureModel): - add_page_markers: bool = False - table_output_format: ReductoTableOutputFormat = "dynamic" - merge_tables: bool = False - include: list[ReductoFormattingInclude] = Field(default_factory=list) - - @field_validator("include") - @classmethod - def validate_unique_include(cls, value: list[ReductoFormattingInclude]) -> list[ReductoFormattingInclude]: - if len(value) != len(set(value)): - raise ValueError("formatting.include entries must be unique") - return value - - -class ReductoChunking(FixtureModel): - chunk_mode: Literal["variable", "section", "page", "disabled", "block", "page_sections"] = "disabled" - chunk_size: int | None = None - chunk_overlap: int = Field(default=0, ge=0) - - @model_validator(mode="after") - def validate_chunking(self) -> Self: - if self.chunk_size is not None and self.chunk_size <= 0: - raise ValueError("chunk_size must be positive") - if self.chunk_size is not None and self.chunk_overlap >= self.chunk_size: - raise ValueError("chunk_overlap must be less than chunk_size") - return self - - -class ReductoRetrieval(FixtureModel): - chunking: ReductoChunking = Field(default_factory=ReductoChunking) - filter_blocks: list[ReductoBlockType] = Field(default_factory=list) - embedding_optimized: bool = False - - @field_validator("filter_blocks") - @classmethod - def validate_unique_blocks(cls, value: list[ReductoBlockType]) -> list[ReductoBlockType]: - if len(value) != len(set(value)): - raise ValueError("retrieval.filter_blocks entries must be unique") - return value - - -class ReductoPageRange(FixtureModel): - start: int | None = Field(default=None, ge=1) - end: int | None = Field(default=None, ge=1) - - @model_validator(mode="after") - def validate_range(self) -> Self: - if self.start is not None and self.end is not None and self.end < self.start: - raise ValueError("page range end must be greater than or equal to start") - return self - - -class ReductoTenantThrottling(FixtureModel): - tenant_id: str = Field(min_length=1, max_length=256) - max_share: float = Field(default=0.5, gt=0, le=1) - - -class ReductoHybridVpcSettings(FixtureModel): - environment: str | None = None - - -ReductoPageSelection = ReductoPageRange | list[ReductoPageRange] | list[int] | list[str] - - -class ReductoSettings(FixtureModel): - ocr_system: Literal["standard", "legacy"] = "standard" - extraction_mode: Literal["ocr", "hybrid"] = "hybrid" - force_url_result: bool = False - force_file_extension: str | None = None - return_ocr_data: bool = False - return_images: list[Literal["figure", "table", "page"]] = Field(default_factory=list) - embed_pdf_metadata: bool = False - embed_pdf_metadata_dpi: int = Field(default=100, ge=50, le=250) - persist_results: bool = False - tenant_throttling: ReductoTenantThrottling | None = None - timeout: float | None = Field(default=None, gt=0) - page_range: ReductoPageSelection | None = None - document_password: str | None = None - hybrid_vpc: ReductoHybridVpcSettings = Field(default_factory=ReductoHybridVpcSettings) - - @field_validator("return_images") - @classmethod - def validate_unique_images( - cls, value: list[Literal["figure", "table", "page"]] - ) -> list[Literal["figure", "table", "page"]]: - if len(value) != len(set(value)): - raise ValueError("settings.return_images entries must be unique") - return value - - -class ReductoParseV3SdkInput(OcrSdkInputBase): - boundary: Literal["reducto_v3"] = "reducto_v3" - model: Literal["reducto/parse-v3", "parse-v3"] - document: ReductoDocument - custom_llm_provider: Literal["reducto"] | None = None - formatting: ReductoFormatting = Field(default_factory=ReductoFormatting) - retrieval: ReductoRetrieval = Field(default_factory=ReductoRetrieval) - settings: ReductoSettings = Field(default_factory=ReductoSettings) - - @model_validator(mode="after") - def validate_provider_routing(self) -> Self: - if self.model == "parse-v3" and self.custom_llm_provider != "reducto": - raise ValueError("unqualified Reducto models require custom_llm_provider='reducto'") - return self - - -class ReductoParseLegacySdkInput(OcrSdkInputBase): - boundary: Literal["reducto_legacy"] = "reducto_legacy" - model: Literal["reducto/parse-legacy", "parse-legacy"] - document: ReductoDocument - custom_llm_provider: Literal["reducto"] | None = None - enhance: JsonObject | None = None - - @model_validator(mode="after") - def validate_provider_routing(self) -> Self: - if self.model == "parse-legacy" and self.custom_llm_provider != "reducto": - raise ValueError("unqualified Reducto models require custom_llm_provider='reducto'") - return self +from tests.test_litellm.ocr.fixtures.base import OcrSdkInputBase +from tests.test_litellm.ocr.fixtures.mistral import MistralOcrSdkInput +from tests.test_litellm.ocr.fixtures.reducto import ReductoParseLegacySdkInput, ReductoParseV3SdkInput +from tests.test_litellm.ocr.fixtures.vertex import VertexDeepSeekOcrSdkInput, VertexMistralOcrSdkInput + +__all__ = ("OcrParityCase", "OcrSdkInput") + + +def _ocr_boundary(value: object) -> str | None: + if isinstance(value, Mapping): + mapping: Final = cast(Mapping[object, object], value) + boundary: Final = mapping.get("boundary") + return boundary if isinstance(boundary, str) else None + if isinstance(value, OcrSdkInputBase): + return value.boundary + return None OcrSdkInput = Annotated[ - MistralOcrSdkInput - | AzureMistralOcrSdkInput - | VertexMistralOcrSdkInput - | AzureDocumentIntelligenceOcrSdkInput - | VertexDeepSeekOcrSdkInput - | ReductoParseV3SdkInput - | ReductoParseLegacySdkInput, - Field(discriminator="boundary"), + Annotated[MistralOcrSdkInput, Tag("mistral")] + | Annotated[AzureMistralOcrSdkInput, Tag("azure_mistral")] + | Annotated[VertexMistralOcrSdkInput, Tag("vertex_mistral")] + | Annotated[AzureDocumentIntelligenceOcrSdkInput, Tag("azure_document_intelligence")] + | Annotated[VertexDeepSeekOcrSdkInput, Tag("vertex_deepseek")] + | Annotated[ReductoParseV3SdkInput, Tag("reducto_v3")] + | Annotated[ReductoParseLegacySdkInput, Tag("reducto_legacy")], + Discriminator(_ocr_boundary), ] diff --git a/tests/test_litellm/ocr/fixtures/record.py b/tests/test_litellm/ocr/fixtures/record.py index f4b2e4b14ee..5d6ea9ed6ab 100644 --- a/tests/test_litellm/ocr/fixtures/record.py +++ b/tests/test_litellm/ocr/fixtures/record.py @@ -16,9 +16,10 @@ from tests.test_litellm.ocr.fixtures.azure import ( azure_document_intelligence_recording_targets, azure_mistral_recording_targets, ) +from tests.test_litellm.ocr.fixtures.base import OcrSdkInputBase from tests.test_litellm.ocr.fixtures.common import OcrFixtureClient, OcrRecordingTarget, OcrSdkCall from tests.test_litellm.ocr.fixtures.mistral import mistral_recording_targets -from tests.test_litellm.ocr.fixtures.models import OcrParityCase, OcrSdkInputBase +from tests.test_litellm.ocr.fixtures.models import OcrParityCase from tests.test_litellm.ocr.fixtures.reducto import reducto_recording_targets from tests.test_litellm.ocr.fixtures.vertex import vertex_recording_targets diff --git a/tests/test_litellm/ocr/fixtures/reducto.py b/tests/test_litellm/ocr/fixtures/reducto.py index 0c05f7bbd44..b2b0e9b591f 100644 --- a/tests/test_litellm/ocr/fixtures/reducto.py +++ b/tests/test_litellm/ocr/fixtures/reducto.py @@ -1,28 +1,213 @@ from __future__ import annotations +import base64 +import binascii from collections.abc import Mapping -from typing import Final, cast +from typing import Annotated, Final, Literal, cast from hypothesis import strategies as st from hypothesis.strategies import DrawFn, SearchStrategy +from pydantic import Field, field_validator, model_validator +from typing_extensions import Self +from tests.route_parity.fixture_models import FixtureModel, JsonObject from tests.route_parity.fixtures.recording import ProviderSpec +from tests.test_litellm.ocr.fixtures.base import OcrSdkInputBase from tests.test_litellm.ocr.fixtures.common import ( OcrFixtureClient, OcrRecordingTarget, fixture_pdf_data_uri, invoke_with_api_key, ) -from tests.test_litellm.ocr.fixtures.models import ( - OcrSdkInputBase, - ReductoChunking, - ReductoDocumentUrlDocument, - ReductoFormatting, - ReductoParseLegacySdkInput, - ReductoParseV3SdkInput, - ReductoRetrieval, - ReductoSettings, -) + + +def _validate_reducto_source(source: str) -> str: + if source.startswith("reducto://"): + return source + if not source.startswith("data:"): + raise ValueError("Reducto documents require a reducto:// id or base64 data URI") + try: + header, encoded = source.split(",", 1) + except ValueError as error: + raise ValueError("invalid Reducto data URI") from error + if ";base64" not in header: + raise ValueError("Reducto data URIs must be base64 encoded") + try: + base64.b64decode(encoded, validate=True) + except (binascii.Error, ValueError) as error: + raise ValueError("invalid Reducto base64 payload") from error + return source + + +class ReductoImageUrlDocument(FixtureModel): + type: Literal["image_url"] + image_url: str + + @field_validator("image_url") + @classmethod + def validate_image_url(cls, value: str) -> str: + return _validate_reducto_source(value) + + +class ReductoDocumentUrlDocument(FixtureModel): + type: Literal["document_url"] + document_url: str + + @field_validator("document_url") + @classmethod + def validate_document_url(cls, value: str) -> str: + return _validate_reducto_source(value) + + +ReductoDocument = Annotated[ + ReductoImageUrlDocument | ReductoDocumentUrlDocument, + Field(discriminator="type"), +] + +ReductoTableOutputFormat = Literal["html", "json", "md", "jsonbbox", "dynamic", "csv"] +ReductoFormattingInclude = Literal[ + "change_tracking", + "highlight", + "comments", + "hyperlinks", + "signatures", + "ignore_watermarks", +] +ReductoBlockType = Literal[ + "Header", + "Footer", + "Title", + "Section Header", + "Page Number", + "List Item", + "Figure", + "Table", + "Key Value", + "Text", + "Comment", + "Signature", +] + + +class ReductoFormatting(FixtureModel): + add_page_markers: bool = False + table_output_format: ReductoTableOutputFormat = "dynamic" + merge_tables: bool = False + include: list[ReductoFormattingInclude] = Field(default_factory=list) + + @field_validator("include") + @classmethod + def validate_unique_include(cls, value: list[ReductoFormattingInclude]) -> list[ReductoFormattingInclude]: + if len(value) != len(set(value)): + raise ValueError("formatting.include entries must be unique") + return value + + +class ReductoChunking(FixtureModel): + chunk_mode: Literal["variable", "section", "page", "disabled", "block", "page_sections"] = "disabled" + chunk_size: int | None = None + chunk_overlap: int = Field(default=0, ge=0) + + @model_validator(mode="after") + def validate_chunking(self) -> Self: + if self.chunk_size is not None and self.chunk_size <= 0: + raise ValueError("chunk_size must be positive") + if self.chunk_size is not None and self.chunk_overlap >= self.chunk_size: + raise ValueError("chunk_overlap must be less than chunk_size") + return self + + +class ReductoRetrieval(FixtureModel): + chunking: ReductoChunking = Field(default_factory=ReductoChunking) + filter_blocks: list[ReductoBlockType] = Field(default_factory=list) + embedding_optimized: bool = False + + @field_validator("filter_blocks") + @classmethod + def validate_unique_blocks(cls, value: list[ReductoBlockType]) -> list[ReductoBlockType]: + if len(value) != len(set(value)): + raise ValueError("retrieval.filter_blocks entries must be unique") + return value + + +class ReductoPageRange(FixtureModel): + start: int | None = Field(default=None, ge=1) + end: int | None = Field(default=None, ge=1) + + @model_validator(mode="after") + def validate_range(self) -> Self: + if self.start is not None and self.end is not None and self.end < self.start: + raise ValueError("page range end must be greater than or equal to start") + return self + + +class ReductoTenantThrottling(FixtureModel): + tenant_id: str = Field(min_length=1, max_length=256) + max_share: float = Field(default=0.5, gt=0, le=1) + + +class ReductoHybridVpcSettings(FixtureModel): + environment: str | None = None + + +ReductoPageSelection = ReductoPageRange | list[ReductoPageRange] | list[int] | list[str] + + +class ReductoSettings(FixtureModel): + ocr_system: Literal["standard", "legacy"] = "standard" + extraction_mode: Literal["ocr", "hybrid"] = "hybrid" + force_url_result: bool = False + force_file_extension: str | None = None + return_ocr_data: bool = False + return_images: list[Literal["figure", "table", "page"]] = Field(default_factory=list) + embed_pdf_metadata: bool = False + embed_pdf_metadata_dpi: int = Field(default=100, ge=50, le=250) + persist_results: bool = False + tenant_throttling: ReductoTenantThrottling | None = None + timeout: float | None = Field(default=None, gt=0) + page_range: ReductoPageSelection | None = None + document_password: str | None = None + hybrid_vpc: ReductoHybridVpcSettings = Field(default_factory=ReductoHybridVpcSettings) + + @field_validator("return_images") + @classmethod + def validate_unique_images( + cls, value: list[Literal["figure", "table", "page"]] + ) -> list[Literal["figure", "table", "page"]]: + if len(value) != len(set(value)): + raise ValueError("settings.return_images entries must be unique") + return value + + +class ReductoParseV3SdkInput(OcrSdkInputBase): + boundary: str = Field(default="reducto_v3", pattern=r"^reducto_v3$") + model: Literal["reducto/parse-v3", "parse-v3"] + document: ReductoDocument + custom_llm_provider: Literal["reducto"] | None = None + formatting: ReductoFormatting = Field(default_factory=ReductoFormatting) + retrieval: ReductoRetrieval = Field(default_factory=ReductoRetrieval) + settings: ReductoSettings = Field(default_factory=ReductoSettings) + + @model_validator(mode="after") + def validate_provider_routing(self) -> Self: + if self.model == "parse-v3" and self.custom_llm_provider != "reducto": + raise ValueError("unqualified Reducto models require custom_llm_provider='reducto'") + return self + + +class ReductoParseLegacySdkInput(OcrSdkInputBase): + boundary: str = Field(default="reducto_legacy", pattern=r"^reducto_legacy$") + model: Literal["reducto/parse-legacy", "parse-legacy"] + document: ReductoDocument + custom_llm_provider: Literal["reducto"] | None = None + enhance: JsonObject | None = None + + @model_validator(mode="after") + def validate_provider_routing(self) -> Self: + if self.model == "parse-legacy" and self.custom_llm_provider != "reducto": + raise ValueError("unqualified Reducto models require custom_llm_provider='reducto'") + return self + _REDUCTO_API_BASE: Final = "https://platform.reducto.ai" diff --git a/tests/test_litellm/ocr/fixtures/vertex.py b/tests/test_litellm/ocr/fixtures/vertex.py index 46a274c76b3..64e592f4f86 100644 --- a/tests/test_litellm/ocr/fixtures/vertex.py +++ b/tests/test_litellm/ocr/fixtures/vertex.py @@ -1,12 +1,14 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Final, cast +from typing import Final, Literal, cast from hypothesis import strategies as st from hypothesis.strategies import DrawFn, SearchStrategy +from pydantic import Field from tests.route_parity.fixtures.recording import ProviderSpec +from tests.test_litellm.ocr.fixtures.base import OcrDocument, OcrSdkInputBase from tests.test_litellm.ocr.fixtures.common import ( OcrFixtureClient, OcrRecordingTarget, @@ -16,15 +18,28 @@ from tests.test_litellm.ocr.fixtures.common import ( ) from tests.test_litellm.ocr.fixtures.mistral import ( MISTRAL_MODEL, + MistralCompatibleOcrSdkInput, + MistralOcrSdkInput, mistral_input_strategy, required_mistral_inputs, ) -from tests.test_litellm.ocr.fixtures.models import ( - MistralOcrSdkInput, - OcrSdkInputBase, - VertexDeepSeekOcrSdkInput, - VertexMistralOcrSdkInput, -) + + +class VertexMistralOcrSdkInput(MistralCompatibleOcrSdkInput): + boundary: str = Field(default="vertex_mistral", pattern=r"^vertex_mistral$") + model: Literal["vertex_ai/mistral-ocr-2505"] = "vertex_ai/mistral-ocr-2505" + custom_llm_provider: Literal["vertex_ai"] | None = None + vertex_project: str + vertex_location: str = "us-central1" + + +class VertexDeepSeekOcrSdkInput(OcrSdkInputBase): + boundary: str = Field(default="vertex_deepseek", pattern=r"^vertex_deepseek$") + model: Literal["vertex_ai/deepseek-ocr-maas"] = "vertex_ai/deepseek-ocr-maas" + document: OcrDocument + custom_llm_provider: Literal["vertex_ai"] | None = None + vertex_project: str + vertex_location: str = "us-central1" def _as_vertex_mistral(case_input: MistralOcrSdkInput, project: str, location: str) -> VertexMistralOcrSdkInput: diff --git a/tests/test_litellm/ocr/test_fixture_models.py b/tests/test_litellm/ocr/test_fixture_models.py index e8fc6d1a358..0292e714b36 100644 --- a/tests/test_litellm/ocr/test_fixture_models.py +++ b/tests/test_litellm/ocr/test_fixture_models.py @@ -11,18 +11,21 @@ from litellm.llms.base_llm.ocr.transformation import BaseOCRConfig from litellm.llms.mistral.ocr.transformation import MistralOCRConfig from litellm.llms.reducto.ocr.transformation import ReductoParseLegacyConfig, ReductoParseV3Config from litellm.llms.vertex_ai.ocr.deepseek_transformation import VertexAIDeepSeekOCRConfig -from tests.test_litellm.ocr.fixtures.azure import azure_document_intelligence_input_strategy -from tests.test_litellm.ocr.fixtures.mistral import mistral_input_strategy -from tests.test_litellm.ocr.fixtures.models import ( +from tests.test_litellm.ocr.fixtures.azure import ( AzureDocumentIntelligenceOcrSdkInput, AzureMistralOcrSdkInput, + azure_document_intelligence_input_strategy, +) +from tests.test_litellm.ocr.fixtures.base import ( + DocumentUrlDocument, + ImageUrlDocument, + ImageUrlValue, JsonSchemaDefinition, JsonSchemaResponseFormat, - MistralDocumentUrlDocument, - MistralImageUrlDocument, - MistralImageUrlValue, - MistralOcrSdkInput, OcrSdkInputBase, +) +from tests.test_litellm.ocr.fixtures.mistral import MistralOcrSdkInput, mistral_input_strategy +from tests.test_litellm.ocr.fixtures.reducto import ( ReductoChunking, ReductoDocumentUrlDocument, ReductoFormatting, @@ -31,11 +34,14 @@ from tests.test_litellm.ocr.fixtures.models import ( ReductoParseV3SdkInput, ReductoRetrieval, ReductoSettings, + reducto_legacy_input_strategy, + reducto_v3_input_strategy, +) +from tests.test_litellm.ocr.fixtures.vertex import ( VertexDeepSeekOcrSdkInput, VertexMistralOcrSdkInput, + vertex_deepseek_input_strategy, ) -from tests.test_litellm.ocr.fixtures.reducto import reducto_legacy_input_strategy, reducto_v3_input_strategy -from tests.test_litellm.ocr.fixtures.vertex import vertex_deepseek_input_strategy COMMON_FIELDS: Final = frozenset( {"boundary", "model", "document", "custom_llm_provider", "vertex_project", "vertex_location"} @@ -88,18 +94,18 @@ def test_deepseek_fixture_fields_match_provider_config() -> None: ( AzureMistralOcrSdkInput( model="azure_ai/mistral-ocr-deployment", - document=MistralImageUrlDocument(type="image_url", image_url="data:image/png;base64,AA=="), + document=ImageUrlDocument(type="image_url", image_url="data:image/png;base64,AA=="), ), VertexMistralOcrSdkInput( - document=MistralImageUrlDocument(type="image_url", image_url="data:image/png;base64,AA=="), + document=ImageUrlDocument(type="image_url", image_url="data:image/png;base64,AA=="), vertex_project="project-1", ), AzureDocumentIntelligenceOcrSdkInput( model="azure_ai/doc-intelligence/prebuilt-layout", - document=MistralImageUrlDocument(type="image_url", image_url="data:image/png;base64,AA=="), + document=ImageUrlDocument(type="image_url", image_url="data:image/png;base64,AA=="), ), VertexDeepSeekOcrSdkInput( - document=MistralImageUrlDocument(type="image_url", image_url="data:image/png;base64,AA=="), + document=ImageUrlDocument(type="image_url", image_url="data:image/png;base64,AA=="), vertex_project="project-1", ), ), @@ -122,15 +128,15 @@ def test_mistral_input_preserves_omission_and_explicit_boolean_values() -> None: def test_mistral_input_supports_document_and_page_variants() -> None: nested_image: Final = MistralOcrSdkInput( model="mistral/mistral-ocr-4-1", - document=MistralImageUrlDocument( + document=ImageUrlDocument( type="image_url", - image_url=MistralImageUrlValue(url="https://example.com/image.png", detail="high"), + image_url=ImageUrlValue(url="https://example.com/image.png", detail="high"), ), pages="0,2-4", ) named_document: Final = MistralOcrSdkInput( model="mistral/mistral-ocr-2512", - document=MistralDocumentUrlDocument( + document=DocumentUrlDocument( type="document_url", document_url="https://example.com/document.pdf", document_name="invoice.pdf", @@ -186,7 +192,7 @@ def test_unqualified_models_require_explicit_provider() -> None: with pytest.raises(ValidationError, match="custom_llm_provider='mistral'"): MistralOcrSdkInput( model="mistral-ocr-latest", - document=MistralImageUrlDocument(type="image_url", image_url="https://example.com/image.png"), + document=ImageUrlDocument(type="image_url", image_url="https://example.com/image.png"), ) with pytest.raises(ValidationError, match="custom_llm_provider='reducto'"): ReductoParseV3SdkInput(model="parse-v3", document=_reducto_document()) diff --git a/tests/test_litellm/ocr/test_record_fixtures.py b/tests/test_litellm/ocr/test_record_fixtures.py index dfd66ac1248..683511adf74 100644 --- a/tests/test_litellm/ocr/test_record_fixtures.py +++ b/tests/test_litellm/ocr/test_record_fixtures.py @@ -9,7 +9,7 @@ import pytest from tests.route_parity.fixtures.inputs import generate_case_inputs from tests.route_parity.fixtures.pipeline import parse_recording_args -from tests.test_litellm.ocr.fixtures.models import OcrSdkInputBase +from tests.test_litellm.ocr.fixtures.base import OcrSdkInputBase from tests.test_litellm.ocr.fixtures.record import ( discover_targets, require_targets,