fabro/lib/components/fabro-llm/src/reasoning.rs
2026-07-24 17:42:44 -04:00

342 lines
12 KiB
Rust

//! 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<ReasoningOutput> {
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<String> {
(!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<ReasoningOutput> {
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());
}
}