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(|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") => {
+14 -27
View File
@@ -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![
+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 {
static RE: OnceLock<Regex> = OnceLock::new();
let re = RE.get_or_init(|| Regex::new(r"[^a-zA-Z0-9_-]").unwrap());