mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
feat(openai): capture reasoning summaries from responses API (#7375)
Signed-off-by: rabi <ramishra@redhat.com>
This commit is contained in:
@@ -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") => {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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<ResponseUsage>,
|
||||
}
|
||||
|
||||
#[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<MessageContent> {
|
||||
let text: String = summary
|
||||
.iter()
|
||||
.map(|s| s.text.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.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<Vec<String>>,
|
||||
#[serde(default)]
|
||||
summary: Vec<SummaryText>,
|
||||
},
|
||||
Message {
|
||||
id: String,
|
||||
@@ -242,7 +262,8 @@ pub struct ResponseMetadata {
|
||||
pub enum ResponseOutputItemInfo {
|
||||
Reasoning {
|
||||
id: String,
|
||||
summary: Vec<String>,
|
||||
#[serde(default)]
|
||||
summary: Vec<SummaryText>,
|
||||
},
|
||||
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<Value> = 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![
|
||||
|
||||
@@ -193,6 +193,28 @@ pub async fn handle_response_google_compat(response: Response) -> Result<Value,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extract_reasoning_effort(model_name: &str) -> (String, Option<String>) {
|
||||
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<Regex> = OnceLock::new();
|
||||
let re = RE.get_or_init(|| Regex::new(r"[^a-zA-Z0-9_-]").unwrap());
|
||||
|
||||
Reference in New Issue
Block a user