Skip to main content

pictor_runtime/
server.rs

1//! OpenAI-compatible chat completions server.
2//!
3//! Provides an Axum-based HTTP server with the following endpoints:
4//!
5//! | Method | Path | Description |
6//! |--------|------|-------------|
7//! | POST | `/v1/chat/completions` | Chat completion (streaming and non-streaming) |
8//! | GET | `/v1/models` | List available models |
9//! | GET | `/health` | Liveness probe |
10//! | GET | `/metrics` | Prometheus text exposition |
11//!
12//! Use [`create_router`] or [`create_router_with_metrics`] to build
13//! the Axum router, then serve it with `axum::serve`.
14
15use 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
33/// Header name used for end-to-end request correlation. Request handlers
34/// echo whatever the client supplied in the response, or generate a fresh
35/// UUIDv4-style id when the header is absent.
36pub const REQUEST_ID_HEADER: &str = "x-request-id";
37
38/// Resolve a [`RequestId`] from an incoming request header, falling back to
39/// a freshly generated id when none is supplied or when the supplied value
40/// is malformed (in either case we still want a usable id to thread through
41/// tracing spans and the response).
42///
43/// Accepts both the 32-hex form (no dashes) and the 36-char UUID form
44/// (`8-4-4-4-12`).
45pub 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
56/// Build response headers for a [`RequestId`]. Returns a `HeaderMap` with the
57/// `X-Request-ID` set to the canonical 36-char UUID form.
58pub 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
66/// Server state.
67///
68/// Holds a *pool* of inference-engine replicas behind a semaphore rather than a
69/// single mutex, so up to `pool.size()` requests can generate concurrently. The
70/// default path (a 1-element pool) is byte-identical to the previous
71/// single-mutex design.
72pub struct AppState {
73    engines: Arc<EnginePool>,
74    tokenizer: Option<TokenizerBridge>,
75    metrics: Arc<InferenceMetrics>,
76}
77
78impl AppState {
79    /// Acquire an exclusive lease on one engine replica from the pool, waiting
80    /// asynchronously if every replica is currently busy.
81    ///
82    /// The returned [`EngineLease`] derefs to the engine (so callers invoke the
83    /// usual `generate*` methods) and returns it to the pool on drop.
84    pub async fn acquire_engine(&self) -> Result<EngineLease, PoolError> {
85        self.engines.acquire().await
86    }
87
88    /// Access the underlying engine pool.
89    pub fn engines(&self) -> &Arc<EnginePool> {
90        &self.engines
91    }
92
93    /// Access the optional tokenizer.
94    pub fn tokenizer(&self) -> Option<&TokenizerBridge> {
95        self.tokenizer.as_ref()
96    }
97
98    /// Access the shared metrics instance.
99    pub fn metrics(&self) -> &Arc<InferenceMetrics> {
100        &self.metrics
101    }
102}
103
104/// Chat message (OpenAI-compatible).
105///
106/// `content` is `Option<String>` so that it can be `null` when `tool_calls`
107/// is set (the model produced a tool call instead of a text reply).
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct ChatMessage {
110    /// Role of the message sender: `"system"`, `"user"`, `"assistant"`, `"tool"`.
111    pub role: String,
112    /// Text content of the message.  `null` when the assistant returns tool calls.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub content: Option<String>,
115    /// Tool calls produced by the model (assistant role only).
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub tool_calls: Option<Vec<crate::api_types::ToolCallResult>>,
118    /// ID of the tool call being responded to (tool role only).
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub tool_call_id: Option<String>,
121}
122
123impl ChatMessage {
124    /// Construct a plain text assistant or user message.
125    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/// Chat completion request.
136#[derive(Debug, Deserialize)]
137pub struct ChatCompletionRequest {
138    /// Conversation history.
139    pub messages: Vec<ChatMessage>,
140    /// Maximum tokens to generate.
141    #[serde(default = "default_max_tokens")]
142    pub max_tokens: usize,
143    /// Sampling temperature.
144    #[serde(default = "default_temperature")]
145    pub temperature: f32,
146    /// Whether to stream the response as SSE.
147    #[serde(default)]
148    pub stream: bool,
149    /// Tools available to the model.
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub tools: Option<Vec<crate::api_types::ToolDefinition>>,
152    /// Tool choice: `"auto"`, `"none"`, or a specific function selector.
153    #[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/// Chat completion response.
165#[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/// Token usage info.
174#[derive(Debug, Serialize)]
175pub struct Usage {
176    pub prompt_tokens: usize,
177    pub completion_tokens: usize,
178    pub total_tokens: usize,
179}
180
181/// A choice in the completion response.
182#[derive(Debug, Serialize)]
183pub struct ChatChoice {
184    pub index: usize,
185    pub message: ChatMessage,
186    pub finish_reason: String,
187}
188
189/// SSE streaming chunk (OpenAI-compatible).
190#[derive(Serialize)]
191struct ChatCompletionChunk {
192    id: String,
193    object: String,
194    created: u64,
195    model: String,
196    choices: Vec<ChunkChoice>,
197}
198
199/// A choice in the SSE streaming chunk.
200#[derive(Serialize)]
201struct ChunkChoice {
202    index: usize,
203    delta: ChunkDelta,
204    finish_reason: Option<String>,
205}
206
207/// Delta content in a streaming chunk.
208#[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
216/// Create the Axum router.
217///
218/// Wraps the single `engine` in a 1-element [`EnginePool`], preserving
219/// byte-identical single-request behavior. Use
220/// [`create_router_with_pool`] to serve from a multi-replica pool.
221pub 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
228/// Create the Axum router with a shared metrics instance.
229///
230/// Wraps the single `engine` in a 1-element [`EnginePool`] and delegates to
231/// [`create_router_with_pool`].
232pub 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
240/// Create the Axum router from a pre-built [`EnginePool`].
241///
242/// This is the shared core behind [`create_router`] and
243/// [`create_router_with_metrics`]; it lets server entry points serve from a
244/// multi-replica pool so independent requests generate concurrently instead of
245/// serializing on a single engine mutex.
246pub 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    // The embeddings router carries its own Arc<EmbeddingAppState>; merge it
258    // before attaching the main AppState so the states don't conflict.
259    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
285/// Prometheus metrics endpoint.
286async 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    // Build prompt from messages
320    let prompt_text = build_prompt(&body.messages);
321
322    // Tokenize
323    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        // Fallback: single start token
331        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        // ── SSE streaming mode ──
341        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        // ── Non-streaming mode ──
351        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
372/// Non-streaming chat completion handler.
373async 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    // Honor the request's temperature while keeping every other sampling knob
383    // (top-k / top-p / repetition penalty) at the engine's startup defaults, so
384    // a request that omits `temperature` is bit-identical to the previous
385    // behavior. The engine's PRNG state is preserved across the swap.
386    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, &params)
397        .map_err(|e| {
398            tracing::error!(error = %e, "generation failed");
399            StatusCode::INTERNAL_SERVER_ERROR
400        })?;
401    // Return the engine to the pool as soon as generation is done, before the
402    // (potentially slow) decode/serialization below.
403    drop(lease);
404
405    let completion_len = output_tokens.len();
406
407    // Record token metrics
408    state
409        .metrics
410        .tokens_generated_total
411        .inc_by(completion_len as u64);
412
413    // Decode
414    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
445/// SSE streaming chat completion handler.
446async 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    // Honor the request's temperature while keeping the other sampling knobs at
462    // the engine's startup defaults (see the non-streaming handler), so omitting
463    // `temperature` is bit-identical to the previous streaming behavior.
464    let params = crate::sampling::SamplingParams {
465        temperature,
466        ..crate::sampling::SamplingParams::default()
467    };
468
469    // Acquire an engine lease in async context, then move it into the blocking
470    // generation task. The lease's Drop (a synchronous std-mutex push) runs at
471    // the closure's end — no async in Drop, so this is safe off the runtime.
472    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, &params, &token_tx);
479        // lease (and thus token_tx) is dropped here: the engine returns to the
480        // pool and the channel closes.
481    });
482
483    // Build SSE stream from the token receiver
484    let id_for_stream = completion_id;
485    let state_for_stream = Arc::clone(&state);
486
487    // First, send a role delta
488    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    // Convert token receiver into a stream of SSE events
511    let token_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(token_rx);
512
513    // Per-request streaming-decode state.  BPE tokens may straddle UTF-8
514    // codepoint boundaries (CJK, emoji), so we buffer through HF's
515    // step_decode_stream and only emit a chunk when a complete UTF-8 piece is
516    // ready.  Mid-codepoint tokens yield `Ok(None)` and are filtered out.
517    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    // Build finish chunk
551    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    // Prepend role event, append finish event and [DONE]
568    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
580/// Build a simple prompt from chat messages.
581///
582/// Messages with `content = None` (e.g. tool-call turns) are skipped.
583fn 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    // Signal model to respond as assistant
613    prompt.push_str("<|im_start|>assistant\n");
614    prompt
615}
616
617/// Generate a short random-ish ID for completion responses.
618fn 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
626// ─── Graceful shutdown ─────────────────────────────────────────────────
627
628/// Start server with graceful shutdown support.
629///
630/// Binds to `addr`, serves `router`, and shuts down cleanly when
631/// `shutdown_signal` completes. In-flight requests are given time
632/// to finish before the server exits.
633pub 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
649/// Create a shutdown signal that responds to SIGTERM and SIGINT (Ctrl+C).
650///
651/// Completes when either signal is received, allowing the server to
652/// begin its graceful shutdown procedure.
653pub 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
681/// Create the full server setup: router + graceful shutdown future.
682///
683/// Returns a future that runs the server until a shutdown signal is received.
684pub 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// ─── Request queue depth tracking ──────────────────────────────────────
695
696/// Server configuration with request management.
697#[derive(Debug, Clone)]
698pub struct ServerConfig {
699    /// Maximum number of queued requests before rejecting new ones.
700    pub max_queue_depth: usize,
701    /// Request timeout in seconds.
702    pub request_timeout_seconds: u64,
703    /// Address to bind to.
704    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
717/// Request queue depth tracker.
718///
719/// Thread-safe counter for tracking how many requests are currently
720/// queued or in-flight. Used to implement backpressure.
721pub struct QueueDepthTracker {
722    current: std::sync::atomic::AtomicUsize,
723    max_depth: usize,
724}
725
726impl QueueDepthTracker {
727    /// Create a new tracker with the given maximum depth.
728    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    /// Try to acquire a slot. Returns `true` if successful, `false` if queue is full.
736    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        // CAS loop for correctness under contention
742        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    /// Release a slot.
753    pub fn release(&self) {
754        self.current
755            .fetch_sub(1, std::sync::atomic::Ordering::Release);
756    }
757
758    /// Current queue depth.
759    pub fn depth(&self) -> usize {
760        self.current.load(std::sync::atomic::Ordering::Relaxed)
761    }
762
763    /// Maximum allowed depth.
764    pub fn max_depth(&self) -> usize {
765        self.max_depth
766    }
767
768    /// Whether the queue has capacity for more requests.
769    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        // Metrics should be accessible from outside
871        assert_eq!(metrics.requests_total.get(), 0);
872    }
873
874    // ── ServerConfig tests ──
875
876    #[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    // ── QueueDepthTracker tests ──
888
889    #[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        // Should fail when full
905        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}