[EC Connector] Add EC Transfer Params (#42433)

Signed-off-by: omerpaz95 <omerpaz95@gmail.com> Co-authored-by: Or Ozeri <oro@il.ibm.com>

omerpaz95 committed Jul 12, 2026 at 14:35 UTC 8df14cfc8c8a09b4e57f082e59593a3abce4ffb3
55 files changed +416 -12
.buildkite/test_areas/misc.yaml
+2
@@ -89,6 +89,7 @@ steps:
89 - tests/v1/simple_kv_offload
90 - tests/v1/worker
91 - tests/v1/kv_connector/unit
92 + - tests/v1/ec_connector/unit
93 - tests/v1/metrics
94 - tests/entrypoints/openai/correctness/test_lmeval.py
95 commands:
@@ -101,6 +102,7 @@ steps:
102 - pytest -v -s v1/simple_kv_offload
103 - pytest -v -s v1/worker
104 - pytest -v -s -m 'not cpu_test' v1/kv_connector/unit
105 + - pytest -v -s -m 'not cpu_test' v1/ec_connector/unit
106 - pytest -v -s -m 'not cpu_test' v1/metrics
107 # Integration test for streaming correctness (requires special branch).
108 - pip install -U git+https://github.com/vllm-project/lm-evaluation-harness.git@streaming-api
rust/proto/vllm_grpc.proto
+4
@@ -107,6 +107,9 @@ message KVCacheParameters {
107
108 // KV Connector transfer parameters
109 google.protobuf.Struct kv_transfer_params = 3;
110 +
111 + // Encoder cache connector transfer parameters
112 + google.protobuf.Struct ec_transfer_params = 4;
113 }
114
115 // Controls which extra candidate tokens at each position should be returned
@@ -173,6 +176,7 @@ message FinishInfo {
176
177 google.protobuf.Struct kv_transfer_params = 6;
178 //uint64 seed = 7;
179 + google.protobuf.Struct ec_transfer_params = 8;
180 }
181
182 // Info for candidate tokens other than the input/sampled
rust/src/chat/src/event.rs
+3
@@ -202,5 +202,8 @@ pub enum ChatEvent {
202 finish_reason: FinishReason,
203 /// Connector-specific KV transfer parameters for disaggregated serving.
204 kv_transfer_params: Option<serde_json::Value>,
205 + /// Connector-specific encoder cache transfer parameters for
206 + /// disaggregated serving.
207 + ec_transfer_params: Option<serde_json::Value>,
208 },
209 }
rust/src/chat/src/output/default/unified.rs
+4
@@ -257,6 +257,7 @@ pub(crate) async fn unified_event_stream(
257 usage: finished.usage,
258 finish_reason: finished.finish_reason,
259 kv_transfer_params: finished.kv_transfer_params,
260 + ec_transfer_params: finished.ec_transfer_params,
261 })
262 .await;
263 }
@@ -387,6 +388,7 @@ mod tests {
388 usage: vllm_llm::TokenUsage::default(),
389 finish_reason: crate::FinishReason::Stop(None),
390 kv_transfer_params: None,
391 + ec_transfer_params: None,
392 }),
393 }
394 }
@@ -628,6 +630,7 @@ mod tests {
630 usage: vllm_llm::TokenUsage::default(),
631 finish_reason: crate::FinishReason::Stop(None),
632 kv_transfer_params: None,
633 + ec_transfer_params: None,
634 },
635 ]
636 );
@@ -671,6 +674,7 @@ mod tests {
674 usage: vllm_llm::TokenUsage::default(),
675 finish_reason: crate::FinishReason::Stop(None),
676 kv_transfer_params: None,
677 + ec_transfer_params: None,
678 },
679 ]
680 );
rust/src/chat/src/output/harmony/mod.rs
+1
@@ -370,6 +370,7 @@ async fn harmony_assistant_event_stream(
370 usage: finished.usage,
371 finish_reason: finished.finish_reason,
372 kv_transfer_params: finished.kv_transfer_params,
373 + ec_transfer_params: finished.ec_transfer_params,
374 })
375 .await;
376 }
rust/src/chat/src/output/harmony/tests.rs
+3
@@ -52,6 +52,7 @@ fn finished() -> Finished {
52 },
53 finish_reason: FinishReason::stop_eos(),
54 kv_transfer_params: None,
55 + ec_transfer_params: None,
56 }
57 }
58
@@ -115,6 +116,7 @@ fn interrupted_final_message_is_preserved() {
116 },
117 finish_reason: FinishReason::stop_eos(),
118 kv_transfer_params: None,
119 + ec_transfer_params: None,
120 })
121 );
122 }
@@ -175,6 +177,7 @@ fn interrupted_analysis_message_is_preserved() {
177 },
178 finish_reason: FinishReason::stop_eos(),
179 kv_transfer_params: None,
180 + ec_transfer_params: None,
181 })
182 );
183 }
rust/src/chat/src/output/mod.rs
+3
@@ -48,6 +48,9 @@ pub(crate) enum AssistantEvent {
48 finish_reason: FinishReason,
49 /// Connector-specific KV transfer parameters for disaggregated serving.
50 kv_transfer_params: Option<serde_json::Value>,
51 + /// Connector-specific encoder cache transfer parameters for
52 + /// disaggregated serving.
53 + ec_transfer_params: Option<serde_json::Value>,
54 },
55 }
56
rust/src/chat/src/output/structured.rs
+11 -1
@@ -146,6 +146,7 @@ impl StructuredEventState {
146 usage: vllm_llm::TokenUsage,
147 finish_reason: FinishReason,
148 kv_transfer_params: Option<serde_json::Value>,
149 + ec_transfer_params: Option<serde_json::Value>,
150 ) -> Result<Vec<ChatEvent>> {
151 let mut events = Vec::new();
152 self.close_open_text_block(&mut events);
@@ -155,6 +156,7 @@ impl StructuredEventState {
156 usage,
157 finish_reason,
158 kv_transfer_params,
159 + ec_transfer_params,
160 });
161 Ok(events)
162 }
@@ -296,8 +298,11 @@ pub(crate) async fn structured_chat_event_stream(
298 usage,
299 finish_reason,
300 kv_transfer_params,
301 + ec_transfer_params,
302 } => {
300 - for next in state.finish(usage, finish_reason, kv_transfer_params)? {
303 + for next in
304 + state.finish(usage, finish_reason, kv_transfer_params, ec_transfer_params)?
305 + {
306 y.yield_ok(next).await;
307 }
308 }
@@ -334,6 +339,7 @@ mod tests {
339 },
340 finish_reason: FinishReason::stop_eos(),
341 kv_transfer_params: None,
342 + ec_transfer_params: None,
343 }),
344 ]);
345
@@ -388,6 +394,7 @@ mod tests {
394 },
395 finish_reason: FinishReason::stop_eos(),
396 kv_transfer_params: None,
397 + ec_transfer_params: None,
398 }),
399 ]);
400
@@ -439,6 +446,7 @@ mod tests {
446 },
447 finish_reason: FinishReason::stop_eos(),
448 kv_transfer_params: None,
449 + ec_transfer_params: None,
450 }),
451 ]);
452
@@ -490,6 +498,7 @@ mod tests {
498 },
499 finish_reason: FinishReason::stop_eos(),
500 kv_transfer_params: None,
501 + ec_transfer_params: None,
502 }),
503 ]);
504
@@ -557,6 +566,7 @@ mod tests {
566 },
567 finish_reason: FinishReason::stop_eos(),
568 kv_transfer_params: None,
569 + ec_transfer_params: None,
570 }),
571 ]);
572
rust/src/chat/src/stream.rs
+7
@@ -22,6 +22,9 @@ pub struct CollectedAssistantMessage {
22 pub finish_reason: FinishReason,
23 /// Connector-specific KV transfer parameters for disaggregated serving.
24 pub kv_transfer_params: Option<serde_json::Value>,
25 + /// Connector-specific encoder cache transfer parameters for disaggregated
26 + /// serving.
27 + pub ec_transfer_params: Option<serde_json::Value>,
28 }
29
30 /// Per-request stream of chat events.
@@ -77,6 +80,7 @@ impl ChatEventStream {
80 usage,
81 finish_reason,
82 kv_transfer_params,
83 + ec_transfer_params,
84 } => {
85 return Ok(CollectedAssistantMessage {
86 message: done,
@@ -89,6 +93,7 @@ impl ChatEventStream {
93 usage,
94 finish_reason,
95 kv_transfer_params,
96 + ec_transfer_params,
97 });
98 }
99 ChatEvent::ToolCallEnd { call, .. } => {
@@ -194,6 +199,7 @@ mod tests {
199 },
200 finish_reason: FinishReason::stop_eos(),
201 kv_transfer_params: None,
202 + ec_transfer_params: None,
203 }),
204 ]),
205 );
@@ -234,6 +240,7 @@ mod tests {
240 },
241 finish_reason: FinishReason::stop_eos(),
242 kv_transfer_params: None,
243 + ec_transfer_params: None,
244 }
245 );
246 }
rust/src/chat/tests/chat.rs
+2
@@ -50,6 +50,7 @@ fn request_output(
50 stop_reason,
51 events: None,
52 kv_transfer_params: None,
53 + ec_transfer_params: None,
54 trace_headers: None,
55 prefill_stats: None,
56 routed_experts: None,
@@ -75,6 +76,7 @@ fn request_output_with_logprobs(
76 stop_reason,
77 events: None,
78 kv_transfer_params: None,
79 + ec_transfer_params: None,
80 trace_headers: None,
81 prefill_stats: None,
82 routed_experts: None,
rust/src/chat/tests/roundtrip.rs
+2
@@ -676,6 +676,7 @@ fn decoded_completion_stream(
676 usage: Default::default(),
677 finish_reason: FinishReason::stop_eos(),
678 kv_transfer_params: None,
679 + ec_transfer_params: None,
680 }),
681 }
682 });
@@ -686,6 +687,7 @@ fn decoded_completion_stream(
687 usage: Default::default(),
688 finish_reason: FinishReason::stop_eos(),
689 kv_transfer_params: None,
690 + ec_transfer_params: None,
691 });
692 events.push(DecodedTextEvent::TextDelta {
693 delta: chunk.delta,
rust/src/engine-core-client/src/protocol/output.rs
+4
@@ -97,6 +97,8 @@ pub struct EngineCoreOutput {
97 #[serde(default)]
98 pub kv_transfer_params: Option<serde_json::Value>,
99 #[serde(default)]
100 + pub ec_transfer_params: Option<serde_json::Value>,
101 + #[serde(default)]
102 pub trace_headers: Option<OpaqueValue>,
103 /// Breakdown of the scheduled prefill computation, set on the first output
104 /// of a newly scheduled prefill and elided for subsequent decode outputs.
@@ -374,6 +376,7 @@ mod tests {
376 stop_reason: Some(StopReason::Text("stop".to_string())),
377 events: None,
378 kv_transfer_params: None,
379 + ec_transfer_params: None,
380 trace_headers: None,
381 prefill_stats: None,
382 routed_experts: None,
@@ -426,6 +429,7 @@ mod tests {
429 stop_reason: None,
430 events: None,
431 kv_transfer_params: None,
432 + ec_transfer_params: None,
433 trace_headers: None,
434 prefill_stats: None,
435 routed_experts: None,
rust/src/engine-core-client/src/tests/client.rs
+2
@@ -226,6 +226,7 @@ fn request_output(
226 stop_reason: None,
227 events: None,
228 kv_transfer_params: None,
229 + ec_transfer_params: None,
230 trace_headers: None,
231 prefill_stats: None,
232 routed_experts: None,
@@ -2517,6 +2518,7 @@ fn python_msgpack_fixtures_match_rust_encoding() {
2518 stop_reason: None,
2519 events: None,
2520 kv_transfer_params: None,
2521 + ec_transfer_params: None,
2522 trace_headers: None,
2523 prefill_stats: None,
2524 routed_experts: None,
rust/src/engine-core-client/src/tests/python_compat.py
+1
@@ -91,6 +91,7 @@ class EngineCoreOutput(
91 stop_reason: int | str | None = None
92 events: object | None = None
93 kv_transfer_params: object | None = None
94 + ec_transfer_params: object | None = None
95 trace_headers: object | None = None
96 prefill_stats: object | None = None
97 routed_experts: object | None = None
rust/src/llm/src/output.rs
+10
@@ -38,6 +38,9 @@ pub struct CollectedGenerateOutput {
38 pub usage: TokenUsage,
39 /// Connector-specific KV transfer parameters for disaggregated serving.
40 pub kv_transfer_params: Option<serde_json::Value>,
41 + /// Connector-specific encoder cache transfer parameters for disaggregated
42 + /// serving.
43 + pub ec_transfer_params: Option<serde_json::Value>,
44 }
45
46 /// Prompt-scoped metadata emitted only once on the first [`GenerateOutput`] for
@@ -146,6 +149,9 @@ pub struct GenerateOutput {
149 pub cached_token_count: usize,
150 /// Connector-specific KV transfer parameters for disaggregated serving.
151 pub kv_transfer_params: Option<serde_json::Value>,
152 + /// Connector-specific encoder cache transfer parameters for disaggregated
153 + /// serving.
154 + pub ec_transfer_params: Option<serde_json::Value>,
155 }
156
157 impl GenerateOutput {
@@ -192,6 +198,7 @@ impl GenerateOutput {
198 finish_reason,
199 cached_token_count: 0,
200 kv_transfer_params: None,
201 + ec_transfer_params: None,
202 }
203 }
204 }
@@ -280,6 +287,7 @@ impl Stream for GenerateOutputStream {
287 finish_reason,
288 cached_token_count,
289 kv_transfer_params: raw.kv_transfer_params,
290 + ec_transfer_params: raw.ec_transfer_params,
291 };
292
293 Poll::Ready(Some(Ok(output)))
@@ -362,6 +370,7 @@ impl<T: Stream<Item = Result<GenerateOutput>> + Send> T {
370 cached_token_count,
371 },
372 kv_transfer_params: None,
373 + ec_transfer_params: None,
374 });
375 }
376
@@ -374,6 +383,7 @@ impl<T: Stream<Item = Result<GenerateOutput>> + Send> T {
383 cached_token_count,
384 };
385 collected.kv_transfer_params = output.kv_transfer_params;
386 + collected.ec_transfer_params = output.ec_transfer_params;
387 return Ok(collected);
388 }
389 }
rust/src/llm/tests/generate.rs
+5
@@ -51,6 +51,7 @@ fn request_output_with_events(
51 stop_reason: None,
52 events,
53 kv_transfer_params: None,
54 + ec_transfer_params: None,
55 trace_headers: None,
56 prefill_stats: None,
57 routed_experts: None,
@@ -75,6 +76,7 @@ fn request_output_with_logprobs(
76 stop_reason: None,
77 events: None,
78 kv_transfer_params: None,
79 + ec_transfer_params: None,
80 trace_headers: None,
81 prefill_stats: None,
82 routed_experts: None,
@@ -89,6 +91,7 @@ fn request_output_with_logprobs_and_kv(
91 new_logprobs: Option<Logprobs>,
92 prompt_logprobs: Option<Logprobs>,
93 kv_transfer_params: Option<serde_json::Value>,
94 + ec_transfer_params: Option<serde_json::Value>,
95 ) -> EngineCoreOutput {
96 EngineCoreOutput {
97 request_id: request_id.to_string(),
@@ -100,6 +103,7 @@ fn request_output_with_logprobs_and_kv(
103 stop_reason: None,
104 events: None,
105 kv_transfer_params,
106 + ec_transfer_params,
107 trace_headers: None,
108 prefill_stats: None,
109 routed_experts: None,
@@ -356,6 +360,7 @@ async fn collect_output_aggregates_raw_tokens_logprobs_and_terminal_metadata() {
360 Some(logprobs_for_position(44, -0.3, 1, 88, -0.4)),
361 None,
362 Some(serde_json::json!({"connector": "x"})),
363 + None,
364 ),
365 ],
366 ..Default::default()
rust/src/server/src/grpc/convert.rs
+7
@@ -69,6 +69,11 @@ pub fn to_text_request(
69 let map = sampling_params.vllm_xargs.get_or_insert_with(Default::default);
70 map.insert("kv_transfer_params".to_string(), kv_json);
71 }
72 + if let Some(ec_struct) = kv.ec_transfer_params.as_ref() {
73 + let ec_json = proto_struct_to_json(ec_struct);
74 + let map = sampling_params.vllm_xargs.get_or_insert_with(Default::default);
75 + map.insert("ec_transfer_params".to_string(), ec_json);
76 + }
77 if kv.bypass_prefix_cache {
78 sampling_params.skip_reading_prefix_cache = Some(true);
79 }
@@ -343,6 +348,7 @@ fn to_finish_info(finished: &Finished, token_ids: &[u32]) -> pb::FinishInfo {
348 finish_reason,
349 stop_reason,
350 kv_transfer_params: finished.kv_transfer_params.as_ref().and_then(json_to_proto_struct),
351 + ec_transfer_params: finished.ec_transfer_params.as_ref().and_then(json_to_proto_struct),
352 }
353 }
354
@@ -586,6 +592,7 @@ mod tests {
592 },
593 finish_reason: reason,
594 kv_transfer_params: None,
595 + ec_transfer_params: None,
596 }
597 }
598
rust/src/server/src/grpc/mod.rs
+1
@@ -71,6 +71,7 @@ impl pb::generate_server::Generate for GenerateServiceImpl {
71 usage: collected.usage,
72 finish_reason: collected.finish_reason,
73 kv_transfer_params: collected.kv_transfer_params,
74 + ec_transfer_params: collected.ec_transfer_params,
75 };
76
77 let outputs = convert::to_sequence_output(
rust/src/server/src/grpc/tests.rs
+1
@@ -104,6 +104,7 @@ fn request_output(
104 stop_reason: None,
105 events: None,
106 kv_transfer_params: None,
107 + ec_transfer_params: None,
108 trace_headers: None,
109 prefill_stats: None,
110 routed_experts: None,
rust/src/server/src/routes/http_client_tests.rs
+1
@@ -100,6 +100,7 @@ fn request_output(
100 stop_reason: None,
101 events: None,
102 kv_transfer_params: None,
103 + ec_transfer_params: None,
104 trace_headers: None,
105 prefill_stats: None,
106 routed_experts: None,
rust/src/server/src/routes/inference/generate.rs
+3
@@ -266,6 +266,7 @@ fn collect_generate(
266 }],
267 prompt_logprobs,
268 kv_transfer_params: collected.kv_transfer_params,
269 + ec_transfer_params: collected.ec_transfer_params,
270 })
271 }
272
@@ -404,6 +405,7 @@ mod tests {
405 finish_reason: None,
406 cached_token_count: 0,
407 kv_transfer_params: None,
408 + ec_transfer_params: None,
409 }),
410 Ok(GenerateOutput {
411 request_id: String::new(),
@@ -416,6 +418,7 @@ mod tests {
418 finish_reason: Some(FinishReason::stop_eos()),
419 cached_token_count: 2,
420 kv_transfer_params: None,
421 + ec_transfer_params: None,
422 }),
423 ]);
424
rust/src/server/src/routes/inference/generate/convert.rs
+5 -1
@@ -4,7 +4,7 @@ use super::types::GenerateRequest;
4 use super::validate;
5 use crate::error::ApiError;
6 use crate::lora::LoraModelResolution;
7 -use crate::utils::{ResolvedRequestContext, merge_kv_transfer_params};
7 +use crate::utils::{ResolvedRequestContext, merge_ec_transfer_params, merge_kv_transfer_params};
8
9 /// Lowered generate request plus the response request ID.
10 #[derive(Debug, Clone, PartialEq)]
@@ -56,6 +56,10 @@ pub(super) fn prepare_generate_request(
56 sampling_params.vllm_xargs,
57 request.kv_transfer_params.as_ref(),
58 );
59 + sampling_params.vllm_xargs = merge_ec_transfer_params(
60 + sampling_params.vllm_xargs,
61 + request.ec_transfer_params.as_ref(),
62 + );
63
64 let text_request = TextRequest {
65 request_id: ctx.request_id.clone(),
rust/src/server/src/routes/inference/generate/types.rs
+2
@@ -22,6 +22,7 @@ pub struct GenerateRequest {
22 #[serde(default)]
23 pub priority: i32,
24 pub kv_transfer_params: Option<HashMap<String, Value>>,
25 + pub ec_transfer_params: Option<HashMap<String, Value>>,
26 #[serde(flatten)]
27 pub other: Map<String, Value>,
28 }
@@ -66,6 +67,7 @@ pub(super) struct GenerateResponse {
67 pub choices: Vec<GenerateResponseChoice>,
68 pub prompt_logprobs: Option<Vec<Option<HashMap<u32, GenerateLogprob>>>>,
69 pub kv_transfer_params: Option<Value>,
70 + pub ec_transfer_params: Option<Value>,
71 }
72
73 /// Mirrors the Python vLLM `Logprob` class used in prompt-logprobs payloads.
rust/src/server/src/routes/openai/chat_completions.rs
+8
@@ -144,6 +144,7 @@ async fn collect_chat_completion(
144 usage,
145 finish_reason,
146 kv_transfer_params,
147 + ec_transfer_params,
148 } = collected;
149 let stop_reason = finish_reason.as_stop_reason().map(stop_reason_to_json);
150 let saw_tool_calls = message.tool_calls().next().is_some();
@@ -224,6 +225,7 @@ async fn collect_chat_completion(
225 prompt_logprobs,
226 prompt_token_ids: return_token_ids.then(|| prompt_token_ids.to_vec()),
227 kv_transfer_params,
228 + ec_transfer_params,
229 })
230 }
231
@@ -951,6 +953,7 @@ mod tests {
953 },
954 finish_reason: FinishReason::stop_eos(),
955 kv_transfer_params: None,
956 + ec_transfer_params: None,
957 }),
958 ]);
959
@@ -1031,6 +1034,7 @@ mod tests {
1034 },
1035 finish_reason: FinishReason::stop_eos(),
1036 kv_transfer_params: None,
1037 + ec_transfer_params: None,
1038 }),
1039 ]);
1040
@@ -1086,6 +1090,7 @@ mod tests {
1090 },
1091 finish_reason: FinishReason::stop_eos(),
1092 kv_transfer_params: None,
1093 + ec_transfer_params: None,
1094 }),
1095 ]);
1096
@@ -1167,6 +1172,7 @@ mod tests {
1172 },
1173 finish_reason: FinishReason::stop_eos(),
1174 kv_transfer_params: None,
1175 + ec_transfer_params: None,
1176 }),
1177 ]);
1178
@@ -1300,6 +1306,7 @@ mod tests {
1306 },
1307 finish_reason: FinishReason::stop_eos(),
1308 kv_transfer_params: None,
1309 + ec_transfer_params: None,
1310 }),
1311 ]);
1312
@@ -1381,6 +1388,7 @@ mod tests {
1388 },
1389 finish_reason: FinishReason::stop_eos(),
1390 kv_transfer_params: None,
1391 + ec_transfer_params: None,
1392 }),
1393 ]);
1394
rust/src/server/src/routes/openai/chat_completions/convert.rs
+4 -2
@@ -13,7 +13,9 @@ use crate::routes::openai::utils::structured_outputs::convert_from_response_form
13 use crate::routes::openai::utils::types::{
14 ChatMessage, ContentPart, MessageContent, Tool, ToolChoice, ToolChoiceValue,
15 };
16 -use crate::utils::{ResolvedRequestContext, convert_logit_bias, merge_kv_transfer_params};
16 +use crate::utils::{
17 + ResolvedRequestContext, convert_logit_bias, merge_ec_transfer_params, merge_kv_transfer_params,
18 +};
19
20 /// Lowered chat request plus the public response metadata carried by every SSE
21 /// chunk.
@@ -132,7 +134,7 @@ pub(super) fn prepare_chat_request(
134 structured_outputs,
135 skip_reading_prefix_cache: None,
136 vllm_xargs: merge_kv_transfer_params(
135 - request.vllm_xargs,
137 + merge_ec_transfer_params(request.vllm_xargs, request.ec_transfer_params.as_ref()),
138 request.kv_transfer_params.as_ref(),
139 ),
140 },
rust/src/server/src/routes/openai/chat_completions/types.rs
+5
@@ -232,6 +232,9 @@ pub struct ChatCompletionRequest {
232 /// KV transfer parameters for disaggregated serving
233 pub kv_transfer_params: Option<HashMap<String, Value>>,
234
235 + /// Encoder cache transfer parameters for disaggregated serving
236 + pub ec_transfer_params: Option<HashMap<String, Value>>,
237 +
238 /// Additional request parameters with string or numeric values for custom
239 /// extensions
240 pub vllm_xargs: Option<HashMap<String, Value>>,
@@ -299,6 +302,7 @@ impl Default for ChatCompletionRequest {
302 return_token_ids: None,
303 cache_salt: None,
304 kv_transfer_params: None,
305 + ec_transfer_params: None,
306 vllm_xargs: None,
307 repetition_detection: None,
308 }
@@ -346,6 +350,7 @@ pub(super) struct ChatCompletionResponse {
350 pub prompt_logprobs: Option<Vec<Option<HashMap<String, f32>>>>,
351 pub prompt_token_ids: Option<Vec<u32>>,
352 pub kv_transfer_params: Option<Value>,
353 + pub ec_transfer_params: Option<Value>,
354 }
355
356 /// Mirrors the Python vLLM `ChatCompletionResponseChoice` class.
rust/src/server/src/routes/openai/completions.rs
+7
@@ -212,6 +212,7 @@ async fn collect_completion(
212 usage: Some(usage),
213 system_fingerprint: None,
214 kv_transfer_params: collected.kv_transfer_params,
215 + ec_transfer_params: collected.ec_transfer_params,
216 })
217 }
218
@@ -682,6 +683,7 @@ mod tests {
683 "repetition_detected".to_string(),
684 ))),
685 kv_transfer_params: None,
686 + ec_transfer_params: None,
687 }),
688 }),
689 ]);
@@ -786,6 +788,7 @@ mod tests {
788 },
789 finish_reason: FinishReason::Length,
790 kv_transfer_params: None,
791 + ec_transfer_params: None,
792 }),
793 }),
794 ]);
@@ -837,6 +840,7 @@ mod tests {
840 },
841 finish_reason: FinishReason::Length,
842 kv_transfer_params: None,
843 + ec_transfer_params: None,
844 }),
845 }),
846 ]);
@@ -891,6 +895,7 @@ mod tests {
895 },
896 finish_reason: FinishReason::Length,
897 kv_transfer_params: None,
898 + ec_transfer_params: None,
899 }),
900 }),
901 ]);
@@ -962,6 +967,7 @@ mod tests {
967 },
968 finish_reason: FinishReason::Length,
969 kv_transfer_params: None,
970 + ec_transfer_params: None,
971 }),
972 }),
973 ]);
@@ -1035,6 +1041,7 @@ mod tests {
1041 },
1042 finish_reason: FinishReason::Length,
1043 kv_transfer_params: None,
1044 + ec_transfer_params: None,
1045 }),
1046 }),
1047 ]);
rust/src/server/src/routes/openai/completions/convert.rs
+4 -2
@@ -7,7 +7,9 @@ use crate::error::ApiError;
7 use crate::lora::LoraModelResolution;
8 use crate::routes::openai::completions::validate;
9 use crate::routes::openai::utils::structured_outputs::convert_from_response_format_value;
10 -use crate::utils::{ResolvedRequestContext, convert_logit_bias, merge_kv_transfer_params};
10 +use crate::utils::{
11 + ResolvedRequestContext, convert_logit_bias, merge_ec_transfer_params, merge_kv_transfer_params,
12 +};
13
14 /// Lowered completion request plus the public response metadata carried by
15 /// every SSE chunk.
@@ -127,7 +129,7 @@ pub(super) fn prepare_completion_request(
129 structured_outputs,
130 skip_reading_prefix_cache: None,
131 vllm_xargs: merge_kv_transfer_params(
130 - request.vllm_xargs,
132 + merge_ec_transfer_params(request.vllm_xargs, request.ec_transfer_params.as_ref()),
133 request.kv_transfer_params.as_ref(),
134 ),
135 },
rust/src/server/src/routes/openai/completions/types.rs
+4
@@ -174,6 +174,9 @@ pub struct CompletionRequest {
174 /// KV transfer parameters for disaggregated serving
175 pub kv_transfer_params: Option<HashMap<String, Value>>,
176
177 + /// Encoder cache transfer parameters for disaggregated serving
178 + pub ec_transfer_params: Option<HashMap<String, Value>>,
179 +
180 /// Additional request parameters with string or numeric values for custom
181 /// extensions
182 pub vllm_xargs: Option<HashMap<String, Value>>,
@@ -209,6 +212,7 @@ pub(super) struct CompletionResponse {
212 pub usage: Option<Usage>,
213 pub system_fingerprint: Option<String>,
214 pub kv_transfer_params: Option<Value>,
215 + pub ec_transfer_params: Option<Value>,
216 }
217
218 /// Mirrors the Python vLLM `CompletionResponseChoice` class.
rust/src/server/src/routes/tests.rs
+5
@@ -76,6 +76,7 @@ fn request_output_with_stop_reason(
76 stop_reason,
77 events: None,
78 kv_transfer_params: None,
79 + ec_transfer_params: None,
80 trace_headers: None,
81 prefill_stats: None,
82 routed_experts: None,
@@ -101,6 +102,7 @@ fn request_output_with_logprobs(
102 stop_reason,
103 events: None,
104 kv_transfer_params: None,
105 + ec_transfer_params: None,
106 trace_headers: None,
107 prefill_stats: None,
108 routed_experts: None,
@@ -116,6 +118,7 @@ fn request_output_with_logprobs_and_kv(
118 new_logprobs: Option<Logprobs>,
119 new_prompt_logprobs_tensors: Option<Logprobs>,
120 kv_transfer_params: Option<serde_json::Value>,
121 + ec_transfer_params: Option<serde_json::Value>,
122 ) -> EngineCoreOutput {
123 EngineCoreOutput {
124 request_id: request_id.to_string(),
@@ -127,6 +130,7 @@ fn request_output_with_logprobs_and_kv(
130 stop_reason,
131 events: None,
132 kv_transfer_params,
133 + ec_transfer_params,
134 trace_headers: None,
135 prefill_stats: None,
136 routed_experts: None,
@@ -3634,6 +3638,7 @@ async fn non_stream_raw_generate_returns_token_output_envelope() {
3638 Some(sample_logprobs_for_token(44, 45)),
3639 None,
3640 Some(json!({"connector": "x"})),
3641 + None,
3642 ),
3643 ],
3644 ..Default::default()
rust/src/server/src/utils.rs
+18
@@ -45,6 +45,24 @@ pub fn merge_kv_transfer_params(
45 xargs
46 }
47
48 +/// Merge `ec_transfer_params` into the `vllm_xargs` map, mirroring the Python
49 +/// vLLM behavior where `ec_transfer_params` is injected into `extra_args` for
50 +/// engine-core consumption.
51 +pub fn merge_ec_transfer_params(
52 + mut xargs: Option<HashMap<String, Value>>,
53 + ec_transfer_params: Option<&HashMap<String, Value>>,
54 +) -> Option<HashMap<String, Value>> {
55 + if let Some(ec_params) = ec_transfer_params {
56 + let map = xargs.get_or_insert_with(HashMap::new);
57 + map.insert(
58 + "ec_transfer_params".to_string(),
59 + // This is safe because we know that `ec_params` is already valid JSON.
60 + serde_json::to_value(ec_params).unwrap(),
61 + );
62 + }
63 + xargs
64 +}
65 +
66 /// Convert OpenAI-style `logit_bias` with string token-ID keys into the
67 /// internal `HashMap<u32, f32>` representation, validating that every key
68 /// parses as a `u32`.
rust/src/text/src/output/decoded.rs
+5
@@ -44,6 +44,9 @@ pub struct Finished {
44 pub finish_reason: FinishReason,
45 /// Connector-specific KV transfer parameters for disaggregated serving.
46 pub kv_transfer_params: Option<serde_json::Value>,
47 + /// Connector-specific encoder cache transfer parameters for disaggregated
48 + /// serving.
49 + pub ec_transfer_params: Option<serde_json::Value>,
50 }
51
52 /// Internal decoded-text event emitted before higher-level assistant
@@ -151,6 +154,7 @@ pub async fn decoded_text_event_stream(
154 let decoder = decoder.as_mut().unwrap();
155
156 let kv_transfer_params = output.kv_transfer_params;
157 + let ec_transfer_params = output.ec_transfer_params;
158 let mut finish_reason = output.finish_reason;
159 let mut stop_str_matched = false;
160 let suppress_terminal_stop_token = finish_reason.as_ref().is_some_and(|r| r.is_stop())
@@ -275,6 +279,7 @@ pub async fn decoded_text_event_stream(
279 },
280 finish_reason: reason,
281 kv_transfer_params,
282 + ec_transfer_params,
283 }),
284 })
285 .await;
rust/src/text/src/output/mod.rs
+7
@@ -26,6 +26,9 @@ pub struct CollectedTextOutput {
26 pub usage: vllm_llm::TokenUsage,
27 /// Connector-specific KV transfer parameters for disaggregated serving.
28 pub kv_transfer_params: Option<serde_json::Value>,
29 + /// Connector-specific encoder cache transfer parameters for disaggregated
30 + /// serving.
31 + pub ec_transfer_params: Option<serde_json::Value>,
32 }
33
34 #[allow(clippy::manual_async_fn, reason = "specify `Send` bound")]
@@ -77,6 +80,7 @@ impl<T: TextOutputStream> T {
80 finish_reason: FinishReason::Error,
81 usage: vllm_llm::TokenUsage::default(),
82 kv_transfer_params: None,
83 + ec_transfer_params: None,
84 })
85 };
86
@@ -85,6 +89,7 @@ impl<T: TextOutputStream> T {
89 collected.finish_reason = finished.finish_reason;
90 collected.usage = finished.usage;
91 collected.kv_transfer_params = finished.kv_transfer_params;
92 + collected.ec_transfer_params = finished.ec_transfer_params;
93 return Ok(collected);
94 }
95 }
@@ -156,6 +161,7 @@ mod tests {
161 },
162 finish_reason: FinishReason::stop_eos(),
163 kv_transfer_params: None,
164 + ec_transfer_params: None,
165 }),
166 }),
167 ]);
@@ -273,6 +279,7 @@ mod tests {
279 },
280 finish_reason: FinishReason::stop_eos(),
281 kv_transfer_params: None,
282 + ec_transfer_params: None,
283 }),
284 }),
285 ]);
tests/v1/core/test_async_scheduler.py
+1 -1
@@ -294,7 +294,7 @@ def test_abort_request_when_structured_output_fsm_cannot_advance():
294 def free_request(req, delay_free_blocks=False):
295 scheduler.finished_req_ids.add(req.request_id)
296 scheduler.requests.pop(req.request_id, None)
297 - return None
297 + return None, None
298
299 scheduler._free_request = Mock(side_effect=free_request)
300
tests/v1/core/test_scheduler.py
+1 -1
@@ -2968,7 +2968,7 @@ def test_abort_request_when_structured_output_fsm_cannot_advance():
2968 def free_request(req: Request, delay_free_blocks: bool = False):
2969 scheduler.finished_req_ids.add(req.request_id)
2970 scheduler.requests.pop(req.request_id, None)
2971 - return None
2971 + return None, None
2972
2973 scheduler._free_request = Mock(side_effect=free_request)
2974
tests/v1/ec_connector/unit/test_ec_transfer_params.py new
+118
@@ -0,0 +1,118 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Sanity tests for ec_transfer_params protocol plumbing.
4 +
5 +No running engine required.
6 +"""
7 +
8 +from unittest.mock import MagicMock
9 +
10 +from tests.v1.core.utils import create_scheduler
11 +from vllm.entrypoints.openai.chat_completion.protocol import (
12 + ChatCompletionRequest,
13 +)
14 +from vllm.outputs import CompletionOutput, RequestOutput
15 +from vllm.sampling_params import SamplingParams
16 +from vllm.v1.request import Request, RequestStatus
17 +
18 +EC_PARAMS: dict = {"mm_hash_abc": {"peer_host": "10.0.0.1", "peer_port": 5501}}
19 +
20 +
21 +def test_ec_transfer_params_routed_to_sampling_params_extra_args():
22 + """ec_transfer_params on the request must land in SamplingParams.extra_args."""
23 + req = ChatCompletionRequest(
24 + model="test-model",
25 + messages=[{"role": "user", "content": "hi"}],
26 + max_tokens=5,
27 + ec_transfer_params=EC_PARAMS,
28 + )
29 + sp = req.to_sampling_params(max_tokens=5, default_sampling_params={})
30 + assert sp.extra_args is not None
31 + assert sp.extra_args.get("ec_transfer_params") == EC_PARAMS
32 +
33 +
34 +def test_request_output_add_propagates_ec_transfer_params():
35 + """RequestOutput.add() must carry ec_transfer_params forward to the caller."""
36 +
37 + def _out(ec_params):
38 + return RequestOutput(
39 + request_id="r1",
40 + prompt="p",
41 + prompt_token_ids=[1],
42 + prompt_logprobs=None,
43 + outputs=[
44 + CompletionOutput(
45 + index=0,
46 + text="",
47 + token_ids=[],
48 + cumulative_logprob=0.0,
49 + logprobs=None,
50 + finish_reason=None,
51 + )
52 + ],
53 + finished=False,
54 + ec_transfer_params=ec_params,
55 + )
56 +
57 + accumulated = _out(None)
58 + accumulated.add(_out(EC_PARAMS), aggregate=True)
59 + assert accumulated.ec_transfer_params == EC_PARAMS
60 +
61 +
62 +def test_request_reads_ec_transfer_params_from_extra_args():
63 + """v1 Request must pull ec_transfer_params out of SamplingParams.extra_args."""
64 + sp = SamplingParams(extra_args={"ec_transfer_params": EC_PARAMS})
65 + req = Request(
66 + request_id="r1",
67 + prompt_token_ids=[1, 2, 3],
68 + sampling_params=sp,
69 + pooling_params=None,
70 + )
71 + assert req.ec_transfer_params == EC_PARAMS
72 +
73 +
74 +def test_free_request_calls_ec_connector_and_surfaces_params():
75 + """_free_request must call ec_connector.request_finished() and return its params."""
76 + sp = SamplingParams(max_tokens=1)
77 + sp.update_from_generation_config({}, 50256)
78 + request = Request(
79 + request_id="test-req",
80 + prompt_token_ids=[1, 2, 3],
81 + sampling_params=sp,
82 + pooling_params=None,
83 + client_index=0,
84 + )
85 + scheduler = create_scheduler(use_ec_connector=True, ec_role="ec_producer")
86 + scheduler.add_request(request)
87 + request.status = RequestStatus.FINISHED_STOPPED
88 +
89 + mock_ec = MagicMock()
90 + mock_ec.request_finished.return_value = (False, EC_PARAMS)
91 + scheduler.ec_connector = mock_ec
92 +
93 + kv_params, ec_params = scheduler._free_request(request)
94 +
95 + mock_ec.request_finished.assert_called_once_with(request)
96 + assert ec_params == EC_PARAMS
97 + assert kv_params is None
98 +
99 +
100 +def test_free_request_without_ec_connector_returns_none():
101 + """When no EC connector is configured, ec_transfer_params must be None."""
102 + sp = SamplingParams(max_tokens=1)
103 + sp.update_from_generation_config({}, 50256)
104 + request = Request(
105 + request_id="test-req",
106 + prompt_token_ids=[1, 2, 3],
107 + sampling_params=sp,
108 + pooling_params=None,
109 + client_index=0,
110 + )
111 + scheduler = create_scheduler(use_ec_connector=True, ec_role="ec_producer")
112 + scheduler.add_request(request)
113 + request.status = RequestStatus.FINISHED_STOPPED
114 +
115 + kv_params, ec_params = scheduler._free_request(request)
116 +
117 + assert ec_params is None
118 + assert kv_params is None
vllm/entrypoints/anthropic/protocol.py
+9
@@ -137,6 +137,12 @@ class AnthropicMessagesRequest(BaseModel):
137 default=None,
138 description="KVTransfer parameters used for disaggregated serving.",
139 )
140 + ec_transfer_params: dict[str, Any] | None = Field(
141 + default=None,
142 + description=(
143 + "ECTransfer parameters used for encoder-cache disaggregated serving."
144 + ),
145 + )
146 chat_template_kwargs: dict[str, Any] | None = Field(
147 default=None,
148 description=(
@@ -218,6 +224,9 @@ class AnthropicMessagesResponse(BaseModel):
224 kv_transfer_params: dict[str, Any] | None = Field(
225 default=None, description="KVTransfer parameters."
226 )
227 + ec_transfer_params: dict[str, Any] | None = Field(
228 + default=None, description="ECTransfer parameters."
229 + )
230
231 def model_post_init(self, __context):
232 if not self.id:
vllm/entrypoints/anthropic/serving.py
+2
@@ -491,6 +491,7 @@ class AnthropicServingMessages(OpenAIServingChat):
491 top_p=anthropic_request.top_p,
492 top_k=anthropic_request.top_k,
493 kv_transfer_params=anthropic_request.kv_transfer_params,
494 + ec_transfer_params=anthropic_request.ec_transfer_params,
495 chat_template_kwargs=anthropic_request.chat_template_kwargs,
496 )
497
@@ -630,6 +631,7 @@ class AnthropicServingMessages(OpenAIServingChat):
631 generator.usage,
632 ),
633 kv_transfer_params=generator.kv_transfer_params,
634 + ec_transfer_params=generator.ec_transfer_params,
635 )
636 choice = generator.choices[0]
637 if choice.finish_reason == "stop":
vllm/entrypoints/openai/chat_completion/protocol.py
+13
@@ -134,6 +134,9 @@ class ChatCompletionResponse(OpenAIBaseModel):
134 kv_transfer_params: dict[str, Any] | None = Field(
135 default=None, description="KVTransfer parameters."
136 )
137 + ec_transfer_params: dict[str, Any] | None = Field(
138 + default=None, description="ECTransfer parameters."
139 + )
140 metrics: PerRequestTimingMetrics | None = None
141
142
@@ -439,6 +442,13 @@ class ChatCompletionRequest(OpenAIBaseModel):
442 description="KVTransfer parameters used for disaggregated serving.",
443 )
444
445 + ec_transfer_params: dict[str, Any] | None = Field(
446 + default=None,
447 + description=(
448 + "ECTransfer parameters used for encoder-cache disaggregated serving."
449 + ),
450 + )
451 +
452 vllm_xargs: dict[str, str | int | float | list[str | int | float]] | None = Field(
453 default=None,
454 description=(
@@ -665,6 +675,9 @@ class ChatCompletionRequest(OpenAIBaseModel):
675 if self.kv_transfer_params:
676 # Pass in kv_transfer_params via extra_args
677 extra_args["kv_transfer_params"] = self.kv_transfer_params
678 + if self.ec_transfer_params:
679 + # Pass in ec_transfer_params via extra_args
680 + extra_args["ec_transfer_params"] = self.ec_transfer_params
681 return SamplingParams.from_optional(
682 n=self.n,
683 presence_penalty=self.presence_penalty,
vllm/entrypoints/openai/chat_completion/serving.py
+1
@@ -1057,6 +1057,7 @@ class OpenAIServingChat(GenerateBaseServing):
1057 ),
1058 prompt_text=prompt_text,
1059 kv_transfer_params=final_res.kv_transfer_params,
1060 + ec_transfer_params=final_res.ec_transfer_params,
1061 metrics=per_request_metrics,
1062 )
1063
vllm/entrypoints/openai/completion/protocol.py
+13
@@ -189,6 +189,13 @@ class CompletionRequest(OpenAIBaseModel):
189 description="KVTransfer parameters used for disaggregated serving.",
190 )
191
192 + ec_transfer_params: dict[str, Any] | None = Field(
193 + default=None,
194 + description=(
195 + "ECTransfer parameters used for encoder-cache disaggregated serving."
196 + ),
197 + )
198 +
199 vllm_xargs: dict[str, str | int | float] | None = Field(
200 default=None,
201 description=(
@@ -346,6 +353,9 @@ class CompletionRequest(OpenAIBaseModel):
353 if self.kv_transfer_params:
354 # Pass in kv_transfer_params via extra_args
355 extra_args["kv_transfer_params"] = self.kv_transfer_params
356 + if self.ec_transfer_params:
357 + # Pass in ec_transfer_params via extra_args
358 + extra_args["ec_transfer_params"] = self.ec_transfer_params
359 return SamplingParams.from_optional(
360 n=self.n,
361 presence_penalty=self.presence_penalty,
@@ -595,6 +605,9 @@ class CompletionResponse(OpenAIBaseModel):
605 kv_transfer_params: dict[str, Any] | None = Field(
606 default=None, description="KVTransfer parameters."
607 )
608 + ec_transfer_params: dict[str, Any] | None = Field(
609 + default=None, description="ECTransfer parameters."
610 + )
611 metrics: PerRequestTimingMetrics | None = None
612
613
vllm/entrypoints/openai/completion/serving.py
+4
@@ -510,6 +510,7 @@ class OpenAIServingCompletion(GenerateBaseServing):
510 num_prompt_tokens = 0
511 num_generated_tokens = 0
512 kv_transfer_params = None
513 + ec_transfer_params = None
514 last_final_res = None
515 for final_res in final_res_batch:
516 last_final_res = final_res
@@ -632,6 +633,8 @@ class OpenAIServingCompletion(GenerateBaseServing):
633
634 if final_res_batch:
635 kv_transfer_params = final_res_batch[0].kv_transfer_params
636 + ec_transfer_params = final_res_batch[0].ec_transfer_params
637 +
638 return CompletionResponse(
639 id=request_id,
640 created=created_time,
@@ -640,6 +643,7 @@ class OpenAIServingCompletion(GenerateBaseServing):
643 usage=usage,
644 system_fingerprint=self.system_fingerprint,
645 kv_transfer_params=kv_transfer_params,
646 + ec_transfer_params=ec_transfer_params,
647 metrics=per_request_metrics,
648 )
649
vllm/entrypoints/openai/responses/context.py
+10
@@ -200,6 +200,7 @@ class SimpleContext(ConversationContext):
200
201 self.input_messages: list[ResponseRawMessageAndToken] = []
202 self.kv_transfer_params: dict[str, Any] | None = None
203 + self.ec_transfer_params: dict[str, Any] | None = None
204
205 def append_output(self, output) -> None:
206 self.last_output = output
@@ -210,6 +211,8 @@ class SimpleContext(ConversationContext):
211 self.num_output_tokens += len(output.outputs[0].token_ids or [])
212 if output.kv_transfer_params is not None:
213 self.kv_transfer_params = output.kv_transfer_params
214 + if output.ec_transfer_params is not None:
215 + self.ec_transfer_params = output.ec_transfer_params
216
217 # Accumulate text, token_ids, and logprobs for streaming mode
218 delta_output = output.outputs[0]
@@ -328,6 +331,7 @@ class ParsableContext(ConversationContext):
331 self.output_messages: list[ResponseRawMessageAndToken] = []
332 self._accumulated_token_ids: list[int] = []
333 self.kv_transfer_params: dict[str, Any] | None = None
334 + self.ec_transfer_params: dict[str, Any] | None = None
335
336 def append_output(self, output: RequestOutput) -> None:
337 self.num_prompt_tokens = len(output.prompt_token_ids or [])
@@ -336,6 +340,9 @@ class ParsableContext(ConversationContext):
340 if output.kv_transfer_params is not None:
341 self.kv_transfer_params = output.kv_transfer_params
342
343 + if output.ec_transfer_params is not None:
344 + self.ec_transfer_params = output.ec_transfer_params
345 +
346 completion = output.outputs[0]
347 self.finish_reason = completion.finish_reason
348
@@ -630,6 +637,7 @@ class HarmonyContext(ConversationContext):
637 self.is_first_turn = True
638 self.first_tok_of_message = True
639 self.kv_transfer_params: dict[str, Any] | None = None
640 + self.ec_transfer_params: dict[str, Any] | None = None
641
642 def append_output(self, output: RequestOutput) -> None:
643 if self.first_tok_of_message:
@@ -645,6 +653,8 @@ class HarmonyContext(ConversationContext):
653 self._update_decode_token_usage(output)
654 if output.kv_transfer_params is not None:
655 self.kv_transfer_params = output.kv_transfer_params
656 + if output.ec_transfer_params is not None:
657 + self.ec_transfer_params = output.ec_transfer_params
658
659 if output.finished:
660 self.finish_reason = output.outputs[0].finish_reason
vllm/entrypoints/openai/responses/protocol.py
+13
@@ -285,6 +285,12 @@ class ResponsesRequest(OpenAIBaseModel):
285 default=None,
286 description="KVTransfer parameters used for disaggregated serving.",
287 )
288 + ec_transfer_params: dict[str, Any] | None = Field(
289 + default=None,
290 + description=(
291 + "ECTransfer parameters used for encoder-cache disaggregated serving."
292 + ),
293 + )
294 chat_template_kwargs: dict[str, Any] | None = Field(
295 default=None,
296 description=(
@@ -409,6 +415,8 @@ class ResponsesRequest(OpenAIBaseModel):
415 extra_args: dict[str, Any] = self.vllm_xargs if self.vllm_xargs else {}
416 if self.kv_transfer_params:
417 extra_args["kv_transfer_params"] = self.kv_transfer_params
418 + if self.ec_transfer_params:
419 + extra_args["ec_transfer_params"] = self.ec_transfer_params
420
421 return SamplingParams.from_optional(
422 temperature=temperature,
@@ -675,6 +683,9 @@ class ResponsesResponse(OpenAIBaseModel):
683 kv_transfer_params: dict[str, Any] | None = Field(
684 default=None, description="KVTransfer parameters."
685 )
686 + ec_transfer_params: dict[str, Any] | None = Field(
687 + default=None, description="ECTransfer parameters."
688 + )
689
690 # --8<-- [start:responses-response-extra-params]
691 # These are populated when enable_response_messages is set to True
@@ -720,6 +731,7 @@ class ResponsesResponse(OpenAIBaseModel):
731 input_messages: ResponseInputOutputMessage | None = None,
732 output_messages: ResponseInputOutputMessage | None = None,
733 kv_transfer_params: dict[str, Any] | None = None,
734 + ec_transfer_params: dict[str, Any] | None = None,
735 ) -> "ResponsesResponse":
736 incomplete_details: IncompleteDetails | None = None
737 if status == "incomplete":
@@ -758,6 +770,7 @@ class ResponsesResponse(OpenAIBaseModel):
770 user=request.user,
771 usage=usage,
772 kv_transfer_params=kv_transfer_params,
773 + ec_transfer_params=ec_transfer_params,
774 )
775
776
vllm/entrypoints/openai/responses/serving.py
+1
@@ -932,6 +932,7 @@ class OpenAIServingResponses(GenerateBaseServing):
932 status=status,
933 usage=usage,
934 kv_transfer_params=context.kv_transfer_params,
935 + ec_transfer_params=context.ec_transfer_params,
936 )
937
938 if request.store:
vllm/entrypoints/scale_out/token_in_token_out/protocol.py
+12
@@ -132,6 +132,12 @@ class GenerateRequest(BaseModel):
132 default=None,
133 description="KVTransfer parameters used for disaggregated serving.",
134 )
135 + ec_transfer_params: dict[str, Any] | None = Field(
136 + default=None,
137 + description=(
138 + "ECTransfer parameters used for encoder-cache disaggregated serving."
139 + ),
140 + )
141
142 # Tracks which keys the caller explicitly set inside ``sampling_params``
143 # when the request was parsed from a JSON body. Lets the server tell
@@ -238,6 +244,12 @@ class GenerateResponse(BaseModel):
244 default=None,
245 description="KVTransfer parameters used for disaggregated serving.",
246 )
247 + ec_transfer_params: dict[str, Any] | None = Field(
248 + default=None,
249 + description=(
250 + "ECTransfer parameters used for encoder-cache disaggregated serving."
251 + ),
252 + )
253
254
255 ####### Derender (postprocessing) #######
vllm/entrypoints/scale_out/token_in_token_out/serving.py
+1
@@ -330,6 +330,7 @@ class ServingTokens(GenerateBaseServing):
330 usage=usage,
331 prompt_logprobs=clamp_prompt_logprobs(final_res.prompt_logprobs),
332 kv_transfer_params=final_res.kv_transfer_params,
333 + ec_transfer_params=final_res.ec_transfer_params,
334 )
335
336 # Log complete response if output logging is enabled
vllm/envs.py
+12
@@ -207,6 +207,8 @@ if TYPE_CHECKING:
207 VLLM_DISABLE_REQUEST_ID_RANDOMIZATION: bool = False
208 VLLM_NIXL_SIDE_CHANNEL_HOST: str = "localhost"
209 VLLM_NIXL_SIDE_CHANNEL_PORT: int = 5600
210 + VLLM_EC_SIDE_CHANNEL_HOST: str = "localhost"
211 + VLLM_EC_SIDE_CHANNEL_PORT: int = 5601
212 VLLM_MOONCAKE_BOOTSTRAP_PORT: int = 8998
213 VLLM_MOONCAKE_STORE_TIER_LOG: bool = False
214 VLLM_MOONCAKE_LOAD_RECV_THREADS: int = 1
@@ -1565,6 +1567,16 @@ environment_variables: dict[str, Callable[[], Any]] = {
1567 "VLLM_NIXL_SIDE_CHANNEL_PORT": lambda: int(
1568 os.getenv("VLLM_NIXL_SIDE_CHANNEL_PORT", "5600")
1569 ),
1570 + # IP address used for the EC connector's ZMQ side channel
1571 + # (producer ROUTER bind, consumer DEALER dial).
1572 + "VLLM_EC_SIDE_CHANNEL_HOST": lambda: os.getenv(
1573 + "VLLM_EC_SIDE_CHANNEL_HOST", "localhost"
1574 + ),
1575 + # Port for the EC connector's ZMQ side channel; advertised to peers
1576 + # via `ec_transfer_params.peer_port` on the producer's response.
1577 + "VLLM_EC_SIDE_CHANNEL_PORT": lambda: int(
1578 + os.getenv("VLLM_EC_SIDE_CHANNEL_PORT", "5601")
1579 + ),
1580 # Port used for Mooncake handshake between remote agents.
1581 "VLLM_MOONCAKE_BOOTSTRAP_PORT": lambda: int(
1582 os.getenv("VLLM_MOONCAKE_BOOTSTRAP_PORT", "8998")
vllm/outputs.py
+4
@@ -104,6 +104,7 @@ class RequestOutput:
104 None if decoder-only.
105 num_cached_tokens: The number of tokens with prefix cache hit.
106 kv_transfer_params: The params for remote K/V transfer.
107 + ec_transfer_params: The params for remote encoder-cache transfer.
108 """
109
110 def __init__(
@@ -121,6 +122,7 @@ class RequestOutput:
122 num_cached_tokens: int | None = None,
123 *,
124 kv_transfer_params: dict[str, Any] | None = None,
125 + ec_transfer_params: dict[str, Any] | None = None,
126 # Forward compatibility, code that uses args added in new release can
127 # still run with older versions of vLLM without breaking.
128 **kwargs: Any,
@@ -141,12 +143,14 @@ class RequestOutput:
143 self.encoder_prompt_token_ids = encoder_prompt_token_ids
144 self.num_cached_tokens = num_cached_tokens
145 self.kv_transfer_params = kv_transfer_params
146 + self.ec_transfer_params = ec_transfer_params
147
148 def add(self, next_output: "RequestOutput", aggregate: bool) -> None:
149 """Merge subsequent RequestOutput into this one"""
150
151 self.finished |= next_output.finished
152 self.kv_transfer_params = next_output.kv_transfer_params
153 + self.ec_transfer_params = next_output.ec_transfer_params
154
155 for next_completion in next_output.outputs:
156 for i, completion in enumerate(self.outputs):
vllm/v1/core/sched/interface.py
+4
@@ -9,6 +9,7 @@ from vllm.multimodal import MULTIMODAL_REGISTRY, MultiModalRegistry
9
10 if TYPE_CHECKING:
11 from vllm.config import VllmConfig
12 + from vllm.distributed.ec_transfer.ec_connector.base import ECConnectorBase
13 from vllm.distributed.kv_transfer.kv_connector.v1 import KVConnectorBase_V1
14 from vllm.v1.core.sched.output import GrammarOutput, SchedulerOutput
15 from vllm.v1.engine import EngineCoreOutputs
@@ -248,3 +249,6 @@ class SchedulerInterface(ABC):
249
250 def get_kv_connector(self) -> "KVConnectorBase_V1 | None":
251 return None
252 +
253 + def get_ec_connector(self) -> "ECConnectorBase | None":
254 + return None
vllm/v1/core/sched/scheduler.py
+20 -3
@@ -10,6 +10,7 @@ from typing import Any
10 from vllm.compilation.cuda_graph import CUDAGraphStat
11 from vllm.config import VllmConfig
12 from vllm.distributed.ec_transfer.ec_connector.base import (
13 + ECConnectorBase,
14 ECConnectorMetadata,
15 ECConnectorRole,
16 )
@@ -1675,6 +1676,7 @@ class Scheduler(SchedulerInterface):
1676 new_token_ids = generated_token_ids
1677 pooler_output = pooler_outputs[req_index] if pooler_outputs else None
1678 kv_transfer_params = None
1679 + ec_transfer_params = None
1680 status_before_stop = request.status
1681 num_output_tokens_before = len(request._output_token_ids)
1682
@@ -1761,7 +1763,7 @@ class Scheduler(SchedulerInterface):
1763 finish_reason = request.get_finished_reason()
1764 finished = self._handle_stopped_request(request)
1765 if finished:
1764 - kv_transfer_params = self._free_request(request)
1766 + kv_transfer_params, ec_transfer_params = self._free_request(request)
1767
1768 if status_before_stop == RequestStatus.RUNNING:
1769 stopped_running_reqs.add(request)
@@ -1785,6 +1787,7 @@ class Scheduler(SchedulerInterface):
1787 new_token_ids
1788 or pooler_output is not None
1789 or kv_transfer_params
1790 + or ec_transfer_params
1791 or stopped
1792 ):
1793 # Add EngineCoreOutput for this Request.
@@ -1800,6 +1803,7 @@ class Scheduler(SchedulerInterface):
1803 events=request.take_events(),
1804 prefill_stats=request.take_prefill_stats(),
1805 kv_transfer_params=kv_transfer_params,
1806 + ec_transfer_params=ec_transfer_params,
1807 trace_headers=request.trace_headers,
1808 routed_experts=routed_experts,
1809 num_nans_in_logits=request.num_nans_in_logits,
@@ -2151,11 +2155,21 @@ class Scheduler(SchedulerInterface):
2155
2156 def _free_request(
2157 self, request: Request, delay_free_blocks: bool = False
2154 - ) -> dict[str, Any] | None:
2158 + ) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
2159 assert request.is_finished()
2160
2161 self._inflight_prefills.discard(request)
2162 connector_delay_free_blocks, kv_xfer_params = self._connector_finished(request)
2163 +
2164 + # EC Connector: mirror the KV hook. The contract requires firing
2165 + # before the encoder cache is freed so the connector can inspect
2166 + # per-request state (e.g. which mm_hashes it recorded during
2167 + # save_caches()) and emit ec_transfer_params for the response body.
2168 + ec_xfer_params: dict[str, Any] | None = None
2169 + if self.ec_connector is not None:
2170 + ec_delay_free, ec_xfer_params = self.ec_connector.request_finished(request)
2171 + connector_delay_free_blocks |= ec_delay_free
2172 +
2173 self.encoder_cache_manager.free(request)
2174 request_id = request.request_id
2175 self.finished_req_ids.add(request_id)
@@ -2166,7 +2180,7 @@ class Scheduler(SchedulerInterface):
2180 if not delay_free_blocks:
2181 self._free_blocks(request)
2182
2169 - return kv_xfer_params
2183 + return kv_xfer_params, ec_xfer_params
2184
2185 def _free_blocks(self, request: Request):
2186 assert request.is_finished()
@@ -2414,6 +2428,9 @@ class Scheduler(SchedulerInterface):
2428 def get_kv_connector(self) -> KVConnectorBase_V1 | None:
2429 return self.connector
2430
2431 + def get_ec_connector(self) -> ECConnectorBase | None:
2432 + return self.ec_connector
2433 +
2434 def _connector_finished(
2435 self, request: Request
2436 ) -> tuple[bool, dict[str, Any] | None]:
vllm/v1/engine/__init__.py
+1
@@ -190,6 +190,7 @@ class EngineCoreOutput(
190 stop_reason: int | str | None = None
191 events: list[EngineCoreEvent] | None = None
192 kv_transfer_params: dict[str, Any] | None = None
193 + ec_transfer_params: dict[str, Any] | None = None
194
195 trace_headers: Mapping[str, str] | None = None
196
vllm/v1/engine/core.py
+9
@@ -400,6 +400,15 @@ class EngineCore:
400 "Disabling KVTransfer for this request."
401 )
402
403 + if (
404 + request.ec_transfer_params is not None
405 + and self.scheduler.get_ec_connector() is None
406 + ):
407 + logger.warning(
408 + "Got ec_transfer_params, but no ECConnector found. "
409 + "Disabling ECTransfer for this request."
410 + )
411 +
412 self.scheduler.add_request(request)
413 if request.abort_immediately:
414 # Immediately abort so the connector's request_finished hook runs
vllm/v1/engine/output_processor.py
+11 -1
@@ -276,6 +276,7 @@ class RequestState:
276 finish_reason: FinishReason | None,
277 stop_reason: int | str | None,
278 kv_transfer_params: dict[str, Any] | None = None,
279 + ec_transfer_params: dict[str, Any] | None = None,
280 ) -> RequestOutput | PoolingRequestOutput | None:
281 finished = finish_reason is not None
282 final_only = self.output_kind == RequestOutputKind.FINAL_ONLY
@@ -327,7 +328,11 @@ class RequestState:
328 external_req_id = self.parent_req.external_req_id
329
330 return self._new_request_output(
330 - external_req_id, outputs, finished, kv_transfer_params
331 + external_req_id,
332 + outputs,
333 + finished,
334 + kv_transfer_params,
335 + ec_transfer_params,
336 )
337
338 def _new_request_output(
@@ -336,6 +341,7 @@ class RequestState:
341 outputs: list[CompletionOutput] | list[PoolingOutput],
342 finished: bool,
343 kv_transfer_params: dict[str, Any] | None = None,
344 + ec_transfer_params: dict[str, Any] | None = None,
345 ) -> RequestOutput | PoolingRequestOutput:
346 # If prompt embeds were used, put placeholder prompt token ids
347 prompt_token_ids = self.prompt_token_ids
@@ -369,6 +375,7 @@ class RequestState:
375 outputs=cast(list[CompletionOutput], outputs),
376 finished=finished,
377 kv_transfer_params=kv_transfer_params,
378 + ec_transfer_params=ec_transfer_params,
379 num_cached_tokens=self.num_cached_tokens,
380 metrics=self.stats,
381 )
@@ -497,6 +504,7 @@ class OutputProcessor:
504 finish_reason=FinishReason.ABORT,
505 stop_reason=None,
506 kv_transfer_params=None,
507 + ec_transfer_params=None,
508 )
509 ):
510 req_state.queue.put(request_output)
@@ -620,6 +628,7 @@ class OutputProcessor:
628 finish_reason = engine_core_output.finish_reason
629 stop_reason = engine_core_output.stop_reason
630 kv_transfer_params = engine_core_output.kv_transfer_params
631 + ec_transfer_params = engine_core_output.ec_transfer_params
632 if engine_core_output.routed_experts is not None:
633 req_state.routed_experts_chunks.append(
634 engine_core_output.routed_experts
@@ -654,6 +663,7 @@ class OutputProcessor:
663 finish_reason,
664 stop_reason,
665 kv_transfer_params,
666 + ec_transfer_params,
667 ):
668 if req_state.streaming_input:
669 request_output.finished = False
vllm/v1/request.py
+5
@@ -100,6 +100,8 @@ class Request:
100
101 # P/D: Connector-specific KV transfer parameters.
102 self.kv_transfer_params: dict[str, Any] | None = None
103 + # E/P/D: Connector-specific encoder-cache transfer parameters.
104 + self.ec_transfer_params: dict[str, Any] | None = None
105
106 if pooling_params is not None:
107 # Pooling models.
@@ -115,6 +117,9 @@ class Request:
117 self.kv_transfer_params = sampling_params.extra_args.get(
118 "kv_transfer_params"
119 )
120 + self.ec_transfer_params = sampling_params.extra_args.get(
121 + "ec_transfer_params"
122 + )
123 self.kv_cache_report_mode = sampling_params.extra_args.get(
124 "kv_cache_report_mode", "incremental"
125 )