Fix Gemini 3 Pro/Flash thought_signature compatibility

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
This commit is contained in:
liugangjian 2026-03-04 21:45:03 +08:00
parent 13f99addb7
commit e625edb772
2 changed files with 117 additions and 12 deletions

View file

@ -23,6 +23,8 @@ import (
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/utils" "github.com/sipeed/picoclaw/pkg/utils"
"github.com/sipeed/picoclaw/pkg/voice"
) )
var ( var (
@ -551,12 +553,36 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
if voicePath != "" { if voicePath != "" {
mediaPaths = append(mediaPaths, storeMedia(voicePath, "voice.ogg")) mediaPaths = append(mediaPaths, storeMedia(voicePath, "voice.ogg"))
// 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 != "" { if content != "" {
content += "\n" content += "\n"
} }
content += "[voice]" content += "[voice]"
} }
} }
}
if message.Audio != nil { if message.Audio != nil {
audioPath := c.downloadFile(ctx, message.Audio.FileID, ".mp3") audioPath := c.downloadFile(ctx, message.Audio.FileID, ".mp3")

View file

@ -304,13 +304,7 @@ func serializeMessages(messages []Message) []any {
out := make([]any, 0, len(messages)) out := make([]any, 0, len(messages))
for _, m := range messages { for _, m := range messages {
if len(m.Media) == 0 { if len(m.Media) == 0 {
out = append(out, openaiMessage{ out = append(out, convertMessage(m))
Role: m.Role,
Content: m.Content,
ReasoningContent: m.ReasoningContent,
ToolCalls: m.ToolCalls,
ToolCallID: m.ToolCallID,
})
continue continue
} }
@ -339,7 +333,8 @@ func serializeMessages(messages []Message) []any {
msg["tool_call_id"] = m.ToolCallID msg["tool_call_id"] = m.ToolCallID
} }
if len(m.ToolCalls) > 0 { if len(m.ToolCalls) > 0 {
msg["tool_calls"] = m.ToolCalls msg["tool_calls"] = convertToolCallsForAPI(m.ToolCalls)
}
} }
if m.ReasoningContent != "" { if m.ReasoningContent != "" {
msg["reasoning_content"] = m.ReasoningContent msg["reasoning_content"] = m.ReasoningContent
@ -397,3 +392,87 @@ func asFloat(v any) (float64, bool) {
return 0, false 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
}
}