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
Schema and deserialization
Section titled “Schema and deserialization”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:
cargo add swiftide@0.34 --features openaicargo add schemars@1 serde serde_json --features serde/derivestructured_prompt itself needs no extra feature flag. The json-schema feature only derives JsonSchema on some of Swiftide’s own types.
Schemas for Swiftide types
Section titled “Schemas for Swiftide 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:
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.
Schema rules
Section titled “Schema rules”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.
Trait objects
Section titled “Trait objects”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.
Provider support
Section titled “Provider support”| Client | Feature flag | How the schema is sent |
|---|---|---|
OpenAI | openai | Chat Completions response_format with a JSON Schema. With use_responses_api(true), the Responses API text format. |
Ollama, Groq, Gemini, OpenRouter, Dashscope | ollama, groq, gemini, open-router, dashscope | Same as OpenAI. |
AwsBedrock | aws-bedrock | Converse output_config with a JSON Schema. |
Anthropic | anthropic | Not supported. |
FastEmbed | fastembed | Not 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.
Agents
Section titled “Agents”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:
cargo add swiftide@0.34 --features openai,swiftide-agentslet 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.