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:
+100
-20
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user