Structured Outputs

Get a typed, schema-validated reply instead of free text. The JSON Schema is enforced natively by the provider — not by prompt begging.

#![allow(unused)]
fn main() {
use yoagent::{Agent, provider::ModelConfig};

#[derive(serde::Deserialize)]
struct Invoice {
    vendor: String,
    total_cents: u64,
    line_items: Vec<String>,
}

let mut agent = Agent::from_config(ModelConfig::claude_sonnet_5());

let invoice: Invoice = agent
    .prompt_structured(
        "Extract the invoice from the attached text: ...",
        serde_json::json!({
            "type": "object",
            "properties": {
                "vendor": {"type": "string"},
                "total_cents": {"type": "integer"},
                "line_items": {"type": "array", "items": {"type": "string"}}
            },
            "required": ["vendor", "total_cents", "line_items"],
            "additionalProperties": false
        }),
    )
    .await?;
}

Derive the schema however you like — by hand as above, or with the schemars crate (convert with serde_json::to_value(schemars::schema_for!(Invoice))). Mind the provider dialects: OpenAI strict mode requires additionalProperties: false and every property listed in required; Anthropic's native JSON outputs also require additionalProperties: false on objects and reject numeric/length constraints (minimum, maxLength, …); Gemini rejects $defs/$ref. Schemas are passed through as given.

How each provider enforces it

ProtocolMechanism
Anthropic, native_structured_output on (every ModelConfig::claude_* preset, and ModelConfig::anthropic("claude-…") / opencode_zen("claude-…") ids from Claude 4.5 on)output_config.format: {type: "json_schema", schema} — the reply's text block is constrained to the schema; no tool is forced, thinking stays on, and the key shares output_config with the thinking effort
Anthropic, flag off (Claude ids before 4.5, non-Claude ids behind a Messages-protocol gateway, AnthropicCompat::default())Forced tool call — a synthetic tool is built from your schema and tool_choice forces it; the loop unwraps the call back into text
OpenAI-compatibleresponse_format: {type: "json_schema", strict: true}
Google GeminigenerationConfig.responseSchema + JSON mime type (note: Gemini uses an OpenAPI-style schema dialect — your schema is passed through as given)
OpenAI Responses / Azure / Vertex / BedrockNot yet wired — a warning is logged and the model replies as free text, which still must parse into T

Semantics & caveats

  • prompt_structured runs the loop to completion internally and returns the parsed T — there is no event receiver for this call.

  • Three error shapes: Provider { message } when the API call itself failed (auth, network once retries are exhausted, a schema-induced 400 — retrying the parse is pointless), was cancelled ("Cancelled"), or ended as a refusal or content-filter stop (carrying its explanation); Parse { source, raw } when the model's text didn't deserialize (the raw text is preserved so you can retry or salvage); NoOutput when the run produced no text. Only messages produced by this call are considered — stale output from earlier turns is never parsed.

  • On Anthropic's tool-forcing path the forced tool call preempts regular tools for that request, and disables extended thinking for that request (forced tool choice and thinking are mutually exclusive at the API level — a warning is logged). Treat such structured prompts as extraction/finalization calls, not agentic tool-using turns. The native path has neither restriction: tool choice stays auto, so the model may call your tools first, and the last text block is parsed. The loop's unwrap of the synthetic call runs only on the tool-forcing path, so on the native path a tool of yours that happens to share the schema's name is executed normally.

  • Claude Fable 5.1 and Opus 5.5 reject forced tool_choice (any/tool) with a 400. Their presets (claude_fable_5_1(), claude_opus_5_5()) set AnthropicCompat::native_structured_output, so prompt_structured works on them, and so does a bare ModelConfig::anthropic("claude-opus-5-5", ..), which infers the flag from the id. If you set config.anthropic yourself, start from AnthropicCompat::for_claude_id(id) rather than AnthropicCompat::default(), or the flag is off again and the call gets the 400 as Provider { .. }.

    Anthropic lists native JSON outputs for Fable 5/5.1, Opus 4.5 through 5.5, Sonnet 4.5/4.6/5 and Haiku 4.5 (on Amazon Bedrock, fewer: see the Anthropic structured-outputs page). AnthropicCompat::default() leaves the flag off, and so does ModelConfig::anthropic for a non-Claude id, because gateways that speak the Messages protocol may not accept output_config.format.

  • Markdown code fences around the JSON are stripped defensively before parsing.