Repository: substratusai/kubeai
Pull request #337 (2024-12-04 03:25:07)

update README


Patch:
diff --git a/Makefile b/Makefile
index 1f44412..b1179c0 100644
--- a/Makefile
+++ b/Makefile
@@ -91,6 +91,10 @@ test-e2e-autoscaler-restart: skaffold
 test-e2e-cache-shared-filesystem: skaffold
 	./test/e2e/run.sh cache-shared-filesystem --profile e2e-test-default
 
+.PHONY: test-e2e-engine-vllm-pvc
+test-e2e-engine-vllm-pvc: skaffold
+	./test/e2e/run.sh engine-vllm-pvc --profile e2e-test-default
+
 .PHONY: test-e2e-engine
 test-e2e-engine: skaffold
 	CACHE_PROFILE=$(CACHE_PROFILE) ./test/e2e/run.sh engine-$(ENGINE) --profile e2e-test-default
diff --git a/api/v1/model_types.go b/api/v1/model_types.go
index 8cace19..5ac9f51 100644
--- a/api/v1/model_types.go
+++ b/api/v1/model_types.go
@@ -34,6 +34,8 @@ type ModelSpec struct {
 	// For VLLM, FasterWhisper, Infinity engines:
 	//
 	// "hf://<repo>/<model>"
+	// "pvc://<pvcName>"
+	// "pvc://<pvcName>/<pvcSubpath>"
 	// "gs://<bucket>/<path>" (only with cacheProfile)
 	// "oss://<bucket>/<path>" (only with cacheProfile)
 	// "s3://<bucket>/<path>" (only with cacheProfile)
@@ -44,7 +46,7 @@ type ModelSpec struct {
 	//
 	// +kubebuilder:validation:Required
 	// +kubebuilder:validation:XValidation:rule="self == oldSelf", message="url is immutable."
-	// +kubebuilder:validation:XValidation:rule="self.startsWith(\"hf://\") || self.startsWith(\"ollama://\") || self.startsWith(\"s3://\") || self.startsWith(\"gs://\") || self.startsWith(\"oss://\")", message="url must start with \"hf://\", \"ollama://\", \"s3://\", \"gs://\", or \"oss://\" and not be empty."
+	// +kubebuilder:validation:XValidation:rule="self.startsWith(\"hf://\") || self.startsWith(\"pvc://\") || self.startsWith(\"ollama://\") || self.startsWith(\"s3://\") || self.startsWith(\"gs://\") || self.startsWith(\"oss://\")", message="url must start with \"hf://\", \"pvc://\", \"ollama://\", \"s3://\", \"gs://\", or \"oss://\" and not be empty."
 	URL string `json:"url"`
 
 	Adapters []Adapter `json:"adapters,omitempty"`
diff --git a/charts/kubeai/templates/crds/kubeai.org_models.yaml b/charts/kubeai/templates/crds/kubeai.org_models.yaml
index ca192f9..2480301 100644
--- a/charts/kubeai/templates/crds/kubeai.org_models.yaml
+++ b/charts/kubeai/templates/crds/kubeai.org_models.yaml
@@ -165,6 +165,8 @@ spec:
 
 
                   "hf://<repo>/<model>"
+                  "pvc://<pvcName>"
+                  "pvc://<pvcName>/<pvcSubpath>"
                   "gs://<bucket>/<path>" (only with cacheProfile)
                   "oss://<bucket>/<path>" (only with cacheProfile)
                   "s3://<bucket>/<path>" (only with cacheProfile)
@@ -178,10 +180,10 @@ spec:
                 x-kubernetes-validations:
                 - message: url is immutable.
                   rule: self == oldSelf
-                - message: url must start with "hf://", "ollama://", "s3://", "gs://",
-                    or "oss://" and not be empty.
-                  rule: self.startsWith("hf://") || self.startsWith("ollama://") ||
-                    self.startsWith("s3://") || self.startsWith("gs://") || self.startsWith("oss://")
+                - message: url must start with "hf://", "pvc://", "ollama://", "s3://",
+                    "gs://", or "oss://" and not be empty.
+                  rule: self.startsWith("hf://") || self.startsWith("pvc://") || self.startsWith("ollama://")
+                    || self.startsWith("s3://") || self.startsWith("gs://") || self.startsWith("oss://")
             required:
             - engine
             - features
diff --git a/charts/models/templates/models.yaml b/charts/models/templates/models.yaml
index b945ac1..c6ca28d 100644
--- a/charts/models/templates/models.yaml
+++ b/charts/models/templates/models.yaml
@@ -5,9 +5,15 @@ apiVersion: kubeai.org/v1
 kind: Model
 metadata:
   name: {{ $name }}
+  {{- with $model.labels }}
+  labels:
+  {{- toYaml . | nindent 4 }}
+  {{- end }}
 spec:
   features: {{ $model.features }}
-  owner: {{ $model.owner }}
+  {{- with $model.owner }}
+  owner: {{ . }}
+  {{- end }}
   url: {{ $model.url }}
   {{- with $model.adapters }}
   adapters:
diff --git a/charts/models/values.yaml b/charts/models/values.yaml
index eae2da1..4c1b18e 100644
--- a/charts/models/values.yaml
+++ b/charts/models/values.yaml
@@ -4,6 +4,9 @@ all:
 
 catalog:
   llama-3.2-11b-vision-instruct-l4:
+    # You can optionally add metadata.labels.
+    # labels:
+    #   tenancy: public
     enabled: false
     features: [TextGeneration]
     url: hf://neuralmagic/Llama-3.2-11B-Vision-Instruct-FP8-dynamic
@@ -27,7 +30,6 @@ catalog:
   e5-mistral-7b-instruct-cpu:
     enabled: false
     features: ["TextEmbedding"]
-    owner: intfloat
     url: "hf://intfloat/e5-mistral-7b-instruct"
     engine: VLLM
     # TODO: Adjust - the memory associated with this request is too low.
@@ -38,14 +40,12 @@ catalog:
   gemma2-2b-cpu:
     enabled: false
     features: ["TextGeneration"]
-    owner: google
     url: "ollama://gemma2:2b"
     engine: OLlama
     resourceProfile: cpu:2
   gemma-2b-it-tpu:
     enabled: false
     features: ["TextGeneration"]
-    owner: google
     url: "hf://google/gemma-2b-it"
     engine: VLLM
     resourceProfile: google-tpu-v5e-1x1:1
@@ -54,7 +54,6 @@ catalog:
   # gemma2-9b-it-fp8-tpu:
   #   enabled: false
   #   features: ["TextGeneration"]
-  #   owner: neuralmagic
   #   # vLLM logs: "ValueError: fp8 quantization is currently not supported in TPU Backend."
   #   #url: "hf://neuralmagic/gemma-2-9b-it-FP8"
   #   engine: VLLM
@@ -64,7 +63,6 @@ catalog:
   # gemma2-9b-it-int8-tpu:
   #   enabled: false
   #   features: ["TextGeneration"]
-  #   owner: neuralmagic
   #   # vLLM logs: "ValueError: compressed-tensors quantization is currently not supported in TPU Backend."
   #   #url: "hf://neuralmagic/gemma-2-9b-it-quantized.w8a8"
   #   #url: "hf://neuralmagic/gemma-2-9b-it-quantized.w8a16"
@@ -76,7 +74,6 @@ catalog:
   llama-3.1-8b-instruct-cpu:
     enabled: false
     features: ["TextGeneration"]
-    owner: "meta-llama"
     url: "hf://meta-llama/Meta-Llama-3.1-8B-Instruct"
     engine: VLLM
     resourceProfile: cpu:6
@@ -88,7 +85,6 @@ catalog:
   llama-3.1-8b-instruct-tpu:
     enabled: false
     features: ["TextGeneration"]
-    owner: meta-llama
     url: "hf://meta-llama/Meta-Llama-3.1-8B-Instruct"
     engine: VLLM
     resourceProfile: google-tpu-v5e-2x2:4
@@ -120,7 +116,6 @@ catalog:
   llama-3.1-8b-instruct-fp8-l4:
     enabled: false
     features: ["TextGeneration"]
-    owner: "neuralmagic"
     url: "hf://neuralmagic/Meta-Llama-3.1-8B-Instruct-FP8"
     engine: VLLM
     resourceProfile: nvidia-gpu-l4:1
@@ -242,14 +237,12 @@ catalog:
   nomic-embed-text-cpu:
     enabled: false
     features: ["TextEmbedding"]
-    owner: nomic
     url: "ollama://nomic-embed-text"
     engine: OLlama
     resourceProfile: cpu:1
   bge-embed-text-cpu:
     enabled: false
     features: ["TextEmbedding"]
-    owner: baai
     url: "hf://BAAI/bge-small-en-v1.5"
     engine: Infinity
     resourceProfile: cpu:1
@@ -257,7 +250,6 @@ catalog:
   opt-125m-cpu:
     enabled: false
     features: ["TextGeneration"]
-    owner: facebook
     url: "hf://facebook/opt-125m"
     engine: VLLM
     # TODO: Adjust - the memory associated with this request is too low.
@@ -265,7 +257,6 @@ catalog:
   opt-125m-l4:
     enabled: false
     features: ["TextGeneration"]
-    owner: facebook
     url: "hf://facebook/opt-125m"
     engine: VLLM
     resourceProfile: nvidia-gpu-l4:1
@@ -299,14 +290,12 @@ catalog:
   qwen2-500m-cpu:
     enabled: false
     features: ["TextGeneration"]
-    owner: alibaba
     url: "ollama://qwen2:0.5b"
     engine: OLlama
     resourceProfile: cpu:1
   faster-whisper-medium-en-cpu:
     enabled: false
     features: ["SpeechToText"]
-    owner: Systran
     url: "hf://Systran/faster-whisper-medium.en"
     engine: FasterWhisper
     resourceProfile: cpu:1
diff --git a/docs/README.md b/docs/README.md
index 13ac4c0..3d00c17 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -1,17 +1,17 @@
 # KubeAI: AI Inferencing Operator
 
-Get inferencing running on Kubernetes: LLMs, Embeddings, Speech-to-Text.
-
-✅️  Drop-in replacement for OpenAI with API compatibility  
-⚖️  Scale from zero, autoscale based on load  
-🧠  Serve text generation models (LLMs, VLMs, etc.)  
-💬  Speech to Text API  
-🧮  Embedding/Vector API  
-🚀  Multi-platform: CPU-only, GPU, TPU  
+The easiest way to serve ML models in production. Supports LLMs, embeddings, and speech-to-text.
+
+✅️  OpenAI API Compatibility: Drop-in replacement for OpenAI  
+⚖️  Autoscaling: Scale from zero, autoscale based on load  
+🧠  Serve text generation models with vLLM or Ollama  
+🔌  Lora Adapter aware routing  
+💬  Speech to Text API with FasterWhisper  
+🧮  Embedding/Vector API with Infinity  
+🚀  Multi-platform: CPU, GPU, TPU  
 💾  Model caching with shared filesystems (EFS, Filestore, etc.)  
 🛠️  Zero dependencies (does not depend on Istio, Knative, etc.)  
 💬  Chat UI included ([OpenWebUI](https://github.com/open-webui/open-webui))  
-🤖  Operates OSS model servers (vLLM, Ollama, FasterWhisper, Infinity)  
 ✉  Stream/batch inference via messaging integrations (Kafka, PubSub, etc.)  
 
 Quotes from the community:
@@ -24,6 +24,20 @@ KubeAI serves an OpenAI compatible HTTP API. Admins can configure ML models via
 
 <img src="./diagrams/arch.excalidraw.png"></img>
 
+## Adopters
+
+List of known adopters:
+
+| Name | Description | Link |
+| ---- | ----------- | ---- |
+| Telescope | Telescope uses KubeAI for multi-region large scale batch LLM inference. | [trytelescope.ai](https://trytelescope.ai) |
+| Google Cloud Distributed Edge | KubeAI is included as a reference architecture for inferencing at the edge. | [LinkedIn](https://www.linkedin.com/posts/mikeensor_gcp-solutions-public-retail-edge-available-cluster-traits-activity-7237515920259104769-vBs9?utm_source=share&utm_medium=member_desktop), [GitLab](https://gitlab.com/gcp-solutions-public/retail-edge/available-cluster-traits/kubeai-cluster-trait) |
+| Lambda | You can try KubeAI on the Lambda AI Developer Cloud. See Lambda's [tutorial](https://docs.lambdalabs.com/education/large-language-models/kubeai-hermes-3/) and [video](https://youtu.be/HEtPO2Wuiac). | [Lambda](https://lambdalabs.com/) |
+| Vultr | KubeAI can be deployed on Vultr Managed Kubernetes using the application marketplace. | [Vultr](https://www.vultr.com) |
+| Arcee | Arcee uses KubeAI for multi-region, multi-tenant SLM inference. | [Arcee](https://www.arcee.ai/) |
+
+If you are using KubeAI and would like to be listed as an adopter, please make a PR.
+
 ## Local Quickstart
 
 
@@ -113,20 +127,6 @@ Checkout our documentation on [kubeai.org](https://www.kubeai.org) to find info
 * Concepts (how the components of KubeAI work).
 * How to contribute
 
-## Adopters
-
-List of known adopters:
-
-| Name | Description | Link |
-| ---- | ----------- | ---- |
-| Telescope | Telescope uses KubeAI for multi-region large scale batch LLM inference. | [trytelescope.ai](https://trytelescope.ai) |
-| Google Cloud Distributed Edge | KubeAI is included as a reference architecture for inferencing at the edge. | [LinkedIn](https://www.linkedin.com/posts/mikeensor_gcp-solutions-public-retail-edge-available-cluster-traits-activity-7237515920259104769-vBs9?utm_source=share&utm_medium=member_desktop), [GitLab](https://gitlab.com/gcp-solutions-public/retail-edge/available-cluster-traits/kubeai-cluster-trait) |
-| Lambda | You can try KubeAI on the Lambda AI Developer Cloud. See Lambda's [tutorial](https://docs.lambdalabs.com/education/large-language-models/kubeai-hermes-3/) and [video](https://youtu.be/HEtPO2Wuiac). | [Lambda](https://lambdalabs.com/) |
-| Vultr | KubeAI can be deployed on Vultr Managed Kubernetes using the application marketplace. | [Vultr](https://www.vultr.com) |
-| Arcee | Arcee uses KubeAI for multi-region, multi-tenant SLM inference. | [Arcee](https://www.arcee.ai/) |
-
-If you are using KubeAI and would like to be listed as an adopter, please make a PR.
-
 ## OpenAI API Compatibility
 
 ```bash
diff --git a/docs/how-to/architect-for-multitenancy.md b/docs/how-to/architect-for-multitenancy.md
index ea25c7d..0999573 100644
--- a/docs/how-to/architect-for-multitenancy.md
+++ b/docs/how-to/architect-for-multitenancy.md
@@ -22,6 +22,15 @@ spec:
 # ...
 ```
 
+Example Model using Helm chart:
+```yaml
+catalog:
+  llama-3.2:
+    labels:
+      tenancy: public
+    # ...
+```
+
 Example HTTP requests:
 
 ```bash
diff --git a/docs/how-to/install-models.md b/docs/how-to/install-models.md
index 15cad9b..1107ed1 100644
--- a/docs/how-to/install-models.md
+++ b/docs/how-to/install-models.md
@@ -94,6 +94,22 @@ spec:
   resourceProfile: nvidia-gpu-l4:1
 ```
 
+## Load Models from PVC
+
+You can store your models in a Persistent Volume Claim (PVC) and load them into KubeAI for serving. This guide will show you how to load models from a PVC.
+
+Currenly only vLLM supports loading models from PVCs.
+
+The following formats are supported to load models from a PVC:
+
+- `url: pvc://$PVC_NAME` - Loads the model from the PVC named `$PVC_NAME`.
+- `url: pvc://$PVC_NAME/$PATH` - Loads the model from the PVC named `$PVC_NAME` and mounts the subpath `$PATH` within the PVC.
+
+You need to make sure the model is preloaded into the PVC before you can use it in KubeAI.
+
+The Access Mode of the PVC should be `ReadOnlyMany` or `ReadWriteMany`, because otherwise
+KubeAI won't be able to spin up more than 1 replica of the model.
+
 ## Programmatically installing models
 
 See the [examples](https://github.com/substratusai/kubeai/tree/main/examples/k8s-api-clients).
diff --git a/docs/reference/kubernetes-api.md b/docs/reference/kubernetes-api.md
index 69b003a..4761242 100644
--- a/docs/reference/kubernetes-api.md
+++ b/docs/reference/kubernetes-api.md
@@ -76,7 +76,7 @@ _Appears in:_
 
 | Field | Description | Default | Validation |
 | --- | --- | --- | --- |
-| `url` _string_ | URL of the model to be served.<br />Currently the following formats are supported:<br /><br />For VLLM, FasterWhisper, Infinity engines:<br /><br />"hf://<repo>/<model>"<br />"gs://<bucket>/<path>" (only with cacheProfile)<br />"oss://<bucket>/<path>" (only with cacheProfile)<br />"s3://<bucket>/<path>" (only with cacheProfile)<br /><br />For OLlama engine:<br /><br />"ollama://<model>" |  | Required: \{\} <br /> |
+| `url` _string_ | URL of the model to be served.<br />Currently the following formats are supported:<br /><br />For VLLM, FasterWhisper, Infinity engines:<br /><br />"hf://<repo>/<model>"<br />"pvc://<pvcName>"<br />"pvc://<pvcName>/<pvcSubpath>"<br />"gs://<bucket>/<path>" (only with cacheProfile)<br />"oss://<bucket>/<path>" (only with cacheProfile)<br />"s3://<bucket>/<path>" (only with cacheProfile)<br /><br />For OLlama engine:<br /><br />"ollama://<model>" |  | Required: \{\} <br /> |
 | `adapters` _[Adapter](#adapter) array_ |  |  |  |
 | `features` _[ModelFeature](#modelfeature) array_ | Features that the model supports.<br />Dictates the APIs that are available for the model. |  | Enum: [TextGeneration TextEmbedding SpeechToText] <br /> |
 | `engine` _string_ | Engine to be used for the server process. |  | Enum: [OLlama VLLM FasterWhisper Infinity] <br />Required: \{\} <br /> |
diff --git a/internal/modelcontroller/cache.go b/internal/modelcontroller/cache.go
index 7f81be9..775e6bb 100644
--- a/internal/modelcontroller/cache.go
+++ b/internal/modelcontroller/cache.go
@@ -351,7 +351,7 @@ func (r *ModelReconciler) loadCacheJobForModel(m *kubeaiv1.Model, c ModelConfig)
 		m.Spec.URL,
 		modelCacheDir(m),
 	}
-	c.Source.modelAuthCredentials.applyToPodSpec(&job.Spec.Template.Spec, 0)
+	c.Source.modelSourcePodAdditions.applyToPodSpec(&job.Spec.Template.Spec, 0)
 
 	return job
 }
diff --git a/internal/modelcontroller/engine_fasterwhisper.go b/internal/modelcontroller/engine_fasterwhisper.go
index 03ab809..30d7468 100644
--- a/internal/modelcontroller/engine_fasterwhisper.go
+++ b/internal/modelcontroller/engine_fasterwhisper.go
@@ -137,7 +137,7 @@ func (r *ModelReconciler) fasterWhisperPodForModel(m *kubeaiv1.Model, c ModelCon
 	}
 
 	patchServerCacheVolumes(&pod.Spec, m, c)
-	c.Source.modelAuthCredentials.applyToPodSpec(&pod.Spec, 0)
+	c.Source.modelSourcePodAdditions.applyToPodSpec(&pod.Spec, 0)
 
 	return pod
 }
diff --git a/internal/modelcontroller/engine_infinity.go b/internal/modelcontroller/engine_infinity.go
index 1fa217f..1b69901 100644
--- a/internal/modelcontroller/engine_infinity.go
+++ b/internal/modelcontroller/engine_infinity.go
@@ -157,7 +157,7 @@ func (r *ModelReconciler) infinityPodForModel(m *kubeaiv1.Model, c ModelConfig)
 	}
 
 	patchServerCacheVolumes(&pod.Spec, m, c)
-	c.Source.modelAuthCredentials.applyToPodSpec(&pod.Spec, 0)
+	c.Source.modelSourcePodAdditions.applyToPodSpec(&pod.Spec, 0)
 
 	return pod
 }
diff --git a/internal/modelcontroller/engine_ollama.go b/internal/modelcontroller/engine_ollama.go
index 89c9684..3bd812e 100644
--- a/internal/modelcontroller/engine_ollama.go
+++ b/internal/modelcontroller/engine_ollama.go
@@ -169,7 +169,7 @@ func (r *ModelReconciler) oLlamaPodForModel(m *kubeaiv1.Model, c ModelConfig) *c
 	}
 
 	patchServerCacheVolumes(&pod.Spec, m, c)
-	c.Source.modelAuthCredentials.applyToPodSpec(&pod.Spec, 0)
+	c.Source.modelSourcePodAdditions.applyToPodSpec(&pod.Spec, 0)
 
 	return pod
 
diff --git a/internal/modelcontroller/engine_vllm.go b/internal/modelcontroller/engine_vllm.go
index c3b3eae..b13d48a 100644
--- a/internal/modelcontroller/engine_vllm.go
+++ b/internal/modelcontroller/engine_vllm.go
@@ -21,6 +21,11 @@ func (r *ModelReconciler) vLLMPodForModel(m *kubeaiv1.Model, c ModelConfig) *cor
 	if m.Spec.CacheProfile != "" {
 		vllmModelFlag = modelCacheDir(m)
 	}
+	// The vllmModelFlag can be safely overridden because validation logic ensures
+	// that a model with PVC source and cacheProfile won't be admitted.
+	if c.Source.url.scheme == "pvc" {
+		vllmModelFlag = "/model"
+	}
 
 	args := []string{
 		"--model=" + vllmModelFlag,
@@ -145,7 +150,7 @@ func (r *ModelReconciler) vLLMPodForModel(m *kubeaiv1.Model, c ModelConfig) *cor
 
 	r.patchServerAdapterLoader(&pod.Spec, m, r.ModelLoaders.Image)
 	patchServerCacheVolumes(&pod.Spec, m, c)
-	c.Source.modelAuthCredentials.applyToPodSpec(&pod.Spec, 0)
+	c.Source.modelSourcePodAdditions.applyToPodSpec(&pod.Spec, 0)
 
 	return pod
 }
diff --git a/internal/modelcontroller/model_source.go b/internal/modelcontroller/model_source.go
index de2cbc6..9cb1c31 100644
--- a/internal/modelcontroller/model_source.go
+++ b/internal/modelcontroller/model_source.go
@@ -3,13 +3,14 @@ package modelcontroller
 import (
 	"fmt"
 	"regexp"
+	"strings"
 
 	corev1 "k8s.io/api/core/v1"
 	"k8s.io/utils/ptr"
 )
 
 type modelSource struct {
-	*modelAuthCredentials
+	*modelSourcePodAdditions
 	url modelURL
 }
 
@@ -24,39 +25,41 @@ func (r *ModelReconciler) parseModelSource(urlStr string) (modelSource, error) {
 
 	switch {
 	case u.scheme == "gs":
-		src.modelAuthCredentials = r.authForGCS()
+		src.modelSourcePodAdditions = r.authForGCS()
 	case u.scheme == "oss":
-		src.modelAuthCredentials = r.authForOSS()
+		src.modelSourcePodAdditions = r.authForOSS()
 	case u.scheme == "s3":
-		src.modelAuthCredentials = r.authForS3()
+		src.modelSourcePodAdditions = r.authForS3()
 	case u.scheme == "hf":
-		src.modelAuthCredentials = r.authForHuggingfaceHub()
-	case u.scheme == "ollama":
-		src.modelAuthCredentials = &modelAuthCredentials{}
+		src.modelSourcePodAdditions = r.authForHuggingfaceHub()
+	case u.scheme == "pvc":
+		src.modelSourcePodAdditions = r.pvcPodAdditions(u)
+	default:
+		src.modelSourcePodAdditions = &modelSourcePodAdditions{}
 	}
 	return src, nil
 }
 
-type modelAuthCredentials struct {
+type modelSourcePodAdditions struct {
 	env          []corev1.EnvVar
 	volumes      []corev1.Volume
 	volumeMounts []corev1.VolumeMount
 }
 
-func (c *modelAuthCredentials) append(other *modelAuthCredentials) {
+func (c *modelSourcePodAdditions) append(other *modelSourcePodAdditions) {
 	c.env = append(c.env, other.env...)
 	c.volumes = append(c.volumes, other.volumes...)
 	c.volumeMounts = append(c.volumeMounts, other.volumeMounts...)
 }
 
-func (c *modelAuthCredentials) applyToPodSpec(spec *corev1.PodSpec, containerIndex int) {
+func (c *modelSourcePodAdditions) applyToPodSpec(spec *corev1.PodSpec, containerIndex int) {
 	spec.Containers[containerIndex].Env = append(spec.Containers[containerIndex].Env, c.env...)
 	spec.Volumes = append(spec.Volumes, c.volumes...)
 	spec.Containers[containerIndex].VolumeMounts = append(spec.Containers[containerIndex].VolumeMounts, c.volumeMounts...)
 }
 
-func (r *ModelReconciler) modelAuthCredentialsForAllSources() *modelAuthCredentials {
-	c := &modelAuthCredentials{}
+func (r *ModelReconciler) modelAuthCredentialsForAllSources() *modelSourcePodAdditions {
+	c := &modelSourcePodAdditions{}
 	c.append(r.authForHuggingfaceHub())
 	c.append(r.authForGCS())
 	c.append(r.authForOSS())
@@ -64,8 +67,8 @@ func (r *ModelReconciler) modelAuthCredentialsForAllSources() *modelAuthCredenti
 	return c
 }
 
-func (r *ModelReconciler) authForS3() *modelAuthCredentials {
-	return &modelAuthCredentials{
+func (r *ModelReconciler) authForS3() *modelSourcePodAdditions {
+	return &modelSourcePodAdditions{
 		env: []corev1.EnvVar{
 			{
 				Name: "AWS_ACCESS_KEY_ID",
@@ -95,8 +98,8 @@ func (r *ModelReconciler) authForS3() *modelAuthCredentials {
 	}
 }
 
-func (r *ModelReconciler) authForHuggingfaceHub() *modelAuthCredentials {
-	return &modelAuthCredentials{
+func (r *ModelReconciler) authForHuggingfaceHub() *modelSourcePodAdditions {
+	return &modelSourcePodAdditions{
 		env: []corev1.EnvVar{
 			{
 				Name: "HF_TOKEN",
@@ -114,14 +117,14 @@ func (r *ModelReconciler) authForHuggingfaceHub() *modelAuthCredentials {
 	}
 }
 
-func (r *ModelReconciler) authForGCS() *modelAuthCredentials {
+func (r *ModelReconciler) authForGCS() *modelSourcePodAdditions {
 	const (
 		credentialsDir      = "/secrets/gcp-credentials"
 		credentialsFilename = "credentials.json"
 		credentialsPath     = credentialsDir + "/" + credentialsFilename
 		volumeName          = "gcp-credentials"
 	)
-	return &modelAuthCredentials{
+	return &modelSourcePodAdditions{
 		env: []corev1.EnvVar{
 			{
 				Name:  "GOOGLE_APPLICATION_CREDENTIALS",
@@ -154,8 +157,8 @@ func (r *ModelReconciler) authForGCS() *modelAuthCredentials {
 	}
 }
 
-func (r *ModelReconciler) authForOSS() *modelAuthCredentials {
-	return &modelAuthCredentials{
+func (r *ModelReconciler) authForOSS() *modelSourcePodAdditions {
+	return &modelSourcePodAdditions{
 		env: []corev1.EnvVar{
 			{
 				Name: "OSS_ACCESS_KEY_ID",
@@ -185,6 +188,32 @@ func (r *ModelReconciler) authForOSS() *modelAuthCredentials {
 	}
 }
 
+func (r *ModelReconciler) pvcPodAdditions(url modelURL) *modelSourcePodAdditions {
+	volumeName := "model"
+	// Kubernetes does not support an subPath with a leading slash. SubPath needs to be
+	// a relative path or empty string to mount the entire volume.
+	path := strings.TrimLeft(url.path, "/")
+	return &modelSourcePodAdditions{
+		volumes: []corev1.Volume{
+			{
+				Name: volumeName,
+				VolumeSource: corev1.VolumeSource{
+					PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{
+						ClaimName: url.name,
+					},
+				},
+			},
+		},
+		volumeMounts: []corev1.VolumeMount{
+			{
+				Name:      volumeName,
+				MountPath: "/model",
+				SubPath:   path,
+			},
+		},
+	}
+}
+
 var modelURLRegex = regexp.MustCompile(`^([a-z]+):\/\/(\S+)$`)
 
 func parseModelURL(urlStr string) (modelURL, error) {
@@ -192,10 +221,14 @@ func parseModelURL(urlStr string) (modelURL, error) {
 	if len(matches) != 3 {
 		return modelURL{}, fmt.Errorf("invalid model URL: %s", urlStr)
 	}
+	scheme, ref := matches[1], matches[2]
+	name, path, _ := strings.Cut(ref, "/")
 	return modelURL{
 		original: urlStr,
-		scheme:   matches[1],
-		ref:      matches[2],
+		scheme:   scheme,
+		ref:      ref,
+		name:     name,
+		path:     path,
 	}, nil
 }
 
@@ -203,4 +236,6 @@ type modelURL struct {
 	original string // e.g. "hf://username/model"
 	scheme   string // e.g. "hf", "s3", "gs", "oss"
 	ref      string // e.g. "username/model"
+	name     string // e.g. username or bucket-name
+	path     string // e.g. model or path/to/model
 }
diff --git a/manifests/models/bge-embed-text-cpu.yaml b/manifests/models/bge-embed-text-cpu.yaml
index 34324ac..8399543 100644
--- a/manifests/models/bge-embed-text-cpu.yaml
+++ b/manifests/models/bge-embed-text-cpu.yaml
@@ -5,7 +5,6 @@ metadata:
   name: bge-embed-text-cpu
 spec:
   features: [TextEmbedding]
-  owner: baai
   url: hf://BAAI/bge-small-en-v1.5
   engine: Infinity
   resourceProfile: cpu:1
diff --git a/manifests/models/e5-mistral-7b-instruct-cpu.yaml b/manifests/models/e5-mistral-7b-instruct-cpu.yaml
index 42205a8..d7098cc 100644
--- a/manifests/models/e5-mistral-7b-instruct-cpu.yaml
+++ b/manifests/models/e5-mistral-7b-instruct-cpu.yaml
@@ -5,7 +5,6 @@ metadata:
   name: e5-mistral-7b-instruct-cpu
 spec:
   features: [TextEmbedding]
-  owner: intfloat
   url: hf://intfloat/e5-mistral-7b-instruct
   engine: VLLM
   args:
diff --git a/manifests/models/faster-whisper-medium-en-cpu.yaml b/manifests/models/faster-whisper-medium-en-cpu.yaml
index 8dc61e8..296acd3 100644
--- a/manifests/models/faster-whisper-medium-en-cpu.yaml
+++ b/manifests/models/faster-whisper-medium-en-cpu.yaml
@@ -5,7 +5,6 @@ metadata:
   name: faster-whisper-medium-en-cpu
 spec:
   features: [SpeechToText]
-  owner: Systran
   url: hf://Systran/faster-whisper-medium.en
   engine: FasterWhisper
   resourceProfile: cpu:1
diff --git a/manifests/models/gemma-2b-it-tpu.yaml b/manifests/models/gemma-2b-it-tpu.yaml
index 3a933b7..2ac1db2 100644
--- a/manifests/models/gemma-2b-it-tpu.yaml
+++ b/manifests/models/gemma-2b-it-tpu.yaml
@@ -5,7 +5,6 @@ metadata:
   name: gemma-2b-it-tpu
 spec:
   features: [TextGeneration]
-  owner: google
   url: hf://google/gemma-2b-it
   engine: VLLM
   args:
diff --git a/manifests/models/gemma2-2b-cpu.yaml b/manifests/models/gemma2-2b-cpu.yaml
index 45ce227..17d0f70 100644
--- a/manifests/models/gemma2-2b-cpu.yaml
+++ b/manifests/models/gemma2-2b-cpu.yaml
@@ -5,7 +5,6 @@ metadata:
   name: gemma2-2b-cpu
 spec:
   features: [TextGeneration]
-  owner: google
   url: ollama://gemma2:2b
   engine: OLlama
   resourceProfile: cpu:2
diff --git a/manifests/models/llama-3.1-405b-instruct-fp8-a100-80b.yaml b/manifests/models/llama-3.1-405b-instruct-fp8-a100-80b.yaml
index da2940e..62c4b84 100644
--- a/manifests/models/llama-3.1-405b-instruct-fp8-a100-80b.yaml
+++ b/manifests/models/llama-3.1-405b-instruct-fp8-a100-80b.yaml
@@ -5,7 +5,6 @@ metadata:
   name: llama-3.1-405b-instruct-fp8-a100-80b
 spec:
   features: [TextGeneration]
-  owner:
   url: hf://neuralmagic/Meta-Llama-3.1-405B-Instruct-FP8
   engine: VLLM
   args:
diff --git a/manifests/models/llama-3.1-405b-instruct-fp8-h100.yaml b/manifests/models/llama-3.1-405b-instruct-fp8-h100.yaml
index d2e056a..ad4a038 100644
--- a/manifests/models/llama-3.1-405b-instruct-fp8-h100.yaml
+++ b/manifests/models/llama-3.1-405b-instruct-fp8-h100.yaml
@@ -5,7 +5,6 @@ metadata:
   name: llama-3.1-405b-instruct-fp8-h100
 spec:
   features: [TextGeneration]
-  owner:
   url: hf://neuralmagic/Meta-Llama-3.1-405B-Instruct-FP8
   engine: VLLM
   args:
diff --git a/manifests/models/llama-3.1-70b-instruct-awq-int4-gh200.yaml b/manifests/models/llama-3.1-70b-instruct-awq-int4-gh200.yaml
index dd2ef87..5d85348 100644
--- a/manifests/models/llama-3.1-70b-instruct-awq-int4-gh200.yaml
+++ b/manifests/models/llama-3.1-70b-instruct-awq-int4-gh200.yaml
@@ -5,7 +5,6 @@ metadata:
   name: llama-3.1-70b-instruct-awq-int4-gh200
 spec:
   features: [TextGeneration]
-  owner:
   url: hf://hugging-quants/Meta-Llama-3.1-70B-Instruct-AWQ-INT4
   engine: VLLM
   args:
diff --git a/manifests/models/llama-3.1-70b-instruct-fp8-gh200.yaml b/manifests/models/llama-3.1-70b-instruct-fp8-gh200.yaml
index 6e690e3..f81e423 100644
--- a/manifests/models/llama-3.1-70b-instruct-fp8-gh200.yaml
+++ b/manifests/models/llama-3.1-70b-instruct-fp8-gh200.yaml
@@ -5,7 +5,6 @@ metadata:
   name: llama-3.1-70b-instruct-fp8-gh200
 spec:
   features: [TextGeneration]
-  owner:
   url: hf://neuralmagic/Meta-Llama-3.1-70B-Instruct-FP8
   engine: VLLM
   args:
diff --git a/manifests/models/llama-3.1-70b-instruct-fp8-h100.yaml b/manifests/models/llama-3.1-70b-instruct-fp8-h100.yaml
index 4c308bb..686855a 100644
--- a/manifests/models/llama-3.1-70b-instruct-fp8-h100.yaml
+++ b/manifests/models/llama-3.1-70b-instruct-fp8-h100.yaml
@@ -5,7 +5,6 @@ metadata:
   name: llama-3.1-70b-instruct-fp8-h100
 spec:
   features: [TextGeneration]
-  owner:
   url: hf://neuralmagic/Meta-Llama-3.1-70B-Instruct-FP8
   engine: VLLM
   args:
diff --git a/manifests/models/llama-3.1-70b-instruct-fp8-l4.yaml b/manifests/models/llama-3.1-70b-instruct-fp8-l4.yaml
index b258c5e..a45d1bb 100644
--- a/manifests/models/llama-3.1-70b-instruct-fp8-l4.yaml
+++ b/manifests/models/llama-3.1-70b-instruct-fp8-l4.yaml
@@ -5,7 +5,6 @@ metadata:
   name: llama-3.1-70b-instruct-fp8-l4
 spec:
   features: [TextGeneration]
-  owner:
   url: hf://neuralmagic/Meta-Llama-3.1-70B-Instruct-FP8
   engine: VLLM
   args:
diff --git a/manifests/models/llama-3.1-8b-instruct-cpu.yaml b/manifests/models/llama-3.1-8b-instruct-cpu.yaml
index 0e5103d..6ef3a6c 100644
--- a/manifests/models/llama-3.1-8b-instruct-cpu.yaml
+++ b/manifests/models/llama-3.1-8b-instruct-cpu.yaml
@@ -5,7 +5,6 @@ metadata:
   name: llama-3.1-8b-instruct-cpu
 spec:
   features: [TextGeneration]
-  owner: meta-llama
   url: hf://meta-llama/Meta-Llama-3.1-8B-Instruct
   engine: VLLM
   args:
diff --git a/manifests/models/llama-3.1-8b-instruct-fp8-l4.yaml b/manifests/models/llama-3.1-8b-instruct-fp8-l4.yaml
index 9b5d953..432297a 100644
--- a/manifests/models/llama-3.1-8b-instruct-fp8-l4.yaml
+++ b/manifests/models/llama-3.1-8b-instruct-fp8-l4.yaml
@@ -5,7 +5,6 @@ metadata:
   name: llama-3.1-8b-instruct-fp8-l4
 spec:
   features: [TextGeneration]
-  owner: neuralmagic
   url: hf://neuralmagic/Meta-Llama-3.1-8B-Instruct-FP8
   engine: VLLM
   args:
diff --git a/manifests/models/llama-3.1-8b-instruct-tpu.yaml b/manifests/models/llama-3.1-8b-instruct-tpu.yaml
index 9efee36..d72314f 100644
--- a/manifests/models/llama-3.1-8b-instruct-tpu.yaml
+++ b/manifests/models/llama-3.1-8b-instruct-tpu.yaml
@@ -5,7 +5,6 @@ metadata:
   name: llama-3.1-8b-instruct-tpu
 spec:
   features: [TextGeneration]
-  owner: meta-llama
   url: hf://meta-llama/Meta-Llama-3.1-8B-Instruct
   engine: VLLM
   args:
diff --git a/manifests/models/llama-3.2-11b-vision-instruct-l4.yaml b/manifests/models/llama-3.2-11b-vision-instruct-l4.yaml
index 339c698..b66251a 100644
--- a/manifests/models/llama-3.2-11b-vision-instruct-l4.yaml
+++ b/manifests/models/llama-3.2-11b-vision-instruct-l4.yaml
@@ -5,7 +5,6 @@ metadata:
   name: llama-3.2-11b-vision-instruct-l4
 spec:
   features: [TextGeneration]
-  owner:
   url: hf://neuralmagic/Llama-3.2-11B-Vision-Instruct-FP8-dynamic
   engine: VLLM
   args:
diff --git a/manifests/models/nomic-embed-text-cpu.yaml b/manifests/models/nomic-embed-text-cpu.yaml
index 3c901a4..9d3d4b8 100644
--- a/manifests/models/nomic-embed-text-cpu.yaml
+++ b/manifests/models/nomic-embed-text-cpu.yaml
@@ -5,7 +5,6 @@ metadata:
   name: nomic-embed-text-cpu
 spec:
   features: [TextEmbedding]
-  owner: nomic
   url: ollama://nomic-embed-text
   engine: OLlama
   resourceProfile: cpu:1
diff --git a/manifests/models/opt-125m-cpu.yaml b/manifests/models/opt-125m-cpu.yaml
index 9fb87e5..e49c50f 100644
--- a/manifests/models/opt-125m-cpu.yaml
+++ b/manifests/models/opt-125m-cpu.yaml
@@ -5,7 +5,6 @@ metadata:
   name: opt-125m-cpu
 spec:
   features: [TextGeneration]
-  owner: facebook
   url: hf://facebook/opt-125m
   engine: VLLM
   resourceProfile: cpu:1
diff --git a/manifests/models/opt-125m-l4.yaml b/manifests/models/opt-125m-l4.yaml
index d2ee5d4..fa7d6e1 100644
--- a/manifests/models/opt-125m-l4.yaml
+++ b/manifests/models/opt-125m-l4.yaml
@@ -5,7 +5,6 @@ metadata:
   name: opt-125m-l4
 spec:
   features: [TextGeneration]
-  owner: facebook
   url: hf://facebook/opt-125m
   engine: VLLM
   resourceProfile: nvidia-gpu-l4:1
diff --git a/manifests/models/qwen2-500m-cpu.yaml b/manifests/models/qwen2-500m-cpu.yaml
index ee7f7b2..fbae035 100644
--- a/manifests/models/qwen2-500m-cpu.yaml
+++ b/manifests/models/qwen2-500m-cpu.yaml
@@ -5,7 +5,6 @@ metadata:
   name: qwen2-500m-cpu
 spec:
   features: [TextGeneration]
-  owner: alibaba
   url: ollama://qwen2:0.5b
   engine: OLlama
   resourceProfile: cpu:1
diff --git a/manifests/models/qwen2.5-7b-cpu.yaml b/manifests/models/qwen2.5-7b-cpu.yaml
index 9680a20..989809d 100644
--- a/manifests/models/qwen2.5-7b-cpu.yaml
+++ b/manifests/models/qwen2.5-7b-cpu.yaml
@@ -5,7 +5,6 @@ metadata:
   name: qwen2.5-7b-cpu
 spec:
   features: [TextGeneration]
-  owner:
   url: ollama://qwen2.5:7b
   engine: OLlama
   resourceProfile: cpu:2
diff --git a/manifests/models/qwen2.5-coder-1.5b-cpu.yaml b/manifests/models/qwen2.5-coder-1.5b-cpu.yaml
index 2c99feb..b1c8f2a 100644
--- a/manifests/models/qwen2.5-coder-1.5b-cpu.yaml
+++ b/manifests/models/qwen2.5-coder-1.5b-cpu.yaml
@@ -5,7 +5,6 @@ metadata:
   name: qwen2.5-coder-1.5b-cpu
 spec:
   features: [TextGeneration]
-  owner:
   url: ollama://qwen2.5-coder:1.5b
   engine: OLlama
   resourceProfile: cpu:1
diff --git a/manifests/models/qwen2.5-coder-1.5b-rtx4070-8gb.yaml b/manifests/models/qwen2.5-coder-1.5b-rtx4070-8gb.yaml
index a29989a..6212e80 100644
--- a/manifests/models/qwen2.5-coder-1.5b-rtx4070-8gb.yaml
+++ b/manifests/models/qwen2.5-coder-1.5b-rtx4070-8gb.yaml
@@ -5,7 +5,6 @@ metadata:
   name: qwen2.5-coder-1.5b-rtx4070-8gb
 spec:
   features: [TextGeneration]
-  owner:
   url: hf://Qwen/Qwen2.5-Coder-1.5B-Instruct
   engine: VLLM
   args:


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index cce0101..7ac34e9 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -27,7 +27,12 @@ jobs:
     #needs: unit-and-integration # No use in running e2e tests if integration tests fail.
     strategy:
       matrix:
-        testcase: ["quickstart", "openai-python-client", "autoscaler-restart", "cache-shared-filesystem"]
+        testcase:
+        - "quickstart"
+        - "openai-python-client"
+        - "autoscaler-restart"
+        - "cache-shared-filesystem"
+        - "engine-vllm-pvc"
     steps:
       - name: Checkout code
         uses: actions/checkout@v4
diff --git a/internal/modelcontroller/model_source_test.go b/internal/modelcontroller/model_source_test.go
index 206b43f..2c3c453 100644
--- a/internal/modelcontroller/model_source_test.go
+++ b/internal/modelcontroller/model_source_test.go
@@ -28,6 +28,8 @@ func Test_parseModelURL(t *testing.T) {
 			want: modelURL{
 				scheme: "a",
 				ref:    "path/b://to/model",
+				name:   "path",
+				path:   "b://to/model",
 			},
 		},
 		"valid-google-storage": {
@@ -35,6 +37,8 @@ func Test_parseModelURL(t *testing.T) {
 			want: modelURL{
 				scheme: "gs",
 				ref:    "bucket-name/path/to/model",
+				name:   "bucket-name",
+				path:   "path/to/model",
 			},
 		},
 		"valid-ollama": {
@@ -42,6 +46,8 @@ func Test_parseModelURL(t *testing.T) {
 			want: modelURL{
 				scheme: "ollama",
 				ref:    "gemma2:2b",
+				name:   "gemma2:2b",
+				path:   "",
 			},
 		},
 		"valid-huggingface": {
@@ -49,6 +55,44 @@ func Test_parseModelURL(t *testing.T) {
 			want: modelURL{
 				scheme: "hf",
 				ref:    "test-user/model-name",
+				name:   "test-user",
+				path:   "model-name",
+			},
+		},
+		"valid-pvc": {
+			input: "pvc://my-vpc/path/to/model",
+			want: modelURL{
+				scheme: "pvc",
+				ref:    "my-vpc/path/to/model",
+				name:   "my-vpc",
+				path:   "path/to/model",
+			},
+		},
+		"valid-pvc-no-path": {
+			input: "pvc://my-vpc",
+			want: modelURL{
+				scheme: "pvc",
+				ref:    "my-vpc",
+				name:   "my-vpc",
+				path:   "",
+			},
+		},
+		"valid-pvc-with-slash-empty": {
+			input: "pvc://my-vpc/",
+			want: modelURL{
+				scheme: "pvc",
+				ref:    "my-vpc/",
+				name:   "my-vpc",
+				path:   "",
+			},
+		},
+		"valid-pvc-with-double-slash": {
+			input: "pvc://my-vpc//",
+			want: modelURL{
+				scheme: "pvc",
+				ref:    "my-vpc//",
+				name:   "my-vpc",
+				path:   "/",
 			},
 		},
 	}
diff --git a/test/e2e/engine-vllm-pvc/pv.yaml b/test/e2e/engine-vllm-pvc/pv.yaml
new file mode 100644
index 0000000..7173207
--- /dev/null
+++ b/test/e2e/engine-vllm-pvc/pv.yaml
@@ -0,0 +1,16 @@
+apiVersion: v1
+kind: PersistentVolume
+metadata:
+  name: kind-model-hostpath
+spec:
+  storageClassName: manual
+  capacity:
+    storage: 10Gi
+  accessModes:
+    - ReadWriteMany
+    - ReadOnlyMany
+    - ReadWriteOnce
+  hostPath:
+    path: /tmp/model
+    type: DirectoryOrCreate
+  persistentVolumeReclaimPolicy: Retain
diff --git a/test/e2e/engine-vllm-pvc/pvc.yaml b/test/e2e/engine-vllm-pvc/pvc.yaml
new file mode 100644
index 0000000..93e443c
--- /dev/null
+++ b/test/e2e/engine-vllm-pvc/pvc.yaml
@@ -0,0 +1,12 @@
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+  name: model-pvc
+spec:
+  storageClassName: manual
+  accessModes:
+    - ReadOnlyMany
+  resources:
+    requests:
+      storage: 10Gi
+  volumeName: kind-model-hostpath
\ No newline at end of file
diff --git a/test/e2e/engine-vllm-pvc/test.sh b/test/e2e/engine-vllm-pvc/test.sh
new file mode 100755
index 0000000..65f5057
--- /dev/null
+++ b/test/e2e/engine-vllm-pvc/test.sh
@@ -0,0 +1,40 @@
+#!/bin/bash
+
+source $REPO_DIR/test/e2e/common.sh
+
+models_release="kubeai-models"
+
+PV_HOST_PATH=/tmp/model
+
+mkdir -p ${PV_HOST_PATH}
+
+# Execute into the kind container
+kind_container=$(docker ps --filter "name=kind-control-plane" --format "{{.ID}}")
+docker exec -i $kind_container bash -c "
+  apt update -y && apt install -y python3-pip
+  pip install -U "huggingface_hub[cli]" --break-system-packages
+  mkdir -p ${PV_HOST_PATH}
+  huggingface-cli download facebook/opt-125m --local-dir ${PV_HOST_PATH} \
+    --exclude 'tf_model.h5' 'flax_model.msgpack'"
+
+kubectl apply -f $REPO_DIR/test/e2e/engine-vllm-pvc/pv.yaml
+kubectl apply -f $REPO_DIR/test/e2e/engine-vllm-pvc/pvc.yaml
+
+helm install $models_release $REPO_DIR/charts/models -f - <<EOF
+catalog:
+  opt-125m-cpu:
+    enabled: true
+    url: pvc://model-pvc
+    minReplicas: 2
+EOF
+
+sleep 5
+
+# There are 2 replicas so send 10 requests to ensure that both replicas are used.
+for i in {1..10}; do
+  echo "Sending request $i"
+  curl http://localhost:8000/openai/v1/completions \
+    --max-time 600 \
+    -H "Content-Type: application/json" \
+    -d '{"model": "opt-125m-cpu", "prompt": "Who was the first president of the United States?", "max_tokens": 40}'
+done
diff --git a/test/integration/model_validation_test.go b/test/integration/model_validation_test.go
index dd12264..d9591b2 100644
--- a/test/integration/model_validation_test.go
+++ b/test/integration/model_validation_test.go
@@ -262,6 +262,22 @@ func TestModelValidation(t *testing.T) {
 				"s3://",
 			},
 		},
+		{
+			model: v1.Model{
+				ObjectMeta: metadata("cache-profile-with-non-pvc-url-invalid"),
+				Spec: v1.ModelSpec{
+					URL:          "pvc://test-pvc/test-model",
+					Engine:       "VLLM",
+					Features:     []v1.ModelFeature{},
+					CacheProfile: "some-cache-profile",
+				},
+			},
+			expErrContains: []string{
+				"cacheProfile is only supported with urls of format",
+				"hf://",
+				"s3://",
+			},
+		},
 		{
 			model: v1.Model{
 				ObjectMeta: metadata("update-no-changes-valid"),