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.
The guarantee to build on
Section titled “The guarantee to build on”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.
completeValidatedJSONreturns a run witherrorset 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.
Make the call
Section titled “Make the call”// 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: 1provider calls: 2Two schemas, one call
Section titled “Two schemas, one call”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.
The performance trap
Section titled “The performance trap”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.
Handling the outcome
Section titled “Handling the outcome”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.
Options worth knowing
Section titled “Options worth knowing”| 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. |
What comes back
Section titled “What comes back”| 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 |
- Stay inside a cost ceiling and fail soft → Budgets and errors
- Shell out cross-platform → The subprocess seam
- Cache repeat calls → Caching (the mechanics are layer-independent)
- Test it all offline → Testing your integration