API contract
The full HTTP, CLI, and Python contract, including the typed error envelope.
The HTTP API is a transport over the same public Pydantic contracts used by the Python facade. The initial contract is synchronous and stateless.
Operations
Section titled “Operations”| Operation | Purpose |
|---|---|
GET /health |
Process liveness and package version |
GET /v1/capabilities |
Current versioned capability and embedding-space snapshot |
POST /v1/plan |
Inspect processing decisions without executing them |
POST /v1/encode |
Execute an optional supplied plan or create one first |
POST /v1/embed |
Encode a text or image query into a selected embedding space |
Inputs
Section titled “Inputs”Planning and encoding accept either:
application/jsonwith a URI source or base64 inline bytes; ormultipart/form-datawith a binaryfileand a JSONrequestform part. That part isPlanRequestPart/EncodeRequestPart/EmbedRequestPartin the schema — the JSON request without the field the upload supplies, since the file is the source. It is a named schema rather than an opaque string so a generated client can build it.
Query embedding accepts inline text, a URI/inline image in JSON, or an image in the same multipart form. Both transports validate into the same domain model.
Capabilities
Section titled “Capabilities”GET /v1/capabilities returns the current snapshot: the capabilities and
embedding spaces this installation can route against, plus the routing policy
version they belong to.
The snapshot ID is content-addressed, so the same inventory always has the same
identity and a caller can detect that its environment moved. An optional
snapshot_id query parameter re-requests a specific retained snapshot instead
of the current one; an ID that is not retained returns 404 rather than
silently answering with a different inventory.
Capability availability follows what is installed. A provider distribution advertises its capabilities through an entry point, so installing one is what makes its capability appear here, and a provider that fails to load is omitted rather than taking the endpoint down. Snapshot IDs are stable for a given install, not across releases.
Planning
Section titled “Planning”A successful plan contains:
- deterministic
plan_idand caller/request ID; - source content digest;
- capability snapshot and policy versions;
- observed public decision reasons and signature matches;
- document default, page overrides, optional region overrides;
- ordered fallbacks and estimated cost/latency/quality; and
- an explicit ready or unsatisfied status.
The response is a PlanResult: that plan under plan, and beside it components
naming the distributions that produced it – the loader that fetched the bytes,
the sniffer that named the media type and the observer that produced the
evidence, in the shape the encode trace uses. Only the plan is hashed and
bound; the components are diagnostics, and a plan supplied back to encode is
the plan field alone.
Private raw preflight features are not part of the wire contract.
signature_detection asks the installed parsers whether they recognize the
document. A match reports the signature, the scope it covers, the evidence it
matched on, and the capability it nominates; that capability then leads the
route for those scopes while the generic ladder stays behind it as fallbacks, so
a wrong signature costs one cheap attempt rather than the document. confidence
is the share of independent signals matched, a signal counting in proportion when
it is one (the share of a process chart’s symbols that carry a label), not a
calibrated probability – nothing in this release has been measured against a
labelled corpus. A match whose nominated
capability is not installed is still reported, because the caller is entitled to
know their document was recognized even when the thing that recognized it is
gone; it simply does not change the route.
A media type only a parser declares, planned with signature_detection unset,
comes back unsatisfied naming the parser and the flag. Nomination is a parser’s
only door, and the silent alternative – routing every page to manual review –
would read exactly like an install carrying no parser at all.
Encoding
Section titled “Encoding”POST /v1/encode accepts an optional plan. If present, its source digest, media
type and snapshot must match the request. The executor may use the selected route or its
declared fallbacks only; it cannot silently replan.
It also accepts metadata, a free-form JSON object of the caller’s own labels –
an owner, a tenant, a sensitivity classification. It travels with the source and
comes back on the document block, and indx enforces none of it: who may read a
vector afterwards is the index’s question rather than the router’s. The field is
on EncodeRequest alone. plan_id is the hash of every field of the plan it is
decided into, so a label on PlanRequest would give two identical documents two
plans for a value that routes nothing. Every key the executor writes is refused
rather than accepted: languages from an installed language detector,
classification and chunk_classification from the classifiers a request
enabled, entities from the entity extractors it enabled, enrichment and
chunk_enrichment from the enrichers it enabled, and a label silently replaced
is a label the caller believes travelled and did not.
And it accepts classification, extraction and enrichment, all absent by
default, all naming installed implementations by ID per unit: classification
and enrichment take document_ids, page_ids and chunk_ids, extraction
takes page_ids and chunk_ids, and each list is asked in the order given. Nothing bounds how much
of the document an implementation is shown – each is handed the full text of its
unit and applies its own bound if it needs one, so there is no sample field and
no deployment default behind it
(ADR-0033).
Three refusals, all 422, all before the source is fetched. An ID no installed
provider declares is unknown_classifier, unknown_extractor or
unknown_enricher, naming the ones that are. One that would hand the text to an external service is
data_residency when the request carries that constraint. And a chunk_ids
entry in a request that did not ask for CHUNK granularity is
granularity_required, because there would be no chunks to annotate and an empty
answer is indistinguishable from no opinion. The IDs a caller may name are on the
capability snapshot, outside its content hash; they are one namespace across all
eight ports, so no two may share a string
(DocumentClassifier,
classifiers,
entity extractors,
enrichers).
The response returns the plan, a flat addressable block tree, embedding-space
descriptors, execution trace, and usage. Blocks represent document, page,
region, and chunk scopes and link through id / parent_id.
How many chunks a page yields is installation-dependent: boundaries are drawn
by installed chunker distributions (Chunker), first answer
per page wins, and indx-chunker-page is the floor that keeps one chunk per
readable page. An install carrying no chunker returns no chunk blocks and no
document vectors, the way a stock install resolves no URI.
A chunk block carries either a bbox or a bbox_reason, never both and never
neither. bbox_reason is one of no_geometry (the chunker knows no positions:
worksheets, Word sections, text and email; a page read by recognition gets a
rectangle per recognized line instead), hidden (the source
marks the shape hidden; its text is read, nothing is drawn) or unresolved (the
format has positions and this shape’s could not be resolved). PDF pages read by
the text layer and PowerPoint slides carry boxes; every other chunk today says
no_geometry. Document and page blocks carry neither field.
The document block’s provenance names what the source was read as and where it
came from – media_type, plus source_uri for a URI source or filename for
an inline one that supplied a name – beside the plan bindings every block
carries. Provenance rather than metadata, because the glossary reserves this dict
for where a block came from and metadata for what was extracted out of it; an
exported row has to say where it came from without the plan beside it, and a
digest is not something anyone searches for.
Its metadata carries the caller’s labels and, where a language detector is
installed, languages: {"language": "ja", "confidence": 0.98} entries, highest
first, on each readable page block and, averaged over the pages by how much text
each score was computed over, on the document block. A stock install detects
nothing and carries no key at all
(LanguageDetector).
Where the request enabled a classifier, the document block’s metadata also
carries classification: {"document_type": [{"label": "invoice", "confidence": 0.9}]},
a facet per key, labels highest first, the first enabled classifier with an
opinion on a facet winning it. Facet names are the classifier’s own, and a
document nothing could read carries no key. A page classifier writes to each page
block instead, and a chunk classifier to the document block’s chunk_classification,
keyed by chunk block ID, because chunk blocks carry no metadata.
Where the request enabled an entity extractor, the document block’s metadata
carries entities: the spans found, keyed by the block each was found in, each a
surface text with a [start, end) character range into that block’s own text
and a confidence; and beside them a count per (label, NFKC-normalized surface form) with the raw surface forms kept. A count is not an identity – nothing here
resolves coreference
(entity extractors).
Where the request enabled an enricher, the document block’s metadata carries
enrichment: {"summary": "...", "tags": [{"label": "billing", "confidence": 0.9}]},
only the parts answered, the first enabled enricher with an opinion on a part
winning it. A page enricher writes to each page block instead, and a chunk
enricher to the document block’s chunk_enrichment, keyed by chunk block ID
(enrichers).
Fields a specialized capability extracted ride on metadata of the block that
stated them – an invoice’s number, dates, and amounts under an invoice key.
Money stays a digit string rather than a number, because money in a float is a
rounding waiting to happen, and dates stay the strings the document printed,
because normalizing 5/8/2026 means deciding whether it is May or August and
nothing here knows the issuer’s locale.
A slide the process-chart signature recognized carries process_chart on its page
block, the same way: {"title", "nodes": [{"id", "kind", "symbol", "role", "label", "bbox"}], "edges": [{"id", "kind", "from", "to", "arrow", "path"}]}. A node’s kind is
symbol (with symbol in triangle, ellipse, diamond, double_triangle and role in
material, operation, inspection, assembly), picture, container or text; an
edge’s is connection (a connector between two symbols, arrow saying whether the file drew
one, path its two ends) or contains (a container and a node whose centre it encloses).
IDs are shape: plus the shape’s own cNvPr id, stable within a file; bbox and path
use Block.bbox’s normalized convention, so a node is overlaid with the arithmetic a chunk
uses. The signature is page-scoped: the match names the slide, and process-chart-parser
leads only that page’s route. Fields absent from a node or edge are omitted, not null.
A block whose scope could not be read carries unreadable or failed with a
reason rather than being omitted, and the trace records every capability that
was attempted, including the ones that failed and what stopped them. That is
what makes a fallback visible: a trace showing only the capability that finally
worked would hide the reason fallbacks exist at all.
The trace’s components names what took part without a capability ID to be
named by: {"role": "chunker", "distribution": "indx-chunker-pdf", "pages": [1, 2]}.
Roles are loader, sniffer, chunker, language_detector and classifier;
distribution is the installed package that declared the component, pages are
the pages a chunker cut or a detector answered, and a classifier repeats the id
the request named beside the facets it won. An inline source of a declared type
names no loader and no sniffer, because nothing fetched or recognized it. The
observer is absent by construction – execution never observes – and belongs to
the plan response once that has a wrapper to carry it.
Two outcomes deliberately are not errors. A plan whose constraints nothing
installed can satisfy comes back 200 with a failed document block and a failed
trace, because nothing conflicts and the request was valid – flattening the
caller’s constraints into an error string would lose the part they can act on. A
route whose every declared capability failed is a 503: the routing ladder ends
in manual review by construction, so exhausting one means an installation missing
its terminal fallback, or a caller who set constraints.fallbacks_allowed to
false. Together with the constraints.capability_ids allowlist, that flag is
how a request names its reader (ADR-0063). An allowlist that leaves a page nothing
to read it, or names an ID the snapshot does not hold, is an unsatisfied plan whose
line starts with capability_ids:.
Each vector records its embedding_space_id and embedder_id. An embedding
space declares vector dimension, distance metric, normalization, and compatible
document/query embedders. Text and image embedders share a space ID only when
their vectors are directly comparable.
Query embedding
Section titled “Query embedding”POST /v1/embed requires an embedding-space ID. It chooses that space’s
query-role embedder for the input modality and returns vectors plus the exact
public embedder configuration used. Provider credentials and private endpoints
never appear in the descriptor.
A space that is not in the snapshot, or one with no query-role embedder for the
input’s modality – an image into a text-only space – is a 422 with
type: invalid_embedding_space and a code naming which. An embedder that is
advertised but cannot run, or fails, is a 503: embedders have no fallback
ladder, because a caller who named a space asked for exactly that one.
Every install carries default-text, a 256-dimension feature-hashed character
trigram space. It is a lexical baseline – two strings that share character
grams land near each other and nothing else does – offered so that vectors
exist without a model, not as retrieval quality. uv sync --all-packages --extra fastembed adds minilm-multilingual (384-d, text) and clip-vit-b32
(512-d, text and image queries in one comparable space) through fastembed, and
--extra hosted a hosted text space, hosted-text, configured with
INDX_EMBED_MODEL (a
LiteLLM model id) and INDX_EMBED_DIMENSION, optionally INDX_EMBED_API_KEY,
INDX_EMBED_API_BASE, INDX_EMBED_TIMEOUT_SECONDS and INDX_EMBED_RETRIES.
The dimension is declared rather than discovered because a space has to state
it before any vector exists and a hosted provider only answers for a fee. The
hosted embedder’s device follows the vision lane’s rule: a model LiteLLM routes
to this box is gpu, a hosted one is external, and a plan request naming that
space under data_residency is refused the way the hosted vision lane is. What
a hosted embedder reports charging joins cost_usd on the trace and the usage.
Document vectors are produced by POST /v1/encode for every space named in
embedding_space_ids, one per chunk block; page and document blocks carry none.
A chunk is usually text, and sometimes a rendered page. A page no capability
could read as text has no boundary to cut, and used to leave a page block
carrying its reason and nothing retrievable; indx-chunker-pdf renders it into
one image chunk instead. A space is selected by what a run produces rather than
by naming a text embedder, and what a run produces is
DOCUMENT_EMBEDDING_MODALITIES – (text, image), in that order, so a page
that was read never also pays for a render. A space whose only document-role
embedder reads images is therefore routable: it answers for exactly the pages
that were rendered, and a document may carry text vectors and image vectors in
one space. What still comes back unsatisfied is a space declaring no document
lane at all, saying it embeds no document input this run produces rather than
reporting a missing text embedder it in fact has. Query lanes remain
unconstrained by any of it.
Errors
Section titled “Errors”All failures use one typed envelope containing type, code, message,
optional param, and optional request_id. That includes the transport-level
ones the router raises before an operation is reached: an unknown path answers
404 and a wrong method 405, both with type http_error and code
not_found or method_not_allowed. They belong to no operation, so they are
not listed in the per-operation responses of the OpenAPI document.
| Status | Meaning |
|---|---|
404 |
Pinned capability snapshot ID is not retained (GET /v1/capabilities) |
409 |
Supplied plan does not match the source or capability snapshot |
413 |
Request exceeds the server-advertised input limit |
415 |
Content type is unsupported, no installed loader resolves the source URI scheme (unsupported_source), or no installed capability reads its media type |
422 |
Request validation failed, the source did not resolve to the expected bytes (source_unreadable), needs a password this server was not given (source_encrypted), names a destination a loader’s guard refuses to fetch (source_forbidden), asks for a block granularity nothing installed produces (unsupported_granularity), or an embedding space cannot serve the request as asked |
500 |
An error the server did not type; the message is generic and the X-Request-ID header locates the log line |
501 |
Contract exists but its application implementation has not landed |
503 |
A declared capability is temporarily unavailable |
504 |
The request exceeded INDX_REQUEST_TIMEOUT_SECONDS, or the caller disconnected before the work finished |
Every response carries an X-Request-ID header: the value the caller sent, or
one the server generated. It is the correlation ID on every log line the
request produced, and it is distinct from the envelope’s request_id, which is
the caller’s own identifier from the request body and is echoed whenever the
body carried one – including on validation failures. param is the dotted
location of the field that failed, as pydantic reports it; a member of a
discriminated union carries its tag as a segment (source.uri.uri for an empty
uri in a {"type": "uri"} source), and body means the body was not JSON.
INDX_REQUEST_TIMEOUT_SECONDS bounds the time a request may spend in
planning, execution, or embedding; unset or 0 means unbounded. The deadline is
checked once the source is loaded and then between capability attempts, never
inside one, so a source fetch or a capability mid-call finishes before the
504 is answered. Timeouts and disconnects stop the work; they never fall
back to another route.
The input limit is advertised as limits.max_input_bytes on the capability
snapshot and configured per deployment with INDX_MAX_INPUT_BYTES. It bounds
decoded source bytes, not the encoded request body, and is deliberately excluded
from the snapshot’s content hash so raising it does not invalidate existing plans.
resolvable is excluded for the same reason and is not the same kind of thing.
It reports what this installation can reach – resolvable.schemes – and what
it can look at – resolvable.observable_media_types – and both grow by
installing a distribution rather than by changing a setting. Gaining either
widens what can be planned next without changing what any outstanding plan
selected, so hashing them would answer every plan in flight with a 409 for a
reason unrelated to routing.
Planning estimates start from per-capability-kind defaults that a deployment
corrects with INDX_ROUTING_ECONOMICS, a path to a JSON object keyed by
capability ID or kind, whose values may set quality, cost_usd_per_page, and
latency_ms_per_page. An ID wins over its kind. quality is between 0 and 1,
the other two are at or above zero, and latency_ms_per_page is a whole number
of milliseconds. The numbers are private routing
input, not part of the wire contract, but they move estimates and can change
which route a constrained request receives, so a malformed file is refused
rather than ignored, an unknown field and a fractional latency included.
The variable is operator-side because a capability that could price itself would
be buying its way into a route, and the deployment paying the bill is the party
with a reason to be honest. What that costs is real and is not hidden: a hosted
OCR charging $0.05 per page is admitted against maximum_cost_usd on the OCR
default of $0.0005 and publishes that as estimates.cost_usd, until someone
writes the real figure into this file. Correct a specific install by capability
ID; correct a whole class of work by kind.
Recognition engines ship behind extras, so a default install is a router and a
native-extraction lane with no ONNX runtime, no model weights, and no
credentials. uv sync --all-packages --extra ocr adds PP-OCR through
onnxruntime; --extra vlm adds a vision lane through LiteLLM. What an install
does not carry is advertised as unavailable with the reason, and the routing
ladder descends past it rather than failing the request.
The vision lane is configured with INDX_VLM_MODEL (a LiteLLM model id),
optionally INDX_VLM_API_KEY and INDX_VLM_API_BASE, and
INDX_VLM_TIMEOUT_SECONDS / INDX_VLM_RETRIES / INDX_VLM_DPI. The OCR lane
takes INDX_OCR_DPI and reports PP-OCR’s own per-line scores as the page’s
confidence, weighted by the length of the line each was given for. It scores
recognition and not recall: text the detector never boxed is absent from the
number rather than counted against it. Both raster lanes also take INDX_OCR_MAX_PIXELS and INDX_VLM_MAX_PIXELS (default 178956970, Pillow’s own decompression-bomb ceiling): a page that would rasterize to more pixels than that at the lane’s DPI is refused before the bitmap exists, and returned as unread so the plan’s next rung gets it.
INDX_TEXT_ENCODING belongs to the text lane and is a comma-separated ordered
list, default utf-8-sig. Each name is tried strictly and the first that
decodes wins; a source matching none of them fails rather than being mangled
into whichever was tried last, and an encoding Python does not know names the
variable in its message rather than reporting the source as bad. It exists
because Japanese public-sector CSV is overwhelmingly Shift_JIS — 気象庁 and
東京都オープンデータ both serve CP932 — so a deployment reading those sets
utf-8-sig,cp932. UTF-8 leads because it is the strong test: arbitrary bytes
rarely decode as valid UTF-8, while CP932 decodes almost anything, so ordering
is what stops a UTF-8 file being read as mojibake. The default is utf-8-sig
rather than utf-8 because it is the same codec for a file with no byte-order
mark and strips one for a file that has it.
INDX_CHUNK_DPI (default 120) is the third of these and belongs to
indx-chunker-pdf, which renders a page no capability could read as text into a
single image chunk so it has a vector at all. It is read per call like the other
two. A value that is not a positive integer raises rather than falling back to
the default, but the raise does not fail the encode: the executor skips the
chunker for that document with a logged warning and the page floor chunks it
instead, so the misconfiguration shows in the log and in coarser chunks.
INDX_LANGUAGE_CANDIDATES, INDX_LANGUAGE_MINIMUM_CONFIDENCE (default 0.05)
and INDX_LANGUAGE_LOW_ACCURACY belong to indx-language-lingua, behind the
lang extra. The candidate list is ISO 639-1 codes, comma separated, and unset
means all seventy-five: honest and expensive, since every language loaded costs
memory and every language considered is one more way to be confidently wrong
about a short page. The floor is not optional decoration – the engine returns a
value for every language it was built with, so without one a page would carry
seventy-five entries of which seventy-four are noise.
INDX_NER_PATTERNS_GAZETTEER points indx-ner-patterns at a JSON object of
{label: [surface, ...]}, matched literally and longest first beside the shipped
patterns, so a deployment’s customer list or product names come back as spans
under the label it chose. Unset means the patterns alone.
INDX_NER_VIBRATO_DICTIONARY and INDX_NER_VIBRATO_CACHE belong to indx-ner-vibrato,
behind the ja extra. The first names a compiled vibrato dictionary, system.dic or
system.dic.zst, replacing the IPADIC release the lane otherwise downloads once into the
second (default ~/.cache/indx-ner-vibrato) and verifies by digest on every read. Without
the extra the distribution advertises no extractor at all.
INDX_NER_ONNX_MODEL (default Xenova/bert-base-multilingual-cased-ner-hrl),
INDX_NER_ONNX_MODEL_FILE (default onnx/model_quantized.onnx),
INDX_NER_ONNX_MAX_TOKENS (default 512), INDX_NER_ONNX_WINDOW_CHARACTERS
(default 400, how much text the model is shown at once, cut at whitespace) and
INDX_NER_ONNX_MINIMUM_CONFIDENCE (default 0.5, the mean token score a span
needs) belong to indx-ner-onnx, behind the ner extra. The weights download on
first use into the Hugging Face cache and never during discovery; without the
extra the distribution advertises no extractor at all.
INDX_NER_LLM_MODEL, INDX_NER_LLM_API_KEY, INDX_NER_LLM_API_BASE,
INDX_NER_LLM_TIMEOUT_SECONDS (default 60), INDX_NER_LLM_RETRIES (default
1), INDX_NER_LLM_MAX_CHARACTERS (default 6000) and INDX_NER_LLM_LABELS
(default person,organization,location,date,money,invoice_number, the labels
the model is asked for) belong to indx-ner-llm, behind the llm extra: its own
prefix, so a deployment may extract with a different model than it classifies
with. Without a model the distribution advertises no extractor at all. The
model answers surfaces and the lane locates each in the text; the device rule
is the LLM classifier’s.
INDX_ENRICH_EXTRACTIVE_SENTENCES (default 3, how many sentences make the
summary) and INDX_ENRICH_EXTRACTIVE_MAX_CHARACTERS (default 600, the ceiling
on it, cut at a whitespace boundary) belong to indx-enrich-extractive, the
enricher a default install carries. Wrapped lines are rejoined and a sentence
ends at a terminator in either language; the sentences chosen are the ones that
carry most of the text’s own vocabulary, by term frequency with a redundancy
penalty, returned verbatim in document order. A text with nothing to rank, a
form of short fields, gets its leading lines. It coins no tags.
INDX_ENRICH_LLM_MODEL, INDX_ENRICH_LLM_API_KEY, INDX_ENRICH_LLM_API_BASE,
INDX_ENRICH_LLM_TIMEOUT_SECONDS (default 60), INDX_ENRICH_LLM_RETRIES
(default 1), INDX_ENRICH_LLM_MAX_CHARACTERS (default 6000),
INDX_ENRICH_LLM_SUMMARY_SENTENCES (default 2, how long a summary the model is
asked for) and INDX_ENRICH_LLM_TAGS (default 5, how many tags at most) belong
to indx-enrich-llm, behind the llm extra: its own prefix, so a deployment may
summarise with a different model than it classifies with. One call answers both
parts. Without a model the distribution advertises no enricher at all, and the
device rule is the LLM classifier’s.
INDX_CLASSIFIER_WORDS_LABELS points indx-classifier-words at a JSON taxonomy
replacing the shipped one: facets of labels, each with names in ja and en, the
words that vote for it, and a threshold – an integer count of distinct words or
a fraction of the list. Unset means the shipped document-type, business-unit and
industry tables for Japanese and English business documents. The confidence is
the share of the list that matched, a ratio and not a calibrated probability.
INDX_CLASSIFIER_ZEROSHOT_MODEL (default the Xenova ONNX mirror of
MoritzLaurer/mDeBERTa-v3-base-xnli-multilingual-nli-2mil7),
INDX_CLASSIFIER_ZEROSHOT_MODEL_FILE (default onnx/model_quantized.onnx),
INDX_CLASSIFIER_ZEROSHOT_HYPOTHESIS (default This document is a {}.),
INDX_CLASSIFIER_ZEROSHOT_MINIMUM_CONFIDENCE (default 0.5),
INDX_CLASSIFIER_ZEROSHOT_MAX_TOKENS (default 512) and
INDX_CLASSIFIER_ZEROSHOT_LABELS belong to indx-classifier-zeroshot, behind the
zeroshot extra. Each label becomes one hypothesis against the text, and the
entailment probability is the label’s confidence; the token window is the
lane’s own bound, so the model reads the head of the document. The weights
download on first use into the Hugging Face cache and never during discovery.
INDX_CLASSIFIER_LLM_MODEL, INDX_CLASSIFIER_LLM_API_KEY,
INDX_CLASSIFIER_LLM_API_BASE, INDX_CLASSIFIER_LLM_TIMEOUT_SECONDS (default
60), INDX_CLASSIFIER_LLM_RETRIES (default 1),
INDX_CLASSIFIER_LLM_MAX_CHARACTERS (default 6000, the head of the document
the model is shown, since the contract bounds nothing) and
INDX_CLASSIFIER_LLM_LABELS belong to indx-classifier-llm, behind the llm extra. Without a model the
distribution advertises no classifier at all rather than a broken one, the
hosted-embedding rule. The classifier’s device follows the rule below: a model
LiteLLM routes to this box is gpu, a hosted one external, and only the
former may be enabled by a request carrying data_residency. What the vendor
charged reaches usage.cost_usd.
None of these appear on the wire, but the model id decides one thing that does:
a model LiteLLM routes to this box (ollama/…, or an api_base on localhost) is
declared as a gpu device and a hosted one as external, so a request carrying
data_residency keeps the local vision lane and is correctly refused the hosted
one. Changing the configured model therefore changes the capability snapshot ID,
which is the point: the residency guarantee a plan was decided under is no longer
the same guarantee.
INDX_VALIDATION_MIN_CONFIDENCE is the confidence a deployment refuses output
below, and it defaults to 0.0 – off. A page a capability scored under the
floor is treated as unread: it descends to the plan’s next capability, its output
is discarded, and the trace records the measured value against the floor. Raising
it therefore buys accuracy with money and latency, one rung at a time, which is
why nothing here picks a number for you: no threshold in this repository has been
measured against a labelled corpus.
The floor itself stays off the wire; what it is compared against does not.
trace.events[].quality is what a capability claimed about the pages that one
event covers – a page-scoped event carries that page’s own score, a
document-scoped one the mean over the group it stands for – and
actuals.quality is the mean over the document. Both are self-reports on exactly
the same footing as actuals.cost_usd, not measurements against ground truth,
and null when nothing reported one.
Sources arrive either as inline base64, which always works, or from a URI whose
scheme an installed loader resolves. The four indx ships – file, data,
http, https – come from indx-loader-file and indx-loader-http and are
installed distributions like any other, so the resolvable set is a property of
the installation rather than of indx: GET /v1/capabilities reports it as
resolvable.schemes, and a stock pip install indx resolves none of them. A
source’s media type is then recognized from its bytes before any declared value
is trusted — by the same installed observers, which is what makes recognition
installation-dependent too: an install carrying none believes the declared
Content-Type, then the filename.
There are three refusals here, all installation-dependent, and they are
deliberately distinguishable. A scheme no installed loader resolves is a 415
with code unsupported_source, and it comes first, because nothing else can
happen until the bytes arrive. A media type no installed capability reads is
a 415 without that code. A media type no installed observer can look at is a
422 with code source_unreadable, from preflight. The same source can
therefore be refused on one install and planned on another. GET /v1/capabilities lists what is readable under capabilities, and what is
reachable and observable under resolvable.
A loader may also refuse a destination it could have fetched: 422 with code
source_forbidden. indx-loader-http declines loopback, private, link-local,
reserved and multicast addresses unless INDX_LOADER_HTTP_ALLOW_PRIVATE_HOSTS
is set, re-checking every redirect hop and refusing a redirect into a scheme it
does not speak; INDX_LOADER_HTTP_TIMEOUT_SECONDS bounds the fetch.
indx-loader-file reads any path this process can until INDX_LOADER_FILE_ROOTS
names the directories it may read, after which anything resolving outside them is
refused. In both cases the message names the variable that would permit it and
the server log carries what was actually resolved – the response does not,
because telling a caller which internal address a hostname resolves to is what an
SSRF probe is asking for.
Above both loaders sits INDX_URI_SCHEMES, read by indx-source where the scheme
is dispatched. Unset, every scheme an installed loader declares is resolvable; set,
only the schemes listed are; and none disables URI sources altogether, which is
how a public host without authentication runs. A scheme that is installed but not
enabled is the same 415 unsupported_source as one no loader claims, with the
message naming the variable, and resolvable.schemes on the snapshot advertises
the enabled set rather than the installed one, so a client learns before its first
request whether the URI lane exists at all. Inline sources are never affected:
bytes handed over were never fetched. The roots keep their meaning under it as a
second layer – a deployment that later enables file still reads only the
directories it named.
indx-loader-s3 resolves s3://bucket/key, one object per URI, through boto3
behind the s3 extra; without the extra the scheme is still advertised and a
fetch is a 422 source_unreadable telling the operator to install
indx-loader-s3[s3]. Credentials come from INDX_LOADER_S3_ACCESS_KEY_ID and
INDX_LOADER_S3_SECRET_ACCESS_KEY (with INDX_LOADER_S3_SESSION_TOKEN when a
role’s temporary keys carry one), and when those are unset boto3’s own chain
applies – AWS_* variables, a profile, an instance role. That second layer is
deliberate, so a deployment on EC2 or EKS uses its role without copying
secrets, and it is why INDX_LOADER_S3_BUCKETS exists: a comma-separated
allowlist, unset meaning any bucket the way INDX_LOADER_FILE_ROOTS does, and
once set a URI naming any other bucket is 422 source_forbidden with the
variable in the message and the configured list in the log.
INDX_LOADER_S3_ENDPOINT_URL points the loader at an S3-compatible server and
forces path-style addressing; INDX_LOADER_S3_REGION is passed through when
set; INDX_LOADER_S3_TIMEOUT_SECONDS bounds the connect and the read (default
30). A ?versionId= query is refused rather than dropped.