Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions crates/goose/src/providers/claude_code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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") => {
Expand Down
41 changes: 14 additions & 27 deletions crates/goose/src/providers/formats/openai.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -769,25 +769,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" },
Expand Down Expand Up @@ -815,17 +798,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);
Expand Down Expand Up @@ -1507,7 +1494,7 @@ mod tests {
"content": "system"
}
],
"max_completion_tokens": 1024
"max_tokens": 1024
});

for (key, value) in expected.as_object().unwrap() {
Expand Down
204 changes: 178 additions & 26 deletions crates/goose/src/providers/formats/openai_responses.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -242,7 +262,8 @@ pub struct ResponseMetadata {
pub enum ResponseOutputItemInfo {
Reasoning {
id: String,
summary: Vec<String>,
#[serde(default)]
summary: Vec<SummaryText>,
},
Message {
id: String,
Expand Down Expand Up @@ -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()
Expand All @@ -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(
Expand All @@ -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,
Expand Down Expand Up @@ -535,8 +571,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 {
Expand Down Expand Up @@ -654,21 +690,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)],
);

// 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);
yield (Some(msg), None);
}
}

ResponsesStreamEvent::OutputItemDone { item, .. } => {
Expand Down Expand Up @@ -791,6 +829,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![
Expand Down
Loading