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(|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") => {
|
||||||
|
|||||||
@@ -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![
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
Reference in New Issue
Block a user