1use crate::core::{
5 self, ProxyHealthcheckResponse, ProxyHttpError, ProxyMeta, forward_response, join_path,
6 outbound_authorization,
7};
8use crate::error::ProxyError;
9use axum::Json;
10use axum::body::Body;
11use axum::extract::State;
12use axum::http::{HeaderMap, Response, StatusCode, header};
13use axum::routing::{get, post};
14use axum::{Router, serve};
15use futures_util::future::join_all;
16use serde_json::{Map, Value};
17use std::sync::Arc;
18use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
19use std::time::{Duration, SystemTime, UNIX_EPOCH};
20use tokio::net::TcpListener;
21
22pub const ID: &str = "inferlab-trtllm-proxy";
23pub const VERSION: u32 = 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}