Lutum provides two paths for structured output: StructuredTurn<O> (transcript-integrated) and structured_completion (one-shot, no transcript). Both derive a JSON Schema from your output type and decode the model's response into a typed Rust value.

On This Page

Defining the Output Type

The output type O must derive JsonSchema (for schema generation) and Deserialize (for response decoding). Add schemars and serde to your dependencies:

[dependencies]
schemars = { version = "1", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
struct CodeReview {
    /// Overall quality score from 1 (poor) to 10 (excellent).
    score: u8,
    /// List of specific issues found in the code.
    issues: Vec<String>,
    /// Suggested improvements.
    suggestions: Vec<String>,
}

Doc-comments on fields are included in the JSON Schema as field descriptions, which help the model produce well-structured output.

StructuredTurn — Transcript-Integrated

Use Session::structured_turn::<O>(&llm) when the result should participate in the transcript:

use lutum::StructuredTurnOutcome;

session.push_user("Review this Rust function for quality issues:\n\n```rust\nfn add(a: i32, b: i32) -> i32 { a + b }\n```");

let result = session
    .structured_turn::<CodeReview>(&llm)
    .collect()
    .await?;

match result.semantic.clone() {
    StructuredTurnOutcome::Structured(review) => {
        println!("Score: {}/10", review.score);
        for issue in &review.issues {
            println!("  - {issue}");
        }
        session.commit_structured(result);
    }
    StructuredTurnOutcome::Refusal(reason) => {
        eprintln!("Model refused: {reason}");
    }
}

result.semantic is a StructuredTurnOutcome<O>:

Variant Meaning
Structured(O) The model produced valid structured output that decoded successfully
Refusal(String) The model explicitly refused (e.g. the request was inappropriate)

Structured Turn With Tools

StructuredTurn also supports tool use via .tools::<T>(). The outcome variants extend to NeedsTools (tool round) and Finished (structured result):

use lutum::StructuredStepOutcomeWithTools;

loop {
    let outcome = session
        .structured_turn::<CodeReview>(&llm)
        .tools::<MyTools>()
        .collect()
        .await?;

    match outcome {
        StructuredStepOutcomeWithTools::NeedsTools(round) => {
            let results = execute_tools(&round.tool_calls).await;
            round.commit(&mut session, results)?;
        }
        StructuredStepOutcomeWithTools::Finished(result) => {
            match result.semantic.clone() {
                StructuredTurnOutcome::Structured(review) => {
                    println!("Score: {}", review.score);
                    session.commit_structured_with_tools(result);
                }
                StructuredTurnOutcome::Refusal(r) => eprintln!("{r}"),
            }
            break;
        }
    }
}

structured_completion — One-Shot

When you don't need transcript integration — for example, a single extraction call — use Lutum::structured_completion. This requires Lutum::from_parts because the completion adapter is wired separately:

use std::sync::Arc;
use lutum::{Lutum, ModelName, OpenAiAdapter, SharedPoolBudgetManager, SharedPoolBudgetOptions, StructuredTurnOutcome};

let adapter = Arc::new(
    OpenAiAdapter::new(api_key)
        .with_default_model(ModelName::new("gpt-4o-mini")?)
);

// from_parts is required for completion adapters
let llm = Lutum::from_parts(
    adapter.clone(),
    adapter.clone(),
    adapter,
    SharedPoolBudgetManager::new(SharedPoolBudgetOptions::default()),
);

let result = llm
    .structured_completion::<CodeReview>("Review this function: fn add(a: i32, b: i32) -> i32 { a + b }")
    .system("Return only the structured review.")
    .collect()
    .await?;

match result.semantic {
    StructuredTurnOutcome::Structured(review) => println!("Score: {}", review.score),
    StructuredTurnOutcome::Refusal(r) => eprintln!("{r}"),
}
Note

structured_completion does not produce a CommittedTurn and does not integrate with the transcript model. If you need turn history and replay, use Session::structured_turn::<O>(&llm).

Generation Parameters

Both turn and completion builders accept inline generation parameters:

use lutum::Temperature;

let result = session
    .structured_turn::<CodeReview>(&llm)
    .temperature(Temperature::new(0.0)?)   // deterministic output
    .max_output_tokens(1024)
    .seed(42)
    .collect()
    .await?;

Setting temperature(0.0) is recommended for structured extraction tasks where determinism matters more than creative variation.

Dynamic JSON Schema

When the output shape is known only at runtime, override the schema sent to the provider with .output_schema(name, schema). The decoded type is still O; use serde_json::Value when the decoded shape is runtime-defined too:

let schema = serde_json::json!({
    "type": "object",
    "properties": {
        "email": { "type": "string" }
    },
    "required": ["email"],
    "additionalProperties": false
});

let result = llm
    .structured_turn::<serde_json::Value>(
        lutum::ModelInput::new().user("Extract the email address.")
    )
    .output_schema("runtime_contact", schema)
    .collect()
    .await?;

The same .output_schema(name, schema) method is available on structured_completion and on tool-enabled structured turns.