Просмотр исходного кода

feat: support gemini-3 (#423)

* feat: support gemini-3

* fix: ci lint
zijiren 1 месяц назад
Родитель
Сommit
25ae411384

+ 12 - 10
core/relay/adaptor/gemini/claude.go

@@ -61,8 +61,6 @@ func ConvertClaudeRequest(meta *meta.Meta, req *http.Request) (adaptor.ConvertRe
 		return adaptor.ConvertResult{}, err
 	}
 
-	// fmt.Println(string(data))
-
 	return adaptor.ConvertResult{
 		Header: http.Header{
 			"Content-Type":   {"application/json"},
@@ -290,10 +288,11 @@ func ClaudeStreamHandler(
 					currentContentType = "tool_use"
 
 					toolContent := &relaymodel.ClaudeContent{
-						Type:  "tool_use",
-						ID:    openai.CallID(),
-						Name:  part.FunctionCall.Name,
-						Input: part.FunctionCall.Args,
+						Type:             "tool_use",
+						ID:               openai.CallID(),
+						Name:             part.FunctionCall.Name,
+						Input:            part.FunctionCall.Args,
+						ThoughtSignature: part.ThoughtSignature,
 					}
 					toolCallsBuffer[currentContentIndex] = toolContent
 
@@ -391,10 +390,11 @@ func geminiResponse2Claude(meta *meta.Meta, response *ChatResponse) *relaymodel.
 			if part.FunctionCall != nil {
 				// Convert function call to tool use
 				claudeResponse.Content = append(claudeResponse.Content, relaymodel.ClaudeContent{
-					Type:  "tool_use",
-					ID:    openai.CallID(),
-					Name:  part.FunctionCall.Name,
-					Input: part.FunctionCall.Args,
+					Type:             "tool_use",
+					ID:               openai.CallID(),
+					Name:             part.FunctionCall.Name,
+					Input:            part.FunctionCall.Args,
+					ThoughtSignature: part.ThoughtSignature,
 				})
 			} else if part.Text != "" {
 				if part.Thought {
@@ -415,6 +415,8 @@ func geminiResponse2Claude(meta *meta.Meta, response *ChatResponse) *relaymodel.
 	}
 
 	// If no content was added, ensure at least an empty text block
+	// This can happen when Gemini returns empty content after receiving a tool result,
+	// indicating it has nothing more to add beyond the tool's response
 	if len(claudeResponse.Content) == 0 {
 		claudeResponse.Content = append(claudeResponse.Content, relaymodel.ClaudeContent{
 			Type: "text",

+ 51 - 3
core/relay/adaptor/gemini/main.go

@@ -30,6 +30,13 @@ import (
 
 // https://ai.google.dev/docs/gemini_api_overview?hl=zh-cn
 
+// Dummy thought signatures for skipping Gemini's validation when the actual signature is unavailable
+// See: https://ai.google.dev/gemini-api/docs/thought-signatures#faqs
+const (
+	ThoughtSignatureDummySkipValidator = "skip_thought_signature_validator"
+	ThoughtSignatureDummyContextEng    = "context_engineering_is_the_way_to_go"
+)
+
 var toolChoiceTypeMap = map[string]string{
 	"none":     "NONE",
 	"auto":     "AUTO",
@@ -162,6 +169,8 @@ var unsupportedFields = []string{
 	"$id",
 	"$ref",
 	"$defs",
+	"exclusiveMinimum",
+	"exclusiveMaximum",
 }
 
 var supportedFormats = map[string]struct{}{
@@ -270,12 +279,26 @@ func buildContents(
 					args = make(map[string]any)
 				}
 
-				content.Parts = append(content.Parts, &Part{
+				part := &Part{
 					FunctionCall: &FunctionCall{
 						Name: toolCall.Function.Name,
 						Args: args,
 					},
-				})
+				}
+
+				// Restore Gemini thought signature if present in extra_content (OpenAI format)
+				if toolCall.ExtraContent != nil && toolCall.ExtraContent.Google != nil {
+					if toolCall.ExtraContent.Google.ThoughtSignature != "" {
+						part.ThoughtSignature = toolCall.ExtraContent.Google.ThoughtSignature
+					}
+				} else {
+					// If thought signature is missing (e.g., from non-Gemini sources or clients that don't preserve it),
+					// use a dummy signature to skip Gemini's validation as per their FAQ:
+					// https://ai.google.dev/gemini-api/docs/thought-signatures#faqs
+					part.ThoughtSignature = ThoughtSignatureDummySkipValidator
+				}
+
+				content.Parts = append(content.Parts, part)
 			}
 		case message.Role == "tool" && message.ToolCallID != "":
 			// Handle tool results - get the tool name from our map
@@ -359,7 +382,23 @@ func buildContents(
 		}
 	}
 
-	return systemContent, contents, imageTasks
+	// Merge consecutive messages with the same role to avoid Gemini API errors
+	// Gemini expects alternating user/model messages, but we might receive multiple
+	// consecutive user messages (e.g., multiple tool results)
+	mergedContents := make([]*ChatContent, 0, len(contents))
+	for i, content := range contents {
+		if i > 0 && mergedContents[len(mergedContents)-1].Role == content.Role {
+			// Merge with previous message of the same role
+			mergedContents[len(mergedContents)-1].Parts = append(
+				mergedContents[len(mergedContents)-1].Parts,
+				content.Parts...,
+			)
+		} else {
+			mergedContents = append(mergedContents, content)
+		}
+	}
+
+	return systemContent, mergedContents, imageTasks
 }
 
 func processImageTasks(ctx context.Context, imageTasks []*Part) error {
@@ -573,6 +612,15 @@ func getToolCall(item *Part) (*relaymodel.ToolCall, error) {
 		},
 	}
 
+	// Preserve Gemini thought signature if present (OpenAI format)
+	if item.ThoughtSignature != "" {
+		toolCall.ExtraContent = &relaymodel.ExtraContent{
+			Google: &relaymodel.GoogleExtraContent{
+				ThoughtSignature: item.ThoughtSignature,
+			},
+		}
+	}
+
 	return &toolCall, nil
 }
 

+ 1 - 0
core/relay/adaptor/gemini/model.go

@@ -64,6 +64,7 @@ type Part struct {
 	FunctionResponse *FunctionResponse `json:"functionResponse,omitempty"`
 	Text             string            `json:"text,omitempty"`
 	Thought          bool              `json:"thought,omitempty"`
+	ThoughtSignature string            `json:"thoughtSignature,omitempty"`
 }
 
 type ChatContent struct {

+ 15 - 3
core/relay/adaptor/openai/claude.go

@@ -128,7 +128,9 @@ func convertClaudeMessagesToOpenAI(
 		openAIMsg.ToolCalls = result.ToolCalls
 
 		openAIMsg.Content = result.Content
-		if openAIMsg.Content != nil {
+		// Include the message if it has content OR tool calls
+		// This is important for function calling flow where assistant may only have tool calls
+		if openAIMsg.Content != nil || len(openAIMsg.ToolCalls) > 0 {
 			messages = append(messages, openAIMsg)
 		}
 	}
@@ -196,14 +198,24 @@ func convertClaudeContent(content any) convertClaudeContentResult {
 			case "tool_use":
 				// Handle tool calls
 				args, _ := sonic.MarshalString(content.Input)
-				result.ToolCalls = append(result.ToolCalls, relaymodel.ToolCall{
+				toolCall := relaymodel.ToolCall{
 					ID:   content.ID,
 					Type: "function",
 					Function: relaymodel.Function{
 						Name:      content.Name,
 						Arguments: args,
 					},
-				})
+				}
+				// Preserve Gemini thought signature if present (OpenAI format)
+				if content.ThoughtSignature != "" {
+					toolCall.ExtraContent = &relaymodel.ExtraContent{
+						Google: &relaymodel.GoogleExtraContent{
+							ThoughtSignature: content.ThoughtSignature,
+						},
+					}
+				}
+
+				result.ToolCalls = append(result.ToolCalls, toolCall)
 			case "tool_result":
 				// Create a separate tool message for each tool_result
 				var newContent any

+ 2 - 0
core/relay/model/claude.go

@@ -58,6 +58,8 @@ type ClaudeContent struct {
 	Content      any                 `json:"content,omitempty"`
 	ToolUseID    string              `json:"tool_use_id,omitempty"`
 	CacheControl *ClaudeCacheControl `json:"cache_control,omitempty"`
+	// Gemini-specific field to store thought signature for function calls
+	ThoughtSignature string `json:"thought_signature,omitempty"`
 }
 
 type ClaudeAnyContentMessage struct {

+ 13 - 4
core/relay/model/tool.go

@@ -12,9 +12,18 @@ type Function struct {
 	Name        string `json:"name,omitempty"`
 }
 
+type GoogleExtraContent struct {
+	ThoughtSignature string `json:"thought_signature,omitempty"`
+}
+
+type ExtraContent struct {
+	Google *GoogleExtraContent `json:"google,omitempty"`
+}
+
 type ToolCall struct {
-	Index    int      `json:"index"`
-	ID       string   `json:"id"`
-	Type     string   `json:"type"`
-	Function Function `json:"function"`
+	Index        int           `json:"index"`
+	ID           string        `json:"id"`
+	Type         string        `json:"type"`
+	Function     Function      `json:"function"`
+	ExtraContent *ExtraContent `json:"extra_content,omitempty"`
 }