fix: preserve Gemini thought_signature for tool call continuations

- Add thought_signature field to ToolCall and FunctionCall structs
- Add alias for reasoning_details in OpenRouterMessage
- Expand ReasoningContent with format/index fields
- Add debug logging for reasoning block tracking

Fixes Gemini 3 "Function call is missing a thought_signature" errors
This commit is contained in:
Thomas Marchand
2025-12-19 11:56:42 +00:00
parent c9734cc746
commit 7c9f280d65
3 changed files with 82 additions and 8 deletions

View File

@@ -525,6 +525,30 @@ Use `search_memory` when you encounter a problem you might have solved before or
// Add assistant message with tool calls
// IMPORTANT: Preserve reasoning blocks for "thinking" models (Gemini 3, etc.)
// These contain thought_signature that must be sent back for continuations.
// Debug: Log if we have reasoning/thought_signature to preserve
if let Some(ref reasoning) = response.reasoning {
let has_sig = reasoning.iter().any(|r| r.thought_signature.is_some());
tracing::debug!(
"Preserving {} reasoning blocks (has_thought_signature: {})",
reasoning.len(),
has_sig
);
}
// Also check for thought_signature in tool_calls themselves (Gemini format)
for tc in tool_calls {
let has_tc_sig = tc.thought_signature.is_some();
let has_fn_sig = tc.function.thought_signature.is_some();
if has_tc_sig || has_fn_sig {
tracing::debug!(
"Tool call '{}' has thought_signature (tool_call: {}, function: {})",
tc.function.name,
has_tc_sig,
has_fn_sig
);
}
}
messages.push(ChatMessage {
role: Role::Assistant,
content: response.content.clone().map(MessageContent::text),

View File

@@ -88,17 +88,34 @@ pub enum MessageContent {
/// the model to resume its chain of thought.
///
/// Reference: https://openrouter.ai/docs/use-cases/reasoning-tokens
/// Reference: https://ai.google.dev/gemini-api/docs/thought-signatures
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReasoningContent {
/// The reasoning/thinking content (may be redacted or empty for some models)
#[serde(skip_serializing_if = "Option::is_none")]
/// The reasoning/thinking content (may be redacted or empty for some models).
/// OpenRouter uses both `content` and `text` fields depending on the model.
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "text"
)]
pub content: Option<String>,
/// Encrypted thought signature for resuming reasoning (required for Gemini)
/// Encrypted thought signature for resuming reasoning (required for Gemini 3).
/// This MUST be preserved and sent back in subsequent requests for tool call continuations.
#[serde(skip_serializing_if = "Option::is_none")]
pub thought_signature: Option<String>,
/// Type of reasoning block (typically "thinking")
/// Type of reasoning block (e.g., "thinking", "reasoning.text")
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub reasoning_type: Option<String>,
/// Format of the reasoning content (e.g., "unknown")
#[serde(skip_serializing_if = "Option::is_none")]
pub format: Option<String>,
/// Index of the reasoning block (for ordered reasoning)
#[serde(skip_serializing_if = "Option::is_none")]
pub index: Option<u32>,
}
impl ReasoningContent {
@@ -209,6 +226,9 @@ pub struct ToolCall {
#[serde(rename = "type")]
pub call_type: String,
pub function: FunctionCall,
/// Thought signature for Gemini 3 models (may be at this level instead of function level).
#[serde(skip_serializing_if = "Option::is_none")]
pub thought_signature: Option<String>,
}
/// Function call details.
@@ -218,6 +238,10 @@ pub struct FunctionCall {
/// Arguments as a JSON string. May be empty or missing for no-argument functions.
#[serde(default)]
pub arguments: String,
/// Thought signature for Gemini 3 models. Must be preserved and sent back with tool results.
/// When present, this allows Gemini to resume its chain of thought after a tool call.
#[serde(skip_serializing_if = "Option::is_none")]
pub thought_signature: Option<String>,
}
/// Tool definition for the LLM.

View File

@@ -112,11 +112,36 @@ impl OpenRouterClient {
.ok_or_else(|| LlmError::parse_error("No choices in response".to_string()))?;
// Log if we received reasoning blocks (for debugging thinking models)
if choice.message.reasoning.is_some() {
if let Some(ref reasoning) = choice.message.reasoning {
let has_thought_sig = reasoning.iter().any(|r| r.thought_signature.is_some());
tracing::debug!(
"Received {} reasoning blocks from model",
choice.message.reasoning.as_ref().map_or(0, |r| r.len())
"Received {} reasoning blocks from model (has_thought_signature: {})",
reasoning.len(),
has_thought_sig
);
// Log thought_signature details for debugging Gemini issues
for (i, r) in reasoning.iter().enumerate() {
if r.thought_signature.is_some() {
tracing::debug!("Reasoning block {} has thought_signature", i);
}
}
}
// Log if tool calls have thought_signatures attached (Gemini format)
if let Some(ref tool_calls) = choice.message.tool_calls {
for tc in tool_calls {
// Check both ToolCall level and FunctionCall level
let has_tc_sig = tc.thought_signature.is_some();
let has_fn_sig = tc.function.thought_signature.is_some();
if has_tc_sig || has_fn_sig {
tracing::debug!(
"Tool call '{}' has thought_signature (tool_call: {}, function: {})",
tc.function.name,
has_tc_sig,
has_fn_sig
);
}
}
}
Ok(ChatResponse {
@@ -290,7 +315,8 @@ struct OpenRouterMessage {
tool_calls: Option<Vec<ToolCall>>,
/// Reasoning blocks from "thinking" models (Gemini 3, etc.)
/// Contains thought_signature that must be preserved for tool call continuations.
#[serde(default)]
/// OpenRouter uses `reasoning` or `reasoning_details` depending on the model/provider.
#[serde(default, alias = "reasoning_details")]
reasoning: Option<Vec<ReasoningContent>>,
}