You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Proposal: Text Extraction Capability (OCR / Document Parsing)
Summary
Add a first-class, provider-agnostic text extraction capability to the PHP AI Client, so that provider packages can register document-parsing/OCR models the same way they register text generation and image generation models today.
The design is validated against five concrete provider APIs with very different shapes:
DetectDocumentText / AnalyzeDocument + Start/Get jobs
both
flat Blocks[] graph (bboxes, tables, KV)
Researched with AI (official docs)
Qwen2.5-VL / Qwen3-VL / qwen-vl-ocr
chat completions with image input
sync
prompt-shaped text (md/HTML/LaTeX/JSON)
Researched with AI (official docs)
The goal is that each of these can ship as an independent connector package implementing one shared interface, exactly as ai-provider-for-mistral does for text generation today.
Motivation
Document text extraction is one of the most common AI tasks in CMS contexts (ingesting PDFs into posts, indexing uploads for search/RAG, accessibility). Today the SDK cannot model it:
CapabilityEnum has no extraction-shaped value; ProviderRegistry::findModelsMetadataForSupport() therefore can never discover such models.
Extraction results (pages, markdown, bounding boxes, extracted images, page counts) do not fit the GenerativeAiResult envelope (candidates + finish reasons + messages). Flattening a multi-page structured response into a single model-message candidate loses the structure that is the entire point of dedicated OCR endpoints.
The prompt paradigm doesn't fit: the primary input is a document, not a message list, and options like page ranges have no home in PromptBuilder.
Downstream provider plugins are blocked on this (e.g. ai-provider-for-mistral cannot expose mistral-ocr-latest); the alternative, provider-specific side APIs outside the SDK, defeats the SDK's core value of capability-agnostic model discovery.
Why not just send the file to a text generation model?
The SDK can already attach a document to a chat prompt (inputModalities: [document]) and ask an LLM to "transcribe this". That path is a complement, not a substitute, because dedicated extraction endpoints are fundamentally more reliable for getting a document's actual text out:
Hallucination. A generative model produces text conditioned on the document; nothing constrains its output to be a faithful transcription. On long PDFs especially, LLMs are known to silently skip pages, paraphrase, "fix" numbers and names, or fabricate plausible-looking content for low-quality scans. Extraction pipelines transcribe what is on the page, and several (Google, AWS, optionally Mistral) attach per-word/per-block confidence scores so uncertainty is reported instead of papered over.
Length limits. A long PDF quickly exhausts a chat model's context window, and output token limits cap how much text can come back in one completion. Extraction endpoints process documents page by page (Mistral OCR: up to ~1,000 pages per request; Textract/Document AI batch: thousands of pages) with no prompt-window coupling.
Determinism and structure. Extraction returns machine-verifiable structure, page indices, bounding boxes, tables, embedded images, that a free-text completion cannot guarantee and that flattening into a chat message destroys.
Cost. Per-page extraction pricing (e.g. $4/1,000 pages for Mistral OCR) is orders of magnitude cheaper at document scale than paying per input+output token to have an LLM re-emit an entire document.
For ingestion pipelines (search indexing, RAG, archiving, accessibility), where fidelity to the source is the requirement, extraction is the correct tool, and the SDK currently cannot express it. Document QnA via chat remains the right tool for reasoning about a document, and the two compose: extract first, then feed clean markdown to a text generation model.
Completing the RAG pipeline with embedding generation
The SDK gained embedding generation in 1.4.0, but embedding models take text (list<MessagePart>), they cannot read a PDF. Text extraction is the missing first stage of that pipeline: without it, a consumer who wants to embed their document library must leave the SDK to get the text out, which reintroduces exactly the per-provider integration work the SDK exists to remove.
With this capability the full document-to-vector flow becomes SDK-native and provider-agnostic:
$result = AiClient::document($pdfUrl)->extractTextResult();
foreach ($result->getPages() as$page) {
$embedding = AiClient::input($page->getMarkdown())->generateEmbedding();
// store vector with page number for citation-accurate retrieval
}
The extraction result is also a better embedding input than any ad-hoc alternative: per-page (and optionally per-block) segmentation gives natural chunk boundaries that respect document structure instead of arbitrary character offsets; markdown preserves headings and tables that improve embedding quality; and page numbers/bounding boxes carried alongside each chunk enable citation-accurate retrieval, the retrieved passage can point back to the exact page and region of the source document. Layout-aware chunking is precisely what Google's Layout Parser markets itself for in RAG contexts; this proposal makes that pattern available uniformly across providers.
Precedent: embedding generation (1.4.0) faced the same mismatch and established the pattern this proposal follows: a new capability value, a standalone model interface, a dedicated result DTO implementing ResultInterface, a separate builder (AiClient::input() → EmbeddingBuilder), a dedicated ModelRequirements factory, and its own events. Text extraction is the same kind of "not prompt-shaped" capability.
Naming
Recommend TEXT_EXTRACTION = 'text_extraction' over OCR:
"OCR" implies raster input; LlamaParse and Document AI Layout Parser parse born-digital DOCX/PPTX/HTML with no optical step.
It parallels existing verb-object names (text_generation, image_generation, embedding_generation).
It leaves room for the same capability to cover future "document parsing" providers without a misleading name.
(Naming is genuinely bikesheddable; DOCUMENT_TEXT_EXTRACTION is the verbose alternative. The rest of this proposal uses TEXT_EXTRACTION.)
Design principles (derived from the API survey)
Input is a File. The existing File DTO already models the two transport forms every provider accepts in its sync path: remote URL and inline base64 (Mistral document_url/data-URI, LlamaParse source_url/upload, Google rawDocument.content, Textract Document.Bytes, Qwen image_url/data-URI). Provider-side file references (S3/GCS/pre-uploaded file IDs) are provider implementation details, reachable via customOptions in v1.
Normalize to pages of markdown; keep structure optional. Every provider has a per-page notion and can produce text per page. Markdown is the richest common text format (Mistral and LlamaParse emit it natively; Google/Textract adapters synthesize it from blocks; Qwen is prompted into it). Bounding boxes, tables, and extracted images exist only in some providers, so they are optional fields, and model metadata declares what a model can do via supported options.
Never discard provider fidelity. The raw decoded provider payload rides along in additionalData, so consumers who need Textract Blocks relationships or the full Document AI JSON aren't blocked by the normalization.
Sync interface first; async as a follow-up that reuses the existing operations track. Mistral and Qwen are sync; Google and Textract have sync paths with limits; LlamaParse is async-only but short-lived (an adapter can poll internally). A TextExtractionOperationModelInterface mirroring the existing generateXOperation() pattern is specified but explicitly deferred (the operations track has no concrete implementation in-tree yet).
Auth stays out of the capability. Google needs OAuth service accounts and AWS needs SigV4, neither fits RequestAuthenticationMethod::apiKey(). That is an orthogonal, pre-existing gap tracked in Support provider-owned custom request authentication #237 (provider-owned custom request authentication); this proposal depends on it only for those two connectors, not for the capability itself.
Proposed API
1. Capability enum value
src/Providers/Models/Enums/CapabilityEnum.php, add one constant plus the magic-method docblock lines (no factory body needed; AbstractEnum::__callStatic provides textExtraction() and isTextExtraction() automatically):
* @method static self textExtraction() Creates an instance forTEXT_EXTRACTION capability.
* @method bool isTextExtraction() Checks if the capability is TEXT_EXTRACTION.
ProviderRegistry discovery works with zero further changes, ModelRequirements::areMetBy() is capability-generic.
2. Model interface
src/Providers/Models/TextExtraction/Contracts/TextExtractionModelInterface.php (standalone interface, like all capability contracts; concrete models also implement ModelInterface via AbstractApiBasedModel):
interface TextExtractionModelInterface
{
/** * Extracts text and structure from a document. * * Extraction options (page selection, image/bounding-box inclusion, * output format) are provided via the model's ModelConfig, consistent * with how generation options are handled. * * @since n.e.x.t * * @param File $document The document to process (remote URL or inline data). * @return TextExtractionResult The structured extraction result. */publicfunctionextractTextResult(File$document): TextExtractionResult;
}
Options ride on ModelConfig (house style, capability interfaces take only the payload; setConfig() carries everything else), which also makes them discoverable/matchable through SupportedOption metadata.
3. Result DTOs
Following the EmbeddingResult precedent: a dedicated result implementing ResultInterface (NOT candidate-based), living in src/Results/DTO/. All DTOs extend AbstractDataTransferObject with KEY_* constants, toArray()/fromArray()/getJsonSchema(), and deep __clone.
class TextExtractionResult implements ResultInterface
{
publicfunctiongetId(): string;
/** @return list<ExtractedPage> */publicfunctiongetPages(): array;
publicfunctiongetPageCount(): int; // pages processed (billing-relevant for per-page providers)publicfunctiongetTokenUsage(): TokenUsage; // zeros for page-priced providers; real for VLM-based extractionpublicfunctiongetProviderMetadata(): ProviderMetadata;
publicfunctiongetModelMetadata(): ModelMetadata;
publicfunctiongetAdditionalData(): array; // MUST include the raw provider payload under 'raw'publicfunctiontoText(): string; // convenience: all pages' markdown joinedpublicfunctiontoMarkdown(): string; // alias emphasizing format
}
class ExtractedPage extends AbstractDataTransferObject
{
publicfunctiongetPageNumber(): int; // 1-based, normalized across providerspublicfunctiongetMarkdown(): string; // markdown (may be plain text for text-only providers)/** @return list<ExtractedBlock> */publicfunctiongetBlocks(): array; // optional; empty when unsupported/not requested/** @return list<ExtractedImage> */publicfunctiongetImages(): array; // optional; empty when unsupported/not requestedpublicfunctiongetDimensions(): ?PageDimensions; // null when the provider doesn't report them
}
class ExtractedBlock extends AbstractDataTransferObject
{
publicfunctiongetType(): TextExtractionBlockTypeEnum; // PARAGRAPH | HEADING | TABLE | LIST | IMAGE | OTHERpublicfunctiongetText(): string;
publicfunctiongetBoundingBox(): ?BoundingBox;
publicfunctiongetConfidence(): ?float; // 0–1; null when the provider doesn't score
}
class ExtractedImage extends AbstractDataTransferObject
{
publicfunctiongetId(): string;
publicfunctiongetFile(): ?File; // inline base64 File when returned, else nullpublicfunctiongetBoundingBox(): ?BoundingBox;
}
class BoundingBox extends AbstractDataTransferObject
{
// Normalized coordinates in the 0–1 range, origin top-left.// Rationale: Textract is natively normalized; Google provides both;// pixel-native providers (Mistral) divide by page dimensions, which// they always return. Pixel values are recoverable via PageDimensions.publicfunctiongetLeft(): float;
publicfunctiongetTop(): float;
publicfunctiongetWidth(): float;
publicfunctiongetHeight(): float;
}
class PageDimensions extends AbstractDataTransferObject
{
publicfunctiongetWidth(): int; // pixelspublicfunctiongetHeight(): int; // pixelspublicfunctiongetDpi(): ?int;
}
TokenUsage note: ResultInterface requires it; page-priced providers return new TokenUsage(0, 0, 0) and the meaningful unit is getPageCount(). VLM-based connectors (Qwen) populate real token counts. Both are honest; neither overloads the other's field.
4. ModelConfig / OptionEnum additions
OptionEnum reflects ModelConfig::KEY_* constants automatically, so adding config keys is the entire change. Proposed minimal set:
outputMimeType, text/markdown (default) vs text/plain; a connector for a provider with HTML table output could advertise text/html.
outputSchema, schema-driven structured extraction (Mistral document_annotation_format with JSON schema, Textract QUERIES/FORMS mapping, Document AI custom extractors, qwen-vl-ocrkey_information_extraction.result_schema). A model advertising SupportedOption(OptionEnum::outputSchema()) under this capability means "can return schema-shaped extraction", the result's additionalData['structuredData'] carries it in v1.
inputModalities, declares what documents a model accepts: [document], [image], or both. This is how the Qwen connector honestly declares images-only (no native PDF input; rasterization is out of scope), while Mistral/LlamaParse/Google declare [document, image].
customOptions, escape hatch for provider-specific knobs (Mistral image_min_size, LlamaParse tier, Textract FeatureTypes, Google processor selection, DashScope ocr_options.task).
5. Fluent API
Text extraction gets its own builder, exactly as embeddings did (per ARCHITECTURE.md's rationale: prompt-oriented parameters don't apply).
builds requirements via a new ModelRequirements::fromExtractionData(File $document, ModelConfig $config): capability textExtraction(), RequiredOption(inputModalities, [document|image]) chosen from the file's MIME type, plus required options for any set config keys;
validates the resolved model instanceof TextExtractionModelInterface and throws the standard 'Model "%s" does not support text extraction.' otherwise;
dispatches BeforeExtractTextEvent / AfterExtractTextEvent (mirroring the embedding events);
exposes isSupported(): bool for feature detection.
6. Abstract base class
src/Providers/ApiBasedImplementation/AbstractApiBasedTextExtractionModel.php, mirrors the existing OpenAI-compatible generation bases: holds metadata/config, template method flow prepareRequest(File): Request → authenticate → send → throwIfNotSuccessful() → parseResponseToTextExtractionResult(Response): TextExtractionResult. Concrete connectors override the two abstract ends.
Unlike text generation there is no dominant wire format to ship a shared "compatible" implementation for, each connector implements its own request/parse pair. That is fine; the shared value is in the result normalization and discovery, not the HTTP shape.
7. Async operations (specified, deferred)
TextExtractionOperationModelInterface::extractTextOperation(File $document): TextExtractionOperation plus a TextExtractionOperation DTO (id, OperationStateEnum, ?TextExtractionResult), mirroring the existing generateXOperation() contracts and GenerativeAiOperation. Deferred because the operations track has no concrete provider-side implementation or builder exposure anywhere in the SDK yet; text extraction shouldn't be the pioneer.
Until then, async-only providers poll internally behind the sync interface with a configurable timeout (LlamaParse jobs on typical documents complete in seconds; this is what its own SDKs do). Connectors SHOULD expose the timeout via customOptions and throw a clear RuntimeException on expiry.
Connector mapping
How each target provider implements TextExtractionModelInterface:
Concern
Mistral OCR
LlamaParse
Google Document AI
AWS Textract
Qwen-VL / qwen-vl-ocr
Request
POST /v1/ocr with document_url/data-URI
upload or source_url → POST /api/v2/parse → poll GET /api/v2/parse/{id}
processors/{id}:process with base64 rawDocument
AnalyzeDocument/DetectDocumentText with Document.Bytes
synthesize markdown from pages[].blocks/paragraphs + textAnchor
synthesize from LINE/LAYOUT_* blocks grouped by Page
one page per input image; text as returned
Blocks/bboxes
include_blocks paragraph bboxes (pixel → normalize by dimensions)
layout items
boundingPoly (already normalized)
Geometry.BoundingBox (already normalized)
only qwen-vl-ocradvanced_recognition (rotated rects → axis-aligned approximation, or omit)
Images
include_image_base64 → ExtractedImage
image download URLs → fetch → inline File
none (empty)
none (empty)
none (empty)
Model discovery
GET /v1/models (mistral-ocr-*)
fixed tier list, hardcoded metadata
list processors as "models"
single hardcoded model entry (+ adapters later)
hardcoded / GET /v1/models on self-hosted
Usage
usage_info.pages_processed → getPageCount()
page count from result
pages length
DocumentMetadata.Pages
real TokenUsage from completion
The Qwen connector is the important stress test: it proves the capability is about the contract, not the transport, a chat-completions-backed extractor and a dedicated-endpoint extractor are interchangeable to consumers, which is precisely the provider-agnostic philosophy. It also demonstrates why inputModalities must be declarable per model ([image] only).
Proof of concept
The proposed API has been implemented end to end and validated against two live providers with intentionally different API shapes:
SDK capability, saarnilauri/php-ai-client @ feature/text-extraction-poc (fork of this repo): CapabilityEnum::TEXT_EXTRACTION, TextExtractionModelInterface, the result DTOs (TextExtractionResult, ExtractedPage, ExtractedImage, BoundingBox, PageDimensions), ModelRequirements::fromExtractionData(), TextExtractionBuilder, and the AiClient::document() / extractTextResult() / extractText() entry points, with unit tests.
Sync connector, saarnilauri/ai-provider-for-mistral @ feature/text-extraction-poc: Mistral's dedicated POST /v1/ocr endpoint. Covers URL and inline (data URI) input, page selection and other options via custom-options passthrough, pixel→normalized bounding box conversion, and embedded image decoding.
Async connector, saarnilauri/ai-provider-for-llamaparse (main): LlamaParse's job-based API (upload → poll → fetch result) hidden behind the synchronous interface with internal polling, multipart file upload, and hardcoded model metadata (LlamaParse has no model-list endpoint, parsing modes are exposed as model entries).
Both connectors pass the same integration test suite against the real APIs, capability discovery, multi-page remote PDF extraction, local PDF upload, and local image extraction, with only the provider ID differing between the suites, which demonstrates the provider-agnostic claim in practice.
Running the PoC locally
The connector packages consume the SDK branch via a Composer path repository with a temporary version pin:
This means the three checkouts must be sibling directories (e.g. dev/php-ai-client, dev/ai-provider-for-mistral, dev/ai-provider-for-llamaparse), with the SDK checkout on the feature/text-extraction-poc branch. The pin is PoC-only and reverts to a released version constraint once the capability ships in the SDK.
Integration tests require MISTRAL_API_KEY / LLAMAPARSE_API_KEY in each connector's .env and are run with composer test:integration. Extracted markdown and embedded images are written to tests/integration/extractions/ for inspection.
Findings from the PoC
The embedding-generation pattern (separate builder + dedicated result type) transferred cleanly; no changes to ProviderRegistry, ModelResolver, or option matching were needed beyond the new requirements factory.
MIME inference needs an explicit escape hatch: extensionless document URLs (e.g. https://arxiv.org/pdf/1805.04770) cannot be typed automatically, which is why withDocument() accepts an optional $mimeType.
Providers differ in how strictly they validate declared vs. actual file content: Mistral silently accepted an AVIF image mislabeled as PNG, while LlamaParse hard-failed the parsing job. Content sniffing (magic bytes) in the File DTO would harden this for all capabilities.
Async-behind-sync polling is workable for LlamaParse-scale jobs (seconds to ~1 minute for a 15-page PDF), supporting the decision to defer the operation interface.
Out of scope (v1)
PDF rasterization for image-only models (Qwen), consumer or connector concern; the model honestly declares inputModalities: [image].
First-class table / key-value DTOs, tables arrive as markdown/HTML inside page text (all providers can deliver that); a structured ExtractedTable normalization across Textract CELL graphs, Document AI pages.tables, and LlamaParse items is a follow-up once two connectors ship.
First-class structured annotation results, reuse outputSchema + additionalData['structuredData'] in v1; promote to a typed result field later.
Reference connector: ai-provider-for-mistral registers mistral-ocr-latest (simplest API: sync, bearer auth, markdown-native, list-models discovery), proves the contract end to end.
Second connector, different shape: LlamaParse (async-only, no model listing), validates internal polling and hardcoded metadata; then Qwen (chat-transport), validates the VLM path.
All additions use @since n.e.x.t, PHP 7.4-compatible code, and are purely additive (no BC breaks).
Open questions
Capability name: text_extraction (recommended) vs ocr vs document_text_extraction?
Builder entry point name: AiClient::document() (recommended, parallels AiClient::input()) vs AiClient::extract()?
Should ExtractedPage::getMarkdown() be named getText() with format governed by outputMimeType, to avoid baking a format into the API name?
Should pageCount live on TextExtractionResult (proposed) or inside TokenUsage as a new nullable field usable by other per-unit-priced capabilities?
Multi-document batching in one call (Document AI batch, Textract async), out of scope, or should the interface accept list<File> from day one? (Proposed: single File; batching via multiple calls.)
This proposal and the proof-of-concept implementations were drafted with the assistance of Claude Code (Anthropic), used for researching the provider APIs, analyzing the SDK architecture, and writing code and prose. All work was done with a human in the loop: the design direction, scope decisions, and API trade-offs were made or reviewed by the author, and the proof of concept was verified by the author against the live Mistral and LlamaParse APIs (including real integration test runs and inspection of the extracted output).
Proposal: Text Extraction Capability (OCR / Document Parsing)
Summary
Add a first-class, provider-agnostic text extraction capability to the PHP AI Client, so that provider packages can register document-parsing/OCR models the same way they register text generation and image generation models today.
The design is validated against five concrete provider APIs with very different shapes:
mistral-ocr-*)POST /v1/ocr/api/v1/parsing/*; v2 exists):process/:batchProcessDocumentJSON (blocks, anchors, bboxes)DetectDocumentText/AnalyzeDocument+ Start/Get jobsBlocks[]graph (bboxes, tables, KV)qwen-vl-ocrThe goal is that each of these can ship as an independent connector package implementing one shared interface, exactly as
ai-provider-for-mistraldoes for text generation today.Motivation
Document text extraction is one of the most common AI tasks in CMS contexts (ingesting PDFs into posts, indexing uploads for search/RAG, accessibility). Today the SDK cannot model it:
CapabilityEnumhas no extraction-shaped value;ProviderRegistry::findModelsMetadataForSupport()therefore can never discover such models.GenerativeAiResultenvelope (candidates + finish reasons + messages). Flattening a multi-page structured response into a single model-message candidate loses the structure that is the entire point of dedicated OCR endpoints.PromptBuilder.Downstream provider plugins are blocked on this (e.g.
ai-provider-for-mistralcannot exposemistral-ocr-latest); the alternative, provider-specific side APIs outside the SDK, defeats the SDK's core value of capability-agnostic model discovery.Why not just send the file to a text generation model?
The SDK can already attach a document to a chat prompt (
inputModalities: [document]) and ask an LLM to "transcribe this". That path is a complement, not a substitute, because dedicated extraction endpoints are fundamentally more reliable for getting a document's actual text out:For ingestion pipelines (search indexing, RAG, archiving, accessibility), where fidelity to the source is the requirement, extraction is the correct tool, and the SDK currently cannot express it. Document QnA via chat remains the right tool for reasoning about a document, and the two compose: extract first, then feed clean markdown to a text generation model.
Completing the RAG pipeline with embedding generation
The SDK gained embedding generation in 1.4.0, but embedding models take text (
list<MessagePart>), they cannot read a PDF. Text extraction is the missing first stage of that pipeline: without it, a consumer who wants to embed their document library must leave the SDK to get the text out, which reintroduces exactly the per-provider integration work the SDK exists to remove.With this capability the full document-to-vector flow becomes SDK-native and provider-agnostic:
The extraction result is also a better embedding input than any ad-hoc alternative: per-page (and optionally per-block) segmentation gives natural chunk boundaries that respect document structure instead of arbitrary character offsets; markdown preserves headings and tables that improve embedding quality; and page numbers/bounding boxes carried alongside each chunk enable citation-accurate retrieval, the retrieved passage can point back to the exact page and region of the source document. Layout-aware chunking is precisely what Google's Layout Parser markets itself for in RAG contexts; this proposal makes that pattern available uniformly across providers.
Precedent: embedding generation (1.4.0) faced the same mismatch and established the pattern this proposal follows: a new capability value, a standalone model interface, a dedicated result DTO implementing
ResultInterface, a separate builder (AiClient::input()→EmbeddingBuilder), a dedicatedModelRequirementsfactory, and its own events. Text extraction is the same kind of "not prompt-shaped" capability.Naming
Recommend
TEXT_EXTRACTION = 'text_extraction'overOCR:text_generation,image_generation,embedding_generation).(Naming is genuinely bikesheddable;
DOCUMENT_TEXT_EXTRACTIONis the verbose alternative. The rest of this proposal usesTEXT_EXTRACTION.)Design principles (derived from the API survey)
File. The existingFileDTO already models the two transport forms every provider accepts in its sync path: remote URL and inline base64 (Mistraldocument_url/data-URI, LlamaParsesource_url/upload, GooglerawDocument.content, TextractDocument.Bytes, Qwenimage_url/data-URI). Provider-side file references (S3/GCS/pre-uploaded file IDs) are provider implementation details, reachable viacustomOptionsin v1.additionalData, so consumers who need TextractBlocksrelationships or the full Document AI JSON aren't blocked by the normalization.TextExtractionOperationModelInterfacemirroring the existinggenerateXOperation()pattern is specified but explicitly deferred (the operations track has no concrete implementation in-tree yet).RequestAuthenticationMethod::apiKey(). That is an orthogonal, pre-existing gap tracked in Support provider-owned custom request authentication #237 (provider-owned custom request authentication); this proposal depends on it only for those two connectors, not for the capability itself.Proposed API
1. Capability enum value
src/Providers/Models/Enums/CapabilityEnum.php, add one constant plus the magic-method docblock lines (no factory body needed;AbstractEnum::__callStaticprovidestextExtraction()andisTextExtraction()automatically):ProviderRegistrydiscovery works with zero further changes,ModelRequirements::areMetBy()is capability-generic.2. Model interface
src/Providers/Models/TextExtraction/Contracts/TextExtractionModelInterface.php(standalone interface, like all capability contracts; concrete models also implementModelInterfaceviaAbstractApiBasedModel):Options ride on
ModelConfig(house style, capability interfaces take only the payload;setConfig()carries everything else), which also makes them discoverable/matchable throughSupportedOptionmetadata.3. Result DTOs
Following the
EmbeddingResultprecedent: a dedicated result implementingResultInterface(NOT candidate-based), living insrc/Results/DTO/. All DTOs extendAbstractDataTransferObjectwithKEY_*constants,toArray()/fromArray()/getJsonSchema(), and deep__clone.TokenUsagenote:ResultInterfacerequires it; page-priced providers returnnew TokenUsage(0, 0, 0)and the meaningful unit isgetPageCount(). VLM-based connectors (Qwen) populate real token counts. Both are honest; neither overloads the other's field.4. ModelConfig / OptionEnum additions
OptionEnumreflectsModelConfig::KEY_*constants automatically, so adding config keys is the entire change. Proposed minimal set:Deliberately reused rather than duplicated:
outputMimeType,text/markdown(default) vstext/plain; a connector for a provider with HTML table output could advertisetext/html.outputSchema, schema-driven structured extraction (Mistraldocument_annotation_formatwith JSON schema, Textract QUERIES/FORMS mapping, Document AI custom extractors,qwen-vl-ocrkey_information_extraction.result_schema). A model advertisingSupportedOption(OptionEnum::outputSchema())under this capability means "can return schema-shaped extraction", the result'sadditionalData['structuredData']carries it in v1.inputModalities, declares what documents a model accepts:[document],[image], or both. This is how the Qwen connector honestly declares images-only (no native PDF input; rasterization is out of scope), while Mistral/LlamaParse/Google declare[document, image].customOptions, escape hatch for provider-specific knobs (Mistralimage_min_size, LlamaParsetier, TextractFeatureTypes, Google processor selection, DashScopeocr_options.task).5. Fluent API
Text extraction gets its own builder, exactly as embeddings did (per ARCHITECTURE.md's rationale: prompt-oriented parameters don't apply).
AiClientadditions:src/Builders/TextExtractionBuilder.php:The builder:
File|string(URL, data URI, or local path,Filealready normalizes all three);ModelResolutionTrait(usingModel(),usingProvider(),usingModelPreference(), …) unchanged;ModelRequirements::fromExtractionData(File $document, ModelConfig $config): capabilitytextExtraction(),RequiredOption(inputModalities, [document|image])chosen from the file's MIME type, plus required options for any set config keys;instanceof TextExtractionModelInterfaceand throws the standard'Model "%s" does not support text extraction.'otherwise;BeforeExtractTextEvent/AfterExtractTextEvent(mirroring the embedding events);isSupported(): boolfor feature detection.6. Abstract base class
src/Providers/ApiBasedImplementation/AbstractApiBasedTextExtractionModel.php, mirrors the existing OpenAI-compatible generation bases: holds metadata/config, template method flowprepareRequest(File): Request→ authenticate → send →throwIfNotSuccessful()→parseResponseToTextExtractionResult(Response): TextExtractionResult. Concrete connectors override the two abstract ends.Unlike text generation there is no dominant wire format to ship a shared "compatible" implementation for, each connector implements its own request/parse pair. That is fine; the shared value is in the result normalization and discovery, not the HTTP shape.
7. Async operations (specified, deferred)
TextExtractionOperationModelInterface::extractTextOperation(File $document): TextExtractionOperationplus aTextExtractionOperationDTO (id,OperationStateEnum,?TextExtractionResult), mirroring the existinggenerateXOperation()contracts andGenerativeAiOperation. Deferred because the operations track has no concrete provider-side implementation or builder exposure anywhere in the SDK yet; text extraction shouldn't be the pioneer.Until then, async-only providers poll internally behind the sync interface with a configurable timeout (LlamaParse jobs on typical documents complete in seconds; this is what its own SDKs do). Connectors SHOULD expose the timeout via
customOptionsand throw a clearRuntimeExceptionon expiry.Connector mapping
How each target provider implements
TextExtractionModelInterface:POST /v1/ocrwithdocument_url/data-URIsource_url→POST /api/v2/parse→ pollGET /api/v2/parse/{id}processors/{id}:processwith base64rawDocumentAnalyzeDocument/DetectDocumentTextwithDocument.Bytesocr_optionsExtractedPagepages[].markdownexpand=markdown,itemsper pagepages[].blocks/paragraphs+textAnchorLINE/LAYOUT_*blocks grouped byPageinclude_blocksparagraph bboxes (pixel → normalize bydimensions)itemsboundingPoly(already normalized)Geometry.BoundingBox(already normalized)qwen-vl-ocradvanced_recognition(rotated rects → axis-aligned approximation, or omit)include_image_base64→ExtractedImageFileGET /v1/models(mistral-ocr-*)GET /v1/modelson self-hostedusage_info.pages_processed→getPageCount()pageslengthDocumentMetadata.PagesTokenUsagefrom completionThe Qwen connector is the important stress test: it proves the capability is about the contract, not the transport, a chat-completions-backed extractor and a dedicated-endpoint extractor are interchangeable to consumers, which is precisely the provider-agnostic philosophy. It also demonstrates why
inputModalitiesmust be declarable per model ([image]only).Proof of concept
The proposed API has been implemented end to end and validated against two live providers with intentionally different API shapes:
saarnilauri/php-ai-client@feature/text-extraction-poc(fork of this repo):CapabilityEnum::TEXT_EXTRACTION,TextExtractionModelInterface, the result DTOs (TextExtractionResult,ExtractedPage,ExtractedImage,BoundingBox,PageDimensions),ModelRequirements::fromExtractionData(),TextExtractionBuilder, and theAiClient::document()/extractTextResult()/extractText()entry points, with unit tests.saarnilauri/ai-provider-for-mistral@feature/text-extraction-poc: Mistral's dedicatedPOST /v1/ocrendpoint. Covers URL and inline (data URI) input, page selection and other options via custom-options passthrough, pixel→normalized bounding box conversion, and embedded image decoding.saarnilauri/ai-provider-for-llamaparse(main): LlamaParse's job-based API (upload → poll → fetch result) hidden behind the synchronous interface with internal polling, multipart file upload, and hardcoded model metadata (LlamaParse has no model-list endpoint, parsing modes are exposed as model entries).Both connectors pass the same integration test suite against the real APIs, capability discovery, multi-page remote PDF extraction, local PDF upload, and local image extraction, with only the provider ID differing between the suites, which demonstrates the provider-agnostic claim in practice.
Running the PoC locally
The connector packages consume the SDK branch via a Composer path repository with a temporary version pin:
This means the three checkouts must be sibling directories (e.g.
dev/php-ai-client,dev/ai-provider-for-mistral,dev/ai-provider-for-llamaparse), with the SDK checkout on thefeature/text-extraction-pocbranch. The pin is PoC-only and reverts to a released version constraint once the capability ships in the SDK.Integration tests require
MISTRAL_API_KEY/LLAMAPARSE_API_KEYin each connector's.envand are run withcomposer test:integration. Extracted markdown and embedded images are written totests/integration/extractions/for inspection.Findings from the PoC
ProviderRegistry,ModelResolver, or option matching were needed beyond the new requirements factory.https://arxiv.org/pdf/1805.04770) cannot be typed automatically, which is whywithDocument()accepts an optional$mimeType.FileDTO would harden this for all capabilities.Out of scope (v1)
inputModalities: [image].ExtractedTablenormalization across Textract CELL graphs, Document AIpages.tables, and LlamaParse items is a follow-up once two connectors ship.outputSchema+additionalData['structuredData']in v1; promote to a typed result field later.customOptionsin v1.Rollout plan
CapabilityEnum::TEXT_EXTRACTION,TextExtractionModelInterface, result DTOs,ModelConfig/OptionEnumkeys,ModelRequirements::fromExtractionData(),TextExtractionBuilder,AiClient::document()/extractTextResult()/extractText(), events,AbstractApiBasedTextExtractionModel, unit tests (DTOfromArray(toArray($x))roundtrips, builder validation, requirements matching, JSON schemas).ai-provider-for-mistralregistersmistral-ocr-latest(simplest API: sync, bearer auth, markdown-native, list-models discovery), proves the contract end to end.All additions use
@since n.e.x.t, PHP 7.4-compatible code, and are purely additive (no BC breaks).Open questions
text_extraction(recommended) vsocrvsdocument_text_extraction?AiClient::document()(recommended, parallelsAiClient::input()) vsAiClient::extract()?ExtractedPage::getMarkdown()be namedgetText()with format governed byoutputMimeType, to avoid baking a format into the API name?pageCountlive onTextExtractionResult(proposed) or insideTokenUsageas a new nullable field usable by other per-unit-priced capabilities?list<File>from day one? (Proposed: singleFile; batching via multiple calls.)References
/api/v2/parse)Documentformat)AnalyzeDocument, Blocks)qwen-vl-ocr, compatible-mode chat completions)$builder->with_file()method is not working. #165 (document chunks in chat, complementary: chat QnA loses page structure), Implement proper multimodal output model classes #160 (multimodal output models), Support provider-owned custom request authentication #237 (custom request authentication, prerequisite for Google/AWS connectors), Expose model capabilities (input/output types) #226 (exposing model capabilities)EmbeddingResult,EmbeddingBuilder,ModelRequirements::fromEmbeddingData()mistral-ocr-latest)Use of AI Tools
This proposal and the proof-of-concept implementations were drafted with the assistance of Claude Code (Anthropic), used for researching the provider APIs, analyzing the SDK architecture, and writing code and prose. All work was done with a human in the loop: the design direction, scope decisions, and API trade-offs were made or reviewed by the author, and the proof of concept was verified by the author against the live Mistral and LlamaParse APIs (including real integration test runs and inspection of the extracted output).