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
23 changes: 12 additions & 11 deletions go/adk/pkg/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,17 +191,18 @@ func CreateLLM(ctx context.Context, m adk.Model, log logr.Logger) (adkmodel.LLM,
switch m := m.(type) {
case *adk.OpenAI:
cfg := &models.OpenAIConfig{
TransportConfig: transportConfigFromBase(m.BaseModel, m.Timeout),
Model: m.Model,
BaseUrl: m.BaseUrl,
FrequencyPenalty: m.FrequencyPenalty,
MaxTokens: m.MaxTokens,
N: m.N,
PresencePenalty: m.PresencePenalty,
ReasoningEffort: m.ReasoningEffort,
Seed: m.Seed,
Temperature: m.Temperature,
TopP: m.TopP,
TransportConfig: transportConfigFromBase(m.BaseModel, m.Timeout),
Model: m.Model,
BaseUrl: m.BaseUrl,
FrequencyPenalty: m.FrequencyPenalty,
MaxTokens: m.MaxTokens,
MaxCompletionTokens: m.MaxCompletionTokens,
N: m.N,
PresencePenalty: m.PresencePenalty,
ReasoningEffort: m.ReasoningEffort,
Seed: m.Seed,
Temperature: m.Temperature,
TopP: m.TopP,
}
return models.NewOpenAIModelWithLogger(cfg, log)

Expand Down
21 changes: 11 additions & 10 deletions go/adk/pkg/models/openai.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,17 @@ import (
// OpenAIConfig holds OpenAI configuration
type OpenAIConfig struct {
TransportConfig
Model string
BaseUrl string
FrequencyPenalty *float64
MaxTokens *int
N *int
PresencePenalty *float64
ReasoningEffort *string
Seed *int
Temperature *float64
TopP *float64
Model string
BaseUrl string
FrequencyPenalty *float64
MaxTokens *int
MaxCompletionTokens *int
N *int
PresencePenalty *float64
ReasoningEffort *string
Seed *int
Temperature *float64
TopP *float64
}

// AzureOpenAIConfig holds Azure OpenAI configuration
Expand Down
9 changes: 8 additions & 1 deletion go/adk/pkg/models/openai_adk.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,14 @@ func applyOpenAIConfig(params *openai.ChatCompletionNewParams, cfg *OpenAIConfig
if cfg.Temperature != nil {
params.Temperature = openai.Float(*cfg.Temperature)
}
if cfg.MaxTokens != nil {
// max_tokens and max_completion_tokens are mutually exclusive on the OpenAI
// API: reasoning models (GPT-5 / o-series) reject max_tokens, while some
// OpenAI-compatible endpoints only accept max_tokens. Never send both;
// max_completion_tokens (the modern, superset parameter) takes precedence
// when both are configured.
if cfg.MaxCompletionTokens != nil {
params.MaxCompletionTokens = openai.Int(int64(*cfg.MaxCompletionTokens))
} else if cfg.MaxTokens != nil {
params.MaxTokens = openai.Int(int64(*cfg.MaxTokens))
}
if cfg.TopP != nil {
Expand Down
24 changes: 24 additions & 0 deletions go/adk/pkg/models/openai_adk_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,30 @@ func TestApplyOpenAIConfig(t *testing.T) {
}
})

t.Run("config with max_completion_tokens", func(t *testing.T) {
n := 100
cfg := &OpenAIConfig{MaxCompletionTokens: &n}
var params openai.ChatCompletionNewParams
applyOpenAIConfig(&params, cfg)
if !params.MaxCompletionTokens.Valid() || params.MaxCompletionTokens.Value != 100 {
t.Errorf("MaxCompletionTokens: Valid=%v, Value=%v, want (true, 100)", params.MaxCompletionTokens.Valid(), params.MaxCompletionTokens.Value)
}
})

t.Run("both set prefers max_completion_tokens", func(t *testing.T) {
mt := 100
mct := 200
cfg := &OpenAIConfig{MaxTokens: &mt, MaxCompletionTokens: &mct}
var params openai.ChatCompletionNewParams
applyOpenAIConfig(&params, cfg)
if !params.MaxCompletionTokens.Valid() || params.MaxCompletionTokens.Value != 200 {
t.Errorf("MaxCompletionTokens: Valid=%v, Value=%v, want (true, 200)", params.MaxCompletionTokens.Valid(), params.MaxCompletionTokens.Value)
}
if params.MaxTokens.Valid() {
t.Errorf("MaxTokens should not be set when max_completion_tokens is present, got Value=%v", params.MaxTokens.Value)
}
})

t.Run("config with reasoning_effort", func(t *testing.T) {
effort := "medium"
cfg := &OpenAIConfig{ReasoningEffort: &effort}
Expand Down
21 changes: 11 additions & 10 deletions go/api/adk/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,16 +79,17 @@ type TokenExchangeConfig struct {

type OpenAI struct {
BaseModel
BaseUrl string `json:"base_url"`
FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"`
MaxTokens *int `json:"max_tokens,omitempty"`
N *int `json:"n,omitempty"`
PresencePenalty *float64 `json:"presence_penalty,omitempty"`
ReasoningEffort *string `json:"reasoning_effort,omitempty"`
Seed *int `json:"seed,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
Timeout *int `json:"timeout,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
BaseUrl string `json:"base_url"`
FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"`
MaxTokens *int `json:"max_tokens,omitempty"`
MaxCompletionTokens *int `json:"max_completion_tokens,omitempty"`
N *int `json:"n,omitempty"`
PresencePenalty *float64 `json:"presence_penalty,omitempty"`
ReasoningEffort *string `json:"reasoning_effort,omitempty"`
Seed *int `json:"seed,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
Timeout *int `json:"timeout,omitempty"`
TopP *float64 `json:"top_p,omitempty"`

// TokenExchange configures dynamic bearer token acquisition
TokenExchange *TokenExchangeConfig `json:"token_exchange,omitempty"`
Expand Down
19 changes: 18 additions & 1 deletion go/api/config/crd/bases/kagent.dev_modelconfigs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -594,8 +594,22 @@ spec:
frequencyPenalty:
description: Frequency penalty
type: string
maxCompletionTokens:
description: |-
Maximum completion tokens to generate. Sent as the OpenAI
`max_completion_tokens` request parameter (an upper bound on visible
output plus reasoning tokens). This is the parameter reasoning models
(GPT-5 / o-series) require in place of the deprecated maxTokens.
Mutually exclusive with maxTokens.
minimum: 1
type: integer
maxTokens:
description: Maximum tokens to generate
description: |-
Maximum tokens to generate. Sent as the OpenAI `max_tokens` request
parameter, which is deprecated and rejected by reasoning models
(GPT-5 / o-series). For those models set maxCompletionTokens instead.
Mutually exclusive with maxCompletionTokens.
minimum: 1
type: integer
"n":
description: N value
Expand Down Expand Up @@ -652,6 +666,9 @@ spec:
description: Top-p sampling parameter
type: string
type: object
x-kubernetes-validations:
- message: maxTokens and maxCompletionTokens are mutually exclusive
rule: '!(has(self.maxTokens) && has(self.maxCompletionTokens))'
provider:
default: OpenAI
description: The provider of the model
Expand Down
158 changes: 158 additions & 0 deletions go/api/v1alpha2/modelconfig_cel_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
/*
Copyright 2025.

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 v1alpha2

import (
"context"
"testing"

"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
ctrl_client "sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/envtest"
)

// TestOpenAIConfigCELValidation pins the OpenAIConfig admission rules against a
// real kube-apiserver loaded with the shipped CRDs:
// - maxTokens and maxCompletionTokens are mutually exclusive (type-level
// XValidation), because native OpenAI reasoning models reject max_tokens
// while some OpenAI-compatible endpoints reject max_completion_tokens, so
// sending both risks hard 400s.
// - both fields carry Minimum=1, so a non-positive value is rejected at
// admission rather than silently ignored by the translator.
func TestOpenAIConfigCELValidation(t *testing.T) {
testEnv := &envtest.Environment{
BinaryAssetsDirectory: envtestAssetsDir(t),
CRDDirectoryPaths: []string{crdBasesDir(t)},
ErrorIfCRDPathMissing: true,
}
cfg, err := testEnv.Start()
require.NoError(t, err)
t.Cleanup(func() { _ = testEnv.Stop() })

scheme := runtime.NewScheme()
require.NoError(t, corev1.AddToScheme(scheme))
require.NoError(t, AddToScheme(scheme))
cl, err := ctrl_client.New(cfg, ctrl_client.Options{Scheme: scheme})
require.NoError(t, err)

ctx := context.Background()
const ns = "openai-cel"
require.NoError(t, cl.Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: ns}}))

cases := []struct {
name string
build func() ctrl_client.Object
wantReject string // substring in admission error; empty means accept
}{
{
name: "both maxTokens and maxCompletionTokens rejected",
build: func() ctrl_client.Object {
return &ModelConfig{
ObjectMeta: metav1.ObjectMeta{Name: "mc-both-token-caps", Namespace: ns},
Spec: ModelConfigSpec{
Model: "gpt-4",
Provider: ModelProviderOpenAI,
OpenAI: &OpenAIConfig{MaxTokens: 1000, MaxCompletionTokens: 1000},
},
}
},
wantReject: "maxTokens and maxCompletionTokens are mutually exclusive",
},
{
name: "maxTokens below minimum rejected",
build: func() ctrl_client.Object {
return &ModelConfig{
ObjectMeta: metav1.ObjectMeta{Name: "mc-maxtokens-neg", Namespace: ns},
Spec: ModelConfigSpec{
Model: "gpt-4",
Provider: ModelProviderOpenAI,
OpenAI: &OpenAIConfig{MaxTokens: -1},
},
}
},
wantReject: "maxTokens",
},
{
name: "maxCompletionTokens below minimum rejected",
build: func() ctrl_client.Object {
return &ModelConfig{
ObjectMeta: metav1.ObjectMeta{Name: "mc-maxcompletion-neg", Namespace: ns},
Spec: ModelConfigSpec{
Model: "gpt-4",
Provider: ModelProviderOpenAI,
OpenAI: &OpenAIConfig{MaxCompletionTokens: -1},
},
}
},
wantReject: "maxCompletionTokens",
},
{
name: "only maxTokens accepted",
build: func() ctrl_client.Object {
return &ModelConfig{
ObjectMeta: metav1.ObjectMeta{Name: "mc-maxtokens-only", Namespace: ns},
Spec: ModelConfigSpec{
Model: "gpt-4",
Provider: ModelProviderOpenAI,
OpenAI: &OpenAIConfig{MaxTokens: 1000},
},
}
},
},
{
name: "only maxCompletionTokens accepted",
build: func() ctrl_client.Object {
return &ModelConfig{
ObjectMeta: metav1.ObjectMeta{Name: "mc-maxcompletion-only", Namespace: ns},
Spec: ModelConfigSpec{
Model: "gpt-5",
Provider: ModelProviderOpenAI,
OpenAI: &OpenAIConfig{MaxCompletionTokens: 1000},
},
}
},
},
{
name: "neither token cap accepted",
build: func() ctrl_client.Object {
return &ModelConfig{
ObjectMeta: metav1.ObjectMeta{Name: "mc-no-token-caps", Namespace: ns},
Spec: ModelConfigSpec{
Model: "gpt-4",
Provider: ModelProviderOpenAI,
OpenAI: &OpenAIConfig{},
},
}
},
},
}

for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := cl.Create(ctx, c.build())
if c.wantReject == "" {
require.NoError(t, err)
return
}
require.Error(t, err)
require.Contains(t, err.Error(), c.wantReject)
})
}
}
17 changes: 16 additions & 1 deletion go/api/v1alpha2/modelconfig_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ type TokenExchangeConfig struct {
}

// OpenAIConfig contains OpenAI-specific configuration options
//
// +kubebuilder:validation:XValidation:message="maxTokens and maxCompletionTokens are mutually exclusive",rule="!(has(self.maxTokens) && has(self.maxCompletionTokens))"
type OpenAIConfig struct {
// Base URL for the OpenAI API (overrides default)
// +optional
Expand All @@ -151,10 +153,23 @@ type OpenAIConfig struct {
// +optional
Temperature string `json:"temperature,omitempty"`

// Maximum tokens to generate
// Maximum tokens to generate. Sent as the OpenAI `max_tokens` request
// parameter, which is deprecated and rejected by reasoning models
// (GPT-5 / o-series). For those models set maxCompletionTokens instead.
// Mutually exclusive with maxCompletionTokens.
// +optional
// +kubebuilder:validation:Minimum=1
MaxTokens int `json:"maxTokens,omitempty"`

@mesutoezdil mesutoezdil Jul 8, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

maxTokens has no +kubebuilder:validation:Minimum=1 marker but maxCompletionTokens below does..
value like maxTokens: 0 or maxTokens: -1 passes crd validation silently.. and is then ignored by the translator
adding the same minimum marker here would make the two fields consistent in my oprinio

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch — fixed in 9283c15. maxTokens now carries +kubebuilder:validation:Minimum=1 too, so maxTokens: 0/-1 is rejected at admission instead of silently ignored by the translator. The regenerated CRD bases/helm templates reflect minimum: 1 on both fields, and the new CEL test asserts a negative value on either field is rejected.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good catch — fixed in 9283c15. maxTokens now carries +kubebuilder:validation:Minimum=1 too, so maxTokens: 0/-1 is rejected at admission instead of silently ignored by the translator. The regenerated CRD bases/helm templates reflect minimum: 1 on both fields, and the new CEL test asserts a negative value on either field is rejected.

why do you post llm outputs here instead of writing your own sentences? i think everyone should maintain healthy communication as a sign of respect for the person they are interacting with..


// Maximum completion tokens to generate. Sent as the OpenAI
// `max_completion_tokens` request parameter (an upper bound on visible
// output plus reasoning tokens). This is the parameter reasoning models
// (GPT-5 / o-series) require in place of the deprecated maxTokens.
// Mutually exclusive with maxTokens.
// +optional
// +kubebuilder:validation:Minimum=1
MaxCompletionTokens int `json:"maxCompletionTokens,omitempty"`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We should add a validation rule that only one of MaxCompletionTokens or MaxTokens is provided (if I understood properly from the description they should be mutually exclusive)?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done in 9283c15. Added a type-level CEL rule on OpenAIConfig:

+kubebuilder:validation:XValidation:message="maxTokens and maxCompletionTokens are mutually exclusive",rule="!(has(self.maxTokens) && has(self.maxCompletionTokens))"

So a ModelConfig that sets both is now rejected at admission (matching the existing XValidation pattern used for the TLSConfig rules in this file). The runtime-level precedence guard in the Python/Go clients stays as defense-in-depth for OpenAI-compatible paths that don't go through CRD admission. Added an envtest-based CEL test (modelconfig_cel_test.go) covering both-set rejection plus the single-field and neither-field cases.


// Top-p sampling parameter
// +optional
TopP string `json:"topP,omitempty"`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,9 @@ func (a *adkApiTranslator) translateModel(ctx context.Context, namespace, modelC
if model.Spec.OpenAI.MaxTokens > 0 {
openai.MaxTokens = &model.Spec.OpenAI.MaxTokens
}
if model.Spec.OpenAI.MaxCompletionTokens > 0 {
openai.MaxCompletionTokens = &model.Spec.OpenAI.MaxCompletionTokens
}
if model.Spec.OpenAI.Seed != nil {
openai.Seed = model.Spec.OpenAI.Seed
}
Expand Down
Loading