fix(gemini): 为 Gemini 工具调用添加 thoughtSignature 避免 INVALID_ARGUMENT 错误
This commit is contained in:
@@ -36,6 +36,11 @@ const (
|
|||||||
geminiRetryMaxDelay = 16 * time.Second
|
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 {
|
type GeminiMessagesCompatService struct {
|
||||||
accountRepo AccountRepository
|
accountRepo AccountRepository
|
||||||
groupRepo GroupRepository
|
groupRepo GroupRepository
|
||||||
@@ -528,6 +533,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
return nil, s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||||
}
|
}
|
||||||
|
geminiReq = ensureGeminiFunctionCallThoughtSignatures(geminiReq)
|
||||||
originalClaudeBody := body
|
originalClaudeBody := body
|
||||||
|
|
||||||
proxyURL := ""
|
proxyURL := ""
|
||||||
@@ -978,6 +984,10 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
|
|||||||
return nil, s.writeGoogleError(c, http.StatusNotFound, "Unsupported action: "+action)
|
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
|
mappedModel := originalModel
|
||||||
if account.Type == AccountTypeAPIKey {
|
if account.Type == AccountTypeAPIKey {
|
||||||
mappedModel = account.GetMappedModel(originalModel)
|
mappedModel = account.GetMappedModel(originalModel)
|
||||||
@@ -2657,6 +2667,58 @@ func nextGeminiDailyResetUnix() *int64 {
|
|||||||
return &ts
|
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 {
|
func extractGeminiFinishReason(geminiResp map[string]any) string {
|
||||||
if candidates, ok := geminiResp["candidates"].([]any); ok && len(candidates) > 0 {
|
if candidates, ok := geminiResp["candidates"].([]any); ok && len(candidates) > 0 {
|
||||||
if cand, ok := candidates[0].(map[string]any); ok {
|
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) != "" {
|
if strings.TrimSpace(id) != "" && strings.TrimSpace(name) != "" {
|
||||||
toolUseIDToName[id] = name
|
toolUseIDToName[id] = name
|
||||||
}
|
}
|
||||||
|
signature, _ := bm["signature"].(string)
|
||||||
|
signature = strings.TrimSpace(signature)
|
||||||
|
if signature == "" {
|
||||||
|
signature = geminiDummyThoughtSignature
|
||||||
|
}
|
||||||
parts = append(parts, map[string]any{
|
parts = append(parts, map[string]any{
|
||||||
|
"thoughtSignature": signature,
|
||||||
"functionCall": map[string]any{
|
"functionCall": map[string]any{
|
||||||
"name": name,
|
"name": name,
|
||||||
"args": bm["input"],
|
"args": bm["input"],
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
"testing"
|
"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