fix: handle reasoning field as string or array

Some models (Kimi) return `reasoning` as a plain string and
`reasoning_details` as an array. Others may return just one.

- Add flexible get_reasoning() method to handle both formats
- Parse reasoning field as serde_json::Value to avoid type errors
- Convert string reasoning to ReasoningContent when needed
This commit is contained in:
Thomas Marchand
2025-12-19 12:17:15 +00:00
parent 89e8dabe6f
commit de8721e09f

View File

@@ -111,16 +111,19 @@ impl OpenRouterClient {
.next()
.ok_or_else(|| LlmError::parse_error("No choices in response".to_string()))?;
// Get reasoning using the flexible parser that handles both string and array formats
let reasoning = choice.message.get_reasoning();
// Log if we received reasoning blocks (for debugging thinking models)
if let Some(ref reasoning) = choice.message.reasoning {
let has_thought_sig = reasoning.iter().any(|r| r.thought_signature.is_some());
if let Some(ref reasoning_blocks) = reasoning {
let has_thought_sig = reasoning_blocks.iter().any(|r| r.thought_signature.is_some());
tracing::debug!(
"Received {} reasoning blocks from model (has_thought_signature: {})",
reasoning.len(),
reasoning_blocks.len(),
has_thought_sig
);
// Log thought_signature details for debugging Gemini issues
for (i, r) in reasoning.iter().enumerate() {
for (i, r) in reasoning_blocks.iter().enumerate() {
if r.thought_signature.is_some() {
tracing::debug!("Reasoning block {} has thought_signature", i);
}
@@ -152,7 +155,7 @@ impl OpenRouterClient {
.usage
.map(|u| TokenUsage::new(u.prompt_tokens, u.completion_tokens)),
model: parsed.model.or_else(|| Some(request.model.clone())),
reasoning: choice.message.reasoning,
reasoning,
})
}
@@ -313,11 +316,55 @@ struct OpenRouterChoice {
struct OpenRouterMessage {
content: Option<String>,
tool_calls: Option<Vec<ToolCall>>,
/// Reasoning blocks from "thinking" models (Gemini 3, etc.)
/// Reasoning text as a plain string (some models like Kimi return this).
/// We'll merge this with reasoning_details if both are present.
#[serde(default)]
reasoning: Option<serde_json::Value>,
/// Reasoning blocks from "thinking" models (Gemini 3, Kimi, etc.)
/// Contains thought_signature that must be preserved for tool call continuations.
/// OpenRouter uses `reasoning` or `reasoning_details` depending on the model/provider.
#[serde(default, alias = "reasoning_details")]
reasoning: Option<Vec<ReasoningContent>>,
#[serde(default)]
reasoning_details: Option<Vec<ReasoningContent>>,
}
impl OpenRouterMessage {
/// Get reasoning content, handling both string and array formats.
/// Some models (Kimi) return `reasoning` as a string AND `reasoning_details` as an array.
/// Other models (Gemini) may put thought_signature in the array.
fn get_reasoning(&self) -> Option<Vec<ReasoningContent>> {
// Prefer reasoning_details if available (it's the structured format)
if let Some(ref details) = self.reasoning_details {
if !details.is_empty() {
return Some(details.clone());
}
}
// Fall back to reasoning field - could be string or array
if let Some(ref reasoning) = self.reasoning {
match reasoning {
serde_json::Value::String(s) => {
// Single string reasoning - convert to ReasoningContent
return Some(vec![ReasoningContent {
content: Some(s.clone()),
thought_signature: None,
reasoning_type: Some("thinking".to_string()),
format: None,
index: None,
}]);
}
serde_json::Value::Array(arr) => {
// Array of reasoning blocks - try to parse
if let Ok(blocks) = serde_json::from_value::<Vec<ReasoningContent>>(
serde_json::Value::Array(arr.clone())
) {
return Some(blocks);
}
}
_ => {}
}
}
None
}
}
/// Usage data (OpenAI-compatible).