Turn invoices, forms and letters into JSON that matches your schema. The schema rides on the request, routing serves only models with verified structured-output support, retries are idempotent, validation decides what goes to a reviewer, and every record carries the model and the cost that produced it. Built as a worker pipeline on Postgres.
What you build
A pipeline that takes documents from a bucket or a queue, gets their text (the PDF text layer, OCR, or a vision model for scans), asks Inferbase for JSON that matches a schema you define in code, validates the result, writes a record with the model and the cost that produced it, and sends what fails validation to a review queue. Every extraction carries an idempotency key derived from the document and the schema version, so a worker can retry anything without paying twice or writing twice. The model is chosen per document under a policy that only admits models with verified structured-output support.
When you are done
- A worker that turns a document into a validated record, with the schema as code and its version on every record
- Retries that are safe by construction: the same document and schema replay the original response
- A review queue that holds exactly the records validation rejected, with the model's output beside the document
- Cost, model and request id on every record, so the expensive document types are visible
- An evaluation that scores field accuracy, schema validity and repair rate on a labelled set, for auto against a pinned model
The components
- Intake
- A queue message or a bucket event per document, carrying where the bytes are and the document type that selects the schema.
- Text acquisition
- The PDF text layer when there is one; OCR for scans; or the page sent as an image to a vision model through the same API when layout matters.
- Schema registry
- One Pydantic model per document type, exported as a strict JSON Schema, with a version string that is part of the idempotency key and the record.
- Extraction call
- One chat completion with the model set to auto, response_format json_schema, thinking off, and the idempotency key in the header. The policy decides what happens to an answer that does not parse.
- Validation
- Pydantic parses the JSON; business rules check what the schema cannot: totals that add up, dates that parse, currencies that exist.
- Records and review
- Postgres: a records table and a review table. A record that passes is written; one that fails goes to review with the raw output and the reason.
- Inferbase
- The key with a spend cap, the routing policy with a rule pool on structured output and extract-then-retry for invalid JSON, and the chat completions endpoint.
- Evaluation
- A labelled set of documents with gold records. Field-level accuracy, validity and repair rates, and cost per document, for auto against a pinned model.
The problem
Extraction is a pipeline step, and a pipeline step cannot take a model that returns prose.
A document pipeline turns text into fields: the vendor, the total, the line items, the dates. The output goes into a database or a downstream system, so it must be valid JSON in a known shape every time, not most of the time. A model that wraps its answer in a code fence, adds a sentence of commentary, or invents a field breaks the step that reads it.
Documents also vary in difficulty. A clean invoice with three lines is a simple read; a scanned letter with a table spread over two pages and a correction in the margin is not. One model for every document is the usual compromise, and it is paid for twice: in the price of the hard-document model on the easy documents, and in the retries when the easy-document model meets a hard one.
The third problem is retries. A worker that times out and runs the document again pays again and may write the record twice. In a pipeline, a retry has to be free and idempotent, or operators stop retrying.
This guide puts the schema on the request as a JSON Schema and sends the model as auto. Routing keeps only models with verified structured-output support, serves the one that wins for the document in front of it, and the routing policy says what happens when an answer still does not parse: unwrap it, ask once more, or return it as is and let validation decide. Every request carries an idempotency key, so a retry replays the response instead of running the extraction again.
Who has it
- A back office that keys invoices, receipts or forms by hand today
- A product that ingests customer documents and needs fields, not summaries
- A pipeline that already calls a model for extraction and handles its failures with regular expressions
Architecture
System overview
Every component, grouped by who runs it. The product sits at the generation call; everything in your lanes is yours and unchanged by this design.
One request, end to end
The schema is yours and travels with each request. Inferbase chooses a model that honours it and tells you when it had to unwrap the answer.
- 01QueueWorkerA message: the document's location and its type, for example invoice.The type selects the schema and its version.
- 02WorkerDocument storeFetches the bytes and hashes them.The hash plus the schema version is the idempotency key.
- 03WorkerWorkerGets the text: the PDF text layer if present, else OCR; or keeps the page as an image when the layout carries meaning.An image goes to the model as a content part.
- 04WorkerInferbaseOne chat completion: model auto, the schema as response_format json_schema, the document as the user turn, thinking off, Idempotency-Key in the header.A replayed key returns the original response; a reused key with a different body is a 422.
- 05InferbaseInferbaseKeeps only pool models with verified structured-output support (and image input, for a scan), classifies the document, ranks on the objective, serves the winner.Time to decide: p50 18 to 31 ms on live traffic, longer for a long document.
- 06InferbaseInferbaseReads the answer. If it is not valid JSON, the policy applies: extract it from fences or commentary, then ask the same model once more.usage.structured_output_repair says when an answer was unwrapped.
- 07InferbaseWorkerThe response: the JSON, the model that served, the cost in micro-dollars, the repair flag.The response id reads back the decision.
- 08WorkerWorkerParses the JSON with the Pydantic model, then runs the business rules: totals add up, dates parse, currency is known.The schema guarantees shape; the rules guarantee sense.
- 09WorkerRecords tableWrites the record with the schema version, the model, the cost, the request id and the repair flag.Insert on conflict do nothing, keyed by the idempotency key: a retry cannot write twice.
- 10WorkerReview queueOr, on a validation failure, writes the raw output, the document reference and the reason.A reviewer corrects it; the correction becomes an evaluation case.
Deployment
Where each part runs, and what talks to what over the network.
What stays in your stack
- The schema and its versioning
- OCR and page splitting
- Validation and what happens to a rejected record
- The queue and its retry policy
Technology stack
One choice per layer, the alternative that was considered, and the reason. Swap any row; the Inferbase call does not change.
| Layer | Choice | Alternatives | Why |
|---|---|---|---|
| Language | Python 3.12 | TypeScript with zod for the schema | Pydantic defines the schema, exports the JSON Schema and validates the answer: one definition, three uses. |
| API client | openai 3.x with base_url set to Inferbase | httpx | response_format and extra_headers are first-class; the idempotency key is one header. |
| Schema | Pydantic models with strict JSON Schema export, additionalProperties false, every field required | Hand-written JSON Schema | A strict schema is what structured-output models honour best. Optional fields become nullable required fields, so the shape is fixed. |
| Text acquisition | PDF text layer first; OCR for scans; a vision model through the same API when layout matters | A document AI service | The text layer is free and exact. OCR is cheap. The vision path keeps tables and stamps readable and routes to models that accept images. |
| Queue | SQS, Pub/Sub or a Redis stream, one message per document | A database table polled by workers | At-least-once delivery is fine because the extraction is idempotent and the write is on-conflict-do-nothing. |
| Records | Postgres with a JSONB column for the record and columns for the provenance | A document database | The record is queried by field; the provenance (model, cost, request id, schema version) is queried by column. |
| Models | model auto under a policy whose pool requires structured output | One pinned model | A simple document goes cheap, a hard one goes strong, and no model that cannot honour the schema is ever chosen. |
| Evaluation | A labelled set with gold records, scored per field | Spot checks | Field accuracy by document type tells you which types need the strong models; the receipts tell you what each costs. |
What carries it
The Inferbase features this design rests on. Each exists in the product today and is linked to its documentation.
- json_schema on the request
- response_format with a JSON Schema is forwarded to the serving model as written. With the model set to auto, routing serves only models whose structured-output support is verified. Documentation
- Invalid JSON handling in the policy
- When an answer does not parse, the policy decides: extract the JSON from fences or commentary, extract and then ask the same model once more, or serve the answer as it came. Nothing is rewritten to make it parse. Documentation
- Repair disclosed
- When the JSON was taken out of a wrapped answer, usage.structured_output_repair says so, so a pipeline can count how often each model needed it. Documentation
- An idempotency key per document
- A retry with the same Idempotency-Key replays the original response instead of running the extraction again; the same key with a different body is refused with a 422. A failed request releases the key at once. Documentation
- Image input for scanned pages
- A page sent as an image content part is routed to models that accept images. A request no pool model can accept gets a clear 503 rather than a text-only guess. Documentation
- Cost and model on every record
- The response names the model that served and carries the cost in micro-dollars, so each extracted record can store what produced it and what it cost. Documentation
- Zero retention
- Documents carry names and account numbers. Zero retention on the key keeps request content out of the log; the decision record is kept without it. Documentation
Build it step by step
Eight steps from a bucket of documents to validated records. The code is Python; the extraction call alone is also given in TypeScript after the policy, and both were run against the API. Each step ends with the thing it turns on.
Before you start
- An Inferbase account, a project, and a key with a spend cap for this pipeline.
- A bucket of documents and a queue, or a table to poll. Postgres for the records.
- Python 3.12 with openai, pydantic and a PDF library (pypdf); Tesseract for scans if you will OCR.
Step 1: Create the routing policy
Build the pool by rule: supports structured output. Set eligibility to High, because an extraction error is silent until someone reconciles the numbers, and Balanced as the objective so simple documents go cheap. Under Fallback, set invalid JSON handling to extract then retry, and two attempts with the model allowed to switch. If the pipeline will send scanned pages as images, the rule should also require image input, or keep a second pool for scans. Turn on zero retention for the key if the documents carry personal data. Attach the policy to the pipeline's key.
High eligibility narrows the pool to models with the strongest evidence. For extraction that is the right trade: the saving on simple documents still comes from Balanced choosing the cheapest model that clears the high bar.
Step 2: Define the schema in code
One Pydantic model per document type. Make every field required and make the ones that may be absent nullable, so the shape is fixed and a model cannot omit a key. Export a strict JSON Schema with additionalProperties false at every level. Give the schema a version string; it goes into the idempotency key and onto every record, so a schema change never replays an old response and never mixes shapes in one table.
from pydantic import BaseModel, ConfigDict, Field
SCHEMA_VERSION = "invoice-v3"
class LineItem(BaseModel):
model_config = ConfigDict(extra="forbid")
description: str
quantity: float
amount: float
class Invoice(BaseModel):
model_config = ConfigDict(extra="forbid")
vendor: str
invoice_number: str
date: str = Field(description="ISO 8601 date")
currency: str = Field(description="ISO 4217 code")
total: float
line_items: list[LineItem]
purchase_order: str | None = Field(description="null when the document shows none")
def response_format() -> dict:
"""The schema as the request carries it: strict, every field required, no extras."""
return {"type": "json_schema", "json_schema": {"name": "invoice", "strict": True, "schema": Invoice.model_json_schema()}}Pydantic marks a field with a default as optional in the schema. Give nullable fields no default, as purchase_order above, so they stay required and the model writes null rather than leaving the key out.
Step 3: Get the text, or keep the image
Three paths by document. A PDF with a text layer: extract it and send text. A scan: run OCR and send text, which keeps the pool wide and the cost low. A document where the layout carries meaning, such as a form with boxes or a table with merged cells: send the page as an image content part, and routing filters to models that accept images. Decide per type, and record which path a record took.
import base64
from pypdf import PdfReader
def document_content(path: str, kind: str) -> list[dict]:
"""The user message content: text when there is text, an image part when layout matters."""
if kind == "scan-layout":
with open(path, "rb") as f:
data = base64.b64encode(f.read()).decode()
return [
{"type": "text", "text": "Extract the fields from this page."},
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{data}"}},
]
if path.endswith(".pdf"):
text = "\n".join(page.extract_text() or "" for page in PdfReader(path).pages)
if text.strip():
return [{"type": "text", "text": text}]
return [{"type": "text", "text": ocr(path)}] # tesseract, or your OCR serviceStep 4: The extraction call, idempotent
One call with the schema as the response format, the content as the user turn, thinking off, and the idempotency key in the header: a hash of the document bytes plus the schema version. The same document under the same schema replays the original response for free; the same key with a different body is refused with a 422, which is the guard against sending a changed document under an old key. Read the repair flag and the cost from the response.
import hashlib
import json
from openai import OpenAI
client = OpenAI(api_key="inf_your_api_key", base_url="https://api.inferbase.ai/api/v1/inference")
def idempotency_key(document_bytes: bytes, schema_version: str) -> str:
return f"extract-{schema_version}-{hashlib.sha256(document_bytes).hexdigest()[:32]}"
def extract(content: list[dict], document_bytes: bytes, schema_version: str, fmt: dict) -> dict:
completion = client.chat.completions.create(
model="auto",
max_tokens=1_500,
messages=[
{"role": "system", "content": "Extract the fields from the document. Every field is required; use null where the document shows nothing."},
{"role": "user", "content": content},
],
response_format=fmt,
extra_headers={"Idempotency-Key": idempotency_key(document_bytes, schema_version)},
extra_body={"extended_thinking": False},
)
usage = completion.usage.model_extra
return {
"raw": completion.choices[0].message.content or "",
"model": completion.model,
"request_id": completion.id,
"cost_usd": usage["cost_micros"] / 1e6,
"repaired": bool(usage.get("structured_output_repair")),
}Step 5: Validate: the schema, then the rules
Parse the JSON with the Pydantic model; a shape error here means the policy served the answer as it came or the model broke the schema, both of which belong in review. Then the rules the schema cannot express: the line items sum to the total within a cent, the date parses, the currency code is known. A record that fails any rule goes to review with the raw output and the reason; one that passes is written.
from datetime import date
from pydantic import ValidationError
KNOWN_CURRENCIES = {"EUR", "USD", "GBP", "INR", "JPY"}
def validate(raw: str) -> tuple[Invoice | None, str | None]:
try:
invoice = Invoice.model_validate_json(raw)
except ValidationError as exc:
return None, f"schema: {exc.errors()[0]['loc']}: {exc.errors()[0]['msg']}"
if abs(sum(i.amount for i in invoice.line_items) - invoice.total) > 0.01:
return None, "line items do not sum to the total"
try:
date.fromisoformat(invoice.date)
except ValueError:
return None, f"date not ISO 8601: {invoice.date}"
if invoice.currency not in KNOWN_CURRENCIES:
return None, f"unknown currency: {invoice.currency}"
return invoice, NoneThe shipping line is a line item with quantity 1 in the verified run. Decide in the schema whether shipping is a line item or a field, and say so in the system prompt; the model follows the schema, not your intent.
Step 6: Write the record, or the review item
Two tables. The records table has the parsed JSON and the provenance: schema version, model, cost, request id, repair flag, text path, and the idempotency key as a unique column. Insert with on conflict do nothing, so a retried message that already wrote cannot write twice. The review table has the document reference, the raw output and the reason; a reviewer's correction is written to records and kept as an evaluation case.
CREATE TABLE records (
idempotency_key text PRIMARY KEY,
document_ref text NOT NULL,
document_type text NOT NULL,
schema_version text NOT NULL,
record jsonb NOT NULL,
model text NOT NULL,
cost_usd numeric(12, 6) NOT NULL,
request_id text NOT NULL,
repaired boolean NOT NULL,
text_path text NOT NULL, -- text_layer | ocr | image
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE review (
id bigserial PRIMARY KEY,
idempotency_key text NOT NULL,
document_ref text NOT NULL,
raw_output text NOT NULL,
reason text NOT NULL,
model text NOT NULL,
request_id text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);Step 7: The worker
One message at a time: fetch, get content, extract, validate, write. Any failure that is not a validation failure (a timeout, a 5xx, a 429) re-queues the message; the idempotency key makes the retry free if the extraction had already succeeded. A validation failure is not retried, because the same input gives the same output; it goes to review.
def handle(message: dict) -> None:
document_bytes = fetch(message["ref"])
kind = message["type"] # invoice, receipt, ...
schema = REGISTRY[kind] # the Pydantic model, its version and its response format
content = document_content_from_bytes(document_bytes, message.get("path", "auto"))
result = extract(content, document_bytes, schema.version, schema.response_format()) # idempotent: a retry replays
record, reason = validate(result["raw"])
key = idempotency_key(document_bytes, schema.version)
if record is None:
db.execute(
"INSERT INTO review (idempotency_key, document_ref, raw_output, reason, model, request_id) VALUES (%s, %s, %s, %s, %s, %s)",
(key, message["ref"], result["raw"], reason, result["model"], result["request_id"]),
)
return
db.execute(
"INSERT INTO records (idempotency_key, document_ref, document_type, schema_version, record, model, cost_usd, request_id, repaired, text_path) "
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) ON CONFLICT (idempotency_key) DO NOTHING",
(key, message["ref"], kind, schema.version, record.model_dump_json(), result["model"], result["cost_usd"], result["request_id"], result["repaired"], message.get("path", "auto")),
)Step 8: Run many workers, and watch the repair rate
Workers are stateless, so run as many as the key's rate limit allows; a 429 from the key is a signal to add nothing and raise the limit. Record the repair flag on every record and read it by model: a model that needs unwrapping often is a model to drop from the pool, and a document type with a high review rate is one whose schema or prompt needs work.
SELECT model,
count(*) AS records,
round(100.0 * avg(repaired::int), 1) AS repair_pct,
round(avg(cost_usd), 6) AS mean_cost_usd
FROM records
WHERE created_at > now() - interval '7 days'
GROUP BY model
ORDER BY records DESC;The routing policy
Balanced as the objective, a rule pool that requires structured output, High eligibility, extract-then-retry for answers that do not parse, and zero retention for documents that carry personal data.
| Stage | Setting | Value | Why |
|---|---|---|---|
| Pool | How the pool grows | Models that meet a rule: supports structured output | The schema is the contract. A model that cannot honour it should never be in the pool, and new models that can should join as they arrive. Add accepts images to the rule for scans sent as pages. |
| Routing | Optimization mode | Balanced | Simple documents go to cheap models and hard ones to strong models, on the complexity the router reads from the document. |
| Routing | Eligibility | High | An extraction error is silent until someone reconciles the numbers. Pay the evidence bar up front. |
| Fallback | Invalid JSON handling | Extract, then retry | Unwrapping is free and covers most wrapped answers; one retry on the same model covers most of the rest. The response discloses which happened. |
| Fallback | Retries | 2 attempts, model may switch | A busy provider should not fail a document. The idempotency key keeps the queue retry safe above this. |
| Key | Zero retention | On, if documents carry personal data | Invoices and letters carry names and addresses. Zero retention keeps request content out of the log; the decision record is kept without it. |
Create it under Policies in the portal and attach it to the key the application uses; the steps are in Create a routing policy.
The call
One extraction: the schema on the request, the document as the user turn, an idempotency key derived from the document so a retry replays rather than re-runs, and the repair flag read from the response. The full pipeline above wraps this call in validation, records and a review queue.
import hashlib
import json
from openai import OpenAI
client = OpenAI(api_key="inf_your_api_key", base_url="https://api.inferbase.ai/api/v1/inference")
SCHEMA = {
"name": "invoice",
"strict": True,
"schema": {
"type": "object",
"properties": {
"vendor": {"type": "string"},
"invoice_number": {"type": "string"},
"date": {"type": "string", "description": "ISO 8601 date"},
"currency": {"type": "string"},
"total": {"type": "number"},
"line_items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"description": {"type": "string"},
"quantity": {"type": "number"},
"amount": {"type": "number"},
},
"required": ["description", "quantity", "amount"],
"additionalProperties": False,
},
},
},
"required": ["vendor", "invoice_number", "date", "currency", "total", "line_items"],
"additionalProperties": False,
},
}
document = """INVOICE
Northwind Paper Co.
Invoice no. NP-2041 - Date: 3 October 2026
2 x A4 copier paper, 5 reams EUR 48.00
1 x Toner cartridge, black EUR 89.50
Shipping EUR 12.00
TOTAL DUE EUR 149.50
Payment within 30 days.""" # a PDF text layer or an OCR result
completion = client.chat.completions.create(
model="auto",
max_tokens=400,
messages=[
{"role": "system", "content": "Extract the invoice fields from the document. Use null for nothing else; every field is required."},
{"role": "user", "content": document},
],
response_format={"type": "json_schema", "json_schema": SCHEMA},
# The same document, the same key: a queue retry replays the response instead of paying again.
extra_headers={"Idempotency-Key": "invoice-" + hashlib.sha256(document.encode()).hexdigest()[:24]},
extra_body={"extended_thinking": False},
)
invoice = json.loads(completion.choices[0].message.content)
usage = completion.usage.model_extra
print(json.dumps(invoice, indent=2))
print(completion.model, f'{usage["cost_micros"] / 1e6:.6f} USD', "repaired" if usage.get("structured_output_repair") else "parsed as returned")Both snippets were run against api.inferbase.ai on 2026-10-13: every field came back as the schema states, with the date normalised to ISO 8601 and three line items. The TypeScript run was made on a second copy of the document, because the key the Python run had used for the same document was refused for a different request body, which is the idempotency rule doing its job.
Open in PlaygroundAPI reference
Security and privacy
Documents are the most personal data most pipelines touch. The controls are about what is kept, who may see it, and what a retry can do.
- Zero retention on the key
- Request content is kept for up to 30 days by default. With zero retention, the document text and the model's answer are not kept; the decision record is, without the content. For invoices, forms and letters, this is the setting.
- The pool is the allow-list
- Only models in the policy pool ever see a document. A rule pool is a rule you can show an auditor; a chosen pool is a list. Your own provider keys or cloud accounts keep the call inside a contract you already have.
- The idempotency key carries no content
- It is a hash of the bytes and the schema version: it proves identity without revealing the document, and it is safe in a log.
- Redact before you send
- What the record never needs, such as card numbers on a receipt, can be masked in the text before the call. What is masked is never sent and never kept anywhere.
- The review queue is sensitive
- It holds raw model output beside document references. Put it behind the same access control as the documents, and purge items once corrected.
- The key
- One key per pipeline with a spend cap and a rate limit, held by the workers. A runaway queue meets the cap before it meets your budget.
Evaluation
A labelled set: documents with gold records, by type. Run them through the pipeline and score four things per document: whether the output was valid JSON against the schema, whether it needed repair, field-level accuracy against the gold record, and cost. Then run the set pinned to one model and compare.
- Validity rate
- The fraction of documents whose output parsed against the schema without review. With a strict schema and the structured-output pool it should be close to all of them; what is left is the model breaking the schema or the policy serving an unparseable answer as it came.
- Repair rate
- The fraction whose JSON had to be unwrapped, from the repair flag on each response. Read it by model; a model that needs it often is a candidate to leave the pool.
- Field accuracy
- Per field, exact match for strings and codes, tolerance for amounts, parsed equality for dates. Report by document type: the types with low accuracy are the ones that need the strong models or a better prompt.
- Cost per document
- From the receipts, by type and by model. The saving from routing is here: simple types on cheap models, hard types on strong ones.
- Auto against pinned
- The same set with the model pinned to what you use today. Compare field accuracy and cost per document; the decision records say which model handled which document.
def field_accuracy(predicted: dict, gold: dict) -> float:
hits = 0
for key, want in gold.items():
got = predicted.get(key)
if isinstance(want, float):
hits += abs((got or 0) - want) < 0.01
elif key == "line_items":
hits += len(got or []) == len(want)
else:
hits += got == want
return hits / len(gold)
scores = [field_accuracy(run(doc), gold) for doc, gold in labelled]
print(f"mean field accuracy {sum(scores) / len(scores):.1%}")Operations
The records table is the dashboard. Everything an operator needs is a query on it.
- Review queue size
- The number of items in review by type and by reason. A reason that grows is a schema or prompt problem; a type that grows is a document source that changed.
- Repair and validity by model
- The weekly query in step 8. A model whose repair rate climbs is a model whose structured-output support changed; the pool rule will drop it when Inferbase's verification does, but your own number is earlier.
- Cost by type
- Mean cost per document by type and model. The expensive types are where a cheaper model or a shorter prompt pays.
- Replays
- A retried message that replayed costs nothing and writes nothing new. Count replays by comparing messages handled with records written; a high replay rate is a queue with a visibility timeout too short for the call.
- Read one decision
- The request id on a record reads back why that model was chosen: the task, the complexity, the pool after filtering. Start there when a record looks wrong.
- Schema versions
- Bump the version with the schema. Old records keep their version; the new key space means nothing replays across versions.
Scaling and limits
The pipeline scales by workers; the limits are the key, document size and the model's window.
- Real time, measured
- A batch pipeline, and latency only matters at the queue. Measured on 2026-10-14: a short invoice extracted in 1.8 to 3.2 s per document, non-streamed, under Balanced. A worker handles 20 to 30 documents a minute; the limit at volume is the key's rate limit, not the model. Interactive use, a person waiting on one document, is fine at 2 to 3 s; sub-second is not what this is.
- Workers and the rate limit
- One document is one request. A key at 600 requests a minute carries 600 documents a minute across any number of workers; raise the limit before the queue backs up.
- Long documents
- A fifty-page contract does not fit one call. Split by page or section, extract per part with the same schema, and merge by rule; or set min_context_window on the request so only long-context models qualify.
- Images
- A page as an image costs more than its text and routes to fewer models. Reserve the image path for layouts that need it; send text for the rest.
- Prompt caching
- The system prompt and the schema repeat on every call. Turn caching on in the policy; the decision says whether the chosen provider caches.
- Batch windows
- A nightly batch can run at full rate for an hour and stop; a daily cap on the key sized to the batch is the safety net.
- Many document types
- One schema per type in the registry, one prompt line per type, the same worker. A type with different compliance needs gets its own key and policy.
Troubleshooting
What goes wrong when this is built, what it means, and what to do. Each one was met while building or running it.
| Symptom | Cause | Fix |
|---|---|---|
| The answer is not valid JSON despite the schema | The policy's invalid JSON handling is serve anyway, or the model broke the schema and the retry did too. | Set extract then retry; send the record to review; if one model does it often, drop it from the pool. The repair flag and the model are on the response. |
| 422 on the request | A field the API does not know, or a schema keyword a provider rejects, or the same Idempotency-Key with a different body. | The message names the field. For the key: the body changed under an old key, so include the schema version in the key as step 4 does. |
| 503 saying no model can accept the input | A page was sent as an image and no pool model accepts images. | Add image input to the pool rule, or send OCR text for that type. |
| Line items sum to a different total | The document has a discount, tax or shipping line the schema did not model, or OCR misread a digit. | Model the lines the documents have; send such records to review; for scans, compare the text path, OCR against image. |
| Dates come back in the document's own format | The field description did not say ISO 8601, or the model ignored it. | Say ISO 8601 in the schema description and in the system prompt; validate with date.fromisoformat. |
| Every retry pays again | No idempotency key, or a key that changes between attempts (a timestamp in it). | Derive the key from the document bytes and the schema version only, as step 4 does. |
| Simple documents are landing on expensive models | The objective is quality, or High eligibility left only strong models in the pool. | Balanced as the objective; check the decision record to see which filter narrowed the pool; add a cheaper model with structured-output evidence. |
Extending
The pipeline is a worker and a schema registry. Each extension is a schema, a path, or a step.
- Classify first
- When the document type is unknown, a first call with a small schema (type, confidence) picks the schema; the task can be stated as general and the objective as cost. One cheap call saves a wrong extraction.
- Tables
- A schema with an array of rows and named columns; send the page as an image when the table's structure is visual.
- A review interface
- The review table is the backlog; a page that shows the document beside the raw output and lets a reviewer correct fields writes the record and an evaluation case.
- Confidence
- Ask for a per-field confidence in the schema, and route low-confidence records to review even when validation passes.
- Your own keys
- Build the pool from a provider key or cloud account. The worker does not change; the policy decides whose credential serves.
- Downstream events
- Publish an event per written record with the request id; a consumer can read the decision and the cost without touching the pipeline.
What was measured
What one extraction costs in time and money on the public API. A long document classifies and extracts slower than a short one.
| Measurement | Value |
|---|---|
| One short invoice, json_schema, non-streamed (3 runs) | 1.8 to 3.2 s |
| The verified invoice extraction, 2026-10-13 | about $0.0002, parsed as returned |
| Time to decide (classify and rank), live traffic, 2026-10-13 | p50 18 to 31 ms |
Measured from our own infrastructure in India against api.inferbase.ai on 2026-10-14, three runs each, client-side wall time including the network hop (about 0.25 s each way), thinking off. Every response returns its own figures.
Further reading
- The Inferbase quickstarts repository; the document-extraction folder for this guide is planned
- Structured output on chat completions
- Invalid JSON handling in the policy
- Authentication and idempotency
- Client compatibility: images and structured output
- Security and privacy policies: zero retention
Frequently asked questions
With the model set to auto, only those whose structured-output support Inferbase has verified. The schema is forwarded as written, so a model that honours json_schema receives exactly your shape. A model whose support is unknown is excluded rather than tried.
The routing policy decides. Extract takes the JSON out of code fences or commentary, which is free. Extract then retry asks the same model once more if nothing can be taken out. Serve anyway returns the answer as it came. In every case nothing is rewritten to make it parse, and usage.structured_output_repair says when an answer was unwrapped.
Yes, with the prompt also asking for JSON, as the OpenAI API requires. Claude takes json_schema only, because Anthropic has no plain JSON mode, so a json_object request does not route to Claude. For a pipeline, the schema form is the one to use: it states the shape rather than hoping for it.
Send an Idempotency-Key header on every extraction, derived from the document bytes and the schema version. A retry with the same key replays the original response instead of running the extraction again, so a queue can retry on any failure without paying twice or inserting a record twice. A failed request releases the key at once, and the same key with a different body is refused.
Yes. Send the page as an image content part on the user message, as the OpenAI shape allows. Routing then filters to models that accept images; if no model in the pool does, the request gets a 503 that says so rather than a text-only answer. For high-volume scanning, running OCR in your pipeline and sending text keeps the pool wider and the cost lower.
The request is classified for its task and complexity before a model is chosen, on the document text it carries. A short clean invoice reads as simple; a long document with tables and corrections does not. Under Balanced, a simple document can go to a cheaper model that clears the evidence bar, and a hard one goes to a stronger model. The response says which, and you can override the objective per request.
Store them beside the record, as the records table does. The response names the model that served and carries the cost in micro-dollars, the same figure Usage shows. A table of records with their model and cost is how you learn which document types need the strong models and which do not.
Request content is kept for up to 30 days by default and can be deleted at any time. With zero retention on the key or the organisation, it is not kept at all; the decision record is kept without the content. For invoices and letters, which carry names and addresses, zero retention is the setting to use.
Yes. Name the model on the request for the type that needs it, and send auto for the rest. A pinned model that fails falls back according to the policy pinned-model fallback setting: the same model on another provider, a model you choose, auto, or none.
