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
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user