From 12ecd2fcdbabb052aff3806e45fcc9d513ad073c Mon Sep 17 00:00:00 2001 From: Mark Rossetti Date: Thu, 25 Jun 2026 21:56:12 +0000 Subject: [PATCH 1/2] feat: add Foundry provider backend support Signed-off-by: Mark Rossetti --- go/adk/pkg/agent/agent.go | 11 + go/adk/pkg/models/foundry.go | 139 +++++ go/adk/pkg/models/foundry_test.go | 144 +++++ go/api/adk/types.go | 45 ++ .../crd/bases/kagent.dev_modelconfigs.yaml | 179 ++++++ .../kagent.dev_modelproviderconfigs.yaml | 1 + go/api/v1alpha2/modelconfig_types.go | 100 ++- go/api/v1alpha2/zz_generated.deepcopy.go | 111 ++++ .../translator/agent/adk_api_translator.go | 215 +++++-- .../controller/translator/agent/compiler.go | 63 +- .../translator/agent/deployments.go | 47 +- .../translator/agent/deployments_test.go | 66 ++ .../translator/agent/foundry_test.go | 576 ++++++++++++++++++ go/core/pkg/env/providers.go | 31 + go/go.mod | 6 + go/go.sum | 17 + .../templates/kagent.dev_modelconfigs.yaml | 179 ++++++ .../kagent.dev_modelproviderconfigs.yaml | 1 + 18 files changed, 1873 insertions(+), 58 deletions(-) create mode 100644 go/adk/pkg/models/foundry.go create mode 100644 go/adk/pkg/models/foundry_test.go create mode 100644 go/core/internal/controller/translator/agent/foundry_test.go diff --git a/go/adk/pkg/agent/agent.go b/go/adk/pkg/agent/agent.go index fa9d633d14..437eba99b8 100644 --- a/go/adk/pkg/agent/agent.go +++ b/go/adk/pkg/agent/agent.go @@ -340,6 +340,17 @@ func CreateLLM(ctx context.Context, m adk.Model, log logr.Logger) (adkmodel.LLM, } return models.NewSAPAICoreModelWithLogger(cfg, log) + case *adk.Foundry: + cfg := &models.FoundryConfig{ + TransportConfig: transportConfigFromBase(m.BaseModel, nil), + Model: m.Model, + Endpoint: m.Endpoint, + Deployment: m.Deployment, + APIVersion: m.APIVersion, + AuthType: string(m.Auth.Type), + } + return models.NewFoundryModelWithLogger(ctx, cfg, log) + default: return nil, fmt.Errorf("unsupported model type: %s", m.GetType()) } diff --git a/go/adk/pkg/models/foundry.go b/go/adk/pkg/models/foundry.go new file mode 100644 index 0000000000..596dc16c71 --- /dev/null +++ b/go/adk/pkg/models/foundry.go @@ -0,0 +1,139 @@ +package models + +import ( + "context" + "fmt" + "net/http" + "os" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/go-logr/logr" + "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/option" +) + +// FoundryConfig holds Foundry configuration. +type FoundryConfig struct { + TransportConfig + Model string + Endpoint string + Deployment string + APIVersion string + AuthType string +} + +const ( + foundryAuthTypeAPIKey = "APIKey" + foundryAuthTypeWorkloadIdentity = "WorkloadIdentity" + foundryAuthTypeAPIKeyPassthrough = "APIKeyPassthrough" +) + +// NewFoundryModelWithLogger creates a Foundry model. +func NewFoundryModelWithLogger(ctx context.Context, config *FoundryConfig, logger logr.Logger) (*OpenAIModel, error) { + endpoint := config.Endpoint + if endpoint == "" { + endpoint = os.Getenv("FOUNDRY_ENDPOINT") + } + if endpoint == "" { + return nil, fmt.Errorf("FOUNDRY_ENDPOINT environment variable is not set") + } + deployment := config.Deployment + if deployment == "" { + deployment = os.Getenv("FOUNDRY_DEPLOYMENT") + } + if deployment == "" { + return nil, fmt.Errorf("FOUNDRY_DEPLOYMENT environment variable is not set") + } + apiVersion := config.APIVersion + if apiVersion == "" { + apiVersion = os.Getenv("FOUNDRY_API_VERSION") + } + if apiVersion == "" { + apiVersion = "2024-10-21" + } + + httpClient, err := BuildHTTPClient(config.TransportConfig) + if err != nil { + return nil, err + } + opts := []option.RequestOption{ + option.WithBaseURL(strings.TrimSuffix(endpoint, "/") + "/"), + option.WithQueryAdd("api-version", apiVersion), + option.WithMiddleware(azurePathRewriteMiddleware()), + option.WithHTTPClient(httpClient), + } + + authType := config.AuthType + if authType == "" { + authType = foundryAuthTypeWorkloadIdentity + } + switch authType { + case foundryAuthTypeAPIKey: + apiKey := os.Getenv("FOUNDRY_API_KEY") + if apiKey == "" { + return nil, fmt.Errorf("FOUNDRY_API_KEY environment variable is not set") + } + opts = append(opts, option.WithHeader("Api-Key", apiKey)) + case foundryAuthTypeWorkloadIdentity: + credential, err := azidentity.NewDefaultAzureCredential(nil) + if err != nil { + return nil, fmt.Errorf("failed to create Azure credential: %w", err) + } + opts = append(opts, + option.WithAPIKey("foundry-entra"), + option.WithMiddleware(foundryBearerTokenMiddleware(credential)), + ) + case foundryAuthTypeAPIKeyPassthrough: + config.APIKeyPassthrough = true + opts = append(opts, option.WithMiddleware(foundryPassthroughBearerTokenMiddleware())) + default: + return nil, fmt.Errorf("unsupported Foundry auth type: %s", authType) + } + + client := openai.NewClient(opts...) + if logger.GetSink() != nil { + logger.Info("Initialized Foundry model", "model", config.Model, "deployment", deployment, "endpoint", endpoint, "apiVersion", apiVersion) + } + return &OpenAIModel{ + Config: &OpenAIConfig{ + TransportConfig: config.TransportConfig, + Model: deployment, + BaseUrl: strings.TrimSuffix(endpoint, "/") + "/", + }, + Client: client, + IsAzure: true, + Logger: logger, + }, nil +} + +type foundryTokenCredential interface { + GetToken(context.Context, policy.TokenRequestOptions) (azcore.AccessToken, error) +} + +func foundryBearerTokenMiddleware(credential foundryTokenCredential) option.Middleware { + return func(r *http.Request, next option.MiddlewareNext) (*http.Response, error) { + token, err := credential.GetToken(r.Context(), policy.TokenRequestOptions{Scopes: []string{"https://cognitiveservices.azure.com/.default"}}) + if err != nil { + return nil, fmt.Errorf("failed to acquire Foundry token: %w", err) + } + r = r.Clone(r.Context()) + r.Header.Set("Authorization", "Bearer "+token.Token) + return next(r) + } +} + +func foundryPassthroughBearerTokenMiddleware() option.Middleware { + return func(r *http.Request, next option.MiddlewareNext) (*http.Response, error) { + token, ok := r.Context().Value(BearerTokenKey).(string) + if !ok || token == "" { + return next(r) + } + r = r.Clone(r.Context()) + r.Header.Del("Api-Key") + r.Header.Set("Authorization", "Bearer "+token) + return next(r) + } +} diff --git a/go/adk/pkg/models/foundry_test.go b/go/adk/pkg/models/foundry_test.go new file mode 100644 index 0000000000..489cb9a51d --- /dev/null +++ b/go/adk/pkg/models/foundry_test.go @@ -0,0 +1,144 @@ +package models + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/go-logr/logr" + "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/shared" +) + +func TestNewFoundryModelWithLoggerAPIKeyRequiresEnv(t *testing.T) { + t.Setenv("FOUNDRY_API_KEY", "") + + _, err := NewFoundryModelWithLogger(context.Background(), &FoundryConfig{ + Endpoint: "https://example.openai.azure.com/", + Deployment: "gpt-4-1-nano", + AuthType: foundryAuthTypeAPIKey, + }, logr.Discard()) + if err == nil || !strings.Contains(err.Error(), "FOUNDRY_API_KEY environment variable is not set") { + t.Fatalf("NewFoundryModelWithLogger() error = %v, want missing FOUNDRY_API_KEY", err) + } +} + +func TestNewFoundryModelWithLoggerAPIKeyPassthrough(t *testing.T) { + model, err := NewFoundryModelWithLogger(context.Background(), &FoundryConfig{ + Endpoint: "https://example.openai.azure.com/", + Deployment: "gpt-4-1-nano", + AuthType: foundryAuthTypeAPIKeyPassthrough, + }, logr.Discard()) + if err != nil { + t.Fatalf("NewFoundryModelWithLogger() error = %v", err) + } + if model == nil || model.Config == nil || !model.Config.APIKeyPassthrough { + t.Fatalf("APIKeyPassthrough = false, want true") + } + if !model.IsAzure { + t.Fatalf("IsAzure = false, want true") + } +} + +func TestFoundryAPIKeyPassthroughSendsAuthorizationHeader(t *testing.T) { + requests := make(chan foundryRequest, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests <- foundryRequest{ + apiKey: r.Header.Get("Api-Key"), + authorization: r.Header.Get("Authorization"), + path: r.URL.Path, + apiVersion: r.URL.Query().Get("api-version"), + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"id":"chatcmpl-test","object":"chat.completion","created":0,"model":"gpt-4-1-nano","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}`) + })) + t.Cleanup(server.Close) + + model, err := NewFoundryModelWithLogger(context.Background(), &FoundryConfig{ + Endpoint: server.URL, + Deployment: "gpt-4-1-nano", + AuthType: foundryAuthTypeAPIKeyPassthrough, + }, logr.Discard()) + if err != nil { + t.Fatalf("NewFoundryModelWithLogger() error = %v", err) + } + + ctx := context.WithValue(context.Background(), BearerTokenKey, "incoming-token") + _, err = model.Client.Chat.Completions.New(ctx, openai.ChatCompletionNewParams{ + Model: shared.ChatModel("gpt-4-1-nano"), + Messages: []openai.ChatCompletionMessageParamUnion{openai.UserMessage("hello")}, + }, openAIPassthroughOpts(ctx, model)...) + if err != nil { + t.Fatalf("Chat completion request error = %v", err) + } + req := <-requests + if req.path != "/openai/deployments/gpt-4-1-nano/chat/completions" { + t.Fatalf("path = %q, want Azure deployment path", req.path) + } + if req.apiVersion != "2024-10-21" { + t.Fatalf("api-version = %q, want 2024-10-21", req.apiVersion) + } + if req.authorization != "Bearer incoming-token" { + t.Fatalf("Authorization header = %q, want Bearer incoming-token", req.authorization) + } + if req.apiKey != "" { + t.Fatalf("Api-Key header = %q, want empty", req.apiKey) + } +} + +func TestFoundryBearerTokenMiddlewareUsesRequestContext(t *testing.T) { + credential := &requestContextCredential{t: t} + middleware := foundryBearerTokenMiddleware(credential) + req := httptest.NewRequest(http.MethodPost, "https://example.com/chat/completions", nil) + req = req.WithContext(context.WithValue(req.Context(), foundryRequestContextKey{}, "request-context")) + + _, err := middleware(req, func(r *http.Request) (*http.Response, error) { + if got := r.Header.Get("Authorization"); got != "Bearer request-token" { + t.Fatalf("Authorization = %q, want bearer token", got) + } + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil + }) + if err != nil { + t.Fatalf("middleware error = %v", err) + } +} + +func TestNewFoundryModelWithLoggerUnsupportedAuthType(t *testing.T) { + _, err := NewFoundryModelWithLogger(context.Background(), &FoundryConfig{ + Endpoint: "https://example.openai.azure.com/", + Deployment: "gpt-4-1-nano", + AuthType: "Unknown", + }, logr.Discard()) + if err == nil || !strings.Contains(err.Error(), "unsupported Foundry auth type: Unknown") { + t.Fatalf("NewFoundryModelWithLogger() error = %v, want unsupported auth type", err) + } +} + +type foundryRequestContextKey struct{} + +type foundryRequest struct { + apiKey string + authorization string + path string + apiVersion string +} + +type requestContextCredential struct { + t *testing.T +} + +func (c *requestContextCredential) GetToken(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) { + c.t.Helper() + if got := ctx.Value(foundryRequestContextKey{}); got != "request-context" { + c.t.Fatalf("GetToken context marker = %v, want request-context", got) + } + if len(opts.Scopes) != 1 || opts.Scopes[0] != "https://cognitiveservices.azure.com/.default" { + c.t.Fatalf("Scopes = %v, want cognitive services scope", opts.Scopes) + } + return azcore.AccessToken{Token: "request-token"}, nil +} diff --git a/go/api/adk/types.go b/go/api/adk/types.go index 0885f11016..486583ddf5 100644 --- a/go/api/adk/types.go +++ b/go/api/adk/types.go @@ -104,6 +104,7 @@ const ( ModelTypeGemini = "gemini" ModelTypeBedrock = "bedrock" ModelTypeSAPAICore = "sap_ai_core" + ModelTypeFoundry = "foundry" ) func (o *OpenAI) MarshalJSON() ([]byte, error) { @@ -302,6 +303,42 @@ func (s *SAPAICore) GetType() string { return ModelTypeSAPAICore } +// Types for Foundry +type FoundryAuthType string + +const ( + FoundryAuthTypeAPIKey FoundryAuthType = "APIKey" + FoundryAuthTypeWorkloadIdentity FoundryAuthType = "WorkloadIdentity" + FoundryAuthTypeAPIKeyPassthrough FoundryAuthType = "APIKeyPassthrough" +) + +type FoundryAuth struct { + Type FoundryAuthType `json:"type"` +} + +type Foundry struct { + BaseModel + Endpoint string `json:"endpoint"` + Deployment string `json:"deployment"` + APIVersion string `json:"api_version"` + Auth FoundryAuth `json:"auth"` +} + +func (a *Foundry) GetType() string { + return ModelTypeFoundry +} + +func (a *Foundry) MarshalJSON() ([]byte, error) { + type Alias Foundry + return json.Marshal(&struct { + Type string `json:"type"` + *Alias + }{ + Type: ModelTypeFoundry, + Alias: (*Alias)(a), + }) +} + // GenericModel is a catch-all model type used by the Go ADK when the model // type doesn't match any known constant. type GenericModel struct { @@ -370,6 +407,12 @@ func ParseModel(bytes []byte) (Model, error) { return nil, err } return &sapAICore, nil + case ModelTypeFoundry: + var foundry Foundry + if err := json.Unmarshal(bytes, &foundry); err != nil { + return nil, err + } + return &foundry, nil } return nil, fmt.Errorf("unknown model type: %s", model.Type) } @@ -438,6 +481,8 @@ func ModelToEmbeddingConfig(m Model) *EmbeddingConfig { case *SAPAICore: e.Model = v.Model e.BaseUrl = v.BaseUrl + case *Foundry: + e.Model = v.Model 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 4dfdc96bce..9c8d8ae26a 100644 --- a/go/api/config/crd/bases/kagent.dev_modelconfigs.yaml +++ b/go/api/config/crd/bases/kagent.dev_modelconfigs.yaml @@ -532,6 +532,166 @@ spec: additionalProperties: type: string type: object + foundry: + description: Foundry-specific configuration + properties: + apiVersion: + default: "2024-10-21" + description: API version for the Foundry OpenAI-compatible data-plane + API. + type: string + auth: + description: Auth configures Foundry authentication. + properties: + type: + description: Type identifies the Foundry auth mode. + enum: + - APIKey + - WorkloadIdentity + - APIKeyPassthrough + type: string + workloadIdentity: + description: WorkloadIdentity contains Azure Workload Identity + configuration. + properties: + clientId: + description: ClientID is the Azure managed identity client + ID used by Azure Workload Identity. + type: string + clientIdFrom: + description: ClientIDFrom references a source for the + Azure managed identity client ID. + properties: + configMapKeyRef: + description: ConfigMapKeyRef selects a key of a ConfigMap + containing the value. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-validations: + - message: configMapKeyRef is required + rule: has(self.configMapKeyRef) + tenantId: + description: TenantID is the Azure tenant ID. Optional + when supplied by the workload identity webhook. + type: string + tenantIdFrom: + description: TenantIDFrom references a source for the + Azure tenant ID. + properties: + configMapKeyRef: + description: ConfigMapKeyRef selects a key of a ConfigMap + containing the value. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-validations: + - message: configMapKeyRef is required + rule: has(self.configMapKeyRef) + type: object + x-kubernetes-validations: + - message: clientId and clientIdFrom are mutually exclusive + rule: '!(has(self.clientId) && size(self.clientId) > 0 && + has(self.clientIdFrom))' + - message: clientId or clientIdFrom is required + rule: (has(self.clientId) && size(self.clientId) > 0) || + has(self.clientIdFrom) + - message: tenantId and tenantIdFrom are mutually exclusive + rule: '!(has(self.tenantId) && size(self.tenantId) > 0 && + has(self.tenantIdFrom))' + required: + - type + type: object + x-kubernetes-validations: + - message: workloadIdentity is required when auth.type is WorkloadIdentity + rule: self.type != 'WorkloadIdentity' || has(self.workloadIdentity) + - message: workloadIdentity must be nil unless auth.type is WorkloadIdentity + rule: '!(has(self.workloadIdentity) && self.type != ''WorkloadIdentity'')' + deployment: + description: Deployment is the Foundry model deployment name. + type: string + endpoint: + description: Endpoint is the Foundry or AI Services account endpoint. + type: string + endpointFrom: + description: EndpointFrom references a source for the Foundry + endpoint. + properties: + configMapKeyRef: + description: ConfigMapKeyRef selects a key of a ConfigMap + containing the Foundry endpoint. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or its key + must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-validations: + - message: configMapKeyRef is required + rule: has(self.configMapKeyRef) + required: + - auth + - deployment + type: object + x-kubernetes-validations: + - message: endpoint and endpointFrom are mutually exclusive + rule: '!(has(self.endpoint) && size(self.endpoint) > 0 && has(self.endpointFrom))' + - message: endpoint or endpointFrom is required + rule: (has(self.endpoint) && size(self.endpoint) > 0) || has(self.endpointFrom) gemini: description: Gemini-specific configuration type: object @@ -665,6 +825,7 @@ spec: - AnthropicVertexAI - Bedrock - SAPAICore + - Foundry type: string sapAICore: description: SAP AI Core-specific configuration @@ -760,6 +921,8 @@ spec: rule: '!(has(self.bedrock) && self.provider != ''Bedrock'')' - message: provider.sapAICore must be nil if the provider is not SAPAICore 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: 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 @@ -783,6 +946,22 @@ spec: - message: openAI.tokenExchange type GDCHServiceAccount requires openAI.tokenExchange.gdchServiceAccount rule: '!(has(self.openAI) && has(self.openAI.tokenExchange) && self.openAI.tokenExchange.type == ''GDCHServiceAccount'' && !has(self.openAI.tokenExchange.gdchServiceAccount))' + - message: Foundry auth.type APIKey requires apiKeySecret + rule: '!(self.provider == ''Foundry'' && has(self.foundry) && self.foundry.auth.type + == ''APIKey'' && (!has(self.apiKeySecret) || size(self.apiKeySecret) + == 0))' + - message: Foundry auth.type WorkloadIdentity must not set apiKeySecret + rule: '!(self.provider == ''Foundry'' && has(self.foundry) && self.foundry.auth.type + == ''WorkloadIdentity'' && has(self.apiKeySecret) && size(self.apiKeySecret) + > 0)' + - message: Foundry auth.type APIKeyPassthrough requires apiKeyPassthrough=true + rule: '!(self.provider == ''Foundry'' && has(self.foundry) && self.foundry.auth.type + == ''APIKeyPassthrough'' && (!has(self.apiKeyPassthrough) || !self.apiKeyPassthrough))' + - message: apiKeyPassthrough is only valid for Foundry when foundry.auth.type + is APIKeyPassthrough + rule: '!(self.provider == ''Foundry'' && has(self.apiKeyPassthrough) + && self.apiKeyPassthrough && (!has(self.foundry) || self.foundry.auth.type + != ''APIKeyPassthrough''))' status: description: ModelConfigStatus defines the observed state of ModelConfig. properties: diff --git a/go/api/config/crd/bases/kagent.dev_modelproviderconfigs.yaml b/go/api/config/crd/bases/kagent.dev_modelproviderconfigs.yaml index 493e817e9e..4c1d0af392 100644 --- a/go/api/config/crd/bases/kagent.dev_modelproviderconfigs.yaml +++ b/go/api/config/crd/bases/kagent.dev_modelproviderconfigs.yaml @@ -91,6 +91,7 @@ spec: - AnthropicVertexAI - Bedrock - SAPAICore + - Foundry type: string required: - type diff --git a/go/api/v1alpha2/modelconfig_types.go b/go/api/v1alpha2/modelconfig_types.go index 4e0231fbd1..43b16687fc 100644 --- a/go/api/v1alpha2/modelconfig_types.go +++ b/go/api/v1alpha2/modelconfig_types.go @@ -17,6 +17,7 @@ limitations under the License. package v1alpha2 import ( + corev1 "k8s.io/api/core/v1" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -27,7 +28,7 @@ const ( ) // ModelProvider represents the model provider type -// +kubebuilder:validation:Enum=Anthropic;OpenAI;AzureOpenAI;Ollama;Gemini;GeminiVertexAI;AnthropicVertexAI;Bedrock;SAPAICore +// +kubebuilder:validation:Enum=Anthropic;OpenAI;AzureOpenAI;Ollama;Gemini;GeminiVertexAI;AnthropicVertexAI;Bedrock;SAPAICore;Foundry type ModelProvider string const ( @@ -40,6 +41,7 @@ const ( ModelProviderAnthropicVertexAI ModelProvider = "AnthropicVertexAI" ModelProviderBedrock ModelProvider = "Bedrock" ModelProviderSAPAICore ModelProvider = "SAPAICore" + ModelProviderFoundry ModelProvider = "Foundry" ) type BaseVertexAIConfig struct { @@ -310,6 +312,93 @@ type SAPAICoreConfig struct { AuthURL string `json:"authUrl,omitempty"` } +// FoundryAuthType identifies the authentication mode for Foundry. +// +kubebuilder:validation:Enum=APIKey;WorkloadIdentity;APIKeyPassthrough +type FoundryAuthType string + +const ( + FoundryAuthTypeAPIKey FoundryAuthType = "APIKey" + FoundryAuthTypeWorkloadIdentity FoundryAuthType = "WorkloadIdentity" + FoundryAuthTypeAPIKeyPassthrough FoundryAuthType = "APIKeyPassthrough" +) + +// FoundryEndpointSource contains a reference to the Foundry endpoint. +// +kubebuilder:validation:XValidation:message="configMapKeyRef is required",rule="has(self.configMapKeyRef)" +type FoundryEndpointSource struct { + // ConfigMapKeyRef selects a key of a ConfigMap containing the Foundry endpoint. + // +optional + ConfigMapKeyRef *corev1.ConfigMapKeySelector `json:"configMapKeyRef,omitempty"` +} + +// FoundryValueSource contains a reference to a string value used by Foundry config. +// +kubebuilder:validation:XValidation:message="configMapKeyRef is required",rule="has(self.configMapKeyRef)" +type FoundryValueSource struct { + // ConfigMapKeyRef selects a key of a ConfigMap containing the value. + // +optional + ConfigMapKeyRef *corev1.ConfigMapKeySelector `json:"configMapKeyRef,omitempty"` +} + +// FoundryWorkloadIdentityConfig contains Azure Workload Identity configuration. +// +kubebuilder:validation:XValidation:message="clientId and clientIdFrom are mutually exclusive",rule="!(has(self.clientId) && size(self.clientId) > 0 && has(self.clientIdFrom))" +// +kubebuilder:validation:XValidation:message="clientId or clientIdFrom is required",rule="(has(self.clientId) && size(self.clientId) > 0) || has(self.clientIdFrom)" +// +kubebuilder:validation:XValidation:message="tenantId and tenantIdFrom are mutually exclusive",rule="!(has(self.tenantId) && size(self.tenantId) > 0 && has(self.tenantIdFrom))" +type FoundryWorkloadIdentityConfig struct { + // ClientID is the Azure managed identity client ID used by Azure Workload Identity. + // +optional + ClientID string `json:"clientId,omitempty"` + + // ClientIDFrom references a source for the Azure managed identity client ID. + // +optional + ClientIDFrom *FoundryValueSource `json:"clientIdFrom,omitempty"` + + // TenantID is the Azure tenant ID. Optional when supplied by the workload identity webhook. + // +optional + TenantID string `json:"tenantId,omitempty"` + + // TenantIDFrom references a source for the Azure tenant ID. + // +optional + TenantIDFrom *FoundryValueSource `json:"tenantIdFrom,omitempty"` +} + +// FoundryAuthConfig configures authentication for Foundry. +// +kubebuilder:validation:XValidation:message="workloadIdentity is required when auth.type is WorkloadIdentity",rule="self.type != 'WorkloadIdentity' || has(self.workloadIdentity)" +// +kubebuilder:validation:XValidation:message="workloadIdentity must be nil unless auth.type is WorkloadIdentity",rule="!(has(self.workloadIdentity) && self.type != 'WorkloadIdentity')" +type FoundryAuthConfig struct { + // Type identifies the Foundry auth mode. + // +required + Type FoundryAuthType `json:"type"` + + // WorkloadIdentity contains Azure Workload Identity configuration. + // +optional + WorkloadIdentity *FoundryWorkloadIdentityConfig `json:"workloadIdentity,omitempty"` +} + +// FoundryConfig contains Foundry-specific configuration options. +// +kubebuilder:validation:XValidation:message="endpoint and endpointFrom are mutually exclusive",rule="!(has(self.endpoint) && size(self.endpoint) > 0 && has(self.endpointFrom))" +// +kubebuilder:validation:XValidation:message="endpoint or endpointFrom is required",rule="(has(self.endpoint) && size(self.endpoint) > 0) || has(self.endpointFrom)" +type FoundryConfig struct { + // Endpoint is the Foundry or AI Services account endpoint. + // +optional + Endpoint string `json:"endpoint,omitempty"` + + // EndpointFrom references a source for the Foundry endpoint. + // +optional + EndpointFrom *FoundryEndpointSource `json:"endpointFrom,omitempty"` + + // Deployment is the Foundry model deployment name. + // +required + Deployment string `json:"deployment"` + + // API version for the Foundry OpenAI-compatible data-plane API. + // +kubebuilder:default="2024-10-21" + // +optional + APIVersion string `json:"apiVersion,omitempty"` + + // Auth configures Foundry authentication. + // +required + Auth FoundryAuthConfig `json:"auth"` +} + // TLSConfig contains TLS/SSL configuration options for outbound HTTPS // connections from the agent (model provider, RemoteMCPServer). The // XValidation rules below apply at admission to every CRD field that @@ -377,6 +466,7 @@ func (t *TLSConfig) IsEmpty() bool { // +kubebuilder:validation:XValidation:message="provider.anthropicVertexAI must be nil if the provider is not AnthropicVertexAI",rule="!(has(self.anthropicVertexAI) && self.provider != 'AnthropicVertexAI')" // +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="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)" @@ -384,6 +474,10 @@ func (t *TLSConfig) IsEmpty() bool { // +kubebuilder:validation:XValidation:message="openAI.tokenExchange requires apiKeySecret (the service account secret)",rule="!(has(self.openAI) && has(self.openAI.tokenExchange) && (!has(self.apiKeySecret) || size(self.apiKeySecret) == 0))" // +kubebuilder:validation:XValidation:message="openAI.tokenExchange and apiKeyPassthrough are mutually exclusive",rule="!(has(self.openAI) && has(self.openAI.tokenExchange) && has(self.apiKeyPassthrough) && self.apiKeyPassthrough)" // +kubebuilder:validation:XValidation:message="openAI.tokenExchange type GDCHServiceAccount requires openAI.tokenExchange.gdchServiceAccount",rule="!(has(self.openAI) && has(self.openAI.tokenExchange) && self.openAI.tokenExchange.type == 'GDCHServiceAccount' && !has(self.openAI.tokenExchange.gdchServiceAccount))" +// +kubebuilder:validation:XValidation:message="Foundry auth.type APIKey requires apiKeySecret",rule="!(self.provider == 'Foundry' && has(self.foundry) && self.foundry.auth.type == 'APIKey' && (!has(self.apiKeySecret) || size(self.apiKeySecret) == 0))" +// +kubebuilder:validation:XValidation:message="Foundry auth.type WorkloadIdentity must not set apiKeySecret",rule="!(self.provider == 'Foundry' && has(self.foundry) && self.foundry.auth.type == 'WorkloadIdentity' && has(self.apiKeySecret) && size(self.apiKeySecret) > 0)" +// +kubebuilder:validation:XValidation:message="Foundry auth.type APIKeyPassthrough requires apiKeyPassthrough=true",rule="!(self.provider == 'Foundry' && has(self.foundry) && self.foundry.auth.type == 'APIKeyPassthrough' && (!has(self.apiKeyPassthrough) || !self.apiKeyPassthrough))" +// +kubebuilder:validation:XValidation:message="apiKeyPassthrough is only valid for Foundry when foundry.auth.type is APIKeyPassthrough",rule="!(self.provider == 'Foundry' && has(self.apiKeyPassthrough) && self.apiKeyPassthrough && (!has(self.foundry) || self.foundry.auth.type != 'APIKeyPassthrough'))" type ModelConfigSpec struct { // +required Model string `json:"model"` @@ -450,6 +544,10 @@ type ModelConfigSpec struct { // +optional SAPAICore *SAPAICoreConfig `json:"sapAICore,omitempty"` + // Foundry-specific configuration + // +optional + Foundry *FoundryConfig `json:"foundry,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/zz_generated.deepcopy.go b/go/api/v1alpha2/zz_generated.deepcopy.go index 2c03020b70..3dd1799b52 100644 --- a/go/api/v1alpha2/zz_generated.deepcopy.go +++ b/go/api/v1alpha2/zz_generated.deepcopy.go @@ -902,6 +902,112 @@ func (in *DeclarativeDeploymentSpec) DeepCopy() *DeclarativeDeploymentSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FoundryAuthConfig) DeepCopyInto(out *FoundryAuthConfig) { + *out = *in + if in.WorkloadIdentity != nil { + in, out := &in.WorkloadIdentity, &out.WorkloadIdentity + *out = new(FoundryWorkloadIdentityConfig) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FoundryAuthConfig. +func (in *FoundryAuthConfig) DeepCopy() *FoundryAuthConfig { + if in == nil { + return nil + } + out := new(FoundryAuthConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FoundryConfig) DeepCopyInto(out *FoundryConfig) { + *out = *in + if in.EndpointFrom != nil { + in, out := &in.EndpointFrom, &out.EndpointFrom + *out = new(FoundryEndpointSource) + (*in).DeepCopyInto(*out) + } + in.Auth.DeepCopyInto(&out.Auth) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FoundryConfig. +func (in *FoundryConfig) DeepCopy() *FoundryConfig { + if in == nil { + return nil + } + out := new(FoundryConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FoundryEndpointSource) DeepCopyInto(out *FoundryEndpointSource) { + *out = *in + if in.ConfigMapKeyRef != nil { + in, out := &in.ConfigMapKeyRef, &out.ConfigMapKeyRef + *out = new(v1.ConfigMapKeySelector) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FoundryEndpointSource. +func (in *FoundryEndpointSource) DeepCopy() *FoundryEndpointSource { + if in == nil { + return nil + } + out := new(FoundryEndpointSource) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FoundryValueSource) DeepCopyInto(out *FoundryValueSource) { + *out = *in + if in.ConfigMapKeyRef != nil { + in, out := &in.ConfigMapKeyRef, &out.ConfigMapKeyRef + *out = new(v1.ConfigMapKeySelector) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FoundryValueSource. +func (in *FoundryValueSource) DeepCopy() *FoundryValueSource { + if in == nil { + return nil + } + out := new(FoundryValueSource) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FoundryWorkloadIdentityConfig) DeepCopyInto(out *FoundryWorkloadIdentityConfig) { + *out = *in + if in.ClientIDFrom != nil { + in, out := &in.ClientIDFrom, &out.ClientIDFrom + *out = new(FoundryValueSource) + (*in).DeepCopyInto(*out) + } + if in.TenantIDFrom != nil { + in, out := &in.TenantIDFrom, &out.TenantIDFrom + *out = new(FoundryValueSource) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FoundryWorkloadIdentityConfig. +func (in *FoundryWorkloadIdentityConfig) DeepCopy() *FoundryWorkloadIdentityConfig { + if in == nil { + return nil + } + out := new(FoundryWorkloadIdentityConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GDCHServiceAccountConfig) DeepCopyInto(out *GDCHServiceAccountConfig) { *out = *in @@ -1138,6 +1244,11 @@ func (in *ModelConfigSpec) DeepCopyInto(out *ModelConfigSpec) { *out = new(SAPAICoreConfig) **out = **in } + if in.Foundry != nil { + in, out := &in.Foundry, &out.Foundry + *out = new(FoundryConfig) + (*in).DeepCopyInto(*out) + } if in.TLS != nil { in, out := &in.TLS, &out.TLS *out = new(TLSConfig) 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 4b09d30a22..808b55e820 100644 --- a/go/core/internal/controller/translator/agent/adk_api_translator.go +++ b/go/core/internal/controller/translator/agent/adk_api_translator.go @@ -39,10 +39,13 @@ import ( ) const ( - MCPServiceLabel = "kagent.dev/mcp-service" - MCPServicePathAnnotation = "kagent.dev/mcp-service-path" - MCPServicePortAnnotation = "kagent.dev/mcp-service-port" - MCPServiceProtocolAnnotation = "kagent.dev/mcp-service-protocol" + MCPServiceLabel = "kagent.dev/mcp-service" + MCPServicePathAnnotation = "kagent.dev/mcp-service-path" + MCPServicePortAnnotation = "kagent.dev/mcp-service-port" + MCPServiceProtocolAnnotation = "kagent.dev/mcp-service-protocol" + azureWorkloadIdentityUseLabel = "azure.workload.identity/use" + azureWorkloadIdentityClientIDAnnotation = "azure.workload.identity/client-id" + azureWorkloadIdentityTenantIDAnnotation = "azure.workload.identity/tenant-id" MCPServicePathDefault = "/mcp" MCPServiceProtocolDefault = v1alpha2.RemoteMCPServerProtocolStreamableHttp @@ -413,25 +416,91 @@ func addTokenExchangeConfiguration(openai *adk.OpenAI, mdd *modelDeploymentData, } } +func (a *adkApiTranslator) resolveFoundryValueSource(ctx context.Context, namespace string, source *v1alpha2.FoundryValueSource) (string, error) { + if source == nil || source.ConfigMapKeyRef == nil { + return "", nil + } + cm := &corev1.ConfigMap{} + if err := a.kube.Get(ctx, types.NamespacedName{Namespace: namespace, Name: source.ConfigMapKeyRef.Name}, cm); err != nil { + return "", fmt.Errorf("failed to get Foundry config map %s: %w", source.ConfigMapKeyRef.Name, err) + } + value, ok := cm.Data[source.ConfigMapKeyRef.Key] + if !ok { + if source.ConfigMapKeyRef.Optional != nil && *source.ConfigMapKeyRef.Optional { + return "", nil + } + return "", fmt.Errorf("Foundry config map %s does not contain key %q", source.ConfigMapKeyRef.Name, source.ConfigMapKeyRef.Key) + } + return value, nil +} + +func (a *adkApiTranslator) resolveFoundryEndpoint(ctx context.Context, namespace string, cfg *v1alpha2.FoundryConfig) (string, error) { + if cfg.Endpoint != "" { + return cfg.Endpoint, nil + } + if cfg.EndpointFrom == nil || cfg.EndpointFrom.ConfigMapKeyRef == nil { + return "", nil + } + return a.resolveFoundryValueSource(ctx, namespace, &v1alpha2.FoundryValueSource{ConfigMapKeyRef: cfg.EndpointFrom.ConfigMapKeyRef}) +} + +func (a *adkApiTranslator) addFoundryWorkloadIdentityConfiguration(ctx context.Context, namespace string, mrr *modelRuntimeRequirements, auth *v1alpha2.FoundryAuthConfig) error { + if auth == nil || auth.Type != v1alpha2.FoundryAuthTypeWorkloadIdentity || auth.WorkloadIdentity == nil { + return nil + } + wi := auth.WorkloadIdentity + clientID := wi.ClientID + if clientID == "" { + var err error + clientID, err = a.resolveFoundryValueSource(ctx, namespace, wi.ClientIDFrom) + if err != nil { + return err + } + } + if clientID == "" { + return fmt.Errorf("Foundry workload identity clientId is required") + } + tenantID := wi.TenantID + if tenantID == "" { + var err error + tenantID, err = a.resolveFoundryValueSource(ctx, namespace, wi.TenantIDFrom) + if err != nil { + return err + } + } + if mrr.PodLabels == nil { + mrr.PodLabels = map[string]string{} + } + mrr.PodLabels[azureWorkloadIdentityUseLabel] = "true" + if mrr.ServiceAccountAnnotations == nil { + mrr.ServiceAccountAnnotations = map[string]string{} + } + mrr.ServiceAccountAnnotations[azureWorkloadIdentityClientIDAnnotation] = clientID + if tenantID != "" { + mrr.ServiceAccountAnnotations[azureWorkloadIdentityTenantIDAnnotation] = tenantID + } + return nil +} + // translateEmbeddingConfig resolves the embedding ModelConfig and returns the -// EmbeddingConfig for the Python config JSON, the deployment data for the -// embedding model, and the raw secret hash bytes (caller decides whether to -// include them). The caller should use mergeDeploymentData to combine the -// returned deployment data with the existing deployment data. -func (a *adkApiTranslator) translateEmbeddingConfig(ctx context.Context, namespace, modelConfigName string) (*adk.EmbeddingConfig, *modelDeploymentData, []byte, error) { - embModel, embMdd, embHash, err := a.translateModel(ctx, namespace, modelConfigName) +// EmbeddingConfig for the Python config JSON, the deployment data and runtime +// requirements for the embedding model, and the raw secret hash bytes (caller +// decides whether to include them). The caller should merge the returned data +// with the existing model data. +func (a *adkApiTranslator) translateEmbeddingConfig(ctx context.Context, namespace, modelConfigName string) (*adk.EmbeddingConfig, *modelDeploymentData, *modelRuntimeRequirements, []byte, error) { + embModel, embMdd, embMrr, embHash, err := a.translateModel(ctx, namespace, modelConfigName) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } - return adk.ModelToEmbeddingConfig(embModel), embMdd, embHash, nil + return adk.ModelToEmbeddingConfig(embModel), embMdd, embMrr, embHash, nil } -func (a *adkApiTranslator) translateModel(ctx context.Context, namespace, modelConfig string) (adk.Model, *modelDeploymentData, []byte, error) { +func (a *adkApiTranslator) translateModel(ctx context.Context, namespace, modelConfig string) (adk.Model, *modelDeploymentData, *modelRuntimeRequirements, []byte, error) { model := &v1alpha2.ModelConfig{} err := a.kube.Get(ctx, types.NamespacedName{Namespace: namespace, Name: modelConfig}, model) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } // Decode hex-encoded secret hash to bytes @@ -439,12 +508,13 @@ func (a *adkApiTranslator) translateModel(ctx context.Context, namespace, modelC if model.Status.SecretHash != "" { decoded, err := hex.DecodeString(model.Status.SecretHash) if err != nil { - return nil, nil, nil, fmt.Errorf("failed to decode secret hash: %w", err) + return nil, nil, nil, nil, fmt.Errorf("failed to decode secret hash: %w", err) } secretHashBytes = decoded } modelDeploymentData := &modelDeploymentData{} + modelRuntimeRequirements := &modelRuntimeRequirements{} // Add TLS configuration if present addTLSConfiguration(modelDeploymentData, model.Spec.TLS) @@ -508,7 +578,7 @@ func (a *adkApiTranslator) translateModel(ctx context.Context, namespace, modelC }) } } - return openai, modelDeploymentData, secretHashBytes, nil + return openai, modelDeploymentData, modelRuntimeRequirements, secretHashBytes, nil case v1alpha2.ModelProviderAnthropic: if !model.Spec.APIKeyPassthrough && model.Spec.APIKeySecret != "" { modelDeploymentData.EnvVars = append(modelDeploymentData.EnvVars, corev1.EnvVar{ @@ -545,10 +615,10 @@ func (a *adkApiTranslator) translateModel(ctx context.Context, namespace, modelC anthropic.TopK = &spec.TopK } } - return anthropic, modelDeploymentData, secretHashBytes, nil + return anthropic, modelDeploymentData, modelRuntimeRequirements, secretHashBytes, nil case v1alpha2.ModelProviderAzureOpenAI: if model.Spec.AzureOpenAI == nil { - return nil, nil, nil, fmt.Errorf("AzureOpenAI model config is required") + return nil, nil, nil, nil, fmt.Errorf("AzureOpenAI model config is required") } if !model.Spec.APIKeyPassthrough { modelDeploymentData.EnvVars = append(modelDeploymentData.EnvVars, corev1.EnvVar{ @@ -594,10 +664,10 @@ func (a *adkApiTranslator) translateModel(ctx context.Context, namespace, modelC populateTLSFields(&azureOpenAI.BaseModel, model.Spec.TLS) azureOpenAI.APIKeyPassthrough = model.Spec.APIKeyPassthrough - return azureOpenAI, modelDeploymentData, secretHashBytes, nil + return azureOpenAI, modelDeploymentData, modelRuntimeRequirements, secretHashBytes, nil case v1alpha2.ModelProviderGeminiVertexAI: if model.Spec.GeminiVertexAI == nil { - return nil, nil, nil, fmt.Errorf("GeminiVertexAI model config is required") + return nil, nil, nil, nil, fmt.Errorf("GeminiVertexAI model config is required") } modelDeploymentData.EnvVars = append(modelDeploymentData.EnvVars, corev1.EnvVar{ Name: env.GoogleCloudProject.Name(), @@ -639,10 +709,10 @@ func (a *adkApiTranslator) translateModel(ctx context.Context, namespace, modelC populateTLSFields(&gemini.BaseModel, model.Spec.TLS) gemini.APIKeyPassthrough = model.Spec.APIKeyPassthrough - return gemini, modelDeploymentData, secretHashBytes, nil + return gemini, modelDeploymentData, modelRuntimeRequirements, secretHashBytes, nil case v1alpha2.ModelProviderAnthropicVertexAI: if model.Spec.AnthropicVertexAI == nil { - return nil, nil, nil, fmt.Errorf("AnthropicVertexAI model config is required") + return nil, nil, nil, nil, fmt.Errorf("AnthropicVertexAI model config is required") } modelDeploymentData.EnvVars = append(modelDeploymentData.EnvVars, corev1.EnvVar{ Name: env.GoogleCloudProject.Name(), @@ -680,10 +750,10 @@ func (a *adkApiTranslator) translateModel(ctx context.Context, namespace, modelC populateTLSFields(&anthropic.BaseModel, model.Spec.TLS) anthropic.APIKeyPassthrough = model.Spec.APIKeyPassthrough - return anthropic, modelDeploymentData, secretHashBytes, nil + return anthropic, modelDeploymentData, modelRuntimeRequirements, secretHashBytes, nil case v1alpha2.ModelProviderOllama: if model.Spec.Ollama == nil { - return nil, nil, nil, fmt.Errorf("ollama model config is required") + return nil, nil, nil, nil, fmt.Errorf("ollama model config is required") } host := model.Spec.Ollama.Host if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") { @@ -704,7 +774,7 @@ func (a *adkApiTranslator) translateModel(ctx context.Context, namespace, modelC populateTLSFields(&ollama.BaseModel, model.Spec.TLS) ollama.APIKeyPassthrough = model.Spec.APIKeyPassthrough - return ollama, modelDeploymentData, secretHashBytes, nil + return ollama, modelDeploymentData, modelRuntimeRequirements, secretHashBytes, nil case v1alpha2.ModelProviderGemini: modelDeploymentData.EnvVars = append(modelDeploymentData.EnvVars, corev1.EnvVar{ Name: env.GoogleAPIKey.Name(), @@ -725,10 +795,10 @@ func (a *adkApiTranslator) translateModel(ctx context.Context, namespace, modelC } // Populate TLS fields in BaseModel populateTLSFields(&gemini.BaseModel, model.Spec.TLS) - return gemini, modelDeploymentData, secretHashBytes, nil + return gemini, modelDeploymentData, modelRuntimeRequirements, secretHashBytes, nil case v1alpha2.ModelProviderBedrock: if model.Spec.Bedrock == nil { - return nil, nil, nil, fmt.Errorf("bedrock model config is required") + return nil, nil, nil, nil, fmt.Errorf("bedrock model config is required") } // Set AWS region (always required) @@ -742,7 +812,7 @@ func (a *adkApiTranslator) translateModel(ctx context.Context, namespace, modelC if !model.Spec.APIKeyPassthrough && model.Spec.APIKeySecret != "" { secret := &corev1.Secret{} if err := a.kube.Get(ctx, types.NamespacedName{Namespace: namespace, Name: model.Spec.APIKeySecret}, secret); err != nil { - return nil, nil, nil, fmt.Errorf("failed to get Bedrock credentials secret: %w", err) + return nil, nil, nil, nil, fmt.Errorf("failed to get Bedrock credentials secret: %w", err) } if _, hasBearerToken := secret.Data[env.AWSBearerTokenBedrock.Name()]; hasBearerToken { @@ -799,7 +869,7 @@ func (a *adkApiTranslator) translateModel(ctx context.Context, namespace, modelC var additionalFields map[string]any if model.Spec.Bedrock.AdditionalModelRequestFields != nil { if err := json.Unmarshal(model.Spec.Bedrock.AdditionalModelRequestFields.Raw, &additionalFields); err != nil { - return nil, nil, nil, fmt.Errorf("failed to unmarshal bedrock additionalModelRequestFields: %w", err) + return nil, nil, nil, nil, fmt.Errorf("failed to unmarshal bedrock additionalModelRequestFields: %w", err) } } bedrock := &adk.Bedrock{ @@ -817,16 +887,16 @@ func (a *adkApiTranslator) translateModel(ctx context.Context, namespace, modelC populateTLSFields(&bedrock.BaseModel, model.Spec.TLS) bedrock.APIKeyPassthrough = model.Spec.APIKeyPassthrough - return bedrock, modelDeploymentData, secretHashBytes, nil + return bedrock, modelDeploymentData, modelRuntimeRequirements, secretHashBytes, nil case v1alpha2.ModelProviderSAPAICore: if model.Spec.SAPAICore == nil { - return nil, nil, nil, fmt.Errorf("sapAICore model config is required") + return nil, nil, nil, nil, fmt.Errorf("sapAICore model config is required") } if !model.Spec.APIKeyPassthrough && model.Spec.APIKeySecret != "" { secret := &corev1.Secret{} if err := a.kube.Get(ctx, types.NamespacedName{Namespace: namespace, Name: model.Spec.APIKeySecret}, secret); err != nil { - return nil, nil, nil, fmt.Errorf("failed to get SAP AI Core credentials secret: %w", err) + return nil, nil, nil, nil, fmt.Errorf("failed to get SAP AI Core credentials secret: %w", err) } modelDeploymentData.EnvVars = append(modelDeploymentData.EnvVars, corev1.EnvVar{ @@ -866,9 +936,70 @@ func (a *adkApiTranslator) translateModel(ctx context.Context, namespace, modelC populateTLSFields(&sapAICore.BaseModel, model.Spec.TLS) sapAICore.APIKeyPassthrough = model.Spec.APIKeyPassthrough - return sapAICore, modelDeploymentData, secretHashBytes, nil + return sapAICore, modelDeploymentData, modelRuntimeRequirements, secretHashBytes, nil + case v1alpha2.ModelProviderFoundry: + if model.Spec.Foundry == nil { + return nil, nil, nil, nil, fmt.Errorf("Foundry model config is required") + } + cfg := model.Spec.Foundry + if cfg.Auth.Type == v1alpha2.FoundryAuthTypeAPIKey && model.Spec.APIKeySecret != "" { + modelDeploymentData.EnvVars = append(modelDeploymentData.EnvVars, corev1.EnvVar{ + Name: env.FoundryAPIKey.Name(), + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: model.Spec.APIKeySecret, + }, + Key: model.Spec.APIKeySecretKey, + }, + }, + }) + } + endpoint, err := a.resolveFoundryEndpoint(ctx, namespace, cfg) + if err != nil { + return nil, nil, nil, nil, err + } + if endpoint != "" { + modelDeploymentData.EnvVars = append(modelDeploymentData.EnvVars, corev1.EnvVar{ + Name: env.FoundryEndpoint.Name(), + Value: endpoint, + }) + } + if cfg.Deployment != "" { + modelDeploymentData.EnvVars = append(modelDeploymentData.EnvVars, corev1.EnvVar{ + Name: env.FoundryDeployment.Name(), + Value: cfg.Deployment, + }) + } + apiVersion := cfg.APIVersion + if apiVersion == "" { + apiVersion = env.FoundryAPIVersion.DefaultValue() + } + modelDeploymentData.EnvVars = append(modelDeploymentData.EnvVars, corev1.EnvVar{ + Name: env.FoundryAPIVersion.Name(), + Value: apiVersion, + }) + if cfg.Auth.Type == v1alpha2.FoundryAuthTypeWorkloadIdentity { + if err := a.addFoundryWorkloadIdentityConfiguration(ctx, namespace, modelRuntimeRequirements, &cfg.Auth); err != nil { + return nil, nil, nil, nil, err + } + } + foundry := &adk.Foundry{ + BaseModel: adk.BaseModel{ + Model: model.Spec.Model, + Headers: model.Spec.DefaultHeaders, + }, + Endpoint: endpoint, + Deployment: cfg.Deployment, + APIVersion: apiVersion, + Auth: adk.FoundryAuth{Type: adk.FoundryAuthType(cfg.Auth.Type)}, + } + populateTLSFields(&foundry.BaseModel, model.Spec.TLS) + foundry.APIKeyPassthrough = model.Spec.APIKeyPassthrough + + return foundry, modelDeploymentData, modelRuntimeRequirements, secretHashBytes, nil default: - return nil, nil, nil, fmt.Errorf("unsupported model provider: %s", model.Spec.Provider) + return nil, nil, nil, nil, fmt.Errorf("unsupported model provider: %s", model.Spec.Provider) } } @@ -1214,6 +1345,22 @@ func mergeDeploymentData(dst, src *modelDeploymentData) { } } +func mergeRuntimeRequirements(dst, src *modelRuntimeRequirements) error { + if dst == nil || src == nil { + return nil + } + if dst.PodLabels == nil && len(src.PodLabels) > 0 { + dst.PodLabels = map[string]string{} + } + if err := mergeStringMapNoConflict(dst.PodLabels, src.PodLabels, "pod label"); err != nil { + return err + } + if dst.ServiceAccountAnnotations == nil && len(src.ServiceAccountAnnotations) > 0 { + dst.ServiceAccountAnnotations = map[string]string{} + } + return mergeStringMapNoConflict(dst.ServiceAccountAnnotations, src.ServiceAccountAnnotations, "service account annotation") +} + func collectOtelEnvFromProcess() []corev1.EnvVar { var envVars []corev1.EnvVar for _, envVar := range os.Environ() { diff --git a/go/core/internal/controller/translator/agent/compiler.go b/go/core/internal/controller/translator/agent/compiler.go index 2e51e426b4..2c435acfd9 100644 --- a/go/core/internal/controller/translator/agent/compiler.go +++ b/go/core/internal/controller/translator/agent/compiler.go @@ -127,11 +127,12 @@ func (a *adkApiTranslator) CompileAgent( switch spec.Type { case v1alpha2.AgentType_Declarative: var mdd *modelDeploymentData - cfg, mdd, secretHashBytes, err = a.translateInlineAgent(ctx, agent) + var mrr *modelRuntimeRequirements + cfg, mdd, mrr, secretHashBytes, err = a.translateInlineAgent(ctx, agent) if err != nil { return nil, err } - dep, err = resolveInlineDeployment(agent, mdd) + dep, err = resolveInlineDeployment(agent, mdd, mrr) if err != nil { return nil, err } @@ -210,17 +211,20 @@ func (a *adkApiTranslator) validateAgent(ctx context.Context, agent v1alpha2.Age return nil } -func (a *adkApiTranslator) translateInlineAgent(ctx context.Context, agent v1alpha2.AgentObject) (*adk.AgentConfig, *modelDeploymentData, []byte, error) { +func (a *adkApiTranslator) translateInlineAgent(ctx context.Context, agent v1alpha2.AgentObject) (*adk.AgentConfig, *modelDeploymentData, *modelRuntimeRequirements, []byte, error) { spec := agent.GetAgentSpec() - model, mdd, secretHashBytes, err := a.translateModel(ctx, agent.GetNamespace(), spec.Declarative.ModelConfig) + model, mdd, mrr, secretHashBytes, err := a.translateModel(ctx, agent.GetNamespace(), spec.Declarative.ModelConfig) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err + } + if err := requireFoundryGoRuntime(agent, model.GetType()); err != nil { + return nil, nil, nil, nil, err } // Resolve the raw system message (template processing happens after tools are translated). rawSystemMessage, err := a.resolveRawSystemMessage(ctx, agent) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } cfg := &adk.AgentConfig{ @@ -263,12 +267,18 @@ func (a *adkApiTranslator) translateInlineAgent(ctx context.Context, agent v1alp if summarizerModelName == "" || summarizerModelName == spec.Declarative.ModelConfig { compCfg.SummarizerModel = model } else { - summarizerModel, summarizerMdd, summarizerSecretHash, err := a.translateModel(ctx, agent.GetNamespace(), summarizerModelName) + summarizerModel, summarizerMdd, summarizerMrr, summarizerSecretHash, err := a.translateModel(ctx, agent.GetNamespace(), summarizerModelName) if err != nil { - return nil, nil, nil, fmt.Errorf("failed to translate summarizer model config %q: %w", summarizerModelName, err) + return nil, nil, nil, nil, fmt.Errorf("failed to translate summarizer model config %q: %w", summarizerModelName, err) + } + if err := requireFoundryGoRuntime(agent, summarizerModel.GetType()); err != nil { + return nil, nil, nil, nil, err } compCfg.SummarizerModel = summarizerModel mergeDeploymentData(mdd, summarizerMdd) + if err := mergeRuntimeRequirements(mrr, summarizerMrr); err != nil { + return nil, nil, nil, nil, err + } if len(summarizerSecretHash) > 0 { secretHashBytes = append(secretHashBytes, summarizerSecretHash...) } @@ -283,9 +293,12 @@ func (a *adkApiTranslator) translateInlineAgent(ctx context.Context, agent v1alp // Handle Memory Configuration: presence of Memory field enables it. if spec.Declarative.Memory != nil { - embCfg, embMdd, embHash, err := a.translateEmbeddingConfig(ctx, agent.GetNamespace(), spec.Declarative.Memory.ModelConfig) + embCfg, embMdd, embMrr, embHash, err := a.translateEmbeddingConfig(ctx, agent.GetNamespace(), spec.Declarative.Memory.ModelConfig) if err != nil { - return nil, nil, nil, fmt.Errorf("failed to resolve embedding config: %w", err) + return nil, nil, nil, nil, fmt.Errorf("failed to resolve embedding config: %w", err) + } + if err := requireFoundryGoRuntime(agent, embCfg.Provider); err != nil { + return nil, nil, nil, nil, err } cfg.Memory = &adk.MemoryConfig{ @@ -294,6 +307,9 @@ func (a *adkApiTranslator) translateInlineAgent(ctx context.Context, agent v1alp } mergeDeploymentData(mdd, embMdd) + if err := mergeRuntimeRequirements(mrr, embMrr); err != nil { + return nil, nil, nil, nil, err + } if spec.Declarative.Memory.ModelConfig != spec.Declarative.ModelConfig { secretHashBytes = append(secretHashBytes, embHash...) } @@ -302,14 +318,14 @@ func (a *adkApiTranslator) translateInlineAgent(ctx context.Context, agent v1alp for _, tool := range spec.Declarative.Tools { headers, err := tool.ResolveHeaders(ctx, a.kube, agent.GetNamespace()) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } switch { case tool.McpServer != nil: toolHashBytes, err := a.translateMCPServerTarget(ctx, cfg, mdd, agent.GetNamespace(), tool.McpServer, headers, a.globalProxyURL) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } // Fold the RemoteMCPServer's TLS-Secret hash into the agent // config hash so an in-place rotation of the RMS CA Secret @@ -323,11 +339,11 @@ func (a *adkApiTranslator) translateInlineAgent(ctx context.Context, agent v1alp case tool.Agent != nil: toolAgent, err := a.getToolAgent(ctx, tool.Agent, agent.GetNamespace()) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } if agentStateKey(toolAgent) == agentStateKey(agent) { - return nil, nil, nil, fmt.Errorf("agent tool cannot be used to reference itself, %s", utils.GetObjectRef(toolAgent)) + return nil, nil, nil, nil, fmt.Errorf("agent tool cannot be used to reference itself, %s", utils.GetObjectRef(toolAgent)) } toolSpec := toolAgent.GetAgentSpec() @@ -339,7 +355,7 @@ func (a *adkApiTranslator) translateInlineAgent(ctx context.Context, agent v1alp if a.globalProxyURL != "" { targetURL, headers, err = applyProxyURL(originalURL, a.globalProxyURL, headers) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } } @@ -350,30 +366,37 @@ func (a *adkApiTranslator) translateInlineAgent(ctx context.Context, agent v1alp Description: toolSpec.Description, }) default: - return nil, nil, nil, fmt.Errorf("unknown agent type: %s", toolSpec.Type) + return nil, nil, nil, nil, fmt.Errorf("unknown agent type: %s", toolSpec.Type) } default: - return nil, nil, nil, fmt.Errorf("tool must have a provider or tool server") + return nil, nil, nil, nil, fmt.Errorf("tool must have a provider or tool server") } } if spec.Declarative.PromptTemplate != nil && len(spec.Declarative.PromptTemplate.DataSources) > 0 { lookup, err := resolvePromptSources(ctx, a.kube, agent.GetNamespace(), spec.Declarative.PromptTemplate.DataSources) if err != nil { - return nil, nil, nil, fmt.Errorf("failed to resolve prompt sources: %w", err) + return nil, nil, nil, nil, fmt.Errorf("failed to resolve prompt sources: %w", err) } tplCtx := buildTemplateContext(agent, cfg) resolved, err := executeSystemMessageTemplate(cfg.Instruction, lookup, tplCtx) if err != nil { - return nil, nil, nil, fmt.Errorf("failed to execute system message template: %w", err) + return nil, nil, nil, nil, fmt.Errorf("failed to execute system message template: %w", err) } cfg.Instruction = resolved } - return cfg, mdd, secretHashBytes, nil + return cfg, mdd, mrr, secretHashBytes, nil +} + +func requireFoundryGoRuntime(agent v1alpha2.AgentObject, modelType string) error { + if modelType == adk.ModelTypeFoundry && v1alpha2.EffectiveDeclarativeRuntimeForAgent(agent) != v1alpha2.DeclarativeRuntime_Go { + return fmt.Errorf("Foundry model provider requires declarative runtime %q", v1alpha2.DeclarativeRuntime_Go) + } + return nil } // resolveRawSystemMessage gets the raw system message string from the agent spec diff --git a/go/core/internal/controller/translator/agent/deployments.go b/go/core/internal/controller/translator/agent/deployments.go index 2a55255697..1af90ef7cb 100644 --- a/go/core/internal/controller/translator/agent/deployments.go +++ b/go/core/internal/controller/translator/agent/deployments.go @@ -21,6 +21,12 @@ type modelDeploymentData struct { VolumeMounts []corev1.VolumeMount } +// Internal to translator - pod-level runtime requirements inferred from model configuration. +type modelRuntimeRequirements struct { + PodLabels map[string]string + ServiceAccountAnnotations map[string]string +} + // Internal to translator – a unified deployment spec for any agent. type resolvedDeployment struct { // Required concrete runtime properties @@ -150,7 +156,10 @@ func resolveGoRuntimeImage(registry string, full bool) (string, error) { ) } -func resolveInlineDeployment(agent v1alpha2.AgentObject, mdd *modelDeploymentData) (*resolvedDeployment, error) { +func resolveInlineDeployment(agent v1alpha2.AgentObject, mdd *modelDeploymentData, mrr *modelRuntimeRequirements) (*resolvedDeployment, error) { + if mrr == nil { + mrr = &modelRuntimeRequirements{} + } specRef := agent.GetAgentSpec() // Defaults port := int32(8080) @@ -215,6 +224,25 @@ func resolveInlineDeployment(agent v1alpha2.AgentObject, mdd *modelDeploymentDat return nil, err } + labels := getDefaultLabels(agent.GetName(), spec.Labels) + if err := mergeStringMapNoConflict(labels, mrr.PodLabels, "pod label"); err != nil { + return nil, err + } + serviceAccountConfig := spec.ServiceAccountConfig + if len(mrr.ServiceAccountAnnotations) > 0 { + if serviceAccountConfig == nil { + serviceAccountConfig = &v1alpha2.ServiceAccountConfig{} + } else { + serviceAccountConfig = serviceAccountConfig.DeepCopy() + } + if serviceAccountConfig.Annotations == nil { + serviceAccountConfig.Annotations = map[string]string{} + } + if err := mergeStringMapNoConflict(serviceAccountConfig.Annotations, mrr.ServiceAccountAnnotations, "service account annotation"); err != nil { + return nil, err + } + } + dep := &resolvedDeployment{ Image: image, Args: args, @@ -224,7 +252,7 @@ func resolveInlineDeployment(agent v1alpha2.AgentObject, mdd *modelDeploymentDat ImagePullSecrets: slices.Clone(spec.ImagePullSecrets), Volumes: append(slices.Clone(spec.Volumes), mdd.Volumes...), VolumeMounts: append(slices.Clone(spec.VolumeMounts), mdd.VolumeMounts...), - Labels: getDefaultLabels(agent.GetName(), spec.Labels), + Labels: labels, Annotations: maps.Clone(spec.Annotations), Env: append(slices.Clone(spec.Env), mdd.EnvVars...), Resources: getDefaultResources(spec.Resources), // Set default resources if not specified @@ -234,7 +262,7 @@ func resolveInlineDeployment(agent v1alpha2.AgentObject, mdd *modelDeploymentDat SecurityContext: spec.SecurityContext, PodSecurityContext: spec.PodSecurityContext, ServiceAccountName: spec.ServiceAccountName, - ServiceAccountConfig: spec.ServiceAccountConfig, + ServiceAccountConfig: serviceAccountConfig, ExtraContainers: slices.Clone(spec.ExtraContainers), } @@ -246,10 +274,23 @@ func resolveInlineDeployment(agent v1alpha2.AgentObject, mdd *modelDeploymentDat dep.ServiceAccountName = serviceAccountName } } + if len(mrr.ServiceAccountAnnotations) > 0 && dep.ServiceAccountName != nil && *dep.ServiceAccountName != agent.GetName() { + return nil, fmt.Errorf("model runtime requires ServiceAccount annotations, but serviceAccountName %q is external; use the generated ServiceAccount or preconfigure the external ServiceAccount with the required annotations", *dep.ServiceAccountName) + } return dep, nil } +func mergeStringMapNoConflict(dst, src map[string]string, label string) error { + for key, value := range src { + if existing, ok := dst[key]; ok && existing != value { + return fmt.Errorf("conflicting %s %q: %q != %q", label, key, existing, value) + } + dst[key] = value + } + return nil +} + func checkPullSecretAlreadyPresent(spec v1alpha2.DeclarativeDeploymentSpec) bool { alreadyPresent := false for _, secret := range spec.ImagePullSecrets { diff --git a/go/core/internal/controller/translator/agent/deployments_test.go b/go/core/internal/controller/translator/agent/deployments_test.go index 8712049262..d7ea61f8ef 100644 --- a/go/core/internal/controller/translator/agent/deployments_test.go +++ b/go/core/internal/controller/translator/agent/deployments_test.go @@ -1,6 +1,7 @@ package agent import ( + "strings" "testing" "github.com/kagent-dev/kagent/go/api/v1alpha2" @@ -85,3 +86,68 @@ func TestValidateExtraContainers(t *testing.T) { }) } } + +func TestMergeRuntimeRequirements(t *testing.T) { + t.Parallel() + + t.Run("nil inputs are ignored", func(t *testing.T) { + t.Parallel() + if err := mergeRuntimeRequirements(nil, &modelRuntimeRequirements{PodLabels: map[string]string{"a": "b"}}); err != nil { + t.Fatalf("mergeRuntimeRequirements(nil, src) error = %v", err) + } + dst := &modelRuntimeRequirements{} + if err := mergeRuntimeRequirements(dst, nil); err != nil { + t.Fatalf("mergeRuntimeRequirements(dst, nil) error = %v", err) + } + }) + + t.Run("merges labels and annotations", func(t *testing.T) { + t.Parallel() + dst := &modelRuntimeRequirements{ + PodLabels: map[string]string{"existing-label": "same"}, + ServiceAccountAnnotations: map[string]string{"existing-annotation": "same"}, + } + src := &modelRuntimeRequirements{ + PodLabels: map[string]string{ + "existing-label": "same", + "new-label": "value", + }, + ServiceAccountAnnotations: map[string]string{ + "existing-annotation": "same", + "new-annotation": "value", + }, + } + + if err := mergeRuntimeRequirements(dst, src); err != nil { + t.Fatalf("mergeRuntimeRequirements() error = %v", err) + } + if got := dst.PodLabels["new-label"]; got != "value" { + t.Fatalf("new label = %q, want value", got) + } + if got := dst.ServiceAccountAnnotations["new-annotation"]; got != "value" { + t.Fatalf("new annotation = %q, want value", got) + } + }) + + t.Run("rejects conflicting pod labels", func(t *testing.T) { + t.Parallel() + dst := &modelRuntimeRequirements{PodLabels: map[string]string{"identity/use": "true"}} + src := &modelRuntimeRequirements{PodLabels: map[string]string{"identity/use": "false"}} + + err := mergeRuntimeRequirements(dst, src) + if err == nil || !strings.Contains(err.Error(), `conflicting pod label "identity/use"`) { + t.Fatalf("mergeRuntimeRequirements() error = %v, want conflicting pod label", err) + } + }) + + t.Run("rejects conflicting service account annotations", func(t *testing.T) { + t.Parallel() + dst := &modelRuntimeRequirements{ServiceAccountAnnotations: map[string]string{"identity/client-id": "one"}} + src := &modelRuntimeRequirements{ServiceAccountAnnotations: map[string]string{"identity/client-id": "two"}} + + err := mergeRuntimeRequirements(dst, src) + if err == nil || !strings.Contains(err.Error(), `conflicting service account annotation "identity/client-id"`) { + t.Fatalf("mergeRuntimeRequirements() error = %v, want conflicting service account annotation", err) + } + }) +} diff --git a/go/core/internal/controller/translator/agent/foundry_test.go b/go/core/internal/controller/translator/agent/foundry_test.go new file mode 100644 index 0000000000..f7570740f7 --- /dev/null +++ b/go/core/internal/controller/translator/agent/foundry_test.go @@ -0,0 +1,576 @@ +package agent + +import ( + "context" + "encoding/json" + "testing" + + "github.com/kagent-dev/kagent/go/api/adk" + "github.com/kagent-dev/kagent/go/api/v1alpha2" + "github.com/kagent-dev/kagent/go/core/pkg/env" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + schemev1 "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestTranslateModelFoundryWorkloadIdentity(t *testing.T) { + scheme := schemev1.Scheme + require.NoError(t, v1alpha2.AddToScheme(scheme)) + + modelConfig := &v1alpha2.ModelConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foundry-model", + Namespace: "default", + }, + Spec: v1alpha2.ModelConfigSpec{ + Model: "gpt-4.1-nano", + Provider: v1alpha2.ModelProviderFoundry, + Foundry: &v1alpha2.FoundryConfig{ + Endpoint: "https://kagentfoundrytest0623.cognitiveservices.azure.com/", + Deployment: "gpt-4-1-nano", + Auth: v1alpha2.FoundryAuthConfig{ + Type: v1alpha2.FoundryAuthTypeWorkloadIdentity, + WorkloadIdentity: &v1alpha2.FoundryWorkloadIdentityConfig{ + ClientID: "11111111-1111-1111-1111-111111111111", + TenantID: "22222222-2222-2222-2222-222222222222", + }, + }, + }, + }, + } + + kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(modelConfig).Build() + translator := &adkApiTranslator{kube: kubeClient} + + model, deploymentData, runtimeRequirements, secretHashBytes, err := translator.translateModel(context.Background(), "default", "foundry-model") + require.NoError(t, err) + require.Empty(t, secretHashBytes) + + foundryModel, ok := model.(*adk.Foundry) + require.True(t, ok) + assert.Equal(t, "gpt-4.1-nano", foundryModel.Model) + assert.Equal(t, "https://kagentfoundrytest0623.cognitiveservices.azure.com/", foundryModel.Endpoint) + assert.Equal(t, "gpt-4-1-nano", foundryModel.Deployment) + assert.Equal(t, "2024-10-21", foundryModel.APIVersion) + assert.Equal(t, adk.FoundryAuthTypeWorkloadIdentity, foundryModel.Auth.Type) + + assert.Equal(t, "https://kagentfoundrytest0623.cognitiveservices.azure.com/", envVarValue(t, deploymentData.EnvVars, env.FoundryEndpoint.Name())) + assert.Equal(t, "gpt-4-1-nano", envVarValue(t, deploymentData.EnvVars, env.FoundryDeployment.Name())) + assert.Equal(t, "2024-10-21", envVarValue(t, deploymentData.EnvVars, env.FoundryAPIVersion.Name())) + assertNoEnvVar(t, deploymentData.EnvVars, env.OpenAIAPIKey.Name()) + assertNoEnvVar(t, deploymentData.EnvVars, env.AzureOpenAIAPIKey.Name()) + + assert.Equal(t, "true", runtimeRequirements.PodLabels[azureWorkloadIdentityUseLabel]) + assert.Equal(t, "11111111-1111-1111-1111-111111111111", runtimeRequirements.ServiceAccountAnnotations[azureWorkloadIdentityClientIDAnnotation]) + assert.Equal(t, "22222222-2222-2222-2222-222222222222", runtimeRequirements.ServiceAccountAnnotations[azureWorkloadIdentityTenantIDAnnotation]) +} + +func TestTranslateModelFoundryAPIKey(t *testing.T) { + scheme := schemev1.Scheme + require.NoError(t, v1alpha2.AddToScheme(scheme)) + + modelConfig := &v1alpha2.ModelConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foundry-model", + Namespace: "default", + }, + Spec: v1alpha2.ModelConfigSpec{ + Model: "gpt-4.1-nano", + Provider: v1alpha2.ModelProviderFoundry, + APIKeySecret: "foundry-secret", + APIKeySecretKey: "api-key", + Foundry: &v1alpha2.FoundryConfig{ + Endpoint: "https://kagentfoundrytest0623.cognitiveservices.azure.com/", + Deployment: "gpt-4-1-nano", + Auth: v1alpha2.FoundryAuthConfig{ + Type: v1alpha2.FoundryAuthTypeAPIKey, + }, + }, + }, + } + + kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(modelConfig).Build() + translator := &adkApiTranslator{kube: kubeClient} + + model, deploymentData, runtimeRequirements, _, err := translator.translateModel(context.Background(), "default", "foundry-model") + require.NoError(t, err) + + foundryModel, ok := model.(*adk.Foundry) + require.True(t, ok) + assert.Equal(t, adk.FoundryAuthTypeAPIKey, foundryModel.Auth.Type) + assert.False(t, foundryModel.APIKeyPassthrough) + + apiKeyEnv := envVar(t, deploymentData.EnvVars, env.FoundryAPIKey.Name()) + require.NotNil(t, apiKeyEnv.ValueFrom) + require.NotNil(t, apiKeyEnv.ValueFrom.SecretKeyRef) + assert.Equal(t, "foundry-secret", apiKeyEnv.ValueFrom.SecretKeyRef.Name) + assert.Equal(t, "api-key", apiKeyEnv.ValueFrom.SecretKeyRef.Key) + assert.Empty(t, runtimeRequirements.PodLabels) + assert.Empty(t, runtimeRequirements.ServiceAccountAnnotations) +} + +func TestTranslateModelFoundryAPIKeyPassthrough(t *testing.T) { + scheme := schemev1.Scheme + require.NoError(t, v1alpha2.AddToScheme(scheme)) + + modelConfig := &v1alpha2.ModelConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foundry-model", + Namespace: "default", + }, + Spec: v1alpha2.ModelConfigSpec{ + Model: "gpt-4.1-nano", + Provider: v1alpha2.ModelProviderFoundry, + APIKeyPassthrough: true, + Foundry: &v1alpha2.FoundryConfig{ + Endpoint: "https://kagentfoundrytest0623.cognitiveservices.azure.com/", + Deployment: "gpt-4-1-nano", + Auth: v1alpha2.FoundryAuthConfig{ + Type: v1alpha2.FoundryAuthTypeAPIKeyPassthrough, + }, + }, + }, + } + + kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(modelConfig).Build() + translator := &adkApiTranslator{kube: kubeClient} + + model, deploymentData, runtimeRequirements, _, err := translator.translateModel(context.Background(), "default", "foundry-model") + require.NoError(t, err) + + foundryModel, ok := model.(*adk.Foundry) + require.True(t, ok) + assert.Equal(t, adk.FoundryAuthTypeAPIKeyPassthrough, foundryModel.Auth.Type) + assert.True(t, foundryModel.APIKeyPassthrough) + assertNoEnvVar(t, deploymentData.EnvVars, env.FoundryAPIKey.Name()) + assert.Empty(t, runtimeRequirements.PodLabels) + assert.Empty(t, runtimeRequirements.ServiceAccountAnnotations) +} + +func TestTranslateModelFoundryResolvesConfigMapRefs(t *testing.T) { + scheme := schemev1.Scheme + require.NoError(t, v1alpha2.AddToScheme(scheme)) + + values := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foundry-values", + Namespace: "default", + }, + Data: map[string]string{ + "endpoint": "https://from-configmap.cognitiveservices.azure.com/", + "clientId": "33333333-3333-3333-3333-333333333333", + "tenantId": "44444444-4444-4444-4444-444444444444", + }, + } + modelConfig := &v1alpha2.ModelConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foundry-model", + Namespace: "default", + }, + Spec: v1alpha2.ModelConfigSpec{ + Model: "gpt-4.1-nano", + Provider: v1alpha2.ModelProviderFoundry, + Foundry: &v1alpha2.FoundryConfig{ + EndpointFrom: &v1alpha2.FoundryEndpointSource{ + ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: values.Name}, + Key: "endpoint", + }, + }, + Deployment: "gpt-4-1-nano", + Auth: v1alpha2.FoundryAuthConfig{ + Type: v1alpha2.FoundryAuthTypeWorkloadIdentity, + WorkloadIdentity: &v1alpha2.FoundryWorkloadIdentityConfig{ + ClientIDFrom: &v1alpha2.FoundryValueSource{ + ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: values.Name}, + Key: "clientId", + }, + }, + TenantIDFrom: &v1alpha2.FoundryValueSource{ + ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: values.Name}, + Key: "tenantId", + }, + }, + }, + }, + }, + }, + } + + kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(values, modelConfig).Build() + translator := &adkApiTranslator{kube: kubeClient} + + model, deploymentData, runtimeRequirements, _, err := translator.translateModel(context.Background(), "default", "foundry-model") + require.NoError(t, err) + + foundryModel, ok := model.(*adk.Foundry) + require.True(t, ok) + assert.Equal(t, "https://from-configmap.cognitiveservices.azure.com/", foundryModel.Endpoint) + assert.Equal(t, "https://from-configmap.cognitiveservices.azure.com/", envVarValue(t, deploymentData.EnvVars, env.FoundryEndpoint.Name())) + assert.Equal(t, "33333333-3333-3333-3333-333333333333", runtimeRequirements.ServiceAccountAnnotations[azureWorkloadIdentityClientIDAnnotation]) + assert.Equal(t, "44444444-4444-4444-4444-444444444444", runtimeRequirements.ServiceAccountAnnotations[azureWorkloadIdentityTenantIDAnnotation]) +} + +func TestTranslateModelFoundryWorkloadIdentityRequiresResolvedClientID(t *testing.T) { + scheme := schemev1.Scheme + require.NoError(t, v1alpha2.AddToScheme(scheme)) + + optional := true + values := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foundry-values", + Namespace: "default", + }, + Data: map[string]string{}, + } + modelConfig := foundryWorkloadIdentityModelConfig("foundry-model") + modelConfig.Spec.Foundry.Auth.WorkloadIdentity.ClientID = "" + modelConfig.Spec.Foundry.Auth.WorkloadIdentity.ClientIDFrom = &v1alpha2.FoundryValueSource{ + ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: values.Name}, + Key: "clientId", + Optional: &optional, + }, + } + + kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(values, modelConfig).Build() + translator := &adkApiTranslator{kube: kubeClient} + + _, _, _, _, err := translator.translateModel(context.Background(), "default", "foundry-model") + require.ErrorContains(t, err, "Foundry workload identity clientId is required") +} + +func TestTranslateAgentFoundryWorkloadIdentityRuntimeRequirements(t *testing.T) { + scheme := schemev1.Scheme + require.NoError(t, v1alpha2.AddToScheme(scheme)) + + modelConfig := &v1alpha2.ModelConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foundry-model", + Namespace: "default", + }, + Spec: v1alpha2.ModelConfigSpec{ + Model: "gpt-4.1-nano", + Provider: v1alpha2.ModelProviderFoundry, + Foundry: &v1alpha2.FoundryConfig{ + Endpoint: "https://kagentfoundrytest0623.cognitiveservices.azure.com/", + Deployment: "gpt-4-1-nano", + Auth: v1alpha2.FoundryAuthConfig{ + Type: v1alpha2.FoundryAuthTypeWorkloadIdentity, + WorkloadIdentity: &v1alpha2.FoundryWorkloadIdentityConfig{ + ClientID: "11111111-1111-1111-1111-111111111111", + TenantID: "22222222-2222-2222-2222-222222222222", + }, + }, + }, + }, + } + agent := &v1alpha2.Agent{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foundry-agent", + Namespace: "default", + }, + Spec: v1alpha2.AgentSpec{ + Type: v1alpha2.AgentType_Declarative, + Description: "Foundry smoke agent", + Declarative: &v1alpha2.DeclarativeAgentSpec{ + Runtime: v1alpha2.DeclarativeRuntime_Go, + SystemMessage: "You are a Foundry smoke test agent", + ModelConfig: "foundry-model", + }, + }, + } + + kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(modelConfig, agent).Build() + translator := NewAdkApiTranslator(kubeClient, types.NamespacedName{Namespace: "default", Name: "foundry-model"}, nil, "", nil) + + outputs, err := TranslateAgent(context.Background(), translator, agent) + require.NoError(t, err) + require.NotNil(t, outputs) + + var deployment *appsv1.Deployment + var configSecret *corev1.Secret + var serviceAccount *corev1.ServiceAccount + for _, obj := range outputs.Manifest { + switch typedObj := obj.(type) { + case *appsv1.Deployment: + deployment = typedObj + case *corev1.Secret: + configSecret = typedObj + case *corev1.ServiceAccount: + serviceAccount = typedObj + } + } + + require.NotNil(t, deployment) + require.NotNil(t, configSecret) + require.NotNil(t, serviceAccount) + assert.Equal(t, "true", deployment.Spec.Template.Labels[azureWorkloadIdentityUseLabel]) + assert.Equal(t, "foundry-agent", deployment.Spec.Template.Spec.ServiceAccountName) + assert.Equal(t, "11111111-1111-1111-1111-111111111111", serviceAccount.Annotations[azureWorkloadIdentityClientIDAnnotation]) + assert.Equal(t, "22222222-2222-2222-2222-222222222222", serviceAccount.Annotations[azureWorkloadIdentityTenantIDAnnotation]) + + configJSON := configSecret.StringData["config.json"] + require.NotEmpty(t, configJSON) + var agentConfig adk.AgentConfig + require.NoError(t, json.Unmarshal([]byte(configJSON), &agentConfig)) +} + +func TestTranslateAgentFoundryRequiresGoRuntime(t *testing.T) { + scheme := schemev1.Scheme + require.NoError(t, v1alpha2.AddToScheme(scheme)) + + modelConfig := &v1alpha2.ModelConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foundry-model", + Namespace: "default", + }, + Spec: v1alpha2.ModelConfigSpec{ + Model: "gpt-4.1-nano", + Provider: v1alpha2.ModelProviderFoundry, + Foundry: &v1alpha2.FoundryConfig{ + Endpoint: "https://kagentfoundrytest0623.cognitiveservices.azure.com/", + Deployment: "gpt-4-1-nano", + Auth: v1alpha2.FoundryAuthConfig{ + Type: v1alpha2.FoundryAuthTypeWorkloadIdentity, + WorkloadIdentity: &v1alpha2.FoundryWorkloadIdentityConfig{ + ClientID: "11111111-1111-1111-1111-111111111111", + }, + }, + }, + }, + } + agent := &v1alpha2.Agent{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foundry-agent", + Namespace: "default", + }, + Spec: v1alpha2.AgentSpec{ + Type: v1alpha2.AgentType_Declarative, + Declarative: &v1alpha2.DeclarativeAgentSpec{ + SystemMessage: "You are a Foundry smoke test agent", + ModelConfig: "foundry-model", + }, + }, + } + + kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(modelConfig, agent).Build() + translator := NewAdkApiTranslator(kubeClient, types.NamespacedName{Namespace: "default", Name: "foundry-model"}, nil, "", nil) + + _, err := TranslateAgent(context.Background(), translator, agent) + require.ErrorContains(t, err, `Foundry model provider requires declarative runtime "go"`) +} + +func TestTranslateAgentFoundrySummarizerRequiresGoRuntime(t *testing.T) { + scheme := schemev1.Scheme + require.NoError(t, v1alpha2.AddToScheme(scheme)) + + mainModel := openAIModelConfig("main-model") + summarizerModel := foundryWorkloadIdentityModelConfig("foundry-summarizer") + summarizerName := summarizerModel.Name + compactionInterval := 5 + agent := &v1alpha2.Agent{ + ObjectMeta: metav1.ObjectMeta{ + Name: "openai-agent", + Namespace: "default", + }, + Spec: v1alpha2.AgentSpec{ + Type: v1alpha2.AgentType_Declarative, + Declarative: &v1alpha2.DeclarativeAgentSpec{ + SystemMessage: "You are a test agent", + ModelConfig: mainModel.Name, + Context: &v1alpha2.ContextConfig{ + Compaction: &v1alpha2.ContextCompressionConfig{ + CompactionInterval: &compactionInterval, + Summarizer: &v1alpha2.ContextSummarizerConfig{ + ModelConfig: &summarizerName, + }, + }, + }, + }, + }, + } + + kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(mainModel, summarizerModel, agent).Build() + translator := NewAdkApiTranslator(kubeClient, types.NamespacedName{Namespace: "default", Name: mainModel.Name}, nil, "", nil) + + _, err := TranslateAgent(context.Background(), translator, agent) + require.ErrorContains(t, err, `Foundry model provider requires declarative runtime "go"`) +} + +func TestTranslateAgentFoundryMemoryRequiresGoRuntime(t *testing.T) { + scheme := schemev1.Scheme + require.NoError(t, v1alpha2.AddToScheme(scheme)) + + mainModel := openAIModelConfig("main-model") + memoryModel := foundryWorkloadIdentityModelConfig("foundry-memory") + agent := &v1alpha2.Agent{ + ObjectMeta: metav1.ObjectMeta{ + Name: "openai-agent", + Namespace: "default", + }, + Spec: v1alpha2.AgentSpec{ + Type: v1alpha2.AgentType_Declarative, + Declarative: &v1alpha2.DeclarativeAgentSpec{ + SystemMessage: "You are a test agent", + ModelConfig: mainModel.Name, + Memory: &v1alpha2.MemorySpec{ + ModelConfig: memoryModel.Name, + }, + }, + }, + } + + kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(mainModel, memoryModel, agent).Build() + translator := NewAdkApiTranslator(kubeClient, types.NamespacedName{Namespace: "default", Name: mainModel.Name}, nil, "", nil) + + _, err := TranslateAgent(context.Background(), translator, agent) + require.ErrorContains(t, err, `Foundry model provider requires declarative runtime "go"`) +} + +func TestTranslateAgentFoundryRuntimeRequirementConflict(t *testing.T) { + scheme := schemev1.Scheme + require.NoError(t, v1alpha2.AddToScheme(scheme)) + + mainModel := foundryWorkloadIdentityModelConfig("main-model") + summarizerModel := foundryWorkloadIdentityModelConfig("foundry-summarizer") + summarizerModel.Spec.Foundry.Auth.WorkloadIdentity.ClientID = "99999999-9999-9999-9999-999999999999" + summarizerName := summarizerModel.Name + compactionInterval := 5 + agent := &v1alpha2.Agent{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foundry-agent", + Namespace: "default", + }, + Spec: v1alpha2.AgentSpec{ + Type: v1alpha2.AgentType_Declarative, + Declarative: &v1alpha2.DeclarativeAgentSpec{ + Runtime: v1alpha2.DeclarativeRuntime_Go, + SystemMessage: "You are a test agent", + ModelConfig: mainModel.Name, + Context: &v1alpha2.ContextConfig{ + Compaction: &v1alpha2.ContextCompressionConfig{ + CompactionInterval: &compactionInterval, + Summarizer: &v1alpha2.ContextSummarizerConfig{ + ModelConfig: &summarizerName, + }, + }, + }, + }, + }, + } + + kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(mainModel, summarizerModel, agent).Build() + translator := NewAdkApiTranslator(kubeClient, types.NamespacedName{Namespace: "default", Name: mainModel.Name}, nil, "", nil) + + _, err := TranslateAgent(context.Background(), translator, agent) + require.ErrorContains(t, err, `conflicting service account annotation "azure.workload.identity/client-id"`) +} + +func TestTranslateAgentFoundryWorkloadIdentityRejectsExternalServiceAccount(t *testing.T) { + scheme := schemev1.Scheme + require.NoError(t, v1alpha2.AddToScheme(scheme)) + + modelConfig := foundryWorkloadIdentityModelConfig("foundry-model") + serviceAccountName := "external-foundry-sa" + agent := &v1alpha2.Agent{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foundry-agent", + Namespace: "default", + }, + Spec: v1alpha2.AgentSpec{ + Type: v1alpha2.AgentType_Declarative, + Declarative: &v1alpha2.DeclarativeAgentSpec{ + Runtime: v1alpha2.DeclarativeRuntime_Go, + SystemMessage: "You are a Foundry smoke test agent", + ModelConfig: modelConfig.Name, + Deployment: &v1alpha2.DeclarativeDeploymentSpec{ + SharedDeploymentSpec: v1alpha2.SharedDeploymentSpec{ + ServiceAccountName: &serviceAccountName, + }, + }, + }, + }, + } + + kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(modelConfig, agent).Build() + translator := NewAdkApiTranslator(kubeClient, types.NamespacedName{Namespace: "default", Name: modelConfig.Name}, nil, "", nil) + + _, err := TranslateAgent(context.Background(), translator, agent) + require.ErrorContains(t, err, `model runtime requires ServiceAccount annotations, but serviceAccountName "external-foundry-sa" is external`) +} + +func openAIModelConfig(name string) *v1alpha2.ModelConfig { + return &v1alpha2.ModelConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "default", + }, + Spec: v1alpha2.ModelConfigSpec{ + Model: "gpt-4.1-nano", + Provider: v1alpha2.ModelProviderOpenAI, + OpenAI: &v1alpha2.OpenAIConfig{}, + }, + } +} + +func foundryWorkloadIdentityModelConfig(name string) *v1alpha2.ModelConfig { + return &v1alpha2.ModelConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "default", + }, + Spec: v1alpha2.ModelConfigSpec{ + Model: "gpt-4.1-nano", + Provider: v1alpha2.ModelProviderFoundry, + Foundry: &v1alpha2.FoundryConfig{ + Endpoint: "https://kagentfoundrytest0623.cognitiveservices.azure.com/", + Deployment: "gpt-4-1-nano", + Auth: v1alpha2.FoundryAuthConfig{ + Type: v1alpha2.FoundryAuthTypeWorkloadIdentity, + WorkloadIdentity: &v1alpha2.FoundryWorkloadIdentityConfig{ + ClientID: "11111111-1111-1111-1111-111111111111", + }, + }, + }, + }, + } +} + +func envVar(t *testing.T, envVars []corev1.EnvVar, name string) corev1.EnvVar { + t.Helper() + for _, envVar := range envVars { + if envVar.Name == name { + return envVar + } + } + t.Fatalf("env var %s not found", name) + return corev1.EnvVar{} +} + +func envVarValue(t *testing.T, envVars []corev1.EnvVar, name string) string { + t.Helper() + for _, envVar := range envVars { + if envVar.Name == name { + return envVar.Value + } + } + t.Fatalf("env var %s not found", name) + return "" +} + +func assertNoEnvVar(t *testing.T, envVars []corev1.EnvVar, name string) { + t.Helper() + for _, envVar := range envVars { + if envVar.Name == name { + t.Fatalf("env var %s unexpectedly present", name) + } + } +} diff --git a/go/core/pkg/env/providers.go b/go/core/pkg/env/providers.go index 5f264de091..1197d97840 100644 --- a/go/core/pkg/env/providers.go +++ b/go/core/pkg/env/providers.go @@ -170,3 +170,34 @@ var ( ComponentAgentRuntime, ) ) + +// Foundry +var ( + FoundryAPIKey = RegisterStringVar( + "FOUNDRY_API_KEY", + "", + "API key for Foundry.", + ComponentAgentRuntime, + ) + + FoundryEndpoint = RegisterStringVar( + "FOUNDRY_ENDPOINT", + "", + "Endpoint URL for Foundry or Azure AI Services account.", + ComponentAgentRuntime, + ) + + FoundryDeployment = RegisterStringVar( + "FOUNDRY_DEPLOYMENT", + "", + "Foundry model deployment name.", + ComponentAgentRuntime, + ) + + FoundryAPIVersion = RegisterStringVar( + "FOUNDRY_API_VERSION", + "2024-10-21", + "Foundry OpenAI-compatible data-plane API version.", + ComponentAgentRuntime, + ) +) diff --git a/go/go.mod b/go/go.mod index 6b689ba700..92aeb9bf8e 100644 --- a/go/go.mod +++ b/go/go.mod @@ -61,6 +61,8 @@ require ( ) require ( + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 github.com/agent-substrate/substrate v0.0.0 github.com/aws/aws-sdk-go-v2 v1.42.0 github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.53.5 @@ -104,7 +106,9 @@ require ( github.com/Antonboom/errname v1.1.1 // indirect github.com/Antonboom/nilnil v1.1.1 // indirect github.com/Antonboom/testifylint v1.6.4 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 // indirect github.com/BurntSushi/toml v1.6.0 // indirect github.com/ClickHouse/clickhouse-go-linter v1.2.0 // indirect github.com/Djarvur/go-err113 v0.1.1 // indirect @@ -274,6 +278,7 @@ require ( github.com/klauspost/compress v1.18.6 // indirect github.com/kulti/thelper v0.7.1 // indirect github.com/kunwardeep/paralleltest v1.0.15 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect github.com/lasiar/canonicalheader v1.1.2 // indirect github.com/ldez/exptostd v0.4.5 // indirect github.com/ldez/gomoddirectives v0.8.0 // indirect @@ -328,6 +333,7 @@ require ( github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pb33f/ordered-map/v2 v2.3.1 // indirect github.com/pelletier/go-toml/v2 v2.3.1 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/prometheus/client_model v0.6.2 // indirect diff --git a/go/go.sum b/go/go.sum index 4ef8f4beca..f495f4dae9 100644 --- a/go/go.sum +++ b/go/go.sum @@ -40,8 +40,20 @@ github.com/Antonboom/nilnil v1.1.1 h1:9Mdr6BYd8WHCDngQnNVV0b554xyisFioEKi30sksuf github.com/Antonboom/nilnil v1.1.1/go.mod h1:yCyAmSw3doopbOWhJlVci+HuyNRuHJKIv6V2oYQa8II= github.com/Antonboom/testifylint v1.6.4 h1:gs9fUEy+egzxkEbq9P4cpcMB6/G0DYdMeiFS87UiqmQ= github.com/Antonboom/testifylint v1.6.4/go.mod h1:YO33FROXX2OoUfwjz8g+gUxQXio5i9qpVy7nXGbxDD4= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 h1:aokoqcHvaGjiM3VpjKDfMMnF/8epJ+Q1HLJ7CudztqE= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0/go.mod h1:/WYEx9pcM9Y+Dd/APJaNlSvVSvzl54rrMdZT5+Oi2LM= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 h1:CU4+EJeJi3TKYWEcYuSdWsjzw0nVsK/H0MSQOiPcymU= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0/go.mod h1:q0+UTSRvShwUCrR/s5HtyInYphN7Wvxb7snFM3u+SLA= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0 h1:xFaZZ+IubdftrDHnGGwZ6QvQ3KHTtWl2MCK+GMt2vxs= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0/go.mod h1:mCBhUhlMjLLJKr5aqw2TNS/VqJOie8MzWq3DAMJeKso= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 h1:RHK7bS+HQMslb1sZpAokUt+zTVmue0hKSs2C791hhzU= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/ClickHouse/clickhouse-go-linter v1.2.0 h1:zbm174up3hTKjp0wKZVnTzRiG7tSF5XZF0FJG/MuCBI= @@ -504,6 +516,8 @@ github.com/kagent-dev/substrate v0.0.6 h1:WR3mTKAYW4jMz3ojhM87jLjDYgy2ZYgU1Tvzhe github.com/kagent-dev/substrate v0.0.6/go.mod h1:TgdtEUV6iaflJTwmS8ONiGsyyJD+5okPZj2H6mM8WlA= github.com/karamaru-alpha/copyloopvar v1.2.2 h1:yfNQvP9YaGQR7VaWLYcfZUlRP2eo2vhExWKxD/fP6q0= github.com/karamaru-alpha/copyloopvar v1.2.2/go.mod h1:oY4rGZqZ879JkJMtX3RRkcXRkmUvH0x35ykgaKgsgJY= +github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= +github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= github.com/kisielk/errcheck v1.10.0 h1:Lvs/YAHP24YKg08LA8oDw2z9fJVme090RAXd90S+rrw= github.com/kisielk/errcheck v1.10.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8= github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/ttjfJCE= @@ -665,6 +679,8 @@ github.com/pgvector/pgvector-go v0.4.0 h1:879hQCnuix1bkfa5TQISnnK9ik4Fo+cHj2vuZS github.com/pgvector/pgvector-go v0.4.0/go.mod h1:4fSXyjl1TYAIdByAql6JazKWRr2s7J0g4hcRY5cBFCk= github.com/pgvector/pgvector-go/pgx v0.4.0 h1:wHFoQRtCksVfmrBaHoxeT8IkonmnxlvnLzz3T4EW9Y0= github.com/pgvector/pgvector-go/pgx v0.4.0/go.mod h1:G61nQVFeCjO8sJU9SsihwGf5Ko34IOnaqXfOWe2kBpU= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= @@ -979,6 +995,7 @@ golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= diff --git a/helm/kagent-crds/templates/kagent.dev_modelconfigs.yaml b/helm/kagent-crds/templates/kagent.dev_modelconfigs.yaml index 4dfdc96bce..9c8d8ae26a 100644 --- a/helm/kagent-crds/templates/kagent.dev_modelconfigs.yaml +++ b/helm/kagent-crds/templates/kagent.dev_modelconfigs.yaml @@ -532,6 +532,166 @@ spec: additionalProperties: type: string type: object + foundry: + description: Foundry-specific configuration + properties: + apiVersion: + default: "2024-10-21" + description: API version for the Foundry OpenAI-compatible data-plane + API. + type: string + auth: + description: Auth configures Foundry authentication. + properties: + type: + description: Type identifies the Foundry auth mode. + enum: + - APIKey + - WorkloadIdentity + - APIKeyPassthrough + type: string + workloadIdentity: + description: WorkloadIdentity contains Azure Workload Identity + configuration. + properties: + clientId: + description: ClientID is the Azure managed identity client + ID used by Azure Workload Identity. + type: string + clientIdFrom: + description: ClientIDFrom references a source for the + Azure managed identity client ID. + properties: + configMapKeyRef: + description: ConfigMapKeyRef selects a key of a ConfigMap + containing the value. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-validations: + - message: configMapKeyRef is required + rule: has(self.configMapKeyRef) + tenantId: + description: TenantID is the Azure tenant ID. Optional + when supplied by the workload identity webhook. + type: string + tenantIdFrom: + description: TenantIDFrom references a source for the + Azure tenant ID. + properties: + configMapKeyRef: + description: ConfigMapKeyRef selects a key of a ConfigMap + containing the value. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-validations: + - message: configMapKeyRef is required + rule: has(self.configMapKeyRef) + type: object + x-kubernetes-validations: + - message: clientId and clientIdFrom are mutually exclusive + rule: '!(has(self.clientId) && size(self.clientId) > 0 && + has(self.clientIdFrom))' + - message: clientId or clientIdFrom is required + rule: (has(self.clientId) && size(self.clientId) > 0) || + has(self.clientIdFrom) + - message: tenantId and tenantIdFrom are mutually exclusive + rule: '!(has(self.tenantId) && size(self.tenantId) > 0 && + has(self.tenantIdFrom))' + required: + - type + type: object + x-kubernetes-validations: + - message: workloadIdentity is required when auth.type is WorkloadIdentity + rule: self.type != 'WorkloadIdentity' || has(self.workloadIdentity) + - message: workloadIdentity must be nil unless auth.type is WorkloadIdentity + rule: '!(has(self.workloadIdentity) && self.type != ''WorkloadIdentity'')' + deployment: + description: Deployment is the Foundry model deployment name. + type: string + endpoint: + description: Endpoint is the Foundry or AI Services account endpoint. + type: string + endpointFrom: + description: EndpointFrom references a source for the Foundry + endpoint. + properties: + configMapKeyRef: + description: ConfigMapKeyRef selects a key of a ConfigMap + containing the Foundry endpoint. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or its key + must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-validations: + - message: configMapKeyRef is required + rule: has(self.configMapKeyRef) + required: + - auth + - deployment + type: object + x-kubernetes-validations: + - message: endpoint and endpointFrom are mutually exclusive + rule: '!(has(self.endpoint) && size(self.endpoint) > 0 && has(self.endpointFrom))' + - message: endpoint or endpointFrom is required + rule: (has(self.endpoint) && size(self.endpoint) > 0) || has(self.endpointFrom) gemini: description: Gemini-specific configuration type: object @@ -665,6 +825,7 @@ spec: - AnthropicVertexAI - Bedrock - SAPAICore + - Foundry type: string sapAICore: description: SAP AI Core-specific configuration @@ -760,6 +921,8 @@ spec: rule: '!(has(self.bedrock) && self.provider != ''Bedrock'')' - message: provider.sapAICore must be nil if the provider is not SAPAICore 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: 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 @@ -783,6 +946,22 @@ spec: - message: openAI.tokenExchange type GDCHServiceAccount requires openAI.tokenExchange.gdchServiceAccount rule: '!(has(self.openAI) && has(self.openAI.tokenExchange) && self.openAI.tokenExchange.type == ''GDCHServiceAccount'' && !has(self.openAI.tokenExchange.gdchServiceAccount))' + - message: Foundry auth.type APIKey requires apiKeySecret + rule: '!(self.provider == ''Foundry'' && has(self.foundry) && self.foundry.auth.type + == ''APIKey'' && (!has(self.apiKeySecret) || size(self.apiKeySecret) + == 0))' + - message: Foundry auth.type WorkloadIdentity must not set apiKeySecret + rule: '!(self.provider == ''Foundry'' && has(self.foundry) && self.foundry.auth.type + == ''WorkloadIdentity'' && has(self.apiKeySecret) && size(self.apiKeySecret) + > 0)' + - message: Foundry auth.type APIKeyPassthrough requires apiKeyPassthrough=true + rule: '!(self.provider == ''Foundry'' && has(self.foundry) && self.foundry.auth.type + == ''APIKeyPassthrough'' && (!has(self.apiKeyPassthrough) || !self.apiKeyPassthrough))' + - message: apiKeyPassthrough is only valid for Foundry when foundry.auth.type + is APIKeyPassthrough + rule: '!(self.provider == ''Foundry'' && has(self.apiKeyPassthrough) + && self.apiKeyPassthrough && (!has(self.foundry) || self.foundry.auth.type + != ''APIKeyPassthrough''))' status: description: ModelConfigStatus defines the observed state of ModelConfig. properties: diff --git a/helm/kagent-crds/templates/kagent.dev_modelproviderconfigs.yaml b/helm/kagent-crds/templates/kagent.dev_modelproviderconfigs.yaml index 493e817e9e..4c1d0af392 100644 --- a/helm/kagent-crds/templates/kagent.dev_modelproviderconfigs.yaml +++ b/helm/kagent-crds/templates/kagent.dev_modelproviderconfigs.yaml @@ -91,6 +91,7 @@ spec: - AnthropicVertexAI - Bedrock - SAPAICore + - Foundry type: string required: - type From 6b30e0a3a1823438008b7b3c121056a6ecbfda7a Mon Sep 17 00:00:00 2001 From: Mark Rossetti Date: Thu, 25 Jun 2026 22:01:12 +0000 Subject: [PATCH 2/2] feat(ui): add Foundry model configuration Signed-off-by: Mark Rossetti --- .../handlers/modelproviderconfig.go | 34 +++-- .../internal/httpserver/handlers/models.go | 53 +++++++ ui/src/app/models/new/page.tsx | 137 ++++++++++++++++-- ui/src/components/ModelProviderCombobox.tsx | 1 + ui/src/components/ProviderCombobox.tsx | 1 + ui/src/components/models/new/AuthSection.tsx | 38 ++++- ui/src/lib/providers.ts | 11 +- ui/src/types/index.ts | 17 +++ 8 files changed, 261 insertions(+), 31 deletions(-) diff --git a/go/core/internal/httpserver/handlers/modelproviderconfig.go b/go/core/internal/httpserver/handlers/modelproviderconfig.go index 226df54d37..bef51bbedb 100644 --- a/go/core/internal/httpserver/handlers/modelproviderconfig.go +++ b/go/core/internal/httpserver/handlers/modelproviderconfig.go @@ -54,12 +54,33 @@ func getRequiredKeysForModelProvider(providerType v1alpha2.ModelProvider) []stri case v1alpha2.ModelProviderOpenAI, v1alpha2.ModelProviderAnthropic, v1alpha2.ModelProviderOllama: // These providers currently have no fields marked as strictly required in the API definition return []string{} + case v1alpha2.ModelProviderFoundry: + return []string{"endpoint", "deployment", "clientId"} default: // Unknown provider, return empty return []string{} } } +func getOptionalKeysForModelProvider(providerType v1alpha2.ModelProvider, allKeys, requiredKeys []string) []string { + if providerType == v1alpha2.ModelProviderFoundry { + return []string{"apiVersion", "tenantId"} + } + + requiredSet := make(map[string]struct{}) + for _, k := range requiredKeys { + requiredSet[k] = struct{}{} + } + + optionalKeys := []string{} + for _, k := range allKeys { + if _, isRequired := requiredSet[k]; !isRequired { + optionalKeys = append(optionalKeys, k) + } + } + return optionalKeys +} + func getRequiredKeysForMemoryProvider(providerType v1alpha1.MemoryProvider) []string { switch providerType { case v1alpha1.Pinecone: @@ -128,6 +149,7 @@ func (h *ModelProviderConfigHandler) HandleListSupportedModelProviders(w ErrorRe {v1alpha2.ModelProviderAnthropicVertexAI, reflect.TypeFor[v1alpha2.AnthropicVertexAIConfig]()}, {v1alpha2.ModelProviderBedrock, reflect.TypeFor[v1alpha2.BedrockConfig]()}, {v1alpha2.ModelProviderSAPAICore, reflect.TypeFor[v1alpha2.SAPAICoreConfig]()}, + {v1alpha2.ModelProviderFoundry, reflect.TypeFor[v1alpha2.FoundryConfig]()}, } providersResponse := []map[string]any{} @@ -135,17 +157,7 @@ func (h *ModelProviderConfigHandler) HandleListSupportedModelProviders(w ErrorRe for _, pData := range providersData { allKeys := getStructJSONKeys(pData.configType) requiredKeys := getRequiredKeysForModelProvider(pData.providerEnum) - requiredSet := make(map[string]struct{}) - for _, k := range requiredKeys { - requiredSet[k] = struct{}{} - } - - optionalKeys := []string{} - for _, k := range allKeys { - if _, isRequired := requiredSet[k]; !isRequired { - optionalKeys = append(optionalKeys, k) - } - } + optionalKeys := getOptionalKeysForModelProvider(pData.providerEnum, allKeys, requiredKeys) providersResponse = append(providersResponse, map[string]any{ "name": string(pData.providerEnum), diff --git a/go/core/internal/httpserver/handlers/models.go b/go/core/internal/httpserver/handlers/models.go index ec71c2d04a..0a5e7fea92 100644 --- a/go/core/internal/httpserver/handlers/models.go +++ b/go/core/internal/httpserver/handlers/models.go @@ -157,6 +157,59 @@ func (h *ModelHandler) HandleListSupportedModels(w ErrorResponseWriter, r *http. {Name: "sonar", FunctionCalling: false}, {Name: "sap-abap-1", FunctionCalling: false}, }, + v1alpha2.ModelProviderFoundry: { + {Name: "Cohere-command-a", FunctionCalling: true}, + {Name: "DeepSeek-R1", FunctionCalling: false}, + {Name: "DeepSeek-R1-0528", FunctionCalling: false}, + {Name: "DeepSeek-V3-0324", FunctionCalling: true}, + {Name: "DeepSeek-V3.1", FunctionCalling: true}, + {Name: "DeepSeek-V3.2", FunctionCalling: false}, + {Name: "DeepSeek-V3.2-Speciale", FunctionCalling: false}, + {Name: "DeepSeek-V4-Flash", FunctionCalling: false}, + {Name: "DeepSeek-V4-Pro", FunctionCalling: false}, + {Name: "Kimi-K2.5", FunctionCalling: true}, + {Name: "Kimi-K2.6", FunctionCalling: true}, + {Name: "Llama-3.3-70B-Instruct", FunctionCalling: false}, + {Name: "Llama-4-Maverick-17B-128E-Instruct-FP8", FunctionCalling: false}, + {Name: "Mistral-Large-3", FunctionCalling: true}, + {Name: "gpt-chat-latest", FunctionCalling: true}, + {Name: "gpt-4", FunctionCalling: true}, + {Name: "gpt-4-turbo", FunctionCalling: true}, + {Name: "gpt-4o-mini", FunctionCalling: true}, + {Name: "gpt-5", FunctionCalling: true}, + {Name: "gpt-5-chat", FunctionCalling: true}, + {Name: "gpt-5-mini", FunctionCalling: true}, + {Name: "gpt-5-nano", FunctionCalling: true}, + {Name: "gpt-5.1", FunctionCalling: true}, + {Name: "gpt-5.1-chat", FunctionCalling: true}, + {Name: "gpt-5.2", FunctionCalling: true}, + {Name: "gpt-5.2-chat", FunctionCalling: true}, + {Name: "gpt-5.3-chat", FunctionCalling: true}, + {Name: "gpt-5.4", FunctionCalling: true}, + {Name: "gpt-5.4-mini", FunctionCalling: true}, + {Name: "gpt-5.4-nano", FunctionCalling: true}, + {Name: "gpt-5.5", FunctionCalling: true}, + {Name: "gpt-oss-120b", FunctionCalling: true}, + {Name: "gpt-oss-20b", FunctionCalling: true}, + {Name: "gpt-4.1", FunctionCalling: true}, + {Name: "gpt-4.1-mini", FunctionCalling: true}, + {Name: "gpt-4.1-nano", FunctionCalling: true}, + {Name: "gpt-4o", FunctionCalling: true}, + {Name: "grok-4", FunctionCalling: true}, + {Name: "grok-4-20-non-reasoning", FunctionCalling: true}, + {Name: "grok-4-20-reasoning", FunctionCalling: true}, + {Name: "grok-4.1-fast-non-reasoning", FunctionCalling: true}, + {Name: "grok-4.1-fast-reasoning", FunctionCalling: true}, + {Name: "grok-4.3", FunctionCalling: true}, + {Name: "grok-code-fast-1", FunctionCalling: true}, + {Name: "mistral-medium-3-5", FunctionCalling: false}, + {Name: "model-router", FunctionCalling: true}, + {Name: "o1", FunctionCalling: true}, + {Name: "o1-mini", FunctionCalling: true}, + {Name: "o3", FunctionCalling: true}, + {Name: "o3-mini", FunctionCalling: true}, + {Name: "o4-mini", FunctionCalling: true}, + }, } log.Info("Successfully listed supported models", "count", len(supportedModels)) diff --git a/ui/src/app/models/new/page.tsx b/ui/src/app/models/new/page.tsx index 1adc821ee2..4c5fe2039c 100644 --- a/ui/src/app/models/new/page.tsx +++ b/ui/src/app/models/new/page.tsx @@ -21,6 +21,7 @@ import type { AnthropicVertexAIConfig, BedrockConfig, SAPAICoreConfigPayload, + FoundryConfig, ProviderModelsResponse, } from "@/types"; import { toast } from "sonner"; @@ -51,6 +52,43 @@ interface ModelParam { value: string; } +type FoundryAuthType = FoundryConfig["auth"]["type"]; + +const DEFAULT_FOUNDRY_AUTH_TYPE: FoundryAuthType = "WorkloadIdentity"; + +const getProviderRequiredParams = (provider: Provider | null, foundryAuthType: FoundryAuthType): string[] => { + const requiredKeys = provider?.requiredParams || []; + if (provider?.type !== 'Foundry') { + return requiredKeys; + } + return requiredKeys.filter(key => foundryAuthType === "WorkloadIdentity" || key !== "clientId"); +}; + +const getProviderOptionalParams = (provider: Provider | null, foundryAuthType: FoundryAuthType): string[] => { + const optionalKeys = provider?.optionalParams || []; + if (provider?.type !== 'Foundry') { + return optionalKeys; + } + return optionalKeys.filter(key => foundryAuthType === "WorkloadIdentity" || key !== "tenantId"); +}; + +const modelParamValues = (requiredParams: ModelParam[], optionalParams: ModelParam[]): Record => { + return [...requiredParams, ...optionalParams].reduce((acc, param) => { + if (param.key.trim()) { + acc[param.key] = param.value; + } + return acc; + }, {} as Record); +}; + +const modelParamsForKeys = (keys: string[], prefix: string, values: Record): ModelParam[] => { + return keys.map((key, index) => ({ + id: `${prefix}-${index}`, + key, + value: values[key] || "", + })); +}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any const processModelParams = (requiredParams: ModelParam[], optionalParams: ModelParam[]): Record => { const allParams = [...requiredParams, ...optionalParams] @@ -136,6 +174,7 @@ function ModelPageContent() { const [isFetchingModels, setIsFetchingModels] = useState(false); const [existingApiKeySecret, setExistingApiKeySecret] = useState(""); const [existingApiKeySecretKey, setExistingApiKeySecretKey] = useState(""); + const [foundryAuthType, setFoundryAuthType] = useState(DEFAULT_FOUNDRY_AUTH_TYPE); const isOllamaSelected = selectedProvider?.type === "Ollama"; useEffect(() => { @@ -246,27 +285,45 @@ function ModelPageContent() { setExistingApiKeySecretKey(modelData.spec.apiKeySecretKey || ""); const spec = modelData.spec; - const fetchedParams: Record = + let fetchedParams: Record = (spec.openAI ?? spec.anthropic ?? spec.azureOpenAI ?? spec.ollama ?? - spec.gemini ?? spec.geminiVertexAI ?? spec.anthropicVertexAI ?? spec.bedrock ?? spec.sapAICore ?? {}) as Record; + spec.foundry ?? spec.gemini ?? spec.geminiVertexAI ?? spec.anthropicVertexAI ?? spec.bedrock ?? spec.sapAICore ?? {}) as Record; + + let effectiveFoundryAuthType = DEFAULT_FOUNDRY_AUTH_TYPE; + if (spec.foundry?.auth) { + effectiveFoundryAuthType = spec.foundry.auth.type; + setFoundryAuthType(effectiveFoundryAuthType); + fetchedParams = { ...spec.foundry } as Record; + if (spec.foundry.auth.workloadIdentity) { + fetchedParams.clientId = spec.foundry.auth.workloadIdentity.clientId; + fetchedParams.tenantId = spec.foundry.auth.workloadIdentity.tenantId; + } + delete fetchedParams.auth; + } if (provider?.type === 'Ollama') { setModelTag(extractedTag || 'latest'); } - const requiredKeys = provider?.requiredParams || []; + const requiredKeys = getProviderRequiredParams(provider || null, effectiveFoundryAuthType); const initialRequired: ModelParam[] = requiredKeys.map((key, index) => { const fetchedValue = fetchedParams[key]; const displayValue = (fetchedValue === null || fetchedValue === undefined) ? "" : String(fetchedValue); return { id: `req-${index}`, key: key, value: displayValue }; }); - const initialOptional: ModelParam[] = Object.entries(fetchedParams) - .filter(([key]) => !requiredKeys.includes(key)) - .map(([key, value], index) => { - const displayValue = (value === null || value === undefined) ? "" : String(value); - return { id: `fetched-opt-${index}`, key, value: displayValue }; - }); + const optionalKeys = getProviderOptionalParams(provider || null, effectiveFoundryAuthType); + const optionalKeysToRender = [ + ...optionalKeys.filter((key) => !requiredKeys.includes(key)), + ...Object.keys(fetchedParams).filter((key) => + !spec.foundry && !requiredKeys.includes(key) && !optionalKeys.includes(key) + ), + ]; + const initialOptional: ModelParam[] = optionalKeysToRender.map((key, index) => { + const value = fetchedParams[key]; + const displayValue = (value === null || value === undefined) ? "" : String(value); + return { id: `fetched-opt-${index}`, key, value: displayValue }; + }); setRequiredParams(initialRequired); setOptionalParams(initialOptional); @@ -347,8 +404,9 @@ function ModelPageContent() { useEffect(() => { if (selectedProvider) { - const requiredKeys = selectedProvider.requiredParams || []; - let optionalKeys = [...(selectedProvider.optionalParams || [])]; + const effectiveFoundryAuthType = DEFAULT_FOUNDRY_AUTH_TYPE; + const requiredKeys = getProviderRequiredParams(selectedProvider, effectiveFoundryAuthType); + let optionalKeys = [...getProviderOptionalParams(selectedProvider, effectiveFoundryAuthType)]; // Add baseUrl to optional params for providers that support it const providersWithBaseUrl = ['OpenAI', 'Anthropic', 'Gemini']; @@ -359,6 +417,9 @@ function ModelPageContent() { const currentModelRequiresReset = !isEditMode; if (currentModelRequiresReset) { + if (selectedProvider.type === 'Foundry') { + setFoundryAuthType(DEFAULT_FOUNDRY_AUTH_TYPE); + } const newRequiredParams = requiredKeys.map((key, index) => ({ id: `req-${index}`, key: key, @@ -373,6 +434,10 @@ function ModelPageContent() { setOptionalParams(newOptionalParams); } + if (!isEditMode) { + setIsApiKeyNeeded(selectedProvider.type !== 'Ollama' && !(selectedProvider.type === 'Foundry' && effectiveFoundryAuthType !== 'APIKey')); + } + setErrors(prev => ({ ...prev, requiredParams: {}, optionalParams: undefined })); } else { @@ -428,8 +493,8 @@ function ModelPageContent() { if (!isResourceNameValid(name)) newErrors.name = "Name must be a valid RFC 1123 subdomain name"; if (!selectedCombinedModel) newErrors.selectedCombinedModel = "Provider and Model selection is required"; - const isOllamaNow = selectedProvider?.type?.toLowerCase() === 'ollama'; - if (!isEditMode && !isOllamaNow && isApiKeyNeeded && !apiKey.trim()) { + const isKeylessProvider = selectedProvider?.type === 'Ollama' || (selectedProvider?.type === 'Foundry' && foundryAuthType !== 'APIKey'); + if (!isEditMode && !isKeylessProvider && isApiKeyNeeded && !apiKey.trim()) { newErrors.apiKey = "API key is required for new models (except for Ollama or when you don't need an API key)"; } @@ -492,6 +557,24 @@ function ModelPageContent() { } }; + const handleFoundryAuthTypeChange = (nextAuthType: FoundryAuthType) => { + setFoundryAuthType(nextAuthType); + setIsApiKeyNeeded(nextAuthType === "APIKey"); + if (nextAuthType !== "APIKey") { + setApiKey(""); + setErrors(prev => ({ ...prev, apiKey: undefined })); + } + if (selectedProvider?.type !== 'Foundry') { + return; + } + const values = modelParamValues(requiredParams, optionalParams); + const requiredKeys = getProviderRequiredParams(selectedProvider, nextAuthType); + const optionalKeys = getProviderOptionalParams(selectedProvider, nextAuthType); + setRequiredParams(modelParamsForKeys(requiredKeys, "req", values)); + setOptionalParams(modelParamsForKeys(optionalKeys, "opt", values)); + setErrors(prev => ({ ...prev, requiredParams: {}, optionalParams: undefined })); + }; + const handleFetchModels = async () => { setIsFetchingModels(true); try { @@ -612,6 +695,32 @@ function ModelPageContent() { case 'SAPAICore': spec.sapAICore = providerParams as SAPAICoreConfigPayload; break; + case 'Foundry': { + const { clientId, tenantId, ...foundryParams } = providerParams; + const auth: FoundryConfig["auth"] = { type: foundryAuthType }; + if (foundryAuthType === "WorkloadIdentity") { + const workloadIdentity: NonNullable = {}; + if (typeof clientId === 'string') { + workloadIdentity.clientId = clientId; + } + if (typeof tenantId === 'string') { + workloadIdentity.tenantId = tenantId; + } + auth.workloadIdentity = workloadIdentity; + } + if (foundryAuthType === "APIKeyPassthrough") { + spec.apiKeyPassthrough = true; + } + if (foundryAuthType !== "APIKey") { + delete spec.apiKeySecret; + delete spec.apiKeySecretKey; + } + spec.foundry = { + ...(foundryParams as Omit), + auth, + }; + break; + } default: console.error("Unsupported provider type during payload construction:", providerType); toast.error("Internal error: Unsupported provider type."); @@ -748,6 +857,8 @@ function ModelPageContent() { selectedProvider={selectedProvider} isApiKeyNeeded={isApiKeyNeeded} onApiKeyNeededChange={setIsApiKeyNeeded} + foundryAuthType={foundryAuthType} + onFoundryAuthTypeChange={handleFoundryAuthTypeChange} /> {selectedProvider && selectedCombinedModel && ( diff --git a/ui/src/components/ModelProviderCombobox.tsx b/ui/src/components/ModelProviderCombobox.tsx index 26da4affc4..eb366672e1 100644 --- a/ui/src/components/ModelProviderCombobox.tsx +++ b/ui/src/components/ModelProviderCombobox.tsx @@ -68,6 +68,7 @@ export function ModelProviderCombobox({ 'AnthropicVertexAI': Anthropic, 'Bedrock': Bedrock, 'SAPAICore': SAPAICore, + 'Foundry': Azure, }; if (!providerKey || !PROVIDER_ICONS[providerKey]) { return null; diff --git a/ui/src/components/ProviderCombobox.tsx b/ui/src/components/ProviderCombobox.tsx index 4202ac0313..7349fb6fef 100644 --- a/ui/src/components/ProviderCombobox.tsx +++ b/ui/src/components/ProviderCombobox.tsx @@ -24,6 +24,7 @@ const PROVIDER_ICONS: Record void; + foundryAuthType: FoundryAuthType; + onFoundryAuthTypeChange: (authType: FoundryAuthType) => void; } export const AuthSection: React.FC = ({ isOllamaSelected, isEditMode, apiKey, showApiKey, errors, isSubmitting, isLoading, onApiKeyChange, onToggleShowApiKey, selectedProvider, - isApiKeyNeeded, onApiKeyNeededChange + isApiKeyNeeded, onApiKeyNeededChange, foundryAuthType, onFoundryAuthTypeChange }) => { + const isFoundrySelected = selectedProvider?.type === "Foundry"; + const showApiKeyInput = !isOllamaSelected && (!isFoundrySelected || foundryAuthType === "APIKey"); + return ( Authentication - {!isOllamaSelected ? ( + {isFoundrySelected && ( +
+ + +
+ )} + + {showApiKeyInput ? (
{errors.apiKey &&

{errors.apiKey}

} -
+ {!isFoundrySelected &&
= ({ > I don't need to provide an API key -
+
} ) : (
- Ollama models run locally and do not require an API key. + {isFoundrySelected ? "No API key will be stored for this auth mode." : "Ollama models run locally and do not require an API key."}
)}
diff --git a/ui/src/lib/providers.ts b/ui/src/lib/providers.ts index 8bcf889ca5..e3f7d1448f 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"; -export const modelProviders = ["OpenAI", "AzureOpenAI", "Anthropic", "Ollama", "Gemini", "GeminiVertexAI", "AnthropicVertexAI", "Bedrock", "SAPAICore"] as const; +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 ModelProviderKey = typeof modelProviders[number]; @@ -76,6 +76,13 @@ export const PROVIDERS_INFO: { modelDocsLink: "https://help.sap.com/docs/sap-ai-core/sap-ai-core-service-guide/models-and-scenarios-in-generative-ai-hub", help: "Create a K8s Secret with client_id and client_secret from your SAP AI Core service key." }, + Foundry: { + name: "Foundry", + type: "Foundry", + apiKeyLink: null, + modelDocsLink: "https://learn.microsoft.com/azure/ai-foundry/", + help: "Use an Azure AI Services account endpoint and deployment with Entra workload identity." + }, }; export const isValidProviderInfoKey = (key: string): key is ModelProviderKey => { diff --git a/ui/src/types/index.ts b/ui/src/types/index.ts index 7a6ee41841..c26b21abef 100644 --- a/ui/src/types/index.ts +++ b/ui/src/types/index.ts @@ -77,6 +77,22 @@ export interface BedrockConfig { region: string; } +export interface FoundryConfig { + endpoint?: string; + endpointFrom?: Record; + deployment: string; + apiVersion?: string; + auth: { + type: "APIKey" | "WorkloadIdentity" | "APIKeyPassthrough"; + workloadIdentity?: { + clientId?: string; + clientIdFrom?: Record; + tenantId?: string; + tenantIdFrom?: Record; + }; + }; +} + export interface TLSConfig { disableVerify?: boolean; caCertSecretRef?: string; @@ -101,6 +117,7 @@ export interface ModelConfigSpec { anthropicVertexAI?: AnthropicVertexAIConfig; bedrock?: BedrockConfig; sapAICore?: SAPAICoreConfigPayload; + foundry?: FoundryConfig; } export interface ModelConfig {