Merge pull request #835 from Kayphoon/fix/responses-reasoning-content-only

fix(responses): keep raw reasoning on content only
This commit is contained in:
ZheFox
2026-09-19 20:43:09 +08:00
committed by GitHub
9 changed files with 193 additions and 180 deletions
@@ -3203,10 +3203,7 @@ fn openai_responses_body(
"id": openai_responses_synthetic_reasoning_item_id(&response_id, 0), "id": openai_responses_synthetic_reasoning_item_id(&response_id, 0),
"type": "reasoning", "type": "reasoning",
"status": "completed", "status": "completed",
"summary": [{ "summary": [],
"type": "summary_text",
"text": thinking,
}],
"content": [{ "content": [{
"type": "reasoning_text", "type": "reasoning_text",
"text": thinking, "text": thinking,
@@ -4640,10 +4637,7 @@ mod tests {
body["output"][0]["content"][0]["text"], body["output"][0]["content"][0]["text"],
serde_json::json!("short reasoning") serde_json::json!("short reasoning")
); );
assert_eq!( assert_eq!(body["output"][0]["summary"], serde_json::json!([]));
body["output"][0]["summary"][0]["text"],
serde_json::json!("short reasoning")
);
assert_eq!(body["output"][1]["type"], serde_json::json!("message")); assert_eq!(body["output"][1]["type"], serde_json::json!("message"));
assert!(body["output"][1]["id"] assert!(body["output"][1]["id"]
.as_str() .as_str()
@@ -4827,7 +4821,12 @@ mod tests {
assert!(body.contains("event: response.created")); assert!(body.contains("event: response.created"));
assert!(body.contains("event: response.in_progress")); assert!(body.contains("event: response.in_progress"));
assert!(body.contains("event: response.reasoning_summary_part.added")); // Thinking must stay off the summary channel or clients that render
// both (Codex) print the raw chain-of-thought twice.
assert!(!body.contains("event: response.reasoning_summary_part.added"));
assert!(!body.contains("event: response.reasoning_summary_text.delta"));
assert!(!body.contains("event: response.reasoning_summary_text.done"));
assert!(body.contains("\"type\":\"reasoning_text\""));
assert!(body.contains("event: response.content_part.added")); assert!(body.contains("event: response.content_part.added"));
assert!(body.contains("event: response.output_text.done")); assert!(body.contains("event: response.output_text.done"));
assert!(body.contains("event: response.completed")); assert!(body.contains("event: response.completed"));
@@ -11880,7 +11880,11 @@ mod tests {
.expect("response body should read"); .expect("response body should read");
let body = String::from_utf8(body.to_vec()).expect("response body should be utf8"); let body = String::from_utf8(body.to_vec()).expect("response body should be utf8");
assert!( assert!(
body.contains("event: response.reasoning_summary_text.delta\n"), body.contains("event: response.reasoning_text.delta\n"),
"{body}"
);
assert!(
!body.contains("event: response.reasoning_summary_text.delta\n"),
"{body}" "{body}"
); );
assert!( assert!(
@@ -9,7 +9,7 @@ use serde_json::{json, Value};
use crate::formats::{ use crate::formats::{
context::FormatContext, context::FormatContext,
openai::responses::{ openai::responses::{
openai_responses_message_item_id, openai_responses_reasoning_text_fields, openai_responses_message_item_id, openai_responses_reasoning_text_parts,
openai_responses_synthetic_reasoning_item_id, openai_responses_synthetic_reasoning_item_id,
response::ensure_modern_openai_responses_response_fields, response::ensure_modern_openai_responses_response_fields,
}, },
@@ -206,12 +206,12 @@ pub fn build_openai_responses_response_with_content(
if trimmed.is_empty() { if trimmed.is_empty() {
continue; continue;
} }
let (content, summary) = openai_responses_reasoning_text_fields(std::iter::once(trimmed)); let content = openai_responses_reasoning_text_parts(std::iter::once(trimmed));
output.push(json!({ output.push(json!({
"type": "reasoning", "type": "reasoning",
"id": openai_responses_synthetic_reasoning_item_id(response_id, index), "id": openai_responses_synthetic_reasoning_item_id(response_id, index),
"status": "completed", "status": "completed",
"summary": summary, "summary": [],
"content": content, "content": content,
})); }));
} }
@@ -310,8 +310,7 @@ mod tests {
"reasoning_text" "reasoning_text"
); );
assert_eq!(response["output"][0]["content"][0]["text"], "raw thinking"); 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"], json!([]));
assert_eq!(response["output"][0]["summary"][0]["text"], "raw thinking");
assert_eq!(response["output"][1]["content"][0]["text"], "answer"); assert_eq!(response["output"][1]["content"][0]["text"], "answer");
} }
@@ -357,8 +356,7 @@ mod tests {
assert_eq!(item["type"], "reasoning"); assert_eq!(item["type"], "reasoning");
assert_eq!(item["content"][0]["type"], "reasoning_text"); assert_eq!(item["content"][0]["type"], "reasoning_text");
assert_eq!(item["content"][0]["text"], "compare the decimals"); assert_eq!(item["content"][0]["text"], "compare the decimals");
assert_eq!(item["summary"][0]["type"], "summary_text"); assert_eq!(item["summary"], json!([]));
assert_eq!(item["summary"][0]["text"], "compare the decimals");
assert!(!item.get("content").unwrap().is_null()); assert!(!item.get("content").unwrap().is_null());
assert_eq!(converted["output"][1]["type"], "message"); assert_eq!(converted["output"][1]["type"], "message");
assert_eq!( assert_eq!(
@@ -7,7 +7,7 @@ use crate::formats::openai::chat::response::openai_chat_reasoning_texts;
use crate::formats::openai::namespace::NamespaceToolAliases; use crate::formats::openai::namespace::NamespaceToolAliases;
use crate::formats::openai::responses::{ use crate::formats::openai::responses::{
encode_gemini_tool_signature_carrier_with_direction, openai_responses_message_item_id, encode_gemini_tool_signature_carrier_with_direction, openai_responses_message_item_id,
openai_responses_reasoning_text_fields, openai_responses_synthetic_reasoning_item_id, openai_responses_reasoning_text_parts, openai_responses_synthetic_reasoning_item_id,
response::{ response::{
ensure_modern_openai_responses_response_fields, openai_responses_current_timestamp, ensure_modern_openai_responses_response_fields, openai_responses_current_timestamp,
}, },
@@ -1213,40 +1213,13 @@ impl OpenAIResponsesProviderState {
if item.get("type").and_then(Value::as_str) != Some("reasoning") { if item.get("type").and_then(Value::as_str) != Some("reasoning") {
return; return;
} }
let completed_reasoning = reasoning_item_text(item);
if self.terminal_only { if self.terminal_only {
if item if !completed_reasoning.is_empty() {
.get("summary")
.and_then(Value::as_array)
.is_some_and(|summary| {
summary.iter().any(|part| {
part.get("type").and_then(Value::as_str) == Some("summary_text")
&& part
.get("text")
.and_then(Value::as_str)
.is_some_and(|text| !text.is_empty())
})
})
{
self.ensure_started(report_context, out); self.ensure_started(report_context, out);
} }
return; return;
} }
let mut completed_reasoning = String::new();
for raw_summary in item
.get("summary")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
let Some(summary) = raw_summary.as_object() else {
continue;
};
if summary.get("type").and_then(Value::as_str) == Some("summary_text") {
if let Some(text) = summary.get("text").and_then(Value::as_str) {
completed_reasoning.push_str(text);
}
}
}
if !completed_reasoning.is_empty() { if !completed_reasoning.is_empty() {
self.emit_missing_reasoning(report_context, out, &completed_reasoning); self.emit_missing_reasoning(report_context, out, &completed_reasoning);
} }
@@ -1663,7 +1636,8 @@ impl OpenAIResponsesProviderState {
.unwrap_or_default(); .unwrap_or_default();
if !piece.is_empty() { if !piece.is_empty() {
let summary_index = value let summary_index = value
.get("summary_index") .get("content_index")
.or_else(|| value.get("summary_index"))
.and_then(Value::as_u64) .and_then(Value::as_u64)
.map(|value| value as usize) .map(|value| value as usize)
.unwrap_or(0); .unwrap_or(0);
@@ -1698,7 +1672,8 @@ impl OpenAIResponsesProviderState {
.unwrap_or_default(); .unwrap_or_default();
if !text.is_empty() { if !text.is_empty() {
let summary_index = value let summary_index = value
.get("summary_index") .get("content_index")
.or_else(|| value.get("summary_index"))
.and_then(Value::as_u64) .and_then(Value::as_u64)
.map(|value| value as usize) .map(|value| value as usize)
.unwrap_or(0); .unwrap_or(0);
@@ -2095,6 +2070,29 @@ impl OpenAIResponsesProviderState {
} }
} }
/// Reads a Responses reasoning item's raw chain-of-thought.
///
/// Raw thinking lives on `content` (`reasoning_text` parts); `summary` is the
/// summarised view and is only consulted when `content` carries nothing, so
/// items produced by other Aether versions still yield their thinking.
fn reasoning_item_text(item: &Map<String, Value>) -> String {
let mut text = reasoning_item_parts_text(item.get("content"), "reasoning_text");
if text.is_empty() {
text = reasoning_item_parts_text(item.get("summary"), "summary_text");
}
text
}
fn reasoning_item_parts_text(raw: Option<&Value>, expected_type: &str) -> String {
raw.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_object)
.filter(|part| part.get("type").and_then(Value::as_str) == Some(expected_type))
.filter_map(|part| part.get("text").and_then(Value::as_str))
.collect::<String>()
}
#[derive(Default)] #[derive(Default)]
pub struct OpenAIChatClientEmitter { pub struct OpenAIChatClientEmitter {
response_id: Option<String>, response_id: Option<String>,
@@ -2677,12 +2675,14 @@ impl OpenAIResponsesClientEmitter {
} }
fn reasoning_item_value(&self) -> Value { fn reasoning_item_value(&self) -> Value {
let (content, summary) = openai_responses_reasoning_text_fields(self.reasoning_texts()); let content = openai_responses_reasoning_text_parts(self.reasoning_texts());
json!({ json!({
"type": "reasoning", "type": "reasoning",
"id": self.reasoning_item_id(), "id": self.reasoning_item_id(),
"status": "completed", "status": "completed",
"summary": summary, // Raw thinking goes on `content` only. Mirroring it onto `summary`
// makes Codex (which renders both channels) print it twice.
"summary": [],
"content": content, "content": content,
}) })
} }
@@ -2694,29 +2694,17 @@ impl OpenAIResponsesClientEmitter {
let item_id = self.reasoning_item_id(); let item_id = self.reasoning_item_id();
let output_index = self.reasoning_output_index.unwrap_or(0); let output_index = self.reasoning_output_index.unwrap_or(0);
let part_index = self.current_reasoning_summary_index(); let part_index = self.current_reasoning_summary_index();
let mut out = self.encode_response_event( self.encode_response_event(
"response.reasoning_text.delta", "response.reasoning_text.delta",
json!({ json!({
"type": "response.reasoning_text.delta", "type": "response.reasoning_text.delta",
"response_id": self.response_id(), "response_id": self.response_id(),
"item_id": item_id.clone(), "item_id": item_id,
"output_index": output_index, "output_index": output_index,
"content_index": part_index, "content_index": part_index,
"delta": text, "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( fn encode_reasoning_text_done_events(
@@ -2726,7 +2714,7 @@ impl OpenAIResponsesClientEmitter {
part_index: usize, part_index: usize,
part_text: &str, part_text: &str,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> { ) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
let mut out = self.encode_response_event( self.encode_response_event(
"response.reasoning_text.done", "response.reasoning_text.done",
json!({ json!({
"type": "response.reasoning_text.done", "type": "response.reasoning_text.done",
@@ -2736,33 +2724,7 @@ impl OpenAIResponsesClientEmitter {
"content_index": part_index, "content_index": part_index,
"text": part_text, "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 { fn ensure_message_output_index(&mut self) -> usize {
@@ -2827,21 +2789,6 @@ impl OpenAIResponsesClientEmitter {
self.reasoning_item_started = true; self.reasoning_item_started = true;
} }
if !self.reasoning_part_started { if !self.reasoning_part_started {
let summary_index = self.current_reasoning_summary_index();
out.extend(self.encode_response_event(
"response.reasoning_summary_part.added",
json!({
"type": "response.reasoning_summary_part.added",
"response_id": self.response_id(),
"item_id": item_id,
"output_index": output_index,
"summary_index": summary_index,
"part": {
"type": "summary_text",
"text": "",
}
}),
)?);
self.reasoning_part_started = true; self.reasoning_part_started = true;
} }
Ok(out) Ok(out)
@@ -4291,7 +4238,7 @@ mod tests {
data = Some(value); data = Some(value);
} }
} }
if event_name != Some("response.reasoning_summary_text.done") { if event_name != Some("response.reasoning_text.done") {
continue; continue;
} }
let Some(data) = data else { let Some(data) = data else {
@@ -4300,13 +4247,17 @@ mod tests {
let Ok(value) = serde_json::from_str::<Value>(data) else { let Ok(value) = serde_json::from_str::<Value>(data) else {
continue; continue;
}; };
let Some(summary_index) = value.get("summary_index").and_then(Value::as_u64) else { let Some(part_index) = value
.get("content_index")
.or_else(|| value.get("summary_index"))
.and_then(Value::as_u64)
else {
continue; continue;
}; };
let Some(text) = value.get("text").and_then(Value::as_str) else { let Some(text) = value.get("text").and_then(Value::as_str) else {
continue; continue;
}; };
parts.push((summary_index, text.to_string())); parts.push((part_index, text.to_string()));
} }
parts parts
} }
@@ -4527,7 +4478,8 @@ mod tests {
} }
let sse = String::from_utf8(bytes).expect("sse should be utf8"); let sse = String::from_utf8(bytes).expect("sse should be utf8");
assert!(sse.contains("event: response.reasoning_summary_text.delta\n")); assert!(sse.contains("event: response.reasoning_text.delta\n"));
assert!(!sse.contains("event: response.reasoning_summary_text.delta\n"));
assert!(sse.contains("\"delta\":\"Let\"")); assert!(sse.contains("\"delta\":\"Let\""));
assert!(sse.contains("\"delta\":\" me think.\"")); assert!(sse.contains("\"delta\":\" me think.\""));
} }
@@ -6596,20 +6548,19 @@ mod tests {
); );
let sse = String::from_utf8(bytes).expect("sse should be utf8"); 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_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_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\"")); assert!(sse.contains("\"type\":\"reasoning_text\""));
// Raw chain-of-thought must not be duplicated onto the summary channel:
// Codex renders both, so emitting both makes the thinking panel repeat.
assert!(!sse.contains("event: response.reasoning_summary_text.delta\n"));
assert!(!sse.contains("event: response.reasoning_summary_text.done\n"));
assert!(!sse.contains("event: response.reasoning_summary_part.added\n"));
assert!(!sse.contains("event: response.reasoning_summary_part.done\n"));
let reasoning_item_id = openai_responses_synthetic_reasoning_item_id("resp_456", 0); 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(&format!("\"item_id\":\"{reasoning_item_id}\"")));
assert!(sse.contains("\"type\":\"reasoning\"")); assert!(sse.contains("\"type\":\"reasoning\""));
assert_eq!( assert_eq!(response_sequence_numbers(&sse), (1..=7).collect::<Vec<_>>());
response_sequence_numbers(&sse),
(1..=11).collect::<Vec<_>>()
);
} }
#[test] #[test]
@@ -6678,6 +6629,75 @@ mod tests {
); );
} }
/// Regression: raw thinking must reach the client exactly once.
///
/// Codex renders both the `content` (`reasoning_text`) and `summary`
/// (`summary_text`) channels, so emitting the same chain-of-thought on both
/// made its thinking panel print every line twice.
#[test]
fn openai_responses_client_emitter_sends_raw_thinking_once() {
let mut emitter = OpenAIResponsesClientEmitter::default();
let mut bytes = emitter
.emit(CanonicalStreamFrame {
id: "resp_once".to_string(),
model: "gpt-5.4".to_string(),
event: CanonicalStreamEvent::Start,
})
.expect("start should encode");
for text in ["Let", " me", " think."] {
bytes.extend(
emitter
.emit(CanonicalStreamFrame {
id: "resp_once".to_string(),
model: "gpt-5.4".to_string(),
event: CanonicalStreamEvent::ReasoningDelta(text.to_string()),
})
.expect("reasoning delta should encode"),
);
}
bytes.extend(
emitter
.emit(CanonicalStreamFrame {
id: "resp_once".to_string(),
model: "gpt-5.4".to_string(),
event: CanonicalStreamEvent::ReasoningSummaryDone,
})
.expect("reasoning boundary should encode"),
);
bytes.extend(
emitter
.emit(CanonicalStreamFrame {
id: "resp_once".to_string(),
model: "gpt-5.4".to_string(),
event: CanonicalStreamEvent::Finish {
finish_reason: Some("stop".to_string()),
usage: None,
},
})
.expect("finish should encode"),
);
let sse = String::from_utf8(bytes).expect("sse should be utf8");
// Each thinking chunk is streamed on exactly one channel. The same delta
// used to be mirrored onto `reasoning_summary_text.delta`, so clients that
// render both channels (Codex) printed every chunk twice.
assert_eq!(
sse.matches("event: response.reasoning_text.delta\n")
.count(),
3,
"one delta event per thinking chunk: {sse}"
);
assert!(!sse.contains("event: response.reasoning_summary_text.delta\n"));
assert!(!sse.contains("event: response.reasoning_summary_text.done\n"));
assert!(!sse.contains("\"type\":\"summary_text\""));
// The completed item carries the thinking on `content`, not `summary`.
assert!(
sse.contains("\"content\":[{\"type\":\"reasoning_text\",\"text\":\"Let me think.\"}]"),
"{sse}"
);
assert!(sse.contains("\"summary\":[]"), "{sse}");
}
#[test] #[test]
fn openai_responses_client_emitter_emits_failed_event_with_sequence_number() { fn openai_responses_client_emitter_emits_failed_event_with_sequence_number() {
let mut emitter = OpenAIResponsesClientEmitter::default(); let mut emitter = OpenAIResponsesClientEmitter::default();
@@ -126,47 +126,39 @@ pub fn openai_responses_message_item_id(response_id: &str, output_index: usize)
) )
} }
/// Builds Responses reasoning `content` / `summary` arrays from raw thinking text. /// Builds the Responses reasoning `content` array from raw thinking text.
/// ///
/// OpenAI Responses semantics: /// Raw chain-of-thought belongs in `content` as `reasoning_text` parts. It is
/// - `content` holds raw chain-of-thought as `reasoning_text` parts. Desktop UIs /// deliberately *not* mirrored into `summary`: OpenAI keeps the two channels
/// (for example Codex) hide the thinking panel when `content` is null. /// distinct, and clients such as Codex render both, so duplicating the same
/// - `summary` holds `summary_text` parts for skim / CLI clients. When the /// text onto `summary` made the thinking panel print everything twice.
/// upstream only exposes raw thinking (DeepSeek `reasoning_content`, Gemini pub(crate) fn openai_responses_reasoning_text_parts(
/// 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>>, texts: impl IntoIterator<Item = impl AsRef<str>>,
) -> (Value, Value) { ) -> Value {
let texts: Vec<String> = texts Value::Array(
.into_iter() texts
.map(|text| text.as_ref().to_string()) .into_iter()
.filter(|text| !text.trim().is_empty()) .map(|text| text.as_ref().to_string())
.collect(); .filter(|text| !text.trim().is_empty())
let content = texts .map(|text| json!({ "type": "reasoning_text", "text": text }))
.iter() .collect(),
.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 /// Writes raw thinking onto a Responses reasoning item without clobbering an
/// existing structured summary or provider-owned content. /// existing provider-owned summary or content.
pub(crate) fn apply_openai_responses_reasoning_text(item: &mut Map<String, Value>, text: &str) { pub(crate) fn apply_openai_responses_reasoning_text(item: &mut Map<String, Value>, text: &str) {
if text.trim().is_empty() { if text.trim().is_empty() {
return; return;
} }
let (content, summary) = openai_responses_reasoning_text_fields(std::iter::once(text));
if reasoning_item_field_is_empty(item.get("content")) { if reasoning_item_field_is_empty(item.get("content")) {
let content = openai_responses_reasoning_text_parts(std::iter::once(text));
item.insert("content".to_string(), content); item.insert("content".to_string(), content);
} }
if reasoning_item_field_is_empty(item.get("summary")) { // `summary` stays a valid (empty) array so the item keeps its documented
item.insert("summary".to_string(), summary); // shape; a provider-supplied summary is preserved as-is.
} item.entry("summary".to_string())
.or_insert_with(|| Value::Array(Vec::new()));
} }
fn reasoning_item_field_is_empty(value: Option<&Value>) -> bool { fn reasoning_item_field_is_empty(value: Option<&Value>) -> bool {
@@ -540,21 +532,18 @@ mod tests {
} }
#[test] #[test]
fn reasoning_text_fields_put_raw_thinking_in_content_and_summary() { fn reasoning_text_parts_put_raw_thinking_in_content_only() {
let (content, summary) = super::openai_responses_reasoning_text_fields(["raw chain"]); let content = super::openai_responses_reasoning_text_parts(["raw chain"]);
assert_eq!( assert_eq!(
content, content,
json!([{ "type": "reasoning_text", "text": "raw chain" }]) json!([{ "type": "reasoning_text", "text": "raw chain" }])
); );
assert_eq!(
summary,
json!([{ "type": "summary_text", "text": "raw chain" }])
);
let mut item = serde_json::Map::new(); let mut item = serde_json::Map::new();
super::apply_openai_responses_reasoning_text(&mut item, "raw chain"); super::apply_openai_responses_reasoning_text(&mut item, "raw chain");
assert_eq!(item["content"], content); assert_eq!(item["content"], content);
assert_eq!(item["summary"], summary); // Never mirrored onto `summary`: clients rendering both would repeat it.
assert_eq!(item["summary"], json!([]));
item.insert( item.insert(
"summary".to_string(), "summary".to_string(),
@@ -786,7 +786,7 @@ mod tests {
} }
#[test] #[test]
fn responses_response_builder_puts_raw_thinking_in_content_and_summary() { fn responses_response_builder_puts_raw_thinking_in_content_only() {
let response = CanonicalResponse { let response = CanonicalResponse {
id: "resp_think".to_string(), id: "resp_think".to_string(),
model: "deepseek-reasoner".to_string(), model: "deepseek-reasoner".to_string(),
@@ -814,8 +814,7 @@ mod tests {
assert_eq!(item["type"], "reasoning"); assert_eq!(item["type"], "reasoning");
assert_eq!(item["content"][0]["type"], "reasoning_text"); assert_eq!(item["content"][0]["type"], "reasoning_text");
assert_eq!(item["content"][0]["text"], "first add one to one"); assert_eq!(item["content"][0]["text"], "first add one to one");
assert_eq!(item["summary"][0]["type"], "summary_text"); assert_eq!(item["summary"], json!([]));
assert_eq!(item["summary"][0]["text"], "first add one to one");
assert!(!item["content"].is_null()); assert!(!item["content"].is_null());
assert_eq!(body["output"][1]["type"], "message"); assert_eq!(body["output"][1]["type"], "message");
assert_eq!(body["output"][1]["content"][0]["text"], "2"); assert_eq!(body["output"][1]["content"][0]["text"], "2");
@@ -1053,7 +1053,7 @@ mod tests {
"{sse}" "{sse}"
); );
assert!( assert!(
sse.contains("event: response.reasoning_summary_text.delta\n"), !sse.contains("event: response.reasoning_summary_text.delta\n"),
"{sse}" "{sse}"
); );
assert!(sse.contains("\"delta\":\"checking\""), "{sse}"); assert!(sse.contains("\"delta\":\"checking\""), "{sse}");
@@ -9,7 +9,7 @@ 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::response::ensure_modern_openai_responses_response_fields;
use aether_ai_formats::formats::openai::responses::{ use aether_ai_formats::formats::openai::responses::{
openai_responses_message_item_id, openai_responses_reasoning_text_fields, openai_responses_message_item_id, openai_responses_reasoning_text_parts,
openai_responses_synthetic_reasoning_item_id, openai_responses_synthetic_reasoning_item_id,
}; };
use aether_ai_formats::formats::registry::{convert_response, FormatContext, FormatError}; use aether_ai_formats::formats::registry::{convert_response, FormatContext, FormatError};
@@ -2471,7 +2471,7 @@ fn aggregate_openai_responses_stream_sync_response_from_validated_terminal(
reasoning_states reasoning_states
.entry(output_index) .entry(output_index)
.or_default() .or_default()
.summary_text .reasoning_text
.push_str(delta); .push_str(delta);
} }
"response.reasoning_text.done" | "response.reasoning_summary_text.done" => { "response.reasoning_text.done" | "response.reasoning_summary_text.done" => {
@@ -2799,7 +2799,7 @@ struct OpenAIResponsesSyncMessageState {
#[derive(Default)] #[derive(Default)]
struct OpenAIResponsesSyncReasoningState { struct OpenAIResponsesSyncReasoningState {
item: Map<String, Value>, item: Map<String, Value>,
summary_text: String, reasoning_text: String,
} }
#[derive(Default)] #[derive(Default)]
@@ -3113,8 +3113,8 @@ fn merge_openai_responses_reasoning_text(
if text.is_empty() { if text.is_empty() {
return; return;
} }
if state.summary_text.is_empty() || text.len() >= state.summary_text.len() { if state.reasoning_text.is_empty() || text.len() >= state.reasoning_text.len() {
state.summary_text = text.to_string(); state.reasoning_text = text.to_string();
} }
} }
@@ -3297,15 +3297,16 @@ fn materialize_openai_responses_reasoning_item(
}); });
item.entry("status".to_string()) item.entry("status".to_string())
.or_insert_with(|| Value::String("completed".to_string())); .or_insert_with(|| Value::String("completed".to_string()));
if !state.summary_text.is_empty() { if !state.reasoning_text.is_empty()
let (content, summary) = openai_responses_reasoning_text_fields([&state.summary_text]); && reasoning_item_field_missing_or_empty(item.get("content"))
if reasoning_item_field_missing_or_empty(item.get("content")) { {
item.insert("content".to_string(), content); let content = openai_responses_reasoning_text_parts([&state.reasoning_text]);
} item.insert("content".to_string(), content);
if reasoning_item_field_missing_or_empty(item.get("summary")) {
item.insert("summary".to_string(), summary);
}
} }
// Raw chain-of-thought lives on `content` only; never mirror it onto
// `summary`, or clients that render both channels show it twice.
item.entry("summary".to_string())
.or_insert_with(|| Value::Array(Vec::new()));
Value::Object(item) Value::Object(item)
} }
@@ -5728,7 +5729,9 @@ mod tests {
.expect("modern response.done stream should aggregate"); .expect("modern response.done stream should aggregate");
assert_eq!(result["output"][0]["type"], "reasoning"); assert_eq!(result["output"][0]["type"], "reasoning");
assert_eq!(result["output"][0]["summary"][0]["text"], "Need care"); assert_eq!(result["output"][0]["summary"], json!([]));
assert_eq!(result["output"][0]["content"][0]["type"], "reasoning_text");
assert_eq!(result["output"][0]["content"][0]["text"], "Need care");
assert!(result["output"].as_array().is_some()); assert!(result["output"].as_array().is_some());
assert_eq!(result["output_text"], ""); assert_eq!(result["output_text"], "");
assert!(result["completed_at"].as_i64().is_some()); assert!(result["completed_at"].as_i64().is_some());
@@ -5754,7 +5757,7 @@ mod tests {
.as_object() .as_object()
.expect("reasoning item should be an object") .expect("reasoning item should be an object")
.clone(), .clone(),
summary_text: "must not replace provider-owned state".to_string(), reasoning_text: "must not replace provider-owned state".to_string(),
}; };
let materialized = materialize_openai_responses_reasoning_item("resp_opaque_123", state); let materialized = materialize_openai_responses_reasoning_item("resp_opaque_123", state);
@@ -5795,13 +5798,13 @@ mod tests {
} }
#[test] #[test]
fn synthesizes_wire_compatible_id_for_local_reasoning_summary() { fn synthesizes_wire_compatible_id_for_local_reasoning_text() {
let state = OpenAIResponsesSyncReasoningState { let state = OpenAIResponsesSyncReasoningState {
item: json!({"type": "reasoning"}) item: json!({"type": "reasoning"})
.as_object() .as_object()
.expect("reasoning item should be an object") .expect("reasoning item should be an object")
.clone(), .clone(),
summary_text: "Need care".to_string(), reasoning_text: "Need care".to_string(),
}; };
let materialized = materialize_openai_responses_reasoning_item("resp_summary_123", state); let materialized = materialize_openai_responses_reasoning_item("resp_summary_123", state);
@@ -5810,7 +5813,7 @@ mod tests {
materialized["id"], materialized["id"],
openai_responses_synthetic_reasoning_item_id("resp_summary_123", 0) openai_responses_synthetic_reasoning_item_id("resp_summary_123", 0)
); );
assert_eq!(materialized["summary"][0]["text"], "Need care"); assert_eq!(materialized["summary"], json!([]));
assert_eq!(materialized["content"][0]["type"], "reasoning_text"); assert_eq!(materialized["content"][0]["type"], "reasoning_text");
assert_eq!(materialized["content"][0]["text"], "Need care"); assert_eq!(materialized["content"][0]["text"], "Need care");
} }
@@ -8412,7 +8412,8 @@ mod tests {
let rebuilt = canonical_to_openai_responses_request(&canonical, "gpt-5-upstream", false) let rebuilt = canonical_to_openai_responses_request(&canonical, "gpt-5-upstream", false)
.expect("openai responses request"); .expect("openai responses request");
assert_eq!(rebuilt["input"][0]["type"], "reasoning"); assert_eq!(rebuilt["input"][0]["type"], "reasoning");
assert_eq!(rebuilt["input"][0]["summary"][0]["text"], "think"); assert_eq!(rebuilt["input"][0]["content"][0]["type"], "reasoning_text");
assert_eq!(rebuilt["input"][0]["content"][0]["text"], "think");
assert_eq!(rebuilt["input"][0]["encrypted_content"], "enc_reasoning"); assert_eq!(rebuilt["input"][0]["encrypted_content"], "enc_reasoning");
assert_eq!(rebuilt["input"][1]["type"], "message"); assert_eq!(rebuilt["input"][1]["type"], "message");
assert_eq!(rebuilt["input"][1]["content"][0]["text"], "done"); assert_eq!(rebuilt["input"][1]["content"][0]["text"], "done");