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:
parent
d1b9e4598c
commit
5d67e76788
|
|
@ -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(),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue