From 015b0d92d3f7c63634bfedf01ff5a3ed444c2d27 Mon Sep 17 00:00:00 2001 From: Sam Osborn Date: Wed, 22 Apr 2026 02:35:48 -0500 Subject: [PATCH] fix(providers/openai): accept streaming chunks with both reasoning fields (#8715) Signed-off-by: Sam Osborn --- crates/goose/src/providers/formats/openai.rs | 92 ++++++++++++++++++-- 1 file changed, 83 insertions(+), 9 deletions(-) diff --git a/crates/goose/src/providers/formats/openai.rs b/crates/goose/src/providers/formats/openai.rs index cb70a91c..ce632d4e 100644 --- a/crates/goose/src/providers/formats/openai.rs +++ b/crates/goose/src/providers/formats/openai.rs @@ -72,10 +72,21 @@ struct Delta { role: Option, tool_calls: Option>, reasoning_details: Option>, - #[serde(alias = "reasoning")] + 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 { delta: Delta, @@ -769,7 +780,7 @@ where if let Some(details) = &chunk.choices[0].delta.reasoning_details { accumulated_reasoning.extend(details.iter().cloned()); } - if let Some(rc) = &chunk.choices[0].delta.reasoning_content { + if let Some(rc) = chunk.choices[0].delta.reasoning_text() { accumulated_reasoning_content.push_str(rc); } } @@ -811,7 +822,7 @@ where if let Some(details) = &tool_chunk.choices[0].delta.reasoning_details { accumulated_reasoning.extend(details.iter().cloned()); } - if let Some(rc) = &tool_chunk.choices[0].delta.reasoning_content { + if let Some(rc) = tool_chunk.choices[0].delta.reasoning_text() { accumulated_reasoning_content.push_str(rc); } if let Some(delta_tool_calls) = &tool_chunk.choices[0].delta.tool_calls { @@ -919,14 +930,12 @@ where Some(msg), usage, ) - } else if chunk.choices[0].delta.content.is_some() || chunk.choices[0].delta.reasoning_content.is_some() { + } else if chunk.choices[0].delta.content.is_some() || chunk.choices[0].delta.reasoning_text().is_some() { let mut content = Vec::new(); - if let Some(reasoning) = &chunk.choices[0].delta.reasoning_content { - if !reasoning.is_empty() { - let signature = last_signature.as_deref().unwrap_or(""); - content.push(MessageContent::thinking(reasoning, signature)); - } + if let Some(reasoning) = chunk.choices[0].delta.reasoning_text() { + let signature = last_signature.as_deref().unwrap_or(""); + content.push(MessageContent::thinking(reasoning, signature)); } let (text_content, thought_signature) = extract_content_and_signature(chunk.choices[0].delta.content.as_ref()); @@ -2343,4 +2352,69 @@ data: [DONE]"#; assert_eq!(result.output_tokens, Some(128)); assert_eq!(result.total_tokens, Some(170)); } + + // vLLM serving gpt-oss emits both `reasoning` and `reasoning_content` + // in the same payload; the non-streaming path handles it fine today. + #[test] + fn test_response_to_message_with_both_reasoning_fields() -> anyhow::Result<()> { + let response = json!({ + "choices": [{ + "message": { + "role": "assistant", + "content": "answer", + "reasoning": "thinking...", + "reasoning_content": "thinking..." + } + }] + }); + + let message = response_to_message(&response)?; + assert_eq!(message.content.len(), 2); + if let MessageContent::Thinking(t) = &message.content[0] { + assert_eq!(t.thinking, "thinking..."); + } else { + panic!("Expected Thinking content, got {:?}", message.content[0]); + } + Ok(()) + } + + #[tokio::test] + async fn test_streaming_chunk_with_only_reasoning_content() -> anyhow::Result<()> { + let response_lines = "data: {\"id\":\"x\",\"object\":\"chat.completion.chunk\",\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"hi\"},\"finish_reason\":null}]}\ndata: [DONE]"; + let lines: Vec = response_lines.lines().map(|s| s.to_string()).collect(); + let response_stream = tokio_stream::iter(lines.into_iter().map(Ok)); + let mut messages = std::pin::pin!(response_to_streaming_message(response_stream)); + while let Some(result) = messages.next().await { + result?; + } + Ok(()) + } + + // Streaming counterpart: both fields in one delta must parse and yield + // thinking content, not fail with "duplicate field `reasoning_content`". + #[tokio::test] + async fn test_streaming_chunk_with_both_reasoning_fields() -> anyhow::Result<()> { + let response_lines = "data: {\"id\":\"x\",\"object\":\"chat.completion.chunk\",\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning\":\"thinking...\",\"reasoning_content\":\"thinking...\"},\"finish_reason\":null}]}\ndata: [DONE]"; + let lines: Vec = response_lines.lines().map(|s| s.to_string()).collect(); + let response_stream = tokio_stream::iter(lines.into_iter().map(Ok)); + let mut messages = std::pin::pin!(response_to_streaming_message(response_stream)); + + let mut saw_thinking = false; + while let Some(result) = messages.next().await { + let (message, _usage) = result?; + if let Some(msg) = message { + for c in &msg.content { + if let MessageContent::Thinking(t) = c { + assert_eq!(t.thinking, "thinking..."); + saw_thinking = true; + } + } + } + } + assert!( + saw_thinking, + "expected thinking content from merged reasoning fields" + ); + Ok(()) + } }