| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269 |
- /*
- Copyright 2026 LocoStack.
- Licensed under the Apache License, Version 2.0 (the "License");
- you may not use this file except in compliance with the License.
- You may obtain a copy of the License at
- http://www.apache.org/licenses/LICENSE-2.0
- Unless required by applicable law or agreed to in writing, software
- distributed under the License is distributed on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- See the License for the specific language governing permissions and
- limitations under the License.
- */
- package reconciler
- import (
- "context"
- "crypto/sha256"
- "encoding/hex"
- "encoding/json"
- "fmt"
- "maps"
- "sort"
- "strings"
- "github.com/LocoStack/loco-operator/api/v1alpha1"
- "github.com/LocoStack/loco-operator/pkg/templates/litellm"
- "go.yaml.in/yaml/v2"
- corev1 "k8s.io/api/core/v1"
- metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
- "k8s.io/apimachinery/pkg/runtime"
- "sigs.k8s.io/controller-runtime/pkg/client"
- "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
- )
- type LocoAgentsReconciler struct {
- *DefaultComponentReconciler
- client client.Client
- scheme *runtime.Scheme
- stack *v1alpha1.Stack
- component *v1alpha1.Component
- }
- func NewLocoAgentsReconciler(client client.Client, scheme *runtime.Scheme, stack *v1alpha1.Stack, component *v1alpha1.Component) *LocoAgentsReconciler {
- return &LocoAgentsReconciler{
- DefaultComponentReconciler: NewDefaultComponentReconciler(client, scheme, stack, component),
- client: client,
- scheme: scheme,
- stack: stack,
- component: component,
- }
- }
- func (r *LocoAgentsReconciler) ReconcileComponent(ctx context.Context, tmpl *v1alpha1.Template, variables map[string]string) ([]client.Object, error) {
- endpointResolver := NewEndpointResolver(r.client, r.scheme, r.stack.Namespace)
- var modelEndpoint string
- var modelName string
- toolEndpoints := make(map[string]string)
- toolNames := make([]string, 0)
- kbEndpoints := make(map[string]string)
- deps := make([]client.Object, 0)
- for _, dep := range r.component.Spec.Dependencies {
- obj, endpoint, err := endpointResolver.ResolveEndpoint(ctx, r.stack, dep)
- if err != nil {
- return nil, fmt.Errorf("Failed to resolve endpoint for %s %s: %w", dep.Kind, dep.Name, err)
- }
- switch dep.Kind {
- case "ExternalModel":
- fallthrough
- case "ManagedModel":
- var category string
- if extModel, ok := obj.(*v1alpha1.ExternalModel); ok {
- category = extModel.Spec.Category
- modelName = extModel.Spec.ModelName
- } else if managedModel, ok := obj.(*v1alpha1.ManagedModel); ok {
- category = managedModel.Spec.Category
- modelName = managedModel.Spec.ModelName
- } else {
- return nil, fmt.Errorf("Unsupported dependency kind for agent: %s", dep.Kind)
- }
- if category != "language" {
- return nil, fmt.Errorf("Unsupported model category for agent: %s", category)
- }
- modelEndpoint = endpoint
- case "ExternalTool":
- fallthrough
- case "ManagedTool":
- toolEndpoints[dep.Name] = endpoint
- if extTool, ok := obj.(*v1alpha1.ExternalTool); ok {
- toolNames = append(toolNames, extTool.Spec.ToolName)
- } else if managedTool, ok := obj.(*v1alpha1.ManagedTool); ok {
- toolNames = append(toolNames, managedTool.Spec.ToolName)
- }
- case "ExternalKnowledgeBase":
- fallthrough
- case "ManagedKnowledgeBase":
- kbEndpoints[dep.Name] = endpoint
- default:
- return nil, fmt.Errorf("Unsupported dependency kind for agent: %s", dep.Kind)
- }
- deps = append(deps, obj)
- }
- gatewayComp, err := endpointResolver.resolveGatewayEndpoint(ctx, r.stack)
- if err != nil {
- return nil, fmt.Errorf("Failed to resolve gateway for %s: %w", r.component.Name, err)
- }
- masterKey, err := r.gatewayMasterKey(ctx, gatewayComp)
- if err != nil {
- return nil, fmt.Errorf("resolving gateway master key: %w", err)
- }
- agentConfigHash, err := r.reconcileAgentConfig(ctx, r.component.Spec.Variables["systemPrompt"], modelEndpoint, masterKey, modelName, kbEndpoints)
- if err != nil {
- return nil, fmt.Errorf("Failed to reconcile config for %s: %w", r.component.Name, err)
- }
- mcpConfigHash, err := r.reconcileMcpConfig(ctx, gatewayComp.Status.Endpoint, masterKey, toolNames, toolEndpoints)
- if err != nil {
- return nil, fmt.Errorf("Failed to reconcile MCP config for %s: %w", r.component.Name, err)
- }
- h := sha256.Sum256([]byte(agentConfigHash + mcpConfigHash))
- configHash := hex.EncodeToString(h[:])
- if tmpl.Metadata.Annotations == nil {
- tmpl.Metadata.Annotations = make(map[string]string)
- }
- maps.Copy(tmpl.Metadata.Annotations, map[string]string{
- "locostack.com/configHash": configHash,
- })
- if _, err := r.DefaultComponentReconciler.ReconcileComponent(ctx, tmpl, variables); err != nil {
- return nil, err
- }
- return deps, nil
- }
- type modelConfig struct {
- Endpoint string `yaml:"endpoint"`
- APIKey string `yaml:"apiKey,omitempty"`
- Name string `yaml:"name,omitempty"`
- }
- type endpoint struct {
- Name string `json:"name"`
- Endpoint string `json:"endpoint"`
- }
- type agentConfig struct {
- SystemPrompt string `yaml:"systemPrompt"`
- Model modelConfig `yaml:"model"`
- KnowledgeBases []endpoint `yaml:"knowledgeBases,omitempty"`
- MCPConfigPath string `yaml:"mcpConfigPath,omitempty"`
- }
- func (r *LocoAgentsReconciler) reconcileAgentConfig(ctx context.Context, systemPrompt string, modelEndpoint, modelAPIKey, modelName string, knowledgeBases map[string]string) (string, error) {
- config := agentConfig{
- SystemPrompt: systemPrompt,
- Model: modelConfig{
- Endpoint: modelEndpoint,
- APIKey: modelAPIKey,
- Name: modelName,
- },
- MCPConfigPath: "/etc/mcp/mcp.json",
- }
- kbNames := make([]string, 0, len(knowledgeBases))
- for kbName := range knowledgeBases {
- kbNames = append(kbNames, kbName)
- }
- sort.Strings(kbNames)
- for _, kbName := range kbNames {
- config.KnowledgeBases = append(config.KnowledgeBases, endpoint{
- Name: kbName,
- Endpoint: knowledgeBases[kbName],
- })
- }
- yamlBytes, err := yaml.Marshal(config)
- h := sha256.Sum256(yamlBytes)
- configHash := hex.EncodeToString(h[:])
- if err != nil {
- return "", fmt.Errorf("marshaling agent config: %w", err)
- }
- secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: r.ResourceName("config"), Namespace: r.component.GetNamespace()}}
- _, err = controllerutil.CreateOrUpdate(ctx, r.client, secret, func() error {
- if secret.Labels == nil {
- secret.Labels = map[string]string{}
- }
- maps.Copy(secret.Labels, r.ResourceLabels())
- if secret.Annotations == nil {
- secret.Annotations = make(map[string]string)
- }
- secret.Annotations["hash"] = configHash
- secret.Data = map[string][]byte{
- "agent.yaml": yamlBytes,
- }
- return controllerutil.SetControllerReference(r.component, secret, r.scheme)
- })
- return configHash, err
- }
- type mcpConfig struct {
- MCPServers map[string]mcpServer `json:"mcpServers"`
- }
- type mcpServer struct {
- URL string `json:"url"`
- Headers map[string]string `json:"headers,omitempty"`
- }
- // reconcileMcpConfig writes the agent's mcp.json as a Secret (not a ConfigMap): when a
- // gateway is present, the generated config embeds the resolved litellm master key value
- // in the auth header, so the credential must not land in a ConfigMap.
- func (r *LocoAgentsReconciler) reconcileMcpConfig(ctx context.Context, gatewayEndpoint, masterKey string, toolNames []string, toolEndpoints map[string]string) (string, error) {
- cfg := mcpConfig{MCPServers: make(map[string]mcpServer)}
- if gatewayEndpoint != "" && len(toolNames) > 0 {
- cfg.MCPServers["litellm"] = mcpServer{
- URL: fmt.Sprintf("%s/%s/mcp", gatewayEndpoint, strings.Join(toolNames, ",")),
- Headers: map[string]string{
- litellm.MCPAuthHeaderName: "Bearer " + masterKey,
- },
- }
- } else {
- for toolName, toolEndpoint := range toolEndpoints {
- cfg.MCPServers[toolName] = mcpServer{URL: toolEndpoint}
- }
- }
- jsonBytes, err := json.Marshal(cfg)
- if err != nil {
- return "", fmt.Errorf("marshaling agent mcp config: %w", err)
- }
- h := sha256.Sum256(jsonBytes)
- configHash := hex.EncodeToString(h[:])
- secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: r.ResourceName("mcp-config"), Namespace: r.component.GetNamespace()}}
- _, err = controllerutil.CreateOrUpdate(ctx, r.client, secret, func() error {
- if secret.Labels == nil {
- secret.Labels = map[string]string{}
- }
- maps.Copy(secret.Labels, r.ResourceLabels())
- if secret.Annotations == nil {
- secret.Annotations = make(map[string]string)
- }
- secret.Annotations["hash"] = configHash
- secret.Data = map[string][]byte{
- "mcp.json": jsonBytes,
- }
- return controllerutil.SetControllerReference(r.component, secret, r.scheme)
- })
- return configHash, err
- }
- func (r *LocoAgentsReconciler) gatewayMasterKey(ctx context.Context, gatewayComp *v1alpha1.Component) (string, error) {
- if gatewayComp == nil {
- return "", nil
- }
- secret := &corev1.Secret{}
- secretName := litellm.MasterKeySecretName(gatewayComp.Name)
- if err := r.client.Get(ctx, client.ObjectKey{Name: secretName, Namespace: r.component.GetNamespace()}, secret); err != nil {
- return "", fmt.Errorf("Failed to get gateway master key secret %s: %w", secretName, err)
- }
- key, ok := secret.Data[litellm.LITELLM_AUTH_SECRET_KEY]
- if !ok {
- return "", fmt.Errorf("Secret %s does not contain key %q", secretName, litellm.LITELLM_AUTH_SECRET_KEY)
- }
- return string(key), nil
- }
|