1use crate::mock_requests::{ChatCompletionRequest, EmbeddingRequest, Input};
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum RequestKind {
31 ChatCompletion,
33 Embedding,
35 TextCompletion,
37}
38
39#[derive(Debug, Clone)]
43pub struct RoutedRequest {
44 pub id: u64,
45 pub kind: RequestKind,
46 pub inputs: Vec<Vec<u32>>,
50 pub max_tokens: u32,
51 pub temperature: f32,
52 pub stream: bool,
53 pub adapter: Option<String>,
56 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
80pub trait WireProtocol {
86 type Request;
87 fn name(&self) -> &'static str;
88 fn parse(&self, req: Self::Request) -> Result<RoutedRequest, RouteError>;
89}
90
91pub 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 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, 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, temperature: 0.0,
160 stream: false,
161 adapter: None,
162 model: req.model,
163 })
164}
165
166fn 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
183fn 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 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); }
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}