From 36a33367c469ceb09c25fa1436c0913a5da14075 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Wed, 3 Jun 2026 10:34:19 -0400 Subject: [PATCH] refactor: move openai format DTOs Signed-off-by: Jack Amadeo --- crates/goose-providers/src/lib.rs | 2 + crates/goose-providers/src/openai.rs | 134 ++++++++ .../goose-providers/src/openai_responses.rs | 324 ++++++++++++++++++ crates/goose/src/providers/formats/openai.rs | 116 +------ .../src/providers/formats/openai_responses.rs | 257 +------------- 5 files changed, 469 insertions(+), 364 deletions(-) create mode 100644 crates/goose-providers/src/openai.rs create mode 100644 crates/goose-providers/src/openai_responses.rs diff --git a/crates/goose-providers/src/lib.rs b/crates/goose-providers/src/lib.rs index d622f3ba40d5..4cbf29cacded 100644 --- a/crates/goose-providers/src/lib.rs +++ b/crates/goose-providers/src/lib.rs @@ -10,6 +10,8 @@ pub mod image; pub mod json; pub mod metadata; pub mod models; +pub mod openai; +pub mod openai_responses; pub mod provider; pub mod request_log; pub mod retry; diff --git a/crates/goose-providers/src/openai.rs b/crates/goose-providers/src/openai.rs new file mode 100644 index 000000000000..7fb67b9a2e6e --- /dev/null +++ b/crates/goose-providers/src/openai.rs @@ -0,0 +1,134 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; + +pub type ToolCallData = HashMap< + i32, + ( + String, + String, + String, + Option>, + ), +>; + +#[derive(Debug, Clone, Copy, Default)] +pub struct OpenAiFormatOptions { + pub preserve_thinking_context: bool, +} + +fn deserialize_null_default_string<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + Ok(Option::::deserialize(deserializer)?.unwrap_or_default()) +} + +#[derive(Serialize, Deserialize, Debug, Default)] +pub struct DeltaToolCallFunction { + pub name: Option, + #[serde(default, deserialize_with = "deserialize_null_default_string")] + pub arguments: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct DeltaToolCall { + pub id: Option, + pub function: DeltaToolCallFunction, + pub index: Option, + pub r#type: Option, + #[serde(flatten)] + pub extra: Option>, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(untagged)] +pub enum DeltaContent { + String(String), + Array(Vec), +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct ChatContentPart { + pub r#type: String, + #[serde(default)] + pub text: Option, + #[serde(rename = "thoughtSignature")] + pub thought_signature: Option, +} + +#[derive(Serialize, Deserialize, Debug, Default)] +pub struct Delta { + #[serde(default)] + pub content: Option, + pub role: Option, + pub tool_calls: Option>, + pub reasoning_details: Option>, + pub reasoning: Option, + pub reasoning_content: Option, +} + +impl Delta { + pub fn reasoning_text(&self) -> Option<&str> { + self.reasoning_content + .as_deref() + .filter(|s| !s.is_empty()) + .or_else(|| self.reasoning.as_deref().filter(|s| !s.is_empty())) + } +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct StreamingChoice { + #[serde(default)] + pub delta: Delta, + pub index: Option, + pub finish_reason: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct StreamingChunk { + pub choices: Vec, + pub created: Option, + pub id: Option, + pub usage: Option, + pub model: Option, +} + +pub fn merge_reasoning_text(prefix: &str, suffix: &str) -> String { + if prefix.is_empty() { + return suffix.to_string(); + } + if suffix.is_empty() { + return prefix.to_string(); + } + if suffix.starts_with(prefix) { + return suffix.to_string(); + } + if prefix.ends_with(suffix) { + return prefix.to_string(); + } + + format!("{prefix}{suffix}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserializes_null_tool_call_arguments_as_empty_string() { + let parsed: DeltaToolCallFunction = serde_json::from_str(r#"{"arguments":null}"#).unwrap(); + assert_eq!(parsed.arguments, ""); + } + + #[test] + fn reasoning_text_prefers_reasoning_content() { + let delta = Delta { + reasoning: Some("fallback".to_string()), + reasoning_content: Some("preferred".to_string()), + ..Default::default() + }; + + assert_eq!(delta.reasoning_text(), Some("preferred")); + } +} diff --git a/crates/goose-providers/src/openai_responses.rs b/crates/goose-providers/src/openai_responses.rs new file mode 100644 index 000000000000..0ea55b5476d2 --- /dev/null +++ b/crates/goose-providers/src/openai_responses.rs @@ -0,0 +1,324 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[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, Clone)] +#[serde(tag = "type", rename_all = "snake_case")] +pub struct SummaryText { + pub text: String, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "type")] +#[serde(rename_all = "snake_case")] +pub enum ResponseOutputItem { + Reasoning { + id: String, + #[serde(default)] + summary: Vec, + }, + 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>, + }, + Refusal { + refusal: String, + }, + ToolCall { + id: String, + name: String, + input: Value, + }, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ResponseReasoningInfo { + pub effort: Option, + #[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(Debug, Serialize, Deserialize)] +#[serde(tag = "type")] +#[serde(rename_all = "snake_case")] +pub enum ResponsesStreamEvent { + #[serde(rename = "response.created")] + ResponseCreated { + sequence_number: i32, + response: ResponseMetadata, + }, + #[serde(rename = "response.in_progress")] + ResponseInProgress { + sequence_number: i32, + response: ResponseMetadata, + }, + #[serde(rename = "response.output_item.added")] + OutputItemAdded { + sequence_number: i32, + output_index: i32, + item: ResponseOutputItemInfo, + }, + #[serde(rename = "response.content_part.added")] + ContentPartAdded { + sequence_number: i32, + item_id: String, + output_index: i32, + content_index: i32, + part: ContentPart, + }, + #[serde(rename = "response.output_text.delta")] + OutputTextDelta { + sequence_number: i32, + item_id: String, + output_index: i32, + content_index: i32, + delta: String, + #[serde(skip_serializing_if = "Option::is_none")] + logprobs: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + obfuscation: Option, + }, + #[serde(rename = "response.output_item.done")] + OutputItemDone { + sequence_number: i32, + output_index: i32, + item: ResponseOutputItemInfo, + }, + #[serde(rename = "response.content_part.done")] + ContentPartDone { + sequence_number: i32, + item_id: String, + output_index: i32, + content_index: i32, + part: ContentPart, + }, + #[serde(rename = "response.output_text.done")] + OutputTextDone { + sequence_number: i32, + item_id: String, + output_index: i32, + content_index: i32, + text: String, + #[serde(skip_serializing_if = "Option::is_none")] + logprobs: Option>, + }, + #[serde(rename = "response.completed")] + ResponseCompleted { + sequence_number: i32, + response: ResponseMetadata, + }, + #[serde(rename = "response.failed")] + ResponseFailed { sequence_number: i32, error: Value }, + #[serde(rename = "response.function_call_arguments.delta")] + FunctionCallArgumentsDelta { + sequence_number: i32, + item_id: String, + output_index: i32, + delta: String, + #[serde(skip_serializing_if = "Option::is_none")] + obfuscation: Option, + }, + #[serde(rename = "response.function_call_arguments.done")] + FunctionCallArgumentsDone { + sequence_number: i32, + item_id: String, + output_index: i32, + arguments: String, + }, + #[serde(rename = "response.refusal.delta")] + RefusalDelta { + sequence_number: i32, + item_id: String, + output_index: i32, + content_index: i32, + delta: String, + }, + #[serde(rename = "response.refusal.done")] + RefusalDone { + sequence_number: i32, + item_id: String, + output_index: i32, + content_index: i32, + refusal: String, + }, + #[serde(rename = "error")] + Error { error: Value }, + #[serde(rename = "keepalive")] + Keepalive { + #[serde(default)] + sequence_number: Option, + }, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ResponseMetadata { + pub id: String, + pub object: String, + pub created_at: i64, + pub status: String, + pub model: String, + #[serde(default)] + pub output: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub usage: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(tag = "type")] +#[serde(rename_all = "snake_case")] +pub enum ResponseOutputItemInfo { + Reasoning { + id: String, + #[serde(default)] + summary: Vec, + }, + 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, Clone)] +#[serde(tag = "type")] +#[serde(rename_all = "snake_case")] +pub enum ContentPart { + OutputText { + text: String, + #[serde(skip_serializing_if = "Option::is_none")] + annotations: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + logprobs: Option>, + }, + Refusal { + refusal: String, + }, + ToolCall { + id: String, + name: String, + arguments: String, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserializes_reasoning_info_with_null_effort() { + let info: ResponseReasoningInfo = serde_json::from_str(r#"{"effort": null}"#).unwrap(); + assert_eq!(info.effort, None); + assert_eq!(info.summary, None); + } + + #[test] + fn deserializes_reasoning_info_with_effort() { + let info: ResponseReasoningInfo = + serde_json::from_str(r#"{"effort": "high", "summary": "Thought deeply"}"#).unwrap(); + assert_eq!(info.effort.as_deref(), Some("high")); + assert_eq!(info.summary.as_deref(), Some("Thought deeply")); + } + + #[test] + fn deserializes_refusal_content_block() { + let block: ResponseContentBlock = serde_json::from_str( + r#"{"type":"refusal","refusal":"I cannot help with that request."}"#, + ) + .unwrap(); + + match block { + ResponseContentBlock::Refusal { refusal } => { + assert_eq!(refusal, "I cannot help with that request."); + } + _ => panic!("expected refusal block"), + } + } + + #[test] + fn deserializes_refusal_content_part() { + let item: ResponseOutputItemInfo = serde_json::from_str( + r#"{ + "type": "message", + "id": "msg_1", + "status": "completed", + "role": "assistant", + "content": [{"type": "refusal", "refusal": "I'm unable to assist."}] + }"#, + ) + .unwrap(); + + match item { + ResponseOutputItemInfo::Message { content, .. } => { + assert!(matches!(content[0], ContentPart::Refusal { .. })); + } + _ => panic!("expected message output item"), + } + } + + #[test] + fn deserializes_refusal_delta_stream_event() { + let event: ResponsesStreamEvent = serde_json::from_str( + r#"{"type":"response.refusal.delta","sequence_number":5,"item_id":"msg_1","output_index":0,"content_index":0,"delta":"I cannot"}"#, + ) + .unwrap(); + + match event { + ResponsesStreamEvent::RefusalDelta { delta, .. } => { + assert_eq!(delta, "I cannot"); + } + _ => panic!("expected RefusalDelta event"), + } + } +} diff --git a/crates/goose/src/providers/formats/openai.rs b/crates/goose/src/providers/formats/openai.rs index 774edcdc7c5b..8992534f2342 100644 --- a/crates/goose/src/providers/formats/openai.rs +++ b/crates/goose/src/providers/formats/openai.rs @@ -12,132 +12,24 @@ use anyhow::{anyhow, Error}; use async_stream::try_stream; use chrono; use futures::Stream; +#[cfg(test)] +use goose_providers::openai::DeltaToolCallFunction; +pub use goose_providers::openai::OpenAiFormatOptions; +use goose_providers::openai::{merge_reasoning_text, DeltaContent, StreamingChunk, ToolCallData}; use goose_types::ModelConfig; use rmcp::model::{ object, AnnotateAble, CallToolRequestParams, Content, ErrorCode, ErrorData, RawContent, Role, Tool, }; -use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::borrow::Cow; use std::collections::HashMap; use std::ops::Deref; -type ToolCallData = HashMap< - i32, - ( - String, - String, - String, - Option>, - ), ->; - -fn deserialize_null_default_string<'de, D>(deserializer: D) -> Result -where - D: serde::Deserializer<'de>, -{ - Ok(Option::::deserialize(deserializer)?.unwrap_or_default()) -} - fn is_reserved_request_param_key(key: &str) -> bool { matches!(key, "messages" | "model" | "stream" | "stream_options") } -#[derive(Debug, Clone, Copy, Default)] -pub struct OpenAiFormatOptions { - pub preserve_thinking_context: bool, -} - -fn merge_reasoning_text(prefix: &str, suffix: &str) -> String { - if prefix.is_empty() { - return suffix.to_string(); - } - if suffix.is_empty() { - return prefix.to_string(); - } - if suffix.starts_with(prefix) { - return suffix.to_string(); - } - if prefix.ends_with(suffix) { - return prefix.to_string(); - } - - format!("{prefix}{suffix}") -} - -#[derive(Serialize, Deserialize, Debug, Default)] -struct DeltaToolCallFunction { - name: Option, - #[serde(default, deserialize_with = "deserialize_null_default_string")] - arguments: String, -} - -#[derive(Serialize, Deserialize, Debug)] -struct DeltaToolCall { - id: Option, - function: DeltaToolCallFunction, - index: Option, - r#type: Option, - #[serde(flatten)] - extra: Option>, -} - -#[derive(Serialize, Deserialize, Debug)] -#[serde(untagged)] -enum DeltaContent { - String(String), - Array(Vec), -} - -#[derive(Serialize, Deserialize, Debug)] -struct ContentPart { - r#type: String, - #[serde(default)] - text: Option, - #[serde(rename = "thoughtSignature")] - thought_signature: Option, -} - -#[derive(Serialize, Deserialize, Debug, Default)] -struct Delta { - #[serde(default)] - content: Option, - role: Option, - tool_calls: Option>, - reasoning_details: Option>, - reasoning: Option, - reasoning_content: Option, -} - -impl Delta { - /// Prefer `reasoning_content` (DeepSeek/OpenRouter) over `reasoning` - /// (vLLM); some servers (gpt-oss via vLLM) emit both. Skip empty values. - fn reasoning_text(&self) -> Option<&str> { - self.reasoning_content - .as_deref() - .filter(|s| !s.is_empty()) - .or_else(|| self.reasoning.as_deref().filter(|s| !s.is_empty())) - } -} - -#[derive(Serialize, Deserialize, Debug)] -struct StreamingChoice { - #[serde(default)] - delta: Delta, - index: Option, - finish_reason: Option, -} - -#[derive(Serialize, Deserialize, Debug)] -struct StreamingChunk { - choices: Vec, - created: Option, - id: Option, - usage: Option, - model: Option, -} - fn extract_content_and_signature( delta_content: Option<&DeltaContent>, ) -> (Option, Option) { diff --git a/crates/goose/src/providers/formats/openai_responses.rs b/crates/goose/src/providers/formats/openai_responses.rs index 8b4eb6859b6d..b93af2f18ac6 100644 --- a/crates/goose/src/providers/formats/openai_responses.rs +++ b/crates/goose/src/providers/formats/openai_responses.rs @@ -9,32 +9,16 @@ use anyhow::{anyhow, Error}; use async_stream::try_stream; use chrono; use futures::Stream; +pub use goose_providers::openai_responses::{ + ContentPart, ResponseContentBlock, ResponseMetadata, ResponseOutputItem, + ResponseOutputItemInfo, ResponseReasoningInfo, ResponseUsage, ResponsesApiResponse, + ResponsesStreamEvent, SummaryText, +}; use goose_types::ModelConfig; use rmcp::model::{object, CallToolRequestParams, RawContent, Role, Tool}; -use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::ops::Deref; -#[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, Clone)] -#[serde(tag = "type", rename_all = "snake_case")] -pub struct SummaryText { - pub text: String, -} - fn reasoning_from_summary(summary: &[SummaryText]) -> Option { let text: String = summary .iter() @@ -48,176 +32,6 @@ fn reasoning_from_summary(summary: &[SummaryText]) -> Option { } } -#[derive(Debug, Serialize, Deserialize)] -#[serde(tag = "type")] -#[serde(rename_all = "snake_case")] -pub enum ResponseOutputItem { - Reasoning { - id: String, - #[serde(default)] - summary: Vec, - }, - 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>, - }, - Refusal { - refusal: String, - }, - ToolCall { - id: String, - name: String, - input: Value, - }, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct ResponseReasoningInfo { - pub effort: Option, - #[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(Debug, Serialize, Deserialize)] -#[serde(tag = "type")] -#[serde(rename_all = "snake_case")] -pub enum ResponsesStreamEvent { - #[serde(rename = "response.created")] - ResponseCreated { - sequence_number: i32, - response: ResponseMetadata, - }, - #[serde(rename = "response.in_progress")] - ResponseInProgress { - sequence_number: i32, - response: ResponseMetadata, - }, - #[serde(rename = "response.output_item.added")] - OutputItemAdded { - sequence_number: i32, - output_index: i32, - item: ResponseOutputItemInfo, - }, - #[serde(rename = "response.content_part.added")] - ContentPartAdded { - sequence_number: i32, - item_id: String, - output_index: i32, - content_index: i32, - part: ContentPart, - }, - #[serde(rename = "response.output_text.delta")] - OutputTextDelta { - sequence_number: i32, - item_id: String, - output_index: i32, - content_index: i32, - delta: String, - #[serde(skip_serializing_if = "Option::is_none")] - logprobs: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - obfuscation: Option, - }, - #[serde(rename = "response.output_item.done")] - OutputItemDone { - sequence_number: i32, - output_index: i32, - item: ResponseOutputItemInfo, - }, - #[serde(rename = "response.content_part.done")] - ContentPartDone { - sequence_number: i32, - item_id: String, - output_index: i32, - content_index: i32, - part: ContentPart, - }, - #[serde(rename = "response.output_text.done")] - OutputTextDone { - sequence_number: i32, - item_id: String, - output_index: i32, - content_index: i32, - text: String, - #[serde(skip_serializing_if = "Option::is_none")] - logprobs: Option>, - }, - #[serde(rename = "response.completed")] - ResponseCompleted { - sequence_number: i32, - response: ResponseMetadata, - }, - #[serde(rename = "response.failed")] - ResponseFailed { sequence_number: i32, error: Value }, - #[serde(rename = "response.function_call_arguments.delta")] - FunctionCallArgumentsDelta { - sequence_number: i32, - item_id: String, - output_index: i32, - delta: String, - #[serde(skip_serializing_if = "Option::is_none")] - obfuscation: Option, - }, - #[serde(rename = "response.function_call_arguments.done")] - FunctionCallArgumentsDone { - sequence_number: i32, - item_id: String, - output_index: i32, - arguments: String, - }, - #[serde(rename = "response.refusal.delta")] - RefusalDelta { - sequence_number: i32, - item_id: String, - output_index: i32, - content_index: i32, - delta: String, - }, - #[serde(rename = "response.refusal.done")] - RefusalDone { - sequence_number: i32, - item_id: String, - output_index: i32, - content_index: i32, - refusal: String, - }, - #[serde(rename = "error")] - Error { error: Value }, - #[serde(rename = "keepalive")] - Keepalive { - #[serde(default)] - sequence_number: Option, - }, -} - fn is_known_responses_stream_event_type(event_type: &str) -> bool { matches!( event_type, @@ -267,67 +81,6 @@ fn parse_responses_stream_event(data_line: &str) -> anyhow::Result, - #[serde(skip_serializing_if = "Option::is_none")] - pub usage: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub reasoning: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(tag = "type")] -#[serde(rename_all = "snake_case")] -pub enum ResponseOutputItemInfo { - Reasoning { - id: String, - #[serde(default)] - summary: Vec, - }, - 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, Clone)] -#[serde(tag = "type")] -#[serde(rename_all = "snake_case")] -pub enum ContentPart { - OutputText { - text: String, - #[serde(skip_serializing_if = "Option::is_none")] - annotations: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - logprobs: Option>, - }, - Refusal { - refusal: String, - }, - ToolCall { - id: String, - name: String, - arguments: String, - }, -} - fn add_message_items(input_items: &mut Vec, messages: &[Message]) { for message in messages.iter().filter(|m| m.is_agent_visible()) { let role = match message.role {