Skip to content

Structured output

Pass a JSON Schema to complete(), get back a typed parsed object — no JSON.parse, no markdown-fence stripping, no per-provider workaround.

LLMs produce text. When you need structured data (an address, a product list, a sentiment score) you must constrain the model to emit valid JSON and then parse it. Without constraints the model may emit prose, wrap JSON in a code block, or drop required fields.

Providers have different mechanisms for this constraint:

  • OpenAI supports response_format: { type: 'json_schema', json_schema: { ... } }.
  • Anthropic has no native JSON-mode; the standard workaround is a forced tool call where the schema is wrapped in a tool definition and the model’s tool-call arguments are the structured output.
  • Google supports response_mime_type: 'application/json' with response_schema.

Maintaining three code paths — one per provider — in an application that might switch providers is expensive and error-prone.

import { complete } from '@combycode/llm-sdk';
const { parsed } = await complete<{ city: string; tempC: number }>({
model: process.env.LLM_MODEL!,
apiKey: process.env.LLM_API_KEY,
prompt: 'Extract the city and temperature in Celsius. Text: "Paris is 20 degrees C."',
structured: {
schema: {
type: 'object',
properties: {
city: { type: 'string' },
tempC: { type: 'number' },
},
required: ['city', 'tempC'],
additionalProperties: false,
},
},
maxTokens: 64,
});
console.log(parsed.city); // 'Paris'
console.log(parsed.tempC); // 20

The generic <T> on complete<T>() types parsed as T. TypeScript infers all field types from your generic — no extra cast.

Step 2 — Understand what the provider receives

Section titled “Step 2 — Understand what the provider receives”

Under the hood the adapter inspects provider capability:

  • OpenAI receives response_format: { type: 'json_schema', json_schema: { name, strict, schema } }.
  • Anthropic receives an extra tool named structured_output with your schema as parameters; tool_choice is forced to { type: 'tool', name: 'structured_output' }. The tool call arguments are extracted transparently.
  • Google receives response_mime_type: 'application/json' and response_schema.

This translation is automatic. You write one schema, the adapter does the right thing.

Step 3 — Classify sentiment with an enum schema

Section titled “Step 3 — Classify sentiment with an enum schema”

Enum schemas work the same way — just add an enum constraint on the property:

const { parsed } = await complete<{ sentiment: 'positive' | 'negative' | 'neutral' }>({
model: process.env.LLM_MODEL!,
apiKey: process.env.LLM_API_KEY,
prompt: 'Classify the sentiment: "The product is surprisingly good."',
structured: {
schema: {
type: 'object',
properties: {
sentiment: { type: 'string', enum: ['positive', 'negative', 'neutral'] },
},
required: ['sentiment'],
additionalProperties: false,
},
},
maxTokens: 32,
});
console.log(parsed.sentiment); // 'positive'

The structured option on complete() accepts { schema, name? } only. There is no strict field here — strict enforcement is an adapter-level detail handled automatically per provider (OpenAI: json_schema.strict: true; Anthropic: forced tool call; Google: response_mime_type).

Schemas are plain JSON Schema objects. Nested objects and arrays work as expected:

type Invoice = {
vendor: string;
total: number;
items: Array<{ description: string; amount: number }>;
};
const { parsed } = await complete<Invoice>({
model: process.env.LLM_MODEL!,
apiKey: process.env.LLM_API_KEY,
prompt: 'Extract invoice data from: "Acme Corp. Items: Widget $12.50, Bolt $0.99. Total $13.49"',
structured: {
schema: {
type: 'object',
properties: {
vendor: { type: 'string' },
total: { type: 'number' },
items: {
type: 'array',
items: {
type: 'object',
properties: {
description: { type: 'string' },
amount: { type: 'number' },
},
required: ['description', 'amount'],
},
},
},
required: ['vendor', 'total', 'items'],
},
},
maxTokens: 256,
});

If the model emits invalid JSON (network truncation, unusual model behaviour), complete() throws a SyntaxError from JSON.parse — it does not return undefined. Wrap in a try/catch to recover:

let result: { parsed?: MyType; text: string } | undefined;
try {
result = await complete<MyType>({
model: process.env.LLM_MODEL!,
apiKey: process.env.LLM_API_KEY,
prompt,
structured: { schema },
maxTokens: 256,
});
} catch (err) {
if (err instanceof SyntaxError) {
console.warn('parse failed, retrying...');
// retry or fall back
} else {
throw err;
}
}

parsed is undefined only when no schema was passed to complete(), not on a parse error. When a schema is passed and the model output is valid JSON, parsed is always present.

The structured option takes two fields:

FieldTypeDefaultNotes
schemaRecord<string, unknown>requiredA JSON Schema object. Must be a valid JSON Schema — the SDK does not validate the schema itself, but invalid schemas will cause provider errors.
namestring'structured_output'The tool name used on Anthropic (where structured output is implemented via a forced tool call). Also used as json_schema.name on OpenAI. Use a descriptive name when debugging Anthropic tool-call logs.

complete<T>() vs LLMClient.structuredComplete<T>()

complete<T>() with a structured option is the recommended path. It returns { text, parsed?, response } where parsed is typed as T when a schema is passed.

LLMClient.structuredComplete<T>(input, schema, options) is a lower-level convenience on the LLMClient class. It calls complete() internally and throws if parsing fails (instead of returning undefined). Use it when you want the parse failure to propagate as an exception rather than checking parsed !== undefined.

Schema compatibility across providers

Anthropic’s forced-tool workaround accepts any valid JSON Schema. OpenAI’s strict mode rejects schemas using anyOf, oneOf, $ref, optional properties (must all be in required), or null types without explicit union. If you target multiple providers, design schemas conservatively: flat objects, all fields required, no anyOf.

Scenario 11 (raw structured JSON):

import {complete} from '@combycode/llm-sdk';

// One `structured` option, uniform across providers — the SDK maps it to each
// provider's native mechanism (incl. Anthropic's forced-tool) internally.
const t0 = performance.now();
const {parsed} = await complete<{ city: string; tempC: number }>({
    model: process.env.LLM_MODEL!,
    apiKey: process.env.LLM_API_KEY,
    prompt: 'Extract the city and temperature in Celsius. Text: "Paris is 20 degrees C."',
    structured: {
        schema: {
            type: 'object',
            properties: {city: {type: 'string'}, tempC: {type: 'number'}},
            required: ['city', 'tempC']
        }
    },
});

console.log(JSON.stringify({result: parsed?.city ?? '', ms: Math.round(performance.now() - t0)}));

Scenario 12 (typed structured parse):

import {complete} from '@combycode/llm-sdk';

// We have ONE structured mechanism — `complete<T>()` returns a typed `parsed`
// object. (Official SDKs split raw-json vs typed-parse helpers; we don't.)
const t0 = performance.now();
const {parsed} = await complete<{ city: string; tempC: number }>({
    model: process.env.LLM_MODEL!,
    apiKey: process.env.LLM_API_KEY,
    prompt: 'Extract the city and temperature in Celsius. Text: "Paris is 20 degrees C."',
    structured: {
        schema: {
            type: 'object',
            properties: {city: {type: 'string'}, tempC: {type: 'number'}},
            required: ['city', 'tempC']
        }
    },
});

// Typed access: parsed.tempC is a number.
console.log(JSON.stringify({result: parsed ? parsed.city : '', ms: Math.round(performance.now() - t0)}));

The structural difference: official SDKs require you to use three distinct mechanisms. With OpenAI you set response_format and call JSON.parse() or use zodResponseFormat. With Anthropic you define a tool, force tool_choice, and extract tool_use.input from the response content. With Google you set responseMimeType and responseSchema and call JSON.parse() on candidates[0].content.parts[0].text. ORXA reduces this to one structured field; the provider selection, schema translation, and JSON parsing are all done internally.

Parse failure throws, not returns undefined. When the model emits malformed JSON, complete() throws a SyntaxError. parsed is only undefined when no schema was passed at all. Always wrap structured calls in try/catch if you want to retry on bad output.

Model ignoring the schema. If the throw message shows JSON wrapped in a markdown code block, the model ignored your structured-output constraint. This happens more with smaller or older models. Try being more explicit in your prompt: 'Respond with JSON only, matching this exact schema: ...'.

Do not use structured with stream(). The streaming path does not support structured output — the model must emit the complete JSON in a single block. Use complete() for all structured-output calls.

Next steps:

  • Tool call — let the model call functions rather than returning JSON
  • Reasoning — combine reasoning with structured extraction
  • Quickstart — the non-structured baseline