Skip to main content

flashmind_types/
reasoning.rs

1//! Lossless reasoning blocks returned by a provider.
2
3use serde::{Deserialize, Serialize};
4use serde_json::{Map, Value};
5
6/// Native wire format retained alongside an assistant response.
7#[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/// Ordered reasoning blocks, including signatures and opaque provider fields.
18#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(transparent)]
20pub struct ReasoningDetails(Vec<Map<String, Value>>);
21
22impl ReasoningDetails {
23    /// Keep provider blocks without translating or dropping unknown fields.
24    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    /// A provider's complete native content or output item sequence.
33    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    /// Remember the exact field used by a compatible chat endpoint.
53    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    /// Signed and encrypted state must stay attached to its original request prefix.
74    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    /// Assemble streamed fragments without changing the order of logical blocks.
91    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    /// Readable deltas for display. Encrypted blocks remain opaque.
135    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    /// Conservative size estimate, including opaque blocks sent back to the API.
147    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}