Explorar o código

feat(template): add LiteLLM template

Thomas Zhang hai 3 meses
pai
achega
17841e7f4b

+ 6 - 1
internal/controller/component_controller.go

@@ -158,7 +158,12 @@ func (r *ComponentReconciler) Reconcile(ctx context.Context, req ctrl.Request) (
 }
 
 func (r *ComponentReconciler) getComponentReconciler(comp *v1alpha1.Component, stack *v1alpha1.Stack) reconciler.ComponentReconciler {
-	return reconciler.NewDefaultComponentReconciler(r.Client, r.Scheme, stack, comp)
+	switch comp.Spec.Template.Name {
+	case "litellm":
+		return reconciler.NewLiteLLMReconciler(r.Client, r.Scheme, stack, comp)
+	default:
+		return reconciler.NewDefaultComponentReconciler(r.Client, r.Scheme, stack, comp)
+	}
 }
 
 // SetupWithManager sets up the controller with the Manager.

+ 123 - 0
internal/reconciler/litellm.go

@@ -0,0 +1,123 @@
+/*
+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/rand"
+	"crypto/sha256"
+	"encoding/hex"
+	"fmt"
+	"maps"
+
+	"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 LiteLLMReconciler struct {
+	*DefaultComponentReconciler
+	client    client.Client
+	scheme    *runtime.Scheme
+	stack     *v1alpha1.Stack
+	component *v1alpha1.Component
+}
+
+func NewLiteLLMReconciler(client client.Client, scheme *runtime.Scheme, stack *v1alpha1.Stack, component *v1alpha1.Component) *LiteLLMReconciler {
+	return &LiteLLMReconciler{
+		DefaultComponentReconciler: NewDefaultComponentReconciler(client, scheme, stack, component),
+		client:                     client,
+		scheme:                     scheme,
+		stack:                      stack,
+		component:                  component,
+	}
+}
+
+func (r *LiteLLMReconciler) ReconcileComponent(ctx context.Context, tmpl *v1alpha1.Template, variables map[string]string) ([]client.Object, error) {
+	if err := r.ReconcileKey(ctx, "litellm", litellm.LITELLM_AUTH_SECRET_KEY, generateKey); err != nil {
+		return nil, fmt.Errorf("Failed to reconcile LiteLLM master key: %w", err)
+	}
+	configHash, err := r.reconcileConfigMap(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("Failed to reconcile LiteLLM config: %w", err)
+	}
+	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 nil, nil
+}
+
+func (r *LiteLLMReconciler) reconcileConfigMap(ctx context.Context) (string, error) {
+	var o11yComp *v1alpha1.Component
+	if r.stack.Spec.Observability != nil && r.stack.Spec.Observability.Enabled {
+		o11yComp = &v1alpha1.Component{}
+		if err := r.client.Get(ctx, client.ObjectKey{Name: fmt.Sprintf("observability-%s", r.stack.Name), Namespace: r.stack.Namespace}, o11yComp); err != nil {
+			return "", fmt.Errorf("Failed to get Observability component: %w", err)
+		}
+	}
+	configBuilder := litellm.LiteLLMConfigBuilder{
+		MasterKeyEnvName:       litellm.LITELLM_MASTER_KEY_ENV_NAME,
+		Stack:                  r.stack,
+		ObservabilityComponent: o11yComp,
+	}
+	config, err := configBuilder.BuildLiteLLMConfig()
+	if err != nil {
+		return "", fmt.Errorf("Failed to build LiteLLM config: %w", err)
+	}
+	configData, err := yaml.Marshal(config)
+	if err != nil {
+		return "", fmt.Errorf("Failed to marshal LiteLLM config: %w", err)
+	}
+	configDataStr := string(configData)
+	h := sha256.Sum256([]byte(configDataStr))
+	configHash := hex.EncodeToString(h[:])
+	cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: r.ResourceName("config"), Namespace: r.component.GetNamespace()}}
+	if _, err := controllerutil.CreateOrUpdate(ctx, r.client, cm, func() error {
+		if cm.Labels == nil {
+			cm.Labels = map[string]string{}
+		}
+		maps.Copy(cm.Labels, r.ResourceLabels())
+		if cm.Annotations == nil {
+			cm.Annotations = make(map[string]string)
+		}
+		cm.Annotations["hash"] = configHash
+		cm.Data = map[string]string{litellm.LITELLM_CONFIG_KEY: configDataStr}
+		return controllerutil.SetControllerReference(r.component, cm, r.scheme)
+	}); err != nil {
+		return "", err
+	}
+	return configHash, nil
+}
+
+func generateKey() (string, error) {
+	b := make([]byte, 24)
+	if _, err := rand.Read(b); err != nil {
+		return "", err
+	}
+	return "sk-" + hex.EncodeToString(b), nil
+}

+ 120 - 0
pkg/templates/litellm/config.go

@@ -0,0 +1,120 @@
+/*
+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 litellm
+
+import "github.com/LocoStack/loco-operator/api/v1alpha1"
+
+type LiteLLMConfig struct {
+	ModelList       []modelEntry              `yaml:"model_list"`
+	MCPServers      map[string]mcpServerEntry `yaml:"mcp_servers,omitempty"`
+	LiteLLMSettings *liteLLMSettings          `yaml:"litellm_settings,omitempty"`
+	GeneralSettings *liteLLMGeneralSettings   `yaml:"general_settings,omitempty"`
+	EnvVars         map[string]string         `yaml:"environment_variables,omitempty"`
+}
+
+type modelEntry struct {
+	ModelName     string         `yaml:"model_name"`
+	LiteLLMParams map[string]any `yaml:"litellm_params"`
+}
+
+type mcpServerEntry struct {
+	// url is the HTTP/SSE endpoint for sse and http transports.
+	URL string `yaml:"url,omitempty"`
+	// transport selects the MCP transport: sse (default), http, or stdio.
+	Transport string `yaml:"transport,omitempty"`
+
+	// --- authentication ---
+
+	// authType selects the auth method: api_key, bearer_token, basic,
+	// authorization, oauth2, or aws_sigv4.
+	AuthType string `yaml:"auth_type,omitempty"`
+	// authValue is the credential for api_key, bearer_token, basic, and
+	// authorization auth types. May reference an env var via os.environ/VAR.
+	AuthValue string `yaml:"auth_value,omitempty"`
+
+	// --- headers ---
+
+	// staticHeaders are key-value pairs sent with every request to this server.
+	StaticHeaders map[string]string `yaml:"static_headers,omitempty"`
+	// extraHeaders lists client request header names that LiteLLM should
+	// forward to this MCP server.
+	ExtraHeaders []string `yaml:"extra_headers,omitempty"`
+
+	// allowAllKeys grants every LiteLLM API key access to this server when true.
+	AllowAllKeys bool `yaml:"allow_all_keys,omitempty"`
+}
+
+type liteLLMSettings struct {
+	SuccessCallback []string `yaml:"success_callback,omitempty"`
+	FailureCallback []string `yaml:"failure_callback,omitempty"`
+	JSONLogs        bool     `yaml:"json_logs,omitempty"`
+}
+
+type liteLLMGeneralSettings struct {
+	MasterKey string `yaml:"master_key,omitempty"`
+}
+
+type LiteLLMConfigBuilder struct {
+	MasterKeyEnvName       string
+	Stack                  *v1alpha1.Stack
+	ObservabilityComponent *v1alpha1.Component
+}
+
+func (l *LiteLLMConfigBuilder) BuildLiteLLMConfig() (LiteLLMConfig, error) {
+	phoenixEnabled := phoenixEnabled(l.Stack)
+	successCB := []string{}
+	failureCB := []string{}
+	if phoenixEnabled {
+		successCB = append(successCB, "arize_phoenix")
+		failureCB = append(failureCB, "arize_phoenix")
+	}
+	cfg := LiteLLMConfig{
+		ModelList:  []modelEntry{},
+		MCPServers: map[string]mcpServerEntry{},
+		LiteLLMSettings: &liteLLMSettings{
+			SuccessCallback: successCB,
+			FailureCallback: failureCB,
+			JSONLogs:        true,
+		},
+		GeneralSettings: &liteLLMGeneralSettings{
+			MasterKey: "os.environ/" + l.MasterKeyEnvName,
+		},
+	}
+	if phoenixEnabled {
+		cfg.EnvVars = map[string]string{
+			"PHOENIX_PROJECT_NAME":            "locostack",
+			"PHOENIX_COLLECTOR_HTTP_ENDPOINT": l.ObservabilityComponent.Status.Endpoint + "/v1/traces",
+		}
+	}
+	return cfg, nil
+}
+
+func phoenixEnabled(stack *v1alpha1.Stack) bool {
+	if stack == nil {
+		return false
+	}
+	if stack.Spec.Observability == nil {
+		return false
+	}
+	if !stack.Spec.Observability.Enabled {
+		return false
+	}
+	if stack.Spec.Observability.Template != nil && stack.Spec.Observability.Template.Name != "phoenix" {
+		return false
+	}
+	return true
+}

+ 79 - 0
pkg/templates/litellm/template.go

@@ -0,0 +1,79 @@
+/*
+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 litellm
+
+import (
+	"fmt"
+
+	"github.com/LocoStack/loco-operator/api/v1alpha1"
+	corev1 "k8s.io/api/core/v1"
+)
+
+const (
+	LITELLM_MASTER_KEY_ENV_NAME = "MASTER_KEY"
+	LITELLM_AUTH_SECRET_KEY     = "key"
+	LITELLM_CONFIG_KEY          = "config.yaml"
+
+	// MCPAuthHeaderName is the header litellm's MCP gateway expects the caller's
+	// key in, per https://docs.litellm.ai/docs/mcp_control#method-1-url-based-namespacing.
+	MCPAuthHeaderName = "x-litellm-api-key"
+)
+
+// MasterKeySecretName returns the name of the Secret holding a gateway component's
+// litellm master key, as created by ReconcileKey(ctx, "litellm", LITELLM_AUTH_SECRET_KEY, ...).
+func MasterKeySecretName(gatewayComponentName string) string {
+	return gatewayComponentName + "-litellm"
+}
+
+var LiteLLMTemplate = v1alpha1.Template{
+	Name: "litellm",
+	Spec: v1alpha1.TemplateSpec{
+		Runtime: v1alpha1.RuntimeSpec{
+			Name:    "litellm",
+			Image:   "ghcr.io/berriai/litellm:v1.87.1",
+			Command: []string{"litellm"},
+			Args:    []string{"--port", "$(spec.runtime.port)"},
+			Env: []corev1.EnvVar{
+				{Name: "CONFIG_FILE_PATH", Value: fmt.Sprintf("/etc/litellm/%s", LITELLM_CONFIG_KEY)},
+				{Name: LITELLM_MASTER_KEY_ENV_NAME, ValueFrom: &corev1.EnvVarSource{
+					SecretKeyRef: &corev1.SecretKeySelector{
+						LocalObjectReference: corev1.LocalObjectReference{Name: "$(metadata.name)-litellm"},
+						Key:                  LITELLM_AUTH_SECRET_KEY,
+					},
+				}},
+			},
+			VolumeMounts: []corev1.VolumeMount{
+				{
+					Name:      "config",
+					MountPath: "/etc/litellm",
+					ReadOnly:  true,
+				},
+			},
+			Port: 4000,
+		},
+		Volumes: []corev1.Volume{
+			{
+				Name: "config",
+				VolumeSource: corev1.VolumeSource{
+					ConfigMap: &corev1.ConfigMapVolumeSource{
+						LocalObjectReference: corev1.LocalObjectReference{Name: "$(metadata.name)-config"},
+					},
+				},
+			},
+		},
+	},
+}

+ 6 - 0
pkg/templates/templates.go

@@ -22,6 +22,7 @@ import (
 	"slices"
 
 	"github.com/LocoStack/loco-operator/api/v1alpha1"
+	"github.com/LocoStack/loco-operator/pkg/templates/litellm"
 	corev1 "k8s.io/api/core/v1"
 )
 
@@ -235,3 +236,8 @@ func deepCopyTopologySpreadConstraints(in []corev1.TopologySpreadConstraint) []c
 }
 
 var Manager = NewTemplateManager()
+
+func init() {
+	Manager.RegisterTemplate(litellm.LiteLLMTemplate)
+	Manager.RegisterRuntime(litellm.LiteLLMTemplate.Spec.Runtime)
+}