Skip to content

Commit 8d88d15

Browse files
committed
feat(router): add load-aware generate paths
Signed-off-by: Bruno Volpato <brunocvcunha@gmail.com>
1 parent d2ba586 commit 8d88d15

18 files changed

Lines changed: 508 additions & 158 deletions

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,20 @@ vllm-router \
7777
--intra-node-data-parallel-size 8
7878
```
7979

80+
#### Additional Generate Paths
81+
82+
Register extension endpoints that use the `/inference/v1/generate` request
83+
schema. Configured paths get typed routing, active-load accounting, retries,
84+
and circuit breaking while preserving extension fields in requests and
85+
responses. Workers must expose the same paths.
86+
87+
```bash
88+
vllm-router \
89+
--worker-urls http://worker1:8000 http://worker2:8000 \
90+
--policy cache_aware \
91+
--extra-generate-paths /custom/v1/generate
92+
```
93+
8094
#### Prefill-Decode Disaggregation
8195
```bash
8296
# When vLLM runs the NIXL connector, prefill/decode URLs are required.

py_src/vllm_router/router.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ class Router:
2626
Args:
2727
worker_urls: List of URLs for worker nodes that will handle requests. Each URL should include
2828
the protocol, host, and port (e.g., ['http://worker1:8000', 'http://worker2:8000'])
29+
extra_generate_paths: Additional HTTP paths that use the inference-generate request schema.
30+
Requests on these paths use typed routing, retries, circuit breaking, and load tracking.
2931
policy: Load balancing policy to use. Options:
3032
- PolicyType.Random: Randomly select workers
3133
- PolicyType.RoundRobin: Distribute requests in round-robin fashion

py_src/vllm_router/router_args.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,30 @@
55

66
logger = logging.getLogger(__name__)
77

8+
_RESERVED_GENERATE_PATHS = {
9+
"/generate",
10+
"/inference/v1/generate",
11+
"/v1/chat/completions",
12+
"/v1/completions",
13+
"/rerank",
14+
"/v1/rerank",
15+
"/v1/responses",
16+
"/v1/embeddings",
17+
"/liveness",
18+
"/readiness",
19+
"/health",
20+
"/health_generate",
21+
"/v1/models",
22+
"/get_model_info",
23+
"/get_server_info",
24+
"/add_worker",
25+
"/remove_worker",
26+
"/list_workers",
27+
"/flush_cache",
28+
"/get_loads",
29+
"/workers",
30+
}
31+
832

933
@dataclasses.dataclass
1034
class RouterArgs:
@@ -89,6 +113,8 @@ class RouterArgs:
89113
cb_timeout_duration_secs: int = 60
90114
cb_window_duration_secs: int = 120
91115
disable_circuit_breaker: bool = False
116+
# Additional typed inference-generate routes
117+
extra_generate_paths: List[str] = dataclasses.field(default_factory=list)
92118

93119
@staticmethod
94120
def add_cli_args(
@@ -128,6 +154,13 @@ def add_cli_args(
128154
default=[],
129155
help="List of worker URLs (e.g., http://worker1:8000 http://worker2:8000)",
130156
)
157+
parser.add_argument(
158+
f"--{prefix}extra-generate-paths",
159+
type=str,
160+
nargs="*",
161+
default=[],
162+
help="Additional HTTP paths using the inference-generate request schema",
163+
)
131164

132165
# Routing policy configuration
133166
parser.add_argument(
@@ -518,6 +551,19 @@ def from_cli_args(
518551
return cls(**args_dict)
519552

520553
def _validate_router_args(self):
554+
if len(set(self.extra_generate_paths)) != len(self.extra_generate_paths):
555+
raise ValueError("extra_generate_paths must not contain duplicates")
556+
for path in self.extra_generate_paths:
557+
if (
558+
len(path) < 2
559+
or not path.startswith("/")
560+
or path in _RESERVED_GENERATE_PATHS
561+
or path.startswith("/v1/responses/")
562+
or path.startswith("/workers/")
563+
or any(character in path for character in "?#{}*")
564+
):
565+
raise ValueError(f"invalid extra generate path: {path}")
566+
521567
# Validate configuration based on mode
522568
if self.vllm_pd_disaggregation:
523569
# Validate PD configuration - skip URL requirements if using service discovery

py_test/unit/test_arg_parser.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ def test_default_values(self):
2424
assert args.port == 30000
2525
assert args.policy == "cache_aware"
2626
assert args.worker_urls == []
27+
assert args.extra_generate_paths == []
2728
assert args.vllm_pd_disaggregation is False
2829
assert args.prefill_urls == []
2930
assert args.decode_urls == []
@@ -440,6 +441,22 @@ def test_parse_basic_args(self):
440441
assert router_args.worker_urls == ["http://worker1:8000", "http://worker2:8000"]
441442
assert router_args.policy == "round_robin"
442443

444+
def test_parse_extra_generate_paths(self):
445+
router_args = parse_router_args(
446+
[
447+
"--worker-urls",
448+
"http://worker1:8000",
449+
"--extra-generate-paths",
450+
"/custom/v1/generate",
451+
"/extension/generate",
452+
]
453+
)
454+
455+
assert router_args.extra_generate_paths == [
456+
"/custom/v1/generate",
457+
"/extension/generate",
458+
]
459+
443460
def test_parse_pd_args(self):
444461
"""Test parsing PD disaggregated mode arguments."""
445462
args = [

py_test/unit/test_validation.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,29 @@ def test_request_id_headers_validation(self):
399399
class TestLaunchValidation:
400400
"""Test launch-time validation logic."""
401401

402+
def test_extra_generate_paths_accept_distinct_extension_routes(self):
403+
args = RouterArgs(
404+
extra_generate_paths=["/custom/v1/generate", "/extension/generate"]
405+
)
406+
407+
args._validate_router_args()
408+
409+
@pytest.mark.parametrize(
410+
"paths",
411+
[
412+
["custom/v1/generate"],
413+
["/inference/v1/generate"],
414+
["/v1/responses/id"],
415+
["/custom/{path}"],
416+
["/custom/v1/generate", "/custom/v1/generate"],
417+
],
418+
)
419+
def test_extra_generate_paths_reject_invalid_or_conflicting_routes(self, paths):
420+
args = RouterArgs(extra_generate_paths=paths)
421+
422+
with pytest.raises(ValueError, match="extra_generate_paths|extra generate path"):
423+
args._validate_router_args()
424+
402425
def test_pd_mode_requires_urls(self):
403426
"""Test that PD mode requires prefill and decode URLs."""
404427
# PD mode without URLs should fail

src/core/worker.rs

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -884,10 +884,6 @@ pub fn start_health_checker(
884884
let mut interval =
885885
tokio::time::interval(tokio::time::Duration::from_secs(check_interval_secs));
886886

887-
// Counter for periodic load reset (every 10 health check cycles)
888-
let mut check_count = 0u64;
889-
const LOAD_RESET_INTERVAL: u64 = 10;
890-
891887
loop {
892888
interval.tick().await;
893889

@@ -897,8 +893,6 @@ pub fn start_health_checker(
897893
break;
898894
}
899895

900-
check_count += 1;
901-
902896
// Check health of all workers
903897
let workers_to_check = match workers.read() {
904898
Ok(guard) => guard.clone(),
@@ -908,22 +902,6 @@ pub fn start_health_checker(
908902
}
909903
};
910904

911-
// Periodically reset load counters to prevent drift
912-
// Only do this when we believe all workers should be idle
913-
if check_count.is_multiple_of(LOAD_RESET_INTERVAL) {
914-
let max_load = workers_to_check.iter().map(|w| w.load()).max().unwrap_or(0);
915-
// Only reset if load appears to be very low (likely drift)
916-
if max_load <= 2 {
917-
tracing::debug!(
918-
"Resetting load counters to prevent drift (max_load: {})",
919-
max_load
920-
);
921-
for worker in &workers_to_check {
922-
worker.reset_load();
923-
}
924-
}
925-
}
926-
927905
// Perform health checks concurrently
928906
let health_checks = workers_to_check.iter().map(|worker| {
929907
let worker_url = worker.url().to_string();

src/core/worker_registry.rs

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -364,10 +364,6 @@ impl WorkerRegistry {
364364
let mut interval =
365365
tokio::time::interval(tokio::time::Duration::from_secs(check_interval_secs));
366366

367-
// Counter for periodic load reset (every 10 health check cycles)
368-
let mut check_count = 0u64;
369-
const LOAD_RESET_INTERVAL: u64 = 10;
370-
371367
loop {
372368
interval.tick().await;
373369

@@ -387,15 +383,6 @@ impl WorkerRegistry {
387383
for worker in &workers {
388384
let _ = worker.check_health_async().await; // Use async version directly
389385
}
390-
391-
// Reset loads periodically
392-
check_count += 1;
393-
if check_count.is_multiple_of(LOAD_RESET_INTERVAL) {
394-
tracing::debug!("Resetting worker loads (cycle {})", check_count);
395-
for worker in &workers {
396-
worker.reset_load();
397-
}
398-
}
399386
}
400387
});
401388

src/lib.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ struct Router {
3434
host: String,
3535
port: u16,
3636
worker_urls: Vec<String>,
37+
extra_generate_paths: Vec<String>,
3738
policy: PolicyType,
3839
worker_startup_timeout_secs: u64,
3940
worker_startup_check_interval: u64,
@@ -310,6 +311,7 @@ impl Router {
310311
otlp_traces_endpoint = None,
311312
// KV connector default (PD disaggregation)
312313
kv_connector = String::from("nixl"),
314+
extra_generate_paths = vec![],
313315
))]
314316
#[allow(clippy::too_many_arguments)]
315317
fn new(
@@ -372,11 +374,13 @@ impl Router {
372374
enable_trace: bool,
373375
otlp_traces_endpoint: Option<String>,
374376
kv_connector: String,
377+
extra_generate_paths: Vec<String>,
375378
) -> PyResult<Self> {
376379
Ok(Router {
377380
host,
378381
port,
379382
worker_urls,
383+
extra_generate_paths,
380384
policy,
381385
worker_startup_timeout_secs,
382386
worker_startup_check_interval,
@@ -502,6 +506,7 @@ impl Router {
502506
} else {
503507
None
504508
},
509+
extra_generate_paths: self.extra_generate_paths.clone(),
505510
})
506511
.await
507512
.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))

src/main.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,10 @@ struct CliArgs {
106106
#[arg(long, num_args = 0..)]
107107
worker_urls: Vec<String>,
108108

109+
/// Additional HTTP paths using the inference-generate request schema
110+
#[arg(long, num_args = 0..)]
111+
extra_generate_paths: Vec<String>,
112+
109113
/// Load balancing policy to use
110114
#[arg(long, default_value = "cache_aware", value_parser = ["random", "round_robin", "cache_aware", "power_of_two", "consistent_hash", "rendezvous_hash"])]
111115
policy: String,
@@ -614,6 +618,7 @@ impl CliArgs {
614618
} else {
615619
None
616620
},
621+
extra_generate_paths: self.extra_generate_paths.clone(),
617622
}
618623
}
619624
}

src/routers/http/openai_router.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,16 @@ impl super::super::RouterTrait for OpenAIRouter {
223223
.into_response()
224224
}
225225

226+
async fn route_inference_generate_path(
227+
&self,
228+
headers: Option<&HeaderMap>,
229+
body: &InferenceGenerateRequest,
230+
_path: &str,
231+
model_id: Option<&str>,
232+
) -> Response {
233+
self.route_inference_generate(headers, body, model_id).await
234+
}
235+
226236
async fn route_chat(
227237
&self,
228238
headers: Option<&HeaderMap>,

0 commit comments

Comments
 (0)