From f3c3563c66eb967569f115ed46674c8f4790c77a Mon Sep 17 00:00:00 2001 From: "2:5030/1559" Date: Wed, 19 Aug 2026 13:56:50 +0000 Subject: [PATCH] Bug fix: Goose unable to work with DeepSeek and Alibaba using OpenAI responses API. (#11298) Co-authored-by: Kirill Frolov --- .../src/formats/openai_responses.rs | 119 +++++++++++++++++- 1 file changed, 116 insertions(+), 3 deletions(-) diff --git a/crates/goose-provider-types/src/formats/openai_responses.rs b/crates/goose-provider-types/src/formats/openai_responses.rs index 6eb43d029..8e66503af 100644 --- a/crates/goose-provider-types/src/formats/openai_responses.rs +++ b/crates/goose-provider-types/src/formats/openai_responses.rs @@ -95,6 +95,9 @@ pub enum ResponseContentBlock { Refusal { refusal: String, }, + ReasoningText { + text: String, + }, ToolCall { id: String, name: String, @@ -386,6 +389,9 @@ pub enum ContentBlockPart { Refusal { refusal: String, }, + ReasoningText { + text: String, + }, ToolCall { id: String, name: String, @@ -830,6 +836,12 @@ pub fn responses_api_to_message(response: &ResponsesApiResponse) -> anyhow::Resu content.push(MessageContentBlock::text(refusal)); } } + ResponseContentBlock::ReasoningText { text } => { + let text = sanitize_unicode_tags(text); + if !text.is_empty() { + content.push(MessageContentBlock::thinking(text, "")); + } + } ResponseContentBlock::ToolCall { id, name, input } => { let id = sanitize_tool_request_id(id, &mut tool_request_ids)?; content.push(MessageContentBlock::tool_request( @@ -906,6 +918,12 @@ fn process_streaming_output_items( content.push(MessageContentBlock::text(refusal)); } } + ContentBlockPart::ReasoningText { text } => { + let text = sanitize_unicode_tags(&text); + if !text.is_empty() { + content.push(MessageContentBlock::thinking(text, "")); + } + } ContentBlockPart::ToolCall { id, name, @@ -957,6 +975,26 @@ fn output_token_limit_marker(id: Option) -> Message { message } +/// Parse a line per the SSE grammar and return its field name: +/// - `field: value` / `field:value` -> `Some(field)` +/// - `field` (no colon, empty value) -> `Some(field)` +/// - `: comment` -> `Some("")` +/// +/// Returns `None` when the line does not look like an SSE field (e.g. a +/// bare JSON payload such as `{"type": ...}`), so callers can fall back +/// to parsing it as JSON. +fn sse_field_name(line: &str) -> Option<&str> { + let field = line.split_once(':').map_or(line, |(name, _)| name); + if field.is_empty() { + return Some(""); + } + let field_like = !field.contains(char::is_whitespace) + && field + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_')); + field_like.then_some(field) +} + pub fn responses_api_to_streaming_message( mut stream: S, ) -> impl Stream, Option)>> + 'static @@ -992,11 +1030,13 @@ where response_str.strip_prefix("data: ").unwrap() } else if response_str.starts_with("data:") { response_str.strip_prefix("data:").unwrap() - } else if response_str.starts_with("event: ") || response_str.starts_with("event:") { - // Skip event type lines + } else if sse_field_name(&response_str).is_some_and(|f| f != "data") { + // Skip payload-free SSE fields: event, id, retry, comments, + // colon-less fields with empty values, and unknown extension + // fields — the SSE spec requires all of these to be ignored. continue; } else { - // Try to parse as-is when there's no prefix + // Try to parse as-is when there's no prefix (bare JSON frames) &response_str }; @@ -1214,6 +1254,42 @@ mod tests { Ok(()) } + #[tokio::test] + async fn test_responses_stream_ignores_sse_field_lines() -> anyhow::Result<()> { + let lines = vec![ + "id:1".to_string(), + "id".to_string(), + "x-trace: abc123".to_string(), + "retry: 100".to_string(), + ": keepalive comment".to_string(), + r#"data: {"type":"response.created","sequence_number":1,"response":{"id":"resp_1","object":"response","created_at":1737368310,"status":"in_progress","model":"qwen3.8-max","output":[]}}"#.to_string(), + "event: response.output_text.delta".to_string(), + r#"data: {"type":"response.output_text.delta","sequence_number":2,"item_id":"msg_1","output_index":0,"content_index":0,"delta":"Hello"}"#.to_string(), + r#"data: {"type":"response.output_text.delta","sequence_number":3,"item_id":"msg_1","output_index":0,"content_index":0,"delta":" world"}"#.to_string(), + r#"data: {"type":"response.completed","sequence_number":4,"response":{"id":"resp_1","object":"response","created_at":1737368310,"status":"completed","model":"qwen3.8-max","output":[],"usage":{"input_tokens":10,"output_tokens":4,"total_tokens":14}}}"#.to_string(), + "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 text_parts = Vec::new(); + while let Some(item) = messages.next().await { + let (message, _) = item?; + if let Some(msg) = message { + for content in msg.content { + if let MessageContentBlock::Text(text) = content { + text_parts.push(text.text.clone()); + } + } + } + } + + assert_eq!(text_parts.concat(), "Hello world"); + Ok(()) + } + #[tokio::test] async fn test_responses_stream_completed_allows_missing_output() -> anyhow::Result<()> { let lines = vec![ @@ -2587,6 +2663,43 @@ mod tests { Ok(()) } + #[test] + fn test_reasoning_text_content_part_deserializes() { + let json = r#"{"type":"response.content_part.added","content_index":0,"item_id":"64cda83e-03b0-4e61-ab77-30d06ffe7f23","output_index":0,"part":{"type":"reasoning_text","text":"thinking..."},"sequence_number":3}"#; + + let event: ResponsesStreamEvent = serde_json::from_str(json).unwrap(); + match event { + ResponsesStreamEvent::ContentBlockPartAdded { part, .. } => match part { + ContentBlockPart::ReasoningText { text } => { + assert_eq!(text, "thinking..."); + } + _ => panic!("expected ReasoningText part"), + }, + _ => panic!("expected ContentBlockPartAdded event"), + } + } + + #[test] + fn test_reasoning_text_surfaces_as_thinking_block() -> anyhow::Result<()> { + let output_items = vec![ResponseOutputItemInfo::Message { + id: Some("msg_1".to_string()), + status: Some("completed".to_string()), + role: "assistant".to_string(), + content: vec![ContentBlockPart::ReasoningText { + text: "thinking...".to_string(), + }], + }]; + + let content = process_streaming_output_items(output_items, true)?; + assert_eq!(content.len(), 1, "reasoning text should be kept"); + assert!( + matches!(content[0], MessageContentBlock::Thinking(_)), + "reasoning text should become a thinking block" + ); + + Ok(()) + } + #[test] fn test_function_call_output_requires_call_id_or_id() { let output_items = vec![ResponseOutputItemInfo::FunctionCall {