runifold_model/
structured.rs1use serde::{Deserialize, Serialize, de::DeserializeOwned};
2use thiserror::Error;
3
4use crate::{ContentPart, ModelResponse};
5
6#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
8#[serde(rename_all = "snake_case")]
9#[non_exhaustive]
10pub enum StructuredOutputErrorKind {
11 MissingText,
13 Refusal,
15 InvalidOutput,
17}
18
19#[derive(Clone, Debug, Deserialize, Eq, Error, PartialEq, Serialize)]
24#[error("{kind:?}: {message}")]
25pub struct StructuredOutputError {
26 pub kind: StructuredOutputErrorKind,
28 pub message: String,
30 pub line: Option<usize>,
32 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 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}