From c5adcf0312b0941e631e5c7b41e2b3b1f0c82e1b Mon Sep 17 00:00:00 2001 From: Kayphoon <109347466+Kayphoon@users.noreply.github.com> Date: Mon, 14 Sep 2026 07:51:52 +0000 Subject: [PATCH] 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. --- .../src/execution_runtime/grok.rs | 19 +- .../src/formats/conversion/response.rs | 71 +++++- .../formats/src/formats/openai/chat/stream.rs | 240 +++++++++--------- .../src/formats/openai/responses/mod.rs | 84 +++++- .../src/formats/openai/responses/request.rs | 11 +- .../src/formats/openai/responses/response.rs | 75 +++++- .../shared/stream_core/format_matrix.rs | 4 + .../src/formats/shared/sync_products.rs | 42 ++- .../formats/src/protocol/canonical.rs | 67 ++--- 9 files changed, 431 insertions(+), 182 deletions(-) diff --git a/apps/aether-gateway/src/execution_runtime/grok.rs b/apps/aether-gateway/src/execution_runtime/grok.rs index a44e2d792..340a5a5f3 100644 --- a/apps/aether-gateway/src/execution_runtime/grok.rs +++ b/apps/aether-gateway/src/execution_runtime/grok.rs @@ -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() diff --git a/crates/aether-ai/formats/src/formats/conversion/response.rs b/crates/aether-ai/formats/src/formats/conversion/response.rs index 646b99a9f..a10863e6b 100644 --- a/crates/aether-ai/formats/src/formats/conversion/response.rs +++ b/crates/aether-ai/formats/src/formats/conversion/response.rs @@ -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!({ diff --git a/crates/aether-ai/formats/src/formats/openai/chat/stream.rs b/crates/aether-ai/formats/src/formats/openai/chat/stream.rs index a5a29524e..d71d545d8 100644 --- a/crates/aether-ai/formats/src/formats/openai/chat/stream.rs +++ b/crates/aether-ai/formats/src/formats/openai/chat/stream.rs @@ -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 { + 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, 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, 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::>() - }; 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::>() - }; - 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::>()); + assert_eq!( + response_sequence_numbers(&sse), + (1..=11).collect::>() + ); } #[test] diff --git a/crates/aether-ai/formats/src/formats/openai/responses/mod.rs b/crates/aether-ai/formats/src/formats/openai/responses/mod.rs index 36053f67e..e41661c23 100644 --- a/crates/aether-ai/formats/src/formats/openai/responses/mod.rs +++ b/crates/aether-ai/formats/src/formats/openai/responses/mod.rs @@ -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>, +) -> (Value, Value) { + let texts: Vec = 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::>(); + let summary = texts + .iter() + .map(|text| json!({ "type": "summary_text", "text": text })) + .collect::>(); + (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, 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); diff --git a/crates/aether-ai/formats/src/formats/openai/responses/request.rs b/crates/aether-ai/formats/src/formats/openai/responses/request.rs index 519a2e505..cd5799d32 100644 --- a/crates/aether-ai/formats/src/formats/openai/responses/request.rs +++ b/crates/aether-ai/formats/src/formats/openai/responses/request.rs @@ -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(), diff --git a/crates/aether-ai/formats/src/formats/openai/responses/response.rs b/crates/aether-ai/formats/src/formats/openai/responses/response.rs index 977d8b8f6..0d25d4c82 100644 --- a/crates/aether-ai/formats/src/formats/openai/responses/response.rs +++ b/crates/aether-ai/formats/src/formats/openai/responses/response.rs @@ -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!({ diff --git a/crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs b/crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs index 9351e6a25..bdf162e87 100644 --- a/crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs +++ b/crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs @@ -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}" diff --git a/crates/aether-ai/formats/src/formats/shared/sync_products.rs b/crates/aether-ai/formats/src/formats/shared/sync_products.rs index 4a20b159f..d8c743394 100644 --- a/crates/aether-ai/formats/src/formats/shared/sync_products.rs +++ b/crates/aether-ai/formats/src/formats/shared/sync_products.rs @@ -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) -> Option { - 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 { + 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] diff --git a/crates/aether-ai/formats/src/protocol/canonical.rs b/crates/aether-ai/formats/src/protocol/canonical.rs index 55b0d7661..c3ad55edd 100644 --- a/crates/aether-ai/formats/src/protocol/canonical.rs +++ b/crates/aether-ai/formats/src/protocol/canonical.rs @@ -2860,9 +2860,9 @@ fn openai_responses_reasoning_block_from_item( } fn openai_responses_reasoning_text(item_object: &Map) -> 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));