Skip to content

CapabilityProvider

The plugin contract every extension distribution implements.

Protocols:

  • CapabilityProvider.descriptors() -> tuple[CapabilityDescriptor, ...]
  • CapabilityProvider.create(capability_id: str) -> Any

CapabilityProvider is the plugin-facing boundary for advertising processing capabilities and creating their runtime implementations. Installed distributions expose providers through the indx.capabilities Python entry-point group, allowing indx to discover extensions without importing them directly.

descriptors() returns lightweight public metadata for the provider’s capabilities. create() resolves one of those capability IDs to the object that performs the actual work.

Discovery and planning need to know what work is available, but they should not load OCR engines, model weights, GPU runtimes, remote clients, or other expensive dependencies. Separating description from creation lets the registry build a complete inventory cheaply while deferring heavyweight initialization until execution selects a capability.

Stable capability IDs also give snapshots and plans a provider-neutral key. Routing can refer to a capability without depending on its package or concrete implementation class.

descriptors() returns an immutable sequence of CapabilityDescriptor values. Each descriptor declares:

  • A stable ID and version.
  • Its capability kind.
  • Supported devices and media types.
  • Runtime requirements.
  • Availability and, when unavailable, an explicit reason.

create(capability_id) accepts one advertised capability ID and returns its runtime implementation: a PageReader, which reads the named pages out of one loaded source and returns a PageOutput for each.

A capability that can specialize also implements SignatureDetector, whose detect() recognizes what kind of document it is holding. That runs at plan time, so it inherits preflight’s budget: cheap, local, deterministic, and free of OCR, models, rendering, and network. A detector that cannot answer within that budget answers with nothing. It receives bytes rather than extracted text, so the planner never has to grow a text extractor and a detector stays free to decide how much of the document it wants to look at.

A signature reaches the caller as a SignatureMatch on the plan, and the capability it nominates is placed ahead of the generic routing ladder rather than in place of it. A signature is a guess; keeping the ladder behind the specialist is what makes a wrong guess cost one cheap attempt instead of the document.

A signal is a property of the document type, never of one document (ADR-0045): structure the format states, or vocabulary the type carries by definition (an invoice says “invoice”), and it is shown firing on two independent documents before it ships. A word one author put on one document is not a signal, and a name a deployment would want to vary is data on the output, not evidence.

Kind describes the work, never the door. A capability that generically reads its media type declares native_extraction, however specialized the machinery behind it; parser means the capability is only correct about a document something recognized first, and it reaches a route only through nomination. The worked example pairs the two on purpose: acme-plaintext reads the format, acme-purchase-order specializes when its own signature matches. A media type only a parser declares, planned without signature detection, comes back as an unsatisfied plan naming the parser and the flag.

One protocol covers every reading kind. Native extraction, OCR, a vision model and a human queue differ enormously in what they cost and how they fail, and not at all in what they are asked. A protocol per kind would put the routing ladder’s rungs into the type system, and the policy routes over kinds precisely so a third-party OCR distribution behaves exactly like the first-party one.

The set of kinds is closed, and so is the ladder over them. CapabilityKind has six members and an install cannot add a seventh, because a kind is a policy position – a price, a quality, a place in the fallback order – and a distribution that could declare one would be deciding what indx routes to rather than supplying something to route to. A distribution whose work is none of the six, such as classification, redaction, translation, speech or table structure, declares the nearest kind, inherits its ladder position, and asks the deployment to correct the numbers through INDX_ROUTING_ECONOMICS. Adding a kind, or making a preflight signal an observer already emits mean something, is a first-party policy change with a POLICY_VERSION bump.

The numbers are core-owned for the same reason, and the cost of that is worth stating rather than leaving to be discovered. A descriptor carries no cost, quality or latency, and the only override is an environment variable an operator sets. So a hosted OCR charging $0.05 per page inherits the per-kind default of $0.0005, is admitted against a caller’s maximum_cost_usd on a number a hundred times too low, and the plan’s estimates.cost_usd publishes that figure as its price until the deployment writes the real one down. A self-declared price would be what a capability uses to buy its way into a route; the party paying the bill is the one with a reason to be honest about it.

  • Keep descriptor discovery lightweight and free of model initialization.
  • Advertise stable, non-empty IDs and versions.
  • Report availability consistently: available capabilities have no unavailable reason, while unavailable capabilities provide one.
  • Create the implementation associated with the requested advertised ID.
  • Keep credentials, private endpoints, and implementation-only configuration out of descriptors.
  • State each line it located on PageOutput.lines when its engine knows positions, as generic-ocr does from the boxes the recognizer reports: TextLine(text, bbox), the box normalized like Block.bbox. The lines never reach the wire; indx-chunker-lines cuts them into chunks with rectangles, and a reader that states none leaves the page to the chunker floor (ADR-0047).
  • Return one PageOutput for every page a read() call was given. There are two ways to say a page could not be read, and the executor answers both by descending to the plan’s next capability: produce nothing for it, or produce one carrying status failed and a reason. Prefer the second – the reason reaches the trace, where a caller can see it. An empty text says the opposite of both: the page was read and held nothing.
  • status unreadable is not a failure to read but a verdict about the content, and it is kept rather than failed over. That is what makes the terminal manual-review rung terminal; a capability that means “try someone else” says failed.
  • Report a per-page confidence on CONFIDENCE_METADATA_KEY when the engine produces one, the way a billed capability reports COST_METADATA_KEY. It reaches ExecutionActuals.quality as a self-report, and a deployment that has set INDX_VALIDATION_MIN_CONFIDENCE refuses output below it. A capability with no calibrated number reports none: absent is no opinion, never zero.
  • Avoid coupling the provider package to router, executor, or application implementations.

A provider that embeds also satisfies EmbeddingSpaceProvider, whose embedding_spaces() declares whole spaces: dimension, metric, normalization, and every embedder in them. Each embedder is at the same time one of the provider’s EMBEDDER-kind descriptors, which is how availability and create() reach it; for those IDs create() returns a VectorEncoder, one encode() for text and image alike, fed the modality the embedder declared. A space is declared whether or not its embedders can run today, and one provider declares a whole space: two implementations share a space ID only inside one distribution, where a test can demonstrate their vectors compare.

A provider that makes a new media type plannable also satisfies SourceObserverProvider, whose source_observers() declares the observers it ships. It is optional the same way: a provider reading a format indx already observes never implements it. Unlike a space, an observer names no ID and joins no descriptor, so it does not move the snapshot – descriptors() returning empty is a truthful declaration for a distribution that observes and reads nothing, which is exactly what indx-observer-pdf, indx-observer-image, indx-observer-office, indx-observer-text and indx-observer-email are. Their readers ship separately — indx-capability-native-extraction and indx-capability-office-extraction — so what makes a format plannable and what makes it routable are two installs, and an operator can have either without the other.

The capability registry discovers providers and combines their descriptors into a deterministic snapshot. The planner selects descriptor IDs from that snapshot. During execution, the selected provider creates the corresponding runtime implementation lazily.