1use axum::extract::State;
16use axum::http::{HeaderMap, HeaderValue, StatusCode};
17use axum::response::{
18 sse::{Event, Sse},
19 IntoResponse, Json, Response,
20};
21use axum::Router;
22use serde::{Deserialize, Serialize};
23use std::convert::Infallible;
24use std::sync::Arc;
25use tokio_stream::StreamExt;
26
27use crate::engine::InferenceEngine;
28use crate::engine_pool::{EngineLease, EnginePool, PoolError};
29use crate::metrics::InferenceMetrics;
30use crate::request_id::RequestId;
31use crate::tokenizer_bridge::TokenizerBridge;
32
33pub const REQUEST_ID_HEADER: &str = "x-request-id";
37
38pub fn resolve_request_id(headers: &HeaderMap) -> RequestId {
46 if let Some(v) = headers.get(REQUEST_ID_HEADER) {
47 if let Ok(s) = v.to_str() {
48 if let Some(id) = RequestId::from_uuid(s).or_else(|| RequestId::from_hex(s)) {
49 return id;
50 }
51 }
52 }
53 RequestId::new()
54}
55
56pub fn request_id_header_map(id: RequestId) -> HeaderMap {
59 let mut headers = HeaderMap::new();
60 if let Ok(value) = HeaderValue::from_str(&id.as_uuid()) {
61 headers.insert(REQUEST_ID_HEADER, value);
62 }
63 headers
64}
65
66pub struct AppState {
73 engines: Arc<EnginePool>,
74 tokenizer: Option<TokenizerBridge>,
75 metrics: Arc<InferenceMetrics>,
76}
77
78impl AppState {
79 pub async fn acquire_engine(&self) -> Result<EngineLease, PoolError> {
85 self.engines.acquire().await
86 }
87
88 pub fn engines(&self) -> &Arc<EnginePool> {
90 &self.engines
91 }
92
93 pub fn tokenizer(&self) -> Option<&TokenizerBridge> {
95 self.tokenizer.as_ref()
96 }
97
98 pub fn metrics(&self) -> &Arc<InferenceMetrics> {
100 &self.metrics
101 }
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct ChatMessage {
110 pub role: String,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub content: Option<String>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub tool_calls: Option<Vec<crate::api_types::ToolCallResult>>,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub tool_call_id: Option<String>,
121}
122
123impl ChatMessage {
124 pub fn text(role: impl Into<String>, content: impl Into<String>) -> Self {
126 Self {
127 role: role.into(),
128 content: Some(content.into()),
129 tool_calls: None,
130 tool_call_id: None,
131 }
132 }
133}
134
135#[derive(Debug, Deserialize)]
137pub struct ChatCompletionRequest {
138 pub messages: Vec<ChatMessage>,
140 #[serde(default = "default_max_tokens")]
142 pub max_tokens: usize,
143 #[serde(default = "default_temperature")]
145 pub temperature: f32,
146 #[serde(default)]
148 pub stream: bool,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub tools: Option<Vec<crate::api_types::ToolDefinition>>,
152 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub tool_choice: Option<serde_json::Value>,
155}
156
157fn default_max_tokens() -> usize {
158 256
159}
160fn default_temperature() -> f32 {
161 0.7
162}
163
164#[derive(Debug, Serialize)]
166pub struct ChatCompletionResponse {
167 pub id: String,
168 pub object: String,
169 pub choices: Vec<ChatChoice>,
170 pub usage: Usage,
171}
172
173#[derive(Debug, Serialize)]
175pub struct Usage {
176 pub prompt_tokens: usize,
177 pub completion_tokens: usize,
178 pub total_tokens: usize,
179}
180
181#[derive(Debug, Serialize)]
183pub struct ChatChoice {
184 pub index: usize,
185 pub message: ChatMessage,
186 pub finish_reason: String,
187}
188
189#[derive(Serialize)]
191struct ChatCompletionChunk {
192 id: String,
193 object: String,
194 created: u64,
195 model: String,
196 choices: Vec<ChunkChoice>,
197}
198
199#[derive(Serialize)]
201struct ChunkChoice {
202 index: usize,
203 delta: ChunkDelta,
204 finish_reason: Option<String>,
205}
206
207#[derive(Serialize)]
209struct ChunkDelta {
210 #[serde(skip_serializing_if = "Option::is_none")]
211 role: Option<String>,
212 #[serde(skip_serializing_if = "Option::is_none")]
213 content: Option<String>,
214}
215
216pub fn create_router(
222 engine: InferenceEngine<'static>,
223 tokenizer: Option<TokenizerBridge>,
224) -> Router {
225 create_router_with_metrics(engine, tokenizer, Arc::new(InferenceMetrics::new()))
226}
227
228pub fn create_router_with_metrics(
233 engine: InferenceEngine<'static>,
234 tokenizer: Option<TokenizerBridge>,
235 metrics: Arc<InferenceMetrics>,
236) -> Router {
237 create_router_with_pool(EnginePool::new(vec![engine]), tokenizer, metrics)
238}
239
240pub fn create_router_with_pool(
247 engines: Arc<EnginePool>,
248 tokenizer: Option<TokenizerBridge>,
249 metrics: Arc<InferenceMetrics>,
250) -> Router {
251 let state = Arc::new(AppState {
252 engines,
253 tokenizer,
254 metrics,
255 });
256
257 let embeddings_router = crate::embeddings::create_embeddings_router(512);
260
261 Router::new()
262 .route(
263 "/v1/chat/completions",
264 axum::routing::post(chat_completions),
265 )
266 .route(
267 "/v1/chat/completions/extended",
268 axum::routing::post(crate::api_extensions::extended_chat_completions),
269 )
270 .route(
271 "/v1/completions",
272 axum::routing::post(crate::completions::create_completion),
273 )
274 .route("/v1/models", axum::routing::get(list_models))
275 .route("/health", axum::routing::get(health))
276 .route("/metrics", axum::routing::get(prometheus_metrics))
277 .with_state(state)
278 .merge(embeddings_router)
279}
280
281async fn health() -> &'static str {
282 "ok"
283}
284
285async fn prometheus_metrics(State(state): State<Arc<AppState>>) -> impl IntoResponse {
287 let body = state.metrics.render_prometheus();
288 (
289 StatusCode::OK,
290 [("content-type", "text/plain; version=0.0.4; charset=utf-8")],
291 body,
292 )
293}
294
295async fn list_models() -> Json<serde_json::Value> {
296 Json(serde_json::json!({
297 "object": "list",
298 "data": [{
299 "id": "bonsai-8b",
300 "object": "model",
301 "owned_by": "pictor"
302 }]
303 }))
304}
305
306#[tracing::instrument(skip(state, headers, body), fields(request_id))]
307async fn chat_completions(
308 State(state): State<Arc<AppState>>,
309 headers: HeaderMap,
310 Json(body): Json<ChatCompletionRequest>,
311) -> Result<Response, StatusCode> {
312 let request_id = resolve_request_id(&headers);
313 tracing::Span::current().record("request_id", tracing::field::display(&request_id));
314
315 let request_start = std::time::Instant::now();
316 state.metrics.requests_total.inc();
317 state.metrics.active_requests.inc();
318
319 let prompt_text = build_prompt(&body.messages);
321
322 let prompt_tokens = if let Some(tok) = &state.tokenizer {
324 tok.encode(&prompt_text).map_err(|_| {
325 state.metrics.errors_total.inc();
326 state.metrics.active_requests.dec();
327 StatusCode::INTERNAL_SERVER_ERROR
328 })?
329 } else {
330 vec![151644]
332 };
333
334 state
335 .metrics
336 .prompt_tokens_total
337 .inc_by(prompt_tokens.len() as u64);
338
339 let result = if body.stream {
340 chat_completions_stream(
342 Arc::clone(&state),
343 prompt_tokens,
344 body.max_tokens,
345 body.temperature,
346 request_id,
347 )
348 .await
349 } else {
350 chat_completions_non_stream(
352 Arc::clone(&state),
353 prompt_tokens,
354 body.max_tokens,
355 body.temperature,
356 request_id,
357 )
358 .await
359 };
360
361 let elapsed = request_start.elapsed().as_secs_f64();
362 state.metrics.request_duration_seconds.observe(elapsed);
363 state.metrics.active_requests.dec();
364
365 if result.is_err() {
366 state.metrics.errors_total.inc();
367 }
368
369 result
370}
371
372async fn chat_completions_non_stream(
374 state: Arc<AppState>,
375 prompt_tokens: Vec<u32>,
376 max_tokens: usize,
377 temperature: f32,
378 request_id: RequestId,
379) -> Result<Response, StatusCode> {
380 let prompt_len = prompt_tokens.len();
381
382 let params = crate::sampling::SamplingParams {
387 temperature,
388 ..crate::sampling::SamplingParams::default()
389 };
390
391 let mut lease = state.acquire_engine().await.map_err(|e| {
392 tracing::error!(error = %e, "engine pool acquire failed");
393 StatusCode::SERVICE_UNAVAILABLE
394 })?;
395 let output_tokens = lease
396 .generate_with_params(&prompt_tokens, max_tokens, ¶ms)
397 .map_err(|e| {
398 tracing::error!(error = %e, "generation failed");
399 StatusCode::INTERNAL_SERVER_ERROR
400 })?;
401 drop(lease);
404
405 let completion_len = output_tokens.len();
406
407 state
409 .metrics
410 .tokens_generated_total
411 .inc_by(completion_len as u64);
412
413 let content = if let Some(tok) = &state.tokenizer {
415 tok.decode(&output_tokens)
416 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
417 } else {
418 format!("{output_tokens:?}")
419 };
420
421 let response = ChatCompletionResponse {
422 id: format!("chatcmpl-{}", rand_id()),
423 object: "chat.completion".to_string(),
424 choices: vec![ChatChoice {
425 index: 0,
426 message: ChatMessage {
427 role: "assistant".to_string(),
428 content: Some(content),
429 tool_calls: None,
430 tool_call_id: None,
431 },
432 finish_reason: "stop".to_string(),
433 }],
434 usage: Usage {
435 prompt_tokens: prompt_len,
436 completion_tokens: completion_len,
437 total_tokens: prompt_len + completion_len,
438 },
439 };
440
441 let headers = request_id_header_map(request_id);
442 Ok((headers, Json(response)).into_response())
443}
444
445async fn chat_completions_stream(
447 state: Arc<AppState>,
448 prompt_tokens: Vec<u32>,
449 max_tokens: usize,
450 temperature: f32,
451 request_id: RequestId,
452) -> Result<Response, StatusCode> {
453 let completion_id = format!("chatcmpl-{}", rand_id());
454 let created = std::time::SystemTime::now()
455 .duration_since(std::time::UNIX_EPOCH)
456 .unwrap_or_default()
457 .as_secs();
458
459 let (token_tx, token_rx) = tokio::sync::mpsc::unbounded_channel::<u32>();
460
461 let params = crate::sampling::SamplingParams {
465 temperature,
466 ..crate::sampling::SamplingParams::default()
467 };
468
469 let mut lease = state.acquire_engine().await.map_err(|e| {
473 tracing::error!(error = %e, "engine pool acquire failed");
474 StatusCode::SERVICE_UNAVAILABLE
475 })?;
476 tokio::task::spawn_blocking(move || {
477 let _result =
478 lease.generate_streaming_with_params(&prompt_tokens, max_tokens, ¶ms, &token_tx);
479 });
482
483 let id_for_stream = completion_id;
485 let state_for_stream = Arc::clone(&state);
486
487 let role_chunk = ChatCompletionChunk {
489 id: id_for_stream.clone(),
490 object: "chat.completion.chunk".to_string(),
491 created,
492 model: "bonsai-8b".to_string(),
493 choices: vec![ChunkChoice {
494 index: 0,
495 delta: ChunkDelta {
496 role: Some("assistant".to_string()),
497 content: None,
498 },
499 finish_reason: None,
500 }],
501 };
502
503 let role_event = match serde_json::to_string(&role_chunk) {
504 Ok(json) => json,
505 Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
506 };
507
508 let id_clone = id_for_stream.clone();
509
510 let token_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(token_rx);
512
513 let mut stream_state = state_for_stream
518 .tokenizer
519 .as_ref()
520 .map(|t| t.new_decode_stream(true));
521
522 let content_stream = token_stream.filter_map(move |token_id| {
523 let text = match (&state_for_stream.tokenizer, stream_state.as_mut()) {
524 (Some(tok), Some(state)) => match tok.step_decode(state, token_id) {
525 Ok(Some(txt)) => txt,
526 Ok(None) => return None,
527 Err(_) => format!("[{token_id}]"),
528 },
529 _ => format!("[{token_id}]"),
530 };
531
532 let chunk = ChatCompletionChunk {
533 id: id_clone.clone(),
534 object: "chat.completion.chunk".to_string(),
535 created,
536 model: "bonsai-8b".to_string(),
537 choices: vec![ChunkChoice {
538 index: 0,
539 delta: ChunkDelta {
540 role: None,
541 content: Some(text),
542 },
543 finish_reason: None,
544 }],
545 };
546
547 Some(serde_json::to_string(&chunk).unwrap_or_default())
548 });
549
550 let finish_chunk = ChatCompletionChunk {
552 id: id_for_stream,
553 object: "chat.completion.chunk".to_string(),
554 created,
555 model: "bonsai-8b".to_string(),
556 choices: vec![ChunkChoice {
557 index: 0,
558 delta: ChunkDelta {
559 role: None,
560 content: None,
561 },
562 finish_reason: Some("stop".to_string()),
563 }],
564 };
565 let finish_json = serde_json::to_string(&finish_chunk).unwrap_or_default();
566
567 let role_stream = tokio_stream::once(role_event);
569
570 let full_stream = role_stream
571 .chain(content_stream)
572 .chain(tokio_stream::once(finish_json))
573 .map(|json_str| -> Result<Event, Infallible> { Ok(Event::default().data(json_str)) })
574 .chain(tokio_stream::once(Ok(Event::default().data("[DONE]"))));
575
576 let headers = request_id_header_map(request_id);
577 Ok((headers, Sse::new(full_stream)).into_response())
578}
579
580fn build_prompt(messages: &[ChatMessage]) -> String {
584 let mut prompt = String::new();
585 for msg in messages {
586 let text = match msg.content.as_deref() {
587 Some(t) => t,
588 None => continue,
589 };
590 match msg.role.as_str() {
591 "system" => {
592 prompt.push_str("<|im_start|>system\n");
593 prompt.push_str(text);
594 prompt.push_str("<|im_end|>\n");
595 }
596 "user" => {
597 prompt.push_str("<|im_start|>user\n");
598 prompt.push_str(text);
599 prompt.push_str("<|im_end|>\n");
600 }
601 "assistant" => {
602 prompt.push_str("<|im_start|>assistant\n");
603 prompt.push_str(text);
604 prompt.push_str("<|im_end|>\n");
605 }
606 _ => {
607 prompt.push_str(text);
608 prompt.push('\n');
609 }
610 }
611 }
612 prompt.push_str("<|im_start|>assistant\n");
614 prompt
615}
616
617fn rand_id() -> String {
619 let ts = std::time::SystemTime::now()
620 .duration_since(std::time::UNIX_EPOCH)
621 .unwrap_or_default()
622 .as_nanos();
623 format!("{ts:x}")
624}
625
626pub async fn serve_with_shutdown(
634 router: Router,
635 addr: std::net::SocketAddr,
636 shutdown_signal: impl std::future::Future<Output = ()> + Send + 'static,
637) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
638 let listener = tokio::net::TcpListener::bind(addr).await?;
639 tracing::info!(%addr, "server listening");
640
641 axum::serve(listener, router)
642 .with_graceful_shutdown(shutdown_signal)
643 .await?;
644
645 tracing::info!("server shut down gracefully");
646 Ok(())
647}
648
649pub async fn shutdown_signal() {
654 let ctrl_c = async {
655 tokio::signal::ctrl_c()
656 .await
657 .expect("failed to install Ctrl+C handler");
658 };
659
660 #[cfg(unix)]
661 let terminate = async {
662 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
663 .expect("failed to install SIGTERM handler")
664 .recv()
665 .await;
666 };
667
668 #[cfg(not(unix))]
669 let terminate = std::future::pending::<()>();
670
671 tokio::select! {
672 () = ctrl_c => {
673 tracing::info!("received Ctrl+C, initiating shutdown");
674 }
675 () = terminate => {
676 tracing::info!("received SIGTERM, initiating shutdown");
677 }
678 }
679}
680
681pub async fn create_server(
685 engine: InferenceEngine<'static>,
686 tokenizer: Option<TokenizerBridge>,
687 addr: std::net::SocketAddr,
688) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
689 let metrics = Arc::new(InferenceMetrics::new());
690 let router = create_router_with_metrics(engine, tokenizer, metrics);
691 serve_with_shutdown(router, addr, shutdown_signal()).await
692}
693
694#[derive(Debug, Clone)]
698pub struct ServerConfig {
699 pub max_queue_depth: usize,
701 pub request_timeout_seconds: u64,
703 pub bind_addr: std::net::SocketAddr,
705}
706
707impl Default for ServerConfig {
708 fn default() -> Self {
709 Self {
710 max_queue_depth: 128,
711 request_timeout_seconds: 60,
712 bind_addr: std::net::SocketAddr::from(([127, 0, 0, 1], 8080)),
713 }
714 }
715}
716
717pub struct QueueDepthTracker {
722 current: std::sync::atomic::AtomicUsize,
723 max_depth: usize,
724}
725
726impl QueueDepthTracker {
727 pub fn new(max_depth: usize) -> Self {
729 Self {
730 current: std::sync::atomic::AtomicUsize::new(0),
731 max_depth: max_depth.max(1),
732 }
733 }
734
735 pub fn try_acquire(&self) -> bool {
737 let current = self.current.load(std::sync::atomic::Ordering::Relaxed);
738 if current >= self.max_depth {
739 return false;
740 }
741 self.current
743 .compare_exchange(
744 current,
745 current + 1,
746 std::sync::atomic::Ordering::AcqRel,
747 std::sync::atomic::Ordering::Relaxed,
748 )
749 .is_ok()
750 }
751
752 pub fn release(&self) {
754 self.current
755 .fetch_sub(1, std::sync::atomic::Ordering::Release);
756 }
757
758 pub fn depth(&self) -> usize {
760 self.current.load(std::sync::atomic::Ordering::Relaxed)
761 }
762
763 pub fn max_depth(&self) -> usize {
765 self.max_depth
766 }
767
768 pub fn has_capacity(&self) -> bool {
770 self.depth() < self.max_depth
771 }
772}
773
774#[cfg(test)]
775mod tests {
776 use super::*;
777
778 #[test]
779 fn build_prompt_simple() {
780 let msgs = vec![ChatMessage {
781 role: "user".to_string(),
782 content: Some("Hello".to_string()),
783 tool_calls: None,
784 tool_call_id: None,
785 }];
786 let p = build_prompt(&msgs);
787 assert!(p.contains("<|im_start|>user\nHello<|im_end|>"));
788 assert!(p.ends_with("<|im_start|>assistant\n"));
789 }
790
791 #[test]
792 fn build_prompt_system_and_user() {
793 let msgs = vec![
794 ChatMessage {
795 role: "system".to_string(),
796 content: Some("You are a helpful assistant.".to_string()),
797 tool_calls: None,
798 tool_call_id: None,
799 },
800 ChatMessage {
801 role: "user".to_string(),
802 content: Some("Hi".to_string()),
803 tool_calls: None,
804 tool_call_id: None,
805 },
806 ];
807 let p = build_prompt(&msgs);
808 assert!(p.contains("<|im_start|>system\nYou are a helpful assistant.<|im_end|>"));
809 assert!(p.contains("<|im_start|>user\nHi<|im_end|>"));
810 }
811
812 #[test]
813 fn build_prompt_multi_turn() {
814 let msgs = vec![
815 ChatMessage {
816 role: "user".to_string(),
817 content: Some("What is 2+2?".to_string()),
818 tool_calls: None,
819 tool_call_id: None,
820 },
821 ChatMessage {
822 role: "assistant".to_string(),
823 content: Some("4".to_string()),
824 tool_calls: None,
825 tool_call_id: None,
826 },
827 ChatMessage {
828 role: "user".to_string(),
829 content: Some("And 3+3?".to_string()),
830 tool_calls: None,
831 tool_call_id: None,
832 },
833 ];
834 let p = build_prompt(&msgs);
835 assert!(p.contains("<|im_start|>assistant\n4<|im_end|>"));
836 assert!(p.contains("And 3+3?"));
837 }
838
839 #[test]
840 fn rand_id_is_nonempty() {
841 let id = rand_id();
842 assert!(!id.is_empty());
843 }
844
845 #[test]
846 fn default_max_tokens_value() {
847 assert_eq!(default_max_tokens(), 256);
848 }
849
850 #[test]
851 fn default_temperature_value() {
852 assert!((default_temperature() - 0.7).abs() < f32::EPSILON);
853 }
854
855 #[test]
856 fn create_router_builds_without_tokenizer() {
857 let config = pictor_core::config::Qwen3Config::bonsai_8b();
858 let params = crate::sampling::SamplingParams::default();
859 let engine = InferenceEngine::new(config, params, 42);
860 let _router = create_router(engine, None);
861 }
862
863 #[test]
864 fn create_router_with_shared_metrics() {
865 let config = pictor_core::config::Qwen3Config::bonsai_8b();
866 let params = crate::sampling::SamplingParams::default();
867 let engine = InferenceEngine::new(config, params, 42);
868 let metrics = Arc::new(InferenceMetrics::new());
869 let _router = create_router_with_metrics(engine, None, Arc::clone(&metrics));
870 assert_eq!(metrics.requests_total.get(), 0);
872 }
873
874 #[test]
877 fn server_config_default() {
878 let config = ServerConfig::default();
879 assert_eq!(config.max_queue_depth, 128);
880 assert_eq!(config.request_timeout_seconds, 60);
881 assert_eq!(
882 config.bind_addr,
883 std::net::SocketAddr::from(([127, 0, 0, 1], 8080))
884 );
885 }
886
887 #[test]
890 fn queue_depth_tracker_basic() {
891 let tracker = QueueDepthTracker::new(3);
892 assert_eq!(tracker.depth(), 0);
893 assert_eq!(tracker.max_depth(), 3);
894 assert!(tracker.has_capacity());
895
896 assert!(tracker.try_acquire());
897 assert_eq!(tracker.depth(), 1);
898 assert!(tracker.try_acquire());
899 assert_eq!(tracker.depth(), 2);
900 assert!(tracker.try_acquire());
901 assert_eq!(tracker.depth(), 3);
902 assert!(!tracker.has_capacity());
903
904 assert!(!tracker.try_acquire());
906
907 tracker.release();
908 assert_eq!(tracker.depth(), 2);
909 assert!(tracker.has_capacity());
910 assert!(tracker.try_acquire());
911 }
912
913 #[test]
914 fn queue_depth_tracker_min_capacity() {
915 let tracker = QueueDepthTracker::new(0);
916 assert_eq!(tracker.max_depth(), 1);
917 assert!(tracker.try_acquire());
918 assert!(!tracker.try_acquire());
919 }
920}