rstructor: Structured LLM Outputs for Rust
Get structured, validated data out of any LLM as native Rust structs and enums. Define the shape you want as plain Rust types — rstructor generates the JSON Schema, prompts the model, parses the response, and retries on validation errors until the data fits.
Features
- Type-safe schemas from Rust types — Derive
Instructoron structs and enums; rstructor generates the JSON Schema and validated parser for you, no hand-written prompts or DTOs - Multi-provider, one API — OpenAI, Anthropic, Grok (xAI), and Gemini behind a single
materialize()call with swappable clients - Validation with automatic re-ask — Built-in type checking plus custom business rules; validation failures are fed back to the model and retried until the data is correct
- Rich, nested data — Nested objects, arrays, optionals, maps, and enums with associated data, with validation that recurses through the whole tree
- Familiar if you know Pydantic + Instructor — The same structured-output workflow as Python's Instructor + Pydantic, with Rust's compile-time type safety
Installation
[]
= "0.3"
= { = "1.0", = ["derive"] }
= { = "1.0", = ["rt-multi-thread", "macros"] }
Quick Start
Describe the shape you want as plain Rust types, then turn a line of free-form text into a fully-typed, validated value:
use ;
use ;
async
Every field is inferred, not transcribed: the urgency is read from the tone and deadline, the email is plucked out of mid-sentence text, and the tags are synthesized — all parsed into the exact types you declared.
If you want provider-specific configuration, use the explicit client form:
use ;
let client = from_env?.temperature;
let ticket: Ticket = client.materialize.await?;
Already using schemars?
Enable rstructor's schemars feature and wrap your existing
JsonSchema + Serialize + Deserialize model; no Instructor derive is needed:
let ticket = client
.
.await?
.into_inner;
Nested schemas and doc-comment descriptions are inlined automatically. Recursive schemars models are rejected before a provider request because v1 of the bridge intentionally sends only reference-free schemas.
Request Builder
materialize, generate, and (with the tools feature) tool run are also
available through a fluent builder that attaches context, images, and tools to a
single request. Bring RequestExt into scope and chain the pieces you need:
use ;
let client = from_env?;
// Keep stable instructions separate from the dynamic user prompt.
let movie: Movie = client
.with_system
.materialize
.await?;
// Or start from `.request()` and combine builders before a terminal.
let summary = client
.request
.system
.generate
.await?;
The terminals are materialize::<T>(prompt) (structured), generate(prompt)
(text), and — with the tools feature — run(prompt) (text, calling any
attached tools in a loop). Builders compose: with_system, with_media, and
with_tools can be chained in any order before the terminal.
System prompts and prompt caching
Built-in clients send with_system(...) through each provider's native
instruction channel for every request terminal:
- OpenAI-compatible APIs and xAI receive an initial
systemmessage. - Anthropic receives the top-level
systemfield. - Gemini receives
systemInstruction.
This preserves instruction semantics and keeps a stable prefix eligible for the
provider's prompt cache instead of merging it into each changing user message.
It applies to structured materialization, raw generation, streaming, media
requests, retry ledgers, and run with or without tools.
Put stable policy and examples in the system prompt, then keep request-specific data in the user prompt:
use ;
const RISK_POLICY: &str =
"Use the fund's base currency. Report exposure as a multiple of NAV.";
let client = from_env?;
let result = client
.with_system
.
.await?;
if let Some = result.cumulative_usage
OpenAI, Gemini, and xAI can apply implicit prefix caching when a request meets
their model and token thresholds. Anthropic requires cache-control configuration
to create a cache, which rstructor does not enable implicitly because it can
change billing. Likewise, rstructor does not currently create Gemini explicit
cache objects or set OpenAI cache-routing keys. Provider-reported cache reads and
writes are exposed as cached_input_tokens and cache_write_input_tokens;
both are subsets of input_tokens and are not added again by total_tokens().
See the official OpenAI,
Anthropic,
Gemini, and
xAI
prompt-caching guides for current eligibility and retention rules.
Recipes
Start with the task you need, then open the linked runnable example. The task-oriented cookbook expands the core workflows into complete copy-paste recipes.
| I want to… | Example | What it shows |
|---|---|---|
| Extract typed data from free text | structured_movie_info.rs |
Turns one sentence into a validated Rust struct with field descriptions and business rules. |
| Classify into an enum | news_article_categorizer.rs |
Selects a typed category while extracting sentiment, entities, and keywords. |
| Extract from an image or PDF | openai_multimodal_example.rs (Anthropic, Gemini, Grok) |
Sends inline media with a prompt and materializes the answer into a struct. |
| Read a chart with Kimi K3 | kimi_k3_multimodal_example.rs |
Downloads a labeled revenue chart, sends it through Moonshot's OpenAI-compatible endpoint, and returns typed values plus calculated insights. |
| Put extraction behind an axum handler | axum_handler_example.rs |
Injects any LLMClient into typed JSON request and response handling, tested in-process. |
| Test without network | mock_testing_example.rs |
Scripts realistic responses through the real deserialization, validation, and re-ask path. |
| Use a local model with Ollama | ollama_local_example.rs |
Connects to the keyless local endpoint through the same structured-output API. |
| Choose a provider at runtime | runtime_provider_example.rs |
Parses provider/model into one AnyClient, including aggregator model IDs with slashes. |
| Reuse an existing schemars model | schemars_bridge_example.rs |
Materializes JsonSchema + Serde types through the transparent Schemars<T> adapter. |
| Call tools in a loop | tool_calling_example.rs |
Runs schema-validated tool calls until the model produces its final answer. |
| Stream partial output | streaming_example.rs |
Yields validated list items incrementally and explains completion integrity. |
| Add custom validation and re-ask | validation_example.rs |
Rejects domain-invalid output with a custom validator that providers can retry. |
| Inspect retries and token cost | retry_attempt_ledger.rs |
Reports every attempt, disposition, per-response usage, and cumulative known usage. |
| Model nested or recursive schemas | nested_objects_example.rs, recursive_schema_graph.rs |
Builds deeply nested values and finite $defs graphs for recursive domain types. |
Providers
use ;
// OpenAI (reads OPENAI_API_KEY)
let client = from_env?.model;
// Anthropic (reads ANTHROPIC_API_KEY)
let client = from_env?.model;
// Grok/xAI (reads XAI_API_KEY)
let client = from_env?.model;
// Gemini (reads GEMINI_API_KEY)
let client = from_env?.model;
// Local Ollama (no API key)
let client = ollama?.model;
Local models & aggregators
Ollama and LM Studio use the OpenAI-compatible client without an API key or
Authorization header:
use ;
let local = client?;
let movie: Movie = local.materialize.await?;
// The named constructor is equivalent and supports all normal builders.
let local = lm_studio?.model;
Hosted aggregators read their own keys instead of OPENAI_API_KEY. Model IDs
may contain /; only the first slash separates the route prefix from the model:
use ;
// Reads MOONSHOT_API_KEY. Kimi K3 fixes temperature at 1.0.
let image = from_bytes;
let kimi = moonshot?
.model
.temperature;
let report: RevenueChart = kimi
.materialize_with_media
.await?;
// Reads OPENROUTER_API_KEY.
let router = client?;
let movie: Movie = router.materialize.await?;
// GROQ_API_KEY works the same way.
let groq = client?;
See the runnable
kimi_k3_multimodal_example.rs for
the complete chart schema and output. Moonshot documents
kimi-k3 as a native
vision model with strict JSON Schema output. Public image URLs are not supported,
so attach base64 bytes as above (or use a Moonshot ms:// file ID). Supported
image types are JPEG, PNG, GIF, WebP, BMP, HEIC, and HEIF; SVG is rejected, and
Moonshot recommends no more than 4096×2160 resolution.
The named constructors are OpenAIClient::ollama(), lm_studio(),
openrouter(), groq(), and moonshot(). They all require the openai Cargo
feature. Strict response_format / JSON Schema support varies between compatible
servers; rstructor keeps the same schema request and validation/re-ask retry
loop, without endpoint-specific schema-dialect rewriting.
Selecting a provider at runtime
LLMClient::materialize is generic, so the trait isn't object-safe (Box<dyn LLMClient> is impossible). Use AnyClient when the provider is decided at runtime (CLI flag, config, env) and you want to store it in a single type:
use ;
// Parse a case-insensitive "provider/model" string.
let client = client?;
let movie: Movie = client.materialize.await?;
// Or auto-detect in deterministic environment-key order:
let client = client_from_env?;
// Pick a provider dynamically, reading its key from the environment.
let provider = Anthropic; // e.g. parsed from a config file
let client = from_env_for?;
let movie: Movie = client.materialize.await?;
// The equivalent trait-level constructor is also available:
let client = from_env?;
// Or wrap a pre-configured client:
let client: AnyClient = from_env?.model.into;
Validation
Add custom validation with automatic retry on failure:
use ;
// Retries are enabled by default (3 retries, 4 total attempts)
// To increase retries:
let client = from_env?.max_retries;
// To disable retries:
let client = from_env?.no_retries;
Derive attributes
The llm attribute accepts a small, checked API:
- Structs and enums:
description,title,examples,validate - Fields:
description,example,examples - Enum variants:
description
Examples use native Rust expressions. Use serde_json::json! for object values
instead of embedding serialized JSON strings. Multi-value examples accept both
examples = [one, two] and examples(one, two).
Optionality comes from the Rust type itself:
)]
Unknown llm keys, malformed values, invalid validation paths, and unsupported
tuple or unit structs are compile errors at the relevant attribute or item.
Serde attributes outside the schema subset remain owned by Serde and are not
rejected by Instructor.
Complex Types
Dynamic Maps
Use HashMap<String, V> when keys are runtime data such as ticker symbols,
account IDs, or category names. The map values remain fully typed:
use HashMap;
use ;
use ;
let client = from_env?;
let portfolio: Portfolio = client
.materialize
.await?;
assert_eq!;
assert_eq!;
Gemini accepts native typed dynamic-map schemas. OpenAI, Anthropic, and Grok
strict structured-output dialects cannot currently represent arbitrary keys
without weakening or changing the Rust contract. Those clients return a
non-retryable SchemaCompatibilityError before making an HTTP request instead
of silently constraining the map to {}:
use ;
let result = from_env?
.
.await;
match result
Use a struct instead when the keys are a fixed part of the contract. Dynamic-map fallback encodings are deliberately not selected automatically because doing so would change the provider wire schema and structured-output guarantee.
Nested Structures
Derived schemas also support direct and mutual recursion. Recursive definitions are hoisted to the document root, and concrete Rust type identity keeps same-named types from different modules or generic instantiations distinct:
let schema = schema.to_json;
assert!;
OpenAI, Anthropic, and Grok preserve those recursive references in their
structured-output schemas. Gemini cannot currently represent an unbounded
recursive schema. Its client returns a local, non-retryable compatibility error
instead of silently replacing the deepest recursive branch with {}:
use ;
let result = from_env?
.
.await;
assert!;
Run the complete example with
cargo run --example recursive_schema_graph.
Enums with Data
Serde Deserialization Support
rstructor interprets supported Serde metadata from the deserialization side of
the wire contract. It respects rename, rename_all, rename_all_fields,
skip, and skip_deserializing. When Serde has separate directions, the
deserialize-side name is used:
For symmetric names, the usual shorthand remains unchanged:
Supported case conversions: lowercase, UPPERCASE, camelCase, PascalCase, snake_case, SCREAMING_SNAKE_CASE, kebab-case, SCREAMING-KEBAB-CASE.
Dates, UUIDs, and Custom Types
use ;
use Instructor;
use ;
use Uuid;
For your own domain-specific scalar types, implement CustomTypeSchema plus SchemaType:
use CustomTypeSchema;
use ;
use ;
;
Multimodal (Image & PDF Input)
Analyze images with structured extraction across all major providers by
attaching media to a request with with_media:
use ;
async
MediaFile::new(uri, mime_type) is also available for URL/URI-based media input.
The lower-level LLMClient::materialize_with_media(prompt, &media) method does
the same thing in one call when you do not need the builder. Attached media is
honored by materialize, generate, and tool run alike.
PDFs are supported too: pass "application/pdf" as the MIME type and the
attachment is routed to each provider's documented document format (OpenAI
file part, Anthropic document block, Gemini inlineData/fileData).
Combinations a provider does not support — PDFs on Grok, or URL-based PDFs on
OpenAI chat completions — return a clear error instead of a broken request.
Provider examples:
cargo run --example openai_multimodal_example --features openaicargo run --example anthropic_multimodal_example --features anthropiccargo run --example grok_multimodal_example --features grokcargo run --example gemini_multimodal_example --features gemini
Extended Thinking
Configure reasoning depth for supported models:
use ThinkingLevel;
// GPT-5.6 and Gemini 3.6 use named effort levels; Claude 4.x uses
// extended-thinking token budgets.
let client = from_env?
.model
.thinking_level;
// Levels: Off, Minimal, Low, Medium, High
Token Usage
materialize_with_metadata preserves its original behavior: usage describes
only the final successful provider response.
let result = client..await?;
println!;
if let Some = result.usage
Use materialize_with_attempts when retry cost or failure observability matters.
It returns an ordered ledger plus cumulative known usage, including provider
responses that failed decoding or validation:
match client..await
Usage is conservative: attempts remain in the ledger when a provider omits
token metadata, while cumulative totals include only responses with reported
usage. Local schema/media preflight failures record zero provider attempts.
Cache read and write counters are provider-reported subsets of input usage, so
they provide cache observability without inflating total_tokens().
Built-in clients and MockClient set attempts_complete to true; the default
implementation for custom clients sets it to false rather than inventing
provider attempts it cannot observe.
See examples/retry_attempt_ledger.rs for a complete success-and-failure
example.
Error Handling
use ;
match client..await
Streaming
Enable the streaming feature to stream responses as they are generated.
= { = "0.3", = ["streaming"] }
materialize_iter streams a list of structured objects, yielding each item as soon as it is fully generated and validated — the common case where you want a long list without buffering the whole response:
use StreamExt;
use ;
let client = from_env?;
let mut stream = client.;
while let Some = stream.next.await
Streaming uses strict integrity checks by default. A stream can yield validated
items and later report malformed provider data or a truncated response, so the
full collection is authoritative only after the stream drains to None without
an error. OpenAI/Grok must send [DONE], Anthropic must send message_stop, and
Gemini must provide a non-empty finishReason.
For irreversible side effects, stage items until clean completion and handle the machine-readable error kind:
use ;
let mut staged = Vecnew;
while let Some = stream.next.await
commit_inventions.await?;
generate_stream streams raw text deltas:
let mut stream = client.generate_stream;
while let Some = stream.next.await
There is also materialize_stream, which streams a single object as progressive StreamedObject::Partial(json) snapshots followed by a validated Complete(T).
All are available on every provider (OpenAI, Anthropic, Grok, Gemini). See examples/streaming_example.rs.
Streaming terminals are currently text-only. A fluent request with attached
media returns one Unsupported stream error instead of silently dropping the
attachments:
use StreamExt;
use ;
let media = ;
let mut stream = client.with_media.generate_stream;
assert!;
assert!;
Use generate_with_media or materialize_with_media when attachments are
required.
Tool Calling
Enable the tools feature to let the model call your typed Rust functions and feed the results back, looping until it produces a final answer. Tool argument types derive Instructor, so their JSON Schema is generated automatically.
= { = "0.3", = ["tools"] }
use ;
use ;
use json;
let toolbox = new.with;
let client = from_env?;
let answer = client
.with_tools
.system // optional
.run
.await?;
Works with all providers (OpenAI, Anthropic, Grok, Gemini). See examples/tool_calling_example.rs.
Testing (offline)
Enable the mock feature to unit-test code that extracts structured data without any
network or API key. MockClient implements LLMClient, so it drops into any
C: LLMClient slot; scripted responses flow through the real deserialize +
validate() path, so you can test schema/validation failures, not just happy paths.
[]
= { = "0.3", = ["mock"] }
use ;
use ;
// Your code under test is generic over the client:
async
async
Script multiple responses with with_response/with_responses (a FIFO queue), branch
on the request with with_responder, simulate the validation re-ask loop with
with_retries, attach final/default token usage with with_usage, or attach
per-attempt usage with with_response_and_usage. Assert on captured requests via
requests() / last_request(). RequestKind is non-exhaustive, so downstream
matches should include a wildcard arm as new client terminals are added. The mock
feature pulls in only the lightweight path-aware decoder and works without the HTTP
client; streaming and tool-loop mocking light up when the streaming / tools
features are also enabled. See examples/mock_testing_example.rs.
Feature Flags
[]
= { = "0.3", = ["openai", "anthropic", "grok", "gemini"] }
openai,anthropic,grok,gemini— Provider backends (each pulls in the shared HTTP/tokiostack)derive— Derive macro (default)logging— Tracing integrationstreaming— Streaming viagenerate_stream/materialize_iter/materialize_stream(opt-in)tools— Tool/function calling viaToolbox+client.with_tools(..).run(..)(opt-in)mock—MockClientfor offline unit testing (opt-in; see Testing)
All features are on by default. For a schema-only build — generate JSON Schema from your types with no networking, tokio, or reqwest — disable the providers:
[]
= { = "0.3", = false, = ["derive"] }
This keeps the derive macro, SchemaType, the Instructor trait, and the LLMClient trait (so you can implement your own backend) without the async/HTTP dependency tree.
Examples
See examples/ for complete working examples:
For Python Developers
If you're coming from Python and searching for:
- "pydantic rust" or "rust pydantic" — rstructor provides similar schema validation and type safety
- "instructor rust" or "rust instructor" — same structured LLM output extraction pattern
- "structured output rust" or "llm structured output" — exactly what rstructor does
- "type-safe llm rust" — ensures type safety from LLM responses to Rust structs
License
MIT — see LICENSE