fix(api): surface Ollama `reasoning` field for Qwen3 thinking models

Ollama's Qwen3 thinking models stream the chain-of-thought in a `reasoning`
field with an empty `content`. The OpenAI-compatible provider only recognised
`reasoning_content`/`thinking`, so reasoning-only responses surfaced no text
at all.

Add `reasoning` to both the streaming (`ChunkDelta`) and non-streaming
(`ChatMessage`) shapes and fall back to it when extracting thinking, preserving
the existing thinking/text content-block separation. Covered by two new tests
for the streaming and non-streaming paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Heo, Sung 2026-06-06 08:09:18 +09:00
parent d1b9e4598c
commit 5d67e76788
1 changed files with 117 additions and 0 deletions

View File

@ -562,6 +562,7 @@ impl StreamState {
.delta .delta
.reasoning_content .reasoning_content
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.or(choice.delta.reasoning.filter(|value| !value.is_empty()))
.or(choice .or(choice
.delta .delta
.thinking .thinking
@ -816,6 +817,9 @@ struct ChatMessage {
content: Option<String>, content: Option<String>,
#[serde(default)] #[serde(default)]
reasoning_content: Option<String>, reasoning_content: Option<String>,
/// Ollama's Qwen3 thinking models stream chain-of-thought here with `content=""`.
#[serde(default)]
reasoning: Option<String>,
#[serde(default)] #[serde(default)]
tool_calls: Vec<ResponseToolCall>, tool_calls: Vec<ResponseToolCall>,
} }
@ -890,6 +894,9 @@ struct ChunkDelta {
/// Some providers (GLM, DeepSeek) emit reasoning in `reasoning_content` /// Some providers (GLM, DeepSeek) emit reasoning in `reasoning_content`
#[serde(default)] #[serde(default)]
reasoning_content: Option<String>, reasoning_content: Option<String>,
/// Ollama's Qwen3 thinking models stream tokens here with `content=""`.
#[serde(default)]
reasoning: Option<String>,
#[serde(default)] #[serde(default)]
thinking: Option<ThinkingDelta>, thinking: Option<ThinkingDelta>,
#[serde(default, deserialize_with = "deserialize_null_as_empty_vec")] #[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
@ -1500,6 +1507,7 @@ fn normalize_response(
.message .message
.reasoning_content .reasoning_content
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.or_else(|| choice.message.reasoning.filter(|value| !value.is_empty()))
{ {
content.push(OutputContentBlock::Thinking { content.push(OutputContentBlock::Thinking {
thinking, thinking,
@ -1982,6 +1990,7 @@ mod tests {
role: "assistant".to_string(), role: "assistant".to_string(),
content: Some("final answer".to_string()), content: Some("final answer".to_string()),
reasoning_content: Some("hidden thought".to_string()), reasoning_content: Some("hidden thought".to_string()),
reasoning: None,
tool_calls: Vec::new(), tool_calls: Vec::new(),
}, },
finish_reason: Some("stop".to_string()), finish_reason: Some("stop".to_string()),
@ -2007,6 +2016,112 @@ mod tests {
); );
} }
#[test]
fn non_streaming_response_with_ollama_reasoning_field_emits_thinking_block() {
// Given an Ollama-style response that carries the chain-of-thought in
// `reasoning` (not `reasoning_content`) — Qwen3 thinking models emit an
// empty `content` and put the trace in `reasoning`.
let response = super::ChatCompletionResponse {
id: "chatcmpl_ollama".to_string(),
model: "qwen3.5:9b".to_string(),
choices: vec![super::ChatChoice {
message: super::ChatMessage {
role: "assistant".to_string(),
content: Some("43".to_string()),
reasoning_content: None,
reasoning: Some("17 + 26 = 43".to_string()),
tool_calls: Vec::new(),
},
finish_reason: Some("stop".to_string()),
}],
usage: None,
};
// When normalizing the provider response.
let normalized = normalize_response("qwen3.5:9b", response).expect("normalized");
// Then the `reasoning` text surfaces as a Thinking block before the text.
assert_eq!(
normalized.content,
vec![
OutputContentBlock::Thinking {
thinking: "17 + 26 = 43".to_string(),
signature: None,
},
OutputContentBlock::Text {
text: "43".to_string(),
},
]
);
}
#[test]
fn streaming_chunks_with_ollama_reasoning_field_emit_thinking_before_text() {
// Given Ollama Qwen3 streaming chunks carrying the chain-of-thought in
// the `reasoning` field with an empty `content`.
let mut state = StreamState::new("qwen3.5:9b".to_string());
let mut events = state
.ingest_chunk(super::ChatCompletionChunk {
id: "chatcmpl_ollama_stream".to_string(),
model: Some("qwen3.5:9b".to_string()),
choices: vec![super::ChunkChoice {
delta: super::ChunkDelta {
content: None,
reasoning_content: None,
reasoning: Some("thinking".to_string()),
thinking: None,
tool_calls: Vec::new(),
},
finish_reason: None,
}],
usage: None,
})
.expect("reasoning chunk");
events.extend(
state
.ingest_chunk(super::ChatCompletionChunk {
id: "chatcmpl_ollama_stream".to_string(),
model: None,
choices: vec![super::ChunkChoice {
delta: super::ChunkDelta {
content: Some("43".to_string()),
reasoning_content: None,
reasoning: None,
thinking: None,
tool_calls: Vec::new(),
},
finish_reason: Some("stop".to_string()),
}],
usage: None,
})
.expect("text chunk"),
);
events.extend(state.finish().expect("finish"));
// Then a Thinking block (index 0) is emitted before the Text block (index 1).
assert!(matches!(
events[1],
StreamEvent::ContentBlockStart(ContentBlockStartEvent {
index: 0,
content_block: OutputContentBlock::Thinking { .. },
})
));
assert!(events.iter().any(|event| matches!(
event,
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
index: 0,
delta: ContentBlockDelta::ThinkingDelta { .. },
})
)));
assert!(events.iter().any(|event| matches!(
event,
StreamEvent::ContentBlockStart(ContentBlockStartEvent {
index: 1,
content_block: OutputContentBlock::Text { .. },
})
)));
}
#[test] #[test]
fn streaming_chunks_with_reasoning_content_emit_thinking_block_events_before_text() { fn streaming_chunks_with_reasoning_content_emit_thinking_block_events_before_text() {
// Given streaming chunks with reasoning_content followed by text. // Given streaming chunks with reasoning_content followed by text.
@ -2019,6 +2134,7 @@ mod tests {
delta: super::ChunkDelta { delta: super::ChunkDelta {
content: None, content: None,
reasoning_content: Some("think".to_string()), reasoning_content: Some("think".to_string()),
reasoning: None,
thinking: None, thinking: None,
tool_calls: Vec::new(), tool_calls: Vec::new(),
}, },
@ -2036,6 +2152,7 @@ mod tests {
delta: super::ChunkDelta { delta: super::ChunkDelta {
content: Some(" answer".to_string()), content: Some(" answer".to_string()),
reasoning_content: None, reasoning_content: None,
reasoning: None,
thinking: None, thinking: None,
tool_calls: Vec::new(), tool_calls: Vec::new(),
}, },