fix: handle reasoning_content for Kimi/thinking models (#7252)

Signed-off-by: clayarnoldg2m <carnold@g2m.ai>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Clay
2026-02-17 12:27:11 +13:00
committed by GitHub
parent 1ad641cd36
commit 05d2af4d3b
6 changed files with 130 additions and 29 deletions
+18 -11
View File
@@ -82,7 +82,7 @@ pub fn format_messages(messages: &[Message], image_format: &ImageFormat) -> Vec<
let mut output = Vec::new();
let mut content_array = Vec::new();
let mut text_array = Vec::new();
let mut reasoning_text: Option<String> = None;
let mut reasoning_text = String::new();
for content in &message.content {
match content {
@@ -116,7 +116,7 @@ pub fn format_messages(messages: &[Message], image_format: &ImageFormat) -> Vec<
continue;
}
MessageContent::Reasoning(r) => {
reasoning_text = Some(r.text.clone());
reasoning_text.push_str(&r.text);
}
MessageContent::ToolRequest(request) => match &request.tool_call {
Ok(tool_call) => {
@@ -278,15 +278,11 @@ pub fn format_messages(messages: &[Message], image_format: &ImageFormat) -> Vec<
converted["content"] = json!(null);
}
// DeepSeek requires reasoning_content field when tool_calls are present
// Set it to the captured reasoning text, or empty string if not present
if converted.get("tool_calls").is_some() {
let reasoning = reasoning_text.unwrap_or_default();
converted["reasoning_content"] = json!(reasoning);
} else if let Some(reasoning) = reasoning_text {
if !reasoning.is_empty() {
converted["reasoning_content"] = json!(reasoning);
}
// Include reasoning_content only when non-empty.
// Kimi rejects empty reasoning_content (""), so we must omit it entirely
// when there's no reasoning to send.
if !reasoning_text.is_empty() {
converted["reasoning_content"] = json!(reasoning_text);
}
if converted.get("content").is_some() || converted.get("tool_calls").is_some() {
@@ -542,6 +538,7 @@ where
use futures::StreamExt;
let mut accumulated_reasoning: Vec<Value> = Vec::new();
let mut accumulated_reasoning_content = String::new();
'outer: while let Some(response) = stream.next().await {
if response.as_ref().is_ok_and(|s| s == "data: [DONE]") {
@@ -562,6 +559,9 @@ 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 {
accumulated_reasoning_content.push_str(rc);
}
}
let mut usage = extract_usage_with_output_tokens(&chunk);
@@ -602,6 +602,9 @@ 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 {
accumulated_reasoning_content.push_str(rc);
}
if let Some(delta_tool_calls) = &tool_chunk.choices[0].delta.tool_calls {
for delta_call in delta_tool_calls {
if let Some(index) = delta_call.index {
@@ -642,6 +645,10 @@ where
};
let mut contents = Vec::new();
if !accumulated_reasoning_content.is_empty() {
contents.push(MessageContent::reasoning(&accumulated_reasoning_content));
accumulated_reasoning_content.clear();
}
let mut sorted_indices: Vec<_> = tool_call_data.keys().cloned().collect();
sorted_indices.sort();
+56 -5
View File
@@ -221,10 +221,18 @@ impl GeminiCliProvider {
fn parse_stream_json_response(events: &[Value]) -> Result<(Message, Usage), ProviderError> {
let mut all_text_content = Vec::new();
let mut all_thinking_content = Vec::new();
let mut usage = Usage::default();
for parsed in events {
match parsed.get("type").and_then(|t| t.as_str()) {
Some("thinking") => {
if let Some(content) = parsed.get("content").and_then(|c| c.as_str()) {
if !content.is_empty() {
all_thinking_content.push(content.to_string());
}
}
}
Some("message") => {
if parsed.get("role").and_then(|r| r.as_str()) == Some("assistant") {
if let Some(content) = parsed.get("content").and_then(|c| c.as_str()) {
@@ -253,11 +261,16 @@ impl GeminiCliProvider {
));
}
let message = Message::new(
Role::Assistant,
chrono::Utc::now().timestamp(),
vec![MessageContent::text(combined_text)],
);
let mut content = Vec::new();
let combined_thinking = all_thinking_content.join("");
if !combined_thinking.is_empty() {
content.push(MessageContent::thinking(combined_thinking, String::new()));
}
content.push(MessageContent::text(combined_text));
let message = Message::new(Role::Assistant, chrono::Utc::now().timestamp(), content);
Ok((message, usage))
}
@@ -395,6 +408,44 @@ mod tests {
assert!(GeminiCliProvider::parse_stream_json_response(&empty).is_err());
}
#[test]
fn test_parse_thinking_blocks() {
let events = vec![
json!({"type":"init","session_id":"abc","model":"gemini-2.5-pro"}),
json!({"type":"thinking","content":"Let me reason about this...","delta":true}),
json!({"type":"thinking","content":" Step 1: analyze the problem.","delta":true}),
json!({"type":"message","role":"assistant","content":"Here is the answer.","delta":true}),
json!({"type":"result","status":"success","stats":{"input_tokens":30,"output_tokens":15,"total_tokens":45}}),
];
let (message, usage) = GeminiCliProvider::parse_stream_json_response(&events).unwrap();
assert_eq!(message.role, Role::Assistant);
// Should have thinking content followed by text content
assert_eq!(message.content.len(), 2);
let thinking = message.content[0]
.as_thinking()
.expect("first content should be thinking");
assert_eq!(
thinking.thinking,
"Let me reason about this... Step 1: analyze the problem."
);
assert_eq!(message.as_concat_text(), "Here is the answer.");
assert_eq!(usage.input_tokens, Some(30));
assert_eq!(usage.output_tokens, Some(15));
}
#[test]
fn test_parse_no_thinking_blocks() {
// When there's no thinking, message should only have text content
let events = vec![
json!({"type":"message","role":"assistant","content":"Direct answer.","delta":true}),
json!({"type":"result","status":"success","stats":{"input_tokens":10,"output_tokens":5,"total_tokens":15}}),
];
let (message, _usage) = GeminiCliProvider::parse_stream_json_response(&events).unwrap();
assert_eq!(message.content.len(), 1);
assert_eq!(message.as_concat_text(), "Direct answer.");
}
#[test]
fn test_build_prompt_first_and_resume() {
let provider = make_provider();