Repository: substratusai/kubeai
Pull request #440 (2025-03-04 07:09:13)

add goatcounter analytics to kubeai.org website
Privacy-aware; doesn’t track users with unique identifiers and doesn't need a GDPR notice.


Patch:
diff --git a/Dockerfile b/Dockerfile
index 9c3c7cf..d5cdf1f 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,5 @@
 # Build the manager binary
-FROM golang:1.22 AS builder
+FROM golang:1.22.0 AS builder
 ARG TARGETOS
 ARG TARGETARCH
 
diff --git a/Makefile b/Makefile
index 2450d40..88aa72b 100644
--- a/Makefile
+++ b/Makefile
@@ -71,9 +71,10 @@ vet: ## Run go vet against code.
 test-unit: fmt vet
 	go test -v ./internal/... -coverprofile cover.unit.out
 
+# Use RUN=TestName to run specific integration tests.
 .PHONY: test-integration
 test-integration: fmt vet envtest
-	KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test -v ./test/integration -coverprofile cover.integration.out
+	KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test -v ./test/integration $(if $(RUN),-run $(RUN),) -coverprofile cover.integration.out
 
 .PHONY: helm-dependency-build
 helm-dependency-build:
@@ -112,6 +113,10 @@ test-e2e-engine-vllm-pvc: skaffold helm-dependency-build
 test-e2e-engine: skaffold helm-dependency-build
 	CACHE_PROFILE=$(CACHE_PROFILE) ./test/e2e/run.sh engine-$(ENGINE) --profile e2e-test-default
 
+.PHONY: test-e2e-model-files
+test-e2e-model-files: skaffold helm-dependency-build
+	./test/e2e/run.sh model-files --profile e2e-test-default
+
 .PHONY: lint
 lint: golangci-lint ## Run golangci-lint linter
 	$(GOLANGCI_LINT) run
diff --git a/api/v1/model_types.go b/api/v1/model_types.go
index 41297f9..9d30014 100644
--- a/api/v1/model_types.go
+++ b/api/v1/model_types.go
@@ -20,6 +20,8 @@ import (
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 )
 
+// Use "+NOTE: ..." comments to add notes to the code that wont show up in public API reference or Custom Resource Definition.
+
 // ModelSpec defines the desired state of Model.
 // +kubebuilder:validation:XValidation:rule="!has(self.cacheProfile) || self.url.startsWith(\"hf://\") || self.url.startsWith(\"s3://\") || self.url.startsWith(\"gs://\") || self.url.startsWith(\"oss://\")", message="cacheProfile is only supported with urls of format \"hf://...\", \"s3://...\", \"gs://...\", or \"oss://...\" at the moment."
 // +kubebuilder:validation:XValidation:rule="!self.url.startsWith(\"s3://\") || has(self.cacheProfile)", message="urls of format \"s3://...\" only supported when using a cacheProfile"
@@ -28,6 +30,9 @@ import (
 // +kubebuilder:validation:XValidation:rule="!has(self.maxReplicas) || self.minReplicas <= self.maxReplicas", message="minReplicas should be less than or equal to maxReplicas."
 // +kubebuilder:validation:XValidation:rule="!has(self.adapters) || self.engine == \"VLLM\"", message="adapters only supported with VLLM engine."
 // +kubebuilder:validation:XValidation:rule="!has(oldSelf.cacheProfile) || self.url == oldSelf.url", message="url is immutable when using cacheProfile."
+// +NOTE: The self.files.all() check is considered "costly" by the Kubernetes API server and will be rejected if the number of files (and length of .path) are not restricted. These restrictions are applied in field-based validations below.
+// +kubebuilder:validation:XValidation:rule="!has(self.files) || self.files.size() <= 1 || !self.files.exists(f, self.files.filter(other, other.path == f.path).size() > 1)", message="All file paths must be unique."
+// +TODO: Limits on total file size should be less than limit of total ConfigMap (1MiB) data, this fails in version 1.29 (for exceeding "cost"): "!has(self.files) || self.files.map(f, size(f.content)).sum() <= 500000"
 type ModelSpec struct {
 	// URL of the model to be served.
 	// Currently the following formats are supported:
@@ -122,6 +127,10 @@ type ModelSpec struct {
 	// If not specified, a default is used based on the engine and request.
 	// +kubebuilder:default={}
 	LoadBalancing LoadBalancing `json:"loadBalancing,omitempty"`
+
+	// Files to be mounted in the model Pods.
+	// +kubebuilder:validation:MaxItems=10
+	Files []File `json:"files,omitempty"`
 }
 
 // +kubebuilder:validation:Enum=TextGeneration;TextEmbedding;SpeechToText
@@ -189,6 +198,22 @@ type PrefixHash struct {
 	PrefixCharLength int `json:"prefixCharLength,omitempty"`
 }
 
+// File represents a file to be mounted in the model pod.
+type File struct {
+	// Path where the file should be mounted in the pod.
+	// Must be an absolute path.
+	// +kubebuilder:validation:Required
+	// +kubebuilder:validation:XValidation:rule="self.startsWith('/') && !self.contains(':')", message="Path must be an absolute path, starting with /, and must not contain a ':' character."
+	// +kubebuilder:validation:MaxLength=1024
+	Path string `json:"path"`
+
+	// Content of the file to be mounted.
+	// Will be injected into a ConfigMap and mounted in the model Pods.
+	// +kubebuilder:validation:Required
+	// +kubebuilder:validation:MaxLength=100000
+	Content string `json:"content"`
+}
+
 // ModelStatus defines the observed state of Model.
 type ModelStatus struct {
 	Replicas ModelStatusReplicas `json:"replicas,omitempty"`
diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go
index 1c7c57f..47da9d7 100644
--- a/api/v1/zz_generated.deepcopy.go
+++ b/api/v1/zz_generated.deepcopy.go
@@ -39,6 +39,21 @@ func (in *Adapter) DeepCopy() *Adapter {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *File) DeepCopyInto(out *File) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new File.
+func (in *File) DeepCopy() *File {
+	if in == nil {
+		return nil
+	}
+	out := new(File)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *LoadBalancing) DeepCopyInto(out *LoadBalancing) {
 	*out = *in
@@ -160,6 +175,11 @@ func (in *ModelSpec) DeepCopyInto(out *ModelSpec) {
 		**out = **in
 	}
 	out.LoadBalancing = in.LoadBalancing
+	if in.Files != nil {
+		in, out := &in.Files, &out.Files
+		*out = make([]File, len(*in))
+		copy(*out, *in)
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ModelSpec.
diff --git a/charts/.gitignore b/charts/.gitignore
new file mode 100644
index 0000000..ee3892e
--- /dev/null
+++ b/charts/.gitignore
@@ -0,0 +1,1 @@
+charts/
diff --git a/charts/kubeai/templates/crds/kubeai.org_models.yaml b/charts/kubeai/templates/crds/kubeai.org_models.yaml
index 02790ee..d876027 100644
--- a/charts/kubeai/templates/crds/kubeai.org_models.yaml
+++ b/charts/kubeai/templates/crds/kubeai.org_models.yaml
@@ -101,6 +101,33 @@ spec:
                   - SpeechToText
                   type: string
                 type: array
+              files:
+                description: Files to be mounted in the model Pods.
+                items:
+                  description: File represents a file to be mounted in the model pod.
+                  properties:
+                    content:
+                      description: |-
+                        Content of the file to be mounted.
+                        Will be injected into a ConfigMap and mounted in the model Pods.
+                      maxLength: 100000
+                      type: string
+                    path:
+                      description: |-
+                        Path where the file should be mounted in the pod.
+                        Must be an absolute path.
+                      maxLength: 1024
+                      type: string
+                      x-kubernetes-validations:
+                      - message: Path must be an absolute path, starting with /, and
+                          must not contain a ':' character.
+                        rule: self.startsWith('/') && !self.contains(':')
+                  required:
+                  - content
+                  - path
+                  type: object
+                maxItems: 10
+                type: array
               image:
                 description: |-
                   Image to be used for the server process.
@@ -246,6 +273,9 @@ spec:
               rule: '!has(self.adapters) || self.engine == "VLLM"'
             - message: url is immutable when using cacheProfile.
               rule: '!has(oldSelf.cacheProfile) || self.url == oldSelf.url'
+            - message: All file paths must be unique.
+              rule: '!has(self.files) || self.files.size() <= 1 || !self.files.exists(f,
+                self.files.filter(other, other.path == f.path).size() > 1)'
           status:
             description: ModelStatus defines the observed state of Model.
             properties:
diff --git a/charts/models/templates/models.yaml b/charts/models/templates/models.yaml
index c6ca28d..4f91cfc 100644
--- a/charts/models/templates/models.yaml
+++ b/charts/models/templates/models.yaml
@@ -48,5 +48,9 @@ spec:
   {{- with $model.cacheProfile }}
   cacheProfile: {{ . }}
   {{- end}}
+  {{- with $model.files }}
+  files:
+  {{- toYaml . | nindent 4 }}
+  {{- end }}
 {{- end}}
 {{- end}}
\ No newline at end of file
diff --git a/charts/models/values.yaml b/charts/models/values.yaml
index e4abc79..242d4eb 100644
--- a/charts/models/values.yaml
+++ b/charts/models/values.yaml
@@ -523,6 +523,11 @@ catalog:
     engine: VLLM
     # TODO: Adjust - the memory associated with this request is too low.
     resourceProfile: cpu:1
+    args:
+    - --chat-template=/config/chat-template.jinja
+    files:
+    - path: "/config/chat-template.jinja"
+      content: "{% for message in messages %}\n{% if message['role'] == 'user' %}\n{{ 'Question:\n' + message['content'] + '\n\n' }}{% elif message['role'] == 'system' %}\n{{ 'System:\n' + message['content'] + '\n\n' }}{% elif message['role'] == 'assistant' %}{{ 'Answer:\n'  + message['content'] + '\n\n' }}{% endif %}\n{% if loop.last and add_generation_prompt %}\n{{ 'Answer:\n' }}{% endif %}{% endfor %}"
   opt-125m-l4:
     enabled: false
     features: ["TextGeneration"]
diff --git a/docs/how-to/configure-text-generation-models.md b/docs/how-to/configure-text-generation-models.md
index 71abe65..3a6cdc8 100644
--- a/docs/how-to/configure-text-generation-models.md
+++ b/docs/how-to/configure-text-generation-models.md
@@ -64,6 +64,23 @@ spec:
   resourceProfile: nvidia-gpu-l4:1
 ```
 
+## Configure a Chat Template
+Some models do not ship will chat templates and some engines such as vLLM do not provide a default one. In these cases, you can use `.spec.files` to inject a template at Pod runtime.
+
+```yaml
+kind: Model
+spec:
+  # ...
+  engine: VLLM
+  args:
+  - --chat-template=/config/chat-template.jinja
+  files:
+  - path: /config/chat-template.jinja
+    content: |
+      {% for message in messages %}{{'
' + message['role'] + '\n' + message['content']}}{% if (loop.last and add_generation_prompt) or not loop.last %}{{ '
' + '\n'}}{% endif %}{% endfor %}
+      {% if add_generation_prompt and messages[-1]['role'] != 'assistant' %}{{ '
assistant\n' }}{% endif %}
+```
+
 ## Interact with the Text Generation Model
 The KubeAI service exposes an OpenAI compatible API that you can use to query the available models and interact with them.
 
diff --git a/docs/overrides/partials/integrations/analytics/custom.html b/docs/overrides/partials/integrations/analytics/custom.html
new file mode 100644
index 0000000..89b7e70
--- /dev/null
+++ b/docs/overrides/partials/integrations/analytics/custom.html
@@ -0,0 +1,2 @@
+<script data-goatcounter="https://kubeai.goatcounter.com/count"
+        async src="//gc.zgo.at/count.js"></script>
diff --git a/docs/reference/kubernetes-api.md b/docs/reference/kubernetes-api.md
index 30762b2..1a9ff8d 100644
--- a/docs/reference/kubernetes-api.md
+++ b/docs/reference/kubernetes-api.md
@@ -30,6 +30,23 @@ _Appears in:_
 | `url` _string_ |  |  |  |
 
 
+#### File
+
+
+
+File represents a file to be mounted in the model pod.
+
+
+
+_Appears in:_
+- [ModelSpec](#modelspec)
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `path` _string_ | Path where the file should be mounted in the pod.<br />Must be an absolute path. |  | MaxLength: 1024 <br />Required: \{\} <br /> |
+| `content` _string_ | Content of the file to be mounted.<br />Will be injected into a ConfigMap and mounted in the model Pods. |  | MaxLength: 100000 <br />Required: \{\} <br /> |
+
+
 #### LoadBalancing
 
 
@@ -128,6 +145,7 @@ _Appears in:_
 | `scaleDownDelaySeconds` _integer_ | ScaleDownDelay is the minimum time before a deployment is scaled down after<br />the autoscaling algorithm determines that it should be scaled down. | 30 |  |
 | `owner` _string_ | Owner of the model. Used solely to populate the owner field in the<br />OpenAI /v1/models endpoint.<br />DEPRECATED. |  | Optional: \{\} <br /> |
 | `loadBalancing` _[LoadBalancing](#loadbalancing)_ | LoadBalancing configuration for the model.<br />If not specified, a default is used based on the engine and request. | \{  \} |  |
+| `files` _[File](#file) array_ | Files to be mounted in the model Pods. |  | MaxItems: 10 <br /> |
 
 
 #### ModelStatus
diff --git a/hack/dev-models/kind-vllm-cpu.yaml b/hack/dev-models/kind-vllm-cpu.yaml
index 54f50dd..eac8750 100644
--- a/hack/dev-models/kind-vllm-cpu.yaml
+++ b/hack/dev-models/kind-vllm-cpu.yaml
@@ -15,6 +15,18 @@ spec:
   resourceProfile: cpu:1
   minReplicas: 1
   maxReplicas: 3
+  args:
+    # This revision does not contain its own chat template.
+    - --revision=27dcfa74d334bc871f3234de431e71c6eeba5dd6
+    - --chat-template=/config/chat-template.jinja
+    - --swap-space=1
+  env:
+    VLLM_CPU_KVCACHE_SPACE: "2"
+  files:
+    - path: "/config/chat-template.jinja"
+      content: "{% for message in messages %}\n{% if message['role'] == 'user' %}\n{{ 'Question:\n' + message['content'] + '\n\n' }}{% elif message['role'] == 'system' %}\n{{ 'System:\n' + message['content'] + '\n\n' }}{% elif message['role'] == 'assistant' %}{{ 'Answer:\n'  + message['content'] + '\n\n' }}{% endif %}\n{% if loop.last and add_generation_prompt %}\n{{ 'Answer:\n' }}{% endif %}{% endfor %}"
+    - path: "/config/prompt.txt"
+      content: "prompt content"
 ---
 # Service for port-fowarding to the model:
 #
diff --git a/internal/modelcontroller/engine_fasterwhisper.go b/internal/modelcontroller/engine_fasterwhisper.go
index 5bee45f..579f5ad 100644
--- a/internal/modelcontroller/engine_fasterwhisper.go
+++ b/internal/modelcontroller/engine_fasterwhisper.go
@@ -137,6 +137,7 @@ func (r *ModelReconciler) fasterWhisperPodForModel(m *kubeaiv1.Model, c ModelCon
 		},
 	}
 
+	patchFileVolumes(&pod.Spec, m)
 	patchServerCacheVolumes(&pod.Spec, m, c)
 	c.Source.modelSourcePodAdditions.applyToPodSpec(&pod.Spec, 0)
 
diff --git a/internal/modelcontroller/engine_infinity.go b/internal/modelcontroller/engine_infinity.go
index c93fa78..2dcd45f 100644
--- a/internal/modelcontroller/engine_infinity.go
+++ b/internal/modelcontroller/engine_infinity.go
@@ -157,6 +157,7 @@ func (r *ModelReconciler) infinityPodForModel(m *kubeaiv1.Model, c ModelConfig)
 		},
 	}
 
+	patchFileVolumes(&pod.Spec, m)
 	patchServerCacheVolumes(&pod.Spec, m, c)
 	c.Source.modelSourcePodAdditions.applyToPodSpec(&pod.Spec, 0)
 
diff --git a/internal/modelcontroller/engine_ollama.go b/internal/modelcontroller/engine_ollama.go
index db6a0b6..efc6c33 100644
--- a/internal/modelcontroller/engine_ollama.go
+++ b/internal/modelcontroller/engine_ollama.go
@@ -169,6 +169,7 @@ func (r *ModelReconciler) oLlamaPodForModel(m *kubeaiv1.Model, c ModelConfig) *c
 		},
 	}
 
+	patchFileVolumes(&pod.Spec, m)
 	patchServerCacheVolumes(&pod.Spec, m, c)
 	c.Source.modelSourcePodAdditions.applyToPodSpec(&pod.Spec, 0)
 
diff --git a/internal/modelcontroller/engine_vllm.go b/internal/modelcontroller/engine_vllm.go
index b61a17a..388dc44 100644
--- a/internal/modelcontroller/engine_vllm.go
+++ b/internal/modelcontroller/engine_vllm.go
@@ -149,6 +149,7 @@ func (r *ModelReconciler) vLLMPodForModel(m *kubeaiv1.Model, c ModelConfig) *cor
 		},
 	}
 
+	patchFileVolumes(&pod.Spec, m)
 	r.patchServerAdapterLoader(&pod.Spec, m, r.ModelLoaders.Image)
 	patchServerCacheVolumes(&pod.Spec, m, c)
 	c.Source.modelSourcePodAdditions.applyToPodSpec(&pod.Spec, 0)
diff --git a/internal/modelcontroller/files.go b/internal/modelcontroller/files.go
new file mode 100644
index 0000000..11a4f73
--- /dev/null
+++ b/internal/modelcontroller/files.go
@@ -0,0 +1,119 @@
+package modelcontroller
+
+import (
+	"context"
+	"fmt"
+	"reflect"
+	"strings"
+
+	kubeaiv1 "github.com/substratusai/kubeai/api/v1"
+	corev1 "k8s.io/api/core/v1"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/log"
+)
+
+// getModelFilesConfigMapName returns the name of the ConfigMap for a model
+func getModelFilesConfigMapName(model *kubeaiv1.Model) string {
+	return fmt.Sprintf("model-%s-files", model.Name)
+}
+
+// ensureModelFilesConfigMap ensures that the ConfigMap for model files exists and is up to date
+func (r *ModelReconciler) ensureModelFilesConfigMap(ctx context.Context, model *kubeaiv1.Model) error {
+	log := log.FromContext(ctx)
+	configMapName := getModelFilesConfigMapName(model)
+
+	// Build the expected data map with sanitized keys
+	expectedData := make(map[string]string)
+	for _, file := range model.Spec.Files {
+		sanitizedKey := fileConfigMapKey(file.Path)
+		expectedData[sanitizedKey] = file.Content
+	}
+
+	// Check if ConfigMap exists
+	existingConfigMap := &corev1.ConfigMap{}
+	err := r.Get(ctx, client.ObjectKey{Namespace: model.Namespace, Name: configMapName}, existingConfigMap)
+
+	if err != nil {
+		if !apierrors.IsNotFound(err) {
+			return fmt.Errorf("getting model files configmap: %w", err)
+		}
+
+		// ConfigMap doesn't exist, create it
+		configMap := &corev1.ConfigMap{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      configMapName,
+				Namespace: model.Namespace,
+			},
+			Data: expectedData,
+		}
+
+		// Set owner reference
+		if err := ctrl.SetControllerReference(model, configMap, r.Scheme); err != nil {
+			return fmt.Errorf("setting controller reference on model files configmap: %w", err)
+		}
+
+		if err := r.Create(ctx, configMap); err != nil {
+			return fmt.Errorf("creating model files configmap: %w", err)
+		}
+
+		log.Info("Created model files ConfigMap", "configMapName", configMapName)
+		return nil
+	} else if len(model.Spec.Files) == 0 {
+		if err := r.Delete(ctx, existingConfigMap); err != nil {
+			return fmt.Errorf("deleting empty model files configmap: %w", err)
+		}
+		return nil
+	}
+
+	// ConfigMap exists, check if update is needed
+	if !reflect.DeepEqual(existingConfigMap.Data, expectedData) {
+		existingConfigMap.Data = expectedData
+		if err := r.Update(ctx, existingConfigMap); err != nil {
+			return fmt.Errorf("updating model files configmap: %w", err)
+		}
+		log.Info("Updated model files ConfigMap", "configMapName", configMapName)
+	}
+
+	return nil
+}
+
+// patchFileVolumes adds volume and volume mount for the model files if specified.
+func patchFileVolumes(spec *corev1.PodSpec, model *kubeaiv1.Model) {
+	if len(model.Spec.Files) == 0 {
+		return
+	}
+
+	// Add volume for the ConfigMap
+	spec.Volumes = append(spec.Volumes, corev1.Volume{
+		Name: modelFilesVolumeName,
+		VolumeSource: corev1.VolumeSource{
+			ConfigMap: &corev1.ConfigMapVolumeSource{
+				LocalObjectReference: corev1.LocalObjectReference{
+					Name: getModelFilesConfigMapName(model),
+				},
+			},
+		},
+	})
+	for i := range spec.Containers {
+		if spec.Containers[i].Name == serverContainerName {
+			// Add volume mounts for each file
+			for _, file := range model.Spec.Files {
+				spec.Containers[i].VolumeMounts = append(spec.Containers[i].VolumeMounts, corev1.VolumeMount{
+					Name:      modelFilesVolumeName,
+					MountPath: file.Path,
+					SubPath:   fileConfigMapKey(file.Path),
+					ReadOnly:  true,
+				})
+			}
+		}
+	}
+}
+
+// fileConfigMapKey replaces illegal "/" characters in ConfigMap keys.
+// ConfigMap keys must consist of alphanumeric characters, '-', '_' or '.'
+func fileConfigMapKey(filePath string) string {
+	return strings.ReplaceAll(filePath, "/", "_")
+}
diff --git a/internal/modelcontroller/model_controller.go b/internal/modelcontroller/model_controller.go
index 8a51166..53509d0 100644
--- a/internal/modelcontroller/model_controller.go
+++ b/internal/modelcontroller/model_controller.go
@@ -45,6 +45,8 @@ import (
 const (
 	modelReconcilerName = "kubeai-model-controller"
 	serverContainerName = "server"
+	// Model files ConfigMap volume name
+	modelFilesVolumeName = "model-files"
 )
 
 // ModelReconciler reconciles a Model object
@@ -65,15 +67,6 @@ type ModelReconciler struct {
 	ModelRollouts           config.ModelRollouts
 }
 
-// +kubebuilder:rbac:groups=kubeai.org,resources=models,verbs=get;list;watch;create;update;patch;delete
-// +kubebuilder:rbac:groups=kubeai.org,resources=models/status,verbs=get;update;patch
-// +kubebuilder:rbac:groups=kubeai.org,resources=models/scale,verbs=get;update;patch
-// +kubebuilder:rbac:groups=kubeai.org,resources=models/finalizers,verbs=update
-
-//+kubebuilder:rbac:groups="",resources=pods,verbs=get;list;watch;create;update;patch;delete
-//+kubebuilder:rbac:groups="",resources=pods/status,verbs=get;update;patch
-//+kubebuilder:rbac:groups="",resources=pods/finalizers,verbs=update
-
 func (r *ModelReconciler) Reconcile(ctx context.Context, req ctrl.Request) (res ctrl.Result, resErr error) {
 	log := log.FromContext(ctx)
 	log.Info("Reconciling Model")
@@ -93,6 +86,12 @@ func (r *ModelReconciler) Reconcile(ctx context.Context, req ctrl.Request) (res
 		}
 	}()
 
+	// Ensure ConfigMap for model files exists and is up to date
+	if err := r.ensureModelFilesConfigMap(ctx, model); err != nil {
+		log.Error(err, "Failed to ensure model files ConfigMap")
+		return ctrl.Result{}, err
+	}
+
 	// Apply self labels based on features so that we can easily filter models.
 	shouldUpdate := r.applySelfLabels(model)
 	// Apply replica bounds to handle cases where min/max replicas were updated but a scale event was not triggered.
diff --git a/manifests/models/opt-125m-cpu.yaml b/manifests/models/opt-125m-cpu.yaml
index e49c50f..cff3acc 100644
--- a/manifests/models/opt-125m-cpu.yaml
+++ b/manifests/models/opt-125m-cpu.yaml
@@ -7,4 +7,25 @@ spec:
   features: [TextGeneration]
   url: hf://facebook/opt-125m
   engine: VLLM
+  args:
+    - --chat-template=/config/chat-template.jinja
   resourceProfile: cpu:1
+  files:
+    - content: |-
+        {% for message in messages %}
+        {% if message['role'] == 'user' %}
+        {{ 'Question:
+        ' + message['content'] + '
+
+        ' }}{% elif message['role'] == 'system' %}
+        {{ 'System:
+        ' + message['content'] + '
+
+        ' }}{% elif message['role'] == 'assistant' %}{{ 'Answer:
+        '  + message['content'] + '
+
+        ' }}{% endif %}
+        {% if loop.last and add_generation_prompt %}
+        {{ 'Answer:
+        ' }}{% endif %}{% endfor %}
+      path: /config/chat-template.jinja
diff --git a/mkdocs.yml b/mkdocs.yml
index 373629f..73fe371 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -4,6 +4,7 @@ repo_url: https://github.com/substratusai/kubeai
 
 theme:
   name: material
+  custom_dir: docs/overrides
   palette:
     primary: white # Defaults to indigo.
     accent: blue # Defaults to indigo.
@@ -54,3 +55,8 @@ markdown_extensions:
   - pymdownx.tasklist:
       custom_checkbox: true
   - pymdownx.tilde
+
+# Analytics tracking with GoatCounter
+extra:
+  analytics:
+    provider: custom


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 96130ee..0220be1 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -35,6 +35,7 @@ jobs:
         - "autoscaler-restart-no-load"
         - "cache-shared-filesystem"
         - "engine-vllm-pvc"
+        - "model-files"
     steps:
       - name: Checkout code
         uses: actions/checkout@v4
diff --git a/internal/modelcontroller/files_test.go b/internal/modelcontroller/files_test.go
new file mode 100644
index 0000000..9af50db
--- /dev/null
+++ b/internal/modelcontroller/files_test.go
@@ -0,0 +1,371 @@
+package modelcontroller
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	kubeaiv1 "github.com/substratusai/kubeai/api/v1"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+func Test_fileConfigMapKey(t *testing.T) {
+	tests := []struct {
+		name     string
+		input    string
+		expected string
+	}{
+		{
+			name:     "simple path",
+			input:    "/tmp/file.txt",
+			expected: "_tmp_file.txt",
+		},
+		{
+			name:     "complex path",
+			input:    "/usr/local/configs/chat-template.jinja",
+			expected: "_usr_local_configs_chat-template.jinja",
+		},
+		{
+			name:     "no slashes",
+			input:    "filename.txt",
+			expected: "filename.txt",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := fileConfigMapKey(tt.input)
+			assert.Equal(t, tt.expected, result)
+		})
+	}
+}
+
+func Test_patchFileVolumes(t *testing.T) {
+	tests := []struct {
+		name         string
+		podSpec      *corev1.PodSpec
+		model        *kubeaiv1.Model
+		expectedSpec *corev1.PodSpec
+	}{
+		{
+			name: "no files",
+			podSpec: &corev1.PodSpec{
+				Containers: []corev1.Container{
+					{
+						Name: serverContainerName,
+					},
+				},
+			},
+			model: &kubeaiv1.Model{
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "test-model",
+				},
+				Spec: kubeaiv1.ModelSpec{
+					Files: []kubeaiv1.File{},
+				},
+			},
+			expectedSpec: &corev1.PodSpec{
+				Containers: []corev1.Container{
+					{
+						Name: serverContainerName,
+					},
+				},
+			},
+		},
+		{
+			name: "single file",
+			podSpec: &corev1.PodSpec{
+				Containers: []corev1.Container{
+					{
+						Name: serverContainerName,
+					},
+				},
+			},
+			model: &kubeaiv1.Model{
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "test-model",
+				},
+				Spec: kubeaiv1.ModelSpec{
+					Files: []kubeaiv1.File{
+						{
+							Path:    "/config/prompt.txt",
+							Content: "This is a test prompt",
+						},
+					},
+				},
+			},
+			expectedSpec: &corev1.PodSpec{
+				Containers: []corev1.Container{
+					{
+						Name: serverContainerName,
+						VolumeMounts: []corev1.VolumeMount{
+							{
+								Name:      modelFilesVolumeName,
+								MountPath: "/config/prompt.txt",
+								SubPath:   "_config_prompt.txt",
+								ReadOnly:  true,
+							},
+						},
+					},
+				},
+				Volumes: []corev1.Volume{
+					{
+						Name: modelFilesVolumeName,
+						VolumeSource: corev1.VolumeSource{
+							ConfigMap: &corev1.ConfigMapVolumeSource{
+								LocalObjectReference: corev1.LocalObjectReference{
+									Name: "model-test-model-files",
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "multiple files",
+			podSpec: &corev1.PodSpec{
+				Containers: []corev1.Container{
+					{
+						Name: serverContainerName,
+					},
+				},
+			},
+			model: &kubeaiv1.Model{
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "test-model",
+				},
+				Spec: kubeaiv1.ModelSpec{
+					Files: []kubeaiv1.File{
+						{
+							Path:    "/config/prompt.txt",
+							Content: "This is a test prompt",
+						},
+						{
+							Path:    "/templates/chat.jinja",
+							Content: "{{ system }} {{ user }} {{ assistant }}",
+						},
+					},
+				},
+			},
+			expectedSpec: &corev1.PodSpec{
+				Containers: []corev1.Container{
+					{
+						Name: serverContainerName,
+						VolumeMounts: []corev1.VolumeMount{
+							{
+								Name:      modelFilesVolumeName,
+								MountPath: "/config/prompt.txt",
+								SubPath:   "_config_prompt.txt",
+								ReadOnly:  true,
+							},
+							{
+								Name:      modelFilesVolumeName,
+								MountPath: "/templates/chat.jinja",
+								SubPath:   "_templates_chat.jinja",
+								ReadOnly:  true,
+							},
+						},
+					},
+				},
+				Volumes: []corev1.Volume{
+					{
+						Name: modelFilesVolumeName,
+						VolumeSource: corev1.VolumeSource{
+							ConfigMap: &corev1.ConfigMapVolumeSource{
+								LocalObjectReference: corev1.LocalObjectReference{
+									Name: "model-test-model-files",
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "multiple containers but only patch server container",
+			podSpec: &corev1.PodSpec{
+				Containers: []corev1.Container{
+					{
+						Name: "init-container",
+					},
+					{
+						Name: serverContainerName,
+					},
+					{
+						Name: "sidecar",
+					},
+				},
+			},
+			model: &kubeaiv1.Model{
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "test-model",
+				},
+				Spec: kubeaiv1.ModelSpec{
+					Files: []kubeaiv1.File{
+						{
+							Path:    "/config/prompt.txt",
+							Content: "This is a test prompt",
+						},
+					},
+				},
+			},
+			expectedSpec: &corev1.PodSpec{
+				Containers: []corev1.Container{
+					{
+						Name: "init-container",
+					},
+					{
+						Name: serverContainerName,
+						VolumeMounts: []corev1.VolumeMount{
+							{
+								Name:      modelFilesVolumeName,
+								MountPath: "/config/prompt.txt",
+								SubPath:   "_config_prompt.txt",
+								ReadOnly:  true,
+							},
+						},
+					},
+					{
+						Name: "sidecar",
+					},
+				},
+				Volumes: []corev1.Volume{
+					{
+						Name: modelFilesVolumeName,
+						VolumeSource: corev1.VolumeSource{
+							ConfigMap: &corev1.ConfigMapVolumeSource{
+								LocalObjectReference: corev1.LocalObjectReference{
+									Name: "model-test-model-files",
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "server container not found",
+			podSpec: &corev1.PodSpec{
+				Containers: []corev1.Container{
+					{
+						Name: "some-other-container",
+					},
+				},
+			},
+			model: &kubeaiv1.Model{
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "test-model",
+				},
+				Spec: kubeaiv1.ModelSpec{
+					Files: []kubeaiv1.File{
+						{
+							Path:    "/config/prompt.txt",
+							Content: "This is a test prompt",
+						},
+					},
+				},
+			},
+			expectedSpec: &corev1.PodSpec{
+				Containers: []corev1.Container{
+					{
+						Name: "some-other-container",
+					},
+				},
+				Volumes: []corev1.Volume{
+					{
+						Name: modelFilesVolumeName,
+						VolumeSource: corev1.VolumeSource{
+							ConfigMap: &corev1.ConfigMapVolumeSource{
+								LocalObjectReference: corev1.LocalObjectReference{
+									Name: "model-test-model-files",
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "existing volumes and mounts",
+			podSpec: &corev1.PodSpec{
+				Containers: []corev1.Container{
+					{
+						Name: serverContainerName,
+						VolumeMounts: []corev1.VolumeMount{
+							{
+								Name:      "existing-volume",
+								MountPath: "/existing/path",
+							},
+						},
+					},
+				},
+				Volumes: []corev1.Volume{
+					{
+						Name: "existing-volume",
+						VolumeSource: corev1.VolumeSource{
+							EmptyDir: &corev1.EmptyDirVolumeSource{},
+						},
+					},
+				},
+			},
+			model: &kubeaiv1.Model{
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "test-model",
+				},
+				Spec: kubeaiv1.ModelSpec{
+					Files: []kubeaiv1.File{
+						{
+							Path:    "/config/prompt.txt",
+							Content: "This is a test prompt",
+						},
+					},
+				},
+			},
+			expectedSpec: &corev1.PodSpec{
+				Containers: []corev1.Container{
+					{
+						Name: serverContainerName,
+						VolumeMounts: []corev1.VolumeMount{
+							{
+								Name:      "existing-volume",
+								MountPath: "/existing/path",
+							},
+							{
+								Name:      modelFilesVolumeName,
+								MountPath: "/config/prompt.txt",
+								SubPath:   "_config_prompt.txt",
+								ReadOnly:  true,
+							},
+						},
+					},
+				},
+				Volumes: []corev1.Volume{
+					{
+						Name: "existing-volume",
+						VolumeSource: corev1.VolumeSource{
+							EmptyDir: &corev1.EmptyDirVolumeSource{},
+						},
+					},
+					{
+						Name: modelFilesVolumeName,
+						VolumeSource: corev1.VolumeSource{
+							ConfigMap: &corev1.ConfigMapVolumeSource{
+								LocalObjectReference: corev1.LocalObjectReference{
+									Name: "model-test-model-files",
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			patchFileVolumes(tt.podSpec, tt.model)
+			assert.Equal(t, tt.expectedSpec, tt.podSpec)
+		})
+	}
+}
diff --git a/test/e2e/model-files/test.sh b/test/e2e/model-files/test.sh
new file mode 100755
index 0000000..2f641db
--- /dev/null
+++ b/test/e2e/model-files/test.sh
@@ -0,0 +1,62 @@
+#!/bin/bash
+
+source $REPO_DIR/test/e2e/common.sh
+
+model_name="files-test-model"
+
+# Create a model with files
+cat <<EOF | kubectl apply -f -
+apiVersion: kubeai.org/v1
+kind: Model
+metadata:
+  name: ${model_name}
+spec:
+  features: [TextGeneration]
+  url: "hf://facebook/opt-125m"
+  engine: VLLM
+  resourceProfile: "cpu:1"
+  minReplicas: 1
+  args:
+    # This revision does not contain its own chat template.
+    - --revision=27dcfa74d334bc871f3234de431e71c6eeba5dd6
+    - --chat-template=/config/chat-template.jinja
+  env:
+    VLLM_CPU_KVCACHE_SPACE: "1"
+  files:
+    - path: "/config/chat-template.jinja"
+      content: "{% for message in messages %}\n{% if message['role'] == 'user' %}\n{{ 'Question:\n' + message['content'] + '\n\n' }}{% elif message['role'] == 'system' %}\n{{ 'System:\n' + message['content'] + '\n\n' }}{% elif message['role'] == 'assistant' %}{{ 'Answer:\n'  + message['content'] + '\n\n' }}{% endif %}\n{% if loop.last and add_generation_prompt %}\n{{ 'Answer:\n' }}{% endif %}{% endfor %}"
+    - path: "/config/prompt.txt"
+      content: "prompt content"
+EOF
+
+# Wait for the model pod to be ready
+echo "Waiting for model pod to be ready..."
+kubectl wait --timeout 15m --for=jsonpath='.status.replicas.ready'=1 model/${model_name}
+
+# Get the model pod name
+model_pod=$(kubectl get pods -l "model=${model_name}" -o jsonpath='{.items[0].metadata.name}')
+
+# Check that the files are properly mounted in the model pod
+echo "Checking files are mounted in the model pod..."
+kubectl exec ${model_pod} -- cat /config/prompt.txt | grep "prompt content"
+
+# Check that chat completion works.
+curl http://localhost:8000/openai/v1/chat/completions \
+    --fail-with-body \
+    -H "Content-Type: application/json" \
+    -d '{
+        "model": "files-test-model",
+        "max_tokens": 10,
+        "messages": [
+            {
+                "role": "system",
+                "content": "You are a helpful assistant."
+            },
+            {
+                "role": "user",
+                "content": "Write a haiku that explains the concept of recursion."
+            }
+        ]
+    }'
+
+echo "Model files e2e test passed!"
\ No newline at end of file
diff --git a/test/integration/model_files_test.go b/test/integration/model_files_test.go
new file mode 100644
index 0000000..e947432
--- /dev/null
+++ b/test/integration/model_files_test.go
@@ -0,0 +1,178 @@
+package integration
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	kubeaiv1 "github.com/substratusai/kubeai/api/v1"
+	corev1 "k8s.io/api/core/v1"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+// TestModelFiles tests that model files are properly mounted in pods.
+func TestModelFiles(t *testing.T) {
+	sysCfg := baseSysCfg(t)
+	initTest(t, sysCfg)
+
+	// Construct a Model object
+	m := modelForTest(t)
+	m.Spec.MinReplicas = 1
+
+	// Add files to the model
+	m.Spec.Files = []kubeaiv1.File{
+		{
+			Path:    "/config/prompt-template.txt",
+			Content: "This is a prompt template for testing",
+		},
+		{
+			Path:    "/templates/chat-template.jinja",
+			Content: "{{ system }} {{ user }} {{ assistant }}",
+		},
+	}
+
+	// Create the Model object in the Kubernetes cluster
+	require.NoError(t, testK8sClient.Create(testCtx, m))
+
+	// Verify the ConfigMap is created with correct content
+	configMapName := "model-" + m.Name + "-files"
+	configMap := &corev1.ConfigMap{}
+
+	require.EventuallyWithT(t, func(t *assert.CollectT) {
+		if !assert.NoError(t, testK8sClient.Get(testCtx, client.ObjectKey{Namespace: testNS, Name: configMapName}, configMap)) {
+			return
+		}
+
+		// Check ConfigMap has the expected data with sanitized keys
+		assert.Equal(t, 2, len(configMap.Data))
+		assert.Equal(t, "This is a prompt template for testing", configMap.Data["_config_prompt-template.txt"])
+		assert.Equal(t, "{{ system }} {{ user }} {{ assistant }}", configMap.Data["_templates_chat-template.jinja"])
+
+		// Check that the ConfigMap is owned by the model
+		assert.Equal(t, 1, len(configMap.OwnerReferences))
+		assert.Equal(t, "Model", configMap.OwnerReferences[0].Kind)
+		assert.Equal(t, m.Name, configMap.OwnerReferences[0].Name)
+	}, 5*time.Second, time.Second/10, "ConfigMap should be created with correct content")
+
+	// Verify Pod has correct volume and volume mounts
+	var pod *corev1.Pod
+	require.EventuallyWithT(t, func(t *assert.CollectT) {
+		podList := &corev1.PodList{}
+		if !assert.NoError(t, testK8sClient.List(testCtx, podList, client.InNamespace(testNS), client.MatchingLabels{"model": m.Name})) {
+			return
+		}
+		if !assert.Len(t, podList.Items, 1) {
+			return
+		}
+		pod = &podList.Items[0]
+
+		// Verify the volume exists
+		volumeFound := false
+		for _, volume := range pod.Spec.Volumes {
+			if volume.Name == "model-files" {
+				volumeFound = true
+				assert.NotNil(t, volume.ConfigMap)
+				assert.Equal(t, configMapName, volume.ConfigMap.Name)
+			}
+		}
+		assert.True(t, volumeFound, "Pod should have model-files volume")
+
+		// The Pod should have a single container named "server" with volume mounts
+		container := mustFindPodContainerByName(t, pod, "server")
+
+		promptMountFound := false
+		templateMountFound := false
+		for _, mount := range container.VolumeMounts {
+			if mount.Name == "model-files" {
+				if mount.MountPath == "/config/prompt-template.txt" {
+					promptMountFound = true
+					assert.Equal(t, "_config_prompt-template.txt", mount.SubPath)
+					assert.True(t, mount.ReadOnly)
+				}
+				if mount.MountPath == "/templates/chat-template.jinja" {
+					templateMountFound = true
+					assert.Equal(t, "_templates_chat-template.jinja", mount.SubPath)
+					assert.True(t, mount.ReadOnly)
+				}
+			}
+		}
+
+		assert.True(t, promptMountFound, "Server container should mount prompt file")
+		assert.True(t, templateMountFound, "Server container should mount template file")
+	}, 5*time.Second, time.Second/10, "Pod should be created with correct volume mounts")
+
+	// Test updating files
+	require.EventuallyWithT(t, func(t *assert.CollectT) {
+		if !assert.NoError(t, testK8sClient.Get(testCtx, client.ObjectKeyFromObject(m), m)) {
+			return
+		}
+
+		// Add a new file
+		m.Spec.Files = append(m.Spec.Files, kubeaiv1.File{
+			Path:    "/config/new-file.txt",
+			Content: "This is a new file",
+		})
+
+		// Modify an existing file
+		m.Spec.Files[0].Content = "Updated prompt template content"
+
+		assert.NoError(t, testK8sClient.Update(testCtx, m))
+	}, 2*time.Second, time.Second/10, "Update model with new and modified files")
+
+	// Verify ConfigMap is updated
+	require.EventuallyWithT(t, func(t *assert.CollectT) {
+		if !assert.NoError(t, testK8sClient.Get(testCtx, client.ObjectKey{Namespace: testNS, Name: configMapName}, configMap)) {
+			return
+		}
+
+		assert.Equal(t, 3, len(configMap.Data), "ConfigMap should have 3 data entries after update")
+		assert.Equal(t, "Updated prompt template content", configMap.Data["_config_prompt-template.txt"], "ConfigMap should have updated content")
+		assert.Equal(t, "This is a new file", configMap.Data["_config_new-file.txt"], "ConfigMap should have new file content")
+	}, 5*time.Second, time.Second/10, "ConfigMap should be updated with new and modified files")
+
+	// Verify the new Pod has the updated volume mounts
+	require.EventuallyWithT(t, func(t *assert.CollectT) {
+		podList := &corev1.PodList{}
+		if !assert.NoError(t, testK8sClient.List(testCtx, podList, client.InNamespace(testNS), client.MatchingLabels{"model": m.Name})) {
+			return
+		}
+		if !assert.Len(t, podList.Items, 1) {
+			return
+		}
+		pod = &podList.Items[0]
+
+		// The Pod should have a single container named "server" with updated volume mounts
+		container := mustFindPodContainerByName(t, pod, "server")
+
+		newFileMountFound := false
+		for _, mount := range container.VolumeMounts {
+			if mount.Name == "model-files" && mount.MountPath == "/config/new-file.txt" {
+				newFileMountFound = true
+				assert.Equal(t, "_config_new-file.txt", mount.SubPath)
+				assert.True(t, mount.ReadOnly)
+			}
+		}
+
+		assert.True(t, newFileMountFound, "Server container should mount new file")
+	}, 15*time.Second, time.Second/10, "New Pod should be created with updated volume mounts")
+
+	// Test updating files to empty
+	require.EventuallyWithT(t, func(t *assert.CollectT) {
+		if !assert.NoError(t, testK8sClient.Get(testCtx, client.ObjectKeyFromObject(m), m)) {
+			return
+		}
+
+		// Set files to empty
+		m.Spec.Files = []kubeaiv1.File{}
+
+		assert.NoError(t, testK8sClient.Update(testCtx, m))
+	}, 2*time.Second, time.Second/10, "Update model with empty files")
+
+	// Verify ConfigMap is deleted
+	require.EventuallyWithT(t, func(t *assert.CollectT) {
+		err := testK8sClient.Get(testCtx, client.ObjectKey{Namespace: testNS, Name: configMapName}, configMap)
+		assert.True(t, apierrors.IsNotFound(err), "ConfigMap should be deleted when files is empty")
+	}, 5*time.Second, time.Second/10, "ConfigMap should be deleted when files is empty")
+}
diff --git a/test/integration/model_validation_test.go b/test/integration/model_validation_test.go
index c55dfe5..1e4145d 100644
--- a/test/integration/model_validation_test.go
+++ b/test/integration/model_validation_test.go
@@ -1,8 +1,11 @@
 package integration
 
 import (
+	"context"
 	"testing"
 
+	"log"
+
 	"github.com/stretchr/testify/require"
 	v1 "github.com/substratusai/kubeai/api/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -334,6 +337,133 @@ func TestModelValidation(t *testing.T) {
 			},
 			expErrContain: "url is immutable when using cacheProfile",
 		},
+		{
+			model: v1.Model{
+				ObjectMeta: metadata("root-file-path-valid"),
+				Spec: v1.ModelSpec{
+					URL:      "hf://test-repo/test-model",
+					Engine:   "VLLM",
+					Features: []v1.ModelFeature{},
+					Files: []v1.File{
+						{
+							Path:    "/file.txt",
+							Content: "file content",
+						},
+					},
+				},
+			},
+			expValid: true,
+		},
+		{
+			model: v1.Model{
+				ObjectMeta: metadata("absolute-file-path-valid"),
+				Spec: v1.ModelSpec{
+					URL:      "hf://test-repo/test-model",
+					Engine:   "VLLM",
+					Features: []v1.ModelFeature{},
+					Files: []v1.File{
+						{
+							Path:    "/absolute/path/to/file.txt",
+							Content: "file content",
+						},
+					},
+				},
+			},
+			expValid: true,
+		},
+		{
+			model: v1.Model{
+				ObjectMeta: metadata("relative-file-path-invalid"),
+				Spec: v1.ModelSpec{
+					URL:      "hf://test-repo/test-model",
+					Engine:   "VLLM",
+					Features: []v1.ModelFeature{},
+					Files: []v1.File{
+						{
+							Path:    "relative/path/to/file.txt",
+							Content: "file content",
+						},
+					},
+				},
+			},
+			expErrContain: "Path must be an absolute path",
+		},
+		{
+			model: v1.Model{
+				ObjectMeta: metadata("invalid-file-path-character"),
+				Spec: v1.ModelSpec{
+					URL:      "hf://test-repo/test-model",
+					Engine:   "VLLM",
+					Features: []v1.ModelFeature{},
+					Files: []v1.File{
+						{
+							Path:    "c://path/to/file.txt",
+							Content: "file content",
+						},
+					},
+				},
+			},
+			expErrContain: "must not contain a ':' character",
+		},
+		{
+			model: v1.Model{
+				ObjectMeta: metadata("duplicate-file-paths-invalid"),
+				Spec: v1.ModelSpec{
+					URL:      "hf://test-repo/test-model",
+					Engine:   "VLLM",
+					Features: []v1.ModelFeature{},
+					Files: []v1.File{
+						{
+							Path:    "/path/to/file1.txt",
+							Content: "file 1 content",
+						},
+						{
+							Path:    "/path/to/file2.txt",
+							Content: "file 2 content",
+						},
+						{
+							Path:    "/path/to/file1.txt", // Duplicate path
+							Content: "duplicated path content",
+						},
+					},
+				},
+			},
+			expErrContain: "All file paths must be unique",
+		},
+		{
+			model: v1.Model{
+				ObjectMeta: metadata("large-files-valid"),
+				Spec: v1.ModelSpec{
+					URL:      "hf://test-repo/test-model",
+					Engine:   "VLLM",
+					Features: []v1.ModelFeature{},
+					Files: []v1.File{
+						{
+							Path:    "/path/to/file1.txt",
+							Content: nCharacters(100_000),
+						},
+					},
+				},
+			},
+			expValid: true,
+		},
+		{
+			model: v1.Model{
+				ObjectMeta: metadata("xlarge-files-invalid"),
+				Spec: v1.ModelSpec{
+					URL:      "hf://test-repo/test-model",
+					Engine:   "VLLM",
+					Features: []v1.ModelFeature{},
+					Files: []v1.File{
+						{
+							Path:    "/path/to/file1.txt",
+							Content: nCharacters(100_001),
+						},
+					},
+				},
+			},
+			expErrContain: "may not be longer than 100000",
+		},
 	}
 	for _, c := range cases {
 		t.Run(c.model.Name, func(t *testing.T) {
@@ -353,6 +483,13 @@ func TestModelValidation(t *testing.T) {
 				}
 			}
 
+			if c.expValid {
+				t.Cleanup(func() {
+					if err := testK8sClient.Delete(context.Background(), &c.model); err != nil {
+						log.Printf("Failed to delete model: %v", err)
+					}
+				})
+			}
 			if c.update == nil {
 				validateErr(testK8sClient.Create(testCtx, &c.model))
 			} else {
@@ -363,3 +500,11 @@ func TestModelValidation(t *testing.T) {
 		})
 	}
 }
+
+func nCharacters(n int) string {
+	s := make([]byte, n)
+	for i := range s {
+		s[i] = 'a'
+	}
+	return string(s)
+}