Structured Output & Tool Calling

This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

Examples on this page target Python 3.12 with the current anthropic and openai SDKs, Ollama 0.34, Spring Boot 4.1 with the Spring AI 2.0.x BOM, and llama.cpp’s current grammar tooling, checked against the vendors' own API references as of 2026-09-28. Every example below extracts the same target shape from the shared DocsAssistant scenario (introduced in the vector-database and RAG section): given the raw text of one DocsAssistant page, return its metadata as \{title: string, component: string, topics: string[]}. Holding the shape constant across every vendor and technique lets this page be read as a comparison table as much as a tutorial — the only thing that changes between sections is how each stack constrains and validates the same JSON.

From parsing prose to native structured output

Before any vendor offered a schema-aware generation mode, the only way to get JSON out of a model was to ask for it in the prompt — "respond with a JSON object containing these fields, and nothing else" — and then regex- or substring-extract whatever the model wrapped it in ("Here is the JSON you requested: \`{…​}\`"), before finally calling a JSON parser on the extracted span. This still works, and it is still what a bare local model with no schema-aware API falls back to, but it carries three practical problems: the model can wrap the JSON in prose despite instructions not to, it can produce almost-valid JSON (a trailing comma, an unescaped quote inside a string) that a strict parser rejects, and nothing stops it from inventing a field name or omitting a required one. Native structured-output and JSON-schema modes — covered for each vendor below — replace that parse-the-text approach with the vendor constraining which tokens the model is allowed to sample so that the output is JSON, and in the strict variants so that it also matches a specific schema, before your code ever sees it. The regex-extraction approach is worth recognizing when reading older code or a model/runtime that has no schema-aware mode, but for anything built new against a current API, the sections below are the preferred starting point.

JSON mode vs. JSON-schema / strict structured outputs

Both major hosted vendors expose two tiers of constraint, and the distinction matters for what you can assume downstream:

  • JSON mode (OpenAI’s older \{"type": "json_object"} response format, and Ollama’s format: "json") guarantees the output parses as JSON. It does not guarantee any particular field is present, correctly typed, or even the field you asked for by name — the model is still free to choose the object’s shape. Anthropic has no schema-less JSON mode: as of 2026-09-28 its structured outputs always take a JSON Schema.

  • JSON-schema / strict structured outputs (Anthropic’s JSON outputs through output_config: \{"format": \{"type": "json_schema", "schema": …​}} on the ordinary /v1/messages endpoint, plus strict: true on a tool definition for tool parameters; OpenAI’s json_schema text format with strict: true) additionally constrains the model’s token sampling so that the output matches a JSON Schema you supply: every required field present, every type correct, no extra fields when additionalProperties: false is set. Both vendors implement this with grammar-constrained sampling — compiling the JSON Schema into a grammar that restricts which tokens are even eligible at each decoding step, the same family of technique covered generically in Sampling & Decoding as a constraint layered on top of ordinary temperature/top-p sampling, except here the constraint comes from a schema instead of a fixed vocabulary subset.

Locally run models get the equivalent guarantee through a different mechanism, covered next.

Constrained (grammar) decoding: Ollama and llama.cpp

Ollama and llama.cpp — Ollama’s own inference engine is in fact built on llama.cpp — expose the same grammar-constrained idea for models you run yourself, without depending on a hosted vendor’s schema-aware endpoint. Apple’s on-device Foundation Models framework applies the same idea in Swift: its @Generable guided generation derives a schema from a structure and constrains decoding to it, as shown in Apple Intelligence and Machine Learning.

Ollama: the format field

Ollama’s /api/chat and /api/generate endpoints accept a format field that can be either the literal string "json" (loose JSON-mode behavior) or a full JSON Schema object, which Ollama compiles into a grammar and uses to constrain decoding exactly as the hosted vendors do for their strict modes:

curl http://localhost:11434/api/chat -d '{
  "model": "llama3.1:8b",
  "messages": [
    {"role": "user", "content": "Extract the title, component and topics from this DocsAssistant page:\n\n== Configuring HNSW parameters\nThis page (component: pgvector-guide) covers ef_search, ef_construction and m for pgvector HNSW indexes."}
  ],
  "stream": false,
  "format": {
    "type": "object",
    "properties": {
      "title": {"type": "string"},
      "component": {"type": "string"},
      "topics": {"type": "array", "items": {"type": "string"}}
    },
    "required": ["title", "component", "topics"],
    "additionalProperties": false
  }
}'

See the Ollama structured outputs documentation for the format field on both endpoints.

llama.cpp: GBNF grammars

Ollama’s format field is itself implemented on top of llama.cpp’s grammar system, but llama.cpp also exposes that system directly through GBNF (a GGML-flavored Backus-Naur form): a grammar file of production rules that define which sequences of characters are legal at each position in the output, passed to llama-cli or llama-server with a --grammar (inline) or --grammar-file flag. A minimal GBNF rule for the same \{title, component, topics} shape looks like this:

root   ::= "{" ws "\"title\":" ws string "," ws "\"component\":" ws string "," ws
           "\"topics\":" ws "[" ws (string ("," ws string)*)? ws "]" ws "}"
string ::= "\"" [^"]* "\""
ws     ::= [ \t\n]*

llama.cpp also ships a converter that turns a JSON Schema straight into an equivalent GBNF grammar, so hand writing rules like the ones above is rarely necessary for a schema you already have; it exists mainly for grammars that constrain something other than JSON — a fixed set of command tokens, a restricted subset of a programming language — where no JSON Schema equivalent applies. Either way, the mechanism is the same as Ollama’s format and the hosted vendors' strict modes: legal next-tokens are filtered against the grammar’s current state before sampling ever runs, so an invalid token is never eligible to be chosen in the first place, rather than being generated and then rejected afterward.

Anthropic: JSON outputs with output_config.format

Anthropic’s recommended way to get a schema-conformant JSON answer is JSON outputs: pass output_config: \{"format": \{"type": "json_schema", "schema": …​}} on an ordinary messages.create call to /v1/messages. There is no separate endpoint and, as of 2026-09-28, no beta header. The API compiles the schema into grammar-constrained sampling, so the text block Claude returns always parses as JSON that matches the schema — correct types, all required fields present, no extra fields when additionalProperties: false is set:

import json

import anthropic

client = anthropic.Anthropic()  # reads ANTHROPIC_API_KEY from the environment

PAGE_METADATA_SCHEMA = {
    "type": "object",
    "properties": {
        "title": {"type": "string"},
        "component": {"type": "string"},
        "topics": {"type": "array", "items": {"type": "string"}},
    },
    "required": ["title", "component", "topics"],
    "additionalProperties": False,
}

page_text = (
    "== Configuring HNSW parameters\n"
    "This page (component: pgvector-guide) covers ef_search, ef_construction and m "
    "for pgvector HNSW indexes."
)

response = client.messages.create(
    model="claude-sonnet-5",
    max_tokens=1024,
    messages=[{"role": "user", "content": f"Extract metadata from this page:\n\n{page_text}"}],
    output_config={"format": {"type": "json_schema", "schema": PAGE_METADATA_SCHEMA}},
)

page_metadata = json.loads(response.content[0].text)
print(page_metadata)  # {'title': 'Configuring HNSW parameters', 'component': 'pgvector-guide', 'topics': [...]}

The Python SDK also offers client.messages.parse(), which takes a Pydantic model as output_format, translates it to output_config.format for you, and returns the validated object as response.parsed_output:

from pydantic import BaseModel

class PageMetadata(BaseModel):
    title: str
    component: str
    topics: list[str]

response = client.messages.parse(
    model="claude-sonnet-5",
    max_tokens=1024,
    messages=[{"role": "user", "content": f"Extract metadata from this page:\n\n{page_text}"}],
    output_format=PageMetadata,
)
print(response.parsed_output)  # PageMetadata(title='Configuring HNSW parameters', ...)

Strict tool use is a separate, complementary guarantee: strict: true on a tool definition constrains the tool parameters Claude sends (tool_use.input) to the tool’s input_schema, rather than the shape of Claude’s answer. Use JSON outputs when the JSON is the answer, and strict tool use when the JSON is a call your code executes — the search_docs example in Tool / function calling as structured output below shows the latter.

See the Anthropic structured outputs documentation for output_config.format, messages.parse() and the supported JSON Schema subset, and the Anthropic strict tool use documentation for strict: true on tools.

OpenAI: a JSON-schema text format

OpenAI’s Responses API takes the JSON Schema directly on the request, through text.format with \{"type": "json_schema", …​} and strict: true, rather than routing the extraction through a tool call:

from openai import OpenAI

client = OpenAI()  # reads OPENAI_API_KEY from the environment

page_metadata_format = {
    "type": "json_schema",
    "name": "page_metadata",
    "strict": True,
    "schema": {
        "type": "object",
        "properties": {
            "title": {"type": "string"},
            "component": {"type": "string"},
            "topics": {"type": "array", "items": {"type": "string"}},
        },
        "required": ["title", "component", "topics"],
        "additionalProperties": False,
    },
}

page_text = (
    "== Configuring HNSW parameters\n"
    "This page (component: pgvector-guide) covers ef_search, ef_construction and m "
    "for pgvector HNSW indexes."
)

response = client.responses.create(
    model="gpt-6-sol",
    input=[
        {"role": "user", "content": f"Extract title, component and topics from this page:\n\n{page_text}"}
    ],
    text={"format": page_metadata_format},
)

import json
page_metadata = json.loads(response.output_text)
print(page_metadata)

The json_schema format is, per OpenAI’s own documentation, the direct successor to the older \{"type": "json_object"} JSON mode: both guarantee syntactically valid JSON, but only json_schema with strict: true guarantees the result matches the schema’s required fields, types and (when additionalProperties: false is set) exact field set — OpenAI’s guidance is to prefer it over plain JSON mode whenever the target shape is known ahead of time, which it is for every example on this page.

See the OpenAI structured outputs guide for the text.format/json_schema shape (and its Chat Completions equivalent, response_format) and its per-model support.

Pydantic: parse, validate and retry

A JSON-schema mode still only guarantees the wire format is correct; it says nothing about domain rules a plain JSON Schema cannot express (a component value that must be one of this section’s actual component names, a non-empty topics list). Pydantic closes that gap: keep output_config.format on the request so the shape is guaranteed, define the domain rules on a BaseModel, parse each response into it, and treat a ValidationError as a retry signal — feeding the validation error’s own message back to the model so the next attempt has a concrete reason to correct, rather than repeating the same mistake:

from pydantic import BaseModel, ValidationError, field_validator

KNOWN_COMPONENTS = {"pgvector-guide", "hermes", "irurueta-algebra"}

class PageMetadata(BaseModel):
    title: str
    component: str
    topics: list[str]

    @field_validator("component")
    @classmethod
    def component_must_be_known(cls, value: str) -> str:
        if value not in KNOWN_COMPONENTS:
            raise ValueError(f"'{value}' is not a known DocsAssistant component")
        return value

    @field_validator("topics")
    @classmethod
    def topics_must_not_be_empty(cls, value: list[str]) -> list[str]:
        if not value:
            raise ValueError("topics must contain at least one entry")
        return value

def extract_page_metadata(client, page_text: str, max_attempts: int = 3) -> PageMetadata:
    correction = ""
    for _ in range(max_attempts):
        response = client.messages.create(
            model="claude-sonnet-5",
            max_tokens=1024,
            messages=[{
                "role": "user",
                "content": (
                    f"Extract title, component and topics from this page:\n\n{page_text}"
                    f"{correction}"
                ),
            }],
            # Same schema as the Anthropic section above: the shape is guaranteed by the API.
            output_config={"format": {"type": "json_schema", "schema": PAGE_METADATA_SCHEMA}},
        )
        try:
            # Only the domain-rule validators can still fail here.
            return PageMetadata.model_validate_json(response.content[0].text)
        except ValidationError as error:
            correction = f"\n\nYour previous answer broke a rule: {error}. Correct it."
    raise RuntimeError(f"could not extract valid page metadata after {max_attempts} attempts")

The loop reuses PAGE_METADATA_SCHEMA from the Anthropic section rather than PageMetadata.model_json_schema(), because Pydantic’s generated schema omits additionalProperties: false unless the model sets extra="forbid". It combines with the schema-conformance guarantee rather than replacing it: JSON outputs already rule out a missing field or wrong type, so the retries this loop spends are used entirely on the domain rules a JSON Schema cannot express (component_must_be_known, topics_must_not_be_empty) — a narrower, cheaper problem than validating the whole shape from scratch on every attempt. See the Anthropic structured outputs documentation for output_config.format on messages.create.

Jackson: the JVM equivalent

On the JVM, Jackson plays the role Pydantic’s parsing plays in Python: deserializing a JSON string into a typed object. Spring Boot 4.1 uses Jackson 3, whose packages moved to tools.jackson, whose mapper is the immutable JsonMapper built with JsonMapper.builder(), and whose base exception JacksonException is now unchecked. Domain rules go on the record components as Bean Validation annotations, which a record supports without any compact constructor:

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List;

public record PageMetadata(
        @NotBlank String title,
        @NotBlank String component,
        @NotNull @Size(min = 1) List<String> topics) {}

Jackson does not fail closed on a missing field by default: a missing title simply becomes null. Enable DeserializationFeature.FAIL_ON_MISSING_CREATOR_PROPERTIES (and FAIL_ON_NULL_CREATOR_PROPERTIES for an explicit null) to make it raise. Jackson also never looks at the Bean Validation annotations, so the code calls a Validator explicitly after deserializing:

import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import java.util.Set;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.json.JsonMapper;

JsonMapper mapper = JsonMapper.builder()
        .enable(DeserializationFeature.FAIL_ON_MISSING_CREATOR_PROPERTIES)
        .enable(DeserializationFeature.FAIL_ON_NULL_CREATOR_PROPERTIES)
        .build();
Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); // or inject Spring's Validator bean

try {
    PageMetadata metadata = mapper.readValue(rawJsonFromModel, PageMetadata.class);
    Set<ConstraintViolation<PageMetadata>> violations = validator.validate(metadata);
    if (!violations.isEmpty()) {
        // retry with the violation messages fed back to the model, same as the Pydantic loop above
    }
} catch (JacksonException e) {
    // retry with the exception's message fed back to the model
}

With both features on, a wrong shape raises JacksonException and a broken domain rule shows up as a ConstraintViolation, so the same retry-on-failure pattern from the Pydantic section applies. See the Jackson 3 migration guide for the tools.jackson packages, JsonMapper.builder() and the unchecked JacksonException, and the Hibernate Validator reference for Validator.validate().

Spring AI: ChatClient.entity()

Spring AI wraps exactly this parse-and-validate step behind ChatClient’s `entity() call, so application code never touches raw JSON or ObjectMapper directly. Internally it appends the target type’s JSON Schema as format instructions to the prompt (a BeanOutputConverter, covered in more structural depth for the vector-database and RAG scenario in Integrating with Spring AI) and deserializes the response with Jackson, raising the same way ObjectMapper.readValue does on a mismatch:

public record PageMetadata(String title, String component, List<String> topics) {}
PageMetadata metadata = chatClient.prompt()
        .user(u -> u.text("""
                Extract title, component and topics from this DocsAssistant page:

                {pageText}
                """)
                .param("pageText", pageText))
        .call()
        .entity(PageMetadata.class);

entity() accepts either a Class<T> (as above) or a ParameterizedTypeReference<T> for a generic target such as List<PageMetadata>. Spring AI’s own documentation describes this conversion as best-effort rather than a schema-conformance guarantee equivalent to Anthropic’s JSON outputs or OpenAI’s strict structured outputs — it depends on the underlying model actually following the appended format instructions — so pairing it with validation (Bean Validation annotations, or a manual check before the value is used) remains worthwhile even though entity() already removes the manual JSON parsing step.

Tool / function calling as structured output

Every example so far asked the model to return structured data as the final answer. Tool calling is the same underlying mechanism — the model still emits a schema-shaped JSON object rather than free text — pointed at a different purpose: instead of the JSON being the answer, it is a request your application executes, whose result is fed back so the model can produce a final, free-text answer. A search_docs tool for DocsAssistant illustrates the full round trip:

import anthropic

client = anthropic.Anthropic()  # reads ANTHROPIC_API_KEY from the environment

search_docs_tool = {
    "name": "search_docs",
    "description": "Search DocsAssistant's indexed documentation pages for a query.",
    "strict": True,
    "input_schema": {
        "type": "object",
        "properties": {
            "query": {"type": "string"},
            "component": {"type": "string", "description": "Restrict the search to one component"},
        },
        "required": ["query"],
        "additionalProperties": False,
    },
}

messages = [{"role": "user", "content": "What does ef_search control in pgvector's HNSW index?"}]

# 1. The model requests a tool call instead of answering directly.
response = client.messages.create(
    model="claude-sonnet-5",
    max_tokens=1024,
    tools=[search_docs_tool],
    messages=messages,
)
tool_use = next(block for block in response.content if block.type == "tool_use")
print(tool_use.name, tool_use.input)  # search_docs {'query': 'ef_search HNSW pgvector', 'component': 'pgvector-guide'}

# 2. The application executes the real search and returns a tool_result.
search_result = "ef_search controls how many candidate nodes HNSW visits per query; higher values raise recall and latency."
messages += [
    {"role": "assistant", "content": response.content},
    {"role": "user", "content": [
        {"type": "tool_result", "tool_use_id": tool_use.id, "content": search_result}
    ]},
]

# 3. The model turns the tool result into the final, free-text answer.
followup = client.messages.create(
    model="claude-sonnet-5",
    max_tokens=1024,
    tools=[search_docs_tool],
    messages=messages,
)
final_text = next(block for block in followup.content if block.type == "text")
print(final_text.text)
sequenceDiagram participant U as User participant App as DocsAssistant app participant LLM as Claude participant Tool as search_docs executor U->>App: "What does ef_search control?" App->>LLM: messages.create(tools=[search_docs], messages=[...]) LLM-->>App: tool_use: search_docs(query, component) App->>Tool: execute search_docs(query, component) Tool-->>App: matching page excerpt App->>LLM: messages.create(..., tool_result=excerpt) LLM-->>App: final answer (plain text) App-->>U: final answer

The strict: true flag on search_docs_tool is the tool-parameter counterpart of the JSON outputs used for extraction above: it guarantees tool_use.input always has a query string and never a stray extra field, so the application’s tool-executing code can trust the call’s shape without its own defensive parsing. The round trip itself — request, execute, return, final answer — is the same shape regardless of which vendor’s SDK is used; Context Windows & Memory covers what happens to this exchange once several tool calls accumulate across a longer conversation, and the "Best practices for tool definitions" section of Anthropic’s Define tools documentation covers writing the tool description field well enough that the model reaches for the right tool without being forced into it via tool_choice.

Validation and retry as a general pattern

Across every technique on this page, "validate then retry with the error fed back" is the same three-step loop regardless of which layer produced the invalid output:

  • Schema-level failures (wrong type, missing required field) are what strict structured-output modes exist to eliminate up front — prefer catching these before the call returns rather than after, since a schema violation is exactly the class of error grammar-constrained sampling is built to prevent.

  • Domain-level failures (a component that parses fine as a string but is not one this section actually has, a topics list that is technically non-empty but nonsensical) cannot be expressed in a JSON Schema and are what Pydantic’s @field_validator, Bean Validation annotations, or a hand-written check after entity() exist to catch.

  • The retry itself should always feed the concrete validation error back into the next prompt (as the Pydantic loop above does), rather than merely re-asking the same question — a model given no information about why its previous answer failed has no more reason to succeed on attempt two than on attempt one.

A production system typically bounds the retry loop (three attempts is a common default) and falls back to a human-reviewed queue or a rejection rather than looping indefinitely against a model that keeps failing the same validation.

References