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
12 changes: 10 additions & 2 deletions go/adk/pkg/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,10 +228,14 @@ func CreateLLM(ctx context.Context, m adk.Model, log logr.Logger) (adkmodel.LLM,
if err != nil {
return nil, fmt.Errorf("failed to build HTTP client for Gemini: %w", err)
}
return adkgemini.NewModel(ctx, modelName, &genai.ClientConfig{
geminiModel, err := adkgemini.NewModel(ctx, modelName, &genai.ClientConfig{
APIKey: apiKey,
HTTPClient: httpClient,
})
if err != nil {
return nil, err
}
return models.WrapGeminiWithGenerationConfig(geminiModel, m.MaxOutputTokens), nil

case *adk.GeminiVertexAI:
project := os.Getenv("GOOGLE_CLOUD_PROJECT")
Expand All @@ -246,11 +250,15 @@ func CreateLLM(ctx context.Context, m adk.Model, log logr.Logger) (adkmodel.LLM,
if modelName == "" {
modelName = DefaultGeminiModel
}
return adkgemini.NewModel(ctx, modelName, &genai.ClientConfig{
geminiModel, err := adkgemini.NewModel(ctx, modelName, &genai.ClientConfig{
Backend: genai.BackendVertexAI,
Project: project,
Location: location,
})
if err != nil {
return nil, err
}
return models.WrapGeminiWithGenerationConfig(geminiModel, m.MaxOutputTokens), nil

case *adk.Anthropic:
modelName := m.Model
Expand Down
53 changes: 53 additions & 0 deletions go/adk/pkg/models/gemini.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Package models: helpers for the native Gemini / Gemini Vertex AI models.
//
// The ADK Gemini model reads its generation config from the per-request
// LLMRequest.Config rather than from the model definition, so a
// ModelConfig-level setting such as maxOutputTokens is not otherwise applied.
// geminiGenerationConfigModel wraps the ADK model and injects the setting into
// each request, mirroring the Python _GeminiGenerationConfigMixin.
package models

import (
"context"
"iter"

"google.golang.org/adk/model"
"google.golang.org/genai"
)

// geminiGenerationConfigModel wraps an ADK Gemini model.LLM and applies a
// ModelConfig-level generation config (currently max_output_tokens) to each
// request. A per-request value always wins: the cap is only applied when the
// request does not already set MaxOutputTokens.
type geminiGenerationConfigModel struct {
model.LLM
maxOutputTokens int32
}

// WrapGeminiWithGenerationConfig wraps inner so that maxOutputTokens is applied
// to requests that don't already set it. When maxOutputTokens is nil or not
// positive, inner is returned unchanged.
func WrapGeminiWithGenerationConfig(inner model.LLM, maxOutputTokens *int) model.LLM {
if maxOutputTokens == nil || *maxOutputTokens <= 0 {
return inner
}
return &geminiGenerationConfigModel{
LLM: inner,
maxOutputTokens: int32(*maxOutputTokens),
}
}

// GenerateContent injects the configured max_output_tokens into the request
// config, without overriding a value the request already set, then delegates to
// the wrapped model.
func (m *geminiGenerationConfigModel) GenerateContent(ctx context.Context, req *model.LLMRequest, stream bool) iter.Seq2[*model.LLMResponse, error] {
if req != nil && m.maxOutputTokens > 0 {
if req.Config == nil {
req.Config = &genai.GenerateContentConfig{}
}
if req.Config.MaxOutputTokens == 0 {
req.Config.MaxOutputTokens = m.maxOutputTokens
}
}
return m.LLM.GenerateContent(ctx, req, stream)
}
101 changes: 101 additions & 0 deletions go/adk/pkg/models/gemini_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package models

import (
"context"
"iter"
"testing"

"google.golang.org/adk/model"
"google.golang.org/genai"
)

// fakeLLM records the request it was called with and returns an empty stream.
type fakeLLM struct {
name string
gotReq *model.LLMRequest
gotCall bool
}

func (f *fakeLLM) Name() string { return f.name }

func (f *fakeLLM) GenerateContent(_ context.Context, req *model.LLMRequest, _ bool) iter.Seq2[*model.LLMResponse, error] {
f.gotCall = true
f.gotReq = req
return func(yield func(*model.LLMResponse, error) bool) {}
}

func drain(seq iter.Seq2[*model.LLMResponse, error]) {
for range seq { //nolint:revive // consume the iterator
}
}

func intPtr(i int) *int { return &i }

func TestWrapGeminiWithGenerationConfig_NoOpWhenUnset(t *testing.T) {
inner := &fakeLLM{name: "gemini-2.0-flash"}
for _, tc := range []struct {
name string
max *int
}{
{"nil", nil},
{"zero", intPtr(0)},
{"negative", intPtr(-1)},
} {
t.Run(tc.name, func(t *testing.T) {
got := WrapGeminiWithGenerationConfig(inner, tc.max)
if got != model.LLM(inner) {
t.Errorf("expected inner model returned unchanged, got wrapper %T", got)
}
})
}
}

func TestGeminiGenerationConfig_AppliesCapWhenUnset(t *testing.T) {
inner := &fakeLLM{name: "gemini-2.0-flash"}
wrapped := WrapGeminiWithGenerationConfig(inner, intPtr(1024))

req := &model.LLMRequest{Config: &genai.GenerateContentConfig{}}
drain(wrapped.GenerateContent(context.Background(), req, false))

if !inner.gotCall {
t.Fatal("wrapped model did not delegate to inner")
}
if got := inner.gotReq.Config.MaxOutputTokens; got != 1024 {
t.Errorf("MaxOutputTokens = %d, want 1024", got)
}
}

func TestGeminiGenerationConfig_NilConfigInitialized(t *testing.T) {
inner := &fakeLLM{name: "gemini-2.0-flash"}
wrapped := WrapGeminiWithGenerationConfig(inner, intPtr(512))

req := &model.LLMRequest{} // Config is nil
drain(wrapped.GenerateContent(context.Background(), req, false))

if inner.gotReq.Config == nil {
t.Fatal("Config was not initialized")
}
if got := inner.gotReq.Config.MaxOutputTokens; got != 512 {
t.Errorf("MaxOutputTokens = %d, want 512", got)
}
}

func TestGeminiGenerationConfig_DoesNotOverridePerRequestValue(t *testing.T) {
inner := &fakeLLM{name: "gemini-2.0-flash"}
wrapped := WrapGeminiWithGenerationConfig(inner, intPtr(1024))

req := &model.LLMRequest{Config: &genai.GenerateContentConfig{MaxOutputTokens: 256}}
drain(wrapped.GenerateContent(context.Background(), req, false))

if got := inner.gotReq.Config.MaxOutputTokens; got != 256 {
t.Errorf("MaxOutputTokens = %d, want 256 (per-request value must win)", got)
}
}

func TestGeminiGenerationConfig_NamePassthrough(t *testing.T) {
inner := &fakeLLM{name: "gemini-2.0-flash"}
wrapped := WrapGeminiWithGenerationConfig(inner, intPtr(1024))
if got := wrapped.Name(); got != "gemini-2.0-flash" {
t.Errorf("Name() = %q, want %q", got, "gemini-2.0-flash")
}
}
2 changes: 2 additions & 0 deletions go/api/adk/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ func (a *Anthropic) GetType() string {

type GeminiVertexAI struct {
BaseModel
MaxOutputTokens *int `json:"max_output_tokens,omitempty"`
}

func (g *GeminiVertexAI) MarshalJSON() ([]byte, error) {
Expand Down Expand Up @@ -229,6 +230,7 @@ func (o *Ollama) GetType() string {

type Gemini struct {
BaseModel
MaxOutputTokens *int `json:"max_output_tokens,omitempty"`
}

func (g *Gemini) MarshalJSON() ([]byte, error) {
Expand Down
6 changes: 6 additions & 0 deletions go/api/config/crd/bases/kagent.dev_modelconfigs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,11 @@ spec:
type: object
gemini:
description: Gemini-specific configuration
properties:
maxOutputTokens:
description: Maximum output tokens to generate for a single response
minimum: 1
type: integer
type: object
geminiVertexAI:
description: Gemini Vertex AI-specific configuration
Expand All @@ -546,6 +551,7 @@ spec:
type: string
maxOutputTokens:
description: Maximum output tokens
minimum: 1
type: integer
projectID:
description: The project ID
Expand Down
9 changes: 8 additions & 1 deletion go/api/v1alpha2/modelconfig_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ type GeminiVertexAIConfig struct {

// Maximum output tokens
// +optional
// +kubebuilder:validation:Minimum=1
MaxOutputTokens int `json:"maxOutputTokens,omitempty"`

// Candidate count
Expand Down Expand Up @@ -240,7 +241,13 @@ type OllamaConfig struct {
Options map[string]string `json:"options,omitempty"`
}

type GeminiConfig struct{}
// GeminiConfig contains Gemini (AI Studio, API-key) specific configuration options
type GeminiConfig struct {
// Maximum output tokens to generate for a single response
// +optional
// +kubebuilder:validation:Minimum=1
MaxOutputTokens int `json:"maxOutputTokens,omitempty"`
}

// BedrockConfig contains AWS Bedrock-specific configuration options.
type BedrockConfig struct {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,10 @@ func (a *adkApiTranslator) translateModel(ctx context.Context, namespace, modelC
populateTLSFields(&gemini.BaseModel, model.Spec.TLS)
gemini.APIKeyPassthrough = model.Spec.APIKeyPassthrough

if model.Spec.GeminiVertexAI.MaxOutputTokens > 0 {
gemini.MaxOutputTokens = &model.Spec.GeminiVertexAI.MaxOutputTokens
}

return gemini, modelDeploymentData, secretHashBytes, nil
case v1alpha2.ModelProviderAnthropicVertexAI:
if model.Spec.AnthropicVertexAI == nil {
Expand Down Expand Up @@ -727,6 +731,9 @@ func (a *adkApiTranslator) translateModel(ctx context.Context, namespace, modelC
}
// Populate TLS fields in BaseModel
populateTLSFields(&gemini.BaseModel, model.Spec.TLS)
if model.Spec.Gemini != nil && model.Spec.Gemini.MaxOutputTokens > 0 {
gemini.MaxOutputTokens = &model.Spec.Gemini.MaxOutputTokens
}
return gemini, modelDeploymentData, secretHashBytes, nil
case v1alpha2.ModelProviderBedrock:
if model.Spec.Bedrock == nil {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,81 @@ func Test_AdkApiTranslator_AzureOpenAIParams(t *testing.T) {
assert.Equal(t, &maxTokens, m.MaxTokens)
}

func Test_AdkApiTranslator_GeminiMaxOutputTokens(t *testing.T) {
scheme := schemev1.Scheme
require.NoError(t, v1alpha2.AddToScheme(scheme))

geminiModel := &v1alpha2.ModelConfig{
ObjectMeta: metav1.ObjectMeta{Name: "gm", Namespace: "ns"},
Spec: v1alpha2.ModelConfigSpec{
Model: "gemini-2.5-flash",
Provider: v1alpha2.ModelProviderGemini,
APIKeySecret: "keys",
APIKeySecretKey: "GEMINI_API_KEY",
Gemini: &v1alpha2.GeminiConfig{MaxOutputTokens: 2048},
},
}
vertexModel := &v1alpha2.ModelConfig{
ObjectMeta: metav1.ObjectMeta{Name: "vx", Namespace: "ns"},
Spec: v1alpha2.ModelConfigSpec{
Model: "gemini-2.5-pro",
Provider: v1alpha2.ModelProviderGeminiVertexAI,
APIKeySecret: "keys",
APIKeySecretKey: "service-account.json",
GeminiVertexAI: &v1alpha2.GeminiVertexAIConfig{
BaseVertexAIConfig: v1alpha2.BaseVertexAIConfig{
ProjectID: "my-project",
Location: "us-central1",
},
MaxOutputTokens: 1024,
},
},
}

makeAgent := func(model string) *v1alpha2.Agent {
return &v1alpha2.Agent{
ObjectMeta: metav1.ObjectMeta{Name: "a", Namespace: "ns"},
Spec: v1alpha2.AgentSpec{
Type: v1alpha2.AgentType_Declarative,
Declarative: &v1alpha2.DeclarativeAgentSpec{
SystemMessage: "x",
ModelConfig: model,
},
},
}
}

ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "ns"}}

t.Run("native Gemini", func(t *testing.T) {
agent := makeAgent("gm")
kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(ns, geminiModel, agent).Build()
trans := translator.NewAdkApiTranslator(kubeClient, types.NamespacedName{Namespace: "ns", Name: "gm"}, nil, "", nil)

outputs, err := translator.TranslateAgent(context.Background(), trans, agent)
require.NoError(t, err)

m, ok := outputs.Config.Model.(*adk.Gemini)
require.True(t, ok, "expected model to be of type Gemini")
require.NotNil(t, m.MaxOutputTokens)
assert.Equal(t, 2048, *m.MaxOutputTokens)
})

t.Run("Gemini Vertex AI", func(t *testing.T) {
agent := makeAgent("vx")
kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(ns, vertexModel, agent).Build()
trans := translator.NewAdkApiTranslator(kubeClient, types.NamespacedName{Namespace: "ns", Name: "vx"}, nil, "", nil)

outputs, err := translator.TranslateAgent(context.Background(), trans, agent)
require.NoError(t, err)

m, ok := outputs.Config.Model.(*adk.GeminiVertexAI)
require.True(t, ok, "expected model to be of type GeminiVertexAI")
require.NotNil(t, m.MaxOutputTokens)
assert.Equal(t, 1024, *m.MaxOutputTokens)
})
}

func Test_AdkApiTranslator_ServiceAccountNameOverride(t *testing.T) {
scheme := schemev1.Scheme
require.NoError(t, v1alpha2.AddToScheme(scheme))
Expand Down
6 changes: 6 additions & 0 deletions helm/kagent-crds/templates/kagent.dev_modelconfigs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,11 @@ spec:
type: object
gemini:
description: Gemini-specific configuration
properties:
maxOutputTokens:
description: Maximum output tokens to generate for a single response
minimum: 1
type: integer
type: object
geminiVertexAI:
description: Gemini Vertex AI-specific configuration
Expand All @@ -546,6 +551,7 @@ spec:
type: string
maxOutputTokens:
description: Maximum output tokens
minimum: 1
type: integer
projectID:
description: The project ID
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from ._anthropic import KAgentAnthropicLlm
from ._bedrock import KAgentBedrockLlm
from ._embedding import KAgentEmbedding
from ._gemini import KAgentGeminiLlm
from ._gemini import KAgentGeminiLlm, KAgentGeminiVertexAILlm
from ._ollama import KAgentOllamaLlm
from ._openai import AzureOpenAI, OpenAI
from ._sap_ai_core import KAgentSAPAICoreLlm
Expand All @@ -12,6 +12,7 @@
"KAgentAnthropicLlm",
"KAgentBedrockLlm",
"KAgentGeminiLlm",
"KAgentGeminiVertexAILlm",
"KAgentOllamaLlm",
"KAgentEmbedding",
"KAgentSAPAICoreLlm",
Expand Down
Loading