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
40 changes: 40 additions & 0 deletions .claude/skills/kagent/references/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ kagent supports multiple LLM providers. Configure them via Helm values or the da
| Google Vertex AI (Gemini) | `geminiVertexAI` | (service account — `GOOGLE_CLOUD_PROJECT`, `GOOGLE_CLOUD_LOCATION`) |
| Anthropic via Vertex AI | `anthropicVertexAI` | (service account — `GOOGLE_CLOUD_PROJECT`, `GOOGLE_CLOUD_LOCATION`) |
| Amazon Bedrock | `bedrock` | (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`) |
| Mistral AI | `mistral` | `MISTRAL_API_KEY` (optional `MISTRAL_API_BASE` for custom endpoint) |
| Ollama | `ollama` | (none — local, uses `OLLAMA_API_BASE` for endpoint) |
| BYO OpenAI-compatible | custom | varies |

Expand Down Expand Up @@ -78,6 +79,45 @@ helm install kagent oci://ghcr.io/kagent-dev/kagent/helm/kagent \

Ollama must be accessible from within the cluster.

### Mistral AI

Mistral speaks the OpenAI-compatible wire protocol (POST `/chat/completions` with a Bearer token). The runtime defaults to `https://api.mistral.ai/v1` and honors the same parameters (`temperature`, `top_p`, `max_tokens`, `timeout`).

```bash
export MISTRAL_API_KEY="..."
helm install kagent oci://ghcr.io/kagent-dev/kagent/helm/kagent \
--namespace kagent \
--set providers.default=mistral \
--set providers.mistral.apiKey=$MISTRAL_API_KEY
```

CLI install:
```bash
export KAGENT_DEFAULT_MODEL_PROVIDER=mistral
export MISTRAL_API_KEY="..."
kagent install --profile demo
```

ModelConfig example:
```yaml
apiVersion: kagent.dev/v1alpha2
kind: ModelConfig
metadata:
name: mistral-large
namespace: kagent
spec:
provider: Mistral
model: mistral-large-latest
apiKeySecret: kagent-mistral
apiKeySecretKey: MISTRAL_API_KEY
mistral:
temperature: "0.3"
maxTokens: 4096
# baseUrl: https://api.mistral.ai/v1 # optional, defaults to Mistral cloud
```

Available models: `mistral-large-latest`, `mistral-medium-latest`, `mistral-small-latest`, `magistral-medium-latest`, `magistral-small-latest`, `codestral-latest`, `ministral-8b-latest`, `ministral-3b-latest`, `pixtral-large-latest`, `open-mistral-nemo`. Set `MISTRAL_API_BASE` to point at a self-hosted or regional Mistral endpoint.

## ModelConfig CRD

For fine-grained control, create ModelConfig resources directly:
Expand Down
10 changes: 10 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
title = "kagent gitleaks config"

[extend]
useDefault = true

[allowlist]
description = "Test fixtures use deterministic fake keys; no real credentials live in testdata."
paths = [
'''.*testdata/.*''',
]
12 changes: 12 additions & 0 deletions go/adk/pkg/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,18 @@ func CreateLLM(ctx context.Context, m adk.Model, log logr.Logger) (adkmodel.LLM,
}
return models.NewAnthropicModelWithLogger(cfg, log)

case *adk.Mistral:
cfg := &models.MistralConfig{
TransportConfig: transportConfigFromBase(m.BaseModel, m.Timeout),
Model: m.Model,
BaseUrl: m.BaseUrl,
MaxTokens: m.MaxTokens,
Temperature: m.Temperature,
TopP: m.TopP,
Timeout: m.Timeout,
}
return models.NewMistralModelWithLogger(cfg, log)

case *adk.Ollama:
baseURL := os.Getenv("OLLAMA_API_BASE")
if baseURL == "" {
Expand Down
51 changes: 51 additions & 0 deletions go/adk/pkg/agent/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,57 @@ func TestModelName_ReturnsModelNotProvider(t *testing.T) {
})
}

// TestCreateLLMConfig_Mistral verifies that createLLM wires an *adk.Mistral
// into a MistralModel whose inner OpenAI client keeps the model name (not the
// type discriminator "mistral") and inherits the Mistral base URL.
func TestCreateLLMConfig_Mistral(t *testing.T) {
t.Setenv("MISTRAL_API_KEY", "test-key")
t.Setenv("MISTRAL_API_BASE", "")

configJSON := `{
"model": {
"type": "mistral",
"model": "mistral-large-latest",
"base_url": "https://api.mistral.ai/v1",
"temperature": 0.5,
"max_tokens": 1024
},
"description": "test",
"instruction": "test"
}`

var cfg adk.AgentConfig
if err := json.Unmarshal([]byte(configJSON), &cfg); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}

mistral, ok := cfg.Model.(*adk.Mistral)
if !ok {
t.Fatalf("model is %T, want *adk.Mistral", cfg.Model)
}
if mistral.Model != "mistral-large-latest" {
t.Errorf("model = %q, want %q", mistral.Model, "mistral-large-latest")
}
if mistral.BaseUrl != "https://api.mistral.ai/v1" {
t.Errorf("base_url = %q, want %q", mistral.BaseUrl, "https://api.mistral.ai/v1")
}

llm, err := CreateLLM(context.Background(), cfg.Model, logr.Discard())
if err != nil {
t.Fatalf("CreateLLM returned error: %v", err)
}
mm, ok := llm.(*models.MistralModel)
if !ok {
t.Fatalf("CreateLLM returned %T, want *models.MistralModel", llm)
}
if mm.Name() != "mistral-large-latest" {
t.Errorf("Name() = %q, want %q", mm.Name(), "mistral-large-latest")
}
if mm.Name() == "mistral" {
t.Error("Name() returns provider name 'mistral' instead of model name — this causes 404s from the Mistral API")
}
}

// TestConfigDeserialization_Bedrock verifies that a Bedrock config deserializes
// correctly with the model name and region preserved.
func TestConfigDeserialization_Bedrock(t *testing.T) {
Expand Down
95 changes: 95 additions & 0 deletions go/adk/pkg/models/mistral.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package models

import (
"fmt"
"os"

"github.com/go-logr/logr"
"github.com/openai/openai-go/v3"
"github.com/openai/openai-go/v3/option"
)

// DefaultMistralBaseURL is the default endpoint for the Mistral AI cloud API.
// The MISTRAL_API_BASE environment variable and MistralConfig.BaseUrl both
// override it (self-hosted or Le Platforme regional endpoints).
const DefaultMistralBaseURL = "https://api.mistral.ai/v1"

// MistralConfig holds Mistral AI configuration. Mistral speaks the OpenAI
// wire protocol, so the runtime reuses the OpenAI SDK client and honors the
// same generation parameters (temperature, top_p, max_tokens, timeout).
type MistralConfig struct {
TransportConfig
Model string
BaseUrl string
MaxTokens *int
Temperature *float64
TopP *float64
Timeout *int
}

// MistralModel implements model.LLM (see mistral_adk.go) for Mistral AI.
// It wraps an OpenAIModel because Mistral exposes an OpenAI-compatible API
// (POST {base_url}/chat/completions with a Bearer token).
type MistralModel struct {
Config *MistralConfig
inner *OpenAIModel
Logger logr.Logger
}

// NewMistralModelWithLogger creates a new Mistral model instance with a logger.
// It reads MISTRAL_API_KEY unless APIKeyPassthrough is enabled; base URL falls
// back to MISTRAL_API_BASE then DefaultMistralBaseURL.
func NewMistralModelWithLogger(config *MistralConfig, logger logr.Logger) (*MistralModel, error) {
apiKey := "passthrough" // placeholder; real auth set per-request by transport
if !config.APIKeyPassthrough {
apiKey = os.Getenv("MISTRAL_API_KEY")
if apiKey == "" {
return nil, fmt.Errorf("MISTRAL_API_KEY environment variable is not set")
}
}

baseURL := config.BaseUrl
if baseURL == "" {
baseURL = os.Getenv("MISTRAL_API_BASE")
}
if baseURL == "" {
baseURL = DefaultMistralBaseURL
}

opts := []option.RequestOption{
option.WithAPIKey(apiKey),
option.WithBaseURL(baseURL),
}
httpClient, err := BuildHTTPClient(config.TransportConfig)
if err != nil {
return nil, err
}
if logger.GetSink() != nil && len(config.Headers) > 0 {
logger.Info("Setting default headers for Mistral client", "headersCount", len(config.Headers))
}
opts = append(opts, option.WithHTTPClient(httpClient))

client := openai.NewClient(opts...)
if logger.GetSink() != nil {
logger.Info("Initialized Mistral model", "model", config.Model, "baseUrl", baseURL)
}

inner := &OpenAIModel{
Config: &OpenAIConfig{
TransportConfig: config.TransportConfig,
Model: config.Model,
BaseUrl: baseURL,
MaxTokens: config.MaxTokens,
Temperature: config.Temperature,
TopP: config.TopP,
},
Client: client,
IsAzure: false,
Logger: logger,
}
return &MistralModel{
Config: config,
inner: inner,
Logger: logger,
}, nil
}
22 changes: 22 additions & 0 deletions go/adk/pkg/models/mistral_adk.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Package models: Mistral model implementing Google ADK model.LLM by delegating
// to the OpenAI adapter (Mistral uses the OpenAI wire protocol).
package models

import (
"context"
"iter"

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

// Name implements model.LLM.
func (m *MistralModel) Name() string {
return m.Config.Model
}

// GenerateContent implements model.LLM by delegating to the inner OpenAI
// adapter. Mistral speaks the OpenAI chat/completions wire protocol, so
// message conversion, tool schemas, streaming, and telemetry are identical.
func (m *MistralModel) GenerateContent(ctx context.Context, req *model.LLMRequest, stream bool) iter.Seq2[*model.LLMResponse, error] {
return m.inner.GenerateContent(ctx, req, stream)
}
102 changes: 102 additions & 0 deletions go/adk/pkg/models/mistral_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package models

import (
"testing"

"github.com/go-logr/logr"
)

func TestNewMistralModelWithLogger(t *testing.T) {
tests := []struct {
name string
envAPIKey string
envAPIBase string
config *MistralConfig
wantErr bool
wantBaseURL string
}{
{
name: "missing API key without passthrough returns error",
envAPIKey: "",
config: &MistralConfig{Model: "mistral-large-latest"},
wantErr: true,
},
{
name: "default base URL when nothing set",
envAPIKey: "test-key",
config: &MistralConfig{Model: "mistral-large-latest"},
wantBaseURL: DefaultMistralBaseURL,
},
{
name: "MISTRAL_API_BASE env overrides default",
envAPIKey: "test-key",
envAPIBase: "https://gateway.example.com/mistral/v1",
config: &MistralConfig{Model: "mistral-large-latest"},
wantBaseURL: "https://gateway.example.com/mistral/v1",
},
{
name: "config BaseUrl wins over env",
envAPIKey: "test-key",
envAPIBase: "https://from-env.example.com/v1",
config: &MistralConfig{
Model: "mistral-large-latest",
BaseUrl: "https://from-config.example.com/v1",
},
wantBaseURL: "https://from-config.example.com/v1",
},
{
name: "passthrough skips MISTRAL_API_KEY check",
envAPIKey: "",
config: &MistralConfig{
TransportConfig: TransportConfig{APIKeyPassthrough: true},
Model: "mistral-medium-latest",
},
wantBaseURL: DefaultMistralBaseURL,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("MISTRAL_API_KEY", tt.envAPIKey)
t.Setenv("MISTRAL_API_BASE", tt.envAPIBase)

m, err := NewMistralModelWithLogger(tt.config, logr.Discard())
if tt.wantErr {
if err == nil {
t.Fatalf("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if m == nil {
t.Fatal("expected non-nil MistralModel")
}
if m.Name() != tt.config.Model {
t.Errorf("Name() = %q, want %q", m.Name(), tt.config.Model)
}
if m.inner == nil {
t.Fatal("expected inner OpenAIModel to be initialized")
}
if m.inner.Config.BaseUrl != tt.wantBaseURL {
t.Errorf("inner BaseUrl = %q, want %q", m.inner.Config.BaseUrl, tt.wantBaseURL)
}
if m.inner.Config.Model != tt.config.Model {
t.Errorf("inner Model = %q, want %q", m.inner.Config.Model, tt.config.Model)
}
})
}
}

func TestMistralModel_NamePropagatesModelNotProvider(t *testing.T) {
m := &MistralModel{
Config: &MistralConfig{Model: "mistral-large-latest"},
}
if got := m.Name(); got != "mistral-large-latest" {
t.Errorf("Name() = %q, want %q", got, "mistral-large-latest")
}
if m.Name() == "mistral" {
t.Error("Name() returns provider name 'mistral' instead of model name — this causes 404 from Mistral API")
}
}
Loading