Skip to main content

inferlab_proxy/
trtllm.rs

1//! Built-in routing for TensorRT-LLM prefill/decode serving under
2//! [[RFC-0003:C-TENSORRT-LLM-PREFILL-DECODE]].
3
4use 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}