mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-03 16:07:46 +08:00
Merge pull request #835 from Kayphoon/fix/responses-reasoning-content-only
fix(responses): keep raw reasoning on content only
This commit is contained in:
@@ -3203,10 +3203,7 @@ fn openai_responses_body(
|
||||
"id": openai_responses_synthetic_reasoning_item_id(&response_id, 0),
|
||||
"type": "reasoning",
|
||||
"status": "completed",
|
||||
"summary": [{
|
||||
"type": "summary_text",
|
||||
"text": thinking,
|
||||
}],
|
||||
"summary": [],
|
||||
"content": [{
|
||||
"type": "reasoning_text",
|
||||
"text": thinking,
|
||||
@@ -4640,10 +4637,7 @@ mod tests {
|
||||
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"][0]["summary"], serde_json::json!([]));
|
||||
assert_eq!(body["output"][1]["type"], serde_json::json!("message"));
|
||||
assert!(body["output"][1]["id"]
|
||||
.as_str()
|
||||
@@ -4827,7 +4821,12 @@ mod tests {
|
||||
|
||||
assert!(body.contains("event: response.created"));
|
||||
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.output_text.done"));
|
||||
assert!(body.contains("event: response.completed"));
|
||||
|
||||
@@ -11880,7 +11880,11 @@ mod tests {
|
||||
.expect("response body should read");
|
||||
let body = String::from_utf8(body.to_vec()).expect("response body should be utf8");
|
||||
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}"
|
||||
);
|
||||
assert!(
|
||||
|
||||
@@ -9,7 +9,7 @@ use serde_json::{json, Value};
|
||||
use crate::formats::{
|
||||
context::FormatContext,
|
||||
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,
|
||||
response::ensure_modern_openai_responses_response_fields,
|
||||
},
|
||||
@@ -206,12 +206,12 @@ 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));
|
||||
let content = openai_responses_reasoning_text_parts(std::iter::once(trimmed));
|
||||
output.push(json!({
|
||||
"type": "reasoning",
|
||||
"id": openai_responses_synthetic_reasoning_item_id(response_id, index),
|
||||
"status": "completed",
|
||||
"summary": summary,
|
||||
"summary": [],
|
||||
"content": content,
|
||||
}));
|
||||
}
|
||||
@@ -310,8 +310,7 @@ mod tests {
|
||||
"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"][0]["summary"], json!([]));
|
||||
assert_eq!(response["output"][1]["content"][0]["text"], "answer");
|
||||
}
|
||||
|
||||
@@ -357,8 +356,7 @@ mod tests {
|
||||
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_eq!(item["summary"], json!([]));
|
||||
assert!(!item.get("content").unwrap().is_null());
|
||||
assert_eq!(converted["output"][1]["type"], "message");
|
||||
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::responses::{
|
||||
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::{
|
||||
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") {
|
||||
return;
|
||||
}
|
||||
let completed_reasoning = reasoning_item_text(item);
|
||||
if self.terminal_only {
|
||||
if item
|
||||
.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())
|
||||
})
|
||||
})
|
||||
{
|
||||
if !completed_reasoning.is_empty() {
|
||||
self.ensure_started(report_context, out);
|
||||
}
|
||||
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() {
|
||||
self.emit_missing_reasoning(report_context, out, &completed_reasoning);
|
||||
}
|
||||
@@ -1663,7 +1636,8 @@ impl OpenAIResponsesProviderState {
|
||||
.unwrap_or_default();
|
||||
if !piece.is_empty() {
|
||||
let summary_index = value
|
||||
.get("summary_index")
|
||||
.get("content_index")
|
||||
.or_else(|| value.get("summary_index"))
|
||||
.and_then(Value::as_u64)
|
||||
.map(|value| value as usize)
|
||||
.unwrap_or(0);
|
||||
@@ -1698,7 +1672,8 @@ impl OpenAIResponsesProviderState {
|
||||
.unwrap_or_default();
|
||||
if !text.is_empty() {
|
||||
let summary_index = value
|
||||
.get("summary_index")
|
||||
.get("content_index")
|
||||
.or_else(|| value.get("summary_index"))
|
||||
.and_then(Value::as_u64)
|
||||
.map(|value| value as usize)
|
||||
.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)]
|
||||
pub struct OpenAIChatClientEmitter {
|
||||
response_id: Option<String>,
|
||||
@@ -2677,12 +2675,14 @@ impl OpenAIResponsesClientEmitter {
|
||||
}
|
||||
|
||||
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!({
|
||||
"type": "reasoning",
|
||||
"id": self.reasoning_item_id(),
|
||||
"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,
|
||||
})
|
||||
}
|
||||
@@ -2694,29 +2694,17 @@ impl OpenAIResponsesClientEmitter {
|
||||
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(
|
||||
self.encode_response_event(
|
||||
"response.reasoning_text.delta",
|
||||
json!({
|
||||
"type": "response.reasoning_text.delta",
|
||||
"response_id": self.response_id(),
|
||||
"item_id": item_id.clone(),
|
||||
"item_id": item_id,
|
||||
"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(
|
||||
@@ -2726,7 +2714,7 @@ impl OpenAIResponsesClientEmitter {
|
||||
part_index: usize,
|
||||
part_text: &str,
|
||||
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
||||
let mut out = self.encode_response_event(
|
||||
self.encode_response_event(
|
||||
"response.reasoning_text.done",
|
||||
json!({
|
||||
"type": "response.reasoning_text.done",
|
||||
@@ -2736,33 +2724,7 @@ impl OpenAIResponsesClientEmitter {
|
||||
"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 {
|
||||
@@ -2827,21 +2789,6 @@ impl OpenAIResponsesClientEmitter {
|
||||
self.reasoning_item_started = true;
|
||||
}
|
||||
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;
|
||||
}
|
||||
Ok(out)
|
||||
@@ -4291,7 +4238,7 @@ mod tests {
|
||||
data = Some(value);
|
||||
}
|
||||
}
|
||||
if event_name != Some("response.reasoning_summary_text.done") {
|
||||
if event_name != Some("response.reasoning_text.done") {
|
||||
continue;
|
||||
}
|
||||
let Some(data) = data else {
|
||||
@@ -4300,13 +4247,17 @@ mod tests {
|
||||
let Ok(value) = serde_json::from_str::<Value>(data) else {
|
||||
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;
|
||||
};
|
||||
let Some(text) = value.get("text").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
parts.push((summary_index, text.to_string()));
|
||||
parts.push((part_index, text.to_string()));
|
||||
}
|
||||
parts
|
||||
}
|
||||
@@ -4527,7 +4478,8 @@ mod tests {
|
||||
}
|
||||
|
||||
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\":\" me think.\""));
|
||||
}
|
||||
@@ -6596,20 +6548,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\""));
|
||||
// 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);
|
||||
assert!(sse.contains(&format!("\"item_id\":\"{reasoning_item_id}\"")));
|
||||
assert!(sse.contains("\"type\":\"reasoning\""));
|
||||
assert_eq!(
|
||||
response_sequence_numbers(&sse),
|
||||
(1..=11).collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(response_sequence_numbers(&sse), (1..=7).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn openai_responses_client_emitter_emits_failed_event_with_sequence_number() {
|
||||
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:
|
||||
/// - `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(
|
||||
/// Raw chain-of-thought belongs in `content` as `reasoning_text` parts. It is
|
||||
/// deliberately *not* mirrored into `summary`: OpenAI keeps the two channels
|
||||
/// distinct, and clients such as Codex render both, so duplicating the same
|
||||
/// text onto `summary` made the thinking panel print everything twice.
|
||||
pub(crate) fn openai_responses_reasoning_text_parts(
|
||||
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))
|
||||
) -> Value {
|
||||
Value::Array(
|
||||
texts
|
||||
.into_iter()
|
||||
.map(|text| text.as_ref().to_string())
|
||||
.filter(|text| !text.trim().is_empty())
|
||||
.map(|text| json!({ "type": "reasoning_text", "text": text }))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
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")) {
|
||||
let content = openai_responses_reasoning_text_parts(std::iter::once(text));
|
||||
item.insert("content".to_string(), content);
|
||||
}
|
||||
if reasoning_item_field_is_empty(item.get("summary")) {
|
||||
item.insert("summary".to_string(), summary);
|
||||
}
|
||||
// `summary` stays a valid (empty) array so the item keeps its documented
|
||||
// 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 {
|
||||
@@ -540,21 +532,18 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_text_fields_put_raw_thinking_in_content_and_summary() {
|
||||
let (content, summary) = super::openai_responses_reasoning_text_fields(["raw chain"]);
|
||||
fn reasoning_text_parts_put_raw_thinking_in_content_only() {
|
||||
let content = super::openai_responses_reasoning_text_parts(["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);
|
||||
// Never mirrored onto `summary`: clients rendering both would repeat it.
|
||||
assert_eq!(item["summary"], json!([]));
|
||||
|
||||
item.insert(
|
||||
"summary".to_string(),
|
||||
|
||||
@@ -786,7 +786,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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 {
|
||||
id: "resp_think".to_string(),
|
||||
model: "deepseek-reasoner".to_string(),
|
||||
@@ -814,8 +814,7 @@ mod tests {
|
||||
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_eq!(item["summary"], json!([]));
|
||||
assert!(!item["content"].is_null());
|
||||
assert_eq!(body["output"][1]["type"], "message");
|
||||
assert_eq!(body["output"][1]["content"][0]["text"], "2");
|
||||
|
||||
@@ -1053,7 +1053,7 @@ mod tests {
|
||||
"{sse}"
|
||||
);
|
||||
assert!(
|
||||
sse.contains("event: response.reasoning_summary_text.delta\n"),
|
||||
!sse.contains("event: response.reasoning_summary_text.delta\n"),
|
||||
"{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::{
|
||||
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,
|
||||
};
|
||||
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
|
||||
.entry(output_index)
|
||||
.or_default()
|
||||
.summary_text
|
||||
.reasoning_text
|
||||
.push_str(delta);
|
||||
}
|
||||
"response.reasoning_text.done" | "response.reasoning_summary_text.done" => {
|
||||
@@ -2799,7 +2799,7 @@ struct OpenAIResponsesSyncMessageState {
|
||||
#[derive(Default)]
|
||||
struct OpenAIResponsesSyncReasoningState {
|
||||
item: Map<String, Value>,
|
||||
summary_text: String,
|
||||
reasoning_text: String,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -3113,8 +3113,8 @@ fn merge_openai_responses_reasoning_text(
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
if state.summary_text.is_empty() || text.len() >= state.summary_text.len() {
|
||||
state.summary_text = text.to_string();
|
||||
if state.reasoning_text.is_empty() || text.len() >= state.reasoning_text.len() {
|
||||
state.reasoning_text = text.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3297,15 +3297,16 @@ 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() {
|
||||
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);
|
||||
}
|
||||
if !state.reasoning_text.is_empty()
|
||||
&& reasoning_item_field_missing_or_empty(item.get("content"))
|
||||
{
|
||||
let content = openai_responses_reasoning_text_parts([&state.reasoning_text]);
|
||||
item.insert("content".to_string(), content);
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -5728,7 +5729,9 @@ mod tests {
|
||||
.expect("modern response.done stream should aggregate");
|
||||
|
||||
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_eq!(result["output_text"], "");
|
||||
assert!(result["completed_at"].as_i64().is_some());
|
||||
@@ -5754,7 +5757,7 @@ mod tests {
|
||||
.as_object()
|
||||
.expect("reasoning item should be an object")
|
||||
.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);
|
||||
@@ -5795,13 +5798,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthesizes_wire_compatible_id_for_local_reasoning_summary() {
|
||||
fn synthesizes_wire_compatible_id_for_local_reasoning_text() {
|
||||
let state = OpenAIResponsesSyncReasoningState {
|
||||
item: json!({"type": "reasoning"})
|
||||
.as_object()
|
||||
.expect("reasoning item should be an object")
|
||||
.clone(),
|
||||
summary_text: "Need care".to_string(),
|
||||
reasoning_text: "Need care".to_string(),
|
||||
};
|
||||
|
||||
let materialized = materialize_openai_responses_reasoning_item("resp_summary_123", state);
|
||||
@@ -5810,7 +5813,7 @@ mod tests {
|
||||
materialized["id"],
|
||||
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]["text"], "Need care");
|
||||
}
|
||||
|
||||
@@ -8412,7 +8412,8 @@ mod tests {
|
||||
let rebuilt = canonical_to_openai_responses_request(&canonical, "gpt-5-upstream", false)
|
||||
.expect("openai responses request");
|
||||
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"][1]["type"], "message");
|
||||
assert_eq!(rebuilt["input"][1]["content"][0]["text"], "done");
|
||||
|
||||
Reference in New Issue
Block a user