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
[API Proposal]: IDocumentExtractionClient — a document-extraction capability as its own peer library (Microsoft.Extensions.DocumentExtraction)
Updates
2026-07-30 — Moved out of Microsoft.Extensions.AI into its own peer library, Microsoft.Extensions.DocumentExtraction. Per review steer, document extraction is its own domain (like VectorData / DataIngestion), not an IChatClient sibling inside M.E.AI: it pulls structured content out of documents, complementing DataIngestion, which feeds content into RAG. The capability keeps the M.E.AI building-block shape (abstraction + delegating base + builder + logging/OpenTelemetry/configure-options middleware + DI) and references Microsoft.Extensions.AI.Abstractions internally (e.g. DataContent) without carrying the "AI" brand. This absorbs the shared-model + family-rename tracks below (both now resolved by the move) and lets a provider team (e.g. Azure AI Document Intelligence) own an IDocumentExtractionClient impl without an "AI" branding dependency. Naming flips from the M.E.AI-internal verb scheme (SpeechToText) to the peers' domain-noun convention: a neutral Document* content model + DocumentExtraction* operation/client types. New diagnostic id MEDE0001 (peer precedent: VectorData MEVD9001). The extraction builds green as a straw-man (both new packages + M.E.AI(.Abstractions) with OCR removed + the DataIngestion consumer + both new test projects, 60 tests). The speclet below reflects the new Document* content model and DocumentExtraction* operation/client names, with public-API baselines regenerated across all five TFMs (netstandard2.0, net462, net8/9/10). Methods (ExtractAsync/ExtractPagesAsync/GetService/AsBuilder) are unchanged. The surface stays one [Experimental] unit so a review-driven name change remains a single mechanical re-rename.
2026-07-23 — Pre-review spikes: per-page coordinate model, selective promotes, geometry primitives and GetService kept. SPIKE-07/08 moved DocumentCoordinateUnit / DocumentCoordinateOrigin back onto DocumentPage (reported per page, not per document — engines emit different units for different pages in mixed image/PDF batches, per Google Page.Dimension and Azure DI DocumentPage.Unit), and grouped DocumentPage.Width / Height into a new DocumentPageDimensions value type. SPIKE-06's 14-engine raw-output inventory kept RawRepresentation / AdditionalProperties and promoted per-cell BoundingRegion / Confidence / RawRepresentation / AdditionalProperties onto DocumentTableCell plus RawRepresentation onto DocumentPage. SPIKE-09 kept the three OCR-owned geometry primitives (no cross-platform BCL type carries the page-scoped, rotation-capable DocumentBoundingRegion polygon). SPIKE-04/05 kept IDocumentExtractionClient.GetService (one optional seam for provider metadata, provider-SDK escape, and adapter unwrap). Surface is now 32 public types (adds DocumentPageDimensions).
2026-07-22 — API-review reshape: reading-order element model + document-level coordinate model, grounded in a 12-engine provider survey. Collapsed the parallel DocumentPage.Blocks/Tables/Images into one reading-order DocumentPage.Elements over a new polymorphic DocumentElement base (DocumentBlock/DocumentTable/DocumentImage derive; project with OfType<T>()), and added optional nested DocumentTableCell.Elements. Moved coordinates to the document level: DocumentCoordinateUnit is now a closed enum (added Point) plus a new DocumentCoordinateOrigin enum, both on DocumentExtractionResult/DocumentExtractionPageResult (removed from DocumentPage). Renamed ExtractStreamingAsync → ExtractPagesAsync and OcrResponseUpdate → DocumentExtractionPageResult (non-null Page, dropped Status); Markdown → Text; added DocumentExtractionUsage token counts; added DocumentTableCellKind.RowHeader/RowSection; dropped response ModelId, DocumentPage.Confidence, and DocumentExtractionOptions.IncludeImages; DocumentBoundingRegion.FromRectangle now takes float. Surface is now 31 public types.
2026-07-15 — API-review alignment (family symmetry). Replaced the unary-only shape with the family's unary + streaming pair: added IAsyncEnumerable<OcrResponseUpdate> ExtractStreamingAsync(...) (the IChatClient.GetStreamingResponseAsync twin), OcrResponseUpdate, and an OcrResponseUpdateExtensions.ToDocumentExtractionResult/ToDocumentExtractionResultAsync reducer, and removed IProgress<DocumentExtractionProgress> and DocumentExtractionProgress (progress now rides on the streamed update). DocumentBlock.Kind / DocumentTableCell.Kind are now ChatRole-style open structs (DocumentBlockKind / DocumentTableCellKind), not raw strings. Added DocumentPage.Width / Height + DocumentCoordinateUnit so bounding-box coordinates are interpretable across engines. Removed the leaky DocumentExtractionResult.OcrSource (ModelId + DocumentExtractionClientMetadata.ProviderName carry provenance). Unsealed the result / data types to match ChatResponse / ChatOptions. Surface is now 29 public types.
2026-07-14 — Reformatted to the API-proposal template and refreshed the API Proposal speclet to match the surface implemented in PR Add IDocumentExtractionClient document-extraction capability as a new Microsoft.Extensions.DocumentExtraction library #7588: GetTextAsync → ExtractAsync; IDocumentExtractionClient : IDisposable; typed geometry DocumentPoint / DocumentBoundingBox with DocumentBoundingRegion.Polygon as IReadOnlyList<DocumentPoint>; 1-based DocumentPage.PageNumber; [Experimental("MEDE0001")]; added DocumentImage, DocumentPage.Images, DocumentExtractionOptions.Clone(), DocumentExtractionClientMetadata, the DocumentExtractionClientExtensions surface (incl. opt-in ExtractFromUriAsync), and the full builder / middleware / DI types. The sibling IDocumentAnalysisClient is scoped out to its own future proposal.
Background and motivation
Document parsing is a core RAG and ingestion building block, but there is no provider-neutral
document-extraction capability in the Microsoft.Extensions.* stack. Today, you either wire provider SDKs
directly or route OCR through IChatClient, which loses native document structure such as tables,
bounding boxes, confidence, polygons, and reading order.
This proposal adds IDocumentExtractionClient as a provider-neutral capability in its own peer
library, Microsoft.Extensions.DocumentExtraction — a sibling to Microsoft.Extensions.VectorData and Microsoft.Extensions.DataIngestion, not a capability inside Microsoft.Extensions.AI. It adopts the
same builder, middleware, and DI shape developers already use across the Microsoft.Extensions.AI
capability family, and references Microsoft.Extensions.AI.Abstractions internally (e.g. DataContent
inputs) without carrying the "AI" brand in its own namespace or types. Extraction pulls structured content out of documents; it complements DataIngestion, which feeds content into RAG.
What is OCR / document AI?
OCR is the process of extracting text from documents and images. Document AI goes further: it keeps
structure around that text, including pages, tables, blocks, regions, confidence scores, and reading
order.
For RAG and ingestion pipelines, that structure matters. A document reader should not only produce
markdown. It should also preserve enough page, region, table, confidence, and source metadata for
downstream chunking, retrieval, grounding, and evaluation.
Why an abstraction?
Microsoft.Extensions.AI.Abstractions ships a family of capability interfaces: IChatClient, IEmbeddingGenerator, ISpeechToTextClient, ITextToSpeechClient, IImageGenerator, IRealtimeClient, and IHostedFileClient. There is no OCR / document-extraction capability, even
though Microsoft.Extensions.DataIngestion (MEDI) already depends on document parsing. Its IngestionDocumentReader roadmap, per MS Learn, includes LlamaParse and Azure Document Intelligence,
both hosted document-AI services that need a provider-agnostic seam.
Today, if you want to use document-AI models, you must:
Couple ingestion code directly to a provider SDK.
Model OCR as a chat prompt against IChatClient.
Add reader-mode flags for specific engines or hosts.
Rebuild retry, logging, DI, middleware, and test seams per provider.
Give up native structure when the abstraction cannot represent it.
CommunityToolkit/AI #3 is a representative example.
It introduced a PdfReadingMode.VisionOnly flag that routes whole-document transcription through a
vision LLM (IChatClient). That is a layer leak: a model choice hardened into a reader-mode flag,
with temporal coupling because the reader emits placeholders that are useless unless a specific enricher
runs. The cleaner shape is a capability client the reader composes, exactly how MEDI's enrichers
already compose an injected IChatClient.
OCR is not chat. Most OCR / document-AI engines emit structured output: tables, bounding boxes,
confidence, polygons, reading order. That does not fit ChatResponse. A vision LLM can transcribe by
prompt, but it is the lowest-fidelity path and loses native structure. Purpose-built engines (Mistral
OCR, Azure Document Intelligence, Azure AI Content Understanding) and local document VLMs
(granite-docling, PaddleOCR) beat it. Modeling OCR as "call IChatClient with an image" makes those
engines unrepresentable without discarding their value. That points to a separate capability
interface, independent of IChatClient.
Prototype validation
This proposal is not a sketch. It describes a working prototype spiked across four real engine
providers (three using no IChatClient at all) plus one vision-LLM adapter, composed with an IChatClient-style builder pipeline, and validated end-to-end through a MEDI ingestion pipeline:
FoundryMistralDocumentExtractionClient: Azure AI Foundry mistral-ocr-4-0, keyless Entra (verified HTTP 200).
MistralDocumentExtractionClient: Mistral-direct, API key.
VisionLlmDocumentExtractionClient: the one adapter over IChatClient (gpt-4o / Gemini / local Ollama GLM-OCR), the lowest-fidelity path.
Every claim below ("one pipeline wraps all engines", "the polygon flows losslessly from DI and Mistral",
"streaming yields pages as they finish") is backed by code that builds and runs, not by assertion. The demo
is a public, runnable proof: one IDocumentExtractionClient in front of four OCR engines, bridged into a MEDI RAG
pipeline, with the identical consumer loop across both provider archetypes (document-native and
image-per-page).
The design goal is provider-neutrality: one small set of composable primitives that every provider
maps onto equally, judged on interoperability, reusability, composition, extensibility. No provider
is privileged. Providers form a coverage matrix, not a hierarchy.
The same precedent that justifies splitting OpenAIClient / AzureOpenAIClient behind IChatClient
applies here. The interface is the portability guarantee; concrete classes split by engine and by host where credential, route, or provider behavior leaks. The model/deployment id is a parameter,
never a type or a boolean flag.
Building-block symmetry with Microsoft.Extensions.AI
The goal is not a one-off OCR helper, and not a new pattern to learn. Microsoft.Extensions.DocumentExtraction
is a peer library that mirrors the M.E.AI building-block shape — exactly as VectorData and DataIngestion do — rather than a capability living inside M.E.AI.
If you know one Microsoft.Extensions.AI capability, you should know this one: abstraction,
options/result types, delegating base, builder/middleware, provider implementation, and DI registration.
The row below shows the shape it adopts.
Capability
Abstraction
Options / result types
Delegating base
Builder / middleware
Example implementation
DI registration shape
Chat
IChatClient
ChatOptions, ChatResponse, ChatResponseUpdate
DelegatingChatClient
ChatClientBuilder, .Use(...), logging, OpenTelemetry, caching, function invocation
That symmetry is the main API shape. IDocumentExtractionClient should feel like a natural next capability, not a
separate pattern you have to relearn.
Provider coverage
Providers are peers behind a provider-neutral contract, not a tier or hierarchy.
Provider
IDocumentExtractionClient (markdown/structure)
IDocumentAnalysisClient (typed fields + grounding) — future sibling proposal, not in this PR
Foundry Mistral OCR
yes
—
Azure Document Intelligence
yes
yes (Documents[].Fields)
Content Understanding
yes (markdown path)
yes (fields{} + grounding)
Vision-LLM adapter
yes (lowest fidelity)
—
Local ONNX / Ollama (roadmap)
yes
—
No row is privileged. Some providers implement more of the family than others; the family is the
design, and coverage is a matrix. Content Understanding is the widest-surface conformance test (one
service exercises both interfaces with the same primitives), not an apex; it validates
provider-neutrality because the same polygon / confidence / builder primitives serve its two shapes,
Mistral OCR, Azure DI, and a vision LLM. The second column previews a future sibling capability
(IDocumentAnalysisClient, see Related and future work) and is shown only to illustrate that the same
region / confidence / builder primitives generalize; it is not part of this PR.
API Proposal
The surface below is the settled public API on PR #7588 (32 public types), reshaped per the 2026-07-22 API
review and a 12-engine provider survey (see Validated design decisions). Signatures only, no method
bodies.
Core abstraction, options, and result types (Microsoft.Extensions.DocumentExtraction.Abstractions)
namespaceMicrosoft.Extensions.DocumentExtraction;/// <summary>/// A capability for OCR / document-extraction engines. Independent of <see cref="IChatClient"/>:/// engines emit structured output (tables, bounding boxes, confidence, reading order) that does not/// fit a chat response. One contract, many engines (Mistral OCR, Azure Document Intelligence, Content/// Understanding, a local ONNX model, or a vision LLM behind an adapter)./// </summary>[Experimental("MEDE0001")]publicinterfaceIDocumentExtractionClient:IDisposable{/// <summary>Runs OCR / document parsing over a document stream and returns structured text + pages.</summary>Task<DocumentExtractionResult>ExtractAsync(Streamdocument,stringmediaType,DocumentExtractionOptions?options=null,CancellationTokencancellationToken=default);/// <summary>/// Streams OCR / document parsing as <see cref="DocumentExtractionPageResult"/> values — one per page as it finishes/// (the <see cref="IChatClient.GetStreamingResponseAsync"/> twin). Reassemble into an/// <see cref="DocumentExtractionResult"/> via <see cref="DocumentExtractionPageResultExtensions.ToDocumentExtractionResultAsync"/>. Lets/// large-document RAG chunk/embed early pages while later pages are still being parsed./// </summary>IAsyncEnumerable<DocumentExtractionPageResult>ExtractPagesAsync(Streamdocument,stringmediaType,DocumentExtractionOptions?options=null,CancellationTokencancellationToken=default);/// <summary>Provider escape hatch (the <see cref="IChatClient.GetService"/> pattern).</summary>object?GetService(TypeserviceType,object?serviceKey=null);}/// <summary>Normalized OCR result — "normalize the common, preserve the raw" (the ChatResponse pattern).</summary>[Experimental("MEDE0001")]publicclassDocumentExtractionResult{publicDocumentExtractionResult(IReadOnlyList<DocumentPage>pages);publicIReadOnlyList<DocumentPage>Pages{get;}publicstringText{get;}// derived: page Text joined with blank linespublicDocumentExtractionUsage?Usage{get;set;}publicobject?RawRepresentation{get;set;}// provider-native object — nothing is lostpublicAdditionalPropertiesDictionary?AdditionalProperties{get;set;}}[Experimental("MEDE0001")]publicclassDocumentPage{publicDocumentPage(intpageNumber,stringtext);publicintPageNumber{get;}// 1-basedpublicstringText{get;}publicIReadOnlyList<DocumentElement>Elements{get;set;}// READING ORDER; OfType<T>() to project. default: emptypublicDocumentPageDimensions?Dimensions{get;set;}// page extent (width+height), when the engine reports itpublicDocumentCoordinateUnit?CoordinateUnit{get;set;}// per page — engines emit different units per page (image vs PDF batches)publicDocumentCoordinateOrigin?CoordinateOrigin{get;set;}[JsonIgnore]publicobject?RawRepresentation{get;set;}// provider-native page object; survives ToDocumentExtractionResult reduction (SPIKE-06)publicAdditionalPropertiesDictionary?AdditionalProperties{get;set;}}/// <summary>/// The reading-order element base — a polymorphic ($type) shape (the AIContent pattern), designed to be/// promotable to a future shared document-element type. DocumentBlock / DocumentTable / DocumentImage derive from it./// </summary>[Experimental("MEDE0001")][JsonPolymorphic(TypeDiscriminatorPropertyName="$type")][JsonDerivedType(typeof(DocumentBlock),"block")][JsonDerivedType(typeof(DocumentTable),"table")][JsonDerivedType(typeof(DocumentImage),"image")]publicabstractclassDocumentElement{protectedDocumentElement();publicDocumentBoundingRegion?BoundingRegion{get;set;}publicdouble?Confidence{get;set;}[JsonIgnore]publicobject?RawRepresentation{get;set;}publicAdditionalPropertiesDictionary?AdditionalProperties{get;set;}}[Experimental("MEDE0001")]publicclassDocumentBlock:DocumentElement{publicDocumentBlock(stringtext);publicstringText{get;}publicDocumentBlockKind?Kind{get;set;}// open struct: Paragraph / Title / Figure / ...// BoundingRegion, Confidence inherited from DocumentElement}/// <summary>An image or figure extracted from a page (emitted normally; no request flag).</summary>[Experimental("MEDE0001")]publicclassDocumentImage:DocumentElement{publicDataContent?Content{get;set;}publicstring?Caption{get;set;}// BoundingRegion, Confidence inherited from DocumentElement}/// <summary>A single 2-D point in page coordinates (a polygon vertex).</summary>[Experimental("MEDE0001")]publicreadonlyrecordstructDocumentPoint(floatX,floatY);/// <summary>An axis-aligned bounding box, for coarse filters / hit-testing.</summary>[Experimental("MEDE0001")]publicreadonlyrecordstructDocumentBoundingBox(floatLeft,floatTop,floatRight,floatBottom);/// <summary>/// The SHARED, provider-neutral geometry primitive — a polygon of DocumentPoint vertices (populated natively by/// Azure DI, via FromRectangle for Mistral's rect, and reused for field grounding). GetBounds() gives a coarse box./// </summary>[Experimental("MEDE0001")]publicclassDocumentBoundingRegion{publicDocumentBoundingRegion(intpageNumber,IReadOnlyList<DocumentPoint>polygon);publicintPageNumber{get;}publicIReadOnlyList<DocumentPoint>Polygon{get;}publicstaticDocumentBoundingRegionFromRectangle(intpageNumber,floatleft,floattop,floatright,floatbottom);// float (was double)publicDocumentBoundingBox?GetBounds();}/// <summary>Cells are the primary structured representation; MarkdownRepresentation is the fallback (Mistral).</summary>[Experimental("MEDE0001")]publicclassDocumentTable:DocumentElement{publicDocumentTable(introwCount,intcolumnCount,IReadOnlyList<DocumentTableCell>?cells=null,string?markdownRepresentation=null);publicintRowCount{get;}publicintColumnCount{get;}publicIReadOnlyList<DocumentTableCell>?Cells{get;}publicstring?MarkdownRepresentation{get;}// BoundingRegion, Confidence inherited from DocumentElement}[Experimental("MEDE0001")]publicclassDocumentTableCell{publicDocumentTableCell(introwIndex,intcolumnIndex,stringcontent);publicDocumentTableCellKind?Kind{get;set;}// open struct: ColumnHeader / Content / RowHeader / RowSectionpublicintRowIndex{get;}publicintColumnIndex{get;}publicintRowSpan{get;set;}// default 1publicintColumnSpan{get;set;}// default 1publicstringContent{get;}// flat-text convenience (kept)publicIReadOnlyList<DocumentElement>?Elements{get;set;}// optional NESTED content (structured cells)// Positioned-node facet mirrored from DocumentElement (NOT inheritance; reversible way-station, SPIKE-06):publicDocumentBoundingRegion?BoundingRegion{get;set;}// per-cell geometry (5 engines: Textract/Google/DI/Adobe/Docling)publicdouble?Confidence{get;set;}[JsonIgnore]publicobject?RawRepresentation{get;set;}publicAdditionalPropertiesDictionary?AdditionalProperties{get;set;}}/// <summary>/// A streamed OCR update — one completed page (the ChatResponseUpdate pattern). Reduce a sequence of these/// into an DocumentExtractionResult with DocumentExtractionPageResultExtensions. Page is non-null (no sentinel update)./// </summary>[Experimental("MEDE0001")]publicclassDocumentExtractionPageResult{[JsonConstructor]publicDocumentExtractionPageResult(DocumentPagepage);// Page is non-null (no sentinel update)publicDocumentPagePage{get;}publicint?PagesProcessed{get;set;}// progress (absorbs the retired DocumentExtractionProgress)publicint?TotalPages{get;set;}publicDocumentExtractionUsage?Usage{get;set;}[JsonIgnore]publicobject?RawRepresentation{get;set;}publicAdditionalPropertiesDictionary?AdditionalProperties{get;set;}}/// <summary>Reducers that assemble streamed page results back into one DocumentExtractionResult (the ToChatResponseAsync pattern).</summary>[Experimental("MEDE0001")]publicstaticclassDocumentExtractionPageResultExtensions{publicstaticDocumentExtractionResultToDocumentExtractionResult(thisIEnumerable<DocumentExtractionPageResult>updates);publicstaticTask<DocumentExtractionResult>ToDocumentExtractionResultAsync(thisIAsyncEnumerable<DocumentExtractionPageResult>updates,CancellationTokencancellationToken=default);}[Experimental("MEDE0001")]publicclassDocumentExtractionUsage{publicint?PagesProcessed{get;set;}publicint?InputTokenCount{get;set;}// vision-LLM path; classic OCR leaves these nullpublicint?OutputTokenCount{get;set;}publicint?TotalTokenCount{get;set;}publicAdditionalPropertiesDictionary?AdditionalProperties{get;set;}}/// <summary>The kind of a text block — a ChatRole-style OPEN set (well-knowns + provider-specific kinds).</summary>[Experimental("MEDE0001")]publicreadonlystructDocumentBlockKind:IEquatable<DocumentBlockKind>{publicDocumentBlockKind(stringvalue);// throws on null/whitespacepublicstaticDocumentBlockKindParagraph{get;}// "paragraph"publicstaticDocumentBlockKindTitle{get;}// "title"publicstaticDocumentBlockKindFigure{get;}// "figure"publicstringValue{get;}// == / != / IEquatable / GetHashCode / ToString + a JsonConverter (the ChatRole shape)}/// <summary>The kind of a table cell — a ChatRole-style OPEN set.</summary>[Experimental("MEDE0001")]publicreadonlystructDocumentTableCellKind:IEquatable<DocumentTableCellKind>{publicDocumentTableCellKind(stringvalue);publicstaticDocumentTableCellKindColumnHeader{get;}// "columnHeader"publicstaticDocumentTableCellKindContent{get;}// "content"publicstaticDocumentTableCellKindRowHeader{get;}// "rowHeader" (added)publicstaticDocumentTableCellKindRowSection{get;}// "rowSection" (added)publicstringValue{get;}}/// <summary>The unit for page dimensions + region coordinates — a CLOSED enum (units are physically bounded).</summary>[Experimental("MEDE0001")]publicenumDocumentCoordinateUnit{Pixel,Point,Inch,Normalized}/// <summary>Origin corner + y-axis direction of the coordinate space — a CLOSED enum.</summary>[Experimental("MEDE0001")]publicenumDocumentCoordinateOrigin{TopLeft,BottomLeft}/// <summary>Page extent (width + height), expressed in the page's DocumentCoordinateUnit — a readonly record struct (atomic pair).</summary>[Experimental("MEDE0001")]publicreadonlyrecordstructDocumentPageDimensions(floatWidth,floatHeight);/// <summary>Request knobs — the ChatOptions pattern.</summary>[Experimental("MEDE0001")]publicclassDocumentExtractionOptions{publicstring?ModelId{get;set;}// "GetChatClient(model)" analogpublicAdditionalPropertiesDictionary?AdditionalProperties{get;set;}publicDocumentExtractionOptionsClone();// shallow clone (the ChatOptions.Clone pattern)}/// <summary>Metadata about an <see cref="IDocumentExtractionClient"/> (the *ClientMetadata pattern).</summary>[Experimental("MEDE0001")]publicclassDocumentExtractionClientMetadata{publicDocumentExtractionClientMetadata(string?providerName=null,Uri?providerUri=null,string?defaultModelId=null);publicstring?ProviderName{get;}publicUri?ProviderUri{get;}publicstring?DefaultModelId{get;}}
/// <summary>Convenience helpers over <see cref="IDocumentExtractionClient"/>.</summary>[Experimental("MEDE0001")]publicstaticclassDocumentExtractionClientExtensions{publicstaticTService?GetService<TService>(thisIDocumentExtractionClientclient,object?serviceKey=null);// Extract from an in-memory DataContent (unary + streaming twin).publicstaticTask<DocumentExtractionResult>ExtractAsync(thisIDocumentExtractionClientclient,DataContentdocument,DocumentExtractionOptions?options=null,CancellationTokencancellationToken=default);publicstaticIAsyncEnumerable<DocumentExtractionPageResult>ExtractPagesAsync(thisIDocumentExtractionClientclient,DataContentdocument,DocumentExtractionOptions?options=null,CancellationTokencancellationToken=default);// Extract from a UriContent. Handles self-contained data: URIs; throws NotSupportedException for// file:/http(s) (whether to download vs. hand the URL to the engine is a deliberate non-decision).publicstaticTask<DocumentExtractionResult>ExtractAsync(thisIDocumentExtractionClientclient,UriContentdocument,DocumentExtractionOptions?options=null,CancellationTokencancellationToken=default);publicstaticIAsyncEnumerable<DocumentExtractionPageResult>ExtractPagesAsync(thisIDocumentExtractionClientclient,UriContentdocument,DocumentExtractionOptions?options=null,CancellationTokencancellationToken=default);// Explicit, opt-in remote downloader: fetches http(s) bytes with a caller-supplied HttpClient// (caller owns handlers/auth/timeouts/lifetime), inlines data: URIs, then extracts.publicstaticTask<DocumentExtractionResult>ExtractFromUriAsync(thisIDocumentExtractionClientclient,UriContentdocument,HttpClienthttpClient,DocumentExtractionOptions?options=null,CancellationTokencancellationToken=default);}
Delegating base, builder, middleware, and DI
All Microsoft.Extensions.AI capabilities ship the same five-layer shape: interface → Delegating<Cap> → <Cap>Builder → Add<Cap> (returns the builder) → .Use*()
middleware, with one composition primitive, Builder Use(Func<T, IServiceProvider, T>). IDocumentExtractionClient
mirrors it exactly.
// ---- Microsoft.Extensions.DocumentExtraction.Abstractions ----/// <summary>Optional base for an <see cref="IDocumentExtractionClient"/> that passes calls through to an inner instance.</summary>[Experimental("MEDE0001")]publicclassDelegatingDocumentExtractionClient:IDocumentExtractionClient{protectedDelegatingDocumentExtractionClient(IDocumentExtractionClientinnerClient);protectedIDocumentExtractionClientInnerClient{get;}publicvoidDispose();publicvirtualTask<DocumentExtractionResult>ExtractAsync(Streamdocument,stringmediaType,DocumentExtractionOptions?options=null,CancellationTokencancellationToken=default);publicvirtualIAsyncEnumerable<DocumentExtractionPageResult>ExtractPagesAsync(Streamdocument,stringmediaType,DocumentExtractionOptions?options=null,CancellationTokencancellationToken=default);publicvirtualobject?GetService(TypeserviceType,object?serviceKey=null);protectedvirtualvoidDispose(booldisposing);}// ---- Microsoft.Extensions.DocumentExtraction ----[Experimental("MEDE0001")]publicsealedclassDocumentExtractionClientBuilder{publicDocumentExtractionClientBuilder(IDocumentExtractionClientinnerClient);publicDocumentExtractionClientBuilder(Func<IServiceProvider,IDocumentExtractionClient>innerClientFactory);publicIDocumentExtractionClientBuild(IServiceProvider?services=null);// first .Use is outermostpublicDocumentExtractionClientBuilderUse(Func<IDocumentExtractionClient,IDocumentExtractionClient>clientFactory);publicDocumentExtractionClientBuilderUse(Func<IDocumentExtractionClient,IServiceProvider,IDocumentExtractionClient>clientFactory);// THE primitive}[Experimental("MEDE0001")]publicclassLoggingDocumentExtractionClient:DelegatingDocumentExtractionClient{}// logging middleware[Experimental("MEDE0001")]publicsealedclassOpenTelemetryDocumentExtractionClient:DelegatingDocumentExtractionClient{}// OTel middleware[Experimental("MEDE0001")]publicsealedclassConfigureOptionsDocumentExtractionClient:DelegatingDocumentExtractionClient{}// options middleware[Experimental("MEDE0001")]publicstaticclassDocumentExtractionClientBuilderDocumentExtractionClientExtensions{publicstaticDocumentExtractionClientBuilderAsBuilder(thisIDocumentExtractionClientinnerClient);}[Experimental("MEDE0001")]publicstaticclassLoggingDocumentExtractionClientBuilderExtensions{publicstaticDocumentExtractionClientBuilderUseLogging(thisDocumentExtractionClientBuilderbuilder,ILoggerFactory?loggerFactory=null,Action<LoggingDocumentExtractionClient>?configure=null);}[Experimental("MEDE0001")]publicstaticclassOpenTelemetryDocumentExtractionClientBuilderExtensions{publicstaticDocumentExtractionClientBuilderUseOpenTelemetry(thisDocumentExtractionClientBuilderbuilder,ILoggerFactory?loggerFactory=null,string?sourceName=null,Action<OpenTelemetryDocumentExtractionClient>?configure=null);}[Experimental("MEDE0001")]publicstaticclassConfigureOptionsDocumentExtractionClientBuilderExtensions{publicstaticDocumentExtractionClientBuilderConfigureOptions(thisDocumentExtractionClientBuilderbuilder,Action<DocumentExtractionOptions>configure);}[Experimental("MEDE0001")]publicstaticclassDocumentExtractionClientBuilderServiceCollectionExtensions{publicstaticDocumentExtractionClientBuilderAddDocumentExtractionClient(thisIServiceCollectionserviceCollection,IDocumentExtractionClientinnerClient,ServiceLifetimelifetime=ServiceLifetime.Singleton);publicstaticDocumentExtractionClientBuilderAddDocumentExtractionClient(thisIServiceCollectionserviceCollection,Func<IServiceProvider,IDocumentExtractionClient>innerClientFactory,ServiceLifetimelifetime=ServiceLifetime.Singleton);publicstaticDocumentExtractionClientBuilderAddKeyedDocumentExtractionClient(thisIServiceCollectionserviceCollection,object?serviceKey,IDocumentExtractionClientinnerClient,ServiceLifetimelifetime=ServiceLifetime.Singleton);publicstaticDocumentExtractionClientBuilderAddKeyedDocumentExtractionClient(thisIServiceCollectionserviceCollection,object?serviceKey,Func<IServiceProvider,IDocumentExtractionClient>innerClientFactory,ServiceLifetimelifetime=ServiceLifetime.Singleton);}
IDocumentExtractionClient ships logging, OpenTelemetry, and configure-options middleware in v1, matching the ISpeechToTextClient template. It does not ship a built-in retry client: no Microsoft.Extensions.AI
capability does, because resilience belongs in the HTTP pipeline
(Microsoft.Extensions.Http.Resilience). The .Use(...) primitive still lets a consumer wrap a custom
retry or cache decorator when they want one.
API Usage
Every snippet below is drawn from the runnable demo (iocrclient-demo), which exercises this exact
surface across four engines and a MEDI RAG pipeline.
One interface, four engines — the payoff. Vision LLM, Mistral OCR, Azure Document Intelligence, and
Azure Content Understanding each speak a completely different wire protocol. Behind IDocumentExtractionClient they are IDocumentExtractionClient; the consumer loop never changes when you add or swap a provider.
varclients=new(stringName,IDocumentExtractionClientClient)[]{("vision-llm",newVisionLlmDocumentExtractionClient(chatClient)),("mistral-ocr",newFoundryMistralDocumentExtractionClient(foundryEndpoint,cred)),("azure-document-intelligence",newAzureDocumentIntelligenceClient(diEndpoint,cred)),("azure-content-understanding",newContentUnderstandingClient(cuEndpoint,cred)),};byte[]bytes=awaitFile.ReadAllBytesAsync("report.pdf");foreach(var(name,client)inclients){using(client){usingvarstream=newMemoryStream(bytes,writable:false);DocumentExtractionResultr=awaitclient.ExtractAsync(stream,"application/pdf");// identical for every engineinttables=r.Pages.Sum(p =>p.Elements.OfType<DocumentTable>().Count());Console.WriteLine($"{name}: {r.Pages.Count} pages, {tables} tables, {r.Text.Length} chars");}}
Stream pages as they finish — the IChatClient.GetStreamingResponseAsync twin. Each DocumentExtractionPageResult carries one completed page (plus progress + usage), so a RAG pipeline can chunk and
embed early pages while later pages are still being OCR'd; ToDocumentExtractionResultAsync reduces the stream back to
the same DocumentExtractionResult the unary call would return.
awaitforeach(DocumentExtractionPageResultupdateinclient.ExtractPagesAsync(stream,"application/pdf")){DocumentPagepage=update.Page;Console.WriteLine($"page {page.PageNumber}/{update.TotalPages}: {page.Text.Length} chars");}// …or reduce the whole stream back into one DocumentExtractionResult (the ToChatResponseAsync pattern):DocumentExtractionResultfull=awaitclient.ExtractPagesAsync(stream,"application/pdf").ToDocumentExtractionResultAsync();
Compose middleware with the builder — the same shape as ChatClientBuilder: you compose a client,
you don't set flags.
Register with dependency injection — the consumer depends only on IDocumentExtractionClient; swap the engine
without touching downstream code.
services.AddDocumentExtractionClient(sp =>newFoundryMistralDocumentExtractionClient(endpoint,newDefaultAzureCredential())).UseOpenTelemetry().UseLogging();// Later, swap the engine on one line — nothing downstream changes:services.AddDocumentExtractionClient(sp =>newAzureDocumentIntelligenceClient(diEndpoint,cred)).UseLogging();
Configure per-request options through the pipeline (for example, a local Ollama GLM-OCR engine that
needs a task-prefix prompt injected even when the caller passes no options):
Bridge into a MEDI ingestion / RAG pipeline. A hosted document-AI service is a reader (the
LlamaParse-as-reader shape). One provider-agnostic reader composes any IDocumentExtractionClient:
publicsealedclassOcrDocumentReader(IDocumentExtractionClientocr,DocumentExtractionOptions?options=null):IngestionDocumentReader{publicoverrideasyncTask<IngestionDocument>ReadAsync(Streamsource,stringidentifier,stringmediaType,CancellationTokenct=default){DocumentExtractionResultr=awaitocr.ExtractAsync(source,mediaType,options,ct);// map r.Pages -> IngestionDocument elements, stamping page/region/confidence/model metadata}}// Usage: one section per OCR page, each stamped with its 1-based PageNumber for downstream chunking.varreader=newOcrDocumentReader(ocr);IngestionDocumentdoc=awaitreader.ReadAsync(fileStream,"report.pdf","application/pdf");
Extract from a remote URI (opt-in download).ExtractAsync(UriContent) never touches the network; ExtractFromUriAsync is the explicit counterpart that fetches http(s) bytes with a caller-owned HttpClient:
Reuse IChatClient with multimodal content + a prompt. Rejected: loses native
tables/bbox/confidence, is nondeterministic and token-expensive, and cannot represent non-chat engines
(Azure DI, CU, local ONNX) at all. A vision LLM is supported as one provider behindIDocumentExtractionClient
(VisionLlmDocumentExtractionClient), not as the contract. The prototype demonstrates this: three of four real
engines use no IChatClient.
A flag on the reader (the VisionOnly approach). Rejected: a model choice hardened into a reader
mode; temporal coupling; not decoratable (no middleware); does not generalize across engines.
A transport-parameterized single class (new MistralDocumentExtractionClient(isFoundry: true)). Rejected:
smuggles host branching into the type; breaks composition. Follow the OpenAIClient/AzureOpenAIClient
precedent: the interface is portable; concrete classes split by host where the host leaks.
One interface for OCR and field extraction (a flag/overload). Rejected: different output
contract; modeled as the sibling IDocumentAnalysisClient peer (the STT/TTS precedent). See Related
and future work.
Validated design decisions (resolved by the prototype + sources)
Geometry = typed polygon; table = structured cells. Azure DI BoundingRegion.Polygon is a
possibly rotation-skewed quad; an axis-aligned rect would be lossy. Resolved with DocumentBoundingRegion.Polygon as IReadOnlyList<DocumentPoint> (populated natively by DI, by FromRectangle
for Mistral's rect, reused for field grounding); GetBounds() returns the DocumentBoundingBox struct for
coarse filters. Tables resolve to DocumentTable{RowCount, ColumnCount, Cells?, MarkdownRepresentation?}
(DI cells primary, Mistral markdown fallback). The prototype demonstrates this: the DI polygon and
the Mistral rect→quad both flow losslessly into the same reader metadata. (Sources: Azure/azure-sdk-for-net DocumentTable/DocumentTableCell/BoundingRegion; Mistral OCR schema.)
Typed DocumentPoint over a flat float[]. The earlier open question (flat IReadOnlyList<float> vs a
point shape) is now resolved: a typed DocumentPoint list makes an odd/empty coordinate count
unrepresentable and reads correctly without documentation, while still carrying DI's 4-vertex quad
without loss.
1-based DocumentPage.PageNumber. Page identity matches how documents, Azure DI, and downstream
provenance number pages (1-based), removing the off-by-one that a 0-based Index forced on adapters.
Unary ExtractAsync + streaming ExtractPagesAsync (updated in API review; IProgress retired).
The proposal originally shipped unary-only with an IProgress<DocumentExtractionProgress> hook and deferred streaming.
API review aligned it to the family instead: every sibling ships a streaming twin
(IChatClient.GetStreamingResponseAsync, ISpeechToTextClient.GetStreamingTextAsync), and on netstandard2.0 / net462 there are no default interface methods, so streaming cannot be added later
without a new interface — it has to ship now. ExtractPagesAsync yields one DocumentExtractionPageResult per
page as it finishes (progress fields PagesProcessed / TotalPages ride on the update, replacing DocumentExtractionProgress), letting large-document RAG chunk/embed early pages while later pages are still
parsing; DocumentExtractionPageResultExtensions.ToDocumentExtractionResultAsync reduces the stream back to a single DocumentExtractionResult
(the ToChatResponseAsync pattern). The prototype demonstrates this: each engine surfaces per-page
updates and the reducer reassembles the same DocumentExtractionResult as the unary call.
Home / process. Lives in Microsoft.Extensions.DocumentExtraction.Abstractions, namespace Microsoft.Extensions.DocumentExtraction, [Experimental("MEDE0001")] from day one, area-ai, full stack (abstractions +
Delegating/Logging/OpenTelemetry/ConfigureOptions middleware + DI) in one PR. First mover: no existing
OCR/IDocumentExtractionClient issue in dotnet/extensions.
Reading-order element model over a polymorphic base (resolved by the survey). Every surveyed engine
emits elements in a single reading order; consumers want "iterate the page top-to-bottom" without
re-deriving order from geometry. DocumentPage therefore exposes one Elements list over an DocumentElement base ($type-discriminated, the AIContent shape), with OfType<DocumentTable>() / OfType<DocumentImage>() projections — replacing the parallel Blocks/Tables/Images lists. The base is
shaped to be promotable to a shared document-element type (cross-team track), so this is not a
redesign later. Tables can carry nested DocumentTableCell.Elements for structured cell content, keeping
the flat Content string as a convenience.
Kind taxonomies are open structs; units/origins are closed enums (resolved by the survey). Block and
cell kinds vary by engine and grow over time, so DocumentBlockKind / DocumentTableCellKind stay ChatRole-style open structs (well-knowns RowHeader/RowSection added to cells). Coordinate units and origins are physically bounded, so DocumentCoordinateUnit (Pixel/Point/Inch/Normalized)
and the new DocumentCoordinateOrigin (TopLeft/BottomLeft) are closed enums.
Coordinate metadata is per page (resolved by SPIKE-08).CoordinateUnit + CoordinateOrigin live
on DocumentPage, not the document. Primary sources refuted a "one unit per document" assumption: Google
models Page.Dimension { width, height, unit } per page, and Azure DI's per-page DocumentPage.Unit is pixel for image inputs and inch for PDF — so one analyze call over a mixed batch returns different
units on different pages. DocumentPage.Width/Height group into a new DocumentPageDimensions value type
(the extent), kept as a sibling to unit + origin (which together describe the coordinate system).
A continuous page-rotation angle is deferred to AdditionalProperties for v1 (element rotation is
already carried losslessly by the polygon DocumentBoundingRegion).
Markdown → Text. Engines return plain text far more often than genuine Markdown; the property is
renamed Text on DocumentPage and DocumentExtractionResult (dropping Markdown). A real Markdown property can be
re-added later if a genuine-Markdown need appears (additive).
DocumentExtractionUsage carries optional tokens. The vision-LLM path reports token usage; classic OCR engines
report only page counts. DocumentExtractionUsage gains nullable InputTokenCount/OutputTokenCount/TotalTokenCount
alongside PagesProcessed (mirrors MEAI UsageDetails), left null by engines that don't bill tokens.
Basis: a 12-engine cross-provider output-model survey — the five demo providers (Azure Document
Intelligence, Azure Content Understanding, Mistral OCR, Foundry Mistral, a vision-LLM adapter) plus an
exhaustive pass over LlamaParse, LiteParse, Microsoft MarkItDown, IBM Docling, Unstructured.io, AWS
Textract, Google Cloud Document AI, Upstage Document Parse, and Adobe PDF Extract — spanning cloud OCR,
agentic/VLM parsers, open-source libraries, and markdown-first converters, each with primary-source
citations.
Staged decisions / open questions (by track)
The reshape above is the "just-do" round that ships on #7588. The genuinely cross-team, tooling, and
empirical-validation questions run as parallel tracks that do not block the PR:
Shared document-model package + assembly home (RESOLVED 2026-07-30 — own peer library). The
surface moved out of Microsoft.Extensions.AI into its own library, Microsoft.Extensions.DocumentExtraction
(like VectorData / DataIngestion). The neutral Document* model is kept extraction-agnostic so a
later hoist into a shared home co-owned with DataIngestion stays a namespace-forward, not a
redesign — that hoist is a deferred follow-up (this round, MEDI just references DocumentExtraction.Abstractions).
Can Azure Document Intelligence implement IDocumentExtractionClient directly? (UNBLOCKED 2026-07-30).
The move removes the "AI"-branding blocker; a prototype (SPIKE-12) already implemented the reshaped
interface directly over Azure.AI.DocumentIntelligence. A first-party impl belongs in a provider-owned
satellite package (mirroring DataIngestion.MarkItDown / .Markdig), owned by the DI team.
Should IChatClient advertise vision capability? Capability metadata vs. an early/compile-time
throw when a non-vision model is handed an image — a MEAI-team discussion (unchanged; still open).
Family rename (RESOLVED 2026-07-30 — executed as the library move).Ocr* becomes the
domain-noun Document* / DocumentExtraction* scheme as part of the extraction, applied in the straw-man across all five TFMs; a review-driven name change stays a single mechanical [Experimental] re-rename.
Implementation spike (separate impl package, not Abstractions)
GetService for v1 (RESOLVED — SPIKE-04/05). Kept. A vision-LLM IChatClient.AsDocumentExtractionClient() prototype confirmed that with a non-public adapter type, GetService<IChatClient>() is the only public unwrap to the inner chat client. The shipping
middleware's GetService<DocumentExtractionClientMetadata>() use is conceded as partly biased evidence (copied
from IChatClient's middleware), but the real need — a telemetry decorator discovering an
arbitrary client's provider identity — remains; dropping GetService would only trade it for a
mandatory Metadata property while losing the provider-SDK escape hatch and the adapter unwrap.
One optional seam covers all three; reversible while [Experimental].
Validation before formal review (via the demo + a DataIngestion reader)
Justify RawRepresentation / AdditionalProperties (RESOLVED — SPIKE-06). An exhaustive
14-engine raw-output inventory shows every engine emits structured data with no first-class home
(Content Understanding's geometry is a source string, unparseable into a polygon), so both escape
hatches stay; AdditionalProperties guidance tightened to real provider signals only. Three fields
cleared the promote bar: DocumentTableCell gained per-cell geometry + confidence + raw + properties
(mirrored from DocumentElement), DocumentPage gained RawRepresentation, and detected language is
deferred to a standardized detectedLanguages key (typed DocumentDetectedLanguage later).
Group Width/Height into an DocumentPageDimensions (RESOLVED — SPIKE-07). Done: DocumentPageDimensions
is folded into the surface above, as a per-page sibling to unit + origin.
Confirm Pixel is the dominant coordinate unit (RESOLVED — SPIKE-08). Units are not
Pixel-dominant: they spread across Pixel/Point/Inch/Normalized and are reported per page, so unit +
origin moved onto DocumentPage. See the reshape rationale above.
Confirm no cross-platform BCL geometry type fits (RESOLVED — SPIKE-09). No BCL type carries
the load-bearing shape — a page-scoped, rotation-capable polygon (DocumentBoundingRegion). System.Numerics.Vector2 has no region type; System.Drawing.RectangleF is axis-aligned only (it
cannot hold Azure DI's skewed quad) and the BCL has no polygon primitive at all. Reusing System.Drawing.PointF for DocumentPoint alone would delete no type while coupling OCR coordinates to a
drawing namespace. The three OCR-owned primitives stay; note the package already uses a BCL value
type where one fits exactly (ImageGenerationOptions.ImageSize is System.Drawing.Size).
Tooling
Adopt the new C# extension-members syntax for the OCR extensions — first verifying the public-API
baseline/analyzer story (OCR would be the repo's first user), then adopting.
Still open from before
Whether .UseDistributedCache(...) ships in v1 (OCR is expensive). Logging, OpenTelemetry, and
configure-options middleware already ship, matching the ISpeechToTextClient template.
Naming of the shared region primitive if grounding is reused by other capabilities: keep OCR-prefixed DocumentBoundingRegion (lowest churn) vs. promote to a capability-neutral BoundingRegion. Recommendation:
keep the prefix for v1.
Page vs. chunk streaming unit — ExtractPagesAsync / DocumentExtractionPageResult use Page for v1; revisit only
if the shared-model track elevates types toward a chunk model.
Risks
Shape creep. Keep IDocumentExtractionClient focused on OCR / document extraction. Field extraction stays in the
sibling IDocumentAnalysisClient proposal (not this PR).
Middleware scope. Logging, OpenTelemetry, and configure-options ship in v1, validated in the
prototype and matching the ISpeechToTextClient template. Retry is intentionally not a built-in
client: no Microsoft.Extensions.AI capability ships per-capability retry, since resilience belongs in
the HTTP pipeline (Microsoft.Extensions.Http.Resilience); the .Use(...) primitive can still wrap a
custom retry decorator. Distributed caching can follow the same builder shape, but API review should
decide whether it ships in v1.
Mutable result lists.DocumentPage.Elements (and DocumentTable.Cells) are settable / init-populated so
providers can fill them incrementally; consumers treat the returned result as read-only. API review may
prefer init-only / ctor-set here (constrained by the netstandard2.0 / net462 targets, which rule out required / DIMs).
Field extraction (a document + a schema/analyzer → typed fields with confidence + grounding) is a categorically different output contract than OCR→markdown, exposed by multiple providers (Azure
DI custom models, Content Understanding). It is best modeled as a peer interface, not a flag/overload
on IDocumentExtractionClient — the same call M.E.AI made splitting ISpeechToTextClient / ITextToSpeechClient. Its
grounding would reuse the SAME DocumentBoundingRegion polygon and compose via the SAME builder, which is why
this PR ships the shared region primitive with IDocumentExtractionClient. It carries its own harder design debates
(schema/analyzer-id representation, the typed-field value model, lifecycle) and its own provider matrix,
so it is deliberately out of scope for this PR and will be its own proposal. Sketch (illustrative only):
[Experimental("MEDE0001")]publicinterfaceIDocumentAnalysisClient{Task<DocumentAnalysisResult>AnalyzeAsync(Streamdocument,stringmediaType,DocumentAnalysisOptionsoptions,CancellationTokencancellationToken=default);object?GetService(TypeserviceType,object?serviceKey=null);}publicsealedclassDocumentField{publicstringName{get;}publicobject?Value{get;set;}publicstring?ValueType{get;set;}publicdouble?Confidence{get;set;}publicDocumentBoundingRegion?Grounding{get;set;}// SHARED region primitive, reusedpublicAdditionalPropertiesDictionary?AdditionalProperties{get;set;}}
References
CommunityToolkit/AI #3: Document Processing packages (MEDI); the VisionOnly flag this proposal replaces.
Prototype + coverage matrix runnable offline: the public iocrclient-demo (one IDocumentExtractionClient in front of four OCR engines, bridged into a MEDI RAG pipeline).
[API Proposal]:
IDocumentExtractionClient— a document-extraction capability as its own peer library (Microsoft.Extensions.DocumentExtraction)Background and motivation
Document parsing is a core RAG and ingestion building block, but there is no provider-neutral
document-extraction capability in the
Microsoft.Extensions.*stack. Today, you either wire provider SDKsdirectly or route OCR through
IChatClient, which loses native document structure such as tables,bounding boxes, confidence, polygons, and reading order.
This proposal adds
IDocumentExtractionClientas a provider-neutral capability in its own peerlibrary,
Microsoft.Extensions.DocumentExtraction— a sibling toMicrosoft.Extensions.VectorDataandMicrosoft.Extensions.DataIngestion, not a capability insideMicrosoft.Extensions.AI. It adopts thesame builder, middleware, and DI shape developers already use across the Microsoft.Extensions.AI
capability family, and references
Microsoft.Extensions.AI.Abstractionsinternally (e.g.DataContentinputs) without carrying the "AI" brand in its own namespace or types. Extraction pulls structured content
out of documents; it complements
DataIngestion, which feeds content into RAG.What is OCR / document AI?
OCR is the process of extracting text from documents and images. Document AI goes further: it keeps
structure around that text, including pages, tables, blocks, regions, confidence scores, and reading
order.
For RAG and ingestion pipelines, that structure matters. A document reader should not only produce
markdown. It should also preserve enough page, region, table, confidence, and source metadata for
downstream chunking, retrieval, grounding, and evaluation.
Why an abstraction?
Microsoft.Extensions.AI.Abstractionsships a family of capability interfaces:IChatClient,IEmbeddingGenerator,ISpeechToTextClient,ITextToSpeechClient,IImageGenerator,IRealtimeClient, andIHostedFileClient. There is no OCR / document-extraction capability, eventhough
Microsoft.Extensions.DataIngestion(MEDI) already depends on document parsing. ItsIngestionDocumentReaderroadmap, per MS Learn, includes LlamaParse and Azure Document Intelligence,both hosted document-AI services that need a provider-agnostic seam.
Today, if you want to use document-AI models, you must:
IChatClient.CommunityToolkit/AI #3 is a representative example.
It introduced a
PdfReadingMode.VisionOnlyflag that routes whole-document transcription through avision LLM (
IChatClient). That is a layer leak: a model choice hardened into a reader-mode flag,with temporal coupling because the reader emits placeholders that are useless unless a specific enricher
runs. The cleaner shape is a capability client the reader composes, exactly how MEDI's enrichers
already compose an injected
IChatClient.OCR is not chat. Most OCR / document-AI engines emit structured output: tables, bounding boxes,
confidence, polygons, reading order. That does not fit
ChatResponse. A vision LLM can transcribe byprompt, but it is the lowest-fidelity path and loses native structure. Purpose-built engines (Mistral
OCR, Azure Document Intelligence, Azure AI Content Understanding) and local document VLMs
(granite-docling, PaddleOCR) beat it. Modeling OCR as "call
IChatClientwith an image" makes thoseengines unrepresentable without discarding their value. That points to a separate capability
interface, independent of
IChatClient.Prototype validation
This proposal is not a sketch. It describes a working prototype spiked across four real engine
providers (three using no
IChatClientat all) plus one vision-LLM adapter, composed with anIChatClient-style builder pipeline, and validated end-to-end through a MEDI ingestion pipeline:FoundryMistralDocumentExtractionClient: Azure AI Foundrymistral-ocr-4-0, keyless Entra (verified HTTP 200).MistralDocumentExtractionClient: Mistral-direct, API key.AzureDocumentIntelligenceClient:Azure.AI.DocumentIntelligence(AnalyzeResult, native polygons + table cells).ContentUnderstandingClient:Azure.AI.ContentUnderstanding1.1.0, keyless Entra (markdown path).VisionLlmDocumentExtractionClient: the one adapter overIChatClient(gpt-4o / Gemini / local Ollama GLM-OCR), the lowest-fidelity path.Every claim below ("one pipeline wraps all engines", "the polygon flows losslessly from DI and Mistral",
"streaming yields pages as they finish") is backed by code that builds and runs, not by assertion. The demo
is a public, runnable proof: one
IDocumentExtractionClientin front of four OCR engines, bridged into a MEDI RAGpipeline, with the identical consumer loop across both provider archetypes (document-native and
image-per-page).
The design goal is provider-neutrality: one small set of composable primitives that every provider
maps onto equally, judged on interoperability, reusability, composition, extensibility. No provider
is privileged. Providers form a coverage matrix, not a hierarchy.
The same precedent that justifies splitting
OpenAIClient/AzureOpenAIClientbehindIChatClientapplies here. The interface is the portability guarantee; concrete classes split by engine and by
host where credential, route, or provider behavior leaks. The model/deployment id is a parameter,
never a type or a boolean flag.
Building-block symmetry with Microsoft.Extensions.AI
The goal is not a one-off OCR helper, and not a new pattern to learn.
Microsoft.Extensions.DocumentExtractionis a peer library that mirrors the M.E.AI building-block shape — exactly as
VectorDataandDataIngestiondo — rather than a capability living inside M.E.AI.If you know one Microsoft.Extensions.AI capability, you should know this one: abstraction,
options/result types, delegating base, builder/middleware, provider implementation, and DI registration.
The row below shows the shape it adopts.
IChatClientChatOptions,ChatResponse,ChatResponseUpdateDelegatingChatClientChatClientBuilder,.Use(...), logging, OpenTelemetry, caching, function invocationOpenAIChatClientAddChatClient,AddKeyedChatClientIEmbeddingGenerator<TInput,TEmbedding>EmbeddingGenerationOptions,GeneratedEmbeddings<TEmbedding>DelegatingEmbeddingGenerator<TInput,TEmbedding>EmbeddingGeneratorBuilder<TInput,TEmbedding>,.Use(...), logging, OpenTelemetry, cachingOpenAIEmbeddingGeneratorAddEmbeddingGenerator,AddKeyedEmbeddingGeneratorISpeechToTextClientSpeechToTextOptions,SpeechToTextResponse, response updatesDelegatingSpeechToTextClientSpeechToTextClientBuilder,.Use(...), logging, OpenTelemetry, optionsOpenAISpeechToTextClientAddSpeechToTextClient,AddKeyedSpeechToTextClientITextToSpeechClientTextToSpeechOptions,TextToSpeechResponse, response updatesDelegatingTextToSpeechClientTextToSpeechClientBuilder,.Use(...), logging, OpenTelemetry, optionsOpenAITextToSpeechClientAddTextToSpeechClient,AddKeyedTextToSpeechClientIImageGeneratorImageGenerationOptions,ImageGenerationRequest,ImageGenerationResponseDelegatingImageGeneratorImageGeneratorBuilder,.Use(...), logging, optionsOpenAIImageGeneratorAddImageGenerator,AddKeyedImageGeneratorIRealtimeClientRealtimeSessionOptions, client/server messages, sessionsDelegatingRealtimeClientRealtimeClientBuilder,.Use(...), logging, OpenTelemetry, function invocationOpenAIRealtimeClientIRealtimeClient/ keyed clients through DIIHostedFileClientHostedFileClientOptions,HostedFileDownloadStreamDelegatingHostedFileClientHostedFileClientBuilder,.Use(...), logging, OpenTelemetryOpenAIHostedFileClientIHostedFileClient/ keyed clients through DIIDocumentExtractionClientDocumentExtractionOptions,DocumentExtractionResult,DocumentExtractionPageResult,DocumentPage,DocumentBlock,DocumentTable,DocumentImage,DocumentExtractionUsageDelegatingDocumentExtractionClientDocumentExtractionClientBuilder,.Use(...), logging, OpenTelemetry, configure-optionsFoundryMistralDocumentExtractionClient,MistralDocumentExtractionClient,AzureDocumentIntelligenceClient,ContentUnderstandingClient,VisionLlmDocumentExtractionClientAddDocumentExtractionClient,AddKeyedDocumentExtractionClientThat symmetry is the main API shape.
IDocumentExtractionClientshould feel like a natural next capability, not aseparate pattern you have to relearn.
Provider coverage
Providers are peers behind a provider-neutral contract, not a tier or hierarchy.
IDocumentExtractionClient(markdown/structure)IDocumentAnalysisClient(typed fields + grounding) — future sibling proposal, not in this PRDocuments[].Fields)fields{}+ grounding)No row is privileged. Some providers implement more of the family than others; the family is the
design, and coverage is a matrix. Content Understanding is the widest-surface conformance test (one
service exercises both interfaces with the same primitives), not an apex; it validates
provider-neutrality because the same polygon / confidence / builder primitives serve its two shapes,
Mistral OCR, Azure DI, and a vision LLM. The second column previews a future sibling capability
(
IDocumentAnalysisClient, see Related and future work) and is shown only to illustrate that the sameregion / confidence / builder primitives generalize; it is not part of this PR.
API Proposal
The surface below is the settled public API on PR #7588 (32 public types), reshaped per the 2026-07-22 API
review and a 12-engine provider survey (see Validated design decisions). Signatures only, no method
bodies.
Core abstraction, options, and result types (
Microsoft.Extensions.DocumentExtraction.Abstractions)Extension methods (
DocumentExtractionClientExtensions)Delegating base, builder, middleware, and DI
All Microsoft.Extensions.AI capabilities ship the same five-layer shape:
interface→Delegating<Cap>→<Cap>Builder→Add<Cap>(returns the builder) →.Use*()middleware, with one composition primitive,
Builder Use(Func<T, IServiceProvider, T>).IDocumentExtractionClientmirrors it exactly.
IDocumentExtractionClientships logging, OpenTelemetry, and configure-options middleware in v1, matching theISpeechToTextClienttemplate. It does not ship a built-in retry client: noMicrosoft.Extensions.AIcapability does, because resilience belongs in the HTTP pipeline
(
Microsoft.Extensions.Http.Resilience). The.Use(...)primitive still lets a consumer wrap a customretry or cache decorator when they want one.
API Usage
Every snippet below is drawn from the runnable demo (
iocrclient-demo), which exercises this exactsurface across four engines and a MEDI RAG pipeline.
One interface, four engines — the payoff. Vision LLM, Mistral OCR, Azure Document Intelligence, and
Azure Content Understanding each speak a completely different wire protocol. Behind
IDocumentExtractionClientthey areIDocumentExtractionClient; the consumer loop never changes when you add or swap a provider.Stream pages as they finish — the
IChatClient.GetStreamingResponseAsynctwin. EachDocumentExtractionPageResultcarries one completed page (plus progress + usage), so a RAG pipeline can chunk andembed early pages while later pages are still being OCR'd;
ToDocumentExtractionResultAsyncreduces the stream back tothe same
DocumentExtractionResultthe unary call would return.Compose middleware with the builder — the same shape as
ChatClientBuilder: you compose a client,you don't set flags.
Register with dependency injection — the consumer depends only on
IDocumentExtractionClient; swap the enginewithout touching downstream code.
Configure per-request options through the pipeline (for example, a local Ollama GLM-OCR engine that
needs a task-prefix prompt injected even when the caller passes no options):
Bridge into a MEDI ingestion / RAG pipeline. A hosted document-AI service is a reader (the
LlamaParse-as-reader shape). One provider-agnostic reader composes any
IDocumentExtractionClient:Extract from a remote URI (opt-in download).
ExtractAsync(UriContent)never touches the network;ExtractFromUriAsyncis the explicit counterpart that fetches http(s) bytes with a caller-ownedHttpClient:Alternative Designs
IChatClientwith multimodal content + a prompt. Rejected: loses nativetables/bbox/confidence, is nondeterministic and token-expensive, and cannot represent non-chat engines
(Azure DI, CU, local ONNX) at all. A vision LLM is supported as one provider behind
IDocumentExtractionClient(
VisionLlmDocumentExtractionClient), not as the contract. The prototype demonstrates this: three of four realengines use no
IChatClient.VisionOnlyapproach). Rejected: a model choice hardened into a readermode; temporal coupling; not decoratable (no middleware); does not generalize across engines.
new MistralDocumentExtractionClient(isFoundry: true)). Rejected:smuggles host branching into the type; breaks composition. Follow the
OpenAIClient/AzureOpenAIClientprecedent: the interface is portable; concrete classes split by host where the host leaks.
contract; modeled as the sibling
IDocumentAnalysisClientpeer (the STT/TTS precedent). See Relatedand future work.
Validated design decisions (resolved by the prototype + sources)
BoundingRegion.Polygonis apossibly rotation-skewed quad; an axis-aligned rect would be lossy. Resolved with
DocumentBoundingRegion.PolygonasIReadOnlyList<DocumentPoint>(populated natively by DI, byFromRectanglefor Mistral's rect, reused for field grounding);
GetBounds()returns theDocumentBoundingBoxstruct forcoarse filters. Tables resolve to
DocumentTable{RowCount, ColumnCount, Cells?, MarkdownRepresentation?}(DI cells primary, Mistral markdown fallback). The prototype demonstrates this: the DI polygon and
the Mistral rect→quad both flow losslessly into the same reader metadata.
(Sources: Azure/azure-sdk-for-net
DocumentTable/DocumentTableCell/BoundingRegion; Mistral OCR schema.)DocumentPointover a flatfloat[]. The earlier open question (flatIReadOnlyList<float>vs apoint shape) is now resolved: a typed
DocumentPointlist makes an odd/empty coordinate countunrepresentable and reads correctly without documentation, while still carrying DI's 4-vertex quad
without loss.
DocumentPage.PageNumber. Page identity matches how documents, Azure DI, and downstreamprovenance number pages (1-based), removing the off-by-one that a 0-based
Indexforced on adapters.ExtractAsync+ streamingExtractPagesAsync(updated in API review;IProgressretired).The proposal originally shipped unary-only with an
IProgress<DocumentExtractionProgress>hook and deferred streaming.API review aligned it to the family instead: every sibling ships a streaming twin
(
IChatClient.GetStreamingResponseAsync,ISpeechToTextClient.GetStreamingTextAsync), and onnetstandard2.0/net462there are no default interface methods, so streaming cannot be added laterwithout a new interface — it has to ship now.
ExtractPagesAsyncyields oneDocumentExtractionPageResultperpage as it finishes (progress fields
PagesProcessed/TotalPagesride on the update, replacingDocumentExtractionProgress), letting large-document RAG chunk/embed early pages while later pages are stillparsing;
DocumentExtractionPageResultExtensions.ToDocumentExtractionResultAsyncreduces the stream back to a singleDocumentExtractionResult(the
ToChatResponseAsyncpattern). The prototype demonstrates this: each engine surfaces per-pageupdates and the reducer reassembles the same
DocumentExtractionResultas the unary call.Microsoft.Extensions.DocumentExtraction.Abstractions, namespaceMicrosoft.Extensions.DocumentExtraction,[Experimental("MEDE0001")]from day one,area-ai, full stack (abstractions +Delegating/Logging/OpenTelemetry/ConfigureOptions middleware + DI) in one PR. First mover: no existing
OCR/
IDocumentExtractionClientissue in dotnet/extensions.emits elements in a single reading order; consumers want "iterate the page top-to-bottom" without
re-deriving order from geometry.
DocumentPagetherefore exposes oneElementslist over anDocumentElementbase ($type-discriminated, theAIContentshape), withOfType<DocumentTable>()/OfType<DocumentImage>()projections — replacing the parallelBlocks/Tables/Imageslists. The base isshaped to be promotable to a shared document-element type (cross-team track), so this is not a
redesign later. Tables can carry nested
DocumentTableCell.Elementsfor structured cell content, keepingthe flat
Contentstring as a convenience.cell kinds vary by engine and grow over time, so
DocumentBlockKind/DocumentTableCellKindstayChatRole-style open structs (well-knownsRowHeader/RowSectionadded to cells). Coordinateunits and origins are physically bounded, so
DocumentCoordinateUnit(Pixel/Point/Inch/Normalized)and the new
DocumentCoordinateOrigin(TopLeft/BottomLeft) are closed enums.CoordinateUnit+CoordinateOriginliveon
DocumentPage, not the document. Primary sources refuted a "one unit per document" assumption: Googlemodels
Page.Dimension { width, height, unit }per page, and Azure DI's per-pageDocumentPage.Unitispixelfor image inputs andinchfor PDF — so one analyze call over a mixed batch returns differentunits on different pages.
DocumentPage.Width/Heightgroup into a newDocumentPageDimensionsvalue type(the extent), kept as a sibling to unit + origin (which together describe the coordinate system).
A continuous page-rotation
angleis deferred toAdditionalPropertiesfor v1 (element rotation isalready carried losslessly by the polygon
DocumentBoundingRegion).Markdown→Text. Engines return plain text far more often than genuine Markdown; the property isrenamed
TextonDocumentPageandDocumentExtractionResult(droppingMarkdown). A realMarkdownproperty can bere-added later if a genuine-Markdown need appears (additive).
DocumentExtractionUsagecarries optional tokens. The vision-LLM path reports token usage; classic OCR enginesreport only page counts.
DocumentExtractionUsagegains nullableInputTokenCount/OutputTokenCount/TotalTokenCountalongside
PagesProcessed(mirrors MEAIUsageDetails), left null by engines that don't bill tokens.Basis: a 12-engine cross-provider output-model survey — the five demo providers (Azure Document
Intelligence, Azure Content Understanding, Mistral OCR, Foundry Mistral, a vision-LLM adapter) plus an
exhaustive pass over LlamaParse, LiteParse, Microsoft MarkItDown, IBM Docling, Unstructured.io, AWS
Textract, Google Cloud Document AI, Upstage Document Parse, and Adobe PDF Extract — spanning cloud OCR,
agentic/VLM parsers, open-source libraries, and markdown-first converters, each with primary-source
citations.
Staged decisions / open questions (by track)
The reshape above is the "just-do" round that ships on #7588. The genuinely cross-team, tooling, and
empirical-validation questions run as parallel tracks that do not block the PR:
Cross-team socialization (does not block #7588)
Shared document-model package + assembly home(RESOLVED 2026-07-30 — own peer library). Thesurface moved out of
Microsoft.Extensions.AIinto its own library,Microsoft.Extensions.DocumentExtraction(like
VectorData/DataIngestion). The neutralDocument*model is kept extraction-agnostic so alater hoist into a shared home co-owned with
DataIngestionstays a namespace-forward, not aredesign — that hoist is a deferred follow-up (this round, MEDI just references
DocumentExtraction.Abstractions).IDocumentExtractionClientdirectly? (UNBLOCKED 2026-07-30).The move removes the "AI"-branding blocker; a prototype (SPIKE-12) already implemented the reshaped
interface directly over
Azure.AI.DocumentIntelligence. A first-party impl belongs in a provider-ownedsatellite package (mirroring
DataIngestion.MarkItDown/.Markdig), owned by the DI team.IChatClientadvertise vision capability? Capability metadata vs. an early/compile-timethrow when a non-vision model is handed an image — a MEAI-team discussion (unchanged; still open).
Family rename(RESOLVED 2026-07-30 — executed as the library move).Ocr*becomes thedomain-noun
Document*/DocumentExtraction*scheme as part of the extraction, applied in the straw-man across all five TFMs; a review-driven name change stays a single mechanical[Experimental]re-rename.Implementation spike (separate impl package, not Abstractions)
(RESOLVED — SPIKE-04/05). Kept. A vision-LLMGetServicefor v1IChatClient.AsDocumentExtractionClient()prototype confirmed that with a non-public adapter type,GetService<IChatClient>()is the only public unwrap to the inner chat client. The shippingmiddleware's
GetService<DocumentExtractionClientMetadata>()use is conceded as partly biased evidence (copiedfrom
IChatClient's middleware), but the real need — a telemetry decorator discovering anarbitrary client's provider identity — remains; dropping
GetServicewould only trade it for amandatory
Metadataproperty while losing the provider-SDK escape hatch and the adapter unwrap.One optional seam covers all three; reversible while
[Experimental].Validation before formal review (via the demo + a DataIngestion reader)
Justify(RESOLVED — SPIKE-06). An exhaustiveRawRepresentation/AdditionalProperties14-engine raw-output inventory shows every engine emits structured data with no first-class home
(Content Understanding's geometry is a source string, unparseable into a polygon), so both escape
hatches stay;
AdditionalPropertiesguidance tightened to real provider signals only. Three fieldscleared the promote bar:
DocumentTableCellgained per-cell geometry + confidence + raw + properties(mirrored from
DocumentElement),DocumentPagegainedRawRepresentation, and detected language isdeferred to a standardized
detectedLanguageskey (typedDocumentDetectedLanguagelater).Group(RESOLVED — SPIKE-07). Done:Width/Heightinto anDocumentPageDimensionsDocumentPageDimensionsis folded into the surface above, as a per-page sibling to unit + origin.
Confirm(RESOLVED — SPIKE-08). Units are notPixelis the dominant coordinate unitPixel-dominant: they spread across Pixel/Point/Inch/Normalized and are reported per page, so unit +
origin moved onto
DocumentPage. See the reshape rationale above.Confirm no cross-platform BCL geometry type fits(RESOLVED — SPIKE-09). No BCL type carriesthe load-bearing shape — a page-scoped, rotation-capable polygon (
DocumentBoundingRegion).System.Numerics.Vector2has no region type;System.Drawing.RectangleFis axis-aligned only (itcannot hold Azure DI's skewed quad) and the BCL has no polygon primitive at all. Reusing
System.Drawing.PointFforDocumentPointalone would delete no type while coupling OCR coordinates to adrawing namespace. The three OCR-owned primitives stay; note the package already uses a BCL value
type where one fits exactly (
ImageGenerationOptions.ImageSizeisSystem.Drawing.Size).Tooling
baseline/analyzer story (OCR would be the repo's first user), then adopting.
Still open from before
.UseDistributedCache(...)ships in v1 (OCR is expensive). Logging, OpenTelemetry, andconfigure-options middleware already ship, matching the
ISpeechToTextClienttemplate.DocumentBoundingRegion(lowest churn) vs. promote to a capability-neutralBoundingRegion. Recommendation:keep the prefix for v1.
ExtractPagesAsync/DocumentExtractionPageResultuse Page for v1; revisit onlyif the shared-model track elevates types toward a chunk model.
Risks
IDocumentExtractionClientfocused on OCR / document extraction. Field extraction stays in thesibling
IDocumentAnalysisClientproposal (not this PR).prototype and matching the
ISpeechToTextClienttemplate. Retry is intentionally not a built-inclient: no
Microsoft.Extensions.AIcapability ships per-capability retry, since resilience belongs inthe HTTP pipeline (
Microsoft.Extensions.Http.Resilience); the.Use(...)primitive can still wrap acustom retry decorator. Distributed caching can follow the same builder shape, but API review should
decide whether it ships in v1.
DocumentPage.Elements(andDocumentTable.Cells) are settable / init-populated soproviders can fill them incrementally; consumers treat the returned result as read-only. API review may
prefer init-only / ctor-set here (constrained by the netstandard2.0 / net462 targets, which rule out
required/ DIMs).Related and future work
Future sibling:
IDocumentAnalysisClient(separate proposal)Field extraction (a document + a schema/analyzer → typed fields with confidence + grounding) is a
categorically different output contract than OCR→markdown, exposed by multiple providers (Azure
DI custom models, Content Understanding). It is best modeled as a peer interface, not a flag/overload
on
IDocumentExtractionClient— the same call M.E.AI made splittingISpeechToTextClient/ITextToSpeechClient. Itsgrounding would reuse the SAME
DocumentBoundingRegionpolygon and compose via the SAME builder, which is whythis PR ships the shared region primitive with
IDocumentExtractionClient. It carries its own harder design debates(schema/analyzer-id representation, the typed-field value model, lifecycle) and its own provider matrix,
so it is deliberately out of scope for this PR and will be its own proposal. Sketch (illustrative only):
References
VisionOnlyflag this proposal replaces.area-data-ingestionIngestionPipeline (dotnet/extensions #7488): the downstream consumer of anOcrDocumentReader.IVideoGeneratordotnet/extensions #7420).iocrclient-demo(oneIDocumentExtractionClientin front of four OCR engines, bridged into a MEDI RAG pipeline).