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        ModelReasoningProtocol,
11    },
12    model_registry::{LoraAdapterModel, ServedModelKind, ServedModelRegistry},
13    openai::*,
14    traits::HttpServer,
15    types::*,
16};
17use async_trait::async_trait;
18use axum::{
19    extract::{multipart::MultipartRejection, rejection::JsonRejection, State},
20    http::{HeaderMap, StatusCode as AxumStatusCode},
21    response::{sse::Event, IntoResponse, Response, Sse},
22    routing::{get, post},
23    Json, Router,
24};
25use ferrum_interfaces::engine::{EmbedEngine, LlmInferenceEngine, TranscribeEngine, TtsEngine};
26use ferrum_types::{
27    has_unclosed_thinking_block, parse_reasoning_response,
28    parse_reasoning_response_started_in_think, EngineMetrics, EngineStatus, FerrumConfigBuilder,
29    FerrumError as Error, FerrumProfileEvent, FinishReason, InferenceExecutionEvidence,
30    InferenceRequest, InferenceResponse, ModelId, ParsedReasoningResponse, Priority,
31    ProcessMemoryObservation, ProcessMemorySample, ProcessMemorySampler, ProfileEntrypoint,
32    ProfileError, ProfileEventKind, ProfileStatus, ReplayReference, RequestId,
33    ResolvedFerrumConfig, ResourceAction, ResourceTraceEvent, ResponseCompletionBoundary,
34    RuntimeConfigSnapshot, SamplingParams, StructuredOutputStart, TokenId, TokenUsage,
35    DEFAULT_CHAT_REPETITION_PENALTY, DEFAULT_MAX_TOKENS_METADATA_KEY,
36    OBSERVABILITY_PROFILE_SCHEMA_VERSION, THINK_END_TAG, THINK_START_TAG,
37};
38use sha2::{Digest, Sha256};
39use std::{
40    collections::{BTreeMap, HashMap},
41    error::Error as StdError,
42    fs,
43    path::{Path, PathBuf},
44    sync::{
45        atomic::{AtomicBool, Ordering},
46        Arc, Mutex, OnceLock,
47    },
48    time::Instant,
49};
50use tokio::sync::{mpsc, Notify};
51use tokio_stream::StreamExt;
52use tower::ServiceBuilder;
53use tower_http::{cors::CorsLayer, trace::TraceLayer};
54use tracing::{debug, error, info, span, warn, Level};
55use uuid::Uuid;
56
57mod responses;
58
59const DEFAULT_SAMPLING_TEMPERATURE: f32 = 0.0;
60const DEFAULT_SAMPLING_TOP_P: f32 = 1.0;
61const DEFAULT_COMPLETION_MAX_TOKENS: u32 = 4096;
62const INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY: &str = "ferrum_initial_forbidden_token_texts";
63const DEFAULT_GUIDED_TOOL_ARGUMENT_STRING_MAX_LENGTH: u64 = 128;
64const MAX_CACHED_JSON_SCHEMA_VALIDATORS: usize = 64;
65const INITIAL_STRUCTURED_CALL_FORBIDDEN_TOKEN_TEXTS: &[&str] =
66    &["<|im_end|>", "<|endoftext|>", "<|eot_id|>", "</s>"];
67const FERRUM_SESSION_HEADER: &str = "x-ferrum-session";
68static JSON_SCHEMA_VALIDATOR_CACHE: OnceLock<Mutex<HashMap<String, Arc<jsonschema::Validator>>>> =
69    OnceLock::new();
70
71/// Product defaults used when an OpenAI chat request omits sampling fields.
72/// The engine composition root consumes the same typed value so its resolved
73/// startup plan cannot describe different defaults from the HTTP endpoint.
74pub fn default_chat_sampling_params() -> SamplingParams {
75    SamplingParams {
76        max_tokens: DEFAULT_COMPLETION_MAX_TOKENS as usize,
77        temperature: DEFAULT_SAMPLING_TEMPERATURE,
78        top_p: DEFAULT_SAMPLING_TOP_P,
79        repetition_penalty: DEFAULT_CHAT_REPETITION_PENALTY,
80        ..SamplingParams::default()
81    }
82}
83
84#[derive(Debug, Clone)]
85struct CachePolicy {
86    prefix_cache_enabled: bool,
87    session_cache_mode: String,
88    session_cache_max_entries: usize,
89    session_cache_max_tokens: usize,
90}
91
92impl CachePolicy {
93    fn current() -> Self {
94        Self {
95            prefix_cache_enabled: env_bool("FERRUM_PREFIX_CACHE_PRODUCT")
96                .or_else(|| env_bool("FERRUM_PREFIX_CACHE_REQUESTED"))
97                .or_else(|| env_bool("FERRUM_PREFIX_CACHE"))
98                .unwrap_or(false),
99            session_cache_mode: std::env::var("FERRUM_SESSION_CACHE")
100                .unwrap_or_else(|_| "off".to_string())
101                .to_ascii_lowercase(),
102            session_cache_max_entries: env_usize("FERRUM_SESSION_CACHE_MAX_ENTRIES").unwrap_or(128),
103            session_cache_max_tokens: env_usize("FERRUM_SESSION_CACHE_MAX_TOKENS").unwrap_or(4096),
104        }
105    }
106
107    fn session_memory_enabled(&self) -> bool {
108        self.session_cache_mode == "memory"
109    }
110}
111
112fn env_bool(key: &str) -> Option<bool> {
113    match std::env::var(key).ok()?.to_ascii_lowercase().as_str() {
114        "1" | "true" | "yes" | "on" => Some(true),
115        "0" | "false" | "no" | "off" => Some(false),
116        _ => None,
117    }
118}
119
120fn env_usize(key: &str) -> Option<usize> {
121    std::env::var(key).ok()?.parse().ok()
122}
123
124/// Shared Prometheus recorder handle for rendering metrics.
125static PROM_HANDLE: std::sync::OnceLock<metrics_exporter_prometheus::PrometheusHandle> =
126    std::sync::OnceLock::new();
127
128/// Initialize the Prometheus metrics recorder.
129///
130/// Must be called once before any `metrics::counter!()` / `histogram!()` calls.
131/// Safe to call multiple times — subsequent calls are no-ops.
132pub fn init_prometheus_recorder() {
133    PROM_HANDLE.get_or_init(|| {
134        let builder = metrics_exporter_prometheus::PrometheusBuilder::new();
135        let handle = builder
136            .install_recorder()
137            .expect("Failed to install Prometheus recorder");
138        info!("Prometheus metrics recorder installed");
139        handle
140    });
141}
142
143/// Axum-based server implementation.
144///
145/// The server is built around [`AppState`], which holds an optional
146/// engine per modality. Handlers fault to 503 when the modality they
147/// need isn't loaded, instead of running stub error logic.
148pub struct AxumServer {
149    state: AppState,
150    config: ServerConfig,
151    lifecycle: Arc<AxumServerLifecycle>,
152}
153
154#[derive(Default)]
155struct AxumServerLifecycle {
156    shutdown_requested: AtomicBool,
157    running: AtomicBool,
158    engines_stopped: AtomicBool,
159    shutdown_notify: Notify,
160    stopped_notify: Notify,
161    stop_lock: tokio::sync::Mutex<()>,
162}
163
164impl AxumServerLifecycle {
165    fn request_shutdown(&self) {
166        self.shutdown_requested.store(true, Ordering::Release);
167        self.shutdown_notify.notify_waiters();
168    }
169
170    async fn wait_for_shutdown(&self) {
171        while !self.shutdown_requested.load(Ordering::Acquire) {
172            self.shutdown_notify.notified().await;
173        }
174    }
175
176    async fn wait_until_stopped(&self) {
177        while self.running.load(Ordering::Acquire) {
178            self.stopped_notify.notified().await;
179        }
180    }
181}
182
183struct AxumServerRunGuard {
184    lifecycle: Arc<AxumServerLifecycle>,
185}
186
187impl Drop for AxumServerRunGuard {
188    fn drop(&mut self) {
189        self.lifecycle.running.store(false, Ordering::Release);
190        self.lifecycle.stopped_notify.notify_waiters();
191    }
192}
193
194fn single_model_registry(engine_model_id: ModelId, kind: ServedModelKind) -> ServedModelRegistry {
195    let public_name = engine_model_id.to_string();
196    ServedModelRegistry::try_new(engine_model_id, kind, vec![public_name], vec![])
197        .expect("engine config must contain a valid model id")
198}
199
200impl AxumServer {
201    /// Create a server with a fully populated AppState.
202    pub fn from_state(state: AppState) -> Self {
203        Self {
204            state,
205            config: ServerConfig::default(),
206            lifecycle: Arc::new(AxumServerLifecycle::default()),
207        }
208    }
209
210    /// Convenience constructor for an LLM-only server (chat / completions).
211    pub fn from_llm(engine: Arc<dyn LlmInferenceEngine + Send + Sync>) -> Self {
212        Self::from_state(AppState::default().with_llm(engine))
213    }
214
215    /// Convenience constructor for an embedding-only server (`/v1/embeddings`).
216    pub fn from_embed(engine: Arc<dyn EmbedEngine + Send + Sync>) -> Self {
217        Self::from_state(AppState::default().with_embed(engine))
218    }
219
220    /// Convenience constructor for a transcription-only server
221    /// (`/v1/audio/transcriptions`).
222    pub fn from_transcribe(engine: Arc<dyn TranscribeEngine + Send + Sync>) -> Self {
223        Self::from_state(AppState::default().with_transcribe(engine))
224    }
225
226    /// Convenience constructor for a TTS-only server (`/v1/audio/speech`).
227    pub fn from_tts(engine: Arc<dyn TtsEngine + Send + Sync>) -> Self {
228        Self::from_state(AppState::default().with_tts(engine))
229    }
230
231    /// Attach the startup auto-configuration decision trace exposed by
232    /// `/health`. Constructors keep this optional so tests and non-LLM
233    /// deployments can use the server without a model-specific resolver.
234    pub fn with_auto_config(mut self, auto_config: ResolvedFerrumConfig) -> Self {
235        self.state = self.state.with_auto_config(auto_config);
236        self
237    }
238
239    /// Attach the loaded model's prompt template, if available. This keeps
240    /// OpenAI request aliases from driving prompt-family selection.
241    pub fn with_prompt_template(mut self, prompt_template: Option<ModelChatTemplate>) -> Self {
242        self.state = self.state.with_prompt_template(prompt_template);
243        self
244    }
245
246    /// Set the server-wide reasoning default for requests that omit
247    /// `chat_template_kwargs.enable_thinking`. Per-request values still win.
248    pub fn with_default_enable_thinking(mut self, enable_thinking: Option<bool>) -> Self {
249        self.state = self.state.with_default_enable_thinking(enable_thinking);
250        self
251    }
252
253    /// Install the public OpenAI model namespace used for request routing and
254    /// `/v1/models`. The registry keeps public aliases separate from the
255    /// engine's internal model id.
256    pub fn with_served_model_registry(mut self, registry: ServedModelRegistry) -> Self {
257        self.state = self.state.with_served_model_registry(registry);
258        self
259    }
260
261    /// Attach startup-loaded LoRA adapter model ids.
262    pub fn with_lora_adapters(
263        mut self,
264        base_model_id: impl Into<String>,
265        adapters: Vec<LoraAdapterModel>,
266    ) -> ferrum_types::Result<Self> {
267        let base_model_id = base_model_id.into();
268        let registry = if self.state.served_model_registry.is_empty() {
269            ServedModelRegistry::try_new(
270                base_model_id.clone(),
271                ServedModelKind::Llm,
272                vec![base_model_id],
273                adapters,
274            )
275        } else {
276            self.state
277                .served_model_registry
278                .try_with_lora_adapters(&base_model_id, adapters)
279        }
280        .map_err(|error| Error::config(error.to_string()))?;
281        self.state = self.state.with_served_model_registry(registry);
282        Ok(self)
283    }
284
285    async fn shutdown_loaded_engines(&self) -> ferrum_types::Result<()> {
286        let mut first_error = None;
287        if let Some(engine) = &self.state.llm {
288            if let Err(error) = engine.shutdown().await {
289                first_error = Some(error);
290            }
291        }
292        if let Some(engine) = &self.state.embed {
293            if let Err(error) = engine.shutdown().await {
294                if first_error.is_none() {
295                    first_error = Some(error);
296                }
297            }
298        }
299        if let Some(engine) = &self.state.transcribe {
300            if let Err(error) = engine.shutdown().await {
301                if first_error.is_none() {
302                    first_error = Some(error);
303                }
304            }
305        }
306        if let Some(engine) = &self.state.tts {
307            if let Err(error) = engine.shutdown().await {
308                if first_error.is_none() {
309                    first_error = Some(error);
310                }
311            }
312        }
313        first_error.map_or(Ok(()), Err)
314    }
315
316    /// Build the router with all routes
317    #[allow(dead_code)]
318    fn build_router(&self) -> Router {
319        self.build_router_with_state(self.state.clone())
320    }
321
322    fn build_router_with_state(&self, app_state: AppState) -> Router {
323        Router::new()
324            // OpenAI API routes
325            .route("/v1/chat/completions", post(chat_completions_handler))
326            .route("/v1/responses", post(responses::responses_handler))
327            .route("/v1/completions", post(completions_handler))
328            .route("/v1/embeddings", post(embeddings_handler))
329            .route("/v1/audio/transcriptions", post(transcriptions_handler))
330            .route("/v1/audio/speech", post(speech_handler))
331            .route("/v1/models", get(models_handler))
332            // Health & observability
333            .route("/health", get(health_handler))
334            .route("/metrics", get(metrics_handler))
335            .route("/", get(root_handler))
336            // Apply middleware
337            .layer(
338                ServiceBuilder::new()
339                    .layer(TraceLayer::new_for_http())
340                    .layer(CorsLayer::permissive()), // For MVP, allow all origins
341            )
342            .with_state(app_state)
343    }
344}
345
346/// Application state shared across handlers — one optional engine per
347/// modality. Handlers reach into the field they need and 503 when it's
348/// not loaded.
349#[derive(Clone, Default)]
350pub struct AppState {
351    pub llm: Option<Arc<dyn LlmInferenceEngine + Send + Sync>>,
352    pub embed: Option<Arc<dyn EmbedEngine + Send + Sync>>,
353    pub transcribe: Option<Arc<dyn TranscribeEngine + Send + Sync>>,
354    pub tts: Option<Arc<dyn TtsEngine + Send + Sync>>,
355    pub auto_config: Option<ResolvedFerrumConfig>,
356    pub prompt_template: Option<Arc<ModelChatTemplate>>,
357    pub default_enable_thinking: Option<bool>,
358    pub served_model_registry: Arc<ServedModelRegistry>,
359    pub request_dump_dir: Option<Arc<PathBuf>>,
360    pub profile_jsonl: Option<Arc<PathBuf>>,
361    pub profile_detail: ferrum_types::ObservabilityProfileDetail,
362    pub memory_profile_jsonl: Option<Arc<PathBuf>>,
363    pub first_request_memory_recorded: Arc<AtomicBool>,
364    cache: Arc<CacheRuntimeState>,
365}
366
367impl AppState {
368    pub fn with_llm(mut self, engine: Arc<dyn LlmInferenceEngine + Send + Sync>) -> Self {
369        if self.served_model_registry.is_empty() {
370            self.served_model_registry = Arc::new(single_model_registry(
371                engine.config().model.model_id.clone(),
372                ServedModelKind::Llm,
373            ));
374        }
375        self.llm = Some(engine);
376        self
377    }
378    pub fn with_embed(mut self, engine: Arc<dyn EmbedEngine + Send + Sync>) -> Self {
379        if self.served_model_registry.is_empty() {
380            self.served_model_registry = Arc::new(single_model_registry(
381                engine.config().model.model_id.clone(),
382                ServedModelKind::Embedding,
383            ));
384        }
385        self.embed = Some(engine);
386        self
387    }
388    pub fn with_transcribe(mut self, engine: Arc<dyn TranscribeEngine + Send + Sync>) -> Self {
389        if self.served_model_registry.is_empty() {
390            self.served_model_registry = Arc::new(single_model_registry(
391                engine.config().model.model_id.clone(),
392                ServedModelKind::Transcription,
393            ));
394        }
395        self.transcribe = Some(engine);
396        self
397    }
398    pub fn with_tts(mut self, engine: Arc<dyn TtsEngine + Send + Sync>) -> Self {
399        if self.served_model_registry.is_empty() {
400            self.served_model_registry = Arc::new(single_model_registry(
401                engine.config().model.model_id.clone(),
402                ServedModelKind::Speech,
403            ));
404        }
405        self.tts = Some(engine);
406        self
407    }
408
409    pub fn with_auto_config(mut self, auto_config: ResolvedFerrumConfig) -> Self {
410        self.auto_config = Some(auto_config);
411        self
412    }
413
414    pub fn with_prompt_template(mut self, prompt_template: Option<ModelChatTemplate>) -> Self {
415        self.prompt_template = prompt_template.map(Arc::new);
416        self
417    }
418
419    pub fn with_default_enable_thinking(mut self, enable_thinking: Option<bool>) -> Self {
420        self.default_enable_thinking = enable_thinking;
421        self
422    }
423
424    pub fn with_served_model_registry(mut self, registry: ServedModelRegistry) -> Self {
425        self.served_model_registry = Arc::new(registry);
426        self
427    }
428
429    pub fn with_request_dump_dir(mut self, request_dump_dir: Option<PathBuf>) -> Self {
430        self.request_dump_dir = request_dump_dir.map(Arc::new);
431        self
432    }
433
434    pub fn with_profile_jsonl(mut self, profile_jsonl: Option<PathBuf>) -> Self {
435        self.profile_jsonl = profile_jsonl.map(Arc::new);
436        self
437    }
438
439    pub fn with_profile_detail(
440        mut self,
441        profile_detail: ferrum_types::ObservabilityProfileDetail,
442    ) -> Self {
443        self.profile_detail = profile_detail;
444        self
445    }
446
447    pub fn with_memory_profile_jsonl(mut self, memory_profile_jsonl: Option<PathBuf>) -> Self {
448        self.memory_profile_jsonl = memory_profile_jsonl.map(Arc::new);
449        self
450    }
451
452    /// Async aggregated status across whichever modality is loaded.
453    /// In single-modality deployments (current CLI), exactly one is Some.
454    async fn status(&self) -> EngineStatus {
455        if let Some(e) = &self.llm {
456            return e.status().await;
457        }
458        if let Some(e) = &self.embed {
459            return e.status().await;
460        }
461        if let Some(e) = &self.transcribe {
462            return e.status().await;
463        }
464        if let Some(e) = &self.tts {
465            return e.status().await;
466        }
467        EngineStatus {
468            is_ready: false,
469            loaded_models: vec![],
470            active_requests: 0,
471            queued_requests: 0,
472            memory_usage: ferrum_types::MemoryUsage {
473                total_bytes: 0,
474                used_bytes: 0,
475                free_bytes: 0,
476                gpu_memory_bytes: None,
477                cpu_memory_bytes: None,
478                cache_memory_bytes: 0,
479                utilization_percent: 0.0,
480            },
481            uptime_seconds: 0,
482            last_heartbeat: chrono::Utc::now(),
483            version: env!("CARGO_PKG_VERSION").to_string(),
484        }
485    }
486
487    fn metrics(&self) -> EngineMetrics {
488        if let Some(e) = &self.llm {
489            return e.metrics();
490        }
491        if let Some(e) = &self.embed {
492            return e.metrics();
493        }
494        if let Some(e) = &self.transcribe {
495            return e.metrics();
496        }
497        if let Some(e) = &self.tts {
498            return e.metrics();
499        }
500        EngineMetrics {
501            total_requests: 0,
502            successful_requests: 0,
503            failed_requests: 0,
504            avg_request_latency_ms: 0.0,
505            p95_request_latency_ms: 0.0,
506            p99_request_latency_ms: 0.0,
507            throughput_rps: 0.0,
508            tokens_per_second: 0.0,
509            queue_metrics: Default::default(),
510            resource_utilization: Default::default(),
511            error_stats: Default::default(),
512            performance_breakdown: Default::default(),
513        }
514    }
515}
516
517#[derive(Default)]
518struct CacheRuntimeState {
519    stats: Mutex<CacheStats>,
520    prefix_prompts: Mutex<HashMap<String, usize>>,
521    sessions: Mutex<HashMap<String, Vec<ChatMessage>>>,
522}
523
524#[derive(Debug, Clone, Default)]
525struct CacheStats {
526    prefix_hits: u64,
527    prefix_misses: u64,
528    prefix_evictions: u64,
529    prefix_saved_prefill_tokens: u64,
530    prefix_entries: u64,
531    prefix_bytes: u64,
532    session_hits: u64,
533    session_misses: u64,
534    session_evictions: u64,
535    session_entries: u64,
536    session_tokens: u64,
537}
538
539#[derive(Clone)]
540struct SessionContext {
541    id: String,
542    prior_messages: Vec<ChatMessage>,
543    incoming_messages: Vec<ChatMessage>,
544}
545
546impl CacheRuntimeState {
547    fn record_prefix_prompt(&self, prompt: &str, policy: &CachePolicy) {
548        if !policy.prefix_cache_enabled {
549            return;
550        }
551
552        let prompt_tokens = approx_tokens(prompt);
553        let mut prompts = self.prefix_prompts.lock().expect("prefix cache lock");
554        let saved_tokens = prompts
555            .keys()
556            .map(|seen| approx_tokens_for_chars(longest_common_prefix_chars(seen, prompt)))
557            .max()
558            .unwrap_or(0);
559
560        let mut stats = self.stats.lock().expect("cache stats lock");
561        if saved_tokens > 0 {
562            stats.prefix_hits += 1;
563            stats.prefix_saved_prefill_tokens += saved_tokens as u64;
564        } else {
565            stats.prefix_misses += 1;
566        }
567
568        let max_entries = policy.session_cache_max_entries.max(1);
569        if !prompts.contains_key(prompt) && prompts.len() >= max_entries {
570            if let Some(key) = prompts.keys().next().cloned() {
571                prompts.remove(&key);
572                stats.prefix_evictions += 1;
573            }
574        }
575        prompts.insert(prompt.to_string(), prompt_tokens);
576        stats.prefix_entries = prompts.len() as u64;
577        stats.prefix_bytes = prompts.keys().map(|key| key.len() as u64).sum();
578    }
579
580    fn prepare_session_request(
581        &self,
582        request: &mut ChatCompletionsRequest,
583        headers: &HeaderMap,
584        policy: &CachePolicy,
585    ) -> Option<SessionContext> {
586        let session_id = request_session_id(headers, request)?;
587        if !policy.session_memory_enabled() {
588            return None;
589        }
590
591        let incoming_messages = request.messages.clone();
592        let prior_messages = {
593            let sessions = self.sessions.lock().expect("session cache lock");
594            sessions.get(&session_id).cloned().unwrap_or_default()
595        };
596        {
597            let mut stats = self.stats.lock().expect("cache stats lock");
598            if prior_messages.is_empty() {
599                stats.session_misses += 1;
600            } else {
601                stats.session_hits += 1;
602                let mut merged = prior_messages.clone();
603                merged.extend(request.messages.clone());
604                request.messages = merged;
605            }
606        }
607
608        Some(SessionContext {
609            id: session_id,
610            prior_messages,
611            incoming_messages,
612        })
613    }
614
615    fn update_session(
616        &self,
617        context: Option<SessionContext>,
618        assistant_message: ChatMessage,
619        policy: &CachePolicy,
620    ) {
621        let Some(context) = context else {
622            return;
623        };
624        if !policy.session_memory_enabled() {
625            return;
626        }
627
628        let mut history = context.prior_messages;
629        history.extend(context.incoming_messages);
630        history.push(assistant_message);
631        trim_messages_to_token_budget(&mut history, policy.session_cache_max_tokens);
632
633        let mut sessions = self.sessions.lock().expect("session cache lock");
634        if !sessions.contains_key(&context.id)
635            && sessions.len() >= policy.session_cache_max_entries.max(1)
636        {
637            if let Some(evict_key) = sessions.keys().next().cloned() {
638                sessions.remove(&evict_key);
639                self.stats
640                    .lock()
641                    .expect("cache stats lock")
642                    .session_evictions += 1;
643            }
644        }
645        sessions.insert(context.id, history);
646
647        let entries = sessions.len() as u64;
648        let tokens = sessions
649            .values()
650            .map(|messages| {
651                messages
652                    .iter()
653                    .map(|msg| approx_tokens(&msg.content))
654                    .sum::<usize>()
655            })
656            .sum::<usize>() as u64;
657        let mut stats = self.stats.lock().expect("cache stats lock");
658        stats.session_entries = entries;
659        stats.session_tokens = tokens;
660    }
661
662    fn stats(&self) -> CacheStats {
663        let mut stats = self.stats.lock().expect("cache stats lock").clone();
664        stats.prefix_entries = self.prefix_prompts.lock().expect("prefix cache lock").len() as u64;
665        let sessions = self.sessions.lock().expect("session cache lock");
666        stats.session_entries = sessions.len() as u64;
667        stats.session_tokens = sessions
668            .values()
669            .map(|messages| {
670                messages
671                    .iter()
672                    .map(|msg| approx_tokens(&msg.content))
673                    .sum::<usize>()
674            })
675            .sum::<usize>() as u64;
676        stats
677    }
678
679    fn health_json(
680        &self,
681        policy: &CachePolicy,
682        engine_prefix_cache: Option<&serde_json::Value>,
683    ) -> serde_json::Value {
684        let stats = self.stats();
685        let prefix_hits = engine_u64(engine_prefix_cache, "hits").unwrap_or(stats.prefix_hits);
686        let prefix_misses =
687            engine_u64(engine_prefix_cache, "misses").unwrap_or(stats.prefix_misses);
688        let prefix_evictions =
689            engine_u64(engine_prefix_cache, "evictions").unwrap_or(stats.prefix_evictions);
690        let prefix_saved = engine_u64(engine_prefix_cache, "saved_prefill_tokens")
691            .unwrap_or(stats.prefix_saved_prefill_tokens);
692        let prefix_entries =
693            engine_u64(engine_prefix_cache, "entries").unwrap_or(stats.prefix_entries);
694        let prefix_bytes = engine_u64(engine_prefix_cache, "bytes").unwrap_or(stats.prefix_bytes);
695        let mut prefix_cache = serde_json::json!({
696            "enabled": engine_bool(engine_prefix_cache, "enabled").unwrap_or(policy.prefix_cache_enabled),
697            "position": engine_str(engine_prefix_cache, "position").unwrap_or("product-observability"),
698            "source": engine_str(engine_prefix_cache, "source").unwrap_or("server-prompt-lcp-observability"),
699            "entries": prefix_entries,
700            "hits": prefix_hits,
701            "misses": prefix_misses,
702            "evictions": prefix_evictions,
703            "saved_prefill_tokens": prefix_saved,
704            "bytes": prefix_bytes,
705            "block_size": engine_u64(engine_prefix_cache, "block_size"),
706            "kv_dtype": engine_str(engine_prefix_cache, "kv_dtype"),
707        });
708        if let (Some(engine), Some(prefix)) = (
709            engine_prefix_cache.and_then(|value| value.as_object()),
710            prefix_cache.as_object_mut(),
711        ) {
712            for (key, value) in engine {
713                prefix.entry(key.clone()).or_insert_with(|| value.clone());
714            }
715        }
716        serde_json::json!({
717            "prefix_cache": prefix_cache,
718            "session_cache": {
719                "mode": policy.session_cache_mode,
720                "entries": stats.session_entries,
721                "hits": stats.session_hits,
722                "misses": stats.session_misses,
723                "evictions": stats.session_evictions,
724                "tokens": stats.session_tokens,
725                "max_entries": policy.session_cache_max_entries,
726                "max_tokens": policy.session_cache_max_tokens,
727            }
728        })
729    }
730
731    fn prometheus_metrics(&self, engine_prefix_cache: Option<&serde_json::Value>) -> String {
732        let stats = self.stats();
733        let prefix_hits = engine_u64(engine_prefix_cache, "hits").unwrap_or(stats.prefix_hits);
734        let prefix_misses =
735            engine_u64(engine_prefix_cache, "misses").unwrap_or(stats.prefix_misses);
736        let prefix_evictions =
737            engine_u64(engine_prefix_cache, "evictions").unwrap_or(stats.prefix_evictions);
738        let prefix_saved = engine_u64(engine_prefix_cache, "saved_prefill_tokens")
739            .unwrap_or(stats.prefix_saved_prefill_tokens);
740        let prefix_entries =
741            engine_u64(engine_prefix_cache, "entries").unwrap_or(stats.prefix_entries);
742        let prefix_bytes = engine_u64(engine_prefix_cache, "bytes").unwrap_or(stats.prefix_bytes);
743        format!(
744            concat!(
745                "ferrum_prefix_cache_hits_total {}\n",
746                "ferrum_prefix_cache_misses_total {}\n",
747                "ferrum_prefix_cache_evictions_total {}\n",
748                "ferrum_prefix_cache_saved_prefill_tokens_total {}\n",
749                "ferrum_prefix_cache_entries {}\n",
750                "ferrum_prefix_cache_bytes {}\n",
751                "ferrum_session_cache_hits_total {}\n",
752                "ferrum_session_cache_misses_total {}\n",
753                "ferrum_session_cache_evictions_total {}\n",
754                "ferrum_session_cache_entries {}\n",
755                "ferrum_session_cache_tokens {}\n"
756            ),
757            prefix_hits,
758            prefix_misses,
759            prefix_evictions,
760            prefix_saved,
761            prefix_entries,
762            prefix_bytes,
763            stats.session_hits,
764            stats.session_misses,
765            stats.session_evictions,
766            stats.session_entries,
767            stats.session_tokens,
768        )
769    }
770}
771
772fn engine_u64(snapshot: Option<&serde_json::Value>, key: &str) -> Option<u64> {
773    snapshot?.get(key)?.as_u64()
774}
775
776fn engine_bool(snapshot: Option<&serde_json::Value>, key: &str) -> Option<bool> {
777    snapshot?.get(key)?.as_bool()
778}
779
780fn engine_str<'a>(snapshot: Option<&'a serde_json::Value>, key: &str) -> Option<&'a str> {
781    snapshot?.get(key)?.as_str()
782}
783
784fn auto_config_health_value(auto_config: Option<&ResolvedFerrumConfig>) -> serde_json::Value {
785    match auto_config {
786        Some(auto_config) => auto_config.effective_config_document(),
787        None => {
788            match FerrumConfigBuilder::new(RuntimeConfigSnapshot::capture_current()).resolve() {
789                Ok(auto_config) => auto_config.effective_config_document(),
790                Err(err) => serde_json::json!({
791                    "schema_version": 1,
792                    "error": err.to_string(),
793                }),
794            }
795        }
796    }
797}
798
799fn admission_health_json(
800    engine_status: &EngineStatus,
801    scheduler_metrics: &EngineMetrics,
802    auto_config: &serde_json::Value,
803    runtime_snapshot: Option<&ferrum_types::ExecutorAdmissionSnapshot>,
804    runtime_error: Option<&str>,
805) -> serde_json::Value {
806    let configured = auto_config
807        .get("admission")
808        .and_then(|value| value.as_object());
809    let preflight_effective_max_concurrent = configured
810        .and_then(|value| value.get("effective_max_concurrent"))
811        .and_then(|value| value.as_u64());
812    let effective_max_concurrent = if runtime_error.is_some() {
813        None
814    } else {
815        Some(
816            runtime_snapshot
817                .map(|snapshot| u64::from(snapshot.maximum_active_sequences()))
818                .or(preflight_effective_max_concurrent)
819                .unwrap_or_else(|| {
820                    (engine_status.active_requests + engine_status.queued_requests)
821                        .max(1)
822                        .try_into()
823                        .unwrap_or(u64::MAX)
824                }),
825        )
826    };
827    let active_sequences = runtime_error.is_none().then(|| {
828        runtime_snapshot
829            .map(|snapshot| u64::from(snapshot.active_sequences()))
830            .unwrap_or_else(|| engine_status.active_requests as u64)
831    });
832    let waiting_requests = runtime_error.is_none().then(|| {
833        runtime_snapshot
834            .map(|snapshot| u64::from(snapshot.waiting_requests()))
835            .unwrap_or_else(|| engine_status.queued_requests as u64)
836    });
837    serde_json::json!({
838        "schema_version": 2,
839        "source": if runtime_error.is_some() {
840            "runtime_error"
841        } else if runtime_snapshot.is_some() {
842            "runtime_executor"
843        } else {
844            "startup_preflight_and_engine_status"
845        },
846        "runtime_snapshot_available": runtime_snapshot.is_some(),
847        "runtime_contract_error": runtime_error,
848        "resource_authority": runtime_snapshot
849            .and_then(|snapshot| serde_json::to_value(snapshot.resource_authority()).ok())
850            .unwrap_or(serde_json::Value::Null),
851        "effective_max_concurrent": effective_max_concurrent,
852        "maximum_active_sequences": runtime_snapshot
853            .map(|snapshot| u64::from(snapshot.maximum_active_sequences())),
854        "maximum_scheduled_tokens": runtime_snapshot
855            .map(|snapshot| snapshot.maximum_scheduled_tokens()),
856        "preflight_effective_max_concurrent": preflight_effective_max_concurrent,
857        "queue_depth": waiting_requests,
858        "active_sequences": active_sequences,
859        "active_prefill": runtime_snapshot
860            .map(|snapshot| u64::from(snapshot.active_prefill_sequences())),
861        "active_decode": runtime_snapshot
862            .map(|snapshot| u64::from(snapshot.active_decode_sequences())),
863        "current_batch_size": runtime_snapshot
864            .and_then(|snapshot| snapshot.current_batch_size())
865            .map(u64::from),
866        "capacity_blocked_requests": runtime_snapshot
867            .and_then(|snapshot| snapshot.capacity_blocked_requests())
868            .map(u64::from),
869        "rejected_requests_total": 0u64,
870        "failed_requests_total": scheduler_metrics.failed_requests,
871        "completed_requests_total": scheduler_metrics.successful_requests,
872        "avg_queue_wait_time_ms": scheduler_metrics.queue_metrics.avg_queue_wait_time_ms,
873        "scheduler_policy": configured
874            .and_then(|value| value.get("scheduler_policy"))
875            .and_then(|value| value.as_str())
876            .unwrap_or("unknown"),
877        "phase_detail_source": if runtime_snapshot.is_some() {
878            "scheduler_request_index_single_read"
879        } else {
880            "unavailable"
881        },
882    })
883}
884
885fn admission_prometheus_metrics(admission: &serde_json::Value) -> String {
886    let snapshot_available = u8::from(
887        admission
888            .get("runtime_snapshot_available")
889            .and_then(serde_json::Value::as_bool)
890            .unwrap_or(false),
891    );
892    let mut output = format!("ferrum_admission_runtime_snapshot_available {snapshot_available}\n");
893    for (field, metric) in [
894        (
895            "effective_max_concurrent",
896            "ferrum_admission_effective_max_concurrent",
897        ),
898        (
899            "maximum_active_sequences",
900            "ferrum_admission_maximum_active_sequences",
901        ),
902        (
903            "maximum_scheduled_tokens",
904            "ferrum_admission_maximum_scheduled_tokens",
905        ),
906        ("queue_depth", "ferrum_admission_queue_depth"),
907        (
908            "capacity_blocked_requests",
909            "ferrum_admission_capacity_blocked_requests",
910        ),
911        ("active_sequences", "ferrum_admission_active_sequences"),
912        ("active_prefill", "ferrum_admission_active_prefill"),
913        ("active_decode", "ferrum_admission_active_decode"),
914        ("current_batch_size", "ferrum_admission_current_batch_size"),
915        (
916            "rejected_requests_total",
917            "ferrum_admission_rejected_requests_total",
918        ),
919        (
920            "failed_requests_total",
921            "ferrum_admission_failed_requests_total",
922        ),
923        (
924            "completed_requests_total",
925            "ferrum_admission_completed_requests_total",
926        ),
927    ] {
928        if let Some(value) = admission.get(field).and_then(serde_json::Value::as_u64) {
929            output.push_str(&format!("{metric} {value}\n"));
930        }
931    }
932    output
933}
934
935fn request_session_id(headers: &HeaderMap, request: &ChatCompletionsRequest) -> Option<String> {
936    headers
937        .get(FERRUM_SESSION_HEADER)
938        .and_then(|value| value.to_str().ok())
939        .map(str::trim)
940        .filter(|value| !value.is_empty())
941        .map(str::to_string)
942        .or_else(|| {
943            request
944                .metadata
945                .as_ref()
946                .and_then(|metadata| metadata.get("ferrum_session_id"))
947                .and_then(|value| value.as_str())
948                .map(str::trim)
949                .filter(|value| !value.is_empty())
950                .map(str::to_string)
951        })
952}
953
954fn approx_tokens(text: &str) -> usize {
955    approx_tokens_for_chars(text.chars().count())
956}
957
958fn approx_tokens_for_chars(chars: usize) -> usize {
959    (chars / 4).max(1)
960}
961
962fn longest_common_prefix_chars(a: &str, b: &str) -> usize {
963    a.chars().zip(b.chars()).take_while(|(a, b)| a == b).count()
964}
965
966fn trim_messages_to_token_budget(messages: &mut Vec<ChatMessage>, max_tokens: usize) {
967    let max_tokens = max_tokens.max(1);
968    while messages.len() > 1
969        && messages
970            .iter()
971            .map(|msg| approx_tokens(&msg.content))
972            .sum::<usize>()
973            > max_tokens
974    {
975        messages.remove(0);
976    }
977}
978
979#[async_trait]
980impl HttpServer for AxumServer {
981    async fn start(&self, config: &ServerConfig) -> ferrum_types::Result<()> {
982        if self.lifecycle.shutdown_requested.load(Ordering::Acquire) {
983            return Err(Error::internal(
984                "cannot start Axum server after shutdown was requested",
985            ));
986        }
987        self.lifecycle
988            .running
989            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
990            .map_err(|_| Error::internal("Axum server is already running"))?;
991        let _run_guard = AxumServerRunGuard {
992            lifecycle: Arc::clone(&self.lifecycle),
993        };
994        let addr = format!("{}:{}", config.host, config.port);
995        info!("Starting Axum server on {}", addr);
996
997        let app = self.build_router_with_state(
998            self.state
999                .clone()
1000                .with_request_dump_dir(config.request_dump_dir.clone())
1001                .with_profile_jsonl(config.profile_jsonl.clone())
1002                .with_profile_detail(config.profile_detail)
1003                .with_memory_profile_jsonl(config.memory_profile_jsonl.clone()),
1004        );
1005        let listener = tokio::net::TcpListener::bind(&addr)
1006            .await
1007            .map_err(|e| Error::internal(format!("Failed to bind to {}: {}", addr, e)))?;
1008
1009        info!("Server listening on {}", addr);
1010
1011        let lifecycle = Arc::clone(&self.lifecycle);
1012        axum::serve(listener, app)
1013            .with_graceful_shutdown(async move { lifecycle.wait_for_shutdown().await })
1014            .await
1015            .map_err(|e| Error::internal(format!("Server error: {}", e)))?;
1016
1017        Ok(())
1018    }
1019
1020    async fn stop(&self, timeout: std::time::Duration) -> ferrum_types::Result<()> {
1021        let _stop_guard = self.lifecycle.stop_lock.lock().await;
1022        info!("Stopping Axum server");
1023        self.lifecycle.request_shutdown();
1024
1025        let mut first_error = None;
1026        if self.lifecycle.running.load(Ordering::Acquire) {
1027            if tokio::time::timeout(timeout, self.lifecycle.wait_until_stopped())
1028                .await
1029                .is_err()
1030            {
1031                first_error = Some(Error::internal(format!(
1032                    "Axum server did not drain within {} ms",
1033                    timeout.as_millis()
1034                )));
1035            }
1036        }
1037
1038        if !self.lifecycle.engines_stopped.load(Ordering::Acquire) {
1039            match tokio::time::timeout(timeout, self.shutdown_loaded_engines()).await {
1040                Ok(Ok(())) => {
1041                    self.lifecycle
1042                        .engines_stopped
1043                        .store(true, Ordering::Release);
1044                }
1045                Ok(Err(error)) => {
1046                    if first_error.is_none() {
1047                        first_error = Some(error);
1048                    }
1049                }
1050                Err(_) => {
1051                    if first_error.is_none() {
1052                        first_error = Some(Error::internal(format!(
1053                            "engine shutdown did not complete within {} ms",
1054                            timeout.as_millis()
1055                        )));
1056                    }
1057                }
1058            }
1059        }
1060
1061        first_error.map_or(Ok(()), Err)
1062    }
1063
1064    fn is_running(&self) -> bool {
1065        self.lifecycle.running.load(Ordering::Acquire)
1066    }
1067
1068    fn address(&self) -> Option<std::net::SocketAddr> {
1069        // For MVP, return configured address
1070        format!("{}:{}", self.config.host, self.config.port)
1071            .parse()
1072            .ok()
1073    }
1074
1075    fn register_handler(
1076        &mut self,
1077        _path: &str,
1078        _method: HttpMethod,
1079        _handler: Box<dyn crate::traits::RequestHandler>,
1080    ) {
1081        // For MVP, routes are static
1082        unimplemented!("Dynamic handler registration not implemented in MVP")
1083    }
1084
1085    fn register_middleware(&mut self, _middleware: Box<dyn crate::traits::Middleware>) {
1086        // For MVP, middleware is static
1087        unimplemented!("Dynamic middleware registration not implemented in MVP")
1088    }
1089
1090    fn get_metrics(&self) -> ServerMetrics {
1091        // Return empty metrics for MVP
1092        ServerMetrics {
1093            total_requests: 0,
1094            requests_by_endpoint: std::collections::HashMap::new(),
1095            requests_by_status: std::collections::HashMap::new(),
1096            avg_response_time_ms: 0.0,
1097            p95_response_time_ms: 0.0,
1098            p99_response_time_ms: 0.0,
1099            active_connections: 0,
1100            bytes_sent: 0,
1101            bytes_received: 0,
1102            error_rate: 0.0,
1103            uptime_seconds: 0,
1104        }
1105    }
1106
1107    async fn health_check(&self) -> HealthStatus {
1108        HealthStatus::Healthy
1109    }
1110}
1111
1112/// Main chat completions handler
1113async fn chat_completions_handler(
1114    State(state): State<AppState>,
1115    headers: HeaderMap,
1116    request: std::result::Result<Json<ChatCompletionsRequest>, JsonRejection>,
1117) -> std::result::Result<Response, ServerError> {
1118    let Json(mut request) = request.map_err(|error| {
1119        ServerError::invalid_request(
1120            format!(
1121                "invalid chat completions request: {}",
1122                json_rejection_detail(&error)
1123            ),
1124            None,
1125        )
1126    })?;
1127    let cache_policy = CachePolicy::current();
1128    let session_context =
1129        state
1130            .cache
1131            .prepare_session_request(&mut request, &headers, &cache_policy);
1132
1133    let span = span!(Level::INFO, "chat_completions", model = %request.model);
1134    let _enter = span.enter();
1135
1136    info!(
1137        "Received chat completions request for model: {}",
1138        request.model
1139    );
1140    debug!("Request: {:?}", request);
1141
1142    // OpenAI spec requires at least one message. Reject empty arrays at
1143    // the boundary rather than synthesising a fake prompt downstream.
1144    validate_chat_request(&request)?;
1145    let (engine_model_id, lora_adapter) = resolve_request_model(
1146        &state.served_model_registry,
1147        &request.model,
1148        ServedModelKind::Llm,
1149    )?;
1150
1151    // Convert OpenAI request to internal format
1152    let mut inference_request = convert_chat_request_with_template_model_and_default(
1153        &request,
1154        &engine_model_id.0,
1155        state.prompt_template.as_deref(),
1156        state.default_enable_thinking,
1157    )
1158    .map_err(server_error_from_ferrum_error)?;
1159    apply_served_model_resolution(&mut inference_request, engine_model_id, lora_adapter);
1160    if state.request_dump_dir.is_some() {
1161        inference_request.evidence_request.capture_prompt_token_ids = true;
1162    }
1163    inference_request
1164        .evidence_request
1165        .capture_engine_token_timing = state.profile_detail.captures_engine_token_timing();
1166    state
1167        .cache
1168        .record_prefix_prompt(&inference_request.prompt, &cache_policy);
1169    if let Err(err) =
1170        write_chat_request_replay_bundle(&state, &headers, &request, &inference_request)
1171    {
1172        warn!("failed to write chat request replay bundle: {}", err);
1173    }
1174
1175    // Check if streaming is requested
1176    if request.stream.unwrap_or(false) {
1177        handle_chat_completions_stream(state, request, inference_request).await
1178    } else {
1179        handle_chat_completions_sync(state, request, inference_request, session_context).await
1180    }
1181}
1182
1183fn json_rejection_detail(rejection: &JsonRejection) -> String {
1184    const MAX_DETAIL_CHARS: usize = 512;
1185
1186    let mut details = Vec::new();
1187    let mut current: Option<&(dyn StdError + 'static)> = Some(rejection);
1188    while let Some(error) = current {
1189        let detail = error.to_string();
1190        if !detail.is_empty() && details.last() != Some(&detail) {
1191            details.push(detail);
1192        }
1193        current = error.source();
1194    }
1195
1196    details.join(": ").chars().take(MAX_DETAIL_CHARS).collect()
1197}
1198
1199fn write_chat_request_replay_bundle(
1200    state: &AppState,
1201    headers: &HeaderMap,
1202    openai_request: &ChatCompletionsRequest,
1203    inference_request: &InferenceRequest,
1204) -> std::result::Result<(), String> {
1205    let Some(root) = state.request_dump_dir.as_ref() else {
1206        return Ok(());
1207    };
1208    let request_id = inference_request.id.to_string();
1209    let bundle_dir = root.join(&request_id);
1210    fs::create_dir_all(&bundle_dir).map_err(|err| err.to_string())?;
1211
1212    let sanitized_body = sanitized_chat_request_body(openai_request);
1213    let replay_body_path = bundle_dir.join("replay_body.json");
1214    write_json_value(&replay_body_path, &sanitized_body)?;
1215    let engine_replay_argv = replay_bundle_argv(&bundle_dir);
1216    let output_text_body = format!(
1217        "[server request replay emitted before response]\nsha256={}\nchars=0\n",
1218        sha256_hex(b"")
1219    );
1220
1221    let request = serde_json::json!({
1222        "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1223        "entrypoint": "serve",
1224        "request_id": request_id,
1225        "model": openai_request.model.clone(),
1226        "backend": "actual",
1227        "endpoint": "/v1/chat/completions",
1228        "method": "POST",
1229        "stream": openai_request.stream.unwrap_or(false),
1230        "actual_model_smoke": true,
1231        "sanitized": true,
1232        "http": {
1233            "method": "POST",
1234            "path": "/v1/chat/completions",
1235            "headers": sanitized_replay_headers(headers),
1236            "body": sanitized_body
1237        }
1238    });
1239    let files = [
1240        ("request.json", request),
1241        (
1242            "prompt_token_ids.json",
1243            serde_json::json!({
1244                "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1245                "request_id": request_id,
1246                "model": openai_request.model.clone(),
1247                "tokenizer_or_model": openai_request.model.clone(),
1248                "token_ids": null,
1249                "token_count": null,
1250                "unavailable_reason": "server request replay captures the OpenAI body before prompt token ids are retained",
1251                "sanitized": true
1252            }),
1253        ),
1254        (
1255            "sampling_params.json",
1256            serde_json::json!({
1257                "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1258                "request_id": request_id,
1259                "sampling_params": inference_request.sampling_params.clone(),
1260                "unavailable_reason": null
1261            }),
1262        ),
1263        (
1264            "runtime_effective_config.json",
1265            serde_json::json!({
1266                "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1267                "request_id": request_id,
1268                "entrypoint": "serve",
1269                "endpoint": "/v1/chat/completions",
1270                "stream": openai_request.stream.unwrap_or(false),
1271                "request_dump_dir": root.to_string_lossy(),
1272                "sanitized": true
1273            }),
1274        ),
1275        (
1276            "backend_selection.json",
1277            serde_json::json!({
1278                "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1279                "request_id": request_id,
1280                "backend": "actual",
1281                "model": openai_request.model.clone(),
1282                "actual_model_smoke": true
1283            }),
1284        ),
1285        (
1286            "output_token_ids.json",
1287            serde_json::json!({
1288                "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1289                "request_id": request_id,
1290                "token_ids": [],
1291                "token_count": 0,
1292                "finish_reason": null,
1293                "unavailable_reason": "server request replay bundle is emitted at request admission in this WP9 slice"
1294            }),
1295        ),
1296        (
1297            "bad_output_scan.json",
1298            serde_json::json!({
1299                "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1300                "request_id": request_id,
1301                "bad_output": false,
1302                "bad_text_count": 0,
1303                "reasons": [],
1304                "first_bad_text_span": null,
1305                "failure_kind": null,
1306                "output_chars": 0,
1307                "classified_output_sha256": sha256_hex(b""),
1308                "output_sha256": sha256_hex(output_text_body.as_bytes())
1309            }),
1310        ),
1311        (
1312            "replay.command.json",
1313            serde_json::json!({
1314                "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1315                "request_id": request_id,
1316                "entrypoint": "serve",
1317                "command": replay_curl_command(&bundle_dir),
1318                "argv": replay_curl_argv(&bundle_dir),
1319                "bundle_dir": bundle_dir.to_string_lossy(),
1320                "requires_running_server": true,
1321                "engine_replay": {
1322                    "mode": "bundle_offline",
1323                    "requires_http_server": false,
1324                    "command": shell_command(&engine_replay_argv),
1325                    "argv": engine_replay_argv
1326                },
1327                "sanitized": true
1328            }),
1329        ),
1330    ];
1331    for (name, value) in files {
1332        write_json_value(&bundle_dir.join(name), &value)?;
1333    }
1334    fs::write(bundle_dir.join("output_text.txt"), output_text_body)
1335        .map_err(|err| err.to_string())?;
1336    Ok(())
1337}
1338
1339fn write_chat_request_failure_diagnostics(
1340    state: &AppState,
1341    request_id: &str,
1342    failure_kind: &str,
1343    phase: &str,
1344    error_kind: &str,
1345    message: &str,
1346    engine_status: Option<&EngineStatus>,
1347) -> std::result::Result<(), String> {
1348    let admission_summary = state
1349        .auto_config
1350        .as_ref()
1351        .map(|config| config.admission_summary_document());
1352    write_chat_request_failure_diagnostics_at_root(
1353        state.request_dump_dir.as_ref().map(|root| root.as_path()),
1354        admission_summary.as_ref(),
1355        engine_status,
1356        request_id,
1357        failure_kind,
1358        phase,
1359        error_kind,
1360        message,
1361    )
1362}
1363
1364fn write_chat_request_completion_replay_bundle(
1365    request_dump_dir: Option<&Path>,
1366    request_id: &str,
1367    output_text: &str,
1368    output_token_ids: &[TokenId],
1369    finish_reason: Option<&str>,
1370) -> std::result::Result<(), String> {
1371    let Some(root) = request_dump_dir else {
1372        return Ok(());
1373    };
1374    let bundle_dir = root.join(request_id);
1375    fs::create_dir_all(&bundle_dir).map_err(|err| err.to_string())?;
1376    let token_ids = output_token_ids
1377        .iter()
1378        .map(|token| token.get())
1379        .collect::<Vec<_>>();
1380    let output_text_body = format!(
1381        "[redacted actual output]\nsha256={}\nchars={}\n",
1382        sha256_hex(output_text.as_bytes()),
1383        output_text.chars().count()
1384    );
1385    write_json_value(
1386        &bundle_dir.join("output_token_ids.json"),
1387        &serde_json::json!({
1388            "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1389            "request_id": request_id,
1390            "token_ids": token_ids,
1391            "token_count": output_token_ids.len(),
1392            "finish_reason": finish_reason,
1393            "unavailable_reason": null
1394        }),
1395    )?;
1396    write_json_value(
1397        &bundle_dir.join("bad_output_scan.json"),
1398        &bad_output_scan_json(request_id, output_text, None, output_text_body.as_bytes()),
1399    )?;
1400    fs::write(bundle_dir.join("output_text.txt"), output_text_body)
1401        .map_err(|err| err.to_string())?;
1402    Ok(())
1403}
1404
1405fn write_chat_prompt_token_evidence(
1406    request_dump_dir: Option<&Path>,
1407    request_id: &str,
1408    model: &str,
1409    execution_evidence: Option<&InferenceExecutionEvidence>,
1410) -> std::result::Result<(), String> {
1411    let (Some(root), Some(evidence)) = (request_dump_dir, execution_evidence) else {
1412        return Ok(());
1413    };
1414    let bundle_dir = root.join(request_id);
1415    fs::create_dir_all(&bundle_dir).map_err(|err| err.to_string())?;
1416    let prompt_token_ids = evidence
1417        .prompt_token_ids
1418        .iter()
1419        .map(|token| token.get())
1420        .collect::<Vec<_>>();
1421    write_json_value(
1422        &bundle_dir.join("prompt_token_ids.json"),
1423        &serde_json::json!({
1424            "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1425            "request_id": request_id,
1426            "model": model,
1427            "tokenizer_or_model": model,
1428            "token_ids": prompt_token_ids,
1429            "token_count": evidence.prompt_token_ids.len(),
1430            "unavailable_reason": null,
1431            "sanitized": true
1432        }),
1433    )
1434}
1435
1436#[derive(Clone, Copy, Default)]
1437struct ChatRequestProfileTiming<'a> {
1438    engine_evidence: Option<&'a InferenceExecutionEvidence>,
1439    first_engine_chunk_received_us: Option<u64>,
1440    first_sse_enqueue_us: Option<u64>,
1441}
1442
1443#[allow(clippy::too_many_arguments)]
1444fn write_chat_request_profile_event(
1445    state: &AppState,
1446    request_id: &str,
1447    model: &str,
1448    stream: bool,
1449    phase: &str,
1450    started_at: Instant,
1451    timing: ChatRequestProfileTiming<'_>,
1452    output_token_count: usize,
1453    usage: Option<&TokenUsage>,
1454    finish_reason: Option<&str>,
1455    error: Option<ProfileError>,
1456) -> std::result::Result<(), String> {
1457    let Some(path) = state.profile_jsonl.as_ref() else {
1458        return Ok(());
1459    };
1460    let timestamp = chrono::Utc::now();
1461    let status = if error.is_some() {
1462        ProfileStatus::Failure
1463    } else {
1464        ProfileStatus::Ok
1465    };
1466    let duration_us = elapsed_us_since(started_at);
1467    let mut attributes = BTreeMap::from([
1468        ("actual_model_smoke".to_string(), serde_json::json!(true)),
1469        (
1470            "diagnostic_only".to_string(),
1471            serde_json::json!(state.profile_detail.diagnostic_only()),
1472        ),
1473        (
1474            "endpoint".to_string(),
1475            serde_json::json!("/v1/chat/completions"),
1476        ),
1477        (
1478            "e2e_duration_us".to_string(),
1479            serde_json::json!(duration_us),
1480        ),
1481        ("l0_only".to_string(), serde_json::json!(false)),
1482        (
1483            "profile_detail".to_string(),
1484            serde_json::json!(state.profile_detail.as_str()),
1485        ),
1486        ("stream".to_string(), serde_json::json!(stream)),
1487        (
1488            "output_token_count".to_string(),
1489            serde_json::json!(output_token_count),
1490        ),
1491        (
1492            "execution_request_id".to_string(),
1493            serde_json::json!(format!("request.product.{request_id}")),
1494        ),
1495    ]);
1496    if let Some(usage) = usage {
1497        attributes.insert(
1498            "prompt_token_count".to_string(),
1499            serde_json::json!(usage.prompt_tokens),
1500        );
1501        attributes.insert(
1502            "completion_token_count".to_string(),
1503            serde_json::json!(usage.completion_tokens),
1504        );
1505        attributes.insert(
1506            "total_token_count".to_string(),
1507            serde_json::json!(usage.total_tokens),
1508        );
1509        attributes.insert("token_count_source".to_string(), serde_json::json!("usage"));
1510    } else {
1511        attributes.insert(
1512            "completion_token_count".to_string(),
1513            serde_json::json!(output_token_count),
1514        );
1515        attributes.insert(
1516            "total_token_count".to_string(),
1517            serde_json::json!(output_token_count),
1518        );
1519        attributes.insert(
1520            "token_count_source".to_string(),
1521            serde_json::json!("generated_tokens"),
1522        );
1523    }
1524    if let Some(engine_timing) = timing
1525        .engine_evidence
1526        .and_then(|evidence| evidence.engine_token_timing.as_ref())
1527    {
1528        engine_timing
1529            .validate(output_token_count)
1530            .map_err(|error| format!("invalid engine token timing evidence: {error}"))?;
1531        attributes.extend(ferrum_types::engine_token_timing_profile_attributes(
1532            engine_timing,
1533        ));
1534    } else if status == ProfileStatus::Ok && state.profile_detail.captures_engine_token_timing() {
1535        return Err(format!(
1536            "{} profile completed without required engine token timing evidence",
1537            state.profile_detail.as_str()
1538        ));
1539    }
1540    if let Some(received_us) = timing.first_engine_chunk_received_us {
1541        attributes.insert(
1542            "engine_stream_first_chunk_received_us".to_string(),
1543            serde_json::json!(received_us),
1544        );
1545    }
1546    if let Some(enqueue_us) = timing.first_sse_enqueue_us {
1547        attributes.insert(
1548            "http_first_sse_enqueue_us".to_string(),
1549            serde_json::json!(enqueue_us),
1550        );
1551    }
1552    if stream {
1553        attributes.insert(
1554            "http_stream_flush_unavailable_reason".to_string(),
1555            serde_json::json!(
1556                "socket flush completion is outside the axum handler observation boundary"
1557            ),
1558        );
1559    }
1560    if let Some(reason) = finish_reason {
1561        attributes.insert("finish_reason".to_string(), serde_json::json!(reason));
1562    }
1563    if let Some(error) = error.as_ref() {
1564        attributes.insert(
1565            if error.blocking {
1566                "first_failure_event"
1567            } else {
1568                "terminal_failure_event"
1569            }
1570            .to_string(),
1571            serde_json::json!(true),
1572        );
1573    }
1574
1575    let replay = state.request_dump_dir.as_ref().map(|root| {
1576        let bundle_dir = root.join(request_id);
1577        ReplayReference {
1578            command: replay_curl_command(&bundle_dir),
1579            bundle_dir: Some(root.to_string_lossy().to_string()),
1580        }
1581    });
1582    let resource = error.as_ref().map(|error| ResourceTraceEvent {
1583        owner_kind: "request".to_string(),
1584        owner_id: request_id.to_string(),
1585        resource_kind: "chat_request".to_string(),
1586        action: ResourceAction::Reject,
1587        amount: None,
1588        before: None,
1589        after: None,
1590        capacity: Some(1),
1591        underflow_amount: None,
1592        reason: Some(error.message.clone()),
1593        error_kind: Some(error.kind.clone()),
1594        message: Some(error.message.clone()),
1595        resource_error_kind: Some(error.kind.clone()),
1596    });
1597    let event = FerrumProfileEvent {
1598        schema_version: OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1599        ts_unix_nanos: timestamp
1600            .timestamp_nanos_opt()
1601            .unwrap_or_else(|| timestamp.timestamp_micros() * 1_000),
1602        event_id: format!(
1603            "evt-server-chat-{}-{request_id}",
1604            if stream { "stream" } else { "sync" }
1605        ),
1606        request_id: request_id.to_string(),
1607        correlation_id: Some(request_id.to_string()),
1608        entrypoint: ProfileEntrypoint::Serve,
1609        backend: "actual".to_string(),
1610        runtime_preset_hash: state
1611            .auto_config
1612            .as_ref()
1613            .map(ResolvedFerrumConfig::runtime_env_hash)
1614            .unwrap_or_else(|| format!("sha256:{}", sha256_hex(b"serve-profile"))),
1615        phase: phase.to_string(),
1616        event_kind: ProfileEventKind::TimedSpan,
1617        timestamp,
1618        status,
1619        model: Some(model.to_string()),
1620        duration_us: Some(duration_us),
1621        memory: None,
1622        resource,
1623        error,
1624        replay,
1625        shape: BTreeMap::from([("batch_size".to_string(), serde_json::json!(1))]),
1626        backend_detail: None,
1627        attributes,
1628    };
1629    append_profile_event(path.as_path(), &event)
1630}
1631
1632fn maybe_write_first_request_memory_stage(
1633    state: &AppState,
1634    request_id: &str,
1635    model: &str,
1636    stream: bool,
1637    started_at: Instant,
1638    before: Option<ProcessMemorySample>,
1639) -> std::result::Result<(), String> {
1640    if state.profile_jsonl.is_none() && state.memory_profile_jsonl.is_none() {
1641        return Ok(());
1642    }
1643    if state
1644        .first_request_memory_recorded
1645        .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
1646        .is_err()
1647    {
1648        return Ok(());
1649    }
1650    let after = ProcessMemorySampler.sample();
1651    let memory = after.map(|after| ProcessMemoryObservation::from_samples(before, after));
1652    let timestamp = chrono::Utc::now();
1653    let mut attributes = BTreeMap::from([
1654        ("actual_model_smoke".to_string(), serde_json::json!(true)),
1655        (
1656            "diagnostic_only".to_string(),
1657            serde_json::json!(state.profile_detail.diagnostic_only()),
1658        ),
1659        (
1660            "endpoint".to_string(),
1661            serde_json::json!("/v1/chat/completions"),
1662        ),
1663        ("l0_only".to_string(), serde_json::json!(false)),
1664        (
1665            "memory_stage".to_string(),
1666            serde_json::json!("first_request_done"),
1667        ),
1668        (
1669            "profile_detail".to_string(),
1670            serde_json::json!(state.profile_detail.as_str()),
1671        ),
1672        ("stream".to_string(), serde_json::json!(stream)),
1673    ]);
1674    let memory_snapshot = if let Some(memory) = &memory {
1675        attributes.insert(
1676            "memory_measurement".to_string(),
1677            serde_json::json!("process_rss"),
1678        );
1679        attributes.insert(
1680            "process_memory_source".to_string(),
1681            serde_json::json!(memory.source),
1682        );
1683        memory.to_snapshot("process", Some("actual"))
1684    } else {
1685        attributes.insert(
1686            "memory_measurement".to_string(),
1687            serde_json::json!("not_collected"),
1688        );
1689        ferrum_types::MemorySnapshot {
1690            scope: "process".to_string(),
1691            backend: Some("actual".to_string()),
1692            before_bytes: Some(0),
1693            after_bytes: Some(0),
1694            current_bytes: Some(0),
1695            high_water_bytes: Some(0),
1696            available_bytes: None,
1697        }
1698    };
1699    let replay = state.request_dump_dir.as_ref().map(|root| {
1700        let bundle_dir = root.join(request_id);
1701        ReplayReference {
1702            command: replay_curl_command(&bundle_dir),
1703            bundle_dir: Some(root.to_string_lossy().to_string()),
1704        }
1705    });
1706    let event = FerrumProfileEvent {
1707        schema_version: OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1708        ts_unix_nanos: timestamp
1709            .timestamp_nanos_opt()
1710            .unwrap_or_else(|| timestamp.timestamp_micros() * 1_000),
1711        event_id: format!("evt-server-chat-memory-first-request-{request_id}"),
1712        request_id: request_id.to_string(),
1713        correlation_id: Some(request_id.to_string()),
1714        entrypoint: ProfileEntrypoint::Serve,
1715        backend: "actual".to_string(),
1716        runtime_preset_hash: state
1717            .auto_config
1718            .as_ref()
1719            .map(ResolvedFerrumConfig::runtime_env_hash)
1720            .unwrap_or_else(|| format!("sha256:{}", sha256_hex(b"serve-profile"))),
1721        phase: "actual_serve_first_request_done".to_string(),
1722        event_kind: ProfileEventKind::Memory,
1723        timestamp,
1724        status: ProfileStatus::Ok,
1725        model: Some(model.to_string()),
1726        duration_us: Some(elapsed_us_since(started_at)),
1727        memory: Some(memory_snapshot),
1728        resource: None,
1729        error: None,
1730        replay,
1731        shape: BTreeMap::from([("batch_size".to_string(), serde_json::json!(1))]),
1732        backend_detail: None,
1733        attributes,
1734    };
1735    if let Some(path) = &state.profile_jsonl {
1736        append_profile_event(path.as_path(), &event)?;
1737    }
1738    if let Some(path) = &state.memory_profile_jsonl {
1739        append_profile_event(path.as_path(), &event)?;
1740    }
1741    Ok(())
1742}
1743
1744fn request_memory_sample_before(state: &AppState) -> Option<ProcessMemorySample> {
1745    (state.profile_jsonl.is_some() || state.memory_profile_jsonl.is_some())
1746        .then(|| ProcessMemorySampler.sample())
1747        .flatten()
1748}
1749
1750fn append_profile_event(
1751    path: &Path,
1752    event: &FerrumProfileEvent,
1753) -> std::result::Result<(), String> {
1754    event.validate().map_err(|err| err.to_string())?;
1755    ferrum_bench_core::write_jsonl_records(
1756        path,
1757        ferrum_bench_core::JsonlJournalOpenMode::Append,
1758        std::slice::from_ref(event),
1759    )
1760    .map_err(|error| error.to_string())
1761}
1762
1763fn elapsed_us_since(started_at: Instant) -> u64 {
1764    started_at
1765        .elapsed()
1766        .as_micros()
1767        .max(1)
1768        .try_into()
1769        .unwrap_or(u64::MAX)
1770}
1771
1772fn write_chat_request_failure_diagnostics_at_root(
1773    request_dump_dir: Option<&Path>,
1774    admission_summary: Option<&serde_json::Value>,
1775    engine_status: Option<&EngineStatus>,
1776    request_id: &str,
1777    failure_kind: &str,
1778    phase: &str,
1779    error_kind: &str,
1780    message: &str,
1781) -> std::result::Result<(), String> {
1782    let Some(root) = request_dump_dir else {
1783        return Ok(());
1784    };
1785    let bundle_dir = root.join(request_id);
1786    fs::create_dir_all(&bundle_dir).map_err(|err| err.to_string())?;
1787    let message = sanitize_diagnostic_text(message);
1788    let now = chrono::Utc::now();
1789
1790    let bad_scan_path = bundle_dir.join("bad_output_scan.json");
1791    let mut bad_scan = fs::read_to_string(&bad_scan_path)
1792        .ok()
1793        .and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
1794        .filter(|value| value.is_object())
1795        .unwrap_or_else(|| serde_json::json!({}));
1796    let bad_scan_obj = bad_scan
1797        .as_object_mut()
1798        .expect("bad scan fallback should be an object");
1799    bad_scan_obj.insert(
1800        "schema_version".to_string(),
1801        serde_json::json!(OBSERVABILITY_PROFILE_SCHEMA_VERSION),
1802    );
1803    bad_scan_obj.insert("request_id".to_string(), serde_json::json!(request_id));
1804    bad_scan_obj
1805        .entry("bad_output".to_string())
1806        .or_insert_with(|| serde_json::json!(false));
1807    bad_scan_obj
1808        .entry("bad_text_count".to_string())
1809        .or_insert_with(|| serde_json::json!(0));
1810    bad_scan_obj
1811        .entry("reasons".to_string())
1812        .or_insert_with(|| serde_json::json!([]));
1813    bad_scan_obj
1814        .entry("first_bad_text_span".to_string())
1815        .or_insert(serde_json::Value::Null);
1816    bad_scan_obj.insert("failure_kind".to_string(), serde_json::json!(failure_kind));
1817    bad_scan_obj.insert("failure_phase".to_string(), serde_json::json!(phase));
1818    bad_scan_obj.insert("error_kind".to_string(), serde_json::json!(error_kind));
1819    bad_scan_obj
1820        .entry("output_chars".to_string())
1821        .or_insert_with(|| serde_json::json!(0));
1822    bad_scan_obj
1823        .entry("output_sha256".to_string())
1824        .or_insert_with(|| serde_json::json!(sha256_hex(b"")));
1825    write_json_value(&bad_scan_path, &bad_scan)?;
1826
1827    let diagnostics = if chat_resource_failure_kind(failure_kind) {
1828        chat_resource_failure_diagnostics(
1829            request_id,
1830            failure_kind,
1831            phase,
1832            error_kind,
1833            &message,
1834            now.timestamp_millis(),
1835            admission_summary,
1836            engine_status,
1837        )
1838    } else {
1839        serde_json::json!({
1840            "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1841            "entrypoint": "serve",
1842            "request_id": request_id,
1843            "failure_kind": failure_kind,
1844            "phase": phase,
1845            "first_failure_event": {
1846                "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1847                "entrypoint": "serve",
1848                "request_id": request_id,
1849                "phase": phase,
1850                "error_kind": error_kind,
1851                "message": message,
1852                "timestamp_unix_ms": now.timestamp_millis()
1853            },
1854            "nearest_request_id": request_id,
1855            "log_excerpt": format!("{phase}: {message}"),
1856            "backtrace_excerpt": null,
1857            "nearest_resource_event": null,
1858            "nearest_memory_snapshot": null
1859        })
1860    };
1861    write_json_value(&bundle_dir.join("failure_diagnostics.json"), &diagnostics)?;
1862    Ok(())
1863}
1864
1865fn chat_resource_failure_diagnostics(
1866    request_id: &str,
1867    failure_kind: &str,
1868    phase: &str,
1869    error_kind: &str,
1870    message: &str,
1871    timestamp_unix_ms: i64,
1872    admission_summary: Option<&serde_json::Value>,
1873    engine_status: Option<&EngineStatus>,
1874) -> serde_json::Value {
1875    let resource_kind = chat_resource_kind_for_failure(failure_kind);
1876    let memory = engine_status
1877        .map(|status| &status.memory_usage)
1878        .map(|memory| {
1879            let current = memory.used_bytes as i64;
1880            let high_water = current.max(0);
1881            serde_json::json!({
1882                "scope": "serve_failure",
1883                "backend": "engine_status",
1884                "current_bytes": current.max(0),
1885                "high_water_bytes": high_water,
1886                "total_bytes": memory.total_bytes,
1887                "free_bytes": memory.free_bytes,
1888                "gpu_memory_bytes": memory.gpu_memory_bytes,
1889                "cpu_memory_bytes": memory.cpu_memory_bytes,
1890                "source": "engine_status"
1891            })
1892        })
1893        .unwrap_or_else(|| {
1894            serde_json::json!({
1895                "scope": "serve_failure",
1896                "backend": "engine_status",
1897                "current_bytes": 0,
1898                "high_water_bytes": 0,
1899                "source": "not_collected"
1900            })
1901        });
1902    let capacity = chat_failure_capacity(resource_kind, admission_summary, engine_status, message);
1903    let needed = capacity
1904        .get("needed")
1905        .and_then(|value| value.as_i64())
1906        .unwrap_or(1)
1907        .max(1);
1908    let capacity_value = capacity
1909        .get("capacity")
1910        .and_then(|value| value.as_i64())
1911        .unwrap_or(0)
1912        .max(0);
1913    serde_json::json!({
1914        "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1915        "entrypoint": "serve",
1916        "request_id": request_id,
1917        "failure_kind": failure_kind,
1918        "phase": phase,
1919        "first_failure_event": {
1920            "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1921            "entrypoint": "serve",
1922            "request_id": request_id,
1923            "phase": phase,
1924            "error_kind": error_kind,
1925            "message": message,
1926            "timestamp_unix_ms": timestamp_unix_ms
1927        },
1928        "nearest_request_id": request_id,
1929        "log_excerpt": format!("{phase}: {message}"),
1930        "capacity": capacity,
1931        "nearest_resource_event": {
1932            "owner_kind": "request",
1933            "owner_id": request_id,
1934            "resource_kind": resource_kind,
1935            "action": "reject",
1936            "amount": needed,
1937            "before": capacity_value,
1938            "after": capacity_value,
1939            "capacity": capacity_value,
1940            "reason": message
1941        },
1942        "nearest_memory_snapshot": memory
1943    })
1944}
1945
1946fn chat_failure_capacity(
1947    resource_kind: &str,
1948    admission_summary: Option<&serde_json::Value>,
1949    engine_status: Option<&EngineStatus>,
1950    reason: &str,
1951) -> serde_json::Value {
1952    if resource_kind == "device_memory" {
1953        let (needed, available, capacity) = engine_status
1954            .map(|status| {
1955                let memory = &status.memory_usage;
1956                let used = memory.used_bytes as i64;
1957                let available = memory.free_bytes as i64;
1958                let capacity = memory.total_bytes as i64;
1959                (
1960                    used.saturating_add(1).max(1),
1961                    available.max(0),
1962                    capacity.max(0),
1963                )
1964            })
1965            .unwrap_or((1, 0, 0));
1966        return serde_json::json!({
1967            "resource_kind": resource_kind,
1968            "needed": needed,
1969            "available": available,
1970            "capacity": capacity,
1971            "reason": reason
1972        });
1973    }
1974    let capacity = admission_summary
1975        .and_then(|summary| summary.get("effective_max_concurrent"))
1976        .and_then(|value| {
1977            value
1978                .as_i64()
1979                .or_else(|| value.as_u64().map(|value| value as i64))
1980        })
1981        .unwrap_or_else(|| {
1982            engine_status
1983                .map(|status| {
1984                    (status.active_requests as i64)
1985                        .saturating_add(status.queued_requests as i64)
1986                        .saturating_add(1)
1987                })
1988                .unwrap_or(0)
1989        })
1990        .max(0);
1991    let used = engine_status
1992        .map(|status| (status.active_requests as i64).saturating_add(status.queued_requests as i64))
1993        .unwrap_or(0)
1994        .max(0);
1995    serde_json::json!({
1996        "resource_kind": resource_kind,
1997        "needed": 1,
1998        "available": capacity.saturating_sub(used),
1999        "capacity": capacity,
2000        "reason": reason
2001    })
2002}
2003
2004fn chat_resource_failure_kind(failure_kind: &str) -> bool {
2005    matches!(
2006        failure_kind,
2007        "oom" | "prevented_oom" | "admission" | "admission_reject" | "oom_admission"
2008    )
2009}
2010
2011fn chat_resource_kind_for_failure(failure_kind: &str) -> &'static str {
2012    match failure_kind {
2013        "oom" | "prevented_oom" => "device_memory",
2014        _ => "admission_capacity",
2015    }
2016}
2017
2018fn sanitize_diagnostic_text(message: &str) -> String {
2019    let trimmed = message.trim();
2020    if trimmed.is_empty() {
2021        return "generation failed without an error message".to_string();
2022    }
2023    let lower = trimmed.to_ascii_lowercase();
2024    if lower.contains("authorization")
2025        || lower.contains("cookie")
2026        || lower.contains("api_key")
2027        || lower.contains("access_token")
2028        || lower.contains("refresh_token")
2029        || lower.contains("password")
2030        || trimmed.contains("sk-")
2031    {
2032        return "[redacted diagnostic message]".to_string();
2033    }
2034    trimmed.chars().take(2048).collect()
2035}
2036
2037fn sanitized_replay_headers(headers: &HeaderMap) -> serde_json::Value {
2038    let mut result = serde_json::Map::new();
2039    for key in ["content-type", "traceparent", "tracestate"] {
2040        if let Some(value) = headers.get(key).and_then(|value| value.to_str().ok()) {
2041            result.insert(key.to_string(), serde_json::json!(value));
2042        }
2043    }
2044    result.insert("authorization".to_string(), serde_json::json!("[redacted]"));
2045    result.insert("cookie".to_string(), serde_json::json!("[redacted]"));
2046    serde_json::Value::Object(result)
2047}
2048
2049fn sanitized_chat_request_body(request: &ChatCompletionsRequest) -> serde_json::Value {
2050    let mut value = serde_json::to_value(request).unwrap_or_else(|_| {
2051        serde_json::json!({
2052            "model": request.model.clone(),
2053            "stream": request.stream.unwrap_or(false),
2054            "messages": []
2055        })
2056    });
2057    redact_json_value(&mut value, None);
2058    value
2059}
2060
2061fn redact_json_value(value: &mut serde_json::Value, key: Option<&str>) {
2062    if key.is_some_and(is_secret_key) {
2063        *value = serde_json::json!("[redacted]");
2064        return;
2065    }
2066    if matches!(key, Some("content" | "arguments")) && value.is_string() {
2067        *value = serde_json::json!("[redacted]");
2068        return;
2069    }
2070    match value {
2071        serde_json::Value::Object(map) => {
2072            for field in ["content", "arguments"] {
2073                if let Some(chars) = map
2074                    .get(field)
2075                    .and_then(|child| child.as_str())
2076                    .map(|text| text.chars().count())
2077                {
2078                    map.insert(field.to_string(), serde_json::json!("[redacted]"));
2079                    map.insert(format!("{field}_redacted"), serde_json::json!(true));
2080                    map.insert(format!("{field}_chars"), serde_json::json!(chars));
2081                }
2082            }
2083            for (child_key, child) in map.iter_mut() {
2084                redact_json_value(child, Some(child_key.as_str()));
2085            }
2086        }
2087        serde_json::Value::Array(items) => {
2088            for child in items {
2089                redact_json_value(child, None);
2090            }
2091        }
2092        _ => {}
2093    }
2094}
2095
2096fn is_secret_key(key: &str) -> bool {
2097    let normalized = key
2098        .chars()
2099        .filter(|ch| *ch != '-' && *ch != '_')
2100        .flat_map(char::to_lowercase)
2101        .collect::<String>();
2102    matches!(
2103        normalized.as_str(),
2104        "authorization"
2105            | "cookie"
2106            | "secret"
2107            | "apikey"
2108            | "password"
2109            | "accesstoken"
2110            | "refreshtoken"
2111            | "idtoken"
2112    )
2113}
2114
2115fn replay_curl_argv(bundle_dir: &Path) -> Vec<String> {
2116    vec![
2117        "curl".to_string(),
2118        "-sS".to_string(),
2119        "-X".to_string(),
2120        "POST".to_string(),
2121        "http://127.0.0.1:8000/v1/chat/completions".to_string(),
2122        "-H".to_string(),
2123        "content-type: application/json".to_string(),
2124        "--data-binary".to_string(),
2125        format!("@{}", bundle_dir.join("replay_body.json").display()),
2126    ]
2127}
2128
2129fn replay_curl_command(bundle_dir: &Path) -> String {
2130    shell_command(&replay_curl_argv(bundle_dir))
2131}
2132
2133fn replay_bundle_argv(bundle_dir: &Path) -> Vec<String> {
2134    vec![
2135        "cargo".to_string(),
2136        "run".to_string(),
2137        "-p".to_string(),
2138        "ferrum-cli".to_string(),
2139        "--".to_string(),
2140        "replay-bundle".to_string(),
2141        bundle_dir.to_string_lossy().to_string(),
2142        "--out".to_string(),
2143        bundle_dir
2144            .join("engine_replay")
2145            .to_string_lossy()
2146            .to_string(),
2147        "--json".to_string(),
2148    ]
2149}
2150
2151fn shell_command(argv: &[String]) -> String {
2152    argv.iter()
2153        .map(|part| shell_quote(part))
2154        .collect::<Vec<_>>()
2155        .join(" ")
2156}
2157
2158fn shell_quote(value: &str) -> String {
2159    if value
2160        .chars()
2161        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-' | ':' | '@'))
2162    {
2163        value.to_string()
2164    } else {
2165        format!("'{}'", value.replace('\'', "'\\''"))
2166    }
2167}
2168
2169fn write_json_value(path: &Path, value: &serde_json::Value) -> std::result::Result<(), String> {
2170    let bytes = serde_json::to_vec_pretty(value).map_err(|err| err.to_string())?;
2171    fs::write(path, [bytes, b"\n".to_vec()].concat()).map_err(|err| err.to_string())
2172}
2173
2174fn bad_output_scan_json(
2175    request_id: &str,
2176    text: &str,
2177    failure_kind: Option<&str>,
2178    output_artifact_bytes: &[u8],
2179) -> serde_json::Value {
2180    let mut reasons = Vec::new();
2181    let mut first_span: Option<serde_json::Value> = None;
2182    for (needle, reason) in [
2183        ("<unk>", "reserved_token"),
2184        ("[PAD", "reserved_token"),
2185        ("<pad>", "reserved_token"),
2186        ("<|endoftext|>", "reserved_token"),
2187        ("<|im_start|>", "reserved_token"),
2188        ("<|im_end|>", "reserved_token"),
2189        ("<|reserved_special_token", "reserved_token"),
2190        ("\u{fffd}", "invalid_utf8"),
2191    ] {
2192        if let Some(index) = text.find(needle) {
2193            reasons.push(reason);
2194            first_span.get_or_insert_with(|| {
2195                serde_json::json!({
2196                    "byte_start": index,
2197                    "byte_end": index + needle.len(),
2198                    "text": needle,
2199                    "reason": reason
2200                })
2201            });
2202        }
2203    }
2204    if let Some(index) = first_mojibake_index(text) {
2205        reasons.push("mojibake");
2206        first_span.get_or_insert_with(|| {
2207            serde_json::json!({
2208                "byte_start": index,
2209                "byte_end": index + 1,
2210                "reason": "mojibake"
2211            })
2212        });
2213    }
2214    reasons.sort_unstable();
2215    reasons.dedup();
2216    let bad_output = !reasons.is_empty();
2217    serde_json::json!({
2218        "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
2219        "request_id": request_id,
2220        "bad_output": bad_output,
2221        "bad_text_count": if bad_output { 1 } else { 0 },
2222        "reasons": reasons,
2223        "first_bad_text_span": first_span,
2224        "failure_kind": failure_kind,
2225        "output_chars": text.chars().count(),
2226        "classified_output_sha256": sha256_hex(text.as_bytes()),
2227        "output_sha256": sha256_hex(output_artifact_bytes)
2228    })
2229}
2230
2231fn first_mojibake_index(text: &str) -> Option<usize> {
2232    let mut chars = text.char_indices().peekable();
2233    while let Some((index, ch)) = chars.next() {
2234        match ch {
2235            '\u{00c2}' | '\u{00c3}' => {
2236                if chars.peek().is_some_and(|(_, next)| !next.is_ascii()) {
2237                    return Some(index);
2238                }
2239            }
2240            '\u{00e2}' => {
2241                if chars.peek().is_some_and(|(_, next)| *next == '\u{20ac}') {
2242                    return Some(index);
2243                }
2244            }
2245            _ => {}
2246        }
2247    }
2248    None
2249}
2250
2251fn sha256_hex(bytes: &[u8]) -> String {
2252    let mut hasher = Sha256::new();
2253    hasher.update(bytes);
2254    format!("{:x}", hasher.finalize())
2255}
2256
2257/// Handle streaming chat completions
2258async fn handle_chat_completions_stream(
2259    state: AppState,
2260    openai_request: ChatCompletionsRequest,
2261    inference_request: InferenceRequest,
2262) -> std::result::Result<Response, ServerError> {
2263    let (tx, rx) = mpsc::unbounded_channel::<std::result::Result<Event, axum::Error>>();
2264
2265    // Spawn task to generate tokens
2266    let engine = state.llm.clone().ok_or_else(|| {
2267        ServerError::ServiceUnavailable("LLM engine not loaded; chat unavailable".into())
2268    })?;
2269    let request_id = Uuid::new_v4().to_string();
2270    let include_stream_usage = openai_request
2271        .stream_options
2272        .as_ref()
2273        .and_then(|opts| opts.include_usage)
2274        .unwrap_or(false);
2275    let output_contract = EffectiveChatOutputContract::resolve(&openai_request);
2276    let buffer_json_object_stream = matches!(
2277        output_contract,
2278        EffectiveChatOutputContract::JsonObjectContent
2279    );
2280    let buffer_strict_json_schema_stream = matches!(
2281        output_contract,
2282        EffectiveChatOutputContract::StrictJsonSchemaContent
2283    );
2284    let stream_api_request = match inference_request.api_request.as_ref() {
2285        Some(ferrum_types::ApiRequest::Chat(request)) => request.clone(),
2286        _ => api_chat_request(
2287            &openai_request,
2288            openai_request.tool_choice.as_ref(),
2289            ferrum_types::ApiToolCallProtocol::default(),
2290        ),
2291    };
2292    let buffer_structured_api_stream =
2293        ferrum_types::chat_api_may_emit_tool_or_function_call(&stream_api_request);
2294    let buffer_stream_output = buffer_json_object_stream
2295        || buffer_strict_json_schema_stream
2296        || buffer_structured_api_stream;
2297    // R1-distill-style templates open the think block inside the prompt;
2298    // the parser must know generation starts mid-think.
2299    let started_in_think = has_unclosed_thinking_block(&inference_request.prompt);
2300    let replay_request_id = inference_request.id.to_string();
2301    let profile_request_model = openai_request.model.clone();
2302    let profile_started_at = Instant::now();
2303    let request_memory_before = request_memory_sample_before(&state);
2304    let mut stream = match engine.infer_stream(inference_request).await {
2305        Ok(stream) => stream,
2306        Err(e) => {
2307            let failure_kind = e.observability_failure_kind();
2308            let error_kind = e.observability_error_kind();
2309            let error_message = e.to_string();
2310            if let Err(err) = write_chat_request_profile_event(
2311                &state,
2312                &replay_request_id,
2313                &profile_request_model,
2314                true,
2315                "chat_completions_stream_start",
2316                profile_started_at,
2317                ChatRequestProfileTiming::default(),
2318                0,
2319                None,
2320                Some("error"),
2321                Some(ProfileError {
2322                    kind: error_kind.to_string(),
2323                    message: error_message.clone(),
2324                    blocking: false,
2325                }),
2326            ) {
2327                warn!("failed to write chat stream failure profile event: {}", err);
2328            }
2329            let engine_status = if chat_resource_failure_kind(failure_kind) {
2330                Some(engine.status().await)
2331            } else {
2332                None
2333            };
2334            error!(
2335                "Stream generation failed before first chunk: {}",
2336                error_message
2337            );
2338            if let Err(err) = write_chat_request_failure_diagnostics(
2339                &state,
2340                &replay_request_id,
2341                failure_kind,
2342                "chat_completions_stream_start",
2343                error_kind,
2344                &error_message,
2345                engine_status.as_ref(),
2346            ) {
2347                warn!("failed to write chat stream failure diagnostics: {}", err);
2348            }
2349            return Err(server_error_from_ferrum_error(e));
2350        }
2351    };
2352    let request_dump_dir = state.request_dump_dir.clone();
2353    let admission_summary = state
2354        .auto_config
2355        .as_ref()
2356        .map(|config| config.admission_summary_document());
2357    let diagnostics_engine = engine.clone();
2358    let profile_state = state.clone();
2359
2360    tokio::spawn(async move {
2361        let mut current_text = String::new();
2362        let mut output_token_ids = Vec::new();
2363        let mut first_engine_chunk_received_us = None;
2364        let mut first_sse_enqueue_us = None;
2365        let mut sent_reasoning_len = 0usize;
2366        let mut sent_content_len = 0usize;
2367
2368        loop {
2369            let next = tokio::select! {
2370                biased;
2371                _ = tx.closed() => break,
2372                next = stream.next() => next,
2373            };
2374            let Some(result) = next else {
2375                break;
2376            };
2377            match result {
2378                Ok(chunk) => {
2379                    if first_engine_chunk_received_us.is_none()
2380                        && (chunk.token.is_some() || !chunk.text.is_empty())
2381                    {
2382                        first_engine_chunk_received_us = Some(elapsed_us_since(profile_started_at));
2383                    }
2384                    if let Some(token) = chunk.token {
2385                        output_token_ids.push(token);
2386                    }
2387                    if !chunk.text.is_empty() {
2388                        current_text.push_str(&chunk.text);
2389
2390                        if !buffer_stream_output {
2391                            if should_defer_reasoning_stream_delta(&current_text) {
2392                                continue;
2393                            }
2394                            let parsed = if started_in_think {
2395                                parse_reasoning_response_started_in_think(&current_text)
2396                            } else {
2397                                parse_reasoning_response(&current_text)
2398                            };
2399                            let full_reasoning = parsed.reasoning.as_deref().unwrap_or("");
2400                            let reasoning_delta =
2401                                stream_text_delta(full_reasoning, &mut sent_reasoning_len);
2402                            let content_delta =
2403                                stream_text_delta(&parsed.content, &mut sent_content_len);
2404                            if reasoning_delta.is_empty() && content_delta.is_empty() {
2405                                continue;
2406                            }
2407                            // Create streaming response chunk
2408                            let response_chunk = ChatCompletionsResponse {
2409                                id: request_id.clone(),
2410                                object: "chat.completion.chunk".to_string(),
2411                                created: chrono::Utc::now().timestamp() as u64,
2412                                model: openai_request.model.clone(),
2413                                choices: vec![ChatChoice {
2414                                    index: 0,
2415                                    message: None,
2416                                    delta: Some(ChatMessage {
2417                                        role: MessageRole::Assistant,
2418                                        content: content_delta,
2419                                        reasoning: (!reasoning_delta.is_empty())
2420                                            .then_some(reasoning_delta),
2421                                        name: None,
2422                                        tool_calls: None,
2423                                        tool_call_id: None,
2424                                        function_call: None,
2425                                    }),
2426                                    finish_reason: None,
2427                                }],
2428                                usage: None,
2429                            };
2430
2431                            let sse_event = Event::default()
2432                                .json_data(&response_chunk)
2433                                .unwrap_or_else(|_| Event::default().data("error"));
2434                            if tx.send(Ok(sse_event)).is_err() {
2435                                break;
2436                            }
2437                            first_sse_enqueue_us
2438                                .get_or_insert_with(|| elapsed_us_since(profile_started_at));
2439                        }
2440                    }
2441
2442                    if chunk.finish_reason.is_some() {
2443                        let terminal_finish_reason = chunk
2444                            .finish_reason
2445                            .expect("finish_reason presence checked above");
2446                        if let Err(err) = write_chat_prompt_token_evidence(
2447                            request_dump_dir.as_ref().map(|root| root.as_path()),
2448                            &replay_request_id,
2449                            &profile_request_model,
2450                            chunk.execution_evidence.as_ref(),
2451                        ) {
2452                            warn!("failed to write chat stream prompt-token evidence: {}", err);
2453                        }
2454                        let usage = chunk.usage.as_ref().map(openai_usage_from_token_usage);
2455                        let mut parsed_final = if started_in_think {
2456                            parse_reasoning_response_started_in_think(&current_text)
2457                        } else {
2458                            parse_reasoning_response(&current_text)
2459                        };
2460                        parsed_final.content = normalize_structured_response_content(
2461                            &openai_request,
2462                            &parsed_final.content,
2463                        );
2464                        let structured_chat_response =
2465                            finish_reason_allows_structured_api_response(terminal_finish_reason)
2466                                .then(|| match chunk.api_response.as_ref() {
2467                                    Some(ferrum_types::ApiResponse::Chat(response)) => {
2468                                        Some(response.clone())
2469                                    }
2470                                    _ if buffer_structured_api_stream => {
2471                                        chat_api_response_from_parsed_generated_text(
2472                                            &stream_api_request,
2473                                            &parsed_final,
2474                                            terminal_finish_reason,
2475                                        )
2476                                    }
2477                                    _ => None,
2478                                })
2479                                .flatten();
2480
2481                        if let Some(chat_response) = structured_chat_response.as_ref() {
2482                            if let Err(e) =
2483                                validate_structured_tool_response(&openai_request, chat_response)
2484                            {
2485                                let error_event = openai_error_sse_event(
2486                                    stream_validation_error_message(e),
2487                                    "internal_server_error",
2488                                    Some("tool_choice"),
2489                                );
2490                                let _ = tx.send(Ok(error_event));
2491                                let _ = tx.send(Ok(Event::default().data("[DONE]")));
2492                                break;
2493                            }
2494                        } else if tool_choice_required(&openai_request) {
2495                            log_required_tool_choice_failure(
2496                                &openai_request,
2497                                &parsed_final.content,
2498                                parsed_final.reasoning.as_deref(),
2499                            );
2500                            let error_event = openai_error_sse_event(
2501                                "model output did not satisfy required tool_choice",
2502                                "invalid_request_error",
2503                                Some("tool_choice"),
2504                            );
2505                            let _ = tx.send(Ok(error_event));
2506                            let _ = tx.send(Ok(Event::default().data("[DONE]")));
2507                            break;
2508                        }
2509                        if let Err(e) = validate_hard_structured_response(
2510                            &openai_request,
2511                            &parsed_final.content,
2512                        ) {
2513                            let error_event = openai_error_sse_event(
2514                                stream_validation_error_message(e),
2515                                "internal_server_error",
2516                                structured_response_error_param(output_contract),
2517                            );
2518                            let _ = tx.send(Ok(error_event));
2519                            let _ = tx.send(Ok(Event::default().data("[DONE]")));
2520                            break;
2521                        }
2522
2523                        if let Some(chat_response) = structured_chat_response.as_ref() {
2524                            let mut delta = openai_chat_delta_from_api(&chat_response.message);
2525                            if delta.reasoning.is_none() {
2526                                delta.reasoning = parsed_final.reasoning.clone();
2527                            }
2528                            let response_chunk = ChatCompletionsResponse {
2529                                id: request_id.clone(),
2530                                object: "chat.completion.chunk".to_string(),
2531                                created: chrono::Utc::now().timestamp() as u64,
2532                                model: openai_request.model.clone(),
2533                                choices: vec![ChatChoice {
2534                                    index: 0,
2535                                    message: None,
2536                                    delta: Some(delta),
2537                                    finish_reason: None,
2538                                }],
2539                                usage: None,
2540                            };
2541
2542                            let sse_event = Event::default()
2543                                .json_data(&response_chunk)
2544                                .unwrap_or_else(|_| Event::default().data("error"));
2545                            if tx.send(Ok(sse_event)).is_err() {
2546                                break;
2547                            }
2548                            first_sse_enqueue_us
2549                                .get_or_insert_with(|| elapsed_us_since(profile_started_at));
2550                        } else if buffer_structured_api_stream
2551                            && parsed_final.content.trim().is_empty()
2552                        {
2553                            let error_event = openai_error_sse_event(
2554                                "model output did not satisfy tool/function call request",
2555                                "internal_server_error",
2556                                Some("tool_choice"),
2557                            );
2558                            let _ = tx.send(Ok(error_event));
2559                            let _ = tx.send(Ok(Event::default().data("[DONE]")));
2560                            break;
2561                        } else if buffer_stream_output && !current_text.is_empty() {
2562                            let response_chunk = ChatCompletionsResponse {
2563                                id: request_id.clone(),
2564                                object: "chat.completion.chunk".to_string(),
2565                                created: chrono::Utc::now().timestamp() as u64,
2566                                model: openai_request.model.clone(),
2567                                choices: vec![ChatChoice {
2568                                    index: 0,
2569                                    message: None,
2570                                    delta: Some(ChatMessage {
2571                                        role: MessageRole::Assistant,
2572                                        content: parsed_final.content.clone(),
2573                                        reasoning: parsed_final.reasoning.clone(),
2574                                        name: None,
2575                                        tool_calls: None,
2576                                        tool_call_id: None,
2577                                        function_call: None,
2578                                    }),
2579                                    finish_reason: None,
2580                                }],
2581                                usage: None,
2582                            };
2583
2584                            let sse_event = Event::default()
2585                                .json_data(&response_chunk)
2586                                .unwrap_or_else(|_| Event::default().data("error"));
2587                            if tx.send(Ok(sse_event)).is_err() {
2588                                break;
2589                            }
2590                            first_sse_enqueue_us
2591                                .get_or_insert_with(|| elapsed_us_since(profile_started_at));
2592                        }
2593                        // Send final chunk. OpenAI-style streaming
2594                        // clients (e.g. `vllm bench serve`) blindly
2595                        // read `choices[0]["delta"]` on every chunk
2596                        // that has any `choices` entries, so the
2597                        // last chunk must include `delta` even when
2598                        // empty. Skipping it triggers
2599                        // `KeyError: 'delta'` on the client side
2600                        // and the request is reported as failed
2601                        // despite returning a 200 with content.
2602                        let final_finish_reason = structured_chat_response
2603                            .as_ref()
2604                            .and_then(|response| response.finish_reason.clone())
2605                            .or_else(|| chunk.finish_reason.as_ref().map(finish_reason_to_string))
2606                            .or(Some("length".to_string()));
2607                        let final_chunk = ChatCompletionsResponse {
2608                            id: request_id.clone(),
2609                            object: "chat.completion.chunk".to_string(),
2610                            created: chrono::Utc::now().timestamp() as u64,
2611                            model: openai_request.model.clone(),
2612                            choices: vec![ChatChoice {
2613                                index: 0,
2614                                message: None,
2615                                delta: Some(ChatMessage {
2616                                    role: MessageRole::Assistant,
2617                                    content: String::new(),
2618                                    reasoning: None,
2619                                    name: None,
2620                                    tool_calls: None,
2621                                    tool_call_id: None,
2622                                    function_call: None,
2623                                }),
2624                                finish_reason: final_finish_reason.clone(),
2625                            }],
2626                            usage: None,
2627                        };
2628
2629                        let final_event = Event::default()
2630                            .json_data(&final_chunk)
2631                            .unwrap_or_else(|_| Event::default().data("error"));
2632                        if tx.send(Ok(final_event)).is_ok() {
2633                            first_sse_enqueue_us
2634                                .get_or_insert_with(|| elapsed_us_since(profile_started_at));
2635                        }
2636                        let completion_token_count = chunk
2637                            .usage
2638                            .as_ref()
2639                            .map(|usage| usage.completion_tokens)
2640                            .unwrap_or(output_token_ids.len());
2641                        let replay_output_token_ids = chunk
2642                            .execution_evidence
2643                            .as_ref()
2644                            .map(|evidence| evidence.output_token_ids.as_slice())
2645                            .filter(|tokens| tokens.len() == completion_token_count)
2646                            .unwrap_or(output_token_ids.as_slice());
2647                        if let Err(err) = write_chat_request_completion_replay_bundle(
2648                            request_dump_dir.as_ref().map(|root| root.as_path()),
2649                            &replay_request_id,
2650                            &parsed_final.content,
2651                            replay_output_token_ids,
2652                            final_finish_reason.as_deref(),
2653                        ) {
2654                            warn!("failed to write chat stream replay bundle: {}", err);
2655                        }
2656                        if let Err(err) = write_chat_request_profile_event(
2657                            &profile_state,
2658                            &replay_request_id,
2659                            &profile_request_model,
2660                            true,
2661                            "chat_completions_stream_complete",
2662                            profile_started_at,
2663                            ChatRequestProfileTiming {
2664                                engine_evidence: chunk.execution_evidence.as_ref(),
2665                                first_engine_chunk_received_us,
2666                                first_sse_enqueue_us,
2667                            },
2668                            completion_token_count,
2669                            chunk.usage.as_ref(),
2670                            final_finish_reason.as_deref(),
2671                            None,
2672                        ) {
2673                            warn!("failed to write chat stream profile event: {}", err);
2674                        }
2675                        if let Err(err) = maybe_write_first_request_memory_stage(
2676                            &profile_state,
2677                            &replay_request_id,
2678                            &profile_request_model,
2679                            true,
2680                            profile_started_at,
2681                            request_memory_before,
2682                        ) {
2683                            warn!("failed to write chat stream memory profile event: {}", err);
2684                        }
2685                        if include_stream_usage && usage.is_some() {
2686                            let usage_chunk = ChatCompletionsResponse {
2687                                id: request_id.clone(),
2688                                object: "chat.completion.chunk".to_string(),
2689                                created: chrono::Utc::now().timestamp() as u64,
2690                                model: openai_request.model.clone(),
2691                                choices: vec![],
2692                                usage,
2693                            };
2694                            let usage_event = Event::default()
2695                                .json_data(&usage_chunk)
2696                                .unwrap_or_else(|_| Event::default().data("error"));
2697                            let _ = tx.send(Ok(usage_event));
2698                        }
2699                        let _ = tx.send(Ok(Event::default().data("[DONE]")));
2700                        break;
2701                    }
2702                }
2703                Err(e) => {
2704                    let failure_kind = e.observability_failure_kind();
2705                    let error_kind = e.observability_error_kind();
2706                    let error_message = e.to_string();
2707                    let engine_status = if chat_resource_failure_kind(failure_kind) {
2708                        Some(diagnostics_engine.status().await)
2709                    } else {
2710                        None
2711                    };
2712                    error!("Stream generation error: {}", error_message);
2713                    if let Err(err) = write_chat_request_profile_event(
2714                        &profile_state,
2715                        &replay_request_id,
2716                        &profile_request_model,
2717                        true,
2718                        "chat_completions_stream_next",
2719                        profile_started_at,
2720                        ChatRequestProfileTiming {
2721                            engine_evidence: None,
2722                            first_engine_chunk_received_us,
2723                            first_sse_enqueue_us,
2724                        },
2725                        output_token_ids.len(),
2726                        None,
2727                        Some("error"),
2728                        Some(ProfileError {
2729                            kind: error_kind.to_string(),
2730                            message: error_message.clone(),
2731                            blocking: false,
2732                        }),
2733                    ) {
2734                        warn!("failed to write chat stream chunk profile event: {}", err);
2735                    }
2736                    if let Err(err) = write_chat_request_failure_diagnostics_at_root(
2737                        request_dump_dir.as_ref().map(|root| root.as_path()),
2738                        admission_summary.as_ref(),
2739                        engine_status.as_ref(),
2740                        &replay_request_id,
2741                        failure_kind,
2742                        "chat_completions_stream_next",
2743                        error_kind,
2744                        &error_message,
2745                    ) {
2746                        warn!(
2747                            "failed to write chat stream chunk failure diagnostics: {}",
2748                            err
2749                        );
2750                    }
2751                    let _ = tx.send(Ok(openai_error_sse_event(
2752                        error_message,
2753                        "internal_server_error",
2754                        None,
2755                    )));
2756                    let _ = tx.send(Ok(Event::default().data("[DONE]")));
2757                    break;
2758                }
2759            }
2760        }
2761    });
2762
2763    let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
2764    let sse_stream = Sse::new(stream);
2765
2766    Ok(sse_stream.into_response())
2767}
2768
2769/// Handle non-streaming chat completions
2770async fn handle_chat_completions_sync(
2771    state: AppState,
2772    openai_request: ChatCompletionsRequest,
2773    inference_request: InferenceRequest,
2774    session_context: Option<SessionContext>,
2775) -> std::result::Result<Response, ServerError> {
2776    info!("Processing non-streaming chat completion");
2777
2778    let engine = state.llm.clone().ok_or_else(|| {
2779        ServerError::ServiceUnavailable("LLM engine not loaded; chat unavailable".into())
2780    })?;
2781    let request_chat_api = inference_request
2782        .api_request
2783        .as_ref()
2784        .and_then(|api_request| match api_request {
2785            ferrum_types::ApiRequest::Chat(chat_request) => {
2786                ferrum_types::chat_api_may_emit_tool_or_function_call(chat_request)
2787                    .then(|| chat_request.clone())
2788            }
2789            _ => None,
2790        });
2791    // R1-distill-style templates open the think block inside the prompt.
2792    let started_in_think = has_unclosed_thinking_block(&inference_request.prompt);
2793    let replay_request_id = inference_request.id.to_string();
2794    let profile_request_model = openai_request.model.clone();
2795    let profile_started_at = Instant::now();
2796    let request_memory_before = request_memory_sample_before(&state);
2797    match engine.infer(inference_request).await {
2798        Ok(output) => {
2799            let InferenceResponse {
2800                text: output_text,
2801                tokens,
2802                finish_reason,
2803                usage,
2804                api_response,
2805                execution_evidence,
2806                ..
2807            } = output;
2808            if let Err(err) = write_chat_prompt_token_evidence(
2809                state.request_dump_dir.as_ref().map(|root| root.as_path()),
2810                &replay_request_id,
2811                &profile_request_model,
2812                execution_evidence.as_ref(),
2813            ) {
2814                warn!("failed to write chat prompt-token evidence: {}", err);
2815            }
2816
2817            // OpenAI stop strings mark a boundary and are not included in the
2818            // returned completion. Hard structured formats are never repaired
2819            // here; malformed output must fail the response contract below.
2820            let stop_sequences = openai_request.stop.clone().unwrap_or_default();
2821            let content = strip_after_stop(&output_text, &stop_sequences);
2822            let parsed = if started_in_think {
2823                parse_reasoning_response_started_in_think(&content)
2824            } else {
2825                parse_reasoning_response(&content)
2826            };
2827            let visible_content =
2828                normalize_structured_response_content(&openai_request, &parsed.content);
2829            let mut message = ChatMessage {
2830                role: MessageRole::Assistant,
2831                content: visible_content,
2832                reasoning: parsed.reasoning.clone(),
2833                name: None,
2834                tool_calls: None,
2835                tool_call_id: None,
2836                function_call: None,
2837            };
2838            let mut openai_finish_reason = finish_reason_to_string(&finish_reason);
2839            let structured_chat_response =
2840                finish_reason_allows_structured_api_response(finish_reason)
2841                    .then(|| match api_response.as_ref() {
2842                        Some(ferrum_types::ApiResponse::Chat(chat_response)) => {
2843                            Some(chat_response.clone())
2844                        }
2845                        _ => match request_chat_api.as_ref() {
2846                            Some(chat_request) => chat_api_response_from_parsed_generated_text(
2847                                chat_request,
2848                                &parsed,
2849                                finish_reason,
2850                            ),
2851                            _ => None,
2852                        },
2853                    })
2854                    .flatten();
2855            if let Some(chat_response) = structured_chat_response.as_ref() {
2856                if let Err(error) =
2857                    validate_structured_tool_response(&openai_request, chat_response)
2858                {
2859                    if let Err(err) = write_chat_request_profile_event(
2860                        &state,
2861                        &replay_request_id,
2862                        &profile_request_model,
2863                        false,
2864                        "chat_completions_sync_tool_contract",
2865                        profile_started_at,
2866                        ChatRequestProfileTiming {
2867                            engine_evidence: execution_evidence.as_ref(),
2868                            ..Default::default()
2869                        },
2870                        tokens.len(),
2871                        Some(&usage),
2872                        Some("error"),
2873                        Some(ProfileError {
2874                            kind: "tool_contract_failure".to_string(),
2875                            message: format!("{error:?}"),
2876                            blocking: true,
2877                        }),
2878                    ) {
2879                        warn!("failed to write chat tool-contract profile event: {}", err);
2880                    }
2881                    return Err(error);
2882                }
2883                message = openai_chat_message_from_api(&chat_response.message);
2884                if message.reasoning.is_none() {
2885                    message.reasoning = parsed.reasoning.clone();
2886                }
2887                if let Some(reason) = &chat_response.finish_reason {
2888                    openai_finish_reason = reason.clone();
2889                }
2890            } else if tool_choice_required(&openai_request) {
2891                log_required_tool_choice_failure(
2892                    &openai_request,
2893                    &parsed.content,
2894                    parsed.reasoning.as_deref(),
2895                );
2896                if let Err(err) = write_chat_request_profile_event(
2897                    &state,
2898                    &replay_request_id,
2899                    &profile_request_model,
2900                    false,
2901                    "chat_completions_sync_tool_choice",
2902                    profile_started_at,
2903                    ChatRequestProfileTiming {
2904                        engine_evidence: execution_evidence.as_ref(),
2905                        ..Default::default()
2906                    },
2907                    tokens.len(),
2908                    Some(&usage),
2909                    Some("error"),
2910                    Some(ProfileError {
2911                        kind: "required_tool_failure".to_string(),
2912                        message: "model output did not satisfy required tool_choice".to_string(),
2913                        blocking: true,
2914                    }),
2915                ) {
2916                    warn!("failed to write chat tool-choice profile event: {}", err);
2917                }
2918                return Err(ServerError::invalid_request(
2919                    "model output did not satisfy required tool_choice",
2920                    Some("tool_choice"),
2921                ));
2922            }
2923            if let Err(error) = validate_hard_structured_response(&openai_request, &message.content)
2924            {
2925                if let Err(err) = write_chat_request_profile_event(
2926                    &state,
2927                    &replay_request_id,
2928                    &profile_request_model,
2929                    false,
2930                    "chat_completions_sync_structured_output",
2931                    profile_started_at,
2932                    ChatRequestProfileTiming {
2933                        engine_evidence: execution_evidence.as_ref(),
2934                        ..Default::default()
2935                    },
2936                    tokens.len(),
2937                    Some(&usage),
2938                    Some("error"),
2939                    Some(ProfileError {
2940                        kind: "structured_output_failure".to_string(),
2941                        message: format!("{error:?}"),
2942                        blocking: true,
2943                    }),
2944                ) {
2945                    warn!("failed to write chat strict-schema profile event: {}", err);
2946                }
2947                return Err(error);
2948            }
2949            if let Err(err) = write_chat_request_completion_replay_bundle(
2950                state.request_dump_dir.as_ref().map(|root| root.as_path()),
2951                &replay_request_id,
2952                &message.content,
2953                &tokens,
2954                Some(&openai_finish_reason),
2955            ) {
2956                warn!("failed to write chat completion replay bundle: {}", err);
2957            }
2958            if let Err(err) = write_chat_request_profile_event(
2959                &state,
2960                &replay_request_id,
2961                &profile_request_model,
2962                false,
2963                "chat_completions_sync_complete",
2964                profile_started_at,
2965                ChatRequestProfileTiming {
2966                    engine_evidence: execution_evidence.as_ref(),
2967                    ..Default::default()
2968                },
2969                tokens.len(),
2970                Some(&usage),
2971                Some(&openai_finish_reason),
2972                None,
2973            ) {
2974                warn!("failed to write chat sync profile event: {}", err);
2975            }
2976            if let Err(err) = maybe_write_first_request_memory_stage(
2977                &state,
2978                &replay_request_id,
2979                &profile_request_model,
2980                false,
2981                profile_started_at,
2982                request_memory_before,
2983            ) {
2984                warn!("failed to write chat sync memory profile event: {}", err);
2985            }
2986            state
2987                .cache
2988                .update_session(session_context, message.clone(), &CachePolicy::current());
2989            let response = ChatCompletionsResponse {
2990                id: Uuid::new_v4().to_string(),
2991                object: "chat.completion".to_string(),
2992                created: chrono::Utc::now().timestamp() as u64,
2993                model: openai_request.model,
2994                choices: vec![ChatChoice {
2995                    index: 0,
2996                    message: Some(message),
2997                    delta: None,
2998                    finish_reason: Some(openai_finish_reason),
2999                }],
3000                usage: Some(openai_usage_from_token_usage(&usage)),
3001            };
3002
3003            Ok(Json(response).into_response())
3004        }
3005        Err(e) => {
3006            let failure_kind = e.observability_failure_kind();
3007            let error_kind = e.observability_error_kind();
3008            let error_message = e.to_string();
3009            let engine_status = if chat_resource_failure_kind(failure_kind) {
3010                Some(engine.status().await)
3011            } else {
3012                None
3013            };
3014            error!("Generation failed: {}", error_message);
3015            if let Err(err) = write_chat_request_profile_event(
3016                &state,
3017                &replay_request_id,
3018                &profile_request_model,
3019                false,
3020                "chat_completions_sync",
3021                profile_started_at,
3022                ChatRequestProfileTiming::default(),
3023                0,
3024                None,
3025                Some("error"),
3026                Some(ProfileError {
3027                    kind: error_kind.to_string(),
3028                    message: error_message.clone(),
3029                    blocking: false,
3030                }),
3031            ) {
3032                warn!("failed to write chat sync failure profile event: {}", err);
3033            }
3034            if let Err(err) = write_chat_request_failure_diagnostics(
3035                &state,
3036                &replay_request_id,
3037                failure_kind,
3038                "chat_completions_sync",
3039                error_kind,
3040                &error_message,
3041                engine_status.as_ref(),
3042            ) {
3043                warn!(
3044                    "failed to write chat generation failure diagnostics: {}",
3045                    err
3046                );
3047            }
3048            Err(server_error_from_ferrum_error(e))
3049        }
3050    }
3051}
3052
3053#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3054enum EffectiveChatOutputContract {
3055    RequiredToolCall,
3056    StrictJsonSchemaContent,
3057    JsonObjectContent,
3058    BestEffortJsonSchemaContent,
3059    Text,
3060}
3061
3062#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3063enum ChatOutputBudget {
3064    AutoCeiling(u32),
3065    Explicit(u32),
3066}
3067
3068impl ChatOutputBudget {
3069    fn resolve(request: &ChatCompletionsRequest) -> Self {
3070        request
3071            .max_completion_tokens
3072            .or(request.max_tokens)
3073            .map(Self::Explicit)
3074            .unwrap_or(Self::AutoCeiling(DEFAULT_COMPLETION_MAX_TOKENS))
3075    }
3076
3077    const fn ceiling(self) -> u32 {
3078        match self {
3079            Self::AutoCeiling(value) | Self::Explicit(value) => value,
3080        }
3081    }
3082
3083    const fn is_auto(self) -> bool {
3084        matches!(self, Self::AutoCeiling(_))
3085    }
3086}
3087
3088impl EffectiveChatOutputContract {
3089    fn resolve(request: &ChatCompletionsRequest) -> Self {
3090        if tool_choice_required(request) {
3091            return Self::RequiredToolCall;
3092        }
3093        let Some(format) = request.response_format.as_ref() else {
3094            return Self::Text;
3095        };
3096        match format.format_type.as_str() {
3097            "json_schema"
3098                if format
3099                    .json_schema
3100                    .as_ref()
3101                    .and_then(|schema| schema.strict)
3102                    .unwrap_or(false) =>
3103            {
3104                Self::StrictJsonSchemaContent
3105            }
3106            "json_schema" => Self::BestEffortJsonSchemaContent,
3107            "json_object" => Self::JsonObjectContent,
3108            _ => Self::Text,
3109        }
3110    }
3111
3112    fn accepts_requested_response_format(self) -> bool {
3113        !matches!(self, Self::RequiredToolCall)
3114    }
3115}
3116
3117/// Convert OpenAI chat request to internal inference request
3118#[allow(dead_code)]
3119fn convert_chat_request(
3120    request: &ChatCompletionsRequest,
3121) -> ferrum_types::Result<InferenceRequest> {
3122    convert_chat_request_with_template_model(request, &request.model, None)
3123}
3124
3125/// Convert OpenAI chat request to internal inference request.
3126///
3127/// `template_model_id` is the loaded model id used for prompt-template family
3128/// detection. The request `model` field may be an OpenAI-compatible alias such
3129/// as "ferrum"; using it for template selection can feed a fallback prompt to
3130/// a Qwen/Llama model.
3131fn convert_chat_request_with_template_model(
3132    request: &ChatCompletionsRequest,
3133    template_model_id: &str,
3134    model_template: Option<&ModelChatTemplate>,
3135) -> ferrum_types::Result<InferenceRequest> {
3136    convert_chat_request_with_template_model_and_default(
3137        request,
3138        template_model_id,
3139        model_template,
3140        None,
3141    )
3142}
3143
3144fn convert_chat_request_with_template_model_and_default(
3145    request: &ChatCompletionsRequest,
3146    template_model_id: &str,
3147    model_template: Option<&ModelChatTemplate>,
3148    default_enable_thinking: Option<bool>,
3149) -> ferrum_types::Result<InferenceRequest> {
3150    let no_tools: &[ChatTool] = &[];
3151    let tools = if tool_choice_none_hides_tools(request.tool_choice.as_ref(), model_template) {
3152        no_tools
3153    } else {
3154        request.tools.as_deref().unwrap_or_default()
3155    };
3156    let default_tool_choice =
3157        default_auto_tool_choice_for_tools(tools, request.tool_choice.as_ref());
3158    let effective_tool_choice = request
3159        .tool_choice
3160        .as_ref()
3161        .or(default_tool_choice.as_ref());
3162    let functions = request.functions.as_deref().unwrap_or_default();
3163    let output_contract = EffectiveChatOutputContract::resolve(request);
3164    let output_budget = ChatOutputBudget::resolve(request);
3165    let forced_response_format = forced_tool_choice_response_format(request);
3166    let hard_tool_call_contract = forced_response_format.is_some();
3167    let requested_response_format = output_contract
3168        .accepts_requested_response_format()
3169        .then(|| requested_response_format_for_sampling(request))
3170        .transpose()?
3171        .flatten();
3172    let chat_template_options =
3173        chat_template_options_for_request(request, model_template, default_enable_thinking)?;
3174    let response_format = forced_response_format
3175        .or(requested_response_format)
3176        .unwrap_or(ferrum_types::ResponseFormat::Text);
3177    let model_generated_thinking = model_template.is_some_and(|template| {
3178        template.reasoning_protocol == ModelReasoningProtocol::ModelGenerated
3179            && template.reasoning_enabled(chat_template_options.enable_thinking)
3180    });
3181    let reasoning_enabled = model_template
3182        .is_some_and(|template| template.reasoning_enabled(chat_template_options.enable_thinking));
3183    let render_messages = render_messages_with_response_format_instruction(
3184        request,
3185        output_contract,
3186        reasoning_enabled,
3187    );
3188    let prompt = if tools.is_empty() && functions.is_empty() {
3189        render_chat_prompt_with_model_template_options(
3190            &render_messages,
3191            template_model_id,
3192            model_template,
3193            &chat_template_options,
3194        )?
3195    } else {
3196        render_chat_prompt_with_tools_and_model_template(
3197            &render_messages,
3198            template_model_id,
3199            model_template,
3200            &chat_template_options,
3201            tools,
3202            effective_tool_choice,
3203            functions,
3204            request.function_call.as_ref(),
3205        )?
3206    };
3207    let tool_call_protocol = model_template
3208        .map(|template| template.tool_call_protocol)
3209        .unwrap_or_default();
3210    let api_chat = api_chat_request(request, effective_tool_choice, tool_call_protocol);
3211    let mut metadata = HashMap::new();
3212    metadata.insert(
3213        "openai_messages".to_string(),
3214        serde_json::to_value(&request.messages)?,
3215    );
3216    if let Some(tools) = &request.tools {
3217        metadata.insert("openai_tools".to_string(), serde_json::to_value(tools)?);
3218    }
3219    if let Some(tool_choice) = effective_tool_choice {
3220        metadata.insert(
3221            "openai_tool_choice".to_string(),
3222            serde_json::to_value(tool_choice)?,
3223        );
3224    }
3225    if let Some(functions) = &request.functions {
3226        metadata.insert(
3227            "openai_legacy_functions".to_string(),
3228            serde_json::to_value(functions)?,
3229        );
3230    }
3231    if let Some(function_call) = &request.function_call {
3232        metadata.insert(
3233            "openai_legacy_function_call".to_string(),
3234            serde_json::to_value(function_call)?,
3235        );
3236    }
3237    if request.ignore_eos.unwrap_or(false) {
3238        metadata.insert("ferrum_ignore_eos".to_string(), serde_json::json!(true));
3239    }
3240    if output_budget.is_auto() {
3241        metadata.insert(
3242            DEFAULT_MAX_TOKENS_METADATA_KEY.to_string(),
3243            serde_json::json!(true),
3244        );
3245    }
3246    if !has_unclosed_thinking_block(&prompt) {
3247        let mut forbidden = vec![THINK_END_TAG.to_string()];
3248        if hard_tool_call_contract {
3249            for token_text in INITIAL_STRUCTURED_CALL_FORBIDDEN_TOKEN_TEXTS {
3250                push_unique_forbidden_token_text(&mut forbidden, token_text);
3251            }
3252            if let Some(eos) = model_template.as_ref().and_then(|template| {
3253                template
3254                    .eos_token
3255                    .as_deref()
3256                    .filter(|token| !token.is_empty())
3257            }) {
3258                push_unique_forbidden_token_text(&mut forbidden, eos);
3259            }
3260        }
3261        if chat_template_options.enable_thinking == Some(false) {
3262            push_unique_forbidden_token_text(&mut forbidden, THINK_START_TAG);
3263        }
3264        metadata.insert(
3265            INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY.to_string(),
3266            serde_json::json!(forbidden),
3267        );
3268    }
3269    let prompt_opened_thinking = has_unclosed_thinking_block(&prompt);
3270    let structured_output_after_reasoning =
3271        !matches!(response_format, ferrum_types::ResponseFormat::Text)
3272            && (prompt_opened_thinking || model_generated_thinking);
3273    let structured_output_start = if structured_output_after_reasoning {
3274        StructuredOutputStart::AfterDelimiter(THINK_END_TAG.to_string())
3275    } else {
3276        StructuredOutputStart::Immediate
3277    };
3278    let response_completion_boundary =
3279        if prompt_opened_thinking || structured_output_after_reasoning {
3280            ResponseCompletionBoundary::AfterDelimiterAndPayload {
3281                delimiter: THINK_END_TAG.to_string(),
3282                alternate_envelope: api_chat.generated_response_envelope(),
3283            }
3284        } else {
3285            ResponseCompletionBoundary::Immediate
3286        };
3287
3288    Ok(InferenceRequest {
3289        id: RequestId(Uuid::new_v4()),
3290        model_id: ModelId(request.model.clone()),
3291        prompt,
3292        sampling_params: SamplingParams {
3293            max_tokens: output_budget.ceiling() as usize,
3294            temperature: request.temperature.unwrap_or(DEFAULT_SAMPLING_TEMPERATURE),
3295            top_p: request.top_p.unwrap_or(DEFAULT_SAMPLING_TOP_P),
3296            top_k: request
3297                .top_k
3298                .filter(|value| *value > 0)
3299                .and_then(|value| usize::try_from(value).ok()),
3300            repetition_penalty: request
3301                .repetition_penalty
3302                .unwrap_or(DEFAULT_CHAT_REPETITION_PENALTY),
3303            presence_penalty: request.presence_penalty.unwrap_or(0.0),
3304            frequency_penalty: request.frequency_penalty.unwrap_or(0.0),
3305            stop_sequences: request.stop.clone().unwrap_or_default(),
3306            seed: request.seed,
3307            min_p: request.min_p.filter(|value| *value > 0.0),
3308            tfs: None,
3309            typical_p: None,
3310            mirostat: None,
3311            response_format,
3312            structured_output_start,
3313            response_completion_boundary,
3314        },
3315        stream: request.stream.unwrap_or(false),
3316        priority: Priority::Normal, // Default priority
3317        client_id: None,
3318        session_id: None,
3319        created_at: chrono::Utc::now(),
3320        api_request: Some(ferrum_types::ApiRequest::Chat(api_chat)),
3321        evidence_request: Default::default(),
3322        metadata,
3323    })
3324}
3325
3326fn push_unique_forbidden_token_text(tokens: &mut Vec<String>, token: &str) {
3327    if !token.is_empty() && !tokens.iter().any(|existing| existing == token) {
3328        tokens.push(token.to_string());
3329    }
3330}
3331
3332fn default_auto_tool_choice_for_tools(
3333    tools: &[ChatTool],
3334    choice: Option<&ToolChoice>,
3335) -> Option<ToolChoice> {
3336    if choice.is_none() && !tools.is_empty() {
3337        Some(ToolChoice::Mode("auto".to_string()))
3338    } else {
3339        None
3340    }
3341}
3342
3343fn tool_choice_none(choice: Option<&ToolChoice>) -> bool {
3344    matches!(choice, Some(ToolChoice::Mode(mode)) if mode.eq_ignore_ascii_case("none"))
3345}
3346
3347fn tool_choice_none_hides_tools(
3348    choice: Option<&ToolChoice>,
3349    model_template: Option<&ModelChatTemplate>,
3350) -> bool {
3351    tool_choice_none(choice)
3352        && model_template
3353            .map(|template| template.template.contains("tools_in_user_message"))
3354            .unwrap_or(false)
3355}
3356
3357fn chat_template_options_for_request(
3358    request: &ChatCompletionsRequest,
3359    model_template: Option<&ModelChatTemplate>,
3360    default_enable_thinking: Option<bool>,
3361) -> ferrum_types::Result<ChatTemplateOptions> {
3362    let mut options = ChatTemplateOptions::default_for_template(model_template);
3363    options.enable_thinking = default_enable_thinking;
3364    let Some(kwargs) = request.chat_template_kwargs.as_ref() else {
3365        return Ok(options);
3366    };
3367    let Some(value) = kwargs.get("enable_thinking") else {
3368        return Ok(options);
3369    };
3370    let Some(enable_thinking) = value.as_bool() else {
3371        return Err(Error::invalid_request(
3372            "chat_template_kwargs.enable_thinking must be a boolean",
3373        ));
3374    };
3375    options.enable_thinking = Some(enable_thinking);
3376    Ok(options)
3377}
3378
3379fn render_messages_with_response_format_instruction(
3380    request: &ChatCompletionsRequest,
3381    output_contract: EffectiveChatOutputContract,
3382    reasoning_enabled: bool,
3383) -> Vec<ChatMessage> {
3384    let Some(instruction) =
3385        response_format_prompt_instruction(request, output_contract, reasoning_enabled)
3386    else {
3387        return request.messages.clone();
3388    };
3389    let mut messages = Vec::with_capacity(request.messages.len() + 1);
3390    messages.push(ChatMessage {
3391        role: MessageRole::System,
3392        content: instruction,
3393        reasoning: None,
3394        name: None,
3395        tool_calls: None,
3396        tool_call_id: None,
3397        function_call: None,
3398    });
3399    messages.extend(request.messages.clone());
3400    messages
3401}
3402
3403fn response_format_prompt_instruction(
3404    request: &ChatCompletionsRequest,
3405    output_contract: EffectiveChatOutputContract,
3406    reasoning_enabled: bool,
3407) -> Option<String> {
3408    if !output_contract.accepts_requested_response_format() {
3409        return None;
3410    }
3411    if let Some(format) = request.response_format.as_ref() {
3412        return match format.format_type.as_str() {
3413            "json_object" => Some(
3414                "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."
3415                    .to_string(),
3416            ),
3417            "json_schema" => {
3418                let schema = format.json_schema.as_ref()?.schema.as_ref()?;
3419                let schema_text = serde_json::to_string(schema).ok()?;
3420                Some(if reasoning_enabled {
3421                    format!(
3422                        "The response_format requires a single valid JSON value satisfying this JSON Schema. Complete any enabled reasoning before the final answer. In the final answer, output only JSON, with no markdown fences, no explanation, and no extra text. Schema: {schema_text}"
3423                    )
3424                } else {
3425                    format!(
3426                        "The response_format requires a single valid JSON value satisfying this JSON Schema. Output only JSON, with no markdown fences, no explanation, no chain-of-thought, and no extra text. Schema: {schema_text}"
3427                    )
3428                })
3429            }
3430            _ => None,
3431        };
3432    }
3433    None
3434}
3435
3436fn forced_tool_choice_response_format(
3437    request: &ChatCompletionsRequest,
3438) -> Option<ferrum_types::ResponseFormat> {
3439    let selected_tool = selected_tool_for_forced_tool_choice(request)?;
3440    let schema = guided_tool_arguments_schema(selected_tool.function.parameters.as_ref())?;
3441    serde_json::to_string(&schema)
3442        .ok()
3443        .map(ferrum_types::ResponseFormat::JsonSchema)
3444}
3445
3446fn requested_response_format_for_sampling(
3447    request: &ChatCompletionsRequest,
3448) -> ferrum_types::Result<Option<ferrum_types::ResponseFormat>> {
3449    let Some(format) = request.response_format.as_ref() else {
3450        return Ok(None);
3451    };
3452    match format.format_type.as_str() {
3453        "json_object" => Ok(Some(ferrum_types::ResponseFormat::JsonObject)),
3454        "json_schema" => {
3455            let Some(schema) = format.json_schema.as_ref() else {
3456                return Err(Error::invalid_request(
3457                    "response_format.json_schema.schema is required",
3458                ));
3459            };
3460            if !schema.strict.unwrap_or(false) {
3461                return Ok(None);
3462            }
3463            let Some(schema_value) = schema.schema.as_ref() else {
3464                return Err(Error::invalid_request(
3465                    "response_format.json_schema.schema is required",
3466                ));
3467            };
3468            serde_json::to_string(schema_value)
3469                .map(|schema| Some(ferrum_types::ResponseFormat::JsonSchema(schema)))
3470                .map_err(|err| Error::invalid_request(err.to_string()))
3471        }
3472        _ => Ok(None),
3473    }
3474}
3475
3476fn selected_tool_for_forced_tool_choice(request: &ChatCompletionsRequest) -> Option<&ChatTool> {
3477    match request.tool_choice.as_ref()? {
3478        ToolChoice::Function {
3479            tool_type,
3480            function,
3481        } if tool_type == "function" => request
3482            .tools
3483            .as_ref()?
3484            .iter()
3485            .find(|tool| tool.function.name == function.name),
3486        ToolChoice::Mode(mode) if mode.eq_ignore_ascii_case("required") => {
3487            single_function_tool(request.tools.as_deref()?)
3488        }
3489        _ => None,
3490    }
3491}
3492
3493fn guided_tool_arguments_schema(
3494    parameters: Option<&serde_json::Value>,
3495) -> Option<serde_json::Value> {
3496    let mut schema = parameters?.clone();
3497    bound_unconstrained_tool_argument_strings(
3498        &mut schema,
3499        DEFAULT_GUIDED_TOOL_ARGUMENT_STRING_MAX_LENGTH,
3500    );
3501    Some(schema)
3502}
3503
3504fn bound_unconstrained_tool_argument_strings(value: &mut serde_json::Value, default_max: u64) {
3505    match value {
3506        serde_json::Value::Object(map) => {
3507            let is_string = map
3508                .get("type")
3509                .and_then(serde_json::Value::as_str)
3510                .is_some_and(|ty| ty == "string");
3511            let has_finite_string_shape = map.contains_key("enum") || map.contains_key("maxLength");
3512            if is_string && !has_finite_string_shape {
3513                map.insert(
3514                    "maxLength".to_string(),
3515                    serde_json::Value::Number(default_max.into()),
3516                );
3517            }
3518            if let Some(properties) = map
3519                .get_mut("properties")
3520                .and_then(serde_json::Value::as_object_mut)
3521            {
3522                for property in properties.values_mut() {
3523                    bound_unconstrained_tool_argument_strings(property, default_max);
3524                }
3525            }
3526            if let Some(items) = map.get_mut("items") {
3527                bound_unconstrained_tool_argument_strings(items, default_max);
3528            }
3529        }
3530        serde_json::Value::Array(items) => {
3531            for item in items {
3532                bound_unconstrained_tool_argument_strings(item, default_max);
3533            }
3534        }
3535        _ => {}
3536    }
3537}
3538
3539fn single_function_tool(tools: &[ChatTool]) -> Option<&ChatTool> {
3540    let mut function_tools = tools.iter().filter(|tool| tool.tool_type == "function");
3541    let tool = function_tools.next()?;
3542    function_tools.next().is_none().then_some(tool)
3543}
3544
3545fn should_defer_reasoning_stream_delta(text: &str) -> bool {
3546    let candidate = text.trim_start_matches(['\r', '\n']);
3547    if candidate.is_empty() {
3548        return true;
3549    }
3550    THINK_START_TAG.starts_with(candidate) || THINK_END_TAG.starts_with(candidate)
3551}
3552
3553fn stream_text_delta(text: &str, sent_len: &mut usize) -> String {
3554    if *sent_len <= text.len() && text.is_char_boundary(*sent_len) {
3555        let delta = text[*sent_len..].to_string();
3556        *sent_len = text.len();
3557        return delta;
3558    }
3559    *sent_len = text.len();
3560    String::new()
3561}
3562
3563fn chat_api_response_from_parsed_generated_text(
3564    chat_request: &ferrum_types::ApiChatRequest,
3565    parsed: &ParsedReasoningResponse,
3566    finish_reason: FinishReason,
3567) -> Option<ferrum_types::ApiChatResponse> {
3568    parsed
3569        .reasoning
3570        .as_deref()
3571        .and_then(|reasoning| {
3572            ferrum_types::chat_api_response_from_generated_text(
3573                chat_request,
3574                reasoning,
3575                finish_reason,
3576            )
3577        })
3578        .or_else(|| {
3579            ferrum_types::chat_api_response_from_generated_text(
3580                chat_request,
3581                &parsed.content,
3582                finish_reason,
3583            )
3584        })
3585}
3586
3587fn finish_reason_allows_structured_api_response(finish_reason: FinishReason) -> bool {
3588    matches!(finish_reason, FinishReason::Stop | FinishReason::EOS)
3589}
3590
3591fn log_required_tool_choice_failure(
3592    request: &ChatCompletionsRequest,
3593    content: &str,
3594    reasoning: Option<&str>,
3595) {
3596    warn!(
3597        model = %request.model,
3598        content_len = content.len(),
3599        content_head = %log_excerpt(content, 512),
3600        reasoning_len = reasoning.map(str::len).unwrap_or(0),
3601        reasoning_head = %reasoning.map(|value| log_excerpt(value, 512)).unwrap_or_default(),
3602        "model output did not satisfy required tool_choice"
3603    );
3604}
3605
3606fn log_excerpt(value: &str, max_chars: usize) -> String {
3607    let mut out = value.chars().take(max_chars).collect::<String>();
3608    if value.chars().count() > max_chars {
3609        out.push_str("...");
3610    }
3611    out
3612}
3613
3614fn normalize_structured_response_content(
3615    request: &ChatCompletionsRequest,
3616    content: &str,
3617) -> String {
3618    match EffectiveChatOutputContract::resolve(request) {
3619        EffectiveChatOutputContract::BestEffortJsonSchemaContent => {
3620            extract_json_object_text(content)
3621                .unwrap_or_else(|| strip_markdown_json_fence(content).to_string())
3622        }
3623        EffectiveChatOutputContract::RequiredToolCall
3624        | EffectiveChatOutputContract::StrictJsonSchemaContent
3625        | EffectiveChatOutputContract::JsonObjectContent
3626        | EffectiveChatOutputContract::Text => content.to_string(),
3627    }
3628}
3629
3630fn extract_json_object_text(text: &str) -> Option<String> {
3631    let text = strip_markdown_json_fence(text.trim());
3632    if serde_json::from_str::<serde_json::Value>(&text)
3633        .ok()
3634        .filter(|value| value.is_object())
3635        .is_some()
3636    {
3637        return Some(text.to_string());
3638    }
3639
3640    let start = text.find('{')?;
3641    let mut depth = 0usize;
3642    let mut in_string = false;
3643    let mut escaped = false;
3644    for (offset, ch) in text[start..].char_indices() {
3645        if in_string {
3646            if escaped {
3647                escaped = false;
3648            } else if ch == '\\' {
3649                escaped = true;
3650            } else if ch == '"' {
3651                in_string = false;
3652            }
3653            continue;
3654        }
3655        match ch {
3656            '"' => in_string = true,
3657            '{' => depth += 1,
3658            '}' => {
3659                depth = depth.saturating_sub(1);
3660                if depth == 0 {
3661                    let end = start + offset + ch.len_utf8();
3662                    let candidate = &text[start..end];
3663                    if serde_json::from_str::<serde_json::Value>(candidate)
3664                        .ok()
3665                        .filter(|value| value.is_object())
3666                        .is_some()
3667                    {
3668                        return Some(candidate.to_string());
3669                    }
3670                }
3671            }
3672            _ => {}
3673        }
3674    }
3675    None
3676}
3677
3678fn api_chat_request(
3679    request: &ChatCompletionsRequest,
3680    effective_tool_choice: Option<&ToolChoice>,
3681    tool_call_protocol: ferrum_types::ApiToolCallProtocol,
3682) -> ferrum_types::ApiChatRequest {
3683    ferrum_types::ApiChatRequest {
3684        messages: request.messages.iter().map(api_chat_message).collect(),
3685        tools: request
3686            .tools
3687            .as_deref()
3688            .unwrap_or_default()
3689            .iter()
3690            .map(api_tool)
3691            .collect(),
3692        tool_choice: effective_tool_choice.map(api_tool_choice),
3693        tool_call_protocol,
3694        legacy_functions: request
3695            .functions
3696            .as_deref()
3697            .unwrap_or_default()
3698            .iter()
3699            .map(api_function)
3700            .collect(),
3701        legacy_function_call: request.function_call.as_ref().map(api_function_call_choice),
3702        response_format: request.response_format.as_ref().map(api_response_format),
3703        stream_options: request.stream_options.as_ref().map(|opts| {
3704            ferrum_types::ApiStreamOptions {
3705                include_usage: opts.include_usage,
3706            }
3707        }),
3708    }
3709}
3710
3711fn api_chat_message(message: &ChatMessage) -> ferrum_types::ApiChatMessage {
3712    ferrum_types::ApiChatMessage {
3713        role: match message.role {
3714            MessageRole::System => ferrum_types::ApiMessageRole::System,
3715            MessageRole::User => ferrum_types::ApiMessageRole::User,
3716            MessageRole::Assistant => ferrum_types::ApiMessageRole::Assistant,
3717            MessageRole::Function => ferrum_types::ApiMessageRole::Function,
3718            MessageRole::Tool => ferrum_types::ApiMessageRole::Tool,
3719        },
3720        content: message.content.clone(),
3721        name: message.name.clone(),
3722        tool_calls: message
3723            .tool_calls
3724            .as_deref()
3725            .unwrap_or_default()
3726            .iter()
3727            .map(api_tool_call)
3728            .collect(),
3729        tool_call_id: message.tool_call_id.clone(),
3730        function_call: message.function_call.as_ref().map(api_function_call),
3731    }
3732}
3733
3734fn api_tool(tool: &ChatTool) -> ferrum_types::ApiTool {
3735    ferrum_types::ApiTool {
3736        tool_type: tool.tool_type.clone(),
3737        function: api_function(&tool.function),
3738    }
3739}
3740
3741fn api_function(function: &ChatFunction) -> ferrum_types::ApiFunction {
3742    ferrum_types::ApiFunction {
3743        name: function.name.clone(),
3744        description: function.description.clone(),
3745        parameters: function.parameters.clone(),
3746        strict: function.strict,
3747    }
3748}
3749
3750fn api_tool_choice(choice: &ToolChoice) -> ferrum_types::ApiToolChoice {
3751    match choice {
3752        ToolChoice::Mode(mode) => ferrum_types::ApiToolChoice::Mode(mode.clone()),
3753        ToolChoice::Function {
3754            tool_type,
3755            function,
3756        } => ferrum_types::ApiToolChoice::Function {
3757            tool_type: tool_type.clone(),
3758            function: ferrum_types::ApiToolChoiceFunction {
3759                name: function.name.clone(),
3760            },
3761        },
3762    }
3763}
3764
3765fn api_function_call_choice(choice: &FunctionCallChoice) -> ferrum_types::ApiFunctionCallChoice {
3766    match choice {
3767        FunctionCallChoice::Mode(mode) => ferrum_types::ApiFunctionCallChoice::Mode(mode.clone()),
3768        FunctionCallChoice::Function { name } => {
3769            ferrum_types::ApiFunctionCallChoice::Function { name: name.clone() }
3770        }
3771    }
3772}
3773
3774fn api_tool_call(tool_call: &ChatToolCall) -> ferrum_types::ApiToolCall {
3775    ferrum_types::ApiToolCall {
3776        id: tool_call.id.clone(),
3777        tool_type: tool_call.tool_type.clone(),
3778        function: api_function_call(&tool_call.function),
3779    }
3780}
3781
3782fn api_function_call(function_call: &ChatFunctionCall) -> ferrum_types::ApiFunctionCall {
3783    ferrum_types::ApiFunctionCall {
3784        name: function_call.name.clone(),
3785        arguments: function_call.arguments.clone(),
3786    }
3787}
3788
3789fn openai_chat_message_from_api(message: &ferrum_types::ApiChatMessage) -> ChatMessage {
3790    ChatMessage {
3791        role: openai_message_role_from_api(message.role),
3792        content: message.content.clone(),
3793        reasoning: None,
3794        name: message.name.clone(),
3795        tool_calls: if message.tool_calls.is_empty() {
3796            None
3797        } else {
3798            Some(
3799                message
3800                    .tool_calls
3801                    .iter()
3802                    .map(openai_tool_call_from_api)
3803                    .collect(),
3804            )
3805        },
3806        tool_call_id: message.tool_call_id.clone(),
3807        function_call: message
3808            .function_call
3809            .as_ref()
3810            .map(openai_function_call_from_api),
3811    }
3812}
3813
3814fn openai_message_role_from_api(role: ferrum_types::ApiMessageRole) -> MessageRole {
3815    match role {
3816        ferrum_types::ApiMessageRole::System => MessageRole::System,
3817        ferrum_types::ApiMessageRole::User => MessageRole::User,
3818        ferrum_types::ApiMessageRole::Assistant => MessageRole::Assistant,
3819        ferrum_types::ApiMessageRole::Function => MessageRole::Function,
3820        ferrum_types::ApiMessageRole::Tool => MessageRole::Tool,
3821    }
3822}
3823
3824fn openai_tool_call_from_api(tool_call: &ferrum_types::ApiToolCall) -> ChatToolCall {
3825    ChatToolCall {
3826        index: None,
3827        id: tool_call.id.clone(),
3828        tool_type: tool_call.tool_type.clone(),
3829        function: openai_function_call_from_api(&tool_call.function),
3830    }
3831}
3832
3833fn openai_tool_call_delta_from_api(
3834    index: usize,
3835    tool_call: &ferrum_types::ApiToolCall,
3836) -> ChatToolCall {
3837    ChatToolCall {
3838        index: Some(usize_to_u32_saturating(index)),
3839        id: tool_call.id.clone(),
3840        tool_type: tool_call.tool_type.clone(),
3841        function: openai_function_call_from_api(&tool_call.function),
3842    }
3843}
3844
3845fn openai_chat_delta_from_api(message: &ferrum_types::ApiChatMessage) -> ChatMessage {
3846    let mut delta = openai_chat_message_from_api(message);
3847    if !message.tool_calls.is_empty() {
3848        delta.tool_calls = Some(
3849            message
3850                .tool_calls
3851                .iter()
3852                .enumerate()
3853                .map(|(index, call)| openai_tool_call_delta_from_api(index, call))
3854                .collect(),
3855        );
3856    }
3857    delta
3858}
3859
3860fn openai_function_call_from_api(
3861    function_call: &ferrum_types::ApiFunctionCall,
3862) -> ChatFunctionCall {
3863    ChatFunctionCall {
3864        name: function_call.name.clone(),
3865        arguments: function_call.arguments.clone(),
3866    }
3867}
3868
3869fn api_response_format(format: &OpenAiResponseFormat) -> ferrum_types::ApiResponseFormat {
3870    ferrum_types::ApiResponseFormat {
3871        format_type: format.format_type.clone(),
3872        json_schema: format
3873            .json_schema
3874            .as_ref()
3875            .map(|schema| ferrum_types::ApiJsonSchema {
3876                name: schema.name.clone(),
3877                schema: schema.schema.clone().unwrap_or(serde_json::Value::Null),
3878                strict: schema.strict,
3879            }),
3880    }
3881}
3882
3883fn validate_chat_request(request: &ChatCompletionsRequest) -> std::result::Result<(), ServerError> {
3884    if request.messages.is_empty() {
3885        return Err(ServerError::invalid_request(
3886            "messages array must not be empty",
3887            Some("messages"),
3888        ));
3889    }
3890
3891    if let Some(n) = request.n {
3892        if n != 1 {
3893            return Err(ServerError::unsupported_feature(
3894                "only n=1 is supported for chat completions",
3895                Some("n"),
3896            ));
3897        }
3898    }
3899
3900    if request
3901        .logit_bias
3902        .as_ref()
3903        .is_some_and(|bias| !bias.is_empty())
3904    {
3905        return Err(ServerError::unsupported_feature(
3906            "logit_bias is not supported",
3907            Some("logit_bias"),
3908        ));
3909    }
3910    if request.logprobs.unwrap_or(false) {
3911        return Err(ServerError::unsupported_feature(
3912            "logprobs is not supported",
3913            Some("logprobs"),
3914        ));
3915    }
3916    if request.top_logprobs.unwrap_or(0) > 0 {
3917        return Err(ServerError::unsupported_feature(
3918            "top_logprobs is not supported",
3919            Some("top_logprobs"),
3920        ));
3921    }
3922
3923    if let Some(top_k) = request.top_k {
3924        if top_k < -1 {
3925            return Err(ServerError::invalid_request(
3926                "top_k must be -1, 0, or a positive integer",
3927                Some("top_k"),
3928            ));
3929        }
3930    }
3931    if let Some(min_p) = request.min_p {
3932        if !min_p.is_finite() || !(0.0..=1.0).contains(&min_p) {
3933            return Err(ServerError::invalid_request(
3934                "min_p must be in range [0, 1]",
3935                Some("min_p"),
3936            ));
3937        }
3938    }
3939    if let Some(repetition_penalty) = request.repetition_penalty {
3940        if !repetition_penalty.is_finite() || repetition_penalty <= 0.0 {
3941            return Err(ServerError::invalid_request(
3942                "repetition_penalty must be positive",
3943                Some("repetition_penalty"),
3944            ));
3945        }
3946    }
3947    if let Some(presence_penalty) = request.presence_penalty {
3948        if !presence_penalty.is_finite() || !(-2.0..=2.0).contains(&presence_penalty) {
3949            return Err(ServerError::invalid_request(
3950                "presence_penalty must be in range [-2, 2]",
3951                Some("presence_penalty"),
3952            ));
3953        }
3954    }
3955    if let Some(frequency_penalty) = request.frequency_penalty {
3956        if !frequency_penalty.is_finite() || !(-2.0..=2.0).contains(&frequency_penalty) {
3957            return Err(ServerError::invalid_request(
3958                "frequency_penalty must be in range [-2, 2]",
3959                Some("frequency_penalty"),
3960            ));
3961        }
3962    }
3963
3964    if request.stream_options.is_some() && !request.stream.unwrap_or(false) {
3965        return Err(ServerError::invalid_request(
3966            "stream_options is only valid when stream=true",
3967            Some("stream_options"),
3968        ));
3969    }
3970    ensure_response_format_supported(request)?;
3971
3972    if let Some(tools) = &request.tools {
3973        for tool in tools {
3974            if tool.tool_type != "function" {
3975                return Err(ServerError::unsupported_feature(
3976                    "only function tools are supported",
3977                    Some("tools"),
3978                ));
3979            }
3980        }
3981    }
3982
3983    if let Some(choice) = &request.tool_choice {
3984        match choice {
3985            ToolChoice::Mode(mode)
3986                if mode.eq_ignore_ascii_case("auto") || mode.eq_ignore_ascii_case("none") => {}
3987            ToolChoice::Mode(mode) if mode.eq_ignore_ascii_case("required") => {
3988                if request.tools.as_deref().unwrap_or_default().is_empty() {
3989                    return Err(ServerError::invalid_request(
3990                        "tool_choice=required requires at least one function tool",
3991                        Some("tool_choice"),
3992                    ));
3993                }
3994            }
3995            ToolChoice::Mode(_) => {
3996                return Err(ServerError::unsupported_feature(
3997                    "unsupported tool_choice mode",
3998                    Some("tool_choice"),
3999                ));
4000            }
4001            ToolChoice::Function {
4002                tool_type,
4003                function,
4004            } => {
4005                if tool_type != "function" {
4006                    return Err(ServerError::unsupported_feature(
4007                        "only function tool_choice is supported",
4008                        Some("tool_choice"),
4009                    ));
4010                }
4011                let declared = request
4012                    .tools
4013                    .as_deref()
4014                    .unwrap_or_default()
4015                    .iter()
4016                    .any(|tool| tool.function.name == function.name);
4017                if !declared {
4018                    return Err(ServerError::invalid_request(
4019                        "tool_choice selects a function that is not declared in tools",
4020                        Some("tool_choice"),
4021                    ));
4022                }
4023            }
4024        }
4025    }
4026
4027    if let Some(choice) = &request.function_call {
4028        match choice {
4029            FunctionCallChoice::Mode(mode)
4030                if mode.eq_ignore_ascii_case("auto") || mode.eq_ignore_ascii_case("none") => {}
4031            FunctionCallChoice::Mode(_) => {
4032                return Err(ServerError::unsupported_feature(
4033                    "unsupported function_call mode",
4034                    Some("function_call"),
4035                ));
4036            }
4037            FunctionCallChoice::Function { name } => {
4038                let declared = request
4039                    .functions
4040                    .as_deref()
4041                    .unwrap_or_default()
4042                    .iter()
4043                    .any(|function| function.name == *name);
4044                if !declared {
4045                    return Err(ServerError::invalid_request(
4046                        "function_call selects a function that is not declared in functions",
4047                        Some("function_call"),
4048                    ));
4049                }
4050            }
4051        }
4052    }
4053
4054    Ok(())
4055}
4056
4057fn tool_choice_required(request: &ChatCompletionsRequest) -> bool {
4058    match request.tool_choice.as_ref() {
4059        Some(ToolChoice::Mode(mode)) if mode.eq_ignore_ascii_case("required") => true,
4060        Some(ToolChoice::Function {
4061            tool_type,
4062            function,
4063        }) => {
4064            tool_type == "function"
4065                && request
4066                    .tools
4067                    .as_deref()
4068                    .unwrap_or_default()
4069                    .iter()
4070                    .any(|tool| tool.function.name == function.name)
4071        }
4072        _ => false,
4073    }
4074}
4075
4076fn openai_usage_from_token_usage(usage: &TokenUsage) -> Usage {
4077    let prompt_tokens = usize_to_u32_saturating(usage.prompt_tokens);
4078    let completion_tokens = usize_to_u32_saturating(usage.completion_tokens);
4079    let total_tokens = usize_to_u32_saturating(usage.total_tokens);
4080    Usage {
4081        prompt_tokens,
4082        completion_tokens,
4083        total_tokens,
4084    }
4085}
4086
4087fn usize_to_u32_saturating(value: usize) -> u32 {
4088    u32::try_from(value).unwrap_or(u32::MAX)
4089}
4090
4091fn ensure_response_format_supported(
4092    request: &ChatCompletionsRequest,
4093) -> std::result::Result<(), ServerError> {
4094    if let Some(rf) = &request.response_format {
4095        match rf.format_type.as_str() {
4096            "text" | "json_object" => {}
4097            "json_schema" => {
4098                let Some(schema_config) = rf.json_schema.as_ref() else {
4099                    return Err(ServerError::invalid_request(
4100                        "response_format.json_schema.schema is required",
4101                        Some("response_format.json_schema"),
4102                    ));
4103                };
4104                let Some(schema) = schema_config.schema.as_ref() else {
4105                    return Err(ServerError::invalid_request(
4106                        "response_format.json_schema.schema is required",
4107                        Some("response_format.json_schema"),
4108                    ));
4109                };
4110                if schema_config.strict.unwrap_or(false) {
4111                    compiled_json_schema_validator(schema).map_err(|reason| {
4112                        ServerError::invalid_request(
4113                            format!("unsupported strict json_schema: {reason}"),
4114                            Some("response_format.json_schema"),
4115                        )
4116                    })?;
4117                }
4118            }
4119            _ => {
4120                return Err(ServerError::invalid_request(
4121                    "unsupported response_format.type",
4122                    Some("response_format.type"),
4123                ));
4124            }
4125        }
4126    }
4127    Ok(())
4128}
4129
4130fn strict_json_schema_string(
4131    request: &ChatCompletionsRequest,
4132) -> std::result::Result<Option<String>, ServerError> {
4133    let Some(rf) = &request.response_format else {
4134        return Ok(None);
4135    };
4136    if rf.format_type != "json_schema" {
4137        return Ok(None);
4138    }
4139    let Some(schema) = &rf.json_schema else {
4140        return Err(ServerError::invalid_request(
4141            "response_format.json_schema.schema is required",
4142            Some("response_format.json_schema"),
4143        ));
4144    };
4145    let Some(schema_value) = schema.schema.as_ref() else {
4146        return Err(ServerError::invalid_request(
4147            "response_format.json_schema.schema is required",
4148            Some("response_format.json_schema"),
4149        ));
4150    };
4151    if !schema.strict.unwrap_or(false) {
4152        return Ok(None);
4153    }
4154    serde_json::to_string(schema_value).map(Some).map_err(|e| {
4155        ServerError::invalid_request(e.to_string(), Some("response_format.json_schema"))
4156    })
4157}
4158
4159fn validate_hard_structured_response(
4160    request: &ChatCompletionsRequest,
4161    content: &str,
4162) -> std::result::Result<(), ServerError> {
4163    match EffectiveChatOutputContract::resolve(request) {
4164        EffectiveChatOutputContract::JsonObjectContent => {
4165            let value = serde_json::from_str::<serde_json::Value>(content).map_err(|error| {
4166                ServerError::InternalError(format!(
4167                    "model output did not satisfy response_format.json_object: invalid JSON: {error}"
4168                ))
4169            })?;
4170            if !value.is_object() {
4171                return Err(ServerError::InternalError(
4172                    "model output did not satisfy response_format.json_object: root must be an object"
4173                        .to_string(),
4174                ));
4175            }
4176            Ok(())
4177        }
4178        EffectiveChatOutputContract::StrictJsonSchemaContent => {
4179            let Some(schema_json) = strict_json_schema_string(request)? else {
4180                return Ok(());
4181            };
4182            let schema: serde_json::Value = serde_json::from_str(&schema_json).map_err(|e| {
4183                ServerError::InternalError(format!(
4184                    "strict json_schema could not be reconstructed after request validation: {e}"
4185                ))
4186            })?;
4187            validate_json_text_against_schema(&schema, content).map_err(|reason| {
4188                ServerError::InternalError(format!(
4189                    "model output did not satisfy response_format.json_schema.strict: {reason}"
4190                ))
4191            })
4192        }
4193        EffectiveChatOutputContract::RequiredToolCall
4194        | EffectiveChatOutputContract::BestEffortJsonSchemaContent
4195        | EffectiveChatOutputContract::Text => Ok(()),
4196    }
4197}
4198
4199fn structured_response_error_param(contract: EffectiveChatOutputContract) -> Option<&'static str> {
4200    match contract {
4201        EffectiveChatOutputContract::JsonObjectContent => Some("response_format"),
4202        EffectiveChatOutputContract::StrictJsonSchemaContent => Some("response_format.json_schema"),
4203        _ => None,
4204    }
4205}
4206
4207fn validate_structured_tool_response(
4208    request: &ChatCompletionsRequest,
4209    response: &ferrum_types::ApiChatResponse,
4210) -> std::result::Result<(), ServerError> {
4211    let required = tool_choice_required(request);
4212    if response.message.tool_calls.is_empty() {
4213        if required {
4214            return Err(ServerError::invalid_request(
4215                "model output did not satisfy required tool_choice",
4216                Some("tool_choice"),
4217            ));
4218        }
4219        return Ok(());
4220    }
4221
4222    if required {
4223        if !response.message.content.trim().is_empty() {
4224            return Err(ServerError::InternalError(
4225                "required tool response contained assistant content".to_string(),
4226            ));
4227        }
4228        if response.finish_reason.as_deref() != Some("tool_calls") {
4229            return Err(ServerError::InternalError(
4230                "required tool response did not finish with tool_calls".to_string(),
4231            ));
4232        }
4233    }
4234
4235    let tools = request.tools.as_deref().unwrap_or_default();
4236    for call in &response.message.tool_calls {
4237        if call.tool_type != "function" {
4238            return Err(ServerError::InternalError(format!(
4239                "model emitted unsupported tool call type '{}'",
4240                call.tool_type
4241            )));
4242        }
4243        let Some(tool) = tools
4244            .iter()
4245            .find(|tool| tool.tool_type == "function" && tool.function.name == call.function.name)
4246        else {
4247            return Err(ServerError::InternalError(format!(
4248                "model emitted undeclared tool call '{}'",
4249                call.function.name
4250            )));
4251        };
4252        if let Some(ToolChoice::Function {
4253            tool_type,
4254            function,
4255        }) = request.tool_choice.as_ref()
4256        {
4257            if tool_type != "function" || function.name != call.function.name {
4258                return Err(ServerError::InternalError(format!(
4259                    "model emitted tool '{}' instead of selected tool '{}'",
4260                    call.function.name, function.name
4261                )));
4262            }
4263        }
4264
4265        let arguments: serde_json::Value =
4266            serde_json::from_str(&call.function.arguments).map_err(|e| {
4267                ServerError::InternalError(format!(
4268                    "model emitted invalid JSON arguments for tool '{}': {e}",
4269                    call.function.name
4270                ))
4271            })?;
4272        if !arguments.is_object() {
4273            return Err(ServerError::InternalError(format!(
4274                "model emitted non-object arguments for tool '{}'",
4275                call.function.name
4276            )));
4277        }
4278        if let Some(schema) = tool.function.parameters.as_ref() {
4279            validate_json_text_against_schema(schema, &call.function.arguments).map_err(
4280                |reason| {
4281                    ServerError::InternalError(format!(
4282                        "model arguments for tool '{}' did not satisfy its schema: {reason}",
4283                        call.function.name
4284                    ))
4285                },
4286            )?;
4287        }
4288    }
4289    Ok(())
4290}
4291
4292fn validate_json_text_against_schema(
4293    schema: &serde_json::Value,
4294    content: &str,
4295) -> std::result::Result<(), String> {
4296    let value = serde_json::from_str::<serde_json::Value>(content)
4297        .map_err(|e| format!("invalid JSON: {e}"))?;
4298    compiled_json_schema_validator(schema)?
4299        .validate(&value)
4300        .map_err(|error| error.to_string())
4301}
4302
4303fn compiled_json_schema_validator(
4304    schema: &serde_json::Value,
4305) -> std::result::Result<Arc<jsonschema::Validator>, String> {
4306    let cache_key = serde_json::to_string(schema)
4307        .map_err(|error| format!("could not serialize JSON Schema: {error}"))?;
4308    let cache = JSON_SCHEMA_VALIDATOR_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
4309    let mut validators = cache
4310        .lock()
4311        .map_err(|_| "JSON Schema validator cache lock was poisoned".to_string())?;
4312    if let Some(validator) = validators.get(&cache_key) {
4313        return Ok(Arc::clone(validator));
4314    }
4315
4316    let validator = Arc::new(
4317        jsonschema::validator_for(schema)
4318            .map_err(|error| format!("could not compile JSON Schema: {error}"))?,
4319    );
4320    if validators.len() >= MAX_CACHED_JSON_SCHEMA_VALIDATORS {
4321        validators.clear();
4322    }
4323    validators.insert(cache_key, Arc::clone(&validator));
4324    Ok(validator)
4325}
4326
4327fn stream_validation_error_message(error: ServerError) -> String {
4328    match error {
4329        ServerError::InternalError(message)
4330        | ServerError::NotImplemented(message)
4331        | ServerError::ServiceUnavailable(message)
4332        | ServerError::InvalidRequest { message, .. }
4333        | ServerError::UnsupportedFeature { message, .. } => message,
4334    }
4335}
4336
4337fn server_error_from_ferrum_error(error: Error) -> ServerError {
4338    match error {
4339        Error::RequestValidation { message } => ServerError::invalid_request(message, None),
4340        Error::ResourceExhausted { message } => ServerError::ServiceUnavailable(message),
4341        other => ServerError::InternalError(other.to_string()),
4342    }
4343}
4344
4345fn stream_error_payload(
4346    message: impl Into<String>,
4347    error_type: &str,
4348    param: Option<&str>,
4349) -> OpenAiError {
4350    OpenAiError {
4351        error: OpenAiErrorDetail {
4352            message: message.into(),
4353            error_type: error_type.to_string(),
4354            param: param.map(str::to_string),
4355            code: None,
4356        },
4357    }
4358}
4359
4360fn openai_error_sse_event(
4361    message: impl Into<String>,
4362    error_type: &str,
4363    param: Option<&str>,
4364) -> Event {
4365    Event::default()
4366        .json_data(&stream_error_payload(message, error_type, param))
4367        .unwrap_or_else(|_| Event::default().data("error"))
4368}
4369
4370fn convert_completion_request(request: &CompletionsRequest) -> InferenceRequest {
4371    let prompt = request
4372        .prompt
4373        .as_text()
4374        .expect("completion prompt validated before conversion");
4375    InferenceRequest {
4376        id: RequestId(Uuid::new_v4()),
4377        model_id: ModelId(request.model.clone()),
4378        prompt: prompt.to_string(),
4379        sampling_params: SamplingParams {
4380            max_tokens: request.max_tokens.unwrap_or(DEFAULT_COMPLETION_MAX_TOKENS) as usize,
4381            temperature: request.temperature.unwrap_or(DEFAULT_SAMPLING_TEMPERATURE),
4382            top_p: request.top_p.unwrap_or(DEFAULT_SAMPLING_TOP_P),
4383            top_k: None,
4384            repetition_penalty: 1.0,
4385            presence_penalty: 0.0,
4386            frequency_penalty: 0.0,
4387            stop_sequences: request.stop.clone().unwrap_or_default(),
4388            seed: None,
4389            min_p: None,
4390            tfs: None,
4391            typical_p: None,
4392            mirostat: None,
4393            response_format: ferrum_types::ResponseFormat::Text,
4394            structured_output_start: StructuredOutputStart::Immediate,
4395            response_completion_boundary: ResponseCompletionBoundary::Immediate,
4396        },
4397        stream: request.stream.unwrap_or(false),
4398        priority: Priority::Normal,
4399        client_id: None,
4400        session_id: None,
4401        created_at: chrono::Utc::now(),
4402        api_request: Some(ferrum_types::ApiRequest::Completion(
4403            ferrum_types::ApiCompletionRequest {
4404                prompt: prompt.to_string(),
4405                response_format: None,
4406            },
4407        )),
4408        evidence_request: Default::default(),
4409        metadata: if request.max_tokens.is_none() {
4410            HashMap::from([(
4411                DEFAULT_MAX_TOKENS_METADATA_KEY.to_string(),
4412                serde_json::json!(true),
4413            )])
4414        } else {
4415            HashMap::new()
4416        },
4417    }
4418}
4419
4420fn resolve_request_model<'a>(
4421    registry: &'a ServedModelRegistry,
4422    request_model: &str,
4423    required_kind: ServedModelKind,
4424) -> std::result::Result<(ModelId, Option<&'a LoraAdapterModel>), ServerError> {
4425    if registry.is_empty() {
4426        return Ok((ModelId::new(request_model), None));
4427    }
4428    let entry = registry
4429        .resolve(request_model, required_kind)
4430        .ok_or_else(|| {
4431            ServerError::invalid_request(format!("unknown model: {request_model}"), Some("model"))
4432        })?;
4433    Ok((entry.engine_model_id().clone(), entry.adapter()))
4434}
4435
4436fn apply_served_model_resolution(
4437    inference_request: &mut InferenceRequest,
4438    engine_model_id: ModelId,
4439    adapter: Option<&LoraAdapterModel>,
4440) {
4441    inference_request.model_id = engine_model_id;
4442    if let Some(adapter) = adapter {
4443        inference_request.metadata.insert(
4444            "ferrum_lora_adapter".to_string(),
4445            serde_json::json!(adapter.name),
4446        );
4447        inference_request.metadata.insert(
4448            "ferrum_lora_model_id".to_string(),
4449            serde_json::json!(adapter.model_id),
4450        );
4451        inference_request.metadata.insert(
4452            "ferrum_lora_path".to_string(),
4453            serde_json::json!(adapter.path),
4454        );
4455    }
4456}
4457
4458async fn handle_completions_sync(
4459    state: AppState,
4460    openai_request: CompletionsRequest,
4461    inference_request: InferenceRequest,
4462) -> std::result::Result<Response, ServerError> {
4463    let engine = state.llm.clone().ok_or_else(|| {
4464        ServerError::ServiceUnavailable("LLM engine not loaded; completions unavailable".into())
4465    })?;
4466    match engine.infer(inference_request).await {
4467        Ok(output) => {
4468            let InferenceResponse {
4469                text: output_text,
4470                finish_reason,
4471                usage,
4472                api_response,
4473                ..
4474            } = output;
4475            let stop_sequences = openai_request.stop.clone().unwrap_or_default();
4476            let mut text = strip_after_stop(&output_text, &stop_sequences);
4477            let mut openai_finish_reason = finish_reason_to_string(&finish_reason);
4478            if let Some(ferrum_types::ApiResponse::Completion(completion_response)) =
4479                api_response.as_ref()
4480            {
4481                text = strip_after_stop(&completion_response.text, &stop_sequences);
4482                if let Some(reason) = &completion_response.finish_reason {
4483                    openai_finish_reason = reason.clone();
4484                }
4485            }
4486            let response = CompletionsResponse {
4487                id: Uuid::new_v4().to_string(),
4488                object: "text_completion".to_string(),
4489                created: chrono::Utc::now().timestamp() as u64,
4490                model: openai_request.model,
4491                choices: vec![CompletionChoice {
4492                    text,
4493                    index: 0,
4494                    finish_reason: Some(openai_finish_reason),
4495                }],
4496                usage: Some(openai_usage_from_token_usage(&usage)),
4497            };
4498            Ok(Json(response).into_response())
4499        }
4500        Err(e) => {
4501            error!("Completion generation failed: {}", e);
4502            Err(ServerError::InternalError(e.to_string()))
4503        }
4504    }
4505}
4506
4507async fn handle_completions_stream(
4508    state: AppState,
4509    openai_request: CompletionsRequest,
4510    inference_request: InferenceRequest,
4511) -> std::result::Result<Response, ServerError> {
4512    let (tx, rx) = mpsc::unbounded_channel::<std::result::Result<Event, axum::Error>>();
4513    let engine = state.llm.clone().ok_or_else(|| {
4514        ServerError::ServiceUnavailable("LLM engine not loaded; completions unavailable".into())
4515    })?;
4516    let request_id = Uuid::new_v4().to_string();
4517
4518    tokio::spawn(async move {
4519        match engine.infer_stream(inference_request).await {
4520            Ok(mut stream) => {
4521                while let Some(result) = stream.next().await {
4522                    match result {
4523                        Ok(chunk) => {
4524                            let response_chunk = CompletionsResponse {
4525                                id: request_id.clone(),
4526                                object: "text_completion".to_string(),
4527                                created: chrono::Utc::now().timestamp() as u64,
4528                                model: openai_request.model.clone(),
4529                                choices: vec![CompletionChoice {
4530                                    text: chunk.text.clone(),
4531                                    index: 0,
4532                                    finish_reason: chunk
4533                                        .finish_reason
4534                                        .as_ref()
4535                                        .map(finish_reason_to_string),
4536                                }],
4537                                usage: None,
4538                            };
4539                            let event = Event::default()
4540                                .json_data(&response_chunk)
4541                                .unwrap_or_else(|_| Event::default().data("error"));
4542                            if tx.send(Ok(event)).is_err() {
4543                                break;
4544                            }
4545                            if chunk.finish_reason.is_some() {
4546                                if let Some(usage) =
4547                                    chunk.usage.as_ref().map(openai_usage_from_token_usage)
4548                                {
4549                                    let final_chunk = CompletionsResponse {
4550                                        id: request_id.clone(),
4551                                        object: "text_completion".to_string(),
4552                                        created: chrono::Utc::now().timestamp() as u64,
4553                                        model: openai_request.model.clone(),
4554                                        choices: vec![],
4555                                        usage: Some(usage),
4556                                    };
4557                                    let event = Event::default()
4558                                        .json_data(&final_chunk)
4559                                        .unwrap_or_else(|_| Event::default().data("error"));
4560                                    let _ = tx.send(Ok(event));
4561                                }
4562                                let _ = tx.send(Ok(Event::default().data("[DONE]")));
4563                                break;
4564                            }
4565                        }
4566                        Err(e) => {
4567                            error!("Completion stream generation error: {}", e);
4568                            let _ = tx.send(Ok(openai_error_sse_event(
4569                                e.to_string(),
4570                                "internal_server_error",
4571                                None,
4572                            )));
4573                            let _ = tx.send(Ok(Event::default().data("[DONE]")));
4574                            break;
4575                        }
4576                    }
4577                }
4578            }
4579            Err(e) => {
4580                error!("Failed to start completion stream: {}", e);
4581                let _ = tx.send(Ok(openai_error_sse_event(
4582                    e.to_string(),
4583                    "internal_server_error",
4584                    None,
4585                )));
4586                let _ = tx.send(Ok(Event::default().data("[DONE]")));
4587            }
4588        }
4589    });
4590
4591    let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
4592    Ok(Sse::new(stream).into_response())
4593}
4594
4595/// Other handlers
4596async fn completions_handler(
4597    State(state): State<AppState>,
4598    request: std::result::Result<Json<CompletionsRequest>, JsonRejection>,
4599) -> std::result::Result<Response, ServerError> {
4600    let Json(request) = request.map_err(|e| {
4601        ServerError::invalid_request(format!("invalid completions request: {e}"), None)
4602    })?;
4603    validate_completion_request(&request)?;
4604    let (engine_model_id, lora_adapter) = resolve_request_model(
4605        &state.served_model_registry,
4606        &request.model,
4607        ServedModelKind::Llm,
4608    )?;
4609    let mut inference_request = convert_completion_request(&request);
4610    apply_served_model_resolution(&mut inference_request, engine_model_id, lora_adapter);
4611    if request.stream.unwrap_or(false) {
4612        handle_completions_stream(state, request, inference_request).await
4613    } else {
4614        handle_completions_sync(state, request, inference_request).await
4615    }
4616}
4617
4618fn validate_completion_request(
4619    request: &CompletionsRequest,
4620) -> std::result::Result<(), ServerError> {
4621    if request.prompt.as_text().is_none() {
4622        return Err(ServerError::invalid_request(
4623            "only string prompt is supported for completions",
4624            Some("prompt"),
4625        ));
4626    }
4627    if let Some(n) = request.n {
4628        if n != 1 {
4629            return Err(ServerError::unsupported_feature(
4630                "only n=1 is supported for completions",
4631                Some("n"),
4632            ));
4633        }
4634    }
4635    if request.logprobs.is_some() {
4636        return Err(ServerError::unsupported_feature(
4637            "logprobs is not supported for completions",
4638            Some("logprobs"),
4639        ));
4640    }
4641    if request
4642        .logit_bias
4643        .as_ref()
4644        .is_some_and(|bias| !bias.is_empty())
4645    {
4646        return Err(ServerError::unsupported_feature(
4647            "logit_bias is not supported",
4648            Some("logit_bias"),
4649        ));
4650    }
4651    Ok(())
4652}
4653
4654/// Embeddings handler — text and image embedding via OpenAI-compatible API.
4655async fn embeddings_handler(
4656    State(state): State<AppState>,
4657    request: std::result::Result<Json<EmbeddingsRequest>, JsonRejection>,
4658) -> std::result::Result<Response, ServerError> {
4659    let Json(request) = request.map_err(|e| {
4660        ServerError::invalid_request(format!("invalid embeddings request: {e}"), None)
4661    })?;
4662
4663    let span = span!(Level::INFO, "embeddings", model = %request.model);
4664    let _enter = span.enter();
4665
4666    validate_embeddings_request(&request)?;
4667    resolve_request_model(
4668        &state.served_model_registry,
4669        &request.model,
4670        ServedModelKind::Embedding,
4671    )?;
4672
4673    // Flatten input into individual items
4674    let items: Vec<EmbeddingItem> = match request.input {
4675        EmbeddingInput::Single(text) => vec![EmbeddingItem {
4676            text: Some(text),
4677            image: None,
4678        }],
4679        EmbeddingInput::Batch(texts) => texts
4680            .into_iter()
4681            .map(|t| EmbeddingItem {
4682                text: Some(t),
4683                image: None,
4684            })
4685            .collect(),
4686        EmbeddingInput::SingleObject(item) => vec![item],
4687        EmbeddingInput::BatchObjects(items) => items,
4688    };
4689
4690    if items.is_empty() {
4691        return Err(ServerError::invalid_request(
4692            "input must not be empty",
4693            Some("input"),
4694        ));
4695    }
4696
4697    let mut data = Vec::with_capacity(items.len());
4698    let mut total_tokens = 0u32;
4699
4700    let engine = state.embed.as_ref().ok_or_else(|| {
4701        ServerError::NotImplemented("Embed engine not loaded; embeddings unavailable".into())
4702    })?;
4703    for (idx, item) in items.iter().enumerate() {
4704        let embedding = if let Some(ref image) = item.image {
4705            engine
4706                .embed_image(image)
4707                .await
4708                .map_err(|e| ServerError::InternalError(format!("embed_image: {e}")))?
4709        } else if let Some(ref text) = item.text {
4710            total_tokens += text.len() as u32;
4711            engine
4712                .embed_text(text)
4713                .await
4714                .map_err(|e| ServerError::InternalError(format!("embed_text: {e}")))?
4715        } else {
4716            return Err(ServerError::invalid_request(
4717                "each input item must have either text or image",
4718                Some("input"),
4719            ));
4720        };
4721
4722        data.push(EmbeddingData {
4723            object: "embedding".to_string(),
4724            embedding,
4725            index: idx,
4726        });
4727    }
4728
4729    let response = EmbeddingsResponse {
4730        object: "list".to_string(),
4731        data,
4732        model: request.model,
4733        usage: EmbeddingUsage {
4734            prompt_tokens: total_tokens,
4735            total_tokens,
4736        },
4737    };
4738
4739    Ok(Json(response).into_response())
4740}
4741
4742fn validate_embeddings_request(
4743    request: &EmbeddingsRequest,
4744) -> std::result::Result<(), ServerError> {
4745    if let Some(format) = request.encoding_format.as_deref() {
4746        if !format.eq_ignore_ascii_case("float") {
4747            return Err(ServerError::unsupported_feature(
4748                "only encoding_format=float is supported for embeddings",
4749                Some("encoding_format"),
4750            ));
4751        }
4752    }
4753    Ok(())
4754}
4755
4756/// Audio transcription handler (OpenAI-compatible multipart form).
4757async fn transcriptions_handler(
4758    State(state): State<AppState>,
4759    multipart: std::result::Result<axum::extract::Multipart, MultipartRejection>,
4760) -> std::result::Result<Response, ServerError> {
4761    let mut multipart = multipart.map_err(|e| {
4762        ServerError::invalid_request(format!("invalid transcriptions request: {e}"), None)
4763    })?;
4764
4765    let span = span!(Level::INFO, "transcription");
4766    let _enter = span.enter();
4767
4768    let mut file_data: Option<Vec<u8>> = None;
4769    let mut language: Option<String> = None;
4770    let mut response_format: Option<String> = None;
4771
4772    while let Some(field) = multipart
4773        .next_field()
4774        .await
4775        .map_err(|e| ServerError::invalid_request(format!("multipart: {e}"), None))?
4776    {
4777        let name = field.name().unwrap_or("").to_string();
4778        match name.as_str() {
4779            "file" => {
4780                file_data = Some(
4781                    field
4782                        .bytes()
4783                        .await
4784                        .map_err(|e| {
4785                            ServerError::invalid_request(format!("read file: {e}"), Some("file"))
4786                        })?
4787                        .to_vec(),
4788                );
4789            }
4790            "language" => {
4791                language = field.text().await.ok().filter(|s| !s.is_empty());
4792            }
4793            "response_format" => {
4794                response_format = field.text().await.ok().filter(|s| !s.is_empty());
4795            }
4796            _ => {} // ignore model and other optional multipart fields for now
4797        }
4798    }
4799
4800    validate_transcription_response_format(response_format.as_deref())?;
4801
4802    let data = file_data
4803        .ok_or_else(|| ServerError::invalid_request("missing file field", Some("file")))?;
4804
4805    let engine = state.transcribe.as_ref().ok_or_else(|| {
4806        ServerError::NotImplemented("Transcribe engine not loaded; ASR unavailable".into())
4807    })?;
4808    let text = engine
4809        .transcribe_bytes(&data, language.as_deref())
4810        .await
4811        .map_err(|e| ServerError::InternalError(format!("transcribe: {e}")))?;
4812
4813    Ok(Json(TranscriptionResponse { text }).into_response())
4814}
4815
4816fn validate_transcription_response_format(
4817    response_format: Option<&str>,
4818) -> std::result::Result<(), ServerError> {
4819    if let Some(format) = response_format {
4820        if !format.eq_ignore_ascii_case("json") {
4821            return Err(ServerError::unsupported_feature(
4822                "only response_format=json is supported for transcriptions",
4823                Some("response_format"),
4824            ));
4825        }
4826    }
4827    Ok(())
4828}
4829
4830/// TTS speech synthesis handler (OpenAI-compatible /v1/audio/speech)
4831async fn speech_handler(
4832    State(state): State<AppState>,
4833    request: std::result::Result<Json<SpeechRequest>, JsonRejection>,
4834) -> std::result::Result<Response, ServerError> {
4835    let Json(request) = request
4836        .map_err(|e| ServerError::invalid_request(format!("invalid speech request: {e}"), None))?;
4837
4838    let response_format = speech_output_format(&request)?;
4839    resolve_request_model(
4840        &state.served_model_registry,
4841        &request.model,
4842        ServedModelKind::Speech,
4843    )?;
4844
4845    let span = span!(Level::INFO, "speech");
4846    let _guard = span.enter();
4847
4848    let language = if request.language.is_empty() || request.language == "auto" {
4849        None
4850    } else {
4851        Some(request.language.as_str())
4852    };
4853
4854    let chunk_frames = 10usize;
4855    let tts = state.tts.as_ref().ok_or_else(|| {
4856        ServerError::NotImplemented("TTS engine not loaded; speech unavailable".into())
4857    })?;
4858    let sample_rate = tts.tts_sample_rate();
4859
4860    if request.stream {
4861        // Streaming: chunked transfer encoding with WAV audio
4862        let (tx, rx) =
4863            mpsc::unbounded_channel::<std::result::Result<axum::body::Bytes, std::io::Error>>();
4864
4865        let engine = tts.clone();
4866        let text = request.input.clone();
4867        let lang = request.language.clone();
4868
4869        tokio::task::spawn_blocking(move || {
4870            let lang_opt = if lang.is_empty() || lang == "auto" {
4871                None
4872            } else {
4873                Some(lang.as_str())
4874            };
4875            let rt = tokio::runtime::Handle::current();
4876
4877            match rt.block_on(engine.synthesize_speech(&text, lang_opt, chunk_frames)) {
4878                Ok(chunks) => {
4879                    for chunk in &chunks {
4880                        let audio_bytes = encode_speech_audio(chunk, sample_rate, response_format);
4881                        let _ = tx.send(Ok(axum::body::Bytes::from(audio_bytes)));
4882                    }
4883                }
4884                Err(e) => {
4885                    error!("TTS error: {e}");
4886                }
4887            }
4888        });
4889
4890        let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
4891        let body = axum::body::Body::from_stream(stream);
4892        Ok(Response::builder()
4893            .status(200)
4894            .header("content-type", speech_content_type(response_format))
4895            .header("transfer-encoding", "chunked")
4896            .body(body)
4897            .unwrap())
4898    } else {
4899        // Non-streaming: return complete WAV
4900        let chunks = tts
4901            .synthesize_speech(&request.input, language, chunk_frames)
4902            .await
4903            .map_err(|e| ServerError::InternalError(format!("TTS: {e}")))?;
4904
4905        let all_samples: Vec<f32> = chunks.into_iter().flatten().collect();
4906        let audio_bytes = encode_speech_audio(&all_samples, sample_rate, response_format);
4907
4908        Ok(Response::builder()
4909            .status(200)
4910            .header("content-type", speech_content_type(response_format))
4911            .header("content-length", audio_bytes.len().to_string())
4912            .body(axum::body::Body::from(audio_bytes))
4913            .unwrap())
4914    }
4915}
4916
4917#[derive(Clone, Copy)]
4918enum SpeechOutputFormat {
4919    Wav,
4920    Pcm,
4921}
4922
4923fn speech_output_format(
4924    request: &SpeechRequest,
4925) -> std::result::Result<SpeechOutputFormat, ServerError> {
4926    if request.response_format.eq_ignore_ascii_case("wav") {
4927        Ok(SpeechOutputFormat::Wav)
4928    } else if request.response_format.eq_ignore_ascii_case("pcm") {
4929        Ok(SpeechOutputFormat::Pcm)
4930    } else {
4931        Err(ServerError::unsupported_feature(
4932            "only response_format=wav or response_format=pcm is supported for speech",
4933            Some("response_format"),
4934        ))
4935    }
4936}
4937
4938fn speech_content_type(format: SpeechOutputFormat) -> &'static str {
4939    match format {
4940        SpeechOutputFormat::Wav => "audio/wav",
4941        SpeechOutputFormat::Pcm => "audio/pcm",
4942    }
4943}
4944
4945fn encode_speech_audio(samples: &[f32], sample_rate: u32, format: SpeechOutputFormat) -> Vec<u8> {
4946    match format {
4947        SpeechOutputFormat::Wav => pcm_to_wav_bytes(samples, sample_rate),
4948        SpeechOutputFormat::Pcm => pcm_to_s16le_bytes(samples),
4949    }
4950}
4951
4952/// Convert PCM f32 samples to WAV bytes (16-bit, mono).
4953fn pcm_to_wav_bytes(samples: &[f32], sample_rate: u32) -> Vec<u8> {
4954    let num_samples = samples.len();
4955    let data_size = num_samples * 2; // 16-bit = 2 bytes per sample
4956    let file_size = 44 + data_size;
4957
4958    let mut buf = Vec::with_capacity(file_size);
4959    // RIFF header
4960    buf.extend_from_slice(b"RIFF");
4961    buf.extend_from_slice(&((file_size - 8) as u32).to_le_bytes());
4962    buf.extend_from_slice(b"WAVE");
4963    // fmt chunk
4964    buf.extend_from_slice(b"fmt ");
4965    buf.extend_from_slice(&16u32.to_le_bytes()); // chunk size
4966    buf.extend_from_slice(&1u16.to_le_bytes()); // PCM
4967    buf.extend_from_slice(&1u16.to_le_bytes()); // mono
4968    buf.extend_from_slice(&sample_rate.to_le_bytes());
4969    buf.extend_from_slice(&(sample_rate * 2).to_le_bytes()); // byte rate
4970    buf.extend_from_slice(&2u16.to_le_bytes()); // block align
4971    buf.extend_from_slice(&16u16.to_le_bytes()); // bits per sample
4972                                                 // data chunk
4973    buf.extend_from_slice(b"data");
4974    buf.extend_from_slice(&(data_size as u32).to_le_bytes());
4975    buf.extend_from_slice(&pcm_to_s16le_bytes(samples));
4976    buf
4977}
4978
4979fn pcm_to_s16le_bytes(samples: &[f32]) -> Vec<u8> {
4980    let mut buf = Vec::with_capacity(samples.len() * 2);
4981    for &s in samples {
4982        let i16_val = (s.clamp(-1.0, 1.0) * 32767.0) as i16;
4983        buf.extend_from_slice(&i16_val.to_le_bytes());
4984    }
4985    buf
4986}
4987
4988async fn models_handler(
4989    State(state): State<AppState>,
4990) -> std::result::Result<Response, ServerError> {
4991    let now = chrono::Utc::now().timestamp() as u64;
4992    let data = state
4993        .served_model_registry
4994        .entries()
4995        .iter()
4996        .map(|entry| crate::openai::ModelInfo {
4997            id: entry.public_name().to_string(),
4998            object: "model".to_string(),
4999            created: now,
5000            owned_by: "ferrum".to_string(),
5001            modalities: entry
5002                .kind()
5003                .modalities()
5004                .iter()
5005                .map(ToString::to_string)
5006                .collect(),
5007            permission: vec![],
5008            root: entry.parent_public_name().map(ToString::to_string),
5009            parent: entry.parent_public_name().map(ToString::to_string),
5010        })
5011        .collect();
5012
5013    let models = ModelListResponse {
5014        object: "list".to_string(),
5015        data,
5016    };
5017
5018    Ok(Json(models).into_response())
5019}
5020
5021async fn health_handler(
5022    State(state): State<AppState>,
5023) -> std::result::Result<Response, ServerError> {
5024    let engine_status = state.status().await;
5025    let scheduler_metrics = state.metrics();
5026    let runtime_config = RuntimeConfigSnapshot::capture_current();
5027    let cache_policy = CachePolicy::current();
5028    let engine_cache = state
5029        .llm
5030        .as_ref()
5031        .and_then(|engine| engine.cache_metrics_snapshot());
5032    let engine_lora = state
5033        .llm
5034        .as_ref()
5035        .and_then(|engine| engine.lora_metrics_snapshot());
5036    let auto_config = auto_config_health_value(state.auto_config.as_ref());
5037    let runtime_admission = match state.llm.as_ref() {
5038        Some(engine) => engine.admission_snapshot(),
5039        None => Ok(None),
5040    };
5041    let (runtime_admission_snapshot, runtime_admission_error) = match &runtime_admission {
5042        Ok(snapshot) => (snapshot.as_ref(), None),
5043        Err(error) => (None, Some(error.to_string())),
5044    };
5045    let admission = admission_health_json(
5046        &engine_status,
5047        &scheduler_metrics,
5048        &auto_config,
5049        runtime_admission_snapshot,
5050        runtime_admission_error.as_deref(),
5051    );
5052
5053    let health = serde_json::json!({
5054        "status": if runtime_admission_error.is_some() { "unhealthy" } else { "healthy" },
5055        "timestamp": chrono::Utc::now().to_rfc3339(),
5056        "version": env!("CARGO_PKG_VERSION"),
5057        "engine": {
5058            "active_requests": engine_status.active_requests,
5059            "queued_requests": engine_status.queued_requests,
5060        },
5061        "scheduler": {
5062            "total_requests": scheduler_metrics.total_requests,
5063            "successful_requests": scheduler_metrics.successful_requests,
5064            "failed_requests": scheduler_metrics.failed_requests,
5065            "throughput_rps": scheduler_metrics.throughput_rps,
5066            "avg_wait_time_ms": scheduler_metrics.queue_metrics.avg_queue_wait_time_ms,
5067            "scheduling_time_ms": scheduler_metrics.performance_breakdown.scheduling_time_ms,
5068            "model_execution_time_ms": scheduler_metrics
5069                .performance_breakdown
5070                .model_execution_time_ms,
5071            "iteration_lock_wait_time_ms": scheduler_metrics
5072                .performance_breakdown
5073                .other_overhead_time_ms,
5074        },
5075        "config": runtime_config,
5076        "auto_config": auto_config,
5077        "admission": admission,
5078        "cache": state.cache.health_json(&cache_policy, engine_cache.as_ref()),
5079        "lora": engine_lora.unwrap_or_else(|| serde_json::json!({
5080            "enabled": state.served_model_registry.adapter_count() > 0,
5081            "adapter_count": state.served_model_registry.adapter_count() as u64,
5082            "active_cache_bindings": 0u64,
5083            "projection_applications": 0u64,
5084            "position": "startup-routing",
5085            "source": "server-lora-registry",
5086        })),
5087    });
5088
5089    Ok(Json(health).into_response())
5090}
5091
5092/// Prometheus metrics endpoint — returns metrics in Prometheus text format.
5093async fn metrics_handler(
5094    State(state): State<AppState>,
5095) -> std::result::Result<Response, ServerError> {
5096    let mut body = match PROM_HANDLE.get() {
5097        Some(handle) => handle.render(),
5098        None => "# Prometheus recorder not initialized\n".to_string(),
5099    };
5100    if !body.ends_with('\n') {
5101        body.push('\n');
5102    }
5103    let engine_cache = state
5104        .llm
5105        .as_ref()
5106        .and_then(|engine| engine.cache_metrics_snapshot());
5107    body.push_str(&state.cache.prometheus_metrics(engine_cache.as_ref()));
5108    let engine_status = state.status().await;
5109    let scheduler_metrics = state.metrics();
5110    let auto_config = auto_config_health_value(state.auto_config.as_ref());
5111    let runtime_admission = match state.llm.as_ref() {
5112        Some(engine) => engine.admission_snapshot(),
5113        None => Ok(None),
5114    };
5115    let (runtime_admission_snapshot, runtime_admission_error) = match &runtime_admission {
5116        Ok(snapshot) => (snapshot.as_ref(), None),
5117        Err(error) => (None, Some(error.to_string())),
5118    };
5119    let admission = admission_health_json(
5120        &engine_status,
5121        &scheduler_metrics,
5122        &auto_config,
5123        runtime_admission_snapshot,
5124        runtime_admission_error.as_deref(),
5125    );
5126    body.push_str(&admission_prometheus_metrics(&admission));
5127
5128    Ok((
5129        [(
5130            axum::http::header::CONTENT_TYPE,
5131            "text/plain; version=0.0.4; charset=utf-8",
5132        )],
5133        body,
5134    )
5135        .into_response())
5136}
5137
5138async fn root_handler() -> std::result::Result<Response, ServerError> {
5139    let info = serde_json::json!({
5140        "name": "Ferrum Inference Server",
5141        "version": env!("CARGO_PKG_VERSION"),
5142        "api_version": "v1",
5143        "status": "running"
5144    });
5145
5146    Ok(Json(info).into_response())
5147}
5148
5149/// Server error type for HTTP responses
5150#[derive(Debug)]
5151enum ServerError {
5152    InvalidRequest {
5153        message: String,
5154        param: Option<String>,
5155    },
5156    UnsupportedFeature {
5157        message: String,
5158        param: Option<String>,
5159    },
5160    InternalError(String),
5161    NotImplemented(String),
5162    ServiceUnavailable(String),
5163}
5164
5165impl ServerError {
5166    fn invalid_request(message: impl Into<String>, param: Option<&str>) -> Self {
5167        Self::InvalidRequest {
5168            message: message.into(),
5169            param: param.map(str::to_string),
5170        }
5171    }
5172
5173    fn unsupported_feature(message: impl Into<String>, param: Option<&str>) -> Self {
5174        Self::UnsupportedFeature {
5175            message: message.into(),
5176            param: param.map(str::to_string),
5177        }
5178    }
5179}
5180
5181impl IntoResponse for ServerError {
5182    fn into_response(self) -> Response {
5183        let (status, message, error_type, param) = match self {
5184            ServerError::InvalidRequest { message, param } => (
5185                AxumStatusCode::BAD_REQUEST,
5186                message,
5187                "invalid_request_error",
5188                param,
5189            ),
5190            ServerError::UnsupportedFeature { message, param } => (
5191                AxumStatusCode::BAD_REQUEST,
5192                message,
5193                "invalid_request_error",
5194                param,
5195            ),
5196            ServerError::InternalError(msg) => (
5197                AxumStatusCode::INTERNAL_SERVER_ERROR,
5198                msg,
5199                "internal_server_error",
5200                None,
5201            ),
5202            ServerError::NotImplemented(msg) => (
5203                AxumStatusCode::SERVICE_UNAVAILABLE,
5204                msg,
5205                "service_unavailable_error",
5206                None,
5207            ),
5208            ServerError::ServiceUnavailable(msg) => (
5209                AxumStatusCode::SERVICE_UNAVAILABLE,
5210                msg,
5211                "service_unavailable_error",
5212                None,
5213            ),
5214        };
5215
5216        let error = OpenAiError {
5217            error: OpenAiErrorDetail {
5218                message,
5219                error_type: error_type.to_string(),
5220                param,
5221                code: None,
5222            },
5223        };
5224
5225        (status, Json(error)).into_response()
5226    }
5227}
5228
5229impl std::fmt::Display for MessageRole {
5230    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5231        match self {
5232            MessageRole::System => write!(f, "system"),
5233            MessageRole::User => write!(f, "user"),
5234            MessageRole::Assistant => write!(f, "assistant"),
5235            MessageRole::Function => write!(f, "function"),
5236            MessageRole::Tool => write!(f, "tool"),
5237        }
5238    }
5239}
5240
5241/// Strip model output at the first user-supplied stop sequence.
5242/// OpenAI-compatible `stop` strings are generation boundaries and must not
5243/// be returned to the caller, even if the model continued after the boundary.
5244fn strip_after_stop(text: &str, stops: &[String]) -> String {
5245    let mut first: Option<usize> = None;
5246    for stop in stops {
5247        if stop.is_empty() {
5248            continue;
5249        }
5250        if let Some(idx) = text.find(stop.as_str()) {
5251            first = Some(first.map_or(idx, |current| current.min(idx)));
5252        }
5253    }
5254    match first {
5255        Some(idx) => text[..idx].to_string(),
5256        None => text.to_string(),
5257    }
5258}
5259
5260/// Compatibility cleanup for explicitly best-effort structured output.
5261/// Hard `json_object` and strict schema contracts never call this helper.
5262fn strip_markdown_json_fence(text: &str) -> String {
5263    let trimmed = text.trim();
5264    // Try the most specific marker first.
5265    for prefix in ["```json\n", "```json", "```\n", "```"] {
5266        if let Some(rest) = trimmed.strip_prefix(prefix) {
5267            if let Some(inner) = rest.strip_suffix("```") {
5268                return inner.trim().to_string();
5269            }
5270        }
5271    }
5272    text.to_string()
5273}
5274
5275/// Convert FinishReason to OpenAI API string
5276fn finish_reason_to_string(reason: &FinishReason) -> String {
5277    match reason {
5278        FinishReason::Length => "length".to_string(),
5279        FinishReason::Stop => "stop".to_string(),
5280        FinishReason::EOS => "stop".to_string(),
5281        FinishReason::Cancelled => "cancelled".to_string(),
5282        FinishReason::Error => "error".to_string(),
5283        FinishReason::ContentFilter => "content_filter".to_string(),
5284    }
5285}
5286
5287#[cfg(test)]
5288mod tests {
5289    use super::*;
5290    use async_trait::async_trait;
5291    use axum::{
5292        body::{to_bytes, Body},
5293        http::{header, Request},
5294        response::Response,
5295    };
5296    use ferrum_interfaces::engine::{
5297        EmbedEngine, InferenceEngine, LlmInferenceEngine, TranscribeEngine, TtsEngine,
5298    };
5299    use ferrum_types::{
5300        EngineConfig, EngineMetrics, EngineStatus, EngineTokenTimingEvidence, FinishReason,
5301        HealthStatus as EngineHealthStatus, InferenceRequest, InferenceResponse, MemoryUsage,
5302        ModelId, StreamChunk, TokenId, TokenUsage,
5303    };
5304    use futures::{stream, Stream};
5305    use serde_json::{json, Value};
5306    use std::{
5307        collections::HashMap,
5308        pin::Pin,
5309        sync::{atomic::AtomicUsize, Arc, Mutex},
5310    };
5311    use tower::ServiceExt;
5312
5313    #[test]
5314    fn strip_after_stop_removes_first_boundary() {
5315        assert_eq!(
5316            strip_after_stop(
5317                "KS0214Z\nS0225\nEND0214Z0214Z\nS0225\n",
5318                &["END0214Z".to_string()]
5319            ),
5320            "KS0214Z\nS0225\n"
5321        );
5322    }
5323
5324    #[tokio::test]
5325    async fn stop_drains_running_server_and_shuts_down_loaded_engine_once() {
5326        let engine = Arc::new(StubLlm::new("ok"));
5327        let server = Arc::new(AxumServer::from_llm(engine.clone()));
5328        let config = ServerConfig {
5329            host: "127.0.0.1".to_string(),
5330            port: 0,
5331            ..ServerConfig::default()
5332        };
5333        let server_task = {
5334            let server = Arc::clone(&server);
5335            tokio::spawn(async move { server.start(&config).await })
5336        };
5337        tokio::time::timeout(std::time::Duration::from_secs(1), async {
5338            while !server.is_running() {
5339                tokio::task::yield_now().await;
5340            }
5341        })
5342        .await
5343        .unwrap();
5344
5345        server
5346            .stop(std::time::Duration::from_secs(1))
5347            .await
5348            .unwrap();
5349        server
5350            .stop(std::time::Duration::from_secs(1))
5351            .await
5352            .unwrap();
5353        server_task.await.unwrap().unwrap();
5354
5355        assert_eq!(engine.shutdown_count.load(Ordering::Acquire), 1);
5356        assert!(!server.is_running());
5357    }
5358
5359    struct StubLlm {
5360        config: EngineConfig,
5361        text: String,
5362        stream_chunks: Option<Vec<String>>,
5363        stream_final_chunk_separate: bool,
5364        stream_usage: Option<TokenUsage>,
5365        api_response: Option<ferrum_types::ApiResponse>,
5366        finish_reason: FinishReason,
5367        lora_metrics: Option<Value>,
5368        pending_stream_drop_notify: Option<Arc<Notify>>,
5369        shutdown_count: AtomicUsize,
5370    }
5371
5372    impl StubLlm {
5373        fn new(text: &str) -> Self {
5374            let mut config = EngineConfig::default();
5375            config.model.model_id = ModelId::new("stub-model");
5376            Self {
5377                config,
5378                text: text.to_string(),
5379                stream_chunks: None,
5380                stream_final_chunk_separate: false,
5381                stream_usage: Some(TokenUsage::new(5, 1)),
5382                api_response: None,
5383                finish_reason: FinishReason::Stop,
5384                lora_metrics: None,
5385                pending_stream_drop_notify: None,
5386                shutdown_count: AtomicUsize::new(0),
5387            }
5388        }
5389
5390        fn without_stream_usage(text: &str) -> Self {
5391            Self {
5392                stream_usage: None,
5393                ..Self::new(text)
5394            }
5395        }
5396
5397        fn with_stream_chunks(chunks: &[&str]) -> Self {
5398            Self {
5399                text: chunks.concat(),
5400                stream_chunks: Some(chunks.iter().map(|chunk| (*chunk).to_string()).collect()),
5401                stream_usage: Some(TokenUsage::new(5, chunks.len())),
5402                ..Self::new("")
5403            }
5404        }
5405
5406        fn with_separate_final_stream_chunk(chunks: &[&str]) -> Self {
5407            Self {
5408                text: chunks.concat(),
5409                stream_chunks: Some(chunks.iter().map(|chunk| (*chunk).to_string()).collect()),
5410                stream_final_chunk_separate: true,
5411                stream_usage: Some(TokenUsage::new(5, chunks.len())),
5412                ..Self::new("")
5413            }
5414        }
5415
5416        fn with_api_response(text: &str, api_response: ferrum_types::ApiResponse) -> Self {
5417            Self {
5418                api_response: Some(api_response),
5419                ..Self::new(text)
5420            }
5421        }
5422
5423        fn with_api_response_and_finish_reason(
5424            text: &str,
5425            api_response: ferrum_types::ApiResponse,
5426            finish_reason: FinishReason,
5427        ) -> Self {
5428            Self {
5429                api_response: Some(api_response),
5430                finish_reason,
5431                ..Self::new(text)
5432            }
5433        }
5434
5435        fn with_lora_metrics(text: &str, lora_metrics: Value) -> Self {
5436            Self {
5437                lora_metrics: Some(lora_metrics),
5438                ..Self::new(text)
5439            }
5440        }
5441
5442        fn with_pending_stream(drop_notify: Arc<Notify>) -> Self {
5443            Self {
5444                pending_stream_drop_notify: Some(drop_notify),
5445                ..Self::new("")
5446            }
5447        }
5448    }
5449
5450    struct PendingDropStream {
5451        drop_notify: Arc<Notify>,
5452    }
5453
5454    impl Stream for PendingDropStream {
5455        type Item = ferrum_types::Result<StreamChunk>;
5456
5457        fn poll_next(
5458            self: Pin<&mut Self>,
5459            _cx: &mut std::task::Context<'_>,
5460        ) -> std::task::Poll<Option<Self::Item>> {
5461            std::task::Poll::Pending
5462        }
5463    }
5464
5465    impl Drop for PendingDropStream {
5466        fn drop(&mut self) {
5467            self.drop_notify.notify_one();
5468        }
5469    }
5470
5471    struct StubEmbed {
5472        config: EngineConfig,
5473    }
5474
5475    impl StubEmbed {
5476        fn new() -> Self {
5477            let mut config = EngineConfig::default();
5478            config.model.model_id = ModelId::new("stub-embed");
5479            Self { config }
5480        }
5481    }
5482
5483    struct StubTranscribe {
5484        config: EngineConfig,
5485    }
5486
5487    impl StubTranscribe {
5488        fn new() -> Self {
5489            let mut config = EngineConfig::default();
5490            config.model.model_id = ModelId::new("stub-transcribe");
5491            Self { config }
5492        }
5493    }
5494
5495    struct StubTts {
5496        config: EngineConfig,
5497    }
5498
5499    impl StubTts {
5500        fn new() -> Self {
5501            let mut config = EngineConfig::default();
5502            config.model.model_id = ModelId::new("stub-tts");
5503            Self { config }
5504        }
5505    }
5506
5507    struct FailingLlm {
5508        config: EngineConfig,
5509        fail_after_stream_start: bool,
5510        infer_failure: ferrum_types::FerrumError,
5511        stream_start_failure: ferrum_types::FerrumError,
5512        stream_chunk_failure: ferrum_types::FerrumError,
5513    }
5514
5515    impl FailingLlm {
5516        fn new() -> Self {
5517            let mut config = EngineConfig::default();
5518            config.model.model_id = ModelId::new("failing-model");
5519            Self {
5520                config,
5521                fail_after_stream_start: false,
5522                infer_failure: ferrum_types::FerrumError::internal("stub generation failed"),
5523                stream_start_failure: ferrum_types::FerrumError::internal("stub stream failed"),
5524                stream_chunk_failure: ferrum_types::FerrumError::internal(
5525                    "stub stream chunk failed",
5526                ),
5527            }
5528        }
5529
5530        fn after_stream_start() -> Self {
5531            Self {
5532                fail_after_stream_start: true,
5533                ..Self::new()
5534            }
5535        }
5536
5537        fn resource_exhausted() -> Self {
5538            let failure = ferrum_types::FerrumError::resource_exhausted(
5539                "admission capacity exhausted while reserving request resources",
5540            );
5541            Self {
5542                infer_failure: failure.clone(),
5543                stream_start_failure: failure.clone(),
5544                stream_chunk_failure: failure,
5545                ..Self::new()
5546            }
5547        }
5548    }
5549
5550    struct CapturingLlm {
5551        config: EngineConfig,
5552        last_request: Mutex<Option<InferenceRequest>>,
5553    }
5554
5555    impl CapturingLlm {
5556        fn new() -> Self {
5557            let mut config = EngineConfig::default();
5558            config.model.model_id = ModelId::new("qwen3");
5559            Self {
5560                config,
5561                last_request: Mutex::new(None),
5562            }
5563        }
5564
5565        fn last_request(&self) -> InferenceRequest {
5566            self.last_request
5567                .lock()
5568                .expect("capture lock")
5569                .clone()
5570                .expect("request captured")
5571        }
5572
5573        fn has_captured_request(&self) -> bool {
5574            self.last_request.lock().expect("capture lock").is_some()
5575        }
5576    }
5577
5578    #[async_trait]
5579    impl InferenceEngine for StubLlm {
5580        async fn status(&self) -> EngineStatus {
5581            EngineStatus {
5582                is_ready: true,
5583                loaded_models: vec![self.config.model.model_id.clone()],
5584                active_requests: 0,
5585                queued_requests: 0,
5586                memory_usage: MemoryUsage {
5587                    total_bytes: 0,
5588                    used_bytes: 0,
5589                    free_bytes: 0,
5590                    gpu_memory_bytes: None,
5591                    cpu_memory_bytes: None,
5592                    cache_memory_bytes: 0,
5593                    utilization_percent: 0.0,
5594                },
5595                uptime_seconds: 0,
5596                last_heartbeat: chrono::Utc::now(),
5597                version: "test".to_string(),
5598            }
5599        }
5600
5601        async fn shutdown(&self) -> ferrum_types::Result<()> {
5602            self.shutdown_count.fetch_add(1, Ordering::AcqRel);
5603            Ok(())
5604        }
5605
5606        fn config(&self) -> &EngineConfig {
5607            &self.config
5608        }
5609
5610        fn metrics(&self) -> EngineMetrics {
5611            EngineMetrics::default()
5612        }
5613
5614        async fn health_check(&self) -> EngineHealthStatus {
5615            EngineHealthStatus::healthy()
5616        }
5617
5618        fn lora_metrics_snapshot(&self) -> Option<Value> {
5619            self.lora_metrics.clone()
5620        }
5621    }
5622
5623    #[async_trait]
5624    impl InferenceEngine for StubEmbed {
5625        async fn status(&self) -> EngineStatus {
5626            EngineStatus {
5627                is_ready: true,
5628                loaded_models: vec![self.config.model.model_id.clone()],
5629                active_requests: 0,
5630                queued_requests: 0,
5631                memory_usage: MemoryUsage {
5632                    total_bytes: 0,
5633                    used_bytes: 0,
5634                    free_bytes: 0,
5635                    gpu_memory_bytes: None,
5636                    cpu_memory_bytes: None,
5637                    cache_memory_bytes: 0,
5638                    utilization_percent: 0.0,
5639                },
5640                uptime_seconds: 0,
5641                last_heartbeat: chrono::Utc::now(),
5642                version: "test".to_string(),
5643            }
5644        }
5645
5646        async fn shutdown(&self) -> ferrum_types::Result<()> {
5647            Ok(())
5648        }
5649
5650        fn config(&self) -> &EngineConfig {
5651            &self.config
5652        }
5653
5654        fn metrics(&self) -> EngineMetrics {
5655            EngineMetrics::default()
5656        }
5657
5658        async fn health_check(&self) -> EngineHealthStatus {
5659            EngineHealthStatus::healthy()
5660        }
5661    }
5662
5663    #[async_trait]
5664    impl EmbedEngine for StubEmbed {
5665        async fn embed_text(&self, text: &str) -> ferrum_types::Result<Vec<f32>> {
5666            Ok(vec![text.len() as f32, 1.0, 0.0])
5667        }
5668
5669        async fn embed_image(&self, image: &str) -> ferrum_types::Result<Vec<f32>> {
5670            Ok(vec![image.len() as f32, 0.0, 1.0])
5671        }
5672
5673        fn embedding_dim(&self) -> usize {
5674            3
5675        }
5676    }
5677
5678    #[async_trait]
5679    impl InferenceEngine for StubTranscribe {
5680        async fn status(&self) -> EngineStatus {
5681            EngineStatus {
5682                is_ready: true,
5683                loaded_models: vec![self.config.model.model_id.clone()],
5684                active_requests: 0,
5685                queued_requests: 0,
5686                memory_usage: MemoryUsage {
5687                    total_bytes: 0,
5688                    used_bytes: 0,
5689                    free_bytes: 0,
5690                    gpu_memory_bytes: None,
5691                    cpu_memory_bytes: None,
5692                    cache_memory_bytes: 0,
5693                    utilization_percent: 0.0,
5694                },
5695                uptime_seconds: 0,
5696                last_heartbeat: chrono::Utc::now(),
5697                version: "test".to_string(),
5698            }
5699        }
5700
5701        async fn shutdown(&self) -> ferrum_types::Result<()> {
5702            Ok(())
5703        }
5704
5705        fn config(&self) -> &EngineConfig {
5706            &self.config
5707        }
5708
5709        fn metrics(&self) -> EngineMetrics {
5710            EngineMetrics::default()
5711        }
5712
5713        async fn health_check(&self) -> EngineHealthStatus {
5714            EngineHealthStatus::healthy()
5715        }
5716    }
5717
5718    #[async_trait]
5719    impl TranscribeEngine for StubTranscribe {
5720        async fn transcribe_file(
5721            &self,
5722            path: &str,
5723            language: Option<&str>,
5724        ) -> ferrum_types::Result<String> {
5725            Ok(format!("file:{path}:{}", language.unwrap_or("auto")))
5726        }
5727
5728        async fn transcribe_bytes(
5729            &self,
5730            data: &[u8],
5731            language: Option<&str>,
5732        ) -> ferrum_types::Result<String> {
5733            Ok(format!(
5734                "bytes:{}:{}",
5735                data.len(),
5736                language.unwrap_or("auto")
5737            ))
5738        }
5739    }
5740
5741    #[async_trait]
5742    impl InferenceEngine for StubTts {
5743        async fn status(&self) -> EngineStatus {
5744            EngineStatus {
5745                is_ready: true,
5746                loaded_models: vec![self.config.model.model_id.clone()],
5747                active_requests: 0,
5748                queued_requests: 0,
5749                memory_usage: MemoryUsage {
5750                    total_bytes: 0,
5751                    used_bytes: 0,
5752                    free_bytes: 0,
5753                    gpu_memory_bytes: None,
5754                    cpu_memory_bytes: None,
5755                    cache_memory_bytes: 0,
5756                    utilization_percent: 0.0,
5757                },
5758                uptime_seconds: 0,
5759                last_heartbeat: chrono::Utc::now(),
5760                version: "test".to_string(),
5761            }
5762        }
5763
5764        async fn shutdown(&self) -> ferrum_types::Result<()> {
5765            Ok(())
5766        }
5767
5768        fn config(&self) -> &EngineConfig {
5769            &self.config
5770        }
5771
5772        fn metrics(&self) -> EngineMetrics {
5773            EngineMetrics::default()
5774        }
5775
5776        async fn health_check(&self) -> EngineHealthStatus {
5777            EngineHealthStatus::healthy()
5778        }
5779    }
5780
5781    #[async_trait]
5782    impl TtsEngine for StubTts {
5783        async fn synthesize_speech(
5784            &self,
5785            _text: &str,
5786            _language: Option<&str>,
5787            _chunk_frames: usize,
5788        ) -> ferrum_types::Result<Vec<Vec<f32>>> {
5789            Ok(vec![vec![0.0, 0.5, -0.5]])
5790        }
5791
5792        fn tts_sample_rate(&self) -> u32 {
5793            16_000
5794        }
5795    }
5796
5797    #[async_trait]
5798    impl InferenceEngine for FailingLlm {
5799        async fn status(&self) -> EngineStatus {
5800            EngineStatus {
5801                is_ready: true,
5802                loaded_models: vec![self.config.model.model_id.clone()],
5803                active_requests: 0,
5804                queued_requests: 0,
5805                memory_usage: MemoryUsage {
5806                    total_bytes: 0,
5807                    used_bytes: 0,
5808                    free_bytes: 0,
5809                    gpu_memory_bytes: None,
5810                    cpu_memory_bytes: None,
5811                    cache_memory_bytes: 0,
5812                    utilization_percent: 0.0,
5813                },
5814                uptime_seconds: 0,
5815                last_heartbeat: chrono::Utc::now(),
5816                version: "test".to_string(),
5817            }
5818        }
5819
5820        async fn shutdown(&self) -> ferrum_types::Result<()> {
5821            Ok(())
5822        }
5823
5824        fn config(&self) -> &EngineConfig {
5825            &self.config
5826        }
5827
5828        fn metrics(&self) -> EngineMetrics {
5829            EngineMetrics::default()
5830        }
5831
5832        async fn health_check(&self) -> EngineHealthStatus {
5833            EngineHealthStatus::healthy()
5834        }
5835    }
5836
5837    #[async_trait]
5838    impl InferenceEngine for CapturingLlm {
5839        async fn status(&self) -> EngineStatus {
5840            EngineStatus {
5841                is_ready: true,
5842                loaded_models: vec![self.config.model.model_id.clone()],
5843                active_requests: 0,
5844                queued_requests: 0,
5845                memory_usage: MemoryUsage {
5846                    total_bytes: 0,
5847                    used_bytes: 0,
5848                    free_bytes: 0,
5849                    gpu_memory_bytes: None,
5850                    cpu_memory_bytes: None,
5851                    cache_memory_bytes: 0,
5852                    utilization_percent: 0.0,
5853                },
5854                uptime_seconds: 0,
5855                last_heartbeat: chrono::Utc::now(),
5856                version: "test".to_string(),
5857            }
5858        }
5859
5860        async fn shutdown(&self) -> ferrum_types::Result<()> {
5861            Ok(())
5862        }
5863
5864        fn config(&self) -> &EngineConfig {
5865            &self.config
5866        }
5867
5868        fn metrics(&self) -> EngineMetrics {
5869            EngineMetrics::default()
5870        }
5871
5872        async fn health_check(&self) -> EngineHealthStatus {
5873            EngineHealthStatus::healthy()
5874        }
5875    }
5876
5877    fn stub_execution_evidence(
5878        request: &InferenceRequest,
5879        output_token_count: usize,
5880    ) -> Option<InferenceExecutionEvidence> {
5881        let requested = &request.evidence_request;
5882        if !requested.capture_prompt_token_ids && !requested.capture_engine_token_timing {
5883            return None;
5884        }
5885        Some(InferenceExecutionEvidence {
5886            prompt_token_ids: requested
5887                .capture_prompt_token_ids
5888                .then(|| vec![TokenId::new(101), TokenId::new(202), TokenId::new(303)])
5889                .unwrap_or_default(),
5890            output_token_ids: (0..output_token_count)
5891                .map(|index| TokenId::new(11 + index as u32))
5892                .collect(),
5893            engine_token_timing: requested.capture_engine_token_timing.then(|| {
5894                EngineTokenTimingEvidence {
5895                    clock_source: "rust_std_instant".to_string(),
5896                    wall_anchor_unix_nanos: 1_700_000_000_000_000_000,
5897                    wall_anchor_max_error_nanos: 500,
5898                    decode_ready_nanos_since_request_start: Some(1_000_000),
5899                    token_commit_nanos_since_request_start: (1..=output_token_count)
5900                        .map(|ordinal| ordinal as u64 * 1_000_000)
5901                        .collect(),
5902                    decode_stage_intervals: Vec::new(),
5903                }
5904            }),
5905        })
5906    }
5907
5908    #[async_trait]
5909    impl LlmInferenceEngine for StubLlm {
5910        async fn infer(
5911            &self,
5912            request: InferenceRequest,
5913        ) -> ferrum_types::Result<InferenceResponse> {
5914            let execution_evidence = stub_execution_evidence(&request, 2);
5915            Ok(InferenceResponse {
5916                request_id: request.id,
5917                text: self.text.clone(),
5918                tokens: vec![TokenId::new(11), TokenId::new(12)],
5919                finish_reason: self.finish_reason,
5920                usage: TokenUsage::new(7, 2),
5921                latency_ms: 1,
5922                created_at: chrono::Utc::now(),
5923                metadata: HashMap::new(),
5924                api_response: self.api_response.clone(),
5925                execution_evidence,
5926            })
5927        }
5928
5929        async fn infer_stream(
5930            &self,
5931            request: InferenceRequest,
5932        ) -> ferrum_types::Result<
5933            Pin<Box<dyn Stream<Item = ferrum_types::Result<StreamChunk>> + Send>>,
5934        > {
5935            if let Some(drop_notify) = self.pending_stream_drop_notify.as_ref() {
5936                return Ok(Box::pin(PendingDropStream {
5937                    drop_notify: Arc::clone(drop_notify),
5938                }));
5939            }
5940            if let Some(chunks) = &self.stream_chunks {
5941                let completion_token_count = self
5942                    .stream_usage
5943                    .as_ref()
5944                    .map(|usage| usage.completion_tokens)
5945                    .unwrap_or(chunks.len());
5946                let execution_evidence = stub_execution_evidence(&request, completion_token_count);
5947                let request_id = request.id;
5948                let mut stream_chunks = Vec::with_capacity(
5949                    chunks.len() + usize::from(self.stream_final_chunk_separate),
5950                );
5951                let last = chunks.len().saturating_sub(1);
5952                for (index, text) in chunks.iter().enumerate() {
5953                    let is_final_text_chunk = index == last && !self.stream_final_chunk_separate;
5954                    stream_chunks.push(Ok(StreamChunk {
5955                        request_id: request_id.clone(),
5956                        text: text.clone(),
5957                        token: Some(TokenId::new(11 + index as u32)),
5958                        finish_reason: is_final_text_chunk.then_some(self.finish_reason),
5959                        usage: is_final_text_chunk
5960                            .then(|| self.stream_usage.clone())
5961                            .flatten(),
5962                        created_at: chrono::Utc::now(),
5963                        metadata: HashMap::new(),
5964                        api_response: is_final_text_chunk
5965                            .then(|| self.api_response.clone())
5966                            .flatten(),
5967                        execution_evidence: is_final_text_chunk
5968                            .then(|| execution_evidence.clone())
5969                            .flatten(),
5970                    }));
5971                }
5972                if self.stream_final_chunk_separate {
5973                    stream_chunks.push(Ok(StreamChunk {
5974                        request_id,
5975                        text: String::new(),
5976                        token: None,
5977                        finish_reason: Some(self.finish_reason),
5978                        usage: self.stream_usage.clone(),
5979                        created_at: chrono::Utc::now(),
5980                        metadata: HashMap::new(),
5981                        api_response: self.api_response.clone(),
5982                        execution_evidence,
5983                    }));
5984                }
5985                return Ok(Box::pin(stream::iter(stream_chunks)));
5986            }
5987
5988            let execution_evidence = stub_execution_evidence(&request, 1);
5989            let chunk = StreamChunk {
5990                request_id: request.id,
5991                text: self.text.clone(),
5992                token: Some(TokenId::new(11)),
5993                finish_reason: Some(self.finish_reason),
5994                usage: self.stream_usage.clone(),
5995                created_at: chrono::Utc::now(),
5996                metadata: HashMap::new(),
5997                api_response: self.api_response.clone(),
5998                execution_evidence,
5999            };
6000            Ok(Box::pin(stream::iter(vec![Ok(chunk)])))
6001        }
6002    }
6003
6004    #[async_trait]
6005    impl LlmInferenceEngine for FailingLlm {
6006        async fn infer(
6007            &self,
6008            _request: InferenceRequest,
6009        ) -> ferrum_types::Result<InferenceResponse> {
6010            Err(self.infer_failure.clone())
6011        }
6012
6013        async fn infer_stream(
6014            &self,
6015            request: InferenceRequest,
6016        ) -> ferrum_types::Result<
6017            Pin<Box<dyn Stream<Item = ferrum_types::Result<StreamChunk>> + Send>>,
6018        > {
6019            if self.fail_after_stream_start {
6020                let _request_id = request.id;
6021                return Ok(Box::pin(stream::iter(vec![Err(self
6022                    .stream_chunk_failure
6023                    .clone())])));
6024            }
6025            Err(self.stream_start_failure.clone())
6026        }
6027    }
6028
6029    #[async_trait]
6030    impl LlmInferenceEngine for CapturingLlm {
6031        async fn infer(
6032            &self,
6033            request: InferenceRequest,
6034        ) -> ferrum_types::Result<InferenceResponse> {
6035            *self.last_request.lock().expect("capture lock") = Some(request.clone());
6036            Ok(InferenceResponse {
6037                request_id: request.id,
6038                text: "captured".to_string(),
6039                tokens: vec![TokenId::new(21)],
6040                finish_reason: FinishReason::Stop,
6041                usage: TokenUsage::new(9, 1),
6042                latency_ms: 1,
6043                created_at: chrono::Utc::now(),
6044                metadata: HashMap::new(),
6045                api_response: None,
6046                execution_evidence: None,
6047            })
6048        }
6049
6050        async fn infer_stream(
6051            &self,
6052            request: InferenceRequest,
6053        ) -> ferrum_types::Result<
6054            Pin<Box<dyn Stream<Item = ferrum_types::Result<StreamChunk>> + Send>>,
6055        > {
6056            *self.last_request.lock().expect("capture lock") = Some(request.clone());
6057            let chunk = StreamChunk {
6058                request_id: request.id,
6059                text: "captured".to_string(),
6060                token: Some(TokenId::new(21)),
6061                finish_reason: Some(FinishReason::Stop),
6062                usage: Some(TokenUsage::new(9, 1)),
6063                created_at: chrono::Utc::now(),
6064                metadata: HashMap::new(),
6065                api_response: None,
6066                execution_evidence: None,
6067            };
6068            Ok(Box::pin(stream::iter(vec![Ok(chunk)])))
6069        }
6070    }
6071
6072    fn state_with_stub(text: &str) -> AppState {
6073        AppState::default().with_llm(Arc::new(StubLlm::new(text)))
6074    }
6075
6076    fn router_with_stub(text: &str) -> Router {
6077        AxumServer::from_llm(Arc::new(StubLlm::new(text))).build_router()
6078    }
6079
6080    fn router_with_stub_and_template(text: &str, template: ModelChatTemplate) -> Router {
6081        AxumServer::from_llm(Arc::new(StubLlm::new(text)))
6082            .with_prompt_template(Some(template))
6083            .build_router()
6084    }
6085
6086    fn router_with_stub_and_request_dump_dir(text: &str, request_dump_dir: PathBuf) -> Router {
6087        AxumServer::from_state(
6088            AppState::default()
6089                .with_llm(Arc::new(StubLlm::new(text)))
6090                .with_request_dump_dir(Some(request_dump_dir)),
6091        )
6092        .build_router()
6093    }
6094
6095    fn router_with_stub_request_dump_and_profile(
6096        text: &str,
6097        request_dump_dir: PathBuf,
6098        profile_jsonl: PathBuf,
6099    ) -> Router {
6100        AxumServer::from_state(
6101            AppState::default()
6102                .with_llm(Arc::new(StubLlm::new(text)))
6103                .with_request_dump_dir(Some(request_dump_dir))
6104                .with_profile_detail(ferrum_types::ObservabilityProfileDetail::Latency)
6105                .with_profile_jsonl(Some(profile_jsonl)),
6106        )
6107        .build_router()
6108    }
6109
6110    fn router_with_stub_stream_chunks(chunks: &[&str]) -> Router {
6111        AxumServer::from_llm(Arc::new(StubLlm::with_stream_chunks(chunks))).build_router()
6112    }
6113
6114    fn router_with_stub_stream_chunks_and_request_dump_dir(
6115        chunks: &[&str],
6116        request_dump_dir: PathBuf,
6117    ) -> Router {
6118        AxumServer::from_state(
6119            AppState::default()
6120                .with_llm(Arc::new(StubLlm::with_stream_chunks(chunks)))
6121                .with_request_dump_dir(Some(request_dump_dir)),
6122        )
6123        .build_router()
6124    }
6125
6126    fn router_with_stub_stream_request_dump_and_profile(
6127        chunks: &[&str],
6128        request_dump_dir: PathBuf,
6129        profile_jsonl: PathBuf,
6130    ) -> Router {
6131        AxumServer::from_state(
6132            AppState::default()
6133                .with_llm(Arc::new(StubLlm::with_stream_chunks(chunks)))
6134                .with_request_dump_dir(Some(request_dump_dir))
6135                .with_profile_detail(ferrum_types::ObservabilityProfileDetail::Latency)
6136                .with_profile_jsonl(Some(profile_jsonl)),
6137        )
6138        .build_router()
6139    }
6140
6141    fn router_with_stub_separate_final_stream_chunk(chunks: &[&str]) -> Router {
6142        AxumServer::from_llm(Arc::new(StubLlm::with_separate_final_stream_chunk(chunks)))
6143            .build_router()
6144    }
6145
6146    fn router_with_stub_api_response(
6147        text: &str,
6148        api_response: ferrum_types::ApiResponse,
6149    ) -> Router {
6150        AxumServer::from_llm(Arc::new(StubLlm::with_api_response(text, api_response)))
6151            .build_router()
6152    }
6153
6154    fn router_with_stub_api_response_and_finish_reason(
6155        text: &str,
6156        api_response: ferrum_types::ApiResponse,
6157        finish_reason: FinishReason,
6158    ) -> Router {
6159        AxumServer::from_llm(Arc::new(StubLlm::with_api_response_and_finish_reason(
6160            text,
6161            api_response,
6162            finish_reason,
6163        )))
6164        .build_router()
6165    }
6166
6167    fn weather_tool_api_response() -> ferrum_types::ApiResponse {
6168        ferrum_types::ApiResponse::Chat(ferrum_types::ApiChatResponse {
6169            message: ferrum_types::ApiChatMessage {
6170                role: ferrum_types::ApiMessageRole::Assistant,
6171                content: String::new(),
6172                name: None,
6173                tool_calls: vec![ferrum_types::ApiToolCall {
6174                    id: "call_1".to_string(),
6175                    tool_type: "function".to_string(),
6176                    function: ferrum_types::ApiFunctionCall {
6177                        name: "weather".to_string(),
6178                        arguments: "{\"city\":\"Paris\"}".to_string(),
6179                    },
6180                }],
6181                tool_call_id: None,
6182                function_call: None,
6183            },
6184            finish_reason: Some("tool_calls".to_string()),
6185        })
6186    }
6187
6188    fn router_with_stub_without_stream_usage(text: &str) -> Router {
6189        AxumServer::from_llm(Arc::new(StubLlm::without_stream_usage(text))).build_router()
6190    }
6191
6192    fn router_without_llm() -> Router {
6193        AxumServer::from_state(AppState::default()).build_router()
6194    }
6195
6196    fn router_with_failing_llm() -> Router {
6197        AxumServer::from_llm(Arc::new(FailingLlm::new())).build_router()
6198    }
6199
6200    fn router_with_failing_llm_and_request_dump_dir(request_dump_dir: PathBuf) -> Router {
6201        AxumServer::from_state(
6202            AppState::default()
6203                .with_llm(Arc::new(FailingLlm::new()))
6204                .with_request_dump_dir(Some(request_dump_dir)),
6205        )
6206        .build_router()
6207    }
6208
6209    fn router_with_failing_llm_request_dump_and_profile(
6210        request_dump_dir: PathBuf,
6211        profile_jsonl: PathBuf,
6212    ) -> Router {
6213        AxumServer::from_state(
6214            AppState::default()
6215                .with_llm(Arc::new(FailingLlm::new()))
6216                .with_request_dump_dir(Some(request_dump_dir))
6217                .with_profile_detail(ferrum_types::ObservabilityProfileDetail::Latency)
6218                .with_profile_jsonl(Some(profile_jsonl)),
6219        )
6220        .build_router()
6221    }
6222
6223    fn router_with_resource_exhausted_llm_and_request_dump_dir(
6224        request_dump_dir: PathBuf,
6225    ) -> Router {
6226        AxumServer::from_state(
6227            AppState::default()
6228                .with_llm(Arc::new(FailingLlm::resource_exhausted()))
6229                .with_request_dump_dir(Some(request_dump_dir)),
6230        )
6231        .build_router()
6232    }
6233
6234    fn router_with_stream_chunk_failing_llm() -> Router {
6235        AxumServer::from_llm(Arc::new(FailingLlm::after_stream_start())).build_router()
6236    }
6237
6238    fn router_with_stream_chunk_failing_llm_and_request_dump_dir(
6239        request_dump_dir: PathBuf,
6240    ) -> Router {
6241        AxumServer::from_state(
6242            AppState::default()
6243                .with_llm(Arc::new(FailingLlm::after_stream_start()))
6244                .with_request_dump_dir(Some(request_dump_dir)),
6245        )
6246        .build_router()
6247    }
6248
6249    fn router_with_capturing_llm() -> (Router, Arc<CapturingLlm>) {
6250        let engine = Arc::new(CapturingLlm::new());
6251        let registry = ServedModelRegistry::try_new(
6252            "qwen3",
6253            ServedModelKind::Llm,
6254            vec![
6255                "qwen3".to_string(),
6256                "stub-model".to_string(),
6257                "served-alias".to_string(),
6258            ],
6259            vec![],
6260        )
6261        .unwrap();
6262        let router = AxumServer::from_llm(engine.clone())
6263            .with_served_model_registry(registry)
6264            .build_router();
6265        (router, engine)
6266    }
6267
6268    fn unique_request_dump_dir(test_name: &str) -> PathBuf {
6269        let path =
6270            std::env::temp_dir().join(format!("ferrum-server-{test_name}-{}", Uuid::new_v4()));
6271        fs::create_dir_all(&path).expect("create request dump dir");
6272        path
6273    }
6274
6275    fn unique_profile_jsonl(test_name: &str) -> PathBuf {
6276        std::env::temp_dir().join(format!(
6277            "ferrum-server-{test_name}-{}.jsonl",
6278            Uuid::new_v4()
6279        ))
6280    }
6281
6282    fn only_replay_bundle(root: &Path) -> PathBuf {
6283        let mut dirs = fs::read_dir(root)
6284            .expect("read request dump dir")
6285            .filter_map(|entry| {
6286                let path = entry.expect("dir entry").path();
6287                path.is_dir().then_some(path)
6288            })
6289            .collect::<Vec<_>>();
6290        dirs.sort();
6291        assert_eq!(
6292            dirs.len(),
6293            1,
6294            "expected exactly one replay bundle in {root:?}"
6295        );
6296        dirs.remove(0)
6297    }
6298
6299    fn read_json_file(path: impl AsRef<Path>) -> Value {
6300        let path = path.as_ref();
6301        let text = fs::read_to_string(path).unwrap_or_else(|err| {
6302            panic!("failed to read {}: {}", path.display(), err);
6303        });
6304        serde_json::from_str(&text).unwrap_or_else(|err| {
6305            panic!("failed to parse {}: {}", path.display(), err);
6306        })
6307    }
6308
6309    fn read_profile_events(path: &Path) -> Vec<Value> {
6310        let text = fs::read_to_string(path)
6311            .unwrap_or_else(|err| panic!("failed to read {}: {}", path.display(), err));
6312        text.lines()
6313            .filter(|line| !line.trim().is_empty())
6314            .map(|line| serde_json::from_str::<Value>(line).expect("profile event json"))
6315            .collect()
6316    }
6317
6318    fn assert_chat_failure_replay_bundle(
6319        root: &Path,
6320        expected_phase: &str,
6321        expected_error_kind: &str,
6322        expected_message: &str,
6323    ) {
6324        let bundle = only_replay_bundle(root);
6325        let request = read_json_file(bundle.join("request.json"));
6326        let request_id = request["request_id"]
6327            .as_str()
6328            .expect("request id")
6329            .to_string();
6330        let bad_scan = read_json_file(bundle.join("bad_output_scan.json"));
6331        assert_eq!(bad_scan["request_id"], request_id);
6332        assert_eq!(bad_scan["failure_kind"], "error");
6333        assert_eq!(bad_scan["failure_phase"], expected_phase);
6334        assert_eq!(bad_scan["error_kind"], expected_error_kind);
6335
6336        let diagnostics = read_json_file(bundle.join("failure_diagnostics.json"));
6337        assert_eq!(diagnostics["request_id"], request_id);
6338        assert_eq!(diagnostics["failure_kind"], "error");
6339        assert_eq!(diagnostics["first_failure_event"]["phase"], expected_phase);
6340        assert_eq!(
6341            diagnostics["first_failure_event"]["error_kind"],
6342            expected_error_kind
6343        );
6344        assert_eq!(diagnostics["nearest_request_id"], request_id);
6345        assert!(diagnostics["log_excerpt"]
6346            .as_str()
6347            .expect("log excerpt")
6348            .contains(expected_message));
6349        assert!(bundle.join("replay.command.json").is_file());
6350    }
6351
6352    fn assert_chat_success_replay_bundle(
6353        root: &Path,
6354        expected_token_ids: &[u32],
6355        expected_finish_reason: &str,
6356        expected_output_text: &str,
6357    ) {
6358        let bundle = only_replay_bundle(root);
6359        let request = read_json_file(bundle.join("request.json"));
6360        let request_id = request["request_id"]
6361            .as_str()
6362            .expect("request id")
6363            .to_string();
6364        let prompt_tokens = read_json_file(bundle.join("prompt_token_ids.json"));
6365        assert_eq!(prompt_tokens["request_id"], request_id);
6366        assert_eq!(prompt_tokens["token_ids"], json!([101, 202, 303]));
6367        assert_eq!(prompt_tokens["token_count"], 3);
6368        assert!(prompt_tokens["unavailable_reason"].is_null());
6369        let output_tokens = read_json_file(bundle.join("output_token_ids.json"));
6370        assert_eq!(output_tokens["request_id"], request_id);
6371        assert_eq!(output_tokens["token_ids"], json!(expected_token_ids));
6372        assert_eq!(output_tokens["token_count"], expected_token_ids.len());
6373        assert_eq!(output_tokens["finish_reason"], expected_finish_reason);
6374        assert!(output_tokens["unavailable_reason"].is_null());
6375
6376        let bad_scan = read_json_file(bundle.join("bad_output_scan.json"));
6377        assert_eq!(bad_scan["request_id"], request_id);
6378        assert_eq!(bad_scan["bad_output"], false);
6379        assert_eq!(bad_scan["failure_kind"], serde_json::Value::Null);
6380        assert_eq!(
6381            bad_scan["output_chars"],
6382            expected_output_text.chars().count()
6383        );
6384        assert_eq!(
6385            bad_scan["classified_output_sha256"],
6386            sha256_hex(expected_output_text.as_bytes())
6387        );
6388
6389        let output_text_bytes = fs::read(bundle.join("output_text.txt")).unwrap();
6390        assert_eq!(bad_scan["output_sha256"], sha256_hex(&output_text_bytes));
6391        let output_text = String::from_utf8(output_text_bytes).unwrap();
6392        assert!(output_text.contains("[redacted actual output]"));
6393        assert!(output_text.contains(&format!(
6394            "sha256={}",
6395            sha256_hex(expected_output_text.as_bytes())
6396        )));
6397        assert!(output_text.contains(&format!("chars={}", expected_output_text.chars().count())));
6398
6399        let replay_body = read_json_file(bundle.join("replay_body.json"));
6400        assert_eq!(replay_body["messages"][0]["role"], "user");
6401        assert_eq!(replay_body["messages"][0]["content"], "[redacted]");
6402        assert_eq!(replay_body["messages"][0]["content_redacted"], true);
6403
6404        let replay = read_json_file(bundle.join("replay.command.json"));
6405        assert_eq!(replay["requires_running_server"], true);
6406        let argv = replay["argv"].as_array().expect("replay argv");
6407        assert!(argv.iter().any(|item| item == "--data-binary"));
6408        assert!(argv.iter().any(|item| {
6409            item.as_str()
6410                .is_some_and(|value| value.starts_with('@') && value.ends_with("replay_body.json"))
6411        }));
6412        assert_eq!(replay["engine_replay"]["requires_http_server"], false);
6413        let engine_argv = replay["engine_replay"]["argv"]
6414            .as_array()
6415            .expect("engine replay argv");
6416        assert!(engine_argv.iter().any(|item| item == "replay-bundle"));
6417    }
6418
6419    fn router_with_capturing_llm_and_template(
6420        template: ModelChatTemplate,
6421    ) -> (Router, Arc<CapturingLlm>) {
6422        router_with_capturing_llm_and_template_default(template, None)
6423    }
6424
6425    fn router_with_capturing_llm_and_template_default(
6426        template: ModelChatTemplate,
6427        default_enable_thinking: Option<bool>,
6428    ) -> (Router, Arc<CapturingLlm>) {
6429        let engine = Arc::new(CapturingLlm::new());
6430        let registry = ServedModelRegistry::try_new(
6431            "qwen3",
6432            ServedModelKind::Llm,
6433            vec!["served-alias".to_string()],
6434            vec![],
6435        )
6436        .unwrap();
6437        let router = AxumServer::from_llm(engine.clone())
6438            .with_served_model_registry(registry)
6439            .with_prompt_template(Some(template))
6440            .with_default_enable_thinking(default_enable_thinking)
6441            .build_router();
6442        (router, engine)
6443    }
6444
6445    fn router_with_capturing_lora_llm() -> (Router, Arc<CapturingLlm>) {
6446        let engine = Arc::new(CapturingLlm::new());
6447        let router = AxumServer::from_llm(engine.clone())
6448            .with_lora_adapters(
6449                "qwen3",
6450                vec![LoraAdapterModel::new(
6451                    "sql",
6452                    "qwen3:sql",
6453                    "/tmp/sql-adapter",
6454                )],
6455            )
6456            .unwrap()
6457            .build_router();
6458        (router, engine)
6459    }
6460
6461    fn router_with_stub_embed() -> Router {
6462        AxumServer::from_embed(Arc::new(StubEmbed::new())).build_router()
6463    }
6464
6465    fn router_with_stub_transcribe() -> Router {
6466        AxumServer::from_transcribe(Arc::new(StubTranscribe::new())).build_router()
6467    }
6468
6469    fn router_with_stub_tts() -> Router {
6470        AxumServer::from_tts(Arc::new(StubTts::new())).build_router()
6471    }
6472
6473    async fn post_json(app: Router, path: &str, body: Value) -> Response {
6474        app.oneshot(
6475            Request::builder()
6476                .method("POST")
6477                .uri(path)
6478                .header(header::CONTENT_TYPE, "application/json")
6479                .body(Body::from(body.to_string()))
6480                .expect("request"),
6481        )
6482        .await
6483        .expect("route response")
6484    }
6485
6486    async fn post_raw_json(app: Router, path: &str, body: &str) -> Response {
6487        app.oneshot(
6488            Request::builder()
6489                .method("POST")
6490                .uri(path)
6491                .header(header::CONTENT_TYPE, "application/json")
6492                .body(Body::from(body.to_string()))
6493                .expect("request"),
6494        )
6495        .await
6496        .expect("route response")
6497    }
6498
6499    async fn post_multipart(app: Router, path: &str, boundary: &str, body: &str) -> Response {
6500        app.oneshot(
6501            Request::builder()
6502                .method("POST")
6503                .uri(path)
6504                .header(
6505                    header::CONTENT_TYPE,
6506                    format!("multipart/form-data; boundary={boundary}"),
6507                )
6508                .body(Body::from(body.to_string()))
6509                .expect("request"),
6510        )
6511        .await
6512        .expect("route response")
6513    }
6514
6515    async fn get(app: Router, path: &str) -> Response {
6516        app.oneshot(
6517            Request::builder()
6518                .method("GET")
6519                .uri(path)
6520                .body(Body::empty())
6521                .expect("request"),
6522        )
6523        .await
6524        .expect("route response")
6525    }
6526
6527    async fn response_json(response: Response) -> Value {
6528        let bytes = to_bytes(response.into_body(), usize::MAX)
6529            .await
6530            .expect("body bytes");
6531        serde_json::from_slice(&bytes).expect("json body")
6532    }
6533
6534    async fn response_text(response: Response) -> String {
6535        let bytes = to_bytes(response.into_body(), usize::MAX)
6536            .await
6537            .expect("body bytes");
6538        String::from_utf8(bytes.to_vec()).expect("utf8 body")
6539    }
6540
6541    async fn response_bytes(response: Response) -> Vec<u8> {
6542        to_bytes(response.into_body(), usize::MAX)
6543            .await
6544            .expect("body bytes")
6545            .to_vec()
6546    }
6547
6548    async fn error_json(error: ServerError) -> (AxumStatusCode, Value) {
6549        let response = error.into_response();
6550        let status = response.status();
6551        (status, response_json(response).await)
6552    }
6553
6554    fn assert_openai_stream_error(body: &str, expected_message: &str) {
6555        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
6556        assert!(
6557            body.contains("\"error\":{\"message\":\""),
6558            "stream failure should emit OpenAI error envelope: {body}"
6559        );
6560        assert!(
6561            body.contains(expected_message),
6562            "stream failure should include engine error message {expected_message:?}: {body}"
6563        );
6564        assert!(
6565            body.contains("\"type\":\"internal_server_error\""),
6566            "stream failure should use internal_server_error: {body}"
6567        );
6568        assert!(
6569            !body.contains("{\"error\":\""),
6570            "stream failure must not use legacy bare error payload: {body}"
6571        );
6572    }
6573
6574    fn chat_request(extra: Value) -> ChatCompletionsRequest {
6575        let mut value = json!({
6576            "model": "stub-model",
6577            "messages": [{"role": "user", "content": "hello"}],
6578            "max_tokens": 8
6579        });
6580        let obj = value.as_object_mut().unwrap();
6581        for (k, v) in extra.as_object().unwrap() {
6582            obj.insert(k.clone(), v.clone());
6583        }
6584        serde_json::from_value(value).expect("chat request")
6585    }
6586
6587    #[tokio::test]
6588    async fn responses_route_returns_sync_text_and_usage() {
6589        let response = post_json(
6590            router_with_stub("hello from ferrum"),
6591            "/v1/responses",
6592            json!({
6593                "model": "stub-model",
6594                "input": "hello",
6595                "store": false
6596            }),
6597        )
6598        .await;
6599        assert_eq!(response.status(), AxumStatusCode::OK);
6600        let body = response_json(response).await;
6601        assert_eq!(body["object"], "response");
6602        assert_eq!(body["status"], "completed");
6603        assert_eq!(body["store"], false);
6604        assert_eq!(body["output"][0]["type"], "message");
6605        assert_eq!(body["output"][0]["content"][0]["text"], "hello from ferrum");
6606        assert_eq!(body["usage"]["input_tokens"], 7);
6607        assert_eq!(body["usage"]["output_tokens"], 2);
6608        assert_eq!(body["usage"]["total_tokens"], 9);
6609    }
6610
6611    #[tokio::test]
6612    async fn responses_route_streams_ordered_text_events_once() {
6613        let response = post_json(
6614            router_with_stub_stream_chunks(&["he", "llo"]),
6615            "/v1/responses",
6616            json!({
6617                "model": "stub-model",
6618                "input": [{"role": "user", "content": "say hello"}],
6619                "stream": true
6620            }),
6621        )
6622        .await;
6623        assert_eq!(response.status(), AxumStatusCode::OK);
6624        let body = response_text(response).await;
6625        for event in [
6626            "response.created",
6627            "response.output_item.added",
6628            "response.output_text.delta",
6629            "response.output_text.done",
6630            "response.output_item.done",
6631            "response.completed",
6632        ] {
6633            assert!(
6634                body.contains(&format!("event: {event}")),
6635                "missing {event}: {body}"
6636            );
6637        }
6638        assert_eq!(
6639            body.matches("event: response.completed").count(),
6640            1,
6641            "completed must be emitted exactly once: {body}"
6642        );
6643        assert!(
6644            body.contains("\"delta\":\"he\""),
6645            "missing first delta: {body}"
6646        );
6647        assert!(
6648            body.contains("\"delta\":\"llo\""),
6649            "missing second delta: {body}"
6650        );
6651        assert!(body.contains("\"input_tokens\":5"), "missing usage: {body}");
6652        assert!(
6653            !body.contains("[DONE]"),
6654            "Responses streams do not use chat DONE: {body}"
6655        );
6656    }
6657
6658    #[tokio::test]
6659    async fn responses_route_supports_stateless_function_round_trip() {
6660        let tool = json!({
6661            "type": "function",
6662            "name": "weather",
6663            "description": "Get weather",
6664            "parameters": {
6665                "type": "object",
6666                "properties": {"city": {"type": "string"}},
6667                "required": ["city"]
6668            }
6669        });
6670        let first = post_json(
6671            router_with_stub_api_response("", weather_tool_api_response()),
6672            "/v1/responses",
6673            json!({
6674                "model": "stub-model",
6675                "input": "Use the weather tool",
6676                "tools": [tool.clone()],
6677                "tool_choice": "auto"
6678            }),
6679        )
6680        .await;
6681        assert_eq!(first.status(), AxumStatusCode::OK);
6682        let first_body = response_json(first).await;
6683        let call = first_body["output"][0].clone();
6684        assert_eq!(call["type"], "function_call");
6685        assert_eq!(call["call_id"], "call_1");
6686        assert_eq!(call["name"], "weather");
6687        assert_eq!(call["arguments"], "{\"city\":\"Paris\"}");
6688
6689        let second = post_json(
6690            router_with_stub("weather received"),
6691            "/v1/responses",
6692            json!({
6693                "model": "stub-model",
6694                "input": [
6695                    {"role": "user", "content": "Use the weather tool"},
6696                    call,
6697                    {"type": "function_call_output", "call_id": "call_1", "output": "sunny"}
6698                ],
6699                "tools": [tool]
6700            }),
6701        )
6702        .await;
6703        assert_eq!(second.status(), AxumStatusCode::OK);
6704        let second_body = response_json(second).await;
6705        assert_eq!(
6706            second_body["output"][0]["content"][0]["text"],
6707            "weather received"
6708        );
6709    }
6710
6711    #[tokio::test]
6712    async fn responses_route_streams_function_call_events() {
6713        let response = post_json(
6714            router_with_stub_api_response("", weather_tool_api_response()),
6715            "/v1/responses",
6716            json!({
6717                "model": "stub-model",
6718                "input": "Use the weather tool",
6719                "stream": true,
6720                "tools": [{
6721                    "type": "function",
6722                    "name": "weather",
6723                    "parameters": {"type": "object"}
6724                }]
6725            }),
6726        )
6727        .await;
6728        assert_eq!(response.status(), AxumStatusCode::OK);
6729        let body = response_text(response).await;
6730        assert!(
6731            body.contains("event: response.function_call_arguments.delta"),
6732            "missing function delta: {body}"
6733        );
6734        assert!(
6735            body.contains("event: response.function_call_arguments.done"),
6736            "missing function done: {body}"
6737        );
6738        assert!(
6739            body.contains("\"call_id\":\"call_1\""),
6740            "missing call id: {body}"
6741        );
6742        assert_eq!(body.matches("event: response.completed").count(), 1);
6743    }
6744
6745    #[tokio::test]
6746    async fn responses_route_rejects_state_and_non_function_tools() {
6747        for (extra, param) in [
6748            (json!({"store": true}), "store"),
6749            (
6750                json!({"previous_response_id": "resp_previous"}),
6751                "previous_response_id",
6752            ),
6753            (
6754                json!({"tools": [{"type": "mcp", "server_label": "docs"}]}),
6755                "tools[0].type",
6756            ),
6757        ] {
6758            let mut body = json!({"model": "stub-model", "input": "hello"});
6759            body.as_object_mut()
6760                .unwrap()
6761                .extend(extra.as_object().unwrap().clone());
6762            let response = post_json(router_with_stub("unused"), "/v1/responses", body).await;
6763            assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
6764            let error = response_json(response).await;
6765            assert_eq!(error["error"]["param"], param, "error: {error}");
6766        }
6767    }
6768
6769    #[tokio::test]
6770    async fn responses_mvp_keeps_chat_completions_route_working() {
6771        let response = post_json(
6772            router_with_stub("chat still works"),
6773            "/v1/chat/completions",
6774            json!({
6775                "model": "stub-model",
6776                "messages": [{"role": "user", "content": "hello"}]
6777            }),
6778        )
6779        .await;
6780        assert_eq!(response.status(), AxumStatusCode::OK);
6781        let body = response_json(response).await;
6782        assert_eq!(body["choices"][0]["message"]["content"], "chat still works");
6783    }
6784
6785    #[test]
6786    fn sanitized_chat_request_body_redacts_user_text_and_secret_metadata() {
6787        let request = chat_request(json!({
6788            "messages": [{"role": "user", "content": "private prompt"}],
6789            "metadata": {"api_key": "should-not-survive"},
6790            "stream": true
6791        }));
6792        let body = sanitized_chat_request_body(&request);
6793        assert_eq!(body["model"], "stub-model");
6794        assert_eq!(body["stream"], true);
6795        assert_eq!(body["messages"][0]["role"], "user");
6796        assert_eq!(body["messages"][0]["content"], "[redacted]");
6797        assert_eq!(body["messages"][0]["content_redacted"], true);
6798        assert_eq!(body["messages"][0]["content_chars"], 14);
6799        assert_eq!(body["metadata"]["api_key"], "[redacted]");
6800    }
6801
6802    #[test]
6803    fn admission_health_prefers_runtime_authority_over_preflight_estimate() {
6804        let engine_status = EngineStatus {
6805            is_ready: true,
6806            loaded_models: Vec::new(),
6807            active_requests: 2,
6808            queued_requests: 1,
6809            memory_usage: MemoryUsage {
6810                total_bytes: 0,
6811                used_bytes: 0,
6812                free_bytes: 0,
6813                gpu_memory_bytes: None,
6814                cpu_memory_bytes: None,
6815                cache_memory_bytes: 0,
6816                utilization_percent: 0.0,
6817            },
6818            uptime_seconds: 0,
6819            last_heartbeat: chrono::Utc::now(),
6820            version: "test".to_owned(),
6821        };
6822        let runtime = ferrum_types::ExecutorAdmissionSnapshot::new(
6823            ferrum_types::ExecutionResourceAuthority::PlanRuntime,
6824            ferrum_types::ExecutorAdmissionLimits::new(32, 4096).unwrap(),
6825            2,
6826            7,
6827            23,
6828            None,
6829            Some(3),
6830        )
6831        .unwrap();
6832        let admission = admission_health_json(
6833            &engine_status,
6834            &EngineMetrics::default(),
6835            &json!({
6836                "admission": {
6837                    "effective_max_concurrent": 16,
6838                    "scheduler_policy": "continuous"
6839                }
6840            }),
6841            Some(&runtime),
6842            None,
6843        );
6844
6845        assert_eq!(admission["source"], "runtime_executor");
6846        assert_eq!(admission["runtime_snapshot_available"], true);
6847        assert_eq!(admission["resource_authority"], "plan_runtime");
6848        assert_eq!(admission["effective_max_concurrent"], 32);
6849        assert_eq!(admission["maximum_active_sequences"], 32);
6850        assert_eq!(admission["maximum_scheduled_tokens"], 4096);
6851        assert_eq!(admission["preflight_effective_max_concurrent"], 16);
6852        assert_eq!(admission["active_sequences"], 30);
6853        assert_eq!(admission["active_prefill"], 7);
6854        assert_eq!(admission["active_decode"], 23);
6855        assert!(admission["current_batch_size"].is_null());
6856        assert_eq!(admission["queue_depth"], 2);
6857        assert_eq!(admission["capacity_blocked_requests"], 3);
6858    }
6859
6860    #[test]
6861    fn admission_health_surfaces_runtime_contract_failure_without_preflight_fallback() {
6862        let engine_status = EngineStatus {
6863            is_ready: true,
6864            loaded_models: Vec::new(),
6865            active_requests: 32,
6866            queued_requests: 1,
6867            memory_usage: MemoryUsage {
6868                total_bytes: 0,
6869                used_bytes: 0,
6870                free_bytes: 0,
6871                gpu_memory_bytes: None,
6872                cpu_memory_bytes: None,
6873                cache_memory_bytes: 0,
6874                utilization_percent: 0.0,
6875            },
6876            uptime_seconds: 0,
6877            last_heartbeat: chrono::Utc::now(),
6878            version: "test".to_owned(),
6879        };
6880        let admission = admission_health_json(
6881            &engine_status,
6882            &EngineMetrics::default(),
6883            &json!({
6884                "admission": {
6885                    "effective_max_concurrent": 16,
6886                    "scheduler_policy": "continuous"
6887                }
6888            }),
6889            None,
6890            Some("active phase count exceeded the runtime ceiling"),
6891        );
6892
6893        assert_eq!(admission["source"], "runtime_error");
6894        assert_eq!(admission["runtime_snapshot_available"], false);
6895        assert_eq!(admission["preflight_effective_max_concurrent"], 16);
6896        assert!(admission["effective_max_concurrent"].is_null());
6897        assert!(admission["queue_depth"].is_null());
6898        assert_eq!(
6899            admission["runtime_contract_error"],
6900            "active phase count exceeded the runtime ceiling"
6901        );
6902    }
6903
6904    #[tokio::test]
6905    async fn route_health_includes_runtime_config_snapshot() {
6906        let response = get(router_with_stub("ok"), "/health").await;
6907        assert_eq!(response.status(), AxumStatusCode::OK);
6908        let body = response_json(response).await;
6909        assert_eq!(body["status"], "healthy");
6910        assert!(body["config"]["entries"].is_array(), "body: {body}");
6911        assert_eq!(body["auto_config"]["schema_version"], 1);
6912        assert!(body["auto_config"]["entries"].is_array(), "body: {body}");
6913        assert!(body["auto_config"]["admission"].is_object(), "body: {body}");
6914        assert_eq!(body["admission"]["schema_version"], 2);
6915        assert!(body["admission"]["effective_max_concurrent"].is_number());
6916        assert!(body["admission"]["queue_depth"].is_number());
6917        assert!(body["admission"]["active_sequences"].is_number());
6918        assert!(body["admission"]["active_prefill"].is_null());
6919        assert!(body["admission"]["active_decode"].is_null());
6920        assert!(body["admission"]["current_batch_size"].is_null());
6921        assert!(body["admission"]["rejected_requests_total"].is_number());
6922        assert!(body["admission"]["failed_requests_total"].is_number());
6923        assert!(body["admission"]["completed_requests_total"].is_number());
6924        assert!(body["admission"]["avg_queue_wait_time_ms"].is_number());
6925        assert!(body["scheduler"]["avg_wait_time_ms"].is_number());
6926        assert!(body["scheduler"]["scheduling_time_ms"].is_number());
6927        assert!(body["scheduler"]["model_execution_time_ms"].is_number());
6928        assert!(body["scheduler"]["iteration_lock_wait_time_ms"].is_number());
6929        assert!(
6930            body["auto_config"]["decisions"].is_array() || body["auto_config"]["error"].is_string(),
6931            "body: {body}"
6932        );
6933    }
6934
6935    #[tokio::test]
6936    async fn route_metrics_includes_admission_counters() {
6937        let response = get(router_with_stub("ok"), "/metrics").await;
6938        assert_eq!(response.status(), AxumStatusCode::OK);
6939        let body = response_text(response).await;
6940        for metric in [
6941            "ferrum_admission_runtime_snapshot_available",
6942            "ferrum_admission_effective_max_concurrent",
6943            "ferrum_admission_queue_depth",
6944            "ferrum_admission_active_sequences",
6945            "ferrum_admission_rejected_requests_total",
6946            "ferrum_admission_failed_requests_total",
6947            "ferrum_admission_completed_requests_total",
6948        ] {
6949            assert!(body.contains(metric), "missing {metric}:\n{body}");
6950        }
6951        for unavailable_metric in [
6952            "ferrum_admission_maximum_active_sequences ",
6953            "ferrum_admission_maximum_scheduled_tokens ",
6954            "ferrum_admission_capacity_blocked_requests ",
6955            "ferrum_admission_active_prefill ",
6956            "ferrum_admission_active_decode ",
6957            "ferrum_admission_current_batch_size ",
6958        ] {
6959            assert!(
6960                !body.contains(unavailable_metric),
6961                "unknown metric was encoded as a real value: {unavailable_metric}\n{body}"
6962            );
6963        }
6964    }
6965
6966    #[tokio::test]
6967    async fn route_health_includes_engine_lora_metrics_snapshot() {
6968        let router = AxumServer::from_llm(Arc::new(StubLlm::with_lora_metrics(
6969            "ok",
6970            json!({
6971                "enabled": true,
6972                "adapter_count": 1,
6973                "active_cache_bindings": 0,
6974                "projection_applications": 7,
6975                "position": "real-inference",
6976                "source": "test-lora",
6977            }),
6978        )))
6979        .with_lora_adapters(
6980            "stub-model",
6981            vec![LoraAdapterModel::new(
6982                "sql",
6983                "stub-model:sql",
6984                "/tmp/sql-adapter",
6985            )],
6986        )
6987        .unwrap()
6988        .build_router();
6989        let response = get(router, "/health").await;
6990        assert_eq!(response.status(), AxumStatusCode::OK);
6991        let body = response_json(response).await;
6992        assert_eq!(body["lora"]["enabled"], true);
6993        assert_eq!(body["lora"]["adapter_count"], 1);
6994        assert_eq!(body["lora"]["projection_applications"], 7);
6995        assert_eq!(body["lora"]["position"], "real-inference");
6996        assert_eq!(body["lora"]["source"], "test-lora");
6997    }
6998
6999    #[tokio::test]
7000    async fn route_models_lists_loaded_stub_model() {
7001        let response = get(router_with_stub("ok"), "/v1/models").await;
7002        assert_eq!(response.status(), AxumStatusCode::OK);
7003        let body = response_json(response).await;
7004        assert_eq!(body["object"], "list");
7005        let data = body["data"].as_array().expect("models data array");
7006        assert_eq!(data.len(), 1, "body: {body}");
7007        assert_eq!(data[0]["id"], "stub-model");
7008        assert_eq!(data[0]["object"], "model");
7009        assert_eq!(data[0]["owned_by"], "ferrum");
7010        assert!(data[0]["created"].as_u64().unwrap_or_default() > 0);
7011        assert_eq!(data[0]["modalities"], json!(["text"]));
7012        assert!(data[0]["permission"].as_array().unwrap().is_empty());
7013        assert!(data[0]["root"].is_null());
7014        assert!(data[0]["parent"].is_null());
7015    }
7016
7017    #[tokio::test]
7018    async fn route_chat_public_alias_maps_to_internal_model_and_is_echoed() {
7019        let engine = Arc::new(CapturingLlm::new());
7020        let registry = ServedModelRegistry::try_new(
7021            "qwen3",
7022            ServedModelKind::Llm,
7023            vec!["served-alias".to_string(), "secondary-alias".to_string()],
7024            vec![],
7025        )
7026        .unwrap();
7027        let router = AxumServer::from_llm(engine.clone())
7028            .with_served_model_registry(registry)
7029            .build_router();
7030        let response = post_json(
7031            router,
7032            "/v1/chat/completions",
7033            json!({
7034                "model": "secondary-alias",
7035                "messages": [{"role": "user", "content": "Say hi"}],
7036                "max_tokens": 8
7037            }),
7038        )
7039        .await;
7040
7041        assert_eq!(response.status(), AxumStatusCode::OK);
7042        let body = response_json(response).await;
7043        assert_eq!(body["model"], "secondary-alias");
7044        assert_eq!(engine.last_request().model_id, ModelId::new("qwen3"));
7045    }
7046
7047    #[tokio::test]
7048    async fn route_models_lists_public_aliases_without_internal_model_id() {
7049        let registry = ServedModelRegistry::try_new(
7050            "qwen3",
7051            ServedModelKind::Llm,
7052            vec!["served-alias".to_string(), "secondary-alias".to_string()],
7053            vec![],
7054        )
7055        .unwrap();
7056        let router = AxumServer::from_llm(Arc::new(CapturingLlm::new()))
7057            .with_served_model_registry(registry)
7058            .build_router();
7059        let body = response_json(get(router, "/v1/models").await).await;
7060        let ids = body["data"]
7061            .as_array()
7062            .unwrap()
7063            .iter()
7064            .map(|entry| entry["id"].as_str().unwrap())
7065            .collect::<Vec<_>>();
7066
7067        assert_eq!(ids, vec!["served-alias", "secondary-alias"]);
7068        assert!(!ids.contains(&"qwen3"));
7069        assert!(body["data"]
7070            .as_array()
7071            .unwrap()
7072            .iter()
7073            .all(|entry| entry["modalities"] == json!(["text"])));
7074    }
7075
7076    #[tokio::test]
7077    async fn route_models_lists_embedding_registry_capabilities() {
7078        let body = response_json(get(router_with_stub_embed(), "/v1/models").await).await;
7079        let data = body["data"].as_array().unwrap();
7080
7081        assert_eq!(data.len(), 1);
7082        assert_eq!(data[0]["id"], "stub-embed");
7083        assert_eq!(data[0]["modalities"], json!(["text", "image"]));
7084    }
7085
7086    #[tokio::test]
7087    async fn route_models_lists_startup_lora_adapters() {
7088        let router = AxumServer::from_llm(Arc::new(StubLlm::new("ok")))
7089            .with_lora_adapters(
7090                "stub-model",
7091                vec![LoraAdapterModel::new(
7092                    "sql",
7093                    "stub-model:sql",
7094                    "/tmp/sql-adapter",
7095                )],
7096            )
7097            .unwrap()
7098            .build_router();
7099        let response = get(router, "/v1/models").await;
7100        assert_eq!(response.status(), AxumStatusCode::OK);
7101        let body = response_json(response).await;
7102        let data = body["data"].as_array().expect("models data array");
7103        let ids: Vec<_> = data
7104            .iter()
7105            .map(|item| item["id"].as_str().unwrap_or_default())
7106            .collect();
7107        assert!(ids.contains(&"stub-model"), "body: {body}");
7108        assert!(ids.contains(&"stub-model:sql"), "body: {body}");
7109        let adapter = data
7110            .iter()
7111            .find(|item| item["id"] == "stub-model:sql")
7112            .expect("adapter model");
7113        assert_eq!(adapter["root"], "stub-model");
7114        assert_eq!(adapter["parent"], "stub-model");
7115        assert_eq!(adapter["modalities"], json!(["text"]));
7116    }
7117
7118    #[tokio::test]
7119    async fn route_chat_lora_adapter_maps_internal_request_to_base_model() {
7120        let (router, engine) = router_with_capturing_lora_llm();
7121        let response = post_json(
7122            router,
7123            "/v1/chat/completions",
7124            json!({
7125                "model": "qwen3:sql",
7126                "messages": [{"role": "user", "content": "Say hi"}],
7127                "max_tokens": 8,
7128                "temperature": 0.0
7129            }),
7130        )
7131        .await;
7132        assert_eq!(response.status(), AxumStatusCode::OK);
7133        let body = response_json(response).await;
7134        assert_eq!(body["model"], "qwen3:sql");
7135        let captured = engine.last_request();
7136        assert_eq!(captured.model_id, ModelId::new("qwen3"));
7137        assert_eq!(captured.metadata["ferrum_lora_adapter"], "sql");
7138        assert_eq!(captured.metadata["ferrum_lora_model_id"], "qwen3:sql");
7139    }
7140
7141    #[tokio::test]
7142    async fn route_chat_base_model_still_uses_base_path_with_lora_loaded() {
7143        let (router, engine) = router_with_capturing_lora_llm();
7144        let response = post_json(
7145            router,
7146            "/v1/chat/completions",
7147            json!({
7148                "model": "qwen3",
7149                "messages": [{"role": "user", "content": "Say hi"}],
7150                "max_tokens": 8,
7151                "temperature": 0.0
7152            }),
7153        )
7154        .await;
7155        assert_eq!(response.status(), AxumStatusCode::OK);
7156        let captured = engine.last_request();
7157        assert_eq!(captured.model_id, ModelId::new("qwen3"));
7158        assert!(!captured.metadata.contains_key("ferrum_lora_adapter"));
7159    }
7160
7161    #[tokio::test]
7162    async fn route_chat_unknown_lora_adapter_returns_openai_model_error() {
7163        let (router, _) = router_with_capturing_lora_llm();
7164        let response = post_json(
7165            router,
7166            "/v1/chat/completions",
7167            json!({
7168                "model": "qwen3:missing",
7169                "messages": [{"role": "user", "content": "Say hi"}],
7170                "max_tokens": 8
7171            }),
7172        )
7173        .await;
7174        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
7175        let body = response_json(response).await;
7176        assert_eq!(body["error"]["type"], "invalid_request_error");
7177        assert_eq!(body["error"]["param"], "model");
7178        assert!(
7179            body["error"]["message"]
7180                .as_str()
7181                .unwrap_or_default()
7182                .contains("unknown model"),
7183            "body: {body}"
7184        );
7185    }
7186
7187    #[tokio::test]
7188    async fn route_chat_unknown_served_model_returns_openai_model_error() {
7189        let engine = Arc::new(CapturingLlm::new());
7190        let router = AxumServer::from_llm(engine.clone()).build_router();
7191        let response = post_json(
7192            router,
7193            "/v1/chat/completions",
7194            json!({
7195                "model": "not-a-loaded-model",
7196                "messages": [{"role": "user", "content": "Say hi"}],
7197                "max_tokens": 8
7198            }),
7199        )
7200        .await;
7201        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
7202        let body = response_json(response).await;
7203        assert_eq!(body["error"]["type"], "invalid_request_error");
7204        assert_eq!(body["error"]["param"], "model");
7205        assert!(
7206            body["error"]["message"]
7207                .as_str()
7208                .unwrap_or_default()
7209                .contains("unknown model"),
7210            "body: {body}"
7211        );
7212        assert!(!engine.has_captured_request());
7213    }
7214
7215    #[tokio::test]
7216    async fn route_models_without_engine_returns_empty_list() {
7217        let response = get(router_without_llm(), "/v1/models").await;
7218        assert_eq!(response.status(), AxumStatusCode::OK);
7219        let body = response_json(response).await;
7220        assert_eq!(body["object"], "list");
7221        assert!(body["data"].as_array().unwrap().is_empty(), "body: {body}");
7222    }
7223
7224    #[tokio::test]
7225    async fn route_basic_chat_contract_uses_stub_engine() {
7226        let response = post_json(
7227            router_with_stub("hello"),
7228            "/v1/chat/completions",
7229            json!({
7230                "model": "stub-model",
7231                "messages": [{"role": "user", "content": "Say hi"}],
7232                "max_tokens": 8,
7233                "temperature": 0.0
7234            }),
7235        )
7236        .await;
7237        assert_eq!(response.status(), AxumStatusCode::OK);
7238        let body = response_json(response).await;
7239        assert_eq!(body["object"], "chat.completion");
7240        assert_eq!(body["choices"][0]["message"]["role"], "assistant");
7241        assert_eq!(body["choices"][0]["message"]["content"], "hello");
7242        assert_eq!(body["usage"]["prompt_tokens"], 7);
7243        assert_eq!(body["usage"]["completion_tokens"], 2);
7244    }
7245
7246    #[tokio::test]
7247    async fn route_chat_serializes_structured_tool_call_response() {
7248        let response = post_json(
7249            router_with_stub_api_response(
7250                "",
7251                ferrum_types::ApiResponse::Chat(ferrum_types::ApiChatResponse {
7252                    message: ferrum_types::ApiChatMessage {
7253                        role: ferrum_types::ApiMessageRole::Assistant,
7254                        content: String::new(),
7255                        name: None,
7256                        tool_calls: vec![ferrum_types::ApiToolCall {
7257                            id: "call_1".to_string(),
7258                            tool_type: "function".to_string(),
7259                            function: ferrum_types::ApiFunctionCall {
7260                                name: "weather".to_string(),
7261                                arguments: "{\"city\":\"Paris\"}".to_string(),
7262                            },
7263                        }],
7264                        tool_call_id: None,
7265                        function_call: None,
7266                    },
7267                    finish_reason: Some("tool_calls".to_string()),
7268                }),
7269            ),
7270            "/v1/chat/completions",
7271            json!({
7272                "model": "stub-model",
7273                "messages": [{"role": "user", "content": "Use the weather tool."}],
7274                "tools": [{
7275                    "type": "function",
7276                    "function": {
7277                        "name": "weather",
7278                        "parameters": {
7279                            "type": "object",
7280                            "properties": {"city": {"type": "string"}},
7281                            "required": ["city"]
7282                        }
7283                    }
7284                }],
7285                "tool_choice": "auto"
7286            }),
7287        )
7288        .await;
7289        assert_eq!(response.status(), AxumStatusCode::OK);
7290        let body = response_json(response).await;
7291        assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
7292        assert_eq!(
7293            body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
7294            "weather"
7295        );
7296        assert_eq!(
7297            body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
7298            "{\"city\":\"Paris\"}"
7299        );
7300    }
7301
7302    #[tokio::test]
7303    async fn route_chat_preserves_length_over_structured_tool_response() {
7304        let generated = r#"{"name":"weather","arguments":{"city":"Paris"}}"#;
7305        let response = post_json(
7306            router_with_stub_api_response_and_finish_reason(
7307                generated,
7308                weather_tool_api_response(),
7309                FinishReason::Length,
7310            ),
7311            "/v1/chat/completions",
7312            json!({
7313                "model": "stub-model",
7314                "messages": [{"role": "user", "content": "Use the weather tool."}],
7315                "tools": [{
7316                    "type": "function",
7317                    "function": {"name": "weather", "parameters": {"type": "object"}}
7318                }],
7319                "tool_choice": "auto"
7320            }),
7321        )
7322        .await;
7323        assert_eq!(response.status(), AxumStatusCode::OK);
7324        let body = response_json(response).await;
7325        assert_eq!(body["choices"][0]["finish_reason"], "length");
7326        assert_eq!(body["choices"][0]["message"]["content"], generated);
7327        assert!(body["choices"][0]["message"]["tool_calls"].is_null());
7328    }
7329
7330    #[tokio::test]
7331    async fn route_chat_serializes_generated_tool_call_json_when_engine_returns_text_only() {
7332        let response = post_json(
7333            router_with_stub(
7334                r#"{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"weather","arguments":{"city":"Paris"}}}]}"#,
7335            ),
7336            "/v1/chat/completions",
7337            json!({
7338                "model": "stub-model",
7339                "messages": [{"role": "user", "content": "Use the weather tool."}],
7340                "tools": [{
7341                    "type": "function",
7342                    "function": {
7343                        "name": "weather",
7344                        "parameters": {
7345                            "type": "object",
7346                            "properties": {"city": {"type": "string"}},
7347                            "required": ["city"]
7348                        }
7349                    }
7350                }],
7351                "tool_choice": "auto"
7352            }),
7353        )
7354        .await;
7355        assert_eq!(response.status(), AxumStatusCode::OK);
7356        let body = response_json(response).await;
7357        assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
7358        assert_eq!(body["choices"][0]["message"]["content"], "");
7359        assert_eq!(
7360            body["choices"][0]["message"]["tool_calls"][0]["id"],
7361            "call_1"
7362        );
7363        assert_eq!(
7364            body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
7365            "weather"
7366        );
7367        assert_eq!(
7368            body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
7369            "{\"city\":\"Paris\"}"
7370        );
7371    }
7372
7373    #[tokio::test]
7374    async fn route_chat_serializes_qwen3_function_parameters_tool_json() {
7375        let response = post_json(
7376            router_with_stub(
7377                r#"{"function":"get_weather","parameters":{"city":"北京","unit":"c"}}"#,
7378            ),
7379            "/v1/chat/completions",
7380            json!({
7381                "model": "stub-model",
7382                "messages": [{"role": "user", "content": "北京现在天气怎么样?"}],
7383                "tools": [{
7384                    "type": "function",
7385                    "function": {
7386                        "name": "get_weather",
7387                        "parameters": {
7388                            "type": "object",
7389                            "properties": {
7390                                "city": {"type": "string"},
7391                                "unit": {"type": "string", "enum": ["c", "f"]}
7392                            },
7393                            "required": ["city"]
7394                        }
7395                    }
7396                }],
7397                "tool_choice": "auto"
7398            }),
7399        )
7400        .await;
7401        assert_eq!(response.status(), AxumStatusCode::OK);
7402        let body = response_json(response).await;
7403        assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
7404        assert_eq!(body["choices"][0]["message"]["content"], "");
7405        assert_eq!(
7406            body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
7407            "get_weather"
7408        );
7409        assert_eq!(
7410            body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
7411            "{\"city\":\"北京\",\"unit\":\"c\"}"
7412        );
7413    }
7414
7415    #[tokio::test]
7416    async fn route_chat_uses_template_tool_protocol_for_function_parameter_xml() {
7417        let template = ModelChatTemplate::new(
7418            "{% if tools %}<tools>{{ tools | tojson }}</tools>Use <tool_call><function=name><parameter=key>value</parameter></function></tool_call>{% endif %}{% for message in messages %}{{ message.content }}{% endfor %}{% if add_generation_prompt %}<assistant>{% endif %}",
7419            "function-parameter-xml-template",
7420        );
7421        let response = post_json(
7422            router_with_stub_and_template(
7423                "<tool_call>\n<function=get_weather>\n<parameter=city>\n北京\n</parameter>\n<parameter=unit>\ncelsius\n</parameter>\n</function>\n</tool_call>",
7424                template,
7425            ),
7426            "/v1/chat/completions",
7427            json!({
7428                "model": "stub-model",
7429                "messages": [{"role": "user", "content": "请调用 get_weather 查询北京天气。"}],
7430                "tools": [{
7431                    "type": "function",
7432                    "function": {
7433                        "name": "get_weather",
7434                        "parameters": {
7435                            "type": "object",
7436                            "properties": {
7437                                "city": {"type": "string"},
7438                                "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
7439                            },
7440                            "required": ["city"]
7441                        }
7442                    }
7443                }]
7444            }),
7445        )
7446        .await;
7447
7448        assert_eq!(response.status(), AxumStatusCode::OK);
7449        let body = response_json(response).await;
7450        assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
7451        assert_eq!(
7452            body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
7453            "get_weather"
7454        );
7455        assert_eq!(
7456            body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
7457            "{\"city\":\"北京\",\"unit\":\"celsius\"}"
7458        );
7459    }
7460
7461    #[tokio::test]
7462    async fn route_chat_parses_tool_call_from_reasoning_before_fake_tool_result_content() {
7463        let response = post_json(
7464            router_with_stub(
7465                "kaza\n\
7466                 {\"name\":\"get_weather\",\"arguments\":{\"city\":\"北京\",\"unit\":\"celsius\"}}\n\
7467                 </think>\n\
7468                 {\"name\":\"get_weather\",\"content\":{\"temperature\":25,\"condition\":\"晴\"}}\n\
7469                 {\"temperature\":25,\"condition\":\"晴\"}",
7470            ),
7471            "/v1/chat/completions",
7472            json!({
7473                "model": "stub-model",
7474                "messages": [{"role": "user", "content": "北京现在天气怎么样?请先调用工具。"}],
7475                "tools": [{
7476                    "type": "function",
7477                    "function": {
7478                        "name": "get_weather",
7479                        "parameters": {
7480                            "type": "object",
7481                            "properties": {
7482                                "city": {"type": "string"},
7483                                "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
7484                            },
7485                            "required": ["city"]
7486                        }
7487                    }
7488                }]
7489            }),
7490        )
7491        .await;
7492        assert_eq!(response.status(), AxumStatusCode::OK);
7493        let body = response_json(response).await;
7494        assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
7495        assert_eq!(body["choices"][0]["message"]["content"], "");
7496        assert_eq!(
7497            body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
7498            "get_weather"
7499        );
7500        assert_eq!(
7501            body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
7502            "{\"city\":\"北京\",\"unit\":\"celsius\"}"
7503        );
7504    }
7505
7506    #[tokio::test]
7507    async fn route_chat_prefers_reasoning_tool_call_over_empty_visible_arguments() {
7508        let response = post_json(
7509            router_with_stub(
7510                "{\"name\":\"get_weather\",\"arguments\":{\"city\":\"北京\",\"unit\":\"celsius\"}}\n\
7511                 </think>\n\
7512                 {\"name\":\"get_weather\",\"arguments\":{}}",
7513            ),
7514            "/v1/chat/completions",
7515            json!({
7516                "model": "stub-model",
7517                "messages": [{"role": "user", "content": "北京现在天气怎么样?请先调用 get_weather 工具。"}],
7518                "tools": [{
7519                    "type": "function",
7520                    "function": {
7521                        "name": "get_weather",
7522                        "parameters": {
7523                            "type": "object",
7524                            "properties": {
7525                                "city": {"type": "string"},
7526                                "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
7527                            },
7528                            "required": ["city"]
7529                        }
7530                    }
7531                }]
7532            }),
7533        )
7534        .await;
7535        assert_eq!(response.status(), AxumStatusCode::OK);
7536        let body = response_json(response).await;
7537        assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
7538        assert_eq!(
7539            body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
7540            "get_weather"
7541        );
7542        assert_eq!(
7543            body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
7544            "{\"city\":\"北京\",\"unit\":\"celsius\"}"
7545        );
7546    }
7547
7548    #[tokio::test]
7549    async fn route_chat_honors_specific_tool_choice_for_generated_tool_call_json() {
7550        let response = post_json(
7551            router_with_stub(r#"{"name":"weather","arguments":{"city":"Paris"}}"#),
7552            "/v1/chat/completions",
7553            json!({
7554                "model": "stub-model",
7555                "messages": [{"role": "user", "content": "Use the selected tool."}],
7556                "tools": [
7557                    {
7558                        "type": "function",
7559                        "function": {"name": "weather", "parameters": {"type": "object"}}
7560                    },
7561                    {
7562                        "type": "function",
7563                        "function": {"name": "calendar", "parameters": {"type": "object"}}
7564                    }
7565                ],
7566                "tool_choice": {
7567                    "type": "function",
7568                    "function": {"name": "weather"}
7569                }
7570            }),
7571        )
7572        .await;
7573        assert_eq!(response.status(), AxumStatusCode::OK);
7574        let body = response_json(response).await;
7575        assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
7576        assert_eq!(
7577            body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
7578            "weather"
7579        );
7580
7581        let response = post_json(
7582            router_with_stub(r#"{"name":"calendar","arguments":{}}"#),
7583            "/v1/chat/completions",
7584            json!({
7585                "model": "stub-model",
7586                "messages": [{"role": "user", "content": "Use the selected tool."}],
7587                "tools": [
7588                    {
7589                        "type": "function",
7590                        "function": {"name": "weather", "parameters": {"type": "object"}}
7591                    },
7592                    {
7593                        "type": "function",
7594                        "function": {"name": "calendar", "parameters": {"type": "object"}}
7595                    }
7596                ],
7597                "tool_choice": {
7598                    "type": "function",
7599                    "function": {"name": "weather"}
7600                }
7601            }),
7602        )
7603        .await;
7604        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
7605        let body = response_json(response).await;
7606        assert_eq!(body["error"]["param"], "tool_choice");
7607        assert_eq!(body["error"]["type"], "invalid_request_error");
7608    }
7609
7610    #[tokio::test]
7611    async fn route_chat_specific_tool_choice_wraps_generated_arguments() {
7612        let response = post_json(
7613            router_with_stub(r#"{"city":"Paris"}"#),
7614            "/v1/chat/completions",
7615            json!({
7616                "model": "stub-model",
7617                "messages": [{"role": "user", "content": "Use the selected tool."}],
7618                "tools": [{
7619                    "type": "function",
7620                    "function": {
7621                        "name": "weather",
7622                        "parameters": {
7623                            "type": "object",
7624                            "properties": {"city": {"type": "string"}},
7625                            "required": ["city"]
7626                        }
7627                    }
7628                }],
7629                "tool_choice": {
7630                    "type": "function",
7631                    "function": {"name": "weather"}
7632                }
7633            }),
7634        )
7635        .await;
7636        assert_eq!(response.status(), AxumStatusCode::OK);
7637        let body = response_json(response).await;
7638        assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
7639        assert_eq!(body["choices"][0]["message"]["content"], "");
7640        assert_eq!(
7641            body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
7642            "weather"
7643        );
7644        assert_eq!(
7645            body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
7646            "{\"city\":\"Paris\"}"
7647        );
7648    }
7649
7650    #[tokio::test]
7651    async fn route_chat_tool_choice_none_keeps_generated_tool_json_as_content() {
7652        let generated = r#"{"name":"weather","arguments":{"city":"Paris"}}"#;
7653        let response = post_json(
7654            router_with_stub(generated),
7655            "/v1/chat/completions",
7656            json!({
7657                "model": "stub-model",
7658                "messages": [{"role": "user", "content": "Do not use tools."}],
7659                "tools": [{
7660                    "type": "function",
7661                    "function": {"name": "weather", "parameters": {"type": "object"}}
7662                }],
7663                "tool_choice": "none"
7664            }),
7665        )
7666        .await;
7667        assert_eq!(response.status(), AxumStatusCode::OK);
7668        let body = response_json(response).await;
7669        assert_eq!(body["choices"][0]["finish_reason"], "stop");
7670        assert_eq!(body["choices"][0]["message"]["content"], generated);
7671        assert!(body["choices"][0]["message"]["tool_calls"].is_null());
7672    }
7673
7674    #[tokio::test]
7675    async fn route_chat_tool_choice_required_wraps_generated_arguments() {
7676        let response = post_json(
7677            router_with_stub(r#"{"city":"Paris"}"#),
7678            "/v1/chat/completions",
7679            json!({
7680                "model": "stub-model",
7681                "messages": [{"role": "user", "content": "Use a tool."}],
7682                "tools": [{
7683                    "type": "function",
7684                    "function": {
7685                        "name": "weather",
7686                        "parameters": {
7687                            "type": "object",
7688                            "properties": {"city": {"type": "string"}},
7689                            "required": ["city"]
7690                        }
7691                    }
7692                }],
7693                "tool_choice": "required"
7694            }),
7695        )
7696        .await;
7697        assert_eq!(response.status(), AxumStatusCode::OK);
7698        let body = response_json(response).await;
7699        assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
7700        assert_eq!(body["choices"][0]["message"]["content"], "");
7701        assert_eq!(
7702            body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
7703            "weather"
7704        );
7705        assert_eq!(
7706            body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
7707            "{\"city\":\"Paris\"}"
7708        );
7709    }
7710
7711    fn required_tool_with_strict_response_format_request(stream: bool) -> Value {
7712        json!({
7713            "model": "stub-model",
7714            "messages": [{"role": "user", "content": "Use the weather tool."}],
7715            "stream": stream,
7716            "stream_options": stream.then_some(json!({"include_usage": true})),
7717            "tools": [{
7718                "type": "function",
7719                "function": {
7720                    "name": "weather",
7721                    "parameters": {
7722                        "type": "object",
7723                        "properties": {"city": {"type": "string", "const": "Paris"}},
7724                        "required": ["city"],
7725                        "additionalProperties": false
7726                    }
7727                }
7728            }],
7729            "tool_choice": "required",
7730            "response_format": {
7731                "type": "json_schema",
7732                "json_schema": {
7733                    "name": "content_answer",
7734                    "strict": true,
7735                    "schema": {
7736                        "type": "object",
7737                        "properties": {"answer": {"type": "string", "const": "IGNORED"}},
7738                        "required": ["answer"],
7739                        "additionalProperties": false
7740                    }
7741                }
7742            }
7743        })
7744    }
7745
7746    #[tokio::test]
7747    async fn route_chat_required_tool_takes_priority_over_strict_response_format() {
7748        let response = post_json(
7749            router_with_stub(r#"{"city":"Paris"}"#),
7750            "/v1/chat/completions",
7751            required_tool_with_strict_response_format_request(false),
7752        )
7753        .await;
7754        assert_eq!(response.status(), AxumStatusCode::OK);
7755        let body = response_json(response).await;
7756        assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
7757        assert_eq!(body["choices"][0]["message"]["content"], "");
7758        assert_eq!(
7759            body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
7760            "weather"
7761        );
7762        assert_eq!(
7763            body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
7764            r#"{"city":"Paris"}"#
7765        );
7766    }
7767
7768    #[tokio::test]
7769    async fn route_chat_required_tool_rejects_arguments_that_violate_const_schema() {
7770        let response = post_json(
7771            router_with_stub(r#"{"city":"London"}"#),
7772            "/v1/chat/completions",
7773            required_tool_with_strict_response_format_request(false),
7774        )
7775        .await;
7776        assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
7777        let body = response_json(response).await;
7778        assert_eq!(body["error"]["type"], "internal_server_error");
7779        assert!(
7780            body["error"]["message"]
7781                .as_str()
7782                .is_some_and(|message| message.contains("did not satisfy its schema")),
7783            "body: {body}"
7784        );
7785    }
7786
7787    #[tokio::test]
7788    async fn route_streaming_required_tool_takes_priority_over_strict_response_format() {
7789        let response = post_json(
7790            router_with_stub(r#"{"city":"Paris"}"#),
7791            "/v1/chat/completions",
7792            required_tool_with_strict_response_format_request(true),
7793        )
7794        .await;
7795        assert_eq!(response.status(), AxumStatusCode::OK);
7796        let body = response_text(response).await;
7797        assert_eq!(body.matches("data: [DONE]").count(), 1, "body: {body}");
7798        assert!(
7799            body.contains(r#""finish_reason":"tool_calls""#),
7800            "tool priority must finish with tool_calls: {body}"
7801        );
7802        assert!(
7803            body.contains(r#""name":"weather""#)
7804                && body.contains(r#""arguments":"{\"city\":\"Paris\"}""#),
7805            "stream must carry the reconstructed tool call: {body}"
7806        );
7807        assert_eq!(
7808            body.matches(r#""usage":{"#).count(),
7809            1,
7810            "stream must carry exactly one usage row: {body}"
7811        );
7812        assert!(
7813            !body.contains("strict json_schema") && !body.contains("invalid JSON"),
7814            "dormant content schema must not reject a required tool call: {body}"
7815        );
7816    }
7817
7818    #[tokio::test]
7819    async fn dropping_buffered_http_response_drops_the_engine_stream() {
7820        let stream_dropped = Arc::new(Notify::new());
7821        let response = post_json(
7822            AxumServer::from_llm(Arc::new(StubLlm::with_pending_stream(Arc::clone(
7823                &stream_dropped,
7824            ))))
7825            .build_router(),
7826            "/v1/chat/completions",
7827            required_tool_with_strict_response_format_request(true),
7828        )
7829        .await;
7830        assert_eq!(response.status(), AxumStatusCode::OK);
7831
7832        drop(response);
7833        tokio::time::timeout(std::time::Duration::from_secs(1), stream_dropped.notified())
7834            .await
7835            .expect("client disconnect must stop a buffered structured stream promptly");
7836    }
7837
7838    #[tokio::test]
7839    async fn route_chat_tool_choice_required_errors_without_valid_tool_call() {
7840        let response = post_json(
7841            router_with_stub("plain answer"),
7842            "/v1/chat/completions",
7843            json!({
7844                "model": "stub-model",
7845                "messages": [{"role": "user", "content": "Use a tool."}],
7846                "tools": [{
7847                    "type": "function",
7848                    "function": {"name": "weather", "parameters": {"type": "object"}}
7849                }],
7850                "tool_choice": "required"
7851            }),
7852        )
7853        .await;
7854        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
7855        let body = response_json(response).await;
7856        assert_eq!(body["error"]["type"], "invalid_request_error");
7857        assert_eq!(body["error"]["param"], "tool_choice");
7858        assert!(
7859            body["error"]["message"]
7860                .as_str()
7861                .is_some_and(|message| message.contains("required tool_choice")),
7862            "body: {body}"
7863        );
7864    }
7865
7866    #[tokio::test]
7867    async fn route_streaming_chat_serializes_generated_tool_call_delta() {
7868        let response = post_json(
7869            router_with_stub(
7870                r#"{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"weather","arguments":{"city":"Paris"}}}]}"#,
7871            ),
7872            "/v1/chat/completions",
7873            json!({
7874                "model": "stub-model",
7875                "messages": [{"role": "user", "content": "Use the weather tool."}],
7876                "stream": true,
7877                "tools": [{
7878                    "type": "function",
7879                    "function": {
7880                        "name": "weather",
7881                        "parameters": {
7882                            "type": "object",
7883                            "properties": {"city": {"type": "string"}},
7884                            "required": ["city"]
7885                        }
7886                    }
7887                }],
7888                "tool_choice": "auto"
7889            }),
7890        )
7891        .await;
7892        assert_eq!(response.status(), AxumStatusCode::OK);
7893        let body = response_text(response).await;
7894        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
7895        assert!(
7896            body.contains(r#""finish_reason":"tool_calls""#),
7897            "stream should finish with tool_calls: {body}"
7898        );
7899        assert!(
7900            body.contains(r#""tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"weather""#),
7901            "stream should emit OpenAI tool_calls delta with index: {body}"
7902        );
7903        assert!(
7904            body.contains(r#""arguments":"{\"city\":\"Paris\"}""#),
7905            "tool arguments should be serialized as JSON string: {body}"
7906        );
7907        assert!(
7908            !body.contains(r#""content":"{\"tool_calls\""#),
7909            "raw tool-call JSON should not be streamed as assistant content: {body}"
7910        );
7911    }
7912
7913    #[tokio::test]
7914    async fn route_streaming_chat_serializes_qwen3_function_parameters_tool_delta() {
7915        let response = post_json(
7916            router_with_stub(
7917                r#"{"function":"get_weather","parameters":{"city":"深圳","unit":"c"}}"#,
7918            ),
7919            "/v1/chat/completions",
7920            json!({
7921                "model": "stub-model",
7922                "messages": [{"role": "user", "content": "深圳天气?"}],
7923                "stream": true,
7924                "tools": [{
7925                    "type": "function",
7926                    "function": {
7927                        "name": "get_weather",
7928                        "parameters": {
7929                            "type": "object",
7930                            "properties": {
7931                                "city": {"type": "string"},
7932                                "unit": {"type": "string", "enum": ["c", "f"]}
7933                            },
7934                            "required": ["city"]
7935                        }
7936                    }
7937                }],
7938                "tool_choice": "auto"
7939            }),
7940        )
7941        .await;
7942        assert_eq!(response.status(), AxumStatusCode::OK);
7943        let body = response_text(response).await;
7944        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
7945        assert!(
7946            body.contains(r#""finish_reason":"tool_calls""#),
7947            "stream should finish with tool_calls: {body}"
7948        );
7949        assert!(
7950            body.contains(r#""function":{"name":"get_weather","arguments":"{\"city\":\"深圳\",\"unit\":\"c\"}"}"#),
7951            "stream should emit parsed Qwen3 function parameters as tool args: {body}"
7952        );
7953        assert!(
7954            !body.contains(r#""content":"{\"function\""#),
7955            "raw Qwen3 tool JSON should not leak as assistant content: {body}"
7956        );
7957    }
7958
7959    #[tokio::test]
7960    async fn route_streaming_chat_honors_specific_tool_choice_for_generated_tool_call_delta() {
7961        let request = |generated: &'static str| {
7962            post_json(
7963                router_with_stub(generated),
7964                "/v1/chat/completions",
7965                json!({
7966                    "model": "stub-model",
7967                    "messages": [{"role": "user", "content": "Use the selected tool."}],
7968                    "stream": true,
7969                    "tools": [
7970                        {
7971                            "type": "function",
7972                            "function": {"name": "weather", "parameters": {"type": "object"}}
7973                        },
7974                        {
7975                            "type": "function",
7976                            "function": {"name": "calendar", "parameters": {"type": "object"}}
7977                        }
7978                    ],
7979                    "tool_choice": {
7980                        "type": "function",
7981                        "function": {"name": "weather"}
7982                    }
7983                }),
7984            )
7985        };
7986
7987        let response = request(r#"{"name":"weather","arguments":{"city":"Paris"}}"#).await;
7988        assert_eq!(response.status(), AxumStatusCode::OK);
7989        let body = response_text(response).await;
7990        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
7991        assert!(
7992            body.contains(r#""finish_reason":"tool_calls""#),
7993            "selected tool should finish with tool_calls: {body}"
7994        );
7995        assert!(
7996            body.contains(r#""function":{"name":"weather","arguments":"{\"city\":\"Paris\"}"}"#),
7997            "selected tool should stream as tool_calls delta: {body}"
7998        );
7999
8000        let response = request(r#"{"name":"calendar","arguments":{}}"#).await;
8001        assert_eq!(response.status(), AxumStatusCode::OK);
8002        let body = response_text(response).await;
8003        assert!(
8004            body.contains(
8005                r#""error":{"message":"model output did not satisfy required tool_choice""#
8006            ),
8007            "selected-tool stream should reject unselected tool output: {body}"
8008        );
8009        assert!(
8010            !body.contains(r#""finish_reason":"tool_calls""#),
8011            "unselected tool JSON must not become tool_calls: {body}"
8012        );
8013    }
8014
8015    #[tokio::test]
8016    async fn route_streaming_chat_prefers_chunk_api_response_for_tool_delta() {
8017        let response = post_json(
8018            router_with_stub_api_response(
8019                "raw text that should not stream",
8020                ferrum_types::ApiResponse::Chat(ferrum_types::ApiChatResponse {
8021                    message: ferrum_types::ApiChatMessage {
8022                        role: ferrum_types::ApiMessageRole::Assistant,
8023                        content: String::new(),
8024                        name: None,
8025                        tool_calls: vec![ferrum_types::ApiToolCall {
8026                            id: "call_1".to_string(),
8027                            tool_type: "function".to_string(),
8028                            function: ferrum_types::ApiFunctionCall {
8029                                name: "weather".to_string(),
8030                                arguments: "{\"city\":\"Paris\"}".to_string(),
8031                            },
8032                        }],
8033                        tool_call_id: None,
8034                        function_call: None,
8035                    },
8036                    finish_reason: Some("tool_calls".to_string()),
8037                }),
8038            ),
8039            "/v1/chat/completions",
8040            json!({
8041                "model": "stub-model",
8042                "messages": [{"role": "user", "content": "Use the weather tool."}],
8043                "stream": true,
8044                "tools": [{
8045                    "type": "function",
8046                    "function": {"name": "weather", "parameters": {"type": "object"}}
8047                }],
8048                "tool_choice": "auto"
8049            }),
8050        )
8051        .await;
8052        assert_eq!(response.status(), AxumStatusCode::OK);
8053        let body = response_text(response).await;
8054        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
8055        assert!(
8056            body.contains(r#""finish_reason":"tool_calls""#),
8057            "stream should finish with tool_calls: {body}"
8058        );
8059        assert!(
8060            body.contains(r#""tool_calls":[{"index":0,"id":"call_1""#),
8061            "stream should emit tool_calls from chunk api_response: {body}"
8062        );
8063        assert!(
8064            !body.contains("raw text that should not stream"),
8065            "structured api_response should suppress raw generated text in tool-call stream: {body}"
8066        );
8067    }
8068
8069    #[tokio::test]
8070    async fn route_streaming_chat_preserves_length_over_structured_tool_response() {
8071        let generated = r#"{"name":"weather","arguments":{"city":"Paris"}}"#;
8072        let response = post_json(
8073            router_with_stub_api_response_and_finish_reason(
8074                generated,
8075                weather_tool_api_response(),
8076                FinishReason::Length,
8077            ),
8078            "/v1/chat/completions",
8079            json!({
8080                "model": "stub-model",
8081                "messages": [{"role": "user", "content": "Use the weather tool."}],
8082                "stream": true,
8083                "tools": [{
8084                    "type": "function",
8085                    "function": {"name": "weather", "parameters": {"type": "object"}}
8086                }],
8087                "tool_choice": "auto"
8088            }),
8089        )
8090        .await;
8091        assert_eq!(response.status(), AxumStatusCode::OK);
8092        let body = response_text(response).await;
8093        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
8094        assert!(
8095            body.contains(r#""finish_reason":"length""#),
8096            "stream must preserve the engine terminal reason: {body}"
8097        );
8098        assert!(
8099            !body.contains(r#""finish_reason":"tool_calls""#),
8100            "length must not be relabeled as tool_calls: {body}"
8101        );
8102    }
8103
8104    #[tokio::test]
8105    async fn route_streaming_chat_tool_choice_required_errors_without_leaking_content() {
8106        let response = post_json(
8107            router_with_stub("plain answer"),
8108            "/v1/chat/completions",
8109            json!({
8110                "model": "stub-model",
8111                "messages": [{"role": "user", "content": "Use a tool."}],
8112                "stream": true,
8113                "tools": [{
8114                    "type": "function",
8115                    "function": {"name": "weather", "parameters": {"type": "object"}}
8116                }],
8117                "tool_choice": "required"
8118            }),
8119        )
8120        .await;
8121        assert_eq!(response.status(), AxumStatusCode::OK);
8122        let body = response_text(response).await;
8123        assert_eq!(body.matches("data: [DONE]").count(), 1, "body: {body}");
8124        assert!(
8125            body.contains(
8126                r#""error":{"message":"model output did not satisfy required tool_choice""#
8127            ),
8128            "stream should emit OpenAI error envelope: {body}"
8129        );
8130        assert!(
8131            body.contains(r#""type":"invalid_request_error""#),
8132            "stream should use invalid_request_error: {body}"
8133        );
8134        assert!(
8135            body.contains(r#""param":"tool_choice""#),
8136            "stream should include tool_choice param: {body}"
8137        );
8138        assert!(
8139            !body.contains(r#""content":"plain answer""#),
8140            "required stream must not leak invalid content before validation: {body}"
8141        );
8142    }
8143
8144    #[tokio::test]
8145    async fn route_streaming_chat_tool_request_falls_back_to_content_when_no_tool_call() {
8146        let response = post_json(
8147            router_with_stub("plain answer"),
8148            "/v1/chat/completions",
8149            json!({
8150                "model": "stub-model",
8151                "messages": [{"role": "user", "content": "Use the weather tool if needed."}],
8152                "stream": true,
8153                "tools": [{
8154                    "type": "function",
8155                    "function": {"name": "weather", "parameters": {"type": "object"}}
8156                }],
8157                "tool_choice": "auto"
8158            }),
8159        )
8160        .await;
8161        assert_eq!(response.status(), AxumStatusCode::OK);
8162        let body = response_text(response).await;
8163        assert!(
8164            body.contains(r#""content":"plain answer""#),
8165            "plain content should still stream when no tool call is generated: {body}"
8166        );
8167        assert!(
8168            body.contains(r#""finish_reason":"stop""#),
8169            "plain content should keep normal finish reason: {body}"
8170        );
8171        assert!(
8172            !body.contains(r#""tool_calls""#),
8173            "fallback content should not synthesize tool_calls: {body}"
8174        );
8175    }
8176
8177    #[tokio::test]
8178    async fn route_streaming_chat_serializes_generated_legacy_function_call_delta() {
8179        let response = post_json(
8180            router_with_stub(
8181                r#"{"function_call":{"name":"weather","arguments":{"city":"Paris"}}}"#,
8182            ),
8183            "/v1/chat/completions",
8184            json!({
8185                "model": "stub-model",
8186                "messages": [{"role": "user", "content": "Use the weather function."}],
8187                "stream": true,
8188                "functions": [{
8189                    "name": "weather",
8190                    "parameters": {
8191                        "type": "object",
8192                        "properties": {"city": {"type": "string"}},
8193                        "required": ["city"]
8194                    }
8195                }],
8196                "function_call": "auto"
8197            }),
8198        )
8199        .await;
8200        assert_eq!(response.status(), AxumStatusCode::OK);
8201        let body = response_text(response).await;
8202        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
8203        assert!(
8204            body.contains(r#""finish_reason":"function_call""#),
8205            "stream should finish with function_call: {body}"
8206        );
8207        assert!(
8208            body.contains(
8209                r#""function_call":{"name":"weather","arguments":"{\"city\":\"Paris\"}"}"#
8210            ),
8211            "stream should emit OpenAI legacy function_call delta: {body}"
8212        );
8213        assert!(
8214            !body.contains(r#""content":"{\"function_call\""#),
8215            "raw function-call JSON should not be streamed as assistant content: {body}"
8216        );
8217    }
8218
8219    #[tokio::test]
8220    async fn route_streaming_chat_honors_specific_legacy_function_call_delta() {
8221        let request = |generated: &'static str| {
8222            post_json(
8223                router_with_stub(generated),
8224                "/v1/chat/completions",
8225                json!({
8226                    "model": "stub-model",
8227                    "messages": [{"role": "user", "content": "Use the selected function."}],
8228                    "stream": true,
8229                    "functions": [
8230                        {"name": "weather", "parameters": {"type": "object"}},
8231                        {"name": "calendar", "parameters": {"type": "object"}}
8232                    ],
8233                    "function_call": {"name": "weather"}
8234                }),
8235            )
8236        };
8237
8238        let response =
8239            request(r#"{"function_call":{"name":"weather","arguments":{"city":"Paris"}}}"#).await;
8240        assert_eq!(response.status(), AxumStatusCode::OK);
8241        let body = response_text(response).await;
8242        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
8243        assert!(
8244            body.contains(r#""finish_reason":"function_call""#),
8245            "selected function should finish with function_call: {body}"
8246        );
8247        assert!(
8248            body.contains(
8249                r#""function_call":{"name":"weather","arguments":"{\"city\":\"Paris\"}"}"#
8250            ),
8251            "selected function should stream as function_call delta: {body}"
8252        );
8253
8254        let response = request(r#"{"function_call":{"name":"calendar","arguments":{}}}"#).await;
8255        assert_eq!(response.status(), AxumStatusCode::OK);
8256        let body = response_text(response).await;
8257        assert!(
8258            body.contains(
8259                r#""content":"{\"function_call\":{\"name\":\"calendar\",\"arguments\":{}}}""#
8260            ),
8261            "unselected function JSON should stream as ordinary content: {body}"
8262        );
8263        assert!(
8264            body.contains(r#""finish_reason":"stop""#),
8265            "unselected function JSON should keep normal stop finish: {body}"
8266        );
8267        assert!(
8268            !body.contains(r#""finish_reason":"function_call""#),
8269            "unselected function JSON must not become function_call: {body}"
8270        );
8271    }
8272
8273    #[tokio::test]
8274    async fn route_chat_serializes_generated_legacy_function_call_when_engine_returns_text_only() {
8275        let response = post_json(
8276            router_with_stub(
8277                r#"{"function_call":{"name":"weather","arguments":{"city":"Paris"}}}"#,
8278            ),
8279            "/v1/chat/completions",
8280            json!({
8281                "model": "stub-model",
8282                "messages": [{"role": "user", "content": "Use the weather function."}],
8283                "functions": [{
8284                    "name": "weather",
8285                    "parameters": {
8286                        "type": "object",
8287                        "properties": {"city": {"type": "string"}},
8288                        "required": ["city"]
8289                    }
8290                }],
8291                "function_call": "auto"
8292            }),
8293        )
8294        .await;
8295        assert_eq!(response.status(), AxumStatusCode::OK);
8296        let body = response_json(response).await;
8297        assert_eq!(body["choices"][0]["finish_reason"], "function_call");
8298        assert_eq!(body["choices"][0]["message"]["content"], "");
8299        assert_eq!(
8300            body["choices"][0]["message"]["function_call"]["name"],
8301            "weather"
8302        );
8303        assert_eq!(
8304            body["choices"][0]["message"]["function_call"]["arguments"],
8305            "{\"city\":\"Paris\"}"
8306        );
8307    }
8308
8309    #[tokio::test]
8310    async fn route_chat_serializes_legacy_function_call_response() {
8311        let response = post_json(
8312            router_with_stub_api_response(
8313                "",
8314                ferrum_types::ApiResponse::Chat(ferrum_types::ApiChatResponse {
8315                    message: ferrum_types::ApiChatMessage {
8316                        role: ferrum_types::ApiMessageRole::Assistant,
8317                        content: String::new(),
8318                        name: None,
8319                        tool_calls: vec![],
8320                        tool_call_id: None,
8321                        function_call: Some(ferrum_types::ApiFunctionCall {
8322                            name: "weather".to_string(),
8323                            arguments: "{\"city\":\"Paris\"}".to_string(),
8324                        }),
8325                    },
8326                    finish_reason: Some("function_call".to_string()),
8327                }),
8328            ),
8329            "/v1/chat/completions",
8330            json!({
8331                "model": "stub-model",
8332                "messages": [{"role": "user", "content": "Use the weather function."}],
8333                "functions": [{
8334                    "name": "weather",
8335                    "parameters": {
8336                        "type": "object",
8337                        "properties": {"city": {"type": "string"}},
8338                        "required": ["city"]
8339                    }
8340                }],
8341                "function_call": "auto"
8342            }),
8343        )
8344        .await;
8345        assert_eq!(response.status(), AxumStatusCode::OK);
8346        let body = response_json(response).await;
8347        assert_eq!(body["choices"][0]["finish_reason"], "function_call");
8348        assert_eq!(
8349            body["choices"][0]["message"]["function_call"]["name"],
8350            "weather"
8351        );
8352        assert_eq!(
8353            body["choices"][0]["message"]["function_call"]["arguments"],
8354            "{\"city\":\"Paris\"}"
8355        );
8356    }
8357
8358    #[tokio::test]
8359    async fn route_streaming_chat_include_usage_contract() {
8360        let response = post_json(
8361            router_with_stub("ok"),
8362            "/v1/chat/completions",
8363            json!({
8364                "model": "stub-model",
8365                "messages": [{"role": "user", "content": "Say ok"}],
8366                "stream": true,
8367                "stream_options": {"include_usage": true}
8368            }),
8369        )
8370        .await;
8371        assert_eq!(response.status(), AxumStatusCode::OK);
8372        let body = response_text(response).await;
8373        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
8374        assert!(
8375            body.contains("\"object\":\"chat.completion.chunk\""),
8376            "missing chat chunk: {body}"
8377        );
8378        assert!(
8379            body.contains("\"usage\":{\"prompt_tokens\""),
8380            "missing final usage chunk: {body}"
8381        );
8382        assert!(
8383            body.contains("\"choices\":[],\"usage\""),
8384            "usage should be emitted as a separate chunk: {body}"
8385        );
8386        assert!(
8387            body.contains("\"prompt_tokens\":5"),
8388            "stream usage should come from engine token usage: {body}"
8389        );
8390    }
8391
8392    #[tokio::test]
8393    async fn route_streaming_chat_waits_for_separate_final_usage_at_max_tokens() {
8394        let response = post_json(
8395            router_with_stub_separate_final_stream_chunk(&["he", "llo"]),
8396            "/v1/chat/completions",
8397            json!({
8398                "model": "stub-model",
8399                "messages": [{"role": "user", "content": "Say hello"}],
8400                "max_tokens": 2,
8401                "stream": true,
8402                "stream_options": {"include_usage": true}
8403            }),
8404        )
8405        .await;
8406        assert_eq!(response.status(), AxumStatusCode::OK);
8407        let body = response_text(response).await;
8408        assert_eq!(body.matches("data: [DONE]").count(), 1, "body: {body}");
8409        assert!(
8410            body.contains("\"content\":\"he\""),
8411            "missing first chunk: {body}"
8412        );
8413        assert!(
8414            body.contains("\"content\":\"llo\""),
8415            "missing second chunk: {body}"
8416        );
8417        assert!(
8418            body.contains("\"choices\":[],\"usage\""),
8419            "missing separate usage chunk from final engine chunk: {body}"
8420        );
8421        assert!(
8422            body.contains("\"prompt_tokens\":5"),
8423            "stream usage should come from engine final usage: {body}"
8424        );
8425    }
8426
8427    #[tokio::test]
8428    async fn route_rejects_multimodal_content_with_400() {
8429        for content in [
8430            json!([{"type": "image_url", "image_url": {"url": "https://example.com/image.png"}}]),
8431            json!([{"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}}]),
8432            json!([{"type": "video_url", "video_url": {"url": "https://example.com/video.mp4"}}]),
8433            json!([
8434                {"type": "text", "text": "describe this"},
8435                {"type": "image_url", "image_url": {"url": "https://example.com/image.png"}}
8436            ]),
8437        ] {
8438            let response = post_json(
8439                router_with_stub("unused"),
8440                "/v1/chat/completions",
8441                json!({
8442                    "model": "stub-model",
8443                    "messages": [{"role": "user", "content": content}]
8444                }),
8445            )
8446            .await;
8447            assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
8448            let body = response_json(response).await;
8449            assert_eq!(body["error"]["type"], "invalid_request_error");
8450            let message = body["error"]["message"].as_str().unwrap();
8451            assert!(message.contains("invalid chat completions request"));
8452            assert!(
8453                message.contains("unsupported message content part type"),
8454                "body: {body}"
8455            );
8456        }
8457    }
8458
8459    #[tokio::test]
8460    async fn route_rejects_non_object_stream_options() {
8461        for stream_options in [json!([]), json!("yes"), json!(42), json!(true)] {
8462            let response = post_json(
8463                router_with_stub("unused"),
8464                "/v1/chat/completions",
8465                json!({
8466                    "model": "stub-model",
8467                    "messages": [{"role": "user", "content": "hello"}],
8468                    "stream": true,
8469                    "stream_options": stream_options
8470                }),
8471            )
8472            .await;
8473            assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
8474            let body = response_json(response).await;
8475            assert_eq!(body["error"]["type"], "invalid_request_error");
8476            assert!(
8477                body["error"]["message"]
8478                    .as_str()
8479                    .unwrap_or_default()
8480                    .contains("stream_options must be a JSON object"),
8481                "body: {body}"
8482            );
8483        }
8484    }
8485
8486    #[tokio::test]
8487    async fn route_accepts_text_only_content_array() {
8488        let response = post_json(
8489            router_with_stub("ok"),
8490            "/v1/chat/completions",
8491            json!({
8492                "model": "stub-model",
8493                "messages": [{
8494                    "role": "user",
8495                    "content": [
8496                        {"type": "text", "text": "say"},
8497                        {"type": "text", "text": "ok"}
8498                    ]
8499                }]
8500            }),
8501        )
8502        .await;
8503        assert_eq!(response.status(), AxumStatusCode::OK);
8504        let body = response_json(response).await;
8505        assert_eq!(body["choices"][0]["message"]["content"], "ok");
8506    }
8507
8508    #[tokio::test]
8509    async fn route_chat_invalid_json_maps_to_openai_error() {
8510        let response = post_raw_json(router_with_stub("unused"), "/v1/chat/completions", "{").await;
8511        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
8512        let body = response_json(response).await;
8513        assert_eq!(body["error"]["type"], "invalid_request_error");
8514        assert_eq!(body["error"]["param"], Value::Null);
8515        assert!(body["error"]["message"]
8516            .as_str()
8517            .unwrap()
8518            .contains("invalid chat completions request"));
8519    }
8520
8521    #[tokio::test]
8522    async fn route_rejects_logit_bias_with_openai_error_param() {
8523        let response = post_json(
8524            router_with_stub("unused"),
8525            "/v1/chat/completions",
8526            json!({
8527                "model": "stub-model",
8528                "messages": [{"role": "user", "content": "hello"}],
8529                "logit_bias": {"1": 42.0}
8530            }),
8531        )
8532        .await;
8533        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
8534        let body = response_json(response).await;
8535        assert_eq!(body["error"]["type"], "invalid_request_error");
8536        assert_eq!(body["error"]["param"], "logit_bias");
8537    }
8538
8539    #[tokio::test]
8540    async fn route_tool_request_reaches_engine_structured_boundary() {
8541        let (router, engine) = router_with_capturing_llm();
8542        let response = post_json(
8543            router,
8544            "/v1/chat/completions",
8545            json!({
8546                "model": "qwen3",
8547                "messages": [
8548                    {"role": "user", "content": "Use the weather tool."},
8549                    {
8550                        "role": "assistant",
8551                        "content": null,
8552                        "tool_calls": [{
8553                            "id": "call_1",
8554                            "type": "function",
8555                            "function": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"}
8556                        }]
8557                    },
8558                    {"role": "tool", "tool_call_id": "call_1", "content": "sunny"}
8559                ],
8560                "tools": [{
8561                    "type": "function",
8562                    "function": {
8563                        "name": "weather",
8564                        "description": "Get weather",
8565                        "parameters": {
8566                            "type": "object",
8567                            "properties": {"city": {"type": "string"}},
8568                            "required": ["city"]
8569                        }
8570                    }
8571                }],
8572                "tool_choice": "auto",
8573                "functions": [{
8574                    "name": "legacy_weather",
8575                    "parameters": {"type": "object", "properties": {}}
8576                }],
8577                "function_call": "auto"
8578            }),
8579        )
8580        .await;
8581        assert_eq!(response.status(), AxumStatusCode::OK);
8582
8583        let request = engine.last_request();
8584        assert!(request.prompt.contains("\"tools\":[{"));
8585        assert!(request.prompt.contains("\"type\":\"function\""));
8586        assert!(request.prompt.contains("\"name\":\"weather\""));
8587        assert!(request.prompt.contains("<|im_start|>assistant\n{"));
8588        assert!(request.prompt.contains("\"tool_calls\":[{"));
8589        assert!(request.prompt.contains("\"id\":\"call_1\""));
8590        assert!(request.prompt.contains("<|im_start|>tool\nsunny<|im_end|>"));
8591        assert_eq!(
8592            request.metadata["openai_tools"][0]["function"]["name"],
8593            "weather"
8594        );
8595        assert_eq!(request.metadata["openai_tool_choice"], "auto");
8596        assert_eq!(
8597            request.metadata["openai_legacy_functions"][0]["name"],
8598            "legacy_weather"
8599        );
8600        assert_eq!(request.metadata["openai_legacy_function_call"], "auto");
8601        let Some(ferrum_types::ApiRequest::Chat(api)) = request.api_request.as_ref() else {
8602            panic!("expected structured chat api_request");
8603        };
8604        assert_eq!(api.messages.len(), 3);
8605        assert_eq!(api.messages[2].role, ferrum_types::ApiMessageRole::Tool);
8606        assert_eq!(api.messages[2].tool_call_id.as_deref(), Some("call_1"));
8607        assert_eq!(api.tools[0].function.name, "weather");
8608        assert_eq!(api.legacy_functions[0].name, "legacy_weather");
8609        assert_eq!(
8610            api.messages[1].tool_calls[0].function.arguments,
8611            "{\"city\":\"Paris\"}"
8612        );
8613    }
8614
8615    #[tokio::test]
8616    async fn route_tool_request_prefers_model_chat_template() {
8617        let template = ModelChatTemplate::new(
8618            "{% 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 %}",
8619            "tool-template",
8620        );
8621        let (router, engine) = router_with_capturing_llm_and_template(template);
8622        let response = post_json(
8623            router,
8624            "/v1/chat/completions",
8625            json!({
8626                "model": "served-alias",
8627                "messages": [
8628                    {"role": "user", "content": "Use the weather tool."},
8629                    {
8630                        "role": "assistant",
8631                        "content": null,
8632                        "tool_calls": [{
8633                            "id": "call_1",
8634                            "type": "function",
8635                            "function": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"}
8636                        }]
8637                    },
8638                    {"role": "tool", "tool_call_id": "call_1", "content": "sunny"}
8639                ],
8640                "tools": [{
8641                    "type": "function",
8642                    "function": {
8643                        "name": "weather",
8644                        "description": "Get weather",
8645                        "parameters": {"type": "object", "properties": {"city": {"type": "string"}}}
8646                    }
8647                }],
8648                "tool_choice": "auto"
8649            }),
8650        )
8651        .await;
8652        assert_eq!(response.status(), AxumStatusCode::OK);
8653
8654        let request = engine.last_request();
8655        assert!(request.prompt.contains("<tools>weather</tools>"));
8656        assert!(
8657            request.prompt.contains("<tool_call>weather:"),
8658            "{}",
8659            request.prompt
8660        );
8661        assert!(request.prompt.contains("\"city\""), "{}", request.prompt);
8662        assert!(request.prompt.contains("Paris"), "{}", request.prompt);
8663        assert!(request
8664            .prompt
8665            .contains("<tool_response id=\"call_1\">sunny</tool_response>"));
8666        assert!(
8667            !request.prompt.contains("<|assistant|>"),
8668            "model-template tool prompt should not use generic fallback: {}",
8669            request.prompt
8670        );
8671        assert!(
8672            !request.prompt.contains("When a tool is needed"),
8673            "model-template tool prompt should not inject fallback tool instructions: {}",
8674            request.prompt
8675        );
8676    }
8677
8678    #[tokio::test]
8679    async fn chat_omitted_output_budget_uses_auto_ceiling() {
8680        let (router, engine) = router_with_capturing_llm();
8681        let response = post_json(
8682            router,
8683            "/v1/chat/completions",
8684            json!({
8685                "model": "stub-model",
8686                "messages": [{"role": "user", "content": "hello"}]
8687            }),
8688        )
8689        .await;
8690        assert_eq!(response.status(), AxumStatusCode::OK);
8691
8692        let request = engine.last_request();
8693        assert_eq!(request.sampling_params.max_tokens, 4096);
8694        assert_eq!(
8695            request.metadata.get(DEFAULT_MAX_TOKENS_METADATA_KEY),
8696            Some(&serde_json::json!(true))
8697        );
8698    }
8699
8700    #[tokio::test]
8701    async fn chat_accepts_stop_string_and_max_completion_tokens() {
8702        let (router, engine) = router_with_capturing_llm();
8703        let response = post_json(
8704            router,
8705            "/v1/chat/completions",
8706            json!({
8707                "model": "stub-model",
8708                "messages": [{"role": "user", "content": "hello"}],
8709                "max_tokens": 99,
8710                "max_completion_tokens": 3,
8711                "stop": "<END>"
8712            }),
8713        )
8714        .await;
8715        assert_eq!(response.status(), AxumStatusCode::OK);
8716
8717        let request = engine.last_request();
8718        let defaults = default_chat_sampling_params();
8719        assert_eq!(request.sampling_params.max_tokens, 3);
8720        assert!(!request
8721            .metadata
8722            .contains_key(DEFAULT_MAX_TOKENS_METADATA_KEY));
8723        assert_eq!(request.sampling_params.temperature, defaults.temperature);
8724        assert_eq!(
8725            request.sampling_params.repetition_penalty,
8726            defaults.repetition_penalty
8727        );
8728        assert_eq!(request.sampling_params.stop_sequences, vec!["<END>"]);
8729    }
8730
8731    #[tokio::test]
8732    async fn chat_maps_vllm_sampling_extensions_without_hidden_defaults() {
8733        let (router, engine) = router_with_capturing_llm();
8734        let response = post_json(
8735            router,
8736            "/v1/chat/completions",
8737            json!({
8738                "model": "stub-model",
8739                "messages": [{"role": "user", "content": "hello"}],
8740                "top_k": 20,
8741                "min_p": 0.05,
8742                "repetition_penalty": 1.25
8743            }),
8744        )
8745        .await;
8746        assert_eq!(response.status(), AxumStatusCode::OK);
8747
8748        let request = engine.last_request();
8749        assert_eq!(request.sampling_params.top_k, Some(20));
8750        assert_eq!(request.sampling_params.min_p, Some(0.05));
8751        assert_eq!(request.sampling_params.repetition_penalty, 1.25);
8752    }
8753
8754    #[tokio::test]
8755    async fn chat_normalizes_disabled_sampling_extensions_and_rejects_invalid_ranges() {
8756        let (router, engine) = router_with_capturing_llm();
8757        let response = post_json(
8758            router,
8759            "/v1/chat/completions",
8760            json!({
8761                "model": "stub-model",
8762                "messages": [{"role": "user", "content": "hello"}],
8763                "top_k": -1,
8764                "min_p": 0.0,
8765                "repetition_penalty": 1.0
8766            }),
8767        )
8768        .await;
8769        assert_eq!(response.status(), AxumStatusCode::OK);
8770        let request = engine.last_request();
8771        assert_eq!(request.sampling_params.top_k, None);
8772        assert_eq!(request.sampling_params.min_p, None);
8773        assert_eq!(request.sampling_params.repetition_penalty, 1.0);
8774
8775        for (field, value) in [
8776            ("top_k", json!(-2)),
8777            ("min_p", json!(1.01)),
8778            ("repetition_penalty", json!(0.0)),
8779            ("presence_penalty", json!(2.01)),
8780            ("frequency_penalty", json!(-2.01)),
8781        ] {
8782            let (router, _) = router_with_capturing_llm();
8783            let response = post_json(
8784                router,
8785                "/v1/chat/completions",
8786                json!({
8787                    "model": "stub-model",
8788                    "messages": [{"role": "user", "content": "hello"}],
8789                    (field): value
8790                }),
8791            )
8792            .await;
8793            assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST, "{field}");
8794            let body = response_json(response).await;
8795            assert_eq!(body["error"]["param"], field);
8796        }
8797    }
8798
8799    #[tokio::test]
8800    async fn chat_request_forbids_initial_think_close_token() {
8801        let engine = Arc::new(CapturingLlm::new());
8802        let router = AxumServer::from_llm(engine.clone()).build_router();
8803        let response = post_json(
8804            router,
8805            "/v1/chat/completions",
8806            json!({
8807                "model": "qwen3",
8808                "messages": [{"role": "user", "content": "hello"}]
8809            }),
8810        )
8811        .await;
8812        assert_eq!(response.status(), AxumStatusCode::OK);
8813
8814        let request = engine.last_request();
8815        assert_eq!(
8816            request
8817                .metadata
8818                .get(INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY),
8819            Some(&serde_json::json!([THINK_END_TAG]))
8820        );
8821    }
8822
8823    #[tokio::test]
8824    async fn omitted_enable_thinking_preserves_model_template_default() {
8825        let template = ModelChatTemplate::new(
8826            "{% 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' }}{% else %}{{ '<think>\n' }}{% endif %}{% endif %}",
8827            "test-template",
8828        );
8829        let (router, engine) = router_with_capturing_llm_and_template(template);
8830        let response = post_json(
8831            router,
8832            "/v1/chat/completions",
8833            json!({
8834                "model": "served-alias",
8835                "messages": [{"role": "user", "content": "hello"}]
8836            }),
8837        )
8838        .await;
8839        assert_eq!(response.status(), AxumStatusCode::OK);
8840
8841        let request = engine.last_request();
8842        assert!(request.prompt.ends_with("<|im_start|>assistant\n<think>\n"));
8843        assert!(!request
8844            .metadata
8845            .contains_key(INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY));
8846    }
8847
8848    #[tokio::test]
8849    async fn server_thinking_default_applies_but_request_override_wins() {
8850        let template = ModelChatTemplate::new(
8851            "{% if add_generation_prompt %}<assistant>{% if enable_thinking is defined and enable_thinking is false %}<think>\n\n</think>\n\n{% else %}<think>\n{% endif %}{% endif %}",
8852            "test-template",
8853        );
8854        let (router, engine) =
8855            router_with_capturing_llm_and_template_default(template, Some(false));
8856
8857        let response = post_json(
8858            router.clone(),
8859            "/v1/chat/completions",
8860            json!({
8861                "model": "served-alias",
8862                "messages": [{"role": "user", "content": "hello"}]
8863            }),
8864        )
8865        .await;
8866        assert_eq!(response.status(), AxumStatusCode::OK);
8867        let request = engine.last_request();
8868        assert_eq!(request.prompt, "<assistant><think>\n\n</think>\n\n");
8869        assert_eq!(
8870            request
8871                .metadata
8872                .get(INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY),
8873            Some(&serde_json::json!([THINK_END_TAG, THINK_START_TAG]))
8874        );
8875
8876        let response = post_json(
8877            router,
8878            "/v1/chat/completions",
8879            json!({
8880                "model": "served-alias",
8881                "messages": [{"role": "user", "content": "hello"}],
8882                "chat_template_kwargs": {"enable_thinking": true}
8883            }),
8884        )
8885        .await;
8886        assert_eq!(response.status(), AxumStatusCode::OK);
8887        let request = engine.last_request();
8888        assert_eq!(request.prompt, "<assistant><think>\n");
8889        assert!(!request
8890            .metadata
8891            .contains_key(INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY));
8892    }
8893
8894    #[tokio::test]
8895    async fn chat_template_enable_thinking_true_overrides_default() {
8896        let template = ModelChatTemplate::new(
8897            "{% if add_generation_prompt %}<assistant>{% if enable_thinking is defined and enable_thinking is false %}<think>\n\n</think>\n\n{% else %}<think>\n{% endif %}{% endif %}",
8898            "test-template",
8899        );
8900        let (router, engine) = router_with_capturing_llm_and_template(template);
8901        let response = post_json(
8902            router,
8903            "/v1/chat/completions",
8904            json!({
8905                "model": "served-alias",
8906                "messages": [{"role": "user", "content": "hello"}],
8907                "chat_template_kwargs": {"enable_thinking": true}
8908            }),
8909        )
8910        .await;
8911        assert_eq!(response.status(), AxumStatusCode::OK);
8912
8913        let request = engine.last_request();
8914        assert_eq!(request.prompt, "<assistant><think>\n");
8915        assert!(!request
8916            .metadata
8917            .contains_key(INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY));
8918    }
8919
8920    #[tokio::test]
8921    async fn chat_template_enable_thinking_false_is_a_hard_override() {
8922        let template = ModelChatTemplate::new(
8923            "{% if add_generation_prompt %}<assistant>{% if enable_thinking is defined and enable_thinking is false %}<think>\n\n</think>\n\n{% else %}<think>\n{% endif %}{% endif %}",
8924            "test-template",
8925        );
8926        let (router, engine) = router_with_capturing_llm_and_template(template);
8927        let response = post_json(
8928            router,
8929            "/v1/chat/completions",
8930            json!({
8931                "model": "served-alias",
8932                "messages": [{"role": "user", "content": "hello"}],
8933                "chat_template_kwargs": {"enable_thinking": false}
8934            }),
8935        )
8936        .await;
8937        assert_eq!(response.status(), AxumStatusCode::OK);
8938
8939        let request = engine.last_request();
8940        assert_eq!(request.prompt, "<assistant><think>\n\n</think>\n\n");
8941        assert_eq!(
8942            request
8943                .metadata
8944                .get(INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY),
8945            Some(&serde_json::json!([THINK_END_TAG, THINK_START_TAG]))
8946        );
8947    }
8948
8949    #[tokio::test]
8950    async fn chat_template_enable_thinking_rejects_non_bool() {
8951        let template = ModelChatTemplate::new(
8952            "{% if add_generation_prompt %}<assistant>{% endif %}",
8953            "test-template",
8954        );
8955        let (router, _) = router_with_capturing_llm_and_template(template);
8956        let response = post_json(
8957            router,
8958            "/v1/chat/completions",
8959            json!({
8960                "model": "served-alias",
8961                "messages": [{"role": "user", "content": "hello"}],
8962                "chat_template_kwargs": {"enable_thinking": "false"}
8963            }),
8964        )
8965        .await;
8966        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
8967        let body = response_json(response).await;
8968        assert_eq!(body["error"]["type"], "invalid_request_error");
8969        assert!(body["error"]["message"]
8970            .as_str()
8971            .unwrap_or_default()
8972            .contains("chat_template_kwargs.enable_thinking must be a boolean"));
8973    }
8974
8975    #[tokio::test]
8976    async fn stop_string_strips_chat_and_completion_suffixes() {
8977        let chat = post_json(
8978            router_with_stub("hello<END>"),
8979            "/v1/chat/completions",
8980            json!({
8981                "model": "stub-model",
8982                "messages": [{"role": "user", "content": "hello"}],
8983                "stop": "<END>"
8984            }),
8985        )
8986        .await;
8987        assert_eq!(chat.status(), AxumStatusCode::OK);
8988        let chat_body = response_json(chat).await;
8989        assert_eq!(chat_body["choices"][0]["message"]["content"], "hello");
8990
8991        let completion = post_json(
8992            router_with_stub("done<END>"),
8993            "/v1/completions",
8994            json!({
8995                "model": "stub-model",
8996                "prompt": "complete",
8997                "stop": "<END>"
8998            }),
8999        )
9000        .await;
9001        assert_eq!(completion.status(), AxumStatusCode::OK);
9002        let completion_body = response_json(completion).await;
9003        assert_eq!(completion_body["choices"][0]["text"], "done");
9004    }
9005
9006    #[test]
9007    fn started_in_think_parse_streams_reasoning_before_end_tag() {
9008        // R1-distill templates open `<think>` inside the prompt, so the
9009        // generated text never contains the start tag. Mid-think text must
9010        // be reasoning, not content (this leaked as content deltas before).
9011        let parsed = parse_reasoning_response_started_in_think("Okay, the user wants");
9012        assert_eq!(parsed.reasoning.as_deref(), Some("Okay, the user wants"));
9013        assert_eq!(parsed.content, "");
9014
9015        let parsed = parse_reasoning_response_started_in_think("thinking...</think>\nanswer");
9016        assert_eq!(parsed.reasoning.as_deref(), Some("thinking..."));
9017        assert_eq!(parsed.content, "answer");
9018
9019        // Model re-opening its own think block defers to the normal parse.
9020        let parsed = parse_reasoning_response_started_in_think("<think>\nx\n</think>\n\nanswer");
9021        assert_eq!(parsed.reasoning.as_deref(), Some("\nx\n"));
9022        assert_eq!(parsed.content, "answer");
9023    }
9024
9025    #[tokio::test]
9026    async fn chat_response_splits_reasoning_from_content() {
9027        let response = post_json(
9028            router_with_stub("<think>\nreasoning\n</think>\n\nfinal answer"),
9029            "/v1/chat/completions",
9030            json!({
9031                "model": "stub-model",
9032                "messages": [{"role": "user", "content": "hello"}]
9033            }),
9034        )
9035        .await;
9036        assert_eq!(response.status(), AxumStatusCode::OK);
9037
9038        let body = response_json(response).await;
9039        let message = &body["choices"][0]["message"];
9040        assert_eq!(message["content"], "final answer");
9041        assert_eq!(message["reasoning"], "\nreasoning\n");
9042        assert!(message.get("reasoning_content").is_none());
9043    }
9044
9045    #[tokio::test]
9046    async fn streaming_chat_reasoning_prefix_chunks_do_not_panic_or_leak_content() {
9047        let response = post_json(
9048            router_with_stub_stream_chunks(&["<", "think", ">\nreason", "\n</think>\n\nfinal"]),
9049            "/v1/chat/completions",
9050            json!({
9051                "model": "stub-model",
9052                "messages": [{"role": "user", "content": "think then answer"}],
9053                "stream": true
9054            }),
9055        )
9056        .await;
9057        assert_eq!(response.status(), AxumStatusCode::OK);
9058        let body = response_text(response).await;
9059        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
9060        assert!(
9061            body.contains(r#""reasoning":"\nreason"#),
9062            "stream should emit reasoning delta after full think prefix: {body}"
9063        );
9064        assert!(
9065            body.contains(r#""content":"final""#),
9066            "stream should emit visible content after think close: {body}"
9067        );
9068        assert!(
9069            !body.contains(r#""content":"<"#),
9070            "partial think prefix must not leak as content: {body}"
9071        );
9072    }
9073
9074    #[tokio::test]
9075    async fn route_rejects_unsupported_tool_and_function_selection() {
9076        for (extra, param) in [
9077            (
9078                json!({
9079                    "tools": [{
9080                        "type": "function",
9081                        "function": {"name": "weather", "parameters": {"type": "object"}}
9082                    }],
9083                    "tool_choice": {
9084                        "type": "function",
9085                        "function": {"name": "calendar"}
9086                    }
9087                }),
9088                "tool_choice",
9089            ),
9090            (
9091                json!({
9092                    "functions": [{"name": "weather", "parameters": {"type": "object"}}],
9093                    "function_call": {"name": "calendar"}
9094                }),
9095                "function_call",
9096            ),
9097        ] {
9098            let mut body = json!({
9099                "model": "stub-model",
9100                "messages": [{"role": "user", "content": "hello"}]
9101            });
9102            body.as_object_mut()
9103                .expect("object")
9104                .extend(extra.as_object().expect("extra object").clone());
9105            let response =
9106                post_json(router_with_stub("unused"), "/v1/chat/completions", body).await;
9107            assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
9108            let body = response_json(response).await;
9109            assert_eq!(body["error"]["type"], "invalid_request_error");
9110            assert_eq!(body["error"]["param"], param);
9111        }
9112    }
9113
9114    #[tokio::test]
9115    async fn route_rejects_non_function_tools_with_openai_error_param() {
9116        let response = post_json(
9117            router_with_stub("unused"),
9118            "/v1/chat/completions",
9119            json!({
9120                "model": "stub-model",
9121                "messages": [{"role": "user", "content": "hello"}],
9122                "tools": [{
9123                    "type": "retrieval",
9124                    "function": {"name": "search", "parameters": {"type": "object"}}
9125                }]
9126            }),
9127        )
9128        .await;
9129        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
9130        let body = response_json(response).await;
9131        assert_eq!(body["error"]["type"], "invalid_request_error");
9132        assert_eq!(body["error"]["param"], "tools");
9133    }
9134
9135    #[tokio::test]
9136    async fn route_rejects_tool_choice_required_without_tools() {
9137        let response = post_json(
9138            router_with_stub("unused"),
9139            "/v1/chat/completions",
9140            json!({
9141                "model": "stub-model",
9142                "messages": [{"role": "user", "content": "hello"}],
9143                "tool_choice": "required"
9144            }),
9145        )
9146        .await;
9147        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
9148        let body = response_json(response).await;
9149        assert_eq!(body["error"]["type"], "invalid_request_error");
9150        assert_eq!(body["error"]["param"], "tool_choice");
9151    }
9152
9153    #[tokio::test]
9154    async fn route_rejects_unknown_response_format_type_with_openai_error_param() {
9155        let response = post_json(
9156            router_with_stub("unused"),
9157            "/v1/chat/completions",
9158            json!({
9159                "model": "stub-model",
9160                "messages": [{"role": "user", "content": "hello"}],
9161                "response_format": {"type": "xml"}
9162            }),
9163        )
9164        .await;
9165        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
9166        let body = response_json(response).await;
9167        assert_eq!(body["error"]["type"], "invalid_request_error");
9168        assert_eq!(body["error"]["param"], "response_format.type");
9169    }
9170
9171    #[tokio::test]
9172    async fn route_chat_engine_unavailable_maps_to_503() {
9173        let response = post_json(
9174            router_without_llm(),
9175            "/v1/chat/completions",
9176            json!({
9177                "model": "stub-model",
9178                "messages": [{"role": "user", "content": "hello"}]
9179            }),
9180        )
9181        .await;
9182        assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
9183        let body = response_json(response).await;
9184        assert_eq!(body["error"]["type"], "service_unavailable_error");
9185        assert_eq!(body["error"]["param"], Value::Null);
9186    }
9187
9188    #[tokio::test]
9189    async fn route_chat_generation_failure_maps_to_500() {
9190        let response = post_json(
9191            router_with_failing_llm(),
9192            "/v1/chat/completions",
9193            json!({
9194                "model": "failing-model",
9195                "messages": [{"role": "user", "content": "hello"}]
9196            }),
9197        )
9198        .await;
9199        assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
9200        let body = response_json(response).await;
9201        assert_eq!(body["error"]["type"], "internal_server_error");
9202        assert!(body["error"]["message"]
9203            .as_str()
9204            .unwrap()
9205            .contains("stub generation failed"));
9206    }
9207
9208    #[tokio::test]
9209    async fn route_chat_generation_failure_writes_replay_diagnostics() {
9210        let root = unique_request_dump_dir("chat-sync-failure");
9211        let profile = unique_profile_jsonl("chat-sync-failure");
9212        let response = post_json(
9213            router_with_failing_llm_request_dump_and_profile(root.clone(), profile.clone()),
9214            "/v1/chat/completions",
9215            json!({
9216                "model": "failing-model",
9217                "messages": [{"role": "user", "content": "hello"}]
9218            }),
9219        )
9220        .await;
9221        assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
9222        assert_chat_failure_replay_bundle(
9223            &root,
9224            "chat_completions_sync",
9225            "internal",
9226            "stub generation failed",
9227        );
9228        let event = read_profile_events(&profile)
9229            .into_iter()
9230            .find(|event| event["phase"] == "chat_completions_sync")
9231            .expect("sync failure profile event");
9232        assert_eq!(event["event_kind"], "timed_span");
9233        assert_eq!(event["status"], "failure");
9234        assert!(event["duration_us"].as_u64().is_some());
9235        assert_eq!(event["attributes"]["terminal_failure_event"], true);
9236        assert_eq!(event["error"]["kind"], "internal");
9237        let _ = fs::remove_dir_all(root);
9238        let _ = fs::remove_file(profile);
9239    }
9240
9241    #[tokio::test]
9242    async fn route_chat_resource_failure_writes_resource_replay_diagnostics() {
9243        let root = unique_request_dump_dir("chat-resource-failure");
9244        let response = post_json(
9245            router_with_resource_exhausted_llm_and_request_dump_dir(root.clone()),
9246            "/v1/chat/completions",
9247            json!({
9248                "model": "failing-model",
9249                "messages": [{"role": "user", "content": "hello"}]
9250            }),
9251        )
9252        .await;
9253        assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
9254        let bundle = only_replay_bundle(&root);
9255        let bad_scan = read_json_file(bundle.join("bad_output_scan.json"));
9256        assert_eq!(bad_scan["failure_kind"], "oom_admission");
9257        let diagnostics = read_json_file(bundle.join("failure_diagnostics.json"));
9258        assert_eq!(diagnostics["failure_kind"], "oom_admission");
9259        assert_eq!(
9260            diagnostics["first_failure_event"]["error_kind"],
9261            "resource_exhausted"
9262        );
9263        assert_eq!(
9264            diagnostics["capacity"]["resource_kind"],
9265            "admission_capacity"
9266        );
9267        assert!(diagnostics["capacity"]["reason"]
9268            .as_str()
9269            .expect("capacity reason")
9270            .contains("admission capacity exhausted"));
9271        assert_eq!(
9272            diagnostics["nearest_resource_event"]["resource_kind"],
9273            "admission_capacity"
9274        );
9275        assert!(diagnostics["nearest_memory_snapshot"]["current_bytes"].is_number());
9276        assert!(diagnostics["nearest_memory_snapshot"]["high_water_bytes"].is_number());
9277        let _ = fs::remove_dir_all(root);
9278    }
9279
9280    #[tokio::test]
9281    async fn route_chat_sync_success_updates_replay_output_tokens() {
9282        let root = unique_request_dump_dir("chat-sync-success-output");
9283        let response = post_json(
9284            router_with_stub_and_request_dump_dir("OK", root.clone()),
9285            "/v1/chat/completions",
9286            json!({
9287                "model": "stub-model",
9288                "messages": [{"role": "user", "content": "hello"}]
9289            }),
9290        )
9291        .await;
9292        assert_eq!(response.status(), AxumStatusCode::OK);
9293        let body = response_json(response).await;
9294        assert_eq!(body["choices"][0]["message"]["content"], "OK");
9295        assert_chat_success_replay_bundle(&root, &[11, 12], "stop", "OK");
9296        let _ = fs::remove_dir_all(root);
9297    }
9298
9299    #[tokio::test]
9300    async fn route_chat_sync_success_writes_product_profile_event() {
9301        let root = unique_request_dump_dir("chat-sync-profile");
9302        let profile = unique_profile_jsonl("chat-sync-profile");
9303        let response = post_json(
9304            router_with_stub_request_dump_and_profile("OK", root.clone(), profile.clone()),
9305            "/v1/chat/completions",
9306            json!({
9307                "model": "stub-model",
9308                "messages": [{"role": "user", "content": "hello"}]
9309            }),
9310        )
9311        .await;
9312        assert_eq!(response.status(), AxumStatusCode::OK);
9313        let _ = response_json(response).await;
9314
9315        let events = read_profile_events(&profile);
9316        assert_eq!(events.len(), 2, "events: {events:#?}");
9317        let event = events
9318            .iter()
9319            .find(|event| event["phase"] == "chat_completions_sync_complete")
9320            .expect("sync completion profile event");
9321        assert_eq!(
9322            event["schema_version"],
9323            OBSERVABILITY_PROFILE_SCHEMA_VERSION
9324        );
9325        assert_eq!(event["entrypoint"], "serve");
9326        assert_eq!(event["event_kind"], "timed_span");
9327        assert_eq!(event["status"], "ok");
9328        assert_eq!(event["phase"], "chat_completions_sync_complete");
9329        assert_eq!(event["attributes"]["actual_model_smoke"], true);
9330        assert_eq!(event["attributes"]["profile_detail"], "latency");
9331        assert_eq!(event["attributes"]["diagnostic_only"], false);
9332        assert_eq!(event["attributes"]["stream"], false);
9333        assert_eq!(event["attributes"]["output_token_count"], 2);
9334        assert_eq!(event["attributes"]["prompt_token_count"], 7);
9335        assert_eq!(event["attributes"]["completion_token_count"], 2);
9336        assert_eq!(event["attributes"]["total_token_count"], 9);
9337        assert_eq!(event["attributes"]["token_count_source"], "usage");
9338        assert_eq!(event["attributes"]["finish_reason"], "stop");
9339        assert_eq!(
9340            event["attributes"]["engine_token_clock_source"],
9341            "rust_std_instant"
9342        );
9343        assert_eq!(event["attributes"]["engine_token_commit_count"], 2);
9344        assert_eq!(event["attributes"]["itl_interval_count"], 1);
9345        assert_eq!(event["attributes"]["ttft_us"], 1_000);
9346        assert_eq!(event["attributes"]["itl_us_avg"], 1_000);
9347        assert!(event["attributes"]["http_first_sse_enqueue_us"].is_null());
9348        assert!(event["duration_us"].as_u64().unwrap_or_default() > 0);
9349        assert!(
9350            event["attributes"]["e2e_duration_us"]
9351                .as_u64()
9352                .unwrap_or_default()
9353                > 0
9354        );
9355        assert_eq!(
9356            event["replay"]["bundle_dir"].as_str(),
9357            Some(root.to_string_lossy().as_ref())
9358        );
9359        assert!(event["replay"]["command"]
9360            .as_str()
9361            .unwrap_or_default()
9362            .contains("replay_body.json"));
9363        let memory_event = events
9364            .iter()
9365            .find(|event| event["phase"] == "actual_serve_first_request_done")
9366            .expect("first request memory profile event");
9367        assert_eq!(memory_event["event_kind"], "memory");
9368        assert_eq!(
9369            memory_event["attributes"]["memory_stage"],
9370            "first_request_done"
9371        );
9372        assert_eq!(
9373            memory_event["attributes"]["memory_measurement"],
9374            "process_rss"
9375        );
9376        assert!(memory_event["memory"]["current_bytes"]
9377            .as_u64()
9378            .is_some_and(|bytes| bytes > 0));
9379        let _ = fs::remove_dir_all(root);
9380        let _ = fs::remove_file(profile);
9381    }
9382
9383    #[tokio::test]
9384    async fn route_chat_sync_profile_jsonl_is_parseable_under_concurrent_requests() {
9385        let root = unique_request_dump_dir("chat-sync-profile-concurrent");
9386        let profile = unique_profile_jsonl("chat-sync-profile-concurrent");
9387        let app = router_with_stub_request_dump_and_profile("OK", root.clone(), profile.clone());
9388
9389        let mut handles = Vec::new();
9390        for request_index in 0..8 {
9391            let app = app.clone();
9392            handles.push(tokio::spawn(async move {
9393                let response = post_json(
9394                    app,
9395                    "/v1/chat/completions",
9396                    json!({
9397                        "model": "stub-model",
9398                        "messages": [{"role": "user", "content": format!("hello {request_index}")}]
9399                    }),
9400                )
9401                .await;
9402                assert_eq!(response.status(), AxumStatusCode::OK);
9403                let body = response_json(response).await;
9404                assert_eq!(body["choices"][0]["message"]["content"], "OK");
9405            }));
9406        }
9407
9408        for handle in handles {
9409            handle.await.expect("concurrent request task");
9410        }
9411
9412        let raw = fs::read_to_string(&profile).expect("profile jsonl");
9413        let mut completion_events = 0usize;
9414        for (line_index, line) in raw
9415            .lines()
9416            .filter(|line| !line.trim().is_empty())
9417            .enumerate()
9418        {
9419            let event: Value = serde_json::from_str(line).unwrap_or_else(|err| {
9420                panic!(
9421                    "profile line {} invalid JSON: {err}: {line}",
9422                    line_index + 1
9423                )
9424            });
9425            if event["phase"] == "chat_completions_sync_complete" {
9426                completion_events += 1;
9427            }
9428        }
9429        assert_eq!(completion_events, 8);
9430        let _ = fs::remove_dir_all(root);
9431        let _ = fs::remove_file(profile);
9432    }
9433
9434    #[tokio::test]
9435    async fn route_chat_stream_success_updates_replay_output_tokens() {
9436        let root = unique_request_dump_dir("chat-stream-success-output");
9437        let response = post_json(
9438            router_with_stub_stream_chunks_and_request_dump_dir(&["O", "K"], root.clone()),
9439            "/v1/chat/completions",
9440            json!({
9441                "model": "stub-model",
9442                "messages": [{"role": "user", "content": "hello"}],
9443                "stream": true
9444            }),
9445        )
9446        .await;
9447        assert_eq!(response.status(), AxumStatusCode::OK);
9448        let body = response_text(response).await;
9449        assert!(body.contains("data: [DONE]"), "body: {body}");
9450        assert_chat_success_replay_bundle(&root, &[11, 12], "stop", "OK");
9451        let _ = fs::remove_dir_all(root);
9452    }
9453
9454    #[tokio::test]
9455    async fn route_chat_stream_success_writes_product_profile_event() {
9456        let root = unique_request_dump_dir("chat-stream-profile");
9457        let profile = unique_profile_jsonl("chat-stream-profile");
9458        let response = post_json(
9459            router_with_stub_stream_request_dump_and_profile(
9460                &["O", "K"],
9461                root.clone(),
9462                profile.clone(),
9463            ),
9464            "/v1/chat/completions",
9465            json!({
9466                "model": "stub-model",
9467                "messages": [{"role": "user", "content": "hello"}],
9468                "stream": true
9469            }),
9470        )
9471        .await;
9472        assert_eq!(response.status(), AxumStatusCode::OK);
9473        let body = response_text(response).await;
9474        assert!(body.contains("data: [DONE]"), "body: {body}");
9475
9476        let events = read_profile_events(&profile);
9477        assert_eq!(events.len(), 2, "events: {events:#?}");
9478        let event = events
9479            .iter()
9480            .find(|event| event["phase"] == "chat_completions_stream_complete")
9481            .expect("stream completion profile event");
9482        assert_eq!(
9483            event["schema_version"],
9484            OBSERVABILITY_PROFILE_SCHEMA_VERSION
9485        );
9486        assert_eq!(event["entrypoint"], "serve");
9487        assert_eq!(event["event_kind"], "timed_span");
9488        assert_eq!(event["status"], "ok");
9489        assert_eq!(event["phase"], "chat_completions_stream_complete");
9490        assert_eq!(event["attributes"]["actual_model_smoke"], true);
9491        assert_eq!(event["attributes"]["profile_detail"], "latency");
9492        assert_eq!(event["attributes"]["diagnostic_only"], false);
9493        assert_eq!(event["attributes"]["stream"], true);
9494        assert_eq!(event["attributes"]["output_token_count"], 2);
9495        assert_eq!(event["attributes"]["prompt_token_count"], 5);
9496        assert_eq!(event["attributes"]["completion_token_count"], 2);
9497        assert_eq!(event["attributes"]["total_token_count"], 7);
9498        assert_eq!(event["attributes"]["token_count_source"], "usage");
9499        assert_eq!(event["attributes"]["finish_reason"], "stop");
9500        assert!(event["duration_us"].as_u64().unwrap_or_default() > 0);
9501        assert!(
9502            event["attributes"]["e2e_duration_us"]
9503                .as_u64()
9504                .unwrap_or_default()
9505                > 0
9506        );
9507        assert!(event["attributes"]["ttft_us"].as_u64().is_some());
9508        assert!(event["attributes"]["itl_us_avg"].as_u64().is_some());
9509        assert_eq!(
9510            event["attributes"]["engine_token_commit_nanos_since_request_start"],
9511            json!([1_000_000, 2_000_000])
9512        );
9513        assert_eq!(event["attributes"]["itl_interval_count"], 1);
9514        assert_eq!(event["attributes"]["itl_source"], "engine_token_commit");
9515        assert!(event["attributes"]["engine_stream_first_chunk_received_us"]
9516            .as_u64()
9517            .is_some());
9518        assert!(event["attributes"]["http_first_sse_enqueue_us"]
9519            .as_u64()
9520            .is_some());
9521        assert!(event["attributes"]["http_stream_flush_unavailable_reason"]
9522            .as_str()
9523            .is_some());
9524        assert_eq!(
9525            event["replay"]["bundle_dir"].as_str(),
9526            Some(root.to_string_lossy().as_ref())
9527        );
9528        let memory_event = events
9529            .iter()
9530            .find(|event| event["phase"] == "actual_serve_first_request_done")
9531            .expect("first request memory profile event");
9532        assert_eq!(memory_event["event_kind"], "memory");
9533        assert_eq!(
9534            memory_event["attributes"]["memory_stage"],
9535            "first_request_done"
9536        );
9537        assert_eq!(
9538            memory_event["attributes"]["memory_measurement"],
9539            "process_rss"
9540        );
9541        assert!(memory_event["memory"]["current_bytes"]
9542            .as_u64()
9543            .is_some_and(|bytes| bytes > 0));
9544        let _ = fs::remove_dir_all(root);
9545        let _ = fs::remove_file(profile);
9546    }
9547
9548    #[tokio::test]
9549    async fn route_chat_stream_profile_retains_non_visible_terminal_token() {
9550        let root = unique_request_dump_dir("chat-stream-profile-terminal-token");
9551        let profile = unique_profile_jsonl("chat-stream-profile-terminal-token");
9552        let llm = StubLlm {
9553            stream_usage: Some(TokenUsage::new(5, 2)),
9554            ..StubLlm::with_stream_chunks(&["Paris"])
9555        };
9556        let app = AxumServer::from_state(
9557            AppState::default()
9558                .with_llm(Arc::new(llm))
9559                .with_request_dump_dir(Some(root.clone()))
9560                .with_profile_detail(ferrum_types::ObservabilityProfileDetail::Latency)
9561                .with_profile_jsonl(Some(profile.clone())),
9562        )
9563        .build_router();
9564
9565        let response = post_json(
9566            app,
9567            "/v1/chat/completions",
9568            json!({
9569                "model": "stub-model",
9570                "messages": [{"role": "user", "content": "hello"}],
9571                "stream": true,
9572                "stream_options": {"include_usage": true}
9573            }),
9574        )
9575        .await;
9576        assert_eq!(response.status(), AxumStatusCode::OK);
9577        let body = response_text(response).await;
9578        assert!(body.contains("\"completion_tokens\":2"), "body: {body}");
9579        assert!(body.contains("data: [DONE]"), "body: {body}");
9580
9581        let events = read_profile_events(&profile);
9582        let event = events
9583            .iter()
9584            .find(|event| event["phase"] == "chat_completions_stream_complete")
9585            .expect("stream completion profile event");
9586        assert_eq!(event["attributes"]["output_token_count"], 2);
9587        assert_eq!(event["attributes"]["completion_token_count"], 2);
9588        assert_eq!(event["attributes"]["engine_token_commit_count"], 2);
9589        assert_chat_success_replay_bundle(&root, &[11, 12], "stop", "Paris");
9590
9591        let _ = fs::remove_dir_all(root);
9592        let _ = fs::remove_file(profile);
9593    }
9594
9595    #[tokio::test]
9596    async fn route_chat_sync_bad_output_updates_replay_classifier() {
9597        let root = unique_request_dump_dir("chat-sync-bad-output");
9598        let response = post_json(
9599            router_with_stub_and_request_dump_dir("<unk>", root.clone()),
9600            "/v1/chat/completions",
9601            json!({
9602                "model": "stub-model",
9603                "messages": [{"role": "user", "content": "hello"}]
9604            }),
9605        )
9606        .await;
9607        assert_eq!(response.status(), AxumStatusCode::OK);
9608        let _ = response_json(response).await;
9609        let bundle = only_replay_bundle(&root);
9610        let bad_scan = read_json_file(bundle.join("bad_output_scan.json"));
9611        assert_eq!(bad_scan["bad_output"], true);
9612        assert_eq!(bad_scan["reasons"], json!(["reserved_token"]));
9613        assert_eq!(bad_scan["first_bad_text_span"]["reason"], "reserved_token");
9614        let _ = fs::remove_dir_all(root);
9615    }
9616
9617    #[tokio::test]
9618    async fn route_chat_stream_generation_failure_emits_openai_error_event() {
9619        let response = post_json(
9620            router_with_failing_llm(),
9621            "/v1/chat/completions",
9622            json!({
9623                "model": "failing-model",
9624                "messages": [{"role": "user", "content": "hello"}],
9625                "stream": true
9626            }),
9627        )
9628        .await;
9629        assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
9630        let body = response_json(response).await;
9631        assert_eq!(body["error"]["type"], "internal_server_error");
9632        assert!(body["error"]["message"]
9633            .as_str()
9634            .unwrap_or_default()
9635            .contains("stub stream failed"));
9636    }
9637
9638    #[tokio::test]
9639    async fn route_chat_stream_generation_failure_writes_replay_diagnostics() {
9640        let root = unique_request_dump_dir("chat-stream-start-failure");
9641        let response = post_json(
9642            router_with_failing_llm_and_request_dump_dir(root.clone()),
9643            "/v1/chat/completions",
9644            json!({
9645                "model": "failing-model",
9646                "messages": [{"role": "user", "content": "hello"}],
9647                "stream": true
9648            }),
9649        )
9650        .await;
9651        assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
9652        let body = response_json(response).await;
9653        assert_eq!(body["error"]["type"], "internal_server_error");
9654        assert!(body["error"]["message"]
9655            .as_str()
9656            .unwrap_or_default()
9657            .contains("stub stream failed"));
9658        assert_chat_failure_replay_bundle(
9659            &root,
9660            "chat_completions_stream_start",
9661            "internal",
9662            "stub stream failed",
9663        );
9664        let _ = fs::remove_dir_all(root);
9665    }
9666
9667    #[tokio::test]
9668    async fn route_chat_stream_chunk_failure_emits_openai_error_event() {
9669        let response = post_json(
9670            router_with_stream_chunk_failing_llm(),
9671            "/v1/chat/completions",
9672            json!({
9673                "model": "failing-model",
9674                "messages": [{"role": "user", "content": "hello"}],
9675                "stream": true
9676            }),
9677        )
9678        .await;
9679        assert_eq!(response.status(), AxumStatusCode::OK);
9680        let body = response_text(response).await;
9681        assert_openai_stream_error(&body, "stub stream chunk failed");
9682    }
9683
9684    #[tokio::test]
9685    async fn route_chat_stream_chunk_failure_writes_replay_diagnostics() {
9686        let root = unique_request_dump_dir("chat-stream-chunk-failure");
9687        let response = post_json(
9688            router_with_stream_chunk_failing_llm_and_request_dump_dir(root.clone()),
9689            "/v1/chat/completions",
9690            json!({
9691                "model": "failing-model",
9692                "messages": [{"role": "user", "content": "hello"}],
9693                "stream": true
9694            }),
9695        )
9696        .await;
9697        assert_eq!(response.status(), AxumStatusCode::OK);
9698        let body = response_text(response).await;
9699        assert_openai_stream_error(&body, "stub stream chunk failed");
9700        assert_chat_failure_replay_bundle(
9701            &root,
9702            "chat_completions_stream_next",
9703            "internal",
9704            "stub stream chunk failed",
9705        );
9706        let _ = fs::remove_dir_all(root);
9707    }
9708
9709    #[tokio::test]
9710    async fn route_completions_engine_unavailable_maps_to_503() {
9711        let response = post_json(
9712            router_without_llm(),
9713            "/v1/completions",
9714            json!({
9715                "model": "stub-model",
9716                "prompt": "complete me"
9717            }),
9718        )
9719        .await;
9720        assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
9721        let body = response_json(response).await;
9722        assert_eq!(body["error"]["type"], "service_unavailable_error");
9723        assert_eq!(body["error"]["param"], Value::Null);
9724    }
9725
9726    #[tokio::test]
9727    async fn route_embeddings_engine_unavailable_maps_to_503() {
9728        let response = post_json(
9729            router_without_llm(),
9730            "/v1/embeddings",
9731            json!({
9732                "model": "embed-model",
9733                "input": "hello"
9734            }),
9735        )
9736        .await;
9737        assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
9738        let body = response_json(response).await;
9739        assert_eq!(body["error"]["type"], "service_unavailable_error");
9740        assert_eq!(body["error"]["param"], Value::Null);
9741    }
9742
9743    #[tokio::test]
9744    async fn route_embeddings_contract_uses_stub_engine() {
9745        let response = post_json(
9746            router_with_stub_embed(),
9747            "/v1/embeddings",
9748            json!({
9749                "model": "stub-embed",
9750                "input": ["hi", "world"],
9751                "encoding_format": "float"
9752            }),
9753        )
9754        .await;
9755        assert_eq!(response.status(), AxumStatusCode::OK);
9756        let body = response_json(response).await;
9757        assert_eq!(body["object"], "list");
9758        assert_eq!(body["model"], "stub-embed");
9759        assert_eq!(body["usage"]["prompt_tokens"], 7);
9760        assert_eq!(body["usage"]["total_tokens"], 7);
9761
9762        let data = body["data"].as_array().expect("embedding data");
9763        assert_eq!(data.len(), 2, "body: {body}");
9764        assert_eq!(data[0]["object"], "embedding");
9765        assert_eq!(data[0]["index"], 0);
9766        assert_eq!(data[0]["embedding"].as_array().unwrap().len(), 3);
9767        assert_eq!(data[0]["embedding"][0].as_f64().unwrap(), 2.0);
9768        assert_eq!(data[1]["index"], 1);
9769        assert_eq!(data[1]["embedding"][0].as_f64().unwrap(), 5.0);
9770    }
9771
9772    #[tokio::test]
9773    async fn route_embeddings_public_alias_succeeds_and_unknown_alias_is_rejected() {
9774        let registry = ServedModelRegistry::try_new(
9775            "stub-embed",
9776            ServedModelKind::Embedding,
9777            vec!["public-embed".to_string()],
9778            vec![],
9779        )
9780        .unwrap();
9781        let server =
9782            AxumServer::from_embed(Arc::new(StubEmbed::new())).with_served_model_registry(registry);
9783        let accepted = post_json(
9784            server.build_router(),
9785            "/v1/embeddings",
9786            json!({"model": "public-embed", "input": "hello"}),
9787        )
9788        .await;
9789        assert_eq!(accepted.status(), AxumStatusCode::OK);
9790        assert_eq!(response_json(accepted).await["model"], "public-embed");
9791
9792        let rejected = post_json(
9793            server.build_router(),
9794            "/v1/embeddings",
9795            json!({"model": "stub-embed", "input": "hello"}),
9796        )
9797        .await;
9798        assert_eq!(rejected.status(), AxumStatusCode::BAD_REQUEST);
9799        let body = response_json(rejected).await;
9800        assert_eq!(body["error"]["type"], "invalid_request_error");
9801        assert_eq!(body["error"]["param"], "model");
9802    }
9803
9804    #[tokio::test]
9805    async fn route_embeddings_rejects_unsupported_encoding_format() {
9806        let response = post_json(
9807            router_with_stub_embed(),
9808            "/v1/embeddings",
9809            json!({
9810                "model": "stub-embed",
9811                "input": "hi",
9812                "encoding_format": "base64"
9813            }),
9814        )
9815        .await;
9816        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
9817        let body = response_json(response).await;
9818        assert_eq!(body["error"]["type"], "invalid_request_error");
9819        assert_eq!(body["error"]["param"], "encoding_format");
9820    }
9821
9822    #[tokio::test]
9823    async fn route_embeddings_rejects_empty_input_with_field_param() {
9824        let response = post_json(
9825            router_with_stub_embed(),
9826            "/v1/embeddings",
9827            json!({
9828                "model": "stub-embed",
9829                "input": []
9830            }),
9831        )
9832        .await;
9833        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
9834        let body = response_json(response).await;
9835        assert_eq!(body["error"]["type"], "invalid_request_error");
9836        assert_eq!(body["error"]["param"], "input");
9837    }
9838
9839    #[tokio::test]
9840    async fn route_embeddings_rejects_empty_item_with_field_param() {
9841        let response = post_json(
9842            router_with_stub_embed(),
9843            "/v1/embeddings",
9844            json!({
9845                "model": "stub-embed",
9846                "input": [{}]
9847            }),
9848        )
9849        .await;
9850        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
9851        let body = response_json(response).await;
9852        assert_eq!(body["error"]["type"], "invalid_request_error");
9853        assert_eq!(body["error"]["param"], "input");
9854    }
9855
9856    #[tokio::test]
9857    async fn route_embeddings_invalid_json_maps_to_openai_error() {
9858        let response = post_raw_json(router_with_stub_embed(), "/v1/embeddings", "{").await;
9859        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
9860        let body = response_json(response).await;
9861        assert_eq!(body["error"]["type"], "invalid_request_error");
9862        assert_eq!(body["error"]["param"], Value::Null);
9863        assert!(body["error"]["message"]
9864            .as_str()
9865            .unwrap()
9866            .contains("invalid embeddings request"));
9867    }
9868
9869    #[tokio::test]
9870    async fn route_transcriptions_engine_unavailable_maps_to_503() {
9871        let boundary = "ferrum-test-boundary";
9872        let body = concat!(
9873            "--ferrum-test-boundary\r\n",
9874            "Content-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\r\n",
9875            "Content-Type: audio/wav\r\n",
9876            "\r\n",
9877            "RIFFtest\r\n",
9878            "--ferrum-test-boundary--\r\n"
9879        );
9880        let response = post_multipart(
9881            router_without_llm(),
9882            "/v1/audio/transcriptions",
9883            boundary,
9884            body,
9885        )
9886        .await;
9887        assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
9888        let body = response_json(response).await;
9889        assert_eq!(body["error"]["type"], "service_unavailable_error");
9890        assert_eq!(body["error"]["param"], Value::Null);
9891    }
9892
9893    #[tokio::test]
9894    async fn route_transcriptions_contract_uses_stub_engine() {
9895        let boundary = "ferrum-test-boundary";
9896        let body = concat!(
9897            "--ferrum-test-boundary\r\n",
9898            "Content-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\r\n",
9899            "Content-Type: audio/wav\r\n",
9900            "\r\n",
9901            "RIFFtest\r\n",
9902            "--ferrum-test-boundary\r\n",
9903            "Content-Disposition: form-data; name=\"language\"\r\n",
9904            "\r\n",
9905            "en\r\n",
9906            "--ferrum-test-boundary\r\n",
9907            "Content-Disposition: form-data; name=\"response_format\"\r\n",
9908            "\r\n",
9909            "json\r\n",
9910            "--ferrum-test-boundary--\r\n"
9911        );
9912        let response = post_multipart(
9913            router_with_stub_transcribe(),
9914            "/v1/audio/transcriptions",
9915            boundary,
9916            body,
9917        )
9918        .await;
9919        assert_eq!(response.status(), AxumStatusCode::OK);
9920        let body = response_json(response).await;
9921        assert_eq!(body["text"], "bytes:8:en");
9922    }
9923
9924    #[tokio::test]
9925    async fn route_transcriptions_rejects_unsupported_response_format() {
9926        let boundary = "ferrum-test-boundary";
9927        let body = concat!(
9928            "--ferrum-test-boundary\r\n",
9929            "Content-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\r\n",
9930            "Content-Type: audio/wav\r\n",
9931            "\r\n",
9932            "RIFFtest\r\n",
9933            "--ferrum-test-boundary\r\n",
9934            "Content-Disposition: form-data; name=\"response_format\"\r\n",
9935            "\r\n",
9936            "text\r\n",
9937            "--ferrum-test-boundary--\r\n"
9938        );
9939        let response = post_multipart(
9940            router_with_stub_transcribe(),
9941            "/v1/audio/transcriptions",
9942            boundary,
9943            body,
9944        )
9945        .await;
9946        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
9947        let body = response_json(response).await;
9948        assert_eq!(body["error"]["type"], "invalid_request_error");
9949        assert_eq!(body["error"]["param"], "response_format");
9950    }
9951
9952    #[tokio::test]
9953    async fn route_transcriptions_rejects_missing_file_with_field_param() {
9954        let boundary = "ferrum-test-boundary";
9955        let body = concat!(
9956            "--ferrum-test-boundary\r\n",
9957            "Content-Disposition: form-data; name=\"language\"\r\n",
9958            "\r\n",
9959            "en\r\n",
9960            "--ferrum-test-boundary--\r\n"
9961        );
9962        let response = post_multipart(
9963            router_with_stub_transcribe(),
9964            "/v1/audio/transcriptions",
9965            boundary,
9966            body,
9967        )
9968        .await;
9969        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
9970        let body = response_json(response).await;
9971        assert_eq!(body["error"]["type"], "invalid_request_error");
9972        assert_eq!(body["error"]["param"], "file");
9973    }
9974
9975    #[tokio::test]
9976    async fn route_transcriptions_invalid_multipart_maps_to_openai_error() {
9977        let response = post_json(
9978            router_with_stub_transcribe(),
9979            "/v1/audio/transcriptions",
9980            json!({}),
9981        )
9982        .await;
9983        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
9984        let body = response_json(response).await;
9985        assert_eq!(body["error"]["type"], "invalid_request_error");
9986        assert_eq!(body["error"]["param"], Value::Null);
9987        assert!(body["error"]["message"]
9988            .as_str()
9989            .unwrap()
9990            .contains("invalid transcriptions request"));
9991    }
9992
9993    #[tokio::test]
9994    async fn route_speech_engine_unavailable_maps_to_503() {
9995        let response = post_json(
9996            router_without_llm(),
9997            "/v1/audio/speech",
9998            json!({
9999                "model": "tts-model",
10000                "input": "hello",
10001                "voice": "default"
10002            }),
10003        )
10004        .await;
10005        assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
10006        let body = response_json(response).await;
10007        assert_eq!(body["error"]["type"], "service_unavailable_error");
10008        assert_eq!(body["error"]["param"], Value::Null);
10009    }
10010
10011    #[tokio::test]
10012    async fn route_speech_contract_uses_stub_engine() {
10013        let response = post_json(
10014            router_with_stub_tts(),
10015            "/v1/audio/speech",
10016            json!({
10017                "model": "stub-tts",
10018                "input": "hello",
10019                "voice": "default",
10020                "response_format": "wav",
10021                "language": "english"
10022            }),
10023        )
10024        .await;
10025        assert_eq!(response.status(), AxumStatusCode::OK);
10026        assert_eq!(
10027            response.headers().get(header::CONTENT_TYPE).unwrap(),
10028            "audio/wav"
10029        );
10030        let body = response_bytes(response).await;
10031        assert!(body.len() > 44, "WAV should include header and PCM data");
10032        assert_eq!(&body[0..4], b"RIFF");
10033        assert_eq!(&body[8..12], b"WAVE");
10034    }
10035
10036    #[tokio::test]
10037    async fn route_speech_streaming_contract_uses_stub_engine() {
10038        let response = post_json(
10039            router_with_stub_tts(),
10040            "/v1/audio/speech",
10041            json!({
10042                "model": "stub-tts",
10043                "input": "hello",
10044                "voice": "default",
10045                "response_format": "wav",
10046                "stream": true
10047            }),
10048        )
10049        .await;
10050        assert_eq!(response.status(), AxumStatusCode::OK);
10051        assert_eq!(
10052            response.headers().get(header::CONTENT_TYPE).unwrap(),
10053            "audio/wav"
10054        );
10055        assert_eq!(
10056            response.headers().get(header::TRANSFER_ENCODING).unwrap(),
10057            "chunked"
10058        );
10059        let body = response_bytes(response).await;
10060        assert!(body.len() > 44, "streaming WAV should include audio bytes");
10061        assert_eq!(&body[0..4], b"RIFF");
10062        assert_eq!(&body[8..12], b"WAVE");
10063    }
10064
10065    #[tokio::test]
10066    async fn route_speech_pcm_response_format_returns_raw_pcm() {
10067        let response = post_json(
10068            router_with_stub_tts(),
10069            "/v1/audio/speech",
10070            json!({
10071                "model": "stub-tts",
10072                "input": "hello",
10073                "voice": "default",
10074                "response_format": "pcm"
10075            }),
10076        )
10077        .await;
10078        assert_eq!(response.status(), AxumStatusCode::OK);
10079        assert_eq!(
10080            response.headers().get(header::CONTENT_TYPE).unwrap(),
10081            "audio/pcm"
10082        );
10083        let body = response_bytes(response).await;
10084        assert_eq!(body.len(), 6, "three f32 samples should encode as s16le");
10085        assert_eq!(&body[0..2], &[0, 0]);
10086        assert_ne!(&body[0..4], b"RIFF");
10087    }
10088
10089    #[tokio::test]
10090    async fn route_speech_rejects_unsupported_response_format() {
10091        let response = post_json(
10092            router_with_stub_tts(),
10093            "/v1/audio/speech",
10094            json!({
10095                "model": "stub-tts",
10096                "input": "hello",
10097                "voice": "default",
10098                "response_format": "mp3"
10099            }),
10100        )
10101        .await;
10102        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
10103        let body = response_json(response).await;
10104        assert_eq!(body["error"]["type"], "invalid_request_error");
10105        assert_eq!(body["error"]["param"], "response_format");
10106    }
10107
10108    #[tokio::test]
10109    async fn route_speech_invalid_json_maps_to_openai_error() {
10110        let response = post_raw_json(router_with_stub_tts(), "/v1/audio/speech", "{").await;
10111        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
10112        let body = response_json(response).await;
10113        assert_eq!(body["error"]["type"], "invalid_request_error");
10114        assert_eq!(body["error"]["param"], Value::Null);
10115        assert!(body["error"]["message"]
10116            .as_str()
10117            .unwrap()
10118            .contains("invalid speech request"));
10119    }
10120
10121    #[tokio::test]
10122    async fn route_completions_generation_failure_maps_to_500() {
10123        let response = post_json(
10124            router_with_failing_llm(),
10125            "/v1/completions",
10126            json!({
10127                "model": "failing-model",
10128                "prompt": "complete me"
10129            }),
10130        )
10131        .await;
10132        assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
10133        let body = response_json(response).await;
10134        assert_eq!(body["error"]["type"], "internal_server_error");
10135        assert!(body["error"]["message"]
10136            .as_str()
10137            .unwrap()
10138            .contains("stub generation failed"));
10139    }
10140
10141    #[tokio::test]
10142    async fn route_completions_stream_generation_failure_emits_openai_error_event() {
10143        let response = post_json(
10144            router_with_failing_llm(),
10145            "/v1/completions",
10146            json!({
10147                "model": "failing-model",
10148                "prompt": "complete me",
10149                "stream": true
10150            }),
10151        )
10152        .await;
10153        assert_eq!(response.status(), AxumStatusCode::OK);
10154        let body = response_text(response).await;
10155        assert_openai_stream_error(&body, "stub stream failed");
10156    }
10157
10158    #[tokio::test]
10159    async fn route_completions_stream_chunk_failure_emits_openai_error_event() {
10160        let response = post_json(
10161            router_with_stream_chunk_failing_llm(),
10162            "/v1/completions",
10163            json!({
10164                "model": "failing-model",
10165                "prompt": "complete me",
10166                "stream": true
10167            }),
10168        )
10169        .await;
10170        assert_eq!(response.status(), AxumStatusCode::OK);
10171        let body = response_text(response).await;
10172        assert_openai_stream_error(&body, "stub stream chunk failed");
10173    }
10174
10175    #[tokio::test]
10176    async fn route_completions_contract_uses_stub_engine() {
10177        let response = post_json(
10178            router_with_stub("done"),
10179            "/v1/completions",
10180            json!({
10181                "model": "stub-model",
10182                "prompt": "complete me",
10183                "max_tokens": 8,
10184                "temperature": 0.0
10185            }),
10186        )
10187        .await;
10188        assert_eq!(response.status(), AxumStatusCode::OK);
10189        let body = response_json(response).await;
10190        assert_eq!(body["object"], "text_completion");
10191        assert_eq!(body["choices"][0]["text"], "done");
10192        assert_eq!(body["usage"]["prompt_tokens"], 7);
10193        assert_eq!(body["usage"]["completion_tokens"], 2);
10194    }
10195
10196    #[tokio::test]
10197    async fn route_completions_public_alias_maps_to_internal_model() {
10198        let engine = Arc::new(CapturingLlm::new());
10199        let registry = ServedModelRegistry::try_new(
10200            "qwen3",
10201            ServedModelKind::Llm,
10202            vec!["served-alias".to_string()],
10203            vec![],
10204        )
10205        .unwrap();
10206        let router = AxumServer::from_llm(engine.clone())
10207            .with_served_model_registry(registry)
10208            .build_router();
10209        let response = post_json(
10210            router,
10211            "/v1/completions",
10212            json!({"model": "served-alias", "prompt": "complete me"}),
10213        )
10214        .await;
10215
10216        assert_eq!(response.status(), AxumStatusCode::OK);
10217        assert_eq!(response_json(response).await["model"], "served-alias");
10218        assert_eq!(engine.last_request().model_id, ModelId::new("qwen3"));
10219    }
10220
10221    #[tokio::test]
10222    async fn route_completions_streaming_contract_uses_stub_engine() {
10223        let response = post_json(
10224            router_with_stub("done"),
10225            "/v1/completions",
10226            json!({
10227                "model": "stub-model",
10228                "prompt": "complete me",
10229                "max_tokens": 8,
10230                "temperature": 0.0,
10231                "stream": true
10232            }),
10233        )
10234        .await;
10235        assert_eq!(response.status(), AxumStatusCode::OK);
10236        let body = response_text(response).await;
10237        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
10238        assert!(
10239            body.contains("\"object\":\"text_completion\""),
10240            "missing completion chunk: {body}"
10241        );
10242        assert!(body.contains("\"text\":\"done\""), "missing text: {body}");
10243        assert!(
10244            body.contains("\"choices\":[],\"usage\""),
10245            "missing separate usage chunk: {body}"
10246        );
10247        assert!(
10248            body.contains("\"prompt_tokens\":5"),
10249            "stream usage should come from engine token usage: {body}"
10250        );
10251        assert!(
10252            body.contains("\"completion_tokens\":1"),
10253            "stream completion usage should come from engine token usage: {body}"
10254        );
10255    }
10256
10257    #[tokio::test]
10258    async fn route_completions_stream_waits_for_separate_final_usage_at_max_tokens() {
10259        let response = post_json(
10260            router_with_stub_separate_final_stream_chunk(&["do", "ne"]),
10261            "/v1/completions",
10262            json!({
10263                "model": "stub-model",
10264                "prompt": "complete me",
10265                "max_tokens": 2,
10266                "temperature": 0.0,
10267                "stream": true
10268            }),
10269        )
10270        .await;
10271        assert_eq!(response.status(), AxumStatusCode::OK);
10272        let body = response_text(response).await;
10273        assert_eq!(body.matches("data: [DONE]").count(), 1, "body: {body}");
10274        assert!(
10275            body.contains("\"text\":\"do\""),
10276            "missing first chunk: {body}"
10277        );
10278        assert!(
10279            body.contains("\"text\":\"ne\""),
10280            "missing second chunk: {body}"
10281        );
10282        assert!(
10283            body.contains("\"choices\":[],\"usage\""),
10284            "missing separate usage chunk from final engine chunk: {body}"
10285        );
10286        assert!(
10287            body.contains("\"prompt_tokens\":5"),
10288            "stream usage should come from engine final usage: {body}"
10289        );
10290    }
10291
10292    #[tokio::test]
10293    async fn route_completions_invalid_json_maps_to_openai_error() {
10294        let response = post_raw_json(router_with_stub("unused"), "/v1/completions", "{").await;
10295        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
10296        let body = response_json(response).await;
10297        assert_eq!(body["error"]["type"], "invalid_request_error");
10298        assert_eq!(body["error"]["param"], Value::Null);
10299        assert!(body["error"]["message"]
10300            .as_str()
10301            .unwrap()
10302            .contains("invalid completions request"));
10303    }
10304
10305    #[tokio::test]
10306    async fn route_completions_rejects_unsupported_fields_explicitly() {
10307        for (extra, param) in [
10308            (json!({"n": 2}), "n"),
10309            (json!({"logprobs": 3}), "logprobs"),
10310            (json!({"logit_bias": {"42": 1.0}}), "logit_bias"),
10311        ] {
10312            let mut body = json!({
10313                "model": "stub-model",
10314                "prompt": "complete me"
10315            });
10316            body.as_object_mut()
10317                .expect("object")
10318                .extend(extra.as_object().expect("extra object").clone());
10319            let response = post_json(router_with_stub("unused"), "/v1/completions", body).await;
10320            assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
10321            let body = response_json(response).await;
10322            assert_eq!(body["error"]["type"], "invalid_request_error");
10323            assert_eq!(body["error"]["param"], param);
10324        }
10325    }
10326
10327    #[tokio::test]
10328    async fn streaming_completions_do_not_synthesize_whitespace_usage() {
10329        let response = post_json(
10330            router_with_stub_without_stream_usage("done"),
10331            "/v1/completions",
10332            json!({
10333                "model": "stub-model",
10334                "prompt": "one two three four",
10335                "stream": true
10336            }),
10337        )
10338        .await;
10339        assert_eq!(response.status(), AxumStatusCode::OK);
10340        let body = response_text(response).await;
10341        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
10342        assert!(
10343            !body.contains("\"usage\":{\"prompt_tokens\""),
10344            "server must not synthesize whitespace-count completion usage: {body}"
10345        );
10346    }
10347
10348    #[tokio::test]
10349    async fn chat_rejects_n_not_one_with_openai_error_param() {
10350        let request = chat_request(json!({"n": 2}));
10351        let err = chat_completions_handler(
10352            State(state_with_stub("unused")),
10353            HeaderMap::new(),
10354            Ok(Json(request)),
10355        )
10356        .await
10357        .expect_err("n=2 should reject");
10358        let (status, body) = error_json(err).await;
10359        assert_eq!(status, AxumStatusCode::BAD_REQUEST);
10360        assert_eq!(body["error"]["type"], "invalid_request_error");
10361        assert_eq!(body["error"]["param"], "n");
10362    }
10363
10364    #[tokio::test]
10365    async fn chat_rejects_logit_bias_and_logprobs_explicitly() {
10366        for (extra, param) in [
10367            (json!({"logit_bias": {"1": 100.0}}), "logit_bias"),
10368            (json!({"logprobs": true}), "logprobs"),
10369            (json!({"top_logprobs": 2}), "top_logprobs"),
10370        ] {
10371            let request = chat_request(extra);
10372            let err = chat_completions_handler(
10373                State(state_with_stub("unused")),
10374                HeaderMap::new(),
10375                Ok(Json(request)),
10376            )
10377            .await
10378            .expect_err("unsupported field should reject");
10379            let (status, body) = error_json(err).await;
10380            assert_eq!(status, AxumStatusCode::BAD_REQUEST);
10381            assert_eq!(body["error"]["param"], param);
10382            assert_eq!(body["error"]["type"], "invalid_request_error");
10383        }
10384    }
10385
10386    #[tokio::test]
10387    async fn chat_stream_options_include_usage_controls_stream_usage() {
10388        let request = chat_request(json!({
10389            "stream": true,
10390            "stream_options": {"include_usage": true}
10391        }));
10392        let response = chat_completions_handler(
10393            State(state_with_stub("ok")),
10394            HeaderMap::new(),
10395            Ok(Json(request)),
10396        )
10397        .await
10398        .expect("stream response");
10399        assert_eq!(response.status(), AxumStatusCode::OK);
10400        let body = response_text(response).await;
10401        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
10402        assert!(
10403            body.contains("\"usage\"") && body.contains("\"completion_tokens\":1"),
10404            "include_usage=true should emit stream usage: {body}"
10405        );
10406        assert!(
10407            body.contains("\"choices\":[],\"usage\""),
10408            "include_usage=true should use a separate usage chunk: {body}"
10409        );
10410        assert!(
10411            body.contains("\"prompt_tokens\":5"),
10412            "stream usage should come from engine token usage: {body}"
10413        );
10414
10415        let request = chat_request(json!({"stream": true}));
10416        let response = chat_completions_handler(
10417            State(state_with_stub("ok")),
10418            HeaderMap::new(),
10419            Ok(Json(request)),
10420        )
10421        .await
10422        .expect("stream response");
10423        let body = response_text(response).await;
10424        assert!(
10425            !body.contains("\"usage\":{\"prompt_tokens\""),
10426            "stream usage should be omitted unless requested: {body}"
10427        );
10428    }
10429
10430    #[tokio::test]
10431    async fn streaming_chat_does_not_synthesize_whitespace_usage() {
10432        let response = post_json(
10433            router_with_stub_without_stream_usage("ok"),
10434            "/v1/chat/completions",
10435            json!({
10436                "model": "stub-model",
10437                "messages": [{"role": "user", "content": "one two three four"}],
10438                "stream": true,
10439                "stream_options": {"include_usage": true}
10440            }),
10441        )
10442        .await;
10443        assert_eq!(response.status(), AxumStatusCode::OK);
10444        let body = response_text(response).await;
10445        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
10446        assert!(
10447            !body.contains("\"usage\":{\"prompt_tokens\""),
10448            "server must not synthesize whitespace-count usage when engine stream omits usage: {body}"
10449        );
10450    }
10451
10452    #[test]
10453    fn tool_requests_and_tool_messages_parse_into_structured_api_request() {
10454        let request: ChatCompletionsRequest = serde_json::from_value(json!({
10455            "model": "qwen3",
10456            "messages": [
10457                {"role": "user", "content": "Use the weather tool."},
10458                {
10459                    "role": "assistant",
10460                    "content": null,
10461                    "tool_calls": [{
10462                        "id": "call_1",
10463                        "type": "function",
10464                        "function": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"}
10465                    }]
10466                },
10467                {"role": "tool", "tool_call_id": "call_1", "content": "sunny"}
10468            ],
10469            "tools": [{
10470                "type": "function",
10471                "function": {
10472                    "name": "weather",
10473                    "description": "Get weather",
10474                    "parameters": {
10475                        "type": "object",
10476                        "properties": {"city": {"type": "string"}},
10477                        "required": ["city"]
10478                    }
10479                }
10480            }],
10481            "tool_choice": "auto"
10482        }))
10483        .expect("tool request parses");
10484
10485        validate_chat_request(&request).expect("tool request validates");
10486        let internal = convert_chat_request(&request).expect("convert");
10487        assert!(internal.prompt.contains("\"tools\":[{"));
10488        assert!(internal.prompt.contains("\"type\":\"function\""));
10489        assert!(internal.prompt.contains("\"name\":\"weather\""));
10490        assert!(internal.prompt.contains("<|im_start|>assistant\n{"));
10491        assert!(internal.prompt.contains("\"tool_calls\":[{"));
10492        assert!(internal.prompt.contains("\"id\":\"call_1\""));
10493        assert!(internal
10494            .prompt
10495            .contains("<|im_start|>tool\nsunny<|im_end|>"));
10496        assert_eq!(
10497            internal.metadata["openai_tools"][0]["function"]["name"],
10498            "weather"
10499        );
10500        assert_eq!(internal.metadata["openai_tool_choice"], "auto");
10501        let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
10502            panic!("expected structured chat api_request");
10503        };
10504        assert_eq!(api.messages.len(), 3);
10505        assert_eq!(api.messages[2].role, ferrum_types::ApiMessageRole::Tool);
10506        assert_eq!(api.messages[2].content, "sunny");
10507        assert_eq!(api.messages[2].tool_call_id.as_deref(), Some("call_1"));
10508        assert_eq!(api.tools[0].function.name, "weather");
10509        assert_eq!(
10510            api.tool_choice,
10511            Some(ferrum_types::ApiToolChoice::Mode("auto".into()))
10512        );
10513        assert_eq!(
10514            api.messages[1].tool_calls[0].function.arguments,
10515            "{\"city\":\"Paris\"}"
10516        );
10517    }
10518
10519    #[test]
10520    fn omitted_tool_choice_defaults_to_auto_when_tools_are_present() {
10521        let request: ChatCompletionsRequest = serde_json::from_value(json!({
10522            "model": "served-alias",
10523            "messages": [{"role": "user", "content": "Use the weather tool."}],
10524            "tools": [{
10525                "type": "function",
10526                "function": {
10527                    "name": "weather",
10528                    "description": "Get weather",
10529                    "parameters": {
10530                        "type": "object",
10531                        "properties": {"city": {"type": "string"}},
10532                        "required": ["city"]
10533                    }
10534                }
10535            }]
10536        }))
10537        .expect("tool request parses");
10538
10539        validate_chat_request(&request).expect("tool request validates");
10540        let internal = convert_chat_request(&request).expect("convert");
10541        assert!(internal.prompt.contains("\"tools\":[{"));
10542        assert!(internal.prompt.contains("\"tool_choice\":\"auto\""));
10543        assert_eq!(internal.metadata["openai_tool_choice"], "auto");
10544        let initial_forbidden = internal.metadata[INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY]
10545            .as_array()
10546            .expect("initial forbidden token list");
10547        assert_eq!(initial_forbidden, &[serde_json::json!(THINK_END_TAG)]);
10548        assert_eq!(
10549            internal.sampling_params.response_format,
10550            ferrum_types::ResponseFormat::Text,
10551            "auto tool choice must preserve native model selection instead of forcing arguments JSON",
10552        );
10553        let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
10554            panic!("expected structured chat api_request");
10555        };
10556        assert_eq!(
10557            api.tool_choice,
10558            Some(ferrum_types::ApiToolChoice::Mode("auto".into()))
10559        );
10560    }
10561
10562    #[test]
10563    fn omitted_tool_choice_uses_native_template_protocol_without_hard_schema() {
10564        let request: ChatCompletionsRequest = serde_json::from_value(json!({
10565            "model": "served-alias",
10566            "messages": [{"role": "user", "content": "北京现在天气怎么样?用摄氏度。"}],
10567            "tools": [{
10568                "type": "function",
10569                "function": {
10570                    "name": "get_weather",
10571                    "description": "查询指定城市的当前天气",
10572                    "parameters": {
10573                        "type": "object",
10574                        "properties": {
10575                            "city": {"type": "string"},
10576                            "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
10577                        },
10578                        "required": ["city"]
10579                    }
10580                }
10581            }]
10582        }))
10583        .expect("tool request parses");
10584        let template = ModelChatTemplate::new(
10585            "{% if tools %}<tools>{{ tools | tojson }}</tools>Use <tool_call><function=name><parameter=key>value</parameter></function></tool_call>{% endif %}{% for message in messages %}{{ message.content }}{% endfor %}",
10586            "function-parameter-xml-template",
10587        );
10588
10589        validate_chat_request(&request).expect("tool request validates");
10590        let internal =
10591            convert_chat_request_with_template_model(&request, "served-alias", Some(&template))
10592                .expect("convert");
10593        assert_eq!(internal.metadata["openai_tool_choice"], "auto");
10594        assert_eq!(
10595            internal.sampling_params.response_format,
10596            ferrum_types::ResponseFormat::Text,
10597        );
10598        let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request else {
10599            panic!("expected chat API request");
10600        };
10601        assert_eq!(
10602            api.tool_call_protocol,
10603            ferrum_types::ApiToolCallProtocol::FunctionParameterXml,
10604        );
10605    }
10606
10607    #[test]
10608    fn tool_schema_response_format_bounds_unconstrained_strings() {
10609        let request: ChatCompletionsRequest = serde_json::from_value(json!({
10610            "model": "served-alias",
10611            "messages": [{"role": "user", "content": "Use the selected tool."}],
10612            "tools": [{
10613                "type": "function",
10614                "function": {
10615                    "name": "get_weather",
10616                    "parameters": {
10617                        "type": "object",
10618                        "properties": {
10619                            "city": {"type": "string"},
10620                            "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
10621                        },
10622                        "required": ["city"]
10623                    }
10624                }
10625            }],
10626            "tool_choice": {
10627                "type": "function",
10628                "function": {"name": "get_weather"}
10629            }
10630        }))
10631        .expect("tool request parses");
10632
10633        validate_chat_request(&request).expect("tool request validates");
10634        let internal = convert_chat_request(&request).expect("convert");
10635        match internal.sampling_params.response_format {
10636            ferrum_types::ResponseFormat::JsonSchema(ref schema) => {
10637                let value: serde_json::Value =
10638                    serde_json::from_str(schema).expect("schema should be JSON");
10639                assert_eq!(
10640                    value["properties"]["city"]["maxLength"],
10641                    DEFAULT_GUIDED_TOOL_ARGUMENT_STRING_MAX_LENGTH
10642                );
10643                assert_eq!(
10644                    value["properties"]["unit"]["enum"],
10645                    json!(["celsius", "fahrenheit"])
10646                );
10647                assert!(
10648                    value["properties"]["unit"]["maxLength"].is_null(),
10649                    "enum string should remain finite via enum instead of maxLength: {value}"
10650                );
10651            }
10652            ref other => panic!("expected forced tool json schema, got {other:?}"),
10653        }
10654    }
10655
10656    #[test]
10657    fn required_tool_choice_uses_tool_schema_response_format_without_extra_prompt_instruction() {
10658        let request: ChatCompletionsRequest = serde_json::from_value(json!({
10659            "model": "served-alias",
10660            "messages": [{"role": "user", "content": "Call capture_quality_marker."}],
10661            "tools": [{
10662                "type": "function",
10663                "function": {
10664                    "name": "capture_quality_marker",
10665                    "description": "Record one marker.",
10666                    "parameters": {
10667                        "type": "object",
10668                        "properties": {
10669                            "marker": {"type": "string", "enum": ["ferrum0401"]},
10670                            "checksum": {"type": "string", "enum": ["S0004"]}
10671                        },
10672                        "required": ["marker", "checksum"]
10673                    }
10674                }
10675            }],
10676            "tool_choice": "required"
10677        }))
10678        .expect("tool request parses");
10679
10680        validate_chat_request(&request).expect("tool request validates");
10681        let internal = convert_chat_request(&request).expect("convert");
10682
10683        assert!(
10684            !internal.prompt.contains(
10685                "Output only a single JSON object containing the selected function arguments"
10686            ),
10687            "{}",
10688            internal.prompt
10689        );
10690        assert!(
10691            internal.prompt.contains("\"tool_choice\":\"required\""),
10692            "{}",
10693            internal.prompt
10694        );
10695        assert_eq!(internal.metadata["openai_tool_choice"], "required");
10696        match internal.sampling_params.response_format {
10697            ferrum_types::ResponseFormat::JsonSchema(ref schema) => {
10698                assert!(schema.contains(r#""enum":["ferrum0401"]"#), "{schema}");
10699                assert!(schema.contains(r#""enum":["S0004"]"#), "{schema}");
10700            }
10701            ref other => panic!("expected forced tool json schema, got {other:?}"),
10702        }
10703    }
10704
10705    #[test]
10706    fn required_tool_choice_suppresses_conflicting_response_format_instruction() {
10707        let request: ChatCompletionsRequest =
10708            serde_json::from_value(required_tool_with_strict_response_format_request(false))
10709                .expect("request parses");
10710
10711        validate_chat_request(&request).expect("request validates");
10712        let internal = convert_chat_request(&request).expect("convert");
10713
10714        assert!(
10715            !internal.prompt.contains("response_format requires"),
10716            "required tool output must not receive a conflicting content-schema instruction: {}",
10717            internal.prompt
10718        );
10719        let ferrum_types::ResponseFormat::JsonSchema(schema) =
10720            internal.sampling_params.response_format
10721        else {
10722            panic!("single required tool must use its argument schema");
10723        };
10724        let schema: Value = serde_json::from_str(&schema).expect("tool schema JSON");
10725        assert!(schema["properties"].get("city").is_some(), "{schema}");
10726        assert!(schema["properties"].get("answer").is_none(), "{schema}");
10727    }
10728
10729    #[test]
10730    fn required_multiple_tools_do_not_force_the_first_tool_schema() {
10731        let request: ChatCompletionsRequest = serde_json::from_value(json!({
10732            "model": "stub-model",
10733            "messages": [{"role": "user", "content": "Use the appropriate tool."}],
10734            "tools": [
10735                {
10736                    "type": "function",
10737                    "function": {
10738                        "name": "weather",
10739                        "parameters": {
10740                            "type": "object",
10741                            "properties": {"city": {"type": "string"}},
10742                            "required": ["city"]
10743                        }
10744                    }
10745                },
10746                {
10747                    "type": "function",
10748                    "function": {
10749                        "name": "calendar",
10750                        "parameters": {
10751                            "type": "object",
10752                            "properties": {"date": {"type": "string"}},
10753                            "required": ["date"]
10754                        }
10755                    }
10756                }
10757            ],
10758            "tool_choice": "required"
10759        }))
10760        .expect("request parses");
10761
10762        validate_chat_request(&request).expect("request validates");
10763        let internal = convert_chat_request(&request).expect("convert");
10764        assert_eq!(
10765            internal.sampling_params.response_format,
10766            ferrum_types::ResponseFormat::Text,
10767            "required permits either declared tool, so guided decoding cannot bind the first tool's arguments"
10768        );
10769    }
10770
10771    #[test]
10772    fn omitted_single_unrelated_tool_keeps_text_response_format() {
10773        let request: ChatCompletionsRequest = serde_json::from_value(json!({
10774            "model": "served-alias",
10775            "messages": [{"role": "user", "content": "讲一个短笑话。"}],
10776            "tools": [{
10777                "type": "function",
10778                "function": {
10779                    "name": "get_weather",
10780                    "description": "查询指定城市的当前天气",
10781                    "parameters": {
10782                        "type": "object",
10783                        "properties": {"city": {"type": "string"}},
10784                        "required": ["city"]
10785                    }
10786                }
10787            }]
10788        }))
10789        .expect("tool request parses");
10790
10791        validate_chat_request(&request).expect("tool request validates");
10792        let internal = convert_chat_request(&request).expect("convert");
10793        assert_eq!(
10794            internal.sampling_params.response_format,
10795            ferrum_types::ResponseFormat::Text
10796        );
10797    }
10798
10799    #[test]
10800    fn tool_choice_none_omits_tools_from_model_template_prompt() {
10801        let request: ChatCompletionsRequest = serde_json::from_value(json!({
10802            "model": "served-alias",
10803            "messages": [
10804                {"role": "user", "content": "Use the weather tool if needed."},
10805                {
10806                    "role": "assistant",
10807                    "content": null,
10808                    "tool_calls": [{
10809                        "id": "call_1",
10810                        "type": "function",
10811                        "function": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"}
10812                    }]
10813                },
10814                {"role": "tool", "tool_call_id": "call_1", "content": "{\"temp\":22}"}
10815            ],
10816            "tools": [{
10817                "type": "function",
10818                "function": {"name": "weather", "parameters": {"type": "object"}}
10819            }],
10820            "tool_choice": "none"
10821        }))
10822        .expect("tool_choice none request parses");
10823        let template = ModelChatTemplate::new(
10824            "{% 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 %}",
10825            "tool-choice-none-template",
10826        );
10827
10828        validate_chat_request(&request).expect("tool_choice none request validates");
10829        let internal = convert_chat_request_with_template_model(
10830            &request,
10831            "served-template-model",
10832            Some(&template),
10833        )
10834        .expect("convert");
10835        assert!(
10836            !internal.prompt.contains("<tools>"),
10837            "tool_choice none must not expose tools to the model template: {}",
10838            internal.prompt
10839        );
10840        assert!(internal.prompt.contains("[tool]"), "{}", internal.prompt);
10841        assert_eq!(
10842            internal.metadata["openai_tools"][0]["function"]["name"],
10843            "weather"
10844        );
10845        assert_eq!(internal.metadata["openai_tool_choice"], "none");
10846        let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
10847            panic!("expected structured chat api_request");
10848        };
10849        assert_eq!(api.tools[0].function.name, "weather");
10850        assert_eq!(
10851            api.tool_choice,
10852            Some(ferrum_types::ApiToolChoice::Mode("none".into()))
10853        );
10854    }
10855
10856    #[test]
10857    fn specific_tool_choice_parses_into_structured_api_request() {
10858        let request: ChatCompletionsRequest = serde_json::from_value(json!({
10859            "model": "qwen3",
10860            "messages": [{"role": "user", "content": "Use the selected tool."}],
10861            "tools": [
10862                {
10863                    "type": "function",
10864                    "function": {"name": "weather", "parameters": {"type": "object"}}
10865                },
10866                {
10867                    "type": "function",
10868                    "function": {"name": "calendar", "parameters": {"type": "object"}}
10869                }
10870            ],
10871            "tool_choice": {
10872                "type": "function",
10873                "function": {"name": "weather"}
10874            }
10875        }))
10876        .expect("specific tool_choice request parses");
10877
10878        validate_chat_request(&request).expect("specific tool_choice validates");
10879        let internal = convert_chat_request(&request).expect("convert");
10880        assert!(internal.prompt.contains("\"tool_choice\":{"));
10881        assert!(internal.prompt.contains("\"name\":\"weather\""));
10882        let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
10883            panic!("expected structured chat api_request");
10884        };
10885        assert_eq!(
10886            api.tool_choice,
10887            Some(ferrum_types::ApiToolChoice::Function {
10888                tool_type: "function".to_string(),
10889                function: ferrum_types::ApiToolChoiceFunction {
10890                    name: "weather".to_string()
10891                },
10892            })
10893        );
10894
10895        let invalid: ChatCompletionsRequest = serde_json::from_value(json!({
10896            "model": "qwen3",
10897            "messages": [{"role": "user", "content": "Use the selected tool."}],
10898            "tools": [{
10899                "type": "function",
10900                "function": {"name": "weather", "parameters": {"type": "object"}}
10901            }],
10902            "tool_choice": {
10903                "type": "function",
10904                "function": {"name": "calendar"}
10905            }
10906        }))
10907        .expect("invalid specific tool_choice request parses");
10908        let err = validate_chat_request(&invalid).expect_err("undeclared tool should reject");
10909        match err {
10910            ServerError::InvalidRequest { param, .. } => {
10911                assert_eq!(param.as_deref(), Some("tool_choice"));
10912            }
10913            other => panic!("expected invalid_request_error for tool_choice, got {other:?}"),
10914        }
10915    }
10916
10917    #[test]
10918    fn legacy_function_role_messages_parse_into_structured_api_request() {
10919        let request: ChatCompletionsRequest = serde_json::from_value(json!({
10920            "model": "mystery-model",
10921            "messages": [
10922                {"role": "user", "content": "Call weather."},
10923                {
10924                    "role": "assistant",
10925                    "content": null,
10926                    "function_call": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"}
10927                },
10928                {"role": "function", "name": "weather", "content": "{\"forecast\":\"sunny\"}"}
10929            ],
10930            "functions": [{
10931                "name": "weather",
10932                "parameters": {
10933                    "type": "object",
10934                    "properties": {"city": {"type": "string"}},
10935                    "required": ["city"]
10936                }
10937            }],
10938            "function_call": "auto"
10939        }))
10940        .expect("legacy function request parses");
10941
10942        validate_chat_request(&request).expect("legacy function request validates");
10943        let internal = convert_chat_request(&request).expect("convert");
10944        assert!(
10945            internal
10946                .prompt
10947                .contains("<|function|>\n{\"forecast\":\"sunny\"}</s>"),
10948            "legacy function role should be preserved in fallback template: {}",
10949            internal.prompt
10950        );
10951        assert_eq!(
10952            internal.metadata["openai_legacy_functions"][0]["name"],
10953            "weather"
10954        );
10955        assert_eq!(internal.metadata["openai_legacy_function_call"], "auto");
10956        let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
10957            panic!("expected structured chat api_request");
10958        };
10959        assert_eq!(api.messages.len(), 3);
10960        assert_eq!(api.messages[2].role, ferrum_types::ApiMessageRole::Function);
10961        assert_eq!(api.messages[2].name.as_deref(), Some("weather"));
10962        assert_eq!(
10963            api.messages[1]
10964                .function_call
10965                .as_ref()
10966                .map(|call| call.name.as_str()),
10967            Some("weather")
10968        );
10969        assert_eq!(api.legacy_functions[0].name, "weather");
10970        assert_eq!(
10971            api.legacy_function_call,
10972            Some(ferrum_types::ApiFunctionCallChoice::Mode("auto".into()))
10973        );
10974    }
10975
10976    #[test]
10977    fn specific_legacy_function_call_parses_into_structured_api_request() {
10978        let request: ChatCompletionsRequest = serde_json::from_value(json!({
10979            "model": "mystery-model",
10980            "messages": [{"role": "user", "content": "Use the selected function."}],
10981            "functions": [
10982                {"name": "weather", "parameters": {"type": "object"}},
10983                {"name": "calendar", "parameters": {"type": "object"}}
10984            ],
10985            "function_call": {"name": "weather"}
10986        }))
10987        .expect("specific function_call request parses");
10988
10989        validate_chat_request(&request).expect("specific function_call validates");
10990        let internal = convert_chat_request(&request).expect("convert");
10991        assert!(internal.prompt.contains("\"function_call\":{"));
10992        assert!(internal.prompt.contains("\"name\":\"weather\""));
10993        let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
10994            panic!("expected structured chat api_request");
10995        };
10996        assert_eq!(
10997            api.legacy_function_call,
10998            Some(ferrum_types::ApiFunctionCallChoice::Function {
10999                name: "weather".to_string(),
11000            })
11001        );
11002
11003        let invalid: ChatCompletionsRequest = serde_json::from_value(json!({
11004            "model": "mystery-model",
11005            "messages": [{"role": "user", "content": "Use the selected function."}],
11006            "functions": [{"name": "weather", "parameters": {"type": "object"}}],
11007            "function_call": {"name": "calendar"}
11008        }))
11009        .expect("invalid specific function_call request parses");
11010        let err = validate_chat_request(&invalid).expect_err("undeclared function should reject");
11011        match err {
11012            ServerError::InvalidRequest { param, .. } => {
11013                assert_eq!(param.as_deref(), Some("function_call"));
11014            }
11015            other => panic!("expected invalid_request_error for function_call, got {other:?}"),
11016        }
11017    }
11018
11019    #[test]
11020    fn stream_text_delta_handles_unicode_boundaries() {
11021        let mut sent_len = 0usize;
11022        assert_eq!(stream_text_delta("你好", &mut sent_len), "你好");
11023        assert_eq!(sent_len, "你好".len());
11024        assert_eq!(stream_text_delta("你好世界", &mut sent_len), "世界");
11025        assert_eq!(sent_len, "你好世界".len());
11026    }
11027
11028    #[test]
11029    fn stream_text_delta_recovers_from_non_boundary_offset() {
11030        let mut sent_len = 1usize;
11031        assert_eq!(stream_text_delta("你好", &mut sent_len), "");
11032        assert_eq!(sent_len, "你好".len());
11033    }
11034
11035    #[test]
11036    fn assistant_tool_call_serializes_openai_shape() {
11037        let message = ChatMessage {
11038            role: MessageRole::Assistant,
11039            content: String::new(),
11040            reasoning: None,
11041            name: None,
11042            tool_calls: Some(vec![ChatToolCall {
11043                index: None,
11044                id: "call_1".to_string(),
11045                tool_type: "function".to_string(),
11046                function: ChatFunctionCall {
11047                    name: "weather".to_string(),
11048                    arguments: "{\"city\":\"Paris\"}".to_string(),
11049                },
11050            }]),
11051            tool_call_id: None,
11052            function_call: None,
11053        };
11054        let value = serde_json::to_value(message).expect("serialize");
11055        assert_eq!(value["role"], "assistant");
11056        assert_eq!(value["tool_calls"][0]["type"], "function");
11057        assert_eq!(value["tool_calls"][0]["function"]["name"], "weather");
11058    }
11059
11060    #[test]
11061    fn unsupported_multimodal_content_is_not_silently_dropped() {
11062        let err = serde_json::from_value::<ChatCompletionsRequest>(json!({
11063            "model": "stub-model",
11064            "messages": [{
11065                "role": "user",
11066                "content": [
11067                    {"type": "text", "text": "describe this"},
11068                    {"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}}
11069                ]
11070            }]
11071        }))
11072        .expect_err("unsupported content part should fail parsing");
11073        assert!(
11074            err.to_string()
11075                .contains("unsupported message content part type"),
11076            "unexpected error: {err}"
11077        );
11078    }
11079
11080    #[tokio::test]
11081    async fn completions_endpoint_uses_stub_engine() {
11082        let request = CompletionsRequest {
11083            model: "stub-model".to_string(),
11084            prompt: CompletionPrompt::Text("complete me".to_string()),
11085            max_tokens: Some(8),
11086            temperature: Some(0.0),
11087            top_p: None,
11088            n: None,
11089            stream: None,
11090            stop: None,
11091            logprobs: None,
11092            logit_bias: None,
11093        };
11094        let response = completions_handler(State(state_with_stub("done")), Ok(Json(request)))
11095            .await
11096            .expect("completion response");
11097        assert_eq!(response.status(), AxumStatusCode::OK);
11098        let body = response_json(response).await;
11099        assert_eq!(body["object"], "text_completion");
11100        assert_eq!(body["choices"][0]["text"], "done");
11101        assert_eq!(body["usage"]["prompt_tokens"], 7);
11102        assert_eq!(body["usage"]["completion_tokens"], 2);
11103    }
11104
11105    #[tokio::test]
11106    async fn route_completions_rejects_non_string_prompt_with_field_param() {
11107        for prompt in [
11108            json!(["a", "b"]),
11109            json!({"text": "complete me"}),
11110            Value::Null,
11111        ] {
11112            let response = post_json(
11113                router_with_stub("unused"),
11114                "/v1/completions",
11115                json!({
11116                    "model": "stub-model",
11117                    "prompt": prompt
11118                }),
11119            )
11120            .await;
11121            assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
11122            let body = response_json(response).await;
11123            assert_eq!(body["error"]["type"], "invalid_request_error");
11124            assert_eq!(body["error"]["param"], "prompt");
11125        }
11126
11127        let response = post_json(
11128            router_with_stub("unused"),
11129            "/v1/completions",
11130            json!({"model": "stub-model"}),
11131        )
11132        .await;
11133        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
11134        let body = response_json(response).await;
11135        assert_eq!(body["error"]["type"], "invalid_request_error");
11136        assert_eq!(body["error"]["param"], "prompt");
11137    }
11138
11139    #[tokio::test]
11140    async fn stream_options_without_stream_is_invalid() {
11141        let request = chat_request(json!({"stream_options": {"include_usage": true}}));
11142        let err = chat_completions_handler(
11143            State(state_with_stub("unused")),
11144            HeaderMap::new(),
11145            Ok(Json(request)),
11146        )
11147        .await
11148        .expect_err("stream_options without stream should reject");
11149        let (status, body) = error_json(err).await;
11150        assert_eq!(status, AxumStatusCode::BAD_REQUEST);
11151        assert_eq!(body["error"]["param"], "stream_options");
11152        assert_eq!(body["error"]["type"], "invalid_request_error");
11153    }
11154
11155    #[tokio::test]
11156    async fn unknown_stream_option_is_rejected_instead_of_ignored() {
11157        let response = post_json(
11158            router_with_stub("unused"),
11159            "/v1/chat/completions",
11160            json!({
11161                "model": "stub-model",
11162                "messages": [{"role": "user", "content": "hello"}],
11163                "stream": true,
11164                "stream_options": {"continuous_usage_stats": true}
11165            }),
11166        )
11167        .await;
11168
11169        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
11170        let body = response_json(response).await;
11171        assert_eq!(body["error"]["type"], "invalid_request_error");
11172        assert!(
11173            body["error"]["message"]
11174                .as_str()
11175                .unwrap_or_default()
11176                .contains("invalid chat completions request"),
11177            "body: {body}"
11178        );
11179    }
11180
11181    #[tokio::test]
11182    async fn json_object_rejects_markdown_fence_instead_of_repairing() {
11183        let request = chat_request(json!({
11184            "response_format": {"type": "json_object"}
11185        }));
11186        let err = chat_completions_handler(
11187            State(state_with_stub("```json\n{\"answer\":\"yes\"}\n```")),
11188            HeaderMap::new(),
11189            Ok(Json(request)),
11190        )
11191        .await
11192        .expect_err("fenced json_object must fail");
11193        let (status, body) = error_json(err).await;
11194        assert_eq!(status, AxumStatusCode::INTERNAL_SERVER_ERROR);
11195        assert_eq!(body["error"]["type"], "internal_server_error");
11196        assert!(body["error"]["message"]
11197            .as_str()
11198            .unwrap_or_default()
11199            .contains("response_format.json_object: invalid JSON"));
11200    }
11201
11202    #[tokio::test]
11203    async fn streaming_json_object_buffers_thinking_and_emits_clean_json_content() {
11204        let response = post_json(
11205            router_with_stub_stream_chunks(&[
11206                "<think>\n好的,我需要输出 JSON。",
11207                "\n</think>\n\n",
11208                "{\"name\":\"李四\",\"age\":30}",
11209            ]),
11210            "/v1/chat/completions",
11211            json!({
11212                "model": "stub-model",
11213                "messages": [{"role": "user", "content": "输出JSON(name,age):李四,30岁"}],
11214                "stream": true,
11215                "response_format": {"type": "json_object"}
11216            }),
11217        )
11218        .await;
11219        assert_eq!(response.status(), AxumStatusCode::OK);
11220        let body = response_text(response).await;
11221        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
11222        assert!(
11223            body.contains(r#""content":"{\"name\":\"李四\",\"age\":30}""#),
11224            "stream should emit clean JSON content: {body}"
11225        );
11226        assert!(
11227            body.contains(r#""reasoning":"\n好的,我需要输出 JSON。\n""#),
11228            "stream should keep thinking in reasoning field: {body}"
11229        );
11230        assert!(
11231            !body.contains(r#""content":"<think"#)
11232                && !body.contains(r#""content":"好的"#)
11233                && !body.contains(r#""content":"我需要"#),
11234            "thinking text must not leak as streamed content: {body}"
11235        );
11236    }
11237
11238    #[tokio::test]
11239    async fn json_object_rejects_non_json_model_output() {
11240        let request = chat_request(json!({
11241            "response_format": {"type": "json_object"}
11242        }));
11243        let err = chat_completions_handler(
11244            State(state_with_stub("not json")),
11245            HeaderMap::new(),
11246            Ok(Json(request)),
11247        )
11248        .await
11249        .expect_err("invalid json_object must fail");
11250        let (status, body) = error_json(err).await;
11251        assert_eq!(status, AxumStatusCode::INTERNAL_SERVER_ERROR);
11252        assert_eq!(body["error"]["type"], "internal_server_error");
11253        assert!(body["error"]["message"]
11254            .as_str()
11255            .unwrap_or_default()
11256            .contains("response_format.json_object"));
11257    }
11258
11259    #[test]
11260    fn one_of_strict_json_schema_reaches_hard_decoder() {
11261        let request = chat_request(json!({
11262            "response_format": {
11263                "type": "json_schema",
11264                "json_schema": {
11265                    "name": "unsupported",
11266                    "strict": true,
11267                    "schema": {"oneOf": [{"type": "string"}, {"type": "integer"}]}
11268                }
11269            }
11270        }));
11271        validate_chat_request(&request).expect("oneOf strict schema should validate");
11272        let internal = convert_chat_request(&request).expect("convert oneOf strict schema");
11273        let ferrum_types::ResponseFormat::JsonSchema(schema) =
11274            internal.sampling_params.response_format
11275        else {
11276            panic!("strict schema did not reach hard decoder");
11277        };
11278        assert_eq!(
11279            serde_json::from_str::<serde_json::Value>(&schema).unwrap()["oneOf"],
11280            json!([{"type": "string"}, {"type": "integer"}])
11281        );
11282        let schema = serde_json::from_str::<serde_json::Value>(&schema).unwrap();
11283        validate_json_text_against_schema(&schema, r#""answer""#)
11284            .expect("oneOf string branch should pass final validation");
11285        validate_json_text_against_schema(&schema, "7")
11286            .expect("oneOf integer branch should pass final validation");
11287        assert!(validate_json_text_against_schema(&schema, "true").is_err());
11288    }
11289
11290    #[tokio::test]
11291    async fn missing_json_schema_schema_rejects_with_field_param() {
11292        let request = chat_request(json!({
11293            "response_format": {
11294                "type": "json_schema",
11295                "json_schema": {
11296                    "name": "missing_schema",
11297                    "strict": true
11298                }
11299            }
11300        }));
11301        let err = chat_completions_handler(
11302            State(state_with_stub("unused")),
11303            HeaderMap::new(),
11304            Ok(Json(request)),
11305        )
11306        .await
11307        .expect_err("missing strict schema should reject");
11308        let (status, body) = error_json(err).await;
11309        assert_eq!(status, AxumStatusCode::BAD_REQUEST);
11310        assert_eq!(body["error"]["param"], "response_format.json_schema");
11311        assert_eq!(body["error"]["type"], "invalid_request_error");
11312        assert!(body["error"]["message"]
11313            .as_str()
11314            .unwrap()
11315            .contains("schema is required"));
11316    }
11317
11318    #[test]
11319    fn non_strict_json_schema_is_preserved_but_not_hard_masked() {
11320        let request = chat_request(json!({
11321            "response_format": {
11322                "type": "json_schema",
11323                "json_schema": {
11324                    "name": "best_effort",
11325                    "strict": false,
11326                    "schema": {"oneOf": [{"type": "string"}, {"type": "integer"}]}
11327                }
11328            }
11329        }));
11330
11331        validate_chat_request(&request).expect("non-strict schema should not boundary reject");
11332        let internal = convert_chat_request(&request).expect("convert non-strict schema");
11333        assert!(
11334            internal
11335                .prompt
11336                .contains("response_format requires a single valid JSON value"),
11337            "response_format instruction should reach the model prompt: {}",
11338            internal.prompt
11339        );
11340        assert!(
11341            internal.prompt.contains("\"oneOf\""),
11342            "schema should reach the model prompt: {}",
11343            internal.prompt
11344        );
11345        assert_eq!(
11346            internal.sampling_params.response_format,
11347            ferrum_types::ResponseFormat::Text,
11348            "non-strict json_schema must stay best-effort instead of enabling hard guided decode"
11349        );
11350        let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
11351            panic!("expected structured chat api_request");
11352        };
11353        assert_eq!(
11354            api.response_format
11355                .as_ref()
11356                .and_then(|format| format.json_schema.as_ref())
11357                .and_then(|schema| schema.strict),
11358            Some(false)
11359        );
11360    }
11361
11362    #[test]
11363    fn json_object_response_format_instruction_reaches_model_prompt() {
11364        let request = chat_request(json!({
11365            "response_format": {"type": "json_object"}
11366        }));
11367
11368        let internal = convert_chat_request(&request).expect("convert json_object");
11369        assert!(
11370            internal
11371                .prompt
11372                .contains("response_format requires a single valid JSON object"),
11373            "response_format instruction should reach the model prompt: {}",
11374            internal.prompt
11375        );
11376        assert!(
11377            internal.prompt.contains("Output only JSON"),
11378            "JSON-only instruction should reach the model prompt: {}",
11379            internal.prompt
11380        );
11381        assert_eq!(
11382            internal.sampling_params.response_format,
11383            ferrum_types::ResponseFormat::JsonObject,
11384            "json_object must reach the tokenizer-aware hard decoder"
11385        );
11386        assert_eq!(
11387            internal.sampling_params.structured_output_start,
11388            StructuredOutputStart::Immediate
11389        );
11390    }
11391
11392    #[test]
11393    fn json_object_thinking_template_activates_after_typed_end_delimiter() {
11394        let request = chat_request(json!({
11395            "response_format": {"type": "json_object"}
11396        }));
11397        let template = ModelChatTemplate::new(
11398            "{% for message in messages %}[{{ message.role }}]{{ message.content }}{% endfor %}{% if add_generation_prompt %}<assistant><think>\n{% endif %}",
11399            "thinking-test-template",
11400        );
11401
11402        assert_eq!(
11403            template.reasoning_protocol,
11404            ModelReasoningProtocol::PromptOpened
11405        );
11406        let internal =
11407            convert_chat_request_with_template_model(&request, "stub-model", Some(&template))
11408                .expect("convert thinking json_object");
11409
11410        assert!(internal.prompt.ends_with("<assistant><think>\n"));
11411        assert!(internal
11412            .prompt
11413            .contains("Output only JSON, with no markdown fences, no explanation, no chain-of-thought, and no extra text"));
11414        assert!(
11415            !internal.prompt.contains(THINK_END_TAG),
11416            "the instruction must not echo the typed end delimiter: {}",
11417            internal.prompt
11418        );
11419        assert_eq!(
11420            internal.sampling_params.structured_output_start,
11421            StructuredOutputStart::AfterDelimiter(THINK_END_TAG.to_string())
11422        );
11423        assert_eq!(
11424            internal.sampling_params.response_completion_boundary,
11425            ResponseCompletionBoundary::AfterDelimiterAndPayload {
11426                delimiter: THINK_END_TAG.to_string(),
11427                alternate_envelope: None,
11428            }
11429        );
11430    }
11431
11432    #[test]
11433    fn json_object_model_generated_thinking_activates_after_typed_end_delimiter() {
11434        let request = chat_request(json!({
11435            "response_format": {"type": "json_object"},
11436            "chat_template_kwargs": {"enable_thinking": true}
11437        }));
11438        let template = ModelChatTemplate::new(
11439            "{% for message in messages %}[{{ message.role }}]{{ message.content }}{% endfor %}{% if add_generation_prompt %}<assistant>{% if enable_thinking is defined and enable_thinking is false %}<think>\n\n</think>\n\n{% endif %}{% endif %}",
11440            "qwen3-model-generated-thinking-template",
11441        );
11442
11443        let internal =
11444            convert_chat_request_with_template_model(&request, "stub-model", Some(&template))
11445                .expect("convert model-generated thinking json_object");
11446
11447        assert!(!has_unclosed_thinking_block(&internal.prompt));
11448        assert!(internal.prompt.ends_with("<assistant>"));
11449        assert!(internal
11450            .prompt
11451            .contains("Output only JSON, with no markdown fences, no explanation, no chain-of-thought, and no extra text"));
11452        assert!(
11453            !internal.prompt.contains(THINK_START_TAG) && !internal.prompt.contains(THINK_END_TAG),
11454            "the instruction must not teach the model the typed reasoning delimiter: {}",
11455            internal.prompt
11456        );
11457        assert_eq!(
11458            internal.sampling_params.structured_output_start,
11459            StructuredOutputStart::AfterDelimiter(THINK_END_TAG.to_string())
11460        );
11461        assert_eq!(
11462            internal.sampling_params.response_completion_boundary,
11463            ResponseCompletionBoundary::AfterDelimiterAndPayload {
11464                delimiter: THINK_END_TAG.to_string(),
11465                alternate_envelope: None,
11466            }
11467        );
11468    }
11469
11470    #[test]
11471    fn strict_schema_model_generated_thinking_does_not_echo_typed_delimiter() {
11472        let request = chat_request(json!({
11473            "response_format": {
11474                "type": "json_schema",
11475                "json_schema": {
11476                    "name": "reasoning_result",
11477                    "strict": true,
11478                    "schema": {
11479                        "type": "object",
11480                        "properties": {
11481                            "result": {"type": "string", "const": "G00-c21-schema-OK"}
11482                        },
11483                        "required": ["result"],
11484                        "additionalProperties": false
11485                    }
11486                }
11487            },
11488            "chat_template_kwargs": {"enable_thinking": true}
11489        }));
11490        let template = ModelChatTemplate::new(
11491            "{% for message in messages %}[{{ message.role }}]{{ message.content }}{% endfor %}{% if add_generation_prompt %}<assistant>{% if enable_thinking is defined and enable_thinking is false %}<think>\n\n</think>\n\n{% endif %}{% endif %}",
11492            "qwen3-model-generated-thinking-template",
11493        );
11494
11495        let internal =
11496            convert_chat_request_with_template_model(&request, "stub-model", Some(&template))
11497                .expect("convert model-generated thinking strict schema");
11498
11499        assert!(!has_unclosed_thinking_block(&internal.prompt));
11500        assert!(internal.prompt.ends_with("<assistant>"));
11501        assert!(internal.prompt.contains("G00-c21-schema-OK"));
11502        assert!(
11503            !internal.prompt.contains(THINK_START_TAG) && !internal.prompt.contains(THINK_END_TAG),
11504            "the instruction must not teach the model the typed reasoning delimiter: {}",
11505            internal.prompt
11506        );
11507        assert_eq!(
11508            internal.sampling_params.structured_output_start,
11509            StructuredOutputStart::AfterDelimiter(THINK_END_TAG.to_string())
11510        );
11511        assert_eq!(
11512            internal.sampling_params.response_completion_boundary,
11513            ResponseCompletionBoundary::AfterDelimiterAndPayload {
11514                delimiter: THINK_END_TAG.to_string(),
11515                alternate_envelope: None,
11516            }
11517        );
11518    }
11519
11520    #[test]
11521    fn json_object_model_generated_thinking_hard_off_starts_immediately() {
11522        let request = chat_request(json!({
11523            "response_format": {"type": "json_object"},
11524            "chat_template_kwargs": {"enable_thinking": false}
11525        }));
11526        let template = ModelChatTemplate::new(
11527            "{% for message in messages %}[{{ message.role }}]{{ message.content }}{% endfor %}{% if add_generation_prompt %}<assistant>{% if enable_thinking is defined and enable_thinking is false %}<think>\n\n</think>\n\n{% endif %}{% endif %}",
11528            "qwen3-model-generated-thinking-template",
11529        );
11530
11531        let internal =
11532            convert_chat_request_with_template_model(&request, "stub-model", Some(&template))
11533                .expect("convert disabled model-generated thinking json_object");
11534
11535        assert_eq!(
11536            internal.sampling_params.structured_output_start,
11537            StructuredOutputStart::Immediate
11538        );
11539        assert_eq!(
11540            internal.sampling_params.response_completion_boundary,
11541            ResponseCompletionBoundary::Immediate
11542        );
11543        assert!(internal.prompt.contains("no chain-of-thought"));
11544    }
11545
11546    #[test]
11547    fn response_completion_contract_is_set_on_text_thinking_template() {
11548        let request = chat_request(json!({}));
11549        let template = ModelChatTemplate::new(
11550            "{% if add_generation_prompt %}<assistant><think>\n{% endif %}",
11551            "thinking-test-template",
11552        );
11553
11554        let internal =
11555            convert_chat_request_with_template_model(&request, "stub-model", Some(&template))
11556                .expect("convert thinking text request");
11557
11558        assert_eq!(
11559            internal.sampling_params.structured_output_start,
11560            StructuredOutputStart::Immediate
11561        );
11562        assert_eq!(
11563            internal.sampling_params.response_completion_boundary,
11564            ResponseCompletionBoundary::AfterDelimiterAndPayload {
11565                delimiter: THINK_END_TAG.to_string(),
11566                alternate_envelope: None,
11567            }
11568        );
11569    }
11570
11571    #[test]
11572    fn thinking_tool_request_compiles_typed_envelope_into_completion_contract() {
11573        let request = chat_request(json!({
11574            "tools": [{
11575                "type": "function",
11576                "function": {
11577                    "name": "weather",
11578                    "parameters": {
11579                        "type": "object",
11580                        "properties": {"city": {"type": "string"}},
11581                        "required": ["city"]
11582                    }
11583                }
11584            }]
11585        }));
11586        let template = ModelChatTemplate::new(
11587            "{% if tools %}<tool_call><function=name><parameter=key>value</parameter></function></tool_call>{% endif %}{% if add_generation_prompt %}<assistant><think>\n{% endif %}",
11588            "thinking-tool-template",
11589        );
11590
11591        let internal =
11592            convert_chat_request_with_template_model(&request, "stub-model", Some(&template))
11593                .expect("convert thinking tool request");
11594
11595        assert_eq!(
11596            internal.sampling_params.response_completion_boundary,
11597            ResponseCompletionBoundary::AfterDelimiterAndPayload {
11598                delimiter: THINK_END_TAG.to_string(),
11599                alternate_envelope: Some(ferrum_types::ResponseCompletionEnvelope {
11600                    open_token_text: "<tool_call>".to_string(),
11601                    close_token_text: "</tool_call>".to_string(),
11602                    max_envelopes: 32,
11603                }),
11604            }
11605        );
11606    }
11607
11608    #[test]
11609    fn strict_json_schema_response_format_uses_guided_sampling_mode() {
11610        let request = chat_request(json!({
11611            "response_format": {
11612                "type": "json_schema",
11613                "json_schema": {
11614                    "name": "answer",
11615                    "strict": true,
11616                    "schema": {
11617                        "type": "object",
11618                        "properties": {"answer": {"type": "string"}},
11619                        "required": ["answer"]
11620                    }
11621                }
11622            }
11623        }));
11624
11625        let internal = convert_chat_request(&request).expect("convert strict json_schema");
11626        assert!(
11627            internal
11628                .prompt
11629                .contains("response_format requires a single valid JSON value"),
11630            "response_format instruction should reach the model prompt: {}",
11631            internal.prompt
11632        );
11633        let ferrum_types::ResponseFormat::JsonSchema(schema) =
11634            internal.sampling_params.response_format
11635        else {
11636            panic!(
11637                "strict json_schema must reach guided decoding, got {:?}",
11638                internal.sampling_params.response_format
11639            );
11640        };
11641        let schema: serde_json::Value = serde_json::from_str(&schema).unwrap();
11642        assert_eq!(schema["type"], "object");
11643        assert_eq!(schema["properties"]["answer"]["type"], "string");
11644        assert_eq!(schema["required"], json!(["answer"]));
11645    }
11646
11647    #[tokio::test]
11648    async fn strict_json_schema_validates_non_streaming_response() {
11649        let request = chat_request(json!({
11650            "response_format": {
11651                "type": "json_schema",
11652                "json_schema": {
11653                    "name": "answer",
11654                    "strict": true,
11655                    "schema": {
11656                        "type": "object",
11657                        "properties": {"answer": {"type": "string"}},
11658                        "required": ["answer"]
11659                    }
11660                }
11661            }
11662        }));
11663        let response = chat_completions_handler(
11664            State(state_with_stub("{\"answer\":\"yes\"}")),
11665            HeaderMap::new(),
11666            Ok(Json(request)),
11667        )
11668        .await
11669        .expect("strict response");
11670        assert_eq!(response.status(), AxumStatusCode::OK);
11671        let body = response_json(response).await;
11672        assert_eq!(
11673            body["choices"][0]["message"]["content"],
11674            "{\"answer\":\"yes\"}"
11675        );
11676    }
11677
11678    #[tokio::test]
11679    async fn strict_json_schema_validates_non_streaming_response_after_reasoning_block() {
11680        let request = chat_request(json!({
11681            "response_format": {
11682                "type": "json_schema",
11683                "json_schema": {
11684                    "name": "answer",
11685                    "strict": true,
11686                    "schema": {
11687                        "type": "object",
11688                        "properties": {"answer": {"type": "string"}},
11689                        "required": ["answer"]
11690                    }
11691                }
11692            }
11693        }));
11694        let response = chat_completions_handler(
11695            State(state_with_stub(
11696                "<think>\nreasoning\n</think>\n\n{\"answer\":\"yes\"}",
11697            )),
11698            HeaderMap::new(),
11699            Ok(Json(request)),
11700        )
11701        .await
11702        .expect("strict response with reasoning");
11703        assert_eq!(response.status(), AxumStatusCode::OK);
11704        let body = response_json(response).await;
11705        assert_eq!(
11706            body["choices"][0]["message"]["content"],
11707            "{\"answer\":\"yes\"}"
11708        );
11709        assert_eq!(body["choices"][0]["message"]["reasoning"], "\nreasoning\n");
11710    }
11711
11712    #[tokio::test]
11713    async fn strict_json_schema_validates_streaming_final_response() {
11714        let response = post_json(
11715            router_with_stub("{\"answer\":\"yes\"}"),
11716            "/v1/chat/completions",
11717            json!({
11718                "model": "stub-model",
11719                "messages": [{"role": "user", "content": "Return an answer object."}],
11720                "stream": true,
11721                "response_format": {
11722                    "type": "json_schema",
11723                    "json_schema": {
11724                        "name": "answer",
11725                        "strict": true,
11726                        "schema": {
11727                            "type": "object",
11728                            "properties": {"answer": {"type": "string"}},
11729                            "required": ["answer"]
11730                        }
11731                    }
11732                }
11733            }),
11734        )
11735        .await;
11736        assert_eq!(response.status(), AxumStatusCode::OK);
11737        let body = response_text(response).await;
11738        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
11739        assert!(
11740            body.contains("\\\"answer\\\":\\\"yes\\\""),
11741            "strict streaming content missing: {body}"
11742        );
11743        assert!(
11744            !body.contains("\"error\""),
11745            "valid strict streaming response should not emit error: {body}"
11746        );
11747    }
11748
11749    #[tokio::test]
11750    async fn strict_json_schema_validates_streaming_final_response_after_reasoning_block() {
11751        let response = post_json(
11752            router_with_stub_stream_chunks(&[
11753                "<think>\nreasoning",
11754                "\n</think>\n\n",
11755                "{\"answer\":\"yes\"}",
11756            ]),
11757            "/v1/chat/completions",
11758            json!({
11759                "model": "stub-model",
11760                "messages": [{"role": "user", "content": "Return an answer object."}],
11761                "stream": true,
11762                "response_format": {
11763                    "type": "json_schema",
11764                    "json_schema": {
11765                        "name": "answer",
11766                        "strict": true,
11767                        "schema": {
11768                            "type": "object",
11769                            "properties": {"answer": {"type": "string"}},
11770                            "required": ["answer"]
11771                        }
11772                    }
11773                }
11774            }),
11775        )
11776        .await;
11777        assert_eq!(response.status(), AxumStatusCode::OK);
11778        let body = response_text(response).await;
11779        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
11780        assert!(
11781            body.contains("\\\"answer\\\":\\\"yes\\\""),
11782            "strict streaming content missing: {body}"
11783        );
11784        assert!(
11785            body.contains(r#""reasoning":"\nreasoning\n""#),
11786            "strict streaming should keep reasoning separate: {body}"
11787        );
11788        assert!(
11789            !body.contains("\"error\""),
11790            "valid strict streaming response should not emit error: {body}"
11791        );
11792    }
11793
11794    #[tokio::test]
11795    async fn strict_json_schema_invalid_streaming_output_emits_error_event() {
11796        let response = post_json(
11797            router_with_stub("not json"),
11798            "/v1/chat/completions",
11799            json!({
11800                "model": "stub-model",
11801                "messages": [{"role": "user", "content": "Return an answer object."}],
11802                "stream": true,
11803                "response_format": {
11804                    "type": "json_schema",
11805                    "json_schema": {
11806                        "name": "answer",
11807                        "strict": true,
11808                        "schema": {
11809                            "type": "object",
11810                            "properties": {"answer": {"type": "string"}},
11811                            "required": ["answer"]
11812                        }
11813                    }
11814                }
11815            }),
11816        )
11817        .await;
11818        assert_eq!(response.status(), AxumStatusCode::OK);
11819        let body = response_text(response).await;
11820        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
11821        assert!(
11822            body.contains("\"type\":\"internal_server_error\""),
11823            "strict streaming validation failure should emit OpenAI error: {body}"
11824        );
11825        assert!(
11826            body.contains("\"param\":\"response_format.json_schema\""),
11827            "strict streaming validation error should identify schema param: {body}"
11828        );
11829        assert!(
11830            body.contains("invalid JSON"),
11831            "strict streaming validation should report invalid JSON: {body}"
11832        );
11833        assert!(
11834            !body.contains("not json"),
11835            "strict streaming must not emit invalid partial deltas before validation failure: {body}"
11836        );
11837    }
11838
11839    #[tokio::test]
11840    async fn route_strict_json_schema_supported_schema_passes_100_runs() {
11841        let request_body = json!({
11842            "model": "stub-model",
11843            "messages": [{"role": "user", "content": "Return an answer object."}],
11844            "response_format": {
11845                "type": "json_schema",
11846                "json_schema": {
11847                    "name": "answer",
11848                    "strict": true,
11849                    "schema": {
11850                        "type": "object",
11851                        "properties": {"answer": {"type": "string"}},
11852                        "required": ["answer"]
11853                    }
11854                }
11855            }
11856        });
11857        let router = router_with_stub("{\"answer\":\"yes\"}");
11858        for run in 0..100 {
11859            let response =
11860                post_json(router.clone(), "/v1/chat/completions", request_body.clone()).await;
11861            assert_eq!(
11862                response.status(),
11863                AxumStatusCode::OK,
11864                "strict schema run {run} returned non-200"
11865            );
11866            let body = response_json(response).await;
11867            let content = body["choices"][0]["message"]["content"]
11868                .as_str()
11869                .unwrap_or("");
11870            assert_eq!(
11871                content, "{\"answer\":\"yes\"}",
11872                "strict schema run {run} returned unexpected content"
11873            );
11874            let parsed: serde_json::Value =
11875                serde_json::from_str(content).expect("strict content JSON");
11876            assert_eq!(parsed["answer"], "yes");
11877        }
11878    }
11879
11880    #[test]
11881    fn cache_metrics_use_engine_real_kv_snapshot_when_available() {
11882        let cache = CacheRuntimeState::default();
11883        let policy = CachePolicy {
11884            prefix_cache_enabled: true,
11885            session_cache_mode: "memory".to_string(),
11886            session_cache_max_entries: 128,
11887            session_cache_max_tokens: 4096,
11888        };
11889        cache.record_prefix_prompt("alpha beta gamma", &policy);
11890        cache.record_prefix_prompt("alpha beta delta", &policy);
11891
11892        let engine_snapshot = json!({
11893            "position": "real-kv-reuse",
11894            "source": "llama-family-paged-block-prefix-cache",
11895            "enabled": true,
11896            "hits": 7,
11897            "misses": 3,
11898            "evictions": 1,
11899            "saved_prefill_tokens": 64,
11900            "entries": 5,
11901            "bytes": 8192,
11902            "block_size": 16,
11903            "kv_dtype": "fp16",
11904            "selected_pipeline_mode": "batch",
11905            "selected_stage_bridge": "host",
11906            "stage_count": 2,
11907        });
11908
11909        let health = cache.health_json(&policy, Some(&engine_snapshot));
11910        let prefix = &health["prefix_cache"];
11911        assert_eq!(prefix["position"], "real-kv-reuse");
11912        assert_eq!(prefix["source"], "llama-family-paged-block-prefix-cache");
11913        assert_eq!(prefix["hits"], 7);
11914        assert_eq!(prefix["misses"], 3);
11915        assert_eq!(prefix["evictions"], 1);
11916        assert_eq!(prefix["saved_prefill_tokens"], 64);
11917        assert_eq!(prefix["entries"], 5);
11918        assert_eq!(prefix["bytes"], 8192);
11919        assert_eq!(prefix["block_size"], 16);
11920        assert_eq!(prefix["kv_dtype"], "fp16");
11921        assert_eq!(prefix["selected_pipeline_mode"], "batch");
11922        assert_eq!(prefix["selected_stage_bridge"], "host");
11923        assert_eq!(prefix["stage_count"], 2);
11924
11925        let metrics = cache.prometheus_metrics(Some(&engine_snapshot));
11926        assert!(metrics.contains("ferrum_prefix_cache_hits_total 7\n"));
11927        assert!(metrics.contains("ferrum_prefix_cache_misses_total 3\n"));
11928        assert!(metrics.contains("ferrum_prefix_cache_saved_prefill_tokens_total 64\n"));
11929        assert!(metrics.contains("ferrum_prefix_cache_entries 5\n"));
11930        assert!(metrics.contains("ferrum_prefix_cache_bytes 8192\n"));
11931    }
11932
11933    #[tokio::test]
11934    async fn strict_json_schema_invalid_model_output_fails_before_response() {
11935        let request = chat_request(json!({
11936            "response_format": {
11937                "type": "json_schema",
11938                "json_schema": {
11939                    "name": "answer",
11940                    "strict": true,
11941                    "schema": {
11942                        "type": "object",
11943                        "properties": {"answer": {"type": "string"}},
11944                        "required": ["answer"]
11945                    }
11946                }
11947            }
11948        }));
11949        let err = chat_completions_handler(
11950            State(state_with_stub("not json")),
11951            HeaderMap::new(),
11952            Ok(Json(request)),
11953        )
11954        .await
11955        .expect_err("invalid strict response should fail");
11956        let (status, body) = error_json(err).await;
11957        assert_eq!(status, AxumStatusCode::INTERNAL_SERVER_ERROR);
11958        assert_eq!(body["error"]["type"], "internal_server_error");
11959        assert!(body["error"]["message"]
11960            .as_str()
11961            .unwrap()
11962            .contains("json_schema.strict"));
11963    }
11964
11965    #[tokio::test]
11966    async fn strict_json_schema_does_not_rely_on_markdown_fence_stripping() {
11967        let request = chat_request(json!({
11968            "response_format": {
11969                "type": "json_schema",
11970                "json_schema": {
11971                    "name": "answer",
11972                    "strict": true,
11973                    "schema": {
11974                        "type": "object",
11975                        "properties": {"answer": {"type": "string"}},
11976                        "required": ["answer"]
11977                    }
11978                }
11979            }
11980        }));
11981        let err = chat_completions_handler(
11982            State(state_with_stub("```json\n{\"answer\":\"yes\"}\n```")),
11983            HeaderMap::new(),
11984            Ok(Json(request)),
11985        )
11986        .await
11987        .expect_err("strict schema should fail fenced JSON instead of repairing it");
11988        let (status, body) = error_json(err).await;
11989        assert_eq!(status, AxumStatusCode::INTERNAL_SERVER_ERROR);
11990        assert_eq!(body["error"]["type"], "internal_server_error");
11991        assert!(body["error"]["message"]
11992            .as_str()
11993            .unwrap()
11994            .contains("json_schema.strict: invalid JSON"));
11995    }
11996}