From 03e94f9f538d325887c755d956a46931b71d2030 Mon Sep 17 00:00:00 2001 From: ianshaw Date: Tue, 3 Feb 2026 06:01:29 +0800 Subject: [PATCH] =?UTF-8?q?fix(gemini):=20=E4=B8=BA=20Gemini=20=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E8=B0=83=E7=94=A8=E6=B7=BB=E5=8A=A0=20thoughtSignatur?= =?UTF-8?q?e=20=E9=81=BF=E5=85=8D=20INVALID=5FARGUMENT=20=E9=94=99?= =?UTF-8?q?=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/gemini_messages_compat_service.go | 68 ++++++++++++++++ .../gemini_messages_compat_service_test.go | 77 +++++++++++++++++++ 2 files changed, 145 insertions(+) diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index cbbf5fcf..2d2e86d5 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -36,6 +36,11 @@ const ( geminiRetryMaxDelay = 16 * time.Second ) +// Gemini tool calling now requires `thoughtSignature` in parts that include `functionCall`. +// Many clients don't send it; we inject a known dummy signature to satisfy the validator. +// Ref: https://ai.google.dev/gemini-api/docs/thought-signatures +const geminiDummyThoughtSignature = "skip_thought_signature_validator" + type GeminiMessagesCompatService struct { accountRepo AccountRepository groupRepo GroupRepository @@ -528,6 +533,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex if err != nil { return nil, s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) } + geminiReq = ensureGeminiFunctionCallThoughtSignatures(geminiReq) originalClaudeBody := body proxyURL := "" @@ -978,6 +984,10 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. return nil, s.writeGoogleError(c, http.StatusNotFound, "Unsupported action: "+action) } + // Some Gemini upstreams validate tool call parts strictly; ensure any `functionCall` part includes a + // `thoughtSignature` to avoid frequent INVALID_ARGUMENT 400s. + body = ensureGeminiFunctionCallThoughtSignatures(body) + mappedModel := originalModel if account.Type == AccountTypeAPIKey { mappedModel = account.GetMappedModel(originalModel) @@ -2657,6 +2667,58 @@ func nextGeminiDailyResetUnix() *int64 { return &ts } +func ensureGeminiFunctionCallThoughtSignatures(body []byte) []byte { + // Fast path: only run when functionCall is present. + if !bytes.Contains(body, []byte(`"functionCall"`)) { + return body + } + + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return body + } + + contentsAny, ok := payload["contents"].([]any) + if !ok || len(contentsAny) == 0 { + return body + } + + modified := false + for _, c := range contentsAny { + cm, ok := c.(map[string]any) + if !ok { + continue + } + partsAny, ok := cm["parts"].([]any) + if !ok || len(partsAny) == 0 { + continue + } + for _, p := range partsAny { + pm, ok := p.(map[string]any) + if !ok || pm == nil { + continue + } + if fc, ok := pm["functionCall"].(map[string]any); !ok || fc == nil { + continue + } + ts, _ := pm["thoughtSignature"].(string) + if strings.TrimSpace(ts) == "" { + pm["thoughtSignature"] = geminiDummyThoughtSignature + modified = true + } + } + } + + if !modified { + return body + } + b, err := json.Marshal(payload) + if err != nil { + return body + } + return b +} + func extractGeminiFinishReason(geminiResp map[string]any) string { if candidates, ok := geminiResp["candidates"].([]any); ok && len(candidates) > 0 { if cand, ok := candidates[0].(map[string]any); ok { @@ -2856,7 +2918,13 @@ func convertClaudeMessagesToGeminiContents(messages any, toolUseIDToName map[str if strings.TrimSpace(id) != "" && strings.TrimSpace(name) != "" { toolUseIDToName[id] = name } + signature, _ := bm["signature"].(string) + signature = strings.TrimSpace(signature) + if signature == "" { + signature = geminiDummyThoughtSignature + } parts = append(parts, map[string]any{ + "thoughtSignature": signature, "functionCall": map[string]any{ "name": name, "args": bm["input"], diff --git a/backend/internal/service/gemini_messages_compat_service_test.go b/backend/internal/service/gemini_messages_compat_service_test.go index d49f2eb3..f31b40ec 100644 --- a/backend/internal/service/gemini_messages_compat_service_test.go +++ b/backend/internal/service/gemini_messages_compat_service_test.go @@ -1,6 +1,8 @@ package service import ( + "encoding/json" + "strings" "testing" ) @@ -126,3 +128,78 @@ func TestConvertClaudeToolsToGeminiTools_CustomType(t *testing.T) { }) } } + +func TestConvertClaudeMessagesToGeminiGenerateContent_AddsThoughtSignatureForToolUse(t *testing.T) { + claudeReq := map[string]any{ + "model": "claude-haiku-4-5-20251001", + "max_tokens": 10, + "messages": []any{ + map[string]any{ + "role": "user", + "content": []any{ + map[string]any{"type": "text", "text": "hi"}, + }, + }, + map[string]any{ + "role": "assistant", + "content": []any{ + map[string]any{"type": "text", "text": "ok"}, + map[string]any{ + "type": "tool_use", + "id": "toolu_123", + "name": "default_api:write_file", + "input": map[string]any{"path": "a.txt", "content": "x"}, + // no signature on purpose + }, + }, + }, + }, + "tools": []any{ + map[string]any{ + "name": "default_api:write_file", + "description": "write file", + "input_schema": map[string]any{ + "type": "object", + "properties": map[string]any{"path": map[string]any{"type": "string"}}, + }, + }, + }, + } + b, _ := json.Marshal(claudeReq) + + out, err := convertClaudeMessagesToGeminiGenerateContent(b) + if err != nil { + t.Fatalf("convert failed: %v", err) + } + s := string(out) + if !strings.Contains(s, "\"functionCall\"") { + t.Fatalf("expected functionCall in output, got: %s", s) + } + if !strings.Contains(s, "\"thoughtSignature\":\""+geminiDummyThoughtSignature+"\"") { + t.Fatalf("expected injected thoughtSignature %q, got: %s", geminiDummyThoughtSignature, s) + } +} + +func TestEnsureGeminiFunctionCallThoughtSignatures_InsertsWhenMissing(t *testing.T) { + geminiReq := map[string]any{ + "contents": []any{ + map[string]any{ + "role": "user", + "parts": []any{ + map[string]any{ + "functionCall": map[string]any{ + "name": "default_api:write_file", + "args": map[string]any{"path": "a.txt"}, + }, + }, + }, + }, + }, + } + b, _ := json.Marshal(geminiReq) + out := ensureGeminiFunctionCallThoughtSignatures(b) + s := string(out) + if !strings.Contains(s, "\"thoughtSignature\":\""+geminiDummyThoughtSignature+"\"") { + t.Fatalf("expected injected thoughtSignature %q, got: %s", geminiDummyThoughtSignature, s) + } +}