mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-16 16:07:45 +08:00
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:
@@ -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();
|
||||
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", "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));
|
||||
let extensions = openai_thinking_extensions(extensions);
|
||||
blocks.push(CanonicalContentBlock::Thinking {
|
||||
text: text.to_string(),
|
||||
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));
|
||||
|
||||
Reference in New Issue
Block a user