//! Normalization of provider reasoning material into [`ReasoningOutput`]. //! //! Every provider that returns readable reasoning does it differently, and //! several return more than one channel at once. This module reduces the //! final response's content parts to the two normalized fields without //! reaching into opaque material (signatures, item IDs, encrypted payloads) //! and without failing a completion it cannot classify. //! //! Parsing is deliberately tolerant: provider payloads are read as //! `serde_json::Value` with optional lookups, so unknown detail variants, //! missing members, extra members, and unexpected member types are ignored //! rather than surfaced as errors. use fabro_types::{ContentPart, ReasoningOutput}; /// Separator between distinct complete reasoning blocks. Fragments of one /// logical block are coalesced by the streaming decoders before they reach /// this module. const BLOCK_SEPARATOR: &str = "\n\n"; /// Readable blocks collected per normalized field. /// /// Explicit blocks come from a channel with documented reasoning semantics. /// Fallback blocks come from flattened provider strings, which aggregators /// commonly duplicate alongside a structured channel. They only fill a trace /// that no explicit trace produced. #[derive(Default)] struct Blocks<'a> { explicit_summary: Vec<&'a str>, explicit_trace: Vec<&'a str>, fallback_trace: Vec<&'a str>, } impl Blocks<'_> { fn into_output(self) -> Option { let summary = join_blocks(&self.explicit_summary); let trace = join_blocks(&self.explicit_trace) .or_else(|| join_blocks(&self.fallback_trace)) .filter(|trace| summary.as_ref() != Some(trace)); match (summary, trace) { (Some(summary), Some(trace)) => Some(ReasoningOutput::new(summary, trace)), (Some(summary), None) => Some(ReasoningOutput::from_summary(summary)), (None, Some(trace)) => Some(ReasoningOutput::from_trace(trace)), (None, None) => None, } } } /// Join retained complete blocks in provider order. Text is never trimmed or /// rewritten. fn join_blocks(blocks: &[&str]) -> Option { (!blocks.is_empty()).then(|| blocks.join(BLOCK_SEPARATOR)) } fn push_block<'a>(blocks: &mut Vec<&'a str>, block: &'a str) { if !block.trim().is_empty() { blocks.push(block); } } /// Read a text-bearing member with the provider's documented semantics. fn readable_member<'a>(entry: &'a serde_json::Value, member: &str) -> Option<&'a str> { entry.get(member).and_then(serde_json::Value::as_str) } /// Extract readable text from an OpenAI Responses `reasoning` output item. /// /// `summary[].text` is the model-authored summary; `content[]` entries typed /// `reasoning_text` are the verbatim trace. `encrypted_content`, `id`, and /// `status` are opaque and ignored. fn collect_openai_reasoning_item<'a>(item: &'a serde_json::Value, blocks: &mut Blocks<'a>) { if let Some(entries) = item.get("summary").and_then(serde_json::Value::as_array) { for entry in entries { if let Some(text) = entry.as_str() { push_block(&mut blocks.explicit_summary, text); } else if let Some(text) = entry.get("text").and_then(serde_json::Value::as_str) { push_block(&mut blocks.explicit_summary, text); } } } if let Some(entries) = item.get("content").and_then(serde_json::Value::as_array) { for entry in entries { let Some(text) = entry.get("text").and_then(serde_json::Value::as_str) else { continue; }; let entry_type = entry .get("type") .and_then(serde_json::Value::as_str) .unwrap_or_default(); if entry_type == "reasoning_text" { push_block(&mut blocks.explicit_trace, text); } } } } /// Extract readable text from OpenAI-compatible `reasoning_details` entries. fn collect_reasoning_details<'a>(details: &'a serde_json::Value, blocks: &mut Blocks<'a>) { let Some(entries) = details.as_array() else { return; }; for entry in entries { let detail_type = entry .get("type") .and_then(serde_json::Value::as_str) .unwrap_or_default(); match detail_type { "reasoning.text" => { if let Some(text) = readable_member(entry, "text") { push_block(&mut blocks.explicit_trace, text); } } "reasoning.summary" => { if let Some(text) = readable_member(entry, "summary") { push_block(&mut blocks.explicit_summary, text); } } _ => {} } } } /// Normalize the content parts of a final response into readable reasoning. /// /// Returns `None` when the response carries no readable reasoning, so an /// event without reasoning keeps its previous serialized shape. pub(crate) fn normalize(content: &[ContentPart]) -> Option { let mut blocks = Blocks::default(); for part in content { match part { ContentPart::Thinking(thinking) if !thinking.redacted => { push_block(&mut blocks.fallback_trace, &thinking.text); } ContentPart::Other { kind, data } if kind == ContentPart::OPENAI_REASONING => { collect_openai_reasoning_item(data, &mut blocks); } ContentPart::Other { kind, data } if kind == ContentPart::OPENAI_COMPAT_REASONING_DETAILS => { collect_reasoning_details(data, &mut blocks); } _ => {} } } blocks.into_output() } #[cfg(test)] mod tests { use fabro_types::ThinkingData; use serde_json::json; use super::*; fn thinking(text: &str) -> ContentPart { ContentPart::Thinking(ThinkingData { text: text.to_string(), signature: None, redacted: false, }) } fn openai_reasoning(item: serde_json::Value) -> ContentPart { ContentPart::Other { kind: ContentPart::OPENAI_REASONING.to_string(), data: item, } } fn reasoning_details(details: serde_json::Value) -> ContentPart { ContentPart::Other { kind: ContentPart::OPENAI_COMPAT_REASONING_DETAILS.to_string(), data: details, } } #[test] fn non_redacted_thinking_becomes_a_trace() { let output = normalize(&[thinking("weighing the options")]).unwrap(); assert!(output.summary().is_none()); assert_eq!(output.trace(), Some("weighing the options")); } #[test] fn redacted_thinking_yields_no_readable_reasoning() { let redacted = ContentPart::Thinking(ThinkingData { text: "AAAAopaque".to_string(), signature: Some("sig".to_string()), redacted: true, }); assert!(normalize(&[redacted]).is_none()); } #[test] fn responses_item_with_summary_and_reasoning_text_produces_both_fields() { let output = normalize(&[openai_reasoning(json!({ "type": "reasoning", "id": "rs_1", "encrypted_content": "gAAAAA", "summary": [{"type": "summary_text", "text": "inspect first"}], "content": [{"type": "reasoning_text", "text": "step one"}], }))]) .unwrap(); assert_eq!(output.summary(), Some("inspect first")); assert_eq!(output.trace(), Some("step one")); } #[test] fn responses_blocks_join_in_provider_order() { let output = normalize(&[openai_reasoning(json!({ "summary": [ {"type": "summary_text", "text": "first"}, {"type": "summary_text", "text": "second"}, ], }))]) .unwrap(); assert_eq!(output.summary(), Some("first\n\nsecond")); } #[test] fn unknown_responses_content_types_remain_opaque() { assert!( normalize(&[openai_reasoning(json!({ "content": [{"type": "reasoning_future", "text": "not classified"}], }))]) .is_none() ); } #[test] fn structured_details_produce_summary_and_trace() { let output = normalize(&[reasoning_details(json!([ {"type": "reasoning.summary", "summary": "checked the parser"}, {"type": "reasoning.text", "text": "read convert.rs", "signature": "sig"}, ]))]) .unwrap(); assert_eq!(output.summary(), Some("checked the parser")); assert_eq!(output.trace(), Some("read convert.rs")); } #[test] fn encrypted_details_are_excluded() { let output = normalize(&[reasoning_details(json!([ {"type": "reasoning.encrypted", "data": "gAAAAAsecret", "format": "openai-responses-v1"}, {"type": "reasoning.summary", "summary": "visible"}, ])),]) .unwrap(); assert_eq!(output.summary(), Some("visible")); assert!(output.trace().is_none()); } #[test] fn encrypted_only_details_produce_no_reasoning() { assert!( normalize(&[reasoning_details(json!([ {"type": "reasoning.encrypted", "data": "gAAAAAsecret"}, ]))]) .is_none() ); } #[test] fn unknown_detail_variants_remain_opaque() { assert!( normalize(&[reasoning_details(json!([ {"type": "reasoning.future", "text": "new channel"}, ]))]) .is_none() ); } #[test] fn malformed_details_are_ignored_without_failing() { assert!(normalize(&[reasoning_details(json!("not-an-array"))]).is_none()); assert!( normalize(&[reasoning_details(json!([ 42, {"type": "reasoning.summary", "summary": 7}, {"no_type": true}, ]))]) .is_none() ); } #[test] fn structured_details_suppress_a_duplicate_flattened_value() { let output = normalize(&[ reasoning_details(json!([ {"type": "reasoning.summary", "summary": "checked the parser"}, ])), thinking("checked the parser"), ]) .unwrap(); assert_eq!(output.summary(), Some("checked the parser")); assert!(output.trace().is_none()); } #[test] fn structured_trace_takes_precedence_over_flattened_trace() { let output = normalize(&[ reasoning_details(json!([{"type": "reasoning.text", "text": "verbatim"}])), thinking("flattened"), ]) .unwrap(); assert!(output.summary().is_none()); assert_eq!(output.trace(), Some("verbatim")); } #[test] fn structured_summary_keeps_a_distinct_flattened_trace() { let output = normalize(&[ reasoning_details(json!([ {"type": "reasoning.summary", "summary": "short summary"}, ])), thinking("full verbatim trace"), ]) .unwrap(); assert_eq!(output.summary(), Some("short summary")); assert_eq!(output.trace(), Some("full verbatim trace")); } #[test] fn whitespace_only_fragments_do_not_create_reasoning() { assert!(normalize(&[thinking(" \n ")]).is_none()); } #[test] fn non_empty_text_is_preserved_verbatim() { let output = normalize(&[thinking(" indented thought\n")]).unwrap(); assert_eq!(output.trace(), Some(" indented thought\n")); } #[test] fn unrelated_content_parts_are_ignored() { let parts = vec![ContentPart::text("answer"), ContentPart::Other { kind: ContentPart::OPENAI_MESSAGE.to_string(), data: json!({"type": "message", "content": [{"text": "answer"}]}), }]; assert!(normalize(&parts).is_none()); } }