mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 00:17:45 +08:00
fix(responses): keep raw reasoning on content only
Raw chain-of-thought was written to both `content` (`reasoning_text`) and `summary` (`summary_text`), and the stream emitter sent the same delta on `response.reasoning_text.delta` *and* `response.reasoning_summary_text.delta`. Clients that render both channels therefore printed every thinking chunk twice — most visibly the Codex CLI, whose thinking panel repeated itself. OpenAI keeps the two channels distinct: `content` carries the raw CoT while `summary` is the summarised view. Emit the thinking on `content` only: - `openai_responses_reasoning_text_fields` becomes `openai_responses_reasoning_text_parts`, returning just the `content` array; reasoning items keep `summary: []` (or a provider-supplied summary). - The Responses stream emitter emits `response.reasoning_text.delta` / `.done` and no longer mirrors them onto the summary events. The reasoning `output_item.added` no longer announces a `reasoning_summary_part`. - The provider-state reasoning reader accepts `content` (`reasoning_text`) first and falls back to `summary`, so it also understands items produced by older Aether versions; its state field is renamed accordingly. - Non-streaming builders (Chat -> Responses, manual Responses response, Grok gateway) place the thinking on `content` and leave `summary` empty. Tests cover the raw thinking appearing exactly once in the emitted stream.
This commit is contained in:
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user