mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-08 22:21:45 +00:00
342 lines
12 KiB
Rust
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());
|
|
}
|
|
}
|