Skip to content

Structured output

By the end of this page, a model call returns your own Rust struct. The provider shapes its answer to the struct’s JavaScript Object Notation (JSON) Schema.

Bring the types and traits into scope:

use schemars::{JsonSchema, schema_for};
use serde::Deserialize;
use swiftide::{
agents::{Agent, StopReason, tools::control::StopWithArgs},
integrations::openai::OpenAI,
traits::{DynStructuredPrompt, StructuredPrompt as _},
};

Source: snippets/src/models/structured_output.rs#L4-L10

Describe the answer as a type:

/// What a model reads out of a changelog.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ReleaseSummary {
/// One line that names the release theme.
pub title: String,
/// Changes a user notices.
pub highlights: Vec<String>,
/// True when upgrading needs code changes.
pub breaking: bool,
}

Source: snippets/src/models/structured_output.rs#L14-L24

Then ask the model for it:

let openai = OpenAI::builder()
.default_prompt_model("gpt-5-mini")
.build()?;
let summary = openai
.structured_prompt::<ReleaseSummary>(
format!("Summarise this changelog:\n{changelog}").into(),
)
.await?;
println!("{}: {} highlights", summary.title, summary.highlights.len());
if summary.breaking {
println!("This release needs code changes.");
}

Source: snippets/src/models/structured_output.rs#L30-L43

The doc comments on the fields become descriptions in the schema, so the model reads them as instructions.

structured_prompt::<T> is a method of the StructuredPrompt trait, so the trait must be in scope. The method takes a Prompt, so .into() turns a String or a &'static str into one. Templates and template context work as on Prompts and templates.

Swiftide builds the schema with schemars::schema_for!(T) and sends it with the rendered prompt. It then deserializes the JSON the model returns into T. When the JSON does not fit T, the call returns LanguageModelError::PermanentError.

You need schemars, serde and serde_json in your own crate:

Terminal window
cargo add swiftide@0.34 --features openai
cargo add schemars@1 serde serde_json --features serde/derive

structured_prompt itself needs no extra feature flag. The json-schema feature only derives JsonSchema on some of Swiftide’s own types.

With json-schema, ToolCall, ToolSpec, ToolFeedback, StopReason and the agent State derive JsonSchema. Your own types can then hold them as fields and still derive a schema, for example the request body of an approval endpoint:

Terminal window
cargo add swiftide@0.34 --features openai,swiftide-agents,json-schema
/// The body your approval endpoint accepts; the `json-schema` feature flag lets it derive
/// `JsonSchema` with Swiftide types as fields.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ApprovalDecision {
/// The call the agent stopped on, as `StopReason::FeedbackRequired` reported it.
pub tool_call: swiftide::chat_completion::ToolCall,
/// The person's decision.
pub feedback: swiftide::traits::ToolFeedback,
}
/// Prints the JSON Schema of the approval body, for example for an OpenAPI document.
pub fn approval_schema() -> anyhow::Result<()> {
let schema = schema_for!(ApprovalDecision);
println!("{}", serde_json::to_string_pretty(&schema)?);
Ok(())
}

Source: snippets/src/models/structured_output.rs#L88-L104

StopReason and State live in swiftide::agents, so they also need the swiftide-agents feature flag. The json-schema feature adds no API of its own.

OpenAI clients send the schema in strict mode. This holds for Chat Completions and for the Responses application programming interface (API). Strict mode has two rules for every object in the schema.

The first rule is additionalProperties: false. Add #[serde(deny_unknown_fields)] to the struct, and to every struct it contains. Schemars then writes additionalProperties: false for that object. Without it, OpenAI rejects the request with an error about additionalProperties.

The second rule is that every field is listed in required. Schemars leaves Option<T> fields and #[serde(default)] fields out of required, so OpenAI rejects them. Use a required field with a sentinel value, or an enum, instead.

Swiftide passes the schemars schema through as it is. It does not rewrite the schema to fit a provider, so the provider’s rules on schemas apply to your type directly.

StructuredPrompt is generic over T, so it is not dyn safe. You cannot store a Box<dyn StructuredPrompt>. Use DynStructuredPrompt instead:

let llm: Box<dyn DynStructuredPrompt> = Box::new(openai);
let value = llm
.structured_prompt_dyn(
"Summarise the 0.33 release.".into(),
schema_for!(ReleaseSummary),
)
.await?;
let summary: ReleaseSummary = serde_json::from_value(value)?;

Source: snippets/src/models/structured_output.rs#L51-L59

structured_prompt_dyn takes a schemars::Schema and returns a serde_json::Value. You deserialize the value yourself.

Every DynStructuredPrompt gets StructuredPrompt through a blanket implementation. To add structured output to your own client, implement DynStructuredPrompt only.

ClientFeature flagHow the schema is sent
OpenAIopenaiChat Completions response_format with a JSON Schema. With use_responses_api(true), the Responses API text format.
Ollama, Groq, Gemini, OpenRouter, Dashscopeollama, groq, gemini, open-router, dashscopeSame as OpenAI.
AwsBedrockaws-bedrockConverse output_config with a JSON Schema.
AnthropicanthropicNot supported.
FastEmbedfastembedNot supported.

Whether a JSON Schema response format works on the GenericOpenAI aliases depends on the endpoint and the model.

A structured call is not retried. Errors, backoff, usage and tokens explains why.

An agent talks to its model through chat completion, not through structured_prompt. To get a typed final answer from an agent, replace the default stop tool with StopWithArgs. Agents live behind the swiftide-agents feature flag:

Terminal window
cargo add swiftide@0.34 --features openai,swiftide-agents
let mut agent = Agent::builder()
.llm(&openai)
.without_default_stop_tool()
.tools([StopWithArgs::with_parameters_schema(schema_for!(
ReleaseSummary
))])
.build()?;
agent.query("Summarise the 0.33 release.").await?;
let summary: Option<ReleaseSummary> = match agent.stop_reason() {
Some(StopReason::RequestedByTool(_, Some(payload))) => {
Some(serde_json::from_value(payload.clone())?)
}
_ => None,
};

Source: snippets/src/models/structured_output.rs#L67-L82

The model ends the run by calling stop with arguments that follow the ReleaseSummary schema. The stop reason is then StopReason::RequestedByTool(_, Some(payload)), where payload is the arguments as a serde_json::Value. serde_json::from_value turns it into your type.

The stop tool’s schema follows different rules than structured_prompt. Swiftide normalizes tool schemas for OpenAI: it fills in required and adds additionalProperties: false. Stopping and structured results covers stop tools.

Tool arguments use schemars schemas too, so the same derive works for a tool’s arguments; see Tools.

Images and multimodal input