Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions go/ai/formatter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -661,17 +661,14 @@ func TestResolveFormat(t *testing.T) {
}
})

t.Run("defaults to text even when schema present but no format", func(t *testing.T) {
t.Run("defaults to json even when schema present but no format", func(t *testing.T) {
schema := map[string]any{"type": "object"}
formatter, err := resolveFormat(r, schema, "")
if err != nil {
t.Fatalf("resolveFormat() error = %v", err)
}
// Note: The current implementation defaults to text when format is empty,
// even if schema is present. The schema/format combination is typically
// handled at a higher level (e.g., in Generate options).
if formatter.Name() != OutputFormatText {
t.Errorf("resolveFormat() = %q, want %q", formatter.Name(), OutputFormatText)
if formatter.Name() != OutputFormatJSON {
t.Errorf("resolveFormat() = %q, want %q", formatter.Name(), OutputFormatJSON)
}
})

Expand Down
2 changes: 1 addition & 1 deletion go/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ require (
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0
golang.org/x/tools v0.34.0
google.golang.org/api v0.236.0
google.golang.org/genai v1.36.0
google.golang.org/genai v1.40.0
)

require (
Expand Down
4 changes: 2 additions & 2 deletions go/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -537,8 +537,8 @@ google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9Ywl
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=
google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
google.golang.org/genai v1.36.0 h1:sJCIjqTAmwrtAIaemtTiKkg2TO1RxnYEusTmEQ3nGxM=
google.golang.org/genai v1.36.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
google.golang.org/genai v1.40.0 h1:kYxyQSH+vsib8dvsgyLJzsVEIv5k3ZmHJyVqdvGncmc=
google.golang.org/genai v1.40.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
Expand Down
62 changes: 59 additions & 3 deletions go/plugins/googlegenai/gemini.go
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,34 @@ func toGeminiTools(inTools []*ai.ToolDefinition) ([]*genai.Tool, error) {
return outTools, nil
}

// toGeminiFunctionResponsePart translates a slice of [ai.Part] to a slice of [genai.FunctionResponsePart]
func toGeminiFunctionResponsePart(parts []*ai.Part) ([]*genai.FunctionResponsePart, error) {
frp := []*genai.FunctionResponsePart{}
for _, p := range parts {
switch {
case p.IsData():
contentType, data, err := uri.Data(p)
if err != nil {
return nil, err
}
frp = append(frp, genai.NewFunctionResponsePartFromBytes(data, contentType))
case p.IsMedia():
if strings.HasPrefix(p.Text, "data:") {
contentType, data, err := uri.Data(p)
if err != nil {
return nil, err
}
frp = append(frp, genai.NewFunctionResponsePartFromBytes(data, contentType))
continue
}
frp = append(frp, genai.NewFunctionResponsePartFromURI(p.Text, p.ContentType))
default:
return nil, fmt.Errorf("unsupported function response part type: %d", p.Kind)
}
}
return frp, nil
}

// mergeTools consolidates all FunctionDeclarations into a single Tool
// while preserving non-function tools (Retrieval, GoogleSearch, CodeExecution, etc.)
func mergeTools(ts []*genai.Tool) []*genai.Tool {
Expand Down Expand Up @@ -814,6 +842,14 @@ func translateCandidate(cand *genai.Candidate) (*ai.ModelResponse, error) {
Name: part.FunctionCall.Name,
Input: part.FunctionCall.Args,
})
// FunctionCall parts may contain a ThoughtSignature that must be preserved
// and returned in subsequent requests for the tool call to be valid.
if len(part.ThoughtSignature) > 0 {
if p.Metadata == nil {
p.Metadata = make(map[string]any)
}
p.Metadata["signature"] = part.ThoughtSignature
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@apascal07 Wanted to ask, is #3902 related to this signature handling for tool calls? or is it something else?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's for reasoning, it's a token that the model returns that must be propagated to subsequent calls in the conversation otherwise it will error.

}
}
if part.CodeExecutionResult != nil {
partFound++
Expand Down Expand Up @@ -894,7 +930,7 @@ func toGeminiParts(parts []*ai.Part) ([]*genai.Part, error) {
func toGeminiPart(p *ai.Part) (*genai.Part, error) {
switch {
case p.IsReasoning():
// TODO: go-genai does not support genai.NewPartFromThought()
// NOTE: go-genai does not support genai.NewPartFromThought()
signature := []byte{}
if p.Metadata != nil {
if sig, ok := p.Metadata["signature"].([]byte); ok {
Expand Down Expand Up @@ -934,8 +970,22 @@ func toGeminiPart(p *ai.Part) (*genai.Part, error) {
"content": toolResp.Output,
}
}
fr := genai.NewPartFromFunctionResponse(toolResp.Name, output)
return fr, nil

var isMultipart bool
if multiPart, ok := p.Metadata["multipart"].(bool); ok {
isMultipart = multiPart
}
if len(toolResp.Content) > 0 {
isMultipart = true
}
if isMultipart {
toolRespParts, err := toGeminiFunctionResponsePart(toolResp.Content)
if err != nil {
return nil, err
}
return genai.NewPartFromFunctionResponseWithParts(toolResp.Name, output, toolRespParts), nil
}
return genai.NewPartFromFunctionResponse(toolResp.Name, output), nil
case p.IsToolRequest():
toolReq := p.ToolRequest
var input map[string]any
Expand All @@ -947,6 +997,12 @@ func toGeminiPart(p *ai.Part) (*genai.Part, error) {
}
}
fc := genai.NewPartFromFunctionCall(toolReq.Name, input)
// Restore ThoughtSignature if present in metadata
if p.Metadata != nil {
if sig, ok := p.Metadata["signature"].([]byte); ok {
fc.ThoughtSignature = sig
}
}
return fc, nil
default:
panic("unknown part type in a request")
Expand Down
76 changes: 76 additions & 0 deletions go/plugins/googlegenai/gemini_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,82 @@ func TestValidToolName(t *testing.T) {
}
}

func TestToGeminiParts_MultipartToolResponse(t *testing.T) {
t.Run("ValidPartType", func(t *testing.T) {
// Create a tool response with both output and additional content (media)
toolResp := &ai.ToolResponse{
Name: "generateImage",
Output: map[string]any{"status": "success"},
Content: []*ai.Part{
ai.NewMediaPart("image/png", "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="),
},
}

// create a mock ToolResponsePart, setting "multipart" to true is required
part := ai.NewToolResponsePart(toolResp)
part.Metadata = map[string]any{"multipart": true}

geminiParts, err := toGeminiParts([]*ai.Part{part})
if err != nil {
t.Fatalf("toGeminiParts failed: %v", err)
}

// Expecting 1 part which contains the function response with internal parts
if len(geminiParts) != 1 {
t.Fatalf("expected 1 Gemini part, got %d", len(geminiParts))
}

if geminiParts[0].FunctionResponse == nil {
t.Error("expected first part to be FunctionResponse")
}
if geminiParts[0].FunctionResponse.Name != "generateImage" {
t.Errorf("expected function name 'generateImage', got %q", geminiParts[0].FunctionResponse.Name)
}
})

t.Run("UnsupportedPartType", func(t *testing.T) {
// Create a tool response with text content (unsupported for multipart)
toolResp := &ai.ToolResponse{
Name: "generateText",
Output: map[string]any{"status": "success"},
Content: []*ai.Part{
ai.NewTextPart("Generated text"),
},
}

part := ai.NewToolResponsePart(toolResp)
part.Metadata = map[string]any{"multipart": true}

_, err := toGeminiParts([]*ai.Part{part})
if err == nil {
t.Fatal("expected error for unsupported text part in multipart response, got nil")
}
})
}

func TestToGeminiParts_SimpleToolResponse(t *testing.T) {
// Create a simple tool response (no content)
toolResp := &ai.ToolResponse{
Name: "search",
Output: map[string]any{"result": "foo"},
}

part := ai.NewToolResponsePart(toolResp)

geminiParts, err := toGeminiParts([]*ai.Part{part})
if err != nil {
t.Fatalf("toGeminiParts failed: %v", err)
}

if len(geminiParts) != 1 {
t.Fatalf("expected 1 Gemini part, got %d", len(geminiParts))
}

if geminiParts[0].FunctionResponse == nil {
t.Error("expected part to be FunctionResponse")
}
}

// genToolName generates a string of a specified length using only
// the valid characters for a Gemini Tool name
func genToolName(length int, chars string) string {
Expand Down
37 changes: 34 additions & 3 deletions go/plugins/googlegenai/googleai_live_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ func TestGoogleAILive(t *testing.T) {
t.Fatal(err)
}

out := resp.Message.Content[0].Text
out := resp.Text()
const want = "11.31"
if !strings.Contains(out, want) {
t.Errorf("got %q, expecting it to contain %q", out, want)
Expand Down Expand Up @@ -219,7 +219,7 @@ func TestGoogleAILive(t *testing.T) {
t.Fatal(err)
}

out := resp.Message.Content[0].Text
out := resp.Text()
const want = "11.31"
if !strings.Contains(out, want) {
t.Errorf("got %q, expecting it to contain %q", out, want)
Expand Down Expand Up @@ -307,7 +307,7 @@ func TestGoogleAILive(t *testing.T) {
t.Fatal(err)
}

out := resp.Message.Content[0].Text
out := resp.Text()
const doNotWant = "11.31"
if strings.Contains(out, doNotWant) {
t.Errorf("got %q, expecting it NOT to contain %q", out, doNotWant)
Expand Down Expand Up @@ -582,6 +582,37 @@ func TestGoogleAILive(t *testing.T) {
t.Fatal("thoughts tokens should be zero")
}
})
t.Run("multipart tool", func(t *testing.T) {
m := googlegenai.GoogleAIModel(g, "gemini-3-pro-preview")
img64, err := fetchImgAsBase64()
if err != nil {
t.Fatal(err)
}

tool := genkit.DefineMultipartTool(g, "getImage", "returns a misterious image",
func(ctx *ai.ToolContext, input any) (*ai.MultipartToolResponse, error) {
return &ai.MultipartToolResponse{
Output: map[string]any{"status": "success"},
Content: []*ai.Part{
ai.NewMediaPart("image/jpeg", "data:image/jpeg;base64,"+img64),
},
}, nil
},
)

resp, err := genkit.Generate(ctx, g,
ai.WithModel(m),
ai.WithTools(tool),
ai.WithPrompt("get an image and tell me what is in it"),
)
if err != nil {
t.Fatal(err)
}

if !strings.Contains(strings.ToLower(resp.Text()), "cat") {
t.Errorf("expected response to contain 'cat', got: %s", resp.Text())
}
})
}

func TestCacheHelper(t *testing.T) {
Expand Down
68 changes: 68 additions & 0 deletions go/samples/multipart-tools/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Copyright 2025 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package main

import (
"context"

"github.com/firebase/genkit/go/ai"
"github.com/firebase/genkit/go/genkit"
"github.com/firebase/genkit/go/plugins/googlegenai"
"google.golang.org/genai"
)

func main() {
ctx := context.Background()

g := genkit.Init(ctx, genkit.WithPlugins(&googlegenai.GoogleAI{}))

// Define a multipart tool.
// This simulates a tool that takes a screenshot
screenshot := genkit.DefineMultipartTool(g, "screenshot", "Takes a screenshot",
func(ctx *ai.ToolContext, input any) (*ai.MultipartToolResponse, error) {
rectangle := "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAHIAAABUAQMAAABk5vEVAAAABlBMVEX///8AAABVwtN+" +
"AAAAI0lEQVR4nGNgGHaA/z8UHIDwOWASDqP8Uf7w56On/1FAQwAAVM0exw1hqwkAAAAASUVORK5CYII="
return &ai.MultipartToolResponse{
Output: map[string]any{"success": true},
Content: []*ai.Part{
ai.NewMediaPart("image/png", rectangle),
},
}, nil
},
)

// Define a simple flow that uses the multipart tool
genkit.DefineStreamingFlow(g, "cardFlow", func(ctx context.Context, input any, cb ai.ModelStreamCallback) (string, error) {
resp, err := genkit.Generate(ctx, g,
ai.WithModelName("googleai/gemini-3-pro-preview"),
ai.WithConfig(&genai.GenerateContentConfig{
Temperature: genai.Ptr[float32](1.0),
ThinkingConfig: &genai.ThinkingConfig{
ThinkingLevel: genai.ThinkingLevelHigh,
},
}),
ai.WithTools(screenshot),
ai.WithStreaming(cb),
ai.WithPrompt("Tell me what I'm seeing in the screen"),
)
if err != nil {
return "", err
}

return resp.Text(), nil
})

<-ctx.Done()
}
Loading