mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 00:17:45 +08:00
Merge pull request #820 from Kayphoon/cursor/responses-reasoning-content-68bd
fix(responses): put raw reasoning in content, keep summary for CLI
This commit is contained in:
@@ -3198,13 +3198,18 @@ fn openai_responses_body(
|
|||||||
let response_id = format!("resp_{}", Uuid::new_v4());
|
let response_id = format!("resp_{}", Uuid::new_v4());
|
||||||
let mut output = Vec::new();
|
let mut output = Vec::new();
|
||||||
if !collected.thinking.trim().is_empty() {
|
if !collected.thinking.trim().is_empty() {
|
||||||
|
let thinking = collected.thinking.trim();
|
||||||
output.push(json!({
|
output.push(json!({
|
||||||
"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",
|
"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)
|
serde_json::json!(usage.reasoning_tokens)
|
||||||
);
|
);
|
||||||
assert_eq!(body["output"][0]["type"], serde_json::json!("reasoning"));
|
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_eq!(body["output"][1]["type"], serde_json::json!("message"));
|
||||||
assert!(body["output"][1]["id"]
|
assert!(body["output"][1]["id"]
|
||||||
.as_str()
|
.as_str()
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ 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_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,
|
response::ensure_modern_openai_responses_response_fields,
|
||||||
},
|
},
|
||||||
registry,
|
registry,
|
||||||
@@ -205,14 +206,13 @@ 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));
|
||||||
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,
|
||||||
"type": "summary_text",
|
"content": content,
|
||||||
"text": trimmed,
|
|
||||||
}]
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
if !content.is_empty() {
|
if !content.is_empty() {
|
||||||
@@ -289,6 +289,32 @@ mod tests {
|
|||||||
assert!(converted["completed_at"].as_i64().is_some());
|
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]
|
#[test]
|
||||||
fn manual_responses_response_builder_emits_modern_fields() {
|
fn manual_responses_response_builder_emits_modern_fields() {
|
||||||
let response = super::build_openai_responses_response(
|
let response = super::build_openai_responses_response(
|
||||||
@@ -306,6 +332,41 @@ mod tests {
|
|||||||
assert!(response["completed_at"].as_i64().is_some());
|
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]
|
#[test]
|
||||||
fn pairwise_response_helper_uses_report_context_model_fallback() {
|
fn pairwise_response_helper_uses_report_context_model_fallback() {
|
||||||
let body = json!({
|
let body = json!({
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use sha2::{Digest, Sha256};
|
|||||||
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_synthetic_reasoning_item_id,
|
openai_responses_reasoning_text_fields, 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,
|
||||||
},
|
},
|
||||||
@@ -2621,6 +2621,107 @@ impl OpenAIResponsesClientEmitter {
|
|||||||
self.reasoning_summary_parts.len()
|
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 {
|
fn ensure_message_output_index(&mut self) -> usize {
|
||||||
if let Some(output_index) = self.message_output_index {
|
if let Some(output_index) = self.message_output_index {
|
||||||
return output_index;
|
return output_index;
|
||||||
@@ -2676,6 +2777,7 @@ impl OpenAIResponsesClientEmitter {
|
|||||||
"type": "reasoning",
|
"type": "reasoning",
|
||||||
"id": item_id.clone(),
|
"id": item_id.clone(),
|
||||||
"summary": [],
|
"summary": [],
|
||||||
|
"content": [],
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)?);
|
)?);
|
||||||
@@ -2812,66 +2914,23 @@ impl OpenAIResponsesClientEmitter {
|
|||||||
if self.reasoning_part_started {
|
if self.reasoning_part_started {
|
||||||
let summary_index = self.current_reasoning_summary_index();
|
let summary_index = self.current_reasoning_summary_index();
|
||||||
let part_text = self.reasoning_part.clone();
|
let part_text = self.reasoning_part.clone();
|
||||||
out.extend(self.encode_response_event(
|
out.extend(self.encode_reasoning_text_done_events(
|
||||||
"response.reasoning_summary_text.done",
|
&item_id,
|
||||||
json!({
|
output_index,
|
||||||
"type": "response.reasoning_summary_text.done",
|
summary_index,
|
||||||
"response_id": self.response_id(),
|
part_text.as_str(),
|
||||||
"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(),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)?);
|
)?);
|
||||||
self.reasoning_summary_parts.push(part_text);
|
self.reasoning_summary_parts.push(part_text);
|
||||||
self.reasoning_part.clear();
|
self.reasoning_part.clear();
|
||||||
self.reasoning_part_started = false;
|
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(
|
out.extend(self.encode_response_event(
|
||||||
"response.output_item.done",
|
"response.output_item.done",
|
||||||
json!({
|
json!({
|
||||||
"type": "response.output_item.done",
|
"type": "response.output_item.done",
|
||||||
"response_id": self.response_id(),
|
"response_id": self.response_id(),
|
||||||
"output_index": output_index,
|
"output_index": output_index,
|
||||||
"item": {
|
"item": self.reasoning_item_value(),
|
||||||
"type": "reasoning",
|
|
||||||
"id": item_id,
|
|
||||||
"summary": summary,
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
)?);
|
)?);
|
||||||
Ok(out)
|
Ok(out)
|
||||||
@@ -3035,35 +3094,10 @@ impl OpenAIResponsesClientEmitter {
|
|||||||
incomplete_reason: Option<&str>,
|
incomplete_reason: Option<&str>,
|
||||||
) -> Value {
|
) -> Value {
|
||||||
let mut ordered_output = Vec::new();
|
let mut ordered_output = Vec::new();
|
||||||
let summary = if self.reasoning_summary_parts.is_empty() {
|
if !self.reasoning_texts().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() {
|
|
||||||
ordered_output.push((
|
ordered_output.push((
|
||||||
self.reasoning_output_index.unwrap_or(0),
|
self.reasoning_output_index.unwrap_or(0),
|
||||||
json!({
|
self.reasoning_item_value(),
|
||||||
"type": "reasoning",
|
|
||||||
"id": self.reasoning_item_id(),
|
|
||||||
"status": "completed",
|
|
||||||
"summary": summary,
|
|
||||||
}),
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if self.text_item_started || !self.text.is_empty() {
|
if self.text_item_started || !self.text.is_empty() {
|
||||||
@@ -3297,17 +3331,7 @@ impl OpenAIResponsesClientEmitter {
|
|||||||
let mut out = self.ensure_reasoning_item_started()?;
|
let mut out = self.ensure_reasoning_item_started()?;
|
||||||
self.reasoning.push_str(&text);
|
self.reasoning.push_str(&text);
|
||||||
self.reasoning_part.push_str(&text);
|
self.reasoning_part.push_str(&text);
|
||||||
out.extend(self.encode_response_event(
|
out.extend(self.encode_reasoning_text_delta(&text)?);
|
||||||
"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,
|
|
||||||
}),
|
|
||||||
)?);
|
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
CanonicalStreamEvent::ReasoningSummaryDone => {
|
CanonicalStreamEvent::ReasoningSummaryDone => {
|
||||||
@@ -3320,32 +3344,12 @@ impl OpenAIResponsesClientEmitter {
|
|||||||
let item_id = self.reasoning_item_id();
|
let item_id = self.reasoning_item_id();
|
||||||
let summary_index = self.current_reasoning_summary_index();
|
let summary_index = self.current_reasoning_summary_index();
|
||||||
let part_text = self.reasoning_part.clone();
|
let part_text = self.reasoning_part.clone();
|
||||||
let mut out = Vec::new();
|
let out = self.encode_reasoning_text_done_events(
|
||||||
out.extend(self.encode_response_event(
|
&item_id,
|
||||||
"response.reasoning_summary_text.done",
|
output_index,
|
||||||
json!({
|
summary_index,
|
||||||
"type": "response.reasoning_summary_text.done",
|
part_text.as_str(),
|
||||||
"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(),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)?);
|
|
||||||
self.reasoning_summary_parts.push(part_text);
|
self.reasoning_summary_parts.push(part_text);
|
||||||
self.reasoning_part.clear();
|
self.reasoning_part.clear();
|
||||||
self.reasoning_part_started = false;
|
self.reasoning_part_started = false;
|
||||||
@@ -6352,13 +6356,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_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_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_text.done\n"));
|
||||||
assert!(sse.contains("event: response.reasoning_summary_part.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);
|
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!(response_sequence_numbers(&sse), (1..=9).collect::<Vec<_>>());
|
assert_eq!(
|
||||||
|
response_sequence_numbers(&sse),
|
||||||
|
(1..=11).collect::<Vec<_>>()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine as _};
|
use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine as _};
|
||||||
use serde_json::Value;
|
use serde_json::{json, Map, Value};
|
||||||
|
|
||||||
pub mod codex;
|
pub mod codex;
|
||||||
pub(crate) mod history;
|
pub(crate) mod history;
|
||||||
@@ -122,6 +122,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.
|
/// Repairs legacy/non-OpenAI message IDs in a Responses request in place.
|
||||||
///
|
///
|
||||||
/// Aether versions before the `msg_` contract emitted IDs such as
|
/// Aether versions before the `msg_` contract emitted IDs such as
|
||||||
@@ -460,6 +512,36 @@ mod tests {
|
|||||||
assert_ne!(first, other);
|
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]
|
#[test]
|
||||||
fn synthetic_message_item_ids_are_stable_and_start_with_msg() {
|
fn synthetic_message_item_ids_are_stable_and_start_with_msg() {
|
||||||
let first = openai_responses_message_item_id("1c938e58-32a8-4d28-9c34-538d78076895", 0);
|
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 serde_json::{json, Map, Value};
|
||||||
|
|
||||||
use super::encode_tool_result_error;
|
use super::{apply_openai_responses_reasoning_text, encode_tool_result_error};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
formats::context::FormatContext,
|
formats::context::FormatContext,
|
||||||
@@ -702,14 +702,7 @@ fn canonical_thinking_to_responses_reasoning_item(
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
item.remove("item_type");
|
item.remove("item_type");
|
||||||
item.insert("type".to_string(), Value::String("reasoning".to_string()));
|
item.insert("type".to_string(), Value::String("reasoning".to_string()));
|
||||||
if !text.trim().is_empty() {
|
apply_openai_responses_reasoning_text(&mut item, text);
|
||||||
item.entry("summary".to_string()).or_insert_with(|| {
|
|
||||||
json!([{
|
|
||||||
"type": "summary_text",
|
|
||||||
"text": text,
|
|
||||||
}])
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if let Some(value) = encrypted_content.filter(|value| !value.is_empty()) {
|
if let Some(value) = encrypted_content.filter(|value| !value.is_empty()) {
|
||||||
item.insert(
|
item.insert(
|
||||||
"encrypted_content".to_string(),
|
"encrypted_content".to_string(),
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ use std::{
|
|||||||
use serde_json::{json, Map, Value};
|
use serde_json::{json, Map, Value};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
encode_gemini_tool_signature_carrier, encode_tool_result_error,
|
apply_openai_responses_reasoning_text, encode_gemini_tool_signature_carrier,
|
||||||
history::record_converted_response_history, openai_responses_synthetic_reasoning_item_id,
|
encode_tool_result_error, history::record_converted_response_history,
|
||||||
|
openai_responses_synthetic_reasoning_item_id,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -217,15 +218,7 @@ pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, compact: bo
|
|||||||
Value::String(encrypted_content.clone()),
|
Value::String(encrypted_content.clone()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if !text.trim().is_empty() {
|
apply_openai_responses_reasoning_text(&mut item, text);
|
||||||
item.insert(
|
|
||||||
"summary".to_string(),
|
|
||||||
Value::Array(vec![json!({
|
|
||||||
"type": "summary_text",
|
|
||||||
"text": text,
|
|
||||||
})]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
output.push(Value::Object(item));
|
output.push(Value::Object(item));
|
||||||
}
|
}
|
||||||
CanonicalContentBlock::ToolUse {
|
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]
|
#[test]
|
||||||
fn responses_response_parser_preserves_encrypted_reasoning_without_summary() {
|
fn responses_response_parser_preserves_encrypted_reasoning_without_summary() {
|
||||||
let body = json!({
|
let body = json!({
|
||||||
|
|||||||
@@ -950,6 +950,10 @@ mod tests {
|
|||||||
.expect("first Gemini thought chunk should transform");
|
.expect("first Gemini thought chunk should transform");
|
||||||
let sse = String::from_utf8(output).expect("reasoning SSE should be utf8");
|
let sse = String::from_utf8(output).expect("reasoning SSE should be utf8");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
sse.contains("event: response.reasoning_text.delta\n"),
|
||||||
|
"{sse}"
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
sse.contains("event: response.reasoning_summary_text.delta\n"),
|
sse.contains("event: response.reasoning_summary_text.delta\n"),
|
||||||
"{sse}"
|
"{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::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_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::formats::registry::{convert_response, FormatContext, FormatError};
|
||||||
use aether_ai_formats::{
|
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> {
|
fn extract_openai_responses_reasoning_text(item: &Map<String, Value>) -> Option<String> {
|
||||||
item.get("summary")
|
extract_openai_responses_reasoning_parts(item.get("content"), "reasoning_text")
|
||||||
.and_then(Value::as_array)
|
.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()
|
.into_iter()
|
||||||
.flatten()
|
.flatten()
|
||||||
.find_map(|part| {
|
.find_map(|part| {
|
||||||
let part = part.as_object()?;
|
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")
|
part.get("text")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_string()
|
.to_string()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
.filter(|text| !text.is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn merge_openai_responses_message_item(
|
fn merge_openai_responses_message_item(
|
||||||
@@ -3276,17 +3285,26 @@ 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.summary_text.is_empty() {
|
||||||
item.insert(
|
let (content, summary) = openai_responses_reasoning_text_fields([&state.summary_text]);
|
||||||
"summary".to_string(),
|
if reasoning_item_field_missing_or_empty(item.get("content")) {
|
||||||
Value::Array(vec![json!({
|
item.insert("content".to_string(), content);
|
||||||
"type": "summary_text",
|
}
|
||||||
"text": state.summary_text,
|
if reasoning_item_field_missing_or_empty(item.get("summary")) {
|
||||||
})]),
|
item.insert("summary".to_string(), summary);
|
||||||
);
|
}
|
||||||
}
|
}
|
||||||
Value::Object(item)
|
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(
|
fn materialize_openai_responses_tool_item(
|
||||||
output_index: usize,
|
output_index: usize,
|
||||||
state: OpenAIResponsesSyncToolState,
|
state: OpenAIResponsesSyncToolState,
|
||||||
@@ -5689,6 +5707,8 @@ mod tests {
|
|||||||
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"][0]["text"], "Need care");
|
||||||
|
assert_eq!(materialized["content"][0]["type"], "reasoning_text");
|
||||||
|
assert_eq!(materialized["content"][0]["text"], "Need care");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -2860,9 +2860,9 @@ fn openai_responses_reasoning_block_from_item(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn openai_responses_reasoning_text(item_object: &Map<String, Value>) -> String {
|
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() {
|
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")
|
parts.join("\n")
|
||||||
}
|
}
|
||||||
@@ -2961,38 +2961,47 @@ pub(crate) fn openai_responses_output_to_canonical(
|
|||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.map(ToOwned::to_owned);
|
.map(ToOwned::to_owned);
|
||||||
if let Some(summary_items) = item_object.get("summary").and_then(Value::as_array) {
|
let mut texts = openai_responses_reasoning_text_parts(item_object.get("content"));
|
||||||
for summary in summary_items {
|
if texts.is_empty() {
|
||||||
let Some(summary_object) = summary.as_object() else {
|
texts = openai_responses_reasoning_text_parts(item_object.get("summary"));
|
||||||
continue;
|
}
|
||||||
};
|
for text in texts {
|
||||||
let text = summary_object
|
if text.trim().is_empty() {
|
||||||
.get("text")
|
continue;
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default();
|
|
||||||
if text.trim().is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let mut extensions = openai_responses_extensions(
|
|
||||||
item_object,
|
|
||||||
&["type", "id", "status", "summary", "encrypted_content"],
|
|
||||||
);
|
|
||||||
canonical_extension_object_mut(&mut extensions, "openai")
|
|
||||||
.insert("omit_reasoning_parts".to_string(), Value::Bool(true));
|
|
||||||
let extensions = openai_thinking_extensions(extensions);
|
|
||||||
blocks.push(CanonicalContentBlock::Thinking {
|
|
||||||
text: text.to_string(),
|
|
||||||
signature: None,
|
|
||||||
encrypted_content: encrypted_content.clone(),
|
|
||||||
extensions,
|
|
||||||
});
|
|
||||||
emitted = true;
|
|
||||||
}
|
}
|
||||||
|
let mut extensions = openai_responses_extensions(
|
||||||
|
item_object,
|
||||||
|
&[
|
||||||
|
"type",
|
||||||
|
"id",
|
||||||
|
"status",
|
||||||
|
"summary",
|
||||||
|
"content",
|
||||||
|
"encrypted_content",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
canonical_extension_object_mut(&mut extensions, "openai")
|
||||||
|
.insert("omit_reasoning_parts".to_string(), Value::Bool(true));
|
||||||
|
let extensions = openai_thinking_extensions(extensions);
|
||||||
|
blocks.push(CanonicalContentBlock::Thinking {
|
||||||
|
text,
|
||||||
|
signature: None,
|
||||||
|
encrypted_content: encrypted_content.clone(),
|
||||||
|
extensions,
|
||||||
|
});
|
||||||
|
emitted = true;
|
||||||
}
|
}
|
||||||
if !emitted && encrypted_content.is_some() {
|
if !emitted && encrypted_content.is_some() {
|
||||||
let mut extensions = openai_responses_extensions(
|
let mut extensions = openai_responses_extensions(
|
||||||
item_object,
|
item_object,
|
||||||
&["type", "id", "status", "summary", "encrypted_content"],
|
&[
|
||||||
|
"type",
|
||||||
|
"id",
|
||||||
|
"status",
|
||||||
|
"summary",
|
||||||
|
"content",
|
||||||
|
"encrypted_content",
|
||||||
|
],
|
||||||
);
|
);
|
||||||
canonical_extension_object_mut(&mut extensions, "openai")
|
canonical_extension_object_mut(&mut extensions, "openai")
|
||||||
.insert("omit_reasoning_parts".to_string(), Value::Bool(true));
|
.insert("omit_reasoning_parts".to_string(), Value::Bool(true));
|
||||||
|
|||||||
Reference in New Issue
Block a user