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.

When your schema is simple (no circular refs, no anyOf with nullable, all fields required) enable strict mode for stricter enforcement on providers that support it:

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,
},
strict: true,
},
maxTokens: 32,
});
console.log(parsed.sentiment); // 'positive'

Strict mode (strict: true) maps to json_schema.strict: true on OpenAI, which enables guaranteed-valid JSON at the cost of rejecting schemas that use features OpenAI’s strict validator does not support (e.g. anyOf, oneOf). On Anthropic and Google the field is silently ignored — structured output is always enforced through tool-call or response-schema mechanisms which do not have a separate strict flag.

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 somehow emits invalid JSON (network truncation, unusual model behaviour), parsed will be undefined and text will contain the raw string. A retry with the same schema usually succeeds:

const result = await complete<MyType>({
model: process.env.LLM_MODEL!,
apiKey: process.env.LLM_API_KEY,
prompt,
structured: { schema },
maxTokens: 256,
});
if (result.parsed === undefined) {
console.warn('parse failed, raw text:', result.text);
// retry or fall back
}

The structured option takes three 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.
strictbooleanfalseMaps to OpenAI’s strict: true in response_format. No effect on Anthropic or Google. Only enable if your schema is compatible (no anyOf, all fields required).

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

complete<T>() with a structured option is the recommended path. It returns { text, parsed, usage, response } where parsed is typed as T | undefined.

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.

Empty parsed is not an error. When parsed is undefined, inspect text. If text contains valid 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.

strict: true and optional fields. OpenAI’s strict mode requires every property to be in required and does not allow additionalProperties: true. If you want optional fields, mark them required and use { type: ['string', 'null'] } instead.

Next steps:

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