diff --git a/crates/goose/src/providers/formats/openai.rs b/crates/goose/src/providers/formats/openai.rs index b553260b5684..a9544f5213f5 100644 --- a/crates/goose/src/providers/formats/openai.rs +++ b/crates/goose/src/providers/formats/openai.rs @@ -18,6 +18,79 @@ use serde_json::{json, Value}; use std::borrow::Cow; use std::ops::Deref; +// ============================================================================ +// Responses API Types (for gpt-5.1-codex and similar models) +// ============================================================================ + +#[derive(Debug, Serialize, Deserialize)] +pub struct ResponsesApiResponse { + pub id: String, + pub object: String, + pub created_at: i64, + pub status: String, + pub model: String, + pub output: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub usage: Option, +} + +#[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>, + }, + Message { + id: String, + status: String, + role: String, + content: Vec, + }, + FunctionCall { + id: String, + status: String, + #[serde(skip_serializing_if = "Option::is_none")] + call_id: Option, + name: String, + arguments: String, + }, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "type")] +#[serde(rename_all = "snake_case")] +pub enum ResponseContentBlock { + OutputText { + text: String, + #[serde(skip_serializing_if = "Option::is_none")] + annotations: Option>, + }, + ToolCall { + id: String, + name: String, + input: Value, + }, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ResponseReasoningInfo { + pub effort: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ResponseUsage { + pub input_tokens: i32, + pub output_tokens: i32, + pub total_tokens: i32, +} + #[derive(Serialize, Deserialize, Debug)] struct DeltaToolCallFunction { name: Option, @@ -604,6 +677,179 @@ where } } +// ============================================================================ +// Responses API Helper Functions +// ============================================================================ + +pub fn create_responses_request( + model_config: &ModelConfig, + system: &str, + messages: &[Message], + tools: &[Tool], +) -> anyhow::Result { + let mut conversation_parts = Vec::new(); + + for message in messages.iter().filter(|m| m.is_agent_visible()) { + for content in &message.content { + match content { + MessageContent::Text(text) if !text.text.is_empty() => { + let role_str = if message.role == Role::User { + "User" + } else { + "Assistant" + }; + conversation_parts.push(format!("{}: {}", role_str, text.text)); + } + MessageContent::ToolRequest(request) => { + if let Ok(tool_call) = &request.tool_call { + conversation_parts.push(format!( + "Tool Call: {} with arguments {:?}", + tool_call.name, tool_call.arguments + )); + } + } + MessageContent::ToolResponse(response) => { + if let Ok(contents) = &response.tool_result { + let text_content: Vec = contents + .iter() + .filter_map(|c| { + if let RawContent::Text(t) = c.deref() { + Some(t.text.clone()) + } else { + None + } + }) + .collect(); + if !text_content.is_empty() { + conversation_parts + .push(format!("Tool Result: {}", text_content.join(" "))); + } + } + } + _ => {} + } + } + } + + let input = if conversation_parts.is_empty() { + "Hello".to_string() + } else { + conversation_parts.join("\n\n") + }; + + let mut payload = json!({ + "model": model_config.model_name, + "input": input, + "instructions": system, + }); + + if !tools.is_empty() { + let tools_spec: Vec = tools + .iter() + .map(|tool| { + json!({ + "type": "function", + "name": tool.name, + "description": tool.description, + "parameters": tool.input_schema, + }) + }) + .collect(); + + payload + .as_object_mut() + .unwrap() + .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 let Some(tokens) = model_config.max_tokens { + payload + .as_object_mut() + .unwrap() + .insert("max_output_tokens".to_string(), json!(tokens)); + } + + Ok(payload) +} + +pub fn responses_api_to_message(response: &ResponsesApiResponse) -> anyhow::Result { + let mut content = Vec::new(); + + for item in &response.output { + match item { + ResponseOutputItem::Reasoning { .. } => { + continue; + } + ResponseOutputItem::Message { + content: msg_content, + .. + } => { + for block in msg_content { + match block { + ResponseContentBlock::OutputText { text, .. } => { + if !text.is_empty() { + content.push(MessageContent::text(text)); + } + } + ResponseContentBlock::ToolCall { id, name, input } => { + content.push(MessageContent::tool_request( + id.clone(), + Ok(CallToolRequestParam { + name: name.clone().into(), + arguments: Some(object(input.clone())), + }), + )); + } + } + } + } + ResponseOutputItem::FunctionCall { + id, + name, + arguments, + .. + } => { + let parsed_args = if arguments.is_empty() { + json!({}) + } else { + serde_json::from_str(arguments).unwrap_or_else(|_| json!({})) + }; + + content.push(MessageContent::tool_request( + id.clone(), + Ok(CallToolRequestParam { + name: name.clone().into(), + arguments: Some(object(parsed_args)), + }), + )); + } + } + } + + let mut message = Message::new(Role::Assistant, chrono::Utc::now().timestamp(), content); + + message = message.with_id(response.id.clone()); + + Ok(message) +} + +pub fn get_responses_usage(response: &ResponsesApiResponse) -> Usage { + response.usage.as_ref().map_or_else(Usage::default, |u| { + Usage::new( + Some(u.input_tokens), + Some(u.output_tokens), + Some(u.total_tokens), + ) + }) +} + pub fn create_request( model_config: &ModelConfig, system: &str, diff --git a/crates/goose/src/providers/openai.rs b/crates/goose/src/providers/openai.rs index dca3c0c6bdd7..d3f72d235fb3 100644 --- a/crates/goose/src/providers/openai.rs +++ b/crates/goose/src/providers/openai.rs @@ -15,7 +15,10 @@ use super::api_client::{ApiClient, AuthMethod}; use super::base::{ConfigKey, ModelInfo, Provider, ProviderMetadata, ProviderUsage, Usage}; use super::embedding::{EmbeddingCapable, EmbeddingRequest, EmbeddingResponse}; use super::errors::ProviderError; -use super::formats::openai::{create_request, get_usage, response_to_message}; +use super::formats::openai::{ + create_request, create_responses_request, get_responses_usage, get_usage, response_to_message, + responses_api_to_message, ResponsesApiResponse, +}; use super::retry::ProviderRetry; use super::utils::{ get_model, handle_response_openai_compat, handle_status_openai_compat, ImageFormat, @@ -41,6 +44,8 @@ pub const OPEN_AI_KNOWN_MODELS: &[(&str, usize)] = &[ ("gpt-3.5-turbo", 16_385), ("gpt-4-turbo", 128_000), ("o4-mini", 128_000), + ("gpt-5.1-codex", 400_000), + ("gpt-5-codex", 400_000), ]; pub const OPEN_AI_DOC_URL: &str = "https://platform.openai.com/docs/models"; @@ -184,6 +189,11 @@ impl OpenAiProvider { }) } + fn uses_responses_api(model_name: &str) -> bool { + model_name.starts_with("gpt-5-codex") + || model_name.starts_with("gpt-5.1-codex") + } + async fn post(&self, payload: &Value) -> Result { let response = self .api_client @@ -191,6 +201,14 @@ impl OpenAiProvider { .await?; handle_response_openai_compat(response).await } + + async fn post_responses(&self, payload: &Value) -> Result { + let response = self + .api_client + .response_post("v1/responses", payload) + .await?; + handle_response_openai_compat(response).await + } } #[async_trait] @@ -238,31 +256,62 @@ impl Provider for OpenAiProvider { messages: &[Message], tools: &[Tool], ) -> Result<(Message, ProviderUsage), ProviderError> { - let payload = create_request(model_config, system, messages, tools, &ImageFormat::OpenAi)?; - - let mut log = RequestLog::start(&self.model, &payload)?; - let json_response = self - .with_retry(|| async { - let payload_clone = payload.clone(); - self.post(&payload_clone).await - }) - .await - .inspect_err(|e| { - let _ = log.error(e); - })?; - - let message = response_to_message(&json_response)?; - let usage = json_response - .get("usage") - .map(get_usage) - .unwrap_or_else(|| { - tracing::debug!("Failed to get usage data"); - Usage::default() - }); - - let model = get_model(&json_response); - log.write(&json_response, Some(&usage))?; - Ok((message, ProviderUsage::new(model, usage))) + if Self::uses_responses_api(&model_config.model_name) { + let payload = create_responses_request(model_config, system, messages, tools)?; + let mut log = RequestLog::start(&self.model, &payload)?; + + let json_response = self + .with_retry(|| async { + let payload_clone = payload.clone(); + self.post_responses(&payload_clone).await + }) + .await + .inspect_err(|e| { + let _ = log.error(e); + })?; + + let responses_api_response: ResponsesApiResponse = + serde_json::from_value(json_response.clone()).map_err(|e| { + ProviderError::ExecutionError(format!( + "Failed to parse responses API response: {}", + e + )) + })?; + + let message = responses_api_to_message(&responses_api_response)?; + let usage = get_responses_usage(&responses_api_response); + let model = responses_api_response.model.clone(); + + log.write(&json_response, Some(&usage))?; + Ok((message, ProviderUsage::new(model, usage))) + } else { + let payload = + create_request(model_config, system, messages, tools, &ImageFormat::OpenAi)?; + + let mut log = RequestLog::start(&self.model, &payload)?; + let json_response = self + .with_retry(|| async { + let payload_clone = payload.clone(); + self.post(&payload_clone).await + }) + .await + .inspect_err(|e| { + let _ = log.error(e); + })?; + + let message = response_to_message(&json_response)?; + let usage = json_response + .get("usage") + .map(get_usage) + .unwrap_or_else(|| { + tracing::debug!("Failed to get usage data"); + Usage::default() + }); + + let model = get_model(&json_response); + log.write(&json_response, Some(&usage))?; + Ok((message, ProviderUsage::new(model, usage))) + } } async fn fetch_supported_models(&self) -> Result>, ProviderError> { @@ -319,48 +368,59 @@ impl Provider for OpenAiProvider { messages: &[Message], tools: &[Tool], ) -> Result { - let mut payload = - create_request(&self.model, system, messages, tools, &ImageFormat::OpenAi)?; - payload["stream"] = serde_json::Value::Bool(true); - payload["stream_options"] = json!({ - "include_usage": true, - }); - let mut log = RequestLog::start(&self.model, &payload)?; - - let response = self - .with_retry(|| async { - let resp = self - .api_client - .response_post(&self.base_path, &payload) - .await?; - let status = resp.status(); - if !status.is_success() { - return Err(super::utils::map_http_error_to_provider_error( - status, None, // We'll let handle_status_openai_compat parse the error - )); + if Self::uses_responses_api(&self.model.model_name) { + let (message, usage) = self + .complete_with_model(&self.model, system, messages, tools) + .await?; + + Ok(Box::pin(try_stream! { + yield (Some(message), Some(usage)); + })) + } else { + let mut payload = + create_request(&self.model, system, messages, tools, &ImageFormat::OpenAi)?; + payload["stream"] = serde_json::Value::Bool(true); + payload["stream_options"] = json!({ + "include_usage": true, + }); + let mut log = RequestLog::start(&self.model, &payload)?; + + let response = self + .with_retry(|| async { + let resp = self + .api_client + .response_post(&self.base_path, &payload) + .await?; + let status = resp.status(); + if !status.is_success() { + return Err(super::utils::map_http_error_to_provider_error( + status, + None, // We'll let handle_status_openai_compat parse the error + )); + } + Ok(resp) + }) + .await + .inspect_err(|e| { + let _ = log.error(e); + })?; + let response = handle_status_openai_compat(response).await?; + + let stream = response.bytes_stream().map_err(io::Error::other); + + Ok(Box::pin(try_stream! { + let stream_reader = StreamReader::new(stream); + let framed = FramedRead::new(stream_reader, LinesCodec::new()).map_err(anyhow::Error::from); + + let message_stream = response_to_streaming_message(framed); + pin!(message_stream); + while let Some(message) = message_stream.next().await { + let (message, usage) = message.map_err(|e| ProviderError::RequestFailed(format!("Stream decode error: {}", e)))?; + log.write(&message, usage.as_ref().map(|f| f.usage).as_ref())?; + yield (message, usage); } - Ok(resp) - }) - .await - .inspect_err(|e| { - let _ = log.error(e); - })?; - let response = handle_status_openai_compat(response).await?; - - let stream = response.bytes_stream().map_err(io::Error::other); - - Ok(Box::pin(try_stream! { - let stream_reader = StreamReader::new(stream); - let framed = FramedRead::new(stream_reader, LinesCodec::new()).map_err(anyhow::Error::from); - - let message_stream = response_to_streaming_message(framed); - pin!(message_stream); - while let Some(message) = message_stream.next().await { - let (message, usage) = message.map_err(|e| ProviderError::RequestFailed(format!("Stream decode error: {}", e)))?; - log.write(&message, usage.as_ref().map(|f| f.usage).as_ref())?; - yield (message, usage); - } - })) + })) + } } }