VernLLMVernLLM
Core Features

Structured Output

Client-side validation with Zod and provider-native JSON Schema mode

With Zod

Remember to install zod first!

pnpm add zod

Pass a schema and get a typed, validated result. Works with any validator exposing safeParse (Zod v3/v4).

candidate-schema-zod.ts
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.

candidate-json-schema-mode.ts
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,
});
  • jsonSchema implies JSON mode. It overrides jsonMode: false and sends the request as structured JSON.
  • strict is 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 on strict. 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 no strict field at all, on either provider.
  • description is forwarded where supported. OpenAI-shaped providers receive it in the schema object. Gemini includes it in responseSchema. 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 no description field, 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.

native-structured-output.ts
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.

On this page