Structured Output
Client-side validation with Zod and provider-native JSON Schema mode
With Zod
Remember to install zod first!
pnpm add zodPass a schema and get a typed, validated result. Works with any validator exposing safeParse (Zod v3/v4).
import { z } from 'zod';
const CandidateSchema = z.object({
name: z.string(),
skills: z.array(z.string()),
});
const result = await llm.call({
systemPrompt: 'Extract the candidate name and skills as JSON.',
userContent: resumeText,
schema: CandidateSchema,
});On a schema mismatch, call throws LLMError('validation') with .issues set to the validator's
error object, without burning a retry.
schema only runs if the call actually parses JSON, which happens when jsonMode is true (the
default) or jsonSchema is set. If you pass jsonMode: false alongside schema without also
setting jsonSchema, call throws LLMError('invalid_params') immediately. On Anthropic and
Bedrock, which don't support json_object as a real constraint, schema without jsonSchema
throws the same way even without an explicit jsonMode: false: an implicit request for JSON (via
schema) isn't silently downgraded into skipping validation the way a schema-less call is. See
Anthropic and Bedrock for details.
Provider-native JSON Schema mode
schema validates client-side after generation. jsonSchema sends the schema to the provider using its native structured-output mechanism.
const result = await llm.call({
systemPrompt: 'Extract the candidate name and skills.',
userContent: resumeText,
jsonSchema: {
name: 'Candidate',
schema: {
type: 'object',
properties: {
name: { type: 'string' },
skills: { type: 'array', items: { type: 'string' } },
},
required: ['name', 'skills'],
},
strict: true,
description: 'A candidate profile',
},
schema: CandidateSchema,
});jsonSchemaimplies JSON mode. It overridesjsonMode: falseand sends the request as structured JSON.strictis forwarded when supported by the adapter. Enforcement depends on the adapter and the mechanism it uses: OpenAI/Groq use JSON Schema mode, Gemini uses schema-based structured output without relying onstrict. On Anthropic and Bedrock, this only applies to the legacy forced-single-tool-call mechanism (Anthropic's tool strictness, Bedrock's tool strictness where supported); their native structured-output mechanism (see Combining with tools) has nostrictfield at all, on either provider.descriptionis forwarded where supported. OpenAI-shaped providers receive it in the schema object. Gemini includes it inresponseSchema. On Anthropic and Bedrock, this again only applies to the legacy forced-single-tool-call mechanism, where it's included in the generated tool definition; Anthropic's native mechanism has nodescriptionfield, and Bedrock's native mechanism does accept one, but nested differently from every other schema field these adapters build (see below).
Anthropic uses native tool use for structured output on models not covered by
nativeStructuredOutputModels, the legacy path described above: the schema is passed as the tool
input_schema and tool_choice forces the model to call it. On a covered model, jsonSchema is
sent through output_config.format instead (see Combining with tools). A
client-side schema is still recommended when you need validation guarantees across providers,
regardless of which path is used.
Combining with tools
By default, on Anthropic and Bedrock, jsonSchema and tools cannot be used together.
jsonSchema is emulated as a forced single tool call, which occupies the same request field real,
caller-supplied tools need. On unsupported Anthropic and Bedrock models, setting both throws
LLMError('invalid_params') with code: 'unsupported_capability' and
issues.capability: 'tools_with_json_schema' before a request is sent, allowing the default
fallback policy to try the next target. Gemini and OpenAI-compatible clients never had this restriction and don't need
nativeStructuredOutputModels at all: Gemini builds responseSchema and tools as independent,
unconditional fields, and OpenAI-compatible clients pass response_format/tools straight
through, so jsonSchema and tools always compose there.
Both providers also support a schema-constrained output mechanism, output_config.format on
Anthropic and outputConfig.textFormat on Bedrock, that lives in its own request field,
independent of tool calling. On a model that supports it, jsonSchema no longer needs to be
emulated as a tool call, so it composes with real tools in the same request.
This is opt-in and per-adapter, not automatic. Pass nativeStructuredOutputModels as part of the
second argument to fromAnthropic/fromBedrock, either a static list of model IDs you've
verified support the mechanism, or a predicate function:
Anthropic maintains an up to date list of which model families support output_config.format
(JSON outputs) and strict: true (strict tool use), and which platforms (Claude API, Bedrock,
Google Cloud, Microsoft Foundry) that support applies to, at Structured outputs →
Compatibility.
This library doesn't hardcode that list into nativeStructuredOutputModels's default, since
support varies by exact model and can change: check the linked page for the current set before
populating this option, rather than relying on a snapshot baked into a specific vern-llm
version.
import Anthropic from '@anthropic-ai/sdk';
import { VernLLM, fromAnthropic } from 'vern-llm';
const llm = new VernLLM({
client: fromAnthropic(new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY }), {
nativeStructuredOutputModels: ['claude-native-model-id'],
// ...or a predicate:
// nativeStructuredOutputModels: (model) => model.startsWith('claude-4'),
}),
model: 'claude-native-model-id',
});
const result = await llm.call({
userContent: 'What is the weather, and extract it as structured data?',
tools: [weatherTool],
jsonSchema: {
name: 'WeatherReport',
schema: { type: 'object', properties: { summary: { type: 'string' } } },
},
});There is no built-in default list of native-capable models. Anthropic's and Bedrock's supported
model set changes over time and isn't this package's call to make, hardcoding a guess would risk
silently routing a request onto a field a given model doesn't actually support, trading a clear
LLMError('invalid_params') with code: 'unsupported_capability' for a confusing error from the
provider instead. Left unset, every model keeps using the forced-single-tool-call emulation, and
tools + jsonSchema together is rejected, exactly the behavior before this option existed.
On a model not covered by nativeStructuredOutputModels, jsonSchema alone (no tools) is
unaffected and keeps working exactly as before, forced tool-use, described above. The restriction
only applies when both tools and jsonSchema are set on the same call.
Wire shape on the native path
Each provider's native mechanism has a narrower field set than the legacy forced-tool-call path, matched exactly to what each provider's real API accepts:
Anthropic: output_config.format
Only type and schema are sent. There is no name, description, or strict field on this
mechanism at all, unlike Anthropic's tool-based schema (used on the legacy path), which does
have all three.
Bedrock: outputConfig.textFormat
The schema is nested one level deeper than every other schema shape these adapters build, under
structure.jsonSchema, and schema itself must be a JSON-encoded string, not a parsed
object. name and description are still accepted here, nested at that same level, but there
is no strict field, unlike Bedrock's tool-based schema (toolSpec.inputSchema.json, used on
the legacy path), which accepts a parsed object and does have strict.
Both shapes are verified against the real @anthropic-ai/sdk and @aws-sdk/client-bedrock-runtime
clients in this package's own test suite, not just asserted internally, so a request built by
fromAnthropic/fromBedrock for a covered model is confirmed to actually reach the wire in the
shape each provider's real SDK expects.