Files
kserve/pkg/controller/v1alpha2/llmisvc/controller_int_scheduler_config_test.go

1914 lines
66 KiB
Go

/*
Copyright 2025 The KServe Authors.
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 llmisvc_test
import (
"context"
"fmt"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/util/retry"
"k8s.io/utils/ptr"
"knative.dev/pkg/kmeta"
"sigs.k8s.io/controller-runtime/pkg/client"
igwapi "sigs.k8s.io/gateway-api-inference-extension/api/v1"
"github.com/kserve/kserve/pkg/apis/serving/v1alpha2"
"github.com/kserve/kserve/pkg/constants"
. "github.com/kserve/kserve/pkg/controller/v1alpha2/llmisvc/fixture"
)
var _ = Describe("LLMInferenceService Scheduler Config", func() {
Context("Inline scheduler config", func() {
It("should use inline scheduler config in the scheduler deployment", func(ctx SpecContext) {
// given
svcName := "test-llm-inline-scheduler-config"
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
customSchedulerConfig := `
apiVersion: inference.networking.x-k8s.io/v1alpha1
kind: EndpointPickerConfig
plugins:
- type: custom-plugin
parameters:
customParam: customValue
schedulingProfiles:
- name: custom
plugins:
- pluginRef: custom-plugin
weight: 5
`
llmSvc := LLMInferenceService(svcName,
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
WithModelURI("hf://facebook/opt-125m"),
WithManagedRoute(),
WithManagedGateway(),
WithManagedScheduler(),
WithSchedulerConfigInline(customSchedulerConfig),
)
// when
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
defer func() {
testNs.DeleteAndWait(ctx, llmSvc)
}()
// then - verify the scheduler deployment is created and uses the inline config
expectedDeployment := &appsv1.Deployment{}
Eventually(func(g Gomega, ctx context.Context) error {
return envTest.Get(ctx, types.NamespacedName{
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
Namespace: testNs.Name,
}, expectedDeployment)
}).WithContext(ctx).Should(Succeed())
// Verify the deployment uses the custom inline config
configText, found := getSchedulerConfigText(expectedDeployment)
Expect(found).To(BeTrue(), "Expected to find --config-text with inline config in scheduler deployment")
Expect(configText).To(ContainSubstring("custom-plugin"))
Expect(configText).To(ContainSubstring("customParam"))
// Verify status.workloads references include the scheduler
Eventually(func(g Gomega, ctx context.Context) {
current := &v1alpha2.LLMInferenceService{}
g.Expect(envTest.Get(ctx, types.NamespacedName{Name: svcName, Namespace: testNs.Name}, current)).To(Succeed())
g.Expect(current.Status.Workloads).NotTo(BeNil())
g.Expect(current.Status.Workloads.Scheduler).To(Equal(&corev1.TypedLocalObjectReference{
APIGroup: ptr.To("apps"),
Kind: "Deployment",
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
}))
}).WithContext(ctx).Should(Succeed())
// when - remove managed scheduler
Expect(retry.RetryOnConflict(retry.DefaultRetry, func() error {
current := &v1alpha2.LLMInferenceService{}
if err := envTest.Get(ctx, types.NamespacedName{Name: svcName, Namespace: testNs.Name}, current); err != nil {
return err
}
current.Spec.Router.Scheduler = nil
return envTest.Update(ctx, current)
})).To(Succeed())
// then - scheduler deployment should be deleted
Eventually(func(ctx context.Context) error {
return envTest.Get(ctx, types.NamespacedName{
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
Namespace: testNs.Name,
}, &appsv1.Deployment{})
}).WithContext(ctx).Should(MatchError(ContainSubstring("not found")))
// status.workloads.scheduler should be nil, other refs preserved
Eventually(func(g Gomega, ctx context.Context) {
current := &v1alpha2.LLMInferenceService{}
g.Expect(envTest.Get(ctx, types.NamespacedName{Name: svcName, Namespace: testNs.Name}, current)).To(Succeed())
g.Expect(current.Status.Workloads).NotTo(BeNil())
g.Expect(current.Status.Workloads.Scheduler).To(BeNil())
g.Expect(current.Status.Workloads.Primary).NotTo(BeNil())
g.Expect(current.Status.Workloads.Service).NotTo(BeNil())
}).WithContext(ctx).Should(Succeed())
})
It("should not override config when args already contain --config-text or --configFile", func(ctx SpecContext) {
// given
svcName := "test-llm-config-already-set"
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
// Create scheduler template with existing --config-text
modelConfig := LLMInferenceServiceConfig("model-config",
InNamespace[*v1alpha2.LLMInferenceServiceConfig](testNs.Name),
WithConfigModelName("facebook/opt-125m"),
WithConfigModelURI("hf://facebook/opt-125m"),
)
routerConfig := LLMInferenceServiceConfig("router-with-custom-args",
InNamespace[*v1alpha2.LLMInferenceServiceConfig](testNs.Name),
)
routerConfig.Spec.Router = &v1alpha2.RouterSpec{
Gateway: &v1alpha2.GatewaySpec{},
Route: &v1alpha2.GatewayRoutesSpec{},
Scheduler: &v1alpha2.SchedulerSpec{
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "main",
Image: "ghcr.io/llm-d/llm-d-router-endpoint-picker:v0.9.0-rc.2",
Args: []string{
"--config-text",
"existing-config-from-template",
"--poolName",
"test-pool",
},
Ports: []corev1.ContainerPort{
{Name: "grpc", ContainerPort: 9002, Protocol: corev1.ProtocolTCP},
{Name: "grpc-health", ContainerPort: 9003, Protocol: corev1.ProtocolTCP},
{Name: "metrics", ContainerPort: 9090, Protocol: corev1.ProtocolTCP},
},
},
},
},
Pool: &v1alpha2.InferencePoolSpec{},
},
}
Expect(envTest.Client.Create(ctx, modelConfig)).To(Succeed())
Expect(envTest.Client.Create(ctx, routerConfig)).To(Succeed())
llmSvc := LLMInferenceService(svcName,
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
WithBaseRefs(
corev1.LocalObjectReference{Name: "model-config"},
corev1.LocalObjectReference{Name: "router-with-custom-args"},
),
)
// when
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
defer func() {
testNs.DeleteAndWait(ctx, llmSvc)
}()
// then - verify the scheduler deployment preserves the existing --config-text from template
expectedDeployment := &appsv1.Deployment{}
Eventually(func(g Gomega, ctx context.Context) error {
return envTest.Get(ctx, types.NamespacedName{
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
Namespace: testNs.Name,
}, expectedDeployment)
}).WithContext(ctx).Should(Succeed())
// Verify that the existing --config-text is preserved (not overwritten or duplicated)
configText, found := getSchedulerConfigText(expectedDeployment)
Expect(found).To(BeTrue(), "Expected to find --config-text in scheduler deployment")
Expect(configText).To(Equal("existing-config-from-template"))
Expect(countConfigTextArgs(expectedDeployment)).To(Equal(1), "Expected exactly one --config-text argument")
})
})
Context("ConfigMap ref scheduler config", func() {
It("should resolve scheduler config from ConfigMap ref and use it in deployment", func(ctx SpecContext) {
// given
svcName := "test-llm-configmap-ref-scheduler"
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
configMapData := `
apiVersion: inference.networking.x-k8s.io/v1alpha1
kind: EndpointPickerConfig
plugins:
- type: configmap-plugin
schedulingProfiles:
- name: configmap-profile
plugins:
- pluginRef: configmap-plugin
weight: 10
`
configMap := SchedulerConfigMap("scheduler-config-custom", testNs.Name, configMapData)
Expect(envTest.Client.Create(ctx, configMap)).To(Succeed())
llmSvc := LLMInferenceService(svcName,
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
WithModelURI("hf://facebook/opt-125m"),
WithManagedRoute(),
WithManagedGateway(),
WithManagedScheduler(),
WithSchedulerConfigRef("scheduler-config-custom", ""),
)
// when
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
defer func() {
testNs.DeleteAndWait(ctx, llmSvc)
}()
// then - verify the scheduler deployment uses the config from ConfigMap
expectedDeployment := &appsv1.Deployment{}
Eventually(func(g Gomega, ctx context.Context) error {
return envTest.Get(ctx, types.NamespacedName{
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
Namespace: testNs.Name,
}, expectedDeployment)
}).WithContext(ctx).Should(Succeed())
// Verify the deployment uses config from ConfigMap
configText, found := getSchedulerConfigText(expectedDeployment)
Expect(found).To(BeTrue(), "Expected to find --config-text with ConfigMap config in scheduler deployment")
Expect(configText).To(ContainSubstring("configmap-plugin"))
Expect(configText).To(ContainSubstring("configmap-profile"))
})
It("should use custom key from ConfigMap ref", func(ctx SpecContext) {
// given
svcName := "test-llm-configmap-custom-key"
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
configMapData := `
apiVersion: inference.networking.x-k8s.io/v1alpha1
kind: EndpointPickerConfig
plugins:
- type: custom-key-plugin
schedulingProfiles:
- name: custom-key-profile
plugins:
- pluginRef: custom-key-plugin
`
configMap := SchedulerConfigMapWithKey("scheduler-config-custom-key", testNs.Name, "my-custom-key", configMapData)
Expect(envTest.Client.Create(ctx, configMap)).To(Succeed())
llmSvc := LLMInferenceService(svcName,
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
WithModelURI("hf://facebook/opt-125m"),
WithManagedRoute(),
WithManagedGateway(),
WithManagedScheduler(),
WithSchedulerConfigRef("scheduler-config-custom-key", "my-custom-key"),
)
// when
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
defer func() {
testNs.DeleteAndWait(ctx, llmSvc)
}()
// then
expectedDeployment := &appsv1.Deployment{}
Eventually(func(g Gomega, ctx context.Context) error {
return envTest.Get(ctx, types.NamespacedName{
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
Namespace: testNs.Name,
}, expectedDeployment)
}).WithContext(ctx).Should(Succeed())
// Verify the deployment uses config from the custom key
configText, found := getSchedulerConfigText(expectedDeployment)
Expect(found).To(BeTrue(), "Expected to find --config-text with custom key config")
Expect(configText).To(ContainSubstring("custom-key-plugin"))
})
})
Context("ConfigMap update triggers reconciliation", func() {
It("should update scheduler deployment when referenced ConfigMap is updated", func(ctx SpecContext) {
// given
svcName := "test-llm-configmap-update"
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
initialConfigData := `
apiVersion: inference.networking.x-k8s.io/v1alpha1
kind: EndpointPickerConfig
plugins:
- type: initial-plugin
schedulingProfiles:
- name: initial-profile
plugins:
- pluginRef: initial-plugin
`
configMap := SchedulerConfigMap("scheduler-config-update", testNs.Name, initialConfigData)
Expect(envTest.Client.Create(ctx, configMap)).To(Succeed())
llmSvc := LLMInferenceService(svcName,
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
WithModelURI("hf://facebook/opt-125m"),
WithManagedRoute(),
WithManagedGateway(),
WithManagedScheduler(),
WithSchedulerConfigRef("scheduler-config-update", ""),
)
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
defer func() {
testNs.DeleteAndWait(ctx, llmSvc)
}()
// Wait for initial deployment
expectedDeployment := &appsv1.Deployment{}
Eventually(func(g Gomega, ctx context.Context) error {
return envTest.Get(ctx, types.NamespacedName{
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
Namespace: testNs.Name,
}, expectedDeployment)
}).WithContext(ctx).Should(Succeed())
// Verify initial config
configText, found := getSchedulerConfigText(expectedDeployment)
Expect(found).To(BeTrue(), "Expected initial config in scheduler deployment")
Expect(configText).To(ContainSubstring("initial-plugin"))
// when - update ConfigMap
updatedConfigData := `
apiVersion: inference.networking.x-k8s.io/v1alpha1
kind: EndpointPickerConfig
plugins:
- type: updated-plugin
schedulingProfiles:
- name: updated-profile
plugins:
- pluginRef: updated-plugin
`
errRetry := retry.RetryOnConflict(retry.DefaultRetry, func() error {
updatedConfigMap := &corev1.ConfigMap{}
if err := envTest.Get(ctx, client.ObjectKeyFromObject(configMap), updatedConfigMap); err != nil {
return err
}
updatedConfigMap.Data["epp"] = updatedConfigData
return envTest.Update(ctx, updatedConfigMap)
})
Expect(errRetry).ToNot(HaveOccurred())
// then - verify the deployment is updated with new config
updatedDeployment := &appsv1.Deployment{}
Eventually(func(g Gomega, ctx context.Context) error {
updatedDeployment = &appsv1.Deployment{}
if err := envTest.Get(ctx, types.NamespacedName{
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
Namespace: testNs.Name,
}, updatedDeployment); err != nil {
return err
}
// Check for updated config using helper function
configText, found := getSchedulerConfigText(updatedDeployment)
g.Expect(found).To(BeTrue(), "Expected to find --config-text in updated deployment")
g.Expect(configText).To(ContainSubstring("updated-plugin"))
return nil
}).WithContext(ctx).Should(Succeed(), fmt.Sprintf("Expected to find updated scheduler config in updated deployment %#v", updatedDeployment))
})
})
Context("Default scheduler config", func() {
It("should use default scheduler config when no config is specified (non-prefill)", func(ctx SpecContext) {
// given
svcName := "test-llm-default-scheduler-config"
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
llmSvc := LLMInferenceService(svcName,
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
WithModelURI("hf://facebook/opt-125m"),
WithManagedRoute(),
WithManagedGateway(),
WithManagedScheduler(),
)
// No Config specified - should use default
// when
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
defer func() {
testNs.DeleteAndWait(ctx, llmSvc)
}()
// then - verify the scheduler deployment uses default config (for non-prefill mode)
expectedDeployment := &appsv1.Deployment{}
Eventually(func(g Gomega, ctx context.Context) error {
return envTest.Get(ctx, types.NamespacedName{
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
Namespace: testNs.Name,
}, expectedDeployment)
}).WithContext(ctx).Should(Succeed())
// Verify default config for non-prefill mode (should contain queue-scorer, kv-cache-utilization-scorer, etc.)
configText, found := getSchedulerConfigText(expectedDeployment)
Expect(found).To(BeTrue(), "Expected default config in scheduler deployment")
// Default non-prefill config should contain these plugins
Expect(configText).To(ContainSubstring("queue-scorer"))
Expect(configText).To(ContainSubstring("prefix-cache-scorer"))
Expect(configText).To(ContainSubstring("max-score-picker"))
Expect(configText).To(ContainSubstring("name: default"))
})
It("should use prefill/decode scheduler config when prefill is configured", func(ctx SpecContext) {
// given
svcName := "test-llm-prefill-scheduler-config"
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
llmSvc := LLMInferenceService(svcName,
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
WithModelURI("hf://facebook/opt-125m"),
WithManagedRoute(),
WithManagedGateway(),
WithManagedScheduler(),
WithPrefill(&corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "main",
Image: "quay.io/pierdipi/vllm-cpu:latest",
},
},
}),
)
// when
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
defer func() {
testNs.DeleteAndWait(ctx, llmSvc)
}()
// then - verify the scheduler deployment uses P/D config
expectedDeployment := &appsv1.Deployment{}
Eventually(func(g Gomega, ctx context.Context) error {
return envTest.Get(ctx, types.NamespacedName{
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
Namespace: testNs.Name,
}, expectedDeployment)
}).WithContext(ctx).Should(Succeed())
// Verify P/D config (should contain prefill-filter, decode-filter, disagg-profile-handler, etc.)
configText, found := getSchedulerConfigText(expectedDeployment)
Expect(found).To(BeTrue(), "Expected P/D config in scheduler deployment")
// P/D config should contain these plugins (using new v0.7.0 names)
Expect(configText).To(ContainSubstring("disagg-headers-handler"))
Expect(configText).To(ContainSubstring("prefill-filter"))
Expect(configText).To(ContainSubstring("decode-filter"))
Expect(configText).To(ContainSubstring("disagg-profile-handler"))
Expect(configText).To(ContainSubstring("name: prefill"))
Expect(configText).To(ContainSubstring("name: decode"))
})
})
Context("System namespace ConfigMap fallback", func() {
It("should resolve ConfigMap from system namespace when not found in service namespace", func(ctx SpecContext) {
// given
svcName := "test-llm-system-ns-configmap"
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
systemConfigData := `
apiVersion: inference.networking.x-k8s.io/v1alpha1
kind: EndpointPickerConfig
plugins:
- type: system-namespace-plugin
schedulingProfiles:
- name: system-profile
plugins:
- pluginRef: system-namespace-plugin
`
systemConfigMap := SchedulerConfigMap("config-scheduler-system", constants.KServeNamespace, systemConfigData)
Expect(envTest.Client.Create(ctx, systemConfigMap)).To(Succeed())
defer func() {
Expect(envTest.Client.Delete(ctx, systemConfigMap)).To(Succeed())
}()
llmSvc := LLMInferenceService(svcName,
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
WithModelURI("hf://facebook/opt-125m"),
WithManagedRoute(),
WithManagedGateway(),
WithManagedScheduler(),
WithSchedulerConfigRef("config-scheduler-system", ""),
)
// when
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
defer func() {
testNs.DeleteAndWait(ctx, llmSvc)
}()
// then - verify the scheduler deployment uses the config from system namespace
expectedDeployment := &appsv1.Deployment{}
Eventually(func(g Gomega, ctx context.Context) error {
return envTest.Get(ctx, types.NamespacedName{
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
Namespace: testNs.Name,
}, expectedDeployment)
}).WithContext(ctx).Should(Succeed())
// Verify the deployment uses config from system namespace
configText, found := getSchedulerConfigText(expectedDeployment)
Expect(found).To(BeTrue(), "Expected to find config from system namespace in scheduler deployment")
Expect(configText).To(ContainSubstring("system-namespace-plugin"))
})
})
Context("Scheduler config error handling", func() {
It("should report error when referenced ConfigMap does not exist", func(ctx SpecContext) {
// given
svcName := "test-llm-missing-configmap"
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
llmSvc := LLMInferenceService(svcName,
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
WithModelURI("hf://facebook/opt-125m"),
WithManagedRoute(),
WithManagedGateway(),
WithManagedScheduler(),
WithSchedulerConfigRef("non-existent-configmap", ""),
)
// when
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
defer func() {
testNs.DeleteAndWait(ctx, llmSvc)
}()
// then - the scheduler deployment should not be created and status should indicate error
Consistently(func(g Gomega, ctx context.Context) error {
deployment := &appsv1.Deployment{}
return envTest.Get(ctx, types.NamespacedName{
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
Namespace: testNs.Name,
}, deployment)
}).WithContext(ctx).
Within(2 * time.Second).
WithPolling(300 * time.Millisecond).
Should(HaveOccurred())
})
It("should report error when ConfigMap is missing the referenced key", func(ctx SpecContext) {
// given
svcName := "test-llm-missing-key"
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
// Create ConfigMap without the expected key
configMap := SchedulerConfigMapWithKey("scheduler-config-wrong-key", testNs.Name, "wrong-key", "some-config")
Expect(envTest.Client.Create(ctx, configMap)).To(Succeed())
llmSvc := LLMInferenceService(svcName,
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
WithModelURI("hf://facebook/opt-125m"),
WithManagedRoute(),
WithManagedGateway(),
WithManagedScheduler(),
WithSchedulerConfigRef("scheduler-config-wrong-key", ""),
)
// when
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
defer func() {
testNs.DeleteAndWait(ctx, llmSvc)
}()
// then - the scheduler deployment should not be created
Consistently(func(g Gomega, ctx context.Context) error {
deployment := &appsv1.Deployment{}
return envTest.Get(ctx, types.NamespacedName{
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
Namespace: testNs.Name,
}, deployment)
}).WithContext(ctx).
Within(2 * time.Second).
WithPolling(300 * time.Millisecond).
Should(HaveOccurred())
})
})
Context("Scheduler config via baseRefs", func() {
It("should inherit scheduler config from baseRef LLMInferenceServiceConfig", func(ctx SpecContext) {
// given
svcName := "test-llm-baseref-scheduler-config"
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
schedulerConfigData := `
apiVersion: inference.networking.x-k8s.io/v1alpha1
kind: EndpointPickerConfig
plugins:
- type: baseref-plugin
schedulingProfiles:
- name: baseref-profile
plugins:
- pluginRef: baseref-plugin
`
configMap := SchedulerConfigMap("scheduler-config-baseref", testNs.Name, schedulerConfigData)
Expect(envTest.Client.Create(ctx, configMap)).To(Succeed())
// Create LLMInferenceServiceConfig with scheduler config ref
modelConfig := LLMInferenceServiceConfig("model-with-scheduler",
InNamespace[*v1alpha2.LLMInferenceServiceConfig](testNs.Name),
WithConfigModelName("facebook/opt-125m"),
WithConfigModelURI("hf://facebook/opt-125m"),
WithConfigSchedulerConfigRef("scheduler-config-baseref", ""),
)
Expect(envTest.Client.Create(ctx, modelConfig)).To(Succeed())
routerConfig := LLMInferenceServiceConfig("router-managed-baseref",
InNamespace[*v1alpha2.LLMInferenceServiceConfig](testNs.Name),
WithConfigManagedRouter(),
)
Expect(envTest.Client.Create(ctx, routerConfig)).To(Succeed())
llmSvc := LLMInferenceService(svcName,
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
WithBaseRefs(
corev1.LocalObjectReference{Name: "model-with-scheduler"},
corev1.LocalObjectReference{Name: "router-managed-baseref"},
),
)
// when
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
defer func() {
testNs.DeleteAndWait(ctx, llmSvc)
}()
// then - verify the scheduler deployment uses the config from baseRef
expectedDeployment := &appsv1.Deployment{}
Eventually(func(g Gomega, ctx context.Context) error {
return envTest.Get(ctx, types.NamespacedName{
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
Namespace: testNs.Name,
}, expectedDeployment)
}).WithContext(ctx).Should(Succeed())
// Verify the deployment uses config inherited from baseRef
configText, found := getSchedulerConfigText(expectedDeployment)
Expect(found).To(BeTrue(), "Expected to find config from baseRef in scheduler deployment")
Expect(configText).To(ContainSubstring("baseref-plugin"))
})
It("should inherit inline scheduler config from baseRef LLMInferenceServiceConfig", func(ctx SpecContext) {
// given
svcName := "test-llm-baseref-inline-config"
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
inlineSchedulerConfig := `
apiVersion: inference.networking.x-k8s.io/v1alpha1
kind: EndpointPickerConfig
plugins:
- type: inline-baseref-plugin
schedulingProfiles:
- name: inline-baseref-profile
plugins:
- pluginRef: inline-baseref-plugin
`
// Create LLMInferenceServiceConfig with inline scheduler config
modelConfig := LLMInferenceServiceConfig("model-with-inline-scheduler",
InNamespace[*v1alpha2.LLMInferenceServiceConfig](testNs.Name),
WithConfigModelName("facebook/opt-125m"),
WithConfigModelURI("hf://facebook/opt-125m"),
WithConfigSchedulerConfigInline(inlineSchedulerConfig),
)
Expect(envTest.Client.Create(ctx, modelConfig)).To(Succeed())
routerConfig := LLMInferenceServiceConfig("router-managed-inline",
InNamespace[*v1alpha2.LLMInferenceServiceConfig](testNs.Name),
WithConfigManagedRouter(),
)
Expect(envTest.Client.Create(ctx, routerConfig)).To(Succeed())
llmSvc := LLMInferenceService(svcName,
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
WithBaseRefs(
corev1.LocalObjectReference{Name: "model-with-inline-scheduler"},
corev1.LocalObjectReference{Name: "router-managed-inline"},
),
)
// when
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
defer func() {
testNs.DeleteAndWait(ctx, llmSvc)
}()
// then - verify the scheduler deployment uses the inline config from baseRef
expectedDeployment := &appsv1.Deployment{}
Eventually(func(g Gomega, ctx context.Context) error {
return envTest.Get(ctx, types.NamespacedName{
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
Namespace: testNs.Name,
}, expectedDeployment)
}).WithContext(ctx).Should(Succeed())
// Verify the deployment uses inline config inherited from baseRef
configText, found := getSchedulerConfigText(expectedDeployment)
Expect(found).To(BeTrue(), "Expected to find inline config from baseRef in scheduler deployment")
Expect(configText).To(ContainSubstring("inline-baseref-plugin"))
Expect(configText).To(ContainSubstring("inline-baseref-profile"))
})
})
Context("Leader election flag injection", func() {
It("should inject --ha-enable-leader-election when replicas > 1", func(ctx SpecContext) {
// given
svcName := "test-llm-ha-replicas-gt-1"
nsName := kmeta.ChildName(svcName, "-test")
namespace := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: nsName,
},
}
Expect(envTest.Client.Create(ctx, namespace)).To(Succeed())
Expect(envTest.Client.Create(ctx, IstioShadowService(svcName, nsName))).To(Succeed())
defer func() {
envTest.DeleteAll(ctx, namespace)
}()
llmSvc := LLMInferenceService(svcName,
InNamespace[*v1alpha2.LLMInferenceService](nsName),
WithModelURI("hf://facebook/opt-125m"),
WithManagedRoute(),
WithManagedGateway(),
WithManagedScheduler(),
WithSchedulerReplicas(2),
)
// when
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
defer func() {
Expect(envTest.Delete(ctx, llmSvc)).To(Succeed())
}()
// then
expectedDeployment := &appsv1.Deployment{}
Eventually(func(g Gomega, ctx context.Context) error {
return envTest.Get(ctx, types.NamespacedName{
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
Namespace: nsName,
}, expectedDeployment)
}).WithContext(ctx).Should(Succeed())
Expect(hasLeaderElectionFlag(expectedDeployment)).To(BeTrue(),
"Expected --ha-enable-leader-election flag when replicas > 1")
})
It("should NOT inject --ha-enable-leader-election when replicas = 1", func(ctx SpecContext) {
// given
svcName := "test-llm-ha-replicas-eq-1"
nsName := kmeta.ChildName(svcName, "-test")
namespace := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: nsName,
},
}
Expect(envTest.Client.Create(ctx, namespace)).To(Succeed())
Expect(envTest.Client.Create(ctx, IstioShadowService(svcName, nsName))).To(Succeed())
defer func() {
envTest.DeleteAll(ctx, namespace)
}()
llmSvc := LLMInferenceService(svcName,
InNamespace[*v1alpha2.LLMInferenceService](nsName),
WithModelURI("hf://facebook/opt-125m"),
WithManagedRoute(),
WithManagedGateway(),
WithManagedScheduler(),
WithSchedulerReplicas(1),
)
// when
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
defer func() {
Expect(envTest.Delete(ctx, llmSvc)).To(Succeed())
}()
// then
expectedDeployment := &appsv1.Deployment{}
Eventually(func(g Gomega, ctx context.Context) error {
return envTest.Get(ctx, types.NamespacedName{
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
Namespace: nsName,
}, expectedDeployment)
}).WithContext(ctx).Should(Succeed())
Expect(hasLeaderElectionFlag(expectedDeployment)).To(BeFalse(),
"Expected NO --ha-enable-leader-election flag when replicas = 1")
})
It("should NOT inject --ha-enable-leader-election when replicas is not specified", func(ctx SpecContext) {
// given
svcName := "test-llm-ha-replicas-nil"
nsName := kmeta.ChildName(svcName, "-test")
namespace := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: nsName,
},
}
Expect(envTest.Client.Create(ctx, namespace)).To(Succeed())
Expect(envTest.Client.Create(ctx, IstioShadowService(svcName, nsName))).To(Succeed())
defer func() {
envTest.DeleteAll(ctx, namespace)
}()
llmSvc := LLMInferenceService(svcName,
InNamespace[*v1alpha2.LLMInferenceService](nsName),
WithModelURI("hf://facebook/opt-125m"),
WithManagedRoute(),
WithManagedGateway(),
WithManagedScheduler(),
// No scheduler replicas specified
)
// when
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
defer func() {
Expect(envTest.Delete(ctx, llmSvc)).To(Succeed())
}()
// then
expectedDeployment := &appsv1.Deployment{}
Eventually(func(g Gomega, ctx context.Context) error {
return envTest.Get(ctx, types.NamespacedName{
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
Namespace: nsName,
}, expectedDeployment)
}).WithContext(ctx).Should(Succeed())
Expect(hasLeaderElectionFlag(expectedDeployment)).To(BeFalse(),
"Expected NO --ha-enable-leader-election flag when replicas is not specified")
})
It("should NOT duplicate --ha-enable-leader-election when flag already exists", func(ctx SpecContext) {
// given
svcName := "test-llm-ha-flag-exists"
nsName := kmeta.ChildName(svcName, "-test")
namespace := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: nsName,
},
}
Expect(envTest.Client.Create(ctx, namespace)).To(Succeed())
Expect(envTest.Client.Create(ctx, IstioShadowService(svcName, nsName))).To(Succeed())
defer func() {
envTest.DeleteAll(ctx, namespace)
}()
// Create scheduler template with existing --ha-enable-leader-election
modelConfig := LLMInferenceServiceConfig("model-ha-config",
InNamespace[*v1alpha2.LLMInferenceServiceConfig](nsName),
WithConfigModelName("facebook/opt-125m"),
WithConfigModelURI("hf://facebook/opt-125m"),
)
routerConfig := LLMInferenceServiceConfig("router-with-ha-flag",
InNamespace[*v1alpha2.LLMInferenceServiceConfig](nsName),
)
routerConfig.Spec.Router = &v1alpha2.RouterSpec{
Gateway: &v1alpha2.GatewaySpec{},
Route: &v1alpha2.GatewayRoutesSpec{},
Scheduler: &v1alpha2.SchedulerSpec{
Replicas: ptr.To[int32](3),
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "main",
Image: "ghcr.io/llm-d/llm-d-router-endpoint-picker:v0.9.0-rc.2",
Args: []string{
"--ha-enable-leader-election",
"--poolName",
"test-pool",
},
Ports: []corev1.ContainerPort{
{Name: "grpc", ContainerPort: 9002, Protocol: corev1.ProtocolTCP},
{Name: "grpc-health", ContainerPort: 9003, Protocol: corev1.ProtocolTCP},
{Name: "metrics", ContainerPort: 9090, Protocol: corev1.ProtocolTCP},
},
},
},
},
Pool: &v1alpha2.InferencePoolSpec{},
},
}
Expect(envTest.Client.Create(ctx, modelConfig)).To(Succeed())
Expect(envTest.Client.Create(ctx, routerConfig)).To(Succeed())
llmSvc := LLMInferenceService(svcName,
InNamespace[*v1alpha2.LLMInferenceService](nsName),
WithBaseRefs(
corev1.LocalObjectReference{Name: "model-ha-config"},
corev1.LocalObjectReference{Name: "router-with-ha-flag"},
),
)
// when
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
defer func() {
Expect(envTest.Delete(ctx, llmSvc)).To(Succeed())
}()
// then
expectedDeployment := &appsv1.Deployment{}
Eventually(func(g Gomega, ctx context.Context) error {
return envTest.Get(ctx, types.NamespacedName{
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
Namespace: nsName,
}, expectedDeployment)
}).WithContext(ctx).Should(Succeed())
Expect(countLeaderElectionFlags(expectedDeployment)).To(Equal(1),
"Expected exactly one --ha-enable-leader-election flag (not duplicated)")
})
})
Context("Scheduler RBAC", func() {
It("should create scheduler role with leases permission for leader election", func(ctx SpecContext) {
// given
svcName := "test-llm-scheduler-rbac"
nsName := kmeta.ChildName(svcName, "-test")
namespace := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: nsName,
},
}
Expect(envTest.Client.Create(ctx, namespace)).To(Succeed())
Expect(envTest.Client.Create(ctx, IstioShadowService(svcName, nsName))).To(Succeed())
defer func() {
envTest.DeleteAll(ctx, namespace)
}()
llmSvc := LLMInferenceService(svcName,
InNamespace[*v1alpha2.LLMInferenceService](nsName),
WithModelURI("hf://facebook/opt-125m"),
WithManagedRoute(),
WithManagedGateway(),
WithManagedScheduler(),
)
// when
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
defer func() {
Expect(envTest.Delete(ctx, llmSvc)).To(Succeed())
}()
// then - verify the scheduler role is created with leases permission
expectedRole := &rbacv1.Role{}
Eventually(func(g Gomega, ctx context.Context) error {
return envTest.Get(ctx, types.NamespacedName{
Name: svcName + "-epp-role",
Namespace: nsName,
}, expectedRole)
}).WithContext(ctx).Should(Succeed())
// Verify leases permission exists
hasLeasesPermission := false
for _, rule := range expectedRole.Rules {
for _, apiGroup := range rule.APIGroups {
if apiGroup == "coordination.k8s.io" {
for _, resource := range rule.Resources {
if resource == "leases" {
hasLeasesPermission = true
// Verify all necessary verbs for leader election
Expect(rule.Verbs).To(ContainElements("get", "list", "watch", "create", "update", "patch", "delete"))
}
}
}
}
}
Expect(hasLeasesPermission).To(BeTrue(), "Expected scheduler role to have leases permission for leader election")
})
})
Context("UDS tokenizer config injection", func() {
It("should inject tokenizersPoolConfig when precise-prefix-cache-scorer has indexerConfig", func(ctx SpecContext) {
// given
svcName := "test-llm-uds-tokenizer-inject"
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
precisePrefixConfig := `
apiVersion: inference.networking.x-k8s.io/v1alpha1
kind: EndpointPickerConfig
plugins:
- type: single-profile-handler
- type: precise-prefix-cache-scorer
parameters:
indexerConfig:
tokenProcessorConfig:
blockSize: 16
hashSeed: "42"
- type: queue-scorer
- type: max-score-picker
schedulingProfiles:
- name: default
plugins:
- pluginRef: queue-scorer
weight: 2
- pluginRef: precise-prefix-cache-scorer
weight: 3
- pluginRef: max-score-picker
`
llmSvc := LLMInferenceService(svcName,
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
WithModelURI("hf://facebook/opt-125m"),
WithManagedRoute(),
WithManagedGateway(),
WithManagedScheduler(),
WithSchedulerConfigInline(precisePrefixConfig),
)
// when
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
defer func() {
testNs.DeleteAndWait(ctx, llmSvc)
}()
// then - verify the scheduler deployment has injected tokenizersPoolConfig
expectedDeployment := &appsv1.Deployment{}
Eventually(func(g Gomega, ctx context.Context) error {
if err := envTest.Get(ctx, types.NamespacedName{
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
Namespace: testNs.Name,
}, expectedDeployment); err != nil {
return err
}
configText, found := getSchedulerConfigText(expectedDeployment)
g.Expect(found).To(BeTrue(), "Expected to find --config-text in scheduler deployment")
g.Expect(configText).To(ContainSubstring("modelName: base"))
g.Expect(configText).To(ContainSubstring("socketFile: /tmp/tokenizer/tokenizer-uds.socket"))
// Verify tokenProcessorConfig was migrated from indexerConfig to top-level parameters
g.Expect(configText).To(ContainSubstring("tokenProcessorConfig"))
g.Expect(configText).To(ContainSubstring("blockSize: 16"))
return nil
}).WithContext(ctx).Should(Succeed())
})
It("should override existing tokenizersPoolConfig values", func(ctx SpecContext) {
// given
svcName := "test-llm-uds-tokenizer-override"
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
//nolint:gosec // G101: not a credential, scheduler config YAML
configWithExistingTokenizer := `
apiVersion: inference.networking.x-k8s.io/v1alpha1
kind: EndpointPickerConfig
plugins:
- type: single-profile-handler
- type: precise-prefix-cache-scorer
parameters:
indexerConfig:
tokenProcessorConfig:
blockSize: 16
hashSeed: "42"
tokenizersPoolConfig:
modelName: "wrong-model-name"
uds:
socketFile: /wrong/path/tokenizer.socket
- type: max-score-picker
schedulingProfiles:
- name: default
plugins:
- pluginRef: precise-prefix-cache-scorer
weight: 3
- pluginRef: max-score-picker
`
llmSvc := LLMInferenceService(svcName,
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
WithModelURI("hf://facebook/opt-125m"),
WithManagedRoute(),
WithManagedGateway(),
WithManagedScheduler(),
WithSchedulerConfigInline(configWithExistingTokenizer),
)
// when
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
defer func() {
testNs.DeleteAndWait(ctx, llmSvc)
}()
// then - verify the values are overridden with the correct ones
expectedDeployment := &appsv1.Deployment{}
Eventually(func(g Gomega, ctx context.Context) error {
if err := envTest.Get(ctx, types.NamespacedName{
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
Namespace: testNs.Name,
}, expectedDeployment); err != nil {
return err
}
configText, found := getSchedulerConfigText(expectedDeployment)
g.Expect(found).To(BeTrue(), "Expected to find --config-text in scheduler deployment")
g.Expect(configText).To(ContainSubstring("modelName: base"))
g.Expect(configText).To(ContainSubstring("socketFile: /tmp/tokenizer/tokenizer-uds.socket"))
g.Expect(configText).NotTo(ContainSubstring("wrong-model-name"))
g.Expect(configText).NotTo(ContainSubstring("/wrong/path"))
// Verify tokenProcessorConfig was migrated from indexerConfig to top-level parameters
g.Expect(configText).To(ContainSubstring("tokenProcessorConfig"))
g.Expect(configText).To(ContainSubstring("blockSize: 16"))
return nil
}).WithContext(ctx).Should(Succeed())
})
It("should not inject tokenizersPoolConfig when no precise-prefix-cache-scorer plugin", func(ctx SpecContext) {
// given
svcName := "test-llm-uds-no-precise-prefix"
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
configWithoutPrecisePrefix := `
apiVersion: inference.networking.x-k8s.io/v1alpha1
kind: EndpointPickerConfig
plugins:
- type: single-profile-handler
- type: queue-scorer
- type: prefix-cache-scorer
- type: max-score-picker
schedulingProfiles:
- name: default
plugins:
- pluginRef: queue-scorer
weight: 2
- pluginRef: prefix-cache-scorer
weight: 3
- pluginRef: max-score-picker
`
llmSvc := LLMInferenceService(svcName,
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
WithModelURI("hf://facebook/opt-125m"),
WithManagedRoute(),
WithManagedGateway(),
WithManagedScheduler(),
WithSchedulerConfigInline(configWithoutPrecisePrefix),
)
// when
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
defer func() {
testNs.DeleteAndWait(ctx, llmSvc)
}()
// then - verify no tokenizersPoolConfig is injected
expectedDeployment := &appsv1.Deployment{}
Eventually(func(g Gomega, ctx context.Context) error {
if err := envTest.Get(ctx, types.NamespacedName{
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
Namespace: testNs.Name,
}, expectedDeployment); err != nil {
return err
}
configText, found := getSchedulerConfigText(expectedDeployment)
g.Expect(found).To(BeTrue(), "Expected to find --config-text in scheduler deployment")
g.Expect(configText).NotTo(ContainSubstring("tokenizersPoolConfig"))
g.Expect(configText).NotTo(ContainSubstring("modelName: base"))
return nil
}).WithContext(ctx).Should(Succeed())
})
})
Context("tokenProcessorConfig migration from indexerConfig to top-level parameters", func() {
It("should migrate tokenProcessorConfig from indexerConfig to top-level plugin parameters", func(ctx SpecContext) {
// given
svcName := "test-llm-tpc-migrate"
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
oldFormatConfig := `
apiVersion: inference.networking.x-k8s.io/v1alpha1
kind: EndpointPickerConfig
plugins:
- type: single-profile-handler
- type: precise-prefix-cache-scorer
parameters:
indexerConfig:
tokenProcessorConfig:
blockSize: 64
hashSeed: "42"
kvBlockIndexConfig:
enableMetrics: true
- type: max-score-picker
schedulingProfiles:
- name: default
plugins:
- pluginRef: precise-prefix-cache-scorer
weight: 3
- pluginRef: max-score-picker
`
llmSvc := LLMInferenceService(svcName,
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
WithModelURI("hf://facebook/opt-125m"),
WithManagedRoute(),
WithManagedGateway(),
WithManagedScheduler(),
WithSchedulerConfigInline(oldFormatConfig),
)
// when
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
defer func() {
testNs.DeleteAndWait(ctx, llmSvc)
}()
// then - verify tokenProcessorConfig was migrated to top-level parameters
expectedDeployment := &appsv1.Deployment{}
Eventually(func(g Gomega, ctx context.Context) error {
if err := envTest.Get(ctx, types.NamespacedName{
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
Namespace: testNs.Name,
}, expectedDeployment); err != nil {
return err
}
configText, found := getSchedulerConfigText(expectedDeployment)
g.Expect(found).To(BeTrue(), "Expected to find --config-text in scheduler deployment")
// tokenProcessorConfig should be at top-level parameters (sibling of indexerConfig)
g.Expect(configText).To(ContainSubstring("blockSize: 64"))
g.Expect(configText).To(ContainSubstring("hashSeed: \"42\""))
// indexerConfig should still have kvBlockIndexConfig but not tokenProcessorConfig
g.Expect(configText).To(ContainSubstring("kvBlockIndexConfig"))
g.Expect(configText).To(ContainSubstring("enableMetrics: true"))
return nil
}).WithContext(ctx).Should(Succeed())
})
It("should not overwrite top-level tokenProcessorConfig if already present", func(ctx SpecContext) {
// given
svcName := "test-llm-tpc-no-overwrite"
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
newFormatConfig := `
apiVersion: inference.networking.x-k8s.io/v1alpha1
kind: EndpointPickerConfig
plugins:
- type: single-profile-handler
- type: precise-prefix-cache-scorer
parameters:
tokenProcessorConfig:
blockSize: 128
hashSeed: "99"
indexerConfig:
tokenProcessorConfig:
blockSize: 64
hashSeed: "42"
- type: max-score-picker
schedulingProfiles:
- name: default
plugins:
- pluginRef: precise-prefix-cache-scorer
weight: 3
- pluginRef: max-score-picker
`
llmSvc := LLMInferenceService(svcName,
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
WithModelURI("hf://facebook/opt-125m"),
WithManagedRoute(),
WithManagedGateway(),
WithManagedScheduler(),
WithSchedulerConfigInline(newFormatConfig),
)
// when
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
defer func() {
testNs.DeleteAndWait(ctx, llmSvc)
}()
// then - verify top-level tokenProcessorConfig is preserved, not overwritten
expectedDeployment := &appsv1.Deployment{}
Eventually(func(g Gomega, ctx context.Context) error {
if err := envTest.Get(ctx, types.NamespacedName{
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
Namespace: testNs.Name,
}, expectedDeployment); err != nil {
return err
}
configText, found := getSchedulerConfigText(expectedDeployment)
g.Expect(found).To(BeTrue(), "Expected to find --config-text in scheduler deployment")
// Top-level values should be preserved
g.Expect(configText).To(ContainSubstring("blockSize: 128"))
g.Expect(configText).To(ContainSubstring("hashSeed: \"99\""))
return nil
}).WithContext(ctx).Should(Succeed())
})
})
Context("Scheduler SA credential propagation", func() {
It("should propagate annotations and imagePullSecrets from main workload SA to generated scheduler SA", func(ctx SpecContext) {
// given
svcName := "test-llm-sa-propagation"
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
// Create a main workload SA with annotations and imagePullSecrets
mainSA := &corev1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{
Name: "main-workload-sa",
Namespace: testNs.Name,
Annotations: map[string]string{
"eks.amazonaws.com/role-arn": "arn:aws:iam::123456789012:role/my-model-role",
"custom-annotation": "custom-value",
},
},
ImagePullSecrets: []corev1.LocalObjectReference{
{Name: "my-registry-secret"},
},
}
Expect(envTest.Client.Create(ctx, mainSA)).To(Succeed())
llmSvc := LLMInferenceService(svcName,
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
WithModelURI("hf://facebook/opt-125m"),
WithManagedRoute(),
WithManagedGateway(),
WithManagedScheduler(),
WithTemplate(&corev1.PodSpec{
ServiceAccountName: "main-workload-sa",
Containers: []corev1.Container{
{
Name: "main",
Image: "quay.io/test/vllm:latest",
},
},
}),
)
// when
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
defer func() {
testNs.DeleteAndWait(ctx, llmSvc)
}()
// then - verify the generated scheduler SA has propagated credentials
schedulerSA := &corev1.ServiceAccount{}
Eventually(func(g Gomega, ctx context.Context) error {
if err := envTest.Get(ctx, types.NamespacedName{
Name: svcName + "-epp-sa",
Namespace: testNs.Name,
}, schedulerSA); err != nil {
return err
}
g.Expect(schedulerSA.Annotations).To(HaveKeyWithValue(
"eks.amazonaws.com/role-arn", "arn:aws:iam::123456789012:role/my-model-role"))
g.Expect(schedulerSA.Annotations).To(HaveKeyWithValue(
"custom-annotation", "custom-value"))
g.Expect(schedulerSA.ImagePullSecrets).To(ContainElement(
corev1.LocalObjectReference{Name: "my-registry-secret"}))
return nil
}).WithContext(ctx).Should(Succeed())
})
It("should not add extra credentials when no main workload SA is specified", func(ctx SpecContext) {
// given
svcName := "test-llm-sa-no-propagation"
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
llmSvc := LLMInferenceService(svcName,
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
WithModelURI("hf://facebook/opt-125m"),
WithManagedRoute(),
WithManagedGateway(),
WithManagedScheduler(),
// No WithTemplate - so no ServiceAccountName set
)
// when
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
defer func() {
testNs.DeleteAndWait(ctx, llmSvc)
}()
// then - verify the generated scheduler SA has no extra credentials
schedulerSA := &corev1.ServiceAccount{}
Eventually(func(g Gomega, ctx context.Context) error {
return envTest.Get(ctx, types.NamespacedName{
Name: svcName + "-epp-sa",
Namespace: testNs.Name,
}, schedulerSA)
}).WithContext(ctx).Should(Succeed())
Expect(schedulerSA.ImagePullSecrets).To(BeEmpty())
// Annotations should only contain system-managed ones (if any), not credential annotations
Expect(schedulerSA.Annotations).ToNot(HaveKey("eks.amazonaws.com/role-arn"))
})
It("should update scheduler SA when main workload SA credentials change", func(ctx SpecContext) {
// given
svcName := "test-llm-sa-update"
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
// Create a main workload SA with initial annotations
mainSA := &corev1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{
Name: "updatable-sa",
Namespace: testNs.Name,
Annotations: map[string]string{
"eks.amazonaws.com/role-arn": "arn:aws:iam::123456789012:role/initial-role",
},
},
}
Expect(envTest.Client.Create(ctx, mainSA)).To(Succeed())
llmSvc := LLMInferenceService(svcName,
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
WithModelURI("hf://facebook/opt-125m"),
WithManagedRoute(),
WithManagedGateway(),
WithManagedScheduler(),
WithTemplate(&corev1.PodSpec{
ServiceAccountName: "updatable-sa",
Containers: []corev1.Container{
{
Name: "main",
Image: "quay.io/test/vllm:latest",
},
},
}),
)
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
defer func() {
testNs.DeleteAndWait(ctx, llmSvc)
}()
// Wait for initial scheduler SA creation with initial annotation
schedulerSA := &corev1.ServiceAccount{}
Eventually(func(g Gomega, ctx context.Context) error {
if err := envTest.Get(ctx, types.NamespacedName{
Name: svcName + "-epp-sa",
Namespace: testNs.Name,
}, schedulerSA); err != nil {
return err
}
g.Expect(schedulerSA.Annotations).To(HaveKeyWithValue(
"eks.amazonaws.com/role-arn", "arn:aws:iam::123456789012:role/initial-role"))
return nil
}).WithContext(ctx).Should(Succeed())
// when - update main SA annotations
errRetry := retry.RetryOnConflict(retry.DefaultRetry, func() error {
updatedSA := &corev1.ServiceAccount{}
if err := envTest.Get(ctx, client.ObjectKeyFromObject(mainSA), updatedSA); err != nil {
return err
}
updatedSA.Annotations["eks.amazonaws.com/role-arn"] = "arn:aws:iam::123456789012:role/updated-role"
updatedSA.ImagePullSecrets = []corev1.LocalObjectReference{{Name: "new-secret"}}
return envTest.Update(ctx, updatedSA)
})
Expect(errRetry).ToNot(HaveOccurred())
// Trigger re-reconciliation by updating the LLMInferenceService
errRetry = retry.RetryOnConflict(retry.DefaultRetry, func() error {
updatedSvc := &v1alpha2.LLMInferenceService{}
if err := envTest.Get(ctx, client.ObjectKeyFromObject(llmSvc), updatedSvc); err != nil {
return err
}
if updatedSvc.Annotations == nil {
updatedSvc.Annotations = make(map[string]string)
}
updatedSvc.Annotations["reconcile-trigger"] = "update-sa"
return envTest.Update(ctx, updatedSvc)
})
Expect(errRetry).ToNot(HaveOccurred())
// then - scheduler SA should be updated with new credentials
Eventually(func(g Gomega, ctx context.Context) error {
updatedSchedulerSA := &corev1.ServiceAccount{}
if err := envTest.Get(ctx, types.NamespacedName{
Name: svcName + "-epp-sa",
Namespace: testNs.Name,
}, updatedSchedulerSA); err != nil {
return err
}
g.Expect(updatedSchedulerSA.Annotations).To(HaveKeyWithValue(
"eks.amazonaws.com/role-arn", "arn:aws:iam::123456789012:role/updated-role"))
g.Expect(updatedSchedulerSA.ImagePullSecrets).To(ContainElement(
corev1.LocalObjectReference{Name: "new-secret"}))
return nil
}).WithContext(ctx).Should(Succeed())
})
})
Context("v0.9.0 prefix-cache-scorer parameter removal", func() {
It("should remove all parameters except prefixMatchInfoProducerName from prefix-cache-scorer", func(ctx SpecContext) {
svcName := "test-llm-pcs-param-removal"
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
configWithPrefixCacheParams := `
apiVersion: inference.networking.x-k8s.io/v1alpha1
kind: EndpointPickerConfig
plugins:
- type: single-profile-handler
- type: prefix-cache-scorer
parameters:
blockSizeTokens: 16
prefixMatchInfoProducerName: my-producer
- type: queue-scorer
- type: max-score-picker
schedulingProfiles:
- name: default
plugins:
- pluginRef: queue-scorer
weight: 2
- pluginRef: prefix-cache-scorer
weight: 3
- pluginRef: max-score-picker
`
llmSvc := LLMInferenceService(svcName,
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
WithModelURI("hf://facebook/opt-125m"),
WithManagedRoute(),
WithManagedGateway(),
WithManagedScheduler(),
WithSchedulerConfigInline(configWithPrefixCacheParams),
)
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
defer func() {
testNs.DeleteAndWait(ctx, llmSvc)
}()
expectedDeployment := &appsv1.Deployment{}
Eventually(func(g Gomega, ctx context.Context) error {
if err := envTest.Get(ctx, types.NamespacedName{
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
Namespace: testNs.Name,
}, expectedDeployment); err != nil {
return err
}
configText, found := getSchedulerConfigText(expectedDeployment)
g.Expect(found).To(BeTrue(), "Expected to find --config-text in scheduler deployment")
g.Expect(configText).To(ContainSubstring("prefixMatchInfoProducerName: my-producer"))
g.Expect(configText).NotTo(ContainSubstring("blockSizeTokens"))
return nil
}).WithContext(ctx).Should(Succeed())
})
It("should remove parameters entirely when no prefixMatchInfoProducerName is present", func(ctx SpecContext) {
svcName := "test-llm-pcs-param-all-removed"
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
configWithOnlyDeprecatedParams := `
apiVersion: inference.networking.x-k8s.io/v1alpha1
kind: EndpointPickerConfig
plugins:
- type: single-profile-handler
- type: prefix-cache-scorer
parameters:
blockSizeTokens: 16
- type: queue-scorer
- type: max-score-picker
schedulingProfiles:
- name: default
plugins:
- pluginRef: queue-scorer
weight: 2
- pluginRef: prefix-cache-scorer
weight: 3
- pluginRef: max-score-picker
`
llmSvc := LLMInferenceService(svcName,
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
WithModelURI("hf://facebook/opt-125m"),
WithManagedRoute(),
WithManagedGateway(),
WithManagedScheduler(),
WithSchedulerConfigInline(configWithOnlyDeprecatedParams),
)
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
defer func() {
testNs.DeleteAndWait(ctx, llmSvc)
}()
expectedDeployment := &appsv1.Deployment{}
Eventually(func(g Gomega, ctx context.Context) error {
if err := envTest.Get(ctx, types.NamespacedName{
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
Namespace: testNs.Name,
}, expectedDeployment); err != nil {
return err
}
configText, found := getSchedulerConfigText(expectedDeployment)
g.Expect(found).To(BeTrue(), "Expected to find --config-text in scheduler deployment")
g.Expect(configText).NotTo(ContainSubstring("blockSizeTokens"))
g.Expect(configText).NotTo(ContainSubstring("parameters"))
g.Expect(configText).To(ContainSubstring("prefix-cache-scorer"))
return nil
}).WithContext(ctx).Should(Succeed())
})
It("should not touch prefix-cache-scorer without parameters", func(ctx SpecContext) {
svcName := "test-llm-pcs-no-params"
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
configWithNoParams := `
apiVersion: inference.networking.x-k8s.io/v1alpha1
kind: EndpointPickerConfig
plugins:
- type: single-profile-handler
- type: prefix-cache-scorer
- type: queue-scorer
- type: max-score-picker
schedulingProfiles:
- name: default
plugins:
- pluginRef: queue-scorer
weight: 2
- pluginRef: prefix-cache-scorer
weight: 3
- pluginRef: max-score-picker
`
llmSvc := LLMInferenceService(svcName,
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
WithModelURI("hf://facebook/opt-125m"),
WithManagedRoute(),
WithManagedGateway(),
WithManagedScheduler(),
WithSchedulerConfigInline(configWithNoParams),
)
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
defer func() {
testNs.DeleteAndWait(ctx, llmSvc)
}()
expectedDeployment := &appsv1.Deployment{}
Eventually(func(g Gomega, ctx context.Context) error {
if err := envTest.Get(ctx, types.NamespacedName{
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
Namespace: testNs.Name,
}, expectedDeployment); err != nil {
return err
}
configText, found := getSchedulerConfigText(expectedDeployment)
g.Expect(found).To(BeTrue(), "Expected to find --config-text in scheduler deployment")
g.Expect(configText).To(ContainSubstring("prefix-cache-scorer"))
return nil
}).WithContext(ctx).Should(Succeed())
})
})
Context("Multi-GPU-vendor pooling via workload label propagation", func() {
It("should create InferencePool with default workload labels that AMD pods can match", func(ctx SpecContext) {
// This test verifies the multi-GPU-vendor pooling pattern:
// 1. NVIDIA instance with default scheduler creates InferencePool with default workload labels
// 2. AMD instance with no router uses spec.labels to match the NVIDIA InferencePool selector
nvidiaSvcName := "test-llm-nvidia"
amdSvcName := "test-llm-amd"
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(nvidiaSvcName))
// Create NVIDIA instance with default scheduler
nvidiaLLMSvc := LLMInferenceService(nvidiaSvcName,
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
WithModelURI("hf://facebook/opt-125m"),
WithManagedRoute(),
WithManagedGateway(),
WithManagedScheduler(),
)
Expect(envTest.Create(ctx, nvidiaLLMSvc)).To(Succeed())
defer func() {
testNs.DeleteAndWait(ctx, nvidiaLLMSvc)
}()
// Verify InferencePool is created with default workload label selector
ip := &igwapi.InferencePool{}
Eventually(func(g Gomega, ctx context.Context) error {
return envTest.Get(ctx, client.ObjectKey{
Name: nvidiaSvcName + "-inference-pool",
Namespace: testNs.Name,
}, ip)
}).WithContext(ctx).Should(Succeed())
Expect(ip.Spec.Selector.MatchLabels).To(HaveKeyWithValue(
igwapi.LabelKey("app.kubernetes.io/name"), igwapi.LabelValue(nvidiaSvcName)))
Expect(ip.Spec.Selector.MatchLabels).To(HaveKeyWithValue(
igwapi.LabelKey("kserve.io/component"), igwapi.LabelValue("workload")))
Expect(ip.Spec.Selector.MatchLabels).To(HaveKeyWithValue(
igwapi.LabelKey("app.kubernetes.io/part-of"), igwapi.LabelValue("llminferenceservice")))
// Create AMD instance with no router, using spec.labels to match NVIDIA's InferencePool
amdLLMSvc := LLMInferenceService(amdSvcName,
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
WithModelURI("hf://facebook/opt-125m"),
WithWorkloadLabels(map[string]string{
"app.kubernetes.io/name": nvidiaSvcName,
"app.kubernetes.io/part-of": "llminferenceservice",
"kserve.io/component": "workload",
}),
)
Expect(envTest.Create(ctx, amdLLMSvc)).To(Succeed())
defer func() {
testNs.DeleteAndWait(ctx, amdLLMSvc)
}()
// Verify AMD workload deployment is created and its pod template labels
// match the NVIDIA InferencePool selector
amdDeployment := &appsv1.Deployment{}
Eventually(func(g Gomega, ctx context.Context) error {
return envTest.Get(ctx, client.ObjectKey{
Name: amdSvcName + "-kserve",
Namespace: testNs.Name,
}, amdDeployment)
}).WithContext(ctx).Should(Succeed())
podLabels := amdDeployment.Spec.Template.Labels
for labelKey, labelValue := range ip.Spec.Selector.MatchLabels {
Expect(podLabels).To(HaveKeyWithValue(string(labelKey), string(labelValue)),
"AMD pod template label %q should match NVIDIA InferencePool selector", labelKey)
}
// Verify no InferencePool was created for the AMD instance
amdIP := &igwapi.InferencePool{}
err := envTest.Get(ctx, client.ObjectKey{
Name: amdSvcName + "-inference-pool",
Namespace: testNs.Name,
}, amdIP)
Expect(errors.IsNotFound(err)).To(BeTrue(),
"AMD instance should not create its own InferencePool")
// Verify no scheduler deployment was created for the AMD instance
amdScheduler := &appsv1.Deployment{}
err = envTest.Get(ctx, client.ObjectKey{
Name: amdSvcName + "-kserve-epp",
Namespace: testNs.Name,
}, amdScheduler)
Expect(errors.IsNotFound(err)).To(BeTrue(),
"AMD instance should not create a scheduler deployment")
})
})
Context("EPP port defaulting for upgrade compatibility", func() {
// The port defaulting for configs that omit port (pre-GIE-v1.2.0 upgrade
// scenario) is covered by the v1alpha2pool conversion (convertExtensionRefToV1)
// and combineBaseRefsConfig. Integration tests for the nil-port case are not
// feasible here because the v1alpha2 CEL rule rejects creating configs without
// port, and the v1alpha1 conversion defaults port to 9002 during ConvertTo.
It("should not override explicitly set EPP port", func(ctx SpecContext) {
svcName := "test-llm-port-explicit"
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
schedulerCfg := LLMInferenceServiceConfig("kserve-config-llm-scheduler",
InNamespace[*v1alpha2.LLMInferenceServiceConfig](testNs.Name),
)
schedulerCfg.Spec.Router = &v1alpha2.RouterSpec{
Scheduler: &v1alpha2.SchedulerSpec{
Pool: &v1alpha2.InferencePoolSpec{
Spec: &igwapi.InferencePoolSpec{
EndpointPickerRef: igwapi.EndpointPickerRef{
Kind: "Service",
Name: igwapi.ObjectName(kmeta.ChildName(svcName, "-epp-service")),
Port: ptr.To(igwapi.Port{Number: 8080}),
FailureMode: igwapi.EndpointPickerFailOpen,
},
Selector: igwapi.LabelSelector{
MatchLabels: map[igwapi.LabelKey]igwapi.LabelValue{
"app": "placeholder",
},
},
TargetPorts: []igwapi.Port{{Number: 8000}},
},
},
Template: &corev1.PodSpec{
Containers: []corev1.Container{{
Name: "main",
Image: "ghcr.io/llm-d/llm-d-inference-scheduler:v0.7.1",
Ports: []corev1.ContainerPort{
{Name: "grpc", ContainerPort: 8080, Protocol: corev1.ProtocolTCP},
{Name: "grpc-health", ContainerPort: 9003, Protocol: corev1.ProtocolTCP},
{Name: "metrics", ContainerPort: 9090, Protocol: corev1.ProtocolTCP},
},
}},
},
},
}
Expect(envTest.Client.Create(ctx, schedulerCfg)).To(Succeed())
llmSvc := LLMInferenceService(svcName,
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
WithModelURI("hf://facebook/opt-125m"),
WithManagedRoute(),
WithManagedGateway(),
WithManagedScheduler(),
)
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
defer func() {
testNs.DeleteAndWait(ctx, llmSvc)
}()
ip := &igwapi.InferencePool{}
Eventually(func(g Gomega, ctx context.Context) {
g.Expect(envTest.Get(ctx, client.ObjectKey{
Name: svcName + "-inference-pool",
Namespace: testNs.Name,
}, ip)).To(Succeed())
g.Expect(ip.Spec.EndpointPickerRef.Port).NotTo(BeNil())
g.Expect(ip.Spec.EndpointPickerRef.Port.Number).To(Equal(igwapi.PortNumber(8080)))
}).WithContext(ctx).Should(Succeed())
})
})
})
// schedulerContainerName is the expected name of the main container in the scheduler deployment
const schedulerContainerName = "main"
// getSchedulerConfigText extracts the --config-text argument value from a scheduler deployment.
// Returns the config text and a boolean indicating whether it was found.
func getSchedulerConfigText(deployment *appsv1.Deployment) (configText string, found bool) {
for _, container := range deployment.Spec.Template.Spec.Containers {
if container.Name == schedulerContainerName {
for i, arg := range container.Args {
if arg == "--config-text" && i+1 < len(container.Args) {
return container.Args[i+1], true
}
}
}
}
return "", false
}
// countConfigTextArgs counts how many --config-text arguments exist in the scheduler deployment.
// Used to verify that config is not duplicated.
func countConfigTextArgs(deployment *appsv1.Deployment) int {
count := 0
for _, container := range deployment.Spec.Template.Spec.Containers {
if container.Name == schedulerContainerName {
for _, arg := range container.Args {
if arg == "--config-text" {
count++
}
}
}
}
return count
}
// hasLeaderElectionFlag checks if the scheduler deployment has the --ha-enable-leader-election flag
func hasLeaderElectionFlag(deployment *appsv1.Deployment) bool {
return countLeaderElectionFlags(deployment) > 0
}
// countLeaderElectionFlags counts how many leader election flags exist in the scheduler deployment
func countLeaderElectionFlags(deployment *appsv1.Deployment) int {
count := 0
for _, container := range deployment.Spec.Template.Spec.Containers {
if container.Name == schedulerContainerName {
for _, arg := range container.Args {
if arg == "--ha-enable-leader-election" || arg == "-ha-enable-leader-election" {
count++
}
}
}
}
return count
}