From 0fba6353e4b00a8c6dabfbca22e51b4229d8a793 Mon Sep 17 00:00:00 2001 From: Rabi Mishra Date: Wed, 11 Mar 2026 03:06:40 +0530 Subject: [PATCH] feat(openai): capture reasoning summaries from responses API (#7375) Signed-off-by: rabi --- crates/goose/src/providers/claude_code.rs | 18 +- crates/goose/src/providers/formats/openai.rs | 41 ++-- .../src/providers/formats/openai_responses.rs | 204 +++++++++++++++--- crates/goose/src/providers/utils.rs | 22 ++ 4 files changed, 224 insertions(+), 61 deletions(-) diff --git a/crates/goose/src/providers/claude_code.rs b/crates/goose/src/providers/claude_code.rs index d11a20ee23..d574f26641 100644 --- a/crates/goose/src/providers/claude_code.rs +++ b/crates/goose/src/providers/claude_code.rs @@ -785,14 +785,16 @@ impl Provider for ClaudeCodeProvider { .and_then(|d| d.get("text")) .and_then(|t| t.as_str()) { - let mut partial_message = Message::new( - Role::Assistant, - stream_timestamp, - vec![MessageContent::text(text)], - ); - partial_message.id = - Some(message_id.clone()); - yield (Some(partial_message), None); + if !text.is_empty() { + let mut partial_message = Message::new( + Role::Assistant, + stream_timestamp, + vec![MessageContent::text(text)], + ); + partial_message.id = + Some(message_id.clone()); + yield (Some(partial_message), None); + } } } Some("message_start") => { diff --git a/crates/goose/src/providers/formats/openai.rs b/crates/goose/src/providers/formats/openai.rs index 5ded36a735..465b9d356c 100644 --- a/crates/goose/src/providers/formats/openai.rs +++ b/crates/goose/src/providers/formats/openai.rs @@ -3,8 +3,8 @@ use crate::mcp_utils::extract_text_from_resource; use crate::model::ModelConfig; use crate::providers::base::{ProviderUsage, Usage}; use crate::providers::utils::{ - convert_image, detect_image_path, is_valid_function_name, load_image_file, safely_parse_json, - sanitize_function_name, ImageFormat, + convert_image, detect_image_path, extract_reasoning_effort, is_valid_function_name, + load_image_file, safely_parse_json, sanitize_function_name, ImageFormat, }; use anyhow::{anyhow, Error}; use async_stream::try_stream; @@ -760,25 +760,8 @@ pub fn create_request( )); } - let is_reasoning_model = model_config.is_openai_reasoning_model(); - - let (model_name, reasoning_effort) = if is_reasoning_model { - let parts: Vec<&str> = model_config.model_name.split('-').collect(); - let last_part = parts.last().unwrap(); - - match *last_part { - "low" | "medium" | "high" => { - let base_name = parts[..parts.len() - 1].join("-"); - (base_name, Some(last_part.to_string())) - } - _ => ( - model_config.model_name.to_string(), - Some("medium".to_string()), - ), - } - } else { - (model_config.model_name.to_string(), None) - }; + let (model_name, reasoning_effort) = extract_reasoning_effort(&model_config.model_name); + let is_reasoning_model = reasoning_effort.is_some(); let system_message = json!({ "role": if is_reasoning_model { "developer" } else { "system" }, @@ -806,17 +789,21 @@ pub fn create_request( payload["tools"] = json!(tools_spec); } - // o1, o3 models currently don't support temperature if !is_reasoning_model { if let Some(temp) = model_config.temperature { payload["temperature"] = json!(temp); } } - payload.as_object_mut().unwrap().insert( - "max_completion_tokens".to_string(), - json!(model_config.max_output_tokens()), - ); + let key = if is_reasoning_model { + "max_completion_tokens" + } else { + "max_tokens" + }; + payload + .as_object_mut() + .unwrap() + .insert(key.to_string(), json!(model_config.max_output_tokens())); if for_streaming { payload["stream"] = json!(true); @@ -1459,7 +1446,7 @@ mod tests { "content": "system" } ], - "max_completion_tokens": 1024 + "max_tokens": 1024 }); for (key, value) in expected.as_object().unwrap() { diff --git a/crates/goose/src/providers/formats/openai_responses.rs b/crates/goose/src/providers/formats/openai_responses.rs index 5b84226df4..c37bb30391 100644 --- a/crates/goose/src/providers/formats/openai_responses.rs +++ b/crates/goose/src/providers/formats/openai_responses.rs @@ -1,6 +1,7 @@ use crate::conversation::message::{Message, MessageContent}; use crate::model::ModelConfig; use crate::providers::base::{ProviderUsage, Usage}; +use crate::providers::utils::extract_reasoning_effort; use anyhow::{anyhow, Error}; use async_stream::try_stream; use chrono; @@ -24,14 +25,33 @@ pub struct ResponsesApiResponse { pub usage: Option, } +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(tag = "type", rename_all = "snake_case")] +pub struct SummaryText { + pub text: String, +} + +fn reasoning_from_summary(summary: &[SummaryText]) -> Option { + let text: String = summary + .iter() + .map(|s| s.text.as_str()) + .collect::>() + .join("\n"); + if text.is_empty() { + None + } else { + Some(MessageContent::reasoning(text)) + } +} + #[derive(Debug, Serialize, Deserialize)] #[serde(tag = "type")] #[serde(rename_all = "snake_case")] pub enum ResponseOutputItem { Reasoning { id: String, - #[serde(skip_serializing_if = "Option::is_none")] - summary: Option>, + #[serde(default)] + summary: Vec, }, Message { id: String, @@ -242,7 +262,8 @@ pub struct ResponseMetadata { pub enum ResponseOutputItemInfo { Reasoning { id: String, - summary: Vec, + #[serde(default)] + summary: Vec, }, Message { id: String, @@ -411,12 +432,25 @@ pub fn create_responses_request( add_message_items(&mut input_items, messages); + let (model_name, reasoning_effort) = extract_reasoning_effort(&model_config.model_name); + let is_reasoning_model = reasoning_effort.is_some(); + let mut payload = json!({ - "model": model_config.model_name, + "model": model_name, "input": input_items, - "store": false, // Don't store responses on server (we replay history ourselves) + "store": false, }); + if let Some(effort) = reasoning_effort { + payload.as_object_mut().unwrap().insert( + "reasoning".to_string(), + json!({ + "effort": effort, + "summary": "auto", + }), + ); + } + if !tools.is_empty() { let tools_spec: Vec = tools .iter() @@ -436,11 +470,13 @@ pub fn create_responses_request( .insert("tools".to_string(), json!(tools_spec)); } - if let Some(temp) = model_config.temperature { - payload - .as_object_mut() - .unwrap() - .insert("temperature".to_string(), json!(temp)); + if !is_reasoning_model { + if let Some(temp) = model_config.temperature { + payload + .as_object_mut() + .unwrap() + .insert("temperature".to_string(), json!(temp)); + } } payload.as_object_mut().unwrap().insert( @@ -456,8 +492,8 @@ pub fn responses_api_to_message(response: &ResponsesApiResponse) -> anyhow::Resu for item in &response.output { match item { - ResponseOutputItem::Reasoning { .. } => { - continue; + ResponseOutputItem::Reasoning { summary, .. } => { + content.extend(reasoning_from_summary(summary)); } ResponseOutputItem::Message { content: msg_content, @@ -527,8 +563,8 @@ fn process_streaming_output_items( for item in output_items { match item { - ResponseOutputItemInfo::Reasoning { .. } => { - // Skip reasoning items + ResponseOutputItemInfo::Reasoning { summary, .. } => { + content.extend(reasoning_from_summary(&summary)); } ResponseOutputItemInfo::Message { content: parts, .. } => { for part in parts { @@ -637,21 +673,23 @@ where ResponsesStreamEvent::OutputTextDelta { delta, .. } => { is_text_response = true; - accumulated_text.push_str(&delta); - - // Yield incremental text updates for true streaming - let mut content = Vec::new(); if !delta.is_empty() { - content.push(MessageContent::text(&delta)); - } - let mut msg = Message::new(Role::Assistant, chrono::Utc::now().timestamp(), content); + accumulated_text.push_str(&delta); - // Add ID so desktop client knows these deltas are part of the same message - if let Some(id) = &response_id { - msg = msg.with_id(id.clone()); - } + // Yield incremental text updates for true streaming + let mut msg = Message::new( + Role::Assistant, + chrono::Utc::now().timestamp(), + vec![MessageContent::text(&delta)], + ); - yield (Some(msg), None); + // Add ID so desktop client knows these deltas are part of the same message + if let Some(id) = &response_id { + msg = msg.with_id(id.clone()); + } + + yield (Some(msg), None); + } } ResponsesStreamEvent::OutputItemDone { item, .. } => { @@ -774,6 +812,120 @@ mod tests { Ok(()) } + #[test] + fn test_responses_api_to_message_captures_reasoning_summary() -> anyhow::Result<()> { + let response: ResponsesApiResponse = serde_json::from_value(serde_json::json!({ + "id": "resp_1", + "object": "response", + "created_at": 1737368310, + "status": "completed", + "model": "gpt-5", + "output": [ + { + "type": "reasoning", + "id": "rs_1", + "summary": [ + { "type": "summary_text", "text": "Thinking about the question..." }, + { "type": "summary_text", "text": "The answer is straightforward." } + ] + }, + { + "type": "message", + "id": "msg_1", + "status": "completed", + "role": "assistant", + "content": [ + { "type": "output_text", "text": "The capital of France is Paris." } + ] + } + ] + }))?; + + let message = responses_api_to_message(&response)?; + + let reasoning = message.content.iter().find_map(|c| c.as_reasoning()); + assert!(reasoning.is_some(), "should contain reasoning content"); + assert_eq!( + reasoning.unwrap().text, + "Thinking about the question...\nThe answer is straightforward." + ); + + let text = message.content.iter().find_map(|c| c.as_text()); + assert_eq!(text, Some("The capital of France is Paris.")); + + Ok(()) + } + + #[tokio::test] + async fn test_responses_stream_captures_reasoning_summary() -> anyhow::Result<()> { + let reasoning_item = serde_json::json!({ + "type": "reasoning", + "id": "rs_1", + "summary": [ + { "type": "summary_text", "text": "Let me think step by step." } + ] + }); + let message_item = serde_json::json!({ + "type": "message", + "id": "msg_1", + "status": "completed", + "role": "assistant", + "content": [{ "type": "output_text", "text": "Paris." }] + }); + + let lines = vec![ + format!( + r#"data: {{"type":"response.created","sequence_number":1,"response":{{"id":"resp_1","object":"response","created_at":1737368310,"status":"in_progress","model":"gpt-5","output":[]}}}}"# + ), + format!( + r#"data: {{"type":"response.output_text.delta","sequence_number":2,"item_id":"msg_1","output_index":1,"content_index":0,"delta":"Paris."}}"# + ), + format!( + r#"data: {{"type":"response.output_item.done","sequence_number":3,"output_index":0,"item":{}}}"#, + serde_json::to_string(&reasoning_item)? + ), + format!( + r#"data: {{"type":"response.output_item.done","sequence_number":4,"output_index":1,"item":{}}}"#, + serde_json::to_string(&message_item)? + ), + format!( + r#"data: {{"type":"response.completed","sequence_number":5,"response":{{"id":"resp_1","object":"response","created_at":1737368310,"status":"completed","model":"gpt-5","output":[{},{}],"usage":{{"input_tokens":10,"output_tokens":5,"total_tokens":15}}}}}}"#, + serde_json::to_string(&reasoning_item)?, + serde_json::to_string(&message_item)? + ), + "data: [DONE]".to_string(), + ]; + + let response_stream = tokio_stream::iter(lines.into_iter().map(Ok)); + let messages = responses_api_to_streaming_message(response_stream); + futures::pin_mut!(messages); + + let mut reasoning_parts = Vec::new(); + let mut text_parts = Vec::new(); + + while let Some(item) = messages.next().await { + let (message, _) = item?; + if let Some(msg) = message { + for content in msg.content { + match &content { + MessageContent::Reasoning(r) => reasoning_parts.push(r.text.clone()), + MessageContent::Text(t) => text_parts.push(t.text.clone()), + _ => {} + } + } + } + } + + assert!( + !reasoning_parts.is_empty(), + "should capture reasoning from stream" + ); + assert_eq!(reasoning_parts.join(""), "Let me think step by step."); + assert!(text_parts.concat().contains("Paris.")); + + Ok(()) + } + #[tokio::test] async fn test_responses_stream_error_event_still_returns_error() -> anyhow::Result<()> { let lines = vec![ diff --git a/crates/goose/src/providers/utils.rs b/crates/goose/src/providers/utils.rs index 5f8de42cde..7e717fee8f 100644 --- a/crates/goose/src/providers/utils.rs +++ b/crates/goose/src/providers/utils.rs @@ -193,6 +193,28 @@ pub async fn handle_response_google_compat(response: Response) -> Result (String, Option) { + let is_reasoning_model = model_name.starts_with("o1") + || model_name.starts_with("o2") + || model_name.starts_with("o3") + || model_name.starts_with("o4") + || model_name.starts_with("gpt-5"); + + if !is_reasoning_model { + return (model_name.to_string(), None); + } + + let parts: Vec<&str> = model_name.split('-').collect(); + let last_part = parts.last().unwrap(); + match *last_part { + "low" | "medium" | "high" => { + let base_name = parts[..parts.len() - 1].join("-"); + (base_name, Some(last_part.to_string())) + } + _ => (model_name.to_string(), Some("medium".to_string())), + } +} + pub fn sanitize_function_name(name: &str) -> String { static RE: OnceLock = OnceLock::new(); let re = RE.get_or_init(|| Regex::new(r"[^a-zA-Z0-9_-]").unwrap());