Skip to main content

ferrum_server/
axum_server.rs

1//! Axum-based HTTP server implementation for Ferrum
2//!
3//! This module provides a concrete implementation of the HttpServer trait
4//! using the Axum web framework, with OpenAI-shaped endpoint compatibility.
5
6use crate::{
7    chat_template::{
8        render_chat_prompt_with_model_template_options,
9        render_chat_prompt_with_tools_and_model_template, ChatTemplateOptions, ModelChatTemplate,
10    },
11    openai::*,
12    traits::HttpServer,
13    types::*,
14};
15use async_trait::async_trait;
16use axum::{
17    extract::{multipart::MultipartRejection, rejection::JsonRejection, State},
18    http::{HeaderMap, StatusCode as AxumStatusCode},
19    response::{sse::Event, IntoResponse, Response, Sse},
20    routing::{get, post},
21    Json, Router,
22};
23use ferrum_interfaces::engine::{EmbedEngine, LlmInferenceEngine, TranscribeEngine, TtsEngine};
24use ferrum_types::{
25    EngineMetrics, EngineStatus, FerrumConfigBuilder, FerrumError as Error, FinishReason,
26    InferenceRequest, InferenceResponse, ModelId, Priority, RequestId, ResolvedFerrumConfig,
27    RuntimeConfigSnapshot, SamplingParams, TokenUsage, DEFAULT_MAX_TOKENS_METADATA_KEY,
28};
29use std::{
30    collections::HashMap,
31    sync::{Arc, Mutex},
32};
33use tokio::sync::mpsc;
34use tokio_stream::StreamExt;
35use tower::ServiceBuilder;
36use tower_http::{cors::CorsLayer, trace::TraceLayer};
37use tracing::{debug, error, info, span, warn, Level};
38use uuid::Uuid;
39
40const DEFAULT_SAMPLING_TEMPERATURE: f32 = 0.0;
41const DEFAULT_SAMPLING_TOP_P: f32 = 1.0;
42const DEFAULT_COMPLETION_MAX_TOKENS: u32 = 512;
43const INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY: &str = "ferrum_initial_forbidden_token_texts";
44const THINK_START_TAG: &str = "<think>";
45const THINK_END_TAG: &str = "</think>";
46const DEFAULT_GUIDED_TOOL_ARGUMENT_STRING_MAX_LENGTH: u64 = 128;
47const INITIAL_STRUCTURED_CALL_FORBIDDEN_TOKEN_TEXTS: &[&str] =
48    &["<|im_end|>", "<|endoftext|>", "<|eot_id|>", "</s>"];
49const FERRUM_SESSION_HEADER: &str = "x-ferrum-session";
50
51#[derive(Debug, Clone)]
52struct CachePolicy {
53    prefix_cache_enabled: bool,
54    session_cache_mode: String,
55    session_cache_max_entries: usize,
56    session_cache_max_tokens: usize,
57}
58
59impl CachePolicy {
60    fn current() -> Self {
61        Self {
62            prefix_cache_enabled: env_bool("FERRUM_PREFIX_CACHE_PRODUCT")
63                .or_else(|| env_bool("FERRUM_PREFIX_CACHE_REQUESTED"))
64                .or_else(|| env_bool("FERRUM_PREFIX_CACHE"))
65                .unwrap_or(false),
66            session_cache_mode: std::env::var("FERRUM_SESSION_CACHE")
67                .unwrap_or_else(|_| "off".to_string())
68                .to_ascii_lowercase(),
69            session_cache_max_entries: env_usize("FERRUM_SESSION_CACHE_MAX_ENTRIES").unwrap_or(128),
70            session_cache_max_tokens: env_usize("FERRUM_SESSION_CACHE_MAX_TOKENS").unwrap_or(4096),
71        }
72    }
73
74    fn session_memory_enabled(&self) -> bool {
75        self.session_cache_mode == "memory"
76    }
77}
78
79fn env_bool(key: &str) -> Option<bool> {
80    match std::env::var(key).ok()?.to_ascii_lowercase().as_str() {
81        "1" | "true" | "yes" | "on" => Some(true),
82        "0" | "false" | "no" | "off" => Some(false),
83        _ => None,
84    }
85}
86
87fn env_usize(key: &str) -> Option<usize> {
88    std::env::var(key).ok()?.parse().ok()
89}
90
91/// Shared Prometheus recorder handle for rendering metrics.
92static PROM_HANDLE: std::sync::OnceLock<metrics_exporter_prometheus::PrometheusHandle> =
93    std::sync::OnceLock::new();
94
95/// Initialize the Prometheus metrics recorder.
96///
97/// Must be called once before any `metrics::counter!()` / `histogram!()` calls.
98/// Safe to call multiple times — subsequent calls are no-ops.
99pub fn init_prometheus_recorder() {
100    PROM_HANDLE.get_or_init(|| {
101        let builder = metrics_exporter_prometheus::PrometheusBuilder::new();
102        let handle = builder
103            .install_recorder()
104            .expect("Failed to install Prometheus recorder");
105        info!("Prometheus metrics recorder installed");
106        handle
107    });
108}
109
110/// Axum-based server implementation.
111///
112/// The server is built around [`AppState`], which holds an optional
113/// engine per modality. Handlers fault to 503 when the modality they
114/// need isn't loaded, instead of running stub error logic.
115pub struct AxumServer {
116    state: AppState,
117    config: ServerConfig,
118}
119
120impl AxumServer {
121    /// Create a server with a fully populated AppState.
122    pub fn from_state(state: AppState) -> Self {
123        Self {
124            state,
125            config: ServerConfig::default(),
126        }
127    }
128
129    /// Convenience constructor for an LLM-only server (chat / completions).
130    pub fn from_llm(engine: Arc<dyn LlmInferenceEngine + Send + Sync>) -> Self {
131        Self::from_state(AppState::default().with_llm(engine))
132    }
133
134    /// Convenience constructor for an embedding-only server (`/v1/embeddings`).
135    pub fn from_embed(engine: Arc<dyn EmbedEngine + Send + Sync>) -> Self {
136        Self::from_state(AppState::default().with_embed(engine))
137    }
138
139    /// Convenience constructor for a transcription-only server
140    /// (`/v1/audio/transcriptions`).
141    pub fn from_transcribe(engine: Arc<dyn TranscribeEngine + Send + Sync>) -> Self {
142        Self::from_state(AppState::default().with_transcribe(engine))
143    }
144
145    /// Convenience constructor for a TTS-only server (`/v1/audio/speech`).
146    pub fn from_tts(engine: Arc<dyn TtsEngine + Send + Sync>) -> Self {
147        Self::from_state(AppState::default().with_tts(engine))
148    }
149
150    /// Attach the startup auto-configuration decision trace exposed by
151    /// `/health`. Constructors keep this optional so tests and non-LLM
152    /// deployments can use the server without a model-specific resolver.
153    pub fn with_auto_config(mut self, auto_config: ResolvedFerrumConfig) -> Self {
154        self.state = self.state.with_auto_config(auto_config);
155        self
156    }
157
158    /// Attach the loaded model's prompt template, if available. This keeps
159    /// OpenAI request aliases from driving prompt-family selection.
160    pub fn with_prompt_template(mut self, prompt_template: Option<ModelChatTemplate>) -> Self {
161        self.state = self.state.with_prompt_template(prompt_template);
162        self
163    }
164
165    /// Attach startup-loaded LoRA adapter model ids.
166    pub fn with_lora_adapters(
167        mut self,
168        base_model_id: impl Into<String>,
169        adapters: Vec<LoraAdapterModel>,
170    ) -> Self {
171        self.state = self.state.with_lora_adapters(base_model_id, adapters);
172        self
173    }
174
175    /// Build the router with all routes
176    fn build_router(&self) -> Router {
177        let app_state = self.state.clone();
178
179        Router::new()
180            // OpenAI API routes
181            .route("/v1/chat/completions", post(chat_completions_handler))
182            .route("/v1/completions", post(completions_handler))
183            .route("/v1/embeddings", post(embeddings_handler))
184            .route("/v1/audio/transcriptions", post(transcriptions_handler))
185            .route("/v1/audio/speech", post(speech_handler))
186            .route("/v1/models", get(models_handler))
187            // Health & observability
188            .route("/health", get(health_handler))
189            .route("/metrics", get(metrics_handler))
190            .route("/", get(root_handler))
191            // Apply middleware
192            .layer(
193                ServiceBuilder::new()
194                    .layer(TraceLayer::new_for_http())
195                    .layer(CorsLayer::permissive()), // For MVP, allow all origins
196            )
197            .with_state(app_state)
198    }
199}
200
201/// Application state shared across handlers — one optional engine per
202/// modality. Handlers reach into the field they need and 503 when it's
203/// not loaded.
204#[derive(Clone, Default)]
205pub struct AppState {
206    pub llm: Option<Arc<dyn LlmInferenceEngine + Send + Sync>>,
207    pub embed: Option<Arc<dyn EmbedEngine + Send + Sync>>,
208    pub transcribe: Option<Arc<dyn TranscribeEngine + Send + Sync>>,
209    pub tts: Option<Arc<dyn TtsEngine + Send + Sync>>,
210    pub auto_config: Option<ResolvedFerrumConfig>,
211    pub prompt_template: Option<Arc<ModelChatTemplate>>,
212    pub lora_registry: Arc<LoraModelRegistry>,
213    cache: Arc<CacheRuntimeState>,
214}
215
216#[derive(Clone, Debug, PartialEq, Eq)]
217pub struct LoraAdapterModel {
218    pub name: String,
219    pub model_id: String,
220    pub path: String,
221}
222
223impl LoraAdapterModel {
224    pub fn new(
225        name: impl Into<String>,
226        model_id: impl Into<String>,
227        path: impl Into<String>,
228    ) -> Self {
229        Self {
230            name: name.into(),
231            model_id: model_id.into(),
232            path: path.into(),
233        }
234    }
235}
236
237#[derive(Clone, Debug, Default)]
238pub struct LoraModelRegistry {
239    base_model_id: Option<String>,
240    adapters: Vec<LoraAdapterModel>,
241}
242
243#[derive(Clone, Debug)]
244struct LoraModelResolution {
245    base_model_id: String,
246    adapter: Option<LoraAdapterModel>,
247}
248
249impl LoraModelRegistry {
250    pub fn new(base_model_id: impl Into<String>, adapters: Vec<LoraAdapterModel>) -> Self {
251        Self {
252            base_model_id: Some(base_model_id.into()),
253            adapters,
254        }
255    }
256
257    pub fn is_enabled(&self) -> bool {
258        !self.adapters.is_empty()
259    }
260
261    fn adapter_models(&self) -> &[LoraAdapterModel] {
262        &self.adapters
263    }
264
265    fn resolve(
266        &self,
267        request_model: &str,
268        loaded_models: &[ModelId],
269    ) -> std::result::Result<Option<LoraModelResolution>, ServerError> {
270        if !self.is_enabled() {
271            return Ok(None);
272        }
273        let base = self
274            .base_model_id
275            .clone()
276            .or_else(|| loaded_models.first().map(ToString::to_string))
277            .unwrap_or_default();
278        if request_model == base || loaded_models.iter().any(|model| model.0 == request_model) {
279            return Ok(Some(LoraModelResolution {
280                base_model_id: request_model.to_string(),
281                adapter: None,
282            }));
283        }
284        if let Some(adapter) = self
285            .adapters
286            .iter()
287            .find(|adapter| adapter.model_id == request_model)
288        {
289            return Ok(Some(LoraModelResolution {
290                base_model_id: base,
291                adapter: Some(adapter.clone()),
292            }));
293        }
294        Err(ServerError::invalid_request(
295            format!("unknown LoRA adapter model: {request_model}"),
296            Some("model"),
297        ))
298    }
299}
300
301impl AppState {
302    pub fn with_llm(mut self, engine: Arc<dyn LlmInferenceEngine + Send + Sync>) -> Self {
303        self.llm = Some(engine);
304        self
305    }
306    pub fn with_embed(mut self, engine: Arc<dyn EmbedEngine + Send + Sync>) -> Self {
307        self.embed = Some(engine);
308        self
309    }
310    pub fn with_transcribe(mut self, engine: Arc<dyn TranscribeEngine + Send + Sync>) -> Self {
311        self.transcribe = Some(engine);
312        self
313    }
314    pub fn with_tts(mut self, engine: Arc<dyn TtsEngine + Send + Sync>) -> Self {
315        self.tts = Some(engine);
316        self
317    }
318
319    pub fn with_auto_config(mut self, auto_config: ResolvedFerrumConfig) -> Self {
320        self.auto_config = Some(auto_config);
321        self
322    }
323
324    pub fn with_prompt_template(mut self, prompt_template: Option<ModelChatTemplate>) -> Self {
325        self.prompt_template = prompt_template.map(Arc::new);
326        self
327    }
328
329    pub fn with_lora_adapters(
330        mut self,
331        base_model_id: impl Into<String>,
332        adapters: Vec<LoraAdapterModel>,
333    ) -> Self {
334        self.lora_registry = Arc::new(LoraModelRegistry::new(base_model_id, adapters));
335        self
336    }
337
338    /// Async aggregated status across whichever modality is loaded.
339    /// In single-modality deployments (current CLI), exactly one is Some.
340    async fn status(&self) -> EngineStatus {
341        if let Some(e) = &self.llm {
342            return e.status().await;
343        }
344        if let Some(e) = &self.embed {
345            return e.status().await;
346        }
347        if let Some(e) = &self.transcribe {
348            return e.status().await;
349        }
350        if let Some(e) = &self.tts {
351            return e.status().await;
352        }
353        EngineStatus {
354            is_ready: false,
355            loaded_models: vec![],
356            active_requests: 0,
357            queued_requests: 0,
358            memory_usage: ferrum_types::MemoryUsage {
359                total_bytes: 0,
360                used_bytes: 0,
361                free_bytes: 0,
362                gpu_memory_bytes: None,
363                cpu_memory_bytes: None,
364                cache_memory_bytes: 0,
365                utilization_percent: 0.0,
366            },
367            uptime_seconds: 0,
368            last_heartbeat: chrono::Utc::now(),
369            version: env!("CARGO_PKG_VERSION").to_string(),
370        }
371    }
372
373    fn metrics(&self) -> EngineMetrics {
374        if let Some(e) = &self.llm {
375            return e.metrics();
376        }
377        if let Some(e) = &self.embed {
378            return e.metrics();
379        }
380        if let Some(e) = &self.transcribe {
381            return e.metrics();
382        }
383        if let Some(e) = &self.tts {
384            return e.metrics();
385        }
386        EngineMetrics {
387            total_requests: 0,
388            successful_requests: 0,
389            failed_requests: 0,
390            avg_request_latency_ms: 0.0,
391            p95_request_latency_ms: 0.0,
392            p99_request_latency_ms: 0.0,
393            throughput_rps: 0.0,
394            tokens_per_second: 0.0,
395            queue_metrics: Default::default(),
396            resource_utilization: Default::default(),
397            error_stats: Default::default(),
398            performance_breakdown: Default::default(),
399        }
400    }
401}
402
403#[derive(Default)]
404struct CacheRuntimeState {
405    stats: Mutex<CacheStats>,
406    prefix_prompts: Mutex<HashMap<String, usize>>,
407    sessions: Mutex<HashMap<String, Vec<ChatMessage>>>,
408}
409
410#[derive(Debug, Clone, Default)]
411struct CacheStats {
412    prefix_hits: u64,
413    prefix_misses: u64,
414    prefix_evictions: u64,
415    prefix_saved_prefill_tokens: u64,
416    prefix_entries: u64,
417    prefix_bytes: u64,
418    session_hits: u64,
419    session_misses: u64,
420    session_evictions: u64,
421    session_entries: u64,
422    session_tokens: u64,
423}
424
425#[derive(Clone)]
426struct SessionContext {
427    id: String,
428    prior_messages: Vec<ChatMessage>,
429    incoming_messages: Vec<ChatMessage>,
430}
431
432impl CacheRuntimeState {
433    fn record_prefix_prompt(&self, prompt: &str, policy: &CachePolicy) {
434        if !policy.prefix_cache_enabled {
435            return;
436        }
437
438        let prompt_tokens = approx_tokens(prompt);
439        let mut prompts = self.prefix_prompts.lock().expect("prefix cache lock");
440        let saved_tokens = prompts
441            .keys()
442            .map(|seen| approx_tokens_for_chars(longest_common_prefix_chars(seen, prompt)))
443            .max()
444            .unwrap_or(0);
445
446        let mut stats = self.stats.lock().expect("cache stats lock");
447        if saved_tokens > 0 {
448            stats.prefix_hits += 1;
449            stats.prefix_saved_prefill_tokens += saved_tokens as u64;
450        } else {
451            stats.prefix_misses += 1;
452        }
453
454        let max_entries = policy.session_cache_max_entries.max(1);
455        if !prompts.contains_key(prompt) && prompts.len() >= max_entries {
456            if let Some(key) = prompts.keys().next().cloned() {
457                prompts.remove(&key);
458                stats.prefix_evictions += 1;
459            }
460        }
461        prompts.insert(prompt.to_string(), prompt_tokens);
462        stats.prefix_entries = prompts.len() as u64;
463        stats.prefix_bytes = prompts.keys().map(|key| key.len() as u64).sum();
464    }
465
466    fn prepare_session_request(
467        &self,
468        request: &mut ChatCompletionsRequest,
469        headers: &HeaderMap,
470        policy: &CachePolicy,
471    ) -> Option<SessionContext> {
472        let session_id = request_session_id(headers, request)?;
473        if !policy.session_memory_enabled() {
474            return None;
475        }
476
477        let incoming_messages = request.messages.clone();
478        let prior_messages = {
479            let sessions = self.sessions.lock().expect("session cache lock");
480            sessions.get(&session_id).cloned().unwrap_or_default()
481        };
482        {
483            let mut stats = self.stats.lock().expect("cache stats lock");
484            if prior_messages.is_empty() {
485                stats.session_misses += 1;
486            } else {
487                stats.session_hits += 1;
488                let mut merged = prior_messages.clone();
489                merged.extend(request.messages.clone());
490                request.messages = merged;
491            }
492        }
493
494        Some(SessionContext {
495            id: session_id,
496            prior_messages,
497            incoming_messages,
498        })
499    }
500
501    fn update_session(
502        &self,
503        context: Option<SessionContext>,
504        assistant_message: ChatMessage,
505        policy: &CachePolicy,
506    ) {
507        let Some(context) = context else {
508            return;
509        };
510        if !policy.session_memory_enabled() {
511            return;
512        }
513
514        let mut history = context.prior_messages;
515        history.extend(context.incoming_messages);
516        history.push(assistant_message);
517        trim_messages_to_token_budget(&mut history, policy.session_cache_max_tokens);
518
519        let mut sessions = self.sessions.lock().expect("session cache lock");
520        if !sessions.contains_key(&context.id)
521            && sessions.len() >= policy.session_cache_max_entries.max(1)
522        {
523            if let Some(evict_key) = sessions.keys().next().cloned() {
524                sessions.remove(&evict_key);
525                self.stats
526                    .lock()
527                    .expect("cache stats lock")
528                    .session_evictions += 1;
529            }
530        }
531        sessions.insert(context.id, history);
532
533        let entries = sessions.len() as u64;
534        let tokens = sessions
535            .values()
536            .map(|messages| {
537                messages
538                    .iter()
539                    .map(|msg| approx_tokens(&msg.content))
540                    .sum::<usize>()
541            })
542            .sum::<usize>() as u64;
543        let mut stats = self.stats.lock().expect("cache stats lock");
544        stats.session_entries = entries;
545        stats.session_tokens = tokens;
546    }
547
548    fn stats(&self) -> CacheStats {
549        let mut stats = self.stats.lock().expect("cache stats lock").clone();
550        stats.prefix_entries = self.prefix_prompts.lock().expect("prefix cache lock").len() as u64;
551        let sessions = self.sessions.lock().expect("session cache lock");
552        stats.session_entries = sessions.len() as u64;
553        stats.session_tokens = sessions
554            .values()
555            .map(|messages| {
556                messages
557                    .iter()
558                    .map(|msg| approx_tokens(&msg.content))
559                    .sum::<usize>()
560            })
561            .sum::<usize>() as u64;
562        stats
563    }
564
565    fn health_json(
566        &self,
567        policy: &CachePolicy,
568        engine_prefix_cache: Option<&serde_json::Value>,
569    ) -> serde_json::Value {
570        let stats = self.stats();
571        let prefix_hits = engine_u64(engine_prefix_cache, "hits").unwrap_or(stats.prefix_hits);
572        let prefix_misses =
573            engine_u64(engine_prefix_cache, "misses").unwrap_or(stats.prefix_misses);
574        let prefix_evictions =
575            engine_u64(engine_prefix_cache, "evictions").unwrap_or(stats.prefix_evictions);
576        let prefix_saved = engine_u64(engine_prefix_cache, "saved_prefill_tokens")
577            .unwrap_or(stats.prefix_saved_prefill_tokens);
578        let prefix_entries =
579            engine_u64(engine_prefix_cache, "entries").unwrap_or(stats.prefix_entries);
580        let prefix_bytes = engine_u64(engine_prefix_cache, "bytes").unwrap_or(stats.prefix_bytes);
581        let mut prefix_cache = serde_json::json!({
582            "enabled": engine_bool(engine_prefix_cache, "enabled").unwrap_or(policy.prefix_cache_enabled),
583            "position": engine_str(engine_prefix_cache, "position").unwrap_or("product-observability"),
584            "source": engine_str(engine_prefix_cache, "source").unwrap_or("server-prompt-lcp-observability"),
585            "entries": prefix_entries,
586            "hits": prefix_hits,
587            "misses": prefix_misses,
588            "evictions": prefix_evictions,
589            "saved_prefill_tokens": prefix_saved,
590            "bytes": prefix_bytes,
591            "block_size": engine_u64(engine_prefix_cache, "block_size"),
592            "kv_dtype": engine_str(engine_prefix_cache, "kv_dtype"),
593        });
594        if let (Some(engine), Some(prefix)) = (
595            engine_prefix_cache.and_then(|value| value.as_object()),
596            prefix_cache.as_object_mut(),
597        ) {
598            for (key, value) in engine {
599                prefix.entry(key.clone()).or_insert_with(|| value.clone());
600            }
601        }
602        serde_json::json!({
603            "prefix_cache": prefix_cache,
604            "session_cache": {
605                "mode": policy.session_cache_mode,
606                "entries": stats.session_entries,
607                "hits": stats.session_hits,
608                "misses": stats.session_misses,
609                "evictions": stats.session_evictions,
610                "tokens": stats.session_tokens,
611                "max_entries": policy.session_cache_max_entries,
612                "max_tokens": policy.session_cache_max_tokens,
613            }
614        })
615    }
616
617    fn prometheus_metrics(&self, engine_prefix_cache: Option<&serde_json::Value>) -> String {
618        let stats = self.stats();
619        let prefix_hits = engine_u64(engine_prefix_cache, "hits").unwrap_or(stats.prefix_hits);
620        let prefix_misses =
621            engine_u64(engine_prefix_cache, "misses").unwrap_or(stats.prefix_misses);
622        let prefix_evictions =
623            engine_u64(engine_prefix_cache, "evictions").unwrap_or(stats.prefix_evictions);
624        let prefix_saved = engine_u64(engine_prefix_cache, "saved_prefill_tokens")
625            .unwrap_or(stats.prefix_saved_prefill_tokens);
626        let prefix_entries =
627            engine_u64(engine_prefix_cache, "entries").unwrap_or(stats.prefix_entries);
628        let prefix_bytes = engine_u64(engine_prefix_cache, "bytes").unwrap_or(stats.prefix_bytes);
629        format!(
630            concat!(
631                "ferrum_prefix_cache_hits_total {}\n",
632                "ferrum_prefix_cache_misses_total {}\n",
633                "ferrum_prefix_cache_evictions_total {}\n",
634                "ferrum_prefix_cache_saved_prefill_tokens_total {}\n",
635                "ferrum_prefix_cache_entries {}\n",
636                "ferrum_prefix_cache_bytes {}\n",
637                "ferrum_session_cache_hits_total {}\n",
638                "ferrum_session_cache_misses_total {}\n",
639                "ferrum_session_cache_evictions_total {}\n",
640                "ferrum_session_cache_entries {}\n",
641                "ferrum_session_cache_tokens {}\n"
642            ),
643            prefix_hits,
644            prefix_misses,
645            prefix_evictions,
646            prefix_saved,
647            prefix_entries,
648            prefix_bytes,
649            stats.session_hits,
650            stats.session_misses,
651            stats.session_evictions,
652            stats.session_entries,
653            stats.session_tokens,
654        )
655    }
656}
657
658fn engine_u64(snapshot: Option<&serde_json::Value>, key: &str) -> Option<u64> {
659    snapshot?.get(key)?.as_u64()
660}
661
662fn engine_bool(snapshot: Option<&serde_json::Value>, key: &str) -> Option<bool> {
663    snapshot?.get(key)?.as_bool()
664}
665
666fn engine_str<'a>(snapshot: Option<&'a serde_json::Value>, key: &str) -> Option<&'a str> {
667    snapshot?.get(key)?.as_str()
668}
669
670fn auto_config_health_value(auto_config: Option<&ResolvedFerrumConfig>) -> serde_json::Value {
671    match auto_config {
672        Some(auto_config) => auto_config.effective_config_document(),
673        None => {
674            match FerrumConfigBuilder::new(RuntimeConfigSnapshot::capture_current()).resolve() {
675                Ok(auto_config) => auto_config.effective_config_document(),
676                Err(err) => serde_json::json!({
677                    "schema_version": 1,
678                    "error": err.to_string(),
679                }),
680            }
681        }
682    }
683}
684
685fn admission_health_json(
686    engine_status: &EngineStatus,
687    scheduler_metrics: &EngineMetrics,
688    auto_config: &serde_json::Value,
689) -> serde_json::Value {
690    let configured = auto_config
691        .get("admission")
692        .and_then(|value| value.as_object());
693    let effective_max_concurrent = configured
694        .and_then(|value| value.get("effective_max_concurrent"))
695        .and_then(|value| value.as_u64())
696        .unwrap_or_else(|| {
697            (engine_status.active_requests + engine_status.queued_requests)
698                .max(1)
699                .try_into()
700                .unwrap_or(u64::MAX)
701        });
702    serde_json::json!({
703        "schema_version": 1,
704        "source": "startup_auto_config_and_engine_status",
705        "effective_max_concurrent": effective_max_concurrent,
706        "queue_depth": engine_status.queued_requests as u64,
707        "active_prefill": 0u64,
708        "active_decode": engine_status.active_requests as u64,
709        "current_batch_size": engine_status.active_requests as u64,
710        "rejected_requests_total": 0u64,
711        "failed_requests_total": scheduler_metrics.failed_requests,
712        "completed_requests_total": scheduler_metrics.successful_requests,
713        "avg_queue_wait_time_ms": scheduler_metrics.queue_metrics.avg_queue_wait_time_ms,
714        "scheduler_policy": configured
715            .and_then(|value| value.get("scheduler_policy"))
716            .and_then(|value| value.as_str())
717            .unwrap_or("unknown"),
718        "phase_detail_source": "engine_status_does_not_split_prefill_decode",
719    })
720}
721
722fn admission_prometheus_metrics(admission: &serde_json::Value) -> String {
723    let value = |key: &str| {
724        admission
725            .get(key)
726            .and_then(|value| value.as_u64())
727            .unwrap_or(0)
728    };
729    format!(
730        concat!(
731            "ferrum_admission_effective_max_concurrent {}\n",
732            "ferrum_admission_queue_depth {}\n",
733            "ferrum_admission_active_prefill {}\n",
734            "ferrum_admission_active_decode {}\n",
735            "ferrum_admission_current_batch_size {}\n",
736            "ferrum_admission_rejected_requests_total {}\n",
737            "ferrum_admission_failed_requests_total {}\n",
738            "ferrum_admission_completed_requests_total {}\n"
739        ),
740        value("effective_max_concurrent"),
741        value("queue_depth"),
742        value("active_prefill"),
743        value("active_decode"),
744        value("current_batch_size"),
745        value("rejected_requests_total"),
746        value("failed_requests_total"),
747        value("completed_requests_total"),
748    )
749}
750
751fn request_session_id(headers: &HeaderMap, request: &ChatCompletionsRequest) -> Option<String> {
752    headers
753        .get(FERRUM_SESSION_HEADER)
754        .and_then(|value| value.to_str().ok())
755        .map(str::trim)
756        .filter(|value| !value.is_empty())
757        .map(str::to_string)
758        .or_else(|| {
759            request
760                .metadata
761                .as_ref()
762                .and_then(|metadata| metadata.get("ferrum_session_id"))
763                .and_then(|value| value.as_str())
764                .map(str::trim)
765                .filter(|value| !value.is_empty())
766                .map(str::to_string)
767        })
768}
769
770fn approx_tokens(text: &str) -> usize {
771    approx_tokens_for_chars(text.chars().count())
772}
773
774fn approx_tokens_for_chars(chars: usize) -> usize {
775    (chars / 4).max(1)
776}
777
778fn longest_common_prefix_chars(a: &str, b: &str) -> usize {
779    a.chars().zip(b.chars()).take_while(|(a, b)| a == b).count()
780}
781
782fn trim_messages_to_token_budget(messages: &mut Vec<ChatMessage>, max_tokens: usize) {
783    let max_tokens = max_tokens.max(1);
784    while messages.len() > 1
785        && messages
786            .iter()
787            .map(|msg| approx_tokens(&msg.content))
788            .sum::<usize>()
789            > max_tokens
790    {
791        messages.remove(0);
792    }
793}
794
795#[async_trait]
796impl HttpServer for AxumServer {
797    async fn start(&self, config: &ServerConfig) -> ferrum_types::Result<()> {
798        let addr = format!("{}:{}", config.host, config.port);
799        info!("Starting Axum server on {}", addr);
800
801        let app = self.build_router();
802        let listener = tokio::net::TcpListener::bind(&addr)
803            .await
804            .map_err(|e| Error::internal(format!("Failed to bind to {}: {}", addr, e)))?;
805
806        info!("Server listening on {}", addr);
807
808        axum::serve(listener, app)
809            .await
810            .map_err(|e| Error::internal(format!("Server error: {}", e)))?;
811
812        Ok(())
813    }
814
815    async fn stop(&self, _timeout: std::time::Duration) -> ferrum_types::Result<()> {
816        info!("Stopping Axum server");
817        // Axum doesn't have explicit stop - server stops when task is cancelled
818        Ok(())
819    }
820
821    fn is_running(&self) -> bool {
822        // For MVP, always return true when server object exists
823        true
824    }
825
826    fn address(&self) -> Option<std::net::SocketAddr> {
827        // For MVP, return configured address
828        format!("{}:{}", self.config.host, self.config.port)
829            .parse()
830            .ok()
831    }
832
833    fn register_handler(
834        &mut self,
835        _path: &str,
836        _method: HttpMethod,
837        _handler: Box<dyn crate::traits::RequestHandler>,
838    ) {
839        // For MVP, routes are static
840        unimplemented!("Dynamic handler registration not implemented in MVP")
841    }
842
843    fn register_middleware(&mut self, _middleware: Box<dyn crate::traits::Middleware>) {
844        // For MVP, middleware is static
845        unimplemented!("Dynamic middleware registration not implemented in MVP")
846    }
847
848    fn get_metrics(&self) -> ServerMetrics {
849        // Return empty metrics for MVP
850        ServerMetrics {
851            total_requests: 0,
852            requests_by_endpoint: std::collections::HashMap::new(),
853            requests_by_status: std::collections::HashMap::new(),
854            avg_response_time_ms: 0.0,
855            p95_response_time_ms: 0.0,
856            p99_response_time_ms: 0.0,
857            active_connections: 0,
858            bytes_sent: 0,
859            bytes_received: 0,
860            error_rate: 0.0,
861            uptime_seconds: 0,
862        }
863    }
864
865    async fn health_check(&self) -> HealthStatus {
866        HealthStatus::Healthy
867    }
868}
869
870/// Main chat completions handler
871async fn chat_completions_handler(
872    State(state): State<AppState>,
873    headers: HeaderMap,
874    request: std::result::Result<Json<ChatCompletionsRequest>, JsonRejection>,
875) -> std::result::Result<Response, ServerError> {
876    let Json(mut request) = request.map_err(|e| {
877        ServerError::invalid_request(format!("invalid chat completions request: {e}"), None)
878    })?;
879    let cache_policy = CachePolicy::current();
880    let session_context =
881        state
882            .cache
883            .prepare_session_request(&mut request, &headers, &cache_policy);
884
885    let span = span!(Level::INFO, "chat_completions", model = %request.model);
886    let _enter = span.enter();
887
888    info!(
889        "Received chat completions request for model: {}",
890        request.model
891    );
892    debug!("Request: {:?}", request);
893
894    // OpenAI spec requires at least one message. Reject empty arrays at
895    // the boundary rather than synthesising a fake prompt downstream.
896    validate_chat_request(&request)?;
897    let loaded_models = state.status().await.loaded_models;
898    let lora_resolution = state
899        .lora_registry
900        .resolve(&request.model, &loaded_models)?;
901
902    // Convert OpenAI request to internal format
903    let template_model_id = lora_resolution
904        .as_ref()
905        .map(|resolution| resolution.base_model_id.clone())
906        .or_else(|| loaded_models.first().map(ToString::to_string))
907        .unwrap_or_else(|| request.model.clone());
908    let mut inference_request = convert_chat_request_with_template_model(
909        &request,
910        &template_model_id,
911        state.prompt_template.as_deref(),
912    )
913    .map_err(server_error_from_ferrum_error)?;
914    apply_lora_resolution(&mut inference_request, lora_resolution.as_ref());
915    state
916        .cache
917        .record_prefix_prompt(&inference_request.prompt, &cache_policy);
918
919    // Check if streaming is requested
920    if request.stream.unwrap_or(false) {
921        handle_chat_completions_stream(state, request, inference_request).await
922    } else {
923        handle_chat_completions_sync(state, request, inference_request, session_context).await
924    }
925}
926
927/// Handle streaming chat completions
928async fn handle_chat_completions_stream(
929    state: AppState,
930    openai_request: ChatCompletionsRequest,
931    inference_request: InferenceRequest,
932) -> std::result::Result<Response, ServerError> {
933    let (tx, rx) = mpsc::unbounded_channel::<std::result::Result<Event, axum::Error>>();
934
935    // Spawn task to generate tokens
936    let engine = state.llm.clone().ok_or_else(|| {
937        ServerError::ServiceUnavailable("LLM engine not loaded; chat unavailable".into())
938    })?;
939    let request_id = Uuid::new_v4().to_string();
940    let include_stream_usage = openai_request
941        .stream_options
942        .as_ref()
943        .and_then(|opts| opts.include_usage)
944        .unwrap_or(false);
945    let buffer_json_object_stream = response_format_is_json_object(&openai_request);
946    let buffer_strict_json_schema_stream = strict_json_schema_string(&openai_request)?.is_some();
947    let stream_api_request = match inference_request.api_request.as_ref() {
948        Some(ferrum_types::ApiRequest::Chat(request)) => request.clone(),
949        _ => api_chat_request(&openai_request, openai_request.tool_choice.as_ref()),
950    };
951    let buffer_structured_api_stream =
952        ferrum_types::chat_api_may_emit_tool_or_function_call(&stream_api_request);
953    let buffer_stream_output = buffer_json_object_stream
954        || buffer_strict_json_schema_stream
955        || buffer_structured_api_stream;
956    let mut stream = match engine.infer_stream(inference_request).await {
957        Ok(stream) => stream,
958        Err(e) => {
959            error!("Stream generation failed before first chunk: {}", e);
960            let _ = tx.send(Ok(openai_error_sse_event(
961                e.to_string(),
962                "internal_server_error",
963                None,
964            )));
965            let _ = tx.send(Ok(Event::default().data("[DONE]")));
966            let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
967            return Ok(Sse::new(stream).into_response());
968        }
969    };
970
971    tokio::spawn(async move {
972        let mut current_text = String::new();
973        let mut sent_reasoning_len = 0usize;
974        let mut sent_content_len = 0usize;
975
976        while let Some(result) = stream.next().await {
977            match result {
978                Ok(chunk) => {
979                    if !chunk.text.is_empty() {
980                        current_text.push_str(&chunk.text);
981
982                        if !buffer_stream_output {
983                            if should_defer_reasoning_stream_delta(&current_text) {
984                                continue;
985                            }
986                            let parsed = parse_reasoning_response(&current_text);
987                            let full_reasoning = parsed.reasoning.as_deref().unwrap_or("");
988                            let reasoning_delta =
989                                stream_text_delta(full_reasoning, &mut sent_reasoning_len);
990                            let content_delta =
991                                stream_text_delta(&parsed.content, &mut sent_content_len);
992                            if reasoning_delta.is_empty() && content_delta.is_empty() {
993                                continue;
994                            }
995                            // Create streaming response chunk
996                            let response_chunk = ChatCompletionsResponse {
997                                id: request_id.clone(),
998                                object: "chat.completion.chunk".to_string(),
999                                created: chrono::Utc::now().timestamp() as u64,
1000                                model: openai_request.model.clone(),
1001                                choices: vec![ChatChoice {
1002                                    index: 0,
1003                                    message: None,
1004                                    delta: Some(ChatMessage {
1005                                        role: MessageRole::Assistant,
1006                                        content: content_delta,
1007                                        reasoning: (!reasoning_delta.is_empty())
1008                                            .then_some(reasoning_delta),
1009                                        name: None,
1010                                        tool_calls: None,
1011                                        tool_call_id: None,
1012                                        function_call: None,
1013                                    }),
1014                                    finish_reason: None,
1015                                }],
1016                                usage: None,
1017                            };
1018
1019                            let sse_event = Event::default()
1020                                .json_data(&response_chunk)
1021                                .unwrap_or_else(|_| Event::default().data("error"));
1022                            if tx.send(Ok(sse_event)).is_err() {
1023                                break;
1024                            }
1025                        }
1026                    }
1027
1028                    if chunk.finish_reason.is_some() {
1029                        let usage = chunk.usage.as_ref().map(openai_usage_from_token_usage);
1030                        let mut parsed_final = parse_reasoning_response(&current_text);
1031                        parsed_final.content = normalize_structured_response_content(
1032                            &openai_request,
1033                            &parsed_final.content,
1034                        );
1035                        if let Err(e) = validate_strict_json_schema_response(
1036                            &openai_request,
1037                            &parsed_final.content,
1038                        ) {
1039                            let error_event = openai_error_sse_event(
1040                                strict_stream_validation_error_message(e),
1041                                "internal_server_error",
1042                                Some("response_format.json_schema"),
1043                            );
1044                            let _ = tx.send(Ok(error_event));
1045                            let _ = tx.send(Ok(Event::default().data("[DONE]")));
1046                            break;
1047                        }
1048                        let structured_chat_response = match chunk.api_response.as_ref() {
1049                            Some(ferrum_types::ApiResponse::Chat(response)) => {
1050                                Some(response.clone())
1051                            }
1052                            _ if buffer_structured_api_stream => {
1053                                chat_api_response_from_parsed_generated_text(
1054                                    &stream_api_request,
1055                                    &parsed_final,
1056                                )
1057                            }
1058                            _ => None,
1059                        };
1060
1061                        if let Some(chat_response) = structured_chat_response.as_ref() {
1062                            let mut delta = openai_chat_delta_from_api(&chat_response.message);
1063                            if delta.reasoning.is_none() {
1064                                delta.reasoning = parsed_final.reasoning.clone();
1065                            }
1066                            let response_chunk = ChatCompletionsResponse {
1067                                id: request_id.clone(),
1068                                object: "chat.completion.chunk".to_string(),
1069                                created: chrono::Utc::now().timestamp() as u64,
1070                                model: openai_request.model.clone(),
1071                                choices: vec![ChatChoice {
1072                                    index: 0,
1073                                    message: None,
1074                                    delta: Some(delta),
1075                                    finish_reason: None,
1076                                }],
1077                                usage: None,
1078                            };
1079
1080                            let sse_event = Event::default()
1081                                .json_data(&response_chunk)
1082                                .unwrap_or_else(|_| Event::default().data("error"));
1083                            if tx.send(Ok(sse_event)).is_err() {
1084                                break;
1085                            }
1086                        } else if tool_choice_required(&openai_request) {
1087                            log_required_tool_choice_failure(
1088                                &openai_request,
1089                                &parsed_final.content,
1090                                parsed_final.reasoning.as_deref(),
1091                            );
1092                            let error_event = openai_error_sse_event(
1093                                "model output did not satisfy required tool_choice",
1094                                "invalid_request_error",
1095                                Some("tool_choice"),
1096                            );
1097                            let _ = tx.send(Ok(error_event));
1098                            let _ = tx.send(Ok(Event::default().data("[DONE]")));
1099                            break;
1100                        } else if buffer_structured_api_stream
1101                            && parsed_final.content.trim().is_empty()
1102                        {
1103                            let error_event = openai_error_sse_event(
1104                                "model output did not satisfy tool/function call request",
1105                                "internal_server_error",
1106                                Some("tool_choice"),
1107                            );
1108                            let _ = tx.send(Ok(error_event));
1109                            let _ = tx.send(Ok(Event::default().data("[DONE]")));
1110                            break;
1111                        } else if buffer_stream_output && !current_text.is_empty() {
1112                            let response_chunk = ChatCompletionsResponse {
1113                                id: request_id.clone(),
1114                                object: "chat.completion.chunk".to_string(),
1115                                created: chrono::Utc::now().timestamp() as u64,
1116                                model: openai_request.model.clone(),
1117                                choices: vec![ChatChoice {
1118                                    index: 0,
1119                                    message: None,
1120                                    delta: Some(ChatMessage {
1121                                        role: MessageRole::Assistant,
1122                                        content: parsed_final.content.clone(),
1123                                        reasoning: parsed_final.reasoning.clone(),
1124                                        name: None,
1125                                        tool_calls: None,
1126                                        tool_call_id: None,
1127                                        function_call: None,
1128                                    }),
1129                                    finish_reason: None,
1130                                }],
1131                                usage: None,
1132                            };
1133
1134                            let sse_event = Event::default()
1135                                .json_data(&response_chunk)
1136                                .unwrap_or_else(|_| Event::default().data("error"));
1137                            if tx.send(Ok(sse_event)).is_err() {
1138                                break;
1139                            }
1140                        }
1141                        // Send final chunk. OpenAI-style streaming
1142                        // clients (e.g. `vllm bench serve`) blindly
1143                        // read `choices[0]["delta"]` on every chunk
1144                        // that has any `choices` entries, so the
1145                        // last chunk must include `delta` even when
1146                        // empty. Skipping it triggers
1147                        // `KeyError: 'delta'` on the client side
1148                        // and the request is reported as failed
1149                        // despite returning a 200 with content.
1150                        let final_chunk = ChatCompletionsResponse {
1151                            id: request_id.clone(),
1152                            object: "chat.completion.chunk".to_string(),
1153                            created: chrono::Utc::now().timestamp() as u64,
1154                            model: openai_request.model.clone(),
1155                            choices: vec![ChatChoice {
1156                                index: 0,
1157                                message: None,
1158                                delta: Some(ChatMessage {
1159                                    role: MessageRole::Assistant,
1160                                    content: String::new(),
1161                                    reasoning: None,
1162                                    name: None,
1163                                    tool_calls: None,
1164                                    tool_call_id: None,
1165                                    function_call: None,
1166                                }),
1167                                finish_reason: structured_chat_response
1168                                    .as_ref()
1169                                    .and_then(|response| response.finish_reason.clone())
1170                                    .or_else(|| {
1171                                        chunk.finish_reason.as_ref().map(finish_reason_to_string)
1172                                    })
1173                                    .or(Some("length".to_string())),
1174                            }],
1175                            usage: None,
1176                        };
1177
1178                        let final_event = Event::default()
1179                            .json_data(&final_chunk)
1180                            .unwrap_or_else(|_| Event::default().data("error"));
1181                        let _ = tx.send(Ok(final_event));
1182                        if include_stream_usage && usage.is_some() {
1183                            let usage_chunk = ChatCompletionsResponse {
1184                                id: request_id.clone(),
1185                                object: "chat.completion.chunk".to_string(),
1186                                created: chrono::Utc::now().timestamp() as u64,
1187                                model: openai_request.model.clone(),
1188                                choices: vec![],
1189                                usage,
1190                            };
1191                            let usage_event = Event::default()
1192                                .json_data(&usage_chunk)
1193                                .unwrap_or_else(|_| Event::default().data("error"));
1194                            let _ = tx.send(Ok(usage_event));
1195                        }
1196                        let _ = tx.send(Ok(Event::default().data("[DONE]")));
1197                        break;
1198                    }
1199                }
1200                Err(e) => {
1201                    error!("Stream generation error: {}", e);
1202                    let _ = tx.send(Ok(openai_error_sse_event(
1203                        e.to_string(),
1204                        "internal_server_error",
1205                        None,
1206                    )));
1207                    let _ = tx.send(Ok(Event::default().data("[DONE]")));
1208                    break;
1209                }
1210            }
1211        }
1212    });
1213
1214    let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
1215    let sse_stream = Sse::new(stream);
1216
1217    Ok(sse_stream.into_response())
1218}
1219
1220/// Handle non-streaming chat completions
1221async fn handle_chat_completions_sync(
1222    state: AppState,
1223    openai_request: ChatCompletionsRequest,
1224    inference_request: InferenceRequest,
1225    session_context: Option<SessionContext>,
1226) -> std::result::Result<Response, ServerError> {
1227    info!("Processing non-streaming chat completion");
1228
1229    let engine = state.llm.clone().ok_or_else(|| {
1230        ServerError::ServiceUnavailable("LLM engine not loaded; chat unavailable".into())
1231    })?;
1232    let request_chat_api = inference_request
1233        .api_request
1234        .as_ref()
1235        .and_then(|api_request| match api_request {
1236            ferrum_types::ApiRequest::Chat(chat_request) => {
1237                ferrum_types::chat_api_may_emit_tool_or_function_call(chat_request)
1238                    .then(|| chat_request.clone())
1239            }
1240            _ => None,
1241        });
1242    match engine.infer(inference_request).await {
1243        Ok(output) => {
1244            let InferenceResponse {
1245                text: output_text,
1246                finish_reason,
1247                usage,
1248                api_response,
1249                ..
1250            } = output;
1251
1252            // Post-process the completion text in two passes:
1253            //   1. Strip a markdown fence when `response_format = json_object`
1254            //      — JsonModeProcessor's soft biases don't hard-mask the
1255            //      fence the model wants to emit. Strict json_schema does
1256            //      not get this cleanup; success must come from hard masking
1257            //      and final validation, not markdown repair.
1258            //   2. Strip a trailing user-supplied `stop` sentinel — OpenAI
1259            //      convention is that stop strings mark a boundary and are
1260            //      NOT included in the returned completion.
1261            // Order matters: fence-strip first reveals the actual JSON,
1262            // then any stop sentinel inside that JSON gets trimmed.
1263            let after_fence = match &openai_request.response_format {
1264                Some(rf) if rf.format_type == "json_object" => {
1265                    strip_markdown_json_fence(&output_text)
1266                }
1267                _ => output_text,
1268            };
1269            let stop_sequences = openai_request.stop.clone().unwrap_or_default();
1270            let content = strip_after_stop(&after_fence, &stop_sequences);
1271            let parsed = parse_reasoning_response(&content);
1272            let visible_content =
1273                normalize_structured_response_content(&openai_request, &parsed.content);
1274            let mut message = ChatMessage {
1275                role: MessageRole::Assistant,
1276                content: visible_content,
1277                reasoning: parsed.reasoning.clone(),
1278                name: None,
1279                tool_calls: None,
1280                tool_call_id: None,
1281                function_call: None,
1282            };
1283            let mut openai_finish_reason = finish_reason_to_string(&finish_reason);
1284            let structured_chat_response = match api_response.as_ref() {
1285                Some(ferrum_types::ApiResponse::Chat(chat_response)) => Some(chat_response.clone()),
1286                _ => match request_chat_api.as_ref() {
1287                    Some(chat_request) => {
1288                        chat_api_response_from_parsed_generated_text(chat_request, &parsed)
1289                    }
1290                    _ => None,
1291                },
1292            };
1293            if let Some(chat_response) = structured_chat_response.as_ref() {
1294                message = openai_chat_message_from_api(&chat_response.message);
1295                if message.reasoning.is_none() {
1296                    message.reasoning = parsed.reasoning.clone();
1297                }
1298                if let Some(reason) = &chat_response.finish_reason {
1299                    openai_finish_reason = reason.clone();
1300                }
1301            } else if tool_choice_required(&openai_request) {
1302                log_required_tool_choice_failure(
1303                    &openai_request,
1304                    &parsed.content,
1305                    parsed.reasoning.as_deref(),
1306                );
1307                return Err(ServerError::invalid_request(
1308                    "model output did not satisfy required tool_choice",
1309                    Some("tool_choice"),
1310                ));
1311            }
1312            validate_strict_json_schema_response(&openai_request, &message.content)?;
1313            state
1314                .cache
1315                .update_session(session_context, message.clone(), &CachePolicy::current());
1316            let response = ChatCompletionsResponse {
1317                id: Uuid::new_v4().to_string(),
1318                object: "chat.completion".to_string(),
1319                created: chrono::Utc::now().timestamp() as u64,
1320                model: openai_request.model,
1321                choices: vec![ChatChoice {
1322                    index: 0,
1323                    message: Some(message),
1324                    delta: None,
1325                    finish_reason: Some(openai_finish_reason),
1326                }],
1327                usage: Some(openai_usage_from_token_usage(&usage)),
1328            };
1329
1330            Ok(Json(response).into_response())
1331        }
1332        Err(e) => {
1333            error!("Generation failed: {}", e);
1334            Err(server_error_from_ferrum_error(e))
1335        }
1336    }
1337}
1338
1339/// Convert OpenAI chat request to internal inference request
1340#[allow(dead_code)]
1341fn convert_chat_request(
1342    request: &ChatCompletionsRequest,
1343) -> ferrum_types::Result<InferenceRequest> {
1344    convert_chat_request_with_template_model(request, &request.model, None)
1345}
1346
1347/// Convert OpenAI chat request to internal inference request.
1348///
1349/// `template_model_id` is the loaded model id used for prompt-template family
1350/// detection. The request `model` field may be an OpenAI-compatible alias such
1351/// as "ferrum"; using it for template selection can feed a fallback prompt to
1352/// a Qwen/Llama model.
1353fn convert_chat_request_with_template_model(
1354    request: &ChatCompletionsRequest,
1355    template_model_id: &str,
1356    model_template: Option<&ModelChatTemplate>,
1357) -> ferrum_types::Result<InferenceRequest> {
1358    let no_tools: &[ChatTool] = &[];
1359    let tools = if tool_choice_none_hides_tools(request.tool_choice.as_ref(), model_template) {
1360        no_tools
1361    } else {
1362        request.tools.as_deref().unwrap_or_default()
1363    };
1364    let default_tool_choice =
1365        default_auto_tool_choice_for_tools(tools, request.tool_choice.as_ref());
1366    let effective_tool_choice = request
1367        .tool_choice
1368        .as_ref()
1369        .or(default_tool_choice.as_ref());
1370    let functions = request.functions.as_deref().unwrap_or_default();
1371    let forced_response_format = forced_tool_choice_response_format(request);
1372    let render_messages =
1373        render_messages_with_response_format_instruction(request, forced_response_format.as_ref());
1374    let chat_template_options = chat_template_options_for_request(request, model_template)?;
1375    let prompt = if tools.is_empty() && functions.is_empty() {
1376        render_chat_prompt_with_model_template_options(
1377            &render_messages,
1378            template_model_id,
1379            model_template,
1380            &chat_template_options,
1381        )
1382    } else {
1383        render_chat_prompt_with_tools_and_model_template(
1384            &render_messages,
1385            template_model_id,
1386            model_template,
1387            &chat_template_options,
1388            tools,
1389            effective_tool_choice,
1390            functions,
1391            request.function_call.as_ref(),
1392        )
1393    };
1394    let api_chat = api_chat_request(request, effective_tool_choice);
1395    let may_emit_structured_call = ferrum_types::chat_api_may_emit_tool_or_function_call(&api_chat);
1396    let mut metadata = HashMap::new();
1397    metadata.insert(
1398        "openai_messages".to_string(),
1399        serde_json::to_value(&request.messages)?,
1400    );
1401    if let Some(tools) = &request.tools {
1402        metadata.insert("openai_tools".to_string(), serde_json::to_value(tools)?);
1403    }
1404    if let Some(tool_choice) = effective_tool_choice {
1405        metadata.insert(
1406            "openai_tool_choice".to_string(),
1407            serde_json::to_value(tool_choice)?,
1408        );
1409    }
1410    if let Some(functions) = &request.functions {
1411        metadata.insert(
1412            "openai_legacy_functions".to_string(),
1413            serde_json::to_value(functions)?,
1414        );
1415    }
1416    if let Some(function_call) = &request.function_call {
1417        metadata.insert(
1418            "openai_legacy_function_call".to_string(),
1419            serde_json::to_value(function_call)?,
1420        );
1421    }
1422    if request.ignore_eos.unwrap_or(false) {
1423        metadata.insert("ferrum_ignore_eos".to_string(), serde_json::json!(true));
1424    }
1425    if request.max_completion_tokens.is_none() && request.max_tokens.is_none() {
1426        metadata.insert(
1427            DEFAULT_MAX_TOKENS_METADATA_KEY.to_string(),
1428            serde_json::json!(true),
1429        );
1430    }
1431    if !has_unclosed_thinking_block(&prompt) {
1432        let mut forbidden = vec![THINK_END_TAG.to_string()];
1433        if may_emit_structured_call {
1434            for token_text in INITIAL_STRUCTURED_CALL_FORBIDDEN_TOKEN_TEXTS {
1435                push_unique_forbidden_token_text(&mut forbidden, token_text);
1436            }
1437            if let Some(eos) = model_template.as_ref().and_then(|template| {
1438                template
1439                    .eos_token
1440                    .as_deref()
1441                    .filter(|token| !token.is_empty())
1442            }) {
1443                push_unique_forbidden_token_text(&mut forbidden, eos);
1444            }
1445        }
1446        if chat_template_options.enable_thinking == Some(false) {
1447            push_unique_forbidden_token_text(&mut forbidden, THINK_START_TAG);
1448        }
1449        metadata.insert(
1450            INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY.to_string(),
1451            serde_json::json!(forbidden),
1452        );
1453    }
1454
1455    Ok(InferenceRequest {
1456        id: RequestId(Uuid::new_v4()),
1457        model_id: ModelId(request.model.clone()),
1458        prompt,
1459        sampling_params: SamplingParams {
1460            max_tokens: chat_completion_max_tokens(request) as usize,
1461            temperature: request.temperature.unwrap_or(DEFAULT_SAMPLING_TEMPERATURE),
1462            top_p: request.top_p.unwrap_or(DEFAULT_SAMPLING_TOP_P),
1463            top_k: None, // OpenAI doesn't use top-k
1464            repetition_penalty: 1.0,
1465            presence_penalty: request.presence_penalty.unwrap_or(0.0),
1466            frequency_penalty: request.frequency_penalty.unwrap_or(0.0),
1467            stop_sequences: request.stop.clone().unwrap_or_default(),
1468            seed: request.seed,
1469            min_p: None,
1470            tfs: None,
1471            typical_p: None,
1472            mirostat: None,
1473            response_format: forced_response_format
1474                .or_else(|| inferred_auto_tool_response_format(request))
1475                .unwrap_or(ferrum_types::ResponseFormat::Text),
1476        },
1477        stream: request.stream.unwrap_or(false),
1478        priority: Priority::Normal, // Default priority
1479        client_id: None,
1480        session_id: None,
1481        created_at: chrono::Utc::now(),
1482        api_request: Some(ferrum_types::ApiRequest::Chat(api_chat)),
1483        metadata,
1484    })
1485}
1486
1487fn push_unique_forbidden_token_text(tokens: &mut Vec<String>, token: &str) {
1488    if !token.is_empty() && !tokens.iter().any(|existing| existing == token) {
1489        tokens.push(token.to_string());
1490    }
1491}
1492
1493fn default_auto_tool_choice_for_tools(
1494    tools: &[ChatTool],
1495    choice: Option<&ToolChoice>,
1496) -> Option<ToolChoice> {
1497    if choice.is_none() && !tools.is_empty() {
1498        Some(ToolChoice::Mode("auto".to_string()))
1499    } else {
1500        None
1501    }
1502}
1503
1504fn tool_choice_none(choice: Option<&ToolChoice>) -> bool {
1505    matches!(choice, Some(ToolChoice::Mode(mode)) if mode.eq_ignore_ascii_case("none"))
1506}
1507
1508fn tool_choice_none_hides_tools(
1509    choice: Option<&ToolChoice>,
1510    model_template: Option<&ModelChatTemplate>,
1511) -> bool {
1512    tool_choice_none(choice)
1513        && model_template
1514            .map(|template| template.template.contains("tools_in_user_message"))
1515            .unwrap_or(false)
1516}
1517
1518fn chat_template_options_for_request(
1519    request: &ChatCompletionsRequest,
1520    model_template: Option<&ModelChatTemplate>,
1521) -> ferrum_types::Result<ChatTemplateOptions> {
1522    let mut options = ChatTemplateOptions::default_for_template(model_template);
1523    let Some(kwargs) = request.chat_template_kwargs.as_ref() else {
1524        return Ok(options);
1525    };
1526    let Some(value) = kwargs.get("enable_thinking") else {
1527        return Ok(options);
1528    };
1529    let Some(enable_thinking) = value.as_bool() else {
1530        return Err(Error::invalid_request(
1531            "chat_template_kwargs.enable_thinking must be a boolean",
1532        ));
1533    };
1534    options.enable_thinking = Some(enable_thinking);
1535    Ok(options)
1536}
1537
1538fn render_messages_with_response_format_instruction(
1539    request: &ChatCompletionsRequest,
1540    forced_response_format: Option<&ferrum_types::ResponseFormat>,
1541) -> Vec<ChatMessage> {
1542    let Some(instruction) = response_format_prompt_instruction(request, forced_response_format)
1543    else {
1544        return request.messages.clone();
1545    };
1546    let mut messages = Vec::with_capacity(request.messages.len() + 1);
1547    messages.push(ChatMessage {
1548        role: MessageRole::System,
1549        content: instruction,
1550        reasoning: None,
1551        name: None,
1552        tool_calls: None,
1553        tool_call_id: None,
1554        function_call: None,
1555    });
1556    messages.extend(request.messages.clone());
1557    messages
1558}
1559
1560fn response_format_prompt_instruction(
1561    request: &ChatCompletionsRequest,
1562    _forced_response_format: Option<&ferrum_types::ResponseFormat>,
1563) -> Option<String> {
1564    if let Some(format) = request.response_format.as_ref() {
1565        return match format.format_type.as_str() {
1566            "json_object" => Some(
1567                "The response_format requires a single valid JSON object. Output only JSON, with no markdown fences, no explanation, no chain-of-thought, and no extra text."
1568                    .to_string(),
1569            ),
1570            "json_schema" => {
1571                let schema = format.json_schema.as_ref()?.schema.as_ref()?;
1572                let schema_text = serde_json::to_string(schema).ok()?;
1573                Some(format!(
1574                    "The response_format requires a single valid JSON object satisfying this JSON Schema. Output only JSON, with no markdown fences, no explanation, no chain-of-thought, and no extra text. Schema: {schema_text}"
1575                ))
1576            }
1577            _ => None,
1578        };
1579    }
1580    None
1581}
1582
1583fn forced_tool_choice_response_format(
1584    request: &ChatCompletionsRequest,
1585) -> Option<ferrum_types::ResponseFormat> {
1586    let selected_tool = selected_tool_for_forced_tool_choice(request)?;
1587    let schema = guided_tool_arguments_schema(selected_tool.function.parameters.as_ref())?;
1588    serde_json::to_string(&schema)
1589        .ok()
1590        .map(ferrum_types::ResponseFormat::JsonSchema)
1591}
1592
1593fn inferred_auto_tool_response_format(
1594    request: &ChatCompletionsRequest,
1595) -> Option<ferrum_types::ResponseFormat> {
1596    if !tool_choice_auto_or_omitted(request.tool_choice.as_ref()) {
1597        return None;
1598    }
1599    let tool = single_function_tool(request.tools.as_deref()?)?;
1600    let prompt = latest_user_text(request)?;
1601    if !text_mentions_tool(&prompt, &tool.function) {
1602        return None;
1603    }
1604    let schema = serde_json::to_string(&guided_tool_arguments_schema(
1605        tool.function.parameters.as_ref(),
1606    )?)
1607    .ok()?;
1608    Some(ferrum_types::ResponseFormat::JsonSchema(schema))
1609}
1610
1611fn selected_tool_for_forced_tool_choice(request: &ChatCompletionsRequest) -> Option<&ChatTool> {
1612    match request.tool_choice.as_ref()? {
1613        ToolChoice::Function {
1614            tool_type,
1615            function,
1616        } if tool_type == "function" => request
1617            .tools
1618            .as_ref()?
1619            .iter()
1620            .find(|tool| tool.function.name == function.name),
1621        ToolChoice::Mode(mode) if mode.eq_ignore_ascii_case("required") => {
1622            request.tools.as_ref()?.first()
1623        }
1624        _ => None,
1625    }
1626}
1627
1628fn guided_tool_arguments_schema(
1629    parameters: Option<&serde_json::Value>,
1630) -> Option<serde_json::Value> {
1631    let mut schema = parameters?.clone();
1632    bound_unconstrained_tool_argument_strings(
1633        &mut schema,
1634        DEFAULT_GUIDED_TOOL_ARGUMENT_STRING_MAX_LENGTH,
1635    );
1636    Some(schema)
1637}
1638
1639fn bound_unconstrained_tool_argument_strings(value: &mut serde_json::Value, default_max: u64) {
1640    match value {
1641        serde_json::Value::Object(map) => {
1642            let is_string = map
1643                .get("type")
1644                .and_then(serde_json::Value::as_str)
1645                .is_some_and(|ty| ty == "string");
1646            let has_finite_string_shape = map.contains_key("enum") || map.contains_key("maxLength");
1647            if is_string && !has_finite_string_shape {
1648                map.insert(
1649                    "maxLength".to_string(),
1650                    serde_json::Value::Number(default_max.into()),
1651                );
1652            }
1653            if let Some(properties) = map
1654                .get_mut("properties")
1655                .and_then(serde_json::Value::as_object_mut)
1656            {
1657                for property in properties.values_mut() {
1658                    bound_unconstrained_tool_argument_strings(property, default_max);
1659                }
1660            }
1661            if let Some(items) = map.get_mut("items") {
1662                bound_unconstrained_tool_argument_strings(items, default_max);
1663            }
1664        }
1665        serde_json::Value::Array(items) => {
1666            for item in items {
1667                bound_unconstrained_tool_argument_strings(item, default_max);
1668            }
1669        }
1670        _ => {}
1671    }
1672}
1673
1674fn tool_choice_auto_or_omitted(choice: Option<&ToolChoice>) -> bool {
1675    match choice {
1676        None => true,
1677        Some(ToolChoice::Mode(mode)) => mode.eq_ignore_ascii_case("auto"),
1678        _ => false,
1679    }
1680}
1681
1682fn single_function_tool(tools: &[ChatTool]) -> Option<&ChatTool> {
1683    let mut function_tools = tools.iter().filter(|tool| tool.tool_type == "function");
1684    let tool = function_tools.next()?;
1685    function_tools.next().is_none().then_some(tool)
1686}
1687
1688fn latest_user_text(request: &ChatCompletionsRequest) -> Option<String> {
1689    request
1690        .messages
1691        .iter()
1692        .rev()
1693        .find(|message| matches!(message.role, MessageRole::User))
1694        .map(|message| message.content.clone())
1695        .filter(|content| !content.trim().is_empty())
1696}
1697
1698fn text_mentions_tool(text: &str, function: &ChatFunction) -> bool {
1699    let text_lower = text.to_lowercase();
1700    for word in ascii_words(&function.name) {
1701        if text_lower.contains(&word) {
1702            return true;
1703        }
1704    }
1705    if let Some(description) = &function.description {
1706        for word in ascii_words(description) {
1707            if text_lower.contains(&word) {
1708                return true;
1709            }
1710        }
1711        for bigram in cjk_bigrams(description) {
1712            if text.contains(&bigram) {
1713                return true;
1714            }
1715        }
1716    }
1717    false
1718}
1719
1720fn ascii_words(text: &str) -> Vec<String> {
1721    text.split(|ch: char| !ch.is_ascii_alphanumeric())
1722        .filter_map(|word| {
1723            let word = word.to_ascii_lowercase();
1724            (word.len() >= 3).then_some(word)
1725        })
1726        .collect()
1727}
1728
1729fn cjk_bigrams(text: &str) -> Vec<String> {
1730    let chars = text
1731        .chars()
1732        .filter(|ch| matches!(*ch as u32, 0x3400..=0x9fff | 0xf900..=0xfaff))
1733        .collect::<Vec<_>>();
1734    chars
1735        .windows(2)
1736        .map(|window| window.iter().collect::<String>())
1737        .collect()
1738}
1739
1740fn has_unclosed_thinking_block(prompt: &str) -> bool {
1741    match (prompt.rfind(THINK_START_TAG), prompt.rfind(THINK_END_TAG)) {
1742        (Some(start), Some(end)) => start > end,
1743        (Some(_), None) => true,
1744        _ => false,
1745    }
1746}
1747
1748fn should_defer_reasoning_stream_delta(text: &str) -> bool {
1749    let candidate = text.trim_start_matches(['\r', '\n']);
1750    if candidate.is_empty() {
1751        return true;
1752    }
1753    THINK_START_TAG.starts_with(candidate) || THINK_END_TAG.starts_with(candidate)
1754}
1755
1756fn stream_text_delta(text: &str, sent_len: &mut usize) -> String {
1757    if *sent_len <= text.len() && text.is_char_boundary(*sent_len) {
1758        let delta = text[*sent_len..].to_string();
1759        *sent_len = text.len();
1760        return delta;
1761    }
1762    *sent_len = text.len();
1763    String::new()
1764}
1765
1766#[derive(Debug, Clone, PartialEq, Eq)]
1767struct ParsedReasoningResponse {
1768    content: String,
1769    reasoning: Option<String>,
1770}
1771
1772fn parse_reasoning_response(text: &str) -> ParsedReasoningResponse {
1773    let Some(start) = text.find(THINK_START_TAG) else {
1774        if let Some(end) = text.find(THINK_END_TAG) {
1775            let reasoning = text[..end].to_string();
1776            let content = text[end + THINK_END_TAG.len()..]
1777                .trim_start_matches(['\r', '\n'])
1778                .to_string();
1779            return ParsedReasoningResponse {
1780                content,
1781                reasoning: (!reasoning.is_empty()).then_some(reasoning),
1782            };
1783        }
1784        return ParsedReasoningResponse {
1785            content: text.to_string(),
1786            reasoning: None,
1787        };
1788    };
1789
1790    let before = &text[..start];
1791    let after_start = &text[start + THINK_START_TAG.len()..];
1792    let Some(end) = after_start.find(THINK_END_TAG) else {
1793        return ParsedReasoningResponse {
1794            content: before.to_string(),
1795            reasoning: Some(after_start.to_string()),
1796        };
1797    };
1798
1799    let reasoning = after_start[..end].to_string();
1800    let after_end = &after_start[end + THINK_END_TAG.len()..];
1801    let mut content = String::new();
1802    content.push_str(before);
1803    content.push_str(after_end.trim_start_matches(['\r', '\n']));
1804
1805    ParsedReasoningResponse {
1806        content,
1807        reasoning: (!reasoning.is_empty()).then_some(reasoning),
1808    }
1809}
1810
1811fn chat_api_response_from_parsed_generated_text(
1812    chat_request: &ferrum_types::ApiChatRequest,
1813    parsed: &ParsedReasoningResponse,
1814) -> Option<ferrum_types::ApiChatResponse> {
1815    parsed
1816        .reasoning
1817        .as_deref()
1818        .and_then(|reasoning| {
1819            ferrum_types::chat_api_response_from_generated_text(chat_request, reasoning)
1820        })
1821        .or_else(|| {
1822            ferrum_types::chat_api_response_from_generated_text(chat_request, &parsed.content)
1823        })
1824}
1825
1826fn log_required_tool_choice_failure(
1827    request: &ChatCompletionsRequest,
1828    content: &str,
1829    reasoning: Option<&str>,
1830) {
1831    warn!(
1832        model = %request.model,
1833        content_len = content.len(),
1834        content_head = %log_excerpt(content, 512),
1835        reasoning_len = reasoning.map(str::len).unwrap_or(0),
1836        reasoning_head = %reasoning.map(|value| log_excerpt(value, 512)).unwrap_or_default(),
1837        "model output did not satisfy required tool_choice"
1838    );
1839}
1840
1841fn log_excerpt(value: &str, max_chars: usize) -> String {
1842    let mut out = value.chars().take(max_chars).collect::<String>();
1843    if value.chars().count() > max_chars {
1844        out.push_str("...");
1845    }
1846    out
1847}
1848
1849fn normalize_structured_response_content(
1850    request: &ChatCompletionsRequest,
1851    content: &str,
1852) -> String {
1853    let Some(response_format) = request.response_format.as_ref() else {
1854        return content.to_string();
1855    };
1856    match response_format.format_type.as_str() {
1857        "json_object" => extract_json_object_text(content)
1858            .unwrap_or_else(|| strip_markdown_json_fence(content).to_string()),
1859        "json_schema"
1860            if !response_format
1861                .json_schema
1862                .as_ref()
1863                .and_then(|schema| schema.strict)
1864                .unwrap_or(false) =>
1865        {
1866            extract_json_object_text(content)
1867                .unwrap_or_else(|| strip_markdown_json_fence(content).to_string())
1868        }
1869        _ => content.to_string(),
1870    }
1871}
1872
1873fn response_format_is_json_object(request: &ChatCompletionsRequest) -> bool {
1874    request
1875        .response_format
1876        .as_ref()
1877        .is_some_and(|format| format.format_type == "json_object")
1878}
1879
1880fn extract_json_object_text(text: &str) -> Option<String> {
1881    let text = strip_markdown_json_fence(text.trim());
1882    if serde_json::from_str::<serde_json::Value>(&text)
1883        .ok()
1884        .filter(|value| value.is_object())
1885        .is_some()
1886    {
1887        return Some(text.to_string());
1888    }
1889
1890    let start = text.find('{')?;
1891    let mut depth = 0usize;
1892    let mut in_string = false;
1893    let mut escaped = false;
1894    for (offset, ch) in text[start..].char_indices() {
1895        if in_string {
1896            if escaped {
1897                escaped = false;
1898            } else if ch == '\\' {
1899                escaped = true;
1900            } else if ch == '"' {
1901                in_string = false;
1902            }
1903            continue;
1904        }
1905        match ch {
1906            '"' => in_string = true,
1907            '{' => depth += 1,
1908            '}' => {
1909                depth = depth.saturating_sub(1);
1910                if depth == 0 {
1911                    let end = start + offset + ch.len_utf8();
1912                    let candidate = &text[start..end];
1913                    if serde_json::from_str::<serde_json::Value>(candidate)
1914                        .ok()
1915                        .filter(|value| value.is_object())
1916                        .is_some()
1917                    {
1918                        return Some(candidate.to_string());
1919                    }
1920                }
1921            }
1922            _ => {}
1923        }
1924    }
1925    None
1926}
1927
1928fn api_chat_request(
1929    request: &ChatCompletionsRequest,
1930    effective_tool_choice: Option<&ToolChoice>,
1931) -> ferrum_types::ApiChatRequest {
1932    ferrum_types::ApiChatRequest {
1933        messages: request.messages.iter().map(api_chat_message).collect(),
1934        tools: request
1935            .tools
1936            .as_deref()
1937            .unwrap_or_default()
1938            .iter()
1939            .map(api_tool)
1940            .collect(),
1941        tool_choice: effective_tool_choice.map(api_tool_choice),
1942        legacy_functions: request
1943            .functions
1944            .as_deref()
1945            .unwrap_or_default()
1946            .iter()
1947            .map(api_function)
1948            .collect(),
1949        legacy_function_call: request.function_call.as_ref().map(api_function_call_choice),
1950        response_format: request.response_format.as_ref().map(api_response_format),
1951        stream_options: request.stream_options.as_ref().map(|opts| {
1952            ferrum_types::ApiStreamOptions {
1953                include_usage: opts.include_usage,
1954            }
1955        }),
1956    }
1957}
1958
1959fn chat_completion_max_tokens(request: &ChatCompletionsRequest) -> u32 {
1960    request
1961        .max_completion_tokens
1962        .or(request.max_tokens)
1963        .unwrap_or(DEFAULT_COMPLETION_MAX_TOKENS)
1964}
1965
1966fn api_chat_message(message: &ChatMessage) -> ferrum_types::ApiChatMessage {
1967    ferrum_types::ApiChatMessage {
1968        role: match message.role {
1969            MessageRole::System => ferrum_types::ApiMessageRole::System,
1970            MessageRole::User => ferrum_types::ApiMessageRole::User,
1971            MessageRole::Assistant => ferrum_types::ApiMessageRole::Assistant,
1972            MessageRole::Function => ferrum_types::ApiMessageRole::Function,
1973            MessageRole::Tool => ferrum_types::ApiMessageRole::Tool,
1974        },
1975        content: message.content.clone(),
1976        name: message.name.clone(),
1977        tool_calls: message
1978            .tool_calls
1979            .as_deref()
1980            .unwrap_or_default()
1981            .iter()
1982            .map(api_tool_call)
1983            .collect(),
1984        tool_call_id: message.tool_call_id.clone(),
1985        function_call: message.function_call.as_ref().map(api_function_call),
1986    }
1987}
1988
1989fn api_tool(tool: &ChatTool) -> ferrum_types::ApiTool {
1990    ferrum_types::ApiTool {
1991        tool_type: tool.tool_type.clone(),
1992        function: api_function(&tool.function),
1993    }
1994}
1995
1996fn api_function(function: &ChatFunction) -> ferrum_types::ApiFunction {
1997    ferrum_types::ApiFunction {
1998        name: function.name.clone(),
1999        description: function.description.clone(),
2000        parameters: function.parameters.clone(),
2001        strict: function.strict,
2002    }
2003}
2004
2005fn api_tool_choice(choice: &ToolChoice) -> ferrum_types::ApiToolChoice {
2006    match choice {
2007        ToolChoice::Mode(mode) => ferrum_types::ApiToolChoice::Mode(mode.clone()),
2008        ToolChoice::Function {
2009            tool_type,
2010            function,
2011        } => ferrum_types::ApiToolChoice::Function {
2012            tool_type: tool_type.clone(),
2013            function: ferrum_types::ApiToolChoiceFunction {
2014                name: function.name.clone(),
2015            },
2016        },
2017    }
2018}
2019
2020fn api_function_call_choice(choice: &FunctionCallChoice) -> ferrum_types::ApiFunctionCallChoice {
2021    match choice {
2022        FunctionCallChoice::Mode(mode) => ferrum_types::ApiFunctionCallChoice::Mode(mode.clone()),
2023        FunctionCallChoice::Function { name } => {
2024            ferrum_types::ApiFunctionCallChoice::Function { name: name.clone() }
2025        }
2026    }
2027}
2028
2029fn api_tool_call(tool_call: &ChatToolCall) -> ferrum_types::ApiToolCall {
2030    ferrum_types::ApiToolCall {
2031        id: tool_call.id.clone(),
2032        tool_type: tool_call.tool_type.clone(),
2033        function: api_function_call(&tool_call.function),
2034    }
2035}
2036
2037fn api_function_call(function_call: &ChatFunctionCall) -> ferrum_types::ApiFunctionCall {
2038    ferrum_types::ApiFunctionCall {
2039        name: function_call.name.clone(),
2040        arguments: function_call.arguments.clone(),
2041    }
2042}
2043
2044fn openai_chat_message_from_api(message: &ferrum_types::ApiChatMessage) -> ChatMessage {
2045    ChatMessage {
2046        role: openai_message_role_from_api(message.role),
2047        content: message.content.clone(),
2048        reasoning: None,
2049        name: message.name.clone(),
2050        tool_calls: if message.tool_calls.is_empty() {
2051            None
2052        } else {
2053            Some(
2054                message
2055                    .tool_calls
2056                    .iter()
2057                    .map(openai_tool_call_from_api)
2058                    .collect(),
2059            )
2060        },
2061        tool_call_id: message.tool_call_id.clone(),
2062        function_call: message
2063            .function_call
2064            .as_ref()
2065            .map(openai_function_call_from_api),
2066    }
2067}
2068
2069fn openai_message_role_from_api(role: ferrum_types::ApiMessageRole) -> MessageRole {
2070    match role {
2071        ferrum_types::ApiMessageRole::System => MessageRole::System,
2072        ferrum_types::ApiMessageRole::User => MessageRole::User,
2073        ferrum_types::ApiMessageRole::Assistant => MessageRole::Assistant,
2074        ferrum_types::ApiMessageRole::Function => MessageRole::Function,
2075        ferrum_types::ApiMessageRole::Tool => MessageRole::Tool,
2076    }
2077}
2078
2079fn openai_tool_call_from_api(tool_call: &ferrum_types::ApiToolCall) -> ChatToolCall {
2080    ChatToolCall {
2081        index: None,
2082        id: tool_call.id.clone(),
2083        tool_type: tool_call.tool_type.clone(),
2084        function: openai_function_call_from_api(&tool_call.function),
2085    }
2086}
2087
2088fn openai_tool_call_delta_from_api(
2089    index: usize,
2090    tool_call: &ferrum_types::ApiToolCall,
2091) -> ChatToolCall {
2092    ChatToolCall {
2093        index: Some(usize_to_u32_saturating(index)),
2094        id: tool_call.id.clone(),
2095        tool_type: tool_call.tool_type.clone(),
2096        function: openai_function_call_from_api(&tool_call.function),
2097    }
2098}
2099
2100fn openai_chat_delta_from_api(message: &ferrum_types::ApiChatMessage) -> ChatMessage {
2101    let mut delta = openai_chat_message_from_api(message);
2102    if !message.tool_calls.is_empty() {
2103        delta.tool_calls = Some(
2104            message
2105                .tool_calls
2106                .iter()
2107                .enumerate()
2108                .map(|(index, call)| openai_tool_call_delta_from_api(index, call))
2109                .collect(),
2110        );
2111    }
2112    delta
2113}
2114
2115fn openai_function_call_from_api(
2116    function_call: &ferrum_types::ApiFunctionCall,
2117) -> ChatFunctionCall {
2118    ChatFunctionCall {
2119        name: function_call.name.clone(),
2120        arguments: function_call.arguments.clone(),
2121    }
2122}
2123
2124fn api_response_format(format: &OpenAiResponseFormat) -> ferrum_types::ApiResponseFormat {
2125    ferrum_types::ApiResponseFormat {
2126        format_type: format.format_type.clone(),
2127        json_schema: format
2128            .json_schema
2129            .as_ref()
2130            .map(|schema| ferrum_types::ApiJsonSchema {
2131                name: schema.name.clone(),
2132                schema: schema.schema.clone().unwrap_or(serde_json::Value::Null),
2133                strict: schema.strict,
2134            }),
2135    }
2136}
2137
2138fn validate_chat_request(request: &ChatCompletionsRequest) -> std::result::Result<(), ServerError> {
2139    if request.messages.is_empty() {
2140        return Err(ServerError::invalid_request(
2141            "messages array must not be empty",
2142            Some("messages"),
2143        ));
2144    }
2145
2146    if let Some(n) = request.n {
2147        if n != 1 {
2148            return Err(ServerError::unsupported_feature(
2149                "only n=1 is supported for chat completions",
2150                Some("n"),
2151            ));
2152        }
2153    }
2154
2155    if request
2156        .logit_bias
2157        .as_ref()
2158        .is_some_and(|bias| !bias.is_empty())
2159    {
2160        return Err(ServerError::unsupported_feature(
2161            "logit_bias is not supported",
2162            Some("logit_bias"),
2163        ));
2164    }
2165    if request.logprobs.unwrap_or(false) {
2166        return Err(ServerError::unsupported_feature(
2167            "logprobs is not supported",
2168            Some("logprobs"),
2169        ));
2170    }
2171    if request.top_logprobs.unwrap_or(0) > 0 {
2172        return Err(ServerError::unsupported_feature(
2173            "top_logprobs is not supported",
2174            Some("top_logprobs"),
2175        ));
2176    }
2177
2178    if request.stream_options.is_some() && !request.stream.unwrap_or(false) {
2179        return Err(ServerError::invalid_request(
2180            "stream_options is only valid when stream=true",
2181            Some("stream_options"),
2182        ));
2183    }
2184    ensure_response_format_supported(request)?;
2185
2186    if let Some(tools) = &request.tools {
2187        for tool in tools {
2188            if tool.tool_type != "function" {
2189                return Err(ServerError::unsupported_feature(
2190                    "only function tools are supported",
2191                    Some("tools"),
2192                ));
2193            }
2194        }
2195    }
2196
2197    if let Some(choice) = &request.tool_choice {
2198        match choice {
2199            ToolChoice::Mode(mode)
2200                if mode.eq_ignore_ascii_case("auto") || mode.eq_ignore_ascii_case("none") => {}
2201            ToolChoice::Mode(mode) if mode.eq_ignore_ascii_case("required") => {
2202                if request.tools.as_deref().unwrap_or_default().is_empty() {
2203                    return Err(ServerError::invalid_request(
2204                        "tool_choice=required requires at least one function tool",
2205                        Some("tool_choice"),
2206                    ));
2207                }
2208            }
2209            ToolChoice::Mode(_) => {
2210                return Err(ServerError::unsupported_feature(
2211                    "unsupported tool_choice mode",
2212                    Some("tool_choice"),
2213                ));
2214            }
2215            ToolChoice::Function {
2216                tool_type,
2217                function,
2218            } => {
2219                if tool_type != "function" {
2220                    return Err(ServerError::unsupported_feature(
2221                        "only function tool_choice is supported",
2222                        Some("tool_choice"),
2223                    ));
2224                }
2225                let declared = request
2226                    .tools
2227                    .as_deref()
2228                    .unwrap_or_default()
2229                    .iter()
2230                    .any(|tool| tool.function.name == function.name);
2231                if !declared {
2232                    return Err(ServerError::invalid_request(
2233                        "tool_choice selects a function that is not declared in tools",
2234                        Some("tool_choice"),
2235                    ));
2236                }
2237            }
2238        }
2239    }
2240
2241    if let Some(choice) = &request.function_call {
2242        match choice {
2243            FunctionCallChoice::Mode(mode)
2244                if mode.eq_ignore_ascii_case("auto") || mode.eq_ignore_ascii_case("none") => {}
2245            FunctionCallChoice::Mode(_) => {
2246                return Err(ServerError::unsupported_feature(
2247                    "unsupported function_call mode",
2248                    Some("function_call"),
2249                ));
2250            }
2251            FunctionCallChoice::Function { name } => {
2252                let declared = request
2253                    .functions
2254                    .as_deref()
2255                    .unwrap_or_default()
2256                    .iter()
2257                    .any(|function| function.name == *name);
2258                if !declared {
2259                    return Err(ServerError::invalid_request(
2260                        "function_call selects a function that is not declared in functions",
2261                        Some("function_call"),
2262                    ));
2263                }
2264            }
2265        }
2266    }
2267
2268    Ok(())
2269}
2270
2271fn tool_choice_required(request: &ChatCompletionsRequest) -> bool {
2272    match request.tool_choice.as_ref() {
2273        Some(ToolChoice::Mode(mode)) if mode.eq_ignore_ascii_case("required") => true,
2274        Some(ToolChoice::Function {
2275            tool_type,
2276            function,
2277        }) => {
2278            tool_type == "function"
2279                && request
2280                    .tools
2281                    .as_deref()
2282                    .unwrap_or_default()
2283                    .iter()
2284                    .any(|tool| tool.function.name == function.name)
2285        }
2286        _ => false,
2287    }
2288}
2289
2290fn openai_usage_from_token_usage(usage: &TokenUsage) -> Usage {
2291    let prompt_tokens = usize_to_u32_saturating(usage.prompt_tokens);
2292    let completion_tokens = usize_to_u32_saturating(usage.completion_tokens);
2293    let total_tokens = usize_to_u32_saturating(usage.total_tokens);
2294    Usage {
2295        prompt_tokens,
2296        completion_tokens,
2297        total_tokens,
2298    }
2299}
2300
2301fn usize_to_u32_saturating(value: usize) -> u32 {
2302    u32::try_from(value).unwrap_or(u32::MAX)
2303}
2304
2305fn ensure_response_format_supported(
2306    request: &ChatCompletionsRequest,
2307) -> std::result::Result<(), ServerError> {
2308    if let Some(rf) = &request.response_format {
2309        match rf.format_type.as_str() {
2310            "text" | "json_object" => {}
2311            "json_schema" => {
2312                let Some(schema_json) = strict_json_schema_string(request)? else {
2313                    if rf.json_schema.is_none() {
2314                        return Err(ServerError::invalid_request(
2315                            "response_format.json_schema.schema is required",
2316                            Some("response_format.json_schema"),
2317                        ));
2318                    }
2319                    return Ok(());
2320                };
2321                ferrum_sampler::schema_to_regex::schema_to_regex(&schema_json).map_err(|e| {
2322                    ServerError::unsupported_feature(
2323                        format!("unsupported strict json_schema: {e}"),
2324                        Some("response_format.json_schema"),
2325                    )
2326                })?;
2327            }
2328            _ => {
2329                return Err(ServerError::invalid_request(
2330                    "unsupported response_format.type",
2331                    Some("response_format.type"),
2332                ));
2333            }
2334        }
2335    }
2336    Ok(())
2337}
2338
2339fn strict_json_schema_string(
2340    request: &ChatCompletionsRequest,
2341) -> std::result::Result<Option<String>, ServerError> {
2342    let Some(rf) = &request.response_format else {
2343        return Ok(None);
2344    };
2345    if rf.format_type != "json_schema" {
2346        return Ok(None);
2347    }
2348    let Some(schema) = &rf.json_schema else {
2349        return Err(ServerError::invalid_request(
2350            "response_format.json_schema.schema is required",
2351            Some("response_format.json_schema"),
2352        ));
2353    };
2354    let Some(schema_value) = schema.schema.as_ref() else {
2355        return Err(ServerError::invalid_request(
2356            "response_format.json_schema.schema is required",
2357            Some("response_format.json_schema"),
2358        ));
2359    };
2360    if !schema.strict.unwrap_or(false) {
2361        return Ok(None);
2362    }
2363    serde_json::to_string(schema_value).map(Some).map_err(|e| {
2364        ServerError::invalid_request(e.to_string(), Some("response_format.json_schema"))
2365    })
2366}
2367
2368fn validate_strict_json_schema_response(
2369    request: &ChatCompletionsRequest,
2370    content: &str,
2371) -> std::result::Result<(), ServerError> {
2372    let Some(schema_json) = strict_json_schema_string(request)? else {
2373        return Ok(());
2374    };
2375    let _parsed_json: serde_json::Value = serde_json::from_str(content).map_err(|e| {
2376        ServerError::InternalError(format!(
2377            "model output did not satisfy response_format.json_schema.strict: invalid JSON: {e}"
2378        ))
2379    })?;
2380    let pattern = ferrum_sampler::schema_to_regex::schema_to_regex(&schema_json).map_err(|e| {
2381        ServerError::InternalError(format!(
2382            "strict json_schema translator failed after validation: {e}"
2383        ))
2384    })?;
2385    let regex = regex_lite::Regex::new(&format!("^(?:{pattern})$")).map_err(|e| {
2386        ServerError::InternalError(format!("strict json_schema validator build failed: {e}"))
2387    })?;
2388    if !regex.is_match(content) {
2389        return Err(ServerError::InternalError(
2390            "model output did not satisfy response_format.json_schema.strict".to_string(),
2391        ));
2392    }
2393    Ok(())
2394}
2395
2396fn strict_stream_validation_error_message(error: ServerError) -> String {
2397    match error {
2398        ServerError::InternalError(message)
2399        | ServerError::NotImplemented(message)
2400        | ServerError::ServiceUnavailable(message)
2401        | ServerError::InvalidRequest { message, .. }
2402        | ServerError::UnsupportedFeature { message, .. } => message,
2403    }
2404}
2405
2406fn server_error_from_ferrum_error(error: Error) -> ServerError {
2407    match error {
2408        Error::RequestValidation { message } => ServerError::invalid_request(message, None),
2409        Error::ResourceExhausted { message } => ServerError::ServiceUnavailable(message),
2410        other => ServerError::InternalError(other.to_string()),
2411    }
2412}
2413
2414fn stream_error_payload(
2415    message: impl Into<String>,
2416    error_type: &str,
2417    param: Option<&str>,
2418) -> OpenAiError {
2419    OpenAiError {
2420        error: OpenAiErrorDetail {
2421            message: message.into(),
2422            error_type: error_type.to_string(),
2423            param: param.map(str::to_string),
2424            code: None,
2425        },
2426    }
2427}
2428
2429fn openai_error_sse_event(
2430    message: impl Into<String>,
2431    error_type: &str,
2432    param: Option<&str>,
2433) -> Event {
2434    Event::default()
2435        .json_data(&stream_error_payload(message, error_type, param))
2436        .unwrap_or_else(|_| Event::default().data("error"))
2437}
2438
2439fn convert_completion_request(request: &CompletionsRequest) -> InferenceRequest {
2440    let prompt = request
2441        .prompt
2442        .as_text()
2443        .expect("completion prompt validated before conversion");
2444    InferenceRequest {
2445        id: RequestId(Uuid::new_v4()),
2446        model_id: ModelId(request.model.clone()),
2447        prompt: prompt.to_string(),
2448        sampling_params: SamplingParams {
2449            max_tokens: request.max_tokens.unwrap_or(DEFAULT_COMPLETION_MAX_TOKENS) as usize,
2450            temperature: request.temperature.unwrap_or(DEFAULT_SAMPLING_TEMPERATURE),
2451            top_p: request.top_p.unwrap_or(DEFAULT_SAMPLING_TOP_P),
2452            top_k: None,
2453            repetition_penalty: 1.0,
2454            presence_penalty: 0.0,
2455            frequency_penalty: 0.0,
2456            stop_sequences: request.stop.clone().unwrap_or_default(),
2457            seed: None,
2458            min_p: None,
2459            tfs: None,
2460            typical_p: None,
2461            mirostat: None,
2462            response_format: ferrum_types::ResponseFormat::Text,
2463        },
2464        stream: request.stream.unwrap_or(false),
2465        priority: Priority::Normal,
2466        client_id: None,
2467        session_id: None,
2468        created_at: chrono::Utc::now(),
2469        api_request: Some(ferrum_types::ApiRequest::Completion(
2470            ferrum_types::ApiCompletionRequest {
2471                prompt: prompt.to_string(),
2472                response_format: None,
2473            },
2474        )),
2475        metadata: if request.max_tokens.is_none() {
2476            HashMap::from([(
2477                DEFAULT_MAX_TOKENS_METADATA_KEY.to_string(),
2478                serde_json::json!(true),
2479            )])
2480        } else {
2481            HashMap::new()
2482        },
2483    }
2484}
2485
2486fn apply_lora_resolution(
2487    inference_request: &mut InferenceRequest,
2488    resolution: Option<&LoraModelResolution>,
2489) {
2490    let Some(resolution) = resolution else {
2491        return;
2492    };
2493    if let Some(adapter) = &resolution.adapter {
2494        inference_request.model_id = ModelId(resolution.base_model_id.clone());
2495        inference_request.metadata.insert(
2496            "ferrum_lora_adapter".to_string(),
2497            serde_json::json!(adapter.name),
2498        );
2499        inference_request.metadata.insert(
2500            "ferrum_lora_model_id".to_string(),
2501            serde_json::json!(adapter.model_id),
2502        );
2503        inference_request.metadata.insert(
2504            "ferrum_lora_path".to_string(),
2505            serde_json::json!(adapter.path),
2506        );
2507    }
2508}
2509
2510async fn handle_completions_sync(
2511    state: AppState,
2512    openai_request: CompletionsRequest,
2513    inference_request: InferenceRequest,
2514) -> std::result::Result<Response, ServerError> {
2515    let engine = state.llm.clone().ok_or_else(|| {
2516        ServerError::ServiceUnavailable("LLM engine not loaded; completions unavailable".into())
2517    })?;
2518    match engine.infer(inference_request).await {
2519        Ok(output) => {
2520            let InferenceResponse {
2521                text: output_text,
2522                finish_reason,
2523                usage,
2524                api_response,
2525                ..
2526            } = output;
2527            let stop_sequences = openai_request.stop.clone().unwrap_or_default();
2528            let mut text = strip_after_stop(&output_text, &stop_sequences);
2529            let mut openai_finish_reason = finish_reason_to_string(&finish_reason);
2530            if let Some(ferrum_types::ApiResponse::Completion(completion_response)) =
2531                api_response.as_ref()
2532            {
2533                text = strip_after_stop(&completion_response.text, &stop_sequences);
2534                if let Some(reason) = &completion_response.finish_reason {
2535                    openai_finish_reason = reason.clone();
2536                }
2537            }
2538            let response = CompletionsResponse {
2539                id: Uuid::new_v4().to_string(),
2540                object: "text_completion".to_string(),
2541                created: chrono::Utc::now().timestamp() as u64,
2542                model: openai_request.model,
2543                choices: vec![CompletionChoice {
2544                    text,
2545                    index: 0,
2546                    finish_reason: Some(openai_finish_reason),
2547                }],
2548                usage: Some(openai_usage_from_token_usage(&usage)),
2549            };
2550            Ok(Json(response).into_response())
2551        }
2552        Err(e) => {
2553            error!("Completion generation failed: {}", e);
2554            Err(ServerError::InternalError(e.to_string()))
2555        }
2556    }
2557}
2558
2559async fn handle_completions_stream(
2560    state: AppState,
2561    openai_request: CompletionsRequest,
2562    inference_request: InferenceRequest,
2563) -> std::result::Result<Response, ServerError> {
2564    let (tx, rx) = mpsc::unbounded_channel::<std::result::Result<Event, axum::Error>>();
2565    let engine = state.llm.clone().ok_or_else(|| {
2566        ServerError::ServiceUnavailable("LLM engine not loaded; completions unavailable".into())
2567    })?;
2568    let request_id = Uuid::new_v4().to_string();
2569
2570    tokio::spawn(async move {
2571        match engine.infer_stream(inference_request).await {
2572            Ok(mut stream) => {
2573                while let Some(result) = stream.next().await {
2574                    match result {
2575                        Ok(chunk) => {
2576                            let response_chunk = CompletionsResponse {
2577                                id: request_id.clone(),
2578                                object: "text_completion".to_string(),
2579                                created: chrono::Utc::now().timestamp() as u64,
2580                                model: openai_request.model.clone(),
2581                                choices: vec![CompletionChoice {
2582                                    text: chunk.text.clone(),
2583                                    index: 0,
2584                                    finish_reason: chunk
2585                                        .finish_reason
2586                                        .as_ref()
2587                                        .map(finish_reason_to_string),
2588                                }],
2589                                usage: None,
2590                            };
2591                            let event = Event::default()
2592                                .json_data(&response_chunk)
2593                                .unwrap_or_else(|_| Event::default().data("error"));
2594                            if tx.send(Ok(event)).is_err() {
2595                                break;
2596                            }
2597                            if chunk.finish_reason.is_some() {
2598                                if let Some(usage) =
2599                                    chunk.usage.as_ref().map(openai_usage_from_token_usage)
2600                                {
2601                                    let final_chunk = CompletionsResponse {
2602                                        id: request_id.clone(),
2603                                        object: "text_completion".to_string(),
2604                                        created: chrono::Utc::now().timestamp() as u64,
2605                                        model: openai_request.model.clone(),
2606                                        choices: vec![],
2607                                        usage: Some(usage),
2608                                    };
2609                                    let event = Event::default()
2610                                        .json_data(&final_chunk)
2611                                        .unwrap_or_else(|_| Event::default().data("error"));
2612                                    let _ = tx.send(Ok(event));
2613                                }
2614                                let _ = tx.send(Ok(Event::default().data("[DONE]")));
2615                                break;
2616                            }
2617                        }
2618                        Err(e) => {
2619                            error!("Completion stream generation error: {}", e);
2620                            let _ = tx.send(Ok(openai_error_sse_event(
2621                                e.to_string(),
2622                                "internal_server_error",
2623                                None,
2624                            )));
2625                            let _ = tx.send(Ok(Event::default().data("[DONE]")));
2626                            break;
2627                        }
2628                    }
2629                }
2630            }
2631            Err(e) => {
2632                error!("Failed to start completion stream: {}", e);
2633                let _ = tx.send(Ok(openai_error_sse_event(
2634                    e.to_string(),
2635                    "internal_server_error",
2636                    None,
2637                )));
2638                let _ = tx.send(Ok(Event::default().data("[DONE]")));
2639            }
2640        }
2641    });
2642
2643    let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
2644    Ok(Sse::new(stream).into_response())
2645}
2646
2647/// Other handlers
2648async fn completions_handler(
2649    State(state): State<AppState>,
2650    request: std::result::Result<Json<CompletionsRequest>, JsonRejection>,
2651) -> std::result::Result<Response, ServerError> {
2652    let Json(request) = request.map_err(|e| {
2653        ServerError::invalid_request(format!("invalid completions request: {e}"), None)
2654    })?;
2655    validate_completion_request(&request)?;
2656    let loaded_models = state.status().await.loaded_models;
2657    let lora_resolution = state
2658        .lora_registry
2659        .resolve(&request.model, &loaded_models)?;
2660    let mut inference_request = convert_completion_request(&request);
2661    apply_lora_resolution(&mut inference_request, lora_resolution.as_ref());
2662    if request.stream.unwrap_or(false) {
2663        handle_completions_stream(state, request, inference_request).await
2664    } else {
2665        handle_completions_sync(state, request, inference_request).await
2666    }
2667}
2668
2669fn validate_completion_request(
2670    request: &CompletionsRequest,
2671) -> std::result::Result<(), ServerError> {
2672    if request.prompt.as_text().is_none() {
2673        return Err(ServerError::invalid_request(
2674            "only string prompt is supported for completions",
2675            Some("prompt"),
2676        ));
2677    }
2678    if let Some(n) = request.n {
2679        if n != 1 {
2680            return Err(ServerError::unsupported_feature(
2681                "only n=1 is supported for completions",
2682                Some("n"),
2683            ));
2684        }
2685    }
2686    if request.logprobs.is_some() {
2687        return Err(ServerError::unsupported_feature(
2688            "logprobs is not supported for completions",
2689            Some("logprobs"),
2690        ));
2691    }
2692    if request
2693        .logit_bias
2694        .as_ref()
2695        .is_some_and(|bias| !bias.is_empty())
2696    {
2697        return Err(ServerError::unsupported_feature(
2698            "logit_bias is not supported",
2699            Some("logit_bias"),
2700        ));
2701    }
2702    Ok(())
2703}
2704
2705/// Embeddings handler — text and image embedding via OpenAI-compatible API.
2706async fn embeddings_handler(
2707    State(state): State<AppState>,
2708    request: std::result::Result<Json<EmbeddingsRequest>, JsonRejection>,
2709) -> std::result::Result<Response, ServerError> {
2710    let Json(request) = request.map_err(|e| {
2711        ServerError::invalid_request(format!("invalid embeddings request: {e}"), None)
2712    })?;
2713
2714    let span = span!(Level::INFO, "embeddings", model = %request.model);
2715    let _enter = span.enter();
2716
2717    validate_embeddings_request(&request)?;
2718
2719    // Flatten input into individual items
2720    let items: Vec<EmbeddingItem> = match request.input {
2721        EmbeddingInput::Single(text) => vec![EmbeddingItem {
2722            text: Some(text),
2723            image: None,
2724        }],
2725        EmbeddingInput::Batch(texts) => texts
2726            .into_iter()
2727            .map(|t| EmbeddingItem {
2728                text: Some(t),
2729                image: None,
2730            })
2731            .collect(),
2732        EmbeddingInput::SingleObject(item) => vec![item],
2733        EmbeddingInput::BatchObjects(items) => items,
2734    };
2735
2736    if items.is_empty() {
2737        return Err(ServerError::invalid_request(
2738            "input must not be empty",
2739            Some("input"),
2740        ));
2741    }
2742
2743    let mut data = Vec::with_capacity(items.len());
2744    let mut total_tokens = 0u32;
2745
2746    let engine = state.embed.as_ref().ok_or_else(|| {
2747        ServerError::NotImplemented("Embed engine not loaded; embeddings unavailable".into())
2748    })?;
2749    for (idx, item) in items.iter().enumerate() {
2750        let embedding = if let Some(ref image) = item.image {
2751            engine
2752                .embed_image(image)
2753                .await
2754                .map_err(|e| ServerError::InternalError(format!("embed_image: {e}")))?
2755        } else if let Some(ref text) = item.text {
2756            total_tokens += text.len() as u32;
2757            engine
2758                .embed_text(text)
2759                .await
2760                .map_err(|e| ServerError::InternalError(format!("embed_text: {e}")))?
2761        } else {
2762            return Err(ServerError::invalid_request(
2763                "each input item must have either text or image",
2764                Some("input"),
2765            ));
2766        };
2767
2768        data.push(EmbeddingData {
2769            object: "embedding".to_string(),
2770            embedding,
2771            index: idx,
2772        });
2773    }
2774
2775    let response = EmbeddingsResponse {
2776        object: "list".to_string(),
2777        data,
2778        model: request.model,
2779        usage: EmbeddingUsage {
2780            prompt_tokens: total_tokens,
2781            total_tokens,
2782        },
2783    };
2784
2785    Ok(Json(response).into_response())
2786}
2787
2788fn validate_embeddings_request(
2789    request: &EmbeddingsRequest,
2790) -> std::result::Result<(), ServerError> {
2791    if let Some(format) = request.encoding_format.as_deref() {
2792        if !format.eq_ignore_ascii_case("float") {
2793            return Err(ServerError::unsupported_feature(
2794                "only encoding_format=float is supported for embeddings",
2795                Some("encoding_format"),
2796            ));
2797        }
2798    }
2799    Ok(())
2800}
2801
2802/// Audio transcription handler (OpenAI-compatible multipart form).
2803async fn transcriptions_handler(
2804    State(state): State<AppState>,
2805    multipart: std::result::Result<axum::extract::Multipart, MultipartRejection>,
2806) -> std::result::Result<Response, ServerError> {
2807    let mut multipart = multipart.map_err(|e| {
2808        ServerError::invalid_request(format!("invalid transcriptions request: {e}"), None)
2809    })?;
2810
2811    let span = span!(Level::INFO, "transcription");
2812    let _enter = span.enter();
2813
2814    let mut file_data: Option<Vec<u8>> = None;
2815    let mut language: Option<String> = None;
2816    let mut response_format: Option<String> = None;
2817
2818    while let Some(field) = multipart
2819        .next_field()
2820        .await
2821        .map_err(|e| ServerError::invalid_request(format!("multipart: {e}"), None))?
2822    {
2823        let name = field.name().unwrap_or("").to_string();
2824        match name.as_str() {
2825            "file" => {
2826                file_data = Some(
2827                    field
2828                        .bytes()
2829                        .await
2830                        .map_err(|e| {
2831                            ServerError::invalid_request(format!("read file: {e}"), Some("file"))
2832                        })?
2833                        .to_vec(),
2834                );
2835            }
2836            "language" => {
2837                language = field.text().await.ok().filter(|s| !s.is_empty());
2838            }
2839            "response_format" => {
2840                response_format = field.text().await.ok().filter(|s| !s.is_empty());
2841            }
2842            _ => {} // ignore model and other optional multipart fields for now
2843        }
2844    }
2845
2846    validate_transcription_response_format(response_format.as_deref())?;
2847
2848    let data = file_data
2849        .ok_or_else(|| ServerError::invalid_request("missing file field", Some("file")))?;
2850
2851    let engine = state.transcribe.as_ref().ok_or_else(|| {
2852        ServerError::NotImplemented("Transcribe engine not loaded; ASR unavailable".into())
2853    })?;
2854    let text = engine
2855        .transcribe_bytes(&data, language.as_deref())
2856        .await
2857        .map_err(|e| ServerError::InternalError(format!("transcribe: {e}")))?;
2858
2859    Ok(Json(TranscriptionResponse { text }).into_response())
2860}
2861
2862fn validate_transcription_response_format(
2863    response_format: Option<&str>,
2864) -> std::result::Result<(), ServerError> {
2865    if let Some(format) = response_format {
2866        if !format.eq_ignore_ascii_case("json") {
2867            return Err(ServerError::unsupported_feature(
2868                "only response_format=json is supported for transcriptions",
2869                Some("response_format"),
2870            ));
2871        }
2872    }
2873    Ok(())
2874}
2875
2876/// TTS speech synthesis handler (OpenAI-compatible /v1/audio/speech)
2877async fn speech_handler(
2878    State(state): State<AppState>,
2879    request: std::result::Result<Json<SpeechRequest>, JsonRejection>,
2880) -> std::result::Result<Response, ServerError> {
2881    let Json(request) = request
2882        .map_err(|e| ServerError::invalid_request(format!("invalid speech request: {e}"), None))?;
2883
2884    let response_format = speech_output_format(&request)?;
2885
2886    let span = span!(Level::INFO, "speech");
2887    let _guard = span.enter();
2888
2889    let language = if request.language.is_empty() || request.language == "auto" {
2890        None
2891    } else {
2892        Some(request.language.as_str())
2893    };
2894
2895    let chunk_frames = 10usize;
2896    let tts = state.tts.as_ref().ok_or_else(|| {
2897        ServerError::NotImplemented("TTS engine not loaded; speech unavailable".into())
2898    })?;
2899    let sample_rate = tts.tts_sample_rate();
2900
2901    if request.stream {
2902        // Streaming: chunked transfer encoding with WAV audio
2903        let (tx, rx) =
2904            mpsc::unbounded_channel::<std::result::Result<axum::body::Bytes, std::io::Error>>();
2905
2906        let engine = tts.clone();
2907        let text = request.input.clone();
2908        let lang = request.language.clone();
2909
2910        tokio::task::spawn_blocking(move || {
2911            let lang_opt = if lang.is_empty() || lang == "auto" {
2912                None
2913            } else {
2914                Some(lang.as_str())
2915            };
2916            let rt = tokio::runtime::Handle::current();
2917
2918            match rt.block_on(engine.synthesize_speech(&text, lang_opt, chunk_frames)) {
2919                Ok(chunks) => {
2920                    for chunk in &chunks {
2921                        let audio_bytes = encode_speech_audio(chunk, sample_rate, response_format);
2922                        let _ = tx.send(Ok(axum::body::Bytes::from(audio_bytes)));
2923                    }
2924                }
2925                Err(e) => {
2926                    error!("TTS error: {e}");
2927                }
2928            }
2929        });
2930
2931        let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
2932        let body = axum::body::Body::from_stream(stream);
2933        Ok(Response::builder()
2934            .status(200)
2935            .header("content-type", speech_content_type(response_format))
2936            .header("transfer-encoding", "chunked")
2937            .body(body)
2938            .unwrap())
2939    } else {
2940        // Non-streaming: return complete WAV
2941        let chunks = tts
2942            .synthesize_speech(&request.input, language, chunk_frames)
2943            .await
2944            .map_err(|e| ServerError::InternalError(format!("TTS: {e}")))?;
2945
2946        let all_samples: Vec<f32> = chunks.into_iter().flatten().collect();
2947        let audio_bytes = encode_speech_audio(&all_samples, sample_rate, response_format);
2948
2949        Ok(Response::builder()
2950            .status(200)
2951            .header("content-type", speech_content_type(response_format))
2952            .header("content-length", audio_bytes.len().to_string())
2953            .body(axum::body::Body::from(audio_bytes))
2954            .unwrap())
2955    }
2956}
2957
2958#[derive(Clone, Copy)]
2959enum SpeechOutputFormat {
2960    Wav,
2961    Pcm,
2962}
2963
2964fn speech_output_format(
2965    request: &SpeechRequest,
2966) -> std::result::Result<SpeechOutputFormat, ServerError> {
2967    if request.response_format.eq_ignore_ascii_case("wav") {
2968        Ok(SpeechOutputFormat::Wav)
2969    } else if request.response_format.eq_ignore_ascii_case("pcm") {
2970        Ok(SpeechOutputFormat::Pcm)
2971    } else {
2972        Err(ServerError::unsupported_feature(
2973            "only response_format=wav or response_format=pcm is supported for speech",
2974            Some("response_format"),
2975        ))
2976    }
2977}
2978
2979fn speech_content_type(format: SpeechOutputFormat) -> &'static str {
2980    match format {
2981        SpeechOutputFormat::Wav => "audio/wav",
2982        SpeechOutputFormat::Pcm => "audio/pcm",
2983    }
2984}
2985
2986fn encode_speech_audio(samples: &[f32], sample_rate: u32, format: SpeechOutputFormat) -> Vec<u8> {
2987    match format {
2988        SpeechOutputFormat::Wav => pcm_to_wav_bytes(samples, sample_rate),
2989        SpeechOutputFormat::Pcm => pcm_to_s16le_bytes(samples),
2990    }
2991}
2992
2993/// Convert PCM f32 samples to WAV bytes (16-bit, mono).
2994fn pcm_to_wav_bytes(samples: &[f32], sample_rate: u32) -> Vec<u8> {
2995    let num_samples = samples.len();
2996    let data_size = num_samples * 2; // 16-bit = 2 bytes per sample
2997    let file_size = 44 + data_size;
2998
2999    let mut buf = Vec::with_capacity(file_size);
3000    // RIFF header
3001    buf.extend_from_slice(b"RIFF");
3002    buf.extend_from_slice(&((file_size - 8) as u32).to_le_bytes());
3003    buf.extend_from_slice(b"WAVE");
3004    // fmt chunk
3005    buf.extend_from_slice(b"fmt ");
3006    buf.extend_from_slice(&16u32.to_le_bytes()); // chunk size
3007    buf.extend_from_slice(&1u16.to_le_bytes()); // PCM
3008    buf.extend_from_slice(&1u16.to_le_bytes()); // mono
3009    buf.extend_from_slice(&sample_rate.to_le_bytes());
3010    buf.extend_from_slice(&(sample_rate * 2).to_le_bytes()); // byte rate
3011    buf.extend_from_slice(&2u16.to_le_bytes()); // block align
3012    buf.extend_from_slice(&16u16.to_le_bytes()); // bits per sample
3013                                                 // data chunk
3014    buf.extend_from_slice(b"data");
3015    buf.extend_from_slice(&(data_size as u32).to_le_bytes());
3016    buf.extend_from_slice(&pcm_to_s16le_bytes(samples));
3017    buf
3018}
3019
3020fn pcm_to_s16le_bytes(samples: &[f32]) -> Vec<u8> {
3021    let mut buf = Vec::with_capacity(samples.len() * 2);
3022    for &s in samples {
3023        let i16_val = (s.clamp(-1.0, 1.0) * 32767.0) as i16;
3024        buf.extend_from_slice(&i16_val.to_le_bytes());
3025    }
3026    buf
3027}
3028
3029async fn models_handler(
3030    State(state): State<AppState>,
3031) -> std::result::Result<Response, ServerError> {
3032    let status = state.status().await;
3033    let now = chrono::Utc::now().timestamp() as u64;
3034    let mut data: Vec<_> = status
3035        .loaded_models
3036        .into_iter()
3037        .map(|model_id| crate::openai::ModelInfo {
3038            id: model_id.to_string(),
3039            object: "model".to_string(),
3040            created: now,
3041            owned_by: "ferrum".to_string(),
3042            permission: vec![],
3043            root: None,
3044            parent: None,
3045        })
3046        .collect();
3047    data.extend(state.lora_registry.adapter_models().iter().map(|adapter| {
3048        crate::openai::ModelInfo {
3049            id: adapter.model_id.clone(),
3050            object: "model".to_string(),
3051            created: now,
3052            owned_by: "ferrum".to_string(),
3053            permission: vec![],
3054            root: state.lora_registry.base_model_id.as_ref().cloned(),
3055            parent: state.lora_registry.base_model_id.as_ref().cloned(),
3056        }
3057    }));
3058
3059    let models = ModelListResponse {
3060        object: "list".to_string(),
3061        data,
3062    };
3063
3064    Ok(Json(models).into_response())
3065}
3066
3067async fn health_handler(
3068    State(state): State<AppState>,
3069) -> std::result::Result<Response, ServerError> {
3070    let engine_status = state.status().await;
3071    let scheduler_metrics = state.metrics();
3072    let runtime_config = RuntimeConfigSnapshot::capture_current();
3073    let cache_policy = CachePolicy::current();
3074    let engine_cache = state
3075        .llm
3076        .as_ref()
3077        .and_then(|engine| engine.cache_metrics_snapshot());
3078    let engine_lora = state
3079        .llm
3080        .as_ref()
3081        .and_then(|engine| engine.lora_metrics_snapshot());
3082    let auto_config = auto_config_health_value(state.auto_config.as_ref());
3083    let admission = admission_health_json(&engine_status, &scheduler_metrics, &auto_config);
3084
3085    let health = serde_json::json!({
3086        "status": "healthy",
3087        "timestamp": chrono::Utc::now().to_rfc3339(),
3088        "version": env!("CARGO_PKG_VERSION"),
3089        "engine": {
3090            "active_requests": engine_status.active_requests,
3091            "queued_requests": engine_status.queued_requests,
3092        },
3093        "scheduler": {
3094            "total_requests": scheduler_metrics.total_requests,
3095            "successful_requests": scheduler_metrics.successful_requests,
3096            "failed_requests": scheduler_metrics.failed_requests,
3097            "throughput_rps": scheduler_metrics.throughput_rps,
3098            "avg_wait_time_ms": scheduler_metrics.queue_metrics.avg_queue_wait_time_ms,
3099            "scheduling_time_ms": scheduler_metrics.performance_breakdown.scheduling_time_ms,
3100            "model_execution_time_ms": scheduler_metrics
3101                .performance_breakdown
3102                .model_execution_time_ms,
3103            "iteration_lock_wait_time_ms": scheduler_metrics
3104                .performance_breakdown
3105                .other_overhead_time_ms,
3106        },
3107        "config": runtime_config,
3108        "auto_config": auto_config,
3109        "admission": admission,
3110        "cache": state.cache.health_json(&cache_policy, engine_cache.as_ref()),
3111        "lora": engine_lora.unwrap_or_else(|| serde_json::json!({
3112            "enabled": state.lora_registry.is_enabled(),
3113            "adapter_count": state.lora_registry.adapter_models().len() as u64,
3114            "active_cache_bindings": 0u64,
3115            "projection_applications": 0u64,
3116            "position": "startup-routing",
3117            "source": "server-lora-registry",
3118        })),
3119    });
3120
3121    Ok(Json(health).into_response())
3122}
3123
3124/// Prometheus metrics endpoint — returns metrics in Prometheus text format.
3125async fn metrics_handler(
3126    State(state): State<AppState>,
3127) -> std::result::Result<Response, ServerError> {
3128    let mut body = match PROM_HANDLE.get() {
3129        Some(handle) => handle.render(),
3130        None => "# Prometheus recorder not initialized\n".to_string(),
3131    };
3132    if !body.ends_with('\n') {
3133        body.push('\n');
3134    }
3135    let engine_cache = state
3136        .llm
3137        .as_ref()
3138        .and_then(|engine| engine.cache_metrics_snapshot());
3139    body.push_str(&state.cache.prometheus_metrics(engine_cache.as_ref()));
3140    let engine_status = state.status().await;
3141    let scheduler_metrics = state.metrics();
3142    let auto_config = auto_config_health_value(state.auto_config.as_ref());
3143    let admission = admission_health_json(&engine_status, &scheduler_metrics, &auto_config);
3144    body.push_str(&admission_prometheus_metrics(&admission));
3145
3146    Ok((
3147        [(
3148            axum::http::header::CONTENT_TYPE,
3149            "text/plain; version=0.0.4; charset=utf-8",
3150        )],
3151        body,
3152    )
3153        .into_response())
3154}
3155
3156async fn root_handler() -> std::result::Result<Response, ServerError> {
3157    let info = serde_json::json!({
3158        "name": "Ferrum Inference Server",
3159        "version": env!("CARGO_PKG_VERSION"),
3160        "api_version": "v1",
3161        "status": "running"
3162    });
3163
3164    Ok(Json(info).into_response())
3165}
3166
3167/// Server error type for HTTP responses
3168#[derive(Debug)]
3169enum ServerError {
3170    InvalidRequest {
3171        message: String,
3172        param: Option<String>,
3173    },
3174    UnsupportedFeature {
3175        message: String,
3176        param: Option<String>,
3177    },
3178    InternalError(String),
3179    NotImplemented(String),
3180    ServiceUnavailable(String),
3181}
3182
3183impl ServerError {
3184    fn invalid_request(message: impl Into<String>, param: Option<&str>) -> Self {
3185        Self::InvalidRequest {
3186            message: message.into(),
3187            param: param.map(str::to_string),
3188        }
3189    }
3190
3191    fn unsupported_feature(message: impl Into<String>, param: Option<&str>) -> Self {
3192        Self::UnsupportedFeature {
3193            message: message.into(),
3194            param: param.map(str::to_string),
3195        }
3196    }
3197}
3198
3199impl IntoResponse for ServerError {
3200    fn into_response(self) -> Response {
3201        let (status, message, error_type, param) = match self {
3202            ServerError::InvalidRequest { message, param } => (
3203                AxumStatusCode::BAD_REQUEST,
3204                message,
3205                "invalid_request_error",
3206                param,
3207            ),
3208            ServerError::UnsupportedFeature { message, param } => (
3209                AxumStatusCode::BAD_REQUEST,
3210                message,
3211                "invalid_request_error",
3212                param,
3213            ),
3214            ServerError::InternalError(msg) => (
3215                AxumStatusCode::INTERNAL_SERVER_ERROR,
3216                msg,
3217                "internal_server_error",
3218                None,
3219            ),
3220            ServerError::NotImplemented(msg) => (
3221                AxumStatusCode::SERVICE_UNAVAILABLE,
3222                msg,
3223                "service_unavailable_error",
3224                None,
3225            ),
3226            ServerError::ServiceUnavailable(msg) => (
3227                AxumStatusCode::SERVICE_UNAVAILABLE,
3228                msg,
3229                "service_unavailable_error",
3230                None,
3231            ),
3232        };
3233
3234        let error = OpenAiError {
3235            error: OpenAiErrorDetail {
3236                message,
3237                error_type: error_type.to_string(),
3238                param,
3239                code: None,
3240            },
3241        };
3242
3243        (status, Json(error)).into_response()
3244    }
3245}
3246
3247impl std::fmt::Display for MessageRole {
3248    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3249        match self {
3250            MessageRole::System => write!(f, "system"),
3251            MessageRole::User => write!(f, "user"),
3252            MessageRole::Assistant => write!(f, "assistant"),
3253            MessageRole::Function => write!(f, "function"),
3254            MessageRole::Tool => write!(f, "tool"),
3255        }
3256    }
3257}
3258
3259/// Strip model output at the first user-supplied stop sequence.
3260/// OpenAI-compatible `stop` strings are generation boundaries and must not
3261/// be returned to the caller, even if the model continued after the boundary.
3262fn strip_after_stop(text: &str, stops: &[String]) -> String {
3263    let mut first: Option<usize> = None;
3264    for stop in stops {
3265        if stop.is_empty() {
3266            continue;
3267        }
3268        if let Some(idx) = text.find(stop.as_str()) {
3269            first = Some(first.map_or(idx, |current| current.min(idx)));
3270        }
3271    }
3272    match first {
3273        Some(idx) => text[..idx].to_string(),
3274        None => text.to_string(),
3275    }
3276}
3277
3278/// When `response_format = json_object` is set, the model is meant to
3279/// emit valid JSON only. Qwen / Llama instruct models frequently wrap
3280/// the JSON in markdown fences anyway (```` ```json ... ``` ````)
3281/// because that's how they were trained. `JsonModeProcessor` only
3282/// applies soft logit biases, not a hard mask, so the fence slips
3283/// through. Strip a single outermost ` ```json ... ``` ` /
3284/// ` ``` ... ``` ` wrapper. Preserves inner JSON exactly. Returns the
3285/// input unchanged if no fence is present.
3286fn strip_markdown_json_fence(text: &str) -> String {
3287    let trimmed = text.trim();
3288    // Try the most specific marker first.
3289    for prefix in ["```json\n", "```json", "```\n", "```"] {
3290        if let Some(rest) = trimmed.strip_prefix(prefix) {
3291            if let Some(inner) = rest.strip_suffix("```") {
3292                return inner.trim().to_string();
3293            }
3294        }
3295    }
3296    text.to_string()
3297}
3298
3299/// Convert FinishReason to OpenAI API string
3300fn finish_reason_to_string(reason: &FinishReason) -> String {
3301    match reason {
3302        FinishReason::Length => "length".to_string(),
3303        FinishReason::Stop => "stop".to_string(),
3304        FinishReason::EOS => "stop".to_string(),
3305        FinishReason::Cancelled => "cancelled".to_string(),
3306        FinishReason::Error => "error".to_string(),
3307        FinishReason::ContentFilter => "content_filter".to_string(),
3308    }
3309}
3310
3311#[cfg(test)]
3312mod tests {
3313    use super::*;
3314    use async_trait::async_trait;
3315    use axum::{
3316        body::{to_bytes, Body},
3317        http::{header, Request},
3318        response::Response,
3319    };
3320    use ferrum_interfaces::engine::{
3321        EmbedEngine, InferenceEngine, LlmInferenceEngine, TranscribeEngine, TtsEngine,
3322    };
3323    use ferrum_types::{
3324        EngineConfig, EngineMetrics, EngineStatus, FinishReason,
3325        HealthStatus as EngineHealthStatus, InferenceRequest, InferenceResponse, MemoryUsage,
3326        ModelId, StreamChunk, TokenId, TokenUsage,
3327    };
3328    use futures::{stream, Stream};
3329    use serde_json::{json, Value};
3330    use std::{
3331        collections::HashMap,
3332        pin::Pin,
3333        sync::{Arc, Mutex},
3334    };
3335    use tower::ServiceExt;
3336
3337    #[test]
3338    fn strip_after_stop_removes_first_boundary() {
3339        assert_eq!(
3340            strip_after_stop(
3341                "KS0214Z\nS0225\nEND0214Z0214Z\nS0225\n",
3342                &["END0214Z".to_string()]
3343            ),
3344            "KS0214Z\nS0225\n"
3345        );
3346    }
3347
3348    struct StubLlm {
3349        config: EngineConfig,
3350        text: String,
3351        stream_chunks: Option<Vec<String>>,
3352        stream_final_chunk_separate: bool,
3353        stream_usage: Option<TokenUsage>,
3354        api_response: Option<ferrum_types::ApiResponse>,
3355        lora_metrics: Option<Value>,
3356    }
3357
3358    impl StubLlm {
3359        fn new(text: &str) -> Self {
3360            let mut config = EngineConfig::default();
3361            config.model.model_id = ModelId::new("stub-model");
3362            Self {
3363                config,
3364                text: text.to_string(),
3365                stream_chunks: None,
3366                stream_final_chunk_separate: false,
3367                stream_usage: Some(TokenUsage::new(5, 1)),
3368                api_response: None,
3369                lora_metrics: None,
3370            }
3371        }
3372
3373        fn without_stream_usage(text: &str) -> Self {
3374            Self {
3375                stream_usage: None,
3376                ..Self::new(text)
3377            }
3378        }
3379
3380        fn with_stream_chunks(chunks: &[&str]) -> Self {
3381            Self {
3382                text: chunks.concat(),
3383                stream_chunks: Some(chunks.iter().map(|chunk| (*chunk).to_string()).collect()),
3384                ..Self::new("")
3385            }
3386        }
3387
3388        fn with_separate_final_stream_chunk(chunks: &[&str]) -> Self {
3389            Self {
3390                text: chunks.concat(),
3391                stream_chunks: Some(chunks.iter().map(|chunk| (*chunk).to_string()).collect()),
3392                stream_final_chunk_separate: true,
3393                ..Self::new("")
3394            }
3395        }
3396
3397        fn with_api_response(text: &str, api_response: ferrum_types::ApiResponse) -> Self {
3398            Self {
3399                api_response: Some(api_response),
3400                ..Self::new(text)
3401            }
3402        }
3403
3404        fn with_lora_metrics(text: &str, lora_metrics: Value) -> Self {
3405            Self {
3406                lora_metrics: Some(lora_metrics),
3407                ..Self::new(text)
3408            }
3409        }
3410    }
3411
3412    struct StubEmbed {
3413        config: EngineConfig,
3414    }
3415
3416    impl StubEmbed {
3417        fn new() -> Self {
3418            let mut config = EngineConfig::default();
3419            config.model.model_id = ModelId::new("stub-embed");
3420            Self { config }
3421        }
3422    }
3423
3424    struct StubTranscribe {
3425        config: EngineConfig,
3426    }
3427
3428    impl StubTranscribe {
3429        fn new() -> Self {
3430            let mut config = EngineConfig::default();
3431            config.model.model_id = ModelId::new("stub-transcribe");
3432            Self { config }
3433        }
3434    }
3435
3436    struct StubTts {
3437        config: EngineConfig,
3438    }
3439
3440    impl StubTts {
3441        fn new() -> Self {
3442            let mut config = EngineConfig::default();
3443            config.model.model_id = ModelId::new("stub-tts");
3444            Self { config }
3445        }
3446    }
3447
3448    struct FailingLlm {
3449        config: EngineConfig,
3450        fail_after_stream_start: bool,
3451    }
3452
3453    impl FailingLlm {
3454        fn new() -> Self {
3455            let mut config = EngineConfig::default();
3456            config.model.model_id = ModelId::new("failing-model");
3457            Self {
3458                config,
3459                fail_after_stream_start: false,
3460            }
3461        }
3462
3463        fn after_stream_start() -> Self {
3464            Self {
3465                fail_after_stream_start: true,
3466                ..Self::new()
3467            }
3468        }
3469    }
3470
3471    struct CapturingLlm {
3472        config: EngineConfig,
3473        last_request: Mutex<Option<InferenceRequest>>,
3474    }
3475
3476    impl CapturingLlm {
3477        fn new() -> Self {
3478            let mut config = EngineConfig::default();
3479            config.model.model_id = ModelId::new("qwen3");
3480            Self {
3481                config,
3482                last_request: Mutex::new(None),
3483            }
3484        }
3485
3486        fn last_request(&self) -> InferenceRequest {
3487            self.last_request
3488                .lock()
3489                .expect("capture lock")
3490                .clone()
3491                .expect("request captured")
3492        }
3493    }
3494
3495    #[async_trait]
3496    impl InferenceEngine for StubLlm {
3497        async fn status(&self) -> EngineStatus {
3498            EngineStatus {
3499                is_ready: true,
3500                loaded_models: vec![self.config.model.model_id.clone()],
3501                active_requests: 0,
3502                queued_requests: 0,
3503                memory_usage: MemoryUsage {
3504                    total_bytes: 0,
3505                    used_bytes: 0,
3506                    free_bytes: 0,
3507                    gpu_memory_bytes: None,
3508                    cpu_memory_bytes: None,
3509                    cache_memory_bytes: 0,
3510                    utilization_percent: 0.0,
3511                },
3512                uptime_seconds: 0,
3513                last_heartbeat: chrono::Utc::now(),
3514                version: "test".to_string(),
3515            }
3516        }
3517
3518        async fn shutdown(&self) -> ferrum_types::Result<()> {
3519            Ok(())
3520        }
3521
3522        fn config(&self) -> &EngineConfig {
3523            &self.config
3524        }
3525
3526        fn metrics(&self) -> EngineMetrics {
3527            EngineMetrics::default()
3528        }
3529
3530        async fn health_check(&self) -> EngineHealthStatus {
3531            EngineHealthStatus::healthy()
3532        }
3533
3534        fn lora_metrics_snapshot(&self) -> Option<Value> {
3535            self.lora_metrics.clone()
3536        }
3537    }
3538
3539    #[async_trait]
3540    impl InferenceEngine for StubEmbed {
3541        async fn status(&self) -> EngineStatus {
3542            EngineStatus {
3543                is_ready: true,
3544                loaded_models: vec![self.config.model.model_id.clone()],
3545                active_requests: 0,
3546                queued_requests: 0,
3547                memory_usage: MemoryUsage {
3548                    total_bytes: 0,
3549                    used_bytes: 0,
3550                    free_bytes: 0,
3551                    gpu_memory_bytes: None,
3552                    cpu_memory_bytes: None,
3553                    cache_memory_bytes: 0,
3554                    utilization_percent: 0.0,
3555                },
3556                uptime_seconds: 0,
3557                last_heartbeat: chrono::Utc::now(),
3558                version: "test".to_string(),
3559            }
3560        }
3561
3562        async fn shutdown(&self) -> ferrum_types::Result<()> {
3563            Ok(())
3564        }
3565
3566        fn config(&self) -> &EngineConfig {
3567            &self.config
3568        }
3569
3570        fn metrics(&self) -> EngineMetrics {
3571            EngineMetrics::default()
3572        }
3573
3574        async fn health_check(&self) -> EngineHealthStatus {
3575            EngineHealthStatus::healthy()
3576        }
3577    }
3578
3579    #[async_trait]
3580    impl EmbedEngine for StubEmbed {
3581        async fn embed_text(&self, text: &str) -> ferrum_types::Result<Vec<f32>> {
3582            Ok(vec![text.len() as f32, 1.0, 0.0])
3583        }
3584
3585        async fn embed_image(&self, image: &str) -> ferrum_types::Result<Vec<f32>> {
3586            Ok(vec![image.len() as f32, 0.0, 1.0])
3587        }
3588
3589        fn embedding_dim(&self) -> usize {
3590            3
3591        }
3592    }
3593
3594    #[async_trait]
3595    impl InferenceEngine for StubTranscribe {
3596        async fn status(&self) -> EngineStatus {
3597            EngineStatus {
3598                is_ready: true,
3599                loaded_models: vec![self.config.model.model_id.clone()],
3600                active_requests: 0,
3601                queued_requests: 0,
3602                memory_usage: MemoryUsage {
3603                    total_bytes: 0,
3604                    used_bytes: 0,
3605                    free_bytes: 0,
3606                    gpu_memory_bytes: None,
3607                    cpu_memory_bytes: None,
3608                    cache_memory_bytes: 0,
3609                    utilization_percent: 0.0,
3610                },
3611                uptime_seconds: 0,
3612                last_heartbeat: chrono::Utc::now(),
3613                version: "test".to_string(),
3614            }
3615        }
3616
3617        async fn shutdown(&self) -> ferrum_types::Result<()> {
3618            Ok(())
3619        }
3620
3621        fn config(&self) -> &EngineConfig {
3622            &self.config
3623        }
3624
3625        fn metrics(&self) -> EngineMetrics {
3626            EngineMetrics::default()
3627        }
3628
3629        async fn health_check(&self) -> EngineHealthStatus {
3630            EngineHealthStatus::healthy()
3631        }
3632    }
3633
3634    #[async_trait]
3635    impl TranscribeEngine for StubTranscribe {
3636        async fn transcribe_file(
3637            &self,
3638            path: &str,
3639            language: Option<&str>,
3640        ) -> ferrum_types::Result<String> {
3641            Ok(format!("file:{path}:{}", language.unwrap_or("auto")))
3642        }
3643
3644        async fn transcribe_bytes(
3645            &self,
3646            data: &[u8],
3647            language: Option<&str>,
3648        ) -> ferrum_types::Result<String> {
3649            Ok(format!(
3650                "bytes:{}:{}",
3651                data.len(),
3652                language.unwrap_or("auto")
3653            ))
3654        }
3655    }
3656
3657    #[async_trait]
3658    impl InferenceEngine for StubTts {
3659        async fn status(&self) -> EngineStatus {
3660            EngineStatus {
3661                is_ready: true,
3662                loaded_models: vec![self.config.model.model_id.clone()],
3663                active_requests: 0,
3664                queued_requests: 0,
3665                memory_usage: MemoryUsage {
3666                    total_bytes: 0,
3667                    used_bytes: 0,
3668                    free_bytes: 0,
3669                    gpu_memory_bytes: None,
3670                    cpu_memory_bytes: None,
3671                    cache_memory_bytes: 0,
3672                    utilization_percent: 0.0,
3673                },
3674                uptime_seconds: 0,
3675                last_heartbeat: chrono::Utc::now(),
3676                version: "test".to_string(),
3677            }
3678        }
3679
3680        async fn shutdown(&self) -> ferrum_types::Result<()> {
3681            Ok(())
3682        }
3683
3684        fn config(&self) -> &EngineConfig {
3685            &self.config
3686        }
3687
3688        fn metrics(&self) -> EngineMetrics {
3689            EngineMetrics::default()
3690        }
3691
3692        async fn health_check(&self) -> EngineHealthStatus {
3693            EngineHealthStatus::healthy()
3694        }
3695    }
3696
3697    #[async_trait]
3698    impl TtsEngine for StubTts {
3699        async fn synthesize_speech(
3700            &self,
3701            _text: &str,
3702            _language: Option<&str>,
3703            _chunk_frames: usize,
3704        ) -> ferrum_types::Result<Vec<Vec<f32>>> {
3705            Ok(vec![vec![0.0, 0.5, -0.5]])
3706        }
3707
3708        fn tts_sample_rate(&self) -> u32 {
3709            16_000
3710        }
3711    }
3712
3713    #[async_trait]
3714    impl InferenceEngine for FailingLlm {
3715        async fn status(&self) -> EngineStatus {
3716            EngineStatus {
3717                is_ready: true,
3718                loaded_models: vec![self.config.model.model_id.clone()],
3719                active_requests: 0,
3720                queued_requests: 0,
3721                memory_usage: MemoryUsage {
3722                    total_bytes: 0,
3723                    used_bytes: 0,
3724                    free_bytes: 0,
3725                    gpu_memory_bytes: None,
3726                    cpu_memory_bytes: None,
3727                    cache_memory_bytes: 0,
3728                    utilization_percent: 0.0,
3729                },
3730                uptime_seconds: 0,
3731                last_heartbeat: chrono::Utc::now(),
3732                version: "test".to_string(),
3733            }
3734        }
3735
3736        async fn shutdown(&self) -> ferrum_types::Result<()> {
3737            Ok(())
3738        }
3739
3740        fn config(&self) -> &EngineConfig {
3741            &self.config
3742        }
3743
3744        fn metrics(&self) -> EngineMetrics {
3745            EngineMetrics::default()
3746        }
3747
3748        async fn health_check(&self) -> EngineHealthStatus {
3749            EngineHealthStatus::healthy()
3750        }
3751    }
3752
3753    #[async_trait]
3754    impl InferenceEngine for CapturingLlm {
3755        async fn status(&self) -> EngineStatus {
3756            EngineStatus {
3757                is_ready: true,
3758                loaded_models: vec![self.config.model.model_id.clone()],
3759                active_requests: 0,
3760                queued_requests: 0,
3761                memory_usage: MemoryUsage {
3762                    total_bytes: 0,
3763                    used_bytes: 0,
3764                    free_bytes: 0,
3765                    gpu_memory_bytes: None,
3766                    cpu_memory_bytes: None,
3767                    cache_memory_bytes: 0,
3768                    utilization_percent: 0.0,
3769                },
3770                uptime_seconds: 0,
3771                last_heartbeat: chrono::Utc::now(),
3772                version: "test".to_string(),
3773            }
3774        }
3775
3776        async fn shutdown(&self) -> ferrum_types::Result<()> {
3777            Ok(())
3778        }
3779
3780        fn config(&self) -> &EngineConfig {
3781            &self.config
3782        }
3783
3784        fn metrics(&self) -> EngineMetrics {
3785            EngineMetrics::default()
3786        }
3787
3788        async fn health_check(&self) -> EngineHealthStatus {
3789            EngineHealthStatus::healthy()
3790        }
3791    }
3792
3793    #[async_trait]
3794    impl LlmInferenceEngine for StubLlm {
3795        async fn infer(
3796            &self,
3797            request: InferenceRequest,
3798        ) -> ferrum_types::Result<InferenceResponse> {
3799            Ok(InferenceResponse {
3800                request_id: request.id,
3801                text: self.text.clone(),
3802                tokens: vec![TokenId::new(11), TokenId::new(12)],
3803                finish_reason: FinishReason::Stop,
3804                usage: TokenUsage::new(7, 2),
3805                latency_ms: 1,
3806                created_at: chrono::Utc::now(),
3807                metadata: HashMap::new(),
3808                api_response: self.api_response.clone(),
3809            })
3810        }
3811
3812        async fn infer_stream(
3813            &self,
3814            request: InferenceRequest,
3815        ) -> ferrum_types::Result<
3816            Pin<Box<dyn Stream<Item = ferrum_types::Result<StreamChunk>> + Send>>,
3817        > {
3818            if let Some(chunks) = &self.stream_chunks {
3819                let request_id = request.id;
3820                let mut stream_chunks = Vec::with_capacity(
3821                    chunks.len() + usize::from(self.stream_final_chunk_separate),
3822                );
3823                let last = chunks.len().saturating_sub(1);
3824                for (index, text) in chunks.iter().enumerate() {
3825                    let is_final_text_chunk = index == last && !self.stream_final_chunk_separate;
3826                    stream_chunks.push(Ok(StreamChunk {
3827                        request_id: request_id.clone(),
3828                        text: text.clone(),
3829                        token: Some(TokenId::new(11 + index as u32)),
3830                        finish_reason: is_final_text_chunk.then_some(FinishReason::Stop),
3831                        usage: is_final_text_chunk
3832                            .then(|| self.stream_usage.clone())
3833                            .flatten(),
3834                        created_at: chrono::Utc::now(),
3835                        metadata: HashMap::new(),
3836                        api_response: is_final_text_chunk
3837                            .then(|| self.api_response.clone())
3838                            .flatten(),
3839                    }));
3840                }
3841                if self.stream_final_chunk_separate {
3842                    stream_chunks.push(Ok(StreamChunk {
3843                        request_id,
3844                        text: String::new(),
3845                        token: None,
3846                        finish_reason: Some(FinishReason::Stop),
3847                        usage: self.stream_usage.clone(),
3848                        created_at: chrono::Utc::now(),
3849                        metadata: HashMap::new(),
3850                        api_response: self.api_response.clone(),
3851                    }));
3852                }
3853                return Ok(Box::pin(stream::iter(stream_chunks)));
3854            }
3855
3856            let chunk = StreamChunk {
3857                request_id: request.id,
3858                text: self.text.clone(),
3859                token: Some(TokenId::new(11)),
3860                finish_reason: Some(FinishReason::Stop),
3861                usage: self.stream_usage.clone(),
3862                created_at: chrono::Utc::now(),
3863                metadata: HashMap::new(),
3864                api_response: self.api_response.clone(),
3865            };
3866            Ok(Box::pin(stream::iter(vec![Ok(chunk)])))
3867        }
3868    }
3869
3870    #[async_trait]
3871    impl LlmInferenceEngine for FailingLlm {
3872        async fn infer(
3873            &self,
3874            _request: InferenceRequest,
3875        ) -> ferrum_types::Result<InferenceResponse> {
3876            Err(ferrum_types::FerrumError::internal(
3877                "stub generation failed",
3878            ))
3879        }
3880
3881        async fn infer_stream(
3882            &self,
3883            request: InferenceRequest,
3884        ) -> ferrum_types::Result<
3885            Pin<Box<dyn Stream<Item = ferrum_types::Result<StreamChunk>> + Send>>,
3886        > {
3887            if self.fail_after_stream_start {
3888                let _request_id = request.id;
3889                return Ok(Box::pin(stream::iter(vec![Err(
3890                    ferrum_types::FerrumError::internal("stub stream chunk failed"),
3891                )])));
3892            }
3893            Err(ferrum_types::FerrumError::internal("stub stream failed"))
3894        }
3895    }
3896
3897    #[async_trait]
3898    impl LlmInferenceEngine for CapturingLlm {
3899        async fn infer(
3900            &self,
3901            request: InferenceRequest,
3902        ) -> ferrum_types::Result<InferenceResponse> {
3903            *self.last_request.lock().expect("capture lock") = Some(request.clone());
3904            Ok(InferenceResponse {
3905                request_id: request.id,
3906                text: "captured".to_string(),
3907                tokens: vec![TokenId::new(21)],
3908                finish_reason: FinishReason::Stop,
3909                usage: TokenUsage::new(9, 1),
3910                latency_ms: 1,
3911                created_at: chrono::Utc::now(),
3912                metadata: HashMap::new(),
3913                api_response: None,
3914            })
3915        }
3916
3917        async fn infer_stream(
3918            &self,
3919            request: InferenceRequest,
3920        ) -> ferrum_types::Result<
3921            Pin<Box<dyn Stream<Item = ferrum_types::Result<StreamChunk>> + Send>>,
3922        > {
3923            *self.last_request.lock().expect("capture lock") = Some(request.clone());
3924            let chunk = StreamChunk {
3925                request_id: request.id,
3926                text: "captured".to_string(),
3927                token: Some(TokenId::new(21)),
3928                finish_reason: Some(FinishReason::Stop),
3929                usage: Some(TokenUsage::new(9, 1)),
3930                created_at: chrono::Utc::now(),
3931                metadata: HashMap::new(),
3932                api_response: None,
3933            };
3934            Ok(Box::pin(stream::iter(vec![Ok(chunk)])))
3935        }
3936    }
3937
3938    fn state_with_stub(text: &str) -> AppState {
3939        AppState::default().with_llm(Arc::new(StubLlm::new(text)))
3940    }
3941
3942    fn router_with_stub(text: &str) -> Router {
3943        AxumServer::from_llm(Arc::new(StubLlm::new(text))).build_router()
3944    }
3945
3946    fn router_with_stub_stream_chunks(chunks: &[&str]) -> Router {
3947        AxumServer::from_llm(Arc::new(StubLlm::with_stream_chunks(chunks))).build_router()
3948    }
3949
3950    fn router_with_stub_separate_final_stream_chunk(chunks: &[&str]) -> Router {
3951        AxumServer::from_llm(Arc::new(StubLlm::with_separate_final_stream_chunk(chunks)))
3952            .build_router()
3953    }
3954
3955    fn router_with_stub_api_response(
3956        text: &str,
3957        api_response: ferrum_types::ApiResponse,
3958    ) -> Router {
3959        AxumServer::from_llm(Arc::new(StubLlm::with_api_response(text, api_response)))
3960            .build_router()
3961    }
3962
3963    fn router_with_stub_without_stream_usage(text: &str) -> Router {
3964        AxumServer::from_llm(Arc::new(StubLlm::without_stream_usage(text))).build_router()
3965    }
3966
3967    fn router_without_llm() -> Router {
3968        AxumServer::from_state(AppState::default()).build_router()
3969    }
3970
3971    fn router_with_failing_llm() -> Router {
3972        AxumServer::from_llm(Arc::new(FailingLlm::new())).build_router()
3973    }
3974
3975    fn router_with_stream_chunk_failing_llm() -> Router {
3976        AxumServer::from_llm(Arc::new(FailingLlm::after_stream_start())).build_router()
3977    }
3978
3979    fn router_with_capturing_llm() -> (Router, Arc<CapturingLlm>) {
3980        let engine = Arc::new(CapturingLlm::new());
3981        let router = AxumServer::from_llm(engine.clone()).build_router();
3982        (router, engine)
3983    }
3984
3985    fn router_with_capturing_llm_and_template(
3986        template: ModelChatTemplate,
3987    ) -> (Router, Arc<CapturingLlm>) {
3988        let engine = Arc::new(CapturingLlm::new());
3989        let router = AxumServer::from_llm(engine.clone())
3990            .with_prompt_template(Some(template))
3991            .build_router();
3992        (router, engine)
3993    }
3994
3995    fn router_with_capturing_lora_llm() -> (Router, Arc<CapturingLlm>) {
3996        let engine = Arc::new(CapturingLlm::new());
3997        let router = AxumServer::from_llm(engine.clone())
3998            .with_lora_adapters(
3999                "qwen3",
4000                vec![LoraAdapterModel::new(
4001                    "sql",
4002                    "qwen3:sql",
4003                    "/tmp/sql-adapter",
4004                )],
4005            )
4006            .build_router();
4007        (router, engine)
4008    }
4009
4010    fn router_with_stub_embed() -> Router {
4011        AxumServer::from_embed(Arc::new(StubEmbed::new())).build_router()
4012    }
4013
4014    fn router_with_stub_transcribe() -> Router {
4015        AxumServer::from_transcribe(Arc::new(StubTranscribe::new())).build_router()
4016    }
4017
4018    fn router_with_stub_tts() -> Router {
4019        AxumServer::from_tts(Arc::new(StubTts::new())).build_router()
4020    }
4021
4022    async fn post_json(app: Router, path: &str, body: Value) -> Response {
4023        app.oneshot(
4024            Request::builder()
4025                .method("POST")
4026                .uri(path)
4027                .header(header::CONTENT_TYPE, "application/json")
4028                .body(Body::from(body.to_string()))
4029                .expect("request"),
4030        )
4031        .await
4032        .expect("route response")
4033    }
4034
4035    async fn post_raw_json(app: Router, path: &str, body: &str) -> Response {
4036        app.oneshot(
4037            Request::builder()
4038                .method("POST")
4039                .uri(path)
4040                .header(header::CONTENT_TYPE, "application/json")
4041                .body(Body::from(body.to_string()))
4042                .expect("request"),
4043        )
4044        .await
4045        .expect("route response")
4046    }
4047
4048    async fn post_multipart(app: Router, path: &str, boundary: &str, body: &str) -> Response {
4049        app.oneshot(
4050            Request::builder()
4051                .method("POST")
4052                .uri(path)
4053                .header(
4054                    header::CONTENT_TYPE,
4055                    format!("multipart/form-data; boundary={boundary}"),
4056                )
4057                .body(Body::from(body.to_string()))
4058                .expect("request"),
4059        )
4060        .await
4061        .expect("route response")
4062    }
4063
4064    async fn get(app: Router, path: &str) -> Response {
4065        app.oneshot(
4066            Request::builder()
4067                .method("GET")
4068                .uri(path)
4069                .body(Body::empty())
4070                .expect("request"),
4071        )
4072        .await
4073        .expect("route response")
4074    }
4075
4076    async fn response_json(response: Response) -> Value {
4077        let bytes = to_bytes(response.into_body(), usize::MAX)
4078            .await
4079            .expect("body bytes");
4080        serde_json::from_slice(&bytes).expect("json body")
4081    }
4082
4083    async fn response_text(response: Response) -> String {
4084        let bytes = to_bytes(response.into_body(), usize::MAX)
4085            .await
4086            .expect("body bytes");
4087        String::from_utf8(bytes.to_vec()).expect("utf8 body")
4088    }
4089
4090    async fn response_bytes(response: Response) -> Vec<u8> {
4091        to_bytes(response.into_body(), usize::MAX)
4092            .await
4093            .expect("body bytes")
4094            .to_vec()
4095    }
4096
4097    async fn error_json(error: ServerError) -> (AxumStatusCode, Value) {
4098        let response = error.into_response();
4099        let status = response.status();
4100        (status, response_json(response).await)
4101    }
4102
4103    fn assert_openai_stream_error(body: &str, expected_message: &str) {
4104        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
4105        assert!(
4106            body.contains("\"error\":{\"message\":\""),
4107            "stream failure should emit OpenAI error envelope: {body}"
4108        );
4109        assert!(
4110            body.contains(expected_message),
4111            "stream failure should include engine error message {expected_message:?}: {body}"
4112        );
4113        assert!(
4114            body.contains("\"type\":\"internal_server_error\""),
4115            "stream failure should use internal_server_error: {body}"
4116        );
4117        assert!(
4118            !body.contains("{\"error\":\""),
4119            "stream failure must not use legacy bare error payload: {body}"
4120        );
4121    }
4122
4123    fn chat_request(extra: Value) -> ChatCompletionsRequest {
4124        let mut value = json!({
4125            "model": "stub-model",
4126            "messages": [{"role": "user", "content": "hello"}],
4127            "max_tokens": 8
4128        });
4129        let obj = value.as_object_mut().unwrap();
4130        for (k, v) in extra.as_object().unwrap() {
4131            obj.insert(k.clone(), v.clone());
4132        }
4133        serde_json::from_value(value).expect("chat request")
4134    }
4135
4136    #[tokio::test]
4137    async fn route_health_includes_runtime_config_snapshot() {
4138        let response = get(router_with_stub("ok"), "/health").await;
4139        assert_eq!(response.status(), AxumStatusCode::OK);
4140        let body = response_json(response).await;
4141        assert_eq!(body["status"], "healthy");
4142        assert!(body["config"]["entries"].is_array(), "body: {body}");
4143        assert_eq!(body["auto_config"]["schema_version"], 1);
4144        assert!(body["auto_config"]["entries"].is_array(), "body: {body}");
4145        assert!(body["auto_config"]["admission"].is_object(), "body: {body}");
4146        assert_eq!(body["admission"]["schema_version"], 1);
4147        assert!(body["admission"]["effective_max_concurrent"].is_number());
4148        assert!(body["admission"]["queue_depth"].is_number());
4149        assert!(body["admission"]["active_prefill"].is_number());
4150        assert!(body["admission"]["active_decode"].is_number());
4151        assert!(body["admission"]["current_batch_size"].is_number());
4152        assert!(body["admission"]["rejected_requests_total"].is_number());
4153        assert!(body["admission"]["failed_requests_total"].is_number());
4154        assert!(body["admission"]["completed_requests_total"].is_number());
4155        assert!(body["admission"]["avg_queue_wait_time_ms"].is_number());
4156        assert!(body["scheduler"]["avg_wait_time_ms"].is_number());
4157        assert!(body["scheduler"]["scheduling_time_ms"].is_number());
4158        assert!(body["scheduler"]["model_execution_time_ms"].is_number());
4159        assert!(body["scheduler"]["iteration_lock_wait_time_ms"].is_number());
4160        assert!(
4161            body["auto_config"]["decisions"].is_array() || body["auto_config"]["error"].is_string(),
4162            "body: {body}"
4163        );
4164    }
4165
4166    #[tokio::test]
4167    async fn route_metrics_includes_admission_counters() {
4168        let response = get(router_with_stub("ok"), "/metrics").await;
4169        assert_eq!(response.status(), AxumStatusCode::OK);
4170        let body = response_text(response).await;
4171        for metric in [
4172            "ferrum_admission_effective_max_concurrent",
4173            "ferrum_admission_queue_depth",
4174            "ferrum_admission_active_prefill",
4175            "ferrum_admission_active_decode",
4176            "ferrum_admission_current_batch_size",
4177            "ferrum_admission_rejected_requests_total",
4178            "ferrum_admission_failed_requests_total",
4179            "ferrum_admission_completed_requests_total",
4180        ] {
4181            assert!(body.contains(metric), "missing {metric}:\n{body}");
4182        }
4183    }
4184
4185    #[tokio::test]
4186    async fn route_health_includes_engine_lora_metrics_snapshot() {
4187        let router = AxumServer::from_llm(Arc::new(StubLlm::with_lora_metrics(
4188            "ok",
4189            json!({
4190                "enabled": true,
4191                "adapter_count": 1,
4192                "active_cache_bindings": 0,
4193                "projection_applications": 7,
4194                "position": "real-inference",
4195                "source": "test-lora",
4196            }),
4197        )))
4198        .with_lora_adapters(
4199            "stub-model",
4200            vec![LoraAdapterModel::new(
4201                "sql",
4202                "stub-model:sql",
4203                "/tmp/sql-adapter",
4204            )],
4205        )
4206        .build_router();
4207        let response = get(router, "/health").await;
4208        assert_eq!(response.status(), AxumStatusCode::OK);
4209        let body = response_json(response).await;
4210        assert_eq!(body["lora"]["enabled"], true);
4211        assert_eq!(body["lora"]["adapter_count"], 1);
4212        assert_eq!(body["lora"]["projection_applications"], 7);
4213        assert_eq!(body["lora"]["position"], "real-inference");
4214        assert_eq!(body["lora"]["source"], "test-lora");
4215    }
4216
4217    #[tokio::test]
4218    async fn route_models_lists_loaded_stub_model() {
4219        let response = get(router_with_stub("ok"), "/v1/models").await;
4220        assert_eq!(response.status(), AxumStatusCode::OK);
4221        let body = response_json(response).await;
4222        assert_eq!(body["object"], "list");
4223        let data = body["data"].as_array().expect("models data array");
4224        assert_eq!(data.len(), 1, "body: {body}");
4225        assert_eq!(data[0]["id"], "stub-model");
4226        assert_eq!(data[0]["object"], "model");
4227        assert_eq!(data[0]["owned_by"], "ferrum");
4228        assert!(data[0]["created"].as_u64().unwrap_or_default() > 0);
4229        assert!(data[0]["permission"].as_array().unwrap().is_empty());
4230        assert!(data[0]["root"].is_null());
4231        assert!(data[0]["parent"].is_null());
4232    }
4233
4234    #[tokio::test]
4235    async fn route_models_lists_startup_lora_adapters() {
4236        let router = AxumServer::from_llm(Arc::new(StubLlm::new("ok")))
4237            .with_lora_adapters(
4238                "stub-model",
4239                vec![LoraAdapterModel::new(
4240                    "sql",
4241                    "stub-model:sql",
4242                    "/tmp/sql-adapter",
4243                )],
4244            )
4245            .build_router();
4246        let response = get(router, "/v1/models").await;
4247        assert_eq!(response.status(), AxumStatusCode::OK);
4248        let body = response_json(response).await;
4249        let data = body["data"].as_array().expect("models data array");
4250        let ids: Vec<_> = data
4251            .iter()
4252            .map(|item| item["id"].as_str().unwrap_or_default())
4253            .collect();
4254        assert!(ids.contains(&"stub-model"), "body: {body}");
4255        assert!(ids.contains(&"stub-model:sql"), "body: {body}");
4256        let adapter = data
4257            .iter()
4258            .find(|item| item["id"] == "stub-model:sql")
4259            .expect("adapter model");
4260        assert_eq!(adapter["root"], "stub-model");
4261        assert_eq!(adapter["parent"], "stub-model");
4262    }
4263
4264    #[tokio::test]
4265    async fn route_chat_lora_adapter_maps_internal_request_to_base_model() {
4266        let (router, engine) = router_with_capturing_lora_llm();
4267        let response = post_json(
4268            router,
4269            "/v1/chat/completions",
4270            json!({
4271                "model": "qwen3:sql",
4272                "messages": [{"role": "user", "content": "Say hi"}],
4273                "max_tokens": 8,
4274                "temperature": 0.0
4275            }),
4276        )
4277        .await;
4278        assert_eq!(response.status(), AxumStatusCode::OK);
4279        let body = response_json(response).await;
4280        assert_eq!(body["model"], "qwen3:sql");
4281        let captured = engine.last_request();
4282        assert_eq!(captured.model_id, ModelId::new("qwen3"));
4283        assert_eq!(captured.metadata["ferrum_lora_adapter"], "sql");
4284        assert_eq!(captured.metadata["ferrum_lora_model_id"], "qwen3:sql");
4285    }
4286
4287    #[tokio::test]
4288    async fn route_chat_base_model_still_uses_base_path_with_lora_loaded() {
4289        let (router, engine) = router_with_capturing_lora_llm();
4290        let response = post_json(
4291            router,
4292            "/v1/chat/completions",
4293            json!({
4294                "model": "qwen3",
4295                "messages": [{"role": "user", "content": "Say hi"}],
4296                "max_tokens": 8,
4297                "temperature": 0.0
4298            }),
4299        )
4300        .await;
4301        assert_eq!(response.status(), AxumStatusCode::OK);
4302        let captured = engine.last_request();
4303        assert_eq!(captured.model_id, ModelId::new("qwen3"));
4304        assert!(!captured.metadata.contains_key("ferrum_lora_adapter"));
4305    }
4306
4307    #[tokio::test]
4308    async fn route_chat_unknown_lora_adapter_returns_openai_model_error() {
4309        let (router, _) = router_with_capturing_lora_llm();
4310        let response = post_json(
4311            router,
4312            "/v1/chat/completions",
4313            json!({
4314                "model": "qwen3:missing",
4315                "messages": [{"role": "user", "content": "Say hi"}],
4316                "max_tokens": 8
4317            }),
4318        )
4319        .await;
4320        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
4321        let body = response_json(response).await;
4322        assert_eq!(body["error"]["type"], "invalid_request_error");
4323        assert_eq!(body["error"]["param"], "model");
4324        assert!(
4325            body["error"]["message"]
4326                .as_str()
4327                .unwrap_or_default()
4328                .contains("unknown LoRA adapter model"),
4329            "body: {body}"
4330        );
4331    }
4332
4333    #[tokio::test]
4334    async fn route_models_without_engine_returns_empty_list() {
4335        let response = get(router_without_llm(), "/v1/models").await;
4336        assert_eq!(response.status(), AxumStatusCode::OK);
4337        let body = response_json(response).await;
4338        assert_eq!(body["object"], "list");
4339        assert!(body["data"].as_array().unwrap().is_empty(), "body: {body}");
4340    }
4341
4342    #[tokio::test]
4343    async fn route_basic_chat_contract_uses_stub_engine() {
4344        let response = post_json(
4345            router_with_stub("hello"),
4346            "/v1/chat/completions",
4347            json!({
4348                "model": "stub-model",
4349                "messages": [{"role": "user", "content": "Say hi"}],
4350                "max_tokens": 8,
4351                "temperature": 0.0
4352            }),
4353        )
4354        .await;
4355        assert_eq!(response.status(), AxumStatusCode::OK);
4356        let body = response_json(response).await;
4357        assert_eq!(body["object"], "chat.completion");
4358        assert_eq!(body["choices"][0]["message"]["role"], "assistant");
4359        assert_eq!(body["choices"][0]["message"]["content"], "hello");
4360        assert_eq!(body["usage"]["prompt_tokens"], 7);
4361        assert_eq!(body["usage"]["completion_tokens"], 2);
4362    }
4363
4364    #[tokio::test]
4365    async fn route_chat_serializes_structured_tool_call_response() {
4366        let response = post_json(
4367            router_with_stub_api_response(
4368                "",
4369                ferrum_types::ApiResponse::Chat(ferrum_types::ApiChatResponse {
4370                    message: ferrum_types::ApiChatMessage {
4371                        role: ferrum_types::ApiMessageRole::Assistant,
4372                        content: String::new(),
4373                        name: None,
4374                        tool_calls: vec![ferrum_types::ApiToolCall {
4375                            id: "call_1".to_string(),
4376                            tool_type: "function".to_string(),
4377                            function: ferrum_types::ApiFunctionCall {
4378                                name: "weather".to_string(),
4379                                arguments: "{\"city\":\"Paris\"}".to_string(),
4380                            },
4381                        }],
4382                        tool_call_id: None,
4383                        function_call: None,
4384                    },
4385                    finish_reason: Some("tool_calls".to_string()),
4386                }),
4387            ),
4388            "/v1/chat/completions",
4389            json!({
4390                "model": "stub-model",
4391                "messages": [{"role": "user", "content": "Use the weather tool."}],
4392                "tools": [{
4393                    "type": "function",
4394                    "function": {
4395                        "name": "weather",
4396                        "parameters": {
4397                            "type": "object",
4398                            "properties": {"city": {"type": "string"}},
4399                            "required": ["city"]
4400                        }
4401                    }
4402                }],
4403                "tool_choice": "auto"
4404            }),
4405        )
4406        .await;
4407        assert_eq!(response.status(), AxumStatusCode::OK);
4408        let body = response_json(response).await;
4409        assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
4410        assert_eq!(
4411            body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
4412            "weather"
4413        );
4414        assert_eq!(
4415            body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
4416            "{\"city\":\"Paris\"}"
4417        );
4418    }
4419
4420    #[tokio::test]
4421    async fn route_chat_serializes_generated_tool_call_json_when_engine_returns_text_only() {
4422        let response = post_json(
4423            router_with_stub(
4424                r#"{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"weather","arguments":{"city":"Paris"}}}]}"#,
4425            ),
4426            "/v1/chat/completions",
4427            json!({
4428                "model": "stub-model",
4429                "messages": [{"role": "user", "content": "Use the weather tool."}],
4430                "tools": [{
4431                    "type": "function",
4432                    "function": {
4433                        "name": "weather",
4434                        "parameters": {
4435                            "type": "object",
4436                            "properties": {"city": {"type": "string"}},
4437                            "required": ["city"]
4438                        }
4439                    }
4440                }],
4441                "tool_choice": "auto"
4442            }),
4443        )
4444        .await;
4445        assert_eq!(response.status(), AxumStatusCode::OK);
4446        let body = response_json(response).await;
4447        assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
4448        assert_eq!(body["choices"][0]["message"]["content"], "");
4449        assert_eq!(
4450            body["choices"][0]["message"]["tool_calls"][0]["id"],
4451            "call_1"
4452        );
4453        assert_eq!(
4454            body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
4455            "weather"
4456        );
4457        assert_eq!(
4458            body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
4459            "{\"city\":\"Paris\"}"
4460        );
4461    }
4462
4463    #[tokio::test]
4464    async fn route_chat_serializes_qwen3_function_parameters_tool_json() {
4465        let response = post_json(
4466            router_with_stub(
4467                r#"{"function":"get_weather","parameters":{"city":"北京","unit":"c"}}"#,
4468            ),
4469            "/v1/chat/completions",
4470            json!({
4471                "model": "stub-model",
4472                "messages": [{"role": "user", "content": "北京现在天气怎么样?"}],
4473                "tools": [{
4474                    "type": "function",
4475                    "function": {
4476                        "name": "get_weather",
4477                        "parameters": {
4478                            "type": "object",
4479                            "properties": {
4480                                "city": {"type": "string"},
4481                                "unit": {"type": "string", "enum": ["c", "f"]}
4482                            },
4483                            "required": ["city"]
4484                        }
4485                    }
4486                }],
4487                "tool_choice": "auto"
4488            }),
4489        )
4490        .await;
4491        assert_eq!(response.status(), AxumStatusCode::OK);
4492        let body = response_json(response).await;
4493        assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
4494        assert_eq!(body["choices"][0]["message"]["content"], "");
4495        assert_eq!(
4496            body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
4497            "get_weather"
4498        );
4499        assert_eq!(
4500            body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
4501            "{\"city\":\"北京\",\"unit\":\"c\"}"
4502        );
4503    }
4504
4505    #[tokio::test]
4506    async fn route_chat_parses_tool_call_from_reasoning_before_fake_tool_result_content() {
4507        let response = post_json(
4508            router_with_stub(
4509                "kaza\n\
4510                 {\"name\":\"get_weather\",\"arguments\":{\"city\":\"北京\",\"unit\":\"celsius\"}}\n\
4511                 </think>\n\
4512                 {\"name\":\"get_weather\",\"content\":{\"temperature\":25,\"condition\":\"晴\"}}\n\
4513                 {\"temperature\":25,\"condition\":\"晴\"}",
4514            ),
4515            "/v1/chat/completions",
4516            json!({
4517                "model": "stub-model",
4518                "messages": [{"role": "user", "content": "北京现在天气怎么样?请先调用工具。"}],
4519                "tools": [{
4520                    "type": "function",
4521                    "function": {
4522                        "name": "get_weather",
4523                        "parameters": {
4524                            "type": "object",
4525                            "properties": {
4526                                "city": {"type": "string"},
4527                                "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
4528                            },
4529                            "required": ["city"]
4530                        }
4531                    }
4532                }]
4533            }),
4534        )
4535        .await;
4536        assert_eq!(response.status(), AxumStatusCode::OK);
4537        let body = response_json(response).await;
4538        assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
4539        assert_eq!(body["choices"][0]["message"]["content"], "");
4540        assert_eq!(
4541            body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
4542            "get_weather"
4543        );
4544        assert_eq!(
4545            body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
4546            "{\"city\":\"北京\",\"unit\":\"celsius\"}"
4547        );
4548    }
4549
4550    #[tokio::test]
4551    async fn route_chat_prefers_reasoning_tool_call_over_empty_visible_arguments() {
4552        let response = post_json(
4553            router_with_stub(
4554                "{\"name\":\"get_weather\",\"arguments\":{\"city\":\"北京\",\"unit\":\"celsius\"}}\n\
4555                 </think>\n\
4556                 {\"name\":\"get_weather\",\"arguments\":{}}",
4557            ),
4558            "/v1/chat/completions",
4559            json!({
4560                "model": "stub-model",
4561                "messages": [{"role": "user", "content": "北京现在天气怎么样?请先调用 get_weather 工具。"}],
4562                "tools": [{
4563                    "type": "function",
4564                    "function": {
4565                        "name": "get_weather",
4566                        "parameters": {
4567                            "type": "object",
4568                            "properties": {
4569                                "city": {"type": "string"},
4570                                "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
4571                            },
4572                            "required": ["city"]
4573                        }
4574                    }
4575                }]
4576            }),
4577        )
4578        .await;
4579        assert_eq!(response.status(), AxumStatusCode::OK);
4580        let body = response_json(response).await;
4581        assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
4582        assert_eq!(
4583            body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
4584            "get_weather"
4585        );
4586        assert_eq!(
4587            body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
4588            "{\"city\":\"北京\",\"unit\":\"celsius\"}"
4589        );
4590    }
4591
4592    #[tokio::test]
4593    async fn route_chat_honors_specific_tool_choice_for_generated_tool_call_json() {
4594        let response = post_json(
4595            router_with_stub(r#"{"name":"weather","arguments":{"city":"Paris"}}"#),
4596            "/v1/chat/completions",
4597            json!({
4598                "model": "stub-model",
4599                "messages": [{"role": "user", "content": "Use the selected tool."}],
4600                "tools": [
4601                    {
4602                        "type": "function",
4603                        "function": {"name": "weather", "parameters": {"type": "object"}}
4604                    },
4605                    {
4606                        "type": "function",
4607                        "function": {"name": "calendar", "parameters": {"type": "object"}}
4608                    }
4609                ],
4610                "tool_choice": {
4611                    "type": "function",
4612                    "function": {"name": "weather"}
4613                }
4614            }),
4615        )
4616        .await;
4617        assert_eq!(response.status(), AxumStatusCode::OK);
4618        let body = response_json(response).await;
4619        assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
4620        assert_eq!(
4621            body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
4622            "weather"
4623        );
4624
4625        let response = post_json(
4626            router_with_stub(r#"{"name":"calendar","arguments":{}}"#),
4627            "/v1/chat/completions",
4628            json!({
4629                "model": "stub-model",
4630                "messages": [{"role": "user", "content": "Use the selected tool."}],
4631                "tools": [
4632                    {
4633                        "type": "function",
4634                        "function": {"name": "weather", "parameters": {"type": "object"}}
4635                    },
4636                    {
4637                        "type": "function",
4638                        "function": {"name": "calendar", "parameters": {"type": "object"}}
4639                    }
4640                ],
4641                "tool_choice": {
4642                    "type": "function",
4643                    "function": {"name": "weather"}
4644                }
4645            }),
4646        )
4647        .await;
4648        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
4649        let body = response_json(response).await;
4650        assert_eq!(body["error"]["param"], "tool_choice");
4651        assert_eq!(body["error"]["type"], "invalid_request_error");
4652    }
4653
4654    #[tokio::test]
4655    async fn route_chat_specific_tool_choice_wraps_generated_arguments() {
4656        let response = post_json(
4657            router_with_stub(r#"{"city":"Paris"}"#),
4658            "/v1/chat/completions",
4659            json!({
4660                "model": "stub-model",
4661                "messages": [{"role": "user", "content": "Use the selected tool."}],
4662                "tools": [{
4663                    "type": "function",
4664                    "function": {
4665                        "name": "weather",
4666                        "parameters": {
4667                            "type": "object",
4668                            "properties": {"city": {"type": "string"}},
4669                            "required": ["city"]
4670                        }
4671                    }
4672                }],
4673                "tool_choice": {
4674                    "type": "function",
4675                    "function": {"name": "weather"}
4676                }
4677            }),
4678        )
4679        .await;
4680        assert_eq!(response.status(), AxumStatusCode::OK);
4681        let body = response_json(response).await;
4682        assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
4683        assert_eq!(body["choices"][0]["message"]["content"], "");
4684        assert_eq!(
4685            body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
4686            "weather"
4687        );
4688        assert_eq!(
4689            body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
4690            "{\"city\":\"Paris\"}"
4691        );
4692    }
4693
4694    #[tokio::test]
4695    async fn route_chat_tool_choice_none_keeps_generated_tool_json_as_content() {
4696        let generated = r#"{"name":"weather","arguments":{"city":"Paris"}}"#;
4697        let response = post_json(
4698            router_with_stub(generated),
4699            "/v1/chat/completions",
4700            json!({
4701                "model": "stub-model",
4702                "messages": [{"role": "user", "content": "Do not use tools."}],
4703                "tools": [{
4704                    "type": "function",
4705                    "function": {"name": "weather", "parameters": {"type": "object"}}
4706                }],
4707                "tool_choice": "none"
4708            }),
4709        )
4710        .await;
4711        assert_eq!(response.status(), AxumStatusCode::OK);
4712        let body = response_json(response).await;
4713        assert_eq!(body["choices"][0]["finish_reason"], "stop");
4714        assert_eq!(body["choices"][0]["message"]["content"], generated);
4715        assert!(body["choices"][0]["message"]["tool_calls"].is_null());
4716    }
4717
4718    #[tokio::test]
4719    async fn route_chat_tool_choice_required_wraps_generated_arguments() {
4720        let response = post_json(
4721            router_with_stub(r#"{"city":"Paris"}"#),
4722            "/v1/chat/completions",
4723            json!({
4724                "model": "stub-model",
4725                "messages": [{"role": "user", "content": "Use a tool."}],
4726                "tools": [{
4727                    "type": "function",
4728                    "function": {
4729                        "name": "weather",
4730                        "parameters": {
4731                            "type": "object",
4732                            "properties": {"city": {"type": "string"}},
4733                            "required": ["city"]
4734                        }
4735                    }
4736                }],
4737                "tool_choice": "required"
4738            }),
4739        )
4740        .await;
4741        assert_eq!(response.status(), AxumStatusCode::OK);
4742        let body = response_json(response).await;
4743        assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
4744        assert_eq!(body["choices"][0]["message"]["content"], "");
4745        assert_eq!(
4746            body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
4747            "weather"
4748        );
4749        assert_eq!(
4750            body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
4751            "{\"city\":\"Paris\"}"
4752        );
4753    }
4754
4755    #[tokio::test]
4756    async fn route_chat_tool_choice_required_errors_without_valid_tool_call() {
4757        let response = post_json(
4758            router_with_stub("plain answer"),
4759            "/v1/chat/completions",
4760            json!({
4761                "model": "stub-model",
4762                "messages": [{"role": "user", "content": "Use a tool."}],
4763                "tools": [{
4764                    "type": "function",
4765                    "function": {"name": "weather", "parameters": {"type": "object"}}
4766                }],
4767                "tool_choice": "required"
4768            }),
4769        )
4770        .await;
4771        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
4772        let body = response_json(response).await;
4773        assert_eq!(body["error"]["type"], "invalid_request_error");
4774        assert_eq!(body["error"]["param"], "tool_choice");
4775        assert!(
4776            body["error"]["message"]
4777                .as_str()
4778                .is_some_and(|message| message.contains("required tool_choice")),
4779            "body: {body}"
4780        );
4781    }
4782
4783    #[tokio::test]
4784    async fn route_streaming_chat_serializes_generated_tool_call_delta() {
4785        let response = post_json(
4786            router_with_stub(
4787                r#"{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"weather","arguments":{"city":"Paris"}}}]}"#,
4788            ),
4789            "/v1/chat/completions",
4790            json!({
4791                "model": "stub-model",
4792                "messages": [{"role": "user", "content": "Use the weather tool."}],
4793                "stream": true,
4794                "tools": [{
4795                    "type": "function",
4796                    "function": {
4797                        "name": "weather",
4798                        "parameters": {
4799                            "type": "object",
4800                            "properties": {"city": {"type": "string"}},
4801                            "required": ["city"]
4802                        }
4803                    }
4804                }],
4805                "tool_choice": "auto"
4806            }),
4807        )
4808        .await;
4809        assert_eq!(response.status(), AxumStatusCode::OK);
4810        let body = response_text(response).await;
4811        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
4812        assert!(
4813            body.contains(r#""finish_reason":"tool_calls""#),
4814            "stream should finish with tool_calls: {body}"
4815        );
4816        assert!(
4817            body.contains(r#""tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"weather""#),
4818            "stream should emit OpenAI tool_calls delta with index: {body}"
4819        );
4820        assert!(
4821            body.contains(r#""arguments":"{\"city\":\"Paris\"}""#),
4822            "tool arguments should be serialized as JSON string: {body}"
4823        );
4824        assert!(
4825            !body.contains(r#""content":"{\"tool_calls\""#),
4826            "raw tool-call JSON should not be streamed as assistant content: {body}"
4827        );
4828    }
4829
4830    #[tokio::test]
4831    async fn route_streaming_chat_serializes_qwen3_function_parameters_tool_delta() {
4832        let response = post_json(
4833            router_with_stub(
4834                r#"{"function":"get_weather","parameters":{"city":"深圳","unit":"c"}}"#,
4835            ),
4836            "/v1/chat/completions",
4837            json!({
4838                "model": "stub-model",
4839                "messages": [{"role": "user", "content": "深圳天气?"}],
4840                "stream": true,
4841                "tools": [{
4842                    "type": "function",
4843                    "function": {
4844                        "name": "get_weather",
4845                        "parameters": {
4846                            "type": "object",
4847                            "properties": {
4848                                "city": {"type": "string"},
4849                                "unit": {"type": "string", "enum": ["c", "f"]}
4850                            },
4851                            "required": ["city"]
4852                        }
4853                    }
4854                }],
4855                "tool_choice": "auto"
4856            }),
4857        )
4858        .await;
4859        assert_eq!(response.status(), AxumStatusCode::OK);
4860        let body = response_text(response).await;
4861        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
4862        assert!(
4863            body.contains(r#""finish_reason":"tool_calls""#),
4864            "stream should finish with tool_calls: {body}"
4865        );
4866        assert!(
4867            body.contains(r#""function":{"name":"get_weather","arguments":"{\"city\":\"深圳\",\"unit\":\"c\"}"}"#),
4868            "stream should emit parsed Qwen3 function parameters as tool args: {body}"
4869        );
4870        assert!(
4871            !body.contains(r#""content":"{\"function\""#),
4872            "raw Qwen3 tool JSON should not leak as assistant content: {body}"
4873        );
4874    }
4875
4876    #[tokio::test]
4877    async fn route_streaming_chat_honors_specific_tool_choice_for_generated_tool_call_delta() {
4878        let request = |generated: &'static str| {
4879            post_json(
4880                router_with_stub(generated),
4881                "/v1/chat/completions",
4882                json!({
4883                    "model": "stub-model",
4884                    "messages": [{"role": "user", "content": "Use the selected tool."}],
4885                    "stream": true,
4886                    "tools": [
4887                        {
4888                            "type": "function",
4889                            "function": {"name": "weather", "parameters": {"type": "object"}}
4890                        },
4891                        {
4892                            "type": "function",
4893                            "function": {"name": "calendar", "parameters": {"type": "object"}}
4894                        }
4895                    ],
4896                    "tool_choice": {
4897                        "type": "function",
4898                        "function": {"name": "weather"}
4899                    }
4900                }),
4901            )
4902        };
4903
4904        let response = request(r#"{"name":"weather","arguments":{"city":"Paris"}}"#).await;
4905        assert_eq!(response.status(), AxumStatusCode::OK);
4906        let body = response_text(response).await;
4907        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
4908        assert!(
4909            body.contains(r#""finish_reason":"tool_calls""#),
4910            "selected tool should finish with tool_calls: {body}"
4911        );
4912        assert!(
4913            body.contains(r#""function":{"name":"weather","arguments":"{\"city\":\"Paris\"}"}"#),
4914            "selected tool should stream as tool_calls delta: {body}"
4915        );
4916
4917        let response = request(r#"{"name":"calendar","arguments":{}}"#).await;
4918        assert_eq!(response.status(), AxumStatusCode::OK);
4919        let body = response_text(response).await;
4920        assert!(
4921            body.contains(
4922                r#""error":{"message":"model output did not satisfy required tool_choice""#
4923            ),
4924            "selected-tool stream should reject unselected tool output: {body}"
4925        );
4926        assert!(
4927            !body.contains(r#""finish_reason":"tool_calls""#),
4928            "unselected tool JSON must not become tool_calls: {body}"
4929        );
4930    }
4931
4932    #[tokio::test]
4933    async fn route_streaming_chat_prefers_chunk_api_response_for_tool_delta() {
4934        let response = post_json(
4935            router_with_stub_api_response(
4936                "raw text that should not stream",
4937                ferrum_types::ApiResponse::Chat(ferrum_types::ApiChatResponse {
4938                    message: ferrum_types::ApiChatMessage {
4939                        role: ferrum_types::ApiMessageRole::Assistant,
4940                        content: String::new(),
4941                        name: None,
4942                        tool_calls: vec![ferrum_types::ApiToolCall {
4943                            id: "call_1".to_string(),
4944                            tool_type: "function".to_string(),
4945                            function: ferrum_types::ApiFunctionCall {
4946                                name: "weather".to_string(),
4947                                arguments: "{\"city\":\"Paris\"}".to_string(),
4948                            },
4949                        }],
4950                        tool_call_id: None,
4951                        function_call: None,
4952                    },
4953                    finish_reason: Some("tool_calls".to_string()),
4954                }),
4955            ),
4956            "/v1/chat/completions",
4957            json!({
4958                "model": "stub-model",
4959                "messages": [{"role": "user", "content": "Use the weather tool."}],
4960                "stream": true,
4961                "tools": [{
4962                    "type": "function",
4963                    "function": {"name": "weather", "parameters": {"type": "object"}}
4964                }],
4965                "tool_choice": "auto"
4966            }),
4967        )
4968        .await;
4969        assert_eq!(response.status(), AxumStatusCode::OK);
4970        let body = response_text(response).await;
4971        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
4972        assert!(
4973            body.contains(r#""finish_reason":"tool_calls""#),
4974            "stream should finish with tool_calls: {body}"
4975        );
4976        assert!(
4977            body.contains(r#""tool_calls":[{"index":0,"id":"call_1""#),
4978            "stream should emit tool_calls from chunk api_response: {body}"
4979        );
4980        assert!(
4981            !body.contains("raw text that should not stream"),
4982            "structured api_response should suppress raw generated text in tool-call stream: {body}"
4983        );
4984    }
4985
4986    #[tokio::test]
4987    async fn route_streaming_chat_tool_choice_required_errors_without_leaking_content() {
4988        let response = post_json(
4989            router_with_stub("plain answer"),
4990            "/v1/chat/completions",
4991            json!({
4992                "model": "stub-model",
4993                "messages": [{"role": "user", "content": "Use a tool."}],
4994                "stream": true,
4995                "tools": [{
4996                    "type": "function",
4997                    "function": {"name": "weather", "parameters": {"type": "object"}}
4998                }],
4999                "tool_choice": "required"
5000            }),
5001        )
5002        .await;
5003        assert_eq!(response.status(), AxumStatusCode::OK);
5004        let body = response_text(response).await;
5005        assert_eq!(body.matches("data: [DONE]").count(), 1, "body: {body}");
5006        assert!(
5007            body.contains(
5008                r#""error":{"message":"model output did not satisfy required tool_choice""#
5009            ),
5010            "stream should emit OpenAI error envelope: {body}"
5011        );
5012        assert!(
5013            body.contains(r#""type":"invalid_request_error""#),
5014            "stream should use invalid_request_error: {body}"
5015        );
5016        assert!(
5017            body.contains(r#""param":"tool_choice""#),
5018            "stream should include tool_choice param: {body}"
5019        );
5020        assert!(
5021            !body.contains(r#""content":"plain answer""#),
5022            "required stream must not leak invalid content before validation: {body}"
5023        );
5024    }
5025
5026    #[tokio::test]
5027    async fn route_streaming_chat_tool_request_falls_back_to_content_when_no_tool_call() {
5028        let response = post_json(
5029            router_with_stub("plain answer"),
5030            "/v1/chat/completions",
5031            json!({
5032                "model": "stub-model",
5033                "messages": [{"role": "user", "content": "Use the weather tool if needed."}],
5034                "stream": true,
5035                "tools": [{
5036                    "type": "function",
5037                    "function": {"name": "weather", "parameters": {"type": "object"}}
5038                }],
5039                "tool_choice": "auto"
5040            }),
5041        )
5042        .await;
5043        assert_eq!(response.status(), AxumStatusCode::OK);
5044        let body = response_text(response).await;
5045        assert!(
5046            body.contains(r#""content":"plain answer""#),
5047            "plain content should still stream when no tool call is generated: {body}"
5048        );
5049        assert!(
5050            body.contains(r#""finish_reason":"stop""#),
5051            "plain content should keep normal finish reason: {body}"
5052        );
5053        assert!(
5054            !body.contains(r#""tool_calls""#),
5055            "fallback content should not synthesize tool_calls: {body}"
5056        );
5057    }
5058
5059    #[tokio::test]
5060    async fn route_streaming_chat_serializes_generated_legacy_function_call_delta() {
5061        let response = post_json(
5062            router_with_stub(
5063                r#"{"function_call":{"name":"weather","arguments":{"city":"Paris"}}}"#,
5064            ),
5065            "/v1/chat/completions",
5066            json!({
5067                "model": "stub-model",
5068                "messages": [{"role": "user", "content": "Use the weather function."}],
5069                "stream": true,
5070                "functions": [{
5071                    "name": "weather",
5072                    "parameters": {
5073                        "type": "object",
5074                        "properties": {"city": {"type": "string"}},
5075                        "required": ["city"]
5076                    }
5077                }],
5078                "function_call": "auto"
5079            }),
5080        )
5081        .await;
5082        assert_eq!(response.status(), AxumStatusCode::OK);
5083        let body = response_text(response).await;
5084        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
5085        assert!(
5086            body.contains(r#""finish_reason":"function_call""#),
5087            "stream should finish with function_call: {body}"
5088        );
5089        assert!(
5090            body.contains(
5091                r#""function_call":{"name":"weather","arguments":"{\"city\":\"Paris\"}"}"#
5092            ),
5093            "stream should emit OpenAI legacy function_call delta: {body}"
5094        );
5095        assert!(
5096            !body.contains(r#""content":"{\"function_call\""#),
5097            "raw function-call JSON should not be streamed as assistant content: {body}"
5098        );
5099    }
5100
5101    #[tokio::test]
5102    async fn route_streaming_chat_honors_specific_legacy_function_call_delta() {
5103        let request = |generated: &'static str| {
5104            post_json(
5105                router_with_stub(generated),
5106                "/v1/chat/completions",
5107                json!({
5108                    "model": "stub-model",
5109                    "messages": [{"role": "user", "content": "Use the selected function."}],
5110                    "stream": true,
5111                    "functions": [
5112                        {"name": "weather", "parameters": {"type": "object"}},
5113                        {"name": "calendar", "parameters": {"type": "object"}}
5114                    ],
5115                    "function_call": {"name": "weather"}
5116                }),
5117            )
5118        };
5119
5120        let response =
5121            request(r#"{"function_call":{"name":"weather","arguments":{"city":"Paris"}}}"#).await;
5122        assert_eq!(response.status(), AxumStatusCode::OK);
5123        let body = response_text(response).await;
5124        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
5125        assert!(
5126            body.contains(r#""finish_reason":"function_call""#),
5127            "selected function should finish with function_call: {body}"
5128        );
5129        assert!(
5130            body.contains(
5131                r#""function_call":{"name":"weather","arguments":"{\"city\":\"Paris\"}"}"#
5132            ),
5133            "selected function should stream as function_call delta: {body}"
5134        );
5135
5136        let response = request(r#"{"function_call":{"name":"calendar","arguments":{}}}"#).await;
5137        assert_eq!(response.status(), AxumStatusCode::OK);
5138        let body = response_text(response).await;
5139        assert!(
5140            body.contains(
5141                r#""content":"{\"function_call\":{\"name\":\"calendar\",\"arguments\":{}}}""#
5142            ),
5143            "unselected function JSON should stream as ordinary content: {body}"
5144        );
5145        assert!(
5146            body.contains(r#""finish_reason":"stop""#),
5147            "unselected function JSON should keep normal stop finish: {body}"
5148        );
5149        assert!(
5150            !body.contains(r#""finish_reason":"function_call""#),
5151            "unselected function JSON must not become function_call: {body}"
5152        );
5153    }
5154
5155    #[tokio::test]
5156    async fn route_chat_serializes_generated_legacy_function_call_when_engine_returns_text_only() {
5157        let response = post_json(
5158            router_with_stub(
5159                r#"{"function_call":{"name":"weather","arguments":{"city":"Paris"}}}"#,
5160            ),
5161            "/v1/chat/completions",
5162            json!({
5163                "model": "stub-model",
5164                "messages": [{"role": "user", "content": "Use the weather function."}],
5165                "functions": [{
5166                    "name": "weather",
5167                    "parameters": {
5168                        "type": "object",
5169                        "properties": {"city": {"type": "string"}},
5170                        "required": ["city"]
5171                    }
5172                }],
5173                "function_call": "auto"
5174            }),
5175        )
5176        .await;
5177        assert_eq!(response.status(), AxumStatusCode::OK);
5178        let body = response_json(response).await;
5179        assert_eq!(body["choices"][0]["finish_reason"], "function_call");
5180        assert_eq!(body["choices"][0]["message"]["content"], "");
5181        assert_eq!(
5182            body["choices"][0]["message"]["function_call"]["name"],
5183            "weather"
5184        );
5185        assert_eq!(
5186            body["choices"][0]["message"]["function_call"]["arguments"],
5187            "{\"city\":\"Paris\"}"
5188        );
5189    }
5190
5191    #[tokio::test]
5192    async fn route_chat_serializes_legacy_function_call_response() {
5193        let response = post_json(
5194            router_with_stub_api_response(
5195                "",
5196                ferrum_types::ApiResponse::Chat(ferrum_types::ApiChatResponse {
5197                    message: ferrum_types::ApiChatMessage {
5198                        role: ferrum_types::ApiMessageRole::Assistant,
5199                        content: String::new(),
5200                        name: None,
5201                        tool_calls: vec![],
5202                        tool_call_id: None,
5203                        function_call: Some(ferrum_types::ApiFunctionCall {
5204                            name: "weather".to_string(),
5205                            arguments: "{\"city\":\"Paris\"}".to_string(),
5206                        }),
5207                    },
5208                    finish_reason: Some("function_call".to_string()),
5209                }),
5210            ),
5211            "/v1/chat/completions",
5212            json!({
5213                "model": "stub-model",
5214                "messages": [{"role": "user", "content": "Use the weather function."}],
5215                "functions": [{
5216                    "name": "weather",
5217                    "parameters": {
5218                        "type": "object",
5219                        "properties": {"city": {"type": "string"}},
5220                        "required": ["city"]
5221                    }
5222                }],
5223                "function_call": "auto"
5224            }),
5225        )
5226        .await;
5227        assert_eq!(response.status(), AxumStatusCode::OK);
5228        let body = response_json(response).await;
5229        assert_eq!(body["choices"][0]["finish_reason"], "function_call");
5230        assert_eq!(
5231            body["choices"][0]["message"]["function_call"]["name"],
5232            "weather"
5233        );
5234        assert_eq!(
5235            body["choices"][0]["message"]["function_call"]["arguments"],
5236            "{\"city\":\"Paris\"}"
5237        );
5238    }
5239
5240    #[tokio::test]
5241    async fn route_streaming_chat_include_usage_contract() {
5242        let response = post_json(
5243            router_with_stub("ok"),
5244            "/v1/chat/completions",
5245            json!({
5246                "model": "stub-model",
5247                "messages": [{"role": "user", "content": "Say ok"}],
5248                "stream": true,
5249                "stream_options": {"include_usage": true}
5250            }),
5251        )
5252        .await;
5253        assert_eq!(response.status(), AxumStatusCode::OK);
5254        let body = response_text(response).await;
5255        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
5256        assert!(
5257            body.contains("\"object\":\"chat.completion.chunk\""),
5258            "missing chat chunk: {body}"
5259        );
5260        assert!(
5261            body.contains("\"usage\":{\"prompt_tokens\""),
5262            "missing final usage chunk: {body}"
5263        );
5264        assert!(
5265            body.contains("\"choices\":[],\"usage\""),
5266            "usage should be emitted as a separate chunk: {body}"
5267        );
5268        assert!(
5269            body.contains("\"prompt_tokens\":5"),
5270            "stream usage should come from engine token usage: {body}"
5271        );
5272    }
5273
5274    #[tokio::test]
5275    async fn route_streaming_chat_waits_for_separate_final_usage_at_max_tokens() {
5276        let response = post_json(
5277            router_with_stub_separate_final_stream_chunk(&["he", "llo"]),
5278            "/v1/chat/completions",
5279            json!({
5280                "model": "stub-model",
5281                "messages": [{"role": "user", "content": "Say hello"}],
5282                "max_tokens": 2,
5283                "stream": true,
5284                "stream_options": {"include_usage": true}
5285            }),
5286        )
5287        .await;
5288        assert_eq!(response.status(), AxumStatusCode::OK);
5289        let body = response_text(response).await;
5290        assert_eq!(body.matches("data: [DONE]").count(), 1, "body: {body}");
5291        assert!(
5292            body.contains("\"content\":\"he\""),
5293            "missing first chunk: {body}"
5294        );
5295        assert!(
5296            body.contains("\"content\":\"llo\""),
5297            "missing second chunk: {body}"
5298        );
5299        assert!(
5300            body.contains("\"choices\":[],\"usage\""),
5301            "missing separate usage chunk from final engine chunk: {body}"
5302        );
5303        assert!(
5304            body.contains("\"prompt_tokens\":5"),
5305            "stream usage should come from engine final usage: {body}"
5306        );
5307    }
5308
5309    #[tokio::test]
5310    async fn route_rejects_multimodal_content_with_400() {
5311        let response = post_json(
5312            router_with_stub("unused"),
5313            "/v1/chat/completions",
5314            json!({
5315                "model": "stub-model",
5316                "messages": [{
5317                    "role": "user",
5318                    "content": [
5319                        {"type": "text", "text": "describe this"},
5320                        {"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}}
5321                    ]
5322                }]
5323            }),
5324        )
5325        .await;
5326        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
5327        let body = response_json(response).await;
5328        assert_eq!(body["error"]["type"], "invalid_request_error");
5329        assert!(body["error"]["message"]
5330            .as_str()
5331            .unwrap()
5332            .contains("invalid chat completions request"));
5333    }
5334
5335    #[tokio::test]
5336    async fn route_chat_invalid_json_maps_to_openai_error() {
5337        let response = post_raw_json(router_with_stub("unused"), "/v1/chat/completions", "{").await;
5338        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
5339        let body = response_json(response).await;
5340        assert_eq!(body["error"]["type"], "invalid_request_error");
5341        assert_eq!(body["error"]["param"], Value::Null);
5342        assert!(body["error"]["message"]
5343            .as_str()
5344            .unwrap()
5345            .contains("invalid chat completions request"));
5346    }
5347
5348    #[tokio::test]
5349    async fn route_rejects_logit_bias_with_openai_error_param() {
5350        let response = post_json(
5351            router_with_stub("unused"),
5352            "/v1/chat/completions",
5353            json!({
5354                "model": "stub-model",
5355                "messages": [{"role": "user", "content": "hello"}],
5356                "logit_bias": {"1": 42.0}
5357            }),
5358        )
5359        .await;
5360        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
5361        let body = response_json(response).await;
5362        assert_eq!(body["error"]["type"], "invalid_request_error");
5363        assert_eq!(body["error"]["param"], "logit_bias");
5364    }
5365
5366    #[tokio::test]
5367    async fn route_tool_request_reaches_engine_structured_boundary() {
5368        let (router, engine) = router_with_capturing_llm();
5369        let response = post_json(
5370            router,
5371            "/v1/chat/completions",
5372            json!({
5373                "model": "qwen3",
5374                "messages": [
5375                    {"role": "user", "content": "Use the weather tool."},
5376                    {
5377                        "role": "assistant",
5378                        "content": null,
5379                        "tool_calls": [{
5380                            "id": "call_1",
5381                            "type": "function",
5382                            "function": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"}
5383                        }]
5384                    },
5385                    {"role": "tool", "tool_call_id": "call_1", "content": "sunny"}
5386                ],
5387                "tools": [{
5388                    "type": "function",
5389                    "function": {
5390                        "name": "weather",
5391                        "description": "Get weather",
5392                        "parameters": {
5393                            "type": "object",
5394                            "properties": {"city": {"type": "string"}},
5395                            "required": ["city"]
5396                        }
5397                    }
5398                }],
5399                "tool_choice": "auto",
5400                "functions": [{
5401                    "name": "legacy_weather",
5402                    "parameters": {"type": "object", "properties": {}}
5403                }],
5404                "function_call": "auto"
5405            }),
5406        )
5407        .await;
5408        assert_eq!(response.status(), AxumStatusCode::OK);
5409
5410        let request = engine.last_request();
5411        assert!(request.prompt.contains("\"tools\":[{"));
5412        assert!(request.prompt.contains("\"type\":\"function\""));
5413        assert!(request.prompt.contains("\"name\":\"weather\""));
5414        assert!(request.prompt.contains("<|im_start|>assistant\n{"));
5415        assert!(request.prompt.contains("\"tool_calls\":[{"));
5416        assert!(request.prompt.contains("\"id\":\"call_1\""));
5417        assert!(request.prompt.contains("<|im_start|>tool\nsunny<|im_end|>"));
5418        assert_eq!(
5419            request.metadata["openai_tools"][0]["function"]["name"],
5420            "weather"
5421        );
5422        assert_eq!(request.metadata["openai_tool_choice"], "auto");
5423        assert_eq!(
5424            request.metadata["openai_legacy_functions"][0]["name"],
5425            "legacy_weather"
5426        );
5427        assert_eq!(request.metadata["openai_legacy_function_call"], "auto");
5428        let Some(ferrum_types::ApiRequest::Chat(api)) = request.api_request.as_ref() else {
5429            panic!("expected structured chat api_request");
5430        };
5431        assert_eq!(api.messages.len(), 3);
5432        assert_eq!(api.messages[2].role, ferrum_types::ApiMessageRole::Tool);
5433        assert_eq!(api.messages[2].tool_call_id.as_deref(), Some("call_1"));
5434        assert_eq!(api.tools[0].function.name, "weather");
5435        assert_eq!(api.legacy_functions[0].name, "legacy_weather");
5436        assert_eq!(
5437            api.messages[1].tool_calls[0].function.arguments,
5438            "{\"city\":\"Paris\"}"
5439        );
5440    }
5441
5442    #[tokio::test]
5443    async fn route_tool_request_prefers_model_chat_template() {
5444        let template = ModelChatTemplate::new(
5445            "{% if tools %}<tools>{% for tool in tools %}{{ tool.function.name }}{% endfor %}</tools>{% endif %}{% for message in messages %}[{{ message.role }}]{{ message.content }}{% if message.tool_calls %}{% for tool_call in message.tool_calls %}<tool_call>{{ tool_call.function.name }}:{{ tool_call.function.arguments }}</tool_call>{% endfor %}{% endif %}{% if message.tool_call_id %}<tool_response id=\"{{ message.tool_call_id }}\">{{ message.content }}</tool_response>{% endif %}{% endfor %}{% if add_generation_prompt %}[assistant]{% endif %}",
5446            "tool-template",
5447        );
5448        let (router, engine) = router_with_capturing_llm_and_template(template);
5449        let response = post_json(
5450            router,
5451            "/v1/chat/completions",
5452            json!({
5453                "model": "served-alias",
5454                "messages": [
5455                    {"role": "user", "content": "Use the weather tool."},
5456                    {
5457                        "role": "assistant",
5458                        "content": null,
5459                        "tool_calls": [{
5460                            "id": "call_1",
5461                            "type": "function",
5462                            "function": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"}
5463                        }]
5464                    },
5465                    {"role": "tool", "tool_call_id": "call_1", "content": "sunny"}
5466                ],
5467                "tools": [{
5468                    "type": "function",
5469                    "function": {
5470                        "name": "weather",
5471                        "description": "Get weather",
5472                        "parameters": {"type": "object", "properties": {"city": {"type": "string"}}}
5473                    }
5474                }],
5475                "tool_choice": "auto"
5476            }),
5477        )
5478        .await;
5479        assert_eq!(response.status(), AxumStatusCode::OK);
5480
5481        let request = engine.last_request();
5482        assert!(request.prompt.contains("<tools>weather</tools>"));
5483        assert!(
5484            request.prompt.contains("<tool_call>weather:"),
5485            "{}",
5486            request.prompt
5487        );
5488        assert!(request.prompt.contains("\"city\""), "{}", request.prompt);
5489        assert!(request.prompt.contains("Paris"), "{}", request.prompt);
5490        assert!(request
5491            .prompt
5492            .contains("<tool_response id=\"call_1\">sunny</tool_response>"));
5493        assert!(
5494            !request.prompt.contains("<|assistant|>"),
5495            "model-template tool prompt should not use generic fallback: {}",
5496            request.prompt
5497        );
5498        assert!(
5499            !request.prompt.contains("When a tool is needed"),
5500            "model-template tool prompt should not inject fallback tool instructions: {}",
5501            request.prompt
5502        );
5503    }
5504
5505    #[tokio::test]
5506    async fn chat_accepts_stop_string_and_max_completion_tokens() {
5507        let (router, engine) = router_with_capturing_llm();
5508        let response = post_json(
5509            router,
5510            "/v1/chat/completions",
5511            json!({
5512                "model": "stub-model",
5513                "messages": [{"role": "user", "content": "hello"}],
5514                "max_tokens": 99,
5515                "max_completion_tokens": 3,
5516                "stop": "<END>"
5517            }),
5518        )
5519        .await;
5520        assert_eq!(response.status(), AxumStatusCode::OK);
5521
5522        let request = engine.last_request();
5523        assert_eq!(request.sampling_params.max_tokens, 3);
5524        assert_eq!(
5525            request.sampling_params.temperature,
5526            DEFAULT_SAMPLING_TEMPERATURE
5527        );
5528        assert_eq!(request.sampling_params.stop_sequences, vec!["<END>"]);
5529    }
5530
5531    #[tokio::test]
5532    async fn chat_request_forbids_initial_think_close_token() {
5533        let engine = Arc::new(CapturingLlm::new());
5534        let router = AxumServer::from_llm(engine.clone()).build_router();
5535        let response = post_json(
5536            router,
5537            "/v1/chat/completions",
5538            json!({
5539                "model": "stub-model",
5540                "messages": [{"role": "user", "content": "hello"}]
5541            }),
5542        )
5543        .await;
5544        assert_eq!(response.status(), AxumStatusCode::OK);
5545
5546        let request = engine.last_request();
5547        assert_eq!(
5548            request
5549                .metadata
5550                .get(INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY),
5551            Some(&serde_json::json!([THINK_END_TAG]))
5552        );
5553    }
5554
5555    #[tokio::test]
5556    async fn chat_template_enable_thinking_default_is_template_controlled() {
5557        let template = ModelChatTemplate::new(
5558            "{% for message in messages %}{{ '<|im_start|>' ~ message.role ~ '\n' ~ message.content ~ '<|im_end|>\n' }}{% endfor %}{% if add_generation_prompt %}{{ '<|im_start|>assistant\n' }}{% if enable_thinking is defined and enable_thinking is false %}{{ '<think>\n\n</think>\n\n' }}{% endif %}{% endif %}",
5559            "test-template",
5560        );
5561        let (router, engine) = router_with_capturing_llm_and_template(template);
5562        let response = post_json(
5563            router,
5564            "/v1/chat/completions",
5565            json!({
5566                "model": "served-alias",
5567                "messages": [{"role": "user", "content": "hello"}]
5568            }),
5569        )
5570        .await;
5571        assert_eq!(response.status(), AxumStatusCode::OK);
5572
5573        let request = engine.last_request();
5574        assert!(request
5575            .prompt
5576            .ends_with("<|im_start|>assistant\n<think>\n\n</think>\n\n"));
5577        assert_eq!(
5578            request
5579                .metadata
5580                .get(INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY),
5581            Some(&serde_json::json!([THINK_END_TAG, THINK_START_TAG]))
5582        );
5583    }
5584
5585    #[tokio::test]
5586    async fn chat_template_enable_thinking_true_overrides_default() {
5587        let template = ModelChatTemplate::new(
5588            "{% if add_generation_prompt %}<assistant>{% if enable_thinking is defined and enable_thinking is false %}<think>\n\n</think>\n\n{% endif %}{% endif %}",
5589            "test-template",
5590        );
5591        let (router, engine) = router_with_capturing_llm_and_template(template);
5592        let response = post_json(
5593            router,
5594            "/v1/chat/completions",
5595            json!({
5596                "model": "served-alias",
5597                "messages": [{"role": "user", "content": "hello"}],
5598                "chat_template_kwargs": {"enable_thinking": true}
5599            }),
5600        )
5601        .await;
5602        assert_eq!(response.status(), AxumStatusCode::OK);
5603
5604        let request = engine.last_request();
5605        assert_eq!(request.prompt, "<assistant>");
5606    }
5607
5608    #[tokio::test]
5609    async fn chat_template_enable_thinking_rejects_non_bool() {
5610        let template = ModelChatTemplate::new(
5611            "{% if add_generation_prompt %}<assistant>{% endif %}",
5612            "test-template",
5613        );
5614        let (router, _) = router_with_capturing_llm_and_template(template);
5615        let response = post_json(
5616            router,
5617            "/v1/chat/completions",
5618            json!({
5619                "model": "served-alias",
5620                "messages": [{"role": "user", "content": "hello"}],
5621                "chat_template_kwargs": {"enable_thinking": "false"}
5622            }),
5623        )
5624        .await;
5625        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
5626        let body = response_json(response).await;
5627        assert_eq!(body["error"]["type"], "invalid_request_error");
5628        assert!(body["error"]["message"]
5629            .as_str()
5630            .unwrap_or_default()
5631            .contains("chat_template_kwargs.enable_thinking must be a boolean"));
5632    }
5633
5634    #[tokio::test]
5635    async fn stop_string_strips_chat_and_completion_suffixes() {
5636        let chat = post_json(
5637            router_with_stub("hello<END>"),
5638            "/v1/chat/completions",
5639            json!({
5640                "model": "stub-model",
5641                "messages": [{"role": "user", "content": "hello"}],
5642                "stop": "<END>"
5643            }),
5644        )
5645        .await;
5646        assert_eq!(chat.status(), AxumStatusCode::OK);
5647        let chat_body = response_json(chat).await;
5648        assert_eq!(chat_body["choices"][0]["message"]["content"], "hello");
5649
5650        let completion = post_json(
5651            router_with_stub("done<END>"),
5652            "/v1/completions",
5653            json!({
5654                "model": "stub-model",
5655                "prompt": "complete",
5656                "stop": "<END>"
5657            }),
5658        )
5659        .await;
5660        assert_eq!(completion.status(), AxumStatusCode::OK);
5661        let completion_body = response_json(completion).await;
5662        assert_eq!(completion_body["choices"][0]["text"], "done");
5663    }
5664
5665    #[tokio::test]
5666    async fn chat_response_splits_reasoning_from_content() {
5667        let response = post_json(
5668            router_with_stub("<think>\nreasoning\n</think>\n\nfinal answer"),
5669            "/v1/chat/completions",
5670            json!({
5671                "model": "stub-model",
5672                "messages": [{"role": "user", "content": "hello"}]
5673            }),
5674        )
5675        .await;
5676        assert_eq!(response.status(), AxumStatusCode::OK);
5677
5678        let body = response_json(response).await;
5679        let message = &body["choices"][0]["message"];
5680        assert_eq!(message["content"], "final answer");
5681        assert_eq!(message["reasoning"], "\nreasoning\n");
5682        assert!(message.get("reasoning_content").is_none());
5683    }
5684
5685    #[tokio::test]
5686    async fn streaming_chat_reasoning_prefix_chunks_do_not_panic_or_leak_content() {
5687        let response = post_json(
5688            router_with_stub_stream_chunks(&["<", "think", ">\nreason", "\n</think>\n\nfinal"]),
5689            "/v1/chat/completions",
5690            json!({
5691                "model": "stub-model",
5692                "messages": [{"role": "user", "content": "think then answer"}],
5693                "stream": true
5694            }),
5695        )
5696        .await;
5697        assert_eq!(response.status(), AxumStatusCode::OK);
5698        let body = response_text(response).await;
5699        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
5700        assert!(
5701            body.contains(r#""reasoning":"\nreason"#),
5702            "stream should emit reasoning delta after full think prefix: {body}"
5703        );
5704        assert!(
5705            body.contains(r#""content":"final""#),
5706            "stream should emit visible content after think close: {body}"
5707        );
5708        assert!(
5709            !body.contains(r#""content":"<"#),
5710            "partial think prefix must not leak as content: {body}"
5711        );
5712    }
5713
5714    #[tokio::test]
5715    async fn route_rejects_unsupported_tool_and_function_selection() {
5716        for (extra, param) in [
5717            (
5718                json!({
5719                    "tools": [{
5720                        "type": "function",
5721                        "function": {"name": "weather", "parameters": {"type": "object"}}
5722                    }],
5723                    "tool_choice": {
5724                        "type": "function",
5725                        "function": {"name": "calendar"}
5726                    }
5727                }),
5728                "tool_choice",
5729            ),
5730            (
5731                json!({
5732                    "functions": [{"name": "weather", "parameters": {"type": "object"}}],
5733                    "function_call": {"name": "calendar"}
5734                }),
5735                "function_call",
5736            ),
5737        ] {
5738            let mut body = json!({
5739                "model": "stub-model",
5740                "messages": [{"role": "user", "content": "hello"}]
5741            });
5742            body.as_object_mut()
5743                .expect("object")
5744                .extend(extra.as_object().expect("extra object").clone());
5745            let response =
5746                post_json(router_with_stub("unused"), "/v1/chat/completions", body).await;
5747            assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
5748            let body = response_json(response).await;
5749            assert_eq!(body["error"]["type"], "invalid_request_error");
5750            assert_eq!(body["error"]["param"], param);
5751        }
5752    }
5753
5754    #[tokio::test]
5755    async fn route_rejects_non_function_tools_with_openai_error_param() {
5756        let response = post_json(
5757            router_with_stub("unused"),
5758            "/v1/chat/completions",
5759            json!({
5760                "model": "stub-model",
5761                "messages": [{"role": "user", "content": "hello"}],
5762                "tools": [{
5763                    "type": "retrieval",
5764                    "function": {"name": "search", "parameters": {"type": "object"}}
5765                }]
5766            }),
5767        )
5768        .await;
5769        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
5770        let body = response_json(response).await;
5771        assert_eq!(body["error"]["type"], "invalid_request_error");
5772        assert_eq!(body["error"]["param"], "tools");
5773    }
5774
5775    #[tokio::test]
5776    async fn route_rejects_tool_choice_required_without_tools() {
5777        let response = post_json(
5778            router_with_stub("unused"),
5779            "/v1/chat/completions",
5780            json!({
5781                "model": "stub-model",
5782                "messages": [{"role": "user", "content": "hello"}],
5783                "tool_choice": "required"
5784            }),
5785        )
5786        .await;
5787        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
5788        let body = response_json(response).await;
5789        assert_eq!(body["error"]["type"], "invalid_request_error");
5790        assert_eq!(body["error"]["param"], "tool_choice");
5791    }
5792
5793    #[tokio::test]
5794    async fn route_rejects_unknown_response_format_type_with_openai_error_param() {
5795        let response = post_json(
5796            router_with_stub("unused"),
5797            "/v1/chat/completions",
5798            json!({
5799                "model": "stub-model",
5800                "messages": [{"role": "user", "content": "hello"}],
5801                "response_format": {"type": "xml"}
5802            }),
5803        )
5804        .await;
5805        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
5806        let body = response_json(response).await;
5807        assert_eq!(body["error"]["type"], "invalid_request_error");
5808        assert_eq!(body["error"]["param"], "response_format.type");
5809    }
5810
5811    #[tokio::test]
5812    async fn route_chat_engine_unavailable_maps_to_503() {
5813        let response = post_json(
5814            router_without_llm(),
5815            "/v1/chat/completions",
5816            json!({
5817                "model": "stub-model",
5818                "messages": [{"role": "user", "content": "hello"}]
5819            }),
5820        )
5821        .await;
5822        assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
5823        let body = response_json(response).await;
5824        assert_eq!(body["error"]["type"], "service_unavailable_error");
5825        assert_eq!(body["error"]["param"], Value::Null);
5826    }
5827
5828    #[tokio::test]
5829    async fn route_chat_generation_failure_maps_to_500() {
5830        let response = post_json(
5831            router_with_failing_llm(),
5832            "/v1/chat/completions",
5833            json!({
5834                "model": "failing-model",
5835                "messages": [{"role": "user", "content": "hello"}]
5836            }),
5837        )
5838        .await;
5839        assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
5840        let body = response_json(response).await;
5841        assert_eq!(body["error"]["type"], "internal_server_error");
5842        assert!(body["error"]["message"]
5843            .as_str()
5844            .unwrap()
5845            .contains("stub generation failed"));
5846    }
5847
5848    #[tokio::test]
5849    async fn route_chat_stream_generation_failure_emits_openai_error_event() {
5850        let response = post_json(
5851            router_with_failing_llm(),
5852            "/v1/chat/completions",
5853            json!({
5854                "model": "failing-model",
5855                "messages": [{"role": "user", "content": "hello"}],
5856                "stream": true
5857            }),
5858        )
5859        .await;
5860        assert_eq!(response.status(), AxumStatusCode::OK);
5861        let body = response_text(response).await;
5862        assert_openai_stream_error(&body, "stub stream failed");
5863    }
5864
5865    #[tokio::test]
5866    async fn route_chat_stream_chunk_failure_emits_openai_error_event() {
5867        let response = post_json(
5868            router_with_stream_chunk_failing_llm(),
5869            "/v1/chat/completions",
5870            json!({
5871                "model": "failing-model",
5872                "messages": [{"role": "user", "content": "hello"}],
5873                "stream": true
5874            }),
5875        )
5876        .await;
5877        assert_eq!(response.status(), AxumStatusCode::OK);
5878        let body = response_text(response).await;
5879        assert_openai_stream_error(&body, "stub stream chunk failed");
5880    }
5881
5882    #[tokio::test]
5883    async fn route_completions_engine_unavailable_maps_to_503() {
5884        let response = post_json(
5885            router_without_llm(),
5886            "/v1/completions",
5887            json!({
5888                "model": "stub-model",
5889                "prompt": "complete me"
5890            }),
5891        )
5892        .await;
5893        assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
5894        let body = response_json(response).await;
5895        assert_eq!(body["error"]["type"], "service_unavailable_error");
5896        assert_eq!(body["error"]["param"], Value::Null);
5897    }
5898
5899    #[tokio::test]
5900    async fn route_embeddings_engine_unavailable_maps_to_503() {
5901        let response = post_json(
5902            router_without_llm(),
5903            "/v1/embeddings",
5904            json!({
5905                "model": "embed-model",
5906                "input": "hello"
5907            }),
5908        )
5909        .await;
5910        assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
5911        let body = response_json(response).await;
5912        assert_eq!(body["error"]["type"], "service_unavailable_error");
5913        assert_eq!(body["error"]["param"], Value::Null);
5914    }
5915
5916    #[tokio::test]
5917    async fn route_embeddings_contract_uses_stub_engine() {
5918        let response = post_json(
5919            router_with_stub_embed(),
5920            "/v1/embeddings",
5921            json!({
5922                "model": "stub-embed",
5923                "input": ["hi", "world"],
5924                "encoding_format": "float"
5925            }),
5926        )
5927        .await;
5928        assert_eq!(response.status(), AxumStatusCode::OK);
5929        let body = response_json(response).await;
5930        assert_eq!(body["object"], "list");
5931        assert_eq!(body["model"], "stub-embed");
5932        assert_eq!(body["usage"]["prompt_tokens"], 7);
5933        assert_eq!(body["usage"]["total_tokens"], 7);
5934
5935        let data = body["data"].as_array().expect("embedding data");
5936        assert_eq!(data.len(), 2, "body: {body}");
5937        assert_eq!(data[0]["object"], "embedding");
5938        assert_eq!(data[0]["index"], 0);
5939        assert_eq!(data[0]["embedding"].as_array().unwrap().len(), 3);
5940        assert_eq!(data[0]["embedding"][0].as_f64().unwrap(), 2.0);
5941        assert_eq!(data[1]["index"], 1);
5942        assert_eq!(data[1]["embedding"][0].as_f64().unwrap(), 5.0);
5943    }
5944
5945    #[tokio::test]
5946    async fn route_embeddings_rejects_unsupported_encoding_format() {
5947        let response = post_json(
5948            router_with_stub_embed(),
5949            "/v1/embeddings",
5950            json!({
5951                "model": "stub-embed",
5952                "input": "hi",
5953                "encoding_format": "base64"
5954            }),
5955        )
5956        .await;
5957        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
5958        let body = response_json(response).await;
5959        assert_eq!(body["error"]["type"], "invalid_request_error");
5960        assert_eq!(body["error"]["param"], "encoding_format");
5961    }
5962
5963    #[tokio::test]
5964    async fn route_embeddings_rejects_empty_input_with_field_param() {
5965        let response = post_json(
5966            router_with_stub_embed(),
5967            "/v1/embeddings",
5968            json!({
5969                "model": "stub-embed",
5970                "input": []
5971            }),
5972        )
5973        .await;
5974        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
5975        let body = response_json(response).await;
5976        assert_eq!(body["error"]["type"], "invalid_request_error");
5977        assert_eq!(body["error"]["param"], "input");
5978    }
5979
5980    #[tokio::test]
5981    async fn route_embeddings_rejects_empty_item_with_field_param() {
5982        let response = post_json(
5983            router_with_stub_embed(),
5984            "/v1/embeddings",
5985            json!({
5986                "model": "stub-embed",
5987                "input": [{}]
5988            }),
5989        )
5990        .await;
5991        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
5992        let body = response_json(response).await;
5993        assert_eq!(body["error"]["type"], "invalid_request_error");
5994        assert_eq!(body["error"]["param"], "input");
5995    }
5996
5997    #[tokio::test]
5998    async fn route_embeddings_invalid_json_maps_to_openai_error() {
5999        let response = post_raw_json(router_with_stub_embed(), "/v1/embeddings", "{").await;
6000        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
6001        let body = response_json(response).await;
6002        assert_eq!(body["error"]["type"], "invalid_request_error");
6003        assert_eq!(body["error"]["param"], Value::Null);
6004        assert!(body["error"]["message"]
6005            .as_str()
6006            .unwrap()
6007            .contains("invalid embeddings request"));
6008    }
6009
6010    #[tokio::test]
6011    async fn route_transcriptions_engine_unavailable_maps_to_503() {
6012        let boundary = "ferrum-test-boundary";
6013        let body = concat!(
6014            "--ferrum-test-boundary\r\n",
6015            "Content-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\r\n",
6016            "Content-Type: audio/wav\r\n",
6017            "\r\n",
6018            "RIFFtest\r\n",
6019            "--ferrum-test-boundary--\r\n"
6020        );
6021        let response = post_multipart(
6022            router_without_llm(),
6023            "/v1/audio/transcriptions",
6024            boundary,
6025            body,
6026        )
6027        .await;
6028        assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
6029        let body = response_json(response).await;
6030        assert_eq!(body["error"]["type"], "service_unavailable_error");
6031        assert_eq!(body["error"]["param"], Value::Null);
6032    }
6033
6034    #[tokio::test]
6035    async fn route_transcriptions_contract_uses_stub_engine() {
6036        let boundary = "ferrum-test-boundary";
6037        let body = concat!(
6038            "--ferrum-test-boundary\r\n",
6039            "Content-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\r\n",
6040            "Content-Type: audio/wav\r\n",
6041            "\r\n",
6042            "RIFFtest\r\n",
6043            "--ferrum-test-boundary\r\n",
6044            "Content-Disposition: form-data; name=\"language\"\r\n",
6045            "\r\n",
6046            "en\r\n",
6047            "--ferrum-test-boundary\r\n",
6048            "Content-Disposition: form-data; name=\"response_format\"\r\n",
6049            "\r\n",
6050            "json\r\n",
6051            "--ferrum-test-boundary--\r\n"
6052        );
6053        let response = post_multipart(
6054            router_with_stub_transcribe(),
6055            "/v1/audio/transcriptions",
6056            boundary,
6057            body,
6058        )
6059        .await;
6060        assert_eq!(response.status(), AxumStatusCode::OK);
6061        let body = response_json(response).await;
6062        assert_eq!(body["text"], "bytes:8:en");
6063    }
6064
6065    #[tokio::test]
6066    async fn route_transcriptions_rejects_unsupported_response_format() {
6067        let boundary = "ferrum-test-boundary";
6068        let body = concat!(
6069            "--ferrum-test-boundary\r\n",
6070            "Content-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\r\n",
6071            "Content-Type: audio/wav\r\n",
6072            "\r\n",
6073            "RIFFtest\r\n",
6074            "--ferrum-test-boundary\r\n",
6075            "Content-Disposition: form-data; name=\"response_format\"\r\n",
6076            "\r\n",
6077            "text\r\n",
6078            "--ferrum-test-boundary--\r\n"
6079        );
6080        let response = post_multipart(
6081            router_with_stub_transcribe(),
6082            "/v1/audio/transcriptions",
6083            boundary,
6084            body,
6085        )
6086        .await;
6087        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
6088        let body = response_json(response).await;
6089        assert_eq!(body["error"]["type"], "invalid_request_error");
6090        assert_eq!(body["error"]["param"], "response_format");
6091    }
6092
6093    #[tokio::test]
6094    async fn route_transcriptions_rejects_missing_file_with_field_param() {
6095        let boundary = "ferrum-test-boundary";
6096        let body = concat!(
6097            "--ferrum-test-boundary\r\n",
6098            "Content-Disposition: form-data; name=\"language\"\r\n",
6099            "\r\n",
6100            "en\r\n",
6101            "--ferrum-test-boundary--\r\n"
6102        );
6103        let response = post_multipart(
6104            router_with_stub_transcribe(),
6105            "/v1/audio/transcriptions",
6106            boundary,
6107            body,
6108        )
6109        .await;
6110        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
6111        let body = response_json(response).await;
6112        assert_eq!(body["error"]["type"], "invalid_request_error");
6113        assert_eq!(body["error"]["param"], "file");
6114    }
6115
6116    #[tokio::test]
6117    async fn route_transcriptions_invalid_multipart_maps_to_openai_error() {
6118        let response = post_json(
6119            router_with_stub_transcribe(),
6120            "/v1/audio/transcriptions",
6121            json!({}),
6122        )
6123        .await;
6124        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
6125        let body = response_json(response).await;
6126        assert_eq!(body["error"]["type"], "invalid_request_error");
6127        assert_eq!(body["error"]["param"], Value::Null);
6128        assert!(body["error"]["message"]
6129            .as_str()
6130            .unwrap()
6131            .contains("invalid transcriptions request"));
6132    }
6133
6134    #[tokio::test]
6135    async fn route_speech_engine_unavailable_maps_to_503() {
6136        let response = post_json(
6137            router_without_llm(),
6138            "/v1/audio/speech",
6139            json!({
6140                "model": "tts-model",
6141                "input": "hello",
6142                "voice": "default"
6143            }),
6144        )
6145        .await;
6146        assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
6147        let body = response_json(response).await;
6148        assert_eq!(body["error"]["type"], "service_unavailable_error");
6149        assert_eq!(body["error"]["param"], Value::Null);
6150    }
6151
6152    #[tokio::test]
6153    async fn route_speech_contract_uses_stub_engine() {
6154        let response = post_json(
6155            router_with_stub_tts(),
6156            "/v1/audio/speech",
6157            json!({
6158                "model": "stub-tts",
6159                "input": "hello",
6160                "voice": "default",
6161                "response_format": "wav",
6162                "language": "english"
6163            }),
6164        )
6165        .await;
6166        assert_eq!(response.status(), AxumStatusCode::OK);
6167        assert_eq!(
6168            response.headers().get(header::CONTENT_TYPE).unwrap(),
6169            "audio/wav"
6170        );
6171        let body = response_bytes(response).await;
6172        assert!(body.len() > 44, "WAV should include header and PCM data");
6173        assert_eq!(&body[0..4], b"RIFF");
6174        assert_eq!(&body[8..12], b"WAVE");
6175    }
6176
6177    #[tokio::test]
6178    async fn route_speech_streaming_contract_uses_stub_engine() {
6179        let response = post_json(
6180            router_with_stub_tts(),
6181            "/v1/audio/speech",
6182            json!({
6183                "model": "stub-tts",
6184                "input": "hello",
6185                "voice": "default",
6186                "response_format": "wav",
6187                "stream": true
6188            }),
6189        )
6190        .await;
6191        assert_eq!(response.status(), AxumStatusCode::OK);
6192        assert_eq!(
6193            response.headers().get(header::CONTENT_TYPE).unwrap(),
6194            "audio/wav"
6195        );
6196        assert_eq!(
6197            response.headers().get(header::TRANSFER_ENCODING).unwrap(),
6198            "chunked"
6199        );
6200        let body = response_bytes(response).await;
6201        assert!(body.len() > 44, "streaming WAV should include audio bytes");
6202        assert_eq!(&body[0..4], b"RIFF");
6203        assert_eq!(&body[8..12], b"WAVE");
6204    }
6205
6206    #[tokio::test]
6207    async fn route_speech_pcm_response_format_returns_raw_pcm() {
6208        let response = post_json(
6209            router_with_stub_tts(),
6210            "/v1/audio/speech",
6211            json!({
6212                "model": "stub-tts",
6213                "input": "hello",
6214                "voice": "default",
6215                "response_format": "pcm"
6216            }),
6217        )
6218        .await;
6219        assert_eq!(response.status(), AxumStatusCode::OK);
6220        assert_eq!(
6221            response.headers().get(header::CONTENT_TYPE).unwrap(),
6222            "audio/pcm"
6223        );
6224        let body = response_bytes(response).await;
6225        assert_eq!(body.len(), 6, "three f32 samples should encode as s16le");
6226        assert_eq!(&body[0..2], &[0, 0]);
6227        assert_ne!(&body[0..4], b"RIFF");
6228    }
6229
6230    #[tokio::test]
6231    async fn route_speech_rejects_unsupported_response_format() {
6232        let response = post_json(
6233            router_with_stub_tts(),
6234            "/v1/audio/speech",
6235            json!({
6236                "model": "stub-tts",
6237                "input": "hello",
6238                "voice": "default",
6239                "response_format": "mp3"
6240            }),
6241        )
6242        .await;
6243        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
6244        let body = response_json(response).await;
6245        assert_eq!(body["error"]["type"], "invalid_request_error");
6246        assert_eq!(body["error"]["param"], "response_format");
6247    }
6248
6249    #[tokio::test]
6250    async fn route_speech_invalid_json_maps_to_openai_error() {
6251        let response = post_raw_json(router_with_stub_tts(), "/v1/audio/speech", "{").await;
6252        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
6253        let body = response_json(response).await;
6254        assert_eq!(body["error"]["type"], "invalid_request_error");
6255        assert_eq!(body["error"]["param"], Value::Null);
6256        assert!(body["error"]["message"]
6257            .as_str()
6258            .unwrap()
6259            .contains("invalid speech request"));
6260    }
6261
6262    #[tokio::test]
6263    async fn route_completions_generation_failure_maps_to_500() {
6264        let response = post_json(
6265            router_with_failing_llm(),
6266            "/v1/completions",
6267            json!({
6268                "model": "failing-model",
6269                "prompt": "complete me"
6270            }),
6271        )
6272        .await;
6273        assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
6274        let body = response_json(response).await;
6275        assert_eq!(body["error"]["type"], "internal_server_error");
6276        assert!(body["error"]["message"]
6277            .as_str()
6278            .unwrap()
6279            .contains("stub generation failed"));
6280    }
6281
6282    #[tokio::test]
6283    async fn route_completions_stream_generation_failure_emits_openai_error_event() {
6284        let response = post_json(
6285            router_with_failing_llm(),
6286            "/v1/completions",
6287            json!({
6288                "model": "failing-model",
6289                "prompt": "complete me",
6290                "stream": true
6291            }),
6292        )
6293        .await;
6294        assert_eq!(response.status(), AxumStatusCode::OK);
6295        let body = response_text(response).await;
6296        assert_openai_stream_error(&body, "stub stream failed");
6297    }
6298
6299    #[tokio::test]
6300    async fn route_completions_stream_chunk_failure_emits_openai_error_event() {
6301        let response = post_json(
6302            router_with_stream_chunk_failing_llm(),
6303            "/v1/completions",
6304            json!({
6305                "model": "failing-model",
6306                "prompt": "complete me",
6307                "stream": true
6308            }),
6309        )
6310        .await;
6311        assert_eq!(response.status(), AxumStatusCode::OK);
6312        let body = response_text(response).await;
6313        assert_openai_stream_error(&body, "stub stream chunk failed");
6314    }
6315
6316    #[tokio::test]
6317    async fn route_completions_contract_uses_stub_engine() {
6318        let response = post_json(
6319            router_with_stub("done"),
6320            "/v1/completions",
6321            json!({
6322                "model": "stub-model",
6323                "prompt": "complete me",
6324                "max_tokens": 8,
6325                "temperature": 0.0
6326            }),
6327        )
6328        .await;
6329        assert_eq!(response.status(), AxumStatusCode::OK);
6330        let body = response_json(response).await;
6331        assert_eq!(body["object"], "text_completion");
6332        assert_eq!(body["choices"][0]["text"], "done");
6333        assert_eq!(body["usage"]["prompt_tokens"], 7);
6334        assert_eq!(body["usage"]["completion_tokens"], 2);
6335    }
6336
6337    #[tokio::test]
6338    async fn route_completions_streaming_contract_uses_stub_engine() {
6339        let response = post_json(
6340            router_with_stub("done"),
6341            "/v1/completions",
6342            json!({
6343                "model": "stub-model",
6344                "prompt": "complete me",
6345                "max_tokens": 8,
6346                "temperature": 0.0,
6347                "stream": true
6348            }),
6349        )
6350        .await;
6351        assert_eq!(response.status(), AxumStatusCode::OK);
6352        let body = response_text(response).await;
6353        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
6354        assert!(
6355            body.contains("\"object\":\"text_completion\""),
6356            "missing completion chunk: {body}"
6357        );
6358        assert!(body.contains("\"text\":\"done\""), "missing text: {body}");
6359        assert!(
6360            body.contains("\"choices\":[],\"usage\""),
6361            "missing separate usage chunk: {body}"
6362        );
6363        assert!(
6364            body.contains("\"prompt_tokens\":5"),
6365            "stream usage should come from engine token usage: {body}"
6366        );
6367        assert!(
6368            body.contains("\"completion_tokens\":1"),
6369            "stream completion usage should come from engine token usage: {body}"
6370        );
6371    }
6372
6373    #[tokio::test]
6374    async fn route_completions_stream_waits_for_separate_final_usage_at_max_tokens() {
6375        let response = post_json(
6376            router_with_stub_separate_final_stream_chunk(&["do", "ne"]),
6377            "/v1/completions",
6378            json!({
6379                "model": "stub-model",
6380                "prompt": "complete me",
6381                "max_tokens": 2,
6382                "temperature": 0.0,
6383                "stream": true
6384            }),
6385        )
6386        .await;
6387        assert_eq!(response.status(), AxumStatusCode::OK);
6388        let body = response_text(response).await;
6389        assert_eq!(body.matches("data: [DONE]").count(), 1, "body: {body}");
6390        assert!(
6391            body.contains("\"text\":\"do\""),
6392            "missing first chunk: {body}"
6393        );
6394        assert!(
6395            body.contains("\"text\":\"ne\""),
6396            "missing second chunk: {body}"
6397        );
6398        assert!(
6399            body.contains("\"choices\":[],\"usage\""),
6400            "missing separate usage chunk from final engine chunk: {body}"
6401        );
6402        assert!(
6403            body.contains("\"prompt_tokens\":5"),
6404            "stream usage should come from engine final usage: {body}"
6405        );
6406    }
6407
6408    #[tokio::test]
6409    async fn route_completions_invalid_json_maps_to_openai_error() {
6410        let response = post_raw_json(router_with_stub("unused"), "/v1/completions", "{").await;
6411        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
6412        let body = response_json(response).await;
6413        assert_eq!(body["error"]["type"], "invalid_request_error");
6414        assert_eq!(body["error"]["param"], Value::Null);
6415        assert!(body["error"]["message"]
6416            .as_str()
6417            .unwrap()
6418            .contains("invalid completions request"));
6419    }
6420
6421    #[tokio::test]
6422    async fn route_completions_rejects_unsupported_fields_explicitly() {
6423        for (extra, param) in [
6424            (json!({"n": 2}), "n"),
6425            (json!({"logprobs": 3}), "logprobs"),
6426            (json!({"logit_bias": {"42": 1.0}}), "logit_bias"),
6427        ] {
6428            let mut body = json!({
6429                "model": "stub-model",
6430                "prompt": "complete me"
6431            });
6432            body.as_object_mut()
6433                .expect("object")
6434                .extend(extra.as_object().expect("extra object").clone());
6435            let response = post_json(router_with_stub("unused"), "/v1/completions", body).await;
6436            assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
6437            let body = response_json(response).await;
6438            assert_eq!(body["error"]["type"], "invalid_request_error");
6439            assert_eq!(body["error"]["param"], param);
6440        }
6441    }
6442
6443    #[tokio::test]
6444    async fn streaming_completions_do_not_synthesize_whitespace_usage() {
6445        let response = post_json(
6446            router_with_stub_without_stream_usage("done"),
6447            "/v1/completions",
6448            json!({
6449                "model": "stub-model",
6450                "prompt": "one two three four",
6451                "stream": true
6452            }),
6453        )
6454        .await;
6455        assert_eq!(response.status(), AxumStatusCode::OK);
6456        let body = response_text(response).await;
6457        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
6458        assert!(
6459            !body.contains("\"usage\":{\"prompt_tokens\""),
6460            "server must not synthesize whitespace-count completion usage: {body}"
6461        );
6462    }
6463
6464    #[tokio::test]
6465    async fn chat_rejects_n_not_one_with_openai_error_param() {
6466        let request = chat_request(json!({"n": 2}));
6467        let err = chat_completions_handler(
6468            State(state_with_stub("unused")),
6469            HeaderMap::new(),
6470            Ok(Json(request)),
6471        )
6472        .await
6473        .expect_err("n=2 should reject");
6474        let (status, body) = error_json(err).await;
6475        assert_eq!(status, AxumStatusCode::BAD_REQUEST);
6476        assert_eq!(body["error"]["type"], "invalid_request_error");
6477        assert_eq!(body["error"]["param"], "n");
6478    }
6479
6480    #[tokio::test]
6481    async fn chat_rejects_logit_bias_and_logprobs_explicitly() {
6482        for (extra, param) in [
6483            (json!({"logit_bias": {"1": 100.0}}), "logit_bias"),
6484            (json!({"logprobs": true}), "logprobs"),
6485            (json!({"top_logprobs": 2}), "top_logprobs"),
6486        ] {
6487            let request = chat_request(extra);
6488            let err = chat_completions_handler(
6489                State(state_with_stub("unused")),
6490                HeaderMap::new(),
6491                Ok(Json(request)),
6492            )
6493            .await
6494            .expect_err("unsupported field should reject");
6495            let (status, body) = error_json(err).await;
6496            assert_eq!(status, AxumStatusCode::BAD_REQUEST);
6497            assert_eq!(body["error"]["param"], param);
6498            assert_eq!(body["error"]["type"], "invalid_request_error");
6499        }
6500    }
6501
6502    #[tokio::test]
6503    async fn chat_stream_options_include_usage_controls_stream_usage() {
6504        let request = chat_request(json!({
6505            "stream": true,
6506            "stream_options": {"include_usage": true}
6507        }));
6508        let response = chat_completions_handler(
6509            State(state_with_stub("ok")),
6510            HeaderMap::new(),
6511            Ok(Json(request)),
6512        )
6513        .await
6514        .expect("stream response");
6515        assert_eq!(response.status(), AxumStatusCode::OK);
6516        let body = response_text(response).await;
6517        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
6518        assert!(
6519            body.contains("\"usage\"") && body.contains("\"completion_tokens\":1"),
6520            "include_usage=true should emit stream usage: {body}"
6521        );
6522        assert!(
6523            body.contains("\"choices\":[],\"usage\""),
6524            "include_usage=true should use a separate usage chunk: {body}"
6525        );
6526        assert!(
6527            body.contains("\"prompt_tokens\":5"),
6528            "stream usage should come from engine token usage: {body}"
6529        );
6530
6531        let request = chat_request(json!({"stream": true}));
6532        let response = chat_completions_handler(
6533            State(state_with_stub("ok")),
6534            HeaderMap::new(),
6535            Ok(Json(request)),
6536        )
6537        .await
6538        .expect("stream response");
6539        let body = response_text(response).await;
6540        assert!(
6541            !body.contains("\"usage\":{\"prompt_tokens\""),
6542            "stream usage should be omitted unless requested: {body}"
6543        );
6544    }
6545
6546    #[tokio::test]
6547    async fn streaming_chat_does_not_synthesize_whitespace_usage() {
6548        let response = post_json(
6549            router_with_stub_without_stream_usage("ok"),
6550            "/v1/chat/completions",
6551            json!({
6552                "model": "stub-model",
6553                "messages": [{"role": "user", "content": "one two three four"}],
6554                "stream": true,
6555                "stream_options": {"include_usage": true}
6556            }),
6557        )
6558        .await;
6559        assert_eq!(response.status(), AxumStatusCode::OK);
6560        let body = response_text(response).await;
6561        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
6562        assert!(
6563            !body.contains("\"usage\":{\"prompt_tokens\""),
6564            "server must not synthesize whitespace-count usage when engine stream omits usage: {body}"
6565        );
6566    }
6567
6568    #[test]
6569    fn tool_requests_and_tool_messages_parse_into_structured_api_request() {
6570        let request: ChatCompletionsRequest = serde_json::from_value(json!({
6571            "model": "qwen3",
6572            "messages": [
6573                {"role": "user", "content": "Use the weather tool."},
6574                {
6575                    "role": "assistant",
6576                    "content": null,
6577                    "tool_calls": [{
6578                        "id": "call_1",
6579                        "type": "function",
6580                        "function": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"}
6581                    }]
6582                },
6583                {"role": "tool", "tool_call_id": "call_1", "content": "sunny"}
6584            ],
6585            "tools": [{
6586                "type": "function",
6587                "function": {
6588                    "name": "weather",
6589                    "description": "Get weather",
6590                    "parameters": {
6591                        "type": "object",
6592                        "properties": {"city": {"type": "string"}},
6593                        "required": ["city"]
6594                    }
6595                }
6596            }],
6597            "tool_choice": "auto"
6598        }))
6599        .expect("tool request parses");
6600
6601        validate_chat_request(&request).expect("tool request validates");
6602        let internal = convert_chat_request(&request).expect("convert");
6603        assert!(internal.prompt.contains("\"tools\":[{"));
6604        assert!(internal.prompt.contains("\"type\":\"function\""));
6605        assert!(internal.prompt.contains("\"name\":\"weather\""));
6606        assert!(internal.prompt.contains("<|im_start|>assistant\n{"));
6607        assert!(internal.prompt.contains("\"tool_calls\":[{"));
6608        assert!(internal.prompt.contains("\"id\":\"call_1\""));
6609        assert!(internal
6610            .prompt
6611            .contains("<|im_start|>tool\nsunny<|im_end|>"));
6612        assert_eq!(
6613            internal.metadata["openai_tools"][0]["function"]["name"],
6614            "weather"
6615        );
6616        assert_eq!(internal.metadata["openai_tool_choice"], "auto");
6617        let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
6618            panic!("expected structured chat api_request");
6619        };
6620        assert_eq!(api.messages.len(), 3);
6621        assert_eq!(api.messages[2].role, ferrum_types::ApiMessageRole::Tool);
6622        assert_eq!(api.messages[2].content, "sunny");
6623        assert_eq!(api.messages[2].tool_call_id.as_deref(), Some("call_1"));
6624        assert_eq!(api.tools[0].function.name, "weather");
6625        assert_eq!(
6626            api.tool_choice,
6627            Some(ferrum_types::ApiToolChoice::Mode("auto".into()))
6628        );
6629        assert_eq!(
6630            api.messages[1].tool_calls[0].function.arguments,
6631            "{\"city\":\"Paris\"}"
6632        );
6633    }
6634
6635    #[test]
6636    fn omitted_tool_choice_defaults_to_auto_when_tools_are_present() {
6637        let request: ChatCompletionsRequest = serde_json::from_value(json!({
6638            "model": "served-alias",
6639            "messages": [{"role": "user", "content": "Use the weather tool."}],
6640            "tools": [{
6641                "type": "function",
6642                "function": {
6643                    "name": "weather",
6644                    "description": "Get weather",
6645                    "parameters": {
6646                        "type": "object",
6647                        "properties": {"city": {"type": "string"}},
6648                        "required": ["city"]
6649                    }
6650                }
6651            }]
6652        }))
6653        .expect("tool request parses");
6654
6655        validate_chat_request(&request).expect("tool request validates");
6656        let internal = convert_chat_request(&request).expect("convert");
6657        assert!(internal.prompt.contains("\"tools\":[{"));
6658        assert!(internal.prompt.contains("\"tool_choice\":\"auto\""));
6659        assert_eq!(internal.metadata["openai_tool_choice"], "auto");
6660        let initial_forbidden = internal.metadata[INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY]
6661            .as_array()
6662            .expect("initial forbidden token list");
6663        for token in [
6664            THINK_END_TAG,
6665            "<|im_end|>",
6666            "<|endoftext|>",
6667            "<|eot_id|>",
6668            "</s>",
6669        ] {
6670            assert!(
6671                initial_forbidden.iter().any(|value| value == token),
6672                "missing initial forbidden token {token}: {initial_forbidden:?}"
6673            );
6674        }
6675        let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
6676            panic!("expected structured chat api_request");
6677        };
6678        assert_eq!(
6679            api.tool_choice,
6680            Some(ferrum_types::ApiToolChoice::Mode("auto".into()))
6681        );
6682    }
6683
6684    #[test]
6685    fn omitted_single_matching_tool_uses_tool_schema_response_format() {
6686        let request: ChatCompletionsRequest = serde_json::from_value(json!({
6687            "model": "served-alias",
6688            "messages": [{"role": "user", "content": "北京现在天气怎么样?用摄氏度。"}],
6689            "tools": [{
6690                "type": "function",
6691                "function": {
6692                    "name": "get_weather",
6693                    "description": "查询指定城市的当前天气",
6694                    "parameters": {
6695                        "type": "object",
6696                        "properties": {
6697                            "city": {"type": "string"},
6698                            "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
6699                        },
6700                        "required": ["city"]
6701                    }
6702                }
6703            }]
6704        }))
6705        .expect("tool request parses");
6706
6707        validate_chat_request(&request).expect("tool request validates");
6708        let internal = convert_chat_request(&request).expect("convert");
6709        assert_eq!(internal.metadata["openai_tool_choice"], "auto");
6710        match internal.sampling_params.response_format {
6711            ferrum_types::ResponseFormat::JsonSchema(ref schema) => {
6712                assert!(schema.contains(r#""required":["city"]"#), "{schema}");
6713            }
6714            ref other => panic!("expected inferred tool json schema, got {other:?}"),
6715        }
6716    }
6717
6718    #[test]
6719    fn tool_schema_response_format_bounds_unconstrained_strings() {
6720        let request: ChatCompletionsRequest = serde_json::from_value(json!({
6721            "model": "served-alias",
6722            "messages": [{"role": "user", "content": "Use the selected tool."}],
6723            "tools": [{
6724                "type": "function",
6725                "function": {
6726                    "name": "get_weather",
6727                    "parameters": {
6728                        "type": "object",
6729                        "properties": {
6730                            "city": {"type": "string"},
6731                            "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
6732                        },
6733                        "required": ["city"]
6734                    }
6735                }
6736            }],
6737            "tool_choice": {
6738                "type": "function",
6739                "function": {"name": "get_weather"}
6740            }
6741        }))
6742        .expect("tool request parses");
6743
6744        validate_chat_request(&request).expect("tool request validates");
6745        let internal = convert_chat_request(&request).expect("convert");
6746        match internal.sampling_params.response_format {
6747            ferrum_types::ResponseFormat::JsonSchema(ref schema) => {
6748                let value: serde_json::Value =
6749                    serde_json::from_str(schema).expect("schema should be JSON");
6750                assert_eq!(
6751                    value["properties"]["city"]["maxLength"],
6752                    DEFAULT_GUIDED_TOOL_ARGUMENT_STRING_MAX_LENGTH
6753                );
6754                assert_eq!(
6755                    value["properties"]["unit"]["enum"],
6756                    json!(["celsius", "fahrenheit"])
6757                );
6758                assert!(
6759                    value["properties"]["unit"]["maxLength"].is_null(),
6760                    "enum string should remain finite via enum instead of maxLength: {value}"
6761                );
6762            }
6763            ref other => panic!("expected forced tool json schema, got {other:?}"),
6764        }
6765    }
6766
6767    #[test]
6768    fn required_tool_choice_uses_tool_schema_response_format_without_extra_prompt_instruction() {
6769        let request: ChatCompletionsRequest = serde_json::from_value(json!({
6770            "model": "served-alias",
6771            "messages": [{"role": "user", "content": "Call capture_quality_marker."}],
6772            "tools": [{
6773                "type": "function",
6774                "function": {
6775                    "name": "capture_quality_marker",
6776                    "description": "Record one marker.",
6777                    "parameters": {
6778                        "type": "object",
6779                        "properties": {
6780                            "marker": {"type": "string", "enum": ["ferrum0401"]},
6781                            "checksum": {"type": "string", "enum": ["S0004"]}
6782                        },
6783                        "required": ["marker", "checksum"]
6784                    }
6785                }
6786            }],
6787            "tool_choice": "required"
6788        }))
6789        .expect("tool request parses");
6790
6791        validate_chat_request(&request).expect("tool request validates");
6792        let internal = convert_chat_request(&request).expect("convert");
6793
6794        assert!(
6795            !internal.prompt.contains(
6796                "Output only a single JSON object containing the selected function arguments"
6797            ),
6798            "{}",
6799            internal.prompt
6800        );
6801        assert!(
6802            internal.prompt.contains("\"tool_choice\":\"required\""),
6803            "{}",
6804            internal.prompt
6805        );
6806        assert_eq!(internal.metadata["openai_tool_choice"], "required");
6807        match internal.sampling_params.response_format {
6808            ferrum_types::ResponseFormat::JsonSchema(ref schema) => {
6809                assert!(schema.contains(r#""enum":["ferrum0401"]"#), "{schema}");
6810                assert!(schema.contains(r#""enum":["S0004"]"#), "{schema}");
6811            }
6812            ref other => panic!("expected forced tool json schema, got {other:?}"),
6813        }
6814    }
6815
6816    #[test]
6817    fn omitted_single_unrelated_tool_keeps_text_response_format() {
6818        let request: ChatCompletionsRequest = serde_json::from_value(json!({
6819            "model": "served-alias",
6820            "messages": [{"role": "user", "content": "讲一个短笑话。"}],
6821            "tools": [{
6822                "type": "function",
6823                "function": {
6824                    "name": "get_weather",
6825                    "description": "查询指定城市的当前天气",
6826                    "parameters": {
6827                        "type": "object",
6828                        "properties": {"city": {"type": "string"}},
6829                        "required": ["city"]
6830                    }
6831                }
6832            }]
6833        }))
6834        .expect("tool request parses");
6835
6836        validate_chat_request(&request).expect("tool request validates");
6837        let internal = convert_chat_request(&request).expect("convert");
6838        assert_eq!(
6839            internal.sampling_params.response_format,
6840            ferrum_types::ResponseFormat::Text
6841        );
6842    }
6843
6844    #[test]
6845    fn tool_choice_none_omits_tools_from_model_template_prompt() {
6846        let request: ChatCompletionsRequest = serde_json::from_value(json!({
6847            "model": "served-alias",
6848            "messages": [
6849                {"role": "user", "content": "Use the weather tool if needed."},
6850                {
6851                    "role": "assistant",
6852                    "content": null,
6853                    "tool_calls": [{
6854                        "id": "call_1",
6855                        "type": "function",
6856                        "function": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"}
6857                    }]
6858                },
6859                {"role": "tool", "tool_call_id": "call_1", "content": "{\"temp\":22}"}
6860            ],
6861            "tools": [{
6862                "type": "function",
6863                "function": {"name": "weather", "parameters": {"type": "object"}}
6864            }],
6865            "tool_choice": "none"
6866        }))
6867        .expect("tool_choice none request parses");
6868        let template = ModelChatTemplate::new(
6869            "{% set tools_in_user_message = true %}{% if tools %}<tools>{% for tool in tools %}{{ tool.function.name }}{% endfor %}</tools>{% endif %}{% for message in messages %}[{{ message.role }}]{{ message.content }}{% endfor %}{% if add_generation_prompt %}[assistant]{% endif %}",
6870            "tool-choice-none-template",
6871        );
6872
6873        validate_chat_request(&request).expect("tool_choice none request validates");
6874        let internal = convert_chat_request_with_template_model(
6875            &request,
6876            "served-template-model",
6877            Some(&template),
6878        )
6879        .expect("convert");
6880        assert!(
6881            !internal.prompt.contains("<tools>"),
6882            "tool_choice none must not expose tools to the model template: {}",
6883            internal.prompt
6884        );
6885        assert!(internal.prompt.contains("[tool]"), "{}", internal.prompt);
6886        assert_eq!(
6887            internal.metadata["openai_tools"][0]["function"]["name"],
6888            "weather"
6889        );
6890        assert_eq!(internal.metadata["openai_tool_choice"], "none");
6891        let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
6892            panic!("expected structured chat api_request");
6893        };
6894        assert_eq!(api.tools[0].function.name, "weather");
6895        assert_eq!(
6896            api.tool_choice,
6897            Some(ferrum_types::ApiToolChoice::Mode("none".into()))
6898        );
6899    }
6900
6901    #[test]
6902    fn specific_tool_choice_parses_into_structured_api_request() {
6903        let request: ChatCompletionsRequest = serde_json::from_value(json!({
6904            "model": "qwen3",
6905            "messages": [{"role": "user", "content": "Use the selected tool."}],
6906            "tools": [
6907                {
6908                    "type": "function",
6909                    "function": {"name": "weather", "parameters": {"type": "object"}}
6910                },
6911                {
6912                    "type": "function",
6913                    "function": {"name": "calendar", "parameters": {"type": "object"}}
6914                }
6915            ],
6916            "tool_choice": {
6917                "type": "function",
6918                "function": {"name": "weather"}
6919            }
6920        }))
6921        .expect("specific tool_choice request parses");
6922
6923        validate_chat_request(&request).expect("specific tool_choice validates");
6924        let internal = convert_chat_request(&request).expect("convert");
6925        assert!(internal.prompt.contains("\"tool_choice\":{"));
6926        assert!(internal.prompt.contains("\"name\":\"weather\""));
6927        let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
6928            panic!("expected structured chat api_request");
6929        };
6930        assert_eq!(
6931            api.tool_choice,
6932            Some(ferrum_types::ApiToolChoice::Function {
6933                tool_type: "function".to_string(),
6934                function: ferrum_types::ApiToolChoiceFunction {
6935                    name: "weather".to_string()
6936                },
6937            })
6938        );
6939
6940        let invalid: ChatCompletionsRequest = serde_json::from_value(json!({
6941            "model": "qwen3",
6942            "messages": [{"role": "user", "content": "Use the selected tool."}],
6943            "tools": [{
6944                "type": "function",
6945                "function": {"name": "weather", "parameters": {"type": "object"}}
6946            }],
6947            "tool_choice": {
6948                "type": "function",
6949                "function": {"name": "calendar"}
6950            }
6951        }))
6952        .expect("invalid specific tool_choice request parses");
6953        let err = validate_chat_request(&invalid).expect_err("undeclared tool should reject");
6954        match err {
6955            ServerError::InvalidRequest { param, .. } => {
6956                assert_eq!(param.as_deref(), Some("tool_choice"));
6957            }
6958            other => panic!("expected invalid_request_error for tool_choice, got {other:?}"),
6959        }
6960    }
6961
6962    #[test]
6963    fn legacy_function_role_messages_parse_into_structured_api_request() {
6964        let request: ChatCompletionsRequest = serde_json::from_value(json!({
6965            "model": "mystery-model",
6966            "messages": [
6967                {"role": "user", "content": "Call weather."},
6968                {
6969                    "role": "assistant",
6970                    "content": null,
6971                    "function_call": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"}
6972                },
6973                {"role": "function", "name": "weather", "content": "{\"forecast\":\"sunny\"}"}
6974            ],
6975            "functions": [{
6976                "name": "weather",
6977                "parameters": {
6978                    "type": "object",
6979                    "properties": {"city": {"type": "string"}},
6980                    "required": ["city"]
6981                }
6982            }],
6983            "function_call": "auto"
6984        }))
6985        .expect("legacy function request parses");
6986
6987        validate_chat_request(&request).expect("legacy function request validates");
6988        let internal = convert_chat_request(&request).expect("convert");
6989        assert!(
6990            internal
6991                .prompt
6992                .contains("<|function|>\n{\"forecast\":\"sunny\"}</s>"),
6993            "legacy function role should be preserved in fallback template: {}",
6994            internal.prompt
6995        );
6996        assert_eq!(
6997            internal.metadata["openai_legacy_functions"][0]["name"],
6998            "weather"
6999        );
7000        assert_eq!(internal.metadata["openai_legacy_function_call"], "auto");
7001        let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
7002            panic!("expected structured chat api_request");
7003        };
7004        assert_eq!(api.messages.len(), 3);
7005        assert_eq!(api.messages[2].role, ferrum_types::ApiMessageRole::Function);
7006        assert_eq!(api.messages[2].name.as_deref(), Some("weather"));
7007        assert_eq!(
7008            api.messages[1]
7009                .function_call
7010                .as_ref()
7011                .map(|call| call.name.as_str()),
7012            Some("weather")
7013        );
7014        assert_eq!(api.legacy_functions[0].name, "weather");
7015        assert_eq!(
7016            api.legacy_function_call,
7017            Some(ferrum_types::ApiFunctionCallChoice::Mode("auto".into()))
7018        );
7019    }
7020
7021    #[test]
7022    fn specific_legacy_function_call_parses_into_structured_api_request() {
7023        let request: ChatCompletionsRequest = serde_json::from_value(json!({
7024            "model": "mystery-model",
7025            "messages": [{"role": "user", "content": "Use the selected function."}],
7026            "functions": [
7027                {"name": "weather", "parameters": {"type": "object"}},
7028                {"name": "calendar", "parameters": {"type": "object"}}
7029            ],
7030            "function_call": {"name": "weather"}
7031        }))
7032        .expect("specific function_call request parses");
7033
7034        validate_chat_request(&request).expect("specific function_call validates");
7035        let internal = convert_chat_request(&request).expect("convert");
7036        assert!(internal.prompt.contains("\"function_call\":{"));
7037        assert!(internal.prompt.contains("\"name\":\"weather\""));
7038        let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
7039            panic!("expected structured chat api_request");
7040        };
7041        assert_eq!(
7042            api.legacy_function_call,
7043            Some(ferrum_types::ApiFunctionCallChoice::Function {
7044                name: "weather".to_string(),
7045            })
7046        );
7047
7048        let invalid: ChatCompletionsRequest = serde_json::from_value(json!({
7049            "model": "mystery-model",
7050            "messages": [{"role": "user", "content": "Use the selected function."}],
7051            "functions": [{"name": "weather", "parameters": {"type": "object"}}],
7052            "function_call": {"name": "calendar"}
7053        }))
7054        .expect("invalid specific function_call request parses");
7055        let err = validate_chat_request(&invalid).expect_err("undeclared function should reject");
7056        match err {
7057            ServerError::InvalidRequest { param, .. } => {
7058                assert_eq!(param.as_deref(), Some("function_call"));
7059            }
7060            other => panic!("expected invalid_request_error for function_call, got {other:?}"),
7061        }
7062    }
7063
7064    #[test]
7065    fn stream_text_delta_handles_unicode_boundaries() {
7066        let mut sent_len = 0usize;
7067        assert_eq!(stream_text_delta("你好", &mut sent_len), "你好");
7068        assert_eq!(sent_len, "你好".len());
7069        assert_eq!(stream_text_delta("你好世界", &mut sent_len), "世界");
7070        assert_eq!(sent_len, "你好世界".len());
7071    }
7072
7073    #[test]
7074    fn stream_text_delta_recovers_from_non_boundary_offset() {
7075        let mut sent_len = 1usize;
7076        assert_eq!(stream_text_delta("你好", &mut sent_len), "");
7077        assert_eq!(sent_len, "你好".len());
7078    }
7079
7080    #[test]
7081    fn assistant_tool_call_serializes_openai_shape() {
7082        let message = ChatMessage {
7083            role: MessageRole::Assistant,
7084            content: String::new(),
7085            reasoning: None,
7086            name: None,
7087            tool_calls: Some(vec![ChatToolCall {
7088                index: None,
7089                id: "call_1".to_string(),
7090                tool_type: "function".to_string(),
7091                function: ChatFunctionCall {
7092                    name: "weather".to_string(),
7093                    arguments: "{\"city\":\"Paris\"}".to_string(),
7094                },
7095            }]),
7096            tool_call_id: None,
7097            function_call: None,
7098        };
7099        let value = serde_json::to_value(message).expect("serialize");
7100        assert_eq!(value["role"], "assistant");
7101        assert_eq!(value["tool_calls"][0]["type"], "function");
7102        assert_eq!(value["tool_calls"][0]["function"]["name"], "weather");
7103    }
7104
7105    #[test]
7106    fn unsupported_multimodal_content_is_not_silently_dropped() {
7107        let err = serde_json::from_value::<ChatCompletionsRequest>(json!({
7108            "model": "stub-model",
7109            "messages": [{
7110                "role": "user",
7111                "content": [
7112                    {"type": "text", "text": "describe this"},
7113                    {"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}}
7114                ]
7115            }]
7116        }))
7117        .expect_err("unsupported content part should fail parsing");
7118        assert!(
7119            err.to_string()
7120                .contains("unsupported message content part type"),
7121            "unexpected error: {err}"
7122        );
7123    }
7124
7125    #[tokio::test]
7126    async fn completions_endpoint_uses_stub_engine() {
7127        let request = CompletionsRequest {
7128            model: "stub-model".to_string(),
7129            prompt: CompletionPrompt::Text("complete me".to_string()),
7130            max_tokens: Some(8),
7131            temperature: Some(0.0),
7132            top_p: None,
7133            n: None,
7134            stream: None,
7135            stop: None,
7136            logprobs: None,
7137            logit_bias: None,
7138        };
7139        let response = completions_handler(State(state_with_stub("done")), Ok(Json(request)))
7140            .await
7141            .expect("completion response");
7142        assert_eq!(response.status(), AxumStatusCode::OK);
7143        let body = response_json(response).await;
7144        assert_eq!(body["object"], "text_completion");
7145        assert_eq!(body["choices"][0]["text"], "done");
7146        assert_eq!(body["usage"]["prompt_tokens"], 7);
7147        assert_eq!(body["usage"]["completion_tokens"], 2);
7148    }
7149
7150    #[tokio::test]
7151    async fn route_completions_rejects_non_string_prompt_with_field_param() {
7152        for prompt in [
7153            json!(["a", "b"]),
7154            json!({"text": "complete me"}),
7155            Value::Null,
7156        ] {
7157            let response = post_json(
7158                router_with_stub("unused"),
7159                "/v1/completions",
7160                json!({
7161                    "model": "stub-model",
7162                    "prompt": prompt
7163                }),
7164            )
7165            .await;
7166            assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
7167            let body = response_json(response).await;
7168            assert_eq!(body["error"]["type"], "invalid_request_error");
7169            assert_eq!(body["error"]["param"], "prompt");
7170        }
7171
7172        let response = post_json(
7173            router_with_stub("unused"),
7174            "/v1/completions",
7175            json!({"model": "stub-model"}),
7176        )
7177        .await;
7178        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
7179        let body = response_json(response).await;
7180        assert_eq!(body["error"]["type"], "invalid_request_error");
7181        assert_eq!(body["error"]["param"], "prompt");
7182    }
7183
7184    #[tokio::test]
7185    async fn stream_options_without_stream_is_invalid() {
7186        let request = chat_request(json!({"stream_options": {"include_usage": true}}));
7187        let err = chat_completions_handler(
7188            State(state_with_stub("unused")),
7189            HeaderMap::new(),
7190            Ok(Json(request)),
7191        )
7192        .await
7193        .expect_err("stream_options without stream should reject");
7194        let (status, body) = error_json(err).await;
7195        assert_eq!(status, AxumStatusCode::BAD_REQUEST);
7196        assert_eq!(body["error"]["param"], "stream_options");
7197        assert_eq!(body["error"]["type"], "invalid_request_error");
7198    }
7199
7200    #[tokio::test]
7201    async fn json_object_strips_single_markdown_fence_as_best_effort_repair() {
7202        let request = chat_request(json!({
7203            "response_format": {"type": "json_object"}
7204        }));
7205        let response = chat_completions_handler(
7206            State(state_with_stub("```json\n{\"answer\":\"yes\"}\n```")),
7207            HeaderMap::new(),
7208            Ok(Json(request)),
7209        )
7210        .await
7211        .expect("json_object response");
7212        assert_eq!(response.status(), AxumStatusCode::OK);
7213        let body = response_json(response).await;
7214        let content = body["choices"][0]["message"]["content"]
7215            .as_str()
7216            .expect("content string");
7217        assert_eq!(content, "{\"answer\":\"yes\"}");
7218        let parsed: serde_json::Value = serde_json::from_str(content).expect("parse json_object");
7219        assert_eq!(parsed["answer"], "yes");
7220    }
7221
7222    #[tokio::test]
7223    async fn streaming_json_object_buffers_thinking_and_emits_clean_json_content() {
7224        let response = post_json(
7225            router_with_stub_stream_chunks(&[
7226                "<think>\n好的,我需要输出 JSON。",
7227                "\n</think>\n\n",
7228                "{\"name\":\"李四\",\"age\":30}",
7229            ]),
7230            "/v1/chat/completions",
7231            json!({
7232                "model": "stub-model",
7233                "messages": [{"role": "user", "content": "输出JSON(name,age):李四,30岁"}],
7234                "stream": true,
7235                "response_format": {"type": "json_object"}
7236            }),
7237        )
7238        .await;
7239        assert_eq!(response.status(), AxumStatusCode::OK);
7240        let body = response_text(response).await;
7241        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
7242        assert!(
7243            body.contains(r#""content":"{\"name\":\"李四\",\"age\":30}""#),
7244            "stream should emit clean JSON content: {body}"
7245        );
7246        assert!(
7247            body.contains(r#""reasoning":"\n好的,我需要输出 JSON。\n""#),
7248            "stream should keep thinking in reasoning field: {body}"
7249        );
7250        assert!(
7251            !body.contains(r#""content":"<think"#)
7252                && !body.contains(r#""content":"好的"#)
7253                && !body.contains(r#""content":"我需要"#),
7254            "thinking text must not leak as streamed content: {body}"
7255        );
7256    }
7257
7258    #[tokio::test]
7259    async fn json_object_remains_best_effort_not_strict_validation() {
7260        let request = chat_request(json!({
7261            "response_format": {"type": "json_object"}
7262        }));
7263        let response = chat_completions_handler(
7264            State(state_with_stub("not json")),
7265            HeaderMap::new(),
7266            Ok(Json(request)),
7267        )
7268        .await
7269        .expect("json_object remains best-effort");
7270        assert_eq!(response.status(), AxumStatusCode::OK);
7271        let body = response_json(response).await;
7272        assert_eq!(body["choices"][0]["message"]["content"], "not json");
7273    }
7274
7275    #[tokio::test]
7276    async fn unsupported_strict_json_schema_is_rejected_at_boundary() {
7277        let request = chat_request(json!({
7278            "response_format": {
7279                "type": "json_schema",
7280                "json_schema": {
7281                    "name": "unsupported",
7282                    "strict": true,
7283                    "schema": {"oneOf": [{"type": "string"}, {"type": "integer"}]}
7284                }
7285            }
7286        }));
7287        let err = chat_completions_handler(
7288            State(state_with_stub("unused")),
7289            HeaderMap::new(),
7290            Ok(Json(request)),
7291        )
7292        .await
7293        .expect_err("unsupported strict schema should reject");
7294        let (status, body) = error_json(err).await;
7295        assert_eq!(status, AxumStatusCode::BAD_REQUEST);
7296        assert_eq!(body["error"]["param"], "response_format.json_schema");
7297        assert_eq!(body["error"]["type"], "invalid_request_error");
7298    }
7299
7300    #[tokio::test]
7301    async fn missing_json_schema_schema_rejects_with_field_param() {
7302        let request = chat_request(json!({
7303            "response_format": {
7304                "type": "json_schema",
7305                "json_schema": {
7306                    "name": "missing_schema",
7307                    "strict": true
7308                }
7309            }
7310        }));
7311        let err = chat_completions_handler(
7312            State(state_with_stub("unused")),
7313            HeaderMap::new(),
7314            Ok(Json(request)),
7315        )
7316        .await
7317        .expect_err("missing strict schema should reject");
7318        let (status, body) = error_json(err).await;
7319        assert_eq!(status, AxumStatusCode::BAD_REQUEST);
7320        assert_eq!(body["error"]["param"], "response_format.json_schema");
7321        assert_eq!(body["error"]["type"], "invalid_request_error");
7322        assert!(body["error"]["message"]
7323            .as_str()
7324            .unwrap()
7325            .contains("schema is required"));
7326    }
7327
7328    #[test]
7329    fn non_strict_json_schema_is_preserved_but_not_hard_masked() {
7330        let request = chat_request(json!({
7331            "response_format": {
7332                "type": "json_schema",
7333                "json_schema": {
7334                    "name": "best_effort",
7335                    "strict": false,
7336                    "schema": {"oneOf": [{"type": "string"}, {"type": "integer"}]}
7337                }
7338            }
7339        }));
7340
7341        validate_chat_request(&request).expect("non-strict schema should not boundary reject");
7342        let internal = convert_chat_request(&request).expect("convert non-strict schema");
7343        assert!(
7344            internal
7345                .prompt
7346                .contains("response_format requires a single valid JSON object"),
7347            "response_format instruction should reach the model prompt: {}",
7348            internal.prompt
7349        );
7350        assert!(
7351            internal.prompt.contains("\"oneOf\""),
7352            "schema should reach the model prompt: {}",
7353            internal.prompt
7354        );
7355        assert_eq!(
7356            internal.sampling_params.response_format,
7357            ferrum_types::ResponseFormat::Text,
7358            "non-strict json_schema must stay best-effort instead of enabling hard guided decode"
7359        );
7360        let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
7361            panic!("expected structured chat api_request");
7362        };
7363        assert_eq!(
7364            api.response_format
7365                .as_ref()
7366                .and_then(|format| format.json_schema.as_ref())
7367                .and_then(|schema| schema.strict),
7368            Some(false)
7369        );
7370    }
7371
7372    #[test]
7373    fn json_object_response_format_instruction_reaches_model_prompt() {
7374        let request = chat_request(json!({
7375            "response_format": {"type": "json_object"}
7376        }));
7377
7378        let internal = convert_chat_request(&request).expect("convert json_object");
7379        assert!(
7380            internal
7381                .prompt
7382                .contains("response_format requires a single valid JSON object"),
7383            "response_format instruction should reach the model prompt: {}",
7384            internal.prompt
7385        );
7386        assert!(
7387            internal.prompt.contains("Output only JSON"),
7388            "JSON-only instruction should reach the model prompt: {}",
7389            internal.prompt
7390        );
7391        assert_eq!(
7392            internal.sampling_params.response_format,
7393            ferrum_types::ResponseFormat::Text,
7394            "json_object response_format should use prompt instruction and final JSON cleanup, not engine-wide JSON soft bias"
7395        );
7396    }
7397
7398    #[test]
7399    fn strict_json_schema_response_format_uses_text_sampling_mode() {
7400        let request = chat_request(json!({
7401            "response_format": {
7402                "type": "json_schema",
7403                "json_schema": {
7404                    "name": "answer",
7405                    "strict": true,
7406                    "schema": {
7407                        "type": "object",
7408                        "properties": {"answer": {"type": "string"}},
7409                        "required": ["answer"]
7410                    }
7411                }
7412            }
7413        }));
7414
7415        let internal = convert_chat_request(&request).expect("convert strict json_schema");
7416        assert!(
7417            internal
7418                .prompt
7419                .contains("response_format requires a single valid JSON object"),
7420            "response_format instruction should reach the model prompt: {}",
7421            internal.prompt
7422        );
7423        assert_eq!(
7424            internal.sampling_params.response_format,
7425            ferrum_types::ResponseFormat::Text,
7426            "strict json_schema is validated after generation instead of applying JSON soft bias during Qwen3 thinking"
7427        );
7428    }
7429
7430    #[tokio::test]
7431    async fn strict_json_schema_validates_non_streaming_response() {
7432        let request = chat_request(json!({
7433            "response_format": {
7434                "type": "json_schema",
7435                "json_schema": {
7436                    "name": "answer",
7437                    "strict": true,
7438                    "schema": {
7439                        "type": "object",
7440                        "properties": {"answer": {"type": "string"}},
7441                        "required": ["answer"]
7442                    }
7443                }
7444            }
7445        }));
7446        let response = chat_completions_handler(
7447            State(state_with_stub("{\"answer\":\"yes\"}")),
7448            HeaderMap::new(),
7449            Ok(Json(request)),
7450        )
7451        .await
7452        .expect("strict response");
7453        assert_eq!(response.status(), AxumStatusCode::OK);
7454        let body = response_json(response).await;
7455        assert_eq!(
7456            body["choices"][0]["message"]["content"],
7457            "{\"answer\":\"yes\"}"
7458        );
7459    }
7460
7461    #[tokio::test]
7462    async fn strict_json_schema_validates_non_streaming_response_after_reasoning_block() {
7463        let request = chat_request(json!({
7464            "response_format": {
7465                "type": "json_schema",
7466                "json_schema": {
7467                    "name": "answer",
7468                    "strict": true,
7469                    "schema": {
7470                        "type": "object",
7471                        "properties": {"answer": {"type": "string"}},
7472                        "required": ["answer"]
7473                    }
7474                }
7475            }
7476        }));
7477        let response = chat_completions_handler(
7478            State(state_with_stub(
7479                "<think>\nreasoning\n</think>\n\n{\"answer\":\"yes\"}",
7480            )),
7481            HeaderMap::new(),
7482            Ok(Json(request)),
7483        )
7484        .await
7485        .expect("strict response with reasoning");
7486        assert_eq!(response.status(), AxumStatusCode::OK);
7487        let body = response_json(response).await;
7488        assert_eq!(
7489            body["choices"][0]["message"]["content"],
7490            "{\"answer\":\"yes\"}"
7491        );
7492        assert_eq!(body["choices"][0]["message"]["reasoning"], "\nreasoning\n");
7493    }
7494
7495    #[tokio::test]
7496    async fn strict_json_schema_validates_streaming_final_response() {
7497        let response = post_json(
7498            router_with_stub("{\"answer\":\"yes\"}"),
7499            "/v1/chat/completions",
7500            json!({
7501                "model": "stub-model",
7502                "messages": [{"role": "user", "content": "Return an answer object."}],
7503                "stream": true,
7504                "response_format": {
7505                    "type": "json_schema",
7506                    "json_schema": {
7507                        "name": "answer",
7508                        "strict": true,
7509                        "schema": {
7510                            "type": "object",
7511                            "properties": {"answer": {"type": "string"}},
7512                            "required": ["answer"]
7513                        }
7514                    }
7515                }
7516            }),
7517        )
7518        .await;
7519        assert_eq!(response.status(), AxumStatusCode::OK);
7520        let body = response_text(response).await;
7521        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
7522        assert!(
7523            body.contains("\\\"answer\\\":\\\"yes\\\""),
7524            "strict streaming content missing: {body}"
7525        );
7526        assert!(
7527            !body.contains("\"error\""),
7528            "valid strict streaming response should not emit error: {body}"
7529        );
7530    }
7531
7532    #[tokio::test]
7533    async fn strict_json_schema_validates_streaming_final_response_after_reasoning_block() {
7534        let response = post_json(
7535            router_with_stub_stream_chunks(&[
7536                "<think>\nreasoning",
7537                "\n</think>\n\n",
7538                "{\"answer\":\"yes\"}",
7539            ]),
7540            "/v1/chat/completions",
7541            json!({
7542                "model": "stub-model",
7543                "messages": [{"role": "user", "content": "Return an answer object."}],
7544                "stream": true,
7545                "response_format": {
7546                    "type": "json_schema",
7547                    "json_schema": {
7548                        "name": "answer",
7549                        "strict": true,
7550                        "schema": {
7551                            "type": "object",
7552                            "properties": {"answer": {"type": "string"}},
7553                            "required": ["answer"]
7554                        }
7555                    }
7556                }
7557            }),
7558        )
7559        .await;
7560        assert_eq!(response.status(), AxumStatusCode::OK);
7561        let body = response_text(response).await;
7562        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
7563        assert!(
7564            body.contains("\\\"answer\\\":\\\"yes\\\""),
7565            "strict streaming content missing: {body}"
7566        );
7567        assert!(
7568            body.contains(r#""reasoning":"\nreasoning\n""#),
7569            "strict streaming should keep reasoning separate: {body}"
7570        );
7571        assert!(
7572            !body.contains("\"error\""),
7573            "valid strict streaming response should not emit error: {body}"
7574        );
7575    }
7576
7577    #[tokio::test]
7578    async fn strict_json_schema_invalid_streaming_output_emits_error_event() {
7579        let response = post_json(
7580            router_with_stub("not json"),
7581            "/v1/chat/completions",
7582            json!({
7583                "model": "stub-model",
7584                "messages": [{"role": "user", "content": "Return an answer object."}],
7585                "stream": true,
7586                "response_format": {
7587                    "type": "json_schema",
7588                    "json_schema": {
7589                        "name": "answer",
7590                        "strict": true,
7591                        "schema": {
7592                            "type": "object",
7593                            "properties": {"answer": {"type": "string"}},
7594                            "required": ["answer"]
7595                        }
7596                    }
7597                }
7598            }),
7599        )
7600        .await;
7601        assert_eq!(response.status(), AxumStatusCode::OK);
7602        let body = response_text(response).await;
7603        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
7604        assert!(
7605            body.contains("\"type\":\"internal_server_error\""),
7606            "strict streaming validation failure should emit OpenAI error: {body}"
7607        );
7608        assert!(
7609            body.contains("\"param\":\"response_format.json_schema\""),
7610            "strict streaming validation error should identify schema param: {body}"
7611        );
7612        assert!(
7613            body.contains("invalid JSON"),
7614            "strict streaming validation should report invalid JSON: {body}"
7615        );
7616        assert!(
7617            !body.contains("not json"),
7618            "strict streaming must not emit invalid partial deltas before validation failure: {body}"
7619        );
7620    }
7621
7622    #[tokio::test]
7623    async fn route_strict_json_schema_supported_schema_passes_100_runs() {
7624        let request_body = json!({
7625            "model": "stub-model",
7626            "messages": [{"role": "user", "content": "Return an answer object."}],
7627            "response_format": {
7628                "type": "json_schema",
7629                "json_schema": {
7630                    "name": "answer",
7631                    "strict": true,
7632                    "schema": {
7633                        "type": "object",
7634                        "properties": {"answer": {"type": "string"}},
7635                        "required": ["answer"]
7636                    }
7637                }
7638            }
7639        });
7640        let router = router_with_stub("{\"answer\":\"yes\"}");
7641        for run in 0..100 {
7642            let response =
7643                post_json(router.clone(), "/v1/chat/completions", request_body.clone()).await;
7644            assert_eq!(
7645                response.status(),
7646                AxumStatusCode::OK,
7647                "strict schema run {run} returned non-200"
7648            );
7649            let body = response_json(response).await;
7650            let content = body["choices"][0]["message"]["content"]
7651                .as_str()
7652                .unwrap_or("");
7653            assert_eq!(
7654                content, "{\"answer\":\"yes\"}",
7655                "strict schema run {run} returned unexpected content"
7656            );
7657            let parsed: serde_json::Value =
7658                serde_json::from_str(content).expect("strict content JSON");
7659            assert_eq!(parsed["answer"], "yes");
7660        }
7661    }
7662
7663    #[test]
7664    fn cache_metrics_use_engine_real_kv_snapshot_when_available() {
7665        let cache = CacheRuntimeState::default();
7666        let policy = CachePolicy {
7667            prefix_cache_enabled: true,
7668            session_cache_mode: "memory".to_string(),
7669            session_cache_max_entries: 128,
7670            session_cache_max_tokens: 4096,
7671        };
7672        cache.record_prefix_prompt("alpha beta gamma", &policy);
7673        cache.record_prefix_prompt("alpha beta delta", &policy);
7674
7675        let engine_snapshot = json!({
7676            "position": "real-kv-reuse",
7677            "source": "llama-family-paged-block-prefix-cache",
7678            "enabled": true,
7679            "hits": 7,
7680            "misses": 3,
7681            "evictions": 1,
7682            "saved_prefill_tokens": 64,
7683            "entries": 5,
7684            "bytes": 8192,
7685            "block_size": 16,
7686            "kv_dtype": "fp16",
7687            "selected_pipeline_mode": "batch",
7688            "selected_stage_bridge": "host",
7689            "stage_count": 2,
7690        });
7691
7692        let health = cache.health_json(&policy, Some(&engine_snapshot));
7693        let prefix = &health["prefix_cache"];
7694        assert_eq!(prefix["position"], "real-kv-reuse");
7695        assert_eq!(prefix["source"], "llama-family-paged-block-prefix-cache");
7696        assert_eq!(prefix["hits"], 7);
7697        assert_eq!(prefix["misses"], 3);
7698        assert_eq!(prefix["evictions"], 1);
7699        assert_eq!(prefix["saved_prefill_tokens"], 64);
7700        assert_eq!(prefix["entries"], 5);
7701        assert_eq!(prefix["bytes"], 8192);
7702        assert_eq!(prefix["block_size"], 16);
7703        assert_eq!(prefix["kv_dtype"], "fp16");
7704        assert_eq!(prefix["selected_pipeline_mode"], "batch");
7705        assert_eq!(prefix["selected_stage_bridge"], "host");
7706        assert_eq!(prefix["stage_count"], 2);
7707
7708        let metrics = cache.prometheus_metrics(Some(&engine_snapshot));
7709        assert!(metrics.contains("ferrum_prefix_cache_hits_total 7\n"));
7710        assert!(metrics.contains("ferrum_prefix_cache_misses_total 3\n"));
7711        assert!(metrics.contains("ferrum_prefix_cache_saved_prefill_tokens_total 64\n"));
7712        assert!(metrics.contains("ferrum_prefix_cache_entries 5\n"));
7713        assert!(metrics.contains("ferrum_prefix_cache_bytes 8192\n"));
7714    }
7715
7716    #[tokio::test]
7717    async fn strict_json_schema_invalid_model_output_fails_before_response() {
7718        let request = chat_request(json!({
7719            "response_format": {
7720                "type": "json_schema",
7721                "json_schema": {
7722                    "name": "answer",
7723                    "strict": true,
7724                    "schema": {
7725                        "type": "object",
7726                        "properties": {"answer": {"type": "string"}},
7727                        "required": ["answer"]
7728                    }
7729                }
7730            }
7731        }));
7732        let err = chat_completions_handler(
7733            State(state_with_stub("not json")),
7734            HeaderMap::new(),
7735            Ok(Json(request)),
7736        )
7737        .await
7738        .expect_err("invalid strict response should fail");
7739        let (status, body) = error_json(err).await;
7740        assert_eq!(status, AxumStatusCode::INTERNAL_SERVER_ERROR);
7741        assert_eq!(body["error"]["type"], "internal_server_error");
7742        assert!(body["error"]["message"]
7743            .as_str()
7744            .unwrap()
7745            .contains("json_schema.strict"));
7746    }
7747
7748    #[tokio::test]
7749    async fn strict_json_schema_does_not_rely_on_markdown_fence_stripping() {
7750        let request = chat_request(json!({
7751            "response_format": {
7752                "type": "json_schema",
7753                "json_schema": {
7754                    "name": "answer",
7755                    "strict": true,
7756                    "schema": {
7757                        "type": "object",
7758                        "properties": {"answer": {"type": "string"}},
7759                        "required": ["answer"]
7760                    }
7761                }
7762            }
7763        }));
7764        let err = chat_completions_handler(
7765            State(state_with_stub("```json\n{\"answer\":\"yes\"}\n```")),
7766            HeaderMap::new(),
7767            Ok(Json(request)),
7768        )
7769        .await
7770        .expect_err("strict schema should fail fenced JSON instead of repairing it");
7771        let (status, body) = error_json(err).await;
7772        assert_eq!(status, AxumStatusCode::INTERNAL_SERVER_ERROR);
7773        assert_eq!(body["error"]["type"], "internal_server_error");
7774        assert!(body["error"]["message"]
7775            .as_str()
7776            .unwrap()
7777            .contains("json_schema.strict: invalid JSON"));
7778    }
7779}