From d88003b335e08dbc174dd6965d01a8ba440e8b97 Mon Sep 17 00:00:00 2001 From: Hugo Aguirre Parra Date: Wed, 19 Nov 2025 15:55:07 +0000 Subject: [PATCH 1/8] fix(go): aggregated stream responses --- go/ai/generate.go | 12 +++++++++++- go/go.mod | 2 +- go/go.sum | 4 ++-- go/plugins/googlegenai/gemini.go | 29 +++++++++++++---------------- 4 files changed, 27 insertions(+), 20 deletions(-) diff --git a/go/ai/generate.go b/go/ai/generate.go index fe8470d301..739c4fa1e8 100644 --- a/go/ai/generate.go +++ b/go/ai/generate.go @@ -340,10 +340,20 @@ func GenerateWithRequest(ctx context.Context, r api.Registry, opts *GenerateActi currentIndex++ currentRole = chunk.Role } - chunk.Index = currentIndex if chunk.Role == "" { chunk.Role = RoleModel } + // For model streams, the plugin provides a relative chunk index. + // We make it absolute by adding the current message index. + if chunk.Role == RoleModel { + chunk.Index = currentIndex + chunk.Index + } else { + // For other roles (like Tool), the caller is assumed to provide the + // correct absolute index. + chunk.Index = currentIndex + } + fmt.Printf("[%d] returning chunk: %#v\n", chunk.Index, chunk) + fmt.Printf("\t== chunk content: %s\n", chunk.Text()) return cb(ctx, chunk) } } diff --git a/go/go.mod b/go/go.mod index cbde46d01c..70a8eb0a54 100644 --- a/go/go.mod +++ b/go/go.mod @@ -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.30.0 + google.golang.org/genai v1.36.0 ) require ( diff --git a/go/go.sum b/go/go.sum index 7100070ee1..f528809d99 100644 --- a/go/go.sum +++ b/go/go.sum @@ -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.30.0 h1:7021aneIvl24nEBLbtQFEWleHsMbjzpcQvkT4WcJ1dc= -google.golang.org/genai v1.30.0/go.mod h1:7pAilaICJlQBonjKKJNhftDFv3SREhZcTe9F6nRcjbg= +google.golang.org/genai v1.36.0 h1:sJCIjqTAmwrtAIaemtTiKkg2TO1RxnYEusTmEQ3nGxM= +google.golang.org/genai v1.36.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= diff --git a/go/plugins/googlegenai/gemini.go b/go/plugins/googlegenai/gemini.go index ee430a697f..b30d13c67a 100644 --- a/go/plugins/googlegenai/gemini.go +++ b/go/plugins/googlegenai/gemini.go @@ -290,11 +290,12 @@ func generate( // Streaming version. iter := client.Models.GenerateContentStream(ctx, model, contents, gcc) - var r *ai.ModelResponse + var r *ai.ModelResponse // merge all streamed responses var resp *genai.GenerateContentResponse - var chunks []*genai.Part + chunks := []*ai.Part{} + index := 0 for chunk, err := range iter { // abort stream if error found in the iterator items if err != nil { @@ -307,27 +308,22 @@ func generate( } err = cb(ctx, &ai.ModelResponseChunk{ Content: tc.Message.Content, + Role: ai.RoleModel, + Index: index, }) if err != nil { return nil, err } - chunks = append(chunks, c.Content.Parts...) + chunks = append(chunks, tc.Message.Content...) } + index += 1 // keep the last chunk for usage metadata resp = chunk } - // manually merge all candidate responses, iterator does not provide a - // merged response utility - merged := []*genai.Candidate{ - { - Content: &genai.Content{ - Parts: chunks, - }, - }, - } - resp.Candidates = merged r, err = translateResponse(resp) + r.Message.Content = chunks + if err != nil { return nil, fmt.Errorf("failed to generate contents: %w", err) } @@ -704,6 +700,7 @@ func toGeminiToolChoice(toolChoice ai.ToolChoice, tools []*ai.ToolDefinition) (* // translateCandidate translates from a genai.GenerateContentResponse to an ai.ModelResponse. func translateCandidate(cand *genai.Candidate) (*ai.ModelResponse, error) { m := &ai.ModelResponse{} + fmt.Printf("finish reason: %v, finish message: %s\n", cand.FinishReason, cand.FinishMessage) switch cand.FinishReason { case genai.FinishReasonStop: m.FinishReason = ai.FinishReasonStop @@ -715,16 +712,16 @@ func translateCandidate(cand *genai.Candidate) (*ai.ModelResponse, error) { m.FinishReason = ai.FinishReasonBlocked case genai.FinishReasonOther: m.FinishReason = ai.FinishReasonOther - default: // Unspecified - m.FinishReason = ai.FinishReasonUnknown + // default: // Unspecified + // m.FinishReason = ai.FinishReasonUnknown } + m.FinishMessage = cand.FinishMessage if cand.Content == nil { return nil, fmt.Errorf("no valid candidates were found in the generate response") } msg := &ai.Message{} msg.Role = ai.Role(cand.Content.Role) - // iterate over the candidate parts, only one struct member // must be populated, more than one is considered an error for _, part := range cand.Content.Parts { From e26f1a69ae67e0bf6359b46985460fc926022ba1 Mon Sep 17 00:00:00 2001 From: Hugo Aguirre Parra Date: Wed, 19 Nov 2025 21:05:06 +0000 Subject: [PATCH 2/8] include custom plugin metrics --- go/ai/generate.go | 2 -- go/plugins/googlegenai/gemini.go | 32 ++++++++++++++++++++++++++++---- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/go/ai/generate.go b/go/ai/generate.go index 739c4fa1e8..cd0c8a6780 100644 --- a/go/ai/generate.go +++ b/go/ai/generate.go @@ -352,8 +352,6 @@ func GenerateWithRequest(ctx context.Context, r api.Registry, opts *GenerateActi // correct absolute index. chunk.Index = currentIndex } - fmt.Printf("[%d] returning chunk: %#v\n", chunk.Index, chunk) - fmt.Printf("\t== chunk content: %s\n", chunk.Text()) return cb(ctx, chunk) } } diff --git a/go/plugins/googlegenai/gemini.go b/go/plugins/googlegenai/gemini.go index b30d13c67a..7a6f26aeae 100644 --- a/go/plugins/googlegenai/gemini.go +++ b/go/plugins/googlegenai/gemini.go @@ -292,8 +292,9 @@ func generate( iter := client.Models.GenerateContentStream(ctx, model, contents, gcc) var r *ai.ModelResponse - // merge all streamed responses var resp *genai.GenerateContentResponse + + genaiParts := []*genai.Part{} chunks := []*ai.Part{} index := 0 for chunk, err := range iter { @@ -314,13 +315,31 @@ func generate( if err != nil { return nil, err } + genaiParts = append(genaiParts, c.Content.Parts...) chunks = append(chunks, tc.Message.Content...) } index += 1 - // keep the last chunk for usage metadata resp = chunk + + } + + if resp.Candidates == nil { + return nil, fmt.Errorf("no valid candidates found") + } + + // preserve original parts since they will be included in the + // "custom" response field + merged := []*genai.Candidate{ + { + FinishReason: resp.Candidates[0].FinishReason, + Content: &genai.Content{ + Role: string(ai.RoleModel), + Parts: genaiParts, + }, + }, } + resp.Candidates = merged r, err = translateResponse(resp) r.Message.Content = chunks @@ -712,8 +731,6 @@ func translateCandidate(cand *genai.Candidate) (*ai.ModelResponse, error) { m.FinishReason = ai.FinishReasonBlocked case genai.FinishReasonOther: m.FinishReason = ai.FinishReasonOther - // default: // Unspecified - // m.FinishReason = ai.FinishReasonUnknown } m.FinishMessage = cand.FinishMessage @@ -796,13 +813,20 @@ func translateResponse(resp *genai.GenerateContentResponse) (*ai.ModelResponse, r.Usage = &ai.GenerationUsage{} } + // populate "custom" with plugin custom information + custom := make(map[string]any) + custom["candidates"] = resp.Candidates + if u := resp.UsageMetadata; u != nil { r.Usage.InputTokens = int(u.PromptTokenCount) r.Usage.OutputTokens = int(u.CandidatesTokenCount) r.Usage.TotalTokens = int(u.TotalTokenCount) r.Usage.CachedContentTokens = int(u.CachedContentTokenCount) r.Usage.ThoughtsTokens = int(u.ThoughtsTokenCount) + custom["usageMetadata"] = resp.UsageMetadata } + + r.Custom = custom return r, nil } From 6fbb9a17586982e372ea66c71ff189e04bbdf619 Mon Sep 17 00:00:00 2001 From: Hugo Aguirre Parra Date: Thu, 20 Nov 2025 21:34:10 +0000 Subject: [PATCH 3/8] fix: change chunk index to int ptr --- go/ai/gen.go | 2 +- go/ai/generate.go | 16 +++++----------- go/ai/generate_test.go | 7 ++++--- go/core/schemas.config | 2 +- go/plugins/googlegenai/gemini.go | 15 +++++++-------- 5 files changed, 18 insertions(+), 24 deletions(-) diff --git a/go/ai/gen.go b/go/ai/gen.go index 5d24d51bf0..884eb6a900 100644 --- a/go/ai/gen.go +++ b/go/ai/gen.go @@ -271,7 +271,7 @@ type ModelResponseChunk struct { Aggregated bool `json:"aggregated,omitempty"` Content []*Part `json:"content,omitempty"` Custom any `json:"custom,omitempty"` - Index int `json:"index,omitempty"` + Index *int `json:"index,omitempty"` Role Role `json:"role,omitempty"` } diff --git a/go/ai/generate.go b/go/ai/generate.go index cd0c8a6780..a45235743a 100644 --- a/go/ai/generate.go +++ b/go/ai/generate.go @@ -234,10 +234,11 @@ func GenerateWithRequest(ctx context.Context, r api.Registry, opts *GenerateActi opts = resumeOutput.revisedRequest if resumeOutput.toolMessage != nil && cb != nil { + idx := 0 err := cb(ctx, &ModelResponseChunk{ Content: resumeOutput.toolMessage.Content, Role: RoleTool, - Index: 0, + Index: &idx, }) if err != nil { return nil, fmt.Errorf("streaming callback failed for resumed tool message: %w", err) @@ -340,18 +341,10 @@ func GenerateWithRequest(ctx context.Context, r api.Registry, opts *GenerateActi currentIndex++ currentRole = chunk.Role } + chunk.Index = ¤tIndex if chunk.Role == "" { chunk.Role = RoleModel } - // For model streams, the plugin provides a relative chunk index. - // We make it absolute by adding the current message index. - if chunk.Role == RoleModel { - chunk.Index = currentIndex + chunk.Index - } else { - // For other roles (like Tool), the caller is assumed to provide the - // correct absolute index. - chunk.Index = currentIndex - } return cb(ctx, chunk) } } @@ -715,10 +708,11 @@ func handleToolRequests(ctx context.Context, r api.Registry, req *ModelRequest, toolMsg.Content = toolResps if cb != nil { + idx := messageIndex + 1 err := cb(ctx, &ModelResponseChunk{ Content: toolMsg.Content, Role: RoleTool, - Index: messageIndex + 1, + Index: &idx, }) if err != nil { return nil, nil, fmt.Errorf("streaming callback failed: %w", err) diff --git a/go/ai/generate_test.go b/go/ai/generate_test.go index 95299f1852..81637d71ba 100644 --- a/go/ai/generate_test.go +++ b/go/ai/generate_test.go @@ -98,7 +98,8 @@ func TestStreamingChunksHaveRoleAndIndex(t *testing.T) { From string To string Temperature float64 - }) (float64, error) { + }, + ) (float64, error) { if input.From == "celsius" && input.To == "fahrenheit" { return input.Temperature*9/5 + 32, nil } @@ -185,7 +186,7 @@ func TestStreamingChunksHaveRoleAndIndex(t *testing.T) { if chunks[0].Role != RoleModel { t.Errorf("Expected first chunk to have role 'model', got %s", chunks[0].Role) } - if chunks[0].Index != 0 { + if *chunks[0].Index != 0 { t.Errorf("Expected first chunk to have index 0, got %d", chunks[0].Index) } @@ -193,7 +194,7 @@ func TestStreamingChunksHaveRoleAndIndex(t *testing.T) { for _, chunk := range chunks { if chunk.Role == RoleTool { toolChunkFound = true - if chunk.Index != 1 { + if *chunk.Index != 1 { t.Errorf("Expected tool chunk to have index 1, got %d", chunk.Index) } } diff --git a/go/core/schemas.config b/go/core/schemas.config index fba66996b5..737ee518e8 100644 --- a/go/core/schemas.config +++ b/go/core/schemas.config @@ -267,7 +267,7 @@ ModelResponseChunk pkg ai ModelResponseChunk.aggregated type bool ModelResponseChunk.content type []*Part ModelResponseChunk.custom type any -ModelResponseChunk.index type int +ModelResponseChunk.index type *int ModelResponseChunk.role type Role GenerationCommonConfig doc diff --git a/go/plugins/googlegenai/gemini.go b/go/plugins/googlegenai/gemini.go index 7a6f26aeae..635fb86968 100644 --- a/go/plugins/googlegenai/gemini.go +++ b/go/plugins/googlegenai/gemini.go @@ -292,7 +292,7 @@ func generate( iter := client.Models.GenerateContentStream(ctx, model, contents, gcc) var r *ai.ModelResponse - var resp *genai.GenerateContentResponse + var genaiResp *genai.GenerateContentResponse genaiParts := []*genai.Part{} chunks := []*ai.Part{} @@ -310,7 +310,7 @@ func generate( err = cb(ctx, &ai.ModelResponseChunk{ Content: tc.Message.Content, Role: ai.RoleModel, - Index: index, + Index: &index, }) if err != nil { return nil, err @@ -319,11 +319,11 @@ func generate( chunks = append(chunks, tc.Message.Content...) } index += 1 - resp = chunk + genaiResp = chunk } - if resp.Candidates == nil { + if len(genaiResp.Candidates) == 0 { return nil, fmt.Errorf("no valid candidates found") } @@ -331,7 +331,7 @@ func generate( // "custom" response field merged := []*genai.Candidate{ { - FinishReason: resp.Candidates[0].FinishReason, + FinishReason: genaiResp.Candidates[0].FinishReason, Content: &genai.Content{ Role: string(ai.RoleModel), Parts: genaiParts, @@ -339,8 +339,8 @@ func generate( }, } - resp.Candidates = merged - r, err = translateResponse(resp) + genaiResp.Candidates = merged + r, err = translateResponse(genaiResp) r.Message.Content = chunks if err != nil { @@ -719,7 +719,6 @@ func toGeminiToolChoice(toolChoice ai.ToolChoice, tools []*ai.ToolDefinition) (* // translateCandidate translates from a genai.GenerateContentResponse to an ai.ModelResponse. func translateCandidate(cand *genai.Candidate) (*ai.ModelResponse, error) { m := &ai.ModelResponse{} - fmt.Printf("finish reason: %v, finish message: %s\n", cand.FinishReason, cand.FinishMessage) switch cand.FinishReason { case genai.FinishReasonStop: m.FinishReason = ai.FinishReasonStop From 5467a7dd034896da5a3c5e226b8d132efdbeb97f Mon Sep 17 00:00:00 2001 From: Hugo Aguirre Parra Date: Fri, 21 Nov 2025 18:17:59 +0000 Subject: [PATCH 4/8] revert Index changes --- go/ai/gen.go | 2 +- go/ai/generate.go | 8 +++----- go/ai/generate_test.go | 4 ++-- go/core/schemas.config | 2 +- go/plugins/googlegenai/gemini.go | 2 +- 5 files changed, 8 insertions(+), 10 deletions(-) diff --git a/go/ai/gen.go b/go/ai/gen.go index 884eb6a900..5d24d51bf0 100644 --- a/go/ai/gen.go +++ b/go/ai/gen.go @@ -271,7 +271,7 @@ type ModelResponseChunk struct { Aggregated bool `json:"aggregated,omitempty"` Content []*Part `json:"content,omitempty"` Custom any `json:"custom,omitempty"` - Index *int `json:"index,omitempty"` + Index int `json:"index,omitempty"` Role Role `json:"role,omitempty"` } diff --git a/go/ai/generate.go b/go/ai/generate.go index a45235743a..fe8470d301 100644 --- a/go/ai/generate.go +++ b/go/ai/generate.go @@ -234,11 +234,10 @@ func GenerateWithRequest(ctx context.Context, r api.Registry, opts *GenerateActi opts = resumeOutput.revisedRequest if resumeOutput.toolMessage != nil && cb != nil { - idx := 0 err := cb(ctx, &ModelResponseChunk{ Content: resumeOutput.toolMessage.Content, Role: RoleTool, - Index: &idx, + Index: 0, }) if err != nil { return nil, fmt.Errorf("streaming callback failed for resumed tool message: %w", err) @@ -341,7 +340,7 @@ func GenerateWithRequest(ctx context.Context, r api.Registry, opts *GenerateActi currentIndex++ currentRole = chunk.Role } - chunk.Index = ¤tIndex + chunk.Index = currentIndex if chunk.Role == "" { chunk.Role = RoleModel } @@ -708,11 +707,10 @@ func handleToolRequests(ctx context.Context, r api.Registry, req *ModelRequest, toolMsg.Content = toolResps if cb != nil { - idx := messageIndex + 1 err := cb(ctx, &ModelResponseChunk{ Content: toolMsg.Content, Role: RoleTool, - Index: &idx, + Index: messageIndex + 1, }) if err != nil { return nil, nil, fmt.Errorf("streaming callback failed: %w", err) diff --git a/go/ai/generate_test.go b/go/ai/generate_test.go index 81637d71ba..b39778a231 100644 --- a/go/ai/generate_test.go +++ b/go/ai/generate_test.go @@ -186,7 +186,7 @@ func TestStreamingChunksHaveRoleAndIndex(t *testing.T) { if chunks[0].Role != RoleModel { t.Errorf("Expected first chunk to have role 'model', got %s", chunks[0].Role) } - if *chunks[0].Index != 0 { + if chunks[0].Index != 0 { t.Errorf("Expected first chunk to have index 0, got %d", chunks[0].Index) } @@ -194,7 +194,7 @@ func TestStreamingChunksHaveRoleAndIndex(t *testing.T) { for _, chunk := range chunks { if chunk.Role == RoleTool { toolChunkFound = true - if *chunk.Index != 1 { + if chunk.Index != 1 { t.Errorf("Expected tool chunk to have index 1, got %d", chunk.Index) } } diff --git a/go/core/schemas.config b/go/core/schemas.config index 737ee518e8..fba66996b5 100644 --- a/go/core/schemas.config +++ b/go/core/schemas.config @@ -267,7 +267,7 @@ ModelResponseChunk pkg ai ModelResponseChunk.aggregated type bool ModelResponseChunk.content type []*Part ModelResponseChunk.custom type any -ModelResponseChunk.index type *int +ModelResponseChunk.index type int ModelResponseChunk.role type Role GenerationCommonConfig doc diff --git a/go/plugins/googlegenai/gemini.go b/go/plugins/googlegenai/gemini.go index 635fb86968..858e60e808 100644 --- a/go/plugins/googlegenai/gemini.go +++ b/go/plugins/googlegenai/gemini.go @@ -310,7 +310,7 @@ func generate( err = cb(ctx, &ai.ModelResponseChunk{ Content: tc.Message.Content, Role: ai.RoleModel, - Index: &index, + Index: index, }) if err != nil { return nil, err From 53d0e7d332c50819a65ff05a586c9ec78ae440bb Mon Sep 17 00:00:00 2001 From: Hugo Aguirre Parra Date: Fri, 21 Nov 2025 21:25:40 +0000 Subject: [PATCH 5/8] update jsonschemagen to allow empty schema fields --- go/ai/gen.go | 2 +- .../cmd/jsonschemagen/jsonschemagen.go | 24 ++++++++- .../cmd/jsonschemagen/jsonschemagen_test.go | 50 +++++++++++++++++++ 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/go/ai/gen.go b/go/ai/gen.go index 5d24d51bf0..b6b9a62c43 100644 --- a/go/ai/gen.go +++ b/go/ai/gen.go @@ -271,7 +271,7 @@ type ModelResponseChunk struct { Aggregated bool `json:"aggregated,omitempty"` Content []*Part `json:"content,omitempty"` Custom any `json:"custom,omitempty"` - Index int `json:"index,omitempty"` + Index int `json:"index"` Role Role `json:"role,omitempty"` } diff --git a/go/internal/cmd/jsonschemagen/jsonschemagen.go b/go/internal/cmd/jsonschemagen/jsonschemagen.go index 6714c0dce8..a676f8bf2d 100644 --- a/go/internal/cmd/jsonschemagen/jsonschemagen.go +++ b/go/internal/cmd/jsonschemagen/jsonschemagen.go @@ -42,6 +42,14 @@ var ( outputDir = flag.String("outdir", "", "directory to write to, or '-' for stdout") noFormat = flag.Bool("nofmt", false, "do not format output") configFile = flag.String("config", "", "config filename") + + // fieldOmitEmptyConfig maps schemas (e.g., "ModelResponseChunk") to fields (e.g., "index") + // that should *not* receive the `omitempty` JSON tag. + fieldOmitEmptyTag = map[string]map[string]struct{}{ + "ModelResponseChunk": { + "index": {}, + }, + } ) func main() { @@ -241,7 +249,6 @@ func nameAnonymousTypes(schemas map[string]*Schema) { nameFields(prefix+fname, fs.Properties) } } - } for typeName, ts := range schemas { nameFields(typeName, ts.Properties) @@ -407,13 +414,28 @@ func (g *generator) generateStruct(name string, s *Schema, tcfg *itemConfig) err } } g.generateDoc(fs, fcfg) + jsonTag := fmt.Sprintf(`json:"%s,omitempty"`, field) + if skipOmitEmpty(goName, field) { + jsonTag = fmt.Sprintf(`json:"%s"`, field) + } g.pr(fmt.Sprintf(" %s %s `%s`\n", adjustIdentifier(field), typeExpr, jsonTag)) } g.pr("}\n\n") return nil } +// skipOmitEmpty determines whether a schema field should include the +// `omitempty` JSON tag +func skipOmitEmpty(schema, field string) bool { + fields, ok := fieldOmitEmptyTag[schema] + if !ok { + return false + } + _, ok = fields[field] + return ok +} + func (g *generator) generateStringEnum(name string, s *Schema, tcfg *itemConfig) error { g.generateDoc(s, tcfg) goName := tcfg.name diff --git a/go/internal/cmd/jsonschemagen/jsonschemagen_test.go b/go/internal/cmd/jsonschemagen/jsonschemagen_test.go index 09360fc3cc..d903481fa2 100644 --- a/go/internal/cmd/jsonschemagen/jsonschemagen_test.go +++ b/go/internal/cmd/jsonschemagen/jsonschemagen_test.go @@ -57,3 +57,53 @@ func Test(t *testing.T) { } } } + +func TestSkipOmitEmpty(t *testing.T) { + tests := []struct { + name string + schema string + field string + expected bool + }{ + { + name: "ChunkIndexOK", + schema: "ModelResponseChunk", + field: "index", + expected: true, + }, + { + name: "ChunkNoIndex", + schema: "ModelResponseChunk", + field: "text", + expected: false, + }, + { + name: "NotChunkSchema", + schema: "RequestHeader", + field: "ID", + expected: false, + }, + { + name: "ChunkNoField", + schema: "ModelResponseChunk", + field: "", + expected: false, + }, + { + name: "EmptySchema", + schema: "", + field: "index", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := skipOmitEmpty(tt.schema, tt.field) + if actual != tt.expected { + t.Errorf("skipOmitEmpty(schema: %q, field: %q) = %v, want %v", + tt.schema, tt.field, actual, tt.expected) + } + }) + } +} From 7fbf7a71659b7caa669be3de0e21c01c7e1ec4b9 Mon Sep 17 00:00:00 2001 From: Hugo Aguirre Parra Date: Fri, 21 Nov 2025 21:32:13 +0000 Subject: [PATCH 6/8] remove index handling from plugin --- go/plugins/googlegenai/gemini.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/go/plugins/googlegenai/gemini.go b/go/plugins/googlegenai/gemini.go index 858e60e808..0f018edf13 100644 --- a/go/plugins/googlegenai/gemini.go +++ b/go/plugins/googlegenai/gemini.go @@ -296,7 +296,6 @@ func generate( genaiParts := []*genai.Part{} chunks := []*ai.Part{} - index := 0 for chunk, err := range iter { // abort stream if error found in the iterator items if err != nil { @@ -310,7 +309,6 @@ func generate( err = cb(ctx, &ai.ModelResponseChunk{ Content: tc.Message.Content, Role: ai.RoleModel, - Index: index, }) if err != nil { return nil, err @@ -318,7 +316,6 @@ func generate( genaiParts = append(genaiParts, c.Content.Parts...) chunks = append(chunks, tc.Message.Content...) } - index += 1 genaiResp = chunk } From b726caa1d99efe652f6044d2738075b50086b4df Mon Sep 17 00:00:00 2001 From: Hugo Aguirre Parra Date: Fri, 21 Nov 2025 21:42:31 +0000 Subject: [PATCH 7/8] add live test --- go/plugins/googlegenai/googleai_live_test.go | 28 ++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/go/plugins/googlegenai/googleai_live_test.go b/go/plugins/googlegenai/googleai_live_test.go index 2ebe49e114..6f63ba42f6 100644 --- a/go/plugins/googlegenai/googleai_live_test.go +++ b/go/plugins/googlegenai/googleai_live_test.go @@ -176,6 +176,34 @@ func TestGoogleAILive(t *testing.T) { t.Errorf("got %q, expecting it to contain %q", out, want) } }) + t.Run("tool stream", func(t *testing.T) { + parts := 0 + out := "" + final, err := genkit.Generate(ctx, g, + ai.WithPrompt("what is a gablorken of 2 over 3.5?"), + ai.WithTools(gablorkenTool), + ai.WithStreaming(func(ctx context.Context, c *ai.ModelResponseChunk) error { + parts++ + out += c.Content[0].Text + return nil + })) + if err != nil { + t.Fatal(err) + } + out2 := "" + for _, p := range final.Message.Content { + out2 += p.Text + } + if out != out2 { + t.Errorf("streaming and final should contain the same text.\nstreaming:%s\nfinal:%s", out, out2) + } + + const want = "11.31" + if !strings.Contains(final.Text(), want) { + t.Errorf("got %q, expecting it to contain %q", out, want) + } + }) + t.Run("tool with thinking", func(t *testing.T) { m := googlegenai.GoogleAIModel(g, "gemini-2.5-flash") resp, err := genkit.Generate(ctx, g, From aefeac67b7de309e293a8e4c4198eaf9e0cc9274 Mon Sep 17 00:00:00 2001 From: Hugo Aguirre Parra Date: Fri, 21 Nov 2025 21:47:39 +0000 Subject: [PATCH 8/8] update docs --- go/internal/cmd/jsonschemagen/jsonschemagen.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/go/internal/cmd/jsonschemagen/jsonschemagen.go b/go/internal/cmd/jsonschemagen/jsonschemagen.go index a676f8bf2d..71daf886a9 100644 --- a/go/internal/cmd/jsonschemagen/jsonschemagen.go +++ b/go/internal/cmd/jsonschemagen/jsonschemagen.go @@ -43,11 +43,11 @@ var ( noFormat = flag.Bool("nofmt", false, "do not format output") configFile = flag.String("config", "", "config filename") - // fieldOmitEmptyConfig maps schemas (e.g., "ModelResponseChunk") to fields (e.g., "index") - // that should *not* receive the `omitempty` JSON tag. + // fieldOmitEmptyTag maps schemas (e.g., "ModelResponseChunk") to fields (e.g., "index") + // that should not receive the `omitempty` JSON tag. fieldOmitEmptyTag = map[string]map[string]struct{}{ "ModelResponseChunk": { - "index": {}, + "index": {}, // fields should be as defined in core/schemas.config }, } )