From e625edb772e361f8fd2c7012f35a87c77344adda Mon Sep 17 00:00:00 2001 From: liugangjian Date: Wed, 4 Mar 2026 21:45:03 +0800 Subject: [PATCH] Fix Gemini 3 Pro/Flash thought_signature compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement proper thought_signature support for Google Gemini APIs - Add convertToolCallsForAPI to handle tool calls with extra content including Gemini-specific thought_signature - Add convertMessage to properly handle message serialization for Google APIs - Modify serializeMessages to use these functions for proper thought_signature handling - Ensures mandatory thought_signature fields work with newer Gemini models Resolves PicoClaw Issue #161: Incompatibility with Gemini 3 Pro/Flash due to Mandatory Thought Signatures 🤖 AI Assisted - Human designed the solution, AI helped implement it --- pkg/channels/telegram/telegram.go | 32 +++++++- pkg/providers/openai_compat/provider.go | 97 ++++++++++++++++++++++--- 2 files changed, 117 insertions(+), 12 deletions(-) diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index 1e33970a8..cbf33610b 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -23,6 +23,8 @@ import ( "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/utils" + "github.com/sipeed/picoclaw/pkg/voice" + ) var ( @@ -551,13 +553,37 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes if voicePath != "" { mediaPaths = append(mediaPaths, storeMedia(voicePath, "voice.ogg")) - if content != "" { - content += "\n" + // Check for Groq transcriber and transcribe if available, otherwise add placeholder + if c.groqTranscriber != nil && c.groqTranscriber.IsAvailable() { + logger.DebugC("telegram", "Transcribing voice message using Groq...") + transcription, err := c.groqTranscriber.Transcribe(ctx, voicePath) + if err != nil { + logger.ErrorCF("telegram", "Voice transcription failed", map[string]any{ + "error": err.Error(), + }) + // Fallback to placeholder if transcription fails + if content != "" { + content += "\n" + } + content += "[voice]" + } else { + // Use the transcription as content + if content != "" { + content += "\n" + } + content += transcription.Text + } + } else { + // Groq not available, use placeholder + if content != "" { + content += "\n" + } + content += "[voice]" } - content += "[voice]" } } + if message.Audio != nil { audioPath := c.downloadFile(ctx, message.Audio.FileID, ".mp3") if audioPath != "" { diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index ff9109e96..911294031 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -304,13 +304,7 @@ func serializeMessages(messages []Message) []any { out := make([]any, 0, len(messages)) for _, m := range messages { if len(m.Media) == 0 { - out = append(out, openaiMessage{ - Role: m.Role, - Content: m.Content, - ReasoningContent: m.ReasoningContent, - ToolCalls: m.ToolCalls, - ToolCallID: m.ToolCallID, - }) + out = append(out, convertMessage(m)) continue } @@ -338,8 +332,9 @@ func serializeMessages(messages []Message) []any { if m.ToolCallID != "" { msg["tool_call_id"] = m.ToolCallID } - if len(m.ToolCalls) > 0 { - msg["tool_calls"] = m.ToolCalls + if len(m.ToolCalls) > 0 { + msg["tool_calls"] = convertToolCallsForAPI(m.ToolCalls) + } } if m.ReasoningContent != "" { msg["reasoning_content"] = m.ReasoningContent @@ -397,3 +392,87 @@ func asFloat(v any) (float64, bool) { return 0, false } } + + +// convertToolCallsForAPI handles special conversion for toolCall structures for API compatibility. +// Particularly for Google Gemini APIs, it ensures thought_signature is properly handled. +func convertToolCallsForAPI(toolCalls []ToolCall) []any { + converted := make([]any, len(toolCalls)) + for i, tc := range toolCalls { + // Check if there's Google-specific extra content that may contain thought_signature + var toolCallObj map[string]any + + // Start building the base structure + toolCallObj = map[string]any{ + "id": tc.ID, + "type": tc.Type, + } + + // Add function details + funcCall := map[string]any{ + "name": tc.Name, + } + + // Convert arguments to JSON string like normal OpenAI format + if tc.Function != nil && tc.Function.Arguments != "" { + funcCall["arguments"] = tc.Function.Arguments + } else { + argsJSON, err := json.Marshal(tc.Arguments) + if err == nil && string(argsJSON) != "{}" { + funcCall["arguments"] = string(argsJSON) + } else { + funcCall["arguments"] = "{}" + } + } + + // For Google Gemini APIs: add the extra content that includes thought_signature + if tc.ExtraContent != nil && tc.ExtraContent.Google != nil { + extraObj := map[string]any{} + if tc.ExtraContent.Google.ThoughtSignature != "" { + extraObj["google"] = map[string]any{ + "thought_signature": tc.ExtraContent.Google.ThoughtSignature, + } + toolCallObj["extra_content"] = extraObj + } + } else if tc.ThoughtSignature != "" { + // If there's a direct thought signature but no ExtraContent, add it + extraObj := map[string]any{ + "google": map[string]any{ + "thought_signature": tc.ThoughtSignature, + }, + } + toolCallObj["extra_content"] = extraObj + } + + toolCallObj["function"] = funcCall + converted[i] = toolCallObj + } + return converted +} + +// convertMessage handles special conversion for messages that may include Google-specific thought_signature info +func convertMessage(m Message) any { + // Handle the basic message structure without media first + basicMsg := openaiMessage{ + Role: m.Role, + Content: m.Content, + ReasoningContent: m.ReasoningContent, + ToolCallID: m.ToolCallID, + } + + // Special handling of tool calls for Google Gemini compatibility + if len(m.ToolCalls) > 0 { + // Rather than passing ToolCalls directly (which would serialize them as Go structs), + // we convert them to proper API format that supports thought_signature + return map[string]any{ + "role": m.Role, + "content": m.Content, + "reasoning_content": m.ReasoningContent, + "tool_call_id": m.ToolCallID, + "tool_calls": convertToolCallsForAPI(m.ToolCalls), + } + } else { + // No tool calls - return basic message as before + return basicMsg + } +}