feat(openai): capture reasoning summaries from responses API (#7375)

Signed-off-by: rabi <ramishra@redhat.com>
This commit is contained in:
Rabi Mishra
2026-03-11 03:06:40 +05:30
committed by GitHub
parent ac73a57ab0
commit 0fba6353e4
4 changed files with 224 additions and 61 deletions
+10 -8
View File
@@ -785,14 +785,16 @@ impl Provider for ClaudeCodeProvider {
.and_then(|d| d.get("text")) .and_then(|d| d.get("text"))
.and_then(|t| t.as_str()) .and_then(|t| t.as_str())
{ {
let mut partial_message = Message::new( if !text.is_empty() {
Role::Assistant, let mut partial_message = Message::new(
stream_timestamp, Role::Assistant,
vec![MessageContent::text(text)], stream_timestamp,
); vec![MessageContent::text(text)],
partial_message.id = );
Some(message_id.clone()); partial_message.id =
yield (Some(partial_message), None); Some(message_id.clone());
yield (Some(partial_message), None);
}
} }
} }
Some("message_start") => { Some("message_start") => {
+14 -27
View File
@@ -3,8 +3,8 @@ use crate::mcp_utils::extract_text_from_resource;
use crate::model::ModelConfig; use crate::model::ModelConfig;
use crate::providers::base::{ProviderUsage, Usage}; use crate::providers::base::{ProviderUsage, Usage};
use crate::providers::utils::{ use crate::providers::utils::{
convert_image, detect_image_path, is_valid_function_name, load_image_file, safely_parse_json, convert_image, detect_image_path, extract_reasoning_effort, is_valid_function_name,
sanitize_function_name, ImageFormat, load_image_file, safely_parse_json, sanitize_function_name, ImageFormat,
}; };
use anyhow::{anyhow, Error}; use anyhow::{anyhow, Error};
use async_stream::try_stream; 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) = extract_reasoning_effort(&model_config.model_name);
let is_reasoning_model = reasoning_effort.is_some();
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 system_message = json!({ let system_message = json!({
"role": if is_reasoning_model { "developer" } else { "system" }, "role": if is_reasoning_model { "developer" } else { "system" },
@@ -806,17 +789,21 @@ pub fn create_request(
payload["tools"] = json!(tools_spec); payload["tools"] = json!(tools_spec);
} }
// o1, o3 models currently don't support temperature
if !is_reasoning_model { if !is_reasoning_model {
if let Some(temp) = model_config.temperature { if let Some(temp) = model_config.temperature {
payload["temperature"] = json!(temp); payload["temperature"] = json!(temp);
} }
} }
payload.as_object_mut().unwrap().insert( let key = if is_reasoning_model {
"max_completion_tokens".to_string(), "max_completion_tokens"
json!(model_config.max_output_tokens()), } else {
); "max_tokens"
};
payload
.as_object_mut()
.unwrap()
.insert(key.to_string(), json!(model_config.max_output_tokens()));
if for_streaming { if for_streaming {
payload["stream"] = json!(true); payload["stream"] = json!(true);
@@ -1459,7 +1446,7 @@ mod tests {
"content": "system" "content": "system"
} }
], ],
"max_completion_tokens": 1024 "max_tokens": 1024
}); });
for (key, value) in expected.as_object().unwrap() { for (key, value) in expected.as_object().unwrap() {
@@ -1,6 +1,7 @@
use crate::conversation::message::{Message, MessageContent}; use crate::conversation::message::{Message, MessageContent};
use crate::model::ModelConfig; use crate::model::ModelConfig;
use crate::providers::base::{ProviderUsage, Usage}; use crate::providers::base::{ProviderUsage, Usage};
use crate::providers::utils::extract_reasoning_effort;
use anyhow::{anyhow, Error}; use anyhow::{anyhow, Error};
use async_stream::try_stream; use async_stream::try_stream;
use chrono; use chrono;
@@ -24,14 +25,33 @@ pub struct ResponsesApiResponse {
pub usage: Option<ResponseUsage>, 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)] #[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type")] #[serde(tag = "type")]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum ResponseOutputItem { pub enum ResponseOutputItem {
Reasoning { Reasoning {
id: String, id: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(default)]
summary: Option<Vec<String>>, summary: Vec<SummaryText>,
}, },
Message { Message {
id: String, id: String,
@@ -242,7 +262,8 @@ pub struct ResponseMetadata {
pub enum ResponseOutputItemInfo { pub enum ResponseOutputItemInfo {
Reasoning { Reasoning {
id: String, id: String,
summary: Vec<String>, #[serde(default)]
summary: Vec<SummaryText>,
}, },
Message { Message {
id: String, id: String,
@@ -411,12 +432,25 @@ pub fn create_responses_request(
add_message_items(&mut input_items, messages); 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!({ let mut payload = json!({
"model": model_config.model_name, "model": model_name,
"input": input_items, "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() { if !tools.is_empty() {
let tools_spec: Vec<Value> = tools let tools_spec: Vec<Value> = tools
.iter() .iter()
@@ -436,11 +470,13 @@ pub fn create_responses_request(
.insert("tools".to_string(), json!(tools_spec)); .insert("tools".to_string(), json!(tools_spec));
} }
if let Some(temp) = model_config.temperature { if !is_reasoning_model {
payload if let Some(temp) = model_config.temperature {
.as_object_mut() payload
.unwrap() .as_object_mut()
.insert("temperature".to_string(), json!(temp)); .unwrap()
.insert("temperature".to_string(), json!(temp));
}
} }
payload.as_object_mut().unwrap().insert( 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 { for item in &response.output {
match item { match item {
ResponseOutputItem::Reasoning { .. } => { ResponseOutputItem::Reasoning { summary, .. } => {
continue; content.extend(reasoning_from_summary(summary));
} }
ResponseOutputItem::Message { ResponseOutputItem::Message {
content: msg_content, content: msg_content,
@@ -527,8 +563,8 @@ fn process_streaming_output_items(
for item in output_items { for item in output_items {
match item { match item {
ResponseOutputItemInfo::Reasoning { .. } => { ResponseOutputItemInfo::Reasoning { summary, .. } => {
// Skip reasoning items content.extend(reasoning_from_summary(&summary));
} }
ResponseOutputItemInfo::Message { content: parts, .. } => { ResponseOutputItemInfo::Message { content: parts, .. } => {
for part in parts { for part in parts {
@@ -637,21 +673,23 @@ where
ResponsesStreamEvent::OutputTextDelta { delta, .. } => { ResponsesStreamEvent::OutputTextDelta { delta, .. } => {
is_text_response = true; 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() { if !delta.is_empty() {
content.push(MessageContent::text(&delta)); accumulated_text.push_str(&delta);
}
let mut msg = Message::new(Role::Assistant, chrono::Utc::now().timestamp(), content);
// Add ID so desktop client knows these deltas are part of the same message // Yield incremental text updates for true streaming
if let Some(id) = &response_id { let mut msg = Message::new(
msg = msg.with_id(id.clone()); 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, .. } => { ResponsesStreamEvent::OutputItemDone { item, .. } => {
@@ -774,6 +812,120 @@ mod tests {
Ok(()) 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] #[tokio::test]
async fn test_responses_stream_error_event_still_returns_error() -> anyhow::Result<()> { async fn test_responses_stream_error_event_still_returns_error() -> anyhow::Result<()> {
let lines = vec![ let lines = vec![
+22
View File
@@ -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 { pub fn sanitize_function_name(name: &str) -> String {
static RE: OnceLock<Regex> = OnceLock::new(); static RE: OnceLock<Regex> = OnceLock::new();
let re = RE.get_or_init(|| Regex::new(r"[^a-zA-Z0-9_-]").unwrap()); let re = RE.get_or_init(|| Regex::new(r"[^a-zA-Z0-9_-]").unwrap());