From 75c7a22ae10cd32aad5e4abb51b3897c5e7392d0 Mon Sep 17 00:00:00 2001 From: Xavier Pestel Date: Sat, 8 Aug 2026 15:25:49 +0200 Subject: [PATCH] feat(models): add Mistral AI as a native LLM provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Mistral AI as a first-class provider across the Go controller, Go ADK runtime, Python ADK runtime, and UI. Mistral speaks the OpenAI-compatible wire protocol, so the runtime reuses the OpenAI SDK client internally with a Mistral base URL and MISTRAL_API_KEY env var — no new HTTP transport, no new SDK dependency. New surfaces: - v1alpha2 CRD: Mistral enum + MistralConfig (baseUrl, temperature, topP, maxTokens, timeout) + CEL rule + default endpoint https://api.mistral.ai/v1 - Controller translator: injects MISTRAL_API_KEY from Secret - Go ADK: adk.Mistral type + models.MistralModel (wraps OpenAIModel) - Python ADK: KAgentMistralLlm subclassing BaseOpenAI - Env vars: MISTRAL_API_KEY, MISTRAL_API_BASE - Telemetry: gen_ai.provider.name=mistral_ai - Model catalog: 10 Mistral models (mistral/magistral/codestral/ministral/ pixtral/nemo latest tags) - UI: Mistral provider entry + icon + combobox wiring - Helm values: providers.mistral entry (default provider unchanged) - Docs: providers.md Mistral section - Tests: Go/Python unit tests, CEL admission tests, translator golden test, e2e mock-server test (gated on MISTRAL_API_KEY) Signed-off-by: Xavier Pestel --- .claude/skills/kagent/references/providers.md | 40 +++ .gitleaks.toml | 10 + go/adk/pkg/agent/agent.go | 12 + go/adk/pkg/agent/agent_test.go | 51 +++ go/adk/pkg/models/mistral.go | 95 ++++++ go/adk/pkg/models/mistral_adk.go | 22 ++ go/adk/pkg/models/mistral_test.go | 102 ++++++ go/api/adk/types.go | 34 ++ .../crd/bases/kagent.dev_modelconfigs.yaml | 24 ++ .../kagent.dev_modelproviderconfigs.yaml | 1 + go/api/v1alpha2/modelconfig_cel_test.go | 67 ++++ go/api/v1alpha2/modelconfig_types.go | 35 +- go/api/v1alpha2/modelproviderconfig_types.go | 2 + go/api/v1alpha2/zz_generated.deepcopy.go | 45 +++ go/core/internal/a2a/trace.go | 2 + .../translator/agent/adk_api_translator.go | 42 +++ .../agent/testdata/inputs/mistral_agent.yaml | 38 +++ .../agent/testdata/outputs/mistral_agent.json | 298 ++++++++++++++++++ .../internal/httpserver/handlers/models.go | 12 + go/core/pkg/env/providers.go | 17 + go/core/test/e2e/mistral_test.go | 100 ++++++ .../templates/kagent.dev_modelconfigs.yaml | 24 ++ .../kagent.dev_modelproviderconfigs.yaml | 1 + helm/kagent/values.yaml | 6 + .../src/kagent/adk/models/__init__.py | 2 + .../src/kagent/adk/models/_mistral.py | 62 ++++ .../kagent-adk/src/kagent/adk/types.py | 26 +- .../tests/unittests/models/test_mistral.py | 111 +++++++ ui/src/components/ModelProviderCombobox.tsx | 2 + ui/src/components/ProviderCombobox.tsx | 2 + ui/src/components/icons/Mistral.tsx | 19 ++ ui/src/lib/providers.ts | 11 +- 32 files changed, 1311 insertions(+), 4 deletions(-) create mode 100644 .gitleaks.toml create mode 100644 go/adk/pkg/models/mistral.go create mode 100644 go/adk/pkg/models/mistral_adk.go create mode 100644 go/adk/pkg/models/mistral_test.go create mode 100644 go/core/internal/controller/translator/agent/testdata/inputs/mistral_agent.yaml create mode 100644 go/core/internal/controller/translator/agent/testdata/outputs/mistral_agent.json create mode 100644 go/core/test/e2e/mistral_test.go create mode 100644 python/packages/kagent-adk/src/kagent/adk/models/_mistral.py create mode 100644 python/packages/kagent-adk/tests/unittests/models/test_mistral.py create mode 100644 ui/src/components/icons/Mistral.tsx diff --git a/.claude/skills/kagent/references/providers.md b/.claude/skills/kagent/references/providers.md index bb227007a..4428c97f8 100644 --- a/.claude/skills/kagent/references/providers.md +++ b/.claude/skills/kagent/references/providers.md @@ -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 | @@ -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: diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 000000000..e37d2db8c --- /dev/null +++ b/.gitleaks.toml @@ -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/.*''', +] diff --git a/go/adk/pkg/agent/agent.go b/go/adk/pkg/agent/agent.go index 6164f5d0d..8defc03a9 100644 --- a/go/adk/pkg/agent/agent.go +++ b/go/adk/pkg/agent/agent.go @@ -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 == "" { diff --git a/go/adk/pkg/agent/agent_test.go b/go/adk/pkg/agent/agent_test.go index cd0b1b1c2..caeb311e4 100644 --- a/go/adk/pkg/agent/agent_test.go +++ b/go/adk/pkg/agent/agent_test.go @@ -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) { diff --git a/go/adk/pkg/models/mistral.go b/go/adk/pkg/models/mistral.go new file mode 100644 index 000000000..d586071b2 --- /dev/null +++ b/go/adk/pkg/models/mistral.go @@ -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 +} diff --git a/go/adk/pkg/models/mistral_adk.go b/go/adk/pkg/models/mistral_adk.go new file mode 100644 index 000000000..1f32c6b5c --- /dev/null +++ b/go/adk/pkg/models/mistral_adk.go @@ -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) +} diff --git a/go/adk/pkg/models/mistral_test.go b/go/adk/pkg/models/mistral_test.go new file mode 100644 index 000000000..9fa1fd8fe --- /dev/null +++ b/go/adk/pkg/models/mistral_test.go @@ -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") + } +} diff --git a/go/api/adk/types.go b/go/api/adk/types.go index 8f6d0b853..411a97715 100644 --- a/go/api/adk/types.go +++ b/go/api/adk/types.go @@ -108,6 +108,7 @@ const ( ModelTypeBedrock = "bedrock" ModelTypeSAPAICore = "sap_ai_core" ModelTypeFoundry = "foundry" + ModelTypeMistral = "mistral" ) func (o *OpenAI) MarshalJSON() ([]byte, error) { @@ -176,6 +177,30 @@ func (a *Anthropic) GetType() string { return ModelTypeAnthropic } +type Mistral struct { + BaseModel + BaseUrl string `json:"base_url,omitempty"` + MaxTokens *int `json:"max_tokens,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + Timeout *int `json:"timeout,omitempty"` +} + +func (m *Mistral) MarshalJSON() ([]byte, error) { + type Alias Mistral + return json.Marshal(&struct { + Type string `json:"type"` + *Alias + }{ + Type: ModelTypeMistral, + Alias: (*Alias)(m), + }) +} + +func (m *Mistral) GetType() string { + return ModelTypeMistral +} + type GeminiVertexAI struct { BaseModel MaxOutputTokens *int `json:"max_output_tokens,omitempty"` @@ -425,6 +450,12 @@ func ParseModel(bytes []byte) (Model, error) { return nil, err } return &foundry, nil + case ModelTypeMistral: + var mistral Mistral + if err := json.Unmarshal(bytes, &mistral); err != nil { + return nil, err + } + return &mistral, nil } return nil, fmt.Errorf("unknown model type: %s", model.Type) } @@ -518,6 +549,9 @@ func ModelToEmbeddingConfig(m Model) *EmbeddingConfig { e.Endpoint = v.Endpoint e.Deployment = v.Deployment e.APIVersion = v.APIVersion + case *Mistral: + e.Model = v.Model + e.BaseUrl = v.BaseUrl default: e.Model = "" } diff --git a/go/api/config/crd/bases/kagent.dev_modelconfigs.yaml b/go/api/config/crd/bases/kagent.dev_modelconfigs.yaml index d3600d0c4..6acb739d7 100644 --- a/go/api/config/crd/bases/kagent.dev_modelconfigs.yaml +++ b/go/api/config/crd/bases/kagent.dev_modelconfigs.yaml @@ -679,6 +679,27 @@ spec: - location - projectID type: object + mistral: + description: Mistral-specific configuration + properties: + baseUrl: + description: Base URL for the Mistral API (overrides default https://api.mistral.ai/v1) + type: string + maxTokens: + description: Maximum tokens to generate + minimum: 1 + type: integer + temperature: + description: Temperature for sampling + type: string + timeout: + description: Timeout in seconds for the underlying HTTP client + minimum: 1 + type: integer + topP: + description: Top-p sampling parameter + type: string + type: object model: type: string ollama: @@ -804,6 +825,7 @@ spec: - Bedrock - SAPAICore - Foundry + - Mistral type: string sapAICore: description: SAP AI Core-specific configuration @@ -901,6 +923,8 @@ spec: rule: '!(has(self.sapAICore) && self.provider != ''SAPAICore'')' - message: provider.foundry must be nil if the provider is not Foundry rule: '!(has(self.foundry) && self.provider != ''Foundry'')' + - message: provider.mistral must be nil if the provider is not Mistral + rule: '!(has(self.mistral) && self.provider != ''Mistral'')' - message: apiKeySecret must be set if apiKeySecretKey is set rule: '!(has(self.apiKeySecretKey) && !has(self.apiKeySecret))' - message: apiKeySecretKey must be set if apiKeySecret is set (except diff --git a/go/api/config/crd/bases/kagent.dev_modelproviderconfigs.yaml b/go/api/config/crd/bases/kagent.dev_modelproviderconfigs.yaml index 4c1d0af39..34d3f2345 100644 --- a/go/api/config/crd/bases/kagent.dev_modelproviderconfigs.yaml +++ b/go/api/config/crd/bases/kagent.dev_modelproviderconfigs.yaml @@ -92,6 +92,7 @@ spec: - Bedrock - SAPAICore - Foundry + - Mistral type: string required: - type diff --git a/go/api/v1alpha2/modelconfig_cel_test.go b/go/api/v1alpha2/modelconfig_cel_test.go index 2168c6466..63af1eb85 100644 --- a/go/api/v1alpha2/modelconfig_cel_test.go +++ b/go/api/v1alpha2/modelconfig_cel_test.go @@ -184,6 +184,73 @@ func TestOpenAIConfigValidation(t *testing.T) { }, wantReject: "reasoningEffort", }, + { + name: "mistral config with matching provider accepted", + build: func() ctrl_client.Object { + baseURL := "https://api.mistral.ai/v1" + temp := "0.3" + return &ModelConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "mc-mistral-ok", Namespace: ns}, + Spec: ModelConfigSpec{ + Model: "mistral-large-latest", + Provider: ModelProviderMistral, + APIKeySecret: "mistral-secret", + APIKeySecretKey: "api-key", + Mistral: &MistralConfig{ + BaseURL: &baseURL, + Temperature: &temp, + }, + }, + } + }, + }, + { + name: "mistral config without any provider-specific block accepted", + build: func() ctrl_client.Object { + return &ModelConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "mc-mistral-plain", Namespace: ns}, + Spec: ModelConfigSpec{ + Model: "mistral-medium-latest", + Provider: ModelProviderMistral, + APIKeySecret: "mistral-secret", + APIKeySecretKey: "api-key", + }, + } + }, + }, + { + name: "mistral config with mismatched provider rejected", + build: func() ctrl_client.Object { + return &ModelConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "mc-mistral-bad-provider", Namespace: ns}, + Spec: ModelConfigSpec{ + Model: "mistral-large-latest", + Provider: ModelProviderOpenAI, + APIKeySecret: "openai-secret", + APIKeySecretKey: "api-key", + Mistral: &MistralConfig{}, + }, + } + }, + wantReject: "provider.mistral must be nil if the provider is not Mistral", + }, + { + name: "mistral maxTokens below minimum rejected", + build: func() ctrl_client.Object { + neg := -1 + return &ModelConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "mc-mistral-maxtokens-neg", Namespace: ns}, + Spec: ModelConfigSpec{ + Model: "mistral-large-latest", + Provider: ModelProviderMistral, + APIKeySecret: "mistral-secret", + APIKeySecretKey: "api-key", + Mistral: &MistralConfig{MaxTokens: &neg}, + }, + } + }, + wantReject: "maxTokens", + }, } for _, c := range cases { diff --git a/go/api/v1alpha2/modelconfig_types.go b/go/api/v1alpha2/modelconfig_types.go index 19cc53e4f..8bfd48133 100644 --- a/go/api/v1alpha2/modelconfig_types.go +++ b/go/api/v1alpha2/modelconfig_types.go @@ -28,7 +28,7 @@ const ( ) // ModelProvider represents the model provider type -// +kubebuilder:validation:Enum=Anthropic;OpenAI;AzureOpenAI;Ollama;Gemini;GeminiVertexAI;AnthropicVertexAI;Bedrock;SAPAICore;Foundry +// +kubebuilder:validation:Enum=Anthropic;OpenAI;AzureOpenAI;Ollama;Gemini;GeminiVertexAI;AnthropicVertexAI;Bedrock;SAPAICore;Foundry;Mistral type ModelProvider string const ( @@ -42,6 +42,7 @@ const ( ModelProviderBedrock ModelProvider = "Bedrock" ModelProviderSAPAICore ModelProvider = "SAPAICore" ModelProviderFoundry ModelProvider = "Foundry" + ModelProviderMistral ModelProvider = "Mistral" ) type BaseVertexAIConfig struct { @@ -96,6 +97,33 @@ type AnthropicVertexAIConfig struct { MaxTokens int `json:"maxTokens,omitempty"` } +// MistralConfig contains Mistral-specific configuration options. +// Mistral exposes an OpenAI-compatible wire protocol; the runtime posts to +// {baseURL}/chat/completions with a Bearer token from MISTRAL_API_KEY. +type MistralConfig struct { + // Base URL for the Mistral API (overrides default https://api.mistral.ai/v1) + // +optional + BaseURL *string `json:"baseUrl,omitempty"` + + // Temperature for sampling + // +optional + Temperature *string `json:"temperature,omitempty"` + + // Top-p sampling parameter + // +optional + TopP *string `json:"topP,omitempty"` + + // Maximum tokens to generate + // +optional + // +kubebuilder:validation:Minimum=1 + MaxTokens *int `json:"maxTokens,omitempty"` + + // Timeout in seconds for the underlying HTTP client + // +optional + // +kubebuilder:validation:Minimum=1 + Timeout *int `json:"timeout,omitempty"` +} + // AnthropicConfig contains Anthropic-specific configuration options type AnthropicConfig struct { // Base URL for the Anthropic API (overrides default) @@ -500,6 +528,7 @@ func (t *TLSConfig) IsEmpty() bool { // +kubebuilder:validation:XValidation:message="provider.bedrock must be nil if the provider is not Bedrock",rule="!(has(self.bedrock) && self.provider != 'Bedrock')" // +kubebuilder:validation:XValidation:message="provider.sapAICore must be nil if the provider is not SAPAICore",rule="!(has(self.sapAICore) && self.provider != 'SAPAICore')" // +kubebuilder:validation:XValidation:message="provider.foundry must be nil if the provider is not Foundry",rule="!(has(self.foundry) && self.provider != 'Foundry')" +// +kubebuilder:validation:XValidation:message="provider.mistral must be nil if the provider is not Mistral",rule="!(has(self.mistral) && self.provider != 'Mistral')" // +kubebuilder:validation:XValidation:message="apiKeySecret must be set if apiKeySecretKey is set",rule="!(has(self.apiKeySecretKey) && !has(self.apiKeySecret))" // +kubebuilder:validation:XValidation:message="apiKeySecretKey must be set if apiKeySecret is set (except for Bedrock and SAPAICore providers)",rule="!(has(self.apiKeySecret) && !has(self.apiKeySecretKey) && self.provider != 'Bedrock' && self.provider != 'SAPAICore')" // +kubebuilder:validation:XValidation:message="apiKeyPassthrough and apiKeySecret are mutually exclusive",rule="!(has(self.apiKeyPassthrough) && self.apiKeyPassthrough && has(self.apiKeySecret) && size(self.apiKeySecret) > 0)" @@ -577,6 +606,10 @@ type ModelConfigSpec struct { // +optional Foundry *FoundryConfig `json:"foundry,omitempty"` + // Mistral-specific configuration + // +optional + Mistral *MistralConfig `json:"mistral,omitempty"` + // TLS configuration for provider connections. // Enables agents to connect to internal LiteLLM gateways or other providers // that use self-signed certificates or custom certificate authorities. diff --git a/go/api/v1alpha2/modelproviderconfig_types.go b/go/api/v1alpha2/modelproviderconfig_types.go index 907001232..2f0307096 100644 --- a/go/api/v1alpha2/modelproviderconfig_types.go +++ b/go/api/v1alpha2/modelproviderconfig_types.go @@ -44,6 +44,8 @@ func DefaultModelProviderEndpoint(providerType ModelProvider) string { return "https://generativelanguage.googleapis.com" case ModelProviderOllama: return "http://localhost:11434" + case ModelProviderMistral: + return "https://api.mistral.ai/v1" default: // Azure, Bedrock, Vertex AI require user-specific endpoints return "" diff --git a/go/api/v1alpha2/zz_generated.deepcopy.go b/go/api/v1alpha2/zz_generated.deepcopy.go index 654d5e371..8f2c48503 100644 --- a/go/api/v1alpha2/zz_generated.deepcopy.go +++ b/go/api/v1alpha2/zz_generated.deepcopy.go @@ -1064,6 +1064,46 @@ func (in *MemorySpec) DeepCopy() *MemorySpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MistralConfig) DeepCopyInto(out *MistralConfig) { + *out = *in + if in.BaseURL != nil { + in, out := &in.BaseURL, &out.BaseURL + *out = new(string) + **out = **in + } + if in.Temperature != nil { + in, out := &in.Temperature, &out.Temperature + *out = new(string) + **out = **in + } + if in.TopP != nil { + in, out := &in.TopP, &out.TopP + *out = new(string) + **out = **in + } + if in.MaxTokens != nil { + in, out := &in.MaxTokens, &out.MaxTokens + *out = new(int) + **out = **in + } + if in.Timeout != nil { + in, out := &in.Timeout, &out.Timeout + *out = new(int) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MistralConfig. +func (in *MistralConfig) DeepCopy() *MistralConfig { + if in == nil { + return nil + } + out := new(MistralConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ModelConfig) DeepCopyInto(out *ModelConfig) { *out = *in @@ -1183,6 +1223,11 @@ func (in *ModelConfigSpec) DeepCopyInto(out *ModelConfigSpec) { *out = new(FoundryConfig) (*in).DeepCopyInto(*out) } + if in.Mistral != nil { + in, out := &in.Mistral, &out.Mistral + *out = new(MistralConfig) + (*in).DeepCopyInto(*out) + } if in.TLS != nil { in, out := &in.TLS, &out.TLS *out = new(TLSConfig) diff --git a/go/core/internal/a2a/trace.go b/go/core/internal/a2a/trace.go index 943407e1d..1204a2c7a 100644 --- a/go/core/internal/a2a/trace.go +++ b/go/core/internal/a2a/trace.go @@ -83,6 +83,8 @@ func genAIProviderName(p v1alpha2.ModelProvider) attribute.KeyValue { return semconv.GenAIProviderNameAWSBedrock case v1alpha2.ModelProviderOllama: return semconv.GenAIProviderNameKey.String("ollama") + case v1alpha2.ModelProviderMistral: + return semconv.GenAIProviderNameKey.String("mistral_ai") default: return semconv.GenAIProviderNameKey.String("kagent") } diff --git a/go/core/internal/controller/translator/agent/adk_api_translator.go b/go/core/internal/controller/translator/agent/adk_api_translator.go index 3a6baf8d0..fed6a0be7 100644 --- a/go/core/internal/controller/translator/agent/adk_api_translator.go +++ b/go/core/internal/controller/translator/agent/adk_api_translator.go @@ -596,6 +596,48 @@ func (a *adkApiTranslator) translateModel(ctx context.Context, namespace, modelC } } return anthropic, modelDeploymentData, secretHashBytes, nil + case v1alpha2.ModelProviderMistral: + if !model.Spec.APIKeyPassthrough && model.Spec.APIKeySecret != "" { + modelDeploymentData.EnvVars = append(modelDeploymentData.EnvVars, corev1.EnvVar{ + Name: env.MistralAPIKey.Name(), + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: model.Spec.APIKeySecret, + }, + Key: model.Spec.APIKeySecretKey, + }, + }, + }) + } + mistral := &adk.Mistral{ + BaseModel: adk.BaseModel{ + Model: model.Spec.Model, + Headers: model.Spec.DefaultHeaders, + }, + } + populateTLSFields(&mistral.BaseModel, model.Spec.TLS) + mistral.APIKeyPassthrough = model.Spec.APIKeyPassthrough + + if model.Spec.Mistral != nil { + spec := model.Spec.Mistral + if spec.BaseURL != nil { + mistral.BaseUrl = *spec.BaseURL + } + if spec.Temperature != nil { + mistral.Temperature = utils.ParseStringToFloat64(*spec.Temperature) + } + if spec.TopP != nil { + mistral.TopP = utils.ParseStringToFloat64(*spec.TopP) + } + if spec.MaxTokens != nil && *spec.MaxTokens > 0 { + mistral.MaxTokens = spec.MaxTokens + } + if spec.Timeout != nil && *spec.Timeout > 0 { + mistral.Timeout = spec.Timeout + } + } + return mistral, modelDeploymentData, secretHashBytes, nil case v1alpha2.ModelProviderAzureOpenAI: if model.Spec.AzureOpenAI == nil { return nil, nil, nil, fmt.Errorf("AzureOpenAI model config is required") diff --git a/go/core/internal/controller/translator/agent/testdata/inputs/mistral_agent.yaml b/go/core/internal/controller/translator/agent/testdata/inputs/mistral_agent.yaml new file mode 100644 index 000000000..5a3fec357 --- /dev/null +++ b/go/core/internal/controller/translator/agent/testdata/inputs/mistral_agent.yaml @@ -0,0 +1,38 @@ +operation: translateAgent +targetObject: mistral-agent +namespace: test +objects: + - apiVersion: v1 + kind: Secret + metadata: + name: mistral-secret + namespace: test + data: + api-key: bWlzdHJhbC1hcGkta2V5 # base64 encoded "mistral-api-key" + - apiVersion: kagent.dev/v1alpha2 + kind: ModelConfig + metadata: + name: mistral-model + namespace: test + spec: + provider: Mistral + model: mistral-large-latest + apiKeySecret: mistral-secret + apiKeySecretKey: api-key + mistral: + baseUrl: "https://api.mistral.ai/v1" + temperature: "0.3" + maxTokens: 4096 + topP: "0.9" + - apiVersion: kagent.dev/v1alpha2 + kind: Agent + metadata: + name: mistral-agent + namespace: test + spec: + type: Declarative + declarative: + description: An agent using Mistral AI + systemMessage: You are a helpful assistant powered by Mistral AI. + modelConfig: mistral-model + tools: [] diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/mistral_agent.json b/go/core/internal/controller/translator/agent/testdata/outputs/mistral_agent.json new file mode 100644 index 000000000..a5279863d --- /dev/null +++ b/go/core/internal/controller/translator/agent/testdata/outputs/mistral_agent.json @@ -0,0 +1,298 @@ +{ + "agentCard": { + "capabilities": { + "extensions": [ + { + "description": "Human in the loop for tool approval, ask user, and nested subagents", + "uri": "https://kagent.dev/extensions/hitl/v1" + } + ], + "streaming": true + }, + "defaultInputModes": [ + "text" + ], + "defaultOutputModes": [ + "text" + ], + "description": "", + "name": "mistral_agent", + "skills": null, + "supportedInterfaces": [ + { + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + "url": "http://mistral-agent.test:8080" + } + ], + "version": "" + }, + "config": { + "description": "", + "instruction": "You are a helpful assistant powered by Mistral AI.", + "model": { + "base_url": "https://api.mistral.ai/v1", + "max_tokens": 4096, + "model": "mistral-large-latest", + "temperature": 0.3, + "top_p": 0.9, + "type": "mistral" + }, + "stream": false + }, + "manifest": [ + { + "apiVersion": "v1", + "kind": "Secret", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "mistral-agent", + "app.kubernetes.io/part-of": "kagent", + "kagent": "mistral-agent" + }, + "name": "mistral-agent", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "mistral-agent", + "uid": "" + } + ] + }, + "stringData": { + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://mistral-agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"}],\"capabilities\":{\"extensions\":[{\"description\":\"Human in the loop for tool approval, ask user, and nested subagents\",\"uri\":\"https://kagent.dev/extensions/hitl/v1\"}],\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"mistral_agent\",\"skills\":[],\"version\":\"\"}", + "config.json": "{\"model\":{\"type\":\"mistral\",\"model\":\"mistral-large-latest\",\"base_url\":\"https://api.mistral.ai/v1\",\"max_tokens\":4096,\"temperature\":0.3,\"top_p\":0.9},\"description\":\"\",\"instruction\":\"You are a helpful assistant powered by Mistral AI.\",\"stream\":false}" + } + }, + { + "apiVersion": "v1", + "kind": "ServiceAccount", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "mistral-agent", + "app.kubernetes.io/part-of": "kagent", + "kagent": "mistral-agent" + }, + "name": "mistral-agent", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "mistral-agent", + "uid": "" + } + ] + } + }, + { + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "mistral-agent", + "app.kubernetes.io/part-of": "kagent", + "kagent": "mistral-agent" + }, + "name": "mistral-agent", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "mistral-agent", + "uid": "" + } + ] + }, + "spec": { + "selector": { + "matchLabels": { + "app": "kagent", + "kagent": "mistral-agent" + } + }, + "strategy": { + "rollingUpdate": { + "maxSurge": 1, + "maxUnavailable": 0 + }, + "type": "RollingUpdate" + }, + "template": { + "metadata": { + "annotations": { + "kagent.dev/config-hash": "7051761157857357774" + }, + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "mistral-agent", + "app.kubernetes.io/part-of": "kagent", + "kagent": "mistral-agent" + } + }, + "spec": { + "containers": [ + { + "args": [ + "--host", + "0.0.0.0", + "--port", + "8080", + "--filepath", + "/config" + ], + "env": [ + { + "name": "MISTRAL_API_KEY", + "valueFrom": { + "secretKeyRef": { + "key": "api-key", + "name": "mistral-secret" + } + } + }, + { + "name": "KAGENT_NAMESPACE", + "valueFrom": { + "fieldRef": { + "fieldPath": "metadata.namespace" + } + } + }, + { + "name": "KAGENT_NAME", + "value": "mistral-agent" + }, + { + "name": "KAGENT_URL", + "value": "http://kagent-controller.kagent:8083" + } + ], + "image": "ghcr.io/kagent-dev/kagent/app:dev", + "imagePullPolicy": "IfNotPresent", + "name": "kagent", + "ports": [ + { + "containerPort": 8080, + "name": "http" + } + ], + "readinessProbe": { + "httpGet": { + "path": "/.well-known/agent-card.json", + "port": "http" + }, + "initialDelaySeconds": 15, + "periodSeconds": 15, + "timeoutSeconds": 15 + }, + "resources": { + "limits": { + "cpu": "2", + "memory": "1Gi" + }, + "requests": { + "cpu": "100m", + "memory": "384Mi" + } + }, + "volumeMounts": [ + { + "mountPath": "/config", + "name": "config" + }, + { + "mountPath": "/var/run/secrets/tokens", + "name": "kagent-token" + } + ] + } + ], + "serviceAccountName": "mistral-agent", + "volumes": [ + { + "name": "config", + "secret": { + "secretName": "mistral-agent" + } + }, + { + "name": "kagent-token", + "projected": { + "sources": [ + { + "serviceAccountToken": { + "audience": "kagent", + "expirationSeconds": 3600, + "path": "kagent-token" + } + } + ] + } + } + ] + } + } + }, + "status": {} + }, + { + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "mistral-agent", + "app.kubernetes.io/part-of": "kagent", + "kagent": "mistral-agent" + }, + "name": "mistral-agent", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "mistral-agent", + "uid": "" + } + ] + }, + "spec": { + "ports": [ + { + "name": "http", + "port": 8080, + "targetPort": 8080 + } + ], + "selector": { + "app": "kagent", + "kagent": "mistral-agent" + }, + "type": "ClusterIP" + }, + "status": { + "loadBalancer": {} + } + } + ] +} \ No newline at end of file diff --git a/go/core/internal/httpserver/handlers/models.go b/go/core/internal/httpserver/handlers/models.go index b7a5d702b..3a9cb973f 100644 --- a/go/core/internal/httpserver/handlers/models.go +++ b/go/core/internal/httpserver/handlers/models.go @@ -194,6 +194,18 @@ func (h *ModelHandler) HandleListSupportedModels(w ErrorResponseWriter, r *http. // Amazon Nova {Name: "us.amazon.nova-2-lite-v1:0", FunctionCalling: false}, }, + v1alpha2.ModelProviderMistral: { + {Name: "mistral-large-latest", FunctionCalling: true}, + {Name: "mistral-medium-latest", FunctionCalling: true}, + {Name: "mistral-small-latest", FunctionCalling: true}, + {Name: "magistral-medium-latest", FunctionCalling: true}, + {Name: "magistral-small-latest", FunctionCalling: true}, + {Name: "codestral-latest", FunctionCalling: true}, + {Name: "ministral-8b-latest", FunctionCalling: true}, + {Name: "ministral-3b-latest", FunctionCalling: true}, + {Name: "pixtral-large-latest", FunctionCalling: true}, + {Name: "open-mistral-nemo", FunctionCalling: true}, + }, v1alpha2.ModelProviderSAPAICore: { // Anthropic (via SAP Generative AI Hub proxy naming) {Name: "anthropic--claude-4.7-opus", FunctionCalling: true}, diff --git a/go/core/pkg/env/providers.go b/go/core/pkg/env/providers.go index cace92159..c5967caab 100644 --- a/go/core/pkg/env/providers.go +++ b/go/core/pkg/env/providers.go @@ -171,6 +171,23 @@ var ( ) ) +// Mistral +var ( + MistralAPIKey = RegisterStringVar( + "MISTRAL_API_KEY", + "", + "API key for Mistral AI.", + ComponentAgentRuntime, + ) + + MistralAPIBase = RegisterStringVar( + "MISTRAL_API_BASE", + "", + "Custom base URL for the Mistral AI API (defaults to https://api.mistral.ai/v1).", + ComponentAgentRuntime, + ) +) + // Foundry var ( FoundryAPIKey = RegisterStringVar( diff --git a/go/core/test/e2e/mistral_test.go b/go/core/test/e2e/mistral_test.go new file mode 100644 index 000000000..82d6ca899 --- /dev/null +++ b/go/core/test/e2e/mistral_test.go @@ -0,0 +1,100 @@ +package e2e_test + +import ( + "fmt" + "net" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/kagent-dev/kagent/go/api/v1alpha2" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + mistralChatModel = "mistral-large-latest" +) + +// setupMistralModelConfig creates a Mistral ModelConfig pointing at the given +// endpoint. Mistral speaks the OpenAI wire protocol, so the mock only needs to +// implement /chat/completions. +func setupMistralModelConfig(t *testing.T, cli client.Client, endpoint, model string) *v1alpha2.ModelConfig { + t.Helper() + baseURL := endpoint + modelCfg := &v1alpha2.ModelConfig{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-mistral-model-config-", + Namespace: "kagent", + }, + Spec: v1alpha2.ModelConfigSpec{ + Model: model, + Provider: v1alpha2.ModelProviderMistral, + APIKeySecret: "kagent-mistral", + APIKeySecretKey: "MISTRAL_API_KEY", + Mistral: &v1alpha2.MistralConfig{ + BaseURL: &baseURL, + }, + }, + } + require.NoError(t, cli.Create(t.Context(), modelCfg)) + cleanup(t, cli, modelCfg) + return modelCfg +} + +// setupMistralMockServer stands up a mock OpenAI-compatible endpoint that +// returns a canned chat-completion response, matching the shape Mistral would +// return. Returns a cluster-reachable URL. +func setupMistralMockServer(t *testing.T) (string, func()) { + t.Helper() + + listener, err := net.Listen("tcp", "0.0.0.0:0") + require.NoError(t, err) + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/chat/completions": + fmt.Fprint(w, `{"id":"chatcmpl-mistral-1","object":"chat.completion","created":0,"model":"mistral-large-latest","choices":[{"index":0,"message":{"role":"assistant","content":"Bonjour from the mock Mistral endpoint."},"finish_reason":"stop"}]}`) + default: + http.Error(w, fmt.Sprintf("unexpected Mistral mock path %s", r.URL.Path), http.StatusNotFound) + } + })) + server.Listener = listener + server.Start() + + clusterURL := buildK8sURL(server.URL) + return clusterURL, server.Close +} + +// TestE2EInvokeWithMistralAgent verifies a Mistral-backed agent can be +// deployed and returns a mock chat completion. The test is skipped when the +// e2e environment or a fake MISTRAL_API_KEY secret is not available: it never +// contacts the real Mistral API. +func TestE2EInvokeWithMistralAgent(t *testing.T) { + // Skip when the caller has not opted into Mistral e2e coverage. The check + // keeps CI green in environments without a mistral-api-key secret while + // still allowing local runs to exercise the full agent pipeline. + if os.Getenv("MISTRAL_API_KEY") == "" { + t.Skip("Skipping Mistral e2e: MISTRAL_API_KEY not set (used only as a signal to opt in; the test uses a mock server)") + } + + endpoint, stopServer := setupMistralMockServer(t) + defer stopServer() + + cli := setupK8sClient(t, false) + chatModelCfg := setupMistralModelConfig(t, cli, endpoint, mistralChatModel) + + goRuntime := v1alpha2.DeclarativeRuntime_Go + agent := setupAgentWithOptions(t, cli, chatModelCfg.Name, nil, AgentOptions{ + Name: "mistral-go-adk-test", + Runtime: &goRuntime, + }) + a2aClient := setupA2AClient(t, agent) + + runSyncTest(t, a2aClient, + "Say hello in French", + "Bonjour", + ) +} diff --git a/helm/kagent-crds/templates/kagent.dev_modelconfigs.yaml b/helm/kagent-crds/templates/kagent.dev_modelconfigs.yaml index d3600d0c4..6acb739d7 100644 --- a/helm/kagent-crds/templates/kagent.dev_modelconfigs.yaml +++ b/helm/kagent-crds/templates/kagent.dev_modelconfigs.yaml @@ -679,6 +679,27 @@ spec: - location - projectID type: object + mistral: + description: Mistral-specific configuration + properties: + baseUrl: + description: Base URL for the Mistral API (overrides default https://api.mistral.ai/v1) + type: string + maxTokens: + description: Maximum tokens to generate + minimum: 1 + type: integer + temperature: + description: Temperature for sampling + type: string + timeout: + description: Timeout in seconds for the underlying HTTP client + minimum: 1 + type: integer + topP: + description: Top-p sampling parameter + type: string + type: object model: type: string ollama: @@ -804,6 +825,7 @@ spec: - Bedrock - SAPAICore - Foundry + - Mistral type: string sapAICore: description: SAP AI Core-specific configuration @@ -901,6 +923,8 @@ spec: rule: '!(has(self.sapAICore) && self.provider != ''SAPAICore'')' - message: provider.foundry must be nil if the provider is not Foundry rule: '!(has(self.foundry) && self.provider != ''Foundry'')' + - message: provider.mistral must be nil if the provider is not Mistral + rule: '!(has(self.mistral) && self.provider != ''Mistral'')' - message: apiKeySecret must be set if apiKeySecretKey is set rule: '!(has(self.apiKeySecretKey) && !has(self.apiKeySecret))' - message: apiKeySecretKey must be set if apiKeySecret is set (except diff --git a/helm/kagent-crds/templates/kagent.dev_modelproviderconfigs.yaml b/helm/kagent-crds/templates/kagent.dev_modelproviderconfigs.yaml index 4c1d0af39..34d3f2345 100644 --- a/helm/kagent-crds/templates/kagent.dev_modelproviderconfigs.yaml +++ b/helm/kagent-crds/templates/kagent.dev_modelproviderconfigs.yaml @@ -92,6 +92,7 @@ spec: - Bedrock - SAPAICore - Foundry + - Mistral type: string required: - type diff --git a/helm/kagent/values.yaml b/helm/kagent/values.yaml index d6a3204c8..f72f6b0de 100644 --- a/helm/kagent/values.yaml +++ b/helm/kagent/values.yaml @@ -614,6 +614,12 @@ providers: apiKeySecretRef: kagent-gemini apiKeySecretKey: GOOGLE_API_KEY # apiKey: "" + mistral: + provider: Mistral + model: "mistral-large-latest" + apiKeySecretRef: kagent-mistral + apiKeySecretKey: MISTRAL_API_KEY + # apiKey: "" # ============================================================================== # KMCP diff --git a/python/packages/kagent-adk/src/kagent/adk/models/__init__.py b/python/packages/kagent-adk/src/kagent/adk/models/__init__.py index bad9f56d2..9c1219611 100644 --- a/python/packages/kagent-adk/src/kagent/adk/models/__init__.py +++ b/python/packages/kagent-adk/src/kagent/adk/models/__init__.py @@ -2,6 +2,7 @@ from ._bedrock import KAgentBedrockLlm from ._embedding import KAgentEmbedding from ._gemini import KAgentGeminiLlm, KAgentGeminiVertexAILlm +from ._mistral import KAgentMistralLlm from ._ollama import KAgentOllamaLlm from ._openai import AzureOpenAI, OpenAI from ._sap_ai_core import KAgentSAPAICoreLlm @@ -13,6 +14,7 @@ "KAgentBedrockLlm", "KAgentGeminiLlm", "KAgentGeminiVertexAILlm", + "KAgentMistralLlm", "KAgentOllamaLlm", "KAgentEmbedding", "KAgentSAPAICoreLlm", diff --git a/python/packages/kagent-adk/src/kagent/adk/models/_mistral.py b/python/packages/kagent-adk/src/kagent/adk/models/_mistral.py new file mode 100644 index 000000000..db3df1303 --- /dev/null +++ b/python/packages/kagent-adk/src/kagent/adk/models/_mistral.py @@ -0,0 +1,62 @@ +"""Mistral AI model implementation. + +Mistral exposes an OpenAI-compatible wire protocol (POST /v1/chat/completions +with a Bearer token), so KAgentMistralLlm subclasses BaseOpenAI to inherit +message conversion, tool schemas, streaming, and telemetry. Only the +discriminator, default base URL, and API-key environment variable differ. +""" + +from __future__ import annotations + +import os +from functools import cached_property +from typing import Literal, Optional + +from openai import AsyncOpenAI + +from ._openai import BaseOpenAI + +DEFAULT_MISTRAL_BASE_URL = "https://api.mistral.ai/v1" + + +class KAgentMistralLlm(BaseOpenAI): + """Mistral AI model (OpenAI-compatible endpoint).""" + + type: Literal["mistral"] + + @classmethod + def supported_models(cls) -> list[str]: + """Regex list for LlmRegistry. Covers current Mistral, Magistral, Codestral, Ministral, Pixtral, and Nemo names.""" + return [ + r"mistral-.*", + r"magistral-.*", + r"codestral-.*", + r"ministral-.*", + r"pixtral-.*", + r"open-mistral-.*", + ] + + @cached_property + def _client(self) -> AsyncOpenAI: + """OpenAI-compatible client pointed at Mistral's endpoint. + + API key resolution: explicit api_key (passthrough or config) wins, + then MISTRAL_API_KEY. Base URL falls back to MISTRAL_API_BASE, then + the Mistral cloud default. + """ + api_key = self.api_key or os.environ.get("MISTRAL_API_KEY") + if not api_key and not self.api_key_passthrough: + raise ValueError( + "Mistral API key must be provided via api_key parameter, " + "MISTRAL_API_KEY environment variable, or api_key_passthrough." + ) + + base_url = self.base_url or os.environ.get("MISTRAL_API_BASE") or DEFAULT_MISTRAL_BASE_URL + + return AsyncOpenAI( + api_key=api_key, + base_url=base_url, + default_headers=self.default_headers, + timeout=self.timeout, + http_client=self._create_http_client(), + ) diff --git a/python/packages/kagent-adk/src/kagent/adk/types.py b/python/packages/kagent-adk/src/kagent/adk/types.py index 494f7def5..2623f10fd 100644 --- a/python/packages/kagent-adk/src/kagent/adk/types.py +++ b/python/packages/kagent-adk/src/kagent/adk/types.py @@ -20,6 +20,7 @@ from kagent.adk.models._anthropic import KAgentAnthropicLlm from kagent.adk.models._bedrock import KAgentBedrockLlm from kagent.adk.models._gemini import KAgentGeminiLlm, KAgentGeminiVertexAILlm +from kagent.adk.models._mistral import KAgentMistralLlm from kagent.adk.models._ollama import create_ollama_llm from kagent.adk.models._openai import AzureOpenAI as OpenAIAzure from kagent.adk.models._openai import OpenAI as OpenAINative @@ -352,7 +353,18 @@ class SAPAICore(BaseLLM): type: Literal["sap_ai_core"] -ModelUnion = Union[OpenAI, Anthropic, GeminiVertexAI, GeminiAnthropic, Ollama, AzureOpenAI, Gemini, Bedrock, SAPAICore] +class Mistral(BaseLLM): + base_url: str | None = None + max_tokens: int | None = Field(default=None, ge=1) + temperature: float | None = None + top_p: float | None = None + timeout: int | None = Field(default=None, ge=1) + type: Literal["mistral"] + + +ModelUnion = Union[ + OpenAI, Anthropic, GeminiVertexAI, GeminiAnthropic, Ollama, AzureOpenAI, Gemini, Bedrock, SAPAICore, Mistral +] class ContextCompressionSettings(BaseModel): @@ -735,6 +747,18 @@ def _create_llm_from_model_config(model_config: ModelUnion): auth_url=model_config.auth_url, **_transport_kwargs(model_config), ) + if model_config.type == "mistral": + return KAgentMistralLlm( + type="mistral", + model=model_config.model, + base_url=base_url, + default_headers=extra_headers, + max_tokens=model_config.max_tokens, + temperature=model_config.temperature, + top_p=model_config.top_p, + timeout=model_config.timeout, + **_transport_kwargs(model_config), + ) raise ValueError(f"Invalid model type: {model_config.type}") diff --git a/python/packages/kagent-adk/tests/unittests/models/test_mistral.py b/python/packages/kagent-adk/tests/unittests/models/test_mistral.py new file mode 100644 index 000000000..bd6b4cfca --- /dev/null +++ b/python/packages/kagent-adk/tests/unittests/models/test_mistral.py @@ -0,0 +1,111 @@ +"""Tests for KAgentMistralLlm.""" + +from unittest import mock + +import pytest +from openai import AsyncOpenAI + +from kagent.adk.models._mistral import DEFAULT_MISTRAL_BASE_URL, KAgentMistralLlm + + +class TestKAgentMistralLlm: + def test_default_construction(self): + llm = KAgentMistralLlm(type="mistral", model="mistral-large-latest", api_key="sk-test") + assert llm.model == "mistral-large-latest" + assert llm.base_url is None + assert llm.default_headers is None + assert llm.api_key_passthrough is None + + def test_supported_models_regex_covers_mistral_families(self): + patterns = KAgentMistralLlm.supported_models() + assert r"mistral-.*" in patterns + assert r"magistral-.*" in patterns + assert r"codestral-.*" in patterns + assert r"ministral-.*" in patterns + assert r"pixtral-.*" in patterns + assert r"open-mistral-.*" in patterns + + def test_client_uses_default_base_url_when_unset(self, monkeypatch): + monkeypatch.delenv("MISTRAL_API_BASE", raising=False) + llm = KAgentMistralLlm(type="mistral", model="mistral-large-latest", api_key="sk-test") + with mock.patch("kagent.adk.models._mistral.AsyncOpenAI") as mock_client: + mock_client.return_value = mock.MagicMock(spec=AsyncOpenAI) + _ = llm._client + assert mock_client.call_args.kwargs["base_url"] == DEFAULT_MISTRAL_BASE_URL + assert mock_client.call_args.kwargs["api_key"] == "sk-test" + + def test_client_uses_env_base_url_when_config_unset(self, monkeypatch): + monkeypatch.setenv("MISTRAL_API_BASE", "https://gateway.example.com/mistral/v1") + llm = KAgentMistralLlm(type="mistral", model="mistral-large-latest", api_key="sk-test") + with mock.patch("kagent.adk.models._mistral.AsyncOpenAI") as mock_client: + mock_client.return_value = mock.MagicMock(spec=AsyncOpenAI) + _ = llm._client + assert mock_client.call_args.kwargs["base_url"] == "https://gateway.example.com/mistral/v1" + + def test_config_base_url_wins_over_env(self, monkeypatch): + monkeypatch.setenv("MISTRAL_API_BASE", "https://from-env.example.com/v1") + llm = KAgentMistralLlm( + type="mistral", + model="mistral-large-latest", + api_key="sk-test", + base_url="https://from-config.example.com/v1", + ) + with mock.patch("kagent.adk.models._mistral.AsyncOpenAI") as mock_client: + mock_client.return_value = mock.MagicMock(spec=AsyncOpenAI) + _ = llm._client + assert mock_client.call_args.kwargs["base_url"] == "https://from-config.example.com/v1" + + def test_client_reads_env_api_key(self, monkeypatch): + monkeypatch.setenv("MISTRAL_API_KEY", "env-key") + llm = KAgentMistralLlm(type="mistral", model="mistral-medium-latest") + with mock.patch("kagent.adk.models._mistral.AsyncOpenAI") as mock_client: + mock_client.return_value = mock.MagicMock(spec=AsyncOpenAI) + _ = llm._client + assert mock_client.call_args.kwargs["api_key"] == "env-key" + + def test_client_raises_when_no_key_and_no_passthrough(self, monkeypatch): + monkeypatch.delenv("MISTRAL_API_KEY", raising=False) + llm = KAgentMistralLlm(type="mistral", model="mistral-small-latest") + with pytest.raises(ValueError, match="Mistral API key must be provided"): + _ = llm._client + + def test_client_allows_passthrough_without_key(self, monkeypatch): + monkeypatch.delenv("MISTRAL_API_KEY", raising=False) + llm = KAgentMistralLlm(type="mistral", model="mistral-small-latest", api_key_passthrough=True) + with mock.patch("kagent.adk.models._mistral.AsyncOpenAI") as mock_client: + mock_client.return_value = mock.MagicMock(spec=AsyncOpenAI) + _ = llm._client + # No API key required; call goes through with None + assert mock_client.call_args.kwargs.get("api_key") is None + + def test_client_uses_default_headers(self): + llm = KAgentMistralLlm( + type="mistral", + model="mistral-large-latest", + api_key="sk-test", + default_headers={"X-Org": "test-org"}, + ) + with mock.patch("kagent.adk.models._mistral.AsyncOpenAI") as mock_client: + mock_client.return_value = mock.MagicMock(spec=AsyncOpenAI) + _ = llm._client + assert mock_client.call_args.kwargs["default_headers"] == {"X-Org": "test-org"} + + +class TestCreateLLMFromMistralConfig: + def test_create_llm_from_mistral_model_config(self): + """Integration: _create_llm_from_model_config returns KAgentMistralLlm for mistral type.""" + from kagent.adk.types import Mistral, _create_llm_from_model_config + + config = Mistral( + type="mistral", + model="mistral-large-latest", + base_url="https://api.mistral.ai/v1", + temperature=0.5, + max_tokens=1024, + ) + result = _create_llm_from_model_config(config) + assert isinstance(result, KAgentMistralLlm) + assert result.model == "mistral-large-latest" + assert result.base_url == "https://api.mistral.ai/v1" + assert result.temperature == 0.5 + assert result.max_tokens == 1024 diff --git a/ui/src/components/ModelProviderCombobox.tsx b/ui/src/components/ModelProviderCombobox.tsx index eb366672e..990d04ba2 100644 --- a/ui/src/components/ModelProviderCombobox.tsx +++ b/ui/src/components/ModelProviderCombobox.tsx @@ -13,6 +13,7 @@ import { Azure } from './icons/Azure'; import { Gemini } from './icons/Gemini'; import { Bedrock } from './icons/Bedrock'; import { SAPAICore } from './icons/SAPAICore'; +import { Mistral } from './icons/Mistral'; interface ComboboxOption { label: string; // e.g., "OpenAI - gpt-4o" @@ -69,6 +70,7 @@ export function ModelProviderCombobox({ 'Bedrock': Bedrock, 'SAPAICore': SAPAICore, 'Foundry': Azure, + 'Mistral': Mistral, }; if (!providerKey || !PROVIDER_ICONS[providerKey]) { return null; diff --git a/ui/src/components/ProviderCombobox.tsx b/ui/src/components/ProviderCombobox.tsx index fdeef943a..faeb57325 100644 --- a/ui/src/components/ProviderCombobox.tsx +++ b/ui/src/components/ProviderCombobox.tsx @@ -13,6 +13,7 @@ import { Azure } from './icons/Azure'; import { Gemini } from './icons/Gemini'; import { Bedrock } from './icons/Bedrock'; import { SAPAICore } from './icons/SAPAICore'; +import { Mistral } from './icons/Mistral'; const PROVIDER_ICONS: Record> = { 'OpenAI': OpenAI, @@ -25,6 +26,7 @@ const PROVIDER_ICONS: Record + + + + + + + + + + + + + + + ); +} diff --git a/ui/src/lib/providers.ts b/ui/src/lib/providers.ts index 5e1737a72..41d2a7b17 100644 --- a/ui/src/lib/providers.ts +++ b/ui/src/lib/providers.ts @@ -1,6 +1,6 @@ -export type BackendModelProviderType = "OpenAI" | "AzureOpenAI" | "Anthropic" | "Ollama" | "Gemini" | "GeminiVertexAI" | "AnthropicVertexAI" | "Bedrock" | "SAPAICore" | "Foundry"; -export const modelProviders = ["OpenAI", "AzureOpenAI", "Anthropic", "Ollama", "Gemini", "GeminiVertexAI", "AnthropicVertexAI", "Bedrock", "SAPAICore", "Foundry"] as const; +export type BackendModelProviderType = "OpenAI" | "AzureOpenAI" | "Anthropic" | "Ollama" | "Gemini" | "GeminiVertexAI" | "AnthropicVertexAI" | "Bedrock" | "SAPAICore" | "Foundry" | "Mistral"; +export const modelProviders = ["OpenAI", "AzureOpenAI", "Anthropic", "Ollama", "Gemini", "GeminiVertexAI", "AnthropicVertexAI", "Bedrock", "SAPAICore", "Foundry", "Mistral"] as const; export type ModelProviderKey = typeof modelProviders[number]; @@ -83,6 +83,13 @@ export const PROVIDERS_INFO: { modelDocsLink: "https://learn.microsoft.com/azure/ai-foundry/", help: "Enter your Azure AI Foundry account endpoint and deployment name. The API key is optional — leave it blank to authenticate with Azure Workload Identity." }, + Mistral: { + name: "Mistral AI", + type: "Mistral", + apiKeyLink: "https://console.mistral.ai/api-keys", + modelDocsLink: "https://docs.mistral.ai/getting-started/models/models_overview/", + help: "Get your API key from the Mistral AI Console. Mistral uses an OpenAI-compatible endpoint at https://api.mistral.ai/v1." + }, }; export const isValidProviderInfoKey = (key: string): key is ModelProviderKey => {