Skip to main content

runifold_model/
structured.rs

1use serde::{Deserialize, Serialize, de::DeserializeOwned};
2use thiserror::Error;
3
4use crate::{ContentPart, ModelResponse};
5
6/// Stable category for local structured-output validation failures.
7#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
8#[serde(rename_all = "snake_case")]
9#[non_exhaustive]
10pub enum StructuredOutputErrorKind {
11    /// The response contained no model-visible text.
12    MissingText,
13    /// The provider returned an explicit refusal instead of structured data.
14    Refusal,
15    /// The textual response was not valid for the requested Rust type.
16    InvalidOutput,
17}
18
19/// A local failure while decoding a model response into a Rust type.
20///
21/// This error never includes the complete model output, which may contain
22/// sensitive application data. Line and column are retained for diagnostics.
23#[derive(Clone, Debug, Deserialize, Eq, Error, PartialEq, Serialize)]
24#[error("{kind:?}: {message}")]
25pub struct StructuredOutputError {
26    /// Stable failure category.
27    pub kind: StructuredOutputErrorKind,
28    /// Safe diagnostic message.
29    pub message: String,
30    /// One-based JSON line, when parsing reached textual input.
31    pub line: Option<usize>,
32    /// One-based JSON column, when parsing reached textual input.
33    pub column: Option<usize>,
34}
35
36impl StructuredOutputError {
37    fn new(kind: StructuredOutputErrorKind, message: impl Into<String>) -> Self {
38        Self {
39            kind,
40            message: message.into(),
41            line: None,
42            column: None,
43        }
44    }
45}
46
47impl ModelResponse {
48    /// Decodes ordered textual output into a Rust type.
49    ///
50    /// Reasoning, citations, and opaque provider metadata are deliberately not
51    /// mixed into the JSON body. An explicit refusal fails closed.
52    ///
53    /// # Errors
54    ///
55    /// Returns [`StructuredOutputError`] when text is absent, the model
56    /// refused, or the assembled JSON does not deserialize as `T`.
57    pub fn structured<T>(&self) -> Result<T, StructuredOutputError>
58    where
59        T: DeserializeOwned,
60    {
61        let mut body = String::new();
62        for part in &self.content {
63            match part {
64                ContentPart::Text { text } => body.push_str(text),
65                ContentPart::Refusal { .. } => {
66                    return Err(StructuredOutputError::new(
67                        StructuredOutputErrorKind::Refusal,
68                        "model refused the structured-output request",
69                    ));
70                }
71                _ => {}
72            }
73        }
74
75        if body.trim().is_empty() {
76            return Err(StructuredOutputError::new(
77                StructuredOutputErrorKind::MissingText,
78                "model response contained no structured-output text",
79            ));
80        }
81
82        serde_json::from_str(&body).map_err(|error| StructuredOutputError {
83            kind: StructuredOutputErrorKind::InvalidOutput,
84            message: "structured output did not match the requested Rust type".into(),
85            line: Some(error.line()),
86            column: Some(error.column()),
87        })
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use std::collections::BTreeMap;
94
95    use serde::Deserialize;
96
97    use crate::{
98        ContentPart, FinishReason, ModelRef, ModelResponse, ModelUsage, ReasoningPart,
99        StructuredOutputErrorKind,
100    };
101
102    #[derive(Debug, Deserialize, Eq, PartialEq)]
103    struct Answer {
104        value: u32,
105    }
106
107    fn response(content: Vec<ContentPart>) -> ModelResponse {
108        ModelResponse {
109            id: None,
110            model: ModelRef::new("test", "model"),
111            content,
112            finish_reason: FinishReason::Stop,
113            usage: ModelUsage::default(),
114            warnings: Vec::new(),
115            provider_metadata: BTreeMap::new(),
116            provider_events: Vec::new(),
117        }
118    }
119
120    #[test]
121    fn decodes_text_blocks_without_mixing_reasoning() {
122        let response = response(vec![
123            ContentPart::Reasoning(ReasoningPart {
124                text: Some("not JSON".into()),
125                signature: None,
126                redacted: false,
127                provider_data: Vec::new(),
128            }),
129            ContentPart::text("{\"value\":"),
130            ContentPart::text("42}"),
131        ]);
132
133        assert_eq!(
134            response.structured::<Answer>().unwrap(),
135            Answer { value: 42 }
136        );
137    }
138
139    #[test]
140    fn refusal_fails_closed_even_when_text_is_present() {
141        let response = response(vec![
142            ContentPart::text("{\"value\":42}"),
143            ContentPart::Refusal {
144                text: "cannot comply".into(),
145            },
146        ]);
147
148        let error = response.structured::<Answer>().unwrap_err();
149        assert_eq!(error.kind, StructuredOutputErrorKind::Refusal);
150    }
151
152    #[test]
153    fn type_mismatch_has_safe_location_metadata() {
154        let response = response(vec![ContentPart::text("{\"value\":\"wrong\"}")]);
155
156        let error = response.structured::<Answer>().unwrap_err();
157        assert_eq!(error.kind, StructuredOutputErrorKind::InvalidOutput);
158        assert_eq!(error.line, Some(1));
159        assert!(error.column.is_some());
160    }
161}