Skip to content

Commit 55b4ad6

Browse files
[Feat][Operator] Add prefixaware and kvaware routing options to VLLMRouter CRD (#881)
- Extend RoutingLogic enum with prefixaware and kvaware strategies - Add LmcacheControllerPort field with maximum=65535 validation - Pass --lmcache-controller-port flag when routingLogic is kvaware and port is set - Add unit tests for routing logic and deployment update detection - Update CRD YAML, sample manifest, Helm values and README Signed-off-by: Keyu Chen <54015474+keyuchen21@users.noreply.github.com> Co-authored-by: Rui Zhang <51696593+ruizhang0101@users.noreply.github.com>
1 parent b9b2364 commit 55b4ad6

7 files changed

Lines changed: 227 additions & 7 deletions

File tree

helm/README.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -231,13 +231,12 @@ This table documents all available configuration values for the Production Stack
231231
| `routerSpec.containerPort` | integer | `8000` | Port the router container is listening on |
232232
| `routerSpec.serviceType` | string | `"ClusterIP"` | Kubernetes service type for the router |
233233
| `routerSpec.serviceAnnotations` | map | `{}` | Service annotations for LoadBalancer/NodePort |
234-
| `routerSpec.nodePort` | integer | `-` | (Optional) Fixed NodePort for the router service when `serviceType` is `NodePort`. If unset, Kubernetes assigns a random port (30000-32767). |
235234
| `routerSpec.servicePort` | integer | `80` | Port the router service will listen on |
236235
| `routerSpec.serviceDiscovery` | string | `"k8s"` | Service discovery mode ("k8s" or "static") |
237236
| `routerSpec.k8sServiceDiscoveryType` | string | `"pod-ip"` | Service discovery Type ("pod-ip" or "service-name") if serviceDiscovery is "k8s" |
238237
| `routerSpec.staticBackends` | string | `""` | Comma-separated list of backend addresses if serviceDiscovery is "static" |
239238
| `routerSpec.staticModels` | string | `""` | Comma-separated list of model names if serviceDiscovery is "static" |
240-
| `routerSpec.routingLogic` | string | `"roundrobin"` | Routing logic ("roundrobin" or "session") |
239+
| `routerSpec.routingLogic` | string | `"roundrobin"` | Routing logic: `"roundrobin"`, `"session"`, `"prefixaware"`, or `"kvaware"` |
241240
| `routerSpec.sessionKey` | string | `""` | Session key if using "session" routing logic |
242241
| `routerSpec.extraArgs` | list | `[]` | Extra command line arguments to pass to the router |
243242
| `routerSpec.engineScrapeInterval` | integer | `15` | Interval in seconds to scrape metrics from the serving engine |
@@ -253,8 +252,8 @@ This table documents all available configuration values for the Production Stack
253252
| `routerSpec.affinity` | map | {} | (Optional) Affinity configuration. If specified, this takes precedence over `nodeSelectorTerms`. |
254253
| `routerSpec.nodeSelectorTerms` | list | `[]` | (Optional) Node selector terms. This is ignored if `affinity` is specified. |
255254
| `routerSpec.hf_token` | string | `""`| Hugging Face token for router |
256-
| `routerSpec.lmcacheControllerPort` | string |`"8000"`|LMCache controller port |
257-
| `routerSpec.lmcacheConfig.logLevel` | string | `"INFO"`| Log level for LMCache in the router when routingLogic is kwaware |
255+
| `routerSpec.lmcacheControllerPort` | integer | `""` | LMCache controller port, used when `routingLogic` is `"kvaware"` (e.g. `9000`) |
256+
| `routerSpec.lmcacheConfig.logLevel` | string | `"INFO"`| Log level for LMCache in the router when routingLogic is kvaware |
258257
| `routerSpec.livenessProbe.initialDelaySeconds` | integer |`30`| Initial delay in seconds for router's liveness probe |
259258
| `routerSpec.livenessProbe.periodSeconds` | integer |`5`| Interval in seconds for router's liveness probe |
260259
| `routerSpec.livenessProbe.failureThreshold` | integer |`3`| Failure threshold for router's liveness probe |

helm/values.yaml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -467,12 +467,15 @@ routerSpec:
467467
staticBackends: ""
468468
staticModels: ""
469469

470-
# -- routing logic, could be "roundrobin" or "session"
470+
# -- routing logic, supports "roundrobin", "session", "prefixaware", or "kvaware"
471471
routingLogic: "roundrobin"
472472

473473
# -- session key if using "session" routing logic
474474
sessionKey: ""
475475

476+
# -- lmcache controller port, used when routingLogic is "kvaware"
477+
lmcacheControllerPort: ""
478+
476479
# -- FastAPI root path for hosting under a subpath (e.g. /vllm)
477480
rootPath: ""
478481

operator/api/v1alpha1/vllmrouter_types.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ type VLLMRouterSpec struct {
5252
StaticModels string `json:"staticModels,omitempty"`
5353

5454
// RoutingLogic specifies the routing strategy
55-
// +kubebuilder:validation:Enum=roundrobin;session
55+
// +kubebuilder:validation:Enum=roundrobin;session;prefixaware;kvaware
5656
// +kubebuilder:default=roundrobin
5757
RoutingLogic string `json:"routingLogic,omitempty"`
5858

@@ -61,6 +61,11 @@ type VLLMRouterSpec struct {
6161
// +kubebuilder:default=""
6262
SessionKey string `json:"sessionKey,omitempty"`
6363

64+
// LmcacheControllerPort is the port of the LMCache controller (used with kvaware routing).
65+
// Set explicitly when routingLogic is "kvaware" (typical value: 9000).
66+
// +kubebuilder:validation:Maximum=65535
67+
LmcacheControllerPort int32 `json:"lmcacheControllerPort,omitempty"`
68+
6469
// EngineScrapeInterval for collecting engine statistics
6570
EngineScrapeInterval int32 `json:"engineScrapeInterval,omitempty"`
6671

operator/config/crd/bases/production-stack.vllm.ai_vllmrouters.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,13 @@ spec:
8585
description: K8sLabelSelector specifies the label selector for vLLM
8686
runtime pods when using k8s service discovery
8787
type: string
88+
lmcacheControllerPort:
89+
description: |-
90+
LmcacheControllerPort is the port of the LMCache controller (used with kvaware routing).
91+
Set explicitly when routingLogic is "kvaware" (typical value: 9000).
92+
format: int32
93+
maximum: 65535
94+
type: integer
8895
nodeSelectorTerms:
8996
description: NodeSelectorTerms for pod scheduling
9097
items:
@@ -194,6 +201,8 @@ spec:
194201
enum:
195202
- roundrobin
196203
- session
204+
- prefixaware
205+
- kvaware
197206
type: string
198207
serviceAccountName:
199208
description: ServiceAccountName for the router pod

operator/config/samples/production-stack_v1alpha1_vllmrouter.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ spec:
1818
# Label selector for vLLM runtime pods
1919
k8sLabelSelector: "app=vllmruntime-sample"
2020

21-
# Routing strategy (roundrobin or session)
21+
# Routing strategy (roundrobin, session, prefixaware, or kvaware)
2222
routingLogic: roundrobin
2323

2424
# Engine statistics collection interval

operator/internal/controller/vllmrouter_controller.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,9 @@ func (r *VLLMRouterReconciler) deploymentForVLLMRouter(router *servingv1alpha1.V
288288
if router.Spec.SessionKey != "" {
289289
args = append(args, "--session-key", router.Spec.SessionKey)
290290
}
291+
if router.Spec.RoutingLogic == "kvaware" && router.Spec.LmcacheControllerPort != 0 {
292+
args = append(args, "--lmcache-controller-port", fmt.Sprintf("%d", router.Spec.LmcacheControllerPort))
293+
}
291294
if router.Spec.EngineScrapeInterval != 0 {
292295
args = append(args, "--engine-stats-interval", fmt.Sprintf("%d", router.Spec.EngineScrapeInterval))
293296
}
@@ -372,6 +375,11 @@ func (r *VLLMRouterReconciler) deploymentNeedsUpdate(dep *appsv1.Deployment, rou
372375
// Generate the expected deployment
373376
expectedDep := r.deploymentForVLLMRouter(router)
374377

378+
// Guard against deployments with no containers (e.g. hand-edited or corrupted).
379+
if len(expectedDep.Spec.Template.Spec.Containers) == 0 || len(dep.Spec.Template.Spec.Containers) == 0 {
380+
return true
381+
}
382+
375383
// Compare image
376384
if expectedDep.Spec.Template.Spec.Containers[0].Image != dep.Spec.Template.Spec.Containers[0].Image {
377385
return true
@@ -384,6 +392,11 @@ func (r *VLLMRouterReconciler) deploymentNeedsUpdate(dep *appsv1.Deployment, rou
384392
return true
385393
}
386394

395+
// Compare container args
396+
if !reflect.DeepEqual(expectedDep.Spec.Template.Spec.Containers[0].Args, dep.Spec.Template.Spec.Containers[0].Args) {
397+
return true
398+
}
399+
387400
return false
388401
}
389402

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
/*
2+
Copyright 2025.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package controller
18+
19+
import (
20+
"testing"
21+
22+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
23+
"k8s.io/apimachinery/pkg/runtime"
24+
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
25+
26+
v1alpha1 "production-stack/api/v1alpha1"
27+
)
28+
29+
func newTestScheme() *runtime.Scheme {
30+
s := runtime.NewScheme()
31+
_ = clientgoscheme.AddToScheme(s)
32+
_ = v1alpha1.AddToScheme(s)
33+
return s
34+
}
35+
36+
// containsConsecutive reports whether args contains a and b as adjacent elements.
37+
func containsConsecutive(args []string, a, b string) bool {
38+
for i := 0; i+1 < len(args); i++ {
39+
if args[i] == a && args[i+1] == b {
40+
return true
41+
}
42+
}
43+
return false
44+
}
45+
46+
// containsArg reports whether arg appears anywhere in args.
47+
func containsArg(args []string, arg string) bool {
48+
for _, a := range args {
49+
if a == arg {
50+
return true
51+
}
52+
}
53+
return false
54+
}
55+
56+
// buildTestRouter constructs a minimal VLLMRouter with static service discovery.
57+
func buildTestRouter(routingLogic, sessionKey string, lmcachePort int32) *v1alpha1.VLLMRouter {
58+
return &v1alpha1.VLLMRouter{
59+
ObjectMeta: metav1.ObjectMeta{
60+
Name: "test-router",
61+
Namespace: "default",
62+
},
63+
Spec: v1alpha1.VLLMRouterSpec{
64+
Replicas: 1,
65+
ServiceDiscovery: "static",
66+
StaticBackends: "http://backend:8000",
67+
StaticModels: "test-model",
68+
Port: 80,
69+
RoutingLogic: routingLogic,
70+
SessionKey: sessionKey,
71+
LmcacheControllerPort: lmcachePort,
72+
Image: v1alpha1.ImageSpec{
73+
Registry: "docker.io",
74+
Name: "vllm/router:latest",
75+
},
76+
},
77+
}
78+
}
79+
80+
func TestDeploymentNeedsUpdate(t *testing.T) {
81+
r := &VLLMRouterReconciler{Scheme: newTestScheme()}
82+
83+
base := buildTestRouter("roundrobin", "", 0)
84+
dep := r.deploymentForVLLMRouter(base)
85+
86+
// identical spec → no update
87+
if r.deploymentNeedsUpdate(dep, base) {
88+
t.Error("expected no update for identical spec")
89+
}
90+
91+
// routing logic changed → args differ → update required
92+
changed := buildTestRouter("prefixaware", "", 0)
93+
if !r.deploymentNeedsUpdate(dep, changed) {
94+
t.Error("expected update when routingLogic changes")
95+
}
96+
97+
// kvaware with port added → args differ → update required
98+
withPort := buildTestRouter("kvaware", "", 9000)
99+
if !r.deploymentNeedsUpdate(dep, withPort) {
100+
t.Error("expected update when lmcacheControllerPort added")
101+
}
102+
}
103+
104+
func TestDeploymentArgsRouting(t *testing.T) {
105+
r := &VLLMRouterReconciler{Scheme: newTestScheme()}
106+
107+
tests := []struct {
108+
name string
109+
routingLogic string
110+
sessionKey string
111+
lmcachePort int32
112+
wantPresent [][2]string // pairs that must appear consecutively
113+
wantAbsent []string // flags that must not appear
114+
}{
115+
{
116+
name: "roundrobin",
117+
routingLogic: "roundrobin",
118+
wantPresent: [][2]string{{"--routing-logic", "roundrobin"}},
119+
wantAbsent: []string{"--lmcache-controller-port"},
120+
},
121+
{
122+
name: "session with key",
123+
routingLogic: "session",
124+
sessionKey: "mykey",
125+
wantPresent: [][2]string{
126+
{"--routing-logic", "session"},
127+
{"--session-key", "mykey"},
128+
},
129+
wantAbsent: []string{"--lmcache-controller-port"},
130+
},
131+
{
132+
name: "prefixaware",
133+
routingLogic: "prefixaware",
134+
wantPresent: [][2]string{{"--routing-logic", "prefixaware"}},
135+
wantAbsent: []string{"--lmcache-controller-port"},
136+
},
137+
{
138+
name: "kvaware default port",
139+
routingLogic: "kvaware",
140+
lmcachePort: 9000,
141+
wantPresent: [][2]string{
142+
{"--routing-logic", "kvaware"},
143+
{"--lmcache-controller-port", "9000"},
144+
},
145+
},
146+
{
147+
name: "kvaware custom port",
148+
routingLogic: "kvaware",
149+
lmcachePort: 8888,
150+
wantPresent: [][2]string{
151+
{"--routing-logic", "kvaware"},
152+
{"--lmcache-controller-port", "8888"},
153+
},
154+
},
155+
{
156+
name: "kvaware zero port",
157+
routingLogic: "kvaware",
158+
lmcachePort: 0,
159+
wantPresent: [][2]string{{"--routing-logic", "kvaware"}},
160+
wantAbsent: []string{"--lmcache-controller-port"},
161+
},
162+
}
163+
164+
for _, tc := range tests {
165+
t.Run(tc.name, func(t *testing.T) {
166+
router := buildTestRouter(tc.routingLogic, tc.sessionKey, tc.lmcachePort)
167+
dep := r.deploymentForVLLMRouter(router)
168+
169+
if dep == nil {
170+
t.Fatal("deploymentForVLLMRouter returned nil")
171+
}
172+
if len(dep.Spec.Template.Spec.Containers) == 0 {
173+
t.Fatal("deployment has no containers")
174+
}
175+
176+
args := dep.Spec.Template.Spec.Containers[0].Args
177+
178+
for _, pair := range tc.wantPresent {
179+
if !containsConsecutive(args, pair[0], pair[1]) {
180+
t.Errorf("expected args to contain %q %q, got: %v", pair[0], pair[1], args)
181+
}
182+
}
183+
184+
for _, flag := range tc.wantAbsent {
185+
if containsArg(args, flag) {
186+
t.Errorf("expected args NOT to contain %q, got: %v", flag, args)
187+
}
188+
}
189+
})
190+
}
191+
}

0 commit comments

Comments
 (0)