Skip to main content

rlx_runtime/
router.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Multi-protocol request router (plan #32).
6//!
7//! Borrowed from MAX's `serve/router/{openai_routes, kserve_routes,
8//! sagemaker_routes, openresponses_routes}.py`. The shape: one
9//! inference engine, multiple wire protocols layered as thin
10//! per-protocol adapters. Adding a new protocol = implementing
11//! [`WireProtocol`] for its raw request type, not editing the hot
12//! path.
13//!
14//! Today's adapter is OpenAI-shaped (chat completions +
15//! embeddings) since [`crate::mock_requests`] already defines
16//! those structs. KServe / SageMaker / OpenResponses slot in by
17//! impl'ing `WireProtocol` for their respective request types.
18//!
19//! All conversion is pure-data: no I/O, no async. The actual HTTP
20//! parsing happens upstream (in the future serving crate); this
21//! module owns the translation between wire types and the
22//! internal [`RoutedRequest`].
23
24use crate::mock_requests::{ChatCompletionRequest, EmbeddingRequest, Input};
25
26/// What kind of inference the request is asking for. Drives
27/// which downstream pipeline serves it (text-gen vs embedding
28/// pool, etc.).
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum RequestKind {
31    /// Chat completion (autoregressive token generation).
32    ChatCompletion,
33    /// Embedding (one forward pass, return pooled output).
34    Embedding,
35    /// Plain text completion (legacy `/v1/completions` shape).
36    TextCompletion,
37}
38
39/// Internal canonical request shape. Every wire protocol parses
40/// into this; downstream schedulers / engines consume only this
41/// type.
42#[derive(Debug, Clone)]
43pub struct RoutedRequest {
44    pub id: u64,
45    pub kind: RequestKind,
46    /// Pre-tokenized input (one entry per text in a batched embed
47    /// request). For chat completion: the system + user history
48    /// flattened into a single token list.
49    pub inputs: Vec<Vec<u32>>,
50    pub max_tokens: u32,
51    pub temperature: f32,
52    pub stream: bool,
53    /// Optional LoRA adapter name; passed through to
54    /// [`crate::lora_scheduler::LoraRequest::adapter`].
55    pub adapter: Option<String>,
56    /// Model name from the wire request — kept for telemetry /
57    /// validation; the actual model selection happens upstream.
58    pub model: String,
59}
60
61#[derive(Debug, Clone)]
62pub enum RouteError {
63    UnknownProtocol { name: String },
64    InvalidRequest { reason: String },
65    UnsupportedFeature { feature: &'static str },
66}
67
68impl std::fmt::Display for RouteError {
69    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        match self {
71            Self::UnknownProtocol { name } => write!(f, "unknown protocol: {name}"),
72            Self::InvalidRequest { reason } => write!(f, "invalid request: {reason}"),
73            Self::UnsupportedFeature { feature } => write!(f, "unsupported feature: {feature}"),
74        }
75    }
76}
77
78impl std::error::Error for RouteError {}
79
80/// Adapter trait — implement once per wire protocol.
81///
82/// Implementations are tiny by design: they parse / validate the
83/// wire shape and produce a `RoutedRequest`. They do NOT touch
84/// the engine.
85pub trait WireProtocol {
86    type Request;
87    fn name(&self) -> &'static str;
88    fn parse(&self, req: Self::Request) -> Result<RoutedRequest, RouteError>;
89}
90
91/// OpenAI-style adapter — handles ChatCompletionRequest and
92/// EmbeddingRequest from [`crate::mock_requests`].
93pub struct OpenAIProtocol;
94
95impl WireProtocol for OpenAIProtocol {
96    type Request = OpenAIRequest;
97    fn name(&self) -> &'static str {
98        "openai"
99    }
100    fn parse(&self, req: OpenAIRequest) -> Result<RoutedRequest, RouteError> {
101        match req {
102            OpenAIRequest::Chat(c) => parse_chat(c),
103            OpenAIRequest::Embedding(e) => parse_embed(e),
104        }
105    }
106}
107
108#[derive(Debug, Clone)]
109pub enum OpenAIRequest {
110    Chat(ChatCompletionRequest),
111    Embedding(EmbeddingRequest),
112}
113
114fn parse_chat(req: ChatCompletionRequest) -> Result<RoutedRequest, RouteError> {
115    if req.messages.is_empty() {
116        return Err(RouteError::InvalidRequest {
117            reason: "messages cannot be empty".into(),
118        });
119    }
120    // Tokenization is the consumer's job — here we synthesize a
121    // single placeholder token-list per message. Real serving
122    // hooks call into a tokenizer before this point and feeds
123    // the resulting vec<u32> in directly.
124    let flat: Vec<u32> = req
125        .messages
126        .iter()
127        .flat_map(|m| pseudo_tokenize(&m.role, &m.content))
128        .collect();
129    Ok(RoutedRequest {
130        id: hash_request_id(&req.model, &flat),
131        kind: RequestKind::ChatCompletion,
132        inputs: vec![flat],
133        max_tokens: req.max_tokens.unwrap_or(256),
134        temperature: req.temperature.unwrap_or(1.0),
135        stream: req.stream.unwrap_or(false),
136        adapter: None, // OpenAI shape doesn't carry adapter; future ext.
137        model: req.model,
138    })
139}
140
141fn parse_embed(req: EmbeddingRequest) -> Result<RoutedRequest, RouteError> {
142    let inputs: Vec<Vec<u32>> = match req.input {
143        Input::Single(s) => vec![pseudo_tokenize("input", &s)],
144        Input::Batch(v) => v.iter().map(|s| pseudo_tokenize("input", s)).collect(),
145    };
146    if inputs.is_empty() {
147        return Err(RouteError::InvalidRequest {
148            reason: "embedding input cannot be empty".into(),
149        });
150    }
151    Ok(RoutedRequest {
152        id: hash_request_id(
153            &req.model,
154            inputs.first().map(|v| v.as_slice()).unwrap_or(&[]),
155        ),
156        kind: RequestKind::Embedding,
157        inputs,
158        max_tokens: 0, // not meaningful for embeddings
159        temperature: 0.0,
160        stream: false,
161        adapter: None,
162        model: req.model,
163    })
164}
165
166/// Placeholder tokenizer: maps each char to its u32 code point,
167/// prefixed by a role-header pseudo-token (1=system, 2=user, 3=...).
168/// Real consumers replace this with a real tokenizer; the routing
169/// layer doesn't depend on which tokenizer.
170fn pseudo_tokenize(role: &str, text: &str) -> Vec<u32> {
171    let role_token = match role {
172        "system" => 1u32,
173        "user" => 2,
174        "assistant" => 3,
175        _ => 4,
176    };
177    let mut tokens = Vec::with_capacity(text.len() + 1);
178    tokens.push(role_token);
179    tokens.extend(text.chars().map(|c| c as u32));
180    tokens
181}
182
183/// Stable-ish u64 from `(model, first_input_tokens)`. The router
184/// itself doesn't need uniqueness — the consumer can override
185/// `RoutedRequest::id` with a real UUID after parsing.
186fn hash_request_id(model: &str, tokens: &[u32]) -> u64 {
187    use std::hash::{Hash, Hasher};
188    let mut h = std::collections::hash_map::DefaultHasher::new();
189    model.hash(&mut h);
190    tokens.hash(&mut h);
191    h.finish()
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use crate::mock_requests::*;
198
199    #[test]
200    fn openai_chat_routes_to_chat_completion() {
201        let req = ChatCompletionRequest {
202            model: "gpt-4o-mini".into(),
203            messages: vec![ChatMessage {
204                role: "user".into(),
205                content: "Hi".into(),
206            }],
207            max_tokens: Some(64),
208            temperature: Some(0.7),
209            stream: Some(false),
210        };
211        let routed = OpenAIProtocol.parse(OpenAIRequest::Chat(req)).unwrap();
212        assert_eq!(routed.kind, RequestKind::ChatCompletion);
213        assert_eq!(routed.inputs.len(), 1);
214        assert_eq!(routed.max_tokens, 64);
215        assert!((routed.temperature - 0.7).abs() < 1e-6);
216        assert_eq!(routed.model, "gpt-4o-mini");
217    }
218
219    #[test]
220    fn openai_embedding_single_string() {
221        let req = EmbeddingRequest {
222            model: "text-embedding-3-small".into(),
223            input: Input::Single("Hello".into()),
224            encoding_format: None,
225        };
226        let routed = OpenAIProtocol.parse(OpenAIRequest::Embedding(req)).unwrap();
227        assert_eq!(routed.kind, RequestKind::Embedding);
228        assert_eq!(routed.inputs.len(), 1);
229        // role(=4 since "input" isn't a known role) + 5 chars
230        assert_eq!(routed.inputs[0].len(), 6);
231    }
232
233    #[test]
234    fn openai_embedding_batch_input() {
235        let req = EmbeddingRequest {
236            model: "text-embedding-3-small".into(),
237            input: Input::Batch(vec!["a".into(), "bb".into(), "ccc".into()]),
238            encoding_format: None,
239        };
240        let routed = OpenAIProtocol.parse(OpenAIRequest::Embedding(req)).unwrap();
241        assert_eq!(routed.inputs.len(), 3);
242        assert_eq!(routed.inputs[1].len(), 3); // role + 2 chars
243    }
244
245    #[test]
246    fn empty_chat_messages_rejected() {
247        let req = ChatCompletionRequest {
248            model: "x".into(),
249            messages: vec![],
250            max_tokens: None,
251            temperature: None,
252            stream: None,
253        };
254        let err = OpenAIProtocol.parse(OpenAIRequest::Chat(req)).unwrap_err();
255        assert!(matches!(err, RouteError::InvalidRequest { .. }));
256    }
257
258    #[test]
259    fn defaults_applied_when_optional_fields_missing() {
260        let req = ChatCompletionRequest {
261            model: "m".into(),
262            messages: vec![ChatMessage {
263                role: "user".into(),
264                content: "x".into(),
265            }],
266            max_tokens: None,
267            temperature: None,
268            stream: None,
269        };
270        let routed = OpenAIProtocol.parse(OpenAIRequest::Chat(req)).unwrap();
271        assert_eq!(routed.max_tokens, 256);
272        assert_eq!(routed.temperature, 1.0);
273        assert!(!routed.stream);
274    }
275
276    #[test]
277    fn protocol_name_introspectable() {
278        assert_eq!(OpenAIProtocol.name(), "openai");
279    }
280}