fix(responses): map raw reasoning into content, keep summary for CLI

OpenAI Responses treats reasoning.content as the raw chain-of-thought
and summary as a skim view. Aether was dumping thinking into summary
and leaving content null, which hid the thinking panel in desktop UIs.

Put reasoning_content / equivalent text into reasoning_text content
parts, and copy the same text into summary_text so CLI clients still
work. Stream emitters now send both reasoning_text and summary events.
This commit is contained in:
Kayphoon
2026-09-14 07:51:52 +00:00
parent 60b89cc840
commit c5adcf0312
9 changed files with 431 additions and 182 deletions
@@ -3198,13 +3198,18 @@ fn openai_responses_body(
let response_id = format!("resp_{}", Uuid::new_v4());
let mut output = Vec::new();
if !collected.thinking.trim().is_empty() {
let thinking = collected.thinking.trim();
output.push(json!({
"id": openai_responses_synthetic_reasoning_item_id(&response_id, 0),
"type": "reasoning",
"status": "completed",
"summary": [{
"type": "summary_text",
"text": collected.thinking.trim(),
"text": thinking,
}],
"content": [{
"type": "reasoning_text",
"text": thinking,
}],
}));
}
@@ -4627,6 +4632,18 @@ mod tests {
serde_json::json!(usage.reasoning_tokens)
);
assert_eq!(body["output"][0]["type"], serde_json::json!("reasoning"));
assert_eq!(
body["output"][0]["content"][0]["type"],
serde_json::json!("reasoning_text")
);
assert_eq!(
body["output"][0]["content"][0]["text"],
serde_json::json!("short reasoning")
);
assert_eq!(
body["output"][0]["summary"][0]["text"],
serde_json::json!("short reasoning")
);
assert_eq!(body["output"][1]["type"], serde_json::json!("message"));
assert!(body["output"][1]["id"]
.as_str()
@@ -9,7 +9,8 @@ use serde_json::{json, Value};
use crate::formats::{
context::FormatContext,
openai::responses::{
openai_responses_message_item_id, openai_responses_synthetic_reasoning_item_id,
openai_responses_message_item_id, openai_responses_reasoning_text_fields,
openai_responses_synthetic_reasoning_item_id,
response::ensure_modern_openai_responses_response_fields,
},
registry,
@@ -205,14 +206,13 @@ pub fn build_openai_responses_response_with_content(
if trimmed.is_empty() {
continue;
}
let (content, summary) = openai_responses_reasoning_text_fields(std::iter::once(trimmed));
output.push(json!({
"type": "reasoning",
"id": openai_responses_synthetic_reasoning_item_id(response_id, index),
"status": "completed",
"summary": [{
"type": "summary_text",
"text": trimmed,
}]
"summary": summary,
"content": content,
}));
}
if !content.is_empty() {
@@ -289,6 +289,32 @@ mod tests {
assert!(converted["completed_at"].as_i64().is_some());
}
#[test]
fn manual_responses_response_builder_puts_reasoning_in_content() {
let response = super::build_openai_responses_response_with_reasoning(
"resp_manual_reason",
"gpt-5",
"answer",
vec!["raw thinking".to_string()],
Vec::new(),
super::OpenAiResponsesResponseUsage {
prompt_tokens: 1,
output_tokens: 2,
total_tokens: 3,
},
);
assert_eq!(response["output"][0]["type"], "reasoning");
assert_eq!(
response["output"][0]["content"][0]["type"],
"reasoning_text"
);
assert_eq!(response["output"][0]["content"][0]["text"], "raw thinking");
assert_eq!(response["output"][0]["summary"][0]["type"], "summary_text");
assert_eq!(response["output"][0]["summary"][0]["text"], "raw thinking");
assert_eq!(response["output"][1]["content"][0]["text"], "answer");
}
#[test]
fn manual_responses_response_builder_emits_modern_fields() {
let response = super::build_openai_responses_response(
@@ -306,6 +332,41 @@ mod tests {
assert!(response["completed_at"].as_i64().is_some());
}
#[test]
fn chat_reasoning_content_maps_to_responses_content_and_summary() {
let body = json!({
"id": "chatcmpl-reason",
"object": "chat.completion",
"model": "deepseek-reasoner",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"reasoning_content": "compare the decimals",
"content": "9.80 is larger"
},
"finish_reason": "stop"
}],
"usage": {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3}
});
let converted = convert_openai_chat_response_to_openai_responses(&body, &json!({}), false)
.expect("responses response");
let item = &converted["output"][0];
assert_eq!(item["type"], "reasoning");
assert_eq!(item["content"][0]["type"], "reasoning_text");
assert_eq!(item["content"][0]["text"], "compare the decimals");
assert_eq!(item["summary"][0]["type"], "summary_text");
assert_eq!(item["summary"][0]["text"], "compare the decimals");
assert!(!item.get("content").unwrap().is_null());
assert_eq!(converted["output"][1]["type"], "message");
assert_eq!(
converted["output"][1]["content"][0]["text"],
"9.80 is larger"
);
}
#[test]
fn pairwise_response_helper_uses_report_context_model_fallback() {
let body = json!({
@@ -6,7 +6,7 @@ use sha2::{Digest, Sha256};
use crate::formats::openai::namespace::NamespaceToolAliases;
use crate::formats::openai::responses::{
encode_gemini_tool_signature_carrier_with_direction, openai_responses_message_item_id,
openai_responses_synthetic_reasoning_item_id,
openai_responses_reasoning_text_fields, openai_responses_synthetic_reasoning_item_id,
response::{
ensure_modern_openai_responses_response_fields, openai_responses_current_timestamp,
},
@@ -2621,6 +2621,107 @@ impl OpenAIResponsesClientEmitter {
self.reasoning_summary_parts.len()
}
fn reasoning_texts(&self) -> Vec<String> {
if self.reasoning_summary_parts.is_empty() {
if self.reasoning.trim().is_empty() {
Vec::new()
} else {
vec![self.reasoning.clone()]
}
} else {
self.reasoning_summary_parts.clone()
}
}
fn reasoning_item_value(&self) -> Value {
let (content, summary) = openai_responses_reasoning_text_fields(self.reasoning_texts());
json!({
"type": "reasoning",
"id": self.reasoning_item_id(),
"status": "completed",
"summary": summary,
"content": content,
})
}
fn encode_reasoning_text_delta(
&mut self,
text: &str,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
let item_id = self.reasoning_item_id();
let output_index = self.reasoning_output_index.unwrap_or(0);
let part_index = self.current_reasoning_summary_index();
let mut out = self.encode_response_event(
"response.reasoning_text.delta",
json!({
"type": "response.reasoning_text.delta",
"response_id": self.response_id(),
"item_id": item_id.clone(),
"output_index": output_index,
"content_index": part_index,
"delta": text,
}),
)?;
out.extend(self.encode_response_event(
"response.reasoning_summary_text.delta",
json!({
"type": "response.reasoning_summary_text.delta",
"response_id": self.response_id(),
"item_id": item_id,
"output_index": output_index,
"summary_index": part_index,
"delta": text,
}),
)?);
Ok(out)
}
fn encode_reasoning_text_done_events(
&mut self,
item_id: &str,
output_index: usize,
part_index: usize,
part_text: &str,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
let mut out = self.encode_response_event(
"response.reasoning_text.done",
json!({
"type": "response.reasoning_text.done",
"response_id": self.response_id(),
"item_id": item_id,
"output_index": output_index,
"content_index": part_index,
"text": part_text,
}),
)?;
out.extend(self.encode_response_event(
"response.reasoning_summary_text.done",
json!({
"type": "response.reasoning_summary_text.done",
"response_id": self.response_id(),
"item_id": item_id,
"output_index": output_index,
"summary_index": part_index,
"text": part_text,
}),
)?);
out.extend(self.encode_response_event(
"response.reasoning_summary_part.done",
json!({
"type": "response.reasoning_summary_part.done",
"response_id": self.response_id(),
"item_id": item_id,
"output_index": output_index,
"summary_index": part_index,
"part": {
"type": "summary_text",
"text": part_text,
}
}),
)?);
Ok(out)
}
fn ensure_message_output_index(&mut self) -> usize {
if let Some(output_index) = self.message_output_index {
return output_index;
@@ -2676,6 +2777,7 @@ impl OpenAIResponsesClientEmitter {
"type": "reasoning",
"id": item_id.clone(),
"summary": [],
"content": [],
}
}),
)?);
@@ -2812,66 +2914,23 @@ impl OpenAIResponsesClientEmitter {
if self.reasoning_part_started {
let summary_index = self.current_reasoning_summary_index();
let part_text = self.reasoning_part.clone();
out.extend(self.encode_response_event(
"response.reasoning_summary_text.done",
json!({
"type": "response.reasoning_summary_text.done",
"response_id": self.response_id(),
"item_id": item_id.clone(),
"output_index": output_index,
"summary_index": summary_index,
"text": part_text.as_str(),
}),
)?);
out.extend(self.encode_response_event(
"response.reasoning_summary_part.done",
json!({
"type": "response.reasoning_summary_part.done",
"response_id": self.response_id(),
"item_id": item_id.clone(),
"output_index": output_index,
"summary_index": summary_index,
"part": {
"type": "summary_text",
"text": part_text.as_str(),
}
}),
out.extend(self.encode_reasoning_text_done_events(
&item_id,
output_index,
summary_index,
part_text.as_str(),
)?);
self.reasoning_summary_parts.push(part_text);
self.reasoning_part.clear();
self.reasoning_part_started = false;
}
let summary = if self.reasoning_summary_parts.is_empty() {
if self.reasoning.trim().is_empty() {
Vec::new()
} else {
vec![json!({
"type": "summary_text",
"text": self.reasoning.as_str(),
})]
}
} else {
self.reasoning_summary_parts
.iter()
.map(|text| {
json!({
"type": "summary_text",
"text": text,
})
})
.collect::<Vec<_>>()
};
out.extend(self.encode_response_event(
"response.output_item.done",
json!({
"type": "response.output_item.done",
"response_id": self.response_id(),
"output_index": output_index,
"item": {
"type": "reasoning",
"id": item_id,
"summary": summary,
}
"item": self.reasoning_item_value(),
}),
)?);
Ok(out)
@@ -3035,35 +3094,10 @@ impl OpenAIResponsesClientEmitter {
incomplete_reason: Option<&str>,
) -> Value {
let mut ordered_output = Vec::new();
let summary = if self.reasoning_summary_parts.is_empty() {
if self.reasoning.trim().is_empty() {
Vec::new()
} else {
vec![json!({
"type": "summary_text",
"text": self.reasoning.as_str(),
})]
}
} else {
self.reasoning_summary_parts
.iter()
.map(|text| {
json!({
"type": "summary_text",
"text": text,
})
})
.collect::<Vec<_>>()
};
if !summary.is_empty() {
if !self.reasoning_texts().is_empty() {
ordered_output.push((
self.reasoning_output_index.unwrap_or(0),
json!({
"type": "reasoning",
"id": self.reasoning_item_id(),
"status": "completed",
"summary": summary,
}),
self.reasoning_item_value(),
));
}
if self.text_item_started || !self.text.is_empty() {
@@ -3297,17 +3331,7 @@ impl OpenAIResponsesClientEmitter {
let mut out = self.ensure_reasoning_item_started()?;
self.reasoning.push_str(&text);
self.reasoning_part.push_str(&text);
out.extend(self.encode_response_event(
"response.reasoning_summary_text.delta",
json!({
"type": "response.reasoning_summary_text.delta",
"response_id": self.response_id(),
"item_id": self.reasoning_item_id(),
"output_index": self.reasoning_output_index.unwrap_or(0),
"summary_index": self.current_reasoning_summary_index(),
"delta": text,
}),
)?);
out.extend(self.encode_reasoning_text_delta(&text)?);
Ok(out)
}
CanonicalStreamEvent::ReasoningSummaryDone => {
@@ -3320,32 +3344,12 @@ impl OpenAIResponsesClientEmitter {
let item_id = self.reasoning_item_id();
let summary_index = self.current_reasoning_summary_index();
let part_text = self.reasoning_part.clone();
let mut out = Vec::new();
out.extend(self.encode_response_event(
"response.reasoning_summary_text.done",
json!({
"type": "response.reasoning_summary_text.done",
"response_id": self.response_id(),
"item_id": item_id.clone(),
"output_index": output_index,
"summary_index": summary_index,
"text": part_text.as_str(),
}),
)?);
out.extend(self.encode_response_event(
"response.reasoning_summary_part.done",
json!({
"type": "response.reasoning_summary_part.done",
"response_id": self.response_id(),
"item_id": item_id,
"output_index": output_index,
"summary_index": summary_index,
"part": {
"type": "summary_text",
"text": part_text.as_str(),
}
}),
)?);
let out = self.encode_reasoning_text_done_events(
&item_id,
output_index,
summary_index,
part_text.as_str(),
)?;
self.reasoning_summary_parts.push(part_text);
self.reasoning_part.clear();
self.reasoning_part_started = false;
@@ -6352,13 +6356,19 @@ mod tests {
let sse = String::from_utf8(bytes).expect("sse should be utf8");
assert!(sse.contains("event: response.reasoning_summary_part.added\n"));
assert!(sse.contains("event: response.reasoning_text.delta\n"));
assert!(sse.contains("event: response.reasoning_summary_text.delta\n"));
assert!(sse.contains("event: response.reasoning_text.done\n"));
assert!(sse.contains("event: response.reasoning_summary_text.done\n"));
assert!(sse.contains("event: response.reasoning_summary_part.done\n"));
assert!(sse.contains("\"type\":\"reasoning_text\""));
let reasoning_item_id = openai_responses_synthetic_reasoning_item_id("resp_456", 0);
assert!(sse.contains(&format!("\"item_id\":\"{reasoning_item_id}\"")));
assert!(sse.contains("\"type\":\"reasoning\""));
assert_eq!(response_sequence_numbers(&sse), (1..=9).collect::<Vec<_>>());
assert_eq!(
response_sequence_numbers(&sse),
(1..=11).collect::<Vec<_>>()
);
}
#[test]
@@ -1,5 +1,5 @@
use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine as _};
use serde_json::Value;
use serde_json::{json, Map, Value};
pub mod codex;
pub(crate) mod history;
@@ -119,6 +119,58 @@ pub fn openai_responses_message_item_id(response_id: &str, output_index: usize)
)
}
/// Builds Responses reasoning `content` / `summary` arrays from raw thinking text.
///
/// OpenAI Responses semantics:
/// - `content` holds raw chain-of-thought as `reasoning_text` parts. Desktop UIs
/// (for example Codex) hide the thinking panel when `content` is null.
/// - `summary` holds `summary_text` parts for skim / CLI clients. When the
/// upstream only exposes raw thinking (DeepSeek `reasoning_content`, Gemini
/// thoughts, Claude thinking), the same text is copied into both so neither
/// client family loses the panel.
pub(crate) fn openai_responses_reasoning_text_fields(
texts: impl IntoIterator<Item = impl AsRef<str>>,
) -> (Value, Value) {
let texts: Vec<String> = texts
.into_iter()
.map(|text| text.as_ref().to_string())
.filter(|text| !text.trim().is_empty())
.collect();
let content = texts
.iter()
.map(|text| json!({ "type": "reasoning_text", "text": text }))
.collect::<Vec<_>>();
let summary = texts
.iter()
.map(|text| json!({ "type": "summary_text", "text": text }))
.collect::<Vec<_>>();
(Value::Array(content), Value::Array(summary))
}
/// Writes raw thinking onto a Responses reasoning item without clobbering an
/// existing structured summary or provider-owned content.
pub(crate) fn apply_openai_responses_reasoning_text(item: &mut Map<String, Value>, text: &str) {
if text.trim().is_empty() {
return;
}
let (content, summary) = openai_responses_reasoning_text_fields(std::iter::once(text));
if reasoning_item_field_is_empty(item.get("content")) {
item.insert("content".to_string(), content);
}
if reasoning_item_field_is_empty(item.get("summary")) {
item.insert("summary".to_string(), summary);
}
}
fn reasoning_item_field_is_empty(value: Option<&Value>) -> bool {
match value {
None | Some(Value::Null) => true,
Some(Value::Array(parts)) => parts.is_empty(),
Some(Value::String(text)) => text.trim().is_empty(),
_ => false,
}
}
/// Repairs legacy/non-OpenAI message IDs in a Responses request in place.
///
/// Aether versions before the `msg_` contract emitted IDs such as
@@ -419,6 +471,36 @@ mod tests {
assert_ne!(first, other);
}
#[test]
fn reasoning_text_fields_put_raw_thinking_in_content_and_summary() {
let (content, summary) = super::openai_responses_reasoning_text_fields(["raw chain"]);
assert_eq!(
content,
json!([{ "type": "reasoning_text", "text": "raw chain" }])
);
assert_eq!(
summary,
json!([{ "type": "summary_text", "text": "raw chain" }])
);
let mut item = serde_json::Map::new();
super::apply_openai_responses_reasoning_text(&mut item, "raw chain");
assert_eq!(item["content"], content);
assert_eq!(item["summary"], summary);
item.insert(
"summary".to_string(),
json!([{ "type": "summary_text", "text": "kept" }]),
);
item.insert("content".to_string(), json!([]));
super::apply_openai_responses_reasoning_text(&mut item, "replacement");
assert_eq!(
item["content"],
json!([{ "type": "reasoning_text", "text": "replacement" }])
);
assert_eq!(item["summary"][0]["text"], "kept");
}
#[test]
fn synthetic_message_item_ids_are_stable_and_start_with_msg() {
let first = openai_responses_message_item_id("1c938e58-32a8-4d28-9c34-538d78076895", 0);
@@ -2,7 +2,7 @@ use std::collections::{BTreeMap, VecDeque};
use serde_json::{json, Map, Value};
use super::encode_tool_result_error;
use super::{apply_openai_responses_reasoning_text, encode_tool_result_error};
use crate::{
formats::context::FormatContext,
@@ -702,14 +702,7 @@ fn canonical_thinking_to_responses_reasoning_item(
.unwrap_or_default();
item.remove("item_type");
item.insert("type".to_string(), Value::String("reasoning".to_string()));
if !text.trim().is_empty() {
item.entry("summary".to_string()).or_insert_with(|| {
json!([{
"type": "summary_text",
"text": text,
}])
});
}
apply_openai_responses_reasoning_text(&mut item, text);
if let Some(value) = encrypted_content.filter(|value| !value.is_empty()) {
item.insert(
"encrypted_content".to_string(),
@@ -6,8 +6,9 @@ use std::{
use serde_json::{json, Map, Value};
use super::{
encode_gemini_tool_signature_carrier, encode_tool_result_error,
history::record_converted_response_history, openai_responses_synthetic_reasoning_item_id,
apply_openai_responses_reasoning_text, encode_gemini_tool_signature_carrier,
encode_tool_result_error, history::record_converted_response_history,
openai_responses_synthetic_reasoning_item_id,
};
use crate::{
@@ -217,15 +218,7 @@ pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, compact: bo
Value::String(encrypted_content.clone()),
);
}
if !text.trim().is_empty() {
item.insert(
"summary".to_string(),
Value::Array(vec![json!({
"type": "summary_text",
"text": text,
})]),
);
}
apply_openai_responses_reasoning_text(&mut item, text);
output.push(Value::Object(item));
}
CanonicalContentBlock::ToolUse {
@@ -792,6 +785,66 @@ mod tests {
);
}
#[test]
fn responses_response_builder_puts_raw_thinking_in_content_and_summary() {
let response = CanonicalResponse {
id: "resp_think".to_string(),
model: "deepseek-reasoner".to_string(),
content: vec![
CanonicalContentBlock::Thinking {
text: "first add one to one".to_string(),
signature: None,
encrypted_content: None,
extensions: BTreeMap::new(),
},
CanonicalContentBlock::Text {
text: "2".to_string(),
extensions: BTreeMap::new(),
},
],
outputs: Vec::new(),
stop_reason: Some(CanonicalStopReason::EndTurn),
usage: None,
extensions: BTreeMap::new(),
};
let body = to_raw(&response, &json!({}), false);
let item = &body["output"][0];
assert_eq!(item["type"], "reasoning");
assert_eq!(item["content"][0]["type"], "reasoning_text");
assert_eq!(item["content"][0]["text"], "first add one to one");
assert_eq!(item["summary"][0]["type"], "summary_text");
assert_eq!(item["summary"][0]["text"], "first add one to one");
assert!(!item["content"].is_null());
assert_eq!(body["output"][1]["type"], "message");
assert_eq!(body["output"][1]["content"][0]["text"], "2");
}
#[test]
fn responses_response_parser_prefers_content_over_summary_for_raw_reasoning() {
let body = json!({
"id": "resp_test",
"model": "gpt-5",
"status": "completed",
"output": [{
"type": "reasoning",
"id": "rs_1",
"status": "completed",
"summary": [{"type": "summary_text", "text": "short summary"}],
"content": [{"type": "reasoning_text", "text": "full chain of thought"}]
}]
});
let canonical = from_raw(&body).expect("response should parse");
assert!(matches!(
canonical.content.first(),
Some(CanonicalContentBlock::Thinking { text, .. })
if text == "full chain of thought"
));
}
#[test]
fn responses_response_parser_preserves_encrypted_reasoning_without_summary() {
let body = json!({
@@ -950,6 +950,10 @@ mod tests {
.expect("first Gemini thought chunk should transform");
let sse = String::from_utf8(output).expect("reasoning SSE should be utf8");
assert!(
sse.contains("event: response.reasoning_text.delta\n"),
"{sse}"
);
assert!(
sse.contains("event: response.reasoning_summary_text.delta\n"),
"{sse}"
@@ -9,7 +9,8 @@ use aether_ai_formats::formats::conversion::response::{
};
use aether_ai_formats::formats::openai::responses::response::ensure_modern_openai_responses_response_fields;
use aether_ai_formats::formats::openai::responses::{
openai_responses_message_item_id, openai_responses_synthetic_reasoning_item_id,
openai_responses_message_item_id, openai_responses_reasoning_text_fields,
openai_responses_synthetic_reasoning_item_id,
};
use aether_ai_formats::formats::registry::{convert_response, FormatContext, FormatError};
use aether_ai_formats::{
@@ -3117,19 +3118,27 @@ fn merge_openai_responses_tool_arguments(
}
fn extract_openai_responses_reasoning_text(item: &Map<String, Value>) -> Option<String> {
item.get("summary")
.and_then(Value::as_array)
extract_openai_responses_reasoning_parts(item.get("content"), "reasoning_text")
.or_else(|| extract_openai_responses_reasoning_parts(item.get("summary"), "summary_text"))
}
fn extract_openai_responses_reasoning_parts(
raw: Option<&Value>,
expected_type: &str,
) -> Option<String> {
raw.and_then(Value::as_array)
.into_iter()
.flatten()
.find_map(|part| {
let part = part.as_object()?;
(part.get("type").and_then(Value::as_str) == Some("summary_text")).then(|| {
(part.get("type").and_then(Value::as_str) == Some(expected_type)).then(|| {
part.get("text")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string()
})
})
.filter(|text| !text.is_empty())
}
fn merge_openai_responses_message_item(
@@ -3276,17 +3285,26 @@ fn materialize_openai_responses_reasoning_item(
item.entry("status".to_string())
.or_insert_with(|| Value::String("completed".to_string()));
if !state.summary_text.is_empty() {
item.insert(
"summary".to_string(),
Value::Array(vec![json!({
"type": "summary_text",
"text": state.summary_text,
})]),
);
let (content, summary) = openai_responses_reasoning_text_fields([&state.summary_text]);
if reasoning_item_field_missing_or_empty(item.get("content")) {
item.insert("content".to_string(), content);
}
if reasoning_item_field_missing_or_empty(item.get("summary")) {
item.insert("summary".to_string(), summary);
}
}
Value::Object(item)
}
fn reasoning_item_field_missing_or_empty(value: Option<&Value>) -> bool {
match value {
None | Some(Value::Null) => true,
Some(Value::Array(parts)) => parts.is_empty(),
Some(Value::String(text)) => text.trim().is_empty(),
_ => false,
}
}
fn materialize_openai_responses_tool_item(
output_index: usize,
state: OpenAIResponsesSyncToolState,
@@ -5689,6 +5707,8 @@ mod tests {
openai_responses_synthetic_reasoning_item_id("resp_summary_123", 0)
);
assert_eq!(materialized["summary"][0]["text"], "Need care");
assert_eq!(materialized["content"][0]["type"], "reasoning_text");
assert_eq!(materialized["content"][0]["text"], "Need care");
}
#[test]
@@ -2860,9 +2860,9 @@ fn openai_responses_reasoning_block_from_item(
}
fn openai_responses_reasoning_text(item_object: &Map<String, Value>) -> String {
let mut parts = openai_responses_reasoning_text_parts(item_object.get("summary"));
let mut parts = openai_responses_reasoning_text_parts(item_object.get("content"));
if parts.is_empty() {
parts = openai_responses_reasoning_text_parts(item_object.get("content"));
parts = openai_responses_reasoning_text_parts(item_object.get("summary"));
}
parts.join("\n")
}
@@ -2961,38 +2961,47 @@ pub(crate) fn openai_responses_output_to_canonical(
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
if let Some(summary_items) = item_object.get("summary").and_then(Value::as_array) {
for summary in summary_items {
let Some(summary_object) = summary.as_object() else {
continue;
};
let text = summary_object
.get("text")
.and_then(Value::as_str)
.unwrap_or_default();
if text.trim().is_empty() {
continue;
}
let mut extensions = openai_responses_extensions(
item_object,
&["type", "id", "status", "summary", "encrypted_content"],
);
canonical_extension_object_mut(&mut extensions, "openai")
.insert("omit_reasoning_parts".to_string(), Value::Bool(true));
let extensions = openai_thinking_extensions(extensions);
blocks.push(CanonicalContentBlock::Thinking {
text: text.to_string(),
signature: None,
encrypted_content: encrypted_content.clone(),
extensions,
});
emitted = true;
let mut texts = openai_responses_reasoning_text_parts(item_object.get("content"));
if texts.is_empty() {
texts = openai_responses_reasoning_text_parts(item_object.get("summary"));
}
for text in texts {
if text.trim().is_empty() {
continue;
}
let mut extensions = openai_responses_extensions(
item_object,
&[
"type",
"id",
"status",
"summary",
"content",
"encrypted_content",
],
);
canonical_extension_object_mut(&mut extensions, "openai")
.insert("omit_reasoning_parts".to_string(), Value::Bool(true));
let extensions = openai_thinking_extensions(extensions);
blocks.push(CanonicalContentBlock::Thinking {
text,
signature: None,
encrypted_content: encrypted_content.clone(),
extensions,
});
emitted = true;
}
if !emitted && encrypted_content.is_some() {
let mut extensions = openai_responses_extensions(
item_object,
&["type", "id", "status", "summary", "encrypted_content"],
&[
"type",
"id",
"status",
"summary",
"content",
"encrypted_content",
],
);
canonical_extension_object_mut(&mut extensions, "openai")
.insert("omit_reasoning_parts".to_string(), Value::Bool(true));