1use crate::core::{
5 self, ProxyHealthcheckResponse, ProxyHttpError, ProxyMeta, forward_response, join_path,
6 outbound_authorization,
7};
8use crate::error::ProxyError;
9use axum::Json;
10use axum::body::Body;
11use axum::extract::State;
12use axum::http::{HeaderMap, Response, StatusCode, header};
13use axum::routing::{get, post};
14use axum::{Router, serve};
15use futures_util::future::join_all;
16use serde_json::{Map, Value};
17use std::sync::Arc;
18use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
19use std::time::{Duration, SystemTime, UNIX_EPOCH};
20use tokio::net::TcpListener;
21
22pub const ID: &str = "inferlab-trtllm-proxy";
23pub const VERSION: u32 = 1;
24
25const MIN_REQUEST_ID: u64 = 1_u64 << 42;
26const CONTEXT_FIRST_SCHEDULE_STYLE: u64 = 0;
27const TERMINAL_SSE: &[u8] = b"data: [DONE]\n\n";
28
29pub fn meta() -> ProxyMeta {
30 ProxyMeta {
31 id: ID,
32 version: VERSION,
33 }
34}
35
36#[derive(Clone, Debug)]
37pub struct Config {
38 pub host: String,
39 pub port: u16,
40 pub prefill: Vec<String>,
41 pub decode: Vec<String>,
42}
43
44pub fn run(config: Config) -> Result<(), ProxyError> {
45 core::run(|| run_async(config))
46}
47
48pub async fn run_async(config: Config) -> Result<(), ProxyError> {
49 let host = config.host.clone();
50 let port = config.port;
51 let state = ProxyState::new(config)?;
52 tokio::spawn(await_backends(state.clone()));
53 let listener = TcpListener::bind((host.as_str(), port))
54 .await
55 .map_err(|error| ProxyError::Io {
56 message: format!("failed to bind TensorRT-LLM proxy on {host}:{port}: {error}"),
57 })?;
58 serve(listener, router(state))
59 .await
60 .map_err(|error| ProxyError::Io {
61 message: format!("TensorRT-LLM proxy server failed: {error}"),
62 })
63}
64
65fn router(state: ProxyState) -> Router {
66 Router::new()
67 .route("/healthcheck", get(healthcheck))
68 .route("/v1/completions", post(completions))
69 .with_state(state)
70}
71
72#[derive(Clone)]
73struct ProxyState {
74 inner: Arc<ProxyStateInner>,
75}
76
77struct ProxyStateInner {
78 client: reqwest::Client,
79 prefill: Vec<String>,
80 decode: Vec<String>,
81 ready: AtomicBool,
82 prefill_cursor: AtomicUsize,
83 decode_cursor: AtomicUsize,
84 request_counter: AtomicU64,
85}
86
87impl ProxyState {
88 fn new(config: Config) -> Result<Self, ProxyError> {
89 if config.prefill.is_empty() {
90 return Err(ProxyError::Invalid {
91 message: "TensorRT-LLM proxy requires at least one prefill endpoint".to_owned(),
92 });
93 }
94 if config.decode.is_empty() {
95 return Err(ProxyError::Invalid {
96 message: "TensorRT-LLM proxy requires at least one decode endpoint".to_owned(),
97 });
98 }
99 let client = core::build_pooled_client().map_err(|error| ProxyError::Io {
100 message: format!("failed to create TensorRT-LLM proxy HTTP client: {error}"),
101 })?;
102 Ok(Self {
103 inner: Arc::new(ProxyStateInner {
104 client,
105 prefill: config.prefill,
106 decode: config.decode,
107 ready: AtomicBool::new(false),
108 prefill_cursor: AtomicUsize::new(0),
109 decode_cursor: AtomicUsize::new(0),
110 request_counter: AtomicU64::new(request_id_seed()),
111 }),
112 })
113 }
114
115 fn client(&self) -> reqwest::Client {
116 self.inner.client.clone()
117 }
118
119 fn ready(&self) -> bool {
120 self.inner.ready.load(Ordering::SeqCst)
121 }
122
123 fn set_ready(&self) {
124 self.inner.ready.store(true, Ordering::SeqCst);
125 }
126
127 fn next_prefill(&self) -> String {
128 let index = core::round_robin_index(&self.inner.prefill_cursor, self.inner.prefill.len());
129 self.inner.prefill[index].clone()
130 }
131
132 fn next_decode(&self) -> String {
133 let index = core::round_robin_index(&self.inner.decode_cursor, self.inner.decode.len());
134 self.inner.decode[index].clone()
135 }
136
137 fn next_request_id(&self) -> u64 {
138 self.inner.request_counter.fetch_add(1, Ordering::SeqCst)
139 }
140}
141
142fn request_id_seed() -> u64 {
143 const SEED_CEILING: u64 = 1_u64 << 61;
144 let nanos = SystemTime::now()
145 .duration_since(UNIX_EPOCH)
146 .map_or(0, |elapsed| elapsed.as_nanos() as u64);
147 let entropy = nanos ^ (u64::from(std::process::id()) << 32);
148 MIN_REQUEST_ID + entropy % (SEED_CEILING - MIN_REQUEST_ID)
149}
150
151async fn await_backends(state: ProxyState) {
152 let urls = state
153 .inner
154 .prefill
155 .iter()
156 .chain(&state.inner.decode)
157 .cloned();
158 join_all(urls.map(|url| await_backend(state.client(), url))).await;
159 state.set_ready();
160}
161
162async fn await_backend(client: reqwest::Client, url: String) {
163 loop {
164 if client
165 .get(join_path(&url, "/health"))
166 .send()
167 .await
168 .is_ok_and(|response| response.status().is_success())
169 {
170 return;
171 }
172 tokio::time::sleep(Duration::from_secs(1)).await;
173 }
174}
175
176async fn healthcheck(
177 State(state): State<ProxyState>,
178) -> (StatusCode, Json<ProxyHealthcheckResponse>) {
179 let ready = state.ready();
180 let status = if ready {
181 StatusCode::OK
182 } else {
183 StatusCode::SERVICE_UNAVAILABLE
184 };
185 (
186 status,
187 Json(ProxyHealthcheckResponse {
188 ready,
189 prefill_instances: state.inner.prefill.len(),
190 decode_instances: state.inner.decode.len(),
191 }),
192 )
193}
194
195async fn completions(
196 State(state): State<ProxyState>,
197 headers: HeaderMap,
198 Json(body): Json<Value>,
199) -> Result<Response<Body>, ProxyHttpError> {
200 completion_route(state, headers, body).await
201}
202
203async fn completion_route(
204 state: ProxyState,
205 headers: HeaderMap,
206 body: Value,
207) -> Result<Response<Body>, ProxyHttpError> {
208 let stream = validate_public_request(&body)?;
209 if !state.ready() {
210 return Err(ProxyHttpError::status(
211 StatusCode::SERVICE_UNAVAILABLE,
212 "proxy is not ready",
213 ));
214 }
215
216 let prefill = state.next_prefill();
217 let decode = state.next_decode();
218 let request_id = state.next_request_id();
219 let request_id_header = request_id.to_string();
220 let authorization = outbound_authorization(&headers);
221 let context_body = context_body(&body, request_id)?;
222 let context = send_context_request(
223 state.client(),
224 prefill,
225 context_body,
226 &request_id_header,
227 authorization.as_deref(),
228 )
229 .await?;
230
231 match context_outcome(context, request_id)? {
232 ContextOutcome::Complete(context) => complete_context_response(context, stream),
233 ContextOutcome::Handoff(handoff) => {
234 let generation_body = generation_body(&body, handoff)?;
235 let response = core::send_json_post(
236 state.client(),
237 join_path(&decode, "/v1/completions"),
238 &generation_body,
239 Some(&request_id_header),
240 authorization.as_deref(),
241 &[],
242 "decode request",
243 )
244 .await?;
245 if stream {
246 core::stream_response(response)
247 } else {
248 forward_response(response).await
249 }
250 }
251 }
252}
253
254fn validate_public_request(body: &Value) -> Result<bool, ProxyHttpError> {
255 let object = body.as_object().ok_or_else(|| {
256 ProxyHttpError::status(
257 StatusCode::BAD_REQUEST,
258 "OpenAI completion request body must be a JSON object",
259 )
260 })?;
261 match object.get("prompt") {
262 Some(Value::String(_)) => {}
263 Some(Value::Array(_)) => {
264 return Err(ProxyHttpError::status(
265 StatusCode::BAD_REQUEST,
266 "TensorRT-LLM built-in proxy does not support prompt arrays",
267 ));
268 }
269 _ => {
270 return Err(ProxyHttpError::status(
271 StatusCode::BAD_REQUEST,
272 "TensorRT-LLM built-in proxy requires a scalar string prompt",
273 ));
274 }
275 }
276 if object
277 .get("n")
278 .is_some_and(|count| count.as_u64() != Some(1))
279 {
280 return Err(ProxyHttpError::status(
281 StatusCode::BAD_REQUEST,
282 "TensorRT-LLM built-in proxy supports only n=1",
283 ));
284 }
285 Ok(object
286 .get("stream")
287 .and_then(Value::as_bool)
288 .unwrap_or(false))
289}
290
291fn context_body(body: &Value, request_id: u64) -> Result<Value, ProxyHttpError> {
292 let mut body = body.clone();
293 let object = body.as_object_mut().ok_or_else(|| {
294 ProxyHttpError::status(
295 StatusCode::BAD_REQUEST,
296 "OpenAI completion request body must be a JSON object",
297 )
298 })?;
299 object.insert("stream".to_owned(), Value::Bool(false));
300 object.remove("stream_options");
301 object.insert(
302 "disaggregated_params".to_owned(),
303 Value::Object(Map::from_iter([
304 (
305 "request_type".to_owned(),
306 Value::String("context_only".to_owned()),
307 ),
308 ("disagg_request_id".to_owned(), Value::from(request_id)),
309 (
310 "schedule_style".to_owned(),
311 Value::from(CONTEXT_FIRST_SCHEDULE_STYLE),
312 ),
313 ])),
314 );
315 Ok(body)
316}
317
318struct ContextResponse {
319 status: StatusCode,
320 content_type: Option<String>,
321 body: Value,
322}
323
324async fn send_context_request(
325 client: reqwest::Client,
326 prefill: String,
327 body: Value,
328 request_id: &str,
329 authorization: Option<&str>,
330) -> Result<ContextResponse, ProxyHttpError> {
331 let response = core::send_json_post(
332 client,
333 join_path(&prefill, "/v1/completions"),
334 &body,
335 Some(request_id),
336 authorization,
337 &[],
338 "context request",
339 )
340 .await?;
341 let status = core::status_code(response.status())?;
342 let content_type = response
343 .headers()
344 .get(reqwest::header::CONTENT_TYPE)
345 .and_then(|value| value.to_str().ok())
346 .map(str::to_owned);
347 let bytes = response
348 .bytes()
349 .await
350 .map_err(|error| ProxyHttpError::upstream("context response body read failed", error))?;
351 let body = serde_json::from_slice(&bytes).map_err(|error| {
352 ProxyHttpError::status(
353 StatusCode::BAD_GATEWAY,
354 format!("context response was not valid JSON: {error}"),
355 )
356 })?;
357 Ok(ContextResponse {
358 status,
359 content_type,
360 body,
361 })
362}
363
364enum ContextOutcome {
365 Complete(ContextResponse),
366 Handoff(Handoff),
367}
368
369struct Handoff {
370 prompt_token_ids: Value,
371 usage: Value,
372 disaggregated_params: Map<String, Value>,
373}
374
375fn context_outcome(
376 mut response: ContextResponse,
377 request_id: u64,
378) -> Result<ContextOutcome, ProxyHttpError> {
379 let first = response
380 .body
381 .get("choices")
382 .and_then(Value::as_array)
383 .and_then(|choices| choices.first())
384 .and_then(Value::as_object)
385 .ok_or_else(|| {
386 ProxyHttpError::status(
387 StatusCode::BAD_GATEWAY,
388 "context response did not include a first choice",
389 )
390 })?;
391 let needs_generation = first
392 .get("finish_reason")
393 .and_then(Value::as_str)
394 .is_some_and(|reason| matches!(reason, "length" | "not_finished"));
395 if !needs_generation {
396 sanitize_context_response(&mut response.body);
397 return Ok(ContextOutcome::Complete(response));
398 }
399
400 let prompt_token_ids = response
401 .body
402 .get("prompt_token_ids")
403 .filter(|tokens| is_scalar_token_array(tokens))
404 .cloned()
405 .ok_or_else(|| handoff_error("prompt_token_ids must be a scalar token array"))?;
406 let usage = response
407 .body
408 .get("usage")
409 .filter(|usage| usage.is_object())
410 .cloned()
411 .ok_or_else(|| handoff_error("usage is missing"))?;
412 let params = first
413 .get("disaggregated_params")
414 .and_then(Value::as_object)
415 .cloned()
416 .ok_or_else(|| handoff_error("disaggregated_params is missing"))?;
417 if params.get("ctx_request_id").is_none_or(Value::is_null) {
418 return Err(handoff_error("ctx_request_id is null"));
419 }
420 if params.get("disagg_request_id").and_then(Value::as_u64) != Some(request_id) {
421 return Err(handoff_error(
422 "disagg_request_id does not match the assigned request",
423 ));
424 }
425 if params.get("first_gen_tokens").is_none_or(Value::is_null) {
426 return Err(handoff_error("first_gen_tokens is missing"));
427 }
428 Ok(ContextOutcome::Handoff(Handoff {
429 prompt_token_ids,
430 usage,
431 disaggregated_params: params,
432 }))
433}
434
435fn is_scalar_token_array(value: &Value) -> bool {
436 value.as_array().is_some_and(|tokens| {
437 tokens.iter().all(|token| {
438 token
439 .as_number()
440 .is_some_and(|number| number.is_i64() || number.is_u64())
441 })
442 })
443}
444
445fn handoff_error(detail: &str) -> ProxyHttpError {
446 ProxyHttpError::status(
447 StatusCode::BAD_GATEWAY,
448 format!("invalid TensorRT-LLM context handoff: {detail}"),
449 )
450}
451
452fn sanitize_context_response(body: &mut Value) {
453 if let Some(choices) = body.get_mut("choices").and_then(Value::as_array_mut) {
454 for choice in choices {
455 if let Some(choice) = choice.as_object_mut() {
456 choice.remove("disaggregated_params");
457 }
458 }
459 }
460}
461
462fn complete_context_response(
463 context: ContextResponse,
464 stream: bool,
465) -> Result<Response<Body>, ProxyHttpError> {
466 if stream {
467 return Response::builder()
468 .status(context.status)
469 .header(header::CONTENT_TYPE, "text/event-stream")
470 .body(Body::from(TERMINAL_SSE))
471 .map_err(|error| {
472 ProxyHttpError::internal(format!(
473 "failed to build terminal context response: {error}"
474 ))
475 });
476 }
477 let body = serde_json::to_vec(&context.body).map_err(|error| {
478 ProxyHttpError::internal(format!("failed to serialize context response: {error}"))
479 })?;
480 let mut builder = Response::builder().status(context.status);
481 if let Some(content_type) = context.content_type {
482 builder = builder.header(header::CONTENT_TYPE, content_type);
483 }
484 builder.body(Body::from(body)).map_err(|error| {
485 ProxyHttpError::internal(format!("failed to build context response: {error}"))
486 })
487}
488
489fn generation_body(body: &Value, handoff: Handoff) -> Result<Value, ProxyHttpError> {
490 let mut body = body.clone();
491 let object = body.as_object_mut().ok_or_else(|| {
492 ProxyHttpError::status(
493 StatusCode::BAD_REQUEST,
494 "OpenAI completion request body must be a JSON object",
495 )
496 })?;
497 let mut params = handoff.disaggregated_params;
498 params.insert(
499 "request_type".to_owned(),
500 Value::String("generation_only".to_owned()),
501 );
502 params.insert(
503 "schedule_style".to_owned(),
504 Value::from(CONTEXT_FIRST_SCHEDULE_STYLE),
505 );
506 params.insert("ctx_usage".to_owned(), handoff.usage);
507 object.insert("prompt".to_owned(), handoff.prompt_token_ids);
508 object.insert("disaggregated_params".to_owned(), Value::Object(params));
509 Ok(body)
510}
511
512#[cfg(test)]
513mod tests {
514 use super::*;
515 use anyhow::{Context, Result, bail};
516 use async_stream::stream;
517 use axum::body::{Body, to_bytes};
518 use axum::http::{HeaderValue, header};
519 use axum::response::IntoResponse;
520 use axum::routing::{get, post};
521 use bytes::Bytes;
522 use futures_util::StreamExt;
523 use serde_json::json;
524 use std::sync::atomic::AtomicUsize;
525 use tokio::sync::{Mutex, Notify};
526 use tokio::task::JoinHandle;
527
528 #[test]
529 fn meta_exports_proxy_identity() {
530 assert_eq!(ID, "inferlab-trtllm-proxy");
531 assert_eq!(VERSION, 1);
532 assert_eq!(meta().id, ID);
533 assert_eq!(meta().version, VERSION);
534 }
535
536 #[test]
537 fn context_request_is_non_streaming_context_first_with_large_integer_id() -> Result<()> {
538 let state = proxy_state(
539 vec!["http://prefill".to_owned()],
540 vec!["http://decode".to_owned()],
541 )?;
542 let first = state.next_request_id();
543 let second = state.next_request_id();
544 assert!(first >= MIN_REQUEST_ID);
545 assert_eq!(second, first + 1);
546
547 let lowered = context_body(
548 &json!({
549 "model": "m",
550 "prompt": "hello",
551 "stream": true,
552 "stream_options": {"include_usage": true},
553 "opaque": "preserved"
554 }),
555 first,
556 )
557 .map_err(|error| anyhow::anyhow!(error.to_string()))?;
558 assert_eq!(lowered["stream"], Value::Bool(false));
559 assert!(lowered.get("stream_options").is_none());
560 assert_eq!(lowered["opaque"], Value::String("preserved".to_owned()));
561 assert_eq!(
562 lowered["disaggregated_params"]["request_type"],
563 "context_only"
564 );
565 assert_eq!(
566 lowered["disaggregated_params"]["schedule_style"],
567 Value::from(0)
568 );
569 assert_eq!(lowered["disaggregated_params"]["disagg_request_id"], first);
570 Ok(())
571 }
572
573 #[test]
574 fn handoff_preserves_opaque_params_and_replaces_only_owned_fields() -> Result<()> {
575 let request_id = MIN_REQUEST_ID + 7;
576 let context = context_response(json!({
577 "choices": [{
578 "finish_reason": "not_finished",
579 "disaggregated_params": {
580 "request_type": "context_only",
581 "schedule_style": 1,
582 "ctx_usage": {"stale": true},
583 "ctx_request_id": 91,
584 "disagg_request_id": request_id,
585 "first_gen_tokens": [8],
586 "opaque_future_field": {"endpoint": "nixl://ctx"}
587 }
588 }],
589 "prompt_token_ids": [10, 11, 12],
590 "usage": {"prompt_tokens": 3, "completion_tokens": 1}
591 }));
592 let handoff = match context_outcome(context, request_id)
593 .map_err(|error| anyhow::anyhow!(error.to_string()))?
594 {
595 ContextOutcome::Handoff(handoff) => handoff,
596 ContextOutcome::Complete(_) => bail!("not_finished must require generation"),
597 };
598 let generated = generation_body(
599 &json!({
600 "model": "m",
601 "prompt": "hello",
602 "stream": true,
603 "temperature": 0.25,
604 "opaque_request_field": [1, 2]
605 }),
606 handoff,
607 )
608 .map_err(|error| anyhow::anyhow!(error.to_string()))?;
609
610 assert_eq!(generated["prompt"], json!([10, 11, 12]));
611 assert_eq!(generated["stream"], Value::Bool(true));
612 assert_eq!(generated["temperature"], json!(0.25));
613 assert_eq!(generated["opaque_request_field"], json!([1, 2]));
614 let params = &generated["disaggregated_params"];
615 assert_eq!(params["request_type"], "generation_only");
616 assert_eq!(params["schedule_style"], CONTEXT_FIRST_SCHEDULE_STYLE);
617 assert_eq!(
618 params["ctx_usage"],
619 json!({"prompt_tokens": 3, "completion_tokens": 1})
620 );
621 assert_eq!(params["ctx_request_id"], 91);
622 assert_eq!(params["disagg_request_id"], request_id);
623 assert_eq!(params["first_gen_tokens"], json!([8]));
624 assert_eq!(
625 params["opaque_future_field"],
626 json!({"endpoint": "nixl://ctx"})
627 );
628 Ok(())
629 }
630
631 #[test]
632 fn malformed_required_handoff_metadata_is_rejected() -> Result<()> {
633 let request_id = MIN_REQUEST_ID + 9;
634 let cases = [
635 (
636 "missing choices",
637 json!({"choices": [], "prompt_token_ids": [1], "usage": {}}),
638 ),
639 (
640 "nested prompt tokens",
641 handoff_response(
642 request_id,
643 json!([[1, 2]]),
644 json!({}),
645 valid_params(request_id),
646 ),
647 ),
648 (
649 "missing usage",
650 handoff_response(
651 request_id,
652 json!([1, 2]),
653 Value::Null,
654 valid_params(request_id),
655 ),
656 ),
657 (
658 "missing disaggregated params",
659 handoff_response(request_id, json!([1, 2]), json!({}), Value::Null),
660 ),
661 (
662 "null context id",
663 handoff_response(
664 request_id,
665 json!([1, 2]),
666 json!({}),
667 json!({
668 "ctx_request_id": null,
669 "disagg_request_id": request_id,
670 "first_gen_tokens": [3]
671 }),
672 ),
673 ),
674 (
675 "mismatched request id",
676 handoff_response(
677 request_id,
678 json!([1, 2]),
679 json!({}),
680 json!({
681 "ctx_request_id": 1,
682 "disagg_request_id": request_id + 1,
683 "first_gen_tokens": [3]
684 }),
685 ),
686 ),
687 (
688 "missing first token",
689 handoff_response(
690 request_id,
691 json!([1, 2]),
692 json!({}),
693 json!({"ctx_request_id": 1, "disagg_request_id": request_id}),
694 ),
695 ),
696 ];
697 for (label, body) in cases {
698 let result = context_outcome(context_response(body), request_id);
699 assert!(result.is_err(), "{label} was accepted");
700 }
701 Ok(())
702 }
703
704 #[test]
705 fn prefill_and_decode_round_robin_are_independent() -> Result<()> {
706 let state = proxy_state(
707 vec!["p0".to_owned(), "p1".to_owned()],
708 vec!["d0".to_owned(), "d1".to_owned(), "d2".to_owned()],
709 )?;
710 assert_eq!(state.next_prefill(), "p0");
711 assert_eq!(state.next_decode(), "d0");
712 assert_eq!(state.next_decode(), "d1");
713 assert_eq!(state.next_prefill(), "p1");
714 assert_eq!(state.next_decode(), "d2");
715 assert_eq!(state.next_prefill(), "p0");
716 Ok(())
717 }
718
719 #[tokio::test]
720 async fn invalid_public_shapes_are_rejected_before_dispatch() -> Result<()> {
721 let context_backend = ContextBackend::default();
722 let decode_backend = DecodeBackend::default();
723 let (prefill, prefill_server) = spawn_context_backend(context_backend.clone()).await?;
724 let (decode, decode_server) = spawn_decode_backend(decode_backend.clone()).await?;
725 let state = proxy_state(vec![prefill], vec![decode])?;
726 state.set_ready();
727
728 for request in [
729 json!({"model": "m", "prompt": ["hello"]}),
730 json!({"model": "m", "prompt": "hello", "n": 2}),
731 ] {
732 let error = match completion_route(state.clone(), HeaderMap::new(), request).await {
733 Ok(_) => bail!("invalid public request was dispatched"),
734 Err(error) => error,
735 };
736 assert_eq!(error.into_response().status(), StatusCode::BAD_REQUEST);
737 }
738 assert!(context_backend.requests.lock().await.is_empty());
739 assert!(decode_backend.requests.lock().await.is_empty());
740 prefill_server.abort();
741 decode_server.abort();
742 Ok(())
743 }
744
745 #[tokio::test]
746 async fn context_completion_skips_decode_and_returns_public_shape() -> Result<()> {
747 let context_backend = ContextBackend::default();
748 let decode_backend = DecodeBackend::default();
749 let (prefill, prefill_server) = spawn_context_backend(context_backend).await?;
750 let (decode, decode_server) = spawn_decode_backend(decode_backend.clone()).await?;
751 let state = proxy_state(vec![prefill], vec![decode])?;
752 state.set_ready();
753
754 let response = completion_route(
755 state.clone(),
756 HeaderMap::new(),
757 json!({"model": "m", "prompt": "hello", "mode": "complete"}),
758 )
759 .await
760 .map_err(|error| anyhow::anyhow!(error.to_string()))?;
761 assert_eq!(response.status(), StatusCode::CREATED);
762 let returned: Value =
763 serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await?)?;
764 assert_eq!(returned["opaque"], "kept");
765 assert!(returned["choices"][0].get("disaggregated_params").is_none());
766
767 let response = completion_route(
768 state,
769 HeaderMap::new(),
770 json!({
771 "model": "m",
772 "prompt": "hello",
773 "mode": "complete",
774 "stream": true
775 }),
776 )
777 .await
778 .map_err(|error| anyhow::anyhow!(error.to_string()))?;
779 assert_eq!(
780 response.headers().get(header::CONTENT_TYPE),
781 Some(&HeaderValue::from_static("text/event-stream"))
782 );
783 assert_eq!(
784 to_bytes(response.into_body(), usize::MAX).await?,
785 TERMINAL_SSE
786 );
787 assert!(decode_backend.requests.lock().await.is_empty());
788 prefill_server.abort();
789 decode_server.abort();
790 Ok(())
791 }
792
793 #[tokio::test]
794 async fn generation_handoff_reuses_id_auth_and_forwards_both_response_modes() -> Result<()> {
795 let context_backend = ContextBackend::default();
796 let decode_backend = DecodeBackend::default();
797 let stream_gate = decode_backend.stream_gate.clone();
798 let (prefill, prefill_server) = spawn_context_backend(context_backend.clone()).await?;
799 let (decode, decode_server) = spawn_decode_backend(decode_backend.clone()).await?;
800 let state = proxy_state(vec![prefill], vec![decode])?;
801 state.set_ready();
802 let mut headers = HeaderMap::new();
803 headers.insert(header::AUTHORIZATION, "Bearer inbound".parse()?);
804
805 let response = completion_route(
806 state.clone(),
807 headers.clone(),
808 json!({"model": "m", "prompt": "hello", "mode": "generate"}),
809 )
810 .await
811 .map_err(|error| anyhow::anyhow!(error.to_string()))?;
812 assert_eq!(response.status(), StatusCode::CREATED);
813 assert_eq!(
814 response.headers().get(header::CONTENT_TYPE),
815 Some(&HeaderValue::from_static("application/x-inferlab-test"))
816 );
817 assert_eq!(
818 to_bytes(response.into_body(), usize::MAX).await?,
819 Bytes::from_static(b"decode-complete")
820 );
821
822 let response = completion_route(
823 state,
824 headers,
825 json!({
826 "model": "m",
827 "prompt": "hello",
828 "mode": "generate",
829 "stream": true
830 }),
831 )
832 .await
833 .map_err(|error| anyhow::anyhow!(error.to_string()))?;
834 assert_eq!(response.status(), StatusCode::ACCEPTED);
835 assert_eq!(
836 response.headers().get(header::CONTENT_TYPE),
837 Some(&HeaderValue::from_static("text/event-stream"))
838 );
839 let mut stream = response.into_body().into_data_stream();
840 let first = tokio::time::timeout(Duration::from_secs(1), stream.next())
841 .await?
842 .context("decode stream ended before the first event")??;
843 assert_eq!(first, Bytes::from_static(b"data: first\n\n"));
844 stream_gate.notify_one();
845 let second = stream
846 .next()
847 .await
848 .context("decode stream ended before the terminal event")??;
849 assert_eq!(second, Bytes::from_static(TERMINAL_SSE));
850
851 let context_requests = context_backend.requests.lock().await;
852 let decode_requests = decode_backend.requests.lock().await;
853 assert_eq!(context_requests.len(), 2);
854 assert_eq!(decode_requests.len(), 2);
855 for (context, decode) in context_requests.iter().zip(decode_requests.iter()) {
856 let assigned = context.body["disaggregated_params"]["disagg_request_id"]
857 .as_u64()
858 .context("context request lacked an integer disagg_request_id")?;
859 assert!(assigned >= MIN_REQUEST_ID);
860 assert_eq!(
861 decode.body["disaggregated_params"]["disagg_request_id"],
862 assigned
863 );
864 assert_eq!(decode.body["prompt"], json!([10, 11, 12]));
865 assert_eq!(
866 context.headers.get(header::AUTHORIZATION),
867 Some(&HeaderValue::from_static("Bearer inbound"))
868 );
869 assert_eq!(
870 decode.headers.get(header::AUTHORIZATION),
871 Some(&HeaderValue::from_static("Bearer inbound"))
872 );
873 let assigned_header = assigned.to_string();
874 let context_header = context
875 .headers
876 .get("x-request-id")
877 .and_then(|value| value.to_str().ok());
878 let decode_header = decode
879 .headers
880 .get("x-request-id")
881 .and_then(|value| value.to_str().ok());
882 assert_eq!(context_header, Some(assigned_header.as_str()));
883 assert_eq!(decode_header, context_header);
884 }
885 drop(context_requests);
886 drop(decode_requests);
887 prefill_server.abort();
888 decode_server.abort();
889 Ok(())
890 }
891
892 #[tokio::test]
893 async fn upstream_failures_remain_failures_before_and_after_headers() -> Result<()> {
894 let context_backend = ContextBackend::default();
895 let decode_backend = DecodeBackend::default();
896 let stream_gate = decode_backend.stream_gate.clone();
897 let (prefill, prefill_server) = spawn_context_backend(context_backend).await?;
898 let (decode, decode_server) = spawn_decode_backend(decode_backend).await?;
899 let state = proxy_state(vec![prefill], vec![decode])?;
900 state.set_ready();
901
902 for mode in ["context-fail", "decode-fail"] {
903 let result = completion_route(
904 state.clone(),
905 HeaderMap::new(),
906 json!({"model": "m", "prompt": "hello", "mode": mode}),
907 )
908 .await;
909 let error = match result {
910 Ok(_) => bail!("{mode} returned a successful public response"),
911 Err(error) => error,
912 };
913 assert_eq!(error.into_response().status(), StatusCode::BAD_GATEWAY);
914 }
915
916 let response = completion_route(
917 state,
918 HeaderMap::new(),
919 json!({
920 "model": "m",
921 "prompt": "hello",
922 "mode": "stream-error",
923 "stream": true
924 }),
925 )
926 .await
927 .map_err(|error| anyhow::anyhow!(error.to_string()))?;
928 assert_eq!(response.status(), StatusCode::ACCEPTED);
929 let mut stream = response.into_body().into_data_stream();
930 assert!(matches!(stream.next().await, Some(Ok(_))));
931 stream_gate.notify_one();
932 let result = stream
933 .next()
934 .await
935 .context("decode stream ended cleanly after an upstream body failure")?;
936 let error = match result {
937 Ok(_) => bail!("decode body failure was returned as successful bytes"),
938 Err(error) => error,
939 };
940 assert!(error.to_string().contains("decode stream failed"));
941 prefill_server.abort();
942 decode_server.abort();
943 Ok(())
944 }
945
946 #[tokio::test]
947 async fn healthcheck_waits_for_every_configured_worker() -> Result<()> {
948 let context_backend = ContextBackend::default();
949 let decode_backend = DecodeBackend::default();
950 let (prefill, prefill_server) = spawn_context_backend(context_backend.clone()).await?;
951 let (decode, decode_server) = spawn_decode_backend(decode_backend.clone()).await?;
952 let state = proxy_state(vec![prefill], vec![decode])?;
953 let (status, Json(body)) = healthcheck(State(state.clone())).await;
954 assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
955 assert!(!body.ready);
956
957 tokio::time::timeout(
958 Duration::from_secs(1),
959 tokio::spawn(await_backends(state.clone())),
960 )
961 .await
962 .context("worker-aware health did not observe both backends")??;
963 let (status, Json(body)) = healthcheck(State(state)).await;
964 assert_eq!(status, StatusCode::OK);
965 assert!(body.ready);
966 assert_eq!(context_backend.health_requests.load(Ordering::SeqCst), 1);
967 assert_eq!(decode_backend.health_requests.load(Ordering::SeqCst), 1);
968 prefill_server.abort();
969 decode_server.abort();
970 Ok(())
971 }
972
973 fn proxy_state(prefill: Vec<String>, decode: Vec<String>) -> Result<ProxyState> {
974 ProxyState::new(Config {
975 host: "127.0.0.1".to_owned(),
976 port: 8000,
977 prefill,
978 decode,
979 })
980 .map_err(Into::into)
981 }
982
983 fn context_response(body: Value) -> ContextResponse {
984 ContextResponse {
985 status: StatusCode::CREATED,
986 content_type: Some("application/json".to_owned()),
987 body,
988 }
989 }
990
991 fn valid_params(request_id: u64) -> Value {
992 json!({
993 "ctx_request_id": 1,
994 "disagg_request_id": request_id,
995 "first_gen_tokens": [3]
996 })
997 }
998
999 fn handoff_response(
1000 request_id: u64,
1001 prompt_token_ids: Value,
1002 usage: Value,
1003 params: Value,
1004 ) -> Value {
1005 json!({
1006 "choices": [{
1007 "finish_reason": "length",
1008 "disaggregated_params": params
1009 }],
1010 "prompt_token_ids": prompt_token_ids,
1011 "usage": usage,
1012 "assigned_for_fixture": request_id
1013 })
1014 }
1015
1016 #[derive(Clone)]
1017 struct ObservedRequest {
1018 headers: HeaderMap,
1019 body: Value,
1020 }
1021
1022 #[derive(Clone, Default)]
1023 struct ContextBackend {
1024 requests: Arc<Mutex<Vec<ObservedRequest>>>,
1025 health_requests: Arc<AtomicUsize>,
1026 }
1027
1028 #[derive(Clone)]
1029 struct DecodeBackend {
1030 requests: Arc<Mutex<Vec<ObservedRequest>>>,
1031 health_requests: Arc<AtomicUsize>,
1032 stream_gate: Arc<Notify>,
1033 }
1034
1035 impl Default for DecodeBackend {
1036 fn default() -> Self {
1037 Self {
1038 requests: Arc::new(Mutex::new(Vec::new())),
1039 health_requests: Arc::new(AtomicUsize::new(0)),
1040 stream_gate: Arc::new(Notify::new()),
1041 }
1042 }
1043 }
1044
1045 async fn spawn_context_backend(state: ContextBackend) -> Result<(String, JoinHandle<()>)> {
1046 let app = Router::new()
1047 .route("/health", get(context_health))
1048 .route("/v1/completions", post(context_completion))
1049 .with_state(state);
1050 spawn_router(app).await
1051 }
1052
1053 async fn spawn_decode_backend(state: DecodeBackend) -> Result<(String, JoinHandle<()>)> {
1054 let app = Router::new()
1055 .route("/health", get(decode_health))
1056 .route("/v1/completions", post(decode_completion))
1057 .with_state(state);
1058 spawn_router(app).await
1059 }
1060
1061 async fn spawn_router(app: Router) -> Result<(String, JoinHandle<()>)> {
1062 let listener = TcpListener::bind("127.0.0.1:0").await?;
1063 let address = listener.local_addr()?;
1064 let server = tokio::spawn(async move {
1065 let _ = serve(listener, app).await;
1066 });
1067 Ok((format!("http://{address}"), server))
1068 }
1069
1070 async fn context_health(State(state): State<ContextBackend>) -> StatusCode {
1071 state.health_requests.fetch_add(1, Ordering::SeqCst);
1072 StatusCode::OK
1073 }
1074
1075 async fn decode_health(State(state): State<DecodeBackend>) -> StatusCode {
1076 state.health_requests.fetch_add(1, Ordering::SeqCst);
1077 StatusCode::OK
1078 }
1079
1080 async fn context_completion(
1081 State(state): State<ContextBackend>,
1082 headers: HeaderMap,
1083 Json(body): Json<Value>,
1084 ) -> Response<Body> {
1085 state.requests.lock().await.push(ObservedRequest {
1086 headers,
1087 body: body.clone(),
1088 });
1089 if body.get("mode").and_then(Value::as_str) == Some("context-fail") {
1090 return (StatusCode::INTERNAL_SERVER_ERROR, "context failed").into_response();
1091 }
1092 let request_id = body["disaggregated_params"]["disagg_request_id"].clone();
1093 let finish_reason = if body.get("mode").and_then(Value::as_str) == Some("complete") {
1094 "stop"
1095 } else {
1096 "length"
1097 };
1098 (
1099 StatusCode::CREATED,
1100 Json(json!({
1101 "id": "cmpl-context",
1102 "choices": [{
1103 "finish_reason": finish_reason,
1104 "text": "answer",
1105 "disaggregated_params": {
1106 "request_type": "context_only",
1107 "ctx_request_id": 91,
1108 "disagg_request_id": request_id,
1109 "first_gen_tokens": [8],
1110 "opaque_future_field": {"endpoint": "nixl://ctx"}
1111 }
1112 }],
1113 "prompt_token_ids": [10, 11, 12],
1114 "usage": {"prompt_tokens": 3, "completion_tokens": 1},
1115 "opaque": "kept"
1116 })),
1117 )
1118 .into_response()
1119 }
1120
1121 async fn decode_completion(
1122 State(state): State<DecodeBackend>,
1123 headers: HeaderMap,
1124 Json(body): Json<Value>,
1125 ) -> Response<Body> {
1126 state.requests.lock().await.push(ObservedRequest {
1127 headers,
1128 body: body.clone(),
1129 });
1130 let mode = body.get("mode").and_then(Value::as_str);
1131 if mode == Some("decode-fail") {
1132 return (StatusCode::INTERNAL_SERVER_ERROR, "decode failed").into_response();
1133 }
1134 if body.get("stream").and_then(Value::as_bool) == Some(true) {
1135 let gate = state.stream_gate.clone();
1136 let fail = mode == Some("stream-error");
1137 let body = Body::from_stream(stream! {
1138 yield Ok::<Bytes, std::io::Error>(Bytes::from_static(b"data: first\n\n"));
1139 gate.notified().await;
1140 if fail {
1141 yield Err(std::io::Error::other("decode body failed"));
1142 } else {
1143 yield Ok(Bytes::from_static(TERMINAL_SSE));
1144 }
1145 });
1146 return (
1147 StatusCode::ACCEPTED,
1148 [(header::CONTENT_TYPE, "text/event-stream")],
1149 body,
1150 )
1151 .into_response();
1152 }
1153 (
1154 StatusCode::CREATED,
1155 [(header::CONTENT_TYPE, "application/x-inferlab-test")],
1156 "decode-complete",
1157 )
1158 .into_response()
1159 }
1160}