From eb90f949a0df5f455dba931e0d8a9d45f93bf384 Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Fri, 17 Jul 2026 13:41:41 +0000 Subject: [PATCH] fix: Gemini provider compatibility with Gemini 3 models - Strip additionalProperties and $schema from tool parameter schemas (Gemini API rejects these unsupported JSON Schema fields) - Add thoughtSignature to functionCall parts for multi-turn tool calling (Gemini 3 models require thought signatures on function call history) - Ensure functionResponse.response is always a JSON object, never an array or primitive (wrap non-objects in {"result": ...}) - Resolve tool names from tool_call_id or positional index when the tool message doesn't carry a name field - Add functionCall parsing in streaming responses (emitGeminiChunk) to properly relay tool calls via SSE - Add error logging for Gemini stream failures with request body dump - Add unit tests for schema cleaning and streaming tool call emission --- internal/providers/gemini.go | 120 ++++++++++++++--- internal/providers/gemini_test.go | 211 ++++++++++++++++++++++++++++++ internal/providers/helpers.go | 28 +++- 3 files changed, 337 insertions(+), 22 deletions(-) create mode 100644 internal/providers/gemini_test.go diff --git a/internal/providers/gemini.go b/internal/providers/gemini.go index 26b38fc7..47d8b6ee 100644 --- a/internal/providers/gemini.go +++ b/internal/providers/gemini.go @@ -59,6 +59,7 @@ type GeminiPart struct { InlineData *GeminiInlineData `json:"inlineData,omitempty"` FunctionCall *GeminiFunctionCall `json:"functionCall,omitempty"` FunctionResponse *GeminiFunctionResponse `json:"functionResponse,omitempty"` + ThoughtSignature string `json:"thoughtSignature,omitempty"` } type GeminiInlineData struct { @@ -231,6 +232,7 @@ func (p *GeminiProvider) ChatCompletion(ctx context.Context, req *models.Unified Name: tc.Function.Name, Args: json.RawMessage(tc.Function.Arguments), }, + ThoughtSignature: "skip_thought_signature_validator", }) } contents = append(contents, GeminiContent{Role: "model", Parts: parts}) @@ -256,16 +258,9 @@ func (p *GeminiProvider) ChatCompletion(ctx context.Context, req *models.Unified if len(m.Content) > 0 { text = m.Content[0].Text } - name := "unknown_function" - if m.Name != nil { - name = *m.Name - } + name := resolveToolName(m, msg.ToolCalls, j-i-1) - var responseObj interface{} - if err := json.Unmarshal([]byte(text), &responseObj); err != nil { - responseObj = map[string]interface{}{"result": text} - } - respBytes, _ := json.Marshal(responseObj) + respBytes := ensureJSONObject(text) functionParts = append(functionParts, GeminiPart{ FunctionResponse: &GeminiFunctionResponse{ @@ -353,7 +348,9 @@ func (p *GeminiProvider) ChatCompletion(ctx context.Context, req *models.Unified geminiTool := GeminiTool{FunctionDeclarations: []models.FunctionDef{}} for _, t := range req.Tools { if t.Type == "function" { - geminiTool.FunctionDeclarations = append(geminiTool.FunctionDeclarations, t.Function) + funcDef := t.Function + funcDef.Parameters = cleanParametersSchema(funcDef.Parameters) + geminiTool.FunctionDeclarations = append(geminiTool.FunctionDeclarations, funcDef) } } if len(geminiTool.FunctionDeclarations) > 0 { @@ -509,6 +506,7 @@ func (p *GeminiProvider) ChatCompletionStream(ctx context.Context, req *models.U Name: tc.Function.Name, Args: json.RawMessage(tc.Function.Arguments), }, + ThoughtSignature: "skip_thought_signature_validator", }) } contents = append(contents, GeminiContent{Role: "model", Parts: parts}) @@ -522,16 +520,9 @@ func (p *GeminiProvider) ChatCompletionStream(ctx context.Context, req *models.U if len(m.Content) > 0 { text = m.Content[0].Text } - name := "unknown_function" - if m.Name != nil { - name = *m.Name - } + name := resolveToolName(m, msg.ToolCalls, j-i-1) - var responseObj interface{} - if err := json.Unmarshal([]byte(text), &responseObj); err != nil { - responseObj = map[string]interface{}{"result": text} - } - respBytes, _ := json.Marshal(responseObj) + respBytes := ensureJSONObject(text) functionParts = append(functionParts, GeminiPart{ FunctionResponse: &GeminiFunctionResponse{ @@ -610,7 +601,9 @@ func (p *GeminiProvider) ChatCompletionStream(ctx context.Context, req *models.U geminiTool := GeminiTool{FunctionDeclarations: []models.FunctionDef{}} for _, t := range req.Tools { if t.Type == "function" { - geminiTool.FunctionDeclarations = append(geminiTool.FunctionDeclarations, t.Function) + funcDef := t.Function + funcDef.Parameters = cleanParametersSchema(funcDef.Parameters) + geminiTool.FunctionDeclarations = append(geminiTool.FunctionDeclarations, funcDef) } } if len(geminiTool.FunctionDeclarations) > 0 { @@ -652,6 +645,9 @@ func (p *GeminiProvider) ChatCompletionStream(ctx context.Context, req *models.U msg = string(body) } } + fmt.Printf("[Gemini-Stream] API Error %d: %s\n", resp.StatusCode(), msg) + reqJSON, _ := json.Marshal(body) + fmt.Printf("[Gemini-Stream] Request Body: %s\n", string(reqJSON)) return nil, fmt.Errorf("Gemini API error (%d): %s", resp.StatusCode(), msg) } @@ -668,3 +664,87 @@ func uint32Ptr(v uint32) *uint32 { } return nil } + +func cleanGeminiSchema(schema map[string]interface{}) { + delete(schema, "additionalProperties") + delete(schema, "$schema") + for _, v := range schema { + if subMap, ok := v.(map[string]interface{}); ok { + cleanGeminiSchema(subMap) + } else if subList, ok := v.([]interface{}); ok { + for _, item := range subList { + if itemMap, ok := item.(map[string]interface{}); ok { + cleanGeminiSchema(itemMap) + } + } + } + } +} + +func cleanParametersSchema(raw json.RawMessage) json.RawMessage { + if len(raw) == 0 { + return raw + } + var schema map[string]interface{} + if err := json.Unmarshal(raw, &schema); err != nil { + return raw + } + cleanGeminiSchema(schema) + cleaned, err := json.Marshal(schema) + if err != nil { + return raw + } + return json.RawMessage(cleaned) +} + +// ensureJSONObject ensures that a tool result string is serialized as a JSON object +// for Gemini's functionResponse.response field, which must always be an object. +// If the text is a JSON array, primitive, or invalid JSON, it gets wrapped in {"result": ...}. +func ensureJSONObject(text string) []byte { + if text == "" { + b, _ := json.Marshal(map[string]interface{}{"result": ""}) + return b + } + + var parsed interface{} + if err := json.Unmarshal([]byte(text), &parsed); err != nil { + // Not valid JSON — wrap as string + b, _ := json.Marshal(map[string]interface{}{"result": text}) + return b + } + + // Only allow map types through; wrap everything else + if _, ok := parsed.(map[string]interface{}); ok { + return []byte(text) + } + + // It's a JSON array, number, string, bool, or null — wrap it + b, _ := json.Marshal(map[string]interface{}{"result": parsed}) + return b +} + +// resolveToolName determines the function name for a tool response message. +// It tries: 1) the Name field on the tool message, 2) matching by ToolCallID +// against the preceding assistant's tool calls, 3) positional index match. +func resolveToolName(toolMsg models.UnifiedMessage, toolCalls []models.ToolCall, posIndex int) string { + if toolMsg.Name != nil && *toolMsg.Name != "" { + return *toolMsg.Name + } + + // Try to match by tool_call_id + if toolMsg.ToolCallID != nil && *toolMsg.ToolCallID != "" { + for _, tc := range toolCalls { + if tc.ID == *toolMsg.ToolCallID { + return tc.Function.Name + } + } + } + + // Positional fallback + if posIndex >= 0 && posIndex < len(toolCalls) { + return toolCalls[posIndex].Function.Name + } + + return "unknown_function" +} + diff --git a/internal/providers/gemini_test.go b/internal/providers/gemini_test.go new file mode 100644 index 00000000..cb1522b7 --- /dev/null +++ b/internal/providers/gemini_test.go @@ -0,0 +1,211 @@ +package providers + +import ( + "encoding/json" + "reflect" + "testing" + + "gophergate/internal/models" +) + +func TestCleanParametersSchema(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "flat additionalProperties and $schema", + input: `{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "query": { + "type": "string" + } + }, + "required": ["query"], + "additionalProperties": false + }`, + expected: `{ + "type": "object", + "properties": { + "query": { + "type": "string" + } + }, + "required": ["query"] + }`, + }, + { + name: "nested additionalProperties", + input: `{ + "type": "object", + "properties": { + "user": { + "type": "object", + "properties": { + "id": { + "type": "integer" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": true + }`, + expected: `{ + "type": "object", + "properties": { + "user": { + "type": "object", + "properties": { + "id": { + "type": "integer" + } + } + } + } + }`, + }, + { + name: "nested additionalProperties in array items", + input: `{ + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": false + } + } + } + }`, + expected: `{ + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + } + } + } + }`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + inputRaw := json.RawMessage(tc.input) + cleanedRaw := cleanParametersSchema(inputRaw) + + var cleanedMap, expectedMap map[string]interface{} + if err := json.Unmarshal(cleanedRaw, &cleanedMap); err != nil { + t.Fatalf("failed to unmarshal cleaned: %v", err) + } + if err := json.Unmarshal([]byte(tc.expected), &expectedMap); err != nil { + t.Fatalf("failed to unmarshal expected: %v", err) + } + + if !reflect.DeepEqual(cleanedMap, expectedMap) { + t.Errorf("expected %v, got %v", expectedMap, cleanedMap) + } + }) + } +} + +func TestEmitGeminiChunk_ToolCalls(t *testing.T) { + ch := make(chan *models.ChatCompletionStreamResponse, 1) + defer close(ch) + + chunk := &geminiStreamChunk{} + chunk.Candidates = []struct { + Content struct { + Parts []struct { + Text string `json:"text,omitempty"` + Thought string `json:"thought,omitempty"` + FunctionCall *struct { + Name string `json:"name"` + Args json.RawMessage `json:"args"` + } `json:"functionCall,omitempty"` + } `json:"parts"` + } `json:"content"` + FinishReason string `json:"finishReason"` + }{ + { + Content: struct { + Parts []struct { + Text string `json:"text,omitempty"` + Thought string `json:"thought,omitempty"` + FunctionCall *struct { + Name string `json:"name"` + Args json.RawMessage `json:"args"` + } `json:"functionCall,omitempty"` + } `json:"parts"` + }{ + Parts: []struct { + Text string `json:"text,omitempty"` + Thought string `json:"thought,omitempty"` + FunctionCall *struct { + Name string `json:"name"` + Args json.RawMessage `json:"args"` + } `json:"functionCall,omitempty"` + }{ + { + FunctionCall: &struct { + Name string `json:"name"` + Args json.RawMessage `json:"args"` + }{ + Name: "google_search", + Args: json.RawMessage(`{"query": "test"}`), + }, + }, + }, + }, + FinishReason: "", + }, + } + + emitted := emitGeminiChunk(ch, chunk, "gemini-3-flash-preview") + if !emitted { + t.Fatalf("expected emitGeminiChunk to return true") + } + + select { + case resp := <-ch: + if len(resp.Choices) != 1 { + t.Fatalf("expected 1 choice, got %d", len(resp.Choices)) + } + choice := resp.Choices[0] + if choice.FinishReason == nil || *choice.FinishReason != "tool_calls" { + t.Errorf("expected finish_reason 'tool_calls', got %v", choice.FinishReason) + } + if len(choice.Delta.ToolCalls) != 1 { + t.Fatalf("expected 1 tool call in delta, got %d", len(choice.Delta.ToolCalls)) + } + tc := choice.Delta.ToolCalls[0] + if tc.ID == nil || *tc.ID != "call_google_search" { + t.Errorf("expected ID 'call_google_search', got %v", tc.ID) + } + if tc.Function == nil || tc.Function.Name == nil || *tc.Function.Name != "google_search" { + t.Errorf("expected function name 'google_search', got %v", tc.Function) + } + if tc.Function == nil || tc.Function.Arguments == nil || *tc.Function.Arguments != `{"query": "test"}` { + t.Errorf("expected arguments '{\"query\": \"test\"}', got %v", tc.Function) + } + default: + t.Fatalf("expected response on channel") + } +} diff --git a/internal/providers/helpers.go b/internal/providers/helpers.go index 05559070..84e99877 100644 --- a/internal/providers/helpers.go +++ b/internal/providers/helpers.go @@ -409,8 +409,12 @@ type geminiStreamChunk struct { Candidates []struct { Content struct { Parts []struct { - Text string `json:"text,omitempty"` - Thought string `json:"thought,omitempty"` + Text string `json:"text,omitempty"` + Thought string `json:"thought,omitempty"` + FunctionCall *struct { + Name string `json:"name"` + Args json.RawMessage `json:"args"` + } `json:"functionCall,omitempty"` } `json:"parts"` } `json:"content"` FinishReason string `json:"finishReason"` @@ -433,6 +437,7 @@ func emitGeminiChunk(ch chan<- *models.ChatCompletionStreamResponse, chunk *gemi content := "" var reasoning *string var finishReason *string + var toolCalls []models.ToolCallDelta if len(chunk.Candidates) > 0 { for _, p := range chunk.Candidates[0].Content.Parts { if p.Text != "" { @@ -444,8 +449,26 @@ func emitGeminiChunk(ch chan<- *models.ChatCompletionStreamResponse, chunk *gemi } *reasoning += p.Thought } + if p.FunctionCall != nil { + name := p.FunctionCall.Name + args := string(p.FunctionCall.Args) + tcID := fmt.Sprintf("call_%s", name) + tcType := "function" + toolCalls = append(toolCalls, models.ToolCallDelta{ + Index: uint32(len(toolCalls)), + ID: &tcID, + Type: &tcType, + Function: &models.FunctionCallDelta{ + Name: &name, + Arguments: &args, + }, + }) + } } fr := strings.ToLower(chunk.Candidates[0].FinishReason) + if len(toolCalls) > 0 && fr == "" { + fr = "tool_calls" + } finishReason = &fr } @@ -460,6 +483,7 @@ func emitGeminiChunk(ch chan<- *models.ChatCompletionStreamResponse, chunk *gemi Delta: models.ChatStreamDelta{ Content: &content, ReasoningContent: reasoning, + ToolCalls: toolCalls, }, FinishReason: finishReason, },