1use serde::{Deserialize, Serialize};
4use serde_json::{Map, Value};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::AsRefStr)]
8pub enum NativeReasoningFormat {
9 #[strum(serialize = "anthropic.content")]
10 Anthropic,
11 #[strum(serialize = "responses.output")]
12 Responses,
13 #[strum(serialize = "mistral.content")]
14 Mistral,
15}
16
17#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(transparent)]
20pub struct ReasoningDetails(Vec<Map<String, Value>>);
21
22impl ReasoningDetails {
23 pub fn from_blocks(blocks: impl IntoIterator<Item = Map<String, Value>>) -> Self {
25 Self(blocks.into_iter().collect())
26 }
27
28 pub fn iter(&self) -> impl Iterator<Item = &Map<String, Value>> {
29 self.0.iter()
30 }
31
32 pub fn native(format: NativeReasoningFormat, items: Vec<Value>) -> Self {
34 let kind = format.as_ref();
35 Self(vec![Map::from_iter([
36 ("type".into(), Value::String(kind.into())),
37 ("id".into(), Value::String(kind.into())),
38 ("items".into(), Value::Array(items)),
39 ])])
40 }
41
42 pub fn native_items(&self, format: NativeReasoningFormat) -> Option<&[Value]> {
43 let kind = format.as_ref();
44 self.0
45 .iter()
46 .find(|block| block.get("type").and_then(Value::as_str) == Some(kind))?
47 .get("items")?
48 .as_array()
49 .map(Vec::as_slice)
50 }
51
52 pub fn chat_field(name: &str, text: &str) -> Self {
54 Self(vec![Map::from_iter([
55 ("type".into(), Value::String("chat.reasoning".into())),
56 ("id".into(), Value::String(name.into())),
57 ("name".into(), Value::String(name.into())),
58 ("text".into(), Value::String(text.into())),
59 ])])
60 }
61
62 pub fn chat_fields(&self) -> impl Iterator<Item = (&str, &str)> {
63 self.0.iter().filter_map(|block| {
64 if block.get("type")?.as_str()? != "chat.reasoning" {
65 return None;
66 }
67 let name = block.get("name")?.as_str()?;
68 matches!(name, "reasoning" | "reasoning_content")
69 .then_some((name, block.get("text")?.as_str()?))
70 })
71 }
72
73 pub fn is_context_bound(&self) -> bool {
75 self.native_items(NativeReasoningFormat::Anthropic)
76 .is_some()
77 || self
78 .native_items(NativeReasoningFormat::Responses)
79 .is_some()
80 || self.0.iter().any(|block| {
81 block.get("signature").is_some_and(|value| !value.is_null())
82 || block.get("type").and_then(Value::as_str) == Some("reasoning.encrypted")
83 })
84 }
85
86 pub fn is_empty(&self) -> bool {
87 self.0.is_empty()
88 }
89
90 pub fn append(&mut self, delta: Self) {
92 for fragment in delta.0 {
93 let existing = self.0.iter_mut().rev().find(|block| {
94 let same = |key| match (block.get(key), fragment.get(key)) {
95 (Some(a), Some(b)) if !a.is_null() && !b.is_null() => a == b,
96 _ => true,
97 };
98 let identity = ["id", "index"].iter().any(|key| {
99 block
100 .get(*key)
101 .is_some_and(|value| !value.is_null() && fragment.get(*key) == Some(value))
102 });
103 identity
104 && block.get("type") == fragment.get("type")
105 && same("id")
106 && same("index")
107 && same("format")
108 });
109 if let Some(block) = existing {
110 for (key, value) in fragment {
111 if key == "items"
112 && block.get("type").and_then(Value::as_str) == Some("mistral.content")
113 && let (Some(Value::Array(current)), Value::Array(next)) =
114 (block.get_mut(&key), &value)
115 {
116 current.extend(next.iter().cloned());
117 continue;
118 }
119 if matches!(key.as_str(), "text" | "summary" | "signature" | "data")
120 && let Some(text) = value.as_str()
121 && let Some(Value::String(current)) = block.get_mut(&key)
122 {
123 current.push_str(text);
124 } else if !value.is_null() || !block.contains_key(&key) {
125 block.insert(key, value);
126 }
127 }
128 } else {
129 self.0.push(fragment);
130 }
131 }
132 }
133
134 pub fn text(&self) -> String {
136 self.0
137 .iter()
138 .filter_map(|block| match block.get("type").and_then(Value::as_str) {
139 Some("reasoning.text") => block.get("text").and_then(Value::as_str),
140 Some("reasoning.summary") => block.get("summary").and_then(Value::as_str),
141 _ => None,
142 })
143 .collect()
144 }
145
146 pub fn estimated_bytes(&self) -> usize {
148 self.0.iter().fold(0usize, |size, block| {
149 size.saturating_add(Value::Object(block.clone()).to_string().len())
150 })
151 }
152}
153
154#[cfg(test)]
155mod tests {
156 use serde_json::json;
157
158 use super::*;
159
160 #[test]
161 fn fragments_keep_signatures_unknown_fields_and_block_order() {
162 let mut details = ReasoningDetails::default();
163 for delta in [
164 json!([{"type":"reasoning.text","index":0,"text":"Check ","signature":null,"format":"example-v1"}]),
165 json!([{"type":"reasoning.encrypted","index":0,"id":"rs_example","data":"opaque","extra":{"keep":true}}]),
166 json!([{"type":"reasoning.text","index":0,"text":"facts","signature":"sig-"}]),
167 json!([{"type":"reasoning.text","index":0,"signature":"end"}]),
168 ] {
169 details.append(serde_json::from_value(delta).unwrap());
170 }
171 assert_eq!(
172 serde_json::to_value(&details).unwrap(),
173 json!([
174 {"type":"reasoning.text","index":0,"text":"Check facts","signature":"sig-end","format":"example-v1"},
175 {"type":"reasoning.encrypted","index":0,"id":"rs_example","data":"opaque","extra":{"keep":true}}
176 ])
177 );
178 assert_eq!(details.text(), "Check facts");
179 }
180
181 #[test]
182 fn distinct_ids_and_unidentified_blocks_are_not_combined() {
183 let mut details = ReasoningDetails::default();
184 for id in ["rs_first", "rs_second"] {
185 details.append(
186 serde_json::from_value(json!([
187 {"type":"reasoning.summary","index":0,"id":id,"summary":"summary"},
188 {"type":"reasoning.unknown","payload":"keep"}
189 ]))
190 .unwrap(),
191 );
192 }
193 assert_eq!(
194 serde_json::to_value(details)
195 .unwrap()
196 .as_array()
197 .unwrap()
198 .len(),
199 4
200 );
201 }
202}