Skip to content

Structured extraction

One call, one subject, one schema-valid object — or an honest failure.

This is the completion layer on its own. You do not need the judge layer for any of this, and nothing on this page assumes it exists.

Signatures are in the completion reference.

If you are adding a non-deterministic feature to a tool whose value proposition is determinism, this is the sentence that matters:

An errored run is recorded, never dropped and never coerced. completeValidatedJSON returns a run with error set rather than throwing or inventing a result.

There is no retry-until-success loop, and there never will be. A bad response cannot silently become data in your output.

examples/extract-once.mjs
// Single-shot structured extraction: a narrow request schema, a wider validation schema.
// Runs with no API key: MockProvider stands in for a real provider.
import { MockProvider, completeValidatedJSON, validatorFor } from "@hawkeyexl/inference";
// Every field this tool knows how to fill.
const ALL_FIELDS = { title: { type: "string" }, type: { type: "string" }, owner: { type: "string" } };
// The wider schema the response is *validated* against — compiled once, at module scope.
const validateProposal = validatorFor({
type: "object",
properties: ALL_FIELDS,
additionalProperties: false,
});
// The narrower schema the model is *asked* for, built per document.
//
// validatorFor caches on schema object identity, so a builder that returns a fresh
// object every call would recompile Ajv once per document. Memoize on the field set.
const schemaCache = new Map();
function proposalSchema(missing) {
const key = [...missing].sort().join(",");
let schema = schemaCache.get(key);
if (schema === undefined) {
schema = {
type: "object",
required: [...missing].sort(),
properties: Object.fromEntries([...missing].sort().map((f) => [f, ALL_FIELDS[f]])),
additionalProperties: false,
};
schemaCache.set(key, schema);
}
return schema;
}
const provider = new MockProvider([
{ json: { title: "Authentication", type: "how-to" } },
{ json: { title: "Rate limits", type: "reference" } },
]);
const documents = [
{ path: "auth.md", missing: ["title", "type"] },
{ path: "limits.md", missing: ["title", "type"] },
];
for (const doc of documents) {
const run = await completeValidatedJSON({
provider,
system: "You propose frontmatter values for a documentation page.",
user: `Propose values for: ${doc.missing.join(", ")}\n\nPath: ${doc.path}`,
schema: proposalSchema(doc.missing), // narrow: only what is missing
validate: validateProposal, // wide: every field this tool accepts
});
if (run.error !== undefined) {
// Never a throw, never a coerced value. Skip the document and keep going.
console.log(`${doc.path}: skipped — ${run.error}`);
continue;
}
console.log(`${doc.path}:`, run.result);
}
// Two documents, one distinct field set, one compiled request schema.
console.log("distinct schemas built:", schemaCache.size);
console.log("provider calls:", provider.requests.length);
auth.md: { title: 'Authentication', type: 'how-to' }
limits.md: { title: 'Rate limits', type: 'reference' }
distinct schemas built: 1
provider calls: 2

The move that makes this layer worth using: ask the model for a narrow schema, validate against a wider one.

const run = await completeValidatedJSON({
provider,
system, user,
schema: proposalSchema(doc.missing), // narrow: only the fields this document is missing
validate: validateProposal, // wide: every field your tool accepts
});

Why bother: sending the model only the fields you actually need keeps the prompt focused and the output smaller, while validating against your full configured set means a response is accepted as long as it is legal, not only if it is exactly what you asked for.

validate takes a pre-compiled Ajv ValidateFunction — build it once with validatorFor at module scope.

const schemaCache = new Map();
function proposalSchema(missing) {
const key = [...missing].sort().join(",");
let schema = schemaCache.get(key);
if (schema === undefined) {
schema = { /* ... */ };
schemaCache.set(key, schema);
}
return schema;
}

Identity keying is deliberate — a fresh Ajv instance is created per distinct schema object, so two equal-but-distinct schemas sharing an $id cannot collide in a shared registry. The cost is that you must hand back the same object for the same logical schema.

if (run.error !== undefined) {
warn(`${doc.path}: skipped — ${run.error}`);
continue; // the deterministic path is untouched
}
applyProposal(doc, run.result);

Check run.error, not a try/catch. A model failure never throws.

Option Default Notes
temperature 0 Extraction wants determinism. Leave it.
attempts 2 One call plus one retry. Raising it buys little — a schema the model cannot satisfy will not become satisfiable on the fourth try.
validate derived from schema Supply a pre-compiled validator to widen what you accept.
Field Notes
result Absent when the run errored
error The validation failure or the provider error
provider, model Identity — feeds cache keys and pricing
cached true only when replayed from a cache you supplied
usage Absent when the provider does not report tokens
durationMs Wall clock