fix(gemini): 为 Gemini 工具调用添加 thoughtSignature 避免 INVALID_ARGUMENT 错误
This commit is contained in:
@@ -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"],
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user