fix(providers/openai): accept streaming chunks with both reasoning fields (#8715)

Signed-off-by: Sam Osborn <samosborn88@gmail.com>
This commit is contained in:
Sam Osborn
2026-04-22 02:35:48 -05:00
committed by GitHub
parent c8b339e559
commit 015b0d92d3
+83 -9
View File
@@ -72,10 +72,21 @@ struct Delta {
role: Option<String>,
tool_calls: Option<Vec<DeltaToolCall>>,
reasoning_details: Option<Vec<Value>>,
#[serde(alias = "reasoning")]
reasoning: Option<String>,
reasoning_content: Option<String>,
}
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<String> = 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<String> = 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(())
}
}