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